基于TypeScript + Vue 框架核心知识详解

1,832 阅读17分钟

前不久浏览了@寻找蓝湖96大神的一篇文章《哪些技术会决定前端开发者的未来发展?》,其中提到了TypeScript(下文简称TS)会在下半年依靠Vue 3.0强大的支持大规模普及。但作为一个相对于后端只有基础C#语言能力的我来说,曾经在看TS的时候确实没有被其吸引,于是乎又从头稍细致地学习了一下TS,并整理了一些自己认为比较重要和常见的知识点,如果之前比较少接触TS,可以来一同学习一下。本文前部分将整理出TS的大部分知识点,后部分以Vue 3.0 + TS 为框架,结合风趣的语言和趣味的demo深化知识点(熟悉TS的可以直接到demo),希望大家喜欢,谢谢大家点赞支持(偏基础,顶级大神求忽略哈)。

1. TypeScript简介及优势

1.1 何为TypeScript

TypeScript是一种由微软开发的JavaScript语言类型的超集,其本质即在JavaScript的基础上添加了可选的静态类型和基于类的面向对象编程,以及一些诸如后端语言Java、C#等具有的强类、抽象类、接口等语法特性。所以,如果有ES6和后端语言基础的话,TS会超级轻松掌握。

1.2 TypeScript与JavaScript的对比

只要谈论起TS,基本上都会说到它强大的规避类型错误的能力。俗话说,有比较才有优势,可以用TS和JS做一下重点比较:

  • JS是一种解释型语言,无需编译(TS是需要编译的),虽然是基于对象的编程语言,但并不支持接口、继承、重载等面向对象特性;
  • JS使用变量为弱类型,因此灵活性更强,兼容性也很不错(暂定为优势);
  • TS增加了静态类型、类、模块、接口和类型注解等内容;
  • TS为强类型语言,定义的变量要带有明确或推断的具体类型,因此数据流更规范;
  • TS完整定义接口的概念,通过定义明确的接口,减少了查阅文档的成本。 综合来看,TS的优势基本两点:
  1. 强类型语言,变量类型明确,基本的类型错误可以在写入和在终端执行程序时就会被锁定,避免了这类错误出现在浏览器中(JS是动态语言,只有运行时才会报错),即规避简单错误,且数据流规范清晰;
  2. 便于查阅文档,这个虽然在写业务代码上基本感觉不到,但在写库和数据模型之类的业务上来说是很棒的,比如写业务埋点代码时定义埋点数据的类型。

1.3 JavaScript并非一无是处

但是我个人认为也不能一棒子打死JS,之前有位老师说可以抛弃JS了,我一来不舍得,二来不敢苟同。我觉得JS的弱类型性和灵活性对于写业务代码来说有优势的,且ES6本身也支持了包括类、模块化等机制(虽然还需要额外引入babel做编译),个人认为对于大多数中型甚至大型工程来说,我觉得ES6当下也够了(才疏学浅,没接触过多大的工程)。JS有多“弱”呢?举几个JS常见的类型转换规则:

'1' + 2 // '12'
'3' + true // '3true'
'2' + undefined // '2undefined'
'5' - '2' // 3
true + 1 // 2
true + false //1

上述转换遵循的规则为:

  1. 在没有对象类型的前提下发生加法运算时,当一个值为字符串,另一个为非字符串,则后者转为字符串;
  2. 当无字符串类型的加法算法时,会优先转为Number类型;
  3. 除加法运算符,其他运算符都会把运算自动转为Number类型。

我想大多数后端转前端的朋肯定对这种强制容错的机制既感到诧异又感到费解,这些在TS下都是不允许发生的。那下面就梳理一下TS的重点知识体系和思维模式。

2. TypeScript知识点梳理

TS有几个大的语法特性: 类(classes)、接口(interfaces)、模块(modules)、类型注解(type annotations)、编译检查(Compile time type checking),下面叙述一些常见知识点。

2.1 基础类型

2.1.1 any

any为任意类型,即声明为any的变量可以赋予任意类型的值。且在赋值过程中可以跳过编译阶段的类型检查。

2.1.2 枚举(enum)

即用于定义数值集合

enum Color { Red, Green, Blue };
let c: Color = Color.Blue;
console.log(c);    // 输出 2

2.1.3 null 和 undefined

在if判定中,null和undefined的结果都是false,但它们在定义上有本质的差别,即:

  • null表示“什么都没有”,用typeof 检测时返回 Object;
  • undefined表示一个没有设置值的变量。 typeof 检测时返回 undefined。比如声明变量的时候,没有声明初始值,就会是个undefined:
var uname: string;
let notcare;

注意在TS中,如果启用了严格空校验,就可以使得null和undefined只能被赋值给void或本身对应的类型。

2.1.4 never类型

never 是其它类型(包括 null 和 undefined)的子类型,代表从不会出现的值,因此声明为 never 类型的变量只能被 never 类型所赋值,在函数中它用于抛出异常或无法执行到终止点(例如无限循环):

let x: never;
let y: number;

// 运行错误,数字类型不能转为 never 类型
x = 123;

// 运行正确,never 类型可以赋值给 never 类型
x = (()=>{ throw new Error('exception')})();

// 运行正确,never 类型可以赋值给 数字类型
y = (()=>{ throw new Error('exception')})();

// 返回值为 never 的函数可以是抛出异常的情况
function error(message: string): never {
   throw new Error(message);
}

// 返回值为 never 的函数可以是无法被执行到的终止点的情况
function loop(): never {
   while (true) {}
}

2.2 变量声明

2.2.1 类型推断

当类型没有给出时,TypeScript 编译器利用类型推断来判定类型:

var num = 'xiaoming';    // 类型推断为 number

此时num就被推断为是number类型了,如果之后再给num赋值其他类型,则编译报错:

num = 12;    // error TS2322: Type '12' is not assignable to type 'string'.

所以可以说JS的变量类型转换为“朝令夕改”,而TS则为“板上钉钉”。

2.2.2 类型断言

类型断言即可以用来手动指定一个值的类型。语法为:

 <类型> 值
 // 或as 类型

官网给出的例子供大家理解:

function getLength(param: string | number): number {
    return param.length;
}// error Property 'length' does not exist on type 'number'

即 number 类型是不含有length属性的,那此时可以这么玩

function getLength(param: string | number): number {
    if ((<string>param).length) {
        return (<string>param).length;
    } else {
        return param.toString().length;
    }
}

此时就把 param 断言为string类型,即不影响后续代码的运行

2.2.3 变量作用域

TS 有以下几种作用域:全局作用域、类作用域和局部作用域,一个例子就基本看明白了:

var global_num = 12          // 全局变量
class NumberType { 
   num_val = 13;             // 类变量
   static sval = 10;         // 静态变量
   
   storeNum():void { 
      var local_num = 14;    // 局部变量
   } 
} 

其中 sval 为静态变量,其主要特点是:普通变量可以通过类的实例对象来访问,而静态变量可直接通过类名访问而无需做实例化:静态变量的优势是具有记忆功能:

var obj = new NumberType();
obj.num_val // 13 
NumberType.sval // 10

2.3 函数

2.3.1 定义函数返回值和参数类型

这没啥可述的,举例如下:

function function_name(x: number, y: number):number { 
    // 语句
    return x + y;
} 

2.3.2 可选参数

JS中,参数个数和类型都是可选的,没传参的时候默认就是undefined。而在TypeScript中每个函数的参数的类型和数量都是默认指定的:

function buildName(firstName: string, lastName: string) {
  return firstName + " " + lastName;
}

let result1 = buildName("Bob");                  // error, too few parameters
let result2 = buildName("Bob", "Adams", "Sr.");  // error, too many parameters
let result3 = buildName("Bob", "Adams");         // 'Bob Adams'

因此在TypeScript中可以在参数名后添加"?"实现可选参数,即把上例改为:

function buildName(firstName: string, lastName?: string){
   return firstName + " " + lastName;
}
let result1 = buildName("Bob"); // 'Bob'

注意:可选参数必须跟在必选参数后面

2.3.3 默认参数

TS也可给参数一个默认值,用于当前没有传递这个参数或参数为undefined时。默认参数不需要放在最后,和JS一样:

function buildName(firstName = 'Will' , lastName: string ) {
  return firstName + " " + lastName;
}
let result1 = buildName("Bob");                  // error, too few parameters
let result2 = buildName("Bob", "Adams", "Sr.");  // error, too many parameters
let result3 = buildName("Bob", "Adams");         // "Bob Adams"
let result4 = buildName(undefined, "Adams");     // "Will Adams"

注意:参数不可同时设置为可选和默认

2.3.4 剩余参数

不知道有多少个参数,可以用剩余参数,在TS中可以把所有参数收集到一个变量里:

function buildName( firstName: string , ...restOfName: string[] ) {
    return firstName + " " + restofName.join(" ");
}
let result = buildName('C罗' , ['梅西','姆巴佩','内马尔']) 
// => C罗  梅西  姆巴佩  内马尔 

2.3.5 函数比较

举个小例子:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

要查看x是否能赋值给y,首先看它们的参数列表。x的每个参数必须能在y里找到对应类型的参数。 注意的是参数的名字相同与否无所谓,只看它们的类型。这里,x的每个参数在y中都能找到对应的参数,所以允许赋值。

2.4 接口

2.4.1 定义

接口是一系列抽象方法的声明,是一组方法特征的集合,也是TS的核心内容之一。其作用就是为了定义约定,举个最单独的例子:

interface Person {
  name: string;
  age?: number;
  address: string;
  readonly height: number;
  [propName: string]: any;
  sayHi: () => string
}

function testInterface(person: Person): void { }

此时在testInterface方法里对参数person做各种操作时,就会有特征提示,真的是很棒的: 示例图

上例中,属性后面带有“ ? ” 为可选属性;属性前面有 readonly 为只读属性,该属性属性只能在对象刚刚创建的时候修改其值:

let p1: Person = {
  name: 'JayChou',
  address: '台北',
  height: 175,
  sayHi: ():string => { return 'Hello大家好,我是周杰伦'}
};

p1.height = 180;
// Cannot assign to 'height' because it is a read-only property.

'[propName: string]: any'表示Person还允许拓展其他额外的属性,比如我可以再给它额外添加一个rank属性而不会报错:

function testInterface(person: Person): void { 
    person.rank = '菜鸡级前端开发人员' 
}

2.4.2 接口实现类

TS与C#和Java一样,也能够用接口来明确的强制一个类去符合某种契约,它主要是描述了所实现类的公共部分:

interface ClockInterface {
    currentTime: Date;
    setTime(d: Date);
}

// 实现一个Clock类(implements 为实现的意思)
class Clock implements ClockInterface {
    currentTime: Date;
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) { }
}

2.4.3 接口继承

接口继承就是说接口可以通过其他接口来扩展自己。TS 允许接口继承多个接口。继承使用关键字 extends。

interface Person { 
   age:number 
} 
 
interface Musician extends Person { 
   instrument:string 
} 
 
var drummer = <Musician>{}; 
drummer.age = 27 
drummer.instrument = "Drums" 
console.log("年龄:  " + drummer.age)
console.log("喜欢的乐器:  " + drummer.instrument)

2.4.4 多接口继承

这个无需赘述,直接上例子:

interface PianoPlayer {
   name: string;
   age: number;
   instrument: string[]
}

interface GuitarPlayer {
   instrument: string[];
   rank: number
}

interface Musician extends PianoPlayer, GuitarPlayer {};

var IObj: Musician = {
   name: '周杰伦';
   age: 40;
   instrument: ['钢琴',吉他','大提琴'];
   rank: 1
}

不知不觉,我伦已经40岁了......

2.4.5 接口继承类

当接口继承了一个类时,它会继承该类的成员,但不包括其实现,即(接口声明了所有类中存在的成员,但并没有提供具体实现一样)。 这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement):

class Control {
    private state: any;
}

interface SelectableControl extends Control {
    select(): void;
}

class Image implements SelectableControl {
    select() {}
}

综上,我个人可以认为,对于写业务代码来说,接口可以用到具体业务埋点上,通过接口把埋点属性都定义好,这是极佳的体验。

2.5 类

2.5.1 静态属性

静态属性存在于类本身上面而不是类的实例上。

class Grid {
    static origin = { x: 0 , y: 0 };
    calculate(point: { x: number; y: number;}) {
        let xDist = (point.x - Grid.origin.x);
        let yDist = (point.y - Grid.origin.y);
    }
}

2.5.2 抽象类(abstract)

抽象类即类本身就是抽象的,用一个最通俗的例子:水果有甜度、颜色等熟悉,但世界上不存在水果这个明确的类,明确的类是苹果、西瓜等,而苹果、西瓜就是水果这个抽象类的派生类。抽象类主要用于作为其他派生类的基类,注意如下:

  1. 抽象类中的抽象方法必须在派生类中实现,其他非抽象方法随意;
  2. 不能创建抽象类的实例,但能对抽象类的派生类进行实例化和赋值;
  3. 一旦创建了一个实例,并引用了抽象类型,抽象类的派生类中不能有抽象类中不存在的方法。

看一个小例子,为便于清晰,用截图: 抽象类

2.6 泛型

2.6.1 为什么用泛型

泛型即广泛存在的类型,我们在编程程序时,经常会遇到功能非常相似的模块,只是它们处理的数据不一样。举个例子,我们需要一种方法使返回值的类型与传入参数的类型是相同的,那此时你就可以用到泛型:

function identity<T>(arg: T): T {
    return arg;
}

上例给identity添加了类型变量T。T捕获用户传入的参数类型,并再次使用T将该类型当做返回值类型。因此,泛型有以下两种使用方式:

let output = identity<string>('myString');

这里明确指定了T是string类型。

第二种方法使用了类型推论--即编译器会根据传入的参数自动地帮我们确定T的类型:

let output = identity('mystring');// type of output will be 'string'

2.6.2 泛型数组

考虑一个场景:

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // Error: T doesn't have .length
    return arg;
}

此时无法编译,因为虽然参数类型是泛型,但只限于类型是数组的情况下才会有length属性,所以在不确定的情况下,自然会报错。所以,如果一定要使用length属性,就要使用泛型数组:

function loggingIdentity<T>(arg: T[]): T[] { return arg }

也可以换成这种形式:

function loggingIdentity<T>(arg: Array<T>): Array<T> {}

2.6.3 泛型类

泛型类使用(<>)括起泛型类型,跟在类名后面。它的作用是不限制实例的类型,但需要定义好此泛型类到底是个什么类型的类就好。就像下面这个myGenericNumber实例,定义好了其zeroValue属性和add方法的参数及返回值类型是个number即可。

class GenericNumber<T> {
  zeroValue: T;
  add: (x: T , y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();

myGenericNumber.zeroValue = 0;

myGenericNumber.add = function(x,y) {return x + y};

2.7 枚举

直接上例子了,枚举真的很好用:

const enum MediaTypes {
  JSON = "application/json"
}

fetch("https://swapi.co/api/people/1/", {
  headers: {
      Accept: MediaTypes.JSON
  }
})
.then((res) => res.json())

2.8 命名空间

命名空间的作用很明显,即不想把一些特定的方法暴露在全局下,而归一个命名空间去控制和管理,从而可以起到一定的防止变量冲突、分模块开发的作用。官网给的例子多少有些不太容易看,这里我写一个通俗demo:

namespace China {
  export interface Cultual {
      language: string;
      color: string;
  }
  export class People implements Cultual {
      language: '汉语'
      color:'黄种'
      population(num: number): void {
        console.log('the population of China is' + num);
      }
  }
}

namespace USA {
    export interface Cultual {
        language: string;
        color: string;
    }
    export class People implements Cultual {
        language: '英语'
        color: '白种'
        population(num: number): void {
          console.log('the population of China is' + num);
        }
    }
}
let Chinese = new China.People();
let American = new USA.People();

以上就是整理的一些重要、基础的TS知识点。接下来会以Vue3.0 + TS展开讨论。

3. Vue-cli 3.0 + TypeSCript

Vue-cli 3.0 的源码改用了TS来写,听听 尤大大的解释吧。现在可以直接创建一个基于TS的 Vue-cli 3.0 工程,选择流程可以参考如下:

  1. 创建项目 vue create 项目名称;
  2. 选择 Manually select features,即选择自配方案;
  3. 在选项中可以选取 babel、ts、router、vuex;
  4. 可以各种yes操作吧,最后会告知是否保存当前的配置方案作为下一次配置时的选择;

之后就搭建好了工程,可以看到组件的写法很“类”:

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class HelloWorld extends Vue {
  @Prop() private msg!: string;
}
</script>

这种类式组件写法要归功于vue-class-component 和 vue-property-decorator两个官方依赖库。通常它们一起使用,且 vue-property-decorator 是 vue-class-component 的扩展,常使用如下7个装饰器,进而实现像原生 JavaScript class 那样声明组件:

  • @Emit
  • @Inject
  • @Model
  • @Prop
  • @Provide
  • @Watch
  • @Component

接下来我们就用一个简单风趣的demo把这些知识点都梳理一下。

3.1 计算属性

计算属性的标识就是 get ,用计算属性拼接一句话,并作为展示,代码如下:

<h2>{{describPeople}}</h2>

<script lang='ts'>
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class HelloWorld extends Vue {
   private name: string;
   private team: string;
   constructor() {
       super();
       this.name = 'C罗'; // 初始化数据
       this.team = '意大利尤文图斯';
   }
   //计算属性
   public get description() {
       return `${this.name}的球队是${this.team}`;
   }
}
</script>

注意一下 lang='ts',话说回来。。。我罗到底还能火几年

3.2 监听 @Watch

现在我们做一个场景:初始化2s后(created),把变量name改为梅西,通过监听事件将球队变更为巴萨:

<h2>{{describPeople}}</h2>
@Component
export default class HelloWorld extends Vue {
   private name: string;
   private team: string;
   constructor() {
       super();
       this.name = 'C罗'; // 初始化数据
       this.team = '意大利尤文图斯';
   }
   
   //计算属性
   public get description() {
       return `${this.name}的球队是${this.team}`;
   }
   
   //监听
   @Watch('name')
   public nameChanged(val: string, oldVal: string) {
      if(val == '梅西') {
         this.team = '西班牙巴塞罗那';
      }
   }
   
   // created生命周期钩子
   public created() {
      setTimeout(() => {
          this.name = '梅西';
      },2000)
   }
}

其实我是第三个例子才写的梅西,但是怕有梅西粉丝抱怨没有双骄并列,故改正。

3.3 父传子通信(@Prop/@Provide/@Inject)

父组件传值给子组件有两种方式

3.3.1 @Prop

@Prop即由标签属性注入给子组件,这个相对简单,只需子组件定义好传来的数据类型,且可设置默认值:

// 子组件
<p>{{match}}</p>
@Component
export default class HelloWorld extends Vue {
   @Prop({default:'欧洲冠军联赛'}) private match!:string; // 注意!"
   private name: string;
   private team: string;
   constructor() {
       super();
       this.name = 'C罗'; // 初始化数据
       this.team = '意大利尤文图斯';
   }
}

// 父组件
<template>
  <div class='home'>
    <HelloWorld match="欧洲五大联赛"/>
  </div>
</template>

此时如果父组件不传入msg,则默认展示“欧洲冠军联赛”:

父子prop

3.3.2 @Provide & @Inject

  • @Provide:即向任意层级的子组件提供可访问的属性,默认为当前属性的名称,也可以指定其他名称;
  • @Inject:即获取任意层级父组件由@Provide提供的数据,也可以指定名称。

再改一下demo,父组件直接传入相关赛季年份数据:

// 父组件
<template>
  <div class='home'>
    <child></child> <!--不在子组件标签上导入-->
  </div>
</template>
@Component
export default class Home extends Vue {
  @Provide() private matchDate!:string;
  constructor() {
     super()
     this.matchDate = '2018-2019赛季';
  }
}

// 子组件
<template>
  <h2>{{matchDate}}</h2>
</template>
@Component
export default class Child extends Vue {
    @Inject() private matchDate: string
}

就这么简单。

3.4 子传父通信(@Emit)

@Emit顾名思义,就是的我们常用的$emit的装饰器,其效果与 this.$emit()一样,故我们设定一个场景:给子组件一个Button,点击触发传给父组件相应数据的请求,父组件接收并显示。如下:

//子组件
<button @click='toParent'>点我试试</button>
@Component
export default class HelloWorld extends Vue {

   @Emit('getData')
   public getData(data: string) {
       return data //传给父组件
   }
   
    public toParent() {
       this.getData('父组件你好'); // 等同于 this.$emit('getData', '父组件你好');
    }
}

// 父组件
<template>
  <div class='home'>
    <hello-world @getData='getChildData'></hello-world>
  </div>
</template>
@Component
export default class Home extends Vue {
    private childData: string; 
    constructor() {
      super()
      this.childData = '';
    }
  
    public getChildData(val: string) {
      this.childData = val; //这就拿到啦
    }
}

3.5 双向绑定@Model

这个我想很多Vue初学者都不是经常使用自定义v-model的,网上各种鱼龙混杂的例子特别多,这里还是用上例整理一个demo,希望大家喜欢。

这里把父组件比作国际足联委员会,通知子组件今年世界足球先生的获得者,并在子组件上展示。同时子组件也可以申述发回给父组件相关意见,常规写法如下:

// 父组件
<template>
  <div class='home'>
    <child :notify='MrFootBall' @response='getResponse'></child>
  </div>
</template>
@Component
export default class Home extends Vue {
  private MrFootBall: string;
  constructor() {
    super()
    this.MrFootBall = '2019世界足球先生是C罗,谁同意?谁反对';
  }
  
  public getResponse(val: string) {
    this.MrFootBall = val;
  }
}

//子组件
<template>
  <div class='child'>
     <h2>{{notify}}</h2>
     <button @click='sendAddvice('我萨迷反对')'>发送意见</button>
  </div>
</template>
@Component
export default class Child extends Vue {
   @Prop() private notify!: string;
   
   @Emit('response')
   public sendAddvice(data: string) {
     return data;
   }
}

最终实现的效果是,首先展示父组件传来的的data:

父通知子

反馈给父组件新的data,并展示:

父通知子

好了,那现在用@Model实现相同效果的双向绑定,其思想和 Vue 2.2文档的思路一致:

// 父组件
<template>
  <div class='home'>
    <child v-model='MrFootBall' @response='getResponse'></child>
  </div>
</template>
@Component
export default class Home extends Vue {
  private MrFootBall: string;
  constructor() {
    super()
    this.MrFootBall = '2019世界足球先生是C罗,谁同意?谁反对';
  }
  
  public getResponse(val: string) {
    this.MrFootBall = val;
  }
}

//子组件
<template>
  <div class='child'>
     <h2>{{notify}}</h2>
     <button @click='sendAddvice('我萨迷反对')'>发送意见</button>
  </div>
</template>
@Component
export default class Child extends Vue {
   @Model("response") notify!:string;
   
   @Emit('response')
   public sendAddvice(data: string) {
     return data;
   }
}

即Vue 2.2 的定义Model的思路一致,看看两者的方式:

// Vue 2.2 及以上
model: {
  prop: 'notify',
  event: 'response'
},

// @Model 写法
@Model("response") notify!:string;

demo就写到这里,其实在重新巩固完TS相关知识并结合 Vue拓展学习后,自己也确实深感TS的独到之处,相信从后端转到前端的朋友们会对TS更为亲切。但若真的说TS一定会在未来取代JS,我觉得目前来看并没有那么大的优势。希望大家喜欢这篇诚意之作。同时祈盼快找到章子欣小朋友,希望奇迹出现。


写完后再刷了一下新闻,痛心、难过,小朋友遗体已经找到,愿天堂没有恶魔。子欣一路走好