【第2032期】基于react的组件库主题设计方案
前言
今日早读文章由腾讯@余佩纯投稿分享。
@余佩纯,来自腾讯音乐全民K歌web开发团队,主要负责全民K歌web与客户端之间的跨端工作,为全民K歌中高访问量的一二级页面提供技术能力支持。欢迎对跨端感兴趣的同学加入到我们的web团队。
正文从这开始~~
基于react设计与开发的组件库主题方案,以 Hippy React 主题方案设计为例
需求背景
单一的视觉不再满足用户体验需求,为提高用户体验,提高应用体验口碑,同时提高开发者效率,我们希望提高组件库的可定制化,因此提供换肤功能以及多种类组件中的样式定制功能,允许用户将应用切换不同主题风格的皮肤,也允许开发者对指定组件进行样式改造。
设计目标
性能
一个方案的落地前提得有性能的保障,不重新初始化视图,避免出现闪屏、卡顿等性能缺陷现象,同时也要保障功能稳定,不能存在部分组件不按预期切换主题现象。
可维护性
组件库需不断迭代完善,应避免过多的条件判断,避免在单个组件上有过多的主题特殊逻辑,主题的设置和组件的实现应解耦,保证后续可维护可扩展。
可配置
可配置分为两部分,一部分为可配置任意全局统一的样式变量,或者某个组件的局部样式;另一部分为强制模式,即指定部分组件不跟随主题变化而变化,保留着本身一种样式。
易用性
提供快捷接入主题的接口,降低学习成本和时间成本。
粒度细分
组件层面的主题定制、整套组件库的主题定制。开发者可以修改全局样式,比如更换全局中字号的字体大小,也可以局部修改样式,比如按钮组件A的边框颜色。
样式提取
暴露出提取当前整套样式的接口,方便开发者提取指定样式做二次操作。比如开发者需要提取当前主题颜色作为视图背景色,可从组件库中获取。
样式可定制内容,包括但不限于:
颜色:品牌色、默认背景色、通用背景色、基本文本颜色、辅助文本颜色、链接色
文本:文本大小,字重,字体间距等
按钮:圆角大小,按钮尺寸,边框尺寸等
图片:图片尺寸,圆角大小等
技术选型
主题定制是大多数组件库都会提供的一个核心样式相关的功能,技术选项上需要考虑的两点:
如何生成一份全局样式配置表
组件如何获取样式配置表
针对以上两点,我们做了一些分析:
如何生成一份全局样式配置表
目前各类组件库最常用的是以下两种方案:
借助gulp/webpack等打包工具相关的插件,配置需要定制的样式变量,在打包时覆盖对应变量值。
重写样式,覆盖样式配置表,生成新的全局样式配置表。
在我们实现的hippy-react-ui中我们并没有提供打包的能力,而是把这部分移交到业务侧处理,原因是现在大部分业务发布时都会对业务进行打包处理,业务侧可能灵活设置打包配置内容,而不受限于组件库打包,另一方面是让业务侧使用组件时可以快速定位组件内部结构,方便排查使用过程中遇到的问题。因此我们选用了以上第二种方案,提供一份默认的样式配置表,而业务侧可以写入重新样式对其覆盖。
组件如何获取样式配置表
组件库是基于hippy-react设计开发的,hippy-react提供的数据的传递有两种:
通过 props 属性自上而下(由父及子)进行传递
Context 提供了一种在组件之间共享值的方式,不必显式地通过组件树的逐层传递 props
第一个方案使用简单,只需要将样式从根节点往下一层层传递即可,但它的缺点也是需要一层层传递。我们的组件库中,复合组件很多,比如列表组件中用到了按钮组件,按钮组件中用到了文本组件,这要求每个组件都需要获取一遍props再往下传递,不仅加大开发成本,对影响了后续开发的可维护性。而第二个方案,我们只需要使用context提供主题的提供者和消费者,在需要使用主题的组件中注入即可,但它有个缺点:每次更新context的内容,都会将所有消费到主题的组件重新更新一遍。而针对context的缺点,我们可以放下这个顾虑,因为主题本身也是只消费一遍,在切换主题的时候进行消费,而不是高频的去使用。因此组件获取样式配置表是通过context的方式进行获取。
设计方案
通过上面技术的选型,我们确定了两点:
重写样式,覆盖样式配置表,生成新的全局样式配置表
组件通过Context提高的组件之间共享值的方式,获取样式配置表
生成样式配置表
以上是生成全局样式表的过程,在讲解流程前需补充说明上图中深色/浅色主题:组件库内置两份主题色,主题的切换主要是颜色部分的切换,提供两种主题的原因是我们尽可能通用化配色,比如以下几个例子,背景色/背景图片我们可以随意替换,但作用在其之上的内容,简单分为深/浅两种方案基本可以适用到大部分场景。
样式优先级
组件库自带的样式分为三部分:跟主题相关的 深色主题
和 浅色主题
,还有与主题切换无关的 其他样式
, 在业务侧未指定主题时,组件库默认使用浅色主题的颜色配置表+其他可配置的默认样式值,如字体大小,字重等,业务侧可以重写样式,最终生成的样式表作为提供者Provider给到各个组件使用。
我们暴露一个属性 value={}
给业务侧赋值给组件库,业务侧可以在对象中传入指定的主题,比如 value={theme:"light"}
或者 value={theme:"dark"}
,我们提供一个便利,业务侧可以直接传入 value="light"
或 value="dark"
。如果希望针对某个样式值进行重写,可以 value={textBaseColor:"#555555"}
。
在组件库中,我们根据业务侧传入的自定义内容进行判断且合并成新的样式配置表:
function getStyle (style) {
if (style === "light") {
return useTheme(lightStyleSheet);
} else if (style === "dark") {
return useTheme(darkStyleSheet);
} else {
const themeStyle = style && style.theme === "dark" ? darkStyleSheet : lightStyleSheet;
return useTheme({ ...themeStyle, ...style });
}
};
该函数中style即为业务侧传入的value,首先判断style是否为主题(light/dark),是即返回对应主题表。 useTheme
是一个合并样式的方法,参数是样式对象。
function useTheme(args = {}) {
return Object.assign({}, defaultStyle, args);
}
可以看到这个方法只是一个合并的操作, defaultStyle
指向了默认的样式表。
Context传递共享值
以上为样式合并的过程,接下来我们需要将样式配置表作为样式提供者(Provider)传递到各个消费者(consumer)各个组件中。Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。
提供主题的Provider(提供者)和Consumer(消费者),我们通过 React.createContext() 创建上下文,使得Provider和Consumer可以接收和获取同一来源信息。
const ThemeContext = React.createContext(defaultTheme);
Provider: 用于接收主题和样式参数,并与默认样式合集、深/浅色主题样式合并。
如上文所提到的,我们允许给组件传入指定主题变量:"dark" 或者 "light",也允许传入自定义样式对象,如:{hiColorTheme: "#666666"} ,下图展示样式获取过程,根据优先级(用户自定义样式 > 用户自定义主题 > 默认主题)会生成一份配置表,而我们所有允许定制的样式,样式属性值均从配置表获取。
// ThemeProvider:将样式合集写入value提供给消费者
const ThemeProvider = (props: ThemeProviderProps) => {
let style = getStyle(props.value);
return <ThemeContext.Provider value={style}>{props.children}</ThemeContext.Provider>;
};
Consumer: 用于获取样式合集并提供给子组件
Consumer获取到的样式合集作为生成子组件的函数参数,这就要求子组件是以函数的方式获取样式合集,后面如何使用中会对应介绍,如下
class ThemeConsumer extends React.Component {
render() {
return (
<ThemeContext.Consumer>
// children是一个函数,而非组件
{style => {
return this.props.children(style);
}}
</ThemeContext.Consumer>
);
}
}
组件接收样式表
如上图,我们将Provider包围在最外层,建议在根节点使用,根据需要也可包裹置于局部组件。在Provider中的任意Consumer均可获取到同一份样式表,当Provider更改自定义值时,在任意订阅的地方均可以获取到最新样式表,从而更新节点。
在根节点使用Provider,并引入自定义样式或者指定主题
<Provider theme={{theme: "dark",defaultFontSize: 18}}>
<HiText />
<HiList />
</Provider>
Text 组件
<Consumer>
{(themeStyle) => {
return (
<Text style={{fontSize: themeStyle.defaultFontSize}}>
Text 组件
</Text>
);
}}
</Consumer>
List 组件
<Consumer>
{(themeStyle) => {
return (
<View>
<Text style={{fontSize: themeStyle.defaultFontSize}}>
List 组件
</Text>
<HiText />
</View>
);
}}
</Consumer>
强制模式
强制模式即当用户切换主题时,该模式下的组件不会跟随主题变化。何时会使用到该模式呢?
例如上图,是在浅色主题下的展示,但蓝框中因为有固定的背景图存在,我们不希望它跟随主题色切换文本颜色,而是固定为深色模式下的浅色文本颜色,就需要用到强制模式让它主题固定下来。
强制模式的实现是采用了拦截Provider传递给Consumer的方式,如下图:
例如文本组件中使用了Consumer接收全局样式,但如果业务使用了Text组件,并赋予了主题属性,那么我们会将主题属性告知Consumer,在Consumer中,局部组件提供主题属性优先级高于Provider提供的主题属性值。
renderChildren(style: any) {
let children = this.props.children(style);
if (children && children.props && children.props.theme) { // 判断局部逐渐是否传递了主题属性
let partStyle = getStyle(children.props.theme);
children = this.props.children(partStyle);
}
return children;
}
暴露样式表
上文中提到主题的切换均作用于组件库中的组件,当业务不需要组件而需要获取样式表的内容进行其他操作时,我们需要给到业务侧当前的主题样式表,使得用组件库的业务可以做更多的界面统一。于是我们在主题Provider提供了一个静态变量,允许业务获取
class ThemeProvider extends Component<ThemeProviderProps> {
static styleConfig: DefaultStyle;
render() {
const style = getStyle(this.props.value);
ThemeProvider.styleConfig = style; // 暴露主题配置表
......
}
}
如何使用
Provider引入
使用: 将 Provider 置于根节点上
// app.js
<Provider theme="dark"></Provider>
theme 属性使用
定制主题
theme 可传入"dark"或者"light"
<Provider theme="light"></Provider>
定制样式
theme 可以重写样式表中默认的样式,如需修改默认字体中字号的大小
<Provider theme={{ hiFontSizeM: 20 }}></Provider>
定制主题+定制样式
<Provider theme={{ theme: "dark", hiFontSizeM: 20 }}></Provider>
全局定制背景色
默认使用主题背景色。优先级:style 属性 > 更改配置表定制背景色 > 默认主题背景色
// 更改配置表定制背景色:背景色使用的是样式表中的 hiBgColor 值
<Provider theme={{ hiBgColor: "#666666" }}></Provider>
// style属性更改背景色
<Provider style={{ backgroundColor: "#666666" }}></Provider>
强制模式
import HiText from "../HiText";
<HiText theme="dark">default(Text 28 A)</HiText>
<HiText theme={{ hiBgColor: "#666666" }}>default(Text 28 A)</HiText>
获取样式配置表
// 引入 Provider
let styleConfig = Provider.styleConfig;
重点问题解决
兼容新旧SDK
主题设计核心用到了hippy-react的Context,这是hippy-react 2.0.3之后提供的API,针对SDK未升级的旧业务,我们需要兼容处理,避免报错。组件库采用的是判断版本号和检查是否有Context判断该版本是否支持主题切换
const ThemeContext = React.createContext ? React.createContext(defaultStyle) : null;
const IS_SUPPORT_THEME = ((HippyReact && HippyReact.version && versionCompare("2.0.3", HippyReact.version))) && ThemeContext;
对于低版本使用到主题功能的部分,我们同样需要给到指定的样式表
Provider兼容方式:
const ThemeProvider = (props: ThemeProviderProps) => {
const style = getStyle(props.value);
if (IS_SUPPORT_THEME) {
// 支持主题切换,使用Context API
return (
<View {...props} style={[{ backgroundColor: style.hiBgColor || "#FFFFFF" }, props.style]} key={style.type}>
<ThemeContext.Provider value={style}>{props.children}</ThemeContext.Provider>
</View>
);
} else {
// 不支持主题切换,返回Provider下的children内容
return (
<View {...props} style={[{ backgroundColor: style.hiBgColor || "#FFFFFF" }, props.style]} key={style.type}>
{props.children}
</View>
);
}
};
Consumer兼容方式:
class ThemeConsumer extends React.Component<ThemeConsumerProps> {
static defaultProps: ThemeConsumerProps = {
children: () => {}
};
constructor(props: ThemeConsumerProps) {
super(props);
this.state = {};
}
renderChildren(style: any) {
let children = this.props.children(style);
if (children && children.props && children.props.theme) {
let partStyle = getStyle(children.props.theme);
children = this.props.children(partStyle);
}
return children;
}
render() {
if (IS_SUPPORT_THEME) {
// 支持主题切换,children是一个方法,style是从provider获取到的样式表,作为参数给到children
return (
<ThemeContext.Consumer>
{style => {
return this.renderChildren(style);
}}
</ThemeContext.Consumer>
);
} else {
// 不支持主题切换,children也是一个方法,这里style作为参数直接执行方法渲染children
return this.renderChildren(defaultStyle);
}
}
}
实现效果
总结
文章介绍了组件库的主题切换和样式定制的功能实现机制,主题和样式定制是组件库的核心一员,它让组件库的使用不局限于组件设计者的设计范畴,我们可灵活扩展组件,让组件库支持范围更广。
为你推荐
欢迎自荐投稿,前端早读课等你来