最近在梳理vue响应式的原理,有一些心得,值得写一篇博客出来看看。
其实之前也尝试过了解vue响应式的原理,毕竟现在面试看你用的是vue的话,基本上都会问你几句vue响应式的原理。以往学习这块就是看看别人写的文章,或者翻翻源码。这个过程中发现相当一部分文章看完之后一句话总结就是—— vue通过 Object.defineProperty
或者 Proxy API
拦截了数据的getter/setter,再在getter/setter里面做数据响应的相关逻辑。除此之外就一无所知了。与此同时又发现直接看源码的话,所花费的时间跟收获比太小了。当然,看源码困难的原因还是现在的水平不够,没到看源码的那一步。
后来我在知乎上看到 一年内的前端看不懂前端框架源码怎么办?这个问题,以及尤雨溪对 维护一个大型开源项目是怎样的体验? 这个问题的回答。思考总结了这么几点启示:
Vue响应式的关键在于数据劫持和发布订阅模式,数据劫持的目的是在于实现发布订阅,通过发布订阅模式,在数据发生变更时通知到各“订阅者”,也就是Vue中的各种模板语法、计算属性、侦听器等等。下面就先介绍与发布订阅相关的几个概念,然后通过自己实现一个简单版Vue的方式介绍一下Vue2.0和Vue3.0响应式的简单模型。注意只是简单模型,但应该可以帮你应付大部分的面试。
本篇文章所用到的代码都放到了GitHub上
观察者模式是一种通知机制,让发送通知的一方(被观察者)和接收通知的一方(观察者)能彼此分离,互不影响。
观察者模式和发布订阅模式是有区别的,具体后面会详细介绍。
这里通过一个简单的例子展示观察者模式,具体的业务逻辑是在商店有新商品上架时通知到顾客。为了方便,这个例子可以直接通过node运行,不需要关注ui的实现。
// 被观察者
module.exports = class Store {
constructor() {
// 商品列表
this.products = new Set()
// 观察者列表
this.observers = []
}
// 注册观察者
addObserver(watcher) {
this.observers.push(watcher)
}
// 有新商品时通知观察者
addProduct(name) {
if (this.products.has(name)) return
this.products.add(name)
// 遍历观察者列表,调用观察者对应处理方法
this.observers.forEach((watcher) => watcher.onPublished(name))
}
}
// 观察者
module.exports = class watcher {
constructor(name) {
this.name = name
}
onPublished(product) {
console.log(`观察者${
this.name}观察到商品${
product}上架`)
}
}
// 在商店有新品上架时,通知顾客
const Store = require('./store')
const Watcher = require('./watcher')
const supermarket = new Store()
const watchA = new Watcher('A')
const watchB = new Watcher('B')
supermarket.addObserver(watchA)
supermarket.addObserver(watchB)
supermarket.addProduct('香蕉')
setTimeout(() => {
supermarket.addProduct('苹果')
}, 3000)
观察者模式有个明显的特征,那就是被观察者维护了一份观察者列表,也就是说被观察者知道有哪些观察者在观察自己。在需要进行通知的时候,遍历列表,通知观察者们。
参考上面观察者模式的例子,在观察者模式中,被观察者主动收集观察者。
而在发布订阅模式中,发布者不需要主动收集订阅者,订阅者订阅发布平台,发布者将变更推给发布平台,由发布平台告知订阅者。
我在 GitHub 上看到一个很形象的比喻:
发布-订阅模式就好像报社, 邮局和个人的关系,报纸的订阅和分发是由邮局来完成的。报社只负责将报纸发送给邮局。
观察者模式就好像 个体奶农和个人的关系。奶农负责统计有多少人订了产品,所以个人都会有一个相同拿牛奶的方法。奶农有新奶了就负责调用这个方法。
根据上面的比喻,我们实现一个简单的读者通过邮局订阅报纸的项目
// 发布者,也就是报社
module.exports = class Publisher {
constructor() {
this.subscribers = []
}
addSubscriber(subscriber) {
this.subscribers.push(subscriber)
}
publish(value) {
this.subscribers.forEach((subscribe) =>{
subscribe.update(value)
})
}
}
// 订阅者,也就是读者
module.exports = class Subscriber {
constructor(cb) {
this.cb = cb
}
update(val) {
this.cb(val)
}
// 订阅者主动订阅发布平台
subscribe(publisher) {
publisher.addSubscriber(this)
}
}
// 发布平台,也就是邮局
module.exports = class Publisher {
constructor() {
this.subscribers = []
}
addSubscriber(subscriber) {
this.subscribers.push(subscriber)
}
publish(value) {
this.subscribers.forEach((subscribe) =>{
subscribe.update(value)
})
}
}
// 通过邮局订阅报纸
const Producer = require('./producer')
const Publisher = require('./publisher')
const Subscriber = require('./subscriber')
// 创建一个报社
const newspaper = new Producer()
// 创建两个邮局用于分发报纸
const postOfficeA = new Publisher()
const postOfficeB = new Publisher()
// 创建三个读者,通过不同的报社订阅报纸
const readerA = new Subscriber((value) => {
console.log(`读者A收到${
value}`)
})
const readerB = new Subscriber((value) => {
console.log(`读者B收到${
value}`)
})
const readerC = new Subscriber((value) => {
console.log(`读者C收到${
value}`)
})
readerA.subscribe(postOfficeA)
readerA.subscribe(postOfficeB)
readerB.subscribe(postOfficeB)
readerC.subscribe(postOfficeB)
newspaper.addPublisher(postOfficeA)
newspaper.addPublisher(postOfficeB)
newspaper.publish('新青年')
从上面的代码可以看出,发布订阅模式与观察者模式最大的不同在于多了一个发布平台。除此之外,发布者不用主动收集订阅者,而是指定发布平台。同时,订阅者也需要在发布平台中注册自己,也就是把自己添加到发布平台内维护的一份订阅者列表中。这里发布平台并没有主动收集订阅者,而是订阅者调用发布平台对应方法,将自己注册到发布平台中。发布者变更时首先通知发布平台,发布平台再遍历自己的订阅者列表,将变更告知订阅者们。
在了解了观察者模式,以及发布订阅模式之后,我们就可以理解Vue响应式的基本模型了。在Vue的官网上有这么一张图:
这张图大概说明了发布订阅的相关逻辑,通过自己的理解提炼出了三个角色:
三个角色联动的关键问题有三个:
正如前言所说,自己实现一遍,能够更好的理解源码。这里就简单的通过 Object.defineProperty
实现监听数据更新进而刷新页面。
首先创建一个html文件和js文件
<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>轻量级vue2.0实现title>
head>
<body>
<div id="app">div>
<script type="module" src="./index.js">script>
body>
html>
// index.js
import Vue from './src/vue.js'
const App = new Vue({
el: '#app',
data() {
return {
name: '特朗普',
info: {
message: '没有人比他更懂',
},
}
},
render(createElement) {
return createElement(
'div',
[
createElement('span', `${
this._data.name} 说: ${
this._data.info.message}`)
]
)
}
})
setTimeout(() => {
App._data.name = '川宝'
}, 2000)
setTimeout(() => {
App._data.info.message = 'MAGA!!!!'
}, 4000)
在index.js中我们创建了一个自己实现的Vue实例,将其挂在到id为app的节点上,接下来实现 ./src/vue.js中的代码
// ./src/vue.js
// 引入处理数据劫持的函数
import observe from './observer.js'
// 引入观察者
import Watcher from './watcher.js'
class Vue {
constructor(options) {
this.$options = options
this._data = options.data()
this.render = options.render
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 数据劫持
observe(this._data)
// 创建一个订阅者,订阅_data的变更
// 订阅者收到变更通知时重新渲染组件
new Watcher(this._data, ()=> {
this.$mount()
})
}
// 这里就是创建html节点
createElement(tagName, children) {
let element = document.createElement(tagName)
if (Object.prototype.toString.call(children) === '[object Array]') {
children.forEach((child) => {
element.appendChild(child)
})
} else {
element.textContent = children
}
return element
}
// 创建并挂载节点
$mount() {
const elements = this.render(this.createElement)
this.$el.innerHTML = ''
this.$el.appendChild(elements)
}
}
export default Vue
接下来就是关键的处理数据劫持的方法
// ./src/observer.js
import Dep from './dep.js'
const typeTo = (val) => Object.prototype.toString.call(val)
// 重写属性get/set方法
function defineReactive(obj, key, val) {
// 每个对象的属性都有一个 Dep 作为该属性变更的发布平台
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// get时发布平台收集订阅者
get() {
console.log(`get ${
key}`)
if (Dep.depTarget && Dep.depTarget.id >= 0) {
console.log(`当前订阅者id: ${
Dep.depTarget.id}`)
}
dep.addSub(Dep.depTarget)
return val
},
// set时发布平台dep通知订阅者
set(newValue) {
console.log(`set ${
key}`)
if (newValue === val) return
val = newValue
dep.notify()
},
})
}
function walk(obj) {
Object.keys(obj).forEach((key) => {
// 如果值是对象,继续处理对象内部的字段
if(typeTo(obj[key]) === '[object Object]'){
walk(obj[key])
}
// 处理属性本身
defineReactive(obj, key, obj[key])
})
}
// observe用于劫持数据
function observe(obj) {
if(typeTo(obj) !== '[object Object]') {
return null
}
walk(obj)
}
export default observe
在./src/observer.js 中引入了dep.js,这便是发布订阅模型中的发布平台对应的类。在observer.js 中通过遍历对象的key,为每个key创建发布平台。当属性get被触发时,证明有订阅者在使用该属性,此时Dep.depTarget会指向使用该属性的订阅者,将其添加到发布平台的订阅者列表中。当属性被设值时,触发set,发布平台遍历订阅者列表,通知订阅者。
在看./src/dep.js的实现
// ./src/dep.js
// 发布订阅模型中的发布平台
class Dep{
constructor() {
// 订阅者列表
this.subs = []
}
addSub(sub) {
// 如果订阅者不在订阅者列表,就把它添加进来
if(sub && (this.subs.indexOf(sub) === -1)) {
this.subs.push(sub)
}
}
notify() {
console.log('通知变更', this.subs.length)
this.subs.length > 0 && this.subs.forEach((sub) => {
sub.update()
})
}
}
Dep.depTarget = null
export default Dep
这部分代码比较易懂,剩下的就是./src/watcher.js的实现了
// ./src/watcher.js
import Dep from './dep.js'
// 通过id区分不同的订阅者
let id = 0
class Watcher{
// 在订阅者创建时,将单例Dep.depTarget指向当前订阅者
constructor(value, cb) {
this.cb = cb
// 创建时调用get方法的目的主要是通过调用cb触发所用到的属性的get
// 进而在对应属性的发布平台中添加该订阅者
this.get()
// this.val指向vue._data
this.val = value
// 通过id可以确定是否为同一个订阅者
this.id = ++id
}
get() {
Dep.depTarget = this
this.cb()
// 发布平台收集完订阅者后重置单例
Dep.depTarget = null
}
// 订阅者在更新的时候调用this.cb()触发所用到属性的get
// 如果该订阅者不在对应属性发布平台的订阅者列表中,则会被添加进列表
update() {
this.get()
console.log('val value', this.val.name, this.val.info.message)
}
}
export default Watcher
在简易版的实现中,3.0和2.0的不同主要体现在 ./src/observer.js 用Proxy API做数据劫持,由于Proxy API返回一个Proxy对象,因此index.js和./src/vue.js某些地方的写法也有所不同
// ./src/observer.js
// 使用proxy api做数据劫持
import Dep from './dep.js'
const typeTo = (val) => Object.prototype.toString.call(val)
// 重写属性get/set方法
function defineReactive(obj) {
// 每个对象的属性都有一个 Dep 作为该属性变更的发布平台
let dep = new Dep()
if (typeTo(obj) !== '[object Object]') {
return null
}
return new Proxy(obj, {
get(target, key) {
console.log('触发get', target, key)
dep.addSub(Dep.depTarget)
return target[key]
},
set(target, key, value, receiver) {
console.log('触发set', target, key, value)
let newValue = Reflect.set(target, key, value, receiver)
dep.notify()
return true
}
})
}
function walk(obj) {
const res = {
}
Object.keys(obj).forEach((key) => {
if (typeTo(obj[key]) === '[object Object]') {
// 如果值是对象,继续处理对象内部的字段
res[key] = walk(obj[key])
} else {
// 如果不是对象,则赋值
res[key] = obj[key]
}
})
// 记得处理该属性本身
return defineReactive(res)
}
// observe用于劫持数据
function observe(obj) {
if(typeTo(obj) !== '[object Object]') {
return null
}
return walk(obj)
}
export default observe
// ./src/vue.js
import observe from './observer.js'
import Watcher from './watcher.js'
class Vue {
constructor(options) {
this.$options = options
this._data = options.data()
this.render = options.render
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 这里和2.0不同
this.$data = observe(this._data)
// 创建一个订阅者,订阅_data的变更
// 订阅者收到变更通知时重新渲染组件
new Watcher(this.$data, ()=> {
this.$mount()
})
}
createElement(tagName, children) {
let element = document.createElement(tagName)
if (Object.prototype.toString.call(children) === '[object Array]') {
children.forEach((child) => {
element.appendChild(child)
})
} else {
element.textContent = children
}
return element
}
$mount() {
const elements = this.render(this.createElement)
this.$el.innerHTML = ''
this.$el.appendChild(elements)
}
}
export default Vue
// index.js
import Vue from './src/vue.js'
const App = new Vue({
el: '#app',
data() {
return {
name: '特朗普',
info: {
message: '没有人比他更懂',
},
}
},
render(createElement) {
return createElement(
'div',
[
createElement('span', `${
this.$data.name} 说: ${
this.$data.info.message}`)
]
)
}
})
setTimeout(() => {
// 通过$data属性操作Proxy API返回的对象
App.$data.name = '川宝'
}, 2000)
setTimeout(() => {
App.$data.info.message = 'MAGA!!!!'
}, 4000)
最终呈现的结果和2.0一样
这里只展示了Vue响应式最简单的模型,肯定在细节和功能上与源码有很大的差异,但通过对发布订阅模式的了解,以及自己分别实现Vue2.0和3.0的简单模型,下次面试官再问的时候心里就不慌了。
【vue系列】从发布订阅模式解读,到vue响应式原理实现(包含vue3.0)
观察者
介绍下观察者模式和订阅-发布模式的区别,各自适用于什么场景