文件差异与文档预览
本页说明 web-client 中与文档预览页面及构建产物相关的实现:Vite 如何生成 preview.html、注入 worker bootstrap,并通过产品包隔离机制验证页面可达的脚本、样式、资源和 worker 输入。页面交互本身的 React 组件实现未在本页已读取的源码范围内发现,因此不对未验证的 UI 行为作推断。
Purpose and Scope
本页聚焦构建阶段的“文档预览”交付链路,尤其是 apps/web/vite.config.ts 中的 emitPreviewPage()、rejectStandaloneServe()、文档渲染相关 vendor chunk 配置,以及 apps/web/product-isolation.ts 与 WebProductBundleIsolation 对最终 index.html 可达依赖的追踪和校验。
页面内具体的 Markdown 编辑器、差异渲染 React 组件、路由和后端文档 API 不在当前源码证据范围内;这些应由对应的 UI、路由或服务端目录页面说明。部署命令的完整包装逻辑同样不在本页展开,只记录 Vite 配置明确表达的运行约束。
Overview
该能力不是单纯复制 index.html。构建时首先由 Vite 产出普通入口和名为 bootstrap 的 worker 入口;随后插件在 bundle 阶段定位 worker 文件,并在输出目录中读取已写入的 index.html,将 worker bootstrap 的 <script type="module"> 插入到原页面入口脚本之前,生成并列的 preview.html。两个页面共享构建 chunk,预览页面的差异仅是额外的 worker bootstrap 标签。
同时,产品包隔离插件会在 CSS 转换、资源 URL 解析、普通 chunk、worker 子构建等阶段记录输入关系。最终校验从 index.html 开始遍历静态导入、动态导入、CSS、资源和 worker,并将每个实际输入交给 BundleInputIsolation。这样做的设计意图是:预览页面可以复用已构建的产品页面,但不能悄悄携带未允许的实验性包或缺少来源证明的输出。
Architecture
Sources: vite.config.ts, product-isolation.ts, web-product-bundle-isolation.ts
vite.config.ts 负责把多个插件组合到同一构建中;product-isolation.ts 只负责把 Vite 生命周期适配为 WebProductBundleIsolation 的记录与验证调用;后者保存 chunk、CSS、资源引用和 worker 输入,并把实际输入交给底层隔离器。preview.html 并不重新生成一套页面资源,而是复用 index.html 的内容和同一个输出目录。
预览页面的生成机制
构建生命周期状态
emitPreviewPage() 维护四个局部状态:bootstrapFile 保存入口 chunk 文件名,write 保存 config.build.write,written 标记 Vite 是否真正写出了 bundle,outputDirectory 保存解析后的输出目录。configResolved 读取写入模式和目录;buildStart 在每次构建或 watch 重建时清空入口名及写入标志,避免上一次构建的文件名泄漏到下一次构建。
在 generateBundle 阶段,插件只接受同时满足 item.type === 'chunk'、item.isEntry 和 item.name === 'bootstrap' 的输出。如果没有找到该入口,会立即抛出错误,而不是生成一个缺少 worker bootstrap 的“看似成功”的预览页。这是一个重要的 fail-fast 约束。
从 index.html 派生 preview.html
Vite 完成写盘后,closeBundle 仅在写入模式、确实写过 bundle 且找到了 bootstrap 文件时继续。它读取输出目录下的 index.html,查找第一个 module script 标签,将下面形式的脚本插到该位置之前:
1const page = await readFile(resolve(outputDirectory, 'index.html'), 'utf8')
2const anchor = page.indexOf('<script type="module"')
3if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag')
4const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>`
5await writeFile(resolve(outputDirectory, 'preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)Source: vite.config.ts
这里没有重新解析 HTML,也没有改变原入口标签的属性;实现只依赖一个明确的 <script type="module"> 锚点,并使用相对路径 ./${bootstrapFile},因此与配置中的 base: './' 一致,预览页面可以在输出目录的不同挂载路径下解析资源。
standalone serve 约束
同一配置显式拒绝裸 Vite serve:rejectStandaloneServe() 在 env.command === 'serve' 时抛出错误。源码说明原因是裸 Vite 无法注入 window.__DSH_BOOT__;因此预览相关页面必须经过项目自己的启动包装流程,而不是把 apps/web 当作独立 Vite 应用直接启动。
1function rejectStandaloneServe(): Plugin {
2 return {
3 name: 'dsh-reject-standalone-web-serve',
4 config(_config, env) {
5 if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
6 },
7 }
8}Source: vite.config.ts
文档渲染依赖与输出组织
配置将 KaTeX、Shiki 以及 micromark/mdast 解析链列入 VENDOR_PACKAGES。源码注释明确说明这些是数学、语法高亮和 Markdown 解析的重量级依赖;React 侧的增量渲染代码仍留在 workspace 代码对应的 index chunk 中。配置还要求 vendor 成员保持 React-free,以免 Rollup 把共享 React 拖入 vendor chunk。
这一划分服务于预览场景的缓存稳定性:编辑 shell 代码时主要重新哈希 index,而 Markdown/math/highlight 依赖只有在依赖变更时才影响 vendor chunk。该结论仅适用于源码中列出的包和注释表达的构建策略,不代表所有前端资源都必然进入 vendor。
1const VENDOR_PACKAGES: ReadonlySet<string> = new Set([
2 'katex',
3 'shiki',
4 'mdast-util-from-markdown',
5 'mdast-util-gfm',
6 'mdast-util-math',
7 'micromark-core-commonmark',
8 'micromark-extension-gfm',
9 'micromark-extension-math',
10 'micromark-factory-space',
11 'micromark-util-character',
12 'micromark-util-classify-character',
13 'micromark-util-sanitize-uri',
14 'micromark-util-symbol',
15 'micromark-util-types',
16])Source: vite.config.ts
构建隔离适配层
productWebBundleIsolation(repository, webRoot) 创建一个 WebProductBundleIsolation 实例,并返回两个 Vite build 插件。第一个插件在 generateBundle 的 pre 阶段捕获 chunk;第二个插件在 post 阶段安装资源 URL、worker、CSS 输入记录,并在最终 bundle 阶段执行验证。
适配层会保留已有的 Vite worker 插件,在其后追加 dsh-worker-build-inputs。这意味着 worker 子构建不会被单独忽略,而是通过 inputs.workerBundle(bundle, this.getWatchFiles()) 记录其 watched files 和模块输入。
1export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] {
2 const inputs = new WebProductBundleIsolation(repository, webRoot)
3 let dependencyAnalysis = false
4 return [{
5 name: 'dsh-product-web-chunk-inputs',
6 apply: 'build',
7 generateBundle: {
8 order: 'pre',
9 handler(_options, bundle) {
10 if (!dependencyAnalysis) inputs.captureChunks(bundle)
11 },
12 },
13 }, {
14 name: 'dsh-product-web-bundle-isolation',
15 apply: 'build',
16 enforce: 'post',
17 configResolved(config) {
18 dependencyAnalysis = config.plugins.some(plugin => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN)
19 },
20 buildStart() { inputs.reset() },
21 generateBundle: {
22 order: 'post',
23 handler(_options, bundle) {
24 if (!dependencyAnalysis) inputs.verify(bundle, id => this.getModuleInfo(id))
25 },
26 },
27 }]
28}Source: product-isolation.ts Sources: product-isolation.ts
Core Flow
Sources: vite.config.ts, product-isolation.ts, web-product-bundle-isolation.ts
实际顺序的关键点是:隔离验证发生在最终 bundle 生命周期中,而 preview.html 的派生写入发生在 closeBundle。因此预览页只有在 bundle 产出并通过相关构建钩子后才会被写入;缺失 bootstrap、缺失 module entry 或隔离校验失败都会阻止正常生成。
依赖图遍历与验证细节
WebProductBundleIsolation 使用四类状态保存构建期间的证据:cssInputs 记录样式转换实际要求 Rollup watch 的文件,chunks 保存 Vite 输出 chunk 的导入和资源元数据,references 保存资源 URL 边,workerInputs 保存 worker 输出到其完整 watched input 集合的映射。reset() 会清空这些可重建的输出图和输入记录,适合 watch 重建;它不会假设上一次 bundle 的结构仍然有效。
1export class WebProductBundleIsolation {
2 private readonly cssInputs = new Map<string, Set<string>>()
3 private readonly chunks = new Map<string, WebOutputChunk>()
4 private readonly references: AssetReference[] = []
5 private readonly workerInputs = new Map<string, Set<string>>()
6 private readonly inputs: BundleInputIsolation
7 private readonly webRoot: string
8
9 constructor(repository: string, webRoot: string) {
10 this.webRoot = webRoot
11 this.inputs = new BundleInputIsolation(repository, 'Web product isolation')
12 }
13
14 reset(): void {
15 this.chunks.clear()
16 this.references.length = 0
17 this.workerInputs.clear()
18 this.inputs.reset()
19 }
20}Source: web-product-bundle-isolation.ts
验证从 index.html 入队开始。首先确认该 HTML 确实来自 web root 下的原始 index.html;然后建立 preliminary filename、资源原名和实际输出名之间的别名。遍历输出时,chunk 会继续扩展其静态导入、动态导入、隐式前置加载、引用文件、导入 CSS 和导入 asset;CSS 会回溯到拥有它的模块;普通 asset 则要求存在 originalFileNames。资源引用若是 public 文件,会解析到 webRoot/public;普通 asset 引用会再次加入遍历队列。
对每个模块,校验器会:
- 调用
assertInput检查模块输入所有权; - 拒绝没有 Rollup module record 的模块;
- 拒绝 external 模块,因为它没有 bundle 输入证明;
- 对 CSS、worker、静态导入和动态导入分别检查对应记录;
- 最终要求至少访问到一个模块,否则认为
index.html没有可验证的可达模块。
这种实现把“页面能加载”与“页面所有输入可追溯”绑定在一起:仅有文件存在并不足够,输出图中每个可达节点都必须有来源证据。
配置与运行约束
| 配置或常量 | 类型 | 默认值/条件 | 作用 |
|---|---|---|---|
base | string | './' | 使 index.html 和派生的 preview.html 使用相对资源路径。 |
target | string | 'es2022' | 支持 worker bootstrap 使用的 top-level await。 |
sourcemap | boolean | true | 为构建产物生成 source map。 |
DSH_CLIENT_TITLE | 环境变量 | 'DSH Local Build' | 经 HTML 转义后替换初始页面标题。 |
config.build.write | boolean | 由 Vite 决定 | 为 false 时不写 preview.html,并允许依赖分析插件只分析不发布。 |
bootstrap entry | Vite entry | 必须存在 | emitPreviewPage() 必须找到名为 bootstrap 的 entry chunk,否则构建失败。 |
配置中 clientDocumentTitle() 使用 escapeHtmlText() 对环境变量中的 &、<、> 做转义后再写入 <title>,这是避免构建时文本直接进入 HTML 的必要边界处理。源码没有为标题设置长度限制或其他字符策略。
1function clientDocumentTitle(): Plugin {
2 const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE)
3 return {
4 name: 'dsh-client-document-title',
5 transformIndexHtml(html) {
6 return html.replace('<title>DSH Local Build</title>', `<title>${title}</title>`)
7 },
8 }
9}Source: vite.config.ts
失败模式、边界与并发性
已验证的失败模式
- 直接执行裸 Vite serve:
rejectStandaloneServe()抛出STANDALONE_ERROR,防止缺少 boot manifest 的 shell 被暴露。 - 缺少 bootstrap entry:
generateBundle()找不到名为bootstrap的入口时抛错。 - index.html 缺少 module entry:
closeBundle()找不到插入锚点时抛错。 - index.html 不是原始输入:隔离校验要求输出 asset 的
originalFileNames包含 web root 下的index.html。 - 模块无 Rollup 记录或为 external:校验器分别报告缺少 module record 或缺少 bundled input proof。
- CSS 未记录 transform 输入:CSS 文件进入依赖图但没有
cssInputs记录时失败。 - worker 没有输入记录:worker 输出未出现在
workerInputs时失败。 - asset 没有原始文件名:非 CSS、非模块资源缺少
originalFileNames时失败。 - 没有可达模块:遍历结束后
visitedModules.size === 0时失败。
这些错误均在源码中通过同步或异步 throw new Error(...) 表达;当前已读取的实现没有重试、降级生成或吞掉错误的逻辑。因此构建流水线应把它们视为发布前的硬失败,而不是运行时告警。
watch 重建与状态隔离
buildStart() 会重置预览插件的入口状态,产品隔离适配器也在 buildStart() 调用 inputs.reset()。这两个动作降低了 watch 模式下使用旧 bundle 图和旧 bootstrap 文件名的风险。源码使用普通局部变量、Map 和 Set,没有看到异步并发锁;Vite 生命周期按钩子顺序调用时,状态属于一次构建过程。若外部同时启动多个独立 Vite 实例,它们是否共享输出目录或发生写入竞争,当前源码没有提供协调机制。
API 与扩展点
emitPreviewPage(): Plugin
返回 Vite 插件。它读取构建配置,捕获 bootstrap entry,并在满足写入条件时生成 preview.html。该函数没有参数,返回值是 Vite Plugin。它的扩展边界是入口命名、HTML 插入锚点和输出文件命名;变更这些约定需要同时更新实际 worker entry 或页面模板。
productWebBundleIsolation(repository: string, webRoot: string): Plugin[]
创建并返回两个只应用于 build 的 Vite 插件。repository 用于构造 WebProductBundleIsolation 的底层输入隔离器,webRoot 用于解析 index.html、public asset 和原始文件。返回数组包含一个 pre generateBundle 插件和一个 post generateBundle 插件。若修改 hook 顺序,必须保留“先捕获 chunk,再验证最终 bundle”的关系。
WebProductBundleIsolation.verify(bundle, moduleInfo)
从输出 index.html 遍历可达产物,并通过回调取得模块记录。它不返回业务结果;验证成功时自然结束,发现输入缺失、external 依赖、无记录模块或不可追溯资源时抛出错误。moduleInfo 必须能为遍历中的模块提供 importedIds、dynamicallyImportedIds、isExternal 和 isIncluded 等字段,否则无法完成验证。
性能与运维注意事项
隔离校验采用 visitedOutputs 和 visitedModules 去重,并将资源别名预先放入 Map;因此同一 chunk、模块或资源不会因多条引用边被重复递归。它会遍历 index.html 实际可达的依赖闭包,而不是扫描整个仓库,这既减少无关检查,也使产物可达性成为安全边界。
Markdown 解析相关的重量级依赖被集中到 vendor 集合,目的在源码注释中明确为改善缓存和 chunk 稳定性。sourcemap: true 有利于构建产物诊断,但会增加输出体积;本页读取的配置没有提供 source map 的环境开关。
测试与证据边界
在本页限定的 6 次源代码探索预算内,没有读取测试文件,也没有读取具体 UI 预览组件。因此无法从已验证证据声明测试覆盖范围、差异算法、Markdown 安全策略或最终用户可见状态。当前页面只记录构建插件和隔离器中明确存在的行为;实现细节不足之处应在对应页面补充,而不应根据配置名称推断。