Vue 响应式——模拟封装Vue
前言
首先,一个问题。
为啥要去了解Vue的响应式数据?
直白的说 为了面试的时候应付面试官的相关问题,在现在Vue广泛使用的情况下,基本上数据响应式 是一个必考点
委婉的讲 了解它的原理,可以学习别人优秀经验,转为自己的经验,还可以通过原理层面去解决一些开发中的问题,比如:
- 给 Vue 实例新增一个成员是否是响应式的?
- 给属性重新赋值称为对象,是否是响应式的?
真正的原因 为了能够跟同事们,尤其是漂亮的前端小妹妹装逼
三个概念
在开始模拟实现Vue响应式原理之前,需要先了解三个概念。分别是:
- 数据驱动
- 响应式核心原理
- 发布/订阅模式 和 观察者模式
数据驱动
数据响应式、双向绑定、数据驱动
- 数据响应式
数据模型仅仅是普通的 JavaScript 对象,而当我们修改数据时,视图会进行更新,避免了繁琐的 DOM 操作,提高开发效率- 双向绑定
数据改变,视图改变;视图改变,数据也随之改变 我们可以使用 v-model 在表单元素上创建双向数据绑定- 数据驱动是 Vue 最独特的特性之一
开发过程中仅需要关注数据本身,不需要关心数据是如何渲染到视图
响应式核心原理
Vue2 和 Vue3 响应式的实现方式发生了变化,我们分别来列出。
Vue2.x
- Vue 2.x深入响应式原理
- MDN - Object.defineProperty
- 浏览器兼容 IE8 以上(不兼容 IE8)
当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用
Object.defineProperty 把这些 property 全部转为
getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持
IE8 以及更低版本浏览器的原因
无法 shim 的特性 意思就是不能被降级,文章中说的意思就是,Object.defineProperty这个特性是无法使用低级浏览器中的方法来实现的,所以Vue不支持IE8以及更低版本的浏览器
<!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>
以上代码 就是 vm 对象通过defineProperty 劫持了 data 对象中的所有的属性,所以需要利用循环去添加所有的data中的属性
Vue3.x
- MDN - Proxy
- 直接监听对象,而非属性, 因此不需要向defineProperty那样去循环
- ES 6中新增,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>
以上代码 是 vm 代理 data这个对象,data作为代理目标 当访问vm.key的时候 就会代理访问data.key
所以不需要循环。
发布/订阅模式
发布/订阅模式
- 订阅者
- 发布者
- 信号中心
我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信
号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执
行。这就叫做"发布/订阅模式"(publish-subscribe pattern)
代码流程:
- 创建EventEmitter 事件触发器类
- 其中添加 $on 注册事件方法 和 $emit 触发事件方法
- 添加 subs 属性,以键值对的形式记录所有的事件和事件对应的执行函数,subs是一个对象,我们可以用 Object.create(null) 的方式初始化它的值,这样的好处是创建出来的对象原型为null,性能会好一些,因为我们只是需要subs来记录一些数据
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发布订阅模式</title>
</head>
<body>
<script>
// 事件触发器
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')
</script>
</body>
</html>
观察者模式
- 观察者(订阅者) – Watcher
update():当事件发生时,具体要做的事情 - 目标(发布者) – Dep
subs 数组:存储所有的观察者
addSub():添加观察者
notify():当事件发生,调用所有观察者的 update() 方法
没有事件中心
class Dep {
constructor() {
this.subs = []
}
// 添加观察者
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 发布
notify() {
this.subs.forEach(item => item.update())
}
}
class Watcher {
constructor(name) {
this.name = name
}
update() {
console.log(this.name + ' update!');
}
}
var sub = new Watcher('dog')
var dep = new Dep()
dep.addSub(sub)
dep.notify()
两种模式的区别
- 观察者模式 是由具体目标调度,比如当事件触发,Dep就会去调用观察者的方法,所以观察者模式的订阅和发布之间是存在依赖的
- 发布/订阅模式 由统一调度中心调用,因为发布者和订阅者 不需要知道对方的存在。
实现最简易的Vue
要实现最简易的Vue,我们需要实现以下几种功能:
- Vue
把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter - Observer
能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep - Compiler
解析每个元素中的指令/插值表达式,并替换成相应的数据 - Dep
添加观察者(watcher),当数据变化通知所有观察者 - Watcher
数据变化更新视图, 其中要有一个update方法
创建class Vue
先确定其基本结构
options 、el 、data 都是用于接收传入的参数
proxyData() 方法的作用是把data中属性 转为getter setter 注入到vue实例中
下划线 _ 开头的都是私 有成员
class Vue {
constructor(options) {
// 1 通过属性保存选择的数据
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 2 把data中的成员转换成getter和setter,注入到vue实例中
this._proxyData(this.$data)
// 3 调用observer对象, 监听数据变化
new Observer(this.$data)
// 4 调用compiler对象, 解析指令和插值表达式
new Compile(this.$el,this)
}
_proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newValue) {
if (newValue == data[key]) {
return
}
data[key] = newValue
}
})
})
}
}
Observer
- 功能
- 负责把 data 选项中的属性转换成响应式数据
- data 中的某个属性也是对象,把该属性转换成响应式数据
- 数据变化发送通知
- 结构
- walk(data), walk 方法 用来遍历 data 中的所有属性
- defineReactive 方法,意思是定义响应式数据,也就是通过调用Object.defineProperty 把属性转化成getter 和 setter
- walk 方法在循环中 会调用defineReactive
- defineReactive 中为data中的属性添加getter setter
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
// 1. 判断data 是否是一个对象
if (!data || typeof data !== 'object') {
return
}
// 2. 遍历data 中的属性添加getter setter
Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
}
defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return value
},
set(newValue) {
if (newValue == value) return
value = newValue
// 发出通知
}
})
}
}
defineReactive(obj, key, value)
为什么 defineReactive中要接收第三个形参value,而不是直接在get中return data[key]?
如果我们访问vm上的数据
比如:
console.log(vm.msg)
这样其实就是在触发 vm 上msg的getter
而vm上msg的getter 中 return的就是data[key]
这样就再次触发了 data中msg的getter ,如果data中msg属性的get再返回data[key]就会陷入死循环
而如果我们使用value传递,value是在Object.keys[data].forEach 循环的时候产生的 此时还没有添加getter
就可以得到value 而因为defineReactive函数访问了walk函数中的 value 就形成了闭包,
value不会被释放掉,我们就可以得到其值
优化Observer
此时,我们还存在两个问题。
- 如果data对象中的属性也是一个对象,那么这个对象中的属性并没有被添加getter 和setter
- 如果我们对data中的某个值是基本数据类型的属性,进行重新赋值,赋值成一个对象,那么赋值后的这个对象中的属性 也没有getter和setter
解决办法:
第一个问题,在defineReactive中 Object.defineProperty中立刻先调用 walk方法,这样就会判断是否是一个object 如果是 则深入进行遍历添加getter和setter 如果不是则直接return
第二个问题,我们需要在 defineReactive 方法中 Object.defineProperty的set中 在赋值之后 再次调用walk
这两次调用walk我们又面临一个新的问题 ,就是this的指向问题,这个好解决,我们可以用一个that变量,提前保存this
最后 优化后的代码
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
// 1. 判断data 是否是一个对象
if (!data || typeof data !== 'object') {
return
}
// 2. 遍历data 中的属性添加getter setter
Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
}
defineReactive(obj, key, value) {
let that = this
// 递归解决属性依然是对象的问题
that.walk(value)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return value
},
set(newValue) {
if (newValue == value) return
value = newValue
// 发出通知
// 为新赋值的对象属性 添加响应式的getter和setter
that.walk(newValue)
}
})
}
}
Compiler
功能
- 负责编译模板,解析指令/插值表达式
- 负责页面的首次渲染
- 当数据变化后重新渲染视图
compile 方法用于编译模板
compileElement 方法用于编译元素节点
compileText 方法用于编译文本节点
isDirective 方法用于判断是否是指令
isTextNode 方法用于判断是否是文本节点
isElementNode 方法用于判断是否是元素节点
class Compile {
constructor(el,vm){
this.el = el
this.vm = vm
// 调用 compile
this.compile(this.el)
}
// 用于编译模板
compile(el){
}
// 用于编译元素节点属性
compileElement(node){
}
// 用于编译文本节点
compileText(node){
}
// 判断是否是指令
isDirective(attrName){
return attrName.startsWith('v-')
}
// 判断是否是文本节点
isTextNode(node){
return node.nodeType === 3
}
// 判断是否是元素节点
isElementNode(node){
return node.nodeType === 1
}
}
compile方法
// 用于编译模板
compile(el){
let childNodes = el.childNodes
// Array.from 将伪数组转化为数组
Array.from(childNodes).forEach(node=>{
if(this.isTextNode(node)){
this.compileText(node)
}else if(this.isElementNode(node)){
this.compileElement(node)
}
})
// 判断节点是否还有子节点,进行递归调用
if(node.childNodes && node.childNodes.length){
this.compile(node)
}
}
compileText 方法
处理文本节点
// 用于编译文本节点
compileText(node) {
// 判断文件节点中的内容是不是插值表达式
let reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
// 获取括号分组内的字符
let key = RegExp.$1.trim()
// 替换掉插值表达式的内容
node.textContent = value.replace(reg, this.vm[key])
}
}
compileElement
编译元素节点 处理指令
// 用于编译元素节点属性
compileElement(node) {
// 遍历所有的属性节点
Array.from(node.attributes).forEach(attr => {
// 判断是否是指令 v-xxx
let attrName = attr.name
if (this.isDirective(attrName)) {
// v-model --> model
attrName = attrName.substr(2)
let key = attr.value
// 因为指令有很多 不可能通过if来进行判断
// 可以采用函数名拼接的方式来扩展执行
this.update(node,key,attrName)
}
})
}
// 定义update函数来执行不同指令的更新操作
update(node, key, attrName) {
let updateFn = this[attrName + 'Updater']
updateFn && updateFn(node, this.vm[key])
}
// 处理 v-text 指令
textUpdater(node,value){
node.textContent = value
}
// v-model
modelUpdater(node,value){
node.value = value
}
Compile结束
class Compile {
constructor(el, vm) {
this.el = el
this.vm = vm
this.compile(this.el)
}
// 用于编译模板
compile(el) {
let childNodes = el.childNodes
// Array.from 将伪数组转化为数组
Array.from(childNodes).forEach(node => {
if (this.isTextNode(node)) {
this.compileText(node)
} else if (this.isElementNode(node)) {
this.compileElement(node)
}
// 判断节点是否还有子节点,进行递归调用
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
})
}
// 用于编译元素节点属性
compileElement(node) {
// 遍历所有的属性节点
Array.from(node.attributes).forEach(attr => {
// 判断是否是指令 v-xxx
let attrName = attr.name
if (this.isDirective(attrName)) {
// v-model --> model
attrName = attrName.substr(2)
let key = attr.value
// 因为指令有很多 不可能通过if来进行判断
// 可以采用函数名拼接的方式来扩展执行
this.update(node,key,attrName)
}
})
}
// 定义update函数来执行不同指令的更新操作
update(node, key, attrName) {
let updateFn = this[attrName + 'Updater']
updateFn && updateFn(node, this.vm[key])
}
// 处理 v-text 指令
textUpdater(node,value){
node.textContent = value
}
// v-model
modelUpdater(node,value){
node.value = value
}
// 用于编译文本节点
compileText(node) {
// 判断文件节点中的内容是不是插值表达式
let reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
// 获取括号分组内的字符
let key = RegExp.$1.trim()
// 替换掉插值表达式的内容
node.textContent = value.replace(reg, this.vm[key])
}
}
// 判断是否是指令
isDirective(attrName) {
return attrName.startsWith('v-')
}
// 判断是否是文本节点
isTextNode(node) {
return node.nodeType === 3
}
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1
}
}
到此为止,我们初步渲染就处理完成了,但是核心功能还没有实现,数据更新,视图也更新的功能 我们下篇再继续…