likes
comments
collection
share

实现mini-vue -- runtime-core模块(一)组件的初始化逻辑

作者站长头像
站长
· 阅读数 30

从今天开始我们进入到mini-vue的另一个模块 -- runtime-core,这是vue的核心模块,组件相关的逻辑都是在这个模块中处理的,以及vue的渲染器等功能也是在这里实现的,本篇文章是该模块的第一篇,将会讲解一下如何实现组件的初始化逻辑

首先来看一下基本的Hello World案例吧!

1. Hello World案例搭建

首先搭建一个HelloWorld案例用于测试基本的runtime-core运行流程是否能够顺利执行

在项目根目录下新建examples/helloworld,并创建入口html文件

<!-- examples/helloworld/index.html -->
<!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>Hello World</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./main.js"></script>
  </body>
</html>

然后创建一个main.js作为案例的入口文件

// examples/helloworld/main.js
createApp(App).mount('#app');

再创建一个App.js,它类似于vue中的App.vue,只不过我们目前还没有实现模板编译的功能,因此用的是纯js文件,vue的模板最终其实会被编译成render函数去生成vnode,所以暂时先直接用render函数来代替

// examples/helloworld/App.js
export const App = {
  // 由于还没有实现模板编译的功能 因此先用 render 函数来替代
  render() {
    return h('div', {
      class: ['cyan', 'success']
    }, 'hi' + this.msg);
  },
  setup() {
    // Composition API
    return {
      msg: 'plasticine-mini-vue',
    };
  },
};

当然,这些函数目前都还没有实现,我们接下来的任务就是要去实现它们,实现完后用rollup打包,然后让这个案例能够正常跑起来


2. 组件初始化逻辑

首先要了解整个组件初始化的逻辑,下面是我根据vue3源码总结出来的部分主要逻辑 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 下面就根据这部分流程图实现一下大概的组件初始化流程


2.1 createApp

创建src/runtime-core/createApp.ts

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

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

createApp主要做的事情就是返回一个App对象,这个对象里面有很多方法,目前我们只用关注mount方法,它能够接收一个dom容器结点,比如<div id="app"></div>,可以以querySelector的方式传入css选择器字符串给mount方法,然后它就能够帮助我们把App根组件挂载到里面

挂载主要是通过render函数实现,它会接受一个vnode以及要挂载的目标dom容器结点,为此还要实现一个createVNode函数来创建vnode


2.2 createVNode

创建src/runtime-core/vnode.ts,在里面实现vnode相关的功能,首先我们要实现创建vnode的功能,根据vue3源码,vnode包括以下核心属性

  1. typevnode的类型,vue3中有多种vnode类型,目前我们只关注ComponentElement类型,Component就是我们平时写单文件组件时导出的内容就是一个组件,Element则是指像div这样的原生dom标签结点类型,这样说可能不准确,但是目前才刚开始,我们对它们有个大概认识就行
  2. props,就是vue3中的props
  3. children,子节点
/**
 * @description 创建虚拟 DOM 结点
 * @param type 组件导出的对象
 * @param props 组件的 props
 * @param children 子组件
 */
export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
  };

  return vnode;
}

2.3 render

创建src/runtime-core/renderer.tsrender函数用于将vnode渲染成真实的dom结点,其内部调用了patch函数去处理,因为vnode有多种类型,而patch则会根据vnode的类型作出不同的处理

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

2.4 patch

目前我们的patch只用关注ComponentElement类型的vnode的处理,首先看处理Component类型

// src/runtime-core/renderer.ts

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

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

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

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

function setupRenderEffect(instance, container) {
  const subTree = instance.render();

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

2.4.1 mountComponent

processComponent中分为mountComponentupdateComponent,前者是在首次渲染时挂载组件实例,而后者则是根据vnode的变化去更新组件实例

目前我们先关注mountComponentupdateComponent留到后面再实现 mountComponent主要做的事情就是根据vnode创建出组件实例,然后根据组件中定义的setup方法进行初始化和渲染

渲染后可能会得到子树vnode,那么此时就需要递归调用patch继续处理,直到没有子节点为止


2.5 createComponentInstance

创建src/runtime-core/component.ts,在这里实现组件相关的功能,首先是最基本的创建一个组件实例,它会根据vnode的内容去创建组件实例

export function createComponentInstance(vnode) {
  const component = {
    vnode,
    type: vnode.type,
  };

  return component;
}

组件实例创建后还需要去执行初始化的逻辑,这时候就可以根据setup方法中的内容进行初始化了

export function setupComponent(instance) {
  // TODO
  // initProps()
  // initSlots()

  setupStatefulComponent(instance);
}

function setupStatefulComponent(instance: any) {
  const Component = instance.type;
  const { setup } = Component;

  if (setup) {
    const setupResult = setup();

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

function handleSetupResult(instance, setupResult: any) {
  // TODO 处理 setupResult 是 function 的情况
  if (typeof setupResult === 'object') {
    instance.setupState = setupResult;
  }

  finishComponentSetup(instance);
}

function finishComponentSetup(instance: any) {
  const Component = instance.type;

  instance.render = Component.render;
}

由于用户不一定会使用Composition API,因此setup的执行是需要先判断的,执行完毕后会得到setupResult该结果可能是**function**也可能是**object**

  • 如果是function,则会作为组件的render函数
  • 如果是object,则会注入到组件的上下文中,也就是handleSetupResult中将setupResult赋值给组件实例的setupState属性这一步

在处理完setupResult后,还要组件的render函数赋给组件实例作为它的render函数(如果有的话)


2.6 封装h函数

h函数可以让用户手动生成vnode,为了和官方api一致,我们就直接将createVNode放到h函数中

import { createVNode } from './vnode';

export function h(type, props?, children?) {
  return createVNode(type, props, children);
}

3. 使用rollup对库进行打包

在开发库的时候,一般会使用rollup进行打包,首先安装rollup

pnpm i rollup -D

由于使用typescript进行开发,所以还要安装相应的插件让rollup处理typescript

pnpm i tslib @rollup/plugin-typescript -D

创建rollup.config.js

import typescript from '@rollup/plugin-typescript';
import pkg from './package.json';

/**
 * @type { import('rollup').RollupOptions }
 */
const config = {
  input: './src/index.ts',
  output: [
    {
      format: 'cjs',
      file: pkg.main,
    },
    {
      format: 'es',
      file: pkg.module,
    },
  ],
  plugins: [typescript()],
};

export default config;

这里配置了两个出口,主要是出于我们的库既要能够在cjs模块化方案中运行,也要在esm模块化方案中能够运行,所以要设置两个出口,并且根据package.json中的配置去配置文件名

然后修改package.json,添加打包脚本

{
  "main": "lib/plasticine-mini-vue.cjs.js",
  "module": "lib/plasticine-mini-vue.esm.js",
  "scripts": {
    "build": "rollup -c ./rollup.config.js"
  },
}

现在运行pnpm build即可完成打包

但是通过live-server运行我们的hello world案例会发现报错,这是因为我们目前只处理了Component类型的渲染逻辑,没有处理真实DOM元素的渲染逻辑,真实DOMvnode中是没有render函数的,这个之后再进行处理,现在只要能够顺利打包就行啦!


4. 真实DOM元素初始化逻辑

4.1 逻辑梳理

目前我们的实现中是无法运行hello world案例的,运行时会出现如下报错: 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 再来回顾以下hello world案例中的渲染函数会渲染什么样的虚拟DOM

export const App = {
  // 由于还没有实现模板编译的功能 因此先用 render 函数来替代
  render() {
    return h('div', {
      class: ['cyan', 'success']
    }, 'hi' + this.msg);
  },
  setup() {
    // Composition API
    return {
      msg: 'plasticine-mini-vue',
    };
  },
};

首先**App**自身是一个Component,然后它内部有一个render函数,我们希望会渲染一个div标签,且标签内的文本内容是hi plasticine-mini-vue

再理一下我们的初始化逻辑,首先会根据根组件创建对应的vnode,然后将创建好的vnode变成真实DOM挂载到#app标签中,由于根组件是Component类型,因此在patch的时候会进入processComponent分支处理,然后通过setupRenderEffect执行根组件的render函数

render函数中返回了一个由h函数创建的vnode,该vnode是一个div标签的普通DOM元素,因此在patch的时候会走processElement分支(暂未实现,正是本节的重点),然后在这个分支里面将真正地创建dom结点,并挂载到父容器中

理清楚了我们目前的任务,就可以开始干活了!


4.2 区分Component和真实DOM

根据最开始画的流程图,相信大家都清楚vnode是要交给patch函数处理的,那么就意味着我们需要在patch函数中区分Component和真实的DOM元素对应的虚拟DOM,这也正是为什么虚拟DOM要存储一个type的属性,就是用于区分类型的

那么如何区分Component和真实DOM呢?我们现在patch函数中打印以下传入的vnode

/**
 * @description 能够处理 component 类型和 dom element 类型
 *
 * component 类型会递归调用 patch 继续处理
 * element 类型则会进行渲染
 */
export function patch(vnode, container) {
+ console.log(vnode);

  // 处理 component 类型
  processComponent(vnode, container);
}

实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 首次打印出来的vnode正是根据根组件创建出来的vnode,它有一个render方法和一个setup方法,render可以被setupRenderEffect函数调用得到子树vnode

而第二次打印出来的vnode则是初次调用根组件的render函数时,通过h函数返回的vnode,由于只是一个普通DOM结点,因此没有render函数,而我们仍然是让它走processComponent,从而又来到了setupRenderEffect中尝试调用它的render函数,自然是不可行的

所以只需要在patch中根据vnode类型走不同的分支去处理即可,区分类型也很简单,从打印的结果中大家也能看出来,Component类型的vnode.type是一个对象,而真实DOMvnode.type是一个string,那很自然会想到如下的处理方式:

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

  if (typeof type === 'string') {
    // 真实 DOM
    processElement(vnode, container);
  } else if (isObject(type)) {
    // 处理 component 类型
    processComponent(vnode, container);
  }
}

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

function mountElement(vnode: any, container: any) {
  const el = document.createElement(vnode.type);
  const { children } = vnode;

  el.textContent = children;

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    el.setAttribute(key, value);
  }

  container.append(el);
}
  1. 如果是普通DOM类型的vnode,会根据它的type作为标签创建真实DOM结点el
  2. 将文本内容设置为children的内容,作为el的内容
  3. 遍历props把所有属性设置到el
  4. 将创建出来的el挂载到container

这个逻辑还是比较简单的,现在我们就可以到渲染结果啦 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 会是undefined是因为我们还没有处理在h函数中的this指向的问题,这个后面再处理,先不用急

你以为现在这样就算完成了吗?其实并没有,目前我们只处理了children是普通的string的情况,直接把它作为textContent插入到渲染的vnode中了,但是如果children也是一个vnode怎么办呢?这个场景是很常见的,不可能说vnode只能有一个文本子节点吧?

为了方便测试,我们修改一下hello world中的渲染函数返回的vnode,将children从普通字符串变为新的vnode

export const App = {
  // 由于还没有实现模板编译的功能 因此先用 render 函数来替代
  render() {
    return h(
      'div',
      {
        class: ['cyan', 'success'],
      },
-     'hi' + this.msg
+     [
+       h('p', { class: 'cyan' }, 'hi '),
+       h('p', { class: 'darkcyan' }, 'plasticine '),
+       h('p', { class: 'darkviolet' }, 'mini-vue!'),
+     ]
    );
  },
  setup() {
    // Composition API
    return {
      msg: 'plasticine-mini-vue',
    };
  },
};

先来看看渲染结果 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 很正常,因为我们还没有对数组类型的children进行处理,如果直接把数组作为文本插入到el中,会调用它的toString()方法,从而导致现在这样的渲染结果

那么我们就处理一下数组类型的children

function mountElement(vnode: any, container: any) {
  const el = document.createElement(vnode.type);
  const { children } = vnode;

- el.textContent = children;
+ if (typeof children === 'string') {
+   el.textContent = children;
+ } else if (Array.isArray(children)) {
+   children.forEach((v) => {
+     patch(v, el);
+   });
+ }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    el.setAttribute(key, value);
  }

  container.append(el);
}

现在再来看一看渲染结果: 实现mini-vue -- runtime-core模块(一)组件的初始化逻辑 总算是正常了,考虑到后续对children的处理可能会变得复杂,因此还可以将children的挂载逻辑抽离出来

function mountElement(vnode: any, container: any) {
  const el = document.createElement(vnode.type);
  const { children } = vnode;

  if (typeof children === 'string') {
    el.textContent = children;
  } else if (Array.isArray(children)) {
-   children.forEach((v) => {
-     patch(v, el);
-   });
+   mountChildren(children, el);
  }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
    el.setAttribute(key, value);
  }

  container.append(el);
}

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