三大核心系统
vue的源码包含三大核心:
- Compiler模块:编译模板系统
- Runtime模块:也可以称之为Renderer模块,真正渲染的模块
-
三大系统如何协同工作?
这里我们实现一个简洁版的Mini-Vue框架,该Vue包含三个模块: 渲染系统模块
- 可响应式系统模块
-
渲染系统实现
渲染系统,该模块主要包含三个功能:
功能一:h函数,用于返回一个VNode对象
- 功能二:mount函数,用于将VNode挂载到DOM上
功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode
h函数的实现
const vnode = h('div', { class: 'zx' }, [
h("h2", null, "当前计数:100"),
h("button", null, "+1")
])
const h = (tag, props, children) => {
return {
tag,
props,
children,
};
};
非常的不可思议对不对?本以为h函数会非常复杂,但其实h函数非常的简单,就是返回一个对象,用于描述dom节点的对象,也就是vnode,真实的h函数也和我们这个差不多,不过是多了一些边界处理。
mount函数的实现
mount(vnode, document.querySelector('#app'))
const mount = (vnode, container) => {
// 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));
} 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);
});
}
}
container.appendChild(el);
};
mount函数的主要作用就是实现将vnode对象转换成真实的dom,也就是说,解析前面h函数生成的对象,根据解析出的对象创建真实的dom节点。
创建节点就是document.createElement(vnode.tag),这个原生的API就能实现这个功能。这里也做了很多的简化,比如没有考虑传入的是组件,但其实即使是组件,也不过是一个不同的vnode罢了,虽然代码会复杂,因为会用到正则表达式,但是原理是一样的。
处理props那部分,就是遍历一遍props的参数,然后通过setAttribute(key, value)这个原生API实现属性绑定。
最后是处理children这部分,也只考虑了string和数组,而数组也只考虑了普通的html标签,而没有考虑组件标签等。如果是字符串,就通过el.textContent = vnode.children绑定字符串;如果是数组,就遍历这个数组,并且这里使用了递归,就是为了对数组内的元素也进行一遍mount函数的操作。
全部处理完成后,我们mount函数的第二个参数是要挂载的节点,将最终得出的el(要创建的真实dom)挂载到我们想要挂载的节点,就实现了mount函数。
patch函数的实现
patch函数的实现要稍微复杂一些,主要的原理就是传入两个vnode,根据这两个vnode的差异来进行diff算法,最终修改dom,实现节点的改变。
const patch = (vnode1, vnode2) => {
if (vnode1.tag !== vnode2.tag) {
const vnode1ElParent = vnode1.el.parentElement;
vnode1ElParent.removeChild(vnode1.el);
mount(vnode2, vnode1ElParent);
} else {
// 1. 取出element对象,并且在n2中进行保存
const el = (vnode2.el = vnode1.el);
// 2. 处理props
const oldProps = vnode1.props || {};
const newProps = vnode2.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).toLocaleLowerCase(), newValue);
} else {
el.setAttribute(key, newValue);
}
}
}
// 2.2 删除旧的props
for (const key in oldProps) {
if (!(key in newProps)) {
if (key.startsWith("on")) {
const value = oldProps[key];
el.removeEventListener(key.slice(2).toLocaleLowerCase(), value);
} else {
el.removeAttribute(key);
}
}
}
// 3. 处理children
const oldChildren = vnode1.children || [];
const newChildren = vnode2.children || [];
if (typeof newChildren === "string") {
if (typeof oldChildren === "string") {
// 边界判断
if (newChildren !== oldChildren) {
el.textContent = newChildren;
}
} else {
el.innerHTML = newChildren;
}
} else {
// 情况二:newChildren本身是一个数组
if (typeof oldChildren === "string") {
el.innerHTML = "";
newChildren.forEach((item) => {
mount(item, el);
});
} else {
// oldChildren:[v1,v2,v3]
// newChildren:[v1,v5,v6,v8,v9]
// 1. 前面有相同节点的元素进行patch操作
const commonLength = Math.min(oldChildren.length, newChildren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 2. newChildren > oldChildren
if (newChildren.length > oldChildren.length) {
newChildren.slice(oldChildren.length).forEach((item) => {
mount(item, el);
});
}
// 2. newChildren < oldChildren
if (newChildren.length < oldChildren.length) {
oldChildren.slice(newChildren.length).forEach((item) => {
el.removeChild(item.el);
});
}
}
}
}
};
分段讲解
const patch = (vnode1, vnode2) => {
if (vnode1.tag !== vnode2.tag) {
const vnode1ElParent = vnode1.el.parentElement;
vnode1ElParent.removeChild(vnode1.el);
mount(vnode2, vnode1ElParent);
}
patch函数首先接收两个参数,一个是旧的vnode节点vnode1,一个是新的vnode节点vnode2。首先我们比较新旧两个vnode节点的类型,如果类型都不同,那么直接删除旧节点,生成新的节点。如果类型相同,那么进入下一部分。
else {
// 1. 取出element对象,并且在n2中进行保存
const el = (vnode2.el = vnode1.el);
// 2. 处理props
const oldProps = vnode1.props || {};
const newProps = vnode2.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).toLocaleLowerCase(), newValue);
} else {
el.setAttribute(key, newValue);
}
}
}
// 2.2 删除旧的props
for (const key in oldProps) {
if (!(key in newProps)) {
if (key.startsWith("on")) {
const value = oldProps[key];
el.removeEventListener(key.slice(2).toLocaleLowerCase(), value);
} else {
el.removeAttribute(key);
}
}
}
首先获取到el对象,也就是节点本身的真实dom,然后遍历新vnode的attribute,以新attribute的键名分别获取新旧attribute的值,如果旧值和新值不相等,那么将新值添加给el。然后遍历旧vnode的attribute,如果和新值不相等就删除。这就实现了attribute的更新。
// 3. 处理children
const oldChildren = vnode1.children || [];
const newChildren = vnode2.children || [];
if (typeof newChildren === "string") {
if (typeof oldChildren === "string") {
// 边界判断
if (newChildren !== oldChildren) {
el.textContent = newChildren;
}
} else {
el.innerHTML = newChildren;
}
} else {
// 情况二:newChildren本身是一个数组
if (typeof oldChildren === "string") {
el.innerHTML = "";
newChildren.forEach((item) => {
mount(item, el);
});
} else {
// oldChildren:[v1,v2,v3]
// newChildren:[v1,v5,v6,v8,v9]
// 1. 前面有相同节点的元素进行patch操作
const commonLength = Math.min(oldChildren.length, newChildren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 2. newChildren > oldChildren
if (newChildren.length > oldChildren.length) {
newChildren.slice(oldChildren.length).forEach((item) => {
mount(item, el);
});
}
// 2. newChildren < oldChildren
if (newChildren.length < oldChildren.length) {
oldChildren.slice(newChildren.length).forEach((item) => {
el.removeChild(item.el);
});
}
}
}
这一部分是最复杂的,就是比较子节点。首先是判断新节点是否为字符串类型,如果是的话,再判断旧节点是否为字符串,如果是的话,直接更改textContent,也就是更改文本内容即可;如果不是,那么将旧节点直接更改为新的字符串节点。
然后是边界处理情况,也就是新旧children的不同类型,如果新节点为数组,旧节点为字符串,那么清空节点的文本内容,然后遍历新数组,调用mount函数实现节点的创建和挂载。
如果新旧节点都为数组,那么首先获取新旧节点数组中短的那一方,比如新数组长度为5,旧数组长度为3,那么执行一个0-3的循环,循环的item为新旧树组0-3的值,比较这部分值,递归调用patch函数比较(就是将数组打开,拿到单独的一个个节点,调用patch函数比较,重复之前的步骤,从比较两个节点是否为相同类型结点开始)。
如果新节点比旧节点长,那么新节点截取掉旧节点的长度后,多出来的那部分通过调用mount函数实现节点的创建;如果旧节点比新节点长,那么旧节点截取掉和新节点相同长度的部分后,剩下的那部分通过调用unmount函数实现节点的卸载(这里只简单的通过removeChild实现了节点的卸载,实际实现要复杂一些)。
上面这部分代码就是diff算法的基本逻辑,不论是vue还是react都是基于这个实现的,不过它们的代码处理的边界情况更多。
响应式系统实现
简单实现
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();
分析一下代码:
首先,我们定义了一个Dep类,类中有一个subscribers,是一个不允许重复的数组。然后这个类有两个方法,addEffect方法用于向subscribers数组添加副作用(修改变量的操作);notify方法用于遍历存入subscribers数组的方法并且执行。
然后在测试数据中,我们定义了对象类型变量info,然后新建了Dep的实例dep,然后是两个函数,doubleCounter用于打印info的counter值*2,powerCounter用于打印info的counter的平方。
通过实例的addEffect方法将这两个函数存入subscribers数组。
接着我们将info的counter做一个加一,最后调用实例的notify方法。
那么执行结果是什么呢?
首先info.counter++会使counter的值变为101,然后执行dep.notify(),这时因为之前dep.addEffect(doubleCounter)和dep.addEffect(powerCounter)执行使得subscribers数组内有两个函数分别是doubleCounter和powerCounter,notify会依次执行这两个函数,所以会打印 101 2 = 202,还会打印 101 * 101 = 10201
上面的代码实现了在info数据改变的时候监听到每一次改变。
上面的代码,实现了简单的响应式功能,但是需要在数据发生改变时手动添加到subscribers数组,而真正的响应式系统是自动跟踪改变,所以还需要进行改进。
vue2实现思路
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;
}
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;
}
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({ counter: 100, name: "zx" });
// 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);
});
我们来分析一下:
前面提到简单实现需要手动调用addEffect来将改变变量的函数添加到subscribers数组中,所以我们创建一个watchEffect函数来实现自动添加。
vue3实现思路
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;
}
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进行数据劫持
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();
},
});
}
为什么vue3选择proxy?
Object.defineProperty在劫持对象的属性时,如果新增元素:
- 那么vue2需要再次调用definedProperty,而Proxy劫持的是整个对象,不需要做特殊处理
修改对象的不同:
- 使用defineProperty时,我们修改原来的obj对象就可以触发拦截
- 而使用proxy,就必须修改代理对象,即Proxy的实例才可以触发拦截
Proxy能观察的类型比defineProperty更丰富
- has:in操作符的捕获器
- deleteProperty:delete操作符的捕捉器
- 等等其他操作
Proxy作为新标准将受到浏览器厂商重点持续的性能优化
缺点:Proxy不兼容IE,也没有polyfi
,defineProperty能支持到IE9