Vue的响应式原理

323 阅读10分钟

前言

随着互联网行业的飞速发展,互联网所携带的产业也迎来了春天,尤其是前端行业更为突出,各种框架层出不穷,Vue React Angular Solid 等优秀框架的出现,不仅使得前端行业发生了翻天覆地的变化,还大大减轻了开发者的学习成本,但是随着框架的不断优化和版本升级,我们不在只追逐如何去使用这些框架,而是把目光留在了框架本身,比如框架的设计思想、框架的实现方案、以及框架的设计模式等等

为什么介绍Vue2

  • Vue3 的组合式API,和hooks设计已经成熟,可以说是对Vue2进行了彻底的颠覆
  • 比如Vue2中,响应式拦截采用的是Object.definePropertyVue3中采用的是 Proxy
  • 对于小白是学习Vue2 还是 Vue3,尤大在 Vue Toronto 的主题演讲中也回答了 Vue3 是向下兼容的,可以直接学习 Vue3 即可 那么为什么还要介绍Vue2,我认为虽然Vue2已经要新版本取代,但是Vue2的设计思想和实现原理值得我们去深度刨析,框架的设计思想是不会过期的,这仍是值得我们学习的地方

观察者模式

Vue2的响应式开发,采用的就是设计模式中的观察者模式,这个设计模式在开发过程中也是用的最多的一种,下面我们一起来看一下观察者模式的实现原理

观察者模式下有两个重要的角色,分别是发布者订阅者,通常情况下,发布者只有一个,而订阅者有很多个, 当状态发生改变(发布者发布信息),会通知所有订阅者进行更新处理,那么这个过程就是观察者模式的实现

发布者类

class Publisher {
  constructor() {
    // 订阅者 依赖存储器 初始化
    this.observers = [];
  }

  // 增加订阅者
  add(observer) {
    this.observers.push(observer);
  }

  // 删除订阅者
  remove(observer) {
    const eq = this.observers.findIndex(observer);
    if (eq) {
      this.observers.splice(eq, 1);
    }
  }

  // 通知订阅者更新
  notify() {
    this.observers.forEach((item) => {
      item?.update(this);
    });
  }
}

我们看一下这段代码,这是一个发布者类,主要的方法有,添加订阅者add、删除订阅者remove、通知订阅者notify

订阅者类

class Observer {
  constructor() {
    console.log("订阅者 创建了");
  }

  update() {
    console.log("更新");
  }
}

订阅者类的解构很简单,只有一个 update方法, 订阅者 通过调用 Publisher.add(订阅者)发布者发布通知后,会逐个调用 notify 进行调用

现学现用

最近华为mate50iphone14发布会已经结束,大家都在挣钱恐后的抢购,导致服务器爆炸,特喵的我根本就挤不进去,跑偏了不好意思,哈哈哈。那么我们现在有个需求,有一个手机厂商(phoneFactory),他会不定时的发布一款新的手机,而我们有A、B、C三位同学(studentObserver)着急换手机,所以就一直在等待这个厂商的新品发布,那么我们如何实现一个当phoneFactory工厂发布新手机时,通知studentObserver购买

PhoneFactory
class PhoneFactory extends Publisher {
  constructor() {
    // 执行继承类的构造函数
    super();
    // 初始化手机 默认是没有
    this.phone = null;
    this.phonePrice = 0;
    // 订阅者列表 存起来用于通知
    this.observers = [];
  }

  // 获取当前的产品名称和价格
  getProduct() {
    return {
        phone: this.phone,
        phonePrice: this.phonePrice
    };
  }

  // 修改产品信息
  setPrd(phone, phonePrice) {
    this.phone = phone;
    this.phonePrice = phonePrice;
    // 通知订阅者可以购买了
    this.notify();
  }
}
studentObserver
class StudentObserver extends Observer {
  constructor(name, price) {
    super();
    // 订阅者姓名
    this.name = name
    // 资产
    this.price = price;
    // 当前手机型号
    this.phoneModel = 'HUAWEI nova5 pro';
  }

  // update 购买
  update(Publisher) {
    // 更新需求文档
    let foo, boo = false
    foo = Publisher.getProduct();
    if(foo.phonePrice < this.price) {
        // 买得起
        boo = true
        // 换新手机了,修改手机的参数
        this.price -= foo.phonePrice
        this.phoneModel = foo.phone
    } 
    // 其他逻辑
    this.run(boo);
  }

  // 执行的逻辑
  run(boo) {
     console.log(
      `${this.name} ${boo ? "购买了新手机" : "存款买不起"}`,
      `存款剩余 ${this.price}`, `当前手机:${this.phoneModel}`
    );
  }
}

代码测试:

// 工厂
const Factory = new PhoneFactory()
// 学生A、B、C
// A同学有一万块钱
const A  = new StudentObserver('A', 10000)
// B同学有四千块钱
const B  = new StudentObserver('B', 4000)
// C同学有两千块钱
const C  = new StudentObserver('C', 2000)
// 学生订阅到工厂
Factory.add(A)
Factory.add(B)
Factory.add(C)

// 工厂发布手机
Factory.setPrd('iphone14 pro max 远峰蓝', 9999)

我们运行代码查看输出的结果

image.png

我们可以看到,订阅者创建了三次,因为有三名同学订阅了这个工厂,当工厂发布手机时,三名同学会触发购买流程,但是很可惜B、C两位同学因为买不起错过了这次发布会

以上实例就是一个完整的观察者模式的示例,虽然有时候写法大同小异,但是设计思想都是一样的,无非就是通过订阅,然后发布者修改数据并通知订阅者完成具体的逻辑操作

Vue2 中响应式的实现

我们先来看一段代码

var vm = new Vue({
  el: "#app",
  data: {
    name: "yunhe达摩院",
    age: 16,
    school: "郑州大学",
  },
});

我们在使用Vue2时,首先会在data定义一个对象或者闭包, data 存放的是我们响应式处理的数据,当我们在模板中渲染data中的数据后,如果data里面的数据发生了改变,就会通知模板更新绑定的data

现在我们撸一撸思路Vue修改参数,通知模板更改,这用的不就是我们的观察者模式

  1. 模板调用data,完成订阅
  2. data.xxx = newXxx 修改数据后,通知notify修改页面上的变量,完成响应式更新

那么怎么去用代码具体实现Vue2的响应式开发,下面让我们一起来看一下

文件目录结构如下

image.png

main.js 为入口文件
index.jsvue文件
dep.js 为订阅者文件
proxy.js 为代理文件
watcher.js 为订阅者文件
defineReactive.js 为拦截器文件
mount.js 为渲染文件

简单的实现Vue2的响应式,大致需要以上几个文件,现在都是空白文件,现在让我们一步一步的根据我们的思路,去实现一下Vue2的响应式处理

main.js

main.js 是项目的入口文件,这里通常是使用Vue的地方,所以我们可以根据Vue2的使用原理去编写一个Vue2的解构代码

import Vue from "./index.js";
var vm = new Vue({
  el: "#app",
  data: {
    name: "达摩院",
    age: 16,
    school: "郑州大学",
  },
  // data 还可以使用闭包
  // data() {
  //   return {
  //     name: "达摩院",
  //     age: 16,
  //     school: '郑州大学'
  //   }
  // }
});
// 将 $vm 绑定到window全局变量上,帮助我们在控制台可以操作修改变量
window.$vm = vm;

index.js

我们在 main.js 中导入了 import Vue from "./index.js",所以接下来我们要创建它

export default function Vue(options) {
    // _init 用于初始化;
    this._init(options);
}

// 初始化 把data绑定到
Vue.prototype._init = function (options) {
    // 把数据配置挂载到Vue.$options 上
    this.$options = options;
    // 初始数据(data methods props computed等 这里制作简单的处理 data)
    initData(this);
}

// 初始化数据 具体的方法实现
function initData(vm) {
    const {
        data,
        el
    } = vm.$options
    // data 就是我们在首页 new Vue({data(){return {}}})  的data
    let _data
    if (data) {
        // 把data的值挂载到vm实例上
        // data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
        _data = vm._data = typeof data === 'function' ? data() : data
    }
}

现在我们已经将 new Vue({data(){return {}}})data 绑定到了Vue示例上, 现在让我们打开控制台看一下

image.png

现在我们不仅可以查看绑定的数据,而且还可以进行修改,那么在Vue2的逻辑中,我们在进行修改属性时,会被系统劫持,也就是说我们所有的赋值操作,都应该被拦截到,这里我们使用的是 defineReactive.js

defineReactive.js

export default function defineReactive(target, key, val) {
    Object.defineProperty(target, key, {
        get() {
            return val
        },
        set(value) {
            console.log(val,'被修改了 ',value);
            if (val === value) return
            val = value
        }
    })
}

修改 index.js

import defineReactive from "./defineReactive.js";

export default function Vue(options) {
  this._init(options);
}
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
  console.log("options ==> ", options);
  // 把数据配置挂载到Vue.$options 上
  this.$options = options;
  // 初始数据  (data methods props computed等 这里制作简单的处理 data)
  initData(this);
};

// 初始化数据 具体的方法实现
function initData(vm) {
  const { data, el } = vm.$options;
  // data 就是我们在首页 new Vue({data(){return {}}})  的data
  // console.log('data ==> ', data);
  let _data;
  if (data) {
    // 把data的值挂载到vm实例上
    // data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
    _data = vm._data = typeof data === "function" ? data() : data;
  }

  // 给对象属性设置响应式 (只支持对象)
  function walk(obj) {
    for (let key in obj) {
      // 依次给对象设置拦截
      defineReactive(obj, key, obj[key]);
    }
  }
  // 设置拦截
  walk(_data);
}

运行示例查看效果

image.png

到目前为止,我们的程序已经可以监听到我们的修改了

index.html

通过上面的示例,我们已经创建了Vue文件,并设置了拦截修改的处理,但是我们发现,我们的数据只能在console.log内运行,现在我们渲染一下我们的数据,看一下我们的效果

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app">
      <div>{{name}}</div>
      <div>{{age}}</div>
      <div>{{shops}}</div>
      <div>{{school}}</div>
      <div>{{name}}</div>
    </div>
    <script type="module" src="./main.js"></script>
  </body>
</html>

image.png

mount.js

现在我们已经将变量放置在了模板块中,但是我们现在还无法渲染数据,因为我们的mount挂载逻辑没有处理,现在让我们完善一下mount.js

// 把数据挂载到页面上
export default function mount(vm) {
    // 获取根节点
    let {
        el
    } = vm.$options;
    el = document.querySelectorAll(el);
    // el[0].childNodes 默认情况下是 NodeList类数组,输出可看,不能直接进行遍历需要转换为普通数组
    // 把vm实例传下去 方便替换数据
    compuleNode(Array.from(el[0].childNodes), vm)
}

// 查找变量并替换
function compuleNode(nodes, vm) {
    // node 是我们拿到的所有根节点下的dom
    // 遍历dom节点
    for (const node of nodes) {
        // 节点有区别: 
        // nodeType === 1 表示是元素div span这些元素等
        // nodeType === 3 表示是文本 也就是最后一级
        if (node.nodeType === 3 && node.textContent.match(/{{(.*)}}/)) {
            // node.textContent.match(/{{(.*)}}/) 会对我们的结果进行分组 {{name}}  {{age}} 
            // 通过正则 可以使用下标获取结果
            // console.log('RegExp.$1 ==> ', RegExp.$1);
            // 既然是文本 那么我们就可以直接查找替换元素即可
            // <div>{{name}}</div>  我们要判断他是否有 {{}}
            //  是文本节点 并且有 {{(.*)}} 因为我们的变量是通过双括号包含的
            compuleTextNode(node, vm);
        }

        // 如果是dom元素 就递归遍历查找他的子元素
        if (node.nodeType === 1) {
            compuleNode(node.childNodes, vm)
        }
    }
}

// 替换数据
function compuleTextNode(node, vm) {
    // 获取匹配的结果 
    // 替换dom得值
    const key = RegExp.$1
    // 写一个回调 专门修改当前这个节点的数据
    // 如果 vm[key] 有数据 就返回数据 没有就返回 未找到
    node.textContent = vm[key] ? JSON.stringify(vm[key]) : `${key} 未定义`;
}

修改 index.js

import mount from "./compiler/mount.js";
import defineReactive from "./defineReactive.js";

export default function Vue(options) {
  this._init(options);
}
// 初始化 把data绑定到
Vue.prototype._init = function (options) {
  console.log("options ==> ", options);
  // 把数据配置挂载到Vue.$options 上
  this.$options = options;
  // 初始数据  (data methods props computed等 这里制作简单的处理 data)
  initData(this);
  // 挂在到dom
  if (this.$options.el) {
    this.$mount();
  }
};

// 挂在实例
Vue.prototype.$mount = function () {
  // 挂载到页面上
  mount(this);
};
}

运行文件,我们会发现,所有的变量全部都报未定义

image.png

那是因为在这里,我是需要通过 _data.xx 来访问 xx 变量的,在Vue中,我们使用data中的变量的时候,直接使用的方式是 this.xxx,而不是 this.data.xxx, 当然,我们也可以渲染 _data 到页面查看

index.html 输出 {{_data}}

image.png

虽然使用_data.xxx的方式可行,但是我们总不能一直使用_data.为前缀吧,这样的话会使得我们的代码非常的不堪,那么这个地方,我们可以使用proxy代理,将 xxx 代理到 vm._data 上,这样当我们访问xxx时,会被拦截并代理到_data.xxx

proxy.js

export default function proxy(target, sourceKey, key) {
    // 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a 
    // proxy(Obj, data, a) 代理
    Object.defineProperty(target, key, {
        get() {
            // this.a 访问 this._data.a
            return target[sourceKey][key]
        },
        set(val) {
            // this.a = 1 修改 this._data.a = 1
            target[sourceKey][key] = val
        }
    })
}

index.js 中调用

import mount from "./compiler/mount.js";
import defineReactive from "./defineReactive.js";
import proxy from './proxy.js'

// 初始化数据 具体的方法实现
function initData(vm) {
  const { data, el } = vm.$options;
  // data 就是我们在首页 new Vue({data(){return {}}})  的data
  // console.log('data ==> ', data);
  let _data;
  if (data) {
    // 把data的值挂载到vm实例上
    // data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
    _data = vm._data = typeof data === "function" ? data() : data;
  }

  // 给对象属性设置响应式 (只支持对象)
  function walk(obj) {
    for (let key in obj) {
      // 依次给对象设置拦截
      defineReactive(obj, key, obj[key]);
    }
  }
  // 设置拦截
  walk(_data);

  // proxy 映射
  for (const key in _data) {
    // 把data数据代理到 _data 下 是他支持 this.xxx 调用
    // 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
    // props methods computed同理
    proxy(vm, "_data", key);
  }
}

完成代理后,我们在浏览器中重新执行,会发现,所有的数据都已经被渲染出来了

image.png

现在我们已经实现了一个很小的Vue模板渲染功能,但是,我们目前的功能还不能够进行响应式的实现,因为我们修改了数据,也仅仅只是修改了对象中的数据而已,并没有被重新渲染到页面上

image.png

重点来袭

vue中,发布者订阅者的关系比较抽象

  • 我们定义一个Dep 类用来订阅 data,当data修改时通知Dep类更新,dataDep 关系是一对一的订阅模式,一个data 只能被订阅一次,在这里data 等于被 Dep 订阅
  • 在模板渲染中,我们定义一个Watcher类,当我们在模板中渲染data时,我们将调用的地方传递给Watcher,并让 Watcher 订阅 DepDep修改后通知模板Watcher渲染模板,也就是说,DepWatcher的关系属于是一对多的关系, 一个Dep可以被多个Watcher调用

具体的流程图如下

image.png

当我们修改name 时,会触发更新DepDep更新后会开始遍历更新Watcher,此时只会去更新name的订阅者,而不会影响其他
也就是说在这里,Dep不仅仅是订阅者,他也是发布者

Dep.js

理解完上面的原理,我们现在开始创建Dep

class Dep {
    // Dep 不仅是订阅者 他订阅后还要收集watcher来更新模板 所以Dep也可以说是发布者
    constructor() {
        this.watcher = []
    }
    // target 表示节点, 当在模板中调用变量时,target指向哪个dom节点
    static target = null

    depend() {
        // 主要用于保存 Watcher,Watcher 会在DOM调用时处理
        // Dep.target 就是 当前模板中的 Watcher
        this.watcher.push(Dep.target)
    }

    // 通知依赖 watcher 更新
    notify() {
        this.watcher.forEach(sub => {
            sub.update()
        })
    }
}

export default Dep

处理完 Dep 我们开始订阅 data
修改 index.js

// 添加引用
import Dep from "./dep.js";

// 修改方法
function initData(vm) {
  const { data, el } = vm.$options;
  // data 就是我们在首页 new Vue({data(){return {}}})  的data
  // console.log('data ==> ', data);
  let _data;
  if (data) {
    // 把data的值挂载到vm实例上
    // data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
    _data = vm._data = typeof data === "function" ? data() : data;
  }

  // proxy 映射
  for (const key in _data) {
    // 把data数据代理到 _data 下 是他支持 this.xxx 调用
    // 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
    // props methods computed同理
    proxy(vm, "_data", key);
  }

  // 设置完代理以后 给data数据设置响应式更新 也就是 Dep订阅data
  observe(vm._data);
}

// 响应式判断和设置响应式
function observe(data) {
  // 如果数据已经是响应式 就不需要observe进行依赖收集 否则会造成 重复更新指定节点
  if (data.__ob__) return value.__ob__;
  return new Observer(data);
}

// 订阅
function Observer(data) {
  // 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
  Object.defineProperty(data, "__ob__", {
    value: this,
    // 数据可枚举
    enumerable: false,
    // 数据可修改
    writable: true,
    // 数据可配置
    configurable: true,
  });
  console.log("data ==> ", data);
  // 对值进行依赖收集也就是订阅
  data.__ob__.dep = new Dep();
  // 给对象的每一项都设置响应式
  this.walk(data);
}

// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    // 依次给对象设置拦截
    defineReactive(obj, key, obj[key]);
  }
};

添加完Dep订阅以后,我们在控制台输出查看我们的变量是否被Dep订阅

image.png

在这里Dep已经成功订阅了data

Watcher.js

Dep订阅了data之后,我们开始处理模板中使用data时,让Watcher订阅Dep

首先我们要知道 Watcher 其实就是更新Dom 操作的
我们修改一下mount.js下面的 ompuleTextNode

// 新增引入
import Watcher from "../watcher.js";

function compuleTextNode(node, vm) {
    // 获取匹配的结果 
    // 替换dom得值
    const key = RegExp.$1
    // 封装成一个方法 专门更新当前的Dom
    function cb() {
        // 如果 vm[key] 有数据 就返回数据 没有就返回 未找到
        node.textContent = vm[key] ? JSON.stringify(vm[key]) : `${key} 未定义`;
    }
    // Watcher 是专门用来处理Dom节点更新的
    // 我们把cb回调传递给Watcher,当更新至 执行这个cb() 就可以完成重新渲染
    new Watcher(cb)
}

Wtcher.js

import Dep from "./dep.js"
class Watcher {
    // cb 是修改指定dom的闭包回调,只修改某一个
    constructor(cb) {
        // Dep.target 是一个静态变量, this就是我们当前的Watcher,赋值给Dep后,Dep放入到订阅数组中
        // 为什么要使用Dep.target 因为我们必须要知道我们调用data的时机
        // js this.xxx调用时 我们不需要获取依赖,所以 Dep.target 主要适用于判断那我们的时机
        Dep.target = this
        // 这一步是将修改的回调函数存储起来
        this._cb = cb
        // 执行一下,因为初始化状态需要执行以下,将 {{name}} 转换为 data 的变量值
        this._cb()
        // 处理完毕后 要制空 否则会阻断后面的变量
        Dep.target = null
    }
    // 执行更新
    update() {
        this._cb()
    }
}

export default Watcher

创建完DepWatcher之后,我们开始处理最后一步,也就是拦截修改,通知Dep修改,并让Dep通知Watcher修改

修改defineReactive.js
当我们在模板中调用 data 时,我们需要将Dep订阅这个data

import Dep from "./dep.js";
// 拦截器
export default function defineReactive(target, key, val) {
  // Dep 订阅 data  一对一
  const dep = new Dep();
  Object.defineProperty(target, key, {
    get() {
      // Dep.target 表示 Watcher,可以输出查看
      // Dep.target 如果有值,表示是在模板中使用了data,需要订阅
      // 如果只是在 js 中使用了,那么不进行订阅
      if (Dep.target) {
        // 订阅
        dep.depend();
      }
      return val;
    },
    set(value) {
      if (val === value) return;
      val = value;
      // dep 通知 watcher更新
      // watcher 就是修改模板方法
      dep.notify();
    },
  });
}

至此,我们已经实现了响应式的原理,我们在控制台更改一下数据,看看是否能够完成响应式的更改

t13qa-g04ks.gif

总结

Dep是怎么与data进行绑定的
答:当我们在模板中调用data时,会触发拦截器的get()方法,在这个方法里,会去判断是否是模板文件调用的data,我们在new Watcher()时,会将 Dep.target 指向当前 Watcher,我们通过Dep.target来判断是否是模板调用的data,如果是那么就将Dep订阅到data

结尾

Vue的响应式设计原理,不论是Vue2还是Vue3,都是采用的观察者模式来实现的,这个设计模式不论是在工作中,还是在面试中,都非常的常见,而且非常的重要,如果感兴趣的话,可以去了解一下js中的设计模式,我想一定会对你的思路和间接有一定的提升

完整项目文件