TypeScript

TypeScript

  • 开始
  • 官方文档
  • 语言规范

›手册

文档说明

  • 文档约定
  • 历史版本

入门指南

  • 五分钟速览

手册

  • 数据类型
  • 变量声明
  • 接口
  • 类
  • 函数
  • 泛型
  • 枚举
  • 类型推断
  • 类型兼容
  • 高级类型

变量声明

let 和 const 是 JavaScript 中相对比较新的变量声明方式,正如我们前面所述,let 在某些方面和 var 有些类似,但是 let 可以避免使用者在使用 JavaScript 编写代码时落入一些常见的陷阱。const 为 let 增加了一些特性,阻止变量被重新赋值。

TypeScript 是 JavaScript 的超集,自然支持 let 和 const。在这里,我们将详细说明这些新的声明方式以及为什么它们比 var 更可取。

如果你使用过 JavaScript,下面的部分将帮助你加深印象。如果你非常了解使用 var 声明变量的时候造成的令人迷惑的问题,你可以选择跳过当前部分。

使用 var

在 JavaScript 中声明变量我们习惯性地采用 var 关键字。

var a = 10;

你应该知道,以上代码声明了一个变量 a,它的值是 10。

我们也可以在函数中声明一个变量。

function f() {
    var message = "Hello, world!";

    return message;
}

我们也可以在其他的函数内部访问这些变量。

function f() {
    var a = 10;
    return function g() {
        var b = a + 1;
        return b;
    }
}

var g = f();
g(); // returns '11'

在上面的例子中,变量 g 使用了在函数 f 中声明的变量 a。当 g 被调动的时候,a 的值将与函数 f 中 a 的值绑定。就算 f 函数已经执行完成,a 的值仍然可以被访问或被修改。

function f() {
    var a = 1;

    a = 2;
    var b = g();
    a = 3;

    return b;

    function g() {
        return a;
    }
}

f(); // returns '2'

作用域规则

相比其他语言,用 var 声明的变量有一些奇怪的作用域规则,看下面的例子:

function f(shouldInitialize: boolean) {
    if (shouldInitialize) {
        var x = 10;
    }

    return x;
}

f(true);  // returns '10'
f(false); // returns 'undefined'

一些读者可能没法马上理解这个例子。变量 x 是在 if 代码块中声明的,然而我们却可以从 if 代码块的外部访问它。那是因为 var 声明的变量可以在包含该变量的函数、模块、命名空间或者全局范围内的任何位置被访问(这些我们后面都会详细描述),无论该变量是在什么代码块内。有些人将这称为 var 作用域或函数作用域。参数也是函数作用域。

这些作用域规则会造成很多的错误。一个大问题是多次声明一个相同的变量是被允许的。

function sumMatrix(matrix: number[][]) {
    var sum = 0;
    for (var i = 0; i < matrix.length; i++) {
        var currentRow = matrix[i];
        for (var i = 0; i < currentRow.length; i++) {
            sum += currentRow[i];
        }
    }

    return sum;
}

上面例子中内部 for 循环会意外覆盖变量 i,因为 i 引用的是同一函数作用域的变量。正如经验丰富的开发人员所知,类似的错误会在代码审查中被漏过,并持续让人感觉很有挫败感。

变量持有

来看看下面这段代码的输出。

for (var i = 0; i < 10; i++) {
    setTimeout(function() { console.log(i); }, 100 * i);
}

setTimeout 将在指定的毫秒数之后尝试执行函数(?though waiting for anything else to stop running)。

准备好了吗?看看输出。

10
10
10
10
10
10
10
10
10
10

很多的 JavaScript 开发人员都熟悉这个问题,但是如果你感到惊讶,那么你也并不是一个人。大多数人期待的输出应该像下面这样。

0
1
2
3
4
5
6
7
8
9

还记得我们之前提到过的变量持有吗?我们传递给 setTimeout 的每个函数表达式中的 i 实际上是同一个作用域中的同一个 i。

让我们看看这是什么意思,setTimeout 将在一定的毫秒数之后尝试执行函数,但是实际上是在 for 循环结束之后尝试执行函数(~因为 for 循环的速度太快了,在 setTimeout 开始调用函数的时候,for 循环早已经执行完成)。在 for 循环结束之后,i 的值已经是 10 了,所以每一次 setTimeout 调用函数的时候都会打印 10。

常见的解决方法是使用 IIFE - Immediately Invoked Function Expression,每一次迭代的时候持有变量 i。

for (var i = 0; i < 10; i++) {
    // capture the current state of 'i'
    // by invoking a function with its current value
    (function(i) {
        setTimeout(function() { console.log(i); }, 100 * i);
    })(i);
}

这种看上去很奇怪的模式其实很常见,参数列表中的 i 实际上映射 for 循环中声明的 i,但是由于我们给它们起了相同的名字,所以我们不必再去修改循环体里面的代码。

使用 let

现在你应该知道了 var 有很多的问题,这就是为什么引入 let 的原因。除了声明变量所使用的关键字不同以外,其他都和使用 var 没有区别。

let hello = "Hello!";

关键的不同并不在于语法,而在于语义,让我们深入讨论。

块级作用域

当一个变量使用 1et 声明的时候,它使用了所谓的词法作用域或者块级作用域。与 var 变量将自己暴露到其所在的函数作用域不同,块级作用域中的变量在包含它的最近的块或for循环之外是完全不可见的。

function f(input: boolean) {
    let a = 100;

    if (input) {
        // Still okay to reference 'a'
        let b = a + 1;
        return b;
    }

    // Error: 'b' doesn't exist here
    return b;
}

上面的代码中有两个变量 a 和 b,a 的可见性被限制在函数体 f 内部,而 b 的可见性被限制在包含它的 if 代码块之中。

在 catch 语法中声明的变量的可见性也具有相同的规则。

try {
    throw "oh no!";
}
catch (e) {
    console.log("Oh well.");
}

// Error: 'e' doesn't exist here
console.log(e);

块级作用域中声明的变量的另外一个特性是:在它们声明之前,变量不能被读写。尽管变量在作用域里面都存在,从块级作用域开始直到变量被声明这中间的区域叫暂时性死区。这也就是变量在 let 声明之前不能使用的一种复杂说法,TypeScript 将在语法层面提示你这一点。

a++; // illegal to use 'a' before it's declared;
let a;

需要注意的是你仍然可以在一个块级作用域变量被声明之前持有它,唯一需要注意的是不能在声明该变量之前调用含有该变量函数。如果编译器的目标版本是 ES2015,先进的运行环境将抛出异常。然而目前 TypeScript 允许这种使用用法并且不会报错。

function foo() {
    // okay to capture 'a'
    return a;
}

// illegal call 'foo' before 'a' is declared
// runtimes should throw an error here
foo();

let a;

更多关于暂时性死区的内容,请查看 Mozilla Developer Network 内相关的内容。

重复声明和覆盖(Shadowing)

当使用 var 声明变量的时候,var 语法并不关心你的变量声明了多少次,你只会得到一个变量。

function f(x) {
    var x;
    var x;

    if (true) {
        var x;
    }
}

在上面的例子中,所有关于 x 的声明实际上都指向同一个 x,这完全是合法的。而这通常也会成为 BUG 的来源,幸亏 let 语法没有那么宽容。

let x = 10;
let x = 20; // error: can't re-declare 'x' in the same scope

TypeScript 在检查声明语法的时候不要求变量都必须是块级作用域。

function f(x) {
    let x = 100; // error: interferes with parameter declaration
}

function g() {
    let x = 100;
    var x = 100; // error: can't have both declarations of 'x'
}

这并不是说就不能同时声明一个块级作用域变量和一个函数作用域变量。块级作用域变量只要求该变量声明时所在的代码块不同就行。

function f(condition, x) {
    if (condition) {
        let x = 100;
        return x;
    }

    return x;
}

f(false, 0); // returns '0'
f(true, 0);  // returns '100'

在内部嵌套的块里面声明同样名称的变量的行为称为覆盖。它有点像一把双刃剑,因为它会在意外覆盖的情况下引入某些 BUG ,也会避免一些特定的 BUG 。假设我们之前已经使用 let 变量编写了 sumMatrix 函数。

function sumMatrix(matrix: number[][]) {
    let sum = 0;
    for (let i = 0; i < matrix.length; i++) {
        var currentRow = matrix[i];
        for (let i = 0; i < currentRow.length; i++) {
            sum += currentRow[i];
        }
    }

    return sum;
}

循环实际执行的时候不会有代码错误,但是内部循环的 i 覆盖了外部循环的 i。

为了让代码更通俗易懂,平常应该避免变量覆盖。尽管在某些情况下它可能有应用价值,你也应该仔细考虑是否值得使用(~避免混淆)。

块级作用域的变量

在之前我们使用 var 声明变量时,我们简要介绍了变量被持有后的行为。每次作用域启动的时候,都会创建变量的“环境”,该“环境”及其持有的变量即使在作用域内的所有任务都执行完毕后也可以存在。

function theCityThatAlwaysSleeps() {
    let getCity;

    if (true) {
        let city = "Seattle";
        getCity = function() {
            return city;
        }
    }

    return getCity();
}

因为我们已经在 city 变量的运行环境中持有了该变量,所以即便 if 代码块执行完成,我们仍然可以访问它。

再看之前的 setTimeout 的例子,我们需要使用 IIFE 来持有 for 循环每次迭代中变量的状态。我们做的就是为持有的变量创造一个新的变量环境,这种做法比较笨拙,但幸运的是你再也不用在 TypeScript 中那样做了。

当 let 变量参与循环的时候,代码语义将完全不同。let 将在循环每次迭代的时候创建一个全新的作用域,而不仅仅在循环中引入新的变量环境。这件事之前我们是使用 IIFE 来做的,现在我们可以使用 let 来改造 setTimeout 这个例子了。

for (let i = 0; i < 10 ; i++) {
    setTimeout(function() { console.log(i); }, 100 * i);
}

正如预期的一样,输出如下。

0
1
2
3
4
5
6
7
8
9

使用 const

const 是声明变量的另外一种方法。

const numLivesForCat = 9;

它有点像 let,但是它的名字就暗示着变量一旦被绑定就不能再更改。也就是说,它和 let 一样,有同样的作用域规则,但是你不能对 const 变量再次赋值。

请注意,不能对 const 变量再次赋值并不代表该类变量是不可变的(immutable)。

const numLivesForCat = 9;
const kitty = {
    name: "Aurora",
    numLives: numLivesForCat,
}

// Error
kitty = {
    name: "Danielle",
    numLives: numLivesForCat
};

// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;

除非你采用特殊的方法避免这个规则,const 定义的变量其内部状态依然是可以修改的。幸运的是,TypeScript 允许你指定一个对象的成员为 readonly,接口一章会有详细描述。

let vs const

鉴于我们有两种具有相似作用域语义的变量声明方式,那很自然可以想到,我们该使用哪一种呢?就像大多数问题一样,答案是:看具体情况。

根据最小权限原则,除了你准备修改的变量以外,其他所有变量都应该用 const 来声明。基本原理是:如果一个变量不需要被改写,其他使用这个代码的人就不应该能够改写这个变量,同时也要考虑是否真的有必要对这个变量重新赋值。使用 const 也可以在分析数据流时让代码变得具有可预测性。

请仔细考虑使用情况,并且如果条件允许的话尽可能和团队成员一起讨论(~以形成约定)。

本手册大部分地方都使用 let。

解构

另一个 TypeScript 也具有的 ES2015 的特性是解构,相关的完整指南,请看 Mozilla Developer Network 上面的文章,这里我们只是简单介绍一下。

数组解构

解构最简单的形式是数组解构。

let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2

上面的代码创建了两个新的变量 first 和 second,它和使用索引方式是一样的,但是更方便。

first = input[0];
second = input[1];

解构时也可以使用已经声明的变量。

// swap variables
[first, second] = [second, first];

解构也可以在函数参数中使用。

// swap variables
function f([first, second]: [number, number]) {
    console.log(first);
    console.log(second);
}
f([1, 2]);

你可以使用 ... 语法创建一个变量用于持有列表剩余部分的值。

let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]

当然你也可以忽略后续尾随的无关数组元素。

let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1

或者另外一些元素。

let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // outputs 2
console.log(fourth); // outputs 4

元组解构

元组也可以像数组一样解构,解构后的变量具有相应的元组元素的类型。

let tuple: [number, string, boolean] = [7, "hello", true];

let [a, b, c] = tuple; // a: number, b: string, c: boolean

如果解构元组时越界将抛出错误。

let [a, b, c, d] = tuple; // Error, no element at index 3

像数组一样,可以使用 ... 语法创建一个包含剩余元组元素的更小的元组。

let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], the empty tuple

或者忽略后续尾随元素,或其他元素。

let [a] = tuple; // a: number
let [, b] = tuple; // b: string

对象解构

你也可以解构对象。

let o = {
    a: "foo",
    b: 12,
    c: "bar"
};
let { a, b } = o;

代码使用 o.a 和 o.b 分别创建了两个变量 a 和 b,注意如果你不需要 c,你可以忽略它。

就像数组解构一样,你可以对变量直接赋值,无需提前声明变量。

({ a, b } = { a: "baz", b: 101 });

注意我们使用括号围绕代码,因为 JavaScript 把 { 当做一个块的开始。

你可以使用 ... 语法创建一个变量用于持有对象的剩余部分属性。

let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;

~总之,... 语法定义的变量在 = 的左边叫解构,在右边就可以叫展开,后续会讲到这个概念。

属性重命名

你也可以为属性取一个不同的名字。

let { a: newName1, b: newName2 } = o;

这个语法就有点让人迷糊了,你可以把 a: newName1 看作 a as newName1,方向是从左到右,就像你写过的代码一样(如下)。

let newName1 = o.a;
let newName2 = o.b;

令人困惑的是,这里冒号不是定义变量的类型,如果要指定变量类型,你需要在解构语法的后面进行定义。

let { a, b }: { a: string, b: number } = o;

默认值

默认值使你可以指定未定义属性的缺省值。

function keepWholeObject(wholeObject: { a: string, b?: number }) {
    let { a, b = 1001 } = wholeObject;
}

在上面的代码中,b? 表示属性 b 是可选的,所以它的值可能是 undefined。keepWholeObject 函数现在就有一个具有属性 a 和 b 的参数变量 wholeObject,即便 b 是 undefined 也没问题。

函数声明

解构也可以用在函数声明的时候,下面是简单的用法。

type C = { a: string, b?: number }
function f({ a, b }: C): void {
    // ...
}

但是为参数指定默认值更为常见,而通过解构来正确设置默认值可能会很麻烦,首先你要记得将模式放在默认值之前。

function f({ a="", b=0 } = {}): void {
    // ...
}
f();

上面的代码段是类型推断的一个例子,稍后将在手册中详细描述。

然后,你要记得为解构后属性中的可选项提供一个默认值,记住在类型 C 中 属性 b 是可选的。

function f({ a, b = 0 } = { a: "" }): void {
    // ...
}
f({ a: "yes" }); // ok, default b = 0
f(); // ok, default to { a: "" }, which then defaults b = 0
f({}); // error, 'a' is required if you supply an argument

使用解构要小心,就像上面的例子,除了简单的解构表达式外其他的用法都很容易让人混淆,对于深度嵌套的解构更是如此,就算没有重命名、默认值和类型注解都已经很难理解了。要尽量让解构表达式短小和简单。你一定能写出和解构结果一样的赋值表达式。

展开

展开与解构是相反的,你可以展开一个数组到另外一个数组,或者一个对象到另外一个对象。例如:

let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];

代码设置 bothPlus 的值为 [0, 1, 2, 3, 4, 5],展开语法创建了变量 first 和 second 的浅拷贝,展开不会改变他们本身的值。

你也可以展开对象。

let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };

现在,search 的值为 { food: "rich", price: "$$", ambiance: "noisy" },对象展开比数组展开更复杂,就像数组展开一样,对象展开从左到右进行处理,但最终的结果仍然是一个对象,这就意味着展开对象中后续添加的属性可能会重写之前的属性。我们可以修改之前的例子:

let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { food: "rich", ...defaults };

对象 defaults 中的属性 food 将会重写前面的 food: "rich",这不是我们想要的结果。

对象展开也有另外一些意料之外的限制。首先,重组后的对象仅会包含源对象自己的可枚举属性。这意味着当完成对象展开后会丢失源对象拥有的方法。

class C {
  p = 12;
  m() {
  }
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!

其次,TypeScript 编译器不允许展开泛型函数的类型参数,该功能将在未来的版本中提供。

← 数据类型接口 →
  • 使用 var
    • 作用域规则
    • 变量持有
  • 使用 let
    • 块级作用域
    • 重复声明和覆盖(Shadowing)
    • 块级作用域的变量
  • 使用 const
  • let vs const
  • 解构
    • 数组解构
    • 元组解构
    • 对象解构
    • 属性重命名
    • 默认值
    • 函数声明
    • 展开
Copyright © 2025 unimits