
以前做前端的时候我经常遇到一个尴尬情况页面需要一份 JSON 配置数据是静态的又不想为它专门启动一个接口。最简单的办法是fetch(./config.json)然后在一堆then和catch里处理状态。代码能跑但总觉得哪里不对。这份配置明明是项目的一部分它被哪些页面用了、什么时候加载、能不能提前缓存在代码里完全没有体现。另一条路是把 JSON 交给打包器像import config from ./config.json这样写。可这样它就会被打进 bundle改一个开关变量都要重新构建整个资源。直到浏览器开始原生支持 JSON 模块导入我才觉得这条路终于走通了。这个功能的价值不是让你少写几个 fetch而是让“一段静态数据”正式进入浏览器的模块系统。数据文件不再只是网络请求而是一等公民能被静态分析、被预加载、被缓存也能被明确地放进依赖图里。接下来我想从语法、实现、边界和真实场景几个角度把这个能力讲透。1. 先搞清楚这个能力解决的其实是“数据依赖”问题1.1 过去实现 JSON 读取常用的三条路径在原生 JSON 模块出现之前前端读取 JSON 的方式基本可以归为三类。第一类是运行时请求也就是fetch(./data.json)。这种方式最直接但也最“无脑”。数据的加载时机由代码决定浏览器不会提前知道你要这个文件。如果这个文件被多个模块依赖你还需要自行设计复用机制否则容易出现重复请求。第二类是构建工具转换。在 Webpack、Vite 等工具里import data from ./data.json早就被支持了。但这不是浏览器认识 JSON而是打包器把 JSON 翻译成了一个 JS 对象然后塞进了最终的 JavaScript bundle。它解决的只是“源码里写起来方便”并没有让浏览器真正理解 JSON 模块。第三类是 TypeScript 的resolveJsonModule。这本质上是编译层面的类型支持它解决的是类型检查问题不是运行时问题。最终运行到浏览器里的依然是打包器转换后的 JS 代码。这三条路径都有一个共同点JSON 文件本身没有成为“可被浏览器识别的模块”。1.2 为什么这个问题一直不容易被解开要理解浏览器原生 JSON 模块的重要性得先理解一个基础约束浏览器里的script typemodule默认只认 JavaScript。哪怕你写的是import config from ./config.json;如果浏览器没有 JSON 模块支持它也会按照 JS 模块去解析config.json。JSON 的语法和 JavaScript 并不一样比如 JSON 里的key键名这种写法虽然看起来像对象字面量但在模块解析流程里很容易触发语法错误或者更糟——被浏览器当成一段无意义的 JavaScript 执行。所以在模块系统里每增加一种新资源类型都需要浏览器明确支持“模块类型声明”。过去没有这种机制所以 JSON 文件只能靠其他方式绕过。这就是原生 JSON 模块出现的历史背景先让模块系统具备声明资源类型的能力再让浏览器原生解析 JSON。1.3 原生 JSON 模块带来的关键变化当浏览器原生支持 JSON 模块后最直观的变化是.json文件可以被直接import而且由浏览器直接完成解析。这意味着很多原本由构建工具承担的“JSON 转 JS”工作可以交还给浏览器。你的数据文件可以独立于 JavaScript bundle 存在它有自己的缓存、自己的 URL、自己的依赖关系。更重要的是数据依赖关系变得可见了。以往用 fetch代码里看不到这个页面依赖哪些配置现在你在源码里写import siteConfig from ./site-config.json静态分析工具、浏览器预加载器都能顺着这段代码发现这个依赖。它是模块图的一部分。所以这个功能的核心价值不在“快”而在“结构”。2. 从 assert 到 with再到默认导入语法演进与正确姿势2.1 为什么 JSON 文件需要被“点名”早期设计者面临一个很实际的问题浏览器支持了模块类型声明后import config from ./config.json该如何让浏览器知道这是 JSON 而不是 JS答案是在导入时显式声明资源类型。这就是最开始assert { type: json }的来源。写法长这样import config from ./config.json assert { type: json };用的词是assert中文意思是“断言”。2.2 import assertions 与 import attributes 的差别后来这个语法被调整过关键词从assert改成了withimport config from ./config.json with { type: json };你可能会觉得这只是在换名字但其实背后语义有变化。assert更多表达“我确信它是 JSON你要检查一下”with表达的则是“我要以 JSON 模块的方式加载它”。后者更像是给模块系统提供导入属性而不是在断言一件事。这也是为什么with后续被叫做 Import Attributes而不是 Import Assertions。方案演进过程中整个提案的定位从“验证”转向了“指令”。如果在非必要的情况下我建议不要继续用assert写法。它属于已经被淘汰的语法方向长期维护成本更高。2.3 默认导入与动态导入示例随着浏览器支持的推进JSON 模块也出现了一种更简洁的用法import config from ./config.json;这种写法听起来不够“华丽”但对开发者最友好。浏览器会根据文件扩展名和 MIME 类型直接按 JSON 模块解析。不过在大部分生产环境里我仍然建议先确认你熟用的浏览器内核是否支持这种默认推断。如果支持面不够就继续使用import config from ./config.json with { type: json };除了静态导入你也可以用动态导入const { default: config } await import(./config.json, { with: { type: json } }); console.log(config);需要注意动态导入返回的是一个模块命名空间对象真正的 JSON 内容被放在default属性里。2.4 关键语义默认导出、冻结对象、MIME 类型JSON 模块有一些和普通 JS 模块不同的语义这里特别值得留意。第一一个 JSON 模块只能提供一个默认导出。JSON 本身不是 JavaScript 的程序结构没有“命名空间”的概念所以规范约定把解析后的整个 JSON 对象作为默认导出。你不能写import { title } from ./config.json。第二默认导出的对象是冻结的。也就是说你不能在运行时修改这个对象的属性。import config from ./config.json; config.title new title; // TypeError: Cannot assign to read only property模块作用域默认是严格模式所以一旦尝试修改控制台会直接报错。这个设计是对的。配置数据如果可以被任意模块修改很容易出现多模块互相污染的状态问题。只读反而更安全。第三服务器返回的 MIME 类型必须是application/json。如果服务器把.json文件当成了text/plain浏览器依然可能拒绝加载或者继续按错误的模块类型解析。3. 落地实操一个最小页面跑通 JSON 模块3.1 目录结构与最小示例先不看复杂的工程化项目我们从一个最普通的静态页面开始。假设有这样一个目录json-modules-demo/ index.html main.mjs data/ config.jsonindex.html里只需要一个模块入口!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title浏览器原生 JSON 模块/title /head body h1 idtitle读取中.../h1 script typemodule src./main.mjs/script /body /htmldata/config.json内容{ title: 浏览器原生 JSON 模块示例, version: 1.2.0, darkMode: true, menu: [首页, 文档, 关于] }然后在main.mjs里直接导入import config from ./data/config.json; document.getElementById(title).textContent config.title; console.log(JSON module loaded:, config);如果你的浏览器支持 JSON 模块打开页面后标题会被替换成 JSON 里的内容控制台里能看到完整的配置对象。3.2 本地服务器、MIME 与 CORS 三个前置条件上面的示例虽然简单但有一个很重要的前提你不能直接双击index.html在file://协议下打开。ES Module 默认受 CORS 限制file://下模块加载会失败。你需要启动一个本地静态服务。如果你有 Python可以这样python3 -m http.server 8080然后访问http://localhost:8080/index.html这时候还有两个容易被忽略的点。第一静态服务要为.json文件返回正确的 MIME 类型。绝大多数现代静态服务器默认没问题但如果你用了某些精简服务器或者 CDN 配置不当就可能会返回text/plain。这时候打开 DevTools 的 Network 面板检查 JSON 请求的Content-Type是一个好习惯。第二如果你把 JSON 模块放在另一个域名下比如https://cdn.example.com/site-config.json那么服务端必须返回Access-Control-Allow-Origin头。模块导入的跨域策略比script严格它不会像普通脚本那样绕过 CORS。3.3 支持检测与兼容回退由于不同浏览器内核的支持进度不一样落地前最好先做一个快速检测。你可以用动态导入探一下当前浏览器的支持情况const probeUrl new URL(./data/probe.json, import.meta.url).href; try { const module await import(probeUrl, { with: { type: json } }); console.log(JSON modules supported, module.default); } catch (err) { console.warn(JSON modules not supported, err); }注意这里依赖import.meta.url所以代码必须放在模块文件里不能直接放在普通script里。如果浏览器不支持with语法这段动态导入可能会在解析阶段就报错但错误会被catch捕获。也就是说你至少能知道这个能力不可用然后决定是否需要走回退方案。回退方案也很直接继续用 fetch。let config; try { const module await import(./config.json, { with: { type: json } }); config module.default; } catch { const response await fetch(./config.json); config await response.json(); }这属于一种渐进增强策略支持原生 JSON 模块的环境用它不支持的回到请求处理。3.4 常见报错和排查链路我用过一段时间后整理过一条排查路径遇到问题时按顺序走基本都能找到方向。先从现象说起。如果页面加载后控制台报模块加载失败第一反应不要怀疑语法先看 Network 面板里.json文件的请求状态。如果是 404大概率是路径写错了检查import里的相对路径是否正确。如果状态码是 200但控制台报 unexpected token 或类似语法错误说明 JSON 文件被当成了 JavaScript 模块来解析。此时检查有没有写with { type: json }以及当前浏览器内核是否支持 JSON 模块。如果控制台提示 CORS 问题说明是跨域请求看服务端有没有返回正确的 CORS 头。如果加载成功了但运行时报Cannot assign to read only property说明你试图修改数据结构里的某个字段这不是加载问题是模块语义导致的限制。如果双击本地 HTML 文件打开后报错解决办法是先启动一个本地 HTTP 服务而不是继续在file://下折腾。这里最容易误判的情况是“浏览器没有报语法错误但数据始终没有显示”。这种情况十有八九是模块入口在file://下无法加载或者静态服务器没有正确处理.json的 MIME 类型。4. 场景判断它适合什么不适合什么4.1 适合的场景静态配置、语言包、测试数据原生 JSON 模块最适合的数据是那些“基本不随用户变化、运行时不需要修改、但希望独立缓存”的内容。最典型的场景是国际化语言包。以前语言包通常被打进 bundle或者用 fetch 按需拉取。现在你可以让每个语言文件成为一个独立 JSON 模块依赖关系由源码决定浏览器能自动处理加载。另一个场景是站点静态配置比如功能开关、公告内容、版本信息。如果这些数据是从后端生成的也可以由构建流程写成一个 JSON 文件然后被前端模块直接导入。这样变更配置时不需要提交新的 JavaScript bundle。测试代码中固定使用的 fixture 数据也很适合。测试环境里不需要复杂请求直接用 JSON 模块导入能省掉一套 mock 机制。4.2 不适合的场景动态数据、用户权限数据、JSONC但原生 JSON 模块并不是万能的。第一类不适合的场景是动态数据。比如实时变化的股票行情、用户行为统计、服务端实时计算的结果。这类数据通过接口获取仍然更合理因为你可以控制请求频率、超时、错误重试还能按用户维度定制。第二类不适合的场景是包含用户权限的数据。浏览器端能访问到的任何数据在安全性上都应该视为公开数据。JSON 模块也不例外。你不能因为加载方式更优雅就把需要鉴权的配置直接暴露在前端。第三类不适合的场景是 JSONC、JSON5 这类带有注释和尾逗号的配置文件。原生 JSON 模块要求文件必须是严格 JSON 格式。如果你维护的是带注释的开发配置那就需要构建时预处理或者干脆继续用打包器方案。4.3 和 fetch 的取舍对照很多人会问有了 JSON 模块fetch 是不是就没用了这两者其实解决的是不同问题。我做过一个对比对比维度fetch()原生 JSON 模块加载时机脚本执行后发起模块解析阶段声明依赖可见性代码里需要维护引用关系静态依赖图可见缓存策略需要手动设计模块缓存自动复用数据是否可修改默认可变默认冻结请求可控性支持超时、取消、自定义 header受模块加载机制约束响应格式支持任意格式仅支持严格 JSON适用场景动态接口、实时数据静态配置、确定性数据如果你要请求的是后端接口显然应该用 fetch。如果你要读取的是项目静态资源JSON 模块明显更贴合。4.4 从单页面到正式工程还差哪些拼图在小页面里跑通之后如果想把它放进正式工程有几个现实问题要先确认。第一是构建工具的配合。像 Vite、Webpack 这类打包器默认会把源码里的 JSON 导入当作“非浏览器模块”处理最后打进 bundle。如果你希望浏览器保留原生 JSON 模块行为就需要让构建链路把.json当作外部资源而不是转换为 JS 对象。不同工具配置方式不同这个必须在动手前查清楚。我的建议是先在一个没有打包器的原生页面里验证核心能力再评估你的构建工具是否能完整保留这种行为。第二是浏览器版本覆盖。如果项目需要支持老版本浏览器原生 JSON 模块可能不在支持范围内。这时候要么用with显式声明并做兼容回退要么继续使用打包器方案。第三是部署策略。JSON 模块有自己的缓存依赖 URL 作为模块标识。如果你的数据内容变化频繁建议在文件名里带上内容哈希避免浏览器拿到旧缓存。5. 我的判断不要神化它但要重视它5.1 模块系统的边界正在扩大站在更宏观的视角看原生 JSON 模块代表了一个明显的趋势浏览器的 ES Module 体系正在从“只能加载 JavaScript”扩展成“可以加载多种资源”。模块系统以后可能还会覆盖更多类型。这对前端架构的影响是深远的。过去我们习惯把所有资源交给打包器处理觉得浏览器天然不懂这些文件类型。但原生模块能力出现后一部分工作可以重新分配浏览器负责解析和加载构建工具负责组织和优化。两者不是替代关系而是重新划清了边界。这个边界划分对中小项目尤其有价值。一个不需要复杂构建链的静态站点可以只依赖浏览器原生能力把语言包、配置、数据文件作为模块直接管理。5.2 落地时要盯住三个风险点任何新能力都伴随风险JSON 模块也不例外。我建议你重点盯住三点。一是支持范围。不要默认所有浏览器都支持 JSON 模块。更好的做法是把它当作一个渐进增强能力在支持的环境里使用在不支持的环境里自动回退到 fetch 或打包器方案。二是数据冻结。多人协作时很容易有人习惯性地给导入的配置对象追加字段。一旦遇到冻结对象报错沟通成本会额外增加。所以在团队里使用前最好先说明这个语义差异。三是缓存粒度。模块缓存是独立的数据更新后如果没有新的 URL浏览器可能不会重新拉取。发布时尽量让数据文件的 URL 随内容变化否则容易出现“配置改了但前端看不到变化”的诡异问题。5.3 下一步你可以怎么做如果你想尝试这个能力我建议从一个小实验入手。找一个小型静态页面把其中一个固定 JSON 文件改成模块导入跑通后再尝试搭配 import map看看能否把数据源映射成更短、更稳定的标识符。最后再评估你的构建链这个 JSON 文件是继续留在 bundle 里还是独立出来交给浏览器。等这一步跑通你会真正理解原生 JSON 模块的体验差别它在很多时候并不表现为“更快”而是表现为“更清晰”。数据文件终于有了自己的位置不再只是代码里的一个字符串也不是网络请求里的一个临时响应而是模块系统里一个正式的成员。浏览器原生 JSON 模块不是让你改写所有项目而是给了一个新的选择。当你的数据是静态的、确定的、只读的时候可以试着让它回归到模块系统里来。