首先,Vue 3 全面拥抱了 TypeScript。一方面源码全部使用 TpeScript 进行了重写,另一方面,开发者使用 TypeScript 进行开发时也能进行更好地类型推导,获得更好的开发体验。关于这方面,我们不做过多的解析。
第二个重要的改变是,Vue 3 在基本兼容 Vue 2 的使用方式之外,还提供了一套名为 Compostion API 的新接口
Composition API 是指可以在组件初始化的时候使用一个setup()
方法,数据、方法等都可以由setup()
方法统一初始化后返回
<template>
<button @click="increment">{{count}}</button>
</template>
<script>
export default {
setup(){
const count = ref(0);
function increment(){
count.value++;
}
return {
count,
increment
}
}
}
</script>
上面的例子只演示了 data
、method
在 Composition API 中是如何等价表达,其它的如 computed
、watch
等也有相应的表达。这套 API 带来的好处比较多:
官方例子:
使用 Vue 2 的源代码位于 https://github.com/vuejs/vue-cli/blob/a09407dd5b9f18ace7501ddb603b95e31d6d93c0/packages/@vue/cli-ui/src/components/folder/FolderExplorer.vue。
基于 Vue 3 Composition API 重构的代码位于 https://gist.github.com/yyx990803/8854f8f6a97631576c14b63c8acd8f2e。
Vue 2 中响应式数据是通过 ES5 的 getter/setter 来实现的,在前面的章节中我们做了比较详尽的介绍。这套响应式数据机制受限于语言机制,有一些不能完全覆盖到数据变更的地方,例如当动态向对象上添加 key,或者通过下标的方式修改数组,就不能触发数据变更的检测机制。
Vue 3 中响应式数据使用了一个不同的机制 ——Proxy,这个机制我们在前文介绍响应式的实现机制时有专门介绍过,在这里可以再复习一下。
Proxy 可以为任何一个对象设置一个代理对象,并在代理对象中设置一些自定义的逻辑。Vue 3 使用这个机制来监测数据变更:首先针对需要监听的数据对象设置一个代理对象,在代理对象中拦截对数据赋值的操作,从而获知数据变化:
const dataObj = {};
const dataProxy = new Proxy(dataObj, {
set(target, key, value){
// 进行对应界面的更新
Reflact.set(target, key, value);
}
});
值得注意的是,因为只有在 ES6 的环境中才可以使用 Proxy,且这个特性无法通过编译到 ES5 的方式进行降级,所以目前兼容性相对来说比较差,这也是 Vue 3 不支持 IE 浏览器的主要原因
Vue 2 的体积其实已经不算大了,但是使用 Vue 3 时可以让打包体积更小。这一方面利益于 Composition API 的设计,另一方面也是因为随着 ESM 模块化规范(和 TS 模块化规范)的成熟,tree-shaking 被使用得越来越广泛了。
tree-shaking 是指针对代码中模块的依赖关系进行静态分析,将完全没有使用到的模块在打包时直接移除掉,只打包使用到的模块,从而减少体积。
得益于 Vue 3 良好的模块划分,开发者在使用 Vue 3 时可以按需选择需要的模块引入,而不用一次性将所有的代码全部引入,这样在打包时 Vue 3 中没有被引用的源码将被移除。
在 Vue 的仓库中专门有一个 @vue/size-check 的包,就是用来测试 tree-shaking 的:
import { h, createApp } from '@vue/runtime-dom'
// The bare minimum code required for rendering something to the screen
createApp({
render: () => h('div', 'hello world!')
}).mount('#app')
这个例子中只使用了 h 和 createApp,其它的诸如 watch、computed、reactive 等模块都没有被引用,因此打包时也将只包含使用到的源码,从而使整个应用的打包体积变得更小
Vue 2 引入了虚拟 DOM 来进行对比计算,从而确定 DOM 中需要更新的节点。虽然虚拟 DOM 是在 JavaScript 中进行运算,速度足够快了,但是实际上这个对比计算的过程仍然有非常多不必要的步骤。
Vue 3 在这里进行了更为精细的性能优化,主要包括:
以 v-if 和 v-for 作为边界,将模板分成很多 “块”,每个块中的结构是完全固定的,这样块中就不需要进行树的遍历,只需要对比绑定的值即可
将静态节点、子树等渲染代码移到渲染函数之外,这样可以避免每次渲染时重新创建这些不会变化的对象
将元素的更新类型进行细分,例如动态绑定的部分如果只涉及到 class,则在对比时只需要对比 class 即可,不需要对比它的内容
这些优化大大改进了 Vue 3 的渲染性能,从各种公开的数据来看,基本上能有 5 到 10 倍的提升
除了上述几点变化以外,Vue 还有不少的变化,如官方支持了Teleport
组件、模板中支持了多根节点的组件等等。但相比上述变化,这些都显得不是那么重要,因此不花更多篇幅进行解读
我们非常熟悉的模式是一个 npm 包对应一个 git 代码仓库,这样做的好处是代码仓库中的代码边界清晰、职责单一,在开发维护的时候也一定程度上会强迫开发人员认真思考每个包的功能和 API。但是如果代码间拆分的包过多,或者包之间耦合较多,这种模式在日常开发和维护时就显得不太方便了,因为很有可能一个小小的改动就需要动到好几个 npm 包的代码。
单体仓库就是为解决这个问题而出现的一种解决方案。具体而言,就是各个包从逻辑上互相独立,通过 npm 包的形式来互相依赖,但是各个包的代码都放在同一个 git 仓库中。
Vue 3 从代码结构上进行了梳理,更加模块化,而且将很多之前属于内部实现的部分也抽象成了模块,作为单独的 npm 包发布。这样开发者还可以不引用整个框架,而只使用框架中用到的各个独立的模块。这样一来,Vue 3 最终要发布的包数量就多了很多,因此也选择了使用单体仓库模式
Vue 3 的代码仓库根目录基本上就只有 packages
和 scripts
两个目录,后者主要用于一些工程操作(检查环境、打包等),因此真正的源码都位于 packages
目录中。这个目录下一共有 13 个包:
compiler-core
模板解析核心,与具体环境无关,主要生成 AST,并根据 AST 生成 render()
方法compiler-dom
浏览器环境中的模板解析逻辑,如处理 HTML 转义、处理 v-model 等指令compiler-sfc
负责解析 Vue 单文件组件,在前面 vue-loader 的解析中有讲解过compiler-ssr
服务端渲染环境中的模板解析逻辑reactivity
响应式数据相关逻辑runtime-core
与平台无关的运行时核心,包括 renderruntime-dom
浏览器环境中的运行时核心runtime-test
用于自动化测试的相关配套server-renderer
用于 SSR 服务端渲染的逻辑shared
一些各个包之间共享的公共工具size-check
一个用于测试 tree shaking 后代码大小的示例库template-explorer
用于检查模板编译后的输出,主要用于开发调试vue
Vue 3 的主要入口,包括运行时和编译器,包括几个不同的入口(开发版本、runtime 版本、full 版本)上述包中,剔除服务端渲染相关代码、开发调试、测试相关代码,并将它们的依赖关系体现出来,则大致是这样的(来自 Vue 3 仓库):
可见 Vue 3 的模块关系拆分得非常清楚:
vue
作为入口,分别依赖 compiler 和 runtime 2 个大模块(如果是 runtime only 的版本,则只依赖 runtime)。这两个大模块内部又将核心剥离出去,然后将浏览器环境相关的部分再放到一个单独的包中我们很关注的响应式数据的部分 reactivity
也是作为 runtime 的一部分,用于处理运行时数据发生变化的情况
Vue 3 将响应式数据的逻辑单独提出来作为一个独立的模块了,即源码中的reactivity
包,在 npm 上是@vue/reactivity
effect
与当前数据的依赖关系effect
运行effect(fn)
,则之后任何fn()
中依赖的数据发生变化,fn()
都会被再次调用。这便是 Vue 3 的响应式数据的原理 Vue 作者还写了一个小项目vue-lit
(https://github.com/yyx990803/vue-lit),这个项目使用了reactivity
,仅仅用很少量的代码就搭出了一个非常轻量的组件化框架。这个项目也可以帮忙我们更好地理解独立出来的reactivity
模块的用法。
和 Vue 2 一样,compiler 的主要作用是解析 Vue 中的模板部分,最终将模板转换成render()
方法
整个编译过程分为 3 步:
baseParse()
方法,解析传入的模板,生成 AST(即 ast
变量)transform()
方法对 AST 进行转换generate()
方法,根据转换后的 AST 生成 render()
方法并返回第 2 点需要特别注意,在 Vue 2 中,生成 AST 后会有一个 optimize 的过程,而在 Vue 3 中,optimize “升级” 了一下,变成了 transform 的过程。transform 会做一些额外的逻辑处理,也会为了后续渲染性能的优化预先做一些处理并反映到 AST 中。
首先看模板解析并生成 AST 的过程,Vue 3 的模板解析是非常典型的代码解析方法,即从源码的第 1 个字符开始逐个字符进行扫描,每当找到可以识别的对象则将该对象放入 AST,并接着之前的源码位置往下继续解析
transform 的作用是针对上一步生成的 AST 进行一些变换,例如针对 v-if
和 v-for
增加一些特殊的节点,以及为了后续渲染性能优化增加一些属性等等
tranverseNode()
对 AST 进行遍历,并在每次遍历退出前调用 hook,以便相关逻辑对相应的节点进行修改hoistStatic()
方法提升静态节点createRootCodegen()
方法生成 codegenNode
,以便在后续 generate 阶段使用和前面的 parse、transform 一样,generate 的过程也是先生成一个 context
,然后再处理。
首先它会做一堆前置处理:
scopeId
和 isSetupInlined
相关逻辑接下来调用 genNode()
,很机械地生成代码,不做详细解析,值得注意的是传入的参数正是 ast.codegenNode
,而不是 ast
本身
在 Vue 3 中,不再使用new App()
的方式来新建 Vue APP,而是使用createApp()
方法来创建。这个入口就属于runtime
了
上一节我们提到了一个概念叫 block,在上面的代码中也再次出现了 block 的概论。这个概念是 Vue 3 为了提升渲染性能而提出来的。
在 Vue 2 中,虚拟 DOM 的更新需要对新、旧两棵 VNode 树进行全量遍历和对比,然后才能确定发生变动的地方。但是,Vue 的虚拟 DOM 多数情况下是由模板编译而来,而模板的结构大部分地方是确定的,也就是树的结构大部分地方不会发生变化,变化的可能只是属性、文字等,真正会使结构发生变化的基本上只有 v-if
、v-for
等少数几个指令。
因此 Vue 3 增加了一个 block 的概念,一个 block 中就是一个确定的树状结构。我们以 v-if
为例,尽管它会导致组件的整体结构发生变更,但它自己的子节点却是稳定的,因此它可以当作是一个完整的 block。当其它的 block 中碰到 v-if
的时候,只需要知道 “这里有一个 block” 就好了。通过这样的方式,虚拟 DOM 的对比就会非常高效,因为 block 的结构是固定的,block 内部就没有必要去对比是否结构发生了变化。
具体到实现中,如果 VNode 节点上存在 dynamicChildren
,则说明它是一个 block。再回到前面生成的 render()
函数
Vue 3 runtime 模块的主要功能:
createApp()
API,完成初始化的工作render()
方法完成渲染在上面我们也看到了 Vue 3 是如何通过 ShapeFlag
、PatchFlag
、block 等机制实现高性能渲染的