《基于 Vite 的 SSG 框架开发实战》学习笔记

1,501 阅读31分钟

1. CLI 是什么

CLI (Command Line Interface) 是指命令行界面,它是一种用户与计算机系统进行交互的方式。通过 CLI,用户可以通过输入命令和参数来执行各种操作和任务,而无需使用图形用户界面 (GUI)。

2. 搭建CLI脚手架

Vite内置了CAC方案

  1. 首先安装依赖
pnpm i cac -s
  1. 新建src/node/cli.ts
import { cac } from "cac";

const version = require("../../package.json").version;

const cli = cac("island").version(version).help();

cli
  .command("[root]", "start dev server")
  .alias("dev")
  .action(async (root: string) => {
    console.log("dev", root);
  });

cli
  .command("build [root]", "build for production")
  .action(async (root: string) => {
    console.log("build", root);
  });

cli.parse();

  1. package.json中注册cli命令
// package.json
{
  "bin": {
    "island": "bin/island.js"
  }
}
  1. 执行npm link 将 island 命令 link 到全局

3. Dev Server的开发

Dev Server本质上是一个开发阶段是用的HTTP Server,主要包含以下作用:

  • 对资源进行编译,然后将编译产物返回给浏览器
  • 实现模块热更新,在文件改动时能推送更新到浏览器
  • 静态资源服务,比如支持访问图片等静态资源

4. vite接入热更新

  1. 首先优化indexHtml插件
// node/plugin-island/indexHtml.ts
import { readFile } from "fs/promises";
import { Plugin } from "vite";
import { DEFAULT_HTML_PATH } from "../constants";

export function pluginIndexHtml(): Plugin {
  return {
    name: "island:index-html",
    configureServer(server) {
      return () => {
        server.middlewares.use(async (req, res, next) => {
          let html = await readFile(DEFAULT_HTML_PATH, "utf-8");

          try {
+            html = await server.transformIndexHtml(
+              req.url,
+              html,
+              req.originalUrl
+            );
            res.statusCode = 200;
            res.setHeader("Content-Type", "text/html");
            res.end(html);
          } catch (e) {
            return next(e);
          }
        });
      };
    },
  };
}

transformIndexHtml钩子会将html文件用vite transform一次,在html文件中添加一些script标签,使其具有热更新的能力。

  1. 再接入Vite官方的React插件,实现完整的热更新效果
pnpm i @vitejs/plugin-react -S

5. CSR、SSR、SSG

CSR:

是没有页面 HTML 的具体内容,依靠 JS 来完成页面渲染。在 Vue 或 React 大行其道的环境下,大部分的页面都是采用 CSR 的渲染模式。

存在的问题:

  • 首屏加载慢。  一方面需要请求数据,会带来网络 IO 的开销,另一方面需要通过前端框架来渲染页面,这又是一部分运行时开销。
  • 对 SEO 不友好。  因为没有完整的 HTML 内容,无法让搜索引擎爬虫摘取有用的信息。

SSR:

SSR 的诞生就是为了解决 CSR 模式下的一系列问题。 SSR 的全称是Server Side Render,它的核心特征就是在服务端返回完整的 HTML 内容,也就是说浏览器一开始拿到的就是完整的 HTML 内容,不需要 JS 执行来完成页面渲染。

存在的问题: SSR直接产出的页面不可以交互,DOM 元素事件绑定的逻辑仍然需要 JS 才能够完成,所以一般的 SSR 项目都会采用同构的架构,也就是在 SSR 页面中加入 CSR 的脚本,完成事件绑定。那么这个完成事件绑定的过程,也被称为Hydration

SSG:

SSG 全称为 Static Site Generation,即静态站点生成。它本质上是构建阶段的 SSR,在 build 过程中产出完整的 HTML

它的优点如下:

  • 服务器压力小;
  • 继承 SSR 首屏性能以及 SEO 的优势。

存在的问题: 并不适用于数据经常变化的场景。你可以试想一个 10 分钟刷新一次的榜单,如果使用 SSG 方案,那么项目会进行频繁的构建和部署,并且也做不到良好的时效性。

因此,SSG 更加适合一些数据变化频率较低的站点,比如文档站、官方站点、博客等等。

6. react-dom/server

react-dom/server 是 React 库的一部分,主要用于在服务器端渲染 React 组件。它提供了一些方法,例如 renderToStringrenderToStaticMarkup,用于将 React 组件转换为 HTML 字符串。

以下是 react-dom/server 中最主要的两个函数:

  1. renderToString(element): 此方法将 React 元素渲染为初始 HTML。React 会返回一个 HTML 字符串,可以在服务器端使用,然后发送到客户端以加速页面的初次渲染。

  2. renderToStaticMarkup(element): 和 renderToString 类似,但是它不会创建额外的 DOM 属性(如 data-reactroot),这对于生成静态页面更有用。

这两个方法主要用于服务器端渲染(SSR)。在服务器上预先渲染应用的 HTML 可以带来两个主要好处:

  • 改善性能:服务器渲染可以为用户快速提供完整的渲染页面,而不是发送一个空白页面并等待客户端的 JavaScript 执行完成。

  • SEO 友好:由于搜索引擎的爬虫程序可以直接读取完全渲染的 HTML,因此服务器渲染通常更利于搜索引擎优化(SEO)。特别是对于那些不能很好地处理 JavaScript 的爬虫,服务器端渲染可以让它们看到完整的页面内容。

然后在客户端,你可以用 ReactDOM.hydrate() 方法使得由服务器渲染的 HTML 变得可以交互,即对其添加事件监听等功能。

7. 为什么CJS 模块中不能导入 ESM 模块

CJS 模块是通过 require 进行同步加载的,而 ESM 模块是通过 import 异步加载。同步的 require 方法并不能导入 ESM 模块。

解决方法:

async function foo() {
  const { add } = await import("./util.mjs");

  console.log(add(1, 2));
}

foo();

8. tsup 开启了 shims

会自动帮我们注入一些 API 的 polyfill 代码,如 __dirname, __filename, import.meta 等等,保证这些 API 在 ESM 和 CJS 环境下的兼容性。

9. 常见的库构建方案

Vite/Rollup: 底层使用 Rollup 进行库打包

tsup: Esbuild 打包代码,Rollup 和 rollup-plugin-dts 打包 TS 类型

unbuild: 原理同 tsup,另外包含 bundleless 模式

10. 单元测试搭建

使用Vitest来作为项目的测试方案

1.安装依赖

pnpm i vitest -D

2.初始化Vitest配置文件vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    passWithNoTests: true,
    exclude: ['**/node_modules/**', '**/dist/**'],
    threads: true
  }
});

3.新建测试文件

import { expect, test } from 'vitest';

test('add', () => {
  expect(1 + 1).toBe(2);
  expect('map'.slice(1)).toMatchSnapshot('"ap"');
  expect('map'.slice(1)).toMatchInlineSnapshot('"ap"');
});

4.在package.json中新增测试脚本

{
  "test:init": "vitest run"
}

若要开启UI界面,可安装依赖

pnpm i @vitest/ui -D

并且将测试脚本换成vitest --ui, 接着执行pnpm test:init

11. E2E测试搭建

端到端(End to End,简称 E2E)测试是软件测试的一种类型,目的是测试完整的应用程序流程,从用户界面开始,经过业务逻辑,到达数据库或网络服务等后端组件,最后再返回到用户界面。

端到端测试是为了确保各个组件以及组件之间的交互都能正常工作,所有流程都能如预期那样执行。这种测试通常模拟真实用户场景,检查系统/软件是否按照需求正常运行。

举个例子,对于一个电子商务网站,端到端测试可能会包括以下步骤:

  1. 打开网站首页
  2. 搜索某个商品
  3. 将商品添加到购物车
  4. 填写送货地址
  5. 选择支付方式并完成支付
  6. 检查购物车是否已清空,检查订单是否在用户的订单历史中

端到端测试可以帮助你发现整个系统级别的问题,例如某个组件之间的交互存在问题,或者某个流程的某个步骤出现错误。这些问题在单元测试或集成测试中可能无法发现,因为这些测试通常只关注特定的函数或模块,或者只测试一小部分组件的交互。

在本项目中使用比较流行的一个E2E测试套件playwright

pnpm i @playwright/test@1.26.1 -D

详细请看基于 Vite 的 SSG 框架开发实战

12. 通用配置文件解析

本框架支持用户编写配置及文件,因此需要开发一个通用配置解析器

1.首先要获取配置文件的路径:

使用 getUserConfigPath函数,先查看docs目录下的config.ts文件是否存在,如果存在resolve路径后返回文件的路径,否则报错。

2. 读取配置文件的内容

使用vite的loadConfigFromFileapi,读取配置文件的内容。

3.在src/node/dev.ts 中调用配置解析的逻辑

const config = await resolveConfig(root, 'serve', 'development');

解决配置文件类型提示的问题

src/node/config.ts,我们可以增加一个 API:

export function defineConfig(config: UserConfig) {
  return config;
}

在 tsup.config.ts 中新增一个入口配置:

export default defineConfig({
-  entryPoints: './src/node/cli.ts',
+  entryPoints: ["./src/node/cli.ts", "./src/node/index.ts"],
+  clean: true // 清空之前的构建产物
});

新增 src/node/index.ts,内容如下:

export { defineConfig } from './config';

这样可以在dist中生成产物。

15. 前端UI层访问配置数据

新建一个插件src/node/plugin-island/config.ts(利用虚拟模块):

虚拟模块(Virtual Module)是在内存中而非在文件系统中创建的模块。这些模块在运行时动态生成,并且它们的内容也可以在运行时改变。虚拟模块的一大优点是,因为它们存在于内存中,所以访问速度快于在磁盘上的实体文件。

虚拟模块在许多场景下都很有用。例如,你可能希望将应用的配置信息包含到你的打包结果中,但这些配置信息可能在运行时才可用。在这种情况下,你可以创建一个虚拟模块,将配置信息添加到其中,然后在你的代码中如同导入一个实际文件一样导入这个虚拟模块。

在 JavaScript 打包器(如 Webpack 或 Rollup)中,你可以使用插件来创建虚拟模块。这些插件允许你定义模块的名称和内容,然后在你的代码中导入这些模块。虚拟模块在打包器处理你的代码时被创建,在这个过程中,打包器会将对虚拟模块的引用替换为模块的实际内容。

请注意,虚拟模块并不会在文件系统中产生实际的文件,因此如果你尝试在打包过程之外的上下文(例如在你的 IDE 中)访问这些模块,可能会得到一个文件不存在的错误。

import { relative } from 'path';
import { Plugin } from 'vite';
import { SiteConfig } from '../../shared/types/index';

const SITE_DATA_ID = 'island:site-data';

export function pluginConfig(
  config: SiteConfig,
): Plugin {
  return {
    name: 'island:config',
    resolveId(id) {
      if (id === SITE_DATA_ID) {
        return '\0' + SITE_DATA_ID;
      }
    },
    load(id) {
      if (id === '\0' + SITE_DATA_ID) {
        return `export default ${JSON.stringify(config.siteData)}`;
      }
    },
  };
}

16.配置文件热更新

在 pluginConfig 插件中新增一个钩子用来处理配置文件的热更新:

async handleHotUpdate(ctx) {
  const customWatchedFiles = [config.configPath];
  const include = (id: string) =>
    customWatchedFiles.some((file) => id.includes(file));

  if (include(ctx.file)) {
    console.log(
      `\n${relative(config.root, ctx.file)} changed, restarting server...`
    );
  }
}
  1. 首先将 dev.ts 文件进行单独打包,修改 tsup.config.ts 的内容如下:
import { defineConfig } from 'tsup';

export default defineConfig({
  entryPoints: {
    cli: './src/node/cli.ts',
    index: './src/node/index.ts',
    dev: './src/node/dev.ts'
  },
  bundle: true,
  splitting: true,
  minify: process.env.NODE_ENV === 'production',
  outDir: 'dist',
  format: ['cjs', 'esm'],
  dts: true,
  shims: true
});
  1. 然后回到 src/node/cli.ts 中,修改dev命令的代码如下:
cli.command('dev [root]', 'start dev server').action(async (root: string) => {
  const createServer = async () => {
  //从dev.js引入代码的耦合性更低一些
    const { createDevServer } = await import('./dev.js');
    const server = await createDevServer(root, async () => {
      await server.close();
      await createServer();
    });
    await server.listen();
    server.printUrls();
  };
  await createServer();
});

17. 前端路由设计和Demo实现

什么是约定式路由?约定式路由一般指文件系统路由,页面的文件路径会简单映射为路由的路径,这样让整个项目的路由非常直观。

1. 安装依赖

pnpm i react-router-dom

2. src/runtime/client-entry.tsx 中接入 react-router-dom

<BrowserRouter></BrowserRouter><App />包裹起来, 并将其挂载在页面的root节点上。

3. 新建路由组件 src/runtime/Content.tsx, 以docs目录下的文件生成demo路由

import { useRoutes } from 'react-router-dom';
import A from '../../docs/guide/a';
import B from '../../docs/b';
import Index from '../../docs/guide/index';

const routes = [
  {
    path: '/guide',
    element: <Index />
  },
  {
    path: '/guide/a',
    element: <A />
  },
  {
    path: '/b',
    element: <B />
  }
];

export const Content = () => {
  const routeElement = useRoutes(routes);
  return routeElement;
};

4. 完善生产环境build的逻辑

在 cli.ts 中,我们需要在 build 命令中补充配置解析的逻辑

首先补充配置解析的逻辑,然后在build方法中新加config入参,在bundle方法中加入一些必要的插件

18.约定式路由的 Vite 插件

1. 初始化插件的代码,新建 src/node/plugin-routes/index.ts,内容如下:

import { Plugin } from 'vite';
import { RouteService } from './RouteService';
// 本质: 把文件目录结构 -> 路由数据

export interface Route {
  path: string;
  element: React.ReactElement;
  filePath: string;
}

interface PluginOptions {
  root: string;
}

export const CONVENTIONAL_ROUTE_ID = 'island:routes';

export function pluginRoutes(options: PluginOptions): Plugin {
  const routeService = new RouteService(options.root);

  return {
    name: 'island:routes',
    async configResolved() {
      // Vite 启动时,对 RouteService 进行初始化,生成文件的路由路径和相对路径
      await routeService.init();
    },
    resolveId(id: string) {
      if (id === CONVENTIONAL_ROUTE_ID) {
        return '\0' + id;
      }
    },

    load(id: string) {
      if (id === '\0' + CONVENTIONAL_ROUTE_ID) {
      // 生成"export const routes = [ { path: '/a', element: React.createElement(Route0) }, { path: '/guide/b', element: React.createElement(Route1) } ];"
        return routeService.generateRoutesCode();
      }
    }
  };
}

虚拟模块 island:routes 的内容,这个模块也就包含了约定式路由的核心数据。有了这个模块之后,我们就不需要像上一小节那样手动地声明路由数组了,直接通过一行 import 语句就能获取路由数据:

2. 约定式路由插件核心逻辑

  1. 首先遍历docs目录下的所有js,jsx,ts,tsx,md,mdx文件, 将其路径返回,然后排序,排序的目的是为了每次文件顺序一样

  2. 遍历所有符合规则的文件路径,将文件路径进行转换,转换成路由路径文件绝对路径,并保存至私有变量routeData中

  3. 根据routeData,生成export const routes = [ { path: '/a', element: React.createElement(Route0) }, { path: '/guide/b', element: React.createElement(Route1) } ];这就是虚拟模块所返回的内容

  4. 实现组件的动态加载:使用 @loadable/component

使用插件后,虚拟模块返回的内容差不多是这个样子:

import React from 'react';
import loadable from '@loadable/component';
const Route0 = loadable(() => import('TEST_DIR/a.mdx'));
const Route1 = loadable(() => import('TEST_DIR/guide/b.mdx'));
export const routes = [
  { path: '/a', element: React.createElement(Route0) },
  { path: '/guide/b', element: React.createElement(Route1) }
];

19. MDX编译:整体工具链和生态介绍

  1. MDX:它是一种专注于内容编写的一种语法文件格式,你既可以编写 Markdown 的语法,也可以使用组件的语法 JSX。

  2. MDX 背后的组织——Unified。这个组织定义了一系列的语法规范,提供了非常多的 MDX 的编译工具,在社区是一个非常有名的组织。 它的愿景就是做 AST 与内容之间的相互转,并且提供了上百个包来做这件事情。当然他本身也是一个底层的工具,你可以去 Github 访问它的仓库

  3. Unified 的实现原理:

整个编译的过程,我们可以把它看做一个 process,那么这个 process 会分成三个步骤:

image.png

第一个步骤是 parse,也就是 AST 的解析,我们将内容输入进来之后(如一段 Markdown),通过一个 Parser 来完成 AST 的解析过程,随后产出语法树的信息。

第二步是 run,在这个环节会进行一系列 AST 的转换,也就是说会有一系列的插件来操作语法树的信息。

最后一步是 stringify,就是序列化 AST,将其化为字符串的格式,作为最终的输出。

其实 AST 也是包含多种类型的,首先是 mdast,也就是 Markdown 的 AST;然后 esast,它的全称是 ECMAScript AST,也就是 JS 语法的 AST;最后是 hast,操作的是 HTML 的 AST。

  1. Unified 的编译生态:

在 MDX 领域中主要包含两个工具:

  • remark。主要用于编译 Markdown 和 JSX。
  • rehype。主要用于编译 HTML。

20. 如何接入 MDX 编译能力

  1. rehype-parse和rehype-stringfy插件的作用

rehype-parserehype-stringify 是 Rehype 的两个核心插件,用于解析和序列化 HTML 数据。

  1. rehype-parse:这个插件可以将 HTML 字符串解析为一个 Rehype 兼容的抽象语法树(AST)。AST 是一种用于表示程序结构的数据结构,其将源代码的结构抽象化,使得我们可以方便地进行各种操作,比如查询、修改等。rehype-parse 支持解析 HTML 和 XHTML,并且可以通过配置来处理解析错误、解析脚本和样式内容、解析文档碎片等。

    使用示例:

    import { unified } from 'unified';
    import rehypeParse from 'rehype-parse';
    
    const processor = unified().use(rehypeParse);
    const ast = processor.parse('<h1>Hello, world!</h1>');
    
  2. rehype-stringify:这个插件可以将 Rehype 的 AST 转换(序列化)为 HTML 或 XHTML 字符串。你可以通过配置来控制输出的格式,比如使用 XHTML 规则、缩进和换行等。

    使用示例:

    import { unified } from 'unified';
    import rehypeStringify from 'rehype-stringify';
    
    const processor = unified().use(rehypeStringify);
    const html = processor.stringify(ast);
    

在 Rehype 中,我们通常先使用 rehype-parse 将 HTML 字符串解析为 AST,然后进行各种操作,比如查询、修改、添加插件等,最后再使用 rehype-stringify 将 AST 转换为 HTML 字符串。 3. 安装rollup的mdx插件

@mdx-js/rollup:一个rollup插件,用来处理 MDX 文件, 使用示例如下:

// rollup.config.js
import mdx from '@mdx-js/rollup';

export default {
  input: 'input.js',
  output: {
    file: 'output.js',
    format: 'iife'
  },
  plugins: [mdx({ remarkPlugins: [], rehypePlugins: [] })]
}

下面的代码片段配置了 MDX 插件,并通过 remarkPluginsrehypePlugins 指定了额外的插件。

pluginMdx({
  remarkPlugins: [], // 用于处理或转换 Markdown 的插件列表
  rehypePlugins: []  // 用于处理或转换 HTML 的插件列表
})
  • remarkPlugins: 是一个包含 Remark 插件的数组。Remark 是一个处理 Markdown 的工具链,它有许多插件可以用于改变 Markdown 文件的解析、转换和字符串化过程。例如,你可以使用 remark-gfm 插件来添加对 GitHub 风格的 Markdown 的支持。
  • rehypePlugins:是一个包含 Rehype 插件的数组。Rehype 是一个处理 HTML 的工具链,也有许多插件可以用于改变 HTML 文件的解析、转换和字符串化过程。例如,你可以使用 rehype-highlight 插件来添加对代码高亮的支持。

这个配置允许你精细控制 MDX 文件的解析和转换过程,适应不同的需求和场景。例如,你可以使用不同的 Remark 和 Rehype 插件来添加目录、高亮代码、转换图片等。

  1. 安装一些常用一些 remark/rehype 插件,丰富现有的 MDX 编译能力
  • remark-gfm:

remark-gfm 是一个 remark 插件,用于支持 GitHub 风格的 Markdown(GFM)语法。Remark 是一个强大的 Markdown 处理库,它提供了许多插件来扩展其处理能力。

GitHub 风格的 Markdown(GFM)是 GitHub 对标准 Markdown 语法的扩展,增加了一些新的特性,例如表格、任务列表、自动链接等。以下是一些 GFM 提供的额外功能:

  • 表格:可以在 Markdown 中创建表格,无需使用 HTML。
| First Header  | Second Header |
| ------------- | ------------- |
| Content Cell  | Content Cell  |
| Content Cell  | Content Cell  |
  • 任务列表:可以创建包含复选框的任务列表。
- [x] This is a complete item
- [ ] This is an incomplete item

remark-gfm 插件就是为了在 remark 中提供这些 GFM 特性的支持。例如,如果你在处理 Markdown 文本时,需要支持这些 GFM 语法,你可以这样使用它:

const remark = require('remark');
const gfm = require('remark-gfm');

remark()
  .use(gfm)
  .process('_Emphasis_ and **importance** work, and `code`.', function(err, file) {
    if (err) throw err;
    console.log(String(file));
  });

在这个例子中,remark().use(gfm) 就启用了 remark-gfm 插件,使得 remark 能够处理 GFM 语法。

  • rehype-autolink-headings:

rehype-autolink-headings 是一个用于处理 HTML 文件的 Rehype 插件。Rehype 是一个可以解析和转换 HTML 文件的库。通过 Rehype 插件,你可以自定义 HTML 文件的解析和转换过程。

具体来说,rehype-autolink-headings 插件可以在 HTML 文档中的所有标题(如 h1、h2、h3 等)旁边添加链接。这样,用户可以直接点击链接,跳转到对应的标题。这个功能在一些长文档中非常有用,可以帮助用户更快地导航和查找内容。

以下是一个如何使用 rehype-autolink-headings 插件的例子:

import rehype from 'rehype';
import autolinkHeadings from 'rehype-autolink-headings';
import html from 'rehype-stringify';
import parse from 'rehype-parse';

rehype()
  .use(parse)
  .use(autolinkHeadings, {
    behavior: 'wrap'
  })
  .use(html)
  .process('<h1>Hello, world!</h1>', function(err, file) {
    if (err) throw err;
    console.log(String(file));
  });

在这个例子中,rehype().use(autolinkHeadings) 就启用了 rehype-autolink-headings 插件。当然,你还需要先使用 rehype-parse 插件将 HTML 字符串解析为 Rehype 可以处理的数据结构,然后再使用 rehype-stringify 插件将处理后的结果转回 HTML 字符串。

这个 autolinkHeadings 插件的配置项 behavior 设置为 'wrap',表示将整个标题文本包裹在链接中。如果设置为 'prepend',则会在标题前添加链接;如果设置为 'append',则会在标题后添加链接。

需要注意的是,这个插件并不会自动生成链接的目标(即 href 属性)。你需要自行提供一个函数来根据标题文本生成链接的目标。你可以在配置项中使用 contentlinkProperties 等选项来自定义链接的显示和行为。

  • rehype-slug

rehype-slug 是一个用于处理 HTML 文件的 Rehype 插件。Rehype 是一个可以解析和转换 HTML 文件的库。通过 Rehype 插件,你可以自定义 HTML 文件的解析和转换过程。

具体来说,rehype-slug 插件的作用是为 HTML 文档中的每一个标题(如 h1、h2、h3 等)生成一个唯一的 slug。这个 slug 会被添加为标题元素的 id 属性,使得该标题可以通过 URL 的 fragment(也就是 URL 中 # 后面的部分)来定位。这在创建锚点链接(Anchor Link)时非常有用。

以下是一个如何使用 rehype-slug 插件的例子:

import rehype from 'rehype';
import slug from 'rehype-slug';
import html from 'rehype-stringify';
import parse from 'rehype-parse';

rehype()
  .use(parse)
  .use(slug)
  .use(html)
  .process('<h1>Hello, world!</h1>', function(err, file) {
    if (err) throw err;
    console.log(String(file));
  });

在这个例子中,rehype().use(slug) 就启用了 rehype-slug 插件。这会为所有的标题元素添加 id 属性,其值为该标题文本的 slug。这个 slug 是通过将标题文本转换为小写、替换掉非字母数字字符、并用短横线 - 连接各个单词生成的。

需要注意的是,你还需要先使用 rehype-parse 插件将 HTML 字符串解析为 Rehype 可以处理的数据结构,然后再使用 rehype-stringify 插件将处理后的结果转回 HTML 字符串。

  • remark-frontmatter remark-mdx-frontmatter

解析页面的元信息,比如:

// index.md
---
title: 'island'
---

21. rehype插件实战————实现preWrapper插件

preWrapper插件的目的是为了对MD代码块的编译进行扩展

在默认情况下,代码块的编译结果是怎样的呢:

console.log(123)

如上的代码会被编译为以下的 html 内容:

<pre><code class="language-js">console.log(123)</code></pre>

但实际上需要的结构是这样的:

<div class="language-js">
  <span class="lang">js</span>
  <pre><code class="language-js">console.log(123)</code></pre>
</div>

也就是说,要将其转化为:外层有一个 div 容器进行包裹,在容器内部还有一个 span 标签,用来展示语法名称。

必要的依赖:

pnpm i unist-util-visit @types/hast -D

transform步骤:

  1. 找到 pre 元素
  2. 解析出代码的语言名称
  3. 变换 Html AST

unist-util-visit和hast怎么使用:

unist-util-visit 是一个用于遍历 unist 抽象语法树(AST)的工具。unist 是统一的 AST 格式,用于表示结构化数据。而 hast 是用于表示 HTML 的 unist 兼容语法树。

这是如何使用 unist-util-visithast 的简单概述:

  1. 使用 hast 解析 HTML: 通常,你可能会使用一个库,如 rehype-parse,将 HTML 转换为 hast 树。

  2. 使用 unist-util-visit 遍历 hast: 使用 unist-util-visit 可以轻松地访问和修改特定类型的节点。

以下是一个简单的例子,展示如何使用这两个工具:

const visit = require('unist-util-visit');
const rehypeParse = require('rehype-parse');
const unified = require('unified');

// 将 HTML 字符串转换为 HAST 树
const processor = unified().use(rehypeParse, { fragment: true });
const hastTree = processor.parse('<div>Hello <span>world</span>!</div>');

// 使用 unist-util-visit 遍历 HAST 树
visit(hastTree, 'element', node => {
  if (node.tagName === 'span') {
    console.log('Found a <span> element!');
  }
});

// ... 进行其他操作,例如修改树或将其转换回 HTML

在上述示例中,我们首先使用 rehype-parse 将一个 HTML 字符串转换为一个 hast 树。接着,我们使用 unist-util-visit 遍历这个树,并在找到每个 <span> 元素时打印一条消息。

当然,unist 和其相关工具提供了大量的功能,使你可以进行复杂的树遍历、修改和转换操作。这只是一个基本的入门示例。

22.rehype插件实战————实现代码高亮插件

所需依赖

pnpm i shiki hast-util-from-html

关键步骤:

代码块元素的结构:

<pre><code>...</code></pre>
  1. 首先定位到pre标签
  2. 接下来我们需要获取 『语法名称』 和 『代码内容』
  3. 传给shiki的高亮器进行处理
// 高亮结果
const highlightedCode = highlighter.codeToHtml(codeContent, { lang });
// 注意!我们需要将其转换为 AST 然后进行插入
const fragmentAst = fromHtml(highlightedCode, { fragment: true });
parent.children.splice(index, 1, ...fragmentAst.children);

这里用到了shiki代码高亮库:

使用 shiki 高亮代码的基本步骤如下:

  1. 初始化高亮器:
const shiki = require('shiki');

let highlighter;
shiki.getHighlighter({ theme: 'nord' }).then(h => {
  highlighter = h;
});
  1. 高亮代码:

一旦你有了高亮器实例,你就可以用它来高亮代码了:

const code = `const greet = () => {
  console.log('Hello, world!');
};`;

const highlightedCode = highlighter.codeToHtml(code, 'javascript');
console.log(highlightedCode);

highlightedCode 现在包含适当高亮的 HTML,你可以将其插入到任何地方,如网页或文档中。

23. remark 插件——TOC插件的开发

TOC 的全称是 Table of Content,你可以理解为页面的大纲信息:

image.png

那这部分大纲信息如何来获取呢?实际上我们需要在编译阶段来提取 markdown 的标题,具体来说就是通过自定义 remark 插件来提取 TOC 的内容。

关键步骤

首先安装依赖:

pnpm i github-slugger mdast-util-mdxjs-esm acorn

pnpm i @types/mdast -D

遍历AST节点:

  • 首先遍历heading的depth在1-5之间的node,若满足这个条件,遍历其children;
  • 遍历children的过程中,若child的type为link类型,则将其children中child的value提取出来,否则直接返回child.value
  • originText使用sluuger进行规范化,生成id
  • { id, text: originText, depth: node.depth }存入TOC
  • 所有heading遍历完成后,将 TOC 的信息注入到模块中:
const insertCode = `export const toc = ${JSON.stringify(toc, null, 2)};`;

tree.children.push({
  type: 'mdxjsEsm',
  value: insertCode,
  data: {
    estree: parse(insertCode, {
      ecmaVersion: 2020,
      sourceType: 'module'
    }) as unknown as Program
  }
} as MdxjsEsm);

24. 生产环境SSG构建——如何支持多路由打包

1.首先将RouteService.ts 中添加一些兼容 SSR 的逻辑,主要是修改它的 generateRoutesCode 方法

// 添加 ssr 参数
+ generateRoutesCode(ssr = false) {
    return `
import React from 'react';
+ ${ssr ? '' : 'import loadable from "@loadable/component";'}

${this.#routeData
  .map((route, index) => {
+    return ssr
+      ? `import Route${index} from "${route.absolutePath}";`
+      : `const Route${index} = loadable(() => import('${route.absolutePath}'));`;
  })
  .join('\n')}
export const routes = [
  ${this.#routeData
    .map((route, index) => {
      return `{ path: '${route.routePath}', element: React.createElement(Route${index}) }`;
    })
    .join(',\n')}
];
`;
  }

之前我们通过 @loadable/component 进行浏览器端的按需加载,避免请求全量的组件代码,可以降低网络 IO 的开销。但在 SSR/SSG 阶段,所有的 JS 都通过本地磁盘进行读取,并没有网络 IO 开销相关的负担,因此我们可以通过静态 import 来导入组件。

2.然后回到 src/node/plugin-routes/index.ts 中,扩展一下约定式路由插件, 补充isSSR参数:

import { Plugin } from 'vite';
import { RouteService } from './RouteService';

export interface Route {
  path: string;
  element: React.ReactElement;
  filePath: string;
}

interface PluginOptions {
  root: string;
+  isSSR: boolean;
}

export const CONVENTIONAL_ROUTE_ID = 'island:routes';

export function pluginRoutes(options: PluginOptions): Plugin {
  const routeService = new RouteService(options.root);

  return {
    name: 'island:routes',
    async configResolved() {
      await routeService.init();
    },
    resolveId(id: string) {
      if (id === CONVENTIONAL_ROUTE_ID) {
        return '\0' + id;
      }
    },

    load(id: string) {
      if (id === '\0' + CONVENTIONAL_ROUTE_ID) {
+        return routeService.generateRoutesCode(options.isSSR || false);
      }
    }
  };
}

3.接着我们到 vitePlugins.ts 中,补充参数,修改如下:

export async function createVitePlugins(
  config: SiteConfig,
  restartServer?: () => Promise<void>,
+  isSSR = false
) {
  return [
    pluginIndexHtml(),
    pluginReact({
      jsxRuntime: 'automatic'
    }),
    pluginConfig(config, restartServer),
    pluginRoutes({
      root: config.root,
+      isSSR
    }),
    await pluginMdx()
  ];
}

4.也需要到 build.ts 中,传入 ssr 的标志位:

export async function bundle(root: string, config: SiteConfig) {
  const resolveViteConfig = async (
    isServer: boolean
  ): Promise<InlineConfig> => ({
    mode: 'production',
    root,
     // 传入 isServer 参数
+    plugins: await createVitePlugins(config, undefined, isServer),
    // ...
  });
  
  
  try {
    const [clientBundle, serverBundle] = await Promise.all([
       // client build
+    viteBuild(await resolveViteConfig(false)),
       // server build
+    viteBuild(await resolveViteConfig(true))
    ]);
    return [clientBundle, serverBundle] as [RollupOutput, RollupOutput];
  } catch (e) {
    console.log(e);
  }

5.如何开发多路由呢?

首先考虑路由数据如何获取:在ssr-entry.js中导出路由数据

// 导出路由数据
export { routes } from 'island:routes';

接着就可以在build.ts中顺利拿到路由信息,拿到路由信息后将render和routes传入renderPages(),同时renderPages需要进行改动。改动的地方主要有两部分:增加 routes 入参,遍历 routes 数组,针对每个路由生成对应的 HTML 内容,并写入到磁盘,这样产物就生成了。

25. 为什么选择unocss

所有的样式都是语义化,比如 pt 代表 padding-top,w 代表 width,同时我们不需要编写具体的 class 名称,给我们的开发降低了一定的负担。总体而言,原子化 CSS 方案可以有效地提升我们的开发效率,也是我在实际工作应用比较多的一类样式方案。

社区中的代表方案:TailwindCSS、WindiCSS 以及 Unocss,但为什么选择了unocss呢?

主要以三个维度来进行分析,包括:

  • 语法: Unocss 兼容了 Tailwind CSS 和 Windi CSS 的全部语法,但它并不是一个框架,而更像是一个底层的引擎、一个调度器,没有实现任何具体的规则和主题样式,而是通过一个个的 preset 来丰富样式转换的规则和各种编译功能;支持 Attributify 模式,也就是属性化的模式;它对于 CSS 图标的支持度也是相当不错的,可以做到开箱即用。你只需要接入对应的 preset 就可以使用了。
  • 性能:比 windicss 和 tailwindcss 快出了五十倍左右;
  • 调试体验:官方在这个方面也花了不少的精力,开发了一个调试面板,你可以在这个面板当中预览所有的模块内容,以及被提取出来的 CSS 代码,非常的直观和方便。

image.png

26. 如何接入unocss

1.首先安装依赖

pnpm i unocss

2.在vitePlugins.ts中接入相应的插件

import pluginUnocss from 'unocss/vite';
import unocssOptions from './unocssOptions';

export async function createVitePlugins(
  config: SiteConfig,
  restartServer?: () => Promise<void>,
  isSSR = false
) {
  return [
    pluginUnocss(unocssOptions),
    // ...
  ];
}

3.UnoCSS 相关的配置封装在 unocssOptions.ts 中,内容如下:

import { VitePluginConfig } from 'unocss/vite';
import { presetAttributify, presetWind, presetIcons } from 'unocss';

const options: VitePluginConfig = {
  presets: [presetAttributify(), presetWind({}), presetIcons()],
};

export default options;

27. 页面的数据问题:如何生产出页面数据以及在前端如何使用这些数据?

如何生产页面的数据

1.首先src/shared/index.ts定义数据类型

export type PageType = 'home' | 'doc' | 'custom' | '404';

export interface Header {
  id: string;
  text: string;
  depth: number;
}

export interface FrontMatter {
  title?: string;
  description?: string;
  pageType?: PageType;
  sidebar?: boolean;
  outline?: boolean;
}

export interface PageData {
  siteData: UserConfig;
  pagePath: string;
  frontmatter: FrontMatter;
  pageType: PageType;
  toc?: Header[];
}

2.改造入口文件client-entry.tsx,在正式渲染页面前需要获到pageData数据:

import { createRoot } from 'react-dom/client';
import { App, initPageData } from './app';
import { BrowserRouter } from 'react-router-dom';

async function renderInBrowser() {
  const containerEl = document.getElementById('root');
  if (!containerEl) {
    throw new Error('#root element not found');
  }
  // 初始化 PageData
  const pageData = await initPageData(location.pathname);
  createRoot(containerEl).render(
    <BrowserRouter>
      <App />
    </BrowserRouter>
  );
}

renderInBrowser();

initPageData 函数我们在 app.tsx 中进行实现:

// app.tsx
import { routes } from 'island:routes';
import { matchRoutes } from 'react-router-dom';
import { PageData } from 'shared/types';
import { Layout } from '../theme-default';
import siteData from 'island:site-data';

export async function initPageData(routePath: string): Promise<PageData> {
  // 获取路由组件编译后的模块内容
  const matched = matchRoutes(routes, routePath);

  if (matched) {
    // Preload route component
    const moduleInfo = await matched[0].route.preload();
    console.log(moduleInfo);
    return {
      pageType: 'doc',
      siteData,
      frontmatter: moduleInfo.frontmatter,
      pagePath: routePath
    };
  }
  return {
    pageType: '404',
    siteData,
    pagePath: routePath,
    frontmatter: {}
  };
}

解读initPageData

  • 首先改造island:site-data虚拟模块,将虚拟模块返回的内容添加上preload: () => import('${route.absolutePath}')方法,它的作用就是为了获取路由组件编译后的模块内容;
  • 然后使用react-router-dom的matchRoutes,匹配当前路径和路由配置,如果匹配到了,返回的内容matched包含了path、element、preload;
  • 执行preload,获取当前页面的pagetype、元信息、siteData、以及pagePath,一并返回;
  • 如果匹配不到返回:{ pageType: '404', siteData, pagePath: routePath, frontmatter: {} };

前端组件接入initPageData返回的数据

首先createContext创建上下文(新建 src/runtime/hooks.ts):

import { createContext, useContext } from 'react';
import { PageData } from 'shared/types';

export const DataContext = createContext({} as PageData);

export const usePageData = () => {
  return useContext(DataContext);
};

编写了一个 usePageData hook,这个 hook 在后续会频繁用到,主要的功能就是提供数据供前端组件消费。

接着在client-entry.tsx中接入Context相关的逻辑:

import { createRoot } from 'react-dom/client';
import { App, initPageData } from './app';
import { BrowserRouter } from 'react-router-dom';
import { DataContext } from './hooks';

async function renderInBrowser() {
  const containerEl = document.getElementById('root');
  if (!containerEl) {
    throw new Error('#root element not found');
  }
  // 初始化 PageData
  const pageData = await initPageData(location.pathname);
  createRoot(containerEl).render(
    <DataContext.Provider value={pageData}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </DataContext.Provider>
  );
}

renderInBrowser();

最后再修改一下Layout:

import { usePageData } from '../../runtime';
import 'uno.css';

export function Layout() {
  const pageData = usePageData();
  // 获取 pageType
  const { pageType } = pageData;
  // 根据 pageType 分发不同的页面内容
  const getContent = () => {
    if (pageType === 'home') {
      return <div>Home 页面</div>;
    } else if (pageType === 'doc') {
      return <div>正文页面</div>;
    } else {
      return <div>404 页面</div>;
    }
  };
  return <div>{getContent()}</div>;
}

28. 实现深色模式

UI 部分比较简单,这里就不展开介绍了。接下来我们来开发背后的逻辑部分,也就是我们在各种站点经常接触到的主题切换的功能

这里需要注意的主要有两个地方:

  • 深色模式本质上是往 html 这个根标签上追加一个名为 dark 的 class。
  • 在切换模式之后,我们需要将主题模式的状态进行本地化存储,这样在页面刷新后仍然能恢复之前的主题,这一步通过 localStorage 来完成。

主要逻辑:

  • 点击切换主题按钮,将会在html根标签上追加一个dark 的 class
  • 在切换模式之后,我们需要将主题模式的状态进行本地化存储,这样在页面刷新后仍然能恢复之前的主题,这一步通过 localStorage 来完成。

29. Home页面实现——根据数据自动组装首页内容

首页所需要的数据都定义在 frontmatter 里面,我们可以在 usePageData 这个钩子里面拿到这些数据,然后渲染出完整的页面内容。

首页主要分为两个部分:

  • HomeHero,即页面上半部分的 slogan 介绍文字及 logo 图片。
  • HomeFeatures,即页面下半部分的特性介绍。

首先,frontmatter 是根据remark-frontmatter这个remark插件来获取到的,并保存在对应的mdx文件中;获取到了fromtmatter 后,取出里面的hero信息和feature信息,填充至HomeHero组件 和 HomeFeatures组件中。

30.处理静态资源

在 Vite 插件的 configureServer 钩子中操作了 Vite 的 DevServer 实例,往里面增加了一个静态资源处理的中间件(通过 sirv 包),这样我们就可以顺利地访问 docs/public 下的静态资源了。

pnpm i sirv
+ import sirv from 'sirv';

export function pluginConfig(
  config: SiteConfig,
  restartServer?: () => Promise<void>
): Plugin {
+    configureServer(server) {
+      const publicDir = path.join(config.root, PUBLIC_DIR);
+      if (fs.pathExistsSync(publicDir)) {
+        server.middlewares.use(sirv(publicDir));
+      }
+    }
}

具体来说,这段代码执行了以下操作:

  1. const publicDir = join(config.root, 'public');:首先,它构建了一个表示项目 public 文件夹路径的变量 publicDir。这个文件夹通常用于存放在开发和构建过程中需要公开访问的静态文件,例如图像、字体、JSON 文件等。
  2. server.middlewares.use(sirv(publicDir));:接下来,它使用 sirv 中间件将 publicDir 中的静态文件提供给开发服务器。sirv 是一个用于静态文件服务的中间件,它可以将静态文件映射到指定的 URL 路径。

总之,这段代码的目的是在开发服务器中使用 sirv 中间件,将 public 文件夹中的静态文件暴露给客户端。这样,在开发过程中,你可以通过 URL 访问这些静态文件,比如图片、字体等。这有助于在开发阶段预览这些文件。

31.自动化侧边栏生成

在 usePageData 这个钩子里面拿到sidebar的内容,根据sidebar的结构和数据和当前页面的pathname,匹配出当前的matchedSidebar,向<Sidebar />组件传入matchedSidebar和pathname。

sidebar结构如下:

    sidebar: {
      '/guide/': [
        {
          text: '教程',
          items: [
            {
              text: '快速上手',
              link: '/guide/a'
            },
            {
              text: '如何安装',
              link: '/guide/b'
            },
            {
              text: '注意事项',
              link: '/guide/c'
            }
          ]
        }
      ]
    }

Sidebar如何实现?

主要逻辑: 首先遍历到一级标题,然后将一级标题下的所有文章标题给遍历完,然后接着返回遍历下一个一级标题。遍历的时候要根据pathname来判断二级标题是否要高亮,因为pathname是当前的路由。

32. 正文页面内容生成

正文Content

首先要计算正文部分的宽度,通过 scss 中的变量以及 calc 函数来完成。那为什么要单独计算正文宽度呢?

  1. 可以注意一下左侧的侧边栏是 fixed 布局,而后续我们要开发的右侧大纲栏也是 fixed 布局,因此在计算正文宽度的时候就需要手动将侧栏的宽度以及相应的 padding 减掉,计算出比较合适的宽度。
  2. 基于 100vw 来计算内容宽度可以让内容宽度适应屏幕大小,方便做响应式布局。

计算公式: 100 vw - sidebar 的宽度 - 右侧大纲栏的宽度 - padding-right

正文Footer

DocFooter主要是提供上一页下一页的功能

如何实现上一页下一页逻辑:实现上一页/下一页切换的逻辑,即 usePrevNextPage 钩子。新建 theme-default/logic/usePrevNextPage.ts,内容如下:

import { usePageData } from '@runtime';
import { useLocation } from 'react-router-dom';
import { SidebarItem } from 'shared/types';

export function usePrevNextPage() {
  const { pathname } = useLocation();
  const { siteData } = usePageData();
  const sidebar = siteData.themeConfig?.sidebar || {};
  const flattenTitles: SidebarItem[] = [];

  // 遍历 Sidebar 数据,收集所有的文章信息,并平铺到一个数组里面
  Object.keys(sidebar).forEach((key) => {
    const groups = sidebar[key] || [];
    groups.forEach((group) => {
      group.items.forEach((item) => {
        flattenTitles.push(item);
      });
    });
  });

  const pageIndex = flattenTitles.findIndex((item) => item.link === pathname);

  const prevPage = flattenTitles[pageIndex - 1] || null;
  const nextPage = flattenTitles[pageIndex + 1] || null;

  return {
    prevPage,
    nextPage
  };
}

遍历 Sidebar 数据,收集所有的文章信息,并平铺到一个数组里面,根据当前页面的在sidebar中的index,判断是否有前驱和后继,然后将前驱和后继封装成一个对象返回。 然后引入<DocLayout />组件中。

33.如何实现TOC——右侧大纲栏模块

  • 关于TOC的信息,已经在MDX的TOC插件中解析了出来,并输入到了模块中。因此,我们只需使用matched = matchRoutes(routes, routePath);的match中的preload()函数,就可以获取路由组件编译后的模块内容,因此可以获取到当前页面的TOC;
  • 获取了TOC后,将其放在pageData中一并返回给页面;
  • 新增< Aside/>组件,然后再组件<DocLayout/>中导入Aside组件;

Aside组件的实现:

1.先判断TOC的长度,如果为0则不需要渲染TOC;

2.map遍历TOC中的item,将其用<li></li>标签包裹住并返回,这个过程需要根据item的depth对padding-left做一些处理;

滚动事件的逻辑:

主要有两个功能函数:bindingAsideScrollscrollToTarget,主要用于处理页面中的锚点导航。

整体来看,主要实现的功能是:点击某个链接可以滚动到页面的相应位置,并且在滚动页面时,导航栏的链接会根据当前的滚动位置自动高亮。

以下是详细解释:

  1. 变量声明:

    • links: 一个数组,存储页面中所有的锚点 (<a> 标签)。
    • NAV_HEIGHT: 声明了一个常量 NAV_HEIGHT,代表导航栏的高度。
  2. bindingAsideScroll 函数:

    • 这个函数主要用于监听滚动事件,并根据当前滚动的位置高亮导航栏的相应链接。
    • 函数内部首先获取 aside-markeraside-container 的 DOM 元素。
    • headers 保存了页面中所有的锚点链接的 hash 值。
    • activate 函数: 用于高亮某个锚点链接。
    • setActiveLink 函数: 当滚动事件触发时,该函数会执行以下逻辑:
      1. 获取所有的锚点链接 (anchor links):

        • 使用 querySelectorAll 方法获取页面中所有的 .island-doc .header-anchor 元素(即页面中所有锚点链接),并将其转换为一个数组 links
        • 使用 filter 方法过滤掉父元素是 H1 的锚点链接。
      2. 检查是否滚动到页面底部:

        • 使用 scrollTopwindow.innerHeight 计算当前滚动的位置,并与 scrollHeight 比较,判断是否已经滚动到页面底部。
        • 如果已经滚动到底部,则高亮最后一个链接。
      3. 寻找需要高亮的锚点链接:

        • 遍历所有的锚点链接,并获取当前和下一个锚点。

        • 计算当前锚点的位置(offsetTop)。

        • 根据滚动的位置和锚点的位置来判断哪个锚点需要被高亮。

        • 如果滚动的位置在当前锚点和下一个锚点之间,则高亮当前锚点。

        • 如果滚动的位置小于第一个锚点的位置,则高亮第一个锚点。

        • 如果是最后一个锚点,则高亮最后一个锚点。

    • 最后,该函数绑定滚动事件监听器,并返回一个函数,用于解绑该事件监听器。这是一个常见的模式,可以防止因为事件没有解绑而引起的内存泄漏。
  3. scrollToTarget 函数:

    • 这个函数允许页面平滑滚动到指定的 DOM 元素。
    • 输入参数有 target (目标 DOM 元素) 和 isSmooth (是否平滑滚动)。
    • 函数内部计算了滚动到目标元素的正确的滚动位置,然后调用 window.scrollTo 方法来滚动到该位置。

34. Island架构的基本实现原理和流程

采用类似于 Astro 的 Islands 组件标识,利用__island将组件标识为 Islands 组件,比如:

import { Aside } from '../components/Aside';

export function Layout() {
  return <Aside __island />;
}

然后通过 Babel 插件编译 JSX,将这些 __island prop 进行转换:

import { Aside } from '../components/Aside';

export function Layout() {
  return <Aside __island="/Users/project/src/components/Aside.tsx" />;
}

目的是记录下 Islands 组件的文件路径。

这样我们就能在 renderToString 的时候,也就是 SSR 运行时搜集到所有 Islands 组件的信息。整体的流程如下:

F5LHF~$LT_OX2FG9{P7E0DE.png

客户端拿到服务端下发的HTML页面后,这个页面是完整的,但是不具有交互功能,需要下载和执行 JS,完成 hydration才具有交互功能;islands架构就是将具有交互功能的各个模块给提取出来,给非静态组件针对性的进行hydration。

35.如何实现 __island  prop 的转换

为了在编译阶段注入 Islands 组件的路径信息,方便后续的 Islands 组件的打包,因此需要开发一个babel插件,实现__island 这个 prop 的转换。

举个例子:

// 转换前
<Aside __island />

// 转换后
<Aside __island="../comp/id.ts!!ISLAND!!/User/import.ts" />

转换后的 __island prop 包含了 importPath 和 importer 两方面的信息,也就是引用方式 和 引用者

必要依赖

pnpm i @babel/core @babel/preset-react @babel/traverse @babel/helper-plugin-utils

  1. @babel/core: Babel 的核心库,提供了 Babel 转换的主要功能。它是所有 Babel 插件和预设的基础。你可以通过它来调用 Babel 的 API,执行转换操作,或者与其他工具(如 Webpack)集成。

  2. @babel/preset-react: 是一个 Babel 预设,包含了所有用于将 React JSX 语法转换为普通 JavaScript 的插件。如果你正在使用 React 和 JSX 语法,那么你需要这个预设来确保你的代码能在没有支持 JSX 的环境中运行。

  3. @babel/traverse: 是 Babel 的一个模块,用于遍历和处理抽象语法树(AST)。它提供了一组工具,你可以用它来遍历 AST,查找和处理特定的节点。它是编写自定义 Babel 插件的核心模块。

  4. @babel/helper-plugin-utils: 是一个用于创建 Babel 插件的工具库。它提供了一些实用的功能和验证,可以帮助你更容易地编写 Babel 插件。

这些模块都是 Babel 生态系统的一部分,用于不同的目的。如果你正在使用 Babel 转换你的 JavaScript 代码,并且你还使用了 React 和 JSX 语法,那么这些模块都是必需的。如果你想要编写自定义的 Babel 插件,那么 @babel/traverse@babel/helper-plugin-utils 也将非常有用。

插件框架

import { declare } from '@babel/helper-plugin-utils';
import type { Visitor } from '@babel/traverse';
import type { PluginPass } from '@babel/core';

export default declare((api) => {
  api.assertVersion(7);
  
  const visitor: Visitor<PluginPass> = {
    // 核心逻辑实现
  }
  
  return {
    name: 'transform-jsx-island',
    visitor
  };
}

核心部分visitor的实现步骤

  1. JSXOpeningElement(path, state) {: 当访问到一个JSX开启标签时,该函数会被触发。它接受两个参数:path是当前节点及其上下文的信息,state是插件的状态信息。

  2. const name = path.node.name;: 获取当前JSX标签的名称。

  3. let bindingName = '';: 初始化一个空字符串变量bindingName,用于存储JSX标签的绑定名称。

  4. if (name.type === 'JSXIdentifier') {: 如果标签名称的类型是JSXIdentifier(例如<div>),则执行以下代码。

  5. bindingName = name.name;: 将标签名称赋值给bindingName

  6. } else if (name.type === 'JSXMemberExpression') {: 如果标签名称的类型是JSXMemberExpression(例如<A.B.C>),则执行以下代码。

  7. let object = name.object;: 获取表达式中的对象部分。

  8. while (t.isJSXMemberExpression(object)) {: 使用while循环遍历表达式,直到找到最左边的对象(例如,在A.B.C中找到A)。

  9. object = object.object;: 进入表达式的下一层级。

  10. }: 结束while循环。

  11. bindingName = object.name;: 将最左边的对象名称赋值给bindingName

  12. } else {: 如果标签名称的类型既不是JSXIdentifier也不是JSXMemberExpression,则执行以下代码。

  13. return;: 直接退出函数。

  14. }: 结束else语句块。

  15. const binding = path.scope.getBinding(bindingName);: 使用path.scope.getBinding方法根据bindingName获取当前作用域中的绑定信息。

  16. if (binding?.path.parent.type === 'ImportDeclaration') {: 如果绑定信息的父节点是一个导入声明(例如import Component from 'module'),则执行以下代码。

  17. const source = binding.path.parent.source;: 获取组件对应的导入路径。

  18. const attributes = (path.container as t.JSXElement).openingElement.attributes;: 获取当前JSX元素的所有属性。

  19. for (let i = 0; i < attributes.length; i++) {: 遍历所有属性。

  20. const name = (attributes[i] as t.JSXAttribute).name;: 获取当前属性的名称。

  21. if (name?.name === '__island') {: 如果属性名称为__island,则执行以下代码。

  22. (attributes[i] as t.JSXAttribute).value = t.stringLiteral(source.value{source.value}{MASK_SPLITTER}${normalizePath(state.filename || '')});: 修改找到的__island属性的值。它使用了一个模板字符串来构建新的值,包括组件的导入路径、一个分隔符(MASK_SPLITTER)、以及一个规范化的文件路径。

  23. }: 结束if语句块。

  24. }: 结束for循环。

  25. }: 结束if语句块。

  26. }: 结束JSXOpeningElement函数。

总结

  1. 根据当前JSX标签的名称类型(JSXIdentifierJSXMemberExpression),获取标签的绑定名称(例如,对于<A.B.C>标签,它会提取出A)。
  2. 使用绑定名称在当前作用域中查找绑定信息。
  3. 确认绑定信息来自于一个导入声明(ImportDeclaration),获取组件的导入路径。
  4. 遍历当前JSX标签的所有属性,寻找名为__island的属性。
  5. 如果找到__island属性,则修改其值。新值包括组件的导入路径、一个分隔符、以及一个规范化的文件路径。

这个Babel插件主要用于修改JSX标签中的__island属性的值。它会遍历所有的JSX标签,找到名为__island的属性,并将其值修改为组件的导入路径和文件路径的组合。

36. 自定义 JSX Runtime 的逻辑

基于React的新的JSX Runtime(react/jsx-runtime),提供了自定义的jsxjsxs函数,以便对包含特定__island prop的组件进行特殊处理。此外,该代码还维护了一个data对象,用于跟踪与这些组件相关的数据。

import * as jsxRuntime from 'react/jsx-runtime';

// 拿到 React 原始的 jsxRuntime 方法,包括 jsx 和 jsxs
// 注: 对于一些静态节点,React 会使用 jsxs 来进行创建,优化渲染性能
const originJsx = jsxRuntime.jsx;
const originJsxs = jsxRuntime.jsxs;

export const data = {
  islandProps: [],
  islandToPathMap: {}
};

const internalJsx = (jsx, type, props, ...args) => {
  // 如果发现有 __island 这个 prop,则视为一个 Island 组件,记录下来
  if (props && props.__island) {
    data.islandProps.push(props);
    const id = type.name;
    data['islandToPathMap'][id] = props.__island;

    delete props.__island;
    return jsx('div', {
      __island: `${id}:${data.islandProps.length - 1}`,
      children: jsx(type, props, ...args)
    });
  }
  // 否则走原始的 jsx/jsxs 方法
  return jsx(type, props, ...args);
};

// 下面是我们自定义的 jsx 和 jsxs
export const jsx = (...args) => internalJsx(originJsx, ...args);

export const jsxs = (...args) => internalJsx(originJsxs, ...args);

export const Fragment = jsxRuntime.Fragment;

export const clearIslandData = () => {
  data.islandProps = [];
  data.islandToPathMap = {};
};

核心思想

进行一个拦截:当在jsxRuntime进行编译的时候,发现有__island 这个 prop,则视为一个 Island 组件,记录下来,保存至

export const data = { islandProps: [], islandToPathMap: {} };

islandProps 和 islandToPathMap 两份数据,前者是一个数组,用来记录 Islands 组件的数据,而后者是一个对象结构,用来记录 Island 组件的路径信息。

关键步骤

  1. 引入原始的JSX Runtime函数:

    • react/jsx-runtime模块中引入了jsxjsxs函数,以及Fragment
    • 将它们分别赋值给originJsxoriginJsxs常量。
  2. 定义data对象:

    • 用于存储与特定__island prop相关的数据。
    • islandProps是一个数组,用于存储包含__island prop的组件的props。
    • islandToPathMap是一个对象,用于将__island组件的名称映射到它们的__island prop。
  3. 定义internalJsx函数:

    • 该函数的第一个参数jsx是一个函数,可能是originJsxoriginJsxs
    • 如果props中包含__island prop,则:
      • props对象添加到data.islandProps数组中。
      • 将组件的名称和__island prop添加到data.islandToPathMap对象中。
      • 删除props.__island属性。
      • 返回一个div元素,其__island属性值是组件名称和索引的组合,并包含原始组件作为子元素。
    • 如果没有__island prop,则直接调用原始的jsxjsxs函数。
  4. 定义自定义的jsxjsxs函数:

    • 调用internalJsx函数,并将originJsxoriginJsxs作为第一个参数传递。
  5. 导出Fragmentjsxjsxs函数和data对象:

    • 将这些对象导出,以便在其他模块中使用。
  6. 定义并导出clearIslandData函数:

    • 重置data.islandPropsdata.islandToPathMap,清除所有已存储的数据。

这个自定义的JSX Runtime实现允许对包含特定__island prop的组件进行特殊处理,并将与这些组件相关的数据存储在data对象中。

除此之外,需要在pluginReact插件中指定jsxRuntime 参数为 automatic,而不是 classic,因为classic 模式会将组件编译为 React.createElement(xxx) 的模式,无法使用我们的jsxRuntime;还要指定jsxruntime的路径,根据isSSR参数判断。

import { pluginIndexHtml } from './plugin-island/indexHtml';
import pluginReact from '@vitejs/plugin-react';
import { pluginConfig } from './plugin-island/config';
import { pluginRoutes } from './plugin-routes';
import { SiteConfig } from 'shared/types';
import { pluginMdx } from './plugin-mdx';
import pluginUnocss from 'unocss/vite';
import unocssOptions from './unocssOptions';
import path from 'path';
+ import { PACKAGE_ROOT } from './constants';
import babelPluginIsland from './babel-plugin-island';

export async function createVitePlugins(
  config: SiteConfig,
  restartServer?: () => Promise<void>,
  isSSR = false
) {
  return [
    pluginUnocss(unocssOptions),
    pluginIndexHtml(),
    pluginReact({
+      jsxRuntime: 'automatic',
+      jsxImportSource: isSSR
+        ? path.join(PACKAGE_ROOT, 'src', 'runtime')
+        : 'react',
+      babel: {
+        plugins: [babelPluginIsland]
+      }
    }),
    pluginConfig(config, restartServer),
    pluginRoutes({
      root: config.root,
      isSSR
    }),
    await pluginMdx()
  ];
}