跳到主要内容

版本

2024年03月12日
柏拉文
越努力,越幸运

一、vue.js 1.0


1.1 响应式

通过 Object.defineProperty: 负责数据的拦截。getter 时进行依赖收集,setter 时让 dep 通知 watcher 去更新。Dep: Vue data 选项返回的对象,对象的 keydep 一一对应。Watcher: keywatcher 是一对多的关系,组件模版中每使用一次 key 就会生成一个 watcher。当数据更新时,dep 通知 watcher 去直接更新 DOM,因为这个版本的 watcherDOM 时一一对应关系,watcher 可以非常明确的知道这个 key 在组件模版中的位置,因此可以做到定向更新,所以它的更新效率是非常高的。

虽然更新效率高,但随之也产生了严重的问题,无法完成一个企业级应用,理由很简单:当你的页面足够复杂时,会包含很多的组件,在这种架构下就意味这一个页面会产生大量的 watcher,这非常耗资源。

二、vue.js 2.0


2.1 响应式

Vue 2.0 中通过引入 VNodediff 算法去解决 1.x 中的问题。将 watcher 的粒度放大,变成一个组件一个 watcher(就是我们说的渲染 watcher),这时候你页面再大,watcher 也很少,这就解决了复杂页面 watcher 太多导致性能下降的问题。

在组件渲染的过程中, 用到的数据属性会触发 getter, getter 内部会收集依赖。当依赖发生改变,触发 setter,则会通知watcher,从而使关联的组件重新渲染。这时候问题就来了,Vue 1.xwatcherkey 一一对应,可以明确知道去更新什么地方,但是 Vue 2.0watcher 对应的是一整个组件,更新的数据在组件的的什么位置,watcher 并不知道。这时候就需要 VNode 出来解决问题。

通过引入 VNode,当组件中数据更新时,会为组件生成一个新的 VNode,通过比对新老两个 VNode,找出不一样的地方,然后执行 DOM 操作更新发生变化的节点,这个过程就是大家熟知的 diff

Vue 响应式的实现过程如下:

  1. Vue 初始化的过程中, 调用 initState 初始化响应式数据, 调用 observedata 添加响应性

  2. 遍历 data 对象所有属性, 调用 defineReactive 为每个属性添加响应性:

    1. 为每个属性实例化 Dep

    2. 每个属性继续调用 observe 尝试为后代属性添加响应性, observe 函数中会判断属性类型, 只有对象或者数组才会继续添加响应性

      • 如果 data[xx] 为对象: 继续循环遍历 data[xx] 对象中的所有属性, 递归

      • 如果 data[xx] 为数组: 重写 data[xx] 中的 pushpopshiftunshiftsplicesortreverse 等七个可以原地改变数组的方法, 然后调用 observeArray 遍历数组, 为每个元素调用 observe 添加响应性

    3. 为每个属性通过 defineProperty 添加 setter/getter 拦截函数, 后续在访问或者设置值时可以拦截

  3. 访问数据, 触发 getter 函数: 如果当前 Watcher 存在的话, 调用 dep.depend() 进行依赖收集, 当前 WatchernewDeps 存储当前 dep , 当前 depsubs 存储当前 Watcher

  4. 设置数据值, 触发 setter 函数: 调用 dep.notify() , 循环遍历 subs 中的所有 Watcher, 执行 Watcherupdate 方法。

  5. update 方法中, 会将此时的 Watcher 加入到渲染队列 queue, 通过 nextTick 进行批量更新渲染

  6. nextTick 中,通过 promise.then(flushCallbacks) 将批量更新任务放到了微任务队列, 依次执行任务队列中的任务, 每一个任务就是一个 Watcher, 开始执行每一个 Watcherrun 方法

  7. Watcher 中的 run 方法调用 Watcher 中的 get 方法, get 方法调用 Watcher 中的 getter 函数, 此时的 getter 函数就是 updateComponent , 用于初始或者更新渲染

2.2 Virtual DOM

Vue2 引入了 VNodediff 算法,将组件 编译 成 VNode,每次响应式数据发生变化时,会生成新的 VNode,通过 diff 算法对比新旧 VNode,找出其中发生改变的地方,然后执行对应的 DOM 操作完成更新。

三、vue.js 3.0


3.1 响应式

Vue3.0 针对 Vue2.0 所做的响应式优化: Vue2.0 使用的是 Object.defineProperty 来劫持整个对象,然后进行深度遍历所有属性,给每个属性添加 gettersetter,实现响应式,因此在嵌套层级比较深时, 一次性递归执行, 把所有子对象变成响应式。但是它不能监听对象属性的新增和删除,所以需要使用 $set、$delete 这种语法糖去实现,这其实是一种设计上的不足。所以 Vue3.0 采用了 Proxy 重写了响应式系统, 可以对整个对象进行监听,所以不需要深度遍历, 在访问到嵌套层级较深的子对象时, 才会将他变成响应式。可以监听动态属性的添加、删除;可以监听到数组的索引和数组 length 属性。

当然 Proxy 不兼容 IE, 也没有 polyfillObject.defineProperty 可以支持到 IE9

Vue.js 3.0 中, ref 对原始值 BooleanNumberBigIntStringSymbolundefinednull 通过访问器 get 和设置器 set 实现响应式。注意: Proxy 无法代理原始值。监听策略如下:

  1. 通过 get 访问器来收集该值对应副作用函数

  2. 通过 set 设置器来触发该值对应副作用函数

Vue.js 3.0 reactive 对非原始值 ObjectArraySetMap 基于 Proxy 实现响应式。通过 Proxy 监听整个对象, 监听策略如下:

  1. 通过 get 处理器监听属性的访问, 收集对应属性相关副作用

  2. 通过 set 处理器监听属性的变更, 触发对应属性相关副作用函数执行

  3. 通过 has 处理器来监听 xx in yy 操作符, 收集对应属性相关副作用

  4. 通过 ownKeys 处理器来监听 for infor of 遍历操作, 收集 ITERATE_KEY 相关的副作用。因为 ownKeys 只有一个 target 参数, 没有 key, 对于对象来说需要一个 ITERATE_KEY 来充当 key, 后续之后新增属性时, 才会触发副作用函数执行, 已有属性不触发。对于数组而言, 无论是为数组添加新元素还是直接修改数组的长度, 本质上都是因为修改了数组的 length 属性。 一旦数组的 length 被修改, 那么 for…infor……of 循环对数组的遍历结果就会改变。

  5. 通过 deleteProperty 处理器来监听属性删除操作, 触发对应属性副作用函数重新执行

  6. 对于 array.includes() array.indexOf() array.lastIndexOf() 查找类型的数组 API, 查找的目标元素有可能是代理数据, 有可能是原始数据, 但是 array 肯定是代理数据, 所以需要对查找类型的 API 重写, 添加代理数组与原始数组的映射关系, 之后优先在代理数组中查找, 如果找不到再去原始数组中查找。对于 array.push() array.push() array.shift() array.unshift() array.splice() 会读取数组中的 length 属性,也会设置数组中的 length 属性, 这会导致两个独立的副作用函数相互影响。只要屏蔽了对 length 属性的读取, 从而避免在它与副作用函数之间建立响应联系。具体策略: 重写array.push() array.push() array.shift() array.unshift() array.splice() 方法, 在调用原始方法之前, 停止追踪, 调用原始方法之后, 继续追踪

3.2 组合式 API

1. 什么是组合式 API

组合式 API (Composition API) 是一系列 API 的集合,使我们可以使用函数而不是声明选项的方式书写 Vue 组件。它是一个概括性的术语,涵盖了以下方面的 API:

  • 响应式 API: 例如 ref()reactive(),使我们可以直接创建响应式状态、计算属性和侦听器。

  • 生命周期钩子: 例如 onMounted()onUnmounted(),使我们可以在组件各个生命周期阶段添加逻辑。

  • 依赖注入: 例如 provide()inject(),使我们可以在使用响应式 API 时,利用 Vue 的依赖注入系统。

虽然这套 API 的风格是基于函数的组合,但组合式 API 并不是函数式编程。组合式 API 是以 Vue 中数据可变的、细粒度的响应性系统为基础的,而函数式编程通常强调数据不可变。

2. 为什么要有组合式 API

  1. 更好的逻辑复用: 组合式 API 最基本的优势是它使我们能够通过组合函数来实现更加简洁高效的逻辑复用。在选项式 API 中我们主要的逻辑复用机制是 mixins,而组合式 API 解决了 mixins 的所有缺陷。

  2. 更灵活的代码组织: 许多用户喜欢选项式 API 的原因是它在默认情况下就能够让人写出有组织的代码: 大部分代码都自然地被放进了对应的选项里。然而,选项式 API 在单个组件的逻辑复杂到一定程度时,会面临一些无法忽视的限制。这些限制主要体现在需要处理多个逻辑关注点的组件中。处理相同逻辑关注点的代码被强制拆分在了不同的选项中,位于文件的不同部分。在一个几百行的大组件中,要读懂代码中的一个逻辑关注点,需要在文件中反复上下滚动,这并不理想。另外,如果我们想要将一个逻辑关注点抽取重构到一个可复用的工具函数中,需要从文件的多个不同部分找到所需的正确片段。而如果用组合式 API 重构这个组件, 现在与同一个逻辑关注点相关的代码被归为了一组:我们无需再为了一个逻辑关注点在不同的选项块间来回滚动切换。此外,我们现在可以很轻松地将这一组代码移动到一个外部文件中,不再需要为了抽象而重新组织代码,大大降低了重构成本,这在长期维护的大型项目中非常关键。

组合式 API 不像选项式 API 那样会手把手教你该把代码放在哪里。但反过来,它却让你可以像编写普通的 JavaScript 那样来编写组件代码。这意味着你能够,并且应该在写组合式 API 的代码时也运用上所有普通 JavaScript 代码组织的最佳实践。如果你可以编写组织良好的 JavaScript,你也应该有能力编写组织良好的组合式 API 代码。

选项式 API 确实允许你在编写组件代码时“少思考”,这是许多用户喜欢它的原因。然而,在减少费神思考的同时,它也将你锁定在规定的代码组织模式中,没有摆脱的余地,这会导致在更大规模的项目中难以进行重构或提高代码质量。在这方面,组合式 API 提供了更好的长期可维护性。

  1. 更好的类型推导: 组合式 API 主要利用基本的变量和函数,它们本身就是类型友好的。用组合式 API 重写的代码可以享受到完整的类型推导,不需要书写太多类型标注。大多数时候,用 TypeScript 书写的组合式 API 代码和用 JavaScript 写都差不太多!这也让许多纯 JavaScript 用户也能从 IDE 中享受到部分类型推导功能。

  2. 更小的生产包体积​: 搭配 <script setup> 使用组合式 API 比等价情况下的选项式 API 更高效,对代码压缩也更友好。这是由于 <script setup> 形式书写的组件模板被编译为了一个内联函数,和 <script setup> 中的代码位于同一作用域。不像选项式 API 需要依赖 this 上下文对象访问属性,被编译的模板可以直接访问 <script setup> 中定义的变量,无需从实例中代理。这对代码压缩更友好,因为本地变量的名字可以被压缩,但对象的属性名则不能。

3. 组合式 APIReact Hooks 的关系?

组合式 API 提供了和 React Hooks 相同级别的逻辑组织能力,但它们之间有着一些重要的区别。

React Hooks 在组件每次更新时都会重新调用。这就产生了一些即使是经验丰富的 React 开发者也会感到困惑的问题。这也带来了一些性能问题,并且相当影响开发体验。例如:

  1. Hooks 有严格的调用顺序,并不可以写在条件分支中。

  2. React 组件中定义的变量会被一个钩子函数闭包捕获,若开发者传递了错误的依赖数组,它会变得过期。这导致了 React 开发者非常依赖 ESLint 规则以确保传递了正确的依赖,然而,这些规则往往不够智能,保持正确的代价过高,在一些边缘情况时会遇到令人头疼的、不必要的报错信息。

  3. 昂贵的计算需要使用 useMemo,这也需要传入正确的依赖数组。

  4. 在默认情况下,传递给子组件的事件处理函数会导致子组件进行不必要的更新。子组件默认更新,并需要显式的调用 useCallback 作优化。这个优化同样需要正确的依赖数组,并且几乎在任何时候都需要。忽视这一点会导致默认情况下对应用进行过度渲染,并可能在不知不觉中导致性能问题。

  5. 要解决变量闭包导致的问题,再结合并发功能,使得很难推理出一段钩子代码是什么时候运行的,并且很不好处理需要在多次渲染间保持引用 (通过 useRef) 的可变状态。

相比起来,Vue 的组合式 API:

  1. 仅调用 setup()<script setup> 的代码一次。这使得代码更符合日常 JavaScript 的直觉,不需要担心闭包变量的问题。组合式 API 也并不限制调用顺序,还可以有条件地进行调用。

  2. Vue 的响应性系统运行时会自动收集计算属性和侦听器的依赖,因此无需手动声明依赖。

  3. 无需手动缓存回调函数来避免不必要的组件更新。Vue 细粒度的响应性系统能够确保在绝大部分情况下组件仅执行必要的更新。对 Vue 开发者来说几乎不怎么需要对子组件更新进行手动优化。

我们承认 React Hooks 的创造性,它是组合式 API 的一个主要灵感来源。然而,它的设计也确实存在上面提到的问题,而 Vue 的响应性模型恰好提供了一种解决这些问题的方法。

3.3 TreeShaking

Tree Shaking 是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫 Dead code elimination。简单来讲,就是在保持代码运行结果不变的前提下,找出使用的代码, 去除无用的代码, 进而减小程序运行时间和程序打包体积。

**Vue3**源码引入 Tree Shaking 特性,将全局 API 进行分块。如果您不使用其某些功能,它们将不会包含在您的基础包中。

Tree shaking是基于ES6模板语法(importexports),主要是借助ES6模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量。Tree shaking 无非就是做了两件事:

  1. 编译阶段利用ES6 Module判断哪些模块已经加载

  2. 判断那些模块和变量未被使用或者引用,进而删除对应代码

3.4 编译阶段 动态标记

编译优化 是编译器将模版编译为渲染函数的过程中, 尽可能多的提取关键信息, 并以此指导生成最优代码的过程。Vue.js 3.0compile 编译器会将编译时得到的关键信息附着在它生成的虚拟DOM上,这些信息会通过虚拟DOM传递给渲染器, 最终渲染器会根据这些关键信息执行快捷路径, 从而提升运行时的性能。

在编译器优化阶段, 提取的关键信息会影响最终生成的渲染函数代码, 具体体现在用于创建虚拟 DOM 节点的辅助函数上。比如:

<div id="foo">
<p class="bar"> {{text}} </p>
</div>

编译器会对模版进行编译优化, 会生成带有补丁标志的渲染函数, 如下所示:

render(){
return createVNode('div',{id: 'foo'},[
createVNode('p',{class: 'bar'},text,patchFlags.TEXT)
])
}

patchFlags.TEXT补丁标志, 表示当前虚拟节点是一个动态节点, 并且动态因子元素是: 具有动态的文本子节点。 在编译优化中, 用来描述节点信息的虚拟节点拥有一个额外的属性, 即 patchFlag, 它的值是一个数字。只要虚拟节点存在该属性, 我们就认为它是一个动态节点。所以 patchFlag 也是一个补丁标记。补丁标记 根据数字值的不同赋予它不同的含义:

  • 1 表示节点具有动态的文本

  • 1 << 1: 表示节点具有动态的 class 绑定

  • 1 << 2: 表示节点具有动态的 style 绑定

  • 1 << 3: 表示节点具有动态的 props 属性

  • ……

基于 patchFlag 属性, 在创建虚拟节点阶段, 把它的动态节点提取出来, 并将其存入到该虚拟节点的 dyamicChildren 数组内。我们把带有 dyamicChildren 属性的虚拟节点称为BlockBlock 不仅能够收集它的直接动态子节点, 还能够收集所有动态子代节点。有了 Block 之后, 会忽略虚拟节点的 children 数组, 而是直接找到该虚拟节点的 dynamicChildren 数组, 只更新该数组中的动态节点。这样,在更新时就实现了跳过静态内容, 只更新动态内容。同时, 由于动态节点中存在对应的补丁标志, 所以在更新动态节点时, 也能够做到靶向更新。例如: 当一个动态节点的 patchFlag 值为数字 1 时, 我们知道它只存在动态的文本节点, 所以只需要更新它的文本内容即可。

优化表现一: 渲染器更新标签节点时, 使用 patchChildren 函数更新标签子节点, 优先检测是否存在 dynamicChildren 动态节点集合, 如果存在, 调用 patchBlockChildren 函数对比 dynamicChildren 动态子节点完成更新。 这样渲染函数只会更新动态节点, 而跳过所有的静态节点。

优化表现二: 动态节点存在对应的补丁标志, 因此我们可以针对性的完成靶向更新, 避免全量的 props 更新, 从而最大化的提升性能。

if(n2.patchFlags){
if(n2.patchFlags === 1){
// 只需要更新 class
}else if(n2.patchFlags === 2){
// 只需要更新 style
} else if(){

}
}else {
// 全量更新
}

3.5 编译阶段 静态提升

静态提升 能够减少更新时创建 虚拟DOM 带来的性能开销和内存占用。如下所示:

没有静态提升的情况下, 对应的渲染函数是:

function render(){
return (openBlock(), createBlock('div',null,[
createVNode('p',null,'static text'),
createVNode('p',null,ctx.title,1)
]))
}

可以看到, 上述 虚拟DOM 中存在两个 p 标签, 一个是纯静态的, 而另一个是拥有动态文本。当响应式数据 title 的值发生变化时, 整个渲染函数会重新执行, 并产生新的 虚拟DOM。这个过程有一个很明显的问题,即纯静态的虚拟节点在更新时也会被重新创建一次。 很显然, 这是没有必要的。因此, 我们需要想办法避免由此带来的性能开销,解决方案就是静态提升,即把纯静态的节点提升到渲染函数之外。

const hoist1 = createVNode('p',null,'static text');

function render(){
return (openBlock(), createBlock('div',null,[
hoist1,
createVNode('p',null,ctx.title,1)
]))
}

可以看到, 当把纯静态节点提升到渲染函数之外后, 在渲染函数内部只会持有对静态节点的引用。当响应式数据发生变化,并使得渲染函数重新执行时,并不会重新创建静态的虚拟节点,从而避免了额外的性能开销。

另外, 虽然包含动态绑定的节点本身不会被提升, 但是该动态节点上仍然可能存在纯静态的属性, 同样可以将纯静态的 props 提升到渲染函数之外。, 这样同样可以减少创建虚拟 DOM 产生的开销以及内存占用。

3.6 编译阶段 预字符串化

预字符串化 是基于静态提升的一种优化策略。预字符串化 可以将静态提升中提升出来的静态节点序列化为字符串, 并生成一个 Static 类型的 VNode。如下所示:

<div>
<p></p>
<p></p>
<p></p>
<p></p>
</div>

静态提升

const hoist1 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist2 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist3 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist4 = createVNode('p',null,null,PatchFlags.HOISTED);

render(){
return (openBlock(),createBlock('div',null,[
hoist1,hoist2,hoist3,hoist4
]))
}

预字符串化

const hoistStatic = createStaticVNode('<p></p><p></p><p></p><p></p><p></p>');

render(){
return (openBlock(),createBlock('div',null,[hoistStatic]))
}

预字符串化 有以下几大优势:

  1. 大块的静态内容可以通过 innerHTML 进行设置, 在性能上具有一定优势

  2. 减少创建虚拟节点产生的性能开销

  3. 减少内存占用

3.7 编译阶段 内联函数缓存

缓存内联事件处理函数 可以避免不必要的更新。如下所示:

<Comp @change=" a+b " >

对于这样的模版, 编译器会为其创建一个内联事件处理函数,如下所示:

function render(ctx){
return h(Comp,{
onChange: ()=> (ctx.a + ctx.b)
});
}

很显然, 每次重新渲染时(即 render 重新执行时), 都会为 Comp 组件创建一个全新的 props 对象。同时, props 对象中 onChange 属性的值也会是全新的函数。这会导致渲染器对 Comp 组件进行更新, 造成额外的性能开销。为了避免无用的更新, 我们需要对内联事件处理函数进行缓存, 如下所示:

function render(ctx,cache){
return h(Comp,{
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}

渲染函数的第二个参数是一个数组 cache, 该数组来自组件实例, 我们可以把内联事件处理函数添加到 cache 数组中。 这样,当渲染函数重新执行并创建新的虚拟DOM树时, 会优先读取缓存中的事件处理函数。这样, 无论执行多少次渲染函数, props 对象中的 onChange 属性的值始终不变, 于是就不会触发 Comp 组件更新了。

3.8 Virtual DOM Diff 静态标记

Vue3Diff 算法中相比 Vue2 增加了静态标记, 已标记静态节点的节点不会参与比较, 进一步提高性能。

3.9 Virtual DOM Diff 算法优化

  • Vue3 没有了 Vue2 的新老首尾节点进行比较,只是从两组节点的开头和结尾进行比较,然后往中间靠拢,那么 Vue3 在进行新老节点的开始和结尾比对的时候,都没有比对成功,接下来就进行中间部分的比较,先把老节点处理成 key - valueMap 数据结构,然后又使用最长递增子序列算法找出其中的稳定序列部分,然再对新节点进行循环比对

  • Diff 的时候,Vue2 是判断如果是静态节点则跳过过循环对比,而 Vue3 则是把整个静态节点进行提升处理,Diff 的时候是不过进入循环的,所以 Vue3Vue2Diff 性能更高效。