Webpack 愛與恨
關於標題,為什麼是“愛與恨”?
因為在 webpack 剛出來的時候,我並不是堅定的支持者,有很多地方用起來不方便,api 設計不合理。隨著 webpack 和 react 生態的越發完善,加上 webpack2.0 的發布,它的功能也越來越強大,讓我又重新認識它。
內容提要
webpack 構建方案
- webpack 生態
- 需求是什麼
- 對比其他方案
webpack vs gulp
- webpack
- gulp
- 什麼時候用
webpack 構建方案
webpack 生態
網上有好多介紹 webpack 的文章,本文隻簡單介紹兩個基本概念。
- Loaders
- Plugins
Loaders
webpack 可以使用 loader 來處理文件,允許你打包除 JavaScript 之外的任何靜態資源。
文件
- raw-loader 加載文件原始內容
- file-loader 將文件發送到輸出文件夾,並返回 URL
- url-loader 像 file loader 一樣工作,但如果文件小於限製,可以返回 data URL
編譯
- babel-loader 加載 ES2015+ 代碼,然後使用 Babel 轉譯為 ES5
- traceur-loader 加載 ES2015+ 代碼,然後使用 Traceur 轉譯為 ES5
- ts-loader 像 JavaScript 一樣加載 TypeScript 2.0+
- coffee-loader 像 JavaScript 一樣加載 CoffeeScript
模板
- html-loader 導出 HTML 為字符串,需要引用靜態資源
- markdown-loader 將 Markdown 轉譯為 HTML
- handlebars-loader 將 Handlebars 轉譯為 HTML
樣式
- css-loader 解析 CSS 文件後,使用 import 加載,並且返回 CSS 代碼
- less-loader 加載和轉譯 LESS 文件
- sass-loader 加載和轉譯 SASS/SCSS 文件
- postcss-loader 使用 PostCSS 加載和轉譯 CSS/SSS 文件
Plugins
- CommonsChunkPlugin
- 將多個入口起點之間共享的公共模塊,生成為一些 chunk,並且分離到單獨的 bundle 中,例如,vendor.bundle.js 和 app.bundle.js
- DefinePlugin, EnvironmentPlugin
- 允許在編譯時(compile time)配置的全局常量,用於允許「開發/發布」構建之間的不同行為
- ExtractTextWebpackPlugin
- 從 bundle 中提取 CSS 文本到獨立的文件
- HtmlWebpackPlugin
- 用於簡化 HTML 文件(index.html)的創建,提供訪問 bundle 的服務。
- I18nWebpackPlugin
- 為 bundle 增加國際化支持
- NamedModulesPlugin
- 保留編譯結果的模塊名,便於調試
需求是什麼
技術問題還是要從需求出發,我們團隊的實際需求是什麼。
- 區分兩套環境
- 多頁麵多入口
- mock 接口數據
- iconfont 字體打包
1. 區分兩套環境
- develop
- production
/build
文件夾編譯結果如下:
/build
- /develop
- /pageA
- index.js
- index.css
- index.html
- /pageB
- common.js
- common.css
- /production
- /1.0.0
- /pageA
- index.js
- index.css
- index.html
- /pageB
- common.js
- common.css
- /1.0.1
- /pageA
- index.js
- index.css
- index.html
- /pageB
- common.js
- common.css
其中開發環境的 html 編譯結果為:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>首頁</title>
<link href="/common.css" rel="stylesheet">
<link href="/home/index.css" rel="stylesheet">
</head>
<body>
<div ></div>
<script src="/common.js" type="text/javascript"></script>
<script src="/home/index.js" type="text/javascript"></script>
</body>
</html>
其中生產環境的 html 編譯結果為:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>首頁</title>
<link href="/0.1.0/common.css" rel="stylesheet">
<link href="/0.1.0/home/index.css" rel="stylesheet">
</head>
<body>
<div ></div>
<script src="/0.1.0/common.js" type="text/javascript"></script>
<script src="/0.1.0/home/index.js" type="text/javascript"></script>
</body>
</html>
生產環境的最終頁麵上靜態資源路徑是
<script src="/{version}/path/file.js"></script>
業界還有一種做法是使用 /path/file.{hash}.js
形式的路徑,都是為了配合 http 緩存策略,做到前端資源的緩存和無縫發布。
-
緩存策略:在 http 響應頭中,設置
Cache-Control
、Expires
和Last-Modified
控製靜態資源緩存。用戶隻有首次訪問需要下載全部靜態資源,以後的訪問都直接使用緩存資源。cache-control:max-age=31536000 expires:Fri, 06 Apr 2018 08:32:17 GMT last-modified:Thu, 06 Apr 2017 06:54:04 GMT
對用戶和客戶端來說,每次發布更新代碼,隻需要下載新的資源,而無需清除緩存
-
無縫發布:在團隊合作開發中,往往會遇到新老版本兼容和發布順序的問題。例如原來的
a.js
添加了新功能變成了a'.js
,為了避免前端先發布上線的代碼影響線上業務,保證發布上去的代碼兼容舊版本,需要在代碼中添加如下的兼容語句:if(newVersion) { // new feature } else { // old feature }
而使用增量發布的方式不需要這樣的額外處理,由於每次都是生成新的 url,那麼隻要後端引用的路徑沒有變化,就始終引用舊資源,一旦後端發布完成就自動引用新資源。
多頁麵多入口
雖然 React 天然是開發 SPA 的利器,它的生態中的配套工具也是以解決 SPA 問題為主,例如 React-Redux, React-Saga 等,但是我們的項目中頁麵非常簡單,沒有太多的用戶交互,沒有太多的組件間的消息傳遞,如果強行引入這些概念,反而把整個項目搞得非常複雜,因此選用多頁麵多入口的方案。使用前麵提到的 HtmlWebpackPlugin
插件進行多頁麵的構建。
// 編譯 html,多頁麵多入口
Object.keys(webpackConfig.entry).forEach(name => {
webpackConfig.plugins.push(new HtmlWebpackPlugin({
template: `./src/template.ejs`,
filename: `${name}.html`,
chunks: ['common', name]
}));
});
mock 接口數據
webpack-dev-server 提供了 proxy
代理解決方案,但是沒有解決 mock 接口數據的問題。我們利用 Koa 開發了一個簡版的 mock 服務器,可以加載本地文件中的模擬測試數據。
首先,在 package.json 中添加 mockEnable 字段,當 mockEnable 為 true 時,則開啟 mock 服務。
// package.json
{
"mockEnable": true,
"proxy": {
"/api/**": {
"target": "https://example.com"
}
},
}
其次,在 webpack.config.js 中,將 package.proxy 的 target 指向 mock 服務器
// webpack.config.js
const pkg = require('./package.json');
const MockServer = require('./mock/server.js');
// 將 https://example.com/api/path 的接口,轉發到 https://localhost:8088/api/path
if(pkg.mockEnable) {
let port = 8088;
MockServer.start(port);
Object.keys(pkg.proxy).forEach(filter => {
let proxy = pkg.proxy[filter];
proxy.target = `https://localhost:${port}`; // mock server
});
}
最後,在 mock 服務器中處理請求,mock server 的邏輯很簡單,隻有不到 50 行代碼:
// mock/server.js
const fs = require('fs');
const path = require('path');
const color = require('colorful');
const Koa = require('koa');
const router = require('koa-router')();
let app = new Koa();
/* 訪問日誌,logger */
app.use(async function (ctx, next) {
console.log(color.green('Mock Server'), (new Date()).toLocaleString(), 'url:', ctx.url);
await next();
});
/* 路由,router */
router.all('*', async (ctx) => {
let filepath = path.resolve(__dirname, 'data', `./${ctx.url}.json`);
let methodFilepath = path.resolve(__dirname, 'data', `./${ctx.url}.${ctx.method}.json`);
if(fs.existsSync(filepath)) {
ctx.body = require(filepath);
} else if(fs.existsSync(methodFilepath)) {
ctx.body = require(methodFilepath);
} else {
ctx.status = 404;
ctx.body = 'Mock data not found';
}
});
app.use(router.routes()).use(router.allowedMethods());
/* 啟動 Mock Server */
exports.start = function(port) {
app.listen(port, () => {
console.log('Mock Server started on', color.green(`https://127.0.0.1:${port}/`));
});
return app;
}
最終的效果是,隻要開啟了 mockEnable,當用戶在頁麵上發起請求時,自動返回本地文件中的模擬數據。例如請求的是GET https://localhost/api/path/users
則返回/mock/data/api/path/users.json
中的數據。如果同一個 url 既有 GET 請求又有 POST 請求,則可以通過如下方式避免衝突
/mock/data/api/path/users.get.json
/mock/data/api/path/users.post.json
iconfont 字體打包
我們的項目是基於 Ant Design 做二次開發,由於 ant design 的 iconfont 會依賴 https://at.alicdn.com/ 的字體資源,而公司內網不能訪問外部資源,所以需要將字體打包到自己的應用裏。利用 less-loader 的 modifyVars 屬性,修改 antd 裏的 @icon-url 變量。
先設置 @icon-url
的變量值。
// webpack.config.js
let cssVars = {
'@icon-url': '"/assets/iconfont/iconfont"',
}
然後在 less-loader 的參數中設置 modifyVars。
// webpack.config.js
{
test: /\.less$/,
use: ExtractTextPlugin.extract([
'css-loader',
{ loader:'less-loader', options: { modifyVars:cssVars } }
])
}
對比其他方案
- atool-build
- 預設了適用於 antd 的 webpack 構建腳本
- 配置非常靈活,幾乎支持所有場景的定製
- 對插件的修改略麻煩
- 版本升級兼容性不太好
- roadhog
- 使用非常簡單,不用關心那麼多概念
- 自定義的靈活性受局限
Webpack vs Gulp
先看看 webpack 和 gulp 各自的官方說明:
- Webpack
- webpack is a module bundler for modern JavaScript applications.
- Gulp
- gulp is a toolkit for automating painful or time-consuming tasks in your development workflow, so you can stop messing around and build something.
從上述官方描述可以看出,webpack 的核心概念是 module bundler,而 gulp 的核心概念是 toolkit for tasks。一個側重模塊打包,一個側重自動化任務處理。
webpack - 一切皆是模塊
從官方網站上的圖片可以看出,一切皆是模塊,不管是 js 模塊,還是 css、sass 樣式,還是模板(hds)、圖片(jpg/png)、字體等資源,都以被 webpack 當做互相依賴的模塊(modules with dependencies)處理。經過 loader 的解析,最終處理成頁麵上可用的靜態資源(static assets)。由於模塊間需要有互相依賴關係,因此需要在 js 裏 require 樣式和圖片等資源。
蛋疼的 api,webpack loader 的參數形式簡直反人類,這種字符串拚接的方式不直觀且不方便擴展
require("file-loader?name=js/[hash].script.[ext]!./javascript.js"); require("file-loader?name=html-[hash:6].html!./page.html"); require("file-loader?name=[hash]!./flash.txt"); require("file-loader?name=[sha512:hash:base64:7].[ext]!./image.png"); require("file-loader?name=img-[sha512:hash:base64:7].[ext]!./image.jpg"); require("file-loader?name=picture.png!./myself.png"); require("file-loader?name=[path][name].[ext]?[hash]!./dir/file.png")
以下是一個簡單的 webpack 的例子:
// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
let webpackConfig = {
entry: {
index: './src/index.js'
},
output: {
path: path.resolve(__dirname, './build'),
filename: '[name].js',
publicPath: './'
},
module: {
rules: [{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}, {
test: /\.less$/,
use: ExtractTextPlugin.extract([
'css-loader',
'less-loader'
])
}, {
test: /\.(png|jpg|jpeg|gif)$/i,
use: {
loader:'file-loader',
options: {
name: 'static/images/[name].[ext]'
}
}
}, {
test: /\.(woff2?|ttf|eot|svg)$/,
use: {
loader:'file-loader',
options: {
name: 'static/fonts/[name].[ext]'
}
}
}]
},
plugins: [
new ExtractTextPlugin('[name].css'),
new UglifyJSPlugin(),
new CleanWebpackPlugin(['./build']),
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'index.html',
chunks: ['index'],
title: '首頁',
message: 'webpack 測試頁麵'
})
]
};
module.exports = webpackConfig;
gulp - 一切基於任務
gulp 是作為一個 task runner 存在的,最核心的功能是自動化任務執行,複雜任務組織,基於文件 stream 的構建,加上完善的插件體係,處理各種類型的任務執行流程。用戶可以預先定義好一係列的 task,定義好這些 task 分別做些什麼,然後定義好執行順序,最後由 gulp 來執行這些 task。所以 gulp 可以做到幾乎所有 node 能做到的事情,不僅僅是用來打包 js。
下麵是一個簡單的 gulp 的例子:
// gulpfile.js
const gulp = require('gulp');
const clean = require('del');
const ejs = require('gulp-ejs');
const less = require('gulp-less');
const jsmin = require('gulp-jsmin');
const minifyCSS = require('gulp-csso');
gulp.task('html', function(){
return gulp.src('src/*.html')
.pipe(ejs({
title: '首頁',
message: 'gulp 測試頁麵'
}))
.pipe(gulp.dest('build'))
});
gulp.task('css', function(){
return gulp.src('src/*.less')
.pipe(less())
.pipe(minifyCSS())
.pipe(gulp.dest('build'))
});
gulp.task('js', function () {
gulp.src(['src/*.js'])
.pipe(jsmin())
.pipe(gulp.dest('build'))
});
gulp.task('clean', function () {
clean(['build']);
});
gulp.task('clone', function () {
gulp.src(['static/**'])
.pipe(gulp.dest('build/static/'))
});
gulp.task('default', ['clean', 'clone', 'html', 'css', 'js' ]);
webpack vs gulp
下麵對比 webpack 和 gulp 各自適合的場景
-
webpack
- 基於模塊依賴的打包構建
- 模塊切割
- 公共模塊提取
-
gulp
- 與模塊化無關的構建過程
- js / css 批量壓縮
- 圖片壓縮
- 批量文本替換
- 複雜的任務處理
- 項目發布任務
- 啟動服務器
最後更新:2017-07-26 09:32:57