那会儿接到一个需求,用户上传Excel,在后端转成几百条数据,前端要根据这些数据渲染出一个超长的列表。因为后台业务就我一个人维护,自然没打算把Vue或者React塞进去。以前我最爱用字符串拼接,把一条一条商品拼成一个大字符串,最后一插进一个div里。刚开始还行,等数据超过一千条,页面明显感觉卡顿,滚动一下能掉好几帧。而且最难受的是,一不小心数据里有一个`<script>`标签,就直接噩梦。
后来我打算学学别人怎么处理。有人给我推荐了“。他原话是:这玩意就像一个“看不见的土模子”,你需要多少份,就复制多少次,比用字符串快得多。我半信半疑,试完以后发现真的不一样。它不是快那么一点点,而是内存增长都很平滑。
Template到底是什么鬼
HTML里的“标签,你放在页面里,浏览器不会渲染它。不管你写多少内容,用户都看不见,它就像一块不会动的地基。只有通过JavaScript去提取它的内容,然后克隆,在需要的地方插进去才会显示。
有人可能觉得这和`display:none`没啥区别。但有本质不同:display:none里的元素仍然会被浏览器解析,而且它内部的图片、样式都会加载;但`template`里面的东西根本不会被当成页面区域来处理,它更像是一段模板源文本。
举个例子:
<template id="cardTemplate">
<div class="card">
<h3 class="title"></h3>
<p class="desc"></p>
</div>
</template>
你在页面上看不到那张卡,但模板里的内容已经存在于一个特殊的`DocumentFragment`(文档片段)之中。当我们需要时,把它拿出来克隆一份,填充数据,再放进去,它就和普通DOM一样了。
纯字符串拼接到底慢在哪里
字符串拼接并非慢在连接字符串上,而是最后你用`innerHTML`赋给元素。浏览器要把这一整段HTML源码重新解析一遍,生成对应的节点树,然后再覆盖原来的子节点。数据量一大,字符串解析、节点创建,全部挤在一块,主线程就被卡住了。
而且如果将后端传过来的数据直接拼进HTML,只要有一条里面带了`<img onerror>`,XSS漏洞就开了。你需要转义特殊字符,这又得写函数,又要小心翼翼。我用模板之后呢?只要不去直接给响应式元素塞HTML,我们自然可以避开一堆坑。
动手做一个真正的列表渲染
假设后端返回了一串用户数据:
const remoteData = [
{ name: '张小凡', skill: '烧火棍法', level: 10 },
{ name: '陆雪琪', skill: '神剑御雷真诀', level: 11 },
// 别嫌少,实际可能有一两千条
];
第一步,定义模板结构。注意模板里没有任何跟某个具体用户相关的文字,只留下等待填充的容器。
<template id="userCardTemplate">
<div class="user-row">
<span class="user-name"></span>
<span class="user-skill"></span>
<span class="user-level"></span>
</div>
</template>
第二步,在JS里获取template的content,然后克隆出一个真实节点:
const template = document.getElementById('userCardTemplate');
const newContent = template.content.cloneNode(true);
克隆出来的newContent是一个文档片段,它不是活的元素。你可以对它进行常规DOM操作,比如通过querySelector找到里面的span,并填充文本。这些修改不会触发全局重排,因为片段还没插入文档。
接下来把所有克隆好的片段收集起来,一次性的插入父容器里:
const container = document.getElementById('listContainer');
const fragment = document.createDocumentFragment(); // 使用另一个片段来暂存所有新卡片
remoteData.forEach(user => {
const card = template.content.cloneNode(true);
card.querySelector('.user-name').textContent = user.name;
card.querySelector('.user-skill').textContent = user.skill;
card.querySelector('.user-level').textContent = 'Lv.' + user.level;
fragment.appendChild(card);
});
container.appendChild(fragment);
这里的代码跟之前用字符串拼接直观多了:模板里看到的class名,和最后生成的DOM结构完全一致。不用在头晕的双引号地狱里找错误。
性能为什么反而好了
因为模板中的节点早就在解析HTML时被生成过一次,存在template内部。我们需要克隆的是真正的节点树,而不是源码字符串再重新解析。浏览器做克隆是内存级的操作,比解析html源码快得多。同时大量新节点先放在一个独立fragment中,直到最后一次性插入DOM。这个过程中不会触发无数次页面回流。
我拿一千五百条数据试过,首屏渲染在普通笔记本上大约只有几十毫秒到一百毫秒左右,而且是在没有进行虚拟滚动的情况下。如果换成字符串拼接,整个页面可能要白屏好几秒。这差距不止一点点。
顺手还治好了我的XSS焦虑
以前拼接的时候每一条字段都要用安全函数把<和>替换成实体。用了模板就不一样了,咱们用`textContent`赋值,浏览器会直接把它当作纯文本处理。哪怕用户提交了一条恶意文本<img src=x onerror=alert(1)>,它也会原样显示成文字,绝不会被解析成HTML。就这一条,我晚上睡觉都踏实了不少。
如果你偏要在模板里通过`innerHTML`填数据,那还是有风险,但那是自找的。好的习惯是:像标题、内容这种来自外部数据,一律用textContent。
模板不止用于列表
后来我还用它来做弹窗里的表单片段、右键菜单、甚至复杂表格的表头。因为模板可以定义任意的结构,你可以把它看成一段可以无限复制的“组件骨架”。如果你需要根据后台数据动态生成几个配置项,那么模板的循环、赋值会变得很清晰。
神奇的是,你可以把模板放在页面任何位置,不一定放body的最后。但按惯例还是把它写在需要的容器旁边,用id标记。这样团队的其他人一看就知道哪个模板对应哪个模块。
进一步改进:利用模板配合事件委托
列表渲染出来后,可能线上每个卡片上都有一个“点击删除”按钮。你不需要给每个按钮单独绑定事件,只要在父容器上监听一次click。在事件回调里,通过event.target判断是否点击到指定class,再把当前行的数据取出来。
container.addEventListener('click', function(event) {
if (event.target.classList.contains('delete-btn')) {
const row = event.target.closest('.user-row');
const userName = row.querySelector('.user-name').textContent;
// 执行删除
row.remove();
}
});
这不算什么新知识,但和模板配合起来,整个代码逻辑很顺:模板只管长得像什么样,事件管理独立于模板在父层做委托。不需要像某些框架那样搞出一套虚拟DOM再diff,一样能达到期望的交互效果。
一个容易被忽略的坑
需要特别提醒:很多人喜欢把模板的内容写在“只后,然后想在JS里用`innerHTML`来更新模板内部,他们说模板里的元素在DOM中不存在,导致访问不到。其实不是不存在,而是你需要先把它克隆出来。注意是`cloneNode(true)`,如果参数不是true,只会复制模板本身,里面的内容不会复制。
还有一个点:如果你在一个动态表格中要给每个卡片添加不同的id,或者设置不同dataset属性。要在填充文本时一起设置:
card.dataset.userId = item.id;
不要想着等插入后再逐个去更新,那时会导致额外重排,但也没多大问题。
还有没有更强的用法
你知道吗,这种模式被现代框架变成了一种“内部魔法”。Vue的模板编译后其实会生成类似的render函数去创建vnode,但最终操作DOM时不还是用原生方法?我们没有框架,自己用“来处理纯前端渲染,其实就是自己实现了轻量级的“组件片段管理”。
如果再用上Shadow DOM,每个模板克隆出来的部分还能附带独立的样式作用域,但那就扯得更远了。单单是配合“,就已经能为老项目减少80%的DOM操作代码。
直接手写一个完整demo
最后展示一个可直接运行的代码片段,一个简单的表格渲染。这个demo只用了HTML和极简的JavaScript,没有任何库。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>template基础demo</title>
</head>
<body>
<template id="listItemTpl">
<tr>
<td class="item-name"></td>
<td class="item-price"></td>
<td><button class="deleteBtn">删除</button></td>
</tr>
</template>
<table>
<thead>
<tr>
<th>名称</th>
<th>价格</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<script>
const products = [
{ name: '键盘', price: '¥199' },
{ name: '鼠标', price: '¥99' },
{ name: '显示器', price: '¥1299' },
];
const tbody = document.getElementById('tableBody');
const tpl = document.getElementById('listItemTpl');
const fragment = document.createDocumentFragment();
products.forEach((product, index) => {
const row = tpl.content.cloneNode(true);
row.querySelector('.item-name').textContent = product.name;
row.querySelector('.item-price').textContent = product.price;
const deleteBtn = row.querySelector('.deleteBtn');
deleteBtn.addEventListener('click', function handler() {
tbody.removeChild(row);
});
fragment.appendChild(row);
});
tbody.appendChild(fragment);
</script>
</body>
</html>
实际运行你会发现和普通构造的DOM没区别,但它的语义很清晰——模板负责布局,JS只负责填数据和处理事件。
最后
HTML里一直有一堆我们没好好珍惜的标签,`template`就是其中一个。它可能没有popover那么扎眼,也没有dialog那么功能明确,但它在构建大量重复DOM时确实是一把好手。如果你也想在不用框架的情况下解决复杂列表渲染,我建议你试试它。
我现在已经彻底放弃字符串拼接做界面了。只要遇到重复的视图单元,第一反应就是写个template。除了干净安全,更重要的是省下来的时间足以让我多刷几个论坛。

