小程序工程化尝试(二)- webpack

承接上篇

代码较多警告

entry

小程序实质是多页应用,用webpack打包需要指定多入口,以默认项目为例:

1
2
3
4
5
6
7
8
9
10
11
// webpack.config.js
module.exports = {
// ...
entry: {
'app': './app.js',
'pages/index/index': './pages/index/index.js',
'pages/logs/logs': './pages/logs/logs.js',
// ...
}
// ...
}

这意味着每添加一个Page或Component,甚至是scss都需要修改webpack的入口,这部分的处理方法后文再谈。

output

output唯一需要注意的是,需要指定globalObjectwx,小程序里没有window对象

1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js
const { resolve } = require('path')

module.exports = {
// ...
output: {
path: resolve('dist'),
filename: '[name].js',
globalObject: 'wx',
publicPath: '/'
}
// ...
}

JS部分

babel

这个方案放弃小程序开发工具自带的转码方法,使用babel去进行转码

安装所需包

1
npm i @babel/core @babel/preset-env @babel/plugin-transform-runtime babel-loader --save-dev

webpack配置loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
// ...
{
test: /\.js$/,
use: 'babel-loader'
}
// ...
]
}
// ...
}

添加runtime用于支持async/await

1
2
3
4
5
// .babelrc
{
"presets": ["@babel/env"],
"plugins": ["@babel/plugin-transform-runtime"]
}

代码压缩

同样放弃小程序开发工具自带压缩

modeproduction时,webpack会对代码进行压缩。

1
2
3
4
5
6
7
8
// webpack.config.js
const env = process.env.NODE_ENV

module.exports = {
// ...
mode: env
// ...
}

Source Map

若不使用开发者工具的转码和压缩功能,Source Map就需要自己提供了

开发者文档关于Source Map的描述

如果使用外部的编译脚本对源文件进行处理,只需将对应生成的 Source Map 文件放置在源文件的相同目录下
开发者工具会读取、解析 Source Map 文件,并进行将其上传
后续可以在小程序后台的运营中心可以利用上传的 Source Map 文件进行错误分析

1
2
3
4
5
module.exports = {
// ...
devtool: env === 'development' ? 'inline-source-map' : 'source-map'
// ...
}

关于devtool的选择,官方文档有相关解释

这里开发环境选择inline-source-map,正式打包提交则用source-map

Tips: 这里devtool不能使用eval系列,小程序不支持…

SCSS部分

安装所需包

1
npm i sass-loader node-sass postcss-loader file-loader --save-dev

依旧是scss=>css=>postcss处理css=>wxss的线路

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
// webpack.config.js
const fileLoader = name => {
return {
useRelativePath: true,
name,
context: resolve('src')
}
}

const sassHeader = `
@import "@/assets/styles/_variable.scss";
@import "@/assets/styles/_mixins.scss";
`

module.exports = {
// ...
module: {
rules: [
// ...
{
test: /\.scss$/,
include: /src/,
use: [
{
loader: 'file-loader',
options: fileLoader('[path][name].wxss')
},
{
loader: 'postcss-loader'
},
{
loader: 'sass-loader',
options: {
includePaths: [resolve('src')],
data: sassHeader
}
}
]
}
// ...
]
}
// ...
}

postcss和上篇内容一样,插件内容不贴了

1
2
3
4
5
6
7
8
9
10
11
// postcss.config.js
const px2rpx = require('./plugins/postcss-px2rpx')

module.exports = {
plugins: [
px2rpx({
proportion: 2,
minPixelValue: 5
})
]
}

图片

.png为例

主要是小图片转base64,对于上篇提到的wxml的动态引入问题,在这里就可以使用require解决,比如

1
2
// index.wxml
<image src={{ icon[type] }}></image>
1
2
3
4
5
6
7
8
9
10
// index.js
Page({
data:{
type: 'warning',
icon: {
warning: require('@/assets/img/icon_warning.png'),
success: require('@/assets/img/icon_success.png')
}
}
})

图片交给url-loader处理

1
npm i url-loader --save-dev

小于limit转base64,否则交给file-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
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
// ...
{
test: /\.png$/,
include: /src/,
use: [
{
loader: 'url-loader',
options: {
limit: 1000,
fallback: {
loader: 'file-loader',
options: {
name: '[path][name].[ext]'
}
}
}
}
]
}
// ...
]
}
// ...
}

wxml

wxml-loader: 用来处理wxml上的引用

暂时没想到需要处理的场景,但是还是先加上了..

1
npm i wxml-loader --save-dev
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
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
// ...
{
test: /\.wxml$/,
include: /src/,
use: [
{
loader: 'file-loader',
options: fileLoader('[path][name].[ext]')
},
{
loader: 'wxml-loader',
options: {
root: resolve('src'),
enforceRelativePath: true
}
}
]
},
// ...
]
// ...
}

其他不需要处理的文件

1
npm i copy-webpack-plugin --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
// ...
plugins: [
// ...
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
ignore: ['**/*.js', '**/*.scss', '**/*.wxml', '**/*.png']
}
])
// ...
]
// ...
}

其他

公共部分提取

1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js
module.exports = {
// ...
optimization: {
splitChunks: {
chunks: 'all',
name: 'common',
minChunks: 2,
minSize: 0
},
}
// ...
}

删除打包文件

1
npm i clean-webpack-plugin --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
// ...
plugins: [
// ...
new CleanWebpackPlugin({
cleanStaleWebpackAssets: false
})
// ...
]
// ...
}

alias

1
2
3
4
5
6
7
8
9
10
// webpack.config.js
module.exports = {
// ...
resolve: {
alias: {
'@': resolve('src')
}
}
// ...
}

环境变量

1
2
3
4
5
6
// webpack.config.js
const dotenv = require('dotenv')
const env = process.env.NODE_ENV
const envFilePath = resolve(__dirname, `.env.${env}`)
dotenv.config({ path: envFilePath })
const { SERVER_HOST } = process.env

这部分和gulp一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
const { DefinePlugin } = require('webpack')

module.exports = {
// ...
plugins: [
// ...
new DefinePlugin({
SERVER_HOST: JSON.stringify(SERVER_HOST)
})
// ...
]
// ...
}

这里就用webpack自带插件就行了,使用方法有点不一样,不是作为字符串替换

1
const baseURL = SERVER_HOST

再谈entry

上插件,来源

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// ./plugins/MinaWebpackPlugin.js
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin')
const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin')
const path = require('path')
const fs = require('fs')
const replaceExt = require('replace-ext')

const assetsChunkName = '__assets_chunk_name__'

function itemToPlugin(context, item, name) {
if (Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name)
}
return new SingleEntryPlugin(context, item, name)
}

function _inflateEntries(entries = [], dirname, entry) {
const configFile = replaceExt(entry, '.json')
const content = fs.readFileSync(configFile, 'utf8')
const config = JSON.parse(content)

;['pages', 'usingComponents'].forEach(key => {
const items = config[key]
if (typeof items === 'object') {
Object.values(items).forEach(item => inflateEntries(entries, dirname, item))
}
})
}

function inflateEntries(entries, dirname, entry) {
entry = path.resolve(dirname, entry)
if (entry != null && !entries.includes(entry)) {
entries.push(entry)
_inflateEntries(entries, path.dirname(entry), entry)
}
}

function first(entry, extensions) {
for (const ext of extensions) {
const file = replaceExt(entry, ext)
if (fs.existsSync(file)) {
return file
}
}
return null
}

function all(entry, extensions) {
const items = []
for (const ext of extensions) {
const file = replaceExt(entry, ext)
if (fs.existsSync(file)) {
items.push(file)
}
}
return items
}

class MinaWebpackPlugin {
constructor(options = {}) {
this.scriptExtensions = options.scriptExtensions || ['.ts', '.js']
this.assetExtensions = options.assetExtensions || []
this.entries = []
}

applyEntry(compiler, done) {
const { context } = compiler.options

this.entries
.map(item => first(item, this.scriptExtensions))
.map(item => path.relative(context, item))
.forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))

// 把所有的非 js 文件都合到同一个 entry 中,交给 MultiEntryPlugin 去处理
const assets = this.entries
.reduce((items, item) => [...items, ...all(item, this.assetExtensions)], [])
.map(item => './' + path.relative(context, item))
itemToPlugin(context, assets, assetsChunkName).apply(compiler)

if (done) {
done()
}
}

apply(compiler) {
const { context, entry } = compiler.options
inflateEntries(this.entries, context, entry)

compiler.hooks.entryOption.tap('MinaWebpackPlugin', () => {
this.applyEntry(compiler)
return true
})

compiler.hooks.watchRun.tap('MinaWebpackPlugin', (compiler, done) => {
this.applyEntry(compiler, done)
})

compiler.hooks.compilation.tap('MinaWebpackPlugin', compilation => {
// beforeChunkAssets 事件在 compilation.createChunkAssets 方法之前被触发
compilation.hooks.beforeChunkAssets.tap('MinaWebpackPlugin', () => {
const assetsChunkIndex = compilation.chunks.findIndex(
({ name }) => name === assetsChunkName
)
if (assetsChunkIndex > -1) {
// 移除该 chunk, 使之不会生成对应的 asset,也就不会输出文件
// 如果没有这一步,最后会生成一个 __assets_chunk_name__.js 文件
compilation.chunks.splice(assetsChunkIndex, 1)
}
})
})
}
}

module.exports = MinaWebpackPlugin

这个插件分析json文件,把所有js(scriptExtensions)以及需要处理的资源文件(assetExtensions)合并为一个entry

使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// webpack.config.js
const MinaWebpackPlugin = require('./plugins/MinaWebpackPlugin')

module.exports = {
entry: './app.js',

// ...
plugins: [
// ...
MinaWebpackPlugin({
scriptExtensions: ['.js'],
assetExtensions: ['.scss', '.wxml']
})
// ...
]
// ...
}

runtime

package.jsonscripts中添加

1
2
"dev": "cross-env NODE_ENV=development webpack --watch"
"build": "cross-env NODE_ENV=production webpack"

执行npm run dev,会在根目录下生成对应的dist文件夹,由于配置了project.config.jsonminiprogramRoot,在开发者工具下就直接看到对应的结果。

但是查看dist会发现,每个入口对应的js开头都有webpack的runtime,需要将这些重复的runtime分离出来:
11.1

1
2
3
4
5
6
7
8
9
10
11
12
// webpack.config.js
module.exports={
// ...
optimization:{
//...
runtimeChunk: {
name: 'runtime'
}
//...
}
// ...
}

然而此时开发者工具报错了:
11.2

因为现在其他模块无法get到runtime…

这里需要另一个插件解决

1
npm i @tinajs/mina-runtime-webpack-plugin --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
// webpack.config.js
const MinaRuntimePlugin = require('@tinajs/mina-runtime-webpack-plugin')

module.exports = {
// ...
plugins: [
// ...
new MinaRuntimePlugin()
// ...
]
// ...
}

再执行npm run dev会发现每个入口对应的js开头都require了runtime

注意事项

插件顺序

webpack插件执行是按顺序来的,完整版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
plugins: [
new CleanWebpackPlugin({
cleanStaleWebpackAssets: false
}),
new MinaWebpackPlugin({
scriptExtension: ['.js'],
assetExtensions: ['.scss', '.wxml']
}),
new MinaRuntimePlugin(),
new CopyWebpackPlugin([
{
from: '**/*',
to: './',
ignore: ['**/*.js', '**/*.scss', '**/*.wxml', '**/*.png']
}
]),
new DefinePlugin({
SERVER_HOST: JSON.stringify(SERVER_HOST)
})
]

loader顺序

webpack的loader顺序则是从下到上,需要注意不要写反了,参考scss部分

感谢以下

小程序工程化实践(上篇)
mina-webpack
wxapp-webpack-plugin

以及,当时搞完配置的同时,小程序项目好像咕咕咕了。