├── .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 | ![使用测试号创建一个小程序项目](./screenshots/create_app.png) 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 | ![成功输入时间](./screenshots/miniprogram_npm_success.png) 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 | ![使用 lodash 时出错了](./screenshots/miniprogram_npm_error.png) 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 | ![webpack npm 成功!](./screenshots/npm_success.png) 320 | 321 | 可以看到,不管是 moment 还是 lodash, 都能正常工作。 322 | 323 | **这是重要的里程碑的一步,因为我们终于能够正常地使用 npm 了。** 324 | 325 | 而此时,我们还没有开始写 webpack 插件。 326 | 327 | 如果你有留意,在执行 `npx webpack` 命令时,终端会输出以下信息 328 | 329 | ![生成的 app.js 文件太大](./screenshots/asset_size_large.png) 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 | ![dist/app.js](./screenshots/template.jpg) 929 | 930 | 由 mainTemplate 生成的 webpackBootstrap 代码就是 webpack runtime 的代码,是整个应用的执行起点。moduleTemplate 则把我们的代码包裹在一个模块包装器函数中。 931 | 932 | 代码行有 `/******/` 前缀的表示该行代码由 mainTemplate 生成,有 `/***/` 前缀的表示该行代码由 moduleTemplate 生成,没有前缀的就是我们编写的经过 loader 处理后的模块代码。 933 | 934 | 我们再来看看 dist/logs/logs.js 的代码 935 | 936 | ![dist/logs/logs.js](./screenshots/logs.png) 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 | ![dist/runtime.js](./screenshots/runtime.png) 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 | ![dist/app.js](./screenshots/app.png) 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 | ![runtime error](./screenshots/runtime_error.png) 993 | 994 | 这是因为小程序和 web 应用不一样,web 应用可以通过 `