372 lines
14 KiB
Plaintext
372 lines
14 KiB
Plaintext
---
|
||
title: 用户界面组件API设计原则
|
||
description: 设计开发者界面组件API的最佳实践,对UI组件编码和系统设计面试很有用
|
||
---
|
||
|
||
像[Bootstrap](https://getbootstrap.com/)和[Material UI](https://mui.com/)这样的用户界面组件库,通过提供常用的组件,如按钮、标签、模版等,帮助开发者更快地构建用户界面,从而使开发者在开始一个新项目时不必重新发明轮子,从头构建这些组件。
|
||
|
||
通常在前端面试中,你会被要求建立UI组件并设计一个API来初始化它们。 设计好的组件API是前端工程师的面包和黄油。 本页涵盖了设计UI组件API的一些顶级技巧和最佳实践。 其中一些提示可能是针对框架的,但也可以推广到其他基于组件的UI框架。
|
||
|
||
## 初始化
|
||
|
||
### jQuery 风格
|
||
|
||
在[React](https://reactjs.org/)、[Angular](https://angular.io/)和[Vue](https://vuejs.org/)等现代JavaScript UI库/框架出现之前,[jQuery](https://jquery.com/)(和[jQuery UI](https://jqueryui.com/))是构建UI的最流行方式。jQuery UI推广了通过涉及两个参数的 "构造函数 "初始化UI组件的想法:
|
||
|
||
1. **根元素**:一个根DOM元素,用来渲染内容。
|
||
2. **定制选项**:可选的、额外的、自定义的选项,通常以普通JavaScript对象的形式出现。
|
||
|
||
使用jQuery UI,人们可以用一行代码将一个DOM元素变成一个[slider](https://api.jqueryui.com/slider/)(以及许多其他UI组件):
|
||
|
||
```html
|
||
<div id="gfe-slider"></div>
|
||
<script>
|
||
$('#gfe-slider').slider();
|
||
</script>
|
||
```
|
||
|
||
**jQuery refresher**:jQuery UI的`slider()`方法(构造函数)接收了一个JavaScript对象,作为定制选项。 执行`$('#slider')`选择`<div id="slider">`元素并返回一个jQuery对象,其中包含方便的方法来对该元素 "做一些事情",如`addClass`,`removeClass`等和其他DOM操作方法。 在jQuery方法中,选定的元素可以通过`this`关键字访问。 jQuery的API是围绕这个 "选择一个元素并对其进行处理 "的方法而建立的,因此`slider()`方法不需要一个根DOM元素的参数。
|
||
|
||
slider 可以通过传入一个普通的JavaScript对象的选项来进行定制:
|
||
|
||
```html
|
||
<div id="gfe-slider"></div>
|
||
<script>
|
||
$('#gfe-slider').slider({
|
||
animate: true,
|
||
max: 50,
|
||
min: 10,
|
||
// See other options here: https://api.jqueryui.com/slider/
|
||
});
|
||
</script>
|
||
```
|
||
|
||
### Vanilla JavaScript 风格
|
||
|
||
在初始化组件方面没有vanilla JavaScript风格,因为vanilla JavaScript不是一个标准或框架。 但如果你读够了GreatFrontEnd对我们的vanilla JavaScript [UI编码问题](/questions/vanilla)的解决方案,你会发现我们推荐的API与jQuery的类似,构造函数接收一个根元素和选项:
|
||
|
||
```js
|
||
function slider(rootEl, options) {
|
||
// Do something with rootEl and options.
|
||
}
|
||
```
|
||
|
||
### React
|
||
|
||
React迫使你把UI写成包含其自身逻辑和外观的组件。 React组件是返回标记的JavaScript函数(关于如何呈现自身的描述)。 React组件可以接受 "props",它本质上是对组件选项的定制。
|
||
|
||
```js
|
||
function Slider({ min, max }) {
|
||
// Use the props to render a customized component.
|
||
return <div>...</div>;
|
||
}
|
||
|
||
<Slider max={50} min={10} />;
|
||
```
|
||
|
||
组件不会在 root 元素中。 为了将该元素渲染到页面中,需要使用一个单独的API。
|
||
|
||
```jsx
|
||
import { createRoot } from 'react-dom/client';
|
||
import Slider from './Slider';
|
||
|
||
const domNode = document.getElementById('#gfe-slider');
|
||
// React will manage the DOM within this element.
|
||
const root = createRoot(domNode);
|
||
// Display the Slider component within the element.
|
||
root.render(<Slider max={50} min={10} />);
|
||
```
|
||
|
||
如果整个页面是React应用,你通常不需要自己调用`createRoot()`,因为根/页级组件只有一个`createRoot`调用。
|
||
|
||
## 定制外观
|
||
|
||
尽管UI库中的UI组件提供了默认的样式,但开发者通常希望用他们公司/产品的品牌和主题颜色来定制它们。 因此,所有的用户界面组件将允许通过一些方法来定制外观:
|
||
|
||
### 类注入
|
||
|
||
这里的想法很简单,组件接受一个prop/option,允许开发者提供他们自己的类,这些类被添加到实际的DOM元素中。 这种方法不是很稳健,因为如果组件也通过类来添加自己的样式,那么在组件的类和开发者提供的类中可能会有冲突的属性。
|
||
|
||
#### React
|
||
|
||
```jsx
|
||
import clsx from 'clsx';
|
||
|
||
function Slider({ className, value }) {
|
||
return (
|
||
<div className={clsx('gfe-slider', className)}>
|
||
<input type="range" value={value} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
<Slider className="my-custom-slider" value={50} />;
|
||
```
|
||
|
||
```css
|
||
/* UI library default stylesheet */
|
||
.gfe-slider {
|
||
height: 12px;
|
||
}
|
||
```
|
||
|
||
```css
|
||
/* Developer's custom stylesheet */
|
||
.my-custom-slider {
|
||
color: red;
|
||
}
|
||
```
|
||
|
||
通过类注入,开发者可以将组件的文本`color`改为`red`。
|
||
|
||
如果组件内有许多DOM元素,一个`className` prop 是不够的,你也可以为不同元素的`className` 设置多个不同名称的 prop:
|
||
|
||
```jsx
|
||
import { useId } from 'react';
|
||
import clsx from 'clsx';
|
||
|
||
function Slider({ label, value, className, classNameLabel, classNameTrack }) {
|
||
const id = useId();
|
||
return (
|
||
<div className={clsx('gfe-slider', className)}>
|
||
<label className={clsx('gfe-slider-label', classNameLabel)} for={id}>
|
||
{label}
|
||
</label>
|
||
<input
|
||
className={clsx('gfe-slider-range', classNameRange)}
|
||
id={id}
|
||
type="range"
|
||
value={value}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### jQuery
|
||
|
||
在jQuery中,类也可以作为选项的一个字段传递。
|
||
|
||
```js
|
||
$('#gfe-slider').slider({
|
||
// 在现实中,jQuery UI 在 'class' 字段中使用
|
||
// 因为有多个元素。
|
||
class: 'my-custom-slider',
|
||
});
|
||
```
|
||
|
||
在现实中,所有jQuery UI的组件初始化器都接受`classes`字段,以允许添加额外的类到单个元素。 下面的例子取自[jQuery UI Slider](https://api.jqueryui.com/slider/#option-classes):
|
||
|
||
```js
|
||
$('#gfe-slider').slider({
|
||
classes: {
|
||
'ui-slider': 'highlight',
|
||
'ui-slider-handle': 'ui-corner-all',
|
||
'ui-slider-range': 'ui-corner-all ui-widget-header',
|
||
},
|
||
});
|
||
```
|
||
|
||
#### 非确定性风格
|
||
|
||
类注入有一个不明显的缺点--最终的视觉结果是不确定的,可能不是预期的那样。 以下面的代码为例:
|
||
|
||
```jsx
|
||
import clsx from 'clsx';
|
||
|
||
function Slider({ className, value }) {
|
||
return (
|
||
<div className={clsx('gfe-slider', className)}>
|
||
<input type="range" value={value} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
<Slider className="my-custom-slider" value={50} />;
|
||
```
|
||
|
||
```css
|
||
/* UI library 默认样式表 */
|
||
.gfe-slider {
|
||
height: 12px;
|
||
color: black;
|
||
}
|
||
```
|
||
|
||
```css
|
||
/* 开发者的自定义样式表 */
|
||
.my-custom-slider {
|
||
color: red; /* .gfe-slider 也定义了一个颜色的值。*/
|
||
}
|
||
```
|
||
|
||
在上面的例子中,`.gfe-slider`和`.my-custom-slider`类都指定了`color`,由于这两个选择器具有相同的特殊性,获胜的样式实际上是后来出现在HTML页面上的类。 如果样式表的加载顺序没有保证(例如,如果样式表是懒惰地加载的),那么视觉结果将不是确定的。 这时,开发者开始使用`!important`或`.my-custom-slider.my-custom-slider`等黑客手段,让他们的选择器赢得特异性战争,CSS代码开始变得不可维护。
|
||
|
||
在jQuery UI中,如果一个自定义类被添加,现有的默认值不会被使用。 这消除了 "获胜风格 "的模糊性,但用户现在必须重新实现原类中存在的所有必要风格。 这种方法也可以应用于React组件,以解决模糊不清的问题。
|
||
|
||
尽管它可能存在缺陷,但类注入仍然是一个非常受欢迎的选择。
|
||
|
||
### CSS 选择器钩子
|
||
|
||
从技术上讲,如果开发者阅读组件的源代码,并通过使用相同的类来定义他们的自定义样式,就可以实现定制。 然而,这样做是很危险的,因为依赖组件的内部结构,而且不能保证类名在将来不会改变。
|
||
|
||
如果UI库的作者能够将这些类/属性作为他们的API的一部分,这就有了这些保证:
|
||
|
||
1. 选择器列表已发布,供外部参考。
|
||
2. 已发布的选择器将不会被更改。 如果它们被改变,这将是一个破坏性的变化,需要按照semver的要求进行版本升级。
|
||
|
||
然后,这是一种可接受的做法,开发者可以通过在他们的样式表中使用这些选择器来 "钩住 "它们(针对它们)。
|
||
|
||
钩住一个组件的选择器的一个例子:
|
||
|
||
```jsx
|
||
import { useId } from 'react';
|
||
import clsx from 'clsx';
|
||
|
||
function Slider({ label, value }) {
|
||
const id = useId();
|
||
return (
|
||
<div className="gfe-slider">
|
||
<label className="gfe-slider-label" for={id}>
|
||
{label}
|
||
</label>
|
||
<input className="gfe-slider-range" id={id} type="range" value={value} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
```css
|
||
/* UI library 默认样式表 */
|
||
.gfe-slider {
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 在这个样式表中没有定义其他的类,gfe-slider-label和gfe-slider-range被添加到组件中,只是为了让开发者获得对底层元素的访问。 */
|
||
```
|
||
|
||
```css
|
||
/* Developer's 默认样式表 */
|
||
.gfe-slider {
|
||
font-size: 16px; /* 与默认的.gfe-slider冲突 */
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
.gfe-slider-label {
|
||
color: red;
|
||
}
|
||
|
||
.gfe-slider-range {
|
||
height: 20px;
|
||
}
|
||
```
|
||
|
||
这种方法为开发者省去了在组件中传递类的麻烦,因为他们只需要编写CSS来定制样式。 [Reach UI](https://reach.tech/styling)是React的一个无头UI组件库,使用元素选择器。 每个组件在底层DOM元素上都有一个`data-reach-*`属性。
|
||
|
||
```css
|
||
[data-reach-menu-item] {
|
||
color: blue;
|
||
}
|
||
```
|
||
|
||
然而,这种方法仍然受到 "类注入 "的非确定性风格问题的影响,并且不容易允许按实例进行样式设计。 如果需要每个实例的样式,这种方法可以与类的注入方法相结合。
|
||
|
||
### 主题对象
|
||
|
||
该组件不是接收类,而是接收一个用于样式的键/值的对象。 如果只有一个严格的属性子集需要定制,或者你想把样式限制在几个属性上,这就很有用。
|
||
|
||
```jsx
|
||
const defaultTheme = { color: 'black', height: 12 };
|
||
|
||
function Slider({ value, label, theme }) {
|
||
// Combine with default.
|
||
const finalTheme = { ...defaultTheme, ...theme };
|
||
|
||
return (
|
||
<div className="gfe-slider">
|
||
<label
|
||
for={id}
|
||
style={{
|
||
fontSize: finalTheme.color,
|
||
}}>
|
||
{label}
|
||
</label>
|
||
<input
|
||
id={id}
|
||
type="range"
|
||
value={value}
|
||
style={{
|
||
height: finalTheme.height,
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
<Slider themeOptions={{ color: 'red', height: 24 }} {...props} />;
|
||
```
|
||
|
||
然而,由于没有使用有冲突的样式的类,而且内联样式的特异性比类高,所以没有特异性冲突,内联样式将获胜。 然而,需要支持的选项数量可能真的会迅速增长。 内联样式也存在于每个组件实例的DOM中,如果这个组件在一个页面中被渲染了数百/数千次,这可能对性能不利。
|
||
|
||
主题对象只是一种将造型限制在某些属性和可选的一组值上的方法,这些值不需要作为内联样式使用,而是可以与其他造型方法相结合。
|
||
|
||
### CSS预处理程序的编译
|
||
|
||
UI库通常用CSS预处理器编写,如[Sass](https://sass-lang.com/)和[Less](https://lesscss.org/)。 [Bootstrap](https://getbootstrap.com/)是用Sass编写的,他们提供了一种方法来[自定义所使用的Sass变量](https://getbootstrap.com/docs/5.3/customize/sass/),以便开发者可以生成一个自定义的UI库样式表。
|
||
|
||
这种方法很好,因为它不依赖覆盖CSS选择器来实现定制。 此外,产生的CSS数量也较少,没有多余的重写样式。 缺点是需要一个编译的步骤。
|
||
|
||
### CSS 变量 / 自定义属性
|
||
|
||
[CSS变量](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)(或更正式地称为CSS自定义属性)是由CSS作者定义的实体,它包含特定的值以便在整个文档中重复使用。 `var()`函数,如果给定的变量未被设置,它接受回退值。
|
||
|
||
```jsx
|
||
function Slider({ value, label }) {
|
||
return (
|
||
<div className="gfe-slider">
|
||
<label for={id}>{label}</label>
|
||
<input id={id} type="range" value={value} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
```css
|
||
/* UI library default stylesheet */
|
||
.gfe-slider {
|
||
/* 如果没有设置,则回调为12px。 */
|
||
font-size: var(--gfe-slider-font-size, 12px);
|
||
}
|
||
```
|
||
|
||
```css
|
||
/* Developer's custom stylesheet */
|
||
:root {
|
||
--gfe-slider-font-size: 15px;
|
||
}
|
||
```
|
||
|
||
开发者可以通过`:root`选择器为`--gfe-slider-font-size`全局定义一个值,并为`.gfe-slider`类设置字体大小为15px。 这种方法的好处是它不需要JavaScript,然而,每个组件的定制将更加麻烦(但仍有可能)。
|
||
|
||
### Render Props
|
||
|
||
在React中,render props 是一个组件用来知道要渲染什么的函数prop。 它对于将行为和表现形式分开是很有用的。 许多行为/无头UI库,如[Radix](https://www.radix-ui.com/)、[Headless UI](https://headlessui.com/)和[Reach UI](https://reach.tech/menu-button)大量使用了render props。
|
||
|
||
{/* TODO: Section on manipulation of component after initialization */}
|
||
|
||
## 国际化 (i18n)
|
||
|
||
你的用户界面是否适用于多种语言? 增加对更多语言的支持有多容易?
|
||
|
||
### 避免用某种语言对标签进行硬编码
|
||
|
||
一些UI组件内有标签字符串(例如,图像轮播有上一个/下一个按钮的标签)。 如果能让这些标签字符串成为组件props/options的一部分,允许自定义这些标签字符串就更好了。
|
||
|
||
### 从右到左的语言
|
||
|
||
有些语言(如阿拉伯语、希伯来语)是从右向左阅读的,用户界面必须水平翻转。 该组件可以接受一个 "方向 "props/options,并改变元素的渲染顺序。 例如,在RTL语言中,上一页和下一页的按钮将分别在右边和左边。
|
||
|
||
使用[CSS逻辑属性](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties)来为你的风格做准备,让你的布局适用于不同的[书写模式](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Writing_Modes)。
|
||
|
||
{/* TODO: Give examples of how to implement RTL. */}
|