Webpack+React正确使用姿势

前言

在去年的夏天折腾了一波React开发环境,研究了下Webpack和Gulp工具,最终选择混用两者。当时Github上的外国大佬koistya也用类似的方式实现了比较完善的React脚手架。可是两个工具的功能重叠十分尴尬,冗余的感觉一直刺激着强迫症…尤其是当时发现热加载功能好像经常出问题 ( 没看明白文档 ),干脆就换用了每次webpack bundle完写磁盘,让browser-sync自动刷新的蛋疼方式。

今年九月才发现,koistya在react-starter-kit后来的更新中逐渐地去除了Gulp,完全使用webpack,并且整个工作流优雅地使用了ES6、ES7…容我再次跪舔一发…

仿照着koistya的做法,以及其他Github上开源的webpack+react脚手架,写了一个适合自己的webpack种子项目:Github仓库

功能、写法大同小异,折腾的过程中更深入地了解了下这些工具的原理,写这篇文章整理一下。

注:本文适合粗略使用过webpack、接触过ES6的同学

前置工作

1.使用ES6、ES7
这是一个前置工作,旨在从打包任务到项目的编写全面使用ES6、ES7。最省事的方案是安装babel-cli,使用babel-node实时转译运行打包任务代码,之于项目代码的转换交给webpack的babel-loader

2.项目目录

看看就好,后面详述。

3.package.json 依赖
看这里的文件链接 ≖‿≖✧ ~

webpack.config.js

接触webpack的同学一定非常熟悉这个文件,webpack想做到的事就是完全依赖一个配置文件帮前端工程师们处理所有的前端资源,理想很丰满。先探讨一下webpack.config的正确编写姿势。

1.使用npm script
开发时首先会遇到的问题是,如何区分不同的环境,并根据不同的需求改变打包工具的配置。一些混用Gulp和Webpack的方案是在Gulp调用时传参或者修改config.

推荐是采用npm script来简化这个步骤
如下是在package.json中的配置,有部分是TODO _(:з」∠)_

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"scripts": {
"start": "babel-node tools/run start",
"pub": "babel-node tools/run start --no-server --release --no-hmr",
"build": "babel-node tools/run build",
"deploy": "babel-node tools/run deploy",
"clean": "babel-node tools/run clean",
"bundle": "babel-node tools/run start --no-server --no-hmr",
"lint": "eslint src",
"koa": "babel-node server/koa"
},
"babel": {
"presets": [
"react",
"es2015",
"stage-0"
],
"plugins": [
"transform-runtime"
]
}

这种写法确保了我们的任务文件可以用ES6语法,所有的任务都由tools/run.js调用,比如在CLI输入npm run pub,就会运行start任务并且传入--no-server(无需启动服务器),--release(发布),--no-hmr(无需热加载模块)三个参数,生成压缩混淆后的打包代码。

接下来在需要判断运行环境的文件中使用类似下面的写法取到命令行参数

1
2
3
const DEBUG = !process.argv.includes('--release'),
HMR = !process.argv.includes('--no-hmr'),
EXTRACT = process.argv.includes('--no-server');

2.热替换
这是webpack最潮的看家功能,因为使用了babel处理React/ES6/ES7,配置需要增加react-hot-loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// configuration里面entry字段里入口文件列表需要新增的项目
const HOT_ENTRY = ['react-hot-loader/patch', 'webpack-hot-middleware/client'];
// 在webpack.config.js中添加babel-loader的一些配置
const babelConfig = Object.assign({}, pkg.babel, {
babelrc: false,
cacheDirectory: HMR,
});
// 除了运行时自动转译还需要添加下面的插件
if (HMR) babelConfig.plugins.unshift('react-hot-loader/babel');

const config = {
entry: {
// 三元表达式+数组解构
index: [...HMR ? HOT_ENTRY : [], path.resolve(__dirname, '../src/index.js')],
main: [...HMR ? HOT_ENTRY : [], path.resolve(__dirname, '../src/main.js')]
},
module: {
loaders: [{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
// 配置babel-loader (关键!important)
loader: `babel?${JSON.stringify(babelConfig)}`
},
// ... 其他加载器
]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
// ... 其他插件
]
// ... 其他配置
};
// 输出
export default config;

不过上述一波配置后,刚开始还是无法热加载,查了下github上的issue,需要在入口文件 ( 目前的例子是indexmain两个文件末尾添加钩子 )

1
2
3
if (module.hot) {
module.hot.accept();
}

好了,终于不再是”刷新浏览器工程师”

3.使用CSS Module & PostCss & 提取CSS 文件

什么是CSS Module?看这篇 定义文档
它提供了一个css的作用域机制,主要通过给类名增加hash值防止样式污染。
可以看下这篇文章的探讨:What are CSS Modules and why do we need them?

postcss的插件大大方便了css的编写,config对象中可以加下面的字段,添加postcss处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
postcss: function(bundle) {
return [
// 处理@import为内联引入
require('postcss-import')({
addDependencyTo: bundle
}),
// filter滤镜语法转换成svg
require('pleeease-filters')(),
// 浏览器前缀标识
require('autoprefixer')({
browsers: BROWSERS // BROWSERS是已经定义过的浏览器兼容列表
}),
// 处理sass语法
require('precss')()
]
},

有些同学(包括我)不喜欢webpack将所有的资源全部压在js里,希望提取出css,方便管理。

上次写Webpack+Gulp构建开发结构时,就提到使用extract-text-webpack-plugin,不过这里也是有个小窍门的。

可以通过一些配置定制css的打包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import ExtractCssPlugin from 'extract-text-webpack-plugin';

// 函数CSS_CONF根据接受的布尔值参数及DEBUG标识符,返回Loaders Array
const CSS_CONF = (isModule) => [
// 模板字符串
`css?${JSON.stringify({
sourceMap: DEBUG,
// 是否开启css module
modules: isModule,
// css module 类名配置
localIdentName: DEBUG ? '[name]--[local]--[hash:base64:3]' : '[hash:base64:6]',
// 是否打包
minimize: !DEBUG,
// 处理@import语法
importLoaders: 1
})}`
,

`postcss${DEBUG ? '?sourceMap=true' : ''}`
];

// 定义不需要CSS Module的公共样式文件路径
const COMMON_CSS = path.resolve(__dirname, '../src/common');

// 在配置的两个加载器配置里这么写
{
// 匹配需要使用CSS module的文件(组件样式)
test: /\.(css|scss)$/,
exclude: COMMON_CSS,
loader: EXTRACT ? ExtractCssPlugin.extract('style', CSS_CONF(true).join('!')) : '',
loaders: EXTRACT ? [] : ['style'].concat(CSS_CONF(true))
}, {
// 匹配无需使用CSS Module的文件(公共样式)
test: /\.(css|scss)$/,
include: COMMON_CSS,
loader: EXTRACT ? ExtractCssPlugin.extract('style', CSS_CONF(false).join('!')) : '',
loaders: EXTRACT ? [] : ['style'].concat(CSS_CONF(false))
}

注意上面css加载器的写法,在开发调试时,我们需要使用sourceMap等工具,并且由于使用dev-middleware,打包生成的内容直接在内存中,不需要使用ExtractCssPlugin(会报错) 所以采用loaders字段用字符串数组写法定义加载器;但在发布时,需要使用插件提取CSS并写入磁盘,但要剔除sourceMap,所以使用loader字段用!连接字符串的写法定义加载器。

4.import相对路径配置

以前刚开始写node时,觉得require('../../../xxx/aa.ext')的写法十分繁琐,而且容易出错。webpack提供了一些路径配置,方便引入前端资源。

如下是config里的resolve字段

1
2
3
4
5
6
7
8
9
10
11
12
resolve: {
root: path.resolve(__dirname, '../src'),
alias: {
'@components': 'components'
},
modulesDirectories: ['node_modules'],
// 如果引入的是一个目录,webpack默认会找package.json文件,按文件配置引入模块
// directoryDescriptionFiles: {
// "package.json": true
// },
extensions: EXT // EXT是已经定义过的后缀名列表,形如['.js', '.jsx', '.css']
}

比如在任意一个模块中写import AModule from 'lib/amodule'会引入项目的./src/lib/amodule,如果是文件名,则匹配后缀;如果是文件夹,则寻找里面的package.json按其定义引入模块。
如果在任意模块中写import TinyHeader from '@components/TinyHeader'则会替换别名,在项目目录的./src/components/TinyHeader搜寻合适的模块。
我这里采用@components是为了标识这是一个React组件。

5.其他小点

  • 其他关于js的处理网上资料较多,不赘述
  • 多页面项目可以采用读取src目录动态生成entry入口的方式
  • assets-webpack-plugin 能方便生成打包记录

任务文件

因为任务文件有时序问题,所以一律使用ES6 Promise或者ES7 async/await写法 ( 只是想体验体验新的js _(:з」∠)_ )

1.run.js
几乎照抄了koistya的写法,是调用其他任务的入口,汇报运行状态,及时报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const run = (task, options) => {
// 引入任务代码
const fn = require(`./${task}.js`).default;
const startTime = new Date();
console.log(`[${startTime}] Start -${fn.name}-`);

// 统计任务运行时间
return fn(options).then(resolve => {
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
console.log(`[${endTime}] Finished -${fn.name}- after ${duration} ms`);
}, reject => {
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
console.log(`[${endTime}] Failed -${fn.name}- after ${duration} ms`);
});
}

export default run;

// 如果直接调用运行如下代码,默认运行start任务
if(require.main === module && process.argv.length >= 2) {
let task = 'start';
if(process.argv.length > 2) task = process.argv[2];
run(task).catch(err => {
console.log(err.stack);
process.exit(1);
});
}

2.bunlde.js

打包任务,采用了webpack(config).run(callback(err, stats))API,在打包完成的回调函数中需要用console.log(stats.toString(config.stats));,否则看不到打包详细结果;并且由于没有在webpack引入html,需要手动从打包记录中抓取打包后文件的名字,用html任务渲染ejs生成最终的index.html到目标目录(挺绕的)

3.start.js

这是最主要的任务,逻辑比较简单。
await run('clean')清除之前的构建文件。
而后判断是否是要发布,发布则调用bundle任务并结束任务。

如果是调试,需要在第一次bundle完毕后生成html,而后启动Browser-sync,host测试文件夹prebuild,并且在bs实例挂上webpack-dev-middlewarewebpack-hot-middleware两个插件;之后watch文件修改时,不重启资源服务器,只重新生成html即可。

webpack在使用webpack-dev-middleware后,在打包完不会退出,会一直watch文件修改,每次修改(保存src目录下的文件)都会触发新的一次bundle,然后新的bundle文件会带着版本戳在内存中。可以定制一个回调函数去执行结果中取到这些内容。

因为想使用Browser-sync多端调试页面,需要有一个host目录,目录下必须有一个生成的index.html,所以需要从内存中取出webpack-html-plugin生成的index.html.

但是由于webpack翔一般的官网文档,回调函数能取到的对象内容没有完全介绍= =、在猜测N次API失败后,去google了一个解决方案,感谢这位歪果仁的代码

即将webpack打包输出的内容丢给memory-fs的示例,用输出文件路径作为字段提出html的Buffer对象,然后写入实际的测试路径。

想了想还是贴下代码好了 ( 这篇贴的有点多= = )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import fs from 'fs';
import path from 'path';
import browserSync from 'browser-sync';
import webpack from 'webpack';
import webpackMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import run from './run';
import html from './html';
import MemoryFileSystem from 'memory-fs';
import webpackConfig from './webpack.config';

const RUN = !process.argv.includes('--no-server'),
DEBUG = !process.argv.includes('--release');

async function start() {
await run('clean');

if(!DEBUG || !RUN) {
await run('bundle');
return;
}

// dev server
await new Promise(resolve => {
const publicPath = webpackConfig.output.publicPath;
const bundler = webpack(webpackConfig);
const memFs = bundler.outputFileSystem = new MemoryFileSystem();
const wpMiddleware = webpackMiddleware(bundler, {
publicPath: publicPath,
stats: webpackConfig.stats
});

const bs = browserSync.create();
let doneTimes = 0;
bundler.plugin('done', (stats) => {
// const files = Object.keys(stats.compilation.assets);
// html(files);
const outPath = path.resolve(__dirname, '../prebuild/index.html');
const out = memFs.readFileSync(outPath).toString();
fs.writeFileSync(outPath, out, 'utf-8');
if(++doneTimes === 1) {
bs.init({
server: {
baseDir: path.resolve(__dirname, '../prebuild'),
middleware: [
wpMiddleware,
webpackHotMiddleware(bundler),
]
}
}, resolve);
}
});
});
}
export default start;