当前位置: 首页
前端开发
在浏览器中运行Prettier格式化Markdown的四个难点

在浏览器中运行Prettier格式化Markdown的四个难点

热心网友 时间:2026-07-21
转载

在浏览器中运行Prettier格式化Markdown需解决四大难点:全量包体积超主包需懒加载;中文段落因无词间空格必须用proseWrap:preserve避免折行错乱;代码块借助embedded-language自动格式化但光标需经formatWithCursor映射;撤销操作需用isolateHistory隔离事务以防误合并。

「一键格式化」——听起来是不是很简单?只需将文本交给 Prettier,获取结果,替换原内容,即可完成。

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

真正动手实施时,才会发现至少需要跨越四道难关:Prettier 的全量加载体积比整个应用主包还大;中文段落不能按照 printWidth 自动折行;格式化后光标无法稳定定位在文档开头;Ctrl+Z 必须实现一步撤销干净,不能把用户刚刚输入的内容一起回退。

在 MarkView(markview.art)中,这个功能的实现代码只有 45 行,但每一行背后都隐藏着多次决策。

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

先看一组数据。这套方案依赖的 Prettier 模块,构建产物如下:

chunk原始体积gzip
standalone83 KB27 KB
markdown270 KB92 KB
yaml144 KB44 KB
babel320 KB83 KB
estree215 KB63 KB
postcss159 KB45 KB
html163 KB51 KB
合计约 1.32 MB约 406 KB

而应用主包大小为 857 KB。全量 Prettier 的体积,竟然比整个应用还大。

由此可见,懒加载不是优化选项,而是必须满足的前提条件:

 复制代码// 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 = nullconst 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

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

 复制代码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)。

为什么中文必须使用 preserve?因为 Prettier 的折行算法是基于空格断词的。中文没有词间空格,一整段中文会被视为一个超长的「单词」,在 always 模式下要么撑破 printWidth,要么在标点处胡乱断开。再加上全角字符宽度与 printWidth(80 个半角)本身就不匹配,无论如何调整都不对。preserve 直接绕开了整个问题。

测试就是用来锁定这个行为:

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

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

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

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

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

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

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

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

Math.trunc(...) || 0 同时处理了 NaNundefined 和小数,再夹进合法区间——因为传入越界的 offset 会导致 Prettier 直接抛出错误。

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

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

测试采用「切片比对」而非硬编码数字,写法值得借鉴:

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

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

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

这段内容值得单独拿出来讲——这是全篇最值得记下的一个坑,而且是端到端验证阶段才发现的。

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

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

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

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

 复制代码// 应用格式化结果:整段替换 + 光标落到映射后的新位置并滚动可见。// 单次 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),这期间用户可以继续打字、切换文档、甚至再次按下快捷键。稍有不慎,就会覆盖用户最新的输入。下面设置了四道防线:

 复制代码// Ctrl/Cmd + Alt + F:整篇 Prettier 格式化(Markdown 结构 + 受支持语言的代码块),// 光标经 formatWithCursor 映射保持原位。格式化是异步的(首次还要加载引擎 chunk),// 期间用户可能继续输入或切换文档,应用前核对文档是否有变化,变了则静默放弃,绝不覆盖新输入。let isFormatting = falseconst 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,交互时序留给端到端验证,二者不能互相替代。
来源:https://juejin.cn/post/7664442820094099490

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
JavaScript数组字面量与构造函数创建稀疏数组的差异

JavaScript数组字面量与构造函数创建稀疏数组的差异

数组字面量创建稠密数组,空位默认为undefined;Array()构造函数传入单个数字参数会生成稀疏数组,索引不存在且遍历方法跳过,多参数或非数字参数则行为与字面量一致。初始化稠密数组应使用Array from或fill。

时间:2026-07-25 22:10
如何优化Bootstrap按钮的焦点状态环CSS样式方法详解

如何优化Bootstrap按钮的焦点状态环CSS样式方法详解

Bootstrap按钮焦点样式优化需将内阴影改为外发光,覆盖所有焦点选择器避免原生蓝边闪烁。使用:focus-visible区分键盘与鼠标交互,同时处理按钮组圆角、父容器溢出及浏览器兼容性,确保焦点反馈清晰且符合无障碍标准。

时间:2026-07-25 22:09
Less中强制转换CSS单位适配不同移动端方案详解

Less中强制转换CSS单位适配不同移动端方案详解

Less单位转换需手动完成:用unit()剥离单位,通过变量控制基准值,再拼接目标单位。px2rem函数须区分输入类型(纯数字、带px单位等),基准值@base-font-size需全局定义且不可在媒体查询中重定义。所有运算发生在编译期,适配需提前编译多套CSS文件。

时间:2026-07-25 22:09
Vue 插件开发与使用完整指南

Vue 插件开发与使用完整指南

Vue插件通过install方法为应用注入全局属性、组件、指令、混入和provide等扩展能力,注册时机须在createApp之后、mount之前。插件支持对象或函数形式,使用app use()注册。开发时需注意命名冲突、配置默认值及错误处理,确保工程健壮性。

时间:2026-07-25 22:09
CSS响应式视频全屏黑边排版问题解决方案

CSS响应式视频全屏黑边排版问题解决方案

CSS响应式视频全屏黑边源于盒子模型、定位与加载策略缺失。需重置body边距及溢出,父容器用position:fixed与100dvh,video设为block+object-fit:cover。autoplay需加muted、playsinline。移动端用100dvh防地址栏抖动,低端机分辨率不超1倍。

时间:2026-07-25 22:09
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜