0%

Eslint 安装

  • 安装 npm install eslint --save-dev
  • 使用 npx eslint --init 初始化一个配置文件
  • 执行 npm eslint test.js --fix –fix 会自动修正代码风格问题,而一些问题代码就需要手动修正
  • 当代码有语法问题时 Eslint 无法检查问题代码和代码风格,所有要先手动解决语法问题

ESLint 配置文件解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = {
env: {
// 标记代码运行环境
browser: true,
es2020: true,
},
extends: [
// 继承共享配置
"standard",
],
parser: "@typescript-eslint/parser", // 语法解析器
parserOptions: {
// 设置语法解析器,检查语法,而不是代表某个成员是否可以,成员通过环境定义
ecmaVersion: 11,
},
rules: {
// 校验规则的开启、关闭 off:关闭 warning:结果 error:警告
"no-alert": "error",
},
globals: {
// 额外声明代码中可以使用的全局成员
jQuery: "readonly", // 代码中可以使用jQuery而且不会报错
},
};

ESLint 配置注释

  • 在编码时有时需要违反 eslint 规则,eslint-disable-line 可以通过注释临时禁用校验规则
  • 当一行有多个问题时可以跟上具体禁用哪个规则
  • 具体配置注释介绍文档
1
2
3
4
// eslint-disable-line no-template-curly-in-string
const str = "${name} is a coder";

console.log(str);

ESLint 结合自动化工具

  • gulp
    • 安装 gulp-eslint
    • 创建 eslint 配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// plugins 自动加载插件模块
const script = () => {
return (
src("src/assets/script/*.js", { base: "src" })
.pipe(plugins.eslint()) // 默认只会检查代码的问题,并不会根据检查结果做出反馈
// 控制台打印错误信息
.pipe(plugins.eslint.format())
// eslint检查错误后终止任务
.pipe(plugins.eslint.failAfterError())
.pipe(plugins.babel({ presets: ["@babel/preset-env"] }))
.pipe(dest("temp"))
.pipe(bs.reload({ stream: true }))
);
};

ESLint 结合 Webpack

  • 通过 loader 机制完成校验
  • 安装 eslint-loader
  • 初始化配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
mode: "production",
entry: "./src/main.js",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
"babel-loader",
// 'eslint-loader'
],
},
{
test: /\.js$/,
exclude: /node_modules/,
use: "eslint-loader",
enfore: "pre",
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "src/index.html",
}),
],
};
  • 在 react 项目中会报 React 未使用的问题,和 App is defined but never used no-unused-vars,可以使用插件
  • 安装插件 npm install eslint-plugin-react
  • 在配置文件的 plugins 属性配置
1
2
3
4
5
6
7
8
9
10
11
12
modul.exports = {
rules: {
"react/jsx-uses-react": 2, // 开启报错,2 代替 error
"react/jsx-uses-vars": 2,
},
extends: [
// 'plugin:react/recommended' // 方法二
],
plugins: [
"react", // 模块名会去除eslint-plugin
],
};

Stylelint 认识

  • 安装 npm install stylelint -D
  • 添加配置文件 .stylelintrc.js
  • 共享配置名称要完整的模块名称
  • stylelint 内部没有提供任何可用的共享配置,可以自定义安装
  • npm install stylelint-config-standard -D 对 css 代码检查
  • npm install stylelint-config-sass-guidelines -D 对 sass 代码检查
1
2
3
4
module.exports = {
// 共享配置
extends: ["stylelint-config-standard", "stylelint-config-sass-guidelines"],
};

Prettier 的使用—代码格式化工具

  • 安装 npm install prettier -D
  • npm prettier style.css --write 自动格式化文件,–write 把格式化的代码覆盖源代码

ESLint 结合 Git Hooks

  • 安装依赖 npm install husky -D
  • 在 package.json 文件添加一个 husky 字段,在 husky 添加一个 hooks 属性,在 hooks 属性添加一个 pre-commit 钩子属性,值为 npm run xxxx,在提交 commit 前就会先执行 scripts 下的对应命令,比如执行 eslint,如果不符合规范就不能直接提交到代码仓库中
1
2
3
4
5
6
7
8
9
10
11
12
// package.json
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"link": "eslint ./index.js"
},
"husky": {
"hooks": {
"pre-commit": "npm run link"
}
}
}
  • 安装 npm install lint-staged -D 在 commit 前执行 eslint 后,再进行其他操作,如格式化代码
  • 安装代码格式化工具 npm install prettier -D
  • 在 package.json 中添加一个 lint-staged 字段
  • 一般 husky 和 lint-staged 配合使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"link": "eslint ./index.js",
"precommit": "lint-staged"
},
"husky": {
"hooks": {
"pre-commit": "npm run precommit"
}
},
"lint-staged": {
"*.js": ["prettier --write", "eslint --fix", "git add"]
}
}

Git 提交规范—commitlint

  • 文档:https://commitlint.js.org/#/
  • 安装插件 yarn add husky @commitlint/cli @commitlint/config-conventional --dev
  • husky 是一个 Git Hook 工具
  • 项目根目录创建配置文件 commitlint.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"subject-case": [0, "never"],
"type-enum": [
2, // 代表必须输入
"always",
[
"docs", // Adds or alters documentation. 仅仅修改了文档,比如README, CHANGELOG, CONTRIBUTE等等
"chore", // Other changes that don't modify src or test files. 改变构建流程、或者增加依赖库、工具等
"feat", // Adds a new feature. 新增feature
"fix", // Solves a bug. 修复bug
"merge", // Merge branch ? of ?.
"perf", // Improves performance. 优化相关,比如提升性能、体验
"refactor", // Rewrites code without feature, performance or bug changes. 代码重构,没有加新功能或者修复bug
"revert", // Reverts a previous commit. 回滚到上一个版本
"style", // Improves formatting, white-space. 仅仅修改了空格、格式缩进、都好等等,不改变代码逻辑
"test", // Adds or modifies tests. 测试用例,包括单元测试、集成测试等
],
],
},
};
  • 配置 package.json 文件,添加一下内容,在执行 git commit -m "commit message" 命令的时候,将会对 commit message 校验,是否符合 rules
1
2
3
4
5
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},

自动化构建

自动化构建简介

一切重复工作本应自动化

  • 自动化构建(自动化构建工作流):把开发阶段代码自动转换为生产环境运行的代码/程序;
  • 作用:脱离运行环境兼容带来的问题,使用提高效率的语法、规范和标准
  • 自动化构建工具,构建转换那些不被支持的【特性】(sass、ECMAScript next、模板引擎)

自动化构建初体验

NPM Scripts 可以在 NPM Scripts 中定义一些与项目开发过程有关的脚本命令便于后期开发使用

  • 使用过程
    • 在命令行执行yarn init -y生成 package.json 文件
    • 在 package.json 文件添加 scripts 字段,该字段是一个对象,键是 scripts 名称,值是需要执行的命令;scripts 可以自动发现 node_modules 里面的命令,所以不用写完整的路径直接使用命令的名称即可
    • 使用 npm/yarn 启动 scripts
    • --watch: scss在工作是会监听文件的变化,scss 文件发生改变时会自动编译
1
2
3
"scripts": {
"build": "sass scss/main.scss css/style.css --watch",
}
  • NPM Scripts 实现自动化构建工作流的最简单方式

使用 browser-sync 模块可以开启测试服务器运行项目,在 scripts 中添加命令"serve": "browser-sync ."运行项目

--files "css/*.css" 参数可以让 browser-sync 在启动过后监听项目下文件的变化,browser-sync 会将文件内容自动同步到浏览器,更新浏览器界面

1
2
3
4
"scripts": {
"build": "sass scss/main.scss css/style.css --watch",
"serve": "browser-sync . --files \"css/*.css\""
},

在启动 serve 前让 build 工作可以借助 NPM Scripts 钩子机制定义一个 preserve,它可以在 serve 执行前去执行

1
2
3
4
5
"scripts": {
"build": "sass scss/main.scss css/style.css --watch",
"preserve": "yarn build",
"serve": "browser-sync . --files \"css/*.css\""
},

可以使用npm-run-all模块同时执行多个任务

1
2
3
4
5
"scripts": {
"build": "sass scss/main.scss css/style.css",
"serve": "browser-sync . --files \"css/*.css\"",
"start": "run-p build serve"
},

常用的自动化构建工具

  • Grunt 最早的构建系统;插件生态完善;工作过程基于临时文件实现所以构建速度较慢,如使用它完成 scss 文件构建,我们会先对 scss 文件进行编译工作,自动添加属性前缀,压缩代码,Grunt 每一步都有磁盘读写操作,处理环节越多,磁盘的读写越多;大型项目中速度会非常慢
  • Gulp 可以解决 Grunt 中构建速度慢的问题,他基于内存实现的,对文件的读写都是基于内存完成的,相对与磁盘读写就快了;支持同时执行多个任务效率高;使用方式相对于 Grunt 更直观易懂;插件生态完善
  • FIS 微内核;更像是一种捆绑套餐,把项目中典型的需求尽可能集成在内部

Grunt

Grunt 的基本使用

  • 项目命令行输入 yarn init -y 生成 package.json 文件
  • 安装 grunt yarn add grunt
  • 项目根目录创建 gruntfile.js 文件,该文件是 Grunt 入口文件,用于定义一些需要 Grunt 自动执行的任务,需要导出一个函数,此函数接收一个 grunt 的形参,内部提供一些创建任务时可以用到的 API
  • 最后使用 yanr grunt taskName 执行任务

使用 grunt.registerTask() 方法注册一个任务

  • 第一个参数为任务名称,如果任务名称为 default,该任务将作为默认任务,运行时不用指定任务名,Grunt 将自动调用 default
  • 第二个参数
    • 第二个参数是字符串时,该字符串将是任务的描述,命令行输入 yarn grunt --help 可以看到该信息;
    • 一般会使用 default 任务映射一些其他的任务,其他的任务将作为第二个参数,参数是数组,元素为任务名,当执行 default 任务时 Grunt 会依次执行数组中的任务;
    • 当第二个参数为函数时,将制定任务函数,任务发生时自动执行函数
  • 异步任务:需要使用 this.async() 方法得到一个回调函数,在异步回调函数完成后调用这个函数标识任务已经完成
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
// Grunt 的入口文件
// 用于定义一些需要 Grunt 自动执行的任务
// 需要导出一个函数
// 此函数接收一个 grunt 的对象类型的形参
// grunt 对象中提供一些创建任务时会用到的 API

module.exports = (grunt) => {
grunt.registerTask("foo", "a sample task", () => {
console.log("hello grunt");
});

grunt.registerTask("bar", () => {
console.log("other task");
});

// // default 是默认任务名称
// // 通过 grunt 执行时可以省略
// grunt.registerTask('default', () => {
// console.log('default task')
// })

// 第二个参数可以指定此任务的映射任务,
// 这样执行 default 就相当于执行对应的任务
// 这里映射的任务会按顺序依次执行,不会同步执行
grunt.registerTask("default", ["foo", "bar"]);

// 也可以在任务函数中执行其他任务
grunt.registerTask("run-other", () => {
// foo 和 bar 会在当前任务执行完成过后自动依次执行
grunt.task.run("foo", "bar");
console.log("current task runing~");
});

// 默认 grunt 采用同步模式编码
// 如果需要异步可以使用 this.async() 方法创建回调函数
// grunt.registerTask('async-task', () => {
// setTimeout(() => {
// console.log('async task working~')
// }, 1000)
// })

// 由于函数体中需要使用 this,所以这里不能使用箭头函数
grunt.registerTask("async-task", function () {
const done = this.async();
setTimeout(() => {
console.log("async task working~");
done();
}, 1000);
});
};

Grunt 标记任务失败

在 grunt.registerTask() 任务函数 return false,在命令行执行该任务时将会提示执行失败,在任务列表中会导致后面的任务无法执行,在执行任务时添加 –force 命令,将会强制执行所有的任务 yarn grunt --force,在异步任务中无法通过 return false 来标记失败,要给异步的回调函数指定 false 实参就可以标记失败了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = (grunt) => {
grunt.registerTask("foo", () => {
console.log("hello grunt~");
});

grunt.registerTask("bar", "任务描述", () => {
console.log("other task~");
});

grunt.registerTask("bad-async", function () {
const done = this.async();
setTimeout(() => {
console.log("async task working~");
done(false); // 出入实参false标识异步任务失败
}, 1000);
});

grunt.registerTask("bad", () => {
console.log("bad working~");
return false;
});

grunt.registerTask("default", ["foo", "bad", "bar"]);
};

Grunt 的配置方法

  • 通过 grunt.initConfig() 方法为任务添加一些配置项
  • grunt.initConfig() 参数为一个对象,键一般对应任务的名,值可以是任意类型的数据
  • 在 grunt.registerTask() 中可以通过 grunt.config() 获取配置,如果配置中的属性值是对象的话,config 可以使用点的方式定位对象中的属性值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = (grunt) => {
// grunt.initConfig() 用于为任务添加一些配置选项
grunt.initConfig({
// 键一般对应任务的名称
// 值可以是任意类型的数据
foo: {
bar: "baz",
},
});

grunt.registerTask("foo", () => {
// 任务中可以使用 grunt.config() 获取配置
console.log(grunt.config("foo"));
// 如果属性值是对象的话,config 中可以使用点的方式定位对象中属性的值
console.log(grunt.config("foo.bar"));
});
};

Grunt 多目标任务(子任务)

  • grunt.registerMultiTask()
    • 通过 grunt.registerMultiTask() 定义多目标
    • 多目标模式,可以让任务根据配置形成多个子任务
    • grunt.registerMultiTask() 第一个参数是任务的名字,第二个参数是任务执行过程要做的事情
    • 多任务,要使用 grunt.initConfig()为任务配置目标
    • 当运行任务时会去执行目标(子任务),如果要运行指定的目标可以使用“:目标名”,yarn grunt build:css
    • 可以通过 this.target 拿到当前执行的目标名称,通过 this.data 拿到目标的配置数据
    • 可以通过 this.options()方法拿到配置选项
  • grunt.initConfig()
    • 参数为对象,键名与任务名相同(registerMultiTask()方法的第一个参数),值必须为对象,对象中每个属性名就是目标名称
    • 除了 options 键,其他都会作为目标,options 指定的信息会作为这个任务的配置选项
    • 目标当中也可以添加 options,运行目标是可以获取相应的 options(目标 options 覆盖任务 options),如果目标没指定而任务指定了则可以获取任务的 options
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = (grunt) => {
grunt.initConfig({
build: {
// ‘build’名与
options: {
foo: "bar",
},
css: {
options: {
foo: "baz",
},
},
js: "2",
},
});
grunt.registerMultiTask("build", function () {
console.log(this.options());
console.log(`task: ${this.target}, data:${this.data}`);
});
};

Grunt 插件的使用

  • 通过 npm 安装
  • 在 gruntfile.js 文件中通过 grunt.loadNpmTasks() 方法把插件中提供的任务加载进来,根据插件文档配置
  • 在 grunt.initConfig() 方法中为任务添加配置选项
1
2
3
4
5
6
7
8
9
10
11
12
13
// 删除文件的任务

module.exports = (grunt) => {
grunt.initConfig({
clean: {
// temp:'temp/app.js' // 需要清除的文件,可以使用通配符"*",“**”表示找到该目录下的文件以及子目录的文件(即该目录下的所有文件)
temp: "temp/*.txt",
},
});

// 通过 loadNpmTasks 加载插件中提供的任务
grunt.loadNpmTasks("grunt-contrib-clean");
};

Grunt 常用插件及总结

  • grunt-sass
    • 安装 yarn grunt-sass sass --dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const sass = require("sass");
module.exports = (grunt) => {
grunt.initConfig({
sass: {
options: {
implementation: sass,
},
main: {
// 目标main(随便写)
files: {
"dist/css/main.css": "src/scss/main.scss", // 键是输出文件,值是输入文件
},
},
},
});
// 通过 loadNpmTasks 加载插件中提供的任务
grunt.loadNpmTasks("grunt-sass");
};
  • load-grunt-tasks
    自动加载所有的 grunt 插件
    • 安装 yarn add load-grunt-tasks --dev
1
2
3
4
5
const loadGruntTasks = require("load-grunt-tasks");
module.exports = (grunt) => {
// grunt.loadNpmTasks('grunt-sass')
loadGruntTasks(grunt); // 自动加载所有的 grunt 插件中的任务
};
  • babel
    • 安装 yarn add grunt-babel @babel/core @babel/preset-env --dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const sass = require("sass");
const loadGruntTasks = require("load-grunt-tasks");

module.exports = (grunt) => {
grunt.initConfig({
sass: {
options: {
implementation: sass,
},
main: {
// 目标main(随便写)
files: {
"dist/css/main.css": "src/scss/main.scss", // 键是输出文件,值是输入文件
},
},
},
babel: {
options: {
sourceMap: true,
presets: ["@babel/preset-env"],
},
main: {
files: {
"dist/js/app.js": "src/js/app.js",
},
},
},
});

// grunt.loadNpmTasks('grunt-sass')
loadGruntTasks(grunt); // 自动加载所有的 grunt 插件中的任务
};
  • grunt-contrib-watch 自动编译
    • 安装 yarn add grunt-contrib-watch --dev
    • 执行 yarn grunt watch,当监听的文件发生变化的时候会执行相应的任务,但是刚开始的时候不会执行,可以使用 grunt.registerTask() 方法,执行yarn grunt
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
const sass = require("sass");
const loadGruntTasks = require("load-grunt-tasks");

module.exports = (grunt) => {
grunt.initConfig({
sass: {
options: {
implementation: sass,
},
main: {
// 目标main(随便写)
files: {
"dist/css/main.css": "src/scss/main.scss", // 键是输出文件,值是输入文件
},
},
},
babel: {
options: {
sourceMap: true,
presets: ["@babel/preset-env"],
},
main: {
files: {
"dist/js/app.js": "src/js/app.js",
},
},
},
watch: {
js: {
files: ["src/js/*.js"], // 监听的文件
tasks: ["babel"], // 当文件发生改变要执行什么任务
},
css: {
files: ["src/scss/*.scss"], // 监听的文件
tasks: ["sass"], // 当文件发生改变要执行什么任务
},
},
});

// grunt.loadNpmTasks('grunt-sass')
loadGruntTasks(grunt); // 自动加载所有的 grunt 插件中的任务
grunt.registerTask("default", ["sass", "babel", "watch"]);
};

Gulp

Gulp 的基本使用

  • 执行 yarn init -y
  • 安装 yarn add gulp --dev
  • 在根目录新建 gulpfile.js,此文件作为 gulp 入口文件
  • 导出函数,导出的函数都会作为 gulp 任务
  • gulp 的任务函数都是异步的,可以通过调用回调函数标识任务完成
  • default 是默认任务,在运行是可以省略任务名参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// gulp入口文件

// 导出的函数都会作为 gulp 任务
exports.foo = (done) => {
// gulp 的任务函数都是异步的
// 可以通过调用回调函数标识任务完成
console.log("foo task working~");
done(); // 标识任务执行完成
};

// default 是默认任务
// 在运行是可以省略任务名参数
exports.default = (done) => {
console.log("default task working~");
done();
};

Gulp 的组合任务

series(),parallel() 可以创建并行任务和串行任务

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
const { series, parallel } = require("gulp");

// 可以没导出的理解为私有任务

const task1 = (done) => {
setTimeout(() => {
console.log("task1 working~");
done();
}, 1000);
};

const task2 = (done) => {
setTimeout(() => {
console.log("task2 working~");
done();
}, 1000);
};

const task3 = (done) => {
setTimeout(() => {
console.log("task3 working~");
done();
}, 1000);
};

exports.foo = series(task1, task2, task3); // // 让多个任务按照顺序依次执行
exports.bar = parallel(task1, task2, task3); // // 让多个任务同时执行

Gulp 的异步任务

  • 回调函数
    • gulp 的任务为异步,异步函数无法知道是否执行完成,可以通过回调解决(done),任务执行完后调用回调函数通知 gulp 任务执行完成
    • 回调函数叫做一种错误优先的回调函数,在执行过程中报错,阻止剩下任务执行可以通过给回调函数的第一个参数指定一个错误对象
1
2
3
4
exports.callback_error = (done) => {
console.log("callback task~");
done(new Error("task failed!"));
};
  • promise
    • 使用 promise 通知任务执行完成
    • 任务中返回一个 Promise 对象,如果 resolve 意味着任务结束了,不需要返回任何的值,如果 reject,gulp 会认为是一个失败任务结束后续任务的执行
1
2
3
4
5
6
7
8
exports.promise = () => {
console.log("promise task~");
return Promise.resolve();
};
exports.promise = () => {
console.log("promise task~");
return Promise.reject(new Error("task failed~"));
};
  • async await
1
2
3
4
5
6
7
8
9
const timeout = (time) => {
return new Promise((resolve) => {
setTimeout(resolve, time);
});
};
exports.async = async () => {
await timeout(1000);
console.log("async task~");
};
  • stream
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// exports.stream = () => {
// const readStream = fs.createReadStream('package.json')
// const writeStream = fs.createWriteStream('temp.txt')
// readStream.pipe(writeStream)
// return readStream
// }
exports.stream = (done) => {
const readStream = fs.createReadStream("package.json");
const writeStream = fs.createWriteStream("temp.txt");
readStream.pipe(writeStream);
readStream.on("end", () => {
done();
});
};

Gulp 构建过程核心工作原理

  • 工作过程:输入(读取流) => 加工(转换流) => 输出(写入流)
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
const fs = require("fs");
const { Transform } = require("stream");

exports.default = () => {
// 文件读取流
const read = fs.createReadStream("normalize.css");
// 文件写入流
const write = fs.createWriteStream("normalize.min.css");
// 文件转换流
const transform = new Transform({
transform: (chunk, encoding, callback) => {
// 核心转换过程
// chunk => 读取流中读取到的内容(Buffer)
const input = chunk.toString();
// 替换空格、注释(压缩代码)
const output = input.replace(/\s+/g, "").replace(/\/\*.+?\*\//g, "");
// 把内容传出
callback(null, output); // 错误优先的回调函数
},
});

// 把读取出来的文件流导入写入文件流
read
.pipe(transform) // 转换
.pipe(write); // 写入
return read;
};

Gulp 文件操作 API

  • src:读取流,方法参数为文件路径,可以使用通配符匹配所以的文件
  • dest:写入流
1
2
3
4
5
6
7
8
9
10
11
const { src, dest } = require("gulp");
const cleanCss = require("gulp-clean-css"); // 压缩代码转换流模块
const rename = require("gulp-rename"); // 重命名扩展名模块

exports.default = () => {
// gulp读取流 和 写入流,可以使用统配符
return src("src/*.css")
.pipe(cleanCss())
.pipe(rename({ extname: ".min.css" })) // 指定重命名的扩展名
.pipe(dest("dist"));
};

Gulp 案例 - 样式编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { src, dest } = require("gulp");

// 该依赖会把下划线文件名的文件认为是主文件中依赖的文件,所以不会吧下划线文件名的文件转换
const sass = require("gulp-sass");

const style = () => {
// base 基准路径,会把src后面的路径保留下来,否则不会按照原本路径输出
return src("src/assets/styles/*.scss", { base: "src" })
.pipe(sass({ outputStyle: "expanded" })) // outputStyle编译后“}”位于后面
.pipe(dest("dist"));
};

module.exports = {
style,
};

Gulp 案例 - 脚本编译

安装 babel 依赖 yarn add babel @babel/core @babel/preset-env --dev

1
2
3
4
5
6
7
8
9
10
11
12
13
const { src, dest } = require("gulp");
const babel = require("gulp-babel");

const script = () => {
// base 基准路径,会把src后面的路径保留下来,否则不会按照原本路径输出
return src("src/assets/scripts/*.js", { base: "src" })
.pipe(babel({ presets: ["@babel/preset-env"] })) // 如果不添加presets转换无效
.pipe(dest("dist"));
};

module.exports = {
script,
};

Gulp 案例 - 页面模板编译

安装 swig 模板引擎转换插件 yarn add gulp-swig --dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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
const swig = require("gulp-swig");

const data = {
menus: [
{
name: "Home",
icon: "aperture",
link: "index.html",
},
{
name: "Features",
link: "features.html",
},
{
name: "About",
link: "about.html",
},
{
name: "Contact",
link: "#",
children: [
{
name: "Twitter",
link: "https://twitter.com/w_zce",
},
{
name: "About",
link: "https://weibo.com/zceme",
},
{
name: "divider",
},
{
name: "About",
link: "https://github.com/zce",
},
],
},
],
pkg: require("./package.json"),
date: new Date(),
};
const page = () => {
// 如果存在子目录:'src/**/*.html'
return (
src("src/*.html", { base: "src" })
// data 把数据传入到模板中,渲染对应的数据
// cache 防止模板缓存导致页面不能及时更新
.pipe(swig({ data, defaults: { cache: false } }))
.pipe(dest("dist"))
);
};
module.exports = {
page,
};

Gulp 案例 - 图片和字体文件转换

安装图片压缩转换插件 yarn add gulp-imagemin --dev

1
2
3
4
5
6
7
8
9
10
11
12
13
const imagemin = require("gulp-imagemin");

const image = () => {
return src("src/assets/images/**", { base: "src" })
.pipe(imagemin())
.pipe(dest("dist"));
};

const font = () => {
return src("src/assets/fonts/**", { base: "src" })
.pipe(imagemin())
.pipe(dest("dist"));
};

Gulp 案例 - 其他文件及文件清除

打包是自动删除 dist 文件目录,安装插件 yarn add del --dev,这个模块不是 gulp 的插件,只是可以在 gulp 中使用

1
2
3
4
5
6
7
8
9
10
const del = require("del");

const extra = () => {
// 转换其他文件
return src("public/**", { base: "public" }).pipe(dest("dist"));
};

const clean = () => {
return del(["dist"]);
};

Gulp 案例 - 自动加载插件

安装插件 yarn add gulp-load-plugins --dev

1
2
3
4
5
6
7
8
9
10
const loadPlugins = require("gulp-load-plugins");
// 自动加载所有的插件
const plugins = loadPlugins(); // 把‘gulp-’去掉,如果后面有多个‘-’会变为驼峰命名

const style = () => {
// base 基准路径,会把src后面的路径保留下来,否则不会按照原本路径输出
return src("src/assets/styles/*.scss", { base: "src" })
.pipe(plugins.sass({ outputStyle: "expanded" })) // 使用plugins.sass
.pipe(dest("dist"));
};

Gulp 案例 - 开发服务器

热更新开发服务器
安装模块 yarn add browser-sync --dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const browserSync = require("browser-sync");
// 创建服务器
const bs = browserSync.create();
// 服务任务
// 此时dist文件目录的样式指向node_modules,需要修改配置
const serve = () => {
bs.init({
notify: false, // 关闭notify提示
port: 8080, // 端口
// open: false, // 是否自动打开浏览器
files: "dist/**", // browserSync服务监听的路径通配符,文件发生改变自动刷新浏览器
server: {
baseDir: "dist",
routes: {
// 优先于baseDir,请求发生后会先看routes是否有对应的配置,如果有就走routes配置,否则就走baseDir对应的文件
"/node_modules": "node_modules", // 键是请求的前缀
},
},
});
};

Gulp 案例 - 监视变化以及构建优化

使用 gulp 提供的 watch()方法,第一个参数是监听的文件,第二个参数是执行任务

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
const { watch } = require("gulp");
// 服务任务
// 此时dist文件目录的样式指向node_modules,使用路由映射
const serve = () => {
watch("src/assets/styles/*.scss", style);
watch("src/assets/scripts/*.js", script);
watch("src/*.html", page);

// 开发阶段对图片、字体不构建,提高构建效率
// watch('src/assets/images/**', image)
// watch('src/assets/fonts/**', font)
// watch('public/**', extra)

// 文件变化后自动更新浏览器,浏览器重新发起请求
watch(
["src/assets/images/**", "src/assets/fonts/**", "public/**"],
bs.reload
);

bs.init({
notify: false, // 关闭notify提示
port: 8080, // 端口
// open: false, // 是否自动打开浏览器
files: "dist/**", // browserSync服务监听的路径通配符,文件发生改变自动刷新浏览器
server: {
baseDir: ["dist", "src", "public"], // 如果资源在dist找不到就到src找,还找不到就到public找
routes: {
// 优先于baseDir,请求发生后会先看routes是否有对应的配置,如果有就走routes配置,否则就走baseDir对应的文件
"/node_modules": "node_modules", // 键是请求的前缀
},
},
});
};

Gulp 案例 - useref 文件引用处理

安装 yarn add gulp-useref --dev

把 HTML 文件中的构建注释去除、把构建注释中的内容合并到一个文件(比如上边提到的引用了 node_modules 目录中的文件)

1
2
3
4
5
6
7
8
const useref = () => {
return (
src("dist/*.html", { base: "dist" })
// 去除HTML文件构建注释、把构建注释的内容合并到一个文件中
.pipe(plugins.useref({ searchPath: ["dist", "."] }))
.pipe(dest("dist"))
);
};

Gulp 案例 - 文件压缩

对 html、js、css 压缩

安装插件 yarn add gulp-htmlmin gulp-uglify gulp-clean-css --dev

判断读取流的文件使用对应的压缩插件,安装 yarn add gulp-if --dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const useref = () => {
return (
src("dist/*.html", { base: "dist" })
// 去除HTML文件构建注释、把构建注释的内容合并到一个文件中
.pipe(plugins.useref({ searchPath: ["dist", "."] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // 压缩js
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // 压缩css
.pipe(
plugins.if(
/\.html$/,
plugins.htmlmin({
collapseWhitespace: true,
minifyCSS: true, // 把html文件中的style压缩
minifyJS: true, // 把script中的js压缩
})
)
) // 压缩html
.pipe(dest("release"))
); // 写入到另外一个文件,防止读、写同一个文件引发错乱
};

Gulp 案例 - 重新规划构建过程

因为 useref 任务导致项目目录结构发生改变,所以构建时先把 html、css、js 文件放到一个临时目录中,开启的服务读取文件也是读取临时目录中的文件;在 useref 任务中把临时目录中的 html、css、js 取出压缩放进最终目录(dist)

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
/ 转换样式
const style = () => {
// base 基准路径,会把src后面的路径保留下来,否则不会按照原本路径输出
return src('src/assets/styles/*.scss', { base: 'src' })
.pipe(plugins.sass({ outputStyle: 'expanded' })) // outputStyle编译后“}”位于后面
.pipe(dest('temp'))
.page(bs.reload({ stream: true })) // 把信息以流的方法推到浏览器
}

// 转换脚本
const script = () => {
return src('src/assets/scripts/*.js', { base: 'src' })
.pipe(plugins.babel({ presets: ['@babel/preset-env'] }))
.pipe(dest('temp'))
.page(bs.reload({ stream: true }))
}

// 转换模板
const page = () => {
// 如果存在子目录:'src/**/*.html'
return src('src/*.html', { base: 'src' })
// data 把数据传入到模板中,渲染对应的数据
// cache 防止模板缓存导致页面不能及时更新
.pipe(plugins.swig({ data, defaults: { cache: false } }))
.pipe(dest('temp'))
.page(bs.reload({ stream: true }))
}

const useref = () => {
return src('temp/*.html', { base: 'temp' })
// 去除HTML文件构建注释、把构建注释的内容合并到一个文件中
.pipe(plugins.useref({ searchPath: ['temp', '.'] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // 压缩js
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // 压缩css
.pipe(plugins.if(/\.html$/, plugins.htmlmin({
collapseWhitespace: true,
minifyCSS: true, // 把html文件中的style压缩
minifyJS: true // 把script中的js压缩
}))) // 压缩html
.pipe(dest('dist')) // 写入到另外一个文件,防止读、写同一个文件引发错乱
}

完整构建

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
const { src, dest, parallel, series, watch } = require("gulp");

const del = require("del");
const browserSync = require("browser-sync");

const loadPlugins = require("gulp-load-plugins");

// 自动加载所有的插件
const plugins = loadPlugins(); // 把‘gulp-’去掉,如果后面有多个‘-’会变为驼峰命名

// 创建服务器
const bs = browserSync.create();

const data = {
menus: [
{
name: "Home",
icon: "aperture",
link: "index.html",
},
{
name: "Features",
link: "features.html",
},
{
name: "About",
link: "about.html",
},
{
name: "Contact",
link: "#",
children: [
{
name: "Twitter",
link: "https://twitter.com/w_zce",
},
{
name: "About",
link: "https://weibo.com/zceme",
},
{
name: "divider",
},
{
name: "About",
link: "https://github.com/zce",
},
],
},
],
pkg: require("./package.json"),
date: new Date(),
};

// 删除文件目录
const clean = () => {
return del(["dist", "temp"]);
};

const style = () => {
// base 基准路径,会把src后面的路径保留下来,否则不会按照原本路径输出
return src("src/assets/styles/*.scss", { base: "src" })
.pipe(plugins.sass({ outputStyle: "expanded" })) // outputStyle编译后“}”位于后面
.pipe(dest("temp"))
.pipe(bs.reload({ stream: true })); // 把信息以流的方法推到浏览器
};

const script = () => {
return src("src/assets/scripts/*.js", { base: "src" })
.pipe(plugins.babel({ presets: ["@babel/preset-env"] }))
.pipe(dest("temp"))
.pipe(bs.reload({ stream: true }));
};

const page = () => {
// 如果存在子目录:'src/**/*.html'
return (
src("src/*.html", { base: "src" })
// data 把数据传入到模板中,渲染对应的数据
// cache 防止模板缓存导致页面不能及时更新
.pipe(plugins.swig({ data, defaults: { cache: false } })) // 防止模板缓存导致页面不能及时更新
.pipe(dest("temp"))
.pipe(bs.reload({ stream: true }))
);
};

// 压缩图片
const image = () => {
return src("src/assets/images/**", { base: "src" })
.pipe(plugins.imagemin())
.pipe(dest("dist"));
};

// 压缩字体
const font = () => {
return src("src/assets/fonts/**", { base: "src" })
.pipe(plugins.imagemin())
.pipe(dest("dist"));
};

// 转换其他文件
const extra = () => {
// 转换其他文件
return src("public/**", { base: "public" }).pipe(dest("dist"));
};

// 服务任务
// 此时dist文件目录的样式指向node_modules,需要修改配置
const serve = () => {
watch("src/assets/styles/*.scss", style);
watch("src/assets/scripts/*.js", script);
watch("src/*.html", page);

// 开发阶段对图片、字体不构建,提高构建效率
// watch('src/assets/images/**', image)
// watch('src/assets/fonts/**', font)
// watch('public/**', extra)

// 文件变化后自动更新浏览器,浏览器重新发起请求
watch(
["src/assets/images/**", "src/assets/fonts/**", "public/**"],
bs.reload
);

bs.init({
notify: false, // 关闭notify提示
port: 8080, // 端口
// open: false, // 是否自动打开浏览器
files: "dist/**", // browserSync服务监听的路径通配符,文件发生改变自动刷新浏览器
server: {
baseDir: ["temp", "src", "public"], // 如果资源在dist找不到就到src找,还找不到就到public找
routes: {
// 优先于baseDir,请求发生后会先看routes是否有对应的配置,如果有就走routes配置,否则就走baseDir对应的文件
"/node_modules": "node_modules", // 键是请求的前缀
},
},
});
};

const useref = () => {
return (
src("temp/*.html", { base: "temp" })
// 去除HTML文件构建注释、把构建注释的内容合并到一个文件中
.pipe(plugins.useref({ searchPath: ["temp", "."] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // 压缩js
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // 压缩css
.pipe(
plugins.if(
/\.html$/,
plugins.htmlmin({
collapseWhitespace: true,
minifyCSS: true, // 把html文件中的style压缩
minifyJS: true, // 把script中的js压缩
})
)
) // 压缩html
.pipe(dest("dist"))
); // 写入到另外一个文件,防止读、写同一个文件引发错乱
};

// 并行执行任务
const compile = parallel(style, script, page);

// 串行执行任务,上线前执行的任务
const build = series(
clean,
parallel(series(compile, useref), image, font, extra)
);

const develop = series(compile, serve);

module.exports = {
clean,
build,
develop,
};

Gulp 案例 - 补充

  • 不需要全部任务都导出,只需要导出部分用到的就行
  • 把任务写入到 package.json 文件中的 scripts 属性
1
2
3
4
5
"scripts": {
"clean": "gulp clean",
"build": "gulp build",
"dev": "gulp develop"
},

封装工作流 - 提取 gulpfile

  • 把 gulp 构建文件封装打包为模块发布到 npm
  • 如果模块还在本地没有发布到 npm 可以使用 yarn link 的方式把构建模块 link 到全局,然后在项目中 yarn link zce-pages (脚手架名) 的方式安装进项目(相当于 yarn add xxx)
  • 如果有不应该提取到 gulp 构建模块的内容,应把它们抽离到项目配置文件(约定文件名),然后 gulp 构建模块读取该配置文件
    • 在 gulp 构建模块中获取抽离的配置,process.cwd()返回当前命令行所在的工作目录
  • 如果报 babel 相关的错误,如@babel/preset-env,因为 gulp 构建模块使用 babel 转换语法,@babel/preset-env 会查找根目录的 node_modules 目录中的@babel/preset-env 模块,所以就报错;可以在 gulp 构建模块 javascript 任务的 babel 修改为 {presets: [require('@babel/preset-env')]}
  • 如果是通过 link 到全局的话,在构建项目时会报 gulp gulp-cli 相关的错误,是因为构建时在 node_modules 找不到 gulp 相关的命令,这时可以先在项目中安装依赖 yarn add gulp gulp-cli --dev
  • 在入口文件 gulpfile.js 导入 gulp 构建模块
1
module.exports = require("zce-pages");

封装工作流 - 抽象路径配置

封装的 gulp 构建模块中任务使用的路径应该可配置,使任务中的文件路径可根据开发者配置
模块中默认路径配置;在开发项目中也可以传入该配置,让封装的 gulp 构建模块灵活

1
2
3
4
5
6
7
8
9
10
11
12
13
build: {
src: 'src',
dist: 'dist',
temp: 'temp',
public: 'public',
paths: {
styles: 'assets/styles/*.scss',
scripts: 'assets/scripts/*.js',
pages: '*.html',
images: 'assets/images/**',
fonts: 'assets/fonts/**'
}
}

封装工作流 - 包装 Gulp CLI

在 gulp 构建模块添加一个 cli,在 cli 里自动传递参数(参数:–gulpfile): yarn gulp build --gulpfile,然后在内部调用 gulp 提供的可执行程序

  • 在模块根目录新建目录 bin,在目录下新建 js 文件作为 cli 的入口,入口文件的文件头需要添加 #!/usr/bin/env node
  • 在 package.json 文件中添加‘bin’字段,值为该入口文件路径
  • 把 gulp 的调用和传入的参数放在该文件中
  • 在 window 系统执行 gulp 构建命令,会去 node_modules 下的.bin 目录下的 gulp.cmd 文件,如下代码;在该文件中根据判断执行当前目录外面的 gulp-cli/bin/gulp.js,该文件就 require ('gulp-cli')(),所以我们只需在入口文件中引入这个 gulp.js: require('gulp/bin/gulp')
1
2
3
4
5
6
7
8
// %~dp0: 当前目录
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\gulp-cli\bin\gulp.js" %*
) ELSE (
@SETLOCAL
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\gulp-cli\bin\gulp.js" %*
)
  • 接下来要指定 gulpfile 和 cmd 路径,命令行传递的产生可以通过process.argv获取,该属性返回一个数组
  • 从中可以看出gulp-cli是通过process.argv拿到参数的,可以在代码运行前 push 需要传递的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
// 入口文件

// 参数cwd:告诉gulp工作目录是命令行所在的目录
process.argv.push("--cwd");
process.argv.push(process.cwd());

// gulpfile:告诉gulp gulpfile的目录
process.argv.push("--gulpfile");
process.argv.push(require.resolve(".."));
// require:载入模块
// resolve:找到模块所对应的路径;这里传递‘..’它会找到package.json中的bin字段对应的文件

require("gulp/bin/gulp");

这时把该构建模块安装在项目,项目中就不在需要 gulpfile.js 文件

封装工作流 - 发布并使用模块

  • 在发布 npm 时会把项目根目录的文件和 package.json 中的 files 字段中的目录发布到 npm 仓库,所以要在 files 字段添加一个 cli 入口目录 bin,然后yarn publish --registry https ://registry.yarnpkg.com 推到 npm 仓库
  • 在新项目中安装发布的模块,如果发布时间和安装时间间距短可能会安装到的是老版本,因为国能的镜像同步 npm 原地址需要时间,可以在淘宝镜像中搜索发布的模块,然后点击 SYNC
  • 在项目中执行 yarn zce-pages build就可以构建项目了,zce-pages 是发布到 npm 的模块名
  • 也可以在项目的 package.json 文件中的 scripts 字段添加命令
1
2
3
4
5
"scripts": {
"clean": "zce-pages clean",
"build": "zce-pages build",
"develop": "zce-pages develop"
}

Yeoman

构建定制化的脚手架.

安装

1
yarn global add yo
1
yarn global add generator-node
1
yo node
1
yo node:cli

小问题

这里执行当前项目的命令时会遇到权限问题

1
yarn link
1
sudo chmod -R 777 目录名

这里需要目录名,要么是绝对路径,要么可以先返回到上一层,再给这个文件夹权限.

自定义 generator

Yeoman 使用步骤总结

  • 明确需求
  • 找到合适的Generator
  • 全局范围安装找到 Generator
  • 通过 yo 运行对应的 Generator
  • 通过命令行交互填写选项
  • 生成你需要的项目结构

创建 Generator 模块

Generator 本质上就是一个 NPM 模块
Generator 基本结构

1
2
3
4
5
6
|— generator-sample
| |— generators/ ------------- 生成器目录
| | |— app/ ----------------- 默认生成器目录
| |— components -------- 模板文件
| |— index.js ---------- 默认生成器实现
|— package.json --------------- 模块包配置文件

如果需要提供多个 Sub Generator,可以在 app 目录添加新的生成器目录

1
2
3
4
5
6
7
8
|— generator-sample
| |— generators/ ------------ 生成器目录
| | |— app/ ---------------- 默认生成器目录
| | | |— components ------- 模板文件
| | | |— index.js --------- 默认生成器实现
+| |— component/ ------------- 其他生产器目录
+| |— index.js ------------ 其他生成器实现
|— package.json -------------- 模块包配置文件

Yeoman Generator 模快的名称必须是generator-,否则 Yeoman 后续工作无法找到你的生成器模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/index.js

// 此文件为 Generator 的核心入口
// 需要导出一个继承自 Yeoman Generator 的类
// Yeoman Generator 在工作时会自动调用我们在此类型中定义的一些生命周期方法
// 我们在这些生命周期方法中通过调用父类提供的一些工具方法实现一些功能,例如文件写入

const Generator = require("yeoman-generator");

module.exports = class extends Generator {
writing() {
// Yeoman 自动在生成文件阶段调用此方法
// 我们尝试往项目目录中写入文件
this.fs.write(this.destinationPath("temp.txt"), Math.random().toString());
}
};
  • 在生成器目录终端执行yarn link
  • 在新建项目终端执行yo sample

根据模板创建文件

在生成器目录下添加 template 目录,模板中遵循 EJS 模板语法

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = class extends Generator {
writing() {
// Yeoman 自动在生成文件阶段调用此方法
// 通过模板方式写入文件到目标目录
// 模板文件路径
const tmpl = this.templatePath("foo.txt"); // 自动获取当前生成器下template目录下的文件路径
// 输出目标路径
const outPut = this.destinationPath("foo.txt");
// 模板数据上下文
const context = { title: "Hello zyh~", success: false };
this.fs.copyTpl(tmpl, outPut, context);
}
};

接收用户输入(终端命令行交互)prompting()方法

Yeoman 在询问用户环节会调用prompting()方法,在此方法中可以调用父类的 prompt() 方法发出对用户的命令行询问
prompt()方法参数为一个数组,数组的每一个元素是一个问题对象(命令行交互)

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
const Generator = require("yeoman-generator");

module.exports = class extends Generator {
prompting() {
// Yeoman 在询问用户环节会自动调用此方法
// 在此方法中可以调用父类的 prompt() 方法发出对用户的命令行询问

// 数组的每个元素都是一个问题(终端命令行交互)
return this.prompt([
{
type: "input", // input:使用用户输入的方式
name: "name", // value是最终得到结果的键(用户输入的值的键)
message: "Your project name", // 终端界面给用户的提示(问题)
default: this.appname, // 默认值,如果用户不输入则使用默认值,appname 为项目生成目录名称
},
]).then((answers) => {
// 用户交互选择的结果
// answers => { name: 'user input value' }
this.answers = answers;
});
}

writing() {
// Yeoman 自动在生成文件阶段调用此方法

// 模板文件路径
const tmp = this.templatePath("bar.html");
//输出模板路径
const outPut = this.destinationPath("bar.html");
// 模板数据上下文
const context = this.answers;
this.fs.copyTpl(tmp, outPut, context);
}
};

生成 vue 项目结构

如果模板文件中有'<%= BASE_URL %>'需要添加一个%进行转义,'<%%= BASE_URL %>'

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
const Generator = require("yeoman-generator");

module.exports = class extends Generator {
prompting() {
return this.prompt([
{
type: "input",
name: "name",
message: "Your project name",
default: this.appname,
},
{
type: "list",
name: "tool",
choices: ["yarn", "npm"],
message: "请选择版本管理工具",
default: "yarn",
},
]).then((answers) => {
this.answers = answers;
});
}
writing() {
// 把每一个文件都通过模板转换到目标路径

// templates目录下的模板文件相对路径
const templates = [
".browserslistrc",
".editorconfig",
".env.development",
".env.production",
".eslintrc.js",
".gitignore",
"babel.config.js",
"package.json",
"postcss.config.js",
"README.md",
"public/favicon.ico",
"public/index.html",
"src/App.vue",
"src/main.js",
"src/router.js",
"src/assets/logo.png",
"src/components/HelloWorld.vue",
"src/store/actions.js",
"src/store/getters.js",
"src/store/index.js",
"src/store/mutations.js",
"src/store/state.js",
"src/utils/request.js",
"src/views/About.vue",
"src/views/Home.vue",
];
templates.forEach((item) => {
const temp = this.templatePath(item);
const outPut = this.destinationPath(this.answers.name + "/" + item);
this.fs.copyTpl(temp, outPut, this.answers);
});
}

// 进入项目目录,根据用户选择的管理工具安装依赖
install() {
const { name, tool } = this.answers;
const npmDir = process.cwd() + "/" + name;
process.chdir(npmDir);
tool === "yarn" ? this.yarnInstall() : this.npmInstall();
// this.installDependencies({
// bower: true,
// npm: true
// })
}
};

发布 Generator

  1. 上传 GitHub
  2. yarn publish填写相关信息后,如果出现错误是因为使用淘宝镜像取代官方镜像,所以往 npm 仓库发布代码就会出现问题,淘宝镜像是只读镜像;
    • 可以修改本地的镜像配置;
    • yarn publish --registry=https://registry.yarnpkg.com(官方的镜像)
  3. 发布成功

Plop

小型脚手架工具.
相当于一个可定制化的自动生成代码文件的工具.
比如有一套文件,包括 js,css,还要复制很多几乎相同的页面文件,使用 Plop 就可以一键生成.

可以帮助我们自动生成文件

  • 将 plop 模快作为项目开发依赖安装yarn add plop --dev
  • 在项目根目录新建文件 plopfile.js 作为 Plop 入口文件,需要导出一个函数,此函数接收一个 plop 对象,用于创建生成器任务
  • 在 plopfile.js 文件中定义脚手架任务
  • 编写用于生成特定类型文件的模板
  • 通过 Plop 提供的 CLI 运行脚手架任务yarn plop component
  • 如果执行 yarn plop Home,则直接把Home作为文件名创建相应的目录文件,如何输入的是yarn plop component,则会提示输入组件名
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
module.exports = (plop) => {
// 第一个:生成器名字 第二个:生成器的配置选项
plop.setGenerator("component", {
description: `cretate a component`,
prompts: [
// 命令行问题
{
type: "input",
name: "name",
message: "component name",
default: "MyComponent",
},
{
type: "input",
name: "path",
message: "output path",
default: "src/components",
},
],
// 完成命令行交互后,执行的一些动作
actions: [
{
type: "add", // 代表添加文件
path: "{{path}}/{{name}}/{{name}}.js", // 输出路径;name为上面命令行交互中得到的name值
templateFile: "plop-templates/component.hbs", // 添加的文件的模板文件
},
{
type: "add", // 代表添加文件
path: "{{path}}/{{name}}/{{name}}.css", // name为上面命令行交互中得到的name值
templateFile: "plop-templates/component.css.hbs", // 添加的文件的模板文件
},
{
type: "add", // 代表添加文件
path: "{{path}}/{{name}}/{{name}}.test.js", // name为上面命令行交互中得到的name值
templateFile: "plop-templates/component.test.hbs", // 添加的文件的模板文件
},
],
});
};

脚手架的工作原理

工作原理:启用脚手架后询问用户一些问题,将用户回答结果结合一些模板文件生成项目结构

  • 新建一个项目
  • yarn init -y初始化,在 package.json 文件下添加bin字段,用于指定 CLI 的入口文件:如:"bin": "cli.js"
  • 在项目新增 bin 入口文件 cli.js
  • Node CLI 应用入口文件必须要有这样的文件头:#!/usr/bin/env node,如果是 Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755,具体就是通过 chmod 755 cli.js 实现修改
  • 脚手架的工作过程:
    • 通过命令行交互询问用户问题
    • 根据用户回答的结果生成文件
  • node 中发起命令行交互方式使用inquirer库。安装 inquirer 库 yarn add inquirer;inquirer 库提供一个 prompt 方法用于发起命令行交互
  • commander库,命令行工具
  • chalk库,命令行输出美化
  • child_process模块,nodejs 的子进程模块,可以运行命令、检查 npm、yarn
    • child_process.execSync('yarnpkg --version').toString()
    • child_process.spawn('npm', ['isntall', 'webpack', '-D'], {shell: true})
  • download-git-repo库,下载仓库模板
  • 编写用于生成特定类型文件的模板
  • 命令行执行yarn link把模块 link 到全局
  • 新建项目,在项目命令行执行sample-scaffolding
  • 另外 webpack 的ProgressPlugin插件,用于监听编译进度
  • webpack 启动的 devServer,可以通过 nodejs 的os模块获取本机 IP 地址
1
2
3
4
sample-scaffolding
|— templates
|— cli.js
|— package.json
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
const fs = require("fs");
const path = require("path");
const inquirer = require("inquirer");
const ejs = require("ejs");

inquirer
.prompt([
{
// 命令行问题
type: "input",
name: "name",
message: "Project name?",
},
])
.then((anwsers) => {
// 用户回答
// console.log(anwsers)
// 根据用户回答的结果生成文件

// 模板目录
const tmplDir = path.join(__dirname, "templates");
// 目标目录
const destDir = process.cwd(); // 输出目录(命令行在那执行,就输出到哪个目录)
// 将模板下的文件全部转换到目标目录
fs.readdir(tmplDir, (err, files) => {
if (err) throw err;
files.forEach((file) => {
// 通过模板引擎渲染文件
ejs.renderFile(path.join(tmplDir, file), anwsers, (err, result) => {
if (err) throw err;

// 将结果写入目标文件路径
fs.writeFileSync(path.join(destDir, file), result);
});
});
});
});

还是不常用

Rollup 概述

  • Rollup 更小巧,仅仅是一款 ESModule 打包器
  • Rollup 中并不支持类似 HMR 这种高级特性
  • 提供一个充分利用 ESM 各项特性的高效打包器

Rollup 快速上手

  • 安装 yarn add rollup --dev
  • 打包 yarn rollup ./src/index.js --format iife --file dist/bundle.js
  • 入口文件 index.js,指定输出格式:–format,格式:iife(自执行函数),–file:输出到目录
  • 没使用的不会打包,自动开启 Tree Shaking

Rollup 配置文件

  • rollup 运行在 node 环境中
  • 根目录新建 rollup.config.mjs 配置文件
  • rollup 会额外处理这个配置文件,所有可以使用 ESM,注意使用 esm 需要将后缀改为.mjs
  • 配置文件导出一个对象
  • 通过 input 属性指定打包入口
  • 通过 output 属性指定输出,该属性值是一个对象,通过 file 指定输出的文件名,format 属性指定输出格式
  • 打包 pnpm rollup --config rollup.config.mjs 通过–config 告诉 rollup 使用配置文件,可以根据开发、生产指定对应的配置文件
1
2
3
4
5
6
7
export default {
input: "src/index.js",
output: {
file: "dist/bundle.js",
format: "iife",
},
};

Rollup 使用插件

  • 插件是 rollup 唯一的扩展途径
  • 安装插件 pnpm add @rollup/plugin-json -D 一个可以在代码导入 json 文件的插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import json from "@rollup/plugin-json";

export default {
input: "src/index.js",
output: {
file: "dist/bundle.js",
format: "iife",
},
plugins: [json()],
};
// index.js
// package.json 的属性会作为成员导出
import { name, version } from "../package";
console.log(name, version);

Rollup 加载 NPM 模块

  • 安装插件 pnpm add @rollup/plugin-node-resolve -D
  • 安装 lodash 的 es 版本 pnpm add lodash-es

该插件可以让 rollup 通过模块名成导入插件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";

export default {
input: "src/index.js",
output: {
file: "dist/bundle.js",
format: "iife",
},
plugins: [json(), nodeResolve()],
};
// index.js
import _ from "lodash-es";
console.log(_.camelCase("hello world"));

Rollup 加载 CommonJS 模块

  • 安装插件 yarn add @rollup/plugin-commonjs --dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

export default {
input: "src/index.js",
output: {
file: "dist/bundle.js",
format: "iife",
},
plugins: [json(), nodeResolve(), commonjs()],
};

// cjs-module.js
module.exports = { foo: "bar" };
// index.js
import cjs from "./cjs-module.js";
console.log(cjs);

Rollup 代码拆分

  • 可以 ESM 的动态导入方式(import())实现模块的按需加载
  • rollup 内部会自动处理代码拆分
  • 代码拆分的方式打包 formats 不可以使用 iife(自执行函数)
  • 自执行函数会把所有模块放到同一个函数中,可以使用 AMD 或者 Commonjs 标准,浏览器使用 AMD
  • 需要输出多个文件 output 中就不能使用 file 属性,而是使用 dis 属性 值是输出目录
1
2
3
4
5
6
7
8
9
10
11
export default {
input: "src/index.js",
output: {
dir: "dist",
format: "amd",
},
};
// index.js
import("./logger").then(({ log }) => {
log("code splitting~");
});

Rollup 多入口打包

  • 会把不同入口公共代码自动提取到单个文件中作为独立的 bundle
  • 把配置文件的 input 属性值修改为一个数组,或者使用对象的方式
  • rollup 内部会自动拆分 format 要使用 AMD
  • AMD 文件不能直接引入,要使用特定的库去加载
1
2
3
4
5
6
7
8
9
10
11
export default {
// input: ['src/index.js', 'src/album.js']
input: {
foo: "src/index.js",
bar: "src/album.js",
},
output: {
dir: "dist",
format: "amd",
},
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<!-- AMD 标准格式的输出 bundle 不能直接引用 -->
<!-- <script src="foo.js"></script> -->
<!-- 需要 Require.js 这样的库 -->
<!--data-main:指定require入口路径-->
<script
src="https://unpkg.com/requirejs@2.3.6/require.js"
data-main="foo.js"
></script>
</body>
</html>

Rollup 选用原则

  • 优势
    • 输出结果更加扁平
    • 自动移除未引用的代码
    • 打包结果依然完全可读
  • 缺点
    • 加载非 ESM 的第三方模块比较复杂
    • 模块最终都被打包到一个函数中,无法实现 HMR
    • 浏览器环境中,代码拆分功能依赖 AMD 库
  • 应用开发建议使用 webpack
  • 库、框架开发建议使用 Rollup

Parcel 打包器

  • 零配置的前端应用打包器
  • 安装 yarn add parcel-bundler --dev
  • 官方建议使用 html 文件作为打包入口
  • 打包 yarn parcel src/index.html parcel 会根据导入的模块查找 从而完成整个项目的打包,会自动开启一个服务
  • 模块热更新
1
2
3
4
5
if (module.hot) {
module.hot.accept(() => {
console.log("hmr");
});
}
  • 会自动安装模块依赖,如文件中导入 JQ,保存文件后,会自动安装模块
  • 支持导入其他类型文件,而且是零配置的 import './style.css 保存就生效了
  • 可以动态导入模块 import()
  • 生成环境打包 yarn parcel build src/index.html
  • 内部使用多进程同时工作,构建速度快

一、模块化开发

Commonjs 规范

  • Commonjs 是以同步模式加载模块
  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过module.exports导出成员
  • 通过require函数载入模块

ES Module

ESM 是 ES6 引入的 JS 标准模块化规范,它的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系和输入和输出的变量。

功能

模块功能主要由两个命令构成:exportimport
export 命令用于规定模块的对外接口,
import 命令用于输入其他模块提供的功能。

特性

通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 js 代码了

  • 自动采用严格模式,忽略‘use strict’
  • 每个 ES Module 模块都是单独的私有作用域
  • ES Module 是通过 CORS 去请求外部 js 模块的,服务器要支持 CORS
  • ES Module 的 script 标签会延迟执行脚本(页面渲染完在执行脚本)

export 导出

  • 导出
    • 导出成员使用 export 修饰成员声明的方式,如:
1
2
export var name = 'yan;
export var age = 18'
  • export 可以单独使用,如:
1
2
3
var name = "yan";
var age = 18;
export { name, age };
  • 使用 as 给导出的成员重命名,导入时就要使用重命名后的变量名,如:
1
2
var name = "yan";
export { name as firstName };
  • 如果重命名为 default 那这个成员就会做为当前模块默认导出的成员
  • 在导入这个成员就必须要给这个成员重命名,default 是一个关键词不能作为一个变量使用
  • 导入
    • 导入使用 import 导入,如:import { name } from './module.js',name 为导出的变量名
    • 导入默认导出传成员可以不使用花括号,变量名可以根据需要随便取,如:import name from './module.js'
    • 如果导入的变量名为 default,需要使用 as 重命名,如:import { default as firstName } from './module.js'

导入导出的注意事项

  • export { name, age } 该写法不是对象字面量的,如果要对象字面量的写法应该是:export default { name, age },这样花括号会被理解为对象
  • import {} from './module.js',花括号不是解构
  • 导出成员时,导出的是这个成员的引用,导出的变量是只读的(在导入的文件中无法改变变量的值),如,在导出成员的文件中在一秒后改变变量的值,在导入变量的文件中两秒后输出变量值,输出的结果是改变后的值
1
2
3
4
5
6
7
8
9
10
export var a = 1;
setTimeout(() => {
a = 2;
}, 1000);

import { a } from "./module.js";
// a = 22 // 报错,Assignment to constant variable
setTimeout(() => {
console.log(a); // 2
}, 2000);

import 导入用法

  • import 必须使用完整的路径不能省略扩展名,即使是引入 index 也要完整的路径不能省略 index
  • import 可以使用完整的 url 加载模块 import { name } from 'https://xxx.com/xxx.js'
  • 相对路径,在网页引用资源是可以省略‘./’的,但是 import 不可以省略,如果省略了就是以字母开头 ES Module 会以为在加载第三方模块,可以使用绝对路径和完整的 url
  • 如果想要执行某个模块而不用提取模块成员,花括号为空即可,这是就只会执行这个模块,不会提出成员,如:import {} from './module.js',简写:import './module.js'
  • 如果模块导出的成员特别多,而导入的都用到,可以使用星号‘_’,把这个模块的成员全部导入,在通过 as 将导入的成员全部放到一个对象中,导出的成员都会作为这个对象的属性,如:import _ as mod from './module.js'
  • 动态导入模块,不可以通过判断使用if (true){import {name} from './module.js'},如果需要动态导入,可以使用 import 函数import('./module.js'),import 函数返回的是一个 promise
  • 如果模块同时导出默认成员和命名成员,在导入时可以给默认成员重命名import { name, default as age } from './module.js',也可以import age(默认成员), { name } from './module.js'

导出导入成员

  • import 可以配合 export 使用,效果就是将导入的结果直接作为当前模块的导出成员,那么在当前作用域就不能访问这些成员,如:export { name, age } from './module',利用这种特性可以把散落的模块组织在一起,如,一个 index 把零散的模块组织一起,然后再导出
1
2
3
4
5
6
7
8
9
10
// button.js
var Button = "Button Component";
export default Button;
// avatar.js
export var Avatar = "Avatar Component";
// index.mjs
export { default as Button } from "./button.js";
export { Avatar } from "./avatar.js";
// app.js
import { Button, Avatar } from "./index.mjs";

ES Modules 浏览器环境 Polyfill

  • 在不支持 ES Module 的浏览器中,可以使用 browser-es-module-loader 第三方库,在执行的时候解析
  • 但是在支持 ES Module 的浏览器中,这样会执行两次,可以添加 script 标签的新属性 nomodule
  • nomodule:在不支持 ES Module 的浏览器工作
1
2
3
4
5
6
7
8
9
10
11
12
<script
nomodule
src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"
></script>
<script
nomodule
src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"
></script>
<script
nomodule
src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"
></script>

ES Modules in Node.js - 支持情况

  • 在 node 中使用 ES Module 文件后缀名为‘mjs’
  • 执行 node --experimental-modules 文件,experimental-modules:启用 ES Module 实验特性
  • 可以通过 import 导入原生的模块和第三方模块import fs from 'fs'; import _ form 'loadsh'
  • import { camelCase } from 'lodash',不支持,因为第三方模块都是导出默认成员;内置模块兼容了 ES Module 的提取成员方式,所以import { writeFileSync } from 'fs'支持

ES Modules in Node.js - 与 CommonJS 交互

  • ES Module 中可以导入 Commonjs 模块
  • Commonjs 中不能导入 ES Module 模块
  • Commonjs 始终只会导出一个默认成员
  • 注意 import 不是解构导出对象

ES Modules in Node.js - 与 CommonJS 的差异

  • ES Module 中没有 Commonjs 中的那些模块全局成员(require/module/exports/**filename/**dirname)
  • Commonjs 中 require/module/exports 在 ES Module 中使用 import 和 export 替代,**filename 和 **dirname 可以通过import.meta.url 可以拿到当前工作文件的文件 url 地址,可以使用原生的 url 模块的fileURLToPath()方法可以把文件 url 转换为路径
1
2
3
4
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

ES Modules in Node.js - 新版本进一步支持

  • 在 package.json 文件添加 type 字段值为 module,此时文件扩展名可以用 js,而不需要 mjs
  • 此时想要使用 Commonjs 规范,那么文件后缀名要改为 cjs

ES Modules in Node.js - Babel 兼容方案

  • 安装 babel 相关依赖 yarn add @babel/node @babel/core @babel/preset-env babel 工作依赖插件,preset 是一个插件集合
  • 执行 yarn babel-node index.js(想要转换的文件) --presets=@babel/preset-env
  • 或者添加一个.babelrc 文件
  • babel 的核心就是 preset-env,用于将 ES6 转换成 ES5 代码。
1
2
3
{
"presets": ["@babel/preset-env"]
}

二、Webpack 打包

模块打包工具的由来

  • ES Module 存在环境兼容问题
  • 模块文件过多,网络请求频繁
  • 所有的前端资源都需要模块化

Webpack 配置文件

  • webpack 默认以src/index.js 为入口文件
  • 在项目的根目录添加webpack.config.js文件,可以自定义相应的配置,该文件是运行在 nodejs 环境中
  • 该文件导出一个对象,通过导出对象的属性就可以完成相应的配置选项
  • entry 属性:webpack 打包入口文件的路径,如果是相对路径‘./’不能省略
  • output 属性:webpack 打包输出的位置,值要求是一个对象,通过对象的 filename 属性指定输出的文件名,path 属性指定输出的目录(绝对路径)

Webpack 工作模式

  • 在 webpack.config.js 文件对象中添加 mode 属性
  • mode 默认值是 production,mode 可选值有 production、development、none
  • production:自动启动优化,会把代码压缩加密
  • development:会自动优化打包速度,添加一些调式过程中的辅助
  • none:运行最原始的打包,不做额外的处理

loader

webpack 的核心就是 loader。

Webpack 资源模块加载

  • webpack 内容默认只会处理 js 文件
  • 处理其他类型的文件要安装使用对角的 loader(加载器)
  • loader 的输入和输出都是字符串.
  • 使用 loader,在webpack.config.js导出的对象中的 module 属性,添加一个 rules 数组
  • rules 数组就是针对加载其他资源模块的规则配置
  • 每个规则对象要设置两个属性,
    • test:正则表达式,匹配打包过程中遇到的文件;
    • use:匹配到的文件使用的 loader,use 的值可以是单个 loader 字符串也可以是一个数组 loader,如果是一个数组,会从右往左执行
  • css-loader 的作用就是将 css 文件转换为一个 js 模块;具体实现就是将 css 代码 push 到一个数组中
  • style-loader 的作用就是将 css-loader 转换后的结果通过 style 标签追加到页面上
  • loader 是 webpack 的核心特性
  • 借助不同的 loader 就可以加载任何类型的资源

file-loader 文件资源加载器

  • 安装依赖 yarn add file-loader
  • 在配置文件中添加 rules { test: /.png$/, use: 'file-loader' }
  • webpack 默认所有打包的结果放在网站根目录;为项目中的所有资源指定一个基础路径,在 output 中添加 publicPath 属性,默认值为空表示网站的根目录
  • 把 publicPath 值修改为 ‘dist/‘ 注意“/”不能省略,打包后是一个变量名(publicPath 值)拼接资源名(资源名前面没有“/”)所有“/”不能省略

publicPath是整个项目的静态资源的基础路径,一般在生产模式下使用.

webpack-dev-server中的publicPath是打包后代码存放的路径.这里的文件访问的静态资源路径还是output中的publicPath.所以二者要保持一致.

假设 output.publicPath 设置成了 './dist/',那么打包后的 JS 引用路径为 ./dist/main.js。这里会存在一个问题,相对路径在本地能正常访问到。但是如果把静态资源托管到 CDN 上,访问路径显然不能使用相对路径的。如果设置成 '/dist/',则打包后的访问路径是 localhost:8080/dist/main.js,此时本地无法访问。
一般解决方法就是利用 webpack.DefinePlugin 来定义一个全局变量(process.env.NODE_ENV)区分开发、生产环境来设定不同的值,或者是采用两份不同的配置文件来进行打包。
一般来说,output.publicPath 应该以 ‘/‘ 结尾,而其他 loader 或插件的配置不要以 ‘/‘ 开头。

url-loader URL 加载器

优点: 不用再发起 http 请求

  • Data URLS 格式:data(协议):[mediatype(媒体类型和编码)][;base64],
  • 安装插件 yarn add url-loader --dev
  • 在配置文件中添加 rules
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
module: {
use: [
{
test: /.png$/,
use: {
loader: "url-loader",
option: {
limit: 10 * 1024, // 对于小于10kb的png图片使用url-loader打包;
// 而大于10kb的会使用file-loader打包
},
},
},
],
},
};

loader 中做大小限制还是需要安装 file-loader,不然无法解析.

  • 打包后,图片将是以 base64 的格式显示

最佳实践

  • 小文件使用 Data URLs,减少请求次数
  • 大文件单独提取存放,提高加载速度

Webpack 常用加载器 loader 分类

  • 编译转换类:会把加载的资源转为 js 代码,如css-loader
  • 文件操作类:会把加载的资源拷贝到输出的目录,导出文件访问路径,如file-loader
  • 代码检查类:目的是统一代码风格,如eslint-loader

Webpack 与 ES6

wepack 并不能转换 es6 中的其他特性,只能处理模块相关的import,export

  • 安装babel-loader @babel/core @babel/preset-env
  • 在配置文件中 module rules 添加
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
module: {
rules: [
{
test: /.js$/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
},
],
},
};

Webpack 加载资源的方式

  • 遵循 ES Modules 标准的 import 声明
  • 遵循 Commonjs 标准的 require 函数
  • 遵循 AMD 标准的 define 函数和 require 函数
  • Loader 加载器的非 JavaScript 也会触发资源加载
    • 样式代码中的@import 指令和 url 函数
    • HTML 代码中图片标签的 src 属性

如果用require导致 esm 模块代码,需要用导入值的default属性导入.不过,除非必要,不要混用.

1
const heading = require("./heading.js").default;

Webpack 核心工作原理

  • webpack 会根据配置找到打包入口文件,然后顺着入口文件的代码
  • 根据代码中的 import 或者 require 解析推断这个文件依赖的资源模块
  • 然后分别解析每个资源模块对应的依赖,形成整个项目中所有用到文件之间的依赖关系的依赖树
  • webpack 递归依赖树,找到每个节点对于的资源文件
  • 根据配置文件中的 rules 属性找到模块对应的加载器,找到对于的资源放在打包结果中,实现整个项目的打包

Webpack 开发一个 Loader

loader 都需要导出一个函数,这个函数就是对加载资源的处理.

  • loader 负责资源文件从输入到输出的转换
  • 对于同一个资源可以依次使用多个 loader (css-loader -> style-loader)
  • loader 模块导出一个函数;该函数输入的是加载到的资源文件内容;函数体是处理内容的过程;输出的是经过处理过的资源文件内容结果,结果必须是 javascript 代码
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
// main.js
import about from "./about.md";

console.log(about);

// markdown-loader.js
const marked = require("marked");

module.exports = (source) => {
// source接受输入
// console.log(source);
// return 'hello';

// 方法一
const html = marked.parse(source);
// return `module.exports = ${JSON.stringify(html)}`

// 方法二 返回 html 字符串交给下一个 loader处理 (html-loader)
return html;
};

// webpack.config.js
module.exports = {
mode: "none",
entry: "./src/main.js",
output: {
filename: "bundle.js",
path: path.join(__dirname, "dist"),
publicPath: "dist/",
},
module: {
rules: [
{
test: /.md$/,
use: ["html-loader", "./markdown-loader"],
},
],
},
};

plugin

loader 实现资源加载,plugin 解决除了资源加载,其他的自动化工作.
比如,打包前清除 dist 目录(为了清除上一次代码),文件压缩,拷贝静态文件到输出目录.
也就实现了大部分的前端工程化的工作.

plugin 插件机制介绍

  • 增强 webpack 自动化能力
  • loader 专注实现资源模块加载
  • plugin 解决其他自动化工作
    • 清除 dist 目录
    • 拷贝静态文件至输出目录
    • 压缩输出代码

plugin 之 自动清除输出目录插件

  • 安装插件 yarn add clean-webpack-plugin --dev
  • 配置文件中导入插件
  • 在配置对象中 plugins 属性,该属性是专门配置插件的地方,属性值是一个数组
  • 数组里就是导入的类型创建的实例.
1
2
3
4
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
plugins: [new CleanWebpackPlugin()],
};

plugin 之 自动生成 HTML 插件

  • 安装插件 yarn add html-webpack-plugin --dev
  • 在配置文件中导入插件
  • 在配置对象中 plugins 属性,该属性是专门配置插件的地方,属性值是一个数组
  • 同时输出多个页面文件,可以再通过 HtmlWebpackPlugin 生成多个实例,通过 filename 指定文件名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
plugins: [
// 用于生成 index.html
new HtmlWebpackPlugin({
title: "Webpack Plugin Sample", // html文件的title
meta: {
viewport: "width=device-width", // 设置meta的viewport
},
template: "./src/index.html", // 模版,写法见最下方
}),
// 输出多个文件
new HtmlWebpackPlugin({
filename: "about.html",
}),
],
};

// index.html
// ....
// <h1><%= htmlWebpackPlugin.options.title %></h1>

plugin 之 复制静态文件

  • 安装插件 yarn add copy-webpack-plugin --dev

有些文件不需要打包,比如网站图标 icon,可以使用copy-webpack-plugin直接复制.

建议上线前再使用此插件,否则频繁打包会影响性能,平时开发可以使用的devserver中的contentBase属性设置静态资源路径
1
2
3
new CopyWebpackPlugin([
'public'// 将public下所有文件拷贝到输出目录
])

plugin 之 开发一个插件

原理: 钩子机制,通过在生命周期的钩子中挂载函数实现的.

  • 相比于 loader,plugin 拥有更宽的能力范围

  • plugin 通过钩子机制实现

  • 插件必须是一个函数或者是一个包含 apply 方法的对象

  • 通过在生命周期的钩子中挂载函数实现扩展

  • 一般就是定义一个类,在类中定义 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
    26
    27
    28
    class MyPlugin {
    // 目标 删除bundle.js中的注释
    apply(compiler) {
    // 会自动执行
    console.log("my plugin 启动");
    // https://webpack.js.org/api/compiler-hooks/
    // emit钩子:即将输出文件时执行
    // 参数一:插件名 参数二、挂载到钩子上的函数
    compiler.hooks.emit.tap("MyPlugin", (compilation) => {
    // compilation => 可以理解为此次打包的上下文
    for (const name in compilation.assets) {
    // assets:资源文件
    // console.log(name); // 文件名
    // console.log(compilation.assets[name].source());//文件内容
    if (name.endsWith(".js")) {
    // 判断是js文件
    const contents = compilation.assets[name].source(); // 文件内容
    const withoutComments = contents.replace(/\/\*\*+\*\//g, ""); // 全局替换注释
    compilation.assets[name] = {
    // 覆盖原有内容
    source: () => withoutComments, // 返回新的内容
    size: () => withoutComments.length, // 返回内容的大小 webpack内部规定必须
    };
    }
    }
    });
    }
    }

    webpack-dev-server

    集成了自动编译,自动刷新浏览器功能

    1
    2
    3
    4
    5
    # 安装
    pnpm add webpack-dev-server -D
    # 运行
    pnpm webpack-dev-server
    # 如果加--open会自动打开浏览器

    Webpack Dev Server 静态资源访问

    • Dev Server 默认只会 serve 打包输出文件
    • 只要是 webpack 打包输出的文件都可以直接被访问
    1
    2
    3
    4
    devServer: {
    // 额外开发服务指定静态资源路径,可以是字符串或数组
    contentBase: "./public";
    }

    Webpack Dev Server 代理 API

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    module.exports = {
    devServer: {
    proxy: {
    // 前缀
    "/api": {
    // http://localhost:8080/api/users => https://api.github.com/api/users
    target: "https://api.github.com",
    // 路径重写
    pathRewrite: {
    "^/api": "", // 去除 /api
    },
    // 不能使用 localhost:8080 作为请求github的主机名
    changeOrigin: true,
    },
    },
    },
    };

    Source Map

    Source Map 介绍

    • 调试和报错都是基于运行代码
    • source map 映射转换后代码和源代码关系,通过 source map 逆向解析
    • 解决源代码与运行代码不一致所产生的调试问题

    Webpack 配置 Source Map

    在配置项添加 devtool: 'source-map'

    Webpack eval 模式的 Source Map

    • eval 是否使用 eval 执行模块代码,带 eval,用 eval 执行模块代码,只能定位文件,不能定位具体位置.
    • eval-source-map 可以定位具体位置
    • cheap-eval-source-map 可以定位行,不能定位列.
    • cheap-source map 是否包含行信息
    • module 是否能够得到 Loader 处理之前的源代码
    • 带 module 的模式下解析的出来的原代码是没有经过 loader 加工,手写的源代码
    • 不带 module 是 loader 加工过后的代码
    • inline-source-map:source map 是因 dataURL 嵌入代码,其他的 source map 是以物理文件存在
    • hidden-source-map:开发工具看不到 source map 效果 在开发第三方包比较有用

    Webpack 选择 Source Map 模式

    • 开发模式
      • cheap-module-eval-source-map:代码每行不会超过 80 个字符;代码进过 loader 转换后差异较大需要 module 模式(loader 处理前的源代码);首次打包速度慢无所谓,重写打包相对较快
    • 生成模式
      • nonenosources-source-map:Source Map 会暴露源代码

    热更新 HMR

    Webpack HMR 体验(热更新)

    • 应用运行过程中实时替换某个模块,应用运行状态不受影响
    • HMR 集成在webpack-dev-server
    • 开启 HMR
      • css 样式(经过 loader 处理的)可以热更新但是 js 还是会刷新浏览器,webpack 中的 HMR 不可以开箱即用
      • webpack 中的 HMR 需要手动处理模块热替换逻辑
    1
    2
    3
    4
    5
    6
    7
    8
    const webpack = require("webpack");
    module.exports = {
    target: "web",
    devServer: {
    hot: true,
    },
    plugins: [new webpack.HotModuleReplacementPlugin()],
    };

    Webpack 使用 HMR API

    Webpack 处理 JS 模块热替换

    js 热更新:保存旧数据,代码发生变化后把数据回填

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    let lastEditor = editor;
    module.hot.accept("./editor", () => {
    // console.log('editor HMR')
    // console.log(createEditor)
    const value = lastEditor.innerHTML;
    document.removeChild(editor);
    const newEditor = createEditor();
    newEditor.innerHTML = value;
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
    });

    // 图片热更新
    module.hot.accept("./better.png", () => {
    img.src = background;
    console.log(background);
    });

    HMR 注意事项

    • 如果没有开启热更新,但是 js 代码做了热更新处理,先做判断 if (module.hot){// 热更新逻辑}
    • hot 会刷新浏览器,报错信息会被刷走;hotOnly不会刷新浏览器,可以把错误信息输出
    • 打包后会自动去除 js 热更新逻辑代码

    Webpack 生产环境优化

    • mode:none、production、development
    • 为不同的工作环境创建不同的配置

    Webpack 不同环境下的配置

    • 配置文件根据环境不同导出不同的配置
      • webpack 可以导出一个函数,函数返回配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 参数一:cli传递的环境名参数;参数二:运行cli传递的所有参数
    module.exports = (env, argv) => {
    const config = {
    //...
    };
    if (env === "production") {
    // ...
    }
    };
    // 执行 yarn webpack --env production
    • 一个环境对应一个配置文件
      • webpack.common.js 存放公共配置
      • webpack.dev.js 开发配置,把公共配置和开发配置合并(使用 loadsh merge)
      • webpack.prod.js 生产环境,把公共配置和生产配置合并(使用 loadsh merge)
      • 使用webpack-merge插件替换 loadsh 工具.
      • 在没有默认配置文件的情况就要执行 pnpm webpack --config webpack.dev.js
      • 或者在 scripts 中添加命令 "build": "webpack --config webpack.dev.js"
    1
    2
    3
    4
    5
    const common = require('./webpack.common')
    const merge = require('webpack-merge')
    module.exports = merge(common, {
    ...
    }

    Webpack DefinePlugin

    • DefinePlugin 为代码注入全局成员
    • 接收的是一个对象,每一个键值都会注入到代码中
    • 文件代码中直接使用键,打包后把注入的值 直接替换到代码中,definePlugin 传递的字符串应该是代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const webpack = require("webpack");
    module.exports = {
    plugins: [
    new webpack.DefinePlugin({
    // 想要输出的是字符串要个引号,否则输出的不是字符串
    // 输出值可以使用JSON.stringify('https://api.github.com')
    API_BASE_URL: '"https://api.github.com"',
    }),
    ],
    };
    // ==
    console.log(API_BASE_URL);

    Tree Shaking

    tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)
    • 在生成模式下自动开启,对于冗余、未引用代码不会打包
    • 不是指某个配置选项,是一组功能搭配使用后的优化效果
    • 只支持 ESM,不支持 CommonJS.(ESM 是静态引入,cms 是动态引入)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // webpack 使用 Tree Shaking
    module.exports = {
    mode: "none",
    entry: "./src/index.js",
    output: {
    filename: "bundle.js",
    },
    // 集中配置webpack内部优化功能
    optimization: {
    usedExports: true, // 只到处外部使用了的成员 (相当于标记没使用的代码)
    minimize: true, // 开启webpack代码压缩 会把外部没使用的代码去除
    },
    };

    Webpack 合并模块—concatenateModules

    • 尽可能的将所有模块合并输出到一个函数中
    • 提升运行效率,减少代码体积
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    module.exports = {
    mode: "none",
    entry: "./src/index.js",
    output: {
    filename: "bundle.js",
    },
    // 集中配置webpack内部优化功能
    optimization: {
    concatenateModules: true, // 打包后把所有的模块放在同一个函数中;
    },
    };

    Webpack Tree Shaking 与 Babel

    • babel 和 tree shaking 同时使用会导致 tree shaking 失效
      • tree shaking 前提是 ES Modules,由 webpack 打包的代码必须使用 ESM
      • babel-loader 转换代码时可能会把 ES Modules 转为 CommonJS
        新版的 babel-loader 不会导致 tree shaking 失效,
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    module.exports = {
    module: {
    rules: [
    {
    test: /\.js$/,
    use: {
    loader: "babel-loader",
    options: {
    presets: [
    // modules:默认值是auto 根据环境判断是否开启ESM,可以设为false
    ["@babel/preset-env", { modules: "commonjs" }], // 强制使用babel ESM,把ESM转为commonjs
    ],
    },
    },
    },
    ],
    },
    };

    sideEffects

    • 允许通过配置方式标识代码是否有副作用(模块执行时除了导出成员之外所做的事情)
    • 一般用于开发 npm 包时 标记是否有副作用
    • 生产模式自动开启 会检查当前代码所属的 package.json 有没有 sideEffects 标识,以此来判断这个模块是否有副作用
    • 如果这个模块没有副作用,那些没有用到的模块不会被打包
    • package.json 添加 sideEffects: false 表示 package.json 所影响的项目当中所有的代码都没有副作用
    1
    2
    3
    optimization: {
    sideEffects: true;
    }

    Webpack sideEffects 注意

    • 使用 sideEffects 前提确保代码真的没有副作用,否则 webpack 再打报时会误删有副作用的代码
    • 如在 Number 原型添加方法然后载入、载入 css 也是
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // extend.js
    // 为Number的原型添加一个扩展方法
    // 副作用代码 在package.json声明没有副作用,在打包时会被去除
    Number.prototype.pad = function(size) {
    let result = this + ''
    while(result.length < size) {
    result = '0' + result
    }
    return result
    }

    // index.js
    import './extend.js'
    console.log(8.pad(3))
    • 解决:在 package.json 关闭副作用(设置为 false)或者标识哪些文件有副作用
    1
    2
    3
    4
    5
    // package.json
    "sideEffects": [ // 标识有副作用的文件
    './src/extends.js',
    '*.css'
    ]

    代码分割

    • 所有代码最终都被打包到一起,打包文件体积过大
    • 分包,按需加载
    • 多入口打包
    • 动态导入

    Webpack 多入口打包

    • 多页应用程序,一个页面对应一个打包入口,公共部分单独提取
    • 多个入口可以把 entry 定义为对象,一个属性就是一个入口,键就是入口名,值就是入口对应的文件路径
    • 多入口,输出也要多个,可以给 filename 的值添加 [name]占位 outpur:{filename: '[name].bundle.js'} 这样 [name]就会被替换为入口名称
    • plugins 中的 HtmlWebpackPlugin 插件会自动输入注入所有打包结果的 html,所以此时入口文件会注入所有的打包结果
    • 可以给 HtmlWebpackPlugin 插件配置注入的打包文件 new HtmlWebpackPlugin({chunks:['index']}), new HtmlWebpackPlugin({chunks:['album']})
    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
    const { CleanWebpackPlugin } = require("clean-webpack-plugin");
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    module.exports = {
    mode: "none",
    entry: {
    index: "./src/index.js",
    album: "./src/album.js",
    },
    output: {
    filename: "[name].bundle.js",
    },
    module: {
    rules: [
    {
    test: /\.css$/,
    use: ["style-loader", "css-loader"],
    },
    ],
    },
    plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
    title: "Multi Entry",
    template: "./src/index.html",
    filename: "index.html",
    chunks: ["index"],
    }),
    new HtmlWebpackPlugin({
    title: "Multi Entry",
    template: "./src/album.html",
    filename: "album.html",
    chunks: ["album"],
    }),
    ],
    };

    Webpack 提取公共模块

    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
    module.exports = {
    // ...
    optimization: {
    splitChunks: {
    // 选择对哪些文件进行拆分,默认是async,即只对动态导入的文件进行拆分
    chunks: "all",
    // 提取chunk的最小体积
    minSize: 20000,
    // 要提取的chunk最少被引用次数
    minChunks: 1,
    // 对要提取的trunk进行分组
    cacheGroups: {
    // 匹配node_modules中的三方库,将其打包成一个trunk
    defaultVendors: {
    test: /[\\/]node_modules[\\/]/,
    name: "vendors",
    priority: -10,
    },
    default: {
    // 将至少被两个trunk引入的模块提取出来打包成单独trunk
    minChunks: 2,
    name: "default",
    priority: -20,
    },
    },
    },
    },
    // ...
    };

    Webpack 动态导入

    • 需要用到某个模块时,再加载这个模块
    • 动态导入的模块会被自动分包
    • 一般我们都是在文件头直接 import 文件的,这会造成资源浪费
    • 可以使用 ES Modules 的 import()在需要导入的地方导入模块,该方法返回的是 promise 可以拿到导出的对象
    1
    2
    3
    4
    5
    if (true) {
    import("./posts").then(({ default: posts }) => {
    mainElement.append(posts());
    });
    }

    Webpack 魔法注释

    • 在 import()方法中添加特定的注释 import(/* webpackChunkName: 'posts' */ './posts') 这样在打包后文件名就会带上这个 webpackChunkName 值
    • 如果 webpackChunkName 相同,打包后会打包在一起

    Webpack MiniCssExtractPlugin—提取 css 到单独文件

    • 安装插件 yarn add mini-css-extract-plugin --dev

    注意: 如果 css 小于 150k,就不要用这个了,不然单独生成一个文件,就多次请求,效果可能不如style-loader.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const MiniCssExtractPlugin = require("mini-css-extract-plugin");

    module.exports = {
    module: {
    rules: [
    {
    test: /\.css$/,
    use: [
    // 'style-loader', // 将样式通过 style 标签注入
    MiniCssExtractPlugin.loader, // 通过 link 标签注入
    "css-loader",
    ],
    },
    ],
    },
    plugins: [new MiniCssExtractPlugin()],
    };

    Webpack OptimizeCssAssetsWebpackPlugin— 压缩 css

    • 安装插件 yarn add optimize-css-assets-webpack-plugin --dev
    • 如果配置在 plugins 中这个插件在任何情况都会正常工作
    • 配置在 minimizer 中 只有 minimizer 开启(生产环境自动开启)是才工作
    • webpack 建议压缩创建配置在 minimizer 中,以便通过 minimizer 选项统一控制
    • 当 minimizer 使用一个数组,webpack 认为我们要自定义所使用的压缩器插件,webpack 内部的 js 压缩器就会被覆盖,如果不配置 js 压缩器,这是打包,js 代码不会被压缩
    • 手动把 js 压缩器添加回来,安装插件 yarn add terser-webpack-plugin --dev
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const OptimizeCssAssetsWebpackPlugin = require("optimize-css-assets-webpack-plugin");
    const TerserWebpackPlugin = require("terser-webpack-plugin");

    module.exports = {
    optimization: {
    minimizer: [
    new TerserWebpackPlugin(), //压缩js
    new OptimizeCssAssetsWebpackPlugin(), // 压缩css
    ],
    },
    };

    Webpack 输出文件名 Hash

    • 在部署时服务器开启静态文件缓存,如果缓存时间过长,程序发生更改,得不到更新
    • 生产环境下,文件名使用 Hash,文件名发生变化 对客户端而言全新的文件名就是全新的请求,就不会有缓存问题
    • [hash]【占位符】:整个项目级别的,项目中有任何地方改动,这次打包的 hash 值都会发生变化
    • [chunkhash]【占位符】:chuankhash 级别,打包过程中只要是同一路的打包 chunkhash 都是相同的
    • [contenthask]【占位符】:文件级别 hash,根据文件输出内容生成 hash 值(不同的文件就有不同的 hash 值)
    • 指定 hash 值长度[chunkhash:8]【占位符】
    1
    2
    3
    4
    5
    module.exports = {
    output: {
    filename: "[name]-[contenthash:8].bundle.js",
    },
    };

    Polyfill

    可翻译为垫片,

    引入方法

    1. 入口文件引入
    1
    2
    3
    4
    5
    # npm安装
    pnpm add core-js regenerator-runtime
    # 在入口文件顶部导入
    import 'core-js/stable'
    import 'regenerator-runtime/runtime'
    1. webpack 引入
    1
    2
    3
    moduls.exports = {
    entry: ["core-js/stable", "./src/index.js"],
    };

    预设配置

    target

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //babel.config.js
    module.exports = {
    presets: [
    [
    "@babel/preset-env",
    {
    target: {
    chrome: 30,
    },
    },
    ],
    ],
    };

    配合browserslistrc

    这个文件是目标环境配置表,也就是说通过配置,可以让我们的代码运行在哪些指定的浏览器或 node 环境,比如下边这种配置的意思就是,市场份额大于 1%的浏览器,并且不考虑 ie10 以下版本的浏览器,并且不是已’死亡’(指 24 个月没有官方的更新)的浏览器.

    1
    2
    3
    4
    // .browserslistrc 文件
    > 1%
    not ie <= 10
    not dead

    上面预设配置第一个参数 targets 里边就可以填写.browserslistrc 中的内容,效果是一样的,

    useBuiltIns

    useBuiltIns有三个值.默认是false.其他是entryusage.
    entry: 需要在入口或者 webpack 配置文件中引入core-js.
    Babel 会根据我们.browserslistrc文件中的环境配置(这里是 chrome 60 版本),来补齐 chrome 60 版本所有不支持的新增 ES6API,引入 chrome60 版本所有的 polyfill,不管你的入口文件中有没有用到相应的 API。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    npm install core-js -S
    npm install regenerator-runtime -S (根据@babel/core版本判断是否需要安装)
    // babel.config.js
    module.exports = {
    presets: [["@babel/preset-env", {
    useBuiltIns: 'entry',
    corejs: 3
    }]],
    plugins: []
    }
    // before.js,在第一行引入'core-js/stable'
    import 'core-js/stable'
    let promise = Promise.resolve('success')
    //.browserslintrc文件中,我们配置一个chrome浏览器的低版本
    chrome 60

    usage: 就把上面 entry 改了就行,作用就是类似于按需加载,只把用的 Promsie 进行 polyfill 补齐.

    corejs

    就是 corejs 的版本,推荐 3.

    modules

    可以配置成”amd” | “umd” | “systemjs” | “commonjs” | “cjs” | “auto” | false,默认为 auto,这个参数的含义是,是否将 ES6 模块化语法转译成其他的语法,设置为 false 的话,转译的结果就还是 ES6 模块化语法,就是 import,export default 这种,不会使用 require(CommonJs 语法)这种进行导入导出,如果使用 webpack 或 rollup 等打包工具,推荐将 modules 设置为 false,这样的话就有利于打包工具进行静态分析,从而做 tree shaking 等优化操作

    范例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    module.exports = {
    presets: [
    [
    "@babel/preset-env",
    {
    // targets不做配置,默认使用我们 .browserslistrc文件中的配置
    // targets: {},
    // 对所用语法进行polyfill
    useBuiltIns: "usage",
    corejs: {
    // 使用最新版本的core-js进行polyfill
    version: "^3.25.2",
    // 开启草案中的polyfill转译
    proposals: true,
    },
    // 使用es6模块进行
    modules: false,
    },
    ],
    ],
    plugins: ["@babel/plugin-transform-runtime"],
    };

    Postcss

    用来处理 css 样式兼容性的工具,用于适配不同的浏览器.

    1
    pnpm add postcss-cli postcss-loader autoprefixer

    autoprefixer

    自动适配浏览器,在browserslistrc文件中写上要适配的浏览器种类

    1
    2
    3
    4
    5
    6
    7
    // .browserslistrc
    // 使用率大于0.01%
    >0.01%
    // 往上数两个版本
    last 2 versions
    // 还没停更的
    not dead

    可以将配置写在单独的postcss.config.js中,或者 webpack 配置中

    1
    2
    3
    4
    // postcss.config.js
    module.exports = {
    plugins: [require("autoprefixer")],
    };

    postcss-preset-env

    在一些陈旧的项目中可能会看到 autoprefixer 这个插件,现在我们可以直接使用 postcss-preset-env 这个插件,自动添加前缀的功能已经包含在了其中

    在项目根目录下新增 postcss.config.js 配置文件:

    1
    2
    3
    module.exports = {
    plugins: ["postcss-preset-env"],
    };

    postcss-loader

    在 webpack 中的配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    moudule.exports = {
    //...,
    modules: {
    rules: [
    {
    test: /.css$/,
    use: ["style-loader", "css-loader", "postcss-loader"],
    },
    ],
    },
    };

    静态资源

    在 webpack5 之前,加载图片、字体等资源需要我们使用 url-loader、 file-loader 等来处理
    从 webpack5 开始,我们可以使用内置的资源模块类型 Asset Modules type,来替代这些 loader 的工作
    资源模块类型 Asset Modules type 分为四种:

    • asset/resource 发送一个单独的文件并导出 URL,之前通过使用 file-loader 实现
    • asset/inline 导出一个资源的 data URI,之前通过使用 url-loader 实现
    • asset/source 导出资源的源代码,之前通过使用 raw-loader 实现
    • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择,之前通过使用 url-loader 实现,并且可以配置资源体积限制
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    module.epxorts = {
    module: {
    rules: [
    {
    test: /\.(png|svg|jpe?g|gif)$/,
    type: "asset",
    generator: {
    filename: "images/[name]-[hash][ext]",
    },
    },
    ],
    },
    };

    文件缓存

    webpack5 之前我们常用 cache-loader 、hard-source-webpack-plugin 做文件缓存,加速二次构建, webpack5 现在内置了这种能力,默认会把编译的结果缓存到内存中,通过配置还可以缓存到文件系统中
    修改 webpack.base.js:

    1
    2
    3
    4
    5
    6
    7
    module.exports = {
    // ...
    cache: {
    type: "filesystem",
    },
    // ...
    };

    多个 webpack 配置文件

    可以根据开发环境配置多个 webpack 配置文件,将共配置抽离出来。
    一般配置如下:
    基础:webpack.base.config.js
    开发:webpack.dev.config.js
    生产:webpack.prod.config.js

    webpack-merge

    使用该插件进行合并操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 基础配置在base文件下
    // 其他文件使用webpack.merge进行合并处理
    const { merge } = require("webpack-merge");
    // 公共配置
    const baseConfig = require("./webapck.base");

    const devConfig = {
    mode: "development",
    devtool: "cheap-module-eval-source-map",
    devServer: {},
    //...
    };

    // 合并两个配置
    module.exports = merge(baseConfig, devConfig);

    目的

    也就是解决了什么问题?

    常规流程

    请求数据 =>加载中 => 后端返回 => 如果有报错展示报错 =>刷新数据

    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
    import * as React from "react";

    export default function App() {
    // 存储 后端返回数据
    const [zen, setZen] = React.useState(""); // 存储 加载状态
    const [isLoading, setIsLoading] = React.useState(false); // 存储 是否请求成功
    const [isError, setIsError] = React.useState(false); // 存储 后端返回的错误数据
    const [errorMessage, setErrorMessage] = React.useState("");

    const fetchData = () => {
    // 开始获取数据,将isLoading置为true
    setIsLoading(true);

    fetch("https://api.github.com/zen")
    .then(async (response) => {
    // 如果请求返回status不为200 则抛出后端错误
    if (response.status !== 200) {
    const { message } = await response.json();

    throw new Error(message);
    }

    return response.text();
    })
    .then((text: string) => {
    // 请求完成将isLoading置为false
    setIsLoading(false); // 接口请求成功,将isError置为false
    setIsError(false); // 存储后端返回的数据
    setZen(text);
    })
    .catch((error) => {
    // 请求完成将isLoading置为false
    setIsLoading(false); // 接口请求错误,将isError置为true
    setIsError(true); // 存储后端返回的错误数据
    setErrorMessage(error.message);
    });
    };

    React.useEffect(() => {
    // 初始化请求数据
    fetchData();
    }, []);

    return (
    <div>
         <h1>Zen from Github</h1>     <p>
    {isLoading ? "加载中..." : isError ? errorMessage : zen}
    </p>   {" "}
    {!isLoading && (
    <button onClick={fetchData}>{isError ? "重试" : "刷新"}</button>
    )}
       
    </div>
    );
    }
    • 使用 isLoading 来存储加载状态
    • 使用 isError 来存储接口是否有错误
    • 使用 errorMessage 来存储后端返回的报错信息
    • 使用 zen 来存储后端返回数据存储
    • 重新调用 fetchData 方法来刷新数据

    修正

    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
    import * as React from "react";
    import { useQuery } from "react-query";

    const fetchData = () => {
    return fetch("https://api.github.com/zen").then(async (response) => {
    // 如果请求返回status不为200 则抛出后端错误
    if (response.status !== 200) {
    const { message } = await response.json();

    throw new Error(message);
    }

    return response.text();
    });
    };

    export default function App() {
    const zenQuery = useQuery(["zen"], fetchData); // ①

    return (
    <div>
         <h1>Zen from Github</h1>     <p>
         {" "}
    {zenQuery.isLoading || zenQuery.isFetching
    ? "加载中..."
    : zenQuery.isError
    ? zenQuery.error?.message
    : data}
         
    </p>   {" "}
    {!zenQuery.isLoading && !zenQuery.isFetching && (
    <button
    onClick={() => {
    zenQuery.refetch();
    }}
    >
            {zenQuery.isError ? "重试" : "刷新"}       
    </button>
    )}
       
    </div>
    );
    }

    结果

    为了解决这么多大量冗杂的模板代码.使用 react-query 会变得十分整洁.
    上面各种 loading,error,数据刷新等等都交给 react-query 处理.

    初始配置

    将组件包裹在QueryClientProvider中,和 useContext 搭配就是把 useContext 实例化的组件放在其中.

    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
    import {
    QueryClient,
    QueryClientProvider,
    useQuery,
    } from "@tanstack/react-query";

    const queryClient = new QueryClient();

    function Example() {
    const query = useQuery("todos", fetchTodos);

    return (
    <div>
    {query.isLoading
    ? "Loading..."
    : query.isError
    ? "Error!"
    : query.data
    ? query.data.map((todo) => <div key={todo.id}>{todo.title}</div>)
    : null}
    </div>
    );
    }

    function App() {
    return (
    <QueryClientProvider client={queryClient}>
    <Example />
    </QueryClientProvider>
    );
    }

    搭配 useContext

    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
    import { ReactNode } from "react";
    import { AuthProvider } from "./auth.context";
    import { QueryClient, QueryClientProvider } from "react-query";

    export const AppProviders = ({ children }: { children: ReactNode }) => {
    return (
    <QueryClientProvider client={new QueryClient()}>
    <AuthProvider>{children}</AuthProvider>
    </QueryClientProvider>
    );
    };

    //auth.context
    import * as auth from "auth-provider";
    import { FullPageLoading } from "components/lib";
    import { createContext, ReactNode, useContext } from "react";
    import { useQueryClient } from "react-query";
    import { User } from "types/user";
    import { useMount } from "utils";
    import { http } from "utils/http";
    import { useAsync } from "utils/use-async";
    import { FullPageErrorFallback } from "../components/lib";

    const AuthContext = createContext<
    | {
    user: User | null;
    login: (form: AuthForm) => Promise<void>;
    register: (form: AuthForm) => Promise<void>;
    logout: () => Promise<void>;
    }
    | undefined
    >(undefined);

    AuthContext.displayName = "AuthContext";

    interface AuthForm {
    username: string;
    password: string;
    }

    // 启动时重置token
    const bootstrapUser = async () => {
    let user = null;
    const token = auth.getToken();
    if (token) {
    const data = await http("me", { token });
    user = data.user;
    }
    return user;
    };

    export const AuthProvider = ({ children }: { children: ReactNode }) => {
    // const [user, setUser] = useState<User | null>(null);

    const {
    run,
    isIdle,
    isLoading,
    isError,
    error,
    data: user,
    setData: setUser,
    } = useAsync<User | null>();
    const queryClient = useQueryClient();

    const login = (form: AuthForm) => auth.login(form).then(setUser);
    const register = (form: AuthForm) => auth.register(form).then(setUser);
    const logout = () =>
    auth.logout().then(() => {
    setUser(null);
    // 清除usequery获取的数据
    queryClient.clear();
    });

    // 挂载时重置user
    useMount(() => {
    run(bootstrapUser());
    });

    if (isIdle || isLoading) {
    return <FullPageLoading />;
    }
    if (isError) {
    return <FullPageErrorFallback error={error} />;
    }

    return (
    <AuthContext.Provider
    children={children}
    value={{ user, login, register, logout }}
    />
    );
    };

    export const useAuth = () => {
    const context = useContext(AuthContext);

    if (!context) {
    throw new Error("useAuth必须在AuthProvider中使用");
    }
    return context;
    };

    常用方法

    查询键和查询函数

    1
    const zenQuery = useQuery(["zen"], fetchData);
    • 其中['zen']就是 react-query 的查询键,react-query 通过不同的查询键来标识(映射)不同接口(或是同一接口不同参数请求)返回的数据。在 react-query@4 中,查询键必须是数组。
    • fetchData就是我们请求后端接口的函数,也就是查询函数.

      PS:查询键内的元素可以是嵌套数组、对象、字符串、数字
      例如:[‘zen’, { form: ‘confucius’ }]或[‘zen’, [‘confucius’, ‘Lao Tzu’]]

    为了方便记忆,打个比方,你可以将查询键看做是你存储 localStorage 时的 key,而 value 则是通过查询函数查询到数据后,将各种我们需要的状态数据存储进入 value
    使用变量作为查询键的元素时,当变量的值变化后,react-query 将会重新调用 fetchData 方法,获取新的数据,并缓存到对应变量值为 key 的缓存中。

    并行请求

    同时多个请求就写多个 useQuery,合并请求就用 Promise.all 包裹.

    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
    import * as React from "react";
    import { useQuery } from "react-query";

    const getReposAndGists = (username) => {
    return Promise.all([
    fetch(`https://api.github.com/users/${username}/repos`).then((res) =>
    res.json()
    ),
    fetch(`https://api.github.com/users/${username}/gists`).then((res) =>
    res.json()
    ),
    ]);
    };

    const ReposAndGists = ({ username }) => {
    const reposAndGistsQuery = useQuery(["reposAndGists", username], () =>
    getReposAndGists(username)
    );

    if (reposAndGistsQuery.isLoading) {
    return <p>加载数据中...</p>;
    }

    if (reposAndGistsQuery.isError) {
    return <p>数据加载错误: {reposAndGistsQuery.error.message}</p>;
    }

    if (!reposAndGistsQuery.data) {
    return null;
    }

    const [repos, gists] = reposAndGistsQuery.data;

    return (
    <div>
    <h2>仓库列表</h2>
    <ul>
    {repos.map((repo) => (
    <li key={repo.id}>{repo.name}</li>
    ))}
    </ul>

    <hr />

    <h2>代码片段列表</h2>
    <ul>
    {gists.map((gist) => (
    <li key={gist.id}>{gist.description || "暂无描述"}</li>
    ))}
    </ul>
    </div>
    );
    };

    export default ReposAndGists;

    动态生成请求

    useQueries.从动态获取[‘facebook’, ‘vuejs’, ‘nestjs’, ‘mongdb’],到重新批量获取了以下用户的仓库[‘microsoft’, ‘tesla’].

    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
    import * as React from "react";
    import { useQueries } from "react-query";

    export default function App() {
    const [users, setUsers] = React.useState([
    "facebook",
    "vuejs",
    "nestjs",
    "mongodb",
    ]);

    const getRepos = (username) =>
    fetch(`https://api.github.com/users/${username}/repos`).then((res) =>
    res.json()
    );

    const userQueries = useQueries({
    queries: users.map((user) => {
    return {
    queryKey: ["user", user],
    queryFn: () => getRepos(user),
    };
    }),
    });
    return (
    <div>
         <h1>查看github用户的仓库</h1>     <button
    onClick={() => setUsers(["microsoft", "tesla"])}
    >
           更改获取用户      
    </button>   {" "}
    {userQueries.map((query) =>
    query.isLoading ? (
    <div>加载中....</div>
    ) : (
    <ol>
             {" "}
    {query.data.map((item) => (
    <li>{item.full_name}</li>
    ))}
             
    </ol>
    )
    )}
       
    </div>
    );
    }

    依赖请求

    请求 B 接口的某个参数依赖 A 接口请求返回的内容。

    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
    import * as React from "react";
    import { useQuery } from "react-query";

    const IssueLabelFilter = ({ owner, repo }) => {
    const labelsQuery = useQuery(["repos", owner, repo, "labels"], () =>
    fetch(`https://api.github.com/repos/${owner}/${repo}/labels`).then((res) =>
    res.json()
    )
    );

    const labels = labelsQuery.data;

    const issuesQuery = useQuery(
    ["repos", owner, repo, "issues"],
    () =>
    fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues?labels=${labels[1].name}`
    ).then((res) => res.json()),
    {
    /**
    * 该配置项在value为true时,会触发接口请求。
    * 因此当第一个接口请求返回后,此时该配置项表达式为true
    * 会触发请求github中的issue列表
    * ①
    */
    enabled: !!labels,
    }
    );

    return (
    <div>
    <h2>标签</h2>
    {labelsQuery.isLoading ? (
    <p>加载标签中...</p>
    ) : (
    <ul>
    {labelsQuery.data.map((label) => (
    <li key={label.id}>{label.name}</li>
    ))}
    </ul>
    )}

    <hr />

    <h2>
    Issues
    {Array.isArray(issuesQuery.data) ? `(${labels[1].name})` : ""}
    </h2>
    {issuesQuery.isLoading ? (
    <p>加载issues中...</p>
    ) : (
    <ul>
    {issuesQuery.data.map((issue) => (
    <li key={issue.id}>{issue.title}</li>
    ))}
    </ul>
    )}
    </div>
    );
    };

    export default function App() {
    return (
    <div>
    <IssueLabelFilter owner={"facebook"} repo={"react"} />
    </div>
    );
    }

    在 react-query 中,当该组件被加载时,组件内的 useQuery 就会开始请求。
    此时明显不符合需求,需求的要求是在加载完标签列表后,获取到第二个标签,再开始请求 issues 列表。
    因此就需要使用到 useQuery 的 enabled 参数,当参数值为 false 时,将会禁止请求接口。
    现在回到上面的例子中,当 labelsQuery 请求还没有结果时,labels 变量值为 undefined,此时在 ① 行代码中的值为 false,当 labelsQuery 请求结束时,labels 变量值为数组,此时在 ① 行代码中的值为 true,issuesQuery 开始请求数据。完全符合需求的要求。

    改进依赖查询接口一直为 loading 的问题

    上面的例子中,issuesQuery 在加载后,由于处于禁用状态(配置项 enabled: false),此时 isLoading 将会一直处于 true 的状态,直到 issuesQuery 请求完成数据后变为 false。
    这种提示会非常奇怪,明明有一段时间里 issuesQuery 没有真正的请求数据,为啥要一直显示加载标签中…的内容?
    解决办法是:需要一个字段来区分查询函数当前并没有请求数据,处于摸鱼状态。
    在 useQuery 中当 fetchStatus 字段在为 idle 时,表示当前查询函数不在运行,处于摸鱼状态^ ^

    fetchStatus 一共有三个状态,fetching 状态表示当前查询函数正在运行,idle 状态表示当时查询函数不在运行。paused 状态表示查询函数尝试运行,但是无法进行请求,最可能的原因是由于当前没有联网,处于离线状态

    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
    import * as React from "react";
    import { useQuery } from "react-query";
    import "./style.css";

    const IssueLabelFilter = ({ owner, repo }) => {
    const labelsQuery = useQuery(["repos", owner, repo, "labels"], () =>
    fetch(`https://api.github.com/repos/${owner}/${repo}/labels`).then((res) =>
    res.json()
    )
    );

    const labels = labelsQuery.data;

    const issuesQuery = useQuery(
    ["repos", owner, repo, "issues"],
    () =>
    fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues?labels=${labels[1].name}`
    ).then((res) => res.json()),
    {
    enabled: !!labels, // 👈🏻❗️❗️❗️该配置项在value为true时,会触发接口请求。因此当第一个接口请求返回后,此时该配置项表达式为true,会触发请求github中的issue列表
    }
    );

    return (
    <div>
    <h2>标签</h2>
    {labelsQuery.isLoading ? (
    <p>加载标签中...</p>
    ) : (
    <ul>
    {labelsQuery.data.map((label) => (
    <li key={label.id}>{label.name}</li>
    ))}
    </ul>
    )}
    <hr />
    // 👇🏻下面的代码判断了在查询函数处于摸鱼状态时,不显示任何内容 ②{issuesQuery.isLoading &&
    issuesQuery.fetchStatus === "idle" ? null : (
    <div>
    <h2>
    Issues
    {Array.isArray(issuesQuery.data) ? `(${labels[1].name})` : ""}
    </h2>
    // 当查询函数处于干活状态时,显示加载issues中 ③{issuesQuery.isLoading ? (
    <p>加载issues中...</p>
    ) : (
    <ul>
    {issuesQuery.data.map((issue) => (
    <li key={issue.id}>{issue.title}</li>
    ))}
    </ul>
    )}
    </div>
    )}
    </div>
    );
    };

    export default function App() {
    return (
    <div>
    <IssueLabelFilter owner={"facebook"} repo={"react"} />
    </div>
    );
    }

    useMutation

    使用useMutation进行增删除操作.

    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
    function App() {
    const mutation = useMutation((newTodo) => axios.post("/todos", newTodo));

    return (
    <div>
    {mutation.isLoading ? (
    "Adding todo..."
    ) : (
    <>
    {mutation.isError ? (
    <div>An error occurred: {mutation.error.message}</div>
    ) : null}

    {mutation.isSuccess ? <div>Todo added!</div> : null}

    <button
    onClick={() => {
    mutation.mutate({ id: new Date(), title: "Do Laundry" });
    }}
    >
    Create Todo
    </button>
    </>
    )}
    </div>
    );
    }

    mutate 函数是一个异步函数,这意味着你不能在事件回调中直接使用它 (React16 及之前版本)。 如果你需要在 onSubmit 中访问事件,则需要将 mutate 包装在另一个函数中。 这是由于 React 事件池限制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 在React16及之前的版本,这将无法正常工作
    const CreateTodo = () => {
    const mutation = useMutation((event) => {
    event.preventDefault();
    return fetch("/api", new FormData(event.target));
    });

    return <form onSubmit={mutation.mutate}>...</form>;
    };

    // 这将正常工作
    const CreateTodo = () => {
    const mutation = useMutation((formData) => {
    return fetch("/api", formData);
    });
    const onSubmit = (event) => {
    event.preventDefault();
    mutation.mutate(new FormData(event.target));
    };

    return <form onSubmit={onSubmit}>...</form>;
    };

    重置修改状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    const CreateTodo = () => {
    const [title, setTitle] = useState("");
    const mutation = useMutation(createTodo);

    const onCreateTodo = (e) => {
    e.preventDefault();
    mutation.mutate({ title });
    };

    return (
    <form onSubmit={onCreateTodo}>
    {mutation.error && (
    <h5 onClick={() => mutation.reset()}>{mutation.error}</h5>
    )}
    <input
    type="text"
    value={title}
    onChange={(e) => setTitle(e.target.value)}
    />

    <button type="submit">Create Todo</button>
    </form>
    );
    };

    总结

    • 并行请求

    通过在查询函数中,使用Promise.all来进行接口的合并请求
    通过useQueries来进行动态的并行请求

    • 依赖请求

    isLoadingtrue表示第一次请求未返回结果前这段时间的状态,如果对接口进行了禁用,可以通过fetchStatusidle来获取接口禁用请求这段时间的状态。

    缓存状态

    react-query 通常在挂载组件时获取数据;在获取数据后,将数据存储到缓存中,并将该数据提供给组件使用。
    在获取数据时,有三种状态.loading,success,error.
    也对应useQuery钩子中的isLoadingisSuccessisError属性
    当 react-query 进行后端请求查询时,会有以下三个状态:

    • idle:空闲,表示当前不需要从后端获取数据
    • fetching: 获取数据,表示当前正在从后端获取数据
    • paused:暂停,表示原本尝试从后端获取数据,但是通常由于未联网的原因导致暂停

    fetchStatus将会在 idle、fetching、paused 这三个状态间经历循环

    在 react-query 中 status 为loading状态(或者isLoading为 true)指的是第一次从后端获取成功之前的状态.
    而 fetchStatus 为fetching状态(或者isFetching为 true)指的是每次从后端获取数据的加载状态(包含第一次获取数据)。

    整个生命周期:
    假如你使用 react-query 从 Github 的接口请求了 react 的 issue 列表,你此次的请求结果将会在 status 中标记为 success 或 error,或者从 isSuccess、isError 中判断请求成功或者失败。
    请求后端数据成功后,在写入缓存时,此时的缓存状态是fresh(最新)状态,但是很快(默认过期时间是 0ms)就会变为stale(老旧)状态。
    如果使用 react 的 issue 列表的每个组件都被卸载后,issue 列表数据的缓存状态将会被标记为inactive(不活跃)状态。此时数据将不会被删除,直到一段时间后(默认为 5 分钟),react-query 将会从缓存中删除该条数据。
    在变为inactive(不活跃)状态之前,该条数据将会在fresh(最新)与stale(老旧)之间来回切换,同时接口请求状态也会在idlefetching 之间切换。

    fresh 最新态和 stale 老旧态

    react-query 是否会触发查询函数,并从后端接口获取数据,与缓存状态是:fresh(最新)状态或stale(老旧)状态有关。如果缓存状态是stale(老旧),表示该查询将会有资格重新获取,但如果缓存状态是fresh(最新)的,就不会重新获取。

    如果使用 react 的 issue 列表的每个组件都被卸载后,issue 列表数据的缓存状态将会被标记为inactive(不活跃)状态。此时数据将不会被删除,直到一段时间后(默认为 5 分钟),react-query 将会从缓存中删除该条数据。
    在变为inactive(不活跃)状态之前,该条数据将会在fresh(最新)与stale(老旧)之间来回切换,同时接口请求状态也会在idlefetching之间切换。

    staleTime

    在默认情况下,后端返回的数据其缓存状态将会立即({staleTime: 0})fresh(最新)状态变为stale(老旧)状态。
    其实这样做不难理解,因为当你请求到数据后,后端的数据有可能就发生了变化,因此当拿到后端返回的数据瞬间,缓存状态就是stale(陈旧)的了。
    你可以将配置中的staleTime,设置一个毫秒数的数字,那么缓存将会在staleTime毫秒后过期(从fresh(最新)变为stale(陈旧))
    如果把staleTime设置为Infinity,表示当前查询的数据将只会获取一次,且会在整个网页的生命周期内缓存。

    触发条件

    在 react-query 中并不是缓存从 fresh(最新)转换为 stale(老旧)状态时,就会重新获取。

    1. 当组件首次加载,将会触发数据的获取。如果组件被卸载后再次被加载,此时也会触发数据的重新获取。
    2. 当用户把浏览器重新聚焦,或者切换到当前选项卡。这个触发条件是默认开启的,如果希望关闭这个触发条件,可以把refetchOnWindowFocus选项设置为 false 来禁止。
    3. 网络重新连接。可以使用refetchOnReconnect来禁止。
    4. 定时刷新。当你在配置中设置refetchInterval为数字(代表 xxx 毫秒)时。无论此时数据是fresh(最新)还是stale(老旧)的缓存状态,react-query 都会在你设置的毫秒时间间隔内重新获取数据

    清理缓存

    在缓存状态处于inactive(不活跃)状态或者使用这个查询数据的组件卸载时,超过 5 分钟(默认情况下)后,react-query 将会自动清除该缓存。
    如果你希望自定义这个时间,你可以使用cacheTime配置,下面的例子中将cacheTime设置为 0,实现的效果是,当查询数据在inactive状态时,立即从缓存中删除。

    1
    2
    3
    4
    5
    6
    const userQuery = useQuery(
    ["user", username],
    () =>
    fetch(`https://api.github.com/users/${username}`).then((res) => res.json()),
    { cacheTime: 0 }
    );

    处理错误

    错误重试

    当一个查询无法重新获取时,将会基于指数退避算法的时间间隔,尝试请求 3 次。 从 1s 的延迟起步,到 30s 的最大延迟。下面是默认重试策略的相关配置:

    1
    2
    3
    4
    5
    6
    const exampleQuery = useQuery("example", fetchExample, {
    retry: 3,
    retryDelay: (attempt) => {
    return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000);
    },
    });

    PS: 如果只配置了 retry 的次数,那么 retry 的时间间隔,将会默认采用指数退避算法。

    错误边界

    使用react-error-boundary这个包,来减少相关错误边界的配置。
    需要在配置项中,配置useErrorBoundarytrue,之后错误边界就能捕获到相关的报错

    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
    import * as React from "react";
    import "./style.css";
    import { ErrorBoundary } from "react-error-boundary";
    import { useQuery } from "react-query";

    const fetchWithError = async (url, options = {}) => {
    const response = await fetch(url, options);

    let errorMessage = "";
    if (response.status !== 200) {
    errorMessage += `请求错误状态码为: ${response.status}. `;
    }

    const body = await response.json();
    if (body.message) {
    errorMessage += body.message;
    }

    console.log(errorMessage);

    if (errorMessage) {
    throw new Error(errorMessage);
    }

    return body;
    };

    const Repos = () => {
    const reposQuery = useQuery(
    ["users"],
    () => fetchWithError("https://api.github.com/users/facebook/repos"),
    {
    useErrorBoundary: true,
    onError: (error) => console.log(error, "onError"),
    }
    );

    return <div>{JSON.stringify(reposQuery.data)}</div>;
    };

    const Gists = () => {
    const gistsQuery = useQuery(
    ["gists"],
    () => fetchWithError("https://api.github.com/users/facebook/gists"),
    {
    useErrorBoundary: true,
    onError: (error) => console.log(error, "onError"),
    }
    );

    return <div>{JSON.stringify(gistsQuery.data)}</div>;
    };

    function QueryError({ error }) {
    return (
    <div>
    <h1>出现错误</h1>
    <p>{error.message}</p>
    </div>
    );
    }

    export default function App() {
    return (
    <ErrorBoundary FallbackComponent={QueryError}>
    <Repos />
    <Gists />
    </ErrorBoundary>
    );
    }

    错误回调

    在 react-query 中,你可以在配置中设置 onError 属性,来获取错误的回调。包含了 error 的数据。
    你可以在回调中,调用消息弹窗来提示报错信息,如果你使用 ant-design 组件库,你可以这么写:

    1
    2
    3
    4
    5
    6
    7
    const reposQuery = useQuery(
    ["users"],
    () => fetchWithError("https://api.github.com/users/facebook/repos"),
    {
    onError: (error) => message.error({ content: error.message }),
    }
    );

    针对重新获取数据失败处理的方式

    在第一次请求数据时,查询函数返回成功。在页面失焦后,重新聚焦,触发第二次请求时,查询函数返回失败。
    如何处理?

    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
    import * as React from "react";
    import { useQuery } from "react-query";

    let count = 0;

    export default function App() {
    const mockQuery = useQuery(
    ["mock"],
    async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    if (count === 0) {
    count++;
    return "成功";
    } else {
    throw new Error("失败");
    }
    },
    { retry: false }
    );

    return (
    <div>
       {" "}
    {mockQuery.isError ? (
    <div className="error-message">{mockQuery.error.message}</div>
    ) : null}
        {mockQuery.isLoading ? "首次加载中..." : mockQuery.data}   
    </div>
    );
    }

    你可以根据不同的情况来指定你的显示策略:

    • 当重新获取数据失败后,你可以像上面的例子一样即显示缓存数据,又显示报错
    • 也可以判断 isError 字段为 true 时,只显示报错信息,隐藏缓存数据。

    更多内容

    建议看官方文档

    定时请求数据

    该问题的原因是 Promise 执行过程中,无法取消后续还没有定义的 setTimeout() 导致的。所以最开始想到的就是我们不应该直接对 timeoutID 进行记录,而是应该向上记录整个逻辑的 Promise 对象。当 Promise 执行完成之后我们再清除 timeout,保证我们每次都能确切的清除掉任务。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import { useState, useEffect } from "react";

    export function useFetchDataInterval(fetchData) {
    const [list, setList] = useState([]);
    useEffect(() => {
    let getListPromise;
    async function getList() {
    const data = await fetchData();
    setList((list) => list.concat(data));
    return setTimeout(() => {
    getListPromise = getList();
    }, 2000);
    }

    getListPromise = getList();
    return () => {
    getListPromise.then((id) => clearTimeout(id));
    };
    }, [fetchData]);
    return list;
    }

    上面的方案能比较好的解决问题,但是在组件卸载的时候 Promise 任务还在执行,会造成资源的浪费。其实我们换个思路想一下,Promise 异步请求对于组件来说应该也是副作用,也是需要”清除“的。只要清除了 Promise 任务,后续的流程自然不会执行,就不会有这个问题了。
    清除 Promise 目前可以利用 AbortController 来实现,我们通过在卸载回调中执行 controller.abort() 方法,最终让代码走到 Reject 逻辑中,阻止了后续的代码执行。

    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
    import { useState, useEffect } from "react";

    function fetchDataWithAbort({ fetchData, signal }) {
    if (signal.aborted) {
    return Promise.reject("aborted");
    }
    return new Promise((resolve, reject) => {
    fetchData().then(resolve, reject);
    signal.addEventListener("aborted", () => {
    reject("aborted");
    });
    });
    }
    function useFetchDataInterval(fetchData) {
    const [list, setList] = useState([]);
    useEffect(() => {
    let id;
    const controller = new AbortController();
    async function getList() {
    try {
    const data = await fetchDataWithAbort({
    fetchData,
    signal: controller.signal,
    });
    setList((list) => list.concat(data));
    id = setTimeout(getList, 2000);
    } catch (e) {
    console.error(e);
    }
    }
    getList();
    return () => {
    clearTimeout(id);
    controller.abort();
    };
    }, [fetchData]);

    return list;
    }

    上面一种方案,我们的本质是让异步请求抛错,中断了后续代码的执行。那是不是我设置一个标记变量,标记是非卸载状态才执行后续的逻辑也可以呢?所以该方案应运而生。
    定义了一个 unmounted 变量,如果在卸载回调中标记其为 true。在异步任务后判断如果 unmounted === true 的话就不走后续的逻辑来实现类似的效果。

    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
    import { useState, useEffect } from "react";

    export function useFetchDataInterval(fetchData) {
    const [list, setList] = useState([]);
    useEffect(() => {
    let id;
    let unmounted;
    async function getList() {
    const data = await fetchData();
    if (unmounted) {
    return;
    }

    setList((list) => list.concat(data));
    id = setTimeout(getList, 2000);
    }
    getList();
    return () => {
    unmounted = true;
    clearTimeout(id);
    };
    }, [fetchData]);

    return list;
    }

    Count To 动态数字

    使用自定义 Hook 封装

    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
    const useCount = (initialValue: number) => {
    const [count, setCount] = useState(0);
    const maxTimes: number = 200;
    const interval: number = initialValue / maxTimes;

    useEffect(() => {
    let i = 0;
    let timer: number | undefined;
    if (initialValue <= maxTimes) {
    setCount(initialValue);
    return;
    }
    timer = setInterval(() => {
    if (i >= maxTimes) {
    clearInterval(timer);
    setCount(initialValue);
    return;
    }
    setCount((prev) => Math.floor(prev + interval));
    i++;
    }, 15);
    return () => clearInterval(timer);
    }, []);
    return [count];
    };

    路由问题

    问题描述:先匹配 404 页面,再匹配其他页面,会闪一下 404,再匹配其他。
    问题原因:useEffect 在页面渲染后执行。
    详细解释:动态路由生成放在 useEffect 中,那么是在页面渲染后才生成。渲染的时候没有匹配到 home 路由,所以走通配路由。
    解决方法:

    1. 不使用 useEffect,使用 useLayoutEffect,或者动态生成路由是同步代码。
    2. 数据过多可能会出现卡顿,不如在页面渲染前单独准备一个 home 路由。其他路由还是异步。
    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
    const Router = () => {
    // 从store获取路由表json,该json在登录时由请求接口获取
    const routes = useBoundStore((state) => state.routes);
    const [children, setChildren] = useState<RouteObject[]>([]);
    // 根据路由表生成子路由
    useEffect(() => {
    const router = getRoutes(routes);
    console.log("router: ", router);
    setChildren(router);
    }, [routes]);

    // 生成路由
    const rootRoutes: RouteObject[] = [
    {
    path: "/",
    element: <Navigate to="/home" />,
    },
    {
    path: "/",
    element: <BasicLayout />,
    children: children,
    },
    {
    path: "/login",
    element: <Login />,
    // errorElement: <ErrorPage />,
    },
    {
    path: "*",
    element: <NotFound />,
    },
    ];

    const router = useRoutes(rootRoutes);

    return router;
    };

    Keep-Alive

    缓存 dom.

    使用场景

    • 移动端中,用户访问了一个列表页,上拉浏览列表页的过程中,随着滚动高度逐渐增加,数据也将采用触底分页加载的形式逐步增加,列表页浏览到某个位置,用户看到了感兴趣的项目,点击查看其详情,进入详情页,从详情页退回列表页时,需要停留在离开列表页时的浏览位置上
    • 已填写但未提交的表单、管理系统中可切换和可关闭的功能标签等,这类数据随着用户交互逐渐变化或增长,这里理解为状态,在交互过程中,因为某些原因需要临时离开交互场景,则需要对状态进行保存

    在 React 中,我们通常会使用路由去管理不同的页面,而在切换页面时,路由将会卸载掉未匹配的页面组件,所以上述列表页例子中,当用户从详情页退回列表页时,会回到列表页顶部,因为列表页组件被路由卸载后重建了,状态被丢失。

    useHook 实现防抖和节流

    useDebounce

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 防抖
    function useDebounce(fn, delay, dep = []) {
    // 使用ref保存fn和timer
    const { current } = useRef({ fn, timer: null });

    useEffect(() => {
    current.fn = fn;
    }, [fn]);
    // 使用useCallback保证返回的函数不会一直重渲染
    return useCallback(
    (...args) => {
    if (current.timer) {
    clearTimeout(current.timer);
    }
    current.timer = setTimeout(() => {
    current.fn.call(this, ...args);
    }, delay);
    },
    [dep]
    );
    }

    useThrottle

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 节流
    function useThrottle(fn, delay, dep = []) {
    // 使用ref保存fn和timer
    const { current } = useRef({ fn, timer: null });

    useEffect(() => {
    current.fn = fn;
    }, [fn]);
    // 使用useCallback保证返回的函数不会一直重渲染
    return useCallback(
    (...args) => {
    if (!current.timer) {
    current.timer = setTimeout(() => {
    delete current.timer;
    }, delay);
    current.fn.call(this, ...args);
    }
    },
    [dep]
    );
    }

    // 这个方法缺点,第一次会先执行一次

    树形选项点击主项子项默认全选

    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
    carSelectedChange(val, row) {
    //先重置
    this.responseData.carInfo.map((car) => {
    if (val.find((x) => x.accCarSeq == car.accCarSeq)) {
    car.checked = true;
    } else {
    car.checked = false;
    }

    if (car.children) {
    car.children.map((p) => {
    if (val.find((x) => x.accCarSeq == p.accCarSeq)) {
    p.checked = true;
    } else {
    p.checked = false;
    }
    });
    }
    });

    if (row) {
    //单独操作行
    let action = "";

    //判断是否是新增
    if (val.find((x) => x.accCarSeq == row.accCarSeq)) {
    //新增
    action = "add";
    } else {
    //移除
    action = "remove";
    }
    let carIndex = this.responseData.carInfo.findIndex(
    (car) => car.accCarSeq == row.accCarSeq
    );

    if (carIndex >= 0) {
    this.responseData.carInfo[carIndex].children.map((p) => {
    p.checked = action == "add";
    this.$refs.carTable.toggleRowSelection(p, action == "add");
    });
    } else {
    this.responseData.carInfo.map((car) => {
    car.children.map((p) => {
    if (p.accCarSeq == row.accCarSeq && action == "add") {
    car.checked = true;
    this.$refs.carTable.toggleRowSelection(car, true);
    }
    });
    });
    }
    } else {
    //全选
    this.responseData.carInfo.map((car) => {
    car.children.map((p) => {
    p.checked = car.checked;
    this.$refs.carTable.toggleRowSelection(p, p.checked);
    });
    });
    }
    },

    常用测试工具 Jest,用于测试 js 相关.
    测试 react 使用@testing-library/react
    测试 react-hook 使用@testing-library/react-hooks

    案例

    1
    yarn add @testing-library/react-hooks msw -D

    新建__tests__文件夹,约定__为测试使用文件夹.

    单元测试

    __tests__/http中测试http模块.
    下面模块用于模拟异步请求.

    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
    import { setupServer } from "msw/node";
    const server = setupServer();

    // beforeAll是Jest测试库中一个方法,表示执行所有测试之前先执行
    beforeAll(() => server.listen());
    //每次测试完毕都重置mock路由
    afterEach(() => server.resetHandlers());
    //所有测试完毕后,关闭mock路由
    afterAll(() => server.close());

    // 开始测试,第二个参数是回调函数就是测试内容
    test("http异步发送请求", async () => {
    const endpoint = "test-endpoint";
    const mockResult = { mockValue: "mock" };

    // 使用msw模拟mock
    server.use(
    // rest是msw中用于restful接口的方法
    rest.get(`${apiUrl}/${endpoint}`, (req, res, ctx) => {
    res(ctx.json(mockResult));
    })
    );
    // 使用http模块返回mock值
    const result = await http(endpoint);
    // 期望http的返回值和mock数据相等(注意不是完全相等,完全是toBe)
    expect(result).toEqual(mockResult);
    });

    test("http请求时携带token", async () => {
    const token = "FAKE_TOKEN";
    const endpoint = "test-endpoint";
    const mockResult = { mockValue: "mock" };

    let request: any;

    // 使用msw模拟mock
    server.use(
    // rest是msw中用于restful接口的方法
    rest.get(`${apiUrl}/${endpoint}`, (req, res, ctx) => {
    request = req;
    return res(ctx.json(mockResult));
    })
    );
    // 使用http模块返回mock值
    await http(endpoint, { token });
    // 期望http的返回值和mock数据相等(注意不是完全相等,完全是toBe)
    expect(request.headers.get("Authorization")).toBe(`Bearer ${token}`);
    });

    测试 Hook

    新建__test__/use-async文件.

    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
    import { act, renderHook } from "@testing-library/react";
    import { useAsync } from "utils/use-async";

    const defaultState: ReturnType<typeof useAsync> = {
    stat: "idle",
    error: null,
    data: null,
    isIdle: true,
    isLoading: false,
    isError: false,
    isSuccess: false,
    run: expect.any(Function),
    setData: expect.any(Function),
    setError: expect.any(Function),
    retry: expect.any(Function),
    };

    const loadingState: ReturnType<typeof useAsync> = {
    ...defaultState,
    stat: "loading",
    isLoading: true,
    isIdle: false,
    };
    const successState: ReturnType<typeof useAsync> = {
    ...defaultState,
    stat: "success",
    isSuccess: true,
    isIdle: false,
    };

    // 测试脚本
    test("useAsync可以异步处理", async () => {
    let resolve: any, reject;
    const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
    });

    const { result } = renderHook(() => useAsync());
    // 期望Hook的默认返回值与test的默认值一致
    expect(result.current).toEqual(defaultState);

    let p: Promise<any>;
    // 如果操作包含改变setState,需要使用act包裹
    act(() => {
    p = result.current.run(promise);
    });
    // 期望刚创建的promise中是loading状态
    expect(result.current).toEqual(loadingState);

    // 期望promise执行完毕后,返回的值是success状态的值
    const resolvedValue = { mockValue: "resolved" };
    act(async () => {
    // 执行resolve,之后promise就是fulfilled
    resolve(resolvedValue);
    await p;
    });
    expect(result.current).toEqual({
    ...successState,
    data: resolvedValue,
    });
    });

    测试组件

    新建__tests__/mark.tsx用于测试高亮组件.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { renderHook } from "@testing-library/react-hooks";
    import { screen } from "@testing-library/react";
    import { Mark } from "../components/mark";

    test("Mark组件正确高亮关键词", () => {
    const name = "物料管理";
    const keyword = "管理";

    renderHook(() => <Mark name={name} keyword={keyword} />);

    // 期望keyword存在document中
    expect(screen.getByText(keyword)).toBeInTheDocument();
    expect(screen.getByText(keyword)).toHaveStyle("color: #257AFD");
    // 期望其他字没有高亮颜色
    expect(screen.getByText("物料")).not.toHaveStyle("color: #257AFD");
    });