不知道你们在刚开始学习Vue、React、Angular这些前端框架时,有没有对其中的指令感到好奇,特别是v-if,v-for,HTML不是标记语言吗?为什么在DOM元素添加上这些指令就可以实现类似编程语言的条件判断和for循环?知道后来我才发现原来实际实现这些功能的还是JS,那么到底怎么实现的呢?
这里我只学过Vue,所以举例就以Vue的v-for来说明。这样同一框架的不同指令、不同框架相同功能的指令都能照葫芦画瓢,知道大概如何实现。当然既然要说明,实现还是用原生JS。还有要说明一点,我的实现方法不是Vue真正的实现方式,我的方法只是可以实现,可能还有其它时间和空间复杂度更好的算法。
为了方便说明,我们假设目标元素为
<div s-for="item in list">{
{
item}}</div>
有人可能会好奇这里是不是打错字了,不是指令吗?怎么变成属性了。其实v-for这些指令在HTML标签里实际上就是一个自定义属性,只不过加了这些属性,JS获取后做了一些处理,让他可以执行一些操作,这样称之为指令也无可厚非。
接下来我们就来说说到底做了哪些处理,执行了什么操作。
说到用JS获取DOM元素,大家肯定会想到getElementById、getElementsByTagName这些方法,但是仔细一想这些方法貌似都不太试用。因为有s-for属性的标签不一定有id属性,有id属性的标签不一定有s-for属性,同理getElementsByTagName也不行。那么到底要怎样才能拿到包含我们自定义s-for属性的DOM元素呢?
答案就是document.querySelectorAll()方法。它的功能相比之前两个方法,有更好的普适性。因为他只要传入CSS选择器就可获取到对应的DOM元素,而CSS选择器有一个属性选择器,这样我们就可以获取到页面上所有包含s-for属性的标签。
这里注意一定要用querySelectorAll()方法,而不能使用querySelector(),因为页面上可能不止有一处地方被我们添加了s-for指令,我们要循环处理querySelectorAll()返回的数组,这样所有s-for指令的元素都能被获取。而querySelector()只能获取匹配到指定选择器的第一个元素。
<div s-for="item in list">{
{
item}}</div>
<p s-for="item in list">{
{
item}}</p>
<script type="text/javascript">
var forDirect = document.querySelectorAll('[s-for]');
for(item of forDirect){
console.log(item);
}
</script>
console控制台显示我们拿到了我们想要的DOM元素
Vue的模板字符串语法是{ {name}},ES6的语法是`${name}`,实现都差不多。
主要通过innerHTML拿到对应标签的内容,因为是字符串我们需要用正则匹配到{ {}}里的内容,记为content,因为content应该是一个script里已经定义好的变量,所以我们可以直接eval(content)拿到里面的值。
模板字符串的实现eval()函数和正则表达式是关键,大家不懂可以先了解一下。eval()函数的主要功能就是将传入的字符串解析成js代码并执行。
话不多说,上代码。
<div>我的名字叫:{
{
name }}</div>
<p>我的年龄:{
{
age }}</p>
<!--测试标签嵌套是否也可以替换-->
<div><span>我的年级:{
{
classroom }}</span></div>
<script type="text/javascript">
// 把这里的数据看成是Vue里的data数据
var name = "lisi";
var age = 12;
var classroom = "高一一班";
function replace(content){
return content.replace(/\{
{2}([^}]+)\}{2}/g,function(match,string){
try{
return eval(string);
}catch(e){
return match;
}
})
}
var body = document.querySelector("body");
var elements = body.children;
for(var i = 0; i < elements.length; i++){
elements[i].innerHTML = replace(elements[i].innerHTML);
}
</script>
实现效果:
因为页面上任何一处地方都可能有模板字符串。所以我们需要拿到body下所有标签的一个数组。不能单纯document.querySelectorAll(""),因为 这样html标签也会被选取到,body标签也会选取到,html标签里包含body标签,重复了。同理也不能拿到body元素后,body.querySelectorAll("")。
var body = document.querySelector("body");
var elements = body.children;
document.querySelectorAll("*")就会获取如下元素:
这里说明一下replace函数(这跟字符串原型上的replace方法不是同一个东西),它的参数接收一个标签的innerHTML。接着看字符串方法replace(),相信大家都用过,第一个参数是正则表达式,大家都知道,关键是第二个参数是一个回调函数,一般大家使用的第二个参数直接传入一个字符串,但是这里我们用回调函数。这个回调函数第一个参数是匹配到的子串,那么他就是{ { name }},第二个参数代表正则第一个括号匹配的结果,那么他就是 name 。这么说大家可能就明白了,当然这个回调函数还有一些其他参数,功能强大,大家可以去MDN官方文档看他说明。另外为什么要try catch捕获异常。这里主要考虑到代码的鲁棒性。有可能开发人员写错了,模板字符串里的并不是一个已经定义好的变量,那么eval()执行的话就会报错导致整个页面无法渲染,我们直接将原字符串作为替换字符串直接返回出去就好了,即不进行替换。function replace(content){
// 这里的正则是匹配{出现两次、}出现两次,以及他们包围的字符串
return content.replace(/\{
{2}([^}]+)\}{2}/g,function(match,string){
try{
return eval(string);
}catch(e){
return match;
}
})
}
另外这里如果出于性能优化把script标签放在body里(放在body后面也会),如果代码里有{ { name }},并且确实有name这个属性,那么也会被替换掉。因为script也是body的子标签。解决办法是将script放在head里,并使用window.onload将代码包进去。
这里就比较简单了,字符串匹配list,然后eval()就可以拿到数据了。
<div s-for="item in list">{
{
item}}</div>
<script type="text/javascript">
var list = ["LOL","CF","DNF","QQ"]
var forDirect = document.querySelectorAll('[s-for]');
for(item of forDirect){
var value = item.getAttribute('s-for');
// 因为s-for的属性包含空格,所以我们可以用正则匹配他里面的三个单词
// 因为变量开头不能是数字,且可能包含下划线和$,所以比较复杂
var reg = /[a-zA-Z_$]{1}[0-9a-zA-Z_$]*/g;
// 返回一个包含三个单词的数组,因为list是最后一个元素,所以是[2];
var aList;
try {
aList = eval(value.match(reg)[2]);
} catch(e){
continue;
}
for(item of aList){
console.log(item);
}
}
</script>
控制台显示我们拿到了数据。
这里还有一个难题,s-for属性里的子项item是list的子项,script标签里实际并没有定义这个变量,那么模板字符串就不会替换,因为根本不存在这个变量。
//script里有name这个变量,所以可以替换
<div>我的名字叫:{
{
name }}</div>
//script没有item这个变量。所以无法替换
<div s-for="item in items">{
{
item}}</div>
解决这个问题的办法有两种,一个是在script里随便创建一个变量 i 在list 循环中将list子项赋值给他,这样在list循环中 i 就是list的子项,同时把标签里的item全部替换成i,然后就可以用模板字符串替换了(本人没有试验过,觉得比较麻烦,我采用下面第二种方式,这种方式理论也是可以的)。
<div s-for="item in items">{
{
item}}</div> => <div s-for="i in items">{
{
i}}</div>
第二种方式在eval()里传入用字符串匹配到的 item ,然后在它的的左侧加上"var “,右侧加上”=list[i]"。在eval()里直接声明这个变量。这样不用更改标签里的内容也能使用模板字符串替换了。(有没有觉得还挺巧妙。。。)
var forDirect = document.querySelectorAll('[s-for]');
var value = item.getAttribute('s-for')
for(var i = 0; i < list.length; i++){
eval("var " + value.match(reg)[0] + " = list[j]")
}
<body>
<ul>
<li s-for="item in list">
<span>我叫{
{
item.name}},</span>
<span>我{
{
item.age}}岁,</span>
<span>我喜欢{
{
item.gf}}</span>
</li>
</ul>
<p>我也不知道写啥</p>
<div>
<div s-for="item in list">
<span>我叫{
{
item.name}},</span>
<span>我{
{
item.age}}岁,</span>
<span>我喜欢{
{
item.gf}}</span>
</div>
</div>
<script type="text/javascript">
var list = [{
name:"张山",age:14,gf:"Cmf"},{
name:"李四",age:16,gf:"Tsai"},{
name:"王五",age:18,gf:"CMF"}];
function replace(content){
return content.replace(/\{
{2}([^}]+)\}{2}/g,function(match,string){
try{
return eval(string);
} catch(e) {
return match;
}
})
}
var forDirect = document.querySelectorAll('[s-for]');
for(var i = 0; i < forDirect.length; i++){
var value = forDirect[i].getAttribute('s-for');
var reg = /[a-zA-Z_$]{1}[0-9a-zA-Z_$]*/g;
var aList = eval(value.match(reg)[2]);
var parentNode = forDirect[i].parentNode;
var frag = document.createDocumentFragment();
for(var j = 0; j < aList.length; j++){
eval("var " + value.match(reg)[0] + " = aList[j]");
var childNode = document.createElement(forDirect[i].localName);
childNode.innerHTML = replace(forDirect[i].innerHTML);
frag.appendChild(childNode);
}
parentNode.removeChild(forDirect[i])
parentNode.appendChild(frag);
}
</script>
</body>
实现效果如下:
触类旁通,v-if、v-show的实现也都差不多。区别在于拿到v-if和v-show的DOM元素数组之后,循环的时候做的处理不一样,做的处理不一样功能自然不一样。v-if和v-show都比较好实现,直接拿到指令里面的值放到eval里执行判断真假即可实现对元素的隐藏和显示,v-if是创建删除元素、v-show控制该元素的display属性,处理有一点点小区别。(本人没尝试过,但是实现起来应该没什么大问题)。最后还可以把相关逻辑代码写到js文件里,这样就跟Vue的指令使用没啥区别了。先引脚本文件,然后js里定义好数据,标签里绑定指令就可以使用了。
当然Vue内部v-for实现考虑肯定更加全面周到,容错率也很高,算法也比较好。我只是最近几天突然来了灵感,解决了我之前学习Vue的一些疑惑,所以把这些分享大家,如果有讲错的地方,请各位大佬及时评论纠正。