打字机效果(Typewriter)在落地页标题、企业官网首页的文案展示、命令行风格的终端演示中出镜率非常高。它的实现原理并不复杂:把一段文字按字符依次插入页面,配合一个闪烁的光标,就能模拟出人在敲键盘的过程。真正麻烦的地方在于,要输出的内容往往不只是纯文本,而是带着加粗、强调甚至换行标签的富文本。如果直接用substring截取HTML字符串再塞进容器,标签会被拦腰截断,页面直接报错或者渲染出混乱的结构。本文就来解决这个问题,给出一份可直接落地、支持HTML标签整块输出的jQuery打字机实现。

一、为什么直接截取HTML字符串会出问题
不少人在第一版实现里会写出类似这样的代码:每隔一段时间,用substring(0, i)截取原始HTML,然后html()写回容器。如果内容是纯文本,这样做完全没问题,因为每一个字符都是独立可显示的。但一旦内容里出现<strong>这类标签,截断点落在标签中间时,写入的就是类似<str这样的残缺片段。
浏览器在解析残缺HTML时会尽力容错,结果就是标签被当作纯文本显示出来,或者闭合标签丢失导致后续样式全部错乱。比如输出到一半时页面上突然出现一串ong>的文字,视觉效果非常糟糕。而且这种错误是随机的,取决于光标速度和标签位置的相对关系,调试起来很折磨人。
另一个容易被忽视的问题是事件绑定。如果打字内容里包含按钮或链接,并且这些元素上挂着事件,每次html()整体重写都会销毁并重建DOM节点,之前绑定的事件会全部失效。所以正确的思路应该是:不要重写整个容器,而是只追加增量内容,并且把HTML标签当作一个不可拆分的整体来处理。
二、核心思路:按节点拆分,标签整块输出
解决思路是把原始HTML字符串交给浏览器解析成一个临时的节点列表,然后遍历这个列表。遇到文本节点,就把文本再按字符拆开,一个一个追加;遇到元素节点,就把它整个克隆并一次性插入,插入后递归处理它的子节点。这样标签永远以完整形态出现在DOM中,文本部分则保持逐字的打字节奏。
不过递归处理子节点会让代码变复杂,这里介绍一种更轻量的等价方案:先把整段HTML解析成节点序列,再把每个文本节点拆成单字符,同时保留元素节点原样,最终得到一个扁平的输出队列。输出队列里的每一项,要么是一个字符,要么是一个完整的标签节点。之后只需要一个定时器从队列头部逐项取出并追加即可。
先看解析拆分部分的实现:
/**
* 把包含HTML标签的字符串拆成输出队列
* 文本拆成单字符,标签作为整体保留
*/
function buildQueue(html) {
var queue = [];
// 借助一个临时div解析HTML字符串
var holder = $('<div>').html(html);
var nodes = holder[0].childNodes;
// 用队列遍历,顺便处理嵌套标签内部的文本
var pending = Array.prototype.slice.call(nodes);
while (pending.length) {
var node = pending.shift();
if (node.nodeType === 3) {
// 文本节点:拆成单字符依次入队
var text = node.nodeValue;
for (var i = 0; i < text.length; i++) {
queue.push(document.createTextNode(text.charAt(i)));
}
} else {
// 元素节点:整块入队,子节点继续展开处理
queue.push(node.cloneNode(false)); // 先入队标签本身(不含子节点)
var children = Array.prototype.slice.call(node.childNodes);
// 子节点插到当前标签之后,顺序用临时标记记录
queue.push({ openTag: node, children: children });
}
}
return queue;
}上面的写法把开标签、子内容、闭标签的顺序问题留了一个尾巴,实际落地时更推荐用下面这个改良版:直接把每个元素节点连同内部文本一起处理,内部文本同样拆字符,闭标签用一个占位标记表示,输出到该标记时给对应的已打开元素追加后续字符即可。为了代码简洁,下面给出一个完整可运行的实现,它采用游标递归的方式,逻辑更直观:
(function ($) {
$.fn.typewriter = function (options) {
var settings = $.extend({
speed: 80, // 每个字符的间隔毫秒数
cursor: '|' // 光标字符
}, options);
this.each(function () {
var $this = $(this);
var html = $this.html();
$this.empty();
// 闪烁光标
var $cursor = $('<span class="tw-cursor">')
.text(settings.cursor)
.appendTo($this);
var index = 0;
// 每次输出一个最小单元:一个字符或一个完整标签
function step() {
if (index >= html.length) {
$cursor.trigger('done');
return;
}
var ch = html.charAt(index);
if (ch === '<') {
// 遇到标签:找到结束尖括号,整块输出
var end = html.indexOf('>', index);
var tagStr = html.substring(index, end + 1);
$(tagStr).insertBefore($cursor);
index = end + 1;
} else {
document.createTextNode(ch)
.insertBefore ? null : null;
// 用原生方式插入纯文本字符,避免特殊字符被解析
$cursor.before(document.createTextNode(ch));
index++;
}
setTimeout(step, settings.speed);
}
step();
});
return this;
};
})(jQuery);这段代码的关键点有两个。第一,通过charAt(index)判断当前字符是不是<,是的话就用indexOf('>')找到标签结尾,把整段标签字符串交给jQuery解析并插入,标签就以完整形态进入DOM。第二,纯文本字符不用$cursor.before(ch)而是用before(document.createTextNode(ch)),这样即使文本里包含<或&等特殊字符,也会被当作字面文本显示,不会被误当成标签解析。不过要注意,如果文本里本身就有<字符的转义形式<,按字符截断时可能把<拆成&l和t;两部分,插入文本节点后显示会短暂异常,这种情况需要先对HTML实体做保护处理,下文会提到。
三、光标闪烁与CSS配合
光标是打字机效果的灵魂。上面代码里给光标加了tw-cursor类名,配合下面这段CSS就能实现闪烁:
.tw-cursor {
display: inline-block;
width: 2px;
min-width: 2px;
background: #333;
color: transparent;
margin-left: 2px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}这里用step-end而不是默认的平滑过渡,因为真实光标的闪烁是瞬间切换的,平滑淡入淡出反而显得假。另外建议把光标宽度设为2px并用background画出来,字符形式的光标在不同字体下宽度不一,容易和文字重叠。打字结束后可以监听自定义事件done来移除光标,或者让光标继续闪烁停留几秒再淡出,看具体的视觉需求。
四、进阶玩法:速度变化、循环与多段依次输出
真实打字的速度不是恒定的,人类敲键盘有快有慢,标点后还会停顿。可以在step里加入随机延迟来增强真实感:
function nextDelay(ch, base) {
// 标点后停顿更久
if ('。,!?.!?'.indexOf(ch) >= 0) {
return base * 6;
}
// 正常字符加一点随机抖动
return base * (0.6 + Math.random() * 0.8);
}循环播放也是常见需求,比如首页标语轮播。思路是打完后清空容器,切换到下一段内容重新开始。用Promise风格的写法把每段打字包装成可等待的任务,再用async或回调串联即可。多段依次输出(比如终端里逐行打印命令)则可以给插件加一个done回调,在一个元素完成后触发下一个元素的打字。
驱动方式上,setTimeout和requestAnimationFrame都可行。前者写起来直观,延迟控制精确到毫秒;后者与浏览器渲染帧同步,页面切到后台时会自动暂停,节省资源,适合长时间运行的页面。如果打字速度设置得很快(比如20毫秒以下),建议改用requestAnimationFrame加时间累积的方式,避免定时器堆积导致卡顿。
五、常见坑与排查方法
第一个坑前面提过:HTML实体被拆断。解决方法是在构建输出队列前,先把<、&这类实体当成不可拆分的单元处理,判断当前字符是&时向后查找到分号,整体作为一个文本单元输出。
第二个坑是自闭合标签和属性中包含>的情况。属性值里出现>(比如data-tip="a>b")会让简单的indexOf('>')提前截断。稳妥的做法是用DOMParser解析而不是字符串查找,或者约定打字内容里不在属性中写>。对绝大多数展示型文案来说,后者完全够用。
第三个坑是容器高度跳动。打字过程中内容不断变长,如果容器没有固定高度,页面下方元素会被推来推去。给容器设置min-height,或者在打字前先隐形渲染一遍完整内容量出高度再锁定,都能解决。
最后提醒一点,如果打字内容是由用户输入或接口返回的动态数据,务必做好转义或白名单过滤,只允许有限的标签通过,防止把打字机效果变成XSS注入的入口。展示层做得再炫,安全底线也不能丢。
jQuery打字机效果Typewriter效果HTML逐字输出修改时间:2026-09-12 21:12:47