react、redux什么的都用起来 【4】生产部署和优化
现在项目已经有了,但是要把它放到生产环境中还是有些事情要做,在这最后一节,来把它们一一搞定。
打包部署文件
我们的源代码是没法直接跑起来的。ES6语法大部分浏览器还不完全支持,有些浏览器完全不支持。而less、sass这些样式框架就更不用说了。另外对这些代码最好进行压缩,以获得更快的访问速度。所以在正式发布这些代码前必须先要编译打包。webpack可是干这个的以大能手,看名字就知道了。那要怎么打包呢?终端执行:
npm run dist
搞定。
现在我们的项目目录里多出了一个名为dist的文件夹,这里面就是要部署的全部内容。由于generator-react-webpack-redux已经为我们做好了webpack的一些配置,所以我们看到打包好的文件已经经过了压缩混淆。
服务器设置
如果我们在使用react-router的时候选择了浏览器历史管理方式,那么服务器必须要能够正确处理各种路径。实际上我们的应用只有一个页面文件,在访问各种有效路径的时候,服务都应该返回那唯一的页面。在开发过程中,我们通过npm start指令启动了一个node服务,它已经处理好了这些路由。但是在实际生产环境中,我们往往会使用一个静态服务器,比如nginx或apache。如果把刚才打包好的dist目录扔给nginx,你会发现只有根路径可以访问,通过点击跳转到各个路由没问题(也就是通过react-router控制的跳转),要直接在浏览器的地址栏输入"http://localhost/news"这样的自路径就404了。现在以nginx为例来配置好适合我们应用的路由。
我们所需配置的内容都在http > server节点下。
首先考虑对诸如/news这样的路径并不存在对应的页面文件,所以对于未知路径要都给打发到根路径下:
location / {
root /Users/someone/my-project/dist;
index index.html index.htm;
try_files $uri /index.html;
}
这样,我们在地址栏输入"http://localhost/news"以后,nginx没有找到news.html,它就尝试找index.html,inedex.html打开后,我们的代码就生效了,react-router看到地址栏里的路径是/news,它就会在一开始去匹配/news,并改变状态。
至于脚本、图片这些静态文件我们不用处理,因为nginx按照路径就可以直接找到这些文件。另外就是把后端服务的接口处理好,nginx代理tomcat这些后端服务是很常见的配置,只要注意在路径上服务和页面要能明显区分开,比如所有的后端服务接口都有.do后缀,这样配置就行了:
location ~*.do$ {
proxy_pass http://192.168.1.1:8088;
}
分离样式文件
尽管在示例代码里我把样式都写成内联形式的了,但我还是建议写单独的样式文件。前面也提到过,样式文件可以直接在js代码中引入,这对于构造独立的模块非常方便。但是在默认状态下,我们会发现导出的文件没有css文件,实际上导入的样式是在代码运行时加到页面上的style标签里的。这样页面渲染性能不太好,而且会增大js文件的体积,最好还是把它拿出来。万能的npm里有专干这个的webpack插件,来把它装上先:
npm install extract-text-webpack-plugin --save-dev
然后要修改一下webpack的配置文件。由于这个插件只有在打包的时候才会用到,所以我们只改cfg/dist.js文件。引入这个插件,然后在plugins数组里添加相应的项目:
// ...
let ExtractTextPlugin = require('extract-text-webpack-plugin');
// ...
let config = _.merge({
// ...
plugins: [
// ...
new ExtractTextPlugin('app.css')
]
// ...
还要改一下loader。原本loader是写在cfg/base.js里面的,但是在开发环境中我们用不到这个插件,而如果使用了插件提供的loader就会报错,所以我们在dist.js里面把config.module.loaders数组覆盖。假如我们的项目里用到了css和less两种样式文件,就在config.module.loaders.push这一段前面添加如下代码:
config.module.loaders = [
{
test: /\.css$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader')
},
{
test: /\.less/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!less-loader')
},
{
test: /\.(png|jpg|gif|woff|woff2)$/,
loader: 'url-loader?limit=8192'
}
]
这里除了两种样式文件的loader以外,还把base里的一个非样式的loader给带过来了,别把它忽略了,它很有用,一会儿再说。
现在再运行npm run dist,可以看到asset文件夹里多了一个app.css文件。别忘了在index.html文件里面引入新生成的样式文件。
加载图片
webpack让我们可以在js代码中引入图片并使用,引入图片只需一个简单的require语句:
let logo = require('../images/logo.png');
然后可以像使用其它变量一样来使用这个图片:
render(){
return <img src={logo}>
}
你可能觉得,一个图片直接用它的路径就行了,何必要装模作样的引入呢?我认为有这么做两个好处:
首先还是模块化。如果一个组件需要用到图片,在这个组件文件内引入图片,图片会在run dist时一并打包,不用担心图片丢失。
其次很多服务器会对图片进行CDN缓存,如果你替换了一张图片,很可能它在一段时间内不会生效,而通过webpack引入的图片是一内联base64或者重命名为唯一hash文件名的形式打包的,这样就不会出现恼人的缓存情况。
不只是在js中引入图片会被webpack处理,css里的图片也会被同样的方式处理。
如果你已经在你的项目里加上了几个小图片,你可能会发现打包后并没有看到图片或者图片比原来少,这是因为有一个临界值,低于它的图片会直接转成base64写在导出的js文件里。这样也好也不好,好处是图片在一开始就被载入,后面不会出现图片延后载入的效果,用户体验很好,不好就是base64比原图片大小更大,如果图片比较多,导出的js文件就会太大,让用户初始等待时间过长。所以我们要权衡利弊设置一个合适的临界值。前面我们在dist.js配置文件中重写loaders的时候把base里的一个loader带了过来,它就是干这个用的,test属性的正则表达式表明我们想让webpack处理什么格式的图片,loader属性最后的数字就是内联图片临界值,单位是字节。我们把它设置成1K吧:
{
test: /\.(png|jpg|gif|woff|woff2)$/,
loader: 'url-loader?limit=1024'
}
多个入口
我们的目标是单页应用,但是当项目规模比较大的时候整个项目可能会被拆分成多个单页应用。拆分多个应用的关键在于要有多个入口文件。目前我们的项目只有一个入口文件:src/index.js。来看cfg/dist.js文件,里面的config对象中entry属性的值现在是一个index.js路径字符串。entry的值也可以是一个对象,这样就可以声明多个入口文件,对象的key对应着文件名。比如我们想要增加一个入口文件src/test.js,先搞点很简单的内容:
import React from 'react';
import { render } from 'react-dom';
render(
<div>TEST</div>,
document.getElementById('app')
);
把cfg/dist.js中的config.entry改成这样:
entry: {
app: path.join(__dirname, '../src/index'),
test: path.join(__dirname, '../src/test')
}
现在明确指定了两个入口文件,然后还要修改config.output.filename:
config.output.filename = '[name].js'
输出文件时,name会自动对应成entry中的key。执行npm run dist,现在asset目录中多出了个test.js。
使用这个文件需要另一个单独的页面,如果我们用静态html页面的话,要把页面路径添加到项目根目录下的package.json中,在scripts对象中有个copy属性,加到里面就行了,这样才能在run dist的时候把它一并拷贝到dist目录里。
最后,也许你还要修改一下nginx配置,让test路径单独匹配。
分离第三方库
你可能发现了刚才我们把文件分成多个入口时,新入口文件即使内容非常少,哪怕只渲染了一个div,生成的文件大小还有上百k。里面其实主要都是第三方库。这太不优雅了,既然这些第三方库几乎会被所有的应用重复使用,一定得把他们单拎出来。于是我们需要一个插件:CommonsChunkPlugin。这个插件不用单独安装了,它被包含在webpact.optimize里面。我们打算再输出一个叫commons.js的文件,包含全部第三方库。在cfg/dist.js的plugins数组里面添加这个插件:
new webpack.optimize.CommonsChunkPlugin('commons', 'commons.js')
然后在entry对象里面再添加一个commons属性,它的值是一个数组,包含所有我们想要拎出来的库:
entry: {
app: path.join(__dirname, '../src/index'),
test: path.join(__dirname, '../src/test'),
commons: [
'react',
'react-dom',
'react-redux',
'react-router',
'redux',
'redux-thunk'
]
}
OK,输出的文件多了个commons.js,而app.js和test.js比原来小了很多。这回优雅了。别忘了在所有的页面里都把commons.js引进去。
按需加载
当项目非常大的时候,拆分多个入口文件是一种方案,还有一种方案是按需加载,也就是懒加载或异步加载。我们可以让用户真正进入一个路由时才把对应的组件加载进来,要实现这个非常简单,只需要一个webpack的loader:react-router-loader,先用npm把它安装上,然后修改src/routs.js文件,比如我们现在想让登录页面懒加载,那就把登录页面的路由改成这样:
<Route path="login" component={require('react-router!./containers/Login')}/>
编译打包后,又多出了一个1.1.js文件,这就是在进入登录路由时要加载的文件,也就是单独的登录组件。其它的就不用我们管了,代码会自动处理的。
既然是按需加载,我们一定是希望初始的时候加载的代码尽量少,尽可能在进入某个路由时才载入相应的全部内容。我们的代码大致就三类东西:组件、action和reducer。组件很明显可以是独立载入的。reducer恐怕没办法,因为它需要指导整个仓库状态的建立。至于action,我们前面的示例代码是不独立的,因为reducer要依赖action文件里面的常量,我们只需要把所有的常量提出到一个公共的文件中,只有组件引用action文件。比如我们新建一个src/consts.js文件,内容是:
export const INPUT_USERNAME = 'INPUT_USERNAME'
export const INPUT_PASSWORD = 'INPUT_PASSWORD'
export const RECEIVE_NEWS_LIST = 'RECEIVE_NEWS_LIST'
export const SET_KEYWORD = 'SET_KEYWORD'
// 所有action的常量...
然后还以login为例,把src/reducers/login.js里面引入常量的目标改为consts.js:
import {INPUT_USERNAME, INPUT_PASSWORD} from '../consts'
src/actions/login.js里也这样引入常量。run dist后,1.1.js文件就包含了actions/login.js里面的内容。
添加hash后缀
在一个大型且需要频繁升级的项目中,静态文件往往需要添加hash后缀,这主要是出于两个原因:一个是所有版本的静态文件可以同时存在,而页面由后端控制,后端根据接口的版本绑定js和css文件,这样便于升级和回滚。另一个是防止缓存,这和前面图片重命名为hash值是一个道理。
让webpack为文件名添加后缀非常简单,只需要在输出的文件名上加上[hash]就可以了。比如我们想让app.js带上hash后缀,只需要在cfg/dist.js最后一句前面加上一句:
config.output.filename = 'app.[hash].js'
而对于插件生成的样式文件和公共js文件同样也是在文件名上加上[hash]就行了。
现在关键的问题是怎么应用这些有了hash后缀的文件。总不能每打一次包我们就手动改一下index.html把。
webpack的配置文件是js,这就意味着这个配置文件是活的,我们可以很容易把想做的事情通过代码实现。现在我要在每次打包后把index.html文件引入的js和css文件自动替换成带hash尾巴的形式,只需添加一个自己写的插件,其实就是一个函数。在cfg/dist.js里面的plugins数组里添加以下函数:
function() {
this.plugin("done", function(stats) {
let htmlPath = path.join(__dirname, '../dist/index.html')
let htmlText = fs.readFileSync(htmlPath, {encoding:'utf-8'})
let assets = stats.toJson().assetsByChunkName
Object.keys(assets).forEach((key)=>{
let fileNames = assets[key];
['js', 'css'].forEach(function(ext){
htmlText = htmlText.replace(key+'.'+ext, fileNames.find(function(item){
return new RegExp(key+'\\.\\w+\\.'+ext+'$').test(item)
}))
})
})
fs.writeFileSync( htmlPath, htmlText)
});
}
很暴力,就是赤裸裸的node操作文件系统。这回dist文件夹中的index.html里引入的脚本和样式都是带hash的了。
在很多项目中,我们前端要提供的可能不是一个引用好js和css的html文件,而是一个map文件,里面有静态文件的版本信息(hash值),这样后端就能直接把需要的静态文件挂上。可以自己写一个跟上面代码类似的插件输出一个map文件,也可在万能的npm找个插件,比如map-json-webpack-plugin。上面那个功能也可以试试replace-webpack-plugin。
到这里,这一系列关于react的博客就算告一段落了。其实我还想写一个关于测试的,因为react+redux的这种模式非常利于测试,不过我还在琢磨测试当中,等琢磨得差不多了也许会补上一篇。
🖐