obsidian

在JS正则表达式中学到的
最近在看Vue2数据双向绑定原理,在完成数据劫持后,需要用Compiler对DOM中的模板语法进行匹配替换,涉及到...
扫描右侧二维码阅读全文
07
2022/08

在JS正则表达式中学到的

最近在看Vue2数据双向绑定原理,在完成数据劫持后,需要用Compiler对DOM中的模板语法进行匹配替换,涉及到正则表达式,发现对自己写的DOM,并没有完全替换。

问题

DOM结构

<body>
  <div id="app">
    <h1>实现Vue2数据双向绑定</h1>
    <div>
      姓名:{{ name }}
      <input v-model="name">
      <hr>
      工具:{{ tools.pc.name }}
      时间:{{ tools.pc.year }} 年
    </div>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  const vm = new Vue({
    el: '#app',
    data: {
      name: 'obsidian',
      tools: {
        pc: {
          name: 'macbook pro',
          year: 4
        },
      }
    }
  })
  console.log(vm);
</script>

DOM结构

DOM结构

Compiler

// HTML模版解析 - 替换DOM内
function Compiler(element, vm) {
  vm.$el = document.querySelector(element);
  let fragment = document.createDocumentFragment(); //创建文档碎片
  let child;
  while (child = vm.$el.firstChild) {
    fragment.append(child);
  }
  console.log(fragment);
  fragment_compile(fragment);
  // 替换文档碎片中的内容,也就是把模版语法中{{xxx}}替换成data中的xxx的值
  function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/;
    if (node.nodeType === 3) {
      let result = pattern.exec(node.nodeValue);
      if (result) {
        let ans = result[1].split('.').reduce((total, curr) => total[curr], vm.$data); // 属性链式调用
        node.nodeValue = node.nodeValue.replace(pattern, ans); // 将对应{{xxx}}中替换成相应属性值
      }
      return;
    };
    node.childNodes.forEach((child) => fragment_compile(child));
  }
  vm.$el.appendChild(fragment);
}

照着例子写完后发现时间并没有被替换,因为例子中一行里只有一个模板语法,所以也就不会有问题。

思路1

发现问题出现在正则匹配上,打印匹配结果,发现第二行之匹配了工具{{ tools.pc.name }}{{ tools.pc.year }}并没有匹配到,如下图:

于是我想到了将正则表达式改成全局模式,即使用const pattern = /\{\{\s*(\S+)\s*\}\}/g;这个表达式进行匹配。

这个方法的结果是虽然对时间进行了替换,但是把内容替换成了macbook pro

思路2

于是想换种方式匹配,这么看下来,这个问题就只是一个正则匹配的问题:要怎么样才能把字符串中的多个匹配项进行对应替换呢?

想到js里除了exec()方法之外,还有match()方法。在设置正则表达式为全局匹配的模式下,使用match的确可以匹配到全部结果。

虽然match把所有内容都匹配到了,可是结果是带有{{}}的,也就是他没有返回子匹配项。我们的目的只需要括号内部的对象属性,才好做下一步的属性链式调用。

为了去掉外面的{{}}而不失优雅,那么是否可以利用exec的匹配特性再对match到的每一个结果在进行一次匹配,拿到属性的链式调用字符串呢?

于是就有了下面的代码:

// HTML模版解析 - 替换DOM内
function Compiler(element, vm) {
  vm.$el = document.querySelector(element);
  let fragment = document.createDocumentFragment(); //创建文档碎片
  let child;
  while (child = vm.$el.firstChild) {
    fragment.append(child);
  }
  console.log(fragment);
  fragment_compile(fragment);
  // 替换文档碎片中的内容,也就是把模版语法中{{xxx}}替换成data中的xxx的值
  function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/g;
    if (node.nodeType === 3) {
      let result = node.nodeValue.match(pattern); // 全局匹配时,match会返回所有匹配上的内容
      if (result) {
          result.forEach(item => {
          console.log('item',item);
          let propName = pattern.exec(item); // 重复利用pattern
          console.log(item+'的propName', propName[1]);
          let ans = propName.split('.').reduce((total, curr) => total[curr], vm.$data);
          node.nodeValue = node.nodeValue.replace(pattern, ans);
        })
        console.log('node.nodeValue',node.nodeValue,'result',result);
      }
      return;
    };
    node.childNodes.forEach((child) => fragment_compile(child));
  }
  vm.$el.appendChild(fragment);
}

结果保存运行,发现报错了,原本exec匹配到的结果是个数组,里面包含匹配结果的详细信息(比如匹配项,子匹配项,匹配到的下标,原字符串等),而下标为1的位置就是我们要的子匹配项的内容。

为了一探究竟,把报错位置替换成了console.log(item+'的propName', propName && propName[1]);以便先确定propName存在的情况下,再打印propName[1]

结果发现匹配到{{ tools.pc.year }}时,其propName[1]居然是null

这就很奇怪了,{{ tools.pc.name }}都匹配的好好的,怎么一到{{ tools.pc.year }}就不行了呢?

最终昨天晚上还是放弃了,睡觉去了。

思路3(成功)

睡醒后,看到一个视频刚好在讲js的正则表达式的exec方法(可能是查资料太多被推荐了)。说到如果没有设置全局匹配的情况下,exec()match()的匹配结果是基本一致的,即只返回第一个匹配到的结果。但在全局匹配的模式下,match()会返回所有匹配结果,但是不会返回详细信息(这也和我昨天晚上的实验结果一致)。而exec()在全局模式下,重复调用会不断向下匹配,直到匹配不到返回null为止(实际测试发现,匹配到null再执行exec()就会从头开始循环匹配,这也是个新发现)。

从网上看到这么一段话:

但是,当 RegExpObject 是一个全局正则表达式时,exec() 的行为就稍微复杂一些。它会在 RegExpObject 的 lastIndex 属性指定的字符处开始检索字符串 string。当 exec() 找到了与表达式相匹配的文本时,在匹配后,它将把 RegExpObject 的 lastIndex 属性设置为匹配文本的最后一个字符的下一个位置。这就是说,您可以通过反复调用 exec() 方法来遍历字符串中的所有匹配文本。当 exec() 再也找不到匹配的文本时,它将返回 null,并把 lastIndex 属性重置为 0。

意思和上面描述的现象是一致的。

知道这一点就有思路了,既然exec()在全局模式下多次调用会不断匹配下一个,可以利用循环,每匹配到一个结果,就把字符串进行替换,然后把替换后的字符串再放入循环内进行匹配,那么这样永远匹配到的都是最新的结果,直到匹配不到返回null循环结束。

思路有了,开始编码。

// HTML模版解析 - 替换DOM内
function Compiler(element, vm) {
  vm.$el = document.querySelector(element);
  let fragment = document.createDocumentFragment(); //创建文档碎片
  let child;
  while (child = vm.$el.firstChild) {
    fragment.append(child);
  }
  console.log(fragment);
  fragment_compile(fragment);
  // 替换文档碎片中的内容,也就是把模版语法中{{xxx}}替换成data中的xxx的值
  function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/; //注意这里并不是g,这里不是g的原因是每个循环里替换了新的字符串,每次都会从头进行匹配
    if (node.nodeType === 3) {
      let res;
      while ((res = pattern.exec(node.nodeValue))) { // exec会不断的从上次匹配到的地方继续往下匹配,直到找不到为止,返回null
        console.log('res',res);
        const value = res[1].split('.').reduce((total, curr) => total[curr], vm.$data);
        node.nodeValue = node.nodeValue.replace(pattern, value);
      }
      return;
    };
    node.childNodes.forEach((child) => fragment_compile(child));
  }
  vm.$el.appendChild(fragment);
}

结果也是顺利解决了问题:

看到这,相信知道了exec()的用法,思路1和思路2存在的问题也就非常明显了。

思路1分析

虽然用了exec的全局匹配,但是只匹配了一次,没有多次调用,所以也不会出现全部匹配的结果。怪自己之前没有好好读文档,以为execmatch一样只要设置了全局匹配,那么调用一次就会返回全部匹配结果。

思路2分析

其实思路2的方法也是正确的,但是不够优雅,因为多了一步去掉括号的操作。虽然不优雅,但也能用。知道问题在于全局匹配后,解决办法也很简单,就是在去掉{{}}时用非全局匹配即可,也要注意在替换字符串时用非全局正则表达式进行匹配替换,代码如下:

function Compiler(element, vm) {
  vm.$el = document.querySelector(element);
  let fragment = document.createDocumentFragment(); //创建文档碎片
  let child;
  while (child = vm.$el.firstChild) {
    fragment.append(child);
  }
  console.log(fragment);
  fragment_compile(fragment);
  // 替换文档碎片中的内容,也就是把模版语法中{{xxx}}替换成data中的xxx的值
  function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/g; // 全局匹配
    if (node.nodeType === 3) {
      let result = node.nodeValue.match(pattern); // 全局匹配时,match会返回所有匹配上的内容
      if (result) {
        const pat = /\{\{\s*(\S+)\s*\}\}/; // 非全局匹配
        result.forEach(item => {
          console.log('item',item);
          let propName = pat.exec(item); // 重复利用pattern,非全局匹配,只匹配第一个
          console.log(item+'的propName', propName[1]);
          let ans = propName[1].split('.').reduce((total, curr) => total[curr], vm.$data);
          node.nodeValue = node.nodeValue.replace(pat, ans); // 这里也要换成pat
        })
      }
      return;
    };
    node.childNodes.forEach((child) => fragment_compile(child));
  }
  vm.$el.appendChild(fragment);
}

虽然这么也能做,但是写了两遍正则表达式,可读性就稍微差一点,搞不好还容易写错。

总结

虽然这个问题有经验的人一眼就明了了,但是从发现问题到苦思冥想再到解决问题的过程,痛并快乐着。当问题得到解决的时候,觉得昨晚熬的夜也不是浪费,起码是给自己多了一些思考的空间。

其次就是要注重基础,正则表达式的确是常见的开发工具,不同语言对于正则表达式的使用还是稍有不同的,所以需要多看文档,多动手实践,注意细节。

总之,问题得到解决,心情都变得好多了。

参考资料

[1] JavaScript exec() 方法, https://www.w3school.com.cn/jsref/jsref_exec_regexp.asp

[2] JavaScript replace() 方法, https://www.w3school.com.cn/jsref/jsref_replace.asp

[3] https://github.com/vuejs/vue

本文作者:Author:     文章标题:在JS正则表达式中学到的
本文地址:https://alphalrx.cn/index.php/archives/189/     
版权说明:若无注明,本文皆为“LRX's Blog”原创,转载请保留文章出处。
Last modification:August 7th, 2022 at 01:54 pm
给作者赏一杯奶茶吧!

Leave a Comment