那张对照表就写在页面最上面,一个字也没错,浏览器却说这些名字它一个都不认识
本文目录
- 浏览器为什么不认识lodash这样的名字?
- 没有表的时候,两个引擎说的话完全不一样
- 这张表长什么样
- 一份表写坏了,是整站瘫痪还是少一个功能?
- 第一档:JSON语法错,整张表作废
- 第二档:结构类型错,同样整张表作废
- 第三档:单条值不是合法地址,只作废那一条,而且只吐一个warning
- 把三档并排放,规律就出来了
- 顺手把值的几种边界也测了
- 那张表到底得写在哪一行之前?
- 单变量隔离:到底是什么关上了这扇窗
- 为什么这条特别难防
- 顺序调过来就好了
- 为什么它不能像别的配置那样放进单独文件?
- 写了会怎样
- 只能内联,就意味着它撞上了内联脚本的禁令
- 唯一的出路是给它加nonce
- 两个团队各写一张表,会合并还是会打架?
- 实测:两张表在两个引擎上是两种命运
- 最阴的地方在于同名那条两边表现一样
- 一个反直觉的方向对比
- 现实里该怎么办
- 同一个库怎么会在一个页面里跑两遍?
- 判据是解析之后的地址,不是你写的名字
- 为什么这种事总在合并配置的时候发生
- scopes是另一条制造分身的路
- 给模块加的那行预载,为什么反而多下了一份?
- href里写的是地址,不是名字
- 地址差一个参数,就白下载一份
- 官方文档的承诺和这条实测并不矛盾
- 表里那把哈希锁,管的范围比你想的宽
- 还有几处它管不到的地方
- 真实站点上到底有多少人在用这张表?
- 三个数字
- 唯一那张表长这样
- 把窗口位置量化一下
- 这个1% 该怎么读
- 上线前怎么把这几种静默失效一次查出来?
- 第一步:确认那张表到底还在不在
- 第二步:数一数标签的先后顺序
- 第三步:把两个引擎都跑一遍,别只跑一个
- 第四步:把内容安全策略那条也过一遍
- 第五步:数一数网络面板里有没有重复的文件名
- 第六步:给这张表加一条监控
- 压成一张清单
- 常见问题解答
- import map到底该放在head还是body?
- 为什么我在本地一切正常,上线就有一半用户报功能不见了?
- 能不能把这张表放到单独的JSON文件里,用src引进来?
- 表里某个名字写错了地址,会不会拖垮其他名字?
- 模块预载的href能写表里的名字吗?
- 同一个库怎么会有两个实例?表里明明只写了一次。
- 表里的integrity键和写在标签上的integrity属性是一回事吗?
- 把importmap标签从DOM里删掉或者改掉,能实现热更新吗?
- 模块worker里能用表里的名字吗?
- 现在到底该不该用它?
- 权威参考资料
摘要:页面里那张把
lodash这类名字翻译成真实地址的表,写对了也可能一个名字都翻不出来。保哥搭了一台真实HTTPS双主机名实验台,把50种写法在两个浏览器引擎上各跑一遍,又扫了94个能抓到首页的线上站点,得到三个反直觉的结论:这张表的失效半径分三档,分界线是错误落在JSON的哪一层;它的有效窗口会被上面一行模块预载标签关掉,而且只在其中一个引擎上关;它必须内联写在HTML里,于是和禁止内联脚本的安全策略天然打架。三种作废方式在页面上长得一模一样,报错文案还都在建议你去改相对路径。
先说一个现象。你在页面里写了这么一行:
<script type="module">
import { debounce } from "lodash-es";
</script>浏览器打开,控制台报错,功能没了。你检查网络面板,那个文件明明躺在CDN上,手动敲地址能打开。你把地址复制到import后面,一切正常。
于是你得出结论:这个名字不能用,改成完整地址就好了。
这个结论对了一半,也正是麻烦的开始。因为让那个名字能用的东西,是页面上另一个你可能压根没看的标签,它叫import map。而它不生效的原因,有至少五种,其中三种在页面上长得完全一样。
浏览器为什么不认识lodash这样的名字?
这件事得从模块的地址说起。
import后面那个字符串,规范里叫模块说明符。浏览器拿到它以后要做的第一件事,是把它变成一个能发请求的地址。而浏览器认的路只有三条:完整的绝对地址、以斜杠开头的根路径、以点开头的相对路径。
只有这三条。别的一律不认。
lodash-es这种既不带协议也不带斜杠的写法,规范里叫裸说明符。它在服务端的运行时里能用,因为那边有一套约定俗成的目录查找规则,可以顺着依赖目录一层层往上翻。
浏览器没有这套目录,也没打算有。MDN的模块指南把这层意思说得很直白:To use bare names on a browser you need an import map,而且明确指出,如果一个说明符解析不到具体位置,JavaScript会直接抛出TypeError。
顺带划一下边界:搜索引擎那边对模块脚本的处理是另一条线,JS渲染的页面抓不到时该查什么讲的是渲染阶段,本文讲的是渲染之前那一步——连地址都还没算出来的时候。
没有表的时候,两个引擎说的话完全不一样
这一点在实验台上很值得看一眼。同样是没有import map、同样import "lib",两个引擎给的报错文案是这样的:
| 引擎 | 控制台原文 |
|---|---|
| 引擎A | Failed to resolve module specifier "lib". Relative references must start with either "/", "./", or "../". |
| 引擎B | The specifier "lib" was a bare specifier, but was not remapped to anything. |
看出区别了吗。引擎B直接告诉你:这是个裸名字,没有被重映射。它把你往import map这个方向推。
引擎A那句话里,一个字都没提import map。它只讲了相对路径必须怎么开头。一个正在排查的人读完这句,最自然的反应就是去把lodash-es改成./node_modules/lodash-es/lodash.js,然后发现路径不对,再翻构建配置,再翻发布流程。
而真正的问题可能只是那张表被上面一行标签挡住了,或者被安全策略拦掉了,或者根本就没通过JSON解析。
同一个故障,两个引擎给的第一句提示,一个指向根因,一个指向岔路。而大多数人排查前端问题时手边开的是哪一个,基本是习惯决定的。
这张表长什么样
它就是一段内联的JSON,写在<script type="importmap">里面:
<script type="importmap">
{
"imports": {
"lodash-es": "https://cdn.example.com/lodash-es@4.17.21/lodash.js",
"utils/": "https://cdn.example.com/utils@1.2.0/"
}
}
</script>结构简单得不像会出事的东西。左边是你在代码里写的名字,右边是浏览器实际去请求的地址。带斜杠结尾的键是前缀映射,utils/format.js会被拼成后面那个目录加上format.js。
问题不在语法。问题在于这张表被塞进了一个它并不适合待的位置:它是配置,却只能写成HTML;它服务于整个应用,却受制于页面上其他标签的先后顺序;它需要多个团队协作维护,规范上却长期只允许一份。
接下来这几节,全部是围着这三件事转的实测。
一份表写坏了,是整站瘫痪还是少一个功能?
这是我最想搞清楚的一件事。因为它直接决定排查时该往哪个方向看。
实验台里我准备了三种写坏的方式,都很像真人会犯的错,然后看剩下的名字还能不能用。
第一档:JSON语法错,整张表作废
少一个右花括号,就这么简单。两个引擎的反应完全一致,都在控制台抛出一个SyntaxError:
Failed to parse import map: invalid JSON紧接着是第二条报错,说lib解析不出来。而这张表里原本还有另外几个名字,此刻也全都解析不出来了。
这一档的好处是响亮。它是error级别,会触发window.onerror,前端监控只要挂了全局错误采集就能收到。坏处是范围最大:一个逗号,所有裸名字同时阵亡。
第二档:结构类型错,同样整张表作废
把imports写成数组而不是对象,JSON本身是合法的,但结构不对。两个引擎依然是整张表丢掉:
| 引擎 | 控制台原文 |
|---|---|
| 引擎A | Failed to parse import map: "imports" top-level key must be a JSON object. |
| 引擎B | the imports top-level key needs to be a JSON object |
这一档依然是error,依然能被监控抓到。到这里为止,事情都还算讲道理。
第三档:单条值不是合法地址,只作废那一条,而且只吐一个warning
这一档是真正会咬人的。
表里有两个名字,其中一个的值写成了不能解析成地址的东西,另一个完全正确。实测结果:正确的那个照常工作,页面表面上一切正常,控制台只留下一条warning。
| 引擎 | 控制台原文 | 级别 |
|---|---|---|
| 引擎A | Ignored an import map value of "broken": Bare specifier: not a url at all | warning |
| 引擎B | Address "not a url at all" was invalid. | warning |
warning不触发window.onerror,不进任何默认的前端错误采集。它只是静静地待在控制台里,等一个愿意主动去看的人。
而那条坏掉的映射并没有消失,它变成了一个null。等哪天真有代码去import那个名字,报错是这样的:
| 引擎 | 控制台原文 |
|---|---|
| 引擎A | "broken" matches with "broken" but is blocked by a null value |
| 引擎B | Resolution of specifier "broken" was blocked by a null entry. |
请注意这句报错的措辞。它说的是被一个null条目挡住了,一个字都没提你那行地址当初写错了。上线那天的warning早就随着标签页关掉了,此刻站在控制台前的人,看到的是一个来路不明的null。
把三档并排放,规律就出来了
| 写错的位置 | 失效范围 | 控制台级别 | 监控能不能收到 |
|---|---|---|---|
| JSON语法 | 整张表 | error | 能 |
| 顶层键的类型 | 整张表 | error | 能 |
| 单条映射的值 | 只有那一条 | warning | 不能 |
MDN在这张表的文档里给了一句总结:Browsers generate console warnings for other cases where the import map JSON does not conform to the import map schema。翻译过来就是,只要JSON本身能解析,剩下不合规范的部分一律降级成warning。
这条规律有一个不太舒服的推论:错得越离谱,你越早知道;错得越像样,你越晚知道。把地址写成一坨乱码,那是语法层面的事故,当场炸开;把地址写成一个少了协议头的域名,那是值层面的瑕疵,安安静静躺进表里,等一个季度以后某次灰度发布把那个名字用上,才第一次露面。
顺手把值的几种边界也测了
- 值写成相对路径,比如
./assets/lib.js:可用,按写这张表的那个文档的地址来解析。 - 值写成另一个裸名字,指望它再翻一次:不可用。映射不递归,一次就到底。
- 值显式写成
null:等价于第三档那种作废,报的也是blocked by a null entry。 - 同一个键在JSON里写了两遍:后写的赢。这是JSON解析本身的行为,两个引擎一致。请记住这个方向,下面还会用到它,而且会翻转。
那张表到底得写在哪一行之前?
MDN那句话说得很清楚:这张表must be declared and processed before any <script> elements that import modules using specifiers declared in the map。
先声明,再使用。听起来是废话,实际做起来全是坑,因为“使用”这个动作的判定边界,比字面意思宽得多。
单变量隔离:到底是什么关上了这扇窗
我在实验台上一次只改一个变量,看看在import map前面放什么东西会让它失效。做单因素隔离这件事保哥在SEO实验设计那篇里啰嗦过,这里是同一套路子,只是把测量对象换成了浏览器行为。
结果如下,全部在同一台服务器、同一份HTML骨架下跑出来:
| 放在import map前面的东西 | 引擎A | 引擎B |
|---|---|---|
普通样式表<link rel="stylesheet"> | 正常 | 正常 |
普通脚本<script src> | 正常 | 正常 |
<link rel="preload" as="script"> | 正常 | 正常 |
<link rel="prefetch"> | 正常 | 正常 |
<link rel="modulepreload">,同一个地址 | 正常 | 整张表作废 |
<link rel="modulepreload">,完全无关的地址 | 正常 | 整张表作废 |
<script type="module"> | 整张表作废 | 整张表作废 |
引擎B在那几组失败里给出的原话是:
Import maps are not allowed after a module load or preload has started.这一句把边界划得很清楚:关窗的不是“模块开始加载”,而是“模块开始加载或者预载”。而预载的是哪个地址完全无所谓,哪怕预载的那个模块跟表里所有名字都不沾边,窗照样关。
为什么这条特别难防
因为把<link rel="modulepreload">尽量往前放,是所有性能优化材料里的标准建议。它的全部意义就在于早一点开始下载,放得越靠前收益越大。谁会想到这个动作顺带把翻译表关在了门外。
更要命的是它只在一个引擎上发生。你在本地验过、在测试环境验过、同事也验过,一切正常,因为大家用的是同一个引擎。上线之后另一半用户的页面上,所有裸名字同时解析失败。这种“我在浏览器上验过了”的失效,正是前端与SEO协作那套动作里要求双引擎复核的原因。
顺序调过来就好了
补测了两组:把import map放在前面、modulepreload放在后面。两个引擎全部正常,包括预载地址跟表里对不上的那一组。
所以正解只有一句话:这张表必须是文档里第一个跟模块沾边的标签,比任何模块脚本、任何模块预载都早。不是“放在head里”就够了,是“放在head里那一堆预载的前面”。
顺带一提,这条约束和HTTPS那边的HSTS preload提交流程里的preload完全是两回事,只是撞了名字,别在搜资料的时候搜串了。
为什么它不能像别的配置那样放进单独文件?
这是很多人第一次用它就会问的问题。既然是配置,为什么不能写成一个JSON文件,让src指过去,顺便还能吃上CDN缓存。
答案是:规范明确禁止。MDN那一页的原话是The src, async, nomodule, defer, crossorigin, integrity, and referrerpolicy attributes must not be specified。这七个属性一个都不许写在这个标签上。
写了会怎样
实验台里我硬写了一个src指向一份完全正确的JSON。结果是两个引擎都不认,页面上所有裸名字全部解析失败。
但两个引擎的态度差别很大:
- 引擎B给了明确提示:
External import maps are not supported: <script type='importmap'> with a src attribute is currently not supported. - 引擎A一个字都没说。控制台里只有下游那条“lib解析不出来”的报错,关于src被忽略这件事,零提示。
又是一次报错指向下游。你看到的是终点的症状,起点那一步被静默跳过了。
只能内联,就意味着它撞上了内联脚本的禁令
这才是真正的麻烦。
但凡站点上过一轮安全加固,内容安全策略里大概率写着不许执行内联脚本,靠nonce或者哈希放行白名单里的那几段。这条策略是标准做法,也确实拦得住不少东西,它和其他那些靠响应头生效的机制一样,属于加固清单上的常客。
问题在于,浏览器眼里<script type="importmap">就是一个script元素,而且是内联的那种。
实测:给页面发一条script-src 'self' 'nonce-xxx',import map标签不带nonce。两个引擎的结果一致,表整个被拦掉,所有裸名字失败。控制台里是这样两句话:
Executing inline script violates the following Content Security Policy directive ...
Failed to resolve module specifier "lib" ...第一句讲根因,第二句讲症状。两句都在,中间那一步没人讲——就是“于是这张表没了”。
而排查的人为什么会漏掉第一句?因为在他的心智模型里,import map不是脚本,它是配置。一条抱怨内联脚本的策略报错,跟一张翻译表之间,没有明显的联想通路。
唯一的出路是给它加nonce
把nonce加上,两个引擎立刻恢复正常。
MDN关于script-src-elem那一页把机制说得很完整:这条指令管的就是script元素本身,包括请求和内联块两种;nonce和哈希都是它认的放行方式;这条指令缺席时回退到script-src,两条都缺席时回退到default-src。
所以这里有一个很别扭的组合:
这张表不许放到外部文件里,所以它必须内联;而它一旦内联,就归内联脚本那条策略管;于是一份纯数据、不含任何可执行代码的JSON,必须每次渲染都带上一个动态生成的nonce才能生效。
顺带说一句,这也意味着它吃不到任何HTTP缓存。做边缘缓存与回源率优化时习惯先问一句这段字节能不能被缓存,到这里答案是不能。每一次页面渲染,这张表都得原样重发一遍。表越大,每个页面重复的字节越多。做页面速度优化时习惯把公共配置抽出去共享的那套思路,在这里完全用不上。
两个团队各写一张表,会合并还是会打架?
现实里这种情况太常见了。主应用维护一张,某个嵌入式组件带着自己那张,或者营销团队的标签管理系统往页面里塞了一段。
MDN那一页对这件事的描述值得逐字读:Supporting browsers can declare one or more import maps anywhere in the document, provided they are defined before any module that depends on them is loaded (some browser versions allow only a single import map declaration, which must appear before any module is loaded)。
括号里那半句是重点。
实测:两张表在两个引擎上是两种命运
第一张表映射lib,第二张表映射一个新名字lib2,两个名字都被import。
| 引擎 | lib | lib2 | 控制台 |
|---|---|---|---|
| 引擎A | 正常 | 正常 | 无 |
| 引擎B | 正常 | 解析失败 | Multiple import maps are not allowed. |
引擎A把两张表合并了,引擎B直接把第二张整个丢掉。
最阴的地方在于同名那条两边表现一样
我又跑了一组:两张表都定义lib,但指向不同地址。
两个引擎的可见结果完全相同,都用了第一张表里的那个地址。
可它们的机制完全不同。引擎A是真的把两张表合并了,只是在冲突时保留了先写的那条,还老老实实吐了句warning:An import map rule for specifier 'lib' was removed, as it conflicted with an existing rule.。引擎B压根没看第二张表。
推论是:你测两张表冲不冲突的时候,最容易想到去测的就是同名那条,而同名那条恰好是两个引擎唯一表现一致的地方。真正会出事的是第二张表里那些独有的新名字,而那些名字往往属于后加进来的那个组件,不属于你。
一个反直觉的方向对比
把上一节测过的重复键和这一节的多表冲突放在一起,方向正好相反:
| 冲突形态 | 谁赢 |
|---|---|
| 同一张表的JSON里,同一个键写两遍 | 后写的赢 |
| 两张不同的表,各写了同一个键 | 先写的赢 |
一个是JSON解析的默认行为,一个是import map合并的规则,各自都讲得通,凑一起就成了一道判断题。合并两份配置的时候,把两段JSON简单拼一起和把两个标签都留在页面上,结果是相反的。
现实里该怎么办
结论很朴素:一个页面只维护一张表,由构建流程统一生成。别指望多张表能合并,也别指望冲突时的优先级到处都一样。任何第三方组件想往页面里塞第二张表,先把它的映射合并进主表再说。
这本质上是配置治理问题,不是技术问题。它跟内容发布工作流治理里讲的那类漏洞同源:环节多了以后没人拥有最终那份,出事时也就没人认领。
同一个库怎么会在一个页面里跑两遍?
这个坑不响,但很贵。
MDN模块指南里有一句承诺:Modules are only executed once, even if they have been referenced in multiple <script> tags。模块只执行一次,无论被引用多少回。
这句话是真的。关键在于“同一个模块”是怎么判定的。
判据是解析之后的地址,不是你写的名字
我给测试模块的顶层放了一个计数标记,模块每被求值一次就记一笔。然后跑了四组:
| 写法 | 解析后的地址 | 实际实例数 |
|---|---|---|
| 两个不同的名字,映射到同一个地址 | 相同 | 1 |
| 两个模块脚本,各自import同一个名字 | 相同 | 1 |
| 一处用名字,另一处直接写完整地址,二者等价 | 相同 | 1 |
| 两个名字,映射到同一份文件,但地址差一个查询参数 | 不同 | 2 |
最后那一组,两个引擎都跑出了两个实例。两份完全独立的模块状态,导出的内容一模一样,连版本号都一样,因为本来就是同一个文件。
这就是那个著名的“页面上有两个某某实例”的故障的底层形态。它不需要你装两个版本,只需要有人在某处的地址后面多加了一个缓存刷新参数。
为什么这种事总在合并配置的时候发生
因为差异往往长这样:
"chart": "https://cdn.example.com/chart@3.9.1/dist/chart.js"
"chart-lib": "https://cdn.example.com/chart@3.9.1/dist/chart.js?v=20251201"一份是主应用维护的,一份是某个团队为了绕过一次缓存事故临时加的。代码评审的时候,两行看起来指向同一个文件,谁也不会觉得这是问题。而在浏览器眼里,这是两个毫不相干的模块。
这个坑跟当年WordPress圈子里给静态资源加版本号后缀踩的是同一类:查询参数改变的是缓存身份,而缓存身份在模块体系里等于模块身份。
scopes是另一条制造分身的路
scopes允许你按引用方的路径给同一个名字换目标:
{
"imports": { "lib": "https://cdn.example.com/lib@2/index.js" },
"scopes": {
"/legacy/": { "lib": "https://cdn.example.com/lib@1/index.js" }
}
}实测确认了它按预期工作:主目录下的模块拿到版本2,/legacy/下的模块拿到版本1,页面上同时存在两个实例。
这个能力是设计出来解决过渡期问题的,本身没毛病。要注意的是MDN给的那句提醒:Note that the path used to select a scope does not affect how the address is resolved。挑scope用的是引用方的路径,解析地址用的是写表那个文档的地址,两者互不相干。把scope的键当成目录前缀去理解,就会在这儿判断错。
说白了,scopes是一个正大光明的分身器。你用它的时候心里是有数的,代价也是明知的。真正咬人的从来不是它,是那个多出来的查询参数。
给模块加的那行预载,为什么反而多下了一份?
前面说过modulepreload会关掉表的窗口。就算你把顺序排对了,它还有第二个坑。
href里写的是地址,不是名字
这行标签的href走的是普通的地址解析,跟import map一点关系都没有。
实测:把href写成裸名字lib,浏览器把它当相对路径解析成了/lib,向服务器要了一份HTML回来,然后报MIME类型错误。两个引擎都是这个反应。
同一个字符串,写在import后面是名字,写在href里是路径。中间没有任何提示告诉你换了个世界。
地址差一个参数,就白下载一份
这是我用服务端请求计数做的一组对照,两个引擎结果一致:
| 预载的地址 | 表解析出的地址 | 服务端收到的请求数 | 预载有没有派上用场 |
|---|---|---|---|
| 完全一致 | 同一个 | 1 | 用上了 |
| 多一个查询参数 | 另一个 | 2 | 一次都没用上 |
那份预载下来的文件,安安静静躺在缓存里,直到页面关闭都没被谁碰过一下。带宽花了,连接占了,解析和编译的开销也付了,收益是零。
而页面表现完全正常。功能好好的,控制台干干净净。你唯一能看出问题的地方,是网络面板里同一个文件名出现了两次。
官方文档的承诺和这条实测并不矛盾
web.dev那篇讲模块预载的文章里有一句建议:The best practice would be to declare the module and the flat list of its dependencies, and trust the browser not to fetch the same module twice。相信浏览器不会把同一个模块抓两遍。
这句话是对的。浏览器确实不会。问题在于“同一个模块”的判据依然是地址,而预载那条路径根本没经过import map的翻译。于是从浏览器的角度看,它诚实地抓了两个不同的模块,一次都没重复。
MDN讲modulepreload那一页,从头到尾没提过import map一个字。不是漏写,是这两件事在规范层面本来就不发生关系。而使用者的直觉恰恰相反:既然表能翻译import,凭什么不能翻译href。
这类坑有个共同形状:文档里A页面和B页面各自都写对了,但没有任何一页负责讲它俩碰上会怎样。
表里那把哈希锁,管的范围比你想的宽
这张表还有第三个顶层键,叫integrity,把地址映射到摘要,用来校验模块字节有没有变过:
{
"imports": { "lib": "https://cdn.example.com/lib.js" },
"integrity": { "https://cdn.example.com/lib.js": "sha384-..." }
}实测了两件事。
第一,摘要对不上时,字节是完整下载完才被拒绝执行的,服务端日志里是一次干干净净的成功请求。也就是说,带宽照付、往返照等,被省下的只有那几毫秒的执行时间。这一点在CDN缓存与边缘路由那套账里尤其别扭:字节已经穿过整条链路了,最后一步才被扔掉。
第二件事更值得注意:这个键管的是地址,不管你是通过名字还是直接写完整地址import的。我特意跑了一组,表的imports里压根没有这个名字,代码里直接写完整地址import,摘要键照样生效,照样拦。
而报错文案是这样的:
Failed to find a valid digest in the 'integrity' attribute for resource ...它说的是integrity属性。而页面上根本没有任何一个标签带这个属性——它写在那张表的JSON里。一个人拿着这句报错去搜页面上的integrity属性,会一个也搜不到,然后开始怀疑人生。
还有几处它管不到的地方
- 模块worker:在模块worker里import裸名字,解析失败。两个引擎一致,而且worker的onerror事件里message是空字符串,跟网络故障长得一模一样。离线应用那一套里的另一种worker同样在覆盖范围之外,一律写完整地址最省事。
- 普通脚本的src:
<script src="lib">里的lib不会被翻译,被当相对路径处理。 - 动态import():这个是管得到的,跟静态写法走同一套解析。
- 表被从DOM里删掉之后:照常生效。表在解析那一刻就定型进了内部状态,之后你把标签删了、改了、重新插一个新的,都不影响已经生效的那份。这意味着靠改DOM来热更新映射这条路是走不通的。
真实站点上到底有多少人在用这张表?
讲了这么多机制,得看一眼现实里的分布,不然容易把一个边缘特性说得像天塌了。
保哥拿一份164个域名的清单跑了一遍首页,只发GET、不登录、不执行脚本,最后有94个站抓到了完整HTML。清单覆盖独立站、支付网关、开源CMS官网、几个国内站点,算不上严格抽样,但足够看形状。
三个数字
| 指标 | 站点数 | 占抓到的比例 |
|---|---|---|
| 页面上有模块脚本 | 43 | 46% |
| 页面上有模块预载 | 17 | 18% |
| 页面上有import map | 1 | 1% |
模块化早就铺开了,接近一半的站首页上有模块脚本。模块预载也不算冷门。但把裸名字交给浏览器去翻译这件事,94个站里只有一个在做。
唯一那张表长这样
它一共44个字节:
{"imports":{"#entry":"/_nuxt4/D95v_LBE.js"}}只映射一个名字,而且这个名字不是任何包名,是一个以井号开头的内部别名,指向一个文件名里带内容指纹的构建产物。
这个用法很有意思,因为它跟import map最初被设计出来要解决的问题基本无关。它不是在解决“我想在浏览器里直接写import lodash”,而是在解决“我的入口文件名每次构建都会变,我需要一个稳定的别名指过去”。
更值得看的是它的写法全部踩在正确的一侧:
- 表出现在文档第553个字节,几乎是head的开头。
- 它那45条模块预载出现在第356577个字节,远远在后面。顺序是对的。
- 预载列表里第二条正是表里映射到的那个地址,一个字节不差。预载没白做。
- 映射的目标是内容指纹文件名,内容一变文件名就变,映射本身永远不需要跟着改。
把窗口位置量化一下
那43个已经有模块脚本的站,如果哪天想加一张import map,窗口在文档的哪个位置就关上了?我拿“第一个模块脚本或第一条模块预载出现的字节位置”当窗口关闭点,算出来是这样:
| 统计量 | 字节位置 |
|---|---|
| 最早 | 46 |
| 中位数 | 142441 |
| 最晚 | 2036002 |
中位数看着挺宽裕,139 KB的余地,怎么写都来得及。但分布是长尾的:有5个站的窗口在前5 KB之内就关上了,最极端的那个站,第一个模块脚本出现在第46个字节。那基本上是文档类型声明刚写完的位置,任何东西都插不进去。
另外有13个站,模块预载出现在任何模块脚本之前。对这13个站来说,将来要加表,得插到那一整片预载标签的前面去,而那片标签往往是构建工具自动生成的,手工往前插一行的成本比看上去高。
这个1% 该怎么读
我的判断是:这不是“没人需要”,而是“需要的人都用构建工具解决了”。
把裸名字换成真实地址,本来就是打包工具最基础的那项工作,顺带还做了摇树、分包、压缩。既然构建这一步无论如何都要跑,那把名字翻译也交给它,比在HTML里手写一张必须内联、必须排在最前、还得配nonce的表要省心得多。
反过来说,import map真正不可替代的场景只有一类:你没法在构建期决定那个地址。比如按环境切CDN域名、灰度期间给一部分流量换库版本、微前端里让多个独立发布的应用共享同一份依赖。这几种场景确实存在,只是不常见。
顺带提醒一句,这类“用得少所以出事少”的特性,跟技术债务盘点里那些沉默的配置是一路货:零故障率有时候只是零覆盖率换了个说法。等哪天真有人上了,前面那八节里的坑一个都不会少。
上线前怎么把这几种静默失效一次查出来?
前面八节拆的都是机制。这一节把它压成能在评审和上线前真正跑一遍的东西,和第一年隐性失分那份清单一样,价值全在能不能被真的执行。
第一步:确认那张表到底还在不在
所有“名字解析不出来”的故障,第一个岔路口都是这个:表被丢掉了,还是表在但那一条不对。判据只有一个动作,在控制台里执行:
document.querySelectorAll('script[type="importmap" i]').length但这个数字只能说明标签在DOM里,不能说明它被采纳了。真正的判据是这条:
import("你表里的任意一个名字").then(m => console.log("解析成功", m))
.catch(e => console.log(e.name, e.message))写法上它和那些一行就能验完的原生写法是一个量级的成本,值得固化进上线流程。三种回答对应三条完全不同的路:
| 报错里出现的字眼 | 说明 | 该去看哪里 |
|---|---|---|
| blocked by a null | 表在,这一条的值当初写坏了 | 那一行的地址格式 |
| bare specifier / 相对路径必须以斜杠开头 | 整张表没生效 | 下面第二、三、四步 |
| 没报错,但拿到的地址不对 | 表生效了,但被另一张表或scope改写了 | 数一数页面上有几个importmap标签 |
第二步:数一数标签的先后顺序
这一步能一次排掉最难查的那一类。在控制台里跑:
const html = document.documentElement.outerHTML;
console.log("map :", html.indexOf('type="importmap"'));
console.log("preload :", html.indexOf('rel="modulepreload"'));
console.log("module :", html.indexOf('type="module"'));第一个数字必须是这三个里最小的,而且不能是 -1。只要模块预载或模块脚本排在前面,就有一个引擎会把整张表丢掉。
要提醒的是,这个顺序在源码里对不代表在最终HTML里对。模板拼装、边缘改写、注入型的第三方标签,都可能把顺序搅乱。在CDN边缘改HTML这类做法尤其要小心,那一层的插入位置常常是“追加到head开头”,正好插在表前面。所以这条要在真实响应上验,不是在模板里验。这跟做结构化标记可提取性检查时坚持看最终产物而不是看模板,是同一个道理。
第三步:把两个引擎都跑一遍,别只跑一个
本文这50组实测里,两个引擎结论不一致的有三处:多张表、模块预载关窗口、外部表被忽略时的提示。三处全都是“一个引擎正常、另一个引擎整张表作废”的形态。
这意味着单引擎验证在这个话题上不是覆盖不全的问题,是直接会给出相反结论的问题。
第四步:把内容安全策略那条也过一遍
如果站点发了限制内联脚本的策略,检查这张表有没有带nonce。判据同样在控制台,看有没有这一类报错:
... violates the following Content Security Policy directive ...它后面通常紧跟着一条名字解析失败。两条报错要连起来读,单看后一条会一直往模块方向查。
第五步:数一数网络面板里有没有重复的文件名
这一步查的是不报错的那两类问题:同一个库跑了两遍,和模块预载白下载。
方法很土,也很有效:网络面板按名称排序,看有没有同名文件出现两次,只有查询参数不同。有就查两处地址是从哪来的。
performance.getEntriesByType("resource")
.map(e => e.name)
.filter(n => /\.m?js(\?|$)/.test(n))
.map(n => [n.split("?")[0], n])
.reduce((acc, [k, v]) => ((acc[k] = acc[k] || []).push(v), acc), {});输出里凡是数组长度大于1的键,都值得看一眼。这套采集思路跟做交互响应指标排查时从资源时间线里捞证据是同一路,只是关注点从耗时换成了重名。
第六步:给这张表加一条监控
前面反复出现的一个模式是:warning不进监控,error进监控但文案指向下游。所以值得在页面里加一段极短的自检,把结果发到你自己的通道上:
import("表里的一个哨兵名字").catch(e => {
navigator.sendBeacon("/_probe/importmap", JSON.stringify({
ua: navigator.userAgent, name: e.name,
msg: String(e.message).slice(0, 200)
}));
});哨兵名字选一个体积最小、映射到真实文件的名字就行。这条探针能同时覆盖前面所有失效路径,因为不管是被策略拦了、被预载关了窗、还是JSON写坏了,症状都是这个名字解析不出来。
做告警这件事的老规矩在这里同样适用:先有能确认通路是活的阳性锚点,再去解读零上报。否则收不到报文到底是没出事还是探针自己也死了,你分不清。
压成一张清单
| 检查项 | 通过标准 |
|---|---|
| 表在最终响应里的位置 | 早于任何modulepreload与module脚本 |
| 页面上importmap标签数量 | 1 |
| 内容安全策略 | 表带nonce,或策略允许内联 |
| JSON | 能被JSON.parse通过,imports是对象 |
| 每条值 | 能被new URL()解析,带斜杠的键对应带斜杠的值 |
| 模块预载地址 | 与表解析结果逐字节一致 |
| 网络面板 | 没有仅查询参数不同的同名模块 |
| 验证引擎 | 两个内核各跑一遍 |
常见问题解答
import map到底该放在head还是body?
规范不限制位置,只限制顺序。实测把它放在body里、但排在模块脚本之前,两个引擎都正常。真正的约束是它必须早于第一个模块脚本和第一条模块预载。实践中放在head最前面最安全,因为你控制不了后面会被谁插进来什么。
为什么我在本地一切正常,上线就有一半用户报功能不见了?
优先查两件事。第一,页面上是不是有<link rel="modulepreload">排在表前面,这个组合只在其中一个引擎上失败。第二,生产环境是不是比本地多发了一条内容安全策略,把没带nonce的内联表拦掉了。这两个原因都会让另一半用户看到完全正常的页面,从而让问题看起来像玄学。
能不能把这张表放到单独的JSON文件里,用src引进来?
不能。规范明确写着这个标签上不许出现src,实测两个引擎都不认。想复用同一份映射,只能在服务端渲染时把JSON内联进每个页面,或者交给构建流程注入。代价是这段字节吃不到缓存,每次渲染都要重发。
表里某个名字写错了地址,会不会拖垮其他名字?
不会,前提是JSON本身合法。写错的那一条会变成null,其余照常工作,控制台只留一条warning。反过来说,如果JSON语法错了或者imports不是对象,整张表会一起作废,所有名字同时失效。分界线是错误落在JSON的哪一层。
模块预载的href能写表里的名字吗?
不能。href走普通地址解析,裸名字会被当成相对路径。实测写href="lib"会向站点根目录要一个叫lib的文件,拿回HTML然后报MIME类型错。预载必须写表解析之后的那个完整地址,而且要逐字节一致,差一个查询参数就等于白下载一份。
同一个库怎么会有两个实例?表里明明只写了一次。
看有没有别的地方绕过了表。常见的三种:某处直接写了完整地址而不是名字,且那个地址跟表里的差一个查询参数;有第二张表定义了同名或近义的键;有scope给某个目录换了目标。判据是把网络面板里所有模块地址列出来,去掉查询参数后看有没有重名。
表里的integrity键和写在标签上的integrity属性是一回事吗?
机制一样,覆盖面不一样。表里那个键按地址匹配,只要请求的是那个地址就校验,不管代码里写的是名字还是完整地址。要注意报错文案说的是integrity属性,而页面上可能根本没有这个属性,别照着这句话去搜标签。
把importmap标签从DOM里删掉或者改掉,能实现热更新吗?
不能。实测把标签整个删掉之后,后续的动态import照常按原映射解析。表在解析那一刻就定型了,之后对DOM的任何改动都不影响已经生效的那份。想换映射只能重新加载页面。
模块worker里能用表里的名字吗?
不能。实测在模块worker里import裸名字直接解析失败,两个引擎一致。更麻烦的是worker的onerror事件里message是空字符串,跟脚本下载失败长得一模一样,只看事件对象分不出来。worker里一律写完整地址。
现在到底该不该用它?
如果你的地址在构建期就能确定,交给打包工具更省事,本文这些坑一个都不用碰。真正值得用它的只有一类场景:地址必须在运行时才能决定,比如按环境切CDN、灰度切库版本、多个独立发布的应用共享同一份依赖。这种时候它不可替代,但要把前面那张清单当成上线闸门。
权威参考资料
本文标题:《那张对照表就写在页面最上面,一个字也没错,浏览器却说这些名字它一个都不认识》
本文链接:https://zhangwenbao.com/import-map-bare-specifier-module-resolution-breakage.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0