vue3 全面深入原理讲解

语言: CN / TW / HK

前言

这篇文章的大部分内容成于去年8月。当时自己对 vue3 非常感兴趣,才有了这些整理与探索。其实内容放到今天也完全不过时,甚至在掘金上也很少看到有人写这些偏原理的东西,全都在讨论API。

自己最近在整理前端知识图谱,想到之前有过Vue3相关的整理,便拿了出来(比较懒,原封不动拿出来的,后续会整理整理,添加一些解释性的内容)

为什么要升级到Vue3

  1. 更小
  2. 核心代码 + Composition Api: 13.5kb(vue2为 31.94kb)
  3. 所有Runtime: 22.5kb(vue2 为32kb)
  4. 更快
  5. SSR速度提高了2~3倍
  6. 初始渲染/更新最高可提速一倍
  7. 更优

image.png - update性能提高1.3~2倍 - 内存占用减小了一半 4. 更易 - 更好的TypeScript支持 - 更多友好特性和检测 - ......

Vue3有哪些新特性

  1. Tree-shaking支持 ( 按需加载 )
  2. 静态树提升
  3. 静态属性提升
  4. 虚拟 DOM 重构
  5. 插槽优化
  6. Suspense、Fragment、Teleport
  7. 支持TS ( 原生Class Api 和 TSX )
  8. 基于 Proxy 的新数据监听系统(Composition API)
  9. 自定义渲染平台(Custom Render)
    ......

按需加载

非常用功能可以按需加载,比如:v-model, Transition等 ``` js

{{ 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 /)) } ```

``` js // 在 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`来缩短属性名称

静态提升

``` js

{{ msg }}
{{ msg2 }}
msg3

//编译后代码 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 /)) } ```

```js hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3

{{msg}}

//编译后代码 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("hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3", 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

```js

{{ msg }}
{{ msg2 }}
msg3

//编译后代码 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

```js

{{ msg }}
{{ msg2 }}
msg3

//编译后代码 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 /)) } js 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 ``` image.png

与 React 对比

image.png React走了另外一条路,既然主要问题是diff导致卡顿,于是React走了类似 cpu 调度的逻辑,把vdom这棵树微观变成了链表,利用浏览器的空闲时间来做diff,如果超过了16ms,有动画或者用户交互的任务,就把主进程控制权还给浏览器,等空闲了继续。实际上是在之前用不上的时间里做了diff操作。

时间切片

浏览器每间隔一定的时间重新绘制一下当前页面。一般来说这个频率是每秒60次。也就是说每16毫秒浏览器会有一个周期性地重绘行为,这每16毫秒我们称为一帧。这一帧的时间里面浏览器的主要工作有: 1. 执行JS 2. 计算Style 3. 构建布局模型(Layout) 4. 绘制图层样式(Paint) 5. 组合计算渲染呈现结果(Composite)

如果这六个步骤总时间超过 16ms 了之后,用户也许就能看到卡顿。如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务,随后再回来继续执行没有执行完的任务。

image.png

Vue3放弃了时间切片支持

image.png

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,会执行新旧 vnodeprepatch,这个过程又会执行 updateChildComponent, 如果这个子组件 vnode 有插槽,会重新执行一次子组件的 forceUpdate(),这种情况下会触发子组件的重新渲染。简单来说,当父组件更新时,插槽会被重新渲染。vue3对这种场景进行了优化。

在Vue3中,所有由编译器生成的 slot 都将是函数形式,并且在子组件的 render 函数被调用过程中才被调用。这使得 slot 中的依赖项 将被作为子组件的依赖项,而不是现在的父组件;从而意味着:1)当 slot 的内容发生变动时,只有子组件会被重新渲染;2)当父组件重新渲染时,如果子组件的内容未发生变动,子组件就没必要重新渲染。 1. 静态编译时,给一个Component打上一个PatchFlag标记---是否是DynamicSlot 2. 遇到有传入slot的组件,它的Children不是普通的vnode数组,而是一个slot function的映射表,这些slot function用于在组件中懒生成slot中的vnodes。 3. 在子组件的render函数里面,调用相应的slot生成函数,因此这个slot函数里面的属性都会被当前的组件实例所track

Suspense

一个异步加载组件,抄自React
它可以在嵌套的组件树渲染到屏幕上之前,在内存中进行渲染,可以检测整颗树里面的异步依赖,只有当将整颗树的异步依赖都渲染完成之后,也就是resolve之后,才会将组件树渲染到屏幕上去。 <Suspense> is an experimental feature and its API will likely change.

Fragment

抄自React
自动在template中增加一层虚拟节点,不再需要用根元素进行包裹 ``` js

{{ msg }}
{{ msg2 }}

//编译后代码 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 节点的优秀的方案。可以应用在弹窗等需要挂载到全局的组件。 ```html

<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 image
mixin的问题:
  • 命名冲突
    Vue组件的默认合并策略是本地选项将覆盖mixin选项(生命周期钩子除外)。在跨多个组件和mixin处理命名属性时,编写代码变得越来越困难。一旦第三方mixin作为带有自己命名属性的npm包被添加进来,就会特别困难,因为它们可能会导致冲突。
  • 来源不清晰
    当有多层或多个mixin时,调用的属性来源不清晰
  • 隐式依赖
    mixin和使用它的组件之间没有层次关系。这意味着组件可以使用mixin中定义的数据属性,但是mixin也可以使用假定在组件中定义的数据属性。如果想重构一个组件,改变了mixin需要的变量的名称,我们在看这个组件时,不会发现有什么问题。linter也不会发现它,我们只会在运行时看到错误。

mixin模式表面上看起来很安全。然而,通过合并对象来共享代码,由于它给代码增加了脆弱性,并且掩盖了推理功能的能力,因此成为一种反模式。Composition API 最聪明的部分是,它允许Vue依靠原生JavaScript中内置的保障措施来共享代码,比如将变量传递给函数和模块系统。

在大多数情况下,你坚持使用经典API是没有问题的。但是,如果你打算重用代码,Composition API无疑是优越的。

Vue2响应式实现: Object.defineProperty

简单来说就是拦截对象,给对象的属性增加setget

Object.defineProperty缺点: - 有时无法监听到数组的变化 - 需要深度遍历,浪费内存 - 对 Map、Set、WeakMap 和 WeakSet 的支持

Vue3响应式实现: Proxy

  • reactive 大致实现过程 ```js 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的大致实现 js 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); } } js 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添加到依赖中 } } } js 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(); }); } } } ```

image.png

  • 默认为惰性监测。在 Vue2中,任何响应式数据都会在启动的时候被监测。如果数据量很大,在应用启动时,就可能造成可观的性能消耗。而在Vue3 中,只有应用的初始可见部分所用到的数据会被监测。
  • 更精准的变动通知。举个例子:在 Vue2 中,通过 Vue.set 强制添加一个新的属性,将导致所有依赖于这个对象的 watch 函数都会被执行一次;而在 Vue3 中,只有依赖于这个具体属性的 watch 函数会被通知到。
  • 不可变监测对象。我们可以创建一个对象的“不可变”版本,这种机制可以用来冻结传递到组件属性上的对象和处在 mutation 范围外的 Vuex 状态树。
  • 更良好的可调试能力。通过使用新增的 renderTrackedrenderTriggered 钩子,我们可以精确地追踪到一个组件发生重渲染的触发时机和完成时机,及其原因。

自定义渲染平台( Custom Render )

通过这个API理论上你可以自定义任意平台的渲染函数,把VNode渲染到不同的平台上,比如小程序;你可以对着@vue/runtime-dom复制一个@vue/runtime-miniprogram出来, 再比如游戏:@vue/runtime-canvas
这个 API 的到来,将使得那些如 WeexNativeScript 的“渲染为原生应用”的项目保持与 Vue 的同步更新变得更加容易。

一些讨论

  1. 现有的项目该升级吗
  2. 新增的 Composition API 兼容 Vue2,只需要在项目中单独引入 @vue/composition-api 这个包就可以。
  3. 2.x 的最后一个次要版本将成为 LTS,并在 3.0 发布后继续享受 18 个月的 bug 和安全修复更新。
  4. 当前项目生态中的几个库都面临巨大升级,以及升级后的诸多坑要填,比如:vue-router、vuex、ElementUI/ViewUI/AntDesignVue 等
  5. element不更新后,组件库该怎么办