前言
这篇文章的大部分内容成于去年8月。当时自己对 vue3 非常感兴趣,才有了这些整理与探索。其实内容放到今天也完全不过时,甚至在掘金上也很少看到有人写这些偏原理的东西,全都在讨论API。
自己最近在整理前端知识图谱,想到之前有过Vue3相关的整理,便拿了出来(比较懒,原封不动拿出来的,后续会整理整理,添加一些解释性的内容)
为什么要升级到Vue3
- 更小
- 核心代码 + Composition Api: 13.5kb(vue2为 31.94kb)
- 所有Runtime: 22.5kb(vue2 为32kb)
- 更快
- SSR速度提高了2~3倍
- 初始渲染/更新最高可提速一倍
- 更优
- update性能提高1.3~2倍
- 内存占用减小了一半
- 更易
- 更好的TypeScript支持
- 更多友好特性和检测
- ......
Vue3有哪些新特性
- Tree-shaking支持 ( 按需加载 )
- 静态树提升
- 静态属性提升
- 虚拟 DOM 重构
- 插槽优化
- Suspense、Fragment、Teleport
- 支持TS ( 原生Class Api 和 TSX )
- 基于 Proxy 的新数据监听系统(Composition API)
- 自定义渲染平台(Custom Render)
......
按需加载
非常用功能可以按需加载,比如:v-model, Transition等
<div>{{ msg }}</div>
<input v-model="msg" />
//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, vModelText as _vModelText, withDirectives as _withDirectives, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_withDirectives(_createVNode("input", {
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => (_ctx.msg = $event))
}, null, 512 /* NEED_PATCH */), [
[_vModelText, _ctx.msg]
])
], 64 /* STABLE_FRAGMENT */))
}
复制代码
// 在 Vue2 中,初始化一个应用
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'
const app = new Vue({
router,
store
render: h => h(App)
})
app.$mount('#app')
// 在 Vue3 中,初始化一个应用
import { createApp } from 'vue'
import App from './app'
import router from './router'
import store from './store'
createApp(App).use(router).use(store).mount('#app')
复制代码
Vue2 中通过 new 一个 Vue 实例初始化,而 Vue3 通过链式调用来创建。这样可以去做tree-shaking
,不需要的模块则不打包进去。而通过对象创建时webpack
是无法处理动态语言对象上的属性的,而且也无法对这些属性进行优化,比如通过uglify
来缩短属性名称
静态提升
<div>{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3">msg3</div>
//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "msg2" }
const _hoisted_2 = /*#__PURE__*/_createVNode("div", { class: "msg3" }, "msg3", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
_hoisted_2
], 64 /* STABLE_FRAGMENT */))
}
复制代码
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<div>{{msg}}</div>
//编译后代码
import { createVNode as _createVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span>", 10)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_hoisted_1,
_createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
复制代码
Cache Handler
<div :class="msg1">{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3" @click="msgClickHandler">msg3</div>
//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "msg2" }
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("div", { class: _ctx.msg1 }, _toDisplayString(_ctx.msg), 3 /* TEXT, CLASS */),
_createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
_createVNode("div", {
class: "msg3",
onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.msgClickHandler(...args)))
}, "msg3")
], 64 /* STABLE_FRAGMENT */))
}
复制代码
在 Vue2 中,每次更新,render
函数跑完之后 vnode
绑定的事件都是一个全新生成的function
,就算它们内部的代码是一样的
而在Vue3中传入的事件会自动生成并缓存一个内联函数在cache
里,变为一个静态节点。这样就算我们自己写内联函数,也不会导致多余的重复渲染。类似于React
中的useCallback()
享元模式:主要用于减少创建对象的数量,以减少内存占用和提高性能。这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。享元模式尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象。
Patch Flag
<div :class="msg1" :id="msg1">{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3">msg3</div>
//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { class: "msg2" }
const _hoisted_2 = /*#__PURE__*/_createVNode("div", { class: "msg3" }, "msg3", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("div", {
class: _ctx.msg1,
id: _ctx.msg1
}, _toDisplayString(_ctx.msg), 11 /* TEXT, CLASS, PROPS */, ["id"]),
_createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
_hoisted_2
], 64 /* STABLE_FRAGMENT */))
}
复制代码
const PatchFlagNames = {
// 表示具有动态 textContent 的元素
[1 /* TEXT */]: `TEXT`,
// 表示有动态 class 的元素
[2 /* CLASS */]: `CLASS`,
// 表示动态样式
[4 /* STYLE */]: `STYLE`,
// 表示具有非类/样式动态道具的元素。
[8 /* PROPS */]: `PROPS`,
// 表示带有动态键的道具的元素,与上面三种相斥
[16 /* FULL_PROPS */]: `FULL_PROPS`,
// 表示带有事件监听器的元素
[32 /* HYDRATE_EVENTS */]: `HYDRATE_EVENTS`,
// 表示其子顺序不变的片段
[64 /* STABLE_FRAGMENT */]: `STABLE_FRAGMENT`,
// 表示带有键控或部分键控子元素的片段。
[128 /* KEYED_FRAGMENT */]: `KEYED_FRAGMENT`,
// 表示带有无key绑定的片段
[256 /* UNKEYED_FRAGMENT */]: `UNKEYED_FRAGMENT`,
// 表示具有动态插槽的元素
[1024 /* DYNAMIC_SLOTS */]: `DYNAMIC_SLOTS`,
// 表示只需要非属性补丁的元素,例如ref或hooks
[512 /* NEED_PATCH */]: `NEED_PATCH`,
[-1 /* HOISTED */]: `HOISTED`,
[-2 /* BAIL */]: `BAIL`
};
复制代码
有多种 patchFlag 时进行叠加。TEXT=1, CLASS=2, PROPS=8,得到11;
之后patch
函数拿到flag
后,通过分别和1,2,4,8按位与,最后结果不为 0 表示含有该动态属性。
000000001 1 text
000000010 2 class
000000100 4 style
000001000 8 props
000001011 11
11 & 1 //true
11 & 2 //true
11 & 4 //false
11 & 8 //true
复制代码
与 React 对比
React
走了另外一条路,既然主要问题是diff
导致卡顿,于是React
走了类似 cpu 调度的逻辑,把vdom
这棵树微观变成了链表,利用浏览器的空闲时间来做diff
,如果超过了16ms,有动画或者用户交互的任务,就把主进程控制权还给浏览器,等空闲了继续。实际上是在之前用不上的时间里做了diff操作。
时间切片
浏览器每间隔一定的时间重新绘制一下当前页面。一般来说这个频率是每秒60次。也就是说每16毫秒浏览器会有一个周期性地重绘行为,这每16毫秒我们称为一帧。这一帧的时间里面浏览器的主要工作有:
- 执行JS
- 计算Style
- 构建布局模型(Layout)
- 绘制图层样式(Paint)
- 组合计算渲染呈现结果(Composite)
如果这六个步骤总时间超过 16ms 了之后,用户也许就能看到卡顿。如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务,随后再回来继续执行没有执行完的任务。
Vue3放弃了时间切片支持
React为何支持
- React的虚拟DOM操作(reconciliation )天生就比较慢
- React使用JSX来渲染函数相对较于用模板来渲染更加难以优化,模板更易于静态分析。
- React Hooks将大部分组件树级优化(即防止不必要的子组件的重新渲染)留给了开发人员,一个使用Hook的React应用在默认配置下会过度渲染
Vue3 为何放弃
- 相比 React 本质上更简单,因此虚拟DOM操作更快
- 通过分析模板进行了大量的运行前编译优化,减少了虚拟 DOM 操作的基本开销。Benchmark显示,对于一个典型的DOM代码块来说,动态与静态内容的比例大约是1:4,Vue3的原生执行速度甚至比Svelte更快,在CPU上花费的时间不到 React 的 1/10,而只有cpu 任务繁重时时间切片才有意义。
- 智能组件树级优化通过响应式跟踪,将插槽编译成函数(避免子元素重复渲染)和自动缓存内联句柄(避免内联函数重复渲染)。除非必要,否则子组件永远不需要重新渲染。这一切不需要开发人员进行任何手动优化。
- 时间切片增加了额外的复杂性,Vue 3的运行时仍然只有当前 React + React DOM 的1/4大小
- Vue3通过 Proxy 响应式 + 组件内部 vdom + 静态标记,把任务颗粒度控制的足够细致,所以也不太需要 time-slice了
- 时间切片特别解决了 React 中比其他框架更突出的问题,同时也带来了成本。对于Vue 3来说,这种权衡似乎是不值得的
插槽优化
在vue2中,当父组件数据更新的时候执行会触发重新渲染,最终执行父组件的 patch
,在 patch
过程中,遇到组件 vnode,会执行新旧 vnode
的 prepatch
,这个过程又会执行 updateChildComponent
, 如果这个子组件 vnode
有插槽,会重新执行一次子组件的 forceUpdate()
,这种情况下会触发子组件的重新渲染。简单来说,当父组件更新时,插槽会被重新渲染。vue3对这种场景进行了优化。
在Vue3中,所有由编译器生成的 slot 都将是函数形式,并且在子组件的 render 函数被调用过程中才被调用。这使得 slot 中的依赖项 将被作为子组件的依赖项,而不是现在的父组件;从而意味着:1)当 slot 的内容发生变动时,只有子组件会被重新渲染;2)当父组件重新渲染时,如果子组件的内容未发生变动,子组件就没必要重新渲染。
- 静态编译时,给一个
Component
打上一个PatchFlag
标记---是否是DynamicSlot
- 遇到有传入
slot
的组件,它的Children
不是普通的vnode
数组,而是一个slot function
的映射表,这些slot function
用于在组件中懒生成slot
中的vnodes
。 - 在子组件的
render
函数里面,调用相应的slot
生成函数,因此这个slot
函数里面的属性都会被当前的组件实例所track
。
Suspense
一个异步加载组件,抄自React
它可以在嵌套的组件树渲染到屏幕上之前,在内存中进行渲染,可以检测整颗树里面的异步依赖,只有当将整颗树的异步依赖都渲染完成之后,也就是resolve之后,才会将组件树渲染到屏幕上去。
<Suspense> is an experimental feature and its API will likely change.
复制代码
Fragment
抄自React
自动在template中增加一层虚拟节点,不再需要用根元素进行包裹
<div>{{ msg }}</div>
<div>{{ msg2 }}</div>
//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createVNode("div", null, _toDisplayString(_ctx.msg2), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
复制代码
Teleport
是一个全局组件,抄自React
中的Portal
提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。可以应用在弹窗等需要挂载到全局的组件。
<button @click="modalOpen = true">
Open full screen modal! (With teleport!)
</button>
<teleport to="body">
<div v-if="modalOpen" class="modal">
<div>
I'm a teleported modal! My parent is "body".
<button @click="modalOpen = false">
Close
</button>
</div>
</div>
</teleport>
复制代码
Composition API
命令式编程 --> 函数式编程
- 更好的逻辑复用与代码组织
- 更好的类型推导
弃用this
后通过函数式的调用方式来支持Typescript
mixin的问题:
- 命名冲突
Vue组件的默认合并策略是本地选项将覆盖mixin选项(生命周期钩子除外)。在跨多个组件和mixin处理命名属性时,编写代码变得越来越困难。一旦第三方mixin作为带有自己命名属性的npm包被添加进来,就会特别困难,因为它们可能会导致冲突。
- 来源不清晰
当有多层或多个mixin时,调用的属性来源不清晰
- 隐式依赖
mixin和使用它的组件之间没有层次关系。这意味着组件可以使用mixin中定义的数据属性,但是mixin也可以使用假定在组件中定义的数据属性。如果想重构一个组件,改变了mixin需要的变量的名称,我们在看这个组件时,不会发现有什么问题。linter也不会发现它,我们只会在运行时看到错误。
mixin模式表面上看起来很安全。然而,通过合并对象来共享代码,由于它给代码增加了脆弱性,并且掩盖了推理功能的能力,因此成为一种反模式。Composition API 最聪明的部分是,它允许Vue依靠原生JavaScript中内置的保障措施来共享代码,比如将变量传递给函数和模块系统。
在大多数情况下,你坚持使用经典API是没有问题的。但是,如果你打算重用代码,Composition API无疑是优越的。
Vue2响应式实现: Object.defineProperty
简单来说就是拦截对象,给对象的属性增加set
和get
Object.defineProperty缺点:
- 有时无法监听到数组的变化
- 需要深度遍历,浪费内存
- 对 Map、Set、WeakMap 和 WeakSet 的支持
Vue3响应式实现: Proxy
- reactive 大致实现过程
const toProxy = new WeakMap(); // 存放被代理过的对象
const toRaw = new WeakMap(); // 存放已经代理过的对象
function reactive(target) {
// 创建响应式对象
return createReactiveObject(target);
}
function isObject(target) {
return typeof target === "object" && target !== null;
}
function hasOwn(target,key){
return target.hasOwnProperty(key);
}
function createReactiveObject(target) {
if (!isObject(target)) {
return target;
}
let observed = toProxy.get(target);
if(observed){ // 判断是否被代理过
return observed;
}
if(toRaw.has(target)){ // 判断是否要重复代理
return target;
}
const handlers = {
get(target, key, receiver) {
// 取值
let res = Reflect.get(target, key, receiver);
track(target,'get',key); //依赖收集
// 懒代理,只有当取值时再次做代理,vue2中一上来就会全部递归增加getter,setter
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let hadKey = hasOwn(target,key);
let result = Reflect.set(target, key, value, receiver);
if(!hadKey){
trigger(target,'add',key); // 触发添加
}else if(oldValue !== value){
trigger(target,'set',key); // 触发修改
}
return result;
},
deleteProperty(target, key) {
//...
const result = Reflect.deleteProperty(target, key);
return result;
}
};
// 开始代理
observed = new Proxy(target, handlers);
toProxy.set(target,observed);
toRaw.set(observed,target); // 做映射表
return observed;
}
复制代码
- effect的大致实现
const activeReactiveEffectStack = []; // 存放响应式effect
function effect(fn) {
const effect = function() {
// 响应式的effect
return run(effect, fn);
};
effect(); // 先执行一次
return effect;
}
function run(effect, fn) {
try {
activeReactiveEffectStack.push(effect);
return fn(); // 先让fn执行,执行时会触发get方法,可以将effect存入对应的key属性
} finally {
activeReactiveEffectStack.pop(effect);
}
}
复制代码
const targetMap = new WeakMap();
function track(target,type,key){
// 查看是否有effect
const effect = activeReactiveEffectStack[activeReactiveEffectStack.length-1];
if(effect){
let depsMap = targetMap.get(target);
if(!depsMap){ // 不存在map
targetMap.set(target,depsMap = new Map());
}
let dep = depsMap.get(target);
if(!dep){ // 不存在则set
depsMap.set(key,(dep = new Set()));
}
if(!dep.has(effect)){
dep.add(effect); // 将effect添加到依赖中
}
}
}
复制代码
function trigger(target, type, key) {
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
if (effects) {
effects.forEach(effect => {
effect();
});
}
// 处理如果当前类型是增加属性,如果用到数组的length的effect应该也会被执行
if (type === "add") {
let effects = depsMap.get("length");
if (effects) {
effects.forEach(effect => {
effect();
});
}
}
}
复制代码
- 默认为惰性监测。在 Vue2中,任何响应式数据都会在启动的时候被监测。如果数据量很大,在应用启动时,就可能造成可观的性能消耗。而在Vue3 中,只有应用的初始可见部分所用到的数据会被监测。
- 更精准的变动通知。举个例子:在 Vue2 中,通过 Vue.set 强制添加一个新的属性,将导致所有依赖于这个对象的 watch 函数都会被执行一次;而在 Vue3 中,只有依赖于这个具体属性的 watch 函数会被通知到。
- 不可变监测对象。我们可以创建一个对象的“不可变”版本,这种机制可以用来冻结传递到组件属性上的对象和处在 mutation 范围外的 Vuex 状态树。
- 更良好的可调试能力。通过使用新增的
renderTracked
和renderTriggered
钩子,我们可以精确地追踪到一个组件发生重渲染的触发时机和完成时机,及其原因。
自定义渲染平台( Custom Render )
通过这个API
理论上你可以自定义任意平台的渲染函数,把VNode
渲染到不同的平台上,比如小程序;你可以对着@vue/runtime-dom
复制一个@vue/runtime-miniprogram
出来, 再比如游戏:@vue/runtime-canvas
。
这个 API
的到来,将使得那些如 Weex
和 NativeScript
的“渲染为原生应用”的项目保持与 Vue
的同步更新变得更加容易。
一些讨论
- 现有的项目该升级吗
- 新增的 Composition API 兼容 Vue2,只需要在项目中单独引入 @vue/composition-api 这个包就可以。
- 2.x 的最后一个次要版本将成为 LTS,并在 3.0 发布后继续享受 18 个月的 bug 和安全修复更新。
- 当前项目生态中的几个库都面临巨大升级,以及升级后的诸多坑要填,比如:vue-router、vuex、ElementUI/ViewUI/AntDesignVue 等
- element不更新后,组件库该怎么办