yarn 下载速度很慢

场景

虽然 yarn 默认使用缓存策略,然而由于众所周知的原因,初次下载时还是非常慢,所以还是需要设置代理,此处做一下记录。

设置代理

1
2
yarn config set proxy http://127.0.0.1:1080
yarn config set https-proxy http://127.0.0.1:1080

如果某一天不需要了,也可以删除

1
2
yarn config delete proxy
yarn config delete https-proxy

项目配置

有人提到了一种有趣的方法,在项目根目录下添加 .npmrc 配置文件指定仓库地址为淘宝镜像,相当于项目级别的配置吧

1
registry=https://registry.npm.taobao.org

js 处理 url 数组参数

场景

使用 axios.get 时遇到的问题,axios 在 get 请求时会将参数转换为 url 上,这本是正常的逻辑,然而 Spring MVC 却无法接收,会抛出错误。

使用 Axios 发送的请求代码

1
2
3
4
5
axios.get('/api/index/array', {
params: {
list: ['1', '2', '3'],
},
})

Spring MVC 接口代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/api/index")
public class IndexTestController {
@GetMapping("/array")
public IndexVo array(IndexVo indexVo) {
return indexVo;
}

public static class IndexVo {
private List<String> list;

public List<String> getList() {
return list;
}

public IndexVo setList(List<String> list) {
this.list = list;
return this;
}
}
}

此处为了简单演示使用了内部类

请求如下

1
2
3
4
5
6
7
8
9
10
11
12
GET /api/index/array?list[]=1&list[]=2&list[]=3 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=F8E42F1AC8B9CD46A0F6678DFEB3E9F3

抛出的错误

1
java.lang.IllegalArgumentException: Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986

说是参数中包含 RFC 7230 and RFC 3986 未定义的字符,所以说 RFC 7230 and RFC 3986 是个什么东西?

去 Google 上一搜,好吧,果然吾辈不是第一个被坑的人。没想到不是 Spring 的问题,而是新版 Tomcat(7) 的问题。Tomcat 要求 URL 中的字符必须符合 RFC 3986。
即:只能包含英文字符(a-zA-Z),数字(0-9),特殊字符(-_.~),保留字符(!*'();:@&=+$,/?#[])。

然后,作为一个 URI 的数据与作为保留字符的分隔符发生冲突了,自然是要使用 % 进行编码的。

解决

既然 Axios 本身的 get 函数中对参数进行编码有问题,那么吾辈就自己手动将 params 转换到 URL 上好了。
本以为是个很简单的功能,所以最初吾辈直接使用了 rx-util 中之前写的 spliceParams 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// @ts-check
/**
* 拼接参数字符串
* @param {Object} params 参数对象
* @returns {String} 拼接后的字符串
*/
export function spliceParams(params) {
if (!params) {
throw new Error(`参数对象不能为空:${params}`)
}
var res = ''
for (const k in params) {
if (params.hasOwnProperty(k)) {
const v = params[k]
res += `${encodeURIComponent(k)}=${encodeURIComponent(v)}&`
}
}
return res
}

然而之前没有处理的边界情况 Array 和 Date 却出现了问题,修改如下

注: 此处的 dateFormat 亦来自于 rx-util

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
// @ts-check
import { dateFormat } from './../date/dateFormat'

const deteFormatter = 'yyyy-MM-ddThh:mm:ss.SSSZ'
const encode = (k, v) => encodeURIComponent(k) + '=' + encodeURIComponent(v)

/**
* 拼接参数字符串
* @param {Object} params 参数对象
* @returns {String} 拼接后的字符串
*/
export function spliceParams(params = {}) {
if (!(params instanceof Object)) {
throw new Error(`The parameter type must be Object: ${params}`)
}
return Array.from(Object.entries(params)).reduce((res, [k, v]) => {
if (v === undefined || v === null) {
return res
} else if (v instanceof Date) {
res += encode(k, dateFormat(v, deteFormatter))
} else if (v instanceof Array) {
res += v
.map(item =>
encode(
k,
item instanceof Date ? dateFormat(item, deteFormatter) : item,
),
)
.join('&')
} else {
res += encode(k, v)
}
return (res += '&')
}, '')
}

现在,spliceParams 可以正常使用了,对空值,Date 与 Array 都是友好的了!

使用的话,直接在将 axios 包装一下即可,类似于下面这样

1
2
3
4
5
6
7
8
const rxAjax = (axios => {
return {
...axios,
get(url, params, config) {
return axios.get(`${url}?${rx.spliceParams(params)}`, config)
},
}
})(axios.create())

现在,再次发送请求,参数会被正确的处理

1
2
3
4
5
axios.get('/api/index/array', {
params: {
list: ['1', '2', '3'],
},
})

请求如下

1
2
3
4
5
6
7
8
9
10
11
12
GET /api/index/array?list=1&list=2&list=3& HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=F8E42F1AC8B9CD46A0F6678DFEB3E9F3

或许,吾辈应该向 axios 提出这个 bug?

vue 使用 v-model 双向绑定父子组件的值

场景

今天在使用 v-model 进行组件双向数据绑定的时候遇到了一个奇怪的问题,网页本身运行正常,浏览器一直出现警告信息。

1
[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"

引发这个警告的是一个自定义组件 RxSelect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
template: `
<select
v-model="value"
@change="$emit('change', value)"
>
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

吾辈使用的代码看起来代码貌似没什么问题?

1
2
3
4
5
6
<main id="app">
当前选择的性别是: {{map.get(sex)}}
<div>
<rx-select :map="map" v-model="sex" />
</div>
</main>

JavaScript 代码

1
2
3
4
5
6
7
8
9
10
new Vue({
el: '#app',
data: {
map: new Map()
.set(1, '保密')
.set(2, '男')
.set(3, '女'),
sex: '',
},
})

经测试,程序本身运行正常,父子组件的传值也没什么问题,双向数据绑定确实生效了,然而浏览器就是一直报错。

尝试解决

吾辈找到一种方式

  1. 为需要双向绑定的变量在组件内部 data 声明一个变量 innerValue,并初始化为 value
  2. select 上使用 v-model 绑定这个变量 innerValue
  3. 监听 value 的变化,在父组件中 value 变化时修改 innerValue 的值
  4. 监听 innerValue 的变化,在变化时使用 this.$emit('change', val) 告诉父组件需要更新 value 的值
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
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
data() {
return {
innerValue: this.value,
}
},
watch: {
value(val) {
this.innerValue = val
},
innerValue(val) {
this.$emit('change', val)
},
},
template: `
<select v-model="innerValue">
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

使用代码完全一样,然而组件 RxSelect 的代码却多了许多。。。

解决

一种更优雅的方式是使用 computed 计算属性以及其的 get/set,代码增加的程度还是可以接受的

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
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
computed: {
innerValue: {
get() {
return this.value
},
set(val) {
this.$emit('change', val)
},
},
},
template: `
<select v-model="innerValue">
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

jsdoc 注释标签一览

速览表格

标签列表

标签 简介
param 参数
returns 返回值
example 示例
test 测试代码
class 类定义
property 类属性定义

语法列表

语法 简介
{T} 类型
{T,R} 多个类型
[] 可选值
[arg=v] 默认值
.<T> 泛型
obj.property 对象参数
function(T):R 函数参数

标签

param

1
2
3
4
5
6
7
/**
* 在控制台上打印一个值
* @param obj 需要被打印的值
*/
function print(obj) {
console.log(obj)
}

returns

1
2
3
4
5
6
7
/**
* 获取一个 0-1 之间的随机数
* @returns 随机数
*/
function random() {
return Math.random()
}

example

1
2
3
4
5
6
7
8
9
10
/**
* 获取一个 0-1 之间的随机数
* @returns 随机数
* @example
* const i = random()
* console.log(i)
*/
function random() {
return Math.random()
}

test

1
2
3
4
5
6
7
8
9
10
/**
* @test {random} 测试 random 函数
*/
describe('测试 random 函数', () => {
it('测试两次随机数是否相等', () => {
const i = random()
const k = random()
expect(i).not.toBe(k)
})
})

class

1
2
3
4
5
/**
* 简单模拟 Vue class
* @class Vue
*/
class Vue {}

property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 简单模拟 Vue class
* @class Vue
* @property {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @property {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @property {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @property {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
class Vue {
/**
* 构造函数
* @param {Object} option 可选项
* @param {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @param {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @param {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @param {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
constructor({ el, data = {}, methods = {}, mounted = function() {} } = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}
}

语法

{}

1
2
3
4
5
6
7
8
9
/**
* 计算两个数字之和
* @param {Number} i 第一个数字
* @param {Number} k 第二个数字
* @returns {Number} 两数之和
*/
function add(i, k) {
return i + k
}

{T,R}

1
2
3
4
5
6
7
8
9
/**
* 计算两个数字之和,或者两个字符串之间的连接
* @param {Number|String} i 第一个数字
* @param {Number|String} k 第二个数字
* @returns {Number|String} 两数之和,或者两个字符串之间的连接
*/
function add(i, k) {
return i + k
}

[]

使用场景: 可选参数不需要在函数中所有条件下使用

例如下面的 sep 在不传入时会默认返回 [str],一般优先使用 [arg=v] 更好

1
2
3
4
5
6
7
8
9
/**
* 分割字符串为数组
* @param {String} str 字符串
* @param {String} [sep] 分隔符
* @returns {Array} 分割后的数组
*/
function split(str, sep) {
return sep ? str.split(sep) : [str]
}

[arg=v]

使用场景: 需要为传入的参数赋予默认值

注: 太过冗长的默认值最好使用文件描述而非加到 []

例如下面的函数参数 sep,如果想要在不传入的时候默认为 '',就需要使用默认值标记。

1
2
3
4
5
6
7
8
9
/**
* 分割字符串为数组
* @param {String} str 字符串
* @param {String} [sep=''] 分隔符
* @returns {Array} 分割后的数组
*/
function split(str, sep = '') {
return str.split(sep)
}

.<T>

使用场景: Array, Map, Set, Iterator 这中集合接口/类限定元素类型,也有 Promise 这种内嵌其他类型异步结果的情况

例如下面的集合就声明元素全部都需要为 StringObject 的话可能出现 [object Object] 这种内容

1
2
3
4
5
6
7
8
/**
* 将 String 类型的数组中的元素都连接起来,并以逗号进行分割
* @param {Array.<String>} arr 字符串数组
* @returns {String} 连接后的字符串
*/
function join(arr) {
return arr.join(',')
}

obj.property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 简单模拟 Vue API
* @param {Object} option 可选项
* @param {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @param {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @param {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @param {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
function Vue({ el, data = {}, methods = {}, mounted = function() {} } = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}

function(T):R

1
2
3
4
5
6
7
8
9
/**
* 自行实现 flatMap,将数组压平一层
* @param {Array.<Object>} arr 数组
* @param {function(Object):Array} fn 映射方法,将一个元素映射为一个数组
* @returns {Array.<Object>} 压平一层的数组
*/
export function flatMap(arr, fn) {
return arr.reduce((res, item) => res.concat(fn(item)), [])
}

使用 esdocs 生成文档

esdocs 官网, 博客地址, 示例项目

场景

在尝试过使用 markdown, jsdoc, docz 之后,吾辈终于找到了一个比较满意工具 – esdocs。

期望

  • 开箱即用: 毫无疑问, js 正在把一切事情变得复杂,到处都是大量的配置,永远都学不会开箱即用
  • 支持 jsdoc 注释: 已经熟悉了 jsdoc,所以不太希望切换到其他的注释规范了呢
  • 可配置自定义页: 作为文档 API 列表还算合适,然而首页的话果然还是自定义最好

因为以上的期望,吾辈最终选择了 esdocs。

使用

添加依赖

1
yarn add -D esdoc esdoc-standard-plugin

初始化配置

创建一个配置文件 .esdoc.json

1
2
3
4
5
{
"source": "./src",
"destination": "./docs",
"plugins": [{ "name": "esdoc-standard-plugin" }]
}

当然,如果你使用的命令行是 bash/git-for-bash/cmder 的话,亦可使用命令快速完成

1
2
3
4
5
echo '{
"source": "./src",
"destination": "./docs",
"plugins": [{"name": "esdoc-standard-plugin"}]
}' > .esdoc.json

打包

package.json 中添加一个打包文档的 script 命令

1
2
3
"scripts": {
"docs": "esdoc"
}

然后使用 yarn docs 命令即可打包一份新鲜可用的文档啦

查看

然后打开 docs/index.html 文件即可查看了,下面截张吾辈的工具库 rx-util 生成的文档。

rx-util

总结

感觉是不是很简单,吾辈也是这样认为的呢!后面会整理一份 jsdoc 的标签列表,便于快速查找与一览。

使用 jest 和 babel 测试

博客, GitHub 示例

场景

最近想为吾辈的工具函数库 rx-util 添加单元测试,因为目前还在学习 ReactJS,所以最终选择了 Fackbook 家的 jest 进行测试。这里记录一下整个过程,以供他人参考。

注:Babel 是现代前端库的天坑之一,不保证不同版本按照该教程能正常完成。如果出现了错误,请对比示例项目库 jest-example

过程

添加依赖

使用 yarn 安装 jest 和 babel 的依赖项

1
yarn add -D jest @types/jest babel-jest @babel/core @babel/preset-env

注: @types/jest 是 jest 的 ts 类型定义文件,而 vscode 便是基于 ts 进行代码提示的。

进行配置

添加 babel 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
}

在 package.json 中添加 test 命令(可选)

1
2
3
"scripts": {
"test": "jest --watch"
}

一般测试

现在,我们可以进行基本的测试了

src 下添加一个 add.js

1
2
3
4
5
6
7
8
9
10
11
// src/add.js
// @ts-check
/**
* 相加函数
* @param {Number} than 第一个数字
* @param {Number} that 第二个数字
* @returns {Number} 两数之和
*/
export function add(than, that) {
return than + that
}
1
2
3
4
5
6
// src/add.test.js
import { add } from './add'

test('test add', () => {
expect(add(1, 2)).toBe(3)
})

添加稍微麻烦一点的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/uniqueBy.js
// @ts-check

/**
* js 的数组去重方法
* @typedef {any} T 参数数组以及函数的参数类型
* @param {Array.<T>} arr 要进行去重的数组
* @param {Function} fn 唯一标识元素的方法,默认使用 {@link JSON.stringify()}
* @returns {Array.<T>} 进行去重操作之后得到的新的数组 (原数组并未改变)
*/
export function uniqueBy(arr, fn = item => JSON.stringify(item)) {
const obj = {}
return arr.filter(item =>
obj.hasOwnProperty(fn(item)) ? false : (obj[fn(item)] = true),
)
}
1
2
3
4
5
6
// src/uniqueBy.test.js
import { uniqueBy } from './uniqueBy'

test('test uniqueBy', () => {
expect(uniqueBy([1, 2, 3, 1, 2])).toEqual(expect.arrayContaining([1, 2, 3]))
})

异步测试

或许你会认为异步测试需要单独的配置?然而事实上 jest 不愧是开箱即用的,直接就可以使用 async/await 进行异步测试。

下面是一个简单的异步函数及其测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/wait.js
// @ts-check
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
export 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
5
6
7
8
// src/wait.test.js
import { wait } from './wait'

test('test wait sepecify time', async () => {
const start = Date.now()
await wait(1000)
expect(Date.now() - start).toBeGreaterThanOrEqual(1000)
})

集成 ESLint

一般而言,项目中都会使用 eslint 进行代码规范,而 jest 所使用的全局变量 testexpect 却并不符合 eslint 默认的规范。

添加 eslint 依赖,这里选择 standard 模板

1
yarn add -D eslint standard

然后初始化 eslint 配置项

1
yarn eslint --init

这里也添加一个 script 便于运行

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

现在回到 src/add.js,可以看到 test/expect 上都已经出现错误警告,其实消除错误很简单。

安装依赖

1
yarn add -D eslint-plugin-jest

修改 eslint 默认配置文件 .eslintrc.js,最终结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
env: {
browser: true,
es6: true,
// 配置项目使用了 jest
'jest/globals': true,
},
extends: 'standard',
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {},
// 配置 jest 的插件
plugins: ['jest'],
}

那么,使用 yarn lint,一切都将正常运行!

vuejs data 属性中的 this 指向问题

场景

之前在封装 table 组件 BasicTableVue 的时候遇到的问题,在 data 属性中无法使用 this.** 调用 methods 中的函数。
例如下面的代码

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
class BasicTableData {
constructor({
user = {
name: 'rx',
age: 17,
},
} = {}) {
this.user = user
}
}
class Table extends Vue {
constructor({ data, methods, mounted, computed }) {
super({
data: _.merge(new BasicTableData(), data),
methods,
mounted,
computed,
})
}
}

const table = new Table({
data: {
user: {
birthday: new Date(),
birthdayFormatter: this.calcTime,
},
},
methods: {
calcTime(time) {
return time.toISOString()
},
},
})

// 将输出 undefined
console.log(table.user.birthdayFormatter)

吾辈尝试了一下原生的 vuejs,发现这样的 data 仍然不能用。

解决

后来在官方文档找到了 这里,data 如果是一个对象或者箭头函数时,不会绑定 this,仅当 data 是一个普通函数(使用 function 声明)时,才会被绑定 this

那么,知道了原因,解决方案就很简单了。

  1. 如果需要使用在 data 中使用 this 调用 methods 中的函数,则 data 必须声明为普通函数
  2. 如果需要默认 data defaultData,则 Table 可以将合并后的 data 声明为函数,并将 defaultDatadata(使用 Table 创建实例时传入的)的返回值合并

修改后的代码如下

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
class BasicTableData {
constructor({
user = {
name: 'rx',
age: 17,
},
} = {}) {
this.user = user
}
}
class Table extends Vue {
constructor({ data, methods, mounted, computed }) {
super({
// 关键是这里将 data 声明为普通函数
data() {
// 此处为了简洁使用 lodash 的深度合并
return _.merge(
new BasicTableData(),
// 此处判断 data 是否为函数,是的话就绑定 this 计算结果
typeof data === 'function' ? data.call(this) : data,
)
},
methods,
mounted,
computed,
})
}
}

const table = new Table({
data: function() {
return {
user: {
birthday: new Date(),
birthdayFormatter: this.calcTime,
},
}
},
methods: {
calcTime(time) {
return time.toISOString()
},
},
})

// 打印的结果是
// ƒ calcTime(time) {
// return time.toISOString()
// }
console.log(table.user.birthdayFormatter)

思考

现在问题解决了,那么,为什么 vuejs 就能够在传入 data 函数时就能调用 methods 中的函数了呢?吾辈稍微 debug 进入源码看了一下

  1. 创建 Table 进入构造函数
    构造函数

  2. 因为继承了 Vue,所以进入 Vue 的构造函数中
    进入 Vue 的构造函数中

  3. 因为当前实例属于 Vue,所以进入 _init 进行初始化
    进入 _init 初始化

  4. 跳转到 initState(vm); 处,该函数将对 data 属性进行初始化(至于为什么是 state 可能是因为最初就是模仿 react 写的?)
    跳转到 initState()

  5. 进入到 initState(),跳转到 initData(vm);
    initData(vm) 处

  6. 进入到 initData() 函数,看到了判断逻辑
    判断逻辑

    1
    2
    var data = vm.$options.data
    data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

    注意看,这里的 vue 内部判断了 data 是否为函数,如果是就去 getData(data, vm)

  7. 进入 getData() 函数看看,发现了关键代码
    关键代码

    1
    return data.call(vm, vm)

    是的,data 调用时使用 call 绑定 this 为 vm,而此时 vm.calcTime 已经有值了。

  8. 那么,vm.calcTime 是什么时候被初始化的呢?
    其实也在 initState 函数中,可以看到,vue 的初始化顺序是

    1. props: 外部传递的属性
    2. methods: 组件的函数
    3. data: 组件的属性
    4. computed: 计算属性
    5. watch: 监听函数

    初始化顺序

总结

相比于 react,vue 做了更多的 黑魔法 呢!就像 this 指向问题,react 是交由用户自行解决的,而 vue 则在后面偷偷的为函数绑定 this 为 vue 实例本身。

react 受控表单必须初始化

场景

这些天在学习 React 的时候遇到了一个奇怪的问题,明明受控表单的双向绑定已经成功了,然而控制台还是会出现 react 的警告:

1
Warning: A component is changing an uncontrolled input of type undefined to be controlled. Input elements should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component.

代码很简单,仅仅只是一个登录表单

1
2
3
4
5
6
7
8
9
/**
* 用户类
*/
export class User {
constructor({ username, password } = {}) {
this.username = username
this.password = password
}
}
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 React, { Component } from 'react'
import { User } from './User'

class App extends Component {
constructor(props) {
super(props)
this.state = {
user: new User(),
}
}
change = e => {
const el = e.target
const k = el.name
const v = el.value
const user = { ...this.state.user }
user[k] = v
this.setState({
user,
})
}
submit = () => {}
reset = () => {
this.setState({
user: new User(),
})
}
render() {
const { username, password } = this.state.user
return (
<div>
<div>
<label htmlFor="username">用户名: </label>
<input name="username" value={username} onChange={this.change} />
</div>
<div>
<label htmlFor="password">密码: </label>
<input name="password" value={password} onChange={this.change} />
</div>
<div>
<button onClick={this.submit}>登录</button>
<button onClick={this.reset}>重置</button>
</div>
</div>
)
}
}

export default App

在 App 组件的 constructor 中明明已经通过 new User() 初始化了 user 属性,然而在输入的时候,还是会出现警告。

注:此时在输入框中输入值,确实会影响到 react state 中的 user 属性,反之亦然。只有一点,当重置表单,即使用 this.setState({user: new User()}) 重置 user 对象无法影响到页面上输入框的值。

此处出现了两个问题

  1. 为什么在输入的时候会出现警告
  2. 为什么重置之后输入框的值没有变化

解决

最终,吾辈在 StackOverflow 上找到了答案。
很重要的一句话:对于要控制的输入,其值必须与状态变量的值相对应。
最初并未满足这个条件,值为 nullstate 属性会被 react 视为未定义,导致表单最初是不受控制的。但是,当 onChange 第一次被触发的时候,this.state.user.username 就被设置了。此时,满足了条件,从非受控表单转换为了受控表单并导致了控制台的警告。
同理,当使用 this.setState({user: new User()}) 重置的时候,又变成了非受控表单,所以这里的绑定再次失效了。

注: react 使用 == 而非 === 比较是否为 null,而 null == undefined 的值为 true,所以。。。

那么,知道问题了之后,我们只要保证初始值 val != null 即可。
例如上面的代码可以修改 User.js

1
2
3
4
5
6
7
8
9
/**
* 用户类
*/
export class User {
constructor({ username = '', password = '' } = {}) {
this.username = username
this.password = password
}
}

那么,关于 react 中的受控表单初始化的问题便到此为止了。可想而知,react 的坑还有很多没有踩完呢

Vue 表格封装 BasicTableVue

场景

后台项目中大量使用表格,我们使用的 element-ui 中的表格并不足以满足吾辈的需求,而且使用起来重复的代码实在太多,所以吾辈便对数据表格进行了二次封装。

实现

API 列表

  • [el]: 绑定的选择器。默认为 '#app'
  • data: 数据对象
    • form: 搜索表单绑定对象
    • columns: 表格的列数组。每个列定义参考 TableColumn
    • [formShow]: 是否显示搜索表单
    • [page]: 分页信息,包含分页的数据。具体参考 Page
    • [selectedIdList]: 选中项的 id 列表
    • [fileSelectorShow]: 是否显示导入 Excel 的文件选择器
  • methods: 绑定的函数
    • createForm: 初始化 form 表单,主要是为了自定义初始化逻辑
    • getPage: 获取分页信息
    • exportFile: 导出文件
    • importFile: 导入文件
    • deleteData: 删除选择的数据
    • [init]: 初始化函数,如果可能请使用该函数而非重写 mounted 生命周期函数,该函数会在 mounted 中调用
    • [resetFile]: 重置导入选择的文件,必须为 input:file 绑定属性 ref="fileInput"
    • [searchPage]: 搜索分页信息
    • [resetPage]: 重置分页信息
    • [toggle]: 切换搜索表单显示
    • [selection]: 选择的 id
    • [changeSize]: 改变一页的大小
    • [goto]: 跳转到指定页数
    • [deleteSelected]: 删除选择的数据项
    • [showFileSelector]: 是否显示导入文件选择器
    • [initCommon]: 初始化功能,如果重写了 mounted 生命周期函数,请务必调用它!

自定义表格组件

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
/**
* 自定义表格组件
*/
Vue.component('my-table', {
/**
* 列
*/
props: {
columns: {
type: Array,
default: [],
},
data: {
type: Array,
default: [],
},
},
template: `<el-table
:data="data"
tooltip-effect="dark"
style="width: 100%"
border
@selection-change="handleSelectionChange"
>
<template v-for="column in columns">

<el-table-column
:type="column.type"
:prop="column.prop"
:label="column.title"
:align="column.align"
:sortable="column.sortable"
:width="column.width"
:formatter="column.formatter"
v-if="column.customComponent"
>
<!--suppress HtmlUnknownAttribute -->
<template #default="scope">
<!--这里将传递给模板当前行的数据-->
<slot :name="humpToLine(column.prop)" :row="scope.row"></slot>
</template>
</el-table-column>
<el-table-column
:type="column.type"
:prop="column.prop"
:label="column.title"
:align="column.align"
:sortable="column.sortable"
:width="column.width"
:formatter="column.formatter"
v-else
>
</el-table-column>
</template>

</el-table>`,
methods: {
handleSelectionChange(val) {
this.$emit('handle-selection-change', val)
},
humpToLine(data) {
return toLine(data)
},
},
})

定义一些公共的实体

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
/**
* 分页信息,多次使用到所以定义一个公共的
*/
class Page {
/**
* 构造函数
* @param {Number} current 当前页数,从 1 开始
* @param {Number} size 每页的数量
* @param {Number} total 数据总条数
* @param {Number} pages 数据总页数
* @param {Array} records 一页的数据记录
* @param {...Object} [args] 其他的参数项,这里只是为了避免遗漏
* @returns {Page} 分页对象
*/
constructor({
current = 1,
size = 10,
total = 0,
pages = 0,
records = [],
...args
} = {}) {
this.current = current
this.size = size
this.total = total
this.pages = pages
this.records = records
Object.assign(this, args)
}
}

/**
* 表格的列
*/
class TableColumn {
/**
* 格式化日期事件
* @param value 字段的值
* @returns {String|*} 格式化得到的日期时间字符串 TableColumn.datetimeFormat()
*/
static datetimeFormat(_row, _column, value, _index) {
return !value ? '' : rx.dateFormat(new Date(value), 'yyyy-MM-dd hh:mm:ss')
}

/**
* 构造函数
* @param {String} [prop] 字段名
* @param {String} [title] 标题
* @param {'selection'} [type] 列类型,可以设置为选择列
* @param {Boolean} [sortable=true] 排序方式
* @param {Number} [width] 宽度
* @param {'center'} [align='center'] 水平对齐方式
* @param {Function} [formatter] 格式化列
* @param {Boolean} [customComponent] 是否自定义组件
* @param {...Object} [args] 其他的参数项,这里只是为了避免遗漏
*/
constructor({
prop,
type,
width,
title,
sortable = true,
align = 'center',
formatter,
customComponent,
...args
} = {}) {
this.prop = prop
this.type = type
this.width = width
this.align = align
this.title = title
this.sortable = sortable
this.align = align
this.formatter = formatter
this.customComponent = customComponent
Object.assign(this, args)
}
}

定义一个 BasicTableVue 继承 Vue

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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* 基本的表格数据配置
*/
class BasicTableData {
/**
* 构造函数
* @param {Object} [form={}] 搜索表单,子类一般需要覆盖(不覆盖的话可能在 html 中没有提示)
* @param {Array<TableColumn>} [columns=[]] 列信息列表,子类必须覆盖
* @param {Boolean} [formShow=false] 是否显示搜索表单
* @param {Page} [page=new Page()] 分页信息,包含数据列表
* @param {Array} [selectedIdList=[]] 选择的列表 id
* @param {Boolean} [fileSelectorShow=false] 导入文件选择器是否需要
*/
constructor({
form = {},
columns = [],
formShow = false,
page = new Page(),
selectedIdList = [],
fileSelectorShow = false,
} = {}) {
this.form = form
this.columns = columns
this.formShow = formShow
this.page = page
this.selectedIdList = selectedIdList
this.fileSelectorShow = fileSelectorShow
}
}

/**
* 基本的表格方法
*/
class BasicTableMethods {
/**
* 构造函数
* @param {Function} createForm 初始化 form 表单,主要是为了自定义初始化逻辑
* @param {Function} getPage 获取分页信息,需要覆盖
* @param {Function} exportFile 导出文件,需要覆盖
* @param {Function} importFile 导入文件,需要覆盖
* @param {Function} deleteData 删除选择的数据,需要覆盖
* @param {Function} init 初始化函数,如果可能请使用该函数而非重写 mounted 生命周期函数,该函数会在 mounted 中调用
* @param {Function} [resetFile] 重置导入选择的文件,必须为 input:file 绑定属性 ref="fileInput"
* @param {Function} [searchPage] 搜索分页信息
* @param {Function} [resetPage] 重置分页信息
* @param {Function} [toggle] 切换搜索表单显示
* @param {Function} [selection] 选择的 id
* @param {Function} [changeSize] 改变一页的大小
* @param {Function} [goto] 跳转到指定页数
* @param {Function} [deleteSelected] 删除选择的数据项
* @param {Function} [showFileSelector] 是否显示导入文件选择器
* @param {Function} [initCommon] 初始化功能,如果重写了 mounted 生命周期函数,请务必调用它!
*/
constructor({
createForm = function() {
throw new Error('如果需要搜索条件,请重写 initForm() 方法')
},
getPage = async function(page, entity) {
throw new Error('如果需要自动分页,请重写 getPage() 方法')
},
exportFile = async function() {
throw new Error('如果需要导出数据,请重写 exportFile() 方法')
},
importFile = function() {
throw new Error('如果需要导入数据,请重写 importFile() 方法')
},
deleteData = async function(idList) {
throw new Error('如果需要删除数据,请重写 deleteData 方法')
},
init = async function() {},
resetFile = function() {
const $el = this.$refs['fileInput']
if (!$el) {
throw new Error(
'如果需要清空选择文件,请为 input:file 绑定属性 ref 的值为 fileInput',
)
}
$el.value = ''
},
searchPage = async function() {
try {
this.page = await this.getPage(this.page, this.form)
} catch (e) {
console.error(e)
await rxPrompt.dangerMsg('查询数据失败,请刷新页面')
}
},
resetPage = async function() {
this.form = this.createForm()
await this.searchPage()
},
toggle = function() {
this.formShow = !this.formShow
},
selection = function(data) {
this.selectedIdList = data.map(({ id }) => id)
},
changeSize = function(size) {
this.page.current = 1
this.page.size = size
this.searchPage()
},
goto = function(current) {
if (!current) {
current = this.page.current
}
if (current < 1) {
return
}
if (current > this.page.pages) {
return
}
this.page.current = current
this.searchPage()
},
deleteSelected = async function() {
const result = await this.deleteData(this.selectedIdList)
if (result.code !== 200 || !result.data) {
await rxPrompt.msg('')
return
}
// noinspection JSIgnoredPromiseFromCall
rxPrompt.msg('删除成功')
this.page.current = 1
await this.searchPage()
},
showFileSelector = function() {
this.fileSelectorShow = !this.fileSelectorShow
},
initCommon = async function() {
this.form = this.createForm()
this.searchPage()
},
} = {}) {
this.createForm = createForm
this.getPage = getPage
this.searchPage = searchPage
this.resetPage = resetPage
this.toggle = toggle
this.selection = selection
this.changeSize = changeSize
this.goto = goto
this.exportFile = exportFile
this.importFile = importFile
this.resetFile = resetFile
this.deleteData = deleteData
this.init = init
this.deleteSelected = deleteSelected
this.showFileSelector = showFileSelector
this.initCommon = initCommon
}
}

/**
* 基本的 vue 表格配置信息
*/
class BasicTableOption {
/**
* 构造函数
* @param {String} [el='#app'] 标签选择器
* @param {BasicTableData} data 数据
* @param {BasicTableMethods} methods 方法
* @param {Function} mounted 初始化方法
*/
constructor({
el = '#app',
data = new BasicTableData(),
methods = new BasicTableMethods(),
mounted = async function() {
await this.initCommon()
await this.init()
},
} = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}
}

/**
* 基本的表格 vue 类
*/
class BasicTableVue extends Vue {
/**
* 构造函数
* @param {BasicTableOption} option 初始化选项
* @param {BasicTableData|Function} option.data vue 的 data 数据,如果是 {@link Function} 类型,则必须返回 {@link BasicTableData} 的结构
* @param {BasicTableMethods} option.methods vue 中的 methods 属性
* @param {Function} option.mounted 初始化方法,如果覆盖则必须手动初始化表格
*/
constructor({ data, methods, mounted, ...args } = {}) {
//注:这里为了应对 data 既有可能是对象,又有可能是函数的情况
super(
_.merge(new BasicTableOption(), {
data: function() {
return _.merge(
new BasicTableData(),
typeof data === 'function' ? data.call(this) : data,
)
},
methods,
mounted,
...args,
}),
)
}
}

注:这里分开这么多的类是因为便于 IDE 进行提示

使用

下面简单的使用一下 BasicTableVue

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
<!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>
<main>
<h1>用户列表</h1>
<!-- 使用内置函数 toggle 切换表单是否显示 -->
<button @click="toggle">高级搜索</button>
<!-- 使用 formShow 属性控制表单是否显示 -->
<form v-show="formShow">
<div>
<label for="name">名字:</label>
<input v-model="form.name" name="name" type="text" />
</div>
<div>
<label for="age">年龄:</label>
<input v-model="form.age" name="age" type="number" />
</div>
<div>
<!-- 使用 searchPage 查询 -->
<button @click="searchPage">查询</button>
<!-- 使用 resetPage 重置条件并搜索 -->
<button @click="resetPage">重置</button>
</div>
</form>
<div>
<!--
分页数据绑定 page 对象的 records 属性
表格的列绑定 columns 属性(需要自定义覆盖)
选中的项需要将 selection 属性绑定到 @handle-selection-change 事件
-->
<my-table
:data="page.records"
:columns="columns"
@handle-selection-change="selection"
>
<!--
定义自定义操作列
scope 指代当前行的信息
-->
<template #operating="scope">
<span>
<!-- 将自定义的函数绑定到 @click.stop.prevent 上 -->
<button @click.stop.prevent="() => viewInfo(scope.row)">
查看信息
</button>
</span>
</template>
</my-table>
<!--
分页组件
将内置的属性或函数绑定到 el-pagination 组件上
changeSize(): 改变一页数据大小的函数
goto(): 跳转指定页的函数
page: 具体参考 Page 对象
-->
<el-pagination
background
@size-change="changeSize"
@current-change="goto"
:current-page="page.current"
:page-sizes="[10, 20, 30]"
:page-size="page.size"
layout="total, sizes, prev, pager, next, jumper"
:total="page.total"
>
</el-pagination>
</div>
</main>
<script src="/user-info.js"></script>
</body>
</html>

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
40
class UserInfo {
constructor({ id, name, age, ...args }) {
this.id = id
this.name = name
this.age = age
Object.assign(this, args)
}
}

const app = new BasicTableVue({
data: {
columns: [
new TableColumn({ width: 30, type: 'selection' }),
new TableColumn({ prop: 'name', title: '姓名' }),
new TableColumn({ prop: 'age', title: '年龄' }),
new TableColumn({
prop: 'operating',
title: '操作',
customComponent: true,
}),
],
},
methods: {
createForm() {
return new UserInfo()
},
async getPage(page, entity) {
return await baseUserInfoApi.page(page, entity)
},
deleteData(idList) {
return baseCustomerApi.delete(idList)
},
viewInfo(row) {
forward('/user_info_detail', row)
},
async init() {
console.log('这里想做一些自定义的初始化操作')
},
},
})

这里需要注意一些要点

  1. 如果需要在 data 中调用 methods 中的函数,则 data 必须是一个函数并返回对象
  2. 不要直接重写 mounted() 生命周期函数,而是在重写的 init() 中进行自定义操作
  3. 任何实体都需要有 ...args 属性以避免一些没有声明的属性找不到

那么,关于 BasicTableVue 的封装便到此结束了。这是一个相当简陋的封装,如果有什么更好的方式,后面也会更新。

react 入坑思考

场景

吾辈为什么要学 react 呢?难道 HTML+CSS+JavaScript 已经满足不了了?是的,传统前端确实满足不了吾辈了,前端在快速发展,而后端手中(甚至眼中)的前端仍然是只有 HTML+CSS+JavaScript+JQuery 的世界。吾辈不想就这样下去,所以想要了解、学习、使用现代前端的内容。
谜之音:难道 vuejs 还不够么?
vuejs 既是国产(阿里),所以文档(中文)相对而言应该是最好的。而且相比于 reactvuejs 的门槛相对而言还是比较低的。至少,不用一开始就接触 webpack(天坑),在不用 webpack 的情况下使用 react 将是很困难的。
或许有人说,react 不是有 create-react-app 可以快速创建 web app 么?然而使用 create-react-app 之后,一大波僵尸(概念)将会袭来。

对白

让我们先来看一段对白

问:react 好像不推荐在浏览器中直接使用 <script> 标签引入呢?
答:是呀,你需要 npm/yarn 这类工具呢
问:npm 是什么?
答:npm 能帮助我们管理依赖的库 只要 install 一下就可以啦
问:那么安装的包要怎么引用呢?
答:你需要用 commonjs/es2016 之类的方式引入呢?
问:等等,commonjses2016?这都是什么呀?
答:哦,这是一种 js 模块化的规范而已,我们只要知道 importexport 就好啦?
问:嗯,那么我应该在哪里写 HTML
答:不不不,react 里面没有 HTML,只有 jsx
问:OMG,jsx 又是什么?
答:一种 js + xmldsl,语法上很像 HTML,no problem!
问:那写完的的 jsx 组件怎么在浏览器中查看啊?
答:你需要使用打包工具,例如 webpack,将 jsx 打包成 HTML+JavaScript 才行
问:额,不是没有 HTML 了么?
答:写的时候没有,但浏览器只认识 HTML/CSS/JavaScript,所以最终还是要变成这些才行呀
问:嗯,那么 webpack 是什么呢?
答:现代前端的一个打包工具
问:好的,那我去看看文档
一段时间后。。。
问:我看了 webpack 官网的文档,但还是不明白应该怎么打包
答:额,不行的话就用 create-react-app 吧。它会自动帮你生成一个完整配置的项目的,你只要懂得配置的意思并且会修改就好了。
问:于是,我开始了愉快的 react 之旅。。。个鬼呀!idea 怎么没提示?
答:额,你需要插件,不过更推荐 vscode,毕竟已经是事实上的前端标准编辑器了。
问:也就是说,我又要用一个新的 IDE 了?
答:不是啦,vscode 只是一个编辑器,比 idea 轻量太多了。而且,vscode 对前端生态支持很好哦
一段时间后。。。
问:我写了几个组件,但不知道应该怎么控制页面跳转?我好像并不能在后端映射到组件呀
答:react 需要使用 react router 之类的前端路由,现在前端的跳转由前端来控制就好了
问:所以说,不能使用 java 来控制么?≥﹏≤
答:额,可能真不行,为什么不用 react router 呢?
问:好吧,我先去看看。。。
问:唉,组件之间的交互好麻烦呀,每次都要依赖传递 props
答:哦,你可以尝试一下状态管理。例如 redux
问:redux?那是什么?
答:react 中的一个状态管理,可以不用一层层的传递 props 了呢
问:听起来很不错,我现在就去看一下!
问:两天后,woc,redux 去死吧?就想改个状态怎么这么麻烦,而且异步那里什么鬼?(ノ =Д=) ノ ┻━┻
答:看来你不适合 react,或许你可以看看 vuejs,更简单一点。国产,中文文档齐全,门槛很低的呢!

诚然,以上的问题不一定是指 react 本身,但依赖于如此之多的工具,本就造成了 react 的复杂性。

思考

看完以上对话,或许吾辈看起来很讨厌 react 的样子?
事实上,吾辈第一次学习现代前端的时候,就是从 react 开始的,然后基本上就像上面的对话所述,直接败退了,然后滚去学了一段时间的 vuejs
然而,直到最近,吾辈发现 vuejs 的生态实在太小了。最开始吾辈就了解过这两个框架,也知道 vuejs 的生态很小,然而确实没想到会这么小。。。
深层次来讲,vuejs 毕竟是国产,毕竟是阿里,所以还是慎用。想想 Dubbo 放弃维护Ant Design 圣诞彩蛋,一切皆是不言自明的!

现在再看 react,感觉简单了一些。一方面,由于 vuejs 的原因,接触到了 es6/npm/yarn/webpack/babel/vscode 这些前端工具链,对现代前端有了基本的概念与认知。不再因为某些代码看不懂而卡住,也不会面对各种工具一脸懵逼了。