「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」。
(一)阅读必看
技术栈: Typescript + Jest + Rollup
文章中代码非完整代码,为方便理解作适当精简,详细代码可见仓库。
github:github.com/Merlin218/m…
gitee:gitee.com/merlin218/m…
学习参考:github.com/cuixiaorui/… 崔大 mini-vue
回顾上期,我们完成了vue响应式的相关实现,这一期我们来完成vue在运行时的核心部分,一步步构建一个vue应用。
往期链接
【手写vue3系列】响应式实现:juejin.cn/post/702861…
(二)原料/工具准备
(1)原料
Index.html:Vue应用的容器,我们一般将Vue应用挂载在“#app”的结点上。
<!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>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./main.js" type="module"></script>
</body>
</html>
main.js:入口文件,以module的形式嵌入index.html中
import { App } from "./App.js";
import { createApp } from "../../lib/mini-vue.esm.js";
const app = document.querySelector("#app");
createApp(App).mount(app);
App.js:根结点组件,创建一个Vue应用
import { h } from "../../lib/mini-vue.esm.js";
export const App = {
render() {
return h("div", {}, "hi! " + this.msg);
},
setup() {
return {
msg: "mini-vue",
};
},
};
(2)工具
在编写vue3时,我们使用typescript作为编码语言,它提供了良好的类型检查和代码提示等优点,使用rollup作为打包工具。
npm i typescript rollup @rollup/plugin-typescript tslib -D
(3)配置
//package.json
{
//...
"main": "/lib/mini-vue.cjs.js",
"module": "/lib/mini-vue.esm.js",
"scripts": {
"build": "rollup -c rollup.config.js --watch"
},
//...
}
//rollup.config.js
import typescript from "@rollup/plugin-typescript";
export default {
input: "./src/index.ts", //入口
output: [ //出口
{
format: "cjs", //CommonJs规范
file: "lib/mini-vue.cjs.js",
},
{
format: "es", //ES Module规范
file: "lib/mini-vue.esm.js",
},
],
plugins: [typescript()], //插件
};
//tsconfig.json
{
"compilerOptions":{
"module":"esnext"
}
}
三)原理剖析
(1)流程图
(2)流程讲解
首先,我们可以看到main.js中,导入了一个createApp的函数,我们可以从这里入手。
//createApp.ts
import { render } from "./renderer";
import { createVNode } from "./vnode";
export function createApp(rootComponent) {
//接收一个根组件对象,返回一个对象,包含mount方法
return {
mount(rootContainer) {
//先把component转化成vnode(虚拟结点)
//后续所有的逻辑操作,都会基于vnode做处理
const vnode = createVNode(rootComponent);
//转成虚拟结点之后,对其进行渲染
render(vnode, rootContainer);
},
};
}
顺着逻辑,我们创建一个对根组件创建根节点vnode
对于vnode,可能存在两种情况:
-
一种是一个组件
component
- 如根组件
-
一种是一个具体的元素标签
element
- 如 App.js 的 render函数 中 h函数 接受的
div
- 如 App.js 的 render函数 中 h函数 接受的
//vnode.ts
export function createVNode(type, props?, children?) {
const vnode = {
type, // component或element
props,
children,
};
return vnode;
}
h函数实际上也还是返回一个vnode,只不过这个vnode的类型是element
//h.ts
import { createVNode } from "./vnode";
export function h(type, props?: Object, children?: String | Array<Object>) {
return createVNode(type, props, children);
}
创建完结点,我们对其进行渲染。
//renderer.ts
export function render(vnode, container) {
patch(vnode, container);
}
//处理虚拟结点vnode
function patch(vnode, container) {
//判断类型
if (typeof vnode.type === "string") {
processElement(vnode, container);
} else if (isObject(vnode.type)) {
processComponent(vnode, container);
}
}
对于element类型结点,相对比较简单。
//renderer.ts
//处理element
function processElement(vnode: any, container) {
//挂载element
mountElement(vnode, container);
}
function mountElement(vnode: any, container: any) {
//将 DOM实例 绑定到vnode上,我们可以在后续的业务中直接访问DOM实例
const el = (vnode.el = document.createElement(vnode.type));
const { props, children } = vnode;
//判断是否包含子结点,如果包含,也进行patch操作
//此处其实可以发现是一个递归的过程
if (typeof vnode.children === "string") {
el.textContent = children;
} else if (Array.isArray(children)) {
mountChildren(vnode, el);
}
//对该结点的属性进行设置
for (const key in props) {
const value = props[key];
el.setAttribute(key, value);
}
//添加到容器中
container.append(el);
}
//挂载孩子结点
function mountChildren(vnode: any, container: any) {
vnode.children.forEach((v) => {
patch(v, container);
});
}
对于component的结点:
//renderer.ts
//处理component
function processComponent(vnode: any, container: any) {
//挂载组件
mountComponent(vnode, container);
}
function mountComponent(initialVNode: any, container) {
//创建一个组件实例
const instance = createComponentInstance(initialVNode);
//初始化组件实例
setupComponent(instance);
//对组件实例进行初次渲染
setupRenderEffect(instance, initialVNode, container);
}
创建一个组件实🌰 :
//component.ts
export function createComponentInstance(vnode) {
const componentInstance = {
vnode, //虚拟结点
type: vnode.type, //组件类型
render: Function, //render函数
setupState: {}, // 组件状态
proxy: Proxy, // 组件代理对象
};
return componentInstance;
}
初始化组件实🌰 :
//component.ts
export function setupComponent(instance) {
//TODO
//initProps()
//initSlots()
//处理setup返回值,初始化一个有状态的component
setupStatefulComponent(instance);
}
function setupStatefulComponent(instance: any) {
const Component = instance.type;
//我们定义一个代理对象,为后续通过this.xxx访问数据提供基础
instance.proxy = new Proxy(
{_:instance},
PublicInstanceProxyHandlers
);
const { setup } = Component;
if (setup) {
//setup()返回可能是function或object
//如果是function,我们认为是组件的render函数
//如果是object,则将返回的内容注入到上下文中
const setupResult = setup();
// 处理setup返回的结果
handleSetupResult(instance, setupResult);
}
}
//componentPublicInstance.ts
const PublicPropertiesMap = {
$el: (i) => i.vnode.el,
//...
};
export const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
const { setupState } = instance;
//setupState
if (key in setupState) {
return setupState[key];
}
const publicGetter = PublicPropertiesMap[key];
if (publicGetter) {
return publicGetter(instance);
}
},
};
处理setup返回的结果:
//component.ts
function handleSetupResult(instance, setupResult: any) {
//function
//TODO function
if (typeof setupResult === "object") {
instance.setupState = setupResult;
}
finishComponentSetup(instance);
}
//完成组件的初始化
function finishComponentSetup(instance: any) {
const Component = instance.type;
if (Component.render) {
instance.render = Component.render;
}
}
完成初始化后,对组件进行初次渲染:
//renderer.ts
function setupRenderEffect(instance: any, initialVNode, container) {
//我们取出实例中的proxy,将render函数中的this指向proxy
//那么在后续使用this.xxx获取值中,会调用proxy的getter方法
//因为我们在初始化组件时,已经对proxy的getter进行了定义
//从而实现使用this.xxx来方便地获取我们需要的值
const { proxy } = instance;
const subTree = instance.render.call(proxy);
//递归调用
patch(subTree, container);
//在subTree渲染完成后,绑定$el根节点
initialVNode.el = subTree.el;
}
到此,我们实现了一个基本的组件初始化渲染的过程,接下来我们可以新建一个index.ts作为打包入口文件,将用户需要的创建函数导出。
//index.ts
export { createApp } from "./createApp";
export { h } from "./h";
运行 yarn build
进行打包,我们得到了 mini-vue.esm.js
文件。
将文件引入main.js
和App.js
,浏览器运行index.html
文件。
相信就可以看到我们的 hi! mini-vue
了!
持续更新中~欢迎关注我的掘金和github,觉得不错的话,记得给我的项目🌟 一下哦~