Lighthouse 复用 axe valid-langs 模块的实践:trie 编码的 ISO 639 语言代码校验与 hreflang 审计
【免费下载链接】lighthouseAutomated auditing, performance metrics, and best practices for the web.项目地址: https://gitcode.com/GitHub_Trending/lig/lighthouse
Lighthouse 的 SEO 审计需要在运行时判断hreflang属性中的语言代码是否合法,而全量加载 axe-core 又过于笨重。为此,项目以third-party/axe/目录为媒介,将 axe-core 内部的高密度 trie 编码语言表valid-langs.js抽取为独立文件,供hreflang审计直接调用。本文以 third-party/axe/README.md 为骨架,结合该目录下的实现文件、hreflang审计源码及其测试用例,完整还原这一"借用第三方内部模块"的工程决策与编码原理,读完你可以理解:为什么 axe 的 lib 不能直接 import、trie 编码如何把 20KB 的语言表压到 2KB、以及 Lighthouse 是如何逐字符查表校验语言代码的。
一、README 说了什么:一次克制的第三方代码复用
third-party/axe/README.md 全文只有两个核心结论,却准确交代了一次"vendoring(代码抽取)"的全部动机:
- 用途:Lighthouse 在
href审计中使用 axe 包内部的valid-langs.js模块。README 中的 "href" 审计,在当前代码库中对应的实际消费者是 SEO 分类下的hreflang审计(core/audits/seo/hreflang.js),该审计同时校验rel="alternate"链接的hreflang语言代码与href指向是否合格。 - 原因:axe 发布时并没有提供允许导入其
lib的package.json(即没有把内部模块声明为可被外部 import 的导出路径),因此 Lighthouse 必须把所需的内部文件抽取出来、随仓库维护。
这一决策直接决定了third-party/axe/目录的形态——它不是一个依赖安装目录,而是一个"精选单文件抽取"目录,目前只包含三个文件:valid-langs.js(实现)、LICENSE(MPL 2.0 许可证)、README。同时,tsconfig.json 第 22 行将third-party/axe/valid-langs.js显式纳入工程的 include 列表(文件首行带有// @ts-nocheck,不参与严格类型检查但进入编译上下文)。
二、valid-langs.js 内部:把语言表编码成 trie 嵌套数组
valid-langs.js 的头部注释(第 6~11 行)揭示了整个文件的设计动机与编码手法:
In order to reduce the file size of axe-core, this file was specially encoded. Normally, an array of strings of each valid ISO 639-1/2 code is 20kb gzipped. Encoding the codes is only 2kb gzipped.
即:直接以字符串数组存放全部合法 ISO 639-1/2 语言代码,gzip 后约 20KB;而采用 trie 编码后仅约 2KB,体积缩小约 90%。该编码手法借鉴自 Js13kGames 游戏开发竞赛中的 ZzFXM 技术——把大量数据存成嵌套的整数数组,这种结构对 gzip 极其友好。
编码规则
- 语言代码被组织成一棵字典树(trie),用嵌套数组表示:每个数组下标代表一个字母(
a对应 1,b对应 2,依此类推),值为1表示该路径在此处是合法的语言代码,值为数组则表示继续向下分叉。 - 所有代码统一补齐到 3 个字符:长度不足 3 的代码末尾用反引号
`填充。反引号的charCodeAt(0) - 96 === 0,正好落在数组的 0 号下标上,用来标记"较短字符串在此处即为合法"。例如aaa与aa同时合法时,存储为[,[,[1,1]]]——外层第二个元素(a)是数组,其中第二个元素(aa)又是数组,该数组的 0 号位(由`填充而来)与 1 号位(第三个 a)均为 1。
文件主体(第 59 行)就是一个被prettier-ignore与eslint-disable注释保护的大常量langs,即整个 IANA 语言表的 trie 形态。
解码函数 isValidLang
文件末尾导出的核心 API 是isValidLang(第 68~84 行):
function isValidLang(lang) { let array = langs; // padEnd is not supported in IE11 while (lang.length < 3) { lang += '`'; } for (let i = 0; i <= lang.length - 1; i++) { const index = lang.charCodeAt(i) - 96; array = array[index]; if (!array) { return false; } } return true; }逐行拆解其原理:
- 补位:由于
padEnd在 IE11 不受支持,这里用while循环手动把不足 3 位的代码用反引号补足,与编码端的规则严格对齐。 - 逐字符查表:对每个字符计算
charCodeAt(i) - 96('a'为 97,减 96 得 1),作为数组下标逐层下钻。 - 提前终止:一旦某层下标不存在(
array为undefined),立即返回false。全部字符走完仍存在,说明该代码在 trie 中合法。
值得注意的是,查表是严格逐字符匹配的:isValidLang(' es')(带前导空格)会因空格字符计算出负下标而返回false,这一点在测试用例中专门覆盖(见下文)。
被标记弃用的反向解码器 validLangs
同文件还保留了 axe-core 原版的validLangs(第 93~108 行),其作用与isValidLang相反——把 trie 数组展开回完整语言代码列表。它递归遍历嵌套数组,用String.fromCharCode(index + 96)还原字母并拼接前缀,遇到值为1的叶子则输出完整代码。该函数在 JSDoc 中标注@deprecated,Lighthouse 并不消费它,保留它主要是为了与上游 axe-core 保持同步成本最低。
三、数据从哪来:基于 IANA 注册表的再生成脚本
valid-langs.js头部的注释块(第 13~55 行)附带了一段完整的数据再生成脚本,这保证了语言表可以随 IANA 注册表更新而重新生成,而不是靠手工维护几千条代码:
const str = document.querySelector('pre').innerHTML; const langs = new Set(); str.split('%%').forEach(language => { const properties = language.split('\n'); for (let i = 0; i < properties.length; i++) { const property = properties[i]; const match = property.match(/(?<type>\w+): (?<value>\w+)/); if (!match) continue; const { type, value } = match.groups; if (type === 'Type' && value !== 'language') return; if (type === 'Subtag') langs.add(value); if (type === 'Deprecated') langs.delete(value); // 剔除已废弃代码 } }); Array.from(langs).forEach(lang => { lang = lang.padEnd(3, '`'); let array = encodedLangs; lang.split('').forEach((char, i) => { const index = char.charCodeAt(0) - 96; if (i < lang.length - 1) { array[index] = array[index] || []; array = array[index]; } else { array[index] = 1; } }); });这段脚本的运行前提是在 IANA 的 language-subtag-registry 页面(浏览器开发者工具中执行)解析其<pre>文本:按%%切分每个语言记录,只收集Type: language的Subtag,并自动剔除标记为Deprecated的代码(这也是isValidLang能拒绝过期代码的原因),最后按前述规则构建 trie 并JSON.stringify。从仓库历史看,该文件经历过至少两次变更:一次是 changelog-pre10.md 记录的"移除 eval、直接 import axe 的 valid-langs.js",另一次是 changelog.md 记录的"更新 valid-langs.js"。
四、消费端:hreflang 审计如何用它判分
抽取出来的isValidLang在 core/audits/seo/hreflang.js 第 13 行被直接 import:
import {isValidLang} from '../../../third-party/axe/valid-langs.js';审计对语言代码的校验封装在isExpectedLanguageCode(第 46~54 行):
function isExpectedLanguageCode(hreflang) { if (hreflang.toLowerCase() === NO_LANGUAGE) { // NO_LANGUAGE = 'x-default' return true; } // hreflang can consist of language-script-region, we are validating only language const [lang] = hreflang.split('-'); return isValidLang(lang.toLowerCase()); }这里有三个值得注意的工程细节:
- 特例放行:
x-default是 hreflang 规范中的保留值(表示默认语言版本),先于查表判断,不进入语言表校验。 - 只校验语言主标签:
hreflang可能形如language-script-region(如zh-Hans、nl-be),而语言表只收录 ISO 639 主代码,因此先用split('-')取出第一段再做校验——这也与valid-langs.js注释中"ISO 639-1/2"的定位一致。 - 大小写归一:先
toLowerCase()再查表,因此FR-BE、XX-be这类写法会被统一处理(fr合法通过,xx不合法被拒)。
审计主流程
audit()(第 75~144 行)接收采集器产出(gatherer 在 core/gather/gatherers/link-elements.js 中从 DOM 的<link>元素与 HTTP 响应头提取rel、hreflang、hrefRaw等字段):
- 通过
rel === 'alternate'、存在hreflang属性、且source !== 'body'三个条件过滤出可审计的链接(<body>内的链接按规范不属于 hreflang 声明,直接忽略); - 对每个候选链接执行两项校验并收集失败原因:语言代码不合法记入
Unexpected language code,href不是http:/https:全限定 URL 记入Relative href value; - 失败项以表格细节输出,展示
link标签的代码片段(<head>来源)或Link:响应头原文(headers来源),并把失败原因作为子项(subItems)逐条列出; - 只要存在任意失败项,审计得分即为 0,否则为 1。
测试如何锁死行为
core/test/audits/seo/hreflang-test.js 用一组精心设计的用例验证了上述规则与isValidLang的边界行为:
- 非法代码被判失败:
xx1、XX-be、XX-be-Hans、es(前导空格)全部产生失败项,印证"trie 逐字符匹配"的严格性——XX转为小写xx后并不存在于 IANA 语言表,空格则直接破坏下标计算; - 合法代码放行:
pl、nl-be、zh-Hans、x-default、FR-BE全部通过,同时验证了"只校验语言主标签"(zh-Hans取zh)与大小写归一(FR-BE取fr)两条规则; - body 来源一律忽略:即使 body 中的链接携带
xx等非法值,审计仍得 1 分; - href 校验独立:
example.com、//example.com这类非全限定 URL 单独触发Relative href value;当语言代码与 href 同时非法时,单个失败项会携带两个子项原因(测试断言subItems.items.length === 2)。
五、为什么要"抽取"而不是"依赖":包导出边界的现实约束
回到 README 的第二句话——"The axe package is not published with apackage.jsonthat allows importing of itslib, so we must extract it"。这是整个目录存在的根本原因:
- axe-core 作为一个面向页面注入场景的库,其包发布形态并未声明
lib/为可外部导入的入口;直接import 'axe-core/lib/...'在模块解析与打包层面都不可靠,也无法被 Lighthouse 的构建体系稳定引用; - 因此 Lighthouse 采用"单文件抽取 + 仓库内维护"的 vendoring 策略:把需要的
valid-langs.js连同 MPL 2.0 许可证一起收进third-party/axe/,以第一方代码的方式随仓库构建、随仓库测试; - 这一策略把对上游的依赖收敛为"偶尔按需同步单个文件",避免了引入完整 axe 依赖树的体积与安全面开销。
需要说明的是:这种复用是有边界的——Lighthouse 只借用了语言表这一纯数据 + 纯函数模块,而没有把 axe 的页面注入与规则引擎搬进来;无状态、无副作用、输入输出清晰的模块性质,正是它适合被抽取复用的前提。
六、小结:一次可借鉴的"小而精"第三方复用范本
回顾整个链路:README 用两句话交代动机(hreflang 审计需要、lib 不可导入)→valid-langs.js用 trie 编码把 20KB 语言表压到 2KB 并提供逐字符查表 API → IANA 注册表脚本保证数据可再生 → hreflang 审计只取主语言标签做大小写归一化校验 → 测试用例锁死全部边界行为。这条链路上每一个环节都有源码或测试可查:
- 实现与编码说明:third-party/axe/valid-langs.js
- 消费方审计:core/audits/seo/hreflang.js
- 行为验证:core/test/audits/seo/hreflang-test.js
- 数据采集:core/gather/gatherers/link-elements.js
- 许可声明:third-party/axe/LICENSE
对于任何需要在生产代码中复用"大而全"依赖内部小工具的场景,Lighthouse 的这一做法都提供了可复制的模板:先评估模块是否无状态、可独立;再以最小文件 + 许可证随仓库抽取;最后用测试把行为边界固化下来。
【免费下载链接】lighthouseAutomated auditing, performance metrics, and best practices for the web.项目地址: https://gitcode.com/GitHub_Trending/lig/lighthouse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考