公司的主题工具项目临近尾声.我接手项目时已经开发了一部分,已经有很多代码积重难返,公司也有第三方依赖的限制,因此我对在这个项目的工作不甚满意;但我认为在主题工具的设计和开发上的相关经验值得整理和复盘.
仓库
主题生成方式
graph TD
主题theme-.对主题的变更.->映射themeMap-.对映射的变更.->主题变量themeVars
- 组件使用主题变量调整展示结果
- 主题与映射可以生成主题变量
- 主题和映射可以分别变更,并选择是否应用变更
- 变更包括主题和映射增、删、改
主题体系的设计
@/lib/Theme下包含了主题体系的内容,经过二次封装后可以供给项目.主题体系完全暴露主题变量、映射及其的变更,并提供了若干函数用于操作它们.
主题元和主题变量 @/lib/Theme/ThemeVar.ts
主题变量是名称与主题元的映射关系.主题元是对应名称所指示的描述和值.
/** 主题元值基础类型 数字、字符串(可被识别为颜色) */
type ThemeItemBaseValue<T> = number | string | T
/** 主题元值类型 基础类型或它的嵌套数组 */
type ThemeItemValue<T> = ThemeItemBaseValue<T> | ThemeItemValue<T>[]
/** 主题元 */
type ThemeItem<T> = {
/** 主题元描述 */
desc: string,
/** 主题元值 */
value: ThemeItemValue<T>
}
/** 主题 */
type ThemeVar<T> = Map<string, ThemeItem<T>>
主题元值的类型采用泛型,这使得它可以在字符串和数字的基础上进行拓展
约定
- 主题元名称
合法名称必须经过正则表达式/^@([a-z]+-)*[a-z]+$/的校验.'@'开头,后续是'-'隔开的小写字母,例如@primary-color. - 对主题元名称的拓展
符合正则表达式/^@([a-z]+-)*[a-z]+(-G[0-9])?(-A[0-9]{1,3})?$/的字符串被视为合法主题元的拓展.以@primary-color-G3-A100为例,如果@primary-color的值是合法的颜色字符串,它的含义是以@primary-color为主色生成的系列色的第3个颜色,且透明度为100.
示例
Map
{
'@version' => { desc:'版本号', value:'1' },
'@theme-name' => { desc:'主题名', value:'example' },
'@primary-color' => { desc:'主题主色', value:'#4096ff' },
'@title-font-size' => { desc:'文本主字号', value:24 }
}
主题变量变更
主题变量变更是主题元名称与变更结果的映射
/** 变更类型 */
type ThemeVarEdit<T> = { type: 'add', value: ThemeItem<T> }
| { type: 'delete' }
| { type: 'valueChange', value: ThemeItemValue<T> }
| { type: 'descChange', desc: string }
| { type: 'change', value: { value: ThemeItemValue<T>, desc: string } }
/** 主题变量变更 */
type ThemeVarEditRecorder<T> = Map<string, ThemeEdit<T>>
主题变量变更的约定
- 映射的key是合法主题元名称,但不必包含在主题中.
- 对变更的解释
假设存在变更key => { type, value(或desc) }- 若type为'delete',则主题中同名主题元被视为删除;
- 若type为'valueChange'且key在主题中,则value被视为该主题元的新值;
- 若type为'descChange'且key在主题中,则desc被视为该主题元的描述;
- 若type为'change',视为同名主题元被value完全替换;
- 若type为'add',则视作主题中增加映射
key => value.
主题变量变更示例
变更
Map
{
'@version' => '1.0.1'
}
应用变更的主题
Map
{
'@version' => { desc:'版本号', value:'1.0.1' },
'@theme-name' => { desc:'主题名', value:'example' },
'@primary-color' => { desc:'主题主色', value:'#4096ff' },
'@title-font-size' => { desc:'文本主字号', value:24 }
}
主题映射 @/lib/Theme/ThemeMap.ts
主题映射是主题变量的属性与生成结果的映射.生成结果可以是属性映射,也可以是具有下级结果的子映射
/** 属性映射值的基础类型 主题元名称、字符串、数字*/
type PropertyMapBaseValue = string | number
/** 属性映射值类型 基础类型或它的嵌套数组 */
type PropertyMapValue = PropertyMapBaseValue | PropertyMapValue[]
/** 每个映射的基础类型 */
type ThemeMapItemBaseType = {
/** 对当前层级主题变量的描述 */
desc: string
}
/** 属性映射 */
type PropertyMap = ThemeMapItemBaseType & {
/** 对应主题变量的值 */
value: PropertyMapValue
}
/** 具有下级结构的子映射 */
type SubThemeMap = ThemeMapItemBaseType & {
children: ThemeMap
}
/** 主题映射 */
type ThemeMap = Map<string, PropertyMap | SubThemeMap>
约定
PropertyMapValue的解释 value描述了对应属性的值:- 若value是主题包含的合法主题元,则它被解释为对应主题元的值;
- 若value是普通字符串或数字,以value作为此属性的值;
- value是数组时,它的每一项分别按照此规则解释后重新组合为数组.
示例
Map
{
'version' => { value:'@version', desc:'当前主题版本号' },
'themeName' => { value:'@theme-name', desc:'当前主题名称' },
'example' => {
desc:'示例',
children:
Map{
'Title' => {
desc:'标题',
children:
Map{
'size' => { value:'@title-font-size', desc:'标题字号' }
}
},
'Panel' => {
desc:'面板',
children:
Map{
'gradient' => {
value:['@primary-color-G3-A100','@primary-color-G3-A0'],
desc:'渐变背景'
}
}
}
}
}
}
映射变更
定义映射索引为映射中各级属性名用'.'连接形成的字符串.'version','example','example.Title.size'都是示例映射的映射索引.映射索引可以指向映射中不存在的对象或属性.
映射变更是映射索引与变更结果的映射.
/** 变更类型 */
type ThemeMapEdit = { type: 'add', value: SubThemeMap | PropertyMap }
| { type: 'delete' }
| { type: 'valueChange', value: PropertyMapValue }
| { type: 'descChange', desc: string }
| { type: 'change', value: { value: PropertyMapValue, desc: string } }
/** 映射变更 */
type ThemeMapEditRecorder = Map<string, ThemeMapEdit>
映射变更的约定
- 变更总是按照顺序同步地应用于映射.因此可以先添加一个映射再对齐进行修改.
- 对变更的解释
假设存在变更
key => { type, value(或desc) }- 若type为'delete',key的目标被视作删除
- 若type为'valueChange'且key的目标是属性映射,这个属性映射的的值被value替换
- 若type为'descChange'且key的目标在映射中,这个属性映射的的desc被desc替换
- 若type为'change'且key的目标在映射中,视为key的目标被value替换
- 若type为'add',key去除最后一部分索引后指示的目标是由下级结构的子映射,则在此子映射下以value创建新映射.(例如'example.Title1'会创建新映射,而'example1.Title1',''version'.'example'则不会,'example'指示的目标是子映射而非属性映射)
映射变更示例
变更
Map
{
'example.Title.size'=>'30px'
}
应用变更的映射
Map
{
'version' => { value:'@version', desc:'当前主题版本号' },
'themeName' => { value:'@theme-name', desc:'当前主题名称' },
'example' => {
desc:'示例',
children:
Map{
'Title' => {
desc:'标题',
children:
Map{
'size' => { value:'30px', desc:'标题字号' }
}
},
'Panel' => {
desc:'面板',
children:
Map{
'gradient' => {
value:['@primary-color-G3-A100','@primary-color-G3-A0'],
desc:'渐变背景'
}
}
}
}
}
}
主题 @/lib/Theme/index.ts
主题是主题变量应用于映射上的结果
/** 主题属性值基础类型 字符串、数字或者主题元的值 */
export type PropertyBaseValue<T> = PropertyMapValue | ThemeItemValue<T>
/** 主题属性值类型 基础类型或它的嵌套数组 */
export type PropertyValue<T> = PropertyBaseValue<T> | PropertyValue<T>[]
/** 主题属性类型 */
export type Property<T> = ThemeMapItemBaseType & {
/** 变量值 属性值基础类型或者它的数组 */
value: PropertyValue<T>
}
/** 具有下层结构的主题子主题 */
export type SubTheme<T> = ThemeMapItemBaseType & {
/** 下层结构 */
children: Theme<T>
}
/** 主题 */
export type Theme<T> = Map<string, Property<T> | SubTheme<T>>
示例
以前文所述主题、映射及其变更为例,生成以下主题变量 应用变更的主题
Map
{
'version' => { value:'1.0.1', desc:'当前主题版本号' },
'themeName' => { value:'example', desc:'当前主题名称' },
'example' => {
desc:'示例',
children:
Map{
'Title' => {
desc:'标题',
children:
Map{
'size' => { value:'30px', desc:'标题字号' }
}
},
'Panel' => {
desc:'面板',
children:
Map{
'gradient' => {
value:['#4096ffff','#4096ff00'],
desc:'渐变背景'
}
}
}
}
}
}
主题的使用
利用@/lib/Theme/index.ts导出的资源使用和编辑主题
创建主题上下文
在@/utils/theme/index.tsx中
'use client'
import { createTheme } from "@/lib/Theme"
export const { ThemeProvider, useTheme } = createTheme()
使用ThemeProvider
在根布局app/layout.tsx中,为下层组件提供主题上下文
import { ThemeProvider } from "@/utils/theme";
// 组件内
<ThemeProvider>
{props.children}
</ThemeProvider>
使用useTheme()
在客户端组件内中
const { themeInfo, edit, setThemeInfo } = useTheme()
if(themeInfo){
// 使用新的主题
setThemeInfo(initValue)
//增删改撤
edit.theme.// add,delete,changeValue,changeDesc,undo 增删改撤
edit.themeMap.// add,addPropertyMap,delete,change,changeDesc,undo 增加子映射或者属性映射
// 取得应用变更的主题、映射和主题变量
const editedTheme = getEditedTheme(themeInfo.themeVar,themeInfo.themeVarEditRecorder)
const editedThemeMap = getEditedThemeMap(themeInfo.themeMap,themeInfo.themeMapEditRecorder)
const themeVars = getThemeVars(editedTheme,editedThemeMap)
console.log(themeVars.entries())
}
实用函数
@/lib/Theme/index.ts导出了便于使用主题的函数
主题变量相关
checkThemeItemName
(name: string)=>void|never
检查是否是合法主题元名称getEditedThemeVar
(themeVar: ThemeVar<T>, themeVarEditRecorder: ThemeVarEditRecorder<T>)=>ThemeVar<T>
取得应用变更后的主题变量getInfoFromExtendThemeItemName
(name: string)=>null|{ themeItemName: string; level: number; opacity: number; }
从拓展主题名中获取信息isDeletedThemeItem
(name: string, themeVarEditRecorder: ThemeVarEditRecorder<T>)=>boolean
是否是被删除的主题元isEditedThemeItem
(name: string, themeVarEditRecorder: ThemeVarEditRecorder<T>)=>boolean
是否是被编辑的主题元isOriginThemeItem
(name: string, themeVar: ThemeVar<T>, themeVarEditRecorder: ThemeVarEditRecorder<T>)=>boolean
是否是主题变量中初始具有的主题元
映射相关
isPropertyMap
(value: PropertyMap | SubThemeMap)=> value is PropertyMap
判断是否是属性映射getEditedThemeMap
(themeMap: ThemeMap, themeMapEditRecorder: ThemeMapEditRecorder): ThemeMap
取得应用变更后的映射isProperty
(value: Property<T> | SubTheme<T>): value is Property<T>
判断主题的映射结果是否是属性值isDeletedThemeMap
(themeMapKey: string, themeMap: ThemeMap, themeMapEditRecorder: ThemeMapEditRecorder)=>boolean
是否是被删除的映射isEditedThemeMap
(themeMapKey: string, themeMapEditRecorder: ThemeMapEditRecorder)=>boolean
是否是被编辑的主题映射isOriginThemeMap
(themeMapKey: string, themeMap: ThemeMap, themeMapEditRecorder: ThemeMapEditRecorder)=>boolean
是否来自初始的主题映射getThemeMapByKey(themeMap: ThemeMap, themeMapKey: string)=> PropertyMap | SubThemeMap | null从一个映射中获取key所指示的子映射
主题相关
getValue
(themeVar: ThemeVar<T>, value: PropertyMapValue)=> PropertyValue<T>
从属性映射的value获取具体值getThemeVars
(themeVar: ThemeVar<T>, themeMap: ThemeMap)=>ThemeVars<T>
取得主题变量getDerivedColor
(color: string, level: number | null, opacity: number | null)=>string
获取一个颜色指定色阶和透明度的版本
主题体系的展示和修改
引用主题体系的内容,按照层级结构分别展示和变更主题、映射
基础类型
展示类型为ThemeItemBaseValue<T>的数据并收集变更.
使非受控组件具有突变能力
项目内这是一个常见的需求,要求在突变时组件做出响应,而平常则处于非受控状态.@/utils/useCompWithMutation提供钩子解决这个问题.
数字
基于useCompWithMutation构建数字组件@/utils/NumberValue
字符串和颜色
对于字符串类型,@/utils/BaseStringValue提供组件进行展示.
const initColor = 'rgba(0,255,0,1)'
const [value, setValue] = useState(initColor)
<div className='flex flex-col items-start'>
{value}
<Button onClick={() => setValue('#' + Math.random().toString(16).slice(2, 8))} type='primary'>重置</Button>
<BaseStringValue text={value} value={value} onChange={debounce(setValue, 1000)} className='m-4 w-60'></BaseStringValue>
</div>
此组件可以按照字符串是否是颜色选择不同的展示方式;支持防抖方式调用,表现上是非受控组件,但可以在value突变时受控.
可以设置参数pureString为true 将value完全识别为字符串
展示主题体系
在组件@/app/components/ThemeContent中展示整个主题体系,包括主题变量,映射,它们的变更以及生成的主题.它们被序列化后展示在屏幕上.通过@/utils/themeCommonFn提供的一些函数将它们转化为可序列化的对象.
展示和变更主题变量
主题变量使用组件@/app/ThemeVar进行展示
主题变量的展示和变更
基础类型使用上述的两种组件进行展示 嵌套的则使用侧边卡片.
主题元可以直接变更描述或值,也可以撤回变更.
主题元名称旁边还提供删除按钮,主题元列表下方提供新增按钮.
\
撤回删除
主题初始具有的主题元删除后会单独展示 可以撤销.\
新增
点击新增按钮后 可以选择新增主题元.
展示和变更主题映射
主题映射使用组件@/app/ThemeMap进行展示\
主题映射的展示和变更
基础类型使用上述的两种组件进行展示 嵌套的则使用侧边卡片.
映射可以直接变更描述或值,也可以撤回变更.
映射名称旁边还提供删除按钮
每个映射子映射列表都提供新增按钮.
撤回删除
来自初始主题映射的映射删除后会单独展示 可以撤销\
新增
新增窗口有多个选项
补充
目前此项目只满足了主题工具最基本的要求,还有不少与比较麻烦的需求都没有做,比如按照主题元筛选映射,从映射定位到使用该映射的组件以及反向定位,导入和导出主题以及多主题切换,数据类型可能为选择、对象等.
题外话: 关于组件重建和焦点
由于项目是给设计用的,这里面非常重视焦点,要求组件变更时不会引起焦点丢失.举个例子
一开始的时候 sub的值是@c 这是一个主题元 因此显示为数字输入框 上面还显示主题元名字;编辑后,sub就是一个单纯的数字,不显示别的内容.需要这样写
// 编辑前
<>
{text}<InputNumber/>
</>
//编辑后
<>
{''}<InputNumber/>
</>
//编辑后的组件不能这样写
<InputNumber/>
这是因为react默认按照在父组件中的位置逐个比对子组件,可以理解为以子组件的索引index作为它的key.只写InputNumber(key为0)的话会和上次的InputNumber(key为1)对不上 导致组件重建 丢失焦点.顺便一提 还有那种在组件里面嵌套写组件的 也会导致重建 因为key对的上但是组件变了.
这个项目里也出现了丢失焦点的情况.由于映射是嵌套的数据结构 所以映射也必须嵌套展示 所用的组件是递归的.
const PropertyMapValueRender: FC<PropertyMapValueRenderProps> = props => {
const { themeInfo } = useTheme()
if (!themeInfo) return null
const editedThemeVar = getEditedThemeVar(themeInfo.themeVar, themeInfo.themeVarEditRecorder)
const { value, onChange } = props
if (typeof value === 'number') {
return (
<>
{props.text}
<NumberValue className='block' value={value} onChange={onChange}></NumberValue>
</>
)
} else if (Array.isArray(value)) {
const content = // ...
return (
<Popover trigger={'click'} content={content} placement='left'>
<div className='flex items-center cursor-pointer text-sm'>
<LeftOutlined></LeftOutlined>
<span className='flex-auto text-center'>{props.text ? `${props.text}(数组)` : '数组[...]'}</span>
</div>
</Popover>
)
} else if (typeof value === 'string') {
if(...){
// 展示字符串
} else {
// 这一行有问题
return <PropertyMapValueRender value={themeItemValue} text={value} onChange={onChange} />
}
这个组件接受参数text作为额外显示的字符串,编辑后不会因为少一个字符串导致结构变动,但是最后额外调用一次PropertyMapValueRender会导致结构变化.
这样写是因为我一开始按照尾递归的思路去做:排除掉数组、数字和一般字符串的情况,如果这个字符串指代了主题元且这个主题元的值是数字或数组,就用主题元的值再生成一次,但这回导致组件树上多了一层PropertyMapValueRender.编辑后,映射值不再是主题元,而是编辑后的数组和字符串,相对编辑前结构有所不同,组件重建,失去焦点.更正方法是
/** 展示属性映射值 */
const PropertyMapValueRender: FC<PropertyMapValueRenderProps> = props => {
const { themeInfo } = useTheme()
if (!themeInfo) return null
const editedThemeVar = getEditedThemeVar(themeInfo.themeVar, themeInfo.themeVarEditRecorder)
const { onChange } = props
/** 统一使用这个变量控制渲染 在props.value指示主题元时对其进行变更 */
const renderInfo = { value: props.value, text: props.text }
if (typeof renderInfo.value === 'string') {
if (...) {
// 一般字符串
} else {
// 编辑renderInfo控制数字和字符串组件的渲染
renderInfo.text = value
renderInfo.value = themeItemValue
}
}
if (typeof renderInfo.value === 'number') {
const { value,text } = renderInfo
return (
<>
{text}
<NumberValue className='block' value={value} onChange={onChange}></NumberValue>
</>
)
} else if (Array.isArray(renderInfo.value)) {
const { value,text } = renderInfo
const content = // ...
return (
<Popover trigger={'click'} content={content} placement='left'>
<div className='flex items-center cursor-pointer text-sm'>
<LeftOutlined></LeftOutlined>
<span className='flex-auto text-center'>{text ? `${text}(数组)` : '数组[...]'}</span>
</div>
</Popover>
)
}
}
通过renderInfo控制数字和数组类型的渲染 可以使编辑前后组件树结构不变 不会失去焦点