news 2026/9/19 4:39:56

Immer API 全景指南:produce 与全部导出 API 的用法、原理与源码剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Immer API 全景指南:produce 与全部导出 API 的用法、原理与源码剖析

Immer API 全景指南:produce 与全部导出 API 的用法、原理与源码剖析

【免费下载链接】immerCreate the next immutable state by mutating the current one项目地址: https://gitcode.com/gh_mirrors/im/immer

Immer 的核心理念是"通过修改当前状态来创建下一个不可变状态"。本文以官方 API overview 文档为主线,逐一解析 Immer 暴露的全部 21 个 API:从最常用的produce,到类型工具Draft<T>/Immutable<T>、补丁系统applyPatches/produceWithPatches、手动 draft 生命周期createDraft/finishDraft,以及三个按需加载插件enablePatches()/enableMapSet()/enableArrayMethods()。读完本文,你将掌握每个 API 的签名、使用场景、底层实现原理,以及如何用Immer构造函数创建独立配置的实例。文中的源码分析均指向本仓库实际文件,可对照阅读。

API 总览

Immer 从包入口 src/immer.ts 导出一系列 API,官方文档用一张总览表统摄全局,整理如下:

导出名称作用关联章节
produceImmer 的核心 API:import {produce} from "immer"Produce
applyPatches给定 base state 或 draft 和一组 patches,应用这些补丁Patches
castDraft把不可变类型转换为对应的可变(draft)类型。仅是一次类型转换,运行时不产生任何效果TypeScript
castImmutable把可变类型转换为对应的不可变类型。同样仅是一次类型转换TypeScript
createDraft给定 base state,创建一个可变的 draft,对其的任何修改都会被记录Async
current给定一个 draft 对象(不必是树根),对 draft 的当前状态拍快照Current
Draft<T>导出的 TypeScript 类型,把不可变类型转换为可变类型TypeScript
enableArrayMethods()启用针对数组的优化方法处理,提升数组密集型操作性能Array Methods
enableMapSet()启用对MapSet集合的支持Installation
enablePatches()启用对 JSON patches 的支持Installation
finishDraft给定由createDraft创建的 draft,封存 draft 并产出捕获了所有变更的不可变状态Async
freeze(obj, deep?)冻结可 draft 的对象,返回原对象。默认浅冻结,第二个参数为true时递归冻结
Immer构造函数,可创建第二个"immer 实例",暴露本表全部 API,且不与全局实例共享配置
immerable可添加到构造函数或原型上的 Symbol,告诉 Immer 该类可被安全地 draftClasses
Immutable<T>导出的 TypeScript 类型,把可变类型转换为不可变类型
isDraft判断给定对象是否为 draft 对象
isDraftable判断 Immer 能否把给定对象转换为 draft。对以下对象返回true:数组、无原型的对象、以Object为原型的对象、在构造函数或原型上带有immerableSymbol 的对象
nothing可从 recipe 中返回的哨兵值,表示应产出undefinedReturn
original给定一个 draft 对象(不必是树根),返回原状态树中同一路径下的原始对象(若存在)Original
Patch导出的 TypeScript 类型,描述(反向)补丁对象的形状Patches
produceWithPatchesproduce行为一致,但额外返回补丁:结果为[result, patches, inversePatches]三元组Patches
setAutoFreeze启用/禁用对产出树的自动冻结,默认启用Freezing
setUseStrictShallowCopy启用严格浅拷贝。启用后,Immer 会尽可能拷贝不可枚举属性Classes
setUseStrictIteration控制迭代行为:传false为宽松迭代(仅可枚举属性,性能更好),传true为严格迭代(包含 symbol 与不可枚举属性)。默认关闭(宽松迭代)

说明:官方文档表格中enablePatches()的链接写作./installation(缺.mdx后缀),本文统一为实际存在的 installation.mdx 路径。

导入 Immer

绝大多数场景下,你只需从 Immer 导入produce

import {produce} from "immer"

需要注意:在旧版本中produce同时以默认导出形式存在(例如import produce from "immer"也是合法的),但为了提升生态兼容性,现在已不再支持默认导出。这正是 src/immer.ts 中export const produce: IProduce = immer.produce这一命名导出的原因——所有 API 均以具名导出的方式暴露。

从 src/immer.ts 可以看到完整的导出清单:运行时 API(produceproduceWithPatchessetAutoFreezesetUseStrictShallowCopysetUseStrictIterationapplyPatchescreateDraftfinishDraftoriginalcurrentisDraftisDraftablefreeze)、哨兵常量(nothingimmerable)、构造函数Immer,以及三个插件启用函数(enablePatchesenableMapSetenableArrayMethods)与一组 TypeScript 类型(DraftWritableDraftImmutablePatchPatchListenerProducerObjectishStrictMode)。

produce:Immer 的核心

produce接受一个值与一个 recipe 函数(其返回值通常依赖 base state)。recipe 函数可以随意修改它的第一个参数(draft),所有修改只会作用于 base state 的拷贝之上。只有普通对象和数组会被转换为可变的 draft,其他对象一律视为不可拷贝。

基本用法

import {produce} from "immer" const baseState = [ {title: "Learn TypeScript", done: true}, {title: "Try Immer", done: false} ] const nextState = produce(baseState, draft => { draft[1].done = true draft.push({title: "Tweet about it"}) })

柯里化用法

只传入一个函数,即可创建"柯里化 producer",省去每次传递 recipe 的麻烦:

const toggleTodo = produce((draft, id) => { const todo = draft.find(t => t.id === id) todo.done = !todo.done }) const nextState = toggleTodo(baseState, "todo-1")

底层实现

从源码看,Immer 类的 produce 方法 的执行流程如下:

  1. 柯里化分支:若第一个参数是函数且第二个不是函数,则把 recipe 与默认 base 包装成一个curriedProduce闭包返回;
  2. 参数校验:recipe 必须是函数,否则die(6)报错;patchListener 若存在也必须是函数;
  3. 可 draft 分支:当isDraftable(base)为真时,进入enterScopecreateProxy创建代理 → 在try/finally中执行 recipe(出错则revokeScope,成功则leaveScope),随后用usePatchesInScope注册 patchListener,最后processResult产出最终状态。finally而非catch + rethrow的设计是为了保留原始调用栈;
  4. 不可 draft 分支:对原始值直接调用 recipe,undefined返回值会被替换为 base,NOTHING会被转为undefined,并按需freeze结果;
  5. 其他情况(如 base 是函数但 recipe 也是函数)直接报错die(1, base)

从这一实现可以推断:produce绑定在其Immer实例上的函数(源码注释也明确标注 "This function isboundto itsImmerinstance"),因此 src/immer.ts 中produce直接引用immer.produce即可共享全局实例的配置。

手动 draft 生命周期:createDraft 与 finishDraft

createDraftfinishDraft把"创建 draft"和"产出结果"拆成两个独立步骤,适合需要在多个异步步骤中持续修改同一份状态的场景(详见 Async)。

import {createDraft, finishDraft} from "immer" const draft = createDraft(baseState) // 在任意多个异步步骤中修改 draft await step1(draft) await step2(draft) const finalState = finishDraft(draft)

源码要点

createDraft 的实现 与produce共享同一套代理创建逻辑,差异在于:

  • 要求 base 必须可 draft(否则die(8));
  • 若传入的 base 本身已是 draft,会先current(base)取快照再创建新 draft;
  • 创建代理后将state.isManual_ = true标记为手动模式,并在不执行 recipe的情况下直接返回 proxy。

finishDraft 的实现 则要求 draft 存在且isManual_为真(否则die(9)),随后与produce走同样的processResult流程。其第二个参数是可选的 patchListener,传入后可根据修改生成 patches。注意finishDraft之后不得再修改该 draft。

补丁系统:applyPatches 与 produceWithPatches

produceWithPatches:产出补丁

produceWithPatchesproduce行为一致,但返回[result, patches, inversePatches]三元组:

import {produceWithPatches} from "immer" const [nextState, patches, inversePatches] = produceWithPatches( baseState, draft => { draft.x = 2 } )

从 源码实现 可以看到,它内部就是调用this.produce(base, recipe, listener),并把 listener 收集到的patchesinversePatches连同结果一起打包成元组返回;它也支持柯里化调用。

applyPatches:应用补丁

applyPatches给定 base state 或 draft 与一组补丁,将补丁逐一应用(实现见 src/core/immerClass.ts#L205-L231):

import {applyPatches} from "immer" const nextState = applyPatches(baseState, [ {op: "replace", path: ["x"], value: 3} ])

源码揭示了几个关键行为:

  • 若存在op: "replace"path.length === 0的补丁(即替换整棵状态树),会直接以补丁中的 value 作为新 base,再从该补丁之后继续应用;
  • 若 base 是 draft,直接在 draft 上应用;否则内部走this.produce(base, ...),即 applyPatches 本身也是一个 producer,写时复制同样生效
  • 应用过程位于 patches.ts 的 applyPatches_:对每个补丁沿 path 向下解析,遇到__proto__constructor等危险属性会直接报错(防止原型污染),Set 不允许replace操作,数组ADD支持"-"表示 push。补丁值在应用前会做深拷贝,避免修改原补丁对象(见 deepClonePatchValue)。

Patch 类型

types-external.ts 中 Patch 接口 定义了补丁对象的形状:

export interface Patch { op: "replace" | "remove" | "add" path: (string | number)[] value?: any }

补丁生成逻辑在 patches.ts:对象与 Map 走generatePatchesFromAssigned(依据assigned_记录判定 REPLACE/ADD/REMOVE),数组走generateArrayPatches(比较 base 与 copy 的长度与索引),Set 走generateSetPatches(只支持 ADD/REMOVE)。

运行时工具函数:current、original、isDraft、isDraftable、freeze

current:对 draft 拍快照

current(draft)对 draft 的当前状态拍快照并"终结化"(但不冻结),非常适合调试时打印当前状态,或把结果安全地泄露到 producer 之外(详见 Current)。它不要求 draft 是树根,任意子 draft 均可。

实现见 src/core/current.ts:

  • 非 draft 参数直接报错die(10, value)
  • 未修改的节点直接返回state.base_
  • 已修改的节点做浅拷贝后递归处理子节点;若未修改则直接返回原始对象,保证结构共享。

original:取原始对象

original(draft)返回原状态树中与 draft 同一路径下的原始对象(若存在),对非 draft 调用会报错die(15, value)。实现非常直接——src/utils/common.ts#L69-L73 中返回value[DRAFT_STATE].base_。注意:读到的原始对象是未修改的,因此它不可变、也不受 draft 后续修改影响。

isDraft 与 isDraftable

  • isDraft(value):判断是否为 Immer draft。实现 即检查对象上是否存在DRAFT_STATE标记(Symbol.for("immer-state"),定义于 src/utils/env.ts);
  • isDraftable(value):判断能否被 draft。实现 检查四类情况:普通对象(isPlainObject)、数组、带有DRAFTABLESymbol(Symbol.for("immer-draftable"))的对象、以及 Map/Set。

其中isPlainObject的判定(src/utils/common.ts#L48-L65)相当精细:原型为nullObject.prototype的对象直接通过;否则比较构造函数的toString结果是否等于Object的,并带 WeakMap 缓存。这就是为什么"无原型的对象"和"以 Object 为原型的对象"都会被判定为可 draft。

freeze:冻结 draftable 对象

freeze(obj, deep?)冻结可 draft 对象并返回原对象。默认浅冻结;第二个参数为true时递归冻结。实现见 src/utils/common.ts#L253-L276:对 Map/Set 还会用dontMutateMethodOverride覆盖set/add/clear/delete方法,使冻结集合在尝试修改时报出"不可修改冻结集合"的错误。递归冻结时只遍历可枚举的字符串键属性(对应 issue #590 的约束,避免进入不可枚举/Symbol 属性)。

哨兵值:nothing 与 immerable

nothing:产出自定义 undefined

nothing是从 recipe 返回的哨兵值,表示"应产出undefined"(详见 Return)。其定义为Symbol.for("immer-nothing")(src/utils/env.ts#L6),在 src/immer.ts 中以NOTHING as nothing形式导出。

使用场景:当你想要把状态改为undefined而非"保持原值"时。由于 recipe 返回undefined会被视为"未修改"(返回 base),只有显式返回nothing才能表达删除意图:

import {produce, nothing} from "immer" const state = {name: "immer", version: "10"} const next = produce(state, draft => { return nothing // 产出 {name: "immer", version: "10", ...} 的 undefined 版本 })

在 produce 实现 中,result === NOTHING会被转换为undefined

immerable:让类可被 draft

immerableSymbol.for("immer-draftable")(src/utils/env.ts#L16),添加到构造函数或原型上即可标记该类可被安全 draft(详见 Classes):

import {immerable, produce} from "immer" class Foo { [immerable] = true // 方式一:实例属性 constructor() { this.x = 1 } } // 或方式二:在类体中使用 static class Bar { static [immerable] = true }

从 isDraftable 实现 可见,检查顺序是先看值本身,再看其构造函数的DRAFTABLE属性。对于"部分类(如 Date、Promise)"而言,没有这个标记就不会被 draft,从而保持其原生行为。

配置 API:setAutoFreeze、setUseStrictShallowCopy、setUseStrictIteration

这三个 API 均绑定在全局 immer 实例上(src/immer.ts#L62-L79),对应 Immer 类的内部字段:

配置项默认值作用
setAutoFreeze(value)true自动冻结所有由 Immer 创建的拷贝(详见 Freezing)。源码注释强调:"始终默认冻结,即使在生产模式也一样"
setUseStrictShallowCopy(value)false启用严格浅拷贝。默认情况下 Immer拷贝 getter/setter 等属性描述符与不可枚举属性;启用后尽可能拷贝(src/utils/common.ts#L200-L244 的shallowCopy中对应strict === true分支会用Object.getOwnPropertyDescriptors+Object.create重建对象)
setUseStrictIteration(value)false控制迭代行为:false为宽松迭代(仅可枚举字符串属性,性能更好),true为严格迭代(包含 symbol 与不可枚举属性)。注意setUseStrictShallowCopy支持boolean \| "class_only"三种取值(StrictMode类型,见 src/core/immerClass.ts#L45),而setUseStrictIteration仅接受布尔值
import {setAutoFreeze} from "immer" setAutoFreeze(false) // 关闭自动冻结,提升性能;需要自己保证状态不被意外修改

严格迭代/严格拷贝模式对应 src/utils/common.ts 的 each 函数:严格模式下用Reflect.ownKeys(obj)遍历全部自有键,宽松模式用Object.keys(obj)仅遍历可枚举字符串键。

按需加载的插件:enablePatches、enableMapSet、enableArrayMethods

Immer 的核心包默认只支持普通对象与数组。Map/Set、JSON 补丁、数组方法优化这三项能力以插件形式按需启用,调用对应的enableXxx()后立即生效。插件注册机制在 src/utils/plugins.ts:loadPlugin只加载一次,getPlugin在插件未加载时抛出错误(die(0, pluginKey)),插件键分别为"MapSet""Patches""ArrayMethods"

enablePatches()

启用 JSON patches 支持,即produceWithPatchesapplyPatchesproduce的 patchListener 参数的前提条件。实现位于 src/plugins/patches.ts,注册了四个能力:applyPatches_(应用补丁)、generatePatches_(按对象/数组/Set 分派生成补丁)、generateReplacementPatches_(整树替换补丁)、getPath(从 draft 状态回溯补丁路径)。

import {enablePatches, produceWithPatches} from "immer" enablePatches() const [nextState, patches, inversePatches] = produceWithPatches( base, draft => { draft.x = 2 } )

enableMapSet()

启用MapSet支持(详见 Map-Set)。实现位于 src/plugins/mapset.ts:内部定义DraftMap extends MapDraftSet extends Set两个子类,通过覆写get/set/delete/add/clear等方法来记录修改(assigned_映射)并延迟创建拷贝(prepareMapCopy+markChanged)。值得注意的实现细节(src/plugins/mapset.ts#L23-L32):通过globalThis.Iterator.from做特性检测(ES2025 新增),目的是在不污染全局声明的前提下获得迭代器能力。

import {enableMapSet, produce} from "immer" enableMapSet() const next = produce(new Map([["a", 1]]), draft => { draft.set("b", 2) })

enableArrayMethods()

启用数组方法的优化处理,对数组密集型操作有显著性能提升(详见 Array Methods)。实现位于 src/plugins/arrayMethods.ts,其核心思想是避免在迭代过程中为每个元素创建 Proxy

  • 变更类方法pushpopshiftunshiftsplicereversesort):直接操作拷贝,不创建逐元素代理;
  • 子集操作filterslicefindfindLast):返回 draft 代理,后续修改仍会被追踪;
  • 变换操作concatflat):返回 base 值,修改不会被追踪;
  • 返回原始值的方法findIndexindexOfincludessomeeveryjoin等):无需 draft。

重要注意事项:被覆写方法的回调接收的是base 值而非 draft——这正是性能优化的核心,但也意味着在回调中修改传入的元素不会影响最终状态(arrayMethods.ts#L79-L96 的注释与示例明确说明了这一点)。

import {enableArrayMethods, produce} from "immer" enableArrayMethods() const next = produce(state, draft => { // 优化路径:不创建逐元素代理 draft.items.sort((a, b) => a.value - b.value) // filter 返回 drafts,修改可传播 const filtered = draft.items.filter(x => x.value > 5) filtered[0].value = 999 // 会影响 draft.items 中的对应元素 })

Immer 构造函数:创建独立配置的实例

Immer是构造函数,可创建第二个"immer 实例",它暴露本表列出的全部 API,但不共享全局实例的配置。这在需要"一个应用里同时存在不同冻结策略"的场景下非常有用。

import {Immer} from "immer" const immer = new Immer({autoFreeze: false}) const next = immer.produce(base, draft => { draft.x = 1 })

构造函数签名 接受一个可选配置对象:

new Immer({ autoFreeze?: boolean useStrictShallowCopy?: boolean | "class_only" useStrictIteration?: boolean })

传入的配置会立即调用对应的 setter 方法。从源码结构可以推断,全局导出的produce等函数就是new Immer()默认实例的方法绑定(src/immer.ts#L27-L48 中const immer = new Immer()之后才有export const produce = immer.produce),因此自定义实例与全局实例的配置互不影响。

TypeScript 类型工具:Draft、Immutable、castDraft、castImmutable、Patch

Immer 导出一组类型工具,帮助在类型层面打通"不可变 ↔ 可变"的转换(详见 TypeScript)。

Draft 与 Immutable

Draft<T>把不可变类型转换为可变类型;Immutable<T>反向操作。两者的定义位于 src/types/types-external.ts#L59-L86,遵循相同的递归结构:

  • 原始类型与"原子对象"(AtomicObject,如DatePromise)原样保留;
  • ReadonlyMap<K, V>Map<Draft<K>, Draft<V>>(反向为ReadonlyMap<Immutable<K>, Immutable<V>>);
  • ReadonlySet<V>Set<Draft<V>>
  • 普通对象:Draft通过WritableDraft去掉readonly并递归,Immutable则给每个键加上readonly并递归。

另外还导出WritableDraft<T>(仅去readonly、不做类型转换,src/types/types-external.ts#L31-L37),以及对元组类型做了专门处理(IsPlainArray判别普通数组与元组,避免元组被拓宽为Element[])。

castDraft 与 castImmutable

这两个函数运行时是 no-op,纯粹用于类型断言,让 TypeScript 在无法自动推导时"闭嘴"(src/immer.ts#L110-L117):

export let castDraft = <T>(value: T): Draft<T> => value as any export let castImmutable = <T>(value: T): Immutable<T> => value as any

典型用法是在 Redux 等外部代码传入宽泛类型时使用(参考测试 redux.ts 与 type-external.ts)。

Patch 与 PatchListener

Patch描述补丁对象形状(op: "replace" | "remove" | "add"path: (string | number)[]、可选value);PatchListener是补丁监听器类型(patches: Patch[], inversePatches: Patch[]) => void(src/types/types-external.ts#L88-L94),即produce第三参数与finishDraft第二参数的类型。

常见问题与最佳实践

  1. 必须使用命名导入import {produce} from "immer",旧式的默认导入已不再支持;
  2. currentoriginal的区别current返回 draft 的当前快照(包含未提交的修改),original返回未被修改的原始对象。调试打印用current,读取初始值用original
  3. 插件必须在调用相关 API 前启用:未调用enablePatches()就使用produceWithPatches会因插件未加载而报错(getPlugindie(0, pluginKey));Map/Set同理依赖enableMapSet()
  4. enableArrayMethods()的回调陷阱:回调中收到的是 base 值,不要在回调内部直接修改传入元素并期望它反映到结果中;想修改请对filter/slice等返回的 draft 结果进行操作;
  5. 自动冻结与性能setAutoFreeze(false)可以换取性能,但会失去"修改产出状态即报错"的保护(StrictMode相关检查可见 src/utils/env.ts 与 src/core/finalize.ts),需自行保证状态不可变性;
  6. 需要多实例配置时用new Immer(config):全局 setter 影响所有使用命名导出函数的代码,独立实例则隔离配置。

延伸阅读

  • produce 完整指南:柯里化、返回值约定与 recipe 细节
  • 补丁系统:produceWithPatchesapplyPatches的完整示例
  • 异步与手动 draft:createDraft/finishDraft的异步场景
  • Map/Set 支持:enableMapSet的用法与限制
  • 数组方法优化:enableArrayMethods的性能细节
  • TypeScript 指南:Draft/Immutable/castDraft/castImmutable的类型技巧
  • Freezing 与 Classes:冻结策略与自定义类支持
  • 源码入口:src/immer.ts(导出清单)、src/core/immerClass.ts(核心实现)、src/plugins/(三个插件)、src/utils/common.ts(工具函数)、src/types/types-external.ts(公开类型)

【免费下载链接】immerCreate the next immutable state by mutating the current one项目地址: https://gitcode.com/gh_mirrors/im/immer

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

SPC控制图选型与Python实现避坑指南

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

作者头像 李华
网站建设 2026/9/19 4:38:56

Codex本地部署完整指南:环境变量配置与安装避坑

1. 从"下载完打不开"说起&#xff1a;Codex本地部署到底难在哪很多人第一次接触 Codex&#xff0c;卡住的地方根本不是模型本身&#xff0c;而是"下载完之后怎么办"。官网给的安装包双击没反应、命令行敲进去提示找不到命令、环境变量配完重启终端还是报错…

作者头像 李华
网站建设 2026/9/19 4:38:42

用Trae和Flutter Web从零开发2048小游戏:完整教程与代码解析

最早让我动心写 2048 的&#xff0c;不是游戏本身&#xff0c;而是想验证一件事&#xff1a;一个完全没写过 Flutter 的前端&#xff0c;能不能只靠标题里的「Trae、Flutter Web、2048」这三个关键词&#xff0c;从空白目录走到一个能分享给朋友的在线小游戏。做完之后我的结论…

作者头像 李华
网站建设 2026/9/19 4:38:30

Sentinel-1 SAR数据预处理全流程:从下载到精确配准的实操指南

SAR数据处理这件事&#xff0c;说难也难&#xff0c;说简单也简单。难在链路长、参数多、每一步都有坑&#xff1b;简单在于&#xff0c;只要你理解了每个环节在干什么&#xff0c;剩下的就是按部就班地操作。我接触Sentinel-1数据差不多有几年时间了&#xff0c;从最开始连轨道…

作者头像 李华
网站建设 2026/9/19 4:37:54

Codex运维脚本生成实战:自然语言转生产级Shell/Python自动化

1. 项目概述&#xff1a;用Codex把运维脚本从“手动抄写”变成“自然语言对话”我干运维这行快十二年了&#xff0c;从最早手敲Shell脚本查日志、配监控、拉服务&#xff0c;到后来用Ansible写Playbook批量部署&#xff0c;再到写Python封装API调用——每一步都在省力&#xff…

作者头像 李华
网站建设 2026/9/19 4:37:52

Notepad++主题与字体调校指南:从内置主题到自制护眼配色

Notepad是我每天打开次数最多的编辑器&#xff0c;比浏览器还勤。但原装默认主题那个白底蓝字&#xff0c;看一上午眼睛就酸得不行。今天不聊插件也不聊宏&#xff0c;专门把“主题和字体”这件事彻底讲透——从内置主题怎么选&#xff0c;到Stylers.xml手写一套护眼主题&#…

作者头像 李华