升级TS,听起来应该是件很简单的事,如果你的项目相对独立那确实稍微花点时间就做完了。但通常一个大型项目会将各个模块分给每个小的业务组,还有负责基础库的基架组,这时候想要单独升级业务模块的TS版本,就要考虑对上游和下游的影响了。

麻烦的问题就来了,TS并不遵守 Semantic Version,它除了bugfix以外的版本更新几乎都会带来Break Changes。这里的Break Changes可能是两个方面的:

  1. 低版本TS代码在高版本TS下编译会报错,也就是说升级业务模块时要考虑底层依赖的影响。
  2. 高版本TS编译出来的d.ts类型定义在低版本TS下编译会报错,这时就得考虑怎么样升级TS后模块的编译生成的文件给上层应用使用时不会报错。

这篇文章会对上面两个问题给出我的解决方案,另外由于这篇文章是我去年年底写的,所以只考虑到TS3.0~3.7遇到的问题。

另外本文只提到了二方包情况,对于社区维护三方包通常会跟随TS版本升级和做兼容的,出现问题先升一下版本。

低版本TS代码在高版本TS下的错误

这个问题很好解决,只要让代码在两个版本的TS下同时编译通过即可。当然这要求你去修复所有报错的依赖库,我建议有权限的话就自己做,因为这里的错误不会太多相比跨组协同的成本不值一提。

下面我总结了一些我遇到的问题以及解决方法。

可能会遇到的问题

error TS2300: Duplicate identifier ‘IteratorResult’.

升级@types/node即可。

范型不再默认为{}类型,改为了unknown

3.5以下的版本中范型变量的类型默认为{},在3.5之后你必须显式地指定范型上界T extends {}或者通过type guard才能使参数为对象类型。see detail

// error
function foo<T>(a: T) {
    return a.toString();
}

// pass
function foo<T extends {}>(a: T) {
    return a.toString();
}

// pass: use type guard
function isObject(data: any): data is object {
    return typeof data === 'object' && data != null;
}

function foo<T>(a: T) {
    if (isObject(a)) {
        return a.toString();
    }
}

Property ‘wheelDelta’ does not exist on type ‘WheelEvent’.

使用deltaY

无法推断的类型从any变为unknown

之前在new Set()如果没有指定范型,则会默认给any,现在改为了unknown。在自定义的范型函数中也很容易出现这个问题,原来infer不出来的类型会给个any,现在都改为了unknown

// set is Set<unknown> now
const set = new Set();
// error
set.add(1);

const set = new Set<number>();
// pass
set.add(1);

TS2744: Type parameter defaults can only reference previously declared type parameters.

在ts3.3之后在范型的默认值里就只能引用该项之前的类型。

// error
class A<T = D, D = string> {}
// work
class A<D = string, T = D> {}

Property ‘children’ does not exist on type ‘xxx’

出现这个错误很可能这个React组件使用里函数的方式来声明(如下)

function MyCom(props: { href: string }) {
    return <a {...props} />;
}

这样子声明的组件是不会给Props自动加上{ children?: ReactNode }的。在旧版本的ts里对children的检查不会很严格所以不会报错,但这个问题在新版本里就会显现。

解决方法:

  1. 使用React.FCReact.SFC来指定类型
  2. 手动加上children类型。

在3.3之后类继承时方法会应用逆变逻辑

比如下面的代码在3.3以上时会报错

interface A {
    a: string;
}

interface B extends A {
    b: number;
}

class CA {
    static a: (a: A) => void;
}

class CB extends CA {
    // 由于函数参数是逆变的这里会导致报错
    static a: (a: B) => void;
}

一般来说只要类型没写错是不会遇到什么问题,但是我们项目中使用了React组件继承的写法,加上contextType时就会暴露问题了。它的类型React.Context是个逆变的类型,但是context又是协变的,这样就很矛盾了。所以不建议继承组件的Context,当然组件继承和Context继承都没啥意义。要写还是得hack一下类型。

// class B extends A {}
// a = new A()
// b = new B()
class A extends React.Component<any> {
    // contextType交给react使用的,不会被我们用到,所以这里指定类型为any
    static contextType: React.Context<any> = React.createContext(a);
    context: A;
}

class B extends A {
    static contextType = React.createContext(b);
    context: B;
}

高版本TS编译出来的d.ts类型定义在低版本TS下的报错

这部分错误不太多,就一个个来解决

一些错误

An accessor cannot be declared in an ambient context.ts(1086)

ts@3.7会在.d.ts里生成 gettersetter,ts@<3.6会不能识别。

release note

Cannot find name ‘readonly’

3.4后会默认将函数参数类型的Readonly转为readonly关键字

release note

TS2315: Type ‘Generator’ is not generic

3.6修改了Generator类型,导致编译generator函数生成的类型在旧版本TS里会报错。

解决方法

为了避免引用方大规模更新ts,需要考虑兼容方案。上面第二个问题可以通过指定类型来解决,第三个问题也一样,不过考虑到3.6以下Generator不能指定元素类型,可以使用IterableIterator作为返回类型。

第一个问题的话目前没有兼容性方案。不过在ts@3.1里支持了根据ts版本来选择文件的功能,这样我们只要通过这个工具,就能打包出一个兼容低版本的.d.ts文件。(PS: downlevel-dts只支持兼容到3.4版本,3.4下对很常用的readonly array&tuple是不支持的,我在自己的fork里加了这个支持,代码见PR)。

跨组升级

虽然写了很多兼容方法,但我在实际执行时还是向着推动整个组的项目升级TS做的,第二个问题其实可以不用考虑,所以我也不能保证像 downlevel-dts 不会有什么其他问题。我其实也非常建议推动整个组升级,虽然跨组沟通成本很高,但是能将收益与风险向着好的方向走。

这里我简要讲一下如何跨组推动升级TS,这里很重要的一点是要换位思考,可以想想当别的组给你们提了一个需求的时候,你希望了解什么?这样我们就能自己归纳出需要做的准备了,因为每个公司流程不太一样,我就拿自己的做法来举例。

我首先思考的是就是经典的三大问题,这个需求是做啥?为什么要做?怎么做?第一个问题对于前端来说不需要解释,关于第二个问题的话,我建议将TS升级后更严格的类型检查作为主要优势来讲,这些可以在升级自己项目时或者尝试升级其他组项目时,把遇到的问题记录下来作为例子(问题数和升级成本是成反比的,如果实在没啥可以举例的,那么直接说升级成本很低就行了)。新语法新特性附上链接即可。怎么做的话我上面已经讲了很多了,你可以根据自己公司的情况来添加修改。

回到推进方来思考,我们面临的第一个问题是升级的范围和顺序。如果不能一次性全升完并且项目之间有依赖的话,由于之前提到的不兼容,就得考虑项目的升级顺序。我们公司的项目分为APP、插件和二方包3层,从左往右升就ok了。这里各个业务方主要开发的是插件,所以我的顺序是:

修复二方包里TS在新版本里的问题 -> 通知所有业务方修复TS问题或者直接升级TS(因为APP里基本没有代码所以一般可以不考虑兼容) -> 升级APP的TS版本 -> 通知业务方升级TS -> 升级二方包的TS。

这里插件间也会有依赖还比较复杂,如果要一级级升会比较耗时。恰逢底层框架的升级,我就先挑了依赖该框架的插件来一起升级,这里我用到了yarn list --json来生成依赖树并找到所有的依赖

上面这些计划一定要写好时间表,并给deadline预留一个月左右的时间,这样能减少因为某些组延期导致后续也要推迟的概率。