前端渲染引擎doT.js解析

背景

前端渲染有很多框架,而且形式和内容在不断发生变化。这些演变的背后是设计模式的变化,而归根到底是功能划分逻辑的演变:MVC—>MVP—>MVVM(忽略最早混在一起的写法,那不称为模式)。近几年兴起的React、Vue、Angular等框架都属于MVVM模式,能帮我们实现界面渲染、事件绑定、路由分发等复杂功能。但在一些只需完成数据和模板简单渲染的场合,它们就显得笨重而且学习成本较高了。

例如,在美团外卖的开发实践中,前端经常从后端接口取得长串的数据,这些数据拥有相同的样式模板,前端需要将这些数据在同一个样式模板上做重复渲染操作。

解决这个问题的模板引擎有很多,doT.js(出自女程序员Laura Doktorova之手)是其中非常优秀的一个。下表将doT.js与其他同类引擎做了对比:

| 框架 | 大小 | 压缩版本大小 | 迭代 | 条件表达式 | 自定义语法 | | ———— | ————- | ———— | ———— | | doT.js | 6KB | 4KB | ✓ | ✓ | ✓ | | mustache | 18.9 KB | 9.3 KB | ✓ | ✗ | ✓ | | Handlebars | 512KB | 62.3KB | ✓ | ✓ | ✓ | | artTemplate(腾讯) | - | 5.2KB | ✓ | ✓| ✓ | | BaiduTemplate(百度) | 9.45KB | 6KB | ✓ | ✓ | ✓ | | jQuery-tmpl | 18.6KB | 5.98KB | ✓ | ✓ | ✓ |

可以看出,doT.js表现突出。而且,它的性能也很优秀,本人在Mac Pro上的用Chrome浏览器(版本为:56.0.2924.87)上做100条数据10000次渲染性能测试,结果如下:

性能测试

从上可以看出doT.js更值得推荐,它的主要优势在于: 1. 小巧精简,源代码不超过两百行,6KB的大小,压缩版只有4KB; 2. 支持表达式丰富,涵盖几乎所有应用场景的表达式语句; 3. 性能优秀; 4. 不依赖第三方库。

本文主要对doT.js的源码进行分析,探究一下这类模板引擎的实现原理。

如何使用

如果之前用过doT.js,可以跳过此小节,doT.js使用示例如下:

<script type="text/html" id="tpl"><div><a>name:{{= it.name}}</a><p>age:{{= it.age}}</p><p>hello:{{= it.sayHello() }}</p><select>{{~ it.arr:item}}<option {{?item.id == it.stringParams2}}selected{{?}} value="{{=item.id}}">{{=item.text}}</option>{{~}}</select></div>
</script>
<script>$("#app").html(doT.template($("#tpl").html())({name:'stringParams1',stringParams1:'stringParams1_value',stringParams2:1,arr:[{id:0,text:'val1'},{id:1,text:'val2'}],sayHello:function () {return this[this.name]}}));
</script>

可以看出doT.js的设计思路:将数据注入到预置的视图模板中渲染,返回HTML代码段,从而得到最终视图。

下面是一些常用语法表达式对照表:

项目JavaScript语法对应语法案例
输出变量={{= 变量名}}{{=it.name }}
条件判断if{{? 条件表达式}}{{? i > 3}}
条件转折else/else if{{??}}/{{?? 表达式}}{{?? i ==2}}
循环遍历for{{~ 循环变量}}{{~ it.arr:item}}…{{~}}
执行方法funcName(){{= funcName() }}{{= it.sayHello() }}

源码分析及实现原理

和后端渲染不同,doT.js的渲染完全交由前端来进行,这样做主要有以下好处:

  1. 脱离后端渲染语言,不需要依赖后端项目的启动,从而降低了开发耦合度、提升开发效率;
  2. View层渲染逻辑全在JavaScript层实现,容易维护和修改;
  3. 数据通过接口得到,无需考虑后端数据模型变化,只需关心数据格式。

doT.js源码核心:

...
// 去掉所有制表符、空格、换行
str = ("var out='" + (c.strip ? str.replace(/(^|\r|\n)\t* +| +\t*(\r|\n|$)/g," ").replace(/\r|\n|\t|\/\*[\s\S]*?\*\//g,""): str).replace(/'|\\/g, "\\$&").replace(c.interpolate || skip, function(m, code) {return cse.start + unescape(code,c.canReturnNull) + cse.end;}).replace(c.encode || skip, function(m, code) {needhtmlencode = true;return cse.startencode + unescape(code,c.canReturnNull) + cse.end;})// 条件判断正则匹配,包括if和else判断.replace(c.conditional || skip, function(m, elsecase, code) {return elsecase ?(code ? "';}else if(" + unescape(code,c.canReturnNull) + "){out+='" : "';}else{out+='") :(code ? "';if(" + unescape(code,c.canReturnNull) + "){out+='" : "';}out+='");})// 循环遍历正则匹配.replace(c.iterate || skip, function(m, iterate, vname, iname) {if (!iterate) return "';} } out+='";sid+=1; indv=iname || "i"+sid; iterate=unescape(iterate);return "';var arr"+sid+"="+iterate+";if(arr"+sid+"){var "+vname+","+indv+"=-1,l"+sid+"=arr"+sid+".length-1;while("+indv+"<l"+sid+"){"+vname+"=arr"+sid+"["+indv+"+=1];out+='";})// 可执行代码匹配.replace(c.evaluate || skip, function(m, code) {return "';" + unescape(code,c.canReturnNull) + "out+='";})+ "';return out;")...try {return new Function(c.varname, str);//c.varname 定义的是new Function()返回的函数的参数名} catch (e) {/* istanbul ignore else */if (typeof console !== "undefined") console.log("Could not create a template function: " + str);throw e;}
...

这段代码总结起来就是一句话:用正则表达式匹配预置模板中的语法规则,将其转换、拼接为可执行HTML代码,作为可执行语句,通过new Function()创建的新方法返回。

代码解析重点1:正则替换

正则替换是doT.js的核心设计思路,本文不对正则表达式做扩充讲解,仅分析doT.js的设计思路。先来看一下doT.js中用到的正则:

templateSettings: {evaluate:    /\{\{([\s\S]+?(\}?)+)\}\}/g, //表达式interpolate: /\{\{=([\s\S]+?)\}\}/g, // 插入的变量encode:      /\{\{!([\s\S]+?)\}\}/g, // 在这里{{!不是用来做判断,而是对里面的代码做编码use:         /\{\{#([\s\S]+?)\}\}/g,useParams:   /(^|[^\w$])def(?:\.|\[[\'\"])([\w$\.]+)(?:[\'\"]\])?\s*\:\s*([\w$\.]+|\"[^\"]+\"|\'[^\']+\'|\{[^\}]+\})/g,define:      /\{\{##\s*([\w\.$]+)\s*(\:|=)([\s\S]+?)#\}\}/g,// 自定义模式defineParams:/^\s*([\w$]+):([\s\S]+)/, // 自定义参数conditional: /\{\{\?(\?)?\s*([\s\S]*?)\s*\}\}/g, // 条件判断iterate:     /\{\{~\s*(?:\}\}|([\s\S]+?)\s*\:\s*([\w$]+)\s*(?:\:\s*([\w$]+))?\s*\}\})/g, // 遍历varname:   "it", // 默认变量名strip:    true,append:       true,selfcontained: false,doNotSkipEncoded: false // 是否跳过一些特殊字符
}

源码中将正则定义写到一起,这样方便了维护和管理。在早期版本的doT.js中,处理条件表达式的方式和tmpl一样,采用直接替换成可执行语句的形式,在最新版本的doT.js中,修改成仅一条正则就可以实现替换,变得更加简洁。

doT.js源码中对模板中语法正则替换的流程如下:

渲染流程

代码解析重点2:new Function()运用

函数定义时,一般通过Function关键字,并指定一个函数名,用以调用。在JavaScript中,函数也是对象,可以通过函数对象(Function Object)来创建。正如数组对象对应的类型是Array,日期对象对应的类型是Date一样,如下所示:

var funcName = new Function(p1,p2,...,pn,body);

参数的数据类型都是字符串,p1到pn表示所创建函数的参数名称列表,body表示所创建函数的函数体语句,funcName就是所创建函数的名称(可以不指定任何参数创建一个匿名函数)。

下面的定义是等价的。

例如:

// 一般函数定义方式
function func1(a,b){return a+b;
}
// 参数是一个字符串通过逗号分隔
var func2 = new Function('a,b','return a+b');
// 参数是多个字符串
var func3 = new Function('a','b','return a+b');
// 一样的调用方式
console.log(func1(1,2));
console.log(func2(2,3));
console.log(func3(1,3));
// 输出
3 // func1
5 // func2
4 // func3

从上面的代码中可以看出,Function的最后一个参数,被转换为可执行代码,类似eval的功能。eval执行时存在浏览器性能下降、调试困难以及可能引发XSS(跨站)攻击等问题,因此不推荐使用eval执行字符串代码,new Function()恰好解决了这个问题。回过头来看doT代码中的”new Function(c.varname, str)“,就不难理解varname是传入可执行字符串str的变量。

具体关于new Fcuntion的定义和用法,详细请阅读Function详细介绍。

性能之因

读到这里可能会产生一个疑问:doT.js的性能为什么在众多引擎如此突出?通过阅读其他引擎源代码,发现了它们核心代码段中都存在这样那样的问题。

jQuery-tmpl

function buildTmplFn( markup ) {return new Function("jQuery","$item",// Use the variable __ to hold a string array while building the compiled template. (See https://github.com/jquery/jquery-tmpl/issues#issue/10)."var $=jQuery,call,__=[],$data=$item.data;" +// Introduce the data as local variables using with(){}"with($data){__.push('" +// Convert the template into pure JavaScriptjQuery.trim(markup).replace( /([\\'])/g, "\\$1" ).replace( /[\r\t\n]/g, " " ).replace( /\$\{([^\}]*)\}/g, "{{= $1}}" ).replace( /\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,function( all, slash, type, fnargs, target, parens, args ) {//省略部分模板替换语句,若要阅读全部代码请访问:https://github.com/BorisMoore/jquery-tmpl}) +"');}return __;");}

在上面的代码中看到,jQuery-teml同样使用了new Function()的方式编译模板,但是在性能对比中jQuery-teml性能相比doT.js相差甚远,出现性能瓶颈的关键在于with语句的使用。

with语句为什么对性能有这么大的影响?我们来看下面的代码:

var datas = {persons:['李明','小红','赵四','王五','张三','孙行者','马婆子'],gifts:['平民','巫师','狼','猎人','先知']};
function go(){with(datas){var personIndex = 0,giftIndex = 0,i=100000;while(i){personIndex = Math.floor(Math.random()*persons.length);giftIndex = Math.floor(Math.random()*gifts.length)console.log(persons[personIndex] +'得到了新的身份:'+ gifts[giftIndex]);i--;}}
}

上面代码中使用了一个with表达式,为了避免多次从datas中取变量而使用了with语句。这看起来似乎提升了效率,但却产生了一个性能问题:在JavaScript中执行方法时会产生一个执行上下文,这个执行上下文持有该方法作用域链,主要用于标识符解析。当代码流执行到一个with表达式时,运行期上下文的作用域链被临时改变了,一个新的可变对象将被创建,它包含指定对象的所有属性。此对象被插入到作用域链的最前端,意味着现在函数的所有局部变量都被推入第二个作用域链对象中,这样访问datas的属性非常快,但是访问局部变量的速度却变慢了,所以访问代价更高了,如下图所示。

with

这个插件在GitHub上面介绍时,作者Boris Moore着重强调两点设计思路:

  1. 模板缓存,在模板重复使用时,直接使用内存中缓存的模板。在本文作者看来,这是一个鸡肋的功能,在实际使用中,无论是直接写在String中的模板还是从Dom获取的模板都会以变量的形式存放在内存中,变量使用得当,在页面整个生命周期内都能取到这个模板。通过源码分析之后发现jQuery-tmpl的模板缓存并不是对模板编译结果进行缓存,并且会造成多次执行渲染时产生多次编译,再加上代码with性能消耗,严重拖慢整个渲染过程。
  2. 模板标记,可以从缓存模板中取出对应子节点。这是一个不错的设计思路,可以实现数据改变只重新渲染局部界面的功能。但是我觉得:模板将渲染结果交给开发者,并渲染到界面指定位置之后,模板引擎的工作就应该结束了,剩下的对节点操作应该灵活的掌握在开发者手上。

不改变原来设计思路基础之上,尝试对源代码进行性能提升。

先保留提升前性能作为对比:

性能提升

首先来我们做第一次性能提升,移除源码中with语句。

第一次提升后:

性能提升2

接下来第二部提升,落实Boris Moore设计理念中的模板缓存:

性能提升3

优化后的这一部分代码段被我们修改成了:

function buildTmplFn( markup ) {if(!compledStr){// Convert the template into pure JavaScriptcompledStr = jQuery.trim(markup).replace( /([\\'])/g, "\\$1" ).replace( /[\r\t\n]/g, " " ).replace( /\$\{([^\}]*)\}/g, "{{= $1}}" ).replace( /\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,//省略部分模板替换语句}return new Function("jQuery","$item",// Use the variable __ to hold a string array while building the compiled template. (See https://github.com/jquery/jquery-tmpl/issues#issue/10)."var $=jQuery,call,__=[],$data=$item.data;" +// Introduce the data as local variables using with(){}"__.push('" + compledStr +"');return __;")
}

在doT.js源码中没有用到with这类消耗性能的语句,与此同时doT.js选择先将模板编译结果返回给开发者,这样如要重复多次使用同一模板进行渲染便不会反复编译。

仅25行的模板:tmpl

(function(){var cache = {};this.tmpl =  function (str, data){var fn = !/\W/.test(str) ?cache[str] = cache[str] ||tmpl(document.getElementById(str).innerHTML) :new Function("obj","var p=[],print=function(){p.push.apply(p,arguments);};" +"with(obj){p.push('" +str.replace(/[\r\t\n]/g, " ").split("<%").join("\t").replace(/((^|%>)[^\t]*)'/g, "$1\r").replace(/\t=(.*?)%>/g, "',$1,'").split("\t").join("');").split("%>").join("p.push('").split("\r").join("\\'")+ "');}return p.join('');");return data ? fn( data ) : fn;};
})();

阅读这段代码会惊奇的发现,它更像是baiduTemplate精简版。相比baiduTemplate而言,它移除了baiduTemplate的自定义语法标签的功能,使得代码更加精简,也避开了替换用户语法标签而带来的性能消耗。对于doT.js来说,性能问题的关键是with语句。

综合上述我对tmpl的源码进行移除with语句改造:

改造之前性能:

tmpl性能提升

改造之后性能:

tmpl性能提升2

如果读者对性能对比源码比较感兴趣可以访问 https://github.com/chen2009277025/TemplateTest 。

总结

通过对doT.js源码的解读,我们发现:

  1. doT.js的条件判断语法标签不直观。当开发者在使用过程中条件判断嵌套过多时,很难找到对应的结束语法符号,开发者需要自己严格规范代码书写,否则会给开发和维护带来困难。
  2. doT.js限制开发者自定义语法标签,相比较之下baiduTemplate提供可自定义标签的功能,而baiduTemplate的性能瓶颈恰好是提供自定义语法标签的功能。

很多解决我们问题的插件的代码往往简单明了,那些庞大的插件反而存在负面影响或无用功能。技术领域有一个软件设计范式:“约定大于配置”,旨在减少软件开发人员需要做决定的数量,做到简单而又不失灵活。在插件编写过程中开发者应多注意使用场景和性能的有机结合,使用恰当的语法,尽可能减少开发者的配置,不求迎合各个场景。

作者简介

  • 建辉,美团外卖高级前端研发工程师,2015年加入美团外卖事业部。目前在前端业务增长组,主要负责运营平台搭建,主导运营活动业务。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/478347.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

elasticsearch7使用指导

目录结构&#xff1a; 一、es概述 二、es安装/head插件安装/kibana安装 三、es核心概念 四、IK分词器 五、RestFul操作 六、CRUD(增删改查) 七、Springboot集成es ---------------------------------------分割线&#xff1a;正文------------------------------------…

开源开放 | 欢迎选修浙江大学《知识图谱》开放共享慕课

点击“阅读原文”或扫描图中二维码进入课程教学计划第一章知识图谱概论1.1 语言与知识1.2 知识图谱的起源1.3 知识图谱的价值1.4 知识图谱的技术内涵第二章知识图谱的表示2.1 什么是知识表示2.2 人工智能历史发展长河中的知识表示2.3 知识图谱的符号表示方法2.4 知识图谱的向量…

LeetCode 43. 字符串相乘(大数乘法)

文章目录1. 题目2. 小学竖式乘法2.1 普通版2.2 优化版1. 题目 给定两个以字符串形式表示的非负整数 num1 和 num2&#xff0c;返回 num1 和 num2 的乘积&#xff0c;它们的乘积也表示为字符串形式。 示例 1: 输入: num1 "2", num2 "3" 输出: "6&q…

旅游推荐系统的演进

度假业务在整个在线旅游市场中占据着非常重要的位置&#xff0c;如何做好做大这块蛋糕是行业内的焦点。与美食或酒店的用户兴趣点明确&#xff08;比如找某个确定的餐厅或者找某个目的地附近的酒店&#xff09;不同&#xff0c;旅游场景中的用户兴趣点&#xff08;比如周末去哪…

预训练卷不动,可以卷输入预处理啊!

文 | 德志编 | 小戏目前伴随着预训练预言模型的兴起&#xff0c;越来越多的 NLP 任务开始脱离对分词的依赖。通过 Fine-Tune Bert 这类预训练预言模型&#xff0c;能直接在下游任务上取得一个很好的结果。同时也有文章探讨中文分词在神经网络时代的必要性。对于分词任务本身也是…

检索式问答以及评论观点抽取+情感分析

3款开发者神器&#xff0c;快速搭建「检索、问答、情感分析」应用&#xff01; 人工智能与算法学习 于 2021-12-30 08:10:00 发布 26 收藏 文章标签&#xff1a; 百度 大数据 机器学习 人工智能 数据分析 原文链接&#xff1a;https://mp.weixin.qq.com/s?__bizMzIyOTkyOTE…

征稿 | Call for papers on Knowledge Graphs

Knowledge graph是Data Intelligence的核心主题和期刊特色之一。为持续展示这一领域的最新进展和前沿成果&#xff0c;Data Intelligence正在与国际学者一道策划两期Knowledge graph专辑。期待大家关注并积极投稿参与&#xff01;DI专辑Special Issue on Personal Health Knowl…

LeetCode 179. 最大数(自定义谓词函数--Lambda表达式--排序)

1. 题目 给定一组非负整数&#xff0c;重新排列它们的顺序使之组成一个最大的整数。 示例 1: 输入: [10,2] 输出: 210示例 2: 输入: [3,30,34,5,9] 输出: 9534330 说明: 输出结果可能非常大&#xff0c;所以你需要返回一个字符串而不是整数。来源&#xff1a;力扣&#xff08…

python实现requests访问接口,比如es接口

首先我们先引入requests模块 import requests一、发送请求 r requests.get(https://api.github.com/events) # GET请求 r requests.post(http://httpbin.org/post, data {key:value}) # POST请求 r requests.put(http://httpbin.org/put, data {key:value}) # PUT请求 r…

拿下字节offer,这些面试题命中率高达90%以上

昨天在知乎上刷到一个热门问题:程序员需要达到什么水平才能顺利拿到 20k 无压力&#xff1f;其中一个最热门的回答是&#xff1a;“其实&#xff0c;无论你是前端还是后端、想进大厂还是拿高薪&#xff0c;算法都一定很重要。”为什么&#xff0c;算法会如此重要&#xff1f;不…

HDFS NameNode重启优化

本文已发表于InfoQ&#xff0c;下面的版本又经过少量修订。 一、背景 在Hadoop集群整个生命周期里&#xff0c;由于调整参数、Patch、升级等多种场景需要频繁操作NameNode重启&#xff0c;不论采用何种架构&#xff0c;重启期间集群整体存在可用性和可靠性的风险&#xff0c;所…

LeetCode 4. 寻找两个有序数组的中位数(二分查找,难)

文章目录1. 题目2. 解题2.1 合并数组2.2 优化2.1解法&#xff0c;双指针2.3 二分法&#xff08;找第k个数&#xff09;2.4 切分法1. 题目 给定两个大小为 m 和 n 的有序数组 nums1 和 nums2。 请你找出这两个有序数组的中位数&#xff0c;并且要求算法的时间复杂度为O(log(mn…

论文浅尝 | 当Hearst还不够时:用分布模型来提升语料库中的上下义关系检测

笔记整理 | 潘晓梅&#xff0c;东南大学硕士&#xff0c;研究方向为知识图谱构建、自然语言处理。来源&#xff1a;EMNLP 2020.论文下载地址&#xff1a; https://www.aclweb.org/anthology/2020.emnlp-main.502.pdf项目源码地址&#xff1a; https://github.com/ccclyu/ComHyp…

LeetCode 887. 鸡蛋掉落(DP,难、不懂)

1. 题目 你将获得 K 个鸡蛋&#xff0c;并可以使用一栋从 1 到 N 共有 N 层楼的建筑。 每个蛋的功能都是一样的&#xff0c;如果一个蛋碎了&#xff0c;你就不能再把它掉下去。 你知道存在楼层 F &#xff0c;满足 0 < F < N 任何从高于 F 的楼层落下的鸡蛋都会碎&…

正确的LeetCode刷题姿势!

名师 带你刷爆LeetCode算法知识 讲解训练免费0元报名参加在讲到 AI 算法工程师时&#xff0c;大部分同学关注点都在高大上的模型&#xff0c;一线优秀的项目。但大家往往忽略了一点&#xff0c;人工智能的模型、项目最终还是要靠程序和算法实现。算法能力是每一个程序员的基本功…

论文浅尝 | DI刊发的那些有关Knowledge Graph的论文

本文转载自公众号&#xff1a;数据智能英文刊知识图谱被称为人工智能的基石&#xff0c;它的前身是语义网&#xff0c;由谷歌在2012年率先提出&#xff0c;用于改善自身的搜索业务。Data Intelligence执行主编、东南大学计算机科学与技术学院漆桂林教授这样定义知识图谱&#x…

缓存那些事

本文已发表于《程序员》杂志2017年第3期&#xff0c;下面的版本又经过进一步的修订。 一般而言&#xff0c;现在互联网应用&#xff08;网站或App&#xff09;的整体流程&#xff0c;可以概括如图1所示&#xff0c;用户请求从界面&#xff08;浏览器或App界面&#xff09;到网络…

LeetCode 42. 接雨水(双指针、单调栈)

文章目录1. 题目2. 解题2.1 正反扫描法2.2 双指针2.3 单调栈1. 题目 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图&#xff0c;计算按此排列的柱子&#xff0c;下雨之后能接多少雨水。 上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图&#xff0c;在这种情况下&am…

论文浅尝 - IJCAI | Knowledge is NOT always you need: 外部知识注入预训练模型的利与弊...

转载公众号 | 浙大KG论文题目&#xff1a;Drop Redundant, Shrink Irrelevant: Selective Knowledge Injection for Language Model Pretraining本文作者&#xff1a;张宁豫&#xff08;浙江大学&#xff09;、邓淑敏&#xff08;浙江大学&#xff09;、张亦弛&#xff08;阿里…

圆形的CNN卷积核?华中大清华康奈尔提出圆形卷积,进一步提升卷积结构性能!...

文 | 小马编 | 极市平台写在前面目前正常卷积的感受野大多都是一个矩形的&#xff0c;因为矩形更有利于储存和计算数据的方便。但是&#xff0c;人类视觉系统的感受野更像是一个圆形的。因此&#xff0c;作者就提出&#xff0c;能不能将CNN卷积核的感受野也变成圆形呢&#xff…