科技微讯

用 Markdoc 重构了科技微讯博客

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

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

Markdoc

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

Markdoc 三部曲

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

核心概念

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

如果直接使用 <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 一点。基于这两点,我觉得有必要增加一个专门的笔记栏目。

2022-11-23 更新

@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 个。从插件名称就可以猜到它的作用:

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

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 = `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 以下,当然也是有代价的:

暂无评论