Skip to content
目录

03 · 流式 Loader、Head 虚拟化与 writable-dom

为什么用流式加载

传统做法需要等待 完整 HTML 字符串 再解析,首字节到可交互的链路长。v3 loadEntryResponse.body 存在时直接 pipeThroughpipeTo(WritableDOMStream),边收边解析边挂 DOM,并在阻塞资源(同步外链脚本、匹配的 stylesheet)处暂停 walk,同时预加载后续节点能转化的 preload 资源(见下文 writable-dom 片段)。

Head 虚拟化:<head><qiankun-head>

真实页面的 document.head 只有一个;子应用 HTML 里的 head 若直接并进宿主文档,会与主应用 head 强耦合。Loader 在字符串流上把标签替换为自定义元素名(与 packages/sandbox/src/consts.tsqiankunHeadTagName 一致):

78:90:packages/loader/src/index.ts
void readableStream
      .pipeThrough(
        createTagTransformStream(
          [
            { tag: '<head>', alt: `<${qiankunHeadTagName}>` },
            { tag: '</head>', alt: `</${qiankunHeadTagName}>` },
            // TODO support body replacement
            // { tag: 'body', alt: 'qiankun-body' },
          ],
          // { head: true },
          {},
        ),
      )

设计原因:子应用的 meta/link/style 等进入 容器内的虚拟 head 节点,配合 sandbox 对 document.head / document.body 的 patch,把「子应用认为的 head」映射到 容器子树,避免污染宿主 head、也便于卸载时清理。

入口脚本约定:entry 属性且唯一

Loader 通过 isEntryScript 识别入口外链脚本;若超过 1 个会抛 QiankunError

124:128:packages/loader/src/index.ts
if (isEntryScript(script)) {
            if (foundEntryScript) {
              throw new QiankunError(
                `You should not include more than 1 entry scripts in a single HTML entry ${entryUrl} !`,
              );

原因loadEntry 返回的 Promise 需要明确完成条件——入口脚本 load/error 与沙箱 latestSetProp 对齐;多入口会破坏「何时算加载完成」的语义。仓库 AGENTS.md 也将其列为 ANTI-PATTERN

onload 顺序与 latestSetProp

入口脚本包装了 onload/onerror,注释直接点出竞态:必须先 resolve 再执行浏览器原有 listener,否则后续内联脚本可能抢先执行,沙箱来不及记录最后一次全局赋值属性:

152:156:packages/loader/src/index.ts
/*
               In order to avoid the inline script to be executed immediately after the prev onload is executed, resulting in the failure of the sandbox to obtain the latestSetProp
               here we must resolve the entryScriptLoadedDeferred firstly and then execute the prevListener
               */

这与 Membrane 在 set trap 里更新 latestSetProp 的逻辑配套(见 04-沙箱Membrane与Compartment.md)。

Defer 脚本:prepareDeferredQueue

外链 defer 脚本通过 prepareDeferredQueue 形成链式 Deferred,保证转译/注入顺序与浏览器 defer 语义一致(packages/shared/src/deferred-queue/index.ts + loader 中对 prevScriptTranspiledDeferred 的等待)。

writable-dom:阻塞与预加载

fork 自 writable-dom 的核心循环中,遇到 阻塞型 script/link 会 isBlocked = true,并在阻塞期间扫描后续 DOM,插入 link rel=preload 以提前拉取资源:

94:109:packages/loader/src/writable-dom/index.ts
if (isBlocked) {
      // If we are blocked, we walk ahead and preload
      // any assets we can ahead of the last checked node.
      const blockedNode = walker.currentNode;
      if (scanNode) walker.currentNode = scanNode;

      while ((node = walker.nextNode())) {
        const link = getPreloadLink((scanNode = node));
        if (link) {
          const transformedLink = typeof assetTransformer === 'function' ? assetTransformer(link) : link;
          transformedLink.onload = transformedLink.onerror = () => target.removeChild(transformedLink);
          target.insertBefore(transformedLink, nextSibling);
        }
      }

优化含义:同步脚本会阻塞 HTML 解析;在阻塞窗口内尽量 preload 后续脚本/样式/图片,缩短关键路径总时长。

同步外链脚本还需手动 clone.async = false,因 importNode 会重置行为,注释指向 marko-js/writable-dom issue:

131:135:packages/loader/src/writable-dom/index.ts
// document.importNode will reset the `async` attribute to true, here we need to set it manually.
          // see https://github.com/marko-js/writable-dom/issues/7
          if (isSyncScript(clone)) {
            clone.async = false;
          }

TagTransformStream 的边界(FIXME)

若服务端分块导致长时间看不到完整 <head> 字符串,流式替换可能无法触发;TagTransformStreamflush 留有 FIXME:

35:37:packages/loader/src/TagTransformStream.ts
flush(controller: TransformStreamDefaultController<string>) {
          if (buffer) {
            // FIXME It may be a non-standard HTML chunk that does not contain the head tag, in which case you need to manually fill in a head element

可简述为:流式替换是字符串级操作,对「chunk 边界切在标签中间」类问题需要缓冲策略或补全 head——当前实现仍有缺口。

下一节

04-沙箱Membrane与Compartment.md

MIT License.