使用 Next.js + Hexo 重构我的博客

使用 Next.js + Hexo 重构我的博客

技术向约 10 千字

在咕咕了一整年、只发布了三篇文章(其中两篇还是译文)之后,我决定还是稍微花一点时间好好折腾一下自己的博客,以 React 作为抓手,通过 Next.js 和 Hexo 深度共建,对标 Gatsby,打通静态 HTML 与用户交互之间的垂直领域屏障,实现多维矩阵闭环,为个人博客赋能(咳咳咳),然后水出 2022 年第一篇文章(逃)

技术选型:Gatsby vs Next.js

我使用 Hexo 驱动博客已经三年之久了,之前写过一个 开源的 Hexo 主题,后来还成为了 Hexo 的 Core Team Member、为 Hexo 贡献了不少代码。

Hexo 是一个诞生于 2012 年的、由 Node.js 驱动的静态站点生成器(Static Site Generator),对标 Jekyll 和 Octopress;受限制于其时代背景、Hexo 只是一个基于模板拼接静态字符串的工具。在构建更强大、更现代的网站时,Hexo 并不能提供太多帮助。

在选择框架的时候,我希望选择的框架能够实现以下功能:

  • 静态导出:静态页面无需后端,易于部署和缓存
  • 无刷新页面导航:不仅仅是 PJAX 的刷新不重载,我希望页面切换时,只需要加载新页面需要但尚未加载的资源(即复用现有的资源),同时浏览器能够差分更新 DOM、节省不必要的重复渲染开销。
  • 页面预载:我希望网站内链接可以在 进入用户 Viewport / 用户 Mouseover 时能够预加载,配合无刷新站内导航实现接近 Native 级别的页面切换性能。

由于我的技术栈不包括 Vue,因此 Nuxt.js、VuePress 和 VitePress 率先出局;常见 React 元框架(The Framework of Framework a.k.a Meta Framework)包括 Remix、Gatsby 和 Next.js。其中 Remix 依赖 Serverless 平台、不支持静态导出和 optimistic 缓存,也一并出局。

关于 Gatsby 和 Next.js 之间的对比和区别,相关讨论数不胜数、无需赘述。对我来说,两者最重要的区别是 Gatsby 的 Data Source 必须是 GraphQL Based Query,而 Next.js 的 Data Source 是任意的:开发者只需要在 pages 中命名导出 getStaticPropsgetServerSidePropsgetStaticPathsgetInitialProps 供 Next.js 在构建时调用,Next.js 本身并不关心数据来源是 GraphQL 还是 RESTful,亦或者是本地文件系统。因此,我可以继续使用 Hexo 作为我的 Content Management System、管理文章数据,让 Next.js(React)专注于 UI/UX 的构建上。

内容管理:从 Hexo 到 Next.js

从 Hexo 获取数据

Hexo 的核心是一个 JSON based NoSQL 数据库 warehouse。除了可以通过 CLI 调用以外,Hexo 还暴露了一系列 JS API,直接操作 warehouse 来对文章数据 CRUD。

首先初始化 Hexo 实例:

import Hexo from 'hexo';

let __SECRET_HEXO_INSTANCE__: Hexo | null = null;

const initHexo = async () => {
  // 复用已初始化的 Hexo 实例
  if (__SECRET_HEXO_INSTANCE__) {
    return __SECRET_HEXO_INSTANCE__;
  }
  // 使用指定参数实例化一个 Hexo 实例
  const hexo = new Hexo(process.cwd(), {
    silent: true,
  });
  // 初始化 Hexo 实例(加载插件、加载配置文件)
  await hexo.init();
  // 载入 Hexo 目录(文章、草稿、站点数据、主题)
  await hexo.load();

  __SECRET_HEXO_INSTANCE__ = hexo;
  return hexo;
};

初始化 Hexo 实例以后,即可从 Hexo 中获取数据了:

import { basename } from 'path';
import { url_for } from 'hexo-util';

// 获取所有文章的路径,供 Next.js 的 getStaticPaths 构建路径索引时使用
export const async fetchAllPostsPaths = () => {
  const hexo = await initHexo();
  const posts = hexo.database.model('Post').find({}).sort('-date');

  return posts.map(post => basename(post.slug));
}

// 获取指定文章的数据,供 Next.js 的 getStaticProps 使用
export const findPostBySlug = async (slug: string) => {
  const hexo = await initHexo();
  const urlFor = url_for.bind(hexo);

  const post = hexo.database.model('Post').findOne({ path: `post/${slug}/` });

  // 只返回需要用到的 prop
  return {
    title: post.title;
    date: post.date;
    updated: post.updated;
    content: post.content;
    permalink: post.permalink;
      prev: post.prev ? {
      title: post.prev.title ?? '',
      url: urlFor(post.prev.path)
    } : null,
    next: post.next ? {
      title: post.next.title ?? '',
      url: urlFor(post.next.path)
    } : null,
  }
};
export const getStaticPaths = async () => {
  const paths = await fetchAllPostsPaths();
  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps = async ({ params }) => {
  const post = await findPostBySlug(params.slug);
  return {
    props: {
      post
    }
  }
}

如果需要返回所有包含指定标签的文章,通过构建 query 索引 Hexo 数据库即可:

export const getPostsFromTag => async (tag: string) => {
  const hexo = await initHexo();
  // 根据标签的 name 在数据库中寻找对应的 Tag 对象
  const tag = hexo.database.model('Tag').findOne({ name: tag }, { lean: true }));
  // 在 Post - Tag 交叉索引数据库中寻找所有包含当前 Tag 的 _id 的文章
  const postIds = hexo.database.model('PostTag').find({ tag_id: tag._id }).map(item => item.post_id);
  // 利用 $in query 寻找 postIds 中的所有文章、并按照日期排序
  const posts = hexo.database.model('Post').find({ _id: { $in: postIds } }).sort('-date');
};

在构建文章列表的时候,需要生成一个形状类似于 { posts: Post[], path: string, index: number, prev: { path: string, index: number }, next: { path: string, index: number } }[] 分页数组,也可以使用 Hexo 的插件:

import hexoIndexGenerator from 'hexo-generator-index/lib/generator';
export const buildIndexPaginations = async () => {
  const hexo = await initHexo();
  const data = hexoIndexGenerator.call(hexo, hexo.locals.toObject());
};

Next.js 多线程与 Hexo 数据持久化

为了加快构建速度,Next.js 默认使用 worker_thread 进行多线程构建,其中 getStaticPropsgetStaticPaths 等函数都会在不同的线程中运行。Next.js 并没有实现线程间共享数据的 API,目前官方推荐的做法是将需要共享的数据写入文件系统进行缓存。而 Hexo 也内置了基于文件系统(db.json)的数据持久化接口 database.save()。我们可以在首次初始化 Hexo 实例、从本地 Hexo 目录加载数据后将 db.json 写入文件系统,后续 Next.js 其余线程再次创建 Hexo 实例时,Hexo 会自动优先从 db.json 获取数据、不再扫描本地目录。

const initHexo = async () => {
  if (__SECRET_HEXO_INSTANCE__) {
    return __SECRET_HEXO_INSTANCE__;
  }
  const hexo = new Hexo(process.cwd(), {
    silent: true,
  });

  const dbPath = join(hexo.base_dir, 'db.json');

  if (process.env.NODE_ENV !== 'production') {
    // 当 不属于生产构建、且本地存在 db.json 时,删除 db.json、确保开发时可以预览实时最新的数据
    if (fs.existsSync(dbPath)) {
      await fs.promises.unlink(dbPath);
    }
  }

  await hexo.init();
  await hexo.load();

  if (hexo.env.init && hexo._dbLoaded) {
    if (!fs.existsSync(dbPath)) {
      // 只有在本地不存在 db.json、且在生产构建时,将数据库写入文件系统
      if (process.env.NODE_ENV === 'production') {
        await hexo.database.save();
      }
    }
  }

  __SECRET_HEXO_INSTANCE__ = hexo;
  return hexo;
};

本地预览 Hexo 草稿

Hexo 支持 草稿写作 功能:正常构建时默认忽略草稿,只包括「已发布」的文章。在本地预览草稿时,通过 CLI 的 --draft 参数即可将草稿包含在构建当中。draft 参数也可以在实例化 Hexo 时传入:

const initHexo = async () => {
  if (__SECRET_HEXO_INSTANCE__) {
    return __SECRET_HEXO_INSTANCE__;
  }
  const hexo = new Hexo(process.cwd(), {
    silent: true,
    // 在 next dev 时包含草稿
    draft: process.env.NODE_ENV !== 'production'
  });

  const dbPath = join(hexo.base_dir, 'db.json');
  if (process.env.NODE_ENV !== 'production') {
    if (fs.existsSync(dbPath)) {
      await fs.promises.unlink(dbPath);
    }
  }

  await hexo.init();
  await hexo.load();

  if (hexo.env.init && hexo._dbLoaded) {
    if (!fs.existsSync(dbPath)) {
      if (process.env.NODE_ENV === 'production') {
        await hexo.database.save();
      }
    }
  }

  __SECRET_HEXO_INSTANCE__ = hexo;
  return hexo;
};

使用 Hexo 为 Next.js 添加 RSS 和 Sitemap

Next.js 并没有内置 RSS 和 Sitemap 支持。不过由于我的内容是通过 Hexo 管理,因此可以通过 Hexo 的 API 和 Hexo 的插件生成 RSS 和 Sitemap:

import { promises as fsPromise } from 'fs';
import feedGenerator from 'hexo-feed-generator/lib/generator';
import betterSitemapGenerator from 'hexo-generator-better-sitemap/lib/generator';

const hexo = await initHexo();

const atom1 = feedGenerator.call(hexo, hexo.locals.toObject(), 'atom', 'atom.xml');
const rss2 = feedGenerator.call(hexo, hexo.locals.toObject(), 'rss', 'rss.xml');
const sitemaps = betterSitemapGenerator.call(hexo, hexo.locals.toObject());

await Promise.all(
  [atom, rss2, ...sitemaps].map(
    async ({ data, path }) => fsPromises.writeFile(path, data, 'utf8')
  )
);

Atomic CSS-in-JS:style9

当「关注点分离」还是主流思想时,传统大型项目使用的 CSS 方法论如 BEM、OOCSS 都曾大行其道,Bootstrap、Foundation、Bulma 等 CSS Framework 便是这种潮流下的产物。而最近,Utility First 的 CSS 概念脱颖而出、逐渐受到社区的关注,其中最典型的便是 Tailwind CSS,通过复用 Utility 规则、以及在编译时只包含用到的 CSS、使得最终 CSS 产物大幅减小。

在 Utility First 上更进一步,就到了 Atomic CSS。在 Atomic CSS 中,每一个 CSS 类都只有一条独立的 CSS 规则。相比于传统 CSS 方法论、CSS 产物大小与项目的复杂程度和组件数量线性正相关;而使用了 Utility First 或 Atomic CSS 以后,随着组件数量逐渐增加、能复用的 CSS 规则越来越多、最终 CSS 产物大小与项目复杂程度呈对数关系:

atomic-css-in-js

Atomic CSS-in-JS 实现有运行时(Runtime)和预编译(Pre-Compile)两种。运行时实现的优势在于可以动态生成样式,更易于组合样式;缺点在于 Vendor Prefix 等处理需要在 Runtime 时执行、Bundle 中必须携带相关依赖、体积难免变大,典型的库有 Uber 的 Styletron(驱动了 Uber 的官网和 H5)和沃尔沃汽车前技术主管的 Fela(驱动了沃尔沃汽车官网,Cloudflare Dashboard 和 Medium)。预编译实现的优势在于无需将 Vendor Prefixer 等依赖打包 ship 给客户端,改善了性能;缺点在于难以实现动态样式组合(高度依赖对代码的静态分析),典型的库有 Atlassian 的 Compiled CSS-in-JS 和 Facebook 尚未开源的 StyleX。

在使用 Atomic CSS 重写博客之前,我需要 ship 25.2 KiB 的 CSS 才能在浏览器中达成首次渲染(First Paint)、之后还需要异步 ship 非关键 CSS;在使用 Atomic CSS 重写以后,我只需要 17.5 KiB 的 CSS 就能够覆盖博客所有页面的样式。

关于 CSS-in-JS 和 Atomic CSS-in-JS,我计划会专门写一篇文章介绍。

原子化状态管理:jotai

React 的哲学是「The Data Flows Down」,即「单向数据流」。状态(State)属于组件,而一个组件的状态只能影响其「子组件」。简单地,React 组件的状态是单向往下的,子组件需要修改父组件的状态,必须通过状态提升(即子组件必须是「受控」的);多个组件需要共享一个状态时,需要将状态提升到公共父组件。

当 React Tree 越来越深时,逐级向上提升状态也会越来越繁琐:

const IncrementButton = (props: { onClick: () => void }) => (<button onClick={props.onClick}>+</button>);
const DecrementButton = (props: { onClick: () => void }) => (<button onClick={props.onClick}>-</button>);

const Control = (props: { value: number, onChange: (value: number) => void }) => {
  const handleInputChange = (event: React.InputEvent<HTMLInputElement>) => props.onChange(Number(event.currentTarget.value));
  const handleIncrement = () => props.onChange(props.value + 1);
  const handleDecrement = () => props.onChange(props.value - 1);

  return (
    <Box>
      <IncrementButton onClick={handleIncrement} />
      <DecrementButton onClick={handleDecrement} />
    </Box>
  )
};

const Count = (props: { value: number }) => (<div>{props.value}</div>);

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleChange = (value) => {
    if (Number.isInteger(value)) setCount(value);
  };

  return (
    <div>
      <Count value={count} />
      <Control value={count} onChange={handleChange} />
    </div>
  );
};

以如上的计数组件为例,为了能在 <Count /> 组件中展示计数、由 <Control /> 组件控制计数,我们不得不将状态提升到公共父组件 <Counter /> 中;而 <IncrementButton /><DecrementButton /> 需要通过 props.onClick 将状态提升到 <Control /> 中;而 <Control /> 组件又需要通过 props.onChange 将状态提升到 <Counter /> 中;为了实现数字递增和递减,我们将状态 count 通过 value prop 分别传递给了 <Control />、又通过 handleIncrementhandleDecrement 传染到了 <IncrementButton /><DecrementButton />,导致整个组件的每一个子组件都会随着 value 改变而重新渲染、无法被 memo 优化。

当然,通过 useReducer 将递增和递减改为 reducer 中的 incrementdecrement 两个 action,然后只需要将不变的 dispatcher 通过 prop 向下传给 <Control />,使得 <IncrementButton /><DecrementButton /> 可被包裹在 memo 中,从而优化整个组件。是否有更加直观的状态管理和传递方式呢?

jotai 是 Daishi Kato 开发的一个「原始、灵活」的、基于原子的 React 状态管理库,在 React Tree 中任意位置的组件都能通过 useAtom Hook 共享一个 atom 的状态、且 API 和 React 的 useState 非常相似,不仅改善开发体验、还降低了学习成本。和 React Context 一改变、所有子组件都要更新不同,只有 subscribe 了 jotai atom 的组件会在 atom 更新时重新渲染。除此以外,jotai 还通过 useReducer 分离状态更新与组件更新,避免了更多潜在的额外渲染(参见 React Hook Cheat Mode)。除此以外,jotai 还提供了对 derived state 和 derived async state 的支持。

nanostores 是由 PostCSS 作者开发的另一个基于原子的、可用于 React、Vue、Angular、Vanilla、Solid。和 jotai 原理相同,nanostores 也是由外部管理状态;相比 jotai(gzip 后 7KiB 左右),nanostores 更加轻便(gzip 后大小不过 199 Byte),但是并没有针对 React 优化、不能避免潜在的额外渲染。

使用 jotai 重写上述计数组件:

const count = atom(0);

const IncrementButton = () => {
  const [, setCount] = useAtom(count);
  return (
    <button onClick={() => setCount(count => count + 1)}>+</button>
  );
};

const DecrementButton = () => {
  const [, setCount] = useAtom(count);
  return (
    <button onClick={() => setCount(count => count - 1)}>+</button>
  );
};

const Control = () => {
  return (
    <Box>
      <IncrementButton />
      <DecrementButton />
    </Box>
  )
};

const Count = () => {
  const [count] = useAtom(count);
  return (<div>{count}</div>)
};

const Counter = () => {
  return (
    <div>
      <Count />
      <Control />
    </div>
  );
};

使用 jotai 在 React 外部管理状态 count,现在所有组件都不再通过 prop 向下传递数据、通过 prop 回调提升状态,我们成功去掉了所有组件的 prop;因此,整个 <Control /> 都可以被包裹在 memo 中;而 jotai 会保证在 count 更新时、只重新渲染 <Count />

在我的博客中,我使用 jotai 来处理 React Tree 的不同深度的组件之间共享状态,例如在 Root - Navbar - DarkModeDropDownRoot - NextHead 中共享深色模式的状态;用 Root - Fab - FabToC 控制 Root - Left Cols - ToC 的 Modal 组件在移动端是否显示。

为网站添加用户友好的深色模式支持

新的深色模式 UX

过去,我在 「你好黑暗,我的老朋友 —— 为网站添加用户友好的深色模式支持」一文之中介绍了什么是深色模式,一些常见的实现深色模式的方法,以及我的博客当时是如何实现深色模式的。简而言之,当时我的博客的深色模式开关需要解决的问题是:

  • 用户可以通过开关手动切换显示模式
  • 网站也可以通过 Media Query、跟随用户系统的偏好设置来自动切换模式
  • 如果用户手动切换模式、且网站将用户的选择永远记录在 localStorage 之中,那么用户将来就只能通过手动切换模式、不再能跟随操作系统的偏好设置自动切换

绝大部分国内网站的深色模式的 UX 体验就是如此差劲:例如,V2EX 的深色模式必须通过手动切换、不能跟随操作系统的偏好设置自动切换;新浪微博的深色模式只有用户从未手动切换过模式时能够跟随操作系统的偏好设置自动切换,一旦用户手动切换深色模式、那么就不能再跟随操作系统的偏好设置自动切换。

因此,当时我的博客的解决方案是:

  • 当网站跟随操作系统的偏好设置、处于浅色模式下时,用户通过点击按钮切换到深色模式
  • 当操作系统的偏好设置也为深色模式时,网站自动忘记 localStorage 中记录的模式、恢复跟随操作系统偏好设置自动切换
  • 因此当操作系统的偏好设置恢复浅色模式后,网站会自动恢复浅色模式
  • 反之亦然:当网站处于深色模式下时,用户可以手动切换到浅色模式;而当用户的操作系统偏好浅色模式时,网站也会恢复到自动切换模式
  • 简而言之,当网站记忆的用户的手动模式与操作系统的偏好设置一致时,网站会忘记用户设置的手动模式、恢复自动模式

虽然解决了用户手动设置后不能恢复自动切换,但是这个方案的 UX 并不好:

  • 当用户手动模式被忘记时,在 UI 上没有任何提示
  • 用户可能并不希望网页恢复到自动模式

为了同时解决「能够在手动模式与自动模式间切换」和「给予用户更大自主权」的问题,我开始参考大部分网站的解决方案:

facebook-darkmodetailwind-darkmodetypescript-darkmode

上面分别是 Facebook、Tailwind CSS 官网、TypeScript 文档的深色模式设置。它们的共同点是提供了三种选项「总是浅色」「总是深色」「跟随系统」,同时尊重用户的手动设置和支持跟随操作系统偏好设置自动切换。当我重构我的博客时,我也使用了相同的方案:

myblog-darkmode

深色模式下拉菜单的 UX 细节

仔细观察深色模式下拉菜单的话,可以发现一个细节:

darkmode-menu
  • 当深色模式下拉菜单首次打开以后,当前生效的选项会高亮、字体加粗、使用强调色
  • 当光标移动时,当前光标悬浮的选项会高亮,当前生效选项的字体仍然加粗和使用强调色
  • 当光标离开菜单后,没有选项被高亮、当前生效选项的字体仍然加粗和使用强调色

由于 DOM 没有光标悬浮事件,因此我们需要一个 state 配合 onMouseEnteronMouseLeave 事件跟踪当前光标悬浮的选项,控制是否高亮;另外一个包含当前生效选项的 state,控制字体是否加粗和使用强调色。

const darkModeItem = [
  ['auto', '跟随系统'],
  ['dark', '总是深色'],
  ['light', '总是浅色']
] as const;

const DarkModeDropdownMenu = () => {
  const [themeValue, setThemeValue] = useAtom(themeAtom);
  // 当前悬浮 state 的初始值取当前生效的选项
  const [hovered, setHovered] = useState(themeValue);

  const handleClick = useCallback(value => () => {
    setThemeValue(value);
  }, [setThemeValue]);
  const handleMouseEnter = useCallback(key => () => {
    setHovered(key);
  }, []);
  const handleMouseLeave = useCallback(() => {
    setHovered(null);
  }, []);

  return (
    <div className={styles('menuBody')}>
      {
        darkModeItem.map(([key, text]) => (
          <DropdownItem
            key={key}
            // 当前生效的选项、控制 <DropdownItem /> 的字体
            active={themeValue === key}
            // 当前光标悬浮的选项、控制 <DropdownItem /> 的高亮
            hovered={hovered === key}
            onMouseEnter={handleMouseEnter(key)}
            onMouseLeave={handleMouseLeave}
            onClick={handleClick(key)}
          >
            {text}
          </DropdownItem>
        ))
      }
    </div>
  )
}

使用 React 为静态 Markdown 添加动态交互

当使用 Hexo 时,我只需要将 Markdown 编译为静态的 HTML,然后通过编写额外的 JS、调用 DOM 和 Web API 来为静态页面添加动态交互,比如使用 medium-zoom 库模仿 Medium 和知乎专栏的文中图片点击放大。使用 Next.js 以后,这些特性都需要用 React 实现,以最大化发挥 React 的特性和优势。

dangerouslySetInnerHTML

迁移到 React 的同时还要维持原有的行为,最简单的办法是通过 dangerouslySetInnerHTML 将生成的 HTML 直接插入到 DOM 中、然后在 React 的副作用(useEffect)中调用 Web API 和操作 DOM。举个例子,只通过 DOM 和 Web API 实现 Next.js 内部链接无刷新导航的实现:

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';

const shouldCatchLinks = (
  event: MouseEvent,
  targetsList: HTMLAnchorElement[]
): boolean => {
  if (
    // 不是左键点击,可能是在试图强制跳转
    event.button !== 0
    // 使用了修饰键,可能是在试图强制跳转
    || event.altKey
    || event.ctrlKey
    || event.metaKey
    || event.shiftKey
    // 点击事件已经在别处被处理了
    || event.defaultPrevented
  ) return false;

  const anchor = event.target;

  if (!(anchor instanceof HTMLAnchorElement)) return false;
  if (!targetsList.includes(anchor)) return false;

  // a[download] 应该由浏览器直接处理
  if (anchor.hasAttribute('download') === true) return false;
  // 不存在 href 属性的链接不应该处理
  if (anchor.hasAttribute('href') === false) return false;

  if ((
    // 链接不包含 target 属性
    anchor.hasAttribute('target') === false
    // 在 IE 上,链接的 target 属性一定存在,但可能为 undefined、null 或空字符
    || anchor.target == null
    || ['_self', ''].includes(anchor.target)
    || (anchor.target === '_parent'
      // 链接的 target 是 _parent,但 parent 可能不存在
      && (!anchor.ownerDocument.defaultView.parent
        // 链接的 target 是 _parent,但 parent 和当前页面一致
        || anchor.ownerDocument.defaultView.parent
        === anchor.ownerDocument.defaultView))
    || (anchor.target === '_top'
      // 链接的 target 是 _top,但 top 可能不存在
      && (!anchor.ownerDocument.defaultView.top
      // 链接的 target 是 _top,但 top 和当前页面一致
        || anchor.ownerDocument.defaultView.top
        === anchor.ownerDocument.defaultView))
  ) === false) return false;

  // 目标链接和当前页面的 Protocol 不相同
  if (anchor.protocol !== window.location.protocol) return false;

  // 目标链接和当前页面的 Host 不相同
  if (anchor.host !== window.location.host) return false;
  // IE 会清除动态生成的链接的 host 属性。
  // 除此以外,IE 会将默认端口(80、443)包含在链接的 host 属性中,但是在 window.location 中又不包含,如:
  // http://example.com 的 location.host 是 example.com,但是在链接中的 host 是 example.com:80
  // 在这里我没有做处理,如果需要兼容 IE,需要动态生成一个链接进行判断:
  // const a = document.createElement('a');
  // a.href = window.location.href;
  // if (anchor.host !== a.host) return false;

  if (
    anchor.hash !== ''
    && (
      // 页面路径和当前相同,可能只是 Hash 变化,不处理
      anchor.pathname === window.location.pathname
      // 在 IE 上,动态生成的链接、且 href 属性只包含 Hash 的,pathname 属性为空字符串
      || anchor.pathname === ''
    )
  ) return false;

  return true;
};

const prefetched = new Set<string>();

export const Content = (props: { content: string }) => {
  const postContainerRef = useRef<HTMLElement>(null);
  const router = useRouter();

  // 监听内链跳转是直接操作 DOM,因此需要视为 React 副作用
  useEffect(() => {
    if (postContainerRef.current) {
      const aEls = [...postContainerRef.current.querySelectorAll('a')];

      const handleClick = (e: MouseEvent) => {
        if (shouldCatchLinks(e, aEls)) {
          e.preventDefault();
          router.push((e.target as HTMLAnchorElement).getAttribute('href'));
        }
      };
      // prefetch on hover, just like <Link prefetch={false} />
      const handleMouseEnter = (e: MouseEvent) => {
        if (shouldCatchLinks(e, aEls)) {
          e.preventDefault();
          const href = (e.target as HTMLAnchorElement).getAttribute('href');
          if (!prefetched.has(href)) {
            prefetched.add(href);
            router.prefetch(href);
          }
        }
      };

      aEls.forEach(a => {
        const href = a.getAttribute('href');
        if (href?.startsWith('/')) {
          a.addEventListener('click', handleClick);
          a.addEventListener('mouseenter', handleMouseEnter);
        }
      });

      return () => {
        aEls.forEach(a => {
          a.removeEventListener('click', handleClick);
          a.removeEventListener('mouseenter', handleMouseEnter);
        });
      };
    }
  }, [router]);
};

显而易见的,这种实现非常 dirty、难以维护,且完全没有发挥出 React 的任何特性。

在浏览器中将 Markdown 或 HTML 运行时编译为 React 节点

为了解决类似的问题,React 社区推出了许多解决方案:

  • react-markdown:在浏览器中将原始 Markdown 编译为 React Node
  • HTML2React:在浏览器中将静态 HTML 字符串通过 document.createElement 和 innerHTML 生成 DOM、再将 DOM 转换为 React Node
  • html-to-react:在浏览器中调用 htmlparser2 将静态 HTML 字符串转换为 AST,再将 AST 渲染为 React Node
  • rehype-react:与 html-to-react 类似,在浏览器中先将静态 HTML 字符串转换为 Unified AST,再将 AST 渲染为 React Node
  • @frontity/html2react:也是先使用 himalaya 库将 HTML 字符串转换为 AST、再将 AST 渲染为 React Node

上述解决方案都能够用 React 为静态的 HTML 字符串、Markdown 字符串赋能,但是都需要在运行时对 HTML、Markdown 进行 Parse 与 Render,不仅导致 bundle 体积变大,同时也给浏览器造成了一定的性能压力。如果要改善客户端的性能,至少需要将 Parse 静态 HTML 的环节交给服务端处理、让运行时只负责 Render,也就是预编译。MDX 就是这种思路的一个实现——

预编译 MDX

在 Next.js 中有两种使用 MDX 的方法:

  • 将 MDX 作为 Next.js 的页面源文件处理,Next.js 会使用 next/mdx Webpack loader 编译
  • 将 MDX 通过外部编译器进行预编译、序列化后,将编译结果喂给 Next.js 的 getStaticProps,如 next-mdx-remotemdx-bundler

上述两种方法都在服务端完成解析和编译环节,但是也有各自的缺陷:

  • Next.js 内置的 MDX 将每一个 MDX 文件作为一个单独的页面处理,不能够自定义和复用内容之外的 Layout
  • next-mdx-remotemdx-bundler 将 MDX 预编译并序列化,在前端渲染时需要使用 evalnew FunctionReflect.construct

在服务端预生成 HTML AST,在客户端将 HTML AST 渲染为 React 节点

之前在使用 Hexo 时,我已经将 PostHTML 作为 Markdown 静态编译为 HTML 后的 After Processor。因此,我最终采用的方法是将 PostHTML 的 AST 直接喂给 Next.js 的 getStaticProps。同时依照 posthtml-render 实现一个将 PostHTML AST 渲染为 React Node 的方法:

type HtmlTagReplaceReact = {
  [TagName in keyof JSX.IntrinsicElements]?: keyof JSX.IntrinsicElements | React.ComponentType<ComponentPropsWithoutRef<TagName>>;
};

const SINGLE_TAGS = new Set([
  'area',
  'base',
  'br',
  'col',
  'command',
  'embed',
  'hr',
  'img',
  'input',
  'keygen',
  'link',
  'menuitem',
  'meta',
  'param',
  'source',
  'track',
  'wbr'
]);

let totalIndex = 0;

const isFalsyNode = (node: PostHTMLNode | PostHTMLNode[]): boolean => {
  if (
    node == null
    || node === ''
    || Number.isNaN(node)
  ) {
    return true;
  }

  return false;
};

const posthtmlToReact = (tree: PostHTML.Node[] | PostHTML.Node[][], components: HtmlTagReplaceReact = {}, level = 0): React.ReactNode[] => {
  const treeLen = tree.length;
  if (treeLen === 0) return [];

  totalIndex = totalIndex + 1;
  const result: React.ReactNode[] = [];

  for (let i = 0; i < treeLen; i++) {
    const node = tree[i];

    if (isFalsyNode(node)) {
      continue;
    }

    if (Array.isArray(node)) {
      if (node.length !== 0) {
        result.push(...posthtmlToReact(node, components, level + 1));
      }
      continue;
    }

    if (typeof node === 'number' || typeof node === 'string') {
      result.push(node);
      continue;
    }

    if (!Array.isArray(node.content)) {
      if (isFalsyNode(node.content)) {
        node.content = [];
      } else {
        node.content = [node.content];
      }
    }

    if (!node.tag) {
      result.push(...posthtmlToReact(node.content, components, level + 1));
      continue;
    }

    const tag = typeof node.tag === 'string' ? node.tag : 'div';
    const compProps = node.attrs ?? {};
    const Comp = components[tag] ? components[tag] : tag;
    const key = `${totalIndex}-${i}-${level}`;

    if (SINGLE_TAGS.has(tag)) {
      result.push(<Comp {...compProps} key={key} />);
      result.push(...posthtmlToReact(node.content, components, level + 1));
    } else {
      result.push(
        <Comp key={key} {...compProps}>{posthtmlToReact(node.content, components, level + 1)}</Comp>
      );
    }
  }

  return result;
};

将 PostHTML 的 AST 通过 postHtmlToReact 函数转换为 React.ReactNode[] 后,即可直接在 React 组件中使用。调用 postHtmlToReact 时也可以指定某些 HTML 标签的渲染方式、控制文章内容的呈现方式。比如,通过控制 a 标签的渲染方式,我可以将 a 标签渲染为我的 CustomLink 组件、实现内链通过 Next.js 无刷新跳转:

import NextLink from 'next/link';

const isExternalLink = (href: string) => {
  if (!href) {
    return false;
  }
  if (!/^(\/\/|http(s)?:)/.test(href)) return false;
  if (href.startsWith('https://blog.skk.moe')) {
    return false;
  }

  let urlObj: URL | undefined = undefined;
  try {
    urlObj = new URL(href, 'https://blog.skk.moe');
  } catch (e) { }
  if (typeof urlObj !== 'object') return false;
  if (urlObj.origin === 'null') return false;
  if (urlObj.hostname !== 'blog.skk.moe') return true;
  return false;
};

const CustomLink = (props: JSX.IntrinsicElements['a']) => {
  const { href, ...rest } = props;

  if (isExternalLink(href)) {
    return (
      <a {...props} target="_blank" rel="noopener noreferrer external nofollow" />
    );
  }

  return (
    <NextLink href={href} passHref>
      <Link {...rest} />
    </NextLink>
  );
};

export const PostContent = (props: { tree: PostHTML.Node[] }) => {
  const tree = Array.isArray(props.tree) ? props.tree : [props.tree];

  return posthtmlToReact(
    tree,
    {
      'a': CustomLink,
    }
  );
};

再比如,我可以用 CSS-in-JS 为文章内容添加样式:

const Blockquote = styled('blockquote', {
  borderLeft: '0.25em solid var(--border)',
  padding: '1em',
  background: 'var(--c-bg)'
});

export const PostContent = (props: { tree: PostHTML.Node[] }) => {
  const tree = Array.isArray(props.tree) ? props.tree : [props.tree];

  return posthtmlToReact(
    tree,
    {
      'blockquote': Blockquote,
    }
  );
};

图片 lazyload 优化

两年前,我曾经写过一篇文章「图片 lazyload 的学问和在 Hexo 上的最佳实践」,简单介绍了图片 lazyload 以及与之有关的 Layout Shift 问题,以及占位图、宽高比盒子等概念。两年过去了,图片 lazyload 的策略也在不断进化。

为什么图片需要指定 width 和 height 属性?

推荐大家阅读「Setting Height And Width On Images Is Important Again」。在这里 TL, DR 一下:

  • 在比较久远的过去,网络环境并不良好、带宽不足、RTT 和 TTFB 高、丢包率高,加上当时没有合适的 Web 图片编码方式,如果网页不显式提供 <img /> 元素的长宽,浏览器不得不等到图片全部下载完成后才能获取图片的长宽,从而导致 Reflow 和 Layout Shift。
  • 随着移动端和平板电脑的出现,网页设计者希望图片的宽度能填满 viewport 或 container(即响应式图片),一种常见的做法就是 max-width: 100%; height: auto。但是一旦通过 CSS 指定了 max-widthheight<img /> 元素的 widthheight 属性便会被浏览器忽略。无法得知图片的长宽,Reflow 和 Layout Shift 问题重新出现。
  • 为了避免响应式图片导致的 Reflow 和 Layout Shift,一个 workaround 是将图片包裹在一个 position: relative 的容器中,然后为图片设置 position: absolute、为容器设置一个值为长宽百分比的 padding-bottom,即「宽高比盒子」(Aspect Ratio Boxes)。使用了宽高比盒子后,虽然浏览器在图片加载完以后仍然会 Reflow,但是避免了 Layout Shift。至今 Medium 仍然在使用宽高比盒子避免 Layout Shift。
  • 为了一劳永逸的解决这个问题,CSSWG(CSS Working-Group)在「CSS Box Sizing Module Level 4」草案中提出了 aspect-ratio、为块级元素指定宽高比。
  • 从 Chrome 79 和 Firefox 71 开始,如果同时为 <img /> 元素设置了 widthheight 属性,浏览器会将 widthheight 用来计算 <img /> 元素的宽高比(aspect-ratio: attr(width) / attr(height)),既兼容了响应式图片、又避免了 Reflow 和 Layout Shift。

1px 占位图的困局

在实现 lazyload 时,一般将真实图片的 URL 放在 data-src 中,并通过 JS 控制 src。但是浏览器并不能很好的处理没有 src 属性的 <img /> 元素(Undefined Behavior),而将 src 的初始值设置为空字符串又会导致浏览器向页面本身发送一个 GET 请求(参见 RFC3986 中对 URI 为空字符串时的行为规定),常见的解决办法便是将 <img /> 元素的 src 属性设置为一个 1px 的透明 GIF。

但是 1px 像素的占位图的宽高比例是 1:1,浏览器在渲染 <img /> 元素时就会设置 1 比 1 的宽高比;当 <img /> 元素进入 viewport 以后,JS 将 src 设置为图片真实的 URL,此时真实图片的宽高比和 1px 像素占位图的宽高比不相同,便会引发 Reflow 和 Layout Shift。一些大型网站选择采用等比低像素占位图(占位图经过压缩和缩放,只保留原始图片的宽高比),避免真实图片代替占位图后引发宽高比变化。Medium 在使用宽高比盒子的同时,也使用了等比低像素占位图。

最终的解决方案

在重构博客的时候,我的图片组件需要解决以下几点问题:

  • 最基础的 Lazyload —— 不加载非关键资源是前端性能优化的铁律,不能违背
  • 当图片已经被加载过后,下次加载同一张图片时不再需要 lazyload
  • 由于我的博客不是大型网站、没有专门的图片服务器生成等比低像素占位图、只能使用 1px GIF 占位图规避 Undefined Behavior,因此我的图片组件需要能规避宽高比变化
  • React DOM 不支持将跨级元素(如 <div /><figure />)渲染在 <p /> 中,这与 Marked.js 的默认行为冲突,因此要么破坏宽高比盒子的 HTML 语义(figure -> img),要么不使用宽高比盒子
  • 1px 透明占位图的 UX 不好、需要为图片组件设置一个 background-color,让用户知道这里存在一个尚未加载的元素(即元素骨架);但是在图片完全加载成功后,又需要将 backgounrd-color 移除,避免影响透明背景图片、半透明图片的显示效果。

首先解决 Lazyload。首先实现一个 use-intersection 的 React Hook。相比 react-use 等现成的实现,我的实现复用了 IntersectionObserver 实例,大幅节省了内存:

type UseIntersectionObserverInit = Pick<IntersectionObserverInit, 'rootMargin' | 'root'>;
type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit & { rootRef?: React.RefObject<HTMLElement> | null };
type ObserveCallback = (isVisible: boolean) => void;
type Identifier = { root: Element | Document | null; margin: string };
type Observer = {
  id: Identifier
  observer: IntersectionObserver
  elements: Map<Element, ObserveCallback>
};

const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined';

export function useIntersection<T extends Element>({
  rootRef,
  rootMargin,
  disabled
}: UseIntersection): [(element: T | null) => void, boolean, () => void] {
  // 通过 isDisabled 控制 useEffect 中副作用是否需要执行
  const isDisabled: boolean = disabled || !hasIntersectionObserver;
  // 通过 Ref 缓存上一次调用 useIntersection 时生成的 unobserve 方法
  const unobserve = useRef<() => void>();
  const [visible, setVisible] = useState(false);
  // 设置 IntersectionObserver 的 root
  const [root, setRoot] = useState(rootRef ? rootRef.current : null);
  // React 回调 Ref
  const setRef = useCallback(
    (el: T | null) => {
      // unobserve 上一次调用 useIntersection 时观察的元素
      if (unobserve.current) {
        unobserve.current();
        unobserve.current = undefined;
      }

      if (isDisabled || visible) return;

      // 如果传入的 el 是一个 HTMLElement
      if (el && el.tagName) {
        unobserve.current = observe(
          el,
          (isVisible) => isVisible && setVisible(isVisible),
          { root, rootMargin }
        );
      }
    },
    [isDisabled, root, rootMargin, visible]
  );

  useEffect(() => {
    if (!hasIntersectionObserver) {
      // 如果当前 Runtime 没有 IntersectionObserver(如 Node.js 服务端、或浏览器不兼容)
      // 在 rIC 后显示图片,作为 fallback。rIC 额外引入 Polyfill。
      if (!visible) {
        const idleCallback = requestIdleCallback(() => setVisible(true));
        return () => cancelIdleCallback(idleCallback);
      }
    }
  }, [visible]);

  useEffect(() => { if (rootRef) setRoot(rootRef.current); }, [rootRef]);

  // 暴露重置 visible 的方法
  const resetVisible = useCallback(() => setVisible(false), []);
  return [setRef, visible, resetVisible];
}

// 缓存 IntersectionObserver 实例
const observers = new Map<Identifier, Observer>();
const idList: Identifier[] = [];

function createObserver(options: UseIntersectionObserverInit): Observer {
  const id = { root: options.root || null, margin: options.rootMargin || '' };
  const existing = idList.find((obj) => obj.root === id.root && obj.margin === id.margin);
  let instance;
  // 复用已有的 IntersectionObserver 实例
  if (existing) {
    instance = observers.get(existing);
  } else {
    instance = observers.get(id);
    idList.push(id);
  }
  if (instance) return instance;

  // 记录每个 IntersectionObserver 实例观察的元素,在所有观察的元素都进入 Viewport 后销毁实例
  const elements = new Map<Element, ObserveCallback>();
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      const callback = elements.get(entry.target);
      const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
      if (callback && isVisible) callback(isVisible);
    });
  }, options);

  observers.set(id, (instance = { id, observer, elements }));
  return instance;
}

function observe(
  element: Element,
  callback: ObserveCallback,
  options: UseIntersectionObserverInit
): () => void {
  const { id, observer, elements } = createObserver(options);
  elements.set(element, callback);

  observer.observe(element);
  return function unobserve(): void {
    elements.delete(element);
    observer.unobserve(element);

    // 当没有元素需要观察时,销毁 IntersectionObserver 实例
    if (elements.size === 0) {
      observer.disconnect();
      observers.delete(id);
      const index = idList.findIndex(
        (obj) => obj.root === id.root && obj.margin === id.margin
      );
      if (index > -1) {
        idList.splice(index, 1);
      }
    }
  };
}

接下来实现 <Image /> 组件:

export const Image = (props: JSX.IntrinsicElements['img']) => {
  const { src, ...rest } = props;
  const imageElRef = useRef<HTMLImageElement>(null);

  const previousSrcRef = useRef<string | undefined>(src);
  const isLazy = useMemo(() => {
    // 至少在 HTML5 spec 中,img 允许没有 src 属性,需要特殊处理
    if (!src) return false;
    if (src?.startsWith('data:') || src?.startsWith('blob:')) return false;
    return true;
  }, [src]);

  const [setIntersection, isIntersected, resetIntersected] = useIntersection<HTMLImageElement>({
    rootMargin: '200px',
    disabled: false
  });

  useLayoutEffect(() => {
    // 在 React Reconcile 中,同一个 Image 组件可能会被复用、DOM 中的 HTMLImageElement 也会被复用
    // 而 useIntersection 中副作用的依赖仅为 HTMLImageElement,因此需要手动重置 visible state
    if (previousSrcRef.current !== src) {
      previousSrcRef.current = src;
      resetIntersected();
    }
    setIntersection(imageElRef.current);
  }, [resetIntersected, setIntersection, src]);

  const isVisible = !isLazy || isIntersected;
  // 由 React 控制显示 1px 占位图还是真实图片
  const srcString = isVisible ? src : SMALLEST_GIF;

  return (
    <img
      {...rest}
      ref={imageElRef}
      decoding="async"
      crossOrigin="anonymous"
      src={srcString}
    />
  )
};

接下来解决 1px 占位图的宽高比问题:

{/* 既然 React DOM 不允许在 <p /> 中渲染 <figure /> 或 <div />,<span /> 总行吧? */}
<span style={{
  boxSizing: 'border-box',
  display: 'inline-block',
  position: 'relative',
  maxWidth: '100%',
  width: 'initial',
  height: 'initial',
  cursor: 'zoom-in',
  margin: '0 auto 1em'
}}>
  <span
    style={{
      boxSizing: 'border-box',
      display: 'block',
      width: 'initial',
      height: 'initial',
      maxWidth: '100%'
    }}
  >
    {/* 使用一个和原始图片长宽一致的 data URI svg 图片撑起宽高比容器,兼容包括 Safari 在内的、没有原生 aspect-ratio 的浏览器 */}
    <img
      style={{
        display: 'block',
        backgroundColor: 'none',
        backgroundImage: 'none',
        width: 'initial',
        height: 'initial',
        maxWidth: '100%'
      }}
      width={width}
      height={height}
      alt=""
      aria-hidden={true}
      src={`data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20version=%271.1%27%20width=%27${width}%27%20height=%27${height}%27/%3e`}
    />
  </span>
  <img
    {...rest}
    {/* 让图片直接填满由 data URI svg 撑起的容器 */}
    style={{
      boxSizing: 'border-box',
      padding: 0,
      border: 0,
      margin: 'auto',
      display: 'block',
      minWidth: '100%',
      maxWidth: '100%',
      minHeight: '100%',
      maxHeight: '100%'
    }}
    ref={imageElRef}
    decoding="async"
    crossOrigin="anonymous"
    src={srcString}
  />
</span>

由于需要在图片尚未下载、或下载了但尚未解码完成时显示骨架(background-color),需要首先实现一个 useImageFullyLoaded 的 React Hook:

const LOADED_IMAGE_URLS = new Set<string>[];

export const useImageFullyLoaded = (imageElRef: React.RefObject<HTMLImageElement>, srcString?: string) => {
  const [isFullyLoaded, setIsFullyLoaded] = useState(false);
  const handleLoad = useCallback(() => {
    if (srcString) {
      const img = imageElRef.current;
      if (!img) return;
      // 真实图片元素当前的 src(currentSrc,当网页用 picture / source 元素指定了变种后,浏览器实际采用的 src)
      const imgSrc = img.currentSrc || img.src;
      if (imgSrc && imgSrc !== SMALLEST_GIF) {
        // 利用 HTMLImageElement.prototype.decode API,获取图片解码后的回调
        // 在不兼容的浏览器上直接等待一个 microtask
        const promise = 'decode' in img ? img.decode() : Promise.resolve();
        promise.catch(() => {}).then(() => {
          if (!imageElRef.current) return;
          // 记录已经加载完、解码的图片
          LOADED_IMAGE_URLS.add(srcString);
          setIsFullyLoaded(true);
        });
      }
    }
  }, [imageElRef, srcString]);
  // 由于 SSR 输出了完整 HTML,而页面的 JS 又全部都是异步加载。
  // 浏览器可能在 React DOM 还没 Hydration 时就完成了图片的下载,因此不能直接添加 onLoad
  useEffect(() => {
    if (imageElRef.current) {
      if (imageElRef.current.complete) {
        handleLoad();
      } else {
        imageElRef.current.onload = handleLoad;
      }
    }
  }, [handleLoad, imageElRef]);

  return isFullyLoaded;
};

有了 useImageFullyLoadedLOADED_IMAGE_URLS,我们就可以获取图片 是否已经下载过 和 是否解码完成并完整地显示在页面上了:

const isLazy = useMemo(() => {
  if (!src) return false;
  if (src?.startsWith('data:') || src?.startsWith('blob:')) return false;
  if (typeof window !== 'undefined') {
    // 这张图片已经加载过、解码过了,无需 lazyload
    if (LOADED_IMAGE_URLS.has(src)) return false;
  }
  return true;
}, [src]);
// ...
const isImageFullyLoaded = useImageFullyLoaded(imageElRef, src);
{/* <img /> 外层的容器 */}
<span style={{
  // 容器的其它样式
  // ....
  // 只有当图片完全解码、显示在页面上后,才去掉 background-color
  backgroundColor: isImageFullyLoaded ? undefined : '#eee',
}}>

最后再加上前文提到的、模仿 Medium 的点击文中图片放大的效果,完整的 <Image /> 组件就封装好了。

尾声

当我一个月前开始动笔写这篇文章的时候,我只是想写一篇流水账,记录我在重构博客时遇到的每个问题、以及解决的办法。但是写着写着,就变成了介绍我最近发现的前端的前沿技术、以及如何在重构我的博客时应用这些技术,以至于最后几乎变成了一篇前端性能优化和 React 的 Newsletter。

重构博客是我突破舒适区的又一次尝试。对博客的每一次打磨,就是抓住机会实践 Web 最前沿的技术和经验;每一次 commit,我都将博客的用户体验和性能推上新的高度。

之前一直有不少人问题,我的博客主题是否开源,我的回答一直是「否」。为了满足这部分人的好奇心,我过去几年间也写过许多关于「前端性能优化」的文章,以及介绍过我过去的博客使用的性能优化方案,如「使 Disqus 不再拖累性能和页面加载」、「天下武功,唯快不破 —— 我是这样优化博客的」和「再快一点,再快一点 —— 优化博客白屏时间的实践」。这一次借着这篇文章,我也分享了部分博客中实际使用的代码、以及我是如何一步一步实现这些特性的,希望能够满足这部分读者的好奇心。

以上。

使用 Next.js + Hexo 重构我的博客
本文作者
Sukka
发布于
2022-03-18
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...