AIChat渲染md格式优化
Web Worker基本原理
很多前端同学第一次接触 Web Worker,往往是因为页面开始"卡"了。
比如这些场景:
- 大列表数据要做复杂过滤和排序
- Markdown 或富文本内容需要实时解析
- 图片、音视频要做额外处理
- 大文件要做分片、校验、加密
这类任务如果都堆在主线程里,最直观的结果就是页面掉帧、输入迟钝、交互发闷。Web Worker 的价值,本质上就是把这些重活挪到后台线程去做,让主线程专心负责界面渲染和用户交互。
这篇文章不讲太多抽象概念,主要回答两个问题:
Web Worker到底该怎么用- 在
Vite项目里,第三方库放进Worker时应该怎么引
先说结论
如果你只想先拿到可用结论,可以先记住这 4 点:
- 在
Vite项目里,优先使用new Worker(new URL(...), { type: 'module' }) Worker适合做计算、解析、转换这类"重逻辑",不适合直接做页面渲染- 第三方库如果是纯 JavaScript、支持
ESM,通常可以直接在Worker中import - 第三方库如果依赖页面环境,就不要强行塞进
Worker
什么是 Web Worker
Web Worker 可以理解成浏览器给前端提供的一种"子线程"能力。它允许你把一段独立的 JavaScript 逻辑放到后台执行,从而避免主线程被长时间占用。
最常见的收益就两个字:解卡。
适合放进 Worker 的场景通常有这些:
- 大数据量计算
- 文本解析和格式转换
- 图片处理
- 加解密
- 大文件分片和校验
不太适合放进 Worker 的场景也很明确:
- 频繁操作 DOM
- 强依赖
window、document的逻辑 - 任务非常轻,通信成本反而高于计算成本
所以不要把 Worker 理解成"性能银弹"。它更像一个专门处理重任务的后台工人,适合做脏活累活,但不负责直接操作页面。
Worker 到底能做什么,不能做什么
Worker 和主线程不是共享同一套运行环境,它们通常通过 postMessage 通信。
在 Worker 里你通常可以使用:
selffetchsetTimeoutPromise- 大部分纯 JavaScript 计算逻辑
但你不能指望它像主线程那样直接访问页面环境,比如:
windowdocument- 大部分直接操作页面的 API
理解这一点很关键,因为后面判断一个第三方库能不能放进 Worker,本质上也就是判断它是否依赖这些页面能力。
基本使用:主线程怎么调用子线程
当前项目是 Vite,推荐直接使用模块化 Worker。
先看主线程代码:
js
const worker = new Worker(new URL('./demo.worker.js', import.meta.url), {
type: 'module'
})
worker.postMessage({
type: 'sum',
payload: [1, 2, 3, 4, 5]
})
worker.onmessage = (event) => {
console.log('主线程收到结果:', event.data)
}
worker.onerror = (error) => {
console.error('worker 执行失败:', error)
}
再看 Worker 文件:
js
self.onmessage = (event) => {
const { type, payload } = event.data || {}
if (type === 'sum') {
const total = payload.reduce((acc, cur) => acc + cur, 0)
self.postMessage({
type: 'sum-result',
payload: total
})
}
}
这段代码其实就做了 4 件事:
- 主线程创建一个
Worker - 主线程通过
postMessage把任务丢给子线程 - 子线程执行计算
- 子线程把结果再通过
postMessage回传给主线程
如果用一张图来理解,会更直观:
最后别忘了销毁:
js
worker.terminate()
如果这个 Worker 只在某个页面或组件里使用,那么在组件卸载时销毁它是一个很好的习惯。
在 Vue 组件里怎么写
如果你的项目是 Vue 3,通常会在组件挂载时创建 Worker,在组件卸载时销毁它:
js
import { onBeforeUnmount, onMounted } from 'vue'
let worker
onMounted(() => {
worker = new Worker(new URL('./demo.worker.js', import.meta.url), {
type: 'module'
})
worker.onmessage = (event) => {
console.log('收到 worker 结果', event.data)
}
worker.postMessage({
type: 'start',
payload: { count: 1000000 }
})
})
onBeforeUnmount(() => {
if (worker) {
worker.terminate()
worker = null
}
})
这类写法最大的好处是生命周期清晰,不容易留下无效线程。
为什么在 Vite 里推荐 new URL() 这种方式
在 Vite 项目中,我更推荐下面这种写法:
js
const worker = new Worker(new URL('./parser.worker.js', import.meta.url), {
type: 'module'
})
原因很简单:
- 路径关系清晰,读代码时一眼就知道 worker 文件在哪里
- 和
Vite的模块体系配合自然 - 团队协作时可读性更好
- 适合大多数业务场景
如果你只是想在项目里稳定地用起来,这种方式基本够用了。
第三方库放进 Worker 时,到底该怎么判断
很多人真正卡住的不是 Worker 本身,而是这一步:
"我能不能把某个第三方库也放进 Worker 里?"
判断标准其实很简单,只看两件事:
- 这个库是不是依赖页面环境
- 这个库是不是能被当前构建体系正确加载
只要你抓住这两个问题,绝大部分场景都能快速判断。
第一类:纯 JavaScript / ESM 库,最适合
如果第三方库本身就是纯计算、纯解析、纯数据处理,并且支持 ESM,那它通常非常适合放进 Worker。
这种情况下,最直接的方式就是在 worker 文件里正常 import:
js
import axios from 'axios'
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt()
self.onmessage = async (event) => {
const { type, payload } = event.data || {}
if (type === 'render-markdown') {
const html = md.render(payload)
self.postMessage({ type: 'rendered', payload: html })
}
if (type === 'load-data') {
const res = await axios.get('/api/demo')
self.postMessage({ type: 'loaded', payload: res.data })
}
}
这类典型库包括:
axioslodash-esdayjsmarkdown-ithighlight.js- 各种解析、格式化、计算类库
如果你的项目是 Vite + module worker,那么这就是最推荐的接入方式。
第二类:不方便直接 import 的库,可以考虑 importScripts()
有些老库没有很好地提供 ESM 产物,但它又能以浏览器脚本的形式运行。这种情况下,可以考虑在经典 Worker 里使用 importScripts()。
js
importScripts('https://cdn.jsdelivr.net/npm/some-lib@1.0.0/dist/some-lib.min.js')
self.onmessage = (event) => {
const result = self.SomeLib.handle(event.data)
self.postMessage(result)
}
这种方式能用,但我一般不把它当首选,原因也很现实:
- 它更偏老式方案
- 外链脚本会受到网络和跨域影响
- 对版本管理和离线构建不够友好
所以它更适合这些场景:
- 接老项目
- 临时验证
- 第三方库实在没有更合适的模块化产物
第三类:WebAssembly 或计算密集型库,非常适合
如果一个第三方库本身就是为了高性能计算而存在,那它和 Worker 往往是天然搭档。
常见例子有:
- 图像处理
- 音视频处理
- 压缩解压
- 编码转换
原因也很好理解:
- 它本来就是耗时任务
- 放在主线程里更容易拖慢页面
- 放进
Worker后,职责划分更清晰
很多时候,Worker 真正的价值不是"让代码更高级",而是把这些高负载任务从主线程里搬出去。
推荐实践:别把 Worker 用成另一个页面线程
很多项目里 Worker 最终没发挥好,不是 API 不会用,而是边界没划清。
我更推荐下面这几个实践。
1. 只把重计算放进 Worker
最稳妥的拆分方式就是:
- 主线程负责交互和渲染
Worker负责计算和预处理
换句话说,主线程面向用户,Worker 面向任务。
2. 通信数据尽量简单
线程之间通信是有成本的,所以消息结构要尽量轻。
优先传这类数据:
- 字符串
- 数组
- 普通对象
ArrayBuffer
尽量避免传这类内容:
- DOM 节点
- 组件实例
- 带复杂原型链的对象
3. 大数据优先考虑可转移对象
如果传的是二进制或大块内存数据,优先考虑 Transferable,这样可以减少拷贝开销。
js
const buffer = new ArrayBuffer(1024)
worker.postMessage(buffer, [buffer])
这个技巧在文件处理、图片处理、流式场景里尤其有用。
一个更贴近业务的例子:用 Worker 处理 Markdown
比起单纯的求和示例,下面这个场景更接近真实项目。
假设页面上有一个 Markdown 编辑器,用户输入内容时需要实时生成预览 HTML。如果每次输入都在主线程同步解析,当文本变大之后,输入体验就容易变差。
这时候就很适合把解析逻辑放进 Worker。
js
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt()
self.onmessage = (event) => {
const { text } = event.data || {}
try {
const html = md.render(text || '')
self.postMessage({
success: true,
html
})
} catch (error) {
self.postMessage({
success: false,
message: error.message
})
}
}
这个例子有两个典型意义:
markdown-it这类纯解析库很适合放进Worker- 主线程只负责接收结果并更新预览区域,职责会很清晰
更贴近当前项目的实战:AI Chat 在结束阶段用 Worker 做完整 Markdown 渲染
上面的例子已经能说明 Markdown 解析适合放进 Worker,但如果放到真实业务里,通常还要再往前走一步:
不是所有阶段都要进 Worker,而是要挑最值的那一段。
以当前项目里的 AI Chat 为例,更合理的拆分方式通常是这样的:
- 流式阶段:主线程做轻量渲染,只保证内容"能看"
- 结束阶段:把完整
markdown-it + highlight.js渲染丢给Worker - 主线程:只接收
Worker返回的 HTML,然后更新界面
这样拆的原因很现实:
- 流式阶段每个 chunk 都可能触发一次更新,如果每次都做完整高亮,成本会很高
- 最重的往往不是"把纯文本显示出来",而是"最终完整解析 + 代码高亮"
- 把最重的一次计算移到
Worker,能明显减轻主线程压力
如果用一张图理解这个过程,会更直观:
为什么这个拆法比"全程都进 Worker"更稳
很多同学第一反应是:
"既然 Worker 能解卡,那我是不是应该把每次流式解析都扔进去?"
理论上可以,但业务里未必划算。
因为流式输出的特点是:
- 更新很频繁
- 每次内容都在变
- 主线程最终还是要更新 DOM
所以如果每一个 chunk 都走一次线程通信,再回主线程替换一遍 HTML,收益未必一定比成本大。
更稳妥的方式通常是:
- 流式阶段保留轻量逻辑
- 完成阶段只做一次完整渲染
这样既保住了交互顺滑,也把最重的计算搬出了主线程。
实战结构设计
可以把职责拆成下面三部分:
-
MarkdownRenderer.vue- 负责接收内容和状态
- 流式阶段直接做轻量渲染
- 完成阶段把原始文本交给
Worker - 收到
Worker结果后更新html
-
markdown.worker.js- 在
Worker内部初始化markdown-it - 引入
highlight.js - 只负责把 Markdown 文本转成最终 HTML
- 在
-
主线程和
Worker的通信协议- 请求:
{ type: 'render-final', text, requestId } - 响应:
{ type: 'rendered', html, requestId } - 出错:
{ type: 'error', message, requestId }
- 请求:
这里加上 requestId 很重要。
因为用户可能很快切换会话,或者同一条消息又被重新渲染,如果没有请求标识,旧结果就可能覆盖新结果。
Worker 文件怎么写
在 Vite 项目里,可以直接新建一个模块化 Worker 文件:
js
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import html from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import sql from 'highlight.js/lib/languages/sql'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import markdown from 'highlight.js/lib/languages/markdown'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('html', html)
hljs.registerLanguage('css', css)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('yml', yaml)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('md', markdown)
const SAFE_LANG_RE = /^[a-zA-Z0-9_+-]{1,30}$/
function escapeHtml(raw = '') {
return raw
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function toSafeLang(lang) {
if (typeof lang !== 'string') return ''
const normalized = lang.trim().toLowerCase()
return SAFE_LANG_RE.test(normalized) ? normalized : ''
}
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight(str, lang) {
const safeLang = toSafeLang(lang)
if (safeLang && hljs.getLanguage(safeLang)) {
try {
const result = hljs.highlight(str, {
language: safeLang,
ignoreIllegals: true
})
return `<pre class="hljs"><code class="language-${safeLang}">${result.value}</code></pre>`
} catch (_) {
// 回退到普通文本
}
}
return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
}
})
self.onmessage = (event) => {
const { type, text, requestId } = event.data || {}
if (type !== 'render-final') return
try {
const html = md.render(text || '')
self.postMessage({
type: 'rendered',
html,
requestId
})
} catch (error) {
self.postMessage({
type: 'error',
message: error?.message || 'Markdown 渲染失败',
requestId
})
}
}
这个 Worker 的核心点就一句话:
它只做完整解析,不碰页面,也不关心 Vue 组件。
这正是 Worker 最合适的边界。
主线程组件怎么接
主线程组件只做 3 件事:
- 创建和销毁
Worker - 在流式阶段直接走轻量渲染
- 在结束阶段把全文发给
Worker
可以参考下面这种写法:
js
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import MarkdownIt from 'markdown-it'
const props = defineProps({
content: String,
isStreaming: Boolean
})
const renderedHtml = ref('')
const finalWorkerHtml = ref('')
let markdownWorker = null
let requestIdSeed = 0
let latestRequestId = 0
const mdStreaming = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true
})
const streamingHtml = computed(() => {
if (!props.content) return ''
return mdStreaming.render(props.content)
})
onMounted(() => {
markdownWorker = new Worker(
new URL('./markdown.worker.js', import.meta.url),
{ type: 'module' }
)
markdownWorker.onmessage = (event) => {
const { type, html, requestId } = event.data || {}
if (requestId !== latestRequestId) return
if (type === 'rendered') {
finalWorkerHtml.value = html || ''
}
}
})
watch(
() => [props.content, props.isStreaming],
([content, isStreaming]) => {
if (!content) {
renderedHtml.value = ''
finalWorkerHtml.value = ''
return
}
if (isStreaming) {
renderedHtml.value = streamingHtml.value
return
}
const requestId = ++requestIdSeed
latestRequestId = requestId
markdownWorker?.postMessage({
type: 'render-final',
text: content,
requestId
})
},
{ immediate: true }
)
watch(finalWorkerHtml, (html) => {
if (!props.isStreaming && html) {
renderedHtml.value = html
}
})
onBeforeUnmount(() => {
if (markdownWorker) {
markdownWorker.terminate()
markdownWorker = null
}
})
这段代码最关键的不是 API 本身,而是设计思想:
- 流式中不追求"最完整"
- 结束后再追求"最准确、最漂亮"
这非常适合 AI 对话、流式消息、在线文档预览这类场景。
这个方案能解决什么问题
如果你的页面和当前项目类似,这个方案主要能缓解下面几类问题:
- 长回答结束时,主线程一次性完整高亮导致的掉帧
- 大代码块渲染时,输入和滚动变迟钝
Markdown解析和语法高亮把主线程占满,影响交互反馈
尤其是代码块比较多时,highlight.js 的收益会更明显,因为它通常比普通 Markdown 结构解析更重。
但也要明确它解决不了什么
把最终渲染放进 Worker,不代表页面所有卡顿都会消失。
因为主线程仍然要负责:
- Vue 响应式更新
v-html替换 DOM- 消息区域自动滚动
- 组件重排和重绘
所以更准确地说,这个方案解决的是:
"把最重的解析和高亮工作从主线程挪走"
而不是:
"让聊天页面完全没有任何渲染成本"
什么时候最值得上这个方案
如果你的业务同时满足下面几条,这个方案通常就很值:
- AI 回复是流式的
- 回复内容经常比较长
- 回复里经常带代码块、表格、列表
- 用户会明显感觉到输入、滚动或者切换会话时发闷
反过来说,如果只是很短的纯文本聊天,或者几乎没有代码块,那么直接留在主线程里,可能已经足够。
最后总结
Web Worker 适合处理"耗时但不依赖页面"的任务,而在 Vite 项目中,最稳妥的使用姿势就是 new URL() + module worker + 纯计算类第三方库。
再具体一点,就是下面几条:
- 创建
Worker时,优先使用new Worker(new URL(...), { type: 'module' }) - 第三方库如果是纯 JavaScript、支持
ESM,通常可以直接在Worker中引入 - 第三方库如果依赖页面环境,就不要强行放进
Worker - 老旧库可以考虑
importScripts(),但更适合作为兜底方案 Worker的职责应该是重计算、重解析、重转换,而不是接管页面逻辑
很多时候,判断一个库能不能进 Worker,其实只看一句话就够了:
如果它"只算不画",大概率适合;如果它"既要算、又要画、还要碰页面",那它更应该留在主线程。