Vue2.x 原理剖析(二)之手写一个简版Vue

237 阅读9分钟

前言

​在上一篇文章Vue2.x 响应式原理剖析(一)我们已经搞清楚了数据响应式的原理,那么今天不妨就让我们利用上次的实现来造一个简版的Vue吧!

创建一个 MVVM 类

// TVue.js
/**
* @desc: TVue 是一个MVVM 类,也是我们自己手写的简版 Vue
*	@params {} options 实例创建时传入的选项
*/
class TVue {
  constructor (options) {
    this.$options = options
    this.$data = options.data
  }
}

TVue 在创建的时候需要做两件事:

  • 对传入的数据做响应式处理;
  • 编译模版将结果渲染
class TVue {
  constructor (options) {
    this.$options = options
    this.$data = options.data
 		// 1.响应式实现
    observe(this.$data)
 		// 2.编译
    if (options.el) {
      this.$mount(options.el)
    }
  }
}

⚠️我们平时在使用data属性值的时候为什么可以直接通过this.xxx访问,而无需通过 this.data.xxx来访问呢?

这是因为Vue 源码里做了代理,将vm实例的data属性值直接代理到了vm实例上

这里我们也可以学习源码来实现一个代理方法

function proxy (vm) {
  Object.keys(vm.$data).forEach(key=>{
    Object.defineProperty(vm, key, {
      get() {
        return vm.$data[key]
      },
      set(v) {
        vm.$data[key] = v
      }
    })
  })
}
// 此时 我们的 TVue 应该如下:
class TVue {
  constructor (options) {
    this.$options = options
    this.$data = options.data
 		// 1.响应式实现
    observe(this.$data)
    // 1.5 为$data做代理
    proxy(this)
 		// 2.编译
    if (options.el) {
      this.$mount(options.el)
    }
  }
}

observe 方法实现

通过上篇文章的分享,Vue2.x 中利用了JS语言特性 Object.defineProperty(),通过定义对象属性getter/setter拦截对属性的访问,下面让我们来回顾下observe 方法的创建和功能吧

function observe(obj) {
  if (typeof obj !== 'object' || typeof === null) {
 		// 如果传入的数据不是对象或者为 null 不做操作,直接返回   
    return 
  }
  // 只要是对象,就创建一个伴生的 Observer 实例
  new Observer(obj)
}
// Observer对象根据数据类型执行对应的响应化操作 
// defineReactive定义对象属性的getter/setter
// getter负责添加依赖,setter负责通知更新
class Observer {
  constructor(options) {
    if (Array.isArray(obj)) {
      // todo: 数组有特殊处理,可在源码 Array.js 查阅 这次我们先不做数组响应式的处理
    } else {
      this.walk(options)
    }
  }
  walk(obj) {
	 	// 遍历对象的所有属性并对其做响应式处理   
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key]
    })
  }
}            
function defineReactive (obj, key, val) {
	// 向下递归遍历
  observe(val)
	Object.defineProperty(obj, key, {
    get () {
      // todo: 依赖收集
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal
        // 解决赋的值是对象的情况(譬如test.foo={f1: 666})
        observe(val)
         // todo: 派发订阅
      }
    }
  })
}

Watcher 和 Dep 的创建

1. Watcher

  • 依赖收集后保存在deps里

  • 变动的时候deps作为发布者通知watcher

  • watcher进行回调渲染

    class Watch {
      // expOrFn: 创建 Watcher 实例时传入的渲染函数
      constructor (vm, expOrFn){
        this.vm = vm
        this.getter = expOrFn
        // 触发依赖收集
        this.get()
      }
      get() {
        Dep.target = this
        this.getter.call(this.vm)
        Dep.target = null
      }
      update() {
        // Dep 未来回通知更新
        this.get()
      }
    }
    

2. Dep

  • 发布者,可以订阅多个观察者

  • 收集依赖后会有一个或者多个watcher

  • 一旦有变动便通知所有watcher

    class Dep {
      // 依赖:和响应式对象的key一一对应
      constructor (){
        // 防止重复创建
        this.deps = new Set()
      }
      addDep (watcher) {
        this.deps.add(watcher)
      }
      notify() {
        this.deps.forEach(watcher => watcher.update())
      }
    }
    

3. 关系

  • Dep负责管理一组Watcher,包括watcher实例的增删及通知更新
  • Watcher解析一个表达式并收集依赖,当数值变化时触发回调函数,常用于$watch API和指令中。 每个组件也会有对应的Watcher,数值变化会触发其update函数导致重新渲染

4. 改造 defineReactive 方法---创建 Dep 实例,收集依赖 & 派发订阅

function defineReactive (obj, key, val) {
	// 如果val是对象,需要递归处理之
  observe(val)
  // 创建 Dep 实例
  const dep = new Dep()
	Object.defineProperty(obj, key, {
    get () {
      // 取值触发依赖收集
      Dep.target && dep.addDep(Dep.target)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal
        // 如果newVal是对象,也要做响应式处理
        observe(val)
         // 通知更新
        dep.notify()
      }
    }
  })
}

编译模版,$mount 实现

1. $mount 创建

function $mount (el){
    this.$el = document.createElement(el)
  	// 定义更新函数 实际调用是在lifeCycleMixin中定义的_update和renderMixin中定义的_render
    const updateComponent = ()=> {
      const { render } = this.$options
      // 执行渲染函数,获取vnode
      const vnode = render.call(this, this.$createElement)
      this._update(vnode)
    }
    // 创建一个 watcher 实例
    new Watcher(this, updateComponent)
  }

2. $createElement 和 _update 实现

  • createElement只做一件事:返回虚拟domcreateElement 只做一件事:返回虚拟 dom(createElement 实际就是传递给 render 函数的 h)

    function $createElement(tag, props, children) {
      return { tag, props, children}
    }
    
  • _update 函数 负责更新dom,转换vnode为dom

    function _update(vnode) {
        const prevVnode = this._vnode
        if(!prevVnode) {
          // 初始化
          this.__patch__(this.$el, vnode)
        } else {
          // 更新
          this.__patch__(prevVnode, vnode)
        }
      }
    

3. __ patch__

​ patch是createPatchFunction的返回值,传递nodeOps和modules是web平台特别实现

patch实现

​ 首先进行树级别比较,可能有三种情况:增删改。

  • new VNode不存在就删;

  • old VNode不存在就增;

  • 都存在就执行diff执行更新

    ​ 比较两个VNode,包括三种类型操作: 属性更新、文本更新、子节点更新 具体规则如下:

    1. 新老节点均有****children子节点,则对子节点进行diff操作,调用**updateChildren

    2. ** 如果新节点有子节点而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点。

    3. 新节点没有子节点而老节点有子节点的时候,则移除该节点的所有子节点。

    4. 新老节点都无子节点的时候,只是文本的替换。

    function __patch__(oldVnode, Vnode) {
        // oldVnode 是dom
        if (oldVnode.nodeType) {
          const parent = oldVnode.parentElement
          const refElm = oldVnode.nextSibling
          // 将虚拟 dom 转换成真实 dom,并插入文档中
          const el = this.createElm(vnode)
          parent.insertBefore(el, refElm)
          // 删除老的节点
          parent.removeChild(oldVnode)
        } else {
          // 获取dom
          const el = vnode.el = oldVnode.el
          // 新老节点是标签相同 则比较子节点
          if (oldVnode.tag === vnode.tag) {
            const oldCh = oldVnode.children
            const newCh = vnode.children
            /**
             * 新旧节点 diff 情形
             * 1.新老节点都是string (文本更新)
             * 2.新老节点都是数组(首尾diff)
             * 3.新节点为数组,老节点为string(递归创建dom树)
             * 4.新节点是string, 老节点是数组(直接将新节点赋值给老节点)
             */
            if (typeof newCh === 'string') {
              // 新的为string
              if (typeof oldCh === 'string') {
                // 新老都是string
                if (newCh !== oldCh) {
                  el.textContent = newCh
                }
              } else {
                // 新的是string 老的不是 直接对dom做文本更新操作
                el.textContent = newCh
              }
            } else {
              // 新的为数组
              // 1. 新的是数组,老的为文本(说明新增了子元素,需要递归创建新的dom树)
              if (typeof oldCh === 'string') {
                oldCh.innerHTML = ''
                newCh.forEach(vnode => this.createElm(vnode))
              } else {
                // 2.新老节点都是数组(源码是做首位diff优化算法)
                this.updateChildren(el, oldCh, newCh)
              }
            }
          } else {
            // 不是同一标签 暂时不考虑
          }
        }
        // 保存当前vnode
        this._vnode = vnode
      }
    

4. updateChildren 比对新旧两个VNode的children得出最小操作

  • 执行一个双循环是传统方式,vue中针对web场景特点做了特别的算法优化
  • 在新老两组VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。 当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环
  • 下面是遍历规则:
    1. 当 oldStartVnode和newStartVnode 或者 oldEndVnode和newEndVnode 满足sameVnode,直接将该 VNode节点进行patchVnode即可,不需再遍历就完成了一次循环
    2. 如果oldStartVnode与newEndVnode满足sameVnode。说明oldStartVnode已经跑到了oldEndVnode 后面去了,进行patchVnode的同时还需要将真实DOM节点移动到oldEndVnode的后面
    3. 如果oldEndVnode与newStartVnode满足sameVnode,说明oldEndVnode跑到了oldStartVnode的前 面,进行patchVnode的同时要将oldEndVnode对应DOM移动到oldStartVnode对应DOM的前面。
    4. 如果以上情况均不符合,则在old VNode中找与newStartVnode相同的节点,若存在执行 patchVnode,同时将elmToMove移动到oldStartIdx对应的DOM的前面。
    5. 当然也有可能newStartVnode在old VNode节点中找不到一致的sameVnode,这个时候会调用 createElm创建一个新的DOM节点。
    6. 至此循环结束,但是我们还需要处理剩下的节点。
      • 当结束时oldStartIdx > oldEndIdx,这个时候旧的VNode节点已经遍历完了,但是新的节点还没有。说 明了新的VNode节点实际上比老的VNode节点多,需要将剩下的VNode对应的DOM插入到真实DOM 中,此时调用addVnodes(批量调用createElm接口)。
      • 但是,当结束时newStartIdx > newEndIdx时,说明新的VNode节点已经遍历完了,但是老的节点还有 剩余,需要从文档中删 的节点删除

**⚠️原算法比较复杂,可以直接去源码查阅,以下我们可以实现一个没有经过优化的硬更新操作 **

// 更新孩子
  updateChildren (parentElm, odlCh, newCh) {
    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(vnode=>{
        const el = this.createElm(vnode)
        parentElm.appendChild(el)
      })
    } else if(newCh.length < old.length){
      parentElm.removeChild(vnode.el)
    }
  }

5. createElm 递归创建dom树

createElm(vnode) {
	const el = document.createElement(vnode.tag)
    // 处理props
    if(vnode.props) {
      for(const key in vnode.props) {
        el.setAttribute(key, vnode.props[key])
      }
    }
    // 处理children
    if (vnode.children) {
      // 处理文本
      if(typeof vnode.children === 'string') {
        el.textContent = vnode.children
      } else {
        // 子元素处理
        vnode.children.forEach(vnode=>{
          const child = this.createElm(vnode)
          el.appendChild(child)
        })
      }
      vnode.el = el
      return el
    }
  }

6. 完整版本的 TVue 源码

function defineReactive(obj, key, val) {
  // ! 向下递归遍历
  observe(val)
  // 创建Dep实例
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      console.log(`get ${key}: ${val}`)
      Dep.target && dep.addDep(Dep.target)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        console.log(`set ${key}: ${newVal}`)
        val = newVal
        //! 解决赋的值是对象的情况(譬如test.foo={f1: 666})
        observe(val)
        dep.notify()
      }
    }
  })
}
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  // * 只要obj是对象,就创建一个伴生的Observer实例
  new Observer(obj)

}
function proxy(vm) {
  Object.keys(vm.$data).forEach(key => {
    Object.defineProperty(vm, key, {
      get() {
        return vm.$data[key]
      },
      set(v) {
        vm.$data[key] = v
      }
    })
  })
}
class Observer {
  constructor(options) {
    if (Array.isArray(options)) {
      // todo 数组有特殊处理
    } else {
      this.walk(options)
    }
  }
  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}
class TVue {
  constructor(options) {
    this.$options = options
    this.$data = options.data
    // ! 1.数据响应式
    observe(this.$data)
    // ! 1.5 代理 将data中的所有属性代理到JVue实例上方便用户使用
    proxy(this)
    // ! 2.编译
    // new Compile(options.el, this)
    if (options.el) {
      this.$mount(options.el)
    }
  }
  $mount (el) {
    // 获取宿主元素
    this.$el = document.querySelector(el)
    const updateComponent = () => {
       // 执行渲染函数
      const { render } = this.$options;

      // 真实dom操作版实现
      // const el = render.call(this);
      // const parent = this.$el.parentElement;
      // parent.insertBefore(el, this.$el.nextSibling);
      // parent.removeChild(this.$el);
      // this.$el = el;

      // vnode版本实现
      const vnode = render.call(this, this.$createElement)
      this._update(vnode)
    }
    // 创建一个 Watcher 实例
    new Watcher(this, updateComponent)
  }
  $createElement (tag, props, children) {
    return {
      tag,
      props,
      children
    }
  }
  _update (vnode) {
    const prevVnode = this._vnode
    if (!prevVnode) {
      this.__patch__(this.$el, vnode)
    } else {
      this.__patch__(prevVnode, vnode)
    }
  }
  __patch__ (oldVnode, vnode) {
    // oldVnode是dom
    if (oldVnode.nodeType) {
      const parent = oldVnode.parentElement
      const refElm = oldVnode.nextSibling
      // props
      // children
      const el = this.createElm(vnode)
      parent.insertBefore(el, refElm)
      parent.removeChild(oldVnode)
    } else {
      // update
      // 获取dom
      const el = vnode.el = oldVnode.el
      if (oldVnode.tag === vnode.tag) {
        const oldCh = oldVnode.children
        const newCh = vnode.children

        /**
         * 新旧节点diff
         * 1.新老节点都是string (文本更新)
         * 2.新老节点都是数组(首尾diff)
         * 3.新节点为数组,老节点为string(递归创建dom树)
         * 4.新节点是string, 老节点是数组(直接将新节点赋值给老节点)
         */
        if (typeof newCh === 'string') {
          if(typeof oldCh === 'string') {
            // 新旧节点都是string且值不同 直接更新
            if(newCh !== oldCh) {
              el.textContent = newCh
            }
          } else {
            el.textContent = newCh
          }

        } else {
          // 1. 新的是数组,老的为文本(说明新增了子元素,需要递归创建新的dom树)
          if (typeof oldCh === 'string') {
            // 清空文本
            oldCh.innerHTML = ''
            newCh.forEach(vnode => this.createElm(vnode))
          } else {
            // 2.新老节点都是数组
            this.updateChildren(el, oldCh, newCh)
          }
        }
      }
    }
    this._vnode = vnode
  }
  // 递归创建dom树
  createElm (vnode) {
    const el = document.createElement(vnode.tag)
    // 处理 props
    if (vnode.props) {
      for (const key in vnode.porps) {
        el.setAttribute(key, vnode.props[key])
      }
    }
    // 处理 children
    if (vnode.children) {
      // 处理文本
      if (typeof vnode.children === 'string') {
        el.textContent = vnode.children
      } else {
        // 子元素
        vnode.children.forEach(vnode => {
          const child = this.createElm(vnode)
          el.appendChild(child)
        })
      }
    }
    // vnode 中保存dom
    vnode.el = el
    return el
  }
  // 更新孩子
  updateChildren(parentElm, oldCh, newCh) {
    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(vnode => {
        const el = this.createElm(vnode)
        parentElm.appendChild(el)
      })
    } else if(newCh.length < oldCh.length){
      oldCh.slice(len).forEach(vnode => {
        parentElm.removeChild(vnode.el)
      })
    }
  }
}


// 负责视图更新,与依赖一一对应
class Watcher {
  constructor(vm, expOrFn) {
    this.vm = vm;
    this.getter = expOrFn;
     // 触发依赖收集
    this.get()

  }
  get () {
    Dep.target = this;
    this.getter.call(this.vm)
    Dep.target = null
  }
  // Dep未来会通知更新
  update() {
    this.get()
  }
}
// 依赖:和响应式对象的key一一对应
class Dep {
  constructor() {
    this.deps = new Set();
  }
  addDep(wather) {
    this.deps.add(wather)
  }
  notify() {
    this.deps.forEach(wather => wather.update())
  }
}

总结

以上我们已经创造了一个简单版本的 TVue,实现了数据响应和异步批量更新dom基础功能。

当然 Vue2.x 的强大之处远远不止于此,剩下的就留给大家去源码中去找答案吧!

以下是 Vue 中几个重要概念的简单介绍,也许可以帮助大家更好的理解这篇文章的总体思路

  1. Observer是用来给数据添加Dep依赖。
  2. Dep是data每个对象包括子对象都拥有一个该对象, 当所绑定的数据有变更时, 通过dep.notify()通知Watcher。
  3. Compile是HTML指令解析器,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
  4. Watcher是连接Observer和Compile的桥梁,Compile解析指令时会创建一个对应的Watcher并绑定update方法 , 添加到Dep对象上。

扩展

Vue 源码的学习小技巧

  1. 获取 Vue 源码

    项目地址: github.com/vuejs/vue

  2. 调试环境搭建

    • 安装依赖: npm i 安装phantom.js时即可终止

    • 安装rollup: npm i -g rollup 修改dev脚本,添加sourcemap,package.json

    "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web- full-dev",
    
    • 运行开发命令: npm run dev 引入前面创建的vue.js
  3. 术语解释:

    • runtime:仅包含运行时,不包含编译器
    • common:cjs规范,用于webpack1
    • esm:ES模块,用于webpack2+
    • umd: universal module definition,兼容cjs和amd,用于浏览器