在至今为止的前半段职业生涯中,我和团队中的其他设计师一起,花了许多心思于微信广告的后台系统设计语言 —— AD UI。
去负责设计语言,对于一个开发者而言,是看着平常、却不可多得的重要项目。我想要在这篇文章中梳理一下我作为开发负责人的历程:关于我从哪里开始,关于如果可能的话,还想往哪里去。
在开始设计语言(或说组件库)的项目前,我们的界面设计是这样的:
旧版界面
旧版界面
各种功能、信息之间没有清晰的分级,一切都是平的。经过了多年以后,我们的组件才变成这样:
AD UI Components
由于我并不负责 UI 设计,如果你对设计语言的 UI 设计感兴趣,也可以去看看 这个人的文章。
在工程上,当时的组件设计同界面设计一样,都是非常混乱的。
我对设计语言的价值观,主要分为设计、组件、效能、流程这四个方面。我将从这四个大方面阐述我对设计语言的理解,以及我的具体工作。
我们以系统字体的方案对字体方案做了标准化。最终的回退方案是:
font-family:
/* 西文 */ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu,
"Helvetica Neue", Helvetica, Arial, /* 中文 */ "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei UI", "Microsoft YaHei", "Source Han Sans CN", sans-serif;
font-family:
/* 西文 */ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu,
"Helvetica Neue", Helvetica, Arial, /* 中文 */ "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei UI", "Microsoft YaHei", "Source Han Sans CN", sans-serif;
在现在所有的微信广告系统中,我们仍然延用了这一套字体方案。
系统字体
对数字进行字体筛选
要写出这一条 CSS 规则,确实经历了一些过程。对更多过程的阐述,可以阅读 《我们认真地想了想 font-family》。
当时我在腾讯内部 KM 发布这篇文章时,也直接推动了 KM 平台自身的字体方案更新。让更多的设计师与开发者关注到这样一个看似简单、细微的问题,也是我的小贡献了。
对动效的迭代,我们也经历了几个阶段。为了缩减篇幅,这里只说最终阶段吧。我将它们统称为 Functional Motion —— 功能优先的动效设计。
首先,我梳理了动效所存在的问题。在这个阶段,我虽然还没有明确要做什么,但基本明确了要避免什么。
随后,我提出了动效的价值观:
明确而可靠:当操作界面时,你的交互是可预期的,你可以自信地完成任务:
明确而可靠:对于在表现上类似的元素,我们需要维持运动方式的一致性,因为一致性是具有语义的。当元素传达相同的含义或执行相同的功能时,使用相同的动作,反之亦然:
懂得分寸的:动效在大部分情况需要是敏捷的,帮助你保持流畅,增加对于变化的感知速度:
懂得分寸的:动效需要是有意义的,不做过多的修饰而大张旗鼓地吸引你的注意力:
“自然”,则是动效中最有特色的部分。在前期讨论中,我和设计师们很快达成了对自然的一致认可。然而,我们所说的自然,可能包含不同的意思:流畅不卡顿,符合认知,契合整个业务场景,模拟现实等等。
随后,我们更具体地将自然收拢为:物理性。
我将物理性中的质量、弹性、阻尼、惯性、加速等属性,转化为了交互或 UI 变量:长按 / 短按,大尺寸 / 小尺寸,长距离 / 短距离,错误反馈 / 成功反馈等。从属性到设计,最终构成了体验。
这真的挺有趣的。比如我将长按和短按的交互区别,映射到是否应该使用具有回弹的物理曲线:
比如,小尺寸的元素的运动时间应该更短,因为它们的视觉重量更轻;比如,错误反馈应该回弹,因为其具有更大的“质量”;再比如对于 Tab 切换,如果是长距离的切换,则应该使用具有回弹的物理曲线。
关键在于,如何界定距离是长是短?—— 答案是 350px。
对更多细节的展示,可以阅读 《Functional Motion:UI 组件库的动效设计细节》。
对于组件的开发,我们经历了以下几个阶段:
组件,当然是最为核心的开发内容。然而事到如今,即便像 Table
、DatePicker
这样的组件确实花了非常、非常多的时间,但又觉得在如今这个年代重提难免又有些过时,比如内部驱动与外部控制、比如 Class Component 的开发历史。
承接上文的 Functional Motion,对于有回弹的动效,我使用了 CSS linear()
function 来模拟物理曲线。对于存在兼容性问题的浏览器,我使用了 cubic-bezier()
来做回退。
--adui-motion-ease-bounce: cubic-bezier(0.36, 1.46, 0.38, 1.01);
--adui-motion-ease-bounce: linear(
0,
0.002,
0.008,
0.018,
0.032 2%,
0.072,
0.129 4.3%,
0.186 5.3%,
0.257 6.5%,
0.551 10.9%,
0.675 12.8%,
0.791,
0.885 16.8%,
0.926,
0.963,
0.995,
1.023,
1.048,
1.068,
1.084,
1.097 25%,
1.103,
1.108,
1.112,
1.114,
1.115,
1.115,
1.113,
1.111 31.1%,
1.104 32.5%,
1.094 34.1%,
1.04 41.2%,
1.027,
1.017 44.9%,
1.007,
0.999,
0.993,
0.989 53.4%,
0.987 56.4% 59.9%,
0.998 73.1%,
1.001 80.4%,
1.002 87.4%,
1
);
--adui-motion-ease-bounce: cubic-bezier(0.36, 1.46, 0.38, 1.01);
--adui-motion-ease-bounce: linear(
0,
0.002,
0.008,
0.018,
0.032 2%,
0.072,
0.129 4.3%,
0.186 5.3%,
0.257 6.5%,
0.551 10.9%,
0.675 12.8%,
0.791,
0.885 16.8%,
0.926,
0.963,
0.995,
1.023,
1.048,
1.068,
1.084,
1.097 25%,
1.103,
1.108,
1.112,
1.114,
1.115,
1.115,
1.113,
1.111 31.1%,
1.104 32.5%,
1.094 34.1%,
1.04 41.2%,
1.027,
1.017 44.9%,
1.007,
0.999,
0.993,
0.989 53.4%,
0.987 56.4% 59.9%,
0.998 73.1%,
1.001 80.4%,
1.002 87.4%,
1
);
由于组件的多状态,我们在 SCSS 中使用了大量的 mixin
和 function
。比如 Button
的样式由以下的代码驱动:
@mixin base-type($type) {
@if $type != "normal" {
.#{$prefix}-left-icon,
.#{$prefix}-rightIcon {
fill: #fff;
}
} @else {
.#{$prefix}-left-icon,
.#{$prefix}-rightIcon {
fill: var(--gray-800);
}
}
color: map.get($colors, #{$type}#{-text});
background-color: map.get($colors, #{$type}#{-bg});
@if $type != "normal" {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
0 0 0 1px map.get($colors, #{$type}#{-bg});
} @else {
box-shadow: map.get($colors, normal-border);
}
}
@mixin base-type($type) {
@if $type != "normal" {
.#{$prefix}-left-icon,
.#{$prefix}-rightIcon {
fill: #fff;
}
} @else {
.#{$prefix}-left-icon,
.#{$prefix}-rightIcon {
fill: var(--gray-800);
}
}
color: map.get($colors, #{$type}#{-text});
background-color: map.get($colors, #{$type}#{-bg});
@if $type != "normal" {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
0 0 0 1px map.get($colors, #{$type}#{-bg});
} @else {
box-shadow: map.get($colors, normal-border);
}
}
AD UI 在键盘可访问性上做了很多优化工作:
早期工程项目只提供了 CommonJS 的打包,因为还需考虑 IE11 的兼容性。之后,我们补充了 ES Tree Shaking:
在补充完基础的 ES Tree Shaking 之后,打包体积从 660.31kb
降为 484.02kb
,然而 Icon
组件的体积非常大,占到了总包的 59%.
Icon
不能被 Shake 掉,是因为我在设计时,实际把所有图标放在一个资源文件中管理。行业内通常的做法是将每一个图标分拆成单独的组件,优点在于能利用 Tree Shaking,但对于我而言不希望发生 break changes,并且单独使用组件非常不方便,也不能利用 TypeScript 的自动参数提示。
import { IconAdd, IconCancel } from "adui";
<IconAdd />
<IconCancel />
import { IconAdd, IconCancel } from "adui";
<IconAdd />
<IconCancel />
直到今天,我还是认为上面的写法是一种不足够优雅的妥协。而我还是希望:
import { Icon } from "adui";
<Icon icon="add" />
<Icon icon="cancel" />
import { Icon } from "adui";
<Icon icon="add" />
<Icon icon="cancel" />
我的目标是:在保证 API 优势的前提下,结合 Webpack/Vite 生态,进行编译时语义分析,动态地将没有用到的图标资源剥离出打包结果:
Webpack 思路:
首先,JS 文件首先经过 babel-loader
;
接着,编写 adui-icon-loader
,该 loader 用 @babel/traverse
分析 ast 提取所有用到的图标名称,写入到 adui-icon-reduced.js
临时文件。在这一步需注意 React 16 与 React 17 的 runtime 语法不同,前者为 React.createElement(Icon, { icon: "add" })
,后者为 _jsx(Icon, {icon: "add"})
;
// adui-icon-loader.js 核心代码
module.exports = function (source) {
// 1. 处理自定义配置
var options = loaderUtils.getOptions(this)
// 2. 转换 source 为 ast
var ast = parser.parse(source, {
sourceType: "module",
plugins: ["dynamicImport"],
})
// 3. 分析 ast 语义
traverse(ast, {
CallExpression: function (path) {},
})
// 转换 ast 导出
return core.transformFromAstSync(ast).code
}
// adui-icon-loader.js 核心代码
module.exports = function (source) {
// 1. 处理自定义配置
var options = loaderUtils.getOptions(this)
// 2. 转换 source 为 ast
var ast = parser.parse(source, {
sourceType: "module",
plugins: ["dynamicImport"],
})
// 3. 分析 ast 语义
traverse(ast, {
CallExpression: function (path) {},
})
// 转换 ast 导出
return core.transformFromAstSync(ast).code
}
最后,编写 adui-icon-plugin
,在整个构建周期中,负责文件创建、替换、修改、删除等操作。
Vite 的思路也是一样的,而且更为简单。
最终,对于这个项目,Icon
的体积从 287.96kb
降为 8.08kb
,缩小 97.1%。
AD UI 的每个组件都会默认引入样式文件:
// Button.tsx
import "./style"
const Button = () => {}
export default Button
// Button.tsx
import "./style"
const Button = () => {}
export default Button
Vite 与 Webpack 的表现不一致。在 Vite 中,样式会作为副作用被全量引入。最终 CSS 的体积超过 200kb
:
有了 Icon
的经验,同时也参考社区 lodash
按需插件的启发后,需要在构建时,把统一的一行引用改为四行的单独引用:
import { Button, Icon } from "adui"
⬇️
import Button from "adui/es/button"
import Icon from "adui/es/icon"
import "adui/es/button/style"
import "adui/es/icon/style"
import { Button, Icon } from "adui"
⬇️
import Button from "adui/es/button"
import Icon from "adui/es/icon"
import "adui/es/button/style"
import "adui/es/icon/style"
核心代码的思路和 Icon
类似,不同的需要修改源文件的 ast,最后编译输出。最终 CSS 的体积从 200kb
降为 21.21kb
。
最后一点,再进行了函数式组件的重构升级。
通过在编译后,函数式组件会省去 class 组件中的一些辅助函数,使得整个编译包会比之前小 6%。
function _classCallCheck(instance, Constructor) {}
function _defineProperties(target, props) {}
function _createClass(Constructor, protoProps, staticProps) {}
function _possibleConstructorReturn(self, call) {}
function _assertThisInitialized(self) {}
function _getPrototypeOf(o) {}
function _inherits(subClass, superClass) {}
function _setPrototypeOf(o, p) {}
function _classCallCheck(instance, Constructor) {}
function _defineProperties(target, props) {}
function _createClass(Constructor, protoProps, staticProps) {}
function _possibleConstructorReturn(self, call) {}
function _assertThisInitialized(self) {}
function _getPrototypeOf(o) {}
function _inherits(subClass, superClass) {}
function _setPrototypeOf(o, p) {}
做设计语言,或者做 B 端 UI 的开发,永远都绕不开 D2C 这个领域。我在这个领域的工作,主要是在 Figma 中进行的。这件事靠谱的最主要原因是 Figma 的组件系统和 React Props 是有一些类似的。尤其是当 Figma 引入了 Auto Layout 之后,更是如此。
而我刚开始尝试的时候,实际上 Figma 还没有 Auto Layout。当时为了解决布局问题。当时我提出一套布局规范,其强迫设计师重新命名 Frame 成 Container
,才能在代码中正确地解析出 Flex 布局。我认为这确实说明 Figma 的开发者实际上在和我思考类似的问题。
一开始提出的布局规范
随着 2020 年末 Auto Layout 的普及,基于 Figma 的完整解决思路得以成型。类比 Webflow 这样的工具所需的核心功能:
我的工作内容则为:
最终的效果:
基于 Figma 的 D2C
从单一的组件,到非常完整的页面,设计师在使用了 Auto Layout 后,我只需要调整一点点地方,就可以完整地实现导出。
我的职责好像从前端重构变成了优化 Figma 布局。
当然,这时候导出的代码质量还是不够好,要么是内联样式,要么是随机的类名:
<div className="frame_67111766">
<div className="frame_67111767">
<span className="text_67111768">{user.name}</span>
<div className="frame_74712515">
<span className="text_67111769">AppID:</span>
<span className="text_74712514">{user.appid}</span>
</div>
</div>
</div>
<div className="frame_67111766">
<div className="frame_67111767">
<span className="text_67111768">{user.name}</span>
<div className="frame_74712515">
<span className="text_67111769">AppID:</span>
<span className="text_74712514">{user.appid}</span>
</div>
</div>
</div>
这也成为了我为什么向团队引入原子类的重要的原因之一,具体会在下面的原子类专项中继续阐述。
而现在(2024.07.11)Figma 已经发布了 用 AI 自动 Auto Layout 以及 Dev Mode 接入组件库代码 的功能!感慨如果现在还在做设计语言相关的工作,我一定会继续优化这个流程。
正是因为 Figma 在自己的生态中不断发展和设计语言的联系,我才越来越认定 D2C 是一个正确的命题。而今,随着如 Vercel 的 V0 这样的 AI 工具的不断涌现,我认为设计语言的开发重心一定还是 D2C。
能导出的,我们就导出。不能导出的,也要尽可能提效。我做了一个 Figma 处理样式、并拷贝出来的插件。即便到现在 Figma Dev Mode 还是不好用,所生成的样式质量还是很低。
插件的功能丰富全面,支持:
absolute
,font-family
等无用规则;Copy CSS + React Style
现在,这个插件在社区中已被 5.5k 人使用。也曾有人在社区中对我表示感谢:
I'm a React.js Developer this is a lifesaver for me. Thanks a lot 💙😍
可是我应该不会再更新了...原因不仅仅是因为现在不做设计语言了,更重要的是,我还是比较看好 Figma Dev Mode 的发展的。文章最后我也会谈一下关于 Figma 的一些观点。
在真正为团队 引入原子类 之前,我还是 做了充分的输入和思考 的。最终认为,原子类确实非常适合我们团队:
w-3
的类名是不生效的,因为设计规范中不存在这个尺寸的定义。
理想的原子类通过设计规范衍生和开发流程发布。正是因为我们是设计团队,我们通过设计规范,就能更好地利用原子类:
300kb
缩小至 18kb
。如下左图所示,在我们的业务工程中,原先我们一个 JS 对应一个 CSS 文件:
我和设计师很早就开始尝试做图表的配色方案。我们尝试为数据可视化提供有效的基础色彩方案,为数据浏览创造更好的体验:
Colorpod
设计师起了一个名,叫 ColorPod。我做了一个简单的 logo 动画:
现在,这个工具已经交给团队内的其他同事维护,加入了许多 features,甚至还有 AI,不变的是名字还是叫 ColorPod。
早期我和设计师接触到 React Sketchapp,觉得用代码作为 source of truth 管理设计资产是一件值得尝试的事情。于是我们就把 AD UI 接入了。
下面的录屏是设计师在手动修改代码,然后在 Sketch 中预览的过程。2024 年的我看到这个录屏,想象 2018 年(应该是)的时候,这个设计师当时应该玩得挺开心的。
AD UI + React Sketchapp
后来我还基于它做了一个 Sketch Plugin。从数据来源(当时是 Google Sheets)一键生成我们组的周报,省去了同事排版的烦恼:
React Sketchapp 周报
当然,我们的 AD UI 工作流最后也没有优化下去,就像 React Sketchapp 也没有发展下去,就像 Sketch 自身都快发展不下去。
我觉得说 React Sketchapp 是玩具,说我们喜欢玩,这些都没错。但是我想念那种一起玩的感觉。有人陪你玩,才会做出有意思的好东西。
我更偏向于称 AD UI 为设计语言,而非组件库。一个很重要的原因是,设计语言就应该孵化自设计团队,这才是正确的方向。如同我在原子类专项中所说,思考的路径应该是 先设计后工程。
同样地,比如我们经常需要评估,哪些组件属于基础组件,哪些属于业务组件(或说区块)?应当由谁来评估?—— 我认为是设计团队。还是因为先设计后工程。
Design Engineer 必须从设计团队、设计规范出发,才能够更好地帮助设计语言落地。
随着 AI 的发展,目前常见的 D2C 方式是和 AI 工具通过自定义的 DSL 沟通。通过 AI 的自然语言理解,加上 DSL 的规范,可以实现相对准确的 D2C。不对,这已经不是 D2C 了,是 T2C(Text2Code)。
加之 Figma Dev Mode 的发展,我非常看好 Figma 在设计语言这一领域的发展,因为 Figma 拥有设计语言。相比 Vercel 的 V0,如何用自己团队的设计语言生成 UI 代码,一直是一个未解决的问题。但 Figma 做这件事,就有可能。
如果单说组件的开发(或 UI 的开发),上限是存在的,而且不高。这背后更重要的问题是:UI 本身是没有意义的。我们作为工程师,经常容易陷入 UI 本身 —— 我们的工作完全就是打造正确的 UI。然而 UI 本身并没有意义。有意义的是 UI 背后的业务逻辑,以及其实际影响的用户体验。
组件库需要摆在其服务的业务中被衡量,唯此才能解决更高维度的问题。