loaders: [{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel?presets[]=es2015'
}, {
test: /\.jsx$/,
exclude: /node_modules/,
loader: 'babel?presets[]=es2015&presets[]=react'
}, {
test: /\.less$/,
loader: ExtractTextPlugin.extract('style-loader',
'css-loader!less-loader')
}]
实践:使用 Webpack 构建前端资源
李飞 于 2015-11-24
背景
前端资源构建过程中有一些特定的需求需要工具解决。Webpack 是目前比较流行的一款构建工具,但是它与 fis 等工具相比,更灵活也更难掌握。
有必要对前端资源构建过程中遇到的场景和需求进行梳理,针对每个场景和需求提出明确的、可实施的解决方案,得出最佳实践并以文档的形式落地。
场景/需求
-
支持 ES6、JSX、LESS 等格式的源文件编译成浏览器识别的格式;
-
支持静态文件压缩;
-
支持静态文件名 hash 化,支持 hash 后的文件名及路径注入到后端模版文件中;
此特性可以发布所有静态文件到一个统一的目录,由 nginx 直接接入,消除静态文件的版本概念,使得静态资源的上线更加方便、安全,无需回滚。
-
支持发布到任意路径或网址下,发布后的网址自动注入到后端模版文件中;
-
支持静态文件自动合并,能够很好的处理公共库和组件文件的合并;
多页面应用更好的复用公共组件,利用 HTTP 协议缓存机制。
-
先进的依赖处理机制;
-
兼容后端模版引擎,不冲突;
-
对开发友好,方便调试;
-
支持环境变量,区分开发环境和生产环境;
-
方便调试和开发,可见即可得;
-
强大方便的 SourceMap 支持,可以直接对源代码进行调试;
-
-
第三方库的支持,如:Framework7 不能采用
import $$ from 'dom7';
这种方式被引入;
方案
经调研,决定采用 webpack 作为前端构建工具。对于各种需求,解决方案如下:
先进的语言
先进的语言对生产力有极大的提升,譬如 ES6、JSX、LESS 等,它们不仅提供了大量新特性以提供生产力,还可以使项目更加统一、规范。Webpack 对新语言这块可以采用 loader 来解决。
-
采用 webpack 的 babel-loader 解决 ES6、JSX 等语言的编译问题;
-
采用 less-loader 解决 less 语言的编译问题;
混淆和压缩
静态文件压缩采用 webpack 提供的 UglifyJsPlugin 插件实现,具体代码如下:
plugins: [new UglifyJsPlugin({
compress: {
warnings: false
},
except: ['$super', '$', 'exports', 'require']
})]
资源文件名 hash 化
Webpack 中,output.filename
等配置项可以指定一个模版,其中有一项就是 hash,一般有两种:[chunkhash] 和 [hash],可在单词后面增加 :num
表示取对应 hash 的多少位,如:[hash:8],表示取 8 位 hash。
output.filename = 'static/js/[name].[chunkhash:8].min.js';
Warning
|
Webpack 提供了多种 hash 方式,如: |
Warning
|
Webapck 提供的 hash 函数会将大量环境相关的因素也作为 hash 的内容,会导致不同时间不同机器上的 hash 结果都不一样。这儿我们推荐使用『webpack-md5-hash』提供的 hash 方法作为用到 hash 函数。 |
配置发布后的目录和网址
发布后的本地目录和目标网址均在 output
小节里配置,path
表示本地文件的目录,publicPath
表示目标的网址目录,可以是 CDN 的地址,也可以是相/绝对地址等。
output: {
path: '../koloda/',
publicPath: '/koloda/'
}
CDN 多域名
旧的浏览器有些技术上的限制,譬如同一个域名下最多会发起 2 个链接等,这个问题一方面可以通过合并 Javascript 文件的方式来解决,另一个解决的办法是采用多个域名。
在“配置发布后的目录和网址”小节中说到配置发布后的目标网址使用output.publicPath
来指定。另外 Webpack 的output.publicPath
还可以是一个function
,我们可以在function
里面计算一下 hash,返回不同的域名。
let i = 0;
output: {
publicPath: () => ++i % 5
}
将静态资源的目标网址注入到对应的 HTML 模版中
注入功能可以采用第三方插件 HtmlWebpackPlugin 实现,这个插件可以根据指定的模版生成 HTML 文件,并将该文件依赖的 chunks 注入到 HTML 中,譬如:
/**
* 独立的页面
*/
const htmls = [{
chunks: ['libs', 'index'],
template: 'index.html'
}];
htmls.forEach(function (o) {
const template = o.template;
const params = {
chunks: o.chunks,
filename: 'templates/' + template,
template: '!raw!./' + template,
inject: true,
minify: {
removeComments: true
}
};
plugins.push(new HtmlWebpackPlugin(params));
});
当然这里头还有一些坑要踩的:
-
插件所采用的 Javascript 模版与 Jinja2 模版语法相互冲突的问题
HtmlWebpackPlugin 插件采用的模版引擎为 blueimp,其语法和 Python 常用的模版引擎 Jinja2 冲突,导致无法正常生成 Jinja2 模版的 HTML 文件。解决这个问题的办法是采用 HtmlWebpackPlugin 插件 2.0 版新增加的 loader 机制并配合 raw-loader 来解决,即不再采用默认的 blueimp 模版引擎来渲染,而是采用 raw-loader,保证文件的原汁原味,自然也就没有冲突了,如上面例子中的
template: '!raw!./' + template
。 -
插件注入的方式是替换
</head>
和</body>
,如果 Jinja2 使用了模版集成特性,由于目标页面没有</head>
和</body>
标签,会导致无法注入。对于 Jinja2 模版中使用
extends
模版集成特性且需要注入静态文件的情况,通过子模版里面显示的声明</head>
和</body>
来实现,例如:父模版 base.html<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, minimal-ui"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="white"> <title>{% block title %}{% endblock %}</title> {% block common_head %}{% endblock %} {% block head %}{% endblock %} </head> <body> {% block content %}{% endblock %} {% block common_footer %}{% endblock %} {% block footer %}{% endblock %} </body> </html>
子模版{% extends 'common/base.html' %} {% block title %} {{ title }} {% endblock %} {% block head %} </head> <- 亮点在此 {% endblock %} {% block footer %} </body> <- 亮点在此 {% endblock %}
Note多余的标签正常情况下,上述例子最后生成的 HTML 中会出现两个
</body>
和</head>
标签。有两个办法可以解决这个问题:-
开启 HtmlWebpackPlugin 插件的
minify
特性,可以去掉多余的(不匹配的)</head>
和</body>
; -
改写父模版,将
</head>
和</body>
嵌入到{ % block footer %}{ % endblock %}
中,如:{ % block footer %}</body>{ % endblock %}
。
Note优雅的办法HtmlWebpackPlugin 插件支持(目前还不支持)注入占位符的设置,如:
inject: '<!-- here -->'
。 -
不需要注入静态资源的 HTML 文件
上面关于 Jinja2 模版继承的例子中,base.html 是不需要注入任何资源的,如果强制使用 HtmlWebpackPlugin 插件,会导致</head>
和</body>
错乱及静态资源错乱等问题。对于这种的文件,应该使用 Webpack 的 file-loader 将文件拷贝到对应的目录中。
loaders: [{
test: /\.(html|xml)$/,
loader: 'file?name=templates/[1]/[2]®Exp=([^/]+)[/\\\\]templates[/\\\\](.+)$'
}]
图片、字体等资源
和不需要注入资源的 HTML 文件一样,图片、字体等不需要编译、生成的资源直接采用 file-loader 拷贝至对应的输出目录即可。
loaders: [{
test: /\.(ttf|eot|svg|woff)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
loader: 'file?name=static/font/[name].[ext]'
}]
将公共库合并到一个文件中
Webpack 在构建的过程中,会将大量的公共库也打包输出到目标的 Javascript 文件中,这样做有几个坏处:
-
重复下载公共库资源
譬如有 A、B 两个页面,都依赖了 React 公共库,Webpack 默认会将 A 页面(Entry)的资源全部打包到 A.js 中,将 B 页面的资源打包到 B.js 中,A.js、B.js 里面都有 React 公共库的代码,导致公共库的重复下载。
-
对更新不友好
在日常工作中,公共库是基本不会被改动的,因此可以使公共库长期被客户端、浏览器缓存住。但如果它们和业务代码打包到一个 Javascript 文件中的话,就无法使用浏览器缓存这个特性了。无论是对生产环境的用户还是开发调试的工程师来说,都需要加载大量重复代码,而没有任何意义,是有弊无利的。
这个需要可以使用 Webpack 的一个插件来实现:CommonsChunkPlugin。它可以将公共的 chunks 提取出来,放到独立的文件中。步骤如下:
-
定义一个
entry
,起名libs
,entry
中指定公共库的名称。 -
new
一个 CommonsChunkPlugin 对象,name
设置为与entry
对应,为libs
。
const entry = {
libs: [
'react', 'react-dom', 'redux', 'react-redux',
'redux-logger', 'redux-thunk', 'react-addons-perf',
'isomorphic-fetch', 'babel-polyfill', 'lodash'
]
};
const plugins = [
new CommonsChunkPlugin({
name: 'libs',
filename: 'static/js/libs.[hash:8].min.js'
})
];
Note
|
这样就会将大量的公共组件库的代码构建到一个独立的文件libs.[hash:8].min.js 中。这个文件一般是体积最大的一个文件,另外一旦生成几乎不会更改,如果配合 HTTP 协议的缓存机制,调试起来会非常爽,在生产环境下对用户也非常友好。
|
Warning
|
文件名 hash 化 对 CommonsChunkPlugin 插件的影响
原生的 CommonsChunkPlugin 插件生成的代码会含有通过 |
Tip
|
使用 chunk-manifest-webpack-plugin 解决 hash 化带来的问题
解决这个问题的办法有几种,譬如官方文档上推荐使用 chunk-manifest-webpack-plugin 插件。[1] 使用方法
|
Tip
|
chunk-manifest-webpack-plugin 插件并不完美
虽然使用 chunk-manifest-webpack-plugin 插件后,构建后的公共库 libs.js 不再随着其他资源的变化而变化,但是由于没有合适的手段,需要手动将 vlkosinov 提供了一个解决这个问题的思路[2],他利用 CommonsChunkPlugin 插件会将第一个 chunk 标记为 Entry-chunk 的实现,通过向 示例代码
这个解决方案与 chunk-manifest-webpack-plugin 插件的思路是一样的,都是将 meta 和代码分离,但是一个将 meta 抽出来放到单独的 |
样式文件
Webpack 的理念是任何都是资源,包括样式文件。在这个理念下,Webpack 默认把样式文件也构建在目标 Javascript 文件里。这样做对基于 Web 的客户端程序比较友好,但是对 WebApp 就不合适了,部分原因可参考“将公共库合并到一个文件中”这一节,很少会存在同时修改样式文件和 Javascript 代码的情况,有时候修改这两者的甚至不是同一个部门的人。
于是,我们需要将样式文件提前到独立的 CSS 文件中,采用 ExtractTextPlugin 插件实现这个功能。分两个步骤:
-
调整样式文件的 loader,改为 ExtractTextPlugin 插件的方式。
-
在
plugins
中增加一个 ExtractTextPlugin 对象,指定文件名。
const loaders = [{
test: /\.less$/,
loader: ExtractTextPlugin.extract('style-loader',
'css-loader!less-loader')
}, {
test: /\.css$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader')
}];
const plugins = [new ExtractTextPlugin('css/[name]', 'static/css/[name].[hash:8].min.css')];
无法import
的第三方库
有一个第三方库无法采用import
的方式被引入,譬如:Framework7,对于这种情况,需要采用手工在 HTML 中将库引入,同时在extenals
中注册为可import
模块。
<!-- Framework7 -->
<script language="javascript" type="application/javascript"
src="//framework7.taobao.org/dist/js/framework7.js"></script>
<!-- GA -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
</script>
externals: {
ga: 'window.ga',
dom7: 'window.Dom7',
framework7: 'window.Framework7'
}
SourceMap 支持
SourceMap 是开发调试过程中不可缺少的利器,Webpack 自带了 SourceMap 机制,通过devtool
设置项来配置。Wepack 的 SourceMap 种类繁多,各个类型的 Source Map 在功能和性能上都有差异,甚至连 React 的创始人之一 Pete Hunt 都无法说清该使用何种 SourceMap。
Webpack 文档关于 SourceMap 的说明可以参考链接: devtool 的说明。
下面简单列举一下这 7 种 SourceMap 的不同:
-
eval
文档上解释的很明白,每个模块都封装到eval
包裹起来,并在后面添加//# sourceURL
。 -
source-map
这是最原始的 SourceMap 实现方式,其实现是打包代码同时创建一个新的 sourcemap 文件, 并在打包文件的末尾添加//# sourceURL
注释行告诉 JS 引擎文件在哪儿 -
hidden-source-map
文档上也说了,就是 SourceMap 但没注释,没注释怎么找文件呢?貌似只能靠后缀,譬如xxx/bundle.js
文件,某些引擎会尝试去找xxx/bundle.js.map
-
inline-source-map
为每一个文件添加 SourceMap 的 DataUrl,注意这里的文件是打包前的每一个文件而不是最后打包出来的,同时这个 DataUrl 是包含一个文件完整 souremap 信息的 Base64 格式化后的字符串,而不是一个 url。 -
eval-source-map
这个就是把eval
的sourceURL
换成了完整 SourceMap 信息的 DataUrl -
cheap-source-map
不包含列信息,不包含 loader 的 SourceMap,(譬如 babel 的 sourcemap) -
cheap-module-source-map
不包含列信息,同时 loader 的 SourceMap 也被简化为只包含对应行的。最终的 SourceMap 只有一份,它是 Webpack 对 loader 生成的 SourceMap 进行简化,然后再次生成的。
Tip
|
webpack 不仅支持这 7 种,而且它们还是可以任意组合的,就如文档所说,你可以设置 SourceMap 选项为 cheap-module-inline-source-map 。
|
|
构建速度 |
二次构建速度 |
生产环境 |
质量 |
|
+ |
+ |
no |
generated code |
|
+ |
++ |
no |
transformed code (lines only) |
|
+ |
o |
yes |
transformed code (lines only) |
|
o |
++ |
no |
original source (lines only) |
|
o |
- |
yes |
original source (lines only) |
|
– |
+ |
no |
original source |
|
– |
– |
yes |
original source |
Tip
|
最佳实践
相关解释:
|
Warning
|
BUG 一
当前版本(v1.1)下的 Webpack 不能正确的处理好 我们通过 Hack 代码的方式来解决,修改源文件
增加了 |
Warning
|
BUG 二
最新版的 Chrome Canary 浏览器(版本 49.0.2623.0 canary)不能识别默认生成的 SourceMap,是由于 SourceMap 的前缀字符引起的,至于为什么突然发生了这个问题,我没有时间去调研它的具体原因,有好奇心的朋友可以去读一下 Chrome 源码看看这几个版本做了哪些有关 SourceMap 的调整。 解决这个问题的办法也很简单,只需要将 |
Webpack 的 devtool
提供了两个 FilenameTemplate
(devtoolModuleFilenameTemplate
和 devtoolFallbackModuleFilenameTemplate
) 来指定的 SourceMap 的文件名。
output.devtoolModuleFilenameTemplate = (info) => {
if (info.absoluteResourcePath.charAt(0) === '/') {
return 'webpack://' + info.absoluteResourcePath;
}
return 'webpack:///' + info.absoluteResourcePath;
};
output.devtoolFallbackModuleFilenameTemplate = (info) => {
if (info.absoluteResourcePath.charAt(0) === '/') {
return 'webpack://' + info.absoluteResourcePath;
}
return 'webpack:///' + info.absoluteResourcePath;
};
热更新
Webpack 提供了热更新的黑科技,广大粉丝争相试用,愿景挺美好,但是现实却比较残酷,因为我们大部分前端程序都是有状态的,比起 HTTP Request、Task 等 Request/Response 模型下的程序,前端程序很难实现完美的热更新。当然,对于样式文件来说,热更新确实是完美的。
Webpack 的热更新是采用 HotModuleReplacementPlugin 插件实现的,还需要启动一个 DevServer
-
在 HTML 中引入或注入 DevServer 和 hot 的代码。
-
如果 HTML 里面引入了公共库,则只需要在公共库里将 DevServer 和 hot 的代码加入即可。
在公共库里引入热更新的代码// 在公共库里加入 DevServer 和 hot 的代码 entry.libs.push(`webpack-dev-server/client?${devServerURL}`); entry.libs.push('webpack/hot/only-dev-server'); // "only" prevents reload on syntax errors
-
如果个别 HTML 没有引入公共库,那么只能采用另外一种办法来实现
-
先在
entry
中新增一个entry
,起名为dev
,内容是 DevServer 和 hot 的依赖; -
在 HTML 页面的
chunks
里引入dev
这个entry
。
采用entry
引入代码// 在公共库里加入 DevServer 和 hot 的代码 entry.dev = [ `webpack-dev-server/client?${devServerURL}`, 'webpack/hot/only-dev-server' // "only" prevents reload on syntax errors ]; htmls.forEach((html) => { html.chunks.push('dev'); });
-
-
-
在
plugins
里面加入相关的插件对象。plugins.push(new HotModuleReplacementPlugin()); plugins.push(new NoErrorsPlugin());
-
修改注入到 HTML 文件里面的目标网址。
output.publicPath = `${devServerURL}/static/`;
-
配置 DevServer
config.devServer = { historyApiFallback: false, hot: true, inline: true, progress: true, host: devServerHost, port: devServerPort, contentBase: './src/' };
webpack.config.js
中配置 DevServerif (hot) {
const devServerHost = '';
const devServerPort = '5000';
const devServerURL = `http://${devServerHost}:${devServerPort}`;
// 在公共库里加入 devServer 和 hot 的代码
entry.libs.push(`webpack-dev-server/client?${devServerURL}`);
entry.libs.push('webpack/hot/only-dev-server'); // "only" prevents reload on syntax errors
plugins.push(new HotModuleReplacementPlugin());
plugins.push(new NoErrorsPlugin());
output.publicPath = `${devServerURL}/static/`;
config.devServer = {
historyApiFallback: false,
hot: true,
inline: true,
progress: true,
host: devServerHost,
port: devServerPort,
contentBase: './src/'
};
}
./node_modules/.bin/webpack-dev-server -w
Tip
|
Webpack 的 DevServer 还提供了大量的其他特性,譬如 Proxy 等,配合调试工具 Charles,可以解决一些譬如跨域的问题。 |
构建环境
开发环境和生产环境的构建区别还是蛮大的,需要针对各个环境做特定的配置。Webpack 的配置文件 webpack.config.js
实际上就是一个 Javascript 文件,因此很多关于环境的代码可以通过代码来区分。
这里,我们采用环境变量 NODE_ENV
来区分生产环境和开发环境,NODE_ENV=production
为生产环境,否则为开发环境。
工程目录结构
关于工程目录结构一般有两种流派,一种是 Rails、Yii等基于 MVC 的 FullStack 框架,一种是 Django等 MVT框架。前者是先分层再按组件组织,后者是先按 apps 分,再分层。
/models app1 app2 /controllers app1 app2 /templates app1 app2 /static app1 app2
/app1 models.py views.py templates static /app2 models.py views.py templates static
事实上,对于上述工程目录结构来说,指的是目标文件的结构,即构建后的目录结构。 因此,前端构建工具构建后的工程目录结构不可避免地会受到后端框架的影响,我们这儿暂时抛开两种方案的优劣,只讨论前端工程对开发部署的帮助。
采用构建工具之后,生产环境已经不再依赖前端的源文件了,整个项目的部署流程已经发生了改变:源文件 ⇒ 构建资源 ⇒ 打包 ⇒ 发布部署。
从各个流程的简化的角度出发来看,我们认为将整个前端的源代码单独放在一起会更有利于简化工作。
-
可以为前端项目单独拆分出独立的 Git 库。
-
非常方便的计算出前端的代码是否需要构建,如果源文件没有更改,就不需要再次构建和打包。
git log -1 --pretty=%h origin/online -- front-end
-
更方便的打包,如果我们的输出文件都在一个独立的目录,打包起来会比从各个目录下摘录各个文件要更加简单、方便。
工程目录结构与项目所采用的应用架构模式有很大的关系,譬如采用 MVC、MVVM 的工程目录结构与采用 Redux 的肯定是不一样的。所以工程目录结构还是需要根据实际情况来决定,这里我们为了演示源文件与目标文件的对应关系,做一个示例:
/front-end /apps <- 模块 /list <- 组件或页面 list.js list.html /detail detail.js detail.html /shares ... /common base.html
/templates /apps list.html detail.html base.html /shares ... /static /js /apps list.847263.min.js detail.847263.min.js /shares ... /css apps.829384.min.css shares.123872.min.css /images /fonts
对应关系
模块/组件/类别 ⇒ 类别/模块/组件
部署
静态资源
由于引入静态资源文件名 hash 化的特性,消除了静态资源的版本概念, 静态资源可采用 nginx 直接接入的方式来部署,譬如直接接入 MFS、NFS、GFS 等公共存储的静态文件目录。
后端模版
构建后的后端模版目标文件本质上将算是后端代码的一部分,必须要部署在各台后端机器的特定目录下;因此模版文件采用改进过的包更新方式来实现模版文件的部署。
FAQ
-w
参数失效,请问要如何解决?A: 这个问题是 VirtualBox 的 vboxsf 文件系统无法接收到宿主机上文件更改信号导致的,事实上,所有网络文件系统(NFS、MFS、GFS)都存在这个问题;另外有些编辑器在保存是会采用 rename 的方式,也会使 fsevent 处理逻辑产生混乱。
解决这个问题其实很简单,只需要将 -w
参数调整为 --watch-poll
即可,为提高性能,可以尝试 --watch-poll=1000
,即一秒钟扫描一次。
参考文件
我们在招人
我们是谁?我们开发了国内领先的新闻客户端,一款基于数据挖掘的推荐引擎产品,它为用户推荐有价值的、个性化的信息,提供连接人与信息的新型服务,是国内移动互联网领域成长最快的产品服务之一。
我们招聘 Python 工程师、全栈工程师、前端工程师,推荐有奖,自荐或推荐请将简历发送到 atob("bGlmZWkudmlwQG91dGxvb2suY29t")
。
-
掌握关系型数据库(MySQL)的用法,表结构设计,SQL语句使用达到高级水平。
-
掌握常见业务需求所需要的数据结构,熟悉它们的优缺点。
-
掌握常见开源组件的使用场景,并可阐述原因,指出方案的优缺点。
-
熟悉 HTTP 协议,掌握协议中状态码的含义、缓存相关的协议等。
-
熟悉常见的 Web 安全挑战,掌握防御措施。
-
熟悉各种调优方案,包括业务上和性能上,如缓存等。