vue3源码阅读与实现: 基础概念+源码阅读环境+源码实现环境+阅读原则

151 阅读12分钟

本文是专栏的第一篇,主要了解一些基本概念,准备源码调试和实现的环境

一些概念

名词概念

编程范式

命令式

面向过程: 以描述怎么做的形式来实现功能

声明式

面向结果: 以描述做什么的形式来实现功能

声明式是建立在命令式之上的,因为一定是经过了某些过程,才能达到某个结果

命令 vs 声明

衡量某种编程范式可以从下面两个方面入手:

  • 性能
  • 可维护性:代码是否方便阅读和修改

对于命令式来说: 只做了能达到结果的必要步骤,其性能要更好一些,但是其随着过程的复杂而更加复杂,因此可维护性会较低

对于声明式来说: 声明式基于命令式所以其性能必然没有命令式好,但其只关心结果,不关心复杂的实现过程,代码复杂度不会随着业务的复杂而疯狂上涨,因此可维护性较高

应用开发和设计原则

企业中开发应用时,最关注两个点:

  • 项目成本
  • 开发体验

项目成本取决于开发周期,开发周期越长,人力成本越大,项目成本就越大

开发体验主要取决于开发时的难度和维护时的难度

声明式的高可维护性在这两点中发挥着至关重要的作用,因此声明式编程才会越来越成为主流.

运行时

运行时runtime,代码运行的过程

vue框架中表示: 利用render函数将vnode渲染的过程,对应的就是h函数和render函数

编译时

编译时complier: 源代码转换为可执行代码的过程

vue框架中表示: 将不同的template编译生成对应的render函数,对应的就是框架中的complie函数

运行时+编译时

了解了运行时和编译时的概念,不难发现,vue是一个编译时+运行时的框架.在编译时将template转换成render函数,在运行时通过render函数将vnode渲染

why

那么为什么vue设计成运行时+编译时的框架呢?

要解答这个问题,需要从DOM渲染开始聊:

DOM渲染分为两种:

  • 初次渲染:也叫挂载,指DOM的第一次渲染
  • 更新渲染:也叫打补丁,指DOM的后续更新

挂载过程就是将DOM从无到有的创建出来,更新过程却不只有一种选择:

  • 全量更新: 无脑替换容器内所有DOM
  • 增量更新:通过js对比出前后差异,只更新差异的DOM (这个找差异过程需要使用到vDOM)

这两种方式同等量级下(渲染nDOM/创建n个虚拟DOM对象)在性能上后者是前者的十倍.js的运行速度要比DOM更新速度快的多.

那么回到vue这个话题上,在保证更新渲染性能的前提下(这是必要的):

如果vue使用纯运行时,确实可以使用js来对比差异进行增量更新,那么就需要提供一个非常复杂的虚拟DOM对象供render函数进行使用

如果vue使用纯编译时,没有了运行时,那么对比差异只能在编译时进行,这种方式的灵活性将大大降低

因此,vue采用编译时+运行时来在性能和灵活性之间达到一种平衡

副作用

副作用指的是函数在计算过程中与函数外界产生的可观察交互,在vue中可以这样理解:当我们对数据进行gettersetter时产生的一切后果

setter:

const modify  = () => {
    msg = 'new msg'
}
在vue中对响应式数据赋值,会引起视图的自动改变,那么这个视图的改变就是这个setter操作引起的副作用

getter:

const msg = 'msg'
在vue中初始化了一个响应式数据,DOM挂载后,视图会显示这个响应式数据的值,那么这个视图的改变就是getter操作引起的副作用

vue的核心模块

现在我们了解了命令式,声明式,运行时,编译时,vue的运行时+编译时,副作用等等一些概念

下面是vue3三大核心模块:

  • 响应性: reactive
  • 运行时: runtime
  • 编译器: compiler

本次源码解析,主要从这三大模块入手.

模块对应的源码地址:vuejs,其中:

响应性在: 以reactive中
运行时在: 以runtime开头的文件中
编译器在: 以compiler开头的文件中

在一个单文件组件中,三个模块的配合而成的渲染流程为:在reactive模块定义响应式数据,通过compiler模块编译模板生成组件的render函数,在runtime模块将组件通过h转换成虚拟DOM通,最终过render函数渲染

vue3与ts

vue3ts优良的支持,并不是因为vue3是由ts编写的,ts编写和提供ts类型支持是两码事,ts类型支持需要编写相应的类型,才能在使用的时候得到良好的类型支持,vue3ts友好主要是因为:

  1. vue3在核心库和插件中提供了完整的ts类型声明
  2. 为组件提供更强大的类型推导,不再需要显示的定义类型
  3. vue3ts的装饰器做了改进,可以使用装饰器对组件类型进行声明
  4. ...等等

正是vue3对这些方面做了优化,才使得vue3ts提供友好的支持

环境准备

vue源码下载

  1. 这里为了方便下载,我把vue3.2.37版本的代码上传到了个人仓库vue-3.2.37,需要的直接clone即可

  2. 如果想从官网下载,则通过以下方式

    1.拉取官方仓库代码: git clone https://github.com/vuejs/core.git
    2.根据3.2.37版本的tag创建新的分支: git checkout tags/v3.2.37 -b v3.2.37
    

vue项目结构

vue-next-3.2.37                                          
├─ packages//核心代码                                              
│  ├─ compiler-core//重要: 编译器核心代码                      
│  ├─ compiler-dom//重要: 浏览器相关编译模块                                       
│  ├─ compiler-sfc//单文件组件的编译模块                                  
│  ├─ compiler-ssr//服务端渲染的编译模块                                       
│  ├─ reactivity// 重要: 响应式的核心模块                                         
│  ├─ reactivity-transform// 已过期,不关注                               
│  ├─ runtime-core// 重要: 运行时核心代码,内部针对不同平台进行了实现                                       
│  ├─ runtime-dom// 重要: 浏览器相关的编译模块                                        
│  ├─ runtime-test// runtime的测试模块                                       
│  ├─ server-renderer// 服务器渲染                                    
│  ├─ sfc-playground// sfc工具,比如:https://sfc.vuejs.org/                                     
│  ├─ shared// 重要: 共享工具类                                             
│  ├─ size-check// 测试运行时包的大小                                         
│  ├─ template-explorer//提供一个线上的测试,https://template-explorer.vuejs.org),用于把 tempalte 转化为 render
│  ├─ vue//重要: 测试实例,打包之后的dist都会放在这里                                                
│  ├─ vue-compat// 用于兼容vue2的代码                                         
│  └─ global.d.ts// 全局ts声明                                        
├─ scripts//配置文件相关,无需关注                                               
├─ test-dts//测试文件相关,无需关注                                              
├─ api-extractor.json// TS的API分析工具                                    
├─ BACKERS.md// 赞助声明                                            
├─ CHANGELOG.md // 更新日志                                         
├─ jest.config.js// 测试相关配置                                        
├─ LICENSE //开源协议                                              
├─ netlify.toml// 自动化部署相关                                          
├─ package.json// npm包管理工具                                          
├─ pnpm-lock.yaml// 使用pnpm下载的依赖包版本                                        
├─ pnpm-workspace.yaml//pnpm相关配置                                   
├─ README.md// 项目声明文件                                             
├─ rollup.config.js//  rollup配置文件                                     
├─ SECURITY.md//报告漏洞,维护安全的声明文件                                           
└─ tsconfig.json// ts配置文件                                         

打包并测试

  1. 安装pnpm,vue使用的pnpm,所以先安装一下

    npm i -g pnpm
    
  2. 安装依赖

    pnpm i
    
  3. 打包,将在/packages/vue文件夹下生成dist文件夹,这个过程会比较耗时

    npm run build
    

    打包完成之后会在/packages/vue/dist中生成打包文件

  4. 编写测试

    新建文件夹packages/vue/examples中新建自己的测试文件夹myTest,新建测试文件reactive.html:

    编写测试,open live server,查看效果:

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <title>Page Title</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script src="../../dist/vue.runtime.global.js"></script>
      </head>
      <body>
        <div id="app"></div>
        <script>
          const { reactive, effect } = Vue
          const data = reactive({ name: '哈哈哈' })
          effect(() => {
            document.querySelector('#app').innerHTML = data.name
          })
          setTimeout(() => {
            data.name = '嘿嘿'
          }, 2000) // 响应式数据将在两秒后修改为嘿嘿,此时页面应自动响应
        </script>
      </body>
    </html>
    

如何debugger

开启sourceMap

打包后的文件由于没有sourceMap所以运行测试用例时,浏览器devtool中都是压缩混淆过的文件,为了更好的阅读,首先需要修改vue的打包配置,使打包结果中包含sourceMap文件

分析:

  1. 来到package.json查看打包命令,从这里可以看出vue打包时都干了什么

    "build": "node scripts/build.js"
    

    显然,执行打包命令,相当于执行了scripts下的build.js,找到这个文件,看看内部做了什么

  2. 进去文件后,搜索sourceMap,可以找到:

    ...
    const args = require('minimist')(process.argv.slice(2))
    ...
    const sourceMap = args.sourcemap || args.s
    ...
    sourceMap ? `SOURCE_MAP:true` : ``
    ...
    

    从下往上看,如果sourceMaptrue,那么SOURCE_MAP:true就会生效,sourceMap的值又由args决定,那么可以查查这个minimist是哪里来的

  3. minimist

    npm中该包的示例如下:

    $ node example/parse.js -a beep -b boop
    { _: [], a: 'beep', b: 'boop' }
    

    也就是说,这个包的作用是从启动命令中获取一些参数

  4. 回到第2步,看来sourceMap的值取决于启动命令中sourceMap或者s的值

  5. 修改一下启动命令

    "build": "node scripts/build.js -s",
    
  6. 重新打包,在浏览器控制台sources中查看上述写的测试用例,看看是否能看到源码

  7. 不出意外,可以在sources中看到多了一个packages文件夹,打开里面就是源码了

debugger

有了源码之后,一切就简单起来了,由于我们的用例是和reactive相关的,在浏览器控制台找到reactive/reactive.ts,搜索reactive函数,然后在函数内部随便点一行,打个断点,刷新页面就可以看到,代码成功进入debugger

框架雏形搭建

直接clone(推荐)

接下来初始化一下项目结构,之后将边阅读源码,边实现一个简易版的vue,.直接clonemini-vue,然后将目录文件中已经写好的内容删除即可

  • 安装依赖npm i
  • 运行打包:npm run dev

不想clone的同学,可以按照以下步骤自行搭建

项目结构

新建一个空文件夹将来的vue就放在这里,起名为mini-vue

packages          
├─ compiler-core // 编译器核心模块  
├─ compiler-dom  // 浏览器相关编译器模块 
├─ reactivity    // 响应式 
├─ runtime-core  // 运行时核心模块 
├─ runtime-dom   // 浏览器相关运行时模块 
├─ shared        // 共享工具库 
├─ vue           // 项目入口,测试用例,打包结果

TS

安装:

npm i --save-dev tslib@2.4.0  typescript@4.7.4

生成配置文件:

npx tsc -init

配置ts,在生成的ts.config.js中添加配置:

// https://www.typescriptlang.org/tsconfig,也可以使用 tsc -init 生成默认的 tsconfig.json 文件进行属性查找
{
    // 编辑器配置
    "compilerOptions": {
        // 根目录
        "rootDir": ".",
        // 严格模式标志
        "strict": true,
        // 指定类型脚本如何从给定的模块说明符查找文件。
        "moduleResolution": "node",
        // https://www.typescriptlang.org/tsconfig#esModuleInterop
        "esModuleInterop": true,
        // JS 语言版本
        "target": "es5",
        // 允许未读取局部变量
        "noUnusedLocals": false,
        // 允许未读取的参数
        "noUnusedParameters": false,
        // 允许解析 json
        "resolveJsonModule": true,
        // 支持语法迭代:https://www.typescriptlang.org/tsconfig#downlevelIteration
        "downlevelIteration": true,
        // 允许使用隐式的 any 类型(这样有助于我们简化 ts 的复杂度,从而更加专注于逻辑本身)
        "noImplicitAny": false,
        // 模块化
        "module": "esnext",
        // 转换为 JavaScript 时从 TypeScript 文件中删除所有注释。
        "removeComments": false,
        // 禁用 sourceMap
        "sourceMap": false,
        // https://www.typescriptlang.org/tsconfig#lib
        "lib": ["esnext", "dom"]
    },
    // 入口
    "include": [
        "packages/*/src"
    ]
}

格式化

安装prettier插件,新建配置文件.prettier.cjs,为了减少项目复杂度,eslint这里没有配置

module.exports = {
  printWidth: 120,
  tabWidth: 2,
  useTabs: false,
  singleQuote: true,
  semi: true,
  trailingComma: 'none',
  bracketSpacing: true,
  quoteProps: 'as-needed',
  proseWrap: 'always', // 超过最大宽度是否换行<always|never|preserve>,默认preserve
  arrowParens: 'avoid', // 在单独的箭头函数参数周围包括括号 always:(x) => x \ avoid:x => x
  requirePragma: false, //无需顶部注释即可格式化
  insertPragma: false, //在已被preitter格式化的文件顶部加上标注
  trailingComma: 'none', //尾部逗号设置,es5是尾部逗号兼容es5,none就是没有尾部逗号,all是指所有可能的情况,需要node8和es2017以上的环境。(trailingComma: "<es5|none|all>"
  bracketSameLine: false, // 将>多行 HTML(HTML、JSX、Vue、Angular)元素放在最后一行的末尾,而不是单独放在下一行(不适用于自关闭元素)<bool>,默认false
  singleAttributePerLine: true //  HTML、Vue  JSX 中强制执行每行单个属性<bool>,默认false
};

模块构建工具

vue使用rollup作为打包工具,我们也是用rollup作为打包工具,他比webpack产生的冗余代码少,非常适合作为库开发的打包工具

根目录新建rollup配置文件rollup.config.js

安装一下插件:

npm i -D @rollup/plugin-commonjs@22.0.1 @rollup/plugin-node-resolve@13.3.0 @rollup/plugin-typescript@8.3.4"@rollup/plugin-commonjs": "^22.0.1", 将CommonJs模块转换为ES Module
"@rollup/plugin-node-resolve": "^13.3.0", 路径自动补全
"@rollup/plugin-typescript": "^8.3.4" ts支持

配置文件:


import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import typescript from "@rollup/plugin-typescript";
​
// 默认导出一个数组,数组的每一个对象都是一个单独的导出配置,https://www.rollupjs.com/guide/big-list-of-options
export default [
  {
    input: "packages/vue/src/index.ts", //入口文件
    output: [
      // 出口文件
      {
        sourcemap: true, // 开启sourcemap
        file: "./packages/vue/dist/vue.js", // 出口文件输出地址
        format: "iife", // 出口文件为IIFE函数模式
        name: "Myvue", // 全局变量名称,类比Vue
      },
    ],
    // 插件
    plugins: [
      // ts 支持
      typescript({
        sourceMap: true,
      }),
      // 模块导入的路径补全
      resolve(),
      // 将 CommonJS 模块转换为 ES2015
      commonjs(),
    ],
  },
];

路径映射

方便进行模块导入导出,给路径加一个映射,将packages文件夹映射到@vue,在ts.config.js中添加配置:

"baseUrl": ".",
"paths": {
  // 路径映射
  "@vue/*": ["packages/*/src"]
}

添加启动命令

package.json中:

"build": "rollup -c",
"dev": "rollup -c -w", // 开发模式

如何阅读源码

核心思想

以测试用例为主导,只关注核心逻辑,不关注边缘业务,将有限的精力放在更重要的事情上.

边缘业务指: 各种边界判断,当前debugger没有走的if分支,这些对理解用例所涉及的功能模块的实现没有太多帮助

阅读步骤

  1. 打包生成源码

    执行npm run build,会在packages/vue/dist下生成文件

  2. 编写想要查看的模块测试文件,如想查看reactive的实现,编写reactive.html

    ...
    <script src="../../dist/vue.global.js"></script>
    ...
    <script>
      const { reactive, effect } = Vue
      debugger;
      const data = reactive({ name: '哈哈哈' })
    ​
      effect(() => {
        document.querySelector('#app').innerHTML = data.name
      })
    ​
      setTimeout(() => {
        data.name = '嘿嘿'
      }, 2000)
    </script>
    
  3. 运行这个html,打开浏览器控制台,在source中找到reactive.html,打上断点,刷新,即可一步一步调试.在环境准备一节中,已经开启了sourcemap,所以这里可以直接进入源码

image-20240711003028667.png OK,环境准备到这样就可以了,下一篇将从vue3响应式系统的reactive函数,开启正式的源码之旅~