一页技术文章可能只有两个交互:展开移动导航和搜索文章。把整页改成客户端应用,会让正文、页脚和静态链接一起参与 JavaScript 生命周期。Astro 岛屿架构的价值不是“零 JavaScript”口号,而是允许每个交互独立证明自己的成本。
这里假设正文服务端或构建时生成,只有少量交互组件。若产品本身是实时协作编辑器,主要体验由客户端状态驱动,架构选择会不同。
先尝试浏览器已有的交互
折叠内容可以使用 details,跳转用链接,提交用表单。原生元素已经处理键盘与基础语义。为一个展开按钮引入完整框架不一定错误,但应该说明额外状态、动画或跨组件协调带来了什么。
站内搜索若只过滤几十条本地文章,可以用小型脚本读入索引。索引需要限制为公开记录,避免把草稿正文随页面下载。全文搜索规模增长后,再考虑专门索引服务,而不是默认把所有 Markdown 转成一个巨大的客户端 JSON。
假设读者在文章顶部输入“缓存”,希望立即找到相关笔记。这个任务包含输入、索引加载、匹配、结果显示与键盘导航。若输入框和结果列表分别放在两个岛屿里,它们就需要共享查询、加载状态和选中项。拆成两个组件在源码中很整齐,却未必是合适的运行时边界。可以保留多个内部组件,但由同一个岛屿拥有整个搜索任务。
正文不需要参与搜索状态更新,页脚也不需要重新渲染。将搜索框、结果区与索引加载组合成小岛后,输入变化只影响该区域。用户提交查询时,若 JavaScript 尚未加载,可以通过普通表单跳转到服务端或静态搜索入口;增强脚本准备好后再接管即时结果。渐进增强要求先有可执行的基础路径,而不是仅在失败时显示一段道歉文字。
索引下载可以延迟到首次聚焦,适合很少使用搜索且索引较大的站点;也可以在首屏空闲时预取,减少首次搜索等待。前者节省未使用资源,后者增加所有访客的传输。选择时记录搜索使用比例和首次结果时间,不凭“延迟加载一定更快”的印象判断。预取失败不能把输入框永久锁死,再次聚焦或提交时应有受控重试。
不要假设岛屿天然隔离业务状态
桌面页头和移动导航各放一个搜索组件时,页面上可能同时存在两份实例。若它们都使用 id="search-results",标签关联、弹层控制和查询选择器就可能指向另一份实例。实例内部状态相互独立,DOM 标识也需要独立;隐藏移动导航不等于从 DOM 删除它。测试两种入口分别打开,确认焦点和结果只属于对应实例。
相反,把主题切换这种全站偏好做成每个岛屿各自读取与写入,也可能造成不同步。应选择一个简单的所有者,用 DOM 属性或小型共享状态表达;注意服务端初始主题与客户端读取偏好的一致性,避免闪烁和 hydration 差异。
主题切换经常暴露 hydration 问题。服务端输出浅色按钮,客户端读取本地深色偏好后立刻改成另一种文案,用户可能看到闪烁,也可能让辅助技术读到不一致状态。需要明确初始主题由什么决定:系统偏好、服务器 Cookie 或页面进入前的小型脚本。不同来源要有优先级,不能让每个岛屿独立猜测。
时间、随机数和浏览器尺寸也不适合直接参与首轮服务端与客户端比较。组件服务端输出“刚刚更新”,客户端计算后变成另一段文字,可能产生不匹配。可以把服务端确定的数据作为 props 传入,或让动态部分在明确的客户端阶段更新。浏览器尺寸影响布局时优先用 CSS,避免为了选择移动版导航而在 JavaScript 中重新构建整页。
这种一致性不是要求页面永远静态,而是要求变化有确定起点。客户端拥有的后续状态可以自由更新;服务端首次渲染的数据则需要可序列化、范围明确且不包含秘密。传给岛屿的 props 会进入浏览器产物,不应包含 API 私钥、私人文章或不需要显示的用户字段。岛屿是执行边界,不是数据保密边界。
加载时机要匹配操作发生的时机
---
import Search from '../components/Search.tsx';
import Comments from '../components/Comments.tsx';
---
<Search client:load />
<Comments client:visible />
首屏搜索可能需要立即响应,评论区位于正文下方,可以在进入视口后加载。client:idle 适合不阻塞初始阅读的组件,但空闲时机不保证符合用户首次操作时机。client:only 放弃服务端 HTML 的收益,必须同时考虑无脚本与加载期间体验。
每个指令不是性能评分按钮。要在慢网络和低性能设备下测试:如果评论区滚入视口后长时间只显示空白,延迟加载节省了早期传输,却损害了实际互动。可以使用稳定尺寸的占位与可识别的加载状态,避免布局跳动。
评论区在滚入视口时才开始加载,如果网络往返后才插入一个很高的区域,读者的当前位置可能移动。给占位设置与实际控件接近的稳定结构,可以减少跳动;但不能固定一个过小高度,让错误信息或长昵称覆盖正文。需要测试空、正常、错误与长内容状态,确认组件在相同可用宽度下能够展开,而不是只对一个演示截图正确。
加载指令还影响焦点。一个尚未 hydration 的按钮若看起来可以操作,点击后没有反应,用户会认为网站失效。可以提供真正可用的基础链接,或在准备期间表达不可操作状态,完成后保留焦点位置。不要在 hydration 后替换整个 DOM 节点导致用户刚聚焦的输入失去焦点。对键盘操作,首次 Tab、Enter 与 Escape 都要在慢网下测试。
离屏组件并非永远不重要。文章底部的订阅表单可能是用户唯一想使用的功能,快速滚动到那里时需要可预测响应。可以用可见性加载加合理预加载距离,但要验证框架与浏览器支持,再决定是否使用。对于必须随时可用的导航,立即加载小脚本往往比等待空闲更合理;节省几千字节不能以破坏基础导航为代价。
多框架与共享库的成本要在产物里判断
一个 React 搜索组件和一个 Vue 评论组件可以同时存在,但可能分别携带运行时。源码里每个组件都很小,不代表网络成本小。先查看构建输出和实际请求,确认哪些依赖被共享、哪些被重复引入。仅比较包的安装体积没有意义,未进入浏览器的开发工具不会影响读者下载,而一个间接导入的日期库可能影响首屏。
同一个框架也不保证成本自动最小。组件导入一个服务端专用模块,可能引入无用逻辑甚至触发构建错误。把数据获取与展示边界分开,服务端完成公开数据准备,客户端只获得交互必需信息。对大型编辑器、图表和搜索库,采用功能级动态导入可以进一步缩小首次脚本,但加载失败需要可恢复,不应在控制台静默吞掉。
性能预算可以按用户任务定义:文章加载不依赖搜索脚本,首次打开搜索有可接受等待,滚动阅读不发生明显长任务。数字阈值需要结合真实设备测量,不把桌面开发机结果当成全部用户。测试禁用缓存的首次访问与已有缓存的回访,二者对应不同瓶颈。长期跟踪产物大小和执行时间,才能发现某次“小组件修改”增加了整包依赖。
最后选择一个故障注入实验:阻断搜索脚本,阻断索引请求,再让评论服务返回错误。正文与基础导航应继续存在,失败控件应提供真实状态。若任何一个局部失败导致整页不可阅读,就说明岛屿架构虽然用上了指令,业务依赖仍然没有真正拆开。
一份岛屿决策记录应能解释拒绝方案
搜索决策可以写成:用户任务是查找公开文章;索引规模有限;搜索框与结果由一个组件拥有;首屏可见,因此及时加载;脚本失败时保留搜索入口;私人稿件不参与索引。被拒绝的方案也要说明原因,例如整页 React 化会扩大生命周期范围,拆开搜索框与列表会增加状态通信。决策记录不需要长篇架构文档,但应该让下次维护者知道边界为何在这里。
当需求加入跨页面搜索历史时,原有边界需要重新考虑,但不必立即升级成全站状态框架。可以先在搜索岛屿中保存有限本地历史,并提供清理动作;若多个页面确实共享复杂状态,再选择统一所有者。历史可能包含敏感查询,不应默认发送分析服务。每次扩展都先确认用户任务与数据范围,避免交互组件成为收集全部行为的隐形入口。
组件卸载与重新进入也属于边界。站点启用客户端导航后,脚本是否重复注册事件、定时器是否清理、搜索状态是否应保留,都要依照 Astro 当前导航机制测试。普通整页刷新没有暴露的重复监听问题,在客户端页面切换中可能导致一次点击执行两次。不要把框架组件自然卸载的经验直接套用到页面级脚本。
可以通过连续进入文章、离开、返回、打开搜索十次进行验收,观察网络请求与事件执行次数。输入状态是否保留由产品意图决定,事件是否重复则是实现正确性。对用户偏好与临时任务状态分别定义生命周期,主题偏好可以跨页面,搜索弹层是否打开通常不必长期保存。
最后把失败体验纳入成本比较。一个无脚本仍能提交的表单,可能比一个需要额外错误组件的小岛更简单;一个真正需要即时过滤和键盘导航的搜索,则值得承担运行时成本。岛屿架构不是禁止 JavaScript,而是让资源、状态与恢复责任落在能被解释的用户任务上。
用搜索会话推演异步结果的所有权
读者先输入 cache,随即改成 cache key,两个请求按相反顺序返回。如果列表只接受最后完成的响应,较早查询可能覆盖较新查询。岛屿边界已经很小,仍然需要给每次搜索绑定请求标识与查询快照。接收响应时核对它是否仍属于当前任务,过期结果不更新列表。取消旧请求可以节省部分资源,但不能代替这项检查,因为取消与完成之间存在竞争。
搜索组件应区分编辑中的字符串、已经提交的查询和当前可见结果所属查询。输入发生变化,不一定立即清空旧结果;若保留旧结果帮助阅读,应明确它们来自旧查询,并避免把数量显示为新查询结果。无匹配、请求失败和正在加载也是不同状态。把网络失败渲染为空列表,会让读者误以为文章不存在,也让性能日志把错误当成很快完成的搜索。
键盘选中项要跟随结果身份,而不是数组下标。新结果到达后,第一个位置可能已是另一篇文章,不能无声保留旧选中下标再让 Enter 跳到错误页面。可以按稳定文章 ID 保留仍存在的选择,否则回到无选择或明确的默认位置。焦点留在输入框,辅助技术通过适当状态通知了解更新,不必每次重渲染都抢走焦点。
验收时使用受控延迟响应,让较早查询更晚返回,检查结果与当前输入是否对应;再让索引加载失败,确认重试不会注册第二套监听器。快速关闭弹层以后,未完成请求返回也不应重新打开弹层。打开状态由用户动作拥有,搜索结果由任务版本拥有,两个状态分开能避免“完成一个网络请求顺便改变所有 UI”的耦合。
给组件数据传输建立最小投影
服务端已经读取文章集合,给搜索组件传递完整对象似乎最方便,但完整对象可能包含原文、内部审核字段和构建路径。先定义浏览器真正需要的搜索记录:公开 ID、标题、摘要、规范地址与少量主题词。明确投影比删除几个已知秘密更稳,因为集合以后新增字段时不会自动进入浏览器。类型接口能够帮助维护,最终仍要检查实际序列化产物。
大型索引可以单独生成公开 JSON,允许组件按需获取。索引版本应与公开集合一致,不能在同一部署里使用另一批尚未发布记录。若索引地址带内容哈希,旧页面可能继续引用旧索引,这是可理解的版本组合;若固定地址更新,旧组件需要能够理解新数据格式。数据格式变更与内容新增分别版本化,避免一次正常发文迫使所有旧脚本失效。
搜索结果的高亮也需要有边界。不要把匹配片段作为任意 HTML 插入 DOM;可以以普通文本与受控标记元素构造结果。正文可能包含代码、特殊字符或外部输入,即使作者可信,错误的转义仍会破坏结构。结果中的 URL 应来自公开投影,而不是用户查询拼接成任意目标。搜索任务只决定匹配,不赋予内容创建脚本或跳转目的地的权力。
检查产物时,准备一条只有私人记录中存在的标记,确认它不在 props、索引、脚本和源码映射中。测试这项投影需要独立的期待字段集合,不能直接把生产投影函数的返回值当作全部期望。否则函数错误地多传字段,测试也会一起认可。浏览器资源中的数据已经交付给访问者,视觉上没有显示并不构成隔离。
一个性能实验只改变一个边界
如果同时更换搜索框架、延迟指令和索引格式,首次搜索变快也无法知道来自哪项变化。可以先固定内容与设备条件,只比较立即加载和首次聚焦加载。记录首屏传输、第一次聚焦到可用结果的时间、主线程长任务与失败率。实验数字属于该组条件,不应被写成对所有站点都成立的推荐阈值。
第二轮再比较索引拆分或更轻的匹配实现。禁用缓存与回访分开,网络节流与 CPU 节流也分别描述。开发服务器的依赖加载方式不同于生产构建,正式结论应来自构建产物。若首屏节省明显但首次搜索等待不可接受,可以选择小型入口立即加载、索引受控预取,而不是在两种极端之间争论。
还要给统计保留用户任务语义。脚本下载完成不等于搜索可用,组件 hydration 完成不等于索引就绪,输入第一次响应不等于结果准确。每项测量的起点与终点应写清楚。失败任务也要进入报告,不把超时样本删除以后宣称平均更快。工程优化的目标是更多真实任务可靠完成,而不是挑选容易改善的技术事件。
原生方案同样需要完整验收
选择 details 不代表可以跳过设计。摘要文案应明确展开内容,嵌套交互是否可用要在键盘与触摸下检查,关闭后的内容不应继续占据错误焦点。普通搜索表单也需要有效目标、查询编码和明确错误反馈。浏览器提供基础语义,应用仍负责内容组织与服务端行为。原生控件减少实现责任,不会自动完成用户任务。
当必须使用自定义弹层时,记录其额外职责:焦点进入与返回、Escape、背景交互限制及滚动行为。检查打开后未加载的搜索区域是否仍可访问,关闭以后页面位置是否恢复。若这些责任比实际搜索需求更复杂,可以退回一个独立搜索页面。边界选择不是追求炫目的交互,而是在可用性与维护成本之间给出可验证的承诺。
衡量下载、执行与错误恢复
页面刚发布后,旧 HTML 仍可能引用上一版岛屿脚本。测试阻断这份旧资源,观察基础链接是否还在、控件是否能表达增强失败,而不是只在“关闭全部 JavaScript”下检查。前者保留了部分已经执行的脚本,能够暴露加载中状态永久不退出的问题;后者主要验证基础 HTML。两种故障不能互相替代。
第三方评论脚本还可能被内容拦截器拒绝。正文末尾不应因此留下无限加载动画,更不应提示用户关闭所有隐私保护。提供受控重试或明确的独立评论入口,并说明该入口仍属于第三方服务。评论请求是否发出、是否完成和组件是否可操作分别检查,网络拒绝不是读者没有评论,也不应触发整页刷新循环。
最后在移动设备验证键盘焦点、输入延迟和首次操作。岛屿边界应由用户任务决定:静态内容保持静态,必须互动的部分获得适当资源,需要共享状态的部分合在一起。这比追求某个固定数量的岛屿更可靠。