使用 rollup 打包 JavaScript SDK

吾辈已经写了一个 TypeScript/JavaScript Cli 工具 liuli-cli,如有需要可以使用这个 Cli 直接生成一个开箱即用 SDK 项目,然后就可以直接开始写自己的代码,不需要太过关心下面的内容了 – 因为,它们都已然集成了。

场景

为什么要使用打包工具

如果我们想要写一个 JavaScript SDK,那么就不太可能将所有的代码都写到同一个 js 文件中。当然了,想做的话的确可以做到,但随着 JavaScript SDK 内容的增加,一个 js 文件容易造成开发冲突,以及测试上的困难,这也是现代前端基本上都依赖于打包工具的原因。

为什么打包工具是 rollup

现今最流行的打包工具是 webpack,然而事实上对于单纯的打包 JavaScript SDK 而言 webpack 显得有些太重了。webpack 终究是用来整合多种类型的资源而产生的(ReactJS/VueJS/Babel/TypeScript/Stylus),对于纯 JavaScript 库而言其实并没有必要使用如此 强大 的工具。而 rollup 就显得小巧精致,少许配置就能立刻打包了。

步骤

该记录的代码被吾辈放到了 GitHub,有需要的话可以看下。

前置要求

开始之前,我们必须要对以下内容有所了解

  • JavaScript
  • npm
  • babel
  • uglify
  • 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
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
// src/wait.js
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}

export default wait

// src/fetchTimeout.js
/**
* 为 fetch 请求添加超时选项
* 注:超时选项并非真正意义上的超时即取消请求,请求依旧正常执行完成,但会提前返回 reject 结果
* @param {Promise} fetchPromise fetch 请求的 Promise
* @param {Number} timeout 超时时间
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
function fetchTimeout(fetchPromise, timeout) {
var abortFn = null
//这是一个可以被 reject 的 Promise
var abortPromise = new Promise(function(resolve, reject) {
abortFn = function() {
reject('abort promise')
}
})
// 有一个 Promise 完成就立刻结束
var abortablePromise = Promise.race([fetchPromise, abortPromise])
setTimeout(function() {
abortFn()
}, timeout)
return abortablePromise
}

export default fetchTimeout

// src/main.js
import wait from './wait'
import fetchTimeout from './fetchTimeout'

/**
* 限制并发请求数量的 fetch 封装
*/
class FetchLimiting {
constructor({ timeout = 10000, limit = 10 }) {
this.timeout = timeout
this.limit = limit
this.execCount = 0
// 等待队列
this.waitArr = []
}

/**
* 执行一个请求
* 如果到达最大并发限制时就进行等待
* 注:该方法的请求顺序是无序的,与代码里的顺序无关
* @param {RequestInfo} url 请求 url 信息
* @param {RequestInit} init 请求的其他可选项
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
async _fetch(url, init) {
const _innerFetch = async () => {
console.log(
`执行 execCount: ${this.execCount}, waitArr length: ${
this.waitArr.length
}, index: ${JSON.stringify(this.waitArr[0])}`,
)
this.execCount++
const args = this.waitArr.shift(0)
try {
return await fetchTimeout(fetch(...args), this.timeout)
} finally {
this.execCount--
}
}
this.waitArr.push(arguments)
await wait(() => this.execCount < this.limit)
// 尝试启动等待队列
return _innerFetch()
}
}

export default FetchLimiting

使用 rollup 直接打包

安装 rollup

1
npm i rollup -D

在根目录创建一个 rollup.config.js 配置文件

1
2
3
4
5
6
7
8
9
10
11
12
export default {
// 入口文件
input: 'src/main.js',
output: {
// 打包名称
name: 'bundlea',
// 打包的文件
file: 'dist/bundle.js',
// 打包的格式,umd 支持 commonjs/amd/life 三种方式
format: 'umd',
},
}

添加一个 npm script

1
2
3
"scripts": {
"build": "rollup -c"
}

然后运行 npm run build 测试打包,可以看到 dist 目录下已经有 bundle.js 文件了

好了,到此为止我们已经简单使用 rollup 打包 js 了,下面的内容都是可选项,如果需要可以分节选读。

使用 babel 转换 ES5

然而,我们虽然已经将 main.js 打包了,然而实际上我们的代码没有发生什么变化。即:原本是 ES6 的代码仍然会是 ES6,而如果我们想要尽可能地支持更多的浏览器,目前而言还是需要兼容到 ES5 才行。

所以,我们需要 babel,它能够帮我们把 ES6 的代码编译成 ES5。

附:babel 被称为现代前端的 jquery。

首先,安装 babel 需要的包

1
npm i -D rollup-plugin-babel @babel/core @babel/plugin-external-helpers @babel/preset-env

rollup.config.js 中添加 plugins

1
2
3
4
5
6
7
8
9
10
import babel from 'rollup-plugin-babel'

export default {
plugins: [
// 引入 babel 插件
babel({
exclude: 'node_modules/**',
}),
],
}

添加 babel 的配置文件 .babelrc

1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
],
"plugins": ["@babel/plugin-external-helpers"]
}

再重新运行 npm run build,可以看到 bundle.js 中的代码已经被编译成 ES5 了。

使用 uglify 压缩生产环境代码

那么,生产中的代码还需要做什么呢?是的,压缩,减小 js 代码的体积是必要的。接下来,我们还需要使用 uglify 压缩我们打包后的 bundle.js 代码。

首先仍然是安装 uglify 相关的包

1
npm i -D rollup-plugin-uglify

然后在 rollup.config.js 中引入插件就好了

1
2
3
4
5
6
7
8
9
// 注意,这里引入需要使用 { uglify } 而非 uglify,因为 uglify 导出自身时使用的是 exports.uglify
import { uglify } from 'rollup-plugin-uglify'

export default {
plugins: [
// js 压缩插件,需要在最后引入
uglify(),
],
}

使用 ESLint 检查代码

如果我们想要需要多人协作统一代码风格,那么可以使用 ESLint 来强制规范。

首先,全局安装 eslint

1
npm i eslint -g

然后使用 eslint cli 初始化

1
eslint --init

下面的三项问题选择

  1. How would you like to configure ESLint? (Use arrow keys)
    Use a popular style guide
  2. Which style guide do you want to follow? (Use arrow keys)
    Standard (https://github.com/standard/standard)
  3. What format do you want your config file to be in? (Use arrow keys)
    JavaScript
  4. Would you like to install them now with npm?
    y

然后,我们发现项目根目录下多出了 .eslintrc.js,这是 eslit 的配置文件。然而,我们需要对其稍微修改一下,不然如果我们的代码中出现了浏览器中的对象,例如 document,eslint 就会傻傻的认为那是个错误!
修改后的 .eslintrc.js 配置

1
2
3
4
5
6
7
module.exports = {
extends: 'standard',
// 添加了运行环境设定,设置 browser 为 true
env: {
browser: true,
},
}

当我们查看打包后的 bundle.js 时发现 eslint 给我们报了一堆错误,所以我们需要排除掉 dist 文件夹
添加 .eslintignore 文件

1
dist

添加 rollup-plugin-eslint 插件,在打包之前进行格式校验

1
npm i -D rollup-plugin-eslint

然后引入它

1
2
3
4
5
6
7
8
import { eslint } from 'rollup-plugin-eslint'

export default {
plugins: [
// 引入 eslint 插件
eslint(),
],
}

这个时候,当你运行 npm run build 的时候,eslint 可能提示你一堆代码格式错误,难道我们还要一个个的去修复么?不,eslint 早已考虑到了这一点,我们可以添加一个 npm 脚本用于全局修复格式错误。

1
2
3
"scripts": {
"lint": "eslint --fix src"
}

然后运行 npm run lint,eslint 会尽可能修复格式错误,如果不能修复,会在控制台打印异常文件的路径,然后我们手动修复就好啦

其他 rollup 配置

添加代码映射文件

其实很简单,只要在 rollup.config.js 启用一个配置就好了

1
2
3
4
5
6
export default {
output: {
// 启用代码映射,便于调试之用
sourcemap: true,
},
}

多环境打包

首先移除掉根目录下的 rollup.config.js 配置文件,然后创建 build 目录并添加下面四个文件

1
2
3
4
5
6
7
8
9
10
11
12
// build/util.js
import path from 'path'

/**
* 根据相对路径计算真是的路径
* 从当前类的文件夹开始计算,这里是 /build
* @param {String} relaPath 相对路径
* @returns {String} 绝对路径
*/
export function calcPath(relaPath) {
return path.resolve(__dirname, relaPath)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// build/rollup.config.dev.js
import { eslint } from 'rollup-plugin-eslint'
import { calcPath } from './util'
import { name } from '../package.json'

export default {
// 入口文件
input: calcPath('../src/main.js'),
output: {
// 打包名称
name,
// 打包的文件
file: calcPath(`../dist/${name}.js`),
// 打包的格式,umd 支持 commonjs/amd/life 三种方式
format: 'umd',
// 启用代码映射,便于调试之用
sourcemap: true,
},
plugins: [
// 引入 eslint 插件,必须在 babel 之前引入,因为 babel 编译之后的代码未必符合 eslint 规范,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
25
26
27
28
29
30
31
32
33
34
// build/rollup.config.prod.js
import babel from 'rollup-plugin-babel'
// 注意,这里引入需要使用 { uglify } 而非 uglify,因为 uglify 导出自身时使用的是 exports.uglify
import { uglify } from 'rollup-plugin-uglify'
import { eslint } from 'rollup-plugin-eslint'
import { calcPath } from './util'
import dev from './rollup.config.dev'
import { name } from '../package.json'

export default [
dev,
{
// 入口文件
input: calcPath('../src/main.js'),
output: {
// 打包名称
name,
// 打包的文件
file: calcPath(`../dist/${name}.min.js`),
// 打包的格式,umd 支持 commonjs/amd/life 三种方式
format: 'umd',
},
plugins: [
// 引入 eslint 插件,必须在 babel 之前引入,因为 babel 编译之后的代码未必符合 eslint 规范,eslint 仅针对我们 [原本] 的代码
eslint(),
// 引入 babel 插件
babel({
exclude: calcPath('../node_modules/**'),
}),
// js 压缩插件,需要在最后引入
uglify(),
],
},
]
1
2
3
4
5
6
// build/rollup.config.js
import dev from './rollup.config.dev'
import prod from './rollup.config.prod'

// 如果当前环境时 production,则使用 prod 配置,否则使用 dev 配置
export default process.env.NODE_ENV === 'production' ? prod : dev

修改 npm 脚本

1
2
3
4
5
"scripts": {
"build:dev": "rollup -c build/rollup.config.js --environment NODE_ENV:development",
"build:prod": "rollup -c build/rollup.config.js --environment NODE_ENV:production",
"build": "npm run build:dev && npm run build:prod",
}

那么,关于使用 rollup 打包 JavaScript 的内容就先到这里了,有需要的话后续吾辈还会继续更新的!

JavaScript 自定义限流队列 fetch

为什么需要它?

有些时候不得不需要限制并发 fetch 的请求数量,避免请求过快导致 IP 封禁

需要做到什么?

  • 允许限制 fetch 请求同时存在的数量
  • 时间过久便认为是超时了

如何实现?

暂停请求

该方法的请求是无序的!

  1. 使用 class 定义默认超时设置和请求数量限制的构造函数
  2. 在请求前判断当前请求的数量,添加请求等待数量
    1. 如果请求数量已满,则进行等待
    2. 如果请求数量未满,则删除一个请求等待数量
  3. 请求完成,删除当前请求数量

等待队列:循环监听

该方法需要使用回调函数

  1. 使用 class 定义默认超时设置和请求数量限制的构造函数
  2. 在请求前将请求 argments 添加到等待队列中
  3. 使用 setInterval 函数持续监听队列和当前执行的请求数
    • 发现请求数量没有到达最大值,且等待队列中还有值,那么就执行一次请求

等待队列:触发钩子

  1. 使用 class 定义默认超时设置和请求数量限制的构造函数
  2. 在请求前将请求 argments 添加到等待队列中
  3. 添加完成,等待当前请求数量未满
  4. 尝试启动等待队列(钩子)

实现代码

暂停请求实现

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
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
/**
* 为 fetch 请求添加超时选项
* 注:超时选项并非真正意义上的超时即取消请求,请求依旧正常执行完成,但会提前返回 reject 结果
* @param {Promise} fetchPromise fetch 请求的 Promise
* @param {Number} timeout 超时时间
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
function promiseTimeout(fetchPromise, timeout) {
var abortFn = null
//这是一个可以被reject的promise
var abortPromise = new Promise(function(resolve, reject) {
abortFn = function() {
reject('abort promise')
}
})
var abortablePromise = Promise.race([fetchPromise, abortPromise])
setTimeout(function() {
abortFn()
}, timeout)

return abortablePromise
}
/**
* 限制并发请求数量的 fetch 封装
*/
class RequestLimiting {
constructor({ timeout = 10000, limit = 10 }) {
this.timeout = timeout
this.limit = limit
this.execCount = 0
this.waitCount = 0
}

/**
* 执行一个请求
* 如果到达最大并发限制时就进行等待
* 注:该方法的请求顺序是无序的,与代码里的顺序无关
* @param {RequestInfo} url 请求 url 信息
* @param {RequestInit} init 请求的其他可选项
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
async _fetch(url, init) {
this.waitCount++
await wait(() => this.execCount < this.limit)
this.waitCount--
this.execCount++
try {
return await promiseTimeout(fetch(url, init), this.timeout)
} finally {
this.execCount--
}
}
}

使用示例

1
2
3
4
5
6
7
const requestLimiting = new RequestLimiting({ timeout: 500, limit: 1 })
new Array(100).fill(0).forEach(i =>
requestLimiting
._fetch('/')
.then(res => console.log(res))
.catch(err => console.log(err)),
)

等待队列:循环监听实现

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
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
/**
* 为 fetch 请求添加超时选项
* 注:超时选项并非真正意义上的超时即取消请求,请求依旧正常执行完成,但会提前返回 reject 结果
* @param {Promise} fetchPromise fetch 请求的 Promise
* @param {Number} timeout 超时时间
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
function promiseTimeout(fetchPromise, timeout) {
var abortFn = null

//这是一个可以被reject的promise
var abortPromise = new Promise(function(resolve, reject) {
abortFn = function() {
reject('abort promise')
}
})

var abortablePromise = Promise.race([fetchPromise, abortPromise])

setTimeout(function() {
abortFn()
}, timeout)

return abortablePromise
}
/**
* 限制并发请求数量的 fetch 封装
*/
class RequestLimiting {
constructor({ timeout = 10000, limit = 10 }) {
this.timeout = timeout
this.limit = limit
this.execCount = 0
// 等待队列
this.waitArr = []

// 监视 execCount 的值
setInterval(async () => {
if (this.execCount >= this.limit) {
return
}
console.debug(
`执行 execCount: ${this.execCount}, waitArr length: ${
this.waitArr.length
}, index: ${JSON.stringify(this.waitArr[0])}`,
)
const args = this.waitArr.shift(0)
if (!args) {
return
}
this.execCount++
const callback = args[2]
try {
// 如果没有错误就返回 res
callback({ res: await promiseTimeout(fetch(...args), this.timeout) })
} catch (err) {
// 否则返回 err
callback({
err: err,
})
} finally {
this.execCount--
}
}, 100)
}

/**
* 执行一个请求
* 如果到达最大并发限制时就进行等待
* 注:该方法的请求顺序是无序的,与代码里的顺序无关
* @param {RequestInfo} url 请求 url 信息
* @param {RequestInit} init 请求的其他可选项
* @param {Function} callback 回调函数
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
async _fetch(url, init, callback) {
this.waitArr.push(arguments)
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const requestLimiting = new RequestLimiting({ timeout: 500, limit: 1 })
new Array(100).fill(0).forEach((item, i) =>
requestLimiting._fetch(
'/',
{
// 这里设置添加时的 index,用于验证是否真的顺序执行了
headers: {
index: i,
},
},
// 这里使用了回调函数,参数使用解构得到
({ res, err }) => {
console.log(`res: ${res}, err: ${err}`)
},
),
)

等待队列:触发钩子实现

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
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
/**
* 为 fetch 请求添加超时选项
* 注:超时选项并非真正意义上的超时即取消请求,请求依旧正常执行完成,但会提前返回 reject 结果
* @param {Promise} fetchPromise fetch 请求的 Promise
* @param {Number} timeout 超时时间
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
function promiseTimeout(fetchPromise, timeout) {
var abortFn = null
//这是一个可以被 reject 的 Promise
var abortPromise = new Promise(function(resolve, reject) {
abortFn = function() {
reject('abort promise')
}
})
// 有一个 Promise 完成就立刻结束
var abortablePromise = Promise.race([fetchPromise, abortPromise])
setTimeout(function() {
abortFn()
}, timeout)
return abortablePromise
}
/**
* 限制并发请求数量的 fetch 封装
*/
class RequestLimiting {
constructor({ timeout = 10000, limit = 10 }) {
this.timeout = timeout
this.limit = limit
this.execCount = 0
// 等待队列
this.waitArr = []
}

/**
* 执行一个请求
* 如果到达最大并发限制时就进行等待
* 注:该方法的请求顺序是无序的,与代码里的顺序无关
* @param {RequestInfo} url 请求 url 信息
* @param {RequestInit} init 请求的其他可选项
* @returns {Promise} 如果超时就提前返回 reject, 否则正常返回 fetch 结果
*/
async _fetch(url, init) {
const _innerFetch = async () => {
console.log(
`执行 execCount: ${this.execCount}, waitArr length: ${
this.waitArr.length
}, index: ${JSON.stringify(this.waitArr[0])}`,
)
this.execCount++
const args = this.waitArr.shift(0)
try {
return await promiseTimeout(fetch(...args), this.timeout)
} finally {
this.execCount--
}
}
this.waitArr.push(arguments)
await wait(() => this.execCount < this.limit)
// 尝试启动等待队列
return _innerFetch()
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
const requestLimiting = new RequestLimiting({ timeout: 500, limit: 1 })
new Array(100).fill(0).forEach((item, i) =>
requestLimiting
._fetch('/', {
// 这里设置添加时的 index,用于验证是否真的顺序执行了
headers: {
index: i,
},
})
.then(res => console.log(res))
.catch(err => console.log(err)),
)

总结

目前而言,最后一种实现是最好的,同时实现了两种规范

  • 返回 Promise,避免使用回调函数
  • 请求执行与添加顺序相同

图床「神器」PicGo v2.0 更新,插件系统终于来了

转自 https://molunerfinn.com/picgo-v2.0-update,作为一个经常使用 Markdown 写作的人而言,一个好的图床工具是很重要的,所以吾辈才会转载这篇文章呢!

前言

距离上次更新 (v1.6.2) 已经过去了 5 个月,很抱歉 2.0 版本来得这么晚。本来想着在 18 年 12 月(PicGo 一周年的时候)发布 2.0 版本,但是无奈正值研究生开题期间,需要花费不少时间(不然毕不了业了 T T),所以这个大版本姗姗来迟。不过从这个版本开始,正式支持插件系统,发挥你们的无限想象,PicGo 也能成为一个极致的效率工具。

除了发布 PicGo 2.0 本体,一同发布的还有 PicGo-Core(PicGo 2.0 的底层,支持 CLI 和 API 调用),以及 VSCode 的 PicGo 插件 vs-picgo 等。

插件系统

PicGo 的底层核心其实是 PicGo-Core。这个核心主要就是一个流程系统。(它支持在 Node.js 环境下全局安装,可以通过命令行上传图片文件、也可以接入 Node.js 项目中调用 api 实现上传。)

PicGo-Core 的上传流程如下:
PicGo-Core 上传流程

Input 一般是文件路径,经过 Transformer 读取信息,传入 Uploader 进行上传,最后通过 Output 输出结果。而插件可以接入三个生命周期(beforeTransformbeforeUploadafterUpload)以及两种部件(TransformerUploader)。

换句话说,如果你书写了合适的 Uploader,那么可以上传到不同的图床。如果你书写了合适的 Transformer,你可以通过 URL 先行下载文件再通过 Uploader 上传等等。

另外,如果你不想下载 PicGo 的 electron 版本,也可以通过 npm 安装 picgo 来实现命令行一键上传图片的快速体验。

PicGo 除了 PicGo-Core 提供的核心功能之外,额外给 GUI 插件给予一些自主控制权。

比如插件可以拥有自己的菜单项:
菜单

因此 GUI 插件除了能够接管 PicGo-Core 给予的上传流程,还可以通过 PicGo 提供的 guiApi 等接口,在插件页面实现一些以前单纯通过 上传区 实现不了的功能:

比如可以通过打开一个 InputBox 获取用户的输入:
InputBox

可以通过打开一个路径来执行其他功能(而非只是上传文件):
打开一个路径来执行其他功能

甚至还可以直接在插件面板通过调用 api 实现上传。

另外插件可以监听相册里图片删除的事件:
远端删除

这个功能就可以写一个插件来实现相册图片和远端存储里的同步删除了。

通过如上介绍,我现在甚至就已经能想到插件系统能做出哪些有意思的插件了。

比如:

  1. 结合 GitHub 刚刚开放的免费私人仓库,可以通过插件实现 PicGo 的相册以及配置文件同步。
  2. 结合 TinyPng 等工具实现上传前给图片瘦身。(不过可能挺影响上传速度的。)
  3. 结合一些 Canvas 工具,可以在上传图片前给图片加水印。
  4. 通过指定文件夹,将文件夹内部的 markdown 里的图片地址进行图床迁移。
  5. 等等。。

希望这个插件系统能够给 PicGo 带来更强大的威力,也希望它能够成为你的极致的效率工具。

需要注意的是,想要使用 PicGo 2.0 的插件系统,需要先行安装 Node.js 环境,因为 PicGo 的插件安装依赖 npm

2.0 其他更新内容

除了上面说的插件系统,PicGo 2.0 还更新了如下内容:

  • 底层重构了之后,某些图床上传不通过 base64 值的将会提升不少速度。比如 SM.MS 图床等。而原本就通过 base64 上传的图床速度不变。
  • 增加一些配置项,比如打开配置文件(包括了上传的图片列表)、mini 窗口置顶、代理设置等。
    更多配置项
  • 在相册页可以选择复制的链接格式,不用再跑去上传页改了。
    相册允许复制链接格式
  • 增加不同页面切换的淡入淡出动画。
  • macOS 版本配色小幅更新。(Windows 版本配色更新 Fluent Design 效果预计在 2.1 版本上线)
  • 更新 electron 版本从 1.8->4.0,启动速度更快了,性能也更好了。

Bug Fixed

  • 修复:macOS 多屏下打开详细窗口时位置错误的问题
  • 修复:多图片上传重命名一致的问题
  • 修复:拖拽图片到软件会自动在软件内部打开这张图片的 bug
  • 修复:重命名窗口只出现在屏幕中央而不是跟随主窗口的 bug

VSCode 的 PicGo 插件 vs-picgo

在 PicGo-Core 发布不久,就有人根据 PicGo-Core 的 API 编写了 VSCode 版的 PicGo 插件。使用起来也非常方便:

  • 截图上传
    截图上传
  • 文件浏览器选择文件上传
    文件浏览器选择文件上传
  • 输入文件路径上传
    输入文件路径上传

配置项与 PicGo 的图床的配置项基本保持一致。在 VSCode 插件栏搜索 PicGo 即可下载安装与体验!

结语

PicGo 第一个稳定版本是在少数派上发布的,详见 PicGo:基于 Electron 的图片上传工具。支持 macOS、Windows、Linux 三平台,开源免费,界面美观,也得到了很多朋友的认可。如果你对它有什么意见或者建议,也欢迎在 issues 里指出。如果你喜欢它,不妨给它点个 star。如果对你真的很有帮助,不妨请我喝杯咖啡(PicGo 的 GitHub 首页有赞助的二维码)?

下载地址:https://github.com/Molunerfinn/PicGo/releases
Windows 用户请下载.exe文件,macOS 用户请下载.dmg文件,Linux 用户请下载.AppImage文件。

Happy uploading!

SpringMVC @RequestParam 参数不能自动转换泛型集合

GitHub example

场景

在使用 SpringMVC 传参的时候遇到的一个问题,本来需要的参数类型是 Map<Integer, Integer>l。然而浏览器传递过来的是 Map<String, String>。然而,此时 SpringMVC 并没有直接说参数类型错误。

Controller 大概是下面这样

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/")
public class HomeApi {
private final Logger log = LoggerFactory.getLogger(this.getClass());

@PostMapping("testMap")
public Map<Integer, Integer> testMap(@RequestParam Map<Integer, Integer> map) {
// 简单的打印 map
log.info("map: {}", map);
return map;
}
}

然而,当吾辈调用这个接口时,却出现了错误

1
2
3
4
5
6
7
8
9
10
const fd = new FormData()
fd.append(1, 1)
fd.append(2, 2)
fd.append(3, 3)
fetch('/testMap', {
method: 'post',
body: fd,
})
.then(res => res.json())
.then(json => console.log(json))

报错信息

1
2
3
4
5
6
7
{
"timestamp": "2019-01-17T09:59:16.809+0000",
"status": 500,
"error": "Internal Server Error",
"message": "Could not write JSON: java.lang.String cannot be cast to java.lang.Number; nested exception is com.fasterxml.jackson.databind.JsonMappingException: java.lang.String cannot be cast to java.lang.Number",
"path": "/testMap"
}

可以看到,Spring 告诉我们,不能转换 StringInteger。这是为什么呢?让我们来调试一下!

可以看到,确实进入了方法,然而最后一步却报错了
进入了方法

报错原因

  • 泛型只在编译期有约束,运行时泛型实际并不存在,所以可以进入方法而非出现参数错误
  • 最终转换类型为 Map<Integer, Integer> 的时候发现类型错误

那么,我们是否可以手动将之转换为 Map<Integer, Integer> 呢?修改代码如下

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/")
public class HomeApi {
private final Logger log = LoggerFactory.getLogger(this.getClass());

@PostMapping("testMap")
public Map<Integer, Integer> testMap(@RequestParam Map<Integer, Integer> map) {
log.info("map: {}", map);
return map.entrySet().stream().collect(Collectors.toMap(kv -> Integer.parseInt(kv.getKey()), kv -> Integer.parseInt(kv.getValue())));
}
}

再次调用,发现在 kv.getKey() 这里就已经发生了异常。那么,我们应该如何解决呢?

解决

使用 Map<String, String>

其实,我们只要将参数类型声明为 Map<String, String>,然后再手动转换即可

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/")
public class HomeApi {
private final Logger log = LoggerFactory.getLogger(this.getClass());

@PostMapping("testMap")
public Map<Integer, Integer> testMap(@RequestParam Map<String, String> map) {
log.info("map: {}", map);
return map.entrySet().stream().collect(Collectors.toMap(kv -> Integer.parseInt(kv.getKey()), kv -> Integer.parseInt(kv.getValue())));
}
}

再次调用,一切恢复了正常!

使用 @RequestBody

除此之外,我们或许还有另外一种方法,使用支持泛型的参数的 @RequestBody 标识为 json 参数。修改代码如下

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/")
public class HomeApi {
private final Logger log = LoggerFactory.getLogger(this.getClass());

@PostMapping("testMap")
public Map<Integer, Integer> testMap(@RequestBody Map<Integer, Integer> map) {
log.info("map: {}", map);
return map;
}
}

同时,客户端也要修改为以 json 的形式,将参数传递给服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch('/testMap', {
method: 'post',
body: JSON.stringify({
1: 1,
2: 2,
3: 3,
}),
headers: {
'content-type': 'application/json',
},
})
.then(res => res.json())
.then(json => console.log(json))

那么,关于 SpringMVC 不能自动转换泛型集合便到这里就结束啦

JavaScript 避免使用 if-else 的方法

场景

在日常编写 JavaScript 代码的过程中,或许会遇到一个很常见的问题。根据某个状态,进行判断,并执行不同的操作。吾辈并不是说 if-else 不好,简单的逻辑判断 if-else 毫无疑问是个不错的选择。然而在很多时候似乎我们习惯了使用 if-else,导致代码不断庞大的同时复杂度越来越高,所有的 JavaScript 代码都乱作一团,后期维护时越发困难。

GitHub, 演示地址

例如下面这段代码,点击不同的按钮,显示不同的面板。

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
<!-- index.html -->
<!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>JavaScript 避免使用 if-else</title>
</head>
<body>
<main>
<div id="tab">
<label>
<input type="radio" data-index="1" name="form-tab-radio" />
第一个选项卡
</label>
<label>
<input type="radio" data-index="2" name="form-tab-radio" />
第二个选项卡
</label>
<label>
<input type="radio" data-index="3" name="form-tab-radio" />
第三个选项卡
</label>
</div>
<form id="extends-form"></form>
</main>
<script src="./js/if-else.js"></script>
</body>
</html>
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
// js/if-else.js
document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach(el => {
el.addEventListener('click', () => {
const index = el.dataset.index
const header = el.parentElement.innerText.trim()
// 如果为 1 就添加一个文本表单
if (index === '1') {
document.querySelector('#extends-form').innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
} else if (index === '2') {
document.querySelector('#extends-form').innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
function readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = event => {
resolve(event.target.result)
}
fr.onerror = error => {
reject(error)
}
fr.readAsDataURL(file)
})
}
document.querySelector('#avatar').addEventListener('change', evnet => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes('image')) {
return
}
readLocalFile(file).then(link => {
document.querySelector('#avatar-preview').src = link
})
})
} else if (index === '3') {
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document.querySelector('#extends-form').innerHTML = `
<header><h2>${header}</h2></header>
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result"></ul>
</div>
`
document
.querySelector('#search-text')
.addEventListener('input', evnet => {
const searchText = event.target.value
document.querySelector('#search-result').innerHTML = initData
.filter(v => v.includes(searchText))
.map(v => `<li>${v}</li>`)
.join()
})
}
})
})

那么,我们可以如何优化呢?

抽取函数

稍有些经验的 developer 都知道,如果一个函数过于冗长,那么就应该将之分离成多个单独的函数。

所以,我们的代码变成了下面这样

实现思路

  1. 抽取每个状态对应执行的函数
  2. 根据状态使用 if-else/switch 判断然后调用不同的函数
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
// 抽取函数

function switchFirst(header) {
document.querySelector('#extends-form').innerHTML = `
${header}
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}

function switchSecond(header) {
document.querySelector('#extends-form').innerHTML = `
${header}
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
function readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = event => {
resolve(event.target.result)
}
fr.onerror = error => {
reject(error)
}
fr.readAsDataURL(file)
})
}
document.querySelector('#avatar').addEventListener('change', evnet => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes('image')) {
return
}
readLocalFile(file).then(link => {
document.querySelector('#avatar-preview').src = link
})
})
}

function switchThree(header) {
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document.querySelector('#extends-form').innerHTML = `
${header}
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result"></ul>
</div>
`
document.querySelector('#search-text').addEventListener('input', evnet => {
const searchText = event.target.value
document.querySelector('#search-result').innerHTML = initData
.filter(v => v.includes(searchText))
.map(v => `<li>${v}</li>`)
.join()
})
}

function switchTab(el) {
const index = el.dataset.index
const header = `<header><h2>${el.parentElement.innerText.trim()}</h2></header>`
// 如果为 1 就添加一个文本表单
if (index === '1') {
switchFirst(header)
} else if (index === '2') {
switchSecond(header)
} else if (index === '3') {
switchThree(header)
}
}

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach(el => {
el.addEventListener('click', () => switchTab(el))
})

ES6 class:有限状态机

如果你知道 ES6 的 class 的话,应该也了解到目前 js 可以使用 class 模拟面向对象的继承,以及多态。

实现思路

  1. 创建一个基类,并在其中声明一个需要被子类重写的方法
  2. 根据不同的状态创建不同的子类,并分别实现基类的方法
  3. 添加一个 Builder 类,用于根据不同的状态判断来创建不同的子类
  4. 调用者使用 Builder 类构造出来的对象调用父类中声明的方法

具体实现

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
// 有限状态机

class Tab {
// 基类里面的初始化方法放一些通用的操作
init(header) {
const html = `
<header><h2>${header}</h2></header>
${this.initHTML()}
`
document.querySelector('#extends-form').innerHTML = html
}

// 给出一个方法让子类实现,以获得不同的 HTML 内容
initHTML() {}
}

class Tab1 extends Tab {
// 实现 initHTML,获得选项卡对应的 HTML
initHTML() {
return `
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
}

class Tab2 extends Tab {
initHTML() {
return `
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
// 重写 init 初始化方法,并首先调用基类通用初始化的方法
init(header) {
super.init(header)
document.querySelector('#avatar').addEventListener('change', evnet => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes('image')) {
return
}
this.readLocalFile(file).then(link => {
document.querySelector('#avatar-preview').src = link
})
})
}
// 子类独有方法
readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = event => {
resolve(event.target.result)
}
fr.onerror = error => {
reject(error)
}
fr.readAsDataURL(file)
})
}
}

class Tab3 extends Tab {
initHTML() {
return `
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result" />
</div>
`
}
init(header) {
super.init(header)
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document.querySelector('#search-text').addEventListener('input', evnet => {
const searchText = event.target.value
document.querySelector('#search-result').innerHTML = initData
.filter(v => v.includes(searchText))
.map(v => `<li>${v}</li>`)
.join()
})
}
}

class TabBuilder {
/**
* 获取一个标签子类对象
* @param {Number} index 索引
* @returns {Tab} 子类对象
*/
static getInstance(index) {
// Tab 构造类,用于根据不同的状态 index 构造不同的 Tab 对象
const tabMap = new Map(
Object.entries({
1: () => new Tab1(),
2: () => new Tab2(),
3: () => new Tab3(),
}),
)
return tabMap.get(index)()
}
}

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach(el => {
el.addEventListener('click', () =>
// 首先通过 Builder 构造类获取 Tab 子类实例,然后调用初始化方法 init
TabBuilder.getInstance(el.dataset.index).init(
el.parentElement.innerText.trim(),
),
)
})

主要优势

  • 分离了状态与执行函数之间的关联,具体执行由具体的子类决定
  • 子类允许包含独有的属性/方法
  • 可扩展性更好,随时可以扩展任意多个子类

ES6 class:无限状态机

上面使用 class 继承多态实现的状态机虽然很好,但却并不能应对 不确定 具体有多少种状态的情况。因为每个子类都与父类有着强关联,直接在 Builder 类中进行了声明。那么,有没有一种方式,可以在添加/删除后不影响基类或者构造类呢?

  1. 创建一个基类,并在其中声明一个需要被子类重写的方法
  2. 添加一个 Builder 类,具体子类对应的状态由子类的某个属性决定
  3. 根据不同的状态创建不同的子类,并分别实现基类的方法,调用 Builder 类的方法注册自身

    此处因为 js 无法通过反射拿到所有子类,所以子类需要在 Builder 类注册自己

  4. 使用 Builder 构造子类对象,并调用基类声明的方法

具体实现

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
// 无限状态机

class Tab {
// 基类里面的初始化方法放一些通用的操作
init(header) {
const html = `
<header><h2>${header}</h2></header>
${this.initHTML()}
`
document.querySelector('#extends-form').innerHTML = html
}

// 给出一个方法让子类实现,以获得不同的 HTML 内容
initHTML() {}
}

/**
* 状态机
* 用于避免使用 if-else 的一种方式
*/
class StateMachine {
static getBuilder() {
const clazzMap = new Map()
/**
* 状态注册器
* 更好的有限状态机,分离子类与构建的关系,无论子类如何增删该都不影响基类及工厂类
*/
return new class Builder {
// noinspection JSMethodCanBeStatic
/**
* 注册一个 class,创建子类时调用,用于记录每一个 [状态 => 子类] 对应
* @param state 作为键的状态
* @param clazz 对应的子类型
* @returns {*} 返回 clazz 本身
*/
register(state, clazz) {
clazzMap.set(state, clazz)
return clazz
}

// noinspection JSMethodCanBeStatic
/**
* 获取一个标签子类对象
* @param {Number} state 状态索引
* @returns {QuestionType} 子类对象
*/
getInstance(state) {
const clazz = clazzMap.get(state)
if (!clazz) {
return null
}
//构造函数的参数
return new clazz(...Array.from(arguments).slice(1))
}
}()
}
}
/**
* 状态注册器
* 更好的有限状态机,分离子类与构建的关系,无论子类如何增删该都不影响基类及工厂类
*/
const builder = StateMachine.getBuilder()

const Tab1 = builder.register(
1,
class Tab1 extends Tab {
// 实现 initHTML,获得选项卡对应的 HTML
initHTML() {
return `
<div>
<label for="name">姓名</label>
<input type="text" name="name" id="name" />
</div>
<div>
<label for="age">年龄</label>
<input type="number" name="age" id="age" />
</div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
},
)

const Tab2 = builder.register(
2,
class Tab2 extends Tab {
initHTML() {
return `
<div>
<label for="avatar">头像</label>
<input type="file" name="avatar" id="avatar" />
</div>
<div><img id="avatar-preview" src="" /></div>
<div>
<button type="submit">提交</button> <button type="reset">重置</button>
</div>
`
}
// 重写 init 初始化方法,并首先调用基类通用初始化的方法
init(header) {
super.init(header)
document.querySelector('#avatar').addEventListener('change', evnet => {
const file = evnet.target.files[0]
if (!file) {
return
}
if (!file.type.includes('image')) {
return
}
this.readLocalFile(file).then(link => {
document.querySelector('#avatar-preview').src = link
})
})
}
// 子类独有方法
readLocalFile(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = event => {
resolve(event.target.result)
}
fr.onerror = error => {
reject(error)
}
fr.readAsDataURL(file)
})
}
},
)

const Tab3 = builder.register(
3,
class Tab3 extends Tab {
initHTML() {
return `
<div>
<label for="search-text">搜索文本</label>
<input type="text" name="search-text" id="search-text" />
<ul id="search-result" />
</div>
`
}
init(header) {
super.init(header)
const initData = new Array(100).fill(0).map((v, i) => `第 ${i} 项内容`)
document
.querySelector('#search-text')
.addEventListener('input', evnet => {
const searchText = event.target.value
document.querySelector('#search-result').innerHTML = initData
.filter(v => v.includes(searchText))
.map(v => `<li>${v}</li>`)
.join()
})
}
},
)

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach(el => {
el.addEventListener('click', () =>
// 调用方式不变
builder
.getInstance(Number.parseInt(el.dataset.index))
.init(el.parentElement.innerText.trim()),
)
})

主要优势

  • 可扩展性最好,添加/修改/删除子类不影响父类及构造类

那么,关于 JavaScript 中如何避免使用 if-else 到这里就结束啦

JavaScript 使用 Promise

场景

为什么要使用 Promise?

JavaScript 异步发展史:回调函数 -> Promise -> async/await

传统异步使用回调函数,回调意味着嵌套,当你需要使用很多异步函数时,那你需要非常多的回调函数,可能形成回调地狱。
有问题就有人解决,js 没有多线程,所以天生就是异步的。正是因为异步的广泛性,所以很早之前就有人着力于解决异步回调的问题,github 上有很多已经废弃的库就是用于解决这个问题的。
然而现在,es6 出现了 Promise,它能把嵌套回调压平为一层的链式调用,并且写进了 js 标准里。es7 甚至出现了更加优雅的方式,async/await,能以同步的方式写异步的代码。当然,本质上只是 Promise 的一个语法糖,但其重要性也是不言而喻的——异步回调地狱已经不存在了!
说了这么多,那么平常我们应该怎么使用 Promise 呢?

使用 Promise

一般而言,我们作为使用者是无需创建 Promise 的,支持 Promise 的函数会返回一个 Promise 对象给我们,然后我们使用它的方法 then/catch 即可。

  • then():当前的 JavaScript 已经完成,要进行下一步的同步/异步操作了
  • catch():用于捕获 Promise 链式调用中可能出现的错误

注:then/catch 均返回一个新的 Promise

例如我们有这样一个需求

  1. 等待资源 A 加载完成
  2. 在 A 资源加载完成之后等待 B 资源加载完成

之前使用回调函数,我们的代码可能是这样的

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
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @param {Function} callback 回调函数
*/
function wait(param, callback) {
if (typeof param === 'number') {
setTimeout(callback, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
callback()
}
}, 100)
} else {
callback()
}
}

wait(
() => document.querySelector('#a'),
() => {
wait(
() => document.querySelector('#b'),
() => {
console.log('a, b 两个资源已经全部加载完成')
},
)
},
)
// 结果
// a, b 两个资源已经全部加载完成

可以看到,上面如果还需要等待 c,d,e,f... 资源,那么回调函数的层级将是无法接受的。
现在,我们使用 Promise 改造一下代码

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
// 先不要管这个函数的具体实现,下面再说如何自己封装 Promise
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}

wait(() => document.querySelector('#a'))
// 注意这里的 wait(() => document.querySelector('#b')) 同样是一个异步函数,返回了一个 Promise
// 接下来,有趣的地方来了
// 很明显,wait 是一个异步函数。wait 函数的 then 函数调用了另一个异步函数,然而 then 会等待异步执行完成,才继续执行后面的函数
.then(() => wait(() => document.querySelector('#b')))
// 这里仍然会等待上面的 Promise 完成之后才执行下面的内容
.then(() => console.log('a, b 两个资源已经全部加载完成'))
// 结果
// a, b 两个资源已经全部加载完成

下面我们尝试使用一下 catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
wait(() => document.querySelector('#a'))
.then(() => wait(() => document.querySelector('#b')))
.then(() => {
throw new Error('执行了某些操作发生了异常')
})
// 上面抛出了异常并且没有使用 catch 处理的话就会继续找下一个调用,直到找到处理的 catch,或者调用结束为止
.then(() => console.log('a, b 两个资源已经全部加载完成'))
// 捕获上面的 then() 发生的异常,保证后面的调用正常执行
.catch(error => console.log('使用 catch 捕获的异常: ', error))
.then(() => console.log('测试异步函数结束'))

// 结果
// 使用 catch 捕获的异常: Error: 执行了某些操作发生了异常
// at wait.then.then (<anonymous>:4:11)
// VM272:9 测试异步函数结束

可以参考 MDN 上的教程 使用 Promises

封装 Promise

那么,你是否也对上面自定义的 wait 函数感到好奇呢?我们来详细的了解一下具体如何做到的吧!

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
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
// 这里返回了一个 Promise 对象,Promise 的构造函数要求一个函数参数
// 函数的参数实际上有两个,resolve 和 reject,分别代表 [已经完成] 和 [出现错误]
// 注:这个函数是立刻执行的,当 resolve 或 reject 执行时,这个 Promise 算是结束了,将进入下一个 then/catch 调用
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
// 这里执行了代码,如果有什么结果需要传递给下一个调用,则直接放到 resolve 函数内即可
resolve()
}
}, 100)
} else {
resolve()
}
})
}

同样的,我们也可以使用 Promise 封装其他函数

  • timeout:一个简单的 setTimeout() 的封装
  • readLocal:读取本地浏览器选择的文件
  • timing:测试函数执行的时间,不管是同步还是异步的(Promise)
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
/**
* 使用 Promise 简单封装 setTimeout
* @param {Number} ms 等待时间
* @returns {Promise} Promise 对象
*/
const timeout = ms => new Promise(resolve => setTimeout(resolve, ms))
/**
* 读取本地浏览器选择的文件
* @param {File} file 选择的文件
* @param {{String}} init 一些初始选项,目前只有 type 一项
* @returns {Promise} 返回了读取到的内容(异步)
*/
const readLocal = (() => {
const result = (file, { type = 'readAsDataURL' } = {}) =>
new Promise((resolve, reject) => {
if (!file) {
reject('file not exists')
}
const fr = new FileReader()
fr.onload = event => {
resolve(event.target.result)
}
fr.onerror = error => {
reject(error)
}
fr[type](file)
})
result.DataURL = 'readAsDataURL'
result.Text = 'readAsText'
result.BinaryString = 'readAsBinaryString'
result.ArrayBuffer = 'readAsArrayBuffer'
return result
})()

/**
* 测试函数的执行时间
* 注:如果函数返回 Promise,则该函数也会返回 Promise,否则直接返回执行时间
* @param {Function} fn 需要测试的函数
* @returns {Number|Promise} 执行的毫秒数
*/
function timing(fn) {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

吾辈建议你也可以封装一些常用的异步函数,下面会展示 JavaScript 中如何更简单的使用异步!

使用 async/await

  • async:用于标识一个函数是异步函数,默认这个函数将返回一个 Promise 对象
  • await:用于在 async 函数内部使用的关键字,标识一个返回 Promise 的异步函数需要等待

使用 async/await 重构上面的代码

1
2
3
4
5
6
7
8
async function init() {
// await 等待异步函数执行完成
await wait(() => document.querySelector('#a'))
await wait(() => document.querySelector('#b'))
console.log('a, b 两个资源已经全部加载完成')
}
// 注:init() 函数将返回一个 Promise,我们可以继续追加下一步的操作
init()

是的,就是如此简单,直接在异步函数添加 await 关键字就好了!


最后,如果你要使用这些特性,请务必使用 babel 转换器。毕竟,有太多的人就是不肯升级浏览器。。。

可以参考

Windows 下 Git 中文乱码

场景

在公司的电脑上碰到了 Git 中文乱码的问题,例如想要查看一下仓库的状态,中文全部变成了 \number 的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
git status
On branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: "source/_posts/JavaScript/\345\234\250\344\274\240\347\273\237\351\241\271\347\233\256\344\270\255\344\275\277\347\224\250-babel-\347\274\226\350\257\221-ES6.md"
modified: "source/_posts/Tool/IDEA/IDEA \344\275\277\347\224\250\346\212\200\345\267\247.md"
modified: test/test.html
modified: test/test.js

no changes added to commit (use "git add" and/or "git commit -a")

解决方案

该方案摘抄自 解决 Git 在 windows 下中文乱码的问题

配置一下这些内容即可

1
2
3
4
5
git config --global core.quotepath false # 显示 status 编码
git config --global gui.encoding utf-8 # 图形界面编码
git config --global i18n.commit.encoding utf-8 # 提交信息编码
git config --global i18n.logoutputencoding utf-8 # 输出 log 编码
export LESSCHARSET=utf-8 # 最后一条命令是因为 git log 默认使用 less 分页,所以需要 bash 对 less 命令进行 utf-8 编码

MySQL 递归查询

场景

最近需要将根据父级分类查询出所有的自己分类,所以却是需要使用 MySQL 实现递归查询的功能。

对于以下数据表(此处简化了)

id parentId name
1 0 数学
2 1 高等数学
3 1 线性代数
4 0 英语
5 4 即时翻译
6 4 口语阅读
7 0 物理
8 7 高能物理
9 8 无限能量
10 9 迪克拉之海

SQL 结构/数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create table question_type (
id bigint primary key not null
comment '问题编号',
parentId bigint not null
comment '问题父分类编号,根节点为 0',
name varchar(20) not null
comment '编号名称'
)
comment '问题编号';
insert into question_type values (1, 0, '数学');
insert into question_type values (2, 1, '高等数学');
insert into question_type values (3, 1, '线性代数');
insert into question_type values (4, 0, '英语');
insert into question_type values (5, 4, '即时翻译');
insert into question_type values (6, 4, '口语阅读');
insert into question_type values (7, 0, '物理');
insert into question_type values (8, 7, '高能物理');
insert into question_type values (9, 8, '无限能量');
insert into question_type values (10, 9, '迪克拉之海');

吾辈只有一个 id,想要查询出所有的子级

解决

这个问题在网络上流传着各种各样的解决方案

  • 使用额外的字段存储节点全路径
  • 在应用层递归查询完成
  • 使用 Mybatis collection 标签
  • 使用存储过程
  • 使用 SQL 视图
  • 使用单条 SQL 实现

吾辈目前只尝试了其中三种

使用额外的字段存储节点全路径

有人提出使用一个额外的字段记录当前节点的全路径,每一级使用 , 进行分割,所以吾辈的数据表变成了下面这样

id parentId name path
1 0 数学 0,1
2 1 高等数学 0,1,2
3 1 线性代数 0,1,3
4 0 英语 0,4
5 4 即时翻译 0,4,5
6 4 口语阅读 0,4,6
7 0 物理 0,7
8 7 高能物理 0,7,8
9 8 无限能量 0,7,8,9
10 9 迪克拉之海 0,7,8,9,10

SQL 结构/数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
create table question_type (
id bigint primary key not null
comment '问题编号',
parentId bigint not null
comment '问题父分类编号,根节点为 0',
name varchar(20) not null
comment '编号名称',
path varchar(100) not null
comment '全路径,每级使用 , 分割'
)
comment '问题编号';
insert into question_type values (1, 0, '数学', '0,1');
insert into question_type values (2, 1, '高等数学', '0,1,2');
insert into question_type values (3, 1, '线性代数', '0,1,3');
insert into question_type values (4, 0, '英语', '0,4');
insert into question_type values (5, 4, '即时翻译', '0,4,5');
insert into question_type values (6, 4, '口语阅读', '0,4,6');
insert into question_type values (7, 0, '物理', '0,7');
insert into question_type values (8, 7, '高能物理', '0,7,8');
insert into question_type values (9, 8, '无限能量', '0,7,8,9');
insert into question_type values (10, 9, '迪克拉之海', '0,7,8,9,10');

现在,我们可以很简单的查询了子级信息了

1
2
3
4
5
6
7
8
9
# 查询物理分类及其子级
select *
from question_type
where path regexp concat(
',', 7,
',|^', 7,
',|,', 7,
'$|^', 7,
'$');

这里使用正则是为了避免出现部分重复的情况,例如 110,直接使用 like 的话可能会出现错误查询额外的数据。

在应用层递归查询完成

在不修改数据表结构的情况下有什么方法能递归查询么?答案是可以的!我们可以在程序中递归查询数据库,虽然效率上会低点,但对于不能修改的数据库而言还是相当有用的。

Domain 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@TableName("question_type")
public class QuestionType implements Serializable {
/**
* 问题编号
*/
private Long id;
/**
* 问题父分类编号,根节点为 0
*/
private Long parentId;
/**
* 编号名称
*/
private String name;
/**
* 全路径,每级使用 , 分割
*/
private String path;
// getter / setter
}

Service 及其实现

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
public interface QuestionTypeService {
/**
* 递归查询节点及其子节点
*
* @param rootId 查询的根节点 id
* @return 子节点列表
*/
List<QuestionType> selectRecursiveById(Long rootId);
}

@Service
public class QuestionTypeServiceImpl implements QuestionTypeService {
@Autowired
private QuestionTypeDao baseMapper;

@Override
public List<QuestionType> selectRecursiveById(Long rootId) {
final List<QuestionType> list = baseMapper.selectListByParentId(rootId).stream()
.flatMap(qt -> selectRecursiveById(qt.getId()).stream())
.collect(Collectors.toList());
Optional.ofNullable(baseMapper.selectById(rootId)).ifPresent(list::add);
return list;
}
}

Dao/Mapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Repository
public interface QuestionTypeDao {
/**
* 根据 id 查询分类
*
* @param id 分类 id
* @return 查询到的分类数据
*/
QuestionType selectById(@Param("id") Long id);

/**
* 根据父节点 id 查询一级子节点
*
* @param parentId 父节点 id
* @return 一级子节点列表
*/
List<QuestionType> selectListByParentId(@Param("parentId") Long parentId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.rxliuli.example.mybatisplussqlinjector.dao.QuestionTypeDao">
<select id="selectListByParentId"
resultType="com.rxliuli.example.mybatisplussqlinjector.entity.QuestionType">
select *
from question_type
where parentId = #{parentId};
</select>
<select id="selectById" resultType="com.rxliuli.example.mybatisplussqlinjector.entity.QuestionType">
select *
from question_type
where id = #{id};
</select>
</mapper>

调用的时候只要传入一个根节点 id 就可以查找到所有节点及其所有子节点了

使用 Mybatis collection 标签

如果你使用的 ORM 是 Mybatis,那么也可以使用 Mybatis collection 标签实现递归查询的功能。

  1. Mybatis collection 标签可以查询一个集合为字段赋值
  2. 那么我们可以使用 select 指向查询子分类本身
  3. 查询的参数 cloumn 设置为查询出来每一个对象的 id 字段
  4. 更改查询子分类的返回值为 resultMap="RecursiveMap"
1
2
3
4
5
6
7
8
9
10
@Repository
public interface QuestionTypeDao {
/**
* 根据父分类 id 递归查询子分类(不包含父分类本身)
*
* @param parentId 分类 id
* @return 查询到的分类树
*/
List<QuestionType> selectRecursiveByParentId(@Param("parentId") Long parentId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.rxliuli.example.mybatisplussqlinjector.dao.QuestionTypeDao">
<!-- 定义一个结果映射 -->
<resultMap id="RecursiveMap" type="com.rxliuli.example.mybatisplussqlinjector.entity.QuestionType">
<result property="id" column="id"/>
<result property="parentId" column="parentId"/>
<result property="name" column="name"/>
<result property="path" column="path"/>
<!-- 这里是关键,定义集合字段,元素类型,查询函数以及对应的列 -->
<collection property="childrenList" ofType="com.rxliuli.example.mybatisplussqlinjector.entity.QuestionType"
select="com.rxliuli.example.mybatisplussqlinjector.dao.QuestionTypeDao.selectRecursiveByParentId"
column="id"/>
</resultMap>

<!-- 正常查询子分类,唯一修改之处就是 resultMap -->
<select id="selectRecursiveByParentId" resultMap="RecursiveMap">
select *
from question_type
where parentId = #{parentId}
</select>
</mapper>

那么,关于 MySQL 递归查询暂且到此为止了。如果吾辈找到了更好的方法,也会继续更新这篇文章的!

在传统项目中使用 babel 编译 ES6

场景

曾经吾辈以为 ES6 早已推广开来,然而事实上远比想象中更加复杂。传统后台的项目就是要兼容性,兼容 2 年前的浏览器,没有 babel,全程 jQuery 一把梭做到底。

之前的项目基本上都是前后端分离的模式,最近新公司的项目却是使用的传统的模板视图的模式。
所以,一些东西发生了变化

  • thymeleaf 模板里面直接有 Java 的代码,在服务端直接编译 html 代码而非是纯粹的 API 交互
  • 使用最多的库是 jquery,主要用于操作 dom
  • 没有现代前端工具链 nodejs/npm/webpack/babel/vuejs

所以吾辈使用 ES6 的语法就被同事诟病语法太新(还有人在用 ES4@2008),浏览器无法正常显示,所以吾辈只能尝试用 babel 来做兼容。众所周知,自 babel6 以来,模块化大行其道,由原先的使用浏览器引入脚本的方式修改为由 npm 等包管理器引入,官方也不推荐使用浏览器引入的方式。

解决

幸好吾辈找到了一个项目 babel-standalone,它提供了从浏览器中引入 babel 的功能。

使用方式很简单,只要在你含有 ES6 代码的脚本之前引入,在含有 ES6 代码的 script 标签上加上 text/babel 即可。

1
2
3
4
5
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel">
const getMessage = () => "Hello World";
document.getElementById('output').innerHTML = getMessage();
</script>

以上,官方是这么说的,然而实际上,吾辈还是遇到了一些问题

  1. 使用 <script type="text/babel"> 标记需要编译确实很方便,然而 babel 的编译过程是异步的,所以如果想要在后面的脚本中使用这个脚本中的内容则是不可能的
  2. 某些语法仍然不能支持,例如 function* 生成器
  3. 不能直接使用未声明的变量
  4. 默认没有编译为 ES5

这些问题我们下面一一解决

异步编译

例如有下面三个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- index.html -->
<!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>index</title>
</head>
<body>
<h1 id="root"></h1>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel" src="./js/common.js"></script>
<script src="./js/index.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// common.js
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
1
2
3
4
// index.js
wait(3000).then(function() {
document.querySelector('#root').innerHTML = '等待 3s 结束'
})

我们会得到一个错误

1
Uncaught ReferenceError: wait is not defined

为什么会这样呢?原因就是加载 common.js 之后实际上还需要被 babel 编译,然而这并非同步操作,所以我们之后的脚本就无法取得全局函数 wait()。那么,如何解决呢?

我们可以将所有的 script 标签都加上 type="text/babel",所有的 script 脚本都是需要编译的,那么就不会有异步的编译的问题了。

babel 没有完全支持

例如在 common.js 中添加一个函数 indexGenerator()

1
2
3
4
5
6
7
8
9
/**
* 生成一个索引序列,从 0 开始,每次递增为 1
* @returns {Generator} 一个生成器
*/
function* indexGenerator() {
for (let i = 0; true; i++) {
yield i
}
}

但我们只会得到一个错误

1
Uncaught ReferenceError: regeneratorRuntime is not defined

这是因为 babel 基础包并没有实现所有的 ES6 的特性,所以就会出现不支持的情况。我们需要拓展包 babel-polyfill,在 babel-standalone 下引入即可

1
2
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.min.js"></script>

不能使用未声明变量

如果我们在标记为需要编译的 script 脚本中使用了未定义的变量,就会出现错误。例如在 index.js

1
username = 'rxliuli'

错误消息

1
Uncaught (in promise) ReferenceError: username is not defined

所以说编程规范很重要啦

默认不支持 ES7

是的,babel 默认是不支持 ES7 的,而 async/await 便属于 ES7 的内容。例如我们修改 index.js

1
2
3
4
;(async () => {
await wait(3000)
document.querySelector('#root').innerHTML = '等待 3s 结束'
})()

错误消息

1
Uncaught SyntaxError: Unexpected token function

我们可以使用 data-presets="latest" 来修复这个问题,永远引入最新版的 presets

1
2
3
4
5
6
<script type="text/babel" src="./js/common.js"></script>
<script
type="text/babel"
data-presets="latest"
src="./js/index.js"
></script>

thymeleaf 不能使用模板字符串 HTML

同时使用 type="text/babel" data-presets="latest"th:inline="javascript" 的时候,thymeleaf 将无法解析 <a href="#"/> 含有 HTML 的模板字符串。

使用环境

  • spring-boot 2.0.3.RELEASE
  • babel 6.26.0
  • babel-polyfill 2.6.1

例如下面这种代码

1
2
3
4
<script type="text/babel" data-presets="latest" th:inline="javascript">
const htmlStr = `<a href="#"/>`
console.log(`htmlStr: ${htmlStr}`)
</script>

甚至于注释了也没用,只能删除掉才可以

1
2
3
4
<script type="text/babel" data-presets="latest" th:inline="javascript">
// const htmlStr = `<a href="#"/>`
console.log(`htmlStr: ${htmlStr}`)
</script>

目前的解决方案是分成两个 script 标签,分别使用 type="text/babel" data-presets="latest"th:inline="javascript" 标签

不能使用浏览器较新的 API

使用一些浏览器较新的 API 时发现不能正常使用,babel-core 也没有实现。例如吾辈想要使用 NodeList.forEach 遍历 a 标签列表,然后打印出来他们的链接

1
document.querySelectorAll('a').forEach(el => console.log(el.href))

会得到错误

1
Uncaught TypeError: document.querySelectorAll(...).forEach is not a function

在旧版浏览器中,NodeList 并没有 forEach 方法,后来,吾辈找到了另一个库 core-js,其最新版 3.x beta 实现了 NodeList.forEach API,唯一的缺点是我们要手动构建才行。

引入也很简单,只要在 babel-standalone 之后,babel-polyfill 之前使用 script 标签引入就好了

1
<script src="https://unpkg.com/core-js-bundle@3.0.0-beta.8/index.js"></script>

好了,下面我们可以愉快的使用新的浏览器 API 了

总结

那么,有关在传统项目中使用 babel 编译 ES6/ES7 的问题就到这里了,希望有更多的人使用这些新特性,让我们早日抛弃掉 babel 吧

JavaScript 善用解构赋值

场景

在今天写 JavaScript 函数时,发现了一个有趣的技巧。

在此之前,吾辈想知道泥萌需要默认值的时候是如何做的呢?

例如下面的函数 print,吾辈需要在没有给定参数 user 的情况下,给出合适的输出

1
2
3
4
5
6
7
8
9
10
11
12
function print(user) {
if (!user) {
user = {}
}
if (!user.name) {
user.name = '未设置'
}
if (!user.age) {
user.age = 0
}
console.log(`姓名:${user.name},年龄:${user.age}`)
}

那么,我们应该怎么优化呢?

  • 三元表达式
  • || / && 赋予默认值
  • Object.assign() 合并对象

我们分别来实现一下

三元表达式实现

1
2
3
4
5
6
7
8
function print(user) {
user = user ? user : {}
console.log(
`姓名:${user.name ? user.name : '未设置'},年龄:${
user.age ? user.age : 0
}`,
)
}

|| / && 赋予默认值

1
2
3
4
5
function print(user) {
console.log(
`姓名:${(user || {}).name || '未设置'},年龄:${(user || {}).age || 0}`,
)
}

使用 && 也可以

1
2
3
4
5
6
function print(user) {
console.log(
`姓名:${(user && user.name) || '未设置'},年龄:${(user && user.age) ||
0}`,
)
}

|| / && 解释

  • || 用来取默认值,避免太多的 if 判断。例如对于 a || b 我们可以认为:如果 a 为空,则赋值为 b
  • && 用来连续执行,避免深层嵌套的 if 判断。例如对于 a || b,我们可以认为:如果 a 不为空,则赋值为 b

注:||/&&` 非常适合简单的默认值赋值,但一旦设置到深层嵌套默认值就不太合适了

Object.assign() 合并对象

1
2
3
4
5
6
7
8
function print(user) {
_user = {
name: '未设置',
age: 0,
}
user = Object.assign(_user, user)
console.log(`姓名:${user.name},年龄:${user.age}`)
}

可以看出

  1. 三元表达式的方式方式明显有点繁琐
  2. || / && 很好很强大,缺点是看起来很不直观,而且容易混淆
  3. Object.assign() 合并对象的方式应该算是最好的了,然而是在方法内部进行的初始化,作为调用者除非查看文档或源码才能知道

那么,有没有更好的解决方案呢?

解构赋值

解构赋值是 ES6 的一个新的语法,具体可以查看 MDN

下面是一些简单的解构赋值操作

数组解构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var arr = [1, 2, 3, 4]
var [first, second, ...remaining] = arr // first: 1, second: 2, remaining: [3, 4]
// 接受一或多个可变参数的函数
function join(...arr) {
return arr.join(', ')
}
// 调用时可以使用 ... 将数组解构
join(...arr) // 1, 2, 3, 4
// 忽略开始的某些值
var [, , ...remaining] = arr // remaining: [3, 4]
// 默认值
var [first = 1, second = 2, ...remaining] = [] // first: 1, second: 2, remaining:
var a = 1,
b = 2
// 交换变量
;[a, b] = [b, a] // a: 2, b: 1

对象解构

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
var user = {
id: 1,
name: '未设置',
age: 0,
sex: false,
}
// 排除指定属性
var { name, age, ...rest } = user // name: '未设置', age: 0, 其他属性: { "id": 1,"sex": false }

// 使用新的变量名
var { name: newName, age: newAge } = user // newName: '未设置', newAge: 0
// 默认值
var { name = '未设置', age = 0 } = {} // name: '未设置', age: 0
// 同时使用新的变量名和默认值
var { name: newName = '未设置', age: newAge = 0 } = user // newName: '未设置', newAge: 0
// 计算属性名
var key = 'name'
var { [key]: name } = user

// 数组迭代解构
var users = [
{
name: '琉璃',
age: 17,
},
{
name: '楚轩',
age: 23,
},
]
users.map(({ name, age }) => `name: ${name}, age: ${age}`).join('\n')
// 解构函数实参
function print({ name = '未设置', age = 0 } = {}) {
console.log(`姓名:${name},年龄:${age}`)
}

啊嘞,吾辈好像不知不觉间把解决方案写出来了。。。?

分析

让我们好好看下这段代码

1
2
3
function print({ name = '未设置', age = 0 } = {}) {
console.log(`姓名:${name},年龄:${age}`)
}

一眼看过去,是不是感觉很直观,如果稍微了解一点 ES6 就能瞬间明白这是解构赋值以及默认参数

我们分析一下具体流程

  1. 调用 print 函数

  2. 检查参数是否为有值,没有的话设置默认值 {}
    相当于

    1
    2
    3
    if (!user) {
    user = {}
    }
  3. 解构参数,检查解构的属性是否有值,没有的话设置默认值
    相当于

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var name
    if (!user.name) {
    name = '未设置'
    } else {
    name = user.name
    }
    var age
    if (!user.age) {
    age = 0
    } else {
    age = user.age
    }
  4. 进入函数内部

关键就在于第 2,3 步,默认参数解构赋值 都是 ES6 的新特性,善于使用能大大简化代码的繁琐性。


希望有更多的人能够学会怎么使用,让我们早日抛弃 babel 吧 (*^ ▽ ^)/