# 如何在生产环境中保护 JavaScript 源代码

任何人都能读到你网站发出的 JavaScript；在生产环境中保护它的办法是：去掉 source map，把密钥留在服务端，再用 AfterPack 做混淆。

Source: https://www.afterpack.dev/zh/blog/protect-javascript-source-code

Published: 2026-09-28 · Author: Nikita Savchenko · Tags: security, guide, AI

要保护 JavaScript 源代码，就停止对外提供 source map，把密钥和访问校验留在服务端，并对必须发到浏览器的代码做混淆。每个访客都会下载你的 bundle，而压缩只会缩短局部变量名：字符串、API 路由、功能开关和定价规则依然清晰可读。AfterPack（afterpack.dev）是一款 JavaScript 混淆器，每次构建都会把你的 bundle 变成一个结构不同的程序。它的开源 CLI 可以通过 [`npx afterpack`](https://www.afterpack.dev/docs/quickstart) 免费在本地运行；Pro 版增加按区域的指令和防篡改。

为了演示实际情况，我做了一个小 demo 应用：四个 TypeScript 文件，包含按席位定价、优惠码、功能开关、免费版的导出次数限制和两个 API 调用。我在 2026-09-27 用 esbuild 0.28.1 打包（`--bundle --minify --sourcemap`）。下文所有内容都是这次构建的真实输出；用 DevTools、一条 `curl` 命令和免费的[安全扫描器](https://www.afterpack.dev/security-scanner)，大约五分钟就能在你自己的生产站点上做同样的检查。

## 用户能看到你的 JavaScript 代码吗？

能。页面运行的每一个脚本都会下载到访客的浏览器里，DevTools 会把它们全部列出来。

「查看网页源代码」（`Ctrl+U`，macOS 上是 `Cmd+Option+U`）只显示 HTML 和其中的 script 标签；DevTools 显示的是脚本本身。React、Next.js 和 Vue 应用也是同样的发布方式：你的组件以 JavaScript 文件的形式到达浏览器，任何人都能打开。

打开你的生产站点，不是 localhost。在 Chrome 里打开 DevTools（macOS 上按 `Cmd+Option+I`，其他系统按 `F12`），切到 **Sources** 标签页。你的 bundle 会列在你的域名下面。点开一个。如果还没有格式化，点底部的 `{}` 按钮。

然后用 `Cmd+Option+F`（Windows 和 Linux 上是 `Ctrl+Shift+F`）在所有已加载的文件里搜索。试试 `/api`、`/v1`、`internal`、`admin`、`free`、`pro`、`trial`、`promo`，以及任何你还没公布的功能名称。

## 压缩后的 JavaScript 会暴露什么？

除了局部变量名，什么都会暴露。我这个 demo 压缩后的完整 bundle 只有 995 字节：

```js
(()=>{var f={LAUNCH40:.4,PARTNER_FRIENDS:.5};function i(e,t,o){let r=e*1200;e>=25&&(r*=.9),t&&(r*=1-.2);let p=o?f[o.toUpperCase()]??0:0;return r*=1-p,Math.round(r)}function a(e,t){return e==="free"?t<3:!0}var l={newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0};function s(e,t){return e==="aiSummaries"?t==="pro"||t==="team":l[e]}var u="https://api.example.com";async function c(e){return(await fetch(`${u}/v2/billing/invoices`,{headers:{Authorization:`Bearer ${e}`}})).json()}async function m(e){return fetch(`${u}/internal/admin/trials/${e}/extend`,{method:"POST"})}var n={id:"u_1",plan:"free",exportsThisMonth:2};document.querySelector("#price").textContent=String(i(10,!0,"launch40")/100);document.querySelector("#export").addEventListener("click",()=>{if(!a(n.plan,n.exportsThisMonth)){alert("Upgrade to Pro to export more than 3 files a month");return}s("aiSummaries",n.plan)&&console.log("summaries on")});window.__debug={fetchInvoices:c,grantTrial:m};})();
//# sourceMappingURL=app.js.map
```

局部变量名没了，其它东西都还在，包括我故意挂到 `window` 上的名字，比如 `grantTrial`：

| 源码里有什么 | 压缩后还能读懂吗？ | 在 bundle 里的位置 |
| --- | --- | --- |
| 局部函数名和变量名 | 不能，已缩短 | `quote()` 变成了 `i()` |
| 优惠码及其折扣 | 能 | `LAUNCH40:.4,PARTNER_FRIENDS:.5` |
| 以美分计的单席位价格和 25 席起的批量折扣 | 能 | `e*1200`、`e>=25&&(r*=.9)` |
| 免费版每月三次的导出上限 | 能 | `e==="free"?t<3:!0` |
| 全部三个功能开关 | 能 | `newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0` |
| API 主机和路由，包括一条管理员路由 | 能 | `/v2/billing/invoices`、`/internal/admin/trials/${e}/extend` |
| 挂到 `window` 上的名字 | 能 | `window.__debug={fetchInvoices:c,grantTrial:m}` |
| 原始文件、类型和常量名 | 仅当 source map 公开时 | `sourceMappingURL` 那一行 |

压缩的作用是让文件变小，它从来就不是为了隐藏什么。

## 生产环境中的 source map 有安全风险吗？

有，只要 map 是公开的。source map 通常内嵌了你的原始文件，所以对外提供它就等于公开你的源码。

看每个 bundle 的最后一行。像 `//# sourceMappingURL=app.js.map` 这样的注释会告诉浏览器 map 文件在哪。有些配置改用 `SourceMap` 响应头来传递，所以 Network 标签页也要看一下。

然后自己去请求这个 map，检查响应体，而不是状态码。很多托管平台对任何未知路径都会返回 `200` 加上你的 `index.html`。如果下面这条命令输出 `3`，说明你正在对外提供 source map：

```bash
curl -s https://example.com/assets/app.js.map | jq .version
# 3   (my demo, served locally; an HTML page gives a jq parse error instead)
```

source map 通常带有一个 `sourcesContent` 数组，这是 [source map 格式](https://tc39.es/ecma426/)中的一个可选字段：也就是你的原始文件，一字不差。两条 `jq` 命令就能列出文件并打印其中一个：

```bash
curl -s https://example.com/assets/app.js.map | jq -r '.sources[]'
# ../src/pricing.ts
# ../src/flags.ts
# ../src/api.ts
# ../src/app.ts

curl -s https://example.com/assets/app.js.map | jq -r '.sourcesContent[0]' | head -8
# const SEAT_PRICE_CENTS = 1200;
# const ANNUAL_DISCOUNT = 0.2;
# const PROMO_CODES: Record<string, number> = {
#   LAUNCH40: 0.4,
#   PARTNER_FRIENDS: 0.5,
# };
#
# export function quote(seats: number, annual: boolean, promo?: string) {
```

这就是我的 TypeScript，连类型和常量名都在，直接来自我磁盘上的文件。DevTools 也在悄悄做同样的事：map 一加载，Sources 标签页就会在 bundle 旁边显示你原始的目录结构。让 [Claude Code 的 npm 包](https://www.afterpack.dev/blog/claude-code-source-leak)登上新闻的，也正是一份公开的 map，尽管它的 bundle 一直都是可读的。

## AI 能读懂压缩或混淆过的 JavaScript 吗？

能。现在的模型用一条提示词就能讲清楚一个压缩后的 bundle，连定价公式和暴露的管理员入口都不放过；AI 智能体同样能读混淆过的代码：在[之前的一次测试](https://www.afterpack.dev/blog/ai-deobfuscates-javascript)里，一个智能体分别用 10 分钟和 20 分钟，把两款混淆器官方公开的 demo 还原成了干净的源码。

2026-09-27，我把上面那个压缩后的 demo bundle（不带 map）交给 Claude Code 2.1.283 里的 Claude Sonnet 5，不开任何工具，用的是下面这段提示词，后面跟着放在 `js` 代码块里的 bundle：

```
This is a minified JavaScript file I downloaded from a website. Explain what it does in plain English: the pricing rules, the feature flags, the API endpoints it calls, and anything a competitor could copy or abuse. Be concise.
```

它还原出了完整的定价公式，一直算到 10 席年付套餐用 `LAUNCH40` 之后的价格：$57.60。它还找到了我埋进去、但没有提示过的那处问题：

> `window.__debug.grantTrial` / `fetchInvoices` expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call `__debug.grantTrial('<any user id>')` from the browser console.

（大意：`window.__debug.grantTrial` / `fetchInvoices` 把管理员延长试用和拉取发票的函数直接暴露在生产 JS 的全局作用域里。任何人都能打开 devtools，在浏览器控制台里调用 `__debug.grantTrial('<任意用户 id>')`。）

**完整回答，未经编辑**

```
## Pricing logic (`i()`)
- Base rate: **$12/seat/month** (`e*1200` in cents).
- **25+ seats** → extra 10% off (stacks).
- Annual billing (`t` truthy) → 20% off.
- Promo codes (case-insensitive): `LAUNCH40` = 40% off, `PARTNER_FRIENDS` = 50% off. Discounts stack multiplicatively (seat discount × annual × promo).
- Example in the code: 10 seats, annual, `launch40` → $120 → $96 (annual) → $57.60 (promo), shown as `57.6` on `#price`.

## Feature gating
- Export limit: free plan capped at **<3 exports/month**; every paid plan is unlimited (`a()`).
- Flags: `newCheckout: true`, `aiSummaries: false`, `teamSeatsBeta: true` (`l`).
- `aiSummaries` is force-enabled for `pro`/`team` plans regardless of the global flag — so it's effectively live for paying users even though the flag reads "off" (likely a soft-launch/A-B mechanism).

## API endpoints
- `GET /v2/billing/invoices` — bearer-token authenticated, returns invoice list.
- `POST /internal/admin/trials/{userId}/extend` — an **internal/admin** endpoint shipped in a public client bundle, with no auth header attached in this call at all.

## What a competitor/attacker could copy or abuse
- **Hardcoded discount codes** (`LAUNCH40`, `PARTNER_FRIENDS`) are fully visible client-side, including their exact percentages — trivial to discover and use even if not publicly advertised.
- **Exact free-tier limit** (3 exports/month) and pricing formula are exposed, handy for competitive benchmarking/undercutting.
- **`window.__debug.grantTrial` / `fetchInvoices`** expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call `__debug.grantTrial('<any user id>')` from the browser console. If the server doesn't independently re-verify admin privileges (and the client sends no auth token here), this is a **self-service trial/privilege-extension exploit** — the single biggest risk in this file.
- Naming leaks internal API structure (`/internal/admin/...`), which helps an attacker guess/probe other admin routes.
- `teamSeatsBeta` flag reveals an unreleased feature (competitive roadmap intel).

**Bottom line:** the real vulnerability isn't the pricing math, it's that debug/admin hooks (`grantTrial`) were left reachable from `window` in production — that should be stripped from the shipped bundle and the endpoint should require server-side admin auth regardless.
```

我对照源码核对过：里面每一条事实都是对的。唯一不严谨的表述是 `<3 exports/month`，它在后文又正确地写成了 3。这只用了一条提示词，任何对你产品好奇的人都能发出同样的一条。

## 如何扫描网站中暴露的源代码和 source map

把你的生产 URL 粘贴到 AfterPack 免费的[安全扫描器](https://www.afterpack.dev/security-scanner)里，不需要注册账号。它会在真实浏览器里渲染页面，收集页面加载的每一个脚本，并报告暴露的密钥、公开的 source map，以及每个 bundle 的保护程度。同一个扫描器也可以在终端里运行：

```bash
npx afterpack@latest audit https://example.com
```

[审计线上网站](https://www.afterpack.dev/docs/audit)列出了它检查的内容和用于 CI 的退出码。

## 如何保护 JavaScript 源代码

先去掉混淆保护不了的东西，再对其余部分做混淆：公开的 source map 或 bundle 里的密钥，无论 bundle 本身写成什么样，都会把代码交出去。

| 步骤 | 防的是什么 | 怎么做 |
| --- | --- | --- |
| 1. 不对外提供 source map | 原始文件、类型和注释被公开 | 使用隐藏式 map 并私下上传；从部署产物中删除 `.map` 文件 |
| 2. 把密钥和权限判定挪到服务端 | 密钥被读取，限额和管理员路由被绕过 | 在服务端校验；客户端的检查只用于 UI |
| 3. 对必须发到浏览器的逻辑做混淆 | 字符串被搜索，逻辑被读懂或被篡改 | `npx afterpack@latest dist/ --preset=hard`，或使用框架插件 |

> **2026 年 10 月更新。** 对整个 bundle 使用 `hard` 会让页面启动时间成倍增加（[加载代价](https://www.afterpack.dev/docs/presets#load-cost)）。整个应用 bundle 适合用 `light`；在 [Pro](https://www.afterpack.dev/docs/pro) 上，用[指令](https://www.afterpack.dev/docs/directives)把 `hard` 加在真正重要的代码上。

### 如何在生产环境中关闭 source map

不要对外提供。如果错误追踪需要它，就生成不带 `sourceMappingURL` 注释的 map：

| 打包工具或框架 | 设置 | 结果 |
| --- | --- | --- |
| esbuild | `--sourcemap=external` | 生成 `.map` 文件，bundle 中没有注释 |
| Vite | `build.sourcemap: "hidden"` | 生成 `.map` 文件，bundle 中没有注释 |
| webpack | `devtool: "hidden-source-map"` | 生成 `.map` 文件，bundle 中没有注释 |
| Rollup | `output.sourcemap: "hidden"` | 生成 `.map` 文件，bundle 中没有注释 |
| Next.js | `productionBrowserSourceMaps` | 默认关闭；确认没有人把它打开 |
| Create React App | `GENERATE_SOURCEMAP=false` | 完全不生成 map（默认是开启的） |

把 map 私下上传到你的错误追踪服务，然后从部署目录里删掉 `.map` 文件。一个「隐藏」的 map 只要还留在服务器上，离暴露就只差一次猜对 URL。[不公开 map 也能调试生产环境的调用栈](https://www.afterpack.dev/docs/best-practices#debug-production-traces-without-publishing-the-map)给出了一条边缘规则，专门应对 map 意外漏出去的情况。

### 把密钥和校验留在服务端

bundle 里的 API 密钥，无论你对文件做什么处理，运行页面的人都能读到。决策逻辑也一样：只在浏览器里检查的免费版限额只是一个建议，优惠码应该放在服务端查询，而我 demo 里那条管理员路由的安全程度，完全取决于服务端自己的校验。客户端的检查留给 UI 用，真正的限制要在服务端执行。

### 对必须发到浏览器的 JavaScript 做混淆

有些代码只能在浏览器里运行，比如实时价格计算器或者浏览器内的编辑器。对这类代码，在构建产物上运行 AfterPack：

```bash
npx afterpack@latest dist/ --preset=hard
```

不加 `--preset` 时默认是 `light`，它会编码字符串字面量。这能挡住对路由和功能开关的文本搜索，但不会让定价计算变得难以理解。对竞争对手想要的代码，比如定价和套餐限制，请用 `hard`；也可以先把一个函数粘贴到 [playground](https://www.afterpack.dev/playground) 里，看看每个级别会把它变成什么样。[预设](https://www.afterpack.dev/docs/presets)页列出了每个级别额外增加了什么。经过 `hard` 处理后，我的 demo 算出来的仍然是 $57.60（AfterPack 0.2.0，2026-09-29）；如果你更希望在常规构建里自动完成，也有[框架插件](https://www.afterpack.dev/docs/frameworks)可用。

形态和预设同样重要。一个固定不变的 bundle 就是一个靶子：模型或脚本只需对它做一次逆向工程，结果就能一直用到你的下一个版本。AfterPack 每次构建都会抽取一个新的种子，所以每个版本在结构上都是一个不同的程序，针对上周 bundle 写的反混淆脚本或做好标注的映射，套不到这周的 bundle 上。把 [`@afterpack/wasm`](https://www.afterpack.dev/docs/workers) 放进你自己的 Cloudflare Worker，代码甚至可以在每次请求时都换一个形态；[Workers 教程](https://www.afterpack.dev/blog/obfuscate-javascript-cloudflare-workers)有完整的配置，[发布公告](https://www.afterpack.dev/blog/announcing-afterpack-javascript-obfuscator)则展示了同一个函数混淆前后的样子。

先从一次搜索开始：在你的生产站点上打开 DevTools，搜索 `internal`。搜出来的东西，每个访客早就拿到了。

## 常见问题

### 能把 JavaScript 完全对用户隐藏吗？

不能。浏览器必须先下载代码才能运行它，所以任何人都能读到它收到的内容。你能做的是把密钥和权限判定留在服务端，停止公开 source map，并用混淆让发出去的代码难以读懂、代价高昂。

### 怎样防止别人复制或逆向工程我的 JavaScript？

下载拦不住；但你可以让复制品读起来代价高昂，而且很快过时。用 `--preset=hard` 做混淆，每次构建（或者在你的 Worker 里每次请求）都发出新的形态，并把权限判定留在服务端，这样复制来的客户端也做不了比原版更多的事。Pro 版的[防篡改](https://www.afterpack.dev/docs/tiers)会让被修改过的副本直接失效，而不是继续运行。

### 混淆能隐藏 API 密钥吗？

不能。bundle 里的密钥，不管有没有混淆，运行页面的人都能读到。把密钥留在服务端，从服务端去调用；[威胁模型](https://www.afterpack.dev/docs/threat-model)说明了混淆能保护什么、不能保护什么。

### 混淆后的 JavaScript 行为还一样吗？

应该一样；万一做不到，你会在构建时就知道：引擎无法混淆的文件会让构建失败并报出文件名，所以坏掉的 bundle 永远不会悄无声息地发出去。请针对[混淆后的构建](https://www.afterpack.dev/docs/best-practices#test-the-obfuscated-build)而不是原始构建运行集成测试：真正发出去的是它。

### AfterPack 免费吗？

本地引擎免费，所有预设都能用；CLI 和框架插件都是开源的。Pro 版增加按区域的指令、防篡改，以及按兆字节计费的云端构建；具体数字见[套餐](https://www.afterpack.dev/docs/tiers)。

— [Nikita](https://nikitaeverywhere.com)
