freeCodeCamp 每日挑战实战:用 JavaScript 实现 WCAG 色彩对比度评级(Challenge 353: Contrast Rating 2)
2026/9/10 5:37:38 网站建设 项目流程

freeCodeCamp 每日挑战实战:用 JavaScript 实现 WCAG 色彩对比度评级(Challenge 353: Contrast Rating 2)

【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp

本篇围绕 freeCodeCamp 每日编程挑战(Daily Coding Challenge)中的第 353 题「Contrast Rating 2」展开:给定两个相对亮度值和一个"是否大字"布尔值,按 WCAG(Web 内容无障碍指南)的对比度公式计算对比率,并按 AAA / AA / Fail 阈值表输出评级。读完本文,你将理解 WCAG 对比率公式中"+0.05"的由来、评级阈值表的分档逻辑,并能独立完成从公式推导、边界判断到参考实现的全部过程,同时了解这道题在 freeCodeCamp 课程库中的定位与相邻挑战的递进关系。

挑战定位:daily-coding-challenges-javascript 系列中的第 353 题

该挑战的定义文件位于 curriculum/challenges/english/blocks/daily-coding-challenges-javascript/6a22d77ddf034bc4e35b1d5a.md,front-matter 声明如下:

--- id: 6a22d77ddf034bc4e35b1d5a title: "Challenge 353: Contrast Rating 2" challengeType: 28 dashedName: challenge-353 ---

challengeType: 28在 freeCodeCamp 的课程类型常量中被定义为dailyChallengeJs。可以在 packages/shared/src/config/challenge-types.ts 中看到定义,以及该类型在视图与提交方式上的映射(viewTypesclassic经典编辑器视图,submitTypestests,即以测试断言判定是否通过):

const dailyChallengeJs = 28; // L30 // ... [dailyChallengeJs]: 'classic', // viewTypes:经典编辑器界面 [dailyChallengeJs]: 'tests', // submitTypes:通过断言测试判分

在课程结构文件 curriculum/structure/blocks/daily-coding-challenges-javascript.json 的challengeOrder数组中,它排在全系列 365 道题的第 353 位,且恰好处于一个由三题组成的"无障碍对比度"递进小节的中间:

序号标题输入考察重点
Challenge 352Contrast Rating 1已算好的对比率(字符串)阈值分档判断
Challenge 353(本文)Contrast Rating 2两个相对亮度值对比率公式 + 阈值分档判断
Challenge 354Contrast Rating 3两组 RGB 数组RGB→相对亮度(伽马校正)+ 前两步

从源码结构看,三题是刻意设计的"阶梯":352 只练判断分支,353 加入对比率公式,354 再补上最底层的 RGB 到相对亮度换算。本文聚焦 353,并会引用相邻两题作为上下文佐证。该 block 的配置还声明了usesMultifileEditor: trueblockLayout: "legacy-challenge-list"(见 block 定义文件),每日挑战的选题入库则由 tools/daily-challenges/seed-daily-challenges.ts 这类脚本负责。

题目描述:从两个相对亮度到 WCAG 评级

原题--description--部分的要求(完整继承自挑战文件):

给定两个相对亮度(relative luminance)值,以及一个表示文字是否为"大字"(large text)的布尔值,按以下方法返回 WCAG 对比度评级:

对比率的计算方式:给两个亮度值各加 0.05,然后用较亮的那个除以较暗的那个。第一个参数始终是较亮的一方

再根据对比率查下表返回评级:

评级普通文字(Normal Text)大字(Large Text)
"AAA"7.0+4.5+
"AA"4.5+3.0+
"Fail"低于 4.5低于 3.0

公式拆解:为什么是 (l1 + 0.05) / (l2 + 0.05)

WCAG 的对比率定义式为:

contrastRatio = (L1 + 0.05) / (L2 + 0.05)

其中L1为较亮颜色的相对亮度,L2为较暗颜色的相对亮度(范围均为 0~1)。这里的 0.05 不是随意取的常数,其工程意义是:模拟真实显示环境中,黑色并不"绝对黑"、白色并不"绝对白"——屏幕本身有环境光与亮度底噪。通过给分子分母同时加一个小的常数偏移,可以避免纯黑 vs 纯白这类极端组合产生理论上限 21:1 之外的失真,同时让靠近黑端的小亮度差异在比值中不至于被过度放大。

题目中"第一个参数始终是较亮一方"这一前提很关键:它省去了Math.max/Math.min排序步骤,让实现可以无条件地把l1放在分子。这也是 353 相比"给定任意两色"的通用实现更简洁的原因。

阈值表解读:四档判断,两个分支

评级表可以转写为判断逻辑:

  • 普通文字(isLargeText = false)ratio >= 7.0"AAA"ratio >= 4.5"AA";否则"Fail"
  • 大字(isLargeText = true)ratio >= 4.5"AAA"ratio >= 3.0"AA";否则"Fail"

注意三点:

  1. 大字的所有阈值恰好等于普通文字"低一档"的阈值,因此两套逻辑可以共用同一组数值常量,只需按isLargeText选分支;
  2. 边界值取>=(闭区间),例如 4.5 的普通文字恰好是"AA"而不是"Fail"
  3. 相对亮度是"感知归一化"后的值(由线性 RGB 经伽马校正与色度加权得到),所以对比率是对"人眼感知亮度差"的度量,而非原始颜色数值差。354 题中的 RGB→亮度换算正是产出这两个输入值的前置步骤。

六条断言测试用例的逐一验算

挑战文件的--hints--给出了 6 条测试断言,它们正好两两覆盖"普通文字 / 大字 × AAA / AA / Fail"的组合。下面把每条用例的对比率手工算一遍,验证期望输出的自洽性:

assert.equal(getContrastRating(1.0, 0.0, false), "AAA"); assert.equal(getContrastRating(0.9015, 0.1364, false), "AA"); assert.equal(getContrastRating(0.8965, 0.1628, false), "Fail"); assert.equal(getContrastRating(0.7469, 0.0957, true), "AAA"); assert.equal(getContrastRating(0.7489, 0.2018, true), "AA"); assert.equal(getContrastRating(0.6571, 0.1974, true), "Fail");
输入 (l1, l2, isLargeText)计算 (l1+0.05)/(l2+0.05)结果判定依据
(1.0, 0.0, false)1.05 / 0.05 =21.00"AAA"21.0 ≥ 7.0
(0.9015, 0.1364, false)0.9515 / 0.1864 ≈5.10"AA"4.5 ≤ 5.10 < 7.0
(0.8965, 0.1628, false)0.9465 / 0.2128 ≈4.45"Fail"4.45 < 4.5
(0.7469, 0.0957, true)0.7969 / 0.1457 ≈5.47"AAA"5.47 ≥ 4.5(大字)
(0.7489, 0.2018, true)0.7989 / 0.2518 ≈3.17"AA"3.0 ≤ 3.17 < 4.5(大字)
(0.6571, 0.1974, true)0.7071 / 0.2474 ≈2.86"Fail"2.86 < 3.0(大字)

两个值得留意的用例设计:

  • 第 3 条0.8965 / 0.1628得到的对比率约 4.45,只比 4.5 的 AA 门槛低一点点,属于典型的"临界 Fail",用来确认边界判断写的是>=而不是>>>=混用;
  • 第 5 条0.7489 / 0.2018 ≈ 3.17说明大字模式下的 AA 门槛(3.0)显著更宽,同一组亮度若用于普通文字就会直接判 Fail——这正是"大字对比要求更宽松"这一 WCAG 规则的量化体现。

另外,亮度值保留了 4 位小数(如 0.9015、0.1364),说明它们是 354 题中 RGB→亮度管线预先算好再传入的中间结果,而非随手编造的数字。

种子代码与参考解法

挑战的初始骨架(--seed--部分)故意返回错误结果,要求学员补全实现:

function getContrastRating(l1, l2, isLargeText) { return l1; }

挑战文件自带的参考解法(--solutions--部分)如下:

function getContrastRating(l1, l2, isLargeText) { const ratio = (l1 + 0.05) / (l2 + 0.05); if (isLargeText) { if (ratio >= 4.5) return "AAA"; if (ratio >= 3.0) return "AA"; } else { if (ratio >= 7.0) return "AAA"; if (ratio >= 4.5) return "AA"; } return "Fail"; }

实现要点逐行解读

  1. 先算比率,再分档ratio只计算一次。若把除法写进每个if条件里,代码更啰嗦,且在某些写法下(比如把l1当普通变量漏加分母)容易引入不一致。
  2. 分支顺序即优先级:先判 AAA 再判 AA,最后 fall-through 到return "Fail"。由于 AAA 门槛严格高于 AA 门槛,顺序判断保证了"满足更高档绝不落到低档"。反过来先判 AA 就会把 AAA 的用例误判掉。
  3. isLargeText在外层、阈值在内层:与阈值表的"列"(普通/大字)对应外层分支,"行"(AAA/AA/Fail)对应内层判断,代码结构与表格一一对应,便于对照审查。
  4. 边界写法:全部使用>=,与题目表中 "7.0+""4.5+""3.0+" 的闭区间语义一致。第 3 条测试用例(ratio ≈ 4.45 → Fail)正是对这一点的验证。
  5. 浮点安全性:题目约定l1总是较亮方,因此分子恒大于等于分母,ratio >= 1恒成立,不存在除零或负值问题(分母l2 + 0.05 >= 0.05 > 0)。测试数据均远离 4.5 / 3.0 的浮点噪声区间,直接用>=比较即可,无需额外做精度舍入。

一种等价的表驱动写法

如果觉得 if 嵌套不够直观,可以把阈值表直接数据化,按分档从高到低匹配:

function getContrastRating(l1, l2, isLargeText) { const ratio = (l1 + 0.05) / (l2 + 0.05); // 每行:[AAA 门槛, AA 门槛],按普通/大字选列 const thresholds = isLargeText ? { aaa: 4.5, aa: 3.0 } : { aaa: 7.0, aa: 4.5 }; if (ratio >= thresholds.aaa) return "AAA"; if (ratio >= thresholds.aa) return "AA"; return "Fail"; }

两个分支共享同一个"AAA → AA → Fail"的判定顺序,只是门槛常量不同,逻辑更收敛。无论采用哪种写法,六条断言的判定结果完全一致。

上下文佐证:352 与 354 如何与 353 咬合

与 352(Contrast Rating 1)的关系:Challenge 352 文件 的输入是"已经算好的对比率字符串",函数签名为getContrastRating(ratio, isLargeText),阈值表与 353 完全相同。它的参考解法就是 353 解法去掉第一行ratio计算后的部分。也就是说,352 练习"分档判断",353 在此基础上补上"如何从亮度算出对比率"。

与 354(Contrast Rating 3)的关系:Challenge 354 文件 的输入是两组 RGB 数组,要求在得到相对亮度后再执行与 353 完全相同的对比率与评级逻辑。其参考解法中包含了 353 省略掉的底层换算,可以完整看到 WCAG 亮度管线:

function toLuminance([r, g, b]) { return [r, g, b].map(channel => { channel = channel / 255; return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; }).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0); }

即:通道值除以 255 归一化 → 逐通道伽马校正(≤ 0.04045 走线性段,否则走 2.4 次幂段)→ 按0.2126*R + 0.7152*G + 0.0722*B的色度系数加权求和。353 题使用的 0.9015、0.1364 这类四舍五入到 4 位的亮度值,正是这一管线的典型输出。三题串起来,就是一条"RGB → 相对亮度 → 对比率 → WCAG 评级"的完整无障碍检测链。

小结

  • 核心公式contrastRatio = (l1 + 0.05) / (l2 + 0.05),l1 恒为较亮方;0.05 偏移用于模拟真实显示环境、压住极端比值。
  • 评级规则:普通文字 7.0/4.5 双门槛,大字 4.5/3.0 双门槛,均取>=闭区间,不满足最低门槛即为"Fail"
  • 实现要点:先统一计算 ratio,再按 AAA → AA → Fail 的高优先级顺序分档;两套门槛可数据化共享同一判定顺序。
  • 系列定位:该题(challengeType 28,即dailyChallengeJs)是 freeCodeCamp 每日 JavaScript 挑战第 353 题,与 352、354 构成无障碍对比度主题的三步递进,完整管线为 RGB → 相对亮度 → 对比率 → 评级。

掌握本题后,你可以把同一套阈值逻辑直接迁移到前端的无障碍自查脚本中:取计算样式里的colorbackground-color,换算出两个相对亮度,再套用本文的分档函数,即可对页面文本做 AAA/AA/Fail 的快速体检。

【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询