手动新建一个React项目

之前在项目开发时,都是直接使用脚手架工具创建项目,不管是React提供的create-react-app还是Vue提供的vue-cli,或者公司提供的更完备脚手架工具。这样虽然省事,但是很多细节就被隐藏起来了,不接触的话就完全不了解,尤其是不新建项目的话,一些配置都是配好了的,只需要写写业务代码,而对前端开发人员来说,最有难度也最核心的工程化技术就没能接触。因此,很有必要不借助任何脚手架工具,直接从0开始新建一个项目,思考涉及到哪些工具,都用来做什么,分别需要怎么配置。我自己对React更熟一些,所以就以React项目来走一遍。

npm init新建一个项目不用多说了,然后直接npm install react react-dom,把框架库安装上。有了库,就直接开始撸代码,./index.js作为入口文件,创建src目录作为业务代码文件夹,src/app.jsx, src/app.scss写一个最简单的helloword页面,这里使用了预编译CSS的方案sass,主要是为了解决CSS命名冲突和模块化的问题。同时,还需要一个html文件作为展示页面,新建public目录,写一个最简单的html,确保body里有个id为root的div即可,这是一个前端SPA项目的最简单demo,页面代码到这里就写完了。

接下来就是编译这个重头戏,新建一个webpack.config.js来进行webpack配置。input和output配置项不用多说,比较重要的就是在module.rules里配置对js、jsx、scss文件的解析规则,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
rules: [{
test: /\.js$|\.jsx$/,
use: 'babel-loader',
exclude: /node_modules/
},

{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /node_modules/
}
]

因为react项目里使用jsx来编写界面,而jsx是需要借助babel来编译成可执行的js代码,因此需要新建一个.babelrc文件,里面放上”@babel/preset-env”, “@babel/preset-react”就可以了,把上面牵扯到的全部用npm add –dev安装上。做完这些,往package.json的scripts里加个build命令,例如”build”: “webpack –mode production”,就可以编译一下看看了。可能会有一些报错,根据具体的报错提示来进行解决就可以了,比如sass没安装,nodejs版本与webpack不匹配等等,问题解决掉就能编译成功了。最后当然是需要能够开发调试,在webpack.config.js里加上devServer配置,往package.json里scripts加上”start”: “webpack-dev-server –port 3000 –open –hot –mode development”,就能开启调试了。

到这里,从0开始新建一个React项目并且支持调试和编译就完成了,虽然实际工作中的项目比这样的helloword的demo要复杂很多,但都是一个又一个具体的问题,只需要遇到的时候认真学习并记录一下,就没什么难的,重要的是掌握思路和原理。

不忘初心,重新开始

这是我荒废了6年的博客,我一直记得它,也偶尔想过要重新开始写,却一直没有勇气。记得6年前,我自认算是个技术青年,在完全没有接触过React和ReactNative的情况下,凭着无知无畏的精神加入公司的ReactNative开发项目,并且还承担了分包加载和性能优化的工作,吭哧吭哧的也算是完成交差了。还记得当时同时啃着iOS、Android和Web三端的源码,被React的声明式渲染、Redux的状态不可变这些设计思想所折服,因此把自己的一些心得记录了下来。现在看来当然非常粗浅,很多都只是简单的代码片段,在技术大拿看来是不值一哂的。但我觉得,这些记录对我是帮助非常大的,没有这些记录,我就不会在好奇心的驱使下去追根究底,去尽量阅读源代码,从而真正思考这些前端框架和库的设计思想,也许就真的满足于会用就行了。

虽然我记录下的这些博客都很简陋,但竟然也得到了一点认可,我被公司里好几个同事和一些网友找过来问过是不是我的技术博客,说查资料看到的,这除了满足了我小小的虚荣心之外,其实更加让我诚惶诚恐,万一写的不对(这是大概率的事情),也许还误导别人了。但现在想想,我没这么介怀了,没有人敢保证自己是绝对正确的,每个人都在成长进步。写个人博客最重要的是对自己的帮助,让自己尽量用认真的态度去做一件事情。当然比起各路技术大牛来说,实在也算不上有多认真,但至少聊胜于无吧。是的,我现在对自己的自信心,几乎是处于人生最低谷了,这就更需要先立好态度了。

任何一件事情,只要开始做了就不难,那么就开始吧。至于博客的内容,想到什么就写什么。前端的各种技术都非常成熟了,而我又是个半路出家的伪前端,写不出什么高精尖的东西,就当随手记录笔记好了。另外就是我对阅读的书籍的感悟,尤其是我很感兴趣的哲学、心理学和个人成长方向。以及平时看到并记录下来的AI应用方向了解的一些知识。

最后还是明确一下自己的初心,如果能对别人带来一点点微小的帮助当然最好,最重要的还是帮助自己学习和进步。如果有人看到这里的话,一起加油!

ReactNative新架构简介之一(React and Codegen)

前言

在网上看到一个介绍ReactNative新架构的一个博客,看起来很不错,所以就直接翻译过来,还省去了自己去整理的功夫。因为不打算拿去投稿,所以翻译就随意一点,不追求和原文完全一致,大概意思到了就行了,如果发现有错误的地方,还请随时指正~

ReactNative官方最初是在2018年宣布要对架构进行重大调整,目的是为了解决自身长久以来存在的诸多问题。在这个系列中,我们会历数这次重构中的主要内容。我们避免使用代码演示,尽可能让解释通俗易懂,并分享我们对这些新技术方案的激动心情。

React and Codegen

作为这个系列的第一篇,我们将讨论这次重构中切实影响写代码的一个方面:新的React特性以及一个叫做Codegen的工具。

在深入之前,我们来回顾一下基础知识:ReactNative是一个开源的跨平台移动应用开发解决方案,它让我们可以用React以及JavaScript来开发一个完全原生的移动应用。不光是创造和发展它的Facebook公司在使用ReactNative,其它公司例如亚马逊和微软,以及很多初创公司都在使用它。

为了帮助更好地理解ReactNative的工作原理,我们准备了下面这张图

img

如图所示,ReactNative结构上有四个核心部分:React代码部分,由代码翻译而来的JavaScript部分,一系列统称为”桥”的部分,以及原生部分。

ReactNative当前架构的一个关键点是:它的两个端——JavaSvript端和Native端,彼此并不了解对方的情况,这意味着,它们的通信必须依赖“桥”传递JSON信息。消息被发到原生端后,寄希望于能收到返回数据,但这无法得到保证。

为了克服ReactNative当前存在的各种限制,Facebook的开发团队决定重新思考这个异步信息传递的方案,并着手开发一套新的架构。在新的架构中,前文所述的四个核心部分都分别进行了改进。这篇我们就将讲述第一个核心部分:React。

React核心库开发人员的工作成果,对ReactNative团队的帮助非常大。这意味着ReactNative将具备React在ReactConf2018(概述)上宣布的所有新特性,尤其是Andrew Clark展示的并发模式和同步事件回调的概念,这些特性从React16.6起开始支持,支撑了一些很重要的底层功能实现,我们将在第三篇中再做介绍。

在这次重构中,会影响到ReactNative代码开发的React的新特性有:使用Suspense来实现组件延迟渲染,以及通过ReactHooks来使用函数组件代替类组件。

ReactNative团队在代码静态检查(Flow或者TypeScript)上也很重视,尤其是:他们正在开发一款叫做CodeGen的工具来自动生成JS和原生端的接口代码,借助于类型化JavaScript的可信任性,这个生成工具可以用来生成Fabric和TurboModules(新架构中的功能点,将在第三节中介绍)所需要的接口文件,从而实现更可靠的端之间数据传递。这种自动化也可以提高端之间数据传递的效率,因为可以避免每次都验证数据的合法性。

总之,在上面ReactNative的架构图上,使用新的架构来描述第一个部分,应该是这个样子:

img

这就是ReactNative新架构简介的第一部分,其它内容将在接下来的二三四篇中继续介绍。这次ReactNative的架构升级,可以让开发者在少修改甚至不修改代码的基础上,在很多方面都带来极大提高,非常值得期待。

ReactNative性能优化系列(一)包体积优化

前言

一直以来都想写一篇ReactNative性能优化的博客,原因很简单,技术知识要落地才有价值,而性能优化是业务开发中的一个非常重要的点,但想了一年多了都还没写,是因为这个选题太大了,在google上一搜,讲RN性能优化的文章有不少,但都没有讲的很全面,有的又偏细节,没有提炼出底层的原理,比如props不要用局部变量和立即执行函数,只是代码层面的执行策略,底层真正的原因实际上重复渲染机制。

今天突然想到,与其寄希望于一口气写出一篇集ReactNative性能优化之大成的文章来,不如想到多少写多少,先写了再说,这才是正确的做事方式,所以先来个最简单的开篇吧。

包体积

所谓包,就是我们在执行react-native bundle命令生成的产物的统称,例如

1
react-native bundle --entry-file ./index.js --platform ios --dev false --bundle-output ./dist/index.ios.bundle --assets-dest ./dist

就会在dist目录下生成一个index.ios.bundle文件,以及图片资源也放在dist目录下,这样我们可以把dist目录打一个压缩包,预置在安装包内,或者拿去下发做热更新。

为什么要优化包体积?首先它会影响到安装包的体积,尤其是预置的情况,安装包体积过大,会影响用户下载应用的体验,各大APP也都在想办法压缩安装包的体积,其次是会影响加载效率,ReactNative需要加载bundle才能运行起来,bundle体积越大,则加载越慢,体验也就更加不好,而如果图片资源体积过大,则影响运行时的图片加载效率,也会影响体验

既然包是由bundle和资源两大块组成的,我们就分别给出它们的优化策略。

图片压缩

资源有很多种,例如图片,音频,视频等,但图片是最普遍的一种,所以我们就只介绍图片压缩。

图片压缩首先有一个非常简单的方案:使用JPEG格式。如果一张图片没有透明度的需要,那么就改成使用jpeg格式,体积比png体积要小很多。

其次是png图片的压缩,业界有非常多也非常成熟的方案可以选择,如果图片数量不多又想省事,可以直接使用tinypng.com,它也对外开放了API可供脚本调用,但每个月只能免费压缩500张。如果更专业一些,可以使用pngquant,它功能更加强大,可以自定义压缩系数,避免压缩系数过大导致失真,或者压缩系数过小导致压缩率不高,它可以下载工具,或者使用命令行调用。

使用工具对png图片进行有损压缩,根据不同图片具体情况,压缩比一般能在20%-60%左右,是效果非常显著的。

bundle压缩

bundle其实是纯js代码,它包含了ReactNative的JavaScript层源码,第三方库,我们自己的业务代码,要优化它的体积,首先我们需要知道bundle里哪些东西占了多少体积,然后再去针对他们做优化,有一个工具叫react-native-bundle-visualizer,使用它可以看到bundle内的详细情况,它的底层是使用了
source-map-explorer,所以我们用source-map-explorer也可以。或者如果我们使用了webpack打包,那么可以使用webpack-bundle-analyzer插件。

下面是一张网上找到的示例图:

img

知道bundle里什么东西占地方的话,就想办法去优化,例如很典型的是moment.js,很多时候我们发现它的locate占了很大一块,实际上又没用到,那我们可以参考how-to-optimize-momentjs-with-webpack,或者简单点直接使用moment.min.js不要locate功能,或者换成其它的替代库。例如lodash,我们只使用了它的几个方法,却引入了一整个库,我们就可以想办法使用局部引用的写法。其次就是如果使用的多个第三方库依赖了同一个库的不同版本,导致了存在同一个库的多份代码,则可以考虑升级其中的一些库来避免这种情况。最后是咱们自己业务的代码,要避免机械的拷贝粘贴,否则同样的代码在bundle里存在多份,就导致了bundle体积的增加。

分包

将bundle拆分成基础包和业务包,也是减少包体积的一个有效方案,但实现起来稍微复杂一些,需要改动ReactNative的源码,修改加载流程,对团队的技术能力有一定要求,但也不用担心,技术方案早就已经很成熟,我在两年前就写过相关的介绍可供参考。因为说起来话题就比较大,暂时不做展开了。

总结

新开了个大坑,这是第一篇,如果能够按照上面的做法,将安装包体积减少,就迈开了性能优化的第一步,这一步虽然不难,但效果会非常显著,如果还没做,不妨立即试一下。

希望这个ReactNative性能优化系列能填完,也希望整理的东西对大家有实际的帮助,有任何问题,欢迎随时沟通~

ReactNative之一次Reconciliation讨论

首先抛出一个问题,这也是这篇博客产生的背景,在这个demo里,有两个render函数,他们的效果是一样的,就是初始渲染Com1和Com2组件,5秒后变成只渲染Com2组件,但实现代码不一样,我看到一篇文章里说第二种写法性能更高,因为第一种写法会有组件的销毁和重新创建,第二种写法没有。读者不妨停下来自己想一想是否认同这个观点,然后再继续看下去。

我看到这个观点时,首先想到的是:这两种情况下最终渲染结果是一模一样的,所以我推断它们生成的virtual dom也是一样的,而组件(实际上是组件对应的原生view)的销毁创建,完全取决于virtual dom的布局,既然virtual dom一样,那这两种方案就没有区别,不存在后者性能更优的说法。于是我就去找作者讨论,作者使用ReactDevTools观察过这两种情况下的render数据,指出在第一种写法中,Com2组件重新构造了,render次数为1,而第二种情况下,Com2组件的render次数在增加,没有重新构造,这个可以在Com1和Com2的构造函数里打一句log,可以很容易验证。但我觉得这也很好解释,第一种情况下原来Com1位置变成了Com2,所以刷新时会卸载Com1和Com2然后装载Com2,这样来生成virtual dom,在这期间,类组件的实例化代价是非常非常小的,几乎不会造成任何性能差别。

本来以为讨论已经结束,结果作者观察后发现两种情况下virtual dom并不一致。两种情况下刷新前的渲染是完全一致的,但刷新后,第一种情况下根View下只有一个子节点Com2,而第二种情况下根View下有两个子节点,分别是null和Com2。于是我赶紧去翻了下React的官方文档

Booleans or null. Render nothing. (Mostly exists to support return test && pattern, where test is boolean.)

这里提到bool值和null不会渲染任何内容,但可没有说不会有virtual dom节点,而且实际观察确实有,于是前面得出的两种情况下virtual dom一样的这个结论就站不住脚了。(顺便说一下,这时我发现我前面因为实际渲染内容一样就推断virtual dom一样是很蠢的,因为搞错了因果关系,是virtual dom决定渲染内容,而不能由渲染内容来推断virtual dom)。不过虽然同意两种情况下virtual dom不一致,我仍然在做负隅顽抗,因为我脑子里揪住“两种情况的渲染结果一样”这个点不放,所以想是不是React在对virtual dom做diff时,忽略掉了这个null节点,这样即使virtual dom不一样,反应到原生端view时还是一样的,接着我就想,如果React能做到忽略掉null节点,那么在第一种情况下,它就不会笨笨的先卸载Com1和Com2然后装载Com2,而是通过virtual dom树的比较,发现Com2节点还在,所以复用Com2节点。

到这里,我觉得我的脑子已经不清醒了,第一种情况下Com2节点并没有被复用,是很明显的,React对virtual dom树的diff算法其实也并没有多深奥和复杂,官方在Reconciliation这一节介绍的也很清楚。所以很快我也就放弃了“React会很智能地帮我们安排好最高效的刷新方案”这个观点,应该靠事实说话,而不是盲目崇拜和迷信权威。

既然能实际观察到第一种情况下Com2销毁和创建了,第二种情况下没有,接下来我就开始思考是什么原因导致的,脑子不清醒的我又迅速掉入了一个坑,我想第一种情况下,return的两个js view不一样,是否导致它们在原生端绑定的不是同一个view呢?我不知道是否有人会觉得这句话很可笑,好在我自己迅速反应过来了:JSX只是语法糖,返回的js view并不代表任何意义!React的render原理是这样的:一个类组件要渲染时,调用它实例的render方法,得到virtual dom节点,进行diff,然后反映到原生view上。所以第一种情况下,return的是不是同一个js view根本没任何影响。

再次跳出坑之后,文章作者跟我说,第二种情况下Com2在父组件内的index没变,第一种情况下变化了,可能跟这个有关系。一句话点醒梦中人,我才突然明白过来,原来真相就是这么简单!React在对virtual dom做diff时,是按顺序一个一个来比较的,除非对组件给出key这个props,diff前后如果key相同,就会认为组件可以复用,这是Reconciliation中的一个重要内容。所以第一种情况下,刷新前后是Com1变成Com2,Com2销毁。而第二种情况下是Com1变成null从而销毁,Com2不变(当然位置变了,但组件不会销毁和创建)。

整个问题的讨论持续了有2个多小时,我原本以为自己对reconciliation非常了解了,但还出现“渲染结果一致所以virtual dom一致”这种低级错误,真的非常不应该,虽然得出结论后发现问题其实很简单,但整个思考过程还是挺有意思的,于是记录下来。

最后,在做demo验证时我发现一个很奇怪的问题,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
render() {
return this.state.visible ? (
<View style={styles.container}>
{false}
<Com1 />
</View>
) : (
<View style={styles.container}>
<Com1 />
</View>
);
}

刷新组件从false Com1变成Com1,理论上来说应该有一次Com1的销毁和创建,但实际上并没有,当增加子组件的个数,例如从false Com1 Com2变成Com1 Com2时,就和预期的一样了,所以为什么只有一个Com1时,没有发生组件的销毁和创建呢?这个我暂时没有答案,希望有大牛帮忙解答。

ReactNative性能优化实践

这篇文章是翻译的medium上的React Native Performance: Do and Don’t,本来是想拿来做内部技术周刊的投稿,但没有被选中,既然花费了时间和精力去翻译,就不想浪费掉,所以放到博客中来。原文的发表时间是2019年5月31日,所以还算比较新,也有4300个赞,所以质量还算可以,但这篇文章和很多技术博客(尤其是我的……哈哈)一样有个问题,讲了一些偏细节的点,但没有提炼出本质。当然,如果说到RN性能的本质,我觉得只有一个:避免重复渲染。这是后话,以后再叙吧,以下就是翻译全文,这算是第一次写翻译稿,还请轻拍。

这篇文章是作者基于两年时间开发Nelio的经验写作而成,Nelio是一个使用React Native开发的跨平台移动应用。阅读本文需要具备一些React或者React Native的开发经验。当然,本文所讲的内容并不完全局限于React Native,很多建议也适用于普通的React应用。此外,本文也不能将所有关于性能的方面进行非常全面的阐述,所以如果你遵循了本文的所有建议,但仍然存在性能问题,也请不要苛责:)

Nelio是一家总部位于巴黎的初创公司,致力于高端优质食品的派送。这家公司也比较「追求质量」,体现在很多个方面,其中就包括了代码编写。性能对移动应用来说,是非常重要的方面,它直接影响到用户对其提供服务的感受。坦白而言,能够满足自己和客户的期待,在很多时候都非常的不容易,所以这篇文章总结了他们在开发阶段所有的那些经历:学习到的知识、犯过的错误、碰到的问题及其解决方案。希望本文对大家能够有所启发。

React和React Native的性能

对于React开发者来说,React Native非常容易入门,因为React Native和React具备相同的架构。但在实际开发中,React Native需要深入理解的内容也很多,就像一个专业的React Web开发者需要去深入了解浏览器的知识一样。

首先需要说明的是:

所有React性能相关的知识,都适用于ReactNative

如果要了解React Native性能相关的内容,第一步可以去看看React的官方文档,然后再看下官方的React Native性能相关文档,这些资料非常有用,不过本文在这里不再复述,而着重于在实际开发中为了提升性能采用了什么解决方案,避免了什么问题。本文也不会花费太多时间去讨论React Native引擎自身性能是否足够好,是否需要考虑转向使用Flutter或者原生开发,市面上有很多表现优异的React Native应用,而我们的目标就是努力成为其中之一。

切记提供UI反馈

性能更多体现在用户的感知层面,而不是精确测量一个函数的运行时间,而且相对于关心「卡了多长时间」,你应该更关心「为什么卡」和「什么时候卡」。一个广泛的共识是:你应该在用户操作后的100毫秒内给予反馈,请在脑海中牢记这一红线,记住:尽可能早地给予用户反馈。

给予用户反馈的方法有很多种,在React Native中,有一个实用而且简单的方案就是多使用TouchOpacity组件,它能够在用户交互时让用户感受到变化,从而明白自己的操作得到了响应。

在打开一个新页面时,你需要考虑数据加载的问题。一个比较好的方案,是先尽快打开页面,展示那些已有的能够渲染的数据,然后在正在加载内容的地方使用一个loading组件或者placeholder组件,这种做法也被叫做skeleton screens

如果点击会产生一些其它效果,例如增加数据、点赞、发送聊天信息等,这些行为都伴随着与服务器通信。在这种场景下,你不应该等收到服务器消息后再刷新页面,而应该提前让客户端表现得像已经成功收到了服务器消息,这种叫做optimistic ui的技术方案目前已经被广泛使用。

在Nelio开发中,我们使用GraphQL和ReactApollo,ReactApollo通过optimisticResponse可以很方便地实现这种技术方案,当然通过别的方式也可以实现,例如Redux。

图片

对一个React Native应用来说,图片加载是体现性能和可用性的一个重要方面。这对于Web开发者来说,可能会感到有些奇怪,但仔细想想,这其实是浏览器帮忙做了大量的工作,例如下载、缓存、解码、缩放以及展示这一整套工作流,但在React Native开发中,这些就需要自己去想办法了。

使用缓存策略

React Native官方提供了Image组件,用来展示单张图片时基本毫无压力,但如果需要同时展示大量图片就略显吃力了,例如会出现闪烁或者停止加载的现象,为此我们选择了使用react-native-fast-image组件。值得一提的是,该组件有非常庞大的使用群体,从npm上的周下载数据来看,占据了react-native下载量的12%,和Expo的下载量几乎一样大。

img

只加载需要尺寸的图片

React-native-fast-image组件能解决很多问题,但我们发现应用在运行中仍然会随机出现一些图片相关的Crash。在进行调研后,我们发现此时应用在同时下载、缓存和缩放数十张尺寸为几百K的图片,我们尝试直接从源头上解决该问题,就是限制用户上传图片的尺寸,但这个解决方案并不是最优的。在任何时候,都要时刻注意展示图片的数量和尺寸,判断会否会对设备造成很大的压力。比较好的方案是,将大部分工作提前做好,而不是留到用户设备上去做。即使在展示图片时还不存在内存问题,也最好能将图片剪辑成真正需要展示的尺寸,这样可以减轻用户的设备压力。

我们选择使用了一个图片缩放CDN的解决方案,它能支持用户下载准确符合展示尺寸的图片。准确来说,我们选择使用的是CloudImage,它能支持在请求图片数据时指定尺寸信息。实际接入时,我们修改了GraphQL接口,将图片URL转换成CloudImage所需的格式,当然也可以在客户端代码中修改。除了CloudImage之外,也有其它的选择方案,例如Cloudinary,或者采用一些开源的方案例如imgProxy或者Thumbor等等。

合理使用PureComponent

正如之前所说,React Native应用本质上也是React应用,所以适用于React应用的大多数优化建议,也同样适用React Native应用。而在所有React性能优化建议中,也许提到最多的就是:是否要使用PureComponent(或者React.memo())。简单来说,通常在React应用中,重复渲染并不是很大的问题,但在一个复杂的移动应用中,就会变得严重了。

PureComponent能够减少重复渲染,它只有在props发生了变化时才刷新,更准确地说,是在shouldComponentUpdate方法中对props进行浅比较来进行判断。有的人认为不管什么情况都使用PureComponent就好了,但作者认为这种做法弊大于利,这实际上是一种典型的过早优化的做法。

在使用PureComponent时,如果想要减少重复渲染,那么你需要做的是:在其父组件render方法里,不要创建新的props变量

在创建新的props变量的写法中,主要是使用新的object和新的function作为props,另外还有使用新组件作为children props的示例,但从本质上讲,JSX实现的组件对象最终还是一个JS Object,如下图所示:

img

另外还需要注意:array也是Object,如果我们写一些函数式的代码,需要注意,很多时候是得到一个新的数组对象,例如下面例子中,item.filter每次都会生成一个新的数组对象:

img

另外,在开发中经常会用到一个技术方案叫renderProps,它将一个能够返回组件的render函数作为props,既然是函数,就需要小心:不要在render时创建一个新的。

在Nelio中,我们还没开始使用React Hooks,它是从React Native 0.59版本起才开始支持,如果你还没使用,可以考虑去尝试一下,我们使用了recompose。recompose对React hooks有很大的启发,其中Pure、withHandlers和withPropsOnChange等功能接口,对项目开发中代码质量的保障和性能提高,都起到了非常大的作用。

不要滥用高阶组件

随着应用复杂度的提升,逐渐会有在组件间共享逻辑的需求,这时通常会选择使用高阶组件。高阶组件本身是个不错的技术方案,虽然它也确实增加了组件层级和代码复杂度,而真正需要注意的是:不要滥用高阶组件,尤其是在render函数里。因为每次调用高阶组件函数,都会生成一个新的组件,在render函数内使用,会导致重复渲染的问题,而且整个高阶组件结点树的所有生命周期函数可能也会重新执行,如下图所示:

img

记得在开发中一次错误的使用场景,我们在混合使用了RenderProps和高阶组件时出了问题,作为ReactApollo的使用者,我们频繁使用了Apollo Query Component来从后端获取数据,同时我们的代码风格是尽可能使用recompose,所以最初的实现方案是使用高阶组件fromRenderProps来包装一个Apollo Query组件,如下图DontMixHOCAndRenderProps中所示,但这个方案只适用于不需要动态参数的场景,一旦需要动态参数就行不通了。因为fromRenderProps不支持传入额外的参数,为了解决这个问题,我们找了两个解决方案,第一个是不使用Recompose HOC,而是使用普通的组件;第二个方案是使用Appolo graphQL HOC,因为它能够满足我们的需求,所以就采用了这个方案。

img

另外一个高阶组件的使用场景,是基于特定props来构造高阶组件实例,例如这个demo,我们在项目中有类似的实现方案,在经过考虑后全部删除了,改成使用renderProps或者直接使用组件作为props的方式。

避免庞大的reducer函数

如果你没有使用GraphQL,那么很可能你使用了Redux,而我们两者都使用了,虽然我通常并不推荐这么做。如果你没有使用normalizr或者rematch来配合Redux,或者需要手动实现reducer函数,请一定谨记只修改发生了变化的state,如果你认真了解过Redux基础教程, 那你应该已经注意到了这点,但如果没有,就再去仔细阅读一下吧:)

如果你像我们一样偶尔匆匆赶代码,那么有可能当你从后端获取一组数据然后存储到state里时,你会很快写出以下代码:

img

如果这么写,而且在刷新列表时出现了性能问题,那你需要改进的就是:只更新state里真正发生了改变的部分。更准确地说,是更新它们的引用,如果一个数据的实际内容和之前相比没有发生变化,那你就不要在Redux内让它指向一个新的引用,否则将会导致使用它的组件发生多余的重复刷新:组件展示的内容没有任何变化,但销毁了老组件并创建了新组件。

不要轻易复用函数

如果使用了Redux,那么调用connect函数时,你一定会用到mapStateToProps函数。随着工程复杂度的提高,mapStateToProps也越来越庞大复杂,可能mapStateToProps内充满了复杂的计算,而且出现了很多重复渲染,这有点出乎意料,因为mapStateToProps返回的对象是进行了浅比较来判断是否发生了变化的。这个问题本质上和前面PureComponent提到的问题是一样的,在父组件每次渲染时给PureComponent组件提供了一个新的props,就会导致重新渲染,所以这里需要做的就是:在mapStateToProps里对state没有发生变化的部分,就让其生成的props也不要发生改变

明白问题所在之后,只要使用reselect库就可以解决这个问题了,虽然它会增加一些代码复杂度,但却是非常值得的。不过也要小心,错误地使用reselect可能也会导致性能问题。尤其是在项目的不同地方或者不同组件之间共享reducer函数时,reselect提供了缓存功能,但对一个函数,cache也只有一个,Reselect官方考虑到了这个问题并提供了解决方案,简单来说就是给每个需要的组件创建各自的selector对象,其它库例如re-reselect使用其它方案也解决了这个问题。

总之:在不同组件或者组件实例间复用函数时,都要谨慎对待。

更多

在移动应用开发过程中,想要一次性解决性能问题是不太现实的,通常都需要持续的投入,下面介绍一些我们正在尝试的一些改进方案。

升级React Native到0.59版本

如前面所说,React Native 0.59版本引入了React Hooks的功能,使用hooks可以避免使用recompose库,因为recompose库目前已经不再维护了。此外React Native 0.59版本还升级了Android端的JavaScriptCore引擎,新的JavaScriptCore引擎能带来大概25%左右的性能提高,而且支持64位CPU架构,可以满足Google应用商店从2019年8月1日起开始对所有App的强制要求

FlatList优化

在渲染列表时,应该选择基于VirtualizedList实现的组件,例如FlatList或者SectionList,根据列表的单元行数量,列表组件的复杂度和尺寸等情况,尽可能地优化其props的使用,因为列表组件会对页面的性能产生直接影响。

使用工具检测性能问题

为了更好理解性能问题,你需要了解组件被装载和渲染的次数,使用React Profiler可以帮你发现卡顿问题的来源。还有spying the queue,它是React Native引擎在JavaScript代码和原生代码之间传递数据的通道,在寻找卡顿原因时会很有帮助,可以点击这里了解更多。假如应用在交互时只响应了一部分,例如scrollView可以正常滚动,按钮点击会变化透明度,但是JavaScript回调却没被调用,这意味着原生代码被执行了,但JavaScript代码没有,那么这种情况下,我们需要去查看一下数据通道是否因为太过繁忙而被堵塞。

JavaScript之unhandled promise rejection

在JavaScript中,unhandled promise rejection问题有两种场景,一个是promise没有写catch,但是又变成了rejected状态,另一种是promise写了catch,但是catch中又抛出了异常,例如:

1
2
3
4
5
6
// 场景一
Promise.reject().then()
// 场景二
Promise.reject().catch(err => {
throw new Error()
})

这篇文档里提到:如果在promise内发生异常,而这个promise又没有catch,那么这个promise会变成rejected状态,然后系统会抛出一个全局异常,原文是

JavaScript engine tracks such rejections and generates a global error

但经过google和实际观察,我发现这并不准确。这篇文章里一直在拿“普通异常未做catch”和“unhandled promise rejection”做类比,但我感觉这二者其实没很多共通的地方,也许作者只是为了便于读者理解吧。

那么实际上会发生什么呢?目前来看,发生unhandled promise rejection不会抛出异常,也就不会导致JavaScript运行中断(写个demo验证下就知道了)。在浏览器中,我们可以在控制台看到有个红色的Uncaught (in promise)报错,同时系统会抛出一个unhandledrejection事件,我们使用window.addEventListener来监听到,例如

1
2
3
4
5
window.addEventListener('unhandledrejection', function(event) {
// the event object has two special properties:
console.log(event.promise); // [object Promise] - the promise that generated the error
console.log(event.reason); // Error: Whoops! - the unhandled error object
});

在nodejs环境中,可以看到一个UnhandledPromiseRejectionWarning警告,同时系统也会抛出一个unhandledRejection事件,使用process.on(‘unhandledRejection’)来统一监听。在未来的版本中可能会抛出异常,总之完全取决于JavaScript解释器如何处理。

我们在实际代码中,显然不能依赖于监听unhandledrejection事件来统一处理的方式,更靠谱的办法是养成好的编码习惯,首先是使用promise时都配备catch函数,即使给个空函数都可以,其次是在最终的catch函数里,最好使用try-catch包装,避免继续抛出异常。当然,使用监听unhandledrejection来做兜底方案也是很有必要的。

ReactNative之Redux源码阅读(applyMiddleware,compose)

去掉utils目录和compose这些辅助类,applyMiddleware是最后一个暴露的接口源码了,果然redux代码还是很好看懂的。当然这主要是因为代码质量很高,而且注释很完善,这是我们自己写sdk时值得学习的榜样,接下来就看下applyMiddleware的代码。

applyMiddleware

ReactNative之Redux源码阅读(createStore)里提到了enhancer函数,applyMiddleware就是redux内置的一个enhancer。enhencer函数都接受createStore函数作为参数,然后返回新的createStore函数。

老样子,先看一下代码注释,然后看具体的代码实现,applyMiddleware接受的参数是middlewares数组,在它返回的createStore函数里,先调用createStore得到store,然后拿middlewares加工store.dispatch方法,最后返回store和加工后的dispatch函数。

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

从这里可以看到,每个middleware函数接受middlewareAPI参数,其中包含getState和dispatch两个属性,并返回一个函数,返回的函数通过compose组合起来(详见compose.js内的代码注释),所以这个返回函数接受的参数是上一个middleware函数的返回值(也是dispatch函数),并返回dispatch函数(会成为下一个middleware的参数)。也就是middleware函数的定义是

function myMiddleware({getState, dispatch}) {
    return function(next) {
        return function(action) {
            return next(action);
        }
    }
}
// 箭头函数写法
const myMiddleware = ({getState, dispatch}) => next => action => next(action)

建议看一下redux-thunk的代码实现加深一下理解。

整个redux的源码到这里就结束了,到这里就会发现真的没太多内容,接下来再看一下React-redux的代码,因为一般redux都会配合React-redux来使用,而且它的代码也不复杂。

ReactNative之Redux源码阅读(combineReducers&bindActionCreators)

闲话少叙,直接开始看另外两个文件

combineReducers

照例,我们最好先把源码开头的注释仔细看一遍。combineReducers在实际中用的很多,它的作用是可以将多个reducer函数聚合起来,避免写一个太过庞大复杂的reducer函数。

直接看源码,它接受的参数是一个object,其每个value都是一个reducer函数,而每个key就会用来作为state的key(这里对redux有了解的同学应该不难理解,每个reducer函数都会返回一个state,这些state就以这些key组合起来),在前一篇ReactNative之Redux源码阅读(createStore)里也提到,使用combineReducers时如果有preloadState,两者结构需要保持一致。

接下来开始看源码,前面的类型检验看完就可以跳过,首先需要注意一下assertReducerShape,它对每个reducer函数先做一次校验,传递一个ActionTypes.INIT以及一个随机字符串type进去,看是否返回合法值,这就要求reducer函数中state必须要有初始值,而且必须能响应任意action返回合法值,在实际操作中我们都是通过switch case的default来返回原始state,如果不这么做会抛出异常,就是这块代码实现的。

接下来可以看到combineReducers返回的combination仍然是一个reducer函数(接收state和action,返回state),这样前一篇提到createStore的第一个参数可以是combineReducers函数的返回值,就很好理解了。

接下来看combination的实现,首先是debug模式下使用getUnexpectedStateShapeWarningMessage进行一下校验,例如reducers不能是空object,state必须是plain object,以及state和reducers结构必须一致,校验不通过会发出黄屏警告。

接下来它把接收的action交给每个reducer处理,然后把返回的state组合起来生成一个新的state.如果某个reducer函数不需要处理这个action,按照我们reducer函数的实现方案,它会返回原state。这里使用了一个临时变量hasChanged做标记,如果所有reducer函数响应该action后都没有发生变化,combination这个聚合reducer函数就返回原state,而不会返回一个新对象,但只要有一个reducer函数响应后state发生变化了,combination就会返回一个新的state对象,从而触发刷新。

bindActionCreators

在这个文件初始有一个函数bindActionCreator,这个函数接受actionCreator函数和dispatch函数,返回一个新函数,它把actionCreator生成的action dispatch出去,我们调用这个新函数时,就是触发了dispatch,这个函数只是帮助开发者稍微简化一下代码。

接下来看bindActionCreators的注释,它把一个object变成新的object,原object的每个value都是actionCreator函数,新object的key与原来一致,但value变成使用上面bindActionCreator封装之后的结果。函数的具体实现非常好看懂,其接受的第一个参数如果是object,就退化成bindActionCreator,否则就如上面所说,把每个value转换一次。

这个辅助函数会用于react-redux中,我们通过this.props.actionCreator来dispatch一个action,就是使用它来简化了代码,以后分析到react-redux时再看

ReactNative之Redux源码阅读(createStore)

很久没写点啥了,一来是最近半年事情很多,二来是自己也迷茫了挺长时间,现在有点缓过来,应该要恢复记点东西了。言归正传,前几天看到说redux源码总共只有600来行,很容易看懂,于是去看了一眼,发现确实是,大概用了两个小时就全部看完了,也确实很容易看明白,想到不少同学对redux有一种犯怵和抵触的心理,顿时觉得很没必要,不信也去看一看源码吧。

这个Redux源码阅读系列会把整个Redux的源码都过一遍,适合对redux已经有足够了解的同学,如果不是很了解,建议仔细把官方文档再看一看。话不多说,进入Redux在github上的仓库,可以看到文件数量只有几个,我们一个一个的讲

index.js

我们要看一个js库的源码,首当其冲就是index.js,这里展示了对外暴露的接口,我们可以看到熟悉的createStore, combineReducers, bindActionCreators以及applyMiddleware。这就过去一个文件了,有没有信心满满呢?哈哈

createStore.js

然后是createStore.js,最顶上的注释把大部分的内容都介绍到了,先看完注释再看源码,会轻松很多。createStore顾名思义是用来创建store,redux的三大原则第一条是单一数据源,所以整个App只会创建一个store。

createStore接受的第一个参数是reducer函数,第二个是可选参数preloadState,第三个是可选参数enhancer。其中reducer函数不用多说,它可以是一个普通函数,或者使用combineReducer生成的函数。preloadState是state的初始值,如果需要设置初始state则传入这个参数,需要注意如果使用了combineReducers,那么preloadState和combineReducers的结构也就是keys需要一致。enhancer参数用来加强redux的能力,Redux自带的applyMiddleware就是一个enhancer。combineReducer和applyMiddleware后面都会看到它们的源码。

然后再看一下返回值,它返回的是一个object,内容是

return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
}

我们直接看这4个返回的方法都做啥,createStore函数最开始那些参数的校验之类的可以快速跳过。

dispatch

先看dispatch方法,dispatch方法就是发出一个action,这个action会被reducer函数处理,生成新的state,然后通知所有注册了监听的组件。它接受一个参数action,action必须是一个plain object,而且必须有type值。

然后是一个开关变量isDispatching,在reducer函数还没执行完之前,是不允许又接收一个action来执行reducer函数的,所以使用这个变量来做开关。

接着currentState = currentReducer(currentState, action)就是调用reducer函数来得到新的state并赋值,之后可以看到遍历了所有的listeners。

subscribe

subscribe用来注册一个监听,前面dispatch修改了state之后,会通知所有的监听者,监听者就是通过subscribe来注册的。

这里的细节是维护了两个listeners队列:currentListeners和nextListeners,在dispatch方法里,先把nextListeners赋值给currentListeners,然后遍历currentListeners,然后在subscribe时,通过ensureCanMutateNextListeners函数拷贝一份currentListeners给nextListeners,然后修改nextListeners。虽然听起来有点绕,但并不难理解,在遍历listeners期间如果注册监听和取消监听,肯定不能修改正在遍历中的数组,所以需要维护两个,一个用来遍历,另一个用来修改。

subscribe函数返回一个unsubscribe函数,这也是js库里常见的一种设计,用来取消注册监听,代码非常好看懂。

observable

这里代码不是很好看懂,它用了一个第三方库symbol-observable,但大概可以看明白这个函数做了什么,它对外暴露了一个subscribe接口,内部实际上是调用上面的subscribe进行注册监听,对外暴露的subscribe需要接受一个observer object。在react-redux中应该能看到这个接口的实际应用。

getState和replaceReducer

这两个比较简单,一起带过,前者是获取到state值。后者是整体替换掉reducer,实际中用到的不多。

dispatch({ type: ActionTypes.INIT })

createStore函数在return之前,dispatch了这么一个action,reducer函数可以用这个action来做初始化工作,当然我们一般都是在reducer函数中用默认值来做初始化,所以这个action实际中很少会用到,但了解这个细节其实会很有用的。

结语

写到后面发现如果把所有源码阅读写成一篇会太长了,所以就做成系列吧,接下来再陆续把剩下的写完。顺便感慨一下,读完源码加记完笔记用了俩小时,现在写这一篇就用了俩小时差不多,还写的很不咋的,语言和文字表达能力有点太堪忧,总是前言不搭后语,可见要写一篇好博客,真的很不容易啊……