手写简化版Vue(二) 响应式原理

121 阅读2分钟

本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;

响应式基本原理

# 手写简化版Vue(一) 初始化里我们简单介绍了Vue的初始化过程, 还有data访问的实现, 那么接下来, 我们就要实现一下响应式的功能, 也就是this.xx = xx的时候, 能够触发相应的更新逻辑;

既然是要在修改data数据的时候触发更新, 那就必然要监听data, 那么如何监听呢, 在什么地方开始执行这个监听呢? 之前我们介绍了initdata方法, 我们知道了data就是在这里被初始化的, 那么是否应该从此处入手呢? 来看看源码:

// 源码路径src/core/instance/state.ts
function initData (vm) {
  let data = vm.$options.data
  data = vm._data = isFunction(data) ? data.call(vm) : data
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    proxy(vm, '_data', key)
  }
  // 相比于之前初始化, 这里增加了observe方法
  observe(data)
}

// 源码路径src/core/observer/index.ts
export function observe (value) {
  let ob = null
  // 判断该对象是否已经被纳入监听
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

// 对传入的对象/数组进行遍历监听
// 注意, 此处目前只考虑了对象的情况, 数组的基本原理与之本质上是相似的
// 有兴趣可以自己去实现下
export class Observer {
  constructor (value) {
    def(value, '__ob__', this)
    // 如果value是一个对象
    if (isPlainObject(value)) {
      let keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        defineReactive(value, keys[i])
      }
    }
  }
}

// 工具方法
// 源码路径src/shared/util.ts
const hasOwnProperty = Object.prototype.hasOwnProperty
// 判断对象上是否有特定的自身的属性
export function hasOwn (value, key) {
  return hasOwnProperty.call(value, key)
}

const _toString = Object.prototype.toString
// 判断是否是一个对象
export function isPlainObject (value) {
  return _toString.call(value) === '[object Object]'
}

// 判断是否为一个函数
export function isFunction(value: any): value is (...args: any[]) => any {
  return typeof value === 'function'
}

// 源码路径src/core/util/lang.ts
export function def (obj, key, value, enumerable) {
  Object.defineProperty(obj, key, {
    value: value,
    configurable: true,
    writable: true,
    enumerable: !!enumerable
  })
}

小节: 目前为止, 我们可以看到监听的大体逻辑是:

  1. 通过initData中执行的observe方法判断传入的data是否有被监听过, 有则返回__ob__属性, 没有, 就进行new Observer实例化;
  2. Observer中, 遍历对象中的属性, 针对每一个属性, 实现一个defineReactive, 即, 将其变为响应式;

我们继续往下看其响应式实现的具体逻辑, 下面介绍了三个内容: defineReactive, Dep, Watcher, 建议先熟悉下defineReactive和Dep, 然后重点关注Watcher逻辑

// 源码路径src/core/observer/index.ts
// 监听具体的属性, 在这里利用Object.defineProperty
// 定义了一个属性存/取的相关逻辑
export function defineReactive (obj, key, val) {
  const dep = new Dep()
  val = obj[key]
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get () {
      // 注意此处的Dep.Target是一个全局的变量, 当其有值的时候, 说明
      // 正在执行监听依赖操作, 这个后续会继续解析, 现在注意下就行了
      if (Dep.Target) {
        dep.depend() // 依赖收集
      }
      return val
    },
    set (newVal) { // 数据发生改变后, 需要通知依赖于该数据的watcher, 并执行对应的方法
      val = newVal
      dep.notify()
    }
  })
}

// 下面是具体实现
// 源码路径src/core/observer/dep.ts
let uid = 0
export default class Dep {
  static Target = null
  constructor () {
    this.id = uid++
    // 存储watcher
    this.subs = []
  }
  addSub (sub) {
    this.subs.push(sub)
  }
  depend () {
    if (Dep.Target) {
      Dep.Target.addDep(this)
    }
  }
  notify () {
    const subs = this.subs.slice()
    for (let i = 0; i < subs.length; i++) {
      subs[i].update()
    }
  }
}

Dep.Target = null
export function pushTarget (target) {
  Dep.Target = target
}

// 源码路径src/core/observer/watcher.ts
export default class Watcher {
  // vm为当前vue实例, expOrFn现在可以只将其看作是一个方法
  constructor(vm, expOrFn) {
    this.vm = vm
    this.depIds = new Set()
    if (isFunction(expOrFn)) {
      this.getter = expOrFn
    }
    this.get()
  }
  get () {
    const vm = this.vm
    // 注意这个pushTarget, 这里就是前面defineReactive中get中
    // Dep.Target的由来, 此时全局的Dep.Target 变为了这个watcher实例!
    pushTarget(this)
    let value = ''
    try {
      // 此处执行的getter方法中, 如果有对data进行取值的逻辑(this.xx), 那么就会触发
      // Object.defineProperty中的get方法最终执行到dep.depend(), 从而进行依赖的收集
      value = this.getter.call(vm, vm)
    } catch (e) {
      console.log(e.message)
    }
    return value
  }
  // 通过addDep方法可以将当前Watcher实例加入到对应的dep
  addDep (dep) {
    const id = dep.id
    if (!this.depIds.has(id)) {
      this.depIds.add(id)
      dep.addSub(this)
    }
  }
  update () {
    this.run()
  }
  run () {
    this.get()
  }
}

代码小节:

  1. 依赖收集:

通过defineReactive, Dep, Watcher三者的操作, 我们可以梳理下

通过一系列处理, 可以将watcher存入对应属性的dep的subs属性当中, 如果未来某个属性发生改变, Vue就会依次执行subs中的watcher, 而watcher中监听的方法就会被执行, 下面来看看事件触发后如何进行更新的;

  1. 依赖更新

已经收集到了依赖之后, 更新就很简单了

  1. Object.defineProperty的setter方法执行
  2. 触发dep.notify方法, 从而遍历dep.subs数组
  3. 执行subs中的watcher.run(), 从而将依赖的方法都更新了

验证

好了, 现在来验证下我们响应式的效果

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>响应式测试页面</title>
  </head>
  <body>
    <input type="text" id="input">
    <script type="module">
      import Vue from '/lib/Vue/index.js'
      import Watcher from './lib/Vue/core/observer/watcher.js'
      const vm = new Vue({
        data () {
          return {
            name: 'york'
          }
        }
      })
      // 由于我们还没有实现v-model, 所以此处暂时使用原生方法绑定事件
      document.querySelector('#input').addEventListener('input', (e) => {
        vm.name = e.target.value
      })
      // 注意, 这里我们监听了
      new Watcher(vm, (this) => {
        let name = this.name
        console.log('🚀 被监听的name的最新值:', name)
      })
    </script>
  </body>
</html>

效果如下:

挂载简述

上面的案例中, 我们使用 new Watcher成功监听到了data属性的改变, 但是实际使用中, 我们肯定不可能把watcher拉出来和new Vue放在都一个层级使用吧, 那它应该在哪里呢? 不错, 就隐藏在挂载逻辑中, 注意我们刚才的案例中, new Vue是没有el属性的, 而这在正常的开发中, 是不可能存在的, 接下来, 就来简单过下挂载的逻辑, 进一步验证我们之前的成果, 也为后续进一步的开发做准备;

// 源码路径 /src/platforms/web/runtime/index.ts
/**
注意, 这里的Vue, 是已经经过了初始化, 源码中此处引入Vue的代码为: 
import Vue from 'core/index', 而core/index中
Vue又引入自src/core/instance/index.ts, 也就是我们第一节最开始初始化Vue的那个文件
*/
Vue.prototype.$mount = function (el) {
  // 入传入字符传选择器, 则将其转换为节点
  if (typeof el === 'string') {
    el = document.querySelector(el)
  }
  mountComponent(this, el)
}

export default Vue

// 源码路径 /src/core/instance/lifecycle.ts
export function mountComponent (vm, el) {
  const updateComponent = function () {
    // 由于我们还没有学习到模板部分, 所以此处简单使用原生方法进行插值
    el.innerHTML = vm.name
  }
  // 注意, watcher出现了
  new Watcher(vm, updateComponent)
}

// 源码路径 /src/core/instance/init.ts
import { initState } from './state'
export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options
    initState(vm)
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

小节: 以上代码中, $mount方法传入一个节点/字符串, 然后执行mountComponent方法, 将updateComponent方法纳入其中, 根据上一节的案例, 我们知道, 只要在这方法中执行this.xx取值操作, 就会被监听, 并在后续的更新中被执行;

现在我们可以像真的一样执行了

<input type="text" id="input">
<div id="app"></div>
<script type="module">
  import Vue from '/lib/Vue/index.js'
  const vm = new Vue({
    data () {
      return {
        name: 'york'
      }
    },
    el: '#app' // 增加了挂载
  })
  document.querySelector('#input').addEventListener('input', (e) => {
    vm.name = e.target.value
  })
  // new Watcher(vm, (_vm) => {
  //   let name = _vm.name
  //   console.log('🚀 被监听的name的最新值:', name)
  // })
</script>

总结:

  1. 本节我们主要是在上一节data访问的基础上, 增加了对data数据的监听;
  2. 响应式依赖收集的基本原理: 我们使用Object.defineProperty, 的get方法, 来监听属性在哪里被使用, 从而将该watcher存入属性专属的dep内, 又用了Object.defineProperty的set方法, 从该属性的dep中获取依赖, 并依次执行
  3. 简单使用了挂载方法, 将watcher内置到挂载逻辑之内, 使其可以进行自动监听, 并更新页面数据