vue2响应式原理,你搞懂了吗?

211 阅读8分钟

一、经典面试题

1、vue响应式原理

首先了解Vue中的三个核心类:

  1. Observer: 给对象属性添加gettersetter,用于依赖收集派发更新
  2. Dep:用于收集当前响应式对象的依赖关系,每个响应式对象都有一个dep实例,dep.subs = watcher[ ]。当数据发生变更的时候,会通过dep.notify( )通知各个watcher
  3. Watcher:观察者对象,render watchercomputed watcheruser watcher(相当于watch)

依赖收集

  1. initState时,对computed属性初始化时,会触发computed watcher 依赖收集
  2. initState时, 对监听属性初始化的时候,触发的user watcher依赖收集(这里就是我们常写的那个watch)
  3. render时, 触发 render watcher 依赖收集
  4. re-render 时,vm.render()再次执⾏,会移除所有 subs 中的 watcher 的订阅,重新赋值。

派发更新

  1. 组件中对响应式的数据进行了修改,会触发setter逻辑

  2. 调用dep.notify()

  3. 遍历所有的subs(Watcher 实例) ,调用每一个watcher update方法

总结原理:

当创建vue实例的时候,vue会遍历data里的属性,Object.defineProperty为属性添加gettersetter对数据的读取进行劫持 (getter ⽤来依赖收集,setter ⽤来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化 。

每个组件实例会有相应的 watcher 实例,会在组件渲染的过程中记录依赖的所有数据属性进⾏ 依赖收集,之后依赖项被改动时,setter ⽅法会通知依赖与此 data 的 watcher 实例重新计算(派 发更新),从⽽使它关联的组件重新渲染 。

2、计算属性的实现原理

计算属性更建议做一些类型/格式的转换或者简单的计算

computed watcher,计算属性的监听器。

computed watcher 持有一个dep实例,通过 this.dirty 属性标记计算属性是否需要重新求值。

computed的依赖值改变后,就会通知订阅的watcher进行更新,对于computed watcher会将 dirty属性设置为true,并且进行计算属性方法的调用。

  1. computed 所谓的缓存是指什么?

计算属性是基于他的响应式依赖进行缓存的,只有依赖发生变化的时候才会重新求值

  1. 那computed缓存存在的意义是什么?或者说你经常在什么时候使用?

比如计算属性方法内部操作非常的耗时,遍历一个极大的数组,计算一次可能要耗时1s

  1. 以下情况,computed可以监听到数据的变化吗?
{{ storageMsg }}

computed:{
	storageMsg:function(){
    return sessionStorage.getItem('xxx')
  },
  time:function(){
    return Date.now()
  }
}
created(){
  sessionStorage.setItem('xxx',1111)
},
onClick(){
  sessionStorage.setItem('xxx',Math.rendom())
}

答案:不可以。没有经过响应式。

3、Vue.nextTick的原理

// 两种使用方式
Vue.nextTick(()=>{
  // TODO
})

await Vue.nextTick();
// TODO

Vue 是 异步执⾏dom更新的,⼀旦观察到数据变化,Vue就会开启⼀个异步队列,然后把在同⼀个事件循环 (event loop) 当中观察到数据变化的 watcher 推送进这个队列。如果这个 watcher被触发多次,只会被推送到队列⼀次。

这种缓冲⾏为可以有效的去掉重复数据造成的不必要的计算和DOm操作。⽽在下⼀个事件循 环时,Vue会清空队列,并进⾏必要的DOM更新。

异步方法优先级:Promise.then() -> MutationObserver -> setImmediate -> setTimeout

enevt loop执行顺序: 宏任务 -> 微任务 ->UI render

所以可以理解为, nextTick会优先尝试使⽤微任务, 如果浏览器不⽀持, 就⽤宏任务

一般什么时候需要使用Vue.nextTick?

在数据变化后要执行某个操作,而这个操作依赖因你数据改变而改变的dom,这个操作就应该被放到 Vue.nextTick 会调用

// 例子:
<template>
  <div v-if='loaded' ref='test'></div>
</template>

async showDiv(){
  this.loaded = true;
  this.$refs.test.xxx()  // 此时拿不到div上面的任何数据,为undefined
  await Vue.nextTick()
  this.$refs.test.xxx()  // 这个时候可以拿到方法
}

二、手写一个简单的vue,实现响应式的更新

1、新建一个目录

  • index.html 主页面
  • vue.js Vue主文件
  • compiler.js 编译模板,解析指令。 v-model v-html
  • dep.js 收集依赖关系,存储观察者 // 以发布-订阅的形式实现
  • observer.js 数据劫持
  • watcher.js 观察者对象类

2、index.html内容

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>vue-model</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

3、初始化Vue class

/**
 * 包括vue 构造函数,接收各种配置参数等等
 */

export default class vue {
  constructor(options = {}) {
    console.log(options);
    // 带$相当于私有变量
    this.$options = options;
    this.$data = options.$data;
    this.$methods = options.$methods;
    this.initRootElement(options);
  }
  /**
   * 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
   */
  initRootElement(options) {
    if (typeof options.el == 'string') {
      // 传⼊的是元素id或者class
      this.$el = document.querySelector(options.el);
    } else if (typeof options.el instanceof HTMLElement) {
      // el:"div",   //对应的 html类型
      this.$el = options.el;
    }

    if (!this.$el) {
      throw new Error('传入的数据不正确');
    }
  }
}

4、验证一下,新建一个index.js

import Vue from './vue.js';

const vm = new Vue({
  el: '#app',
  data: {
    msg: 'hello word'
  },
  methods: {
    handler() {
      console.log('1111111111===', 1111111111);
    }
  }
});

console.log(vm);
  • 输入一个错误的el。 例如 el: '#app1'
  • 输入一个htmlelement

5、vue里可以通过this来获取data里面的属性

/**
 * 包括vue 构造函数,接收各种配置参数等等
 */

export default class vue {
  constructor(options = {}) {
    // 带$相当于私有变量
    this.$options = options;
    // 获取 data
    this.$data = options.data;
    this.$methods = options.methods;
    this.initRootElement(options);
    // 利用Object.defineProperty将data里面的属性注入到vue实例中
    this._proxyData(this.$data);
  }
  /**
   * 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
   */
  initRootElement(options) {
    if (typeof options.el == 'string') {
      this.$el = document.querySelector(options.el);
    } else if (typeof options.el instanceof HTMLElement) {
      this.$el = options.el;
    }

    if (!this.$el) {
      throw new Error('传入的数据不正确');
    }
  }

  // 把data 中的属性注册到 Vue
  _proxyData(data) {
    Object.keys(data).forEach((key) => {
      // 进行数据劫持
      // 把每个data的属性 到添加到 Vue 转化为 getter setter方法
      Object.defineProperty(this, key, {
        // 设置可以枚举
        enumerable: true,
        // 设置可以配置
        configurable: true,
        // 获取数据
        get() {
          return data[key]
        },
        // 设置数据
        set(newValue) {
          // 判断新值和旧值是否相等
          if (newValue === data[key]) return
          // 设置新值
          data[key] = newValue
        },
      })
    })
  }
}

6、声明核心类

具体的实现先不管,写好注释,对于这种外界可能会引用到的方法,使用jsDoc

export default class Dep {
  constructor() {
    // 存储所有的观察者
    this.subs = [];
  }
  // 添加观察者
  addsubs() {}
  // 发送通知
  notify() {}
}
export default class Observer {
  constructor() {}
  // 用于递归data里面的所有属性
  traverse(data) {}
  // 给传入的数据设置   
  defineReactive(obj, key, val) {
    // TODO 递归遍历
  }
}
export default class Watcher {
  constructor(vm, key, cb) {}
  // 当数据变化的时候更新视图
  update() {}
}
export default class Compiler {
  constructor(vm) {}

  // 编译模板  el-根元素
  compile(el) {}
}

7、整体实现

想⼀下应该怎么调⽤这些⽅法? 在vue初始化的时候都应该做些什么?

import Observer from './observer.js';
import Compiler from './compiler.js';
/**
 * 包括vue 构造函数,接收各种配置参数等等
 */

export default class vue {
  constructor(options = {}) {
    console.log(options);
    // 带$相当于私有变量
    this.$options = options;
    this.$data = options.data;
    this.$methods = options.methods;
    this.initRootElement(options);
    // 利用Object.defineProperty将data里面的属性注入到vue实例中
    this._proxyData(this.$data);
    // 实例化observer对象,监听数据变化
    new Observer(this.$data);
    // 实例化compiler对象,解析指令和模板表达式
    new Compiler(this);
  }
  /**
   * 获取根元素,并存储到vue实例,简单检查一下传入的el是否合规
   */
  initRootElement(options) {
    if (typeof options.el == 'string') {
      this.$el = document.querySelector(options.el);
    } else if (typeof options.el instanceof HTMLElement) {
      this.$el = options.el;
    }

    if (!this.$el) {
      throw new Error('传入的数据不正确');
    }
  }

  _proxyData(data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key];
        },
        set(newValue) {
          // 如果数据没有变化不做处理
          if (data[key] === newValue) return;
          data[key] = newValue;
        }
      });
    });
  }
}

完善 dep.js

发布订阅模式

记住, dep是⽤来存储所有观察者的, 也就是watcher.

⽽我们watcher的定义, 每个watcher都会有⼀个update⽅法对吧, ⽤来更新视图的?

  • addSub, 我们如果发现watcher没有update⽅法, 也就没必要添加到subs⾥了.
  • notify, 是提供给外界调⽤的, 当数据有变更的时候, 外界会调⽤notify去通知各个watcher, 也就 是执⾏watcher.update()
/**
 * 发布订阅模式:
 * 存储所有观察者,watcher
 * 每个watcher都有一个update
 * 通知subs里的每个watcher实例,触发update方法
 *
 */
export default class Dep {
  constructor() {
    // 存储所有的观察者
    this.subs = [];
  }
  // 添加观察者
  addSub(watcher) {
    // 判断观察者是否存在 和 是否拥有update方法
    if (watcher && watcher.update) {
      this.subs.push(watcher);
    }
  }
  // 发送通知
  notify() {
    // 触发每个观察者的更新方法
    this.subs.forEach(watcher => {
      watcher.update();
    });
  }
}

// 问题:
// Dep 在哪里实例化? 在哪里addSub?Observer遍历各个属性进行实例化的

// Dep notify 在哪里调用

完善watcher

import Dep from './dep.js';

export default class Watcher {
  /**
   * @param {*} vm   vue实例
   * @param {*} key  data属性名
   * @param {*} cb   负责更新视图的回调函数
   */
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;
		// 此时 Dep.target 作为一个全局变量理解,放的就是这个watcher
    Dep.target = this;
		// 旧数据 更新视图的时候要进行比较
    // 还有一点就是 vm[key] 这个时候就触发了observer的 get 方法
    // 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
    this.oldVal = vm[key]; // 旧值
		// Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null;
  }
  // 当数据变化的时候更新视图
  update() {
    let newValue = this.vm[this.key]; // 新值
    if (this.oldVal === newValue) {
      return;
    }
    this.cb(newValue);
  }
}

// watcher初始化获取oldVal的时候,会去做一些什么操作?添加依赖的操作
// 通过vm[key]获取oldVal前,为什么要将当前的实例挂在Dep上,获取之后为什么又置为null?先收集依赖进行暂存,用完后赋值为空
// update 方法是在什么时候执行的?   notify时调用update

完善compiler.js

import Watcher from './watcher.js';

export default class Compiler {
  // vm 指 Vue 实例
  constructor(vm) {
    this.el = vm.$el;
    this.vm = vm;
    this.methods = vm.$methods;
    // 编译模板
    this.compile(vm.$el);
  }

  // 编译模板  el-根元素
  compile(el) {
    const childNodes = el.childNodes; // 子节点 - 伪数组
    // 伪数组必须转换成真实数组才可进行遍历---不然在低版本浏览器上面不支持forEach
    Array.from(childNodes).forEach(node => {
      if (this.isTextNode(node)) {
        // 文本节点
        this.compileText(node);
      } else if (this.isElementNode(node)) {
        // 元素节点
        this.compileElement(node);
      }

      // 有子节点,递归调用
      if (node.childNodes && node.childNodes.length > 0) {
        // 继续递归编译模板
        this.compile(node);
      }
    });
  }
 // 编译文本节点(简单的实现)
  compileText(node) {
    // 核心思想利用把正则表达式把{{}}去掉找到里面的变量
    // 再去Vue找这个变量赋值给node.textContent
    // {{msg}}] msg:  hello word
    const reg = /{{(.+?)}}/;
     // 获取节点的文本内容
    const value = node.textContent; // hello word
		// 判断是否有 {{}}
    if (reg.test(value)) {
       // 获取分组一  也就是 {{}} 里面的内容 去除前后空格
      const key = RegExp.$1.trim(); // msg
      // 进行替换再赋值给node
      node.textContent = value.replace(reg, this.vm[key]);
      // 添加观察者
      new Watcher(this.vm, key, newValue => {
        // 数据改变时更新
        node.textContent = newValue;
      });
    }
  }
   // 编译元素节点这里只处理指令
  compileElement(node) {
    // 获取到元素节点上面的所有属性进行遍历
    if (node.attributes.length) {
      Array.from(node.attributes).forEach(attr => {
        // 遍历元素节点的所有属性  
        const attrName = attr.name; // v-model v-text v-html v-on:click
        if (this.isDirective(attrName)) {
          let directiveName = attrName.indexOf(':') > -1 ? attrName.substr(5) : attrName.substr(2);
          let key = attr.value; // msg
          // TODO 更新元素节点
          // vue指令很多为了避免大量个 if判断这里就写个 uapdate 方法
          this.update(node, key, directiveName);
        }
      });
    }
  }
  // 添加指令方法 并且执行
  update(node, key, directiveName) {
    // 比如添加 textUpdater 就是用来处理 v-text 方法
    // 我们应该就内置一个 textUpdater 方法进行调用
    // 加个后缀加什么无所谓但是要定义相应的方法
    const updateFn = this[directiveName + 'Updater'];
    // 如果存在这个内置方法 就可以调用了
    // 绑定this执行,不然方法里面的this就是undefined
    updateFn && updateFn.call(this, node, this.vm[key], key, directiveName);
  }
  // 解析v-text
  textUpdater(node, val, key) {
    node.textContent = val;
    new Watcher(this.vm, key, newValue => {
      console.log(newValue);
      node.textContent = newValue;
    });
  }
  // 解析v-mdoel
  modelUpdater(node, val, key) {
    console.log('val===', val);
    node.value = val;
    new Watcher(this.vm, key, newValue => {
      node.value = newValue;
    });

    // 双向绑定
    node.addEventListener('input', () => {
      this.vm[key] = node.value;
    });
  }
  // 解析v-html
  htmlUpdater(node, val, key) {
    console.log(val);
    node.innerHTML = val;
    new Watcher(this.vm, key, newValue => {
      node.innerHTML = newValue;
    });
  }
  // 解析v-on:click
  clickUpdater(node, val, key, directiveName) {
    node.addEventListener(directiveName, this.methods[key]);
  }
  // 判断是否是文本节点
  isTextNode(node) {
    return node.nodeType === 3;
  }
  // 判断是否是元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }
  // 判断元素属性是否是指令
  isDirective(attrName) {
    // v-xxx
    return attrName.startsWith('v-');
  }
}

完善observer.js

// 给每个属性添加getter/setter,用于依赖收集和派发更新

import Dep from './dep.js';

export default class Observer {
  constructor(data) {
    this.traverse(data);
  }
  // 用于递归data里面的所有属性
  traverse(data) {
    if (!data || typeof data !== 'object') {
      return;
    }
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]);
    });
  }
  // 给传入的数据设置  getter/setter
  defineReactive(obj, key, val) {
    // TODO 递归遍历
    this.traverse(val);
    const that = this;
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      configurable: true,
      enumerable: true,
      get() {
         // 在这里添加观察者对象 Dep.target 表示观察者
        Dep.target && dep.addSub(Dep.target);
        return val;
      },
      set(newValue) {
        if (newValue == val) {
          return;
        }
        val = newValue;
        // 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
        // 例如: info.age = {name:1111}
        that.traverse(newValue);
        dep.notify();
      }
    });
  }
}

在模板编译的过程中,遇到模板中绑定的变量,就会解析,并创建 watcher,会在 Watcher 类 的内部获取旧值,即当前的值。

这样就触发了 get,在 get 中就可以将这个 watcher 添加到 Dep 的 subs 数组中进⾏统⼀管 理。

因为在代码中获取 data 中的值操作⽐较多,会经常触发 get,我们⼜要保证 watcher 不会被 重复添加,所以在 Watcher 类中,获取旧值并保存后,⽴即将 Dep.target 赋值为 null,并且 在触发 get 时对 Dep.target 进⾏了判空,存在才调⽤ Dep 的 addSub 进⾏添加。

验证⼀下

import Vue from './vue.js';

const vm = new Vue({
  el: '#app',
  data: {
    msg: 'hello word',
    count: 0,
    testHtml: '<ul><li>1</li><li>2</li><li>3</li></ul>'
  },
  methods: {
    handler() {
      console.log('1111111111===', 1111111111);
    },
  }
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>vue-model</title>
  </head>
  <body>
    <div id="app">
      msg:{{msg}}
      <div v-text="msg"></div>
      <input v-model="msg" type="text" />
      <button v-on:click="handler">按钮</button>
    </div>
    <script src="./index.js" type="module"></script>
  </body>
</html>

源码在这里: github.com/goodjiang/h…