你的位置:首页 > Java教程

[Java教程]Webpack从入门到上线


webpack是目前一个很热门的前端打包工具,官网说得很清楚,webpack的出现就是要把requirejs干掉。同时它还提供了十分便利的本地开发的环境。网上并不容易找到一个讲解得比较详细完整的教程,本文结合实践经验,总结一套可用的开发和上线的配置和流程。

首先,Require JS有什么问题

RequireJs存在的问题

博主先是使用了RequireJs,后来又转了webpack,综合比较,requirejs确实存在一些缺点:

1.写法比较笨拙

 需要把所有的依赖模块写在require函数里面,当模块很多的时候,看起来逼格就不高了,感受如下: 

而webpack既兼容requirejs的写法,也兼容commonjs的写法,也就是说,使用webpack你既可以继续像上面那样写,也可以像node那样写,感受如下:

var modules = {    signHandler: require("module/sign-log"),    chatHandler: require("module/chat-win"),    mapHandler: require("lib/map"),    util: require("lib/util")  };

可以在需要的时候再去require,而不是搞个大括号把全部的模块一下子写到一起。(模块的导出用module.exports = ....)

当然这两种写法不仅是感光上的区别,逻辑上也有区别。用中括号加载的模块通常webpack是动态去加载,而没有中括号是和主文件打包在一起的。

2. 没有通用模块的概念

例如有一个弹框模块,用在登陆注册,并且所有页面都有登陆注册,所以这是一个所有页面的通用模块。如果页面的其它模块都没调到通用模块里面的东西的话,用RequireJs没什么问题。但是实际情况上不是这样的,例如util模块既会被登陆注册的模块调用,也会被很多其它模块调用。这个时候合并压缩就有问题了:合并后的通用模块如common-app.js会带上util的代码、另外一个页面的例如detail.js也会带上util的代码,以后一改util.js里面的东西,就会一并改动其它所有用到util的页面js,就得重新打所有js的版本号。这样无论对布署上线,还是对于用户的缓存来说都是不利的。

 webpack可以把几个文件的通用模块抽出来单独作为一个模块common-chunk.js,引用的时候每个页面先引一个common-chunk.js,再引一个该页面自己的js文件如detail.js,原detail.js里面和其它js文件共用的模块已经被提取到common-chunk.js里面。

3. 没办法直接动态合并压缩一个需要异步加载的模块

这个问题是这样的,假设我的聊天模块文件有500Kb这么大,并不希望一刷页面就加载,而是用户点了聊天再去加载。这个聊天模块有一个入口文件和其它几个模块文件,我合并压缩了入口文件,需要有一个输出文件,而入口文件define的模块名和压缩优化后的输出文件的路径肯定是要不一样的,但是压缩之后他并不会自动去改变输出文件的模块名。这样就导致你要手动去改一下压缩文件的模块名,不然会require不到。我之前找了一下,没有找到解决方案,所以采取了一个压缩两次的比较笨拙的方法。

而webpack有一个文件束chunkFile的概念,它会自动去把需要异步加载的文件变成一个chunkFile,然后触发加载的时候再去加载chunkFile。

4. 需要借助gulp等管理工具进行开发

webpack本身有一些插件和第三方的插件,可以在本地开一个webpack-dev-server,文件一保存的时候就会自动打包编译js/css/less/sass等。

 

使用RequireJs虽然看起来缺点比较多,但是使用RequireJs也有webpack不具备的优点,那就是RequireJs开发的时候在浏览器里面,每个模块都是单独一个文件,跟本地文件保持一致,而webpack是把主文件和该文件都用到的模块都打包成了一个文件,这样在调试的时候就需要你去搜索找到要调试的位置,而使用requireJs直接根据第几行就可以了。不过,考虑到使用webpack可以搭建一个很方便的本地开发环境,所以这个缺点也不是很明显。

使用webpack

用一句概括就是:写一个配置文件,然后执行下webpack,就可以把生成的文件输出,可压缩带版本号,同时生成一个source-map文件,这个文件包含了每个模块的js和css的实际(带版本号)路径,根据这个路径就可以把html里面的js/css等换成真实的路径。

webpack是一个打包的工具,它有一个重要的概念,就是把js/css/image/coffee都当成地位相等的资源,你可以在js里面require一个css,也可以require一个image。但是这种模式比较适用于React等框架,都是用js控制。

 webpack的其它几个重要概念:

1. loader加载器

上面说到,各种各样的资源都可以在webpack里面加载,而这些资源都需要相应的加载器,webpack才能识别,然后解析成正常的浏览器认识的资源。

换句话说,你可以给webpack加载各种各样的资源:css/less/sass/png/babel等,然后在代码里面进行管理。

例如要加一个sass的loader,需要先安装:

npm install sass-loader node-sass

然后在配置文件添加一个loader:

{  test: /\.sass$/,  loaders: ["style", "css", "sass"]
},

这样当你require(“hello.sass”)的时候,webpack就能处理这种.sass结尾的文件。这样子有两个好处,一个是webpack能够自动编译sass为css,另一个是require进来的style,webpack会把它解析成一个object,这个object的key就是类名,就可以在js使用样式的类名,这种比较适合类似于react的开发模式。

2. 文件束chunk

上面提到的,会把动态加载的文件生成一个个的chunk,在配置文件的output里面加一行:

chunkFilename: "bundle-[id].js"

就会根据id区分不同动态加载的chunk文件,而这些chunk文件名对于我们来说是无关紧要,因为这个是webpack管理的,开发者无需关心叫什么又是怎么加载的。

3. webpack-dev-server

  这是webpack的一个插件,可以在本地开一个静态服务,用来作为本地开发的重要工具。具体步骤就是html里面引用的资源用一个假的域名,如develop.com:

<script src="http://www.cnblogs.com///develop.com/site/app-init.js"></script>

然后再把develop.com绑到本地回路:

     127.0.0.1 fedren.com

这样请求就打到了本地的80端口。同时在本地开一个nginx监听在80端口,nginx收到80端口的请求后,再把请求转发到webpack的服务(默认是8080端口)。这样就能够实现本地开发,下文会具体介绍。

 

下面一步步介绍怎么配置和使用webpack

webpack的基本配置

首先,npm init创建一个node的配置文件package.json,然后安装webpack:

npm install webpacksudo npm install webpack -g //安装一个全局的命令

再创建一个webpack.config.js文件,加入最基本的配置:

module.exports = {  // The standard entry point and output config  //每个页面的js文件  entry: {    home: "js/home",    detail: "js/detail"  },  output: {    path: "assets",          //打包输出目录    publicPath: "/static/build/",   //webpack-dev-server访问的路径    filename: "[name].js",      //输出文件名    chunkFilename: "bundle-[id].js"  //输出chunk文件名  }};

工程的js都放到js目录下,一个叫home.js,另一个叫detail.js,输出到assets目录,publicPath是为webpack-dev-server所使用

然后在当前目录执行webpack,发现webpack报错了:

ERROR in Entry module not found: Error: Cannot resolve module 'js/home' in /Users/yincheng/code/blog-webpack

找不到js/home的模块,只要在配置里面加一句resolve:

  resolve: {    modulesDirectories: ['.']  }

告诉webpack所有模块的启始目录由当前目录开始,再执行下webpack就可以正常输出了:

到目前为此,当前工程的目录结构就是这样的了:

接下来,创建html:home.html,里面引入js文件,"static/build"即为上面定义的publicPath:

<body>  <p>home.html</p>  <script src="http://www.cnblogs.com///develop.com/static/build/home.js"></script></body>

注意我们用了一个develop.com的域名,把这个域名绑到本地回路:

127.0.0.1 develop.com

然后配置nginx,打开nginx.conf,加多一个server:

  server {    listen    80;    server_name payment-admin.com;    charset utf-8;    #工程路径    root  /Users/yincheng/code/demo;    autoindex    on;    autoindex_exact_size  on;    location ~* /.+\.[a-z]+$ {      proxy_set_header x-request-filename $request_filename;      # webpack的服务      proxy_pass http://127.0.0.1:8080;    }   }

启动nginx或者重启下nginx

然后再装一个webpack-dev-server:

npm install webpack-dev-server --save-devsudo npm install webpack-dev-server -g

然后启动webpack-dev-server,执行:

webpack-dev-sever --port=8080 //不加port参数,默认就为8080端口

然后就可以访问:http://develop.com/html/home.html

这个时候,只要一改变home.js的内容,webpack-dev-server就会自动打包新的文件 ,一刷新页面,就是最新的修改了。这样就实现了最基本的本地开发,不管你用的jsp/php,都不需要把js/css往服务器上传。 注意webpack-dev-server是在内存生成的文件,你在本地是找不到static/build目录的,只有执行了webpack打包才会输出文件到assets目录。一个为上面配置里的publicPath,另一个为path。

 

引入样式文件——首先创建css/home.css:

body{  color: #f00;}

然后在js里面引入这个css文件:

require("css/home.css");

一保存之后,会发现webpack-dev-server报错了:

ERROR in ./css/home.cssModule parse failed: /Users/yincheng/code/blog-webpack/css/home.css Unexpected token (1:4)You may need an appropriate loader to handle this file type.

根据提示,我们需要加装一个css loader,让webpack能够处理css文件,更改webpack.config.js,加入一个loader:

module.exports = {  entry: ...,  output: ...,  resolve: ...,  module: {    loaders: [      {        test: /\.css$/,        loader: "style-loader!css-loader"      },    ]  }};

当然要先安装一下:npm install style-loader css-loader --save-dev,然后再重启下webpack-dev-server,就可以加载样式了,我们发现webpack是把样式动态插到了head标签的style里面,但是一般并不希望直接写到head里面,而是独立的一个css文件,这个时候借助一个分离css的插件就可以了:

npm install extract-text-webpack-plugin --save-dev

同时把配置文件的loader改一下:

var ExtractTextPlugin = require("extract-text-webpack-plugin");module.exports = {  module: {    loaders: [      // Extract css files      {        test: /\.css$/,        loader: ExtractTextPlugin.extract("style-loader", "css-loader")      },    ]  },  plugins: [    new ExtractTextPlugin("[name].css")  ]};

就会生成和js相同路径和名字的css文件,在home.html里面引入css文件:

<link rel="stylesheet" href="http://www.cnblogs.com///develop.com/static/build/home.css"></link> 

你也可以加载各种各样的loader,如加载一个sass/less loader,require一个sass/less文件后就可以写sass/less了,webpack会把它编译成和上面一样普通的css文件,读者可以自己试试,还可以再装一个png/jpg的loader,指定一个小于多少个k的图片的参数,webpack就会把小于指定尺寸的图片转成base64的格式。各种loader的安装查一查就有了。

 到这里一个最基本的本地开发环境就已经搭起来了。接下来讨论自动刷新

自动刷新

上面一保存js/css的时候,webpack server就会自动打包,刷新页面的时候就是最新的修改。这个刷新只要使用webpack的hot模式就可以自动实现,即一保存就自动打包刷新。将上面运行webpack-dev-server的命令再加多两个参数,按照官方文档的方式:

webpack-dev-server --port=8383 --hot --inline

如果没有意外,在你的电脑上将会报错:

ERROR in multi homeModule not found: Error: Cannot resolve module 'webpack/hot/dev-server' in /Users/yincheng/code/blog-webpack @ multi home

这个问题困惑了笔者好久,因为在node_modules里面是有这个"webpack/hot/dev-server"的,其实只要认真看下上面的提示,就会发现它并不是说在node_modules里面,而是在当前工程目录里,所以把node_modules里的webpack文件夹拷一份到外面就可以正常运行了。(如果你又配了个context的参数的话,那就根据提示拷到context指定的目录)

使用hot模式,只要一保存js/css就可以自动刷新了,这个功能确实很方便。如果不写参数,也可以把它写在配置文件里面:

var hotModuleReplacementPlugin = require("webpack/lib/HotModuleReplacementPlugin");module.exports = {  plugins: [    new ExtractTextPlugin("[name].css"),    new hotModuleReplacementPlugin()  ],  devServer: {    historyApiFallback: true,    hot: true,    inline: true,    progress: true  }};

然后运行server就不用带上后面那两个参数了。

Common chunk

如上文提到,webpack可以将几个js的公共模块提取成一个chunk,需要借助一个commonChunkPlugin,在上面的plugins再添加一个:

var CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");plugins: [    new CommonsChunkPlugin({      //minChunks: 3,      name: "common-app.chunk",      chunks: ["home", "detail", "list"]    })  ]

这样就可以把home、detail、list三个js和css用到的公共模块提取到common-app.chunk.js和common-app.chunk.css这两个文件了。注意页面要先引入这两个文件,然后再引入具体页面的js,webpack在common chunk里面定义了它的require函数。如上面的home.html:

  <script src="http://www.cnblogs.com///develop.com/static/build/common-app.chunk.js"></script>  <script src="http://www.cnblogs.com///develop.com/static/build/home.js"></script>

可以指定一个minChunk的参数,指定模块至少被require几次才能提取出来,默认是3

还可以定义两个commonChunk,例如在详情页、列表页和首页都有搜索的模块,而其它页面没有搜索的模块,也就是说除了所有页面都有的公共模块如登陆注册外,还有一个搜索的公共模块有三个页面要用到。如果都用一个common chunk,会把搜索的也放进来,但其它很多页面并不需要用到。这个时候需要加多一个common chunk:

  plugins: [    new CommonsChunkPlugin({      name: "search-app.chunk",      chunks: ["search-app-init", "home", "detail", "list"]    }),    new CommonsChunkPlugin({      name: "common-app.chunk",      chunks: ["home", "detail", "search-map", "search-app.chunk", "sell", "about", "blog"]    })  ]

注意要把search-app.chunk也写到下面那个所有页面的chunk里面,否则webpack会定义两个一样的require函数,页面的模块也会跟着混乱,一刷页面就报错。页面引用js的顺序就变成了:

  <script src="http://www.cnblogs.com///develop.com/static/build/common-app.chunk.js"></script>  <script src="http://www.cnblogs.com///develop.com/static/build/search-app.chunk.js"></script>  <script src="http://www.cnblogs.com///develop.com/static/build/home.js"></script>

压缩和版本号

压缩只需要要在plugins里面再添加一个用来压缩的插件:

var webpack = require("webpack");plugins: [  new webpack.optimize.UglifyJsPlugin()]

这样执行webpack输出的js/css就是压缩的

版本号就是在输出带上hash的替换符,如下:

module.exports = {  output: {    path: "assets",    publicPath: "/static/build/",    filename: "[name]-[chunkhash].js",    chunkFilename: "bundle-[chunkhash].js"  },  plugins: [    new ExtractTextPlugin("[name]-[contenthash].css")  ],}

其中js用的是webpack的chunkhash,而css用的是contenthash,contenthash是根据内容生成的hash。如果不用contenthash,那么一改js,css的版本号也会跟着改变,这个就有问题了。webpack还有另外一个自带的叫做"[hash]",这个hash是所有文件都用的同一个哈希,也就是说某个文件改了,所有文件的版本号都会跟着改,所以一般不用这个。

运行webpack,如果报了下面这个错误:

ERROR in chunk detail [entry][name]-[chunkhash].jsCannot use [chunkhash] for chunk in '[name]-[chunkhash].js' (use [hash] instead)

那你就把plugins里面的热替换插件注释掉就好了,上线的config不需要热替换:

  plugins: [    //new hotModuleReplacementPlugin(),  ],

成功执行后,就会在设定的output目录下面输出加上版本号的文件:

.├── detail-d19e4614a1c4f3c1581b.js├── home-11198f8526424e8c58ce10a2799793e3.css└── home-5ec13a52eea2a6faf96a.js

有了版本号之后,下一步是要把html里面的js/css换成带版本号的路径

替换Html里js/css路径

 之前在html里的路径是test.com,现在要把它换成cdn且带版本号的路径,也就是说,目标是要把下面的引入:

<script src="http://www.cnblogs.com///develop.com/static/build/home.js"></script>

替换成下面的引入,并把新生成的html输出到built目录

<script src="http://www.cnblogs.com///cdn.mycdn.com/test/home-5ec13a52eea2a6faf96a.js"></script>

目测没有现成符合格式的插件可以用,可以自已用node写一个,不费事。

首先要知道所有文件的对应的版本号,可以用AssetsPlugin,生成source-map:

var AssetsPlugin = require('assets-webpack-plugin');  output: {    publicPath: "//cdn.mycdn.com/static/build/"  },  plugins: [    new AssetsPlugin({filename: './source-map.json', prettyPrint: true}),  ]

执行webpack之后,就会生成source-map.json,打开这个文件:

{ "detail": {  "js": "//cdn.mycdn.com/static/build/detail-c8a2c82ebe2e48e06564.js" }, "home": {  "js": "//cdn.mycdn.com/static/build/home-380af86bfeb6fcb477a4.js",  "css": "//cdn.mycdn.com/static/build/home-11198f8526424e8c58ce10a2799793e3.css" }}

根据develop.com开头的以及最后面的home.js/home.css,就可以在上面找到对应的路径名。笔者写了个脚本,可以实现这个功能,详见:version-control-replace-html

到这里,整个流程就基本完成了。还有一些优化的步骤

优化

1. 优化模块id

 webpack对于每个模块都是用id标志,而不是用模块的名字,只是为了节省空间。还可以再节省,就是用它自带的occurrence-order插件将最常用的模块靠前,这样可以再节省一点点空间,因为id是从0开始排的,从一位数到n位数。

new webpack.optimize.OccurenceOrderPlugin()

2. 移出版本号

在上面用了common-chunk的插件,抽离公共模块,在这个common-chunk.js里,webpack会定义每个模块加载的src,以便于加载那些需要动态加载的chunk,如下:

script.src = __webpack_require__.p + "" + chunkId + "-" + {"0":"0cb48ff1ab1d1156015d","5":"e9e7f761f306c648ccef","6":"cbbdf8e3ad1aba34ced0"}[chunkId] + ".js";

从上面可以看出它会把版本号也写在里面,这样就导致一个问题,每改一个js文件,它的版本号就会变化,就会导致common chunk里面的内容发生变化,所以它的版本号也得跟着变,也就是说改了一个文件,影响了两个文件。所以需要把它抽出来,有个插件已经做了这样的事情,叫做ChunkManifestPlugin:

var ChunkManifestPlugin = require('chunk-manifest-webpack-plugin');plugins: [    new ChunkManifestPlugin({      filename: "chunk-manifest.json",      manifestVariable: "webpackManifest"    })]

传两个参数,一个是输出文件名,另一个是变量名,用于上面的script.src,执行webpack后,它会把上面script.src的那一坨东西放到chunk-manifest.json,然后在页面写一个内联的script,定义一个全局变量window.webpackManifest,值为manifest.json里面的内容。笔者已在上面的替换版本号的脚本做处理,只需在页面合适的地方写上一行:

<!--%webpack manifest%-->

就会把这行替换成一个script标签。

3. 多个common-chunk的优化

 在上面写了两个common chunk,在生成的两个chunk文件里面,你会发现大量的的重复代码,已经失去了公共模块的作用,这个问题可以用一个MoveToParentMergingPlugin解决,它会把search-app用到的common-app的模块全部移到了common-app,search-app就不会重复common-app的内容了。

html保存自动刷新

 上面提到,只要一保存css/js,webpack-dev-server就会自动保存和刷新,但是html/jsp没办法(如果你用react开发,可以用react-hot-loader),其实可以手动解决这个问题。打开node_modules/webpack-dev-server/client/index.js这个文件,可以发现webpack是用的sockjs实现自动刷新的。浏览器使用sockjs创建socket客户端,连接到webpack的服务,保存更改的时候,服务就向浏览器的socket发送消息,接收到这个消息后客户端就调window.location.reload刷新页面。所以可以模仿这个过程,在本地另开一个服务,监听html的修改,然后向浏览器端发送刷新页面的消息。

具体来说,首先在上面的node_modules/webpack-dev-server/client/index.js这个文件最后面再添加一个socket连接:

/*自定义reload window*/var reload = new SockJS("http://localhost:9999/reload");reload.onopen = function(){  console.log("customer reload start.......");}reload.onclose = function(){  console.log("customer reload close.......");}reload.onmessage = function(_msg){  var msg = JSON.parse(_msg.data);  if(msg.type === "reload"){    console.log("customer reload window now");    window.location.reload();  }}

这个9999端口的server就是下面要在本地监听的一个socket服务。在开这个socket服务之前,需要先在本地开一个监听文件修改的服务,然后再向这个socket服务发送消息。监听的服务比较好写,有现成的node包可以用:chokidar,使用也非常简单。监听到修改之后就可以执行上传服务器的命令,然后(使用进程间的通信)再向socket服务发送一个需要刷新的消息,再传递给浏览器的scoket,如上面的代码,一收到消息就刷新页面。具体代码查看github 

 

 除了优化,在使用中会遇到的一些问题:

解决问题

1. umd的require模式

 有时候会引入外部的库,这些库可能会用umd的require模式,判断是要用requirejs还是commonjs或是写个全局的函数:

  /* CommonJS */ if (typeof require === 'function' && typeof module === 'object' && module && typeof exports === 'object' && exports)    module['exports'] = init(require("ByteBuffer"));  /* AMD */ else if (typeof define === 'function' && define["amd"])    define("lib/chat/ProtoBuf", ["./ByteBuffer"], init);  /* Global */ else(global["dcodeIO"] = global["dcodeIO"] || {})["ProtoBuf"] = init(global["dcodeIO"]["ByteBuffer"]);

这个的问题就在于,只要页面上有require出现,webpack就会去打包,不管你是写if里面还click事件里面。因为像上面说的,webpack会把异步加载的文件打包成一个boundle文件,同时也会把非异步的打包到一起。像上面那样写,它会重复打包,生成好多个bundle。只要加多一个umdREquirePlugin,webpack就能正常打包了。

2. 如何加载外部资源

 webpack是一个打包的工具,它并不是像requireJs那样可以支持直接require一个外部资源。

例如我要require谷歌地图:https://maps.googleapis.com/maps/api/js,打包的时候webpack会给出一个warning,说加载不到这个外部资源,运行代码的时候会报错,提示没有这个模块。

另外一个问题是,我需要if else判断,如果是中国的环境就加载中国域名的谷歌地图:http://ditu.google.cn/maps/api/js 否则就加载上面的,使用webpack是没办法做到的, 使用requireJs就可以很简单地直接require一下就行。

但其实这个问题很好解决只要自己写一个动态加载script的函数就好了,一个兼容性很好的版本:

function loadScript(url, callback){      var script = document.createElement("script")      script.type = "text/javascript";      if (script.readyState){ //IE        script.onreadystatechange = function(){          if (script.readyState == "loaded" || script.readyState == "complete"){            script.onreadystatechange = null;            callback();          }        };      } else { //Others        script.onload = function(){ callback(); };      }      script.src = url;      document.getElementsByTagName("head")[0].appendChild(script);    }

详见:The best way to load external JavaScript

 

webpack虽然是一个利器,但是坑也不少,目前遇到过的不太好解决的问题:

遇到的困难

1. chunkhash

 使用chunkhash有两个问题,一个是css改变之后,js的版本号也会跟着改变,即使js没有修改,但是比较这两个js文件的时候,你会发现这两个版本号不一样的文件内容是完全一模一样的。因为chunkhash不是根据文件内容算的hash值。第二个问题是,相同的代码在不同人的机器上打的包的版本号不一样。如果使用一些根据文件内容打版本号的插件,如webpack-md5-hash,这个插件是用文件内容作一个md5的计算得出一个版本号,这样可以解决上面的两个问题,但是又引发了新的问题,这个md5的时不时就会出现打的版本号不唯一的情况,文件内容不同、版本号相同,而且这个概率还不小。所以最后还是放弃了使用这个插件,然后又尝试了另外一个使用sha算法计算,但是这个改了一个文件会使几个文件的版本号也发生变化。现在还是使用chunkhash

2. 模块id发生变化

上文提到,webpack的模块是用id标志的,每个模块对就一个id,例如util对应2,但是这个id不是固定不变的,在n次修改和打包之后,util的id可能会变成了3,这个就比较坑了,给增量上线造成了阻力,即单独上一个html有风险。因为在common-chunk里面,util的id是上次打包的时候定的,但是你这次打包util的id变了,而你只想上home.html,在home.html里面引的home.js里面使用到的util的id对不上common-chunk里面的,导致不能在home里面正常地加载util这个模块。一个临时的解决办法是,home.js不要使用common-chunk,所有的模块都打包到home.js里面就不会有这个问题。

 

综上对于webpack的介绍基本说完了,后续会继续研究webpack的打包方式和怎么样写一个webpack的插件。如果上面有什么不合理或可以优化的地方还请指出。