Vite library mode 里漏到 Chrome Extension 的 process.env

Chrome Extension 里遇到过一个很隐蔽的问题:content script 一加载就报 process is not defined,但同一个扩展里的 popup 一直正常。

麻烦点在于,两边都用了同一个依赖。只看依赖名,很容易以为「既然 popup 没报错,content script 也不应该报错」。真正的差异不在依赖,而在 Vite 的构建形态:popup 是普通 HTML app 构建,content script 当时为了产出 manifest 能直接加载的稳定脚本,借用了 build.lib + iife

这次现场版本是 Vite 8.0.14,触发依赖是 tippy.js@6.3.7。最后的处理方式不是继续给 library mode 打补丁,而是把 content script 改成普通 JS input,再用 output.format = 'iife' 固定成 classic script。这个项目没有动态 import,不需要拆出额外 JS;CSS 可以继续从 JS 入口正常 import,再由 Vite 抽成一个独立 CSS 产物写进 manifest。普通 input 已经能产出 manifest 直接加载的 content/index.js,运行时代码里没有 process.env,也不会留下顶层 import / export

这篇记录主要讲三个边界:为什么 popup 没事、为什么 library mode 会漏、为什么 content script 最后不该继续借用 library mode。build-only define 放在后面解释,它能处理运行时代码,但不适合作为常规配置。

先区分两类扩展入口

Chrome Extension 通常不是一个单入口网页。popup、options page、content script、service worker 都是入口,但它们被浏览器加载的方式不同。

popup 更像普通前端页面。manifest 里写的是:

{
  "action": {
    "default_popup": "popup/index.html"
  }
}

Vite 可以从 index.html 开始构建,分析里面的 <script type="module">、CSS、Vue SFC、静态资源,然后生成一套 HTML + JS + CSS。这个模式本质上是 app build,产物里 JS 文件名是否带 hash、CSS 是否拆出来、HTML 怎么引用资源,都可以由 Vite 处理。popup 这类入口的配置通常长这样:

// vite.config.ts
import { resolve } from 'node:path';
import { defineConfig } from 'vite';

export default defineConfig({
  root: resolve(__dirname, 'src/popup'),
  base: './',
  build: {
    outDir: resolve(__dirname, 'dist/popup'),
    emptyOutDir: true,
    sourcemap: true,
    rollupOptions: {
      input: resolve(__dirname, 'src/popup/index.html'),
      output: {
        entryFileNames: 'js/[name].js',
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]'
      }
    }
  }
});

Vite 8 项目如果已经切到 Rolldown,可以把 rollupOptions 换成 rolldownOptions,结构基本一样。这里的关键点不是选 Rollup 还是 Rolldown,而是入口是 HTML,Vite 能按页面应用处理它。

content script 不一样。manifest 里写的是:

{
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content/index.js"]
    }
  ]
}

Chrome 直接加载 content/index.jsChrome 的 manifest content_scripts 文档里这组入口只有 jscss 文件列表,没有给 JS 配 type: "module" 的位置。也就是说,manifest 声明的 content script 是按 classic script 注入的;源码里可以写 import / export,但最终交给 manifest 的 JS 里不应该再保留顶层 import / export

Vite 没有专门为 Chrome Extension 的 content-script / browser-script 准备的构建模式。content script 不是库,也没有 HTML 入口兜底;在这个场景里,应该把它当成非 library 的自定义 JS input 构建,再把输出约束成 manifest 能直接加载的 classic script。

这个项目最后选择普通 JS input:

// vite.config.ts
import { resolve } from 'node:path';
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist'),
    emptyOutDir: true,
    sourcemap: true,
    rolldownOptions: {
      input: resolve(__dirname, 'src/content/index.ts'),
      output: {
        format: 'iife',
        name: 'ExampleContentScript',
        entryFileNames: 'content/index.js'
      }
    }
  }
});

这条路语义上更准确:content script 不是给别人二次打包的库,而是浏览器最终要执行的脚本。普通 input 配上 iife 后,源码里的静态 import / export 会被打进一个 classic script 文件里,manifest 可以直接加载。

普通 input 不是和 library mode 平级比较的两个备选项。对 content script 来说,build.lib 会把最终浏览器脚本当成库产物,后续问题更多;普通 input 才是这类入口的基线方案。需要额外确认的是它生成的产物是否真的符合 manifest 加载规则:

  1. 最终 JS 必须是 classic script,不能留下顶层 import / export
  2. 构建后的 JS 入口应该收敛成一个稳定文件。Chrome 允许在 manifest 里声明多个 JS,但那更适合明确手写的多脚本;对 bundler 产物来说,拆出动态 import 或公共 chunk 会让加载关系变得不透明,也更容易漏配。
  3. 样式可以正常从 JS 入口 import,再用 Vite 抽出独立 CSS。对 content script 来说,目标不是把 CSS 做成另一个入口,而是让同一个入口依赖到的 CSS 汇总成一个稳定文件,然后写进 manifest 里 content_scriptscss 字段。
  4. 图片、字体等 asset URL 如果进入 JS 或 CSS,也要确认它们在扩展包里能被访问;需要暴露给页面访问时还要配 web_accessible_resources

Vite 这里需要显式配置 cssCodeSplit: false。非 library build 默认 cssCodeSplit: true,但 format: 'iife' 下,CSS 会被内联注入到 JS;关掉 CSS split 后,Vite 会把入口依赖到的 CSS 汇总成一个 CSS asset。这样产物仍然很简单:一个 content/index.js,一个 content/style.css

这些检查不成立时,优先在普通 input 下调整代码结构、约束输出或生成 manifest,不要回到 build.libbuild.lib + iife 会进入 Vite 的 library build 分支,后面那个 process.env 差异就来自这个分支。

Vite 不是只有 HTML app 和 library mode 两种用法。默认心智是 HTML app,显式库构建是 library mode;中间还有 rollupOptions.input / rolldownOptions.input 这种自定义 JS 入口。只是 Chrome Extension content script 这种“无 HTML、直接执行、还要固定文件名”的浏览器脚本没有专门构建模式,需要自己把输出格式和产物检查补上。

依赖为什么会写 process.env

tippy.js@6.3.7 的 ESM 发布产物里有开发态保护分支,简化后是这种形状:

if (process.env.NODE_ENV !== "production") {
  resetVisitedMessages();
}

以及:

if (process.env.NODE_ENV !== "production") {
  validateProps(partialProps, []);
}

这类代码在前端依赖里很常见。依赖作者并不是期待浏览器真的有 Node 的 process,而是期待构建工具在生产构建时把条件折叠掉。比如替换成:

if ("production" !== "production") {
  resetVisitedMessages();
}

后续压缩或 tree-shaking 就可以把这段开发态代码删掉。问题是,一旦构建工具没有替换,浏览器运行时就会直接访问 process.env.NODE_ENV。content script 跑在浏览器扩展上下文里,没有 Node 的 process 全局,所以会报 process is not defined

tippy 的 UMD 包为什么没这个问题

tippy.js@6.3.7 同时发布了几类产物:

{
  "main": "dist/tippy.cjs.js",
  "module": "dist/tippy.esm.js",
  "unpkg": "dist/tippy-bundle.umd.min.js"
}

用 Vite 写 import tippy from 'tippy.js' 时,bundler 会优先吃 module 指向的 ESM 产物,也就是 dist/tippy.esm.js。这份文件里有 process.env.NODE_ENV,需要最终项目的构建工具替换。

这个判断可以直接从 tippy 的 GitHub 源码对上。v6.3.7package.json 同时声明了 mainmoduleunpkg 三个入口:CJS 指向 dist/tippy.cjs.js,ESM 指向 dist/tippy.esm.js,CDN 默认入口指向 dist/tippy-bundle.umd.min.js

再看它的 Rollup 配置。关键形状是这样,下面不是完整配置,只保留和 process.env 有关的部分:

const replaceEnvProduction = replace({
  'process.env.NODE_ENV': JSON.stringify('production')
});

const pluginConfigs = {
  umdBundleMin: [
    replaceEnvProduction,
    minify,
    css
  ],
  bundle: [
    resolve,
    json,
    css
  ]
};

// dist/tippy-bundle.umd.min.js 使用 umdBundleMin
// dist/tippy.esm.js 和 dist/tippy.cjs.js 使用 bundle

这段配置说明,dist/tippy-bundle.umd.min.js 不是因为 UMD 格式天然避开 process.env,而是 tippy 在发布 UMD min 产物时专门做了生产环境替换和压缩。对应的源码位置是:生产替换插件定义在 #L19-L24,UMD min 插件链在 #L53-L59dist/tippy-bundle.umd.min.js 输出配置在 #L105-L112,ESM / CJS 输出配置在 #L114-L145

直接浏览器执行的 UMD 包不一样。dist/tippy-bundle.umd.min.js 是给 CDN / <script> 这类场景用的浏览器产物,包发布时已经把环境判断处理掉了,本地检查不到 process.env,也没有 NODE_ENV 字符串。开发库时要特别注意这个边界:ESM / CJS 产物可以把部分环境判断留给下游构建工具,但 UMD / IIFE 这类给浏览器直接执行的产物已经是最终运行时代码。生产压缩版应该在库自己的构建链路里把 process.env.NODE_ENV 替换成 production;如果保留开发版 UMD,也要替换成 development 或保证浏览器包里没有裸 process.env

因此,UMD 能跑不代表 ESM 入口不需要处理。也可以强行把 Vite 的依赖入口 alias 到 UMD 文件,但这通常不是最干净的修复:它绕过了包的标准 ESM 入口、类型和依赖优化路径,也没有解决下一个依赖继续带 process.env 的问题。这里真正要修的是 content script 的构建形态。

Vite 非 library build 和 library mode 的差异

Vite 的 Env and Mode 文档里强调,import.meta.env 这类常量在构建时会被静态替换,用来让生产构建可以 tree-shaking。process.env.NODE_ENV 的差异也发生在 Vite 的 define 阶段,但分界线不是「HTML app build vs 普通 JS input」,而是「非 library build vs library build」。

Vite 8.0.14definePlugin 源码看,关键判断在这里:

// vite@8.0.14 packages/vite/src/node/plugins/define.ts
const isBuild = config.command === 'build';
const isBuildLib = isBuild && config.build.lib;
const processEnv = {};

if (!isBuildLib) {
  const nodeEnv = process.env.NODE_ENV || config.mode;
  Object.assign(processEnv, {
    'process.env': `{}`,
    'global.process.env': `{}`,
    'globalThis.process.env': `{}`,
    'process.env.NODE_ENV': JSON.stringify(nodeEnv),
    'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
    'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv)
  });
}

processEnv 这组替换只在 !isBuildLib 时注入。popup 的 HTML app 构建属于非 library build,所以运行时代码里没有留下 process.env。content script 如果走 build.lib,这组默认替换就会被跳过。

普通 JS input 也在 !isBuildLib 这一边。也就是说,它不是靠 HTML app 入口才获得替换,而是因为它没有进入 library build 分支。当前 content script 改成 rolldownOptions.input + output.format = 'iife' 后,本质上仍是非 library build,所以它和 popup 一样不会把 process.env.NODE_ENV 留到运行时代码里。真正需要额外解释的是 build.lib:只有它被 Vite 当成库产物处理,默认保留 process.env.*

Vite 这么做是有理由的。Vite Library Mode 文档写得很直白:library mode 下 import.meta.env.* 会被静态替换,但 process.env.* 不会。普通说法就是:库还不是最终产物,后面真正引入这个库的人,可能还要在自己的项目里决定 process.env.NODE_ENV 到底是什么。如果库自己先把它写死,后面的人就没法再调整了。确实不想保留时,可以用 define: { 'process.env.NODE_ENV': '"production"' } 显式替换。

这个取舍对真正的 library 是合理的。比如一个 npm 包后面还会被 Node、Webpack、Vite、测试框架或服务端渲染环境引入,打这个库的人不应该提前替最终使用方决定 process.env.NODE_ENV。但 content script 不是给别人二次打包的库,它已经是最终浏览器运行时代码,这就是它不该继续套 library mode 的原因。

最小复现

同一份入口代码分别走几种 Vite 构建方式,结果会很清楚。入口只做一件事:导入 tippy.js,然后初始化一个 tooltip。

// src/entry.ts
import tippy from 'tippy.js';

const button = document.createElement('button');
button.textContent = 'hover';
document.body.append(button);

tippy(button, { content: 'tip' });

同一台机器上,用 Vite 8.0.14tippy.js@6.3.7 构建后,结果如下:

构建方式 运行时 .js / .html 里的 process.env .map 里的 process.env 说明
HTML app 0 15 对应 popup 这类页面入口,运行时代码干净
普通 input + IIFE 0 15 对应最终采用的 content script 配置
build.lib + iife 12 15 library mode 默认会保留运行时 process.env
build.lib + iife 加 build-only define 0 15 能修运行时代码,但只是给 library mode 打补丁

这个复现拆开的不是「页面入口」和「脚本入口」的差异,而是「非 library build」和「library build」的差异。HTML app 和普通 input 都属于非 library build,所以运行时代码会被替换;build.lib 属于 library build,所以默认保留 process.env.*。两边不是浏览器标签页差异,也不是依赖版本差异。

当前项目改成普通 input + IIFE 后,Jenkins content script 的实际产物是:

{
  "runtimeScriptFiles": ["content/index.js"],
  "processEnvInRuntime": 0,
  "topLevelImportToken": false,
  "topLevelExportToken": false,
  "mapHasSourcesContent": true,
  "processEnvInMap": 15
}

这就是最终方案需要的状态:浏览器执行的 runtime 干净,source map 仍然保留源码内容,排查问题时不需要牺牲调试体验。

sourcemapExcludeSources 不是修复方案,只是排查脚本的噪音处理。Rolldown 文档里这个选项的作用是省略 source map 里的 sourcesContent,让 .map 不再内嵌原始源码;浏览器真正执行的 .js 不会因此改变。当前问题应该用 runtime 文件扫描判断,不应该为了绕过 rg dist process.env 这类检查噪音关掉 source map 源码内容。

始终把 process.env.NODE_ENV 写成 production 也不可取

另一个常见误判是直接在顶层写 define

export default defineConfig({
  define: {
    'process.env.NODE_ENV': JSON.stringify('production')
  }
});

这会修掉 vite build 的产物,但副作用也很明确:开发环境、测试环境和生产构建环境会被混在一起。

Vite Shared Options 的 define 文档说明,define 里的条目在开发态会被定义为全局变量,在构建时会被静态替换。Vitest、Vue 插件、依赖预处理和测试转换通常也会复用同一份 Vite config。把 process.env.NODE_ENV 始终写成 "production" 后,dev server 和测试里的源码、依赖也会按生产分支运行。

这不是抽象风险。实际项目里加了一个始终生效的 define 复现配置后,popup 的 Vue 组件测试出现了 5 个测试文件失败、9 条断言失败。失败集中在 wrapper.emitted(...) 读不到事件,比如 copy-console-write-scriptread-current-tableclickupdate:modelValueupdate:settings

根因可以从 Vue 和 Vue Test Utils 的源码对上:

// @vue/runtime-core
function emit(instance, event, ...rawArgs) {
  // ...
  if (!!(process.env.NODE_ENV !== "production") || __VUE_PROD_DEVTOOLS__) {
    devtoolsComponentEmit(instance, event, args);
  }
}

Vue Test Utils 本来就是开发 / 测试环境里的工具,它会挂 devtools hook 来记录组件自定义事件:

// @vue/test-utils
function captureDevtoolsVueComponentEmitEvent(eventType, payload) {
  if (eventType === "component:emit") {
    const [_, componentVM, event, eventArgs] = payload;
    recordEvent(componentVM, event, eventArgs);
  }
}

当测试环境里的 process.env.NODE_ENV !== "production" 被折叠成生产分支后,Vue 不再发 component:emit,Vue Test Utils 就记不到这些事件。测试失败不是偶然,也不是测试写得脆弱,而是测试运行时被强行改成了生产态。

真正的库产物如果确实需要在 build 里替换,define 至少要放在 command === 'build' 分支里,只修最终产物,不改开发服务和测试转换:

export default defineConfig(({ command }) => ({
  ...(command === 'build'
    ? {
        define: {
          'process.env.NODE_ENV': JSON.stringify('production')
        }
      }
    : {})
}));

这次不再需要这条补丁,因为 content script 已经回到普通 input。

最终修复方式

content script 这类最终浏览器脚本入口,在当前约束下直接用普通 input + IIFE。manifest 同时加载一个 JS 和一个 CSS:

{
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "css": ["content/style.css"],
      "js": ["content/index.js"]
    }
  ]
}
// vite.config.ts
import { resolve } from 'node:path';
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    cssCodeSplit: false,
    sourcemap: true,
    rolldownOptions: {
      input: resolve(__dirname, 'src/content/index.ts'),
      output: {
        format: 'iife',
        name: 'ExampleContentScript',
        entryFileNames: 'content/index.js',
        assetFileNames: 'content/[name][extname]'
      }
    }
  }
});

这段配置有两个关键点。

第一,build.lib 没了,define 也没了,process.env 不会再漏进浏览器真正执行的脚本。

第二,cssCodeSplit: false 会让 Vite 把入口依赖到的 CSS 汇总成一个 CSS 文件。content script 入口可以像普通前端代码一样 import CSS:

import 'tippy.js/dist/tippy.css';
import './style.css';

这更接近 Webpack 里 MiniCssExtractPlugin 的心智:源码按模块依赖写,构建时把 CSS 从 JS 依赖图里抽成文件。区别是这里不需要额外插件,只要用 Vite 自带的 CSS 处理能力,并把 CSS asset 文件名固定住。

如果 CSS 里以后出现 url(...) 引用的图片、字体,也不需要自己写解析逻辑。Vite 的静态资源处理文档里这类资源就是官方能力:CSS 里的 url() 会按静态资源处理,常见图片、媒体和字体类型会自动识别、进入构建资源图,并在产物里改成正确 URL。需要额外考虑的是 Chrome Extension 的访问权限:如果最终 CSS 会加载扩展包里的图片或字体,这些资源要按 Chrome 的 web accessible resources 规则 暴露给目标页面。

当前没有图片和字体,所以 assetFileNames: 'content/[name][extname]' 已经够用。等 CSS 真的引入额外 asset,再把命名策略细分出来,例如 CSS 继续固定成 content/style.css,图片和字体放到 content/assets/[name]-[hash][extname]。如果希望这些资源稳定落成实体文件,而不是小文件被 Vite 按 assetsInlineLimit 内联成 base64,可以同时配置 assetsInlineLimit: 0。最后再在 manifest 里补 web_accessible_resources

assetFileNames: (assetInfo) =>
  assetInfo.name?.endsWith('.css') ? 'content/[name][extname]' : 'content/assets/[name]-[hash][extname]'
{
  "web_accessible_resources": [
    {
      "resources": ["content/assets/*"],
      "matches": ["https://example.com/*"]
    }
  ]
}

它的检查重点是产物结果:

  1. manifest 指向构建出的 content/index.js
  2. manifest 指向构建出的 content/style.css
  3. JS runtime 里没有 process.env
  4. content script JS 里没有顶层 import / export
  5. 没有额外 JS chunk 需要 manifest 或运行时再加载。
  6. source map 可以继续保留 sourcesContent,方便调试。

最后补一个产物检查。检查脚本只扫 Chrome 真实会加载的 .js.html,不把 .map 当成运行时代码。

// scripts/assert-no-node-runtime-globals.mjs
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));
const forbiddenPatterns = [{ label: 'process.env', pattern: /\bprocess\.env\b/ }];

const collectRuntimeFiles = async (dir) => {
  const entries = await readdir(dir, { withFileTypes: true });
  const files = await Promise.all(
    entries.map(async (entry) => {
      const file = join(dir, entry.name);

      if (entry.isDirectory()) {
        return collectRuntimeFiles(file);
      }

      return entry.isFile() && /\.(?:js|html)$/i.test(entry.name) ? [file] : [];
    })
  );

  return files.flat();
};

const files = await collectRuntimeFiles(distDir);
const violations = [];

for (const file of files) {
  const content = await readFile(file, 'utf8');

  for (const forbidden of forbiddenPatterns) {
    if (forbidden.pattern.test(content)) {
      violations.push(`${file}: contains ${forbidden.label}`);
    }
  }
}

if (violations.length > 0) {
  throw new Error(`Chrome extension runtime bundle contains Node globals:\n${violations.join('\n')}`);
}

package.json 里把它接到 check 后面:

{
  "scripts": {
    "check": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run assert:dist",
    "assert:dist": "node scripts/assert-no-node-runtime-globals.mjs"
  }
}

Chrome Extension 的判断口径

后面再给扩展加依赖时,可以按这个顺序判断:

  1. 先区分入口形态:popup / options page 这类 HTML 页面,和 content script / background 这类脚本入口,不一定走同一条 Vite 构建链路。
  2. content script 如果只是最终执行脚本,直接走普通 input + IIFE;不要用 build.lib 解决固定文件名问题。只有在真的发布 npm / browser library 时才讨论 library mode 的 env 替换规则。
  3. 只要是 Chrome 会直接加载的 .js.html,产物里就不应该出现 process.envBufferglobal 这类 Node 全局访问。
  4. content script 的构建产物尽量保持唯一:一个入口 JS,一个入口 CSS。Chrome 允许多个 JS / CSS 文件,但 bundler 产物拆太散会增加 manifest 维护成本和漏配风险。
  5. CSS 不需要做成单独入口。源码里正常 import CSS,再用 cssCodeSplit: false 汇总成一个 CSS asset,manifest 指向这个最终产物。
  6. 依赖源码或 source map 里出现 process.env 不一定有问题,真正要看运行时 bundle。
  7. 不要让 dev server 和 Vitest 始终看到 process.env.NODE_ENV = "production"。需要替换时放到 build-only 分支,避免开发和测试环境被改成生产态。
  8. 把运行时产物扫描接进 check,不要靠人工 rg dist 记忆。

这类问题最容易误判的地方,是把「某个入口没报错」当成「这个依赖没问题」。Chrome Extension 往往有多个入口,每个入口的打包方式、执行上下文和加载时机都不一样。最终判断要落到产物:浏览器真正执行的那几个文件里,有没有留下 Node 环境的假设。