Island 机制实现

本文讲 island 从 markdown 标签到客户端激活的完整链路。涉及 src/shared/islands.ts(内置名单)、src/node/markdown/islands.ts(扫描与预渲染)、src/client/runtime/(注册表与激活)、src/client/islands/(内置组件)。用法层面的说明在指南的 Islands 页,本文只讲实现。

链路总览

一条 island 的生命周期分四步:

  1. 构建期扫描:extractIslands 在源文里找到已注册的 PascalCase 标签,换成占位 div。
  2. 构建期预渲染:内部 markdown 经同一渲染器递归渲染成 HTML,最终产出 <div data-ap-island="Tag" data-props="...">内部HTML</div>。
  3. 页面加载:entry.tsx 调 registerIslands() 装入组件注册表,再调 hydrateIslands()。
  4. 客户端激活:占位 div 的 innerHTML 作为 childrenHtml 传给组件,Solid 组件挂载到该 DOM 节点上。

正文其余部分从不进入 Solid 渲染树;组件用 mountComponent 挂到已有 DOM 节点,不做 SPA 式的整页激活复用。

扫描规则

extractIslands(src/node/markdown/islands.ts)是一次性字符扫描,规则:

island 只允许块级使用,标签独占段落。属性解析窗口上限 64KB,超出时 warn 并把标签留作纯文本——静默透传会让作者找不到原因。

props 协议

属性语法向 Vue 看齐,实现在 parseAttr:

序列化侧是 renderIslandDiv:props JSON 经 attribute 转义后写进 data-props,内部 markdown 的渲染结果成为 div 的 innerHTML。客户端拿到的 props 就是这份 JSON 加 childrenHtml(见下)。island 内部 markdown 里还可以嵌套 island——renderFragment 递归走同一套提取与渲染。

客户端激活

激活核心在 src/client/runtime/hydrate.ts 的 hydrateIslands:

ts
const childrenHtml = el.innerHTML;
el.innerHTML = '';
markMounted(el);
mountComponent(Comp, el, { ...props, childrenHtml } satisfies IslandProps);

要点:

注册表与编译期覆盖检查

内置 island 名单放在 src/shared/islands.ts 的 BUILTIN_ISLAND_NAMES:node 侧渲染器只需要标签名,不能 import 客户端组件,所以名单放 shared。

客户端注册表在 src/client/runtime/islands.ts,名单与组件 map 的一致性由一条编译期检查保证:

ts
const _coverageCheck: Record<(typeof BUILTIN_ISLAND_NAMES)[number], true> = {
  Giscus: true,
  // ...
};

名单加了名字而注册表漏了组件(或反过来),tsc 直接报错,等不到运行时。

站点 island 经 vite 虚拟模块 virtual:absolute-press/islands 注入:src/node/build/plugin.ts 的 load 钩子按 config.islands 生成 import I0 from "<模块>"; export default { "Counter": I0 } 这样的模块代码,registerIslands() 把内置与站点两份 map 合并后通过 setIslandRegistry 推给 hydrate 模块。

依赖方向是刻意的单向流动:runtime/islands.ts value-import 组件,hydrate.ts 保持为叶子模块,组件经 setIslandRegistry 下行——island 组件自己也要调 hydrateIslands 做子树重激活,反向 import 会闭合成 ESM 环。registerIslands 必须由 entry.tsx 显式调用而不能做成模块副作用:hydrateIslands 只是 re-export,没有显式调用时 rolldown 会把整个模块 tree-shake 掉,所有 island 变成 unknown。

懒加载取舍

Mermaid 与 G2Plot 的组件壳随主 bundle 走,重依赖动态 import:Mermaid.tsx 里 import('mermaid')(mermaid 含 elk,chunk 很大),G2Plot.tsx 里 import('@antv/g2plot')。页面没有对应 island 时这些 chunk 完全不下载;代价是首次渲染图表要等一次网络与初始化。mermaid 的 ```mermaid 围栏也算入口:客户端 upgradeMermaidFences 把这类代码块升级成 Mermaid island,构建侧对 mermaid 围栏跳过行号、折叠与工具行(SPECIAL_FENCE_LANGS),因为客户端会重建这部分 DOM。

entryListIslands:站点 island 复用 @@@ 条目管线

ExpandableList 的 @@@ 条目语法对站点自建列表同样有用,实现上把拆分管线做成了可复用的。站点把 island 名加进配置 entryListIslands 后,renderer.ts 里的判定多一个来源:

ts
isEntryListIsland(spec.name) || entryListIslands.has(spec.name);

命中的 island children 不走普通递归渲染,交给 renderEntryListChildren(src/node/markdown/entries.ts):

客户端组件用 childrenHtml 接住这份骨架,按 @@@ 标题作 key 解析回条目、把 TS 数据模块里的 meta 填进对应行,再调 absolute-press/client 导出的 hydrateIslands 重新激活正文里的嵌套 island。典型场景是「TS 数据 + md 插槽正文」的站点列表页:meta 列来自可校验的数据模块,markdown 只留正文。具体的配置写法与注意事项见指南对应节。