💡 背景与痛点
在日常的业务开发中,我们偶尔会遇到这样一种棘手的需求:
后端返回了一段 HTML 字符串,这段字符串不仅包含标准的 DOM 结构,还混杂了自定义格式的文本 。我们需要将这个字符串渲染到页面上,并且将这些自定义格式的文本渲染成我们自己的 Vue 自定义组件。
举个例子,假设我们有这样一段字符串:
html
这是一段含有表格的文本
<table>
<tr>
<th>引用</th>
<th>公式</th>
</tr>
<tr>
<td>[[https://www.baidu.com]]</td>
<td>{{$e=mc^2$}}</td>
</tr>
</table>
思考:对于这种字符串,最直接的方法是什么?
很多人第一时间会想到:正则替换 !我们可以将自定义格式的文本(如
[[...]]或{{$...$}})通过正则替换为对应自定义组件的 HTML 字符串。
**但是,现实往往很骨感。**如果我们的自定义组件比较复杂,尤其是含有各种交互和复杂的 props 参数时,简单的正则替换就显得力不从心了,甚至根本无法实现。
🛠️ 破局思路:AST + 虚拟 DOM (VNode)
既然正则替换走不通,我们不妨换一种思路:
HTML 字符串本质上就是一个 DOM 结构。我们完全可以将其解析为 AST(抽象语法树)形式的 DOM 树 ,然后遍历这棵 AST 树 。在遍历的过程中,一旦遇到我们的"自定义格式文本",就将其替换为自定义组件的 VNode(虚拟节点)。最后,借助 Vue 强大的渲染函数,将这棵全新的 VNode 树渲染到页面上!
这种方案不仅优雅,而且扩展性极强。下面,我们就来一步步实现它。
🚀 详细实现步骤
1️⃣ 第一步:将 HTML 字符串转换为 DOM 树 (AST)
这一步非常简单,我们不需要引入复杂的第三方 AST 解析库,直接使用浏览器原生内置的 DOMParser 即可将 HTML 字符串解析为真正的 DOM 树。
typescript
// 假设 this.htmlString 是我们需要解析的 HTML 字符串
const parser = new DOMParser();
// 将字符串解析为 DOM 文档
const doc = parser.parseFromString(this.htmlString, "text/html");
// 获取 body 下的子节点数组,这就是我们需要的 AST 形式的 DOM 树
const ast = Array.from(doc.body.childNodes);
此时的 ast 数组,就代表了这棵 DOM 树的最顶层节点集合。
2️⃣ 第二步:遍历 AST 树,生成 Vue 的 VNode 树
这是最核心的一步!我们需要遍历刚才得到的 AST 树。根据节点的 nodeType 来判断其类型,从而生成对应的 Vue VNode。
首先,我们处理 Text 文本节点 。在这里,我们将根据业务逻辑进行正则匹配,将特殊格式替换为我们想要的组件(通过 Vue3 的 h 函数返回 VNode)。
typescript
import { h } from "vue";
import katex from "katex";
const parseText = (text) => {
// 匹配 [[链接]] 或 {{$公式$}}
const regex = /(\[\[.*?\]\]|\{\{\$.*?\$\}\})/g;
const parts = text.split(regex);
return parts.map((part) => {
// 处理 [[链接]]
if (part.startsWith("[[") && part.endsWith("]]")) {
const url = part.slice(2, -2);
// 渲染为 a 标签组件 (Vue 3 扁平化 props)
return h(
"a",
{
href: url,
target: "_blank",
style: { color: "#409EFF", textDecoration: "none" },
},
url,
);
}
// 处理 {{$公式$}}
else if (part.startsWith("{{$") && part.endsWith("$}}")) {
const math = part.slice(3, -3);
try {
// 渲染为 KaTeX 公式组件
const html = katex.renderToString(math, { throwOnError: false });
return h("span", {
innerHTML: html,
});
} catch (e) {
// 解析失败时,回退显示红色文本
return h("span", { style: { color: "red" } }, part);
}
}
// 普通文本直接返回
return part;
});
};
接着,我们处理 Element 元素节点 。我们需要提取出标签名和属性,然后递归 调用自身来处理子节点,最后使用 h 函数将它们组合起来。
typescript
const parseNode = (node) => {
// 1. 处理文本节点
if (node.nodeType === Node.TEXT_NODE) {
return parseText(node.textContent || "");
}
// 2. 处理普通元素节点
else if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
// 提取元素的所有属性
const attrs = {};
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
attrs[attr.name] = attr.value;
}
// 递归遍历子节点,生成子 VNode 数组
const children = Array.from(node.childNodes).flatMap(parseNode);
// 生成当前元素的 VNode
return h(tagName, attrs, children);
}
return null;
};
3️⃣ 第三步:调用 Vue 的渲染函数将 VNode 渲染到页面
为了让代码更加简便和响应式,我们可以直接使用 Vue 的 computed 计算属性。当 HTML 字符串发生变化时,自动重新生成 DOM 树,并转化为 VNode 树。
最后,我们通过一个简单的函数式组件将 VNode 树渲染到页面上。
完整组件代码演示如下:
vue
<template>
<div class="custom-render-container">
<RenderVnode :vnode="vnode" v-for="(vnode, index) in vnodes" :key="index" />
</div>
</template>
<script setup>
import { computed, defineProps, h } from "vue";
const props = defineProps({
htmlString: {
type: String,
required: true,
},
});
// 核心逻辑:递归遍历 DOM 节点,转换为 Vue 的 VNode
// ... (此处省略上文的 parseText 和 parseNode 函数代码) ...
// 功能组件:用于在模板中直接渲染 VNode 或文本
const RenderVnode = (props) => props.vnode;
// 计算属性:将输入的 HTML 字符串转化为 VNode 树
const vnodes = computed(() => {
if (!props.htmlString) return [];
// 1. 使用浏览器内置的 DOMParser 解析 HTML 字符串为 DOM 树(即 AST)
const parser = new DOMParser();
const doc = parser.parseFromString(props.htmlString, "text/html");
// 2. 转换为 VNode 树
return Array.from(doc.body.childNodes).flatMap(parseNode);
});
</script>
🎉 最终效果
经过上面的一番操作,原本死板的 HTML 字符串,现在已经能够完美地将其中的特殊标记渲染为我们想要的交互式 Vue 组件了!

📝 总结
通过 DOMParser 获取 HTML 的 AST,再结合 Vue3 的 h 函数动态生成 VNode,我们可以极大地拓展 HTML 字符串的渲染能力。这种方案相比正则替换更加健壮,也更容易应对复杂的组件传参和嵌套场景。
如果你在开发中也遇到了类似的需求,不妨尝试一下这个方案!