React-Native爬坑实录与性能调优
最近入职mini项目在技术选型的时候掉到了“大家都熟悉React技术栈,那我们就用React-Native来进行移动端开发吧”的坑里面。在Facebook已经宣布要对RN进行重构的时间节点上选择这么一个技术栈来进行较大型移动端的开发,实在不是一个明智之举。
虽然如此,在两周的开发中也积累了不少以后可以用到的经验,在这里分享一下。当然性能问题对于不同类型的应用来说,其痛点也不尽相同,所有优化方法的使用也是和自己当前的项目内容密切相关的。
深入理解React生命周期
无论RN对于组件进行了多少封装,其runtime仍然还是离不开React本身的生命周期的,在出现了和你预期渲染结果不相同的问题的时候,大多数都是因为React内部渲染的过程与你的代码产生了冲突。
TextInput绑定(移动端虚拟键盘)
一个常见的UGC类移动端APP,都会有让用户输入内容的地方,比如发布、评论等。由于移动端键盘会在屏幕底部弹出,原本吸附在屏幕底部的TextInput组件需要向上弹起,并且吸附在键盘的上方,这样才能得到最好的输入体验。通过position: "absolute"定位之后的输入框,随着键盘弹起事件的发生,需要将其高度进行调整:
componentWillMount() {
this.keyboardWillShowListener = Keyboard.addListener('keyboardWillShow', this._keyboardWillShow.bind(this));
this.keyboardWillHideListener = Keyboard.addListener('keyboardWillHide', this._keyboardWillHide.bind(this));
}
_keyboardWillShow(event) {
this.setState({
keyboardHeight: event.endCoordinates.height,
keyboardShow: true
});
}
在键盘进行输入的时候,为了保证每次提交评论都可以清空掉评论输入框之中的内容,需要为TextInput组件绑定value到state上面来进行输入框值的实时获取,并且在提交的异步操作完成之后,进行该值的清空。
但是这样做会出现一个问题,那就是中文输入法由于每次组件的重新渲染,不能够展示中文输入的预选,导致不能够通过键盘输入中文。这样的问题可以通过两个方法来解决:
- 将TextInput所在的组件通过PureComponent进行扩展,这样在进行浅比较的时候,不会触发shouldComponentUpdate导致的重新渲染;
- 手动设置showComponentUpdate的返回值,强制让中文的预输入不重新渲染组件。
第二种方法是一种更加灵活的方法,也是对于React生命周期阶段的更好的利用。但是在使用第二种方法的时候,要注意组件的其他props和states,当且仅当输入框内容发生变化的时候,不触发重新渲染。
shouldComponentUpdate(nextProps, nextState){
const flag = Object.keys(nextState).map((k) =>(nextState[k] !== this.state[k])).filter(Boolean).length == 1
if(flag && nextState.commentText !== this.state.commentText){
return false;
}
return true;
}
何时使用Component,何时使用PureComponent
PureComponent会根据一层props和states的浅比较来判断是否re-render一个组件,这一层浅比较会对比简单值的变化以及复杂值的引用变化,所以,即使调用了setState方法,如果采用push这种数据方法来为数组增加一个元素,也不会对于数组的渲染内容进行re-render。
深层的引用元素在PureComponent中是很危险的,一些不注意的操作都会导致渲染结果的不确定性。在RN中也是一致的。APP中常见的列表元素的渲染,一般都是使用数组方式传入FlatList或者SectionList中的。而这些数据大部分都是从服务端进行获取的。每次都是一个全新的数组,即使数组的数据没有发生变化,也会造成列表的重新渲染。
在这种情况下,既然很难避免列表的re-render,那么列表项的re-render就可以很好地通过PureComponent来进行性能优化。数组在传入到FlatList的data属性当中,被解析为较为扁平的数据,如果我们将这个列表项数据完全解析为扁平化的数据,就可以很好地利用PureComponent的浅比较,来尽可能减少列表项的re-render数量,增加一定的刷新或者加载性能。
所以,PureComponent并一定是最好的选择,他有可能会导致组件不能够完成我们需要的渲染操作,但是在扁平状态的叶子组件上,进行没有太多逻辑改变的展示,可以再很大程度上增加整个项目的组件复用性。
路由切换优化
在APP的第一次迭代完成之后,我们发现整个项目的稳定性很差,在需要渲染长列表的发现页面中,几次比较快速的路由切换操作就会导致整个软件闪退,这样的体验是不能够接受的。
开始分析的闪退原因是下面几个:
- 图片太大,导致内存溢出;
- DOM节点数过多(<View>的层层嵌套),导致每一次路由切换时候的组件卸载、挂载阻塞RN或者Native;
- 地图组件的加载以及地图标点功能阻塞渲染;
- 路由切换时过多的网络请求阻塞Native。
虚拟DOM的问题在移动设备上体现了出来。上一个路由的DOM仅仅会被Unmount,为了路由返回时候的复用,但是一部分移动设备仅仅有2G或者更小的RAM,这样过多的DOM节点会导致内存泄露。并且一次性阻塞地渲染几百上千个DOM节点也会让Native不堪重负。
经过几次测试,我们压缩了发现页的图片大小,但是闪退并没有非常明显的优化。并且通过Mock数据,将网络请求压缩为一个,也没有很好地解决这个问题。最后我们将问题定位到了上述2和3两点。
既然是阻塞问题,那么我们将整个页面进行切分,地图组件本身带有很多的DOM节点,并且需要很长时间进行网络加载,并且相对于列表,地图组件并不是非常重要。所以地图组件可以被延迟加载。
列表组件的渲染也应该是在网络请求和RN路由动作完成之后再进行的,所以我们将数据的dispatch和RN路由动作作为第一优先级的事情去做,而将列表组件作为第二优先级,地图作为末尾,形成了一个页面的加载链。
RN的InteractionManager可以帮我们细化整个阶段。将页面的渲染置于页面切换动作完成之后进行。而地图则采用定时器进行延迟加载。
await Promise.all(
[
new Promise((resolve, reject) => {
setTimeout(() => {
this.setState({
mapVisible: true
}, resolve);
}, 1000)}),
dispatch(initDiscoveryItem({
feedId: this.feedId,
}))
]
).then(() => {
this._getLocations(this.props.data);
});
InteractionManager.runAfterInteractions(() => {
this.setState({
startRender: true
});
});
地图的加载被设置了一个1秒的延迟,并且包裹一个Promise,数据的异步加载也是一个Promise,地图坐标定位将会被延迟到地图加载和数据加载之后进行,而当整个页面切换动作完成之后,开始进行整个页面的渲染。
最后的效果是APP的发现页部分进行阶段性渲染:
- 路由切换动作;
- 数据加载;
- 页面组件渲染;
- 地图组件在路由切换之后一秒进行渲染;
- 地图多点定位在所有任务都完成之后进行。
在正常操作的情况下,基本上不会出现页面切换闪退的情况了。
HOC的使用
HOC是React一个老生常谈的话题了。为了更好地进行代码的复用,整个项目分为了三个主要的层次进行架构。
- 基本组件:这些组件实现了扁平数据的基本展现功能;
- 基本的组件的Wrapper,HOC:这些组件并不包含太多的渲染任务,他将组件进行保证,Mixin一些通用功能;
- 路由组件:这些组件是一个路由页面的核心,里面会渲染多个基本组件以及包裹了基本组件的HOC。
HOC其实并不复杂,它接收一个或者多个组件作为参数,返回一个包装后的组件。
平台适配
对于RN来说,为了进行iOS和android的适配,很多地方都需要根据这两个平台的特性进行协调,那么这些协调的代码如果能够复用,采用HOC可以减少很多的代码量,并且增强复用性。
一个UGC社区会有很多需要键盘输入的地方,那么一个通用的键盘谈起的跨平台方案就是必须的,由于android本身比iOS设备会多出一个虚拟的返回键,对于返回键就需要进行特殊的处理。
但是其他部分的键盘逻辑其实是可以复用的,如果将平台处理放在组件内部进行也是可以的,但是这样会导致代码的可读性很低,我们将键盘返回键的处理放在一个高阶组件内部。
(Component) => {
class Wrapper extends PureComponent {
constructor(props) {
super(props);
}
componentWillMount() {
this,keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow);
this,keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide);
}
// android only
handleOnRequestClose() {
...
if (!this.isShowkeyboard) {
this.close();
this.setState({
isKeyboardClose: false
});
}
}
render() {
return <Component {...this.props}/>
}
}
}
这个高阶组件省略了一部分代码,使用这个组件将我们需要进行适配的TextInput注入进去,就可以得到一个可以对android键盘事件进行特殊处理的组件。这样也做到了SOLID原则中的开放封闭原则,我们的组件对于扩展开放,每次进行适配代码修改的时候,不需要修改每一个组件,只需要修改一个HOC就可以了。
渲染拦截
loading状态是大部分SPA都需要的一个状态,来为用户展示一个加载中的动画,让用户进行有明确目的的等待。很多页面都要这样一个状态来完成整个业务流程。这个状态也可以通过高阶组件进行封装。
(Component) => {
class Wrapper extends PureComponent {
constructor(props) {
super(props);
}
render() {
const {
isLoading
} = this.props;
return (
isLoading
? <Loading/>
: <Component {...this.props}/>
)
}
}
}
每个需要loading状态的组件通过这个HOC都可以直接传入需要的参数和组件,封装一个具有加载状态的组件,我们拦截掉了原本的组件渲染,为其增加了一个额外的外部渲染逻辑。
如果你的组件需要一个空态,或者是需要一个统一的错误处理,都可以使用这种方式来进行实现。
props的CRUD
react-redux就是一个非常好的例子,react-redux中的connect方法对于原本的组件进行了代理,将store中数据通过reducer进行切分,然后Mixin到组件的props当中。
async/await
作为ES7中的异步处理方法,通过babel我们可以很容易在我们的项目中使用它。目前整个项目中的异步数据操作都采用async+Promise的方法来进行实现。
当多个异步事件需要异步或者同步进行的时候,我们通过结合两种异步方法,就可以得到很清晰的代码逻辑。
const initData = async () -> {
try(
await AsyncStorage.get('userId', (err, result) => {
if (err) {
errHandler(err);
} else {
this,userId - result;
}
});
await Promise.all(
[dispatch(getInitUserData({
userId,
}),
dispatch(getInitPassageData({userId}]
))).then(data => {
dataHandler(data);
});
} catch(e) {
this._errorHandler(e);
}
}
后记
虽然RN存在不少坑需要去踩,并且其进行和Native相关的组件配置的时候存在很多配置问题,但是作为一种移动端同构方式,也是非常具有前瞻性的,和webview相比,RN不需要使用JSBridge的方式来进行Native和web的通信,这得益于React本身社区的强大,希望facebook重构后的RN能够具有更好的性能和更轻松的开发体验。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。