[笔记]js-to-ts

前言

最近在把公司的一个 React + Webpack 的项目从 js 迁移到 typescript 上. 这里记录下迁移的过程以及遇到的一些问题.

在开始之前, 先安利一下 Typescript 官方文档提供的从 JavaScript 迁移, 以及其中提到的 React 项目转换指南. 这两篇文章也涵盖了我们迁移过程中相当大一部分内容.

配置文件相关

引用方式

tsc 默认对于 commonjs 的模块是这样加载的

1
import * as React from 'react'

我们不想改变原先的写法: import React from 'react'. 幸好, ts 提供了一个编译参数 allowSyntheticDefaultImports.

1
2
3
4
5
{
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node"
}

相对路径

我们项目的 webpack 配置将 src 目录配置在了 resolve 中

1
2
3
4
5
{
resolve: {
modules: ['node_modules', 'src']
}
}

代码中, 可以通过 `import ‘App’ from ‘components/App’ 的方式引用 src/components/App.jsx 文件

在 typescript 中, 需要配置 baseUrlpaths 来告诉 tsc 寻找文件的路径.

1
"baseUrl": "./src",

或者

1
2
3
4
5
6
7
8
9
10
11
{
compilerOptions: {
"baseUrl": ".",
"paths": {
"*": [
"src/*",
"node_modules/*"
]
}
}
}

二者略有不同. 从结论上看 paths 的方式会检测加载模块关联的声明, 对于声明缺失的模块编译时会抛出一个错误.

全局参数

对于项目中用到的全局变量, 以及 WebpackDefinePlugin 之类输出的东西, 我们可以建一个 src/global.d.ts 文件来声明进行声明, 比如:

1
declare const global: any

另一方面, webpack 通过X-loader 加载 js 之外的文件加载. 这些东西 tsc 看不懂, 就会报错. 我们希望 tsc 在 type checking 时能跳过这些模块, 也可以在 global.d.ts 里加上模块的声明:

1
2
3
4
5
declare module '*.png'
declare module '*.jpg'
declare module '*.svg'
declare module '*.mp4'
// ...

顺带一提, 我们的 WebpackDefinePlugin 会输出一个 process 变量来同步一部分配置文件到前端. 一开始我是拒绝在面向 browser 的项目引用 @types/node , 而是通过 global.d.ts 引入的. 然而很多第三方的模块会带入 @types/node 造成重复声明的异常, 最后只好直接显式的引入 @types/node 了.

从 js 到 ts(x)

tsc 的 allowJS 参数支持对 js 的解析. 但是当将 js 后缀改为 ts(x)时, 通常会出现大量的问题, 这里总结下在我们项目中比例较大的一部分情况.

是否包含 jsx

过去, 我们并没有使用 .jsx 的后缀, 无论是否包含 JSX 格式的数据均保存为 .js 文件. 然而在 .ts 的文件中使用 JSX 会导致一个错误.

因此改后缀之前需要判断 文件中是否包含 JSX 格式的数据, 分别转换为对应的 .ts 或 .tsx 文件

Component 声明

1
2
3
4
5
6
7
8
class CrumbHeaderAction extends Component {
render() {
return <Div onClick={this.props.onClick}>
{this.props.icon}
{this.props.label}
</Div>;
}
}

上面这个代码如果把后缀从 js 改为 ts 就会报错. React 默认声明的 props 和 state 都是 {}, tsc 会理解为下面这样:

1
class ComName extends Component<{}, {}>

由于 props.icon 和 props.label 不在 {} 中, 就会抛出一个 tsc 构建异常

我们需要将上面的 extends Component<{}, {}> 转换成 extends Component<any, any> 或者更详细的 props 与 state 定义.

这里我们为了简化流程选择通过 babel 的 AST 工具批量替换为 any 的形式. 通过 React 的 PropType 生成对应的声明也是一个很好的方法. 不过多余的操作可能带来额外的问题. 个人建议这两件事情分开进行会更好些.

静态变量声明

在上面的例子里 , 在 CrumbHeaderAction 外部定义 propTypes 也会报错

1
2
3
4
5
CrumbHeaderAction.propTypes = {
onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.object
}

ts 是不支持隐式静态变量的. 我们要在 class 内部声明这个静态变量

1
2
3
4
class CrumbHeaderAction extends Component {
static propTypes
...
}

1
2
3
4
5
6
7
8
class CrumbHeaderAction extends Component {
private static propTypes = {
onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.object,
}
...
}

属性声明

1
2
3
4
5
class CrumbHeaderAction {
render () {
return <div>{this.content}</div>
}
}

类似于静态变量, 对于 class 的属性也需要显示的声明:

1
private content: string

隐式声明带来的一些问题

js 直接转出的文件都没有声明, typescript 会根据初始赋值自动判断一些变量的类型, 这可能会带来一部分问题, 比如:

1
2
const o = {}
o.a = 1

上面这段代码, typescript 会认为, o 的 interface 是 {}, 第二行赋值语句是会报错的.

实际上这里的 o 的类型应该是

1
2
3
interface o {
[keys: any]: any
}

当然这只是从 js 的代码上所能得到的结论, typescript 并不推荐这种不确定的声明方式.

stateless 组件

如果 stateless 组件在外部使用了 propTypes, 也是需要显式声明的

1
2
3
4
5
6
const SC: React.StatelessComponent<SCProps> = (props: SCProps) => {
return <div>{props.content}</div>
}
interface SCProps {
content: string
}

除此之外还有一些不太常见的奇奇怪怪的问题…

针对以上的规则, 我写了一个脚本不那么智能的去做了一下转换, 大约可以解决掉 60% 左右的问题.

(PS: 由于是自用的脚本, 并且定位比较尴尬做不到完美的迁移, 我只做了简单的封装并没有提供详细的使用指南与文档, 仅作为参考吧)

(PS2: 事后发现了一些副作用: 这个脚本会造成最终生成的代码格式和过去不太一致, 比如注释前后的空白与换行错乱 Orz)

其他配置

jest

ts的转换依赖 ts-jest 这个模块.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"collectCoverageFrom": [
"src/**/*.{ts,tsx}"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.ts?(x)",
"<rootDir>/src/**/?(*.)(spec|test).ts?(x)"
],
"testEnvironment": "jsdom",
"transform": {
"^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest/preprocessor.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
],
"moduleFileExtensions": [
"js",
"json",
"jsx",
"ts",
"tsx"
],
"roots": [
"<rootDir>/src"
],
"modulePaths": [
"<rootDir>/src"
]
}

lint

eslint 本身提供了 typescript 的 parser 然而使用时遇到了不少问题, 最后我选择了 tslint.

tslint 没有什么特别好说的, 按照文档配置 tslint.json 即可.

之后可能需要更新 .git/hooks 相关的钩子. 这里我们的代码提交使用的是 Phabricatorarc, 因此直接在项目中引入了 arc-tslint 在代码提交 review 前触发一次 lint.

另外我们还引入了 tslint-eslint-rules 让 tslint 的规则更加接近我们过去用的 eslint 规则.

总结

虽然从 js 到 ts 的迁移途中会遇到种种困难, 我觉得 Typescript 带来的收益还是要大于迁移成本的.

另外其实迁移的过程可以分为多段执行:

  1. 引入 typescript, 启用 allowJS
  2. 将 .js 转换为 .ts(x) (就是本文主要提到的内容)
  3. 补充声明. 许多第三方库有对应的 @types/name, 上文也提到了将 React propTypes 转换为声明的工具, 最后, 手工也不失为一种办法…

由于 tsc 允许 js + ts 的混合编译, 对于想迁移至 ts 却担忧历史负担的人也可以试试先引入 typescript 用于新写部分的代码, 暂时跳过比较复杂的 2 和 3.