webpack4-学习笔记(原理&实战篇)

157 阅读11分钟

原理篇

详细剖析 webpack 打包原理和插件、Loader 的实现

一、通过源码掌握 webpack 打包原理

开始:从 webpack 命令行说起

通过 npm scripts 运行 webpack

  • 开发环境: npm run dev

  • 生产环境:npm run build

通过 webpack 直接运行

  • webpack entry.js bundle.js

查找 webpack 入口文件

在命令行运行以上命令后,npm会让命令行工具进入node_modules.bin 目录 查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在,就执行,不 存在,就抛出错误。

实际的入口文件是:node_modules\webpack\bin\webpack.js

分析 webpack 的入口文件:webpack.js

process.exitCode = 0; //1. 正常执行返回

const runCommand = (command, args) =>{...}; //2. 运行某个命令

const isInstalled = packageName =>{...}; //3. 判断某个包是否安装

const CLIs =[...]; //4. webpack 可用的 CLI: webpack-cli 和 webpack-command

const installedClis = CLIs.filter(cli => cli.installed); //5.判断是否两个 ClI 是否安装了

if (installedClis.length === 0){...}else if // 6. 根据安装数量进行处理

    (installedClis.length === 1){...}else{...}

启动后的结果

webpack 最终找到 webpack-cli (webpack-command) 这个 npm 包,并且 执行 CLI

webpack-cli 做的事情

引入 yargs,对命令行进行定制

分析命令行参数,对各个参数进行转换,组成编译配置项

引用webpack,根据配置项进行编译和构建

从NON_COMPILATION_CMD分析出不需要编译的命令

webpack-cli 处理不需要经过编译的命令

const { NON_COMPILATION_ARGS } = require("./utils/constants");

const NON_COMPILATION_CMD = process.argv.find(arg => {

    if (arg === "serve") {

        global.process.argv = global.process.argv.filter(a => a !== "serve");

        process.argv = global.process.argv;

    }

    return NON_COMPILATION_ARGS.find(a => a === arg);

});

if (NON_COMPILATION_CMD) {

    return require("./utils/prompt-command")(NON_COMPILATION_CMD, ...process.argv);

}

NON_COMPILATION_ARGS的内容

webpack-cli 提供的不需要编译的命令

const NON_COMPILATION_ARGS = [

    "init", //创建一份 webpack 配置文件

    "migrate", //进行 webpack 版本迁移

    "add", //往 webpack 配置文件中增加属性

    "remove", //往 webpack 配置文件中删除属性

    "serve", //运行 webpack-serve

    "generate-loader", //生成 webpack loader 代码

    "generate-plugin", //生成 webpack plugin 代码

    "info” //返回与本地环境相关的一些信息

]

命令行工具包 yargs 介绍

image.png

webpack-cli 使用 args 分析

参数分组 (config/config-args.js),将命令划分为9类:

  • Config options: 配置相关参数(文件名称、运行环境等)

  • Basic options: 基础参数(entry设置、debug模式设置、watch监听设置、devtool设置)

  • Module options: 模块参数,给 loader 设置扩展

  • Output options: 输出参数(输出路径、输出文件名称)

  • Advanced options: 高级用法(记录设置、缓存设置、监听频率、bail等)

  • Resolving options: 解析参数(alias 和 解析的文件后缀设置)

  • Optimizing options: 优化参数

  • Stats options: 统计参数

  • options: 通用参数(帮助命令、版本信息等)

webpack-cli 执行的结果

webpack-cli对配置文件和命令行参数进行转换最终生成配置选项参数 options

最终会根据配置参数实例化 webpack 对象,然后执行构建流程

Webpack 的本质

Webpack可以将其理解是一种基于事件流的编程范例,一系列的插件运行。

先看一段代码


核心对象 Compiler 继承 Tapable

class Compiler extends Tapable {

    // ...

}

核心对象 Compilation 继承 Tapable

class Compilation extends Tapable {

    // ...

}

Tapable 是什么?

Tapable 是一个类似于 Node.js 的 EventEmitter 的库, 主要是控制钩子函数的发布

与订阅,控制着 webpack 的插件系统。

Tapable库暴露了很多 Hook(钩子)类,为插件提供挂载的钩子


const {

    SyncHook, //同步钩子

    SyncBailHook, //同步熔断钩子

    SyncWaterfallHook, //同步流水钩子

    SyncLoopHook, //同步循环钩子

    AsyncParallelHook, //异步并发钩子

    AsyncParallelBailHook, //异步并发熔断钩子

    AsyncSeriesHook, //异步串行钩子

    AsyncSeriesBailHook, //异步串行熔断钩子

    AsyncSeriesWaterfallHook //异步串行流水钩子

} = require("tapable");

Tapable hooks 类型

image.png

Tapable 的使用 -new Hook 新建钩子

Tapable 暴露出来的都是类方法,new 一个类方法获得我们需要的钩子

class 接受数组参数 options ,非必传。类方法会根据传参,接受同样数量的参数。

const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);

Tapable 的使用-钩子的绑定与执行

Tabpack 提供了同步&异步绑定钩子的方法,并且他们都有绑定事件和执行事件对应的方法。

image.png

Tapable 的使用-hook 基本用法示例

const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);

//绑定事件到webapck事件流

hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3

//执行绑定的事件

hook1.call(1,2,3)

Tapable 的使用-实际例子演示

定义一个 Car 方法,在内部 hooks 上新建钩子。分别是同步钩子 accelerate、brake(accelerate 接受一个参数)、异步钩子 calculateRoutes

使用钩子对应的绑定和执行方法

calculateRoutes 使用 tapPromise 可以返回一个 promise 对象

Tapable 是如何和 webpack 联系起来的?


if (Array.isArray(options)) {

    compiler = new MultiCompiler(options.map(options => webpack(options)));

} else if (typeof options === "object") {

    options = new WebpackOptionsDefaulter().process(options);

    compiler = new Compiler(options.context);

    compiler.options = options;

    new NodeEnvironmentPlugin().apply(compiler);

    if (options.plugins && Array.isArray(options.plugins)) {

        for (const plugin of options.plugins) {

            if (typeof plugin === "function") {

                plugin.call(compiler, compiler);

            } else {

                plugin.apply(compiler);

            }

        }

}

        compiler.hooks.environment.call();

        compiler.hooks.afterEnvironment.call();

        compiler.options = new WebpackOptionsApply().process(options, compiler);

}

模拟 Compiler.js

module.exports = class Compiler {

    constructor() {

      this.hooks = {

        accelerate: new SyncHook(['newspeed']),

        brake: new SyncHook(),

        calculateRoutes: new AsyncSeriesHook(["source", "target", "routesList"])

    }

}

run(){

    this.accelerate(10)

    this.break()

    this.calculateRoutes('Async', 'hook', 'demo')

}

accelerate(speed) {

    this.hooks.accelerate.call(speed);

}

break() {

    this.hooks.brake.call();

}

calculateRoutes() {

    this.hooks.calculateRoutes.promise(...arguments).then(() => {

    }, err => {

        console.error(err);

        });

    }

}

插件 my-plugin.js


const Compiler = require('./Compiler')


class MyPlugin{

    constructor() {

}

    apply(compiler){

        compiler.hooks.brake.tap("WarningLampPlugin",()=>console.log('WarningLampPlugin'));

        compiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to${newSpeed}`));

        compiler.hooks.calculateRoutes.tapPromise("calculateRoutes tapAsync", (source, target, routesList)
=> {

    return new Promise((resolve,reject)=>{

        setTimeout(()=>{

        console.log(`tapPromise to ${source} ${target} ${routesList}`)

        resolve();

           },1000)

        });

    });

}

}

模拟插件执行


const myPlugin = new MyPlugin();

const options = {

    plugins: [myPlugin]

}

const compiler = new Compiler();

for (const plugin of options.plugins) {

    if (typeof plugin === "function") {

        plugin.call(compiler, compiler);

    } else {

        plugin.apply(compiler);

    }

}

compiler.run();

Webpack 流程篇

webpack的编译都按照下面的钩子调用顺序执行

image.png

WebpackOptionsApply

将所有的配置 options 参数转换成 webpack 内部插件

使用默认插件列表

举例:

  • output.library -> LibraryTemplatePlugin

  • externals -> ExternalsPlugin

  • devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin

  • AMDPlugin, CommonJsPlugin

  • RemoveEmptyChunksPlugin

Compiler hooks

流程相关:

  • (before-)run

  • (before-/after-)compile

  • make

  • (after-)emit

  • done

监听相关:

  • watch-run

  • watch-close

Compilation

Compiler 调用 Compilation 生命周期方法

  • addEntry -> addModuleChain

  • finish (上报模块错误)

  • seal

ModuleFactory

image.png

Module

image.png

NormalModule

Build

  • 使用 loader-runner 运行 loaders

  • 通过 Parser 解析 (内部是 acron)

  • ParserPlugins 添加依赖

Compilation hooks

image.png

Chunk 生成算法

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk

  2. 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中

  3. 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个

新的 chunk,继续遍历依赖

  1. 重复上面的过程,直至得到所有的 chunks

模块化:增强代码可读性和维护性

传统的网页开发转变成 Web Apps 开发

代码复杂度在逐步增高

分离的 JS文件/模块,便于后续代码的维护性

部署时希望把代码优化成几个 HTTP 请求

常见的几种模块化方式


ES module

import * as largeNumber from 'large-number';

// ...

largeNumber.add('999', '1');

------------------------------------------------

CJS

const largeNumbers = require('large-number');

// ...

largeNumber.add('999', '1');

------------------------------------------------

AMD

require(['large-number'], function (large-number) {

    // ...

    largeNumber.add('999', '1');

});

AST 基础知识

抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是

源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都

表示源代码中的一种结构。

在线demo: esprima.org/demo/parse.…

image.png

复习一下 webpack 的模块机制

image.png

动手实现一个简易的 webpack

可以将 ES6 语法转换成 ES5 的语法

  • 通过 babylon 生成AST

  • 通过 babel-core 将AST重新生成源码

可以分析模块之间的依赖关系

  • 通过 babel-traverse 的 ImportDeclaration 方法获取依赖属性

生成的 JS 文件可以在浏览器中运行

二、编写 Loader 和插件

一个最简单的 loader 代码结构

定义:loader 只是一个导出为函数的JavaScript 模块

module.exports = function(source) {

return source;

}

多 Loader 时的执行顺序

// 多个 Loader 串行执行,顺序从后到前
module.exports = {

entry: './src/index.js',

output: {

    filename: 'bundle.js',

    path: path.resolve(__dirname, 'dist')

},

+ module: {

    + rules: [

        + {

            + test: /\.less$/,

            + use: [

            + 'style-loader',

            + 'css-loader',

            + ' less-loader'

            + ]

        + }

    + ]

+ }

};

函数组合的两种情况

Unix 中的 pipline

Compose(webpack采取的是这种)

compose = (f, g) => (...args) => f(g(...args));

通过一个例子验证 loader 的执行顺序


a-loader.js:

module.exports = function(source) {

    console.log ('loader a is executed');

    return source;

};

b-loader.js:

module.exports = function(source) {

    console.log ('loader b is executed');

    return source;

};

loader-runner 介绍

定义:loader-runner 允许你在不安装 webpack 的情况下运行 loaders

作用:

  • 作为 webpack 的依赖,webpack 中使用它执行 loader

  • 进行 loader 的开发和调试

loader-runner 的使用


import { runLoaders } from "loader-runner";

runLoaders({

    resource: “/abs/path/to/file.txt?query”, // String: 资源的绝对路径(可以增加查询字符串)

    loaders: [“/abs/path/to/loader.js?query”], // String[]: loader 的绝对路径(可以增加查询字符串)

    context: { minimize: true }, // 基础上下文之外的额外 loader 上下文

    readResource: fs.readFile.bind(fs) // 读取资源的函数

}, function(err, result) {

    // err: Error?

    // result.result: Buffer | String

})

开发一个 raw-loader

src/raw-loader.js:

module.exports = function(source) {

    const json = JSON.stringify(source)

        .replace(/\u2028/g, ‘\\u2028 ' ) // 为了安全起见, ES6模板字符串的问题

        .replace(/\u2029/g, '\\u2029');

        return `export default ${json}`;

};

src/demo.txt

foobar

使用 loader-runner 调试 loader

run-loader.js:

const fs = require("fs");

const path = require("path");

const { runLoaders } = require("loader-runner");

runLoaders(

    {

        resource: "./demo.txt",

        loaders: [path.resolve(__dirname, "./loaders/raw-

        loader")],

        readResource: fs.readFile.bind(fs),

    },

    (err, result) => (err ? console.error(err) : console.log(result))

);

运行查看结果:

node run-loader.js

image.png

loader 的参数获取

通过 loader-utils 的 getOptions 方法获取

const loaderUtils = require("loader-utils");

module.exports = function(content) {

    const { name } = loaderUtils.getOptions(this);

};

loader 异常处理


loader 内直接通过 throw 抛出

通过 this.callback 传递错误

this.callback(

    err: Error | null,

    content: string | Buffer,

    sourceMap?: SourceMap,

    meta?: any

);

loader 的异步处理


通过 this.async 来返回一个异步函数

- 第一个参数是 Error,第二个参数是处理的结果

示意代码:

module.exports = function(input) {

    const callback = this.async();

    // No callback -> return synchronous results

    // if (callback) { ... }

    callback(null, input + input);

};

在 loader 中使用缓存

webpack 中默认开启 loader 缓存

  • 可以使用 this.cacheable(false) 关掉缓存

缓存条件: loader 的结果在相同的输入下有确定的输出

  • 有依赖的 loader 无法使用缓存

loader 如何进行文件输出?

通过 this.emitFile 进行文件写入


const loaderUtils = require("loader-utils");

module.exports = function(content) {

    const url = loaderUtils.interpolateName(this, "[hash].[ext]", {

    content,

});

this.emitFile(url, content);

    const path = `__webpack_public_path__ + ${JSON.stringify(url)};`;

    return `export default ${path}`;

};

实战开发一个自动合成雪碧图的 loader

image.png

准备知识:如何将两张图片合成一张图片?

使用 spritesmith (www.npmjs.com/package/spr…)

spritesmith 使用示例

const sprites = ['./images/1.jpg', './images/2.jpg'];

Spritesmith.run({src: sprites}, function handleResult (err, result) {

    result.image;

    result.coordinates;

    result.properties;

});

插件的运行环境

插件没有像 loader 那样的独立运行环境

只能在 webpack 里面运行

插件的基本结构

基本结构:


class MyPlugin { // 1.插件名称

    apply(compiler) { // 2.插件上的 apply 方法

        compiler.hooks.done.tap(' My Plugin',( // 3.插件的 hooks
 
            stats /* stats is passed as argument when done hook is tapped. */

        ) => {

            console.log('Hello World!'); // 4.插件处理逻辑

        });

    }

}

module.exports = MyPlugin;

插件使用:

plugins: [ new MyPlugin() ]

搭建插件的运行环境

const path = require("path");

const DemoPlugin = require("./plugins/demo-plugin.js");

const PATHS = {

    lib: path.join(__dirname, "app", "shake.js"),

    build: path.join(__dirname, "build"),

};

module.exports = {

    entry: {

        lib: PATHS.lib,

    },

output: {

    path: PATHS.build,

    filename: "[name].js",

},

plugins: [new DemoPlugin()],

}

开发一个最简单的插件


src/demo-plugin.js

module.exports = class DemoPlugin {

    constructor(options) {

        this.options = options;

}

apply() {

    console.log("apply", this.options);

}

};




加入到 webpack 配置中

module.exports = {

    ...

    plugins: [new DemoPlugin({ name: "demo" })]

};

插件中如何获取传递的参数?


通过插件的构造函数进行获取

module.exports = class MyPlugin {

    constructor(options) {

        this.options = options;

    }

apply() {

    console.log("apply", this.options);

}

};

插件的错误处理

参数校验阶段可以直接 throw 的方式抛出

throw new Error(“ Error Message”)

通过 compilation 对象的 warnings 和 errors 接收

compilation.warnings.push("warning");

compilation.errors.push("error");

通过 Compilation 进行文件写入

Compilation 上的 assets 可以用于文件写入

可以将 zip 资源包设置到 compilation.assets 对象上

文件写入需要使用 webpack-sources (www.npmjs.com/package/web…)

const { RawSource } = require("webpack-sources");

module.exports = class DemoPlugin {

    constructor(options) {

        this.options = options;

}

apply(compiler) {

    const { name } = this.options;

    compiler.plugin("emit", (compilation, cb) => {

        compilation.assets[name] = new RawSource("demo");

        cb();

        });

    }

};

插件扩展:编写插件的插件

插件自身也可以通过暴露 hooks 的方式进行自身扩展,以 html-webpack-plugin 为例:

  • html-webpack-plugin-alter-chunks (Sync)

  • html-webpack-plugin-before-html-generation (Async)

  • html-webpack-plugin-alter-asset-tags (Async)

  • html-webpack-plugin-after-html-processing (Async)

  • html-webpack-plugin-after-emit (Async)

编写一个压缩构建资源为zip包的插件

要求:

  • 生成的 zip 包文件名称可以通过插件传入

  • 需要使用 compiler 对象上的特地 hooks 进行资源的生成

准备知识:Node.js 里面将文件压缩为 zip 包

使用 jszip (www.npmjs.com/package/jsz…)

jszip 使用示例

var zip = new JSZip();

zip.file("Hello.txt", "Hello World\n");

var img = zip.folder("images");

img.file("smile.gif", imgData, {base64: true});

zip.generateAsync({type:"blob"}).then(function(content) {

    // see FileSaver.js

    saveAs(content, "example.zip");

});

复习:Compiler 上负责文件生成的 hooks

Hooks 是 emit,是一个异步的 hook (AsyncSeriesHook)

emit 生成文件阶段,读取的是 compilation.assets 对象的值

  • 可以将 zip 资源包设置到 compilation.assets 对象上

实战篇

从实际 Web 商城项⽬出发,讲解 webpack 实际使⽤

一、React 全家桶 和 webpack 开发商城项目

商城技术栈选型

image.png

商城架构设计

image.png

商城界⾯ UI 设计与模块拆分

image.png

前台模块拆分

image.png

后台模块拆分

image.png

React 全家桶环境搭建

. 初始化项⽬ npm init -y

· 创建项⽬⽬录

image.png

安装依赖

  • 安装 react、react-dom、redux、react-redux

npm i react react-dom redux react-redux -S

  • 安装 @babel/core

npm i @babel/core -D

  • 安装 geektime-builder-webpack

npm i geektime-builder-webpack -D

创建 actions、reducers、store

  • actions 和 reducers

src/actions/ 放置所有的actions、src/reducers 放置所有的 reducers

  • rootReducer

src/reducers/rootReducer.js 将所有的 reducers 进⾏ Combine

  • 使⽤ Provider 传递 store

Store 通过 Provider 传递给容器组件

数据库实体设计

image.png

编写服务端 API

  • 登陆注册 API

⽤户注册(POST):/user/register

⽤户登陆(GET):/user/login

  • 商品 API

商品列表(GET):/goods?page=1&per_page=10

商品详情(GET): /goods/:id

修改商品(PUT): /goods/:id

登陆注册模块开发

image.png

JSON Web Token ⼯作原理

image.png

登陆注册模块开发 - 注册功能

curl -X POST -d "username=test&password=123456&email=test@qq.com" "http://127.0.0.1:8001/api/v1/user/register"

  • 注册截图 image.png

登陆注册模块开发 - 登录功能

curl -X POST -d "username=test&password=123456" "http://127.0.0.1:8001/api/v1/user/login"

  • 登录截图 image.png

商品模块开发

image.png

商品模块开发 - 创建商品

创建商品 API (POST): http://127.0.0.1:8001/api/v1/goods/new

image.png

商品模块开发 - 获取商品列表

商品列表 API (GET): http://127.0.0.1:8001/api/v1/goods?page=1&per_page=10 image.png

订单模块开发

image.png

订单模块开发 - 创建订单

创建订单 API (POST): http://127.0.0.1:8001/api/v1/order/new image.png

谈谈 Web 商城的性能优化策略

  • 渲染优化

⾸⻚、列表⻚、详情⻚采⽤ SSR 或者 Native 渲染

个⼈中⼼⻚预渲染

  • 弱⽹优化

使⽤离线包、PWA 等离线缓存技术

  • Webview 优化

打开 Webview 的同时并⾏的加载⻚⾯数据

功能开发要点

  • 浏览器端:

组件化,组件颗粒度尽可能⼩

直接复⽤ builder-webpack-geektime 的构建配置,⽆需关注构建脚本

  • 服务端:

MVC 开发⽅式,数据库基于 Sequelize

Rest API ⻛格

采⽤ JWT 进⾏鉴权

源码和演示地址

· 源码: github.com/cpselvis/ge…

· 演示步骤: README 有详细步骤