面试常考问题

[[八股/_resources/我的面试总结(匡)/40baf170f1a16c04bb7eda0d3ea69aef_MD5.jpeg|Open: 67a1a3759c89bb4d37a9f6bc9a2ee5f4.png]]
![[八股/_resources/我的面试总结(匡)/40baf170f1a16c04bb7eda0d3ea69aef_MD5.jpeg]]
[[八股/_resources/我的面试总结(匡)/ba3820ba6a23d2ca85dcb6c56ba0736b_MD5.jpeg|Open: 722c3bafc88040a0e485cfa5a213d469.png]]
![[八股/_resources/我的面试总结(匡)/ba3820ba6a23d2ca85dcb6c56ba0736b_MD5.jpeg]]
style标签中的scope是如何实现的,各种宏(比如defineProps)是如何实现的

双向绑定又是什么

MVVM 模型 MVP MVC这些有了解过吗

目录

  • [[#vue2和vue3的区别|vue2和vue3的区别]]
  • [[#proxy 与 defineProperty的对比|proxy 与 defineProperty的对比]]
  • [[#createWebHistory() 和 createWebHashHistory()区别|createWebHistory() 和 createWebHashHistory()区别]]
  • [[#什么是SEO|什么是SEO]]
  • [[#什么是SSR|什么是SSR]]
  • [[#怎么看待无虚拟dom(Vapor Mode)趋势|怎么看待无虚拟dom(Vapor Mode)趋势]]
  • [[#怎么看待无虚拟dom(Vapor Mode)趋势#虚拟DOM最大的问题是什么, 为什么会有无VDOM趋势|虚拟DOM最大的问题是什么, 为什么会有无VDOM趋势]]
  • [[#Ajax是什么|Ajax是什么]]
  • [[#Ajax是什么#ajax, axios, fetch的区别|ajax, axios, fetch的区别]]

vue2和vue3的区别

总结: vue3在保持vue2 使用习惯的前提下, 用ts和proxy重写了底层实现, 整体性能和可维护性更好

  1. 写法上: 在 Vue2 中,组件必须有一个根标签。而 Vue3 中,没有根标签,默认将多个根标签包裹在一个 Fragment 虚拟标签中,从而减少了内存占用。

  2. 语法上: - Vue2 采用 选项式 API (Options API),这种方式的好处是代码结构清晰,但缺点是同一业务逻辑的代码可能会被分散到不同的选项中,后期维护时不便于复用。

  • Vue3 引入了 组合式 API (Composition API),可以把同一业务逻辑封装成 Hooks,使得逻辑复用性和模块化更强,对 TypeScript 支持更好。此外,Vue3 依然支持 Options API,兼容性更强。
    • Vue2 使用 Object.defineProperty 和依赖收集,但这种方式不能直接监听属性的新增和删除,且对深层对象需要递归处理,维护成本较高。
  • Vue3 使用 Proxy,能够拦截对象的各种操作(如属性的变化、数组下标、Map 和 Set 等),大大提升了性能和灵活性,尤其对于复杂数据结构的支持更好。
  1. 性能和体积方面: - Vue3 在编译阶段就对静态内容做了标记和提升,配合 Proxy 响应式系统,使得整体更新开销更小,性能相比 Vue2 有显著提升。
  • Vue3 支持 Tree-shaking,只会打包用到的 API,因此最终的体积更小。
  1. ts支持: vue3从底层支持ts重写, 对ts支持非常好, 能够提供更强的类型推导, 减少开发中数据类型的错误

  2. 其他: vue2使用vue-router3, vue3对应 vue-router4(常用的 API包括createRouter, createWebHistory), vue3提供了<script setup>语法糖

proxy 与 defineProperty的对比

一句话总结:
相比 Object.defineProperty, proxy能拦截的操作更多, 对数组和 Map/Set等复杂类型支持更好, 并且不需要在初始化时递归遍历所有属性, 性能和可维护性更好, 所以Vue3被换成了Proxy做响应式

defineProperty缺点
defineProperty的问题在于: 监听不全面(新增/删除, 数组小标变化), 初始化要深度遍历, 复杂结垢支持不好

proxy解决的问题/优点
而proxy可以一次性拦截整个对象的各种操作, 天然支持数组和 Map/Set, 还能按需递归子对象, 整体上高效, 更好用. Vue2需要重写 push/pop 等7个数组方法, Proxy天然支持. 所以vue3用proxy重写响应式系统, 是为了解决vue2的历史坑, 有利于后续的拓展

createWebHistory() 和 createWebHashHistory()区别

“在 vue-router4 里,createWebHistory 用的是 HTML5 的 history 模式,URL 是 /user/list 这种正常路径,需要后端配合做好历史路由的兜底,要能把 index.html 返回给前端,否则用户一刷新就 404。优点是地址干净、规范。更利于后续做 SEO 或 SSR

createWebHashHistory 是用 hash 模式,URL 会变成 /#/user/list,不需要服务端额外配置,刷新也不会 404,但地址会多一个 #,看起来不太优雅。 在一些场景下对 SEO 也没那么友好

一般来说,有服务端配合的新项目我会优先用 createWebHistory,如果只是静态托管或者无法改后端配置,就用 createWebHashHistory,更省事。”

什么是SEO

SEO= Search Engine Optimization, 搜索引擎优化

SEO 就是让网站更容易被搜索引擎收录、排在更靠前,从而获得更多自然流量的一系列优化手段。

做 SEO,一般会关注:

  • 页面有没有语义化的 HTML 结构h1~h6, a, article, section 等)

  • 标题和描述有没有写好(<title>meta description

  • 重要内容是不是直接出现在 HTML 里(而不是全靠前端 JS 渲染)

  • 页面访问是否够快(性能),是否适配移动端

  • URL 是否规范、可读,例如 /user/list/page?id=1&tab=2 更友好

为什么 SPA(Vue / React)对 SEO 不友好?SSR 怎么解决

SPA通常只返回一个 空的HTML, 页面中内容通过 JS 在浏览器运行后生成, 而搜索引擎爬虫主要通过解析 HTML 获取页面, 因此可能无法正确抓取 SPA 页面内容. SSR 通过在服务器端提前渲染出完整HTML, 让搜索引擎爬虫直接获取页面内容, 提高SEO效果

什么是SSR

SSR = Server-Side Rendering,服务端渲染。
SSR 就是把原来在浏览器里用 JS 渲染页面这件事,提前放到服务器上做,直接给浏览器返回一份已经带好内容的 HTML。”

SSR(服务端渲染)

  • 用户请求页面时,服务器在 Node 环境里先跑一遍 Vue/React

  • 把最终渲染好的 HTML 字符串返回给浏览器

  • 浏览器一打开就有完整内容(首屏快 + SEO 友好),再在前端“激活”(hydrate),变成可交互的 SPA

  • Vue 官方有 Vue SSR 方案

  • 也可以用 Nuxt.js(现在叫 Nuxt),是基于 Vue 的 SSR/同构框架

说说Vue的虚拟DOM, Diff策略 和 更新粒度

虚拟DOM
虚拟DOM是一个用JS对象模拟真实DOM的中间层, 因为操作JS对象比操作真实DOM快得多. 虚拟DOM作为中间层, 将多次数据变动积累的DOM操作合并为一次批量处理, 减少直接操作 DOM 的次数, 提升性能

Diff策略
当数据变化生成新VNode树时, Vue会将新旧两棵树进行对比

  • 同层比较: 只对同一层级 的节点进行比较, 不跨层级
  • 双端对比: 比较新旧节点的首尾两端, 快速判断
  • Patch’ Flags: 在编译时 分析出动态节点, 并为其打上类型标签. Diff时直接根据这些标记定位到需要更新的节点, 跳过静态内容对比

更新粒度
Vue通过控制更新范围来提升性能

  • 组件级更新: Vue的响应式依赖收集以组件为单位. 当一个组件的数据变化时, 默认会 触发该组件及其子组件的重新渲染
  • BlockTree: 编译时将动态节点收集到一个 “块” (Block)中. 更新时直接对比 Block 内的动态节点, 跳过了静态子树和兄弟节点的对比, 将更新粒度从”组件级” 优化到”动态节点级”

怎么看待无虚拟dom(Vapor Mode)趋势

我自己在实现 Vue 的基础过程中, 对 Vue 的虚拟DOM有一定的理解.
虚拟DOM主要有两个作用:

  • 一个是提供跨平台抽象,
  • 一个是通过diff实现声明式 UI 的最小化更新

近几年无虚拟DOM的趋势, 本质上是随着编译能力和响应式系统的增强, 把原本需要*运行时 diff 的逻辑前置到编译时 , 通过静态分析或者依赖追踪实现精确更新, 因此具备更高的性能, 包体积更小, 运行时更轻量*
Vue3 现在也有这个趋势, 比如 block treepatch flag 已经把虚拟 DOM diff 优化成了半编译时代码
但是, 虚拟DOM 跨平台抽象, 动态结构更新, 大型生态方面仍然具备不可替代的优势 ,
所以就我个人来看, 无虚拟DOM更适合高性能场景, 虚拟DOM 更适合复杂UI和需要跨平台的生态

Vapor Mode 的更新流程

  • 编译阶段:编译器分析模板,找到静态的部分

  • 生成代码:编译器直接生成类似的代码

1
2
3
4
5
6
7
8
// 初始化
const el = document.createElement('span')

// 绑定更新 (Effect)
watchEffect(() => {
// 核心:直接操作 DOM,没有 VNode,没有 Patch
el.textContent = count.value
})
  • 运行时响应:数据 count 更新,直接触发 watchEffect,执行 el. TextContent = …

将中间的 vnode 创建,diff,patch 全部砍掉

虚拟DOM最大的问题是什么, 为什么会有无VDOM趋势

虚拟DOM的问题不是慢, 而是 “不够确定”
他在运行时必须遍历树, diff, 决定更新路径, 这使得性能优化带有随机型, 而不能利用编译时的信息

趋势转向无VDOM的原因是:

  • 现代框架的编译器非常强大
  • 响应式系统可以精确追踪依赖
  • 运行时diff价值下降, 代价上升
    现代框架追求的不是减少diff, 而是根本不diff

Ajax是什么

Ajax 指的是在浏览器中通过 javaScript 异步向服务器发送请求, 并在不刷新整个页面的情况下更新部分页面内容的一种技术方式, 早期主要通过 XMLHttpRequest 实现
他与传统的表单提交和整页刷新不同, 他是 JS 在后台发送请求, 页面不跳转, 不刷新, 只更新局部DOM
现在我们用的浏览器提供的 fetch或者三方库axios来发送请求, 这些本质上仍然是在做 Ajax异步请求

ajax, axios, fetch的区别

Ajax不是一个具体的 API, 而是一种在不刷新页面的情况下, 用JS异步请求服务器 并更新页面的 技术方式
早期我们用XMLHttpRequest 来实现 Ajax, 现在浏览器提供了更现代的 fetch, 而 axios则是在
XHR/ HTTP 之上做了一层更工程化的封装

相比原生的 fetch, axios请求提供了 请求 / 响应 拦截器, 统一的全局配置, 自动的 JSON处理, 超时 和 取消请求等能力, 我平常写项目用这个最多[[axios执行流程]]

fetch的优点是零依赖, 在简单的场景用的比较多

所以, Ajax是概念, fetch是原生 API, axios是基于 XHR/ fetch 封装的库

ajax
Ajax 的本质是 用Javascript 在页面里发送异步请求(不用刷新整页), 拿到数据后只更新部分 DOM

fetch
fetch 是浏览器提供的 原生现代请求API
缺点:

  • 只有网络错误才会catch, 4xx/5xx 需要自己判断 res.ok
  • 没有内置超时, 要自己用AbortController + setTimeout 封装
  • 没有请求 / 响应拦截器, 要自己写一层 wrapper

axios
axios是一个第三方 HTTP请求库
优点

  • 拦截器 : 请求拦截器(统一加token, traceId), 响应拦截器: 统一处理错误码, 登录过期, 全局提示
  • 错误处理更友好: 4xx/5xx会直接跑错, 可以在一个地方统一处理
  • 自动JSON处理: 自动把JSON字符串解析成对象
  • 全局配置: baseURL, timeout, 默认headers等, 统一一个实例配置
  • 超时/ 取消请求: 内置timeout

“双向记忆(dep ↔ effect)”,能解释一下这个设计是什么意思吗?为什么需要双向记录?

dep 负责触发(trigger), effect负责清理(cleanup / stop)

dep 和 effect 各自存什么, 为什么要双向

  • dep(依赖桶): 从”响应式字段出发, 记录”有哪些effect依赖我”
    • 好处: 让 setter/trigger 能快速找到需要重新执行的副作用(更新派发快, 而不是全局扫描
  • effect(副作用): 从”这段逻辑/渲染函数”出发, 记录”我依赖了哪些 dep”
    • 好处:
    • cleanup: 下次执行前先把自己从旧 dep 里删掉, 再重新收集
    • stop/unmount: 组件写在或stop(effect)时, 能直接遍历 effect.deps, 把自己从所有 dep解绑, 不需要全局找

举例

1
2
3
4
5
6
7
effect(() => {
if (state.ok) {
console.log(state.a)
} else {
console.log(state.b)
}
})
  • 第一次 ok=true:收集到 a
  • 后来 ok=false:这次执行其实只该依赖 b
  • 如果不做 cleanup(或者 effect 不记得自己依赖过哪些 dep),effect 会一直“同时订阅 a 和 b”
    • 结果:改 a 也会触发 effect(多余更新
    • 甚至越来越多依赖残留(内存/订阅泄漏风险

所以双向记忆的目的之一就是:让依赖集合随着每次执行动态变化,始终保持正确

JS中的计时器精确吗, 为什么

硬件层面: 不能实现, 目前只有原子钟(通过核共实现)
系统: 操作系统
我们在实现定时器时, 一般是采用setTimeout, setInterval交给浏览器运行, 然后浏览器交给操作系统来实现. 但是操作系统的计时本身就是不精确的, 而且不同操作系统的实践方式不一样, 可能会进一步扩大差异
标准层面: web标准规定, 当setTimeout 的嵌套大于等于5层时, 会有至少4毫秒的延迟
事件循环: 函数只有在执行栈清空后才会执行里面的回调函数

后端状态码

Http状态码主要分为 5 大类, 他是我们前端和后端沟通请求结果的一种约定

1
2
3
4
5
1开头: 信息提示, 平时开发见的比较少
2开头: 成功
3开头: 重定向
4开头: 客户端错误(也就是我们前端传参或者权限问题)
5开头: 服务端错误

在日常开发中, 我经常遇到的有
成功:

  • 最常见的时200, 表示请求成功拿到数据
  • 如果是做新建表单(比如用户注册), 后端可能会返回201
    客户端错误
  • 400: 通常是我参数传错了, 或者JOSON格式不对
  • 401: 未认证, 比如Token过期或没登陆, 这个时候需要把用户重定向到登录页
  • 403: 无权限
  • 404: 可能是接口路径拼错了
    服务端错误
  • 500: 后端代码报错了
  • 502: 通常是服务器压力太大了, 超时了

JS的类型转换

第一部分:三大基本转换逻辑 (地基)

在处理复杂表达式之前,你必须先记准单个值是如何被转换的。

1. 转布尔值 (ToBoolean) —— 最简单,必须背过

除了以下 6 个假值 (Falsy values),其他所有值在转换时全为 true

  1. false

  2. 0 (包括 -0, 0n)

  3. "" (空字符串)

  4. null

  5. undefined

  6. NaN

💣 笔试坑点:

  • [] (空数组) 是 true

  • {} (空对象) 是 true

  • "0" (字符串0) 是 true

2. 转数字 (ToNumber) —— 最琐碎

当进行数学运算(除了 + 拼接)或比较时触发。

  • true -> 1

  • false -> 0

  • null -> 0 (⚠️ 注意这里)

  • undefined -> NaN (⚠️ 必须区分 null 和 undefined)

  • "" (空串) -> 0

  • "123" -> 123

  • "123a" -> NaN

3. 转字符串 (ToString)

  • null -> "null"

  • undefined -> "undefined"

  • true -> "true"

  • 数字按原样转。

  • 对象/数组:看后面的“引用类型转换规则”。


第二部分:引用类型(对象/数组)如何转为原始值?

当对象(如 [1,2]{a:1})参与运算时,JS 内部会调用 ToPrimitive 流程。

流程如下(简化版):

  1. 先调用 valueOf()。如果得到原始值,就用它。(通常只有包装对象 new Number(1) 才有用的 valueOf,普通对象返回自己,忽略)。

  2. 如果 valueOf 没用,调用 toString()

记准常见对象的 toString 结果:

  • 数组:将元素用逗号连接。

    • [].toString() -> "" (空串)

    • [1, 2].toString() -> "1,2"

  • 普通对象:永远是那句废话。

    • {}.toString() -> "[object Object]"

第三部分:两大核心运算符规则 (核心考点)

场景 A:加号运算符 (+)

规则优先级:

  1. 看两边: 只要有一个是字符串 -> 全部转字符串拼接

  2. 否则: 全部转数字相加。

经典题目:

1
2
3
4
5
6
1 + "1"      // "11" (有串,拼接)
1 + true // 2 (无串,true变1,1+1=2)
1 + null // 1 (无串,null变0,1+0=1)
1 + undefined// NaN (无串,undefined变NaN,1+NaN=NaN)
[] + [] // "" (数组转串是"","" + "" = "")
[] + {} // "[object Object]" ("" + "[object Object]")

场景 B:宽松相等 (==)

这是笔试中最难的。永远不要在实际开发中用 ==**永远不要在实际开发中用 ==`,但笔试必须会。

规则优先级(按顺序判断):

  1. 类型相同:等同于 ===

  2. nullundefined:它俩做朋友,null == undefinedtrue,除此之外它俩不等于任何其他值(不等于0,不等于false,只等于彼此)。

  3. 字符串 vs 数字:字符串转数字。

  4. 布尔值 vs 任何值布尔值先转数字(这是最大的坑!)。

  5. 对象 vs 原始值:对象拆箱(转成原始值)。


第四部分:经典笔试题解析 (实战演练)

遇到这些题,按上面的规则一步步推导。

🔥 题目 1:[] == ![]

答案:true

推导过程:

  1. 右边有 ! (逻辑非),优先级高。

  2. [] 是真值,所以 ![] 变成了 false

    • 现在的式子:[] == false
  3. 一边是对象,一边是布尔?规则4:布尔值先转数字。

    • false -> 0

    • 现在的式子:[] == 0

  4. 对象 vs 数字?规则5:对象转原始值(调用toString)。

      • [] -> "" (空串)
    • 现在的式子:"" == 0“” == 0`
  5. 字符串 vs 数字?规则3:字符串转数字。

    • "" -> 0

    • 现在的式子:0 == 0

  6. 结果:true

🔥 题目 2:NaN 的比较

NaN == NaN // false

解释: NaN 是全宇宙唯一一个不等于自己的值。判断是否是 NaN 必须用 isNaN()Number.isNaN()

🔥 题目 3:null vs 0 的诡异关系

null > 0 // false null < 0 // false null == 0 // false null >= 0 // true (???)

**解释:解释:

  • 比较运算符 (>, <):会把 null 强制转为数字 00。所以 0 > 0 假,0 < 0` 假。

—– * 相等运算符 (==):走特殊规则,null 不进行数字转换,它只等于 undefinedundefined或自己。所以null == 0` 假。

  • >= 运算符:在 JS 里的逻辑是“不小于”。既然 null < 0false,那 null >=null >= 0就是true`。

🔥 题目 4:连续运算

1 + "2" - 1

推导:

  1. 从左到右。

  2. 1 + "2" -> "12" (加号遇串则串)

  3. "“12” - 1->11` (减号强转数字)

  4. 结果:11


第五部分:终极总结表 (考前看一眼)

场景 关键规则 (口诀) 例子
if(x) / !x 只有 6个假值,其余全真 if([]) 执行!
+ 有字符串就拼接,没有就加法 1+'1'='11', 1+true=2
-**- * /` 六亲不认,全部转数字 '10' -‘10’ - 2 = 8`
== 1. null==undefined
2. 布尔变数字
3. 对象变原始值
[]==![][]==![]` 为 true
对象转值 数组变字符串由逗号连,对象变 [object Object]

CSS盒模型

内容区域, 内边距, 边框, 外边框
[[八股/_resources/我的面试总结(匡)/3ebe0a9050c24056f51001d5c9767141_MD5.jpeg|Open: Pasted image 20260101185504.png]]
![[八股/_resources/我的面试总结(匡)/3ebe0a9050c24056f51001d5c9767141_MD5.jpeg]]

BFC

BFC 是一个独立的块级格式化上下文,内部布局不影响外部
常见触发方式有 overflow: hiddendisplay: flow-rootfloatposition: absolute
它主要用来解决三类问题:清除浮动、避免 margin 合并、避免被浮动覆盖

原型和原型链

函数有 prototype,对象有 __proto__
对象的 __proto__ 指向构造函数的 prototype
访问属性时会沿着 __proto__ 形成原型链向上查找,
直到 Object.prototype,最终到 null

过渡到ES6
js没有真正的类继承, 本质是对象通过 __proto__ 形成的原型链
ES6 的 class和extends 只是对原型链继承的语法封装
最终仍然是实例沿着 Child.prototype → Parent.prototype → Object.prototype查找属性

axios

1
2
3
4
5
6
7
8
9
10
11
config 合并

request 拦截器(倒序)

dispatchRequest

adapter(XHR / http)

response 拦截器(正序)

返回 Promise

Axios在发请求时会先合并配置, 然后把请求拦截器, 真正的请求逻辑和响应拦截器组合成一个 Promise
请求拦截器按后添加先执行(栈), 响应拦截器按先添加的先执行(队列), 发送请求后返回一个Promise给调用方

使用总线eventbus而不是watch的原因

痛点: 多个页面 / 组件分别watch同一份store字段, 每次交互会触发一串的watch.
造成重复计算和渲染, 并且修改的时序无法掌控, 难以维护

在使用watch监听时可能会导致其他数据与之相关联, 导致不必要的更新
使用eventBus替代总线监听后, 技能在第一时间更新数据增加体验, 还能减少其余不必要的数据更新

同步还是异步派发?如何抑制“10 连发”导致 UI 抖动

  • 派发:默认同步,但在总线上做“微队列整流”(microtask 或 rAF 批处理+去重)。
  • 去抖/合并策略:
    • 同 key(如 chatId)仅保留最后一条(coalesce)。
    • 在 store 内部“批更新”:一次事件里完成多个字段写入,减少渲染批次。
    • 统一在 nextTick/rAF 后再让 UI 读状态,避免重复读写交错。

EventBus 与 Pinia 如何协作

事件触发 → 调用 store action 完成状态变更 → 最后 emit 1~2 个“领域级”事件通知其它模块“已变更”(只做 UI 级同步或跨模块边界)。

  • 避免“store 更新→watch(再改 store)→emit”的环形链路;事件只做编排,不承载状态。

交互/系统输入 → store.action() 原子更新(列表/当前会话/未读/公告…)→ eventBus.emit('chat:changed' | 'group:renamed'| …,payload) → 订阅组件按需刷新(列表项、标题栏、横幅等)→ 组件卸载 onUnmounted 统一 off。

为什么要本地 DB + FTS

  • 体验:搜索和列表刷新“秒回”,离线也可查本地消息/联系人。

  • 负载:把高频搜索从服务端挪到本地,减轻接口压力。

  • 同步:收到新消息/改备注 → 更新本地表 → 同步更新 FTS 虚拟表,保证搜索结果即时可见。

    • 一致性与更新策略(简述)
      • 常用:先调后端成功,再更新本地表与 FTS,并发事件刷新 UI;失败则不改 UI(或回滚)。
      • 搜索优先走本地 FTS;需要跨端/更久历史时再走服务端补充。

联动更新策略

实现:偏保守(先写后显)
1. await 后端 API(api.updateFriendRemark)
2. 成功后局部更新 store(contacts、详情 shipInfo、chatList 中对应单聊会话名、currentChat.name)
3. emit 事件通知 UI(FRIEND_REMARK_UPDATED、CHAT_CHANGED)
4. 落地本地 DB(friendsMapper.updateById、chatsMapper.updateById + insertOrUpdateFTS)

  • 一致性保障:
    • API 失败:不更新 store,不发事件(返回错误给 UI)

截图绘制流程

• - 窗口与抓屏:用 Tauri 创建独立 WebView 截图窗(透明/可全屏)。Rust 侧通过 screenshots crate 按鼠标所在屏幕抓整屏,前端
拿到 base64 绘到底图 canvas

  • 多层渲染:三层 canvas 分工
    • imgCanvas:整屏底图(只初始化绘一次,按 DPR 放大保证清晰)
    • maskCanvas:半透明遮罩+选区镂空与尺寸文本,并承载鼠标事件(绘制/移动)
    • drawCanvas:用户标注(笔/矩形/圆/线/箭头/马赛克),pointer-events: none
  • 立即模式绘制:交互时在 drawCanvas 上“清屏→重放历史→绘制临时图形”,mouseup 存一帧 ImageData 做撤销/重做;画笔用二次贝
    塞尔平滑,马赛克基于底图局部 ImageData 做均值模糊。
  • 选区与移动:mousedown 命中选区则进入移动(记录鼠标相对选区偏移并 clamp 边界),否则开始拉框;maskCanvas 负责所有命中
    与光标样式。
  • 放大镜:独立小 canvas,按 DPR 从底图采样放大,跟随鼠标渲染;与工具栏置于最高层。
  • 导出与剪贴板:将标注合回底图后,使用离屏 offCanvas 按选区裁剪→生成 PNG(toBlob,失败则 dataURL 兜底)→通过 Tauri 插件
    写系统剪贴板;为 Windows 1418 做聚焦+短延时+重试以稳写入。
  • 快捷键与关闭:注册全局/页面快捷键(如 Esc)关闭窗口;导出成功后主动 close,单例创建避免多窗叠加。
  • DPI/性能:按 devicePixelRatio 设置实际像素尺寸;历史快照限制栈深;必要时可做脏矩形与 offscreen 合成优化(当前场景
    以“即用即走”为主未引入保留模式)。
  • IPC 安全:前端仅发起命令,系统能力在 Rust 层白名单暴露(截图、剪贴板、FS/HTTP),降低渲染进程权限面。

原有思路
每次绘制都直接在画布上落笔, 完成后在历史栈中保存一次画面
画布画面本身即为最终结果, 没有图形级数据结构, 因而已画出的图形无法再选中或修改, 只能通过撤销重来

改为可拖动的思路
*在学习了图标库的设计思路后, * , 我把每次绘制记录为结构化的shape(类型, 坐标/控制点, 样式等)存进数组, 由 repaint()根据数组统一渲染; 画布不在作为唯一状态

添加了命中检测: 根据图形的位置, 层叠情况, 判断鼠标是否选中某个shape; 选中后在mousemove中更新坐标, 再整体重绘, 形成拖动效果

撤销/重做 改为 对shape数组的改变做记录, 保证移动, 缩放等后续操作也能回退

手写Promise

一个构造函数+新建resolve和reject函数+接受函数并执行
私有变量: 状态和结果, 改变代码再封装

思路:
promise里有一个构造函数, 构造函数会接受两个参数resolve和reject
这两个参数不能直接写在实例上, 否则this的指向将不正确. 如果采用bind, 也会创建一个新的函数, 为了方便, 这里我们直接创建两个新的函数

promise里有两个私有变量 #state和#result, 初始时状态时pending, 结果为undefined
执行完resolve的状态为fulfilled, 结果为传入的data
执行reject的状态为rejected, 结果为reason

这里还有一个需要注意的点, 一旦状态发生了改变, 此后将不能再修改状态
所以我们需要再加一个判断
为了代码的简洁, 可以再封装一个函数changestate
把逻辑都写在这个函数里, resolve和reject只需要简单的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class myPromise {
#state = 'pending'
#result = undefined
constructor(excutor) {
const resolve = (data) => {
this.#changeState('fulfilled', data)
}
const reject = (reason) => {
this.#changeState('rejected', reason)
}
try {
excutor(resolve, reject)
} catch (error) {
reject(error)
}
}

#changeState(state, result) {
if (this.#state !== 'pending') return
this.#state = state
this.#result = result
}
}

手写Promise.all

  • 建壳子 (return new Promise)

  • 定位置 (forEach 里的 index)

  • 包一层 (Promise.resolve)

  • 数个数数个数** (count++)

  • 一错全挂 (reject)

思路:
Promise.all 接受一个可迭代对象, 返回一个新的 Promise
为了方便再外层访问resolve和reject函数, 可以在外面保存一下

我们并不知道谁先执行外任务, 所以需要变量来记录状态
对于可迭代对象, 我一般用for of 来遍历
因为任务执行时需要放在微任务队列中执行, 此时for循环早已执行完毕, 不能用length记录顺序, 我用一个index先保存一下执行顺序

成功执行的就放在result里, 失败的久直接打回(一票否决)
记录一下成功的数量, 只有当他全部成功, 才执行resolve函数

对于空的迭代对象需要特殊处理, 当长度为0时, 执行resolve([])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Promise.myAll = function(proms) {
let res, rej
const p = new Promise((resolve, reject) => {
res = resolve
rej = reject
})

let length = 0
let fulfilled = 0
let result = []

for (const pro of proms) {
const index = length
length++

Promise.resolve(pro).then( (data) => {
result[index] = data
fulfilled++
if (fulfilled === length) {
res(result)
}
}, rej)
}

if (length === 0) {
res([])
}
return p
}

Promise.myAll([1, 2, 3]).then(res => {
console.log(res)
}, err => {
console.log('error')
})

单仓库和多仓库的区别

Monorepo(单仓库)

最大优势: 原子性提交
场景: 我修改了一个ui-components 里的一个 Button 组件,同时需要修改使用了这个 Button 的 App-AApp-B
Monorepo: 我可以在一次commit中同时修改者三个地方的代码. CI会保证他们作为一个整体要么都通过, 要么都失败. 不会出现”组件发版了, 但App还没来得及升级”的中间状态

Polyrepo / Multirepo (多仓库)

最大优势: 物理隔离

    • 场景: 团队 A 负责订单系统,团队 B 负责用户系统。

      • Polyrepo: 两个仓库完全独立,CI 流程独立,权限独立。团队 A 无论怎么乱改代码,只要不发版,绝不会影响到团队 B。
  • 致命痛点 (Dependency Hell):

    • 如果你有一个公共库 shared-utils

    • 你需要:修改代码 -> npm publish (v1.0.1) -> 去 App A npm install -> 去 App B npm install

    • 如果发现 v1.0.1 有 Bug?这一套流程要再走一遍,非常折磨人。

双仓库

业界没有标准的“双仓库”定义,但这通常指按领域划分的 Monorepo。这是很多中大型公司在实践中摸索出的最佳实践

前端一个仓库, 后端一个仓库

为什么不合并成一个更大的Monorepo
前端和后端的构建工具链完全不同, 放在一次没办法共享配置, 会增加CI复杂度
可能会出现前端误改后端代码的情况, 增加维护成本

git操作

找到想要的那个版本的版本号后进行回退
$ git reset –hard f8dce4b(版本号)

merge时出现冲突

  1. 1合并分支语句

$ git merge 分支名

2.2 在写合并分支语句之前把要合并的分支的内容pull下来,如将主分支pull下来

1
git pull origin master
  • pull 会出现冲突,pull下来的版本比本地版本更新,可以用git status 看存在哪些冲突,打开文件进行修改,如果要保存原来版本,可在修改前用下列语句存储代码:

    1. 保存
1
$ git stash save "...."
2.可用下列语句恢复
1
git stash apply 
  • 和分支进行合并的时候(git merge 分支名),会出现冲突,同样用git status进行查看冲突,或者运行代码看报错情况,对冲突进行修改。

Vite比webpack更快的原因

冷启动热更新底层工具 三个方面展开:

  1. 冷启动速度:
    webpack: 是全量打包, 它必须从入口文件开始, 递归解析整个依赖树, 把所有文件编译打包完, 服务器才能启动. 项目越大, 启动越慢
    vite: 是按需编译. 他利用浏览器原生支持 ES Modules 的特性, 一开始不编译代码, 直接启动服务器. 只有当浏览器请求某个页面时, vite才去编译那个页面需要的文件, 没访问的页面根本不编译

  2. 热更新速度
    webpack: 修改文件后, 需要重新构建包含该模块的Bundle, 随着项目体积变大, 延迟也会更高
    vite: 修改完文件后, 只需要让浏览器重新请求这一个被修改的ESM模块即可, 浏览器会自动替换. 无论项目多大, 热更新速度机会是恒定的

  3. 底层编译工具
    webpack: 内部基于Node.js运行, js本身就是单线程解释型语言, 运行较慢
    vite: 在开发环境的依赖预构建阶段, 使用了Esbuild. Esbuild使用Go 写的, 编译速度比JS为基础的打包器快10-100倍

注意
不过, vite的快主要体现在开发环境上
在生产环境构建时, vite并没有使用 Esbuild, 而是默认问使用了 Rollup
因为 Esbuild 目前对代码分割(Code Splitting)和 CSS 处理的支持还不够成熟。为了保证线上代码的体积最优和兼容性,Vite 选择了成熟的 Rollup 进行打包,这样既能享受开发时的极速,又能保证线上的质量。

介绍IM项目

我参与的是一套IM桌面端项目, 技术栈 Vue3 + ts + pinia + tauri, 底层本地存储SQLite

我的主要工作有三块: 截图功能的实现(通过多层canvas + tauri实现), pinia从选项式到组合式的重构, 消息区虚拟列表的实现

截图功能
原理: 三层canvas分层渲染(底图/遮罩 + 选框/标注);
tauri rust 截整屏回传base64, 前端绘制为底图;
导出用离屏 Canvas 裁剪 PNG -> 剪贴板

细节: 画笔用二次贝塞尔平滑, 马赛克基于局部ImgData 做均值模糊; 放大镜独立 Canvas采样底图

Pinia 从选项式 → 组合式

  • 动机:更好的类型推断/按需导出/逻辑复用;把之前“多点 watch”改为“store 原子更新 + EventBus 领域事件(如
    CHAT_CHANGED、FRIEND_REMARK_UPDATED)”,降低耦合。
  • 收益:事件合并与最小载荷(id + patch),减少渲染抖动;单次会话切换回调次数明显下降,标题/列表一致性更稳定。

虚拟列表(消息区)

  • 方案:基于 vue-virtual-scroller,key-field 稳定定位;提供 min-item-size 与预估高度、overscan 8–12;粘性时间分组
    (Sticky)避免回流抖动。
  • 性能:在 8k+ 条消息、含图文混排场景,滚动基本保持 55–60fps;首屏渲染与定位体验稳定。

SQLite FTS5 建虚拟表
消息用 messageBody,联系人/会话用 name 等;新消息/改备注时同步 upsert FTS,搜索“本地秒回”,跨端再走服务端。

为什么需要rust传鼠标位置而不是前端监听

之所以让rust负责鼠标位置, 主要是为了突破浏览器的沙箱限制. 并解决窗口事件穿透这个难题

截屏窗口是透明覆盖层
在开发这个工具的时候, 我需要一个全屏透明窗口覆盖在桌面上.这样做的时候, webView自身不会再接受鼠标事件(事件被穿透到了桌面), 用 JS根本拿不到鼠标的位置. 只能再 Rust/tauri侧, 通过系统API读取鼠标位置, 才能在这种穿透模式下实时获取位置

桌面坐标 != WebView坐标
浏览器获取的坐标是相对于WebView的, 我需要截取整块桌面, 就需要得知鼠标在整个屏幕里的位置, 这些是js拿不到的

跨应用/跨场景
截屏快捷键在任何时刻触发, 即使用户的焦点在别的应用, 所以必须要Rust 常驻进程监听全局快捷键并获取鼠标

权限
浏览器访问外部信息需要用户手动授权, 影响体验

纯前端做截图的方案与局限

DOM重绘方案

把DOM树解析一遍画到canvas上

主流库html2canvas 他的原理不是拍照, 而是临摹. 他遍历DOM节点, 根据CSS样式在 Canvas上重新画一遍
如果页面包含跨域图片(CORS), js可以显示他, 但是无法把它画到Canvas里并导出数据

另外JS生成大尺寸的 Canvas的开销非常大, 很容易造成主线程阻塞

如何管理项目依赖, 怎么做项目优化

锁版本保证一致性
开发时我会注意lock文件的提交与更新, 并且在 CI/CD 流水线上, 会用 npm ci (或 pnpm install --frozen-lockfile)
看情况使用 ^(大版本更新前)/~(仅补丁)
在搭建博客的时候就遇到了, 因为没有锁版本而导致项目推送失败的问题
利用Vite生态做按需引入
为了减小首屏体积, 我是用了 unplugin-vue-componentsunplugin-auto-import自动导入
这样在就实现了 对Element这样的组件库的 Tree shaking, 之打包用到的组件, 而且还不需要手动导入

依赖去重
npm dedupe 先合并可以兼容的版本
如果版本不一致, 就可以采用强制去重,把同名依赖强制锁到一个版本
一般npm和pnpm用 overrides, yarn 用resolutions

vue3中的setup语法糖和vue2中setup函数的区别

setup本质上是组件实例创建之前的一次函数执行, 用来建立响应式依赖图
Vue 2 的 setup 是运行时 API,必须 return,通过实例代理访问;
Vue 3 的 <script setup>编译期 API,变量通过 JS 作用域直接绑定到 render 函数。

总结
在底层实现上, Vue2并没有原生setup, 他是通过Composition API插件在 beforeCreate阶段执行setup, 并把返回值合并到 data上(污染实例), 本质是运行时hack, 依赖 Vue2 的 defineProperty响应式和实例对象模型

而Vue3的setup是组件初始化流程 的一部分, 在实例创建之前执行, 返回值放在setupState, render 函数通过闭包直接访问
<script setup> 更进一步, 是编译器语法糖, 模板变量在编译阶段就被静态分析并直接闭包引用, 避免了this和运行时属性查找, 性能和类型推断都更好

怎么解决跨域问题

跨域的本质时浏览器的同源策略限制. 只要协议, 域名, 端口有一个不同, 就被视为跨域

  • 开发本地调试时, 我主要用构建工具 的 proxy代理, 通过Node中间件抓发请求
  • 在我做的台日桌面端项目中, 如果要解决跨域问题, 还要考虑安全性, 通常或使用Rust层的 HTTP Client 来代替浏览器的fetch, 直接从系统层发送请求, 绕过统统元策略的限制

style中scoped实现原理

scoped是在编译时期, 通过重写css选择器实现的

  1. 给当前组件生成一个唯一的scopId( data-v- )
  2. 给这个组件中所有的DOM元素都添加这个属性
  3. 最后给所有style scoped中的选择器都改写, 添加这个属性选择器

curry + compose

curry

1
2
3
4
5
6
7
8
function curry(fn) {

return function curried(...args) {
if (fn.length <= args.length) return fn(...args)
return (...next) => curried(...args, ...next)
}

}

compose

1
2
3
4
5
function compose(...fns) {
return function (value) {
return fns.reduceRight((acc, fn) => fn(acc), value)
}
}

解决了什么问题
curry:

  • curry允许我们把多参数转化为 一系列单一参数的函数, 这样可以通过函数部分应用提高代码的复用性
  • 避免重复代码, 有些时候, 我们可能会执行很多相似的操作, 只是传入不同的参数. curry可以做到共享这些重复的操作, 在针对不同的参数 给出不同的逻辑
  • 增加代码的灵活性和可组合性: 根据传入不同的参数来生成新的函数, 并且不同的函数可以组合成更复杂的逻辑

compose:
简化函数组合, 增强代码可读性和可维护性; 增强函数复用性; 减少副作用, 保持纯函数

curry + compose 可以让我们将复杂的功能拆分成简单的小函数,并通过组合来处理复杂的逻辑,这样可以保持代码的简洁、可复用、易于调试和易于测试。

从输入url 到页面渲染发生了什么

网络阶段(请求与传输)
URL解析 : 输入url后, 浏览器会先检查输入的是 合法URL还是关键词
DNS解析 : 如果是url, 浏览器会将 域名 转化成服务器的IP 地址
TCP连接 : 随后建立TCP链接, 如果是HTTPS, 还需要进行TLS握手加密
发送HTTP请求 : 浏览器构建请求行, 请求头, 并带上Cookie发送给服务器
服务器处理并响应 : 服务器返回状态码, 响应头 和 HTML内容

解析阶段
一旦浏览器开始接受数据, 渲染引擎 就会介入
构建 DOM 树: 将 HTML 字节流解析为一个个 Token, 最后转化为 Node 对象
构建 CSSOM树 : 解析所有的 CSS(CSS不会阻塞DOM构建, 因为他是在另一个线程解析构建的, 但是会阻塞页面渲染)
执行JS : 如果遇到script标签, DOM构建会暂停(除非有async 或defer)

渲染阶段
生成渲染树 : 遍历 DOM 树中所有可见节点( display: none的节点不在渲染树中, 但visibility: hidden在 ), 将对应的 CSSOM 样式应用到节点上
布局 : 计算每个节点在屏幕上的位置与大小
分层 : 将页面分为多个图层
生成绘制列表 : 将每个层的绘制工作拆分成一个个指令
栅格化与显示: 由合成线程将这些层切块, 利用GPU加速

async 和 defer 的区别

浏览器解析HTML 时, 如果遇到 script标签, 会暂停 HTML 的解析发起网络请求获取 JS 脚本, 然后 让 JS 引擎执行 JS 脚本, 执行完成后再恢复对HTML 的解析

async
如果在解析HTML时, 遇到 async script 会异步请求 JS 脚本, 不会阻塞 HTML解析. 但是如果请求完成后 HTML 的解析还没有完成, 就会停止对 html 的解析, 执行js脚本. 如果html已经解析完, 就不会阻塞
如果存在多个async, 他的执行顺序时不确定的, 完全取决于请求完成的先后顺序

defer
defer也是异步获取资源, 等html解析完成后才会解析js脚本, 不会阻塞HTML 解析
但如果存在多个defer script, 他的执行顺序 取决于 script 的引入顺序

闭包是什么, 使用场景

闭包是指 一个函数能够记住并访问他的词法作用域, 即使这个函数实在原始作用域之外执行的

底层原理
闭包的本质 是 JS引擎的垃圾回收(GC) 机制 与 作用域链的共同作用

  • 作用域链 : 内部函数持有对外部函数作用域的引用
  • 内存驻留: 正常情况下, 函数执行外后其变量就会被销毁. 但如果内部函数被返回 , 并被外部引用, 由于他还依赖外部变量, GC机制就不会回收该外部变量, 从而使其在内存中常驻

防抖

在时间被触发n秒后再执行回调, 如果期间再次触发时间, 则重新执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 防抖函数
* @param {Function} fn - 目标函数
* @param {number} delay - 延迟时间 (ms)
*/
function debounce(fn, delay) {
let timer = null; // 通过闭包保存定时器

return function(...args) {
// 这里的 this 指向调用者(如 DOM 元素)
const context = this;

// 如果已经在计时,则清除旧定时器,重新开始计时
if (timer) clearTimeout(timer);

timer = setTimeout(() => {
// 使用 apply 确保 fn 内部的 this 和参数正确
fn.apply(context, args);
}, delay);
};
}

节流

在规定时间内, 只能触发一次函数. 如果在这个时间内有多次触发, 只有第一次生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 节流函数 (定时器实现)
* @param {Function} fn - 目标函数
* @param {number} interval - 间隔时间 (ms)
*/
function throttle(fn, interval) {
let canRun = true; // 状态标记:是否可以执行

return function(...args) {
const context = this;

// 如果正在冷却中,直接跳过
if (!canRun) return;

// 立即关闭开关
canRun = false;

setTimeout(() => {
fn.apply(context, args);
// 执行完后,开启开关,允许下一次触发
canRun = true;
}, interval);
};
}

Promise 和 async/await的底层原理

Promise是一个异步结果的状态机

  • 状态一旦发生改变就不可逆转
  • then / catch注册回调, 回调会被安排为异步执行
  • then 总会返回新的Promise, 实现链式调用

async / await
是Promise的语法糖

  • async function 调用后立即返回 Promise
  • await x 会把后续逻辑”暂停”, 等 Promise.resolve(x)执行完后继续
  • await 的 rejected相当于抛异常, 可以用try/catch

CommonJs 和 EsModule的区别

CommonJs
commonjs 可以 动态加载语句, 代码发生在运行时
CommonJs 导出值是拷贝的, 可以修改导出的值, 但是在代码出错时, 不好排查引起变量污染

Es Module
Es Module是静态的, 不可以动态加载语句, 只能声明在文件的最顶部, 代码发生在编译时
Es Module导出的值 是值的引用, 内部存在映射关系. 值只能读取, 不能修改

怎么加快首屏加载速度

网络传输优化

  • 升级http协议: 可以使用http/2 或者 http/3, 利用多路复用解决头部阻塞, 减少TCP连接消耗
  • DNS 预解析: 使用<link rel="dns-prefetch" href="//example.com"> 提前解析域名 IP。

资源体积优化

  • Tree-Shaking: 基于EsModule的静态分析, 在打包时提出未使用的代码
  • 代码分割: 路由级拆分: 非首屏路由用import() 懒加载;
    组件级拆分: 图表 / 编辑器 / 富文本 等重组件不进首屏包
  • 图片优化: 根据图片分辨率加载不同尺寸的图

渲染策略

  • 异步加载 JS, 使用 defer 和 async避免js阻塞DOM解析
  • 减少回流与重构: 合理调整js 获取页面信息的位置, 减少渲染阻塞

架构与渲染

  • 使用SSR(服务端渲染) 或 SSG(静态站点生成): SSR 在服务器直接生成 HTML并返回
  • 骨架屏: 在真实数据加载出来前, 先展示页面的大体结构

Vue的生命周期

总结
Vue生命周期是组件从创建到销毁的过程, 分为 创建, 挂载, 更新, 销毁 四个阶段

父组件的生命周期遵循 父创建 -> 子创建, 子挂载 -> 父挂载, 父更新 -> 子更新

数据请求在created, DOM操作放在mounted, 清理工作放在 beforeUnmounted / beforeDestory

详细

一、生命周期四个阶段及核心钩子

Vue 组件实例的生命周期主要分为四个阶段:

  1. 创建阶段(Creation):初始化响应式数据和事件。

    • beforeCreate:实例刚创建,数据data和事件methods还未初始化
    • created:实例创建完成。数据data已响应式化,事件methods已配置,可在此发起异步请求但未挂载,DOM 不存在
  2. 挂载阶段(Mounting):将模板编译渲染成真实 DOM 并插入页面。

    • beforeMount:模板已编译,但尚未将渲染内容挂载到页面上
    • mounted:实例已挂载到页面,真实 DOM 已生成并可访问,可在此进行 DOM 操作或访问$refs
  3. 更新阶段(Updating):当数据变化时,虚拟 DOM 重新渲染和打补丁。

    • beforeUpdate:数据发生变化,但虚拟 DOM 尚未重新渲染
    • updated:数据更改导致虚拟 DOM 重新渲染和打补丁完成,可在此操作更新后的 DOM(但要谨慎,避免无限循环更新)。
  4. 卸载阶段(Unmouting/Destruction):实例被销毁。

    • beforeUnmount(Vue 3) /beforeDestroy(Vue 2):实例即将被销毁,此刻实例仍完全可用。
    • unmounted(Vue 3) /destroyed(Vue 2):实例已销毁,所有指令被解绑,事件监听器被移除,子实例也被销毁。在此进行最终的清理工作(如清除定时器、取消事件总线监听)。

父子组件生命周期顺序

加载渲染过程
父 beforeCreate -> 父created -> 父beforeMount -> 子beforeCreate -> 子created -> 子beforeMount -> 子Mounted -> 父mounted

更新过程
父beforeUpdate -> 子beforeUpdate -> 子Updated -> 父Updated

销毁
父beforeUnmount -> 子beforeUnmount -> 子Unmounted -> 父Unmounted

父组件总是等内部的子组件完成就, 再自己完成

浏览器渲染时的优化策略

  1. 读写分离, 避免强制同步布局 : 在修改样式的代码中, 避免穿插读取布局信息的代码.
    最佳实践时先统一读取布局信息, 再集中修改样式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // Bad: 读写穿插,触发多次回流
    div.style.left = div.offsetLeft + 10 + 'px';
    div.style.top = div.offsetTop + 10 + 'px';

    //Good: 集中处理
    const left = div.offsetLsft
    const right = div.offsetRight
    div.style.left = left + 10 + 'px'
    div.style.right = right + 10 + 'px'
  2. 批量处理DOM操作: 对于需要多次操作DOM的场景, 可以先将元素设置为 display: none, 操作完毕后再显示出来; 或者使用documentFragment作为临时容器, 在内存中完成所有的DOM节点修改, 最后一次添加到DOM树中

  3. 使用transform和opacity实现动画: 这两个属性的变化通常可以有GPU直接处理(合成层), 从而绕开回流和重绘

  4. 避免使用table布局: table布局中, 一个微小的改动就可能导致整个表格的回流, 开销较大. 应该优先使用 Flexbox 或 Grid布局

  5. 将频繁回流的元素提升为独立图层: 使用 transform: translateZ(0) 或 will-change的手段将该元素建立一个独立的图层. 这样该元素发生变化时, 只会影响到这一个图层

跨域的成因与方案: CORS, 代理, JSONP, 同源策略细节

源头

跨域的本质是浏览器的同源策略. 同源策略限制的是JS脚本对跨源数据的读取, 而不是请求的发送. 可以在网页里通过<img> 或 <link> 引用外站资源(属于资源嵌入), 但是不能用 fetch去读取另一个域名的api返回值

CORS(Cross-Origin Resource Sharing) - 跨域资源共享

原理: 这是W3C标准. 允许服务器在 HTTP 响应头中添加一些字段, 来声明哪些外部源有权限访问其资源

工作流程 :

  1. 浏览器在发起跨域 AJAX 请求时, 会自动在请求头中添加一个 Origin 字段, 表明请求的来源
  2. 服务器收到请求后,会根据自身的跨域配置, 在响应头中返回一个 Access-Control-Allow-Origin字段
  3. 浏览器接收到相应后, 会检查 Acces-Control-Allow-Origin的值是否包含当前的Origin. 如果包含, 就允许 JS读取响应; 否则, 浏览器会拦截响应并报错
  • 预检请求 (Preflight Request):对于一些“非简单请求”(例如PUT/DELETE方法,或Content-Type为application/json的请求),浏览器会先发送一个OPTIONS方法的“预检”请求,询问服务器是否允许接下来的实际请求。服务器同意后,才会发送真实的请求。

代理 (Proxy)

  • 原理:利用服务器之间通信不受同源策略限制的特点。前端不直接请求目标 API,而是请求自己的同源服务器,再由这个服务器作为“代理”,去请求真正的目标 API,最后将数据返回给前端。
  • 应用场景
    • 开发环境:像webpack-dev-server或Vite都内置了代理功能,可以方便地将 API 请求转发到后端服务器,解决开发时的跨域问题。
    • 生产环境:可以通过 Nginx 反向代理,或在自己的后端服务(Node.js, Java 等)中封装一个接口来实现。这种方式还可以用于隐藏真实 API 地址、添加认证等

HTTP的发展

HTTP0.9

只有一个请求行, 没有 HTTP 请求头和 请求体
服务器没有返回头部信息
返回的文件内容是以 ASCII 字符流来传输的

HTTP 1.0

  • 支持多种不同类型的数据
  • 引入状态码, 告诉浏览器服务器最终的处理结果
  • 提供 Cache机制, 缓存已经下载过的数据以减轻服务器的压力
  • 加入用户代理的字段统计客户端的基础信息

HTTP 1.1

新增特性

  • TCP持久连接, 一个TCP连接上可以传输多个HTTP请求
  • 不成熟的 HTTP 管线化
  • 提供对虚拟主机的支持
  • 支持动态生成的内容, 引入 Chunk transfer机制(分块传输编码机制), 服务器将数据分成若干个任意大小的数据块, 每个数据块发送时会附上上个数据块的长度, 最后使用一个零长度的块作为发送完成的标志
  • 引入 Cookie和安全机制

缺点

  • TCP的慢启动 (慢启动是 TCP 为了减少网络拥塞的一种策略)
  • 同时开启多条 TCP 连接, 这些连接会竞争固定的带宽 (多条TCP连接之间不能协商让那些关键资源优先下载)
  • 队头阻塞(多个请求虽然能公用一个TCP管道, 但因为先进先出的机制, 只能等前一个请求处理完并发出去后, 才能请求B的结果)

HTTP2.0

  • 多路复用, 引入 二进制分帧层 ( 将请求数据进行二进制分帧处理, 转换为一个个带有请求ID编号的帧, 服务器接收到这些帧后, 将所有相同 ID 的帧合成为一条完整的请求信息)
  • 设置请求的优先级
  • 服务器将数据提前推送到浏览器
  • 头部压缩(对请求头和响应头)
  • 能在不中断TCP连接的情况下停止数据发送

资源加载优化: 关键路阻断排查, 按需, 分包, 预获取

资源加载优化的核心是加载首次有效渲染 . 通过 排查关键路径来寻找可优化的地方, 利用代码分包 和 按需加载 来减少初始在和, 再通过 预获取 技术加载未来资源, 优化用户体验

关键渲染路径阻断排查

排查
打开 Network面板L查看阻塞了后续资源下载和页面资源渲染的 JS / CSS 文件
Performance面板: 录制加载过程, 查看 Main主线程是否有长时间的脚本执行或样式计算

优化
js阻塞: 将script标签放在body标签前, 使用 defer / async 异步加载js
css阻塞:

  • 内联关键 CSS:将渲染首屏内容所必需的 CSS(即关键 CSS)直接内联到<head><style>标签中,让首屏能无阻塞地快速渲染。
  • 异步加载非关键 CSS:使用<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">等技术来异步加载剩余的 CSS。

代码分包

  • 路由分包: 为每个页面创建一个单独的js文件, 当用户访问某个界面时, 才加载对应的代码
  • 组件分包: 对于不常用但体型较大的组件, 动态加载import()

按需加载

  • 图片懒加载
  • 动态import()语法再特定交互条件下才加载代码

预获取

  • <link rel="preload">
    • 用途:用于加载当前页面肯定会用到、但浏览器发现较晚的关键资源。
    • 例子:深层 CSS 中定义的 LCP 图片或字体文件。
    • 行为:告诉浏览器以高优先级下载该资源,但不执行它。当浏览器真正需要时,资源已在缓存中,可立即使用。
  • <link rel="prefetch">
    • 用途:用于加载用户在未来导航中可能会用到的资源。
    • 例子:预加载用户鼠标悬停的链接所指向的页面的 JS/CSS。
    • 行为:告诉浏览器在空闲时低优先级下载该资源,并放入缓存。

代码分割与 Tree Shaking

代码分割 通过动态导入实现按需加载, 优化首屏速度

Tree Shaking 利用ES Module的静态分析移除未使用的代码, 优化打包体积
其中, 正确的副作用标注避免命名空间污染 是保证Tree Shaking的关键

副作用标注

副作用 指一段代码在被import时, 除了导出成员外, 还对执行环境产生了其他影响
(修改群居变量, 修改原型链, 引入css文件)

标注方法: package.json 中的 sideEffects;
注释标注纯函数 /* @__PURE__ */

命名空间污染

原因: 在tree shaking环境下, 它主要讲所有东西都挂载到一个大对象上

如果将多个方法包裹在 Object 或 Class中统一导出, 哪怕某些方法未使用, tree Shaking也不能将他们剔除; 更好的做法是导出独立的Functuion, 尽量避免导出一个大对象

清除浮动

BFC 清除浮动

计算BFC的高度时, 浮动元素也参与计算

1
2
3
4
5
根元素, HTML 元素本身就是 BFC
浮动元素:float 属性不为 none(脱离文档流,浮动元素)
绝对定位元素:position 属性为 absolute 或 fixed (绝对与固定定位)
非块级盒子的块级容器:display 属性为 inline-block、table-cell、table-caption、flex、inline-flex、grid、inline-grid、flow-root(最佳,无副作用),定义成块级的非块级元素。
overflow 属性不为 visible(- overflow: auto/ hidden),非溢出的可见元素。

clear清除浮动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  <style>
.box {
border: 1px solid black;
}
.float {
float: left;
background-color: pink;
width: 200px;
height: 200px;
}
.nor {
background-color: orange;
width: 20px;
height: 50px;
}
.box::after {
content: '';
clear: both;
display: block;
}
</style>
</head>
<body>
<div class="box">
<div class="float"></div>
<div class="nor"></div>
</div>
</body>

::after 伪元素在父元素的最后一个子元素后面生成一个内容为空的块级元素, 然后通过clear将这个伪元素移动到所有它之前的浮动元素的后面

水平垂直居中

单行的文本、inline 或 inline-block 元素

此类元素需要水平居中,则父级元素必须是块级元素(block level),且父级元素上需要这样设置样式:

1
2
3
.parent {
text-align: center;
}

垂直居中

方法一:通过设置上下内间距一致达到垂直居中的效果:

1
2
3
4
.single-line {
padding-top: 10px;
padding-bottom: 10px;
}

方法二:通过设置 height 和 line-height 一致达到垂直居中:

1
2
3
4
.single-line {
height: 100px;
line-height: 100px;
}

块级盒子

方法一:absolute + transform

![[八股/_resources/我的面试总结(匡)/421f65b928106e81d830edf9ce93cbc9_MD5.webp]]

方法二:line-height + vertical-align

![[八股/_resources/我的面试总结(匡)/eccf2349c9f1cd2a4db3e56743f1063b_MD5.webp]]

方法三:writing-mode

![[八股/_resources/我的面试总结(匡)/cbe6c62c39e879713ff5cb38970671b1_MD5.webp]]

方法四:table-cell

![[八股/_resources/我的面试总结(匡)/eee892841b347136adb482e3ac7252f4_MD5.webp]]

方法五:flex

![[八股/_resources/我的面试总结(匡)/b4a88dddcc52d971290a1a01e72a29e8_MD5.webp]]

方法六:grid

![[八股/_resources/我的面试总结(匡)/8cb080bc64e622837c232bd632e22bc5_MD5.webp]]

apply, bind, call的区别

  • call 和 apply都是立即执行函数, 区别 call需要逐个传入参数, apply 传入一个数组;
  • bind 返回一个绑定后的新函数, 不会立即执行. 在事件绑定和柯里化中运用较多

相同点: 传入的第一个参数都是 this上下文

Token的持久化

LocalStorage存储

逻辑

  • 用户登录成功后, 后端返回Token
  • 前端通过localStorage.setItem('token', token)存入本地
  • 在封装Axios请求拦截器时, 从本地取出, 并放入请求头的Authorization字段

缺点 : 容易受到 XSS(跨站脚本攻击)。黑客只需植入一段 console.log(localStorage.getItem('token')) 的代码,就能轻易偷走你的身份凭证。

HttpOnly Cookie存储方案

逻辑

  • 用户登录, 后端在响应头中设置Set-Cookie, 并加上HttpOnly标识
  • 浏览器接收后, 自动将该Cookie 存入受保护区域
  • 后续请求: 浏览器在发送同源请求时, 会自动在 Header中带上Cookie字段
    缺点
  • CSRF 风险:虽然黑客偷不走 Token,但可以诱导用户点击链接,利用浏览器“自动携带 Cookie”的特性发起伪造请求(需配合 SameSite 属性防御)。
  • 跨域麻烦:如果前后端域名不同,需要配置复杂的 CORS 和 withCredentials

双Token刷新机制

核心结构

  • Access Token(访问令牌)

    • 有效期短(如 30 分钟)。

    • 存储在内存或 localStorage 中,用于日常接口请求。

  • Refresh Token(刷新令牌)

    • 有效期长(如 7 天或 30 天)。

    • 存储在 HttpOnly Cookie 中,专门用来“续命”。

运行流程

  • 正常请求:前端带着 Access Token 请求接口,一切正常。

  • 过期报错:Access Token 过期,后端返回 401 Unauthorized

  • 自动无感刷新

    • 前端拦截到 401 错误。

    • 前端自动调用一个 /refresh 接口(此时浏览器会自动带上存储在 Cookie 里的 Refresh Token)。

    • 后端验证 Refresh Token 合法后,下发一个新的 Access Token。

    • 前端拿到新 Token,重新发起之前失败的那个请求。

  • 最终失效:如果 Refresh Token 也过期了,此时才真正跳转到登录页。

reflect 和 proxy 的区别

proxy 可以创建一个代理对象, 他能对其他对象实现代理操作
reflect是一个内置的全局对象, 他不是用来拦截的, 而是提供了一套执行js底层操作的方法

reflect解决this指向问题
reflect可以接收recevier 参数, 代表最初触发这次操作的对象(通常是proxy实例). 从而保证getter内部的this永远指向当前代理的 对象

引入reflect的原因

统一错误处理
原有js中, 很多底层操作失败了, 他的处理方式比较混乱, 有些是什么都不返回, 有些是抛出错误

reflect将这些操作直接标准化, 执行失败返回false, 成功返回true

原有api设计混乱
原本很多属于 “语言内部”的方法都放在了Object上(例如Object.keys, Object.defineProperty).并不是Object的工作是构造函数,

ES6 将一些明显属于语言内部的方法转移到了 Reflect 对象上,未来的新方法也会只部署在 Reflect 上。这让 Object 更加纯粹,也让反射 API 有了专门的家。

调用方式容易被篡改
如果你想以指定的 this 上下文和一个数组作为参数来调用一个函数,以前通常用 Function.prototype.apply.call(fn, context, args)
为什么要写这么长?因为如果你直接写 fn.apply(context, args),一旦 fn 自己重写了 apply 方法,代码就崩了。

Reflect.apply 规避了原型链上方法被篡改的风险,提供了一种极其安全、简洁的函数调用方式

Promise

Promise 是 ES6引入的一种异步编程处理方案. 本质上是一个容器, 里面保存着某个未来才会结束的事件. 它主要是为了解决原有的回调函数所导致的回调地狱, 让异步代码可以向同步代码一样链式调用, 并提供了统一的错误处理机制

Promise的状态(pending, fulfilled, rejected)一旦改变就不可以逆转

链式调用:为什么 .then() 后面还能 .then()
Promise.prototype.then()Promise.prototype.catch() 用来注册成功和失败的回调。他们能一直调用下去的原因 是他们内部每次都会返回一个新的Promise实例

  • 如果你在 .then()return 了一个普通值,新的 Promise 会用这个值作为成功的状态传递下去。

  • 如果你在 .then()return 了一个新的 Promise,接下来的 .then() 就会等待这个内部 Promise 状态落埃。

  • 如果在中途抛出了异常(throw new Error),会直接穿透到最后的 .catch() 中被集中捕获。

API 方法 核心行为 / 适用场景 成功条件 失败条件
Promise.all() “一荣俱荣,一损俱损”。场景:需要多个接口全部数据回来后才渲染页面。 所有 Promise 全成功,返回结果数组。 任何一个失败就立即失败(Fail-fast)。
Promise.race() “百米赛跑,只看第一名”。场景:给请求设置超时限制(请求与定时器赛跑)。 第一个完成的 Promise 成功。 第一个完成的 Promise 失败。
Promise.allSettled() (ES202(ES2020)* “无论成败,等所有人交卷”。场景:批量上传图片,不管成功几张,都要等全部传完再统计。 永远不会失败。所有都执行完后,返回包含各自状态和结果的数组。 无(除非代码本身抛出同步错误)。
Promise.any() (ES2021) “只要有一个成功就行”。场景:从多个备用 CDN 节点拉取资源,最快拉到就行。 任何一个成功就立即成功。 所有 Promise 全失败才报错。

es6新特性

变量和作用域: let, const

异步函数: 过去只能通过回调函数, es6引入Promise (链式调用)

箭头函数: 解决this执行问题

对象: 引入class关键字 和 extend是, super(底层依旧是原型链)

模块化: 推出ES Module. ESM的静态编译, 使得打包工具(Tree Shaking)配合使用

数据结构: 结构赋值, 模板字符串, 展开运算符, 新增Map, set等

Vite 的特性

裸模块的路径重写
当浏览器遇到 import { ref } from 'vue' 这种代码时,它会报错,因为浏览器只认识绝对路径(/)或相对路径(./../),它根本不知道 vue 到底存放在哪个硬盘目录下。

Vite 的做法: Vite 在本地启动了一个基于 Koa/Connect 的 HTTP 拦截服务器。当浏览器发起请求时,Vite 会拦截这些请求,把代码里的 import from 'vue' 悄悄**重写(Rewrite)**成 import from '/node_modules/.vite/deps/vue.js'。浏览器拿到带有合法路径的代码后,就能顺利去请求资源了。

依赖预构建
对于一些第三方库, 项loadsh-es包, 内部互相import了上百个文件, 如果直接让浏览器请求, 会瞬间发送几百个HTTP请求

Vite 的做法:
在项目第一次启动前,Vite 会召唤 Go 语言编写的 Esbuild 闪电般地扫描全盘。它会做两件事:

  1. 将 CommonJS/UMD 全部转换成 ESM 格式。

  2. 将内部嵌套了成百上千个模块的依赖,强行合并成一个单文件。这样浏览器只需要发 1 个 HTTP 请求就能拿到完整的 Lodash。

按需编译
浏览器顺着路径,向 Vite 服务器请求一个具体的 .vue.ts 文件
Vite 服务器收到请求后,会实时在内存中调用对应的编译器(如 @vue/compiler-sfc),把 .vue 文件里的 <template><style> 拆解并编译成标准的 JavaScript 代码,然后直接返回给浏览器。

浏览器窗口的组成部分

  1. document(DOM树)
  2. location(地址栏)
  3. history(历史记录)
  4. navigator(浏览器信息)
  5. screen(屏幕信息)

less 与css的区别

less是一种 css 预处理器

less

  • 支持变量, 可以复用样式
  • 支持嵌套
  • 可以复用样式逻辑
  • 支持数学运算
  • 内嵌函数
  • less变量有作用域
  • 支持模块导入
  • 支持循环

ref 和 reactive 的区别

数据结构
ref 底层是一个 RefImpl类(用 class + 访问器属性 实现的响应式容器). 他对基础类型的拦截并没有使用Proxy, 而是依赖类的 get value()set value() 访问器

当给 ref传入一个对象时. set value 内部会调用 toReactive(val),将其转化为 Proxy。它的真实数据保存在 this._value 中,而原始数据保存在 this._rawValue 中(用于 hasChanged 的比对)。

reactive 的本质是 Proxy 代理, 拦截对对象的各种操作

依赖收集机制

ref的依赖是私有的
remImpl 实例中, 依赖被直接挂载到实例的this.dep属性上. 当触发get value时, 直接在当前实力上 trackRefValue; 触发set value, 调用triggerRefValue派发更新

reactive 的依赖是“全局字典”管理的:
因为 Proxy 本身不能挂载额外的属性来存依赖,Vue 在全局维护了一个 targetMap(一个 WeakMap)。
它的结构是 WeakMap<Target, Map<Key, Dep>>。当触发 Proxy 的 get 时,需要通过 target 找到对应的 Map,再通过 key 找到对应的 Dep(Set)进行 trackset 时按同样路径寻找并 trigger

内存引用
为什么 reactive 重新赋值会丢失响应式?
因为 reactive 返回的是一个 Proxy 实例的内存地址。直接用 = 重新赋值(如 state = {}),仅仅是改变了局部变量的指针,指向了一个新的纯净对象,丢失了原来 Proxy 实例的引用。Vue 全局的 targetMap 也就无法通过旧的 target 追踪到新的对象了。

  • 对比 ref ref 重新赋值是 ref.value = {}。变量 ref 指向 RefImpl 实例的指针没变,触发的是 set value() 拦截器,内部会重新对新对象执行 toReactive 并触发 trigger,所以响应式不会丢。

Vite的更新

vite基础配置项:

  • root: 项目根目录(index.html 所在的位置)。

  • base: 开发或生产环境服务的公共基础路径(对应 Webpack 的 publicPath)。

  • mode: 模式(’development’ 或 ‘production’)。

  • define: 定义全局常量替换,常用于在代码中注入环境变量。

  • plugins: 插件数组。这是 Vite 扩展功能的核心。

  • resolve.alias: 路径别名(例如将 @ 指向 src),配合 TS 使用时需在 tsconfig 同步配置。

  • publicDir: 静态资源服务的目录,默认是 public

打包器: 由esbuild(开发) + Rollup(生产)双打包器 –> Rolldown(rust实现打包) + Oxc(rust编译器)
插件: 兼容Rollup/Vite插件
功能: rollupdown 提供了高级的分块控制, 内置的模块热替换,

总结
vite de No-bundle(非打包)核心依旧没有改变. Rolldown改变的时服务端如何快速处理和响应请求

开发阶段 :

  • 原来浏览器请求一个.vue文件的时候, vite需要在接收请求后利用esbuild将ts转换成js, 如果是css或者其他插件处理, 还需要跑一些js逻辑.
  • 在vite8中, 当请求到达服务器的时候, Rolldown直接在内存中完成所有的转换, 消除了以往Node和esbuild进行通信的消耗, 所有解析, 转换, css处理全在Rust内存中完成

预构建

  • 以前开发esbuild与生产环境rollup不同的处理方式, 可能导致开发和生产效果不同
  • 现在直接用rolldown接管预构建任务, 保证了开发和生产效果一致

摇树优化
以前的no-bundle在开发的时候是不走Tree-shaking的, 但是Rolldown足够快, 所以可以在响应ESM请求的同时, 做一些轻量的静态分析

[[八股/_resources/我的面试总结(匡)/0fd28e03b506656c192ded59dfb1553e_MD5.jpeg|Open: 81579286d8f15eb0e10a9d4897963a96.png]]
![[八股/_resources/我的面试总结(匡)/0fd28e03b506656c192ded59dfb1553e_MD5.jpeg]]

为什么Promise的错误不会被catch捕捉到

1. 混用了同步的try …catch 且忘记加await
同步的try ... catch无法捕捉异步操作中的错误. 如果没有用await等待Promise 完成, 主线程的代码会向下执行, 等 Promise真正报错的时候, try ...catch的代码块早就执行完了

错误写法

1
2
3
4
5
6
try {
// 这里的错误抓不到,因为 Promise 是异步的
Promise.reject(new Error("出错了!"));
} catch (error) {
console.log("捕捉到错误:", error);
}

2. 在Promise构造函数内的”异步回调”中抛出错误
Promise 构造函数(new Promise)在执行时,内部会默认包裹一层 try...catch,但这层包裹只能捕捉同步错误。如果你的错误是在 setTimeout 或事件监听等异步回调中直接用 throw 抛出的,.catch() 是抓不到的,它会直接变成全局的未捕获错误

错误写法

1
2
3
4
5
new Promise((resolve, reject) => {
setTimeout(() => {
throw new Error("定时器内错误") //抛出的任务catch捕捉不到
}, 1000)
}).catch(err => console.log(err)

正确写法

1
2
3
4
5
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("定时器里的错误")); // 规范地通过 reject 改变 Promise 状态
}, 1000);
}).catch(err => console.log("捕捉:", err));

3. Promise 链条断裂(忘记 return
.then() 的回调函数内部,如果你创建了一个新的 Promise 并且它报错了,但你没有把它 return 出来,外层的 .catch() 就无法追踪到这个内部 Promise 的状态

错误写法:

1
2
3
4
Promise.resolve().then(() => {  
// 创建了新 Promise,但没有 return,导致外层链条断裂
Promise.reject(new Error("内部错误"));
}).catch(err => console.log("捕捉:", err)); // 抓不到内部那个未返回的 Promise 错误!

正确写法:

1
2
3
Promise.resolve().then(() => {  
return Promise.reject(new Error("内部错误")); // 接上链条
}).catch(err => console.log("捕捉:", err)); // 成功捕捉

4. 错误发生在 .catch() 自身内部

.catch() 只能捕捉它前面的 Promise 链抛出的错误。如果你在 .catch() 的回调函数内部又写错了代码(或主动抛出异常),当前的这个 .catch() 是无能为力的。

错误写法:

1
2
3
4
Promise.reject("初始错误")  
.catch(err => {
throw new Error("catch 内部又出错了"); // 这个错误无法被当前的 catch 处理
});

正确写法:

1
2
3
4
5
Promise.reject("初始错误")  
.catch(err => {
throw new Error("catch 内部又出错了");
})
.catch(err => console.log("最终的兜底捕捉:", err)); // 在后面再接一个 catch

monore单, 双, 多仓库,
git所有操作复习 (注意-的用法),
怎么写, 什么时候写, 怎么写好一个vitest
cicd流, 结合项目回答
最好回答每一个项目流程
八股与项目相关

rollup

虚拟列表的三种实现方法

模板编译的编译(静态提升, patchplag, 快树优化 …..)

渲染器: render, 为什么不仅仅能在浏览器里运行

vue2的diff和vue3的dif发, 优化的点, 发展流程

canvas与svg的区别

pinia2迁移pinia3

组合式api 和选项式api(数据和视图的关系) 的区别

axios的适配器(浏览器和node端, fetch 和xhr的区别, 怎么实现)

设计模式, 适配器模式(axios)

取消请求(cancelToken老, abort新)

网络和浏览器
浏览器安全, 前端安全

suspense异步组件的实现

fragment多根渲染

teleport转义

get, post等七种方式

ts: interface, type的区别

工程化, vite的配置