# 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` 包,它将作为整个程序的入口。
当然这么多包我们不用都学,引用官方的图,我们可以知道有哪些是重点:

上图的包可以分为**编译时**和**运行时**两部分,并且这两部分之间并没有直接依赖,而是通过入口 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 的双层映射结构如下:

梳理一下执行 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 字段与其关联。可以理解为这个对象是用来处理组件类型才需要的一些特殊操作。

### 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);
}
}
```
整个过程可以用下面的流程图表示:

整个源码最复杂的就是这个渲染流程了,也就是 `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. 递归更新化子元素;

由此可见,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 试试?😎