理解Vue

总览

Vue3 的基本结构

采用 Monorepo 模式(多组件放在一个 Repo 中), 在 /packages/ 中存储所有的模块.

模块分为几类:

  • 编译时(/package/compiler-*)
    • compiler-core: 与平台无关的编译器核心
    • compiler-dom: 基于 compiler-core 解析 <template> 标签并编译为 render 函数
    • compiler-sfc: 基于 compiler-domcompiler-core 解析 SFC (单文件组件, 通俗理解就是 .vue 文件) 编译为浏览器可执行的 JavaScript
    • compiler-ssr: 服务端渲染的编译模块
  • 运行时(/package/runtime-*)
    • reactivity: 实现响应式
    • runtime-core: 基于 reactivity 实现运行时核心
    • runtime-dom: 基于 runtime-core 实现针对浏览器的运行时. 包括DOM API, 属性, 事件处理等
  • 其他
    • template-explorer: 用于调试编译器输出的开发工具
    • shared: 多个包之间共享的内容
    • vue: 完整版本,包括运行时和编译器

依赖关系

                                 +---------------------+
                                 |                     |
                                 |  @vue/compiler-sfc  |
                                 |                     |
                                 +-----+--------+------+
                                       |        |
                                       v        v
                   +---------------------+    +----------------------+
                   |                     |    |                      |
     +------------>|  @vue/compiler-dom  +--->|  @vue/compiler-core  |
     |             |                     |    |                      |
+----+----+        +---------------------+    +----------------------+
|         |
|   vue   |
|         |
+----+----+        +---------------------+    +----------------------+    +-------------------+
     |             |                     |    |                      |    |                   |
     +------------>|  @vue/runtime-dom   +--->|  @vue/runtime-core   +--->|  @vue/reactivity  |
                   |                     |    |                      |    |                   |
                   +---------------------+    +----------------------+    +-------------------+

学习路线

根据模块依赖关系, 路线为: reactivity -> runtime-core -> runtime-dom -> compiler. 重点是 runtime-*

代码分析步骤:

  1. 查看单元测试(位于packages/**/__tests__/)
  2. 根据单元测试了解模块实现的功能
  3. 跟着单元测试的了解模块功能, 了解模块功能时: 先看导出(模块是什么), 再看模块被谁导入(为什么被需要), 最后看导出部分对应的实现(怎么样实现)

参考 repo

  • cuixiaorui/mini-vue: 用来学习
  • vuejs/core: 用来验证

Reactivity 的基本流程

Reactivity 模块是运行时的最底层, 负责实现响应式, 位于: mini-vue/packages/reactivity

reactive 的基本流程

reactiveReactivity 的基础. 负责实现对象的响应式, 并向上提供调用时方法. 基本思想就是借助 ES6 的 Proxy 自定义 get & set

  1. 转到 mini-vue/../__tests__/reactive.spec.ts, 发现测试的主要目的是看 reactive 构造方法.

  2. 转到 mini-vue/../src/reactive.ts, 发现定义了 reactive, readonly 等方法, 这些方法都交由 createReactiveObject 处理.

    观察 createReactiveObject, 可以得到三个调用参数意义:

    • target: 要被代理的值

    • proxyMap: 不同类型的工厂函数有不同的全局 proxyMap, 这意味着该变量可能会存储所有代理的某类型变量. 根据

      const existingProxy = proxyMap.get(target);
      if (existingProxy) {
          return existingProxy;
      }

      可以验证想法, 其在 createReactiveObject 的目的就是持久化 Proxy 防止重复创建代理

    • baseHandlers: 根据

      const proxy = new Proxy(target, baseHandlers);

      得出该方法就是 Proxy(MDN) 的 get & set 对象. 不同类型的 Proxy 有不同的 baseHandlers

  3. 转到 mini-vue/../src/baseHandlers.ts 发现模块主要是提供不同的 get & set 而这些都是由两个 create 函数实现的, 尝试理解

    • createGetter 应该返回一个 handler.get(MDN) 实现. 可以看到这个函数上有一堆类型判断的方法, 然后做了两步

      • 通过 Reflect.get(MDN) 获取属性
      • 通过 track 进行依赖收集, 这部分后面再看

      最后返回获取结果. 整个 get 感觉和原生方法相比就是多了个类型判断和 track, 大部分的响应式都是依赖这个 track 实现的

    • createSetter 更加简单, 看起来就是在实现 handler.set(MDN) 的基础上多了个 trigger

    到目前位置这个只有 tracktrigger 是不清楚的, 这两个函数在 effect 等部分做依赖收集的, 可以先不管. 其他部分就是原生功能调用与权限管理

effect 的基本流程

如果让我实现 effect 我会怎么实现呢? 我先想到的是利用编译原理等魔法对代码做静态分析, 找到所用响应式对象, 在响应式对象的 set 上挂上函数. 但是, JavaScript 是个动态语言, 这完全没法挂啊! 只能在运行时动态解析.

Vue 的实现就比较流畅. 既然我 effect 要立即执行一遍函数, 那为啥不在执行前后做下 Flag, 一旦 Proxy 的 get 被调用, 让 get 检查一下是不是在 effect 执行阶段, 若是就把函数注册到这个响应式对象上😎

  1. 转到 mini-vue/../__tests__/reactive.spec.ts 看到 effect 的主要功能是立即执行函数并在响应式数据发生改变时, 去执行 effect 注册的函数

  2. 转到 mini-vue/../src/effect.tseffect 函数的实现. 看到这里有熟悉的 effect, track, trigger

    1. effect 函数将传入函数包装为 ReactiveEffect 对象, 合并配置, 执行 run 函数, 构造 runner 并返回(用于后期调用)

    2. ReactiveEffect

      • active: 根据 run, stop 函数和测试文件中的 it("stop") 断言可以推出其是用来开关 effect 功能的
      • deps: 根据 tracktrigger 对其调用可以判断其是用来记录函数对应依赖的
      • run: 对 effect 注册函数的包装, 在执行函数前后打入 shouldTrack 标记, 并将 activeEffect 标记为要执行的 ReactiveEffect 好让 get 知道哪个 effect 在跑
    3. track 函数: 在 reactiveget 中调用

      track(target, "get", key);

      track 发现自己处于 effect 阶段时会先检查自己所在对象有没有创建 attribute - effect 函数heap 的 map, 如果每就创建, 然后看 map 上有没有记录当前属性, 如果没有, 就建立依赖的 set 并交由 trackEffects 加入并在 ReactiveEffect 上也做记录.

    4. trigger 函数: 在 reactiveset 中调用

      先找到对应 attributeeffect 依赖, 去重, 根据配置延迟或立即支持 effect

总结

  • reactive 的流程: 传入对象, 持久化, 绑定 baseHandlers 做权限管理与依赖收集
  • effect 的流程: 将传入函数包装为对象, 立即执行函数并做好标记, 在执行时收集依赖. 每当 reactive 被调用时就 tigger 收集的 effect, 并二次收集依赖

问题

  • 所有的依赖收集都是基于 get, 这样的 effect 存在问题

    it('should observe basic properties', () => {
      let dummy, flag = false;
      const counter = reactive({ num: 0 });
      effect(() => {
        if (!flag) {
          dummy = -1;
        } else {
          dummy = counter.num;
        }
      });
    
      expect(dummy).toBe(-1);
      flag = true;
      counter.num = 2;
      expect(dummy).toBe(2); // Except 2, Received -1
    });

    不只是 mini-vue, vue/core 的单元测试也存在这个问题. 但是在 Vue 代码中并不会出现无法追踪依赖的问题, 看来还有一些隐藏的优化没有找到

Runtime-core 的基本流程

runtime-core 依赖 Reactivity 为 runtime 提供服务. 可以通过观察 Vue 文件的运行观察 runtime-core 的基本流程

文件基本结构

  1. 转到 mini-vue/packages/vue/example/helloWorld/ 的文件夹了解 vue 的基本工作流程

  2. 转到 mini-vue/../helloWorld/index.html, 只有个 div#rootscript

  3. 转到 mini-vue/.../helloWorld/main.js

    import { createApp } from '../../dist/mini-vue.esm-bundler.js';
    import App from './App.js';
    
    const rootContainer = document.querySelector('#root');
    createApp(App).mount(rootContainer);

    引入了创建根组件的 createApp 与根组件 App, 查找了 html 文件中声明的挂载点, 然后通过 createApp(App) 打包根组件再将打包后结果挂载

  4. 转到 mini-vue/../helloWorld/App.js 发现定义了两个 vue2 风格的组件对象

    {
      name: 'App', // 组件名
      setup() {}, // setup 方法
    
      render() { // 渲染方法
        return h('div', { tId: 1 }, [h('p', {}, '主页'), h(HelloWorld)]);
      },
    };
    • 前面有提到: compiler-dom<template> 标签解析并编译为 render 函数. 在这里为了不追踪 compiler-dom 的行为, 我们直接将 render 给出

    • h 为渲染函数, 参数分别是: 组件的 ElementType, 配置, 子组件数组, 可以看到, 这里第一个子组件是一个 <p> 第二个是一个组件

    • 可以在对象中使用 render, 也可以让 setup 返回 render 方法, 即

      {
        name: 'App',
        setup() {
          return function() {
            return h('div', { tId: 1 }, [h('p', {}, '主页'), h(HelloWorld)]);
          }
        },
      };.
  5. createApp 调用关系比较复杂, 直接使用 dev-tools 观察执行过程. 打开一个 http 服务器并转到 dev-tools下, 找到 createApp.js 并打下断点

  6. createApp 方法接受根组件配置对象 App 直接包了个对象, 有

    • _componment = App
    • mount 方法, 看语义, 这个方法接收挂载点, 将根组件创建为 VNode 并挂载到挂载点(main.js 中的 rootContainer), 执行完后 main.js 就结束了

    我们需要继续分析的就是 VNode 的创建过程与 render 的挂载过程

组件初始化过程

  1. 单步进入 createVNode 发现其声明了个 vnode.

    将传入对象(rootComponent / App) 作为 vnode.type

    vnode 上合并对象并配置 shapeFlag 用于标记类型

    之后调用 normalizeChildren 并返回对象

    • 进入 normalizeChildren 看起来是作了 slot 特判
  2. 单步进入 render, 其接收了处理后的 vnode 与挂载点 rootContainer 然后将参数直接交给 patch, 可以猜到 patch 会是一个很通用的函数

    • 单步进入 patch, 其接收 n1 = null, n2 = vnode, container.

      解构出了n2type = AppshapeFlag,

      通过预定义的 Symbol 判断对象类型, 进入 default,

      通过位运算判断 shapeFlag 类型, 被识别为组件 (而不是像 h('p', {}, '主页') 一样的 Element) 执行 processComponent

      • 单步进入 processComponent,

        函数做了一个判断: 如果没有 n1 就认为 n2 还没有被挂载就挂载 n2 否则更新 n2

        • 单步进入 mountComponent, 其接收了 vnode 与挂载点

          vnode 转换为实例 instance, 执行 setupComponent 处理 instance

          • 单步进入 setupComponent 发现其只是处理了 propslot 然后交给 setupStatefulComponent 继续配置

            • 单步进入 setupStatefulComponent, 其接收 instance

              instance.ctx 配置了 PublicInstanceProxyHandlers 代理(后面分析)

              提取 Component = APP, setup = APP.setup

              如果 setup 不存在就直接 finishComponentSetup

              否则用 setCurrentInstance 打标记, 为 setup 传入参数并获取执行结果, 执行 handleSetupResult 处理结果

              • 单步进入 handleSetupResult 该函数对 setup 结果执行判断

                如果是 function 说明是导出了 render 函数, 将 render 赋值到 instance.render

                否则导出的对象存入 isntance.setupState

                最后执行 finishComponentSetup 与无 setup 的情况汇合

              • 单步进入 finishComponentSetup 其接收 instance

                instance 上没有 render 就尝试从 template 编译结果上获取并存入 instrance.render

          • 单步进入 setupRenderEffect 发现其定义绑定了一个 componentUpdateFn 函数

            • 打断点并进入 componentUpdateFn 函数

              如果组件没有被挂载, 获取子节点, 获取 instance 的 Proxy, 构建子节点 subTree 并递归 patch, 当 patch 到 Element 时调用 processElement 挂载节点

              否则更新节点(后面分析)

组件更新过程

为组件创建响应式并将 reavtive 导出到全局

{
  name: 'HelloWorld',
  setup() {
    const count = ref(10);
    window.count = count;
    return { count };
  },
  render() {
    return h('div', { tId: 'helloWorld' }, `hello world: count: ${this.count}`);
  },
};

在 dev-tools 中修改 count.value 根据输出来自 effect.ts 进入文件并为 run 函数打上断点, 再次修改值, 发现 run 函数实际上就是执行了当时的 componentUpdateFn, 为 componentUpdateFn 中已挂载的判断部分打上断点

  1. 在断点处查看调用栈, 确定函数就是因为 ref 修改而引发的

  2. 在执行修改前先判断有没有 nextTrick 需要执行

  3. 获取新节点的 vnode

  4. 将老节点子树复制到新节点

  5. 触发生命周期函数

  6. patch 新节点

    单步进入 patch, 接受老节点 n1 新节点 n2 这次更新的是一个 Element 于是进入 ShapeFlags.ELEMENT, 进入 processElement

    • 单步进入 processElement, 这次老节点已经挂载, 直接走更新程序
      • 单步进入 updateElement 该函数分别对比了 props 与 子节点并更新
  7. 触发生命周期函数

总结

graph TB

init((初始化组件)) --> createAPp[将App交给createApp, 将App包装为vnode] --- norm1[将vnode应用normalizeChildren配置, 交给render渲染]  --> renderdispatch[render直接交给patch] --> check[patch检查类型] --为组件--> processComponent[交给processComponent判断状态] --为新节点--> mountComponent[执行mountComponent挂载vnode: 构造instance, 运行 setup, 获取 render] --> effect[注册render的effect] --> run[执行effect, 检测是否挂载]  --没有--> patch2(递归patch子节点)

update((reactive更新)) -.-> run[执行effect, 检测是否挂载] -.-挂载了-.-> newvnode[构造新vnode, diff检查, 复制属性] -.-> patch2 ==> check

check ==为Element==> mountDir(直接修改DOM)

实现 Reactivity

环境搭建

  • 目录结构

    .
    ├── jest.config.js
    ├── package.json
    ├── packages
    │   └── reactivity
    │       ├── index.ts # 入口文件
    │       └── __tests__ # 测试文件
    │           └── index.spec.ts
    ├── README-EN.md
    ├── README.md
    └── tsconfig.json # tsc --init

  • 依赖: typescript / @types/node / jest / ts-jest / @types/jest

实现基本 effectreactive

TDD

TDD(Test-Driven Development), 是敏捷开发中的一项核心实践和技术, 也是一种设计方法论. TDD的原理是在开发功能代码之前, 先编写单元测试用例代码, 测试代码确定需要编写什么产品代码. TDD虽是敏捷方法的核心实践.

实现基本的 reactive

需求: 最简单的 reactive, 输入对象并输出对象的代理. 代理对象修改时原对象同步修改

  1. 测试
    it('Should different', () => {
      const origin = { foo: 1 };
      const observed = reactive(origin); // 输入对象并返回代理对象
      expect(observed).not.toBe(origin); // observed 和原来的不是一个对象
      observed.foo = 3;
      expect(observed.foo).toBe(3); // 两者同步修改
      expect(origin.foo).toBe(3);
    });
  2. 实现 只需要为对象配置一个普通代理
    export function reactive(origin) {
      // 就是给一个对象, 返回一个 new Proxy
      return new Proxy(origin, {
        // 语法见 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/get
        get(target, key, receiver) {
          return Reflect.get(target, key, receiver);
        },
        // 语法见 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/set
        set(target, key, value, receiver) {
          return Reflect.set(target, key, value, receiver);
        },
      });
    }
    唯一的难点就是 Proxy 语法
  3. 重构: 无

实现基本的 effect

需求: 1. 输入函数, 执行函数, 当函数中被 [GET] 的响应式对象发生变化时重新执行函数 2. 返回一个函数 runner, 当执行 runner 时执行 effect 传入的函数

需求分析: 1. 为什么是函数中被 [GET] 的响应式对象变化时重新执行函数, [SET] 不行吗? 不行, 响应式对象被 [SET] 后如果执行了函数, 响应式对象会被重新 [SET], 那么上一次 [SET] 就没用了. 同时如果函数中其他变量不变只有响应式对象被 [SET] 此时执行函数并不会使得函数中变量值发生变化(毕竟变化的响应式变量没有被 [GET]), 不会产生 sideEffect. 2. 执行流程: 开始执行函数 -> [GET] 响应式对象 -> 结束执行函数 -> 当响应式对象被 [SET] -> 执行函数 可以发现只需要让响应式对象知道当自己变化时哪些 effect 需要执行就可以了, 至于 effect 知不知道响应式对象是谁那无所谓. 可以在函数执行期间执行依赖收集, 为 [GET] 的响应式对象注册 Effect Function, 在响应式对象修改时执行其注册的 Effect Function.

 

  1. 测试:
    describe('Effect test', () => {
      it('Should sync', () => {
        const origin = { foo: 1 };
        const observed = reactive(origin);
        let bar;
        const runner = effect(() => {
          bar = observed.foo;
        });
        expect(observed.foo).toBe(1); // origin -> observed
        expect(bar).toBe(1); // 立即执行 fn
        observed.foo = 2; // 修改有 [GET] 的响应式对象
        expect(observed.foo).toBe(2); // 响应式对象变化
        expect(origin.foo).toBe(2); // 原对象变化
        expect(bar).toBe(2); // 执行函数
      });
    
      it('Should return runner', () => {
        const origin = { foo: 1 };
        const observed = reactive(origin);
        console.info = jest.fn(); // 劫持 console.info
        let bar;
        const runner = effect(() => {
          console.info('I RUN');
          bar = observed.foo;
        });
        expect(console.info).toBeCalledTimes(1); // 立即执行 fn, console.info 被调用 1 次
        observed.foo = 2;
        expect(console.info).toBeCalledTimes(2); // 响应式对象发生变化执行 fn, console.info 被调用 2 次
        runner(); // 手动调用 runner, console.info 被调用 3 次
        expect(console.info).toBeCalledTimes(3);
      });
    });
  2. 实现 利用 targetMap 实现响应式对象 -> Key -> Effective Function 的映射. 导出 tracktrigger 用于收集与触发依赖
    // target: Object => keyMap:(string=>Set)
    // keyMap: string => Set
    const targetMap: Map<any, Map<string, Set<EffectReactive>>> = new Map();
    let activeEffectFn: any = undefined;
    
    class EffectReactive {
      runner: (...args: any[]) => any;
    
      constructor(public fn) {
        this.runner = this.run.bind(this);
        activeEffectFn = this; // 全局注册当前正在收集依赖的 Effect
        this.run(); // 执行函数
        activeEffectFn = undefined; // 取消注册
      }
    
      run() {
        this.fn();
      }
    }
    
    export function effect(fn) {
      // 考虑到 effect 上动作很多, 我们将其抽离为 EffectFunction 函数
      return new EffectReactive(fn).runner;
    }
    
    // 依赖收集函数, 由 `[GET]` 触发, 该函数检查是否有 active 的 Effect, 有就收集依赖
    export function track(target, key) {
      if (!activeEffect) return;
      if (!targetMap.has(target)) targetMap.set(target, new Map());
      const keyMap = targetMap.get(target)!;
      if (!keyMap.has(key)) keyMap.set(key, new Set());
      const dependenceEffect = keyMap.get(key)!;
      dependenceEffect.add(activeEffect);
    }
    
    // 触发函数, 当响应式对象被 `[SET]` 时尝试触发其收集的所有 Effect
    export function trigger(target, key) {
      const keyMap = targetMap.get(target)!;
      if (!keyMap) return;
      const depSet = keyMap.get(key)!;
      if (!depSet) return;
      [...depSet].forEach((d) => d.run());
    }
    Proxy 上同步修改
    export function reactive(origin) {
      return new Proxy(origin, {
        get(target, key, receiver) {
    +     track(target, key);
          return Reflect.get(target, key, receiver);
        },
        set(target, key, value, receiver) {
    +     // 这两行顺序反了就寄了
          const res = Reflect.set(target, key, value, receiver);
    +     trigger(target, key);
          return res;
        },
      });
    }
  3. 重构: 上面这个代码有点问题, 我们只在构造 EffectFunction 时收集了依赖, 但是并不能收集全
    it('dym track', () => {
      const origin1 = { foo: 1 };
      const observe1 = reactive(origin1);
      const origin2 = { foo: 100 };
      const observe2 = reactive(origin2);
      let cnt = 0,
        ob = 0;
      effect(() => {
        if (cnt == 0) {
          ob = observe1.foo;
        } else {
          ob = observe2.foo;
        }
        cnt++;
      });
      expect(ob).toBe(1);
      observe1.foo = 2;
      expect(ob).toBe(100);
      observe2.foo = 200;
      expect(ob).toBe(200);
    });
    这个测试就无法通过, 因为 observe2 理论上应该在 observe1.set 调用 run 的时候收集依赖, 所以应该修改构造函数和 run
    constructor(public fn, options: any) {
      // ...
      this.run();
    }
    
    run() {
      activeEffect = this;
      const res = this.fn();
      activeEffect = undefined;
      return res;
    }
    我们知道, 所有的依赖收集都是通过 fn 中对 reactive 的 [GET] 实现的, 我可以保证只要执行 fn 在其前后都加入了依赖收集的 flag 就可以. 调用 fn 只可能发生在
  4. 构造函数
  5. 手动执行 runner
  6. reactive 执行 [SET] 触发 trigger

这三部分要执行的都是 run 我们可以保证只要执行 run 就触发依赖收集

实现 effectscheduler 选项 (watch)

需求: 为 effect 传入第二个参数, 参数是一个对象, 其中包含 scheduler 函数, 当构造 Effect 时执行传入的第一个函数参数, 当响应式函数变化时执行 scheduler 函数. 这与 Vue 3 的 watch 类似

需求分析: 在构造 Effect 的时候传入配置并在触发的时候判断是否有 scheduler 函数

  1. 测试

it('Shound run scheduler', () => {
  const origin = { foo: 1 };
  const observed = reactive(origin);
  let bar;
  effect(
    () => {
      bar = observed.foo;
    },
    { // 传入配置
      scheduler() {
        bar = -observed.foo;
      },
    }
  );
  expect(bar).toBe(1); // 第一次运行 fn 函数
  observed.foo = 2;
  expect(bar).toBe(-2); // 第二次运行 scheduler 函数
});

  1. 实现
  • 修改 effect 函数, 加入配置项
    export function effect(fn, options = {}) {
      return new EffectReactive(fn, options).runner;
    }
  • 修改 EffectReactive 的构造函数加载配置项
    class EffectReactive {
      runner: (...args: any[]) => any;
      scheduler: (...args: any[]) => any | undefined;
    
      constructor(public fn, options: any) {
        this.scheduler = options.scheduler;
        // ...
      }
    
      // ...
    }
  • 修改触发函数
    export function trigger(target, key) {
      // ...
      [...depSet].forEach((d) => (d.scheduler ? d.scheduler() : d.run()));
    }
  • 重构: 无

什么时候尝试抽离函数 / 对象

  1. 函数上有很多动作
  2. 函数作用范围广, 语义差

实现 effectstoponStop 选项

需求: 1. 定义一个外部函数 stop. 传入 runnerrunner 不再被响应式对象 trigger 2. effect 中加入 onStop 配置, 在 stop 时调用

需求分析: 只需要将 EffectFunction 从响应式对象的依赖表中删除即可. 但是我们之前就没记录有哪些响应式对象将 EffectFunction 作为依赖..., 所以需要开一个 Set 记录这些响应式对象. 同时, 我们不需要记录依赖的对象是什么, 只需要记录 KeyMap 对应的 Set.

  1. 测试
    it('Should stop trigger', () => {
      const origin = { foo: 1 };
      const observed = reactive(origin);
      let bar;
      const runner = effect(
        () => {
          bar = observed.foo; // 立即执行
        },
        {
          onStop() {
            if (bar > 0) bar = 0; // 如果首次调用置 0
            bar--;
          },
        }
      );
      expect(bar).toBe(1); // 立即执行
      stop(runner);
      expect(bar).toBe(-1); // 停止后第一次执行为 -1
      observed.foo = 2;
      expect(bar).toBe(-1); // reactive 变化也不调用 fn
      stop(runner)
      expect(bar).toBe(-1); // 反复 stop 不反复执行 onStop
    });
  2. 实现 修正 EffectReactive
    class EffectReactive {
      runner: { // effect 只返回 runner, stop 函数需要根据 runner 找到 EffectReactive, 所以要在函数上加一个属性记录一下
        (...args: any[]): any;
        effect?: EffectReactive;
      };
      onStop: (...args: any[]) => any | undefined; // stop 回调
      deps: Set<Set<EffectReactive>>; // 收集了这个函数依赖的变量的依赖表集合
      active: boolean; // EffectReactive 是否运行 (stop 时置 0)
      // ...
    
      constructor(public fn, options: any) {
        this.runner = this.run.bind(this);
        this.runner.effect = this;
        this.onStop = options.onStop;
        this.deps = new Set();
        this.active = true;
        // ...
      }
    
      run() {
        // 如果用户手动执行 runner 那么只执行 fn, 不追踪依赖, 放置依赖追踪给已经解除依赖的元素再绑定上依赖
        if (!this.active) return this.fn();
        activeEffect = this;
        const res = this.fn();
        activeEffect = undefined;
        return res;
      }
    }
    修正依赖收集函数
    export function track(target, key) {
      //
      dependenceEffect.add(activeEffect);
      // 为当前正在依赖收集的 effect 的依赖上加入这个 key 的依赖表
      activeEffect.deps.add(dependenceEffect);
    }
    实现 stop 函数
    export function stop(runner) {
      // 不反复执行
      if (!runner.effect.active) return;
      runner.effect.active = false;
      // 找到所有收集过 effect 的变量, 将 effect 从依赖表中删除
      [...runner.effect.deps].forEach((d) => d.delete(runner.effect));
      // 执行 onStop
      runner.effect.onStop && runner.effect.onStop();
    }
  3. 重构: 无

实现 ProxyReadonly

需求: readonlyreactive 类似, 不过不支持 set

需求分析: 一个元素不支持 set 也就不可能触发依赖, 所以也没有必要做依赖收集. 所以只需要精简一下 reactive. 可以发现, 不同权限的变量只是在构造的时候采用不同的 [GET][SET] 策略. 可以将 [GET][SET] 抽离出来

  1. 测试
    it('Happy path', () => {
      const origin = { foo: 1 };
      const observed = readonly(origin);
      console.warn = jest.fn();
      expect(observed.foo).toBe(1); // 将原始对象包装为只读对象
      expect(console.warn).not.toHaveBeenCalled(); // 最开始不报错
      observed.foo = 2; // 修改, 静默失效, 报 warning
      expect(console.warn).toBeCalledTimes(1); // warning 被调用一次
      expect(observed.foo).toBe(1); // readOnly 静默失效
    });
  2. 实现 抽离 [GET][SET]
    // reactive 的 [GET]
    function get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    }
    
    // reactive 的 [SET]
    function set(target, key, value, receiver) {
      const res = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return res;
    }
    
    function getReadonly(target, key, receiver) {
      return Reflect.get(target, key, receiver);
    }
    
    function setReadonly(target, key, value, receiver) {
      console.warn('Can not set readonly');
      // 要返回一下设置结果, 如果返回 false 会抛出异常, 而我们只希望静默失效
      return true;
    }
    
    export const proxyConfig = {
      get,
      set,
    };
    
    export const proxyReadonlyConfig = {
      get: getReadonly,
      set: setReadonly,
    };
    抽离对象创建函数
    function createReactiveObject(origin, readonly = false) {
      if (readonly) return new Proxy(origin, proxyReadonlyConfig);
      return new Proxy(origin, proxyConfig);
    }
    重写 reactive 实现 readonly
    export function reactive(origin) {
      return createReactiveObject(origin);
    }
    
    export function readonly(origin) {
      return createReactiveObject(origin, true);
    }
  3. 重构: 上面的就是重构后的代码

实现工具函数 isReadonly, isReactive, isProxy

需求: 实现工具函数, isReadonly, isReactive, isProxy(前两个函数二选一).

需求分析: 只需要在 [GET] 上特判即可

  1. 测试
    it('isReadonly test', () => {
      const origin = { foo: 1 };
      const observed = readonly(origin);
      expect(isReadonly(observed)).toBe(true);
      expect(isReactive(observed)).toBe(false);
    });
    
    it('isReactive test', () => {
      const origin = { foo: 1 };
      const observed = reactive(origin);
      expect(isReadonly(observed)).toBe(false);
      expect(isReactive(observed)).toBe(true);
    });
    
    it('isProxy test', () => {
      const origin = { foo: 1 };
      const observed = readonly(origin);
      expect(isProxy(observed)).toBe(true);
    });
  2. 实现 构造个枚举
    export const enum ReactiveFlag {
      IS_REACTIVE = '__v_isReactive',
      IS_READONLY = '__v_isReadonly',
    }
    实现函数
    export function isReactive(value) {
      // 要转一下 Boolean 因为非 reactive 对象会返回 undefined
      return !!value[ReactiveFlag.IS_REACTIVE];
    }
    
    export function isReadonly(value) {
      return !!value[ReactiveFlag.IS_READONLY];
    }
    
    export function isProxy(value) {
      return isReactive(value) || isReadonly(value);
    }
    修改 [GET]
    const reactiveFlags = {
      [ReactiveFlag.IS_REACTIVE]: true,
      [ReactiveFlag.IS_READONLY]: false,
    };
    
    const readonlyFlags = {
      [ReactiveFlag.IS_REACTIVE]: false,
      [ReactiveFlag.IS_READONLY]: true,
    };
    
    function get(target, key, receiver) {
      if (Object.keys(reactiveFlags).find(d=>d===key)) return reactiveFlags[key];
      // ...
    }
    
    function getReadonly(target, key, receiver) {
      if (Object.keys(readonlyFlags).find(d=>d===key)) return readonlyFlags[key];
      // ...
    }
  3. 重构: 无

实现 reactive / readonly 嵌套

需求:reactive / readonly 内部 value 为对象, 那么该对象也应该是 reactive / readonly

需求分析: 我最开始的想法是在构造 reactive 的时候遍历所有属性, 然后为这些属性配置 reactive. 然而, 这无法将动态添加的对象转为 reactive. 考虑需求, 我们希望让内层对象支持 reactive, 实际上是希望让内层对象也支持依赖收集等 reactive 功能, 而这些功能都是在对象被 [GET] 的时候被激活的. 也就是说我们最晚可以在首次访问属性的将内层对象转换为 reactive.

  1. 测试(只写了 reactive 的)
    it('Should nested track', () => {
      const origin = {
        foo: { a: 1 },
        bar: [{ b: 2 }],
      };
      const observe = reactive(origin);
      expect(isReactive(observe)).toBe(true);
      expect(isReactive(observe.foo)).toBe(true);
      expect(isReactive(observe.bar)).toBe(true);
      expect(isReactive(observe.bar[0])).toBe(true);
    });
  2. 实现 只需要在 [GET] 的时候判断属性是否是对象, 如果是对象那么返回包装后的 reactive

function get(target, key, receiver) {
  if (Object.keys(reactiveFlags).find((d) => d === key))
    return reactiveFlags[key];
  const res = Reflect.get(target, key, receiver); // 获取结果
  if (isObject(res)) return reactive(res); // 如果结果是对象, 将其包装为 reactive
  track(target, key);
  return res;
}

function getReadonly(target, key, receiver) {
  if (Object.keys(readonlyFlags).find((d) => d === key))
    return readonlyFlags[key];
  const res = Reflect.get(target, key, receiver); // 获取结果
  if (isObject(res)) return readonly(res); // 如果结果是对象, 将其包装为 readonly
  return res;
}
packages/share/index.ts 中构造工具函数判断输入是否是对象
export function isObject(v) {
  return v !== null && typeof v === 'object';
}
3. 改进: 我们并没有实现内层 reactive 的持久化, 也就是说每次 reactive 的结果是不同的... 实现内层对象持久化
const reactiveMap = new Map();
const readonlyMap = new Map();

export function reactive(origin) {
  if (!reactiveMap.has(origin))
    reactiveMap.set(origin, createReactiveObject(origin));
  return reactiveMap.get(origin)!;
}

export function readonly(origin) {
  if (!readonlyMap.has(origin))
    readonlyMap.set(origin, createReactiveObject(origin, true));
  return readonlyMap.get(origin)!;
}

注意

  1. JS是动态语言, 不要尝试做静态代码分析: 我们在实现功能的时候应该考虑什么时候完成工作不晚, 不遗漏而不是相静态语言一样想什么时候可以操作数据
  2. 实现功能时想想这个功能希望我们对外表现为什么样子: 思考是什么而不是怎么做, 比如内层 reactive 的第一版代码并没有实现将对象转为 reactive 并附着在对象上, 而是考虑如果一个内层对象是 reactive, 那么我们应该在 [GET] 的时候表现的与原始对象不同. 这就启发我们只需要在 [GET] 的时候处理数据就可以而不需要在构造对象的时候实现这一功能.

实现 shadowReadonly

需求: shadowReadonly 就是只对对象外层实现 readonly, 内部对象不管, 不 Proxy

需求分析: 实际上就是不支持嵌套的 readonly

  1. 测试
    it('Happy path', () => {
      const origin = { foo: { bar: 2 } };
      const observed = shadowReadonly(origin);
      console.warn = jest.fn();
      expect(console.warn).not.toHaveBeenCalled();
      observed.foo = 0; // 外层禁止修改
      expect(console.warn).toBeCalledTimes(1);
      expect(observed.foo.bar).toBe(2);
      observed.foo.bar = 0; // 内部不管
      expect(observed.foo.bar).toBe(0);
      expect(console.warn).toBeCalledTimes(1);
      expect(isProxy(observed.foo)).toBe(false);
    });
  2. 实现
    function getShadowReadonly(target, key, receiver) {
      if (Object.keys(readonlyFlags).find((d) => d === key))
        return readonlyFlags[key];
      // 其实就是不支持嵌套追踪的 readonly. shadowReadonly 的元素一定是非 reactive 对象, 所以直接返回
      return Reflect.get(target, key, receiver);
    }
    
    export const proxyShadowReadonlyConfig = {
      get: getShadowReadonly,
      set: setReadonly,
    };
    实现 shadowReadonly
    const shadowReadonlyMap = new Map();
    
    function createReactiveObject(origin, readonly = false, shadow = false) {
      if (shadow && readonly) return new Proxy(origin, proxyShadowReadonlyConfig);
      if (readonly) return new Proxy(origin, proxyReadonlyConfig);
      return new Proxy(origin, proxyConfig);
    }
    
    export function shadowReadonly(origin) {
      if (!shadowReadonlyMap.has(origin))
        shadowReadonlyMap.set(origin, createReactiveObject(origin, true, true));
      return shadowReadonlyMap.get(origin)!;
    }
  3. 重构

实现 ref

需求: 实现 ref - 如果 ref(value) 输入的是不是对象, 那么可以 - 通过 .value 访问值 - 通过 .value 更新值, 如果赋值时新值与旧值一样则什么都不做 - 支持类似 reactive 的依赖收集与触发 - 如果 ref(value) 输入的是对象, 那么可以 - 在上面的基础上对要求对象支持 reactive

需求分析:

  • 我最开始想到的是 ref = (value) => reactive({value}) 但是如果只是这么简单实现, 那么 ref 的非 value 属性也将变为 reactive. 同时可以预见这样实现的 ref 性能不及标准 ref.
  • ref 的特点是外层有且只有 value 一个 key, 这意味我们在实现时
    • 不用使用全局 targetMap (只有一个depSet)
    • 不用像 reactive 一样实现一个 Proxy, 可以只实现一个 [GET] & [SET].
  • 考虑到 ref输入可能是对象或非对象
    • 我们能不使用全局的 targetMap, 否则两个值相同的 ref 会被判定为同一个 keyMap
    • 若输入为对象, 在对比赋值时新值与旧值一样不能简单的比较 _value === newValue. 若输入为对象, 那么 reactive(obj) !== obj. 我们还需要保存输入的原始值
  1. 测试 ref 非对象时

    it('should be reactive', () => {
      const a = ref(1);
      let dummy;
      let calls = 0;
      effect(() => {
        calls++;
        dummy = a.value;
      });
      expect(calls).toBe(1); // 构造 EffectFunction 执行一次
      expect(dummy).toBe(1);
      a.value = 2; // ref 也支持依赖收集与触发
      expect(calls).toBe(2);
      expect(dummy).toBe(2);
      a.value = 2; // 同值不触发
      expect(calls).toBe(2);
      expect(dummy).toBe(2);
    });
    ref 对象时要把内层对象变为 reactive, 对象也可以变为非对象
    it('should convert to reactive', () => {
      const origin = { foo: 1 };
      const a = ref(origin);
      let dummy;
      let calls = 0;
      effect(() => {
        calls++;
        dummy = a.value.foo ? a.value.foo : a.value;
      });
      expect(calls).toBe(1); // 构造 EffectFunction 执行一次
      expect(dummy).toBe(1);
      a.value.foo = 2; // ref 也支持依赖收集与触发
      expect(calls).toBe(2);
      expect(dummy).toBe(2);
      a.value = origin; // 同值不触发
      expect(calls).toBe(2);
      expect(dummy).toBe(2);
      a.value = { foo: 1 }; // 同值不触发
      expect(calls).toBe(3);
      expect(dummy).toBe(1);
      a.value = 5; // 变为非对象
      expect(calls).toBe(4);
      expect(dummy).toBe(5);
    });

  2. 实现

// 与 reactive 直接返回一个 Proxy 不同, 我们只有 value 一个属性, 所以要手动实现一个对象
class RefImpl {
  // 这里我们不使用全局的 targetMap 原因是
  //   - 我们这里的 Key 可以不是对象, 两个值相同的 ref 会被判定为同一个 key
  //   - 只存在一个 Key: value, 所以没有必要使用两个 Map, 只需要一个 Set 就可以存储所有的 EffectReactive
  private deps: Set<EffectReactive>;
  private _value;
  private rawValue;
  constructor(value) {
    this.deps = new Set();
    this._value = isObject(value) ? reactive(value) : value;
    this.rawValue = value;
  }

  // 只需要 value 的 [SET] [GET] 就可以实现
  get value() {
    trackEffect(this.deps); // 依赖追踪
    return this._value;
  }

  set value(newValue) {
    // 重复赋值不触发, 考虑两种情况
    //   - this._value 不是 Object, 直接比较
    //   - this._value 是 Object, 此时 this._value 是一个 reactive, reactive(obj) !== obj, 必须使用原始值比较
    if (this.rawValue === newValue) return;
    this.rawValue = newValue;
    this._value = isObject(newValue) ? reactive(newValue) : newValue;
    triggerEffect(this.deps); // 触发依赖
  }
}

export function ref(value) {
  return new RefImpl(value);
}
在这里, trackEffecttriggerEffect 相当于不需要查 Settracktrigger(因为只有一个 Set). 我们可以将原来的 tracktrigger 拆开

export function track(target, key) {
  if (!activeEffect) return;
  if (!targetMap.has(target)) targetMap.set(target, new Map());
  const keyMap = targetMap.get(target)!;
  if (!keyMap.has(key)) keyMap.set(key, new Set());
  // 抽成一个函数
  trackEffect(keyMap.get(key)!);
}

export function trackEffect(dependenceEffect) {
  // 本来只需要在 track 上判断 activeEffect 但是这个函数可能被 track 或者 RefImpl 调用, 所以还需要在判断一次
  if (!activeEffect) return;
  dependenceEffect.add(activeEffect);
  activeEffect.deps.add(dependenceEffect);
}

export function trigger(target, key) {
  const keyMap = targetMap.get(target)!;
  if (!keyMap) return;
  const depSet = keyMap.get(key)!;
  if (!depSet) return;
  // 抽成一个函数
  triggerEffect(depSet);
}

export function triggerEffect(depSet) {
  [...depSet].forEach((d) => (d.scheduler ? d.scheduler() : d.run()));
}

实现工具函数 isRef & unRef & proxyRefs

需求: - isRef: 判断输入是不是 ref - unRef: 返回 refvalue - proxyRefs: 模拟 Vue3 的 setup 函数, 通过该函数返回的对象中的 ref 在模板字符串中无需 .value 即可访问与赋值. 简单来说就是输入对象, 在访问对象中浅层 refKey 时无需 .value 即可访问

需求分析: - isRef: 加一个 flag 即可 - unRef: 判断是不是 ref, 是就返回 ref.value - proxyRefs: 构造一个代理, 在读写是判断读写目标是不是 ref 如果是就返回 ref.value. 同时, 在 [SET] 时, 如果新旧值都是 ref 那么直接替换掉旧 ref

  1. 测试

    it('isRef', () => {
      const origin1 = 1;
      const origin2 = { foo: 1 };
      const observed1 = ref(origin1);
      const observed2 = ref(origin2);
      expect(isRef(origin1)).toBe(false);
      expect(isRef(origin2)).toBe(false);
      expect(isRef(observed1)).toBe(true);
      expect(isRef(observed2)).toBe(true);
    });
    
    it('unRef', () => {
      const origin1 = 1;
      const origin2 = { foo: 1 };
      const observed1 = ref(origin1);
      const observed2 = ref(origin2);
      expect(unRef(observed1)).toBe(origin1);
      expect(unRef(observed2)).not.toBe(origin2);
      expect(unRef(observed2)).toStrictEqual(origin2);
      expect(unRef(observed2)).toBe(reactive(origin2));
    });
    
    it('proxyRefs', () => {
      const user = {
        sampleRef: ref(10),
        sampleStr: 'demo',
      };
      const proxyUser = proxyRefs(user);
      expect(user.sampleRef.value).toBe(10);
      expect(proxyUser.sampleRef).toBe(10);
      expect(proxyUser.sampleStr).toBe('demo');
    
      (proxyUser as any).sampleRef = 20;
      expect(proxyUser.sampleRef).toBe(20);
      expect(user.sampleRef.value).toBe(20);
    
      proxyUser.sampleRef = ref(10);
      expect(proxyUser.sampleRef).toBe(10);
      expect(user.sampleRef.value).toBe(10);
    });

  2. 实现

打flag

class RefImpl {
  public __v_isRef = true;
}
实现 isRefunRef
export function unRef(ref) {
  return isRef(ref) ? ref.value : ref;
}

export function isRef(value) {
  return !!value?.__v_isRef;
}
实现 proxyRef
export function proxyRefs(origin) {
  return new Proxy(origin, proxyProxyRefConfig);
}

function getProxyRef(target, key, receiver) {
  // 不用这么麻烦
  // if (isRef(target[key])) return target[key].value;
  // return target[key];
  return unRef(target[key]);
}

function setProxyRef(target, key, value, receiver) {
  // 只特判 ref <- 普通值
  if (isRef(target[key]) && !isRef(value)) return (target[key].value = value);
  return Reflect.set(target, key, value, receiver);
}

export const proxyProxyRefConfig = {
  get: getProxyRef,
  set: setProxyRef,
};
3. 重构: 无

实现 computed

需求: 1. 输入一个函数, 返回一个对象, 可以通过 .value 获取函数返回值, 当函数内部 reactive 变化时, 返回值也要变化. 2. 支持 Lazy, 即: 1. 在 computed 内部 reactive 变化时不触发 computed 传入函数 2. 在 [GET] 时才触发 computed 传入函数 3. 若内部 reactive 不变, 重复触发 [GET] 不重复触发传入函数 3. 返回值也是一个 reactive 对象, 即 .value 变化时要触发依赖

需求分析:

  1. 测试
    it('should reactive', () => {
      let cnt = 0;
      const observed = reactive({ foo: 1 });
      const bar = computed(() => {
        cnt++;
        return observed.foo + 1;
      });
      expect(cnt).toBe(0); // Lazy
      expect(bar.value).toBe(2);
      expect(cnt).toBe(1);
      observed.foo = 2;
      expect(cnt).toBe(1); // Lazy
      expect(bar.value).toBe(3);
      expect(cnt).toBe(2);
      expect(bar.value).toBe(3);
      expect(bar.value).toBe(3); // Lazy
      expect(cnt).toBe(2);
    });
    
    // 返回值也可以收集依赖
    it('should trigger effect', () => {
      const value = reactive({});
      const cValue = computed(() => value.foo);
      let dummy;
      effect(() => {
        dummy = cValue.value;
      });
      expect(dummy).toBe(undefined);
      value.foo = 1;
      expect(dummy).toBe(1);
    });
  2. 实现
  • 构造一个 old, 当内部 reactive 变化时修改, 如果内部不变就直接使用原 _value
  • 类似构造 refdep 收集 .value 的依赖
  • 我们希望在第一次 [GET] 的时候收集依赖, 这可以使用 EffectReactive 实现, 但是为了实现 Lazy 我们又不希望每次内部 reactive 变化都触发依赖. 我们可以采用 scheduler 解决, 每次内部 reactive 变化时候打下标记(old), 并通知 computed 要触发依赖了. 如果 computed 没有依赖那这次就 Lazy 过去了, 如果有那在触发依赖时其他函数会调用计算属性的 [GET] 此时完成刷新

import {
  effect,
  EffectReactive,
  trackEffect,
  triggerEffect,
} from './effect';

class ComputedImpl {
  old: boolean;
  fst: boolean;
  _value: any;
  dep: Set<EffectReactive>;
  effect!: EffectReactive;

  constructor(public fn) {
    this.old = false;
    this.fst = true;
    this.dep = new Set();
  }

  get value() {
    trackEffect(this.dep);
    // 为啥人家的代码没 fst 呢? 因为人家的 EffectReactive 每在构造函数的时候 run. 人家可以在构造函数里面注册这个 effect
    if (this.fst) {
      this.fst = false;
      this.effect = new EffectReactive(() => (this._value = this.fn()), {
        scheduler: () => {
          this.old = true;
          triggerEffect(this.dep);
        },
      });
    }
    if (this.old) {
      this.old = false;
      this._value = this.effect.runner();
      triggerEffect(this.dep);
    }
    return this._value;
  }

  set value(_) {
    console.warn('Can not set computed value');
  }
}

export function computed(origin) {
  return new ComputedImpl(origin);
}
- 重构: 考虑修改 EffectReactive 构造函数

小结

  • 实现 Reactivity 的核心就是一个 Proxy. 通过修改 [GET] & [SET] 实现不同权限
  • 时刻谨记 JavaScript 是动态语言, 对象上的属性随时在变化, 不要想在某一个对对象上的属性做特殊处理, 很容易遗漏, 我们可以想想什么时候外部需要我们特殊处理的特性, 在出口处"围追堵截"
  • 注意我们应该在什么时候抽象函数
    • 语义上可以抽象时候
    • 功能重复时
  • 当函数功能部分重叠时要敢于拆分函数
  • ref 相当于是一个整体功能弱化的 reactive, 所以我们没有使用全局 targetMap
  • computed 的实现比较巧妙, 运用了一个 effect 的配置项, 我们在实现工具函数的时候也可以想想是否可以通过配置项将两个功能类似的类合并成一个类.

实现 runtime-core 的 mount 部分

搭建环境

runtime-core 直接参与页面实现, 我们需要利用打包工具打包代码. 在打包网页时一般使用 webpack, 而在打包模块时一般使用 rollup.js. 安装 rollup 及其 TypeScript 依赖

pnpm i -D rollup @rollup/plugin-typescript tslib rollup-plugin-sourcemaps
#         ^ 本体  ^ typescript 支持          ^ TS 支持依赖

配置 rollup

  • 创建 /package/index.ts 作为整个项目的出口
  • 创建 rollup 配置文件 /package/rollup.config.js
    import typescript from '@rollup/plugin-typescript';
    import sourceMaps from 'rollup-plugin-sourcemaps';
    
    export default {
      input: './packages/index.ts', // 入口文件
      output: [
        // 2种输出格式
        {
          format: 'cjs', // 输出格式
          file: './lib/micro-vue.cjs.js', // 输出路径
          sourcemap: true,
        },
        {
          format: 'es',
          file: './lib/micro-vue.esm.js',
          sourcemap: true,
        },
      ],
      plugins: [typescript(), sourceMaps()],
    };
  • 执行 rollup -c ./rollup.config.js 打包
  • 根据提示将 tsconfig.json"module": "commonjs" 改为 "module": "ESNext"
  • package.json 中注册包的入口文件, main 对应 commonjs 包, module 对应 ESM 包
    "main": "lib/micro-vue.cjs.js",
    "module": "lib/micro-vue.esm.js",

构造测试用例

我们构造一个简单的 Vue demo 并尝试实现 vue-runtime 主流程使其可以将我们的 demo 渲染出来, Vue 项目一般包含如下文件

  • index.html: 至少包含一个挂载点
  • index.js: 引入根组件, 将根组件挂载到挂载点
  • App.vue: 定义根组件

SFC 需要 vue-loader 编译才能实现. 而 vue-loader 的作用是将 SFC 处理为 render 函数, 在此我们只能先将 App.vue 处理为 vue-loader 编译后函数. 定义

  • index.html: 只构造一个挂载点并引入 JS
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>micro-vue runtime-core</title>
      </head>
      <body>
        <div id="app"></div>
        <script src="./index.js" type="model"></script>
      </body>
    </html>
  • index.js: 先不管有没有这些函数, 平时咋写就咋写
    import { createApp } from '../../lib/micro-vue.esm';
    import App from './App';
    
    createApp(App).mount('#app');
  • App.js:
    import { h } from '../../lib/micro-vue.esm.js';
    
    export default {
      setup() {},
      render() {
        return h('div', { class: 'title' }, [
          h('span', {}, "111"),
          h('span', {}, "222"),
        ]);
      },
    };

App.js 默认导出了一个配置对象, 该对象应该包含 SFC 中导出的 setup 与 vue-loader 编译得到的 render 函数. 其中

  • setup 函数的返回值可以是对象, 也可以是渲染函数
  • 在解析 SFC 文件时, 如果用户手动通过 setup 返回了渲染函数那么 vue-loader 就不编译模板, 如果没有返回则编译模板并构造渲染函数 render. render 函数描述了这个组件里面应该如何渲染
  • render 中的 h 用于表述一个组件/元素, 语法为: h(type, attr, children).
    • type: 描述元素或组件类型, 如果希望将目标渲染为 Element 那么 type 为标签名, 如果希望渲染为组件那么 type 为 组件配置对象
    • attr: 描述元素或组件上的属性(例如: class)
    • children:
      • 如果待渲染的是一个元素, 如果这个元素下面没有子元素或者子组件, 那么 children 为元素的 innerText, 如果下面还有子组件或子元素, 那么 children 应该是一个 h 函数返回值数组
      • 如果待渲染的是一个组件, children 属性将传入插槽而不是子元素, 这一点与模板设计是类似的
        <template>
          <div>
            <span>111</span>
            <span>222</span>
          </div>
        </template>
        h 函数
        h('div', {}, [h('span', {}, '111'), h('span', {}, '222')])
        对于组件
        <template>
          <Comp>
            <span>111</span>
            <span>222</span>
          </Comp>
        </template>
        h 函数
        h(Comp, {}, [h('span', {}, '111'), h('span', {}, '222')])
        设计上是统一的.

上面这个例子描述了这样一个组件:

  • 首先默认导出的是一个组件配置对象

  • 这个组件被编译为了 render 函数, render 函数返回了一个 h.

    • 诶, 我要渲染一个组件, 为啥 htypediv 而不是配置对象呢? 一定注意, render 描述的是组件里面应该如何渲染, 这里的 h 是说, App 组件里面有一个 div, 如果我们这里写的是 h(demoObj, {}, '111') 这个意思是 App 组件里面有一个 demo 组件, 这个 demo 组件里面啥也没有, 他的 innerText 是 '111'

    • 诶, 那我们在哪里定义了 App 的 h 函数呢? 我们没有用 h 函数定义 App (是利用 createApp 定义的) 至于这俩函数有什么联系后面再说

    • 诶, 那难道组件内部只能有一个一级子元素? 是的, 在 Vue2 中我们就规定 template 下最多只能有一个一级子元素, 在 Vue3 中我们用语法糖解除了这个限制. 你可能会想到对于 App 下的某个组件(如 demo), 我们通过这样的方式让这个组件有多个子元素

      // App.js 的 render
      render() {
          return h(demoConfig, { class: 'title' }, [
              h('span', {}, "111"),
              h('span', {}, "222"),
          ]);
      }

      这是错的, 数组将作为插槽传入 demo 组件, 组件的子元素是在组件自己的 render 中定义的.

      
      demoConfig = {
        render(){
          return h('div', {}, [
              h('span', {}, "111"),
              h('span', {}, "222"),
          ])
        }
      }
      
      // App.js 的 render
      render() {
          return h(demoConfig, { class: 'title' });
      }

      其实我们的疑问就是到底是他妈的谁构造了根组件 Apph 函数

  • App 是一个组件, 这个组件内部有一个 div 这个 div 又有两个子span, 内容分别是 111222

构造主流程

  • vue-runtime 的主流程

      graph TB
    根组件配置对象 --createApp--> 一种特殊的vNode --挂载根组件--> 根组件特殊使命结束,成为普通的组件 --渲染--> 进入patch函数 --目标是Element--> Element处理函数 --新Element--> 挂载Element --没有子Element --> 写入innerText
    挂载Element --有子Element--> 每个子Element --渲染--> 进入patch函数
    Element处理函数 --老Element--> 更新Element
    进入patch函数 --目标是组件--> 组件处理函数 --新组件--> 新建组件 --> 应用配置 --> 运行render --> 每个子组件 --渲染--> 进入patch函数
    组件处理函数 --老组件--> 更新组件
  • 可以看到 createApp 输入配置对象, h 函数输入 type(可以是string可以是配置对象), props, children. 虽然两者输入不同, 但是他们都返回了 vNode. createApp 的输入可以看作是没有 props, childrenh 函数的组件输入, 而 createApp 的输出可以看作是具有特殊功能的 h 输出. 实际上 createApph 在底层都依赖了 createVNode 函数.

  • vue 渲染中对象发生了如下变化:

      graph LR
    组件/元素配置对象 --> 虚拟节点vNode --> 实例对象 --> DOM
    • 组件配置对象包含了 render, setup
    • vNode 在配置对象的基础上加入了部分属性
    • 实例对象又在 vNode 的基础上加入了属性
    • 最后挂载为 DOM

vue-runtime 对外暴露函数只有 createApp 我们从这个函数入手

  • createApp 创建了 app 组件 vNode, 同时这个的 vNode 还应该有 mount 函数(唯一特殊的地方)

    // @packages/runtime-core/src/createApp.ts
    import { createVNode } from './vnode';
    import { render } from './render';
    
    export function createApp(rootComponent) {
      return {
        _component: rootComponent,
        mount(container) {
          const vNode = createVNode(rootComponent);
          render(vNode, document.querySelector(container));
        },
      };
    }
  • createVnode: 收到配置对象, props, children 将他们作为一个对象存起来(API 与 h 函数一样)

    // @packages/runtime-core/src/vnode.ts
    export function createVNode(component, props = {}, children = []) {
      return {
        type: component,
        props,
        children,
      };
    }
  • render 负责渲染 vNode, 但是 render 什么都没做, 只是调用了 patch. 这里多此一举是为了方便之后部署子元素时递归方便

    // @packages/runtime-core/src/render.ts
    export function render(vNode, container) {
      patch(null, vNode, container); // 第一次创建没有老元素
    }
  • patch 函数收入更新前后节点与挂载点(新节点的挂载前节点为 null), 针对不同节点类型调用不同处理函数

    // @packages/runtime-core/src/render.ts
    export function patch(vNode1, vNode2, container) {
      if (isObject(vNode2.type)) processComponent(vNode1, vNode2, container);
      else processElement(vNode1, vNode2, container);
    }
  • processComponent 处理组件

    // @packages/runtime-core/src/render.ts
    function processComponent(vNode1, vNode2, container) {
      if (vNode1) return updateComponent(vNode1, vNode2, container); // 老元素就 update
      return mountComponent(vNode2, container); //  新元素就挂载
    }

    updateComponent 暂时没有必要实现

  • mountComponent 挂载组件. 首先明确组件自己是没有 HTML 标签的, 挂载组件实际上是挂载组件中的子元素. 而组件存在的必要是其导出的 setup 函数中存在子元素需要的变量与函数.

    我们实现组件实例在上面记录组件需要的上下文

    // @packages/runtime-core/src/render.ts
    function mountComponent(vNode, container) {
      const instance = createComponent(vNode); // 创建实例
      setupComponent(instance); // 配置实例
      setupRenderEffect(instance.render, container); // 部署实例
    }
  • createComponent 用于创建组件实例, 为了方便我们将组件的 type 提到实例上

    // @packages/runtime-core/src/componment.ts
    export function createComponent(vNode) {
      return {
        vNode,
        type: vNode.type, // 图方便
        render: null,
      };
    }
  • setupComponent 用于创建实例, 配置实例, 包括初始化 props, slots, 处理 setup 导出的变量等. 这里我们先不处理 props, slot, 忽略 setup 导出的变量后的归属问题, 只解决

    • 如果有 setup 就执行 setup, 如果执行结果是对象就将导出对象绑定到 instance 上, 如果是函数就把他当成 render 函数
    • 如果没 render 就从 vNodetype 上读取 render
    // @packages/runtime-core/src/componment.ts
    export function setupComponent(instance) {
      // initProp
      // initSlot
      setupStatefulComponent(instance);
      finishComponentSetup(instance);
    }
    
    // 如果有 setup 就处理 setup 函数运行结果
    function setupStatefulComponent(instance) {
      if (instance.type.setup)
        handleSetupResult(instance, instance.type.setup.call(instance));
      finishComponentSetup;
    }
    
    // 处理 setup 函数运行结果
    function handleSetupResult(instance, res) {
      if (isFunction(res)) instance.render = res;
      else {
        instance.setupResult = res;
      }
      finishComponentSetup(instance);
    }
    
    // 最后兜底获取 render
    function finishComponentSetup(instance) {
      instance.render = instance.render || instance.type.render;
    }
  • 实现 instance 之后需要将中的子元素挂载出去, 递归 patch 即可

    // @packages/runtime-core/src/componment.ts
    export function setupRenderEffect(render, container) {
      const subTree = render(); // render 只能返回 h 函数的结果, 所以一定是一个 vNode, 直接 patch 就行
      // !
      patch(null, subTree, container);
    }
  • 类似的实现 Element 处理功能

    // @packages/runtime-core/src/render.ts
    function processElement(vNode1, vNode2, container) {
      if (vNode1) return updateElement(vNode1, vNode2, container);
      return mountElement(vNode2, container);
    }
  • 实现挂载 Element

    // @packages/runtime-core/src/render.ts
    function mountElement(vNode, container) {
      const el = document.createElement(vNode.type) as HTMLElement; // 构造 DOM 元素
      // 添加属性
      Object.keys(vNode.props).forEach((k) => el.setAttribute(k, vNode.props[k]));
      // 有子元素
      if (isObject(vNode.children)) {
        vNode.children.forEach((d) => {
          patch(null, d, el); // 递归挂载
        });
      } else el.textContent = vNode.children; // 没子元素
      container.appendChild(el);
    }
  • 最后写下 h 函数

    // @packages/runtime-core/src/h.ts
    import { createVNode } from "./vnode";
    export const h = createVNode

实现组件实例 Proxy

我们想要让组件可以引用自己导出的变量

export default {
  setup() {
    return {
      message: ref('micro-vue'),
    };
  },
  render() {
    return h('div', { class: 'title' }, 'hi ' + this.message);
  },
};

但是因为我们直接调用了 render 函数

// @packages/runtime-core/src/component.ts
export function setupRenderEffect(render, container) {
  const subTree = render();
  // ...
}

所以 renderthisglobal, 我们希望 renderthis 包括 setup 导出的对象与 Vue 3 文档中的组件实例, 所以我们需要构造一个 Proxy 同时实现访问 setup 结果与组件对象

  1. 处理 setup 导出

// @packages/runtime-core/src/component.ts
function handleSetupResult(instance, res) {
  // ...
  instance.setupResult = proxyRefs(res);
  // ...
}

  1. 在结束组件初始化时构造代理对象, 将代理对象作为一个属性插入实例
    // @packages/runtime-core/src/component.ts
    function finishComponentSetup(instance) {
      // 声明代理对象
      instance.proxy = new Proxy({ instance }, publicInstanceProxy);
      instance.render = instance.render || instance.type.render;
    }
    target 定义为 { instance } 看起来很怪, 为啥不直接用 instance 呢? 因为在 DEV 模式下这个对象内部应该还有很多属性, 只不过我们没有考虑
  2. 定义代理
    // @packages/runtime-core/src/publicInstanceProxy.ts
    const specialInstanceKeyMap = {
      $el: (instance) => instance.vNode.el,
    };
    
    export const publicInstanceProxy = {
      get(target, key, receiver) {
        // 如果 setup 导出的对象上有就返回
        if (Reflect.has(target.instance.setupResult, key))
          return Reflect.get(target.instance.setupResult, key);
        // 从组件属性上导出属性
        if (key in specialInstanceKeyMap)
          return specialInstanceKeyMap[key](target.instance);
        return target.instance[key];
      },
    };
  3. 实现 $el

有很多组件实例, 我们暂时只实现 $el. 挂载点应该是 vNode 的属性, 所以我们将挂载点记录在 vNode

// @packages/runtime-core/src/vnode.ts
export function createVNode(component, props = {}, children = []) {
  return {
    // ...
    el: null,
  };
}
el 作为组件实例在组件挂载后在 vNode 上更新即可

// @packages/runtime-core/src/publicInstanceProxy.ts
export function setupRenderEffect(instance, container) {
  // ...
  instance.vNode.el = container;
}

实现 shapeFlags

可以将组件类型判断抽出为一个变量, 通过位运算判断组件类型. 我们目前需要判断的有:

  • 是否是 Element
  • 是否是有 setup 的组件(也叫 stateful component)
  • 子节点是 string 还是数组

实现

  • 修改 vNode 定义
    export function createVNode(component, props = {}, children = []) {
      return {
        shapeFlags: getShapeFlags(component, children),
        // ...
      };
    }
  • 判断函数
    import { isObject } from '../../share/index';
    
    export const enum ShapeFlags {
      ELEMENT = 1 << 0,
      STATEFUL_COMPONENT = 1 << 1,
      TEXT_CHILDREN = 1 << 2,
      ARRAY_CHILDREN = 1 << 3,
    }
    
    export function getShapeFlags(type, children) {
      let res = 0;
      // 注意, 这俩不是互斥的...
      if (!isObject(type)) res |= ShapeFlags.ELEMENT;
      else if (type.setup) res |= ShapeFlags.STATEFUL_COMPONENT;
      if (isObject(children)) res |= ShapeFlags.ARRAY_CHILDREN;
      else res |= ShapeFlags.TEXT_CHILDREN;
      return res;
    }
  • 同步判断
    function setupStatefulComponent(instance) {
      if (instance.vNode.shapeFlags & ShapeFlags.STATEFUL_COMPONENT)
      // ...
    }
    
    function mountElement(vNode, container) {
      const el = document.createElement(vNode.type) as HTMLElement;
      Object.keys(vNode.props).forEach((k) => el.setAttribute(k, vNode.props[k]));
      if (vNode.shapeFlags & ShapeFlags.ARRAY_CHILDREN) {
        // ...
      }
      // ...
    }
    
    export function patch(vNode1, vNode2, container) {
      if (vNode2.shapeFlags & ShapeFlags.ELEMENT)
        processElement(vNode1, vNode2, container);
      // ...
    }

实现事件注册

我们可以为 Element 传入 attribute, 但是无法传入绑定事件, 例如传入 { onClick: ()=>{} } 在渲染到 DOM 时可以发现渲染结果为

<div onclick="()=>{}"></div>
  • onClick 的小驼峰命名没了
  • value 应该是一个函数调用, 而这里只写了一个函数, 这样点击时候并不会执行函数只会右查询一下这个函数

所以我们要手动实现这样的功能: 在挂载 Element 时, 若传入的是事件, 手动绑定这个事件

function mountElement(vNode, container) {
  const el = document.createElement(vNode.type) as HTMLElement;
  Object.keys(vNode.props).forEach((k) => {
    // 通过正则判断是否为事件绑定
    if (/^on[A-Z]/.test(k))
      el.addEventListener(
        k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1)),
        vNode.props[k]
      );
    else el.setAttribute(k, vNode.props[k]);
  });
  // ...
}

实现 props

需求:

  1. 将 props 输入 setup, 使得可以在 setup 中通过 props.属性名 调用, 同时 props 为 shadowReadonly
  2. render 可以通过 this.属性名 调用

实现:

  • 在 setup 时构造

    export function setupComponent(instance) {
      // ...
      initProps(instance);
      // ...
    }
  • 通过第二点我们就知道我们需要将 props 加入 componentPublicProxy

    export const publicInstanceProxy = {
      get(target, key, receiver) {
        // ...
        if (key in target.instance.props) return target.instance.props[key];
      	// ...
      },
    };
  • 参考 Vue 的API, 对于第一点需求我们只需要修改 handleSetupResult 的调用, 传入时加入 shadowReadonly

    handleSetupResult( instance,
          instance.type.setup.call(instance, shadowReadonly(instance.props)})
    );

    为 setup 传入参数即可

    setup(props, { emit }) {
        props.foo++; // warn: readonly value
    }
  • 我们为啥不把 shadowReadonly 写入 componentPublicProxy 呢? 这样岂不是可以保护 render 中调用不会修改原值? 没有必要, 我们只需要保证浅层 readOnly, 而 render 是直接拿属性名的, 不会修改 props 上的属性定义.

实现 emits

需求:

通过 props 传入一堆 onXxxXxx 函数在 setup 中可以通过 emit(xxxXxx) 调用函数. 其中emit 通过 setup(props, {emit}) 的方式传入.

注意, 这里就是差一个 on. 你说为啥他妈的你要差个 on 啊, 我写 Vue 的时候也没有差异啊, 这个应该是 vue-loader 为传入的 emit 名加上的 (如: <comp v-on:doSth='xxx'>, 可能会被 vue-loader 转为 { onDoSth: xxx })

那么, 难道 props 上的 onDoSth 不会被注册成事件监听吗? 怎么会, 我们的事件监听是为 Element 绑定的!

实现:

  • 实现 emit 函数

    export function emit(instance, event, ...args) {
      let eventName = event;
      if (/-([a-z])/.test(eventName)) // 如果是 xxx-xxx 命名法, 将其转换为小驼峰
        eventName = eventName.replace(/-([a-z])/, (_, lc) => lc.toUpperCase());
      if (/[a-z].*/.test(eventName)) // 如果是小驼峰命名法, 将其转换为大驼峰
        eventName = eventName[0].toUpperCase() + eventName.slice(1);
      eventName = 'on' + eventName; // 加入 on
      instance.vNode.props[eventName] && instance.vNode.props[eventName](args);
    }
  • 将函数加入实例对象 $emit

    const specialInstanceKeyMap = {
      $el: (instance) => instance.vNode.el,
      $emit: (instance) => emit.bind(null, instance),
    };
    
    export const publicInstanceProxy = {/*...*/};

    这里有个比较绕的点, Vue 要求 emit 调用方法为 emit(名字, 函数调用参数), 我们这边多了一个 instance, 所以我们在定义 $emit 时为函数 bind 第一个参数

  • 传入 setup 的调用参数

    handleSetupResult(
        instance,
        instance.type.setup.call(instance, shadowReadonly(instance.props), {
            emit: instance.proxy.$emit,
        })
    );

实现 slot

之前已经梳理过组件的 children 存储的是 slot. Vue 有三种 slot

  • 默认 slot: 直接作为子元素写入, 在子组件中会按顺序写入
  • 具名 slot: 指定元素插入什么地方
  • 作用域 slot: 为具名 slot 传入参数

先考虑组件的 children 应该传入什么样的数据类型 (h(comp, {}, children))

  • 如果只支持默认 slot, 我们大可将数组传入 children 并将 render 函数写成下面这样

    const HelloWorld = {
      render() {
        // 假设 $slot 表示父组件传入的插槽数组, 让子组件在渲染时直接解构上去
        return h('div', {}, [h('span', {}, this.foo), ...$slot]);
      },
    };
    
    export default {
      render() {
        return h('div', { class: 'title' }, [
          h('span', {}, 'APP'),
          h(HelloWorld, { foo: 'hi' }, [
            h('div', {}, 'IM LEFT'),
            h('div', {}, 'IM RIGHT'),
          ]),
        ]);
      },
    };

  • 如果需要支持具名插槽, 我们可以传入数组, 并在每个元素上打上 name. 但是这样每次放入元素都需要 \(O(n)\) 查找. 可以考虑将传入的 children 做成对象, Key 为具名插槽名字, Value 可以是 vNode 数组也可以是 vNode.

    import { h, ref, renderSlots } from '../../lib/micro-vue.esm.js';
    
    const HelloWorld = {
      render() {
        return h('div', {}, [
          // 由于 this.$slots[key] 不知道是数组还是对象, 我们用一个函数辅助处理
          renderSlots(this.$slots, 'left'),
          h('span', {}, this.foo),
          renderSlots(this.$slots, 'right'),
          h('span', {}, 'OK'),
          renderSlots(this.$slots), // 调用默认插槽
        ]);
      },
    };
    
    export default {
      render() {
        return h('div', { class: 'title' }, [
          h('span', {}, 'APP'),
          h(HelloWorld, { foo: 'hi' }, {
            left: h('div', {}, 'IM LEFT'), // left 插槽
            right: h('div', {}, 'IM RIGHT'), // right 插槽
            default: [h('div', {}, 'IM D1'),h('div', {}, 'IM D2')] // 默认插槽
          }),
        ]);
      },
    };

    这里 Vue 引入了 renderSlots 函数, 我以为其作用就是找到插槽并转换为数组, 就像下面这样

    function renderSlots(slots, key = 'default') {
      let rSlots = slots[key] ? slots[key] : [];
      return isObject(slots[key]) ? [rSlots] : rSlots;
    }

    但是实际上这个函数的返回值是一个 vNode, Vue 会直接将一个或多个 vNode 打包成一个 vNode 返回从而规避数组解构

    // @packages/runtime-core/src/componentSlots.ts
    function renderSlots(slots, name = 'default') {
        let rSlots = name in slots ? slots[name] : [];
        rSlots = testAndTransArray(rSlots);
        return h('div', {}, rSlots);
    }

    不知道为什么要这么做😟

  • 继续考虑作用域插槽, 为了实现作用域变量传递, 我们需要将插槽定义为函数, 并在调用 renderSolts 时传入参数

    const HelloWorld = {
      render() {
        return h('div', {}, [
          renderSlots(this.$slots, 'left'),
          h('span', {}, this.foo),
          renderSlots(this.$slots, 'right', 'wuhu'), // 作用域 slot 传入参数
          h('span', {}, 'OK'),
          renderSlots(this.$slots, 'default', 'wula'),
        ]);
      },
    };
    
    export default {
      render() {
        return h('div', { class: 'title' }, [
          h('span', {}, 'APP'),
          h(
            HelloWorld,
            { foo: 'hi' },
            {
              left: () => h('div', {}, 'IM LEFT'),
              right: (foo) => h('div', {}, foo),
              default: (foo) => [h('div', {}, 'IM ' + foo), h('div', {}, 'IM D2')],
            }
          ),
        ]);
      },
    };

    只需要在 renderSlots 中判断 value 是对象还是函数并分类讨论即可.

    // @packages/runtime-core/src/componentSlots.ts
    function renderSlots(slots, name = 'default', ...args) {
        let rSlots = name in slots ? slots[name] : []; // 防止给无效 Key
        // 如果是对象 / 数组就不管, 函数就调用
        rSlots = isObject(rSlots) ? rSlots : rSlots(...args);
        // 尝试转为数组
        rSlots = testAndTransArray(rSlots);
        return h(typeSymbol.FragmentNode, {}, rSlots);
    }
    
    // @packages/share/index.ts
    export function testAndTransArray(v) {
      return Array.isArray(v) ? v : [v];
    }

至此, 我们实现了插槽的渲染, 再实现一些外围方法

  • 实现 initSlot

    // @packages/runtime-core/src/componentSlots.ts
    export function initSlot(instance) {
      instance.slots = instance.vNode.children || {};
    }
  • 添加 $slot 定义

    // @packages/runtime-core/src/componentPublicInstance.ts
    const specialInstanceKeyMap = {
      $el: (instance) => instance.vNode.el,
      $emit: (instance) => emit.bind(null, instance),
      $slots: (instance) => instance.slots,
    };

实现 FragmentNode

在实现 renderSolts 时我们为将多个 vNode 打包成一个 vNode 采用 h('div', {}, rSlots) 将多个插槽放入了一个 div 下. 然而我们希望在 HTML 中不现实这个多余的 div, 此时就需要 Fragment 标签, 它相当于 Vue 插槽中的 <template></template> 标签, 永不会被渲染. 其实现的原理就是在 mount 时不挂载父节点, 直接将子节点挂载到 container 上

  • 先用 Symbol 定义标签名

    // @packages/runtime-core/src/vnode.ts
    export const typeSymbol = {
      FragmentNode: Symbol('FragmentNode'),
    };
  • patch 时特判 Fragment (因为 Fragment 与 component, Element 判断条件不同, 我们没法把他们放入用三个 case 中)

    // @packages/runtime-core/src/render.ts
    export function patch(vNode1, vNode2, container) {
      switch (vNode2.type) {
        case typeSymbol.FragmentNode:
          processFragmentNode(vNode1, vNode2, container); // 特判 Fragment
          break;
        default:
          if (vNode2.shapeFlags & ShapeFlags.ELEMENT)
            processElement(vNode1, vNode2, container);
          else processComponent(vNode1, vNode2, container);
      }
    }
    
    function processFragmentNode(vNode1, vNode2, container) {
      if (vNode1) return;
      return mountFragmentNode(vNode2, container);
    }
    
    function mountFragmentNode(vNode, container) {
      // 不挂载父节点直接将子节点挂载到 container 上
      vNode.children.forEach((d) => patch(null, d, container));
    }
  • 修改 renderSlots

    // @packages/runtime-core/src/componentSlots.ts
    export function renderSlots(slots, name = 'default', ...args) {
      let rSlots = name in slots ? slots[name](...args) : [];
      rSlots = testAndTransArray(rSlots);
      return h(typeSymbol.FragmentNode, {}, rSlots);
      //       ^ 小改一下
    }

实现 TextNode

我们还希望在 HTML 中不使用 span 就写入文字, 就像

<div>
  <span>我想写 span 就写 span</span>
  想直接写就直接写
</div>

除非使用 FragmentNode 我们无法不渲染一段内容的标签, 但是 FragmentNode 标签的 children 也必须是 vNode, 所以我们还需要定义一个特殊标签, 它本身会渲染为 TextNode

  • 定义类型

    // @packages/runtime-core/src/vnode.ts
    export const typeSymbol = {
      FragmentNode: Symbol('FragmentNode'),
      TextNode: Symbol('TextNode'),
    };
  • 特判类型

    // @packages/runtime-core/src/render.ts
    export function patch(vNode1, vNode2, container) {
      switch (vNode2.type) {
        case typeSymbol.FragmentNode:
          processFragmentNode(vNode1, vNode2, container);
          break;
        case typeSymbol.TextNode: // 特判 TextNode
          processTextNode(vNode1, vNode2, container);
          break;
        default:
          if (vNode2.shapeFlags & ShapeFlags.ELEMENT)
            processElement(vNode1, vNode2, container);
          else processComponent(vNode1, vNode2, container);
      }
    }
    
    function processTextNode(vNode1, vNode2, container) {
      if (vNode1) return;
      return mountTextNode(vNode2, container);
    }
    
    function mountTextNode(vNode, container) {
      const elem = document.createTextNode(vNode.children); // 通过 createTextNode 创建
      container.appendChild(elem);
    }
  • 向外部暴露接口

    // @packages/runtime-core/src/vnode.ts
    export function createTextVNode(text) {
      return createVNode(typeSymbol.TextNode, {}, text);
    }
  • 使用

    render() {
        return h('div', { class: 'title' }, [
            createTextVNode('im text'),
        ]);
    },

实现工具函数 getCurrentInstance

该函数用于在 setup 中获取当前 setup 所在的 instance. 只须在全局变量上打个标记就可以实现

// @packages/runtime-core/src/component.ts
let currentInstance = undefined;

function setupStatefulComponent(instance) {
  if (instance.vNode.shapeFlags & ShapeFlags.STATEFUL_COMPONENT) {
    currentInstance = instance; // 打个标记再执行
    handleSetupResult(
      instance,
      instance.type.setup(shadowReadonly(instance.props), {
        emit: instance.proxy.$emit,
      })
    );
    currentInstance = undefined; // 删除标记
  }
  finishComponentSetup(instance);
}

export function getCurrentInstance() {
  return currentInstance;
}

实现 provide-inject

provide-inject 机制允许组件在 setup 函数中调用 provide(key, value) 设置一个变量. 在若干级儿组件中通过 inject(key) 获取值的信息传递机制. 同时该机制遵守类似内外层作用域同名时的内层变量保护机制, 例如有如下 provide 关系

graph TB
A:provide-a=1  -->  B:provide-a=2  --> C:inject-a=2
A:provide-a=1  -->  D:provide-b=1
A:provide-a=1  -->  E:inject-a=1 -->  F:inject-b=undefined

也就是说当组件在父组件上无法 inject 属性时会向更上层 inject 属性.

我们可以在组件实例上定义组件的 provide 与该组件的父组件然后实现递归查找的 inject 函数. 但是 JavaScript 的原型链本身就支持递归查找, 我们可以指定通过指定某个组件 provide 的 __proto__ 为父组件的 provide 实现.

  • 在 instance 上加入 provide 属性记录该组件所 provide 的所有键值对并设置 provide.__proto__ = parent.provide

    export function createComponent(vNode, parent) {
      return {
        // ...
        parent, // 父组件
        provides: parent ? Object.create(parent.provides) : {}, // 组件的 provide
      };
    }

    我们要求在 createComponent 的时候提供 parent 属性, 那么我们也要在调用该函数时加入该参数, 该函数依赖关系如下

      graph TB
    
    render -.-> patch --> processComponent --> mountComponent --> createComponent((createComponent)) -.-> renderEffect -.-> patch
    patch --> processElement --> mountElement --> patch

    为了让 createComponent 有参数我们需要让其前置函数都带 parent 参数, 所有可能调用其前置函数的函数也要带参数. 其中存在两个特殊函数.

    • render: 该函数用来渲染根节点, 根节点的父节点是 null
    • renderEffect: 该函数是用来渲染组件 instance 的子组件 subTree 所以 parent 参数就是 instance
  • 实现 API

    // @packages/runtime-core/src/apiInject.ts
    import { getCurrentInstance } from './component';
    
    export function provide(k, v) {
      const currentInstance = getCurrentInstance() as any;
      currentInstance.provides[k] = v;
    }
    
    export function inject(key, defaultValue) {
      const currentInstance = getCurrentInstance() as any;
      return currentInstance && key in currentInstance.parent.provides
        ? currentInstance.parent.provides[key]
        : defaultValue;
    }

测试外层屏蔽与跨组件传递

// @App.js
const ProviderOne = {
  setup() {
    provide('foo', 'F1');
    provide('bar', 'B1');
    return () => h(ProviderTwo);
  },
};

const ProviderTwo = {
  setup() {
    provide('foo', 'F2');
    provide('baz', 'Z2');
    const i_foo = inject('foo');
    const i_bar = inject('bar');
    return () =>
      h('div', {}, [
        h('span', {}, '@ provide 2:'),
        h('span', {}, 'foo: ' + i_foo),
        h('span', {}, 'bar: ' + i_bar),
        h(Consumer),
      ]);
  },
};

const Consumer = {
  setup() {
    const t = getCurrentInstance();
    const i_foo = inject('foo');
    const i_bar = inject('bar');
    const i_baz = inject('baz');
    return () =>
      h('div', {}, [
        h('span', {}, '@ consumer:'),
        h('span', {}, 'foo: ' + i_foo),
        h('span', {}, 'bar: ' + i_bar),
        h('span', {}, 'baz: ' + i_baz),
      ]);
  },
};

export default {
  name: 'App',
  setup() {
    return () => h('div', {}, [h('p', {}, 'apiInject'), h(ProviderOne)]);
  },
};

结果

apiInject

@ provide 2:foo: F1bar: B1
@ consumer:foo: F2bar: B1baz: Z2

小结

到目前为止我们完成了组件挂载部分. 那我们折腾了点什么呢? 我们就是实现了 h 函数的不同功能. 在实现 API 的时候我们也要牢记 API 是为谁实现的.

参数对于组件对于Element
type包含 rendersetup 的对象标签名
props组件实例的 props包含属性与事件的对象
childrenslots子元素 / 子组件

可以发现, 这个 API 设计的非常对仗工整.

  • 对于 type: 分别传入对象与标签名, 无话可说

  • 对于 props:

    • 对于 Element: 传入一堆 attribute. Element 是会被直接渲染的, 我们直接将 Key-Value 写入标签即可. 在实践中我们发现做事件绑定时, 由于 value 是函数名, 我们无法直接将 onXxx 写入标签. 所以需要手动处理事件调用
    • 对于组件: 传入一堆 props 与 emits. 难道就没有类似 onClick 的事件监听或者类似 style 的属性吗? 没有! 组件本身是不会被渲染的! 不可能向组件标签上绑定什么东西. 组件能传入的只有用于 setup / render 的属性与 emits 事件
  • 对于 children:

    • 对于 Element: 传入一堆子元素 / 子组件, 挨个 patch 就行

    • 对于组件: 传入 slots, 将 slots 在添加到元素上

    • 为啥不让组件的子元素也写入 children 呢? 这样看的多工整!

      组件的子元素在组件的 render 里面, 不在 children

    • 为啥不让Element的子元素也写入 render 呢?

      人家 Element 压根就没对象存子元素的

    • 这尤雨溪懂个锤子 Vue, 设计的 API 咋还要分类讨论啊!

      实际上这个 API 设计的很有趣, 看看我们在 template 中是怎么书写的(从前面再抄一遍)

      对于 Element

      <template>
          <div>
              <span>111</span>
              <span>222</span>
          </div>
      </template>

      h 函数写法

      h('div', {}, [h('span', {}, '111'), h('span', {}, '222')])

      对于组件

      <template>
        <Comp>
          <span>111</span>
          <span>222</span>
        </Comp>
      </template>
      h 函数写法

      h(Comp, {}, [h('span', {}, '111'), h('span', {}, '222')])

      在结构上是对仗的. 这 API 设计的太伟大了

此外我们还实现了特殊的 FragmentTextNode 这俩都是魔改 patch 实现的. 我们还实现了 provide-inject API, 这里借助原型链实现功能也很有趣

实现 runtime-core 的 update 部分

在实现更新逻辑时我们也要对组件与 Element 分类讨论并谨记当前实现的是组件还是 Element

基本思想

  • update 事件的触发者是组件. 响应式对象修改后会触发函数, 这个函数一定是组件上的函数, Element 上存不了函数

  • 响应式对象变化后最后应该触发的是依赖组件的 render 函数, render 函数重新执行并生成新的 subTree.

  • 我们不能直接将老的 subTree 删除掉替换为新的 subTree, 这样性能损耗太大, 我们希望尽可能对比新老 subTree, 根据两个 subTree 之间的变化刷新 DOM

  • 我们对比的是 vNode, 存在 Element 与组件两种 vNode, 我们要分别对不同类型 vNode 讨论更新方法

  • 如何判断两个 vNode 是 "同类型" 的

    这里的 "同类型" 是指两个 vNode 可以通过修改对应 DOM 的子元素, 修改 props 实现更新, 而不需要卸载DOM.

    • type 不同的 vNode 一定不是同类型的

      oldVnode = h('div', {}, '呀哈哈')
      newVnode1 = h('span', {}, '克洛洛')
      newVnode2 = h(HelloWorld)

      三个 vNode 的 type 不同这导致 DOM 标签名不同, 铁定无法不卸载元素更新

    • props.key 不同一定不是同类型的

      在 Vue 中我们可以指定元素的 key 作为元素的 id, 不同 id 的元素一定是不同型的

    综上我们可以通过 vNode1.type === vNode2.type && vNode1.props.key === vNode2.props.key 判断两个 vNode 是不是同类型的

  • 如果两个 vNode 是 "同类型" 的如何更新

    • vNode 是 Element 型的

      Element 型 vNode 被实实在在的渲染到了 DOM 树上, 我们希望尽量不卸载挂载 DOM 元素, 而希望在原 Element 上做更新. 我们需要更新 DOM 的属性与 children

      • 更新 props

      • 更新 children

        children 可以是字符串也可以是 vNode 数组, 我们需要分类讨论

        • Text 型到 Text 型:

          oldVnode = h('div', {}, '呀哈哈')
          newVnode = h('div', {}, '克洛洛')

          直接更新 textCont

        • Text 型到 Array 型:

          oldVnode = h('div', {}, '呀哈哈')
          newVnode = h('div', {}, [h('div', {}, '克洛洛'), h('div', {}, '卓')])

          删掉 DOM 的 textCont, patch 新 vNode 进去

        • Array 型到 Text 型:

          oldVnode = h('div', {}, [h('div', {}, '克洛洛'), h('div', {}, '卓')])
          newVnode = h('div', {}, '呀哈哈')

          删掉 DOM 的所有 children, 写入 textContent

        • Array 型到 Array 型: 最麻烦的, 采用双端对比法, 找到节点发生变化的区间, 删除新 vNode 中不存在的节点, 加入新 vNode 中独有节点, 调整节点顺序

    • 新 vNode 是组件型的

      ??

  • 如果两个 vNode 不是 "同类型" 的如何更新

    如果是这种情况我们就束手无策了

    oldVnode1 = h('div', {}, '呀哈哈')
    newVnode1 = h('span', {}, '克洛洛')

    但是这种情况是不存在的. 若两个不同类型 vNode 要求更新, 那么前面一定调用过 patch(vNode1, vNode2, ...), 在更新时哪些情况会调用 vNode1 !== null 的 patch 呢? 组件更新, 同类型 Element 的 Array to Array. 不同类型的节点会被双端对比算法视为不同节点而被删除 / 增加掉. 所以不可能让不同类型节点 patch 在一起.

更新 pipeline

让响应式对象支持依赖收集与触发依赖. 将整个 renderEffect 装入 effect, 这意味着每次响应式对象发生变化都会重复调用 renderEffect. 同时我们要区分是 mount 还是 update, 我们可以让 instance 记录更新前的 subTree 并默认为 null 实现这一功能

// @packages/runtime-core/src/component.ts
export function setupRenderEffect(instance, container) {
  effect(() => {
    const subTree = instance.render.call(instance.proxy);
    patch(instance.subTree, subTree, container, instance);
    //    ^ 第一次是 null
    instance.vNode.el = container;
    instance.subTree = subTree; // 记录当前 subTree
  });
}

同步修正一下 createComponent

export function createComponent(vNode, parent) {
  return {
    // ...
    subTree: null,
  };
}

之后需要实现:

  • Element:
    • 更新 props
    • 更新 children
  • 组件
    • 更新??

Element 更新 props

给定更新前后的 vNode 与目标 DOM 对象, 实现 props 更新.

  • 实现测试用例

    export default {
      setup() {
        let cnt = 1;
        const attrValue = ref(`attrValue${cnt}`);
    
        window.test = function() {
          attrValue.value = `attrValue${++cnt}`;
        };
    
        return { attrValue, htmlValue };
      },
      render() {
        return h('div', {attrValue: this.attrValue}, 'test')
      },
    };
  • 先实现 updateElement 函数

    由于 vNode2 没有被 mount 所以 vNode2.el 不存在, 但是两个 vNode 对应的是同一个 DOM 对象, 我们可以将 vNode1.el 直接给到 vNode2.el. 同时我们定义 patchProps 用于实现功能

    function updateElement(vNode1, vNode2, container) {
      const elem = (vNode2.el = vNode1.el);
      patchProps(elem, vNode1?.props, vNode2.props);
    }
  • 实现 patchProps

    首先要明确我们需要 patch 什么样的 props.

    • 空值当不存在: 如果 props 是 {key: undefined / null} 我们就不 patch 这个 key
    • value 可能是事件监听: 我们可以借助 mountElement 中的 props 实现

    先实现辅助函数判断 key 是否在 props 上. 如果 value 是 null / undefined / NaN 也当 key 不存在

    // @packages/share/index.ts
    export function isUNKey(k, obj) {
      return k in obj && obj[k] !== null && obj[k] !== undefined && !Number.isNaN(obj[k])
    }

    实现 patchProps 函数

    function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) {
      // 获取所有 props
      const props = [
        ...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]),
      ];
      props.forEach((k) => {
        // 假设 key 是事件监听, 尝试将其转化为小驼峰
        let ek = /^on[A-Z]/.test(k)
          ? k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1))
          : undefined;
        // 如果 key 在老 vNode 中存在, 在新 vNode 中不存在: removeAttribute
        // 如果 key 在老 vNode 中存在, 是事件监听: 解除 防止监听函数变化
        // 如果 key 在老 vNode 中存在, 不是事件监听, 在新 vNode 也有: 不管
        if (isUNKey(k, oldProps) && (!isUNKey(k, newProps) || ek))
          ek ? elem.removeEventListener(ek, oldProps[k]) : elem.removeAttribute(k);
        // 不管老节点有没有, 新节点有: setAttribute / addEventListener
        else if (isUNKey(k, newProps))
          ek
            ? elem.addEventListener(ek, newProps[k])
            : elem.setAttribute(k, newProps[k]);
      });
    }
  • 为什么叫 patchProps 不叫 updateProps?

    实际上这个函数也可以用于 mountElement 中 props 处理(令 oldProps = {}), 并不是 updateElement 专用的. 修改

    function mountElement(vNode, container, parent) {
      const el = (vNode.el = document.createElement(vNode.type) as HTMLElement);
    - Object.keys(vNode.props).forEach((k) => {
    -   if (/^on[A-Z]/.test(k))
    -     el.addEventListener(
    -       k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1)),
    -       vNode.props[k]
    -     );
    -   else el.setAttribute(k, vNode.props[k]);
    - });
    + patchProps(el, {}, vNode.props);
    }

Element 更新 children 前三种情况

  • Text to Text

    const T2T = {
      setup() {
        const ot = ref(false);
        window.test = () => {
          ot.value = true;
        };
        return { ot };
      },
      render() {
        return this.ot ? h('div', {}, 'after') : h('div', {}, 'before');
      },
    };
    
    export default {
      setup() {},
      render() {
        return h(T2T);
      },
    };

    只需修改 DOM 内部 textContent, 如果内容不变就不修改

    function updateChildren(vNode1, vNode2, container, parent) {
      if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
        if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN)
       if (vNode2.children !== vNode1.children)
         container.textContent = vNode2.children;
      }
    }
  • Array to Text

    const A2T = {
      setup() {
        const ot = ref(false);
        window.test = () => {
          ot.value = true;
        };
        return { ot };
      },
      render() {
        return this.ot
          ? h('div', {}, 'after')
          : h('div', {}, [h('div', {}, 'before'), h('div', {}, 'before')]);
      },
    };

    将 DOM 中所有 Element 都删除, 写入 conteneText

    function updateChildren(vNode1, vNode2, container, parent) {
      if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
        if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN)
          [...container.children].forEach((d) => d.remove());
        // 这里合并了下代码, 如果是 Array to Text, 那么 Array !== string 一定成立
        if (vNode2.children !== vNode1.children)
          container.textContent = vNode2.children;
      }
    }
  • Text to Array

    const T2A = {
      setup() {
        const ot = ref(false);
        window.test = () => {
          ot.value = true;
        };
        return { ot };
      },
      render() {
        return this.ot
          ? h('div', {}, [h('div', {}, 'after'), h('div', {}, 'after')])
          : h('div', {}, 'before');
      },
    };

    删除内部 textContent 插入 vNode 数组

    function updateChildren(vNode1, vNode2, container, parent) {
      if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
        if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN)
          [...container.children].forEach((d) => d.remove());
        if (vNode2.children !== vNode1.children)
          container.textContent = vNode2.children;
      } else {
        if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
          container.textContent = '';
          vNode2.children.forEach((element) => {
            patch(null, element, container, parent);
          });
        }
      }
    }
  • 为 Array to Array 预留函数调用 patchKeyedChildren

    function updateChildren(vNode1, vNode2, container, parent) {
      if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
        if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN)
          [...container.children].forEach((d) => d.remove());
        if (vNode2.children !== vNode1.children)
          container.textContent = vNode2.children;
      } else {
        if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN) {
          container.textContent = '';
          vNode2.children.forEach((element) => {
            patch(null, element, container, parent);
          });
        } else {
          patchKeyedChildren(vNode1.children, vNode2.children, container, parent);
        }
      }
    }

Element 更新 children 的双端对比法

基本思想

分别从左边右边对比元素, 找到发生变化的区间, 例如前后两个 Array 分别为

[ a b c d e f g h ]
[ a b e d i g h ]

从左边找找到只有 a b 是相同的

[ a b | c d e f g h ]
[ a b | e d i g h ]

从右边找找到只有 g h 是相同的

[ a b | c d e f | g h ]
[ a b | e d i | g h ]

最后我们找到变化区间([c d e f] -> [e d i])

将老 vNode 独有的子 vNode 移除([c f]), 将新 vNode 独有的子 vNode patch上([i]), 调整子 vNode 的顺序. 可以使用 insertBefore 调整顺序.

  • 删除老 vNode 独有子元素

    为新 vNode 变化区间上的元素编号, 建立 key -> index 的映射. 遍历老节点, 如果没有查到 key 就删除节点

  • 创建新 vNode 独有的子元素: 在调整位置时一并处理

  • 调整位置

    可以通过之前建立的 key -> index 映射一股脑的将 DOM 调整到正常位置, 但是调整 DOM 的代价太高了, 我们希望尽可能少的减少 insertBefore 操作.

    [l1 l2 l3 ( a b c d e f g h i ) r1 r2 r3]
    [l1 l2 l3 ( i a b c d e f g h ) r1 r2 r3]

    如果采用一般方法, 我们需要分别将 a - h 移动到 r1 前面. 这明显不如将 i 移动到 a 前面划算.

    我们可以在原 vNode 的子 vNode 数组中定义一个稳定串, 保证稳定串中的元素相对位置符合新 vNode 设定, 我们只需要遍历新 vNode 的子元素, 如果该元素没有在老 vNode 中出现就创建并将其 patch 到指定位置, 如果在非稳定串中出现我们就将其 insetBefore 到指定位置. 考虑到我们只有 insertBefore 没有 insertAfter 函数, 我们还需要保证一个元素在 insertBefore 前他后一个的元素已经就位了. 所以我们需要倒着遍历新 vNode.

    在上面的例子中, 可以将 a b c d e f g h 视作稳定串, 调整 ia 前即可

  • 将 vNode patch 到指定位置

    想要将 Element 调整到指定 Element 前面, 我们可以采用 container.insertBefore(elem, target), 如果 target == null 就将元素调整到 container 尾部.

    但是如果想将新 vNode 调整到指定 Element 前面就需要调用 patch 了, 我们需要为 patch 加入一个锚点参数指定将 vNode patch 到哪里: patch(vNode1, vNode2, container, parent = null, anchor = null)

  • 寻找稳定串

    寻找稳定串其实就是寻找相对位置正确的尽可能长的子串, 我们又已知道老节点对应的新节点有一个 key -> index 的映射, 在新节点中, index 严格递增, 所以可以获取每个老节点对应的 index 并查找 LIS. 例如

    oldIndex:  0  1  2    4 5 6 7 8 9 10 11 3   12 13 14
    oldVNode: [l1 l2 l3 ( a b c d e f g  h  i ) r1 r2 r3]
    newVNode: [l1 l2 l3 ( i a b c d e f  g  h ) r1 r2 r3]
    newIndex:  0  1  2    3 4 5 6 7 8 9  10 11  12 13 14

    可以得到 lis = [a b c d e f g h]

特殊情况

可以针对部分特殊情况特殊处理避免计算 LIS

  • 新 vNode 只在最右边加了一堆元素: 只需要 patch 多出来的部分

    old: [a b c]
    new: [a b c d e f]
  • 新 vNode 只在最左边加了一堆元素: 只需要 patch 多出来的部分

    old: [      d e f]
    new: [a b c d e f]
  • 新 vNode 只最右边少了一堆元素: remove 多余 vNode 的 DOM 元素. 注意这里 remove 的不能是 vNode, 这样不管子 vNode 是 Element 还是组件类型的都可以一键卸载(因为 ELement 或 组件类型的 vNode 对应的 DOM 树都一定只有一个根 Element)

    old: [a b c d e f]
    new: [a b c]
  • 新 vNode 只最左边少了一堆元素: remove 多余 vNode 的 DOM 元素

    old: [a b c d e f]
    new: [      d e f]

我的部分实现

定义

  • c1, c2: 更新前后 vNode 的 children 数组
  • anchor: 锚点
  • i: 变化区间的左边界(包括)
  • e1, e2: 变化区间对应 c1, c2 的右边界(包括)
function patchKeyedChildren(c1, c2, container, parent, anchor) {
  let i = 0,
    e1 = c1.length - 1,
    e2 = c2.length - 1;
  // 从左往右看, 如果类型不同或者 key 不同就退出, 否则递归更新子节点
  for (; i <= Math.min(e1, e2); i += 1) {
    if (c1[i].type !== c2[i].type || c1[i].props.key !== c2[i].props.key) break;
    else patch(c1[i], c2[i], container, parent, anchor);
  }
  // 从右往左看, 如果类型不同或者 key 不同就退出, 否则递归更新子节点
  for (; e1 >= 0 && e2 >= 0; e1 -= 1, e2 -= 1)
    if (c1[e1].type !== c2[e2].type || c1[e1].props.key !== c2[e2].props.key)
      break;
    else patch(c1[e1], c2[e2], container, parent, anchor);
  // 右侧有新节点
  if (i === c1.length)
    c2.slice(i).forEach((d) =>
      patch(null, d, container, parent, i >= c2.length ? null : c2[i].el)
    );
  // 右侧有老节点
  //     传入的是 vNode 要加上 el 找到 DOM 对象
  if (i === c2.length) c1.slice(i).forEach((d) => d.el.remove());
  // 左侧有新节点
  if (e1 === -1 && e2 !== -1)
    c2.slice(0, e2 + 1).forEach((d) =>
      patch(null, d, container, parent, c1[0].el)
    );
  // 左侧有老节点
  if (e2 === -1 && e1 !== -1) c1.slice(0, e1).forEach((d) => d.el.remove());
  // 中间
  if (i <= Math.min(e1, e2)) {
    const newRange = c2.slice(i, e2 + 1);
    const oldRange = c1.slice(i, e1 + 1);
    const k2iNew = new Map(newRange.map((d, idx) => [d.props.key, i + idx]));
    const k2iOld = new Set(oldRange.map((d) => d.props.key));

    oldRange.forEach((d) => {
       // 新的有, 老的有 直接更新
      if (k2iNew.has(d.props.key)) {
        patch(
          d,
          c2[k2iNew.get(d.props.key) as number],
          container,
          parent,
          anchor
        );
      // 新的没有, 老的有 直接删除
      } else if (!k2iNew.has(d.props.key)) {
        d.el.remove();
        k2iOld.delete(d.props.key);
      }
    });
    // 新的有老的没有 新建到问题区间的尾部
    newRange.forEach((d) => {
      if (!k2iOld.has(d.props.key)) {
        patch(
          null,
          c2[k2iNew.get(d.props.key) as number],
          container,
          parent,
          e2 + 1 < c2.length ? c2[e2 + 1].el : null
        );
        k2iOld.add(d.props.key);
      }
    });
    // ... 调整位置
  }
}

存在的问题

  • 在中间对比时: 新的有老的没有的情况可以合并到调整位置上

  • 为只用四个 if 考虑了特殊情况, 没有考虑特殊情况的的扩展

    old = [a b c d e f]
    new = [a b     e f]

    既然从左往右看 [a b] 一样, 我们可以假装消掉 [a b][c d] 看成只有左侧有多于元素的情况此时直接消除 [c d] 即可, 类似的还有

    old = [a b     e f]
    new = [a b c d e f]

    如果可以特判这种情况就爽死了

别个的实现

mini-vue 的实现和原版的差不多

function patchKeyedChildren( c1, c2, container, parentAnchor, parentComponent) {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1;
  let e2 = l2 - 1;

  const isSameVNodeType = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key;
  };

  // ... 求 i e1 e2
  // 人家在这里是比较了 e1 e2 i 的关系, 这样变相的 "消除" 掉了前后置元素
  if (i > e1 && i <= e2) {
    const nextPos = e2 + 1;
    const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
    while (i <= e2) {
      patch(null, c2[i], container, anchor, parentComponent);
      i++;
    }
  } else if (i > e2 && i <= e1) {
    while (i <= e1) {
      hostRemove(c1[i].el);
      i++;
    }
  } else {
    // ... 中间对比
  }
}

这个明显不如四个 if 来的直观, 但是顺道处理了特殊情况的扩展情况. 有一说一 vuejs/core 在这一段中代码的注释中也没有提起这种情况, 但是通过这个泛泛的判断条件我们确实捕获到了这种情况. 看起来这是一个无意为之的优化?

最终实现

  • 实现 diff

    // @packages/runtime-core/src/render.ts
    function patchKeyedChildren(c1: any[], c2: any[], container, parent, anchor) {
      let i = 0,
        e1 = c1.length - 1,
        e2 = c2.length - 1;
      const isSameType = (v1, v2) =>
        v1.type === v2.type && v1.props.key === v2.props.key;
      // 找到区间
      for (; i <= Math.min(e1, e2); i += 1)
        if (!isSameType(c1[i], c2[i])) break;
        else patch(c1[i], c2[i], container, parent, anchor);
      for (; e1 >= 0 && e2 >= 0; e1 -= 1, e2 -= 1)
        if (!isSameType(c1[e1], c2[e2])) break;
        else patch(c1[e1], c2[e2], container, parent, anchor);
      // 特判
      if (e2 < i && i <= e1) c1.slice(i, e1 + 1).forEach((d) => d.el.remove());
      else if (e1 < i && i <= e2)
        c2.slice(i, e2 + 1).forEach((d) =>
          patch(
            null,
            d,
            container,
            parent,
            e1 + 1 >= c1.length ? null : c1[e1 + 1].el
          )
        );
      // 中间
      else if (i <= Math.min(e1, e2)) {
        const newRange = c2.slice(i, e2 + 1);
        const oldRange = c1.slice(i, e1 + 1);
        const new2oldIndex = new Map(); // 新节点 index -> 老节点 index
        const key2indexNew = new Map( // 新节点 key -> 新节点 index
          newRange.map((d, idx) => [d.props.key, i + idx])
        );
        // 如果老节点在新节点中存在 构造 新节点 index -> 老节点 index
        //                     不存在 删除老节点
        oldRange.forEach((vnode, idx) => {
          if (key2indexNew.has(vnode.props.key)) {
            new2oldIndex.set(key2indexNew.get(vnode.props.key), idx);
          } else vnode.el.remove();
        });
        const lis = LIS([...new2oldIndex.keys()]); // 构建稳定序列
        newRange.reduceRight(
          (prev, cur, curIndex) => {
            const oldVnode = oldRange[new2oldIndex.get(curIndex + i)]; // 对应老节点(如果存在)
            if (lis.includes(curIndex + i)) // 处于稳定序列就只更新
              return patch(oldVnode, cur, container, parent, prev?.el);
            if (new2oldIndex.has(curIndex + i)) { // 不在就移动节点
              container.insertBefore(oldVnode.el, prev?.el);
              patch(oldVnode, cur, container, parent, prev?.el);
            } else patch(null, cur, container, parent, prev?.el); // 没有老节点就加入
            return cur;
          },
          e2 + 1 >= c2.length ? null : c2[e2 + 1]
        );
      }
    }

  • 实现 LIS

    定义 low 数组, low[i] 表示长度为 i 的LIS结尾元素的最小值. 对于一个上升子序列,显然其结尾元素越小, 越有利于在后面接其他的元素, 也就越可能变得更长. 因此, 我们只需要维护 low 数组,对于每一个 s[i],如果 s[i] > low[当前最长的LIS长度],就把 a[i] 接到当前最长的 LIS 后面,即 low[++当前最长的LIS长度] = a[i] 对于每一个 s[i] ,如果 s[i] 能接到 LIS 后面,就接上去. 否则,就用 s[i] 取更新 low 数组。具体方法是, 在 low 数组中找到第一个大于等于 s[i] 的元素 low[j], 用 s[i] 去更新 low[j]. 如果从头到尾扫一遍 low 数组的话,时间复杂度仍是 \(O(n^2)\). 我们注意到 low 数组内部一定是单调不降的. 所有我们可以二分 low 数组,找出第一个大于等于 s[i] 的元素. 总时间复杂度为 \(O(n \log n)\)

    // @packages/share/index.ts
    export function LIS(s) {
      const low = [...s];
      const res = [0];
      const len = s.length;
      for (let i = 0, j; i < len; i++) {
        const cur = s[i];
        if (cur !== 0) {
          j = res[res.length - 1];
          if (s[j] < cur) {
            low[i] = j;
            res.push(i);
            continue;
          }
          let u = 0;
          let v = res.length - 1;
          while (u < v) {
            const c = (u + v) >> 1;
            if (s[res[c]] < cur) u = c + 1;
            else v = c;
          }
          if (cur < s[res[u]]) {
            if (u > 0) low[i] = res[u - 1];
            res[u] = i;
          }
        }
      }
      let u = res.length;
      let v = res[u - 1];
      while (u-- > 0) {
        res[u] = v;
        v = low[v];
      }
      return res;
    }

为什么要用双端对比法

  • 双端对比法的理论性能可能并不是最优秀的, 但是其用于前端 vNode list 的对比很优秀, 因为前端 DOM 的修改很少涉及全局修改, 一般都是一两个元素的增减调换, 双端对比法可以快速锁定修改区间, 忽略不变部分, LIS 可以保证在较少插入次数下实现位置调整
  • 为什么要讨论特殊情况, 明明可以直接利用最后的通用算法求解. 首先这个特判会让单次 update 快很多, 同时考虑前端应用场景, update 单个头尾 / 中部元素比较频繁, 这个特判会被特别多次调用

组件更新

组件 vNode 更新时 setupRenderEffect 会触发 patch(组件vNode, ...) 最后 updateComponent

不管组件有多复杂我们更新的都是组件挂载的 DOM, 组件的 render 返回的是一个 h 也就是说组件最多有一个根 DOM, 我们可以直接更新这个 DOM.

我们更新的时候拿到的是 vNode, 但是为组件传入的 props 还在 instance 上, 我们需要为 vNode 绑定 instance (使用 .component 属性)

// @packages/runtime-core/src/render.ts
function updateComponent(vNode1, vNode2, container, parent, anchor) {
  vNode2.el = vNode1.el; // 绑定 DOM
  vNode2.component = vNode1.component; // 绑定 instance
  // 判断 props 一不一样: 一样就不更新 (说明是父节点触发了, 递归到子节点)
  //                      不一样就重新渲染这个组件下的 vNode
  if (isSameProps(vNode1.props, vNode2.props)) {
    vNode1.component.vNode = vNode2;
  } else {
    // 我们要手动触发这个唯一的子 vNode 的render, 所以还需要保存每个 vNode 的 render 函数
    // 保存在 `.runner`
    // 同时记录 .next 为新 vNode
    vNode1.component.next = vNode2;
    // 调用老 vNode 的渲染函数
    vNode1.component?.runner && vNode1.component.runner();
  }
}

判断组件是否有必要更新(props 一不一样)

// @packages/runtime-core/src/component.ts
export function isSameProps(props1 = {}, props2 = {}) {
  let res = true;
  const props = [...new Set([...Object.keys(props1), ...Object.keys(props2)])];
  props.forEach((k) => props1[k] !== props2[k] && (res = false));
  return res;
}

在挂载组件时同步记录 instance

// @packages/runtime-core/src/render.ts
function mountComponent(vNode, container, parent, anchor) {
  const instance = createComponent(vNode, parent);
+ vNode.component = instance;
  setupComponent(instance);
  setupRenderEffect(instance, container, anchor);
}

在重新渲染组件时迁移 props

// @packages/runtime-core/src/component.ts
export function setupRenderEffect(instance, container, anchor) {
+ instance.runner = effect(() => {
    const subTree = instance.render.call(instance.proxy);
+   if (instance.next) {
+     instance.vNode = instance.next;
+     instance.props = instance.next.props;
+     instance.next = null;
+   }
    patch(instance.subTree, subTree, container, instance, anchor);
    instance.vNode.el = container;
    instance.subTree = subTree;
  });
}

实现 nextTrick

我们希望当 Reactivity 发生变化时组件与 DOM 是同步更新的, 这可能会带来不必要的资源消耗, 我们希望组件更新可以变成异步的

export default {
  setup() {
    let cnt = ref(1);
    window.test = () => {
      for (; cnt.value < 100; cnt.value++); // 这里会触发100次组件更新
    };
    return { cnt };
  },
  render() {
    return h('div', {}, 'HTML Context:' + this.cnt);
  },
};

如何完成异步更新呢? 我们可以将更新任务放入微任务这样只有在同步代码执行完成后微任务才会执行. 这也是 Vue 中 nextTrick 的实现原理

export function nextTick(e) {
  return Promise.resolve().then(e);
}

更新实际上就是执行 renderEffect 的中 effect 的 runner. 可以利用 effect-scheduler 实现首次触发执行 runner 之后触发执行 scheduler. 在 scheduler 中我们可以将更新事件加入队列. 并注册更新队列的微事件

const jobs: Set<any> = new Set(); // 任务队列

function insertJob(instance) {
  jobs.add(instance); // 不重复添加
  if (jobs.size <= 1) // 确保之注册一个微任务, 防止创建不必要的 Promise.resolve()
    nextTick(() => [...jobs].forEach((d) => jobs.delete(d) && d()));
}

export function setupRenderEffect(instance, container, anchor) {
  instance.runner = effect(
    () => componentUpdateFn(instance, container, anchor), // 将更新内容提出为函数
    {
      scheduler: () => {
        insertJob(instance.runner);
      },
    }
  );
  // ...
}

当 Reactivity 发生变化时, 同步执行 insertJob 同步 add Set, 注册一个微任务用于在同步代码都执行完成后执行所有刷新函数

测试

setup() {
    let cnt = ref(1);
    const instance = getCurrentInstance();
    window.test = () => {
        for (; cnt.value < 100; cnt.value++);
        console.log('1', instance.vNode.el.innerHTML); // 此时还没修改 还是 1
        nextTick(() => {
            console.log('2', instance.vNode.el.innerHTML); // 此时变为 100
        });
    };
    return { cnt };
},

这也告诉我们, 如果在 Vue 中触发了组件变化, 如果还需要同时获取组件的状态应该使用 nextTrick

实现 runtime-dom

我们的 Vue 默认是渲染在 HTML 上面的, 如果我们向将组件渲染在 canvas 上(DOM 标签变为 canvas 上的一个元素)就需要重写所有 DOM API 相关的函数调用.

我们希望将这些 API 抽象出来 (例如 document.createElement 抽象为 create 函数). 当我们需要将 Vue 组件渲染在 HTML 时只需要为 runtime-core 传入 create 函数即可.

至此我们的组件依赖关系由 vue > runtime-core > reactivity 变为 vue > runtime-dom > runtime-core > reactivity, runtime-dom 就是为 Vue 提供 HTML 渲染能力的组件

实现 runtime-dom API (将 runtime-core 中调用 DOM API 的地方全写出来)

import { createRenderer } from '../../runtime-core/src/render';
import { isUNKey } from '../../share';

export const createElement = (v) => document.createElement(v);

export const createText = (v) => document.createTextNode(v);

export const remove = (el) => el.parent && el.parent.remove(el);

export const insert = (el, parent, anchor) => parent.insertBefore(el, anchor);

export const setText = (el, text) => (el.nodeValue = text);

export const setElementText = (el, text) => (el.textContent = text);

export function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) {
  const props = [
    ...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]),
  ];
  props.forEach((k) => {
    let ek = /^on[A-Z]/.test(k)
      ? k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1))
      : undefined;
    if (isUNKey(k, oldProps) && (!isUNKey(k, newProps) || ek))
      ek ? elem.removeEventListener(ek, oldProps[k]) : elem.removeAttribute(k);
    else if (isUNKey(k, newProps))
      ek
        ? elem.addEventListener(ek, newProps[k])
        : elem.setAttribute(k, newProps[k]);
  });
}

我们还需要将 runtime-core 接入 runtime-dom, 因为我们要为 runtime-core 传入刚刚定义的渲染函数, 调用这些渲染函数的文件只有 render.ts. 之前 render.ts 是通过导出函数向外暴露 API 的, 但是因为我们也要传入函数, 我们只能将 render.ts 外部包裹一个函数, 让该函数传入 runtime-dom 定义的渲染函数最后返回原本需要导出的函数

let renderer;

// 创建可用 DOM API 的 render
function ensureRenderer() {
  return (
    renderer || // 如果创建过了就不重复创建
    (renderer = createRenderer({
      createElement,
      createText,
      setText,
      setElementText,
      patchProps,
      insert,
      remove,
    }))
  );
}

// 创建可用 DOM API 的 createApp
export const createApp = (...args) => {
  return ensureRenderer().createApp(...args);
};

export * from '../../runtime-core/src';

修改 render.ts 的构造方式

export function createRenderer({
  createElement,
  createText,
  remove,
  insert,
  setText,
  setElementText,
  patchProps,
}) {
  function render(vNode, container) {
  // ... 将调用 DOM API 的地方改为调用传入的渲染函数
  // ... 将原本所有 export 的函数改为 return {该函数}
  return {
    render,
    createApp: createApp.bind(null, render),
  };
}

createApp 函数需要 render, 但是我们的 render 也是动态构建的, 所以我们只能为 createApp 传入 render, 并在 render.ts 中先传入这一参数

export function createApp(render, rootComponent) {
  return {
    _component: rootComponent,
    mount(container) {
      const vNode = createVNode(rootComponent);
      render(
        vNode,
        isObject(container)
          ? container
          : document.querySelector(container)
      );
    },
  };
}

最后修改导出

export * from './reactivity/src/index';
- export * from './runtime-core/src/index';
+ export * from './runtime-dom/src/index';

测试

实现一个 runtime-PIXI, PIXI.js 是一个基于 canvas 的游戏库, 完成了对 canvas 的封装. 我们希望通过对 PIXI.js API 的二次封装实现利用 Vue 操作 canvas

我们希望实现执行 test 修改正方形位置

export default {
  setup() {
    const x = ref(0);
    const y = ref(0);
    window.test = (x1, y1) => {
      x.value = x1;
      y.value = y1;
    };
    return { x, y };
  },
  render() {
    return h('div', { x: this.x, y: this.y });
  },
};

为了将测试代码写在一起, 我们将 runtime-dom 完全引入了测试用例并重写 runtime-dom. 这样做可以免去将重新编译 Vue

// modify to export createRenderer (cause the export level is different from vue runtime-dom)
import { createRenderer } from '../../lib/micro-vue.esm.js';
export * from '../../lib/micro-vue.esm.js';

// 创建元素
export const createElement = (v) => {
  const rect = new PIXI.Graphics();
  rect.beginFill(0xff0000);
  rect.drawRect(0, 0, 100, 100);
  rect.endFill();
  return rect;
};

// 插入
export const insert = (el, parent, anchor) => parent.addChild(el);

// 修改属性
export function patchProps(elem, oldProps = {}, newProps = {}) {
  const props = [
    ...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]),
  ];
  props.forEach((k) => {
    elem[k] = newProps[k];
  });
}

let renderer;

// 与测试无关的 API 直接给 NULL
function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer({
      createElement,
      createText: null,
      setText: null,
      setElementText: null,
      patchProps,
      insert,
      remove: null,
    }))
  );
}

export const createApp = (...args) => {
  return ensureRenderer().createApp(...args);
};

// 创建 game
const game = new PIXI.Application({ width: 640, height: 360 });

// 挂载 canvas
document.body.append(game.view);
// 在 PIXI 中 canvas DOM 不用于挂载元素, 新元素是挂载到 game.stage 上的
export const el = game.stage;

实现 compiler-core

graph LR

string(输入string) --> parse(parse模块) --> ast1(输出AST树) --> transform(transform模块对树CRUD) --> ast2(输出AST树) --> CodeGen(CodeGen模块) --> render(输出render)

构建相关模块

@packages/compiler-core
├── src
│   └── index.ts
└── __tests__

并导出模块

export * from './reactivity/src/index';
export * from './runtime-dom/src/index';
export * from './compiler-core/src/index';

实现 parse 模块的插值解析

需求

我们希望可以解析 {{message}} 为 AST 树, 插值语法的 AST 为

{
  type: NodeTypes.INTERPOLATION,
  content: {
    type: NodeTypes.SIMPLE_EXPRESSION,
    content: 'message',
  },
}

实现

  • 定义枚举
    @packages/compiler-core/src/ast.ts
    export const enum NodeTypes {
      INTERPOLATION,
      SIMPLE_EXPRESSION,
    }
  • 实现编译
    @packages/compiler-core/src/parse.ts
    import { NodeTypes } from './ast';
    
    // 构造上下文, 之后源码都从 source 里面取
    function createParserContext(content) {
      return {
        source: content,
      };
    }
    
    // 构造 Root 节点, 其只包含子节点属性
    function createRoot(children) {
      return {
        children,
      };
    }
    
    // 如果代码中包含 '{{' 就执行解析
    function parseChildren(context) {
      const nodes = [] as any[];
      let node = null as any;
      if (context.source.startsWith('{{')) node = parseInterpolation(context);
      node && nodes.push(node);
      return nodes;
    }
    
    // 找到一对最近的 {{ }}, 提取插值, 删除这个插值代码
    function parseInterpolation(context) {
      const closeDelimiter = '}}';
      const openDelimiter = '{{';
    
      const closeIndex = context.source.indexOf(
        closeDelimiter,
        openDelimiter.length
      );
    
      adviceBy(context, openDelimiter.length);
    
      const content = context.source
        .slice(0, closeIndex - openDelimiter.length)
        .trim();
      adviceBy(context, closeIndex);
    
      return {
        type: NodeTypes.INTERPOLATION,
        content: {
          type: NodeTypes.SIMPLE_EXPRESSION,
          content,
        },
      };
    }
    
    // 推进操作(删除已解析代码)
    function adviceBy(context, length) {
      context.source = context.source.slice(length);
    }
    
    // 解析器入口
    export function baseParse(content: string) {
      const context = createParserContext(content);
      return createRoot(parseChildren(context));
    }

实现 parse 模块的 Element 解析

需求

识别 <xx></xx> 的代码并解析为 AST 树, 其结构为

{
  type: NodeTypes.ELEMENT,
  tag: 'div',
  children: [],
}

实现

  • 定义枚举
    export const enum NodeTypes {
      INTERPOLATION,
      SIMPLE_EXPRESSION,
      ELEMENT,
    }
  • 实现解析函数
    // @packages/compiler-core/src/parse.ts
    // 识别条件 <字母模式, 这个定义看起来很宽松, 但是却是标准定义
    if (/^<[a-zA-Z]/.test(context.source)) node = parseElement(context);
    
    function parseElement(context) {
      const tagMatch = context.source.match(/^<([a-zA-Z]*)>.*<\/\1>/);
      const tag = tagMatch[1];
      adviceBy(context, tagMatch[0].length);
      return {
        type: NodeTypes.ELEMENT,
        tag,
      };
    }

实现 parse 模块的 Text 解析

需求

将不满足两种规范的代码识别为 Text 并解析为 AST 树, 其结构为

{
  type: NodeTypes.TEXT,
  content: 'bulabula',
}

实现

  • 定义枚举: 略
  • 返回 AST
    function parseText(context) {
      const content = context.source;
      adviceBy(context, content.length);
      return {
        type: NodeTypes.TEXT,
        content,
      };
    }

同时解析三种类型

需求

<div>hi, {{message}}</div> 解析为

{
  type: NodeTypes.ELEMENT,
  tag: 'div',
  children: [
    {
      type: NodeTypes.TEXT,
      content: 'hi, ',
    },
    {
      type: NodeTypes.INTERPOLATION,
      content: {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content: 'message',
      },
    },
  ],
}

实现

  • 修正 Element 使之可以解析 Element 标签内部文本

    function parseElement(context) {
    - const tagMatch = context.source.match(/^<([a-zA-Z]*)>.*<\/\1>/);
    + const tagMatch = context.source.match(/^<([a-zA-Z]*)>(.*)<\/\1>/);
      const tag = tagMatch[1];
      adviceBy(context, tagMatch[0].length);
      return {
        type: NodeTypes.ELEMENT,
        tag,
    +   children: parseChildren(createParserContext(tagMatch[2])),
      };
    }
  • 修正 Text 解析使之可以在遇到插值 / Element 前导时停止解析

    function parseText(context) {
    + let content = context.source;
    + if (~content.indexOf('{{')) {
    +   content = content.slice(0, content.indexOf('{{') );
    + } else if (/<\/?[a-zA-Z].+/.test(content)) {
    +   content = content.slice(
    +     0,
    +     content.length - content.match(/<\/?[a-zA-Z].+/).length
    +   );
    + }
      adviceBy(context, content.length);
      return {
        type: NodeTypes.TEXT,
        content,
      };
    }
  • 在一次解析并推进完成后继续解析剩余代码

    function parseChildren(context) {
      const nodes = [] as any[];
      let node = null as any;
    + while (context.source) {
        if (context.source.startsWith('{{')) node = parseInterpolation(context);
        else if (/^<[a-zA-Z]/.test(context.source)) node = parseElement(context);
        else node = parseText(context);
        nodes.push(node);
    + }
      return nodes;
    }

实现 Transform 模块

希望为 transform 传入一个函数组, 对每个节点执行这些函数. 我们只需要做一个 DFS 即可

  • DFS
    import { NodeTypes } from './ast';
    import { TO_DISPLAY_STRING } from './runtimeHelpers';
    
    // 创建上下文并遍历
    export function transform(root, options = {}) {
      const context = createTransformContext(root, options);
      traverseNode(root, context);
      root.helpers.push(...context.helpers.keys());
    }
    
    // 创建上下文
    function createTransformContext(root: any, options: any): any {
      const context = {
        root,
        nodeTransforms: options.nodeTransforms || [],
        helpers: new Map(),
        helper(key) {
          context.helpers.set(key, 1);
        },
      };
    
      return context;
    }
    
    // 遍历节点, 如果是插值节点就调用 helper, ROOT 与 Element 就遍历子节点
    function traverseNode(node: any, context) {
      const exitFns: any = [];
      for (let i of context.nodeTransforms) {
        const onExit = i(node, context);
        onExit && exitFns.push(onExit);
      }
    
      switch (node.type) {
        case NodeTypes.INTERPOLATION:
          context.helper(TO_DISPLAY_STRING);
          break;
        case NodeTypes.ROOT:
        case NodeTypes.ELEMENT:
          traverseChildren(node, context);
          break;
        default:
          break;
      }
    
      let i = exitFns.length;
      while (i--) exitFns[i]();
    }
    
    // 遍历子节点
    function traverseChildren(node: any, context: any) {
      for (let child of node.children) traverseNode(child, context);
    }
  • 定义枚举
    export const TO_DISPLAY_STRING = Symbol(`toDisplayString`);
    export const CREATE_ELEMENT_VNODE = Symbol("createElementVNode");
    
    export const helperNameMap = {
      [TO_DISPLAY_STRING]: "toDisplayString",
      [CREATE_ELEMENT_VNODE]: "createElementVNode"
    };
  • 为 ROOT 打上 tag
    function createRoot(children) {
      return {
    +   type: NodeTypes.ROOT,
        children,
      };
    }

实现 compile 模块

需要将 AST 转换为代码字符串. 可以将 AST 分为四类

  • Text
  • 插值
  • Element: children 只有一个元素 (e.g. <div>as</div> => h('div', {}, 'as'))
  • Element: children 有多个元素 (e.g. <div>as {{her}}</div> => h('div', {}, 'as' + her)), 称之为复杂类型

处理最后一种 AST 需要在内容中解析出的每个元素之间用 + 连接

  • 加入枚举类型

    export const enum NodeTypes {
      INTERPOLATION,
      SIMPLE_EXPRESSION,
      ELEMENT,
      TEXT,
      ROOT,
    + COMPOUND_EXPRESSION,
    }

  • 代码生成

    import { isString } from '../../share';
    import { NodeTypes } from './ast';
    import {
      CREATE_ELEMENT_VNODE,
      helperNameMap,
      TO_DISPLAY_STRING,
    } from './runtimeHelpers';
    
    export function generate(ast) {
      const context = createCodegenContext(); // 创建上下文
      genFunctionPreamble(ast, context); // 创建依赖引入
      const functionName = 'render'; // 函数名
      const args = ['_ctx', '_cache']; // 函数参数
      const signature = args.join(', '); // 函数参数字符串
      context.push(`function ${functionName}(${signature}){`); // 连接函数头
      context.push('return ');
      genNode(ast.codegenNode, context); // 生成 AST 对应的内推
      context.push('}');
      return {
        code: context.code,
      };
    }
    
    function genFunctionPreamble(ast, context) {
      const VueBinging = 'Vue'; // 引用自的变量 `import {...} from Vue`
      const aliasHelper = (s) => `${helperNameMap[s]}:_${helperNameMap[s]}`; // 引用代码生成函数
      if (ast.helpers.length > 0) // 生成代码
        context.push(
          `const { ${ast.helpers.map(aliasHelper).join(', ')} } = ${VueBinging}`
        );
      context.push('\n');
      context.push('return ');
    }
    
    function createCodegenContext(): any {
      const context = {
        code: '',
        push(source) { // 将拼接代码功能写入 context
          context.code += source;
        },
        helper(key) {
          return `_${helperNameMap[key]}`;
        },
      };
    
      return context;
    }
    
    function genNode(node: any, context) { // 分类生成不同 AST 对应的代码
      switch (node.type) {
        case NodeTypes.TEXT:
          genText(node, context);
          break;
        case NodeTypes.INTERPOLATION:
          genInterpolation(node, context);
          break;
        case NodeTypes.SIMPLE_EXPRESSION:
          genExpression(node, context);
          break;
        case NodeTypes.ELEMENT:
          genElement(node, context);
          break;
        case NodeTypes.COMPOUND_EXPRESSION:
          genCompoundExpression(node, context);
          break;
        default:
          break;
      }
    }
    
    function genCompoundExpression(node: any, context: any) {
      for (let child of node.children)
        if (isString(child)) context.push(child);
        else genNode(child, context);
    }
    
    function genElement(node: any, context: any) {
      context.push(`${context.helper(CREATE_ELEMENT_VNODE)}(`);
      genNodeList(genNullable([node.tag, node.props, node.children]), context);
      context.push(')');
    }
    
    function genNodeList(nodes, context) {
      for (let i = 0; i < nodes.length; i++) {
        const node = nodes[i];
        if (isString(node)) context.push(node);
        else genNode(node, context);
        if (i < nodes.length - 1) context.push(', ');
      }
    }
    
    function genNullable(args: any) {
      return args.map((arg) => arg || 'null');
    }
    
    function genExpression(node: any, context: any) {
      context.push(`${node.content}`);
    }
    
    function genInterpolation(node: any, context: any) {
      context.push(`${context.helper(TO_DISPLAY_STRING)}(`);
      genNode(node.content, context);
      context.push(')');
    }
    
    function genText(node: any, context: any) {
      context.push(`'${node.content}'`);
    }

  • 创建根节点的入口节点

    function createRootCodegen(root: any, context: any) {
      const { children } = root;
      const child = children[0];
      if (child.type === NodeTypes.ELEMENT && child.codegenNode) {
        const codegenNode = child.codegenNode;
        root.codegenNode = codegenNode;
      } else {
        root.codegenNode = child;
      }
    }

  • Element 生成函数

    import { createVNodeCall, NodeTypes } from "../ast";
    
    export function transformElement(node, context) {
      if (node.type === NodeTypes.ELEMENT) {
        return () => {
          // tag
          const vnodeTag = `'${node.tag}'`;
    
          // props
          let vnodeProps;
    
          // children
          const children = node.children;
          let vnodeChildren = children[0];
    
          node.codegenNode = createVNodeCall(
            context,
            vnodeTag,
            vnodeProps,
            vnodeChildren
          );
        };
      }
    }

  • 生成表达式

    import { NodeTypes } from "../ast";
    
    export function transformExpression(node) {
      if (node.type === NodeTypes.INTERPOLATION) {
        node.content = processExpression(node.content);
      }
    }
    
    function processExpression(node: any) {
      node.content = `_ctx.${node.content}`;
      return node;
    }

  • 生成文本节点

    import { NodeTypes } from '../ast';
    import { isText } from '../utils';
    
    export function transformText(node) {
      if (node.type === NodeTypes.ELEMENT) {
        return () => {
          const { children } = node;
    
          let currentContainer;
          for (let i = 0; i < children.length; i++) {
            const child = children[i];
    
            if (isText(child)) {
              for (let j = i + 1; j < children.length; j++) {
                const next = children[j];
                if (isText(next)) {
                  if (!currentContainer)
                    currentContainer = children[i] = {
                      type: NodeTypes.COMPOUND_EXPRESSION,
                      children: [child],
                    };
    
                  currentContainer.children.push(' + ');
                  currentContainer.children.push(next);
                  children.splice(j, 1);
                  j--;
                } else {
                  currentContainer = undefined;
                  break;
                }
              }
            }
          }
        };
      }
    }

  • Text 节点与 string 判断函数

    export const isString = (value) => typeof value === 'string';
    
    export function isText(node) {
      return node.type === NodeTypes.INTERPOLATION || node.type === NodeTypes.TEXT;
    }

让 runtime-core 调用 compiler-core

runtime-core 会在确定 instance.render 时调用 compiler-core. 如果 setup 不返回函数, 组件没有自带 render 函数, runtime-core 会在有 template 时调用 compiler-core. compiler-core 会返回函数代码, 我们需要根据代码获得 render 函数

测试代码

import { h, ref } from '../../lib/micro-vue.esm.js';

export default {
  setup() {
    const message = ref('micro-vue');
    window.test = () => (message.value = 'hihi');
    return { message };
  },
  template: `<div>hi, {{message}}</div>`,
};

实现

  • 构造 compiler-core 向外暴露的函数

    import { generate } from './codegen';
    import { baseParse } from './parse';
    import { transform } from './transform';
    import { transformExpression } from './transforms/transformExpression';
    import { transformElement } from './transforms/transformElement';
    import { transformText } from './transforms/transformText';
    
    export function baseCompile(template, options = {}) {
      const ast = baseParse(template);
      transform(
        ast,
        Object.assign(options, {
          nodeTransforms: [transformElement, transformText, transformExpression],
        })
      );
      return generate(ast);
    }
  • 在 runtime-core 中加入编译 template 功能

    function finishComponentSetup(instance) {
      instance.render = instance.render || instance.type.render || compiler(instance.type.template);
    }

  • 为 patchProps 加入兜底功能

    export function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) {
    + oldProps ??= {};
    + newProps ??= {};
      // ...
    }

  • 导出 createElementNode

    export { createVNode as createElementVNode };

  • 解决循环依赖问题: runtime-core 需要从 Vue 获取编译函数而 Vue 需要引用 runtime-core. 为了解决循环依赖, 可以让 runtime-core 暴露一个 SET 函数, 当文件加载完毕后调用祖册函数为 runtime-core 注册来自 compiler-core 的编译函数.

    • 在 runtime-core 中暴露注册函数
      let compiler;
      
      export function registerRuntimeCompiler(_compiler){
        compiler = _compiler;
      }
    • 在最外层引入
      export * from './reactivity/src/index';
      export * from './runtime-dom/src/index';
      
      import { baseCompile } from './compiler-core/src';
      import * as runtimeDom from './runtime-dom/src';
      
      // 将文本转换为函数的函数
      export function compileToFunction(template) {
        // 代码
        const { code } = baseCompile(template);
        // compiler-core 编译的函数形如
        //   import { createElementNode as _createElementNode } from Vue
        //   return _createElementVNode(...)
        // 我们希望获取 return 结果, 将这个代码段构造为函数
        //   function ff(Vue){
        //     import { createElementNode as _createElementNode } from Vue
        //     return _createElementVNode(...)
        //   }
        // 只需要传入 Vue 作为参数并获取函数运行结果即可
        // 无需 eval, 用 Function 即可构造函数. 并传入 Vue 即可
        return new Function('Vue', code)(runtimeDom);
      }
      
      // 将这个编译函数注入 runtime-core, runtime-core 即可拥有编译函数
      runtimeDom.registerRuntimeCompiler(compileToFunction);
  • 编译后的函数需要两个参数, 传入相同值

    function componentUpdateFn(instance, container, anchor, patch) {
    - const subTree = instance.render.call(instance.proxy);
    + const subTree = instance.render.call(instance.proxy, instance.proxy);
      if (instance.next) {
        instance.vNode = instance.next;
        instance.props = instance.next.props;
        // ...
    }