Vue3 源码不同版本如何工作和编译构建(源码解读)

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 buildnpm 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 的 privatebuildOptions 字段,只要 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.tssrc/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 值是 vuepackageDir 映射到源码 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 是由 outputConfigsformat 配置而来

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.jsdev.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

相关推荐
真滴book理喻1 小时前
Vue(四)
前端·javascript·vue.js
不是鱼3 小时前
构建React基础及理解与Vue的区别
前端·vue.js·react.js
开心工作室_kaic3 小时前
springboot476基于vue篮球联盟管理系统(论文+源码)_kaic
前端·javascript·vue.js
川石教育3 小时前
Vue前端开发-缓存优化
前端·javascript·vue.js·缓存·前端框架·vue·数据缓存
搏博3 小时前
使用Vue创建前后端分离项目的过程(前端部分)
前端·javascript·vue.js
isSamle3 小时前
使用Vue+Django开发的旅游路书应用
前端·vue.js·django
ss2734 小时前
基于Springboot + vue实现的汽车资讯网站
vue.js·spring boot·后端
武昌库里写JAVA5 小时前
浅谈怎样系统的准备前端面试
数据结构·vue.js·spring boot·算法·课程设计
TttHhhYy5 小时前
uniapp+vue开发app,蓝牙连接,蓝牙接收文件保存到手机特定文件夹,从手机特定目录(可自定义),读取文件内容,这篇首先说如何读取,手机目录如何寻找
开发语言·前端·javascript·vue.js·uni-app
CodeChampion7 小时前
61.基于SpringBoot + Vue实现的前后端分离-在线动漫信息平台(项目+论文)
java·vue.js·spring boot·后端·node.js·maven·idea