【手写Vue】v0.0.2

140 阅读4分钟

接着上一版继续。

如果你不知道上一版在哪?电梯直达

mini-vue 仓库源码 点击关注不迷路,哈哈。

v0.0.2 模块化与响应式依赖收集

要求:

  • 模块化,即功能点分离为不同的文件
  • 每个模块的功能尽量通用,说的再明白点,减少全局变量,能传参就传参,纯函数最好。
  • 搞懂dep 与 watcher 之间的多对多关系映射

每个版本的要求我都会写在最前面,这里推荐大家先不要着急往下看,在上一个版本的基础上,试着自己写写看。

多啰嗦一句哈,上一个版本没搞懂之前,不要急着看下一版本,先按照简单的功能做,不要想太复杂的场景,每个版本的小功能实现了,就可以了,能自己手敲出来,才算真正掌握。

模块化改造

  1. 在example文件夹中,新增app.js文件,这个版本我们主要是探究响应式与依赖收集,因此其他的功能先简化,模板编译先用render函数替代,虚拟DOM先用真实DOM凑合着。
export default {
  data: function () {
    return {
      count: 0,
      test: 1001
    }
  },
  methods: {
    plus () {
      this.count++
    },
    testNoReactive () {
      // 测试修改test属性,不触发重新渲染
      console.log('testNoReactive', this.test++)
    }
  },
  render () {
    /**
     * this为当前实例对象,通过vm.$createElement来生成dom对象
     * vm._v() 通过这个函数来收集依赖,因为vm.count将触发count属性的getter函数
     * 重点注意,此处 h('h2', [vm._v(vm.count)]) 按照js的执行顺序,
     * 1. 先执行vm.count, 触发get响应
     * 2. 然后执行vm._v()
     * 3. 最后执行h(), 
     * 明白了这个执行顺序,能同时在多个函数内使用的变量,就是全局变量,因此在执行render之前,
     * 要先声明一个全局变量,来将这过程中产生的操作记录下来,方便后续数据响应时调用
     */
    const vm = this
    const h = vm.$createElement

    return h('div', [
      h('h2', [vm._v(vm.count)]),
      h('button', { on: { click: vm.plus } }, [vm._v('+1')]),
      h('button', { on: { click: vm.testNoReactive } }, [vm._v('testNoReactive')])
    ])
  }
}
  1. 转到src文件夹下,重点先将vue.js中的代码分离出来。 看过源码的同学,这里可能会说先init初始化methods, props 吧啦吧啦一大堆操作,stop!停下,先忘了vue的源码,只写我们目前需要的功能,我们这个版本只做响应式与依赖收集

    • abserver.js 模块 首先是new Vue(app), app参数传进来了,使用abserver将app.data处理成响应式的,abserver算是一个独立的功能,先把它抽离出去,我们新建一个abserver.js文件,再将abserver函数中的代码移到abserver.js文件中

    • watcher.js 模块 监听器,抽离成一个对象,方便存储依赖,以及更新时调用update

    • dep.js 模块 依赖锚点,为啥叫锚点,这个很重要。dep对象就只是打个标记,该对象并没有存储实质数据,主要就是为了在响应的数据属性处收集依赖,set时能准确触发到对应的watcher

    这里重点理解下 Dep.target 作为全局对象的一个妙用

    • render 模块 简易版的渲染函数,直接操作了真实DOM进行页面更新

具体逻辑还得看代码,一两句说不清楚。

vue.js

import { abserver } from './abserver.js'
import { createElement } from './render.js'
import { noop } from './shared.js'
import Watcher from './watcher.js'
function Vue (app) {

  const data = app.data()
  // 将data改造成响应式的
  abserver(data)
  // 将methods也合并到vm中,方便获取
  const vm = Object.assign(data, app.methods)

  // 定义渲染函数,app的render函数中需要用到
  vm.$createElement = function (...args) {
    return createElement.call(vm, vm, ...args)
  }

  vm._v = val => {
    return val
  }

  function updateComponent () {
    const container = vm.container
    // 调用render函数,获得真实dom节点
    const root = app.render ? app.render.call(vm) : ''
    container.innerHTML = ''
    container.append(root)
  }

  const $mount = selector => {
    const page = document.querySelector(selector) || document.body
    vm.container = page
    // 声明一个全局监听对象,记录app渲染时的操作,当vm中,依赖属性发生变化时,触发updateComponent函数重新渲染
    vm._watcher = new Watcher(vm, updateComponent, noop)
  }

  return {
    $mount
  }
}

// 为了更清晰的了解逻辑,会省略一些数据校验,我们都假设为正确的数据
Vue.$createElement = createElement

export default Vue

abserver.js

import Dep from "./dep.js"

export function abserver (data) {
  for (let key in data) {
    defineReactive(data, key, data[key])
  }
}

function defineReactive (obj, key, val) {
  // 创建一个依赖对象
  let dep = new Dep()

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      // 将count添加到依赖中
      dep.depend()
      return val
    },
    set: function (newVal) {
      val = newVal
      // 在v0.0.1版本中,此处是写死的 button.innerText = newVal, 为了通用,我们这里用一个函数代替
      // 如下notify() 将实现button.innerText = newVal操作,但是button没法获取到,因此需要在get时进行收集
      dep.notify()
    }
  })
}

dep.js

let uid = 0
class Dep {
  constructor() {
    this.id = ++uid
    this.subs = []
  }

  addSub (watcher) {
    this.subs.push(watcher)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // 因为可能存在多个依赖,此处使用数组形式遍历触发
    // 依赖是有先后顺序的,因此需要提前排下序
    this.subs.sort((a, b) => a.id - b.id)
    this.subs.forEach(sub => sub.update())
  }
}

Dep.target = null
const staticTargets = []
export function pushTarget (watcher) {
  staticTargets.push(watcher)
  Dep.target = staticTargets[staticTargets.length - 1]
}

export function popTarget () {
  staticTargets.pop()
  Dep.target = staticTargets[staticTargets.length - 1]
}

export default Dep

watcher.js

import { pushTarget, popTarget } from './dep.js'

let uid = 0
class Watcher {
  constructor(vm, expOrFn, cb, val) {
    this.id = ++uid
    this.vm = vm
    this.expOrFn = expOrFn
    this.cb = cb
    this.deps = []
    this.depIds = new Set()
    this.value = this.get() || val
  }

  addDep (dep) {
    const { id } = dep
    if (!this.depIds.has(id)) {
      dep.addSub(this)
      this.deps.push(dep)
      this.depIds.add(id)
    }
  }

  get () {
    // 将当前监听器设置成target
    pushTarget(this)
    this.expOrFn.call(this.vm)
    popTarget()
  }

  update () {
    let oldVal = this.value
    // 触发组件更新渲染
    this.value = this.get()
    // 执行用户自定义的watch
    this.cb.call(this.vm, this.value, oldVal)
  }
}

export default Watcher

render.js

export function createElement (vm, tag, prop, children) {
  const el = document.createElement(tag)
  vm.el = el
  if (Array.isArray(prop)) {
    children = prop
  } else {
    if (prop.on) {
      // 如果存在on属性,则进行事件绑定操作
      for (let ev in prop.on) {
        el.addEventListener(ev, prop.on[ev].bind(vm))
      }
    }
  }

  if (children && children.length > 0) {
    children.forEach(child => el.append(child))
  }
  return el
}

shared.js // 工具类

export function noop (a, b, c) { }

如果上面的代码逻辑都明白了,vue的响应式依赖收集算是入门了,我们这里摒弃了复杂情况以及数据校验、边界处理等等,主要目的是凸显出 watcher与dep之间的关系,以及它是怎么通过 dep.depend()、dep.notify()两个方法,就实现了依赖收集与响应。真正读懂后,不由感叹,尤大大,妙啊!

v0.0.2版本搞懂后,就可以继续看下一个版本了。

传送门v0.0.3 实现虚拟DOM与diff算法