从0到1搭建react组件库-Grid篇

引言

在构建 Form 表单组件时发现其布局依赖于栅格系统。为此我们优先实现了 Grid 组件系统,该系统由 Row(行)和 Col(列)组件构成,通过 24 等分布局体系实现灵活的响应式布局能力。

调研

在实现 Grid 组件前,我们参考了 AntdMantineArco Design 等主流组件库的实现方案。这些方案均采用 flex 布局实现栅格系统,核心原理是通过 Col 组件的 flex-basis(定义项目在分配多余空间前占据的主轴空间)和 max-width(控制元素最大宽度)配合 span 属性实现主轴空间分配。

设计思路:

Row 组件实现

基础结构:

在继承 div 元素的基础上,通过 props 中的 jusifyalign 属性计算出 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 组件之间的间距。 接下来有两点是实现过程中的拦路虎。

  1. gutter 属性用于统一设置 Col 组件之间的间距。它作为 Row 组件的属性传递,但在 Col 组件中使用,通过 context 解决跨组件通信问题。

  2. 需要支持媒介查询的, 支持对象格式 { xs: 8, sm: 16 } 实现断点自适应:

    1. 可以通过 Window.matchMedia 以及 MediaQueryList.addListenerAPI 来实现对屏幕宽度变化的监听。
    2. 为了避免性能问题,使用单例模式和发布订阅模式,只初始化一次监听媒体查询事件。, 然后订阅每次媒体查询回调函数的执行。
上下文传递Gutter属性
  • 来设计一下 gutter 属性的数据格式。设想中主要分为 水平和垂直方向上的间距([horizontalGutter, verticalGutter])。

    typescript 复制代码
    export interface IRowContext {
      gutter: number[]
    }
  • 设计好数据的格式之后, 我们将所有的 Context 的操作就放在一个文件里,这样逻辑会更加的聚合。 主要导出 Provider、获取这个上下文的hook

    typescript 复制代码
    import { 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 的以下特性:

  1. 循环:用于生成重复的样式规则。
  2. 条件:用于根据不同的条件生成不同的样式。
  3. 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, '');
}
处理屏幕宽度断点:

特别需要注意的是处理宽度断点属性(如 xslg 等)。这些属性的处理需要遍历其 keyvalue,生成最终的 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);
  }

  ...... 省略其余媒介查询。
}

实现效果:

仓库地址

相关推荐
新生派1 小时前
HTML<hgroup>标签
前端·html
timer_0172 小时前
Tailwind CSS 正式发布了 4.0 版本
前端·css
答题卡上的情书3 小时前
uniapp版本升级
前端·javascript·uni-app
枫叶丹44 小时前
【HarmonyOS之旅】基于ArkTS开发(三) -> 兼容JS的类Web开发(三)
开发语言·前端·javascript·华为·harmonyos
酷爱码4 小时前
HTML5+SVG+CSS3实现雪中点亮的圣诞树动画效果源码
前端·css3·html5
有杨既安然4 小时前
Vue.js组件开发深度指南:从零到可复用的艺术
前端·javascript·vue.js·npm
步、步、为营7 小时前
C#牵手Blazor,解锁跨平台Web应用开发新姿势
开发语言·前端·c#
i7i8i9com7 小时前
node-sass已经废弃了,需要替换成以下方式
前端·css·sass
我命由我123458 小时前
脚本运行禁止:npm 无法加载文件,因为在此系统上禁止运行脚本
前端·javascript·前端框架·npm·node.js·html·js
斯密码赛我是美女8 小时前
zyNo.15(Web题型总结1)
前端·安全