寒誉 阿里云开发者
2024年09月04日 08:31 浙江
阿里妹导读
本文结合了作者自身碰到的场景来说明如何做好组件设计和封装。
好的组件设计和封装是一切的基础
好的组件设计和封装是一切的基础,基于这以上构建出的各种工程化方案全局状态管理,React.memo、React.useMemo、React.useCallback都不是必须的,他们保证的是即使没有做好设计也能保证项目的下限,但保证不了他的扩展性。
设计包含什么
我们沿着各个分支走一遍,结合一些我自身的碰到的场景来说明。
基础组件 / 业务组件
这个很好理解,我们开发中会碰到各种基础组件和业务组件,我们如何区分他们的差别。
在我们的开发中除开对 UI 有特定要求的产品,基本 Antd 作为了我们的基础组件,通用性是我们区分基础组件和业务组件的边界。
下方这张图我觉得较好的区分了他们,越往左通用性越高,越往右定制化越强。就像产品需要定位使用人群,组件一样需要定义使用的范围,我们的组件最后总应该给他们归属为下图中的一部分。
图源:Josh W Comeau 的 React
课程:https://www.joyofreact.com/
业务组件专门为业务服务,也需要带入基础组件通用的思维去考虑,尽量地增加他的扩展性。
在刷软件的时候看到的一个场景,关于一个提交按钮的组件。
const SubmitButton = (props) => { return <buttton>{props.buttonText}</buttton>}const SubmitButton = (props) => { return <buttton>{props.isAdd ? '新增' : '编辑'}</buttton>}前者定位是一个通用组件,按钮的文本直接通过外部调用方来确定的。这里会有一个问题:
扩展性问题。按上述设计,如果后续该业务需要增加草稿功能,可能会传入第二个状态,isDraft来定义保存草稿的文案,随着业务的发展,后续的业务增加都需要起码修改两个文件,外部的编辑页组件和这个提交按钮组件。通用特性 / 定制特性
基于上面的问题,我们再思考一个问题,既然通用性是基础组件和业务组件的分界线,我们就需要了解「通用特性」和「定制特性」,通用特性归于基础组件中,定制特性在业务组件中封装。
先回到前文提交按钮的例子:
// 对于按钮来说,按钮的文本是一个通用的特性// 无论文本内容是什么,都不会影响按钮本身的 UI 特性const BaseButton = (props) => { const { buttonText } = props; return <button>{buttonText}</button>}const SubmitButton = (props) => { const { buttonText, isAdd } = props; return <BaseButton buttonText={isAdd ? '新增' : '编辑'} />}UI 状态天然是通用特性,因为一个产品中,我们需要给用户提供一致的 UI 体验和操作,这不仅能体验产品的专业度也能形成用户对于产品交互的心智。
再来看一个复杂些的例子,各大厂都在推的工作流类应用,这是其中一个产品「Dify」的页面。
我们来思考如果是我们来做这些节点,我们应该如何来做?我们可以从以下几点来思考:
节点的通用特性是什么?节点的定制特性是什么?如何封装?const NODE_MAP = { start: StartNode, llm: LLMNode, end: EndNode}const CustomNode = (props) => { const { type } = props; const RenderNode = NODE_MAP[type]; return <BaseNode {...props}> <RenderNode /> </BaseNode>}const BaseNode = (props) => { const { children, data, ...commonProperties } = props; const onNodeClick = () => { // TODO }; return cloneElement(children, { data });}状态定义
状态的定义是一个见仁见智的问题,跟组件层级也有很多的关联。
最基础的原则我相信大家都知道,React 官方的这篇文章值得反复阅读。
https://react.dev/learn/thinking-in-react
DDD 举例这是我个人推崇的状态定义方式:
区分「UI 状态」 和「业务状态」。业务状态的组织借鉴后端 DDD 的思想,将状态归于某一个具体的业务领域。DDD 是一种开发思想,并不是具体的框架和技巧,不同的语言框架也有不同的实现方法。
前端在开发中其实很少去做业务上的抽象和建模,但是这种思想仍然可以借鉴来组织状态,能在多变的业务中易于扩展和修改,也在倒逼我们必须去理解业务的核心包含哪些内容,我们必须在理解业务的基础上做设计。
https://en.wikipedia.org/wiki/Domain-driven_design
Page -> multi Bussiness Entiry -> compose UI components
接下来我们来看一个简单的表单例子:
表单案例
上面按区块分,我们可能会按区块封装组件1,组件2。所有的联动和逻辑是按 UI 块封装的。如果我们需要去掉某些字段或者在某些字段里加逻辑都需要直接变更组件1或组件2。按 DDD 的思路走,我们需要拆分成表单父组件和业务组件1,2,3,4。核心是因为真实的世界里一个业务领域的变更总是在领域内发生,所以扩展更改都只会发生在组件内部和父组件内,不影响其他的组件,我们可以精准的评估影响面。
状态的存储
状态的存储方式是一个跟实际业务挂钩的东西,暂时没有什么特别要说的内容。
唯一要注意的就是区分好「 全局状态 」和 「 组件状态 」。
如果各位读者有什么好的方法论沉淀欢迎评论区讨论。
小技巧
内容提升
// beforeconst Parent = () => { const [name, setName] = useState('han'); return ( <div> <Child name={name}></Child> </div> );}const Child = (props) => { return ( <SubChild name={props.name}/> );}const SubChild = ({ name }) => { // TODO}// 我们注意到 Child 这个组件他只是透传了 name 字段给 SubChild,他本身并没有使用 name。// 这在结构上会在后续扩展上造成影响,而且存在多层的情况下就会导致 参数透传地狱// 我们如果需要进行优化,有哪几种方案呢,React 的优化说到底只有两个方案,组件层级和状态管理// 1. 使用 Context// 2. 使用第三方状态管理工具// 3. 内容提升// 前两种都是将状态提升到一个更高的纬度,是一种相对来讲绕过的方案。// 他们也是很好的解决方案。// 内容提升是改变组件层级来达到同样的目的。// afterconst Parent = () => { const [name, setName] = useState('han'); return ( <div> <Child> <SubChild name={name} /> </Child> </div> );}const Child = ({ children }) => { return ( {children} );}const SubChild = ({ name }) => { // TODO}我们需要区分两个概念,「DOM 结构」和「React 的层级结构」。就像我们需要区分「组件渲染」和「DOM 变更」是两件事。
这里面的优化原理如果你熟悉 React 的渲染原理,可以很轻易的理解,这里我们就不展开介绍了,如果你暂时还不理解,强烈推荐你花费 1 - 2 小时阅读并自己消化下这篇文章[1]。
控制子组件的最小权限
只提供对应功能的修改给特定的组件,代替传递 setXXX。
// bedconst Parent = () => { const [state, setState] = useState({ name: 'han', sex: 'man' }); return ( <div> <section> name: {name} </section> <section> sex: {sex} </section> <ChangeNameForm setState={setState} /> </div> )}// goodconst Parent = () => { const [state, setState] = useState({ name: 'han', sex: 'man' }); const handleChangeName = (name) => { setState({ ...state, name }); } return ( <div> <section> name: {name} </section> <section> sex: {sex} </section> <ChangeNameForm handleChangeName={handleChangeName} /> </div> )}在我们的示例中,这两者没有明显的区别,但在我们云动的真实场景中,state 可能十分复杂,我们可能需要直接修改 ChangeNameForm 组件。
这样的写法一方面是将状态变更都提升到了,遵循了单一数据流。另一方面是限定了子组件的功能,也语义化了功能,我们不会再去理解整个大的 state。
memo 相关
memo 也是我们在做组件设计的时候需要考虑的一个点,一些我们可预料的昂贵的渲染应该被优化掉。
从一个例子来思考
const Parent = (props) => { return <div> <Child name={props.name} /> <MemoChild age={props.age} /> </div>}const Child = (props) => { // 父组件渲染,每次都会渲染 return <div>{props.name}</div>}const MemoChild = memo((props) => { 父组件的 name 属性不变的情况下,不会重渲染 return <div>{props.name}</div>})如果 Child 是一个渲染十分耗时的子组件,那多次的重渲染就会对性能造成影响,如果还是可以多次添加的子组件,那性能就会呈现一个线性增长,如果有 10 个就会导致 10 倍的卡顿。
性能是一个经常被提到的点,如果只是单个节点,硬件越来越好的情况下可能会被我们忽略,毕竟我们很难界定去说 memo 的 diff 和一个子组件的渲染相比到底谁更昂贵,在编码层面起码很难界定,需要依赖性能分析的报告。React 官方也推荐当我们真的遇到性能问题的时候再去分析到底哪里出了问题。
我想说的另一个点是被 memo 大部分情况下总被人冠以性能优化下的本质,他的本质是减少组件渲染,在一些特定业务情况下它必须被使用。
我们来思考这样一个场景:
我们有一个父组件,卡片有 hover,select 等交互状态,卡片内有个输出面板组件,面板中存在一个唯一ID,当我移入移出的时候会发生什么?
const Card = () => { const [otherState, setOtherState] = useState(0); const [outputText, setOutputText] = useState( "大家好,这是一个在特定场景下必须需要使用 memo 的示例,欢迎大家评论沟通想法" ); return ( <div onMouseLeave={() => setOtherState((pre) => ++pre)} style={{ background: "#ddd" }} > <OutputPanel text={outputText} /> </div> );};const OutputPanel = (outputProps: { text: string }) => { return ( <div> <div>{outputProps.text}</div> <div>唯一ID:{Math.floor(Math.random() * 100)}</div> </div> );};我们会发现每当我们移出内容框时,我们的唯一 ID 变了。
https://codesandbox.io/p/sandbox/memo-dui-ye-wu-de-ying-xiang-d749ph?file=%2Fsrc%2FApp.tsx%3A5%2C1-28%2C3
一个组件的优化过程
参考文章[2]。
归纳总结
应该始终以一个消费者的视角来开发组件。
综上所述,组件的设计应该包含以下的路径:
根据「 通用特性/定制特性 」确定组件的通用级别。将特性区分为 「 UI 特性 / 业务特性 」来确定组件层级和封装。结合一些技巧来优化组件层级和状态存储的位置,优化性能表现。还有许多的细节需要处理(Typescript 定义、props 定义、样式等)...参考资料:1、
https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/#standard-render-behavior
2、
https://www.developerway.com/posts/components-composition-how-to-get-it-right
3、
https://courses.joshwcomeau.com/