在浏览器里跑 Prettier:格式化 Markdown 的四个难点

「一键格式化」听起来是最容易做的功能------把文本丢给 Prettier,拿回结果,替换掉。

真做起来有四个坎:Prettier 全量加载比整个应用主包还大;中文段落不能按 printWidth 折行;格式化后光标不能跳到文首;Ctrl+Z 必须一步撤销干净,不能把用户刚打的字一起吞掉。

MarkViewmarkview.art)的实现只有 45 行,但每一行背后都有一个决策。

一、体积:全量 Prettier 比主包还大

先看数字。这套方案要用的 Prettier 模块,构建产物是这样的:

chunk 原始体积 gzip
standalone 83 KB 27 KB
markdown 270 KB 92 KB
yaml 144 KB 44 KB
babel 320 KB 83 KB
estree 215 KB 63 KB
postcss 159 KB 45 KB
html 163 KB 51 KB
合计 约 1.32 MB 约 406 KB

而应用主包是 857 KB。全量 Prettier 比整个应用还大。

所以懒加载不是优化,是前提条件:

js 复制代码
// src/services/formatter.js
// Markdown 格式化服务:Prettier standalone 及插件全部走动态 import,
// Vite 拆成独立异步 chunk,首次调用「格式化文档」才下载,主包体积零增长。
// 插件覆盖:markdown 本体、front matter(yaml)、代码块 js/json(babel+estree)、css(postcss)、html;
// 其余语言(python、mermaid 等)Prettier 不认识,代码块原样保留、不会被破坏。
let enginePromise = null

const loadEngine = () => {
    if (!enginePromise) {
        enginePromise = Promise.all([
            import('prettier/standalone'),
            import('prettier/plugins/markdown'),
            import('prettier/plugins/yaml'),
            import('prettier/plugins/babel'),
            import('prettier/plugins/estree'),
            import('prettier/plugins/postcss'),
            import('prettier/plugins/html')
        ]).then(([standalone, ...plugins]) => ({ formatWithCursor: standalone.formatWithCursor, plugins }))
        // 加载失败(如离线时 chunk 拉取失败)不缓存 rejected promise,下次调用可重试。
        enginePromise.catch(() => {
            enginePromise = null
        })
    }
    return enginePromise
}

「失败不缓存 rejected promise」这一句是个容易漏的细节:如果直接把失败的 promise 留在缓存里,用户离线时点了一次格式化,之后整个会话都无法再重试------因为后续每次调用拿到的都是同一个已经 reject 的 promise。

插件清单也是取舍的结果。TypeScript 插件被刻意排除了:chunk 太大,代价换不来收益,ts 代码块的现状是原样保留、不被破坏。

顺带一个容易忘的改动:prettier 要从 devDependencies 移到 dependencies------它现在是运行时依赖,不再只是构建期工具。

二、中文段落:proseWrap 必须是 preserve

调用配置只显式设了三个选项:

js 复制代码
export const formatMarkdown = async (text, cursorOffset = 0) => {
    const { formatWithCursor, plugins } = await loadEngine()
    const result = await formatWithCursor(text, {
        parser: 'markdown',
        plugins,
        proseWrap: 'preserve',
        cursorOffset: Math.max(0, Math.min(Math.trunc(cursorOffset) || 0, text.length))
    })
    return { formatted: result.formatted, cursorOffset: result.cursorOffset }
}

proseWrap: 'preserve' 是整个方案的关键。它的语义是:段落内的换行原样保留,既不按 printWidth 重新折行(always),也不把软换行合并成一行(never)。

为什么中文必须用它?因为 Prettier 的折行算法按空格断词 。中文没有词间空格,一整段中文会被当成一个超长的「单词」,always 模式下要么撑破 printWidth,要么在标点处胡乱断开。加上全角字符宽度和 printWidth(80 个半角)本来就对不上,怎么调都是错的。preserve 直接绕开了整个问题。

测试就是钉死这个行为:

js 复制代码
it('proseWrap preserve:超长中文段落不被折行', async () => {
    const paragraph =
        '这是一段特意写得很长的中文正文,长度远远超过八十个字符的默认打印宽度,格式化之后必须保持为完整的一行,不能被换行打断。'
    const { formatted } = await formatMarkdown(`# 标题\n\n${paragraph}\n`)
    expect(formatted).toContain(`\n${paragraph}\n`)
})

这里还有个容易混淆的点:项目根目录有一份 .prettierrc.jsontabWidth: 4semi: false 等),但它只作用于项目自己的源码prettier/standalone 不读配置文件,运行时格式化用户文档走的全是 Prettier 默认值------所以文档里的 JS 代码块会被补上分号,尽管项目自身的代码风格是不加分号的。

至于实际生效的规则:表格按列宽对齐、分隔行归一为 | --- |、无序列表符号统一成 -、粗体统一 **、块级元素之间的空行规范化、front matter 交给 yaml 插件重排。

三、代码块内的代码也格式化了------一行代码没写

文档里的 JS 代码块也被格式化了,const a={x:1,y: 2} 变成 const a = { x: 1, y: 2 };

实现方式是:什么都没做。

项目里没有一行代码去扫围栏、切片、拼回。这是 Prettier markdown 插件自带的 embedded-language 能力------只要把对应的 parser 插件一起传进 plugins 数组,它就会对 code 节点走 embed:拿围栏的 info string,推断出 parser(js/jsx → babel,json → json,css/scss/less → postcss,html/vue → html),子格式化后按缩进塞回去。

推断不出来的就原样打印。所以 mermaid、python 这些块完全不受影响------测试里用 toContain(原文) 逐字断言了这一点。

因为「语言标记决定了能不能格式化」,插入代码块的命令特意把光标停在语言标记位:

js 复制代码
// 插入围栏代码块,光标停在语言标记位;标记决定高亮与格式化能力(如 JSON 内容应标 json 而非 js)。

用户敲下 Ctrl+Alt+K,光标就在三个反引号后面,顺手敲 json 就行------一个小设计,让「标语言」变成了默认动作而不是额外负担。

四、光标:官方 API 加一次 clamp

Prettier 有官方方案,formatWithCursor(source, { cursorOffset }) 返回的结果里带一个新的 cursorOffset,已经是格式化后文本中的偏移。所以不需要自己做锚点映射。

项目做的唯一处理是防御性 clamp:

js 复制代码
cursorOffset: Math.max(0, Math.min(Math.trunc(cursorOffset) || 0, text.length))

Math.trunc(...) || 0 同时吃掉 NaNundefined 和小数,再夹进合法区间------传越界的 offset 会让 Prettier 直接抛错。

采样端取的是主选区的 head 而非 anchor

js 复制代码
// 当前光标(主选区头部)在文档中的绝对偏移,供格式化前采样、格式化后映射回位。
getCursorOffset: () => view.state.selection.main.head,

测试用「切片比对」而不是硬编码数字,写法值得抄:

js 复制代码
it('光标映射到格式化后的对应位置', async () => {
    const source = '* item\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\ntail\n'
    const { formatted, cursorOffset } = await formatMarkdown(source, source.indexOf('tail'))
    expect(formatted.slice(cursorOffset, cursorOffset + 4)).toBe('tail')
})

fixture 特意在光标前面放了会改变长度的内容(*-|---|| --- |),所以如果 offset 是原样透传的,这条断言必挂。

五、撤销:一次 Ctrl+Z,不多不少

这是全篇最值得记的一个坑,而且是端到端验证阶段才抓到的。

第一层保证很直觉------整篇替换写成一个事务,天然是一个 history event:

js 复制代码
view.dispatch({
    changes: { from: 0, to: view.state.doc.length, insert: text },
    // ...
})

但实际测下来会发现:格式化之后马上继续打字,按一次 Ctrl+Z新打的字连同格式化一起被撤销了

原因是 CodeMirror 的 history 默认有 500ms 的 newGroupDelay,会把时间接近的事务合并成一个撤销单元。解法是显式隔离:

js 复制代码
// 应用格式化结果:整段替换 + 光标落到映射后的新位置并滚动可见。
// 单次 dispatch = 单步撤销;isolateHistory 阻止本事务与格式化后紧接的输入合并成一步,
// 否则 500ms 内继续打字会让一次 Ctrl+Z 连输入带格式化一起回退。
applyFormatted(text, anchor) {
    if (text === view.state.doc.toString()) return
    const position = Math.max(0, Math.min(Number.isInteger(anchor) ? anchor : 0, text.length))
    view.dispatch({
        changes: { from: 0, to: view.state.doc.length, insert: text },
        selection: { anchor: position },
        effects: EditorView.scrollIntoView(position, { y: 'center' }),
        annotations: isolateHistory.of('full')
    })
},

isolateHistory.of('full') 表示前后都不许合并(另有 'before' / 'after' 可选)。

这类 bug 的特点是单测抓不到------它不在纯函数里,而在编辑器的时间行为里。只有真的在浏览器里格式化完接着打字、再按 Ctrl+Z,才会暴露。

六、异步带来的竞态

格式化是异步的(首次还要下载 chunk),这期间用户可以继续打字、切换文档、甚至再按一次快捷键。四道防线:

js 复制代码
// Ctrl/Cmd + Alt + F:整篇 Prettier 格式化(Markdown 结构 + 受支持语言的代码块),
// 光标经 formatWithCursor 映射保持原位。格式化是异步的(首次还要加载引擎 chunk),
// 期间用户可能继续输入或切换文档,应用前核对文档未变,变了则静默放弃、绝不覆盖新输入。
let isFormatting = false
const formatDocument = async () => {
    const editor = editorController
    if (!editor || isFormatting) return
    isFormatting = true
    try {
        const source = editor.getDoc()
        const { formatted, cursorOffset } = await formatMarkdown(source, editor.getCursorOffset())
        if (editorController !== editor || editor.getDoc() !== source) return
        if (formatted === source) {
            toast.show('内容已符合格式')
            return
        }
        editor.applyFormatted(formatted, cursorOffset)
        toast.show('已格式化,Ctrl+Z 可整体撤销', { tone: 'success' })
    } catch {
        toast.show('格式化失败,内容未做改动', { tone: 'error', duration: 3000 })
    } finally {
        isFormatting = false
    }
}
  • 重入锁 isFormatting:连按快捷键不会并发跑两遍。
  • 双重竞态核对 :既查控制器实例是否被换掉(切文档、编辑器重建),又查文档内容是否被改过。命中就静默 return------不 toast、不覆盖。用户在等待期间打的字,比一次格式化重要。
  • catch 兜底:Markdown 或代码块有语法错误时 Prettier 会抛,提示「内容未做改动」,原文不动。
  • 幂等短路:已经符合格式时提示「内容已符合格式」而不是「已格式化」,避免制造一个什么都没变的撤销步。

诚实地说一个缺口:没有超时机制。Prettier 是同步 CPU 密集的,超大文档理论上会卡住主线程一段时间,目前只有重入锁拦住重复触发。

七、为什么渲染上了 Worker,格式化没有

这是同一个项目里的一处有意思的对比。

Markdown 渲染做了完整的 Worker 化:后台线程渲染、序号防乱序、Worker 崩溃时动态 import 同步版本降级(细节见渲染管线篇)。而格式化全程跑在主线程,async 只来自动态 import 和 Prettier 3 的 promise API,不是并发。

判断依据是频率:渲染每次击键都跑,一次卡顿就是持续可感的输入延迟;格式化是低频的、用户显式触发的一次性操作,跑 200ms 用户是有心理预期的。

工程上的取舍不该看「这个技术是不是更好」,而该看「这个代价是不是值得」。如果哪天要给格式化做 Worker 化,现成的渲染调度器就是可复用的范式------但在此之前,多一层线程通信就是多一层可能出错的地方。

结语

三条经验:

  1. 懒加载的边界要看清------当依赖比主包还大时,「首次使用才下载」不是优化选项而是可行性前提;同时记得让失败的加载可重试。
  2. 国际化的坑常常藏在算法假设里 ------proseWrap 的问题本质不是配置选错,而是 Prettier 的折行算法假设了「词由空格分隔」,中文不满足这个前提。
  3. 有些 bug 只有真机能抓------撤销栈合并、光标漂移这类问题不在纯函数里,单测再全也覆盖不到;纯逻辑用 Vitest,交互时序留给端到端验证,两者不互相替代。
相关推荐
前端炒粉16 小时前
手撕小汇总
java·前端·javascript
r_oo_ki_e_16 小时前
vue快速入门
前端·vue.js
虚惊一场16 小时前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
前端·javascript·vue.js
lauo16 小时前
掌心核爆:iQOO首款小平板搭载2nm骁龙8E6,开启AI原生计算的移动终端新纪元
前端·人工智能·智能手机·重构·电脑·ai-native
万敏16 小时前
Vue3 全栈实战第三周:组件化开发完整记录 —— Props / Emit / provide-inject / 插槽 / 自定义指令
vue.js·node.js·全栈
虚惊一场16 小时前
一条 Markdown 渲染管线的全部细节:Worker、源行标注与按需加载
前端·javascript·vue.js
CoovallyAIHub16 小时前
当能源行业遇上 AI 智能体:Coco 为什么选择留在本地
前端·agent
程序员黑豆17 小时前
鸿蒙开发入门:以 Text 组件为例,掌握内置组件用法
前端·harmonyos
爱勇宝17 小时前
AI不会淘汰所有人,但会淘汰这6种人
前端·后端·程序员