最近在看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>
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
的全局匹配,但是只匹配了一次,没有多次调用,所以也不会出现全部匹配的结果。怪自己之前没有好好读文档,以为exec
和match
一样只要设置了全局匹配,那么调用一次就会返回全部匹配结果。
思路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
本文地址:https://alphalrx.cn/index.php/archives/189/
版权说明:若无注明,本文皆为“LRX's Blog”原创,转载请保留文章出处。