image.png

开发环境

为了让成员的代码符合团队的规则,并且样式保持一致,建议使用 ESlint 和 Prettier。

1. ESLint

ESLint 是一个 JavaScript Lint,帮助我们规范代码质量,提高开发效率。

安装依赖

  1. eslint:JavaScript 代码检测工具
  2. @typescript-eslint/parser:将 TS 转化成 ESTree,使能被 eslint 所识别
  3. @typescript-eslint/eslint-plugin:TS 规则列表,其中的每一条规则都可进行打开或关闭
    1. yarn add eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -D

    配置 .eslintrc.js

    module.export = {
     // 解析器
    parser: "@typescript-eslint/parser",
    // 继承的扩展
    extends: ["plugin:@typescript-eslint/recommended", "react-app"],
    // 插件
    plugins: ["@typescript-eslint", "react"],
    // 规则
    rules: {}
    }
    

    2. Prettier

    Prettier 能够统一团队的编码风格,统一的代码风格有助于保证代码的可读性。

安装依赖

  1. prettier: 按配置格式化代码
  2. eslint-config-prettier: 将禁用任何可能干扰现有 prettier 规则的 linting 规则
  3. eslint-plugin-prettier: 将作为 ESlint 的一部分运行 Prettier 分析
    yarn add prettier eslint-config-prettier eslint-plugin-prettier -D
    

    配置 .eslintrc.js

    module.export = {
     // 解析器
    parser: "@typescript-eslint/parser",
    // 继承的扩展
    extends: ["plugin:@typescript-eslint/recommended", "react-app", "plugin:prettier/recommended"],
    // 插件
    plugins: ["@typescript-eslint", "react"],
    // 规则
    rules: {}
    }
    

    配置 .prettierrc

    {
     "singleQuote": true,
     "trailingComma": "es5",
     "printWidth": 80,
     "semi": true,
     "tabWidth": 4,
     "useTabs": false
    }
    

    3. VSCode 编辑器

    在 workspace settings 中配置检测文件范围,确保 react 项目中 .ts.tsx 文件有 lint 自动修复功能。
    {
    "eslint.validate": [
       "javascript",
       "javascriptreact",
       {
         "language": "typescript",
         "autoFix": true
       },
       {
         "language": "typescriptreact",
         "autoFix": true
       }
    ]
    }
    

TS React 图谱

基础篇

文档传送门

实战篇

环境搭建

同上,第一篇幅。

组件与类型

  1. 函数组件

函数组件一般只需要约束 props 参数。

import React from 'react';
import { Button } from 'antd';

interface Greeting {
    name: string;
    firstName: string;
    lastName: string;
}

const Welcome = (props: Greeting) => <Button>Hello {props.name}</Button>

export default Welcome
  1. 类组件

类组件除了需要约束 props 参数外,还需要给 state 进行定义。如果有可选参数,需要定义默认值,类组件中使用 static 关键字。

import React, { Component } from 'react';
import { Button } from 'antd';

interface Greeting {
    name: string;
    firstName?: string;
    lastName?: string;
}

interface WelcomeState {
    count: number
}

class WelcomeClass extends Component<Greeting, WelcomeState> {
    state: WelcomeState = {
        count: 0
    }

   // 给可选参数定义默认值 
    static defaultProps = {
        firstName: '',
        lastName: ''
    }

    render() {
        return (
            <>
                <p>你点击了 {this.state.count} 次</p>
                <Button onClick={() => {this.setState({count: this.state.count + 1})}}>
                    Hello {this.props.name}
                </Button>
            </>
        )
    }
}

export default WelcomeClass;
  1. 高阶组件

高阶函数需要使用 ts 范型约束传进来的组件,使用类型断言来约束 props。

import React, { Component } from 'react';

interface Loading {
    loading: boolean
}

function WelcomeHOC<P> (WrappedComponent: React.ComponentType<P>) {
    return class extends Component<P & Loading> {
      render () {
      const { loading, ...props } = this.props;
      return loading ? <div>Loading...</div> : <WrappedComponent { ...props as P } />;
    }
  }
} 

export default WelcomeHOC
  1. hooks

hooks 函数组件需要约束 props 参数。

import React, { useEffect, useState } from 'react'
import { Button } from 'antd'

interface Greeting {
  name: string
  firstName: string;
  lastName: string;
}

const WelcomeHooks = (props: Greeting) => {
  const [count, setCount] = useState(0)
  const [text, setText] = useState<string | null>(null)

  useEffect(() => {
    if (count > 5) {
      setText('休息一下')
    }
  }, [count])

  return (
    <>
      <p>你点击了 {count} 次 {text}</p>
      <Button onClick={() => {setCount(count + 1)}}>
        Hello {props.name}
      </Button>
    </>
  )
}

WelcomeHooks.defaultProps = {
    firstName: '',
  lastName: ''
}

export default WelcomeHooks

事件与类型

// 普通原生事件,类型定义
export default class Button<P, S> extends React.Component<P, S> {
  public handleClick = (e: React.MouseEvent) => {
    console.log(e.screenX, e.screenY)
    console.log(this.getVal(5))
  }
  private getVal = (x: number): number => {
    return x * x
  } 
  public render() {
    return (
      <div onClick={this.handleClick}>
        content
      </div>
    )
  }
}

class中推荐各种数据与方法的书写顺序

[
        "public-static-field",
        "protected-static-field",
        "private-static-field",
        "static-field",
        "public-static-method",
        "protected-static-method",
        "private-static-method",
        "static-method",
        "public-instance-field",
        "protected-instance-field",
        "private-instance-field",
        "public-field",
        "protected-field",
        "private-field",
        "instance-field",
        "field",
        "constructor",
        "public-instance-method",
        "protected-instance-method",
        "private-instance-method",
        "public-method",
        "protected-method",
        "private-method",
        "instance-method",
        "method"
]

数据请求

// ajax.d.js
declare namespace Ajax {
  export interface Data<T>{
    pageObject: T[],
    pageIndex: number,
    pageSize: number,
    totalPage: number,
    totalCount: number,
  }
  export interface AjaxResponse{
    code: number,
    msg: string,
    title?: string,
  }


  // boolean number string number object
  export interface AjaxResponseStr<T> extends AjaxResponse{
    data: T,
  }

  // 列表页
  export interface AjaxResponseList<T> extends AjaxResponse {
    data: Data<T>,
  }
}
import 'whatwg-fetch';
import * as Antd from 'antd';
import queryString from 'query-string';




let baseUrl = 'http://boss-test.intra.xiaojukeji.com';
if (/boss-pre.am.intra.xiaojukeji.com/.test(location.host)) {
  baseUrl = 'http://boss-pre.am.intra.xiaojukeji.com';
}
if (/boss.xiaojukeji.com/.test(location.host)) {
  baseUrl = 'http://boss.xiaojukeji.com';
}
const { notification } = Antd;

const codeMessage = {
  200: '服务器成功返回请求的数据。',
  201: '新建或修改数据成功。',
  202: '一个请求已经进入后台排队(异步任务)。',
  204: '删除数据成功。',
  400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
  401: '用户没有权限(令牌、用户名、密码错误)。',
  403: '用户得到授权,但是访问是被禁止的。',
  404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
  406: '请求的格式不可得。',
  410: '请求的资源被永久删除,且不会再得到的。',
  422: '当创建一个对象时,发生一个验证错误。',
  500: '服务器发生错误,请检查服务器。',
  502: '网关错误。',
  503: '服务不可用,服务器暂时过载或维护。',
  504: '网关超时。'
};


function isValidKey(key: number | string, obj: {}): key is keyof typeof obj {
  return key in obj;
}

function checkStatus(response: Response) {
  if (response.status >= 200 && response.status < 300) {
    return response;
  }

  let errortext;
  if (isValidKey(response.status, codeMessage)) {
    errortext = codeMessage[response.status] || response.statusText;
  }

  notification.error({
    message: `请求错误 ${response.status}: ${response.url}`,
    description: errortext
  });
  const error = new Error(errortext);
  error.name = String(response.status);
  throw error;
}

export default function request<T>(url: string, options?: object) {
  const defaultOptions = {
    credentials: 'include',
    headers: {},
  };
  const newOptions: any = { ...defaultOptions, ...options };
  const { method = 'get' } = newOptions;
  if (
    method.toUpperCase() === 'POST' ||
    method.toUpperCase() === 'PUT' ||
    method.toUpperCase() === 'DELETE'
  ) {
    if (!(newOptions.body instanceof FormData)) {
      newOptions.headers = {
        Accept: 'application/json',
        'Content-Type': 'application/json; charset=utf-8',
        ...newOptions.headers
      };
      // 判断
      for (let key in newOptions.headers) {
        if (newOptions.headers.hasOwnProperty(key)) {
          if (
            key === 'Content-Type' &&
            newOptions.headers[key] === 'application/json; charset=utf-8'
          ) {
            newOptions.body = JSON.stringify(newOptions.body);
            break;
          }
        }
      }
    } else {
      newOptions.headers = {
        Accept: 'application/json',
        ...newOptions.headers
      };
    }
  }

  if (newOptions.params !== undefined) {
    url = url + '?' + queryString.stringify({ ...newOptions.params });
  }

  return new Promise((res: (value: T) => void, rej: (value?: any) => void) => {
    fetch(/^https|http/.test(url) ? url : baseUrl + url, newOptions)
      .then(checkStatus)
      .then(response => {
        if (newOptions.method === 'DELETE' || response.status === 204) {
          return response.text();
        }
        return response.json();
      })
      .then((response) => {
        if (response.code === 10003 || response.status === 10008) {
          window.location.href = baseUrl;
        } else if (response.code !== 10000) {
          notification.error({
            message: response.msg,
          });
          rej(response);
        }
        res(response);
      })
      .catch(e => {
        rej(e);
      });
  });
}

Redux 与类型

进行中…

工程篇

命名空间

*在一个模块化的系统中,是可以不需要使用命名空间的。

namespace 命名空间(之前又叫内部空间),目标是解决重名问题。

举例子,你项目中需要验证表单里的用户输入,这时你想要写一套验证器,一般的做法会在 utils 文件中新建一个 validation 文件,将所有的验证方法集中编写并导出。随着验证器的不断增多,可能你会开始担心与其它对象产生命名冲突,为了避免全局污染,TS 希望我们把所有验证器包裹到一个命名空间内,而不是放在全局命名空间下。

// validation.ts
namespace Validation {
  const lettersRegexp = /^[A-Za-z]+$/
  const numberRegexp = /^[0-9]+$/

  // 定义一个字符串验证器
  export interface StringValidator {
    isAcceptable(s: string): boolean
  }

  // 定义一个使用字符串验证器实现的文本验证器类
  export class LettersOnlyValidator implements StringValidator {
    isAcceptable (s: string) {
      return lettersRegexp.test(s)
    }
  }

  // 定义一个使用字符串验证器实现的ZipCode验证器类
  export class ZipCodeValidator implements StringValidator {
    isAcceptable (s: string) {
      return s.length === 5 && numberRegexp.test(s)
    }
  }
}

使用时我们可以将 validation.ts 编译成 validation.js,然后在 index.html 中引入使用。

tsc valition.ts
 <script type="text/javascript" src="./validation.js"><script>

利用别名来简化命名空间操作:

// 接上面 valuation.ts

import lettersOnlyValidator = Validation.LettersOnlyValidator

let lov = new lettersOnlyValidator()

声明合并

*申明合并更重要的是为了兼容老的项目,我们建议将申明放在一起

如果定义了多个相同名字的函数、接口申明,那么编译器会将相同名字的申明合并为一个申明。好处是可以将程序中散落各处的重名申明合并在一起,这样在使用时候对这个多处的定义同时具有感知能力,避免对接口成员的遗漏。

函数合并

function reverse(x: number): number
function reverse(x: string): string
function reverse(x: number | string): number | string {
  if (typeof x === 'number') {
    return Number(x.toString().split('').reverse().join(''))
  } else if (typeof x === 'string') {
    return x.split('').reverse().join('')
  }
  return ''
}

console.log(reverse(123))


接口合并**

interface People {
  name: string
}

interface People {
  age: number
}

let people: People = {
  name: 'allen',
  age: 26
}

console.log(people) // { name: 'allen', age: 26 }

相当于:

interface People {
  name: string
  age: number
}

let people: People = {
  name: 'allen',
  age: 26
}

console.log(people) // { name: 'allen', age: 26 }

注意,合并的属性的类型必须都是一致的:

interface People {
  name: string
  height: number
}

interface People {
  age: number,
  height: number // 重复的属性,但拥有相同的类型,不会报错
}
interface People {
  name: string
  height: number
}

interface People {
  age: number,
  height: string // Subsequent property declarations must have the same type.  Property 'height' must be of type 'number', but here has type 'string'.
}

接口中方法合并,同上函数合并具有函数重载:

interface People {
  name: string
  say(lang: string): string // 顺序2
}

interface People {
  age: number,
  say(lang: string[]): string[] // 顺序1
}

相当于:

interface People {
  name: string
  age: number,
  say(lang: string[]): string[]
  say(lang: string): string,
}


命名空间的合并**

// *命名空间必须放在后面

// 函数与命名空间
function Lib () {}
namespace Lib {
  export let version = '1.0'
}
console.log(Lib.version) // 1.0

// 类与命名空间
class Lib {}
namespace Lib {
  export let state = 1
}
console.log(Lib.state) // 1

// 枚举与命名空间
enum Color {
  Red,
  Yellow,
  Blue
}
namespace Color {
  export function mixin () {}
}
console.log(Color) 
/** 
 { '0': 'Red',
'1': 'Yellow',
'2': 'Blue',
 Red: 0,
 Yellow: 1,
 Blue: 2,
 mixin: [Function: mixin] }
*/

如何编写申明文件

在使用 Typescript 时,有时候我们需要用到第三方库,比如说 jquery。如果我们按照非 Typescript 项目的方式使用 jquery 会是这样的:

import $ from 'jquery'
// 抛错
/** 
Could not find a declaration file for module 'jquery'. '/Users/didi/selfspace/react-ts-practices/node_modules/jquery/dist/jquery.js' implicitly has an 'any' type.
  Try `npm install @types/jquery` if it exists or add a new declaration (.d.ts) file containing `declare module 'jquery';`ts(7016)
*/

意思是没有找到 jquery 的申明文件,需要安装 @types/jquery。很幸运,一般的库官方都已经提供了申明文件。我们只需要同时安装:

npm install @types/jquery

你使用的第三方库是否有官方提供的申明文件,可以通过 http://microsoft.github.io/TypeSearch/ 网站查。
那如果没有申明文件的时候,你就需要自己写了,这也是给社区贡献代码的好时候。

全局类库
global-lib.js

function globalLib(options) {
  console.log(options)
}

globalLib.version = '1.0.0'

globalLib.doSomething = function () {
  console.log('globalLib do something')
}

同级编写一个申明文件 global-lib.d.ts

declare function globalLib(options: globalLib.Options): void

declare namespace globalLib {
  const version: string
  function doSomething(): void
  interface Options {
    [key: string]: any
  }
}


模块库**
module-lib.js

const version = '1.0.0'

function doSomething() {
  console.log('moduleLib do something')
}

function moduleLib(options) {
  console.log(options)
}

moduleLib.version = version
moduleLib.doSomething = doSomething

module.exports = moduleLib

同级编写一个申明文件 module-lib.d.ts

declare function moduleLib(options: Options): void

declare namespace moduleLib {
  const version: string
  function doSomething(): void
  interface Options {
    [key: string]: any
  }
}

export = moduleLib


UMD库**
umd-lib.js

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(factory)
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory()
  } else {
    root.umdLib = factory()
  }
}(this, function () {
  return {
    version: '1.0.0',
    doSomething() {
      console.log('umdLib do something')
    }
  }
}))

同级编写一个申明文件 umd-lib.d.ts

declare namespace umdLib {
  const version: string
  function doSomething(): void
}

export as namespace umdLib

export = umdLib

tsconfig.json 配置

{
  "compilerOptions": {
    /** 输出代码 ES 版本,可以是 ["es3", "es5", "es2015", "es2016", "es2017", "esnext"] */
    "target": "es5",
    /** 引入库定义文件 */
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    /** 允许编译时有 js 文件 */
    "allowJs": true,
    /** 对库定义文件跳过类型检查 */
    "skipLibCheck": true,
    /** 支持从 CommonJS/AMD/UMD 默认导入,并且可以正常工作。 */
    "esModuleInterop": true,
    /** 允许引入没有默认导出的模块 */
    "allowSyntheticDefaultImports": true,
    /** 同时开启 alwaysStrict, noImplicitAny, noImplicitThis 和 strictNullChecks */
    "strict": true,
    /** 不允许不同变量来代表同一文件 */
    "forceConsistentCasingInFileNames": true,
    /** 指定模块生成方式 */
    "module": "esnext",
    /** 指定模块解析方式 */
    "moduleResolution": "node",
    /** 是否允许把 json 文件当做模块进行解析 */
    "resolveJsonModule": true,
    /** 每个文件需要是一个模块 */
    "isolatedModules": true,
    /** 不生成编译文件,即不生成 js */
    "noEmit": true,
    /** jsx 的编译方式 */
    "jsx": "react"
  },
  /** 只编译 src 目录下文件 */
  "include": [
    "src"
  ]
}

目录模板

.
├── README.md
├── config
├── mock
├── package.json
├── scripts
├── src
│   ├── app.ts
│   ├── assets
│   ├── components
│   ├── constants
│   ├── global.less
│   ├── global.ts
│   ├── helper
│   ├── layouts
│   ├── models
│   ├── pages
│   ├── routes
│   ├── apis
│   ├── styles
│   ├── type
│   └── utils
├── tsconfig.json
├── typings.d.ts
└── yarn.lock

编译工具

进行中…

代码检测工具

关于代码检测工具,Typescript 官方从 TSLint 转向 ESLint,原因是 TSLint 执行规则的方式存在一些架构问题,从而影响了性能,修复这些问题会破坏现有的规则。并且 ESLint 的性能更好,社区用户拥有 ESLint 的规则配置。

ts-eslint.png
安装 eslint@typescript-eslint/eslint-plugin@typescript-eslint/parser

配置 .eslintrc.json

{
    // 解析器
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
        "project": "./tsconfig.json"
  },
  // 继承的扩展
  "extends": ["plugin:@typescript-eslint/recommended"],
  // 插件
  "plugins": ["@typescript-eslint"],
  // 规则
  "rules": {}
}

package.json 中配置指令:

"script": {
    "lint": "eslint src --ext .js,.ts,.tsx"
}

单元测试

进行中…

约定篇

  • jsx 代码的文件名后缀使用 .tsx,其它的使用 .ts
  • 类型定义放在文件的最前面,导入内容之后。 ```typescript import React, { Component } from ‘react’

interface People { gender: number name: string age: number }


- interface 接口命名使用大驼峰法,可以与进行约束的函数等命名保持一致。
```typescript
interface People {
    gender: number
  name: string
  age: number
}

const people: Readonly<People> = {
  gender: '123', // Type 'string' is not assignable to type 'number'.ts(2322)
  name: 'allen',
  age: 26
}
  • interface 声明顺序:只读参数放第一位,必须参数第二位,可选参数第三位,不确定参数放最后。 ```typescript interface People { readonly national: ‘string’ gender: number name: string age?: number

}


- interface 接口管理,可以按照功能模块统一 interface/xx.ts 下编写。
- 不要使用如下类型 `Number`,`String`,`Boolean` 或 `Object`。 这些类型指的是非原始的装盒对象。
```typescript
/** ❌错误 */
function atest(s: String): String

// 应该使用 number、string 和 boolean 非原始类型
/** ✅正确 */
function atest(s: string): string
  • 回调函数返回值类型 ```typescript // 不要为返回值被忽略的回调函数设置 any 类型的返回值类型 // 为什么:使用 void 相对安全,因为它防止了你不小心使用 x 的返回值 /* ❌错误 / function fn(x: () => any) { x() }

/* ✅正确 / function fn(x: () => void) { x() }


- 回调函数里的可选参数
```typescript
// 当回调函数不在乎是否带某一个参数进行调用时,你不需要把这个参数当成可选参数来达到目的,因为总是允许提供一个接收较少参数的回调函数。
/** ❌错误 */
interface Fetcher {
    getObj(done: (data: any, elapsedTime?: number) => void): void
}

/** ✅正确 */
interface Fetcher {
    getObj(done: (data: any, elapsedTime: number) => void): void
}
  • 函数重载顺序 ```typescript // 不要把一般的重载放在精确的重载前面 // 为什么:Typescript 会选择第一个匹配到的重载当解析函数时,如果前面的重载比后面的“普通”,那么后面的都被隐藏而不会被调用。 /* ❌错误 / declare function fn(x: any): any declare function fn(x: HTMLElement): number declare function fn(x: HTMLDivElement): string

var elem: HTMLDivElement var x = fn(elem) // x: any

/* ✅正确 / declare function fn(x: HTMLDivElement): string declare function fn(x: HTMLElement): number declare function fn(x: any): any

var elem: HTMLDivElement var x = fn(elem) // x: string


- 使用可选参数
```typescript
// 不要为仅在末尾参数不同时写不同的重载
// 应尽量使用可选参数
/** ❌错误 */
interface ITest {
    diff(one: string): number
  diff(one: string, two: string): number
  diff(one: string, two: string, three: boolean): number
}

/** ✅正确 */
interface ITest {
    diff(one: string, two?: string, three?: boolean): numbernm,-[p;lb hv
}
  • 使用联合类型 ```typescript // 不要为仅在某一个位置上的参数类型不同的情况下定义重载 // 应尽量使用联合类型 /* ❌错误 / interface Moment { utcOffset(): number utcOffset(b: number): Moment utcOffset(b: string): Moment }

/* ✅正确 / interface Moment { utcOffset(): number utcOffset(b: number|string): Moment }


- JSX 语法中,只有 as 语法断言是被允许的
```typescript
// Typescript 中类型断言有两种写法
// 其一“尖括号”语法
let someVal: any = 'this is a string'
let strLen: number = (<string>someVal).length

// 另一种为 as 语法
// 当你在 Typescript 中使用 JSX 时,只有 as 语法断言是被允许的
let someVal: any = 'this is a string'
let strLen: number = (someVal as string).length
  • 类型推断 ```typescript // Typescript 里,在有些没有明确指出类型的地方,类型推断会帮助提供类型 let x = 1 x = ‘be string’ // Type ‘“be string”‘ is not assignable to type ‘number’.ts(2322)

// 虽然 Typescript 会在设置默认参数值和决定函数返回值时推断类型 // 但是 我还是不建议不手动设置类型,这会让代码不那么直观 let x: number = 1 ```

参考资料

https://github.com/typescript-cheatsheets/react-typescript-cheatsheet
https://github.com/microsoft/TypeScript-React-Starter
https://github.com/piotrwitek/react-redux-typescript-guide
https://piotrwitek.github.io/react-redux-typescript-guide/