资讯动态

vinext ESM externals 深度解析:从 Next.js 测试夹具移植到外部化决策原理

发布时间:2026/9/25 3:40:32 来源:尧图企业网站定制
后端Web框架SSR【免费下载链接】vinextVite plugin that reimplements the Next.js API surface — deploy anywhere项目地址https://gitcode.com/gh_mirrors/vi/vinext点击查看免费下载本文以 vinext 仓库中的 tests/fixtures/esm-externals/README.md 为骨架结合 esm-externals.test.ts 及 fixture 源码完整讲解 vinextVite plugin that reimplements the Next.js API surface如何处理 ESM 外部依赖哪些包会被外部化、哪些必须保留在 bundle 内、条件导出import/require/browser如何被解析以及这些决策背后的源码原理。读完你将能复现这套混合 App Router Pages Router 的 ESM externals 测试场景并理解serverExternalPackages、transpilePackages、optimizePackageImports与 alias 在 vinext 中的真实行为。一、什么是 ESM externals fixturevinext 在 tests/fixtures/esm-externals/ 下维护了一个专门用于验证外部化externalization行为的测试夹具fixture。所谓 externalization是指构建工具在服务端打包时把某些第三方依赖排除在 bundle 之外改为在运行时通过 Node 的原生require/import直接加载——这既能减小产物体积、避免重复打包也能让依赖以符合其自身package.json导出条件的方式运行。该 fixture 的设计目标非常明确原文说明五个核心路由pages/static.js、pages/ssr.js、pages/ssg.js以及 App Router 侧的app/server/page.js、app/client/page.js六个本地包目录esm-package1、esm-package2、invalid-esm-package、app-esm-package1、app-esm-package2、app-cjs-esm-package。这些路由与包目录移植自 Next.js 官方test/e2e/esm-externalsv16.2.6 版本并做了两处本地化调整应用了本仓库的代码格式化风格给成对的本地包显式添加了版本号见 package.json 中每个file:./__test_packages__/...依赖以便 pnpm 能够区分它们共享的包名例如esm-package1与app-esm-package1的内部name都可能是esm-package/app-esm-package但版本不同pnpm 才不会把它们合并成同一个 store 条目。注意原文强调这些文件有意保留了上游的import(fail)与require(fail)哨兵sentinelfixture 中没有任何插件或 resolver 为缺失模块提供豁免。换句话说这些哨兵的存在就是为了验证失败就是预期行为——缺失模块不会被悄悄放行。除此之外fixture 还额外补充了多个路由和包目录用于覆盖 vinext 的所有权传播ownership propagation、路径别名aliases、条件导出conditional exports、MDX、动态导入dynamic imports、以及必须保持打包remain bundled的包等场景。二、fixture 的目录结构与角色划分先用一张目录全景图看清每个部件的职责可在仓库中对照查看tests/fixtures/esm-externals/ ├── __test_packages__/ # 12 个本地伪依赖包 │ ├── esm-package1/ # import 条件导出 - correct.mjs │ ├── esm-package2/ # import 条件导出 - correct.js │ ├── invalid-esm-package/ # 会被外部化的无效 ESM含 require(fail) 哨兵 │ ├── app-esm-package1/ # App Router 专用import - correct.mjs │ ├── app-esm-package2/ # App Router 专用import - correct.js │ ├── app-cjs-esm-package/ # App Router 专用 CJS/ESM 混合包 │ ├── dynamic-esm-package/ # 模板字符串动态导入 │ ├── literal-dynamic-esm-package/ # 字面量动态导入 │ ├── explicit-esm-package/ # transpilePackages 显式转译保留在 bundle │ ├── optimized-esm-package/ # optimizePackageImports 优化对象 │ ├── geist/ # 默认被打包的普通依赖 │ └── shared-condition-package/ # react-server 条件导出测试 ├── app/ # App Router 侧 │ ├── layout.js │ ├── server/page.js # 服务端组件 │ ├── client/page.js # use client 客户端组件 │ └── app-shared/page.js ├── pages/ # Pages Router 侧 │ ├── static.js / ssr.js / ssg.js # 核心三路由 │ ├── aliased.js # 别名解析 │ ├── dynamic.js # 动态导入 │ ├── bundled-packages.js # 三种必须打包场景 │ ├── mdx-ownership.mdx # MDX 所有权 │ └── pages-shared.js # 条件导出 ├── lib/ # 共享辅助模块 ├── next.config.mjs # 移植自 Next.js 的配置 ├── vite.config.ts # vinext 插件入口 └── package.json从包目录的命名规则可以读出设计意图带app-前缀的包专门用于 App Router 的 RSC 环境不带前缀的包服务于 Pages Routerinvalid-esm-package特意命名为 invalid是为了验证 vinext 与 Next.js 一致地把它当作外部依赖处理Next.js 对无法转译的包默认外部化而不是尝试去转译它。三、next.config.mjsfixture 的行为配置总纲fixture 根目录的 next.config.mjs 是理解所有行为的关键——它同时兼容 Next.js 原生运行与 vinext 的移植语义export default { pageExtensions: [js, jsx, ts, tsx, mdx], serverExternalPackages: [app-esm-package1, app-esm-package2, app-cjs-esm-package], transpilePackages: [explicit-esm-package], experimental: { optimizePackageImports: [optimized-esm-package] }, turbopack: { resolveAlias: { preact/compat: react } }, webpack(config) { config.resolve.alias { ...config.resolve.alias, preact/compat: react }; return config; }, };各配置项在本 fixture 中的作用配置项值影响pageExtensions[js,jsx,ts,tsx,mdx]允许.mdx作为页面参与路由与所有权解析支撑pages/mdx-ownership.mdxserverExternalPackagesapp-esm-package1/2、app-cjs-esm-package强制外部化这三个 App Router 依赖即使在 RSC 服务端也不打包transpilePackagesexplicit-esm-package显式转译该包 → 必须保留在 bundle 内绝不外部化optimizePackageImportsoptimized-esm-package对包做导入优化按需重写子路径同样不外部化resolveAliasturbopackpreact/compat - react把 preact 兼容层指向 react两个打包器路径都配置了webpack(config)同样注入 alias保证 webpack 与 turbopack 行为一致对应的运行时入口 vite.config.ts 则非常简单——只用vinext()一个插件即可承载这些 Next.js 语义import { defineConfig } from vite; import vinext from vinext; export default defineConfig({ plugins: [vinext()], });四、Pages Router 三大核心路由静态 / SSR / SSG三个核心路由的代码结构完全同构均从preact/compat导入 React并从三个包导入World*// pages/static.js / pages/ssr.js / pages/ssg.js结构一致 import React from preact/compat; import World1 from esm-package1/entry; import World2 from esm-package2/entry; import World3 from invalid-esm-package/entry;区别只在于数据获取方式pages/static.js无数据获取直接渲染pHello WorldWorldWorldWorldWorldWorld/ppages/ssr.js导出getServerSideProps在服务端解析出worlds后作为 props 传入pages/ssg.js导出getStaticProps在构建期完成同样的解析。这里的关键事实是esm-package1、esm-package2会被外部化而invalid-esm-package也被外部化见后文外部化判定一节。三个包在服务端都被require加载因此三个路由的服务端渲染结果一致Hello WorldWorldWorldWorldWorldWorld其中 5 个World分别来自三个包与两处模板字符串拼装。esm-externals.test.ts 中的生产构建测试对三个路由逐一断言for (const route of [static, ssr, ssg]) { expect(await renderParagraph(http://127.0.0.1:${address.port}/${route})).toBe( Hello WorldWorldWorldWorldWorldWorld, ); }开发模式测试tests/esm-externals.test.ts则验证了另一个重要结论原生 ESM 依赖可以留在 Vite 的 dev module runner 内部keeps native ESM dependencies inside Vites dev module runnerdev 下访问/static同样返回Hello WorldWorldWorldWorldWorldWorld。五、条件导出conditional exports如何被解析这是 fixture 的核心考点。以 esm-package1/package.json 为例{ name: esm-package, version: 1.0.0, exports: { ./entry: { browser: ./browser.mjs, import: ./correct.mjs, require: ./wrong.js } } }包内四个文件的分工一目了然correct.mjs/correct.js是正确实现导出的字符串为World而browser.mjs/wrong.js是陷阱文件导出的字符串并非World。条件导出被设计成答错即翻车如果构建工具错误地选择了browser或require条件页面渲染就会出现非World的字符串测试断言立刻失败。测试还额外校验了客户端产物tests/esm-externals.test.tsexpect(clientCode).not.toContain(process.browser); expect(clientCode).not.toContain(Browser only);即客户端代码中绝不能出现browser条件文件的内容——外部化决策对客户端打包器不可见客户端拿到的是独立打包、只走import条件的实现。类似的设计出现在invalid-esm-packageimport - correct.js、require - alternative.js与 App Router 侧的app-esm-package1/2。而shared-condition-package则把条件导出提升到 RSC 语境{ name: shared-condition-package, type: module, exports: { .: { react-server: ./rsc.mjs, import: ./default.mjs } } }App 与 Pages 共用这一个包但预期渲染不同测试断言/app-shared渲染App:RSC→ App Router 服务端走了react-server条件rsc.mjs/pages-shared渲染Pages:DEFAULT→ Pages Router 走了import默认条件default.mjs。这直接证明 vinext 会按环境RSC / Pages选择不同的导出条件同一依赖在不同路由体系下可以被解析为不同实现。六、App RouterserverExternalPackages 与 use client 的外部化App Router 侧由三个页面构成app/layout.js根布局app/server/page.js服务端组件导入app-esm-package1/2与app-cjs-esm-packageapp/client/page.js客户端组件use client指令导入同样的三个包app/app-shared/page.js消费shared-condition-package。next.config.mjs中serverExternalPackages显式列出这三个app-*包意味着即使在 App Router 的 RSC 服务端环境中这三个依赖也会被强制外部化走 Node 运行时原生加载而不是打进 RSC bundle。测试对/server与/client两个路由的断言均为Hello WorldWorldWorldtests/esm-externals.test.ts说明服务端组件与客户端组件都能正确解析这三个外部化包——客户端组件里的三个包则按正常打包流程进入客户端 chunk。这里可以对比 Pages 与 App 的差异Pages 的esm-package1/2默认外部化Next.js 语义App 的app-*包需要显式声明serverExternalPackages才外部化。两种路由体系对外部化的默认策略不同fixture 完整覆盖了这两种路径。七、必须保留在 bundle 内的三种情况pages/bundled-packages.js 集中验证了三种绝不外部化的场景import defaultTranspiled from geist/entry; import optimized from optimized-esm-package/entry; import explicit from explicit-esm-package/entry;geist默认打包一个普普通通、没有任何特殊配置的依赖。Next.js/vinext 的默认策略是只要可以转译就留在 bundle 里所以它被保留explicit-esm-packagetranspilePackages被transpilePackages显式列入转译名单含义就是即使你想外部化也不行必须打包转译optimized-esm-packageoptimizePackageImports被optimizePackageImports启用导入优化——构建工具会按需改写并内联其子路径导入自然不可能外部化。测试断言tests/esm-externals.test.ts页面渲染为DEFAULT_TRANSPILEDOPTIMIZEDEXPLICIT同时最终生成的 externals 清单dist/server/vinext-externals.json明确排除这三个包expect(externals).not.toContain(geist); expect(externals).not.toContain(optimized-esm-package); expect(externals).not.toContain(explicit-esm-package);vinext-externals.json是 vinext 在dist/server下输出的外部化清单文件测试直接读取它来核对最终决策——这是验证外部化行为最直接的产物级证据。八、动态导入与别名额外的覆盖场景fixture 还专门为动态导入与别名设计了两个页面动态导入pages/dynamic.js——同时测试字面量与模板字符串两种形式export async function getServerSideProps() { const [{ default: literal }, { default: template }] await Promise.all([ import(shared/literal-dynamic-world.js), // 字面量 import(shared/dynamic-world.js), // 模板字符串 ]); return { props: { value: literal template } }; }测试断言渲染Dynamic:LITERALDYNAMIC并且在最终 externals 清单中同时包含dynamic-esm-package与literal-dynamic-esm-packagetests/esm-externals.test.ts——说明动态导入涉及的包同样参与外部化判定而不仅仅是静态 import。别名pages/aliased.js——通过shared/pages-worlds.js别名导入共享模块页面渲染Aliased WorldWorldWorld。别名解析在next.config.mjs里由resolveAliasturbopack 侧与webpack.config.resolve.alias双重配置为preact/compat - react而shared/*别名则来自 tsconfig paths / vinext 内部别名映射两者共同验证了别名与外部化决策可以共存。九、MDX 所有权谁拥有一个模块pages/mdx-ownership.mdx 是 fixture 中较为独特的一页import { world } from ../lib/external-content.mdx; pMDX:{world}/p它从 lib/external-content.mdx 导入值。MDX 页面本身会被 vinext 转译pageExtensions包含mdx而这个被导入的 MDX 模块则被判定为外部化测试断言 externals 清单包含mdx-esm-package且页面渲染MDX:MDX_EXTERNAL。这里的所有权ownership指的是一个模块归属哪个环境、由哪条打包管线处理。MDX 文件的导入关系必须正确识别谁引用谁才能决定被引用模块的去留——这正是 vinext 在移植 Next.js 语义时需要额外处理的边界情况因为普通 Vite 构建并不存在页面所有权概念。十、外部化判定与源码实现Pages 专用 externals 插件fixture 行为背后是 vinext 源码中的 pages-node-externals 插件。tests/esm-externals.test.ts 直接对该插件的applyToEnvironment钩子做了单测揭示其启用条件expect(applyToEnvironment(environment(rsc, server))).toBe(true); expect(applyToEnvironment(environment(ssr, server))).toBe(true); expect(applyToEnvironment(environment(client, server))).toBe(false); expect(applyToEnvironment(environment(custom-client, client))).toBe(false);四条断言清晰刻画了外部化插件的边界rsc与ssr环境启用——服务端渲染相关环境需要做 Node 外部化名为client的服务端环境不启用——client 这个名字本身即被排除consumer 为client的环境一律不启用——外部化只属于服务端当pagesDir为null纯 App Router 项目或isEnabled为false时插件整体失效tests/esm-externals.test.ts。配套的第二个单测skips canonical ownership work in App-only buildstests/esm-externals.test.ts用 spy 验证在 App-only 构建中插件的transform与resolveId钩子不会调用fs.realpathSync与底层resolve——即所有权解析这类昂贵操作只服务于 Pages Router纯 App 项目直接跳过这解释了 fixture 中app-*包必须靠serverExternalPackages显式声明才能外部化的原因。综合测试与 fixture 配置可以整理出 vinext 的外部化决策矩阵包/配置外部化依据esm-package1、esm-package2Pages✅Pages 默认外部化移植自 Next.jsinvalid-esm-packagePages✅无法转译的包按外部化处理即使名字带 invalidapp-esm-package1/2、app-cjs-esm-packageApp✅serverExternalPackages显式声明dynamic-esm-package、literal-dynamic-esm-package✅动态导入同样参与判定mdx-esm-package✅MDX 所有权解析判定为外部geist❌可转译即默认打包explicit-esm-package❌transpilePackages强制转译optimized-esm-package❌optimizePackageImports导入优化shared-condition-package按环境解析RSC 走react-serverPages 走import十一、如何复现与验证fixture 完全自包含在仓库根目录下即可运行验证无需修改任何文件# 1. 安装依赖pnpm workspace本地包通过 file: 链接 pnpm install # 2. 运行 ESM externals 专项测试 pnpm vitest run tests/esm-externals.test.ts # 3. 或手动构建并启动生产服务器 pnpm --filter fixture-esm-externals build pnpm --filter fixture-esm-externals start运行测试后可以观察到的关键产物/现象生产构建输出位于tests/fixtures/esm-externals/dist其中dist/server/vinext-externals.json列出全部外部化包三个核心路由/static、/ssr、/ssg与两个 App 路由/server、/client均渲染Hello WorldWorldWorldPages 加模板字符串共 6 个World/aliased、/dynamic、/bundled-packages、/mdx-ownership、/app-shared、/pages-shared各按预期输出逐一对应测试断言。十二、小结tests/fixtures/esm-externals虽然是一个测试夹具却是一份浓缩的 ESM 外部化行为规范。它通过移植 Next.js 官方用例并叠加 vinext 特有的扩展场景一次性覆盖了Pages 与 App 两套路由体系的默认策略差异、serverExternalPackages/transpilePackages/optimizePackageImports三种显式干预手段、import/require/browser/react-server条件导出的环境感知解析、字面量与模板字符串动态导入、别名共存以及 MDX 所有权解析。而 esm-externals.test.ts 与 pages-node-externals 插件 则为这些行为提供了可重复的断言与源码级解释。对于任何希望深入理解外部化边界的 Vite/Next.js 兼容层开发者这份 fixture 都是一份现成且严谨的教材。赞分享后端Web框架SSR【免费下载链接】vinextVite plugin that reimplements the Next.js API surface — deploy anywhere项目地址https://gitcode.com/gh_mirrors/vi/vinext点击查看免费下载相关推荐使用 Notion 构建通用文档数据库Documentation DatabaseSchema 设计与知识捕获实践使用 Notion 构建通用文档数据库Documentation DatabaseSchema 设计与知识捕获实践 导读 本文基于 Codex 技能仓库后端Web框架SSRSingle主题部署指南从本地测试到生产环境Single主题部署指南从本地测试到生产环境 Single主题是一款简洁大气且支持夜间模式的Typecho博客主题本指南将帮助你完成从本地环境测试到生产服务后端Web框架SSR一键防撤回全解析开源工具 RevokeMsgPatcher 让消息永留一键防撤回全解析开源工具 RevokeMsgPatcher 让消息永留 领导撤回了一条重要通知你刚发出去的文件也被秒撤。开源防撤回工具 RevokeMsgP后端Web框架SSR上一篇SmartTabLayout的stl_clickable属性控制标签点击交互下一篇OpenAI Python库企业级AI集成的架构决策与技术实现深度解析创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑