本文首次发布于个人博客youfindme.top,转载请注明来源。

前言

在前端项目开发过程中,我们经常会听到代码规范化这个词,即通过多种工具来对代码、Git记录等进行约束,使之达到某种规范。而这些规范工具多种多样,让人眼花缭乱,本文尝试通过一个工具集,来给出一种前端代码规范化的最佳实践。

本文将基于TypeScript来讲解,工具集中主要包括以下工具:

  • eslint:对代码进行风格和规范进行检查,对不符合规范的代码给出提示,同时可以进行一定程度的自动修复
  • prettier:自动格式化代码工具,根据prettier规范对代码进行修复,拥有比eslint更加强大的代码规范性修复能力
  • husky:Git hooks工具,通过配置一系列钩子,可以在git操作的不同阶段执行相应的命令
  • lint-staged:在提交代码前进行lint检查时,可以让lint只检查git暂存区(staged)的文件,而不会检查所有文件
  • commitzen:可以实现规范的提交说明的git-cz工具,提供选择的提交信息类别,快速生成提交说明,如果需要在项目中使用commitizen生成符合某个规范的提交说明,则需要使用对应的适配器
  • commit-lint:校验提交说明是否符合规范。commitzen只是一个实现规范提交说明的工具,但可能存在不使用这个工具直接用git提交的情况,那么就需要对每次提交说明进行检查是否符合规范

除了上面提到的,其他相关工具会在用到的时候再详细说明。

一、eslint & prettier

1. 首先安装eslintprettier及相关工具

1
2
3
4
5
npm install --save-dev eslint prettier
npm install --save-dev eslint-plugin-prettier eslint-config-prettier
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
npm install --save-dev eslint-plugin-import
npm install --save-dev eslint-import-resolver-typescript

其中除了eslintprettier外的其他几个工具作用分别是:

  • eslint-plugin-prettier:将 prettier 的能力集成到 eslint 中, 按照 prettier 的规则检查代码规范性,并进行修复
  • eslint-config-prettier:让所有可能会与 prettier 规则存在冲突的 eslint rule 失效,并使用 prettier 的规则进行代码检查
  • @typescript-eslint/parser: 解析器,使 eslint 可以解析 ts 语法
  • @typescript-eslint/eslint-plugin:指定了 ts 代码规范的 plugin
  • eslint-plugin-import:对 ES6+ 的导入/导出语法进行 lint, 并防止文件路径和导入名称拼写错误的问题
  • eslint-import-resolver-typescript:这个插件为eslint-plugin-import添加了 ts 支持,详见此处

2. 配置.eslintrc.js.prettierrc.js

首先配置.eslintrc.js(或者.eslintrc.json,不建议使用.eslingtrc),常用配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module.exports = {
env: {
browser: true,
node: true,
es6: true,
},
parser: '@typescript-eslint/parser', // 解析器
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], // 使用 eslint 和 typescript-eslint 建议的规则
plugins: ['@typescript-eslint', 'prettier', 'import'], // 代码规范插件
rules: {
'prettier/prettier': 'error', // 不符合 prettier 规则的代码,要进行错误提示
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
project: 'tsconfig.json',
},
},
},
};

再来配置.prettierrc.js(或者.prettierrc.json.prettierrc),常用规则如下:

1
2
3
4
5
6
7
module.exports = {
trailingComma: 'es5',
singleQuote: true,
semi: true,
tabWidth: 2,
printWidth: 80,
};

如果需要还可以加上相应的.eslintignore.prettierignore来忽略想要的目录/文件。

3. 向package.jsonscripts中添加命令

1
2
3
4
5
6
{
"scripts": {
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx ./ --fix"
}
}

现在可以尝试一下编辑任意jsts文件并保存,通过npm run lint命令来对项目进行 lint 检查,查看是否符合规则的预期。通过npm run lint:fix命令可以对不符合风格规范的代码进行自动修复。

如果需要对另外一些语法进行 lint 检查,只需添加对应的工具即可,比如想要对reactreact hooks进行检查,则安装eslint-plugin-reacteslint-plugin-react-hooks

1
npm install --save-dev eslint-plugin-react eslint-plugin-react-hooks

之后按照各自的配置方式进行配置即可,详见 eslint-plugin-reacteslint-plugin-react-hooks

4. 添加.editorconfig文件

.editorconfig是一个用于跨编辑器和 IDE 统一代码风格的配置文件,也就是说通过这个文件,可以在不同的 IDE 中统一一些代码风格,比如统一缩进、换行符、字符编码等。

现在我们项目中添加一个.editorconfig文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# More info here: http://editorconfig.org
root = true

[*]
# Unix-style newlines with a newline ending every file
end_of_line = lf
insert_final_newline = true

# Space indentation
indent_style = space
indent_size = 2

# Set default charset
charset = utf-8

# Trim trailing whitespace
trim_trailing_whitespace = true

5. 安装 VS Code 插件

如果你使用的编辑器或者 IDE 是VS Code,那么强烈建议安装下面几个VS Code插件:

首先是ESlint和Prettier的插件,通过这两个插件可以实现实时查看当前的eslint和prettier报错而不需要执行命名行检查。

然后是EditorConfig的插件,通过这个插件,可以让VS Code读取项目中的.editorconfig文件,来覆盖VS Code的默认配置或者用户自定义的配置,从而实现一个项目内的统一风格。

顺便聊一下这几个的区别

eslint 和 prettier
  • eslint侧重影响代码质量的问题(比如=====
  • prettier更侧重代码风格的问题(比如代码折行),以及对代码自动修复

eslint很多地方只能warning警告或者报错,但是不能自动修复,比如下面的例子:

  1. 对于=====的eslint问题
    eslint_issue

    eslint_issue_fix
    这个是eslint报的一个问题,点击“快速修复”,只能弹出几个选项,比如disable这个规则,或者展示文档,没有自动修复(下面的copilot不算,这个是GitHub Copilot插件AI修复,不属于自动修复)

  2. 对于prettier的代码风格问题
    prettier_issue

    prettier_issue_fix

对于这个代码风格问题,点击“快速修复”,提供了几个自动修复的选项(Fix this …/Fix all …)

prettier 和 editorconfig

如果你仔细观察的话,会发现prettier和editorconfig的功能有一些重叠,比如都可以设置缩进、换行符等,那么这两个工具有什么区别呢?

拿缩进来说,如果在一个项目中的.prettierrc.js中设置了tabWidth: 2,但是我们的VS Code的默认缩进是4个空格。那么prettier在检查代码风格时,会按照2个空格来检测,自动修复的时候,也会把缩进改成2个空格。但是我们在编辑文件时,按一下Tab键,VS Code会使用4个空格来缩进

tab_size

这个时候就需要editorconfig来发挥作用了,在项目中添加一个.editorconfig文件,设置indent_size = 2,这样就可以覆盖VS Code的默认配置,使得VS Code在编辑文件时,按Tab键,也会使用2个空格来缩进

6. 配置eslintprettier的自动修复

在上面的步骤中,我们已经完成了eslintprettier的配置,同时通过插件也实现了实时检查,但是对于不符合规范的代码,我们还需要手动去修复,这个时候可以配置一下保存时自动修复的功能。

在项目中添加一个.vscode/settings.json文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
// 在 VS Code 1.85 版本之前,这里的值要改为 "source.fixAll.eslint": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
// 添加你项目自己的语言配置,比如 javascriptreact、typescriptreact 等
}

通过上面的配置,在Ctrl + S保存文件时,将会自动修复不符合规范的代码。

二、husky

通过上面的步骤,我们已经成功安装并配置了 eslint 相关的检查,但是我们不能仅仅依靠开发人员的自觉来保证提交到仓库的代码符合eslint规范,这时候就需要husky这样一个工具来操作git提供的一些钩子hooks,在提交代码时对代码进行 lint 检查。

1. 首先是安装husky

1
npm install --save-dev husky

2. 向package.jsonscripts中添加命令

1
2
3
4
5
{
"scripts": {
"prepare": "husky install"
}
}

prepare命令会在执行npm install(不带参数的情况下)之后自动执行。也就是说当我们执行npm install安装完项目依赖后会执行husky install命令,该命令会创建.husky/并指定该目录为git hooks所在的目录。这里我们先手动执行一次npm run prepare

3. 配置husky

添加pre-commit hooks:

1
npx husky add .husky/pre-commit // 或 npx husky set .husky/pre-commit

这将在./husky/目录下生成一个pre-commit脚本文件,在文件里添加npm run lint这个命令,添加完成后文件内容为:

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint

通过上面三个步骤,我们在执行git commit或者通过git-cz工具提交时,将会先执行pre-commit脚本的内容,即执行npm run lint检查,对不符合代码规范的提交进行阻止,防止污染代码仓库。

4. 注意

上面配置husky的方式是针对 v5.0+ 的配置方式,针对 v5.0 之前的配置方式,则是在安装husky之后在package.json中添加如下配置:

1
2
3
4
5
6
7
{
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}
}

这么改的原因是在husky 5.0版本中,做了破坏性的变更,导致两种配置方式不兼容,详见此处。本文推荐使用最新的配置方式。

三、lint-staged

完成husky配置之后,我们做到了通过每次git提交时都对项目做 lint 检查,防止不符合规范的代码提交到仓库,但是这带来一个问题:每次提交都将对整个项目做 lint 检查,对于一个越来越大的项目来说,这无疑是一个很耗时的操作,除此之外,对于新接入这些配置的项目,项目中可能已经存在了大量不符合规范的代码,不能要求在提交时把所有历史遗留的问题修复之后才能提交。这个时候就需要用到lint-staged这个工具了。

1. 安装lint-staged

1
npm install --save-dev lint-staged

2. 在package.json中配置lint-staged

package.json中添加如下配置,配置表明在运行lint-staged的时候将只匹配srctest目录下的tstsx文件,我们可以根据自己项目的需要修改配置:

1
2
3
4
5
6
7
8
9
10
{
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint"
],
"test/**/*.{ts,tsx}": [
"eslint"
]
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"lint-staged": "lint-staged"
}
}

3. 修改.husky/pre-commit脚本的内容

.husky/pre-commit脚本的内容改为npm run lint-staged

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint-staged

通过上面的步骤,就完成了lint-staged的配置,这个时候再进行 git 提交时,将只检查暂存区(staged)的文件,不会检查项目所有文件,加快了每次提交 lint 检查的速度,同时也不会被历史遗留问题影响。如下图:
lint_staged

四、commitzen

有了上面的几个配置后,其实已经算是一个比较完善的代码规范配置了,不过还有一些其他的可以去做的事,比如 git 提交记录规范。

在多人协作开发过程中,如果不注意提交记录的规范,那么每个人可能都有自己的提交风格,如“修复了xxx的bug”、“增加了一个按钮,修改了一处文案”、“config配置文件增加了xxx字段”,更有甚者会在对项目文件做了大量变更后只写一个“增加了若干功能”或“修复了若干问题”这种提交信息。

那什么才一个好的提交信息呢,我们以Angular提交信息规范来举例说明。

在 Angular 提交信息规范中,一个好的提交信息的结构应该如下所示:

1
2
3
4
5
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

整个提交说明包括三部分:header页眉、body正文、footer页脚,在每个部分之间有一个空白行分隔,其中header部分是每次提交中必须包含的内容。

  • header:
    对于header来说,又包括三部分:
  • body:
    本地提交的详细描述,说明代码提交的详细说明
  • footer:
    主要包括本次提交的 BREAKING CHANGE(不兼容变更)和要关闭的 issue

更加详细的内容请参考Angular提交信息规范

commitzen就是这样一个工具,它可以提供可以选择的提交信息类别,快速生成符合规范的提交说明。

1. 先安装commitizencz-conventional-changelog

1
npm install --save-dev commitizen cz-conventional-changelog

我们在前言中提到,如果需要在项目中使用commitizen生成符合某个规范的提交说明,则需要使用对应的适配器,而cz-conventional-changelog就是符合AngularJS规范提交说明的commitzen适配器。

2. 在package.json中配置commitizen

package.json中添加如下配置,配置指明了cz工具commitizen的适配器路径:

1
2
3
4
5
6
7
{
"config": {
"commitizen": {
"path": "node_modules/cz-conventional-changelog"
}
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"commit": "git-cz"
}
}

这时我们就可以使用npm run commit来代替git commit进行提交了:commitizen

3. 配置自定义提交信息规范

如果想定制项目的提交信息规范,可以使用cz-customizable适配器:

1
npm install --save-dev cz-customizable

然后把package.json中配置的适配器路径修改为cz-customizable的路径:

1
2
3
4
5
6
7
{
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
}
}

之后在根目录下新建一个.cz-config.js来配置自定义的规范,这里提供一个官方的示例cz-config-EXAMPLE.js,修改里面字段、内容为自己想要的规范即可

4. 生成日志记录

对于使用了commitzen的项目,还可以配套使用conventional-changelog-cli来生成开发日志:

1
npm install --save-dev conventional-changelog-cli

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"version": "conventional-changelog -p angular -i CHANGELOG.md -s"
}
}

这样执行npm run version之后就可以看到生成了CHANGELOG.md日志文件了。

五、commit-lint

在配置了commitizen之后,我们可以愉快地使用npm run commit规范化提交代码,但是配置commitizen并不等于不能使用git commit或者其他工具提交,还是存在提交不规范记录说明的可能,那么就需要使用commit-lint来对每次提交信息进行限制。

1. 安装commitlint@commitlint/config-conventional

1
npm install --save-dev commitlint @commitlint/config-conventional

其中@commitlint/config-conventional是一个符合Angular规范的校验规则集,就像esint也需要extends一些规则集一样,commitlint也需要extends一些规则集。

2. 在package.json中配置commitlint

package.json中添加如下配置:

1
2
3
4
5
6
7
{
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"commitlint": "commitlint --edit $1"
}
}

注意,我们可能在别的地方见到过commitlint的下面这种写法

1
2
3
4
5
{
"scripts": {
"commitlint": "commitlint -E HUSKY_GIT_PARAMS"
}
}

同样的,这也是一种在husky v5.0 之前的写法,在新版husky中不在使用。

3. 向husky中添加commit-msg hooks

1
npx husky add .husky/commit-msg // 或 npx husky set .husky/commit-msg

这将在./husky/目录下生成一个commit-msg脚本文件,在文件里添加npm run commitlint这个命令,添加完成后文件内容为:

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run commitlint

这时,如果通过git commit或其他工具在提交时没有按照规范写提交信息,那么本次提交就会被阻止,如下图:
commit_lint

4. 对于自定义提交信息规范的 lint 校验

上面说的@commitlint/config-conventional是针对Angular规范的校验规则集,对于使用了cz-customizable的自定义提交信息规范,就需要使用commitlint-config-cz这个工具来对自定义规范进行校验了:

1
npm install --save-dev commitlint-config-cz

然后把package.jsoncommitlint的配置改为:

1
2
3
4
5
6
7
{
"commitlint": {
"extends": [
"cz"
]
}
}

六、总结

通过上面的这些步骤,我们算是完成了前端代码规范化的配置,到目前,我们新增的配置有:

  • .vscode/settings.json
  • .editorconfig
  • .eslintrc.js
  • .eslintignore
  • .prettierrc.js
  • .prettierignore
  • .husky
    • pre-commit
    • commit-msg
    • _ husky自动生成的一个文件夹
  • package.json 下面只列出来新增的配置,对于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
38
39
40
41
42
43
44
45
46
{
"scripts": {
"prepare": "husky install",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx ./ --fix",
"lint-staged": "lint-staged",
"commit": "git-cz",
"commitlint": "commitlint --edit $1",
"version": "conventional-changelog -p angular -i CHANGELOG.md -s"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint"
],
"test/**/*.{ts,tsx}": [
"eslint"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"config": {
"commitizen": {
"path": "node_modules/cz-conventional-changelog"
}
},
"devDependencies": {
"@commitlint/config-conventional": "^12.1.4",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"commitizen": "^4.2.4",
"commitlint": "^12.1.4",
"conventional-changelog-cli": "^2.1.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.30.0",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-prettier": "^3.4.0",
"husky": "^7.0.0",
"lint-staged": "^11.0.0",
"prettier": "^2.3.2"
}
}

另外,还安装了三个 VS Code 插件。

配置过程虽然繁琐,但是对于一个项目,特别是多人协作的大型项目来说,是必不可少的一部分。其实担心配置麻烦,导致每一个新项目都需要重新走一遍配置流程,可以考虑使用Yeoman这个脚手架工具,把一个已经完成代码规范化配置的项目作为模板,通过yo直接一键生成即可,此处是我创建的一个 demo 工程,包含了上述全部配置:https://github.com/JingzheWu/lint-demo,以供参考。

参考文章和链接

AOP在其他编程语言中应用较多,在js中却应用较少,由于最近参与的项目中涉及到性能打点上报等,开始探索和了解无侵入地监控或修改代码的方式,趁此机会深入学习了一下AOP与装饰器

一、什么是AOP

AOP(Aspect-Oriented Programming),即面向切面编程,是一种编程范式,与之对应的有OOP(Object-Oriented Programming)面向对象编程。在JavaScript开发中,面向对象编程不管是ES5中直接操作原型还是ES6+中的class语法糖,大家用的比较多也应该都挺熟悉,但是相对而言,AOP用的就没那么多了。

对于OOP,我们在开发过程中往往是对类和对象通过继承进行纵向拓展,但是在AOP中则更侧重于把对象、方法作为切面,对原对象、方法进行无侵入横向拓展,下面我们以一些简单的例子和代码来讲一下AOP。

假设我们有一个按钮,绑定了一个点击事件处理函数handleClick,现在需要对按钮增加点击上报,很简单,只需要在handleClick中调用一下上报函数就可以了:

1
2
3
4
5
6
7
const report = (message) => {
console.log(`上报了信息:${message}`);
}
const handleClick = (e) => {
// do something...
report('一条信息')
}

如果以后又需要增加对这个函数运行时间的上报,那么就需要在函数开头和结尾各自计算一下时间。

但是想一下,这样做带来的坏处是显而易见的,一是不利于拓展,每次增加新的点都需要改一次handleClick函数,改的越多越不利于后期维护;二是有悖于单一职责原则handleClick里处理了大量非按钮的逻辑,同样增加了维护难度。这时候AOP就是一个很好的解决办法了。

二、ES6+之前的实现

在ES5以及ES6+的装饰器出现之前,我们往往是这样做的(这里用了ES6的部分语法),通过修改Function构造函数的原型,给原型增加两个方法beforeafter,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Function.prototype.before = function (beforeFn, beforeFnArgs) {
const self = this;
return function () {
beforeFn.call(this, beforeFnArgs, ...arguments);
return self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn, afterFnArgs) {
const self = this;
return function () {
const ret = self.apply(this, arguments);
afterFn.call(this, afterFnArgs, ...arguments);
return ret;
};
};

通过这两个方法,实现在任意一个函数执行前后,执行我们自己自定义的行为,以达到装饰或拦截原函数、对象行为的目的。现在,实现上面点击上报的方式变成了这样:

1
2
3
4
5
6
7
8
9
const report = (message) => {
console.log(`上报了信息:${message}`);
};

let handleClick = function (e) {
// do something...
};

handleClick = handleClick.after(report, '一条信息');

和修改原型类似的,我们还可以用高阶函数HOF来实现一个上报函数执行时间的函数(beforeafter本质上也是高阶函数,只不过是定义在Function原型上的高阶函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const reportExeTime = (fn) => {
return function () {
let beforeTime;
return fn
.before(() => {
beforeTime = Date.now();
})
.after(() => {
report(`${Date.now() - beforeTime}`);
});
};
};

handleClick = reportExeTime(handleClick);

假设之后还要增加其他和业务逻辑无关的埋点、统计、监控等逻辑,只需要通过类似的方式来装饰原函数即可,而无需修改原函数。通过这种方式,我们实现了对原函数无侵入的横向拓展,是一种比继承更具弹性的代替方案。

其实到这里,很多小伙伴已经意识到了,在前端开发中已经有很多地方已经用到过AOP这种思想了,例如React的高阶组件HOC、Redux的中间件等,都是这种思想的体现。

三、ES6+通过装饰器实现

上面说的是ES6+中装饰器出现前AOP的实现方式,然而装饰器(Decorators)的出现给我们提供了另一种更为优雅的实现方式(本质上和高阶函数类似),虽然到目前为止这个特性也还只是在提案的第三阶段,还未真正引入到ES标准中,但是通过Babel(> 7.1.0)或者使用TypeScript,就可以提前使用这个特性。

关于装饰器的定义和使用方法已经有很多资料讲解了,这里不做过多介绍,我们来看下用装饰器怎么实现上面的逻辑。很容易想到,这里需要的是方法装饰器,首先定义一个上报的方法装饰器withReport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const withReport = (target, keyName, descriptor) => {
const originFn = descriptor.value;
descriptor.value = function () {
const ret = originFn.apply(this, arguments);
console.log('上报了信息'); // 数据上报
};
return descriptor;
};

class MyClass {
@withReport
handleClick(e) {
// do something...
}
}

或者我们还可以仿照上面的beforeafter,分别实现一个方法前置和后置装饰器工厂来执行任意前置后置方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const before = (beforeFn, beforeFnArgs) => {
return function (target, keyName, descriptor) {
const originFn = descriptor.value;
descriptor.value = function () {
beforeFn.call(this, beforeFnArgs, ...arguments);
return originFn.apply(this, arguments);
};
return descriptor;
};
};

const after = (afterFn, afterFnArgs) => {
return function (target, keyName, descriptor) {
const originFn = descriptor.value;
descriptor.value = function () {
const ret = originFn.apply(this, arguments);
afterFn.call(this, afterFnArgs, ...arguments);
return ret;
};
return descriptor;
};
};

这样我们可以方便地传入一个report函数来实现上报:

1
2
3
4
5
6
7
8
9
10
const report = (message) => {
console.log(`上报了信息:${message}`);
};

class MyClass {
@after(report, '一条信息')
handleClick(e) {
// do something...
}
}

对于后续增加的其他非业务相关逻辑,也可以通过多个装饰器组合来实现,如上报函数执行时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const reportExeTime = (target, keyName, descriptor) => {
const originFn = descriptor.value;
descriptor.value = function () {
const beforeTime = Date.now();
const ret = originFn.apply(this, arguments);
report(`${Date.now() - beforeTime}`);
return ret;
};
return descriptor;
};

class MyClass {
@after(report, '一条信息') // 这个装饰器后执行
@reportExeTime // 这个装饰器先执行
handleClick(e) {
// do something...
}
}

通过装饰器,我们更优雅地实现了AOP,对方法进行横向拓展,而且不会像之前那样直接修改Function原型(确实是一种不太好的方式,污染原型链),装饰器之所以可以做到这一点是因为它并不是在运行时改变类、属性、方法的行为,而是在代码编译的时候就修改了,所以本质上来讲,装饰器就是一个在编译时运行的语法糖函数

四、 AOP与装饰者模式和职责链模式

上面提到了,我们可以通过AOP来拓展函数或对象的行为,可以达到装饰或拦截原函数、对象行为的目的,对应的有两个设计模式,分别是装饰者模式职责链模式(详见《JavaScript设计模式与开发实践》),AOP在这两种模式中的应用有一个明显的区别,就是能否阻断/覆盖原函数、对象的行为。有一点要声明的是,AOP只是实现这两种设计模式的其中一种方式,并不是唯一的实现方式,这里只是探讨在两种模式中AOP的应用。

对于装饰者模式,上面的几个示例都是很好的例子,不管是修改原型实现还是装饰器实现,都是通过beforeafter这两个来横向(区别于纵向的继承)的拓展原函数的能力,这里的beforeafter都是静默执行的,即beforeafter里执行的函数都不会阻断后续函数的执行或者覆盖原函数的返回值。应用场景也比较多,如上报、计时、日志等等,这也是AOP应用的比较多的场景。

然而对于职责链模式,对于beforeafter的能力要求则变成了:beforeafter里执行的函数可以阻断后续函数的执行,或beforeafter里执行的函数的返回值可以覆盖原函数的返回值。对应的代码实现变成了类似于下面这种:

  • 修改原型实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Function.prototype.before = function (beforeFn, beforeFnArgs) {
const self = this;
return function () {
const beforeRet = beforeFn.call(this, beforeFnArgs, ...arguments);
if (!beforeRet) {
// 如果不合法则阻断后续操作,这里为了表示方便这样简写
return beforeRet;
}
return self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn, afterFnArgs) {
const self = this;
return function () {
const ret = self.apply(this, arguments);
if (ret) {
// 返回值符合条件时才返回,这里为了表示方便这样简写
return ret;
}
return afterFn.call(this, afterFnArgs, ...arguments); // 在原函数返回值不符合条件时,执行after后续函数,覆盖原函数返回值
};
};
  • 装饰器实现
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
const before = (beforeFn, beforeFnArgs) => {
return function (target, keyName, descriptor) {
const originFn = descriptor.value;
descriptor.value = function () {
const beforeRet = beforeFn.call(this, beforeFnArgs, ...arguments);
if (!beforeRet) {
// 如果不合法则阻断后续操作,这里为了表示方便这样写
return beforeRet;
}
return originFn.apply(this, arguments);
};
return descriptor;
};
};

const after = (afterFn, afterFnArgs) => {
return function (target, keyName, descriptor) {
const originFn = descriptor.value;
descriptor.value = function () {
const ret = originFn.apply(this, arguments);
if (ret) {
// 返回值符合条件时才返回,这里为了表示方便这样简写
return ret;
}
return afterFn.call(this, afterFnArgs, ...arguments); // 在原函数返回值不符合条件时,执行after后续函数,覆盖原函数返回值
};
return descriptor;
};
};

这种也有很多应用场景,最常见的就是表单等的校验,如传入的值不合法,则阻断后续函数执行,或者在原函数返回值不符合要求时,则用after里函数的返回值来覆盖。

五、 结语

其实除了上面提到的,我们还可以拓展下思路,上面对于函数的修饰,都是需要项目中的每个开发者手动在方法、类等前面显式调用,那么对于一些基础、通用的修饰方法,能否在打包构建时,通过打包构建插件分析语法树自动地给代码里的特定方法、类增加修饰,这个值得我们进一步思考探索。

DNS协议也可以称为DNS服务,全称是Domain Name System,即域名系统,和HTTP协议一样,也是一个位于应用层的协议(服务),它是基于运输层的UDP协议的,关于网络协议的分层介绍,见这里(还没有写好,先放这里一个空链接)。
从DNS的名字我们就可以知道,它提供域名映射到IP地址的服务,那么在我们详细说DNS协议之前,先来大致讲讲互联网的域名结构

1. 域名结构

我们都知道,在互联网中,每一台计算机想要访问互联网,都需要一个IP地址来进行网络通信,可以是形如192.168.1.1这样的点分十进制的IPv4地址,也可以是形如[0:0:0:0:0:0:0:1]这样方括号包围的十六进制的IPv6地址,但是这些地址不便于记忆,所以就产生了域名这种方便人们记住的东西(当然你要是记忆力好能记住IP地址,在浏览器地址栏输入URL的时候,也可以直接用IP地址替换掉域名,效果是一样的)。

域名也可以叫做主机名。这里我们先给出一个域名,例如www.abc.com,以这个域名为例,来看一下域名的结构。

域名采用层次化的结构,其中com是顶级域名,abc是二级域名,www是三级域名,从语法上来说,每个域名都是几个不同层级的名字用.连接起来的组合,域名不区分大小写字母,所以www.ABC.comwww.abc.com是一样的,按照规定来说,域名有长度限制,这里就不再细讲。

上面提到,com是一个顶级域名(TLD,Top Level Domain),除了com之外,顶级域名还有好多,一般来说可以分为:

  • 国家顶级域名

    cn表示中国,us表示美国,uk表示英国,当然还有一些特殊地区也有所谓的国家顶级域名,比如hk表示香港

  • 通用顶级域名

    com表示公司企业,net表示网络服务机构,org表示非营利组织,edu表示美国教育机构(中国的教育机构一般来说是xxx.edu.cn),gov表示美国政府部门(中国的政府部门一般来说是xxx.gov.cn)等。

  • 基础结构域名

    不常见,不做叙述。

所以说,在中国,企业的域名一般可以是xxx.comxxx.com.cnxxx.cn

下面这个域名树,清晰地展示了互联网的域名空间:

互联网的域名结构

在图中,edu.cn下面,划分除了很多三级域名,如tsinghuapku等,一旦某个单位有了一个域名,它就可以自己决定是否要进一步划分下属的子域,域名树的树叶节点就是单台计算机的名字,不能再继续划分了,例如mailwww这两台计算机的完整域名分别为mail.tsinghua.edu.cnwww.tsinghua.edu.cn(这里起什么名字都可以,一般来说,人们愿意把用作邮件服务器的计算机叫做mail,把用作网站的服务器叫做www)。

到这里,我们已经知道了为什么要有域名以及域名的结构,域名就是为了便于人们记忆而产生的,但是域名是方便人们记忆了,计算机反而不好处理了,它更善于处理IP地址这种东西,所以这里就用到了我们要讲的DNS协议,用来把域名映射到IP地址,在了解DNS解析域名的过程之前,我们还需要了解一下域名服务器这种东西(怎么这么多东西,写个博客好麻烦啊23333)。

2. 域名服务器(DNS服务器)

上面说到了域名体系的树状结构,为了实现域名系统,我们首先想到的就是在域名树的每个节点都设置一台对应的服务器,这样就可以了,但是这样做的后果就是导致域名服务器太多,域名系统的运行效率太低,所以,通常是将好多个节点划分为一个区,用分区的方式来实现域名系统,如下图:

DNS分区

其中,(a)是abc这个公司只设置了一个分区的样子,(b)是abc公司设置了两个分区的样子,这两个区分别为abc.comy.abc.com,这两个区对应了两个权限域名服务器(后面还会提到)。下图可以看出域名服务器的层次结构:

域名服务器的层次结构

由上图可以看出,根据域名服务器起的作用,可以把域名服务器分为四种不同的类型:

  • 根域名服务器

    根域名服务器对应的是域名树中的根节点,是最高层次的域名服务器,但是并不是只有一台,而是有好多台,总共有13个不同IP地址的根域名服务器,用a到m来表示,如a.rootservers.net,每个IP地址对应一套装置,一套装置在世界不同地点安装了服务器,总之,数量很多,当访问根域名服务器时,往往就是由路由器找到最近的根域名服务器访问。

    一般来说,每台根域名服务器都知道所有的顶级域名服务器的域名以及IP地址,但往往也只知道这些,所以访问根域名服务器一般只能得到顶级域名服务器的IP地址,而不能直接知晓你要找的具体服务器的IP。一般来讲,如果要通过互联网解析域名,本地域名服务器首先访问的就是根域名服务器,下面还会讲到。

  • 顶级域名服务器

    顶级域名服务器负责管理在顶级域名下的所有二级域名,例如cn是顶级域名服务器,管理edugov等二级域名,当接收到查询请求时,要么给出最终结果(即具体IP地址),要么给出下一步应该访问的权限域名服务器的IP地址。

  • 权限域名服务器

    权限域名服务器上面已经提到过一次了,就是每个域名分区对应一个服务器就叫做权限域名服务器。当接收到DNS查询请求时,如果可以给出具体的IP地址就给出(例如前面的abc.com可以给出v.x.abc.com的IP地址,所以直接返回),如果不可以,就把下一步要访问的权限域名服务器的IP地址返回(如abc.com不能给出t.y.abc.com的IP,就会把y.abc.com这个权限域名服务器的IP地址返回)。

  • 本地域名服务器

    可能有人已经发现了,上面的图中并没有本地域名服务器这一层级结构,这是因为本地域名服务器(有时候也叫作首选域名服务器或者默认域名服务器)不属于域名树的层次结构,但是相当重要。当一台计算机要进行DNS查询的时候,就会将查询的请求发给本地域名服务器。

    一般来说,一个互联网服务提供商(ISP)或者是一个大学(甚至是一个系)都有一个本地域名服务器,也就是说,当你再学校内使用校园网时,你的本地域名服务器并不是指你自己的电脑,因为你的系或者你的学校,都共用一个本地域名服务器。

3. 域名解析过程

在了解了上面的域名结构以及域名服务器之后,我们来看一下域名的解析过程,总的来讲,主机向本地域名服务器的域名查询一般采用递归查询,而本地域名服务器向根域名服务器的查询一般采用迭代查询。

概念其实很简单,递归查询就是如果主机向本地域名服务器查询IP,本地域名服务器不知道IP地址,这是本地域名服务器就代替主机称为DNS客户,向根域名服务器进行下一步查询。

而迭代查询就是,在本地域名服务器向根域名服务器查询时,根域名服务器要么给出最终的IP地址,要么给出下一步本地域名服务器要访问的顶级域名服务器的IP地址,之后本地域名服务器再去访问相应的顶级域名服务器(而不是让根域名服务器代替本地域名服务器去进行下一步查询)。

简单来说,递归查询返回具体IP地址或者报错没找到,迭代查询返回具体IP地址或者返回下一步的IP地址。如下图:

域名迭代查询

当然,我们也可以指定查询方式让查询过程全程使用递归查询,如下图:

域名递归查询

下面以一个例子说说明在无缓存的情况下(下面会谈到缓存),通过DNS来查询域名的步骤,假设域名为m.xyz.com的主机想要查询y.abc.com这个域名对应的IP地址,那么他就会按照域名查询方式1(上图)中的方式进行查询:

  1. 主机m.xyz.com先向本地域名服务器dns.xyz.com进行递归查询。
  2. 本地域名服务器无法给出IP地址,所以本地域名服务器向(离自己最近的)根域名服务器查询,这是的查询已经变为了迭代查询。
  3. 根域名服务器根据本地域名服务器发送的报文,知道了下一步应该查询的是哪个顶级域名服务器,这时根域名服务器告诉本地域名服务器,下一步应该查询的顶级域名服务器dns.com的IP地址。
  4. 本地域名服务器向顶级域名服务器dns.com发送请求查询。
  5. 顶级域名服务器dns.com告诉本地域名服务器,下一步应该查询的权限域名服务器dns.abc.com的IP地址。
  6. 本地域名服务器向权限域名服务器dns.abc.com发送请求查询。
  7. 权限域名服务器dns.abc.com告诉本地域名服务器想要查询的域名y.abc.com的IP地址。
  8. 本地域名服务器在拿到IP地址后,将IP地址返回给主机m.xyz.com
    这就是域名查询的具体过程,因为DNS协议的基于UDP协议,所以上述8个步骤中,总共使用了8个UDP报文。

上面提到,这是在没有缓存的情况下的DNS查询,实际上,为了提高查询的效率以及降低根域名服务器的负荷,一般来说域名服务器中都有高速缓存,用来记录最近查询过的域名的缓存,如果在查询域名时,有想要的域名到IP的映射缓存,并且缓存没有过期,那么就直接将缓存中域名映射的IP地址返回,省去了接下来的查询。

只有在没有缓存或者缓存过期了才会进行下一步的查询。实际上,不知域名服务器有缓存,每台计算机甚至浏览器都会对域名到IP的映射进行缓存,这样一来大大加快的DNS的查询速度。

上面这些就是DNS协议的大概了,如果觉得有哪里写的不对的,还请多多指教。

参考书籍:《计算机网络》谢希仁

基本概念

以下基本概念相关的描述引自菜鸟教程:

Git 工作区、暂存区和版本库 | 菜鸟教程

  • 工作区:就是你在电脑里能看到的目录
  • 暂存区:英文叫 stage 或 index。一般存放在 .git 目录下的 index 文件(.git/index)中,所以我们把暂存区有时也叫作索引(index)
  • 版本库:工作区有一个隐藏目录 .git,这个不算工作区,而是 Git 的版本库

下面这个图展示了工作区、版本库中的暂存区和版本库之间的关系:

工作区、暂存区和版本库之间的关系

  • 图中左侧为工作区,右侧为版本库。在版本库中标记为 “index” 的区域是暂存区(stage/index),标记为 “master” 的是 master 分支所代表的目录树。
  • 图中我们可以看出此时 “HEAD” 实际是指向 master 分支的一个”游标”。所以图示的命令中出现 HEAD 的地方可以用 master 来替换。
  • 图中的 objects 标识的区域为 Git 的对象库,实际位于 “.git/objects” 目录下,里面包含了创建的各种对象及内容。
  • 当对工作区修改(或新增)的文件执行 git add 命令时,暂存区的目录树被更新,同时工作区修改(或新增)的文件内容被写入到对象库中的一个新的对象中,而该对象的ID被记录在暂存区的文件索引中。
  • 当执行提交操作(git commit)时,暂存区的目录树写到版本库(对象库)中,master 分支会做相应的更新。即 master 指向的目录树就是提交时暂存区的目录树。
  • 当执行 git reset HEAD 命令时,暂存区的目录树会被重写,被 master 分支指向的目录树所替换,但是工作区不受影响。
  • 当执行 git rm --cached <file> 命令时,会直接从暂存区删除文件,工作区则不做出改变。
  • 当执行 git checkout . 或者 git checkout -- <file> 命令时,会用暂存区全部或指定的文件替换工作区的文件。这个操作很危险,会清除工作区中未添加到暂存区中的改动。
  • 当执行 git checkout HEAD . 或者 git checkout HEAD <file> 命令时,会用 HEAD 指向的 master 分支中的全部或者部分文件替换暂存区和以及工作区中的文件。这个命令也是极具危险性的,因为不但会清除工作区中未提交的改动,也会清除暂存区中未提交的改动。

为方便下面的描述,简单区分一下工作区、暂存区和版本库:

  • 工作区:本地的项目中没有通过 git add 添加到暂存区的文件
  • 暂存区:本地的项目中通过 git add 添加到暂存区的文件
  • 版本库:本地的项目中通过 git commit 添加到提交历史中的文件

基本命令

  • git init: 初始化一个本地git仓库repository
  • git status: 查看本地仓库的状态
  • add
    • git add <file>: 将工作区下的某个文件的修改加到暂存区(stage)
    • git add .: 将工作区下的当前目录下的修改加到暂存区(stage)
    • git add -A: 将工作区下的所有目录下的修改加到暂存区(stage)
    • git add -u: 将工作区下的所有目录下的修改加到暂存区(stage),但是不包括新添加的文件
  • commit
    • git commit -m "<some message>": 将暂存区(stage)提交到版本库,并写一些描述信息
    • git commit -m "<some message>" --no-verify: 将暂存区(stage)提交到版本库,并写一些描述信息,但是不会执行 pre-commit 钩子(慎用,通常在pre-commit钩子中做一些代码检查,如果不执行pre-commit钩子,那么就会跳过代码检查,这样就可能会导致一些问题)
    • git commit: 将暂存区(stage)提交到版本库,会弹出一个编辑器,让你输入本次提交的说明,编辑器是git配置的默认编辑器,一般是vim
    • git commit --no-verify: 同上,只不过不会执行 pre-commit 钩子(慎用
  • push
    • git push: 将本地的最新改动记录,推送到远端的和本地分支同名的分支下(前提是远端有同名分支)
    • git push -f: 强制推送,即使远端有比本地更新的提交记录,也会强制推送(慎用,可能会导致远端的提交记录丢失,执行这个之前务必知道自己在做什么)

clone 命令

  • git clone git@server-name:path/repo-name.git: 从远程仓库clone一个仓库到本地,同时会关联到远程仓库
  • git clone -b <branchName> git@server-name:path/repo-name.git: 从远程clone指定分支到本地

merge & rebase 命令

  • merge
    • git merge <branchName>: 将指定branchName的分支合并到当前分支
    • git merge --no-ff: 表示在系统采取 fast-forward 时让系统采用普通合并方式,而不是采用快速合并,普通合并会多一次commit,这样在以后查看时能够看到合并历史,而 fast-forward 合并则看不到曾经做过的合并
  • rebase
    • git rebase <branchName>: 将当前分支的改动,rebase到branchName分支上,这样就可以保证当前分支的提交记录是线性的,而不是分叉的,这样在以后查看提交记录时会更清晰。但是如果有冲突,可能需要多次解决冲突,比较麻烦
    • git rebase -i: 交互式rebase,可以在rebase的过程中,对提交记录进行修改,比如说合并提交记录、删除提交记录等
    • Git rebase详细解析_GhostStories的博客-CSDN博客_git rebase 参数

fetch & pull 命令

  • git fetch: 从远程取回所有分支的更新(但不会自动合并)
  • git fetch origin <branchName>: 从远程 originorigin 换成其他名字,则从对应的远程仓库获取更新)取回branchName分支的更新(但不会自动合并)
  • git pull: 从远程拉取最新的修改到本地并合并,如果有冲突则需要在本地解决。git pull 相当于 git fetch 加上 git merge

push 命令

  • git push -u origin master: 第一次将本地的repository的master分支推送到远程,以后再push就不用加 -u 了,关于-u的用法
  • git push origin <branchName>: 将分支推送到远程仓库 origin 的branch分支上,如果远程仓库 origin 中没有叫做 branchName 的分支,则会创建一个

如果 git push 省略了远程仓库名则默认会将本地记录推送到名为 origin 的远程仓库上,且目标推送分支是 origin 远程仓库中和本地仓库分支同名的分支

stash 命令

  • git stash: 把当前工作现场先存起来(不是add也不是commit),可以暂时去做其他事,之后(主要应用场景是当前手头工作没做完不能commit,但是又有紧急的bug需要切换到其他分支上去修复)
  • git stash list: 查看所有存起来的工作现场
  • git stash pop: 将stash list中,最顶部的工作现场恢复,并将list顶部的给删除掉
  • git stash apply <指定stash>: 将指定的工作现场恢复,但是在stash list当中并不删除指定stash
  • git stash clear: 情况stash list(慎用

checkout 命令

  • git checkout -- <file>: 将工作区中file文件的修改撤销,包括把删除的file复原(在添加到暂存区之前才有效,如果将工作区文件的修改add到了暂存区,那么再使用这个命令就会无效,这时应该先把暂存区的修改撤销,再使用这个命令)
  • git checkout <branchName>: 切换到branchName分支上
  • git checkout -b <branchName>: 是上面 git branch <branchName>git checkout <branchName> 两条命令合并之后的简写,即创建一个名为name的分支并切换到那个分支
  • git checkout <commit_id>: 切换到指定的commit(可以是当前分支也可以是其他分支)

reset 命令

  • git reset --hard HEAD^ / git reset --hard HEAD~1: 将git的HEAD指针指向版本库中上一个版本,同时也会把整个项目的文件回退到上一个版本。这个会把最近的一个commit以及对应的改动完全丢弃,不再保留。如需要多回退几个版本,可以使用 HEAD~nn 表示回退几个版本
  • git reset --hard <commit_id>: 将git的HEAD指针指向版本库中某个commit指定的版本,同时也会把整个项目的文件回退到这个版本。这个会把commit_id之后的所有commit以及对应的改动完全丢弃,不再保留
  • git reset --soft HEAD^ / git reset --soft HEAD~1: 将将git的HEAD指针指向版本库中上一个版本,但是不会修项目的文件,会把上一个版本的改动,保留到git暂存区(不会修改工作区,只改暂存区)。如果当前暂存区有内容,会将上一个版本的改动和暂存区的内容合并到暂存区
  • git reset --soft <commit_id>: 将将git的HEAD指针指向版本库中某个commit指定的版本,但是不会修项目的文件,会把commit_id之后的所有commit对应的改动,保留到git暂存区(不会修改工作区,只改暂存区)。如果当前暂存区有内容,会将commit_id之后的所有commit对应的改动和暂存区的内容合并到暂存区
  • git reset --mixed HEAD^ / git reset --mixed HEAD~1 / git reset HEAD^ / git reset HEAD~1: 将git的HEAD指针指向版本库中上一个版本,但是不会修项目的文件,会把上一个版本的改动,保留到git工作区,同时也会将暂存区的内容清空。如果当前暂存区有内容,会将上一个版本的改动和暂存区的内容合并到工作区
  • git reset --mixed <commit_id> / git reset <commit_id>: 将git的HEAD指针指向版本库中某个commit指定的版本,但是不会修项目的文件,会把commit_id之后的所有commit对应的改动,保留到git工作区,同时也会将暂存区的内容清空。如果当前暂存区有内容,会将commit_id之后的所有commit对应的改动和暂存区的内容合并到工作区

其中,--hard--soft--mixed是三种不同的模式,--mixed是默认的模式,如果不指定模式,那么就是--mixed模式。

可以理解为 git reset --hard 通常用于丢弃最近的提交,而另外两个通常用于修改最近的提交。

revert 命令

  • git revert <commit_id>: 撤销提交记录中间的某次commit,同时会生成一条新的commit记录
  • git revert -m <commit_id>: 撤销提交记录中间的某次commit,同时会生成一条新的commit记录(这次commit是别的分支合并过来的)
  • git revert --no-commit commit_id1..commit_id2: 撤销提交记录中间的几个连续的commit(注意这是一个左开右闭区间,即不包括 commit_id1,但包括 commit_id2

cherry-pick 命令

  • git cherry-pick <commit_id>: 将另一个分支上的指定提交记录,摘抄到当前分支
  • git cherry-pick <commit_id1>..<commit_id2>: 将另一个分支上的从commit_id1commit_id2之间的提交记录摘抄到到当前分支

详见:git 场景——从一个分支cherry-pick一个或多个commit到另一个分支

branch 命令

  • git branch: 查看本地所有分支,当前所在分支前面有一个 *
  • git branch -r: 查看远程所有分支
  • git branch -a: 查看本地和远程所有分支
  • git branch -d <name>: 删除名为name的分支
  • git branch -D <name>: 强行删除名为name的分支,用 -d 删除的话,如果name分支没有被合并就无法删除,那么就要用 -D
  • git branch <name>: 创建一个名为name的分支

log 命令

  • git log: 查看提交的日志信息(按q退出)
  • git log --pretty=oneline: 查看只有一行的简略日志信息
  • git log --graph: 查看日志信息的图示信息
  • git log --graph --pretty=oneline --abbrev-commit: 查看日志信息的图示信息,且只有一行的简略日志信息,且commit id只显示前几位

remote 命令

  • git remote: 查看远程仓库信息,远程库默认名字是 origin
  • git remote -v: 查看更详细的远程仓库信息
  • git remote add origin git@server-name:path/repo-name.git: 关联一个远程库,或者说添加一个远程库(一个本地仓库可以有多个远程库),在这里添加的远程仓库名为 origin
  • git remote remove origin: 删除已添加的远程仓库地址,这里删除的是名为 origin 的远程仓库
  • git remote set-url origin https://xxxxx.xxx/repo-name.git: 更改远程仓库 origin 的url,比如说github项目地址改了,或者运维配置的gitlab域名变更了,可以用这个修改
  • git remote rename origin old-origin: 重命名远程仓库,将远程仓库 origin 重命名为 old-origin

diff 命令

  • git diff <filename>: 不带参数就是比较工作区和暂存区中filename文件的不同
  • git diff --cached <filename>: 比较暂存区和版本库中filename文件的不同
  • git diff HEAD <filename>: 比较工作区和版本库中filename文件的不同

rev-parse 命令

  • git rev-parse HEAD: 获取当前分支的commit id
  • git rev-parse --short HEAD: 获取当前分支的commit id的前几位
  • git rev-parse --abbrev-ref HEAD: 获取当前分支名
  • git rev-parse --symbolic --branches: 获取所有分支的名字
  • git rev-parse --show-toplevel: 获取当前git仓库的根目录的绝对路径
  • git rev-parse --git-dir: 获取当前git仓库的 .git 目录
  • git rev-parse --show-prefix: 获取当前目录相对于git仓库根目录的相对路径
  • git rev-parse branchName: 获取本地分支 branchName 的commit id
  • git rev-parse origin/branchName: 获取远程分支 branchName 的commit id
  • git rev-parse origin/branchName^{commit}: 和上面一样,获取远程分支 branchName 的commit id

其他

1. 首次关联本地仓库到远端空仓库

  1. git remote add origin git@server-name:path/repo-name.git: 关联一个远程库
  2. git push -u origin master: 第一次将本地的repository的master分支推送到远程,以后再push就不用加 -u

2. push时其他人已经push了新的提交,被reject

如果多个人同时关联了远程库(多人协作时),在push的时候,发现有人已经向remote推送了他的提交,而你对同样的文件做了修改,那么在push的时候就会报错rejected推送失败,因为有冲突,所以这时候应该git pull拉取最新的提交并(自动地)在本地合并,产生冲突,然后本地解决冲突之后再add、commit,再push

3. 在本地新建一个分支再push到远端

  1. git checkout -b newBranch
  2. git push origin newBranch

4. 对于已经添加到的.gitignore文件,仍然会被trace到的解决方法

git rm --cached dir_name/file_name

原因是添加了 .gitignore 忽略这些路径后, 由于这个路径是已经增加到过仓库管理中,所以尽管已经在 ignore 列表里,依然 会被 git trace 到每个文件的变化。这时只需删除已缓存的trace就可以了

首先,什么是软绑定?

所谓软绑定,是和硬绑定相对应的一个词,在详细解释软绑定之前,我们先来看看硬绑定。在JavaScript中,this的绑定是动态的,在函数被调用的时候绑定,它指向什么完全取决于函数在哪里调用,情况比较复杂,光是绑定规则就有默认绑定、隐式绑定、显式绑定、new绑定等,而硬绑定是显式绑定中的一种,通常情况下是通过调用函数的apply()call()或者ES5里提供的bind()方法来实现硬绑定的。

硬绑定有什么问题,为什么需要软绑定

上述三个方法好是好,可以按照自己的想法将函数的this强制绑定到指定的对象上(除了使用new绑定可以改变硬绑定外),但是硬绑定存在一个问题,就是会降低函数的灵活性,并且在硬绑定之后无法再使用隐式绑定或者显式绑定来修改this的指向。

在这种情况下,被称为软绑定的实现就出现了,也就是说,通过软绑定,我们希望this在默认情况下不再指向全局对象(非严格模式)或undefined(严格模式),而是指向两者之外的一个对象(这点和硬绑定的效果相同),但是同时又保留了隐式绑定和显式绑定在之后可以修改this指向的能力。

软绑定的具体实现

在这里,我用的是《你不知道的JavaScript 上》中的软绑定的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (!Function.prototype.softBind) {
Function.prototype.softBind = function (obj) {
const fn = this;
const args = Array.prototype.slice.call(arguments, 1);
const bound = function () {
return fn.apply(
!this || this === (window || global) ? obj : this,
args.concat.apply(args, arguments)
);
};
bound.prototype = Object.create(fn.prototype);
return bound;
};
}

我们先来看一下效果,之后再讨论它的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo() {
console.log('name: ' + this.name);
}

const obj1 = { name: 'obj1' },
obj2 = { name: 'obj2' },
obj3 = { name: 'obj3' };

const fooOBJ = foo.softBind(obj1);
fooOBJ(); //"name: obj1" 在这里软绑定生效了,成功修改了this的指向,将this绑定到了obj1上

obj2.foo = foo.softBind(obj1);
obj2.foo(); //"name: obj2" 在这里软绑定的this指向成功被隐式绑定修改了,绑定到了obj2上

fooOBJ.call(obj3); //"name: obj3" 在这里软绑定的this指向成功被硬绑定修改了,绑定到了obj3上

setTimeout(obj2.foo, 1000); //"name: obj1"
// 回调函数相当于一个隐式的传参,如果没有软绑定的话,这里将会应用默认绑定将this绑定到全局环境上,但有软绑定,这里this还是指向obj1

可以看到软绑定生效了。下面我们来具体看一下softBind()的实现。

在第一行,先通过判断,如果函数的原型上没有softBind()这个方法,则添加它,然后通过Array.prototype.slice.call(arguments,1)获取传入的外部参数,这里这样做其实为了函数柯里化,也就是说,允许在软绑定的时候,事先设置好一些参数,在调用函数的时候再传入另一些参数(关于函数柯里化大家可以去网上搜一下详细的讲解)最后返回一个bound函数形成一个闭包,这时候,在函数调用softBind()之后,得到的就是bound函数,例如上面的var fooOBJ=foo.softBind(obj1)

bound函数中,首先会判断调用软绑定之后的函数(如fooOBJ)的调用位置,或者说它的this的指向,如果!this(this指向undefined)或者this===(window||global)(this指向全局对象),那么就将函数的this绑定到传入softBind中的参数obj上。如果此时this不指向undefind或者全局对象,那么就将this绑定到现在正在指向的函数(即隐式绑定或显式绑定)。fn.apply的第二个参数则是运行foo所需要的参数,由上面的args(外部参数)和内部的arguments(内部参数)连接成,也就是上面说的柯里化。

其实在第一遍看这个函数时,也有点迷,有一些疑问,比如var fn=this这句,在foo通过foo.softBind()调用softBind的时候,fn到底指向谁呢?是指向foo还是指向softBind?我们可以写个demo测试,然后可以很清晰地看出fn指向什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const a = 2;
function foo() {}
foo.a = 3;
Function.prototype.softBind = function () {
const fn = this;
return function () {
console.log(fn.a);
};
};
Function.prototype.a = 4;
Function.prototype.softBind.a = 5;

foo.softBind()(); // 3
Function.prototype.softBind()(); // 4

可以看出,fn(或者说this)的指向还是遵循this的绑定规则的,softBind函数定义在Function的原型Function.prototype中,但是JavaScript中函数永远不会“属于”某个对象(不像其他语言如java中类里面定义的方法那样),只是对象内部引用了这个函数,所以在通过下面两种方式调用时,fn(或者说this)分别隐式绑定到了fooFunction.prototype,所以分别输出3和4。后面的fn.apply()也就相当于foo.apply()

  1. 通过元素的属性

    可以直接通过元素属性获取或操作特性,但是只有公认的特性(非自定义的特性),例如idtitlestylealignclassName等,注意,因为在ECMAScript中,class是保留字(在ES6中成了关键字),所以就不能再用class这个属性名来表示元素的CSS类名了,只能换成className

  2. 通过getAttribute()setAttribute()removeAttribute()

    也可以通过这三个DOM方法来操作DOM元素的特性,例如

    1
    2
    3
    4
    5
    6
    7
    const div = document.getElementById("my-div");
    div.getAttribute("id"); // 获取id
    div.getAttribute("title"); // 获取title
    div.getAttribute("class"); // 获取元素的CSS类名,因为是传参数给getAttribute函数,所以可以用class
    div.getAttribute("dat-my-attribute"); // 获取自定义特性
    div.setAttribute("id","anotherId"); // 设置id
    div.removeAttribute("title"); // 删除title

    从上面可以看出来,用这种方法,不仅可以获取到公认的特性,也可以获取自定义的特性。不过有两类特殊的特性,在通过属性获取和通过方法获取时获取到的却不一样,一类是style,通过属性访问获取到的是一个对象,通过getAttribute获取到的是CSS文本;另一类就是事件处理程序如onclick,通过属性获取,得到的是一个函数,而通过getAttribute获取得到的则是相应函数代码的字符串。

  3. 通过元素的attributes属性

    Element类型的DOM元素都有一个attributes属性,如div.attributes,这样获取到的是一个NamedNodeMap,是一个动态的集合,和数组类似,也有length属性、可以通过下标访问遍历等,通常用途就是遍历元素特性,里面存放的是多个Att节点,每个节点的nodeName就是特性名称,nodeValue就是特性的值。它有一些方法,如下:

    • getNamedItem(name):返回nodeName为name的节点
    • setNamedItem(node):向集合中插入一个Attr节点
    • removeNamedItem(name):删除集合中nodeName为name的节点
    • item(pos):返回位于数字pos位置的节点
      例如要获取id,有如下代码
    1
    2
    3
    const div = document.getElementById("my-div");
    div.attributes.getNamedItem("id").nodeValue;
    div.attributes["id"].nodeValue; //后两行代码都可以获取到id,下面为简写形式

    从上面可以看出,这种方式最麻烦,所以平时基本不用,一般在遍历元素的特性时才会用到。
    上面三种方式中,方式1是最常使用的,其次是2,最后才是第三种方式。

一个网页的有很多地方可以进行性能优化,比较常见的一种方式就是异步加载js脚本文件。在谈异步加载之前,先来看看浏览器加载js文件的原理。

浏览器加载 JavaScript 脚本,主要通过<script>元素完成。正常的网页加载流程是这样的。

  1. 浏览器一边下载 HTML 网页,一边开始解析。也就是说,不等到下载完,就开始解析。
  2. 解析过程中,浏览器发现<script>元素,就暂停解析,把网页渲染的控制权转交给 JavaScript 引擎。
  3. 如果<script>元素引用了外部脚本,就下载该脚本再执行,否则就直接执行代码。
  4. JavaScript 引擎执行完毕,控制权交还渲染引擎,恢复解析 HTML 网页。

加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。原因是 JavaScript 代码可以修改 DOM,所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。

上面所说的,就是我们平时最常见到的,将<script>标签放到<head>中的做法,这样的加载方式叫做同步加载,或者叫阻塞加载,因为在加载js脚本文件时,会阻塞浏览器解析HTML文档,等到下载并执行完毕之后,才会接着解析HTML文档。如果加载时间过长(比如下载时间太长),就会造成浏览器“假死”,页面一片空白。而且,放在<head>中同步加载的js文件中不能对DOM进行操作,否则会产生错误,因为这个时候HTML还没有进行解析,DOM还没有生成。由此看来,同步加载带来的体验往往并不好。

下面我们来看几种异步加载的方式。

  1. <script>标签放到<body>底部

    严格来说,这并不算是异步加载,但是这也是常见的通过改变js加载方式来提升页面性能的一种方式,所以也就放到这里来说。

    <script>放到<body>底部,解决上上面说到的几个问题,一是不会造成页面解析的阻塞,就算加载时间过长用户也可以看到页面而不是一片空白,而且这时候可以在脚本中操作DOM。

  2. defer属性

    通过给<script>标签设置defer属性,将脚本文件设置为延迟加载,当浏览器遇到带有defer属性的<script>标签时,会再开启一个线程去下载js文件,同时继续解析HTML文档,等等HTML全部解析完毕DOM加载完成之后,再去执行加载好的js文件。

    这种方式只适用于引用外部js文件的<script>标签,可以保证多个js文件的执行顺序就是它们在页面中出现的顺序,但是要注意,添加defer属性的js文件不应该使用document.write()方法。

  3. async属性

    async属性和defer属性类似,也是会开启一个线程去下载js文件,但和defer不同的时,它会在下载完成后立刻执行,而不是会等到DOM加载完成之后再执行,所以还是有可能会造成阻塞。

    同样的,async也是只适用于外部js文件,也不能在js中使用document.write()方法,但是对多个带有async的js文件,它不能像defer那样保证按顺序执行,它是哪个js文件先下载完就先执行哪个。

  4. 动态创建<script>标签

    可以通过动态地创建<script>标签来实现异步加载js文件,例如下面代码:

    1
    2
    3
    4
    5
    6
    7
    8
    (function () {
    const scriptEle = document.createElement('script');
    scriptEle.type = 'text/javasctipt';
    scriptEle.async = true;
    scriptEle.src = 'http://xxx.js';
    const ele = document.getElementsByTagName('head')[0];
    ele.insertBefore(scriptEle, ele.firstChild);
    })();

    或者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    (function () {
    const asyncLoad = function () {
    const scriptEle = document.createElement('script');
    scriptEle.type = 'text/javascript';
    scriptEle.async = true;
    scriptEle.src = 'http://xxx.js';
    const ele = document.getElementsByTagName('head')[0];
    ele.insertBefore(scriptEle, ele.firstChild);
    };

    window.addEventListener('load', asyncLoad);
    })();

    上面两种方法中,第一种方式执行完之前会阻止onload事件的触发,因为添加了async属性的异步脚本一定会在页面的load事件前执行,而现在很多页面的代码都在onload时还执行额外的渲染工作,所以还是会阻塞部分页面的初始化处理。第二种则不会阻止onload事件的触发。

    这里要简要说明一下window.DOMContentLoadedwindow.onload这两个事件的区别,前者是在DOM解析完毕之后触发,这时候DOM解析完毕,JavaScript可以获取到DOM引用,但是页面中的一些资源比如图片、视频等还没有加载完,作用同jQuery中的ready事件。后者则是页面完全加载完毕,包括各种资源。

说完了这几种常见的异步加载js脚本的方式,再来看最后一个问题,什么时候用defer,什么时候用async呢?一般来说,两者之间的选择则是看脚本之间是否有依赖关系,有依赖的话应当要保证执行顺序,应当使用defer没有依赖的话使用async,同时使用的话defer失效。要注意的是两者都不应该使用document.write(),这个导致整个页面被清除重写。

下面一幅图表明了同步加载以及deferasync加载时的区别,其中绿色线代表 HTML 解析,蓝色线代表网络读取js脚本,红色线代表js脚本执行时间:

js-load

0%