暗黑模式 Code Review & 分享

November 29, 2021

零、前言

10月份我进行了一次暗黑模式的 code review

11月份我进行了一场 Docs 暗黑模式的分享,在此我整理成一篇博客,记录一下

本次分享带来的是 Docs 适配暗黑模式的来龙去脉、简要介绍开发方案、分享项目中踩过的坑、引起的思考,希望给大家带来帮助。此外工程上有一些改动,因此借此机会宣讲后续开发规范。

一、项目背景

随着 iOS 13 和 Android 10 正式发布,暗黑(深色)模式逐渐进入大家视野,各大 APP 纷纷进行适配。MacOS 为用户提供了“浅色”、“深色”两种模式进行切换,暗黑对于程序员来说很容易接受,常见的 IDE 默认主题一般都是深色背景。 暗黑模式的好处:

  1. 省电
  2. 色盲色弱友好
  3. 弱光环境使用友好
  4. 长时间专注用眼,减少视觉疲劳
  5. 提升品质感与沉浸感

效果预览:

二、开发过程

1、沟通并确定排期

回顾项目开发的过程:

由于需求排期较长,在项目初期进行了细化的排期

9.29-10.12 色值排查与替换(使用脚本),整理用到的组件(包括 不规范的色值、单色/多色 icon、图片 )
10.13-10.18 组件样式改造(基于 antd 本地化分支)
10.19-10.25 icon、图片整理与替换(可能延期2天)
走查与测试:
10.13,设计师开始色值走查
10.25,设计师开始全局走查
11.01,设计师走查延后两周
11.8-11.17 共计8PD,前端 0.5 * 1 人力支持,设计走查 0.2 * 3 人力
11.17-11.19 测试介入
11.24 全量上线代码

关键文档:组件梳理文档,分模块梳理各个页面用到的 单色 icon、多色 icon 和图片,三个部分

2、进行工程改造

1. 色值切换原理

使用了 css variable 修改主题色,经调研符合目前系统的兼容性要求

初始化时,在 js 逻辑中根据 isKim & 媒体查询,给 html 标签添加 className: .kim-dark-mode

监听 media 的 change 事件,回调时修改 html 标签的 className

// config/public/css-variables.ejs
:root {
  --Gray01: #ffffff;
}
:root.kim-dark-mode {
  --Gray01: #17181A;
}

// packages/ui/style/colors.less
@Gray01: var(--Gray01);

// packages/ui/style/utils.less
// 媒体查询 暗黑模式特殊判断逻辑
// 传入代码片段, IMP: 注意外层不能嵌套选择器
.darkRules(@rules) {
  html.kim-dark-mode {
    @rules();
  }
}

没有使用 less 的 modifyVars 方法,modifyVars 方法基于 less 在浏览器中的编译,工程改造成本较高

2. 构建色值变量

使用 css variables 的形式定义变量

基于 colors-src.ts 文件构建出三个文件(npm run buildColor)

  1. /config/public/css-variables.ejs —— css variables
  2. /packages/ui/style/colors.less —— less variables
  3. /packages/ui/style/colors.ts —— ts variables

ts 使用方法如下(不推荐,可能存在副作用):

// packages/ui/style/colors.ts 源文件
import themes from './color-src';
import { isKimDark } from '@is-docs-packages/components/common-utils/dark';
const mode = isKimDark ? 'dark' : 'light';
const themeColors = themes[mode];

export const Gray01 = themeColors.Gray01;

// xxx.tsx 使用侧
import { colors } from '@is-docs-packages/ui/style/colors';
colors.Gray01

3. 组件改造:

  1. 基于上述色值切换原理,使用色值收敛脚本替换颜色,自动实现 业务组件 暗黑模式切换

    原理:编写 node 脚本,遍历项目各个目录中的文件,使用正则进行字符串替换

    css变量中,十六进制色值修改为 @Gray01 的形式;rgba 修改为 rgba(var(—Gray01RGB), 0.2); 的形式。目前 docs 项目都迁移成了变量与 fade 形式 (除知识库),改色值比较容易

  2. 基于 ant design 本地化,修改变量文件,实现 ant design 组件 的暗黑模式切换

    ant design 的色值未支持暗黑模式,为了实现暗黑模式,需要将 ant design 的色值变量全部替换为支持切换暗黑模式的色值

    Docs 项目使用了很多 ant design 的组件(首页居多),以前使用样式覆盖,修改不方便。本地化后的目录是组件库项目的 【packages/ui/docs-antd】目录,以后修改 ant design 组件的基础样式都在这个目录下修改

    本地化后,适配暗黑模式更方便

    ant design 色彩系统使用了函数计算,根据某一个基准色值生成一条渐变色板,优势是 样式变量高度收敛,劣势是 定制性比较差,不够灵活,3.x后色板算法没再更新,计算得出的某些色值 无法达到足够适合阅读的对比度 。关于 ant design 色板生成算法 的原理、现存问题,有兴趣的同学可以看我的博客

    我们 Docs 项目有比较强的定制性需求,因此色板使用的是设计师定义的色值,但需要设计师维护所有色值,更新色值相对麻烦一些

    @primary-1: color(~`colorPalette('@{primary-color}', 1) `); // replace tint(@primary-color, 90%)
    @primary-2: color(~`colorPalette('@{primary-color}', 2) `); // replace tint(@primary-color, 80%)
    @primary-3: color(~`colorPalette('@{primary-color}', 3) `); // unused
    @primary-4: color(~`colorPalette('@{primary-color}', 4) `); // unused
    @primary-5: color(~`colorPalette('@{primary-color}', 5) `;
    @primary-6: @primary-color; 
    @primary-7: color(~`colorPalette('@{primary-color}', 7) `); // replace shade(@primary-color, 5%)
    @primary-8: color(~`colorPalette('@{primary-color}', 8) `); // unused
    @primary-9: color(~`colorPalette('@{primary-color}', 9) `); // unused
    @primary-10: color(~`colorPalette('@{primary-color}', 10) `); // unused
    // 转换成
    @primary-1: @Blue09;
    @primary-2: @Blue08;
    @primary-3: @Blue07;
    @primary-4: @Blue06;
    @primary-5: @Blue05;
    @primary-7: @Blue04;
    @primary-8: @Blue03;
    @primary-9: @Blue02;
    @primary-10: @Blue01;

    转换过程使用了 tinyColor 第三方库的 readability 函数,该函数返回两种颜色之间的对比度,需要先确定该色值所属的色板,相对来说更加准确,我的策略是先替换再走查

    另一种方式: 之前第一版收敛色值时策略是先走查再替换:使用的是 deltaE 来计算颜色的相似性,梳理了各工程用到的色值,和设计师给出的色板进行了对应,梳理页面如下:

    设计师对梳理的结果进行微调,确定最终替换脚本的规则,附:

    在 js 中计算 deltaE: https://stackoverflow.com/questions/54738431/calculate-deltae94-in-javascript

    第三方库 chroma 也有实现这个功能: https://gka.github.io/chroma.js/#chroma-deltae

    4. 通过 context 实现 tsx 中树节点更新

    更新的一般包括图片、tsx 中的色值

    组件库 is-docs-components/packages/components/component-ctx.tsx 文件提供了暗黑模式的 context 实例,包含 isDark: boolean; theme: 色值表 这两个变量,Docs 首页项目在 App.tsx 中使用该 context,并提供 Provider

    其他工程没有提供 Provider,则取到的 isDark 为 false,即不会影响未接入暗黑模式的工程

    5. icon 适配

    原理:

    iconfont.cn 项目中,暗黑模式的命名是在对应的浅色模式 icon 后面加 “_dark” 后缀

    执行脚本【npm run buildIcon(scripts/dark-icons-build.js)】

    从 iconfont 平台下载后,读取下载的 iconfont 文件,根据每个 svg 标签的 key 值是否包含 “_dark”后缀 打包成两个 iconfont.js 文件,初始化时根据 isKim & media isDark 确定引入的是 fontxxx.js 或者 fontxxx_dark.js

    媒体查询监听暗黑模式 onchange 时,删除 html 中对应的 svg sprite 标签,拼接新 script 标签(iconfont.js 文件)并 append 到 dom 中:

    // 执行 dark-icons-build.js 时,给 svg 标签加上 xmlns:theme 自定义属性,以便通过选择器查找
    document.querySelectorAll(`svg[xmlns\\:theme=${isDark ? 'light' : 'dark'}]`)[0].remove();
    const script = document.createElement('script');
    script.src = isDark ? PC_ICON_URL_DARK : PC_ICON_URL;
    document.body.appendChild(script);

    Icon 的 key 值不变,但对应的 Symbols 中引用的 iconfont path 已经改变,从而实现切换 light/dark 模式的 icons

    封装 <IconFont /> 组件,color 属性通过 props 的形式传入色值的 key 值,传入错误的 key 值在开发环境会报 warning

    推荐通过 className & less 写色值,而不是通过 color props 传入

    <BasicActiveRectIcon
    type="k-docs-icon-cebianshouqi"
    style={{ flexShrink: 0 }}
    color="red"
    />
    <IconFont
    style={{ fontSize: '16px', color: colors.Gray09 }}
    color="Gray09"
    />

    6. 图片

    通用图片收敛到组件库的 packages/components/assets/dark[light] 目录

    两个目录: 根目录 & dark

    根据 context 中的 isDark flag,使用相同/不同图片

    import nullImg from '@src/assets/light/null.png';
    import nullImgDark from '@src/assets/dark/null.png';
    import useThemeContext from '@is-docs-packages/hooks/useThemeContext';
    
    const [isDark] = useThemeContext();
    
    <Empty image={isDark ? nullImgDark : nullImg} />

7. 测试与上线

  1. 测试与走查方案:

    a. docs 测试环境配置灰度,由于 Kima 包尚未准备好,docs 首页走查时前期是在 web 端走查,注释了 isKim 判断是否在 kim 内的逻辑

    b. 测试打出暗黑模式专用的 Kima 包,docs 标签页使用的是 docs 测试环境

  2. 灰度方案

    a. Kim 端计划在12月发布灰度版本,灰度范围逐步扩大,Docs 与 Kim 保持灰度范围一致

    b. 新增的需求使用色值变量,自动实现暗黑模式,需求测试/走查时应注意回归暗黑模式样式

  3. 上线方案

    a. 11.25 全量上线暗黑模式代码,目的是代码 尽早 合入master,增量的业务尽早适配暗黑模式的写法,否则新需求代码合进来都需要为适配暗黑模式做转换

    b. 上线之后通过 分享 的形式对齐工程与规范的变动

    c. 上线后灰度范围 与 Kim 保持一致,直到 kim 全量上线

三、编码规范

内部文档:GUI 一致性 2.0,不便对外展示,列举出部分目录,大体能看到包含的内容:

  1. 规范说明
  2. 目录结构
  3. less 样式编写
    a. less 文件中变量的使用方式不变
    b. 后续需求需替换 fade 写法,否则构建时报错
    c. 使用色值变量文件 (colors.less) 中的色值
    d. 使用场景色
    e. 摹客平台新增规范
    f. 使用 vscode 插件
    g. 暗黑模式特殊判断
    h. 其他样式规范
  4. 色板更新与使用
  5. iconfont 更新与使用
  6. 图片使用
  7. 修改组件库样式
    a. 修改 ant design 组件的默认样式
    b. 历史遗留:修改自定义 ant design 组件样式
    c. 组件库的正确使用姿势:

    场景色如下:

    PrimaryColor: colorBase.light.Blue03, // 主色 (@Blue03)
    CardBg: colorBase.light.Gray01, // 卡片背景色
    CardNestedBg: colorBase.light.Gray02, // 卡片内嵌套区域背景色
    // 主按钮 - primary
    ButtonPrimaryBg: colorBase.light.Blue03, // 主按钮背景色
    ButtonPrimaryText: colorBase.light.Gray01, // 主按钮文本色
    ButtonPrimaryBgHover: colorBase.light.Blue04, // 背景色 - hover
    ButtonPrimaryTextHover: colorBase.light.Gray01, // 文本色 - hover
    ButtonPrimaryBgActive: colorBase.light.Blue02, // 背景色 - active
    ButtonPrimaryTextActive: colorBase.light.Gray01, // 文本色 - active
    ButtonPrimaryBgDisabled: colorBase.light.Blue05, // 背景色 - disabled
    ButtonPrimaryTextDisabled: colorBase.light.Gray01, // 文本色 - disabled
    // 次级按钮 - secondary
    ButtonSecondaryBg: colorBase.light.Gray01, // 次级按钮背景色
    ButtonSecondaryText: colorBase.light.Gray11, // 次级按钮文本色
    ButtonSecondaryBgHover: colorBase.light.Gray02, // 背景色 - hover
    ButtonSecondaryTextHover: colorBase.light.Gray11, // 文本色 - hover
    ButtonSecondaryBgActive: colorBase.light.Gray03, // 背景色 - active
    ButtonSecondaryTextActive: colorBase.light.Gray11, // 文本色 - active
    ButtonSecondaryBgDisabled: colorBase.light.Gray03, // 背景色 - disabled
    ButtonSecondaryTextDisabled: colorBase.light.Gray09, // 文本色 - disabled
    // 幽灵按钮 - ghost
    ButtonGhostBg: colorBase.light.Gray01, // 幽灵按钮背景色
    ButtonGhostText: colorBase.light.Blue03, // 幽灵按钮文本色
    ButtonGhostBgHover: colorBase.light.Blue09, // 背景色 - hover
    ButtonGhostTextHover: colorBase.light.Blue03, // 文本色 - hover
    ButtonGhostBgActive: colorBase.light.Blue08, // 背景色 - active
    ButtonGhostTextActive: colorBase.light.Blue03, // 文本色 - active
    ButtonGhostBgDisabled: colorBase.light.Gray03, // 背景色 - disabled
    ButtonGhostTextDisabled: colorBase.light.Gray09, // 文本色 - disabled
    
    ContainerBorder: rgbaString(colorBase.light.Gray11, 0.12), // 容器 border
    DividerColor: rgbaString(colorBase.light.Gray11, 0.08), // 全局分割线
    SelectColor: rgbaString(colorBase.light.GrayBlue01, 0.08), // item select
    HoverColor: rgbaString(colorBase.light.GrayBlue01, 0.05), // item hover
    ModalMaskBg: rgbaString(colorBase.light.Gray14, 0.45), // modal 半透明遮罩
    CardBoxShadow: rgbaString(colorBase.light.Gray11, 0.04), // 卡片阴影
    TooltipBg: colorBase.light.Gray11, // tooltip 背景色
    TooltipText: colorBase.light.Gray01 // tooltip 文本色

四、思考

1、排期困境

  1. 中间插入需求

    a. toast 样式改版——中间插入消化

    b. 线上样式问题—— 样式迭代,量大,本期不改

  2. 设计师要求增加排期(toast)

    a. 使用甘特图梳理排期;

    b. 拆解大任务为小任务,每个任务定具体的交付时间节点,到节点时或邻近节点时确认;

    c. 了解各方诉求,寻求合适的方案

2、沟通的重要性

  1. 与组内同学沟通

    a. 已建立的规范 —— 色值一致性

    b. 提高效率 —— less 插件 c. ant design 本地化 d. 移动端入侵 e. 上线前与工程 owner 沟通,确定下周上线的需求,提前合并代码(危)

    f. 进行 code review 或 workshop ,聆听大家的建议

  2. 与其他部门沟通

    a. 设计师高频沟通

    i. 开发、测试方案 ii. 摹客上面设计规范修改

    b. 测试、产品

    i. 11.16 开会确认当前进度与测试方案 暗黑模式进度

    ii. 提前与 qa 确认测试时间及排期

    c. Kim 端

    i. 暗黑模式配置的开关能力如何通知 Docs

    ii. Kim 端的方案与规范,统一认知与目标

    iii. 测试时打包

    iv. 沟通灰度与上线方案

3、上线的坑

其他工程接入包含暗黑模式的组件库代码,需要调整代码

  1. 主要是 fade 转换成 rgba,但是项目多(首页/知识库/文档/表格/h5 加上各自调试子模块,共10项目),每个项目都要构建 & 发测试灰度 & 走查 & 测试,比较麻烦
  2. taro 项目没接入 ejs ,使用了 ejs,可以优化成引入 less;taro 项目接入
  3. 文档 “外部” 项目,webpack 版本落后,没引用 ejs,导致上线后丢失了 css variables 定义,已修复,后续统一使用 less 文件
  4. 其他 “外部” 项目,也需要合入代码
  5. ppt 项目工程结构和其他项目不同,一开始没考虑到,延后接入

五、todo与讨论

  1. 组件库需要随着设计规范更新,后面组件样式优化需求做
  2. 样式隔离,现在组件样式存在很多“负负得正”场景,(重点需求、重要组件针对性地优化)
  3. lint 工具
  4. img key 封装
  5. css-variables.ejs 挪到 package/ui/style/variables.less