我们知道 vue 的源码非常庞大复杂,即使我们只学习其中部分核心包,其代码量依然惊人。因为这其中不仅有一些高级功能的实现,还有很多边缘情况的处理。这使我们阅读源码、理解源码变成一个很困难的过程,很容易被劝退。
因此我手写了一个简版的 vue3 源码:small-vue,只实现核心包中的核心逻辑,使代码尽量简单易理解。同时 small-vue 中的文件名、方法名都尽量与 vue3 源码中保持一致,有利于相互对照理解。
本文将作为学习指南,通过 small-vue 来指引大家如何学习 vue3 源码,最后祝大家也能实现一个自己的简版 vue3!
我们先下载 vue3 源码,打开后可以看到源码是一个基于 pnpm 的 monorepo 的架构,即单个仓库中管理多个项目。
通过 pnpm-workspace.yaml
文件中我们可以了解所有子项目都放在 packages 目录下,打开后看到其中包含有十几个项目,并且每个项目名与 npm 上的包名是一致的,比如 compiler-core 项目对应 npm 上的 @vue/compiler-core
包。而 vue 项目则对应 npm 上的 vue
包,它将作为整个程序的入口。
当然这么多包我们不用都学,引用官方的图,我们可以知道有哪些是重点:
上图的包可以分为编译时和运行时两部分,并且这两部分之间并没有直接依赖,而是通过入口 vue 包进行引用。
为什么要分编译时和运行时呢?
我们开发中写到的 template 模板,其实是 vue 提供的一种专属语法,浏览器并不能按 Html 去解析,因此在浏览器执行之前是需要进行一定的转换工作的,这个转换工作就是编译时负责。
template 模板转换的结果是 render 函数,render 函数才是最终在浏览器中运行的,而运行 render 函数,掌控整个运行渲染流程就是运行时负责。
我们可以借助 Vue 3 Template Explorer 工具来试验 template 模板是转成什么样的 render 函数。
比如下面的 template 模板:
<div id="box">
计数:{{ count }}
<button @click="countAdd">按钮</button>
</div>
转换后的 render 函数:
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。
reactivity 包不依赖其他包,又是运行时的基础,因此作为我们学习 Vue3 源码的第一站。
reactivity 包中,src
目录存放源码,__tests__
目录存放测试用例,并且两者文件名是一一对应的。比如 src/reactive.ts
对应的测试用例是 __tests__/reactive.spec.ts
。
通过测试用例,我们可以更加清楚每个方法的作用。在 small-vue 中使用测试工具是 Jest,使用 VSCode 的同学推荐安装 Jest 插件,可以很方便地运行测试用例。没接触过单元测试的同学可以先简单了解下 Jest 或 Vitest。
reactivity 包最重要的作用是实现响应式功能。简单来说,就是通过 reactive 或 ref 创建的响应式对象,在 effect 中的执行时,就会收集依赖。当响应式对象改变时,就会自动触发依赖执行。
其中最关键的 3 个对象则是 reactive、ref、effect。
我们可以通过 effect 的测试用例 effect.spec.ts
来作为学习的切入口。下面这个测试用例可以看作我们的需求,反过来思考下如何其中的 reactive 和 effect。
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 一段伪实现:
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;
},
});
}
接下来重新分析上面那段测试用例:
user.age++
时,响应式对象 user 的属性更改,执行 set 操作,触发 trigger 方法;所以接下来的关键是 effect,track,trigger 这 3 个方法怎么实现?
同样,我先给出一段 effect 的伪实现:
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 方法的伪实现:
// 双层映射的依赖集
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<ReactiveEffect>();
depsMap.set(key, dep);
}
// 依赖集中添加对象
dep.add(activeEffect);
}
这上面有个全局变量 targetMap,它是一个依赖集,用来保存所有的依赖对象,这里的依赖对象指 ReactiveEffect 对象。targetMap 的双层映射结构如下:
梳理一下执行 effect 方法的过程:
然后我们再看看 trigger 方法的伪实现:
export function trigger(target: any, key: any) {
// 找到依赖集
let dep = targetMap.get(target).get(key);
// 遍历依赖集,执行 run 方法
for (let effect of dep) {
effect.run();
}
}
通过这个 trigger 方法,梳理一下响应式对象属性发生改变的过程:
至此,我们通过上面几十行代码,简单实现了响应式的核心,即成功运行了最开始那段测试用例。
有了上节实现 reactive 和 effect,这节实现 ref 就很容易了。ref 本质上和 reactive 一样,都是响应式对象,只不过保存的是原始数据类型。我们同样以一个 ref 的测试用例开始:
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简单实现如下:
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 等,都是在此基础上去拓展。当我们理解了上面的代码,再去看这些就不难了。
回归到上面的测试用例,虽然能运行成功,但是可能大家还是不了解为什么要这样,effect 是具体干嘛用的?
其实这样实现的目的,其实是提供 effect 方法为后面的 runtime-core 所用。可以简单这样理解,在运行时中,render 函数会在 effect 中执行,render 函数如果包含响应式对象,当响应式对象改变时,就可以触发 render 函数再次执行,最终触发页面更新。
effect(()=> {
render();
})
runtime-core 相对比较复杂,我们可以先从 render 函数与虚拟节点入手,再梳理整个运行时流程,最后再逐个分析运行时流程中的核心方法。
这节开始我们需要去研究 render 函数里面具体是什么,重新观察回 2.3 中 render 函数的例子。
template 模板:
<div id="box">
计数:{{ count }}
<button @click="countAdd">按钮</button>
</div>
render 函数:
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 对象及其创建方法:
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 字段与其关联。可以理解为这个对象是用来处理组件类型才需要的一些特殊操作。
理解了 render 函数和虚拟节点,但是我们依然不清楚 render 函数是何时被运行的,以及运行后返回的虚拟节点树又作何处理?
接下来我们从头梳理运行时的流程了,对应的主要核心文件是 createApp.ts
(程序入口)、renderer.ts
(渲染流程)、component.ts
(组件处理)。
我们一般程序的入口文件是 main.js
,mian.js
又引用了 createApp
,如下:
import App from "./App.js";
import { createApp } from "../../dist/vue.global-esm.js";
createApp(App).mount("#app");
因此看回 createApp.ts
,其中的 mount 方法:
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 方法中作为形参传入,有点绕)。
function render(vnode: VNode, container) {
patch(null, vnode, container);
}
接着 render 方法又调用了 patch 方法,patch 方法接收了新旧 2 个 VNode 对象,根据其类型又进行分流,分流后又根据是初始化操作还是更新操作再次分流。最后因为组件类型,元素类型,Fragment 类型包含子节点,又递归回 patch 方法处理子节点。
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 行,阅读理解起来更加友好。
初始化组件的 mountComponent 方法,我们可以用一段伪代码来表示其核心流程:
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 函数也显示出来:
import { ref } from "vue";
export default {
setup() {
let count = ref(1);
function countAdd(params) {
count.value++;
}
return {
count,
countAdd,
};
},
template: `<button @click="countAdd">{{ count }}</button>`,
render(_ctx) {
return _createElementBlock(
"button",
{ onClick: _ctx.countAdd },
_toDisplayString(_ctx.count)
);
}
};
可以看到 template 中涉及到数据地方,比如 countAdd,count,在 render 函数中是用 _ctx
形参对象来引用出来的,而页面的数据都需要在 setup 方法中返回才能使用,因此反过来我们就可以推理出,_ctx
就是 setup 方法返回的对象。
接下来分析第二个要点:render 函数为何在 effect 中执行。
我们从头梳理整个初始化组件流程:
当响应式对象更新时,比如点击事件执行了 count.value++
,触发更新流程:
由此可见,render 函数通过 effect 来实现响应式,当 render 函数中包含的响应式对象更新时,触发更新流程,更新页面元素。
初始化组件的 updateComponent 方法,我们可以用一段伪代码来表示其核心流程:
function updateComponent(n1: VNode, n2: VNode) {
n2.component = n1.component;
n2.component.update();
}
这里的 n1.component
就是初始化组件中的那个 instance 对象,因为我们创建了新的虚拟节点 n2,因此需要把这个对象传递给 n2。接着执行了 update 方法,看回前面的代码,update 其实是 effect 的返回值,不过在 reactivity 包中,我们的 effect 方法并没有给返回值,这里补充完善下:
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:
<div>{{ count }}</div>
<Foo :count="count"></Foo>
转成的 render 函数则是:
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。
初始化元素的 mountElement 方法,可以简单总结为 3 步:创建元素、设置属性、处理子元素。
用一段伪代码来表示其核心流程:
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);
}
初始化元素的 updateElement 方法,可以分为2步:更新属性、更新子节点。
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 个数组的变化,只针对性处理变化部分。以上面为例,我们可以分析出:
vue 中采用的是双端 Diff 算法,介于篇幅,具体实现原理我便不再推演,大家可以直接在 small-vue 中看 patchChildren 方法。
runtime-dom 主要是是提供针对 DOM 的运行时操作,比如说在 runtime-core 中插入元素,我们是直接用 document.createElement(type)
,这其实是 DOM 操作,而我们的 runtime-core 应该是与平台无关,即可以是 DOM,也可以是 Canvas,或者用户自定义的渲染器,而并非绑定死 DOM。
所以我们把 runtime-core 中那些 DOM 操作的方法都提取出来,让 runtime-dom 来实现。
// runtime-dom
import { createRenderer } from "@vue/runtime-core";
createRenderer({
// 定义基于dom的createElement方法
createElement(type) {
return document.createElement(type);
}
})
// 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。
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);
vue 包作为整个项目入口,汇合了 runtime-dom 和 compiler-dom,将 compiler-dom 的编译方法传递到 runtime-dom 中,供运行时将 template 模板转成 render 函数。
当然在用户的项目中,如果通过 webpack 或 vite 打包的话,应该是在打包过程中就进行了转换工作,并在最终打包文件中只保留 render 函数和 runtime-dom,丢弃掉 template 和 compiler-dom。
整个 vue 源码打包后,放在 vue/dist
目录下,并且在 vue/examples
目录中带有相关的示例。
本文仅仅作为 vue3 源码学习的入门指南,很多示例代码都是非常精简,并且很多内容都没有深入展开,感兴趣的同学下载 small-vue 去深入研究。
看到这里,你不来搞一个自己的简版 vue 试试?😎
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。