Lighthouse 复用 axe valid-langs 模块的实践:trie 编码的 ISO 639 语言代码校验与 hreflang 审计
2026/9/10 6:27:47 网站建设 项目流程

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(代码抽取)"的全部动机:

  1. 用途:Lighthouse 在href审计中使用 axe 包内部的valid-langs.js模块。README 中的 "href" 审计,在当前代码库中对应的实际消费者是 SEO 分类下的hreflang审计(core/audits/seo/hreflang.js),该审计同时校验rel="alternate"链接的hreflang语言代码与href指向是否合格。
  2. 原因:axe 发布时并没有提供允许导入其libpackage.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 号下标上,用来标记"较短字符串在此处即为合法"。例如aaaaa同时合法时,存储为[,[,[1,1]]]——外层第二个元素(a)是数组,其中第二个元素(aa)又是数组,该数组的 0 号位(由`填充而来)与 1 号位(第三个 a)均为 1。

文件主体(第 59 行)就是一个被prettier-ignoreeslint-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),作为数组下标逐层下钻。
  • 提前终止:一旦某层下标不存在(arrayundefined),立即返回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: languageSubtag,并自动剔除标记为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()); }

这里有三个值得注意的工程细节:

  1. 特例放行x-default是 hreflang 规范中的保留值(表示默认语言版本),先于查表判断,不进入语言表校验。
  2. 只校验语言主标签hreflang可能形如language-script-region(如zh-Hansnl-be),而语言表只收录 ISO 639 主代码,因此先用split('-')取出第一段再做校验——这也与valid-langs.js注释中"ISO 639-1/2"的定位一致。
  3. 大小写归一:先toLowerCase()再查表,因此FR-BEXX-be这类写法会被统一处理(fr合法通过,xx不合法被拒)。

审计主流程

audit()(第 75~144 行)接收采集器产出(gatherer 在 core/gather/gatherers/link-elements.js 中从 DOM 的<link>元素与 HTTP 响应头提取relhreflanghrefRaw等字段):

  • 通过rel === 'alternate'、存在hreflang属性、且source !== 'body'三个条件过滤出可审计的链接(<body>内的链接按规范不属于 hreflang 声明,直接忽略);
  • 对每个候选链接执行两项校验并收集失败原因:语言代码不合法记入Unexpected language codehref不是http:/https:全限定 URL 记入Relative href value
  • 失败项以表格细节输出,展示link标签的代码片段(<head>来源)或Link:响应头原文(headers来源),并把失败原因作为子项(subItems)逐条列出;
  • 只要存在任意失败项,审计得分即为 0,否则为 1。

测试如何锁死行为

core/test/audits/seo/hreflang-test.js 用一组精心设计的用例验证了上述规则与isValidLang的边界行为:

  • 非法代码被判失败xx1XX-beXX-be-Hanses(前导空格)全部产生失败项,印证"trie 逐字符匹配"的严格性——XX转为小写xx后并不存在于 IANA 语言表,空格则直接破坏下标计算;
  • 合法代码放行plnl-bezh-Hansx-defaultFR-BE全部通过,同时验证了"只校验语言主标签"(zh-Hanszh)与大小写归一(FR-BEfr)两条规则;
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询