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

Mini Vue,顾名思义是一个丐版Vue,本篇将根据Vue的原理去简单的写一下其中的几个核心api思路,就像是伪代码一样,这里只写核心思路不处理任何边缘情况。
代码是跟着coderwhy老师写的。

原理

在实现之前,先来说一下Vue的原理。

事实上Vue包含三大核心:

  1. Compiler模块:编译模版系统;

  2. Runtime模块:或称之Renderer模块,渲染模块;

  3. Reactive模块:响应式系统。

mini Vue的实现 Vue工作原理简析_第1张图片

编译系统和渲染系统合作:

编译系统会将template编译为render函数和createVNode函数(或称h函数,类似于React.createElement),渲染系统执行这些函数,此时就可生成虚拟节点,组合成树形便形成了虚拟dom,再调用patch函数渲染为真实dom,Vue在创建或更新组件时都使用该函数,创建时旧节点就传null,具体逻辑下文会说到。这时候就可以显示到浏览器。

扩展一点,虚拟dom有什么好处?大致有两点:

  1. 操作普通对象比操作dom对象要方便的多,例如diff,clone等操作。
  2. 方便实现跨平台,可以将VNode渲染为任意想要的节点,例如按钮web渲染为button元素,Android渲染为Button控件,此外还可渲染在canvas、ssr、ios等等平台。

响应式系统和渲染系统合作:

响应式系统会监控一些数据,Vue2是通过Object.definedProperty,Vue3是通过Proxy。若值发生变化,会通知渲染系统,渲染系统会根据diff算法去调用patch函数,由此来更新dom。

扩展两点:

diff算法

diff算法会根据dom有没有key去调用不同的patch函数,没有key调用patchUnkeyedChildren,有则调用patchKeyedChildren

  1. patchUnkeyedChildren:从0位置开始依次patch比较新旧节点,没有其他特殊操作,这就意味着如果有一组旧节点abcd,在b后面插入f节点成为一组新节点abfcd,从位置0开始遍历,遍历到位置2时c和f不一样,则会使用f替换c,再往后c替换d,最后再插入一个d,虽然abcd都没有改变,cd仍然被重新创建插入,效率并不高。

    mini Vue的实现 Vue工作原理简析_第2张图片
  2. patchKeyedChildren:因为dom元素存在key值,可以让Vue根据key去判断节点是否是之前存在的(isSameVNodeType函数),这样就可以优化diff算法,不同于unkey从头开始while遍历,这里分为5个不同的while循环,按照从上到下的顺序执行:

    1. 从头部开始遍历,遇到相同的节点就继续,遇到不同的则跳出循环。
    2. 从尾部开始遍历,遇到相同的节点就继续,遇到不同的就跳出循环。
    3. 如果最后新节点更多,就添加新节点。
    4. 如果旧节点更多,就移除旧节点。
    5. 如果中间存在不知道如何排列的位置序列,那么就使用key建立索引图,最大限度的使用旧节点。
    mini Vue的实现 Vue工作原理简析_第3张图片

    下图是一种比较极端的情况,会使用到第五个while的情况:

    mini Vue的实现 Vue工作原理简析_第4张图片

以上diff这部分提到的api可以参见vue3源码,此链接会导航至vue-next/package/runtime-core/src/renderer.js第1621行。renderer.ts — vuejs/vue-next — GitHub1s

为什么Vue3选择Proxy?

  1. Object.definedProperty是劫持对象的属性,如果新增元素,就要再调一次Object.definedProperty,而Proxy劫持的是整个对象,即便是新增元素也不需要做特殊处理。
  2. Proxy能观察到的类型比definedProperty更丰富,比如:Proxy有has,就可以捕获in操作符;Proxy有deleteProperty,可以捕获到delete操作符。
  3. 需要注意的是,使用defineProperty时,修改原来的obj对象就可以触发拦截,而使用Proxy时,就必须修改代理对象,即Proxy实例才可以触发拦截,其实这在真实开发中并不会影响什么。如果要说缺点,Proxy不兼容IE,definedProperty可以支持到IE9,这也是Vue3不支持IE的原因。

三大系统协作

mini Vue的实现 Vue工作原理简析_第5张图片

Mini Vue

分三个模块:渲染模块、响应式模块、应用程序入口模块。

渲染模块

该模块实现3个api:

  1. h函数:生成VNode对象,其实只是一个js对象。
  2. mount函数:将VNode挂载到真实dom上。使用document.createElement创建HTML元素,存储到VNode的el中,然后将传入的props通过setAttribute添加到元素上,最后递归调用mount处理子节点。
  3. patch函数:比较两个VNode,决定如何处理VNode,这里不考虑有key的情况。会分两部分判断,先判断是不是相同的节点,若不同则删除旧节点添加新节点,若相同再去遍历处理props和children
    1. n1和n2是不同类型的节点:
      1. 找到n1的el父节点,删除原来的n1节点的el;
      2. 挂载n2节点到n1的el父节点上;
    2. n1和n2节点是相同的节点:
      1. 处理props的情况:
        1. 先将新节点的props全部挂载到el上;
        2. 判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性;
      2. 处理children的情况:
        1. 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;
        2. 如果新节点不同一个字符串类型:
          1. 旧节点是一个字符串类型:
            1. 将el的textContent设置为空字符串;
            2. 旧节点是一个字符串类型,那么直接遍历新节点,挂载到el上;
          2. 旧节点也是一个数组类型:
            1. 取出数组的最小长度;
            2. 遍历所有的节点,新节点和旧节点进行patch操作;
            3. 如果新节点的length更长,那么剩余的新节点进行挂载操作;
            4. 如果旧节点的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。

这里使用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 });

// 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>

效果展示

mini Vue的实现 Vue工作原理简析_第6张图片

你可能感兴趣的:(前端,vue.js,javascript,前端)