使用 TypeScript 开发使用 React&Redux 项目

最近在用 TypeScript 开发 Redux 项目,这过程踩了很多坑,也学了挺多东西,所以写一篇文章总结下。

Webpack 配置 TS 和 TSLint 支持

通过 Webpack 配置对 TypeScript 的支持是非常简单的,如下:

{
      test: /\.tsx*?$/,
      exclude: /node_modules/,
      loader: require.resolve('ts-loader'),
      options: {
        transpileOnly: true,
      },
}

需要注意的是 options 处设置了 transpileOnly 为 true,否则编译会很慢很慢,官方解释是:

As your project becomes bigger and bigger, compilation time increases linearly. It's because typescript's semantic checker has to inspect all files on every rebuild. The simple solution is to disable it by using the transpileOnly: true option, but doing so leaves you without type checking.

注意最后一句话,那如果我们还想要 type checking 怎么办呢,后面接着就解释怎么做了,想看原文的话去 ts-loader 的 GitHub 库看就好了。我就说下是怎么做的。

你需要配置下这个插件 fork-ts-checker-webpack-plugin

new ForkTsCheckerWebpackPlugin({
  checkSyntacticErrors: true,
  tsconfig: path.resolve(__dirname, 'tsconfig.json'),
  tslint: path.resolve(__dirname, 'tslint.json'),
  watch: ['./src/**/*.tsx'],
  ignoreLints: [
    'no-console',
    'object-literal-sort-keys',
    'quotemark',
  ],
})

把他加入到 Webpack 的 Plugins 中去,然后在 Webpack 启动时他会自动创建一个新进程去做 type checking,和 Webpack 的编译独立开来了,这样就可以在快速编译的同时又可以享受类型检查了。

你还可以使用 Webpack 的另一个插件忽略掉 d.ts 文件,避免因为编译生成 d.ts 文件导致又重新检查。

new webpack.WatchIgnorePlugin([
  /\.d\.ts$/,
]),

在 ts-loader 的仓库中,还有其他的一些提高编译速度的讨论,例如使用 cache-loader 或者 thread-loader,或者使用 happypack,但是这三个库我都试过了,并没有提升,一点提升都没有,反而降速了。。。也有尝试了 awesome-typescript-loader 但编译就直接失败了,想想 ts-loader 跟 ForkTsCheckerWebpackPlugin 一起工作也不慢,就暂时这样了。

配置 CSS 的静态检查

我比较中意 CSS Module 的方案,但是我无法在 TypeScript 中 import 一个 CSS 文件,因为 TypeScript 不知道如何处理这种文件。

另外就算你成功 import 了,TypeScript 也不知道这个文件里面有什么。

一种比较好的也是我目前在用的方法就是给 CSS 文件生成同名 d.ts 文件,这样的话你就可以在 TypeScript 文件中导入 CSS 了,而且 TypeScript 会读取该 d.ts 文件来做静态检查。

import styles from './index.pcss';

export const Test: React.SFC = function (props) {
  return <p className={styles.text}>123</p>
}

如上,当你输入 styles 时,编辑器可以告诉你这个 styles 里面有什么东西,而且当你点击 styles.text 跳转时,你有一定概率直接跳到对应的类名处。之所以说是有一定概率,是因为有时候跳到的是 d.ts 文件。这个我也不清楚原因,但是 TypeScript 的 issue 里面已经不少人反映了类似问题,很多时候你期望跳到源码,确跳到了 d.ts,这应该是每一个 TypeScript 开发者都遇到过。

那怎么配置呢,以使用 PostCSS 预处理为例。使用 typings-for-css-modules-loader 替代 css-loader 即可,之后每次编译 pcss 都会生成一个 d.ts 文件。然后就可以愉快的在 TypeScript 里面使用 css module 了。

{
  test: /\.pcss$/,
    use: {
      loader: require.resolve('typings-for-css-modules-loader'),
      options: {
        importLoaders: 1,
        modules: true,
        namedExport: true,
        camelCase: true,
        minimize: true,
        localIdentName: '[local]_[hash:base64:5]',
      },
    }, {
      loader: require.resolve('postcss-loader'),
    }],
}

这是目前我觉得最适合 TypeScript 的 CSS 方案了。

目录结构

项目基于 dva 开发,目录也比较简单,一般都会有的就 componentscontainersroutesmodelsservicesstyles 这几个。

  • components
    存放展示组件,接受上层 props 并进行渲染,不关心业务,一般可以使用 SFC 或者 Pure Component 来写。

  • containers

    存放容器组件,一般会使用 connect 从 Redux 映射数据和 reducer 过来。业务的集中地方。

  • routes

    组合 containers 和 components,这里的业务不宜过多,最好都拆分到 containers 做。routes 可以有一些状态,在多个 containers 之间流动。

  • models

    这个是 dva 的概念,写起来还是比较简洁的。一般我会每个 model 建一个文件夹,文件夹里面包含 index.tsactions.tsselectors.ts 以及 types.tsactions.ts 是一堆 ActionCreator;selectors.ts 是获取 store 数据的一些方法,需要衍生计算的数据会使用 reselect 库优化;types.ts 则是定义该 model 使用的服务端 API 的请求接口和响应接口。如果你觉得有必要的话还可以再加上一个 constants.ts 文件,用来定义常量,其实这个文件我觉得还是有必要性的,否则有些地方没法最终引用,重构不方便,有安全隐患。

  • services

    把网络请求放到这里面做

  • styles

    存放全局样式或者 CSS 变量

当然可能还有很多其他文件夹,就不介绍了。我觉得 dva 的 model 组织挺不错的,没有了解过的可以去了解下。

API 请求数据类型和返回数据类型

理一下,一个服务端返回的数据在前端要经过哪些东西,从上到下依次是:

  • service
  • Saga effect
  • Saga reducer
  • selector

在 React 组件,直接使用的是 selector,所以我们只需要 selector 可以返回正确的数据类型给我们就好了。我一般这么做:

import { createSelector } from 'reselect';

export const getOpEntityTotal = state => state.log.total as number;
export const getOpEntities = state => state.log.opEntities as struct.OpEntity[];

export const getOpEntitiesTableData = createSelector(
  [getOpEntities, getOpEntityTotal],
  (entities, total) => ({
    Total: total,
    OpEntities: entities.map(entity => ({
      ...entity,
      GmtCreate: new Date(entity.GmtCreate).toISOString().substring(0, 19).replace('T', ' '),
    })),
  })
);

可以看到其实前面两个方法 state 是没有声明参数类型的。这是因为:

  1. 我在 redux state 存放的是领域模型,服务端数据我基本不会做处理就存到 state 中,所以其实每个 state 的类型都是比较显而易见的。
  2. 这里 state 是全局 state,如果你要给他定义类型,那么意味着你每个 model state 都要定义类型,这样我觉得比较麻烦,必要性不大。

当然各有各的见解,我还每尝试捣鼓 rootState 的类型,但想一想就觉得有不少工作要做。后面我会尝试下。

数据衍生计算我是依赖 reselect 来进行,reselect 的优点是可以缓存计算结果,提升性能。而且只要 createSelector 前面方法的返回值类型确定,那么 selector 的返回值就可以被推导确定,非常强大。

Component 中如下 mapStateToProps 就可以了:

const mapStateToProps = (state, ownProps: IOwnProps) => ({
  opEntitiesTable: getOpEntitiesTableData(state),
  ...ownProps,
});

上面是返回数据的类型,接着说请求的数据类型,同样,一个请求的过程:

  1. Component dispatch 一个 ActionCreator

    export const describeOpEntities = (payload: IDescribeOpEntitiesReq) => ({
      type: 'log/describeOpEntities',
      payload,
    });
    
  2. effect 里面通过 call 调用 service 的一个方法

    {
      * describeOpEntities({ payload }, { select, call, put }) {
        const instances: IDescribeInstancesRes = yield call(logService.describeOpEntities, payload);
        yield put({
          type: 'saveOpEntities',
              payload: {
                  ...instances,
              },
          });
        },
    }
    
  3. service 里面

    export default {
      describeOpEntities(params) {
        return axios.get('/DescribeOpEntities.json', {
          params,
        });
      },
    };
    

可以看到,唯一一个声明了传参类型的是 actionCreator。原因是,actionCreator 可以在组件被使用,通过 mapDispatchToProps 映射到 props 中:

const mapDispatchToProps = dispatch => ({
  describeOpEntities: bindActionCreators(describeOpEntities, dispatch),
});

之后我们在组件内部使用 this.props.describeOpEntities 方法时,就会检查参数是否匹配 IDescribeOpEntitiesReq 了。

而后面为什么我不声明了呢?其实主要原因也是在 ActionCreator,type 中的字符串其实对应到了具体的一个 reducer,这种写法导致你的 reducer 从程序自己看起来是没有被引用的。所以你在 reducer 定义参数类型,也只能方便你 reducer 后面静态检查用。同理,调用 service 也是一个道理,并且 service 里面定义类型给我感觉必要性不大。

简而言之,在 ActionCreator 声明请求数据的类型,在 Selector 声明返回数据的类型,其他的地方可声明可不声明。这是目前我的一点看法。

关于 connect

这是只核心的一个地方了,我们知道 mapStateToProps 和 mapDispatchToProps 两个方法返回的的数据类型加上组件本身的 props 类型就是被 connect 组件的 props 类型了。

mapStateToProps 使用的 selector 我们已经有明确的定义类型了;mapDispatchToProps 所使用的 ActionCreators 也是。

那怎么得到这个方法返回值的类型呢?

只能通过污染运行时实现,简单而言就是执行这个方法,返回值赋给一个变量,该变量的类型就是我们要的了。当然方法不是真的执行了,例如:

const test = false as true && mapStateToProps(undefined)
type IMapStateToProps = typeof test

所以代码:

interface IOwnProps {
  currentInstanceId: string;
  currentSelectProtocol?: string;
}
const mapStateToProps = (state, ownProps: IOwnProps) => ({
  listenerTableData: getListenerTableData(state),
  instances: getInstanceSelections(state),
  ...ownProps,
});
const mapDispatchToProps = dispatch => ({
  updateRules: bindActionCreators(describeLayer4Rules, dispatch),
  createLayer4Rule: bindActionCreators(createLayer4Rule, dispatch),
});
const mapState = getReturnOfExpression(mapStateToProps);
const mapDispatch = getReturnOfExpression(mapDispatchToProps);
type IMapStateToProps = typeof mapState;
type IMapDispatchToProps = typeof mapDispatch;
type IProps = IMapStateToProps & IMapDispatchToProps;

现在 IProps 就是我们要的东西,我们可以声明到组件的 Props 中去。

@connect(mapStateToProps, mapDispatchToProps)
export default class RuleTable extends React.Component<IProps, IOwnState> {}

但是这带来了一个新的问题,我们使用该组件时,编辑器会按照 IProps 来检查我们的传参,而期望的是应该按照 IOwnProps 来匹配才对。

当然你组件 Props 接口都这么写了,难怪编辑器会这么处理了。

那怎么办呢?

class RuleTable extends React.Component<IProps, IOwnState> {}
export default connect<IMapStateToProps, IMapDispatchToProps, IOwnProps>(
  mapStateToProps,
  mapDispatchToProps
)(RuleTable);

这里变化了两个地方:

  1. 不使用 connect 装饰器语法糖,其实按道理是可以用装饰器语法的,但是就是用不了。
  2. 使用 connect 的泛型,传入三个类型

这样当你导入该组件时,编辑器就会按照 IOwnProps 来检查了。

全局 namespace 和 interface

之所以说是全局,是因为你不需要 import 就可以直接使用。听起来非常方便,可以减少很多引用,但是也带来了潜在的冲突问题和混乱问题。

目前我是把服务端返回的数据的数据结构放到 struct 这个 namespace 下,例如:

namespace struct {
  export interface Instance {
    InstanceId: string;
    Remark: string;
    Status: 1 | 2 | 3;
    ExpireTime: string;
    GmtCreate: string;
  }
}

一旦你 namespace 前面加上 export,那么他就是一个需要被导入才能使用的命名空间了。但是我这样写,就可以在任意地方直接 struct.Instance 这样用。

interface 也一样,只要放在文件最外层,不进行导出。目前呢,我服务端的请求和响应的数据类型是这样子写的。因为接口都具备唯一性,所以没有重名的可能。

我觉得还是比较好用的,但是似乎 TypeScript 并不鼓励这种做法。需要 tslint:disable:no-namespace 才行。

一些值得注意的点

  1. 如果我们使用 SFC (stateless funcional component) 的话,这时候 props 类型怎么处理呢?

    const NewBalloon: React.SFC<BalloonProps> = (props) => {
       return (
         <span className={styles.balloon}>
           <Balloon
             trigger={<Icon type="prompt" size="small" />}
             align="t"
             children={props.children}
           />
         </span>
       );
     };
    

    React.SFC,泛型就是 props 的类型。如果你没有传类型进去,那么 props 也会具备一个 children 属性。

  2. 怎么检查 styles?

    我们知道 styles 其实是一个比较特殊的 props,我们写的时候其实是有特殊补全的,因此作为 props 传他应该也是可以被检查的,怎么做到 type safe 呢?

    const styles: React.CSSProperties = { flexDirection: 'row', ... }
    
  3. 如果我们使用 defaultProps 了,那么 props 的接口可能就变了,怎么处理比较好?

    export class StatefulCounterWithDefault extends React.Component<StatefulCounterWithDefaultProps, State> {
      static defaultProps: DefaultProps = {
        initialCount: 0,
      };
    
      props: StatefulCounterWithDefaultProps & DefaultProps;
    
      state: State = {
        count: this.props.initialCount,
      };
    }
    

还有很多很多,可以参考这里:https://github.com/piotrwitek/react-redux-typescript-guide

另外,我们在使用一些开源库的时候,可能会经常遇到 type definition 文件缺失或者不正确的情况,这时候我们只能自己写了,例如,像上面我 connect 是可以使用泛型的,但是 dva 本身 connect 的 definition 并没有泛型。这时候我们只能:

declare module 'dva' {
  import { Connect } from 'react-redux';

  export * from 'dva';
  export const connect: Connect;
}

重写 dva 的 connect 类型为 react-redux 的 connect 类型,就可以正常使用了。

总结

使用 TypeScript 写 Redux 没有写 Mobx 舒服,坑也不少例如 connect 这个坑花了我不少时间。但一切都还是值得的,写习惯了 TypeScript 再回去 JavaScript 比较困难了,虽然会增加很多代码,但是带来的收益值得你的那些付出。React 本身搭配 TypeScript 是非常舒服的事情,但是 Redux 中,会有很多坑,想要做完美类型覆盖是很耗费时间且没有必要的事情。好吧,就这样。