原文地址:you-might-not-need-typescript
本文为原文翻译,补充了一些个人看法和自己的理解
每个人都喜欢类型,每个人都喜欢自动补全,每个人都喜欢在问题出现之前得到警告。
但没有人喜欢浪费时间去编译东西。希望这能帮助说服你或你的公司,你实际上不需要TypeScript
风格的语法。因此,我嵌入了与VSCode
中使用的相同编辑器,向你展示你可以在原生JavaScript
中获得类型安全,并且同时拥有最好的两个世界,并可以轻松玩弄它
VSCODE 类型安全
VSCode
内建了一些隐藏功能,不是每个人都知道
它们非常强大,以至于它们默认被禁用,因为这几乎看起来像是Microsoft
希望你使用TypeScript
而不是使用ESM
或原生JavaScript
下面是一些应该默认启用的功能:
json
// settings.json
// 全局添加,或者可以添加到 <root>/.vscode/settings.json 中。
{
// 启用JavaScript文件的语义检查。
// 现有的jsconfig.Json或tsconfig.Json文件将覆盖此设置。
"js/ts.implicitProjectConfig.checkJs": true,
// Enable suggestion to complete JSDoc comments.
"javascript.suggest.completeJSDocs": true,
// 以扩展名结尾的首选路径 (good for ESM & cjs).
"javascript.preferences.importModuleSpecifierEnding": "js",
// 用参数签名完成函数
"javascript.suggest.completeFunctionCalls": true,
// 自动导入提示
"javascript.suggest.autoImports": true
}
JavaScript
const username = 'bob'
username = 10
// Cannot assign to 'username' because it is a constant.(2588)
let age = 22
// @ts-ignore next line
age = '22'
你还可以在项目中添加一个jsconfig.json文件。jsconfig.json是tsconfig.json的后裔,而tsconfig.json是TypeScript的配置文件。jsconfig.json实际上就是将 "allowJs"属性设置为true的tsconfig.json
拥有这个文件可以帮助你生成 .d.ts 文件
类型守卫
类型守卫(Type Guard
)是一种用于在运行时检查变量类型的技术。这种机制允许开发者在特定的代码块内,以类型安全的方式访问对象的属性或方法,使得在代码中能够获得更好的智能感知,提供了更准确的代码提示
TypeScript
TypeScript
本身提供了更丰富的类型检查和智能感知
typescript
interface Robot { model: string }
interface Person { name: string }
/**
* @param obj Object to check
* @return Returns true if `obj` is a Person
*/
function isPerson (obj: object): obj is Person {
return 'name' in obj
}
/**
* Say hello!
* @param personOrRobot To who you want to say hello to
*/
function greet(personOrRobot: Robot | Person) {
if (isPerson(personOrRobot)) {
// 智能提示,会提示`.name`,而不是`.model`
// 因为上面已经使用的if语句进行判断
console.log(`Hello ${personOrRobot.name}!`);
// Below will throw type error
console.log(personOrRobot.model);
} else {
// 会提示`.model`,而不是`.name`
console.log(`GREETINGS ${personOrRobot.model}.`);
}
}
JavaScript
在JavaScript
中,我们需要使用JSDoc
注释来实现相似TypeScript
的效果
JavaScript
/** @typedef {{model: string}} Robot */
/** @typedef {{name: string}} Person */
/**
* @param {object} obj Object to check
* @return {obj is Person}
*/
function isPerson (obj) {
return 'name' in obj
}
/**
* Say hello!
* @param {Robot | Person} personOrRobot To who you want to say hello to
*/
function greet(personOrRobot) {
if (isPerson(personOrRobot)) {
// 智能提示,会提示`.name`,而不是`.model`
console.log(`Hello ${personOrRobot.name}!`);
// 这里会抛出错误,因为使用的是model属性
console.log(personOrRobot.model);
} else {
// 会提示`.model`,而不是`.name`
console.log(`GREETINGS ${personOrRobot.model}.`);
}
}
公开/私有字段
这里涉及到对象属性知识点,在JavaScript
中我们可以通过使用不同的属性访问修饰符来实现的属性的访问权限
TypeScript
typescript
class Employee {
// 存在不必要的注解
private name: string = ''
public alive: boolean = true
public constructor (theName: string = 'bob') {
this.name = theName
}
public getName(): string {
// 看name属性,你觉得它是私有属性吗
return this.name
}
}
const employee = new Employee('Bob')
employee.name // 无法直接访问name属性
// 不是真正的私有
JavaScript
JavaScript
class Employee {
// 真正的私有属性
// 所有带有 # 的都是私有的,其余的都是公有的
// 无需多余注解
#name
alive = true
constructor (theName = 'bob') {
this.#name = theName
}
getName() {
// 看name属性,你觉得它是私有属性吗
//(带了#很容易看出来)
return this.#name
}
}
const employee = new Employee('Bob')
employee.name // 同样无法直接访问
你不必添加注释来说明#name的类型是字符串,或者使用JSDoc来标注它是私有的。VS Code可以通过构造函数的参数推断出name是字符串类型,这是通过默认参数值进行的推断
#{属性名} 这个是ECMAScript中的较新特性(ES12),不是所有环境都支持
Generics in JSDoc
TypeScript
typescript
/**
* 接受带有name属性的任意对象并将其移除
*/
function deleteName <T extends { name?: string }>(obj: T): Omit<T, 'name'> {
const copy = JSON.parse(JSON.stringify(obj))
delete copy.name
return copy
}
// IDE 将自动推断T的类型,并且还将推断nameLess的类型为 { age: number, name: string },不包含name
const nameLess = deleteName({
age: 50,
name: 'Gregory'
})
// 弊端:
// 过于紧凑,难以阅读
// 并没有真正提高可读性
JavaScript
JavaScript
/**
* 接受带有name属性的任意对象并将其移除
* @template T
* @param {T & {name?: string}} obj
* @returns {Omit<T, 'name'>}
*/
const deleteName = (obj) => {
/** @type {typeof obj} */
const copy = JSON.parse(JSON.stringify(obj))
delete copy.name
return copy
}
// IDE 将自动推断T的类型,并且还将推断nameLess的类型为 { age: number, name: string },不包含 name
const nameLess = deleteName({
age: 50,
name: 'Gregory'
})
优势: 任何对jsdoc
或TypeScript
一无所知的人都可以通过仅查看JavaScript
部分就理解正在发生的事情
导入外部/导出类型
TypeScript
typescript
// 导入类型
import type { State } from 'node:fs'
import type { Node as AstNode } from 'unist'
JavaScript
JavaScript
// 通过jsdoc声明类型
/** @type {import('node:fs').Stats} */
let fsStats;
// Or, via typedef for reuse at multiple places:
/** @typedef {import('node:fs').Stats} Stats */
解构参数
typescript
// 定义类型,解构类型
interface DestructuredUser {
userName: string,
age: number
}
/** @type {DestructuredUser} */
const { userName, age } = getUser()
// 在函数参数上进行注解,会要求你写相同的属性两次以获得相同的函数签名,因此更加冗长
function logUser ({userName, age}: {userName: string, age: number}){
// ...
}
// 或者这样子写
function saveUser ({ userName, age }: DestructuredUser) {
// ...
}
// 箭头函数上的写法
const printReceipt = (obj?: {
total: number;
vendorName?: string | undefined;
}) => {
// ...
}
printReceipt({
total: 200,
vendorName: ''
})
function getUser() {
return {
userName: '',
age: 20
}
}
JavaScript
/**
* @typedef {object} DestructuredUser
* @property {string} userName
* @property {number} age
*/
/** @type {DestructuredUser} */
const { userName, age } = getUser()
/**
* 在函数参数上进行注释:
*
* @param {{ userName: string, age: number }} obj
*/
function logUser ({ userName, age }) {
// ...
}
/**
* 另外一种写法
* @param {DestructuredUser} param
*/
function saveUser ({ userName, age }) {
// ...
}
/**
* 注释箭头函数:
* @param {object} [obj] - [] 表示可选参数
* @param {number} obj.total - 价格
* @param {string=} obj.vendorName - 所有带有 {*=} 的表示都是可选的
*/
const printReceipt = ({ total, vendorName }) => {
// 这样子都可以拿到正确的类型
}
printReceipt({
total: 200, // 使用智能感知可以解释它
vendorName: ''
})
function getUser() {
return {
userName: '',
age: 20
}
}
函数中的可选参数
typescript
function increase (x, amount = 1): number {
return x + amount;
}
// Chrome devtools无法理解JSDoc/TypeScript
function decrease (x, amount?): number {
return x - (amount ?? 1)
}
/** @param message */
function say (message?: string): void {
if (message) alert(message)
}
JavaScript
function increase (x, amount = 1) {
return x + amount;
}
// 使用默认参数,告诉devtools它是可选的
function decrease (x, amount = undefined) {
return x - (amount ?? 1)
}
/** @param {string} [message] 可选的参数 */
function say (message) {
if (message) alert(message)
}
/** @param {string=} message 可选的参数 */
function say (message) {
if (message) alert(message)
}
关于CJS
-
CommonJS
从v12.17开始支持从commonjs
中的动态import
(),但TypeScript
并不以这种方式看待 -
TypeScript
将import
替换为require
并破坏了仅在ESM
中使用的导入
TypeScript
typescript
// main.cjs
import('esm-only-pkg')
JavaScript
JavaScript
// Hot garbage output that no longer works
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Promise.resolve().then(() => __importStar(require('esm-only-pkg')));
// loading a esm-only module using require cause a crash 👆
关于ESM支持
尽管你可以通过一些目标配置将TypeScript
转译为1:1
的ES
模块,但TypeScript
在处理扩展名方面表现得相当糟糕,并且自2017年以来一直完全忽视了这个问题。
他们表示:"我们不想涉及它,也不想自动修复扩展名问题,我们依赖于require()
来解决索引和没有扩展名的问题。" 简而言之,他们将其标记为"按设计工作"
因此,开发者简单地按照一些指南(例如Pure ESM package
)的建议添加了.js
:
- 在
package.json
中添加{"type": "module"}
- 在导入的末尾添加
.js
扩展名
typescript
// main.ts
// In before:
import { ModalBackground } from './ModalBackground'
// out before:
import { ModalBackground } from './ModalBackground'
// What ppl are doing now to support esm:
import { ModalBackground } from './ModalBackground.js'
// resulting output
import { ModalBackground } from './ModalBackground.js'
- 没有文件
./ModalBackground.js
,只有一个./ModalBackground.ts
文件 - 如果你实际上设置了
allowJs=true
并且想要导入一个.js
文件,怎么处理?忽略扩展名
如果在同一目录下存在ModalBackground.js
和ModalBackground.ts
文件时,如果使用import('ModalBackground.ts')
这样的导入语句,就会引发歧义。Deno
或其他系统不知道应该加载哪个文件,因为它们具有相同的模块名称
因此,最好的做法是不要发布未编译的
TypeScript
包。TypeScript
增加了编译时的开销,因此加载时间会更长。
JavaScript
// main.js
// JavaScript则没有这个问题
import { ModalBackground } from './ModalBackground.js'
/*
Deno能够很好地下载并解析JavaScript文件,包括对JSDoc的准确处理。如果你使用 Deno的VSCode插件而非内置的TypeScript解析器,你仍然可以享受到正常的IDE自动补全功能
*/
import FileSystem from 'https://cdn.jsdelivr.net/npm/native-file-system-adapter/mod.js'
但对于Deno
用户来说,这也是一个大问题。Deno
不会将远程HTTP
导入与浏览器导入有任何区别,它要求明确指定到其他文件的路径,要求你写成./ModalBackground.ts
和../index.ts
,而不是../
或../index.js
,即使你只有一个index.ts
文件
TypeScript中存在的问题
typescript
new (async ()=>{}).constructor('return "works"')().then(console.log)
new ArrayBuffer() // Expected 1 arguments, but got 0.
fetch(new URL('/')) // Argument is not of type `RequestInfo`
new Date() + 1000 // Operator '+' cannot be applied to other types
new Blob([{}]).text() // is not assignable to type 'BlobPart'.
alert('', extraArg) // Expected 0-1 arguments, but got 2.
1 + true // Operator '+' cannot be applied to other types
'abc'.replaceAll('a', 'A') // 'replaceAll' does not exist on string
import('https://example.com/foo.js') // Error: Cannot find module
await Promise.resolve(
"Unaware if it's a module with top-level await support or not"
)
window.addEventListener('message', () => {}, {
once: true,
// Don't support new unknown features
signal: new AbortController().signal
})
class A {}
class B extends A {
x = ''
constructor (x, y, z) {
if (arguments.length < 3) throw new TypeError('Meh')
super() // A 'super' call must be the first statement...
}
}
JavaScript
new (async ()=>{}).constructor('return "works"')().then(console.log)
new ArrayBuffer() // Totally okay to omit length for 0-length
fetch(new URL('/')) // Fallback behavior is to cast to string
new Date() + 1000 // Ok to use + operator, it has Symbol.toPrimitive
new Blob([{}]).text() // Fallback behavior is to cast to string
alert('', extraArg) // It don't hurts to do byte saving tech
1 + true // Because you can
'abc'.replaceAll('a', 'A') // Don't need to specify target
import('https://example.com/foo.js') // Not a problem
await Promise.resolve(
"Unaware if it's a module with top-level await support or not"
)
window.addEventListener('message', () => {}, {
once: true,
// Dose not complain of unknown properties in new spec
signal: new AbortController().signal
})
class A {}
class B extends A {
x = ''
constructor (x, y, z) {
if (arguments.length < 3) throw new TypeError('Meh')
super()
}
}
TypeScript
目前应该更加注重的是Symbol.toPrimitive
和JavaScript
中类型转换的实际工作方式
个人观点
JSDoc
作为JavaScript
提供静态类型检查的工具,为代码提供额外的上下文类型信息,帮助开发者编写更可靠的代码,从而减少在运行时出现的一些错误
如果只是简单的类型推断,我觉得是可以直接使用
JSDoc
的
个人认为在一些较为复杂的类型场景下,使用JSDoc
的代码可能会变得更加复杂(从上面定义类型的例子中可以看出),相比之下,TypeScript
在处理复杂类型时更为简洁,尤其是在多人协作开发的情况下
另外,TypeScript
提供的功能更为强大,它在编译时能够捕获一些错误信息,包括重构、成员修饰符等功能,而这些是JSDoc
所无法提供的
这可能会增加一些编译时间,但为了获得更优秀的开发体验,这是值得的