- 单页应用的目标
- 实现多页面路由的React-Router库
- 多页面的代码分片
一、单页应用
使用传统的多页面实现方式,即每次页面切换都是一次网页刷新,每次页面切换的时候都遵从以下的步骤:
(1)浏览器的地址栏发生变化指向新的URL,于是浏览器发起一个HTTP请求到服务器获取页面的完整HTML;
(2)浏览器获取到HTML内容后,解析HTML内容;
(3)浏览器根据解析的HTML内容确定还需要下载哪些其他资源,包括JavaScript和CSS资源;
(4)浏览器会根据HTML和其他资源渲染页面内容,然后等待用户的其他操作。
上面的页面存在很大的浪费,每个页面切换都要重新刷新一遍页面。而“单页应用”可以解决这样的问题,“单页应用”只是局部更新,需要做到“单页应用”,需要达到以下的目标:
- **不同页面直接切换不会造成网页的刷新;
- 页面内容和URL保持一致**
页面内容和URL保持一致包含两个方面:第一个方面是指当页面切换的时候,URL会对应改变,这通过浏览器的History API可以实现在不刷新网页的情况下修改URL;另一方面,用户在地址栏直接输入某个正确的URL时,网页上要显示对应的正确的内容。
二、React-Router
React-Router库可以帮我们创建React单页应用。每个URL都包含域名部分和路径部分,例如对于URL http://localhost:3000/home 来说,路径部分是home,因为应用可能会被部署到任何一个域名上,所以决定一个URL显示什么内容的只有路径部分,和域名以及端口没有关系,根据路径找到对应应用内容的过程,也就是React-Router的重要功能-路由。
-
路由
React-Router库提供了两个组件来完成路由功能,一个是Router,一个是Route。Router在整个应用中只需要一个实例,代表整个路由器,后者Route则代表每一个路径对应页面的路由规则,一个应用中应该会有多个Route实例。
-
路由链接和嵌套
React-Router提供了一个名为Link的组件来支持路由链接,Link的作用是产生HTML的链接元素,但是对这个链接元素的点击操作不会引起网页跳转,而是被Link截获操作,把目标路径发送给Router路由器,这样Router就知道可以让哪个Route下的组件显示了。
建立Route组件之间的父子关系,这种方式,就是路由的嵌套。嵌套路由的好处就是每一层Route只决定到这一层的路径,而不是整个路径,所以非常灵活。
- 默认链接
当路径为空的时候,应用也应该显示有意义的内容,通常对应主页内容。在这个应用中,我们希望路径为空的时候显示Home组件,React-Router提供了另外一个组件IndexRoute,就和传统上index.html是一个路径目录下的默认页面一样,IndexRoute代表一个Route下的默认路由,代码如下:
这样一来,无论http://localhost:3000还是http://localhost:3000/home 访问的都是包含home的主页内容。
4.集成Redux
我们希望用Redux来管理应用中的状态,所以要把Redux添加到应用中取。
使用React-Redux库的Provider组件,作为数据的提供者,Provider必须居于接受数据的React组件之上。而React-Redux库Router组件,也有同样的需要,有两种解决办法:
(1)让Router成为Provider的子组件,例如在应用的入口函数src/index.js中代码修改成下面这样:
Router可以是Provider的子组件,但是,不能够让Provider成为Router的子组件,因为Router的子组件只能是Route或者IndexRoute,否则运行时会报错。
(2)使用Router的createElement属性,通过给createElement传递一个函数,可以定制创建每个Route的过程,这个函数第一个参数Component代表Route对应的组件,第二个参数代表传入组件的属性参数。加上Provider的createElement可以这样定义:
需要注意的是,Router会对每个Route的构造都调用一遍createElement,也就是每个组件都创造一个Provider来提供数据,这样并不会产生性能问题。
Redux遵从一个重要的原则就是"唯一数据源",唯一数据源并不是说所有的数据都要存储在一个地方,而是一个数据只存在一个地方,以路由为例,使用React-Redux,即使结合了Redux,当前路由的信息也是存储在浏览器的URL上,而不是像其他数据一样存储在Redux的Store上,这样做并不违背“唯一数据源”的原则,获取路由信息的唯一数据源就是当前的URL。
不过,如果不是所有应用状态都存在Store上,就会有一个很大的缺点,当利用Redux Devtools做调试时,无法重现网页之间的切换,因为当前路由作为应用状态根本没有在Store状态上体现,而Redux Devtools操纵的只有状态。为了克服这个缺点,我们可以利用react-router-redux库来同步浏览器URL和Redux的状态。显然,这违反了“唯一数据源”的原则,但是只要两者绝对保持同步,就不会带来问题,否则,会出大问题。
三、代码分片
借助React-Router,我们可以将需要多页面的应用构建成“单页应用”,在服务器端对任何页面请求都返回同样一个HTML, 然后由一个打包好的JavaScript处理所有路由等应用逻辑,在create-react-app创造的应用中,由webpack产生的唯一打包JavaScript文件被命名为bundle.js。
对于小型的应用,按照上面的方式就足够了,但是,对于大型应用,把所有应用逻辑打包在一个bundle.js文件中,会影响用户感知的性能。
在大型应用中,因为功能很多,若把所有页面的JavaScript打包到一个bundle.js中,那么用户访问任何一个网页,都需要下载整个网站应用的功能。虽然浏览器的缓存机制可以避免下次访问时下载重复资源,但是给用户的第一印象却打了折扣。很明显,当应用变得较大之后,就不能把所有JavaScript打包到一个bundle.js中。
为了提高性能,一个简单有效的方法是对JavaScript分片打包,然后按需加载。也就是把JavaScript转译打包到多个文件中,每一个文件的大小可以被控制的比较小。这样,访问某个网页的时候,只需要下载必须的JavaScript代码就行,不用下载整个应用的逻辑。
1.代码分片的原则
最自然的方式就是根据页面来划分,如果有N个页面,那就划分出N个分片,现实中,各个网页之间肯定有交叉的部分,比如A、B页面都使用一个共同的组件X,而且对于React应用来说,每个页面都依赖于React库,所有至少都有共同的React库部分代码,这些共同的代码没有必要在各个分片里重复,需要抽取出来放在一个共享的打包文件中。
最终,理想情况下,当一个网页被加载时,它会获取一个应用本身的bundle.js文件,一个包含页面间共同内容的common.js文件,还有一个就是特定于这个页面内容的JavaScript文件。
为了实现代码分片,可以使用webpack。webpack的工作方式是根据代码中的import语句和require方法确定模块之间的依赖关系,所以webpack可以发掘所有模块文件的依赖图表,从这个图表中不难归结出分片需要的信息。
如图展示了webpack实现代码分片的原理:
这样,当浏览器访问页面A时,只需要加载PageA.chunk.js、commont.js和bundle.js这三个文件,和页面A无关的4号和5号文件不被加载,节省了代码下载量。
当然,提高网页性能的另一个重要原则是减少http请求数,虽然代码分片减少了每个页面的代码下载量,却也增加了引用的JavaScript资源数,但是这只影响用户访问的第一个页面。例如,用户访问的第一个页面是A,下载PageA.chunk.js、commont.js和bundle.js这三个文件,随后当页面切换到B时,因为浏览器的缓存作用,commont.js和bundle.js不用重新下载,所以新下载的文件只有PageB.chunk.js,当应用中页面越多,这种优化效果也明显。
2.弹射和配置webpack
为了实现代码分片,需要直接操作webpack的配置文件,不能再使用react-create-app产生的默认配置,首先我们要让应用从react-create-app制造的“安全舱“里弹射出来,在命令行执行如下命令:
npm run eject
注意:弹射是不可逆的操作
执行该命令后,应用目录下多了scripts和config两个目录,分别包含脚本和配置文件,同时应用目录下的packge.json文件发在了变化,包含了更多的内容,至此弹射完成,但是功能和”弹射“之前别无二致,要改进功能还需要手工修改一些文件。
有两个webpack配置,分别代表开发环境和产品环境。首先处理开发模式也就是npm start命令启动的模式下的webpack配置。
打开config/webpack.config.dev.js找到给module.exports赋值的语句,在给module.exports赋值的对象中,找到output这个字段,在其中添加上关于chunkFilename的一行,然后找到plugins字段,这是一个数组,在里面添加一个元素增加Commons-ChunkPlugin, 代码修改如下:
增加的output配置,是告诉webpack给每个分片都产生一个文件,文件名包括模块名和后缀”chunk.js“,后缀名可以随意起。
增加在plugins中的配置是告诉webpack把所有分片中共同的代码提取出来,放在名为common.js的文件中。
生成的文件都带上前缀路径,是为了保持和原有的bundle.js文件所在目录一致,也可以是任意一个位置。
上面的修改只针对开发模式,还要修改产品的webpack配置保持一致。
打开config/webpack.config.prod.js文件,在config/webpack.config.prod.js中的output已经有了正确的chunkFileName,所以只需要在plugins中添加下面一行就行:
new webpack.optmize.CommonsChunkPlugins('common','static/js/common.[chunkhash:8].js'),产品环境多出了[chunkhash:8]的部分,这是为了让浏览器缓存在文件内容改变时失去效果。因为产品环境下打包的文件部署出去之后预期会被浏览器长期缓存,所以不能使用固定的文件名,否则后续部署的代码更新无法被浏览为发现。所以每个文件名都会包含一个8位的根据文件内容产生的hash结果,这样当文件内容发生变化时,文件名也发生了变化,对应文件的URL也就发生了变化,浏览器就会去下载最新的JavaScript打包资源。
3.动态加载分片
针对webpack的配置只是告诉webpack分片打包,但是webpack没有”页面“的概念,还是需要修改JavaScript代码来确定怎样按照页面分片。
该实例中,我们希望Home、About、Notfound页面每个都是按需加载的,这三个页面都应该有自己的分片,它们的内容也就不包含在主体的bundle.js文件中。因为webpack的工作方式是根据代码中的import和require函数来找到所有的文件模块,所以,要让这三个页面不出现在bundle.js文件中,就不能再直接使用import命令来导入它们。
完成动态加载分片需要两个方面:
(1)使用require.ensure让webpack产生分片打包文件
在src/Stores.js中,注释掉对Home、About、NotFound的import语句,并利用Route的getComponent属性异步加载组件,代码如下:
(2)使用React-Router的getComponent异步加载页面分片文件
注意:webpack打包过程是对代码静态扫描的过程,即webpack工作的时候,缩写的代码并没有运行,webpack看到import和require参数是字符串,那么webpack就能明确的知道文件模块位置,如果是变量,那webpack无法在静态扫描状态下确定哪些文件应该放在对应分片中。
4.动态更新Store的reducer和状态
当实现动态加载分片后,功能模块(React组件、reducer、Store)会被webpack分配到不同的分片文件中,包含在功能模块中的reducer代码也会也会被分配到不同的代码文件。这样,应用的bundle.js文件中就没有这些reducer函数的定义,每个应用都有唯一的一个Redux Store,当应用启动创建Store时,并不知道这个应用中所有的reducer函数如何定义。所以,当切换到某个页面的时候,除了要加载对用的React组件,还要加载对应的reducer,否则功能模块无法正常工作。功能模块依赖Store上的状态,所以当页面切换时,除了要更新reducer,Store上的状态树也可能需要做对应改变,才能支持新加载的功能组件。