罗晨汛

罗晨汛

移动互联网开发者

对于 coder 来说,实现一个功能、一个框架、一个项目当然是主要任务。但是作为一个有追求的 coder 来说,还有两个目标是要去一直追寻的,简单来说,一个是 “快”,一个是 “省”。让代码运行得更快,涉及到代码的执行效率问题;让代码运行时更节省内存等资源,涉及到代码执行时的资源消耗问题;对此 2 问题的分析标准,就是我们今天主要探索的时间复杂度 (评判效率) 和空间复杂度 (评判资源消耗)。

1 时间复杂度

其实我相信只要了解过算法的人对 时间复杂度 这个概念都不会陌生,本文中不必累述,我们一般使用 大 O 表示法 来表示一个算法的时间复杂度。比如下面这个算法:

1
2
3
4
5
6
7
int sum(int[] array) {
int sum = 0;
for (int i = 0; i < array.length() ; i++) {
sum += array[i];
}
return sum;
}

上面这个对一个数组求和的简单算法的时间复杂度是 O (n),表示执行完这个算法需要用 n 次计算单位。这里的计算单位一般来说是个抽象,我们没法说就是具体的一次 CPU tick,大家可以理解为应该是一个对于当前实际问题的最小解决时间单元,比如上面的例子就是两个数求和。

-最佳时间复杂度、最坏时间复杂度、平均时间复杂度

概念的东西我这就不多说了,这里只讨论些建在基础上的东西。对于一个算法的时间复杂度,我们要知道其实并不是一定的,比如对于链表的查找算法,你从头节点开始找,如果要找的元素就在第一位,那么 O (1) 的时间复杂度就 ok 了。但如果要找的元素根本就不在链表里,你妥妥地要找 O (n) 次才行。那么,我们说,O (1) 是这个算法的 最佳时间复杂度O (n) 是这个算法的 最坏时间复杂度O (2/n) 是此算法的 平均时间复杂度,这个很好理解。

-均摊时间复杂度

除此之外,还有另一种更高级也更科学的时间复杂度计算方法 – 均摊时间复杂度。不过理解起来也不难,均摊时间复杂度 其实就是各种复杂度基于出现概率的加权平均计算法,比如上面说的链表查询,就是把每种情况的出现概率 x 复杂度然后加权平均,比如要查找的元素出现在第一位的概念为 1/n,复杂度为 O (1),我们则用 1/n * 1。要查找的元素出现在第 2 位,概率也是 1/n,复杂度为 O (2),我们则用 1/n * 2,所以我们加权求和一下就是:
$$
{1 \over n} \times 1 + {2 \over n} \times 2 + \ldots + {n \over n} \times n = {(1 + 2 + \ldots + n) \over n} n = {(1 + n) \over 2} = O(n)
$$

所以,我们平常一般情况下说的复杂度其实指的就是 均摊时间复杂度,因为对各种情况做了概率的加权平均,所以理论上是最公平的。上面的推导方式并不复杂,但略繁琐,分析每个算法时我们都要这样计算一把是不太现实的。

2 常见算法的时间复杂度

上面介绍了几种时间复杂度的计算方法,在日常工作中老是这样去推导是不现实的事情,实际上,我们对一个算法进行时间复杂度分析是有方法、技巧的。

最常用的技巧就是 “眼神法” ^_^,实际上很多算法我们一眼就可以看出时间复杂度大概是多少,这里主要是观察循环次数。当有嵌套循环时,就用 乘法法则,比如最简单的冒泡排序,两层循环,复杂度为 O (n*n) = $O (n^2)$。

还有个比较复杂的 主定理 (Master theorem),这个推导太难太复杂了,但其解决了对于各类递归算法的时间复杂度计算,我们只要记住结果即可。

算法 算法特点 时间复杂度 备注
二分查找算法 每次问题规模减半 $O (log n)$
二叉树遍历 $O (n)$
合并排序 $O (n log n)$
快速排序 随机选择待排序序列中的一个数作为划分问题的标准 $O (n log n)$ 划分是否平均影响算法复杂度,最差情况下,复杂度为 $O (n ^ 2)$
冒泡排序 两层循环 $O (n^2)$

然后再附一个各复杂度的增长曲线:

software-algorithm-complexity

3 空间复杂度

空间复杂度 一般是指计算整个算法的辅助空间单元的个数,更具体而言,一般就是运行一个算法需要多少内存。通常来说,我们并不太关心空间复杂度,比方说我们常做的事 – “空间换时间” ^_^

但是,如果你把问题具体到内存的话还是有很多考究,特别是当你写的程序是运行在资源有限的移动设备或是访问量较大的后台服务。

空间复杂度的计算相比时间复杂度要简单些,但我们更重要的是把握两者的平衡,我记得《编程珠玑》第一章里就有关于空间复杂度与时间复杂度的平衡的问题,待我有空也将之总结出来。

在时间与空间复杂度的讨论中有个关于递归的问题比较有趣,这里也值得说说。

3.1 有关递归问题的讨论

在网上看很多人会说递归算法效率较低,其实当我们遇到这类说法时,应该自行多思考思考,为什么递归效率会低?

赞成这类观点的人想的是:递归,就是是函数再调用函数,我们知道函数调用是有成本的,而且一个函数如果不断地嵌套调用,就会导致不断有函数要入栈(我有篇文章介绍过函数调用的过程 深入函数栈 ,有兴趣的可以看看)。那如果这个递归调用的层级很深的话,确实就会有栈溢出的危险。

所以,对于需要嵌套很多层的算法,使用递归看起来就不是一个明智的选择。

但是,我们又可以发现,使用递归可以让代码更简洁明了。比如树的遍历,用递归可以写出很简洁明了的代码,但是如果不用递归,本身实现的复杂度就会高些,更别说让别人看及后面的维护了。

所以,我们并不需要怕使用递归,一个是函数的出入栈并没有大家想的那样影响效率,很多语言也对此有优化的方案 (比如 C++ 的 inline 函数)。其次,我们使用什么算法前都需要对问题进行评估,在效率差别不大的前提下,尽量使用更简洁易懂好维护的代码。另外,对于递归的优化有一个讨论的比较多的优化 – 尾递归,值得我们了解下。

3.2 尾递归

尾递归 就是把一个依赖上一层环境(或者说上下文) 的递归转变为一个不依赖上一层环境的递归, 转变的方法就是把需要用到的环境通过参数传递给下一层。这样介绍起来听起来还是有点绕。也可以这样说,顾名思义,尾递归就是从最后开始计算,每递归一次就算出相应的结果,也就是说,函数调用出现在调用者函数的尾部,因为是尾部,所以根本没有必要去保存任何局部变量。最简单的判断就是递归调用后直接返回,就尾递归。

附录中有篇阮一峰的文章,介绍的很好 深入函数栈

概念不好理解没关系,尾递归是怎样优化递归的?不如看例子来理解,我们看一个普通的累加算法:

求 1 累加到 n 的总和 - 普通递归算法
1
2
3
4
5
def recsum(n):
if n == 1:
return n
else:
return n + recsum(n - 1)

上面是累加算法的普通递归实现,其运行函数栈状况如下:

1
2
3
4
5
6
7
8
9
10
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
5 + (4 + (3 + 3))
5 + (4 + 6)
5 + 10
15

可以看出,因为每次递归都依赖于下层递归函数的结果,所以函数栈需要一直叠加,然后到最后一层计算出来后才层层返回,得出结果 15.

我们下面看看尾递归是如何优化的:

累加问题 - 尾递归算法
1
2
3
4
5
def recsumTail(n, temp=0):
if n == 0:
return temp
else:
return recsumTail(n - 1, temp + n)

可以看到,我们看看尾递归的栈状况:

1
2
3
4
5
6
7
recsumTail(5, 0)
recsumTail(4, 5)
recsumTail(3, 9)
recsumTail(2, 12)
recsumTail(1, 14)
recsumTail(0, 15)
15

可以看出,尾递归因为从最后开始计算,每层函数可以不用依赖下层的计算结果,因此可以调用完直接返回结果出栈,空间复杂度优化至 O (1)。

3.3 尾递归的思想

我们不妨来剖析下尾递归。我们先看看普通递归的问题究竟是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌────────┐ ┌────┐┌──────────────┐
│ f(n) = │ │n + ││ f(n-1) │
└────────┘ └────┘└──────┬───────┘
┌────────┐▼ ┌─────────┐
│(n-1) + │ │ f(n-2) │
└────────┘ └──┬──────┘
┌────────┐▼ ┌─────────┐
│(n-2) + │ │ f(n-3) │
└────────┘ └─────────┘
...


┌───────┐ ┌────────┐
│ 2 + │ │ f(1) │
└───────┘ └────┬───┘

┌───────┐
│ 1 │
└───────┘

我们可以看到,就是因为普通递归每次的函数返回 return n + recsum (n - 1),都包含一个临时计算结果,使得函数之间有了依赖。如果尾递归的思想就是把这个函数栈间的依赖变成参数,往后面传。所以现在让我们再回过头看 recsumTail (n, temp=0) 这个尾递归算法:

累加问题 - 尾递归算法
1
2
3
4
5
def recsumTail(n, temp=0):
if n == 0:
return temp
else:
return recsumTail(n - 1, temp + n)

recsumTail (n - 1, temp + n) 这个递归调用,就是把每次迭代的临时结果加起来,然后往下层函数传。从而解除了函数栈间的结果依赖。

这是我个人对尾递归的理解,要进行尾递归优化时,你们也可以学我一样先基于普通递归画出调用的逻辑图,然后基于临时变量做出优化。总之要注意一点就是递归调用后直接返回单个函数 (不能是表达式),就尾递归

这个优化可以给我们平时编译就算是写普通的业务代码带来许多启发,特别是使用函数式编程的兄弟~

3.4 附附附 - 斐波那契尾递归

我们来看看一个经典问题 斐波那契数。如果使用普通递归,结果为:

斐波那契 - 普通递归解法
1
2
3
4
5
6
7
let Count = 0
function fib(N: number): number {
console.log(`Calculate ${++Count} times`);
if (N == 0) return 0;
else if (N == 1) return 1;
else return fib(N - 1) + fib(N - 2)
};

结果:

结果
1
2
...
Calculate 177 times

函数递归调用了 177 次。

画图分析:

1
2
3
4
5
6
7
  迭代层级  临时变量 1   临时变量 2
(下一函数)(下下函数)
fib(5, 0, 1)
fib(4, 1, 1)
fib(3, 1, 2)
fib(2, 2, 3)
fib(1, 3, 5)

然后我们从中发现规律,写出尾递归算法

斐波那契 - 尾递归解法
1
2
3
4
5
6
7
8
9
10
let Count = 0
function fib(n: number, current = 0, next = 1): number {
console.log(`Calculate ${++Count} times`);

// param check
if (n < 0) { console.log('Error input'); return -1; }

if (n == 0) return current;
else return fib(n - 1, next, current + next);
};
结果
1
Calculate 11 time o

函数递归调用了 11 次。

PS: 解斐波那契,比较好的方法是使用变量保存中间结果的方法。这里只是为了展示下尾递归的思路,并非此题最佳解法。

4 参考

  1. 《Master theorem》 - wikipedia
  2. 《尾调用优化》 - 阮一峰
  3. 《关于尾递归与递归的讨论》- 知乎
  4. 《关于递归效率的讨论》- 知乎

1 例句摘抄

2 单词

2.1 procedure

prəˈsiːdʒə(r)

n. a way of doing sth, especially the usual or correct way (正常)程序,手续,步骤

1
maintenance procedures 维修程序

n. the official or formal order or way of doing sth, especially in business, law or politics (商业、法律或政治上的)程序

The law warrants this procedure.
这一程序是由法律批准的。

It has passed through an interesting procedure of evolution.
它经过了一个有趣的进化过程。

2.2 straightforward

[ˌstreɪtˈfɔːwəd]

adj. 简单的;坦率的;明确的;径直的
adv. 直截了当地;坦率地

I must insist on you give me a straightforward answer.
我一定要你给我一个直截了当的回答。

2.3 deterministic

adj. 确定性的;命运注定论的

2.3.1 determine

[dɪˈtɜːmɪn]
v. (使)下决心,(使)做出决定
vt. 决定,确定;判定,判决;限定

Once you determine your sleep needs, you should meet those needs every day.
一旦你决定你的睡眠需求,你应该每天满足那些需求。

2.4 stride

[straɪd]

n. 大步;步幅;进展
vt. 跨过;大踏步走过;跨坐在…

He crossed the room in two strides. 他两大步跨到屋子另一头。
I was gaining on the other runners with every stride . 我正一步步赶上其他运动员。

We’re making great strides in the search for a cure.
在探索治疗办法方面,我们正不断取得重大进展。

React 组件最终会生成 HTML,所以你可以使用给普通 HTML 设置 CSS 一样的方法来设置样式。此外,React组件还增加了行内样式 – React组件内置了prop – style 可以快速方便地给组件添加行内样式。 也就是说,React支持两种指定样式方式

  • css样式
组件CSS
1
2
3
4
5
6
.star {
background-color: grey;
}
.star.selected {
background-color: red;
}
组件使用className指定样式
1
2
3
import './star.css';

const component = <Star className={(selected ? 'star selected' : 'star')}
  • 行内样式
行内样式
1
2
3
4
5
6
const Styles = {
buttonStyle: {
color: '#fff'
}
}
const component = <Component style={Styles.buttonStyle} />;
阅读全文 »

React 作为前端框架,其管理的是一个个 UI 控件,而在 React 的语言体系中,我们将之称为 component – 组件。

狭义上来说,组件一般是 UI 组件,负责展示及和用户的交互。而广义上,组件是带有一定业务含义的,其不仅有与用户的交互,更重要的是数据与UI控件们之间的交互。

我们在之前介绍JSX时介绍过,React 通过自定义元素的方式实现组件化(虚拟DOM),组件元素被描述成纯粹的 JSON 对象,意味着可以使用方法或是类来构建。React 组件基本上由 3 个部分组成 —— 属性(props)、状态(state)以及生命周期方法。通过 JSX,我们通常将要渲染的组件组成一棵组件树,就像搭乐高玩具一样一步步组成最终我们想要的 UI 界面。

阅读全文 »

虽说是编程规范,但是本文旨在总结我在日常工作中与学习网上同行经验中收集来的一些最佳实践。建议平时编程尽自己最大能力编写最佳实践的代码,有助于提升自己的代码质量,同时也可以让自己在无形中养成追求卓越、追求优雅的气质。

1 优雅的语法

1.1 基于解构的swap方法

使用解构可以非常优雅地实现swap交换变量的值,下面请欣赏:

使用解构优雅地实现swap
1
[x, y] = [y, x];

1.2 书写顺序

在 Taro 组件中会包含类静态属性、类属性、生命周期等的类成员,其书写顺序最好遵循以下约定(顺序从上至下):

1
2
3
4
5
6
7
8
9
10
11
1. static 静态方法
2. constructor
3. componentWillMount
4. componentDidMount
5. componentWillReceiveProps
6. shouldComponentUpdate
7. componentWillUpdate
8. componentDidUpdate
9. componentWillUnmount
10. 点击回调或者事件回调 比如 onClickSubmit() 或者 onChangeDescription()
11. render

1.3 React

  • 不要在调用 this.setState 时使用 this.state

由于 this.setState 异步的缘故,这样的做法会导致一些错误,可以通过给 this.setState 传入函数来避免

  • map 循环时请给元素加上 key 属性
map 循环时请给元素加上 key 属性
1
list.map(item => <View className='list_item' key={item.id}>{item.name}</View> );

1.3.1 setState

  • 不要在调用 this.setState 时使用 this.state
不要在调用 this.setState 时使用 this.state
1
2
3
4
5
6
this.setState({
value: this.state.value + 1
}) // ✗ 错误


this.setState(prevState => ({ value: prevState.value + 1 })) // ✓ 正确
  • 不要在 componentWillUpdate/componentDidUpdate/render 中调用 this.setState
  • 不要定义没有用到的 state
  • 组件最好定义 defaultProps
  • render 方法必须有返回值
  • 值为 true 的属性可以省略书写值
  • 事件绑定均以 on 开头

2 书写规范

2.1 命名

普通 JS/TS 文件以小写字母命名,多个单词以下划线连接,例如 util.js、util_helper.js

React组件文件命名遵循 Pascal 命名法,首字母大写,例如 ReservationCard.jsx

2.2 文件后缀

普通 JS/TS 文件以 .js 或者 .ts 作为文件后缀
React组件则以 .jsx 或者 .tsx 作为文件后缀,当然这不是强制约束,只是作为一个实践的建议,组件文件依然可以以 .js 或者 .ts 作为文件后缀。

2.3 缩进

2.3.1 采用两个空格进行缩进

采用两个空格进行缩进
1
2
3
4
function hello (name) {
console.log('hi', name) // ✓ 正确
console.log('hello', name) // ✗ 错误
}

2.3.2 除了缩进,不要使用多个空格

除了缩进,不要使用多个空格
1
2
const id =    1234    // ✗ 错误
const id = 1234 // ✓ 正确

2.3.3 代码块中避免多余留白

代码块中避免多余留白
1
2
3
4
5
6
7
8
9
if (user) {
// ✗ 错误
const name = getName()

}

if (user) {
const name = getName() // ✓ 正确
}

2.3.4 关键字后面加空格

关键字后面加空格
1
2
if (condition) { ... }   // ✓ 正确
if(condition) { ... } // ✗ 错误

2.3.5 展开运算符与它的表达式间不要留空白

展开运算符与它的表达式间不要留空白
1
2
fn(... args)    // ✗ 错误
fn(...args) // ✓ 正确

2.3.6 注释首尾留空格

注释首尾留空格
1
2
3
4
5
//comment           // ✗ 错误
// comment // ✓ 正确

/*comment*/ // ✗ 错误
/* comment */ // ✓ 正确

2.3.7 逗号后面加空格

逗号后面加空格
1
2
3
4
5
6
7
// ✓ 正确
const list = [1, 2, 3, 4]
function greet (name, options) { ... }

// ✗ 错误
const list = [1,2,3,4]
function greet (name,options) { ... }

2.3.8 单行代码块两边加空格

单行代码块两边加空格
1
2
function foo () {return true}    // ✗ 错误
function foo () { return true } // ✓ 正确

2.3.9 点号操作符须与属性需在同一行

点号操作符须与属性需在同一行
1
2
3
4
5
console.
log('hello') // ✗ 错误

console
.log('hello') // ✓ 正确

2.3.10 字符串

  • 字符串统一使用单引号
字符串统一使用单引号
1
2
3
4
5
console.log('hello there')
// 如果遇到需要转义的情况,请按如下三种写法书写
const x = 'hello "world"'
const y = 'hello \'world\''
const z = `hello 'world'`

3 语法规范

3.1 变量

3.1.1 使用 const/let 定义变量

  • 当前作用域不需要改变的变量使用 const,反之则使用 let
1
2
3
4
5
const a = 'a'
a = 'b' // ✗ 错误,请使用 let 定义

let test = 'test'
var noVar = 'hello, world' // ✗ 错误,请使用 const/let 定义变量

3.1.2 不要省去小数点前面的 0

1
2
const discount = .5      // ✗ 错误
const discount = 0.5 // ✓ 正确

3.2 对象与数组

3.2.1 类名要以大写字母开头

类名要以大写字母开头
1
2
3
4
5
class animal {}
const dog = new animal() // ✗ 错误

class Animal {}
const dog = new Animal() // ✓ 正确

3.2.2 子类的构造器中一定要调用 super

子类的构造器中一定要调用 super
1
2
3
4
5
6
7
8
9
10
11
class Dog {
constructor () {
super() // ✗ 错误
}
}

class Dog extends Mammal {
constructor () {
super() // ✓ 正确
}
}

3.2.3 使用 this 前请确保 super() 已调用

使用 this 前请确保 super() 已调用
1
2
3
4
5
6
class Dog extends Animal {
constructor () {
this.legs = 4 // ✗ 错误
super()
}
}

3.2.4 对象中定义了存值器,一定要对应的定义取值器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const person = {
set name (value) { // ✗ 错误
this._name = value
}
}

const person = {
set name (value) {
this._name = value
},
get name () { // ✓ 正确
return this._name
}
}

3.2.5 使用数组字面量而不是构造器

1
2
const nums = new Array(1, 2, 3)   // ✗ 错误
const nums = [1, 2, 3] // ✓ 正确

3.2.6 NaN

  • 检查 NaN 的正确姿势是使用 isNaN()
1
2
if (price === NaN) { }      // ✗ 错误
if (isNaN(price)) { } // ✓ 正确

3.3 函数

3.3.1 避免使用 arguments.callee 和 arguments.caller

1
2
3
4
5
6
7
8
9
10
11
function foo (n) {
if (n <= 0) return

arguments.callee(n - 1) // ✗ 错误
}

function foo (n) {
if (n <= 0) return

foo(n - 1)
}

3.3.2 不使用 Generator 函数语法

  • 使用 Promise 或者 async functions 来实现异步编程

3.4 正则

3.4.1 正则中不要使用控制符

1
2
const pattern = /\x1f/    // ✗ 错误
const pattern = /\x20/ // ✓ 正确

3.5 逻辑与循环

3.5.1 始终使用 === 替代 ==

  • 例外: obj == null 可以用来检查 null || undefined
始终使用
1
2
3
4
if (name === 'John')   // ✓ 正确
if (name == 'John') // ✗ 错误
if (name !== 'John') // ✓ 正确
if (name != 'John') // ✗ 错误

3.6 出错处理

  • 用 throw 抛错时,抛出 Error 对象而不是字符串
用 throw 抛错时,抛出 Error 对象而不是字符串
1
2
throw 'error'               // ✗ 错误
throw new Error('error') // ✓ 正确
  • finally 代码块中不要再改变程序执行流程
finally 代码块中不要再改变程序执行流程
1
2
3
4
5
6
7
try {
// ...
} catch (e) {
// ...
} finally {
return 42 // ✗ 错误
}

3.6.1 使用 Promise 一定要捕捉错误

使用 Promise 一定要捕捉错误
1
asyncTask('google.com').catch(err => console.log(err))   // ✓ 正确

4 React组件

4.1 state & props

4.2 prop传递函数

父组件要往子组件传递函数,属性名必须以 on 开头。如:

父组件要往子组件传递函数,属性名必须以 on 开头
1
2
3
4
5
6
7
8
class MyButton extends Component {

render () {
return (
<Button onPress={() => this.props.onUserClick()} />
)
}
}

4.2.1 不要在 state 与 props 上用同名的字段

不要在 state 与 props 上用同名的字段
1
2
3
this.props = { content: 'content' }
this.state = { content: 'content' } // ✗ 错误
this.state = { msg: 'content' } // ✓ 正确

5 JSX最佳实践

5.1 属性值

5.1.1 布尔属性值

属性中的布尔属性,当为true时,可以省略赋值(即属性的默认值为true),比如:

1
2
3
<Label bold={true}>Name</Text>
等价于 ==>
<Label bold>Name</Text>

5.1.2 解构与展开

属性展开特性(Object Rest/Spread Properties for ECMAScript),此特性可以方便地为元素传递js对象的所有属性,常用于浅拷贝对象或修改对象的复制版本而不影响原对象。比如常用:

展开特性
1
<Button {...this.props, backgroundColor: '#ff0'} />

这个操作相当于将父组件传下来的props属性全部赋给button,然后只改变backgroundColor的值。

5.2 布局书写

5.2.1 尽量在一行写多个组件

推荐
1
2
3
<div>
<Main content={...} />
</div>
不推荐
1
<div>  <Main content={...} />  </div>

5.2.2 retuen组件使用圆括号

retuen组件使用圆括号
1
2
3
4
5
return (
<View>
<Main content={...} />
</View>
);

5.2.3 多个属性的书写

多个属性书写一行一个属性,使用相同的缩进。
非容器组件最后的结尾/>与最后一个属性一行。

多个属性书写一行一个属性,使用相同的缩进
1
2
3
4
5
6
<Button
text="abc"
onSomething={this.handleSomething} />
<Button
text="123"
onSomething={this.handleSomething} />

5.2.4 条件语句

  1. 多使用短路判断

多使用短路判断特性,&&, ||。因为这样的代码更紧凑更一目了然。

多使用短路判断
1
2
3
4
5
6
7
8
9
10
11
12
13
<View>
{isLoggedIn && <LoginButton />}
</View>

// ---- 上面的写法优于下面这样写 ----
let button = null;
if (isLoggedIn) {
button = <LoginButton />
}

<View>
{button}
</View>
  1. 多种结果时使用三元操作符
使用三元操作符让组件结构更一目了然
1
2
3
<View>
{isLoggedIn ? <LogoutButton /> : <LoginButton />}
</View>
  1. 如果组件的条件判断太复杂,则抽出使用函数式组件
抽出使用函数式组件
1
2
3
4
5
6
7
8
const renderMain = () => {
// ... 复杂判断
return (...返回生成的组件);
}

<View>
{renderMain()}
</View>
  1. 善于使用map等函数式方法循环输出组件

此法常用于列表类等子元素较多的组件。

使用map函数简化代码
1
2
3
<List>
{users.map(user => <UserInfo user={user} />)}
</List>

5.3 巧用Fragment

React只允许返回单个Root元素,比如下面这样返回会报错:

错误的返回
1
2
3
4
5
6
7
8
class UserInfoView extends React.Component {
render() {
return (
<Text>Name</Text> // 报错了,return时只能返回单个元素
<Text>Age</Text>
);
}
}

但有的时候我们不希望组件的层级太深,比如外面已经有一层view了,此封装组件不想再有一层View了,这时可以使用Fragment组件包裹:

使用React.Fragment包裹并列子组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
return (
<React.Fragment>
<Text>Name</Text>
<Text>Age</Text>
</React.Fragment>
);

// 你也可以像下面一样使用短语法,和上面效果是一样的
return (
<>
<Text>Name</Text>
<Text>Age</Text>
</>
);

6 小结

1

7 引用

  1. 《谈一谈 Normalize.css》- 会飞的贼xmy

1 例句摘抄

2 单词

2.1 breeze

[briːz]

n. 微风;轻而易举的事;煤屑;焦炭渣;小风波
vi. 吹微风;逃走

1
Make sth a breeze 让事情变得轻而易举

The flowers were gently swaying in the breeze .
花儿在微风中轻轻舞动。

It was a breeze. 这事不费吹灰之力

本章我们将探讨JavaScript中继承多态的设计及实现。

1 JavaScript的继承

首先,关于继承的概念我觉得无需多说了,这基本上属于程序员的入门课程了。

这里要多说的是,继承,其实也是一种代码复用的技术。我们上节说过,抽象与封装是一种代码复用技术,旨在将具有相同属性与行为的事物抽象出类型(在JavaScript中使用构造函数原型实现)。如果说抽象与封装复用的是一个类型模板的属性与行为,那继承则是复用一系列具有相近属性与行为的类型,这个结构有点像俄罗斯套娃,子类继承自父类,就像在父类这个娃娃上套个更大的子类大娃(在父类的基础上构建另外自己独特的部分)。而子类自己也可以有子类,在这之上所有祖先的代码都将复用。

1.1 继承的实现

因为有了第一章原型链的基础,本章我们将更好理解。就像是我很喜欢的一部电影《盗梦空间》空间一样,第一章中我们只是一层梦境,而这章将讲的是多层梦境而已。

从上一章我们知道,新建一个People构造函数function People,一个<<People.prototype>>原型就伴随着构造函数的定义而自动产生的。而当我们调用new People(),构造函数将返回一个已初始化完成的People对象,系统会自动将<<People.prototype>>原型对象的引用赋给此对象的_prop__成员。

而正因为__prop__的存在,产生了所谓的原型链。当我们访问对象的成员时,如果在本对象的散列表结构中没有找到相应的引用时,解释器会自动帮我们作向上查找,沿着其__proto__属性找到原型对象<<prototype>>。如果还是没有,会一直沿着原型的原型一路向上找,直到找到<<Object.prototype>>, 其__proto__为null。

那么我们现在就要定义我们的目标,要实现继承的复用原则,就是要让我们新定义的Student类型可以复用People类型的属性与方法。 – 一般地,我们使用prototype chan原型链来实现:

使用原型链来实现继承
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
       ┌───────────────────────┐
│ function People() │
│ │◀───────────────────────────────────────┐
├────────────┬──────────┤ ┌──────────────────────────┐ │
│ prototype │ │ │ People │ │
│ │ ─────┼─────────▶│ <<prototype>> │◀─┼───┐
└────────────┴──────────┘ ├────────────┬─────────────┤ │ │
△ │constructor │ ────────┼──┘ │
│ ├────────────┼─────────────┤ │
│ __proto__ │ Object │ │
│ (继承) │ │<<prototype>>│ │
├────────────┼─────────────┤ │
│ │ sayHi │ (function) │ │
└────────────┴─────────────┘ │
│ │
┌(增加的Student类型)─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ┐
│ │
│ ┌───────────────────────┐◀───────────────────────────────────────┐ │ │
│ function Student() │ ┌──────────────────────────┐ │ │
│ ├────────────┬──────────┤ │ Student │ │ │ │
│ prototype │ ─────┼───────┬──▶│ <<prototype>> │ │ │
│ └────────────┴──────────┘ │ ├────────────┬─────────────┤ │ │ │
▲ │ │constructor │ ───────┼─┘ │
│ │ │ ├────────────┼─────────────┤ │ │
│ │ │ __proto__ │ ───────┼─────┘
│ │ │ └────────────┴─────────────┘ │
│ │
│ │ │ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ │
┌───────────────────────┐ │
│ peter (new Student) │ │
├────────────┬──────────┤ │
│ __proto__ │ ─────┼───────┘
├────────────┼──────────┤
│ name │ 'peter' │
├────────────┼──────────┤
│ age │ 9 │
├────────────┼──────────┤
│ grade │ 2 │
└────────────┴──────────┘

上图中虚线框出的就是我们要增加的部分。现在我们增加了一个Student类型(使用Student函数),这在Java等高级面向对象语言中是那么容易,但是在JS中我们要做很多看起来繁杂的处理。

首先第一个要解决的问题是Student类型要复用在People构造函数中定义的成员。解决方案很简单,在Student的构造函数中再调用People构造函数就可以了,这个看起来有点像Java中的构造器重载:

复用父类中定义的成员示例
1
2
3
4
5
6
7
8
9
10
11
12
function People(name, age) {
this.name = name;
this.age = age;
}
People.prototype.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}

function Student(name, age, grade) {
People.call(this, name, age);
this.grade = grade;
}

这样当我们let peter = new Student('Peter', 9, 2)时,实际发生了下面的事:

1
2
3
var peter = new Object();
Student.call(peter, 'Peter', 9, 2); --> People.call(this, 'Peter', 9);
peter.__proto__ = Student.prototype;

我们这里简介下function.call(this),这个方法将调用指定方法,同时指定所调用方法的this(在介绍this的一章中我们将详细讲解)。

所以我们来分析上面的代码:

  1. var peter = new Object(); - 相当于new Student('Peter', 9, 2)时,解释器先帮我们基于Object生成了一个普通对象。
  2. Student.call(...) - 然后调用Student构造器,并将新生成的对象设为该构造器的this。在Student构造器中,给对象添加了grade成员,然后又继续效用此法调用People构造器,为此对象添加了name、age两个成员。
  3. 最后在返回对象前将其__proto__原型链的引用指向<<Student.prototype>>,使其可以复用类型原型上的成员变量与方法。

此时,生成的peter对象如上图一样了:

1
2
3
4
5
6
7
8
9
10
11
┌────────────────────────────────────┐
│ peter (new Student) │
├────────────┬───────────────────────┤
│ __proto__ │ <<Student.prototype>> │
├────────────┼───────────────────────┤
│ name │ 'peter' │
├────────────┼───────────────────────┤
│ age │ 9 │
├────────────┼───────────────────────┤
│ grade │ 2 │
└────────────┴───────────────────────┘

1.2 复用父类原型

但是还没完,还有一个问题,就是Student

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Details_of_the_Object_Model
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/this
https://www.cnblogs.com/leijee/p/7490822.html
https://www.jianshu.com/p/5cb692658704
https://www.runoob.com/w3cnote/js-call-apply-bind.html

2 性能

  • 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。
  • 试图访问不存在的属性时会遍历整个原型链,这个耗费也是略大。

在对效率要求高的场合,建议使用hasOwnProperty()Object.keys(),因为这两个方法不会遍历原型链。

比如想引用某个属性前,先使用hasOwnProperty()查看下,该对象的散列结构中是否存在此属性:

对象直接拥有此方法成员时才调用的示例
1
2
3
if (student.hasOwnProperty("sayHi")) {
student.sayHi();
}

我们说hasOwnProperty() 方法不会遍历原型链的意思就是,比如student对象的实际散列结构下如:

1

hasOwnProperty("sayHi")查找sayHi方法时,不会顺着其__proto__成员沿着原型链向上查找。

3 小结

4 引用

  1. 《重新介绍JavaScript》- 介绍JS很好的入门材料
  2. 《JavaScript高级程序设计》- (美)(Nicholas C.Zakas)扎卡斯 学习JS挺好的入门教材
  3. 《继承与原型链》- MDN web docs 继承与原型链, 很好,讲了性能,和原型实际
  4. [《Function函数与Object对象的关系》- 网文](https://www.cnblogs.com/nature-tao/p/9504712.html Function函数与Object对象的关系的一篇挺好的探索文章

本章将总结一个JavaScript中和变量操作相关的一些高级操作。主要为ECMAScript6及以上但是非常实用的高级特性。

1 对象的解构

ES6中,定义了一种高级的操作,被称之为解构(Destructuring)

解构。。。这个名称听起来很高级,其实说白了,就是一种快速从对象中取出想要的成员的语法糖。我觉得可能这个操作想起来就像是对象构建函数的逆操作所以这样命名吧(想想看,构造一个对象我们是往构造函数中传指定的几个参数来构造之。而解构则是从此对象中抽出其成员属性)。

  • 比如下面对数组的解构(就是从数组中抽出特定的成员):
数组的解构
1
2
3
4
5
6
7
var a = 1;
var b = 2;
var c = 3;

等价于 ==>

var [a, b, c] = [1, 2, 3];
  • 对一个对象的解构(就是从对象中抽出特定的成员):
对象的解构
1
2
3
4
5
6
7
8
let car = {
tie: 4,
engine: 1
}
等价于 ==>

let {tie, engine} = car;
// tie = 4, engine = 1;

1.1 解构可以使用默认值

解构时使用默认值
1
2
3
4
5
var {x, y = 5} = {x: 1};
console.log(x, y) // 1, 5

var { message: msg = "Something went wrong" } = {};
console.log(msg); // "Something went wrong”

1.2 常用到的场合

1.2.1 解构在import中的应用

import时,我们常会使用解构,比如:

import时使用解构
1
2
3
4
5
import {Component} from 'react'

// 这样,相当于从React中抽出Component,这样在下面使用时就可以省去成员引用
// class App extends React.Component
class App extends Component

1.2.2 解构在函数中的应用

在函数的形参中,我们也常常使用解构来快速取出想要的值。比如下面这个React纯函数组件,我们用({ selected = true, onClick = f => f }) => component来代替props => component,相当于快速地把要用到的属性从props中快速地取出来(其中还用到了设置解构默认值):

解构在函数中的应用
1
2
3
4
5
6
7
const Star = ({ selected = true, onClick = f => f }) => {
return (
<div className={(selected ? 'star selected' : 'star unselected')}
onClick={onClick}>
</div>
);
};

1.2.3 swap方法

使用解构可以非常优雅地实现swap交换变量的值,下面请欣赏:

使用解构优雅地实现swap
1
[x, y] = [y, x];

1.2.4 函数返回多个值

有了解构,可以优雅地让函数返回多个值。但是当然,前提是你的注释一定要写清楚:

利用解构让函数返回多个值
1
2
3
4
5
6
7
8
/**
* @method excuteTasks
* @return {[results]} 按task顺序组成响应结果对象数组,第一个为task1的响应,第二个为task2的响应,以此类推。。。
*/
function excuteTasks(...tasks) {
return [result1, reslut2, result3];
}
let [result1, reuslt2, result3] = excuteTasks(task1, task2, task3);

1.2.5 从json中取值

利用解构从json中取值
1
let {id, status = 0} = jsonData;

2 小结

3 引用

  1. 《重新介绍JavaScript》- 介绍JS很好的入门材料

JavaScript本身不提供一个 class 实现(在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 仍然是基于原型的), 而是一种基于原型的语言(prototype-based language) —— 同一类型的对象共有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain)

我看过许多文章,一上来就大讲prototypethis原型链等等“高深”的内容,把各种概念抛出来把人看得云里雾里、似懂非懂。而在这里,我用自己的思路和大家一起分析与学习下JS的面向对象技术。我将以面向对象的三大特性为切入点,尝试着深入了解JavaSctipr的面向对象设计思路与实现:

  1. 抽象与封装;
  2. 继承;
  3. 多态。

然后在这个探索中,我们再来着重弄清楚这几个“高级”问题:

  1. JS的对象、函数是怎样的关系;
  2. 什么是原型(prototype),什么是原型链
    3.执行上下文this

本章中,我们将主要讨论JavaScript是如何实现面向对象编程的封装的特性。

1 抽象与封装

我们说,封装主要做的是两件事:抽象复用

抽象是一种思想,讲究的是如何把现实世界映射到计算机世界中。而封装是计算机中的实现方式,通过设计一套组合方式把基础的数据结构装配成为抽象出来的整体。

要实现这两个概念,面向对象这套编程体系定义了对象这两种结构。其中,对象是一个个具体的客观实体,而是这些实体共有特征的抽象。比如,我们每个人都是具体的实体、是一个个对象;而我们人又具有共同的特性 – 都有名字、性别、都会说话等。基本的抽象模型如下:

类与对象伪代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Class People {
String name; // 姓名
int age; // 年龄

// 构造函数
People(name, age) {
this.name = name;
this.age = age;
}

sayHi() {
log('Hello,my name is ' + this.name);
}
}

People me = new People('Chauncey', 30);
People peter = new People('Peter', 20);

像上面的例子虽然是伪代码,但是很好理解。这里我定义了一个抽象的类 – People表示人; 然后使用类的构造函数又定义了具体的2个对象 – mepeter,该定义的方法是在构造函数前加new关键字 – new People('Chauncey', 30)

  • 通过抽象可以将客观世界映射到计算机世界中,这种思想为我们对程序设计提供了思想的基础。
  • 而通过封装,我们把个体及他们的属性组合了起来。
    比如说我现在要构建一个学生管理系统,抽象必然是我考虑的第一步。

1.1 抽象与封装在JavaScript中的实现

那么,在JavaScript中如何实现抽象的呢?具体来说,就是在JavaScript的世界中,对象是怎样表现的,又是怎样表现的。

我们先从对象入手来看:

1.2 从对象入手

首先开门见山,JavaScript是一种基于原型的语言,并没有类的概念。所以在JS中,只有对象,而对象其实就是一个散列表结构!

JavaScript中的对象,Object,其实可以简单理解成“名称-值”对(而不是键值对:现在,ES 2015 的映射表(Map),比对象更接近键值对),不难联想 JavaScript 中的对象与下面这些概念类似:

  • Python 中的字典(Dictionary)
  • Perl 和 Ruby 中的散列/哈希(Hash)
  • C/C++ 中的散列表(Hash table)
  • Java 中的散列映射表(HashMap)
  • PHP 中的关联数组(Associative array)

这样的设计让JavaScript的对象简单灵活,能应付各类复杂需求。正因为 JavaScript 中的一切(除了核心类型,core object)都是对象,所以 JavaScript 程序必然与大量的散列表查找操作有着千丝万缕的联系,因为散列表擅长的正是高速查找。

js中,可以直接使用字面的方式定义一个对象,就像定义一个散列表那么简单。比如下面的代码:

JavaScript直接使用字面的方式定义一个对象
1
2
3
4
5
6
7
let me = {
name: 'Chauncey',
age: 31,
sayHi: function () {
console.log('Hello, my name is ' + this.name);
}
}

像上面这样,我就定义了一个对象,而我们知道,JS中的对象其实是个散列表结构:

上面代码中对象的结构
1
2
3
4
5
6
7
8
9
┌───────────────────────┐     ┌──────────────────────────┐
│ me │ ┌─▶│ function sayHi() │
├────────────┬──────────┤ │ ├──────┬───────────────────┤
│ name │'Chauncey'│ │ │ f │ console.log(...) │
├────────────┼──────────┤ │ └──────┴───────────────────┘
│ age │ 31 │ │
├────────────┼──────────┤ │
│ sayHi() │ ─────┼──┘
└────────────┴──────────┘

上面的代码在内存中就是这样的一个结构。这样的设计很好实现,也就是当我们定义一个对象时,JS会自动为我们开辟一个散列表空间,其中表的key是对象成员的名字,而成员的值有两种:值类型引用类型,如果成员是一个基本类型(比如是数值),则直接存其值,这是值类型。如果成员变量是指向一个方法或对象,则为引用类型,就像C中的指针一样,它存的是一个指向所指方法或对象的引用

然后我们看到me这个对象的sayHi成员函数引用,指向的是一个sayHi()方法,在这里不得不提一个就是javaScript中的方法与其它语言中的方法并不相同。

1.3 初探javaScript中函数与对象的关系

在JavaScript中,函数与对象的关系是比较暧昧而复杂的,后面我们会详细介绍。但在这里,我们会大概讲一下JS中的函数,暂且不必太深入,我们先不求甚解地探索下。

1.3.1 函数也是一个对象

首先,在JavaScript中,函数也是一个对象。这从我上面画的图可以看出,函数实际上也是一个散列表的组成结构。这就和我们一般熟悉的编译型语言的方法很不同了。众所周知,在编译型语言中,方法是程序的重要组成结构,我们说进程就是运行着的程序。在编译型语言中,方法会被编译成一条条命令,然后放在只读的一块内存中,我们称之为代码段。

而在JS中,函数是一个对象,也是一个散列表结构,而其内一条条的语句,实际上就是当作一条字符串,就像上图一样,我用f表示函数的代码部分,对应的值是具体的代码。为什么可以这样?因为JavaScript是解释性语言,要运行一个方法只需要将之放入解释器就行,这就使其非常的灵活。

我们可以做个实验,如下代码示例:

下面两个定义函数的方法是一样的
1
2
3
4
5
6
7
8
9
10
11
function f1(x, y) {
x++;
return x + y;
}

// 等价于 ==>

let fn2 = new Function('x', 'y', 'x++; return x + y');

console.log(fn1(1, 2)); // 4
console.log(fn2(1, 2)); // 4 ==> 两个函数输出是一样的!

这里我们就看出,JS中声明一个方法其实就是定义了一个方法对象,而具体的方法体就是一段由JS表达式语句组成的字符串。

还有一个就是 –在JavaScript中,函数也是一等公民

1.3.2 函数是一等公民

一等公民”这个称呼起源于哪里已不好追究了,但他的意思主要就是,在JavaScript中,函数也是一个对象,而且是重要的一种一等对象。这样设计的好处在于,你可以将函数像普通对象一样传递(作为另一个函数的参数、作为函数的返回值,或者将之随便赋值给一个变量)。

比如在JS中你可以随便定义一个函数:

1
2
3
function sayHi() {
return 'Hi~';
}

你可以像定义一个字面对象一样给他添加成员变量、成员方法(这里只是意思上的区分,其实我们知道对象的散列表本质,成员变量或成员方法也好都只是添加一条key-value记录)。

1
2
3
4
sayHi.toWho = 'Peter';
sayHi.hello = function (toWho) { console.log('Hello, ' + toWho); }

sayHi.hello(sayHi.toWho); // output => Hello, Peter

这样一看,JS中function的特性和的要求很像,都可以指定成员与方法。而正因为函数的这个特性,让它成为了JavaScript面向对象编程中用于实现封装的主要工具。

1.3.3 使用函数实现类的封装

因为在JS中,函数被设计的无比强大,以至于设计者将面向对象的封装“重任”都交之于它(js语言的早期设计是一种极简风格,追求对关键字能省则省)。我们上小节说过,JS的function和高级面向对象语言的class很像,都可以指定成员与方法,于是可以用之来这样实现我们上面对“People”的封装:

用函数实现类的封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function People(name, age) {
this.name = name;
this.age = age;

this.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}
}

let me = new People('Chauncey', 30);
let peter = new People('Peter', 20);

me.sayHi(); // Hi~My name is Chauncey
peter.sayHi(); // Hi~My name is Peter

上面的代码其实很好懂,语义上就不作过多解释。有趣的是,在我们之前伪代码以及多数语言中,都是使用Class关键词来定义一个类,而在JS中索性就用function来定义了。这表现了JS极简的风格,当然,到ES6中还是引入了Class关键字,但那只是语法糖,JS并没有为之增加一个Class类型。

虽然经过我们上面的学习,这个例子已很容易理解了 –这就是JS实现的抽象与封装,用函数来实现类型的封装,定义对象实例们的通用属性与方法。基于构造函数生成具体的对象实例

所以,至此为止,我们已经掌握了在JavsScript中是如何实现面向对象的抽象与封装的。

但是这里我们还有会产生好奇,在JS中,new function()会发生什么事,对象是怎样基于函数生成的?这就涉及抽象与封装的另一个重要概念 – 复用

2 复用

我们现在抛开上面关于抽象与封装的概念想一想,为什么要有设计“类”与“对象”这两层数据结构? – 答案就是复用

因为在实际的操作中,我们操作的其实都是一个个具体的对象,而之所以抽象出“类”这个概念,就是为了复用。让具体的单个对象可以省去许多一样的模板代码。比如上面的People的例子,我可以每个对象都直接使用字面定义方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let me = {
name: 'Chauncey',
age: 31,
sayHi: function () {
console.log('Hello, my name is ' + this.name);
}
}

let Peter = {
name: 'Peter',
age: 20,
sayHi: function () {
console.log('Hello, my name is ' + this.name);
}
}

但是这样明显会出现许多重复的定义,而且当我想去对它们的共同结构作修改时,我不得不一个个地去修改个体对象的代码。因此,我们说 – 类的存在主要是为了代码复用。(注意这里复用有两层意义:一个是减少重复代码,一个是共用相同逻辑)

2.1 C++中类与对象的实现

我们来看看一般编译型语言中类与对象的实现。我这里用结构体举例而不是使用通用的高级语言的Class是因为一般大家都对struct的内存布局更加熟悉、其底层的理解更加简单。我们看一个例子:

1
2
3
4
5
6
7
8
9
struct People {
char name[10];
int age;
void sayHi() {
// ...
}
};
People *me = (struct People *)malloc(sizeof(struct People)));
People *peter = (struct People *)malloc(sizeof(struct People));

这里定义的People与上面我们用JS作的定义效果是一样的。我们都知道,结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存(正因为这个概念,所以在某些语言中,我们也常将对象称之为实例instance)。所以我们发现在编译语言的实现中,类(比如上面示例中的struct),更像是一个模板。而根据这个模板,生成的对象实例(结构体实例)在内存中表示为:

1
2
3
4
5
6
7
8
9
        ┌──────────┐
│ me │
├──────────┤
name │'Chauncey'│
├──────────┤
age │ 31 │
├──────────┤
sayHi() │ 0x???? │ 函数在代码段中的地址
└──────────┘

我们看到,在C++中,实例在内存中的表示与在JS中大不相同。在JS中,变量是使用散列表存储,根据key来快速寻址成员变量value。而C++中成员是使用偏移地址来表示的(比如说你要访问name变量,编译器会根据name变量类型算出其大小与相对对象首地址的偏移),这样的好处是其对象的结构更简单了,寻址也会较散列的形式都快些。但是缺点也很明显,就是类结构与实例结构是定死的,因为使用偏移寻址,后期想动态添加成员根本不可能。

在对比中学习可以让我们对一门学问了解的都深刻,现在我们知道,在C++中,复用是使用模板的形式来实现的,对象们共用了一份相同的内存布局,这样可以达到更高效率的操作,但是有失灵活性。

2.2 原型prototype

JavaScript中对复用的实现用了另外一种思路,它抽象出来了一个共用的底层对象 – prototype(我们常翻译为“原型”)。由这个底层对象来存放对象共性的东西。这样做是为什么呢?

比如我们探研下之前的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function People(name, age) {
this.name = name;
this.age = age;

this.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}
}

let me = new People('Chauncey', 30);
let peter = new People('Peter', 20);

console.log(me.sayHi === peter.sayHi); // output=> false

这里多说一下,咱的示例代码都可以简单地使用chrome的调试工具来测试,在最后的《小结-使用chrome调试js代码》一节中我们将附操作方法。

从上面的代码我们发现,两个对象的内部函数居然不是同一个。这当然啦,因为new People时, function内的代码都会执行一把,在这里我们发现this.sayHi相当于都重新赋值了一遍,也就是每创建一个对象都生成了一个sayHi()方法。

这里我们不得不又先抛出一个概念,我们new People(...)时究竟是在干嘛?

2.2.1 所有函数都可以是构造函数

我们现在细看一个People这个函数。有没有发现它和java或其它高级面向对象语言的中构造函数很像!没错,JavaScript就是这么精简!在JS中,只要你对函数使用new语句,就可以将此函数变成构造函数(注意,ES6中新增的键头函数不可作为构造函数,只能作为普通函数)。

为什么我要强调这个特性呢?因为JavaScript是这么精简,以至于我们要为之自己脑补许多代码。。。我们将上面People的代码脑补一下:

脑补js构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Class People {
name;
age;
sayHi;

function People(name, age) {
this.name = name;
this.age = age;

this.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}
}
}

这下对于new People()我们就好理解了,当我们对函数作new时,js会将之待作构造函数,并返回为我们生成好的对象。这点和高级面向对象语言比如Java是一样的,在Java中,new ClassName(param)也是调用该类的构造函数生成对象,并完成成员的初始化。

而在JS中并没有class的概念,但你可以更直接地将function“升级”为构造函数。使用new操作符和构造器函数结合来创建对象。我们现在翻回去看看People这个构造函数的定义,在里面this就代指你生成的对象,我们通过给this也就是生成的对象赋于同一的属性与方法,来完成对象的初始化。这样生成出来的对象都有相同的属性与方法,所以JS创造者大概可以说,“你看,我们没有Class关键字,面向对象的抽象与封装不是也实现的好好的吗”。

这样一来你就可以发现什么了吧,在构造函数function People(name, age)中我们对每个对象的sayhi方法都作了这样的赋值 – this.sayHi = function(),这就是每个对象的sayHi方法都不相同的原因(他们都创建了一份新的实例)。那我们怎样解决这个问题呢?JS的解决方案就是用所有对象共享一个公共的对象结构,把公用的属性与方法放在此公共结构上 – 这个结构就是prototype(原型)

现在我们把话题转回来。说回prototype

2.2.2 prototype__proto__与原型链

首先要注意下,经过上一节我们的脑补,我们知道早期JavaScript是一种极为精简的语言,其能省就省的风格造就了它连class关键字都懒得定义。但我们还是要清楚,这后面我们所说的function,其实大多数指的是面向对象中说的class的概念

我们现在的疑问是,所有对象共享的这个公共对象结构 – prototype – 是在何时生成的 – 答案是在函数定义的时候。每当我们使用function定义一个函数时(注意,新增的键头函数不会生成prototype),解释器会自动地为函数生成一个prototype对象(注意,prototype也是一个对象),并为该function增加一个成员属性prototype指向这个对象。

现在,我们就可以将公共的属性或方法定义在它的prototype对象上,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function People(name, age) {
this.name = name;
this.age = age;
this.sex = 1;
}
People.prototype.sayHi = function() { // <--- 注意这里
console.log('Hi~My name is ' + this.name);
}
People.hello = function() {
console.log('Hello');
}
People.Male = 1;
People.Female = 0;

let me = new People('Chauncey', 30);
me.sayHi(); // output=> Hi~My name is Chauncey
// me.hello(); 报错
People.hello(); // output=> 'hello'
me.sex = People.Male;
console.log('The sex of me is ' + ((me.sex === People.Male)? 'Male': 'Female'));
// output=> The sex of me is Male

现在,整个对象与函数的关系如下图:

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
┌───────────────────────┐
│ function People() │◀───────────────────────────────────────┐
├────────────┬──────────┤ ┌──────────────────────────┐ │
│ prototype │ ─────┼─────┐ │ People │ │
├────────────┼──────────┤ ├───▶│ <<prototype>> │ │
│ Male │ 1 │ │ ├────────────┬─────────────┤ │
├────────────┼──────────┤ │ │constructor │ ────────┼──┘
│ Female │ 0 │ │ ├────────────┼─────────────┤
├────────────┼──────────┤ │ │ __proto__ │ Object │
│ hello │(function)│ │ │ │<<prototype>>│
└────────────┴──────────┘ │ ├────────────┼─────────────┤
| │ │ sayHi │ (function) │
me = new People() | │ └────────────┴─────────────┘
▼ │
┌───────────────────────┐ │
│ me │ │
├────────────┬──────────┤ │
│ __proto__ │ ─────┼─────┘
├────────────┼──────────┤
│ name │'Chauncey'│
├────────────┼──────────┤
│ age │ 31 │
├────────────┼──────────┤
│ sex │ 1 │
└────────────┴──────────┘

我们怎么验证此图的正确性呢?按《小结》里的方法在chrome调试器里输入上述示例代码,将`People`与`me`都打印出来就知道了。

为什么调用me.sayHi()可以找到正确的方法呢?这里又有一个重要概念,就是当使用new生成JS对象时,解释器会自动为之添加一个__proto__成员,其指向People的原型People.prototype!!

因此,当你执行:

1
var me = new People('Chauncey', 31);

JavaScript 实际上执行的是:

1
2
3
var me = new Object();
People.call(me, 'Chauncey', 31);
me.__proto__ = People.prototype;
  • 原型链

而当我们访问对象的成员时,解释器会自动帮我们作向上查找,当在本对象的散列表结构中没有找到相应的引用时,会沿着其__proto__属性找到我们所说的公共对象prototype,到其原型对象中去找!这有点像Objective-C中的消息转发机制。

这种机制实现了JS中的复用特性,现在你可以想想看,无论我用People定义多少个对象,他们都可以共用一个prototype对象。prototype是一个可以被所有实例对象共享的对象,称之为原型。它是一个名叫原型链(prototype chain)的查询链的一部分。当访问一个对象(比如People的实例me)的属性或方法时,会先在此对象已定义的成员中找,如果没找到,就会到其prototype中去找!没错,这看起来有点像继承关系的到父类中去找一样(比如sayHi这个函数,我们先在me这个对象的散列表中找。结果发现没找到,就顺着其__prop__属性找到它的原型People.prototype,最终找到sayHi()方法并调用之)。

然后我们还要注意一点的是,me.hello();失败了,也就是在对象中直接调用模板函数的方法是行不通的,因为变量的查找只会沿着__proto__指向往上查找。模板函数上直接定义的方法只能通过模板函数调用,这也是很多高级语言中实现的所谓实例变量(在构造器内使用this定义的变量成员)与静态变量(在函数上直接定义的变量成员)。

2.2.3 关于prototype的oneMoreThing

我们注意到People.prototype中有个constructor指回了People函数。这种设计固然给从原型出发找到其相应的模板函数提供了路径,另一方面,这种类似“循环引用”的设计也给人以原型与模板函数一一绑定的感觉。

还有一点是我们注意到我把People.prototype__proto__属性也画了出来。那当然咯,因为原型也是对象,是对象就有__proto__属性。而People.prototype__proto__指向的是Object.prototype。这是什么意思呢?

这说明所有原型都是解释器自动地使用new Object()生成出来的~也说明了当对象的属性在原型上也找不到时,还会继承向上查找,一直找到Object原型上去!!这不符合面向对象的思想吗?在面向对象的世界中,一切对象皆是Object。我们不妨作如下验证:

1
2
3
console.log(People.prototype.__proto__ === Object.prototype); // ture
console.log(me.__proto__.__proto__ === Object.prototype); // true
console.log(me.toString()); // [object Object]

这里给出了验证,而且me.toString()的调用成功也说明了一点,就是JS的成员查找可以顺着__proto__原型链一直往上找,使得Object的方法也可以为子类们享用。

好了,大家看到这里,想必已经对JS的面向对象编程有了个大概的了解。因为抽象和封装是面向对象的基础,我们今天了解了其抽象和封装的实现,也就是掌握了基oop的基础。这为我们接下来的学习也奠定了根基,下章我们将一起学习另一个特性,也就是继承在JS中是如何实现的。

这里咱们来小结一下:

3 小结

3.1 内容小结

  1. JavaScript是基于原型的面向对象语言,并没有Class的概念。面向对象的抽象与封装的实现主要是由objectfunctionprototype三种主要结构来构建。
  2. 其中object指的是具体的对象实例,本质上是一个散列表结构;function本质上是构造函数,但在其内部实现了为所生成的对象定义相同属性与方法的抽象作用;而prototype原型的出现是为了解决面向对象的复用问题,我们说这里复用有两层意义:一个是减少重复代码,一个是共用相同逻辑。
  3. JS中函数是一个对象,也是一等公民,可以自由赋值、自由传递(作为方法的参数或返回值等)。
  4. JS中没有class的概念,你可以更直接地将function“升级”为构造函数。使用new操作符和构造器函数结合来创建对象 – new People()
  5. prototype原型是伴随着function构造函数的定义而自动产生的,使用func.prototypeobj.__prop__可以直接访问到。
  6. 而正因为__prop__的存在,产生了所谓的原型链。当我们访问对象的成员时,如果在本对象的散列表结构中没有找到相应的引用时,解释器会自动帮我们作向上查找,沿着其__proto__属性找到原型对象prototype。如果还是没有,会一直沿着原型的原型一路向上找,直到找到Object.prototype, 其__proto__为null。

3.2 使用chrome调试js代码

随便打开chrome,按下alt + cmd + j可以打开调试模式。

选中consoletab,这个就是调试工具的调试终端。比如我们在里面输入如下代码(使用):

1
2
3
function test() {
console.log('--')
}

然后你就可以输入test, test.prototype去查看对象的组成。比如我们查看test.prototype,就可以看到输出:

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
test.prototype
{constructor: ƒ}
constructor: ƒ test()
arguments: null
caller: null
length: 0
name: "test"
prototype: {constructor: ƒ}
__proto__: ƒ ()
[[FunctionLocation]]: VM30968:1
[[Scopes]]: Scopes[2]
__proto__:
constructor: ƒ Object()
hasOwnProperty: ƒ hasOwnProperty()
isPrototypeOf: ƒ isPrototypeOf()
propertyIsEnumerable: ƒ propertyIsEnumerable()
toLocaleString: ƒ toLocaleString()
toString: ƒ toString()
valueOf: ƒ valueOf()
__defineGetter__: ƒ __defineGetter__()
__defineSetter__: ƒ __defineSetter__()
__lookupGetter__: ƒ __lookupGetter__()
__lookupSetter__: ƒ __lookupSetter__()
get __proto__: ƒ __proto__()
set __proto__: ƒ __proto__()

3.3 本章相关命令与方法

js中,可以直接使用字面的方式定义一个对象,就像定义一个散列表那么简单。下面罗列了javaScript中创建对象的几个方法:

  1. 使用new关键字;
  2. 创建字面值对象;
  3. 使用Object.create(obj)基于现有对象创建对象
对象相关函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建
let obj = new Object();
let obj2 = {}; // 与上面的创建一个对象相同
var obj3 = Object.create(obj1); // 基于现有对象创建对象
// 定义一个复杂的对象
let obj = {
name: "Carrot",
details: {
color: "orange",
size: 12
},
sayHi: function (params) {
console.log('Hi~');
}
};
// 对象的属性可以通过链式(chain)表示方法进行访问:
obj.details.color; // orange
obj["details"]["size"]; // 12

3.4 JS面向对象的抽象与封装的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function People(name, age) {
this.name = name;
this.age = age;
this.sex = 1;
}
People.prototype.sayHi = function() {
console.log('Hi~My name is ' + this.name);
}
People.hello = function() {
console.log('Hello');
}
People.Male = 1;
People.Female = 0;

let me = new People('Chauncey', 30);
me.sayHi(); // Hi~My name is Chauncey
// me.hello(); 报错
People.hello(); // 'hello'
me.sex = People.Male;
console.log('The sex of me is ' + ((me.sex === People.Male)? 'Male': 'Female')); // 'The sex of me is Male'

4 引用

  1. 《重新介绍JavaScript》- 介绍JS很好的入门材料
  2. 《JavaScript高级程序设计》- (美)(Nicholas C.Zakas)扎卡斯 学习JS挺好的入门教材
  3. 《继承与原型链》- MDN web docs 继承与原型链, 很好,讲了性能,和原型实际
  4. [《Function函数与Object对象的关系》- 网文](https://www.cnblogs.com/nature-tao/p/9504712.html Function函数与Object对象的关系的一篇挺好的探索文章
0%