普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月26日首页

你写的 TypeScript,其实只是穿了件类型外套的 JavaScript

作者 却尘
2026年2月26日 07:32

很多人学了半年 TS,代码里清一色 any,偶尔来个 string | number,然后在简历上写"熟练使用 TypeScript"。这篇文章,就是为了让你彻底告别这种状态。

先说清楚一件事:TypeScript 的类型系统到底在解决什么问题

JavaScript 是动态类型语言。变量可以今天是字符串,明天是数字,后天变成 undefined。这在小项目里无所谓,但一旦代码规模上来——比如一个有 50 个接口、20 个开发者协作的中大型前端项目——没有类型约束,就像在没有红绿灯的十字路口开车,每一次函数调用都是一次赌博

TypeScript 的本质是在编译阶段拦截这些赌局。它不改变运行时行为,但能在你写代码的那一刻,就告诉你哪里会出问题。

理解了这一点,再来看各种类型,你就不会觉得它们是语法糖,而是协议——你和编译器之间的协议,你和团队成员之间的协议。

一、基础类型:别觉得简单,细节决定成败

string / number / boolean

这三个是 TS 里用得最多的类型,也是最容易被忽视的。

let name: string = "Andy";
let age: number = 18;
let isLogin: boolean = false;

看起来平平无奇,但"类型安全"的价值在这里:

let name: string = "andy";
name.toUpperCase(); // ✅ 合法,string 有这个方法
name.toFixed();     // ❌ 直接报错,string 没有 toFixed

换成纯 JS,这个错误只会在运行时才被发现——可能是用户触发了某个边界条件,可能是在生产环境,可能是在凌晨三点。TS 把运行时错误前移到了编写时,这是它最核心的价值。

null 和 undefined 的处理是门学问

很多项目踩过这样的坑:后端接口返回的某个字段"理论上有值",但有时候会是 null。如果你的类型定义是 string,编译器不会报错,但运行时一旦拿到 null 去调用字符串方法,直接崩。

正确姿势:

interface User {
  nickname: string | null; // 明确告诉所有人:这个字段可能为空
}

这样当你试图直接调用 user.nickname.toUpperCase() 时,编译器会强制你先处理 null 的情况。这不是麻烦,这是把锅甩给编译器而不是留给用户

bigint:什么时候才需要它

JavaScript 的 number 类型基于 IEEE 754 双精度浮点数,能精确表示的最大整数是 2^53 - 1,也就是 9007199254740991。超过这个数,精度会丢失。

let big: bigint = 9007199254740991n; // 注意末尾的 n

金融系统、密码学、需要处理超大 ID 的场景才会用到它。普通业务开发遇到的机会不多,但知道它存在,不会在某天看到 n 结尾的数字一脸懵。

二、字面量类型与联合类型:从"能用"到"好用"的关键一跳

字面量类型

type Direction = "left" | "right" | "up" | "down";

function move(dir: Direction) {
  // ...
}

如果参数类型是 string,你传 "diagonal" 进去编译器不会说话。但用字面量联合类型,"diagonal" 直接飘红。类型越窄,保护越强

这个思路很重要:在你确定某个值只会是有限几种可能的时候,不要偷懒用 string,用字面量联合类型把范围锁死。

联合类型与类型缩小(Type Narrowing)

联合类型(Union)表示"这个值可能是 A,也可能是 B":

function print(val: string | number) {
  if (typeof val === "string") {
    console.log(val.toUpperCase()); // 这里 TS 已经知道 val 是 string
  } else {
    console.log(val.toFixed(2));    // 这里 TS 已经知道 val 是 number
  }
}

这叫类型缩小(Type Narrowing)——通过条件判断,编译器会在不同分支里自动推断出更精确的类型。理解这个机制,是写出干净 TS 代码的前提。

交叉类型(Intersection)

联合是"或",交叉是"且":

type User = { name: string; email: string };
type Admin = User & { role: "admin"; permissions: string[] };

Admin 必须同时满足 User 和后面那个对象的结构。在实际项目里,这是组合模块类型的利器,比继承更灵活,比重新定义更省力。

三、any、unknown、never:三个经常被误用的类型

any:能不用就不用

let x: any;
x.foo.bar.baz(); // 不报错,但运行时爆炸

any 是类型系统的逃生舱。它告诉编译器"别管我,我自己负责"。偶尔处理真的无法预知结构的数据,或者接入没有类型声明的第三方库,可以用。但如果你的代码里 any 满天飞,TypeScript 就成了摆设——你得到了所有 TS 的编译复杂度,却没有得到任何类型安全。

unknown:any 的负责任替代品

unknown 同样表示"不知道是什么类型",但它要求你在使用前必须先做类型检查

let x: unknown;

if (typeof x === "string") {
  x.toUpperCase(); // ✅ 通过检查后才能用
}

x.toUpperCase(); // ❌ 直接报错

处理外部输入、API 响应、用户数据时,unknown 是比 any 更安全的选择。

never:表示"这里不应该被执行到"

never 有两个核心用途:

1. 表示函数不会正常返回

function throwError(msg: string): never {
  throw new Error(msg);
}

2. 穷举检查(Exhaustive Check)——这个才是精髓

type Direction = "up" | "down" | "left";

function move(dir: Direction) {
  if (dir === "up") { /* ... */ }
  else if (dir === "down") { /* ... */ }
  else if (dir === "left") { /* ... */ }
  else {
    const _check: never = dir;
    // 如果未来 Direction 加了 "right" 但这里没处理
    // 编译器会在这行报错,提醒你补全逻辑
  }
}

这是一个防御性编程技巧:让编译器帮你检查所有情况是否都被覆盖。在处理状态机、分支逻辑密集的业务代码里,能避免非常隐蔽的 bug。

四、泛型:类型系统的"函数"

泛型(Generic)是 TypeScript 类型系统里最有表达力的特性。你可以把它理解成类型层面的参数——函数接受值的参数,泛型接受类型的参数。

function identity<T>(value: T): T {
  return value;
}

identity<string>("hello"); // 返回类型是 string
identity<number>(42);      // 返回类型是 number

进一步,泛型可以加约束:

function getLength<T extends { length: number }>(val: T): number {
  return val.length;
}

getLength("hello");    // ✅ string 有 length
getLength([1, 2, 3]);  // ✅ array 有 length
getLength(123);        // ❌ number 没有 length,报错

泛型约束用 extends 关键字,表示"T 必须满足某个结构"。这让你写出的工具函数既灵活又安全。

五、工具类型:不要重复造轮子

TS 内置了一批工具类型(Utility Types) ,专门用于对已有类型进行变形。掌握这些,能让你的类型定义简洁一个量级。

Partial:把所有字段变成可选

interface User {
  id: string;
  name: string;
  email: string;
}

type UpdateUserPayload = Partial<User>;
// 等价于 { id?: string; name?: string; email?: string }

更新接口往往只需要传部分字段,Partial 比重新定义一个新 interface 优雅得多。

Pick 和 Omit:精确裁剪类型

type UserPreview = Pick<User, "id" | "name">;
// 只保留 id 和 name

type PublicUser = Omit<User, "password" | "internalNotes">;
// 去掉敏感字段

这两个是一对互补工具。前端展示层经常需要的"脱敏版接口类型",用 Omit 一行搞定。

Record:快速定义映射结构

const userCache: Record<string, User> = {};
// 等价于 { [key: string]: User }

Record<K, V> 比写索引签名更直观。后台管理系统里的权限映射、字典数据、配置对象,Record 用起来非常顺手。

Exclude 和 Extract:在联合类型里做集合运算

type Status = "active" | "inactive" | "banned";

type ActiveStatus = Extract<Status, "active" | "inactive">;
// 结果:"active" | "inactive"

type NonBanned = Exclude<Status, "banned">;
// 结果:"active" | "inactive"

Extract 是取交集,Exclude 是取差集。在处理复杂状态枚举时会用到。

六、条件类型与映射类型:进阶但值得了解

条件类型

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

语法和三元运算符一样,但它运作在类型层面。很多 TS 内置的工具类型(比如 Exclude)底层就是用条件类型实现的。

映射类型

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

keyof T 拿到 T 的所有键,in 遍历它们,然后给每个键加上 readonly 修饰符。PartialRequiredReadonly 这些内置工具类型,背后全是映射类型。

七、interface vs type:别被这个问题困扰太久

这是 TS 社区里被讨论烂了的问题,结论其实挺简单:

interface 定义对象结构,因为它支持声明合并(Declaration Merging) ——同名 interface 会自动合并,这在扩展第三方库类型时很有用。

type 定义联合类型、交叉类型、别名,因为 interface 做不到 type Status = "active" | "inactive" 这种写法。

// interface:适合对象,支持 extends 和合并
interface User {
  name: string;
}
interface User {
  age: number; // 合并生效,不报错
}

// type:更灵活,适合联合/交叉/别名
type ID = string | number;
type AdminUser = User & { role: "admin" };

实际项目里,两者往往混用。不必教条,根据场景选最合适的

八、总结表

以下 12 个类型/特性,覆盖了日常前端开发 90% 以上的场景

类型 / 特性 核心价值
string / number / boolean 基础约束,防止类型误用
联合类型 处理多态数据,配合类型缩小使用
interface 定义数据结构,团队协作的契约
type 定义联合、交叉、别名,比 interface 更灵活
泛型 <T> 复用逻辑的同时保持类型安全
Record 快速定义映射/字典结构
Partial 更新接口的标配
Pick / Omit 从已有类型裁剪出你需要的形状
never 穷举检查,让编译器替你兜底

结语

TypeScript 的类型系统不是负担,是把 bug 消灭在编辑器里的机会。每一个精确的类型定义,都是在为未来的自己、为团队省下一次排查 bug 的时间。

从今天起,遇到 any,先想想能不能换成 unknown。遇到 string,先想想能不能换成字面量联合类型。把类型写得越具体,编译器能帮你做的就越多。

TypeScript 最好的使用方式,是把它当成一个不会累、不会忘、全年无休的代码审查员

昨天以前首页

一个 ERR_SSL_PROTOCOL_ERROR 让我们排查了三层问题,最后发现根本不是 SSL 的锅

作者 却尘
2026年2月24日 14:17

这篇文章写给所有在本地开发时被浏览器报错 ERR_SSL_PROTOCOL_ERROR 整崩溃过的人。

背景

我使用ngrok给我的前端做了一个内网穿透。但后端一直不接受http请求。后端跑的是 HTTP,前端发的是 HTTPS,两者对不上,浏览器给了一个 ERR_SSL_PROTOCOL_ERROR。修复方案写了三层,每一层都有对应的代码证据。整个排查过程涉及:SSL 协议层、Vite 代理路由层、业务会话上下文层。

第一章:事情是怎么发生的

用户打开前端页面,点击任何一个需要后端数据的功能,浏览器 network 面板直接红:

GET https://localhost:8000/api/... 
ERR_SSL_PROTOCOL_ERROR

同一时间,后端日志里出现:

WARNING: Invalid HTTP request received.

这个警告是 uvicorn 抛出来的。uvicorn 收到了一个它根本看不懂的请求——因为客户端发来的是 TLS 握手包,而 uvicorn 根本没有启用 TLS,它启动命令是:

uvicorn http://0.0.0.0:8000

没有 --ssl-keyfile,没有 --ssl-certfile,就是纯 HTTP。

所以整件事的本质很简单:前端用了 HTTPS 去打一个 HTTP 服务器的端口,服务器不认识 TLS 握手,直接丢弃,浏览器报 SSL 错误。

但"为什么前端会用 HTTPS 去请求 localhost",这才是真正需要拆开说的部分。

第二章:前端是怎么一步步走到 HTTPS 的

场景一:开发者用 HTTPS 打开了 Vite 开发服务器

Vite 支持 HTTPS 模式启动。如果开发者本地配置了 --https 或者浏览器历史记录里有 https://localhost:5173,那么所有从这个页面发出去的 fetch 请求,如果 base URL 是绝对路径 https://localhost:8000,就会直接绕过 Vite proxy,用 HTTPS 去打后端。

而 Vite proxy(vite.config.ts:11)配置的是把 /api 转发到 http://localhost:8000这个 proxy 只在相对路径请求时生效。一旦前端代码里写死了 https://localhost:8000,请求就直接出去了,proxy 根本插不上手。

场景二:通过 ngrok 暴露后在本地调试

ngrok 给你一个 https://xxxx.ngrok.io 的域名,前端页面从这个域名加载。此时 window.location.protocolhttps:window.location.hostnamexxxx.ngrok.io(不是 localhost)。

如果前端的 API base URL 逻辑是"我在 HTTPS 环境,所以我用 https://localhost:8000 来请求后端",那就出问题了。从 ngrok 的 HTTPS 页面发出 https://localhost:8000 的请求,浏览器不会走 Vite proxy(因为你不是在 localhost 上),请求直接打到本机 8000 端口,而那里跑的是 HTTP,凉了。

第三章:修复是怎么做的?

修复分三个层次

层次一:normalizeApiBase

这个函数处理"当前环境到底该用什么 base URL"的问题。

逻辑是:如果检测到当前是 HTTPS 环境或远程 host,但目标是 localhost,就回退为空字符串(相对路径)。

空字符串意味着请求走的是 /api/... 这种相对路径,这样 Vite proxy 就能接管,把它转发到 http://localhost:8000

这一步解决了"HTTPS 页面不小心拼出 https://localhost:8000"的问题。

层次二:installLocalhostFetchPatch

这是一个更激进的兜底。它在 window.fetch 上打了一个 monkey patch:拦截所有目标是 https://localhost 的请求,把它们重写成 http://127.0.0.1:xxxx

为什么要用 127.0.0.1 而不是 localhost?因为某些浏览器对 localhost 有特殊的安全策略处理,用 127.0.0.1 更保险。

这一步是防御性的,即使上面那一层没拦住,这里也能把 HTTPS 的 localhost 请求"降级"到 HTTP。

层次三:Vite Proxy

proxy: {
  '/api': 'http://localhost:8000'
}

所有走相对路径 /api/... 的请求,在 Vite dev server 层面就被代理到后端,完全不经过浏览器的 HTTPS/HTTP 协议判断,是最干净的解法。

同时 vite.config.ts:10allowedHosts 包含了 ngrok 域名,确保通过 ngrok 访问时 Vite 不会拒绝请求。

明白了,你想把这一章从"这套方案的局限"扩展成一篇更有普适价值的 SSL 错误指南——用这次排查作为引子,讲清楚开发者最常碰到的那几类 SSL 问题。我来重写这两个部分:

第四章:SSL 报错那么多,到底哪种是哪种

ERR_SSL_PROTOCOL_ERROR 只是浏览器 SSL 错误家族里的一个成员。把它们放在一起看,你会发现每一种错误背后的根因其实差异很大,但开发者往往一看到 SSL 就开始检查证书,其实南辕北辙。

ERR_SSL_PROTOCOL_ERROR:协议对不上

这就是本文的主角。不是证书的问题,是客户端发了 TLS 握手,服务端根本不认识这个握手。最常见的触发条件:后端跑 HTTP,前端用 HTTPS 去打;或者服务端配置的 TLS 版本太低(比如只支持 TLS 1.0),而客户端要求 TLS 1.2 以上。

排查方向:先用 curl -v https://your-host:port 看连接阶段的输出,确认服务端有没有在做 TLS 握手响应。如果 curl 直接报 SSL handshake failure,问题在服务端;如果 curl 能通但浏览器不行,问题在浏览器侧(HSTS、证书信任等)。

ERR_CERT_AUTHORITY_INVALID:CA 不被信任

证书是真实的,但签发这张证书的 CA 不在浏览器的信任链里。本地开发用 openssl 自签名证书时最常见。解法有两个:一是用 mkcert 这类工具生成本地可信证书(它会把自己的 CA 写入系统信任库);二是在 Chrome 地址栏输入 thisisunsafe 临时跳过(仅限开发调试,绝对不能用于生产)。

ERR_CERT_COMMON_NAME_INVALID:域名对不上

证书是有效的,CA 也可信,但证书里写的域名和你实际访问的域名不一致。比如证书颁发给 api.example.com,你用 www.example.com 去访问,就报这个错。通配符证书(*.example.com)可以解决同一域下多子域的问题,但它不覆盖根域本身,也不覆盖二级以上的子域。

用 ngrok 做内网穿透时有时会碰到这个,因为 ngrok 的域名每次可能不同,而你本地配置的证书是固定域名。

ERR_CERT_DATE_INVALID:证书过期

最好排查也最尴尬的一种——证书到期了。Let's Encrypt 的免费证书有效期是 90 天,如果自动续签的 cron job 挂了,就会在某天突然全站 SSL 报错。运维侧应该有证书过期的提前告警(比如到期前 30 天、7 天各发一次通知)。

检查命令:

echo | openssl s_client -connect your-domain:443 2>/dev/null | openssl x509 -noout -dates

输出里的 notAfter 就是过期时间。

NET::ERR_CERT_REVOKED:证书被吊销

证书被 CA 标记为不可信,原因通常是私钥泄露或者证书错误签发。浏览器会通过 OCSP(Online Certificate Status Protocol)或 CRL(Certificate Revocation List)实时查询证书状态。这种错误在开发阶段几乎不会遇到,生产环境一旦出现,需要立即联系 CA 重新签发。

HSTS 导致的强制 HTTPS(没有专属错误码,但很坑)

HSTS(HTTP Strict Transport Security)是服务端通过响应头 Strict-Transport-Security 告诉浏览器:"以后访问我这个域名,只准用 HTTPS。"浏览器会把这个策略缓存下来,即使后来服务端改回 HTTP,浏览器也拒绝发 HTTP 请求。

本地开发最容易踩这个坑:你之前在某个端口跑过 HTTPS 服务并发了 HSTS 头,后来改回 HTTP,结果浏览器死活不肯发 HTTP 请求,报的错看起来像 SSL 问题,但其实是 HSTS 缓存在作怪。

解法:Chrome 里打开 chrome://net-internals/#hsts,在 "Delete domain security policies" 里输入对应的域名或 localhost,删掉缓存。

最后

回头看这次排查,ERR_SSL_PROTOCOL_ERROR 这个报错本身其实挺有误导性的——它让人第一反应是去检查证书、检查 TLS 配置,但真正的问题是连 TLS 都没启用,谈何配置

SSL 报错的排查有一个基本原则值得记住:先确认 TLS 在哪一层断掉的,再去找断掉的原因。 是服务端根本没有 TLS(本文的情况)、还是握手失败(协议版本不兼容)、还是握手成功但证书校验失败(域名不对、CA 不信任、已过期)——这三个阶段的问题,修法完全不同,不能混为一谈。

最短排查路径:curl -v https://target:port 看握手阶段输出,能比浏览器给你更原始的错误信息,省掉很多猜测。

❌
❌