版本
一、vue.js 1.0
1.1 响应式
通过 Object.defineProperty
: 负责数据的拦截。getter
时进行依赖收集,setter
时让 dep
通知 watcher
去更新。Dep
: Vue data
选项返回的对象,对象的 key
和 dep
一一对应。Watcher
: key
和 watcher
是一对多的关系,组件模版中每使用一次 key
就会生成一个 watcher
。当数据更新时,dep
通知 watcher
去直接更新 DOM
,因为这个版本的 watcher
和 DOM
时一一对应关系,watcher
可以非常明确的知道这个 key
在组件模版中的位置,因此可以做到定向更新,所以它的更新效率是非常高的。
虽然更新效率高,但随之也产生了严重的问题,无法完成一个企业级应用,理由很简单:当你的页面足够复杂时,会包含很多的组件,在这种架构下就意味这一个页面会产生大量的 watcher
,这非常耗资源。
二、vue.js 2.0
2.1 响应式
Vue 2.0
中通过引入 VNode
和 diff
算法去解决 1.x
中的问题。将 watcher
的粒度放大,变成一个组件一个 watcher
(就是我们说的渲染 watcher
),这时候你页面再大,watcher
也很少,这就解决了复杂页面 watcher
太多导致性能下降的问题。
在组件渲染的过程中, 用到的数据属性会触发 getter
, getter
内部会收集依赖。当依赖发生改变,触发 setter
,则会通知watcher
,从而使关联的组件重新渲染。这时候问题就来了,Vue 1.x
中 watcher
和 key
一一对应,可以明确知道去更新什么地方,但是 Vue 2.0
中 watcher
对应的是一整个组件,更新的数据在组件的的什么位置,watcher
并不知道。这时候就需要 VNode
出来解决问题。
通过引入 VNode
,当组件中数据更新时,会为组件生成一个新的 VNode
,通过比对新老两个 VNode
,找出不一样的地方,然后执行 DOM
操作更新发生变化的节点,这个过程就是大家熟知的 diff
。
Vue
响应式的实现过程如下:
-
Vue
初始化的过程中, 调用initState
初始化响应式数据, 调用observe
为data
添加响应性 -
遍历
data
对象所有属性, 调用defineReactive
为每个属性添加响应性:-
为每个属性实例化
Dep
-
每个属性继续调用
observe
尝试为后代属性添加响应性,observe
函数中会判断属性类型, 只有对象或者数组才会继续添加响应性-
如果
data[xx]
为对象: 继续循环遍历data[xx]
对象中的所有属性, 递归 -
如果
data[xx]
为数组: 重写data[xx]
中的push
、pop
、shift
、unshift
、splice
、sort
、reverse
等七个可以原地改变数组的方法, 然后调用observeArray
遍历数组, 为每个元素调用observe
添加响应性
-
-
为每个属性通过
defineProperty
添加setter/getter
拦截函数, 后续在访问或者设置值时可以拦截
-
-
访问数据, 触发
getter
函数: 如果当前Watcher
存在的话, 调用dep.depend()
进行依赖收集, 当前Watcher
的newDeps
存储当前dep
, 当前dep
的subs
存储当前Watcher
-
设置数据值, 触发
setter
函数: 调用dep.notify()
, 循环遍历subs
中的所有Watcher
, 执行Watcher
的update
方法。 -
在
update
方法中, 会将此时的Watcher
加入到渲染队列queue
, 通过nextTick
进行批量更新渲染 -
在
nextTick
中,通过promise.then(flushCallbacks)
将批量更新任务放到了微任务队列, 依次执行任务队列中的任务, 每一个任务就是一个Watcher
, 开始执行每一个Watcher
的run
方法 -
Watcher
中的run
方法调用Watcher
中的get
方法,get
方法调用Watcher
中的getter
函数, 此时的getter
函数就是updateComponent
, 用于初始或者更新渲染
2.2 Virtual DOM
Vue2
引入了 VNode
和 diff
算法,将组件 编译 成 VNode
,每次响应式数据发生变化时,会生成新的 VNode
,通过 diff
算法对比新旧 VNode
,找出其中发生改变的地方,然后执行对应的 DOM
操作完成更新。
三、vue.js 3.0
3.1 响应式
Vue3.0
针对 Vue2.0
所做的响应式优化: Vue2.0
使用的是 Object.defineProperty
来劫持整个对象,然后进行深度遍历所有属性,给每个属性添加 getter
和 setter
,实现响应式,因此在嵌套层级比较深时, 一次性递归执行, 把所有子对象变成响应式。但是它不能监听对象属性的新增和删除,所以需要使用 $set、$delete
这种语法糖去实现,这其实是一种设计上的不足。所以 Vue3.0
采用了 Proxy
重写了响应式系统, 可以对整个对象进行监听,所以不需要深度遍历, 在访问到嵌套层级较深的子对象时, 才会将他变成响应式。可以监听动态属性的添加、删除;可以监听到数组的索引和数组 length
属性。
当然 Proxy
不兼容 IE
, 也没有 polyfill
。Object.defineProperty
可以支持到 IE9
。
在 Vue.js 3.0
中, ref
对原始值 Boolean
、Number
、BigInt
、String
、Symbol
、undefined
和 null
通过访问器 get
和设置器 set
实现响应式。注意: Proxy
无法代理原始值。监听策略如下:
-
通过
get
访问器来收集该值对应副作用函数 -
通过
set
设置器来触发该值对应副作用函数
Vue.js 3.0
reactive
对非原始值 Object
、Array
、Set
、Map
基于 Proxy
实现响应式。通过 Proxy
监听整个对象, 监听策略如下:
-
通过
get
处理器监听属性的访问, 收集对应属性相关副作用 -
通过
set
处理器监听属性的变更, 触发对应属性相关副作用函数执行 -
通过
has
处理器来监听xx in yy
操作符, 收集对应属性相关副作用 -
通过
ownKeys
处理器来监听for in
、for of
遍历操作, 收集ITERATE_KEY
相关的副作用。因为ownKeys
只有一个target
参数, 没有key
, 对于对象来说需要一个ITERATE_KEY
来充当key
, 后续之后新增属性时, 才会触发副作用函数执行, 已有属性不触发。对于数组而言, 无论是为数组添加新元素还是直接修改数组的长度, 本质上都是因为修改了数组的length
属性。 一旦数组的length
被修改, 那么for…in
、for……of
循环对数组的遍历结果就会改变。 -
通过
deleteProperty
处理器来监听属性删除操作, 触发对应属性副作用函数重新执行 -
对于
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
-
更好的逻辑复用: 组合式 API 最基本的优势是它使我们能够通过组合函数来实现更加简洁高效的逻辑复用。在选项式 API 中我们主要的逻辑复用机制是 mixins,而组合式 API 解决了 mixins 的所有缺陷。
-
更灵活的代码组织: 许多用户喜欢选项式 API 的原因是它在默认情况下就能够让人写出有组织的代码: 大部分代码都自然地被放进了对应的选项里。然而,选项式 API 在单个组件的逻辑复杂到一定程度时,会面临一些无法忽视的限制。这些限制主要体现在需要处理多个逻辑关注点的组件中。处理相同逻辑关注点的代码被强制拆分在了不同的选项中,位于文件的不同部分。在一个几百行的大组件中,要读懂代码中的一个逻辑关注点,需要在文件中反复上下滚动,这并不理想。另外,如果我们想要将一个逻辑关注点抽取重构到一个可复用的工具函数中,需要从文件的多个不同部分找到所需的正确片段。而如果用组合式 API 重构这个组件, 现在与同一个逻辑关注点相关的代码被归为了一组:我们无需再为了一个逻辑关注点在不同的选项块间来回滚动切换。此外,我们现在可以很轻松地将这一组代码移动到一个外部文件中,不再需要为了抽象而重新组织代码,大大降低了重构成本,这在长期维护的大型项目中非常关键。
组合式 API 不像选项式 API 那样会手把手教你该把代码放在哪里。但反过来,它却让你可以像编写普通的 JavaScript 那样来编写组件代码。这意味着你能够,并且应该在写组合式 API 的代码时也运用上所有普通 JavaScript 代码组织的最佳实践。如果你可以编写组织良好的 JavaScript,你也应该有能力编写组织良好的组合式 API 代码。
选项式 API 确实允许你在编写组件代码时“少思考”,这是许多用户喜欢它的原因。然而,在减少费神思考的同时,它也将你锁定在规定的代码组织模式中,没有摆脱的余地,这会导致在更大规模的项目中难以进行重构或提高代码质量。在这方面,组合式 API 提供了更好的长期可维护性。
-
更好的类型推导: 组合式 API 主要利用基本的变量和函数,它们本身就是类型友好的。用组合式 API 重写的代码可以享受到完整的类型推导,不需要书写太多类型标注。大多数时候,用 TypeScript 书写的组合式 API 代码和用 JavaScript 写都差不太多!这也让许多纯 JavaScript 用户也能从 IDE 中享受到部分类型推导功能。
-
更小的生产包体积: 搭配
<script setup>
使用组合式API
比等价情况下的选项式API
更高效,对代码压缩也更友好。这是由于<script setup>
形式书写的组件模板被编译为了一个内联函数,和<script setup>
中的代码位于同一作用域。不像选项式API
需要依赖this
上下文对象访问属性,被编译的模板可以直接访问<script setup>
中定义的变量,无需从实例中代理。这对代码压缩更友好,因为本地变量的名字可以被压缩,但对象的属性名则不能。
3. 组合式 API
与 React Hooks
的关系?
组合式 API
提供了和 React Hooks
相同级别的逻辑组织能力,但它们之间有着一些重要的区别。
React Hooks
在组件每次更新时都会重新调用。这就产生了一些即使是经验丰富的 React
开发者也会感到困惑的问题。这也带来了一些性能问题,并且相当影响开发体验。例如:
-
Hooks
有严格的调用顺序,并不可以写在条件分支中。 -
React
组件中定义的变量会被一个钩子函数闭包捕获,若开发者传递了错误的依赖数组,它会变得过期。这导致了React
开发者非常依赖ESLint
规则以确保传递了正确的依赖,然而,这些规则往往不够智能,保持正确的代价过高,在一些边缘情况时会遇到令人头疼的、不必要的报错信息。 -
昂贵的计算需要使用
useMemo
,这也需要传入正确的依赖数组。 -
在默认情况下,传递给子组件的事件处理函数会导致子组件进行不必要的更新。子组件默认更新,并需要显式的调用
useCallback
作优化。这个优化同样需要正确的依赖数组,并且几乎在任何时候都需要。忽视这一点会导致默认情况下对应用进行过度渲染,并可能在不知不觉中导致性能问题。 -
要解决变量闭包导致的问题,再结合并发功能,使得很难推理出一段钩子代码是什么时候运行的,并且很不好处理需要在多次渲染间保持引用 (通过
useRef
) 的可变状态。
相比起来,Vue
的组合式 API
:
-
仅调用
setup()
或<script setup>
的代码一次。这使得代码更符合日常JavaScript
的直觉,不需要担心闭包变量的问题。组合式API
也并不限制调用顺序,还可以有条件地进行调用。 -
Vue
的响应性系统运行时会自动收集计算属性和侦听器的依赖,因此无需手动声明依赖。 -
无需手动缓存回调函数来避免不必要的组件更新。
Vue
细粒度的响应性系统能够确保在绝大部分情况下组件仅执行必要的更新。对Vue
开发者来说几乎不怎么需要对子组件更新进行手动优化。
我们承认 React Hooks
的创造性,它是组合式 API
的一个主要灵感来源。然而,它的设计也确实存在上面提到的问题,而 Vue
的响应性模型恰好提供了一种解决这些问题的方法。
3.3 TreeShaking
Tree Shaking
是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫 Dead code elimination
。简单来讲,就是在保持代码运行结果不变的前提下,找出使用的代码, 去除无用的代码, 进而减小程序运行时间和程序打包体积。
**Vue3
**源码引入 Tree Shaking
特性,将全局 API
进行分块。如果您不使用其某些功能,它们将不会包含在您的基础包中。
Tree shaking
是基于ES6
模板语法(import
与exports
),主要是借助ES6
模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量。Tree shaking
无非就是做了两件事:
-
编译阶段利用
ES6 Module
判断哪些模块已经加载 -
判断那些模块和变量未被使用或者引用,进而删除对应代码
3.4 编译阶段 动态标记
编译优化 是编译器将模版编译为渲染函数的过程中, 尽可能多的提取关键信息, 并以此指导生成最优代码的过程。Vue.js 3.0
的 compile
编译器会将编译时得到的关键信息附着在它生成的虚拟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
属性的虚拟节点称为块
即 Block
。Block
不仅能够收集它的直接动态子节点, 还能够收集所有动态子代节点。有了 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]))
}
预字符串化 有以下几大优势:
-
大块的静态内容可以通过
innerHTML
进行设置, 在性能上具有一定优势 -
减少创建虚拟节点产生的性能开销
-
减少内存占用
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 静态标记
Vue3
在 Diff
算法中相比 Vue2
增加了静态标记, 已标记静态节点的节点不会参与比较, 进一步提高性能。
3.9 Virtual DOM Diff 算法优化
-
Vue3
没有了Vue2
的新老首尾节点进行比较,只是从两组节点的开头和结尾进行比较,然后往中间靠拢,那么Vue3
在进行新老节点的开始和结尾比对的时候,都没有比对成功,接下来就进行中间部分的比较,先把老节点处理成key - value
的Map
数据结构,然后又使用最长递增子序列算法找出其中的稳定序列部分,然再对新节点进行循环比对 -
在
Diff
的时候,Vue2
是判断如果是静态节点则跳过过循环对比,而Vue3
则是把整个静态节点进行提升处理,Diff
的时候是不过进入循环的,所以Vue3
比Vue2
的Diff
性能更高效。