从零实现迷你模板引擎:插值、循环、条件与编译原理 1. 模板的本质从重复写页面到只写一次数据说了算先说个场景。你有没有过这种经历网页上需要一个商品列表产品数据在后台数组里你把那段div classcard结构复制了八遍然后手动把每一个商品名字、价格填进去。数据一变又得回头改HTML。这还不算最痛苦的最痛苦的是你把商品数量从八个加到二十个复制粘贴到怀疑人生。模板技术解决的就是这个事。它的核心作用只有一句话把数据和页面结构分家让HTML结构只写一次数据变了自动套用结构重新生成页面。你可以把模板理解成一个带空格的印刷厂空格的形状和排版是模板定的具体印什么字由数据决定。同样的外观换一批数据出来的就是新页面。这篇内容面向刚接触模板概念、或者已经写过一些动态页面但全是靠字符串拼HTML、感觉维护起来很费劲的开发者。我会用一个自己写过的模拟项目来拆解不依赖大型框架用一个几十行代码的迷你模板引擎把模板语法、编译原理、实际落地三个环节全部走通。看完你不仅能理解{{ }}这种写法背后发生了什么还能在自己项目里直接上手。先说清楚初阶这两个字的边界。我强调的是能用、够用、能理解原理不是工程级别的完整方案。真正生产级的模板引擎会考虑沙箱隔离、语法错误提示、性能优化、预编译等等这些我会在每节提到什么时候该升级但在初阶阶段把渲染链路吃透比引入一堆依赖更重要。2. 为什么不用字符串拼接三个你早晚会踩的坑2.1 字符串拼接的维护成本会随时间爆炸很多人在接触模板之前都是用加号拼HTML。比如const list [ { name: 机械键盘, price: 399 }, { name: 显示器, price: 1299 } ]; let html ; for (const item of list) { html div classcard h3 item.name /h3 p价格 item.price 元/p /div; } document.getElementById(app).innerHTML html;这个写法在小数据、单页面上完全没问题。但等你加了if判断、嵌套循环、图片懒加载、需要处理空状态……这段html 会越滚越长。你会在表达式里同时处理逻辑判断和字符串拼接一旦某个字段是null页面上直接出现一个undefined或者null字样的尴尬状况排查半天才发现是数据缺字段。2.2 字符串拼接的XSS隐患是最容易被忽略的字符串拼接最大的问题不是丑是不安全。如果item.name来自用户提交的内容比如用户在评论区填了一句话img srcx onerroralert(1)你拼进HTML后浏览器会把它当成标签解析执行。这就是经典的存储型XSS攻击路径。模板引擎通常会内置转义机制。默认把、、、、这些字符替换成HTML实体让用户内容以纯文本形式显示而不是被当作HTML解析。这个机制在初学阶段特别容易被忽略等到出了问题再补就很被动了。2.3 逻辑和视图混在一起改样式要动逻辑代码字符串拼接把页面结构和业务逻辑揉在一坨里。设计稿改了你得在一堆加号和小括号里找那个标签逻辑改了又得顺着字符串结构往下翻。对于一个超过几百行的模块这种代码改起来极其痛苦。模板的核心思路就是把结构交还给HTML把数据交还给对象。模板里尽量只写结构和对数据的简单控制循环、条件、显示复杂的计算放到数据准备阶段完成。这样前端同事改样式就在模板区改数据相关的同事改逻辑就在脚本区改互不干扰。3. 模板的三个核心语法单元插值、循环、条件3.1 插值把数据填进结构里模板最基本的单元是插值。我在自己实现的迷你引擎里用的是{{ 表达式 }}这个写法它由双花括号和里面的表达式组成h3{{ product.name }}/h3 p价格{{ product.price }}元/p双花括号的含义是计算括号里的表达式把结果转成字符串然后转义后输出到对应的位置。转义这一步就是前面说的防XSS关键。表达式可以是简单的属性访问也可以是三元运算、方法调用p折扣价{{ product.discount ? product.discount : product.price }}元/p p库存状态{{ product.stock 0 ? 有货 : 缺货 }}/p表达式的能力往哪边延伸取决于模板引擎的设计。初阶阶段不用整得太复杂能支持属性访问、三元表达式、简单方法调用就够用了。3.2 循环重复结构只写一次循环解决了开篇那个复制八遍商品卡片的问题。我的迷你引擎里用{{#each 列表变量}} ... {{/each}}来表示一段需要重复输出的区块{{#each products}} div classcard h3{{ name }}/h3 p价格{{ price }}元/p /div {{/each}}{{#each products}}的意思是对products数组里的每一项执行内部这段模板。内部模板能直接访问当前项的数据字段例如{{ name }}就是当前商品的name。为了在循环里拿到当前项的序号可以给模板加一个特殊变量index{{#each products}} div classrow span{{ index }}/span h3{{ name }}/h3 /div {{/each}}index从0开始计数这个在列表展示序号、交替行样式、给DOM节点设置key的时候非常有用。3.3 条件按数据状态决定显示内容条件指令{{#if 表达式}} ... {{/if}}控制某段内容在什么情况下渲染什么情况下不渲染{{#if product.stock 0}} button立即购买/button {{else}} button disabled暂时缺货/button {{/if}}我需要在这里说明一下{{else}}并不是所有模板引擎的默认语法我为了贴近实际项目使用习惯在迷你引擎里做了支持。它的实现原理其实很简单编译阶段看到{{else}}时把当前if块的条件取反再开一个新块。条件指令的实际场景非常多列表为空显示空状态提示、商品有折扣显示促销标签、用户已登录显示个人中心、文章有封面图才显示图片区。你很快会发现模板的骨架就是循环条件两个指令搭起来的插值只是往骨架里填肉。3.4 初阶模板应该避免的写法以我实际写模板的经验初阶最容易犯的错是想在模板里写太多逻辑。比如在模板里做复杂的金额计算、日期格式化、数组过滤。这些操作会让模板越来越难读而且每次渲染都要重新算一遍。正确的做法是把这些计算提前到数据准备阶段。在后端返回原始数据后先做一层视图模型转换把渲染需要的内容都算好再交给模板。模板里只做简单的属性读取和状态判断。这样做还有一个额外的好处模板的单元测试变简单了喂什么数据结构出什么HTML一目了然。4. 从零开始写一个迷你模板引擎编译链路全拆解4.1 核心设计思路模板字符串编译成渲染函数这里的关键点在于编译。模板引擎拿到模板字符串后不会直接去操作DOM而是先把模板字符串解析成一段JavaScript代码然后用new Function生成一个渲染函数。这个渲染函数接收数据对象返回最终的HTML字符串。这个过程可以类比成做菜模板是菜谱数据是食材编译环节是把菜谱翻译成厨师能直接执行的步骤清单渲染函数就是厨师。每次数据变化只要把新食材给厨师颠两下勺就出菜不用重新读菜谱。我最初学模板原理时也觉得编译这个词特别高大上但拆开来看就三个步骤扫描模板字符串按特殊标记{{ }}、{{#each}}、{{#if}}切分成普通文本块和表达式块。根据切分结果拼接一段JavaScript代码用html 文本收集文本用html 表达式收集计算结果。用new Function创建函数对象返回给调用方。4.2 第一步实现插值语法先写最基础的插值解析。我用一个正则来匹配所有{{ 表达式 }}片段然后用String.replace把每个匹配到的表达式替换成对应的html 表达式代码。但这里有个细节正则匹配到的是模板字符串的子串我需要把表达式前后的普通文本也保留出来。一个更清晰的实现方式是扫描-推进法。用一个cursor指针从头往后扫每次匹配到一个{{就把cursor到{{之前的文本作为普通文本收集起来把{{和}}之间的内容作为表达式处理然后把cursor推到}}之后继续扫。直到模板字符串结束。function compile(template) { let body let html ;\n; let cursor 0; const pattern /{{\s*([\s\S]*?)\s*}}/g; let match; while ((match pattern.exec(template)) ! null) { body html JSON.stringify(template.slice(cursor, match.index)) ;\n; body html escapeHtml(String( match[1] ));\n; cursor match.index match[0].length; } body html JSON.stringify(template.slice(cursor)) ;\n; body return html;; const render new Function(data, escapeHtml, with(data) {\n body \n}); return render; }这里用了with(data)把数据对象的属性直接暴露给作用域内的代码这样模板里写{{ name }}就能直接访问到data.name。with在严格模式下是禁用的而且对性能有一定影响但作为教学演示非常直观。工程级模板引擎会用with之外的方式管理作用域这个差异我会在后面升级信号一节提到。配套的escapeHtml函数长这样function escapeHtml(str) { const map { : amp;, : lt;, : gt;, : quot;, : #39; }; return str.replace(/[]/g, (ch) map[ch]); }4.3 第二步加入each循环和if条件在编译过程中识别指令和识别插值的逻辑不一样。插值只需要计算表达式指令则需要改变代码结构——循环要生成for语句条件要生成if语句结束标记要闭合代码块。我把指令的匹配放在插值匹配的同一个循环里。匹配到{{#each products}}时向生成的代码里插入一段for...of循环if (expr.startsWith(#)) { const tag expr.slice(1).trim(); const parts tag.split(/\s/); const type parts[0]; if (type each) { const listExpr parts[1]; body for (const [index, item] of ( listExpr ).entries()) {\n; body with(item) {\n; } else if (type if) { const condition tag.slice(3).trim(); body if ( condition ) {\n; } else if (type /) { body }\n; } continue; }这里的关键设计是each块内部用with(item)把当前循环项设为上下文这样模板里写{{ name }}访问的就是当前商品的字段而不是外部数据对象的name。index变量名在读代码时会被识别为index但JavaScript变量名不能带所以我在生成代码时把index替换成index并在循环参数里声明。if指令的实现更直接{{#if condition}}对应if (condition) {{{else}}对应} else {{{/if}}对应}。由于生成代码用了大量的字符串拼接缩进和可读性会很差所以调试时需要借助Node的util.inspect或者直接在浏览器里打印生成的函数体我习惯在compile里加一个调试开关把body内容打印出来看。4.4 完整代码一个可用但极简的模板引擎把上面逻辑整合起来加上{{else}}支持和缺失字段的兜底处理就会得到一个能跑通的迷你模板引擎。实际代码我放在下面但需要说明这个版本的定位是教学演示不推荐直接用于生产环境function compile(template) { let body let html ;\n; let cursor 0; const pattern /{{\s*([\s\S]*?)\s*}}/g; let match; while ((match pattern.exec(template)) ! null) { body html JSON.stringify(template.slice(cursor, match.index)) ;\n; const rawExpr match[1].trim(); if (rawExpr.startsWith(#)) { const inner rawExpr.slice(1).trim(); if (inner else) { body } else {\n; } else if (inner.startsWith(each)) { const listExpr inner.slice(5).trim(); body for (const [index, item] of ( listExpr || []).entries()) {\n; body with(item) {\n; } else if (inner.startsWith(if)) { const condExpr inner.slice(3).trim(); body if ( condExpr ) {\n; } else if (inner.startsWith(/)) { body }\n; } } else { body html escapeHtml(String( rawExpr ?? ));\n; } cursor match.index match[0].length; } body html JSON.stringify(template.slice(cursor)) ;\n; body return html;; const render new Function(data, escapeHtml, with(data) {\n body \n}); return render; }我把template和data分开模板是静态结构数据是运行时变化的部分。每次渲染传入不同的数据同一个模板就能输出不同的页面。这个思路和React的数据驱动视图、Vue的模板编译其实一脉相承——区别在于它们把模板编译成了虚拟DOM的创建函数而不是HTML字符串。但底层原理是相似的理解了这个迷你引擎你再去翻框架的编译产物会亲切很多。5. 把它用起来一个商品列表页从数据到渲染的完整落地5.1 场景设定和数据结构我用一个商品展示页来演示。后端接口返回的商品数据长这样const products [ { name: 机械键盘, price: 399, stock: 15, discount: 359, tags: [热销, 机械] }, { name: 显示器, price: 1299, stock: 0, discount: null, tags: [4K] }, { name: 鼠标, price: 199, stock: 30, discount: null, tags: [无线] } ];需求有三个列表展示商品卡片、缺货商品stock为0显示缺货标志且按钮置灰、有折扣的商品显示折扣价和原价划线。5.2 编写模板按需求拆分模板div classproduct-list {{#each products}} div classcard h3{{ name }} span classbadge#{{ index }}/span/h3 div classtags {{#each tags}} span classtag{{ this }}/span {{/each}} /div p classprice {{#if discount}} s{{ price }}元/s strong{{ discount }}元/strong {{else}} strong{{ price }}元/strong {{/if}} /p {{#if stock 0}} button立即购买/button {{else}} button disabled暂时缺货/button {{/if}} /div {{/each}} {{#if products.length 0}} p暂时没有商品去看看别的吧。/p {{/if}} /div这里有两层each嵌套外层遍历商品内层遍历商品标签。内层输出的是字符串数组里的每一项所以用的是{{ this }}this在当前作用域下指向数组当前项。模板的最外层加了一个空数据判断当商品列表为空时显示一句提示。这里能看出if和each可以配合工作中的真实场景很多页面在数据加载中、加载成功但为空、加载失败这三种状态下要显示完全不同的内容考的就是你对循环和条件的组合能力。5.3 渲染函数和挂载流程模板写好后调用编译函数生成渲染函数再把数据传进去得到HTML字符串最后通过innerHTML挂载到页面上const templateStr document.getElementById(product-tpl).innerHTML; const render compile(templateStr); function renderProducts(data) { const container document.getElementById(app); container.innerHTML render({ products: data }); } renderProducts(products);我习惯把模板字符串放在script typetext/template标签里而不是直接写在JavaScript字符串里。这样模板可以保持HTML的缩进和高亮写起来不容易错也方便维护。然后是局部更新。点击只看有货按钮时不需要重新请求后端直接把本地数据过滤一遍再传进渲染函数document.getElementById(filter-btn).addEventListener(click, () { const available products.filter(p p.stock 0); renderProducts(available); });这个流程体现了模板引擎的核心价值数据变了调用同一个渲染函数重跑一遍页面就跟着变了。不需要手动操作DOM去改每一个商品的价格、按钮状态。除非你的数据量特别大否则这种全量重渲染的简单粗暴方案在初阶阶段反而是最好维护的。5.4 性能观察什么时候要开始担心我实际测过自己写的迷你引擎渲染100条商品数据从调用渲染函数到DOM节点挂载完成在普通电脑上大概几毫秒到十几毫秒。这个量级对绝大多数非高频交互页面完全够用。真正需要担心的不是渲染本身而是渲染频率。如果你的页面有大量实时更新的数据比如股票行情、聊天消息、多人协作的光标位置每秒钟可能要触发几十次甚至上百次重渲染全量重渲染的浪费就很明显了。这时候就该考虑局部更新策略或虚拟DOM方案。初阶判断标准很简单用setInterval或者requestAnimationFrame实测一下在最大数据量下连续触发10次渲染如果单次超过50毫秒并且用户能感知卡顿再考虑优化。大多数业务页面根本到不了这个瓶颈。6. 实操排坑模板开发里最常遇到的五个问题6.1 页面出现undefined和null字样这个问题我遇到太多次了。原因是数据某个字段不存在模板直接输出String(undefined)。解决思路有两个层面。数据层面在准备数据时给字段设置默认值。模板层面代码里我用?? 做了兜底把undefined和null转为空字符串。这里有一件事必须说清楚??运算符只兜底null和undefined不会兜底0和空字符串。所以价格字段如果是合法的0不会被吞掉这个行为是对的但如果你自己写||去兜底价格0就会被错误替换成默认值这是一个很经典的低级错误。6.2 each循环里拿不到外层数据嵌套循环时内层with(item)的作用域会遮蔽外层数据。我在迷你引擎里没有内置外层访问方案实际写模板时会遇到循环里想用外层商品的其他字段但读出来是undefined的困惑。最稳妥的解决办法是在数据准备阶段解决把内层循环需要的数据直接作为当前项的一个属性传进去。比如在渲染前把products转换一下给每个商品加一个tagText字段模板里直接输出{{ tagText }}就不用嵌套循环了。宁可多准备数据也不要让模板里做复杂的作用域跳转。6.3 HTML标签嵌套花括号导致正则错乱模板里如果写了类似stylewidth: {{ width }}px这种写法完全正常我的正则能匹配{{ width }}。但如果你在JavaScript代码或者内联事件里写花括号比如onclickif ({{ isAdmin }}) {...}正则很容易匹配错位置或者把}}和HTML属性里的}搞混。我的经验是模板里避免写复杂的内联事件逻辑。事件绑定应该用脚本注册方式模板里只做数据输出。这样既能减少正则匹配出错的可能性也让模板本身更干净。6.4 忘记闭合each或if块编译阶段如果遇到{{#each}}开始但没有对应的{{/each}}生成的代码会少一个右花括号运行时报SyntaxError。初学阶段调试这种错误很痛苦因为报错位置指向的是new Function那行根本看不出哪里出了问题。我自己用着顺手的排查办法给编译函数加一个debug模式把生成的body字符串用console.log打出来然后去数if和else的配对。熟练之后一眼就能看出少了个}。更严谨的做法是在编译时维护一个指令栈遇到开始指令时压栈遇到结束指令时弹栈全部扫描完如果栈不为空就抛出错误说明哪个指令没有闭合。6.5 模板里输出用户内容时被浏览器执行关于转义我再强调一遍任何用户产生的数据输出到模板里都要走转义。我的迷你引擎默认对{{ }}做转义如果你想故意输出HTML比如富文本内容需要显式用{{{ }}}三花括号语法跳过转义。如果你不确定一段内容该不该转义那就默认转义。被转义后最多是显示成纯文本不太美观但没转义而出了问题轻则页面错乱重则产生安全漏洞。7. 模板的进阶方向初阶之后的升级信号从迷你模板引擎出发往工程化方向走有几条明确的升级路径。第一作用域管理。with语句虽然好用但在严格模式下被禁用而且作用域链查找有性能损耗。工程级模板引擎会自己维护变量作用域用编译期符号表去解析变量名而不是运行时靠JavaScript的动态作用域。第二模板预编译。把模板字符串在构建阶段就编译成可执行的JavaScript函数运行时直接调用省去编译时间。打包工具里最常见的做法就是.vue文件的模板编译、React的JSX编译它们都在构建期把模板转成了JavaScript函数。第三语法扩展。工程模板引擎会提供自定义helper函数日期格式化、金额格式化、截断文本、URL编码。模板里调用helper处理数据逻辑更清晰。我自己的迷你引擎里没有实现helper注册机制但真到了需要的复杂度这就是最明显的扩展点。第四数据驱动与局部更新。模板引擎解决的是一次渲染数据变化后全量重跑更高级的方案会监听数据变化只更新DOM中受影响的部分。Vue的响应式系统、React的虚拟DOM diff都是在这个方向上的深度演进。了解这些升级路径不是为了让你立刻全部用到而是让你在初阶阶段就意识到你现在写的模板代码和大型框架里的模板编译底层是同一条思路。学懂基础之后再去看框架源码或者面试中遇到相关原理题都会有原来如此的通透感。8. 写在最后的经验之谈这几个月的实践中我自己最深的体会是模板技术入门不难难的是改掉手动操作DOM的惯性。刚学会innerHTML直接输出渲染结果时确实方便但数据一变我又下意识去document.querySelector找元素、改textContent回头发现完全没必要——重新渲染一遍就好。后来我强迫自己所有数据变化都走改数据-调渲染函数这条路代码简洁度立刻上了一个台阶。另一个体会是模板里少做逻辑判断多让数据说话。宁可准备数据时多写几行也不要让模板里出现超过两层的三元表达式。模板是用来描述结构的不是用来算逻辑的这个边界守住模板代码的可读性就会有质的提升。如果你现在正被复制HTML塞数据的重复劳动折磨或者写了一段动态页面但总感觉哪里别扭不妨从今天开始用几十行代码给自己写一个迷你模板引擎试一试。不用急着上框架先把{{ }}背后这条链路走通你会发现动态渲染这件事真的没那么玄。