mini-vue 的设计
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>
<button class="btn">执行patch</button>
<hr></hr>
<button class="refreshBtn">刷新页面</button>
</body>
<script src="./renderer.js"></script>
<script src="./mount.js"></script>
<script src="./patch.js"></script>
<script>
/**
* 思路:
* 1. 通过 h 函数创建 vnode
* 2. 通过 mount 函数挂载
*/
// 1. 生成 vnode
const vnode = h(
"div",
{ class: "youxiaobei", id: "oldId", del: "这是将被删除的" },
[
h("ul", null, [
h("li", null, "我是一个小li"),
h("li", null, "我是一个小li"),
h("li", null, "我是一个小li"),
h("li", null, "我是一个小li"),
]),
h("button", null, "这是将被保留的小小按钮"),
h("h4", null, "以上内容都会被比较为不同然后删除,包括我")
]
);
// 2. 挂载,生成真实 dom,添加到 container 容器中
const container = document.getElementById("app");
mount(vnode, container);
// 3. 新节点 01 (最外层 tagName 不一样,直接都被替换了)
const newVnode = h("h2", { class: "newNode", id: "newId" }, [
h("button", null, "我是你后来加的小按钮"),
]);
const btn = document.querySelector('.btn')
btn.addEventListener("click", () => {
patch(vnode, newVnode);
btn.disabled = true;
},true);
const refreshBtn = document.querySelector('.refreshBtn')
refreshBtn.addEventListener("click", () => {
location.reload()
})
</script>
<style>
/* 新的节点背景色是红色的 */
.newNode {
background-color: red;
}
</style>
</html>
执行 patch 前:
执行后:
1. h 函数
h 函数也就是 render 函数,作用简单:返回一个 Vnode 虚拟节点,但很重要!
/**
* h 函数
* 功能:返回vnode
*
* @param {String} tagName - 标签名
* @param {Object | Null} props - 传递过来的参数
* @param {Array | String} children - 子节点
* @return {vnode} 虚拟节点
*/
const h = (tagName, props, children) => {
// 直接返回一个对象,里面包含vnode结构
return {
tagName,
props,
children,
};
};
2. 响应式
考虑以下功能:
- 收集依赖某个数据的函数
- 当数据变化后,重新执行依赖此数据的函数
/**
* 实现一个类
* 构造一个 订阅列表 subscribers set 对象
* 收集者 addEffect 添加影响的函数,往 subscribers 里面添加
* 通知者 notifier 通知函数, 依次执行 subscribers 里面的函数
*/
class Dep {
constructor() {
// 1.订阅列表 构造一个 subscribers set 对象
this.subscribers = new Set();
}
// 2. 收集者 addEffect 添加影响的函数
addEffect(effect) {
this.subscribers.add(effect);
}
// 3. 通知者 notifier
notifier() {
this.subscribers.forEach((effect) => {
effect();
});
}
}
const dep = new Dep();
let count = 1;
const addFun = function () {
console.log(++count);
};
addFun(); // 2
// 收集订阅
dep.addEffect(addFun);
// 当数据改变后
count = 100;
// 通知者通知函数重执行
dep.notifier(); // 101
3. mount 函数
挂载 Vnode 为真实的 DOM 元素
/**
* mount 函数
* 功能:挂载 vnode 为 真实dom
* 重点:递归调用处理子节点
*
* @param {Object} vnode -虚拟节点
* @param {elememt} container -需要被挂载节点
*/
const mount = (vnode, container) => {
// 1. 创建出真实元素, 给 vnode 添加 el 属性
const el = (vnode.el = document.createElement(vnode.tagName));
// 2. 处理 props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
// 2.1 prop 是函数
if (key.startsWith("on")) {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
// 2.2 prop 是字符串
el.setAttribute(key, value);
}
}
}
// 3. 处理 children
if (vnode.children) {
// 3.1 如果 children 是字符串,直接设置文本内容
if (typeof vnode.children === "string") {
el.textContent = vnode.children;
}
// 3.2 如果 children 是数组,递归挂载每个子节点
else {
// 先拿到里面的每一个 vnode
vnode.children.forEach((item) => {
// 再把里面的vnode递归调用
mount(item, el);
});
}
}
// 4. 挂载
container.appendChild(el);
};
04. patch 函数
patch 对比节点数组,优化性能
/**
* 节点比较
* 调用时机:节点发生变化(数量,内容)
* 功能:比较节点数组,尽可能减少 DOM 操作
*/
/**
* @param {Vnode} n1 - 旧节点
* @param {Vnode} n2 - 新节点
*/
const patch = (n1, n2) => {
// 节点不相同,卸载旧节点,挂载新节点
if (n1.tagName !== n2.tagName) {
const parentElementNode = n1.el.parentElement;
parentElementNode.removeChild(n1.el);
mount(n2, parentElementNode);
} else {
// 1. 取出 element 并保存到 n2
const el = (n2.el = n1.el);
// 2. 处理 props
const oldProps = n1.props || {};
const newProps = n2.props || {};
for (const key in newProps) {
const oldValue = oldProps[key];
const newValue = newProps[key];
// 2.1 值不同才替换
if (oldValue !== newValue) {
if (key.startsWith("on")) {
el.addEventListener(key.slice(2).toLowerCase(), newValue);
} else {
// 2.2 prop 是字符串
el.setAttribute(key, newValue);
}
}
}
// 3. 删除旧的 props
for (const key in oldProps) {
// 如果旧 key 不在新的 props 里
if (!(key in newProps)) {
const oldValue = oldProps[key];
if (key.startsWith("on")) {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
} else {
// 2.2 prop 是字符串
el.removeAttribute(key, oldValue);
}
}
}
// 4. 处理 children
const oldChildren = n1.children;
const newChildren = n2.children;
// children 字符串
if (typeof newChildren === "string") {
// 4.1 如果新 children 是字符串,直接设置文本内容
if (oldChildren !== newChildren) {
el.textContent = newChildren;
} else {
el.innerHTML = newChildren;
}
} else {
// 4.2 如果新 children 是数组,递归挂载每个子节点
// 如果旧 children 的是字符串
if (typeof oldChildren === "string") {
el.innerHTML = "";
// 遍历 children
newChildren.forEach((item) => {
mount(item, el);
});
} else {
// 两个都是数组,开始 diff 算法
// n1: [a,b,d]
// n2: [b,a,c,f]
/**
* 没有 key
*/
if (!n1.props.key && !n2.props.key) {
// 4.3.1 获取两个 vnode 数组的公共长度,比较相同的
const commonLength = Math.min(oldChildren.length, newChildren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 4.3.2 新的长度多于旧的,挂载
if (oldChildren.length < newChildren.length) {
newChildren.slice(oldChildren.length).forEach((item) => {
mount(item, el);
});
}
// 4.3.3 旧的长度多于新的,卸载
if (oldChildren.length > newChildren.length) {
oldChildren.slice(newChildren.length).forEach((item) => {
el.removeChild(item.el);
});
}
} else {
/**
* 有 key
*/
// 4.4.1 根据 key 创建一个映射表,方便查找和比较
const keyMap = {};
oldChildren.forEach((child) => {
if (child.props.key) {
keyMap[child.props.key] = child;
}
});
// 4.4.2 遍历新的 children 数组
newChildren.forEach((newChild, index) => {
const oldChild = keyMap[newChild.props.key];
if (oldChild) {
// 4.4.2.1 如果旧的 children 存在对应的 key,对比并更新子节点
patch(oldChild, newChild);
oldChildren[index] = oldChild; // 更新旧的 children 数组,方便后续删除处理
} else {
// 4.4.2.2 如果旧的 children 中没有对应的 key,说明是新增的节点,直接挂载
mount(newChild, el, index);
}
});
// 4.4.3 删除旧的 children 中没有对应的 key 的子节点
oldChildren.forEach((oldChild) => {
if (!oldChildren.find((child) => child.props.key === oldChild.props.key)) {
el.removeChild(oldChild.el);
}
});
}
}
}
}
};