我们首先封装一个响应式处理的方法 defineReactive
,通过 defineProperty
这个方法重新定义对象属性的 get
和 set
描述符,来实现对数据的劫持,每次 读取数据 的时候都会触发 get
,每次 更新数据 的时候都会触发 set
,所以我们可以在 set
中触发更新视图的方法 update
来实现一个基本的响应式处理。
/**
* @param {*} obj 目标对象
* @param {*} key 目标对象的一个属性
* @param {*} val 目标对象的一个属性的初始值
*/
function defineReactive(obj, key, val) {
// 通过该方法拦截数据
Object.defineProperty(obj, key, {
// 读取数据的时候会走这里
get() {
console.log('🚀🚀~ get:', key);
return val
},
// 更新数据的时候会走这里
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
val = newVal
// 这里是触发视图更新的地方
update()
}
}
})
}
复制代码
复制代码
我们写点代码来测试一下,每 1s
修改一次 obj.foo
的值 , 并定义一个 update
方法来修改 app
节点的内容。
// html
<div id='app'>123</div>
// js
// 劫持 obj.foo 属性
const obj = {}
defineReactive(obj, 'foo', '')
// 给 obj.foo 一个初始值
obj.foo = new Date().toLocaleTimeString()
// 定时器修改 obj.foo
setInterval(() => {
obj.foo = new Date().toLocaleTimeString()
}, 1000)
// 更新视图
function update() {
app.innerHTML = obj.foo
}
复制代码
复制代码
可以看到,每次修改 obj.foo
的时候,都会触发我们定义的 get
和 set
,并调用 update
方法更新了视图,到这里,一个最简单的响应式处理就完成了。
处理深层次的嵌套
一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个 key
调用 defineReactive
进行处理。
/**
* @param {*} obj 目标对象
*/
function observe(obj) {
// 先判断类型, 响应式处理的目标一定要是个对象类型
if (typeof obj !== 'object' || obj === null) {
return
}
// 遍历 obj, 对 obj 的每个属性进行响应式处理
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
// 定义对象 obj
const obj = {
foo: 'foo',
bar: 'bar',
friend: {
name: 'aa'
}
}
// 访问 obj 的属性 , foo 和 bar 都被劫持到,就不在浏览器演示了。
obj.bar = 'barrrrrrrr' // => 🚀🚀~ set: bar
obj.foo = 'fooooooooo' // => 🚀🚀~ set: foo
// 访问 obj 的属性 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ get: friend
复制代码
复制代码
当我们访问 obj.friend.name
的时候,也只是打印出来 get: friend
,而不是 friend.name
, 所以我们还要进行个 递归,把 深层次的属性 同样也做响应式处理。
function defineReactive(obj, key, val) {
// 递归
observe(val)
// 继续执行 Object.defineProperty...
Object.defineProperty(obj, key, {
... ...
})
}
// 再次访问 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ set: name
复制代码
复制代码
递归的时机在 defineReactive
这个方法中,如果 value
是对象就进行递归,如果不是对象直接返回,继续执行下面的代码,保证 obj
中嵌套的属性都进行响应式的处理,所以当我们再次访问 obj.friend.name
的时候,就打印出了 set: name
。
处理直接赋值一个对象
上面已经实现了对深层属性的响应式处理,那么如果我直接给属性赋值一个对象呢?
const obj = {
friend: {
name: 'aa'
}
}
obj.friend = { // => 🚀🚀~ set: friend
name: 'bb'
}
obj.friend.name = 'cc' // => 🚀🚀~ get: friend
复制代码
复制代码
这种赋值方式还是只打印出了 get: friend
,并没有劫持到 obj.friend.name
,那怎么办呢?我们只需要在 触发 set
的时候,判断一下 value
的类型,如果它是个对象类型,我们就对他执行 observe
方法。
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
... ...
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
// 如果 newVal 是个对象类型,再次做响应式处理。
if (typeof obj === 'object' && obj !== null) {
observe(newVal)
}
val = newVal
}
}
})
}
// 再次给 obj.friend 赋值一个对象
obj.friend = {
name: 'bb'
}
// 再次访问 obj.friend.name , 这个时候就成功的劫持到了 name 属性
obj.friend.name = 'cc' //=> 🚀~ set: name
复制代码
复制代码
处理新添加一个属性
上面的例子都是操作 已经存在 的属性,那么如果我们 新添加 一个属性呢?
const obj = {}
obj.age = 18
obj.age = 20
复制代码
复制代码
当我们试图修改 obj.age
的时候,什么都没有打印出来,说明并没有对 obj.age
进行响应式处理。这里也非常好理解,因为新增加的属性并没有经过 defineReactive
的处理,所以我们就需要一个方法来手动处理新添加属性这种情况。
/**
* @param {*} obj 目标对象
* @param {*} key 目标对象的一个属性
* @param {*} val 目标对象的一个属性的初始值
*/
function $set(obj, key, val) {
// vue 中在这进行了很多判断,val 是对象还是数组等等,我们就从简了
defineReactive(obj, key, val)
}
// 调用 $set 方法给 obj 添加新的属性
$set(obj, 'age', 18)
// 再次访问 obj.age
obj.age = 20 //=> 🚀🚀~ set: age
复制代码
复制代码
新定义的 $set
方法,内部也是把目标属性进行了 defineReactive
处理,这时我们再次更新 obj.age
的时候,就打印出了 set: age
, 也就实现了一个响应式的处理。
VUE 中的数据响应式
实现简易的 Vue
这是 Vue
中最基本的使用方式,创建一个 Vue
的实例,然后就可以在模板中使用 data
中定义的响应式数据了,今天我们就来完成一个简易版的 Vue
。
<div id='app'>
<p>{{counter}}</p>
<p>{{counter}}</p>
<p>{{counter}}</p>
<p my-text='counter'></p>
<p my-html='desc'></p>
<button @click='add'>点击增加</button>
<p>{{name}}</p>
<input type="text" my-model='name'>
</div>
<script>
const app = new MyVue({
el: "#app",
data: {
counter: 1,
desc: `<span style='color:red' >一尾流莺</span>`
},
methods: {
add() {
this.counter++
}
}
})
</script>
复制代码
复制代码
原理
设计类型介绍
流程解析
初始化时通过 Observer
对数据进行响应式处理,在 Observer
的 get
的时候创建一个 Dep
的实例,用来通知更新。
初始化时通过 Compile
进行编译,解析模板语法,找到其中动态绑定的数据,从 data
中获取数据并初始化视图,把模板语法替换成数据。
同时进行一次订阅,创建一个 Watcher
,定义一个更新函数 ,将来数据发生变化时,Watcher
会调用更新函数 把 Watcher
添加到 dep
中 。
Watcher
是一对一的负责某个具体的元素,data
中的某个属性在一个视图中可能会出现多次,也就是会创建多个 Watcher
,所以一个 Dep
中会管理多个 Watcher
。
当 Observer
监听到数据发生变化时,Dep
通知所有的 Watcher
进行视图更新。
代码实现 - 第一回合 数据响应式
observe
observe
方法相对于上面,做了一小点的改动,不是直接遍历调用 defineReactive
了,而是创建一个 Observer
类的实例 。
// 遍历obj 对其每个属性进行响应式处理
function observe(obj) {
// 先判断类型, 响应式处理的目标一定要是个对象类型
if (typeof obj !== 'object' || obj === null) {
return
}
new Observer(obj)
}
复制代码
复制代码
Observer 类
Observer
类之前有解释过,它就是用来 做数据响应式 的,在它内部区分了数据是 对象 还是 数组 ,然后执行不同的响应式方案。
// 根据传入value的类型做响应的响应式处理
class Observer {
constructor(value) {
this.value = value
if (Array.isArray(value)) {
// todo 这个分支是数组的响应式处理方式 不是本文重点 暂时忽略
} else {
// 这个分支是对象的响应式处理方式
this.walk(value)
}
}
// 对象的响应式处理 跟前面讲到过的一样,再封装一层函数而已
walk(obj) {
// 遍历 obj, 对 obj 的每个属性进行响应式处理
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
复制代码
复制代码
MVVM 类(MyVue)
这一回合我们就先在实例初始化的时候,对 data
进行响应式处理,为了能用 this.key
的方式访问this.$data.key
,我们需要做一层代理。
class MyVue {
constructor(options) {
// 把数据存一下
this.$options = options
this.$data = options.data
// data响应式处理
observe(this.$data)
// 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
proxy(this)
}
}
复制代码
复制代码
proxy
代理也非常容易理解,就是通过 Object.defineProperty
改变一下引用。
/**
* 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
* @param {*} vm vue 实例
*/
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
// 通过 Object.defineProperty 方法进行代理 这样访问 this.key 等价于访问 this.$data.key
Object.defineProperty(vm, key, {
get() {
return vm.$data[key]
},
set(newValue) {
vm.$data[key] = newValue
}
})
})
}
复制代码
复制代码
代码实现 - 第二回合 模板编译
这一趴要实现下面这个流程,VNode
不是本文的重点,所以先去掉 Vnode
的环节,内容都在注释里啦~
// 解析模板语法
// 1.处理插值表达式{{}}
// 2.处理指令和事件
// 3.以上两者初始化和更新
class Compile {
/**
* @param {*} el 宿主元素
* @param {*} vm vue实例
*/
constructor(el, vm) {
this.$vm = vm
this.$el = document.querySelector(el)
// 如果元素存在,执行编译
if (this.$el) {
this.compile(this.$el)
}
}
// 编译
compile(el) {
// 获取 el 的子节点,判断它们的类型做相应的处理
const childNodes = el.childNodes
childNodes.forEach(node => {
// 判断节点的类型 本文以元素和文本为主要内容 不考虑其他类型
if (node.nodeType === 1) { // 这个分支代表节点的类型是元素
// 获取到元素上的属性
const attrs = node.attributes
// 把 attrs 转换成真实数组
Array.from(attrs).forEach(attr => {
// 指令长 my-xxx = 'abc' 这个样子
// 获取节点属性名
const attrName = attr.name
// 获取节点属性值
const exp = attr.value
// 判断节点属性是不是一个指令
if (attrName.startsWith('my-')) {
// 获取具体的指令类型 也就是 my-xxx 后面的 xxx 部分
const dir = attrName.substring(3)
// 如果this[xxx]指令存在 执行这个指令
this[dir] && this[dir](node, exp)
}
})
} else if (this.isInter(node)) { // 这个分支代表节点的类型是文本 并且是个插值语法{{}}
// 文本的初始化
this.compileText(node)
}
// 递归遍历 dom 树
if (node.childNodes) {
this.compile(node)
}
})
}
// 编译文本
compileText(node) {
// 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
// this.$vm[RegExp.$1] 等价于 this.$vm[key]
// 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
node.textContent = this.$vm[RegExp.$1]
}
// my-text 指令对应的方法
text(node, exp) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
// 把 this.$vm[key] 赋值给文本 即可
node.textContent = this.$vm[exp]
}
// my-html 指令对应的方法
html(node, exp) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
// 把 this.$vm[key] 赋值给innerHTML 即可
node.innerHTML = this.$vm[exp]
}
// 是否是插值表达式{{}}
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}
复制代码
复制代码
代码实现 - 第三回合 收集依赖
视图中会用到的 data
中的属性 key
的地方,都可以被称为一个 依赖 ,同一个 key
可能会出现多次,每次出现都会创建一个 Watcher
进行维护,这些 Watcher
需要收集起来统一管理,这个过程叫做 收集依赖。
同一个 key
创建的多个 Watcher
需要一个 Dep
来管理,需要更新时由 Dep
统一进行通知。
上面这段代码中,name1
用到了两次, 创建了两个 Watcher
,Dep1
收集了这两个 Watcher
,name2
用到了一次, 创建了一个 Watcher
, Dep2
收集了这一个 Watcher
。
收集依赖的思路
defineReactive
时为每一个 key
创建一个 Dep
实例
初始化视图时,读取某个 key
,例如 name1
,创建一个 Watcher1
由于触发 name1
的 getter
方法,便将 Watcher1
添加到 name1
对应的 Dep
中
当 name1
发生更新时,会触发 setter
,便可通过对应的 Dep
通知其管理的所有 Watcher
进行视图的更新
Watcher 类
收集依赖的过程,在 Watcher
实例创建的时候,首先把实例赋值给 Dep.target
,手动读一下 data.key
的值 ,触发 defineReactive
中的 get
,把当前的 Watcher
实例添加到 Dep
中进行管理,然后再把Dep.target
赋值为 null
。
// 监听器:负责依赖的更新
class Watcher {
/**
* @param {*} vm vue 实例
* @param {*} key Watcher实例对应的 data.key
* @param {*} cb 更新函数
*/
constructor(vm, key, updateFn) {
this.vm = vm
this.key = key
this.updateFn = updateFn
// 触发依赖收集 把当前 Watcher 赋值给 Dep 的静态属性 target
Dep.target = this
// 故意读一下 data.key 的值 为了触发 defineReactive 中的 get
this.vm[this.key]
// 收集依赖以后 再置为null
Dep.target = null
}
// 更新方法 未来被 Dep 调用
update() {
// 执行实际的更新操作
this.updateFn.call(this.vm, this.vm[this.key])
}
}
复制代码
复制代码
Dep 类
addDep
方法把 Watchers
收集起来 放在 deps
中进行管理,notify
方法通知 deps
中的所有 Watchers
进行视图的更新。
class Dep {
constructor() {
this.deps = [] // 存放 Watchers
}
// 收集 Watchers
addDep(dep) {
this.deps.push(dep)
}
// 通知所有的 Watchers 进行更新 这里的 dep 指的就是收集起来的 Watcher
notify() {
this.deps.forEach(dep => dep.update())
}
}
复制代码
复制代码
升级 Compile
在第二回合中,我们的 Compile
类只实现了视图的初始化,所以在第三回合中要把它升级一下,支持视图的更新。
Watcher
实例就是在初始化后创建的,用来监听更新。
class Compile {
... ... // 省略号的地方都没有发生改变
// 下面是发生改变的代码
/**
* 根据指令的类型操作 dom 节点
* @param {*} node dom节点
* @param {*} exp 表达式 this.$vm[key]
* @param {*} dir 指令
*/
update(node, exp, dir) {
// 1.初始化 获取到指令对应的实操函数
const fn = this[dir + 'Updater']
// 如果函数存在就执行
fn && fn(node, this.$vm[exp])
// 2.更新 再次调用指令对应的实操函数 值由外面传入
new Watcher(this.$vm, exp, function(val) {
fn && fn(node, val)
})
}
// 编译文本 {{xxx}}
compileText(node) {
// 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
// this.$vm[RegExp.$1] 等价于 this.$vm[key]
// 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
this.update(node, RegExp.$1, 'text')
}
// my-text 指令
text(node, exp) {
this.update(node, exp, 'text')
}
// my-text 指令对应的实操
textUpdater(node, value) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
// 把 this.$vm[key] 赋值给文本 即可
node.textContent = value
}
// my-html 指令
html(node, exp) {
this.update(node, exp, 'html')
}
// my-html 指令对应的实操
htmlUpdater(node, value) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
// 把 this.$vm[key] 赋值给innerHTML 即可
node.innerHTML = value
}
// 是否是插值表达式{{}}
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}
复制代码
复制代码
Watcher 和 Dep 建立关联
首先在 defineReactive
中创建 Dep
实例,与 data.key
是一一对应的关系,然后再 get
中 调用 dep.addDep
进行依赖的收集,Dep.target
就是一个 Watcher
。在 set
中 调用 dep.notify()
通知所有的 Watchers
更新视图。
function defineReactive(obj, key, val) {
... ...
// 创建 Dep 实例 , 与 key 一一对应
const dep = new Dep()
// 通过该方法拦截数据
Object.defineProperty(obj, key, {
// 读取数据的时候会走这里
get() {
console.log('🚀🚀~ get:', key);
// 依赖收集 Dep.target 就是 一个Watcher
Dep.target && dep.addDep(Dep.target)
return val
},
// 更新数据的时候会走这里
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
// 如果 newVal 是个对象类型,再次做响应式处理。
if (typeof obj === 'object' && obj !== null) {
observe(newVal)
}
val = newVal
// 通知更新
dep.notify()
}
}
})
}
复制代码
复制代码
代码实现 - 第四回合 事件和双向绑定
事件绑定
事件绑定也很好理解,首先判断节点的属性是不是以 @
开头,然后拿到事件的类型,也就是例子中的 click
, 再根据函数名找到 methods
中定义的函数体,最后添加事件监听就行了。
class Compile {
... ... // 省略号的地方都没有发生改变
compile(el) {
// 判断节点属性是不是一个事件
if (this.isEvent(attrName)) {
// @click="onClick"
const dir = attrName.substring(1) // click
// 事件监听
this.eventHandler(node, exp, dir)
}
}
... ...
// 判断节点是不是一个事件 也就是以@开头
isEvent(dir) {
return dir.indexOf("@") === 0
}
eventHandler(node, exp, dir) {
// 根据函数名字在配置项中获取函数体
const fn = this.$vm.$options.methods && this.$vm.$options.methods[exp]
// 添加事件监听
node.addEventListener(dir, fn.bind(this.$vm))
}
... ...
}
复制代码
复制代码
双向绑定
my-model
其实也是一个指令,走的也是指令相关的处理逻辑,所以我们只需要添加一个 model
指令和对应的 modelUpdater
处理函数就行了。
my-model
双向绑定其实就是 事件绑定 和修改 value
的一个语法糖,本文以 input
为例,其它的表单元素绑定的事件会有不同,但是道理是一样的。
class Compile {
// my-model指令 my-model='xxx'
model(node, exp) {
// update 方法只完成赋值和更新
this.update(node, exp, 'model')
// 事件监听
node.addEventListener('input', e => {
// 将新的值赋值给 data.key 即可
this.$vm[exp] = e.target.value
})
}
modelUpdater(node, value) {
// 给表单元素赋值
node.value = value
}
}
复制代码
复制代码
现在也可以更新一下模板编译的流程图啦~
最后
如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163 相互学习,我们会有专业的技术答疑解惑
如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点 star:http://github.crmeb.net/u/defu不胜感激 !
完整源码下载地址:https://market.cloud.tencent.com/products/33272
PHP 学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com
评论