logo科技微讯

第二次重构科技微讯博客

作者:科技微讯
日期:2022-10-12
📜 文章

差不多一年前我对科技微讯博客进行了第一次重构,最大的改变是从 GatsbyJS 迁移到 NextJS。今天我完成了对科技微讯博客的第二次重构,主要变化有:

  • 处理 markdown 博客文章的模块从 react-markdown、rehype-slug、rehype-toc 更换为 markdoc2022-11-23 更新:已弃用 markdoc,改用 unified);
  • 博客文章中的图片不再使用 next/image,而是直接使用 <img scr="">
  • 样式则从 tailwindcss 更换为 Next.js 原生支持的 styled-jsx 和一个全局的 css 文件;
  • UI 有较大变化;
  • 增加笔记栏目;
  • 优化我的写作流程;

这次重构的动机主要有三个:

  • 让博客源码尽可能简单明了,方便后续维护,这样我就可以把主要精力放在写作本身;
  • 对原来的界面有了审美疲劳;
  • 我想增加一个笔记栏目;

Markdoc

Markdoc 是 Stripe 出品的 markdown 处理框架,最初用于 Stripe 内部,被称为文档届顶流的 Stripe 开发文档就是用 Markdoc 驱动。我没看过 Stripe 的开发文档,但看过不少对它的赞美,所以当 Markdoc 宣布开源时,我马上就把“将来重构博客就用它”列入了我的 TODO 清单。

Markdoc 三部曲

Markdoc 的工作流程就是一个三部曲:

  • parse 方法把 md 字符串转换为 astconst ast = Markdoc.parse(markdowm);
  • transform 方法把 ast 转换为 renderable tree,该方法支持传入一个 config 对象用来调整 transform 的具体细节:const tree = Markdoc.transform(ast, config)
  • 最后用 htmlreact render 把 renderable tree 转换为 html 或 jsx:Markdoc.renderers.html()Markdoc.renderers.react()

核心概念

Markdoc 是一个 md 处理框架,同时也是一种 md 文档格式,使用 .mdoc 文件后缀,它支持一些独有的语法,是 CommonMark 的超集。

tag 是 Markdoc 新增的概念,这是 Markdoc 和 CommonMark 的主要区别,也是 Markdoc 的核心概念。

Markdoc 第一步是把 md 文档转换为 ast,我们可以把 ast 理解为一个 node 对象,这个 node 对象有一个 children 数组,数组的每个元素也是一个 node,每个 node 都可以有 children 数组,这样就组成了一个树状结构。

CommonMark 一共定义了二十多个 node type,例如 document、heading、list、paragraph、item 等等。Markdoc 继承了 CommonMark 的 node type 概念,支持所有 CommonMark 的 node type,并允许开发者通过 transform(三部曲的第二部)和 render(三部曲的第三部)函数对 node 进行自定义处理,从而生成样式更丰富或功能更丰富的网页。

比如 code_block 这个种类的的 node(代码块,也叫 fence node),如果不经任何处理直接 render,最后得到的代码块没有高亮、没有底色,阅读体验并不好,你在我的博客看到的代码块之所以这么好看,是因为我用了自定义的 transform 和 render 函数对 fence node 进行了额外加工。

下面这段代码是另一个例子,作用是给 heading node 增加 id 属性,即最后生成的标题从比如 <h1>标题</h1> 变成 <h1 id="标题">标题</h1>,这样做是为后续生成 toc(table of content)做准备。

关于如何使用 transform 或 render 函数对 node 进行额外加工,以及 tag 为什么是 Markdoc 的核心概念,请看代码间的注释:

import { heading } from "./schema/Heading.markdoc";
import * as components from "./components";
import { Tag } from "@markdoc/markdoc";

//定义 generateID 函数,顾名思义是用来生成 id 的,具体来说,
//是传入 heading 的 children 和 attributes 属性,然后获得一个字符串用作 heading 的 id
function generateID(children, attributes) {
  if (attributes.id && typeof attributes.id === "string") {
    return attributes.id;
  }
  return children
    .filter((child) => typeof child === "string")
    .join(" ")
    .replace(/[?]/g, "")
    .replace(/\s+/g, "-")
    .toLowerCase();
}

//前面我说过 transform 接收一个 config 对象用来调节 transform 的细节
//config 对象可设置多个属性,包括 nodes、tags 等,其中 nodes 表示可对这些 node 进行自定义的 transform
const config = {
  nodes: {
    //这里的 heading 表示对标题这个 node 进行自定义的 transform,如果要对代码块进行自定义处理,需要改为 fence
    heading: {
      //children 表示原 heading 节点的 children 数组中,哪些会被传入 transform 函数,没有写在这里的就不传入
      children: ["inline"],
      //attributes 表示原 heading 节点的属性中,哪些属性会被传入 transform 函数进行处理
      attributes: {
        id: { type: String },
        level: { type: Number, required: true, default: 1 },
      },
      //这里也有一个 transform 函数,这个 transform 函数是 heading 这个节点的专属 transform 函数
      transform(node, config) {
        //transformAttributes 也是一个 transform 函数,transform 函数接收 node、config 两个参数,这里也不例外,
        //只是 transformAttributes 因为是 node 的一个方法,在这个 node 调用这个方法其实就是传入了这个 node
        //其实也可以这样获得:node.attributes,因为我前面说过了,每个 node 都有 attributes 属性
        const attributes = node.transformAttributes(config);
        //transformChildren 也是一个 transform 韩素,对 children 进行 transform 返回的是一个一个 tag node
        const children = node.transformChildren(config);
        const id = generateID(children, attributes);
        //这里通过 new Tag() 自定义了一个 tag,然后返回这个自定义的 tag,从而实现了对 heading 进行自定义处理,所以你看,transform 总是返回 tag node
        return new Tag(
          `h${node.attributes["level"]}`,
          { ...attributes, id },
          children
        );
      },
      //这里还可以接收一个 render 属性,表示在 render 阶段把 renderable tree 渲染成什么组件,这样也可以实现前面所说的对输出的内容进行自定义
      //例如把 heading node render 为一个叫 Title 的 React 自定义组件:
      //render: Title
      //和 Markdoc.renderers.react() 接收的 components 对象其实作用类似
    },
  },
};

//把 md 字符串转换为 ast
const ast = Markdoc.parse(doc);
//传入 ast 和 config 对象,把 ast 转换为 renderable tree(可渲染树)
const content = Markdoc.transform(ast, config);
//最后把 renderable 转换为 react 组件,剩下就交给 react 处理了
//其实 components 就是原始 node 和 React 自定义组件的一个一一对应的对象
//The components object specifies a mapping from your tags and nodes to the corresponding React component.
const children = Markdoc.renderers.react(content, React, { components });

所以你看,node、tag 是 Markdoc 的两个关键概念,node 不是 Markdoc 特有的,它是从 CommonMark 继承来的,tag 是 Markdoc 在 CommonMark 的基础上增加的特殊概念,是 Markdoc 最与众不同的特点。

使用 transform 和 render 函数去操控 node 和 tag,开发者可完全控制 md 文档最终渲染出来的样子。

You can use tag and node schemas to completely control the markup that the Markdoc renderer outputs. 出处

在上面的代码注释中,我说 transform 返回的 renderable tree 就是 tag tree,其实 tag 不仅仅出现在 transform 函数返回的 renderable tree,也出现在 parse 返回的 ast 中,即 tag 本身就是一种特殊 node。

在代码注释中我说,config 对象除了可以有 nodes 属性外还可以有 tags 属性,tags 不是用来对 tags 进行自定义渲染的,而是用来注册 tag 的,被注册的 tag 可以直接写在 md 文档中,Markdoc 在 parse 这些文档时,会把这个 tag parse 为 tag node。

比如注册了一个叫 callout 的 tag 后,就可以在 md 文档中写 {% callout type="check" %}使用 tag 的例子{% /callout %},这样就用了这个 tag。关于 tag 的语法请看文档,这里不多说。

写 tag 需要使用特殊的语法,这不是我需要的。我个人偏向于使用通用的 md 语法撰写文章,通用语法兼容性更好,可避免被某个框架或平台绑定,后续不爽 Markdoc 了可以随时换,例如可以换成 mdx。Next.js 官方也推荐用 mdx,并开发了一款名为 @next/mdx 的插件,该插件让 Next.js 支持把 .md 后缀的文件识别为网页路径,即可把 md 文件放在 pages 目录下访问。

@markdoc/next.js

Markdoc 也为 Next.js 开发了一款类似的插件 @markdoc/next.js,用了这个插件就可以把 md 文件放在 pages 目录下直接访问,因为它内置了前面提到的 parse、transform、render 三部曲。

但三部曲只是把 markdown 变成了 react 组件,并没有添加好看的样式,所以样式还要开发者自己写。另外,前面提到 transform 步骤支持传入 config 对象以调整 transform 的细节,这个 config 也需要开发者根据实际需求撰写代码,config 可以统一写在 markdoc 文件夹中。

Markdoc 为 Next.js 准备了一个简单例子,如果你想参考更完整的项目,可查阅 Markdoc 开发文档的源码,因为文档本身也是用 Next.js 写。

我这次重构博客就从这两个 repo 复制了很多代码,包括整个全局 css 样式(略有调整)、文章的 TOC(包括为每个标题增加 id 属性把标题渲染为 TOC 组件),以及代码块

弃用 next/image

弃用 next/image 意味着放弃了它的 lazy loading、placeholder 等优良特性,之所以这样做,是因为:

  • 创建基于 next/image 的图片组件后,我不知道怎么计算图片的展示尺寸
  • 我文章的图片并不多,作为一个访问量极少的个人博客,我不必在乎所谓的最佳实践;
  • 正如开头所说的,我想让代码尽量简单明了,方便后续维护。

重新使用 next/image

2022-11-23 更新:最后弃用了 Markdoc 并改用了 unified,所以最后还是没有继续使用 next/image 组件,但通过 unified 实现了图片的懒加载、加载时显示 placeholder、提前确定图片的长宽以避免 layout shift,请看文章最后一部份

如果直接使用 <img> 渲染图片,打开这篇文章会一次性请求所有图片,这可能会导致图片加载速度变慢,同时会出现明显的 layout shift,还是比较影响体验的,所以我决定还是用回 next/image

markdoc 似乎不能在 build 时获取文章内图片的长宽,而 next/image 组件需要传入长宽值,所以先给它一个初始长宽,当图片加载完成(即 onLoadingComplete) 后再重新设置图片的长宽。

//components/Image.js
import NextImage from "next/image";
import React from "react";

export default function Image({ src, alt }) {
  // 只有当图片尺寸不受 container 限制时,naturalWidth、naturalHeight 才是真实的值,所以要设置一个较大的初始 width,因为我希望确认了真实长宽之后再展示图片,所以高度设置为较小值
  // naturalWidth、naturalHeight 都是图片像素值除以 dpi 后的结果
  const [size, setSize] = React.useState({ width: 1000, height: 100 });
  return (
    //不能用 div,因为:https://github.com/markdoc/markdoc/issues/113
    <span className="image-container">
      <NextImage
        src={src}
        alt={alt}
        width={size.width}
        height={size.height}
        onLoadingComplete={(target) => {
          setSize({
            width: target.naturalWidth,
            height: target.naturalHeight,
          });
        }}
      />
      <style jsx>{`
        .image-container {
          display: flex;
          align-items: center;
          justify-content: center;
          margin: 1rem 0;
        }
      `}</style>
    </span>
  );
}

但这样并不能完全避免 layout shift,这个问题似乎可以用 next-imagenext-optimized-images 解决,但为了让代码保持简洁以方便后续维护,我不想再引入更多依赖,所以我决定在 build 之前先拿到所有文章的所有图片的信息,保存在一个 json 文件里,build 的时候直接读取这个文件即可。

既然是在 build 之前获取图片信息,那可以获取的信息就比较丰富了,除了最基本的长宽,我们不妨再获取图片的 blurDataURL,后续传入 next/image 组件,这样图片在加载完成前会先展示一张模糊的占位图,比空白的占位图体验更好。

//utils/getImage.js
const { getPlaiceholder } = require("plaiceholder");
const fs = require("fs");

//本函数在 prebuild 执行,因为我不知道 markdoc 怎么动态获取每一篇文章的尺寸和 placeholder,
//所以干脆一次性获取所有图片的长宽和 placeholder,保存在一个 json 文件里供后续使用
async function getImage() {
  //先获取所有文章的所有图片的路径
  const images = [{ path: "" }];
  //然后通过 getPlaiceholder 获取每一张图片的尺寸和占位图
  const pArray = images.map(async (image) => {
    const src = image.path;
    return getPlaiceholder(src).then((res) => {
      return {
        src: src,
        width: res.img.width,
        height: res.img.height,
        base64: res.base64,
      };
    });
  });
  const result = await Promise.all(pArray);
  fs.writeFileSync("./images.json", JSON.stringify(result));
}

getImage();

弃用 tailwindcss

我的博客很简单,要写的 css 其实很少,没必要用 tailwindcss,而且 Markdoc 文档源码就有一套蛮符合我个人审美的全局样式,直接复制黏贴再略微调整就可以用。

关于笔记栏目

过去两年多我使用一种名为 Zettelkasten 的方法记笔记,到目前为止我记录了八百多条笔记,都保存在 onedrive 中,后续我希望把这些笔记发布在博客。

大多数笔记都是简短的文字内容,并不适合作为文章展示在博客首页。虽然我已经实践 Zettelkasten 一段时间,但并不够 serious,把笔记放上网是希望自己可以更 serious 一点。基于这两点,我觉得有必要增加一个专门的笔记栏目。

弃用 Markdoc

2022-11-23 更新:在短暂使用 Markdoc 一段时间后,我决定弃用 Markdoc。@markdoc/markdoc@0.2.1 的 bundle size 有点大,甚至略大于 react-dom@18.2.0,为了降低页面的 bundle size 以提高网页打开速度,科技微讯博客已经弃用 markdoc,改用 unified 的多个插件把 md 直接转换为 html,最后通过 dangerouslySetInnerHTML 把 html 放入 React 组件中展示出来。

关键是怎样把 md 文档转换为 html,我在第一次博客重构这篇文章中有解释过 unified 及其插件生态,这一次改版我一共用了七款第三方 unified 插件,其中 remark 插件 3 个,rehype 插件 4 个。从插件名称就可以猜到它的作用:

  • remark-parse:parse markdown,即把 md 文档转换为 markdown 节点树,后续操作将会在节点树上进行,parse 通常是整个流程的第一步;
  • remark-gfm:用了这个插件后,你的 md 文档就会被理解为 GitHub Flavored Markdown,gfm 支持一些 markdown 原本不支持的语法,例如 ~~文字~~,如果不用这个插件,~~文字~~ 中的 ~~ 会被当作普通字符串,用了这个插件后 ~~文字~~ 会被理解为删除:文字
  • remark-rehype:这个插件可以把 md 节点树转换为 html 节点树,即 remark -> rehype,后续操作将会在 html 节点树上进行,所以后续的插件都以 rehype 开头;
  • rehype-slug:这个插件不太能顾名思义,它的作用是给 html 文档中的 h1h2 等标题添加 id 属性;
  • rehype-toc:用来给文章添加 toc,即 table of content,即文章目录,因为 rehype-slug 已经给标题增加了 id 属性,所以点击目录可跳转到文章相应内容;
  • rehype-prism-plus:通过 prism 对文章中的代码块进行 token 化,并增加相应 className,这样我们就可以直接复制 prism 的主题样式到我们的项目,从而实现代码高亮;
  • rehype-stringify:前面说过 remark-parse 把 md 文档转换为节点树,而这个就是把 html 节点树转换为 html 文档,有头有尾。

通过 dangerouslySetInnerHTML 渲染文章意味着我不能再用 next/image 组件渲染文章中的图片,所以我必须自己写一个 unified 插件用来优化图片的加载和展示,我希望实现的功能有以下三个:

  • lazy loading
  • 不能有 layout shift
  • 在图片加载完成前显示一个 placeholder

unist-util-visit-parents 可以用来读取 rehype 节点树,我拿到 img 节点后,给它增加了 styleloadingheightwidth 四个属性,于是就实现了以上三个功能。

以下是代码:

import * as fs from "fs";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkHype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeToc from "rehype-toc";
import rehypePrism from "rehype-prism-plus";
import rehypeStringify from "rehype-stringify";
import { visitParents } from "unist-util-visit-parents";
import images from "../utils/images.json";

const filePath = `./_posts/${type}/${slug}.md`;
const fileContent = fs.readFileSync(filePath);

const file = await unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkHype)
  .use(rehypePrism)
  .use(rehypeSlug)
  .use(rehypeToc, {
    headings: ["h2", "h3"],
    customizeTOC: (toc: any) => {
      //检查有没有 toc,如果没有就增加 toc-hide 属性
      let isEmpty = true;
      for (let child of toc.children) {
        if (child.children.length > 0) {
          isEmpty = false;
          break;
        }
      }
      if (isEmpty) {
        toc.properties.className = `${toc.properties.className} toc-hide`;
      }
      return toc;
    },
  })
  .use(() => {
    return (tree) => {
      visitParents(tree, "element", (node: any, ancestors) => {
        if (node.tagName === "img") {
          const _src = `/${type}/${slug}/${node.properties.src}`;
          const image = images.find((item) => item.src === _src);
          if (!image) {
            const log = `这张图片拿不到 placeholder 和尺寸:${_src}`;
            console.log(`\x1b[31m${log}\x1b[0m`);
            return;
          }
          const { width, height, base64 } = image;
          const style = `font-size: 0px; max-width: 100%; height: auto; display: block; margin: auto; background-image: url(${base64}); background-size: cover;`;
          node.properties.style = style;
          node.properties.loading = `lazy`;
          //增加 height 和 width 可避免 layout shift
          node.properties.height = height;
          node.properties.width = width;
        }
      });
    };
  })
  .use(rehypeStringify)
  .process(fileContent);

const html = String(file);

和原来使用 markdoc 相比,我的文章网页体积从原来的 150KB 缩减至 90KB 以下,当然也是有代价的:

  • 因为不能用 next/image,所以文章中的图片无论在多大的屏幕上展示,都是请求原始尺寸和格式,而 next/image 可以给图片生成不同的版本,小屏幕提供小尺寸的版本,对于支持 webp 格式的浏览器,还会提供 webp 格式的版本,可明显降低图片体积,提高图片加载速度;
  • 用 markdoc 时,博客文章直接放在 pages 目录下,在本地电脑开启 dev 模式写文章时,修改文章可实时展示在浏览器中,现在我需要把文章都挪出 pages 目录,修改文章后要刷新页面才能看到更改。
donation赞赏
thumbsup0
thumbsdown0
暂无评论