昨天给大家分享了前端框架核心中的响应式实现的基本原理,今天继续给大家带来该系列的第二篇,关于模板编译的原理以及双向绑定的实现等知识点。
在上一篇的介绍中,我们了解到了如何去监听数据的变化,那么下一步呢?
以类 Vue 框架为例,我们看看一个典型的用法:
{{course.title}} 是 {{course.author}} 发布的
发布时间为 {{course.publishTime}}
let vue = new Vue({
ele: '#app',
data: {
stage: 'GitChat',
course: {
title: '前端面试宝典',
author: '小助手',
publishTime: '2022/04/20',
},
},
});
其中模版变量使用了 {{}} 的表达方式输出模版变量。
最终输出的 HTML 内容应该被合适的数据进行填充替换,因此还需要一步编译过程,该过程任何框架或类库中都是相通的,比如 React 中的 JSX,也是编译为 React.createElement,并在生成虚拟 DOM 时进行数据填充。
我们这里简化过程,将模版内容:
{{course.title}} 是 {{course.author}} 发布的
发布时间为 {{course.publishTime}}
输出为真实 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;
}
代码分析:
replace
方法对 {{变量}}
进行数据替换,同时 {{变量}}
的表达只会出现在 nodeType === 3
的文本类型节点中,因此对于符合 node.nodeType === 3 && reg.test(textContent)
条件的情况,进行数据获取和填充。这个编译过程比较简单,没有考虑到边界情况,只是单纯完成模版变量到真实 DOM 的转换,读者只需体会简单道理即可。
上述实现是单向的,数据变化引起了视图变化,那么如果页面中存在一个输入框,如何触发数据变化呢?比如:
<input v-model="inputData"/>
我们需要在模版编译中,对于存在 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 模式)」一定再熟悉不过了。
这种模式在 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 框架呢?其实不管是 Vue 还是其他类库或框架,其解决思想都是建立在前文所述概念之上的。
我们来进行串联,整个过程是:首先对数据进行深度拦截或代理,对每一个属性的 getter 和 setter 进行「加工」,该「加工」具体做些什么后面马上会有说明。在模版初次编译时,解析指令(如 v-model),并进行依赖收集({{变量}}),订阅数据的变化。
这里的依赖收集过程具体指:当调用 compiler 中的 replace 方法时,我们会读取数据进行模版变量的替换,这时候「读取数据时」需要做一个标记,用来表示「我依赖这一项数据」,因此我要订阅这个属性值的变化。Vue 中定义一个 Watcher 类来表示观察订阅依赖。这就实现了整套流程,换个思路再复述一遍:我们知道模版编译过程中会读取数据,进而触发数据源属性值的 getter,因此上面所说的数据代理的「加工」就是在数据监听的 getter 中记录这个依赖,同时在 setter 触发数据变化时,执行依赖对应的相关操作,最终触发模版中数据的变化。我们抽象成流程图来理解:
这也是 Vue 框架(类库)的基本架构图。由此看出,Vue 的实现,或者大部分 MVVM 的实现。
《前端面试题宝典》经过近一年的迭代,现已推出 小程序
和 电脑版刷题网站 (https://fe.ecool.fun/
),欢迎大家使用~
同时,我们还推出了面试辅导的增值服务,可以为大家提供 “简历指导” 和 “模拟面试” 服务,现在参与还有额外优惠,感兴趣的同学可以联系小助手(微信号:interview-fe)进行体验哦~