什么是响应式
在 Vue 开发中,我们修改了数据,所有用到这份数据的视图都会更新。
响应式概括来说就是数据驱动视图的自动更新
举个例子,本文也将以下面这段代码来讲解与实现响应式
HTML
{{ obj.message }}
JS
let data = {
obj: {
message: 'Hello Vue!',
},
}
new Vue({
el: '#app',
data,
})
setTimeout(() => {
data.obj = {
message: 'Obj have changed!',
}
}, 1000)
setTimeout(() => {
data.obj.message = 'Message have changed!'
}, 2000)
Vue 会将 “ Hello Vue! ” 渲染在页面中,在一秒后修改了 data.obj
,页面也随之更新为 “ Obj have changed! ”,随后又过一秒 data.obj.message
被修改,页面显示为 “ Message have changed! ”
如何实现响应式
为了实现响应式,要解决2个问题
- 数据什么时候变化了 --- 监视数据
- 哪些地方用到了数据 --- 解析模板
监视数据
Vue 使用了 3 个类,实现了数据的拦截、更新的订阅与发布
Observer
,监视者类,监视数据的变化,在数据变化时告诉通知者,在这个类中将数据的所有属性用Object.defineProperty
重新定义一遍,绑定了存取器(getter/setter)Dep
,通知者类,通知订阅者更新视图,因为一个数据可能被多处使用,所以一个通知者会存储多位订阅者Watcher
,订阅者类,用于存储数据变化后要执行的更新函数,调用更新函数可以使用新的数据更新视图
下面我们来详述 Vue 是如何操作我们传入的 data
数据的
- Vue 拿到了
data
这个对象,创建一个监视者,绑定到对象的__ob__
属性上 - 创建监视者会将对象身上的所有属性用
Object.defineProperty
重新一遍,在定义的同时,为每一个数据创建它的通知者。通知者会在闭包环境中创建,只有该数据的存取器能够访问到。 - 如果对象的值中还有对象,会递归上面的过程
- 处理完
data
后,将其所有属性映射到Vue
实例身上(允许vm.xxx直接访问)
以本文举例,监视数据完成时,创建了两个监视者,分别监视 data 与 obj。创建了四个通知者,分别属于数据 obj 与 message 和两个监视者,为了方便之后的讲解,我们为这些通知者编号
解析模板
数据监视完毕后,Vue 会解析模板
模板解析的内容较为复杂,这一过程会创建虚拟节点 vnode
,匹配到 {{ }}
v-
等响应式写法,会根据当前节点的类型、响应式语法等建立该节点的更新函数 patch
,将数据与视图绑定
本文主要是帮助理解响应式更新的逻辑,模板解析语法并不是本文的重点,所以在之后的讲解与实现中使用的还是 DOM 节点
接下来继续以本文的例子来讲解这一流程
- Vue 会根据
el: '#app'
配置项获取根 DOM 元素,遍历其所有子节点 - 发现一个文本节点的内容是
{{ obj.message }}
,检测到特定的响应式写法,建立更新函数并提取数据表达式(字符串'obj.message'
) 因为要操作的是只是文本节点的内容,所以更新函数较为简单(下方代码)
const patch = (value) => { node.textContent = value }
- 创建订阅者,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数
- 执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者中
- 每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者
- 然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者
模板解析完成时,只创建了一个订阅者但被添加到了四个通知者中
如果细想会发现 Dep2 与 Dep3 的内容完全一致,其实 Dep2 主要是给 vue 其他 api 用的
更新数据
以后修改数据就会触发监视者的 setter,setter 就能告诉通知者,通知其内部的订阅者执行更新函数修改视图,实现了数据的响应式
如果修改的数据值是一个对象,会先为其创建监视者,再告诉通知者发布订阅,执行更新函数访问这些数据时,所有子数据的新通知者又存储了之前解析模板时创建的订阅者
还是根据例子来讲解更新流程
- 第一次修改的是
data.obj
,是一个对象,原对象的监视者、Dep2、Dep4被移除 - 触发 obj 的 setter,新值是一个对象,为其创建监视者,同时创建了空的 Dep2、Dep4
- 告诉 Dep3,通知其中的订阅者执行更新函数
- 更新函数执行访问到了
obj
和obj.message
,将此订阅者又添加进 Dep2 和 Dep4 中 - 第二次修改的是
data.obj.message
- 触发 message 的 setter,告诉 Dep4,通知其中的订阅者更新视图
这就是完整的更新流程了
通知者是用集合存储订阅者的,所以多次访问也只会添加一个订阅者
代码实现
Vue 的源码非常复杂,本文只提取了一小部分,以下代码只实现了响应式更新文本节点的功能
// 公开的Vue类
class Vue {
constructor(options) {
// 保存数据
this._data = options.data
// 创建监视者
observe(this._data)
// 将数据都映射到实例上
this._initData()
// 模板解析
compile(options.el, this)
}
// 遍历数据,映射到实例上
_initData() {
for (const key of Object.keys(this._data)) {
Object.defineProperty(this, key, {
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal
},
})
}
}
}
// 创建监视者并返回
function observe(obj) {
// 如果不是对象,不需要创建监视者
if (typeof obj != 'object') return null
let ob
if (typeof obj.__ob__ !== 'undefined') {
ob = obj.__ob__
} else {
// 创建监视着,传入对象
ob = new Observer(obj)
}
return ob
}
// 监视者
class Observer {
constructor(obj) {
// 创建通知者
this.dep = new Dep()
// 将监视者添加到对象的身上
Object.defineProperty(obj, '__ob__', {
value: this,
})
// 遍历对象数据,定义存取器
for (let k of Object.keys(obj)) {
defineReactive(obj, k)
}
}
}
// 通知者
class Dep {
constructor() {
// 用集合存储自己的订阅者
this.subs = new Set()
}
// 添加订阅者
addSub(watcher) {
// 存储订阅者
this.subs.add(watcher)
}
//发布订阅
notify() {
// 依次执行更新函数
// 浅克隆是为了避免在订阅者中修改同一数据,无限更新
for (const sub of [...this.subs]) {
sub.update()
}
}
// 标志,表示是否处于依赖收集阶段,值为 Wather
static target = null
}
// 定义存取拦截器,创建闭包环境
function defineReactive(data, key) {
// 为数据创建通知者
const dep = new Dep()
// 在闭包环境中用局部变量保存数据
let val = data[key]
// 子数据如果是对象,也创建监视者
let childOb = observe(val)
// 定义存取器
Object.defineProperty(data, key, {
// getter
get() {
// 如果处于依赖收集阶段
if (Dep.target != null) {
// 添加订阅
dep.addSub(Dep.target)
// 监视者的通知者也要添加订阅
if (childOb != null) {
childOb.dep.addSub(Dep.target)
}
}
// 从局部变量获取值
return val
},
// setter
set(newValue) {
if (val === newValue) {
return
}
// 更新局部变量
val = newValue
// 新值也需要尝试创建监视着
childOb = observe(newValue)
// 告诉通知者发布订阅
dep.notify()
},
})
}
// 订阅者
class Watcher {
constructor(vue, expression, callback) {
this.target = vue
this.expression = expression
this.callback = callback
this.value = this.get()
}
update() {
// 获取新值,如果不相等,则执行更新函数
const value = this.get()
if (value !== this.value) {
this.value = value
this.callback.call(this.target, value)
}
}
get() {
// 进入依赖收集阶段,让全局的Dep.target设置成Watcher本身
Dep.target = this
// 沿着路径一致寻找
let val = getObjVal(this.target, this.expression)
// 依赖收集结束
Dep.target = null
return val
}
}
// 根据字符串表达式获取值
function getObjVal(obj, exp) {
let val = obj
exp = exp.split('.')
exp.forEach((k) => {
val = val[k]
})
return val
}
// 编译模板
function compile(el, vue) {
// 获取挂载节点
const $el = document.querySelector(el)
// 创建片段,存储dom节点
let fragment = document.createDocumentFragment()
// 将所有dom节点都放入片段中
let child
while ((child = $el.firstChild)) {
fragment.appendChild(child)
}
// 匹配响应式写法
const reg = /\{\{(.*)\}\}/
// 遍历子节点
// 简便起见,直解析文本节点
for (const node of fragment.childNodes) {
const text = node.textContent
if (node.nodeType == 3 && reg.test(text)) {
// 获取字符串表达式
let name = text.match(reg)[1].trim()
// 从vue中获取数据赋值
node.textContent = getObjVal(vue, name)
// 创建订阅者,绑定表达式与更新函数
new Watcher(vue, name, (value) => {
node.textContent = value
})
}
}
// 上树
$el.appendChild(fragment)
}
总结
Vue2 实现响应式的三个类非常绕,希望读者仔细思考,理清其中关系
最后再强调一遍三个类的功能
- 观察者会在每一个对象身上创建,为其所有属性添加存取器,用于操作通知者发布订阅
- 每一个数据(包括对象)都会独有一个通知者,通知者内使用一个集合存储所有依赖这个数据的订阅者
- 数据在模板中的每一次使用,都会创建一个订阅者,存储更新函数与数据访问表达式
结语
如果文中有不理解或不严谨的地方,欢迎评论提问。
如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。