美文网首页moonjs
深入浅出MV*框架源码(八):实现一个最简版 Vue

深入浅出MV*框架源码(八):实现一个最简版 Vue

作者: 云峰yf | 来源:发表于2018-03-23 18:59 被阅读0次

    前言

    由于当今版本的 vue 源码太复杂,所以我们只会挑一些它的核心部分来分析。在这之前,先实现一个最简单的自制版 Vue,然后再它的基础上考虑如何解决数据响应式变更、指令解析、生命周期钩子、模板编译等技术痛点,通过与 Vue 真正的源码对比从而得到我们的答案。

    从一个例子开始

    我们这个最简版的 Vue 能实现什么功能呢?

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <title>自制版 Vue</title>
    </head>
    <body>
      <div id="app">
        <h2>{{title}}</h2>
        <input v-model="content">
        <h1>{{content}}</h1>
        <button v-on:click="clickMe">事件绑定</button>
      </div>
      <script src="js/vue.js"></script>
      <script type="text/javascript">
        new Vue({
          el: '#app',
          data: {
            title: '自制 vue',
            content: '这是双向绑定的模板内容',
          },
          methods: {
            clickMe () {
              this.title = '点击后的标题'
            },
          },
          mounted () {
            setTimeout(() => {
              this.title = 'mounted 后的标题'
            }, 1000)
          },
        })
      </script>
    </body>
    </html>
    

    它可以实现实例化 Vue, v-model 的双向绑定和 v-on 的事件绑定,并支持 mounted 这个生命周期钩子。

    准备工作

    这个自制版 Vue 的核心功能其实就两个:

    1. Vue 构造函数
    2. 响应式数据更新系统

    所以我们需要:

    1. 一个 Vue 类(es5 版本的构造函数也行)
    class Vue {
      constructor (options) {
    
      }
    }
    

    它会接收我们之前传的那个对象作为选项,给 Vue 实例做一些初始化工作。

    1. 响应式数据更新系统的三大构造函数
      Vue 的响应式数据更新系统本质上是利用了观察者模式,通过 Observe、Dep、Watcher 三者实现的。具体原理我们可以通过类比一个例子来说明。

    很久以前,在一个叫知乎的地方也存在一个响应式数据更新系统:轮带逛

    轮带逛.png

    从图中我们可以看得出来:

    1. 当一个作者写了若干文章后,会被轮子哥收藏进收藏夹。
    2. 吃瓜群众既可以主动浏览收藏夹内容,也可以等收藏夹更新内容后推送给他看。
    3. 吃瓜群众看完文章后可以做一些自己想做的事,比如,写个读后感。

    我们再来看 Vue 的响应式数据更新系统:


    同样:

    1. 当一个 Vue 实例设置了若干 data 后,会被 Observe 加入到 Dep 中。
    2. Watcher 既可以主动 get Dep 内容,也可以等 Dep 更新内容后 notify 它。
    3. Watcher get 完 Dep 后可以 update 用户自己想执行的回调函数,比如,更新个 HTML。

    所以,在这,我们需要三个构造函数:

    class Observer {
      constructor (data) {
    
      }
    
      observe () {
    
      }
    }
    
    class Dep {
      constructor () {
    
      }
    
      addSub () {
    
      }
    
      notify () {
    
      }
    }
    
    class Watcher {
      constructor () {
    
      }
    
      get () {
    
      }
    
      update () {
    
      }
    }
    

    另外提一句:观察者模式和事件系统所采用的订阅-发布模式是不一样的:链接

    Vue 类

    我们需要解决这几个问题:

    1. 拿到传入的选项参数后怎么利用?
      答:挂载为实例属性:
    this.data = options.data
    this.methods = options.methods
    

    补充:真实 Vue 对选项参数的处理---mergeOptions

    1. 怎样实现实例和 data 对象间的关联?比如this.name = this.data.name
      答:将 data 对象和实例对象建立代理关系,也就是说访问实例对象属性时其实是访问了 data 对象的属性:
    Object.keys(this.data).forEach(key => {
        Object.defineProperty(this, key, {
          enumerable: false,
          configurable: true,
          get () {
            return this.data[key]
          },
          set (val) {
            this.data[key] = val
          }
        })
    })
    

    这里我们需要把实例的这些代理键的可枚举设为 false,因为我们不想在遍历实例属性的时候把 data 的属性也获取到了。

    1. 怎么使用响应式数据更新系统?
      答:把 data 交给 Observe 去做接下来的事情:
    new Observer(this.data).observe()
    
    1. 怎么将数据编译成 HTML?
      答:交给专门编译类(Compile)和渲染类(Render)去做,这里我们应该把当前挂载的 DOM 元素和加入响应式数据更新系统的实例给它们:
    new Compile(options.el, this)
    

    由于我们这个版本不涉及到 vdom 的部分,所以先略去 Render 部分。

    1. 怎么调用生命钩子的回调?
      答:调用用户在创建实例时传入的钩子函数:
    options.mounted.call(this)
    

    补充: 真实 Vue 构造实例过程

    完整代码

    class Vue {
      constructor (options) {
        // 挂载为实例属性
        this.data = options.data
        this.methods = options.methods
    
        // 将 data 对象和实例对象建立代理关系
        this.initProxy()
    
        // 使用响应式数据更新系统
        new Observer(this.data).observe()
    
        // 将数据编译成 HTML
        new Compile(options.el, this)
    
        // 调用生命钩子的回调
        options.mounted.call(this)
      }
    
      initProxy () {
        Object.keys(this.data).forEach(key => {
          this.proxyKeys(key)
        })
      }
    
      proxyKeys (key) {
        Object.defineProperty(this, key, {
          enumerable: false,
          configurable: true,
          get () {
            return this.data[key]
          },
          set (val) {
            this.data[key] = val
          }
        })
      }
    }
    

    Observe 类

    我们需要解决这几个问题:

    1. new Observe 具体做了什么?
      答:在构造函数中将数据加入到响应式数据更新系统中
    this.data = data
    this.walk(data)
    
    1. observe 方法做了什么?
      答:给非空数据分配一个 Observe 实例:
    observe (value) {
        if (value === null || typeof value !== 'object') {
          return
        }
        return new Observer(value)
    }
    
    1. 怎么确保 data 每个属性都被侦测到了?在什么时机将数据添加到 dep 里去?在什么时机让 dep notify watcher?
      答:在 walk 方法中将 data 的每个的属性都加入响应系统中。在 get 数据时将数据添加到 dep 里去,在 set 数据时让 dep notify watcher。
    Object.keys(data).forEach(key => {
        let val = data[key]
        // 创建一个迎接 data 的 Dep 实例
        let dep = new Dep()
        // 嵌套观察
        let subVal = this.observe(val)
        // 建立 data 和 dep 的联系
        Object.defineProperty(data, key, {
          enumerable: true,
          configurable: true,
          get () {
            // 无则加
            if (Dep.target != null) {
              dep.addSub(Dep.target)
            }
            return val
          },
          set (newVal) {
            if (newVal === val) return
            // 有则改
            val = newVal
            dep.notify(newVal)
          }
        })
    })
    

    这里我们还是选择遍历 data,然后给每个键值对创建一个 Dep 实例,并给它们建立之前图中的联系。

    值得注意的是,我们需要给 data 的每层数据都进行 observe。

    补充:数组类型的数据如何进行 observe

    完整代码

    class Observer {
      constructor (data) {
        this.data = data
        this.walk(data)
      }
    
      observe (value) {
        if (value === null || typeof value !== 'object') {
          return
        }
        return new Observer(value)
      }
    
      walk (data) {
        Object.keys(data).forEach(key => {
          this.defineReactive(data, key, data[key])
        })
      }
    
      defineReactive (data, key, val) {
        // 创建一个迎接 data 的 Dep 实例
        let dep = new Dep()
        // 嵌套观察
        let subVal = this.observe(val)
        // 建立 data 和 dep 的联系
        Object.defineProperty(data, key, {
          enumerable: true,
          configurable: true,
          get () {
            // 无则加
            if (Dep.target != null) {
              dep.addSub(Dep.target)
            }
            return val
          },
          set (newVal) {
            if (newVal === val) return
            // 有则改
            val = newVal
            dep.notify(newVal)
          }
        })
      }
    }
    

    Dep 类

    我们需要解决这几个问题:

    1. 怎么知道自己被哪些 Watcher 订阅了?也就是 addSub 的具体过程?
      答:创建一个 subs 数组保存:
    addSub (sub) {
      this.subs.push(sub)
    }
    
    1. 数据更新后怎么让 Watcher 也知道?也就是 notify 的具体过程?
      答:调用所有 watcher 的 update 方法:
    notify () {
      this.subs.forEach(sub => {
        sub.update()
      })
    }
    
    1. 当前应该被加入 subs 数组的 watcher 如何确定?
      答:给 Dep 一个属性 target,用它来标注依赖的 watcher:
    Dep.target = null
    

    在上面 Observer 的代码中,对加入响应式数据更新系统的数据进行 get 操作时,会通过 addSub 方法将 target 指向的 watcher 加入到 subs 中。

    完整代码

    class Dep {
      constructor () {
        // 订阅者集合
        this.subs = []
      }
    
      // 添加订阅者
      addSub (sub) {
        this.subs.push(sub)
      }
    
      // 通知订阅者
      notify () {
        this.subs.forEach(sub => {
          sub.update()
        })
      }
    }
    
    // 初始化依赖
    Dep.target = null
    

    Watcher 类

    我们需要解决这几个问题:

    1. Watcher 实例有哪些属性?
      答:从之前我们画的图可以看出,它需要当前实例对象、当前从 dep 中 get 到的值、update 需要的新值(可能是一个表达式,比如三元表达式)、update 需要的回调函数。
    constructor (vm, exp, cb) {
      this.cb = cb   
      this.vm = vm
      this.exp = exp
      this.value = this.get()
    }
    
    1. get 的具体过程是什么样的?
      答:让 Dep 的 target 指向自己,并获取实例上的数据:
    get () {
      // enter
      Dep.target = this
      let value = this.vm[this.exp]
      // leave
      Dep.target = null
      return value
    }
    
    1. update 具体要怎么更新 HTML?
      答:获取旧的数据和新的数据,然后借助一个回调函数进行更新:
    update () {
      let value = this.vm[this.exp]
      let oldValue = this.value
      // 更新 
      if (value !== oldValue) {
        this.value = value
        // 给回调函数绑定作用域
        this.cb.call(this.vm, value, oldValue)
      }
    }
    

    完整代码

    class Watcher {
      constructor (vm, exp, cb) {
        this.cb = cb
        this.vm = vm
        this.exp = exp
        this.value = this.get()
      }
    
      // 订阅后才能 get
      get () {
        // enter
        Dep.target = this
        let value = this.vm[this.exp]
        // leave
        Dep.target = null
        return value
      }
    
      // 观察者自己的行为
      update () {
        let value = this.vm[this.exp]
        let oldValue = this.value
        // 更新
        if (value !== oldValue) {
          this.value = value
          // 给回调函数绑定作用域
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
    

    补充:真实的 Vue 是通过 VDOM 更新 HTML 的。Vue VIrtualDOM 介绍

    Compile 类

    这是我们这个最简版代码的最后一个类,它不属于数据响应式更新系统,但它是使用这个系统的用户。

    我们需要解决这几个问题:

    1. 在什么时机使用数据响应式更新系统?怎么使用?
      答:获取当前数据后。实例化一个 Watcher 观察此数据:
    new Watcher(this.vm, exp, (val) => {
        // 更新 DOM
    })
    
    1. 如何操作 DOM 节点?
      答:先创建节点、再加工节点,最后使用节点:
    // 创建节点
    this.fragment = this.nodeToFragment(this.el)
    // 加工节点
    this.compileElement(this.fragment)
    // 使用节点
    this.el.appendChild(this.fragment)
    

    在创建节点这方面我们为了省事,选择了创建 fragment,而不是根据标签和 createElement 创建:

    nodeToFragment (el) {
      let fragment = document.createDocumentFragment()
    
      // 将 DOM 元素移入 fragment 中
      let child = el.firstChild
      while (child) {
        fragment.appendChild(child)
        child = el.firstChild
      }
    
      return fragment
    }
    
    1. 如何编译插值表达式?
      答:首先从模板中剥离出插值表达式,然后给表达式求值,之后更新 DOM
    // 匹配插值表达式的正则
    let reg = /\{\{(.*)\}\}/
    let text = node.textContent
    
    if (this.isTextNode(node) && reg.test(text)) {
        // 剥离出插值表达式
        let exp = reg.exec(text)[1]
        // 表达式求值
        let text = this.vm[exp]
        this.updateText(node, text)
        // 使用数据响应系统
        new Watcher(this.vm, exp, (val) => {
          this.updateText(node, val)
        })
    }
    
    1. 如何编译 v-on 指令?
      答:首先从模板中剥离出 v-on 指令、事件名和事件回调函数,然后给 DOM 元素添加事件监听:
    if (this.isEventDirective(directive)) {
        let dir = directive
        // 获取事件名和回调函数
        let eventName = dir.split(':')[1]
        let cb = null
        if (vm.methods) {
          cb = vm.methods[exp]
        }
        // 添加事件监听
        if (eventName && cb) {
          node.addEventListener(eventName, cb.bind(vm), false)
        }
    }
    
    1. 如何编译 v-model 指令?
      答:首先从模板中剥离出 v-moel 指令和表达式,并给表达式求值,然后给 DOM 元素添加事件监听(我们只考虑 input 元素的 input 事件):
    if (this.isModelDirective(directive)) {
        // 数据->html
        let val = this.vm[exp]
        this.modelUpdater(node, val)
        new Watcher(this.vm, exp, value => {
          this.modelUpdater(node, value)
        })
    
        // html 事件->数据
        node.addEventListener('input', (e) => {
          let newValue = e.target.value
          if (val === newValue) {
            return
          }
          this.vm[exp] = newValue
          val = newValue
        })
    }
    
    1. 如何更新 node 节点?
      答:如果是文本的话,只需要修改 DOM 节点的 textContent 属性,如果是 input 元素的话,需要修改它的 value 属性:
    updateText (node, value) {
      node.textContent = typeof value === 'undefined' ? '' : value
    }
    
    modelUpdater (node, value, oldValue) {
      node.value = typeof value === 'undefined' ? '' : value
    }
    

    完整代码

    class Compile {
      constructor (el, vm) {
        this.vm = vm
        this.el = document.querySelector(el)
        this.fragment = null
        this.init()
      }
    
      init () {
        if (this.el) {
          // 创建节点
          this.fragment = this.nodeToFragment(this.el)
          // 加工节点
          this.compileElement(this.fragment)
          // 使用节点
          this.el.appendChild(this.fragment)
        } else {
          throw Error('DOM 元素未找到!')
        }
      }
    
      nodeToFragment (el) {
        let fragment = document.createDocumentFragment()
    
        // 将 DOM 元素移入 fragment 中
        let child = el.firstChild
        while (child) {
          fragment.appendChild(child)
          child = el.firstChild
        }
    
        return fragment
      }
    
      compileElement (el) {
        let childNodes = Array.from(el.childNodes)
        childNodes.forEach(node => {
          // 匹配插值表达式的正则
          let reg = /\{\{(.*)\}\}/
          let text = node.textContent
    
          // 细粒度绑定
          if (this.isElementNode(node)) {
            this.compile(node)
          } else if (this.isTextNode(node) && reg.test(text)) {
            this.compileText(node, reg.exec(text)[1])
          }
    
          // 递归处理子节点
          if (node.childNodes != null && node.childNodes.length) {
            this.compileElement(node)
          }
        })
      }
    
      compile (node) {
        let attrs = Array.from(node.attributes)
        attrs.forEach(attr => {
          let attrName = attr.name
          // 编译指令
          if (this.isDirective(attrName)) {
            let expression = attr.value
            let directive = attrName.substring(2)
            // v-on
            if (this.isEventDirective(directive)) {
              this.compileEvent(node, this.vm, expression, directive)
            }
            // v-model
            else {
              this.compileModel(node, this.vm, expression, directive)
            }
            node.removeAttribute(attrName)
          }
        })
      }
    
      compileEvent (node, vm, exp, dir) {
        // 获取事件名和回调函数
        let eventName = dir.split(':')[1]
        let cb = null
        if (vm.methods) {
          cb = vm.methods[exp]
        }
        // 添加事件监听
        if (eventName && cb) {
          node.addEventListener(eventName, cb.bind(vm), false)
        }
      }
    
      compileModel (node, vm, exp, dir) {
        // 数据->html
        let val = this.vm[exp]
        this.modelUpdater(node, val)
        new Watcher(this.vm, exp, value => {
          this.modelUpdater(node, value)
        })
    
        // html 事件->数据
        node.addEventListener('input', (e) => {
          let newValue = e.target.value
          if (val === newValue) {
            return
          }
          this.vm[exp] = newValue
          val = newValue
        })
      }
    
      modelUpdater (node, value, oldValue) {
        node.value = typeof value === 'undefined' ? '' : value
      }
    
      compileText (node, exp) {
        let text = this.vm[exp]
        // 先更新一次文本
        this.updateText(node, text)
        // 使用数据响应系统
        new Watcher(this.vm, exp, (val) => {
          this.updateText(node, val)
        })
      }
    
      updateText (node, value) {
        node.textContent = typeof value === 'undefined' ? '' : value
      }
    
      isDirective (attr) {
        return attr.indexOf('v-') === 0
      }
    
      isEventDirective (dir) {
        return dir.indexOf('on:') === 0
      }
    
      isElementNode (node) {
        return node.nodeType === 1
      }
    
      isTextNode (node) {
        return node.nodeType === 3
      }
    }
    

    可以看出,我们将第2个问题的答案封装成了 init、nodeToFragment 函数,第3/4/5个问题的答案封装成了 compileElement、compile、compileText、compileEvent、compileModel 和 isDirective、isEventDirective、isElementNode、isTextNode 函数,将第6个问题的答案封装成了 updateText 、modelUpdater 函数。

    结语

    完整版代码预览地址
    模仿是学习的方法之一,通过自己亲手创造一个 Vue,虽然是玩具级别的,但也能体会很多。下一章我们将使用一个 Vue 实例在真实的 Vue 里遨游,结合我们自制版的找出它那些令人惊叹的设计和实现。

    补充:
    vue 源码学习

    相关文章

      网友评论

        本文标题:深入浅出MV*框架源码(八):实现一个最简版 Vue

        本文链接:https://www.haomeiwen.com/subject/lvlvqftx.html