当前位置: 首页 > 科技观察

如何写出更优雅的React组件——设计思维

时间:2023-03-17 16:22:23 科技观察

下面就从设计思维的角度来谈谈如何设计出更优雅的React组件。基本原则单一职责单一职责原则就是让一个模块专注于一个功能,也就是让一个模块的职责尽可能少。如果一个模块的功能太多,应该拆分成多个模块,更有利于代码维护。就好像一个人最好专心做一件事,把自己负责的每一件事都做到最好。组件也是如此,要求将组件限制在一个合适的粒度上,可以被复用。如果一个组件的功能过于复杂,代码量就会增加。这时候就需要考虑将其拆分成职责单一的小组件。每个小部件只关心自己的功能,组合起来可以满足复杂的需求。单个组件更易于维护和测试,但不要滥用它。仅在必要时拆分组件。最小化粒度是一个极端,它可能会导致大量的模块。模块的离散化也会使项目难以管理。如何划分边界来拆分组件。如果两个组件之间的关系过于密切而无法在逻辑上定义它们各自的职责,则不应拆分这两个组件。否则,各自职责不明确,边界不分明,会导致逻辑混乱的问题。那么拆分组件的关键就是确定边界,通过抽象的参数通信,让每个组件发挥出自己独特的能力。高内聚/低耦合优质的组件必须满足高内聚和低耦合的原则。高内聚意味着逻辑上密切相关的内容聚合在一起。在jQuery时代,我们把一个功能资源放在js、html、css等目录下。在开发的时候,我们需要在不同的目录下寻找相关的逻辑资源。再比如Redux建议将action、reducer、store拆分到不同的地方,分散一个很简单的功能逻辑。这不满足高内聚的特点。抛开Redux,React的组件思维本身就满足了高内聚的原则,即组件是一个自包含的单元,包含逻辑/样式/结构,甚至依赖的静态资源。低耦合是指降低不同组件之间的依赖关系,让每个组件尽可能独立。也就是说,写代码通常都是为了低耦合而做的。通过分离职责和划定边界来分离复杂的业务。遵循基本原则的好处:降低单个组件的复杂度,可读性高,降低耦合,不影响整体,提高复用性,边界透明,易于测试,流程清晰,降低错误率,便于高级设计调试受控/非受控状态React表单管理中有两个常用的术语:受控输入和非受控输入。简单来说,受控意味着当前组件的状态成为表单的唯一数据源。表示表单的值在当前组件的控制下,只能通过setState更新。受控/不受控的概念在组件设计中很常见。受控组件通常与value和onChange配对。传递给子组件,子组件不能直接修改这个值,只能通过onChange回调告诉父组件更新。非受控组件可以传入defaultValue属性来提供初始值。Modal组件的可见性受控/不受控://controlled//uncontrolled如果这个状态作为组件的核心逻辑,那么它应该支持受控,或者兼容非托管模式。如果状态是二级逻辑,可以根据实际情况选择性支持受控模式。比如Select组件处理受控和非受控逻辑:functionSelect(props:SelectProps){//value和onChange是核心逻辑,支持controlled。兼容传入的defaultValue变成不受控//defaultOpen是次级逻辑,可以不受控const{value:controlledValue,onChange:onControlledChange,defaultValue,defaultOpen}=props;//不受控模式使用内部状态const[innerValue,onInnerValueChange]=React。useState(defaultValue);//二级逻辑,选择框展开状态const[visible,setVisible]=React.useState(defaultOpen);//检查参数是否包含value属性判断是否被控制,虽然value是undefinedconstshouldControlled=Reflect.has(props,'value');//支持受控和非受控处理constvalue=shouldControlled?controlledValue:innerValue;constonChange=shouldControlled?onControlledChange:onInnerValueChange;//...}withhooks组件是否受控通常是自己支持的,现在自定义hooks的出现可以突破这个限制。对于复杂的组件,配合钩子会更得心应手。封装此类组件,将逻辑放在hooks中,组件本身被挖空,其作用主要是配合自定义的hooks进行渲染。functionDemo(){//主要逻辑在自定义hookconstsheet=useSheetTable();//组件本身只接收一个参数,即hook的返回值;}优点这样做是逻辑和组件完全分离,更有利于状态提升,可以直接访问sheet的所有状态。这种模式将受到更彻底的控制。简单的组件可能不适合这种模式,它们没有那么大的控制需求,所以封装会增加使用的复杂度。单一数据源原则单一数据源是指组件的一个状态以props的形式传递给子组件,并且在传递过程中具有连续性。也就是说,state在传递给各个子组件的时候,并没有使用useState来接收,这会让传递过来的state失去响应性。下面的代码违反了单一数据源的原则,因为在子组件中定义了statesearchResult来缓存搜索结果,这会导致options参数在onFilter之后失去对子组件的响应性。functionSelectDropdown({options=[],onFilter}:SelectDropdownProps){//缓存搜索结果const[searchResult,setSearchResult]=React.useState(undefined);return(

{setSearchResult(keyword?onFilter(keyword):undefined);}}/>
);}应遵循单一数据源原则。将关键字保存为状态,通过响应关键字变化生成新的选项:functionSelectDropdown({options=[],onFilter}:SelectDropdownProps){//搜索关键字const[keyword,setKeyword]=React.useState(undefined);//使用过滤条件过滤数据constcurrentOptions=React.useMemo(()=>{returnkeyword&&onFilter?options.filter((n)=>onFilter(keyword,n)):options;},[options,onFilter,keyword]);return(
{setKeyword(text);}}/>
);}减少useEffectuseEffect是副作用。必要时尽量减少使用useEffect。React官方将这个API的使用场景总结为改变DOM、添加订阅、异步任务、记录日志等。我们先看一段代码:functionDemo({value,onChange}){const[labelList,setLabelList]=React.useState(()=>value.map(customFn));//值变化后,内部状态更新为React。useEffect(()=>{setLabelList(value.map(customFn));},[value]);}上面的代码使用useEffect是为了保持labelList和value的响应。也许您现在可以看到代码本身可以正常执行。如果现在有需求:labelList也同步到变化后的值,字面意思可以这样写:React.useEffect(()=>{onChange(labelList.map(customFn));},[labelList]);你会发现应用进入永久循环,浏览器失去控制,这是不必要的useEffect。可以这样理解,如果不改变DOM,添加订阅、异步任务、记录日志等场景,尽量不要使用useEffect,比如监听状态再改变其他状态。最终的结果是应用的复杂度达到一定程度,要么是浏览器先崩溃,要么是开发者崩溃。有什么好的方法可以解决吗?我们可以把逻辑理解为动作+状态。其中,状态的改变只能由动作触发。这样可以很好的解决上面代码中的问题,完善labelList的状态,找出改变值的action,并为每个action封装一个改变labelList的联动方法。场景越复杂,这种模式越有效。通用性原则通用性设计实际上是在某种意义上放弃了对DOM的控制,将DOM结构的决策权交给了开发者,比如保留自定义渲染。比如antd中的Table,通过render函数让用户决定渲染每一个cell,大大提高了组件的可扩展性:constcolumns=[{title:'name',dataIndex:'name',width:200,render(text){return{text};},},];;优秀的组件会通过参数Behavior预设默认渲染,同时支持自定义渲染。统一API当组件数量增加时,组件与组件之间可能存在一定的关系。我们可以统一某个行为API的一致性,可以减少用户对各个组件API名称的心理负担。否则,组件传参会像数面条一样痛苦。例如,经典的value和onChangeAPI可以出现在各种表单字段上。通过封装可以导出更多的高层组件,表单管理组件可以容纳这些高层组件。我们可以在每个组件上约定visible、onVisibleChange、bordered、size、allowClear等API,以保持每个组件的一致性。不可变状态对于函数式编程范式中的React,不可变状态和单向数据流是其核心概念。如果一个复杂的组件手动维护一个不可变状态,那么复杂度是相当高的。这里推荐使用immer进行不可变数据管理。如果一个对象的内部属性发生变化,整个对象将是全新的,而不变的部分将保持引用,这自然适合React.memo做浅比较,减少shouldComponentUpdate比较的性能消耗。当心陷阱React是一个状态机,因为render定义的变量每次都被重新声明。ContexttrapexportfunctionThemeProvider(props){const[theme,switchTheme]=useState(redTheme);//这里每次渲染ThemeProvider都会创建一个新的值,会导致所有使用Context的组件强制渲染return{props.children};}所以传递给Context的值是内存缓存的:exportfunctionThemeProvider(props){const[theme,switchTheme]=useState(redTheme);constvalue=React.useMemo(()=>({theme,switchTheme}),[theme]);return{props.children};}渲染道具traprendermethod创建函数,然后使用renderprops将抵消使用React.memo的优势。因为props的浅比较总是会导致false,在这种情况下,每次渲染都会为renderprops生成一个新值。Footer}/>可以用useMethods代替:github.com/MinJieLiu/heo/blob/main/src/useMethods.tsx社区实践高阶组件/装饰器模式constHOC=(组件)=>增强组件;装饰器模式是在不改变原有对象的情况下,对原有对象进行封装扩展(增加属性或方法),使原有对象满足用户更复杂的需求,满足开闭要求。原则,并且不会破坏现有的操作。组件将props转换为UI,而高阶组件将一个组件转换为另一个组件。比如漫威电影中的钢铁侠,他本身就是一个能走能跳的普通人。战斗服装饰后,可以跑得更快,拥有飞行的能力。在一个普通的组件中包裹一个withRouter(react-router)就可以进行路由操作。包装连接(react-redux)具有操作全局数据的能力。RenderProps}/>RenderProps用于使用值为函数的prop在React组件之间共享代码。RenderProps其实和高阶组件一样,都是给纯函数组件添加状态,响应react的生命周期。它使用回调方法传入一个函数给子组件调用,获取状态与父组件进行交互。ChainedHooks在ReactHooks时代,高阶组件和renderprops的使用频率会下降很多,很多场景下会被hooks取代。我们来看看钩子的规则:钩子只在顶层使用。钩子不会在循环、条件或嵌套函数中调用。Hooks仅在React函数中被调用。钩子是按一定顺序调用的,因为它们在内部是使用链表实现的。我们可以通过单一职责的概念将每个钩子呈现为一个模块,并通过组合自定义钩子来实现渐进的功能增强。和rxjs一样,它有链式调用,可以同时操作它的状态和生命周期。示例:functionComponent(){constvalue=useSelectStore();constkeyboardEvents=useInteractive(value);constlabel=useSelectPresent(keyboardEvents);//...}使用语义组合后,可以选择使用需要的钩子来创建各种需求自定义成分。从某种意义上说,最小的单元不仅是组件,还有自定义的钩子。Epilogue希望大家都能写出高质量的组件。