这个系列记录了我阅读《Vue 技术内幕》这本书的笔记,由于该书是 2022 年发行,可能有部分内容过时,因此我会更多的结合最新的 Vue 版本(v3.5.13
)代码一起阅读和实践
Vue3 的优化
源码结构
在 Vue2 的时候,所有的源码都是堆积在一个仓库中下的,都放置于 src
目录下,根据功能不同拆分出不同的子文件夹。Vue3 则采用 mono repo 结构,这样做有一些好处
- 每个子包可以单独分发使用,并且可以有单独的 API、类型定义和测试代码
- 每个子包的职责划分更加清晰,粒度更细,模块依赖关系明确
- 可以单独引入子包来减少包体积大小
Vue2 使用 Flow 来进行 JavaScript 静态类型检查,切换到 TypeScript 后,省去了单独维护 d.ts
文件的烦恼,并且能支持更复杂的类型推导。我使用 tokei 计算了一下项目代码
bash
-> % tokei packages/**/src
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
TypeScript 231 53773 43622 6224 3927
===============================================================================
五万行代码,确实是一个非常大的项目了
打包体积与性能优化
通过下面的方式,Vue3 减少了打包体积,能够更好的适应低网速环境
- 通过 ESM 引入 tree-shaking,能够分析出用户未使用的 API,从而在打包期间使用
terser
等工具删除 - 删除冷门的功能。(这也是 Vue2 和 Vue3 之间不兼容的来源吧,不过挺好的,喜新厌旧)
另一个老生常谈(面试经典)的就是 Vue3 是通过哪些方法实现性能优化
之前使用的是 Object.defineProperty
,他的缺点是在劫持之前必须知道劫持哪个字段,因此如果添加或者删除对象字段,他是不能检测出来的。并且如果遇到了一个嵌套层级很深的对象,为了让他实现响应式,必须递归的将每一层的字段都设置 getter 和 setter,很消耗性能
在 Vue3 中,使用 proxy 劫持对象,可以在访问或者设置对象字段的时候获知正在操作哪个键,因此天然的没有第一个缺陷。除此之外,对于深层对象,只有真正访问到的内部对象才会被设置为响应式的,从而提升性能
Vue2 的更新粒度是组件,但是组件也有可能很大,同时需要响应式的数据很少,比如
vue
<template>
<div>static</div>
<div>static</div>
<div>static</div>
<div>static</div>
<div>{{ message }}</div>
<div>static</div>
</template>
如果每次都要对这些静态节点 diff,毫无疑问是很浪费性能的。Vue3 因此引入了编译优化,在编译时生成 block tree,可以基于动态节点将模板切分成一个个嵌套区块,区块内部节点是固定的,每个区块只需要一个 Array 来追踪自身包含的动态节点
因此借助 block tree,vnode 的更新性能开始和动态数据量相关,而不是和节点数量相关
语法优化
语法优化主要体现在 composition API 和组合式函数两点
Vue2 使用的是 option API,会导致对同一个功能点的代码散落在单文件组件的各处,编辑一个功能点要在文件中跳转很多次,使用 composition API 后,可以将这些代码组织到一处,更方便修改(在滴滴实习的时候深有感觉)
组合式函数是用来取代 mixin 的,因为 mixin 存在命名空间冲突和数据来源不明确的问题,组合式函数可以利用类型推断更好的提供开发体验
RFC
Vue 还引入了 RFC 机制来更好的把控新功能的添加,确实是开发大型项目的一个启发,如果动辄为一个 feature request 花费很多时间是很不值当的
源码总览
子包功能
compiler-core
:用来将模板字符串编译为渲染函数,既可以在浏览器端使用,也可以在服务端使用。流程包括,从模板到 AST,AST 到节点,以及最后的生成代码compiler-dom
:浏览器端编译相关代码,是对compiler-core
的封装compiler-ssr
:服务端编译相关代码,也是对compiler-core
的封装compiler-sfc
:单文件组件不能直接被浏览器解析,因此有了该子包,先将 sfc 的 script template style 抽离出来,然后分别解析runtime-core
:包含了与平台无关的运行时核心实现,包含虚拟 DOM 渲染器,还有一些全局的 JavaScript API,可以在其上创建高阶运行时runtime-dom
:以浏览器为目标的运行时,包含对原生 DOM API,属性样式等的管理runtime-test
:用于测试runtime-core
的轻量运行时,可以在任意 JavaScript 环境使用reactivity
:响应式相关代码,也是最熟悉和欣赏的部分,不到三千行的代码居然实现了这么有意思的功能template-explorer
:用来调试模板编译成渲染函数的工具sfc-playground
:调试 sfc 编译输出的工具shared
:内部实用工具server-renderer
:ssr 的核心实现vue
:用户不是直接使用上面的这些子包,而是统一使用这个包构建产物,包括运行时版本和带有模板编译器的版本vue-compat
:提供了可配置的 Vue2 兼容行为
不同的构建版本
在根目录下运行 pnpm run build
后,可以在 /packages/vue/dist
目录下看到下面一些文件
bash
.
├── vue.cjs.js
├── vue.cjs.prod.js
├── vue.esm-browser.js
├── vue.esm-browser.prod.js
├── vue.esm-bundler.js
├── vue.global.js
├── vue.global.prod.js
├── vue.runtime.esm-browser.js
├── vue.runtime.esm-browser.prod.js
├── vue.runtime.esm-bundler.js
├── vue.runtime.global.js
└── vue.runtime.global.prod.js
可以看到每个 Vue 编译后的产物都携带了一些后缀,我们一一讲解
- runtime:表示这个编译产物没有携带模板编译器,因此只能执行已经编译后的渲染函数。我们一般选择他作为生产环境实际使用的版本,因为 Vue 在使用的时候本身就要编译(sfc 或者模板字符串),同时这个版本的产物由于不携带编译器,更加轻量,性能更好
- global:构建产物可以通过
<script src="xxx">
引入(以 CDN 的形式),他的内容实际上是一个 iife - esm-browser:构建产物可以通过
<script type="module">
来引入(同样是 CDN 的形式),本质是一个 ESM - esm-bundler:需要配合打包工具使用,这也是我们最常用的方式,一般通过包管理器下载到
node_modules
,这种方式更适合 tree-shaking - cjs:表示该产物用来做服务端渲染,遵循 CommonJS 规范,可以通过 require 方式引入
TIP
vue.esm-bundler.js
和 vue.runtime.esm-bundler.js
二者之间差别可能很难理解,为什么后者在生产环境更常用呢?如果不携带模板编译器为什么能够将我们编写的 Vue sfc 编译成模板函数呢? 这是因为如果选用 bundler 的版本,我们通常会搭配 webpack 或者 vite 来进行预构建,这些工具通过利用编译器插件(如 vue-loader
)来将 sfc 编译成渲染函数,不再需要在运行时编译模板,因此我们不需要在生产环境携带编译器
构建流程
执行 pnpm run build
的时候,我们实际上是执行 scripts/build.js
这个脚本,其中过程可以简化为下面三个阶段
- 收集编译目标
- 并行编译
- 编译单个子包
收集编译目标
javascript
run()
async function run() {
try {
const resolvedTargets = targets.length
? fuzzyMatchTarget(targets, buildAllMatching)
: allTargets
await buildAll(resolvedTargets)
}
}
javascript
export const targets = fs
.readdirSync("packages")
.filter((f) => {
if (
!fs.statSync(`packages/${f}`).isDirectory() ||
!fs.existsSync(`packages/${f}/package.json`)
) {
return false;
}
const pkg = require(`../packages/${f}/package.json`);
if (pkg.private && !pkg.buildOptions) {
return false;
}
return true;
})
.concat("template-explorer");
scripts/build.js
就是一个小脚本,执行里面的 run
函数,首先搜索所有的编译目标(allTargets
),发现它定义在 scripts/utils.js
中,进入一看就能找到搜索逻辑
先遍历 packages
目录下的所有子包,读取其中的 package.json
文件,如果该包是私有的或者没有构建配置,那么就排除在构建目标外
经历遍历处理后,发现需要构建的包有如下:
json
[
"compiler-core",
"compiler-dom",
"compiler-sfc",
"compiler-ssr",
"reactivity",
"runtime-core",
"runtime-dom",
"server-renderer",
"shared",
"vue",
"vue-compat",
"template-explorer"
]
并行编译
面试常考题了,如何在 CPU 核心有限的情况下尽可能多的同时执行任务
javascript
async function buildAll(targets) {
await runParallel(cpus().length, targets, build);
}
async function runParallel(maxConcurrency, source, iteratorFn) {
const ret = [];
const executing = [];
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item));
ret.push(p);
if (maxConcurrency <= source.length) {
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
if (executing.length >= maxConcurrency) {
await Promise.race(executing);
}
}
}
return Promise.all(ret);
}
按照遍历顺序依次进行编译,如果发现已经在编译的任务个数多于 CPU 核心数,就一直等待,直到某个任务编译完成,接着遍历添加任务
这里面的 runParallel
函数和我们自己写的 Promise.all 函数很像,只不过他将构建函数作为参数传到了这个并行控制函数中,我们是将他作为一个 task 放入到数组,并无差异
编译单个子包
相关的代码就是我们第二步传入的 build 函数
javascript
async function build(target) {
const pkgBase = privatePackages.includes(target)
? `packages-private`
: `packages`;
const pkgDir = path.resolve(`${pkgBase}/${target}`);
const pkg = JSON.parse(readFileSync(`${pkgDir}/package.json`, "utf-8"));
// if this is a full build (no specific targets), ignore private packages
if ((isRelease || !targets.length) && pkg.private) {
return;
}
// if building a specific format, do not remove dist.
if (!formats && existsSync(`${pkgDir}/dist`)) {
fs.rmSync(`${pkgDir}/dist`, { recursive: true });
}
const env =
(pkg.buildOptions && pkg.buildOptions.env) ||
(devOnly ? "development" : "production");
await exec(
"rollup",
[
"-c",
"--environment",
[
`COMMIT:${commit}`,
`NODE_ENV:${env}`,
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
prodOnly ? `PROD_ONLY:true` : ``,
sourceMap ? `SOURCE_MAP:true` : ``,
]
.filter(Boolean)
.join(","),
],
{ stdio: "inherit" },
);
}
这里传入的参数只有 target(即需要编译的子包名),我们根据子包名获取他的 package.json
,进而获取里面定义的编译选项,然后运行 rollup 命令,与此同时传入一些环境变量,就可以编译这些包了!
rollup 配置
我们在自己的 vitepress 博客中也使用了 rollup,他的配置文件实际要求返回一个对象,大致结构如下:
javascript
export default {
input, output, external, plugins;
}
输入输出配置
Vue 的 rollup.config.js
并没有直接按照上述结构返回配置对象,而是类似于下面这样,通过一个函数构造出配置对象
javascript
const packageConfigs = createConfig(format, outputConfigs[format]);
export default packageConfigs;
因此我们可以看一下 createConfig
函数
javascript
function createConfig(format, output, plugins = []) {
// ...
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`;
// ...
return {
input: resolve(entryFile),
output,
};
}
这是极度精简后的代码,我们可以看到输入文件是根据 format
的值来决定的,如果编译的 format 要求不包含模板编译器,那么就使用 src/runtime.ts
,否则使用 src/index.ts
。输出文件的配置我这里省略了,但是其实没有什么大影响,因为都是一些针对 sourceMap
之类的配置,他的核心在这个函数传入的参数 output(即 outputConfigs[format]
)
javascript
// 前面还有通过 package.json 获取 name 的代码,这里省略了
const outputConfigs = {
"esm-bundler": {
file: resolve(`dist/${name}.esm-bundler.js`),
format: "es",
},
"esm-browser": {
file: resolve(`dist/${name}.esm-browser.js`),
format: "es",
},
cjs: {
file: resolve(`dist/${name}.cjs.js`),
format: "cjs",
},
global: {
file: resolve(`dist/${name}.global.js`),
format: "iife",
},
// runtime-only builds, for main "vue" package only
"esm-bundler-runtime": {
file: resolve(`dist/${name}.runtime.esm-bundler.js`),
format: "es",
},
"esm-browser-runtime": {
file: resolve(`dist/${name}.runtime.esm-browser.js`),
format: "es",
},
"global-runtime": {
file: resolve(`dist/${name}.runtime.global.js`),
format: "iife",
},
};
// format 取值可以是 keyof outputConfigs
const defaultFormats = ["esm-bundler", "cjs"];
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(",");
const packageFormats =
inlineFormats || packageOptions.formats || defaultFormats;
const packageConfigs = process.env.PROD_ONLY
? []
: packageFormats.map((format) => createConfig(format, outputConfigs[format]));
可以看到,输出的配置是根据 format
的值来决定的,而 format
又是从 packageFormats
中遍历得到的,我们只需要关注这个数组的来源即可。inlineFormats || packageOptions.formats || defaultFormats;
这行代码告诉了我们他的内容来源,首先读取环境变量中的 format,如果不存在接着查看 package.json
中的构建配置,如果还有没就选择默认 format
以 vue
这个包为例子,假设我们没有通过命令行设置环境变量,我们会读取 packages/vue/package.json
这个文件,发现它的构建配置如下:
json
{
"buildOptions": {
"name": "Vue",
"formats": [
"esm-bundler",
"esm-bundler-runtime",
"cjs",
"global",
"global-runtime",
"esm-browser",
"esm-browser-runtime"
]
}
}
因此可以知道,这个包一共有两种输入文件,对应了七个不同的输出文件
external
我们肯定希望编译得到的产物体积越小越好,因此 Vue
使用的外部库我们一律不打包到产物中,而是作为依赖项留给用户去安装。这在我写博客自定义主题的时候也遇到了,如果将 dayjs
之类的东西一并打包到构建产物中,那么假设用户也要使用这个包,就会平白无故增加一份 dayjs
的副本。因此我们需要配置 external
项来避免将外部依赖打包进去
javascript
function createConfig(format, output, plugins = []) {
function resolveExternal() {
const treeShakenDeps = [
"source-map-js",
"@babel/parser",
"estree-walker",
"entities/lib/decode.js",
];
if (isGlobalBuild || isBrowserESMBuild || isCompatPackage) {
if (!packageOptions.enableNonBrowserBranches) {
// normal browser builds - non-browser only imports are tree-shaken,
// they are only listed here to suppress warnings.
return treeShakenDeps;
}
} else {
// Node / esm-bundler builds.
// externalize all direct deps unless it's the compat build.
return [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
// for @vue/compiler-sfc / server-renderer
...["path", "url", "stream"],
// somehow these throw warnings for runtime-* package builds
...treeShakenDeps,
];
}
}
return {
external: resolveExternal(),
};
}
这部分代码已经和书上的不一样了,可以看到 Vue 的沧海桑田……不过核心很明确,对于 global
和 esm-browser
构建,我们只需要将浏览器端的依赖项排除在外,对于其他构建,我们需要将所有依赖项排除在外
插件配置
和插件相关的代码如下
javascript
function createConfig(format, output, plugins = []) {
// ...
return {
plugins: [
json({ namedExports: false }),
alias({ entries }),
enumPlugin,
...resolveReplace(),
esbuild({
tsconfig: path.resolve(__dirname, "tsconfig.json"),
sourceMap: output.sourcemap,
minify: false,
target: isServerRenderer || isCJSBuild ? "es2019" : "es2016",
define: resolveDefine(),
}),
...resolveNodePlugins(),
...plugins,
],
};
}
用的插件还是蛮多的,分点介绍一下
json 引入:能够让 JSON 格式的文件直接导入,
namedExports: false
意思是导入整个 JSON 对象而不是解构导入别名引入:允许为导入路径创建别名,简化导入语句,使 Vue 的内部包可以使用简短路径相互引用,在编译时,这些裸模块名都会被替换为实际的模块路径
javascriptconst entries = { vue: "~/Documents/core/packages/vue/src/index.ts", "vue/compiler-sfc": "~/Documents/core/packages/compiler-sfc/src/index.ts", "@vue/shared": "~/Documents/core/packages/shared/src/index.ts", "..." };
枚举内联插件:扫描项目所有的 TypeScript 枚举声明,将枚举转换为内联常量,能够避免运行时的性能消耗,保留了常量枚举的优势,同时避免了 TypeScript 常量枚举的一些问题
NOTE
最近 TypeScript 官方都不支持写带有 enum 的代码了,就不要学习相关的知识了!而且确实不好用……
替换插件:用来在代码中替换字符串 pattern,比如:
- 替换环境变量和特性标志(如
__DEV__
,__BROWSER__
,__FEATURE_SUSPENSE__
) - 在生产构建中添加
/*@__PURE__*/
注释,帮助压缩工具执行更好的 tree-shaking - 根据构建目标(浏览器或 Node.js)替换平台特定代码
- 替换环境变量和特性标志(如
TypeScript 编译:这个部分和书上有很大不同,书上说 Vue 使用的是
rollup-plugin-typescript2
这个插件来编译打包 TypeScript 相关代码的,但我看现在的版本改用了 esbuild 来执行这部分工作
除了他们还有很多插件,比如用于生产环境的 minify 插件,用来照顾老版本浏览器的 polyfill 插件等等,这里就不过多介绍了