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的情况:
从头部开始遍历,遇到相同的节点就继续,遇到不同的则跳出循环。
从尾部开始遍历,遇到相同的节点就继续,遇到不同的就跳出循环。
如果最后新节点更多,就添加新节点。
如果旧节点更多,就移除旧节点。
如果中间存在不知道如何排列的位置序列,那么就使用key建立索引图,最大限度的使用旧节点。
以上diff这部分提到的api可以参见vue3源码,此链接会导航至vue-next/package/runtime-core/src/renderer.js第1621行。renderer.ts — vuejs/vue-next — GitHub1s
为什么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
分三个模块:渲染模块、响应式模块、应用程序入口模块。
渲染模块
该模块实现3个api:
h函数:生成VNode对象,其实只是一个js对象。
mount函数:将VNode挂载到真实dom上。使用document.createElement
创建HTML元素,存储到VNode的el中,然后将传入的props通过setAttribute
添加到元素上,最后递归调用mount处理子节点。
patch函数:比较两个VNode,决定如何处理VNode,这里不考虑有key的情况。会分两部分判断,先判断是不是相同的节点,若不同则删除旧节点添加新节点,若相同再去遍历处理props和children
处理props的情况:
处理children的情况:
先将新节点的props全部挂载到el上;
判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性;
旧节点是一个字符串类型:
旧节点也是一个数组类型:
将el的textContent设置为空字符串;
旧节点是一个字符串类型,那么直接遍历新节点,挂载到el上;
取出数组的最小长度;
遍历所有的节点,新节点和旧节点进行patch操作;
如果新节点的length更长,那么剩余的新节点进行挂载操作;
如果旧节点的length更长,那么剩余的旧节点进行卸载操作;
如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;
如果新节点不同一个字符串类型:
找到n1的el父节点,删除原来的n1节点的el;
挂载n2节点到n1的el父节点上;
n1和n2是不同类型的节点:
n1和n2节点是相同的节点:
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 });// watchEffect1watchEffect(function () { console.log("effect1:", info.height + 1, info.name);});// watchEffect2watchEffect(function () { console.log("effect2:", foo.number);});// watchEffect3watchEffect(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>Document</title></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>
效果展示
原文:https://juejin.cn/post/7097112943287861261