React中封装组件的一些方法
最近参与了一个基于Raact
技术栈的项目,距离我上一次在工作中React
已经过去了挺长一段时间,因此打算整理在React中封装组件的一些方法。
extends 正向继承
对于类组件而言,可以通过extends继承某个父类,从而获得一些公共的能力
class LogPage extends React.Component {
trackLog() {
console.log("trackLog");
}
}
class Page1 extends LogPage {
onBtnClick = () => {
console.log('click')
this.trackLog();
};
render() {
return <button onClick={this.onBtnClick}>click</button>;
}
}
借助OOP的思想,可以通过封装、继承和多态来实现数据的隔离和功能的复用。
HOC
高阶组件其实就是参数为组件,返回值为新组件的函数
高阶组件是React中比较常见的一种做法,主要用于增强某些组件的功能,封装一些公共操作,如处理埋点日志、执行公共逻辑、渲染公共UI等。
劫持props
高阶组件会返回一个新的组件,这个组件会拦截传递过来的props,这样就可以做一些特殊的处理,或者仅仅是添加一些通用的props
function HOC(Comp) {
const commonProps = { x: 1, commonMethod1, commonMethod2 };
return props => <Comp {...commonProps} {...props} />;
}
看起来像组件注入一些通用的props就更轻松了。
反向继承
高阶组件的核心思想是返回一个新的组件,如果是类组件,甚至可以通过继承的方式劫持组件原本的生命周期函数,扩展新的功能
function HOC(Comp){
return class SubComp extends Comp {
componentDidMount(){
// 处理新的生命周期方法,可以按需要决定是否调用supder.componentDidMount
}
render(){
// 使用原始的render
return super.render();
}
}
}
控制渲染
比如我们需要判断某个页面是否需要登录,一种做法是直接在页面组件逻辑中编写判断,如果存在多个这种的页面,就变得重复了
利用HOC可以方便地处理这些逻辑
export default (Comp) => (props) => {
const isLogin = checkLogin()
if (isLogin) {
return (<Comp {...props}/>)
}
return (<Redirect to={{ pathname: 'login' }}/>)
}
对于被包裹的组件而言,HOC更像是一个装饰器添加额外的功能;而对于需要处理多个组件的开发者而言,HOC是一种封装公共逻辑的方案
HOC的缺点
劫持Props是HOC最常用的功能之一,但这也是它的缺点:层级的嵌套和状态的透传。
对于HOC本身而言,传递给他的props是不需要关心的,他只是负责将props透传下去。这就要求对于一些特殊的prop如ref
等,需要额外使用forwardRef
才能够满足需求。
此外,我认为这也导致组件的props来源变得不清晰。最后组件经过多个HOC的装饰之后,我们就很难区分某个props注入的数据到底是哪里来的了
Render Props
在某些场景下,对于组件调用方而言,希望组件能够提供一些自定义的渲染功能,与Vue的slot类似
prop传递ReactElement
React组件默认的prop: children
可以实现default slot
的功能
const Foo = ({ children }) => {
return (
<div>
<h1>Foo</h1>
{children}
</div>
);
};
<Foo>hello from parent</Foo>
在JSX解析的时候,会将组件内的内容解析为React Element,然后作为children属性传递给组件。基于**“prop可以传递ReactElement”**这个思路,可以实现很多骚操作。
const Bar = ({ head, body }) => {
return (
<div>
<h1>{head}</h1>
<p>{body}</p>
</div>
);
};
<Bar
head={<span>title</span>}
body={<span>body</span>}></Bar>
类似于Vue的具名插槽,使用起来却更加直观,这就是JSX灵活而强大的表现。
prop传递函数
但这种直接传递ReactElement也存在一些问题,那就是这些节点都是在父元素定义的。
如果能够根据组件内部的一些数据来动态渲染要展示的元素,这样就会更加灵活了。换言之,我们需要实现在组件内部动态构建渲染元素。
最简单的解决办法就是传递一个函数,由组件内部通过传参的形式通过函数动态生成需要渲染的元素
const Baz = ({ renderHead }) => {
const count = 123;
return <div>{renderHead(count)}</div>;
};
<Baz
renderHead={(count) => <span>count is {count}</span>}
></Baz>
通过函数的方式,可以在不改动组件内部实现的前提下,利用组件的数据实现UI分发和逻辑复用,类似于Vue的插槽作用域,也跟JavaScript中常见的回调函数作用一致。
React官方把这种技术称作Render Props
:
Render Props是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术
Render Props
有下面几个特点
- 也是一个prop,用于父子组件之间传递数据
- 他的值是一个函数,其参数由子组件在合适的时候传入
- 通常用来render(渲染)某个元素或组件
再举一个更常用的例子,渲染列表组件,
const DealItem = ({ item }) => {
return (
<li>
<p>{item.name}</p>
</li>
);
};
实现了列表单个元素组件之后,就可以Array.prototype.map
一把梭渲染列表
const DealListDemo = () => {
const list = [{ name: "1" }, { name: "2" }];
return (
<ul>
{list.map((item, index) => {
return <DealItem key={index} item={item}></DealItem>;
})}
</ul>
);
};
如果需要渲染多个类似的列表,如DealItem2
、DealItem3
之类的,这个时候就可以把重复的list.map解耦出来,实现一个纯粹的List
组件
// 这里假设List组件会执行一些渲染列表的公共逻辑,如滚动加载、窗口列表啥的
const List = ({ list, children }) => {
return (
<ul>
{list.map((item, index) => {
return children(item, index);
})}
</ul>
);
};
然后通过Render Props
就可以动态渲染不同的元素组件列表了
const DealListDemo = () => {
const list = [{ name: "1" }, { name: "2" }];
return (
<List list={list}>
{(item, index) => <DealItem key={index} item={item}></DealItem>}
</List>
);
};
// 渲染不同的组件元素,只需要提供新的元素组件即可
const DealListDemo2 = () => {
const list = [{ name: "1" }, { name: "2" }];
return (
<List list={list}>
{(item, index) => <DealItem2 key={index} item={item}></DealItem>}
</List>
);
};
prop传递组件
上面提到Render props
是值为函数的prop,这个函数返回的是ReactElement。那不就是一个函数组件吗?既然如此,是不是也可以直接传递组件呢?答案是肯定的。
比如现在需要实现一个toolTip组件,可以在某些场景下切换弹窗
const Modal = ({ Trigger }) => {
const [visible, setVisible] = useState(false);
return (
<div>
<Trigger
toggle={() => {
setVisible(!visible);
}}
/>
<dialog open={visible}>
<p>tooltip</p>
</dialog>
</div>
);
};
现在就可以很轻易的实现一些可以触发tool的组件,但实现了和Modal的完全分离。
const ModalButton = ({ toggle }) => {
return <button onClick={toggle}>click</button>;
};
// 当点击该按钮时会切换弹窗
<Modal Trigger={ModalButton}></Modal>
const ModalTitle = ({ toggle }) => {
return <h1 onClick={toggle}>click</h1>;
};
// 点击标题时会切换弹窗
<Modal Trigger={ModalTitle}></Modal>
上面这种并不是使用Render props
的常规方式,但也展示了利用prop实现UI扩展的一些特殊用法
Render Props存在的问题
Render Props
可以有效地以松散耦合的方式设计组件,但由于其本质是一个函数,也会存在回调嵌套过深的问题:当返回的节点也需要传入render props时,就会发生多层嵌套。
<Demo1>
{(props1)=>{
return (
<Demo2>
{(props2)=>{
return (<span>{props1}, {props2}</span>)
}}
</Demo2>)
}}
</Demo1>
一种解决办法是使用react-adopt,它提供了组合多个render props返回结果的功能。
Hooks
强烈建议阅读官方文档,比我自己写的好得多。
Hooks解决的问题
React中组件分为了函数组件和Class组件,函数组件是无状态的,在Hooks之前,只能通过props控制函数组件的数据,如果希望实现一个带状态的组件,则需要通过Class组件的instace来维护。
Class组件主要有几个问题
- 逻辑分散,相互关连的逻辑分散在各个生命周期函数;每个生命周期函数又塞满了各不相同的逻辑
- 逻辑复用需要通过高阶组件
HOC
或者Render Props
来处理,
不论是HOC
还是Render Props
,都需要重新组织组件结构,很容易形成组件嵌套,代码阅读性和可维护性都会变差。
因此需要一种扁平化的逻辑复用的方式,因此Hooks出现了。其优点有
- 扁平化的逻辑复用,在无需修改组件结构的情况下复用状态逻辑
- 将相互关联的部分放在一起,互不相关的地方相互隔离
- 函数式编程
本章节将介绍Hook常见的使用方式及注意事项
useState 初始值
useState
传入的初始化值只有首次会生效,这在使用props传入值作为初始化值可能会带来一些误导
下面实现一个复选框组件
const Checkbox = ({ initChecked }) => {
const [checked, setChecked] = useState(initChecked);
const toggleChecked = () => {
setChecked(!checked);
};
return (
<label>
<input type="checkbox" checked={checked} onChange={toggleChecked} />{" "}
{checked ? "checked" : "unchecked"}
</label>
);
};
假设传入的props发生了变化
const HookDemo = () => {
const [checked, setChecked] = useState(false);
useEffect(()=>{
// 假设这里请求了接口获取返回值,用来初始化默认的checked
setTimeout(()=>{
setChecked(true)
},1000)
}, [])
return (
<div>
<Checkbox initChecked={checked}></Checkbox>
</div>
);
};
此时修改了props initChecked
的值,但Checkbox
组件本身却不会更新。如果希望当props更新时继续修改Checkbox
的选中状态,可以借助useEffect
const Checkbox = ({ initChecked }) => {
// ...
useEffect(() => {
setChecked(initChecked);
}, [initChecked]);
// ...
};
这个写法类型于Vue的自定义model写法
export default {
props: {
initChecked: {
type: Boolean,
}
},
data(){
return {
checked: this.initChecked
}
},
watch:{
initChecked(newVal){
this.checked = newValue
},
checked(newVal){
this.$emit('input', newVal)
}
}
}
闭包陷阱
初使用Hooks时,比较常见的一个错误就是闭包。
下面实现了一个定时器组件,在挂载时开启定时器,每秒更新数值
const IntervalDemo = () => {
const [count, setCount] = useState(0);
useEffect(() => {
let timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return ()=>{
clearInterval(timer)
}
}, []);
return <div>{count}</div>;
};
事实上每次更新之后count的值都不会变化,其原因跟
for (var i = 0; i < 10; ++i) {
setTimeout(function () {
console.log(i);
}, 1000);
}
最后会打印出10个5的原因一样,
定时器在首次渲染的时候注册,后续的更新不会再修改定时器,因此其回调函数的作用域内的自由变量count
,始终都是首次渲染时的值,不会发生改变。
一种解决办法是使用函数式的setCount
,可以获取到最新的count值
const IntervalDemo2 = () => {
const [count, setCount] = useState(0);
useEffect(() => {
let timer = setInterval(() => {
setCount((c) => c + 1); // 可以拿到上一轮的值
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div>{count}</div>;
};
但如果是想在定时器回调函数中先根据上一轮的值进行一些处理,这种方法就无能为力了
归根到底是想在多次渲染之间保存一个值,最简单的做法是使用外部自由变量来保存
let globalCount = 0
const IntervalDemo2 = () => {
const [count, setCount] = useState(0);
useEffect(() => {
let timer = setInterval(() => {
globalCount++
console.log(globalCount)
setCount(globalCount);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div>{count}</div>;
};
当然这种方式肯定是存在问题的,在组件被重复或继续使用时,外部的globalCount
会被污染。
要想在多次渲染期间共享同一个变量,官方的做法是使用useRef
const IntervalDemo3 = () => {
const [count, setCount] = useState(0);
const countRef = useRef(0);
useEffect(() => {
let timer = setInterval(() => {
countRef.current += 1;
setCount(countRef.current);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div>{count}</div>;
};
render Hook
在Class组件的使用中,在某些场景下可能期望获取组件的实例,方便调用组件上面的一些方法,最经典的场景是调用Form.validate()
表单组件的字段校验。
class Form extends React.Component {
validate = () => {
console.log("validate form");
};
render() {
return <div>form</div>;
}
}
可以通过ref获取组件实例然后调用组件方法
const Parent = () => {
const ref = useRef(null)
useEffect(()=>{
const instance = ref.current
instance.validate()
},[])
return (
<Form ref={ref}></Form>
);
};
在函数组件中,并不存在组件instance这一说法,也无法直接设置ref属性,直接在函数组件上使用ref会出现警告
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
为了实现与类组件的功能,需要使用借助forwardRef
和useImperativeHandle
const Form2 = forwardRef((props, ref)=>{
// 实现ref获取到实例相关的接口
useImperativeHandle(ref, ()=>{
return {
validate(){
console.log('validate')
}
}
})
return (<div>form</div>)
})
上面这种通过ref调用接口的操作,其思路都是先拿到组件实例,然后再进行操作。
但是现在有了Hook,我们可以将组件和操作组件的方法通过hook暴露出来,无需再通过ref了。
const useForm = () => {
const validate = () => {
console.log("validate form");
};
const render = () => {
return <div>form</div>;
};
return {
render,
validate,
};
};
const FormDemo = ()=>{
const {render, validate} = useForm()
useEffect(() => {
validate()
}, []);
return render()
}
相较于ref获取类组件实例,这种实现看起来更加简单清晰,一切皆是函数。
借助这种包含渲染render
功能的hook和JSX的强大表现力,可以实现很多有趣的组件,如弹窗。
一般的全局弹窗组件是通过ReactDOM.render
将弹窗组件渲染到body节点上,然后使用Modal.info
等全局接口展示。
这种写法的好处是灵活,缺点也很明显,无法与当前应用共享同一个context,参考antd Modal FAQ。
借助render hook的思路,可以通过一种取巧的方式实现
const Modal = ({ visible, children }) => {
return <dialog open={visible}>{children}</dialog>;
};
const useModal = (content) => {
const [visible, setVisible] = useState(false);
const modal = <Modal visible={visible}>{content}</Modal>;
const toggleModal = () => {
setVisible(!visible);
};
return {
modal,
toggleModal,
};
};
使用起来很方便
const ModalDemo = () => {
const { modal, toggleModal } = useModal(<h1>hi model</h1>);
return (
<div>
{modal}
<button onClick={toggleModal}>toggle</button>
</div>
);
};
由于返回的ReactElement
也是渲染在当前组件树中,因此就不存在context丢失的问题。
小结
本文主要总结了几种封装React组件的方式,包括正向继承、HOC、Render Props、 Hooks等方式,每种方式都有各自的优缺点。恰好最近参与了新的React项目,可以多尝试一下这些方法。
了解如何封装组件是一回事,如何封装一个良好的组件是另外一回事,方法就像是神兵利器的工具,要写好代码,还是需要多思考一下。感觉阅读主流UI库可以学到更多的东西(如果有时间的话,
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。