下一代前端开发语言 TypeScript从零到实践(3)

基础类型

TypeScript 支持与 JavaScript 几乎相同的数据类型,此外还提供了实用的枚举类型方便我们使用。

布尔值

最基本的数据类型就是简单的 true/false 值,在JavaScript 和 TypeScript 里叫做 boolean(其它语言中也一样)。

let isDone: boolean = false

数字

和 JavaScript 一样,TypeScript 里的所有数字都是浮点数。 这些浮点数的类型是 number。 除了支持十进制和十六进制字面量,TypeScript 还支持 ECMAScript 2015中引入的二进制和八进制字面量。

let decLiteral: number = 20
let hexLiteral: number = 0x14
let binaryLiteral: number = 0b10100
let octalLiteral: number = 0o24

字符串

JavaScript 程序的另一项基本操作是处理网页或服务器端的文本数据。 像其它语言里一样,我们使用 string 表示文本数据类型。 和 JavaScript 一样,可以使用双引号(")或单引号(')表示字符串。

let name: string = 'bob'
name = 'smith'

你还可以使用模版字符串,它可以定义多行文本和内嵌表达式。 这种字符串是被反引号包围( `),并且以 ${ expr } 这种形式嵌入表达式

let name: string = `Yee`
let age: number = 37
let sentence: string = `Hello, my name is ${ name }.

I'll be ${ age + 1 } years old next month.`

这与下面定义 sentence 的方式效果相同:

let sentence: string = 'Hello, my name is ' + name + '.\n\n' +
    'I\'ll be ' + (age + 1) + ' years old next month.'

数组

TypeScript 像 JavaScript 一样可以操作数组元素。 有两种方式可以定义数组。 第一种,可以在元素类型后面接上 [],表示由此类型元素组成的一个数组:

let list: number[] = [1, 2, 3]

第二种方式是使用数组泛型,Array<元素类型>

let list: Array<number> = [1, 2, 3]

元组 Tuple

元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同。 比如,你可以定义一对值分别为 stringnumber 类型的元组。

let x: [string, number]
x = ['hello', 10] // OK
x = [10, 'hello'] // Error

当访问一个已知索引的元素,会得到正确的类型:

console.log(x[0].substr(1)) // OK
console.log(x[1].substr(1)) // Error, 'number' 不存在 'substr' 方法

当访问一个越界的元素,会使用联合类型替代:

x[3] = 'world' // OK, 字符串可以赋值给(string | number)类型

console.log(x[5].toString()) // OK, 'string' 和 'number' 都有 toString

x[6] = true // Error, 布尔不是(string | number)类型

联合类型是高级主题,我们会在以后的章节里讨论它。

注意:自从 TyeScript 3.1 版本之后,访问越界元素会报错,我们不应该再使用该特性。

枚举

enum 类型是对 JavaScript 标准数据类型的一个补充。 像 C# 等其它语言一样,使用枚举类型可以为一组数值赋予友好的名字。

enum Color {Red, Green, Blue}
let c: Color = Color.Green

默认情况下,从 0 开始为元素编号。 你也可以手动的指定成员的数值。 例如,我们将上面的例子改成从 1 开始编号:

enum Color {Red = 1, Green, Blue}
let c: Color = Color.Green

或者,全部都采用手动赋值:

enum Color {Red = 1, Green = 2, Blue = 4}
let c: Color = Color.Green

枚举类型提供的一个便利是你可以由枚举的值得到它的名字。 例如,我们知道数值为 2,但是不确定它映射到 Color 里的哪个名字,我们可以查找相应的名字:

enum Color {Red = 1, Green, Blue}
let colorName: string = Color[2]

console.log(colorName)  // 显示'Green'因为上面代码里它的值是2

any

有时候,我们会想要为那些在编程阶段还不清楚类型的变量指定一个类型。 这些值可能来自于动态的内容,比如来自用户输入或第三方代码库。 这种情况下,我们不希望类型检查器对这些值进行检查而是直接让它们通过编译阶段的检查。 那么我们可以使用 any 类型来标记这些变量:

let notSure: any = 4
notSure = 'maybe a string instead'
notSure = false // 也可以是个 boolean

在对现有代码进行改写的时候,any 类型是十分有用的,它允许你在编译时可选择地包含或移除类型检查。并且当你只知道一部分数据的类型时,any 类型也是有用的。 比如,你有一个数组,它包含了不同的类型的数据:

let list: any[] = [1, true, 'free']

list[1] = 100

void

某种程度上来说,void 类型像是与 any 类型相反,它表示没有任何类型。 当一个函数没有返回值时,你通常会见到其返回值类型是 void

function warnUser(): void {
  console.log('This is my warning message')
}

声明一个 void 类型的变量没有什么大用,因为你只能为它赋予 undefinednull

let unusable: void = undefined

null 和 undefined

TypeScript 里,undefinednull 两者各自有自己的类型分别叫做 undefinednull。 和 void 相似,它们的本身的类型用处不是很大:

let u: undefined = undefined
let n: null = null

默认情况下 nullundefined 是所有类型的子类型。 就是说你可以把 nullundefined 赋值给 number 类型的变量。

然而,当你指定了 --strictNullChecks 标记,nullundefined 只能赋值给 void 和它们各自,这能避免 很多常见的问题。 也许在某处你想传入一个 stringnullundefined,你可以使用联合类型 string | null | undefined。 再次说明,稍后我们会介绍联合类型。

never

never 类型表示的是那些永不存在的值的类型。 例如, never 类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型; 变量也可能是 never 类型,当它们被永不为真的类型保护所约束时。

never 类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是 never 的子类型或可以赋值给never 类型(除了 never 本身之外)。 即使 any 也不可以赋值给 never

下面是一些返回 never 类型的函数:

// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
  throw new Error(message)
}

// 推断的返回值类型为never
function fail() {
  return error("Something failed")
}

// 返回never的函数必须存在无法达到的终点
function infiniteLoop(): never {
  while (true) {
  }
}

object

object 表示非原始类型,也就是除 numberstringbooleansymbolnullundefined 之外的类型。

使用 object 类型,就可以更好的表示像 Object.create 这样的 API。例如:

declare function create(o: object | null): void

create({ prop: 0 }) // OK
create(null) // OK

create(42) // Error
create('string') // Error
create(false) // Error
create(undefined) // Error

类型断言

有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。 通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。

通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。 类型断言好比其它语言里的类型转换,但是不进行特殊的数据检查和解构。 它没有运行时的影响,只是在编译阶段起作用。 TypeScript 会假设你,程序员,已经进行了必须的检查。

类型断言有两种形式。 其一是“尖括号”语法:

let someValue: any = 'this is a string'

let strLength: number = (<string>someValue).length

另一个为 as 语法:

let someValue: any = 'this is a string'

let strLength: number = (someValue as string).length

两种形式是等价的。 至于使用哪个大多数情况下是凭个人喜好;然而,当你在 TypeScript 里使用 JSX 时,只有 as 语法断言是被允许的。

变量声明

letconst 是 JavaScript 里相对较新的变量声明方式。let 在很多方面与 var 是相似的,但是可以帮助大家避免在 JavaScript 里常见一些问题。const 是对 let 的一个增强,它能阻止对一个变量再次赋值。

因为 TypeScript 是 JavaScript 的超集,所以它本身就支持 letconst。 下面我们会详细说明这些新的声明方式以及为什么推荐使用它们来代替 var

如果你已经对 var 声明的怪异之处了如指掌,那么你可以轻松地略过这节。

var 声明

在 ES5 的时代,我们都是通过 var 关键字定义JavaScript 变量:

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 被调用时,它都可以访问到 f 里的 a 变量。 即使当 gf 已经执行完后才被调用,它仍然可以访问 a

作用域规则

var 声明有些奇怪的作用域规则。 看下面的例子:

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

  return x
}

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

有些同学可能要多看几遍这个例子。 变量 x 是定义在 if 语句里面,但是我们却可以在语句的外面访问它。 这是因为 var 声明的作用域是函数作用域,函数参数也使用函数作用域。

这些作用域规则可能会引发一些错误。 其中之一就是,多次声明同一个变量并不会报错:

function sumMatrix(matrix) {
  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 会在若干毫秒的延时后执行一个函数(等待其它代码执行完毕):

10
10
10
10
10
10
10
10
10
10

很多 JavaScript 程序员对这种行为已经很熟悉了,但如果你很不解也没有关系,因为你并不是一个人。 大多数人期望输出结果是这样:

0
1
2
3
4
5
6
7
8
9

我们传给 setTimeout 的每一个函数表达式实际上都引用了相同作用域里的同一个 i

让我们花点时间思考一下这是为什么。 setTimeout 在若干毫秒后执行一个函数,并且是在 for 循环结束后。for 循环结束后,i 的值为 10。 所以当函数被调用的时候,它会打印出 10

一个通常的解决方法是使用立即执行的函数表达式(IIFE)来捕获每次迭代时 i 的值:

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

这种奇怪的形式我们已经司空见惯了。 参数 i 会覆盖 for 循环里的 i,但是因为我们起了同样的名字,所以我们不用怎么改 for 循环体里的代码。

let 声明

现在你已经知道了 var 存在一些问题,这恰好说明了为什么用 let 语句来声明变量。 除了名字不同外, letvar 的写法一致:

let hello = 'Hello!'

主要的区别不在语法上,而是语义,我们接下来会深入研究。

块作用域

当用 let 声明一个变量,它使用的是块作用域。 不同于使用 var 声明的变量那样可以在包含它们的函数外访问,块作用域变量在包含它们的块或 for 循环之外是不能访问的。

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

  if (input) {
    // OK: 仍然能访问到 a
    let b = a + 1
    return b
  }

  // Error: 'b' 在这里不存在
  return b
}

这里我们定义了 2 个变量 aba 的作用域是 f 函数体内,而 b 的作用域是 if 语句块里。

catch 语句里声明的变量也具有同样的作用域规则。

try {
  throw 'Oh no!';
}
catch (e) {
  console.log('Catch it.')
}

// Error: 'e' 在这里不存在
console.log(e)

拥有块级作用域的变量的另一个特点是,它们不能在被声明之前读或写。 虽然这些变量始终“存在”于它们的作用域里,但在直到声明它的代码之前的区域都属于暂时性死区。 它只是用来说明我们不能在 let 语句之前访问它们,幸运的是 TypeScript 可以告诉我们这些信息。

a++ // TS2448: Block-scoped variable 'a' used before its declaration.
let a

注意一点,我们仍然可以在一个拥有块作用域变量被声明前获取它。 只是我们不能在变量声明前去调用那个函数。 如果生成代码目标为 ES2015,现代的运行时会抛出一个错误;然而,现今 TypeScript 是不会报错的。

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

// 不能在'a'被声明前调用'foo'
// 运行时应该抛出错误
foo()

let a

关于暂时性死区的更多信息,查看这里 Mozilla Developer Network

重定义及屏蔽

我们提过使用 var 声明时,它不在乎你声明多少次;你只会得到 1 个。

function f(x) {
  var x
  var x

  if (true) {
    var x
  }
}

在上面的例子里,所有 x 的声明实际上都引用一个相同的x,并且这是完全有效的代码,但这经常会成为 bug 的来源。幸运的是 let 的声明就不会这么宽松了。

let x = 10
let x = 20 // 错误,不能在 1 个作用域里多次声明 x

并不是要求两个均是块级作用域的声明 TypeScript 才会给出一个错误的警告。

function f(x) {
  let x = 100 // Error: 干扰参数声明
}

function g() {
  let x = 100
  var x = 100 // Error: 不能同时具有 x 的两个声明
}

并不是说块级作用域变量不能用函数作用域变量来声明。 而是块级作用域变量需要在明显不同的块里声明。

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

  return x
}

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

在一个嵌套作用域里引入一个新名字的行为称做屏蔽。 它是一把双刃剑,它可能会不小心地引入新问题,同时也可能会解决一些错误。 例如,假设我们现在用 let 重写之前的 sumMatrix 函数。

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

  return sum
}

这个版本的循环能得到正确的结果,因为内层循环的 i 可以屏蔽掉外层循环的 i

通常来讲应该避免使用屏蔽,因为我们需要写出清晰的代码。 同时也有些场景适合利用它,你需要好好权衡一下。

块级作用域变量的获取

每次进入一个作用域时,let 会创建一个变量的环境。就算作用域内代码已经执行完毕,这个环境与其捕获的变量依然存在。

回想一下前面 setTimeout 的例子,我们最后需要使用立即执行的函数表达式来获取每次 for 循环迭代里的状态。 实际上,我们做的是为获取到的变量创建了一个新的变量环境。 这样做挺痛苦的,但是幸运的是,你不必在 TypeScript 里这样做了。

let 声明出现在循环体里时拥有完全不同的行为。不仅是在循环里引入了一个新的变量环境,而且针对每次迭代都会创建这样一个新作用域,这就相当于我们在使用立即执行的函数表达式时做的事。所以在 setTimeout 例子里我们仅使用 let 声明就可以了。

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 numLivesForCat = 9
const kitty = {
  name: 'Kitty',
  numLives: numLivesForCat
}

// Error
kitty = {
  name: 'Tommy',
  numLives: numLivesForCat
};

// OK
kitty.name = 'Jerry'
kitty.numLives--

除非你使用特殊的方法去避免,实际上 const 变量的内部状态是可修改的。 幸运的是,TypeScript 允许你将对象的成员设置成只读的。接口一章有详细说明。

let vs. const

现在我们有两种作用域相似的声明方式,我们自然会问到底应该使用哪个。与大多数泛泛的问题一样,答案是:依情况而定。

使用最小特权原则,所有变量除了你计划去修改的都应该使用 const。 基本原则就是如果一个变量不需要对它写入,那么其它使用这些代码的人也不能够写入它们,并且要思考为什么会需要对这些变量重新赋值。使用 const 也可以让我们更容易的推测数据的流动。

解构

解构数组

最简单的解构莫过于数组的解构赋值了:

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

这创建了 2 个命名变量 firstsecond。 相当于使用了索引,但更为方便:

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

作用于函数参数:

let input: [number, number] = [1, 2]

function f([first, second]: [number, number]) {
  console.log(first)
  console.log(second)
}

f(input)

你可以在数组里使用 ... 语法创建剩余变量:

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]

对象解构

你也可以解构对象:

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

这通过 o.ao.b 创建了 ab 。 注意,如果你不需要 c 你可以忽略它。

你可以在对象里使用 ... 语法创建剩余变量:

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

属性重命名

你也可以给属性以不同的名字:

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

这里的语法开始变得混乱。 你可以将 a: newName1 读做 "a 作为 newName1"。 方向是从左到右,好像你写成了以下样子:

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

令人困惑的是,这里的冒号不是指示类型的。 如果你想指定它的类型,仍然需要在其后写上完整的模式。

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

默认值

默认值可以让你在属性为 undefined 时使用缺省值:

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

现在,即使 bundefinedkeepWholeObject 函数的变量 wholeObject 的属性 ab 都会有值。

函数声明

解构也能用于函数声明。 看以下简单的情况:

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, 默认 b = 0
f() // OK, 默认 a: '', b = 0
f({}) // Error, 一旦传入参数则 a 是必须的

要小心使用解构。 从前面的例子可以看出,就算是最简单的解构表达式也是难以理解的。 尤其当存在深层嵌套解构的时候,就算这时没有堆叠在一起的重命名,默认值和类型注解,也是令人难以理解的。 解构表达式要尽量保持小而简单。

展开

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

这会令 bothPlus 的值为 [0, 1, 2, 3, 4, 5]。 展开操作创建了 firstsecond的 一份浅拷贝。 它们不会被展开操作所改变。

你还可以展开对象:

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

search的值为 { food: 'rich', price: '$10', ambiance: 'noisy' }。 对象的展开比数组的展开要复杂的多。像数组展开一样,它是从左至右进行处理,但结果仍为对象。这就意味着出现在展开对象后面的属性会覆盖前面的属性。因此,如果我们修改上面的例子,在结尾处进行展开的话:

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

那么,defaults 里的 food 属性会重写 food: 'rich',在这里这并不是我们想要的结果。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容