likes
comments
collection
share

跟着文章读一篇,人人都能了解Vue3 系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

作者站长头像
站长
· 阅读数 24

前段时间把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 文件夹。cdruntime-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 -Dtarget变量就是我们在执行脚本中定义的runtime-core包名称,format 指定了 'esm' 打包方式。 打包后的输出路径:packages/runtime-core/dist/runtime-core.esm-bundler.js

我们来试下效果:

新建src,新建index.js

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程) 编写以下代码:

export const render = (vnode, container) => {
	console.log(vnode, container);
};

cd到项目根目录,执行打包命令

pnpm run dev:runtime-core

回到packages/runtime-core文件夹中,已经生成了打包好的dist目录。

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

并且生成好了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文件运行测试下:

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

可以看到结果已经成功了。

还记得上面我们在runtime-core包中package.json文件配置的各项字段不。结合打包脚本,就明白了:

name表示包名称 module字段,是因为我们打算用原生ESM功能,所以配置的是打包后的文件路径 buildOptions表示的相关的打包配置,包括指定name、打包格式等等

到这里,整个项目大致的结构、打包、预览效果已经跑通。接下来就是真正的进入源码世界。

h函数


Vue中,虚拟节点(VNode)都是通过h函数生成的。VNode记录了相关节点元素的关键信息:

  • type: 元素类型,是原生元素,还是自定义组件
  • props:元素的属性
  • children:子组件
  • el:关联的真实元素节点
  • anchor:在文档中的位置信息
  • 等等其他属性

在实现h函数之前,我们先考虑下,页面渲染会有几种情况:

  1. 纯文本渲染(静态文本)
  2. 带有属性的元素(样式、 class、 自定义属性)
  3. 有自己的子组件元素
  4. 自定义组件渲染

对应的以上情况,规定h函数对应的写法如下:

1:纯文本渲染(静态文本)

h('div')
h('div', '显示的内容')
  1. 带有属性的元素(样式、 class、 自定义属性)
// 第二个参数一定是属性
h('div', {}, '显示的内容')
h('div', {}, h('span'))
h('div', {}, [''])  
h('div', {}, [h('span')])
  1. 有自己的子组件元素
h('div', [])
h('div', 'text')
h('div', h('span'))
h('div', {}, [''])        // 数组文本children
h('div', {}, [h('span')])  // 数组 VNode children
  1. 自定义组件渲染
h(Components, () => {})  // 默认插槽
h(Component, {}, () => {}) // 默认插槽
h(Component, {}, {}) // 具名插槽

总结下:

  • h函数至少得有一个参数得表明要渲染的节点,比如原生节点divspan这种,还是自定义的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 createElementinsertBefore 等方法,Vue单独抽离出一个库,叫runtime-dom。接下来我们也抽离出来。

runtime-dom

packages文件夹下新建runtime-dom文件夹。 其结构如下

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

// 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能够正常渲染到页面上了。

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

完美。

接下来,我们让他显示一些内容:

通过上面介绍的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);
        ...
};

此时,我们可以看到页面上已经显示出想要的内容了:

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

接下来我们可以给本文整点样式,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]);
		}
	}

	...
};

看下效果:

跟着文章读一篇,人人都能了解Vue3  系列(一)(从0构建pnpm项目, 跑通内部渲染流程)

完美。

总结下:

  • 从0搭建pnpm项目
  • 从最简单的需求,实现h函数 -> createVNode -> 元素的渲染 -> 元素属性的处理

后续还有元素的事件处理、实现内部Text组件、Common组件、自定义组件渲染、diff流程等等。还有很多内容要做。如果能帮到你,欢迎关注。如果哪里写的不对的地方,评论区我们可以友好的交流、讨论。

转载自:https://juejin.cn/post/7355679990560325682
评论
请登录