手写实现一个简单的Mini-Vue系统
【摘要】 大家好,我是 CoderBin,本文将带着大家从零实现一个Mini-Vue系统。完成之后应该对vue整体的底层实现有一定的清晰,希望对你有所帮助,谢谢。
创作不易,你们的点赞收藏留言就是我最大的动力💓
如果文中有不对、疑惑的地方,欢迎各位小伙伴们在评论区留言指正🌻
前言
大家好,我是 CoderBin,本文将带着大家从零实现一个Mini-Vue系统。完成之后应该对vue整体的底层实现有一定的清晰,希望对你有所帮助,谢谢。
创作不易,你们的点赞收藏留言就是我最大的动力💓
如果文中有不对、疑惑的地方,欢迎各位小伙伴们在评论区留言指正🌻
说明:实现一个简洁版的Mini-Vue框架,该Vue包括三个模块
- 渲染系统模块
- 可响应式系统模块
- 应用程序入口模块
一、渲染系统模块
说明:渲染系统,该模块主要包含三个功能:
- 功能一:h函数,用于返回一个VNode对象;
- 功能二:mount函数,用于将VNode挂载到DOM上;
- 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;
1. h函数的实现 - 生成VNode
1.1 实现原理及步骤
直接返回一个VNode对象即可
代码实现:
/**
* * 功能一: 实现 h 函数,生成VNode
* @param {*} tag 标签名
* @param {*} props 属性|事件
* @param {*} children 文本|子节点
* @returns
*/
const h = (tag, props, children) => {
// vnode -> javascript对象
return {
tag,
props,
children
}
}
注意:h函数的的第二个参数只考虑为属性或者事件的情况,第三个参数只考虑为文本或子节点的情况。
1.2 测试代码
在html中的<script>脚本中编写如下代码:
const vnode = h('div', {class:'bin'}, 'CoderBin')
console.log(vnode);
1.3 代码效果
2. mount函数 - 挂载VNode
2. 1 实现原理及步骤
- 第一步:根据tag,创建HTML元素,并且存储到vnode的el中;
- 第二步:处理props属性
* 如果以on开头,那么监听事件;- 普通属性直接通过 setAttribute 添加即可;
- 第三步:处理子节点
* 如果是字符串节点,那么直接设置textContent;- p如果是数组节点,那么遍历调用 mount 函数;
代码实现:
/**
* * 功能二:实现 mount 函数,挂载 DOM
* @param {*} vnode 虚拟节点
* @param {*} container 容器
* 创建dom -> 处理props -> 处理children -> 挂载元素
*/
const mount = (vnode, container) => {
// vnode -> element
//TODO 1. 创建出真实的元素,并且在vnode上保留一份el
const el = vnode.el = document.createElement(vnode.tag);
//TODO 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); // 去掉on,并将事件转为小写
} else {
el.setAttribute(key, value); // 设置标签属性
}
}
};
//TODO 3. 处理children(要么设置文本,要么递归调用mount函数继续挂载)
if (typeof vnode.children === 'string') {
el.textContent = vnode.children; // 如果是文本,就添加到标签内容当中
} else {
vnode.children.forEach(item => {
mount(item, el); // 如果是数组就遍历每一项(每个vnode),然后递归调用mount函数
})
}
//TODO 4. 将el挂载到容器上
container.appendChild(el);
}
2.2 测试代码
在html中的<script>脚本中编写如下代码:
const vnode = h('h1', {class:'bin'}, 'CoderBin')
mount(vnode, document.querySelector("#app"))
2.3 代码效果
3. patch函数 - 对比两个VNode
3. 1 实现原理及步骤
patch函数的实现,分为两种情况
-
n1 和 n2 是不同类型的节点
- 找到n1的el父节点,删除原来的n1节点的el
- 挂载n2节点到n1的el父节点上
-
n1 和 n2 节点是相同的节点
- 2.1 处理props的情况
- [x] 先将新节点的props全部挂载到el上
- [x] 判断旧节点的props是否不需要再新节点上,如果不需要,那么删除对应的属性
- 2.2 处理children的情况
-
[x] 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;
-
[x] 如果新节点不是一个字符串类型,那就是数组类型:
- 旧节点是一个字符串类型
- 将el的textContent设置为空字符串
- 那么直接遍历新节点,挂载到el上
- 旧节点也是一个数组类型
- 取出数组的最小长度
- 遍历所有节点,新节点和旧节点进行patch操作
- 如果新节点的length更长,那么剩余的新节点进行挂载操作;
- 如果旧节点的length更长,那么剩余的旧节点进行卸载操作;
- 旧节点是一个字符串类型
-
- 2.1 处理props的情况
代码实现
/**
* * 功能三:实现 patch 函数,对比两个VNode
* @param {*} n1 旧节点
* @param {*} n2 新节点
*/
const patch = (n1, n2) => {
//TODO 1. 先判断两个VNode的标签是否一致,不是则直接移除就VNode,添加新VNode
if (n1.tag !== n2.tag) {
const n1ElParent = n1.el.parentElement; // 获取父节点
n1ElParent.removeChild(n1.el); // 移除旧节点
mount(n2, n1ElParent); // 挂载新VNode
} else {
//TODO 2. 处理props
// 取出element对象,并且在n2中进行保存
const el = n2.el = n1.el;
const oldProps = n1.props || {}; // 如果有props就取出,没有就赋值{}
const newProps = n2.props || {};
// 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); // 去掉on,并将事件转为小写
} else {
el.setAttribute(key, newValue); // 设置标签属性
}
}
}
// 2. 删除旧的props
for (const key in oldProps) {
if (!(key in newProps)) {
if (key.startsWith('on')) { // 对事件监听的判断
el.removeEventListener(key.slice(2).toLowerCase(), newValue); // 去掉on,并将事件转为小写
} else {
el.removeAttribute(key); // 设置标签属性
}
}
}
//TODO 3. 处理children
const oldChildren = n1.children || [];
const newChildren = n2.children || [];
if (typeof newChildren === 'string') {
// 边界判断
if (typeof oldChildren === 'string') { //* 情况一:newChildren本身是一个string
if (newChildren !== oldChildren) {
el.textContent = newChildren;
}
} else {
el.innerHTML = newChildren;
}
} else { //* 情况二:newChildren本身是一个数组
if (typeof oldChildren === 'string') {
el.innerHTML = ''; // 如果oldChildren,先清空旧的内容
newChildren.forEach(item => { // 然后再遍历newChildren数组,挂载DOM
mount(item, el);
})
} else {
// oldChildren: [v1, v2, v3, v8, v9]
// newChildren: [v1, v5, v6]
// 进行简易的diff算法
// 1.前面有相同节点的元素进行patch操作
const commonLength = Math.min(newChildren.length, oldChildren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 2. newChildren.length > oldChildren.length 时,挂载多出的newChildren节点
if (newChildren.length > oldChildren.length) {
newChildren.splice(oldChildren.length).forEach(item => {
mount(item, el)
})
}
// 3. newChildren.length < oldChildren.length 时,卸载oldChildren多余的节点
if (newChildren.length < oldChildren.length) {
oldChildren.splice(newChildren.length).forEach(item => {
el.removeChild(item.el)
})
}
}
}
}
}
3.2 测试代码
在html中的<script>脚本中编写如下代码:
// 1. 通过 h 函数来创建一个vnode
const vnode = h('div', {class: "why", id: "aaa"}, [
h("h2", null, "当前计数: 100"),
h("button", {onClick: function() {}}, "+1")
]); // vdom
console.log(vnode);
// 2. 通过 mount 函数将vnode挂载到id为app的div上
mount(vnode, document.querySelector("#app"));
// 3. 创建一个新的vnode,为了看到效果,使用 setTimeout 3秒后再进行替换操作
setTimeout(() => {
const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
h("h2", null, "呵呵呵"),
h("button", {onClick: function() {}}, "-1")
]);
patch(vnode, vnode1);
}, 2000)
3.3 代码效果
3秒后,渲染新的dom
4. 渲染系统模块总结
渲染系统模块的总体流程是:生成VNode --> 挂载VNode --> 新旧VNode对比,重新挂载
- h函数的实现比较简单,只要将参数作为对象返回即可
- mount函数也不难,核心实现主要是以下四步,其他的都是一些边界的判断:
- 创建HTML元素 document.createElement;
- 监听元素事件 el.addEventListener、添加元素属性 el.setAttribute;
- 设置元素内容 el.textConent,递归调用mount函数继续挂载
- 挂载VNode, container.appendChild(el);
- patch函数就稍微复杂一点,其中大前提是两个VNode是否是相同节点类型,不是相同节点类型的操作比较简单,只需要替换新的节点即可,如果是相同类型的节点,就要进行额外的情况、边界判断,最后还涉及到了diff算法的基础实现
二、可响应式系统模块
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;
}
// vue3对raw进行数据劫持,实现reactive响应式数据
// 返回一个proxy代理对象
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();
}
})
}
三、应用程序入口模块
/**
* createApp 程序入口函数
* @param {*} rootComponent 根组件
* @returns
*/
function createApp(rootComponent) {
return {
mount(selector) {
const container = document.querySelector(selector);
let isMounted = false;
let oldVNode = null;
// 调用响应式系统里面的watchEffect侦听函数
watchEffect(function() {
if (!isMounted) {
oldVNode = rootComponent.render();
mount(oldVNode, container); // 这个mount是渲染模块中的mount函数
isMounted = true;
} else {
const newVNode = rootComponent.render();// 这个render是渲染模块中的render函数
patch(oldVNode, newVNode);
oldVNode = newVNode;
}
})
}
}
}
四、Mini-Vue系统测试
<body>
<div id="app"></div>
<script src="./mini-vue/renderer.js"></script>
<script src="./mini-vue/reactive.js"></script>
<script src="./mini-vue/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>
每文一句:生活的全部意义在于无穷地探索尚未知道的东西,在于不断地增加更多的知识。
ok,本次的分享就到这里,如果本章内容对你有所帮助的话可以点赞+收藏,希望大家都能够有所收获。有任何疑问都可以在评论区留言,大家一起探讨、进步!
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)