实现mini-vue -- runtime-core模块(十四)实现自定义渲染器

536 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第19天,点击查看活动详情

目前我们已有的渲染器只支持渲染DOM环境,如果我们希望渲染在别的地方,并且依然能够享受到我们的mini-vue的特性的话,就需要将原有的具体DOM环境的实现抽象成通用的渲染逻辑,将具体实现抽象成接口,让runtime-core变得更加通用,并创建新的runtime-dom模块,将DOM环境下的渲染逻辑单独实现 为了验证通用性,最后还会通过一个在自定义canvas的渲染器,在canvas环境下使用我们的mini-vue的特性


1. 找出可抽象的部分

事实上仔细观察目前renderer.ts中的代码,就可以发现我们需要抽象的只是mountElement函数中的内容,因为mountElement就是针对DOM环境的实现,使用到的创建元素、设置**props**和将元素添加到容器这三个逻辑都是依赖于DOM API的,那么我们只需要把这三个操作抽象成三个接口,然后直接调用接口并传入相应的参数即可 为此,我们可以抽象出下面三个接口函数:

  • createElement
  • patchProps
  • insert
function mountElement(vnode: any, container: any, parentComponent) {
  // 将创建的元素挂载到 vnode 上 从而让组件实例能够访问到
  // 创建元素
- const el = (vnode.el = document.createElement(vnode.type));
+ const el = (vnode.el = createElement(vnode.type));
  const { children, shapeFlag } = vnode;

  // children
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = children;
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(children, el, parentComponent);
  }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    // 设置 prop
    // const isOn = (key: string) => /^on[A-Z]/.test(key);
    // // 处理事件监听
    // if (isOn(key)) {
    //   el.addEventListener(key.slice(2).toLowerCase(), value);
    // } else {
    //   el.setAttribute(key, value);
    // }
+   patchProp(el, key, value);
  }

  // 元素添加到容器中
  // container.append(el);
+ insert(el, container);
}

由于需要将接口作为扩展参数传入,所以新建一个函数createRenderer将原来的渲染逻辑相关的函数全都包裹起来,形成一个闭包,最后将得到的render函数返回出去 createRender接收一个options参数,是一个对象,接口函数通过这个对象传入

export function createRenderer(options) {
  const { createElement, patchProp, insert } = options;

  function render(vnode: any, container: any) {
    // 调用 patch
    patch(vnode, container);
  }

  /**
   * @description 能够处理 component 类型和 dom element 类型
   *
   * component 类型会递归调用 patch 继续处理
   * element 类型则会进行渲染
   */
  function patch(vnode, container, parentComponent = null) {
    const { type, shapeFlag } = vnode;

    switch (type) {
      case Fragment:
        processFragment(vnode, container, parentComponent);
        break;
      case Text:
        processText(vnode, container);
        break;

      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 真实 DOM
          processElement(vnode, container, parentComponent);
        } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
          // 处理 component 类型
          processComponent(vnode, container, parentComponent);
        }
        break;
    }
  }

  function processText(vnode: any, container: any) {
    const { children } = vnode;
    const textNode = (vnode.el = document.createTextNode(children));
    container.append(textNode);
  }

  function processFragment(vnode: any, container: any, parentComponent) {
    mountChildren(vnode.children, container, parentComponent);
  }

  function processElement(vnode: any, container: any, parentComponent) {
    mountElement(vnode, container, parentComponent);
  }

  function mountElement(vnode: any, container: any, parentComponent) {
    // 将创建的元素挂载到 vnode 上 从而让组件实例能够访问到
    // 创建元素
    const el = (vnode.el = createElement(vnode.type));
    const { children, shapeFlag } = vnode;

    // children
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      el.textContent = children;
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      mountChildren(children, el, parentComponent);
    }

    // props
    const { props } = vnode;
    for (const [key, value] of Object.entries(props)) {
      // 设置 prop
      // const isOn = (key: string) => /^on[A-Z]/.test(key);
      // // 处理事件监听
      // if (isOn(key)) {
      //   el.addEventListener(key.slice(2).toLowerCase(), value);
      // } else {
      //   el.setAttribute(key, value);
      // }
      patchProp(el, key, value);
    }

    // 元素添加到容器中
    // container.append(el);
    insert(el, container);
  }

  function mountChildren(children: any, container: any, parentComponent) {
    children.forEach((v) => {
      patch(v, container, parentComponent);
    });
  }

  function processComponent(vnode: any, container: any, parentComponent) {
    mountComponent(vnode, container, parentComponent);
  }

  function mountComponent(initialVNode: any, container, parentComponent) {
    // 根据 vnode 创建组件实例
    const instance = createComponentInstance(initialVNode, parentComponent);

    // setup 组件实例
    setupComponent(instance);
    setupRenderEffect(instance, container);
  }

  function setupRenderEffect(instance, container) {
    const { proxy, vnode } = instance;
    const subTree = instance.render.call(proxy);

    // subTree 可能是 Component 类型也可能是 Element 类型
    // 调用 patch 去处理 subTree
    // Element 类型则直接挂载
    patch(subTree, container, instance);

    // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
    // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
    vnode.el = subTree.el;
  }
}

2. 根据抽象实现runtime-dom

既然已经抽象出去了,那么原来的DOM的渲染逻辑就没了,现在我们需要将其实现,保证原有的DOM的渲染逻辑正常运行 创建一个runtime-dom模块,src/runtime-dom/index.ts,在这里面实现createElementpatchPropinsert这三个接口 由于要使用到createRenderer函数,因此我们需要将其先导出

// src/runtime-core/index.ts

export { createApp } from './createApp';
export { h } from './h';
export { renderSlots } from './helpers/renderSlots';
export { createTextVNode } from './vnode';
export { getCurrentInstance } from './component';
export { provide, inject } from './apiInject';
+ export { createRenderer } from './renderer';

然后去实现接口

import { createRenderer } from '../runtime-core';

function createElement(type) {
  return document.createElement(type);
}

function patchProp(el, key, value) {
  const isOn = (key: string) => /^on[A-Z]/.test(key);
  // 处理事件监听
  if (isOn(key)) {
    el.addEventListener(key.slice(2).toLowerCase(), value);
  } else {
    el.setAttribute(key, value);
  }
}

function insert(el, container) {
  container.append(el);
}

const renderer = createRenderer({
  createElement,
  patchProp,
  insert,
});

就是把之前写在mountElement中的代码剪切到这边即可


3. 重构createApp

由于之前的createApp中会用到render函数,而我们现在的render函数已经变了,因此我们需要对createApp的实现进行重构 由于render现在是在createRenderer中的,并没有被导出,由于是在闭包内,相当于变成了一个私有方法,但是createApp又必须用到它,所以一个可行的方案是在createRenderer中,调用一个能够得到createApp的函数,将其结果返回出去即可 可以将原先的createApp包裹在一个createAppAPI函数中,这样就可以在createRender中把原本createApp中用到的render作为createAppAPI的参数传入

首先用createAppAPI包裹createApp

export function createAppAPI(render) {
  return function createApp(rootComponent) {
    return {
      mount(rootContainer) {
        // 先将 rootComponent 转成 VNode 再进行处理
        const vnode = createVNode(rootComponent);

        if (typeof rootContainer === 'string') {
          rootContainer = document.querySelector(rootContainer);
        }

        render(vnode, rootContainer);
      },
    };
  }
}

然后再在createRenderer中调用,并将得到的函数作为结果返回

export function createRenderer(options) {
  const { createElement, patchProp, insert } = options;

  function render(vnode: any, container: any) {
    // 调用 patch
    patch(vnode, container);
  }
  
  // ...
  
+  return {
+    createApp: createAppAPI(render),
+  };
}

再回到runtime-dom中,将原先用户使用的createApp在这里进行导出,由于我们已经调用createRenderer获取到了renderer对象,renderer对象中有createApp方法,我们直接调用该方法并返回即可

import { createRenderer } from '../runtime-core';

function createElement(type) {
  return document.createElement(type);
}

function patchProp(el, key, value) {
  const isOn = (key: string) => /^on[A-Z]/.test(key);
  // 处理事件监听
  if (isOn(key)) {
    el.addEventListener(key.slice(2).toLowerCase(), value);
  } else {
    el.setAttribute(key, value);
  }
}

function insert(el, container) {
  container.append(el);
}

const renderer: any = createRenderer({
  createElement,
  patchProp,
  insert,
});

+ export function createApp(...args) {
+   return renderer.createApp(...args);
+ }

最后,runtime-core的出口文件中已经不需要导出createApp了,createApp现在已经转移给runtime-dom去负责,所以可以将其删除

- export { createApp } from './createApp';
export { h } from './h';
export { renderSlots } from './helpers/renderSlots';
export { createTextVNode } from './vnode';
export { getCurrentInstance } from './component';
export { provide, inject } from './apiInject';
export { createRenderer } from './renderer';

至此,createApp就重构完成啦!


4. 重构mini-vue导出逻辑

由于现在runtime-coreruntime-dom逻辑分离,因此需要分别进行导出,但是又考虑到runtime-core是一个更加底层的模块,而runtime-dom才是用户使用的模块,因此我们从模块结构的角度考虑,应当在mini-vue的出口中导出runtime-dom,并在runtime-dom中导出更加底层的runtime-core

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

// ...

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

5. 验证抽象是否成功

至此,就已经完成了我们的自定义渲染器,将渲染的逻辑进行了一次抽象,为了验证这次抽象是否有问题,我们可以打包后运行一下之前的demo,如果没有受到影响则说明本次抽象是成功的

5.1 DOM环境下的渲染

image.png image.png image.png image.png image.png apiInject Demo 以上这些都是之前的在DOM环境下的Demo,全都没有问题,说明本次抽象是成功的!


5.2 Canvas中的渲染

我们还需要验证一下我们的自定义渲染器对于其他环境下的渲染逻辑如何,比如在canvas中使用我们的渲染器,首先创建一个demo -- customRenderer 在这个Demo中,我们会使用到PIXI这一js的游戏引擎,直接使用cdn的方式导入

<!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>customRenderer</title>
    <script src="https://pixijs.download/release/pixi.js"></script>
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <script type="module" src="main.js"></script>
  </body>
</html>

我们的目标是使用PIXI渲染出一个矩形,首先创建一个App.js,我们将会用它渲染一个矩形

import { h } from '../../lib/plasticine-mini-vue.esm.js';

export const App = {
  setup() {},
  render() {
    return h('rect', {});
  },
};

可以看到,对于渲染矩形这件事情,我们不需要关心如何去渲染,只需要关心核心的渲染逻辑 -- 也就是需要渲染一个啥东西,这里我们约定当组件渲染typerect字符串时,就是要渲染一个矩形出来 而渲染的逻辑,就交给自定义渲染器去实现,组件并不需要关心如何渲染 接下里就看看自定义渲染器是如何处理的

import { createRenderer } from '../../lib/plasticine-mini-vue.esm.js';
import { App } from './App.js';

const WIDTH = document.documentElement.clientWidth;
const HEIGHT = document.documentElement.clientHeight;

const init = () => {
  const app = new PIXI.Application({
    width: WIDTH,
    height: HEIGHT,
  });
  document.body.append(app.view);

  renderer.createApp(App).mount(app.stage);
};

const renderer = createRenderer({
  createElement(type) {
    const drawRect = () => {
      const graphics = new PIXI.Graphics();
      graphics.beginFill(0x92b4ec);
      graphics.drawRect(WIDTH / 2 - 50, HEIGHT / 2 - 50, 100, 100);
      graphics.endFill();

      return graphics;
    };

    switch (type) {
      case 'rect':
        return drawRect();
      default:
        break;
    }
  },
  patchProp(el, key, value) {
    el[key] = value;
  },
  insert(el, container) {
    // addChild 是 PIXI 提供的 API
    container.addChild(el);
  },
});

init();

首先我们在init中创建一个PIXIapp,并将它放到document.body中,作为根组件容器,之后渲染的内容都会在这个容器中,通过app.stage可以获取到这个容器,我们将App组件挂载到这个容器当中 接下来就是创建一个自定义渲染器,在这里面实现具体的渲染逻辑 createElement中,当渲染的vnode的类型为'rect'这样一个字符串的时候,我们就让它渲染一个矩形到根组件容器中 patchProp中,el就是createElement中创建出来的元素对象,这里就是PIXI.Graphics对象,然后将组件中定义的prop挂载到对象当中 insert则是使用PIXI提供的addChildAPI,这里Container现在就是最开始调用mount时传入的app.stage容器对象,它的原型上有一个addChild方法,因此我们可以利用它进行添加元素到容器的逻辑 现在就运行一下看看自定义渲染器的效果吧 image.png 可以看到成功渲染!