前面的话
项目开发中基本离不开 TypeScript 来规范 JS 的类型,这篇文章主要总结 TypeScript 的基本知识。(没有 TypeScript 的项目就像奶茶里没有奶,哈哈哈~。)
1、TypeScript 概述
在介绍前先聊一下,为什么需要使用 TypeScript ?
JS 是一种脚本语言,作为解析型语言,只能在运行时发现错误,是弱类型语言。
TS 是 JS 的超集,在编译的过程中就可以纠正错误,用于解决大型项目的代码复杂性。支持强类型、静态类型,支持模块、接口和泛型,最终被编译成 JS 代码,给浏览器使用。
TS 能在开发时帮助我们检测代码中类型定义的问题,大大增强了代码的可阅读性和可维护性。
2、TypeScript 的安装
-
安装
npm install -g typescript
-
验证
tsc -v
-
编译
tsc hello.ts
执行该命令会生成一个 hello.js 文件
注意:
- ts 必须被编译成 js 文件之后才可以被执行。
- ts 只会在编译阶段对类型进行检查,如果发现有错误,编译时就会报错;而运行时,生成的 js 文件与普通的 js文件一样,不会进行类型检查。
3、TypeScript 基础类型
-
boolean 类型
let isDone: boolean = false; // es6 let isDone = false;
-
number 类型
let age:number = 18; // es6 let age = 18;
-
string 类型
let str:string = 'xiaoqi'; // es6 let str = 'xiaoqi';
-
symbol 类型
const sym = Symbol(); let obj = { [sym]: "value" }; console.log(obj[sym]); // "value"
-
Array 数组
两种表示方式:一种是:元素类型+[];另一种是数组泛型: Array<元素类型>
let list:number[] = [1,2,3]; let list1: Array<number> = [4,5,6]; // es6 let list = [1, 2, 3]; // es6 let list1 = [4, 5, 6];
-
Tuple 类型
Tuple 类型是 TS 中特有的类型,类似于数组,但它规定了每一个位置上的类型。
let tuple:[string,number,string,boolean] = ['xiaoqi',20,"爱前端",true]
如果初始化的类型与规定的类型不匹配时,就会出错。
-
Enum 类型
enum 枚举类型为一组数值,可以很好的实现语义化,比如各种状态码的定义。enum Color { red, green, bule } let color:Color = Color.red; console.log(color); // 0
默认从 0 开始,依次赋值。也可以手动赋值:
enum Color { red = 4, green, bule } let color:Color = Color.green; console.log(color); // 5
这样就是从 5 依次赋值,也可以全部手动赋值:
enum Color { red = 4, green = 9, blue = 12, } let color:Color = Color.blue; console.log(color); // 12
枚举类型除了支持 从成员名称到成员值的映射外,还支持 从成员值到成员名称 的反向映射。
enum Color { red = 4, green = 9, blue = 12, } let color:Color = Color.blue; console.log(color); // 12 let colorName: string = Color[9]; console.log(colorName); // "green"
枚举类型还支持成员值是数字与字符串的混合:
enum Enum { A, //0 B, // 1 C = "C", D = "D", E = 8, F // 9 }
-
any 类型
如果在编译阶段不清楚变量的类型,可以使用 any 类型来标记。使用any 类型相当于避开了 ts 的类型检测,一般来说不建议使用。
let value:any; value.trim(); value(); value[0][1];
给 value 定义了 any 类型之后,上述的代码都不会报错。并且 any 类型的值可以赋给其他类型的值(顶级类型)。
let value:any = 123; let num:number = value;
-
unKnown 类型
任何类型值都可以赋给 any 类型的变量,同样任何类型值都可以赋给 unknown 类型的变量;不同的是 unknown 类型不能赋给其他类型的变量,只能赋值给any 与 unknown 类型
(any 类型太松了,一般建议使用unknown 类型)let value2:unknown = 4; value2 = true; value2 = "hello"; value2 = []; value2 = undefined; value2 = null;
上述代码都是 ok 的,任何类型值都可以赋值给 unknown 类型。
let value: any = 10; let value2:unknown = 10; let value3: unknown = "hello"; let num:number = 4; num = value; // ok num = value2; // Error unknown类型不可以赋给除any和unknown类型之外的类型 value = value2;// ok unknown类型赋给any类型 value3 = value2; // ok unknown类型赋给unknown类型
-
null 与 undefined 类型
在 TypeScript 中,undefined 与 null 各自都有自己的类型分别为 undefined 和 null。let u: undefined = undefined; let n: null = null; let conter:void; conter = u; console.log(conter);// undefined
当指定了 --strictNullChecks 时,null 和 undefined 只能赋值给 void 和它们各自。
-
never 类型
never 类型表示那些永远不存在的值的类型。任何类型都不能赋给never。
值不会存在的两种情况: 比如函数抛出异常,或者函数永远执行不完(死循环)。function errorFunction(): never { throw new Error(); // 抛出异常之后,就无法执行完 console.log('hello'); } function forNever(): never { while(true){};//下面的console 永远不会执行 console.log('hello'); }
-
void 类型
某种程度上说,void 类型是与 any 类型相反,它表示没有任何类型。当函数没有返回值时,其返回值类型是 void。
function user():void { console.log('hello'); // 如果再加一个返回值,就会报错 }
声明一个 void 类型的变量只能赋值为 undefined 和 null。(在 strictNullChecks 未指定为 true时)。
let unusable:void = null;// 当开启 --strictNullChecks 时,不能赋值为 null unusable = undefined;
4、类型推断
let num = 123;
上面的代码中,没有显示的告诉 num 是一个 number 类型,但是将鼠标放置 num 上,会发现 ts 自动把变量推断为 number 类型。
一般来说如果 ts 可以自动帮我们推断类型的时候,我们可以不用显示的为变量写类型。
const one = 1;
const two = 2;
const three = one + two;
上述代码中 ts 可以自动推断 one、two、three 的类型。
function getTotal(one, two) {
return one + two
}
const total = getTotal(1,2);
上述的代码中,one、two、total 是 any 类型,当为 one、two 加上类型之后,total 的类型 ts 就可以帮我们自动推断。
5、类型断言
当我们确定知道变量的类型时,可以使用断言,断言的方式用两种:
-
<>
let someValue:any = "xiaoqi"; let strLength: number = (<string>someValue).length;
-
as
let strLength:number = (someValue as string).length;
除了 as 基本类型之外,还可以 as const 。在 ts 里面有一种类型拓宽,当我们分别使用 const 和 let 定义一个变量时,ts 做了什么呢?
const a = "xiaoqi" let b = "xiaoqi"
此时 a 的类型是 “xiaoqi” , b的类型是 string。这里 let 声明的变量类型就被类型拓宽了。
type Type = "a" | "b" | "c" let x = "a"; // 使用 const 可以避免这个问题 const funTest = (x:Type) => { console.log(x) } funTest(x) // Error. Argument of type 'string' is not assignable to parameter of type 'Type'
上述的例子正是因为 let 的类型拓宽,导致出错了。使用 const 去定义变量之后可以避免这个问题。
除了const 可以解决类型扩宽外, as const 可以帮我们解决一部分类型拓宽的问题。
const obj = { x: 1 } // obj 的类型是 {x: number} const obj1 = { x: 1 as const , }; // obj1 的类型是 {x: 1} const obj2 = { x: 1 } as const // obj2 的类型是 { readonly x: 1 }
上面这个例子中,const 也不是万能的了,里面的 x 的类型也是 number 类型了。
当使用了 as const 之后, TypeScript 将为它推断出最窄的类型,没有拓宽。
-
非空断言
x! 表示将 x 排除 null 和 undefined。
6、类型守卫
类型保护是可以确保类型在一定的范围内,一般用于联合类型。
-
in 关键字
interface Student { teach:boolean, study: ()=>{} } interface Teacher { teach:boolean, skill: ()=>{} } function person (person: Student | Teacher) { if("skill" in person) { person.skill(); }else { person.study(); } }
-
typeof 关键字
typeof 类型保护的类型只能是:number、string、boolean、symbol。
function padLeft(value:string,padding:string|number) { if(typeof padding === "number"){ return Array(padding + 1).join(' ') + value; } if(typeof padding === "string") { return padding + value } throw new Error('Expected string or number'); }
-
instanceof 关键字
class NumberObj { constructor(public count: number){}; } function addObj(first: object|NumberObj, second:object | NumberObj) { if(first instanceof NumberObj && second instanceof NumberObj) { return first.count + second.count; } return 0; } const first = new NumberObj(6); const second = new NumberObj(8); addObj(first , second);
7、联合类型 、交叉类型、类型别名
-
联合类型:表示一个值可以是几种类型之一,用 | 分割每一个类型。
let value:number|string;
-
交叉类型: 将多个类型合并为一个类型,用 & 运算符进行合并 。
type PoinX = {x:number} type Point = PoinX & {y: number} let point:Point = { x:1, y:2 }
-
同名基础类型属性合并
interface A { a:string, b:number, } interface B { a:number, b:number, } type AB = A & B; let ab:AB= { a:"jjj", // Error b: 9, }
接口 A 和接口 B 都有属性a,但类型不同,合并之后 a 的类型是 string & number ,很显然没有这种类型的存在,合并之后的 a 的类型是 never,就会导致报错。
-
同名非基本类型属性的合并
interface C { c:string, } interface D { d: string, } interface A { a:C, b:number, } interface B { a:D, b:number, } type AB = A & B; let ab:AB= { a:{c:"ccc", d:"ddd"}, b: 9, }
上面的代码中,虽然 a 属性时同名的,但是是非基本类型,所以可以合并。
-
-
类型别名
使用 type 给一个类型起一个新的名字:
type Message = string | string[]; let say = (message: Message) => { }
同样也可以重新命名多个字面量类型的联合类型:
type Dir = "left"|"right"|"up"|"down";
8、可选链?.
在TypeScript 3.7中增加了可选链?.。其核心是在遇到 undefined 或者 null 时停止代码运行。
let x = foo?.bar.baz();
当 foo 是 undefined 或者 null 时 ,代码停止运行并返回 undefined。
在代码中我们可以使用?.代替&&的使用。
// 使用 &&
if(foo&&foo.bar&&foo.bar.baz) {}
// 使用 ?.
if(foo?.bar?.bar){}
注意的是,?.只判断 undefined 和 null,对于空字符串、0、NaN、false是不会像&&一样有短路逻辑的。
9、空值合并运算符 ??
TypeScript 3.7 除了引入了可选链?.也引入了空值合并运算符??。其核心在于左侧操作数为undefined或null时返回右侧操作数,否则返回左侧操作数。
const foo = null ?? 'xiaoqi'
console.log(foo); // 输出:"xiaoqi"
const baz = 0 ?? 42;
console.log(baz); // 输出:0
可以看出与 || 运算符不一样的是,|| 操作符在左操作符为 ‘’ 、0、NaN时返回右侧操作数。
10、TypeScript 类
class Person {
name = "mian"; // 默认为public,可以在类的内部、外部被使用
private age = 20; // 只允许在类的内部使用,不允许被继承
protected height = 162; // 只允许在类的内部使用,但是可以被继承,在子类的内部被使用
sayHello() {
return "hello";
}
}
- 继承: 使用 extends 关键字来实现继承。
class Jiejie extends Person{
// 重写父类方法
sayHello() {
return super.sayHello() + "lala";
}
// 使用父类的 height 属性
sayHeight() {
console.log(this.height);
}
}
- 类的构造函数 constructor
class Person {
public name:string;
constructor(name:string) {
this.name = name;
}
// 简化
// constructor(public name: string) {}
}
super 指向父类的this,子类中只要写了constructor 就要写super, 即使父类中没有constructor,也要写super()。
class Teacher extends Person {
constructor(public age: number) {
super("mian");
}
}
- getter、setter 、static 的使用
class Jiejie {
constructor(private _age: number) {}
// 静态属性或方法,直接类调用
static height: string = "jjj";
// getter可以对传入的属性进行封装
get age() {
return this._age - 2;
}
set age(age: number) {
this._age = age;
}
}
- 抽象类:使用 abstract 关键字声明的类,包含一个或多个抽象的方法。继承于抽象类的类都需要实现抽象类里面的抽象方法。
abstract class Man {
abstract skill(): void;
}
class Waiter extends Man {
skill() {
console.log("点餐");
}
}
class SupWaiter extends Man {
skill() {
console.log("表演点餐");
}
}
11、TypeScript 接口
在面向对象语言中,接口是对行为的抽象,而具体的行动需要类自己实现。
TS 中的接口除了可以对类的一部分行为进行抽象以外,还可以对对象的属性做一定的描述。
-
对象的描述
使用接口描述一个对象,具体对象实现这个接口时,必须严格按照接口定义去实现。
interface Person { name:string, age:number } const person = { name:"xiaoqi", age:20 }
-
可选|只读|任意属性
interface Person { readonly name:string, // 只读 age?:number;// 可选 // 还可以有其他属性,属性名为string类型,值为any类型 [propName:string]:any; } const person = { name:"xiaoqi", age:18, height: 163, }
-
函数类型的描述
接口除了可以描述带有属性的普通对象,也可以描述函数类型。
interface AddFun { (value1:number, value2:number):boolean; } const addFun:AddFun = (val1: number,val2:number):boolean => { if((val1 + val2) < 100 )return false; else return true; }
-
类的描述
类使用 implements 来 实现接口。
interface Point { x:number, y:number, counter: (x:number,y:number)=>number, } class SomePoint implements Point { x = 1; y = 2; counter(x:number, y:number){ return x + y; } }
-
接口的继承
接口使用 extends 来实现接口的继承。
interface PartialPointX { x: number; } interface Point extends PartialPointX { y:number; } const point = { x:1, y:2, }
-
接口与类型别名的区别
类型别名也可以来描述对象、函数的结构,同时也可以对类的部分进行描述。
type Point = { x: number, y:number } type counter = (x:number,y:number)=>boolean; const point:Point = { x: 1, y:8 } class SomePoint implements Point { x = 1; y = 4; }
但类型别名可以用于一些其他的类型:
type Word = string | number; type Dir = "left" | "right" | "up" | "down";
大多情况下使用接口描述一个类型,如果需要使用联合类型或者元组类型时,使用类型别名。
12、TypeScript 泛型
泛型: 泛指的类型,可以随意命名(但一般使用 T 表示泛型),在具体使用的时候,声明具体的类型。
function join<T> (first:T, second:T){
return `${first} ${second}`;
}
join<string>("xiaoq","qi");
泛型中数组的使用:
function muFun<T>(params:T[]) {
return params;
}
muFun<string>(['xiao','qi']);
多个泛型的定义:
function myFun<T,P>(first:T,second:P) {
return `${first} ${second}`;
}
myFun<string,number>("xiaoqi",18);
泛型定义接口:
interface KeyPair<T, U> {
key: T;
value: U;
}
let kp1: KeyPair<number, string> = { key: 123, value: "str" };
let kp2: KeyPair<string, number> = { key: "str", value: 123 };
类中的泛型与继承:
interface Girl {
name:string,
}
class SelectGirl <T extends Girl> {
constructor(private girls: T[]) {}
getGirl(index: number): string {
return this.girls[index].name;
}
}
const girl = new SelectGirl<{name:string,age:number}>([{name:'mian',age:18},{name:"xiaoqi",age:19}]);
console.log(girl.getGirl(1));
泛型约束:
type InitValue = {
id: number
dealer_id: number | undefined
arrive_time: string
}
// T 类型继承了 keyof InitValue 即 "id" | "dealer_id" | "arrive_time"
const handleChange = <T extends keyof InitValue>(
curValue: InitValue[T],
curIndex: number,
type: T,
) => {
//
}
handleChange( 8, 3, "id") // 调用的时候这里 T 指定了明确的类型 'id'
这样 T 表示 InitValue 键中的任意类型。
13、typeof | keyof
-
typeof
typeof 除了可以做类型保护,还可以获取一个值的类型。
const person = {name:"xiaoqi", age:20} type Sem = typeof person; const sem:Sem = { name:'zhang', age:20 }
-
keyof
可以获取某种类型的所有键,其返回类型是一个联合类型。
interface Person { name: string; age:number; } type K = keyof Person; // "name"|"age type K2 = keyof Person[];// number|"length" | "toString" | "toLocaleString" | "pop" | "push" | "concat" | "join" | "reverse" | "shift" | "slice" | "sort" | "splice" | "unshift" | "indexOf" | "lastIndexOf" | ... 13 more ... | "values type K3 = keyof {[x:string]:Person};// string | number class Eg2 { private name: string; public readonly age: number; protected home: string; } // T为 age // 而name和home不是公有属性,所以不能被keyof获取到 type T = keyof Eg2
T为 age 而 name 和 home 不是公有属性,所以不能被 keyof 获取到
14、泛型工具
- Partial< T>
使用Partial<T>
可以将一个T中属性变为可选的。
export type FilterParams = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
// FilterParamsOption 中的属性都是可选的
type FilterParamsOption = Partial<FilterParams>
// 即
type FilterParamsOption = {
create_start?: number
create_end?: number
phone?: string
channel_id?: number
city_id?: number
}
// 实现
type Partial<T> = {
[P in keyof T]?: T[P]
}
// [P in keyof T] 遍历T上的所有属性
// ?:设置为属性为可选的
// T[P] 类型为原类型
- Required< T>
使用Required<T>
可以将T中属性变为必选。
export type FilterParams = {
create_start: number
create_end: number
phone?: string
channel_id?: number
city_id?: number
}
// FilterParamsOption 中的属性都是可选的
type FilterParamsOption = Partial<FilterParams>
// 即
type FilterParamsOption = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
// 实现
type Required<T> = {
[P in keyof T]-?: T[P]
}
- Pick<T, K>
将T类型中的K键列表提取出来,生成新的类型
export type FilterParams = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
type FilterParamsOption = Pick<FilterParams, 'phone' | 'city_id'>
// 即
type FilterParamsOption = {
phone: string;
city_id: number;
}
// 实现
type Pick<T, K extends keyof T> = {
[P in k ]?: T[P]
}
- Exclude<T, U>
去除联合类型T与联合类型T的交集,返回T剩余的部分。
type FilterParams = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
type FilterParams2 = {
title: string
city: number
phone: string
channel_id: number
city_id: number
}
type FilterParamsOption = Exclude<keyof FilterParams2, keyof FilterParams>
// 即
type FilterParamsOption = "title" | "city"
// 实现
type Exclude<T, U> = T extends U ? never : T
- Extract<T, U>
与 Exclude 相反,取 联合类型T与联合类型U的交集
type FilterParams = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
type FilterParams2 = {
title: string
city: number
phone: string
channel_id: number
city_id: number
}
type FilterParamsOption = Extract<keyof FilterParams, keyof FilterParams2>
// 即
type FilterParamsOption = "city_id" | "phone" | 'channel_id'
// 实现
type Extract<T, U> = T extends U ? T : never;
- Omit<T, K>
与 Pick<T, K> 相对应,是将T类型中K键值去掉,形成新的类型。
export type FilterParams = {
create_start: number
create_end: number
phone: string
channel_id: number
city_id: number
}
type FilterParamsOption = Omit<FilterParams, 'phone' | 'city_id'>
// 即
type FilterParamsOption = {
create_start: number
create_end: number
channel_id: number
}
// 实现
type Omit = Pick<T, Exclude<keyof T, K>>
- Record<K, T>
可以生成一个以K类型为键,以T类型为值的类型
type FilterParams = {
date: string[];
phone: string;
channel_id: number;
city_id: number;
}
type FilterParamsOption = Record<string, FilterParams>
// 即
type FilterParamsOption = {
[p: string]: FilterParams
}
// 实现
type Record<K extends string | number | symbol, T> = { [P in K]: T; }
- Parameters< T >
返回 T 函数的参数类型,并将每个类型放在一个元组中。
type Foo = (x: string, y: number) => string | number
type FooType = Parameters<Foo>; // string | number
// 即
type FooType = [x: string, y: number]
// 实现
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
1、Parameters 中参数T必须是一个函数类型,所以 T 必须继承与(…args: any) => any 或者 Function
2、具体实现:判断T是否属于函数类型,属于则使用infer让 ts 自动推断函数参数的类型并赋给 P,将P返回;否则返回never。
3、infer 关键词只能在extends条件上使用
使用infer实现获取一个数组各元素的类型:
type ArrType<T extends Array<any>> = T extends Array<infer P> ? P : never
type A = ArrType<[ 'xiaoqi', 20]>
// 即
type A = 'xiaoqi' | 20
- ReturnType< T>
返回 T(函数)的返回值的类型
type Foo = (x: string, y: number) => string | number
type FooType = ReturnType<Foo>; // string | number
// 即
type FooType = string | number
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer P? P : never;
- ConstructParameters< T>
获取类的构造函数的参数类型,并存在一个元组中。
class Person {
constructor(public name: string, age?: number) {}
}
type E1 = ConstructParameters<typeof Person>
// 即
type E1 = [name: string, age?: number | undefined]
// 实现
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;
1、T 必须是一个拥有构造函数的类
2、T 必须是继承一个抽象类(只有抽象类型能同时被抽象类和普通类)
class Test {}
abstract class Test2 {}
// typeof可以获取一个值类型
// 正常赋值
const E1: typeof Test = Test
// 报错: 抽象类不能赋值给非抽象类型
const E2: typeof Test = Test2
// 正常赋值
const E3: typeof Test2 = Test
// 正常赋值
const E4: typeof Test2 = Test2
3、使用类作为类型和使用typeof 类作为类型的区别
class Greeter {
greeting: string; // 实例属性
constructor(message: string) {
this.greeting = message;
}// 构造函数
greet() { // 实例方法
return "Hello, " + this.greeting;
}
static PI: number = Math.PI;// 静态属性
static hi() {} // 静态方法
}
// 正常赋值
const e1:Greeter = new Greeter('xiaoqi')
// 等号后面的Greeter报错: 类型“typeof Greeter”缺少类型“Greeter”中的以下属性: greeting, greet
const e2:Greeter = Greeter
// 正常赋值
const e3: typeof Greeter = Greeter
// 报错:类型'Greeter'缺少类型'typeof Greeter'中的以下属性:prototype, hi,PI
const e4: typeof Greeter = new Greeter('xiaoqi')
1、使用类作为类型,这种类型约束所赋的值必须是该类的实例。这个类型包含类的所有实例成员和构造函数
2、使用typeof 类作为类型,这种类型约束所赋值的类型属于该类型。这个类型包含了类的所有静态成员和构造函数。
- 其他工具类型
//字符串转大写
type Eg1 = Uppercase<'abcd'>;
// 即
type Eg1 = 'ABCD'
// 字符串转小写
type Eg2 = Lowercase<'ABCs'>;
// 即
type Eg2 = 'abcs'
// 首写字母转大写
type Eg3 = Capitalize<'abcd'>;
// 即
type Eg3 = 'Abcd'
// 首写字母转小写
type Eg4 = Uncapitalize<'ABCD'>;
// 即
type Eg4 = 'aBCD'
15、特殊注释
-
@ts-nocheck:在文件顶部加上可以使文件跳过ts检查
-
@ts-check:在.js文件顶部加上可以让这个文件进行ts检查。搭配JSDoc标签使用。
1、变量声明:使用@type标签
// @ts-check /** @type {number} */ let a = 0; a = ""; // 报错 不能将类型“string”分配给类型“number”。
2、函数类型声明:使用@params和@returns标签
/** * @param {number} b * @param {number} c * @returns {string} */ const sum = (b, c) => { return b + c; // 报错 不能将类型“number”分配给类型“string”。 };
3、类声明: 使用@public 、@private、 @protected、@readonly、@static等
class Test { constructor() { /** @private */ this.a = 100; /** @readonly */ this.b = 50; } } const test = new Test(); console.log(test.a); // 报错 属性“a”为私有属性,只能在类“Test”中访问。 test.b += 1; // 报错 无法分配到 "b" ,因为它是只读属性。
-
@ts-ignore 可以忽略ts报错。上例中加上 @ts-ignore,ts将不会报错,但如果eslint中配置了禁止使用@ts-ignore,eslint会报错。(@ts-expect-error 也可以实现一样的效果)
const sum = (b, c) => { // @ts-ignore return b + c; };
尾声
笔记的基础内容来自了 博主技术胖的 TypeScript 从入门到精通图文视频教程。