本文主要介绍一些 React 的设计模式,以及在具体的开发中是如何使用这些模式。
正如「Thinking in React」所说的那样,React 改变了开发 Web 应用的思考方式,组件化的开发模式被广泛地应用到了界面的开发中。
当我们使用 React 开发用户界面时,会按部就班地去开发。首先,会将页面拆分成组件。然后,将为每个组件设置不同的 state。最后,你将这些组件组装在一起,以便数据可以流经它们。
拆分 UI 为组件树结构 我们首先将用户界面(UI)分解为一个组件树结构。每个组件都代表了界面上的一个可重用部分,这样可以使代码更具可维护性和可扩展性。
创建无交互的静态页面 在开始添加交互之前,我们会先实现一个静态页面版本。这个版本中,所有的组件都是静态的,没有任何交互。这有助于确保 UI 的外观和布局符合设计要求。
定义最小的 state 集合 接下来,我们确定哪些组件需要拥有状态(state),以及这些状态的最小集合。这有助于确保状态的管理更加简单,不会引入不必要的复杂性。
确定 state 所在组件位置 我们将状态分配给正确的组件,并确保状态位于最接近需要访问它的组件内。这有助于确保数据的合理流动,同时减少不必要的状态共享。
实现逆向数据流变化 最后,我们引入交互性,实现逆向数据流。这意味着当用户与界面互动时,状态会相应地更新,以反映用户的操作。这通常涉及到事件处理、状态更新和重新渲染组件。
大部分场景下,我们都可以通过以上的步骤,把用户界面开发出来。但是对于一些较为复杂或特殊的用户界面或应用时,我们也需要一些通用解决方案或最佳实践,来提高代码的可维护性、可读性和可扩展性。
在复合组件模式中,一个组件可以包含其他子组件,形成一个组件树,从而实现更高级的功能和复杂的 UI 界面,每个子组件各自负责自己的职责和功能,而父组件则负责协调和管理子组件之间的交互和数据传递。复合组件主要是通过将多个组件以父子组件的方式来实现某项功能,利用显式父子关系来共享隐式状态。
复合组件的特点是,将所有 UI 状态逻辑都放在父组件中,并在内部与所有子组件进行通信,从而明确划分责任。为了实现共享状态,通常是要通过 Context 来实现。下面以一个具体的案例来进行介绍,比如我们要为 SaaS 系统设计的一套插件系统,主系统和插件是两个不同的项目,主系统要将插件加载并渲染出来。我们通过 ExtensionKit.Provider 和 ExtensionKit.Slot 两个组件,来实现这个功能。
ExtensionKit.Provider 请求接口获取到所有插件,并将这些插件信息存储于 context
ExtensionKit.Slot 用于渲染该位置上对应的插件,而插件的信息来自于 context
这么做的好处在于,首先是对主系统的改造较小,它的状态和逻辑都隐藏到组件的内部,主系统是无感知的。其次是便于维护,增加或删除 Slot 的成本都比较低。
在具体的实现上,复合组件基本离不开对 Context 和 children 的处理:
jsx
const ExtensionContext = React.createContext();
// ExtensionKit.Provider
const Provider = ({ children, config }) => {
const [extension, setExtension] = React.useState();
React.useEffect(() => {
const extension = fetchExtension(config);
setExtension(extension);
}, [config]);
return (
<ExtensionContext.Provider value={extension}>
{React.Children.map(children, (child) => child)}
</ExtensionContext.Provider>
);
};
// ExtensionKit.Slot
const Slot = ({ location }) => {
const extension = React.useContext(ExtensionContext);
const { ExtensionUI, data } = extension[location];
// ExtensionUI 是要渲染的插件
return React.createElement(ExtensionUI, { data });
};
export default { Provider, Slot };
接下来我们介绍一个不常见的场景,在插件系统中我们期望插件的开发人员能够按照一定的规则去写插件。比如,我们要求插件的最外层是 ExtensionUI.Extension,插件的标题必须要用 ExtensionUI.Header 来包裹,而插件的内容部分要放到 ExtensionUI.Body 中。而在 ExtensionUI.Extension 的中出现 div 等其他的子元素时,则将这些子元素视为有风险的坏东西,并将这些元素忽略掉不做任何渲染。
因此,要将正确的元素识别出来,过滤掉错误的元素。实现这个功能需要设置组件的 displayName,然后将符合要求的 displayName 所对应的组件渲染出来。下面是具体的示例代码:
jsx
// ExtensionUI.jsx
const ELEMENT_NAME = ["ExtensionUI.Header"];
// ExtensionUI.Header
const Header = (props) => {
const { children } = props;
return <>{children}</>
};
Header.displayName = "ExtensionUI.Header"
// ExtensionUI.Extension
const Extension = (props) => {
const { children } = props;
return (
<>
{ React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
const { displayName } = child.type;
if (ELEMENT_NAME.includes(displayName)) {
return child;
}
}
return null;
}) }
</>
);
};
const Body = () => { ... ... }
export default { Extension, Header, Body };
在这段代码中,将 ExtensionUI.Header 组件的 displayName 设置为 "ExtensionUI.Header",并且将 children 直接进行渲染。在 ExtensionUI.Header 组件中,我们会对它的子组件进行过滤,只有 ELEMENT_NAME 包含的 displayName 才会被渲染。
由于复合组件在使用上比较灵活,把子组件组合在一起的之后,有时需要在渲染时要将它们的位置做一些调整,也可以通过 displayName 来识别出组件,然后放置到预期的位置上。
在 React 开发的实践中,我们习惯于将组件树和数据流都适配成了 DOM 树的形状。在这种单向数据流中,数据从父组件传递给子组件。当嵌套层次较深的时候,很容易造成数据的层层透传,让数据的传递变得难以管理。
那么能不能让组件树不在适配 DOM 树的形状?答案是肯定的,我们可以让组件树来适应数据流,进而来减少组件的嵌套层级。虽然组件树可以不适配 DOM 树的形状,但是渲染的结果还是要渲染出正确的 DOM 树。这里需要使用 Portal 把组件传送到正确的位置,即便我们重置了组件间的距离,但依然保持 DOM 结构不变。
这种模式被称为非受控复合组件,它的关键点主要有以下几点:
用 Context 共享元素位置 在父组件中定义 context 来存储数据。这与复合组件类似,但是差别在于,非受控复合组件是将 DOM 元素放存放在 context 中,以便 context 中的所有组件都可以访问 DOM 中的这个位置。
Ref callback 定位到元素位置 要用 ref 来获取 DOM 元素,并将 DOM 元素的信息更新到 context 中。这里我们不能使用 ref 对象,因为我们需要在 render 中使用它,而且要在设置它的时候触发更新。
使用 Portal 连接到该位置 子组件从 context 中获取到 DOM 元素,并对应的组件进行 portal,最终在正确的位置上渲染出来。
虽然,这种模式看起来很复杂,可能平常也不太会用到,但是在某些特殊场景中,这种模式可以让复杂的问题变得更简单。接下来继续以插件系统为例,来介绍一下如何使用非受控复合组件。
在插件系统中,主系统使用 ExtensionKit.Provider 和 ExtensionKit.Slot 来接入和渲染插件;而插件的实现是由 ExtensionUI.Extension、ExtensionUI.Header 和 ExtensionUI.Body 组合而成。而在实际的开发中,还要解决一个比较棘手的问题。
主系统的页面有自己的 Header 和 Body,而在渲染的时候要渲染插件的 ExtensionUI.Header 和 ExtensionUI.Body。但是,主系统和插件是两个独立的项目,有各自的代码仓库,通过 ExtensionContext.Slot 将插件在主系统的 Body 中渲染出来;这导致了 ExtensionUI.Header 没有在主系统的 Header 中渲染,而我们期望的是 ExtensionUI.Header 出现在主系统的 Header 里。
这个问题的解法就是非受控复合组件。首先是在 ExtensionContext.Provider 中定义 Context:
jsx
// ExtensionKit.jsx
const ExtensionContext = React.createContext();
// ExtensionKit.HeaderRefContext
const HeaderRefContext = React.createContext();
// ExtensionKit.Provider
const Provider = ({ children, config }) => {
const [extension, setExtension] = React.useState();
const [headerRef, setHeaderRef] = React.useState();
React.useEffect(() => {
const extension = fetchExtension(config);
setExtension(extension);
}, [config]);
return (
<ExtensionContext.Provider value={extension}>
<HeaderRefContext.Provider value={[headerRef, setHeaderRef]}>
{React.Children.map(children, (child) => child)}
</HeaderRefContext.Provider>
</ExtensionContext.Provider>
);
};
// ExtensionKit.Slot
const Slot = ({ location }) => {
const extension = React.useContext(ExtensionContext);
const headerRef = React.useContext(HeaderRefContext);
const { ExtensionUI, data } = extension[location];
// ExtensionUI 是要渲染的插件
return React.createElement(ExtensionUI, { data, headerRef });
};
export default { HeaderRefContext, Provider, Slot };
在上面的代码中,我们定义了 headerRef 用于存储主系统 Header 所对应的 DOM 元素;并且要在 Slot 渲染插件的时候,将 headerRef 给到 ExtensionUI.Extension。接下来要在主系统中,将 Header 的 DOM 元素更新到 headerRef 中,代码如下:
jsx
// Header.jsx
// 主系统的 Header
const Header = () => {
const [, setHeaderRef] = React.useContext(ExtensionKit.HeaderRefContext);
return <div ref={setHeaderRef} />;
};
然后也要对 ExtensionUI.Header 中的代码做对应的调整,要把它里面的元素 portal 到主系统的 Header 中:
jsx
// ExtensionUI.jsx
const ELEMENT_NAME = ["ExtensionUI.Header"];
// ExtensionUI.Header
const Header = (props) => {
// headerRef 是 Slot 给到的数据
const {children, headerRef} = props;
return headerRef
? ReactDOM.createPortal(
children,
headerRef,
)
: null;
}
Header.displayName = "ExtensionUI.Header"
// ExtensionUI.Extension
const Extension = (props) => {
const { children } = props;
return (
<>
{ React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
const { displayName } = child.type;
if (ELEMENT_NAME.includes(displayName)) {
return child;
}
}
return null;
}) }
</>
);
};
const Body = () => { ... ... }
export default { Extension, Header, Body };
主系统的中的代码是这样组织的:
jsx
const App = () => {
return (
<ExtensionKit.Provider>
<Header />
<Body>
<ExtensionKit.Slot />
</Body>
</ExtensionKit.Provider>
);
};
插件的代码是这样组织的:
jsx
const App = (props) => {
const { data, headerRef } = props;
return (
<ExtensionUI.Extension>
<ExtensionUI.Header headerRef={headerRef}>
This is header ...
</ExtensionUI.Header>
<ExtensionUI.Body data={data}>This is body ...</ExtensionUI.Body>
</ExtensionUI.Extension>
);
};
经过这一系列的处理,就可以将插件 ExtensionUI.Header 里面的内容渲染到主系统的 Header 中。这么做的好处在于,提高了插件的开发体验,对于插件的开发者来说,完全是按着正常的页面结构在开发。在某些情况下,ExtensionUI.Header 中的元素会和 ExtensionUI.Body 中的元素存在一定的交互,这种交互完全不会受到影响,虽然它们的渲染位置很远,但是它们在组件树里很近。非受控复合组件让复杂的事情变得简单。
Render Props 就是将 Render 作为 Props 给到组件,然后将 UI 在组件中渲染出来。Render Props 主要用于动态渲染的场景,渲染是由外部函数来控制。在插件系统中,有些场景是要插入一些 Button 按钮,但这些按钮的 UI 样式是由主系统来决定的,比如在某个位置插入了按钮,要和主系统的其他按钮 UI 保持一致。而且在同一个位置也要安装多个插件,需要将这些按钮按顺序渲染出来。
如果不使用 Render Props 来开发,ExtensionKit.Slot 将插件的数据给到主系统,然后在主系统把按钮渲染出来。这个种方法的问题在于,对主系统的代码侵入比较多,需要处理插件的渲染、排序等。而且不同位置的插件都要实现类似的处理逻辑。
那么,我们可以抽象出一个 ExtensionKit.ActionSlot 将排序等一些逻辑封装在它的里面,而具体的渲染逻辑要由主系统通过 Render Props 给到 ExtensionKit.ActionSlot。具体实现如下:
jsx
// Extension.ActionSlot
const ActionSlot = (props) => {
const { renderButton, bottonItems } = props;
const items = sort(bottonItems);
return (
<>
<Slot />
{renderButton ? items?.map((item) => renderButton(item)) : null}
</>
);
};
export default ActionSlot;
主系统的代码可以调整为:
jsx
const App = () => {
const renderButton = (item) => {
const { onClick, text } = item;
return <button onClick={onClick}>{text}</button>;
};
return (
<ExtensionKit.Provider>
<Header />
<Body>
<ExtensionKit.Slot />
<div>
<ExtensionKit.ActionSlot renderButton={renderButton} />
</div>
</Body>
</ExtensionKit.Provider>
);
};
不难看出,Render Props 特别适合这种应用场景,组件内 UI 的渲染是由外部来决定的,这样可以实现一些逻辑的复用,让代码更易于维护。
本文介绍了几种 React 设计模式,并结合具体的开发实践做了一些实例。这些模式和技巧的应用,能够将一些复杂的问题很轻松的解决掉,达到事半功倍的效果。React 设计模式帮助开发者更有效地组织和管理复杂的 React 应用程序,并提供一种结构化的方法来处理常见的开发问题。本文介绍了复合组件模式、非受控复合组件以及 Render Props,通过使用这些模式,开发者可以更好地组织和管理复杂的 React 应用程序,减少错误和提高开发效率。