写在前面
MVVM框架将web开发引入了新的境界。昔日的业务代码数据操作与DOM操作混在一起,既需要考虑数据操作是否正确,还需要考虑dom操作是否高效。当业务逻辑复杂起来,其复杂程度无需多言。
而有了这样的一个框架,我们基本不用再考虑过多的DOM细节。专注于维护业务逻辑,数据结构,可以说为开发人员减少了很大的压力。
下面进入正题,我们一起来实现一个简易的响应式框架,以点带面剖析原理。
框架实现
vue.js 是采用数据劫持结合发布/订阅模式,通过Object.defineProperty()劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听函数
框架实现主要包括以下三方面内容:
- 数据劫持(代理)
- 发布/订阅
- 模板编译
前两点实现响应式,第三点解析自定义dom结构,解析自定义指令,添加订阅实现响应等
数据代理/劫持
主要使用两种方法:
- Vue2使用Object.defineProperty()
- Vue3使用Proxy代理
Observer类
首先我们创建一个Observer类用来为数据定义访问器属性。主要功能是为传入对象的每个属性定义访问器属性,这是实现响应式的第一步。
class Observer {
constructor (data) {
this.observe(data)
}
// 观测对象
observe (data) {
// 注重原理,这里目的是判定数据是否为对象,更精确的还有Object.prototype.toString.call()
if(typeof data === 'object' && typeof data !== null) {
// 遍历 data 对象属性,调用 defineReactive 方法
for(let key in data) {
this.defineReactive(data, key, data[key])
}
}
}
// defineReactive方法仅仅是将data的属性转换为访问器属性
defineReactive (data, key, val) {
// 递归观测子属性
this.observe(val)
// 定义一个依赖收集器
const dep = new Dep()
// 定义访问器属性,响应式基础
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get () {
// 在这里收集依赖
Dep.target && dep.add(Dep.target)
return val // 这里用到了闭包
},
set: newVal => {
// 判断数据是否更新
if(val !== newVal){
// 对新值进行观测,同样是为了实现响应式
this.observe(newVal)
// 更新闭包中的值
val = newVal
// 当数据改变后通知当前dep收集到的所有Watcher
dep.notify() // 通知订阅者更新
}
}
})
}
}
由于语言层面的限制,我们只能监听到data对象的常规属性。而对象不同于常规类型,属于引用类型的数据,保存在堆内存中,它的比较更为复杂,无法直接利用等号完成,这也就是为什么Vue中如果修改对象某一个属性时可能会出现没有响应到的情况,这时就需要使用Vue.set()函数强制更新,或者使用整体替换的方式,新建一个对象覆盖原对象。
事件的发布/订阅
到这里,我们实现了Observer类,访问器属性准备就绪.但是光有基础还不够,我们还差一些调度——Dep类和Watcher类。
Dep是Dependency的缩写,意为依赖,是用来收集依赖并将访问器属性与Watcher连接起来的桥梁。每当setter发现数据更新,就通知相应属性的dep实例通知名下所有watcher更新数据,这就是响应,也就是之前的dep.notify()
Dep类
/**
* 依赖收集,用来关联watcher和observer
*/
class Dep {
constructor () {
// 储存订阅者——Watcher实例
this.watchers = []
}
add (sub) {
this.watchers.push(sub)
}
notify () {
// 通知收集到的所有观察者,数据更新啦!执行操作
this.watchers.forEach(watcher => watcher.update())
}
}
Dep类很简单,存储Watcher,然后在适当的时机调用update()
Watcher类
/**
* watcher
* @param {*} vm 当前实例
* @param {*} expr 表达式,就是数据获取途径,例如:data.style.color,data.name
* @param {*} fn 回调函数
*/
class Watcher {
constructor (vm, expr, fn) {
this.callback = fn
this.vm = vm
this.expr = exp // 类似于data.name的字符串
// 添加到订阅中 ————********下面三行划重点*********————
Dep.target = this
this.fire()
Dep.target = null // 防止watcher重复添加
}
// 这里是为了触发Observer中定义的getter属性,执行dep.add(Dep.target)
// 从而将watcher与Observer联系起来,函数叫什么本身并不重要
fire () {
// 利用reduce,将属性路径(如data.address.city)一层层剥开
return this.expr.split('.').reduce((result, key) =>{
return result[key]
}, this.vm.$data)
}
update () {
// 获取最新值,传入回调函数
const val = this.fire()
this.callback(val)
}
}
接下来让我们把目光放到这三行上:
Dep.target = this
this.fire()
Dep.target = null
首先我们思考,我们给data的属性都设置了访问器,数据变化时可以执行相应的逻辑,为什么还需要再使用发布/订阅模式来包一层呢?
由于模块化的限制,我们无法将对应的回调函数传入data属性的getter和setter中,因为我们不知道在网页中究竟哪些地方会使用到data里面的内容,不可能提前写好,只能动态添加。
所以这里需要dep实例来收集依赖,并适当的时机(setter中)执行dep.notify()
。
如何收集呢依赖呢?
Dep.target && dep.add(Dep.target)
结合上面的三行代码可以知道:
Dep.target = this // 将当前实例添加到Dep.target,暴露到外部
this.fire() // 触发getter,将Dep.target添加到dep实例中,收集到了依赖
Dep.target = null // 防止可能出现的意外,例如不小心添加了相同的Watcher
响应式的原理大致就是这样了,下面是框架的实现
模板编译
Vue模板编译的过程就是将template中的内容编译成render函数的形式。再由render函数来生成dom结构。而传入render函数的参数实际上就是虚拟dom。大家不要把虚拟dom想的那么高深,实际上就是json格式的对象而已。每个对象主要有三个属性:
- tag:标签名,如div
- properties:如style,class,id等dom属性
- children:数组,存放子元素,子元素结构与父元素相同。
这样形成的结构实际上是一棵树,所以也称为dom树。经过这样一番处理,template中的内容就变成了虚拟dom的格式,并且传给render函数render(tag, properties, children)
但是这里没有这么复杂,只是单纯的遍历dom结构,演示Watcher类和Dep类的使用
Vue 类
class Vue {
// options就是data, methods等常见配置
constructor(options) {
// 绑定根元素
this.$el = document.querySelector(options.el)
this.$data = options.data
// 用到了Observer为data设置访问器属性
new Observer(this.$data)
// 将方法挂载到this,接可以通过this直接调用
Object.assign(this, options.methods)
// 用来匹配{{}}双括号语法的正则表达式,多处用到,干脆绑定到this
this.reg = new RegExp(/\{\{(.+?)\}\}/, 'g')
// 缓存dom节点,为什么使用文档碎片,后面会提到
let fragment = this.createFragment(this.$el)
// 开始编译
this.compile(fragment)
// 将编译完的文本节点替换回去
this.$el.appendChild(fragment)
// 将data代理到this上,我们使用的时候大都是通过this直接调用
for (let key in this.$data) {
Object.defineProperty(this, key, {
enumerable: true,
get () {
return this.$data[key]
},
set (newVal) {
this.$data[key] = newVal
}
})
}
}
compile (fragment) {
// 将类数组结构展开遍历
[...fragment.childNodes].forEach(node => {
// 如果是元素节点则递归遍历
if (node.nodeType === 1) {
this.compileElement(node)
this.compile(node)
} else {
// 除去元素节点,就是文本节点了
this.compileText(node)
}
})
}
compileElement (node) {
// 元素节点的编译主要是转换其属性,所以这里遍历属性
[...node.attributes].forEach(attr => {
// expr就是expression的简写,是表达式的意思
// 这里name是属性名,value是属性值,就是我们需要编译替换的部分
// 通过解构赋值给value重新命名为expr
let {name, value: expr} = attr
// 判断是不是指令:v-bind,v-model,v-on
if (name.startsWith('@') || name.startsWith(':') || name.startsWith('v-')) {
// 如果属性包含这些字符说明是自定义属性,需要编译
// 否则是原生属性,跳过
this.compileInstruction(node, name, expr)
// 自定义节点编译完就删除
node.removeAttribute(name)
}
})
}
/**
* 编译自定义属性,指令等
* @param node dom节点
* @param name 属性名
* @param expr 表达式(属性值)
*/
compileInstruction (node, name, expr) {
// 用来匹配v-on:中的on(举例)
let reg = new RegExp(/v-(.+?)\:/)
if (reg.test(name)) {
// 举例 v-on:click / v-bind:class
// type就是on或bind
let [, type] = name.match(reg) // 获取匹配内容
// 获取事件名或属性名,例如click或class
let prop = name.substr(name.indexOf(':') + 1)
// 调用策略模式封装的算法,相关代码在后面
Instructions[type](this, node, prop, expr)
} else if (name.startsWith('v-')) {
// type为指令名
let [, type] = name.split('-')
Instructions[type](this, node, expr)
} else {
// prop是对应的事件类型或者属性
let [type, ...prop] = name
if (type === '@') {
Instructions['on'](this, node, prop.join(''), expr)
} else if (type === ':') {
Instructions['bind'](this, node, prop.join(''), expr)
}
}
}
// 编译文本节点
compileText (node) {
// 获取文本节点值
const nodeValue = node.nodeValue
// 匹配 {{}}
if (this.reg.test(nodeValue)) {
// 匹配到双括号说明需要替换
this.updateText(node, nodeValue)
}
}
updateText (node, originText) {
node.nodeValue = originText.replace(this.reg, (match, content) => {
// 这里的content就是我们的表达式
// 例如:我叫{{name}},匹配的content就是'name'
// 这个表达式用于getData函数获取数据,根据属性获取数据
// 这里需要为匹配到双括号表达式的文本节点添加订阅
// 利用箭头函数没有this的特性
// Watcher被触发时依然可以访问到这里的上下文
new Watcher(this, content.trim(), () => {
// *******这里注意*******
// this.getContentValue返回的就是最新值
// 不直接调用this.updateText是为了防止重复添加订阅
// 所以将一部分逻辑分离出来单独作为一个函数
// 实际上getContentValue在编译的时候是不会调用的
// 只有数据更新才会调用到
node.nodeValue = this.getContentValue(originText)
})
// 根据表达式content获取到相应的值并替换
return this.getData(content.trim())
})
}
// 这里为什么多引入一个函数?可以看到这里的逻辑基本是一样的
// 因为如果递归调用updateText的话,不仅会重复添加订阅
// 还会导致originText的值改变,不再为原始双括号表达式 => 我叫{{name}}
// 这样后面就无法匹配并更新这个表达式的值了
// 只有拿到这个文本才能每次替换新的值进去
// 如果递归调用,这个值就会变成例如:我叫小明
// 再往后数据再改变时,正则表达式就匹配不到双括号无法替换了
// 幸运的是,有了闭包,可能你都没意识到你使用了闭包,就这么解决了
getContentValue(originText) {
return originText.replace(this.reg, (match, content) => {
return this.getData(content.trim())
})
}
// 根据表达式拿到对应的数据
getData (expr) {
// 一层层的解析,获取到最终数据
return expr.split('.').reduce((data, key) => {
return data[key]
}, this.$data)
}
// 根据表达式设置data中对应的值
setData (expr, value) {
expr.split('.').reduce((data, current, index, arr) => {
if (index === arr.length - 1) {
return data[current] = value
}
return data[current]
}, this.$data)
}
// 创建文档碎片
createFragment (node) {
let fragment = document.createDocumentFragment()
do {
// 插入碎片相当于从页面中remove
fragment.appendChild(node.firstChild)
} while (node.firstChild)
return fragment
}
}
Instructions对象
封装了常见的指令:
// 指令处理对象
const Instructions = {
// v-model 实现数据双向绑定
model (vm, node, expr) {
// input => vm.$data
node.addEventListener('input', event => {
vm.setData(expr, event.target.value)
})
// vm.$data => input
node.value = vm.getData(expr)
},
// v-on / '@' 绑定事件
on (vm, node, eventType, handler) {
// handler绑定this,否则this指向event.target
node.addEventListener(eventType, vm[handler].bind(vm))
},
// v-bind / ':' 绑定属性
bind (vm, node, prop, expr) {
switch (prop) {
// v-bind:style
case 'style':
new Watcher(vm, expr, value => {
// 驼峰命名的CSS属性
Object.assign(node.style, value)
})
Object.assign(node.style, vm.getData(expr))
break
// v-bind:class
case 'class':
// 由于这里绑定的数组,能够编译出来,但响应方面可能没有做到
// 这里就不做了,感兴趣的话可以自己研究下如何响应数组
// 当然,直接用新的覆盖还是可以的
new Watcher(vm, expr, list => {
node.classList = list.join(' ')
})
node.classList = vm.getData(expr).join(' ')
break
default:
throw new Error('can\'t resolve the value ' + expr)
}
}
}
示例
先是DOM结构:
<style>
.red {
color: red
}
</style>
<div id="app">
<div class="header"
:style="style"
v-on:click="sayHello"
>
姓名:{{name}}
</div>
<div>
<div>省份:{{address.province}}, 市区:{{address.city}}</div>
<div :class="myClass">市区:{{address.city}}</div>
</div>
<div :class="myClass">我叫:{{name}}</div>
<input type="text" id="input" v-model="name">
<button @click="onClick">改变颜色</button>
</div>
然后是Vue实例:
new Vue({
el: '#app',
data: {
style: {
color: 'green',
fontSize: '10px'
},
myClass: ['red'],
name: '小明',
address: {
province: '陕西省',
city: '汉中市'
}
},
methods: {
onClick (e) {
this.style = {
color: 'red',
fontSize: '30px'
}
},
sayHello (e) {
console.log('你好,我叫', this.name)
}
}
})
一些问题
1. Object.defineProperty的缺点
在Vue2使用Object.defineProperty实现响应式,但这个方法有三个缺点:
- 对于庞大的对象需要一次性递归到底,效率很低
- 无法监听新增或删除属性,因为属性劫持只会在初始化时执行一次,这也是为什么,需要用到的属性即使是空值也要提前写在data中才行
- 无法原生监听数组,需要对数组进行包装
2. 如何使用Object.defineProperty监听数组?
监听数组需要做一些额外的处理,替换原型
// 创建一个原型指向Array.prototype的对象
const arrProto = Object.create(Array.prototype)
const methods = ['push', 'pop', 'splice', 'shift', 'unshift']
// 重新定义这些数组方法
methods.forEach(method => {
arrProto[method] = function (...args) {
console.log('更新视图!') // 在这里更新视图
Array.prototype[method].apply(this, args)
}
})
// 修改defineReactive函数
defineReactive (data, key, val) {
// 递归观测子属性
this.observe(val)
// 监听数组,更换新原型
if (Array.isArray(value)) {
value.__proto__ = arrProto
}
···
···
}
3. Vue3使用Proxy实现响应式
/**
* Vue3使用Proxy实现响应式
*/
function proxyObserve (target = {}) {
// 不是对象或数组则直接返回
if (
Object.prototype.toString.call(target) !== '[object Object]' &&
!Array.isArray(target)
) {
return target
}
return new Proxy(target, {
get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
if (Reflect.ownKeys(target).includes(key)) {
console.log('get', key) // 只监听对象,不监听原型
}
return proxyObserve(result) // 递归监听(惰性的,不会一次性递归完全)
},
set (target, key, value, receiver) {
if (target[key] === value) {
return true // 值没有变化则直接返回
}
console.log('set', key, value)
return Reflect.set(target, key, value, receiver)
},
deleteProperty (target, key) {
console.log('delete', key)
return Reflect.deleteProperty(target, key)
}
})
}
4. Proxy的优点和缺点
优点和Object.defineProperty相对应:
- Proxy原生支持监听数组,非常方便
- Proxy可以监听到新增、删除属性
- 在面对庞大对象时,不会一次性递归到底,默认只会代理第一层。当访问到对象属性或数组属性时才会进一步监听,效率更高
缺点主要是:Proxy的兼容性稍差一点,并且无法使用polyfill补救。
5. 关于Reflect对象
这里简要提一下Reflect对象。JavaScript中有很多的工具方法是定义在Object对象中的,但是实际上,这些方法与Object这个数据类型并没有什么关系。例如,Object.definProperty, Object.hanOwnProperty
等等。
Object承担了很多本不应该承担的责任,Reflect的出现就是为了将这些方法从Object中抽离出来,使Object单纯作为一种数据类型使用。并且为了和Proxy一一对应。总结来说,有以下几点:
- 将Object上一些明显属于语言内部的方法放到Reflect对象上(现阶段同时存在,以后会慢慢移除)。
- 修改某些Object方法的返回结果,使其变得更合理。例如
Object.defineProperty
在无法定义属性是抛错,而Refelct.defineProperty
则会返回false。- 让Object操作都变成函数行为(函数式编程)。某些操作是命令式,例如:
name in obj 和 delete obj[name]
。而Reflect对象让其vi按成了函数操作,如:Reflect.has(obj, name) 和 Reflect.deleteProperty(obj, name)
- Reflect的方法和Proxy一一对应,只要是Proxy对象的方法,就能在Reflect上找到。这使得Proxy可以使用Reflect的方法轻松完成对象的默认行为。
6. 实现一个MVVM里面需要那些核心模块?各个核心模块之间的关系是怎样的?
- 模型:就是数据
- 视图:用户在屏幕上看到的结构、布局和外观(UI)。
- 视图模型:连接视图和数据,将视图转化为数据或将数据转换为视图。如何转换?视图到数据需要通过dom事件监听,数据到视图需要观察者。视图模型层就负责调度这些流程。 关系如下: 对比Vue官方的图: 是不是一模一样。重点在于,如何使用Dep类和Watcher类来添加订阅。不明白的话,可以参考上面的defineReactive函数
7. 为什么操作DOM要利用文档碎片(Fragment)?
大家都知道DOM操作很昂贵,非常消耗性能,但却不知道为什么。其实主要是因为会触发浏览器的重绘(repaint)和重排(reflow)。
重绘是部分元素样式改变,需要重新绘制;重排是元素位置、大小等发生改变,浏览器要重新计算渲染树。导致渲染树的一部分或全部发生变化。渲染树重新建立后,浏览器会重新绘制页面上受影响的元素。
这些操作操作需要大量计算才能完成,所以吃性能。但是如果用文档碎片的话,将DOM元素放进内存操作,这时候就只是操作对象了,和浏览器就没有关系了。如果说对性能的影响的话,就只能是最后插入文档中的时候了。同理,也可以将display设置为none,只要浏览器显示出来的界面没有变化就ok,但一般不会这样做。。。