一、前言
React的Ref特性是React声明式编程(Declarative Programming)设计哲学的一个重要补充。之前对它的认识只是停留在非受控组件这种特殊场景,直到最近为了实现项目中的一个特殊功能,才对它有了更深的理解。
二、什么是Ref
React的官方解释是这样的:
In the typical React dataflow, props are the only way that parent components interact with their children. To modify a child, you re-render it with new props. However, there are a few cases where you need to imperatively modify a child outside of the typical dataflow. The child to be modified could be an instance of a React component, or it could be a DOM element. For both of these cases, React provides an escape hatch.
当中提到了几个关键的概念。
- 在典型的React数据流理念中,父组件跟子组件的交互都是通过传递属性(properties)实现的。如果父组件需要修改子组件,只需要将新的属性传递给子组件,由子组件来实现具体的绘制逻辑。
- 在特殊的情况下,如果你需要命令式(imperatively)的修改子组件,React也提供了应急的处理办法--Ref
- Ref既支持修改DOM元素,也支持修改自定义的组件。
三、什么是声明式编程(Declarative Programming)
值得一提的是当中声明式编程(Declarative Programming)和命令式编程(Imperative Programming)的区别。声明式编程的特点是只描述要实现的结果,而不关心如何一步一步实现的,而命令式编程则相反,必须每个步骤都写清楚。以数组为例,如果要打印出数组所有元素,声明式编程是这么写:
代码语言:javascript复制let arr = [1,2,3];
const printElement = (element) => console.log(element);
arr.forEach( printElement );
而用命令式编程,会这么写:
代码语言:javascript复制let arr = [1,2,3];
for (let i = 0; i < arr.length; i ) {
console.log(arr[i]);
}
通过对比,我们可以很直观的感受到声明式编程的好处。代码的核心功能就是这句:
代码语言:javascript复制arr.forEach( printElement );
我们可以根据语义直观的理解代码的功能是:针对数组的每一个元素,将它的值打印出来。不必关心实现其的细节。而命令式编程必须将每行代码读懂,然后再整合起来理解总体实现的功能。
React有2个基石设计理念:一个是声明式编程,一个是函数式编程。函数式编程以后有机会再展开讲。声明式编程的特点体现在2方面:
- 组件定义的时候,所有的实现逻辑都封装在组件的内部,通过state管理,对外只暴露属性。
- 组件使用的时候,组件调用者通过传入不同属性的值来达到展现不同内容的效果。一切效果都是事先定义好的,至于效果是怎么实现的,组件调用者不需要关心。
因此,在使用React的时候,一般很少需要用到Ref。那么,Ref的使用场景又是什么?
四、Ref使用场景
React官方文档是这么说的:
There are a few good use cases for refs:
- Managing focus, text selection, or media playback.
- Triggering imperative animations.
- Integrating with third-party DOM libraries.
Avoid using refs for anything that can be done declaratively.
意思是:
- 控制一些DOM原生的效果,如输入框的聚焦效果和选中效果等;
- 触发一些命令式的动画;
- 集成第三方的DOM库。
最后还补了一句:如果要实现的功能可以通过声明式的方式实现,就不要借助Ref。如果你就是那么任性,要使用Ref,具体该怎么做?
五、Ref用法
- 如果作用在原生的DOM元素上,通过Ref获取的是DOM元素,可以直接操作DOM的API:
class CustomTextInput extends React.Component {
constructor(props) {
super(props);
this.focusTextInput = this.focusTextInput.bind(this);
}
focusTextInput() {
// 获取DOM元素后可以直接操作DOM API
this.textInput.focus();
}
render() {
// 通过Ref获取DOM元素,再保存在实例变量focusTextInput中
return (
<div>
<input
type="text"
ref={(input) => { this.textInput = input; }} />
<input
type="button"
value="Focus the text input"
onClick={this.focusTextInput}
/>
</div>
);
}
}
- 如果作用在自定义组件,Ref获取的是组件的实例,可以直接操作组件内的任意方法:
// CustomTextInput组件的定义跟上面完全相同
class AutoFocusTextInput extends React.Component {
componentDidMount() {
// 这里直接调用CustomTextInput实例的focusTextInput方法
this.textInput.focusTextInput();
}
render() {
return (
// 通过Ref获取CustomTextInput实例,再保存在实例变量textInput中
<CustomTextInput
ref={(input) => { this.textInput = input; }} />
);
}
}
理解了基本使用后,再回到我遇到的真实场景。
六、Ref应用
先简单描述下项目要实现的效果:在一个页面中分左右两部分,左边显示商品的列表,右边显示选中商品的购物车。一次可以将左边的多个商品,添加到右边的购物车中。由于具体的实现细节比较复杂,当时的分工是一个人实现左侧的商品列表,另一人负责右边的购物车。如果用传统的React设计理念来实现,必须要借助左边列表组件和右边购物车组件的共同父组件,也就是页面的根组件,来维护选中的商品数组。然后再将商品数组传入购物车展示。这样做的话实现起来非常不方便,要把购物车中的很多逻辑都放在父组件中,而实际上这些逻辑是购物车自己独立使用的,跟其它组件并没有交互。左侧的列表组件只需要将选中的商品告知购物车即可,后续的逻辑由购物车自己实现。
考虑再三后,我们决定通过Ref的方式将其内部的addProduct的方法暴露出来给父组件,当选中一个商品后,列表组件将商品信息传递给父组件,父组件再通过addProduct方法将商品信息传入购物车。由购物车组件自己来维护客户购买的所有商品数据。整体逻辑就是这样,具体代码此处略过,主要描述的是思路。也许你会问为啥不将商品信息通过props传入购物车组件?实现上是没问题的,都能达到效果。但我们认为显式的调用addProduct方法会更加直观的表达语义,同时对addProduct方法也做了限制,只负责添加商品信息,不做更多的逻辑判断。
如果说还有没更好的实现方式,其实是有的,可以通过Redux来管理整个页面的状态。但引入Redux后,代码的维护成本会随之上升,目前暂时不作考虑。
七、总结
本文以项目中遇到的设计问题为起点,介绍了React Ref特性的使用场景和具体的使用方法,顺便还对比了声明式编程和命令式编程2种编程风格,对React的设计理念作了简要的解读。
八、后记
这篇文章是八个月前写的,随着业务的快速发展,当初写的代码无法承载新业务,要全盘重构了。重构的设计中,我们引入了Redux来做状态管理,组件之间的耦合度一下子就下降了很多,复杂业务的实现也变得容易了。所以我认为最优的实现方案是使用Redux,而不是Ref。