本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。
真实的DOM渲染
我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?
虚拟DOM的优势
目前框架都会引入虚拟DOM来对真实的DOM进行抽象,这样做有很多的好处:
- 首先是可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作。 因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些,就变得非常的简单。 我们可以使用JavaScript来表达非常多的逻辑,而对于DOM本身来说是非常不方便的。
- 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点。 如渲染在canvas、WebGL、SSR、Native(iOS、Android)上。 并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染。
虚拟DOM的渲染过程
Vue3源码三大核心系统
Vue3源码地址:github.com/iamkata/Vue…
事实上Vue的源码包含三大核心:
- 编译模块(complier):主要职责是将template编译成虚拟节点。
- 渲染模块(runtime):又称Renderer模块,真正负责渲染的模块,主要职责是将虚拟节点渲染成真实元素,然后显示到浏览器上。
- 响应式模块(reactivity):主要职责是监听响应式的数据,然后通过diff算法判断VNode是否有变化,如果有变化,会通知渲染系统重新渲染元素,然后展示。
三大系统如何协同工作
- 编译系统做的事
template模板 -> AST语法树 -> render函数
- 渲染模块做的事
调用渲染函数 -> 拿到vnode -> 进行patch操作 -> 进行mount挂载操作
- 响应式系统做的事
收集依赖 -> 数据改变重新执行依赖 -> 生成新的vnode -> 进行diff算法 -> 得到具体要修改的dom -> 渲染到页面上
Vue模板编译原理
- 解析:使用大量的正则表达式对
template字符串进行解析,转化为AST语法树。 - 优化:遍历
AST,找到其中的一些静态节点并进行标记,方便在进行diff比较时,直接跳过这一些静态节点,优化性能 - 生成:将最终的
AST转化为render函数,然后再转成虚拟dom,真实dom
实现Mini-Vue
这里我们实现一个简洁版的Mini-Vue框架,该Vue包括三个模块:
- 渲染系统模块(renderer.js)
- 响应式系统模块(reactive.js)
- 应用程序入口模块(index.js)
我们不使用template,所以没必要编译系统了,所以直接在程序入口模块,也就是index.html里面使用渲染模块和可响应式系统模块即可。
渲染系统实现
渲染系统,该模块主要包含三个功能:
- 功能一:h函数,用于返回一个VNode对象;
- 功能二:mount函数,用于将VNode挂载到DOM上;
- 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;
新建renderer.js文件,这个文件就是我们的渲染系统,在renderer.js中编写代码如下:
① h函数的实现
直接返回一个VNode对象即可。
// 实现h函数
const h = (tag, props, children) => {
// vnode -> javascript对象 -> {}
return {
tag,
props,
children
}
}
② mount函数的实现
- 第一步:根据tag,创建HTML元素,并且存储到vnode的el中。
- 第二步:处理props属性,如果以on开头,那么监听事件,普通属性直接通过 setAttribute 添加即可。
- 第三步:处理子节点,如果是字符串节点,那么直接设置textContent,如果是数组节点,那么遍历调用 mount 函数。
//实现mount挂载函数
const mount = (vnode, container) => {
// vnode -> element
// 1.创建出真实的原生el, 并且在vnode上保留el
const el = vnode.el = document.createElement(vnode.tag);
// 2.处理props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key.startsWith("on")) { // 对事件监听的判断
el.addEventListener(key.slice(2).toLowerCase(), value)
} else {
el.setAttribute(key, value);
}
}
}
// 3.处理children
if (vnode.children) {
if (typeof vnode.children === "string") {
el.textContent = vnode.children;
} else {
vnode.children.forEach(item => {
mount(item, el);
})
}
}
// 4.将el挂载到container上
container.appendChild(el);
}
③ patch函数的实现 - 对比两个VNode
下面是个简单的patch操作。
//实现patch方法用于对比新旧VNode
const patch = (n1, n2) => {
//节点不相同直接替换
if (n1.tag !== n2.tag) {
const n1ElParent = n1.el.parentElement;
n1ElParent.removeChild(n1.el);
mount(n2, n1ElParent);
} else {
// 1.取出element对象, 并且在n2中进行保存
const el = n2.el = n1.el;
// 2.处理props
const oldProps = n1.props || {};
const newProps = n2.props || {};
// 2.1.获取所有的newProps添加到el
for (const key in newProps) {
const oldValue = oldProps[key];
const newValue = newProps[key];
if (newValue !== oldValue) {
if (key.startsWith("on")) { // 对事件监听的判断
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue);
}
}
}
// 2.2.删除旧的props
for (const key in oldProps) {
if (key.startsWith("on")) { // 对事件监听的判断
const value = oldProps[key];
el.removeEventListener(key.slice(2).toLowerCase(), value)
}
if (!(key in newProps)) {
el.removeAttribute(key);
}
}
// 3.处理children
const oldChildren = n1.children || [];
const newChidlren = n2.children || [];
if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
// 边界情况 (edge case)
if (typeof oldChildren === "string") {
if (newChidlren !== oldChildren) {
el.textContent = newChidlren
}
} else {
el.innerHTML = newChidlren;
}
} else { // 情况二: newChildren本身是一个数组
if (typeof oldChildren === "string") {
el.innerHTML = "";
newChidlren.forEach(item => {
mount(item, el);
})
} else { // 都是数组
// oldChildren: [v1, v2, v3, v8, v9]
// newChildren: [v1, v5, v6]
// 1.前面有相同节点的原生进行patch操作
const commonLength = Math.min(oldChildren.length, newChidlren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChidlren[i]);
}
// 2.newChildren.length > oldChildren.length
if (newChidlren.length > oldChildren.length) {
newChidlren.slice(oldChildren.length).forEach(item => {
mount(item, el);
})
}
// 3.newChildren.length < oldChildren.length
if (newChidlren.length < oldChildren.length) {
oldChildren.slice(newChidlren.length).forEach(item => {
el.removeChild(item.el);
})
}
}
}
}
}
使用renderer.js
我们在index.html中使用上面的渲染系统,直接用script标签导入即可。
<!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="./renderer.js"></script>
<script>
// 1.通过h函数来创建一个vnode
const vnode = h('div', {class: "why", id: "aaa"}, [
h("h2", null, "当前计数: 100"),
h("button", {onClick: function() {}}, "+1")
]); // vdom
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode, document.querySelector("#app"))
// 3.创建新的vnode
setTimeout(() => {
const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
h("h2", null, "呵呵呵"),
h("button", {onClick: function() {}}, "-1")
]);
//使用diff算法对比两个VNode
patch(vnode, vnode1);
}, 2000)
</script>
</body>
</html>
响应式系统实现
比如一个数据发生了改变,那么使用该数据的所有方法都要调用一次,这就是响应式系统思想。但是如果数据改变了,我们一个一个手动调用方法会很麻烦,一般我们会把这些依赖都保存下来,等数据改变了,再将保存的方法全部调用一次就行了。
依赖收集系统
class Dep {
constructor() {
// 使用集合,里面的元素不能重复
this.subscribers = new Set();
}
// 添加依赖
addEffect(effect) {
this.subscribers.add(effect);
}
notify() {
this.subscribers.forEach(effect => {
// 执行保存的函数
effect();
})
}
}
const info = {counter: 100};
const dep = new Dep();
function doubleCounter() {
console.log(info.counter * 2);
}
function powerCounter() {
console.log(info.counter * info.counter);
}
// 手动添加依赖
dep.addEffect(doubleCounter);
dep.addEffect(powerCounter);
info.counter++;
//手动调用
dep.notify();
上面的依赖收集都是我们手动实现的,比如手动添加依赖,手动调用,比较麻烦。我们想要的目标是如果有方法使用了数据,就自动将方法添加到依赖,然后数据改变了会自动调用方法。
响应式系统Vue2实现
创建一个reactive.js,代码如下:
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect);
}
}
notify() {
this.subscribers.forEach(effect => {
// 调用方法
effect();
})
}
}
let activeEffect = null;
//传入一个函数,监听函数内引用的数据
function watchEffect(effect) {
activeEffect = effect;
effect();
activeEffect = null;
}
// Map({key: value}): key是一个字符串
// WeakMap({key(对象): value}): key是一个对象, 弱引用
const targetMap = new WeakMap();
function getDep(target, key) {
// 1.根据对象(target)取出对应的Map对象
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 2.取出具体的dep对象
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
return dep;
}
// Vue2对raw进行数据劫持,然后在get方法中,搜集依赖,在set方法中调用搜集的依赖
function reactive(raw) {
Object.keys(raw).forEach(key => {
// 在WeakMap中获取对应的依赖
const dep = getDep(raw, key);
let value = raw[key];
Object.defineProperty(raw, key, {
get() {
// 获取数据的时候添加到依赖
dep.depend();
return value;
},
set(newValue) {
if (value !== newValue) {
value = newValue;
// 设置数据的时候再调用添加的依赖
dep.notify();
}
}
})
})
return raw;
}
// 使用上面的reactive函数
const info = reactive({counter: 100, name: "why"});
const foo = reactive({height: 1.88});
// 收集依赖
// watchEffect1
watchEffect(function () {
console.log("effect1:", info.counter * 2, info.name);
})
// watchEffect2
watchEffect(function () {
console.log("effect2:", info.counter * info.counter);
})
// watchEffect3
watchEffect(function () {
console.log("effect3:", info.counter + 10, info.name);
})
watchEffect(function () {
console.log("effect4:", foo.height);
})
// 触发依赖
info.counter++;
info.name = "why";
foo.height = 2;
上面代码是Vue2响应式系统的简单实现,主要是使用reactive函数对数据进行劫持,然后在reactive的get中添加依赖,在reactive的set中调用所有的依赖,依赖维护在一个WeakMap中,这样就完成了自动添加依赖和自动调用依赖。
响应式系统Vue3实现
和Vue2相比,Vue3的数据劫持使用的是Proxy。
// Vue3对raw进行数据劫持
function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const dep = getDep(target, key);
dep.depend();
return target[key];
},
set(target, key, newValue) {
const dep = getDep(target, key);
target[key] = newValue;
dep.notify();
}
})
}
框架外层API设计
这样我们就知道了,从框架的层面来说,我们需要有两部分内容:
- createApp用于创建一个app对象;
- 该app对象有一个mount方法,可以将根组件挂载到某一个dom元素上;
我们创建一个index.js文件,代码如下:
function createApp(rootComponent) {
return {
// 传入一个选择器
mount(selector) {
const container = document.querySelector(selector);
let isMounted = false;
let oldVNode = null;
watchEffect(function() {
if (!isMounted) { // 第一次挂载
oldVNode = rootComponent.render();
mount(oldVNode, container);
isMounted = true;
} else { // 刷新,获取新的vnode
const newVNode = rootComponent.render();
patch(oldVNode, newVNode); // 对比
oldVNode = newVNode;
}
})
}
}
}
使用Mini-Vue
创建一个index.html就可以使用Mini-Vue了,代码如下:
<!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="../02_渲染器实现/renderer.js"></script>
<script src="../03_响应式系统/reactive.js"></script>
<script src="./index.js"></script>
<script>
// 1.创建根组件
const App = {
data: reactive({
counter: 0
}),
render() {
return h("div", null, [
h("h2", null, `当前计数: ${this.data.counter}`),
h("button", {
onClick: () => {
this.data.counter++
console.log(this.data.counter);
}
}, "+1")
])
}
}
// 2.挂载根组件
const app = createApp(App);
app.mount("#app");
</script>
</body>
</html>
这样我们把renderer.js,reactive.js,index.js放到一个文件夹就是一个Mini-Vue了,就可以直接给其他项目使用了。
Mini-Vue源码地址:github.com/iamkata/Min…