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%