JavaScript深入之词法作用域和动态作用域

作用域

作用域是指程序源代码中定义变量的区域。

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

JavaScript 采用词法作用域 (lexical scoping),也就是静态作用域。

静态作用域与动态作用域

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

让我们认真看个例子就能明白之间的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var value = 1;

function foo() {
console.log(value);
}

function bar() {
var value = 2;
foo();
}

bar();

// 结果是 ???

假设 JavaScript 采用静态作用域,让我们分析下执行过程:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

假设 JavaScript 采用动态作用域,让我们分析下执行过程:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

前面我们已经说了,JavaScript 采用的是静态作用域,所以这个例子的结果是 1。

动态作用域

也许你会好奇什么语言是动态作用域?

bash 就是动态作用域,不信的话,把下面的脚本存成例如 scope.bash,然后进入相应的目录,用命令行执行 bash ./scope.bash,看看打印的值是多少。

1
2
3
4
5
6
7
8
9
value=1
function foo () {
echo $value;
}
function bar () {
local value=2;
foo;
}
bar

这个文件也可以在 Github 博客仓库中找到。

思考题

最后,让我们看一个《JavaScript 权威指南》中的例子:

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

猜猜两段代码各自的执行结果是多少?

这里直接告诉大家结果,两段代码都会打印:local scope

原因也很简单,因为 JavaScript 采用的是词法作用域,函数的作用域基于函数创建的位置。

而引用《JavaScript 权威指南》的回答就是:

JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

但是在这里真正想让大家思考的是:

虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

如果要回答这个问题,就要牵涉到很多的内容,词法作用域只是其中的一小部分,让我们期待下一篇文章————《JavaScript 深入之执行上下文栈》。

JavaScript深入之闭包

定义

MDN 对闭包的定义为:

闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

举个例子:

1
2
3
4
5
6
7
var a = 1;

function foo() {
console.log(a);
}

foo();

foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。

那么,函数 foo + foo 函数访问的自由变量 a 不就是构成了一个闭包嘛……

还真是这样的!

所以在《JavaScript 权威指南》中就讲到:从技术的角度讲,所有的 JavaScript 函数都是闭包。

咦,这怎么跟我们平时看到的讲到的闭包不一样呢!?

别着急,这是理论上的闭包,其实还有一个实践角度上的闭包,让我们看看汤姆大叔翻译的关于闭包的文章中的定义:

ECMAScript 中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2. 在代码中引用了自由变量

接下来就来讲讲实践上的闭包。

分析

让我们先写个例子,例子依然是来自《JavaScript 权威指南》,稍微做点改动:

1
2
3
4
5
6
7
8
9
10
11
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}

var foo = checkscope();
foo();

首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况。

另一个与这段代码相似的例子,在《JavaScript 深入之执行上下文》中有着非常详细的分析。如果看不懂以下的执行过程,建议先阅读这篇文章。

这里直接给出简要的执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
  2. 全局执行上下文初始化
  3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
  4. checkscope 执行上下文初始化,创建变量对象、作用域链、this 等
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  6. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
  7. f 执行上下文初始化,创建变量对象、作用域链、this 等
  8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

了解到这个过程,我们应该思考一个问题,那就是:

当 f 函数执行的时候,checkscope 函数上下文已经被销毁了啊 (即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

以上的代码,要是转换成 PHP,就会报错,因为在 PHP 中,f 函数只能读取到自己作用域和全局作用域里的值,所以读不到 checkscope 下的 scope 值。(这段我问的 PHP 同事……)

然而 JavaScript 却是可以的!

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

1
2
3
fContext = {
Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

对的,就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

所以,让我们再看一遍实践角度上闭包的定义:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

在这里再补充一个《JavaScript 权威指南》英文原版对闭包的定义:

This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.

闭包在计算机科学中也只是一个普通的概念,大家不要去想得太复杂。

必刷题

接下来,看这道刷题必刷,面试必考的闭包题:

1
2
3
4
5
6
7
8
9
10
11
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}

data[0]();
data[1]();
data[2]();

答案是都是 3,让我们分析一下原因:

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

1
2
3
4
5
6
globalContext = {
VO: {
data: [...],
i: 3
}
}

当执行 data[0] 函数的时候,data[0] 函数的作用域链为:

1
2
3
data[0]Context = {
Scope: [AO, globalContext.VO]
}

data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,i 为 3,所以打印的结果就是 3。

data[1] 和 data[2] 是一样的道理。

所以让我们改成闭包看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}

data[0]();
data[1]();
data[2]();

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

1
2
3
4
5
6
globalContext = {
VO: {
data: [...],
i: 3
}
}

跟没改之前一模一样。

当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变:

1
2
3
data[0]Context = {
Scope: [AO, 匿名函数Context.AO globalContext.VO]
}

匿名函数执行上下文的 AO 为:

1
2
3
4
5
6
7
8
9
匿名函数Context = {
AO: {
arguments: {
0: 0,
length: 1
},
i: 0
}
}

data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会找 i 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值 (值为 3),所以打印的结果就是 0。

data[1] 和 data[2] 是一样的道理。

Git分支合并详解

“合并前文件还在的,合并后就不见了”、“我遇到 Git 合并的 bug 了” 是两句经常听到的话,但真的是 Git 的 bug 么?或许只是你的预期不对。本文通过讲解三向合并和 Git 的合并策略,step by step 介绍 Git 是怎么做一个合并的,让大家对 Git 的合并结果有一个准确的预期,并且避免发生合并事故。

**

故事时间

在开始正文之前,先来听一下这个故事。
如下图,小明从节点 A 拉了一条 dev 分支出来,在节点 B 中新增了一个文件 http.js,并且合并到 master 分支,合并节点为 E。这个时候发现会引起线上 bug,赶紧撤回这个合并,新增一个 revert 节点 E’。过了几天小明继续在 dev 分支上面开发新增了一个文件 main.js,并在这个文件中 import 了 http.js 里面的逻辑,在 dev 分支上面一切运行正常。可当他将此时的 dev 分支合并到 master 时候却发现,http.js 文件不见了,导致 main.js 里面的逻辑运行报错了。但这次合并并没有任何冲突。他又得重新做了一下 revert,并且迷茫的怀疑是 Git 的 bug。

两句经常听到的话:
—— ”合并前文件还在的,合并后就不见了“
—— ”我遇到 Git 的 bug 了“
相信很多同学或多或少在不熟悉 Git 合并策略的时候都会发生过类似上面的事情,明明在合并前文件还在的,为什么合并后文件就不在了么?一度还怀疑是 Git 的 bug。这篇文章的目的就是想跟大家讲清楚 Git 是怎么去合并分支的,以及一些底层的基础概念,从而避免发生如故事中的问题,并对 Git 的合并结果有一个准确的预期。

**

如何合并两个文件

在看怎么合并两个分支之前,我们先来看一下怎么合并两个文件,因为两个文件的合并是两个分支合并的基础。
大家应该都听说过“三向合并”这个词,不知道大家有没有思考过为什么两个文件的合并需要三向合并,只有二向是否可以自动完成合并。如下图





很明显答案是不能,如上图的例子,Git 没法确定这一行代码是我修改的,还是对方修改的,或者之前就没有这行代码,是我们俩同时新增的。此时 Git 没办法帮我们做自动合并。


所以我们需要三向合并,所谓三向合并,就是找到两个文件的一个合并 base,如下图,这样子 Git 就可以很清楚的知道说,对方修改了这一行代码,而我们没有修改,自动帮我们合并这两个文件为 Print(“hello”)。







接下来我们了解一下什么是冲突?冲突简单的来说就是三向合并中的三方都互不相同,即参考合并 base,我们的分支和别人的分支都对同个地方做了修改。



**

Git 的合并策略


了解完怎么合并两个文件之后,我们来看一个使用 git merge 来做分支合并。如上图,将 master 分支合并到 feature 分支上,会新增一个 commit 节点来记录这次合并。
Git 会有很多合并策略,其中常见的是 Fast-forward、Recursive 、Ours、Theirs、Octopus。下面分别介绍不同合并策略的原理以及应用场景。默认 Git 会帮你自动挑选合适的合并策略,如果你需要强制指定,使用git merge -s <策略名字>
了解 Git 合并策略的原理可以让你对 Git 的合并结果有一个准确的预期。

####

Fast-forward


Fast-forward 是最简单的一种合并策略,如上图中将 some feature 分支合并进 master 分支,Git 只需要将 master 分支的指向移动到最后一个 commit 节点上。

Fast-forward 是 Git 在合并两个没有分叉的分支时的默认行为,如果不想要这种表现,想明确记录下每次的合并,可以使用git merge –no-ff。

####

Recursive

Recursive 是 Git 分支合并策略中最重要也是最常用的策略,是 Git 在合并两个有分叉的分支时的默认行为。其算法可以简单描述为:递归寻找路径最短的唯一共同祖先节点,然后以其为 base 节点进行递归三向合并。说起来有点绕,下面通过例子来解释。
如下图这种简单的情况,圆圈里面的英文字母为当前 commit 的文件内容,当我们要合并中间两个节点的时候,找到他们的共同祖先节点(左边第一个),接着进行三向合并得到结果为 B。(因为合并的 base 是“A”,下图靠下的分支没有修改内容仍为“A”,下图靠上的分支修改成了“B”,所以合并结果为“B”)。





但现实情况总是复杂得多,会出现历史记录链互相交叉等情况,如下图:







当 Git 在寻找路径最短的共同祖先节点的时候,可以找到两个节点的,如果 Git 选用下图这一个节点,那么 Git 将无法自动的合并。因为根据三向合并,这里是是有冲突的,需要手动解决。(base 为“A“,合并的两个分支内容为”C“和”B“)







而如果 Git 选用的是下图这个节点作为合并的 base 时,根据三向合并,Git 就可以直接自动合并得出结果“C”。(base 为“B“,合并的两个分支内容为”C“和”B“)







作为人类,在这个例子里面我们很自然的就可以看出来合并的结果应该是“C”(如下图,节点 4、5 都已经是“B”了,节点 6 修改成“C”,所以合并的预期为“C”)







那怎么保证 Git 能够找到正确的合并 base 节点,尽可能的减少冲突呢?答案就是,Git 在寻找路径最短的共同祖先节点时,如果满足条件的祖先节点不唯一,那么 Git 会继续递归往下寻找直至唯一。还是以刚刚这个例子图解。
如下图所示,我们想要合并节点 5 和节点 6,Git 找到路径最短的祖先节点 2 和 3。







因为共同祖先节点不唯一,所以 Git 递归以节点 2 和节点 3 为我们要合并的节点,寻找他们的路径最短的共同祖先,找到唯一的节点 1。







接着 Git 以节点 1 为 base,对节点 2 和节点 3 做三向合并,得到一个临时节点,根据三向合并的结果,这个节点的内容为“B”。







再以这个临时节点为 base,对节点 5 和节点 6 做三向合并,得到合并节点 7,根据三向合并的结果,节点 7 的内容为“C”







至此 Git 完成递归合并,自动合并节点 5 和节点 6,结果为“C”,没有冲突。







Recursive 策略已经被大量的场景证明它是一个尽量减少冲突的合并策略,我们可以看到有趣的一点是,对于两个合并分支的中间节点(如上图节点 4,5),只参与了 base 的计算,而最终真正被三向合并拿来做合并的节点,只包括末端以及 base 节点。


需要注意 Git 只是使用这些策略尽量的去帮你减少冲突,如果冲突不可避免,那 Git 就会提示冲突,需要手工解决。(也就是真正意义上的冲突)。

####

Ours & Theirs

Ours 和 Theirs 这两种合并策略也是比较简单的,简单来说就是保留双方的历史记录,但完全忽略掉这一方的文件变更。如下图在 master 分支里面执行git merge -s ours dev,会产生蓝色的这一个合并节点,其内容跟其上一个节点(master 分支方向上的)完全一样,即 master 分支合并前后项目文件没有任何变动。







而如果使用 theirs 则完全相反,完全抛弃掉当前分支的文件内容,直接采用对方分支的文件内容。
这两种策略的一个使用场景是比如现在要实现同一功能,你同时尝试了两个方案,分别在分支是 dev1 和 dev2 上,最后经过测试你选用了 dev2 这个方案。但你不想丢弃 dev1 的这样一个尝试,希望把它合入主干方便后期查看,这个时候你就可以在 dev2 分支中执行git merge -s ours dev1。

Octopus

这种合并策略比较神奇,一般来说我们的合并节点都只有两个 parent(即合并两条分支),而这种合并策略可以做两个以上分支的合并,这也是 git merge 两个以上分支时的默认行为。比如在 dev1 分支上执行git merge dev2 dev3。



他的一个使用场景是在测试环境或预发布环境,你需要将多个开发分支修改的内容合并在一起,如果不用这个策略,你每次只能合并一个分支,这样就会导致大量的合并节点产生。而使用 Octopus 这种合并策略就可以用一个合并节点将他们全部合并进来。

**

Git rebase

git rebase 也是一种经常被用来做合并的方法,其与 git merge 的最大区别是,他会更改变更历史对应的 commit 节点。


如下图,当在 feature 分支中执行 rebase master 时,Git 会以 master 分支对应的 commit 节点为起点,新增两个全新的 commit 代替 feature 分支中的 commit 节点。其原因是新的 commit 指向的 parent 变了,所以对应的 SHA1 值也会改变,所以没办法复用原 feature 分支中的 commit。(这句话的理解需要这篇文章的基础知识)





对于合并时候要使用 git merge 还是 git rebase 的争论,我个人的看法是没有银弹,根据团队和项目习惯选择就可以。git rebase 可以给我们带来清晰的历史记录,git merge 可以保留真实的提交时间等信息,并且不容易出问题,处理冲突也比较方便。唯一有一点需要注意的是,不要对已经处于远端的多人共用分支做 rebase 操作。
我个人的一个习惯是:对于本地的分支或者确定只有一个人使用的远端分支用 rebase,其余情况用 merge。


rebase 还有一个非常好用的东西叫 interactive 模式,使用方法是git rebase -i。可以实现压缩几个 commit,修改 commit 信息,抛弃某个 commit 等功能。比如说我要压缩下图 260a12a5、956e1d18,将他们与 9dae0027 合并为一个 commit,我只需将 260a12a5、956e1d18 前面的 pick 改成“s”,然后保存就可以了。





限于篇幅,git rebase -i 还有很多实用的功能暂不展开,感兴趣的同学可以自己研究一下。

**

总结


现在我们再来看一下文章开头的例子,我们就可以理解为什么最后一次 merge 会导致 http.js 文件不见了。根据 Git 的合并策略,在合并两个有分叉的分支(上图中的 D、E‘)时,Git 默认会选择 Recursive 策略。找到 D 和 E’的最短路径共同祖先节点 B,以 B 为 base,对 D,E‘做三向合并。B 中有 http.js,D 中有 http.js 和 main.js,E’中什么都没有。根据三向合并,B、D 中都有 http.js 且没有变更,E‘删除了 http.js,所以合并结果就是没有 http.js,没有冲突,所以 http.js 文件不见了。


这个例子理解原理之后解决方法有很多,这里简单带过两个方法:1. revert 节点 E’之后,此时的 dev 分支要抛弃删除掉,重新从 E’节点拉出分支继续工作,而不是在原 dev 分支上继续开发节点 D;2. 在节点 D 合并回 E’节点时,先 revert 一下 E‘节点生成 E’‘(即 revert 的 revert),再将节点 D 合并进来。


Git 有很多种分支合并策略,本文介绍了 Fast-forward、Recursive、Ours/Theirs、Octopus 合并策略以及三向合并。掌握这些合并策略以及他们的使用场景可以让你避免发生一些合并问题,并对合并结果有一个准确的预期。
希望这篇文章对大家有用,感兴趣的同学可以逛一逛我的博客www.lzane.com 或看看我的其他文章。


参考


原文 https://www.jiqizhixin.com/articles/2020-05-28-8

web性能优化的15条实用技巧

javascript 在浏览器中运行的性能,可以认为是开发者所面临的最严重的可用性问题。这个问题因为 javascript 的阻塞性而变得复杂,事实上,多数浏览器使用单一进程来处理用户界面和 js 脚本执行,所以同一时刻只能做一件事。js 执行过程耗时越久,浏览器等待响应的时间越长。

加载和执行

一. 提高加载性能

1.IE8,FF,3.5,Safari 4 和 Chrome 都允许并行下载 js 文件,当 script 下载资源时不会阻塞其他 script 的下载。但是 js 下载仍然会阻塞其他资源的下载,如图片。尽管脚本下载不会互相影响,但页面仍然必须等待所有 js 代码下载并执行完才能继续。因此仍然存在脚本阻塞问题. 推荐将所有 js 文件放在 body 标签底部以减少对整个页面的影响。

2. 减少页面外链脚本文件的数量将会提高页面性能:

http 请求会带来额外的开销,因此下载单个 300k 的文件将比下载 10 个 30k 的文件效率更高。

3. 动态脚本加载技术:

无论何时启动下载,文件的下载和执行都不会阻塞页面其他进程。

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
function laodScript(url, callback) {
var script = document.createElement("script");
script.type = "text/javascript";

if (script.readyState) {
script.onreadystatechange = function () {
if (script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
callback();
}
};
} else {
script.onload = function () {
callback();
};
}
script.src = url;
document.getElementsByTagName("head")[0].appendChild(script);
}

// 使用
loadScript("./a.js", function () {
loadScript("./b.js", function () {
loadScript("./c.js", function () {
console.log("加载完成");
});
});
});
  1. 无阻塞加载类库——LABjs, 使用方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script src="lab.js"></script>
// 链式调用时文件逐个下载,.wait()用来指定文件下载并执行完毕后所调用的函数
$LAB.script('./a.js')
.script('./b.js')
.wait(function(){
App.init();
})

// 为了保证执行顺序,可以这么做,此时a必定在b前执行
$LAB.script('./a.js').wait()
.script('./b.js')
.wait(function(){
App.init();
})

二. 数据存取与 JS 性能

1. 在 js 中,数据存储的位置会对代码整体性能产生重大影响。数据存储共有 4 种方式:字面量,变量,数组项,对象成员。他们有着各自的性能特点。

2. 访问字面量和局部变量的速度最快,相反,访问数组和对象相对较慢

3. 由于局部变量存在于作用域链的起始位置,因此访问局部变量的比访问跨域作用变量更快

4. 嵌套的对象成员会明显影响性能,应尽量避免

5. 属性和方法在原型链位置越深,访问他的速度越慢

6. 通常我们可以把需要多次使用的对象成员,数组元素,跨域变量保存在局部变量中来改善 js 性能

三. DOM 编程

1. 访问 DOM 会影响浏览器性能,修改 DOM 则更耗费性能,因为他会导致浏览器重新计算页面的几何变化。**< 通常的做法是减少访问 DOM 的次数,把运算尽量留在 JS 这一端。**

注:如过在一个对性能要求比较高的操作中更新一段 HTML,推荐使用 innerHTML,因为它在绝大多数浏览器中运行的都很快。但对于大多数日常操作而言,并没有太大区别,所以你更应该根据可读性,稳定性,团队习惯,代码风格来综合决定使用 innerHTML 还是 createElement()

  1. HTML 集合优化

HTML 集合包含了 DOM 节点引用的类数组对象,一直与文档保持连接,每次你需要最新的信息时,都会重复执行查询操作,哪怕只是获取集合里元素的个数。

① 优化一——集合转数组 collToArr

1
2
3
4
5
6
function collToArr(coll) {
for (var i = 0, a = [], len = coll.length; i < len; i++) {
a.push(coll[i]);
}
return a;
}

② 缓存集合 length

③ 访问集合元素时使用局部变量(即将重复的集合访问缓存到局部变量中,用局部变量来操作)

  1. 遍历 DOM

① 使用只返回元素节点的 API 遍历 DOM, 因为这些 API 的执行效率比自己实现的效率更高:

| 属性名 | 被替代属性 |
| children | childNodes |
| childElementCount | childNodes.length |
| firstElementChild | firstChild |
| lastElementChild | lastChild |
| nextElementSibling | nextSibling |
| previousElementSibling | previousSibling |

② 选择器 API——querySelectorAll()

querySelectorAll() 方法使用 css 选择器作为参数并返回一个 NodeList——包含着匹配节点的类数组对象,该方法不会返回 HTML 集合,因此返回的节点不会对应实时文档结构,着也避免了 HTML 集合引起的性能问题。

1
let arr = document.querySelectorAll("div.warning, div.notice > p");

4. 重绘和重排

浏览器在下载完页面的所有组件——html,js,css, 图片等之后,会解析并生成两个内部数据结构—— DOM 树,渲染树. 一旦 DOM 树和渲染树构建完成,浏览器就开始绘制页面元素(paint).

① 重排发生的条件:

添加或删除可见的 DOM 元素位置变化 元素尺寸改变 内容改变 页面渲染器初始化 浏览器窗口尺寸变化 出现滚动条时会触发整个页面的重排

重排必定重绘

5. 渲染树变化的排列和刷新

大多数浏览器通过队列化修改并批量执行来优化重排过程,然而获取布局信息的操作会导致队列强制刷新。

`offsetTop,offsetWidth...``scrollTop,scrollHeight...``clientTop,clientHeight...``getComputedStyle()`

一些优化建议:将设置样式的操作和获取样式的操作分开:

1
2
3
4
5
6
// 设置样式
body.style.color = "red";
body.style.fontSize = "24px";
// 读取样式
let color = body.style.color;
let fontSize = body.style.fontSize;

另外,获取计算属性的兼容写法:

1
2
3
4
function getComputedStyle(el){
var computed = (document.body.currentStyle ? el.currentStyle : document.defaultView.getComputedStyle(el,'');
return computed
}

6. 最小化重绘和重排

①. 批量改变样式

1
2
/* 使用cssText */
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 20px';

②. 批量修改 dom 的优化方案——使元素脱离文档流 - 对其应用多重改变 - 把元素带回文档

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
function appendDataToEl(option){
var targetEl = option.target || document.body,
createEl,
data = option.data || [];

var targetEl_display = targetEl.style.display;
targetEl.style.display = 'none';


var fragment = document.createDocumentFragment();

for(var i=0, max = data.length; i< max; i++){
createEl = document.createElement(option.createEl);
for(var item in data[i]){
if(item.toString() === 'text'){
createEl.appendChild(document.createTextNode(data[i][item]));
continue;
}
if(item.toString() === 'html'){
createEl.innerHTML = item,data[i][item];
continue;
}
createEl.setAttribute(item,data[i][item]);
}

fragment.appendChild(createEl);
}

targetEl.appendChild(fragment);

targetEl.style.display = targetEl_display;
}

// 使用
var wrap = document.querySelectorAll('.wrap')[0];
var data = [
{name: 'xujaing',text: '选景', title: 'xuanfij'},
{name: 'xujaing',text: '选景', title: 'xuanfij'},
{name: 'xujaing',text: '选景', title: 'xuanfij'}
];

appendDataToEl({
target: wrap,
createEl: 'div',
data: data
});

上面的优化方法使用了文档片段:  当我们把文档片段插入到节点中时,实际上被添加的只是该片段的子节点,而不是片段本身。可以使得 dom 操作更有效率。

③. 缓存布局信息

1
2
3
4
5
6
7
//缓存布局信息
let current = el.offsetLeft;
current++;
el.style.left = current + "px";
if (current > 300) {
stop();
}

④. 慎用: hover

如果有大量元素使用: hover, 那么会降低相应速度,CPU 升高

⑤. 使用事件委托(通过事件冒泡实现)来减少事件 处理器的数量,减少内存和处理时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function delegation(e, selector, callback) {
e = e || window.event;
var target = e.target || e.srcElement;

if (
target.nodeName !== selector ||
target.className !== selector ||
target.id !== selector
) {
return;
}

if (typeof e.preventDefault === "function") {
e.preventDefault();
e.stopPropagation();
} else {
e.returnValue = false;
e.cancelBubble = true;
}

callback();
}

四. 算法和流程控制

1. 循环中减少属性查找并反转 (可以提升 50%-60% 的性能)

1
2
3
4
5
6
7
8
9
// for 循环
for(var i=item.length; i--){
process(item[i]);
}
// while循环
var j = item.length;
while(j--){
process(item[i]);
}

2. 使用 Duff 装置来优化循环(该方法在后面的文章中会详细介绍)

3. 基于函数的迭代(比基于循环的迭代慢)

1
2
3
items.forEach(function(value,index,array){
process(value);
})

4. 通常情况下 switch 总比 if-else 快,但是不是最佳方案

五. 字符串和正则表达式

1. 除了 IE 外,其他浏览器会尝试为表达式左侧的字符串分配更多的内存,然后简单的将第二个字符串拷贝到他的末尾,如果在一个循环中,基础字符串位于最左侧,就可以避免重复拷贝一个逐渐变大的基础字符串。2. 使用[\s\S]来匹配任意字符串 3. 去除尾部空白的常用做法:

1
2
3
4
5
if (!String.prototype.trim) {
String.prototype.trim = function () {
return this.replace(/^\s+/, "").replace(/\s\s*$/, "");
};
}

六. 快速响应的用户界面

1. 浏览器的 UI 线程:用于执行 javascript 和更新用户界面的进程。

2. 在 windows 系统中定时器分辨率为 15 毫秒,因此设置小于 15 毫秒将会使 IE 锁定,延时的最小值建议为 25ms.

3. 用延时数组分割耗时任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function multistep(steps, args, callback) {
var tasks = steps.concat();

setTimeout(function () {
var task = tasks.shift();
task.apply(null, args || []);

if (tasks.length > 0) {
setTimeout(arguments.callee, 25);
} else {
callback();
}
}, 25);
}

4. 记录代码运行时间批处理任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function timeProcessArray(items, process, callback) {
var todo = item.concat();

setTimeout(function () {
var start = +new Date();

do {
process(todo.shift());
} while (todo.length > 0 && +new Date() - start < 50);

if (todo.length > 0) {
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);
}

5. 使用 Web Worker:它引入了一个接口,能使代码运行且不占用浏览器 UI 线程的时间。一个 Worker 由如下部分组成:

① 一个 navigator 对象,包括 appName,appVersion,user Agent 和 platform.

② 一个 location 对象,只读。

③ 一个 self 对象,指向全局 worker 对象

④ 一个 importScripts() 方法,用来加载 worker 所用到的外部 js 文件

⑤ 所有的 ECMAScript 对象。如 object,Array,Date 等

⑥ XMLHttpRequest 构造器

⑦ setTimeout(),setInterval()

⑧ 一个 close() 方法,它能立刻停止 worker 运行

应用场景

  1. 编码 / 解码大字符串

2.  复杂数学运算(包括图像,视屏处理)

3.  大数组排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

var worker = new Worker('code.js');

worker.onmessage = function(event){
console.log(event.data);
}

worker.postMessage('hello');




importScripts('a.js','b.js');

self.onmessage = function(event){
self.postMessage('hello' + event.data);
}

七. ajax 优化

1. 向服务器请求数据的五种方式:

① XMLHttpRequest

② Dynamic script tag insertion 动态脚本注入

③ iframes

④ Comet(基于 http 长连接的服务端推送技术)

⑤ Multipart XHR(允许客户端只用一个 http 请求就可以从服务器向客户端传送多个资源)

2. 单纯向服务端发送数据(beacons 方法)——信标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 唯一缺点是接收到的响应类型是有限的
var url = '/req.php';
var params = ['step=2','time=123'];

(new Image()).src = url + '?' + params.join('&');

// 如果向监听服务端发送回的数据,可以在onload中实现
var beacon = new Image();
beacon.src = ...;
beacon.onload = function(){
...
}
beacon.onerror = function(){
...
}

3.ajax 性能的一些建议

缓存数据

1. 在服务端设置 Expires 头信息确保浏览器缓存多久响应(必须 GET 请求)

2. 客户端把获取到的信息缓存到本地,避免再次请求

八. 编程实践

1. 避免重复工作

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1.延迟加载
var a = (x, y) => {
if (x > 4) {
a = 0;
} else {
a = 1;
}
};
// 需要使用时调用
a();

// 2.条件预加载(适用于函数马上执行并频繁操作的场景)
var b = a > 0 ? "4" : "0";

2. 使用 Object/Array 字面量

3. 多用原生方法

九. 构建与部署高性能的 js 应用

1.js 的 http 压缩 当 web 浏览器请求一个资源时,它通常会发送一个 Accept-Encoding HTTP 头来告诉 Web 服务器它支持那种编码转换类型。这个信息主要用来压缩文档以获取更快的下载,从而改善用户体验。Accept-Encoding 可用的值包括:gzip,compress,deflate,identity. 如果 Web 服务器在请求中看到这些信息头,他会选择最合适的编码方式,并通过 Content-Encoding HTTP 头通知 WEB 浏览器它的决定。

2. 使用 H5 离线缓存

3. 使用内容分发网络 CDN

4. 对页面进行性能分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 检测代码运行时间
var Timer = {
_data: {},
start: function (key) {
Timer._data[key] = new Date();
},
stop: function (key) {
var time = Timer._data[key];
if (time) {
Timer._data[key] = new Date() - time;
}
console.log(Timer._data[key]);
return Timer._data[key];
},
};

十. 浏览器缓存

1. 添加 Expires 头

2. 使用 cache-control cache-ontrol 详解   浏览器缓存机制

十一. 压缩组件

1.web 客户端可以通过 http 请求中的 Accept-Encoding 头来表示对压缩的支持

1
2
3
4

Accept-Encoding: gzip

Content-Encoding: gzip

2. 压缩能将响应的数据量减少将近 70%,因此可考虑对 html, 脚本,样式,图片进行压缩

十二. 白屏现象的原因

浏览器(如 IE)在样式表没有完全下载完成之前不会呈现页面,导致页面白屏。如果样式表放在页面底部,那么浏览器会花费更长的时间下载样式表,因此会出现白屏,所以最好把样式表放在 head 内。白屏是浏览器对 “无样式闪烁” 的修缮。如果浏览器不采用 “白屏” 机制,将页面内容逐步显示(如 Firefox),则后加载的样式表将导致页面重绘重排,将会承担页面闪烁的风险。

十三. css 表达式使用一次性表达式 (但最好避免 css 表达式)

使用 css 表达式时执行函数重写自身

1
2
3
4
5
6
7
8
// css
p{
background-color: expression(altBgcolor(this))
}
// js
function altBgcolor(el){
el.style.backgroundColor = (new Date()).getHours() % 2 ? "#fff" : "#06c";
}

十四. 减少 DNS 查找

DNS 缓存和 TTL

1.DNS查找可以被缓存起来以提高性能:DNS信息会留在操作系统的DNS缓存中(Microsoft Windows上的“DNS Client服务”,之后对该主机名的请求无需进行过多的查找2.TTL(time to live): 该值告诉客户端可以对记录缓存多久。建议将TTL值设置为一天// 客户端收到DNS记录的平均TTL只有最大TTL值的一半因为DNS解析器返回的时间是其记录的TTL的剩余时间,对于给定的主机名,每次执行DNS查找时接收的TTL值都会变化3.通过使用Keep-Alive和较少的域名来减少DNS查找4.一般建议将页面组件分别放到至少2个,但不要超过4个主机名下复制代码

十五. 避免重定向

这块需要前后端共同配合,对页面路由进行统一规范。

最后

欢迎一起探索打造高性能的 web 应用,在公众号《趣谈前端》加入前端大家庭,和我们一起讨论吧!

汇总系列推荐

欢迎关注下方公众号,获取更多前端知识精粹和学习社群:

回复  学习路径,将获取笔者多年从业经验的前端学习路径的思维导图

回复 lodash,将获得本人亲自翻译的 lodash API 中文思维导图

趣谈前端

Vue、React、小程序、Node

前端算法 | 性能 | 架构 | 安全
https://mp.weixin.qq.com/s?__biz=MzU2Mzk1NzkwOA%3D%3D&chksm=fc531be6cb2492f04475bac0fecbd1a9f9781ca67f35bd30c320964b24a8cbc2ca3d7bbd5345&idx=1&mid=2247483933&scene=21&sn=c2729ef1fd4a28f4707bb923a5ffae79#wechat_redirect

一篇文章让你彻底掌握 Shell

由于 bash 是 Linux 标准默认的 shell 解释器,可以说 bash 是 shell 编程的基础。

本文主要介绍 bash 的语法,对于 linux 指令不做任何介绍

📦 本文已归档到:『blog
💻 本文的源码已归档到『 linux-tutorial

1
2
3
4
5
███████╗██╗  ██╗███████╗██╗     ██╗
██╔════╝██║ ██║██╔════╝██║ ██║
███████╗███████║█████╗ ██║ ██║
╚════██║██╔══██║██╔══╝ ██║ ██║
███████║██║ ██║███████╗███████╗███████╗

简介

什么是 shell

  • Shell 是一个用 C 语言编写的程序,它是用户使用 Linux 的桥梁。
  • Shell 既是一种命令语言,又是一种程序设计语言。
  • Shell 是指一种应用程序,这个应用程序提供了一个界面,用户通过这个界面访问 Linux 内核的服务。

Ken Thompson 的 sh 是第一种 Unix Shell,Windows Explorer 是一个典型的图形界面 Shell。

什么是 shell 脚本

Shell 脚本(shell script),是一种为 shell 编写的脚本程序,一般文件后缀为 .sh

业界所说的 shell 通常都是指 shell 脚本,但 shell 和 shell script 是两个不同的概念。

Shell 环境

Shell 编程跟 java、php 编程一样,只要有一个能编写代码的文本编辑器和一个能解释执行的脚本解释器就可以了。

Shell 的解释器种类众多,常见的有:

  • sh - 即 Bourne Shell。sh 是 Unix 标准默认的 shell。
  • bash - 即 Bourne Again Shell。bash 是 Linux 标准默认的 shell。
  • fish - 智能和用户友好的命令行 shell。
  • xiki - 使 shell 控制台更友好,更强大。
  • zsh - 功能强大的 shell 与脚本语言。

指定脚本解释器

在 shell 脚本,#! 告诉系统其后路径所指定的程序即是解释此脚本文件的 Shell 解释器。#! 被称作shebang(也称为 Hashbang )

所以,你应该会在 shell 中,见到诸如以下的注释:

  • 指定 sh 解释器
1
#!/bin/sh
  • 指定 bash 解释器
1
#!/bin/bash

注意

上面的指定解释器的方式是比较常见的,但有时候,你可能也会看到下面的方式:

1
#!/usr/bin/env bash

这样做的好处是,系统会自动在 PATH 环境变量中查找你指定的程序(本例中的bash)。相比第一种写法,你应该尽量用这种写法,因为程序的路径是不确定的。这样写还有一个好处,操作系统的PATH变量有可能被配置为指向程序的另一个版本。比如,安装完新版本的bash,我们可能将其路径添加到PATH中,来“隐藏”老版本。如果直接用#!/bin/bash,那么系统会选择老版本的bash来执行脚本,如果用#!/usr/bin/env bash,则会使用新版本。

模式

shell 有交互和非交互两种模式。

交互模式

简单来说,你可以将 shell 的交互模式理解为执行命令行。

看到形如下面的东西,说明 shell 处于交互模式下:

1
user@host:~$

接着,便可以输入一系列 Linux 命令,比如 lsgrepcdmkdirrm 等等。

非交互模式

简单来说,你可以将 shell 的非交互模式理解为执行 shell 脚本。

在非交互模式下,shell 从文件或者管道中读取命令并执行。

当 shell 解释器执行完文件中的最后一个命令,shell 进程终止,并回到父进程。

可以使用下面的命令让 shell 以非交互模式运行:

1
2
3
4
sh /path/to/script.sh
bash /path/to/script.sh
source /path/to/script.sh
./path/to/script.sh

上面的例子中,script.sh是一个包含 shell 解释器可以识别并执行的命令的普通文本文件,shbash是 shell 解释器程序。你可以使用任何喜欢的编辑器创建script.sh(vim,nano,Sublime Text, Atom 等等)。

其中,source /path/to/script.sh./path/to/script.sh 是等价的。

除此之外,你还可以通过chmod命令给文件添加可执行的权限,来直接执行脚本文件:

1
2
chmod +x /path/to/script.sh #使脚本具有执行权限
/path/to/test.sh

这种方式要求脚本文件的第一行必须指明运行该脚本的程序,比如:

💻 『示例源码』

1
2
#!/usr/bin/env bash
echo "Hello, world!"

上面的例子中,我们使用了一个很有用的命令echo来输出字符串到屏幕上。

基本语法

解释器

前面虽然两次提到了#! ,但是本着重要的事情说三遍的精神,这里再强调一遍:

在 shell 脚本,#! 告诉系统其后路径所指定的程序即是解释此脚本文件的 Shell 解释器。#! 被称作shebang(也称为 Hashbang )

#! 决定了脚本可以像一个独立的可执行文件一样执行,而不用在终端之前输入sh, bash, python, php等。

1
2
3
# 以下两种方式都可以指定 shell 解释器为 bash,第二种方式更好
#!/bin/bash
#!/usr/bin/env bash

注释

注释可以说明你的代码是什么作用,以及为什么这样写。

shell 语法中,注释是特殊的语句,会被 shell 解释器忽略。

  • 单行注释 - 以 # 开头,到行尾结束。
  • 多行注释 - 以 :<<EOF 开头,到 EOF 结束。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#--------------------------------------------
# shell 注释示例
# author:zp
#--------------------------------------------

# echo '这是单行注释'

########## 这是分割线 ##########

:<<EOF
echo '这是多行注释'
echo '这是多行注释'
echo '这是多行注释'
EOF

echo

echo 用于字符串的输出。

输出普通字符串:

1
2
echo "hello, world"
# Output: hello, world

输出含变量的字符串:

1
2
echo "hello, \"zp\""
# Output: hello, "zp"

输出含变量的字符串:

1
2
3
name=zp
echo "hello, \"${name}\""
# Output: hello, "zp"

输出含换行符的字符串:

1
2
3
4
5
6
7
8
# 输出含换行符的字符串
echo "YES\nNO"
# Output: YES\nNO

echo -e "YES\nNO" # -e 开启转义
# Output:
# YES
# NO

输出含不换行符的字符串:

1
2
3
4
5
6
7
8
9
10
echo "YES"
echo "NO"
# Output:
# YES
# NO

echo -e "YES\c" # -e 开启转义 \c 不换行
echo "NO"
# Output:
# YESNO

输出重定向至文件

1
echo "test" > test.txt

输出执行结果

1
2
echo `pwd`
# Output:(当前目录路径)

💻 『示例源码』

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
#!/usr/bin/env bash

# 输出普通字符串
echo "hello, world"
# Output: hello, world

# 输出含变量的字符串
echo "hello, \"zp\""
# Output: hello, "zp"

# 输出含变量的字符串
name=zp
echo "hello, \"${name}\""
# Output: hello, "zp"

# 输出含换行符的字符串
echo "YES\nNO"
# Output: YES\nNO
echo -e "YES\nNO" # -e 开启转义
# Output:
# YES
# NO

# 输出含不换行符的字符串
echo "YES"
echo "NO"
# Output:
# YES
# NO

echo -e "YES\c" # -e 开启转义 \c 不换行
echo "NO"
# Output:
# YESNO

# 输出内容定向至文件
echo "test" > test.txt

# 输出执行结果
echo `pwd`
# Output:(当前目录路径)

printf

printf 用于格式化输出字符串。

默认,printf 不会像 echo 一样自动添加换行符,如果需要换行可以手动添加 \n

💻 『示例源码』

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
# 单引号
printf '%d %s\n' 1 "abc"
# Output:1 abc

# 双引号
printf "%d %s\n" 1 "abc"
# Output:1 abc

# 无引号
printf %s abcdef
# Output: abcdef(并不会换行)

# 格式只指定了一个参数,但多出的参数仍然会按照该格式输出
printf "%s\n" abc def
# Output:
# abc
# def

printf "%s %s %s\n" a b c d e f g h i j
# Output:
# a b c
# d e f
# g h i
# j

# 如果没有参数,那么 %s 用 NULL 代替,%d 用 0 代替
printf "%s and %d \n"
# Output:
# and 0

# 格式化输出
printf "%-10s %-8s %-4s\n" 姓名 性别 体重kg
printf "%-10s %-8s %-4.2f\n" 郭靖 男 66.1234
printf "%-10s %-8s %-4.2f\n" 杨过 男 48.6543
printf "%-10s %-8s %-4.2f\n" 郭芙 女 47.9876
# Output:
# 姓名 性别 体重kg
# 郭靖 男 66.12
# 杨过 男 48.65
# 郭芙 女 47.99

printf 的转义符

序列 说明
\a 警告字符,通常为 ASCII 的 BEL 字符
\b 后退
\c 抑制(不显示)输出结果中任何结尾的换行字符(只在%b 格式指示符控制下的参数字符串中有效),而且,任何留在参数里的字符、任何接下来的参数以及任何留在格式字符串中的字符,都被忽略
\f 换页(formfeed)
\n 换行
\r 回车(Carriage return)
\t 水平制表符
\v 垂直制表符
\\ 一个字面上的反斜杠字符
\ddd 表示 1 到 3 位数八进制值的字符。仅在格式字符串中有效
\0ddd 表示 1 到 3 位的八进制值字符

变量

跟许多程序设计语言一样,你可以在 bash 中创建变量。

Bash 中没有数据类型,bash 中的变量可以保存一个数字、一个字符、一个字符串等等。同时无需提前声明变量,给变量赋值会直接创建变量。

变量命名原则

  • 命名只能使用英文字母,数字和下划线,首个字符不能以数字开头。
  • 中间不能有空格,可以使用下划线(_)。
  • 不能使用标点符号。
  • 不能使用 bash 里的关键字(可用 help 命令查看保留关键字)。

声明变量

访问变量的语法形式为:${var}$var

变量名外面的花括号是可选的,加不加都行,加花括号是为了帮助解释器识别变量的边界,所以推荐加花括号。

1
2
3
word="hello"
echo ${word}
# Output: hello

只读变量

使用 readonly 命令可以将变量定义为只读变量,只读变量的值不能被改变。

1
2
3
4
rword="hello"
echo ${rword}
readonly rword
# rword="bye" # 如果放开注释,执行时会报错

删除变量

使用 unset 命令可以删除变量。变量被删除后不能再次使用。unset 命令不能删除只读变量。

1
2
3
4
5
6
7
dword="hello"  # 声明变量
echo ${dword} # 输出变量值
# Output: hello

unset dword # 删除变量
echo ${dword}
# Output: (空)

变量类型

  • 局部变量 - 局部变量是仅在某个脚本内部有效的变量。它们不能被其他的程序和脚本访问。
  • 环境变量 - 环境变量是对当前 shell 会话内所有的程序或脚本都可见的变量。创建它们跟创建局部变量类似,但使用的是 export 关键字,shell 脚本也可以定义环境变量。

常见的环境变量:

变量 描述
$HOME 当前用户的用户目录
$PATH 用分号分隔的目录列表,shell 会到这些目录中查找命令
$PWD 当前工作目录
$RANDOM 0 到 32767 之间的整数
$UID 数值类型,当前用户的用户 ID
$PS1 主要系统输入提示符
$PS2 次要系统输入提示符

这里 有一张更全面的 Bash 环境变量列表。

💻 『示例源码』

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
#!/usr/bin/env bash

################### 声明变量 ###################
name="world"
echo "hello ${name}"
# Output: hello world

################### 输出变量 ###################
folder=$(pwd)
echo "current path: ${folder}"

################### 只读变量 ###################
rword="hello"
echo ${rword}
# Output: hello
readonly rword
# rword="bye" # 如果放开注释,执行时会报错

################### 删除变量 ###################
dword="hello" # 声明变量
echo ${dword} # 输出变量值
# Output: hello

unset dword # 删除变量
echo ${dword}
# Output: (空)

################### 系统变量 ###################
echo "UID:$UID"
echo LOGNAME:$LOGNAME
echo User:$USER
echo HOME:$HOME
echo PATH:$PATH
echo HOSTNAME:$HOSTNAME
echo SHELL:$SHELL
echo LANG:$LANG

################### 自定义变量 ###################
days=10
user="admin"
echo "$user logged in $days days age"
days=5
user="root"
echo "$user logged in $days days age"
# Output:
# admin logged in 10 days age
# root logged in 5 days age

################### 从变量读取列表 ###################
colors="Red Yellow Blue"
colors=$colors" White Black"

for color in $colors
do
echo " $color"
done

字符串

单引号和双引号

shell 字符串可以用单引号 '',也可以用双引号 “”,也可以不用引号。

  • 单引号的特点
    • 单引号里不识别变量
    • 单引号里不能出现单独的单引号(使用转义符也不行),但可成对出现,作为字符串拼接使用。
  • 双引号的特点
    • 双引号里识别变量
    • 双引号里可以出现转义字符

综上,推荐使用双引号。

拼接字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 使用单引号拼接
name1='white'
str1='hello, '${name1}''
str2='hello, ${name1}'
echo ${str1}_${str2}
# Output:
# hello, white_hello, ${name1}

# 使用双引号拼接
name2="black"
str3="hello, "${name2}""
str4="hello, ${name2}"
echo ${str3}_${str4}
# Output:
# hello, black_hello, black

获取字符串长度

1
2
3
4
text="12345"
echo ${#text}
# Output:
# 5

截取子字符串

1
2
3
4
text="12345"
echo ${text:2:2}
# Output:
# 34

从第 3 个字符开始,截取 2 个字符

查找子字符串

1
2
3
4
5
6
7
8
#!/usr/bin/env bash

text="hello"
echo `expr index "${text}" ll`

# Execute: ./str-demo5.sh
# Output:
# 3

查找 ll 子字符在 hello 字符串中的起始位置。

💻 『示例源码』

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
#!/usr/bin/env bash

################### 使用单引号拼接字符串 ###################
name1='white'
str1='hello, '${name1}''
str2='hello, ${name1}'
echo ${str1}_${str2}
# Output:
# hello, white_hello, ${name1}

################### 使用双引号拼接字符串 ###################
name2="black"
str3="hello, "${name2}""
str4="hello, ${name2}"
echo ${str3}_${str4}
# Output:
# hello, black_hello, black

################### 获取字符串长度 ###################
text="12345"
echo "${text} length is: ${#text}"
# Output:
# 12345 length is: 5

# 获取子字符串
text="12345"
echo ${text:2:2}
# Output:
# 34

################### 查找子字符串 ###################
text="hello"
echo `expr index "${text}" ll`
# Output:
# 3

################### 判断字符串中是否包含子字符串 ###################
result=$(echo "${str}" | grep "feature/")
if [[ "$result" != "" ]]; then
echo "feature/ 是 ${str} 的子字符串"
else
echo "feature/ 不是 ${str} 的子字符串"
fi

################### 截取关键字左边内容 ###################
full_branch="feature/1.0.0"
branch=`echo ${full_branch#feature/}`
echo "branch is ${branch}"

################### 截取关键字右边内容 ###################
full_version="0.0.1-SNAPSHOT"
version=`echo ${full_version%-SNAPSHOT}`
echo "version is ${version}"

################### 字符串分割成数组 ###################
str="0.0.0.1"
OLD_IFS="$IFS"
IFS="."
array=( ${str} )
IFS="$OLD_IFS"
size=${#array[*]}
lastIndex=`expr ${size} - 1`
echo "数组长度:${size}"
echo "最后一个数组元素:${array[${lastIndex}]}"
for item in ${array[@]}
do
echo "$item"
done

################### 判断字符串是否为空 ###################
#-n 判断长度是否非零
#-z 判断长度是否为零

str=testing
str2=''
if [[ -n "$str" ]]
then
echo "The string $str is not empty"
else
echo "The string $str is empty"
fi

if [[ -n "$str2" ]]
then
echo "The string $str2 is not empty"
else
echo "The string $str2 is empty"
fi

# Output:
# The string testing is not empty
# The string is empty

################### 字符串比较 ###################
str=hello
str2=world
if [[ $str = "hello" ]]; then
echo "str equals hello"
else
echo "str not equals hello"
fi

if [[ $str2 = "hello" ]]; then
echo "str2 equals hello"
else
echo "str2 not equals hello"
fi

数组

bash 只支持一维数组。

数组下标从 0 开始,下标可以是整数或算术表达式,其值应大于或等于 0。

创建数组

1
2
3
# 创建数组的不同方式
nums=([2]=2 [0]=0 [1]=1)
colors=(red yellow "dark blue")

访问数组元素

  • 访问数组的单个元素:
1
2
echo ${nums[1]}
# Output: 1
  • 访问数组的所有元素:
1
2
3
4
5
echo ${colors[*]}
# Output: red yellow dark blue

echo ${colors[@]}
# Output: red yellow dark blue

上面两行有很重要(也很微妙)的区别:

为了将数组中每个元素单独一行输出,我们用 printf 命令:

1
2
3
4
5
6
printf "+ %s\n" ${colors[*]}
# Output:
# + red
# + yellow
# + dark
# + blue

为什么darkblue各占了一行?尝试用引号包起来:

1
2
3
printf "+ %s\n" "${colors[*]}"
# Output:
# + red yellow dark blue

现在所有的元素都在一行输出 —— 这不是我们想要的!让我们试试${colors[@]}

1
2
3
4
5
printf "+ %s\n" "${colors[@]}"
# Output:
# + red
# + yellow
# + dark blue

在引号内,${colors[@]}将数组中的每个元素扩展为一个单独的参数;数组元素中的空格得以保留。

  • 访问数组的部分元素:
1
2
3
echo ${nums[@]:0:2}
# Output:
# 0 1

在上面的例子中,${array[@]} 扩展为整个数组,:0:2取出了数组中从 0 开始,长度为 2 的元素。

访问数组长度

1
2
3
echo ${#nums[*]}
# Output:
# 3

向数组中添加元素

向数组中添加元素也非常简单:

1
2
3
4
colors=(white "${colors[@]}" green black)
echo ${colors[@]}
# Output:
# white red yellow dark blue green black

上面的例子中,${colors[@]} 扩展为整个数组,并被置换到复合赋值语句中,接着,对数组colors的赋值覆盖了它原来的值。

从数组中删除元素

unset命令来从数组中删除一个元素:

1
2
3
4
unset nums[0]
echo ${nums[@]}
# Output:
# 1 2

💻 『示例源码』

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
#!/usr/bin/env bash

################### 创建数组 ###################
nums=( [ 2 ] = 2 [ 0 ] = 0 [ 1 ] = 1 )
colors=( red yellow "dark blue" )

################### 访问数组的单个元素 ###################
echo ${nums[1]}
# Output: 1

################### 访问数组的所有元素 ###################
echo ${colors[*]}
# Output: red yellow dark blue

echo ${colors[@]}
# Output: red yellow dark blue

printf "+ %s\n" ${colors[*]}
# Output:
# + red
# + yellow
# + dark
# + blue

printf "+ %s\n" "${colors[*]}"
# Output:
# + red yellow dark blue

printf "+ %s\n" "${colors[@]}"
# Output:
# + red
# + yellow
# + dark blue

################### 访问数组的部分元素 ###################
echo ${nums[@]:0:2}
# Output:
# 0 1

################### 获取数组长度 ###################
echo ${#nums[*]}
# Output:
# 3

################### 向数组中添加元素 ###################
colors=( white "${colors[@]}" green black )
echo ${colors[@]}
# Output:
# white red yellow dark blue green black

################### 从数组中删除元素 ###################
unset nums[ 0 ]
echo ${nums[@]}
# Output:
# 1 2

运算符

算术运算符

下表列出了常用的算术运算符,假定变量 x 为 10,变量 y 为 20:

运算符 说明 举例
+ 加法 expr $x + $y 结果为 30。
- 减法 expr $x - $y 结果为 -10。
* 乘法 expr $x * $y 结果为 200。
/ 除法 expr $y / $x 结果为 2。
% 取余 expr $y % $x 结果为 0。
= 赋值 x=$y 将把变量 y 的值赋给 x。
== 相等。用于比较两个数字,相同则返回 true。 [ $x == $y ] 返回 false。
!= 不相等。用于比较两个数字,不相同则返回 true。 [ $x != $y ] 返回 true。

注意:条件表达式要放在方括号之间,并且要有空格,例如: [$x==$y] 是错误的,必须写成 [ $x == $y ]

💻 『示例源码』

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
x=10
y=20

echo "x=${x}, y=${y}"

val=`expr ${x} + ${y}`
echo "${x} + ${y} = $val"

val=`expr ${x} - ${y}`
echo "${x} - ${y} = $val"

val=`expr ${x} \* ${y}`
echo "${x} * ${y} = $val"

val=`expr ${y} / ${x}`
echo "${y} / ${x} = $val"

val=`expr ${y} % ${x}`
echo "${y} % ${x} = $val"

if [[ ${x} == ${y} ]]
then
echo "${x} = ${y}"
fi
if [[ ${x} != ${y} ]]
then
echo "${x} != ${y}"
fi

# Output:
# x=10, y=20
# 10 + 20 = 30
# 10 - 20 = -10
# 10 * 20 = 200
# 20 / 10 = 2
# 20 % 10 = 0
# 10 != 20

关系运算符

关系运算符只支持数字,不支持字符串,除非字符串的值是数字。

下表列出了常用的关系运算符,假定变量 x 为 10,变量 y 为 20:

运算符 说明 举例
-eq 检测两个数是否相等,相等返回 true。 [ $a -eq $b ]返回 false。
-ne 检测两个数是否相等,不相等返回 true。 [ $a -ne $b ] 返回 true。
-gt 检测左边的数是否大于右边的,如果是,则返回 true。 [ $a -gt $b ] 返回 false。
-lt 检测左边的数是否小于右边的,如果是,则返回 true。 [ $a -lt $b ] 返回 true。
-ge 检测左边的数是否大于等于右边的,如果是,则返回 true。 [ $a -ge $b ] 返回 false。
-le 检测左边的数是否小于等于右边的,如果是,则返回 true。 [ $a -le $b ]返回 true。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
x=10
y=20

echo "x=${x}, y=${y}"

if [[ ${x} -eq ${y} ]]; then
echo "${x} -eq ${y} : x 等于 y"
else
echo "${x} -eq ${y}: x 不等于 y"
fi

if [[ ${x} -ne ${y} ]]; then
echo "${x} -ne ${y}: x 不等于 y"
else
echo "${x} -ne ${y}: x 等于 y"
fi

if [[ ${x} -gt ${y} ]]; then
echo "${x} -gt ${y}: x 大于 y"
else
echo "${x} -gt ${y}: x 不大于 y"
fi

if [[ ${x} -lt ${y} ]]; then
echo "${x} -lt ${y}: x 小于 y"
else
echo "${x} -lt ${y}: x 不小于 y"
fi

if [[ ${x} -ge ${y} ]]; then
echo "${x} -ge ${y}: x 大于或等于 y"
else
echo "${x} -ge ${y}: x 小于 y"
fi

if [[ ${x} -le ${y} ]]; then
echo "${x} -le ${y}: x 小于或等于 y"
else
echo "${x} -le ${y}: x 大于 y"
fi

# Output:
# x=10, y=20
# 10 -eq 20: x 不等于 y
# 10 -ne 20: x 不等于 y
# 10 -gt 20: x 不大于 y
# 10 -lt 20: x 小于 y
# 10 -ge 20: x 小于 y
# 10 -le 20: x 小于或等于 y

布尔运算符

下表列出了常用的布尔运算符,假定变量 x 为 10,变量 y 为 20:

运算符 说明 举例
! 非运算,表达式为 true 则返回 false,否则返回 true。 [ ! false ] 返回 true。
-o 或运算,有一个表达式为 true 则返回 true。 [ $a -lt 20 -o $b -gt 100 ] 返回 true。
-a 与运算,两个表达式都为 true 才返回 true。 [ $a -lt 20 -a $b -gt 100 ] 返回 false。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
x=10
y=20

echo "x=${x}, y=${y}"

if [[ ${x} != ${y} ]]; then
echo "${x} != ${y} : x 不等于 y"
else
echo "${x} != ${y}: x 等于 y"
fi

if [[ ${x} -lt 100 && ${y} -gt 15 ]]; then
echo "${x} 小于 100 且 ${y} 大于 15 : 返回 true"
else
echo "${x} 小于 100 且 ${y} 大于 15 : 返回 false"
fi

if [[ ${x} -lt 100 || ${y} -gt 100 ]]; then
echo "${x} 小于 100 或 ${y} 大于 100 : 返回 true"
else
echo "${x} 小于 100 或 ${y} 大于 100 : 返回 false"
fi

if [[ ${x} -lt 5 || ${y} -gt 100 ]]; then
echo "${x} 小于 5 或 ${y} 大于 100 : 返回 true"
else
echo "${x} 小于 5 或 ${y} 大于 100 : 返回 false"
fi

# Output:
# x=10, y=20
# 10 != 20 : x 不等于 y
# 10 小于 100 且 20 大于 15 : 返回 true
# 10 小于 100 或 20 大于 100 : 返回 true
# 10 小于 5 或 20 大于 100 : 返回 false

逻辑运算符

以下介绍 Shell 的逻辑运算符,假定变量 x 为 10,变量 y 为 20:

运算符 说明 举例
&& 逻辑的 AND [[ ${x} -lt 100 && ${y} -gt 100 ]] 返回 false
` `

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
x=10
y=20

echo "x=${x}, y=${y}"

if [[ ${x} -lt 100 && ${y} -gt 100 ]]
then
echo "${x} -lt 100 && ${y} -gt 100 返回 true"
else
echo "${x} -lt 100 && ${y} -gt 100 返回 false"
fi

if [[ ${x} -lt 100 || ${y} -gt 100 ]]
then
echo "${x} -lt 100 || ${y} -gt 100 返回 true"
else
echo "${x} -lt 100 || ${y} -gt 100 返回 false"
fi

# Output:
# x=10, y=20
# 10 -lt 100 && 20 -gt 100 返回 false
# 10 -lt 100 || 20 -gt 100 返回 true

字符串运算符

下表列出了常用的字符串运算符,假定变量 a 为 “abc”,变量 b 为 “efg”:

运算符 说明 举例
= 检测两个字符串是否相等,相等返回 true。 [ $a = $b ] 返回 false。
!= 检测两个字符串是否相等,不相等返回 true。 [ $a != $b ] 返回 true。
-z 检测字符串长度是否为 0,为 0 返回 true。 [ -z $a ] 返回 false。
-n 检测字符串长度是否为 0,不为 0 返回 true。 [ -n $a ] 返回 true。
str 检测字符串是否为空,不为空返回 true。 [ $a ] 返回 true。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
x="abc"
y="xyz"


echo "x=${x}, y=${y}"

if [[ ${x} = ${y} ]]; then
echo "${x} = ${y} : x 等于 y"
else
echo "${x} = ${y}: x 不等于 y"
fi

if [[ ${x} != ${y} ]]; then
echo "${x} != ${y} : x 不等于 y"
else
echo "${x} != ${y}: x 等于 y"
fi

if [[ -z ${x} ]]; then
echo "-z ${x} : 字符串长度为 0"
else
echo "-z ${x} : 字符串长度不为 0"
fi

if [[ -n "${x}" ]]; then
echo "-n ${x} : 字符串长度不为 0"
else
echo "-n ${x} : 字符串长度为 0"
fi

if [[ ${x} ]]; then
echo "${x} : 字符串不为空"
else
echo "${x} : 字符串为空"
fi

# Output:
# x=abc, y=xyz
# abc = xyz: x 不等于 y
# abc != xyz : x 不等于 y
# -z abc : 字符串长度不为 0
# -n abc : 字符串长度不为 0
# abc : 字符串不为空

文件测试运算符

文件测试运算符用于检测 Unix 文件的各种属性。

属性检测描述如下:

操作符 说明 举例
-b file 检测文件是否是块设备文件,如果是,则返回 true。 [ -b $file ] 返回 false。
-c file 检测文件是否是字符设备文件,如果是,则返回 true。 [ -c $file ] 返回 false。
-d file 检测文件是否是目录,如果是,则返回 true。 [ -d $file ] 返回 false。
-f file 检测文件是否是普通文件(既不是目录,也不是设备文件),如果是,则返回 true。 [ -f $file ] 返回 true。
-g file 检测文件是否设置了 SGID 位,如果是,则返回 true。 [ -g $file ] 返回 false。
-k file 检测文件是否设置了粘着位(Sticky Bit),如果是,则返回 true。 [ -k $file ]返回 false。
-p file 检测文件是否是有名管道,如果是,则返回 true。 [ -p $file ] 返回 false。
-u file 检测文件是否设置了 SUID 位,如果是,则返回 true。 [ -u $file ] 返回 false。
-r file 检测文件是否可读,如果是,则返回 true。 [ -r $file ] 返回 true。
-w file 检测文件是否可写,如果是,则返回 true。 [ -w $file ] 返回 true。
-x file 检测文件是否可执行,如果是,则返回 true。 [ -x $file ] 返回 true。
-s file 检测文件是否为空(文件大小是否大于 0),不为空返回 true。 [ -s $file ] 返回 true。
-e file 检测文件(包括目录)是否存在,如果是,则返回 true。 [ -e $file ] 返回 true。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
file="/etc/hosts"

if [[ -r ${file} ]]; then
echo "${file} 文件可读"
else
echo "${file} 文件不可读"
fi
if [[ -w ${file} ]]; then
echo "${file} 文件可写"
else
echo "${file} 文件不可写"
fi
if [[ -x ${file} ]]; then
echo "${file} 文件可执行"
else
echo "${file} 文件不可执行"
fi
if [[ -f ${file} ]]; then
echo "${file} 文件为普通文件"
else
echo "${file} 文件为特殊文件"
fi
if [[ -d ${file} ]]; then
echo "${file} 文件是个目录"
else
echo "${file} 文件不是个目录"
fi
if [[ -s ${file} ]]; then
echo "${file} 文件不为空"
else
echo "${file} 文件为空"
fi
if [[ -e ${file} ]]; then
echo "${file} 文件存在"
else
echo "${file} 文件不存在"
fi

# Output:(根据文件的实际情况,输出结果可能不同)
# /etc/hosts 文件可读
# /etc/hosts 文件可写
# /etc/hosts 文件不可执行
# /etc/hosts 文件为普通文件
# /etc/hosts 文件不是个目录
# /etc/hosts 文件不为空
# /etc/hosts 文件存在

控制语句

条件语句

跟其它程序设计语言一样,Bash 中的条件语句让我们可以决定一个操作是否被执行。结果取决于一个包在[[ ]]里的表达式。

[[ ]]sh中是[ ])包起来的表达式被称作 检测命令基元。这些表达式帮助我们检测一个条件的结果。这里可以找到有关bash 中单双中括号区别的答案。

共有两个不同的条件表达式:ifcase

if

(1)if 语句

if在使用上跟其它语言相同。如果中括号里的表达式为真,那么thenfi之间的代码会被执行。fi标志着条件代码块的结束。

1
2
3
4
5
6
7
8
9
10
# 写成一行
if [[ 1 -eq 1 ]]; then echo "1 -eq 1 result is: true"; fi
# Output: 1 -eq 1 result is: true

# 写成多行
if [[ "abc" -eq "abc" ]]
then
echo ""abc" -eq "abc" result is: true"
fi
# Output: abc -eq abc result is: true

(2)if else 语句

同样,我们可以使用if..else语句,例如:

1
2
3
4
5
6
if [[ 2 -ne 1 ]]; then
echo "true"
else
echo "false"
fi
# Output: true

(3)if elif else 语句

有些时候,if..else不能满足我们的要求。别忘了if..elif..else,使用起来也很方便。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
x=10
y=20
if [[ ${x} > ${y} ]]; then
echo "${x} > ${y}"
elif [[ ${x} < ${y} ]]; then
echo "${x} < ${y}"
else
echo "${x} = ${y}"
fi
# Output: 10 < 20

case

如果你需要面对很多情况,分别要采取不同的措施,那么使用case会比嵌套的if更有用。使用case来解决复杂的条件判断,看起来像下面这样:

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
exec
case ${oper} in
"+")
val=`expr ${x} + ${y}`
echo "${x} + ${y} = ${val}"
;;
"-")
val=`expr ${x} - ${y}`
echo "${x} - ${y} = ${val}"
;;
"*")
val=`expr ${x} \* ${y}`
echo "${x} * ${y} = ${val}"
;;
"/")
val=`expr ${x} / ${y}`
echo "${x} / ${y} = ${val}"
;;
*)
echo "Unknown oper!"
;;
esac

每种情况都是匹配了某个模式的表达式。|用来分割多个模式,)用来结束一个模式序列。第一个匹配上的模式对应的命令将会被执行。*代表任何不匹配以上给定模式的模式。命令块儿之间要用;;分隔。

循环语句

循环其实不足为奇。跟其它程序设计语言一样,bash 中的循环也是只要控制条件为真就一直迭代执行的代码块。

Bash 中有四种循环:forwhileuntilselect

for循环

for与它在 C 语言中的姊妹非常像。看起来是这样:

1
2
3
4
for arg in elem1 elem2 ... elemN
do
### 语句
done

在每次循环的过程中,arg依次被赋值为从elem1elemN。这些值还可以是通配符或者大括号扩展

当然,我们还可以把for循环写在一行,但这要求do之前要有一个分号,就像下面这样:

1
for i in {1..5}; do echo $i; done

还有,如果你觉得for..in..do对你来说有点奇怪,那么你也可以像 C 语言那样使用for,比如:

1
2
3
for (( i = 0; i < 10; i++ )); do
echo $i
done

当我们想对一个目录下的所有文件做同样的操作时,for就很方便了。举个例子,如果我们想把所有的.bash文件移动到script文件夹中,并给它们可执行权限,我们的脚本可以这样写:

💻 『示例源码』

1
2
3
4
5
DIR=/home/zp
for FILE in ${DIR}/*.sh; do
mv "$FILE" "${DIR}/scripts"
done
# 将 /home/zp 目录下所有 sh 文件拷贝到 /home/zp/scripts

while循环

while循环检测一个条件,只要这个条件为 _真_,就执行一段命令。被检测的条件跟if..then中使用的基元并无二异。因此一个while循环看起来会是这样:

1
2
3
4
while [[ condition ]]
do
### 语句
done

for循环一样,如果我们把do和被检测的条件写到一行,那么必须要在do之前加一个分号。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
### 0到9之间每个数的平方
x=0
while [[ ${x} -lt 10 ]]; do
echo $((x * x))
x=$((x + 1))
done
# Output:
# 0
# 1
# 4
# 9
# 16
# 25
# 36
# 49
# 64
# 81

until循环

until循环跟while循环正好相反。它跟while一样也需要检测一个测试条件,但不同的是,只要该条件为 就一直执行循环:

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
x=0
until [[ ${x} -ge 5 ]]; do
echo ${x}
x=`expr ${x} + 1`
done
# Output:
# 0
# 1
# 2
# 3
# 4

select循环

select循环帮助我们组织一个用户菜单。它的语法几乎跟for循环一致:

1
2
3
4
select answer in elem1 elem2 ... elemN
do
### 语句
done

select会打印elem1..elemN以及它们的序列号到屏幕上,之后会提示用户输入。通常看到的是$?PS3变量)。用户的选择结果会被保存到answer中。如果answer是一个在1..N之间的数字,那么语句会被执行,紧接着会进行下一次迭代 —— 如果不想这样的话我们可以使用break语句。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env bash

PS3="Choose the package manager: "
select ITEM in bower npm gem pip
do
echo -n "Enter the package name: " && read PACKAGE
case ${ITEM} in
bower) bower install ${PACKAGE} ;;
npm) npm install ${PACKAGE} ;;
gem) gem install ${PACKAGE} ;;
pip) pip install ${PACKAGE} ;;
esac
break # 避免无限循环
done

这个例子,先询问用户他想使用什么包管理器。接着,又询问了想安装什么包,最后执行安装操作。

运行这个脚本,会得到如下输出:

1
2
3
4
5
6
7
$ ./my_script
1) bower
2) npm
3) gem
4) pip
Choose the package manager: 2
Enter the package name: gitbook-cli

breakcontinue

如果想提前结束一个循环或跳过某次循环执行,可以使用 shell 的breakcontinue语句来实现。它们可以在任何循环中使用。

break语句用来提前结束当前循环。

continue语句用来跳过某次迭代。

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
# 查找 10 以内第一个能整除 2 和 3 的正整数
i=1
while [[ ${i} -lt 10 ]]; do
if [[ $((i % 3)) -eq 0 ]] && [[ $((i % 2)) -eq 0 ]]; then
echo ${i}
break;
fi
i=`expr ${i} + 1`
done
# Output: 6

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
# 打印10以内的奇数
for (( i = 0; i < 10; i ++ )); do
if [[ $((i % 2)) -eq 0 ]]; then
continue;
fi
echo ${i}
done
# Output:
# 1
# 3
# 5
# 7
# 9

函数

bash 函数定义语法如下:

1
2
3
4
[ function ] funname [()] {
action;
[return int;]
}

💡 说明:

  1. 函数定义时,function 关键字可有可无。
  2. 函数返回值 - return 返回函数返回值,返回值类型只能为整数(0-255)。如果不加 return 语句,shell 默认将以最后一条命令的运行结果,作为函数返回值。
  3. 函数返回值在调用该函数后通过 $? 来获得。
  4. 所有函数在使用前必须定义。这意味着必须将函数放在脚本开始部分,直至 shell 解释器首次发现它时,才可以使用。调用函数仅使用其函数名即可。

💻 『示例源码』

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
#!/usr/bin/env bash

calc(){
PS3="choose the oper: "
select oper in + - \* / # 生成操作符选择菜单
do
echo -n "enter first num: " && read x # 读取输入参数
echo -n "enter second num: " && read y # 读取输入参数
exec
case ${oper} in
"+")
return $((${x} + ${y}))
;;
"-")
return $((${x} - ${y}))
;;
"*")
return $((${x} * ${y}))
;;
"/")
return $((${x} / ${y}))
;;
*)
echo "${oper} is not support!"
return 0
;;
esac
break
done
}
calc
echo "the result is: $?" # $? 获取 calc 函数返回值

执行结果:

1
2
3
4
5
6
7
8
9
$ ./function-demo.sh
1) +
2) -
3) *
4) /
choose the oper: 3
enter first num: 10
enter second num: 10
the result is: 100

位置参数

位置参数是在调用一个函数并传给它参数时创建的变量。

位置参数变量表:

变量 描述
$0 脚本名称
$1 … $9 第 1 个到第 9 个参数列表
${10} … ${N} 第 10 个到 N 个参数列表
$* or $@ 除了$0外的所有位置参数
$# 不包括$0在内的位置参数的个数
$FUNCNAME 函数名称(仅在函数内部有值)

💻 『示例源码』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env bash

x=0
if [[ -n $1 ]]; then
echo "第一个参数为:$1"
x=$1
else
echo "第一个参数为空"
fi

y=0
if [[ -n $2 ]]; then
echo "第二个参数为:$2"
y=$2
else
echo "第二个参数为空"
fi

paramsFunction(){
echo "函数第一个入参:$1"
echo "函数第二个入参:$2"
}
paramsFunction ${x} ${y}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
$ ./function-demo2.sh
第一个参数为空
第二个参数为空
函数第一个入参:0
函数第二个入参:0

$ ./function-demo2.sh 10 20
第一个参数为:10
第二个参数为:20
函数第一个入参:10
函数第二个入参:20

执行 ./variable-demo4.sh hello world ,然后在脚本中通过 $1$2 … 读取第 1 个参数、第 2 个参数。。。

函数处理参数

另外,还有几个特殊字符用来处理参数:

参数处理 说明
$# 返回参数个数
$* 返回所有参数
$$ 脚本运行的当前进程 ID 号
$! 后台运行的最后一个进程的 ID 号
$@ 返回所有参数
$- 返回 Shell 使用的当前选项,与 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
30
31
32
33
34
runner() {
return 0
}

name=zp
paramsFunction(){
echo "函数第一个入参:$1"
echo "函数第二个入参:$2"
echo "传递到脚本的参数个数:$#"
echo "所有参数:"
printf "+ %s\n" "$*"
echo "脚本运行的当前进程 ID 号:$$"
echo "后台运行的最后一个进程的 ID 号:$!"
echo "所有参数:"
printf "+ %s\n" "$@"
echo "Shell 使用的当前选项:$-"
runner
echo "runner 函数的返回值:$?"
}
paramsFunction 1 "abc" "hello, \"zp\""
# Output:
# 函数第一个入参:1
# 函数第二个入参:abc
# 传递到脚本的参数个数:3
# 所有参数:
# + 1 abc hello, "zp"
# 脚本运行的当前进程 ID 号:26400
# 后台运行的最后一个进程的 ID 号:
# 所有参数:
# + 1
# + abc
# + hello, "zp"
# Shell 使用的当前选项:hB
# runner 函数的返回值:0

Shell 扩展

扩展 发生在一行命令被分成一个个的 记号(tokens) 之后。换言之,扩展是一种执行数学运算的机制,还可以用来保存命令的执行结果,等等。

感兴趣的话可以阅读关于 shell 扩展的更多细节

大括号扩展

大括号扩展让生成任意的字符串成为可能。它跟 文件名扩展 很类似,举个例子:

1
echo beg{i,a,u}n ### begin began begun

大括号扩展还可以用来创建一个可被循环迭代的区间。

1
2
echo {0..5} ### 0 1 2 3 4 5
echo {00..8..2} ### 00 02 04 06 08

命令置换

命令置换允许我们对一个命令求值,并将其值置换到另一个命令或者变量赋值表达式中。当一个命令被``或$()包围时,命令置换将会执行。举个例子:

1
2
3
4
5
now=`date +%T`
### or
now=$(date +%T)

echo $now ### 19:08:26

算数扩展

在 bash 中,执行算数运算是非常方便的。算数表达式必须包在$(( ))中。算数扩展的格式为:

1
2
result=$(( ((10 + 5*3) - 7) / 2 ))
echo $result ### 9

在算数表达式中,使用变量无需带上$前缀:

1
2
3
4
5
x=4
y=7
echo $(( x + y )) ### 11
echo $(( ++x + y++ )) ### 12
echo $(( x + y )) ### 13

单引号和双引号

单引号和双引号之间有很重要的区别。在双引号中,变量引用或者命令置换是会被展开的。在单引号中是不会的。举个例子:

1
2
echo "Your home: $HOME" ### Your home: /Users/<username>
echo 'Your home: $HOME' ### Your home: $HOME

当局部变量和环境变量包含空格时,它们在引号中的扩展要格外注意。随便举个例子,假如我们用echo来输出用户的输入:

1
2
3
INPUT="A string  with   strange    whitespace."
echo $INPUT ### A string with strange whitespace.
echo "$INPUT" ### A string with strange whitespace.

调用第一个echo时给了它 5 个单独的参数 —— $INPUT 被分成了单独的词,echo在每个词之间打印了一个空格。第二种情况,调用echo时只给了它一个参数(整个$INPUT 的值,包括其中的空格)。

来看一个更严肃的例子:

1
2
3
FILE="Favorite Things.txt"
cat $FILE ### 尝试输出两个文件: `Favorite` 和 `Things.txt`
cat "$FILE" ### 输出一个文件: `Favorite Things.txt`

尽管这个问题可以通过把 FILE 重命名成Favorite-Things.txt来解决,但是,假如这个值来自某个环境变量,来自一个位置参数,或者来自其它命令(find, cat, 等等)呢。因此,如果输入 可能 包含空格,务必要用引号把表达式包起来。

流和重定向

Bash 有很强大的工具来处理程序之间的协同工作。使用流,我们能将一个程序的输出发送到另一个程序或文件,因此,我们能方便地记录日志或做一些其它我们想做的事。

管道给了我们创建传送带的机会,控制程序的执行成为可能。

学习如何使用这些强大的、高级的工具是非常非常重要的。

输入、输出流

Bash 接收输入,并以字符序列或 字符流 的形式产生输出。这些流能被重定向到文件或另一个流中。

有三个文件描述符:

代码 描述符 描述
0 stdin 标准输入
1 stdout 标准输出
2 stderr 标准错误输出

重定向

重定向让我们可以控制一个命令的输入来自哪里,输出结果到什么地方。这些运算符在控制流的重定向时会被用到:

Operator Description
> 重定向输出
&> 重定向输出和错误输出
&>> 以附加的形式重定向输出和错误输出
< 重定向输入
<< Here 文档 语法
<<< Here 字符串

以下是一些使用重定向的例子:

1
2
3
4
5
6
7
8
9
10
11
### ls的结果将会被写到list.txt中
ls -l > list.txt

### 将输出附加到list.txt中
ls -a >> list.txt

### 所有的错误信息会被写到errors.txt中
grep da * 2> errors.txt

### 从errors.txt中读取输入
less < errors.txt

/dev/null 文件

如果希望执行某个命令,但又不希望在屏幕上显示输出结果,那么可以将输出重定向到 /dev/null:

1
$ command > /dev/null

/dev/null 是一个特殊的文件,写入到它的内容都会被丢弃;如果尝试从该文件读取内容,那么什么也读不到。但是 /dev/null 文件非常有用,将命令的输出重定向到它,会起到”禁止输出”的效果。

如果希望屏蔽 stdout 和 stderr,可以这样写:

1
$ command > /dev/null 2>&1

Debug

shell 提供了用于 debug 脚本的工具。

如果想采用 debug 模式运行某脚本,可以在其 shebang 中使用一个特殊的选项:

1
#!/bin/bash options

options 是一些可以改变 shell 行为的选项。下表是一些可能对你有用的选项:

Short Name Description
-f noglob 禁止文件名展开(globbing)
-i interactive 让脚本以 交互 模式运行
-n noexec 读取命令,但不执行(语法检查)
-t 执行完第一条命令后退出
-v verbose 在执行每条命令前,向stderr输出该命令
-x xtrace 在执行每条命令前,向stderr输出该命令以及该命令的扩展参数

举个例子,如果我们在脚本中指定了-x例如:

1
2
3
4
5
#!/bin/bash -x

for (( i = 0; i < 3; i++ )); do
echo $i
done

这会向stdout打印出变量的值和一些其它有用的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./my_script
+ (( i = 0 ))
+ (( i < 3 ))
+ echo 0
0
+ (( i++ ))
+ (( i < 3 ))
+ echo 1
1
+ (( i++ ))
+ (( i < 3 ))
+ echo 2
2
+ (( i++ ))
+ (( i < 3 ))

有时我们值需要 debug 脚本的一部分。这种情况下,使用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
# 开启 debug
set -x
for (( i = 0; i < 3; i++ )); do
printf ${i}
done
# 关闭 debug
set +x
# Output:
# + (( i = 0 ))
# + (( i < 3 ))
# + printf 0
# 0+ (( i++ ))
# + (( i < 3 ))
# + printf 1
# 1+ (( i++ ))
# + (( i < 3 ))
# + printf 2
# 2+ (( i++ ))
# + (( i < 3 ))
# + set +x

for i in {1..5}; do printf ${i}; done
printf "\n"
# Output: 12345

资源

最后,Stack Overflow 上 bash 标签下有很多你可以学习的问题,当你遇到问题时,也是一个提问的好地方。

https://github.com/dunwu/blog/blob/master/source/_posts/coding/shell.md