FXFX.GRID

bilibili-evolved-doc 改造记录 - 异步 MarkDown 加载

最近发现 bilibili-evolved-doc 在加载某些页面的时候会花费比较久的时间,便着手考虑能否优化一下。一开始想到的主要原因是网络问题,在应用加载较大页面时,由于花费了一点下载的时间,用户会感受到明显卡顿。随即打算使用 React 18 的新特性 --- Suspense 组件,在加载时利用简单的动画过渡,减少卡顿感。但后来在改造中发现,令页面卡顿的真凶却另有其人……具体内容将在真凶章节展开。

#Next.js 使用 Suspense 组件

根据 Next.js 文档,使用 Suspense 组件则需要使用 dynamic 函数把导入组件包裹一层。该函数返回一个 Loadable Component,将其直接放在 Suspense 下便可以轻松实现组件异步加载。

#❓ 真的那么轻松吗

值得注意的是,在大部分 Next.js 的文档框架实践中,都把一个 MarkDown “当” 成一个页面组件,而非普通的组件。我们通常都使用 markdown loader 把 MarkDown 转换成 Html,抑或是 JSX(比如@mdx-js/loader)。而这个 JSX 将作为 Next.js 页面组件的形式存在于 Next.js 的 App 中。显而易见的是,dynamic 不能用来包裹一个页面,其无疑是针对组件而言的。另外,懒加载一个页面这个方案并不能解决什么问题,我们希望看到的是页面一块一块的慢慢加载的过程,而不是看着无聊的等待页面。这意味着我们希望能够先看到页面的框架,然后看到框架在加载内容,内容再慢慢浮现在对应的盒子内。但实际上我们并不需要如此复杂,我们只需把 MarkDown 内容与框架分离即可达到几乎一样的效果,这意味着,只要文章部分放入 Suspense 组件内即可。

#🔧 于是,我们就有了第一次框架的改造

原先的目录结构
pages
|-- _app.tsx
|-- user
|   |-- *.md
|-- developer
|   |-- *.md
...

👇

原先的目录结构
docs   <-- MarkDown全部迁移至此目录
pages
|-- _app.tsx
|-- docs
|   |-- [...slug].tsx
...

从原先每个 Markdown 文件作为一个页面,我们现在改用一个子路由 docs 来匹配所有的文档路径。在 [...slug].tsx 中根据路径信息动态加载 Markdown。

为了方便,我们可以把 dynamic 函数直接写入配置文件内。

const RouteItem = {
  title: "😊 欢迎使用",
  path: "/docs/user",
  Comp: dynamic(() => import("/docs/user/index.mdx"), {
    suspense: true,
  }) as typeof MDXProvider,
};

这样,我们就可以在 [...slug].tsx 中直接使用该 Comp。

#MDX 插件失效

这样的改造,无疑问是一次 Break Change。意料之中的导致了一些问题的产生。

在原框架下,一个 MarkDown 文件作为一个页面组件,我们写了一些 mdx 插件以实现组件获取 MarkDown 中的 meta,heading 等信息。而这些插件的实现都依赖于 Next.js 页面组件的设计而实现的 ¯(°_o)/¯。简单而言就是插件通过往页面内注入 props (比如改写 getStaticProps)让页面获取足够的信息,而现在每个 MarkDown 已经不再是页面了,这就导致之前的插件完全失效。

#解决方案

搜遍全网,也就找到一篇文章能看得懂的。其核心就是探讨该如何让 Next.js 获取像 Gatsby 一样组件级别的 getStaticProps。其中提到了两种方案。

  1. 使用 global 变量把数据提升到全局,然后我们就可以在整个 App 中访问到数据了
  2. 使用 React.Context 包裹应用,这样,只要其它组件在该上下文内就都能访问到数据

考虑到我们的 Markdown 组件本身是由 loader 生成,而要修改它只能从 mdx 插件或者 loader 动手,无疑方案一是更加方便的。即使它没有那么优雅,还可能带来一些其它的隐患(比如全局污染),但它还是能较快的解决当下的问题。

于是便写了一个核心仅有几行的代码的 recama 插件。

import { fromJs } from "esast-util-from-js";
export default function liftUpProps(propsName = []) {
  /**
   * global.[prop] = prop
   */
  const template = (props) => `global.${props} = ${props}`;
  return (ast) => {
    propsName.forEach((prop) => {
      ast.body.push(fromJs(template(prop)));
    });
  };
}

这样,我们便可以其它的组件内,通过 windows.heading 获取当前组件的标题

我曾经想通过在运行时获取标题,但后来发现由于需要递归的去搜索所以符合的 DOM 节点,这会导致非常严重的性能问题,故放弃了这条路。思考如何利用已经生成的数据无疑是更优解,这里不得不再提一下 Gatsby,其框架设计无疑是非常先进的,但可惜的是,相对于 Next.js 而言,其糟糕的开发体验,比如较慢的启动速度,书写较繁琐的数据提供方案等,让它一直处于比较尴尬的位置。

至此框架更改带来的问题就解决了。

#真凶

在新框架即将完成之际,依然能发现一些页面需要加载很久才能完成渲染,这在 SSG 模式下是不应该的,由于整个 HTML 在构建阶段已经生成,另外是在本地开发,网络应该不是问题才对,这让我非常疑惑。这让我不得不怀疑是在客户端阶段,在用户获取到页面后因为一些什么原因导致的卡顿。

性能测试结果性能测试结果

What... highlight 居然使用了这么长的时间。看来文档采用客户端高亮代码的形式才是问题的关键所在。当一个 MarkDown 较大,代码内容较多时,高亮库花费巨量的时间分析以及重新生成 HTML 模板,导致了性能的严重下降。

解决方法比较简单,我们只需把客户端高亮搬到编译 Markdown 时高亮即可,社区也有已经实现了的方案,直接使用即可。

#后记

虽然这次颇有南辕北辙之味,但也不能说毫无意义。在解决了页面加载过久的问题下,还顺便把网络加载慢的问题给解决了 (不是

#参考

FXFX.THEME