Vue3 源码版本
当我们通过 npm install vue
安装 vue3 之后,可以看到 node_modules/vue/dist
目录下有 12个构建版本
不同构建版本分类
版本 | 完整版 | 运行时版(runtime) |
---|---|---|
cjs | .cjs.js | |
esm-browser | .esm-browser.js | .runtime.esm-browser.js |
esm-bundler | .esm-bundler.js | .runtime.esm-bundler.js |
global | .global.js | .runtime.global.js |
先介绍一下这个表中一些词的意思
- 完整版:包含了 compiler 编译器模块,用来将(template)模板字符串编译成渲染函数
- 运行时版本:不包括编译器 compiler 模块,所以体积更小,如果导入的vue是运行时版本,则要求在构建期间就要编译好
- cjs:CommonJs,常用在 nodejs 服务端的一种模块导入标准,
- esm-browser:用于浏览器通过原生 ES 模块导入使用,在浏览器中通过
<script type="module">
- esm-bundler:用于构建工具(vite、webpack,rollup等)使用原生 ES 模块导入
- global:全局变量版本,使用
script
CDN 引入
对于不同版本的使用是这样分的
-
通过 CDN
script
标签导入,使用带有global
后缀文件,如vue(.runtime).global(.prod).js
版本 -
通过ES6模块导入,
<script type="module">
使用esm-browser
后缀文件,如vue(.runtime).esm-browser(.prod).js
版本 -
通过 Vite、Webpback构建工具进行 npm 安装开发,使用
esm-bundler
后缀文件,如vue(.runtime).esm-bundler.jsesm-bundler
版本 -
用于 Node.js的服务器端渲染使用
cjs
后缀文件,如vue.cjs(.prod).js
版本
其中带有 prod
后缀的,是经过压缩后、删除 console
用于生产环境的代码
在 Vite 脚手架安装的 Vue 项目,查看 core/packages/vue/package.json
,引入的模块是 vue.runtime.esm-bundler.js
文件
js
{
"module": "dist/vue.runtime.esm-bundler.js",
"exports": {
".": {
"import": {
"types": "./dist/vue.d.mts",
"node": "./index.mjs",
"default": "./dist/vue.runtime.esm-bundler.js"
}
},
}
Vue 源码构建
在Vue3中,根据实际需要的不同,分为生产环境和开发环境,使用 npm run build
、 npm run dev
构建。其中生产环境使用 rollup 进行打包,而开发阶段使用 esbuild 作为构建工具
我们看下面代码:
json
// 所属文件:core/package.json 注:Vue3目前的工程目录名称是core
{
// 这里省略许多其他内容...
"scripts": {
"dev": "node scripts/dev.js",
"build": "node scripts/build.js"
// 这里省略许多其他内容...
}
// 这里省略许多其他内容...
}
生产构建编译
在根目录 npm run build
执行 scripts/build.js
,以下是 build.js
核心代码
js
// 此处省略一些代码...
run()
async function run() {
// 此处省略一些代码...
await buildAll(allTargets)
// 此处省略一些代码...
}
async function buildAll(targets) {
await runParallel(require('os').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, source))
ret.push(p)
// 此处省略一些代码...
}
return Promise.all(ret)
}
async function build(target) {
const pkgDir = path.resolve(`packages/${target}`)
const pkg = require(`${pkgDir}/package.json`)
// 此处省略一些代码...
await execa(
'rollup',
[
'-c',
'--environment',
[
`COMMIT:${commit}`,
`NODE_ENV:${env}`,
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
buildTypes ? `TYPES:true` : ``,
prodOnly ? `PROD_ONLY:true` : ``,
sourceMap ? `SOURCE_MAP:true` : ``
]
.filter(Boolean)
.join(',')
],
{ stdio: 'inherit' }
)
// 此处省略一些代码...
}
// 此处省略一些代码...
代码省去了构建 d.ts
文件以及其他许多相对次要的逻辑,经过精简后,build.js
的核心流程做了下面两件事情:
- 获取
packages
目录下的所有子文件夹名称,作为子项目名,对应代码片段中的allTargets
; - 遍历每一个子项目,获取子项目的
package.json
文件,并构造相应参数,为每一个子项目并行执行rollup
命令,将构造好的参数传入。
当然完整的 build.js
,还包括了很多边界条件判断,以及参数处理等逻辑,但是只要把握了这个核心流程,相信大家可以轻松理解其他逻辑。下面详细分析执行流程。
1、run
构建入口函数,此时 args._
不带参数 targets
为空数组,构建目标使用 allTargets
js
import { targets as allTargets } from './utils.js'
const args = minimist(process.argv.slice(2))
const targets = args._
run()
// 入口执行函数
async function run() {
const resolvedTargets = targets.length
? fuzzyMatchTarget(targets, buildAllMatching)
: allTargets
await buildAll(resolvedTargets)
//...
}
2、过滤编译目标
allTargets
是从 utils.js
引入 targets
别名。 读取 packages 目录下子包 package.json
的配置信息,过滤出哪些包需要构建
js
// scripts/utils.js
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
})
上面这段逻辑就是遍历 packages
目录下的子包,读取每个包中的 package.json
文件,获取 JSON 对象的 pkg。然后判断 pkg 的 private
和 buildOptions
字段,只要 private 不为 true 或者配置了 buildOptions,那么该包就是编译的目标
经过遍历处理后,最终获得 targets
的值为
js
[
'compiler-core',
'compiler-dom',
'compiler-sfc',
'compiler-ssr',
'reactivity',
'runtime-core',
'runtime-dom',
'server-renderer',
'shared',
'template-explorer',
'vue',
'vue-compat'
]
并行编译 buildAll
获得编译目标之后,为了提高编译效率,Vue.js 采用了并行编译的方式。因为每个包的编译都是一个异步过程,并且它们之间的编译没有依赖关系,所以可以并行编译。相关代码
js
async function buildAll(targets) {
await runParallel(cpus().length, targets, build)
}
buildAll 函数只有单个参数,传入的 targets 是前面获取的编译目标,buildAll 内部通过 runParallel 实现并行编译,来看一下它的实现
js
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)
}
runParallel 函数三个参数,maxConcurrency 是最大并发数,根据计算机中 CPU 的最大核心数得到,source 传入的是编译目标 targets;iteratorFn 传入的是单个编译函数 build
;
这个函数值得学习的地方有两个点,一是可以通过 Promise.all('一个promise实例数组')
让多个任务并行执行。二是参数 maxConcurrency
实际上传入的值是通过 require('os').cpus().length
得到的CPU核数,通过CPU核数和任务数做比较,再在一定条件下利用 await Promise.race(executing)
保证任务数量不大于CPU核数,实现了并行和串行的有机结合
单个编译
真正执行单个包编译的是 build 函数,来看下它的实现
js
async function build(target) {
const pkgDir = path.resolve(`packages/${target}`)
const pkg = require(`${pkgDir}/package.json`)
// 只编译公共包
if ((isRelease || !targets.length) && pkg.private) {
return
}
// if building a specific format, do not remove dist.
if (!formats && existsSync(`${pkgDir}/dist`)) {
await fs.rm(`${pkgDir}/dist`, { recursive: true })
}
const env =
(pkg.buildOptions && pkg.buildOptions.env) ||
(devOnly ? 'development' : 'production')
// 执行 rollup 命令,运行 rollup 打包工具
await execa(
'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' },
)
}
build 函数只有单个参数 target,表示目录名字符串,根据 target,我们能获取到对应目录下的 package.json 文件描述文件,取到每个包的 buildOptions 字段,这个属性用来描述与包编译相关的配置。然后通过运行 rollup 命令,并传入环境变量,就可以对每个包进行编译
例如看下 reactivity 文件夹中的 package.json
文件配置
js
{
"name": "@vue/reactivity",
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
},
}
所以最终每个包还是通过 rollup 打包工具进行编译,具体的编译方式就需要看 rollup 是如何配置的了
rollup 配置
rollup 的配置在 rollup.config.js
中,该文件最终导出一个对象类型的配置数组,单个配置的格式大致如下
js
{
input,
output,
external,
plugins
}
input 是编译打包的入口文件,output 是编译后的目标文件,external 说排除在目标文件之外的第三方库,而 plugins 是编译中用到的一些插件
以打包编译 vue
这个包为例,运行 npm run build vue
打包 packages/vue
,真正执行命令是这样的
js
await execa(
'rollup',
[
'-c',
'--environment',
'COMMIT:d276a4f,NODE_ENV:production,TARGET:vue',
],
{ stdio: 'inherit' },
)
输入输出
重点关注 input 和 output 字段,了解每个包编译的入口文件有哪些,又会编译生成哪些文件
因为编译配置对象最终通过 createConfig
函数创建,我们倒退方式来分析,截取其中与 input 和 output 相关代码
js
function createConfig(format, output, plugins = []) {
if (!output) {
console.log(pico.yellow(`invalid format: "${format}"`))
process.exit(1)
}
//...
output.sourcemap = !!process.env.SOURCE_MAP
output.externalLiveBindings = false
if (isGlobalBuild) {
output.name = packageOptions.name
}
//...
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`
return {
input: resolve(entryFile),
output,
// ...
}
}
从上述代码可以看出,input 的来源是 resolve(entryFile)
,而 entryFile 的值取决于 format 的格式,要么是 src/runtime.ts
或 src/index.ts
。resolve 函数实现如下
js
const packagesDir = path.resolve(__dirname, 'packages') // 相对 packages 目录绝对路径
const packageDir = path.resolve(packagesDir, process.env.TARGET) // packages/vue
const resolve = (p) => path.resolve(packageDir, p)
npm run build vue
此时环境变量 process.env.TARGET
值是 vue
,packageDir
映射到源码 packages/vue
包,执行resolve后,对应的 input
就是 packages/vue/src/runtime.ts
或者 packages/vue/src/index.ts
接下来看 format
值如何来
js
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}
const defaultFormats = ['esm-bundler', 'cjs']
const inlineFormats = /** @type {any} */ (
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]))
最终通过遍历 packageFormats
拿到每一个 format
,然后执行 createConfig
,传入 format 构建编译配置对象
packageFormats
值优先取 inlineFormats
也就是执行 rollup 命令环境变量中配置的 FORMATS
属性值
inlineFormats 为空,继续找 packageOptions.formats
,packageOptions 值对应就是每个包 package.json
文件中定义的 buildOptions
属性,那么 vue 包的package.json
文件 buildOptions
定义如下
json
{
"buildOptions": {
"name": "Vue",
"formats": [
'esm-bundler',
'esm-bundler-runtime',
'cjs',
'global',
'global-runtime',
'esm-browser',
'esm-browser-runtime'
]
}
}
可以看到它有多种 formats,虽然只有两个 input 入口文件,但是 output 在 format 格式输出有多种,output 的值是一个对象,它的大致格式如下:
js
{
name,
dist,
format,
sourcemap
}
其中,name 表示编译的目标文件名,dist 表示编译的目标目录,format 表示文件编译的格式,sourcemap 表示是否开启 source map
js
packageFormats.map(format => createConfig(format, outputConfigs[format]))
createConfig 函数传入的第二个参数 output
,而这个 output 是由 outputConfigs
和 format
配置而来
js
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 有三种,es 表示 ES 模块,会生成 xxx.esm-bundler.js
或者 xxx.esm-browser.js
;csj 表示 Common JS 模块,会生成 xxx.cjs.js
;iife 表示立即执行函数,会生成 xxx.global.js
至此,我们可以了解到,根据 format 的不同,编译过程会有不同的 input 输入,也会编译生成不同的 output 输出文件
以下是编译输出的文件
dev 开发环境的构建
上文提到过,执行 npm run dev
,其实执行的就是 dev.js
文件中的程序。该程序的职责是构建出开发环境可用的 vue.global.js
文件,这对调试 Vue 来说非常有用。
不同生产环境,开发环境使用的构建工具是 esbuild
,这个工具速度很快,适合在开发环境下使用。而 rollup
虽然速度没有 esbuld
快,但是生成的结果文件体积更小,而且对 treeshaking
的支持也更加良好。
这里体现了,框架作者们对技术选型的用心,同时也能一定程度体现其开发者知识的广度和深度,对各个工具如果只是简单了解,不太容易做出正确科学的选择。
js
const { build } = require('esbuild')
// 此处省略许多代码
build({
entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
outfile,
bundle: true,
external,
sourcemap: true,
format: outputFormat,
globalName: pkg.buildOptions?.name,
platform: format === 'cjs' ? 'node' : 'browser',
plugins:
format === 'cjs' || pkg.buildOptions?.enableNonBrowserBranches
? [nodePolyfills.default()]
: undefined,
define: {
__COMMIT__: `"dev"`,
__VERSION__: `"${pkg.version}"`,
__DEV__: `true`,
__TEST__: `false`,
__BROWSER__: String(
format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches
),
__GLOBAL__: String(format === 'global'),
__ESM_BUNDLER__: String(format.includes('esm-bundler')),
__ESM_BROWSER__: String(format.includes('esm-browser')),
__NODE_JS__: String(format === 'cjs'),
__SSR__: String(format === 'cjs' || format.includes('esm-bundler')),
__COMPAT__: `false`,
__FEATURE_SUSPENSE__: `true`,
__FEATURE_OPTIONS_API__: `true`,
__FEATURE_PROD_DEVTOOLS__: `false`
},
watch: {
onRebuild(error) {
if (!error) console.log(`rebuilt: ${relativeOutfile}`)
}
}
}).then(() => {
console.log(`watching: ${relativeOutfile}`)
})
这里省略了许多代码,但核心逻辑很简单,接收"构建目标"参数,调用 esbuild
提供的 build
函数对参数对应的子项目进行构建。
比如执行 npm run dev reactivity
,将会对子项目 reactivity
进行构建。其实上文执行 pnpm run build reactivity
也会对 子项目 reacitivity
进行构建。不同的是,如果不传参数,执行 pnpm run dev
会默认构建子项目 vue
,而执行npm run build
则会对所有的子项目进行构建。
相较于 build.js
,dev.js
默认开启了 sorcemap
,构建完成会生成 soucemap
相关的文件,方便我们调试,当然 build.js
中也可以开启 sourcemap
配置,但同时还需要在 ts
的配置文件中开启 sorcemap
配置。
在 dev.js
中,还默认开启了对文件系统中文件变化的监听,当监听到有文件发生变化,如果 esbuild
认为该变化可能会引起构建结果文件发生变化,那么就会重写执行构建流程生成新的构建结果,这个监听文件系统变化的配置对应上面代码片段中的 watch
属性
调试案例
了解了如何对 Vue3
进行构建,下面就呈现一个小案例,对我们的 Vue3 中的子项目 reactivity
的源码进行调试。至于 reactivity
的具体功能和实现,本文不会讲解
1、构建reactivity
在vue3工程根目录下执行下面的命令
js
pnpm install
pnpm run dev reactivity
此时会在 core/packages/reactivity/dist
路径下生成下面两个文件:
- reactivity.global.js
- reactivity.global.js.map
注意,此时控制台会有这样一行提示 built: packages/reactivity/dist/reactivity.global.js
,意味着当 reactivity
中的代码发生变化会重写构建文件。
其中 reactivity.global.js
文件中的内容如下
js
var VueReactivity = (() => {
// 此处省略1000多行代码...
})();
2、在html页面中引入reactivity.global.js
在 core/packages/reactivity/dist
目录下新建 test.html
文件,内容如下:
html
<html>
<head></head>
<body>
<div id="app"></div>
</body>
<script src="reactivity.global.js"></script>
<script>
const { effect, reactive } = VueReactivity
let data = reactive({message: '你好,世界'});
effect(()=>{
document.getElementById('app').innerText = data.message;
})
setTimeout(()=>{
data.message = "hello world"
}, 3000)
</script>
</html>
在浏览器打开页面 test.html
,你好,世界
三秒后自动变成了 hello world
,验证了Vue3的响应式的原理
3、设置断点进行debug
假如,我们此时想调试函数effect的内部实现,我们可以在effect函数内部打上断点:
js
// 所属文件:core/packages/reactivity/src/effect.ts
export class ReactiveEffect<T = any> {
// 省略若干代码
run() {
debugger
// 省略若干代码
}
// 省略若干代码
}
此时,保存文件,将会自动触发重新构建。此时打开浏览器调试工具,刷新 test.html
页面,会发现停留在了断点处:
有点需要注意:由于开启 sourcemap
的缘故,调试时候看见的文件是 effect.ts
,而非引入的 reactivity.global.js