webpack的插件实践

# Plugin 开发

# 版本功能

直接添加版本的方法是不是蠢出天际,so 我们随便写个插件玩玩好了

const HtmlWebpackPlugin = require('html-webpack-plugin');
const childProcess = require('child_process')
const branch = childProcess.execSync('git rev-parse --abbrev-ref HEAD').toString().replace(/\s+/, '')
const version = branch.split('/')[1]
class HotLoad {
  apply(compiler) { 
    compiler.hooks.beforeRun.tap('UpdateVersion', (compilation) => {
      compilation.options.output.publicPath = `./${version}/`
    })
  }
}
module.exports = HotLoad;
module.exports = {
  plugins: [
    new HotLoad() 
]}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如上,我们创建一个 webpack plugin,通过监听 webpack hooks 在任务执行之前修改对应的资源路径,通用性上升。

版本号的优势

  1. 可以快速定位 code 版本,针对性的修复;
  2. 每个版本资源保存上在 cdn 上,快速回滚只需要刷新 html,不必重新构建发布;

# CDN资源引入

高级定制化

此外之前的博客我们还引入了 cdn 的概念,我们可以将上述插件升级,构建的时候引入通用的 cdn 资源,减少构建与加载时间。

const scripts = [
  'https://cdn.bootcss.com/react-dom/16.9.0-rc.0/umd/react-dom.production.min.js',
  'https://cdn.bootcss.com/react/16.9.0/umd/react.production.min.js'
]

class HotLoad {
  apply(compiler) { 
    // UpdateVersion 插件
    compiler.hooks.beforeRun.tap('UpdateVersion', (compilation) => {
      compilation.options.output.publicPath = `./${version}/`
    })
    // HotLoadPlugin 插件
    compiler.hooks.compilation.tap('HotLoadPlugin', (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync('HotLoadPlugin', (data, cb) => {
        scripts.forEach(src => [
          data.assetTags.scripts.unshift({
            tagName: 'script',
            voidTag: false,
            attributes: { src }
          })
        ])
        cb(null, data)
      })
    })
  }
}
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

上述我们借助了 HtmlWebpackPlugin 提供的 alterAssetTags hooks,主动添加了 react 相关的第三方 cdn 链接,这样在生产环境中,同域名下面的项目,可以复用资源

# 应用

# 删除项目中无用代码的两种方式

# 方案一:直接loader,指定loader

webpack 的 loader 本质上其实就是一个函数,我们可以在这个函数内部,根据正则匹配出我们想删除的字符串,对其进行替换。

自定义 loaders/ignore-console-log-loader.js 代码很简单,如下:

const reg = /(console.log\()(.*)(\))/g
module.exports = function (sourceCode) {
 return sourceCode.replace(reg, '')
}
1
2
3
4

使用姿势,在webpack.config.js 配置文件中添加一下自定义的 loader :

module: {
  rules: [
   {
    test: /\.js$/,
    loader: ['ignore-console-log-loader'],
   },
  ]
 },
 resolveLoader: {
 	 modules: ['node_modules', path.resolve(__dirname, 'loaders')]// 指定自定义 loader 的位置目录
 },
1
2
3
4
5
6
7
8
9
10
11

这种方式虽简单,但对于一些类似简单的需求(匹配、删除、替换等),足矣。

# 方案二:借助babel,加bable的plugins

比如 webpack 打包原理的几个核心步骤就是利用了 babel

  • 获取主入口内容
  • 分析主入口模块(@babel/parser包、转AST)
  • 对模块内容进行处理 (@babel/traverse包、遍历AST收集依赖)
  • 递归所有模块
  • 生成最终代码

回到正题,我们可以自定义 babel plugin,删除无用代码。

通过 babel 拿到源代码 parsing 之后的 AST,然后匹配出我们的目标节点,对目标节点进行转换、删除等操作,生成新的 AST(去除 console.los(xxx) 之后的 AST)。

先通过 https://astexplorer.net/ 在线转换网站,看一下我们想删除的 AST 节点:

img

通过上图,需求已经很明朗。自定义 plugins/ignore-console-log-plugin.js 结合着图看一下,如下:

// babel 版的工具库,提供了很多 AST 的 Node 节点相关的工具函数
const types = require("@babel/types")
const map = new Map()// 规则
map.set('console', 'log')
// ...

module.exports = function declare() {
 return {
  name: "ignore-console-log",
  visitor: {
   ExpressionStatement(path) {
    const expression = path.node.expression
    if (types.isCallExpression(expression)) {
     const callee = expression.callee
     if (types.isMemberExpression(callee)) {
      const objectName = callee.object.name// 获取到 console
      const propertyName = callee.property.name // 获取到 log
      if (map.get(objectName) === propertyName) {// 规则命中
       path.remove()// 移除
      }
     }
    }
   },
  },
 }
}
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

使用姿势 (webpack 项目中),在webpack.config.js 配置文件中添加一下自定义的 plugin :

{
  test: /\.js$/,
  use: {
   loader: 'babel-loader',
   options: {
    "presets": ['@babel/preset-env'],
    "plugins": ['./plugins/ignore-console-log-plugin.js']
   }
  },
  exclude: '/node_modules/'
}
1
2
3
4
5
6
7
8
9
10
11

使用前后源代码打包对比:

const name = '想太多'
console.log(name)
1
2

使用前(正常打包)

img

使用后(去除了 console.log)

img

# 版本设置相关插件

加载插件;

const webpack = require('webpack')
const GitRevisionPlugin = require('git-revision-webpack-plugin')
const GitRevision = new GitRevisionPlugin()
const buildDate = JSON.stringify(new Date().toLocaleString())

// check Git
function getGitHash() {
  try {
    return GitRevision.version()
  } catch (e) { }
  return 'unknown'
}

configureWebpack: {
    plugins: [
      // ...config.plugins,
      // Ignore all locale files of moment.js
      new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
      new webpack.DefinePlugin({
        APP_VERSION: `"${require('./package.json').version}"`,
        GIT_HASH: JSON.stringify(getGitHash()),
        BUILD_DATE: buildDate
      }),
      new HardSourceWebpackPlugin()
    ],
   }
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

git-revision-webpack-plugin插件

export default function buildFile({ compiler, gitWorkTree, command, replacePattern, asset }: BuildFileOptions) {
  let data: string = ''

  compiler.hooks.compilation.tap('GitRevisionWebpackPlugin', compilation => {
    compilation.hooks.optimizeTree.tapAsync('optimize-tree', (_, __, callback) => {
      runGitCommand(gitWorkTree, command, function(err, res) {
        if (err) {
          return callback(err)
        }
        data = res

        callback()
      })
    })

    compilation.hooks.assetPath.tap('GitRevisionWebpackPlugin', (assetPath: any, chunkData: any) => {
      const path = typeof assetPath === 'function' ? assetPath(chunkData) : assetPath

      if (!data) return path
      return path.replace(replacePattern, data)
    })

    compilation.hooks.processAssets.tap('GitRevisionWebpackPlugin', assets => {
      assets[asset] = {
        source: function() {
          return data
        },
        size: function() {
          return data ? data.length : 0
        },
        buffer: function() {
          return Buffer.from(data)
        },
        map: function() {
          return {}
        },
        sourceAndMap: function() {
          return { source: data, map: {} }
        },
        updateHash: function() {},
      }
    })
  })
}
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

阿里云上传插件 (opens new window)

# 源码

class WebpackAliyunOss {
	constructor(options) {
		const {
			region,
			accessKeyId,
			accessKeySecret,
			bucket
		} = options;

		this.config = Object.assign({
			test: false,				// 测试
			verbose: true,				// 输出log
			dist: '',					// oss目录
			buildRoot: '.',				// 构建目录名
			deleteOrigin: false,		// 是否删除源文件
			deleteEmptyDir: false,		// 是否删除源文件目录, deleteOrigin 为true时有效
			timeout: 30 * 1000,			// 超时时间
			setOssPath: null,			// 手动设置每个文件的上传路径
			setHeaders: null,			// 设置头部
			overwrite: true,			// 覆盖oss同名文件
			bail: false,				// 出错中断上传
			quitWpOnError: false,		// 出错中断打包
			logToLocal: false			// 出错信息写入本地文件
		}, options);

		this.configErrStr = this.checkOptions(options);

		this.client = new OSS({
			region,
			accessKeyId,
			accessKeySecret,
			bucket
		})

		this.filesUploaded = []
		this.filesIgnored = []
	}

	apply(compiler) {
		if (compiler) {
			return this.doWithWebpack(compiler);
		} else {
			return this.doWidthoutWebpack();
		}
	}
	
	doWithWebpack(compiler) {
		compiler.hooks.afterEmit.tapPromise('WebpackAliyunOss', async (compilation) => {
			if (this.configErrStr) {
				compilation.errors.push(this.configErrStr);
				return Promise.resolve();
			}

			const outputPath = path.resolve(slash(compiler.options.output.path));

			const {
				from = outputPath + '/' + '**',
				verbose
			} = this.config;

			const files = await globby(from);

			if (files.length) {
				try {
					return this.upload(files, true, outputPath);
				} catch (err) {
					compilation.errors.push(err);
					return Promise.reject(err);
				}
			} else {
				verbose && console.log('no files to be uploaded');
				return Promise.resolve('no files to be uploaded');
			}
		});
	}

	async doWidthoutWebpack() {
		if (this.configErrStr) return Promise.reject(this.configErrStr);

		const { from, verbose } = this.config;
		const files = await globby(from);

		if (files.length) {
			try {
				return this.upload(files);
			} catch (err) {
				return Promise.reject(err);
			}
		}
		else {
			verbose && console.log('no files to be uploaded');
			return Promise.resolve('no files to be uploaded');
		}
	}

	async upload(files, inWebpack, outputPath = '') {
		const {
			dist,
			setHeaders,
			deleteOrigin,
			deleteEmptyDir,
			setOssPath,
			timeout,
			verbose,
			test,
			overwrite,
			bail,
			quitWpOnError,
			logToLocal
		} = this.config;

		if (test) {
			console.log('');
			console.log('Currently running in test mode. your files won\'t realy be uploaded.'.green.underline);
			console.log('');
		} else {
			console.log('');
			console.log('Your files will be uploaded very soon.'.green.underline);
			console.log('');
		}

		files = files.map(file => ({
			path: file,
			fullPath: path.resolve(file)
		}))

		this.filesUploaded = []
		this.filesIgnored = []
		this.filesErrors = []

		const basePath = this.getBasePath(inWebpack, outputPath)

		for (let file of files) {
			const { fullPath: filePath, path: fPath } = file

			let ossFilePath = slash(
				path.join(
					dist,
					(
						setOssPath && setOssPath(filePath)
						|| basePath && filePath.split(basePath)[1]
						|| ''
					)
				)
			);

			const fileExists = await this.fileExists(ossFilePath)

			if (fileExists && !overwrite) {
				this.filesIgnored.push(filePath)
				continue
			}

			if (test) {
				console.log(fPath.blue, 'is ready to upload to ' + ossFilePath.green);
				continue;
			}

			const headers = setHeaders && setHeaders(filePath) || {}

			try {
				ora.start(`${fPath.underline} is uploading to ${ossFilePath.underline}`)

				let result = await this.client.put(ossFilePath, filePath, {
					timeout,
					headers: !overwrite ? Object.assign(headers, { 'x-oss-forbid-overwrite': true }) : headers
				})

				result.url = this.normalize(result.url);
				this.filesUploaded.push(fPath)

				verbose && ora.succeed(fPath.blue.underline + ' successfully uploaded, oss url => ' + result.url.green);

				if (deleteOrigin) {
					fs.unlinkSync(filePath);
					if (deleteEmptyDir && files.every(f => f.indexOf(path.dirname(filePath)) === -1))
						this.deleteEmptyDir(filePath);
				}
			} catch (err) {
				this.filesErrors.push({
					file: fPath,
					err: { code: err.code, message: err.message, name: err.name }
				});

				const errorMsg = `Failed to upload ${fPath.underline}: ` + `${err.name}-${err.code}: ${err.message}`.red;
				ora.fail(errorMsg);

				if (bail) {
					console.log(' UPLOADING STOPPED '.bgRed.white, '\n');
					break
				}
			}
		}

		verbose && this.filesIgnored.length && console.log('files ignored due to not overwrite'.blue, this.filesIgnored);

		if (this.filesErrors.length) {
			if (!bail)
				console.log(' UPLOADING ENDED WITH ERRORS '.bgRed.white, '\n');

			logToLocal
				&& fs.writeFileSync(path.resolve('upload.error.log'), JSON.stringify(this.filesErrors, null, 2))

			if (quitWpOnError || !inWebpack)
				return Promise.reject(' UPLOADING ENDED WITH ERRORS ')
		}
	}
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207

# 使用

# 作为webpack插件使用
const WebpackAliyunOss = require('webpack-aliyun-oss');
const webpackConfig = {
  // ... 省略其他
  plugins: [new WebpackAliyunOss({
    from: ['./build/**', '!./build/**/*.html'], // build目录下除html之外的所有文件
    dist: '/path/in/alioss', // oss上传目录
    region: 'your region',
    accessKeyId: 'your key',
    accessKeySecret: 'your secret',
    bucket: 'your bucket',

    // 如果希望自定义上传路径,就传这个函数
    // 否则按 output.path (webpack.config.js) 目录下的文件路径上传
    setOssPath(filePath) {
      // filePath为当前文件路径。函数应该返回路径+文件名。
      // 如果返回/new/path/to/file.js,则最终上传路径为 /path/in/alioss/new/path/to/file.js
      return '/new/path/to/file.js';
    },

    // 如果想定义header就传
    setHeaders(filePath) {
      // 定义当前文件header,可选
      return {
        'Cache-Control': 'max-age=31536000'
      }
    }
  })]
}
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
# 独立使用
const WebpackAliyunOss = require('webpack-aliyun-oss');
new WebpackAliyunOss({
    from: ['./build/**', '!./build/**/*.html'],
    dist: '/path/in/alioss',
    buildRoot: 'build', // 构建目录,如果已传setOssPath,可忽略
    region: 'your region',
    accessKeyId: 'your key',
    accessKeySecret: 'your secret',
    bucket: 'your bucket',

    // 如果希望自定义上传路径,就传这个函数
    // 否则按`buildRoot`下的文件结构上传
    setOssPath(filePath) {
      // filePath为当前文件路径。函数应该返回路径+文件名。
      // 如果返回/new/path/to/file.js,则最终上传路径为 /path/in/alioss/new/path/to/file.js
      return '/new/path/to/file.js';
    },

    // 如果想定义header就传
    setHeaders(filePath) {
      return {
        'Cache-Control': 'max-age=31536000'
      }
    }
}).apply(); 
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
上次更新: 2022/04/15, 05:41:27
×