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

前言

由于当今版本的 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 源码学习

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容