├── .babelrc
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── README.2.md
├── README.md
├── jest.config.js
├── package.json
├── plugin
├── MinaRuntimePlugin.js
├── MinaWebpackPlugin.js
└── polyfill.js
├── project.config.json
├── screenshots
├── app.png
├── asset_size_large.png
├── create_app.png
├── logs.png
├── miniprogram_npm.png
├── miniprogram_npm_error.png
├── miniprogram_npm_success.png
├── npm_success.png
├── prepare_1.png
├── prepare_2.png
├── production.png
├── project_setting.png
├── runtime.png
├── runtime_error.png
├── sourcemap.png
└── template.jpg
├── src
├── app.json
├── app.scss
├── app.ts
├── components
│ ├── avatar
│ │ ├── avatar.js
│ │ ├── avatar.json
│ │ ├── avatar.scss
│ │ └── avatar.wxml
│ └── user
│ │ ├── user.js
│ │ ├── user.json
│ │ ├── user.scss
│ │ └── user.wxml
├── images
│ └── face.png
├── pages
│ ├── index
│ │ ├── index.js
│ │ ├── index.json
│ │ ├── index.scss
│ │ └── index.wxml
│ └── logs
│ │ ├── logs.json
│ │ ├── logs.ts
│ │ ├── logs.wxml
│ │ └── logs.wxss
├── sitemap.json
├── styles
│ ├── _mixins.scss
│ └── _share.scss
├── utils
│ ├── __tests__
│ │ ├── demo.test.ts
│ │ └── util.test.ts
│ ├── demo.ts
│ └── util.ts
└── wxml
│ └── motto
│ └── motto.wxml
├── test
└── demo.test.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/env", "@babel/typescript"],
3 | "plugins": ["@babel/proposal-class-properties", "lodash"]
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['@gfez/wechat-miniprogram', 'plugin:prettier/recommended'],
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | yarn-error.log
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: false,
3 | trailingComma: 'all',
4 | singleQuote: true,
5 | printWidth: 120,
6 | tabWidth: 2,
7 | arrowParens: 'avoid'
8 | }
9 |
--------------------------------------------------------------------------------
/README.2.md:
--------------------------------------------------------------------------------
1 | # 小程序工程化实践(下篇)-- Typescript, EsLint, 单元测试, CI / CD
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 小程序工程化实践(上篇)-- 手把手教你撸一个小程序 webpack 插件,一个例子带你熟悉 webpack 工作流程
2 |
3 | > 本文基于 webpack 4 和 babel 7,Mac OS,VS Code
4 |
5 | 小程序开发现状:
6 |
7 | [小程序开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools.html)不好用,官方[对 npm 的支持有限](https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html),缺少对 [webpack](https://webpack.docschina.org/), [babel](https://babel.docschina.org/) 等前端常用工具链的支持。
8 |
9 | 多端框架(Mpvue, Taro)崛起,但限制了原生小程序的能力。
10 |
11 | 我司在使用一段时间多端开发框架后,决定回退到原生方案,除了多端框架对原生能力有所限制外,最重要的是,我们只需要一个微信小程序,并不需要跨端。
12 |
13 | 程序虽小,但需要长期维护,多人维护,因此规范的工程化实践就很重要了。本系列文章分上下两篇,[上篇](https://github.com/listenzz/mina/blob/master/README.md)主要讲 webpack, babel, 环境配置,[下篇](https://github.com/listenzz/mina/blob/master/README.2.md)主要讲 Typescript, EsLint, 单元测试, CI / CD。
14 |
15 | **通过本文,你将学会使用如何使用前端工程技术来开发原生小程序:**
16 |
17 | - webpack 基础配置以及高级配置
18 |
19 | - webpack 构建流程,这是编写 webpack 插件的基础
20 |
21 | - 编写 webpack 插件,核心源码不到 50 行,使得小程序开发支持 npm
22 |
23 | - 为你讲述 webpack 插件中关键代码的作用,而不仅仅是提供源码
24 |
25 | - 优化 webpack 配置,剔除不必要的代码,减少包体积
26 |
27 | - 支持 sass 等 css 预处理器
28 |
29 | ## 微信小程序官方对 npm 的支持程度
30 |
31 | 支持 npm 是小程序工程化的前提,[微信官方声称支持 npm](https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html),但实际操作令人窒息。
32 |
33 | 这也是作者为什么要花大力气学习如何编写 webpack 插件,使得小程序可以像 Web 应用那样支持 npm 的缘故。不得不说,这也是一个学习编写 webpack 插件的契机。
34 |
35 | 先让我们来吐槽官方对 npm 的支持。
36 |
37 | 打开微信开发者工具 -> 项目 -> 新建项目,使用测试号创建一个小程序项目
38 |
39 | 
40 |
41 | 通过终端,初始化 npm
42 |
43 | ```
44 | npm init --yes
45 | ```
46 |
47 | 可以看到,我们的项目根目录下,生成了一个 package.json 文件
48 |
49 | 现在让我们通过 npm 引入一些依赖,首先是大名鼎鼎的 moment 和 lodash
50 |
51 | ```
52 | npm i moment lodash
53 | ```
54 |
55 | 点击微信开发者工具中的菜单栏:工具 -> 构建 npm
56 |
57 | 可以看到,在我们项目的根目录下,生成了一个叫 miniprogram_npm 的目录
58 |
59 |
60 |
61 | 修改 app.js,添加如下内容
62 |
63 | ```diff
64 | // app.js
65 | + import moment from 'moment';
66 | App({
67 | onLaunch: function () {
68 | + console.log('-----------------------------------------------x');
69 | + let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
70 | + console.log(sFromNowText);
71 | }
72 | })
73 | ```
74 |
75 | 并保存,可以看到微信开发者工具控制台输出:
76 |
77 | 
78 |
79 | 再来测试下 lodash,修改 app.js,添加如下内容
80 |
81 | ```diff
82 | // app.js
83 | + import { camelCase } from 'lodash';
84 | App({
85 | onLaunch: function () {
86 | + console.log(camelCase('OnLaunch'));
87 | }
88 | })
89 | ```
90 |
91 | 保存,然后出错了
92 |
93 | 
94 |
95 | 然后作者又尝试了 rxjs 这个库,也同样失败了。查阅了一些资料,说是要把 rxjs 的源码 clone 下来编译,并将编译结果复制到 miniprogram_npm 这个文件夹。尝试了下,确实可行。**但这种使用 npm 的方式也实在是太奇葩了吧**,太反人类了,不是我们熟悉的味道。
96 |
97 | 在持续查阅了一些资料和尝试后,发现使用 [webpack](https://webpack.docschina.org/) 来和 npm 搭配才是王道。
98 |
99 | ## 创建 webpack 化的小程序项目
100 |
101 | 先把 app.js 中新增的代码移除
102 |
103 | ```diff
104 | // app.js
105 | - import moment from 'moment';
106 | - import { camelCase } from 'lodash';
107 | App({
108 | onLaunch: function () {
109 | - console.log('-----------------------------------------------x');
110 | - let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
111 | - console.log(sFromNowText);
112 | - console.log(camelCase('OnLaunch'));
113 | }
114 | })
115 | ```
116 |
117 | 删掉 miniprogram_npm 这个文件夹,这真是个异类。
118 |
119 | 新建文件夹 src,把 pages, utils, app.js, app.json, app.wxss, sitemap.json 这几个文件(夹)移动进去
120 |
121 |
122 |
123 | 安装 webpack 和 webpack-cli
124 |
125 | ```
126 | npm i --save-dev webpack webpack-cli copy-webpack-plugin clean-webpack-plugin
127 | ```
128 |
129 | 在根目录下,新建 webpack.config.js 文件,添加如下内容
130 |
131 | ```js
132 | const { resolve } = require('path')
133 | const CopyWebpackPlugin = require('copy-webpack-plugin')
134 | const { CleanWebpackPlugin } = require('clean-webpack-plugin')
135 |
136 | module.exports = {
137 | context: resolve('src'),
138 | entry: './app.js',
139 | output: {
140 | path: resolve('dist'),
141 | filename: '[name].js',
142 | },
143 | plugins: [
144 | new CleanWebpackPlugin({
145 | cleanStaleWebpackAssets: false,
146 | }),
147 | new CopyWebpackPlugin([
148 | {
149 | from: '**/*',
150 | to: './',
151 | },
152 | ]),
153 | ],
154 | mode: 'none',
155 | }
156 | ```
157 |
158 | 修改 project.config.json 文件,指明小程序的入口
159 |
160 | ```diff
161 | // project.config.json
162 | {
163 | "description": "项目配置文件",
164 | + "miniprogramRoot": "dist/",
165 | }
166 | ```
167 |
168 | 在终端输入 npx webpack。
169 |
170 | 可以看到,在小程序开发者工具的模拟器中,我们的小程序刷新了,而且控制台也没有错误提示。
171 |
172 | 在我们项目的根目录中,生成了一个叫 dist 的文件夹,里面的内容和 src 中一模一样,除了多了个 main.js 文件。
173 |
174 | 对 webpack 有所了解的同学都知道,这是 webpack 化项目的经典结构
175 |
176 |
177 |
178 | 如果你对 webpack 从不了解,那么此时你应该去阅读以下文档,直到你弄明白为什么会多了个 main.js 文件。
179 |
180 | [起步](https://webpack.docschina.org/guides/getting-started/)
181 |
182 | [入口起点(entry points)](https://webpack.docschina.org/concepts/entry-points/)
183 |
184 | [入口和上下文(entry and context)](https://webpack.docschina.org/configuration/entry-context/)
185 |
186 | [输出(output)](https://webpack.docschina.org/concepts/output/)
187 |
188 | [loader](https://webpack.docschina.org/concepts/loaders/)
189 |
190 | [插件(plugin)](https://webpack.docschina.org/concepts/plugins/)
191 |
192 | 在上面的例子中,我们只是简单地将 src 中的文件原封不动地复制到 dist 中,并且让微信开发者工具感知到,dist 中才是我们要发布的代码。
193 |
194 | **这是重要的一步,因为我们搭建了一个 webpack 化的小程序项目。**
195 |
196 | 我们使用 npm,主要是为了解决 js 代码的依赖问题,那么 js 交给 webpack 来处理,其它文件诸如 .json, .wxml, .wxss 直接复制就好了,这么想,事情就会简单很多。
197 |
198 | 如果你对 webpack 已有基本了解,那么此时,你应该理解小程序是个多页面应用程序,它有多个入口。
199 |
200 | 下面,让我们修改 webpack.config.js 来配置入口
201 |
202 | ```diff
203 | - entry: './app.js'
204 | + entry: {
205 | + 'app' : './app.js',
206 | + 'pages/index/index': './pages/index/index.js',
207 | + 'pages/logs/logs' : './pages/logs/logs.js'
208 | + },
209 | ```
210 |
211 | webpack 需要借助 babel 来处理 js,因此 babel 登场。
212 |
213 | ```
214 | npm i @babel/core @babel/preset-env babel-loader --save-dev
215 | ```
216 |
217 | 在根目录创建 .babelrc 文件,添加如下内容
218 |
219 | ```js
220 | // .babelrc
221 | {
222 | "presets": ["@babel/env"]
223 | }
224 | ```
225 |
226 | 修改 webpack.config.js,使用 babel-loader 来处理 js 文件
227 |
228 | ```diff
229 | module.exports = {
230 | + module: {
231 | + rules: [
232 | + {
233 | + test: /\.js$/,
234 | + use: 'babel-loader'
235 | + }
236 | + ]
237 | + },
238 | }
239 | ```
240 |
241 | 从 src 复制文件到 dist 时,排除 js 文件
242 |
243 | ```diff
244 | new CopyWebpackPlugin([
245 | {
246 | from: '**/*',
247 | to: './',
248 | + ignore: ['**/*.js']
249 | }
250 | ])
251 | ```
252 |
253 | 此时,webpack.config.js 文件看起来是这样的:
254 |
255 | ```js
256 | const { resolve } = require('path')
257 | const CopyWebpackPlugin = require('copy-webpack-plugin')
258 | const { CleanWebpackPlugin } = require('clean-webpack-plugin')
259 |
260 | module.exports = {
261 | context: resolve('src'),
262 | entry: {
263 | app: './app.js',
264 | 'pages/index/index': './pages/index/index.js',
265 | 'pages/logs/logs': './pages/logs/logs.js',
266 | },
267 | output: {
268 | path: resolve('dist'),
269 | filename: '[name].js',
270 | },
271 | module: {
272 | rules: [
273 | {
274 | test: /\.js$/,
275 | use: 'babel-loader',
276 | },
277 | ],
278 | },
279 | plugins: [
280 | new CleanWebpackPlugin({
281 | cleanStaleWebpackAssets: false,
282 | }),
283 | new CopyWebpackPlugin([
284 | {
285 | from: '**/*',
286 | to: './',
287 | ignore: ['**/*.js'],
288 | },
289 | ]),
290 | ],
291 | mode: 'none',
292 | }
293 | ```
294 |
295 | 执行 `npx webpack`
296 |
297 | 可以看到,在 dist 文件夹中,main.js 不见了,同时消失的还有 utils 整个文件夹,因为 utils/util.js 已经被合并到依赖它的 pages/logs/logs.js 文件中去了。
298 |
299 | > 为什么 main.js 会不见了呢?
300 |
301 | 可以看到,在小程序开发者工具的模拟器中,我们的小程序刷新了,而且控制台也没有错误提示。
302 |
303 | 把下面代码添加回 app.js 文件,看看效果如何?
304 |
305 | ```diff
306 | // app.js
307 | + import moment from 'moment';
308 | + import { camelCase } from 'lodash';
309 | App({
310 | onLaunch: function () {
311 | + console.log('-----------------------------------------------x');
312 | + let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
313 | + console.log(sFromNowText);
314 | + console.log(camelCase('OnLaunch'));
315 | }
316 | })
317 | ```
318 |
319 | 
320 |
321 | 可以看到,不管是 moment 还是 lodash, 都能正常工作。
322 |
323 | **这是重要的里程碑的一步,因为我们终于能够正常地使用 npm 了。**
324 |
325 | 而此时,我们还没有开始写 webpack 插件。
326 |
327 | 如果你有留意,在执行 `npx webpack` 命令时,终端会输出以下信息
328 |
329 | 
330 |
331 | 生成的 app.js 文件居然有 1M 那么大,要知道,小程序有 2M 的大小限制,这个不用担心,稍后我们通过 webpack 配置来优化它。
332 |
333 | 而现在,我们开始写 webpack 插件。
334 |
335 | ### 第一个 webpack 插件
336 |
337 | 前面,我们通过以下方式来配置小程序的入口,
338 |
339 | ```js
340 | entry: {
341 | 'app': './app.js',
342 | 'pages/index/index': './pages/index/index.js',
343 | 'pages/logs/logs': './pages/logs/logs.js',
344 | },
345 | ```
346 |
347 | 这实在是太丑陋啦,这意味着每写一个 page 或 component,就得配置一次,我们写个 webpack 插件来处理这件事情。
348 |
349 | 首先安装一个可以替换文件扩展名的依赖
350 |
351 | ```
352 | npm i --save-dev replace-ext
353 | ```
354 |
355 | 在项目根目录中创建一个叫 plugin 的文件夹,在里面创建一个叫 MinaWebpackPlugin.js 的文件,内容如下:
356 |
357 | ```js
358 | // plugin/MinaWebpackPlugin.js
359 | const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin')
360 | const MultiEntryPlugin = require('webpack/lib/MultiEntryPlugin')
361 | const path = require('path')
362 | const fs = require('fs')
363 | const replaceExt = require('replace-ext')
364 |
365 | function itemToPlugin(context, item, name) {
366 | if (Array.isArray(item)) {
367 | return new MultiEntryPlugin(context, item, name)
368 | }
369 | return new SingleEntryPlugin(context, item, name)
370 | }
371 |
372 | function _inflateEntries(entries = [], dirname, entry) {
373 | const configFile = replaceExt(entry, '.json')
374 | const content = fs.readFileSync(configFile, 'utf8')
375 | const config = JSON.parse(content)
376 |
377 | ;['pages', 'usingComponents'].forEach(key => {
378 | const items = config[key]
379 | if (typeof items === 'object') {
380 | Object.values(items).forEach(item => inflateEntries(entries, dirname, item))
381 | }
382 | })
383 | }
384 |
385 | function inflateEntries(entries, dirname, entry) {
386 | entry = path.resolve(dirname, entry)
387 | if (entry != null && !entries.includes(entry)) {
388 | entries.push(entry)
389 | _inflateEntries(entries, path.dirname(entry), entry)
390 | }
391 | }
392 |
393 | class MinaWebpackPlugin {
394 | constructor() {
395 | this.entries = []
396 | }
397 |
398 | // apply 是每一个插件的入口
399 | apply(compiler) {
400 | const { context, entry } = compiler.options
401 | // 找到所有的入口文件,存放在 entries 里面
402 | inflateEntries(this.entries, context, entry)
403 |
404 | // 这里订阅了 compiler 的 entryOption 事件,当事件发生时,就会执行回调里的代码
405 | compiler.hooks.entryOption.tap('MinaWebpackPlugin', () => {
406 | this.entries
407 | // 将文件的扩展名替换成 js
408 | .map(item => replaceExt(item, '.js'))
409 | // 把绝对路径转换成相对于 context 的路径
410 | .map(item => path.relative(context, item))
411 | // 应用每一个入口文件,就像手动配置的那样
412 | // 'app' : './app.js',
413 | // 'pages/index/index': './pages/index/index.js',
414 | // 'pages/logs/logs' : './pages/logs/logs.js',
415 | .forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
416 | // 返回 true 告诉 webpack 内置插件就不要处理入口文件了,因为这里已经处理了
417 | return true
418 | })
419 | }
420 | }
421 |
422 | module.exports = MinaWebpackPlugin
423 | ```
424 |
425 | 该插件所做的事情,和我们手动配置 entry 所做的一模一样,通过代码分析 .json 文件,找到所有可能的入口文件,添加到 webpack。
426 |
427 | 修改 webpack.config.js,应用该插件
428 |
429 | ```diff
430 | + const MinaWebpackPlugin = require('./plugin/MinaWebpackPlugin');
431 |
432 | module.exports = {
433 | context: resolve('src'),
434 | - entry: {
435 | - 'app' : './app.js',
436 | - 'pages/index/index': './pages/index/index.js',
437 | - 'pages/logs/logs' : './pages/logs/logs.js'
438 | - },
439 | + entry: './app.js',
440 |
441 | plugins: [
442 | + new MinaWebpackPlugin()
443 | ],
444 | mode: 'none'
445 | };
446 | ```
447 |
448 | 执行 `npx webpack`,顺利通过!
449 |
450 | 上面的插件代码是否读得不太懂?因为我们还没有了解 webpack 的工作流程。
451 |
452 | ## webpack 构建流程
453 |
454 | 编程就是处理输入和输出的技术,webpack 好比一台机器,entry 就是原材料,经过若干道工序(plugin, loader),产生若干中间产物 (dependency, module, chunk, assets),最终将产品放到 dist 文件夹中。
455 |
456 | 在讲解 webpack 工作流程之前,请先阅读[官方编写一个插件指南](https://webpack.docschina.org/contribute/writing-a-plugin/),对一个插件的构成,事件钩子有哪些类型,如何触及(订阅),如何调用(发布),有一个感性的认识。
457 |
458 | 我们在讲解 webpack 流程时,对理解我们将要编写的小程序 webpack 插件有帮助的地方会详情讲解,其它地方会简略,如果希望对 webpack 流程有比较深刻的理解,还需要阅读其它资料以及源码。
459 |
460 | ### 初始化阶段
461 |
462 | 当我们执行 `npx webpack` 这样的命令时,webpack 会解析 webpack.config.js 文件,以及命令行参数,将其中的配置和参数合成一个 options 对象,然后调用 [webpack 函数](https://github.com/webpack/webpack/blob/master/lib/webpack.js#L25)
463 |
464 | ```js
465 | // webpack.js
466 | const webpack = (options, callback) => {
467 | let compiler
468 |
469 | // 补全默认配置
470 | options = new WebpackOptionsDefaulter().process(options)
471 |
472 | // 创建 compiler 对象
473 | compiler = new Compiler(options.context)
474 | compiler.options = options
475 |
476 | // 应用用户通过 webpack.config.js 配置或命令行参数传递的插件
477 | if (options.plugins && Array.isArray(options.plugins)) {
478 | for (const plugin of options.plugins) {
479 | plugin.apply(compiler)
480 | }
481 | }
482 |
483 | // 根据配置,应用 webpack 内置插件
484 | compiler.options = new WebpackOptionsApply().process(options, compiler)
485 |
486 | // compiler 启动
487 | compiler.run(callback)
488 | return compiler
489 | }
490 | ```
491 |
492 | 在这个函数中,创建了 compiler 对象,并将完整的配置参数 options 保存到 compiler 对象中,最后调用了 compiler 的 run 方法。
493 |
494 | compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。可以使用 compiler 来访问 webpack 的主环境。
495 |
496 | 从以上源码可以看到,用户配置的 plugin 先于内置的 plugin 被应用。
497 |
498 | [WebpackOptionsApply.process](https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js#L288) 注册了相当多的内置插件,其中有一个:
499 |
500 | ```js
501 | // WebpackOptionsApply.js
502 | class WebpackOptionsApply extends OptionsApply {
503 | process(options, compiler) {
504 | new EntryOptionPlugin().apply(compiler)
505 | compiler.hooks.entryOption.call(options.context, options.entry)
506 | }
507 | }
508 | ```
509 |
510 | WebpackOptionsApply 应用了 EntryOptionPlugin 插件并立即触发了 compiler 的 entryOption 事件钩子,
511 |
512 | 而 [EntryOptionPlugin](https://github.com/webpack/webpack/blob/master/lib/EntryOptionPlugin.js) 内部则注册了对 entryOption 事件钩子的监听。
513 |
514 | [entryOption](https://github.com/webpack/webpack/blob/master/lib/Compiler.js#L98) 是个 SyncBailHook, 意味着只要有一个插件返回了 true, 注册在这个钩子上的后续插件代码,将不会被调用。我们在编写小程序插件时,用到了这个特性。
515 |
516 | ```js
517 | // EntryOptionPlugin.js
518 | const itemToPlugin = (context, item, name) => {
519 | if (Array.isArray(item)) {
520 | return new MultiEntryPlugin(context, item, name)
521 | }
522 | return new SingleEntryPlugin(context, item, name)
523 | }
524 |
525 | module.exports = class EntryOptionPlugin {
526 | apply(compiler) {
527 | compiler.hooks.entryOption.tap('EntryOptionPlugin', (context, entry) => {
528 | if (typeof entry === 'string' || Array.isArray(entry)) {
529 | // 如果没有指定入口的名字,那么默认为 main
530 | itemToPlugin(context, entry, 'main').apply(compiler)
531 | } else if (typeof entry === 'object') {
532 | for (const name of Object.keys(entry)) {
533 | itemToPlugin(context, entry[name], name).apply(compiler)
534 | }
535 | } else if (typeof entry === 'function') {
536 | new DynamicEntryPlugin(context, entry).apply(compiler)
537 | }
538 | // 注意这里返回了 true,
539 | return true
540 | })
541 | }
542 | }
543 | ```
544 |
545 | EntryOptionPlugin 中的代码非常简单,它主要是根据 entry 的类型,把工作委托给 `SingleEntryPlugin`, `MultiEntryPlugin` 以及 `DynamicEntryPlugin`。
546 |
547 | 这三个插件的代码也并不复杂,逻辑大致相同,最终目的都是调用 `compilation.addEntry`,让我们来看看 [SingleEntryPlugin](https://github.com/webpack/webpack/blob/master/lib/SingleEntryPlugin.js) 的源码
548 |
549 | ```js
550 | // SingleEntryPlugin.js
551 | class SingleEntryPlugin {
552 | constructor(context, entry, name) {
553 | this.context = context
554 | this.entry = entry
555 | this.name = name
556 | }
557 |
558 | apply(compiler) {
559 | // compiler 在 run 方法中调用了 compile 方法,在该方法中创建了 compilation 对象
560 | compiler.hooks.compilation.tap('SingleEntryPlugin', (compilation, { normalModuleFactory }) => {
561 | // 设置 dependency 和 module 工厂之间的映射关系
562 | compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory)
563 | })
564 |
565 | // compiler 创建 compilation 对象后,触发 make 事件
566 | compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => {
567 | const { entry, name, context } = this
568 |
569 | // 根据入口文件和名称创建 Dependency 对象
570 | const dep = SingleEntryPlugin.createDependency(entry, name)
571 |
572 | // 随着这个方法被调用,将会开启编译流程
573 | compilation.addEntry(context, dep, name, callback)
574 | })
575 | }
576 |
577 | static createDependency(entry, name) {
578 | const dep = new SingleEntryDependency(entry)
579 | dep.loc = { name }
580 | return dep
581 | }
582 | }
583 | ```
584 |
585 | 那么 make 事件又是如何被触发的呢?当 WebpackOptionsApply.process 执行完后,将会调用 compiler 的 run 方法,而 run 方法又调用了 compile 方法,在里面触发了 make 事件钩子,如下面代码所示:
586 |
587 | ```js
588 | // webpack.js
589 | const webpack = (options, callback) => {
590 | // 根据配置,应用 webpack 内置插件,其中包括 EntryOptionPlugin,并触发了 compiler 的 entryOption 事件
591 | // EntryOptionPlugin 监听了这一事件,并应用了 SingleEntryPlugin
592 | // SingleEntryPlugin 监听了 compiler 的 make 事件,调用 compilation 对象的 addEntry 方法开始编译流程
593 | compiler.options = new WebpackOptionsApply().process(options, compiler)
594 |
595 | // 这个方法调用了 compile 方法,而 compile 触发了 make 这个事件,控制权转移到 compilation
596 | compiler.run(callback)
597 | }
598 | ```
599 |
600 | ```js
601 | // Compiler.js
602 | class Compiler extends Tapable {
603 | run(callback) {
604 | const onCompiled = (err, compilation) => {
605 | // ...
606 | }
607 | // 调用 compile 方法
608 | this.compile(onCompiled)
609 | }
610 |
611 | compile(callback) {
612 | const params = this.newCompilationParams()
613 | this.hooks.compile.call(params)
614 | // 创建 compilation 对象
615 | const compilation = this.newCompilation(params)
616 | // 触发 make 事件钩子,控制权转移到 compilation,开始编译流程
617 | this.hooks.make.callAsync(compilation, err => {
618 | // ...
619 | })
620 | }
621 |
622 | newCompilation(params) {
623 | const compilation = this.createCompilation()
624 | // compilation 对象创建后,触发 compilation 事件钩子
625 | // 如果想要监听 compilation 中的事件,这是个好时机
626 | this.hooks.compilation.call(compilation, params)
627 | return compilation
628 | }
629 | }
630 | ```
631 |
632 | webpack 函数创建了 compiler 对象,而 compiler 对象创建了 compilation 对象。compiler 对象代表了完整的 webpack 环境配置,而 compilatoin 对象则负责整个打包过程,它存储着打包过程的中间产物。compiler 对象触发 make 事件后,控制权就会转移到 compilation,compilation 通过调用 addEntry 方法,开始了编译与构建主流程。
633 |
634 | 现在,我们有足够的知识理解之前编写的 webpack 插件了
635 |
636 | ```js
637 | // MinaWebpackPlugin.js
638 | class MinaWebpackPlugin {
639 | constructor() {
640 | this.entries = []
641 | }
642 |
643 | apply(compiler) {
644 | const { context, entry } = compiler.options
645 | inflateEntries(this.entries, context, entry)
646 |
647 | // 和 EntryOptionPlugin 一样,监听 entryOption 事件
648 | // 这个事件在 WebpackOptionsApply 中触发
649 | compiler.hooks.entryOption.tap(pluginName, () => {
650 | this.entries
651 | .map(item => replaceExt(item, '.js'))
652 | .map(item => path.relative(context, item))
653 | // 和 EntryOptionPlugin 一样,为每一个 entry 应用 SingleEntryPlugin
654 | .forEach(item => itemToPlugin(context, './' + item, replaceExt(item, '')).apply(compiler))
655 |
656 | // 和 EntryOptionPlugin 一样,返回 true。由于 entryOption 是个 SyncBailHook,
657 | // 而自定义的插件先于内置的插件执行,所以 EntryOptionPlugin 这个回调中的代码不会再执行。
658 | return true
659 | })
660 | }
661 | }
662 | ```
663 |
664 | > 为了动态注册入口,除了可以监听 entryOption 这个钩子外,我们还可以监听 make 这个钩子来达到同样的目的。
665 |
666 | ### module 构建阶段
667 |
668 | `addEntry` 中调用了私有方法 `_addModuleChain` ,这个方法主要做了两件事情。一是根据模块的类型获取对应的模块工厂并创建模块,二是构建模块。
669 |
670 | 这个阶段,主要是 [loader](https://webpack.docschina.org/loaders/) 的舞台。
671 |
672 | ```js
673 | class Compilation extends Tapable {
674 | // 如果有留意 SingleEntryPlugin 的源码,应该知道这里的 entry 不是字符串,而是 Dependency 对象
675 | addEntry(context, entry, name, callback) {
676 | this._addModuleChain(context, entry, onModule, callbak)
677 | }
678 |
679 | _addModuleChain(context, dependency, onModule, callback) {
680 | const Dep = dependency.constructor
681 | // 获取模块对应的工厂,这个映射关系在 SingleEntryPlugin 中有设置
682 | const moduleFactory = this.dependencyFactories.get(Dep)
683 | // 通过工厂创建模块
684 | moduleFactory.create(/*
685 | 在这个方法的回调中,
686 | 调用 this.buildModule 来构建模块。
687 | 构建完成后,调用 this.processModuleDependencies 来处理模块的依赖
688 | 这是一个循环和递归过程,通过依赖获得它对应的模块工厂来构建子模块,直到把所有的子模块都构建完成
689 | */)
690 | }
691 |
692 | buildModule(module, optional, origin, dependencies, thisCallback) {
693 | // 构建模块
694 | module.build(/*
695 | 而构建模块作为最耗时的一步,又可细化为三步:
696 | 1. 调用各 loader 处理模块之间的依赖
697 | 2. 解析经 loader 处理后的源文件生成抽象语法树 AST
698 | 3. 遍历 AST,获取 module 的依赖,结果会存放在 module 的 dependencies 属性中
699 | */)
700 | }
701 | }
702 | ```
703 |
704 | ### chunk 生成阶段
705 |
706 | 在所有的模块构建完成后,webpack 调用 `compilation.seal` 方法,开始生成 chunks。
707 |
708 | ```js
709 | // https://github.com/webpack/webpack/blob/master/lib/Compiler.js#L625
710 | class Compiler extends Tapable {
711 | compile(callback) {
712 | const params = this.newCompilationParams()
713 | this.hooks.compile.call(params)
714 | // 创建 compilation 对象
715 | const compilation = this.newCompilation(params)
716 | // 触发 make 事件钩子,控制权转移到 compilation,开始编译流程
717 | this.hooks.make.callAsync(compilation, err => {
718 | // 编译和构建流程结束后,回到这里,
719 | compilation.seal(err => {})
720 | })
721 | }
722 | }
723 | ```
724 |
725 | 每一个[入口起点](https://webpack.docschina.org/guides/code-splitting/#%E5%85%A5%E5%8F%A3%E8%B5%B7%E7%82%B9-entry-points-)、[公共依赖](https://webpack.docschina.org/guides/code-splitting/#%E9%98%B2%E6%AD%A2%E9%87%8D%E5%A4%8D-prevent-duplication-)、[动态导入](https://webpack.docschina.org/guides/code-splitting/#%E5%8A%A8%E6%80%81%E5%AF%BC%E5%85%A5-dynamic-imports-)、[runtime 抽离](https://webpack.docschina.org/configuration/optimization/#optimization-runtimechunk) 都会生成一个 chunk。
726 |
727 | `seal` 方法包含了优化、分块、哈希,编译停止接收新模块,开始生成 chunks。此阶段依赖了一些 webpack 内部插件对 module 进行优化,为本次构建生成的 chunk 加入 hash 等。
728 |
729 | ```js
730 | // https://github.com/webpack/webpack/blob/master/lib/Compilation.js#L1188
731 | class Compilation extends Tapable {
732 | seal(callback) {
733 | // _preparedEntrypoints 在 addEntry 方法中被填充,它存放着 entry 名称和对应的 entry module
734 | // 将 entry 中对应的 module 都生成一个新的 chunk
735 | for (const preparedEntrypoint of this._preparedEntrypoints) {
736 | const module = preparedEntrypoint.module
737 | const name = preparedEntrypoint.name
738 | // 创建 chunk
739 | const chunk = this.addChunk(name)
740 | // Entrypoint 继承于 ChunkGroup
741 | const entrypoint = new Entrypoint(name)
742 | entrypoint.setRuntimeChunk(chunk)
743 | entrypoint.addOrigin(null, name, preparedEntrypoint.request)
744 |
745 | this.namedChunkGroups.set(name, entrypoint)
746 | this.entrypoints.set(name, entrypoint)
747 | this.chunkGroups.push(entrypoint)
748 |
749 | // 建立 chunk 和 chunkGroup 之间的关系
750 | GraphHelpers.connectChunkGroupAndChunk(entrypoint, chunk)
751 | // 建立 chunk 和 module 之间的关系
752 | GraphHelpers.connectChunkAndModule(chunk, module)
753 |
754 | // 表明这个 chunk 是通过 entry 生成的
755 | chunk.entryModule = module
756 | chunk.name = name
757 |
758 | this.assignDepth(module)
759 | }
760 | // 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中
761 | this.processDependenciesBlocksForChunkGroups(this.chunkGroups.slice())
762 |
763 | // 优化
764 | // SplitChunksPlugin 会监听 optimizeChunksAdvanced 事件,抽取公共模块,形成新的 chunk
765 | // RuntimeChunkPlugin 会监听 optimizeChunksAdvanced 事件, 抽离 runtime chunk
766 | while (
767 | this.hooks.optimizeChunksBasic.call(this.chunks, this.chunkGroups) ||
768 | this.hooks.optimizeChunks.call(this.chunks, this.chunkGroups) ||
769 | this.hooks.optimizeChunksAdvanced.call(this.chunks, this.chunkGroups)
770 | ) {
771 | /* empty */
772 | }
773 | this.hooks.afterOptimizeChunks.call(this.chunks, this.chunkGroups)
774 |
775 | // 哈希
776 | this.createHash()
777 |
778 | // 通过这个钩子,可以在生成 assets 之前修改 chunks,我们后面会用到
779 | this.hooks.beforeChunkAssets.call()
780 |
781 | // 通过 chunks 生成 assets
782 | this.createChunkAssets()
783 | }
784 | }
785 | ```
786 |
787 | ### assets 渲染阶段
788 |
789 | Compilation 在实例化的时候,就会同时实例化三个对象:MainTemplate, ChunkTemplate,ModuleTemplate。这三个对象是用来渲染 chunk 对象,得到最终代码的模板。
790 |
791 | ```js
792 | class Compilation extends Tapable {
793 | // https://github.com/webpack/webpack/blob/master/lib/Compilation.js#L2373
794 | createChunkAssets() {
795 | // 每一个 chunk 会被渲染成一个 asset
796 | for (let i = 0; i < this.chunks.length; i++) {
797 | // 如果 chunk 包含 webpack runtime 代码,就用 mainTemplate 来渲染,否则用 chunkTemplate 来渲染
798 | // 关于什么是 webpack runtime, 后续我们在优化小程序 webpack 插件时会讲到
799 | const template = chunk.hasRuntime() ? this.mainTemplate : this.chunkTemplate
800 | }
801 | }
802 | }
803 | ```
804 |
805 | 接下来我们看 MainTemplate 是如何渲染 chunk 的。
806 |
807 | ```js
808 | // https://github.com/webpack/webpack/blob/master/lib/MainTemplate.js
809 | class MainTemplate extends Tapable {
810 | constructor(outputOptions) {
811 | super()
812 | this.hooks = {
813 | bootstrap: new SyncWaterfallHook(['source', 'chunk', 'hash', 'moduleTemplate', 'dependencyTemplates']),
814 | render: new SyncWaterfallHook(['source', 'chunk', 'hash', 'moduleTemplate', 'dependencyTemplates']),
815 | renderWithEntry: new SyncWaterfallHook(['source', 'chunk', 'hash']),
816 | }
817 | // 自身监听了 render 事件
818 | this.hooks.render.tap('MainTemplate', (bootstrapSource, chunk, hash, moduleTemplate, dependencyTemplates) => {
819 | const source = new ConcatSource()
820 | // 拼接 runtime 源码
821 | source.add(new PrefixSource('/******/', bootstrapSource))
822 |
823 | // 拼接 module 源码,mainTemplate 把渲染模块代码的职责委托给了 moduleTemplate,自身只负责生成 runtime 代码
824 | source.add(this.hooks.modules.call(new RawSource(''), chunk, hash, moduleTemplate, dependencyTemplates))
825 |
826 | return source
827 | })
828 | }
829 |
830 | // 这是 Template 的入口方法
831 | render(hash, chunk, moduleTemplate, dependencyTemplates) {
832 | // 生成 runtime 代码
833 | const buf = this.renderBootstrap(hash, chunk, moduleTemplate, dependencyTemplates)
834 |
835 | // 触发 render 事件,请注意 MainTemplate 自身在构造函数中监听了这一事件,完成了对 runtime 代码和 module 代码的拼接
836 | let source = this.hooks.render.call(
837 | // 传入 runtime 代码
838 | new OriginalSource(Template.prefix(buf, ' \t') + '\n', 'webpack/bootstrap'),
839 | chunk,
840 | hash,
841 | moduleTemplate,
842 | dependencyTemplates,
843 | )
844 |
845 | // 对于每一个入口 module, 即通过 compilation.addEntry 添加的模块
846 | if (chunk.hasEntryModule()) {
847 | // 触发 renderWithEntry 事件,让我们有机会修改生成后的代码
848 | source = this.hooks.renderWithEntry.call(source, chunk, hash)
849 | }
850 | return new ConcatSource(source, ';')
851 | }
852 |
853 | renderBootstrap(hash, chunk, moduleTemplate, dependencyTemplates) {
854 | const buf = []
855 | // 通过 bootstrap 这个钩子,用户可以添加自己的 runtime 代码
856 | buf.push(this.hooks.bootstrap.call('', chunk, hash, moduleTemplate, dependencyTemplates))
857 | return buf
858 | }
859 | }
860 | ```
861 |
862 | 所谓渲染就是生成代码的过程,代码就是字符串,渲染就是拼接和替换字符串的过程。
863 |
864 | 最终渲染好的代码会存放在 compilation 的 assets 属性中。
865 |
866 | ### 输出文件阶段
867 |
868 | 最后,webpack 调用 Compiler 的 emitAssets 方法,按照 output 中的配置项将文件输出到了对应的 path 中,从而结束整个打包过程。
869 |
870 | ```js
871 | // https://github.com/webpack/webpack/blob/master/lib/Compiler.js
872 | class Compiler extends Tapable {
873 | run(callback) {
874 | const onCompiled = (err, compilation) => {
875 | // 输出文件
876 | this.emitAssets(compilation, err => {})
877 | }
878 | // 调用 compile 方法
879 | this.compile(onCompiled)
880 | }
881 |
882 | emitAssets(compilation, callback) {
883 | const emitFiles = err => {}
884 | // 在输入文件之前,触发 emit 事件,这是最后可以修改 assets 的机会了
885 | this.hooks.emit.callAsync(compilation, err => {
886 | outputPath = compilation.getPath(this.outputPath)
887 | this.outputFileSystem.mkdirp(outputPath, emitFiles)
888 | })
889 | }
890 |
891 | compile(onCompiled) {
892 | const params = this.newCompilationParams()
893 | this.hooks.compile.call(params)
894 | // 创建 compilation 对象
895 | const compilation = this.newCompilation(params)
896 | // 触发 make 事件钩子,控制权转移到 compilation,开始编译 module
897 | this.hooks.make.callAsync(compilation, err => {
898 | // 模块编译和构建完成后,开始生成 chunks 和 assets
899 | compilation.seal(err => {
900 | // chunks 和 assets 生成后,调用 emitAssets
901 | return onCompiled(null, compilation)
902 | })
903 | })
904 | }
905 | }
906 | ```
907 |
908 | ## 分离 Runtime
909 |
910 | 现在,回到我们的小程序项目,确保 app.js 已经移除了下列代码
911 |
912 | ```diff
913 | // app.js
914 | - import moment from 'moment';
915 | - import { camelCase } from 'lodash';
916 | App({
917 | onLaunch: function () {
918 | - console.log('-----------------------------------------------x');
919 | - let sFromNowText = moment(new Date().getTime() - 360000).fromNow();
920 | - console.log(sFromNowText);
921 | - console.log(camelCase('OnLaunch'));
922 | }
923 | })
924 | ```
925 |
926 | 执行 `npx webpack`,观察生成的代码
927 |
928 | 
929 |
930 | 由 mainTemplate 生成的 webpackBootstrap 代码就是 webpack runtime 的代码,是整个应用的执行起点。moduleTemplate 则把我们的代码包裹在一个模块包装器函数中。
931 |
932 | 代码行有 `/******/` 前缀的表示该行代码由 mainTemplate 生成,有 `/***/` 前缀的表示该行代码由 moduleTemplate 生成,没有前缀的就是我们编写的经过 loader 处理后的模块代码。
933 |
934 | 我们再来看看 dist/logs/logs.js 的代码
935 |
936 | 
937 |
938 | 可以看到
939 |
940 | - 同样生成了 webpack runtime 代码,
941 |
942 | - utils/util.js 中的代码被合并到了 dist/logs/logs.js
943 |
944 | - logs.js 和 util.js 中的代码分别被包裹在模块包装器中
945 |
946 | 哪些数字是什么意思呢?它们表示模块的 id。
947 |
948 | 从上面的代码可以看到,logs.js 通过 `__webpack_require__(3)` 导入了 id 为 3 的模块,这正是 util.js。
949 |
950 | 我们不希望每个入口文件都生成 runtime 代码,而是希望将其抽离到一个单独的文件中,以减少 app 的体积。我们通过[配置 runtimeChunk](https://webpack.docschina.org/configuration/optimization/#optimization-runtimechunk) 来达到这一目的。
951 |
952 | 修改 webpack.config.js 文件,添加如下配置
953 |
954 | ```diff
955 | module.exports = {
956 | + optimization: {
957 | + runtimeChunk: {
958 | + name: 'runtime'
959 | + }
960 | + },
961 | mode: 'none'
962 | }
963 | ```
964 |
965 | 执行 `npx webpack`,
966 |
967 | 可以看到,在 dist 目录中,生成了名为 runtime.js 的文件
968 |
969 | 
970 |
971 | 这是一个 [IIFE](https://developer.mozilla.org/zh-CN/docs/Glossary/%E7%AB%8B%E5%8D%B3%E6%89%A7%E8%A1%8C%E5%87%BD%E6%95%B0%E8%A1%A8%E8%BE%BE%E5%BC%8F)。
972 |
973 | 现在我们开看看 dist/app.js
974 |
975 | 
976 |
977 | 这似乎是要把 app.js 模块存放到全局对象 window 中,但是小程序中并没有 window 对象,只有 wx。我们在 webpack.config.js 中,把全局对象配置为 `wx`
978 |
979 | ```diff
980 | module.exports = {
981 | output: {
982 | path: resolve('dist'),
983 | - filename: '[name].js'
984 | + filename: '[name].js',
985 | + globalObject: 'wx'
986 | },
987 | }
988 | ```
989 |
990 | 然而,还是有问题,我们的小程序已经跑不起来了
991 |
992 | 
993 |
994 | 这是因为小程序和 web 应用不一样,web 应用可以通过 `