1 Star 0 Fork 0

yuhuo / small-vue

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
README.md 28.52 KB
一键复制 编辑 原始数据 按行查看 历史
huoyu 提交于 2023-12-14 16:41 . 1

small-vue

我们知道 vue 的源码非常庞大复杂,即使我们只学习其中部分核心包,其代码量依然惊人。因为这其中不仅有一些高级功能的实现,还有很多边缘情况的处理。这使我们阅读源码、理解源码变成一个很困难的过程,很容易被劝退。

因此我手写了一个简版的 vue3 源码:small-vue,只实现核心包中的核心逻辑,使代码尽量简单易理解。同时 small-vue 中的文件名、方法名都尽量与 vue3 源码中保持一致,有利于相互对照理解。

本文将作为学习指南,通过 small-vue 来指引大家如何学习 vue3 源码,最后祝大家也能实现一个自己的简版 vue3!

1. 实现功能

  • reactivity
    • 支持 reactive,ref,computed 等响应式对象
    • 支持 effect 函数监听响应式操作
    • 基于 Jest 进行单元测试
  • runtime-core
    • 支持元素类型、组件类型、虚拟类型、文本类型等多种虚拟节点
    • 支持虚拟节点解析,初始化,更新等流程
    • 支持双端 Diff 算法
    • 支持 nextTick,queueJobs 等异步操作
    • 支持 props、emit、provide、inject 等功能
    • 支持插槽 Slots
    • 支持 createRenderer,createApp 等入口函数
  • runtime-dom
    • 支持 Dom 渲染器
  • compiler-core
    • 支持 parse 操作,将 template 字符串转换成抽象语法树 AST
    • 支持 transform 操作,遍历 AST 进行某些转换处理
    • 支持 codegen 操作,将 AST 转成成 render 函数
  • vue
    • 合并编译时和运行时,作为程序主入口
    • 编写 examples 测试整个程序

2. 源码架构

2.1 技术栈

  • 开发语言:TypeScript
  • 模块化:ES6 module
  • 单元测试:Jest
  • 包管理工具:pnpm
  • 打包工具:Rollup

2.2. 核心包

我们先下载 vue3 源码,打开后可以看到源码是一个基于 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 工具来试验 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 函数中引用到的 createElementBlocktoDisplayString 等等方法,都是需要在运行时中去实现的,而非编译时。

理解了 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。

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;
        },
    });
}

接下来重新分析上面那段测试用例:

  • 当执行 effect 方法时,传入的 fn 方法会被初次执行,fn 方法中访问到响应式对象 user 的属性,执行 get 操作,触发 track 方法;
  • 当执行 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 方法的过程:

  1. 创建 ReactiveEffect 对象,起名 _effect,执行 _effect.run();
  2. activeEffect 保存了当前对象 this,然后 执行 fn 方法;
  3. 执行了响应式对象的 get 操作,触发 track 方法;
  4. 在 targetMap 中通过 target 和 key 找到对应依赖集,保存依赖对象 activeEffect;

然后我们再看看 trigger 方法的伪实现:

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 的测试用例开始:

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 等,都是在此基础上去拓展。当我们理解了上面的代码,再去看这些就不难了。

3.3 响应式的目的

回归到上面的测试用例,虽然能运行成功,但是可能大家还是不了解为什么要这样,effect 是具体干嘛用的?

其实这样实现的目的,其实是提供 effect 方法为后面的 runtime-core 所用。可以简单这样理解,在运行时中,render 函数会在 effect 中执行,render 函数如果包含响应式对象,当响应式对象改变时,就可以触发 render 函数再次执行,最终触发页面更新。

effect(()=> {
	render();
})

4. runtime-core

runtime-core 相对比较复杂,我们可以先从 render 函数与虚拟节点入手,再梳理整个运行时流程,最后再逐个分析运行时流程中的核心方法。

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

4.2 运行时流程

理解了 render 函数和虚拟节点,但是我们依然不清楚 render 函数是何时被运行的,以及运行后返回的虚拟节点树又作何处理?

接下来我们从头梳理运行时的流程了,对应的主要核心文件是 createApp.ts (程序入口)、renderer.ts (渲染流程)、component.ts (组件处理)。

我们一般程序的入口文件是 main.jsmian.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 行,阅读理解起来更加友好。

4.3 初始化组件

初始化组件的 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 中执行。

我们从头梳理整个初始化组件流程:

  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 方法,我们可以用一段伪代码来表示其核心流程:

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。

4.5 初始化元素

初始化元素的 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);
}

4.6 更新元素

初始化元素的 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 个数组的变化,只针对性处理变化部分。以上面为例,我们可以分析出:

  • 头尾的 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 来实现。

// 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);

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 去深入研究。

看到这里,你不来搞一个自己的简版 vue 试试?😎

TypeScript
1
https://gitee.com/yuhuo520/small-vue.git
git@gitee.com:yuhuo520/small-vue.git
yuhuo520
small-vue
small-vue
master

搜索帮助