news 2026/10/1 1:47:51

TypeScript工具类型:Pick/Omit/Partial 减少重复定义

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TypeScript工具类型:Pick/Omit/Partial 减少重复定义

写 TypeScript 时间长了会发现,真正让人头疼的往往不是类型系统的概念本身,而是同一份数据结构在项目里被反复重写。我之前接手过一个后台管理系统,单是用户相关的 interface 就有 UserItem、UserForm、UserDetail、UserCreatePayload、UserUpdatePayload 五个,字段重合度超过百分之八十,但每个都要单独维护。后端加一个 departmentId,前端要挨个文件补,漏掉一个就等着联调的时候红一片。

后来我把这些定义全部换成 Record、Pick、Partial、Omit 这几个 TS 高级类型组合出来的结果,类型文件从 1200 行压到 400 行出头,而且新增字段只需要改一处。TypeScript 内置的这一组工具类型——Record、Pick、Partial、Required、Readonly、Exclude、Extract、Omit、NonNullable——本质上都是类型层面的函数,输入一个类型,输出另一个类型。它们的实现代码加起来不到 50 行,但覆盖了日常开发里八成以上的类型变形需求。

这篇内容写给已经会写 interface、用过泛型但还没系统整理过工具类型的人。刚入门也能看懂,因为我会把每个类型拆开讲实现原理;如果已经用了一两年,建议重点看第 4 章和第 8 章,那里有几个容易踩的坑,比如 Exclude 在对象类型上为什么不起作用、Partial 为什么不递归、Readonly 在运行时到底拦不拦得住修改。

1. 为什么这组类型值得单独花时间学

1.1 重复定义带来的真实维护成本

先说个具体场景。假设你有个 User 接口:

interface User { id: number; name: string; email: string; age: number; avatar: string; }

列表页只需要 id、name、avatar,详情页需要全部字段,新增用户时不需要 id,编辑用户时所有字段都是可选的。如果不用工具类型,你会写三个 interface,每个都手抄一遍字段。三个月后产品说 age 字段改成 birthday 字符串,你得改三个地方。要是团队里有五个人各自负责一块,漏改的概率非常高。

用工具类型之后,只保留一个 User 作为唯一数据源,其余全是它的变形:

type UserListItem = Pick<User, "id" | "name" | "avatar">; type UserCreatePayload = Omit<User, "id">; type UserUpdatePayload = Partial<UserCreatePayload>;

改字段只改 User 一处,其他类型自动跟着变。这就是工具类型最核心的价值——把类型的单一数据源这件事真正落地,而不是靠代码规范口头约束。

1.2 工具类型是类型层面的函数

换个角度理解:接口和类型别名是名词,工具类型是动词。Partial<User>读作"把 User 处理成全部可选",Pick<User, "id">读作"从 User 里挑出 id"。

既然是函数,就有输入和输出。理解每个工具类型的输入约束和输出形状,比死记它的功能有用得多。下面这张表是我自己整理的第一遍学习笔记,建议先扫一眼建立整体印象:

工具类型输入约束输出形状常见场景
Partial<T>任意对象类型所有属性变可选更新参数、表单草稿
Required<T>任意对象类型所有属性变必填合并后的配置校验
Readonly<T>任意对象类型所有属性变只读常量表、状态快照
Record<K, T>K 为 string/number/symbol 子集键为 K、值为 T 的对象字典、映射表
Pick<T, K>K 必须是 keyof T 的子集只含 K 对应属性的对象列表项裁剪
Omit<T, K>K 同上排除 K 后剩余属性的对象去除敏感字段
Exclude<T, U>通常是联合类型从 T 中剔除可赋值给 U 的成员联合类型筛选
Extract<T, U>通常是联合类型从 T 中保留可赋值给 U 的成员联合类型取交集
NonNullable<T>任意类型去掉 null 和 undefined接口返回收敛

注意 Record 的键约束写的是keyof any,在 TS 里展开就是string | number | symbol这个联合。这个细节后面第 2.4 节会展开讲。

1.3 一个判断标准:什么时候该用,什么时候不该用

我的经验是,只要出现"同一份数据的不同视图",就考虑用工具类型;如果两个类型只是字段名碰巧一样、语义完全无关,就别硬凑。

举个例子,订单列表项和用户列表项恰好都有一个 name 字段,你不能写Pick<Order, "name">去生成用户类型。工具类型的前提是语义上的从属关系,不是字段名巧合。这一点想不清楚,后面的组合技会越写越乱。

2. 对象结构改造四件套

Partial、Required、Readonly、Record 这四个我归为一类,因为它们操作的都是整个对象的结构,不涉及属性筛选。

2.1 Partial:把必填字段一键变可选

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

它的内部实现是映射类型加上一个问号修饰符:

type MyPartial<T> = { [K in keyof T]?: T[K]; };

[K in keyof T]是遍历 T 的所有键,?给每个属性加上可选标记。理解这一行,就理解了所有映射类工具类型的套路。

注意:Partial 只作用于第一层。如果 User 里嵌套了一个 address 对象,Partial<User>不会把 address 内部的字段变成可选。这个"浅层"特性坑过我不止一次。

2.2 Required:反过来的操作,以及一个容易忽略的减号

type MyRequired<T> = { [K in keyof T]-?: T[K]; };

关键在于-?。TypeScript 的映射类型支持三种修饰符操作:+添加、-移除、不写则保持原样。?表示可选,-?就是强制移除可选标记。同理还有readonly和-readonly。

为什么需要显式写减号?因为如果你写成[K in keyof T]?: ...,那还是加问号。要取消原类型上已有的可选标记,必须用减号。

实际用途上,Required 经常和 Partial 配合做"默认配置合并"的场景:

interface Config { host?: string; port?: number; timeout?: number; } const defaults: Required<Config> = { host: "127.0.0.1", port: 8080, timeout: 3000, }; function createClient(userConfig: Config) { const finalConfig: Required<Config> = { ...defaults, ...userConfig }; return finalConfig; }

这里Required<Config>保证 defaults 里不漏任何一个字段,编译期就能发现你忘配了 timeout。这是 Required 最实用的地方——用类型系统逼自己写全默认值,而不是靠运行时兜底。

2.3 Readonly:编译期的承诺,不是运行时的锁

type MyReadonly<T> = { readonly [K in keyof T]: T[K]; }; const config: Readonly<Config> = { host: "127.0.0.1" }; config.host = "1.2.3.4"; // 编译报错:Cannot assign to 'host' because it is a read-only property.

但要注意,Object.freeze才是运行时的真冻结,Readonly<T>只是编译期约束。如果你把一个 Readonly 对象传给一个接收 any 的函数,或者用as断言绕过,照样能改。所以它防的是"自己人手滑",不是"外部恶意修改"。

还有一个细节:Readonly<T>的只读标记是浅层的。嵌套对象的属性依然可写。需要深层只读的话,得自己写递归版本:

type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; };

这个递归版本在处理配置树、路由表这类结构时特别顺手。不过要注意它遇到函数和数组时的行为——数组也会被递归成只读对象,实际项目里通常要加个T[K] extends Function ? T[K] : ...的判断把函数排除掉。

2.4 Record:把联合类型变成字典

Record 的签名是Record<K extends keyof any, T>。第一个参数是键的集合,第二个是值的类型。

最常见的用法是配合联合类型做映射表:

type Status = "pending" | "success" | "failed"; const statusText: Record<Status, string> = { pending: "处理中", success: "已完成", failed: "失败", };

好处是穷尽性检查。如果 Status 以后新增一个 "canceled",statusText 这里会立刻报错,提醒你补上文案。相比之下写{ [key: string]: string }就完全没有这个保障。

Record 还有一个高频用途是替代索引签名,避免 any:

// 不推荐 const cache: { [key: string]: any } = {}; // 推荐 const cache: Record<string, unknown> = {};

Record<string, unknown>强制你在读取时先做类型收窄,不会悄悄把 any 传染到下游。

提示:Record 的键类型里如果包含 number,实际运行时对象的键依然是字符串形式,只是 TS 在取值时允许你用数字下标。别指望它在运行时区分 1 和 "1"。

3. 属性的取与舍:Pick 和 Omit

这两个操作对象属性,但方向和 Exclude/Extract 完全不同,很多人会搞混,所以单独拆一章。

3.1 Pick:明确拿到你要的那几个字段

type MyPick<T, K extends keyof T> = { [P in K]: T[P]; }; type UserListItem = Pick<User, "id" | "name" | "avatar">;

关键约束是K extends keyof T。如果你写Pick<User, "phone">,而 User 里没有 phone 字段,编译期直接报错。这就是 Pick 最大的好处——字段名拼错会被立刻发现,比手写 interface 抄错字段名安全得多。

Pick 在列表页、表格列定义、下拉选项这些"只用到部分字段"的场景下非常好用。我一般会先在数据层定义完整实体,然后在展示层用 Pick 裁剪,这样后端加了字段也不会污染前端模型。

3.2 Omit:排除法更适合接口继承

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>; type UserCreatePayload = Omit<User, "id" | "createdAt">;

注意 Omit 的实现是Pick套Exclude——先用 Exclude 从 keyof T 里剔除 K,再 Pick 剩下的。这也解释了为什么 Omit 的 K 约束是keyof any而不是keyof T。它允许你写一个 T 里根本不存在、也不会报错的键名,这个设计是有意的(为了兼容一些类型收窄的场景),但也带来一个副作用:Omit 写错字段名不会报错。

type A = Omit<User, "idd">; // 不报错,结果是完整的 User

这个坑真的很隐蔽。我建议在关键路径上优先用 Pick,只有在排除项明显少于保留项时才用 Omit,而且每次写完对着实体类型核一遍。

3.3 什么时候该选哪个

判断逻辑很简单:数一下你要动的属性数量。

  • 保留少数几个 → Pick
  • 排除少数几个 → Omit
  • 数量差不多 → 优先 Pick,因为拼错会报错

给个实际例子的对比。假设 User 有 12 个字段,列表页只要 4 个:

// 用 Pick,清晰且安全 type ListItem = Pick<User, "id" | "name" | "email" | "avatar">; // 用 Omit 要排除 8 个,写法冗长且容易写错 type ListItem2 = Omit<User, "age" | "address" | "createdAt" | ...>;

一眼就能看出 Pick 更合适。反过来,脱敏接口只需要去掉 password 和 salt 两个字段,那显然用 Omit 更省事。

4. 联合类型的筛与留:Exclude 和 Extract

前面两组操作的是"对象的属性",这一组操作的是"联合类型的成员"。这个区别是理解它们的关键,也是新手最容易混淆的地方。

4.1 Exclude:从联合类型里踢掉成员

type MyExclude<T, U> = T extends U ? never : T; type Status = "pending" | "success" | "failed" | "canceled"; type ActiveStatus = Exclude<Status, "canceled" | "failed">; // 结果:"pending" | "success"

它内部是一个条件类型。T extends U ? never : T的意思是:如果 T 的某个成员能赋值给 U,就替换成 never(相当于删掉),否则保留。

这里有一个关键机制叫分发式条件类型:当 T 是裸的联合类型参数时,条件类型会对联合的每一个成员分别执行一次,最后把结果合并成新的联合。所以Exclude<"a" | "b", "a">实际执行的是("a" extends "a" ? never : "a") | ("b" extends "a" ? never : "b"),结果是never | "b",也就是"b"。

4.2 Extract:只保留匹配的那些

type MyExtract<T, U> = T extends U ? T : never; type Handler = (() => void) | string | number; type FnOnly = Extract<Handler, Function>; // 结果:() => void

Extract 常用于从混合联合类型里抽出一类成员。比如从一个包含多种事件类型的联合里,只提取出带 payload 的那些。

4.3 Exclude 在对象类型上为什么"不起作用"

这是我最想强调的一点。很多人第一次看到 Exclude 会下意识写:

type UserWithoutId = Exclude<User, { id: number }>;

结果发现什么都没有变化,UserWithoutId 还是完整的 User。原因很清楚:Exclude 操作的是联合类型,而 User 是一个对象类型,不是联合。User extends { id: number }这个判断整体为真,所以整个 User 走到 never 分支……等等,结果应该是 never 才对。

实际情况是,TS 里对象类型做条件判断时,如果整体可赋值,结果是 never,那就什么都不剩了。但因为很多人的写法里 U 是个不匹配的形状,结果就是原样返回,看起来像"没生效"。总之,对象属性筛选请用 Omit,别用 Exclude。这个错误我在代码评审里见过不下五次。

正确的写法:

type UserWithoutId = Omit<User, "id">;

一个是操作集合成员,一个是操作对象属性,路子完全不同。

5. NonNullable:null 和 undefined 的专项治理

5.1 它到底过滤掉了什么

type MaybeUser = User | null | undefined; type DefiniteUser = NonNullable<MaybeUser>; // 结果:User

在 TypeScript 内置库里,它的定义经历过一次变化。早期版本是:

type NonNullable<T> = T extends null | undefined ? never : T;

新版(4.8 之后)改成了更简洁也更快的写法:

type NonNullable<T> = T & {};

这两种写法的效果在大多数情况下一致,都是把 null 和 undefined 从联合里剔除出去。第二种利用的是空对象类型{}排除 null/undefined 的特性——在 TS 里null & {}是 never,undefined & {}也是 never。

5.2 它和 strictNullChecks 的关系

如果 tsconfig 里strictNullChecks: false,null 和 undefined 会隐式地成为所有类型的子类型,这时候 NonNullable 基本没什么存在感。所以启用 NonNullable 的前提是先打开严格模式:

{ "compilerOptions": { "strict": true } }

我的建议是,新项目一律开 strict,老项目逐步开。NonNullable 在严格模式下才真正有价值——它能帮你表达"这个值经过某个守卫之后一定不是空"这个语义。

5.3 在接口响应处理里的实用套路

后端返回的数据经常是field: T | null。如果业务上你确定某个字段在成功响应里一定有值,可以用 NonNullable 收口:

interface ApiResponse { data: { user: User | null; token: string | null; }; } type SuccessResponse = { data: { user: NonNullable<ApiResponse["data"]["user"]>; token: NonNullable<ApiResponse["data"]["token"]>; }; };

注意这里我用了索引访问类型ApiResponse["data"]["user"]拿到嵌套字段,再套 NonNullable。这个组合技在给第三方 SDK 做类型包装时特别常见。

不过要提醒一句:NonNullable 只是类型层面的断言,它不会在运行时帮你检查。如果你对 null 的数据强行断言,出错时是运行时崩,不是编译期报。所以它更适合用在"协议保证"的场景,而不是"我觉得应该是"的场景。

6. 组合实战:一次真实的接口层重构

讲完单个类型,说说怎么组合。下面这套模式是我在一个中后台项目里跑了两年多的方案,可以照着抄。

6.1 定义唯一的实体类型

// types/entities.ts interface UserEntity { id: number; username: string; nickname: string; email: string; phone: string; avatar: string; status: "active" | "disabled"; departmentId: number; createdAt: string; updatedAt: string; }

所有后续类型都从这一份推导。

6.2 派生出各类业务类型

// 列表项:只要展示需要的字段 export type UserListItem = Pick< UserEntity, "id" | "nickname" | "avatar" | "status" | "departmentId" >; // 详情:去掉内部字段 export type UserDetail = Omit<UserEntity, "phone">; // 新增表单:去掉自动生成字段 export type UserCreateForm = Omit<UserEntity, "id" | "createdAt" | "updatedAt">; // 编辑表单:在新增基础上全部可选 export type UserEditForm = Partial<UserCreateForm>; // 编辑时必填的字段单独约束 export type UserEditFormRequired = Required<Pick<UserCreateForm, "id" | "username">> & Partial<Omit<UserCreateForm, "id" | "username">>; // 部门到用户的映射 export type DepartmentUserMap = Record<number, UserListItem[]>; // 状态文案表 export type UserStatusText = Record<UserEntity["status"], string>;

这七个类型全部从 UserEntity 推导,加起来不到 30 行。以前手写的时候要 200 多行。而且改一个字段名,这里会自动跟着改,编译报错会精确地告诉你哪里的类型不匹配。

6.3 表单校验里的 Required 收口技巧

UserEditFormRequired那一段值得展开说说,因为它体现了一个很实用的思路:大部分字段可选,少数关键字段必填。

type UserEditFormRequired = Required<Pick<UserCreateForm, "id" | "username">> & Partial<Omit<UserCreateForm, "id" | "username">>;

这里的&是交叉类型。先挑出必须有的两个字段并置为必填,再把剩下的字段全部置为可选,两者相交。这样 TS 会给你精确的提示:漏填 id 报错,漏填 email 不报错。

如果直接写Partial<UserCreateForm>,那就是全部可选,id 也可能缺失,提交接口时又要手动检查一遍。用 Required 收口之后,把校验责任交给类型系统,业务代码里就少了一堆 if 判断。

6.4 API 层的通用信封类型

后端接口一般有统一的响应结构:

interface ApiEnvelope<T> { code: number; message: string; data: T; } // 通用成功响应 type SuccessResponse<T> = Omit<ApiEnvelope<T>, "code"> & { code: 0 }; // 分页响应 type PagedResponse<T> = ApiEnvelope<{ list: T[]; total: number; page: number; pageSize: number; }>; // 具体化 type UserListResponse = PagedResponse<UserListItem>; type UserDetailResponse = ApiEnvelope<UserDetail>;

SuccessResponse<T>那个写法有点技巧:用 Omit 去掉 code,再交叉上{ code: 0 }字面量类型,这样在成功分支里 code 就被收窄成 0 了,做判断时可以穷尽匹配所有错误码。

7. 手写一遍,才算真的懂

我强烈建议每个用过这些工具类型的人,都自己手写一遍实现。不是背下来,是理解keyof、映射类型、条件类型这三个东西怎么配合。

7.1 七个映射类的实现合集

type MyPartial<T> = { [K in keyof T]?: T[K]; }; type MyRequired<T> = { [K in keyof T]-?: T[K]; }; type MyReadonly<T> = { readonly [K in keyof T]: T[K]; }; type MyPick<T, K extends keyof T> = { [P in K]: T[P]; }; type MyRecord<K extends keyof any, T> = { [P in K]: T; }; type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>; type MyNonNullable<T> = T extends null | undefined ? never : T;

一行一行读,你会发现规律:这七个里五个都是映射类型([K in ...]那种),两个是条件类型(带extends ? :那种)。

映射类型的通用模板是:

{ [新键名 in 键的集合] 修饰符 值的类型 }

修饰符可以是?、-?、readonly、-readonly,或者都不写。这个模板能覆盖 90% 的对象类型变形需求。

7.2 条件类型和 infer 的延伸

条件类型的模板是T extends U ? X : Y。它的进阶用法是配合infer做类型提取:

// 提取函数返回值类型 type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; // 提取数组元素类型 type ElementOf<T> = T extends (infer E)[] ? E : never; // 提取 Promise 内部类型 type Awaited<T> = T extends Promise<infer R> ? R : T;

infer R的意思是"在这里声明一个类型变量 R,让 TS 自己推断它是什么"。这是工具类型库(比如 utility-types、type-fest)的内核机制。

理解了这层,你就明白 Exclude 和 Extract 为什么是现在这个写法了——它们本质上是在做"联合成员的过滤",而过滤逻辑就是条件类型加上分发式执行。

7.3 一个我常用的自定义工具类型

顺手分享一个我项目里几乎每个文件都用的:

// 深度 Partial,但保留函数和数组原样 type DeepPartial<T> = { [K in keyof T]?: T[K] extends Function ? T[K] : T[K] extends Array<infer E> ? Array<DeepPartial<E>> : T[K] extends object ? DeepPartial<T[K]> : T[K]; };

这个在写配置文件合并、mock 数据生成的时候特别顺手。注意三层嵌套的判断顺序:先排除函数,再单独处理数组,最后才递归对象。顺序写反了数组会被当成对象递归,结果变成{ 0: ..., 1: ... }这种形状。

8. 常见报错与排查速查表

8.1 高频报错逐条对号入座

报错信息常见原因解决思路
Property 'xxx' does not exist on type 'Pick<...>'Pick 的键名拼错,或实体类型没这个字段检查 keyof T 的实际结果,用 Ctrl+空格看提示
Type 'X' is missing the following propertiesRequired 后字段没写全补齐字段,或用 Partial 包一层
Cannot assign to 'xxx' because it is a read-only property试图修改 Readonly 类型改用可变副本,比如{ ...obj }展开
Type 'string' is not assignable to type '"a" | "b"'Record 的值类型约束了字面量检查值类型定义是否过窄
Argument of type 'X' is not assignable to parameter of type 'never'Exclude 用在了对象类型上,或 K 约束不匹配改用 Omit 处理对象属性
Object is possibly 'null' or 'undefined'严格模式下未做空值检查加守卫,或用 NonNullable 断言
Type instantiation is excessively deep递归类型太深拆分层级,或给递归加终止条件

8.2 我踩过的三个坑

第一个坑:Partial 不递归。我当时给一个多层的配置对象套了 Partial,以为所有层都变可选了,结果深层字段还是必填,编译一直报错。后来才发现它只处理第一层。解决方式是自己写 DeepPartial,或者在调用处直接给完整的嵌套对象。

第二个坑:Omit 拼错字段名不报错。这个前面提过,但值得再强调一次。Omit<User, "passwrod">里 typo 一个字母,TS 不会提醒你,因为 Omit 的第二个参数约束是keyof any,允许任意字符串。当时我的脱敏接口一直往外漏 password 字段,排查了半天才发现是拼错了。

第三个坑:误以为 Readonly 能防住所有修改。有次我把一个 Readonly 对象传进了一个接受any的工具函数,函数内部直接改了属性,运行时完全没报错。Readonly 只在"静态类型检查路径上"有效,一旦走到 any 或者用了断言,它就失效了。

8.3 一套稳定的使用习惯

用久了之后我总结出几条习惯,写在这里供参考:

  • 实体类型只在一个文件里定义,其他所有类型都从它派生
  • 优先 Pick 而不是 Omit,因为 Pick 会校验字段名
  • 需要深层可选或深层只读时,不要指望 Partial 和 Readonly,自己写递归版本
  • 严格模式下开 strict,NonNullable 才有意义
  • 组合类型超过三层嵌套时,拆成中间类型别名,方便调试和 hover 查看

关于第五点多说一句。TS 的 hover 提示在类型嵌套太深时会显示成一大坨,看起来很难受。把中间结果命名成type UserBase = ...这样的别名,悬停时就能看到清晰的层级,排查问题效率高很多。这个习惯我是被一个三层 Pick 套 Omit 套 Partial 的类型折腾了两小时之后养成的。

还有一个日常实践:写完工具类型后,在编辑器里对大结果类型做一次"展开",看 TS 实际推导出来的形状跟你脑子里想的是不是一回事。这一步花十秒钟,能省掉后面半小时的调试。说白了,类型体操的核心不是写得多花哨,而是推导结果可预测。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 1:47:51

跨平台兼容层实战:FEX-Emu、Wine与DXMT运行Windows应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:46:58

Madeira项目:在iOS上通过FEX-Emu、Wine和DXMT运行x86-64 Windows应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:46:55

Windows SXS目录详解:解决.NET 3.5启用失败0x800f081f错误

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:46:44

从HTTP到HTTPS:一文搞懂TLS握手、证书链与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:45:09

马德拉岛旅游攻略:徒步云海路线与自驾交通,七天行程避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:45:07

极限保号性图解:从数列到函数,一张图彻底搞懂

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华