Vue源码系列: Vue

474 阅读7分钟

回顾

源码实现前,先简单回顾Vue。Vue是一款响应式数据框架,它可以构成MVVM框架项目,即视图与业务逻辑分离,然后用数据模型操作视图,这样就可以避免大量的DOM操作,让开发者更关注于业务逻辑。 用一个简单的例子展示这个概念。

<!DOCTYPE html>
<html lang="cn">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Vue 基础结构</title>
</head>
<body>
  <div id="app">
    <h1>差值表达式</h1>
    <h3>{{ msg }}</h3>
    <h3>{{ count }}</h3>
    <h1>v-text</h1>
    <div v-text="msg"></div>
    <h1>v-model</h1>
    <input type="text" v-model="msg">
    <input type="text" v-model="count">
  </div>

  <script src="./js/vue.js"></script>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        msg: 'Hello Vue',
        count: 20,
        items: ['a', 'b', 'c']
      }
    })
  </script>
</body>
</html>

上面的例子是一个简单的vue示例。用过vue的开发者都知道,在浏览器里,html里的插值表達式,如 {{msg}},{{count}}会被替换成Vue.data里的数据,如果我们修改data里的数据,页面里的相应的值也会同时作出变化,而有v-model的元素则会实现双向绑定,若在页面里对数据进行修改,则会修改vue里相应的数据,而修改vue里的数据,也会更改页面的值的显示。 这就是数据响应式。

源码实现

vue

现在我们可以开始实现一个简单的vue框架。我们希望所实现的框架也能做数据响应的效果。首先我们要创造一个Vue类,而且它的构造函数要接收一个对象。

class Vue {
  constructor(options){
    this.$options = options || {}
}
} 

之后Vue会对options对象进行解析,把options.el和options.data作为elel和data属性的值

class Vue {
  constructor(options){
    this.$options = options || {}
    this.$data = options.data || {}
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
}
} 

如果el是字符串,则通过它找到DOM树上相应的元素。还有一点是平时我们可以直接通过vue获取数据,这意味着vue本身已经有data里的属性,所以我们要写一个方法,把data的属性注入到vue实例。

_proxyData (data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get () {
          return data[key]
        },
        set (newValue) {
          if (newValue === data[key]) {
            return
          }
          data[key] = newValue
        }
      })
    })
  }
}

Object.defineProperty构造类的属性,第一个参数是被注入的类,这里是Vue,key就是属性名,就是data里的属性,最后一个参数是构造的属性。简单来说,这个方法就是把data的属性注入至Vue实例,然后为每一个属性添加getter和setter。最后在构造函数里调用:

class Vue {
  constructor(options){
    this.$options = options || {}
    this.$data = options.data || {}
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
    this._proxyData(this.$data)
}
}

observer

然而现在Vue仅仅有data的getter和setter,数据本身不是响应式。怎样才能把数据设置成响应式?。。。所谓的响应式,就是当数据进行修改时,之前接收数据的位置都会相应作出变更。这也意味着,一是当某个地方接收数据时,Vue要在这地方放一个 “信号接收器”,记录位置,如果源数据发生变动, “接收器”也要作出相应行动,二是修改数据时,要一个 “信号发射器”,向所有 “信号接收器”作出通知,它已经修改数据。

这叫做观察者模式,画一个图表示上述的关系:

observer

观察者可能有一个或多个,它或它们观察发布者有没有变化,有的话,则作出变化。

Observer类的代码如下:

class Observer {
  constructor (data) {
    this.walk(data)
  }
  walk (data) {
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }
  defineReactive (obj, key, val) {

    let dep = new Dep()

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get () {

        Dep.target && dep.addSub(Dep.target)
        return val
      },
      set (newValue) {
        if (newValue === val) {
          return
        }
        val = newValue
        dep.notify()
      }
    })
  }
}

上述的代码与之前的_proxyData类似,Observer类接收一个data对象,对其注入getter和setter,不同的是,这里多了一个Dep类,先不管它是怎样实现。它所扮演的角色就是信号接收/发射器,通过闭包,getter和setter可以引用Dep类。 Dep.target && dep.addSub(Dep.target)这段代码就是记录getter使用的 “位置”,而 dep.notify()就是 “发射信号”。

现在只是简单讲一下这两段代码的作用,之后才详细讲它们的作用。 Observer基本已经实现,但还是有些问题。如果data属性的值是对象要怎么办?上述的代码仅对原始数据实现响应式效果,如果是对象,仅会对其引用实现响应式,因此要用递归处理。

  walk (data) {
    if (!data || typeof data !== 'object') {
      return
    }

    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }

  defineReactive (obj, key, val) {
    let that = this
    let dep = new Dep()
    this.walk(val)
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get () {
        Dep.target && dep.addSub(Dep.target)
        return val
      },
      set (newValue) {
        if (newValue === val) {
          return
        }
        val = newValue
        that.walk(newValue)
        dep.notify()
      }
    })
  }

defineReactive里第一个walk把对象变成响应式,而第二个walk则是考虑到更改的值可能为对象,要用递归把值变成响应式。

之所以用that引用Observer实例,是因为当调用getter和setter时,里面的this不是指向observer。

Dep

Dep专门存储观察者,当数据修改,它调用存储的观察者的方法。

class Dep {
  constructor () {
    this.subs = []
  }

  addSub (sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }

  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

Dep的实现很简单,就是存储和调用。

Compiler

在构建观察者类前,我们需要考虑观察者到底出现在什么地方,不然我们不知道怎样写这个类。再一次回顾一下Vue。我们写好vue的代码,把vue类挂载到DOM树上某一元素,在html里有些地方 引用vue的数据或方法。所以我们要一个类处理页面,把引用的位置改成vue对应的数据,还要在引用节点添加观察者,以让数据变成响应式。

怎样写这个类?首先构造函数的参数一定是vue类,获得el的值,知道vue挂载的节点,对该节点遍历,进行编译,还要考虑到底引用的位置是文本还是节点属性,以作不同处理,即 {{msg}}还是 <a v-text= “msg”></a>

class Compiler {
  constructor (vm) {
    this.el = vm.$el
    this.vm = vm
    this.compile(this.el)
  }
  // 编译模板,处理文本节点和元素节点
  compile (el) {
    let childNodes = el.childNodes  //获得子节点
    Array.from(childNodes).forEach(node => {  //遍历
      // 处理文本节点
      if (this.isTextNode(node)) {
        this.compileText(node)
      } else if (this.isElementNode(node)) {
        // 处理元素节点
        this.compileElement(node)
      }

      // 判断node节点,是否有子节点,如果有子节点,要递归调用compile
      if (node.childNodes && node.childNodes.length) {
        this.compile(node)
      }
    })
  }

简单来说,就是把DOM树遍历,分情况处理,如果子节点下有自己的节点,则递归处理。

接下来把上述代码调数的方法实现。先把判断方法实现,因为它们是最简单的。


  isDirective (attrName) {
    return attrName.startsWith('v-')
  }

  isTextNode (node) {
    return node.nodeType === 3
  }

  isElementNode (node) {
    return node.nodeType === 1
  }

isDirective判断元素属性是否为vue的指令,看一下属性开头是否为 ‘v-’则可,isTextNode判断是否为文本节点,isElementNode判断是否为元素。

compileText比较简单,就是获取节点的文本,找出引用vue数据的变量,把它替换。

  compileText (node) {
    let reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if (reg.test(value)) {
      let key = RegExp.$1.trim()
      node.textContent = value.replace(reg, this.vm[key])
    }
  }

用正则表达式找出vue数据引用,把它替换成vue的数据。

compileElement比较麻烦,因为它要处理不同的指令。最直接的方法是用if解决,获取vue指令,根据指令调用不同的方法,但这样写会产生大量的if…else if..语句,写起来不好看,日后处理也比较麻烦,其实可以用对象本身的特性处理,既然指令本身是字符串,我们直接用指令调用相关方法就可以了,即直接用 this[“xxx”]调用方法。

  compileElement (node) {
    Array.from(node.attributes).forEach(attr => {
      let attrName = attr.name
      if (this.isDirective(attrName)) {
        attrName = attrName.substr(2)
        let key = attr.value
        this.update(node, key, attrName)
      }
    })

遍历节点属性,查看是否有vue指令,有的话,把 ‘v-’后缀字符取出,把节点,属性的值和指令名放入update函数,作为参数。

Update集中处理vue指令。

  update (node, key, attrName) {
    let updateFn = this[attrName + 'Updater']
    updateFn && updateFn.call(this, node, this.vm[key], key)
  }

看一下有没有指令相关的方法,有的话则调用。这里我们简单实现 v-text和v-model的方法。

  // 处理 v-text 指令
  textUpdater (node, value, key) {
    node.textContent = value
  }

  // v-model
  modelUpdater (node, value, key) {
    node.value = value

    // 双向绑定
    node.addEventListener('input', () => {
      this.vm[key] = node.value
    })
  }

textUpdater不多说,代码很清楚,modelUpdater是双向绑定,一方面是vue的值修改时,input的值也要修改,所以是 node.value = value,另一方面input的值修改时,vue的数据也得改,所以相关的input元素要注册事件,当输入发生时,调用回调函数,所以是

    node.addEventListener('input', () => {
      this.vm[key] = node.value
    })

Compiler这个类基本是完成了。然而如果现在进行测试,在vue里改数据,视图不会发生变化,因为节点没有观察者存在,不能接收数据发生改变的信号。所以我们得要在编译的同时,添加观察者。

Watcher

终于开始写观察者啦。写之前,我们先在Compiler类的编译位置添加Watcher。问题是,我们要传入什么参数给Watcher?或我们应该想,Watcher到底需要知道什么?首先肯定要知道它的位置,所以它必须要有节点引用,然后是vue实例,不然怎样进行观察?还得有要观察的data属性,最后要有一个回调函数,当数据改变时调用。 先在相关位置放上Watcher,之后再实现。

  textUpdater (node, value, key) {
    node.textContent = value
    new Watcher(this.vm, key, (newValue) => {
      node.textContent = newValue
    })
  }
 

  modelUpdater (node, value, key) {
    node.value = value
    new Watcher(this.vm, key, (newValue) => {
      node.value = newValue
    })
    node.addEventListener('input', () => {
      this.vm[key] = node.value
    })
  }

  compileText (node) {
    let reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if (reg.test(value)) {
      let key = RegExp.$1.trim()
      node.textContent = value.replace(reg, this.vm[key])

      new Watcher(this.vm, key, (newValue) => {
        node.textContent = newValue
      })
    }
  }

Watcher类的参数没什么好说,就是把节点的引用放在回调函数。 接下来前方高能,前方高能,前方高能(重要的事情说三次),可能会比较绕。先上代码,再配上图片,进行解释。

class Watcher {
  constructor (vm, key, cb) {
    this.vm = vm
    // data中的属性名称
    this.key = key
    // 回调函数负责更新视图
    this.cb = cb

    Dep.target = this
    this.oldValue = vm[key]
    Dep.target = null
  }
  // 当数据发生变化的时候更新视图
  update () {
    let newValue = this.vm[this.key]
    if (this.oldValue === newValue) {
      return
    }
    this.cb(newValue)
  }
}

先别管构造函数最后三行,先回去看一下Dep类:

class Dep {
  constructor () {
    this.subs = []
  }

  addSub (sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }

  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

Dep类就是专门管理watcher方法,当视图引用vue的数据时,就会构造一个Watcher,而Watcher则会叫相关的Dep把自己存在数组,当数据发生修改时,Dep就会遍历数组,调用Watcher实例的update方法。 现在的问题是到底所谓的 “相关的Dep” 在什么时侯出现? 囧。 我们再回退至Observer类,看一下它的defineReactive方法:

  defineReactive (obj, key, val) {

    let dep = new Dep()

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get () {

        Dep.target && dep.addSub(Dep.target)
        return val
      },
      set (newValue) {
        if (newValue === val) {
          return
        }
        val = newValue
        dep.notify()
      }
    })
  }
}

注意第二行,当每一个Vue实例的数据变成响应式时,都会生成一个Dep实例。当getter方法被调用时,就是当Compiler类里引用vue数据时,Dep实例会查看有没有一个Dep.target的静态属性,有的话就用数组存起来。这个target是什么时候出现? 当Watcher实例构成时,就是Watcher构造函数最后三行:

    Dep.target = this
    this.oldValue = vm[key]
    Dep.target = null

首先生成一个target的静态属性,引用当前的Watcher实例,然后实例的oldValue引用vue相关的数据,这时候就调用了 相关数据的getter相关数据的getter相关数据的getter (重要的事情说三遍)。相关数据的getter把这个Watcher实例 (Dep.target)存入dep的数组里。

画图再解释:

首先生成Watcher实例,到了 Dep.target = this 发生的情况:

Dep.target

getter_analysis

dep_analysis

最后把Dep.target变成null,防止被其他地方引用。 过程有点绕,多看几次,或在纸上把程序的引用关系整理一下就好。

后面的setter就好理解,就是修改数据时,把dep里的Watcher实例拿出来,调用它们的update方法。

最后重构vue的构造函数:

  constructor (options) {
    this.$options = options || {}
    this.$data = options.data || {}
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
    this._proxyData(this.$data)
    new Observer(this.$data)
    new Compiler(this)
  }