# small-vue **Repository Path**: yuhuo520/small-vue ## Basic Information - **Project Name**: small-vue - **Description**: 简版 vue3 - **Primary Language**: TypeScript - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2023-09-06 - **Last Updated**: 2024-03-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # small-vue 我们知道 vue 的源码非常庞大复杂,即使我们只学习其中部分核心包,其代码量依然惊人。因为这其中不仅有一些高级功能的实现,还有很多边缘情况的处理。这使我们阅读源码、理解源码变成一个很困难的过程,很容易被劝退。 因此我手写了一个简版的 vue3 源码:[**small-vue**](https://gitee.com/yuhuo520/small-vue),只实现核心包中的核心逻辑,使代码尽量简单易理解。同时 small-vue 中的文件名、方法名都尽量与 vue3 源码中保持一致,有利于相互对照理解。 本文将作为学习指南,通过 small-vue 来指引大家如何学习 vue3 源码,最后祝大家也能实现一个自己的简版 vue3! ## 1. 实现功能 - **reactivity** - [x] 支持 reactive,ref,computed 等响应式对象 - [x] 支持 effect 函数监听响应式操作 - [x] 基于 Jest 进行单元测试 - **runtime-core** - [x] 支持元素类型、组件类型、虚拟类型、文本类型等多种虚拟节点 - [x] 支持虚拟节点解析,初始化,更新等流程 - [x] 支持双端 Diff 算法 - [x] 支持 nextTick,queueJobs 等异步操作 - [x] 支持 props、emit、provide、inject 等功能 - [x] 支持插槽 Slots - [x] 支持 createRenderer,createApp 等入口函数 - **runtime-dom** - [x] 支持 Dom 渲染器 - **compiler-core** - [x] 支持 parse 操作,将 template 字符串转换成抽象语法树 AST - [x] 支持 transform 操作,遍历 AST 进行某些转换处理 - [x] 支持 codegen 操作,将 AST 转成成 render 函数 - **vue** - [x] 合并编译时和运行时,作为程序主入口 - [x] 编写 examples 测试整个程序 ## 2. 源码架构 ### 2.1 技术栈 - 开发语言:TypeScript - 模块化:ES6 module - 单元测试:Jest - 包管理工具:pnpm - 打包工具:Rollup ### 2.2. 核心包 我们先下载 [vue3 源码](https://github.com/vuejs/core),打开后可以看到源码是一个基于 pnpm 的 monorepo 的架构,即单个仓库中管理多个项目。 通过 `pnpm-workspace.yaml` 文件中我们可以了解所有子项目都放在 packages 目录下,打开后看到其中包含有十几个项目,并且每个项目名与 npm 上的包名是一致的,比如 compiler-core 项目对应 npm 上的 `@vue/compiler-core` 包。而 vue 项目则对应 npm 上的 `vue` 包,它将作为整个程序的入口。 当然这么多包我们不用都学,引用官方的图,我们可以知道有哪些是重点: ![](./images/framework.png) 上图的包可以分为**编译时**和**运行时**两部分,并且这两部分之间并没有直接依赖,而是通过入口 vue 包进行引用。 - **编译时** - compiler-core:编译器核心(平台无关) - compiler-dom:针对 DOM 的编译插件 - compiler-sfc:编译单个 vue 文件 - **运行时** - reactivity:响应式 - runtime-core:运行时核心(平台无关) - runtime-dom:针对 DOM 的运行时操作 ### 2.3 编译时与运行时 为什么要分编译时和运行时呢? 我们开发中写到的 template 模板,其实是 vue 提供的一种专属语法,浏览器并不能按 Html 去解析,因此在浏览器执行之前是需要进行一定的转换工作的,这个转换工作就是编译时负责。 template 模板转换的结果是 render 函数,render 函数才是最终在浏览器中运行的,而运行 render 函数,掌控整个运行渲染流程就是运行时负责。 我们可以借助 [Vue 3 Template Explorer](https://template-explorer.vuejs.org/) 工具来试验 template 模板是转成什么样的 render 函数。 比如下面的 template 模板: ```vue
计数:{{ count }}
``` 转换后的 render 函数: ```javascript import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createElementBlock("div", { id: "box" }, [ _createTextVNode(" 计数:" + _toDisplayString(_ctx.count) + " ", 1 /* TEXT */), _createElementVNode("button", { onClick: _ctx.countAdd }, "按钮", 8 /* PROPS */, ["onClick"]) ])) } ``` 所以编译时的工作就是,接收一个 template 字符串,然后解析这些字符串,转成抽象语法树,识别出里面的各种语法糖,然后生成一个对应的 render 函数,最后通过 `vue` 包传递到运行时去执行。 因为 render 函数是在运行时中执行的,因此 render 函数中引用到的 `createElementBlock`,`toDisplayString` 等等方法,都是需要在运行时中去实现的,而非编译时。 理解了 render 函数,其实我们又可以给核心包再划一次重点,我们可以暂且不关心**编译时**是怎么把 template 转成 render 函数的,而重点关注**运行时**。因此我们可以先确定学习顺序: reactivity → runtime-core → runtime-dom → vue。 ## 3. reactivity reactivity 包不依赖其他包,又是运行时的基础,因此作为我们学习 Vue3 源码的第一站。 ### 3.1 单元测试 reactivity 包中,`src` 目录存放源码,`__tests__` 目录存放测试用例,并且两者文件名是一一对应的。比如 `src/reactive.ts` 对应的测试用例是 `__tests__/reactive.spec.ts`。 通过测试用例,我们可以更加清楚每个方法的作用。在 small-vue 中使用测试工具是 Jest,使用 VSCode 的同学推荐安装 Jest 插件,可以很方便地运行测试用例。没接触过单元测试的同学可以先简单了解下 Jest 或 Vitest。 ### 3.2 实现响应式 reactivity 包最重要的作用是实现响应式功能。简单来说,**就是通过 reactive 或 ref 创建的响应式对象,在 effect 中的执行时,就会收集依赖。当响应式对象改变时,就会自动触发依赖执行**。 其中最关键的 3 个对象则是 reactive、ref、effect。 我们可以通过 effect 的测试用例 `effect.spec.ts` 来作为学习的切入口。下面这个测试用例可以看作我们的需求,反过来思考下如何其中的 reactive 和 effect。 ```typescript describe("effect", () => { it("happy path", () => { // 创建响应式对象 let user = reactive({ age: 1 }); // 用来验证 let nextAge; // 执行effect,传入fn方法 effect(() => { nextAge = user.age + 1; }); // 验证fn方法首次执行 expect(nextAge).toBe(2); // 响应式对象改变 user.age++; // 验证fn方法再次执行 expect(nextAge).toBe(3); }); }); ``` 我们知道 vue3 的响应式是基于 Proxy,可以先给 reactive 一段伪实现: ```typescript export function reactive(raw) { return new Proxy(raw, { get(target, key) { let res = Reflect.get(target, key); // 收集依赖 track(target, key); return res; }, set(target, key, value) { let res = Reflect.set(target, key, value); // 触发依赖 trigger(target, key); return res; }, }); } ``` 接下来重新分析上面那段测试用例: - 当执行 effect 方法时,传入的 fn 方法会被初次执行,fn 方法中访问到响应式对象 user 的属性,执行 get 操作,触发 track 方法; - 当执行 `user.age++` 时,响应式对象 user 的属性更改,执行 set 操作,触发 trigger 方法; 所以接下来的关键是 effect,track,trigger 这 3 个方法怎么实现? 同样,我先给出一段 effect 的伪实现: ```typescript export class ReactiveEffect { private _fn; constructor(fn) { this._fn = fn; } run() { activeEffect = this; this._fn(); activeEffect = null; } } let activeEffect: ReactiveEffect; export function effect(fn) { const _effect = new ReactiveEffect(fn); _effect.run(); } ``` 这里定义了一个类 ReactiveEffect,它将保存 fn 方法,并提供一个 run 方法,执行 run 方法时就会执行 fn 方法。 感觉好像有点套娃,搞半天不还是执行 fn 方法?别急,这里的 run 方法其实是为了每次在执行 fn 方法前先把当前 reactiveEffect 对象保存起来,给 track 和 trigger 方法用的。接下来是 track 方法的伪实现: ```typescript // 双层映射的依赖集 const targetMap = new Map(); // 收集依赖 export function track(target, key) { // 不是从effect的run来的,单纯触发响应对象的get操作,不需要收集依赖 if (!activeEffect) return; // 通过 target 找第一层 let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } // 通过 key 找第二层 let dep = depsMap.get(key); if (!dep) { dep = new Set(); depsMap.set(key, dep); } // 依赖集中添加对象 dep.add(activeEffect); } ``` 这上面有个全局变量 targetMap,它是一个依赖集,用来保存所有的依赖对象,这里的依赖对象指 ReactiveEffect 对象。targetMap 的双层映射结构如下: ![](./images/targetMap.png) 梳理一下执行 effect 方法的过程: 1. 创建 ReactiveEffect 对象,起名 _effect,执行 _effect.run(); 2. activeEffect 保存了当前对象 this,然后 执行 fn 方法; 3. 执行了响应式对象的 get 操作,触发 track 方法; 4. 在 targetMap 中通过 target 和 key 找到对应依赖集,保存依赖对象 activeEffect; 然后我们再看看 trigger 方法的伪实现: ```typescript export function trigger(target: any, key: any) { // 找到依赖集 let dep = targetMap.get(target).get(key); // 遍历依赖集,执行 run 方法 for (let effect of dep) { effect.run(); } } ``` 通过这个 trigger 方法,梳理一下响应式对象属性发生改变的过程: 1. 执行了响应式对象的 set 操作,触发 trigger 方法; 2. 在 targetMap 中通过 target 和 key 找到对应依赖集,执行所有依赖对象的 run 方法; 3. run 方法中的 fn 方法执行; 至此,我们通过上面几十行代码,简单实现了响应式的核心,即成功运行了最开始那段测试用例。 ### 3.3 实现 ref 有了上节实现 reactive 和 effect,这节实现 ref 就很容易了。ref 本质上和 reactive 一样,都是响应式对象,只不过保存的是原始数据类型。我们同样以一个 ref 的测试用例开始: ```typescript describe("ref", () => { it("happy path", () => { // 创建响应式对象 const age = ref(1); // 用来验证 let nextAge; // 执行effect,传入fn方法 effect(() => { nextAge = age.value + 1; }); // 验证fn方法首次执行 expect(nextAge).toBe(2); // 响应式对象改变 age.value++; // 验证fn方法再次执行 expect(nextAge).toBe(3); }); }); ``` 从上面测试用例分析,ref 方法接收原始数据,返回一个带 value 属性的对象,这个对象类似 reactive,其 value 属性同样可以收集依赖和触发依赖。我们可以将 value 设为存取器属性,j简单实现如下: ```typescript class RefImpl { private _value: any; constructor(value) { this._value = value; } get value() { // 收集依赖 track(this, "value"); return this._value; } set value(value) { this._value = value; // 触发依赖 trigger(this, "value"); } } export function ref(value) { return new RefImpl(value); } ``` 总结来说,ref 的响应式原理与 reactive 是一样的,只不过一个基于 Proxy,一个基于存取器属性。 reactive 包的核心内容就总结至此,其他的功能方法,比如 isRef,isReactive,computed 等,都是在此基础上去拓展。当我们理解了上面的代码,再去看这些就不难了。 ### 3.3 响应式的目的 回归到上面的测试用例,虽然能运行成功,但是可能大家还是不了解为什么要这样,effect 是具体干嘛用的? 其实这样实现的目的,其实是提供 effect 方法为后面的 runtime-core 所用。可以简单这样理解,在运行时中,render 函数会在 effect 中执行,render 函数如果包含响应式对象,当响应式对象改变时,就可以触发 render 函数再次执行,最终触发页面更新。 ```typescript effect(()=> { render(); }) ``` ## 4. runtime-core runtime-core 相对比较复杂,我们可以先从 render 函数与虚拟节点入手,再梳理整个运行时流程,最后再逐个分析运行时流程中的核心方法。 ### 4.1 render 函数与虚拟节点 这节开始我们需要去研究 render 函数里面具体是什么,重新观察回 2.3 中 render 函数的例子。 template 模板: ```vue
计数:{{ count }}
``` render 函数: ```javascript export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createElementBlock("div", { id: "box" }, [ _createTextVNode(" 计数:" + _toDisplayString(_ctx.count) + " ", 1 /* TEXT */), _createElementVNode("button", { onClick: _ctx.countAdd }, "按钮", 8 /* PROPS */, ["onClick"]) ] ``` 可以看到里面的 `_createElementBlock`,`_createTextVNode`,`_createElementVNode` 方法我们都不知道是什么。但是对比 template 字符串可以得知,对于 `_createElementBlock` 或 `_createElementVNode` 而言,第一个参数是元素标签名,第二个参数是元素属性,第三个参数是其子元素数组或子文本。对于 `_createTextVNode` 而言,第一个参数就是文本内容。 其实这个 render 函数返回的是一个虚拟节点树,上面这几个方法都是创建虚拟节点,多个虚拟节点层层嵌套,最终返回根虚拟节点。而这些虚拟节点是和我们 template 中的元素或文本一一对应的。 那虚拟节点又是什么呢? 虚拟节点其实就是在 `vnode.ts` 中定义的 VNode 对象。上面这几个不同的创建方法,其实都是创建 VNode 对象,只不过对象的 type 属性不同。一共包含 4 种类型虚拟节点,分别是文本类型、元素类型、Fragment 类型(与插槽有关,可以暂且不理)、组件类型。 我们可以暂且用一段简单的代码来理解 VNode 对象及其创建方法: ```typescript import { ComponentInstance } from "./component"; export const Text = Symbol("Text"); // VNode类型定义 export interface VNode { type; props; children: VNode[] | string; component?: ComponentInstance; } // 通用的创建虚拟节点的方法 export function createVNode(type, props, children): VNode { return { type, props, children }; } // 指定创建文本类型虚拟节点的方法 export function createTextVNode(text: string): VNode { return { type: Text, props: null, children: text }; } ``` 其中需要注意的是,如果是组件类型虚拟节点,会有一个额外的 ComponentInstance 对象(定义在 `component.ts` 中),通过 component 字段与其关联。可以理解为这个对象是用来处理组件类型才需要的一些特殊操作。 ![](./images/vnode_tree.jpg) ### 4.2 运行时流程 理解了 render 函数和虚拟节点,但是我们依然不清楚 render 函数是何时被运行的,以及运行后返回的虚拟节点树又作何处理? 接下来我们从头梳理运行时的流程了,对应的主要核心文件是 `createApp.ts` (程序入口)、`renderer.ts` (渲染流程)、`component.ts` (组件处理)。 我们一般程序的入口文件是 `main.js`,`mian.js` 又引用了 `createApp` ,如下: ```javascript import App from "./App.js"; import { createApp } from "../../dist/vue.global-esm.js"; createApp(App).mount("#app"); ``` 因此看回 `createApp.ts`,其中的 mount 方法: ```typescript mount(rootContainer) { // 创建虚拟节点 const vnode = createVNode(rootComponent); // 字符串转DOM对象 if(typeof rootContainer == "string") { rootContainer = document.querySelector(rootContainer); } // 开启渲染 render(vnode, rootContainer); }, ``` mount 方法用传入的根组件创建了虚拟对象,然后执行了 render 方法,render 方法其实是 `renderer.ts` 26 行的那个 render 方法(在 createAppAPI 方法中作为形参传入,有点绕)。 ```typescript function render(vnode: VNode, container) { patch(null, vnode, container); } ``` 接着 render 方法又调用了 patch 方法,patch 方法接收了新旧 2 个 VNode 对象,根据其类型又进行分流,分流后又根据是初始化操作还是更新操作再次分流。最后因为组件类型,元素类型,Fragment 类型包含子节点,又递归回 patch 方法处理子节点。 ```typescript import { isObject } from "@small-vue/shared"; // n1 旧虚拟节点,n2 新虚拟节点 function patch(n1: VNode, n2: VNode, container) { // 根据 n2.type 判断类型,进行第一次分流 const { type } = n2; // 比如说是组件 if (isObject(type)) { processComponent(n1, n2, container); } } // 判断 n1 不存在则初始化操作,存在则更新操作,进行第二次分流 function processComponent(n1: VNode, n2: VNode, container) { if (!n1) { mountComponent(n2, container, parentComponent); } else { updateComponent(n1, n2); } } ``` 整个过程可以用下面的流程图表示: ![](./images/runtime.jpg) 整个源码最复杂的就是这个渲染流程了,也就是 `renderer.ts` 文件,在 vue3 源码中这个文件有 2400 行,如果直接看源码肯定难度非常大,而在 small-vue 项目中简化到了 400 行,阅读理解起来更加友好。 ### 4.3 初始化组件 初始化组件的 mountComponent 方法,我们可以用一段伪代码来表示其核心流程: ```typescript function mountComponent(vnode, container) { let instance = {}; // vnode.type 就是用户定义的组件对象,执行其中的 setup 方法 // 将 setup 方法执行结果保存起来 instance.setupStatus = vnode.type.setup(); // 执行effect instance.update = effect(() => { // 执行render函数,返回虚拟节点树 let subtree = vnode.type.render(instance.setupStatus); // 递归到patch patch(instance.subTree, subTree); // 保存最新虚拟节点树 instance.subTree = subTree; }) // 保存起来,更新组件时还要用 vnode.component = instance; } ``` 上面的代码虽然只有几行,但是信息量很大,我们先分析第一个问题:**setup 方法的返回值为何传给 render 函数**。 我们用一段很简单的用户端代码辅助理解,直接把 template 字符串对应的 render 函数也显示出来: ```javascript import { ref } from "vue"; export default { setup() { let count = ref(1); function countAdd(params) { count.value++; } return { count, countAdd, }; }, template: ``, render(_ctx) { return _createElementBlock( "button", { onClick: _ctx.countAdd }, _toDisplayString(_ctx.count) ); } }; ``` 可以看到 template 中涉及到数据地方,比如 countAdd,count,在 render 函数中是用 `_ctx` 形参对象来引用出来的,而页面的数据都需要在 setup 方法中返回才能使用,因此反过来我们就可以推理出,`_ctx` 就是 setup 方法返回的对象。 接下来分析第二个要点:**render 函数为何在 effect 中执行。** 我们从头梳理整个初始化组件流程: 1. 执行 **mountComponent** 方法 2. 执行 setup 方法,保存返回值; 3. 执行 effect 方法,继而执行其中的 fn 方法; 4. setup 方法的返回值作为参数传给 render 函数执行; 5. 执行 render 函数时,访问到响应式对象,触发 track 方法收集依赖; 6. render 执行结束,返回根虚拟节点对象 subTree; 7. instance.subTree 作为 n1,subTree 作为 n2,执行 **patch** 方法; 8. subTree 是元素类型虚拟节点 ,分流到 **processElement** 方法; 9. 由于初始化时 instance.subTree 为空,即 n1为空,分流到 **mountElement** 方法,初始化根元素; 10. 递归初始化子元素; 当响应式对象更新时,比如点击事件执行了 `count.value++`,触发更新流程: 1. 触发 trigger 方法 2. 触发依赖执行 run 方法,fn 方法再次执行 3. 再次执行 render 方法,返回新的根虚拟节点对象 subTree 4. instance.subTree 作为 n1,subTree 作为 n2,执行 **patch** 方法; 5. subTree 是元素类型虚拟节点 ,分流到 **processElement** 方法; 6. 由于初始化时保存了 instance.subTree,即 n1 不为空,分流到 **updateElement** 方法,更新根元素; 7. 递归更新化子元素; ![](./images/mountComponent.jpg) 由此可见,render 函数通过 effect 来实现响应式,当 render 函数中包含的响应式对象更新时,触发更新流程,更新页面元素。 ### 4.4 更新组件 初始化组件的 updateComponent 方法,我们可以用一段伪代码来表示其核心流程: ```typescript function updateComponent(n1: VNode, n2: VNode) { n2.component = n1.component; n2.component.update(); } ``` 这里的 `n1.component` 就是初始化组件中的那个 instance 对象,因为我们创建了新的虚拟节点 n2,因此需要把这个对象传递给 n2。接着执行了 update 方法,看回前面的代码,update 其实是 effect 的返回值,不过在 reactivity 包中,我们的 effect 方法并没有给返回值,这里补充完善下: ```typescript export function effect(fn) { const _effect = new ReactiveEffect(fn); _effect.run(); // 新加返回值,放回 run 方法 // 因为 run 方法中使用了 this,因此使用 bind 绑定 this 的指定 return _effect.run.bind(_effect); } ``` 当执行 `n2.component.update()` 时,fn 方法再次执行,重新执行 render 函数和 patch,继而更新页面元素。 不过还有一个问题,updateComponent 是怎么时候执行的? 其实我们一直都是默认只有单个组件,根组件下都是元素的情况,所以在初始化组件的流程中,patch 之后默认是 processElement,其实不一定,根组件中是可能有子组件的。 比如说下面根组件中就包含子组件 Foo: ```vue
{{ count }}
``` 转成的 render 函数则是: ```typescript export function render(_ctx, _cache, $props, $setup, $data, $options) { const _component_Foo = _resolveComponent("Foo") return (_openBlock(), _createElementBlock(_Fragment, null, [ _createElementVNode("div", null, _toDisplayString(_ctx.count), 1 /* TEXT */), _createVNode(_component_Foo, { count: _ctx.count }, null, 8 /* PROPS */, ["count"]) ], 64 /* STABLE_FRAGMENT */)) } ``` 所以 patch 递归的子节点,可能是元素,也可能是子组件上。当初始化组件时,递归到子组件,执行初始化子组件 mountComponent。当响应式对象更新触发更新流程时,递归到子组件,执行更新子组件 updateComponent。 ### 4.5 初始化元素 初始化元素的 mountElement 方法,可以简单总结为 3 步:创建元素、设置属性、处理子元素。 用一段伪代码来表示其核心流程: ```typescript function mountElement(vnode: VNode, container) { const { type, props, children } = vnode; // 创建元素(这里的type是元素标签字符串,比如:div) const el = document.createElement(type); // 遍历属性 for (let key in props) { // 区分一下是否事件,on开头的是事件,比如onClick if (/^on[A-Z]/.test(key)) { const event = key.slice(2).toLowerCase(); el.addEventListener(event, props[key]); } // 普通属性,比如id else { el.setAttribute(key, props[key]); } } // 处理子元素,区分是否文本 if (typeof children == "string") { el.textContent = children; } else if (Array.isArray(children)) { // 非文本,递归子元素 for (let v of children) { patch(null, v, el); } } // 创建的元素添加到父容器中 container.append(el); } ``` ### 4.6 更新元素 初始化元素的 updateElement 方法,可以分为2步:更新属性、更新子节点。 ```typescript function updateElement(n1: VNode, n2: VNode, parentComponent: ComponentInstance) { const el = (n2.el = n1.el); // 更新属性 patchProps(el, n1.props, n2.props); // 更新子节点 patchChildren(n1, n2, el, parentComponent); } ``` 更新属性 patchProps 方法,遍历新虚拟节点 n1 的属性,判断与旧虚拟节点 n2 中的属性值不同,进行更新。再遍历 n1 中存在但 n2 中不存在的属性,则进行删除。 更新子节点 patchChildren 方法,又分 4 种情况:文本变数组,数组变文本,文本变文本,数组变数组。其中前 3 种情况都是很容易处理的,直接重新渲染。最麻烦的是第 4 种:数组变数组。比如说: n1 子节点:A B C D E Z F G n2 子节点:A B D C Y E F G 假如我们全部重新渲染,舍弃掉原有的子元素,一是对性能不好,二是一些有状态的元素,比如用户填了文字的 input,会丢失状态。 因此我们需要一种算法,来计算出前后 2 个数组的变化,只针对性处理变化部分。以上面为例,我们可以分析出: - 头尾的 A B F G 是不变的,不处理 - C D 的位置变了,进行移动处理 - Z 少了,进行删除 - Y 多了,进行添加 vue 中采用的是双端 Diff 算法,介于篇幅,具体实现原理我便不再推演,大家可以直接在 small-vue 中看 patchChildren 方法。 ## 5. runtime-dom runtime-dom 主要是是提供针对 DOM 的运行时操作,比如说在 runtime-core 中插入元素,我们是直接用 `document.createElement(type)`,这其实是 DOM 操作,而我们的 runtime-core 应该是与平台无关,即可以是 DOM,也可以是 Canvas,或者用户自定义的渲染器,而并非绑定死 DOM。 所以我们把 runtime-core 中那些 DOM 操作的方法都提取出来,让 runtime-dom 来实现。 ```typescript // runtime-dom import { createRenderer } from "@vue/runtime-core"; createRenderer({ // 定义基于dom的createElement方法 createElement(type) { return document.createElement(type); } }) ``` ```typescript // runtime-core function mountElement(vnode: VNode, container) { const { type, props, children } = vnode; // 之前是写死 dom 的创建元素 const el = document.createElement(type); // 现在是改成一个通用的 createElement 方法,在 runtime-dom 中传递过来 const el = createElement(type); } ``` 同样,我们也可以不要 runtime-dom,自定义渲染器,传递我们自己实现的 createElement 方法给 runtime-core。 ```typescript import App from "./App.js"; import { createRenderer } from "../../dist/vue.global-esm.js"; const game = new PIXI.Application({ width: 300, height: 300 }); document.body.append(game.view); const renderer = createRenderer({ // 自定义基于pixi的createElement方法 createElement(type) { if(type == "rect") { return new PIXI.Graphics(); } }, }); renderer.createApp(App).mount(game.stage); ``` ## 6. vue vue 包作为整个项目入口,汇合了 runtime-dom 和 compiler-dom,将 compiler-dom 的编译方法传递到 runtime-dom 中,供运行时将 template 模板转成 render 函数。 当然在用户的项目中,如果通过 webpack 或 vite 打包的话,应该是在打包过程中就进行了转换工作,并在最终打包文件中只保留 render 函数和 runtime-dom,丢弃掉 template 和 compiler-dom。 整个 vue 源码打包后,放在 `vue/dist` 目录下,并且在 `vue/examples` 目录中带有相关的示例。 ## 7. 结语 本文仅仅作为 vue3 源码学习的入门指南,很多示例代码都是非常精简,并且很多内容都没有深入展开,感兴趣的同学下载 [**small-vue**](https://gitee.com/yuhuo520/small-vue) 去深入研究。 看到这里,你不来搞一个自己的简版 vue 试试?😎