当前文档对应 qiankun 3.0(RC),安装请使用 npm i qiankun@rc;2.x 文档见 v2 站点。
Skip to content

HTML Entry 流式加载原理 ​

本页面向维护者说明流式加载器的实现细节。面向使用者的约定见 HTML Entry。

qiankun 将微应用的 index.html 作为入口,通过流式解析、逐节点资源转换和增量 DOM 提交完成加载。主应用无需额外维护脚本和样式资源清单。本页介绍加载流程、入口 HTML 需要满足的约定及当前实现限制。

HTML Entry ​

qiankun 中的 entry 是微应用 HTML 文档的 URL:

ts
import { loadMicroApp } from 'qiankun';

const container = document.getElementById('subapp-container');
if (!container) throw new Error('subapp-container not found');

const microApp = loadMicroApp({
  name: 'app-react',
  entry: 'http://localhost:7101', // 微应用的 index.html
  container,
});

qiankun 将 HTML 文档作为微应用资源的唯一描述文件,并按照文档中的 <script>、<link> 和 <style> 声明加载资源,主应用无需同步维护 JavaScript 和 CSS 文件清单。新的页面会话或运行时缓存未命中时,qiankun 会从最新的 index.html 读取带哈希的资源文件名;同一页面内的重新挂载可能复用已缓存的入口和生命周期,因此不能指望通过重新挂载拿到新部署的版本。

loadEntry(entry, container, opts)(packages/loader/src/index.ts)负责处理 HTML Entry 的全过程。它不会将资源直接插入主文档,而是在节点进入应用容器前交给沙箱和资源转译器处理。

流式加载的原因 ​

浏览器在顶层导航中可以边接收 HTML 边解析和渲染,但传统的微应用加载通常先下载完整 HTML,再从字符串中提取脚本和样式,因此无法利用服务端的流式响应。

qiankun 3.0 之前的加载过程也是如此:完整下载 HTML 后,通过正则表达式提取 <script> 和 <link>,再逐项处理。v3 改用客户端流式处理,在接收 HTML 响应的同时转换节点并写入当前页面的 DOM,使首次加载和后续路由切换都可使用流式处理。

该实现主要带来两项改进:

  • 缩短资源发现时间。 外部脚本和样式一旦出现在当前数据块中,就可以开始处理,无需等待完整文档下载。解析方式也由正则表达式改为基于 writable-dom 的 DOM 遍历。
  • 减少手动模拟浏览器行为。 旧实现通过 eval 执行脚本,需要自行模拟 <script> 的 load 和 error 事件。v3 将转换后的脚本节点插入 DOM,由浏览器负责执行和事件派发。Classic 脚本使用 blob URL,模块脚本则由 ESM 沙箱实现处理。

流式处理流程 ​

qiankun 基于 ReadableStream 处理响应。网络字节到达后,HTML 会立即进入解析和 DOM 提交流程,而不是先缓冲为完整字符串。

获取 res = await fetch(entry) 后,响应体依次经过以下阶段:

packages/loader/src/index.ts 中的调用结构如下:

ts
let htmlStream = res.body.pipeThrough(new TextDecoderStream(), { signal });
if (streamTransformer) {
  htmlStream = htmlStream.pipeThrough(streamTransformer(), { signal });
}

void htmlStream
  .pipeThrough(
    createTagTransformStream([
      { tag: '<head>', alt: '<qiankun-head>' },
      { tag: '</head>', alt: '</qiankun-head>' },
    ]),
    { signal },
  )
  .pipeTo(
    createAbortableDOMStream(container, (clone) => {
      // ...
      return nodeTransformer ? nodeTransformer(clone, transformerOpts) : clone;
    }),
    { signal },
  );

createAbortableDOMStream 是加载器内部的取消包装器,底层 sink 由 WritableDOMStream(container, null, transform) 返回。上面省略了资源记录、defer 和入口识别等处理,不是直接调用构造函数的完整示例。

各阶段职责如下:

阶段职责
TextDecoderStream将原始字节解码为 UTF-8 字符串流
streamTransformer可选的 () => TransformStream<string, string>,在解析前改写 HTML 文本,例如替换固定 URL;通过 AppConfiguration 配置
createTagTransformStream在字符串层执行标签改写,用于 <head> 虚拟化
WritableDOMStreamwritable-dom 的项目分支(packages/loader/src/writable-dom/),负责增量解析 HTML;遇到同步脚本和样式表时阻塞以保持执行顺序,并在阻塞期间预加载其他资源

由于写入端按数据块提交内容,微应用 DOM 可以在整个 HTML 文档下载完成前开始构建。

逐节点转译 ​

WritableDOMStream 的第三个参数是节点回调。每个节点从临时解析文档移入真实 DOM 前,加载器都会调用一次该回调。此时节点尚未激活,因此 <script> 不会提前执行,<link> 也不会向真实文档发起请求。

回调内部执行 nodeTransformer(clone, transformerOpts)。默认的 defaultNodeTransformer 将处理交给 transpileAssets,后者按标签类型分发:

  • SCRIPT → transpileScript:Classic 脚本经包装后指向绑定沙箱作用域的 blob URL;模块脚本标记为 data-esm="true",再交给 ESM 沙箱实现。
  • LINK → transpileLink:启用样式隔离时,改写外部样式表和预加载节点。
  • STYLE → transpileStyle:仅在 sandbox.styleIsolation 开启时转换,否则保持原样。

也可以通过 AppConfiguration 提供自定义 nodeTransformer。默认实现已经覆盖 <script>、<link> 和 <style> 节点。

<head> 虚拟化 ​

微应用运行时可能调用 document.head.appendChild(...) 注入样式或预加载代码分块。如果入口中的 <head> 直接映射到真实 document.head,这些节点会进入主应用的 <head>,并影响其他应用。

因此,qiankun 在构建 DOM 前先在字符串层改写 <head> 标签。createTagTransformStream 在 packages/loader/src/index.ts 中配置以下规则:

ts
{ tag: '<head>',  alt: '<qiankun-head>' }
{ tag: '</head>', alt: '</qiankun-head>' }

微应用的 <head>...</head> 会转换为应用容器内的 <qiankun-head>...</qiankun-head> 自定义元素。标签名 qiankun-head 定义于 packages/sandbox/src/consts.ts。

随后,沙箱的动态追加(dynamic append)补丁将 <qiankun-head> 作为应用级虚拟 <head>。微应用向 document.head 追加节点时,补丁会将节点重定向到 container.querySelector('qiankun-head')(packages/sandbox/src/patchers/dynamicAppend/common.ts),而不是真实 document.head。这些节点因此被限制在应用容器中,并在卸载时随容器清理。

转换器会立即输出无需继续匹配的内容,仅保留可能与下一数据块组成标签的尾部前缀。每条替换规则最多使用一次;流结束时,未匹配的剩余文本直接输出,不会等待补出缺失的 <head>。它按字面量匹配标签,不是 HTML 解析器。

entry 脚本约定 ​

HTML Entry 可能包含多个脚本,其中负责导出 bootstrap、mount 和 unmount 的脚本需要通过 entry 属性标识:

html
<script src="/app.js" entry></script>

流式解析阶段会在 packages/loader/src/index.ts 中执行以下校验:

  • 一份 HTML 只能包含一个 entry 脚本。 如果第二个外部脚本也带有 entry,loadEntry 会抛出异常:

    QiankunError: You should not include more than 1 entry scripts in a single HTML entry

  • 只有外部脚本可以作为 entry。 脚本必须包含 src 或 data-src;没有这些属性的内联脚本不能作为 entry 脚本。

相关分类函数如下:

函数判定条件
isExternalScripttagName === 'SCRIPT',且包含 src 或 data-src
isEntryScript属于外部脚本,且包含 entry 属性
isDeferScript属于外部脚本,且包含 defer 属性

通常无需手动添加 entry。@qiankunjs/bundler-plugin 会在受支持的 Webpack 和 Vite 构建中,自动为相应脚本添加 entry 属性。

entry 属性的位置

Webpack UMD 构建会在 runtime 或 main 构建产物对应的脚本上添加 entry,Vite ESM 构建则在 <script type="module"> 上添加该属性。两种情况均由插件处理,无需修改生成后的 index.html。

Classic 与 ESM 的入口处理 ​

标记为 entry 的脚本根据自身类型进入相应流程:

  • Classic(<script src="..." entry>,UMD/全局构建):qiankun 为脚本绑定 onload 和 onerror。加载完成后,从沙箱中解析入口导出。
  • ESM(<script type="module" ... entry>):转译后添加 data-esm="true" 并移除 src,避免浏览器直接执行。加载器通过结构化的 Compartment 接口调用 importDocumentModules(),以其返回的模块命名空间解析入口,不直接依赖 EsmSandboxEngine。模块处理流程见 ESM 沙箱实现。

模块脚本在流式解析期间注册,输入流结束后由 compartment.importDocumentModules() 驱动按文档顺序执行。onDOMStreamSettled 会等待模块求值及 Classic defer 脚本结束后才触发,因此它与入口生命周期 Promise 的完成时机不同:Classic 入口可以在 HTML 尾部仍在写入时先完成。

defer 与阻塞期间预加载 ​

WritableDOMStream 遇到同步脚本和样式表时会阻塞,以保证执行顺序。在等待当前资源时,它仍会预加载输入流中已发现的其他资源,避免网络连接空闲。

对于包含 defer 的外部脚本,加载器为每个脚本创建 Deferred 并加入 prepareDeferredQueue 维护的队列。入口 HTML 处理完成后,队列会依次完成这些 Deferred,以保持与浏览器原生 defer 语义一致。

应用导出解析 ​

脚本执行完成后,两种执行方式通过不同来源返回生命周期对象:

  • Classic:UMD 入口脚本将生命周期对象赋值给全局变量,例如 window.<libraryName> = { bootstrap, mount, unmount }。沙箱隔离膜使用 latestSetProp 记录脚本最后设置的全局属性。标记为 entry 的非 ESM 脚本触发 load 事件时,onEntryLoaded() 以 sandbox.globalThis[sandbox.latestSetProp] 的值完成加载器 Promise。qiankun 会在调用应用自行注册的监听器前读取 latestSetProp,避免该值被后续写入覆盖。
  • ESM:compartment.importDocumentModules() 返回入口模块命名空间,qiankun 从其中读取具名生命周期导出或 export default { ... }。

如果输入流结束后没有找到显式 entry 脚本,加载器采用以下默认规则:

  • 存在 ESM 模块脚本时,引擎优先选择第一个包含有效生命周期的已执行模块命名空间;如果没有符合条件的结果,再使用最后一个成功执行的模块。后一种回退适用于仅包含一个 <script type="module" src="/src/main.ts"> 的常见 Vite index.html。
  • 不存在 ESM 模块时,使用 Classic 执行方式记录的 latestSetProp。

随后,getLifecyclesFromExports 依次检查导出对象本身、.default、latestSetProp 对应的全局属性和 window[appName]。完整顺序见生命周期解析原理。

空响应体

如果入口没有响应体,loadEntry 会抛出 QiankunError: The response body of entry ... is empty。Response.body === null(例如 HTTP 204)会触发此错误;正文流非空但没有生命周期导出时,会在后续解析阶段报错。

增强后的 fetch ​

入口及转译器重新获取的所有资源均通过增强后的 window.fetch:

ts
makeFetchCacheable(makeFetchRetryable(makeFetchThrowable(fetch)));

最内层的 makeFetchThrowable 在响应状态码不属于 200–399 时抛出异常;makeFetchRetryable 为当前封装后的 fetch 实例维护有限的重试额度;最外层的 makeFetchCacheable 负责同 URL 请求去重。可以通过 AppConfiguration 的 fetch 选项替换底层实现,qiankun 仍会在其外部应用上述三层装饰器。

当前实现限制 ​

  • 标签改写按字面量匹配。 当前规则只匹配首次出现的 <head> 和 </head>,不会解析大小写、属性或补全缺失标签。无匹配标签的内容仍会流式输出;需要虚拟 <head> 的应用应提供符合该约定的入口。
  • 尚未实现 <body> 虚拟化。 源码中存在 <body> 到 <qiankun-body> 的转换代码,但当前已注释;<head> 与 <body> 的自动补全也未开启。只有 <head> 会被虚拟化,<body> 内容直接提交到应用容器。
  • sandbox: false 会停用部分导出解析。 latestSetProp 由沙箱隔离膜记录,ESM 引擎也仅在沙箱开启时创建。关闭沙箱后,两者均不可用,生命周期只能通过 window[appName] 兼容回退解析。详见 JavaScript 沙箱实现。

延伸阅读 ​

基于 MIT 协议发布