引言
在构建 Form
表单组件时发现其布局依赖于栅格系统。为此我们优先实现了 Grid 组件系统,该系统由 Row
(行)和 Col
(列)组件构成,通过 24 等分布局体系实现灵活的响应式布局能力。
调研
在实现 Grid
组件前,我们参考了 Antd
、Mantine
、Arco Design
等主流组件库的实现方案。这些方案均采用 flex
布局实现栅格系统,核心原理是通过 Col
组件的 flex-basis
(定义项目在分配多余空间前占据的主轴空间)和 max-width
(控制元素最大宽度)配合 span
属性实现主轴空间分配。
设计思路:
Row 组件实现
基础结构:
在继承 div
元素的基础上,通过 props
中的 jusify
和 align
属性计算出 Row
组件中的 className
。 然后在样式文件中支持布局即可。
typescript
import { PropsWithChildren, useEffect, useState } from "react"
import { getPrefix, classNames as cls, isArray, isObject } from "../../utils"
import { Gutter, RowProps } from "./interface"
import { RowProvider } from "./context"
import { responsiveArray, ScreenMap, useResponsiveObserver } from "../../hooks/useResponsiveObserver"
export const Row = (props: PropsWithChildren<RowProps>) => {
// props 解构
const {
className,
gutter = 0,
justify,
align,
children,
...rest
} = props
// 派生数据
// class name
const prefix = getPrefix("row")
const classNames = cls(
className,
prefix,
{
[`${prefix}-justify-${justify}`]: justify,
[`${prefix}-align-${align}`]: align
}
)
return (
<RowProvider value={rowContext}>
<div
className={classNames}
{...rest}
>
{children}
</div>
</RowProvider>
)
}
样式控制:
样式主要是通过 less 中的 mixins 功能, 快速的生成对应的样式。
less
@import url("../../../styles/index.less");
@row-prefix: ~'@{prefix}-row';
.justify-content(@justify) {
.@{row-prefix}-justify-@{justify} {
justify-content: ~'@{justify}';
}
}
.align-items(@align) {
&-align-@{align} {
align-items: ~'@{align}';
}
}
.@{row-prefix} {
display: flex;
flex-flow: row wrap;
.justify-content(start);
.justify-content(center);
.justify-content(end);
.justify-content(space-around);
.justify-content(space-between);
.align-items(start);
.align-items(center);
.align-items(end);
}
核心特性:gutter:
先来介绍一下 gutter
属性: gutter
属性主要是用来统一设定 Col
组件之间的间距。 接下来有两点是实现过程中的拦路虎。
-
gutter
属性用于统一设置Col
组件之间的间距。它作为Row
组件的属性传递,但在Col
组件中使用,通过context
解决跨组件通信问题。 -
需要支持媒介查询的, 支持对象格式
{ xs: 8, sm: 16 }
实现断点自适应:- 可以通过 Window.matchMedia 以及 MediaQueryList.addListenerAPI 来实现对屏幕宽度变化的监听。
- 为了避免性能问题,使用单例模式和发布订阅模式,只初始化一次监听媒体查询事件。, 然后订阅每次媒体查询回调函数的执行。
上下文传递Gutter属性
-
来设计一下
gutter
属性的数据格式。设想中主要分为 水平和垂直方向上的间距([horizontalGutter, verticalGutter]
)。typescriptexport interface IRowContext { gutter: number[] }
-
设计好数据的格式之后, 我们将所有的
Context
的操作就放在一个文件里,这样逻辑会更加的聚合。 主要导出Provider
、获取这个上下文的hook
。typescriptimport { createContext, useContext } from "react" import { IRowContext } from "../interface" const defaulRowContext: IRowContext = { gutter: [0, 0] } const RowContext = createContext<IRowContext>(defaulRowContext) export const RowProvider = RowContext.Provider export const useRowContext = () => { return useContext(RowContext) }
-
在
Row
组件中间中使用Provider
包围所有的子组件, 然后Col
组件中使用hook
来读取上下文。typescript// Row <RowProvider value={rowContext}> <div className={classNames} {...rest} > {children} </div> </RowProvider> // Col // context const { gutter } = useRowContext()
发布订阅检测媒体查询(屏幕宽度变化)
我们将这个发布订阅分为下面几个部分, 逐步击破。分别有下面的内容:
数据格式:
ts
const [screenMap, setScreenMap] = useState<ScreenMap>({
xs: true,
sm: true,
md: true,
lg: true,
xl: true,
xxl: true,
xxxl: true,
})
单例模式:
通过静态属性 instance
来确保单例模式,然后通过对比媒介查询属性,来决定是否需要重新注册。
ts
class ResponsiveObserver {
// 单例模式
private static instance: ResponsiveObserver | null = null
private responsiveMap: BreakpointMap | null = null
// 发布订阅
private token: number // 订阅的唯一标识
private screenMap: ScreenMap // 屏幕的宽度的Map, 最终消费的数据。
private subscribers: MatchRecord[] // 订阅者列表
private matchListeners: { mql: MediaQueryList, listener: (e: MediaQueryListEvent) => void }[] // 用来取消注册
private constructor(responsiveMap: BreakpointMap) {
this.screenMap = {}
this.subscribers = []
this.token = -1
this.matchListeners = []
this.responsiveMap = responsiveMap
}
static getInstance(responsiveMap: BreakpointMap) {
if (!ResponsiveObserver.instance) {
ResponsiveObserver.instance = new ResponsiveObserver(responsiveMap);
} else if (ResponsiveObserver.instance.responsiveMap !== responsiveMap) {
// 如果 responsiveMap 变化,重新初始化实例 为后续监控器配置化做准备
ResponsiveObserver.instance.unregister();
ResponsiveObserver.instance.responsiveMap = responsiveMap;
ResponsiveObserver.instance.register();
}
return ResponsiveObserver.instance;
}
}
register:
通过 需要监听的媒介查询属性, 结合 window.matchMedia
属性,获取到 MediaQueryList
数据类型的匹配结果。 然后调用 MediaQueryList.addListener
去订阅每次的宽度变化。每次宽度变化后去调用下面的 dispatch
通知订阅更新的订阅者。
ts
register() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this
Object.entries(responsiveMap).forEach(([key, value]) => {
function listener({ matches }: { matches: boolean }) {
_this.dispatch({
..._this.screenMap,
[key]: matches
}, key as Breakpoint)
}
const mql: MediaQueryList = window.matchMedia(value)
mql.addListener(listener)
listener(mql)
})
}
unregister:
通过 unregister
取消事件监听。
ts
unregister() {
this.matchListeners.forEach(item => {
item.mql.removeListener(item.listener)
})
this.matchListeners = []
}
subscribe:
订阅函数, 传入回调函数, 然后存入到 suberscribers
中,返回当前存储的 key
作为取消订阅的依赖。 等待 dispatch
函数被调用时, 触发所有的订阅。
ts
subscribe(cb: ResponsiveCallback) {
if (this.subscribers.length === 0) {
this.register()
}
const token = (++this.token).toString()
this.subscribers.push({
token,
func: cb
})
cb?.(this.screenMap, null)
return token
}
unsubscribe:
取消订阅函数, 将 subscribe
函数的返回值作为参数, 来解除订阅,如果 subscribers
已经清空了,那么就调用 unregister
取消事件监听。
ts
unsubscribe(token: string) {
this.subscribers = this.subscribers.filter(item => item.token !== token)
if (this.subscribers.length === 0) {
return this.unregister()
}
}
dispatch:
ts
dispatch(screenMap: ScreenMap, breakpoint: Breakpoint) {
this.screenMap = screenMap
this.subscribers?.forEach(item => {
item.func(screenMap, breakpoint)
})
}
Row组件监听:
-
通过 useEffect 钩子函数实现注册订阅事件和取消订阅。
ts// status const responsiveObserver = useResponsiveObserver() // effect useEffect(() => { const index = responsiveObserver.subscribe((_screenMap) => { setScreenMap(_screenMap) }) return () => { responsiveObserver.unsubscribe(index) } }, [])
-
判定 gutter 属性的数据类型, 去调用统一的处理函数。
ts// context const rowContext = { gutter: [ getGutter(isArray(gutter) ? gutter[0] : gutter), getGutter(isArray(gutter) ? gutter[1] : 0) ] } function getGutter(_gutter: Gutter) { let result = 0; if(isObject(_gutter)) { for(const breakpoint of responsiveArray) { if(_gutter[breakpoint] && screenMap[breakpoint]) { result = _gutter[breakpoint] break } } } else { result = _gutter } return result }
Col 组件
基础部分:
基础部分包括 span
(占据格子数)、gutter
(通过 context
获取的间距)和 offset
(相对于左侧的偏移量,与 span
实现相同)。关键点就在于生成对应的 className
和样式。
ts
export const Col = (props: PropsWithChildren<ColProps>) => {
// props 解构
const {
span = 24,
offset = 0,
order,
className,
style,
sm,
md,
lg,
xl,
xs,
xxl,
xxxl,
children,
...rest
} = props
// context
const { gutter } = useRowContext()
// 派生数据
// class names
const prefix = getPrefix("col")
const classNames = cls(
prefix,
className,
{
[`${prefix}-span-${span}`]: span,
[`${prefix}-offset-${offset}`]: offset,
[`${prefix}-order-${order}`]: order,
},
getAddaptionClassName(prefix)
)
// style 根据context gutter计算
const mergedStyle: CSSProperties = {
padding: `${gutter[1] / 2}px ${gutter[0] / 2}px`,
...style
}
return (
<div
className={classNames}
style={mergedStyle}
{...rest}
>
{ children }
</div>
)
}
Less 生成样式:
在生成 className
和样式的过程中,可以充分利用 Less 的以下特性:
- 循环:用于生成重复的样式规则。
- 条件:用于根据不同的条件生成不同的样式。
- Mixins:用于复用样式代码,提高代码的可维护性。
less
@import url("../../../styles/index.less");
@col-prefix: ~'@{prefix}-col';
.generate-columns(@n, @screen) {
@adaption: if(@screen = '', ~'', ~'-@{screen}');
.loop(@i) when (@i <= @n) {
&@{adaption}-span-@{i} {
width: (@i * 100% / @n);
}
.loop(@i + 1);
}
.loop(1); // 启动循环
}
.generate-offset(@n, @screen) {
@adaption: if(@screen = '', ~'', ~'-@{screen}');
.loop(@i) when (@i <= @n) {
&@{adaption}-offset-@{i} {
margin-left: (@i * 100% / @n);
}
.loop(@i + 1);
}
.loop(1);
}
.generate-order(@n, @screen) {
@adaption: if(@screen = '', ~'', ~'-@{screen}');
.loop(@i) when (@i <= @n) {
&@{adaption}-order-@{i} {
order: @i;
}
.loop(@i + 1);
}
.loop(1);
}
.@{col-prefix}{
.generate-columns(24, '');
.generate-offset(24, '');
.generate-order(24, '');
}
处理屏幕宽度断点:
特别需要注意的是处理宽度断点属性(如 xs
、lg
等)。这些属性的处理需要遍历其 key
和 value
,生成最终的 className
,然后将其与原有的 className
合并。
ts
// 辅助函数,获取响应式数据下的className
function getAddaptionClassName(prefix: string) {
const screenList = { xs, sm, md, lg, xl ,xxl, xxxl }
let mergedCls = {}
Object.entries(screenList).forEach(([screenKey, screenValue]) => {
if(isNumber(screenValue) && screenValue > 0) {
mergedCls = {
...mergedCls,
[`${prefix}-${screenKey}-span-${screenValue}`]: screenValue
}
} else if(isObject(screenValue)) {
mergedCls = {
...mergedCls,
[`${prefix}-${screenKey}-span-${screenValue.span}`]: screenValue.span,
[`${prefix}-${screenKey}-offset-${screenValue.offset}`]: screenValue.offset,
[`${prefix}-${screenKey}-order-${screenValue.order}`]: screenValue.order
}
}
})
return mergedCls
}
less
.@{col-prefix}{
.generate-columns(24, '');
.generate-offset(24, '');
.generate-order(24, '');
// adaptation
.generate-columns(24, xs);
.generate-offset(24, xs);
.generate-order(24, xs);
@media (width >= 576px) {
.generate-columns(24, sm);
.generate-offset(24, sm);
.generate-order(24, sm);
}
...... 省略其余媒介查询。
}