ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Stencil Hydrate 模块深度解析:从 SSR/SSG 架构到客户端水合的完整指南

Stencil Hydrate 模块深度解析:从 SSR/SSG 架构到客户端水合的完整指南 Stencil Hydrate 模块深度解析从 SSR/SSG 架构到客户端水合的完整指南【免费下载链接】stencilA toolchain for building scalable, enterprise-ready component systems on top of TypeScript and Web Component standards. Stencil components can be distributed natively to React, Angular, Vue, ( more) and traditional web applications from a single, framework-agnostic codebase.项目地址: https://gitcode.com/gh_mirrors/st/stencilHydrate 是 Stencil 编译工具链中负责**服务端渲染SSR与静态站点生成SSG**的核心模块它把 Stencil 组件放到 Node.js 环境中渲染为带水合标记hydration markers的 HTML 字符串供浏览器端在加载 JavaScript 后无缝接管rehydrate。本文以仓库中的 docs/hydrate.md 为主线结合src/hydrate/下的真实实现与类型声明系统讲解 Hydrate 模块的架构、三个核心用户 API、两种 SSR 集成策略、完整配置项与常见坑帮助你直接在自己的 Stencil 组件库中跑通服务端渲染。读完本文你将掌握如何通过dist-hydrate-script生成可导入的 hydrate 模块hydrateDocument/renderToString/streamToString三个 API 的适用场景与内部执行链serializeShadowRoot等关键选项的取值语义以及如何把 Stencil 组件接入 Vite/Webpack编译器方案或 Next.js运行时方案。架构总览三段式渲染管线Hydrate 模块的架构可以拆成三个阶段原文给出的 mermaid 图构建期Build TimeStencil 编译器把用户组件打包成一份特殊的 hydrate app bundleCommonJS 格式面向 Node.js。运行时Runtimehydrate runner 在 Node.js 环境中启动借助stencil/core/mock-doc提供的Mock Document一份轻量级 DOM 模拟实现见 src/mock-doc/创建组件实例并渲染出 HTML 字符串。客户端Client浏览器拿到带注释标记的 HTML 后由运行时中的客户端水合逻辑src/runtime/client-hydrate.ts重建 VDOM、挂接事件把静态 HTML 变成可交互组件。模块实际代码位于 src/hydrate/其中runner/目录承载了核心渲染逻辑render.ts、create-window.ts、render-utils.ts、hydrate-factory.ts 等。生成 Hydrate App配置dist-hydrate-script输出目标要在自己的 Stencil 组件库中启用 SSR第一步是在stencil.config.ts中配置dist-hydrate-script输出目标export const config: Config { outputTargets: [ { type: dist-hydrate-script, dir: ./hydrate, }, ], };编译后会在包内生成hydrate/目录即config.packageDir/hydrate。该输出目标的实现位于 src/compiler/output-targets/dist-hydrate-script/内部流程由 generate-hydrate-app.ts、write-hydrate-outputs.ts 等文件协作完成把所有组件源码通过 Rollup 以commonjs格式打包成hydrate/index.js并注入 hydrate 专用的平台插件bundle-hydrate-factory.ts同时生成对应的.d.ts类型声明。生成的 hydrate 模块可以直接在 Node.js 中导入import { hydrateDocument, renderToString, streamToString, createWindowFromHtml } from yourpackage/hydrate;从 src/hydrate/runner/index.ts 可以看到runner 侧实际导出的符号还包括serializeDocumentToString、deserializeProperty、serializeProperty以及标签转换相关的setTagTransformer/transformTag。三个核心用户 APIhydrateDocument从 DOM Document 出发hydrateDocument接收一个 DOM document通常由createWindowFromHtml创建返回水合后的 HTMLimport { hydrateDocument, createWindowFromHtml } from yourpackage/hydrate; export async function hydrateComponents(template: string) { const win createWindowFromHtml(template, Math.random().toString()) const results await hydrateDocument(win.document, { url: https://example.com, userAgent: Node.js, cookie: sessionabc123, direction: ltr, language: en, }); return results.html; }createWindowFromHtml的真实实现src/hydrate/runner/create-window.ts很有意思它用Mapstring, Window缓存模板 window同一个uniqueId对应的 HTML 只解析一次MockWindow后续调用直接cloneWindow克隆出轻量副本避免重复解析模板带来的开销。这也解释了为什么示例中传入Math.random().toString()作为唯一 ID——需要独立副本时就用一个新 ID。在 render.ts 的实现中hydrateDocument支持传入string | Document两种输入传入字符串时内部new MockWindow(doc)构造 window并设置destroyWindow true、destroyDocument true渲染结束后销毁传入合法 DocumentnodeType 9且有documentElement与body时会调用patchDomImplementation对 DOM 实现打补丁且destroyDocument false即保留用户传入的 document两者都不满足时返回诊断错误Invalid html or document. Must be either a valid html string, or DOM document.renderToString从 HTML 字符串出发renderToString是最常用的入口接收 HTML 字符串并返回水合后的 HTMLconst results await renderToString( my-component nameTest/my-component, { fullDocument: false, prettyHtml: true, serializeShadowRoot: declarative-shadow-dom, } ); console.log(results.html);注意它在内部做了一组默认行为render.tsserializeToHtml true强制把渲染结果序列化为 HTML 字符串fullDocument默认true生成完整 HTML 文档而非仅组件片段serializeShadowRoot默认declarative-shadow-dom声明式 Shadow DOMconstrainTimeouts false不约束setTimeout确保异步组件能完整渲染完成。streamToString渐进式流式渲染streamToString返回一个 Node.jsReadable流适合边渲染边向响应管道输出const stream streamToString(htmlString, { serializeShadowRoot: scoped, beforeHydrate: (doc) { // Modify document before hydration } }); // Use with Node.js response stream.pipe(response);实现上streamToString就是renderToString(html, option, true)的薄封装render.ts第三种参数asStream为true时返回Readable.from(processRender())其中processRender是一个 async generator——它先完整执行一次render()再把结果 HTML 作为唯一 chunk yield 出来。也就是说当前实现的流式是把整份渲染完成后一次性输出属于单块流文档中Future Improvements提到的真正 Suspense 级逐块流式仍在规划中使用时需知悉这一限制。返回结果 HydrateResultshydrateDocument与renderToString均返回HydrateResults。根据 render-utils.ts 中generateHydrateResults的初始化完整字段包括字段说明buildId构建 ID写入html>// vite.config.ts import { stencilSSR } from stencil/ssr; export default defineConfig({ plugins: [ stencilSSR({ module: import(component-library-react), from: component-library-react, hydrateModule: import(component-library/hydrate), serializeShadowRoot: { scoped: [my-button], default: declarative-shadow-dom, }, }), ], });编译器方案的核心思想把 hydrate 结果内联进构建产物浏览器端无需额外请求组件 JS 即可看到首屏内容。其限制在于——传给组件的 props 必须可静态序列化见下文非原始类型参数一节。策略二运行时方案Next.js Server Components针对 React 生态在reactOutputTarget中同时配置hydrateModule与clientModule生成服务端优化版与客户端版两套组件// stencil.config.ts reactOutputTarget({ outDir: ../component-library-react/src, hydrateModule: component-library/hydrate, clientModule: component-library-react, });使用时从component-library-react/next导入服务端组件// Import server-optimized component import { MyComponent } from component-library-react/next; export default function Page() { return MyComponent prop{dynamicValue()} /; }运行时方案的优势是组件在请求时于 Node 侧真实执行因此可以接收运行时动态数据包括复杂对象、父组件状态等。水合流程组件发现与实例化组件发现水合从文档根节点出发做深度优先遍历遇到元素节点时先判断其标签名是否在已注册组件集合中命中则执行水合然后递归处理子节点逻辑对应 hydrate-component.ts 的职责runner 侧完整实现见 render.ts 中的渲染主流程const hydrateComponents async ( node: Node, context: HydrateContext ) { if (node.nodeType NODE_TYPE.ElementNode) { const element node as Element; const tagName element.tagName.toLowerCase(); if (context.registeredComponents.has(tagName)) { // Hydrate this component await hydrateComponent(element, context); } // Recursively hydrate children for (const child of Array.from(element.childNodes)) { await hydrateComponents(child, context); } } };组件水合每个命中的组件依次完成创建 host ref → 分配自增水合 ID 并写入s-id属性 →new Cstr()创建实例 → 执行initializeComponent生命周期初始化 → 登记进水合注册表const hydrateComponent async ( element: Element, context: HydrateContext ) { const tagName element.tagName.toLowerCase(); const Cstr context.components[tagName]; // Create host reference const hostRef createHostRef(element); // Add hydration id const hydrationId context.nextHydrationId; element.setAttribute(s-id, hydrationId); // Create component instance const instance new Cstr(); hostRef.$lazyInstance$ instance; // Run lifecycle await initializeComponent(element, hostRef); // Add to hydration registry context.hydratedComponents.set(hydrationId, { element, instance, hostRef }); };水合标记Hydration Markers服务端产出的 HTML 中埋有大量注释节点与专用属性它们是客户端重建 VDOM 的地图。核心标记包括标记含义s-id组件宿主的水合 ID服务端分配c-id子节点/内容节点 ID格式{hostId}.{index}!--s:{id}--/!--e:{id}--组件的起始/结束注释标记!--t:{id}.{index}--被投射slotted内容在原位置的标记s-p、s-cr、s-hn等运行时内部标记scoped 样式、内容引用、宿主名等服务端插入标记的示意逻辑const addHydrationMarkers ( element: Element, hydrationId: string ) { // Start marker const startComment element.ownerDocument.createComment(s:${hydrationId}); element.parentNode.insertBefore(startComment, element); // End marker const endComment element.ownerDocument.createComment(e:${hydrationId}); element.parentNode.insertBefore(endComment, element.nextSibling); // Child markers for slots element.childNodes.forEach((child, index) { if (child.nodeType NODE_TYPE.ElementNode) { child.setAttribute(c-id, ${hydrationId}.${index}); } }); };序列化时inspect-element.ts 会把s-id、c-id归入SKIP_ATTRS不计入元素属性统计而客户端水合完成后client-hydrate.ts 会执行hostElm.removeAttribute(s-id)把标记从最终 DOM 中清掉。Slot 内容的保序对于 Shadow DOM / scoped 组件中的插槽内容服务端会在节点原始位置插入t:注释标记确保客户端能把投射节点放回正确位置const serializeSlotContent ( slot: HTMLSlotElement, hydrationId: string ) { const assignedNodes slot.assignedNodes(); assignedNodes.forEach((node, index) { // Mark original position const marker document.createComment(t:${hydrationId}.${index}); node.parentNode.insertBefore(marker, node); }); };异步渲染与超时控制组件生命周期中可能包含异步操作如componentWillLoad中的 fetch。水合进程需要等待这些操作完成再序列化同时用超时兜底防止永久挂起const waitForComponents async (context: HydrateContext) { const promises: Promiseany[] []; // Collect all pending operations context.hydratedComponents.forEach(({ instance }) { if (instance.componentWillLoad) { promises.push(instance.componentWillLoad()); } }); // Wait with timeout if (promises.length 0) { await Promise.race([ Promise.all(promises), timeout(context.options.timeout || 15000) ]); } };超时默认值1500015 秒并非文档虚构——它来自 render-utils.ts 中normalizeHydrateOptions的默认值设置可通过timeout选项覆盖。预渲染与静态站点生成SSGHydrate 同样支撑静态站点生成给定入口 URL启动本地 dev server爬取链接、逐页渲染并写入磁盘对应实现见 src/compiler/prerender/export const prerenderPages async ( config: PrerenderConfig ): PromisePrerenderResults { const results: PrerenderResults { diagnostics: [], urls: [] }; // Start dev server const devServer await startDevServer(config); // Crawl and render pages const crawler createCrawler(config); const urlsToRender await crawler.discoverUrls(config.entryUrls); for (const url of urlsToRender) { const page await renderPage(devServer, url, config); // Write to disk await writePage(page, config); results.urls.push({ url: page.url, filePath: page.filePath }); } await devServer.close(); return results; };URL 发现爬虫从入口 URL 出发做 BFS抓取页面 → 提取a href链接 → 按规则过滤 → 加入待访问队列直到没有新 URL 为止const discoverUrls async ( entryUrls: string[], config: PrerenderConfig ): PromiseSetstring { const discovered new Setstring(entryUrls); const toVisit [...entryUrls]; while (toVisit.length 0) { const url toVisit.shift(); const page await fetchPage(url); // Extract links const links extractLinks(page.html); for (const link of links) { if (shouldPrerender(link, config) !discovered.has(link)) { discovered.add(link); toVisit.push(link); } } } return discovered; };PrerenderConfig 完整选项PrerenderConfig定义于 src/declarations/stencil-public-compiler.ts除文档列出的字段外还包含大量生命周期钩子与规则函数选项类型说明entryUrlsstring[]预渲染起始 URL默认根路径/hydrateOptions/hydrateOptions?(url)(url: URL) PrerenderHydrateOptions每个页面使用的水合选项crawlUrlsboolean是否爬取同源a href链接默认truefilterUrl?(url, base)(url: URL, base: URL) boolean返回true的 URL 才会被预渲染filterAnchor?(attrs, base)函数决定某个a是否参与爬取normalizeUrl?(href, base)函数把 href 规范化为URLcanonicalUrl?(url)(url: URL) string \| null生成 canonicallink返回null则不输出trailingSlashboolean预渲染 URL 是否带尾部/默认falsestaticSiteboolean全站静态化不包含客户端 JS 与模块预加载filePath?(url, filePath)函数自定义 HTML 写入路径loadTemplate?(filePath)函数加载所有页面共用的 HTML 模板beforeSerializeTemplate?/afterSerializeTemplate?函数模板序列化前后的钩子beforeHydrate?/afterHydrate?函数每个 document 水合前后的钩子可拿到URL与PrerenderUrlResultsrobotsTxt?(opts)(opts: RobotsTxtOpts) string \| RobotsTxtResults自定义robots.txt内容sitemapXml?(opts)(opts: SitemapXmpOpts) string \| SitemapXmpResults自定义sitemap.xml内容其中RobotsTxtOpts包含urls、sitemapUrl、baseUrl、dirSitemapXmpOpts包含urls、baseUrl、dir同文件 stencil-public-compiler.ts。另外预渲染专属的PrerenderHydrateOptions继承自SerializeDocumentOptions还支持addModulePreloads为即将请求的模块加link relmodulepreload默认true、hashAssets: querystring给静态资源加内容哈希?v便于永久缓存、inlineExternalStyleSheets外链样式内联为style、minifyStyleElements/minifyScriptElements默认true、staticDocument整页静态化等选项。性能优化组件缓存Node 侧复用已加载的组件构造函数避免重复解析同一组件的模块class ComponentCache { private cache new Mapstring, ComponentConstructor(); get(tagName: string): ComponentConstructor { if (!this.cache.has(tagName)) { const Cstr loadComponent(tagName); this.cache.set(tagName, Cstr); } return this.cache.get(tagName); } clear() { this.cache.clear(); } }并行渲染同层组件按 chunk示例中每块 10 个分批Promise.all并行水合兼顾吞吐与资源占用const hydrateComponentsParallel async ( elements: Element[], context: HydrateContext ) { const chunks chunkArray(elements, 10); for (const chunk of chunks) { await Promise.all( chunk.map(element hydrateComponent(element, context)) ); } };客户端水合把静态 HTML 变成交互组件浏览器端水合由 src/runtime/client-hydrate.ts 的initializeClientHydrate承担connected-callback.ts中会在组件连接时调用它。核心思路const clientHydrate ( elm: HTMLElement, cmpMeta: ComponentRuntimeMeta ) { const hydrationId elm.getAttribute(s-id); if (hydrationId) { // Find server-rendered vdom const serverVNode parseServerVNode(elm, hydrationId); // Create host ref with existing vdom const hostRef getHostRef(elm); hostRef.$vnode$ serverVNode; // Mark as hydrated hostRef.$flags$ | HOST_FLAGS.hasHydrated; } };它的职责文件头部注释有明确说明包括四件事重建精确的 VDOM并交给vdom-render.ts渲染——这一步做不好会引发水合错误、重复渲染或节点重复为 Shadow DOM 组件补建#document-fragment树把被转发、投射的 slot 节点移出 shadow DOM为非 shadow DOM 及其 slotted 节点补元数据节点。此外initializeClientHydrate还会做一次整文档的裸树扫描initializeDocumentHydrate构建内容从哪移动到哪的映射plt.$orgLocNodes$用于把 SSR 布局中位置已变化的节点搬回正确位置保证 slotted 节点不会错误地混入内部 shadow DOM。完整配置参考HydrateDocumentOptions 与 SerializeDocumentOptions以下参数均定义于 src/declarations/stencil-public-compiler.ts其中默认值以normalizeHydrateOptionsrender-utils.ts实际生效值为准。HydrateDocumentOptions水合运行时选项选项默认值说明urlhttps://hydrate.stenciljs.com/设置location.href并解析出 host/pathname 等userAgent—设置navigator.userAgentcookie—设置document.cookiereferrer—设置document.referrerdirection—设置html dir属性language—设置html lang属性buildId随机 8 位写入html>describe(hydrate, () { it(should render component, async () { const { html } await renderToString( my-component nameTest/my-component ); expect(html).toContain(s-id); expect(html).toContain(Hello, Test); }); it(should handle async components, async () { const { html } await renderToString( async-component/async-component, { timeout: 5000 } ); expect(html).toContain(Loaded data); }); });仓库中可参考的实现级测试包括 src/compiler/output-targets/dist-hydrate-script/test/dist-hydrate-script.spec.ts验证 hydrate 输出的生成与 src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts验证 shadow root 序列化选项的解析。常见问题与排查内存泄漏渲染结束务必销毁每次渲染结束应清理组件实例、清空缓存、移除全局引用。renderToString传字符串时会自动销毁 window/documentdestroyWindow true如果你手动构造了 document 传入hydrateDocument则需自行管理其生命周期及时调用win.close()const cleanup (context: HydrateContext) { // Clear component instances context.hydratedComponents.forEach(({ instance }) { if (instance.disconnectedCallback) { instance.disconnectedCallback(); } }); // Clear caches context.componentCache.clear(); context.hydratedComponents.clear(); // Remove global references delete global.document; delete global.window; };死循环深度保护SSR 环境缺少浏览器的事件循环约束递归渲染可能失控。实践上应加入最大水合深度限制示例常量MAX_HYDRATE_DEPTH 300与maxHydrateCount默认值一致超过即抛出Maximum hydration depth exceeded。非原始类型参数编译器方案构建期 SSR无法序列化复杂对象 props——组件在构建时运行拿不到请求期的动态数据// ❌ Wont work with compiler-based SSR const menu generateMenuData(); MyComponent data{menu} / // ✅ Use static data or runtime SSR const menu { items: [Home, About] }; MyComponent data{menu} /需要动态数据时改用运行时方案Next.js Server Components或在服务端自行调用 hydrate API。跨组件状态SSR 中组件不应依赖父级内部状态或隐式上下文// ❌ Child wont have access to parent context in SSR ParentComponent ChildComponent / /ParentComponent // ✅ Pass data explicitly ParentComponent ChildComponent data{parentData} / /ParentComponentSlot 的限制编译器方案对复杂 slot 内容可能存在问题运行时方案对 slot 处理更好但有性能开销SSR 重组件建议优先用 props 传递内容而非 slot。性能注意事项与最佳实践Shadow DOM 样式重复问题SSR 下 Shadow DOM 组件的样式会随每个实例重复输出。对高频使用的组件按钮、图标等在stencilSSR中把它们指定为scoped模式以减小 HTML 体积export default stencilSSR({ serializeShadowRoot: { scoped: [my-button, my-icon], // Render as scoped default: declarative-shadow-dom }, });合理使用staticComponents纯展示、无交互的组件页头/页脚标记为 static服务端渲染后浏览器端不再水合省去客户端 JS 成本。注意constrainTimeouts的语义renderToString内部将其置为false以保证异步组件完整渲染而直接调用hydrateDocument时它默认true把setTimeout压到 1ms、setInterval仅触发一次适合需要快速收敛的批量渲染场景。流式接口的适用边界当前streamToString为整块输出若追求逐组件流式需等待上游的 Suspense 级流式 SSR 能力。未来演进方向文档末尾列出的规划方向以仓库现状为准均为计划中而非既有能力Streaming SSR基于 Suspense 的真正逐块流式渲染Partial Hydration只水合可交互组件进一步削减客户端 JSEdge SSR把水合进程部署到边缘 WorkerComponent Islands更精细的水合边界划分Build-time SSG更快的构建期静态生成。延伸阅读Hydrate 模块源码src/hydrate/渲染主流程实现src/hydrate/runner/render.ts选项归一化与默认值src/hydrate/runner/render-utils.ts客户端水合实现src/runtime/client-hydrate.tsHydrate 输出目标生成src/compiler/output-targets/dist-hydrate-script/预渲染实现src/compiler/prerender/类型声明HydrateDocumentOptions/SerializeDocumentOptions/PrerenderConfigsrc/declarations/stencil-public-compiler.tsMock DOM 实现SSR 的 DOM 底座src/mock-doc/【免费下载链接】stencilA toolchain for building scalable, enterprise-ready component systems on top of TypeScript and Web Component standards. Stencil components can be distributed natively to React, Angular, Vue, ( more) and traditional web applications from a single, framework-agnostic codebase.项目地址: https://gitcode.com/gh_mirrors/st/stencil创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表