前言
Vue 项目是从挂载app
开始。通常main.js
文件,
createApp(App).mount("#app");
这里是使用了createApp
方法接受一个组件App
,然后基于这个方法返回的一个方法mount
将这个应用挂载到id
为app
的div
上。
App
可以是一个.vue
文件,也可以是一个渲染函数。我们以渲染函数的方式初始化component
主流程,因为.vue
文件最终也是通过编译生成渲染函数的。
实践
使用渲染函数生成一个组件进行挂载。
main.js
import { createApp } from "vue";
import { App } from "./App.js";
createApp(App).mount("#app");
App.js
import { h } from "vue";
export const App = {
render() {
return h("div", "Hello, " + this.msg);
},
setup() {
return {
msg: "World",
};
},
};
最终页面上呈现Hello World
。
流程思维导图
确定需求
根目录下新建example
文件夹,其中新建helloworld
文件夹,
新建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
新建main.js
import { App } from "./App.js";
createApp(App).mount("#app");
新建App.js
export const App = {
render() {
return h("div", "hello, " + this.msg);
},
setup() {
return {
msg: "world",
};
},
};
最终需要通过我们自己实现的createApp
,h
等方法,让Hello World
出现在页面上。
实现runtime-core
src
下新建文件夹runtime-core
,新建index.ts
作为方法导出出口。接下来按照需求依次实现。
createApp
新建createApp.ts
,
export function createApp(rootComponent) {
return {
mount(rootContainer) {
const vnode = createVNode(rootComponent);
},
};
}
上面代码中,createApp
方法接受一个根组件参数,这个方法返回一个方法mount
,mount
方法接受一个参数,也就是根容器#app
。Vue 中组件都是先转换成虚拟节点vnode,在基于vnode
进行一系列处理后渲染到页面上。
因此我们需要一个createVNode
方法创建虚拟节点。
createVNode
新建vnode.ts
export function createVNode(type, props?, children?) {
const vnode = {
type,
props,
children,
};
return vnode;
}
上面代码createVNode
方法创建了虚拟节点,虚拟节点存在type
,props
,children
三个属性,其中props
和children
不是必须的。这里简单的返回vnode
还没未涉及到其他更多的逻辑,后续作为扩展。
其中h
函数就是对CreateVNode
的二次封装,方便用户调用。新建文件h.ts
import { createVNode } from "./vnode";
export function h(type, props?, children?) {
return createVNode(type, props, children);
}
再回到createApp
中,虚拟节点已经创建完成了,接下来就是渲染render
。
export function createApp(rootComponent) {
return {
mount(rootContainer) {
const vnode = createVNode(rootComponent);
render(vnode, rootContainer);
},
};
}
render
新建renderer.ts
export function render(vnode, container) {
patch(vnode, container);
}
render
函数中主要就是做了patch
,而patch
中要做的就是处理组件。根据思维导图,这里会调用processComponent
方法。
function patch(vnode, container) {
processComponent(vnode, container);
}
processComponent
中会进行组件挂载mountComponent
,
function processComponent(vnode, container) {
mountComponent(vnode, container);
}
再来回顾一下思维导图,mountComponent
做了什么
function mountComponent(vnode, container) {
const instance = createComponentInstance(vnode);
}
首先是创建了组件实例,组件相关逻辑可以单独放在component.ts
中。
component
新建component.ts
export function createComponentInstance(vnode) {
const component = {
vnode,
type: vnode.type,
};
return component;
}
创建完实例,就需要调用setupComponent
方法基于实例instance
做一系列的初始化动作。
function mountComponent(vnode, container) {
const instance = createComponentInstance(vnode);
setupComponent(instance);
}
setupComponent
方法还是和组件逻辑相关的,将这个方法放在component
文件中,代码如下:
export function setupComponent(instance) {
// initProps
// initSlots
setupStatefulComponent(instance);
}
这里暂留位置,后续可以处理对于props
的初始化和slots
的初始化。setupStatefulComponent
就是处理有状态的组件,Vue 中组件其实可分为有状态组件和无状态组件,我们常写的组件就是有状态组件,函数组件为无状态组件。
function setupStatefulComponent(instance) {
const Component = instance.type;
const { setup } = Component;
if (setup) {
const setupResult = setup();
handleSetupResult(instance, setupResult);
}
}
上面代码中,type
就是App.js
中导出的App
配置对象options
,因为在createVNode
中传入的type
参数就是rootComponent
,也就是App
。既然是options
配置对象,就是获取其中setup
,然后就是执行setup
获取结果,这结果进行处理,准备后续render
中需要渲染setup
中返回的数据,例如msg
。
function handleSetupResult(instance, setupResult) {
if (typeof setupResult === "object") {
instance.setupResult = setupResult;
}
finishComponentSetup(instance);
}
上面代码,setupResult
可能是object
类型也可能是function
类型,针对我们需求中示例代码,暂时只考虑object
类型。将setupResult
挂载到实例上,调用finishComponentSetup
确保render
存在可以进行渲染。
function finishComponentSetup(instance) {
const Component = instance.type;
if (Component.render) {
instance.render = Component.render;
}
}
上面代码,当render
存在时也将其挂载到实例上。
setupComponent
组件相关逻辑结束了,再次回到renderer.ts
,开始执行render
。
render
还是在mountComponent
方法中,
function mountComponent(vnode, container) {
const instance = createComponentInstance(vnode);
setupComponent(instance);
setupRenderEffect(instance, container);
}
setupComponent
调用之后,实例instance
上已经挂载了setupResult
和render
。
function setupRenderEffect(instance, container) {
const subTree = instance.render();
patch(subTree, container);
}
上面代码,render
返回值h("div", "hello, " + this.msg)
,h
函数是createVNode
的封装,其实返回的就是虚拟节点。这里就会涉及到element
的相关逻辑,将虚拟节点转换成真实的element
并挂载,实现的地方还是在patch
方法中。
element
那就需要来完善一下patch
方法,根据传入的type
数据类型区分,如果是一个对象还是沿用之前processComponent
的逻辑,如果是字符串,例如div
,就需要走element
逻辑。
function patch(vnode, container) {
if (typeof vnode.type === "string") {
processElement(vnode, container);
} else if (isObject(vnode.type)) {
processComponent(vnode, container);
}
}
processElement
中进行初始化工作,
function processElement(vnode, container) {
mountElement(vnode, container);
}
初始化element
传入虚拟节点vnode
,要挂载的容器container
。这里为了丰富一下我们的判断逻辑,修改需求中App.js
中render
,添加props
并将children
改成数组。
export const App = {
render() {
return h(
"div",
{
id: "root",
class: ["main", "content"],
},
[
h("p", { class: "red" }, "hello"),
h( "p", { class: "blue" }, "world"),
]
);
},
setup() {
return {
msg: "world",
};
},
};
这样需求就变成了,期待页面中最终呈现出两个p
标签的文本,分别是hello
和world
,它们被一个div
包裹,这个div
又是挂载在根节点app
下的。
function mountElement(vnode, container) {
const { type, children } = vnode;
let el = document.createElement(type);
if (typeof children === "string") {
el.textContent = children;
} else if (Array.isArray(children)) {
children.forEach((v) => {
patch(v, el);
});
}
const { props } = vnode;
for (const key in props) {
const value = props[key];
el.setAttribute(key, value);
}
container.append(el);
}
上面代码,初始化element
。vnode
中解构出type
,针对于上面的需求例子中也就是div
;判断children
是数组类型时,循环每一项再调用patch
,因为每一项又是h
函数调用返回的虚拟节点,需要走patch
逻辑再次判断是component
还是element
,此时子节点挂载的容器就是当前的父节点el
;props
属性的设置就是通过setAttribute
;最终整个div
是挂载在传入的container
参数,也就是rootContainer
。
打包测试
在需求示例中,打开index.html
在浏览器中验证。但是createApp
和h
函数还没有导入,此时应该是没有效果的。
那直接从runtime-core
中导入也是无效的,我们需要借助rollup
打包工具,将需要的方法暴露接口,打包成一个单独的js
文件,进行引用。
导出方法
在runtime-core
下index.ts
中,导出createApp
和h
方法,
export { createApp } from "./createApp";
export { h } from "./h";
在src
下index.ts
,作为最外层导出口,
export * from "./runtime-core/index";
安装rollup
终端执行安装命令,
# setup rollup
yarn add rollup --dev
# setup @rollup/plugin-typescript
yarn add @rollup/plugin-typescript --dev
# setup tslib
yarn add tslib --dev
根目录下新建rollup
配置文件rollup.config.js
,
import typescript from "@rollup/plugin-typescript";
export default {
input: "./src/index.ts",
output: [
{
format: "cjs",
file: "lib/zwd-mini-vue.cjs.js",
},
{
format: "es",
file: "lib/zwd-mini-vue.esm.js",
},
],
plugins: [typescript()],
};
input
指定输入文件地址,也就是需要打包的文件入口;output
指定输出的打包文件路径,这里分为两种文件格式输出,commonjs
和esm
;同时需要安装ts
的插件,因为rollup
并不认识ts
。
同时需要修改tsconfig.json
中"module": "ESNext"
。
引入文件
package.json
中添加打包命令,
"build": "rollup -c rollup.config.js"
执行打包,yarn build
。打包成功完成就会在根目录下看到lib
文件夹。
然后在需求示例中代码修改如下,
index.html
添加类名,
<style>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
main.js
import { createApp } from "../../lib/zwd-mini-vue.esm.js";
import { App } from "./App.js";
const rootContainer = document.querySelector("#app");
createApp(App).mount(rootContainer);
App.js
import { h } from "../../lib/zwd-mini-vue.esm.js";
export const App = {
render() {
return h(
"div",
{
id: "root",
class: ["main", "content"],
},
[
h("p", { class: "red" }, "hello"),
h( "p", { class: "blue" }, "world"),
]
);
},
setup() {
return {
msg: "world",
};
},
};
验证
根目录下执行http-server
本地启动一个服务,如果没有这个命令的话,需要全局安装一下。
npm install http-server -g
在浏览器中可以查看到页面,地址 http://127.0.0.1:8080/example/helloworld/
总结
初始化的主流程的方法名全部是按照 Vue3 源码相同命名创建的。可以再次回顾一下流程思维导图,一共是分为两部分展开,component
和element
。
首先是component
,所有组件都会先转变成虚拟节点vnode
,通过vnode
进行一系列操作再渲染,组件挂载部分会先创建实例,将APP
的options
中setup
执行结果和render
函数都挂载到组件实例上。
然后就是patch
中element
的处理,当获取到虚拟节点中type
属性是字符串表示此时处理的是最小单元的标签,挂载element
就是创建标签,丰富它的属性和子节点。
最终通过rollup
打包,生成我们可以运行的js
文件。