前端框架核心学习(二)

昨天给大家分享了前端框架核心中的响应式实现的基本原理,今天继续给大家带来该系列的第二篇,关于模板编译的原理以及双向绑定的实现等知识点。

模版编译原理介绍

上一篇的介绍中,我们了解到了如何去监听数据的变化,那么下一步呢?

以类 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;
}

代码分析:

  • 我们使用 fragment 变量储存生成的真实 HTML 节点内容。
  • 通过 replace 方法对 {{变量}} 进行数据替换,同时 {{变量}} 的表达只会出现在 nodeType === 3 的文本类型节点中,因此对于符合 node.nodeType === 3 && reg.test(textContent) 条件的情况,进行数据获取和填充。
  • 我们借助字符串 replace 方法第二个参数进行一次性替换,此时对于形如 {{data.course.title}} 的深层数据,通过 reduce 方法,获得正确的值。
  • 因为 DOM 结构可能是多层的,所以对存在子节点的节点,依然使用递归进行 replace 替换。

这个编译过程比较简单,没有考虑到边界情况,只是单纯完成模版变量到真实 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 融会贯通

回顾一下前面的基本内容:数据拦截和代理、发布订阅模式、模版编译,那么如何根据这些概念实现一个 MVVM 框架呢?其实不管是 Vue 还是其他类库或框架,其解决思想都是建立在前文所述概念之上的。

我们来进行串联,整个过程是:首先对数据进行深度拦截或代理,对每一个属性的 getter 和 setter 进行「加工」,该「加工」具体做些什么后面马上会有说明。在模版初次编译时,解析指令(如 v-model),并进行依赖收集({{变量}}),订阅数据的变化。

这里的依赖收集过程具体指:当调用 compiler 中的 replace 方法时,我们会读取数据进行模版变量的替换,这时候「读取数据时」需要做一个标记,用来表示「我依赖这一项数据」,因此我要订阅这个属性值的变化。Vue 中定义一个 Watcher 类来表示观察订阅依赖。这就实现了整套流程,换个思路再复述一遍:我们知道模版编译过程中会读取数据,进而触发数据源属性值的 getter,因此上面所说的数据代理的「加工」就是在数据监听的 getter 中记录这个依赖,同时在 setter 触发数据变化时,执行依赖对应的相关操作,最终触发模版中数据的变化。我们抽象成流程图来理解:

这也是 Vue 框架(类库)的基本架构图。由此看出,Vue 的实现,或者大部分 MVVM 的实现。

最后

《前端面试题宝典》经过近一年的迭代,现已推出 小程序 和 电脑版刷题网站 (https://fe.ecool.fun/),欢迎大家使用~

同时,我们还推出了面试辅导的增值服务,可以为大家提供 “简历指导” 和 “模拟面试” 服务,现在参与还有额外优惠,感兴趣的同学可以联系小助手(微信号:interview-fe)进行体验哦~