使用 Vite 重写博客

Back

就使用体验来说,感觉 create-react-app 还是不太好用,不是真正的“开箱即用”,最近 Vite 比较火,看了眼文档感觉确实不错(比 webpack 文档强多了),于是想试试 Vite 用起来咋样。

之前的 repo 里也有些自己觉得写得很离谱的地方(虽然整个 repo 只能算是“极小”的级别),这次也算是重构一下吧。

几个月以来也一直听说 Tailwind CSS,很多人推荐,于是也试了一下。虽然最开始会觉得有些颠覆之前的样式书写习惯,但是整个目录下只有一个 .css 文件还是挺爽的一件事。

这里记录的主要是感觉需要记录的一些点吧。

markdown 文件导入问题

之前使用的 create-react-app 已经在 webpack 层面配置了 .md 文件的模块化,然后使用以一个 adapter 文件做转接,然后在博文组件里根据 pathname 动态导入文本内容。

Vite 在静态资源导入方面有更多选择:

  • 使用 ?url 后缀将资源导入为 URL 形式
  • 使用 ?raw 后缀将资源导入为字符串形式

最开始的想法是用 ?raw 后缀配合 import() 函数在博文页组件动态导入 markdown 文本,没有任何像 adapter.ts 这种看起来很冗余的处理。但是现实比较骨感,这一方式在本地调试环境可行,但是在 build 完成后再 serve 就报错了。

在 Vite 的 issue 里有提到这个问题,但是似乎尚未有有效的解答:Production Build Failed for Dynamic Import of ?raw Files #3222

等这个 issue 被解决了写起来应该就方便多了。但是现在我还是选择了跟原来差不多方案,使用了一个 adapter.ts 的文件,负责导出文件的 url 信息,然后在博客页组件动态 fetch。虽然还是很麻烦,但是相比之前的做法,少了一步动态 import adapter,简洁了一点点。

typescript
// adapter.js
export { default as ReactMarkdownTest } from '../markdown/react-markdown-test.md?url';
export { default as VueNative } from '../markdown/vue-native.md?url';
export { default as JavascriptNoteForBadMemory } from '../markdown/javascript-note-for-bad-memory.md?url';

...
typescript
// 动态 fetch
fetch(mdUrl)
  .then(res => {
    // Vite converts files smaller than 4kb to base64 by default, so the
    // url of markdown file may be a base64 string that start with 'data:text'
    // or the filename that end with '.md'.
    if (!(/^data:text/.test(res.url) || /\.md$/.test(res.url))) {
        throw new Error(`Wrong path: '${location.pathname}'. Redirect to 404 page.`);
    }
    return res.text();
  })
  .then(text => {
    setMarkdownText(text);
  })
  .catch(err => {
    setIsNoContent(true);
    console.error(err);
  });

这里有一个值得注意的地方是,在验证 markdown 资源是否存在的同时,我也验证了这个 url 是否是以 data:text 为开头的。起初并没有做这步处理,结果发现打包后有几个 markdown 文件不翼而飞,博文也加载不出来,直到我看到文档里的这句话:

较小的资源体积小于 assetsInlineLimit 选项值 则会被内联为 base64 data URL。

默认的 assetsInlineLimit 是 4kb,所以比较小的 markdown 文件的 url 被转化为了 base64 格式。不好好看文档就会踩坑

代码高亮问题

这次换了个代码高亮方案,用了 rehype-highlight 插件,这个插件依赖了 lowlight.js 库,lowlight.js 是对 highlight.js 的封装。然后 CDN 引入一个主题 CSS 文件即可。

有一个问题是 build 的时候会报错:

bash
node_modules/rehype-highlight/index.d.ts:11:59 - error TS2694: Namespace '"node_modules/lowlight/lib/core"' has no exported member 'LowlightRoot'.

手动把 node_modules 里的 LowlightRoot 改成 Root 才可以。这个问题有点怪。

2021-12-19 更: 十几天之前 rehype-highlight 官方解决了这个问题,并发布了新版本。 将版本从 5.0.0 升级为 5.0.1 之后解决

目录功能

之前的做法是用 react-markdown 的 components props 在处理标题 tag 的同时向外传输各级标题信息,之前遇到的问题现在也遇到了,调试时回调函数会执行两次,非常奇怪。

这次换了一个思路,分成两步执行:

  1. 在 markdown 文本更新后,逐行读取文本,将 '#{1,6}' 开头的字符串认为是标题信息,逐渐构建一棵目录树结构
  2. 然后再 通过 Selector API 获取所有标题 tag 元素,与之前目录树的结构通过 id 一一对应
typescript
useEffect(() => {
  setCatalogList(getHeadingInfo(markdownText));
  const markdownBody = document.querySelector('.markdown-body');
  if (markdownBody) {
    for (let i = 1; i <= 6; i++) {
      (markdownBody as unknown as Element).querySelectorAll('h' + i).forEach((el, index) => {
        el.id = 'h' + i + '-' + (index + 1);
      });
    }
  }
}, [markdownText]);

其实 id 的标记思路是用的 h + level + 该级别标题出现的次数,这样的话其实并不需构建树结构,直接遍历就行了。构建树是因为最开始想在 id 上体现目录层级结构,后来觉得比较麻烦就放弃了,但是我还是觉得树结构可能后面会用到吧。

Tailwind CSS 预设样式

Tailwind CSS 预设了一些样式,比如列表项默认没有样式:

css
ol,
ul {
  list-style-type: none;
}

这会和我们期望的 markdown 展示效果有所冲突,需要手动添加一些修改。使用 @layer 可以防止样式顺序先后引发的问题。

css
// index.css
@layer base {
    .markdown-body ol,
    .markdown-body ul {
        list-style-type: unset;
    }
}

react-router 版本升级

原本用的 v5,这才没过多久就是 v6 了,而且重要的 API 都发生了改变。匹配也都是 exact 匹配了,而不是原来的前缀匹配。使用体验上还不错。

评论

使用 Waline 做了评论系统,基于 Valine,目测更新频率还不错,而且有很友好的文档。照着文档配合 Vercel 和 LeanCloud 做了一个效果很好的评论系统。

支持 markdown 和微博表情,好看的:

waline test

Vercel 部署

原本一直都是用 Github Page 做静态页面部署,但是最近发现了更为优秀的 Vercel,使用体验和访问速度都可以说有了巨大提升。

在 Vercel 上需要做的事情非常简单,只需关联上 github 仓库就好,build 和 deploy 工作全部是自动完成的。每次仓库的更新都会自动触发部署的更新,极其方便。

另外一个很好的点竟然是自定义域名竟然也可以被生成 SSL,体验很好。

同时 Vercel 通过 rewrite 配置也可以让我们用上 BrowserRouterWhy does react-router not works at vercel?)。之前把网站托管在 github page 上的时候只能被迫用 HashRouter,总觉得有点蹩脚。

代码分割

Vite 配置中有一项 build.chunkSizeWarningLimit,默认值为 500,这意味着编译完成后第三方包大于 500 KB 的时候就会触发 Warning,告知用户需要做一些代码分割之类的工作了。最开始我图省事把这个值改成了 1000,后来装了几个包之后发现 vendor.js 又已经超过 1000 KB 了。所以开始着手开搞 code split。

由于 Vite 支持使用 Rollup 插件,所以在前人的铺垫下这件事做起来并不困难。

首先,rollup-plugin-analyzer 插件可以检测打包时各个 bundle 的大小,给出一个包大小降序排列的统计表:

shell
Rollup File Analysis
-----------------------------
bundle size:    1.281 MB
original size:  1.428 MB
code reduction: 10.25 %
module count:   421

███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
file:            D:/repo/banqinghe.github.io/node_modules/parse5/lib/tokenizer/named-entity-data.js
bundle space:    7.26 %
rendered size:   93.089 KB
original size:   73.717 KB
code reduction:  0 %
dependents:      2
  - D:/repo/banqinghe.github.io/node_modules/parse5/lib/tokenizer/named-entity-data.js?commonjs-proxy
  - D:/repo/banqinghe.github.io/node_modules/parse5/lib/tokenizer/index.js

...

通过这个插件可以找到是哪些包占据了主要的第三方包体积,我现在比较占地方的包主要是:

  • reactreact-dom
  • waline(评论插件)
  • katex(数学公式支持)

减小包体积地主要思路有两个:

  • 引入 CDN
  • React.lazy() 做组件懒加载

代码分割方法也比较好实现,引入 CDN 再做一些配置就好了。这里主要用到了 rollup-plugin-external-globals 插件,可以比较方便地把代码中的模块引用转化为对全局变量的引用。

typescript
rollupOptions: {
  plugins: [
    externalGlobals({
      'react': 'React',
      'react-dom': 'ReactDOM',
      katex: 'katex',
      '@waline/client': 'Waline',
      'highlight.js/lib/core': 'hljs',
    }),
  ],
}

组件懒加载在路由处配置即可,将静态引用组件更改为使用 React.lazy() 引入:

typescript
import PostPage from './PostPage';

const PostPage = React.lazy(() => import('./PostPage'));

同时需要配合 React.Suspense 组件来规定当组件未加载完成时展示的加载页。

一通操作之后终于让每个包的体积都降低到了 500 KB 以下,剩下体积较大的包都来自 parse5,并没有找到 CDN 资源,就先不管了。