最近用Webpack+npm scripts+Mongodb+Nodejs+React写了个后台项目,在用Webpack构建过程中遇到了许多坑,就写出来分享一下。
构建工具五花八门,想当年刚学会Grunt,Grunt就被淘汰了,取而代之的是Gulp,其任务流式的机制,有着逻辑清晰,灵活多变的特点,而且容易上手,相比Grunt真的要少写太多配置文件代码了,立马就学的风声水起,刚熟练Gulp,Webpack又如构建工具界的一颗新星冉冉升起,其独特的模块打包机制和各种各样好用的loader,让无数Coder为之青睐,加之和React,ES6的完美配合,博主又立马放弃Gulp,抱着怀疑的心态尝试纯用Webpack构建一个项目(注意:Webpack和Gulp并不是冲突的,曾在项目中结合使用过,但博主决定试一试完全不用Gulp是否可行),结果当然是肯定的,Gulp有的东西(压缩,合并,MD5)等等,你几乎都可以用Webpack来实现一遍,再配上npm scripts,简直如虎添翼。
首先我简单介绍一下npm scripts。先来看一段代码。
{ "name": "app", "version": "0.0.1", "private": true, "main": "./bin/www", "scripts": { "clean": "rm -rf client/dist/*", "copy": "rsync -a --exclude=*.html --exclude=*.jsx ./client/src/*.* ./client/dist", "start": "./bin/www", "server": "node server.js", "build": "npm run clean && webpack --config webpack.config.pro.js && npm run copy && node qiniu.js" }, "dependencies": { "babel-runtime": "^6.11.6", "body-parser": "~1.15.1", "bootstrap-sass": "^3.3.7", "classnames": "^2.2.5", "cookie-parser": "~1.4.3", "debug": "~2.2.0", "ejs": "~2.4.1", "express": "~4.13.4", "mongoose": "^4.6.4", "morgan": "~1.7.0", "react": "^15.3.2", "react-dom": "^15.3.2", "react-redux": "^4.4.5", "react-router": "^2.8.1", "redux": "^3.6.0", "serve-favicon": "~2.3.0" }, "devDependencies": { "autoprefixer": "^6.5.1", "babel-core": "^6.17.0", "babel-loader": "^6.2.5", "babel-plugin-transform-runtime": "^6.15.0", "babel-preset-es2015": "^6.16.0", "babel-preset-react": "^6.16.0", "css-loader": "^0.25.0", "cssnano": "^3.7.7", "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.9.0", "html-webpack-plugin": "^2.24.0", "node-sass": "^3.10.1", "postcss-loader": "^1.0.0", "qiniu": "^6.1.13", "react-hot-loader": "^3.0.0-beta.6", "sass-loader": "^4.0.2", "style-loader": "^0.13.1", "url-loader": "^0.5.7", "webpack": "^1.13.2", "webpack-dev-server": "^1.16.2", "webpack-md5-hash": "0.0.5" } }
做前端的童鞋们不可能不接触这个配置文件package.json,是npm帮助我们管理依赖的重要配置文件,其中的scripts那一块,就是npm scripts的使用方式啦,凡是在npm的scripts属性中配置的键值对,都可以通过npm run xxx【xxx为键名】来执行对应的值里面的命令,比如:npm run server,就会执行node server.js,npm scripts支持bash shell。是不是有点熟悉?你可以把多个命令配置在一个键名下,通过&&符号连接,这样执行完第一个,就会执行第二个,以此类推,直到最后一个执行完毕就结束运行,如果你想同时并行执行,可以用一个&符号,不过貌似只有bash支持,你可以通过npm-run-all插件或者parallelshell插件来做到并行执行。
废话太多了,接下来开始说Webpack,不得不再废话一句,我们以前使用gulp的时候一般也会配置两套任务流,开发环境和生产环境,webpack当然也可以做到,只不过不是像gulp那样用任务的方式自由组合,而是写两个配置文件。
webpack.config.pro.js
webpack.config.dev.js
我们先来说一说开发环境,webpack.config.dev.js这个配置文件。这个配置文件里面使用了webpack-dev-server,webpack-dev-serve类似gulp里面的browserSync,可以创建一个前端服务器,具有代码变动监测,自动刷新页面,热替换等功能。这里我把webpack-dev-server的配置文件单独拿出来,写了一个server.js,我们可以通过node server.js来执行这个文件,这个文件会创建一个dev server,并注入webpack.config.dev.js的配置来开启服务器。
contentBase属性相当于browserSync里面的baseDir,是一个服务器的运行文件目录。
hot这个属性跟热替换有关,先不说。
var webpack = require('webpack');
var WebpackDevServer = require('webpack-dev-server');
var config = require('./webpack.config.dev');
new WebpackDevServer(webpack(config), {
contentBase: ['./client/src'],
stats: {
colors: true
},
hot: true,
historyApiFallback: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
}).listen(3001, 'localhost', function(err, result) {
if (err) {
return console.log(err);
}
console.log('Listening at http://localhost:3001/');
});
下面我们再看一下webpack.config.dev.js这个文件。
var path = require('path'); var webpack = require('webpack'); var autoprefixer = require('autoprefixer'); var ExtractTextPlugin = require('extract-text-webpack-plugin'); var source_dir = './client/src'; module.exports = { cache: true, context: __dirname, devtool: 'cheap-module-eval-source-map', entry: { vendors: [ 'webpack-dev-server/client?http://0.0.0.0:3001', 'classnames', 'echarts', 'immutable', 'isomorphic-fetch', 'jwt-decode', 'lodash', 'react', 'react-addons-css-transition-group', 'react-dom', 'react-motion', 'react-redux', 'react-router', 'react-select', 'redux', 'redux-logger', 'redux-thunk', 'reselect' ], business: [ 'babel-polyfill', source_dir + '/router', 'webpack/hot/dev-server' ] }, output: { path: '/', filename: 'scripts/[name].js' }, module: { loaders: [{ test: /.js[x]?$/, include: /client/src/, loader: 'babel' }, { test: /.scss$/, include: /(client/src/containers|client/src/components)/, loader: ExtractTextPlugin.extract('style', 'css?modules&sourceMap&localIdentName=[name]__[local]-[hash:base64:5]!postcss!sass?outputStyle=expanded&sourceMap') }, { test: /.(scss|css)$/, include: /client/src/assets/styles/, loader: ExtractTextPlugin.extract('style', 'css!postcss!sass?outputStyle=expanded&sourceMap') }, { test: /.(png|jpe?g|gif)$/, include: /client/src/assets/images/, loader: 'url?limit=2048&name=/images/[name].[hash:8].[ext]' }, { test: /.svg(?v=d+.d+.d+)?$/, include: /client/src/assets/images/, loader: 'url?limit=2048&minetype=image/svg+xml&name=/images/[name].[hash:8].[ext]' }, { test: /.(eot|ttf|woff|woff2|svg)$/, include: /client/src/assets/fonts/, loader: 'url?limit=2048&name=/fonts/[name].[hash:8].[ext]' }] }, postcss: function() { return [ autoprefixer({ browsers: ['last 2 versions'] }) ]; }, plugins: [ new webpack.optimize.CommonsChunkPlugin('vendors', 'scripts/vendors.js', Infinity), new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), new ExtractTextPlugin('styles/main.css', { allChunks: true }), new webpack.DefinePlugin({ ENV: JSON.stringify(require('./config.client.dev')) }) ], resolve: { extensions: ['', '.js', '.jsx'] } };
有点懵逼?没关系,我们一一道来。
context这个属性是配置文件的上下文环境,我们用node的__dirname就行了。
devtool是用来配置使用哪种sourceMap的,这里我就不多说了,看官方文档,要注意的是不要在生产环境配置这个属性,会导致文件巨大,而且生产环境不是用来调试的,不需要sourceMap。
entry是最关键的属性,它的值是一个对象,对象的键名是文件名,值可以是字符串或者数组,可以将一个或多个js文件合并到一个文件中,以键名指定的文件名命名,最后输出。这里我们一般会把业务逻辑代码和框架库的代码分开来,这样每次改动业务代码重新编译就不会去编译体积较大的框架和库文件了,提高了编译效率,不过前提是你要使用CommonsChunkPlugin这个插件,将框架、库文件单独输出合并成一个vendors文件,并在页面中引入。
webpack-dev-server/client?http://localhost:3001,引入这个的目的是什么?这个就是启用server自动刷新必须要添加的模块,当然你也可以用--inline模式,但是api方式不支持inline模式,所以必须要把这个模块加在你的所有业务逻辑文件之前。这样你的代码一改,你发现了什么?自动刷新了吧?哈哈哈!
有了自动刷新,还不满足,我们想要热替换,什么是热替换?就是在不刷新页面的情况下,改变代码就自动改变页面对应的内容。大大节省开发时间(不过这个功能还处于测试阶段)。我这里将它和react hot loader结合使用。
这里出现第一个坑,在新版的hot loader里面,如果你把hot-loader加在babel-loader前面,会出现一个错误,Module build failed:The Webpack loader is now exported separately.如果你的loader是不稳定版的,我建议新建一个.babelrc文件。
{
"presets": ["react", "es2015"],
"plugins": ["react-hot-loader/babel", "transform-runtime"]
}
然后在文件中作如上配置,除了这个配置,你还需要加上如下配置:
- webpack/hot/dev-server,加在你的业务逻辑js文件后面,这里有第二个坑,如果你打包了多个入口,需要在每个入口都加上一个webpack/hot/dev-server,否则不起作用。
- 在plugins模块中加入new webpack.HotModuleReplacementPlugin()
- 并在之前的那个server.js中将hot属性设为true
这里我附上一个官方的Troubleshooting链接。
https://github.com/gaearon/react-hot-loader/blob/master/docs/Troubleshooting.md
这里有个地方要注意一下,很多用gulp转来用webpack的新手会有个困扰,开发环境下我们编译的文件去哪里了?以前我们用gulp的时候一般会生成一个.tmp临时文件夹,但是webpack好像你找来找去没有找到,在哪里呢?其实webpack给你放到内存里了,你是不会在磁盘中找到这些文件的,如果你想查看这些文件,可以在浏览器中输入像如下的路径。
http://localhost:3001/webpack-dev-server
这样你就能看到那些编译过的文件了,或者你也可以使用chrome的开发者工具 -> sources里面也可以看到。
至于配置文件中的其他loader和plugin我就不一一讲述了,官方文档和网上的帖子一大堆,童鞋们自己去研究吧。
怎么样,开发环境很简单吧?那么下面我们来配置生产环境,生产环境去掉一些调试的工具,加上一些编译优化的工具。
下面是生产环境的配置文件webpack.config.pro.js。
var path = require('path'); var webpack = require('webpack'); var autoprefixer = require('autoprefixer'); var ExtractTextPlugin = require('extract-text-webpack-plugin'); var WebpackMd5Hash = require('webpack-md5-hash'); var HtmlWebpackPlugin = require('html-webpack-plugin'); var source_dir = './client/src'; var config = require('./config.server'); module.exports = { cache: true, context: __dirname, entry: { vendors: [ 'classnames', 'echarts', 'immutable', 'isomorphic-fetch', 'jwt-decode', 'lodash', 'react', 'react-addons-css-transition-group', 'react-dom', 'react-motion', 'react-redux', 'react-router', 'react-select', 'redux', 'redux-logger', 'redux-thunk', 'reselect' ], business: [ 'babel-polyfill', source_dir + '/router' ] }, output: { path: 'client/dist', publicPath: config.qn_access.origin, filename: 'scripts/[name].[chunkhash:8].js' }, module: { loaders: [{ test: /.js[x]?$/, include: /client/src/, loader: 'babel' }, { test: /.scss$/, include: /(client/src/containers|client/src/components)/, loader: ExtractTextPlugin.extract('style', 'css?modules&sourceMap&localIdentName=[name]__[local]-[hash:base64:5]!postcss!sass?outputStyle=expanded&sourceMap') }, { test: /.(scss|css)$/, include: /client/src/assets/styles/, loader: ExtractTextPlugin.extract('style', 'css!postcss!sass?outputStyle=expanded&sourceMap') }, { test: /.(png|jpe?g|gif)$/, include: /client/src/assets/images/, loader: 'url?limit=2048&name=/images/[name].[hash:8].[ext]' }, { test: /.svg(?v=d+.d+.d+)?$/, include: /client/src/assets/images/, loader: 'url?limit=2048&minetype=image/svg+xml&name=/images/[name].[hash:8].[ext]' }, { test: /.(eot|ttf|woff|woff2|svg)$/, include: /client/src/assets/fonts/, loader: 'url?limit=2048&name=/fonts/[name].[hash:8].[ext]' }] }, postcss: function() { return [ autoprefixer({ browsers: ['last 2 versions'] }) ]; }, plugins: [ new WebpackMd5Hash(), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') }, ENV: JSON.stringify(require('./config.client.pro')) }), new ExtractTextPlugin('styles/main.[contenthash:8].css', { allChunks: true }), new webpack.optimize.CommonsChunkPlugin('vendors', 'scripts/vendors.[chunkhash:8].js', Infinity), new webpack.optimize.OccurrenceOrderPlugin(), new webpack.optimize.DedupePlugin(), new webpack.optimize.UglifyJsPlugin({ sourceMap: false, mangle: false, compress: { warnings: false } }), new HtmlWebpackPlugin({ title: '奇速后台', template: 'client/src/template.html' }) ], resolve: { extensions: ['', '.js', '.jsx'] } };
很多人知道output是用来指定输出路径的模块,path是用来指定输出文件的目录,publicPath主要是给很多插件提供的路径,比如替换静态资源路径,在开发环境下我们用不到,可以不配置,filename是输出的文件名,这里出现第三个坑,filename不单单可以指定文件名,也可以在文件名前面加路径,但它会成为path的子路径,path会是所有插件的上下文输出路径,所有插件的输出路径都会继承这个父路径,在开发环境下,我们配置了服务器的目录并加载这个配置文件后,path是相对于服务器地址的,一般是/,直接丢到服务器根路径就行了,会输出文件到根目录(在内存中)。而生产环境中,path的路径是真实的输出路径,会在服务器上产生文件,我这边是输出到了client/dist。
生产环境下我们需要压缩JS,很简单:
new webpack.optimize.UglifyJsPlugin({
mangle: false,
sourceMap: false,
compress: {
warnings: false
}
})
这边的mangle属性表示是否要混淆形参的名字,压缩过js的都知道压缩后的js的参数会被转化成a,b,c,d这些简单的无语义的字母,如果不是对js有严苛的大小要求,这里可以把它关闭,因为在合并入一些第三方插件的时候,第三方插件代码的不规范,会导致压缩后出现无法定位调试的错误,或者你也可以手动指定一些不要混淆的代码,比如module.exports。sourceMap一定要关闭,只在开发调试环境有用,生产环境会产生大量无用的映射代码。
ExtractTextPlugin用来把sass、less、css文件单独导出,怎么使用这里不多介绍了,但是这里有一个css中引用图片路径的坑:
我们首先明确一下,不管是grunt中的usemin,gulp中的useref,rev,还是webpack中的loader,无非在做两件事情,第一件事都是帮助我们输出文件到指定路径,第二件事改变引用的地方的路径,使之可以正确找到(有的可能只具备输出,不具备改路径,有的可能只是用来替换路径,不具备输出,有的都具备)。
以开发环境为例子,现在假设我们使用了url-loader,它是同时具备输出和改引用路径功能的,我配置了大于2kb的图片会被作为md5过的独立文件输出,并改变原引用路径,假设现在url-loader设置的路径是 images/[name].[hash:8].[ext],这个路径在js中import或者require图片会正确输出到如下地址,同时js或jsx也能正确找到图片:
[主机地址:端口]/images/xxx.[md5].png
现在我要在css中引用一张图片(background-image),同时ExtractTextPlugin是这么配置的,ExtractTextPlugin.extract('style', 'css!postcss!sass?outputStyle=expanded&sourceMap'),编译时,ExtractTextPlugin中的css-loader会去css里面找有没有匹配的后缀,比如png,然后应用url-loader,最后css中的路径变成了:
background-image:url('images/xxx.[md5].png')
图片正确输出到了内存中,说明输出文件确实是继承了path的根目录来输出的,这点毋庸置疑,如下图。
但是这个background-image的路径确是有问题的,在我调试的时候,css文件就会以相对路径去找图片,结果如下:
[主机地址:端口]/styles/images/xxx.[md5].png,果然出了404错误
这显然不是我们想要的结果,所以url-loader这边我们还是要使用绝对路径 /images/[name].[hash:8].[ext],这样就不会被css的输出路径所影响。
如果你任性,就是想用相对路径,那么这时候publicPath的作用就体现了,其实本质上是用来替换主机地址,变为cdn地址的:
ExtractTextPlugin.extract('style', 'css!postcss!sass?outputStyle=expanded&sourceMap', { publicPath: [你的cdn地址] }),或者在output里面设置全局的也可以,这样配置后编译出来的结果如下:
[cdn]/images/xxx.[md5].png
这才是我们想要看到的。
另外像autoprefixer这些插件就不介绍了,gulp能做的,webpack有过之而无不及。
最后更改文件md5码相同的问题,这里引用一篇文章。
http://www.cnblogs.com/ihardcoder/p/5623411.html
看完我相信你就懂了,用上WebpackMd5Hash这个插件,妥妥的解决了问题。
最后最后,如果你想要替换静态html中的css和js引用路径,可以使用HtmlWebpackPlugin这个插件,自动生成模板文件,包括引入rev过地址的js和css,还自带了压缩模板文件的功能,也可以使用各种模板引擎,ejs,jade等等。
什么?你还想要替换html中的<img src="" />的静态路径?正常来讲做react开发,不可能发生这种事情,不过万一你用了Angular呢?是吧?那么也有办法:
<img src="<%= require('/图片路径') %>" />,这样就可以替换html中的静态资源了。
结语:长江后浪推前浪,前浪死在沙滩上,最早从grunt开始学,到gulp,再到webpack,webpack2,构建工具层出不穷,学习这些构建工具是我们迈向前端工程化模块化必须要走的路,前端也不过是在走后端的老路,不管学什么,能不能用上,学习本身是不会做无用功的,你学习的每一样东西,即使淘汰了,也会对你今后学习其他东西有帮助,重要的不是只知道工具的规则,更要知道为什么这么用,它是如何实现的。