在 react 中优雅的使用 grid 实现页面布局

本文最后更新于:2020年12月6日 上午

场景

吾辈在做 electron 应用的时候遇到了这种布局,顶部是 header,然后是页面中的 toolbar,紧接着右边有一个侧边栏列表,左侧的内容又分为了两块区域。这种布局在中后台系统中应该很常见,但之前并未特别留意过布局通用化。

布局.drawio.svg

  • 使用 css calc() 计算高度
  • 基于 css calc() 封装 Col/Row 组件,然后使用组件进行布局(主要模仿 antd grid)
  • 使用 css grid 自适应布局
  • 使用 css grid 封装组件

使用 css calc() 计算高度

最初,吾辈使用 css calc() 计算剩余高度,以占满全部高度。但这样实现存在的一个明显问题是,每当吾辈需要在纵向添加一行时,都要修改 calc() 重新计算高度。如果存在嵌套内容时,甚至会导致层层声明 height: calc(100% - *px),而且难以复用,这是很难接受的。

下面是一个实现上面那种布局的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>grid test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
.container {
height: 100%;
}
.header {
height: 64px;
}
.main {
height: calc(100% - 64px);
}
.toolbar {
height: 64px;
}
.content {
height: calc(100% - 64px);
display: flex;
}
.main-content {
display: inline-block;
width: calc(100% - 300px);
height: 100%;
}
.sider {
display: inline-block;
width: 300px;
height: 100%;
}
.form {
height: 300px;
}
.list {
height: calc(100% - 300px);
}
.header,
.main,
.toolbar,
.content,
.sider,
.form,
.list {
border: solid 1px red;
overflow-y: auto;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<nav class="header">header</nav>
<main class="main">
<header class="toolbar">toolbar</header>
<div class="content">
<div class="main-content">
<section class="form">form</section>
<section class="list">list</section>
</div>
<section class="sider">sider</section>
</div>
</main>
</div>
</body>
</html>

可以看到,不仅 html 部分嵌套众多,css 部分也需要反复计算宽高,这实在不是一件令人愉快的代码。

基于 css calc() 封装 Col/Row 组件布局

由于不希望每次都去写类似下面的代码,因而吾辈产生了封装组件的想法。

1
2
3
4
.container {
height: 100%;
overflow-y: auto;
}

初次尝试

基本思路来源于 antd 的 Grid 布局,之所以没有使用 antd grid 的原因是它并不支持纵向的布局,即便是 Layout 布局组件也不是那么好用。

  • FullHeight: 让子组件占满父组件的全部高度
  • VerticalCol: 占全高的一列
  • VerticalRow: 可控制占比列中的一行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
type FullHeightProps = {
children: ReactElement;
};

/**
* 让子组件占满父组件的全部高度
*/
const FullHeight: React.FC<FullHeightProps> = (props) => {
return cloneElement(props.children, {
...props.children.props,
style: {
height: "100%",
overflowY: "auto",
...props.children.props.style,
},
});
};

// 垂直布局的上下文

export type VerticalColContextType = {
//分割的份数
gutter: number;
};

/**
* 垂直布局的上下文环境
*/
export const VerticalColContext = createContext<VerticalColContextType>({
gutter: 24,
});

type VerticalColProps = {
children: ReactElement<VerticalRowProps> | ReactElement<VerticalRowProps>[];
style?: CSSProperties;
};

/**
* 垂直布局的一列
* 默认占父容器的全部高度
*/
const VerticalCol = React.forwardRef<HTMLDivElement, VerticalColProps>(
(props, ref) => (
<VerticalColContext.Provider
value={{
gutter: 24,
}}
>
<FullHeight>
<div style={props.style} ref={ref}>
{props.children}
</div>
</FullHeight>
</VerticalColContext.Provider>
)
);

export type VerticalRowProps = {
span?: number;
style?: CSSProperties;
};

/**
* 用来控制每一行占比的元素
*/
const VerticalRow: React.FC<VerticalRowProps> = (props) => {
const { gutter } = useContext(VerticalColContext);
return (
<div
style={{
height: `calc(100% / ${gutter} * ${props.span})`,
overflowY: "auto",
...props.style,
}}
>
{props.children}
</div>
);
};

VerticalRow.defaultProps = {
span: 0,
};

基本使用大概长这样

1
2
3
4
5
6
7
8
9
10
11
12
<VerticalCol
style={{
height: "100%",
}}
>
<VerticalRow span={2} style={{ backgroundColor: "red" }}>
内容块 1
</VerticalRow>
<VerticalRow span={22} style={{ backgroundColor: "green" }}>
内容块 2
</VerticalRow>
</VerticalCol>

看起来还不错,简单的使用组件就可以按比例分割页面了。但一旦在复杂的实际页面使用时,麻烦就接踵而来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<VerticalCol
style={{
height: 800,
}}
>
<VerticalRow span={2} style={{ backgroundColor: "red" }}>
<header>
<h1>标题</h1>
</header>
</VerticalRow>
<VerticalRow span={22} style={{ backgroundColor: "green" }}>
<FullHeight>
<Row>
<FullHeight>
<Col
span={18}
style={{
backgroundColor: "aqua",
}}
>
内容区域
</Col>
</FullHeight>
<FullHeight>
<Col
span={6}
style={{
backgroundColor: "blue",
}}
>
{Array(100)
.fill(0)
.map((_, i) => (
<h2
style={{
color: "white",
}}
>
{i}
</h2>
))}
</Col>
</FullHeight>
</Row>
</FullHeight>
</VerticalRow>
</VerticalCol>

可以看到,这里仅仅是使用 Component 代替了 css class 而已,并未减少页面布局的复杂性,而且由于使用组件进行布局,导致组件的结构变得更深了。

使用 css grid 自适应布局

css grid 确实强大无比,尤其是 grid-template-areas 功能,直接改变了传统网页的布局方式。现在,自适应布局变得异常简单。

下面是使用 grid 的方式实现布局的代码,可以看到最复杂的部分其实是在 grid-template-areas,它确定了不同元素占有的区块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>grid test</title>
<style>
html,
body,
.grid-container {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.grid-container {
height: 100%;
display: grid;
grid-template-columns: 1fr 300px;
grid-template-rows: 64px 64px 430px 1fr;
grid-template-areas:
"header header"
"toolbar toolbar"
"form sider"
"list sider";
}
.header {
grid-area: header;
}
.toolbar {
grid-area: toolbar;
}
.sider {
grid-area: sider;
}
.form {
grid-area: form;
}
.list {
grid-area: list;
}
.header,
.toolbar,
.sider,
.form,
.list {
border: 1px solid red;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="grid-container">
<nav class="header"></nav>
<header class="toolbar"></header>
<section class="sider"></section>
<section class="form"></section>
<section class="list"></section>
</div>
</body>
</html>

但 grid 也并非尽善尽美,它仅对直接子组件生效,而孙子及其子节点则不在 grid 的布局范围,这导致为子组件编写 grid 布局样式时,仍然存在一些样板代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* 下面是典型的样板代码 */
.grid-container {
height: 100%;
display: grid;
}
.header {
grid-area: header;
}
.toolbar {
grid-area: toolbar;
}
.sider {
grid-area: sider;
}
.form {
grid-area: form;
}
.list {
grid-area: list;
}
.header,
.toolbar,
.sider,
.form,
.list {
overflow-y: auto;
}

使用 css grid 封装组件

事实上,吾辈确实找到了一个现有的基于 grid 的 react 布局组件库 react-grid-layout,而且非常强大,但很遗憾的是它并 不支持 react 17。但其 api 及封装方式确实有参考意义,所以吾辈也尝试封装一个 Grid 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type Unit = `${number}${"px" | "fr"}`;

type GridProps<
T extends string,
R extends Unit[] = Unit[],
C extends Unit[] = Unit[],
A extends { [P in keyof R]: { [P in keyof C]: T } } = {
[P in keyof R]: { [P in keyof C]: T };
}
> = {
rows: R;
cols: C;
areas: A;
children: ReactElement[];
} & CommonStyleProps;

/**
* 使用 grid 进行布局的容器组件
*/
function Grid<T extends string>(props: GridProps<T>) {
return (
<div
className={props.className}
style={
{
height: "100%",
display: "grid",
gridTemplateColumns: props.cols.join(" "),
gridTemplateRows: props.rows.join(" "),
gridTemplateAreas: props.areas
.map((row) => '"' + row.join(" ") + '"')
.join(" "),
...props.style,
} as CSSProperties
}
>
{props.children.map((child) => {
console.log("child key: ", child.key);
return cloneElement(child, {
...child.props,
style: {
gridArea: child.key,
border: "solid 1px red",
} as CSSProperties,
});
})}
</div>
);
}

使用起来比较简单,唯一残念的是子元素中的 key 无法使用 ts 自动推导或强制约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Grid<AreaItemName>
style={{
height: "calc(100vh - 48px)",
}}
rows={["48px", "48px", "300px", "1fr"]}
cols={["1fr", "300px"]}
areas={[
["header", "header"],
["toolbar", "toolbar"],
["form", "sider"],
["list", "sider"],
]}
>
<header key={"header" as AreaItemName}>header</header>
<section key={"toolbar" as AreaItemName}>toolbar</section>
<section key={"sider" as AreaItemName}>sider</section>
<section key={"form" as AreaItemName}>form</section>
<section key={"list" as AreaItemName}>list</section>
</Grid>

看起来不错,然而实际上这还是有缺陷的。

  1. 子组件必须处理了 props.style,因为 cloneElement 仅仅为原组件注入了属性(默认的 react html 元素均已处理)
    1. 当布局的子组件需要监听滚动时,由于外层使用 div 在自定义组件外部,实际上并不能取到 ref,需要在业务组件再设置一次滚动。
  2. 类型约束未能完全生效,例如子元素的 key,就没办法做到类型自动推导限定
  3. 子组件内容过多时仍然会导致父元素出现滚动条

接下来一个个解决

1. 子组件必须处理了 props.style,因为 cloneElement 仅仅为原组件注入了属性(默认的 react html 元素均已处理)

当需要为自定义的业务组件布局时,有两种思路

  1. 在外层套 div
  2. 由组件处理 props.style

但这两种方式各有优劣

前者父组件的布局不会受到子组件影响,意味着子组件更容易被复用。后者在子组件内部需要使用 ref 时,不需要再额外声明 css 以使之成为可滚动元素。

下面是一个混合使用两者的的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const style: CSSProperties = { border: "solid 1px red" };
type AreaItemName = "header" | "content" | "sider";

function Header() {
return <header style={style}>header</header>;
}

function Content() {
return <main style={style}>content</main>;
}

function Sider(props: CommonStyleProps) {
const $ref = useRef<HTMLElement>(null);
return wrapper(
<section style={style} ref={$ref}>
<ul>
{Array(100)
.fill(0)
.map((_, i) => (
<li key={i}>{i}</li>
))}
</ul>
<BackTop target={() => $ref.current!}>
<ToTopOutlined
style={
{
fontSize: 24,
padding: 8,
borderRadius: "50%",
color: "#ffffff",
backgroundColor: "#8ECAFE",
} as CSSProperties
}
/>
</BackTop>
</section>,
props
);
}

return (
<Grid<AreaItemName>
style={{
height: "calc(100vh - 48px)",
}}
rows={["48px", "1fr"]}
cols={["1fr", "300px"]}
areas={[["header", "header"]]}
>
<div key={"header" as AreaItemName}>
<Header />
</div>
<Content />
<Sider />
</Grid>
);

2. 类型约束未能完全生效,例如子元素的 key,就没办法做到类型自动推导限定

目前很难处理 ReactElement 类型,导致相关的类型自动推导特别麻烦,so…暂时没有好的办法。

3. 子组件内容过多时仍然会导致父元素出现滚动条

为子组件设置默认 height/overflowY 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Grid<T extends string>(props: GridProps<T>) {
const { cols = [], rows = [], areas = [], children } = props;
return (
<div
className={props.className}
style={
{
height: "100%",
overflowY: "auto",
display: "grid",
gridTemplateColumns: cols.join(" "),
gridTemplateRows: rows.join(" "),
gridTemplateAreas: areas
.map((row) => '"' + row.join(" ") + '"')
.join(" "),
...props.style,
} as CSSProperties
}
>
{children.map((child) => {
return cloneElement(child, {
...child.props,
style: {
// 这里是关键
height: "100%",
overflowY: "auto",
gridArea: child.key,
...child.props.style,
} as CSSProperties,
});
})}
</div>
);
}

总结

老实说吾辈对现代前端的生态乱象表示很讨厌,尤其是 react 生态的 css 处理方案,仅 css-in-js 就有几十种可选方案,而官方又没有作为,导致没有统一的方式(vue 在这点上好很多)处理它们,所以吾辈才选择了 ui 组件 + css module 的方式。乍一看似乎回到了 HTML 标签控制样式的情况,但相比之下组件比 HTML 标签灵活得多,而且更容易扩展和组合(如果使用了 css 预处理器就是另外一回事了)。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!