本文目录
[[toc]]
与 Vue2 的差异
mount
Vue2 mount 的时候,是会将 target 节点替换掉, Vue3 则是作为子元素插入,或者说是替换了 innerHTML
例如 HTML 结构如下:
<body>
<div id="app">
Some app content
</div>
</body>挂载的代码如下:
const app = new Vue({
data() {
return {
message: 'Hello Vue!'
}
},
template: `
<div id="rendered">{{ message }}</div>
`
})
app.$mount('#app')Vue2 执行结果为:
<body>
<div id="rendered">Hello Vue!</div>
</body>Vue3 执行结果为:
<body>
<div id="app" data-v-app="">
<div id="rendered">Hello Vue!</div>
</div>
</body>minix
Vue2 mixin 合并 data 时,会将 data 对象 deep merge ,但是 Vue3 只会进行 shallow merge ,例如:
const Mixin = {
data() {
return {
user: {
name: 'Jack',
id: 1
}
}
}
}
const CompA = {
mixins: [Mixin],
data() {
return {
user: {
id: 2
}
}
}
}Vue2 中, CompA 的 $data 为:
{
"user": {
"id": 2,
"name": "Jack"
}
}Vue3 中, CompA 的 $data 为:
{
"user": {
"id": 2
}
}props
Vue2 中,定义 props 的 default 字段时,可以使用 this 访问组件实例对象。
Vue3 中不再支持 this 访问,而是会将完整的原始 props 传入 default 函数中,并且支持使用 inject 为 props 默认值
import { inject } from 'vue'
export default {
props: {
theme: {
default (props) {
// `props` 是传递给组件的、
// 在任何类型/默认强制转换之前的原始值,
// 也可以使用 `inject` 来访问注入的 property
return inject('theme', 'default-theme')
}
}
}
}directive
Vue3 中修改了 directive 的生命周期,基本与组件生命周期相同,除了没有 setup 与 beforeCreate
const MyDirective = {
created(el, binding, vnode, prevVnode) {}, // 新增
beforeMount() {},
mounted() {},
beforeUpdate() {}, // 新增
updated() {},
beforeUnmount() {}, // 新增
unmounted() {}
}生命周期事件名
Vue2 中生命周期事件可以使用类似 @hook:updated 监听, Vue3 中修改为 @vue:updated
v-if / v-for
Vue2 中, v-for 比 v-if 优先级高, Vue3 中 v-if 比 v-for 高。
v-bind
Vue2 中, v-bind 中的属性优先级较低,会被单独定义的同名属性覆盖,例如:
<!-- 模板 -->
<div id="red" v-bind="{ id: 'blue' }"></div>
<!-- 结果 -->
<div id="red"></div>Vue3 中, v-bind 属性与单独定义的同名属性,优先级会按照定义顺序来决定,例如:
<!-- 模板 -->
<div id="red" v-bind="{ id: 'blue' }"></div>
<!-- 结果 -->
<div id="blue"></div>
<!-- 模板 -->
<div v-bind="{ id: 'blue' }" id="red"></div>
<!-- 结果 -->
<div id="red"></div>ref
ref 字段名称
Vue2 中, ref 定义的名称,将会直接挂载到 $refs 中,不会被改变( vue-class 等 alias 写法不考虑 )
Vue3 中, ref 可以是字段名,也可以是函数,传递函数时可以将参数中的 ref 对象赋值给任意 ref
ref 组件实例
Vue2 中可以拿到完整的组件 data 、 computed 、 methods
Vue3 只能拿到 expose 出来的或者默认导出的内容,比如 Options API ,并且 Options API 的内容还需要通过 $data 、 $methods 等字段间接访问,而不是平铺到组件上。
v-for 中使用 ref
Vue2 在 v-for 中使用 ref ,会将 ref 修改为数组,并且顺序与渲染的组件顺序一致。
Vue3 需要到 3.5 才支持渲染为数组,并且不保证 ref 中元素顺序与 v-for 列表顺序保持一致,如果需要实现 Vue2 效果,需要通过函数 ref 手动赋值实现。
响应原理
Vue2 方案存在的问题
- 检测不到对象属性的添加与删除
- 数组 API 难以扩展与覆盖
- 每层对象都需要遍历监听,深层嵌套存在性能风险
- 响应功能与 Vue 强耦合,不能脱离 Vue 运行(没有 Vue 就没有
scope可以运行响应系统)
Vue3 中的实现
抽象来说就是如下伪代码:
const proxyHandler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key) // 依赖收集
return isObject(result) ? reactive(result) : result // 懒处理嵌套对象
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (hasChanged(value, oldValue)) {
trigger(target, key) // 触发更新
}
return result
}
}track: 在属性被访问时(如模板渲染或计算属性计算),将当前依赖(如组件)记录到依赖列表。trigger: 当属性被修改时,通知所有相关依赖进行更新。
Vue3 方案的优化
- 整个对象代理,不需要逐个属性遍历劫持
- 新增、删除属性自动检测,无需通过额外函数处理
- 支持响应数组、
Set、Map等。 - 性能提升,初始化不需要递归遍历所有字段,只需要在访问时处理即可。
- 独立架构,不依赖 Vue 实例,可以独立运行。
参考资料
性能提升
Block Tree 与 PatchFlags
使用 PatchFlags 标记节点类型,如:
- 包含动态 text
- 包含动态 class
通过预设,对节点进行靶向更新,不需要对比静态属性是否更新。
同时,可以根据 PatchFlags 区分出静态节点,不需要进行 diff 。
针对所有的动态节点,可以单独提升到同一层级存储起来。
这样在更新 VNode 的时候,就知道哪些节点会更新什么属性,从而进行靶向更新。
存储动态节点的节点,称为 Block ,由多个 Block 嵌套可以组成一个树,也就是 Block Tree
整个 Block 的结构如下:
const block = {
tag: 'div',
dynamicChildren: [
{ tag: 'section', { key: 0 }, dynamicChildren: [...]}, /* Block(Section v-if) */
{ tag: 'section', { key: 1 }, dynamicChildren: [...]} /* Block(Section v-else) */
]
}静态提升
介绍
以如下 HTML 为例:
<div>
<p>text</p>
</div>在没有开启 hoistStatic 选项的情况下,编译出来的 render 函数如下:
function render() {
return (
openBlock(),
createBlock(
'div',
null,
[createVNode('p', null, 'text')],
)
)
}从 html 结构来看, p 以及其内容显然是静态的,所以可以将整个静态节点进行缓存,也就是如下方案:
const host1 = createVNode('p', null, 'text')
function render() {
return (
openBlock(),
createBlock(
'div',
null,
[host1],
)
)
}这么做的好处是:减少了 VNode 创建的开销,如果纯静态的代码,除了根节点之外都会被提升。
<div>
<!-- section 不会被提升,因为内部含有 dynamicText -->
<section>
{{ dynamicText }}
<!-- p 会被提升, p 以及后代都是静态的 -->
<p>
<span>abc</span>
</p>
</section>
</div>不会被静态提升的情况
- 带有动态
key: 如<div :key="foo"></div>,只要含有动态key的节点,会被打上NEED_PATCH,永远需要加入Diff。 - 使用
ref的元素:ref可以获取元素实例,也就是会影响到state,可能会在不同的渲染阶段赋值给不同的ref变量 - 使用自定义指令的元素(除 v-pre/v-cloak 之外): 与
key一样,会被打上NEED_PATCH标记,永远需要提升。
静态属性提升
既然 DOM 可以提升,那么 DOM 上的 props 也是可以提升的,如以下 template:
<div>
<p foo="bar" a="b">{{ text }}</p>
</div>虽然 p 不能被提升,但是属性全是静态属性,所以提升后渲染函数如下:
const hoistProp = { foo: 'bar', a: 'b' }
function render(ctx) {
return ([
openBlock(),
createBlock(
'div',
null,
[createVNode('p', hoistProp, ctx.text)],
),
])
}再细粒度的化,就是字符串提升,比如:
<p :foo="10" :bar="'abc' + 'def'">{{ text }}</p>'abc' + 'def' 是常量,也是可以提升的,不用每次都创建两个字符串再拼接成新的字符串。
预字符串化
如果存在大量连续的静态标签,可以进行更进一步的提升,比如:
<div>
<p></p>
<p></p>
...20 个 p 标签
<p></p>
</div>按照静态提升后是
const hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
const hoist2 = createVNode('p', null, null, PatchFlags.HOISTED)
// ... 20 个 hoistx 变量
const hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)
function render() {
return ([
openBlock(),
createBlock(
'div',
null,
[
hoist1,
hoist2, // ...20 个变量, hoist20
]
)
])
}而预字符串化会将节点合并为 Static 类型的 VNode ,也就是:
const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20个...<p></p>')
function render() {
return ([
openBlock(),
createBlock(
'div',
null,
[
hoistStatic
]
)
])
}预字符串化有以下优势:
- 减少生成的代码体积
- 减少创建
VNode开销 - 减少内存占用
静态节点在运行时会通过 innerHTML 来创建真实节点,因此并非所有静态节点都是可以预字符串化的,可以预字符串化的静态节点需要满足以下条件:
- 非表格类的标签
- 标签属性必须是标准 HTML 属性或者
data-/aria-属性 - 无属性静态节点必须连续 20+ 才会触发预字符串化
- 有属性节点需要连续出现 5+ 才会触发预字符串化
- 连续的静态节点不一定是兄弟,可以是父子,只要阈值满足即可。
- 如果是动态绑定常量,会在预字符串化的时候计算好常量值,比如
<div :id="'a-' + 1"></div>会自动计算为<div id="a-1"></div>
Cache Event handler
简单来说,如果组件绑定了一个函数,不管是事件还是属性,最好不要单独传箭头函数,而是定义一个新的具名函数出来,比如:
<template>
<Comp @change="a + b" />
</template>将会被转为如下结构:
export default {
props: {
a: {
type: Number,
default: 0,
},
b: {
type: Number,
default: 0,
},
},
render(ctx) {
return h(Comp, {
onChange: () => (ctx.a + ctx.b)
})
}
}这样每次 props.a 、 props.b 更新的时候,就会导致 onChange 属性更新,那么 Comp 也会随着一起 update 。
而 Cache Event handler 就是解决这个问题的,会在组件中维护一个 cache 表,上述代码会被修改为:
function render(ctx, cache) {
return h(Comp, {
onChange: cache[0] || (cache[0] = $event => (ctx.a + ctx.b))
})
}这样即使多次调用 render ,也不会重复触发 Comp 的 update
v-once
参考 Block Tree 部分, v-once 会被直接提升为静态节点,不会再次渲染,也不会收集到 Block 中参与 Diff 。
参考资料
体积优化
tree-shaking
Vue3 中为全局 API 单独拆分为独立文件,如果没有引入对应的函数,在最终产物中会自动删除对应的 API 。
比如项目中没有使用 computed ,那么最终构建出来的运行时 Vue 将不包含 computed 函数的实现。
而 Vue2 中所有功能与 VNode 强耦合,内部已经互相引用了,没有更底层的抽象,所以无法进行 tree-shaking
特性开关
Vue3 提供了部分特性开关,比如 options API 如果没有使用,可以通过特性开关关闭,这样构建的时候 Vue 运行时也不会包含 options API 所有代码。
diff 算法
底层的数组 diff 由 双端 diff 修改为 快速 diff
快速 diff 的处理步骤如下:
---
title: 快速 diff 工作流程
---
graph TD
subgraph B[预处理]
B1[从头部开始对比] --> B2[从尾部开始对比]
B2 --> B3[记录相同节点]
end
subgraph E[查找最长递增子序列]
E1[构建新节点索引] --> E2[计算最长递增子序列]
E2 --> E3[确定需要移动的节点]
end
subgraph F[更新操作]
F1[移动节点] --> F2[添加新节点]
F2 --> F3[删除旧节点]
end
A[开始] --> B
B --> C{是否相同?}
C -->|是| D[无需更新]
C -->|否| E
E --> F
F --> G[结束]预处理
function patchKeyedChildren(n1, n2, container) {
// 拿到两组Children节点组
const newChildren = n2.children
const oldChildren = n1.children
// 用j定义头索引
let j = 0
let oldVNode = oldChildren[j]
let newVNode = newChildren[j]
// 开始扫描头部
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数更新
patch(oldVNode, newVNode, container)
j++
oldVNode = oldChildren[j]
newVNode = newChildren[j]
}
// ========================================
// 开始处理尾部
// 由于尾部跟头部不同,它们可能不一样。因此需要定义各自的索引
let oldEnd = oldChildren.length - 1
let newEnd = newChildren.length - 1
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
// 开始扫描尾部
// while 循环向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数更新
patch(oldVNode, newVNode, container)
oldEnd--
newEnd--
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
}
// ...
}新增节点
// 满足两者则需要新增节点
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1
// 锚点元素
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
// 采用 while 循环,调用 patch 函数逐个挂载新增的节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor)
}
}删除节点
if (j > oldEnd && j <= newEnd) {
// 省略新增节点的代码
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++])
}
}diff 节点
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1
// 构造 source 数组,并默认填充-1
const source = new Array(count)
source.fill(-1)
// 索引从预处理后的j开始
const oldStart = j
const newStart = j
// moved用作标识是否需要重新排序
let moved = false
// pos记录上一个找到的节点的位置,用于辅助设置moved
let pos = 0
// keyIndx是一个缓存表,记录新节点的key和索引的关系
const keyIndex = {}
// 先把新节点全部放keyIndx
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}
// 记录从旧节点中找到匹对的次数
let patched = 0
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
if (patched < count) {
// k是从keyIndex中找到的节点在新节点组中的索引
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
patch(oldVNode, newVNode, container)
patched++
// 修改对应source中的项
source[k - newStart] = i
// 判断是否需要移动
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
// 没找到
unmount(oldVNode)
}
} else {
// 如果节点中的内容已经全都匹对过了,说明剩下的全是应该删除的
unmount(oldVNode)
}
}if (moved) {
// lis是一个排序函数把source变成一个递增数组。
const seq = lis(source)
// s 指向最长递增子序列的最后一个值
let s = seq.length - 1
let i = count - 1
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引为 i 的节点是全新的节点,应该将其挂载
// 该节点在新 children 中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length
? newChildren[nextPos].el
: null
// 挂载
patch(null, newVNode, container, anchor)
} else if (i !== seq[j]) {
// 说明该节点需要移动
// 该节点在新的一组子节点中的真实位置索引
const pos = i + newStart
const newVNode = newChildren[pos]
// 该节点下一个节点的位置索引
const nextPos = pos + 1
// 锚点
const anchor = nextPos < newChildren.length
? newChildren[nextPos].el
: null
// 移动
insert(newVNode.el, container, anchor)
} else {
// 当 i === seq[j] 时,说明该位置的节点不需要移动
// 并让 s 指向下一个位置
s--
}
}
}expose 的秘密
Component 暴露了哪些内容
根据 setupComponent 函数的实现可以发现,最终会调用 setupStatefulComponent 进行组件初始化。
但是这里只是对 Component 的初始化流程,想了解暴露哪些内容,还需要关注 ref 功能,所以通过 ref
通过反向追溯 测试用例 中的 directives 可以发现,实际上 Vue3 的 compatH 函数创建的就是原本用于 Options API 的 h 函数,继续追溯可以发现是通过 withDirectives 控制指令的,获取的组件实例本质上是 getComponentPublicInstance 获取的。
到这里可以发现,最终返回的是创建的组件对象上,使用的 exposeProxy 或者 proxy 字段。
exposeProxy 本质上只会暴露 publicPropertiesMap 上的属性,并按照指定的回调函数处理数据映射,这是其中的一部分 expose 。
另一部分与 expose 相关的 proxy 字段内容需要回到 setupStatefulComponent ,这是 proxy 字段的定义,重点关注 PublicInstanceProxyHandlers
追溯到最后,还是回到了 publicPropertiesMap ,如果有自定义的 exposed 字段(通过 expose 函数或者 defineExpose 可以更新这个值 ),则使用自定义的 exposed
ref 为什么读不到 data
参见上述的 publicPropertiesMap 部分。
如果是在 setup 函数中返回的 state ,除非在 expose 中传入,否则将不会被 proxy 获取到。
至于 Options API 定义的 data ,实际上并不是不能访问,而是需要通过 $data 去访问。
组件 API 化
以 Modal 组件为例:
// 导入组件
import Modal from './Modal.vue';
// 创建挂载容器
const container = document.createElement('div');
// 实例化 VNode 节点
const vnode = createVNode(Modal);
// 通过 render 将 VNode 挂载到容器上
render(vnode, container);
// 获取组件实例,可以用于控制组件
const instance = vnode.component;
// 将组件挂载到 body 上
document.body.appendChild(container);