实现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
中的props
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.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