vue2的响应式原理可以用一句话来简述,那就是结合数据劫持和发布/订阅模式实现的。
那么是怎么对数据进行劫持的呢?发布/订阅模式又是什么?
1、数据劫持
数据劫持是通过对象下的一个静态方法defineProperty来实现的。
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
Object.defineProperty(obj, prop, descriptor)
a、obj是要定义属性的对象。
b、prop是要定义或修改的属性的名称或 Symbol。
c、descriptor要定义或修改的属性描述符。
该方法允许精确地添加或修改对象的属性。通过赋值操作添加的普通属性是可以枚举的,在枚举对象属性时会被枚举到(for...in或Object.keys方法),可以改变这些属性的值,也可以删除这些属性。这个方法允许修改默认的额外选项(或配置)。
对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符时一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由getter函数和setter函数所描述的属性。一个描述符只能是这两者其中之一,不能同时是两者。
我们先来看看数据描述符
const obj = {
name:'张三'
}
Object.defineProperty(obj,'age',{
value:"18", // 该属性对应的值。可以是任何有效的javascript值(数值,对象,函数等),默认为undefined
writable:false, // 该属性是否可写,若为false,则该属性不能被改变
enumerable:false, // 该属性是否可枚举
configurable:false // 该属性是否可配置
})
obj.name = '李四'
obj.age = '20岁'
console.log(obj) // {name: "李四", age: "18"}
console.log(Object.getOwnPropertyDescriptor(obj,'name')) // {value: "李四", writable: true, enumerable: true,configurable: true}
console.log(Object.keys(obj)) // ["name"]
delete obj.name
delete obj.age
console.log(obj) // {age: "18"}
obj下的name属性是采用赋值操作创建的,默认是可写,可枚举,可配置,所以我们可以对该属性重新赋值,可以枚举,也可以对其进行删除。age属性是我们通过Object.defineProperty()来创建的,我们将它设置为不可写,不可枚举,不可配置,所以我们后续对它进行的操作都无用。
通过上述例子我们了解了数据描述符的作用。那么存取描述符是用来做什么的呢?
const obj = {
name: '张三'
}
Object.defineProperty(obj, 'sex', {
get: function () {
console.log('get sex!');
},
set: function (value) {
console.log('new sex:' + value)
}
})
const sex = obj.sex
obj.sex = '女'
运行以上代码,先后会打印出“get sex!”,“new sex:女”。由此可见,当我们访问对象下的属性时就会调用get函数;当我们修改对象下的属性时,就会调用set函数。
到此,你明白数据挟持的含义了吗?
2、发布/订阅模式
大家的微信应该关注了各种各样自己喜欢的公众号,当公众号推出新的文章时,关注的小伙伴都可以收到消息推送,这其实就是一个发布/订阅模式,微信公众号就是一个发布者,我们都是订阅者。
我们常见的发布/订阅模式有我们经常使用的用于父子组件通信的$bus
// eventBus.js
// 事件中心
let eventHub = new Vue()
// ComponentA.vue
// 发布者
addTodo:function(){
// 发布消息
eventHub.$emit('add-todo',{text:'新增代办事件'})
}
// ComponentB.vue
// 订阅者
created(){
// 订阅消息
eventHub.$on('add-todo',this.addTodo)
}
由此可见,如果我们要实现一个发布/订阅模式,我们需要定义两个类,定义一个订阅者类,要接收消息更新。还需要定义一个发布者类,可以添加订阅者,记录下所有的订阅者,然后有新消息时发布通知到各个订阅者。根据此思路,我们来手动实现一个简单的发布/订阅模式吧~
// 发布者
class Dep {
constructor(){
// 记录所有的订阅者
this.subs = []
}
// 添加订阅者
addSub(sub){
if(sub && sub.update()){
this.subs.push()
}
}
// 发布通知
notify(){
this.subs.forEach(sub => {
sub.update()
})
}
}
// 订阅者
class Watcher {
update(){
console.log('update')
}
}
// test
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
从上述的介绍中我们已经了解了数据劫持和发布/订阅模式的含义,要想实现响应式,首先我们需要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发生变化了,就需要通知我们的订阅者Watcher。因为订阅者是有很多个,所以我们需要有一个发布者Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理。接着,我们还需要一个指令解析器Compiler,对每个结点元素进行扫描和解析,将相关指令对应初始化成一个个Watcher,并替换模板数据或者绑定相应的函数。此时,当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。
根据以上思路,我们自己动手来实现一个简单的vue响应式原理吧!
创建一个my-vue.js
class MyVue {
constructor(options) {
this.$options = options
this.$data = options.data
// 对数据进行响应化处理
observe(this.$data)
//代理,方便数据访问, 代理前 this.$data.counter 代理后 this.counter
proxy(this,'$data')
// 创建编译器
new Compiler(options.el, this)
}
}
将传进来的初始数据都保存在$options下,以便后续访问,然后将我们的数据保存在$data,接着对$data中的数据进行劫持。由于Object.defineProperty()是对象才有的方法,所以仅能劫持对象。
function observe(obj) {
if (typeof obj !== 'object' || obj === null) {
return
}
// 创建Observer实例
new Observer(obj)
}
接下来我们来创建Observer实例,对数据进行劫持
class Observer {
constructor(value) {
this.value = value
// 对数据进行劫持
this.walk(value)
}
walk(obj) {
// 遍历数据进行劫持
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
我们对数据进行劫持,是需要做什么操作呢?
根据之前的思路可知,我们需要给每个数据定义一个发布者,当数据被访问时,则需要将该订阅者添加到发布者中,当数据被修改时,需要通知发布者中的每个订阅者进行更新操作。所以我们首先来实现一个发布者,里面需要有添加订阅者以及通知订阅者的方法。
class Dep {
constructor(){
this.deps = []
}
// 新增订阅者
addDep(dep){
this.deps.push(dep)
}
//通知订阅者进行更新
notify(){
this.deps.forEach(dep => dep.update())
}
}
发布者实现好了后,我们来实现下响应化的函数
function defineReactive(obj, key, val) {
// 递归响应化处理
observe(val)
// 创建一个Dep和当前key一一对应
const dep = new Dep()
// 数据劫持
Object.defineProperty(obj,key,{
get() {
// 增加订阅者
Dep.target && dep.addDep(Dep.target)
return val
},
set(newVal){
if(newVal !== val){
// 如果传入的newVal依然是对象,则需要做响应化处理
observe(newVal)
val = newVal
// 通知更新
dep.notify()
}
}
})
}
由于Object.defineProperty()只能对对象的属性进行劫持处理,如果对象的属性仍是一个对象,则需要递归处理进行劫持。
实现了发布者,我们接着来实现下订阅者。
// 订阅者,保存更新函数,值发生改变时调用更新函数
class Watcher {
constructor(vm,key,updateFn){
this.vm = vm
this.key = key
this.updateFn = updateFn
// Dep.target静态属性上设置为当前的watcher实例
Dep.target = this
// 读取数据会触发getter,则该数据的发布者中会增加当前订阅者
this.vm[this.key]
// 增加完成之后就置空,为下一个订阅者做准备
Dep.target = null
}
// 更新函数
update(){
this.updateFn.call(this.vm,this.vm[this.key])
}
}
最后,我们来实现下代理函数,方便用户直接访问$data中的数据
function proxy(vm,sourceKey) {
Object.keys(vm[sourceKey]).forEach(key => {
Object.defineProperty(vm,key,{
get(){
return vm[sourceKey][key]
},
set(newVal){
vm[sourceKey][key] = newVal
}
})
})
}
接下来,我们需要对模板进行解析,建立Watcher。新建一个compile.js。
首先我们需要先获取所有的节点
class Compiler{
constructor(el,vm){
// 获取当前实例
this.$vm = vm
// 获取当前根节点
this.$el = document.querySelector(el)
// 根节点存在则进行编译处理
if(this.$el){
this.compile(this.$el)
}
}
}
获取到所有的节点后,我们就需要对节点进行解析处理,判断是否是插值表达式,还是自定义的指令,对此进行不同的处理
compile(el){
// 或者根节点下的所有子节点
const childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 判断该节点是否是一个元素节点
if(this.isElement(node)){
this.compileElement(node)
}else if(this.isInterpolation(node)){
// 判断该节点是否是插值表达式
this.compileText(node)
}
// 如果该节点还有子节点,则进行递归处理
if(node.childNodes && node.childNodes.length > 0){
this.compile(node)
}
})
}
// 判断节点是否是元素节点
isElement(node){
return node.nodeType === 1
}
// 判断该节点是否是插值表达式(节点为文本节点,并且采用双大括号包裹)
isInterpolation(node){
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
如果是元素节点的话,我们需要获取该元素上的所有属性,对自定义指令进行解析,在此,我们约定自定义指定以“m-”开头。
// 解析元素节点上的属性
compileElement(node){
// 获取节点上的所有属性
const nodeAttrs = node.attributes
Array.from(nodeAttrs).forEach(attr => {
const attrName = attr.name
const exp = attr.value
// 判断是否是自定义指令
if(this.isDirective(attrName)){
const directive = attrName.substring(2)
// 执行指令对应的函数
this[directive] && this[directive](node,exp)
}
})
}
// 是否是自定义指令
isDirective(attr){
return attr.indexOf('m-') === 0
}
如果是插值表达式,则需要更新该插值表达式
// 解析插值表达式
compileText(node){
// RegExp这个对象会在我们调用了正则表达式的方法后, 自动将最近一次的结果保存在里面
this.update(node,RegExp.$1,'text')
}
我们先猜想下update这个函数里我们需要做什么?首先,我们将该变量的值渲染到页面上,另外,如果后续该变量发生变化的时候,页面需要自动更新,所以我们需要在这里实例化一个订阅者。
// 更新函数
update(node,exp,dir){
const fn = this[dir + 'Updater']
fn && fn(node,this.$vm[exp])
// 实例化一个Watcher
new Watcher(this.$vm,exp,function(val) {
fn && fn(node,val)
})
}
插值表达式的更新函数如下
//更新插值表达式的文本
textUpdater(node,value){
node.textContent = value
}
到此,我们已经实现了一个简单的响应式,我们来使用看看吧!
新建一个vue.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>Document</title>
</head>
<body>
<div id="app">
<p>{{counter}}</p>
<p m-html="richText"></p>
</div>
<script src="compile.js"></script>
<script src="my-vue.js"></script>
<script>
const app = new MyVue({
el:"#app",
data:{
counter:1,
richText:'<span>啦啦啦</span>',
}
})
const timer = setInterval(() => {
if(app.counter < 10){
app.counter++
}else{
clearInterval(timer)
}
}, 1000);
</script>
</body>
</html>
运行结果如下
一个简单的响应式就实现了~当然啦,vue中的响应式原理远比这复杂的多,这仅仅是个大概思路。
留给大家一个小作业~实现代码中的m-html。