0%

Tensorflow 并不是一个严格意义上的机器学习库,它是一个使用图来表示计算的通用计算库。它的核心功能由 C++ 实现,通过封装,能在各种不同的语言下运行。它的 Golang 版和 Python 版不同,Golang 版 Tensorflow 不仅能让你通过 Go 语言使用 Tensorflow,还能让你理解 Tensorflow 的底层实现。

封装

根据官方说明,Tensorflow 开发者发布了以下内容:

  • C++ 源码:底层和高层的具体功能由 C ++ 源码实现,它是真正 Tensorflow 的核心。

  • Python 封装与Python 库:由 C++ 实现自动生成的封装版本,通过这种方式我们可以直接用 Python 来调用 C++ 函数:这也是 numpy 的核心实现方式。

    Python 库通过将 Python 封装版的各种调用结合起来,组成了各种广为人知的高层 API。

  • Java 封装

  • Go 封装

作为一名 Gopher 而非一名 java 爱好者,我对 Go 封装给予了极大的关注,希望了解其适用于何种任务。

译注,这里说的”封装“也有说法叫做”语言界面“

Go 封装

upload successful

图为 Gopher(由 Takuya Ueda @tenntenn 创建,遵循 CC 3.0 协议)与 Tensorflow 的 Logo 结合在一起。


首先要注意的是,代码维护者自己也承认了,Go API 缺少 Variable 支持,因此这个 API 仅用于使用训练好的模型,而不能用于进行模型训练。

在文档 Installing Tensorflow for Go 中已经明确提到:

TensorFlow 为 Go 编程提供了一些 API。这些 API 特别适合加载在 Python 中创建的模型,让其在 Go 应用 中运行。

如果我们对训练机器学习模型没兴趣,那这个限制是 OK 的。

但是,如果你打算自己训练模型,请看下面给的建议:

作为一名 Gopher,请让 Go 保持简洁!使用 Python 去定义、训练模型,在这之后你随时都可以用 Go 来加载训练好的模型!(意思就是他们懒得开发呗)

简而言之,golang 版 tensorflow 可以导入与定义常数图(constant graph)。这个常数图指的是在图中没有训练过程,也没有需要训练的变量。

让我们用 Golang 深入研究 Tensorflow 吧!首先创建我们的第一个应用。

我建议读者在阅读下面的内容前,先准备好 Go 环境,以及编译、安装好 Tensorflow Go 版(编译、安装过程参考 README)。

理解 Tensorflow 的结构

先复习一下什么是 Tensorflow 吧!(这是我个人的理解,和官网的有所不同)

TensorFlow™ 是一个采用数据流图(data flow graphs),用于数值计算的开源软件库。节点(Nodes)在图中表示数学操作,图中的线(edges)则表示在节点间相互联系的多维数据数组,即张量(tensor)。

我们可以把 Tensorflow 看做一种类似于 SQL 的描述性语言,首先你得确定你需要什么数据,它会通过底层引擎(数据库)分析你的查询语句,检查你的句法错误和语法错误,将查询语句转换为私有语言表达式,进行优化之后运算得出计算结果。这样,它能保证将正确的结果传达给你。

因此,我们无论使用什么 API 实质上都是在描述一个图。我们将它放在 Session 中作为求值的起点,这样做确定了这个图将会在这个 Session 中运行。

了解这一点,我们可以试着定义一个计算操作的图,并将其放在一个 Session 中进行求值。

API 文档中明确告知了 tensorflow(简称 tf)包与 op 包中的可用方法列表。

在这个列表中我们可以看到,这两个包中包含了一切我们需要用来定义与评价图的方法。

tf 包中包含了各种构建基础结构的函数,例如 Graph(图)。op 包是最重要的包,它包含了由 C++ 实现自动生成的绑定等功能。

现在,假设我们要计算 AAA 与 xxx 的矩阵乘法:

我假定你们都熟悉 tensorflow 图的定义,都了解 placeholder 并知道它们的工作原理。

下面的代码是一位 Tensorflow Python 用户第一次尝试时会写的代码。让我们给这个文件取名为 attempt1.go

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
package main

import (
"fmt"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)

func main() {
// 第一步:创建图

// 首先我们需要在 Runtime 定义两个 placeholder 进行占位
// 第一个 placeholder A 将会被一个 [2, 2] 的 interger 类型张量代替
// 第二个 placeholder x 将会被一个 [2, 1] 的 interger 类型张量代替

// 接下来我们要计算 Y = Ax

// 创建图的第一个节点:让这个空节点作为图的根
root := op.NewScope()

// 定义两个 placeholder
A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))

// 定义接受 A 与 x 输入的 op 节点
product := op.MatMul(root, A, x)

// 每次我们传递一个域给一个操作的时候,
// 我们都要将操作放在在这个域下。
// 如你所见,现在我们已经有了一个空作用域(由 newScope)创建。这个空作用域
// 是我们图的根,我们可以用“/”表示它。

// 现在让 tensorflow 按照我们的定义建立图吧。
// 依据我们定义的 scope 与 op 结合起来的抽象图,程序会创建相应的常数图。

graph, err := root.Finalize()
if err != nil {
// 如果我们错误地定义了图,我们必须手动修正相关定义,
// 任何尝试自动处理错误的方法都是无用的。

// 就像 SQL 查询一样,如果查询不是有效的语法,我们只能重写它。
panic(err.Error())
}

// 如果到这一步,说明我们的图语法上是正确的。
// 现在我们可以将它放在一个 Session 中并执行它了!

var sess *tf.Session
sess, err = tf.NewSession(graph, &tf.SessionOptions{})
if err != nil {
panic(err.Error())
}

// 为了使用 placeholder,我们需要创建传入网络的值的张量
var matrix, column *tf.Tensor

// A = [ [1, 2], [-1, -2] ]
if matrix, err = tf.NewTensor([2][2]int64{ {1, 2}, {-1, -2} }); err != nil {
panic(err.Error())
}
// x = [ [10], [100] ]
if column, err = tf.NewTensor([2][1]int64{ {10}, {100} }); err != nil {
panic(err.Error())
}

var results []*tf.Tensor
if results, err = sess.Run(map[tf.Output]*tf.Tensor{
A: matrix,
x: column,
}, []tf.Output{product}, nil); err != nil {
panic(err.Error())
}
for _, result := range results {
fmt.Println(result.Value().([][]int64))
}
}

上面的代码写好了注释,我建议读者阅读上面的每一条注释。

现在,这位 Tensorflow Python 用户自我感觉良好,认为他的代码能够成功编译与运行。让我们试一试吧:

go run attempt1.go

然后他会看到:

panic: failed to add operation "Placeholder": Duplicate node name in graph: 'Placeholder'

等等,为什么会这样呢?

问题很明显。上面代码里出现了 2 个重名的“Placeholder”操作。

第 1 课:node IDs

每次在我们调用方法定义一个操作的时候,不管他是否在之前被调用过,Python API 都会生成不同的节点

所以,下面的代码没有任何问题,会返回 3。

1
2
3
4
5
6
import tensorflow as tf
a = tf.placeholder(tf.int32, shape=())
b = tf.placeholder(tf.int32, shape=())
add = tf.add(a,b)
sess = tf.InteractiveSession()
print(sess.run(add, feed_dict={a: 1,b: 2}))

我们可以验证一下这个问题,看看程序是否创建了两个不同的 placeholder 节点: print(a.name, b.name)

它打印出 Placeholder:0 Placeholder_1:0

这样就清楚了,a placeholder 是 Placeholder:0b placeholder 是 Placeholder_1:0

但是在 Go 中,上面的程序会报错,因为 Ax 都叫做 Placeholder。我们可以由此得出结论:

每次我们调用定义操作的函数时,Go API 并不会自动生成新的名称。因此,它的操作名是固定的,我们没法修改。

提问时间:

  • 关于 Tensorflow 的架构我们学到了什么?

    图中的每个节点都必须有唯一的名称。所有节点都是通过名称进行辨认。

  • 节点名称与定义操作符的名称是否相同?

    是的,也可说节点名称是操作符名称的最后一段。

接下来让我们修复节点名称重复的问题,来弄明白上面的第二个提问。

第 2 课:作用域

正如我们所见,Python API 在定义操作时会自动创建新的名称。如果研究底层会发现,Python API 调用了 C++ Scope 类中的 WithOpName 方法。

下面是该方法的文档及特性,参考 scope.h

1
2
3
/// 返回新的作用域。所有在返回的作用域中的 op 都会被命名为
/// <name>/<op_name>[_<suffix].
Scope WithOpName(const string& op_name) const;

注意这个方法,返回一个作用域 Scope 来对节点进行命名,因此节点名称事实上就是作用域 Scope

Scope 就是从根 /(空图)追溯至 op_name完整路径

WithOpName 方法在我们尝试添加一个有着相同的 /op_name 路径的节点时,为了避免在相同作用域下有重复的节点,会为其加上一个后缀 _<suffix><suffix> 是一个计数器)。

了解了以上内容,我们可以通过在 type Scope 中寻找 WithOpName 来解决重复节点名称的问题。然而,Go tf API 中没有这个方法。

如果查阅 type Scope 的文档,我们可以看到唯一能返回新 Scope 的方法只有 SubScope(namespace string)

下面引用文档中的内容:

SubScope 将会返回一个新的 Scope,这个 Scope 能确保所有的被加入图中的操作都被放置在 ‘namespace’ 的命名空间下。如果这个命名空间和作用域中已经存在的命名空间冲突,将会给它加上后缀。

这种加后缀的冲突处理和 C++ 中的 WithOpName 方法不同WithOpName 是在操作名后面suffix,它们都在同样的作用域内(例如 Placeholder 变成 Placeholder_1),而 Go 的 SubScope 是在作用域名称后面suffix

这将导致这两种方法会生成完全不同的图(节点在不同的作用域中了),但是它们的计算结果却是一样的。

让我们试着改一改 placeholder 定义,让它们定义两个不同的节点,然后打印 Scope 名称。

让我们创建 attempt2.go ,将下面几行

1
2
A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))

改成

1
2
3
4
5
// 在根定义域下定义两个自定义域,命名为 input。这样
// 我们就能在根定义域下拥有 input/ 和 input_1/ 两个定义域了。
A := op.Placeholder(root.SubScope("input"), tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root.SubScope("input"), tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))
fmt.Println(A.Op.Name(), x.Op.Name())

编译、运行: go run attempt2.go,输出结果:

1
input/Placeholder input_1/Placeholder

提问时间:

  • 关于 Tensorflow 的架构我们学到了什么?

    节点完全由其定义所在的作用域标识。这个”作用域“是我们从图的根节点追溯到指定节点的一条路径。有两种方法来定义执行同一种操作的节点:1、将其定义放在不同的作用域中(Go 风格)2、改变操作名称(我们在 C++ 中可以这么做,Python 版会自动这么做)

现在,我们已经解决了节点命名重复的问题,但是现在我们的控制台中出现了另一个问题:

panic: failed to add operation "MatMul": Value for attr 'T' of int64 is not in the list of allowed values: half, float, double, int32, complex64, complex128

为什么 MatMul 节点的定义出错了?我们要做的仅仅是计算两个 tf.int64 矩阵的乘积而已!似乎 MatMul 偏偏不能接受 int64 的类型。

Value for attr ‘T’ of int64 is not in the list of allowed values: half, float, double, int32, complex64, complex128

上面这个列表是什么?为什么我们能计算 2 个 int32 矩阵的乘积却不能计算 int64 的乘积?

下面我们将解决这个问题。

第 3 课:Tensorflow 类型系统

让我们深入研究 源代码 来看 C++ 是如何定义 MatMul 操作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
REGISTER_OP("MatMul")
.Input("a: T")
.Input("b: T")
.Output("product: T")
.Attr("transpose_a: bool = false")
.Attr("transpose_b: bool = false")
.Attr("T: {half, float, double, int32, complex64, complex128}")
.SetShapeFn(shape_inference::MatMulShape)
.Doc(R"doc(
Multiply the matrix "a" by the matrix "b".
The inputs must be two-dimensional matrices and the inner dimension of
"a" (after being transposed if transpose_a is true) must match the
outer dimension of "b" (after being transposed if transposed_b is
true).
*Note*: The default kernel implementation for MatMul on GPUs uses
cublas.
transpose_a: If true, "a" is transposed before multiplication.
transpose_b: If true, "b" is transposed before multiplication.

这几行代码为 MatMul 操作定义了一个接口,由 REGISTER_OP 宏对此操作做出了如下描述:

  • 名称: MatMul
  • 参数: a, b
  • 属性(可选参数): transpose_a, transpose_b
  • 模版 T 支持的类型: half, float, double, int32, complex64, complex128
  • 输出类型: 自动识别
  • 文档

这个宏没有包含任何 C++ 代码,但是它告诉了我们当在定义一个操作的时候,即使它使用模版定义,我们也需要指定特定类型 T 支持的类型(或属性)列表。

实际上,属性 .Attr("T: {half, float, double, int32, complex64, complex128}")T 的类型限制在了这个类型列表中。 tensorflow 教程中提到,当时模版 T 时,我们需要对所有支持的重载运算在内核进行注册。这个内核会使用 CUDA 方式引用 C/C++ 函数,进行并发执行。

MatMul 的作者可能是出于以下 2 个原因仅支持上述类型而将 int64 排除在外的:

  1. 疏忽:这个是有可能的,毕竟 Tensorflow 的作者也是人类呀!
  2. 为了支持不能使用 int64 的设备,可能这个特性的内核实现不能在各种支持的硬件上运行。

回到我们的问题中,已经很清楚如何解决问题了。我们需要将 MatMul 支持类型的参数传给它。

让我们创建 attempt3.go ,将所有 int64 的地方都改成 int32

有一点需要注意:Go 封装版 tf 有自己的一套类型,基本与 Go 本身的类型 1:1 相映射。当我们要将值传入图中时,我们必须遵循这种映射关系(例如定义 tf.Int32 类型的 placeholder 时要传入 int32)。从图中取值同理。

*tf.Tensor 类型将会返回一个张量 evaluation,它包含一个 Value() 方法,此方法将返回一个必须转换为正确类型的 interface{}(这是从图的结构了解到的)。

运行 go run attempt3.go,得到结果:

1
2
input/Placeholder input_1/Placeholder
[[210] [-210]]

成功了!

下面是 attempt3 的完整代码,你可以编译并运行它。(这是一个 Gist,如果你发现有啥可以改进的话欢迎来https://gist.github.com/galeone/09657143df49a90536f4ac4893c64696贡献代码)

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
package main                                        

import (
"fmt"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)

func main() {
// 第一步:创建图

// 首先我们需要在 Runtime 定义两个 placeholder 进行占位
// 第一个 placeholder A 将会被一个 [2, 2] 的 interger 类型张量代替
// 第二个 placeholder x 将会被一个 [2, 1] 的 interger 类型张量代替

// 接下来我们要计算 Y = Ax

// 创建图的第一个节点:让这个空节点作为图的根
root := op.NewScope()

// 定义两个 placeholder
// 在根定义域下定义两个自定义域,命名为 input。这样
// 我们就能在根定义域下拥有 input/ 和 input_1/ 两个定义域了。
A := op.Placeholder(root.SubScope("input"), tf.Int32, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root.SubScope("input"), tf.Int32, op.PlaceholderShape(tf.MakeShape(2, 1)))
fmt.Println(A.Op.Name(), x.Op.Name())

// 定义接受 A 与 x 输入的 op 节点
product := op.MatMul(root, A, x)

// 每次我们传递一个域给一个操作的时候,
// 我们都要将操作放在在这个域下。
// 如你所见,现在我们已经有了一个空作用域(由 newScope)创建。这个空作用域
// 是我们图的根,我们可以用“/”表示它。

// 现在让 tensorflow 按照我们的定义建立图吧。
// 依据我们定义的 scope 与 op 结合起来的抽象图,程序会创建相应的常数图。
graph, err := root.Finalize()
if err != nil {
// 如果我们错误地定义了图,我们必须手动修正相关定义,
// 任何尝试自动处理错误的方法都是无用的。

// 就像 SQL 查询一样,如果查询不是有效的语法,我们只能重写它。
panic(err.Error())
}

// 如果到这一步,说明我们的图语法上是正确的。
// 现在我们可以将它放在一个 Session 中并执行它了!

var sess *tf.Session
sess, err = tf.NewSession(graph, &tf.SessionOptions{})
if err != nil {
panic(err.Error())
}

// 为了使用 placeholder,我们需要创建传入网络的值的张量
var matrix, column *tf.Tensor

// A = [ [1, 2], [-1, -2] ]
if matrix, err = tf.NewTensor([2][2]int32{{1, 2}, {-1, -2}}); err != nil {
panic(err.Error())
}
// x = [ [10], [100] ]
if column, err = tf.NewTensor([2][1]int32{{10}, {100}}); err != nil {
panic(err.Error())
}

var results []*tf.Tensor
if results, err = sess.Run(map[tf.Output]*tf.Tensor{
A: matrix,
x: column,
}, []tf.Output{product}, nil); err != nil {
panic(err.Error())
}
for _, result := range results {
fmt.Println(result.Value().([][]int32))
}
}

提问时间:

关于 Tensorflow 的架构我们学到了什么?

每个操作都有自己的一组关联内核。Tensorflow 是一种强类型的描述性语言,它不仅遵循 C++ 类型规则,同时要求在 op 注册时需定义好类型才能实现其功能。

总结

使用 Go 来定义与处理一个图让我们能够更好地理解 Tensorflow 的底层结构。通过不断地试错,我们最终解决了这个简单的问题,一步一步地掌握了图、节点以及类型系统的知识。

如果你觉得这篇文章有用,请点个赞或者分享给别人吧~

发布于掘金 https://juejin.im/post/59420951128fe1006a1960f8

Reference

Stanford CS229 notes 12: http://cs229.stanford.edu/notes/cs229-notes12.pdf

cs229-notes12.pdf

Summary

在CS229 notes 中提到了强化学习的意义:

In the reinforcement learning framework, we will instead provide our algorithms only a reward function, which indicates to the learning agent when it is doing well, and when it is doing poorly.

个人的理解就是强化学习就是让 agent 根据环境包含的信息与强化信号量判断策略的选择,同时策略的不同造成的结果会以反馈的形式产生强化信号量给 agent,最终 agent 以“得到最大化的正强化信号量”为标准,做出最佳的策略选择。

换句话说,强化学习不会给模型提供任何“正确的决策”规则,只会给 agent 从环境状态得来的强化信号量,通过这种方式,agent 在行动=>评价=>学习的过程中学习到了知识,学到了如何做出让评价最好的决策方式。

Markov decision processes (MDP)

马尔科夫决策过程为一个包含5个元素的元组

\[ MDP = (S,A,{P_{sa}},\gamma,R)\]

其中:

S 为 states,状态集,包含所有 agent 可能处于的状态。

A 为 actions,行动集,包含了所有 agent 在各种状态下可以采取的行动。

\(P_{sa}\) 为概率,代表了 agent 在 s 状态下做出 a 行动的概率

\(\gamma\) 的值 \(\gamma in [0,1)\),称为“discount factor”,可以理解为“折算率”

R 为奖励函数(reward function),其值由\(S\times A \mapsto R\)\(S \mapsto R\)决定。

马尔科夫决策过程即为 agent 从初状态\(s_0\)开始行动,在马尔科夫决策的 A(行动集)中选择一种行动方式 \(a_0 \in A\),到达第二个状态\(s_1\),接着选择\(a_1\)……

\[s_0 \overset{a_0}{\rightarrow} s_1\overset{a_1}{\rightarrow} s_2\overset{a_2}{\rightarrow} s_3\overset{a_3}{\rightarrow} ...\]

这个过程的价值(payoff)记作

\[R(s_0,a_0) + \gamma R(s_1,a_1)+\gamma^2 R(s_2,a_2) + ...\]

简写为

\[r_0 + \gamma r_1 + \gamma^2 r_2 + ...\]

\(\gamma^i\)会越来越小,因此越后面的 R 权值越小。

当 MDP 确定后,每次决策时的状态、行为都是确定的,为了让 agent 在任意状态做出最佳的行为让状态尽量达到最好的情况(即获得最大的奖励值),需要确定一组策略,让整个过程的价值尽量最大化。

整个过程的期望记为:

\[E[r_0 + \gamma r_1+\gamma^2 r_2 + ...]\]

将策略记为\(\pi\),规定了任意情况下的\(s \rightarrow a\),因此可以记为:

\[a = \pi (s)\]

这个策略的价值函数(value function)记为

\[V^\pi(s)=E[r_0 + \gamma r_1+\gamma^2 r_2 + ...| r_0=R(s),\pi]\]

上式表示的是在起点为\(s\)、使用策略[latex]pi[/latex]的情况下的价值函数值。

状态值函数

策略评价

对上式变换:

\[V^\pi(s)=E[r_0 + \gamma r_1+\gamma^2 r_2+ ...| r_0=R(s),\pi]\]

\[V^pi(s_t)=E_\pi[r_t + \gamma r_{t+1} + \gamma^2 r_{t+2} + ...]\]

\[V^\pi(s_t) = E_\pi[r_t + \gamma V^\pi(s_{t+1})]\]

上式的\(s_{t+1}\)指的是\(s_t\)状态经过策略\(\pi\)之后到达的下一个状态。根据\(P_{s\pi(s)}\)对上式期望值进行展开,同时考虑在状态\(s_t\)下的所有策略动作情况:

\[V^\pi(s_t) = E_\pi[r_t + \gamma V^\pi(s_{t+1})]\]

\[=\sum_{s_{t+1}} P(s_{t+1}|s_t,\pi(s)) [R(s_t,\pi(s),s_{t+1}) + \gamma V^\pi(s_{t+1})] \]

上式中的\(P(s_{t+1}|s_t,\pi(s))\)指的是在\(s_t\)状态下进行策略\(\pi(s)\)到达状态 \(s_{t+1}\)的概率,\(R(s_t,\pi(s),s_{t+1})\)为从状态 \(s_t\) 转移到状态 \(s_{t+1}\) 的期望回报值(其实就是之前的\(s_t\))。

根据贝尔曼最优化方程,有

\[V^*(s_t) = \max_a \sum_{s_{t+1}} P(s_{t+1}|s_t,pi(s)) [R(s_t,\pi(s),s_{t+1}) + \gamma V^*(s_{t+1})]\]

因此,对于任意一种策略\(\pi\),我们都能通过这种方法对各个动作得到的价值函数值进行最大化迭代,逐渐逼近最大价值函数值。

\[ \text{input pi} \\ \text{While }\Delta < \theta \{\\ \text{tmp} = V(s)\\ V(s) = \max_a \sum_{s_{t+1}} P(s_{t+1}|s_t,\pi(s))[R(s_t,\pi(s),s_{t+1}) +\gamma V^*(s_{t+1})\\ \Delta = \max(\Delta,|V(s) - \text{tmp}|)\\ \} \\ \text{output } V(s) \approx V^\pi(s)\]

策略改进

假设有\(\pi\)\(\pi_1\)两种策略,如果\(Q^\pi(s,\pi_1(s)) \geq V^\pi(s)\)(也就是\(V^\pi_1(s) \geq V^\pi\)),那么说明\(\pi_1\)的效果一定比\(\pi\)要好。

以此为依据,可以在每个状态 s 下对决策允许集进行遍历,计算所有决策会产生的价值函数值,根据贪心策略找到产生做大价值函数值的策略\(\pi^*\),它即为效果最好的策略。

\[\pi_1 = \arg \max_a Q^\pi(s,a)\]

\[=\arg \max_a \sum_{s_{t+1}} P(s_{t+1}|s_t,a) [R(s_t,a,s_{t+1}) + \gamma V^*(s_{t+1})] \]

策略迭代

由上面的策略评价进行计算,能够得到当前策略下最大的价值函数值,接着使用策略改进,得到更好的策略\(\pi_1\),再接着对这个\(\pi_1\)再次使用策略评估……这样一遍又一遍地迭代计算,最终能得到趋近最佳值的策略价值函数值与对应的策略。

upload successful

值迭代

upload successful

值迭代与策略迭代的区别

https://www.zhihu.com/question/41477987

用 flask 做 API 时,发现在 client 的请求需要很长的时间才能得到响应(差不多要 20 多秒)。Google 之后得到解决方案,使用配置

1
app.run(threaded=True)

开启多线程模式。

然而这个服务是使用 java 做的底层,用 jpype 让 python 能调用 java 的方法。flask 开启多线程之后服务报错。经过研究发现是 JVM 并没有能为新开启的线程提供服务。查阅 jpype 的文档,找到 python 线程相关部分:http://jpype.sourceforge.net/doc/user-guide/userguide.html#python_threads

因此可在调用 java class 前加上一个判断语句:

1
2
if not jpype.isThreadAttachedToJVM():
jpype.attachThreadToJVM()

使用 isTreadAttachedToJVM 先做出判断,然后使用 attachThreadToJVM 让 JVM 能为新线程提供服务。

完成上述步骤之后,发现已经没有报错了,但是相应速度依然很慢。从服务端控制台看,早已返回了 200,但是在浏览器中迟迟收不到数据,一直是 Pending 状态。查阅资料发现 flask 默认开启 Debug 模式,会对 response 做大量分析记录,使用配置

1
app.run(debug=False)

关闭 Debug 模式,再连接发现响应时间大大减小了,从 20 多秒减到了几百毫秒。

对于响应式编程来说,RxJS 是一个不可思议的工具。今天我们将深入探讨什么是 Observable(可观察对象)和 observer(观察者),然后了解如何创建自己的 operator(操作符)。

如果你之前用过 RxJS,想了解它的内部工作原理,以及 Observable、operator 是如何运作的,这篇文章将很适合你阅读。

什么是 Observable(可观察对象)?

可观察对象其实就是一个比较特别的函数,它接受一个“观察者”(observer)对象作为参数(在这个观察者对象中有 “next”、“error”、“complete”等方法),以及它会返回一种解除与观察者关系的逻辑。例如我们自己实现的时候会使用一个简单的 “unsubscribe” 函数来实现退订功能(即解除与观察者绑定关系的逻辑)。而在 RxJS 中, 它是一个包含 unsubsribe 方法的订阅对象(Subscription)。

可观察对象会创建观察者对象(稍后我们将详细介绍它),并将它和我们希望获取数据值的“东西”连接起来。这个“东西”就是生产者(producer),它可能来自于 click 或者 input 之类的 DOM 事件,是数据值的来源。当然,它也可以是一些更复杂的情况,比如通过 HTTP 与服务器交流的事件。

我们稍后将要自己写一个可观察对象,以便更好地理解它!在此之前,让我们先看看一个订阅对象的例子:

1
2
3
4
5
6
7
8
9
const node = document.querySelector('input[type=text]');

const input$ = Rx.Observable.fromEvent(node, 'input');

input$.subscribe({
next: (event) => console.log(`你刚刚输入了 ${event.target.value}!`),
error: (err) => console.log(`Oops... ${err}`),
complete: () => console.log(`完成!`)
});

这个例子使用了一个 <input type="text"> 节点,并将其传入 Rx.Observable.fromEvent() 中。当我们触发指定的事件名时,它将会返回一个输入的 Event 的可观察对象。(因此我们在 console.log 中用 ${event.target.value} 可以获取输入值)

当输入事件被触发的时候,可观察对象会将它的值传给观察者。

什么是 Observer(观察者)?

观察者相当容易理解。在前面的例子中,我们传入 .subscribe() 中的对象字面量就是观察者(订阅对象将会调用我们的可观察对象)。

.subscribe(next, error, complete) 也是一种合法的语法,但是我们现在研究的是对象字面量的情况。

当一个可观察对象产生数据值的时候,它会通知观察者,当新的值被成功捕获的时候调用 .next(),发生错误的时候调用 .error()

当我们订阅一个可观察对象的时候,它会持续不断地将值传递给观察者,直到发生以下两件事:一种是生产者告知没有更多的值需要传递了,这种情况它会调用观察者的 .complete() ;一种是我们(“消费者”)对之后的值不再感兴趣,决定取消订阅(unsubsribe)。

如果我们想要对可观察对象传来的值进行组成构建(compose),那么在值传达最终的 .subscribe() 代码块之前,需要经过一连串的可观察对象(也就是操作符)处理。这个一连串的“链”也就是我们所说的可观察对象序列。链中的每个操作符都会返回一个新的可观察对象,让我们的序列能够持续进行下去——这也就是我们所熟知的“流”。

什么是 Operator(操作符)?

我们前面提到,可观察对象能够进行链式调用,也就是说我们可以像这样写代码:

1
2
3
4
5
6
const input$ = Rx.Observable.fromEvent(node, 'input')
.map(event => event.target.value)
.filter(value => value.length >= 2)
.subscribe(value => {
// use the `value`
});

这段代码做了下面一系列事情:

  • 我们先假定用户输入了一个“a”
  • 可观察对象将会对这个输入事件作出反应,将值传给下一个观察者
  • “a”被传给了订阅了我们初始可观察对象的 .map()
  • .map() 会返回一个 event.target.value 的新可观察对象,然后调用它观察者对象中的 .next()
  • .next() 将会调用订阅了 .map().filter(),并将 .map() 处理后的值传递给它
  • .filter() 将会返回另一个可观察对象,.filter() 过滤后留下 .length 大于等于 2 的值,并将其传给 .next()
  • 我们通过 .subscribe() 获得了最终的数据值

这短短的几行代码做了这么多的事!如果你还觉得弄不清,只需要记住:

每当返回一个新的可观察对象,都会有一个新的观察者挂载到前一个可观察对象上,这样就能通过观察者的“流”进行传值,对观察者生产的值进行处理,然后调用 .next() 方法将处理后的值传递给下一个观察者。

简单来说,操作符将会不断地依次返回新的可观察对象,让我们的流能够持续进行。作为用户而言,我们不需要关心什么时候、什么情况下需要创建与使用可观察对象与观察者,我们只需要用我们的订阅对象进行链式调用就行了。

创建我们自己的 Observable(可观察对象)

现在,让我们开始写自己的可观察对象的实现吧。尽管它不会像 Rx 的实现那么高级,但我们还是对完善它充满信心。

Observable 构造器

首先,我们需要创建一个 Observable 构造函数,此构造函数接受且仅接受 subscribe 函数作为其唯一的参数。每个 Observable 实例都存储 subscribe 属性,稍后可以由观察者对象调用它:

1
2
3
function Observable(subscribe) {
this.subscribe = subscribe;
}

每个分配给 this.subscribesubscribe 回调都将会被我们或者其它的可观察对象调用。这样我们下面做的事情就有意义了。

Observer 示例

在深入探讨实际情况之前,我们先看一看基础的例子。

现在我们已经配好了可观察对象函数,可以调用我们的观察者,将 1 这个值传给它并订阅它:

1
2
3
4
5
6
7
8
const one$ = new Observable((observer) => {
observer.next(1);
observer.complete();
});

one$.subscribe({
next: (value) => console.log(value) // 1
});

我们订阅了 Observable 实例,将我们的 observer(对象字面量)传入构造器中(之后它会被分配给 this.subscribe)。

Observable.fromEvent

现在我们已经完成了创建自己的 Observable 的基础步骤。下一步是为 Observable 添加 static 方法:

1
2
3
Observable.fromEvent = (element, name) => {

};

我们将像使用 RxJS 一样使用我们的 Observable:

1
2
3
const node = document.querySelector('input');

const input$ = Observable.fromEvent(node, 'input');

这意味着我们需要返回一个新的 Observable,然后将函数作为参数传递给它:

1
2
3
4
5
Observable.fromEvent = (element, name) => {
return new Observable((observer) => {

});
};

这段代码将我们的函数传入了构造器中的 this.subscribe。接下来,我们需要将事件监听设置好:

1
2
3
4
5
Observable.fromEvent = (element, name) => {
return new Observable((observer) => {
element.addEventListener(name, (event) => {}, false);
});
};

那么这个 observer 参数是什么呢?它又是从哪里来的呢?

这个 observer 其实就是携带 nexterrorcomplete 的对象字面量。

这块其实很有意思。observer.subscribe() 被调用之前都不会被传递,因此 addEventListener 在 Observable 被“订阅”之前都不会被执行。

一旦调用 subscribe,也就会调用 Observable 构造器内的 this.subscribe 。它将会调用我们传入 new Observable(callback) 的 callback,同时也会依次将值传给我们的观察者。这样,当 Observable 做完一件事的时候,它就会用更新过的值调用我们观察者中的 .next() 方法。

那么之后呢?我们已经得到了初始化好的事件监听器,但是还没有调用 .next()。下面完成它:

1
2
3
4
5
6
7
Observable.fromEvent = (element, name) => {
return new Observable((observer) => {
element.addEventListener(name, (event) => {
observer.next(event);
}, false);
});
};

我们都知道,可观察对象在被销毁前需要一个“处理后事”的函数,在我们这个例子中,我们需要移除事件监听:

1
2
3
4
5
6
7
Observable.fromEvent = (element, name) => {
return new Observable((observer) => {
const callback = (event) => observer.next(event);
element.addEventListener(name, callback, false);
return () => element.removeEventListener(name, callback, false);
});
};

因为这个 Observable 还在处理 DOM API 和事件,因此我们还不会去调用 .complete()。这样在技术上就有无限的可用性。

试一试吧!下面是我们已经写好的完整代码:

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
const node = document.querySelector('input');
const p = document.querySelector('p');

function Observable(subscribe) {
this.subscribe = subscribe;
}

Observable.fromEvent = (element, name) => {
return new Observable((observer) => {
const callback = (event) => observer.next(event);
element.addEventListener(name, callback, false);
return () => element.removeEventListener(name, callback, false);
});
};

const input$ = Observable.fromEvent(node, 'input');

const unsubscribe = input$.subscribe({
next: (event) => {
p.innerHTML = event.target.value;
}
});

// 5 秒之后自动取消订阅
setTimeout(unsubscribe, 5000);

在线示例:

创造我们自己的 Operator(操作符)

在我们理解了可观察对象与观察者对象的概念之后,我们可以更轻松地去创造我们自己的操作符了。我们在 Observable 对象原型中加上一个新的方法:

1
2
3
Observable.prototype.map=function(mapFn){

};

这个方法将会像 JavaScript 中的 Array.prototype.map 一样使用,不过它可以对任何值用:

1
2
const input$ = Observable.fromEvent(node, 'input')
.map(event => event.target.value);

所以我们要取得回调函数,并调用它,返回我们期望得到的数据。在这之前,我们需要拿到流中最新的数据值。

下面该做什么就比较明了了,我们要得到调用了这个 .map() 操作符的 Observable 实例的引用入口。我们是在原型链上编程,因此可以直接这么做:

1
2
3
Observable.prototype.map = function (mapFn) {
const input = this;
};

找找乐子吧!现在我们可以在返回的 Obeservable 中调用 subscribe:

1
2
3
4
5
6
Observable.prototype.map = function (mapFn) {
const input = this;
return new Observable((observer) => {
return input.subscribe();
});
};

我们要返回 input.subscribe() ,因为在我们退订的时候,非订阅对象将会顺着链一直转下去,解除每个 Observable 的订阅。

这个订阅对象将允许我们把之前 Observable.fromEvent 传来的值传递下去,因为它返回了构造器中含有 subscribe 原型的新的 Observable 对象。我们可以轻松地订阅它对数据值做出的任何更新!最后,完成通过 map 调用我们的 mapFn() 的功能:

1
2
3
4
5
6
7
8
9
10
Observable.prototype.map = function (mapFn) {
const input = this;
return new Observable((observer) => {
return input.subscribe({
next: (value) => observer.next(mapFn(value)),
error: (err) => observer.error(err),
complete: () => observer.complete()
});
});
};

现在我们可以进行链式调用了!

1
2
3
4
5
6
7
8
const input$ = Observable.fromEvent(node, 'input')
.map(event => event.target.value);

input$.subscribe({
next: (value) => {
p.innerHTML = value;
}
});

注意到最后一个 .subscribe() 不再和之前一样传入 Event 对象,而是传入了一个 value 了吗?这说明你成功地创建了一个可观察对象流。

再试试:

希望这篇文章对你来说还算有趣~:)

发布于掘金 https://juejin.im/post/5934d2532f301e00585ea5f3

拿着以前的笔记复习一下~

概念

决策可以分为静态决策与动态决策。

其中静态决策又被称为“一次性决策”,即根据输入进行决策判断,得到相应的输出结果。如图所示:

1
2
3
4
5
6
7
8
9
10
         u决策
+
|
+--v--+
x1输入+--> +-->x2输出
+--+--+
|
v
z决策效应

动态决策也叫“多阶段决策”

1
2
3
4
5
6
7
8
9
       u1         u2                 uk                     un
| | | |
+--v--+ +--v--+ +--v--+ +--v--+
x1--> T1 +-x2-> T2 +-x3->...xk--> Tk +-x(k+1)->...xn--> Tn +-->x(n+1)
+--+--+ +--v--+ +--v--+ +--v--+
| | | |
v v v v
r1 r2 rk rn

也可以记为\(X_{k+1} = T_k(x_k,u_k)\),若系统在 k 阶段之后的决策只与\(x_k\)有关,而与之前做过的决策无关,则这样的决策过程称为具有无后效性的多段决策过程。

多段决策过程从第 k 步到最后一步的过程称为 k-后部子过程,简称 k-子过程

动态规划模型

\[ \text{opt}_{u_1 \cdots u_n} R = \bigoplus_{k=1}^n r_k (x_k,u_k) \]

\[\left\{ \begin{aligned} &x_{k+1} = T_k(x_k,u_k) \\ &x_k \in X_k \\ &u_k \in U_k \\ &k = 1 \sim n \end{aligned} \right. \]

opt 表示求优过程

Xk 为一个集合,表示在 k 阶段时状态所有可能取值的范围,因此称为状态可能集合

Uk 为一个集合,表示在 k 阶段时决策所有可能取值的范围,称为决策允许集合

一般对于不同的状态,可以选择的决策范围也不同,因此决策允许集合也记为\(U_k(x_k)\)

解决动态规划问题需要确定以下几个步骤:

1、确定阶段与阶段变量

2、明确状态变量与状态可能集合

3、确定决策变量与决策允许集合

4、确定状态转移方程

5、明确阶段效应和目标

其中重要的是确定状态转移方程与明确阶段相应和目标。

状态转移方程即在状态\(x_k\)时做出了决策\(u_k(x_k)\)之后系统状态的变化,这个变化会影响之后的决策过程。因此必须明确状态的转移过程,即根据问题的内在关系,明确\(x_{k+1}=T_k(x_k,u_k)\)中的函数Tk()。

阶段效应\(r_k(x_k,u_k)\)是在阶段k以\(x_k\)为起点发出决策\(u_k\)所产生的后果。明确\(r_k,x_k,u_k\)才能构成目标函数,目标函数由具体问题决定,例如根据具体问题确定求最大还是最小。

多段决策的特点

1、每个阶段都要进行决策

2、相继进行的阶段决策构成决策序列

3、前一阶段的终止状态是后一阶段的起始状态

阶段 k 的最优决策不应该只是当前阶段的最优决策,而应该是 k-后部子过程的最优决策。

最优性原理

无论初始状态和初始决策如何,对于之前所有决策造成的某一状态而言,剩余的决策序列必须构成最优策略。

最优性原理的含义:

1、最优策略的任何一部分子策略,也是相应最初状态的最优策略。

2、每个最优策略只能由最优子策略构成。

因此对于无后效多段决策过程而言,按照 k-后部子过程最优的原则来求各阶段的最优决策,这样构成的决策序列一定具有最优性原理的性质。

贝尔曼函数

阶段 k,从状态\(x_k\)出发,执行最优决策序列,最终到达终点时,整个 k-后部子过程中的目标函数取值被称为条件最优目标函数,即贝尔曼函数。

\[f_k(k_k)=opt_{u_k~u_n} \sum^{n}_{i=n} r_i(x_i,u_i) | k\in {1,2,3,...,n}\]

动态规划基本方程、贝尔曼方程

在阶段 k时,执行任意决策\(u_k\)后的状态是\(x_{k+1} = T_k(x_k,u_k)\)。这时 k-后部子过程就缩小为了 k+1 后部子过程。根据最优性原理,k+1 后部子过程应该采取最优策略,由于无后效性,k-后部子过程的目标函数值为 \(r_k(x_k,u_k)+f_{k+1}(T_k(x_k,u_k))\)。根据条件最优目标函数的定义,有:

\[f_k(x_k) = opt_{u_k}{ r_k(x_k,u_k) + f_{k+1}(T_k(x_k,u_k)) }\]

此方程即为动态规划基本方程。

动态规划求解

1、逆序求出条件最优目标函数值集合与条件最优决策集合

2、顺序求最优目标值、最优策略和最佳路线

逆序求集合:

\[ \begin{aligned} k=n, &f_n(x_n) = \text{opt}_{u_n}\{r_n(x_n,u_n) + f_{n+1}(x_{n+1})\} \\ & \because f_{n+1}(x_{n+1}) = 0 \\ & \therefore 原式 = \text{opt}_{u_n}\{r_n(x_n,u_n)\} \\ & \Rightarrow f_n(x_n) = r_n(x_n,u_n^\prime(x_n)) \\\\ k=n-1, &f_{n-1}(x_{n-1}) = \text{opt}_{u_{n-1}}\{r_{n-1}(x_{n-1},u_{n-1}) + f_{n}(x_{n})\} \\ & \because f_n(x_n) 已求出,因此根据 x_n = T_{n-1}(x_{n-1},u_{n-1})\\ & 可得 n-1 时的 x_{n-1} \in X_{n-1} 对应的条件最有目标函数值\\ & f_{n-1}(x_{n-1}) \\ & \Rightarrow \{f_{n-1}(x_{n-1}),u_{n-1}^\prime(x_{n-1})|x_{n-1} \in X_{n-1}\} \\\\ k=1, &f_1(x_1) = \text{opt}_{u_1}\{r_1(x_1,u_1) + f_2(x_2)\} \\ & \{f_1(x_1),u_1^\prime(x_1)|x_1 \in X_1\} \\ \end{aligned} \]

顺序求目标值:

\[ \begin{aligned} x_1 确定, &R^* = f_1(x_1) \qquad u^*_1(x_1) = u_1^\prime(x_1) \\ x_1 不确定, &R^* = \text{opt}_{x_1 \in X_1}\{f_1(x_1)\} = f_1(x_1^*) \\ & 得 x_1^*,带入求 x_2^,以此类推得 x_n^*,x_{n+1}^* \end{aligned} \]

upload successful

我以教 JavaScript 为生。最近我给学生上了柯里化箭头函数这个课程——这还是最开始的几节课。我认为它是一个很好用的技能,因此将这个内容提到了课程的前面。而学生们没有让我失望,比我想象中地更快地掌握了使用箭头函数进行柯里化。

如果学生们能够理解它,并且能尽快由它获益,为什么不早点将箭头函数教给他们呢?

Note:我的课程并不适合那些从来没有接触过代码的人。大多数学生在加入我们的课程之前至少有几个月的编程经历——无论他们是自学,还是通过培训班学习,或者本身就是专业的。然而,我发现许多只有一点经验或者没有经验的年轻开发者们能够很快地接受这些主题。

我看到很多的学生在上了 1 小时的课之后就能很熟练地使用箭头函数工作了。(如果你是“和 Eric Elliott 一起学习 JavaScript”培训班的同学,你可以看这个约 55 分钟的视频——ES6 的柯里化与组合)。

看到学生们如此之快地掌握与应用他们新发现的柯里化方法,我想起了我在推特上发了柯里化箭头函数的帖子,然后被一群人喷“可读性差”的事。我很惊讶为什么他们会坚持这个观点。

首先,我们先来看看这个例子。我在推特发了这个函数,然后我发现有人强烈反对这种写法:

1
const secret = msg => () => msg;

我对有人在推特上指责我在误导别人感到不可思议。我写这个函数是为了示范在 ES6 中写柯里化函数是多么的简单。它是我能想到的在 JavaScript 中最简单的实际运用与闭包表达式了。(相关阅读:什么是闭包

它和下面的函数表达式等价:

1
2
3
4
5
const secret = function (msg) {
return function () {
return msg;
};
};

secret() 是一个函数,它需要传入 msg 这个参数,然后会返回一个新的函数,这个函数将会返回 msg 的值。无论你向 secret() 中传入什么值,它都会利用闭包固定 msg 的值。

你可以这么用它:

1
2
const mySecret = secret('hi');
mySecret(); // 'hi'

事实证明,双箭头并没有让人感到困惑。我坚信:

对于熟悉的人来说,单行的箭头函数是 JavaScript 表达柯里化函数最具有可读性的方法了。

有许多人指责我,告诉我将代码写的长一些比简短的代码更容易阅读。他们有时也许是对的,但是大多数情况都错了。更长、更详细的代码不一定更容易阅读——至少,对熟悉箭头函数的人来说就是如此。

我在推特上看到的持反对意见的人,并没有像我的学生一样享受平滑的学习箭头函数的过程。在我的经验里,学生学习柯里化箭头函数就像鱼在水里生活一样。仅仅学了几天,他们就开始使用箭头了。它帮助学生们轻松地跨过了各种编程问题的鸿沟。

我没有看到学习、阅读、理解箭头函数对那些学生造成了任何的“困难”——一旦他们决定学习,只要上个大概一小时的课就能基本掌握。

他们能够很轻松地读懂柯里化箭头函数,尽管他们从来没有见过这类的东西,他们还是能够告诉我这些函数做了什么事。当我给他们布置任务后他们也能够很自如地自己完成任务。

从另一方面说,他们能够很快熟悉柯里化箭头函数,并且没有为此产生任何问题。他们阅读这些函数就像你读一句话一样,他们对其的理解让他们写出了更简单、更少 bug 的代码。

为什么一些人认为传统的函数表达式看起来“更具有可读性”?

偏爱是一种显著的人类认知偏差,它会让我们在有更好的选择的情况下做出自暴自弃的选择。我们会因此无视更舒服更好的方法,习惯性地选用以前使用过的老方法。

你可以从这本书中更详细地了解“偏爱”这种心理:《The Undoing Project: A Friendship that Changed Our Minds》(很多情况都是我们自欺欺人)。每个软件工程师都应该读一读这本书,因为它会鼓励你辩证地去看待问题,以及鼓励你多对假设进行实验,以免掉入各种认知陷阱中。书中那些发现认知陷阱的故事也很有趣。

传统的函数表达式可能会在你的代码中导致 Bug 的出现

今天我用 ES5 的语法重写了一个 ES6 写的柯里化箭头函数,以便发布开源模块让人们无需编译就能在老浏览器中用。然而 ES5 版本让我震惊。

ES6 版本的代码非常简短、简介、优雅——仅仅只需要 4 行。

我觉得,这件事可以发个推特,告诉大家箭头函数是一种更加优越的实现,是时候如同放弃自己的坏习惯一样,放弃传统函数表达式的写法了。

所以我发了一条推特:

upload successful

为了防止你看不清图片,下面贴上这个函数的文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 使用箭头函数柯里化
const composeMixins = (...mixins) => (
instance = {},
mix = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)
) => mix(...mixins)(instance);
// 对比一下 ES5 风格的代码:
var composeMixins = function () {
var mixins = [].slice.call(arguments);
return function (instance, mix) {
if (!instance) instance = {};
if (!mix) {
mix = function () {
var fns = [].slice.call(arguments);
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};
}
return mix.apply(null, mixins)(instance);
};
};

这里的函数封装了一个 pipe(),它是标准的函数式编程的工具函数,通常用于组合函数。这个 pipe() 函数在 lodash 中是 lodash/flow,在 Ramda 中是 R.pipe(),在一些函数式编程语言中它甚至本身就是一个运算符号。

每个熟悉函数式编程的人都应该很熟悉它。它的实现主要依赖于Reduce

在这个例子中,它用来组合混合函数,不过这点无关紧要(有专门写这方面的博客文章)。我们需要注意是以下几个重要的细节:

这个函数可以将任何数量的函数混合,最终返回一个函数,这个函数在管道中应用了其它的函数——就像流水线一样。每个混合函数都将实例(instance)作为输入,然后在将自己传递给管道中下一个函数之前,将一些变量传入。

如果你没有传入 instance,它将会为你创建一个新的对象。

有时你可能会想用别的混合方式。例如,使用 compose() 代替 pipe() 来传递函数,让组合顺序反过来。

如果你不需要自定义函数混合时的行为,你可以简单地使用默认设定,使用 pipe() 来完成过程。

事实

除了可读性的区别之外,以下列举了一些与这个例子有关的客观事实

  • 我有多年的 ES5 与 ES6 编程经验,无论是箭头函数表达式还是别的函数表达式我都很熟悉。因此“偏爱”对我来说不是一个变化无常的因素。
  • 我没几秒就写好了 ES6 版本的代码,它没有任何 bug(它通过了所有的单元测试,因此我敢肯定这点)。
  • 写 ES5 版本的代码花了我好几分钟。一个是几秒,一个是几分钟,差距还是挺大的。写 ES5 代码时,我有 2 次弄错了函数的作用范围;写出了 3 个 bug,然后要花时间去分别调试与修复;还有 2 次我不得不使用 console.log() 来弄清函数执行的情况。
  • ES6 版本代码仅仅只有 4 行。
  • ES5 版本代码有 21 行(其中真正有代码的有 17 行)。
  • 尽管 ES5 版本的代码更加冗长,但是它比起 ES6 版本的代码来说仍然缺少了一些信息。它虽然长,但是表达的东西更少。这个问题在后面会提到。
  • ES6 版本代码在代码中有 2 个 speard 运算符。而 ES5 版本代码中没有这个运算符,而是使用了意义晦涩arguments 对象,它将严重影响函数内容的可读性。(不推荐原因之一)
  • ES6 版本代码在函数片段中定义了 mix 的默认值,由此你可以很清楚地看到它是参数的值。而 ES5 版本代码却混淆了这个细节问题,将它隐藏在函数体中。(不推荐原因之二)
  • ES6 版本代码仅有 2 层代码块,这将会帮助读者理解代码结构,以及知道如何去阅读这个代码。而 ES5 代码有 6 层代码块,复杂的层级结构会让函数结构的可读性变得很差。(不推荐原因之三)

在 ES5 版本代码中,pipe() 占据了函数体的大部分内容——要把它们放到同一行中去简直是个荒唐的想法。非常有必要pipe() 这个函数单独抽离出来,让我们的 ES5 版本代码更具有可读性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var pipe = function () {
var fns = [].slice.call(arguments);

return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};

var composeMixins = function () {
var mixins = [].slice.call(arguments);

return function (instance, mix) {
if (!instance) instance = {};
if (!mix) mix = pipe;

return mix.apply(null, mixins)(instance);
};
};

这样,我觉得它更具可读性,并且更容易理解它的意思了。

让我们看看如果我们对 ES6 版本代码做一些可读性“优化”会怎么样:

1
2
3
4
5
6
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

const composeMixins = (...mixins) => (
instance = {},
mix = pipe
) => mix(...mixins)(instance);

就像 ES5 版本代码的优化一样,这个“优化”后的代码更加冗长(它加入了之前没有的新变量)。与 ES5 版本代码不同,这个版本在将管道的概念抽象出来后并没有明显的提高代码可读性。不过毕竟函数里已经清楚的写明了 mix 这个变量,它还是更容易让人理解一些。

mix 的定义本身在它的那一行就已经存在了,它不太可能会让阅读代码的人找不到何时结束 mix、剩下的代码何时执行。

而现在我们用了 2 个变量来表示同一个东西。我们因此而获益了吗?完全没有。

那么为什么 ES5 函数在对函数进行抽象之后会变得更具可读性呢?

因为之前 ES5 版本的代码明显更复杂。这种复杂度的来源是我们讨论的问题重点。我可以断言,它的复杂度的来源归根结底就是语法干扰,这种语法干扰只会让函数的本身含义变得费解,并没有别的用处。

让我们换种方法,把一些多余的变量去掉,在例子中都使用 ES6 代码,只比较箭头函数传统函数表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var composeMixins = function (...mixins) {
return function (
instance = {},

mix = function (...fns) {
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
}
) {
return mix(...mixins)(instance);
};
};

现在,至少我觉得它的可读性显著的提升了。我们利用 rest 语法以及默认参数语法对它进行了修改。当然,你得对 rest 语法和默认参数语法很熟悉才会觉得这个版本的代码更可读。不过即使你不了解这些,我觉得这个版本也会看起来更加有条理

现在已经改进了许多了,但是我觉得这个版本还是比较简洁。将 pipe() 抽象出来,写到它自己的函数里可能会有所帮助

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const pipe = function (...fns) {
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};

// 传统函数表达式
const composeMixins = function (...mixins) {
return function (
instance = {},
mix = pipe
) {
return mix(...mixins)(instance);
};
};

这样是不是更好了?现在 mix 只占了单独的一样,函数结构也更加的清晰——但是这样做不符合我的胃口,它的语法干扰实在是太多了。在现在的 composeMixins() 中,我觉得描述一个函数在哪结束、另一个函数从哪开始还不够清楚。

除了调用函数体之外,funcion 这个关键字似乎和其它的代码混淆在一起了。我的函数的真正的功能被隐藏了起来!参数的调用和函数体的起始到底在哪里?如果我仔细看也能够分析出来,但是它对我来说实在是不容易阅读。

那么如果我们去掉 function 这个关键字,然后通过一个大箭头 => 指向返回值来代替 return 关键字,避免它们和其它关键部分混在一起,现在会怎么样呢?

我们当然可以这么做,代码会是这样的:

1
2
3
4
const composeMixins = (...mixins) => (
instance = {},
mix = pipe
) => mix(...mixins)(instance);

现在应该可以很清楚这段代码做了什么事了。composeMixins() 是一个函数,它传入了任意数量的 mixins,最终会返回一个得到两个额外参数(instancemix)的函数。它返回了通过 mixins 管道组合的 instance 的结果。

还有一件事……如果我们对 pipe() 进行同样的优化,可以神奇地将它写到一行中:

1
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

当它在一行内被定义的时候,将它抽象成一个函数这件事反而变得不那么明了了。

另外请记住,这个函数在 Lodash、Ramda 以及其它库中都有用到,但是仅仅为了用这个函数就去 import 这些库并不是一件划得来的事。

那么我们自己写一行这个函数有必要吗?应该有的。它实际上是两个不同的函数,把它们分开会让代码更加清晰。

另一方面,如果将其写在一行中,当你看参数命名的时候,你就已经明了了其类型以及用例。我们将它写在一行,就如下面代码所示:

1
2
3
4
const composeMixins = (...mixins) => (
instance = {},
mix = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)
) => mix(...mixins)(instance);

现在让我们回头看看最初的函数。无论我们后面做了什么调整,我们都没有丢弃任何本来就有的信息。并且,通过在行内声明变量和默认值,我们还给这个函数增加了信息量,描述了这个函数是怎么使用的以及参数值是什么样子的。

ES5 版本中增加的额外的代码其实都是语法干扰。这些代码对于熟悉柯里化箭头函数的人来说没有任何有用之处

只要你熟悉柯里化箭头函数,你就会觉得最开头的代码更加清晰并具有可读性,因为它没有多余的语法糊弄人。

柯里化箭头函数还能减少错误的藏身之处,因为它能让 bug 隐藏的部分更少。我猜想,在传统函数表达式中一定隐藏了许多的 bug,一旦你升级使用箭头函数就能找到并排除这些 bug。

我希望你的团队也能支持、学习与应用 ES6 的更加简洁的代码风格,提高工作效率。

有时,在代码中详细地进行描述是正确的行为,但通常来说,代码越少越好。如果更少的代码能够实现同样的东西,能够传达更多的信息,不用丢弃任何信息量,那么它明显更加优越。认知这些不同点的关键就是看它们表达的信息。如果加上的代码没有更多的意义,那么这种代码就不应该存在。这个道理很简单,就和自然语言的风格规范一样(不说废话)。将这种表达风格规范应用到代码中。拥抱它,你将能写出更好的代码。

一天过去,天色已黑,仍然有其它推特的回复在说 ES6 版本的代码更加缺乏可读性:

upload successful

我只想说:是时候熟练去掌握 ES6、柯里化与组合函数了。

下一步

“与 Eric Elliott 一起学习 JavaScript”会员现在可以看这个大约 55 分钟的视频课程——ES6 柯里化与组合

如果你还不是我们的会员,你可会遗憾地错过这个机会哦!

upload successful

作者简介

Eric Elliott 是 O'Reilly 出版的《Programming JavaScript Applications》书籍、“与 Eric Elliott 学习 JavaScript”课程作者。他曾经帮助 Adobe、莱美、华尔街日报、ESPN、BBC 进行软件开发,以及帮助 Usher、Frank Ocean、Metallica 等著名音乐家做网站。

最后喂狗粮

他与世界上最美丽的女人在旧金山湾区共度一生。

发布于掘金 https://juejin.im/post/59158c92a0bb9f005fd58fd7

学习如何兼顾模块化与可读性来创建对象

动机

在我刚开始学习 iOS 开发的时候,我在 YouTube 上找了一些教程。我发现这些教程有时候会用下面这种方式来创建 UI 对象:

1
2
3
4
let makeBox: UIView = {
let view = UIView()
return view
}()

作为一个初学者,我自然而然地复制并使用了这个例子。直到有一天,我的一个读者问我:“为什么你要加上{}呢?最后为什么要加上一对()呢?这是一个计算属性吗?”我哑口无言,因为我自己也不知道答案。

因此,我为过去年轻的自己写下了这份教程。说不定还能帮上其他人的忙。

目标

这篇教程有一下三个目标:第一,了解如何像前面的代码一样,非常规地创建对象;第二,知道编在写 Swfit 代码时,什么时候该使用 lazy var;第三,快加入我的邮件列表呀。

预备知识

为了让你能轻松愉快地和我一起完成这篇教程,我强烈推荐你先了解下面这几个概念。

  1. 闭包
  2. 捕获列表与循环引用 [weak self]
  3. 面向对象程序设计

创建 UI 组件

在我介绍“非常规”方法之前,让我们先复习一下“常规”方法。在 Swift 中,如果你要创建一个按钮,你应该会这么做:

1
2
3
4
5
6
7
8
// 设定尺寸
let buttonSize = CGRect(x: 0, y: 0, width: 100, height: 100)

// 创建控件
let bobButton = UIButton(frame: buttonSize)
bobButton.backgroundColor = .black
bobButton.titleLabel?.text = "Bob"
bobButton.titleLabel?.textColor = .white

这样做没问题

假设现在你要创建另外三个按钮,你很可能会把上面的代码复制,然后把变量名从 bobButton 改成 bobbyButton

这未免也太枯燥了吧。

1
2
3
4
5
// New Button 
let bobbyButton = UIButton(frame: buttonSize)
bobbyButton.backgroundColor = .black
bobbyButton.titleLabel?.text = "Bob"
bobbyButton.titleLabel?.textColor = .white

为了方便,你可以:

upload successful

使用快捷键:ctrl-cmd-e 来完成这个工作。

如果你不想做重复的工作,你也可以创建一个函数。

1
2
3
4
5
6
7
func createButton(enterTitle: String) -> UIButton {
let button = UIButton(frame: buttonSize)
button.backgroundColor = .black
button.titleLabel?.text = enterTitle
return button
}
createButton(enterTitle: "Yoyo") // 👍

然而,在 iOS 开发中,很少会看到一堆一模一样的按钮。因此,这个函数需要接受更多的参数,如背景颜色、文字、圆角尺寸、阴影等等。你的函数最后可能会变成这样:

1
func createButton(title: String, borderWidth: Double, backgrounColor, ...) -> Button 

但是,即使你为这个函数加上了默认参数,上面的代码依然不理想。这样的设计降低了代码的可读性。因此,比起这个方法,我们还是采用上面那个”单调“的方法为妙。

到底有没有办法让我们既不那么枯燥,还能让代码更有条理呢?当然咯。我们现在只是复习你过去的做法——是时候更上一层楼,展望你未来的做法了。

介绍”非常规“方法

在我们使用”非常规“方法创建 UI 组件之前,让我们先回答一下最开始那个读者的问题。{}是什么意思,它是一个计算属性吗?

当然不是,它只是一个闭包

首先,让我来示范一下如何用闭包来创建一个对象。我们设计一个名为Human的结构:

1
2
3
4
5
struct Human {
init() {
print("Born 1996")
}
}

现在,让你看看怎么用闭包创建对象:

1
2
3
4
5
6
let createBob = { () -> Human in
let human = Human()
return human
}

let babyBob = createBob() // "Born 1996"

如果你不熟悉这段语法,请先停止阅读这篇文章,去看看 Fear No Closure with Bob 充充电吧。

解释一下,createBob 是一个类型为 ()-> Human 的闭包。你已经通过调用 createBob() 创建好了一个 babyBob 实例。

然而,这样做你创建了两个常量:createBobbabyBob。如何把所有的东西都放在一个声明中呢?请看:

1
2
3
4
let bobby = { () -> Human in
let human = Human()
return human
}()

现在,这个闭包通过在最后加上 () 执行了自己,bobby 现在被赋值为一个 Human 对象。干的漂亮!

现在你已经学会了使用闭包来创建一个对象

让我们应用这个方法,模仿上面的例子来创建一个 UI 对象吧。

1
2
3
4
5
let bobView = { () -> UIView in
let view = UIView()
view.backgroundColor = .black
return view
}()

很好,我们还能让它更简洁。实际上,我们不需要为闭包指定类型,我们只需要指定 bobView 实例的类型就够了。例如:

1
2
3
4
5
let bobbyView: **UIView** = {
let view = UIView()
view.backgroundColor = .black
return view
}()

Swift 能够通过关键字 return 推导出这个闭包的类型是 () -> UIView

现在看看,上面的例子已经和我之前害怕的“非常规方式”一样了。

使用闭包创建的好处

我们已经讨论了直接创建对象的单调和使用构造函数带来的问题。现在你可能会想“为什么我非得用闭包来创建?”

重复起来更容易

我不喜欢用 Storyboard,我比较喜欢复制粘贴用代码来创建 UI 对象。实际上,在我的电脑里有一个“代码库”。假设库里有个按钮,代码如下:

1
2
3
4
5
6
7
8
9
let myButton: UIButton = {
let button = UIButton(frame: buttonSize)
button.backgroundColor = .black
button.titleLabel?.text = "Button"
button.titleLabel?.textColor = .white
button.layer.cornerRadius =
button.layer.masksToBounds = true
return button
}()

我只需要把它整个复制,然后把名字从 myButton 改成 newButtom 就行了。在我用闭包之前,我得重复地把 myButton 改成 newButtom ,甚至要改上七八遍。我们虽然可以用 Xcode 的快捷键,但为啥不使用闭包,让这件事更简单呢?

看起来更简洁

由于对象对象会自己编好组,在我看来它更加的简洁。让我们对比一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 使用闭包创建 
let leftCornerButton: UIButton = {
let button = UIButton(frame: buttonSize)
button.backgroundColor = .black
button.titleLabel?.text = "Button"
button.titleLabel?.textColor = .white
button.layer.cornerRadius =
button.layer.masksToBounds = true
return button
}()

let rightCornerButton: UIButton = {
let button = UIButton(frame: buttonSize)
button.backgroundColor = .black
button.titleLabel?.text = "Button"
button.titleLabel?.textColor = .white
button.layer.cornerRadius =
button.layer.masksToBounds = true
return button
}()

vs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 手动创建
let leftCornerButton = UIButton(frame: buttonSize)
leftCornerButton.backgroundColor = .black
leftCornerButton.titleLabel?.text = "Button"
leftCornerButton.titleLabel?.textColor = .white
leftCornerButton.layer.cornerRadius =
leftCornerButton.layer.masksToBounds = true

let rightCornerButton = UIButton(frame: buttonSize)
rightCornerButton.backgroundColor = .black
rightCornerButton.titleLabel?.text = "Button"
rightCornerButton.titleLabel?.textColor = .white
rightCornerButton.layer.cornerRadius =
rightCornerButton.layer.masksToBounds = true

尽管使用闭包创建对象要多出几行,但是比起要在 rightCornerButton 或者 leftCornerButton 后面狂加属性,我还是更喜欢在 button 后面加属性。

实际上如果按钮的命名特别详细时,用闭包创建对象还可以少几行。

恭喜你,你已经完成了我们的第一个目标

懒加载的应用

辛苦了!现在让我们来看看这个教程的第二个目标吧。

你可能看过与下面类似的代码:

1
2
3
4
5
6
class IntenseMathProblem {
lazy var complexNumber: Int = {
// 请想象这儿要耗费很多CPU资源
1 * 1
}()
}

lazy 的作用是,让 complexNumber 属性只有在你试图访问它的时候才会被计算。例如:

1
2
let problem = IntenseMathProblem 
problem() // 此时complexNumber没有值

没错,现在 complexNumber 没有值。然而,一旦你访问这个属性:

1
problem().complexNumber // 现在回返回1

lazy var 经常用于数据库排序或者从后端取数据,因为你并不想在创建对象的时候就把所有东西都计算、排序。

实际上,由于对象太大了导致 RAM 撑不住,你的手机就会崩溃。

应用

以下是 lazy var 的应用:

排序

1
2
3
4
5
6
class SortManager {
lazy var sortNumberFromDatabase: [Int] = {
// 排序逻辑
return [1, 2, 3, 4]
}()
}

图片压缩

1
2
3
4
5
6
7
8
class CompressionManager {
lazy var compressedImage: UIImage = {
let image = UIImage()
// 压缩图片的
// 逻辑
return image
}()
}

Lazy的一些规定

  1. 你不能把 lazylet 一起用,因为用 lazy 时没有初值,只有当被访问时才会获得值。
  2. 你不能把它和 计算属性 一起用,因为在你修改任何与 lazy 的计算属性有关的变量时,计算属性都会被重新计算(耗费 CPU 资源)。
  3. Lazy 只能是结构或类的成员。

Lazy 能被捕获吗?

如果你读过我的前一篇文章《Swift 闭包和代理中的循环引用》,你就会明白这个问题。让我们试一试吧。创建一个名叫 BobGreet 的类,它有两个属性:一个是类型为 Stringname,一个是类型为 String 但是使用闭包创建的 greeting

1
2
3
4
5
6
7
8
9
10
class BobGreet {
var name = "Bob the Developer"
lazy var greeting: String = {
return "Hello, \(self.name)"
}()

deinit {
print("I'm gone, bruh 🙆")}
}
}

闭包可能BobGuest 有强引用,让我们尝试着 deallocate 它。

1
2
3
var bobGreet: BobGreet? = BobClass()
bobGreet?.greeting
bobClass = nil // I'm gone, bruh 🙆

不用担心 [unowned self],闭包并没有对对象存在引用。相反,它仅仅是在闭包内复制了 self。如果你对前面的代码声明有疑问,可以读读 Swift Capture Lists 来了解更多这方面的知识。👍

最后的唠叨

我在准备这篇教程的过程中也学到了很多,希望你也一样。感谢你们的热情❤️!不过这篇文章还剩一点:我的最后一个目标。如果你希望加入我的邮件列表以获得更多有价值的信息的话,你可以点 这里注册。

正如封面照片所示,我最近买了 Magic Keyboard 和 Magic Mouse。它们超级棒,帮我提升了很多的效率。你可以在 这儿买鼠标,在 这儿买键盘。我才不会因为它们的价格心疼呢。😓

本文的源码

我将要参加 Swift 讨论会

我将在 6 月 1 日至 6 月 2 日 参加我有生以来的第一次讨论会 @SwiftAveir, 我的朋友 Joao协助组织了这次会议,所以我非常 excited。你可以点这儿了解这件事 的详情!

文章推荐

函数式编程简介 (Blog)

我最爱的 XCode 快捷键 (Blog )

关于我

我是一名来自首尔的 iOS 课程教师,你可以在 Instagram 上了解我。我会经常在 Facebook Page 投稿,投稿时间一般在北京时间上午9点(Sat 8pm EST)。


发布于掘金 https://juejin.im/post/590a9eeab123db00549776ee

upload successful

最近一段日子,编写高效的 JavaScript 应用变得越来越复杂。早在几年前,大家都开始合并脚本来减少 HTTP 请求数;后来有了压缩工具,人们为了压缩代码而缩短变量名,甚至连代码的最后一字节都要省出来。

今天,我们有了 tree shaking 和各种模块打包器,我们为了不在首屏加载时阻塞主进程又开始进行代码分割,加快交互时间。我们还开始转译一切东西:感谢 Babel,让我们能够在现在就使用未来的特性。

ES6 模块由 ECMAScript 标准制定,定稿有些时日了。社区为它写了很多的文章,讲解如何通过 Babel 使用它们,以及 import 和 Node.js 的 require 的区别。但是要在浏览器中真正实现它还需要一点时间。我惊喜地发现 Safari 在它的 technology preview 版本中第一个装载了 ES6 模块,并且 Edge 和 Firefox Nightly 版本也将要支持 ES6 模块——虽然目前还不支持。在使用 RequireJSBrowserify 之类的工具后(还记得关于 AMD 与 CommonJS 的讨论吗?),至少看起来浏览器终于能支持模块了。让我们来看看明朗的未来带来了怎样的礼物吧!🎉

传统方法

构建 web 应用的常用方式就是使用由 Browserify、Rollup、Webpack 等工具构建的代码包(bundle)。而不使用 SPA(单页面应用)技术的网站则通常由服务端生成 HTML,在其中引入一个 JavaScript 代码包。

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<title>ES6 modules tryout</title>
<!-- defer to not block rendering -->
<script src="dist/bundle.js" defer></script>
</head>
<body>
<!-- ... -->
</body>
</html>

我们使用 Webpack 打包的代码包中包括了 3 个 JavaScript 文件,这些文件使用了 ES6 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/index.js
import dep1 from './dep-1';

function getComponent () {
var element = document.createElement('div');
element.innerHTML = dep1();
return element;
}

document.body.appendChild(getComponent());

// app/dep-1.js
import dep2 from './dep-2';

export default function() {
return dep2();
}

// app/dep-2.js
export default function() {
return 'Hello World, dependencies loaded!';
}

这个 app 将会显示“Hello world”。在下文中显示“Hello world”即表示脚本加载成功。

装载一个代码包(bundle)

配置使用 Webpack 创建一个代码包相对来说比较直观。在构建过程中,除了打包和使用 UglifyJS 压缩 JavaScript 文件之外并没有做别的什么事。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
entry: './app/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new UglifyJSPlugin()
]
};

3 个基础文件比较小,加起来只有 347 字节。

1
2
3
4
5
$ ll app
total 24
-rw-r--r-- 1 stefanjudis staff 75B Mar 16 19:33 dep-1.js
-rw-r--r-- 1 stefanjudis staff 75B Mar 7 21:56 dep-2.js
-rw-r--r-- 1 stefanjudis staff 197B Mar 16 19:33 index.js

在我通过 Webpack 构建之后,我得到了一个 856 字节的代码包,大约增大了 500 字节。增加这么些字节还是可以接受的,这个代码包与我们平常生产环境中做代码装载没啥区别。感谢 Webpack,我们已经可以使用 ES6 模块了。

1
2
3
4
5
6
7
8
9
$ webpack
Hash: 4a237b1d69f142c78884
Version: webpack 2.2.1
Time: 114ms
Asset Size Chunks Chunk Names
bundle.js 856 bytes 0 [emitted] main
[0] ./app/dep-1.js 78 bytes {0}[built]
[1] ./app/dep-2.js 75 bytes {0}[built]
[2] ./app/index.js 202 bytes {0}[built]

使用原生支持的 ES6 模块的新设定

现在,我们得到了一个“传统的打包代码”,现在所有还不支持 ES6 模块的浏览器都支持这种打包的代码。我们可以开始玩一些有趣的东西了。让我们在 index.html 中加上一个新的 script 元素指向 ES6 模块,为其加上 type="module"

1
<html><head><title>ES6 modules tryout</title><!-- in case ES6 modules are supported --><script src="app/index.js"type="module"></script><script src="dist/bundle.js"defer></script></head><body><!-- ... --></body></html>

然后我们在 Chrome 中看看,发现并没有发生什么事。

upload successful

代码包还是和之前一样加载,“Hello world!” 也正常显示。虽然没看到效果,但是这说明浏览器可以接受这种它们并不理解的命令而不会报错,这是极好的。Chrome 忽略了这个它无法判断类型的 script 元素。

接下来,让我们在 Safari technology preview 中试试:

upload successful

遗憾的是,它并没有显示另外的“Hello world”。造成问题的原因是构建工具与原生 ES 模块的差异:Webpack 是在构建的过程中找到那些需要 include 的文件,而 ES 模块是在浏览器中运行的时候才去取文件的,因此我们需要为此指定正确的文件路径:

1
2
3
4
5
6
7
// app/index.js

// 这样写不行
// import dep1 from './dep-1';

// 这样写能正常工作
import dep1 from './dep-1.js';

改了文件路径之后它能正常工作了,但事实上 Safari Preview 加载了代码包,以及三个独立的模块,这意味着我们的代码被执行了两次。

upload successful

这个问题的解决方案就是加上 nomodule 属性,我们可以在加载代码包的 script 元素里加上这个属性。这个属性是最近才加入标准中的,Safari Preview 也是在一月底才支持它的。这个属性会告诉 Safari,这个 script 是当不支持 ES6 模块时的“退路”。在这个例子中,浏览器支持 ES6 模块因此加上这个属性的 script 元素中的代码将不会执行。

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
<title>ES6 modules tryout</title>
<!-- in case ES6 modules are supported -->
<script src="app/index.js" type="module"></script>
<!-- in case ES6 modules aren't supported -->
<script src="dist/bundle.js" defer nomodule></script>
</head>
<body>
<!-- ... -->
</body>
</html>
upload successful

现在好了。通过结合使用 type="module"nomodule,我们现在可以在不支持 ES6 模块的浏览器中加载传统的代码包,在支持 ES6 模块的浏览器中加载 JavaScript 模块。

你可以在 es-module-on.stefans-playground.rocks 查看这个尚在制定的规范。

模块与脚本的不同

这儿有几个问题。首先,JavaScript 在 ES6 模块中运行与平常在 script 元素中不同。Axel Rauschmayer 在他的探索 ES6一书中很好地讨论了这个问题。我推荐你点击上面的链接阅读这本书,但是在此我先快速地总结一下主要的不同点:

  • ES6 模块默认在严格模式下运行(因此你不需要加上 use strict 了)。
  • 最外层的 this 指向 undefined(而不是 window)。
  • 最高级变量是 module 的局部变量(而不是 global)。
  • ES6 模块会在浏览器完成 HTML 的分析之后异步加载与执行。

我认为,这些特性是巨大进步。模块是局部的——这意味着我们不再需要到处使用 IIFE 了,而且我们不用再担心全局变量泄露。而且默认在严格模式下运行,意味着我们可以在很多地方抛弃 use strict 声明。

译注:IIFE 全称 immediately-invoked function expression,即立即执行函数,也就是大家熟知的在函数后面加括号。

从改善性能的观点来看(可能是最重要的进步),模块默认会延迟加载与执行。因此我们将不再会不小心给我们的网站加上了阻碍加载的代码,使用 type="module" 的 script 元素也不再会有 SPOF 问题。我们也可以给它加上一个 async 属性,它将会覆盖默认的延迟加载行为。不过使用 defer 在现在也是一个不错的选择

译注:SPOF 全称 Single Points Of Failure——单点故障

1
2
3
4
5
6
7
8
9
10
11
12
<!-- not blocking with defer default behavior -->
<script src="app/index.js" type="module"></script>

<!-- executed after HTML is parsed -->
<script type="module">
console.log('js module');
</script>

<!-- executed immediately -->
<script>
console.log('standard module');
</script>

如果你想详细了解这方面内容,可以阅读 script 元素说明,这篇文章简单易读,并且包含了一些示例。

压缩纯 ES6 代码

还没完!我们现在能为 Chrome 提供压缩过的代码包,但是还不能为 Safari Preview 提供单独压缩过的文件。我们如何让这些文件变得更小呢?UglifyJS 能完成这项任务吗?

然而必须指出,UglifyJS 并不能完全处理好 ES6 代码。虽然它有个 harmony 开发版分支(地址)支持ES6,但不幸的是在我写这 3 个 JavaScript 文件的时候它并不能正常工作。

1
2
3
4
5
6
7
$ uglifyjs dep-1.js -o dep-1.min.js
Parse error at dep-1.js:3,23
export default function() {
^
SyntaxError: Unexpected token: punc (()
// ..
FAIL: 1

但是现在 UglifyJS 几乎存在于所有工具链中,那全部使用 ES6 编写的工程应该怎么办呢?

通常的流程是使用 Babel 之类的工具将代码转换为 ES5,然后使用 Uglify 对 ES5 代码进行压缩处理。但是在这篇文章里我不想使用 ES5 翻译工具,因为我们现在是要寻找面向未来的处理方式!Chrome 已经覆盖了 97% ES6 规范 ,而 Safari Preview 版自 verion 10 之后已经 100% 很好地支持 ES6了。

我在推特中提问是否有能够处理 ES6 的压缩工具,Lars Graubner 告诉我可以使用 Babili。使用 Babili,我们能够轻松地对 ES6 模块进行压缩。

1
2
3
4
5
6
7
8
// app/dep-2.js

export default function() {
return 'Hello World. dependencies loaded.';
}

// dist/modules/dep-2.js
export default function(){return 'Hello World. dependencies loaded.'}

使用 Babili CLI 工具,可以轻松地分别压缩各个文件。

1
2
3
4
$ babili app -d dist/modules
app/dep-1.js -> dist/modules/dep-1.js
app/dep-2.js -> dist/modules/dep-2.js
app/index.js -> dist/modules/index.js

最终结果:

1
2
3
4
5
6
7
$ ll dist
-rw-r--r-- 1 stefanjudis staff 856B Mar 16 22:32 bundle.js

$ ll dist/modules
-rw-r--r-- 1 stefanjudis staff 69B Mar 16 22:32 dep-1.js
-rw-r--r-- 1 stefanjudis staff 68B Mar 16 22:32 dep-2.js
-rw-r--r-- 1 stefanjudis staff 161B Mar 16 22:32 index.js

代码包仍然是大约 850B,所有文件加起来大约是 300B。我没有使用 GZIP,因为它并不能很好地处理小文件。(我们稍后会提到这个)

能通过 rel=preload 来加速 ES6 的模块加载吗?

对单个 JS 文件进行压缩取得了很好的效果。文件大小从 856B 降低到了 298B,但是我们还能进一步地加快加载速度。通过使用 ES6 模块,我们可以装载更少的代码,但是看看瀑布图你会发现,request 会按照模块的依赖链一个一个连续地加载。

那如果我们像之前在浏览器中对代码进行预加载那样,用 <link rel="preload" as="script"> 元素告知浏览器要加载额外的 request,是否会加快模块的加载速度呢?在 Webpack 中,我们已经有了类似的工具,比如 Addy Osmani 的 Webpack 预加载插件可以对分割的代码进行预加载,那 ES6 模块有没有类似的方法呢?如果你还不清楚 rel="preload" 是如何运作的,你可以先阅读 Yoav Weiss 在 Smashing Magazine 发表的相关文章:点击阅读

但是,ES6 模块的预加载并不是那么简单,他们与普通的脚本有很大的不同。那么问题来了,对一个 link 元素加上 rel="preload" 将会怎样处理 ES6 模块呢?它也会取出所有的依赖文件吗?这个问题显而易见(可以),但是使用 preload 命令加载模块,需要解决更多浏览器的内部实现问题。Domenic Denicola一个 GitHub issue 中讨论了这方面的问题,如果你感兴趣的话可以点进去看一看。但是事实证明,使用 rel="preload" 加载脚本与加载 ES6 模块是截然不同的。可能以后最终的解决方案是用另一个 rel="modulepreload" 命令来专门加载模块。在本文写作时,这个 pull request 还在审核中,你可以点进去看看未来我们可能会怎样进行模块的预加载。

加入真实的依赖

仅仅 3 个文件当然没法做一个真正的 app,所以让我们给它加一些真实的依赖。Lodash 根据 ES6 模块对它的功能进行了分割,并分别提供给用户。我取出其中一个功能,然后使用 Babili 进行压缩。现在让我们对 index.js 文件进行修改,引入这个 Lodash 的方法。

1
2
3
4
5
6
7
8
9
10
11
import dep1 from './dep-1.js';
import isEmpty from './lodash/isEmpty.js';

function getComponent() {
const element = document.createElement('div');
element.innerHTML = dep1() + ' ' + isEmpty([]);

return element;
}

document.body.appendChild(getComponent());

在这个例子中,isEmpty 基本上没有被使用,但是在加上它的依赖后,我们可以看看发生了什么:

upload successful

可以看到 request 数量增加到了 40 个以上,页面在普通 wifi 下的加载时间从大约 100 毫秒上升到了 400 到 800 毫秒,加载的数据总大小在没有压缩的情况下增加到了大约 12KB。可惜的是 WebPagetest 在 Safari Preview 中不可用,我们没法给它做可靠的标准检测。

但是,Chrome 收到打包后的 JavaScript 数据比较小,只有大约 8KB。

upload successful

这 4KB 的差距是不能忽视的。你可以在 lodash-module-on.stefans-playground.rocks 找到本示例。

压缩工作仅对大文件表现良好

如果你仔细看上面 Safari 开发者工具的截图,你可能会注意到传输后的文件大小其实比源码还要大。在很大的 JavaScript app 中这个现象会更加明显,一堆的小 Chunk 会造成文件大小的很大不同,因为 GZIP 并不能很好地压缩小文件。

Khan Academy 在前一段时间探究了同样的问题,他是用 HTTP/2 进行研究的。装载更小的文件能够很好地确保缓存命中率,但到最后它一般都会作为一个权衡方案,而且它的效果会被很多因素影响。对于一个很大的代码库来说,分解成若干个 chunk(一个 vendor 文件和一个 app bundle)是理所当然的,但是要装载数千个不能被压缩的小文件可能并不是一种明智的方法。

Tree shaking 是个超 COOL 的技术

必须要说:感谢非常新潮的 tree shaking 技术,通过它,构建进程可以将没有使用过以及没有被其它模块引用的代码删除。第一个支持这个技术的构建工具是 Rollup,现在 Webpack 2 也支持它——只要我们在 babel 中禁用 module 选项

我们试着改一改 dep-2.js,让它包含一些不会在 dep-1.js 中使用的东西。

1
2
3
4
5
6
7
export default function() {
return 'Hello World. dependencies loaded.';
}

export const unneededStuff = [
'unneeded stuff'
];

Babili 只会压缩文件, Safari Preview 在这种情况下会接收到这几行没有用过的代码。而另一方面,Webpack 或者 Rollup 打的包将不会包含这个 unnededStuff。Tree shaking 省略了大量代码,它毫无疑问应当被用在真实的产品代码库中。

尽管未来很明朗,但是现在的构建过程仍然不会变动

ES6 模块即将到来,但是直到它最终在各大主流浏览器中实现前,我们的开发并不会发生什么变化。我们既不会装载一堆小文件来确保压缩率,也不会为了使用 tree shaking 和死码删除来抛弃构建过程。前端开发现在及将来都会一如既往地复杂

不要把所有东西都进行分割然后就假设它会改善性能。我们即将迎来 ES6 模块的浏览器原生支持,但是这不意味着我们可以抛弃构建过程与合适的打包策略。在我们 Contentful 这儿,将继续坚持我们的构建过程,以及继续使用我们的 JavaScript SDKs 进行打包。

然而,我们必须承认现在前端的开发体验仍然良好。JavaScript 仍在进步,最终我们将能够使用语言本身提供的模块系统。在几年后,原生模块对 JavaScript 生态的影响以及最佳实践方法将会是怎样的呢?让我们拭目以待。

其它资源

发布于掘金 https://juejin.im/post/590a990a5c497d005852cf61

编写代码其实只是开发者的一小部分工作。为了让工作更有效率,我们还必须精通 debug。我发现,花一些时间学习新的调试技巧,往往能让我能更快地完成工作,对我的团队做出更大的贡献。关于调试这方面我有一些自己重度依赖的技巧与诀窍,同时我在 workshop 中经常建议大家使用这些技巧,因此我对它们进行了一个汇总(其中有一些来自于社区)。我们将从一些核心概念开始讲解,然后深入探讨一些具体的例子。

主要概念

隔离问题

隔离问题大概是 debug 中最重要的核心概念。我们的代码库是由不同的类库、框架组成的,它们有着许多的贡献者,甚至还有一些不再参与项目的人,因此我们的代码库是杂乱无章的。隔离问题可以帮助我们逐步剥离与问题无关的部分以便我们可以把注意力放在解决方案上。

隔离问题的好处包括但不限于以下几条:

  • 能够弄清楚问题的根本原因是否是我们想的那样,还是存在其它的冲突。
  • 对于时序任务,能判断是否存在时序紊乱。
  • 严格审查我们的代码是否还能够更加精简,这样既能帮助我们写代码也能帮助我们维护代码。
  • 解开纠缠在一起的代码,以观察到底是只有一个问题还是存在更多的问题。

让问题能够被重现是很重要的。如果你不能重现问题来分辨出它到底出在哪里,你将会很难修复这个问题。或者你也可以将它和类似的正常工作的模块进行对比,这样你就可以发现哪里进行过改动,或者发现两者之间有什么不同。

在实际操作中,我有许多种方法对问题进行隔离。其中一种是在本地创建一个精简的测试用例,当然你也可以在 CodePen 创建一个私人测试用例,或者在 JSBin 创建你的用例。另一种是在代码中创建断点,这样可以让我详细地观察代码的执行情况。以下是几种定义断点的方式:

你可以在你代码中写上 debugger;,这样你可以看到当时这一小块代码做了什么。

你还可以在 Chrome 开发者工具中进一步进行调试,单步跟踪事件的发生。你也可以用它选择性地观察指定的事件监听器。

upload successful

古老,好用的 console.log 是另一种隔离的方法。(PHP 中是 echo,python 中是 print ……)。你可以一小片一小片地执行代码并对你的假设进行测试,或者检查看有什么东西发生了变化。这可能是最耗费时间的测试方式了。但是无论你的水平如何高,你还是得乖乖用它。ES6 的箭头函数也可以加速我们的 debug 游戏,它让我们可以在控制台中更方便地写单行代码。

console.table 函数也是我最喜欢的工具之一。当你有大量的数据(例如很长的数组、巨大的对象等等)需要展示的时候,它特别有用。console.dir 函数也是个不错的选择。它可以把一个对象的属性以可交互的形式展示出来。

upload successful

上图为 console.dir 输出的可交互的列表

保持条理清晰

当我在 workshop 上做讲师,帮助我的班级的学生时,我发现,思路不够清晰是阻碍他们调试的一大问题。这实际上是一种龟兔赛跑的情形。他们想要行动的更快,因此他们会在写代码时一次就改写很多的代码——然后出了某些问题,他们不知道到底是改的那部分导致了问题的出现。接着,为了 debug,他们又一次改很多代码,最后迷失在寻找哪里能正常运行、哪里不能正常运行中。

其实我们或多或少都在这么做。当我们对一个工具越来越熟练时,我们会在没有对设想的情况进行测试的情况下写越来越多的代码。但是当你刚开始用一个语法或技术时,你需要放慢速度并且非常谨慎。你将能越来越快地处理自己无意间造成的错误。其实,当你弄出了一个问题的时候,一次调试一个问题可能会看起来慢一些,但其实要找出哪里发生了变化以及问题的所在是没法快速解决的。我说以上这些话是想告诉你:欲速则不达。

你还记得小时候父母告诉你的话吗?“如果你迷路了,待在原地别动。“ 至少我的父母这么说了。这么说的原因是如果他们在到处找我,而我也在到处跑着找他们的话,我们将更难碰到一起。代码也是这样的。你每次动的代码越少就越好,你返回一致的结果越多,就越容易找到问题所在。所以当你在调试时,请尽量不要安装任何东西或者添加新的依赖。如果本应该返回一个静态结果的地方每次都出现不同的错误,你就得特别注意了!

选用优秀的工具

人们开发了无数的工具用于解决各种各样的问题。下面,我会依次介绍一些我觉得最有用的工具,并在最后贴上相关资源的链接。

代码高亮

当然,为你的代码高亮主题找一个最热辣的配色与风格方案是很有趣的,但是请花点时间想清楚这件事。我通常使用深色主题,当有语法错误时,深色主题会用较亮的颜色显示我的代码,使我能轻松快速地找到错误。我也尝试过使用 Oceanic Next 配色方案与 Panda 配色方案,但是说实话我还是最喜欢自己的那种。在寻找优秀的代码高亮工具的时候请保持理智,帅气的外观当然很棒,但是为你揪出错误的功能性更加重要。当然,你完全有可能找到两者都很优秀的代码高亮工具。

使用 Lint 工具

使用 Lint 工具能够帮助我们标记出来一些可疑的代码,并且能报出我们忽视的一些错误。Lint 工具相当的重要,使用何种 lint 工具取决于你使用的语言与框架,以及最重要的:你认可怎样的代码风格。

不同的公司有着不同的代码风格及规定。我个人比较喜欢 AirBnB 的 JS 代码规范。你的 Lint 工具将会强制你按照指定的模式进行编程,否则它可以终止你的构建过程。我曾经使用过一个 CSS Lint 工具,当我为浏览器写 css hack 时,它一直在报错。最后我不得不常常关闭它,它也就没能起到应有的作用。但是一个好的 Lint 工具可以把你忽视的一些潜在的问题指出来。

下面是几个资源:

  • 我最近找到了一个响应式图片 lint 工具,它可以告诉你使用 picture 元素、srcset 属性以及 size 属性的时机。
  • 这儿有个很好的分类,收集与对比了一些 JS lint 工具。

浏览器插件

插件是真的超级棒,你可以轻松地启用或禁用它们。并且它们能在特定需求中发挥重要的作用。如果你使用一些特定的框架或类库工作,使用它们的开发者工具插件将会带给你无与伦比的便利。不过请注意,插件不仅会降低浏览器的速度,它们也有权限执行脚本。因此在你使用之前,请先了解一下插件的作者、评价及背景。总之,下面是一些我最喜欢的插件:

  • Deque Systems 提供的 aXe,是一款优秀的可行性分析插件。
  • 如果你工作中使用 React,React DevTools 是你必不可少的工具,你可以通过它观察虚拟 DOM。
  • Vue DevTools,当你使用 Vue 时,同上。
  • Codopen:它会会从编辑器模式弹出 CodePen 的调试窗口。八卦:我老公因为不喜欢看到我一直手动打开调试窗口,所以特意开发了这个工具。(真是个好礼物)
  • Pageruler:它能得到页面中的像素尺寸以及任何需要测量的值。我喜欢这个工具,因为我对于我的布局变态般挑剔。它能帮助我解决这些问题。

开发者工具

这可能是最直观的调试工具了,你可以用它们办到许多事情。它们有着许多内置的特性容易被人所忽视,因此在这个章节中,我们会深入探讨一些我喜欢的特性。

关于学习开发者工具的功能,Umar Hansa 有一套特别好的资料。他制作了一个每周周报与 GIF 动图网站、制作了我们最后一节提到的一个新课程,并在我们网站发表了这篇文章

我最近特别喜欢的一个工具是CSS Tracker 增强插件,收到 Umar 的许可之后我将这个工具在这儿展示给大家看。它会显示出所有没有使用过的 CSS,你可以由此来理解 CSS 对于性能的影响。

upload successful

上图展示了 CSS tracker 为代码被使用的部分和未被使用的部分按照规则表上不同的颜色。

各色各样的工具

  • What input 是一个能跟踪当前输入(鼠标、键盘、触摸)与当前信息的实用工具。(感谢 Marcy Sutton 提供了这个便捷的工具)
  • 如果你做的是响应式开发,或者你得在无数种设备上进行检查,那么 Ghostlabapp 是个挺适合你的时髦工具。它为你提供了同步移动 web 开发、测试与检查。
  • Eruda 是个很棒的工具,它可以帮助我们在移动设备上进行调试。我很喜欢它,因为它不仅是一个模拟器,还为你准备了控制台和真实的开发者工具,让你更容易理解。
upload successful

特别提示

我一直对其他人是怎么 debug 的很感兴趣,所以我通过 CSS-Tricks 与我的个人账号在社区征集大家最喜欢的调试方式。以下是社区中大家给出的技巧的合集。

译注:以下如“@xxx -2017年3月15日”格式的文字均为用户在推特上的发言,点击日期可以看到原推特。

辅助方法

1
2
$('body').on('focusin',function(){
console.log(document.activeElement);});

这段代码会记录当前焦点所在的元素。它用起来很方便,因为当你打开开发者工具的时候会将 activeElement 的焦点移除。

-Marcy Sutton

调试 CSS

我们收到很多回复说一些人喜欢在元素外面加上红色的边框(border),以此来观察元素的行为。

@sarah_edo:对于 CSS,我通常会给有问题的元素加上一个 .debug 的 class,这个 class 定义了红色的 border。

— Jeremy Wagner (@malchata) 2017年3月15日

我也会这么做。而且我还做了一个简单的 CSS 文件,可以让我方便地用一些 class 来加上不同的颜色。

检测 React 的 State

@sarah_edo
{JSON.stringify(this.state, null, 2)}

— MICHAEL JACKSON (@mjackson) 2017年3月15日

Michael 提到的这个办法,是我认为最有用的 debug 工具之一。这点代码可以“美观地输出”你当前正在使用的组件的 state,因此你可以了解此时此刻这个组件将会如何变化。你可以确认这个 state 是否和你设想的一样正常工作,它可以帮助你跟踪任何 state 中的错误,以及你使用 state 出现的错误。

动画

我们收到了许多的回复,说他们会在调试时减慢动画速度:

@sarah_edo@Real_CSS_Tricks: * { animation-duration: 10s !important; }

— Thomas Fuchs (@thomasfuchs) 2017年3月15日

我在之前的文章《调试 CSS 关键帧动画》中提到过这个问题,那篇文章里还有更多的技巧,例如如何使用硬件加速、如何在不同时刻进行多种变换等。

我也会使用 JavaScript 将我的动画减速。在 GreenSock 中,以这种形式实现:timeline.timeScale(0.5),它将会将整个时间轴都减速,而不是仅仅将一个动画减速,这个功能超级有用。在 mo.js 中,这个功能是这么写的:{speed: 0.5}

译注:GreenSock 与 mo.js 都是功能强大的js动画库

Val Head 通过屏幕录像做了一个很好的视频,这个视频展示了 Chrome 与 Firefox 开发者工具中提供的动画调试功能。

如果你打算用 Chrome 开发者工具的时间轴来进行性能评估,那么请注意绘制(paint)是最耗性能的步骤,因此当时间轴中绿色占比很高的时候请当心。

检查不同连接状态下的加载情况

我往往在网速很快的条件中工作,所以我会限制我的网速来观察那些网速较慢的人们所体验到的性能。

upload successful

这是个很有用的功能。它可以与强制刷新、清除缓存结合起来使用。

@sarah_edo:这儿有个不是秘密的小技巧,但是很多人还不知道:打开开发者工具,然后在刷新按钮上右击。pic.twitter.com/FdAfF9Xtxm

— David Corbacho (@dcorbacho) 2017年3月15日

设置定时 Debugger

这一条是 Chris 提供的。对于这点我们写了一篇详细的文章

1
2
3
setTimeout(function() {
debugger;
}, 3000);

它与我之前提到的 debugger; 工具很类似,不过你可以把它放在 setTimeout 函数中,得到更多详细的信息。

模拟器

@Real_CSS_Tricks 有的 Mac 用户可能还不知道,用 iOS 模拟器加上 Safari 简直不要太方便! pic.twitter.com/Uz4XO3e6uD

— Chris Coyier (@chriscoyier) 2017年3月15日

我前面提到了使用 Eruda 模拟器。iOS 用户还有一种很好的模拟器可以使用。在过去,我会告诉你你得先安装 XCode,但是这条推特提供了一种不同的方法:

@chriscoyier@Real_CSS_Tricks 如果你不想装 XCode,你也可以通过这种方式来使用模拟器:https://t.co/WtAnZNo718

— Chris Harrison (@cdharrison) 2017年3月15日

Chrome 也有切换设备型号功能,很实用。

远程调试

@chriscoyier@Real_CSS_Tricksjsconsole 是个很棒的工具。

— Gilles 💾⚽ (@gfra54) 2017年3月15日

在看到他发的这条推特前,我还真不知道有这么一个好用的工具!

译注,jsconsole 官网现在因为未知原因打不开了,也可以用 Weinre 和 Ghostlab 等工具进行移动远程调试。

调试 CSS 网格布局

Rachel Andrew 也送给我们一个很好的方法。当你使用 Firefox 时,点击一个图标,网格的间隔将会被高亮。她的视频详细地解释了这个技巧。

upload successful

上图为 Rachel Andrew 展示了如何在 Firefox 开发者工具中将网格的间距高亮。

数组调试

Wes Bos 提供了一个在数据中搜索元素的一个很有用的技巧:

你可以用 array.find 来查找元素🔥 https://t.co/AuRtyFwnq7

— Wes Bos (@wesbos) 2017年3月15日

更多调试相关的资源

Jon Kuperman 制作了一个 “前端能手课程”,这个课程将会通过这个 app 来帮助你掌握开发者工具的使用。

code school 的一个小课程:发现开发者工具

Umar Hansa 的一个新的在线课程: 现代开发者工具

Julia Evans 写了一篇很不错的 关于调试的文章,在此向 Jamison Dance 致谢,感谢他让我看到这么好的文章。

Paul Irish 总结了一些 使用开发者工具进行性能检查的高级技巧。如果你和我一样是个书呆子,可以把它收藏起来深入研究。

在文章的最后,我将放上一个让人喜忧参半的资源。我的朋友 James Golick 是一位杰出的程序员,在多年以前做过一个关于 degub 的会议讲话。虽然 James 去世了,但是我们仍然能在这个视频中回忆他、向他学习。点击观看视频

发布于掘金 https://juejin.im/post/5901e8d6a0bb9f0065e64f63

upload successful

当听说出了一门新技术的时候,你可能会和我一样有以下 3 种反应:

1. 嫌弃

又来一个 JavaScript 类库?反正我只用 JQuery 就行了。

2. 感兴趣

嗯,也许我应该去了解一下这个我总是听别人说到的新库。

3. 恐慌

救命啊!我必须马上去学这个新库,否则我就会被淘汰了!

在这个迅速发展的时代,让你保持理智的方法就是保持上述第二或第三种态度去学一些新的知识,走在潮流之前的同时激起你的兴趣。

因此,现在就是学习 GraphQL 这个你常常听到别人谈论的东西的最好时机!

基础

简单的说,GraphQL 是一种描述请求数据方法的语法,通常用于客户端从服务端加载数据。GraphQL 有以下三个主要特征:

  • 它允许客户端指定具体所需的数据。
  • 它让从多个数据源汇总取数据变得更简单。
  • 它使用了类型系统来描述数据。

如何入门 GraphQL 呢?它实际应用起来是怎样的呢?你如何开始使用它呢?要找到以上问题的答案,请继续阅读吧!

upload successful

遇到的问题

GraphQL 是由 Facebook 开发的,用于解决他们巨大、老旧的架构的数据请求问题。但是即使是比 Facebook 小很多的 app,也同样会碰上一些传统 REST API 的局限性问题。

例如,假设你要展示一个文章(posts)列表,在每篇文章的下面显示喜欢这篇文章的用户列表(likes),其中包括用户名和用户头像。这个需求很容易解决,你只需要调整你的 posts API 请求,在其中嵌入包括用户对象的 likes 列表,如下所示:

upload successful

但是现在你是在开发移动 app,加载所有的数据明显会降低 app 的速度。所以你得请求两个接口(API),一个包含了 likes 的信息,另一个不含这些信息(只含有文章信息)。

现在我们再掺入另一种情况:posts 数据是由 MySQL 数据库存储的,而 likes 数据却是由 Redis 存储的。现在你该怎么办?

按着这个剧本想一想 Facebook 的客户端有多少个数据源和 API 需要管理,你就知道为什么现在评价很好的 REST API 所体现出的局限性了。

解决的方案

Facebook 提出了一个概念很简单的解决方案:不再使用多个“愚蠢”的节点,而是换成用一个“聪明”的节点来进行复杂的查询,将数据按照客户端的要求传回。

实际上,GraphQL 层处于客户端与一个或多个数据源之间,它接收客户端的请求然后根据你的设定取出需要的数据。还是不明白吗?让我们打个比方吧!

之前的 REST 模型就好像你预定了一块披萨,然后又要叫便利店送一些日用品上门,接着打电话给干洗店去取衣服。这有三个商店,你就得打三次电话。

upload successful

GraphQL 从某方面来说就像是一个私人助理:你只需要给它这三个店的地址,然后简单地告诉它你需要什么 (“把我放在干洗店的衣服拿来,然后带一块大号披萨,顺便带两个鸡蛋”),然后坐着等他回来就行了。

upload successful

换句话说,为了让你能和这个神奇的私人助手沟通,GraphQL 建立了一套标准的语言。

upload successful

上图是 Google 图片找的,有的私人助理甚至有八条手臂。

upload successful

理论上,一个 GraphQL API 主要由三个部分组成:schema(类型)queries(查询) 以及 resolvers(解析器)

查询(Queries)

你向你的 GraphQL 私人助理提出的请求就是 query ,query 的形式如下所示:

1
2
3
query {
stuff
}

在这里,我们用 query 关键字定义了一个新的查询,它将取出名叫 stuff 的字段。GraphQL 查询(Queries)最棒之处就是它支持多个字段嵌套查询,我们可以在上面的基础上加深一个层级:

1
2
3
4
5
6
7
query{
stuff {
eggs
shirt
pizza
}
}

正如你所见,客户端在查询的时候不需要关心数据是来自于哪一个“商店”的。你只需要请求你要的数据,GraphQL 服务端将会完成其它所有的工作。

还有一点值得注意,query 字段也可以指向一个数组。例如,以下是一个查询一个文章列表的常用模式:

1
2
3
4
5
6
7
8
9
10
11
query {
posts { # this is an array
title
body
author { # we can go deeper!
name
avatarUrl
profileUrl
}
}
}

Query 字段也支持使用参数。如果我想展示一篇特别的文章,我可以将 id 参数放在 post 字段中:

1
2
3
4
5
6
7
8
9
10
11
query {
post(id: "123foo"){
title
body
author{
name
avatarUrl
profileUrl
}
}
}

最后,如果我想让 id 参数能动态改变,我可以定义一个变量,然后在 query 字段中重用它。(请注意,我们在 query 字段处也要定义一次这个变量的名字)

1
2
3
4
5
6
7
8
9
10
11
query getMyPost($id: String) {
post(id: $id){
title
body
author{
name
avatarUrl
profileUrl
}
}
}

有个很好的方式来实践这些方法:使用 GitHub’s GraphQL API Explorer 。例如,你可以尝试下面的查询:

1
2
3
4
5
6
query {
repository(owner: "graphql", name: "graphql-js"){
name
description
}
}
upload successful

GraphQL 的自动补全功能

当你尝试在下面输入一个名为 description 的新字段名时,你可能会注意到 IDE 会根据 GraphQL API 将可选的字段名自动补全。真棒!

upload successful

The Anatomy of a GraphQL Query

你可以读读这篇超棒的文章《Anatomy of a GraphQL Query》,了解更多 GraphQL 查询的知识。

解释器(Resolvers)

除非你给他们地址,否则即使是这个世界上最好的私人助理也不能去拿到干洗衣物。

同样的,GraphQL 服务端并不知道要对一个即将到来的查询做什么处理,除非你使用 resolver 来告诉他。

一个 resolver 会告诉 GraphQL 在哪里以及如何去取到对应字段的数据。例如,下面是之前我们取出 post 字段例子的 resolver(使用了 Apollo 的 GraphQL-Tools ):

1
2
3
4
5
Query: {
post(root, args) {
return Posts.find({ id: args.id });
}
}

在这个例子中,我们将 resolver 放在 Query 中,因为我们想要直接在根层级查询 post。但你也可以将 resolver 放在子字段中,例如查询 post(文章)的 author(作者)字段可以按照下面的形式:

1
2
3
4
5
6
7
8
9
10
Query: {
post(root, args) {
return Posts.find({ id: args.id });
}
},
Post: {
author(post) {
return Users.find({ id: post.authorId})
}
}

还有,resolver 不仅仅只能返回数据库里的内容,例如,如果你想为你的 Post 类型加上一个 commentsCount(评论数量)属性,可以这么做:

1
2
3
4
5
6
7
8
Post: {
author(post) {
return Users.find({ id: post.authorId})
},
commentsCount(post) {
return Comments.find({ postId: post.id}).count()
}
}

理解这里的关键在于:对于 GraphQL,你的 API 结构与你的数据库结构是解耦的。换一种说法,我们的数据库中可能根本就没有 authorcommentsCount 这两个字段,但是我们可以通过 resolver 的力量将它们“模拟”出来。

正如你所见,我们可以在 resolver 中写任何你想写的代码。因此,你可以通过改变 resolver 任意地修改数据库中的内容,这种形式也被称为 mutation resolver。

类型(Schema)

GraphQL 的类型结构系统可以让很多事情都变得可行。我今天的目标仅仅是给你做一个快速的概述而不是详细的介绍,所以我不会在这个内容上继续深入。

话虽如此,如果你想了解更多这方面的信息,我建议你阅读 GraphQL 官方文档

upload successful

常见问题

让我们先暂停,回答一些常见的问题。

你肯定想问一些问题,来吧,尽管问别害羞!

GraphQL 与图形数据库有什么关系?

它们真的没有关系,GraphQL 与诸如 Neo4j 之类的图形数据库没有任何关系。名称中的 “Graph” 是来自于 GraphQL 使用字段与子字段来遍历你的 API 图谱;“QL” 的意思是“查询语言”(query language)。

我用 REST 用的很开心,为什么我要切换成 GraphQL 呢?

如果你使用 REST 还没有碰上 GraphQL 所解决的那些痛点,那当然是件好事啦!

但是使用 GraphQL 来代替 REST 基本不会对你 app 的用户体验产生任何影响,所以“切换”这件事并不是所谓“生或死”的抉择。话虽如此,我还是建议你如果有机会的话,先在项目里小范围地尝试一下 GraphQL 吧。

如果我不用 React、Relay 等框架,我能使用 GraphQL 吗?

当然能!因为 GraphQL 仅仅是一个标准,你可以在任何平台、任何框架中使用它,甚至在客户端中也同样能应用它(例如,Apollo 有针对 web、iOS、Angular 等环境的 GraphQL 客户端)。你也可以自己去做一个 GraphQL 服务端。

GraphQL 是 Facebook 做的,但是我不信任 Facebook

再强调一次,GraphQL 只是一个标准,这意味着你可以在不用 Facebook 一行代码的情况下实现 GraphQL。

并且,有 Facebook 的支持对于 GraphQL 生态系统来说是一件好事。关于这块,我相信 GraphQL 的社区足够繁荣,即使 Facebook 停止使用 GraphQL,GraphQL 依然能够茁壮成长。

“让客户端自己请求需要的数据”这整件事情听起来似乎不怎么安全……

你得自己写自己的 resolver,因此在这个层面上是否会出现安全问题完全取决于你。

例如,为了防止客户端一遍又一遍地请求查询记录造成 DDOS 攻击,你可以让客户端指定了一个 limit 参数去控制它接受数据的数量。

那么我如何上手 GraphQL?

通常来说,一个 GraphQL 驱动的 app 起码需要以下两个组件:

  • 一个 GraphQL 服务端 来为你的 API 提供服务。
  • 一个 GraphQL 客户端 来连接你的节点。

了解更多可用的工具,请继续阅读。

upload successful

现在你应该对 GraphQL 有了一个恰当的认识,下面让我们来介绍一下 GraphQL 的主要平台与产品。

GraphQL 服务端

万丈高楼平地起,盖起这栋楼的第一块砖就是一个 GraphQL 服务端。 GraphQL 它本身仅仅是一个标准,因此它敞开大门接受各种各样的实现。

GraphQL-JS (Node)

它是 GraphQL 的最初的实现。你可以将它和 express-graphql 一起使用,创建你自己的 API 服务

GraphQL-Server (Node)

Apollo 团队也有他们自己的一站式 GraphQL 服务端实现。它虽然还没有像 GraphQL-JS 一样被广泛使用,但是它的文档、支持都做得很棒,使用它能快速取得进展。

其它平台

GraphQL.org 列了一个清单: GraphQL 在其它平台下的实现清单 (包括 PHP、Ruby 等)。

GraphQL 客户端

虽然你不使用客户端类库也可以很好地查询 GraphQL API,但是一个相对应的客户端类库将会让你的开发更加轻松

Relay

Relay 是 Facebook 的 GraphQL 工具。我还没用过它,但是我听说它主要是为了 Facebook 自己的需求量身定做的,可能对大多数的用户来说不是那么人性化。

Apollo Client

在这个领域的最新参赛者是 Apollo,它正在迅速发展。典型的 Apollo 客户端技术栈由以下两部分组成:

另外,在默认的情况下 Apollo 客户端使用 Redux 存储数据。这点很棒,Redux 本身是一个有着丰富生态系统的超棒的状态管理类库。

upload successful

Apollo 在 Chrome 开发者工具中的插件

开源 App

虽然 GraphQL 还属于新鲜事物,但是它已经被一些开源 app 使用了。

VulcanJS

upload successful

首先我得声明一下,我是 VulcanJS 的主要维护者。我创建 VulcanJS 是为了让人们在不用写太多样板代码的情况下充分享受 React、GraphQL 技术栈的好处。你可以把它看成是“现代 web 生态系统的 Rails”,让你可以在短短几个小时内做出你的 CRUD(增删查改)型 app。(例如 Instagram clone

Gatsby

Gatsby 是一个 React 静态网站生成器,它现在是基于 GraphQL 1.0 版本 开发。它一眼看上去像个奇怪的大杂脍,但其实它的功能十分强大。Gatsby 在构建过程中,可以从多个 GraphQL API 取得数据,然后用它们创建出一个全静态的无后端 React app。

其它的 GraphQL 工具

GraphiQL

GraphiQL 是一个非常好用的基于浏览器的 IDE,它可以方便你进行 GraphQL 端点查询。

upload successful

GraphiQL

DataLoader

由于 GraphQL 的查询通常是嵌套的,一个查询可能会调用很多个数据库请求。为了避免影响性能,你可以使用一些批量出入库框架和缓存库,例如 Facebook 开发的 DataLoader。

Create GraphQL Server

Create GraphQL Server 是一个简单的命令行工具,它能快速地帮你搭建好基于 Node 服务端与 Mongo 数据库的 GraphQL 服务端。

GraphQL 服务

最后,这儿列了一些 GraphQL BAAS(后台即服务)公司,它们已经为你准备好了服务端的所有东西。这可能是一个让你尝试一下 GraphQL 生态系统的很好的方式。

GraphCool

一个由 GraphQL 和 AWS Lambda 组成的一个弹性后端平台服务,它提供了开发者免费计划。

Scaphold

另一个 GraphQL BAAS 平台,它也提供了免费计划。与 GraphCool 相比,它提供了更多的功能。(例如定制用户角色、常规操作的回调钩子等)

upload successful

下面是一些能让你学习 GraphQL 的资源。

GraphQL.org

GraphQL 的官方网站,有许多很好的文档供你学习。

LearnGraphQL

LearnGraphQL 是由 Kadira 员工共同制作的课程。

LearnApollo

LearnApollo 是由 GraphCool 制作的免费课程,是对于 LearnGraphQL 课程的一个很好的补充。

Apollo 博客

Apollo 的博客有成吨的干货,有很多关于 Apollo 和 GraphQL 的超棒的文章。

GraphQL 周报

由 Graphcool 团队策划的一个简报,其内容包括任何有关 GraphQL 的信息。

Hashbang 周报

另一个不错的简报,除了 GraphQL 的内容外,还涵盖了 React、Meteor。

Awesome GraphQL

一个关于 GraphQL 的链接和资源的很全面的清单。

upload successful

你如何实践你刚学到的 GraphQL 的知识呢?你可以尝试下面这些方式:

Apollo + Graphcool + Next.js

如果你对 Next.js 与 React 很熟悉,这个例子将会帮助你使用 Graphcool 很快的搭建好你的 GraphQL 端点,并在客户端使用 Apollo 进行查询。

VulcanJS

Vulcan 教程将会引导你创建一个简单的 GraphQL 数据层,既有服务端部分也有客户端部分。因为 Vulcan 是一个一站式平台,所以这种无需任何配置的方式是一种很好的上手途径。如果你需要帮助,请访问我们的 Slack 栏目

GraphQL & React 教程

Chroma 博客有一篇《分为六部的教程》,讲述了如何按照组件驱动的开发方式来构建一个 React/GraphQL app。

upload successful

总结

当你刚开始接触 GraphQL 可能会觉得它非常复杂,因为它横跨了现代软件开发的众多领域。但是,如果你稍微花点时间去明白它的原理,我认为你可以找到它很多的可取之处。

所以不管你最后会不会用上它,我相信多了解了解 GraphQL 是值得的。越来越多的公司与框架开始接受它,过几年它可能会成为 web 开发的又一个重要组成部分。

赞同?不赞同?有疑问?请留下评论让我们知道你的看法。如果你还比较喜欢这篇文章,请点亮💚或者分享给他人。

发布于掘金 https://juejin.im/post/58fd6d121b69e600589ec740