实现mini-vue -- runtime-core模块(一)组件的初始化逻辑
从今天开始我们进入到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源码总结出来的部分主要逻辑

下面就根据这部分流程图实现一下大概的组件初始化流程
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包括以下核心属性
type,vnode的类型,vue3中有多种vnode类型,目前我们只关注Component和Element类型,Component就是我们平时写单文件组件时导出的内容就是一个组件,Element则是指像div这样的原生dom标签结点类型,这样说可能不准确,但是目前才刚开始,我们对它们有个大概认识就行props,就是vue3中的propschildren,子节点
/**
* @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.ts,render函数用于将vnode渲染成真实的dom结点,其内部调用了patch函数去处理,因为vnode有多种类型,而patch则会根据vnode的类型作出不同的处理
export function render(vnode: any, container: any) {
// 调用 patch
patch(vnode, container);
}
2.4 patch
目前我们的patch只用关注Component和Element类型的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中分为mountComponent和updateComponent,前者是在首次渲染时挂载组件实例,而后者则是根据vnode的变化去更新组件实例
目前我们先关注mountComponent,updateComponent留到后面再实现
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元素的渲染逻辑,真实DOM的vnode中是没有render函数的,这个之后再进行处理,现在只要能够顺利打包就行啦!
4. 真实DOM元素初始化逻辑
4.1 逻辑梳理
目前我们的实现中是无法运行hello world案例的,运行时会出现如下报错:
再来回顾以下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);
}
首次打印出来的vnode正是根据根组件创建出来的vnode,它有一个render方法和一个setup方法,render可以被setupRenderEffect函数调用得到子树vnode
而第二次打印出来的vnode则是初次调用根组件的render函数时,通过h函数返回的vnode,由于只是一个普通DOM结点,因此没有render函数,而我们仍然是让它走processComponent,从而又来到了setupRenderEffect中尝试调用它的render函数,自然是不可行的
所以只需要在patch中根据vnode类型走不同的分支去处理即可,区分类型也很简单,从打印的结果中大家也能看出来,Component类型的vnode.type是一个对象,而真实DOM的vnode.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);
}
- 如果是普通
DOM类型的vnode,会根据它的type作为标签创建真实DOM结点el - 将文本内容设置为
children的内容,作为el的内容 - 遍历
props把所有属性设置到el上 - 将创建出来的
el挂载到container上
这个逻辑还是比较简单的,现在我们就可以到渲染结果啦
会是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',
};
},
};
先来看看渲染结果
很正常,因为我们还没有对数组类型的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);
}
现在再来看一看渲染结果:
总算是正常了,考虑到后续对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);
+ });
+ }
转载自:https://juejin.cn/post/7103919397772722183