cocos / cocos-engine

Cocos simplifies game creation and distribution with Cocos Creator, a free, open-source, cross-platform game engine. Empowering millions of developers to create high-performance, engaging 2D/3D games and instant web entertainment.
https://www.cocos.com/en/creator
Other
8.4k stars 1.96k forks source link

关于cocos源码的 tsconfig 改造, 可以先一步一步来. 第一步: 关闭 "strictPropertyInitialization" #17317

Open finscn opened 4 months ago

finscn commented 4 months ago

Use Case

关于cocos源码的 tsconfig 和 eslint 的问题, 我在很多渠道发表过观点了. 我也知道 这种改动如果要一步到位 一蹴而就 很难 . 但是可以分部进行. 先找可以平滑过渡的.

建议第一步可以先将 tsconfig 中的 "strictPropertyInitialization" 设置为 false.


当开启 "strict": true 时, ts默认也会把 strictPropertyInitialization 设置为 true, 其实这是非常不合理的. 说明如下:


// 如果 "strictPropertyInitialization" 为 true , 会面临下面的问题:

class KlassFoo {
    // do sth.
}

class KlassA {

    // 必须实例化一个 KlassFoo, 显然是不科学的.  foo 有可能需要延迟创建.
    foo: KlassFoo = new KlassFoo();

}

class KlassB {

    // 需要被迫增加一个 undefined , 破坏了 foo 的原本的意义,
    // 且代码变得冗繁, 到最后项目里会有非常多的 `| undefined`
    foo: KlassFoo | undefined;

}

class KlassC {

    // 被迫加一个 ! , 来欺骗编译器, 这么做是非常危险的
    foo: KlassFoo = null!

}

/*

按照 strictPropertyInitialization 的设计, 其实只有 KlassA 是正道.
后面的两种方案都是为了屏蔽编译器报错 而采取的 "欺骗手段"
但是在实际项目里, KlassA 的做法显然也是不可取的.

成员变量(属性)的类型声明, 本身就是为了声明类型,  而不是创建这个成员变量.
ts里 strictPropertyInitialization=true 的设计, 强迫把声明过程 和 实例化过程混在一起,
是一个 并不好的设计.

*/

实际上, 从cocos的源码也能看出来, strictPropertyInitialization = true 时, 给开发人员带来了巨大的麻烦. 因为实际上 99%类是不需要在 声明成员变量时 直接初始化的, 所以cocos里大量的.

class KlassA {
    foo: KlassFoo = null!
}
image

strictPropertyInitialization 这个绝对是 反模式(至少是反oop模式) 的设计, 事实证明也确实给 cocos的开发人员带来了困扰. 所以 改造的第一步就是:

  1. 在tsconfig里 将 strictPropertyInitialization 设置为 false
  2. 在这之后, 就可以清理掉那些 为了 "欺骗编译器" 而给成员属性增加的 null! , undefined! , | undefined 一类的代码了.

以上两步 不会破坏任何已有的逻辑, 只会让代码变得更干净.


cocos 越来越复杂 , 开发人员和网上愿意提交pr的人也越来越多, 大家的编码水平和编码习惯也有所差异,
越是如此 越需要一套科学合理的 tsconfig 以及 eslint (还有个叫 ESLint Stylistic 的 建议关注下) 来约束代码.

希望官方可以考虑下.

这个事情其实远比你们想象的要重要, 因为目前这种不合理的tsconfig和eslint 已经影响到cocos的开发了 . cocos官方开发人员为了 绕开这些不合理的 tsconfig和eslint , 自己写了很多hack代码 , 不断的用一个个错误的编程方式来弥补错误的tsconfig和eslint 造成的影响. 甚至有人在自己的开发机上暗改 tsconfig、eslint, 偷偷的关闭了 ts的严格模式。 到最后 tsconfig和eslint 只是用来限制社区提交PR的效率和热情 ,对你们内部正面价值甚微。

我们可以站在那些选择"用一个错误弥补另一个错误, 将错就错 错上加错"的开发人员的角度去思考下, 他们为什么这么做. 现在在堆积如山的todo list面前, 奋战在第一线的开发者肯定无暇顾忌太多,快点完成任务才是王道。 所以他为什么暗改tsconfig, 为什么关闭严格模式, 为什么无脑添加 null! ? 是不是因为不如果不这样做 影响他的开发效率和进度?如果是这样,那么是不是可以证明现在的 tsconfig 和 eslint 存在问题?

磨刀不误砍柴工的道理大家都懂, 但是什么时候磨刀 磨多久,每个人的看法不同,每个团队每个项目的标准肯定也不同, 这玩意也没有最佳实践 标准答案.

最后如何去做 , 肯定还是要你们团队自己去考虑. 我只是想善意的提醒一下: 3.8.5之后 该考虑磨磨刀了.

Problem Description

如上所述

Proposed Solution

No response

How it works

No response

Alternatives Considered

Additional Information

No response

whaqzhzd commented 4 months ago

个人认为 strictPropertyInitialization 没任何问题。只需要实现类似rust option类型即可。对于一个可空类型在任意地方unwraps 都是合理的。只需要程序本身可以认为此时是安全的就行。如果出现了空异常,那么说明程序执行了错误的逻辑。对于无法明确是否已经不会空的地方,也可自行通过 if let = Some(..) 判断。 也就是Nullable。或者从其他大型ts项目(babylon/vue)代码来看,大家都会默认为设置此类型为Nullable。也就是 T | undefined。这也更符合强类型语言的编程思维。cocos 的ts是基础设施。严格理当更好。对于这种变量,要么赋值要么可空。

finscn commented 4 months ago

个人认为 strictPropertyInitialization 没任何问题。只需要实现类似rust option类型即可。对于一个可空类型在任意地方unwraps 都是合理的。只需要程序本身可以认为此时是安全的就行。如果出现了空异常,那么说明程序执行了错误的逻辑。对于无法明确是否已经不会空的地方,也可自行通过 if let = Some(..) 判断。 也就是Nullable。或者从其他大型ts项目(babylon/vue)代码来看,大家都会默认为设置此类型为Nullable。也就是 T | undefined。这也更符合强类型语言的编程思维。cocos 的ts是基础设施。严格理当更好。对于这种变量,要么赋值要么可空。

你最后一句话 "要么赋值要么可空" 说到了点子上, 这个是我本来打算说的第二个话题(第二步) : 如果不想关闭 strictPropertyInitialization , 可以考虑关闭 strictNullChecks .

现在cocos 最尴尬的是 (其实不是 cocos的尴尬, 是tsconfig的尴尬) , strictPropertyInitialization = true 了 , 同时 strictNullChecks 也为 true 了.

这就导致你推崇的(其实也是我推崇的) "要么赋值要么可空" 无法实现, 现在的情况是既要赋值, 又不能为空. 于是cocos研发团队那些小机灵鬼们只能用"明明是null, 却要欺骗编译器这不是null" 的手段来解决这个尴尬.

毕竟在oop模式下, 绝大多数的对象是可以为null的, 所以 A 全面禁止为null, 对可以为null做特殊处理 B 全面允许为null, 对不可以的为null的做特殊处理 两者相比, 显然后者是更科学合理的.

其实我也一直希望 ts里能提供 Nullable 来有针对性的对个别变量和属性进行约束, 而不是通过 strictNullChecks 来一次性全局强制.

可惜并没有. (甚至那个 NonNullable<> 的实际作用也完全不像名字看起来那样)

说回 strictPropertyInitialization 的问题. strictPropertyInitialization 自然是有好的一方面, 但是对于我来说, 它这种把"成员变量声明" 和 "成员变量初始化" 两个过程强制绑定的做法我并不喜欢. 我个人是喜欢解耦这两个过程的.

smallmain commented 4 months ago

个人认为 strictPropertyInitialization 没任何问题。只需要实现类似rust option类型即可。对于一个可空类型在任意地方unwraps 都是合理的。只需要程序本身可以认为此时是安全的就行。如果出现了空异常,那么说明程序执行了错误的逻辑。对于无法明确是否已经不会空的地方,也可自行通过 if let = Some(..) 判断。 也就是Nullable。或者从其他大型ts项目(babylon/vue)代码来看,大家都会默认为设置此类型为Nullable。也就是 T | undefined。这也更符合强类型语言的编程思维。cocos 的ts是基础设施。严格理当更好。对于这种变量,要么赋值要么可空。

你最后一句话 "要么赋值要么可空" 说到了点子上, 这个是我本来打算说的第二个话题(第二步) : 如果不想关闭 strictPropertyInitialization , 可以考虑关闭 strictNullChecks .

现在cocos 最尴尬的是 (其实不是 cocos的尴尬, 是tsconfig的尴尬) , strictPropertyInitialization = true 了 , 同时 strictNullChecks 也为 true 了.

这就导致你推崇的(其实也是我推崇的) "要么赋值要么可空" 无法实现, 现在的情况是既要赋值, 又不能为空. 于是cocos研发团队那些小机灵鬼们只能用"明明是null, 却要欺骗编译器这不是null" 的手段来解决这个尴尬.

毕竟在oop模式下, 绝大多数的对象是可以为null的, 所以 A 全面禁止为null, 对可以为null做特殊处理 B 全面允许为null, 对不可以的为null的做特殊处理 两者相比, 显然后者是更科学合理的.

其实我也一直希望 ts里能提供 Nullable 来有针对性的对个别变量和属性进行约束, 而不是通过 strictNullChecks 来一次性全局强制.

可惜并没有. (甚至那个 NonNullable<> 的实际作用也完全不像名字看起来那样)

说回 strictPropertyInitialization 的问题. strictPropertyInitialization 自然是有好的一方面, 但是对于我来说, 它这种把"成员变量声明" 和 "成员变量初始化" 两个过程强制绑定的做法我并不喜欢. 我个人是喜欢解耦这两个过程的.

我觉得 strictPropertyInitialization 其实还好说,只是编码习惯问题,但是必须开启 strictNullChecks,这是使用 TypeScript 的理由。

在规划这些的时候尽量不应该考虑团队成员的错误使用,而是通过员工手册等东西解决,如果不正确使用,那么任何安全措施都会被 ! 解除,无论 Rust 还是 TypeScript。

我觉得 cocos 团队对于 TS 不太了解的话,还是直接参考顶级的,热门的开源项目是怎么制定的吧。

比如 VSCode:https://github.com/microsoft/vscode/blob/main/src/tsconfig.base.json

暂时没有见到什么热门项目开启严格模式竟然会取消 strictNullChecks。

finscn commented 4 months ago

个人认为 strictPropertyInitialization 没任何问题。只需要实现类似rust option类型即可。对于一个可空类型在任意地方unwraps 都是合理的。只需要程序本身可以认为此时是安全的就行。如果出现了空异常,那么说明程序执行了错误的逻辑。对于无法明确是否已经不会空的地方,也可自行通过 if let = Some(..) 判断。 也就是Nullable。或者从其他大型ts项目(babylon/vue)代码来看,大家都会默认为设置此类型为Nullable。也就是 T | undefined。这也更符合强类型语言的编程思维。cocos 的ts是基础设施。严格理当更好。对于这种变量,要么赋值要么可空。

你最后一句话 "要么赋值要么可空" 说到了点子上, 这个是我本来打算说的第二个话题(第二步) : 如果不想关闭 strictPropertyInitialization , 可以考虑关闭 strictNullChecks . 现在cocos 最尴尬的是 (其实不是 cocos的尴尬, 是tsconfig的尴尬) , strictPropertyInitialization = true 了 , 同时 strictNullChecks 也为 true 了. 这就导致你推崇的(其实也是我推崇的) "要么赋值要么可空" 无法实现, 现在的情况是既要赋值, 又不能为空. 于是cocos研发团队那些小机灵鬼们只能用"明明是null, 却要欺骗编译器这不是null" 的手段来解决这个尴尬. 毕竟在oop模式下, 绝大多数的对象是可以为null的, 所以 A 全面禁止为null, 对可以为null做特殊处理 B 全面允许为null, 对不可以的为null的做特殊处理 两者相比, 显然后者是更科学合理的. 其实我也一直希望 ts里能提供 Nullable 来有针对性的对个别变量和属性进行约束, 而不是通过 strictNullChecks 来一次性全局强制. 可惜并没有. (甚至那个 NonNullable<> 的实际作用也完全不像名字看起来那样) 说回 strictPropertyInitialization 的问题. strictPropertyInitialization 自然是有好的一方面, 但是对于我来说, 它这种把"成员变量声明" 和 "成员变量初始化" 两个过程强制绑定的做法我并不喜欢. 我个人是喜欢解耦这两个过程的.

我觉得 strictPropertyInitialization 其实还好说,只是编码习惯问题,但是必须开启 strictNullChecks,这是使用 TypeScript 的理由。

在规划这些的时候尽量不应该考虑团队成员的错误使用,而是通过员工手册等东西解决,如果不正确使用,那么任何安全措施都会被 ! 解除,无论 Rust 还是 TypeScript。

我觉得 cocos 团队对于 TS 不太了解的话,还是直接参考顶级的,热门的开源项目是怎么制定的吧。

比如 VSCode:https://github.com/microsoft/vscode/blob/main/src/tsconfig.base.json

暂时没有见到什么热门项目开启严格模式竟然会取消 strictNullChecks。

参考大项目没问题, 但是最不应该参考的就是 vs本身. 因为他几乎不考虑 运行时扩展 自定义 等等. 和游戏引擎的需求完全不同. (大多数不会在使用vs时, 在运行时去继承重写覆盖vs本身的代码, 有特殊需求通常是在开发一个插件)

我个人比较推崇 pixi 的配置: https://github.com/pixijs/pixijs/blob/dev/tsconfig.json 它开了 "strictPropertyInitialization" , 但是关了 "strictNullChecks"

但是游戏引擎是需要一些动态定制的.

另外, strictPropertyInitialization 和 strictNullChecks 单独看其实 都没什么大问题. 大问题就是 两者 同时为true, 绝对是反模式的. "声明成员变量的同时, 必须初始化, 且不能为空, 如果要为空 就要| null,或者 = null! 这种开发思路我实在无法接受. 因为大多数成员是允许为空的. 项目的默认配置 应该是尽量适合大多数情况, 针对少数特例去编写额外代码. 而不是反过来.

另外 strictNullChecks 本身的设计还有一个缺陷: "词不达意". 或者说 功能过耦合. strictNullChecks 用在做 null check 可以, 但是 他不应该兼具 "任何对象都不能为null" 这样一个副作用.

如果真需要这样一个功能应该再通过另一个属性来实现, 比如新增一个 strictNonNullable .

我更倾向于 关闭 strictNullChecks , 然后 使用 @typescript-eslint/strict-boolean-expressions 来辅助 null check.

finscn commented 4 months ago

再补充下 :

找 tsconfig 参考时, 有的人会习惯性的找 最大的ts项目 vue.js 做参考. vue.js 没有改变 strictPropertyInitialization 和 strictNullChecks 在严格模式下的默认值 (都为 true) 但是 vue.js 本身基于 函数式编程的思想, 几乎没有 类和成员变量的概念, 自然没有受到 strictPropertyInitialization = true 的影响. (对于vue来说, 相当于 strictPropertyInitialization = false, strictNullChecks = true )

所以找参考时, 参考项目的编程思想是否和cocos一致 也很重要. cocos本质还是oop思想. 它ecs也是基于oop开发的.

而我推崇的 pixijs 和cocos有很多类似的地方.

我从 pixijs 1.0 开始就关注pixi, 一路看着它的演变和进化, 可以说cocos遇到的很多问题 它也遇到过, 很多坑它其实已经替cocos踩过了. 它确实有很多地方值得cocos借鉴.

smallmain commented 4 months ago

再补充下 :

找 tsconfig 参考时, 有的人会习惯性的找 最大的ts项目 vue.js 做参考. vue.js 没有改变 strictPropertyInitialization 和 strictNullChecks 在严格模式下的默认值 (都为 true) 但是 vue.js 本身基于 函数式编程的思想, 几乎没有 类和成员变量的概念, 自然没有受到 strictPropertyInitialization = true 的影响. (对于vue来说, 相当于 strictPropertyInitialization = false, strictNullChecks = true )

所以找参考时, 参考项目的编程思想是否和cocos一致 也很重要. cocos本质还是oop思想. 它ecs也是基于oop开发的.

而我推崇的 pixijs 和cocos有很多类似的地方.

  • 都是游戏相关引擎
  • 都是基于oop编程思想
  • 早年都是 js 版本, 最近几年开始转的ts

我从 pixijs 1.0 开始就关注pixi, 一路看着它的演变和进化, 可以说cocos遇到的很多问题 它也遇到过, 很多坑它其实已经替cocos踩过了. 它确实有很多地方值得cocos借鉴.

我没 get 到 strictNullChecks 的开关为什么会受到 vs、游戏领域或者 Web 领域的差别所影响,也没 get 到会受到编程范式的影响,这个开关可以说是 TypeScript 的核心特性,也是最没有争议必须开的特性,不像 strictPropertyInitialization、noImplicitAny 这些有争议的特性。

其实这个我也不太明白,能否举个例子说明一下

"任何对象都不能为null" 这样一个副作用

是什么样的场景?

然后,

我查找带 game-engine tag 的前几名的 TypeScript Github 项目,只有两种情况:

如果说 Pixijs 是故意将 strictNullChecks 去掉的话只能说令人震惊,但是我仔细查了一下 Issue,可以看到 Pixijs 也是因为迁移成本的问题没有开启 strictNullChecks,而不是故意去掉:https://github.com/pixijs/pixijs/issues/8852

finscn commented 4 months ago

@smallmain

我没 get 到 strictNullChecks 的开关为什么会受到 vs、游戏领域或者 Web 领域的差别所影响 我说vue 是函数式编程那段, 重点要说的是 strictPropertyInitialization , vue函数式编程, strictPropertyInitialization变得不那么重要, 相当于 strictPropertyInitialization = false , strictNullChecks = true.

我想强调的是 tsconfig整体如何配置 和 编程范式有关, 而不是单指 strictNullChecks .

对于cocos而言, 我的观点是 strictPropertyInitialization 和 strictNullChecks 两者只能有一个为 true. (vue 和 pixi一样. vue 相当于 strictPropertyInitialization = false , strictNullChecks = true. pixi中 开启了 strictPropertyInitialization = true , 但是 strictNullChecks = false. )


我说 strictNullChecks = true 不合适, 是站在 @whaqzhzd 说的 "个人认为 strictPropertyInitialization 没任何问题" 的角度去看的.

如果站在你的角度 "strictNullChecks 最没有争议必须开的特性,不像 strictPropertyInitialization、noImplicitAny 这些有争议的特性" , 那么我的观点就是 strictPropertyInitialization = true 不合适.

如果我只站在自己的角度的话, 我还是会选择: strictPropertyInitialization = false strictNullChecks = false

因为: 1) 我坚持认为 成员变量的声明 和 初始化 是两个独立的 解耦的过程. 2) OOP也有很多流派, 我是 "万事万物皆对象, null也是对象, 对象可以为null" 这个流派的 . 因为错误的使用null, 带来的bug, 应该用其他手段来解决, 比如 eslint, test-case 等, 而不是阉割语言特性和改变编程范式. (let foo = null 是合法语句, 这个是语言特性; "null也是对象, 对象可以为null" 是编程范式)

如果不允许都为 false, 两者至少有一个为true的话, 我会选择 strictPropertyInitialization = false , strictNullChecks = true.