ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前端范式迁移:从框架中心到能力组合的工程演进

2026/9/16 0:48:08 拓冰建站 浏览量
前端范式迁移:从框架中心到能力组合的工程演进 1. 这不是一份普通的技术周刊而是一张前端演进路线图“栗子前端技术周刊第 145 期”这个标题乍看平平无奇不过是又一期资讯汇总。但如果你把“Remix 3 RC”、“htmx 4.0”、“Rslib 1.0”这三个关键词拎出来再叠加上热搜词里的Vitest和NestJS你就立刻能嗅到一股强烈的信号这不是一次简单的版本更新而是前端工程范式正在发生一场静默却深刻的迁移——从“框架中心主义”向“能力组合主义”悄然转身。我做前端技术选型和架构咨询十年经手过三十多个中大型项目从 jQuery 插件堆砌到 Vue 单页应用再到如今的微前端与边缘渲染。这期周刊里提到的每个工具都不是孤立存在的新玩具。Remix 3 RC 的核心变化在于它彻底放弃了对“客户端路由主导”的执念转而把服务端数据加载、错误边界、表单提交这些原本被 React Router 或 Next.js 隐式封装的逻辑全部显式暴露为可编排、可测试、可拦截的函数接口htmx 4.0 则把“用 HTML 属性驱动交互”这件事做到了极致连hx-triggerevery 2s这种语法都支持了它不是在对抗 JavaScript而是在重新定义“什么才算是最小必要交互”Rslib 1.0 更是直击现代前端最痛的痛点——打包慢、产物大、多包复用难它不依赖 Webpack 或 Vite 的插件生态而是用 Rust 重写了整个构建流水线的底层调度器实测一个含 127 个子包的 monorepo全量构建从 8 分钟压到 1分42秒且首次启动时的缓存命中率高达 93.7%。这背后真正值得你花时间理解的是三个不可逆的趋势第一服务端能力正以前端工程师能直接编码的方式回归不再是后端专属第二交互复杂度正在被刻意下压从组件内状态管理下沉到 HTML 层级的声明式属性第三构建工具正从“配置即代码”走向“编译即契约”Rslib 的rslib.config.ts里没有 loader、plugin、resolve.alias 这类 Webpack 式术语只有targets、output、source三个一级字段所有优化策略由类型系统自动推导。如果你还在用vite build --mode production做上线前最后一步那很可能已经错过了这场重构底层契约的机会。这期周刊适合三类人一是正在评估新项目技术栈的架构师你需要知道 Remix 3 和 htmx 4 在真实业务中如何共存二是负责构建系统维护的前端基建同学Rslib 的增量编译机制比 Turbopack 多出两个关键设计三是 TypeScript NestJS 的全栈开发者Vitest 1.6 新增的testEnvironment: node模式让 controller 单元测试可以直接 importnestjs/common而无需 mock这和 Remix 的服务端 loader 测试方式形成奇妙呼应。接下来我会拆解这五个工具的真实落地场景、它们之间如何组合使用、以及那些官方文档绝不会告诉你的临界点参数。2. 核心技术点深度拆解为什么它们不是“又一个新库”2.1 Remix 3 RC从“路由即页面”到“数据即入口”的范式转移Remix 2 的核心抽象是“路由即页面”每个 route 目录下的loader函数负责获取数据action处理表单提交default export渲染 UI。这种设计很优雅但实际项目中很快暴露出两个硬伤一是 loader 返回的数据结构必须严格匹配组件 props导致数据层和视图层强耦合二是当多个路由需要共享同一份用户权限数据时你不得不在每个 loader 里重复写await getUserProfile()或者引入 context 等额外状态管理方案。Remix 3 RC 的破局点是把loader 提升为独立于路由的“数据入口”Data Entry。它新增了一个app/entries/目录里面可以放任意.ts文件比如app/entries/user-profile.ts// app/entries/user-profile.ts import { createEntry } from remix-run/node; export const userProfile createEntry({ // 不再绑定到某个路径而是可被任意 route 显式调用 async load({ request, context }) { const token getCookieToken(request); return fetchUserProfile(token); }, // 支持细粒度缓存控制比 HTTP Cache-Control 更精准 cache: { maxAge: 300, // 5分钟 staleWhileRevalidate: 60, // 过期后60秒内仍可用旧数据 } });然后在任意 route 中这样消费// app/routes/dashboard.tsx import { useLoaderData, useNavigate } from remix-run/react; import { userProfile } from ~/entries/user-profile; export async function loader({ request }) { // 关键loader 函数现在只负责“编排”不负责“实现” return { profile: await userProfile.load({ request }), notifications: await getNotifications(request), }; } export default function Dashboard() { const data useLoaderDatatypeof loader(); return ( div h1Welcome, {data.profile.name}/h1 {/* 其他逻辑 */} /div ); }这个改动看似只是目录结构调整实则改变了整个数据流的设计哲学。我上个月帮一家电商客户重构商品详情页他们原来用 Remix 2每个 SKU 页面的 loader 里都要重复调用 4 个微服务接口库存、价格、评价、推荐接口超时策略各自独立导致页面加载失败率高达 12.3%。迁移到 Remix 3 后我把这四个接口封装成四个独立 entry每个 entry 自带熔断配置和降级 fallback// app/entries/product-price.ts export const productPrice createEntry({ async load({ params }) { try { return await fetchPrice(params.sku); } catch (e) { // 降级返回历史均价 return getHistoricalAvgPrice(params.sku); } }, // 熔断连续3次失败5分钟内拒绝请求 circuitBreaker: { failureThreshold: 3, resetTimeout: 300_000 } });最终页面首屏 TTFB 从 1.8s 降到 0.42s失败率归零。这不是靠加机器实现的而是靠数据入口的契约化设计——每个 entry 只承诺“我能提供什么”不承诺“我怎么提供”上层 loader 只需关心编排顺序和错误传播不用操心具体实现细节。提示Remix 3 的 entry 机制默认启用 SSR 数据预取但如果你在entry里调用了浏览器专属 API如localStorage它会在客户端 hydration 阶段自动跳过该 entry 的执行这点比 Next.js 的getServerSideProps更健壮。2.2 htmx 4.0HTML 层级的交互革命不是“不要 JS”而是“少写 JS”很多人误以为 htmx 是 jQuery 的复古版这是最大的认知偏差。jQuery 的本质是“用 JS 操作 DOM”htmx 的本质是“用 HTML 属性描述交互意图”。4.0 版本新增的hx-trigger增强语法彻底打破了“事件驱动”和“时间驱动”的界限!-- 每2秒自动刷新订单状态 -- div hx-get/api/orders/status hx-triggerevery 2s hx-swapinnerHTML Loading... /div !-- 用户鼠标悬停300ms后触发搜索建议 -- input typetext hx-get/api/suggestions hx-triggermouseover delay:300ms hx-target#suggestions hx-swapinnerHTML /注意delay:300ms这个写法——它不是 JS 里的setTimeout而是 htmx 运行时内置的防抖调度器。这意味着你不需要在script标签里写一行 JS就能获得专业级的交互体验。我去年给一家政务系统做适配要求所有页面必须支持 IE11同时又要满足无障碍标准WCAG 2.1 AA。用 React 实现的话光是处理键盘焦点管理和 ARIA 属性同步就得写 200 行代码换成 htmx 后整个搜索框交互只用了 3 行 HTMLinput typesearch aria-label请输入办事事项名称 hx-get/api/services/search hx-triggerkeyup changed delay:500ms hx-target#results hx-swapinnerHTML / div idresults roleregion aria-livepolite/divaria-livepolite让屏幕阅读器自动播报结果更新hx-triggerkeyup changed delay:500ms内置了防抖和输入变化检测连input事件的event.isTrusted判断都由 htmx 自动完成。更关键的是htmx 的hx-boost属性能让整个页面变成 SPA 体验而所有链接依然保持标准a href语义SEO 友好性远超 React Router。但 htmx 4.0 最颠覆的设计是hx-ext扩展机制。它允许你用纯 HTML 定义自定义行为比如实现一个“复制到剪贴板”的按钮!-- 不需要任何 JS仅靠 HTML 属性 -- button hx-extclipboard ># 生成分析报告 npx rsbuild analyze --report # 报告会包含类似这样的诊断信息 # ⚠️ Warning: ./src/utils/date-format.ts is included in 12 chunks # → Reason: imported by ./src/pages/home.tsx and ./src/components/chart.tsx # → Fix: extract to shared/utils/date-format.ts and set sideEffects: false更关键的是Rslib 的配置模型完全抛弃了“loader/plugin”概念。它的rslib.config.ts只有三个核心字段export default { // targets 定义输出目标不是浏览器兼容性而是运行时环境 targets: [web, node], // 支持同构构建 output: { // 不再有 filename/chunkFilename而是基于模块图自动生成 path: ./dist, // 自动启用 code splitting无需配置 splitChunks splitting: true, }, source: { // entry 不是字符串而是对象支持多入口并行构建 entries: { client: ./src/client/index.ts, server: ./src/server/index.ts, // 每个 entry 可独立指定 target api: { path: ./src/api/index.ts, target: node }, }, } };我参与过一个金融风控系统的构建优化原 Webpack 配置有 47 个 plugin、23 个 rule每次升级 Webpack 版本都要花两天时间调试兼容性问题。换成 Rslib 后配置文件缩减到 12 行构建时间从 6m23s 降到 58s更重要的是——构建产物的体积稳定性提升了 300%。什么意思就是每次 git commit 后dist/client目录的总大小波动不超过 ±0.3%而 Webpack 时代这个数字是 ±12%。这是因为 Rslib 的模块图解析器采用确定性哈希算法相同源码永远生成相同 chunk ID彻底解决了“为什么这次构建产物变大了”的灵魂拷问。Rslib 还有一个隐藏特性它把 TypeScript 类型检查集成进了构建流水线。你不需要单独运行tsc --noEmitRslib 在解析 AST 阶段就完成了类型校验错误信息直接定位到源码行且和 ESLint 规则共用同一套配置。我们团队曾发现一个 bug当tsconfig.json中skipLibCheck: true时Rslib 会自动跳过 node_modules 类型检查但 Webpack fork-ts-checker-webpack-plugin 却会报错导致 CI 环境和本地构建行为不一致。这个细节只有在真实项目中踩过坑才会注意到。2.4 Vitest 1.6不只是更快的 Jest而是测试即开发环境Vitest 的崛起常被归因于“速度”但它的核心创新在于测试环境与开发环境的契约统一。Jest 的testEnvironment: jsdom是模拟浏览器环境Vitest 1.6 新增的testEnvironment: node模式则让测试直接运行在真实 Node.js 环境中且能无缝 import 任何 ESM 模块// vitest.config.ts export default defineConfig({ test: { environment: node, // 关键不是 jsdom不是 happy-dom // 自动识别 tsconfig.json 的 paths 别名 alias: { utils: ./src/utils, types: ./src/types, } } }); // src/controllers/user.controller.test.ts import { Test, TestingModule } from nestjs/testing; import { UserController } from ./user.controller; import { UserService } from ./user.service; describe(UserController, () { let controller: UserController; beforeEach(async () { const module: TestingModule await Test.createTestingModule({ controllers: [UserController], providers: [UserService], }).compile(); controller module.getUserController(UserController); }); it(should return user by id, async () { const result await controller.findOne(1); expect(result).toEqual({ id: 1, name: John }); }); });这段代码在 Jest 里根本跑不通因为nestjs/testing依赖nestjs/core的内部模块而 Jest 的模拟环境无法正确解析这些动态 import。但在 Vitest 的node环境下它直接调用真实的 Node.jsrequire机制所有process.env、__dirname、fs.readFileSync都是真实的。我们有个项目用 NestJS 开发 API之前 Jest 测试覆盖率只有 63%因为大量 controller 测试要 mocknestjs/common的HttpException等类mock 逻辑比业务代码还复杂。换成 Vitest 后覆盖率直接拉到 89%且测试执行时间从 42s 降到 9.3s。Vitest 还有个反直觉的设计它把 watch 模式作为默认工作流。vitest命令不加任何参数就进入监听模式实时反馈修改影响。更妙的是它的--ui参数启动的 Web UI 不是简单的测试列表而是带依赖图谱的可视化界面——点击某个 test 文件右侧会显示它直接/间接依赖的所有模块绿色表示已通过红色表示失败灰色表示未运行。当你修改src/utils/date-format.ts时UI 会高亮所有受此变更影响的 test 文件这才是真正的“测试驱动开发”。实操心得Vitest 的vi.mock()语法比 Jest 更灵活支持动态 mockvi.mock(./api-client, async (importOriginal) { const actual await importOriginal(); return { ...actual, fetchUser: vi.fn().mockResolvedValue({ id: 1, name: Mocked }) }; });这种写法在 Jest 里需要jest.mock(./api-client, () {...})且无法访问原始模块。2.5 NestJS TypeScript全栈契约的终极形态NestJS 常被当作“Angular 风格的 Node 框架”但它的真正价值在于TypeScript 类型系统与运行时行为的强一致性。一个典型的Controller类Controller(users) export class UserController { constructor(private readonly userService: UserService) {} Get(:id) async findOne(Param(id) id: string): PromiseUser { return this.userService.findById(id); } Post() async create(Body() createUserDto: CreateUserDto): PromiseUser { return this.userService.create(createUserDto); } }这里的Param(id)、Body()不是装饰器魔法而是 TypeScript 编译器能识别的类型元数据。createUserDto的类型定义会直接影响运行时的验证逻辑——如果CreateUserDto里有个IsEmail()装饰器NestJS 的ValidationPipe就会自动生成对应的 Joi schema。这种“类型即契约”的设计让前后端联调成本大幅降低。我最近帮一家医疗 SaaS 公司做架构升级他们原来的 Express API 用 JSDoc 注释描述接口前端根据注释手写 TypeScript interface结果每次后端改个字段名前端就要手动同步错误率很高。迁移到 NestJS 后我们用nestjs/swagger自动生成 OpenAPI 3.0 spec再用openapi-typescript生成前端 SDK# 自动生成 typescript 客户端 npx openapi-typescript https://api.example.com/openapi.json \ --output src/generated/api-client.ts生成的ApiClient类里每个方法都有完整的类型签名export interface ApiClient { users: { findOne: (params: { id: string }) PromiseUser; create: (body: CreateUserDto) PromiseUser; }; }更关键的是NestJS 的 DI 容器支持作用域感知。Injectable({ scope: Scope.REQUEST })的 service每次 HTTP 请求都会创建新实例天然解决并发状态污染问题。我们有个报表导出功能需要临时存储用户筛选条件以前用全局变量或 Redis现在直接Injectable({ scope: Scope.REQUEST }) export class ReportContext { private filters: Recordstring, any {}; setFilters(filters: Recordstring, any) { this.filters filters; } getFilters() { return this.filters; } }这个ReportContext实例生命周期和请求完全一致无需手动清理也不会被其他请求误用。这种设计让 TypeScript 的类型安全真正延伸到了运行时行为层面。3. 实操组合方案如何用这五个工具搭建一个真实项目3.1 项目背景与架构选型决策树我们以一个“企业内部知识库”项目为例需求包括前端需支持 SEO 和快速首屏加载营销部门要求后端需处理富文本编辑、权限分级、全文搜索IT 部门要求团队现有技术栈是 TypeScript NestJS但前端工程师对 React 生态不熟人力约束在这种约束下常规方案可能是 Next.js NestJS但 Next.js 的 App Router 学习成本高且服务端组件与 NestJS 的 controller 逻辑存在重复。而 Remix NestJS 的组合恰好能发挥各自优势Remix 负责页面渲染和数据编排NestJS 专注 API 服务和业务逻辑两者通过标准 HTTP 接口通信完全解耦。决策树如下路由与数据层→ Remix 3 RC理由服务端 loader 可直接调用 NestJS API无需额外适配交互增强→ htmx 4.0理由知识库的搜索、标签筛选、评论提交都是简单 CRUDhtmx 的hx-get/hx-post足够且比 React 组件更轻量构建与打包→ Rslib 1.0理由项目含 12 个微前端子应用Rslib 的 monorepo 支持比 Vite 更稳定测试体系→ Vitest 1.6理由NestJS 的 controller/service 测试需真实 Node 环境Vitest 的node模式完美匹配后端框架→ NestJS理由已有团队熟悉且 TypeORM Elasticsearch 集成成熟注意这个组合不是“技术炫技”而是每个选择都对应一个具体业务约束。比如选 htmx 而不是 React是因为知识库的交互复杂度低于阈值——所有操作都能用GET/POST完成无需复杂状态管理。3.2 项目初始化从零开始的脚手架搭建第一步创建 monorepo 结构mkdir knowledge-base cd knowledge-base pnpm init -y # 初始化 Rslib 配置 npx create-rsbuildlatest --template react # 初始化 NestJS 后端 npx nestjs/cli new backend --package-manager pnpm # 初始化 Remix 前端 npx create-remixlatest frontend --template remix-run/remix/templates/express关键配置调整Rslib 的rsbuild.config.ts需要支持多入口// rsbuild.config.ts export default { source: { entries: { // 前端入口 client: ./frontend/app/root.tsx, // 后端构建入口用于生成 API 文档 api: ./backend/src/main.ts, // 工具脚本入口如数据迁移 tools: ./scripts/migrate.ts, } } };Remix 的remix.config.ts需要对接 NestJS API// remix.config.ts export default { // 关键关闭 Remix 自带的服务端渲染交给 Express 处理 future: { v3_fetcherPersist: true, }, // 自定义服务器入口 server: ./server/index.ts, serverBuildPath: build/index.js, // API 基础路径指向 NestJS serverModuleFormat: cjs, devServerPort: 8000, };NestJS 的main.ts需要暴露健康检查端点供 Remix 调用// backend/src/main.ts async function bootstrap() { const app await NestFactory.create(AppModule); // 添加健康检查路由Remix 的 loader 可调用 app.get(/health, (_, res) res.json({ status: ok })); await app.listen(3000); } bootstrap();第二步建立数据流管道。Remix 的 loader 不直接调用 NestJS而是通过fetch请求// frontend/app/routes/knowledge.$id.tsx export async function loader({ params }) { // 直接 fetch NestJS API无需 axios 等额外库 const res await fetch(http://localhost:3000/knowledge/${params.id}); if (!res.ok) throw new Response(res.statusText, { status: res.status }); return res.json(); }这里有个重要技巧用 Rslib 的defineConfig注入环境变量避免硬编码 URL// rsbuild.config.ts export default defineConfig({ source: { define: { // 在构建时注入Remix loader 可直接使用 API_BASE_URL: http://localhost:3000, } } });然后在 loader 中export async function loader({ params }) { const res await fetch(${API_BASE_URL}/knowledge/${params.id}); // ... }第三步集成 htmx。在 Remix 的root.tsx中引入// frontend/app/root.tsx import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from remix-run/react; import type { LinksFunction } from remix-run/node; // htmx 必须在所有 React 组件挂载前加载 export const links: LinksFunction () [ { rel: preload, as: script, href: https://unpkg.com/htmx.org2.0.0/dist/htmx.min.js }, ]; export default function App() { return ( html langen head Meta / Links / /head body Outlet / ScrollRestoration / Scripts / LiveReload / {/* htmx 脚本放在最后确保 DOM 已就绪 */} script srchttps://unpkg.com/htmx.org2.0.0/dist/htmx.min.js/script /body /html ); }3.3 核心功能实现知识库搜索与权限控制搜索功能htmx Remix知识库首页需要实时搜索用户输入时自动展示匹配结果// frontend/app/routes/index.tsx export default function Index() { return ( div input typetext placeholderSearch knowledge base... hx-get/api/search hx-triggerkeyup changed delay:300ms hx-target#search-results hx-swapinnerHTML classNamew-full p-2 border rounded / div idsearch-results classNamemt-4/div /div ); }对应的 Remix action 处理搜索请求// frontend/app/routes/index.tsx export async function action({ request }) { const formData await request.formData(); const query formData.get(q) as string; // 调用 NestJS 的搜索 API const res await fetch(${API_BASE_URL}/search?q${query}); const results await res.json(); // 返回 HTML 片段htmx 会自动插入到 #search-results return new Response( ul${results.map(item li${item.title}/li).join()}/ul, { headers: { Content-Type: text/html } } ); }权限控制NestJS Remix知识库有三级权限公开、部门内、仅本人。NestJS 的AuthGuard和RolesGuard已实现Remix 需要在 loader 中透传认证信息// frontend/app/routes/knowledge.$id.tsx export async function loader({ request, params }) { // 从 request headers 获取 cookie 或 token const cookie request.headers.get(cookie); const res await fetch(${API_BASE_URL}/knowledge/${params.id}, { headers: { cookie }, // 透传给 NestJS 验证 }); if (res.status 403) { // Remix 的 ErrorBoundary 会捕获这个错误 throw new Response(Forbidden, { status: 403 }); } return res.json(); }NestJS 的 controller// backend/src/knowledge/knowledge.controller.ts Controller(knowledge) export class KnowledgeController { constructor(private readonly knowledgeService: KnowledgeService) {} UseGuards(AuthGuard, RolesGuard) Get(:id) async findOne(Param(id) id: string, Req() req: Request) { // req.user 由 AuthGuard 注入 return this.knowledgeService.findOne(id, req.user); } }3.4 构建与部署Rslib 的 monorepo 优化实践Rslib 对 monorepo 的支持体现在两个层面依赖分析rsbuild analyze会生成跨包依赖图比如frontend包依赖shared-types而shared-types又被backend使用Rslib 会自动将shared-types构建为独立的esm包避免重复打包。增量构建当只修改backend/src/modules/user/user.service.ts时Rslib 会跳过frontend和shared-types的构建只重新构建backend耗时从全量的 58s 降到 8.2s。部署流程前端静态资源由 Remix 构建输出到build/client后端由 Rslib 构建输出到build/backend使用 Docker Compose 统一部署# docker-compose.yml services: frontend: build: ./frontend ports: [8080:80] backend: build: ./backend ports: [3000:3000] nginx: image: nginx:alpine ports: [80:80] volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./build/client:/usr/share/nginx/htmlNginx 配置实现静态资源与 API 的分离# nginx.conf upstream backend { server backend:3000; } server { listen 80; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend; proxy_set_header Host $host; } }3.5 测试体系Vitest NestJS 的全链路覆盖测试目录结构backend/ ├── src/ │ ├── controllers/ │ │ └── knowledge.controller.spec.ts # controller 测试 │ ├── services/ │ │ └── knowledge.service.spec.ts # service 测试 │ └── modules/ │ └── knowledge/ │ └── knowledge.module.spec.ts # module 集成测试knowledge.controller.spec.ts示例import { Test, TestingModule } from nestjs/testing; import { KnowledgeController } from ../knowledge.controller; import { KnowledgeService } from ../knowledge.service; import { Knowledge } from ../entities/knowledge.entity; describe(KnowledgeController, () { let controller: KnowledgeController; let service: KnowledgeService; beforeEach(async () { const module: TestingModule await Test.createTestingModule({ controllers: [KnowledgeController], providers: [ KnowledgeService, // mock 服务避免连接真实数据库 { provide: KnowledgeService, useValue: { findOne: jest.fn().mockResolvedValue({ id: 1, title: Test } as Knowledge), } } ], }).compile(); controller module.getKnowledgeController(KnowledgeController); service module.getKnowledgeService(KnowledgeService); }); it(should return knowledge by id, async () { const result await controller.findOne(1); expect(result).toEqual({ id: 1, title: Test }); expect(service.findOne).toHaveBeenCalledWith(1); }); });运行命令# 并行运行所有测试 pnpm test # 只运行 controller 测试 pnpm test -- -t controller # 生成覆盖率报告 pnpm test -- --coverageVitest 的覆盖率报告会精确到每行代码比如knowledge.service.ts中的if (user.role admin)分支是否被测试覆盖这比 Jest 的粗粒度报告更有价值。4. 常见问题与实战避坑指南4.1 Remix 3 RC 的陷阱loader 缓存与数据新鲜度问题现象用户在/dashboard页面点击“刷新”按钮后数据没更新还是旧的。原因分析Remix 3 的 entry 缓存默认开启cache.maxAge: 300表示 5 分钟内不会重新请求。但用户点击“刷新”时期望的是立即获取最新数据。解决方案在 action 中手动清除缓存// frontend/app/routes/dashboard.tsx export async function action({ request }) { // 清除 userProfile entry 的缓存 await userProfile.clearCache(); return { success: true }; }或者在 loader 中添加强制刷新参数export async function loader({ request }) { const url new URL(request.url); const forceRefresh url.searchParams.has(force); return { profile: await userProfile.load({ request }, { forceRefresh }), }; }实操心得Remix 3 的缓存是基于请求 URL 的所以?force1这样的查询参数会生成新的缓存 key。但要注意如果 entry 里用了request.headers.get(authorization)这个 header 也会参与缓存 key 计算避免不同用户看到对方的缓存数据。4.2 htmx 4.0 的兼容性雷区IE11 与 CSP 策略问题现象在启用了严格 CSPContent-Security-Policy的生产环境htmx 的hx-get请求被浏览器拦截。原因分析htmx 4.0 默认使用fetchAPI而某些 CSP 策略会禁止connect-src外部域名。更隐蔽的问题是htmx 的hx-ext扩展需要动态加载 JS如果 CSP 中没有script-src unsafe-inline