ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uniapp中将towxml迁移到分包:Markdown渲染体积优化实践

uniapp中将towxml迁移到分包:Markdown渲染体积优化实践

前阵子做资讯类小程序,文章详情页需要把一个 Markdown 渲染工具接进 uniapp 工程。试了一圈之后,我把社区维护的 towxml 作为最终方案——它对小程序端的支持比较完整,代码高亮、数学公式这些都有。可问题也随之而来:towxml 体积不小,连同主题样式和插件资源放进主包后,本地代码直接逼近体积红线。于是我把 towxml 整个挪到了分包里,让它跟着文章详情页所在的子包按需加载。整个过程比预想中多踩了不少坑,所以把这套实践整理出来。

能看到这篇文章的,多半是已经选定 towxml、又正在被主包体积或者分包配置折磨的开发者。我会尽量把“为什么这么迁移”“迁移之后怎么配置”“遇到问题怎么排查”写透,而不是只给一个拷贝粘贴结果。

1. towxml 的渲染原理与选型依据

1.1 它到底帮你干了哪些活

towxml 做的事情可以拆成两段:先把 Markdown 字符串解析成一棵节点树,再用递归模板把这棵树渲染成真实节点。在小程序端,它走的是原生自定义组件路径,所以渲染出来的文本、标题、代码块、图片都是可以被点击、被长按识别、被局部重置样式的节点,而不是一张拍扁了的图。

这一点和 rich-text 有本质区别。rich-text 也能显示 HTML 片段,但它更像一个“黑盒容器”,内部节点的事件绑定、样式穿透都受限制。Markdown 里的代码块需要高亮,图片需要点击预览,表格需要能横向滚动,这些在 rich-text 里实现起来要么靠字符串拼接 hack,要么直接做不了。

towxml 的另一部分核心工作在插件层。代码高亮依赖 highlight.js,数学公式依赖 KaTeX。它把这些东西内置成插件目录,需要哪个开哪个。好处是生态完整,坏处也很明显——插件目录里的语言包和字体文件体积不小。这也是后面所有体积问题的根源。

1.2 为什么在 uniapp 里仍然选它

uniapp 是跨端框架,但底层依然会编译到小程序原生组件体系。towxml 作为一个原生自定义组件,理论上完全可以在 uniapp 工程里使用,前提是路径引用正确、组件注册正确。我对比过几条路:

方案Markdown 解析代码高亮数学公式小程序端事件支持体积可控性
towxml完整完整完整较好可通过插件开关裁剪
通用富文本组件需要另接解析器部分支持一般一般中
rich-text 自己拼 HTML需要另接解析器难难弱小
自己用解析库 + 模板渲染工作量大难难可控小

通用富文本组件我在另一个项目里用过,处理普通 HTML 很顺手,但一旦内容变成 Markdown,还要额外引一层解析器,把 Markdown 转成 HTML 节点串再喂进去,数学公式这块经常缺胳膊少腿。自己撸解析器更不现实,小程序端的模板递归、代码高亮语言包管理、图片预览事件,每一块都是深坑。

所以对“文章详情页渲染 Markdown”这个场景,towxml 几乎是最贴近需求的现成方案。它唯一的原罪就是大。既然如此,那就让它从主包里滚出去,到分包里待着。

1.3 体积问题从哪来

towxml 的目录里通常包含组件本体、theme 样式目录、highlight 语言包目录、katex 插件和字体目录。highlight 如果全量加载,会带进来几十种语言的高亮规则;katex 的字体文件单个就有几百 KB。再加上组件内部用于递归渲染的模板结构,整体塞进主包以后,哪怕你的业务代码只有几百 KB,也很容易让主包超过 2M 限制。

看清楚这个体积构成之后,我的判断是:主包页面根本不需要常驻这个库,只有用户点进文章详情才会用到,放进分包是再自然不过的事。唯一需要认真对待的是迁移之后的路径和配置。

2. 分包布局与 towxml 的目录迁移

2.1 先规划好分包根目录

小程序分包的逻辑是“按需下载、按需加载”。用户进到分包页面时,平台才会去下载这一整个子包的代码资源。因此 towxml 必须放在和它所属页面同一个分包内,才能准确实现“用到才下载”。

我采用的目录结构是一个典型的信息流项目:

├── pages # 主包页面 │ └── index/index.vue ├── subpkg/article # 文章业务分包 │ ├── components/towxml/ # towxml 整体迁移到这里 │ └── pages/detail/detail.vue

分包根目录用subpkg/article,把 towxml 放在该分包内的components目录里。之所以不单独建一个subpkg/common再让文章页跨分包引用,是因为小程序分包之间默认是隔离的,跨分包引用组件会带来编译期依赖判断错误,属于自己给自己挖坑。如果 towxml 真的要被多个业务分包共用,正确做法是放进主包的公共组件目录,或者用分包异步化,而不是硬塞在某个子包里让其他子包去引。

2.2 搬迁时别只复制一个 JS 文件

很多新手容易犯一个错误:以为 towxml 就是一个towxml.js,复制过去就能跑。实际它的组件依赖是成体系的,包括:

  • 组件本体:.js、.json、.wxml、.wxss
  • 插件目录:highlight 语言包、katex 字体等
  • 主题目录:light / dark 等样式文件

我当时的做法是先把整个towxml目录原封不动复制到subpkg/article/components/towxml下,再用 IDE 的文件树确认关键文件一个没少。千万不要只复制一部分,否则后面会出现样式丢失或者组件压根注册不上的问题,排查起来非常痛苦。

复制完成后,还要在工程里搜索一下有没有绝对路径引用。towxml 自带的一些资源引用可能写的是从根目录开始的路径,在小程序分包场景下必须确认它们能被相对路径解析,否则上传之后分包内文件缺失。

2.3 pages.json 的分包配置

uniapp 里配置分包,核心是subPackages字段。文章详情页不在主包的pages数组里,而是在分包中声明:

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } } ], "subPackages": [ { "root": "subpkg/article", "pages": [ { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "文章详情" } } ] } ] }

这里要注意path是相对于root的路径。比如物理目录是subpkg/article/pages/detail/detail.vue,那么path就写pages/detail/detail,不需要再带subpkg/article前缀。

配置完成之后,跳转到这个分包页面,需要使用完整分包路径:

uni.navigateTo({ url: '/subpkg/article/pages/detail/detail?id=123' });

主包页面里写跳转时,路径必须以/subpkg/article/开头,否则平台会认为你要跳一个不存在的页面。

2.4 easycom 自定义路径的坑

uniapp 的 easycom 规则非常方便,可以在pages.json里全局指定组件路径,让页面免注册直接使用。但我实际测试后发现,在 towxml 分包场景下,千万不要顺手在全局 easycom 里配置一个指向分包组件的规则。

原因在于 easycom 的自动引入是编译层处理的,它会在所有页面编译时都扫描这个规则。如果主包页面也在编译范围内,编译器很可能尝试把分包里的组件归属到主包页面依赖里,导致报错或者资源错乱。

我的建议是:不依赖 easycom,老老实实在分包页面里用相对路径 import 组件。虽然多写两行代码,但依赖关系非常清晰,排查问题也容易。

3. 在分包页面里把 Markdown 渲染出来

3.1 先理解 towxml 的数据流

towxml 在使用时通常有两种数据输入方式:一是直接传 Markdown 字符串,让组件内部完成解析;二是先用解析方法把 Markdown 转成节点对象,再把对象传给组件。第一种对调用方更友好,第二种更适合对多次渲染做缓存优化。

我比较推荐对象方式。因为文章详情页的特点是:用户可能反复进入同一篇文章,但 Markdown 原文并不会频繁变化。如果每次进入都重新解析,白费一次 CPU 不算,长文解析还会造成页面短暂白屏。把解析结果缓存下来,性能明显更好。

当然不同版本 towxml 暴露的解析方法名可能不一样,落地时以你使用的版本说明文档为准。我这里给出的是组合思路。

3.2 组件注册与页面接入示例

在分包页面<template>里直接使用 towxml 组件:

<template> <view class="article-detail"> <towxml :content="articleNodes" :theme="theme" :events="events" /> </view> </template> <script> import towxml from '../../components/towxml/towxml.vue' export default { components: { towxml }, data() { return { articleNodes: {}, theme: 'light', events: { tap: this.handleNodeTap } } }, onLoad(options) { this.loadArticle(options.id) }, methods: { loadArticle(id) { // 这里用 uni.request 从服务端取 Markdown 原文 // 然后调 towxml 的解析方法,得到 nodes 对象 const nodes = this.parseMarkdown('# 标题\n\n正文内容') this.articleNodes = nodes } } } </script>

如果使用的是直接传 Markdown 字符串的版本,把articleNodes换成普通字符串即可,组件内部会自己处理解析。无论哪种方式,注册组件时用相对路径是关键,绝对路径@的问题后面专门讲。

3.3 主题样式要主动引入

这是我最开始踩的坑。Markdown 节点渲染出来之后,整个页面毫无排版可言,标题不大、代码块没有底色、列表符号全部丢失。我一度以为是组件没加载完整,后来发现是 theme 文件没有被引入。

towxml 的主题样式通常是基于 WXSS 的文件,里面定义了 h1 到 h6、p、pre、table、blockquote 等标签的样式规则。在 uniapp 页面里,需要把主题文件通过@import引入到页面的<style>中:

<style lang="scss"> @import "../../components/towxml/theme/light.wxss"; .article-detail { padding: 24rpx; font-size: 32rpx; } </style>

注意路径是相对当前页面的。用纯@import引入 WXSS 文件在小程序端是可以被编译处理的,H5 端一般也能兼容。引入之后,再去看渲染效果,排版就正常了。

3.4 代码高亮和数学公式的开关控制

towxml 的插件逻辑通常允许你配置启用哪些能力。如果你的文章内容里没有数学公式,那么 katex 完全可以关掉,对应的一堆字体文件也会从实际打包结果里消失。

大致配置思路如下:

const towxmlConfig = { highlight: true, highlightLanguages: ['javascript', 'html', 'css', 'json'], katex: false }

开了代码高亮之后,还要注意代码块的语言类型。towxml 的高亮语言包如果全量引入,体积会不小;按需配置常见语言,能有效压缩体积。这个配置直接决定后面你分包里的 towxml 到底有多大,建议在早期就按需设置,不要图省事全开。

4. 放分包之后我踩过的一串坑

4.1 主包页面引用分包组件导致编译报错

某次联调时,我在一个主包页面里也临时用到了 towxml,直接按之前的相对路径 import 了分包组件,编辑器立刻抛错,大致意思是找不到组件或者组件不在允许的依赖范围内。

排查链路是这样的:先看报错组件的路径,确认目标文件在分包目录里;再看当前引用方页面,确认它属于主包;最后看小程序分包规范,确认主包引用分包组件本身就是不被允许的。

这个问题的本质是平台的分包资源隔离机制。主包页面启动时,分包的代码还没有下载,平台不可能让主包去依赖一个尚未加载的资源。所以 towxml 被哪个包的页面使用,就必须在哪个包的目录下存在。

4.2 路径中带 @ 符在分包场景下的隐患

uniapp 里@通常指向项目根目录,写起来很爽,但分包场景下却给我埋了一个雷。第一次把 towxml 放进分包时,我在分包页面的script里写的是:

import towxml from '@/subpkg/article/components/towxml/towxml.vue'

编译和页面显示都没问题,但打开开发者工具看依赖分析,我发现 towxml 竟然被归到了主包里,主包体积直接飞涨。后来换成了相对路径:

import towxml from '../../components/towxml/towxml.vue'

重新构建,towxml 才老老实实回到子包资源里。

为什么@会有这个问题?因为@从项目根目录解析,编译器在处理分包页面依赖时,对“根目录路径”和“分包相对路径”的包归属判断逻辑不太一样,极容易把资源算到主包去。所以我的建议是:所有分包页面里引用分包内资源,一律用相对路径,不要偷懒用别名。

4.3 样式失效的完整排查链条

还有一个高频问题:Markdown 结构出来了,但代码块底色、标题间距全部没有。这个问题的排查链条很有代表性,建议收藏。

第一步,确认主题文件是否被引入。很多人复制组件时下意识忽略 theme 目录,这是最可能的原因。

第二步,确认组件内部引用的样式资源是否都在。towxml 里代码高亮依赖独立的样式文件,如果 highlight 的 css 丢了,代码块就只有<pre>标签的默认样式。

第三步,检查是否因为 rich-text 包裹导致样式隔离。某些版本或某些用法下,towxml 会通过 rich-text 渲染一部分节点,而 rich-text 内部的样式天然不受外部 class 控制,此时要在 towxml 配置里指定外部样式类,或者改用非 rich-text 的渲染模式。

这三个步骤走完,样式问题基本都能定位。千万不要一上来就怀疑是分包导致样式失效,分包不影响样式引入,影响样式引入的只会是路径和文件缺失。

4.4 为什么 tabBar 页面不能进分包

有位朋友问过我:既然分包能减主包体积,能不能把收藏页做成 tabBar 页面,然后塞进分包?答案是不能。

tabBar 页面必须在启动时立刻渲染出来,而分包资源是按需下载的,平台不可能在启动阶段去下载一个分包页面。如果文章详情页或者某个需要 towxml 的页面被设置成了 tabBar,那 towxml 就只能留在主包里跟着常驻。

所以做规划时要想清楚:凡是需要 towxml 渲染的页面,尽量不要出现在 tabBar 列表里。这也是为什么大多数资讯类 App 的文章详情都是通过列表页跳转进入的,天然就是分包的理想对象。

4.5 H5 端的分包表现

uniapp 是跨端框架,同一个工程可能要出小程序端,也可能要出 H5 端。H5 端没有“分包按需加载”的物理概念,subPackages配置在 H5 端会被当成普通路由处理,所有页面和组件的代码仍然会打进同一个 bundle。

但这不代表 towxml 放在分包目录里就有问题。只要你在 H5 端也能通过相对路径找到它的源码,编译就不会报错。唯一需要注意的是,不要在样式里写死只适用于小程序的单位或者条件编译片段,否则 H5 端会出现排版差异。跨端场景下,用条件编译单独处理两端差异是标准做法。

5. 体积与渲染性能再优化

5.1 用依赖分析面板确认 towxml 落在分包

迁移完成后,第一件事不是急着写业务代码,而是验证产物。在开发者工具里打开“代码依赖分析”或者体积详情面板,搜索 towxml 关键字,看它出现在哪个分包资源下。

如果出现在主包,说明路径引用还是有@或者跨包引用问题,回到前文排查;如果出现在subpkg/article下,说明迁移成功。之后每次发版前,我都习惯看一眼主包体积,确认没有悄悄变回临界值。

5.2 裁剪不需要的插件资源

towxml 的忠实体积大头在插件。如果业务场景只渲染纯文本和一些代码块,那么 katex 一定关掉;如果文章里几乎不会出现冷门语言的代码,那 highlight 的语言包只留最常用的几种。

实际操作时,我还会进一步把 theme 目录里用不到的暗色主题删掉,只保留项目在用的亮色主题。这样能再挤出一部分空间。裁剪完以后,towxml 在分包里的体积从接近 800KB 降到 400KB 左右,虽然绝对值还是不小,但对分包下载的影响已经小很多。

5.3 渲染结果做本地缓存

文章详情的场景,重复进入概率很高。每次进入都重新调接口拿 Markdown 原文、再重新解析,既费流量又费时间。我的做法是:

  • 首次进入:请求 Markdown 原文,解析成 nodes 对象,把原始字符串和解析结果都缓存到本地存储;
  • 再次进入:先读缓存,有就直接用,再在后台偷偷请求一次更新;
  • 如果服务端内容有变更,通过版本号校验后刷新缓存。

不过要注意本地存储有容量上限,长文章或者图片很多的文章,解析结果对象可能很大,需要控制缓存条数并做 LRU 淘汰。简单做法是只缓存最近 20 篇,超过就清最旧的。

5.4 服务端预处理的后路

如果端上解析还是太慢,可以考虑在服务端完成 Markdown 到 towxml 节点结构的转换,接口直接返回解析好的 JSON。小程序端拿到对象直接渲染,性能会好很多,尤其适合文章动辄几万字的内容平台。

这个方案的代价是引入了一套新的处理链路:服务端要部署解析服务,数据结构要和端上版本保持同步,升级 towxml 时要两边一起升。所以我对小项目通常不建议一上来就做,先保证端上方案跑顺,等性能瓶颈真的出现了再迁移到服务端。

如果你现在正好卡在包体积超限的问题上,我的建议是先别急着把 towxml 扔到分包,而是先看你的业务页面结构。如果只有文章详情这类低频页面用到它,放分包是最省事的路径;但如果后期会有大量页面都依赖 Markdown 渲染,那把它迁回主包公共目录,同时裁剪 highlight 语言包、关掉不用的插件,可能是长期更好的平衡点。这套方案跑稳定之后,我最大的体会是:分包不是逃避体积问题的终点,而是逼你把资源归属和路径依赖理清楚的一个起点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进