JavaScript 是由什么组成的?
December 20, 2019
在我最初使用 JavaScript 的几年里,我总觉得自己像个骗子。尽管我能用各种框架搭建网站,但总觉得缺了点什么。每次面试 JavaScript 岗位时我都很害怕,因为我对基础知识没有扎实的掌握。
这些年下来,我逐渐形成了自己的 JavaScript 心智模型,让我变得自信起来。这里,我将分享一个高度精简版的心智模型。它以词汇表的形式组织,每个主题只用几句话介绍。
在阅读本文时,不妨在心里给每个主题打个分,看看自己对它们有多自信。如果有不少内容你还不太明白,也没关系,我不会评判你!在文章结尾,我会给出一些帮助你的建议。
-
值(Value):值的概念有点抽象。它就是一个“东西”。对 JavaScript 来说,值就像数学里的数字,或几何里的点。当你的程序运行时,整个世界都是由各种值组成的。像
1
、2
、420
这样的数字是值,这句话"Cows go moo"
也是值。但并不是所有东西都是值。数字是值,if
语句就不是。我们下面会看看不同类型的值。- 值的类型(Type of Value):值有几种不同的“类型”。比如,像
420
这样的数字,像"Cows go moo"
这样的字符串,还有对象等其他类型。你可以用typeof
来查看某个值的类型。例如,console.log(typeof 2)
会输出"number"
。 - 原始值(Primitive Values):有些值类型是“原始类型”。它们包括数字、字符串以及其他几种类型。原始值有个特别之处:你不能创造更多的它们,也不能以任何方式改变它们。例如,每次你写
2
,得到的都是同一个值2
。你不能在程序里“创造”另一个2
,也不能让2
这个值“变成”3
。字符串也是如此。 null
和undefined
:这是两个特殊的值。它们很特殊,因为你能做的事情很有限——经常会导致错误。通常,null
表示某个值是有意缺失的,而undefined
表示某个值是无意缺失的。不过,具体用哪个由程序员自己决定。它们存在的原因是,有时候操作失败总比在缺失值的情况下继续执行要好。
- 值的类型(Type of Value):值有几种不同的“类型”。比如,像
-
相等性(Equality):和“值”一样,相等性也是 JavaScript 的基础概念。我们说两个值相等时,其实……我不会这么说。如果两个值相等,意味着它们就是同一个值,不是两个不同的值,而是一个!比如,
"Cows go moo" === "Cows go moo"
和2 === 2
,因为2
就是2
。注意,在 JavaScript 里我们用三个等号来表示这种相等。- 严格相等(Strict Equality):同上。
- 引用相等(Referential Equality):同上。
- 宽松相等(Loose Equality):这个就不一样了!宽松相等是用两个等号(
==
)表示的。即使它们指向不同的值,只要看起来相似(比如2
和"2"
),也可能被认为宽松相等。这是 JavaScript 早期为了方便加上的,结果带来了无尽的困惑。这个概念并非基础,但却是常见的错误来源。你可以找个下雨天研究一下它的工作原理,不过很多人都尽量避免使用它。
-
字面量(Literal):字面量就是你在程序里直接写下某个值。例如,
2
是数字字面量,"Banana"
是字符串字面量。 -
变量(Variable):变量让你可以用名字来引用某个值。例如,
let message = "Cows go moo"
。现在你可以用message
来代替每次都写同一句话。你也可以让message
指向另一个值,比如message = "I am the walrus"
。注意,这并没有改变值本身,只是改变了message
指向的对象,就像一根“导线”。它原本指向"Cows go moo"
,现在指向"I am the walrus"
。- 作用域(Scope):如果整个程序只能有一个
message
变量,那就太糟糕了。实际上,当你定义一个变量时,它只在程序的一部分可用,这部分叫做“作用域”。关于作用域有一些规则,但通常你可以找一下定义变量处最近的一对{
和}
,这个“代码块”就是它的作用域。 - 赋值(Assignment):当我们写
message = "I am the walrus"
时,就是把message
变量指向"I am the walrus"
这个值。这叫做赋值、写入或设置变量。 let
vsconst
vsvar
:通常你应该用let
。如果你想禁止给这个变量重新赋值,可以用const
。(有些代码库或同事很较真,要求你只有一次赋值时必须用const
。)尽量避免用var
,因为它的作用域规则很混乱。
- 作用域(Scope):如果整个程序只能有一个
-
对象(Object):对象是 JavaScript 中一种特殊的值。对象的厉害之处在于它们可以和其他值建立连接。例如,
{flavor: "vanilla"}
这个对象有一个flavor
属性,指向"vanilla"
这个值。可以把对象想象成“你自己的”值,上面连着很多“导线”。- 属性(Property):属性就像从对象上伸出来的一根“导线”,指向某个值。它有点像变量:有名字(比如
flavor
),指向一个值(比如"vanilla"
)。但和变量不同,属性“存在于”对象本身,而不是代码的某个作用域里。属性被认为是对象的一部分——但它指向的值不是。 - 对象字面量(Object Literal):对象字面量就是你在程序里直接写下一个对象值,比如
{}
或{flavor: "vanilla"}
。在{}
里可以有多个属性: 值
对,用逗号分隔。这样我们就能设置对象上各个属性“导线”指向的值。 - 对象标识(Object Identity):前面提到,
2
等于2
(即2 === 2
),因为每次写2
,你“召唤”的都是同一个值。但每次写{}
,得到的总是不同的值!所以{}
并不等于另一个{}
。你可以在控制台试试:{} === {}
(结果是 false)。当计算机在代码里遇到2
,总是给你同一个2
值;而遇到{}
,则创建一个新对象,总是一个新值。那么什么是对象标识?其实就是相等性、同一性的另一种说法。当我们说“a
和b
有相同的标识”,意思是“a
和b
指向同一个值”(a === b
)。说“a
和b
有不同的标识”,就是“a
和b
指向不同的值”(a !== b
)。 - 点号访问(Dot Notation):当你想读取或赋值对象的属性时,可以用点号(
.
)语法。例如,如果变量iceCream
指向一个对象,它的flavor
属性指向"chocolate"
,那么写iceCream.flavor
就会得到"chocolate"
。 - 中括号访问(Bracket Notation):有时候你事先并不知道要读取哪个属性。例如,有时你想读
iceCream.flavor
,有时想读iceCream.taste
。中括号([]
)语法让你可以用变量来动态读取属性名。例如,假设let ourProperty = 'flavor'
,那么iceCream[ourProperty]
就会得到"chocolate"
。有趣的是,创建对象时也可以用它:{ [ourProperty]: "vanilla" }
。 - 变异(Mutation):当有人把对象的某个属性改为指向另一个值时,我们说这个对象被变异了。例如,
let iceCream = {flavor: "vanilla"}
,之后可以用iceCream.flavor = "chocolate"
来变异它。注意,即使我们用const
声明了iceCream
,依然可以变异iceCream.flavor
。这是因为const
只禁止给iceCream
变量本身重新赋值,但我们变异的是它指向的对象的属性。有些人觉得这太容易误导,干脆完全不用const
。 - 数组(Array):数组其实是一种表示一组东西的对象。当你写下数组字面量
["banana", "chocolate", "vanilla"]
时,本质上是创建了一个对象,它有名为0
的属性指向"banana"
,名为1
的属性指向"chocolate"
,名为2
的属性指向"vanilla"
。要是每次都写{0: ..., 1: ..., 2: ...}
就太麻烦了,所以数组很有用。数组还有很多内置方法,比如map
、filter
和reduce
。如果觉得reduce
难懂,不要灰心——大家都觉得难。 - 原型(Prototype):如果我们读取一个不存在的属性会发生什么?比如
iceCream.taste
(但我们只有flavor
属性)。简单来说,会得到特殊的undefined
值。更细致地说,大多数 JavaScript 对象都有一个“原型”。你可以把原型想象成每个对象上一个“隐藏”的属性,决定了“接下来去哪找”。所以如果iceCream
没有taste
属性,JavaScript 会去它的原型上找,然后再去原型的原型……直到在整个“原型链”上都找不到.taste
,才会返回undefined
。你很少需要直接操作这个机制,但它解释了为什么iceCream
对象有个我们没定义过的toString
方法——它来自原型。
- 属性(Property):属性就像从对象上伸出来的一根“导线”,指向某个值。它有点像变量:有名字(比如
-
函数(Function):函数是一种特殊的值,唯一的作用就是:它代表你程序中的一段代码。如果你不想重复写同样的代码,函数就很有用。像
sayHi()
这样“调用”一个函数,就是让计算机运行它里面的代码,然后再回到原来的地方。JavaScript 有很多定义函数的方式,细节上略有不同。- 参数(Arguments 或 Parameters):参数让你可以从调用处向函数传递信息,比如
sayHi("Amelie")
。在函数内部,它们和变量类似。根据你是在定义函数还是调用函数,分别叫“参数”或“实参”,但这个术语区分其实很学究,实际中大家混着用。 - 函数表达式(Function Expression):之前我们把变量赋值为字符串,比如
let message = "I am the walrus"
。其实也可以把变量赋值为函数,比如let sayHi = function() { }
。等号右边的就是函数表达式。它给我们一个特殊的值(函数),代表那段代码,方便以后调用。 - 函数声明(Function Declaration):每次都写
let sayHi = function() { }
很麻烦,所以有更简洁的写法:function sayHi() { }
。这叫函数声明。不用在左边写变量名,而是直接跟在function
关键字后面。这两种写法基本可以互换。 - 函数提升(Function Hoisting):通常,只有在用
let
或const
声明的变量被执行到之后才能用。这对函数来说很麻烦,因为函数可能会相互调用,很难理清谁先定义谁后定义。为了解决这个问题,只有用函数声明语法时,定义顺序就不重要了,因为它们会被“提升”。意思是说,概念上它们会自动被移到作用域顶部。等你调用它们时,它们都已经定义好了。 this
:可能是 JavaScript 里最让人困惑的概念,this
像是函数的一个特殊参数。你不会自己传递它,而是 JavaScript 根据你怎么调用函数来传递它。例如,使用点号.
调用——像iceCream.eat()
——会让this
的值来自点号前面的对象(这里是iceCream
)。函数内部的this
值取决于调用方式,而不是定义位置。像.bind
、.call
和.apply
这样的辅助方法可以让你更灵活地控制this
的值。- 箭头函数(Arrow Functions):箭头函数和函数表达式类似。你可以这样声明:
let sayHi = () => { }
。它们语法简洁,常用于一行代码的场景。箭头函数比普通函数更有限——比如它们根本没有this
的概念。在箭头函数里写this
,其实用的是外层最近的“普通”函数的this
。这就像你在箭头函数里用一个只在外层函数存在的参数或变量一样。实际上,这意味着人们在需要箭头函数内部“看到”和外部一样的this
时会用它。 - 函数绑定(Function Binding):通常,把函数
f
绑定到特定的this
和参数,就是创建一个新函数,这个新函数会用预设的值调用f
。JavaScript 有内置的.bind
方法来实现,但你也可以手动实现。绑定曾经是让嵌套函数“看到”外层函数同一个this
的常用方法,但现在箭头函数已经解决了这个问题,所以绑定用得没那么多了。 - 调用栈(Call Stack):调用函数就像进入一个房间。每次调用函数,里面的变量都会重新初始化。所以每次函数调用就像建造一个新“房间”并进入。函数的变量“生活”在这个房间里。当我们从函数返回时,这个“房间”连同所有变量都会消失。你可以把这些房间想象成一摞垂直叠放的房间——也就是调用栈。退出函数时,我们就回到调用栈“下面”的那个函数。
- 递归(Recursion):递归就是函数在内部调用自己。这在你想让函数“再做一次同样的事,但用不同参数”时很有用。例如,你在写一个爬虫,
collectLinks(url)
函数先收集页面上的链接,然后对每个链接再递归调用自己,直到访问完所有页面。递归的陷阱是很容易写出永远不会结束的代码,因为函数会一直调用自己。如果这样,JavaScript 会报错叫“栈溢出”(stack overflow)。这个名字的意思就是调用栈里堆积了太多函数调用,真的溢出了。 - 高阶函数(Higher-Order Function):高阶函数就是接受其他函数作为参数,或者返回函数的函数。乍一看可能很奇怪,但要记住函数也是值,所以我们可以像传递数字、字符串、对象一样传递函数。这种风格如果滥用会很混乱,但适度使用非常强大。
- 回调(Callback):回调其实不是 JavaScript 的术语,更像是一种模式。就是你把一个函数作为参数传给另一个函数,期望它以后再调用你的函数。你希望它“回调”你。例如,
setTimeout
接收一个回调函数,然后……在超时后回调你。但回调函数本身没什么特别的。它们就是普通函数,说“回调”只是表达我们的期望。 - 闭包(Closure):通常,当你退出一个函数时,里面的所有变量都会“消失”。因为没人再需要它们。但如果你在一个函数内部声明了另一个函数呢?那么这个内部函数以后还可能被调用,并且能读取外部函数的变量。实际上,这非常有用!但要实现这一点,外部函数的变量就得“留存”下来,而不是像平常那样被遗忘。这时,JavaScript 会负责“让这些变量继续存活”,而不是像通常那样“忘掉”它们。这就叫“闭包”。虽然闭包常被认为是 JavaScript 里难懂的概念,但你可能每天都在用,只是没意识到!
- 参数(Arguments 或 Parameters):参数让你可以从调用处向函数传递信息,比如
JavaScript 就是由这些概念(以及更多)组成的。在我建立起正确的心智模型之前,我一直对自己的 JavaScript 知识很焦虑。我希望能帮助新一代开发者更快地跨越这个鸿沟。
如果你想和我一起深入学习这些主题,我有个东西推荐给你。Just JavaScript 是我对 JavaScript 工作原理的精炼心智模型,并且会有出色的 Maggie Appleton 提供的可视化插图。和这篇文章不同,它节奏更慢,让你能细致跟进每一个细节。
Just JavaScript 目前还处于非常早期的阶段,所以只以一系列邮件的形式发送,没有任何润色或编辑。如果你对这个项目感兴趣,可以点击这里注册,免费通过邮件收到草稿。非常欢迎你的反馈。谢谢!
Pay what you like