导读
今天分享一下学习内容:Vue.js响应式原理
Vue.js响应式是Vue.js框架的核心之一,主要体现在下面三点中:
1、数据响应式
数据模型仅仅是普通的JavaScript对象,当我们修改数据时,视图会进行更新,避免了繁琐的DOM操作,提高开发效率
2、双向绑定
数据改变,视图改变; 视图改变,数据改变; 可以使用v-model在表单元素上创建双向数据绑定
3、数据驱动
数据驱动是vue最独特的特性之一,开发过程中只需要关注数据本身,不需要关心数据如何渲染到视图。
响应式原理
当我们将一个普通的JS对象传入vue实例作为data选项,vue将遍历此对象所有的属性。
Object.defineProperty
在Vue.js2.X版本中,响应式原理使用Object.defineProperty把这些属性全部转化为getter/setter。Object.defineProperty是ES5中不可shim的特性,这就是Vue不支持IE8及更低浏览器的原因。
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>defineProperty</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello'
}
// 模拟 Vue 的实例
let vm = {}
// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
// 可枚举(可遍历)
enumerable: true,
// 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义)
configurable: true,
// 当获取值的时候执行
get () {
console.log('get: ', data.msg)
return data.msg
},
// 当设置值的时候执行
set (newValue) {
console.log('set: ', newValue)
if (newValue === data.msg) {
return
}
data.msg = newValue
// 数据更改,更新 DOM 的值
document.querySelector('#app').textContent = data.msg
}
})
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
</script>
</body>
</html>
Object.defineProperty中传入三个参数,第一个参数为一个对象,第二个参数为将要添加到当前对象中的属性名,第三个参数为属性描述符,通过属性描述符中的get和set方法可以将属性值进行获取和修改。在属性描述符中有两个常用属性,
若一个对象中有多个属性,那么可以通过循环的方式遍历所有的属性:enumerable代表可枚举,也就是可循环;configurable代表可配置,也就是可以通过defineProperty重新定义或使用delete删除
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>defineProperty 多个成员</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 10
}
// 模拟 Vue 的实例
let vm = {}
proxyData(data)
function proxyData(data) {
// 遍历 data 对象的所有属性
Object.keys(data).forEach(key => {
// 把 data 中的属性,转换成 vm 的 setter/setter
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get () {
console.log('get: ', key, data[key])
return data[key]
},
set (newValue) {
console.log('set: ', key, newValue)
if (newValue === data[key]) {
return
}
data[key] = newValue
// 数据更改,更新 DOM 的值
document.querySelector('#app').textContent = data[key]
}
})
})
}
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
</script>
</body>
</html>
Proxy
在Vue.js3版本中,通过Proxy的方式实现数据劫持,与Object.defineProperty不同的是,Proxy直接监听的是对象,而非属性。
Proxy是ES6中新增的语法,所以不支持IE浏览器,性能由浏览器自己优化
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Proxy</title>
</head>
<body>
<div id="app">
hello
</div>
<script>
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 0
}
// 模拟 Vue 实例
let vm = new Proxy(data, {
// 执行代理行为的函数
// 当访问 vm 的成员会执行
get (target, key) {
console.log('get, key: ', key, target[key])
return target[key]
},
// 当设置 vm 的成员会执行
set (target, key, newValue) {
console.log('set, key: ', key, newValue)
if (target[key] === newValue) {
return
}
target[key] = newValue
document.querySelector('#app').textContent = target[key]
}
})
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
</script>
</body>
</html>
在代码中,我们通过new Proxy的方式创建一个代理对象,里面传入两个参数:第一个参数为我们需要代理的对象,第二个参数是内部的方法,执行代理成员的函数,包括get和set等。与Obeject.defineProperty不同的是,get方法中传入了两个参数,target为代理的对象,key为代理对象中内部的属性值,不需要我们手动传递。set方法中同样有这两个参数,newValue参数为修改的值,当对代理对象属性进行修改时,set方法首先去判断修改后的值与原值是否相同,相同直接返回,不同则进行修改和重新渲染。
发布订阅模式
发布订阅模式是vue中常用的一种场景,通过事件中心保存内容,发布者和订阅者互相不知道对方的存在,通过向事件中心进行发布和订阅来实现。在Vue中,兄弟组件的通信就是通过自定义事件实现了发布订阅模式,发布者通过$emit发布一个消息(触发一个事件),订阅者通过on订阅一个消息(注册事件)。在订阅者注册事件时,可以注册多个事件,也可以注册同一个事件的多个处理函数。
下面我们来试一下去实现发布订阅模式:
// 事件触发器
class EventEmitter {
constructor () {
// { 'click': [fn1, fn2], 'change': [fn] }
this.subs = Object.create(null)
}
// 注册事件
$on (eventType, handler) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 触发事件
$emit (eventType) {
if (this.subs[eventType]) {
this.subs[eventType].forEach(handler => {
handler()
})
}
}
}
// 测试
let em = new EventEmitter()
em.$on('click', () => {
console.log('click1')
})
em.$on('click', () => {
console.log('click2')
})
em.$emit('click')
观察者模式
观察者模式与发布订阅模式有一些类似,观察者模式是由具体目标进行调度,在观察者模式中,发布者与订阅者是相互依赖的,不存在事件中心进行统一处理,所以这一点是和发布订阅模式是不同的
// 发布者-目标
class Dep {
constructor () {
// 记录所有的订阅者
this.subs = []
}
// 添加订阅者
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发布通知
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 订阅者-观察者
class Watcher {
update () {
console.log('update')
}
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
在观察者模式中,发布者在数据进行变化时,会调用notify()方法通知所有的观察者,调用观察者中的update()方法来执行对应的业务,当观察者对发布者的数据变化有兴趣,会调用目标对象的addSub方法进行记录,目标对象记录了所有的的观察者,发布者和观察者存在依赖
Vue 响应式原理模拟
下面我们来实现一下vue的响应式原理
vue.js
在这里我们定义一个Vue的类,在这个类中,我们需要做的是将新建vue实例的数据保存起来,并且通过vue类内部的proxyData方法将data成员转换成getter和setter,通过obeserver监听数据变化,通过compiler解析插值表达式和指令
class Vue {
constructor(options) {
// 通过属性保存选项数据
this.$options = options || {}
this.$data = options.data || {}
// 判断el是否为字符串或者dom对象
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 把data中的成员转换成getter和setter注入到vue实例中
this._proxyData(this.$data)
// 调用observer对象,监听数据变化
new Observer(this.$data)
//调用compiler对象,解析插值表达式和指令
new Compiler(this)
}
_proxyData (data) {
// 遍历data中的所有属性
Object.keys(data).forEach(key => {
// 把data属性注入vue实例中
Object.defineProperty(this, key, {
// 可枚举
enumerable: true,
// 可配置
configurable: true,
get() {
return data[key]
},
set(value) {
if(data[key] === value) {
return
}
data[key] = value
}
})
})
}
}
在vue这个类中,我们在构造函数中将vue类内部的**data和el中对传入的options.el进行判断,传入的为字符串时,我们将其转换成dom对象,若传入的为dom对象直接赋值。
随后我们在_proxyData方法中传入了data参数,在这个方法中,通过Object.keys方法对传入的data中的属性进行便利,随后通过Object.defineProperty对其进行响应式处理
在构造函数中,我们通过observer来监听数据变化,并且通过compiler对插值表达式和指令进行解析
observer.js
在observer类中,我们需要对$data中的属性进行监听,监听属性的变化。
class Observer {
constructor (data) {
this.walk(data)
}
// 遍历
walk(data) {
// 判断data 是否为对象
if (!data || typeof data !== 'object') {
return
}
// 遍历所有属性调用defineReactive
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
// 将属性转换为 getter 和 setter
/**
*
* @param {对象} obj
* @param {对象的属性名} key
* @param {属性值} val
*/
defineReactive(obj, key, val) {
const self = this
// 收集依赖并发送通知
let dep = new Dep()
// 调用walk方法判断当前属性值为对象类型时,内部的属性无法转换成getter和setter
// 通过再次遍历实现响应式
this.walk(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖
Dep.target && dep.addSub(Dep.target)
return val
},
set(newVal) {
if(newVal === val) {
return
}
val = newVal
// 当当前属性修改为对象时,解决修改后无法转换成getter和setter的问题
self.walk(newVal)
// 发送通知
dep.notify()
}
})
}
}
在observer类中,一共有两个方法,walk方法的作用是遍历当前data的属性,判断属性是否为对象类型,不是对象类型直接返回。并且通过遍历的方式调用defineReactive方法
在defineReactive方法中,同样适用了Object.defineProperty实现响应式处理。在处理之前,需要先对当前循环的属性进行walk函数的遍历处理,防止当前属性为Object类型时,无法将内部的属性值转换成getter和setter。
需要注意的是,调用defineReactive方法时,传入了data[key]这个值,这是因为在当前vue实例中,
get方法获取当前属性的值的时候,是通过在vue类中的get方法获取,在vue类中获取的值可以
通过data[key]的方式获取值,随后在observer中的get方法获取当前属性的值,
但是在observer类中,若通过data[key]的方式获取属性值,将会出现死递归的情况,
所以在这里进行了处理。
通过调用defineReactive方法时传入val,将val中的值进行闭包处理,不会释放val的值
在defineReactive方法中的set方法内部再次调用了walk方法,这是因为当将当前属性值修改为对象类型时,无法转换成响应式数据,通过再次递归实现将属性修改为对象时,解决修改后无法转换成getter和setter的问题。
在defineReactive方法中,通过构造函数生成了Dep对象,这个对象的作用是作为发布者进行收集依赖和发送通知,收集依赖是在get方法中收集,通过判断target是否存在,target是观察者watcher添加的属性,判断之后,在set方法中通过notify进行发送通知给观察者,Dep类和Watcher类后续我们会提到,这里简单介绍一下。
compiler.js
compiler这个类的功能是负责编译模板,解析插值表达式和指令,同时负责页面的首次渲染,让数据发生变化时,重新渲染视图
class Compiler {
constructor(vm) {
this.el = vm.$el
this.vm = vm
this.compile(this.el)
}
// 编译模板,处理文本节点和元素节点
compile (el) {
let childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 处理文本节点
if(this.isTextNode(node)) {
this.compileText(node)
}
// 处理元素节点
else if(this.isElementNode(node)) {
this.compileElement(node)
}
// 判断node是否有子节点,递归调用
if(node.childNodes && node.childNodes.length) {
this.compile(node)
}
})
}
// 编译元素节点,处理指令
compileElement(node) {
// 遍历所有的属性节点
Array.from(node.attributes).forEach(attr => {
//判断是否为指令
let attrName = attr.name
if(this.isDirective(attrName)) {
// v-text => text
attrName = attrName.substr(2)
let key = attr.value
this.update(node, key, attrName)
}
})
}
// 处理相关的指令,通过attrName拼接形式
update(node, key, attrName) {
let updateFn = this[attrName + 'Updater']
updateFn && updateFn.call(this, node, this.vm[key], key)
}
// 处理v-text指令
textUpdater (node, value, key) {
node.textContent = value
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
// 处理v-model指令
modelUpdater (node, value, key) {
node.value = value
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
// 处理文本节点(插值表达式)
compileText(node) {
// {{ msg }}
let reg = /\{\{(.+?)\}\}/
// 获取当前文本节点内容
let value = node.textContent
if(reg.test(value)) {
// 去除多余空格
let key = RegExp.$1.trim()
// 替换插值表达式内容
node.textContent = value.replace(reg, this.vm[key])
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
// 判断元素属性是否是指令
isDirective(attrName) {
return attrName.startsWith('v-')
}
// 判断节点是否是文本节点
isTextNode(node) {
// 3 为文本节点
return node.nodeType === 3
}
// 判断节点是否是元素节点
isElementNode(node) {
// 1 为元素节点
return node.nodeType === 1
}
}
在compiler这个类中。首先在构造函数中存入el和vue实例,随后调用compile方法立即编译模板。在compile方法中,需要明确的是el的子节点是一个伪数组,通过Array.from转换成数组进行遍历,随后在内部对节点元素进行判断,分别调用isTextNode方法和isElementNode方法判断是否为文本节点或者元素节点,node.nodeType为 3 时是文本节点,node.nodeType为 1 时是元素节点。
在compile方法中,判断了节点类型后,分别调用对应的compileText方法和compileElement方法,对指定的节点进行编译,随后再次判断当前循环的节点中是否存在子节点,若存在子节点,递归调用compile方法继续对内部的子节点进行编译
在compileText方法中,传入了节点参数,首先通过正则表达式获取{{}}这种文本节点格式,随后通过判断获取当前符合正则表达式内容的节点值,经过trim去除多余空格,随后替换到节点内部的textContent。
在compileElement方法中,同样传入了节点参数,首先将node.attributes伪数组转换成数组进行遍历,随后通过isDirective方法判断是否为指令,通过substr截取v-指令后面的内容,通过update方法进行处理。在update方法中,传入了当前节点,节点值和节点指令v-后面的内容,通过方法名拼接的方式,对指定指令名需要进行的操作调用指定的方法,这样减少了判断当前指令内容的if语句的编写,大大提高了代码的可维护性。
在textUpdater、modelUpdater和compileText中,通过new Watcher生成了watcher对象,这里是watcher作为观察者生成观察者对象,当数据发生改变时,对视图进行更新,这里我们后续在watcher.js中会提到
dep.js
dep类作为发布者,是实现观察者模式的重要的类,上面observer类中通过构造函数实现了收集依赖和发送通知,这里我们通过代码介绍一下
class Dep {
constructor () {
// 存储所有的观察者
this.subs = []
}
// 添加观察者
addSub(sub) {
if(sub && sub.update) {
this.subs.push(sub)
}
}
//发送通知
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
dep的主要作用是存储观察者对象,并向观察者发送通知。在构造函数中初始化一个观察者对象的空数组,在addSub方法中,判断传入参数不为空并且内部存在update方法,将判断结果传入到观察者数组中,最后通过notify方法对观察者数组对象进行遍历,调用每个观察者对象的update方法进行发送通知。
watcher.js
watcher的作用是:dep在get方法中收集依赖,在数据通过set方法进行修改时,会触发notify方法对每个watcher进行发送消息,也就是触发依赖,这时watcher开始更新视图。当创建一个watcher对象时,需要将创建的对象添加到dep中的subs数组中。
class Watcher {
constructor (vm, key, cb) {
this.vm = vm
// data中的属性名称
this.key = key
// 回调函数负责更新视图
this.cb = cb
// 把watcher对象记录到Dep类中的静态属性target
Dep.target = this
// 触发get方法,在get方法中调用addSub
this.oldValue = vm[key]
// 防止重复添加
Dep.target = null
}
// 当数据发生变化时更新视图
update() {
let newValue = this.vm[this.key]
if(newValue === this.oldValue) {
return
}
this.cb(newValue)
}
}
在watcher中,构造函数传入了三个参数:当前vue实例,data中的属性名称key和回调函数,并且在构造函数中, 通过Dep.target = this将当前观察者对象记录到Dep类中并赋予静态属性target,并且在设置oldValue属性时,通过vm[key]的方式获取当前值,同时触发了get方法,get方法中对Dep.target进行了判断并调用了addSub方法。后续将其设置为null防止重复添加
在update方法中,对当前数据进行赋值,并与oldValue进行判断,当数据发生更新,调用回调函数并传入newValue,更新视图
我们知道,vue更新视图是在compiler中实现的,所以我们回到compiler中
// 处理v-model指令
modelUpdater (node, value, key) {
node.value = value
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
// 处理文本节点(插值表达式)
compileText(node) {
// {{ msg }}
let reg = /\{\{(.+?)\}\}/
// 获取当前文本节点内容
let value = node.textContent
if(reg.test(value)) {
// 去除多余空格
let key = RegExp.$1.trim()
// 替换插值表达式内容
node.textContent = value.replace(reg, this.vm[key])
// 创建watcher对象,当数据改变更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
这里只截取一部分compiler中的代码,在这段代码中,在处理文本节点和相关数据指令的方法中创建了watcher对象,并传入当前Vue实例和属性值,在回调函数中,对当前插值表达式或指令内容进行重新赋值,将值修改为传入的newValue,通过这种方式实现了观察者接收依赖,更新视图的操作
渲染流程
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Mini Vue</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg">
<input type="text" v-model="count">
</div>
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 100,
person: { name: 'zs' }
}
})
console.log(vm.msg)
// vm.msg = { test: 'Hello' }
vm.test = 'abc'
</script>
</body>
</html>
根据测试代码对渲染流程进行说明:
首先在创建vm实例时,进入到vue.js类中,在这个类中,对当前实例的$data通过Object.defineProperty注入到当前vm实例中并设置get和set,随后进入到Obeserver类中,将data中的属性转换成getter和setter,在Obeserver中,通过遍历data对象的属性,将所有属性转换成getter和setter,随后进入defineReactive方法中,首先定义Dep对象,负责收集依赖并发送通知,随后再次调用walk方法,对内部的属性进行转换,再次调用Object.defineProperty将data数据转换成getter和setter。随后在vue中调用compiler对象,在构造函数中调用compile方法,自动进行渲染,在compile方法中遍历每个元素的子节点,根据元素类型调用对应的处理节点方法对其进行编译并进行渲染,在渲染时会将当前的表达式转换成对应的值,在转换时会触发vue中的get方法,随后调用observer中的get方法,调用完成后在compiler中创建了watcher对象,在watcher中,将当前的watcher对象记录到Dep类中,通过将当前的对象添加到Dep.target中,随后对oldValue进行赋值,通过vm[key]的方式间接的调用了observer的get方法,首先判断了Dep.target随后调用了addSub方法将watcher对象添加到Dep中的subs数组中。
通过这一系列的调用实现了首次的页面渲染。
数据改变
当数据发生改变时,进入到obserer中的set方法,对数据进行更新,随后进入到dep.notify方法,遍历所有观察者对象,调用每个对象的update方法,在watcher的update方法中,对数据进行更新,随后调用回调函数,在compiler中对应的watcher对象生成的韦志中对新值进行赋值,并对视图中的内容进行更新。
整体流程
以上就是我对Vue.js响应式原理的简单实现,其中很多问题没有进行处理(例如新增响应式数据等,没有进行处理),只是一个最简单的Vue实现,其中有什么写写的不对的地方希望各位大佬及时指出。