AIChat渲染md格式优化-Web Worker

AIChat渲染md格式优化

Web Worker基本原理

很多前端同学第一次接触 Web Worker,往往是因为页面开始"卡"了。

比如这些场景:

  • 大列表数据要做复杂过滤和排序
  • Markdown 或富文本内容需要实时解析
  • 图片、音视频要做额外处理
  • 大文件要做分片、校验、加密

这类任务如果都堆在主线程里,最直观的结果就是页面掉帧、输入迟钝、交互发闷。Web Worker 的价值,本质上就是把这些重活挪到后台线程去做,让主线程专心负责界面渲染和用户交互。

这篇文章不讲太多抽象概念,主要回答两个问题:

  • Web Worker 到底该怎么用
  • Vite 项目里,第三方库放进 Worker 时应该怎么引

先说结论

如果你只想先拿到可用结论,可以先记住这 4 点:

  • Vite 项目里,优先使用 new Worker(new URL(...), { type: 'module' })
  • Worker 适合做计算、解析、转换这类"重逻辑",不适合直接做页面渲染
  • 第三方库如果是纯 JavaScript、支持 ESM,通常可以直接在 Workerimport
  • 第三方库如果依赖页面环境,就不要强行塞进 Worker

什么是 Web Worker

Web Worker 可以理解成浏览器给前端提供的一种"子线程"能力。它允许你把一段独立的 JavaScript 逻辑放到后台执行,从而避免主线程被长时间占用。

最常见的收益就两个字:解卡

适合放进 Worker 的场景通常有这些:

  • 大数据量计算
  • 文本解析和格式转换
  • 图片处理
  • 加解密
  • 大文件分片和校验

不太适合放进 Worker 的场景也很明确:

  • 频繁操作 DOM
  • 强依赖 windowdocument 的逻辑
  • 任务非常轻,通信成本反而高于计算成本

所以不要把 Worker 理解成"性能银弹"。它更像一个专门处理重任务的后台工人,适合做脏活累活,但不负责直接操作页面。

Worker 到底能做什么,不能做什么

Worker 和主线程不是共享同一套运行环境,它们通常通过 postMessage 通信。

Worker 里你通常可以使用:

  • self
  • fetch
  • setTimeout
  • Promise
  • 大部分纯 JavaScript 计算逻辑

但你不能指望它像主线程那样直接访问页面环境,比如:

  • window
  • document
  • 大部分直接操作页面的 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 件事:

  1. 主线程创建一个 Worker
  2. 主线程通过 postMessage 把任务丢给子线程
  3. 子线程执行计算
  4. 子线程把结果再通过 postMessage 回传给主线程

如果用一张图来理解,会更直观:

sequenceDiagram participant Main as 主线程 participant Worker as Worker 子线程 Main->>Worker: new Worker(...) Main->>Worker: postMessage({ type, payload }) Note right of Worker: 执行耗时计算 / 解析 / 转换 Worker-->>Main: postMessage({ result }) Main->>Main: onmessage 中更新状态或渲染页面 Main->>Worker: terminate()

最后别忘了销毁:

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 里?"

判断标准其实很简单,只看两件事:

  1. 这个库是不是依赖页面环境
  2. 这个库是不是能被当前构建体系正确加载

只要你抓住这两个问题,绝大部分场景都能快速判断。

第一类:纯 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 })
  }
}

这类典型库包括:

  • axios
  • lodash-es
  • dayjs
  • markdown-it
  • highlight.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,能明显减轻主线程压力

如果用一张图理解这个过程,会更直观:

sequenceDiagram participant Main as 主线程 participant Worker as Markdown Worker Main->>Main: 流式接收 AI 文本 Main->>Main: 轻量 Markdown 渲染(无高亮) Note right of Main: 保证对话过程不断流、不卡输入 Main->>Worker: postMessage({ type: 'render-final', text }) Note right of Worker: markdown-it + highlight.js 完整渲染 Worker-->>Main: postMessage({ type: 'rendered', html }) Main->>Main: 更新最终 HTML

为什么这个拆法比"全程都进 Worker"更稳

很多同学第一反应是:

"既然 Worker 能解卡,那我是不是应该把每次流式解析都扔进去?"

理论上可以,但业务里未必划算。

因为流式输出的特点是:

  • 更新很频繁
  • 每次内容都在变
  • 主线程最终还是要更新 DOM

所以如果每一个 chunk 都走一次线程通信,再回主线程替换一遍 HTML,收益未必一定比成本大。

更稳妥的方式通常是:

  • 流式阶段保留轻量逻辑
  • 完成阶段只做一次完整渲染

这样既保住了交互顺滑,也把最重的计算搬出了主线程。

实战结构设计

可以把职责拆成下面三部分:

  1. MarkdownRenderer.vue

    • 负责接收内容和状态
    • 流式阶段直接做轻量渲染
    • 完成阶段把原始文本交给 Worker
    • 收到 Worker 结果后更新 html
  2. markdown.worker.js

    • Worker 内部初始化 markdown-it
    • 引入 highlight.js
    • 只负责把 Markdown 文本转成最终 HTML
  3. 主线程和 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, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

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,其实只看一句话就够了:

如果它"只算不画",大概率适合;如果它"既要算、又要画、还要碰页面",那它更应该留在主线程。

相关推荐
闲云一鹤5 小时前
本地部署 B 站 IndexTTS2 模型 - AI 文本生语音神器
前端·人工智能
前端双越老师6 小时前
Skills 是什么?如何用于 Agent 开发?
人工智能·node.js·agent
yiyu071615 小时前
3分钟搞懂深度学习AI:环境安装与工具使用
人工智能·深度学习
冬奇Lab17 小时前
一天一个开源项目(第44篇):GitNexus - 零服务器的代码智能引擎,为 AI Agent 构建代码库知识图谱
人工智能·开源·资讯
冬奇Lab17 小时前
OpenClaw 深度解析(七):安全模型与沙盒
人工智能·开源
IT_陈寒19 小时前
别再死记硬背Python语法了!这5个思维模式让你代码量减半
前端·人工智能·后端
Ray Liang19 小时前
彻底治愈AI“失忆”和胡说八道的真正办法
人工智能·rag·智能体·ai助手·mindx
阿星AI工作室20 小时前
飞书OpenClaw插件太香了!自动写文+整理表格+按评论修改保姆级教程
人工智能
生如夏呱20 小时前
【教程】230 行代码实现一个极简的 OpenClaw
人工智能