组件仍在补充, 欢迎加群交流哦,微信: a2298613245
headless ui
✕
组件还在补充中,希望能够帮助你有一个亮点项目放入简历!
全局方案
按钮 Button
图标 Icon
布局 Grid
间距 Space
输入框 Input
弹窗 Modal
弹出框 Popover
消息 Toast
警告 Alert
单选框 Radio
复选框 Checkbox
标签 Tag
其它组件
必读指南
Grid 完整案例
传统案例
创意案例

这么灵活的布局的组件,值得一学哦!
从常见的布局方案看,Grid组件

icon

前言

现在国内国外主流 UI 组件库会有一个叫布局的组件,例如 antd 和 M-UI 都有一个 Grid 组件,帮助布局,

antd的布局组件示例如下:

antd grid

这些组件主要是帮助我们布局的,并且可以设置在不同屏幕尺寸下布局的样式。是一种来实现的布局方案,实用性很强。

其主要的实现方式是使用 flex 布局,以及借助 css 的 media query 来实现的。flex 布局在单行布局时十分方便,尤其新的浏览器版本支持 flex 的 gap 属性后,flex 在多行布局上也更加方便。

在绝大多数情况下,基于 flex 布局的 Grid 组件足够了。

我这里的 Grid 组件主要是基于 css 的 display: grid 布局来实现的,display: grid 主要是为二维布局而生的,当然单行布局也没问题。

然后也我使用 javascript 控制 media query 来实现不同屏幕尺寸下的布局样式。所以就很符合 headless 的思想,不需要像 antd,M-UI 需要配合 css 。

这也是我为什么使用 display: grid 布局的原因,因为 grid 布局在二维布局上更方便,也更符合 headless 的思想。

在介绍我们的方案之前,可以先来看看常见的自适应布局方案有哪些。

两类布局方案

主流的布局方案主要分为两个大类:

一类依靠媒体查询,就一种设置 breakpoint(断点),来实现的。例如屏幕宽度:

  • 小于 526px 时,布局为单列
  • 大于等于 526px 但小于等于 768px 时,布局为双列
  • 大于等于 768px 时,布局为三列

一类是流体布局方案,就是屏幕缩放时,布局也会相应地缩放。例如百分比布局,我设置一个元素的宽度为 50%,那么当浏览器的宽度发生变化时,这个元素的宽度也会相应地变化。

这方面我不是专业的,但我感觉(欢迎大家补充)媒体查询的方式,更适合实现多平台的响应式布局,例如你要做一个适应 手机,平板,电脑 等不同屏幕宽度,且宽度范围差距较大的响应式布局。

而流体布局,更适合做宽度变化范围较小的设备上,例如移动端的布局(不同的手机尺寸变化范围并不大)。接下来我们就讲两个常见的流体布局方案。

  • 百分比布局方案
  • vw/vh 布局方案

最后讲一下我们组件库中 Grid 布局组件的媒体查询方案。

当然这个肯定不绝对,因为有些酷炫的网站,可能流体布局更适合。

百分比布局方案

这个方案就是给元素设置百分比,例如相对于 window 窗口的宽度或者高度设置当前元素的百分比, 当浏览器的宽度或者高度发生变化时,通过百分比单位可以使得浏览器中的组件的宽和高随着浏览器的变化而变化,从而实现响应式的效果。

例如:

1.container { 2 width: 100%; /* 容器占满视口 */ 3} 4 5.box { 6 width: 50%; /* box始终是container宽度的一半 */ 7 height: 30%; /* 高度是container高度的30% */ 8}

我们看到,百分比布局是一种基于父容器大小的布局方案,但这个方案在布局简单的情况下还可以,但是稍微有点复杂的话,就会有一个很麻烦的地方。 就是百分比到底是相对于什么的百分比?这句话大家可能觉得有点奇怪,肯定是子元素的 width 相当于父元素的 width,子元素的 height 相当于父元素的 height 呗!

但其实不是呢,情况也比较复杂,列举一些:

  • 子元素的 margin 如果设置成百分比,不论是垂直方向还是水平方向,都相对于直接父元素的 width。而与父元素的 height无关。
  • 子元素的 padding 同上。
  • 子元素的 top 和 bottom 如果设置百分比,则相对于 包含块 的 height 的百分比。
  • 子元素的 left 和 right 如果设置百分比,则相对于 包含块 的 width 的百分比。

上面提到一个 包含块 的概念,想了解详细内容,可以去搜 MDN。顺便纠正一个常见的错误,很多定位,例如 absolute 定位,top, left这些定位值 是相对于最近的一个非 static 定位的父元素的,这个说法是错的。

absolute 的定位上下文其实是它的 包含块。

这就体现出百分比布局优缺点非常明显:

  • 优点:简单场景百分比布局简单方便,只需要设置百分比即可。
  • 缺点:复杂场景注意的细节太多。但实际上 antd 布局组件的 Grid (基于 display: flex) 在处理自适应布局时,也使用了 百分比, 但仅限于 width 和 height,所以总体来说,百分比布局还是比较方便的。

vw/vh 布局

vw/vh 布局是基于视口的宽度和高度来布局的,例如 100vw 就是视口的宽度,100vh 就是视口的高度。也就是说 vw/vh 布局也是某种程度上的百分比布局。 核心理解是相对参照物不同,百分比常相对于父元素,而 vw/vh 相对于屏幕宽度。

简单的 vw/vh 布局使用方法是,首先设计稿例如是 750px 为基准,那么 100vw 就是 750px, 那么 1px 对应 100 / 750 vw。

所以设计稿中其它的 px 单位,都可以直接转换为 vw/vh 单位。当然自己计算是比较麻烦的,所以我们一般可以借助一些现成的插件,例如 postcss-px-to-viewport,来自动帮我们转换。

这种 vw/vh 布局会有一些常见的坑,例如:

  • 经典的 1px 像素问题,很多移动端组件库采取的是,添加伪类,然后使用 transform: scale(0.5) 来解决。
  • 对于例如 input 这种能换出软键盘的功能,在安卓机下 vm/vh 布局就会有问题,因为安卓的软键盘弹出会把原先的网页 height 减小,那么原先页面如果设置整个网页高度为 100vh 的话, 那么此时软键盘占据了部分 height,整个网页就会缩小。
  • ...等等

媒体查询方案

这种方案主要是借助 css 的媒体查询。但 css 的媒体查询要写很多 css, 我们的 Grid 布局组件则是利用 javascript 来实现的动态控制媒体查询。加上 display: grid 本身对自适应布局良好的支持,结合起来,算是一个很好的媒体查询解决方案。

这里我们说一下核心的 媒体查询 hook 的实现思路。

媒体查询核心 hook

简单介绍一下这个 hook 的实现思路。以下的源码,粗略看下即可,后续会详细解释。

1import { useEffect, useRef, useState, useMemo } from 'react'; 2import ResponsiveObserve, { responsiveArray, ScreenMap } from '../utils/responsive-observe'; 3import { ResponsiveValue } from '../interface'; 4import { isObject } from '../../utils'; 5 6function isResponsiveValue(val: number | ResponsiveValue): val is ResponsiveValue { 7return isObject(val); 8} 9 10export const useResponsiveState = (val: number | ResponsiveValue, defaultValue: number) => { 11const token = useRef<string>(null); 12const [screens, setScreens] = useState<ScreenMap>({ 13 xs: true, 14 sm: true, 15 md: true, 16 lg: true, 17 xl: true, 18 xxl: true, 19 xxxl: true, 20}); 21useEffect(() => { 22 token.current = ResponsiveObserve.subscribe((screens) => { 23 if (isResponsiveValue(val)) { 24 setScreens(screens); 25 } 26 }); 27 28 return () => { 29 ResponsiveObserve.unsubscribe(token.current); 30 }; 31}, []); 32 33const result = useMemo(() => { 34 let res = defaultValue; 35 if (isResponsiveValue(val)) { 36 for (let i = 0; i < responsiveArray.length; i++) { 37 const breakpoint = responsiveArray[i]; 38 if (screens[breakpoint] || (breakpoint === 'xs' && val[breakpoint] !== undefined)) { 39 res = (val[breakpoint] as number) || defaultValue; 40 break; 41 } 42 } 43 } else { 44 res = val; 45 } 46 return res; 47}, [screens, val, defaultValue]); 48return result; 49};

首先我们看 useResponsiveState hook,接收两个参数,一个是 val,可以是数字,例如 1, 表示在 display: grid 中的 column 数占 1 份。

column 就是你把当前容器切分成多少份,例如 12 份。 1 就代表占 1/12。还可以是响应式的,例如 { xs: 1, sm: 2, md: 3, lg: 4, xl: 6, xxl: 12 }, 表示在不同的屏幕宽度下,column 数不同。

其中默认设置的是例如

  • xs 表示的是 (max-width: 575px),表示在 575px 以下的屏幕宽度下,column 数为 1。
  • sm 表示的是 (min-width: 576px),表示在 576px 以上的屏幕宽度下,column 数为 2。
  • md 表示的是 (min-width: 768px),表示在 768px 以上的屏幕宽度下,column 数为 3。
  • ...等等

全部的 breakpoint 如下:

1export const responsiveMap = { 2 xs: '(max-width: 575px)', 3 sm: '(min-width: 576px)', 4 md: '(min-width: 768px)', 5 lg: '(min-width: 992px)', 6 xl: '(min-width: 1200px)', 7 xxl: '(min-width: 1600px)', 8 xxxl: '(min-width: 2000px)', 9};

其实这个是我组件写死的,后续会考虑是否可以根据业务场景,来动态调整 breakpoint。

然后 defaultValue 是默认值,当 val 不是响应式值时,或者当前屏幕宽度下,没有对应的 breakpoint 时,就会返回 defaultValue。是一个 number 值。

其中 useEffect 很重要,我们来看看做了什么: `=

1useEffect(() => { 2 token.current = ResponsiveObserve.subscribe((screens) => { 3 if (isResponsiveValue(val)) { 4 setScreens(screens); 5 } 6 }); 7 8 return () => { 9 ResponsiveObserve.unsubscribe(token.current); 10 }; 11}, []); 12};

这里调用了外部引入的 ResponsiveObserve 的 subscribe 方法。

作用就是,每当我们设置的 breakpoint 发生变化时,就会调用 subscribe 中的回调函数,将当前屏幕宽度下的 breakpoint 信息传递给我们。比如告诉我们

  • 当前屏幕宽度下,xs 这个 breakpoint 是否生效了。
  • 当前屏幕宽度下,sm 这个 breakpoint 是否生效了。
  • ...等等

现在的关键是,如何知道当前屏幕宽度下,哪个 breakpoint 生效了?也就是我们要去看看 ResponsiveObserve 的实现逻辑。

完整代码如下,我们来看看:

1export type Breakpoint = 'xxxl' | 'xxl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; 2export type BreakpointMap = Partial<Record<Breakpoint, string>>; 3export type ScreenMap = Partial<Record<Breakpoint, boolean>>; 4 5export const responsiveArray: Breakpoint[] = ['xxxl', 'xxl', 'xl', 'lg', 'md', 'sm', 'xs']; 6 7export const responsiveMap: BreakpointMap = { 8 xs: '(max-width: 575px)', 9 sm: '(min-width: 576px)', 10 md: '(min-width: 768px)', 11 lg: '(min-width: 992px)', 12 xl: '(min-width: 1200px)', 13 xxl: '(min-width: 1600px)', 14 xxxl: '(min-width: 2000px)', 15}; 16 17type SubscribeFunc = (screens: ScreenMap, breakpointChecked: Breakpoint) => void; 18 19let subscribers: Array<{ 20token: string; 21func: SubscribeFunc; 22}> = []; 23let subUid = -1; 24let screens = {}; 25 26const responsiveObserve = { 27matchHandlers: {}, 28dispatch(pointMap: ScreenMap, breakpointChecked: Breakpoint) { 29 screens = pointMap; 30 if (subscribers.length < 1) { 31 return false; 32 } 33 34 subscribers.forEach((item) => { 35 item.func(screens, breakpointChecked); 36 }); 37 38 return true; 39}, 40subscribe(func: SubscribeFunc) { 41 if (subscribers.length === 0) { 42 this.register(); 43 } 44 const token = (++subUid).toString(); 45 subscribers.push({ 46 token, 47 func, 48 }); 49 func(screens, null); 50 return token; 51}, 52unsubscribe(token: string) { 53 subscribers = subscribers.filter((item) => item.token !== token); 54 if (subscribers.length === 0) { 55 this.unregister(); 56 } 57}, 58unregister() { 59 Object.keys(responsiveMap).forEach((screen: Breakpoint) => { 60 const matchMediaQuery = responsiveMap[screen]; 61 const handler = this.matchHandlers[matchMediaQuery]; 62 if (handler && handler.mql && handler.listener) { 63 handler.mql.removeListener(handler.listener); 64 } 65 }); 66}, 67register() { 68 Object.keys(responsiveMap).forEach((screen: Breakpoint) => { 69 const matchMediaQuery = responsiveMap[screen]; 70 const listener = ({ matches }: { matches: boolean }) => { 71 this.dispatch( 72 { 73 ...screens, 74 [screen]: matches, 75 }, 76 screen, 77 ); 78 }; 79 const mql = window.matchMedia(matchMediaQuery); 80 mql.addListener(listener); 81 this.matchHandlers[matchMediaQuery] = { 82 mql, 83 listener, 84 }; 85 86 listener(mql); 87 }); 88}, 89}; 90 91export default responsiveObserve;

我们首先看到 subscribe 方法,首先会初始化一下 screens , 也就是当前屏幕宽度下,所有 breakpoint 是否生效的映射关系。

还需注意 subscribe 方法中,会把我们在 hooks 中注册的 func 函数,添加到 subscribers 数组中。

然后最关键的代码来了:

1const mql = window.matchMedia(matchMediaQuery); 2mql.addListener(listener);

window.matchMedia(matchMediaQuery) ,例如:window.matchMedia('(max-width: 575px)') ,返回的是一个 MediaQueryList 对象。

这个对象有一个 matches 属性,当 MediaQueryList 所描述的媒体查询条件被满足时,matches 属性的值为 true,否则为 false。

我们可以监听 MediaQueryList 对象的 change 事件,当媒体查询条件发生变化时,会触发 change 事件。

也就是说每当我们设置的断点发生变化,就会触发 change 事件,也就是我们我们注册的 listener 函数,这个函数会调用 this.dispatch 方法,这个

this.dispatch 方法实则把之前 subscribers 数组中的所有函数,都调用一遍,而这些注册的函数,实际上就是我们调用 hook 中,subscribe 方法注册的函数。

这就是这个函数总体的思路。

Grid 布局组件

了解 Grid 布局组件,可以直接去仓库看源码,主要是你需要了解 display: grid 布局的常见用法,比如 grid-template-columns、grid-template-rows、grid-gap 等。

还有就是 grid-column-start、grid-row-start、grid-column-end、grid-row-end 等属性,这些属性可以用来指定元素在 grid 布局中的起始位置。

总结

欢迎加入到我们的组件库交流群中。有什么疑问都可以在群里讨论,并且会有视频直播每个组件的实现。

更重要的是,我可以帮助你增加一些简历中的核心项目,例如我们这个组件库级别的。无论你是面试初级开发还是到前端技术专家,都会帮助你在面试中脱颖而出。

安装 Grid 组件

1npm i @t-headless-ui/react 2yarn i @t-headless-ui/react 3pnpm i @t-headless-ui/react

引入 Grid 组件

1import { Grid, Cell } from '@t-headless-ui/react';
前言
两类布局方案
百分比布局方案
vw/vh 布局
媒体查询方案
媒体查询核心 hook
Grid 布局组件
总结
安装 Grid 组件
引入 Grid 组件