vue 源码解析+手写 (vue2.x实现)

399 阅读4分钟

前言

在上个版本中我们实现了点对点的数据响应更新,但是随之而来的内存消耗是极大问题,2x版本增加了虚拟dom和diff算法优化节点创建和更新,这里就来简单的实现一下。

全局变量

数组拦截需要使用

    const originProto = Array.prototype;
    const arrayProto = Object.create(originProto); // 备份数组原型

vue核心类

核心类中这次多了许多函数,丢弃了之前的Compile类

  1. constructor 实例化组件时执行的函数
  2. $mount 组件挂载
  3. $createElement 创建虚拟dom
  4. _update 更新
  5. __patch__ 创建dom并挂载
  6. updateChildren diff算法
  7. createElm 创建真实dom

constructor

实例化时的初始操作

  constructor(options) {

    this.$options = options; // 保存传入的选项
    this.$data = options.data; // 保存传入的响应式数据

    observe(this.$data); // 将数据进行响应式监听

    proxy(this, "$data"); // 数据代理

    if (options.el) this.$mount(options.el) // 挂载

  }

$mount

  $mount(el) {

    // 获取宿主元素
    this.$el = document.querySelector(el);

    // 组件更新函数
    const updateComponent = () => {
      const { render } = this.$options; // 在vue中有编译器自动转换了dom结构,这里就简化实现,直接自己传入转换后的render
      const vnode = render.call(this, this.$createElement); // 设置上下文为vue实例 传入参数 createElement 也就是vue源码中常见的 h() 函数;
      console.log("vnode", vnode);
      this._update(vnode); // vnode 转换为 dom
    }

    new Watcher(this, updateComponent); // 创建组件对应的watcher实例 也就是一个watcher对应一个组件级别的渲染函数
  }

$createElement

  $createElement(tag, data, children) { // 创建虚拟dom
    return { tag, data, children }
  }

_update

  _update(vnode) {

    const prevVnode = this._vnode;
    if (!prevVnode)  this.__patch__(this.$el, vnode) // 不存在就是初始化
    else this.__patch__(prevVnode, vnode) // 否则就是更新 传入新老虚拟dom进行比较
    this._vnode = vnode; // 保存vnode,这时候的vnode 有了el属性与真实dom元素关联

  }

__patch__

  __patch__(oldVnode, vnode) { // 更新或创建dom并挂载

    if (oldVnode.nodeType) { // 如果是真实节点,那么是初始化

      const parent = oldVnode.parentElement; // 获取oldVnode(el)的父节点
      const refElm = oldVnode.nextSibling; // 获取oldVnode(el)的旁边的位置
      const el = this.createElm(vnode); // 创建真实dom元素,并与虚拟dom关联
      parent.insertBefore(el, refElm); // 追加到父节点参考元素的旁边
      parent.removeChild(oldVnode); // 删除不需要的旧元素,也就是el根元素

    } else {

      // diff update
      const el = oldVnode.el; // 获取之前保存在vnode中的真实dom
      vnode.el = el; // 新的vnode 下一次就变成旧的了 所以这里也要保存
      
      // props update todo 以后再做

      // 如果标签相同 那么就是节点子元素更新
      if (oldVnode.tag === vnode.tag) {
        const oldCh = oldVnode.children;
        const newCh = vnode.children;
        if (typeof newCh === "string") {
          if (typeof oldCh === "string") {
            // 双方都是文本
            if (newCh !== oldCh) el.textContent = newCh;
          } else {
            // old是数组,new是字符串
            el.textContent = newCh;
          }
        } else {
          if (typeof oldCh === "string") {
            // old是文本 new是数组
            el.innerHTML = ""; // 清空之前的内容
            newCh.forEach(child => {
              el.appendChild(this.createElm(child)); // 递归创建新节点的子元素并追加
            })
          } else {
            // 重排
            this.updateChildren(el, oldCh, newCh);
          }
        }

      } else {
        // replace todo
      }
    }
  }

updateChildren

diff 算法 简易版,这里没有使用vue源码中的头尾游标两两移动,而是直接进行强制下标更新, 如果长度增加则创建,减少则删除。这样更能理清思绪。

  updateChildren(parentElm, oldCh, newCh) {

    // 直接更新相同索引的两个节点,如果相同索引节点相等那么更新,不相等那么删除旧的 创建新的,新的添加到el中
    const len = Math.min(oldCh.length, newCh.length);
    for (let i = 0; i < len; i++) {
      this.__patch__(oldCh[i], newCh[i]);
    }

    // 如果newCh更长 说明是新增。
    if (newCh.length > oldCh.length) {
      newCh.slice(len).forEach(child => {
        const el = this.createElm(child); // 创建
        parentElm.appendChild(el); // 追加
      })
    } else if (newCh.length < oldCh.length) { // 删减
      oldCh.slice(len).forEach(chlid => {
        parentElm.removeChild(chlid.el); // 删除
      })
    }

  }

createElm

  createElm(vnode) {

    const el = document.createElement(vnode.tag); // 创建元素

    // props 留待以后实现

    if (vnode.children) { // 如果有子节点
      if (typeof vnode.children === "string") el.textContent = vnode.children; // 子节点为文本
      else { // 子节点为元素
        vnode.children.forEach(v => {
          el.appendChild(this.createElm(v)); // 递归创建子节点,并追加到父节点里面 
        })
      }
    }

    vnode.el= el; // 建立虚拟dom和节点元素之间的关系 未来更新需要使用
    return el; // 返回创建的dom节点
  }

Observer类

与1x版本没有变化

  class Observer { // 执行数据响应化(分辨数据是对象还是数组)
    constructor(obj) {

      if (Array.isArray(obj)) {
        obj.__proto__ = arrayProto; // 原型拦截
        this.arrayCover(obj);
      } else {
        this.walk(obj);
      }

    }

    walk(obj) {
      Object.keys(obj).forEach(key => {
        const value = obj[key];
        defineReactive(obj, key, value);
      })
    }

    arrayCover(obj) {

      const methods = ["push", "pop", "shift", "unshift"];

      methods.forEach(method => {
        // 覆盖操作
        arrayProto[method] = function() {
          originProto[method].apply(this, arguments); // 执行原来数组本身操作
          Dep.update.notify(); // 通知更新
          Dep.update = null; // 清空
        }
      })

      // 这里接收的obj是一个数组,那么数组里面可能也有对象或者数组,所以要继续递归遍历
      const keys = Object.keys(obj);
      keys.forEach(key => observe(obj[key]));

    }
  }

Watcher类

与1x版本相比,2x使用了一个组件一个watcher级别的渲染,所以这里会保存组件对应的渲染函数,当数据发生响应式变化时直接调用该函数进行更新

  class Watcher { // 每一个组件对应一个watcher实例,用于更新组件
    
    constructor(vm, componentRender) {
      this.vm = vm; // vm实例
      this.updaterFn = componentRender; // 对应组件的更新函数
      this.get(); // 触发依赖收集并且建立dep和watcher的关系   
    }

    get() {
      Dep.target = this; // 保存当前需要监听的watcher,在defineReactive进行精确赋值
      this.updaterFn(); // 执行渲染,渲染函数会访问响应式数据,然后触发依赖收集
      Dep.target = null; // 等关系建立完成之后,重新置空,等待下一个watcher的建立,直至数据全部绑定完成
    }

    update() { // 将来dep会调用
      this.updaterFn.call(this.vm);
    }

  }

Dep类

dep也发生了一点小变化,因为是每个组件一个watcher,所以同一个watcher只需入队一次

  class Dep { // 保存watcher实例的依赖类,因为一个属性可能有多个使用的地方,所以一次要更新多个
    constructor() {
      this.deps = new Set(); // watcher 只需入队一次,所以用set结构
    }

    addDep(watcher) {
      this.deps.add(watcher); // 保存组件对应的wathcer
    }

    notify() {
      this.deps.forEach(watcher => watcher.update()) // 遍历执行每个wather的内部更新函数,让dom视图更新
    }
  }

defineReactive

与1x版本没有变化

  function defineReactive(obj, key, value) {

    observe(value); // 如果监听的是一个对象,再次进行递归处理

    // 因为每个响应式数据都会走这个函数,所以在这里实例化dep
    const dep = new Dep(); // 这里实例化的每个dep由于闭包关系 所以会和接收到的 key 进行一一对应。

    Object.defineProperty(obj, key, {
      get() {

        console.log(`访问属性: ${key} => ${ value }`);

        Dep.update = dep; // 当对数组进行push pop等操作时 会先触发 get 但是无法触发 set 导致视图不更新,这里保存一个对应的dep用于更新
        
        Dep.target && dep.addDep(Dep.target); // 依赖关系收集 传入的是当前数据对应的watcher实例。

        return value;
      },
      set(newVal) {
        if (newVal !== value) {

          value = newVal;
          console.log(`设置属性: ${key} => ${ newVal }`);

          observe(newVal); // 如果新值是对象,那么给这个对象添加数据响应式
          
          dep.notify(); // 更新dep下收集到的所有对应key的
        }
      }
    })
  }

observe

与1x版本没有变化

  function observe(obj) { // 观察者
    if (typeof obj !== "object" || obj === null) return;
    new Observer(obj);
  }

proxy

与1x版本没有变化

  function proxy(vm, agentPropertyName) { // 代理vm下的数据,如 vm.$data.name 映射成 vm.name 可以进行正确的访问
    
    const watchData = vm[agentPropertyName];

    Object.keys(watchData).forEach(key => {
      Object.defineProperty(vm, key, {
        get() {
          return watchData[key];
        },
        set(newVal) {
          watchData[key] = newVal;
        }
      })
    })

  }
  window.Vue = Vue

html页面示例

这里只实现了简单的文本读取示例,手动创建了一个render函数传入,因为源码级别中需要处理的东西太多(且源码有对应的编译器),不拆分反而不好阅读,所以这里实现的主要是主体思路。

  <!DOCTYPE html>
  <html lang="">
    <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">
    </head>
    <body>
      <div id="app">
        {{ address }}
      </div>
    </body>
    <script src="./vue/index2x.js">
      
    </script>
    <script>
      const vm = new Vue({
        el: "#app",
        data: {
          address: "成都"
        },
        render: function(h) {
          console.log("this", this);
          return h("h1", null, [
            h("p", null, this.address),
            h("p", null, this.address),
            h("p", null, this.address)
          ])
        },
        methods: {

        }
      })
      setTimeout(() => {
        vm.address = "成都 武侯"
      },1000)

    </script>
  </html>

源码中的updateChildren

vue2x中虚拟 dom diff 的完整过程 头尾比较,指针往中间移动,一旦新节点或者旧节点指针重合结束比对,进行扫尾工作,如果老的游标重合,代表是批量增加新的vnode树中剩下的节点。如果新的游标重合,代表删除,此时删除旧树中需要删除的节点。

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 四个游标
    // 以及对应节点
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    // 后续查找需要的变量
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    // 开始循环:指针不能重合
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 前两个条件是校正工作,移动操作可能导致游标对应的位置变空,需要调整一下
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 首尾没有找到
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 新节点在老数组中的位置
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          // 没有则创建
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 有则更新
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 递归更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 非相同节点直接替换
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // 有游标重合
    // 扫尾工作
    if (oldStartIdx > oldEndIdx) {
      // 老的结束,新的如果有剩下,批量创建
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 新的结束,如果老的又剩下,批量删除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
  
  
   function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    if (oldVnode === vnode) {
      return
    }

    if (isDef(vnode.elm) && isDef(ownerArray)) {
      // clone reused vnode
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = vnode.elm = oldVnode.elm

    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }

    // reuse element for static trees.
    // note we only do this if the vnode is cloned -
    // if the new node is not cloned it means the render functions have been
    // reset by the hot-reload-api and we need to do a proper re-render.
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
      i(oldVnode, vnode)
    }

    // 获取双方children
    const oldCh = oldVnode.children
    const ch = vnode.children
    // 属性更新
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // 新节点没有文本
    if (isUndef(vnode.text)) {

      // 双方都有子元素
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 文本更新
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

结语

Vue2.x降低watcher粒度,引⼊VNode和Patch算法,⼤幅提升了vue在⼤规模应⽤中的适⽤性、扩平台 的能⼒和性能表现,是⼀个⾥程碑版本。但是同时也存在⼀定问题:

  1. 数据响应式实现在性能上存在⼀些问题,对象和数组处理上还不⼀致,还引⼊了额外的API。
  2. 没有充分利⽤预编译的优势,patch过程还有不少优化空间。
  3. 响应式模块、渲染器模块都内嵌在核⼼模块中,第三⽅库扩展不便。
  4. 静态API设计给打包时的摇树优化造成困难。
  5. 选项式的编程⽅式在业务复杂时不利于维护。
  6. 混⼊的⽅式在逻辑复⽤⽅⾯存在命名冲突和来源不明等问题