首页>>前端>>Vue->mini Vue 的实现,Vue工作原理简析

mini Vue 的实现,Vue工作原理简析

时间:2023-11-30 本站 点击:1

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。

这里使用WeakMapMap来存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


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/Vue/3766.html