vue3源码解析

Vue3源码解析

准备工作项目结构为翻译官方贡献者指南内容,若翻译有误,尽情谅解。从入口开始以后内容为笔者阅读源码与有关博客时的心得与理解,因为小编能力有限,不会具体讲解各个指令与vue特性的实现方式。而是主要讲述vue源码的整体流程以及patch算法,若有理解不到位的地方,请联系晓蟲进行理性探讨。

  • 准备工作
  • 脚本
  • 项目结构
  • 从入口开始
  • 初探编译-解析
  • 再探编译-变换
  • 编译终点-生成代码字符串
  • 初探运行时
  • patch算法

准备工作

需要Node.js Version 16+和PNPM,同时建议下载ni,ni提供的nr命令可以使npm脚本运行更简单。

$ pnpm i # 下载项目依赖包

使用了以下高阶工具:

  • TypeScript作为开发语言
  • Rollup用于打包
  • Jest用于单元测试
  • Prettier用于代码格式化

脚本

以下所有命令都使用ni包中的nr命令。当然也可以使用npm run,但是需要在命令后面添加额外的参数--,例如nr build runtime --all等价于npm run build -- runtime --all

nr build

bulid脚本可以构建所有公共包(对应包中的package.json没有private: true配置)
可以使用模糊匹配你进行包的构建

# 单独构建runtime-core
nr build runtime-core

# 构建所有能够匹配"runtime"的包
nr build runtime --all

构建格式

默认情况下,每个包将以它的package.json文件下的buildOptions.formats指定的格式构建多版本的发行包。这些格式可以通过-f参数复写,其中支持以下格式:

  • global
  • esm-bundler
  • esm-browser
  • cjs

以下额外的格式只能应用于vue主包:

  • global-runtime
  • esm-bundler-runtime
  • esm-browser-runtime

更多关于格式的细节可以阅读这两个文件进行了解vue的README和rollup的配置文件 //TODO。

例如,使用只使用global格式构建runtime-core:

nr build runtime-core -f global

可以用逗号分隔的列表指定多种格式:

nr build runtime-core -f esm-browser,cjs

生成源映射的构建

使用--sourcemap-s参数可以带源映射构建。
PS:这会导致构建速度变慢。

带类型声明的构建

使用--types-t参数会在构建时生成类型声明

  • 每个包将类型声明集中到一个单独的.d.ts文件中。
  • /temp/.api.md中生成API报告。
  • /temp/.api.json中生成一个API模型json,这个文件可以用来生成导出api的Markdown版本。

nr dev

dev脚本在dev模式下以指定的格式(默认:global)捆绑一个目标包(默认:vue),并监视其变化。

$ nr dev

> watching: packages/vue/dist/vue.global.js
  • dev脚本不支持模糊匹配-你必须指定完整的包名,例如nr dev runtime-core
  • dev脚本支持通过-f参数指定构建格式,就像build脚本一样。
  • dev脚本还支持-s参数来生成源映射,但它会使重构变慢。
  • dev脚本支持-i参数来内联所有deps。这在调试默认将deps外部化的esm-bundler构建时非常有用。//TODO

nr dev-compiler

dev-compiler脚本构建、监听和在http://localhost:5000为Template Explorer文件提供服务,这在调试编译器时非常有用。

nr test

test脚本只是简单地调用jest,所以几乎所有的Jest CLI Options都可以被使用。

# 运行所有测试用例
$ nr test

# 运行runtime-core包下的所有测试用例
$ nr test runtime-core

# 运行指定文件的测试用例
$ nr test fileName

# 运行指定文件的指定测试用例
$ nr test fileName -t 'test name'

默认的test脚本包括--runInBand jest标志,以提高测试的稳定性,特别是CSS转换相关的测试。在测试特定的测试时,也可以直接运行带有标志的npx jest来加速测试(jest默认是并行运行的)。

项目结构

vue3项目是用monorepo创建的,它能够在packages目录里关联多个包,在一个项目里管理多个代码库

  • reactivity : 响应式API,例如toRefreactiveEffectcomputedwatch等,可作为与框架无关的包,独立构建。
  • runtime-core : 平台无关的运行时核心代码。包括虚拟dom渲染、组件实现和JavaScript API。可以使用这个包针对特定平台构建高价运行时(即定制渲染器)。
  • runtime-dom : 针对浏览器的运行时。包括对原生DOM API、属性(attributes)、特性(properties)、事件回调的处理。
  • runtime-test : 用于测试的轻量级运行时。可以在任何JavaScript环境使用,因为它最终只会呈现JavaScript对象形式的渲染树,其可以用来断言正确的渲染输出。另外还提供用于序列化树、触发事件和记录更新期间执行的实际节点操作的实用工具。
  • server-renderer : 服务端渲染相关。
  • compiler-core : 平台无关的编译器核心代码。包括编译器可扩展基础以及与所有平台无关的插件。
  • compiler-dom : 添加了针对浏览器的附加插件的编译器。
  • compiler-sfc : 用于编译Vue单文件组件的低阶工具。
  • compiler-ssr : 为服务端提供优化后的渲染函数的编译器。
  • template-explorer : 用于调试编译器输出的开发者工具。运行nr dev template-explorer命令后打开它的index.html文件,获取基于当前源代码的模板的编译结果。也可以使用在线版本live version
  • shared : 多个包共享的内部工具(特别是运行时包和编译器包所使用的与环境无关的工具)。
  • vue : 用于面向公众的完整构建,其中包含编译器和运行时。

导包

各个包可以直接使用包名导入其他包。
PS:导包时应该使用package.json下所列的包名,大多数情况下需要使用@vue/前缀:

import { h } from '@vue/runtime-core'

主要是通过一下几种方式实现导入前缀:

  • 针对TypeScript,通过tsconfig.jsoncompilerOptions.paths
  • 针对Jest,通过jest.config.jsmoduleNameMapper
  • 针对普通的Node.js,使用PNPM工作空间进行链接。

包依赖关系

                                    +---------------------+
                                    |                     |
                                    |  @vue/compiler-sfc  |
                                    |                     |
                                    +-----+--------+------+
                                          |        |
                                          v        v
                      +---------------------+    +----------------------+
                      |                     |    |                      |
        +------------>|  @vue/compiler-dom  +--->|  @vue/compiler-core  |
        |             |                     |    |                      |
   +----+----+        +---------------------+    +----------------------+
   |         |
   |   vue   |
   |         |
   +----+----+        +---------------------+    +----------------------+    +-------------------+
        |             |                     |    |                      |    |                   |
        +------------>|  @vue/runtime-dom   +--->|  @vue/runtime-core   +--->|  @vue/reactivity  |
                      |                     |    |                      |    |                   |
                      +---------------------+    +----------------------+    +-------------------+

在跨包边界导入时遵循的一些规则:

  • 当从另一个包导入时,不要使用直接相对路径。应在源包进行导出并通过包层级进行导入。
  • 编译包不应该导入运行时包,反之亦然。如果需要在编译器端和运行时端共享某些内容,应该将其提取到@vue/shared中。
  • 如果A包有一个非类型导入,或者从另一个B包重新导出一个类型。那么B包应该作为A包package.json的依赖项。这是因为当一个包使用ESM/-bundler/CJS格式构建和类型声明文件会被外部化。所以当从包注册中心使用依赖包时,必须将依赖包实际安装为依赖包。

从入口开始

在源码阅读阶段,将以贴出关键性源码(所有忽略代码将以/* <功能> */方式替换),辅以注释与流程图的方式进行讲解。

阅读任何源码都应从代码的入口开始,让我们将目光落入vue/src/index.ts文件。入口文件相对简单只声明了一个编译缓存compileCachecompileToFunction编译器函数,并只运行了来自runtime-dom的函数registerRuntimeCompiler(compileToFunction)compileToFunction注册为运行时编译器,并最终导出运行时和编译器。在看源码之前,我们先看一张图来理解这个函数体的作用。

vue入口文件逻辑.png
// Vue入口文件 packages/vue/src/index.ts

//声明编译缓存key为HTML字符串,value为渲染函数
const compileCache: Record = Object.create(null)

function compileToFunction(
  template: string | HTMLElement,
  options?: CompilerOptions
): RenderFunction {
  //如果模板不是字符串,判断是否为dom的node节点,是的话取其innerHTML作为模板
  if (!isString(template)) {
    if (template.nodeType) {
      template = template.innerHTML
    } else {
     /* 错误处理 */
    }
  }

  //如果有缓存渲染函数,返回缓存
  const key = template
  const cached = compileCache[key]
  if (cached) {
    return cached
  }

  //如果模板以'#'开头,表明要找对应id元素
  if (template[0] === '#') {
    const el = document.querySelector(template)
    /* 找不到el,报错 */
    
    //不安全,因为在in-DOM模板中可能执行JS表达式。用户必须确保in-DOM模板可信。
    //如果模板来自服务器,那么必须保证模板中不包含任何用户数数据
    template = el ? el.innerHTML : ``
  }

  const { code } = compile(
    template,
    extend(
      {
        hoistStatic: true, //是否静态提升
        /* 报错处理 */
      } as CompilerOptions,
      options //用户添加的可选项
    )
  )

  /* 报错处理函数 */

 //将code作为参数构建匿名函数并调用,返回结果为渲染函数
  const render = (
    __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
  ) as RenderFunction

  // 将函数标记为运行时编译
  ;(render as InternalRenderFunction)._rc = true

  //返回渲染函数并缓存
  return (compileCache[key] = render)
}

//将编译函数注册到运行时
registerRuntimeCompiler(compileToFunction)

//导出编译器函数与运行时
export { compileToFunction as compile }
export * from '@vue/runtime-dom'

这段代码不难,主要难点是不清楚code变量的值。我们可以打开在项目结构中提到的live version,我们可以看到左边为源码,右边为编译结果,打开console,可以看到抽象语法树AST。


Hello World
//code变量
const code = 'const _Vue = Vue return function render(_ctx, _cache) {  with (_ctx) {    const { openBlock: _openBlock, createBlock: _createBlock } = _Vue     return (_openBlock(), _createBlock("div", null, "Hello World"))  } }'
//至于render就是执行了code构建的匿名函数return的结果

初探编译-解析

在上一章中,我们不难发现入口文件的核心是调用@vue/compiler-domcompile,在这一章我们将初探编译,看看compile是如何解析源码,生成AST。

AST

在剖析源码之前,我们需要先了解什么是AST。Abstract Syntax Tree,即抽象语法树,是对源代码的结构抽象。因此我们对该树进行语义分析,通过变换该抽象结构,而不改变原来的语义,达到优化的目的等等。在前端领域,如果写一个底层框架,AST是不可或缺的技术之一,比如TypeScript、Webpack、babel、ESlint等等。
比如这样一段JS表达式function add(a, b) { return a + b},我们可以将其拆分成如下的语法树:

AST抽象语法树解析.png

解析后的对象,则如下所示:

{
    "type": "FunctionDeclaration",
     "id": {
         "type": "Identifier",
         "name": "add",
         "loc": {/*关于位置的信息*/}
     },
     "params":[
        {
            "type": "Identifier",
            "name": "a",
            "loc": {/*关于位置的信息*/}
        },
         {
            "type": "Identifier",
            "name": "b",
            "loc": {/*关于位置的信息*/}
        }
     ],
     "body":{
         "type": "BlockStatement",
         "body": [
            {
                "type": "ReturnStatement",
                "argument":{
                    "type": "BinaryExpression",
                    "operator": "+",
                    "left": {
                        "type": "Identifier",
                        "name": "a",
                         "loc": {/*关于位置的信息*/}
                    },
                     "right": {
                        "type": "Identifier",
                        "name": "b",
                         "loc": {/*关于位置的信息*/}
                    },
                     "loc": {/*关于位置的信息*/}
                },
                 "loc": {/*关于位置的信息*/}
            }
         ],
         "loc": {/*关于位置的信息*/}
     },
    "generator": false,
    "expression": false,
    "async": false,
    "loc": {/*关于位置的信息*/}
}

如果我们将该AST结构进行改变,就能将原来的普通函数声明,变换为匿名函数赋值。使用recast包进行以下操作:

const recast = require("recast");

const code =`function add(a, b) {return a +b}`
const ast = recast.parse(code);
const add  = ast.program.body[0]
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, 
    add.params,
    add.body
  ))
]);
//将AST对象重新转回可以阅读的代码
const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

console.log(output) 
/*
const add = function(a, b) {
  return a + b;
};
*/

compile

好的,在了解AST之后,让我们将目光重新聚焦到Vue的compile函数。compile只是简单地调用了@vue/compiler-corebaseCompile,传入符合浏览器的CompilerOptions,并将结果返回。

export function compile(
  template: string,
  options: CompilerOptions = {}
): CodegenResult {
  return baseCompile(
    template,
    // parserOptions包含适用于浏览器的辅助函数,options为用户传入的选项
    extend({}, parserOptions, options, {
     //nodeTransforms列表会对抽象语法树的node节点进行特定变换
      nodeTransforms: [
        // 忽略 
                    
                    

你可能感兴趣的:(vue3源码解析)