一、背景
2022 年 11 月,钉钉多维表进行全新改版。改版过程中,出于解决体验一致性、提升研发效率、降低沟通成本的需求,急需构建一套设计系统(Design System)以供使用。
这套系统由以下三个部分构成:we-design-next 组件库、WCON 图标交付方案、Design Token 视觉语言
本期主要对 Design Token 的使用与实现进行讲解
二、Design Token 概述
为了保证视觉一致性,设计团队制定了一系列原子样式,包含了颜色、圆角、阴影、字体/字号等。并且研发侧也做了对齐实现,保证设计同学和研发同学使用的原子样式是一致的。
我们将这些原子样式称之为 Token,并约定设计侧与研发侧均能且只能使用已有的 Token,不得自行定义新样式,通过这种方式来保障了视觉的统一。
设计侧将这些原子样式做成了 Master Go 组件,以便可以方便的进行复用。研发侧采用了一种巧妙的方案来进行了对齐实现,兼顾了运行性能与研发效率,在实现了方便使用 Token 的基础上,还能进行无重渲染换肤。具体实现方式是什么这里先卖个关子。
这里先介绍下使用方法,后面再聊下具体的实现。也许聪明的你从下文的使用方法中就可以猜出背后的实现原理了。
三、使用方法
步骤1. 获取常量名
打开设计稿,选中要实现的组件,通常会在右侧属性面板有一个常量名
注意:如果发现没有常量名,说明使用了视觉规范以外的属性。请联系设计师进行修正。
步骤2. 在代码中使用
强烈推荐 方式一:CSS 常量
常用于组件的声明中,这种方式实际上获取到的是 CSS 变量(var(--xx-xxx))。
强烈推荐这种方式,CSS 变量的优势在于动态切换主题时,不必触发组件的 React 重新渲染即可完成视觉的转换,性能、体验比较好。
themeCss.color.common_brand
themeCss.radius.small
themeCss.shadow.small
DEMO
import { themeCss } from '@ali/notable-common';
const DemoStyledComponent = styled(div)`
background-color: ${themeCss.color.common_brand};
radius: ${themeCss.radius.small};
shadow: ${}
`;
方式二:JS 常量
常用于 JS / TS 逻辑中,这种方式获取到的是真实的静态值(#ABCDEF),不会动态变化。
因此当动态切换主题后,通常需要手动重新执行渲染逻辑。
theme.color.common_brand
handleClick = useCallback(() => {
controller.setHighlightColor(theme.color.common_brand)
})
四、实现原理
上面的使用方法一有提到我们 Token 的表现形式是 CSS 变量。使用 CSS 变量有很多好处,最主要的是切换主题时,不需要组件重新渲染,用户体验极佳。与之相对的是通过 js 方案实现的主题系统,通常在切换主题时,需要组件进行重新渲染,或者页面整体刷新。
不过使用 CSS 变量对开发效率是有影响的,由于 CSS 变量就是一段纯粹的字符串,因此开发同学在书写样式时,既没有代码提示,也没有代码检查。因为设计侧的 Token 和研发侧的 Token 虽然大体上是一一对应的,但具体的表现形式可能不同,例如设计侧采用 / 斜杠作为分割符,而研发侧是不可能有 / 斜杠的。所以研发同学不仅写起来费力,而且很容易写错。
但你可以看到,我们的研发同学在使用 Token 的时候,完全是按照 JS 的方式来使用的,这种情况下是具有完整的代码提示能力与类型检查的,只需要写一些大概的字符,例如 common_brand 就可以正确匹配。
那我们是如何实现的呢?是人工写了一个超大的对象,一个一个手工写了映射关系吗?例如 const theme = { color: { common_brand: 'var(--color-common-brand)' } };
肯定不是,且不说这种方式的工作量有多么大,这种方式违反了最基本的开闭原则,每当 Token 有变动都需要更新这个大对象,这是不可接受的。
相信有经验的前端同学在看了上面的使用方法之后,基本就已经猜出了实现的原理。这里也就不卖关子了,没错,就是基于 Proxy 将 js 对象转换为了 css 变量。使用这种方式,既可以享受 CSS 变量的高性能,也可以享受 JS 变量带来的自动补全与类型检查,大大提高了研发效率。开发同学在开发时面对的是 JS 对象,而浏览器在解析时,又将其视为 CSS 变量。
具体实现上,我们实现了一个 theme 模块,该模块包含以下内容:
-
主题管理器:ThemeManager
-
主题接口:ITheme。
-
一些实现了主题接口的主题包:例如 Common 主题与 Dark 主题
-
转换逻辑
该模块最终共导出几个常量:themeManager 用来管理主题、theme 用来提供对值的访问、themeCss 用来提供对 Token 的访问、themeCssVarsDeclaration CSS 变量声明集合,用来提供 CSS 变量的值、themeList 用来提供当前启用的主题列表。
themeCss 的转化如下:
/**
* 可变 CSS 变量访问符
*
* 使用该访问符,可以实现动态主题
*
* 用法:css`color: ${themeCss.color.common_blue1}`
*
* 返回值:var(--color-common-blue1)
*/
export const themeCss = new Proxy({}, {
get(target, property) {
const currentTheme = themeManager.getTheme();
if (isThemeProperty(currentTheme, property)) {
return proxyPropertyToCssVarAccessor(property, target[property]);
}
},
}) as Theme;
export const isThemeProperty = (theme: Theme, property: unknown): property is keyof GenericStyleProperty => {
return Reflect.has(theme, property);
};
/**
* 将被代理对象的属性访问符劫持,访问时返回对应的 Css 变量访问符
*
* 访问 foo.bar 时,返回 'var(--foo-bar)'
*
* @param objectName `foo`
* @param object `{ bar: 1 }`
* @returns var(--foo-bar)
*/
export const proxyPropertyToCssVarAccessor = (objectName: string, object: object) => {
return new Proxy(object, {
get(_, property) {
if (typeof property === 'string') {
return `var(--${objectName}-${property.replace(/_/g, '-')})`;
}
},
});
};
在转换后,外界任何对 themeCss 的访问,最终都会被转换为 CSS 变量的形式
themeCssVarsDeclaration 的转换如下:
/**
* 全局 CSS 变量声明
*/
export const themeCssVarsDeclaration = availableThemes.map(generateCssVars).join('\n');
export const generateCssVars = (theme: Theme) => {
const cssVars: string[] = [];
const { name, color, radius, shadow } = theme;
(Object.keys(color)).forEach((key) => {
cssVars.push(` --color-${key.replace(/_/g, '-')}: ${color[key]};`);
});
(Object.keys(radius)).forEach((key) => {
cssVars.push(` --radius-${key.replace(/_/g, '-')}: ${radius[key]};`);
});
(Object.keys(shadow)).forEach((key) => {
cssVars.push(` --shadow-${key.replace(/_/g, '-')}: ${shadow[key]};`);
});
const selector = name ? `[ddTheme=${name}]` : ':root';
const result = `${selector} {\n${cssVars.join('\n')}\n}`;
return result;
};
例如,有如下两个主题:
const defaultTheme = {
color: {
common_blue1: '#5C71DD',
},
};
const darkTheme = {
name: 'dark',
color: {
common_blue1: '#000000',
},
};
最终会转换为:
const themeDeclarations = `
:root {
--color-common-blue1: #5C71DD;
}
[ddTheme=dark] {
--color-common-blue1: #000000;
}
`
开发时,需要做如下工作:
-
在应用的全局样式中引用 themeCssVarsDeclaration,以便完成对 CSS 变量的声明
-
(可选)在应用的初始化过程中,
-
调用 themeManager.mount() 设置挂载 DOM ,以便限制主题作用域。如不设置,则默认挂载为 document.body
-
调用 themeManager.setTheme() 进行主题的设置。如不设置,则使用默认主题
-
-
在需要使用 Token 的地方直接调用 ${themeCss.color.common_brand} 即可
切换主题的实现方法:
当用户切换主题时,themeManager 会将挂载 DOM 的 ddTheme 属性设置为对应的主题名字,这样各个子 DOM 样式中的 CSS 变量会命中含有对应主题名字选择器的 CSS 变量声明,浏览器自动使用新的 CSS 变量值对其 DOM 进行重绘,这样就完成了主题切换
五、方案对比
StyleComponents ThemeProvider
优点:业界知名的成熟方案,本身也契合我们项目中广泛应用的 styled-components 方案
缺点:切换时,由于采用的是 ThemeProvider ,组件会重新渲染。重新进行昂贵的 js 计算。
多 CSS 方案(Token)
优点:常用的成熟方案,准备多套包含不同内容的 CSS,切换主题时加载对应的 CSS。每个主题的 CSS 可以通过 CSS 预处理例如 sass / less 等变量进行替代,再利用 webpack 等插件进行生成。fusion 等提供了相应的支持,使用时只需要将主题包引入到工程中打包即可。
缺点:打包配置复杂、打包出的样式文件有冗余、动态加载 CSS 会有延迟、包体积会增大不少
多 CSS 方案(className)
优点与缺点与上文类似,主要是切换主题的实现方式是切换 DOM 上的 className,
六、结语
CSS 变量是比较成熟的方案了,由于时代的发展,目前的业务也不太需要兼容 IE、Safari 10、Chrome 50 以下这些老古董了,因此我们可以放心大胆的使用 CSS 变量,享受现代前端发展的结果,乘上用户体验飞翔的翅膀。
不过目前业界中对 CSS 变量的使用仍然基本上是采用直接书写 var(--xxx-xx) 字符串的方式,包括 fusion 等业界先驱也是如此,这无疑限制了开发者们的开发效率。
本文的解决方案很好地解决了这个问题,在开发同学使用 CSS 变量时,恢复了前端开发时应有的代码提示和类型检查的能力,让研发效率更上一层楼。