跟着文章读一篇,人人都能了解Vue3 系列(一)(从0构建pnpm项目, 跑通内部渲染流程)
前段时间把Vue3源码学习了一遍,从reactivity到complier,大致过了一遍,对Vue3这个库底层逻辑有了更清晰的了解。我打算写个系列的文章,来通俗易懂的阐述源码逻辑。我并不会直接一上来就贴一大段一大段的源码。我想从比较简单的渲染逻辑开始,这一块源码逻辑比较好理解,学起来也比较容易建立信心。
完整代码地址:Simple-Vue3
构建pnpm项目
新建一个项目simple-mini-vue3
mkdir simple-mini-vue3
这里推荐直接用pnpm
。
cd simple-mini-vue3
pnpm init
新建pnpm-workspace.yaml
文件,编写配置。
packages:
- "packages/*"
因为所有的源码都在packages文件夹中,你可以理解为一个工作空间,你想用项目中其他独立的包,就可以根据workspace协议来链接。比如在源码中就是这么用的:
"dependencies": {
"@vue/runtime-core": "workspace:^",
"@vue/shared": "workspace:^1.0.0"
}
新建.npmrc
文件,编写配置:
shamefully-hoist = true
shamefully-hoist
主要作用就是将依赖包提升到根node_modules
目录下,避免幽灵依赖。
关于上述配置,可以参考pnpm官方,有更加详细的讲解。这不是本文的重点。
在根目录下新建packages
文件夹,新建runtime-core
文件夹。cd
到 runtime-core
文件夹中,执行npm init
生成package.json。
编写配置:
{
"name": "@vue/runtime-core",
"version": "1.0.0",
"module": "dist/runtime-core.esm-bundler.js",
"buildOptions": {
"name": "VueRuntimeCore",
"formats": [
"esm-bundler"
]
}
}
关于上述package.json文件的配置,待会会有挨个的解释,先按下不表。
至此,一个简单的pnpm项目搭建完成。我们的目的是让项目运行起来。最好是直接把编写的 js/ts
源码打包一份,在html中引入它们,能够直接在浏览器中运行,实时查看我们完成的效果。
我们引入esbuild
来做开发打包:
pnpm i -w esbuild -D
在项目根目录下,新建scripts
文件夹,这个不属于项目源码,隶属于开发/发布新版本的辅助脚本文件,因此不能放在packages
文件夹中。 然后在其scripts
文件夹中,新建dev.js,这里编写项目打包脚本。
回到根目录的package.json
中,定义开发运行脚本:
"scripts": {
"dev:runtime-core": "node scripts/dev.js runtime-core -f esm-bundler"
},
该命令就是执行 scripts/dev.js 脚本文件,后面在跟其他参数,多个参数空格分割。
编写dev.js 打包脚本:
我们打算直接用浏览器ESM原生支持的功能,也就是 <script type="module">
。 所以在编写打包脚本时,是不能用cjs那一套。
import esbuild from 'esbuild';
import minimist from 'minimist';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
const args = minimist(process.argv.slice(2));
const target = args._[0];
const format = args.f;
const pkg = require(`../packages/${target}/package.json`);
const outputFormat = 'esm';
// packages/runtime-core/dist/reactivity.esm-bundler.js
const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);
esbuild
.context({
// 打包入口
entryPoints: [resolve(__dirname, `../packages/${target}/src/index.js`)],
// 输出文件
outfile,
bundle: true,
sourcemap: true,
format: outputFormat,
globalName: pkg.buildOptions.name,
// 平台指定为 浏览器
platform: 'browser',
target: 'es2016'
})
.then(ctx => ctx.watch());
引入minimist
,来做node脚本命令行参数解析。pnpm i -w minimist -D
。target
变量就是我们在执行脚本中定义的runtime-core包名称,format 指定了 'esm' 打包方式。
打包后的输出路径:packages/runtime-core/dist/runtime-core.esm-bundler.js
我们来试下效果:
新建src,新建index.js
编写以下代码:
export const render = (vnode, container) => {
console.log(vnode, container);
};
cd到项目根目录,执行打包命令
pnpm run dev:runtime-core
回到packages/runtime-core文件夹中,已经生成了打包好的dist目录。
并且生成好了map文件。
接着,我们在dist
目录下新建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">
import { render } from './runtime-core.esm-bundler.js';
render(
{
key: 1
},
app
);
</script>
</body>
</html>
通过浏览器原生支持的ESM功能,导入打包后的文件,浏览器打开index.html
文件运行测试下:
可以看到结果已经成功了。
还记得上面我们在runtime-core
包中package.json文件配置的各项字段不。结合打包脚本,就明白了:
name表示包名称 module字段,是因为我们打算用原生ESM功能,所以配置的是打包后的文件路径 buildOptions表示的相关的打包配置,包括指定name、打包格式等等
到这里,整个项目大致的结构、打包、预览效果已经跑通。接下来就是真正的进入源码世界。
h函数
在Vue
中,虚拟节点(VNode)都是通过h
函数生成的。VNode记录了相关节点元素的关键信息:
- type: 元素类型,是原生元素,还是自定义组件
- props:元素的属性
- children:子组件
- el:关联的真实元素节点
- anchor:在文档中的位置信息
- 等等其他属性
在实现h
函数之前,我们先考虑下,页面渲染会有几种情况:
- 纯文本渲染(静态文本)
- 带有属性的元素(样式、 class、 自定义属性)
- 有自己的子组件元素
- 自定义组件渲染
对应的以上情况,规定h
函数对应的写法如下:
1:纯文本渲染(静态文本)
h('div')
h('div', '显示的内容')
- 带有属性的元素(样式、 class、 自定义属性)
// 第二个参数一定是属性
h('div', {}, '显示的内容')
h('div', {}, h('span'))
h('div', {}, [''])
h('div', {}, [h('span')])
- 有自己的子组件元素
h('div', [])
h('div', 'text')
h('div', h('span'))
h('div', {}, ['']) // 数组文本children
h('div', {}, [h('span')]) // 数组 VNode children
- 自定义组件渲染
h(Components, () => {}) // 默认插槽
h(Component, {}, () => {}) // 默认插槽
h(Component, {}, {}) // 具名插槽
总结下:
h
函数至少得有一个参数得表明要渲染的节点,比如原生节点div
、span
这种,还是自定义的Components
组件。- 当有2个参数时,第二个参数有可能是属性,也有可能是子节点,子节点有可能是纯文本,也有可能是VNode节点
- 当有三个参数时,那就是最标准的入参,有要渲染的节点类型type,属性props和子节点
- 当大于三个参数时,只会取最后两个参数当作子节点渲染
为了方便理解,我们就从最简单的开始:h('div')
在runtime-core
包中src
文件下新建h.js
文件
// h.js
export function h(type, propsOrChildren, children, ..._) {
return createVNode(type, propsOrChildren, children);
}
不要忘记在index.js
入口文件导出所有函数
// index.js
export * from './h';
接下来实现createVNode
逻辑
createVNode函数
新建vnode.js
:
// vnode.js
export function createVNode(type, props, children) {
const vnode = {
type,
props,
children,
el: null // 真实节点 初始化为null
};
return vnode;
}
逻辑很简单,生成一个vnode对象,vnode保存了一些关键信息:type、props、children、el
现在VNode已经生成好了,Vue
中渲染VNode节点信息,都是通过render
函数来做的。
实现render
函数:
render函数
新建renderer.js
,
// renderer.js
const patchElement = (n1, n2) => {
// diff 流程
};
const mountElement = (vnode, container) => {
const { type } = vnode;
// 创建真实节点
const el = (vnode.el = document.createElement(type));
container.insertBefore(el, null);
};
const processElement = (n1, n2, container) => {
if (n1 === null) {
// 第一次挂载、创建
mountElement(n2, container);
} else {
// diff 更新
patchElement(n1, n2);
}
};
const patch = (n1, n2, container) => {
processElement(n1, n2, container);
};
export const render = (vnode, container) => {
patch(null, vnode, container);
};
patch
函数接受三个参数,n1, n2, container,其中n1表示旧虚拟节点,n2表示新的虚拟节点,container表示父元素,在这里就是根元素app。
patch
函数另外一个作用就是判断虚拟节点的type类型,有可能是div
这种原生元素,也有可能是自定义组件、也有后续实现的TELEPORT
组件。
伪代码:
const patch = (n1, n2, container) => {
const { type } = n2
switch (type) {
case '原生元素':
processElement(n1, n2, container);
break;
case '自定义组件':
break;
case 'TELEPORT':
break
default:
break;
}
processElement(n1, n2, container);
};
processElement
函数是专门用来处理原生元素的函数,这里主要处理第一次元素的挂载和diff、更新阶段。
mountElement
函数主要处理元素的创建和渲染,以及后续的props、children处理。
对于原生api createElement
、 insertBefore
等方法,Vue
单独抽离出一个库,叫runtime-dom
。接下来我们也抽离出来。
runtime-dom
packages
文件夹下新建runtime-dom
文件夹。 其结构如下
// nodeOps.js
// 增删改查
// 增删改查
export const nodeOps = {
// 增加、插入
insert(child, parent, anchor) {
// 插入子节点,也用作新增节点
parent.insertBefore(child, anchor || null);
},
// 删除
remove(child) {
const parent = child.parentNode;
if (parent) {
// 移除子节点
parent.removeChild(child);
}
},
createElement(tagName) {
// 创建普通元素
return document.createElement(tagName);
},
createText(text) {
// 创建文本节点
return document.createTextNode(text);
},
createComment(data) {
// 创建注释节点
return document.createComment(data);
},
setText(el, text) {
// 设置节点value
el.nodeValue = text;
},
setElementText(el, text) {
el.textContent = text;
},
parentNode(node) {
// 返回父节点
return node.parentNode;
},
nextSibling(node) {
// 返回兄弟节点
return node.nextSibling;
},
quertSelector(selector) {
// 查询元素
return document.querySelector(selector);
}
};
nodeOps定义了所能用到的原生dom操作api。后续操作dom,都是调用这里的方法。
index.js文件导出所有方法。
import { nodeOps } from './nodeOps';
export { nodeOps };
每当我们新增一个包时,我们需要对根目录的package.json打包命令新增一个:如下:
"scripts": {
"dev:shared": "node scripts/dev.js shared -f esm-bundler",
"dev:runtime-core": "node scripts/dev.js runtime-core -f esm-bundler",
"dev:runtime-dom": "node scripts/dev.js runtime-dom -f esm-bundler"
},
在引入runtime-dom
之前,执行以下pnpm run dev:runtime-dom
打包命令。
接着runtime-core
包引入runtime-dom
,执行命令
pnpm add @vue/runtime-dom --workspace
--workspace
是指只从当前的项目的workspace引入。
回到renderer.js
,我们重构下mountElement
方法:
从@vue/runtime-dom
库中导入nodeOps
import { nodeOps } from '@vue/runtime-dom';
const mountElement = (vnode, container) => {
const { type } = vnode;
// 创建真实节点
const el = (vnode.el = nodeOps.createElement(type));
nodeOps.insert(el, container, null);
};
到此,我们已经完成了从h 函数 - > 创建vnode -> 根据vnode渲染真实节点整体流程。
验证下结果
执行下打包命令:
pnpm run dev:runtime-core
生成dist目录。新建测试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">
import { render, h } from './runtime-core.esm-bundler.js';
render(h('div'), app);
</script>
</body>
</html>
通过VSCode 插件 Live Server打开index.html。我们可以看到div能够正常渲染到页面上了。
完美。
接下来,我们让他显示一些内容:
通过上面介绍的h函数我们知道,显示内容h函数可以这么写,h('div', '显示的内容')
其实就是设置下children,子元素,文本内容也可以看成子元素children。
第二个参数就是文本内容,也就是子元素。
完善下h函数
export function h(type, propsOrChildren, children, ..._) {
const l = arguments.length;
if (l === 2) {
return createVNode(type, null, propsOrChildren);
}
return createVNode(type, propsOrChildren, children);
}
判断下参数length,如果第二个是文本,那就意味着没有属性,把propsOrChildren参数丢给createVNode函数的children参数。
mountElement函数也需要优化下:
const mountElement = (vnode, container) => {
...
// 处理文本子节点
nodeOps.setElementText(el, children);
...
};
此时,我们可以看到页面上已经显示出想要的内容了:
接下来我们可以给本文整点样式,class、style。 还记得我们说的h函数实现么,第二个参数可以是props:
h('div', { style: { color: 'blue' } }, '显示的内容')
回到runtime-dom
,新建patchProp.js
import { patchClass } from './modules/class';
import { patchStyle } from './modules/style';
/**
*
* @param {*} el 真实节点信息
* @param {*} key props对应的key
* @param {*} preValue 上一个值
* @param {*} nextValue 新值
*
* preValue 和 nextValue主要用于diff阶段的判断
*/
export const patchProp = (el, key, preValue, nextValue) => {
if (key === 'class') {
patchClass(el, nextValue);
} else if (key === 'style') {
patchStyle(el, preValue, nextValue);
}
};
实现patchClass
patchStyle
方法:
// patchClass.js
export function patchClass(el, value) {
if (value === undefined || value === null) {
// 直接覆盖
el.className = value;
} else {
// 删除class属性
el.removeAttribute('class');
}
}
// patchStyle.js
export function patchStyle(el, prev, next) {
const style = el.style;
const isCssString = typeof next === 'string';
const isPrevCssString = typeof prev === 'string';
// eg. <div :style={}></div> style是个对象
// 存在新值,并且是对象
if (next && !isCssString) {
if (prev && !isPrevCssString) {
// 说明老值 和新值都是对象
for (const key in prev) {
// 老值 在新的值里面没有,删除老值
if (next[key] == null) {
style[key] = '';
}
}
}
// 设置新的值
for (const key in next) {
style[key] = next[key];
}
} else {
// eg. <div style=""></div>
// 新值是string
if (isCssString) {
// 比较下是否相等
if (prev != next) {
style.cssText = next;
}
} else if (prev) {
// 新值是null,而且存在老值,删掉老值
el.removeAttribute('style');
}
}
}
逻辑还是比较简单,class属性直接赋值、删除就行,style处理稍微多一点,要考虑到新老值之间的变化,老值在新值中不存在就删除,(不能完全删除,因为新值有可能只替换了老值的部分属性) ,然后设置新值。如果是字符串style,直接比较,不同直接替换就行,最后,如果新值是null,直接removeAttribute('style')就可以了。
重构下导出文件:将nodeOps和patchProp合并下。
import { patchProp } from './patchProp';
const rendererOptions = Object.assign(nodeOps, { patchProp });
export { rendererOptions };
回到mountElement方法处理下props:
const mountElement = (vnode, container) => {
...
if (props) {
for (const key in props) {
nodeOps.patchProp(el, key, null, props[key]);
}
}
...
};
看下效果:
完美。
总结下:
- 从0搭建pnpm项目
- 从最简单的需求,实现h函数 -> createVNode -> 元素的渲染 -> 元素属性的处理
后续还有元素的事件处理、实现内部Text组件、Common组件、自定义组件渲染、diff流程等等。还有很多内容要做。如果能帮到你,欢迎关注。如果哪里写的不对的地方,评论区我们可以友好的交流、讨论。
转载自:https://juejin.cn/post/7355679990560325682