Mini Vue,顾名思义是一个丐版Vue,本篇将根据Vue的原理去简单的写一下其中的几个核心api思路,就像是伪代码一样,这里只写核心思路不处理任何边缘情况。
代码是跟着coderwhy老师写的。
在实现之前,先来说一下Vue的原理。
事实上Vue包含三大核心:
Compiler模块:编译模版系统;
Runtime模块:或称之Renderer模块,渲染模块;
Reactive模块:响应式系统。
编译系统会将template编译为render
函数和createVNode
函数(或称h函数,类似于React.createElement
),渲染系统执行这些函数,此时就可生成虚拟节点,组合成树形便形成了虚拟dom,再调用patch
函数渲染为真实dom,Vue在创建或更新组件时都使用该函数,创建时旧节点就传null,具体逻辑下文会说到。这时候就可以显示到浏览器。
扩展一点,虚拟dom有什么好处?大致有两点:
响应式系统会监控一些数据,Vue2是通过Object.definedProperty
,Vue3是通过Proxy
。若值发生变化,会通知渲染系统,渲染系统会根据diff
算法去调用patch
函数,由此来更新dom。
扩展两点:
diff算法会根据dom有没有key去调用不同的patch函数,没有key调用patchUnkeyedChildren
,有则调用patchKeyedChildren
。
patchUnkeyedChildren:从0位置开始依次patch比较新旧节点,没有其他特殊操作,这就意味着如果有一组旧节点abcd,在b后面插入f节点成为一组新节点abfcd,从位置0开始遍历,遍历到位置2时c和f不一样,则会使用f替换c,再往后c替换d,最后再插入一个d,虽然abcd都没有改变,cd仍然被重新创建插入,效率并不高。
patchKeyedChildren:因为dom元素存在key值,可以让Vue根据key去判断节点是否是之前存在的(isSameVNodeType
函数),这样就可以优化diff算法,不同于unkey从头开始while遍历,这里分为5个不同的while循环,按照从上到下的顺序执行:
下图是一种比较极端的情况,会使用到第五个while的情况:
以上diff这部分提到的api可以参见vue3源码,此链接会导航至vue-next/package/runtime-core/src/renderer.js第1621行。renderer.ts — vuejs/vue-next — GitHub1s
分三个模块:渲染模块、响应式模块、应用程序入口模块。
该模块实现3个api:
document.createElement
创建HTML元素,存储到VNode的el中,然后将传入的props通过setAttribute
添加到元素上,最后递归调用mount处理子节点。const h = (tag, props, children) => {
return {
tag,
props,
children
}
}
const mount = (vnode, container) => {
// vnode -> element
// 1.创建出真实的原生, 并且在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);
}
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
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);
})
}
}
}
}
}
这里模仿Vue的watchEffect和reactive。
这是响应式系统的核心思想,使用Set
来收集依赖,可以保证不会收集到重复的依赖。这里是简化版本,实际收集依赖时需要一个数据(或者说属性)就有一个dep实例来收集使用到它的依赖,这样就可以实现一个数据改变只有使用到它的依赖才会被重新调用。
现在的问题就简化为何时调用dep.depend()和dep.notify()了。
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;
dep.depend();
effect();
activeEffect = null;
}
//以下为测试代码
const dep = new Dep();
watchEffect(() => {
console.log('依赖回调');
});
dep.notify()
现在解答上面的问题,何时调用dep.depend()和dep.notify()?
答:使用数据是调dep.depend()收集依赖,改变数据时调用dep.notify()通知渲染系统数据改变。
Vue2使用了Object.definedProperty来劫持对象的getter和setter,在这里分别调用depend和notify。
这里使用WeakMap
和Map
来存dep实例,比如reactive({name: ‘hxy’, height: 186}),就创建一个以reactive传入对象为key的WeakMap实例,然后这个对象里的每个属性都会创建一个以它们自己为key的Map实例,这也是Vue3收集依赖的数据结构。
讨论一个问题:为什么要用WeakMap呢?
WeakMap
对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
上面是MDN对于WeakMap的定义,这也就是原因,当某个响应式数据被不使用了置为null,垃圾回收就会工作释放该对象的堆空间,此时该数据的dep实例们也就都使用不到了,因为WeakMap的键是弱引用,它的键也就不存在了,dep实例们自然也会被回收。
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进行数据劫持
function reactive(raw) {
Object.keys(raw).forEach((key) => {
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;
}
// 以下为测试代码
const info = reactive({ name: "hxy", height: 186 });
const foo = reactive({ num: 1 });
// watchEffect1
watchEffect(function () {
console.log("effect1:", info.height + 1, info.name);
});
// watchEffect2
watchEffect(function () {
console.log("effect2:", foo.number);
});
// watchEffect3
watchEffect(function () {
console.log("effect3:", info.counter + 10);
});
// info.height++;
foo.num = 2;
和上面的区别在于reactive函数里要使用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();
}
})
}
仅实现将VNode挂载到dom上的功能
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 {
const newVNode = rootComponent.render();
patch(oldVNode, newVNode);
oldVNode = newVNode;
}
})
}
}
}
至此Mini Vue已实现,可以使用下面代码测试
DOCTYPE html>
<html lang="zh">
<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>Documenttitle>
head>
<body>
<div id="app">div>
<script src="./renderer.js">script>
<script src="./reactive.js">script>
<script src="./init.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>
效果展示