Mini Vue,顾名思义是一个丐版Vue,本篇将根据Vue的原理去简单的写一下其中的几个核心api思路,就像是伪代码一样,这里只写核心思路不处理任何边缘情况。
代码是跟着coderwhy老师写的。
在实现之前,先来说一下Vue的原理。
事实上Vue包含三大核心:
Compiler模块:编译模版系统;
Runtime模块:或称之Renderer模块,渲染模块;
Reactive模块:响应式系统。
编译系统和渲染系统合作:编译系统会将template编译为render
函数和createVNode
函数(或称h函数,类似于React.createElement
),渲染系统执行这些函数,此时就可生成虚拟节点,组合成树形便形成了虚拟dom,再调用patch
函数渲染为真实dom,Vue在创建或更新组件时都使用该函数,创建时旧节点就传null,具体逻辑下文会说到。这时候就可以显示到浏览器。
扩展一点,虚拟dom有什么好处?大致有两点:
*** 作普通对象比 *** 作dom对象要方便的多,例如diff,clone等 *** 作。方便实现跨平台,可以将VNode渲染为任意想要的节点,例如按钮web渲染为button元素,Android渲染为Button控件,此外还可渲染在canvas、ssr、ios等等平台。 响应式系统和渲染系统合作:响应式系统会监控一些数据,Vue2是通过Object.definedProperty
,Vue3是通过Proxy
。若值发生变化,会通知渲染系统,渲染系统会根据diff
算法去调用patch
函数,由此来更新dom。
扩展两点:
diff算法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的情况:
为什么Vue3选择Proxy? Object.definedProperty是劫持对象的属性,如果新增元素,就要再调一次Object.definedProperty,而Proxy劫持的是整个对象,即便是新增元素也不需要做特殊处理。Proxy能观察到的类型比definedProperty更丰富,比如:Proxy有has,就可以捕获in *** 作符;Proxy有deleteProperty,可以捕获到delete *** 作符。需要注意的是,使用defineProperty时,修改原来的obj对象就可以触发拦截,而使用Proxy时,就必须修改代理对象,即Proxy实例才可以触发拦截,其实这在真实开发中并不会影响什么。如果要说缺点,Proxy不兼容IE,definedProperty可以支持到IE9,这也是Vue3不支持IE的原因。 三大系统协作 Mini Vue以上diff这部分提到的api可以参见vue3源码,此链接会导航至vue-next/package/runtime-core/src/renderer.js第1621行。renderer.ts — vuejs/vue-next — GitHub1s
分三个模块:渲染模块、响应式模块、应用程序入口模块。
渲染模块该模块实现3个api:
h函数:生成VNode对象,其实只是一个js对象。mount函数:将VNode挂载到真实dom上。使用document.createElement
创建HTML元素,存储到VNode的el中,然后将传入的props通过setAttribute
添加到元素上,最后递归调用mount处理子节点。patch函数:比较两个VNode,决定如何处理VNode,这里不考虑有key的情况。会分两部分判断,先判断是不是相同的节点,若不同则删除旧节点添加新节点,若相同再去遍历处理props和children
n1和n2是不同类型的节点:
找到n1的el父节点,删除原来的n1节点的el;挂载n2节点到n1的el父节点上; n1和n2节点是相同的节点:
处理props的情况:
先将新节点的props全部挂载到el上;判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性; 处理children的情况:
如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;如果新节点不同一个字符串类型:
旧节点是一个字符串类型:
将el的textContent设置为空字符串;旧节点是一个字符串类型,那么直接遍历新节点,挂载到el上; 旧节点也是一个数组类型:
取出数组的最小长度;遍历所有的节点,新节点和旧节点进行patch *** 作;如果新节点的length更长,那么剩余的新节点进行挂载 *** 作;如果旧节点的length更长,那么剩余的旧节点进行卸载 *** 作;
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()
响应式Vue2实现
现在解答上面的问题,何时调用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;
响应式Vue3实现
和上面的区别在于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>
效果展示
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)