写在前面:有人说,一叶知秋 ABAB 年究竟会怎么样,我们拭目以待,我认为,越往后越是后互联网时代,竞争之惨烈。
接续上一篇的,上一篇我们说我们可以用对数组函数重写的方法,方式来实现对数组,push 方法等进行监控。
然而有更好的替代方式和方法,那就是ES Next的新特性 - Proxy,之前也介绍过,它可以完成对数据的代理。
尝试使用Proxy 来完成对代码的重构:
let data = {
stage: 'GitChat',
course: {
title: '前端开发进阶',
author: ['Lucas'],
publishTime: '2018 年 5 月'
}
}
const observe = data => {
if (!data || Object.prototype.toString.call(data) !== '[object Object]') {
return
}
Object.keys(data).forEach(key => {
let currentValue = data[key]
// 事实上 proxy 也可以对函数类型进行代理。这里只对承载数据类型的 object 进行处理,读者了解即可。
if (typeof currentValue === 'object') {
observe(currentValue)
data[key] = new Proxy(currentValue, {
set(target, property, value, receiver) {
// 因为数组的 push 会引起 length 属性的变化,所以 push 之后会触发两次 set 操作,我们只需要保留一次即可,property 为 length 时,忽略
if (property !== 'length') {
console.log(`setting ${key} value now, setting value is`, currentValue)
}
return Reflect.set(target, property, value, receiver)
}
})
}
else {
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`getting ${key} value now, getting value is:`, currentValue)
return currentValue
},
set(newValue) {
currentValue = newValue
console.log(`setting ${key} value now, setting value is`, currentValue)
}
})
}
})
}
此时对数组进行操作:
data.course.author.push('messi')
// setting author value now, setting value is ["Lucas"]
已经符合我们的需求了。注意这里在使用Proxy 进行代理时,并没有对getter进行代理,因此上诉代码的输出结果并不像之前使用
Object.defineProperty 那样也会有 getting value 输出。
整体实现并不难理解,需要读者了解最基本的 Proxy 知识。简单总结一下,对于数据键值为基本类型的情况,我们使用Object.defineProperty; 对于键值为对象类型的情况,继续递归调用 observe 方法,并通过 Proxy 返回的新对象对data[key]重新赋值,这个新值的getter 和 setter 已经被添加了代理。
了解了 Proxy 实现之后,我们对 Proxy 实现数据代理 和 Object.defineProperty 实现数据拦截进行对比,会发现;
Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写
Object.defineProperty 必须遍历对象的每个属性,且对于嵌套结构需要深层遍历
Proxy的代理是针对整个对象的,而不是对象的某个属性,因此不同于 Object.defineProperty的必须遍历对象每个属性,Proxy 只需要做一层代理监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的
Proxy 支持代理数组的变化
Proxy 的第二个参数除了 set 和 get 以外,可以有13种拦截方式,比起 Object.defineProperty() 更加强大,这里不再一一举例
Proxy 性能将会被底层持续优化,而 Object.defineProperty 已经不再是优化重点。
模板编译原理介绍
到此,我们了解如何监听数据的变化,那么下一步?以类 Vue 框架为例,我们看看一个典型的用法:
<body>
<div id="app">
<h1>{{stage}} 平台课程:{{course.title}}</h1>
<p>{{course.title}} 是 {{course.author}} 发布的课程</p>
<p>发布时间为 {{course.publishTime}} </p>
</div>
<script>
let vue = new Vue({
ele: '#app',
data: {
stage: 'GitChat',
course: {
title: '前端开发进阶',
author: 'Lucas',
publishTime: '2018 年 5 月'
},
}
})
</script>
<body>
其中 模板变量使用了{{}} 表达方式输出模板变量。最终输出的HTML内容应该被合适的数据进行填充替换,因此还需要一步编译过程,该过程任何框架或类库中都是想通的,比如React中的JSX,也是编译为React.createElement,并在生成虚拟DOM时进行数据填充。
我们这里简化过程,将模板内容:
<div id="app">
<h1>{{stage}} 平台课程:{{course.title}}</h1>
<p>{{course.title}} 是 {{course.author}} 发布的课程</p>
<p>发布时间为 {{course.publishTime}} </p>
</div>
输出为真实HTML即可。
模拟编译实现
一提到这样的“模板编译”过程,很多开发者都会想到词法分析,也许都会感觉都头大。其实原理很简单,就是使用正则 + 遍历,有时也需要一些算法知识,我们来看现在的场景,只需要对#app节点下内容进行替换,通过正则识别出模板变量,获取对应的数据即可:
compile(document.querySelector('#app'), data)
function compile(el, data) {
let fragment = document.createDocumentFragment()
while (child = el.firstChild) {
fragment.appendChild(child)
}
// 对 el 里面的内容进行替换
function replace(fragment) {
Array.from(fragment.childNodes).forEach(node => {
let textContent = node.textContent
let reg = /\{\{(.*?)\}\}/g
if (node.nodeType === 3 && reg.test(textContent)) {
const nodeTextContent = node.textContent
const replaceText = () => {
node.textContent = nodeTextContent.replace(reg, (matched, placeholder) => {
return placeholder.split('.').reduce((prev, key) => {
return prev[key]
}, data)
})
}
replaceText()
}
// 如果还有子节点,继续递归 replace
if (node.childNodes && node.childNodes.length) {
replace(node)
}
})
}
replace(fragment)
el.appendChild(fragment)
return el
}
代码分析:我们使用 fragment 变量存储生成真实HTML 节点内容。通过replace 方法对{{变量}}进行数据替换,同时{{变量}}的表达只会出现在nodeType === 3的文本类型节点中,因此对于符合 node.nodeType === 3 && reg.test(textContent) 条件的情况,进行数据获取和填充。我们借助字符串replace 方法第二个参数进行一次性替换,此时对于形如{{data.course.title}}的深层数据,通过reduce 方法,获取正确的值。
因为DOM结构可能是多层的,所以对存在子节点的节点,依然使用递归进行 replace 替换。
这个编译过程比较简单,没有考虑到边界问题,只是单纯完成模板变量到真实DOM的转换。
————————————————————————————————————一条分割线
双向绑定的实现
上述实现是单向的,数据变化引起了视图变化,那么如果页面中存在一个输入框,如何触发数据变化呢?比如:
<input v-model = "inputData" type = "text">
我们需要在模板编译中,对于存在 v-model 属性的 node 进行事件监听,在输入框输入时,改变 v-model 属性值对应的数据即可
(这里为 inputData),增加compile中的 replace 方法逻辑,对于 node.nodeType === 1 的 DOM 类型,伪代码如下:
function replace(el, data) {
// 省略...
if (node.nodeType === 1) {
let attributesArray = node.attributes
Array.from(attributesArray).forEach(attr => {
let attributeName = attr.name
let attributeValue = attr.value
if (name.includes('v-')) {
node.value = data[attributeValue]
}
node.addEventListener('input', e => {
let newVal = e.target.value
data[attributeValue] = newVal
// ...
// 更改数据源,触发 setter
// ...
})
})
}
if (node.childNodes && node.childNodes.length) {
replace(node)
}
}
发布订阅模式简单应用,实际上之前有过归档设计模式,写过一些设计模式的东西,设计模式这种东西,不用光背诵实际上是没有意义的也是没有效果的,例如那篇小明买楼的文章,就很好的解释了一些设计模式的事情。
作为前端开发人员,我们对于所谓的“事件驱动”理念——既“事件发布订阅模式”(Pub/Sub模式)
我特意查了下 (Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。)
事件驱动大多数人肯定是听过, 事件发布订阅模式,大多数人也听过。但是能讲好,能好好万万全全 完整的讲出来,我认为不多。
这种模式在JavaScript 里面有与生俱来的基因:我们可以认为JavaScript 本身就是事件驱动型语言,比如,应用中对一个button进行了事件绑定,用户点击之后就会触发按钮上的click事件。这是因为此时有特定程序正在监听这个事件,随之触发了相应的处理程序。
这个模式的一个好处之一在于能解耦,实现“高内聚、低耦合”的理念。这种模式对于我们框架的设计同样也不可或缺。请思考:通过前面内容的学习,我们了解了如何监听数据的变化。如果最终实现响应式MVVM,或所谓的双向绑定,那么还需要根据这个数据变化作出相应的视图更新。这个逻辑和我们在页面中对 button 绑定事件处理函数是多么相近。
再次来到代码实现部分
class Notify {
constructor() {
this.subscribers = []
}
add(handler) {
this.subscribers.push(handler)
}
emit() {
this.subscribers.forEach(subscriber => subscriber())
}
}
使用:
let notify = new Notify()
notify.add(() => {
console.log('emit here')
})
notify.emit()
// emit here
这就是一个简单实现的“事件发布订阅模式”,当然代码只是启发思路,真实应用还比较“粗糙”,没有进行事件名设置,APIs 也并不丰富,但完全能够说明问题了。其实读者翻看Vue源码,也能了解Vue中的发布订阅模式很简单。
MVVM 融会贯通
回顾一下前面的基本内容:数据拦截和代理、发布订阅模式、模板编译,那么如何根据这些概念实现一个MVVM框架呢?其实不管是 Vue 还是其他类库或框架,其解决的思想都是建立在前文所述概念之上的。
我们来进行串联,整个过程是:首先对数据进行深度拦截或代理,对每一个属性的 getter 和 setter 进行“加工”,该“加工”具体做些什么后面马上会有说明。在模板初次编译时,解析指令(如 v-model),并进行依赖收集({{变量}}),订阅数据的变化。
这里的依赖收集过程具体指: 当调用 compiler 中的 replace 方法时,我们会读取数据进行模板变量的替换,这时候“读取数据时”需要做一个标记,用来表示“我依赖这一项数据”,因此我要订阅这个属性值的变化。Vue 中定义一个 Watcher 类来表示观察订阅依赖。这就实现了整套流程,换个思路再复述一遍:我们知道模板编译过程中会读取数据,进而触发数据源属性值的getter,因此上面所说的数据代理的“加工”就是在数据监听的getter,因此上面所说的数据代理的“加工”就是在数据监听的getter 中记录这个依赖,同时在setter 触发数据变化时,执行依赖对应的相关操作,最终触发模板中数据的变化。
我们抽象成流程图来理解:

我又在盗图了。。。。。
这也是Vue框架 (类库) 的基本架构图。由此看出, Vue 的实现,或者大部分 MVVM 的实现,就是我们本节课程介绍的概念组合应用。
文章采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接。