<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0"><channel><title>LikeYY</title><link>https://likeyy.love</link><atom:link href="https://likeyy.love/rss.xml" rel="self" type="application/rss+xml"/><description>记录美好生活</description><generator>Halo v2.26.0</generator><language>zh-cn</language><image><url>https://hanserwei-1308845726.cos.ap-chengdu.myqcloud.com/markdown/20250731105905287.svg</url><title>LikeYY</title><link>https://likeyy.love</link></image><lastBuildDate>Tue, 1 Sep 2026 07:38:23 GMT</lastBuildDate><item><title><![CDATA[TypeScript 7 现代开发指南：从类型基础到装饰器]]></title><link>https://likeyy.love/archives/typescript-7-modern-development-guide</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%207%20%E7%8E%B0%E4%BB%A3%E5%BC%80%E5%8F%91%E6%8C%87%E5%8D%97%EF%BC%9A%E4%BB%8E%E7%B1%BB%E5%9E%8B%E5%9F%BA%E7%A1%80%E5%88%B0%E8%A3%85%E9%A5%B0%E5%99%A8&amp;url=/archives/typescript-7-modern-development-guide" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 7 现代开发指南：从类型基础到装饰器</h1>
<blockquote>
 <p>更新日期：2026-08-31
  <br>
  技术基线：TypeScript 7.0.2、ECMAScript Modules、现代 Node.js 与主流前端构建工具</p>
</blockquote>
<p>原来的 TypeScript 长文把安装编译、基础类型、面向对象、泛型、声明文件和旧版装饰器放在同一篇文章中。它既不适合一次学习，也很难在编译器和模块系统升级后准确维护。本系列将内容重写为 9 篇可以独立阅读的文章，并以 TypeScript 7 的默认行为为起点。</p>
<h2>系列目录</h2>
<table>
 <thead>
  <tr>
   <th>篇目</th>
   <th>内容</th>
   <th>发布链接</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>01</td>
   <td>TypeScript 7 工程起步：安装、<code>tsconfig</code> 与编译流程</td>
   <td><a href="https://likeyy.love/archives/typescript-7-project-setup-tsconfig">开始阅读</a></td>
  </tr>
  <tr>
   <td>02</td>
   <td>日常类型：推断、函数、数组与元组</td>
   <td><a href="https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples">开始阅读</a></td>
  </tr>
  <tr>
   <td>03</td>
   <td>类型收窄：<code>unknown</code>、<code>never</code>、断言与穷尽检查</td>
   <td><a href="https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness">开始阅读</a></td>
  </tr>
  <tr>
   <td>04</td>
   <td>对象建模：对象类型、索引签名与只读数据</td>
   <td><a href="https://likeyy.love/archives/typescript-object-types-index-signatures-readonly">开始阅读</a></td>
  </tr>
  <tr>
   <td>05</td>
   <td>类型组合：联合、交叉、<code>type</code>、<code>interface</code> 与 <code>enum</code></td>
   <td><a href="https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum">开始阅读</a></td>
  </tr>
  <tr>
   <td>06</td>
   <td>类设计：修饰符、抽象类与接口契约</td>
   <td><a href="https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces">开始阅读</a></td>
  </tr>
  <tr>
   <td>07</td>
   <td>泛型进阶：约束、<code>keyof</code>、映射类型与工具类型</td>
   <td><a href="https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types">开始阅读</a></td>
  </tr>
  <tr>
   <td>08</td>
   <td>模块与声明文件：ESM、模块解析和 <code>.d.ts</code></td>
   <td><a href="https://likeyy.love/archives/typescript-modules-resolution-declaration-files">开始阅读</a></td>
  </tr>
  <tr>
   <td>09</td>
   <td>装饰器：标准语法与旧版迁移</td>
   <td><a href="https://likeyy.love/archives/typescript-decorators-standard-legacy-migration">开始阅读</a></td>
  </tr>
 </tbody>
</table>
<h2>2026 年的技术基线</h2>
<p>本系列在 2026-08-31 核对 npm <code>latest</code> 标签后，以 TypeScript 7.0.2 为稳定基线。TypeScript 7 是编译器和语言服务的原生移植版本，普通项目仍通过 <code>typescript</code> 包安装，并继续使用熟悉的 <code>tsc</code> 命令，但完整构建通常比旧版快 8～12 倍。</p>
<p>TypeScript 7 延续 TypeScript 6 的类型检查行为，同时采用一组更适合现代项目的新默认值：</p>
<ul>
 <li><code>strict</code> 默认开启；</li>
 <li><code>module</code> 默认为 <code>esnext</code>；</li>
 <li><code>target</code> 默认指向紧邻 <code>esnext</code> 之前的稳定 ECMAScript 版本；</li>
 <li><code>noUncheckedSideEffectImports</code> 默认开启；</li>
 <li><code>types</code> 默认为空数组，不再自动把所有可见的 <code>@types</code> 包加入全局作用域；</li>
 <li><code>rootDir</code> 默认为项目根目录 <code>./</code>；</li>
 <li>旧的 <code>node</code> / <code>node10</code> / <code>classic</code> 模块解析和 <code>target: es5</code> 不再受支持。</li>
</ul>
<p>这些是“没有显式配置时”的默认行为。应用项目仍应根据运行环境明确选择 <code>moduleResolution: "bundler"</code> 或 <code>"nodenext"</code>，库项目还要明确声明文件、导出映射和兼容范围。</p>
<h2>阅读建议</h2>
<ul>
 <li>第一次学习 TypeScript：按 01 → 09 顺序阅读，先学会让编译器参与日常开发，再进入类型编程。</li>
 <li>已会 JavaScript：重点阅读 02～05，理解推断、收窄和结构化类型系统。</li>
 <li>正在维护旧 TypeScript 项目：先看 01、08、09，检查 TypeScript 7 默认值、模块解析和两套装饰器语义。</li>
 <li>业务开发遇到“类型写得比代码还复杂”：回看 03、04、07。优先让推断工作，只在边界建模，不要为了展示技巧制造泛型。</li>
</ul>
<h2>本系列的取舍</h2>
<p>这不是 TypeScript 类型系统的完整参考手册。文章会优先讲日常工程中最常用、最容易误用的部分，并刻意避免以下旧习惯：</p>
<ul>
 <li>不建议全局安装 TypeScript；项目应固定本地版本并提交 lockfile。</li>
 <li>不用 <code>any</code> 掩盖不可信输入；边界数据先用 <code>unknown</code>，验证后再收窄。</li>
 <li>不把类型断言当作类型转换；断言不会生成运行时代码。</li>
 <li>不默认用 <code>enum</code> 表示所有常量；普通应用常量优先考虑字面量联合和 <code>as const</code> 对象。</li>
 <li>不把 <code>experimentalDecorators</code> 下的旧版装饰器写法当作当前标准装饰器。</li>
</ul>
<h2>官方资料</h2>
<ul>
 <li><a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/">TypeScript 7.0 发布说明</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/intro.html">TypeScript Handbook</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/">TSConfig Reference</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/typescript-from-scratch.html">TypeScript for the New Programmer</a></li>
</ul>
<blockquote>
 <p>下一篇：<a href="https://likeyy.love/archives/typescript-7-project-setup-tsconfig">TypeScript 7 工程起步</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-7-modern-development-guide</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FBCDA21E5-6BAA-4EA4-A81D-2EE03D59E02A.png&amp;size=m" type="image/jpeg" length="1889624"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:41 GMT</pubDate></item><item><title><![CDATA[TypeScript 装饰器：标准语法与旧版迁移]]></title><link>https://likeyy.love/archives/typescript-decorators-standard-legacy-migration</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E8%A3%85%E9%A5%B0%E5%99%A8%EF%BC%9A%E6%A0%87%E5%87%86%E8%AF%AD%E6%B3%95%E4%B8%8E%E6%97%A7%E7%89%88%E8%BF%81%E7%A7%BB&amp;url=/archives/typescript-decorators-standard-legacy-migration" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 装饰器：标准语法与旧版迁移</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a> · <a href="https://likeyy.love/archives/typescript-modules-resolution-declaration-files">上一篇</a>
  <br>
  技术基线：TypeScript 7.0.2</p>
</blockquote>
<p>TypeScript 现在存在两套名称相同、语义却不兼容的装饰器：TypeScript 5.0 起默认支持的 ECMAScript Stage 3 标准装饰器，以及通过 <code>experimentalDecorators</code> 开启的旧版实验性装饰器。两者的函数签名、执行模型、元数据能力和可装饰位置都不同。</p>
<p>本篇以标准装饰器为主线，使用现代 <code>(value, context)</code> 签名讲解类、方法、字段和自动访问器装饰器；旧版语法只放在单独的迁移章节中，不能把旧版 <code>target / propertyKey / descriptor</code> 示例当成现代写法。</p>
<h2>一、先分清两套装饰器</h2>
<table>
 <thead>
  <tr>
   <th>对比项</th>
   <th>Stage 3 标准装饰器</th>
   <th>旧版 TypeScript 装饰器</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TypeScript 开关</td>
   <td>TS 5.0 起默认支持，不开启 <code>experimentalDecorators</code></td>
   <td><code>experimentalDecorators: true</code></td>
  </tr>
  <tr>
   <td>典型签名</td>
   <td><code>(value, context)</code></td>
   <td><code>(target, propertyKey, descriptor?)</code></td>
  </tr>
  <tr>
   <td>方法替换</td>
   <td>返回新方法</td>
   <td>修改或返回 <code>PropertyDescriptor</code></td>
  </tr>
  <tr>
   <td>字段处理</td>
   <td>接收 <code>undefined</code>，可返回字段初始化函数</td>
   <td>接收原型/构造器和属性名</td>
  </tr>
  <tr>
   <td>参数装饰器</td>
   <td>不支持</td>
   <td>支持</td>
  </tr>
  <tr>
   <td><code>emitDecoratorMetadata</code></td>
   <td>不兼容</td>
   <td>可与旧版装饰器配合</td>
  </tr>
  <tr>
   <td>标准状态</td>
   <td>ECMAScript Stage 3 提案语义</td>
   <td>TypeScript 历史实验实现</td>
  </tr>
 </tbody>
</table>
<p>标准模式不需要额外开关：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "strict": true
  }
}
</code></pre>
<p>关键点是不要把 <code>experimentalDecorators</code> 设为 <code>true</code>。这个选项不是“让标准装饰器功能更完整”，而是切换到另一套旧语义。</p>
<h2>二、现代装饰器的统一模型：<code>value</code> 与 <code>context</code></h2>
<p>标准装饰器函数通常接收两个参数：</p>
<ol>
 <li><code>value</code>：被装饰的类或类元素。方法会收到原函数，自动访问器会收到 <code>{ get, set }</code>；字段在类定义阶段还没有实例值，因此收到 <code>undefined</code>；</li>
 <li><code>context</code>：描述装饰位置和能力的上下文对象。</li>
</ol>
<p>TypeScript 提供了对应的上下文类型：</p>
<ul>
 <li><code>ClassDecoratorContext</code>；</li>
 <li><code>ClassMethodDecoratorContext</code>；</li>
 <li><code>ClassFieldDecoratorContext</code>；</li>
 <li><code>ClassAccessorDecoratorContext</code>；</li>
 <li>getter / setter 还分别有 <code>ClassGetterDecoratorContext</code>、<code>ClassSetterDecoratorContext</code>。</li>
</ul>
<p>成员上下文常见信息包括：</p>
<pre><code class="language-ts">context.kind       // "class"、"method"、"field"、"accessor" 等
context.name       // string | symbol；类名还可能是 undefined
context.static     // 是否为静态成员
context.private    // 是否为私有成员
context.access     // has/get/set 等安全访问能力，具体成员可用项不同
context.addInitializer(initializer)
</code></pre>
<p><code>addInitializer()</code> 注册的函数会在合适的初始化阶段执行。它适合绑定方法、登记实例、完成类定义后的初始化等工作，避免旧版装饰器直接猜测原型和字段赋值时机。</p>
<h2>三、方法装饰器：返回替代函数</h2>
<p>下面的装饰器记录方法耗时，并完整保留 <code>this</code>、参数与返回值类型：</p>
<pre><code class="language-ts">function logged&lt;This, Args extends unknown[], Result&gt;(
  originalMethod: (this: This, ...args: Args) =&gt; Result,
  context: ClassMethodDecoratorContext&lt;
    This,
    (this: This, ...args: Args) =&gt; Result
  &gt;,
) {
  const methodName = String(context.name)

  return function (this: This, ...args: Args): Result {
    const startedAt = performance.now()

    try {
      return originalMethod.call(this, ...args)
    } finally {
      const elapsed = performance.now() - startedAt
      console.log(`${methodName} 耗时 ${elapsed.toFixed(1)}ms`)
    }
  }
}

class Calculator {
  @logged
  sum(values: number[]): number {
    return values.reduce((total, value) =&gt; total + value, 0)
  }
}
</code></pre>
<p>标准方法装饰器不接收 <code>PropertyDescriptor</code>。要包装方法，就返回一个兼容的新函数；没有返回值则保留原方法。</p>
<p>这个同步示例的 <code>finally</code> 只统计函数返回 Promise 之前的同步时间。若要统计异步任务，应单独设计只接受 Promise 返回值的异步装饰器，并 <code>await</code> 原方法。</p>
<h2>四、<code>addInitializer()</code>：为实例绑定方法</h2>
<p>把类方法作为回调传递时可能丢失 <code>this</code>。方法装饰器可以在每个实例初始化时完成绑定：</p>
<pre><code class="language-ts">function bound&lt;This, Args extends unknown[], Result&gt;(
  originalMethod: (this: This, ...args: Args) =&gt; Result,
  context: ClassMethodDecoratorContext&lt;
    This,
    (this: This, ...args: Args) =&gt; Result
  &gt;,
): void {
  if (context.private) {
    throw new Error(`@bound 不能用于私有方法 ${String(context.name)}`)
  }

  context.addInitializer(function () {
    const method = context.access.get(this)

    Object.defineProperty(this, context.name, {
      value: method.bind(this),
      configurable: true,
      writable: true,
    })
  })
}

class ButtonController {
  constructor(private readonly label: string) {}

  @bound
  handleClick(): void {
    console.log(this.label)
  }
}

const controller = new ButtonController('保存')
const callback = controller.handleClick
callback() // 保存
</code></pre>
<p>与在构造器里手写 <code>this.handleClick = this.handleClick.bind(this)</code> 相比，装饰器把重复行为集中到一个可复用边界。是否值得使用仍取决于团队可读性与运行时成本。</p>
<h2>五、字段装饰器：返回初始化函数</h2>
<p>标准字段装饰器执行时还没有类实例，因此第一个参数是 <code>undefined</code>。它可以返回一个初始化函数，转换每个实例的初始字段值：</p>
<pre><code class="language-ts">function trim&lt;This&gt;(
  _value: undefined,
  context: ClassFieldDecoratorContext&lt;This, string&gt;,
) {
  if (context.static) {
    throw new Error('@trim 仅用于实例字段')
  }

  return function (this: This, initialValue: string): string {
    return initialValue.trim()
  }
}

class Profile {
  @trim
  displayName = '  Ada Lovelace  '
}

console.log(new Profile().displayName)
// "Ada Lovelace"
</code></pre>
<p>字段装饰器不是旧版的“拿到原型和字段名后调用 <code>Object.defineProperty</code>”。它通过初始化函数处理每个实例，因而不会把多个实例的字段值错误地共享在原型闭包中。</p>
<p>字段初始化函数的实际调用约定由标准装饰器转换负责；TypeScript 的 <code>ClassFieldDecoratorContext&lt;This, Value&gt;</code> 会检查返回函数是否接收并返回正确的字段值类型。</p>
<h2>六、自动访问器装饰器：包装 <code>accessor</code></h2>
<p>标准装饰器支持 <code>accessor</code> 声明。自动访问器拥有隐藏存储，并向装饰器提供原始 <code>get</code> / <code>set</code>：</p>
<pre><code class="language-ts">function clamp(min: number, max: number) {
  return function &lt;This&gt;(
    target: ClassAccessorDecoratorTarget&lt;This, number&gt;,
    context: ClassAccessorDecoratorContext&lt;This, number&gt;,
  ) {
    const normalize = (value: number) =&gt;
      Math.min(max, Math.max(min, value))

    return {
      get(this: This) {
        return target.get.call(this)
      },
      set(this: This, value: number) {
        target.set.call(this, normalize(value))
      },
      init(value: number) {
        console.log(`初始化 ${String(context.name)}`)
        return normalize(value)
      },
    }
  }
}

class Player {
  @clamp(0, 100)
  accessor score = 120
}

const player = new Player()
console.log(player.score) // 100

player.score = -10
console.log(player.score) // 0
</code></pre>
<p>访问器装饰器可以返回新的 <code>get</code>、<code>set</code> 和 <code>init</code>。<code>init</code> 转换初始值，<code>set</code> 处理后续赋值。它比把普通字段改写成原型访问器更符合标准字段初始化模型。</p>
<p>已有的 <code>get value()</code> 和 <code>set value()</code> 也可以分别装饰，对应上下文类型为 <code>ClassGetterDecoratorContext</code> 与 <code>ClassSetterDecoratorContext</code>。</p>
<h2>七、类装饰器：观察或替换类</h2>
<p>类装饰器接收构造器和 <code>ClassDecoratorContext</code>。下面在类定义完成后封闭构造器及其原型：</p>
<pre><code class="language-ts">function sealed(
  value: Function,
  context: ClassDecoratorContext,
): void {
  context.addInitializer(function () {
    Object.seal(value)
    Object.seal(value.prototype)
  })
}

@sealed
class Session {
  constructor(readonly id: string) {}
}
</code></pre>
<p>类装饰器也可以返回兼容的新类来替换原类，但要谨慎：私有字段、静态成员、继承关系和构造签名都会增加复杂度，而且装饰器在运行时添加的实例成员不会自动出现在类的静态类型上。若目标只是为对象增加明确能力，普通工厂函数、继承或组合通常更容易理解。</p>
<h2>八、装饰器工厂</h2>
<p>装饰器工厂是“返回装饰器的函数”，适合接收配置。前面的 <code>clamp(0, 100)</code> 就是工厂：外层函数接收范围，内层函数才接收 <code>value</code> 与 <code>context</code>。</p>
<p>再看一个可配置的方法装饰器：</p>
<pre><code class="language-ts">function trace(prefix: string) {
  return function &lt;This, Args extends unknown[], Result&gt;(
    originalMethod: (this: This, ...args: Args) =&gt; Result,
    context: ClassMethodDecoratorContext&lt;
      This,
      (this: This, ...args: Args) =&gt; Result
    &gt;,
  ) {
    return function (this: This, ...args: Args): Result {
      console.log(`${prefix}:${String(context.name)}`, args)
      return originalMethod.call(this, ...args)
    }
  }
}

class Repository {
  @trace('repository')
  findById(id: string) {
    return { id }
  }
}
</code></pre>
<p>工厂参数在类定义时求值，不是每次调用方法时求值。不要在工厂求值阶段放入依赖请求上下文的状态。</p>
<h2>九、求值顺序与应用顺序</h2>
<p>多个装饰器叠加时要区分两个阶段：</p>
<ol>
 <li>装饰器表达式或工厂从上到下求值；</li>
 <li>得到的装饰器函数从下到上应用，类似函数组合。</li>
</ol>
<pre><code class="language-ts">function mark(label: string) {
  console.log(`求值 ${label}`)

  return function &lt;This, Args extends unknown[], Result&gt;(
    value: (this: This, ...args: Args) =&gt; Result,
    context: ClassMethodDecoratorContext&lt;
      This,
      (this: This, ...args: Args) =&gt; Result
    &gt;,
  ) {
    console.log(`应用 ${label} 到 ${String(context.name)}`)
    return value
  }
}

class Demo {
  @mark('A')
  @mark('B')
  run() {}
}
</code></pre>
<p>定义类时输出顺序是：</p>
<pre><code class="language-text">求值 A
求值 B
应用 B 到 run
应用 A 到 run
</code></pre>
<p>如果多个装饰器都会包装返回函数，最靠近方法的装饰器先应用，外层装饰器随后包住它。组合日志、缓存、重试与权限检查时，顺序可能改变实际语义，应写测试固定行为。</p>
<h2>十、一个完整的现代示例</h2>
<p>下面把四类标准装饰器放在同一个类中。所有签名都是现代 <code>(value, context)</code> 模型：</p>
<pre><code class="language-ts">@sealed
class Account {
  @trim
  owner = '  Grace Hopper  '

  @clamp(0, 1_000_000)
  accessor balance = 100

  @logged
  deposit(amount: number): number {
    this.balance += amount
    return this.balance
  }

  @bound
  print(): void {
    console.log(`${this.owner}: ${this.balance}`)
  }
}

const account = new Account()
account.deposit(50)

const print = account.print
print() // Grace Hopper: 150
</code></pre>
<p>这里没有 <code>target.prototype</code>、属性描述符或参数索引。字段初值由字段初始化函数转换，访问器通过 <code>get</code> / <code>set</code> / <code>init</code> 包装，方法通过返回函数替换，绑定逻辑则由 <code>addInitializer()</code> 在实例初始化阶段完成。</p>
<h2>十一、旧版 <code>experimentalDecorators</code> 只用于遗留生态</h2>
<p>旧项目或依赖旧装饰器的框架可能包含下面这种签名：</p>
<pre><code class="language-ts">// 这是旧版示意，不是标准装饰器写法
function legacyLog(
  target: object,
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor,
): void {
  console.log(target, propertyKey, descriptor)
}
</code></pre>
<p>它依赖以下配置：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}
</code></pre>
<p>如果旧框架还读取设计类型元数据，可能同时要求：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
</code></pre>
<p><code>emitDecoratorMetadata</code> 是 TypeScript 的旧实验能力，常与 <code>reflect-metadata</code> 及特定框架约定配合。它不属于 Stage 3 标准装饰器，也不能用于标准装饰器模式。标准装饰器上下文中可能出现的标准元数据机制与旧的 <code>design:type</code>、<code>design:paramtypes</code> 发射并不是同一套协议，不能当作无缝替代。</p>
<h2>十二、迁移为什么不能只改函数参数名</h2>
<p>旧版与标准版存在实质差异：</p>
<ul>
 <li>旧方法装饰器操作 <code>PropertyDescriptor</code>；标准方法装饰器接收原函数并返回替代函数；</li>
 <li>旧字段装饰器接收原型或构造器；标准字段装饰器接收 <code>undefined</code> 并返回初始化函数；</li>
 <li>标准装饰器用 <code>addInitializer()</code> 表达初始化时机；旧版通常直接修改原型或描述符；</li>
 <li>标准装饰器不支持参数装饰器；</li>
 <li>标准装饰器不能与 <code>emitDecoratorMetadata</code> 配合；</li>
 <li>第三方框架可能依赖旧版参数索引、反射元数据或特定发射结果。</li>
</ul>
<p>因此，一个按旧签名实现的装饰器通常不能直接在标准模式下使用，同一函数也很难在两种模式中保持可靠语义。</p>
<p>建议按以下步骤迁移：</p>
<ol>
 <li>盘点项目中的装饰器来源、使用位置、参数装饰器和元数据读取；</li>
 <li>查阅框架是否明确支持标准装饰器，不要仅依据“TypeScript 5+ 可编译”判断；</li>
 <li>先迁移自研、无元数据依赖的方法或字段装饰器；</li>
 <li>使用标准上下文类型重新实现，并为求值、应用、初始化顺序补测试；</li>
 <li>对依赖参数装饰器或 <code>emitDecoratorMetadata</code> 的框架，继续保留独立的 legacy 配置，或按框架官方方案升级；</li>
 <li>无法表达的参数注入、校验元数据可改为显式注册、schema、工厂函数或静态字段，而不是制造脆弱的兼容适配器。</li>
</ol>
<p>装饰器模式由编译配置决定。大型仓库如需同时维护两套生态，最好按包或构建目标隔离，避免在同一个源文件中混用两类假设。</p>
<h2>十三、常见陷阱</h2>
<ol>
 <li><strong>以为 <code>experimentalDecorators</code> 是标准模式开关</strong>：设为 <code>true</code> 会启用旧语义。</li>
 <li><strong>把旧签名复制到现代示例</strong>：<code>target / key / descriptor</code> 不是 Stage 3 方法装饰器签名。</li>
 <li><strong>尝试标准参数装饰器</strong>：标准提案目前不支持装饰参数。</li>
 <li><strong>标准模式开启 <code>emitDecoratorMetadata</code></strong>：两者不兼容；依赖设计类型元数据的框架仍属于旧版迁移问题。</li>
 <li><strong>字段装饰器读取实例字段</strong>：装饰器应用时实例尚不存在，应返回初始化函数或使用 <code>addInitializer()</code>。</li>
 <li><strong>忽略 <code>private</code>、<code>static</code> 与 <code>symbol</code> 名称</strong>：通用装饰器应检查上下文能力，并用 <code>String(context.name)</code> 安全记录名称。</li>
 <li><strong>返回不兼容的替代值</strong>：新方法、新访问器或新类必须满足标准和 TypeScript 对该位置的兼容要求。</li>
 <li><strong>忽略组合顺序</strong>：工厂从上到下求值，装饰器从下到上应用，顺序会改变包装行为。</li>
 <li><strong>用装饰器隐藏核心业务流程</strong>：权限、事务、缓存等行为若完全不可见，会增加调试成本；装饰器应保持职责单一并有测试。</li>
</ol>
<h2>小结</h2>
<p>TypeScript 5.0 起默认支持的是 Stage 3 标准装饰器：它以 <code>(value, context)</code> 为统一模型，通过返回替代值和 <code>addInitializer()</code> 完成扩展。类、方法、字段和自动访问器各自拥有明确的上下文类型与初始化规则。</p>
<p><code>experimentalDecorators</code> 则明确代表旧版 TypeScript 实现。旧版签名、参数装饰器和 <code>emitDecoratorMetadata</code> 不能直接迁入标准模式。迁移前应先确认框架生态和元数据依赖，再逐个用现代上下文重写；若依赖仍未迁移，隔离并保留 legacy 构建比表面兼容更可靠。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators">TypeScript 5.0：Decorators</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/experimentalDecorators.html">TSConfig: experimentalDecorators</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/emitDecoratorMetadata.html">TSConfig: emitDecoratorMetadata</a></li>
 <li><a href="https://github.com/tc39/proposal-decorators">TC39 Decorators Proposal</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#decorator-metadata">TypeScript Decorator Metadata</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">返回目录</a> · <a href="https://likeyy.love/archives/typescript-modules-resolution-declaration-files">上一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-decorators-standard-legacy-migration</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FAB3DA136-4586-4C72-9056-25E3ABD88A9E.png&amp;size=m" type="image/jpeg" length="1533240"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:12 GMT</pubDate></item><item><title><![CDATA[TypeScript 模块与声明文件：ESM、模块解析和 .d.ts]]></title><link>https://likeyy.love/archives/typescript-modules-resolution-declaration-files</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E6%A8%A1%E5%9D%97%E4%B8%8E%E5%A3%B0%E6%98%8E%E6%96%87%E4%BB%B6%EF%BC%9AESM%E3%80%81%E6%A8%A1%E5%9D%97%E8%A7%A3%E6%9E%90%E5%92%8C%20.d.ts&amp;url=/archives/typescript-modules-resolution-declaration-files" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 模块与声明文件：ESM、模块解析和 .d.ts</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a> · <a href="https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types">上一篇</a> · <a href="https://likeyy.love/archives/typescript-decorators-standard-legacy-migration">下一篇</a>
  <br>
  技术基线：TypeScript 7.0.2</p>
</blockquote>
<p>现代 TypeScript 项目通常使用 ECMAScript Modules（ESM），但“源码采用 ESM 语法”并不能单独决定模块如何解析。前端构建工具与 Node.js 对文件扩展名、<code>package.json</code> 和包导出映射的处理不同，<code>module</code> 与 <code>moduleResolution</code> 必须匹配真实运行环境。</p>
<p>本篇先梳理值导入与类型导入，再分别给出 bundler 和现代 Node.js 的配置，并说明声明文件、<code>@types</code>、TypeScript 7 的 <code>types: []</code> 默认值以及库的声明生成流程。</p>
<h2>一、一个文件何时是模块</h2>
<p>只要文件包含顶层 <code>import</code> 或 <code>export</code>，它就是模块，顶层声明不会自动进入全局作用域：</p>
<pre><code class="language-ts">// src/math.ts
export const PI = 3.1415926

export function add(left: number, right: number): number {
  return left + right
}

export interface Point {
  x: number
  y: number
}
</code></pre>
<p>使用命名导入：</p>
<pre><code class="language-ts">// src/main.ts
import { PI, add, type Point } from './math.js'

const point: Point = { x: add(1, 2), y: PI }
console.log(point)
</code></pre>
<p>ESM 还支持默认导出，但公共库通常优先命名导出：名称稳定、重构清楚，也更方便自动补全。</p>
<pre><code class="language-ts">// formatter.ts
export default function format(value: number): string {
  return value.toFixed(2)
}

// main.ts
import format from './formatter.js'
</code></pre>
<p>示例中的 <code>.js</code> 后缀是现代 Node ESM 的写法：源码文件虽然是 <code>math.ts</code>，运行时产物是 <code>math.js</code>。使用 bundler 时通常也可以写无扩展名的相对导入，但库代码最好遵循目标运行环境和构建工具的约定。</p>
<h2>二、<code>import type</code> 与类型专用导出</h2>
<p>接口、类型别名等只存在于类型系统，编译后会被擦除。使用 <code>import type</code> 可以明确这条导入不产生运行时依赖：</p>
<pre><code class="language-ts">import type { Point } from './math.js'

export function distance(point: Point): number {
  return Math.hypot(point.x, point.y)
}
</code></pre>
<p>值与类型来自同一模块时，可以写在一条语句中：</p>
<pre><code class="language-ts">import { add, type Point } from './math.js'
</code></pre>
<p>导出也有对应语法：</p>
<pre><code class="language-ts">export type { Point } from './math.js'
export { add, type Point as MathPoint } from './math.js'
</code></pre>
<p>推荐开启 <code>verbatimModuleSyntax</code>。开启后，带 <code>type</code> 的导入导出会被删除，未带 <code>type</code> 的语句会按原样保留，源码意图和运行时依赖更容易判断：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}
</code></pre>
<p>注意，类和 <code>enum</code> 同时是值与类型。要在运行时调用构造器、读取静态属性、使用 <code>instanceof</code> 或访问枚举成员时，不能只用 <code>import type</code>：</p>
<pre><code class="language-ts">import { User } from './user.js'

const user = new User()
console.log(user instanceof User)
</code></pre>
<h2>三、<code>module</code> 与 <code>moduleResolution</code> 解决不同问题</h2>
<ul>
 <li><code>module</code> 决定 TypeScript 如何解释并输出模块代码；</li>
 <li><code>moduleResolution</code> 决定编译器如何从导入说明符找到文件、包入口和类型声明。</li>
</ul>
<p>两者应成对选择，而不是复制一份“万能 tsconfig”：</p>
<table>
 <thead>
  <tr>
   <th>运行方式</th>
   <th>推荐组合</th>
   <th>关键特点</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Vite、Rolldown、webpack、esbuild 等 bundler</td>
   <td><code>module: "esnext"</code> + <code>moduleResolution: "bundler"</code></td>
   <td>由打包器处理扩展名、别名和最终产物</td>
  </tr>
  <tr>
   <td>现代 Node.js 直接运行编译后的文件</td>
   <td><code>module: "nodenext"</code> + <code>moduleResolution: "nodenext"</code></td>
   <td>按 Node ESM/CJS、扩展名和 <code>package.json</code> 规则解析</td>
  </tr>
 </tbody>
</table>
<p>TypeScript 7 不再支持旧的 <code>classic</code>、<code>node</code> / <code>node10</code> 解析策略。升级旧项目时，应先根据真实运行环境迁移，而不是简单删除报错选项。</p>
<h2>四、前端构建工具项目：使用 <code>bundler</code></h2>
<p>Vite 一类项目通常由打包器发射 JavaScript，TypeScript 只负责检查：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}
</code></pre>
<p><code>bundler</code> 模式理解包的 <code>exports</code> / <code>imports</code> 映射，同时允许打包工具常见的无扩展名相对导入：</p>
<pre><code class="language-ts">import { createApp } from './app'
</code></pre>
<p><code>paths</code> 只告诉 TypeScript 如何检查别名，不会自动修改浏览器或 Node 的运行时解析。配置 <code>@/*</code> 时，还要在 Vite、测试工具或运行器中配置同一别名；如果框架已经生成配置，应优先沿用框架方案。</p>
<h2>五、现代 Node.js 项目：使用 <code>nodenext</code></h2>
<p>直接让 Node.js 运行编译后的 ESM 时，建议把 Node 规则交给 <code>nodenext</code>：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
</code></pre>
<p>并在 <code>package.json</code> 中声明 ESM：</p>
<pre><code class="language-json">{
  "name": "node-esm-app",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/main.js"
  }
}
</code></pre>
<p>需要注意：</p>
<ol>
 <li><code>type: "module"</code> 会让该包范围内的普通 <code>.js</code> 文件按 ESM 解释；</li>
 <li>Node ESM 的相对导入通常必须写运行时扩展名，因此 <code>.ts</code> 源码导入 <code>./math.js</code>；</li>
 <li><code>.mts</code> 明确表示 ESM 源文件，<code>.cts</code> 明确表示 CommonJS 源文件；对应声明文件是 <code>.d.mts</code> 与 <code>.d.cts</code>；</li>
 <li><code>module: "nodenext"</code> 应与 <code>moduleResolution: "nodenext"</code> 配对；</li>
 <li>不要依赖开发运行器恰好支持、而生产 Node 不支持的扩展名省略或路径别名。</li>
</ol>
<h2>六、<code>package.json</code> 的 <code>type</code> 与 <code>exports</code></h2>
<p>发布包时，<code>exports</code> 是公共入口清单。未列出的深层路径会被阻止，因此源码中可访问并不代表消费者可以导入：</p>
<pre><code class="language-json">{
  "name": "@acme/math",
  "version": "1.0.0",
  "type": "module",
  "files": ["dist"],
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./geometry": {
      "types": "./dist/geometry.d.ts",
      "import": "./dist/geometry.js"
    }
  }
}
</code></pre>
<p><code>types</code> 条件应放在相应条件对象前面，让 TypeScript 优先找到声明；顶层 <code>types</code> 可以兼容尚未完整理解 <code>exports</code> 的工具。每个公开子路径都要同时提供运行时代码和匹配的类型入口。</p>
<p>如果一个包同时发布 ESM 与 CommonJS，不要让两种运行时入口随意共用一份声明。声明文件自身也有模块格式：通常应分别提供与 ESM、CJS 入口匹配的 <code>.d.mts</code>、<code>.d.cts</code>，并在条件导出中准确映射。双包发布容易出现构造器身份和模块解析问题，除非确有消费者需求，纯 ESM 包更简单。</p>
<h2>七、声明文件 <code>.d.ts</code> 是运行时代码的类型契约</h2>
<p>声明文件只描述已有 JavaScript 的类型，不包含实现：</p>
<pre><code class="language-js">// legacy-math.js
export function add(left, right) {
  return left + right
}
</code></pre>
<pre><code class="language-ts">// legacy-math.d.ts
export declare function add(left: number, right: number): number

export interface MathOptions {
  precision?: number
}
</code></pre>
<p>消费者正常导入运行时模块：</p>
<pre><code class="language-ts">import { add } from './legacy-math.js'

const total = add(2, 3)
</code></pre>
<p><code>.d.ts</code> 必须如实反映运行时行为。声明一个实际不存在的导出只能骗过编译器，运行时仍会失败。手写声明时还要保持文件名、模块格式、默认/命名导出和公开子路径一致。</p>
<h2>八、为无类型第三方包声明环境模块</h2>
<p>某个 JavaScript 包没有自带声明、也没有可用的 <code>@types</code> 时，可以先补充最小声明：</p>
<pre><code class="language-ts">// src/types/tiny-chart.d.ts

declare module 'tiny-chart' {
  export interface ChartOptions {
    width: number
    height: number
  }

  export function render(
    target: HTMLElement,
    options: ChartOptions,
  ): void
}
</code></pre>
<p>然后正常使用：</p>
<pre><code class="language-ts">import { render } from 'tiny-chart'

render(document.querySelector('#chart')!, {
  width: 800,
  height: 450,
})
</code></pre>
<p>还可以为资源导入声明通配模块：</p>
<pre><code class="language-ts">// src/types/assets.d.ts

declare module '*.svg' {
  const url: string
  export default url
}
</code></pre>
<p>新建环境模块的 <code>.d.ts</code> 文件通常不应在顶层写 <code>import</code> 或 <code>export</code>；一旦文件自身成为外部模块，其中的 <code>declare module 'name'</code> 通常会被解释为对已有模块的增强，而不是新建声明。需要引用别处类型时，可以使用 <code>import('./types.js').SomeType</code> 形式的内联类型导入。</p>
<p>不要长期使用下面这种兜底：</p>
<pre><code class="language-ts">declare module 'tiny-chart'
</code></pre>
<p>它会让整个模块近似 <code>any</code>，应尽快用真实 API 补全。</p>
<h2>九、先找包自带类型，再找 <code>@types</code></h2>
<p>使用第三方库时按以下顺序处理：</p>
<ol>
 <li>检查包自身的 <code>package.json</code> 是否提供 <code>types</code>、<code>typings</code> 或 <code>exports</code> 中的 <code>types</code>；</li>
 <li>再查询 DefinitelyTyped 上对应的 <code>@types/包名</code>；</li>
 <li>都没有时，在项目内补充最小 <code>.d.ts</code>，并考虑向上游贡献。</li>
</ol>
<p>例如：</p>
<pre><code class="language-bash">npm install lodash
npm install --save-dev @types/lodash
</code></pre>
<p><code>@types</code> 包应与运行时库的主版本和 API 相匹配。类型包安装成功不代表运行时库已安装，反过来也一样。</p>
<h2>十、TypeScript 7 的 <code>types: []</code> 默认值</h2>
<p>TypeScript 7 默认不再把所有可见的 <code>node_modules/@types</code> 包自动注入全局作用域，等价于从更干净的 <code>types: []</code> 起步。这可以避免测试框架、Node、浏览器扩展等包的全局名字互相污染。</p>
<p>需要某组全局类型时显式列出：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "types": ["node", "vitest/globals"]
  }
}
</code></pre>
<p>几个容易混淆的边界：</p>
<ul>
 <li><code>types</code> 控制自动加入全局作用域的类型包，不会限制源码可以导入哪些普通模块；</li>
 <li><code>lib</code> 控制 <code>Array</code>、<code>Promise</code>、DOM 等标准环境声明；它与 <code>types</code> 不是同一个选项；</li>
 <li>Node 项目需要安装 <code>@types/node</code> 并加入 <code>"node"</code>；</li>
 <li>Vite 的 <code>import.meta.env</code> 等类型可按项目模板加入 <code>"vite/client"</code>；</li>
 <li>测试项目可以使用单独的 tsconfig，只在测试范围加入测试框架全局类型。</li>
</ul>
<p>显式配置环境类型能让生产代码、浏览器代码和测试代码的边界更清楚。</p>
<h2>十一、为 TypeScript 库生成声明</h2>
<p>库项目不要手工维护所有 <code>.d.ts</code>，应从公开 TypeScript 源码生成：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "verbatimModuleSyntax": true,
    "types": []
  },
  "include": ["src/**/*.ts"]
}
</code></pre>
<p>执行：</p>
<pre><code class="language-bash">npx tsc -p tsconfig.build.json
</code></pre>
<p>关键选项：</p>
<ul>
 <li><code>declaration</code>：生成声明文件；</li>
 <li><code>declarationMap</code>：生成声明映射，方便消费者跳转到源码；</li>
 <li><code>emitDeclarationOnly</code>：只生成类型产物，JavaScript 可交给另一构建流程；</li>
 <li><code>rootDir</code> / <code>outDir</code>：保持可预测的目录结构。</li>
</ul>
<p><code>tsc</code> 会逐模块生成声明，但不会自动把它们打成一个文件。如果运行时代码由 bundler 合并或改写入口，需要额外验证声明路径仍与最终 <code>exports</code> 一致，必要时使用专门的声明打包工具。</p>
<p>发布前至少在一个临时消费者项目中验证：包能否导入、公开子路径是否可用、默认与命名导出是否一致、声明是否引用了未发布文件。也应检查实际 npm 包内容，而不只检查本地源码。</p>
<h2>十二、副作用导入与解析错误</h2>
<p>副作用导入没有绑定名称，但仍会在运行时执行模块：</p>
<pre><code class="language-ts">import './polyfills.js'
import './theme.css'
</code></pre>
<p>TypeScript 7 默认开启 <code>noUncheckedSideEffectImports</code>，无法解析的副作用导入会报错，避免拼写错误被静默忽略。CSS 等资源应由框架提供的客户端类型或项目内通配声明覆盖，而不是关闭检查。</p>
<h2>十三、常见陷阱</h2>
<ol>
 <li><strong>把 <code>moduleResolution</code> 当成运行时加载器</strong>：它只影响 TypeScript 如何查找模块，不会让 Node 理解 <code>paths</code> 别名。</li>
 <li><strong>Node ESM 源码导入 <code>.ts</code> 产物</strong>：编译后文件通常是 <code>.js</code>，相对说明符应按真实运行时写。</li>
 <li><strong>值被写成 <code>import type</code></strong>：类、枚举或运行时常量会在 JavaScript 中消失，导致无法构造或读取。</li>
 <li><strong><code>package.json</code> 与声明格式不一致</strong>：<code>type</code>、<code>exports</code>、<code>.d.ts</code> / <code>.d.mts</code> / <code>.d.cts</code> 必须共同描述同一种模块格式。</li>
 <li><strong><code>exports</code> 漏掉子路径</strong>：消费者无法导入未公开入口，即使文件确实存在于包内。</li>
 <li><strong>环境模块直接写成 <code>any</code></strong>：短期消除错误，却把风险推迟到运行时。</li>
 <li><strong>误解 <code>types: []</code></strong>：它清理自动全局类型，不会禁止导入带类型的依赖。</li>
 <li><strong>只生成声明、不验证发布包</strong>：声明可能引用未包含的内部文件，或与 bundler 的最终入口脱节。</li>
</ol>
<h2>小结</h2>
<p>ESM 语法只是起点，真正可靠的模块配置必须匹配执行环境：构建工具项目使用 <code>esnext</code> + <code>bundler</code>，现代 Node.js 项目使用成对的 <code>nodenext</code>。<code>import type</code> 和类型专用导出明确区分编译期依赖与运行时依赖，<code>package.json</code> 的 <code>type</code>、<code>exports</code> 和声明入口则共同定义包边界。</p>
<p>TypeScript 7 默认的 <code>types: []</code> 让全局环境更干净；需要 Node、Vite 或测试框架类型时应显式加入。对于库项目，生成 <code>.d.ts</code> 只是第一步，还必须验证声明格式、导出映射和实际发布内容彼此一致。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/modules/reference.html">TypeScript Modules Reference</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html">Choosing Compiler Options</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/module.html">TSConfig: module</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/moduleResolution.html">TSConfig: moduleResolution</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/types.html">TSConfig: types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html">Declaration Files</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html">Publishing Declaration Files</a></li>
 <li><a href="https://nodejs.org/api/packages.html">Node.js Packages</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">返回目录</a> · <a href="https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types">上一篇</a> · <a href="https://likeyy.love/archives/typescript-decorators-standard-legacy-migration">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-modules-resolution-declaration-files</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F71507F27-B6D3-4F3C-9950-B412411137E3.png&amp;size=m" type="image/jpeg" length="1741719"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:12 GMT</pubDate></item><item><title><![CDATA[TypeScript 泛型进阶：约束、keyof、映射类型与工具类型]]></title><link>https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E6%B3%9B%E5%9E%8B%E8%BF%9B%E9%98%B6%EF%BC%9A%E7%BA%A6%E6%9D%9F%E3%80%81keyof%E3%80%81%E6%98%A0%E5%B0%84%E7%B1%BB%E5%9E%8B%E4%B8%8E%E5%B7%A5%E5%85%B7%E7%B1%BB%E5%9E%8B&amp;url=/archives/typescript-generics-keyof-mapped-utility-types" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 泛型进阶：约束、keyof、映射类型与工具类型</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a> · <a href="https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces">上一篇</a> · <a href="https://likeyy.love/archives/typescript-modules-resolution-declaration-files">下一篇</a>
  <br>
  技术基线：TypeScript 7.0.2</p>
</blockquote>
<p>泛型的价值不是“把类型写得更抽象”，而是描述多个位置之间的类型关系：输入是什么，输出就保留什么；传入哪个属性名，结果就是那个属性的类型。<code>keyof</code>、索引访问、映射类型和条件类型则把这种关系扩展到对象键、属性值与类型转换。</p>
<p>本篇从推断优先的泛型函数出发，逐步组合这些类型运算能力。目标不是追求复杂类型，而是用尽可能少的类型参数表达真实约束。</p>
<h2>一、泛型描述输入与输出的关系</h2>
<p>如果只用 <code>unknown</code>，函数虽然可以接收任何值，却会丢失调用处的具体类型：</p>
<pre><code class="language-ts">function identityUnknown(value: unknown): unknown {
  return value
}

const result = identityUnknown('TypeScript')
// result 是 unknown
</code></pre>
<p>泛型参数会在一次调用中代表同一个具体类型：</p>
<pre><code class="language-ts">function identity&lt;T&gt;(value: T): T {
  return value
}

const name = identity('Ada')
// name 推断为 "Ada"

const count = identity(3)
// count 推断为 3
</code></pre>
<p>这里的 <code>T</code> 没有表示“任意、不检查”，而是表示“由本次调用确定，并在参数和返回值之间保持一致的类型”。这正是泛型与 <code>any</code> 的根本区别。</p>
<h2>二、优先让 TypeScript 推断类型参数</h2>
<p>大多数泛型调用不需要手写尖括号：</p>
<pre><code class="language-ts">function first&lt;T&gt;(items: readonly T[]): T | undefined {
  return items[0]
}

const firstName = first(['Ada', 'Linus'])
// string | undefined

const firstPoint = first([
  { x: 1, y: 2 },
  { x: 3, y: 4 },
])
// { x: number; y: number } | undefined
</code></pre>
<p>只有推断信息不足，或需要主动选择一个更宽的类型时，才显式传入类型参数：</p>
<pre><code class="language-ts">const queue = first&lt;string&gt;([])
// string | undefined
</code></pre>
<p>显式类型参数不是运行时校验。下面的写法只是告诉编译器结果类型，无法证明服务器真的返回了 <code>User</code>：</p>
<pre><code class="language-ts">interface User {
  id: string
  name: string
}

function unsafeParse&lt;T&gt;(text: string): T {
  return JSON.parse(text) as T
}

const user = unsafeParse&lt;User&gt;('null')
// 编译通过，运行时仍然是 null
</code></pre>
<p>外部数据应先以 <code>unknown</code> 接收，再通过校验函数或 schema 库验证，而不是让调用者用 <code>&lt;T&gt;</code>“许愿”。</p>
<h2>三、用 <code>extends</code> 约束可用能力</h2>
<p>无约束的 <code>T</code> 不能假定存在任何属性。约束用于声明泛型值至少具备什么结构：</p>
<pre><code class="language-ts">interface HasId {
  id: string
}

function findById&lt;T extends HasId&gt;(
  items: readonly T[],
  id: string,
): T | undefined {
  return items.find((item) =&gt; item.id === id)
}

const products = [
  { id: 'p-1', name: 'Keyboard', price: 599 },
  { id: 'p-2', name: 'Mouse', price: 299 },
]

const product = findById(products, 'p-2')
// { id: string; name: string; price: number } | undefined
</code></pre>
<p>参数类型如果直接写成 <code>HasId[]</code>，返回值就只剩 <code>HasId</code>；使用 <code>T extends HasId</code>，既能在实现中安全读取 <code>id</code>，又能为调用者保留 <code>name</code>、<code>price</code> 等额外属性。</p>
<p>约束不等于类型转换。<code>T extends HasId</code> 表示调用者传入的类型满足 <code>HasId</code>，并不表示函数可以随意构造一个 <code>T</code>。</p>
<h2>四、多个类型参数只用于表达关系</h2>
<p>多个类型参数适合描述不同位置之间的转换：</p>
<pre><code class="language-ts">function mapItems&lt;Input, Output&gt;(
  items: readonly Input[],
  transform: (item: Input, index: number) =&gt; Output,
): Output[] {
  return items.map(transform)
}

const labels = mapItems(products, (item) =&gt; `${item.name}: ¥${item.price}`)
// string[]
</code></pre>
<p><code>Input</code> 连接数组元素和回调参数，<code>Output</code> 连接回调返回值和最终数组元素。两个参数都有明确关系。</p>
<p>如果类型参数只出现一次，通常不需要泛型：</p>
<pre><code class="language-ts">// 不必要：T 没有连接两个位置
function printBad&lt;T&gt;(value: T): void {
  console.log(value)
}

// 更直接
function print(value: unknown): void {
  console.log(value)
}
</code></pre>
<p>同样，不要仅因为两个参数“可能是不同类型”就声明 <code>&lt;T, U&gt;</code>。如果实现不保留或约束它们的关系，直接写具体类型或 <code>unknown</code> 往往更清楚。</p>
<h2>五、<code>keyof</code> 与索引访问：让键和值保持一致</h2>
<p><code>keyof T</code> 会得到对象类型 <code>T</code> 的键联合；<code>T[K]</code> 则取得键 <code>K</code> 对应的值类型。两者组合可以写出类型安全的属性读取函数：</p>
<pre><code class="language-ts">function getProperty&lt;T, K extends keyof T&gt;(object: T, key: K): T[K] {
  return object[key]
}

const user = {
  id: 'u-1',
  name: 'Ada',
  active: true,
}

const userName = getProperty(user, 'name')
// string

const active = getProperty(user, 'active')
// boolean

// getProperty(user, 'email')
// 报错："email" 不是 user 的键
</code></pre>
<p>索引访问也可以直接拆取已有类型：</p>
<pre><code class="language-ts">interface Order {
  id: string
  status: 'pending' | 'paid' | 'cancelled'
  customer: {
    id: string
    email: string
  }
}

type OrderStatus = Order['status']
type Customer = Order['customer']
type CustomerEmail = Order['customer']['email']
type OrderSummary = Order['id' | 'status']
</code></pre>
<p>对于数组，使用 <code>number</code> 可以取得元素类型：</p>
<pre><code class="language-ts">const routes = [
  { path: '/', auth: false },
  { path: '/admin', auth: true },
]

type Route = (typeof routes)[number]
// { path: string; auth: boolean }
</code></pre>
<p>注意，带字符串索引签名的类型，其 <code>keyof</code> 可能包含 <code>string | number</code>，因为 JavaScript 对象的数字键最终也会转成字符串。</p>
<h2>六、类型位置中的 <code>typeof</code></h2>
<p>值空间里的 <code>typeof value</code> 返回运行时字符串；类型位置中的 <code>typeof value</code> 则提取某个值的静态类型：</p>
<pre><code class="language-ts">const defaultConfig = {
  retries: 3,
  mode: 'safe' as 'safe' | 'fast',
  features: ['cache', 'metrics'],
}

type AppConfig = typeof defaultConfig
</code></pre>
<p>它经常与 <code>as const</code>、索引访问和工具类型组合：</p>
<pre><code class="language-ts">const roles = ['admin', 'editor', 'viewer'] as const

type Role = (typeof roles)[number]
// "admin" | "editor" | "viewer"

function createSession(role: Role) {
  return { role, createdAt: new Date() }
}

type Session = ReturnType&lt;typeof createSession&gt;
</code></pre>
<p>类型位置的 <code>typeof</code> 只能查询标识符或属性访问等可命名值，不能直接执行函数：</p>
<pre><code class="language-ts">// type Bad = typeof createSession('admin')
// 应写为：
type Good = ReturnType&lt;typeof createSession&gt;
</code></pre>
<p>原则上，先定义运行时数据，再从数据提取类型，能减少值与类型维护两份名单的问题。</p>
<h2>七、映射类型：逐个转换对象属性</h2>
<p>映射类型遍历 <code>keyof</code> 得到的键，并为每个属性生成新类型：</p>
<pre><code class="language-ts">interface Profile {
  id: string
  displayName: string
  avatarUrl?: string
}

type Nullable&lt;T&gt; = {
  [K in keyof T]: T[K] | null
}

type NullableProfile = Nullable&lt;Profile&gt;
</code></pre>
<p>可以用 <code>+</code>、<code>-</code> 增删 <code>readonly</code> 和可选修饰符：</p>
<pre><code class="language-ts">type MutableRequired&lt;T&gt; = {
  -readonly [K in keyof T]-?: T[K]
}
</code></pre>
<p>也可以通过 <code>as</code> 重映射键名：</p>
<pre><code class="language-ts">type ChangeHandlers&lt;T&gt; = {
  [K in keyof T as `on${Capitalize&lt;string &amp; K&gt;}Change`]: (
    value: T[K],
  ) =&gt; void
}

type ProfileHandlers = ChangeHandlers&lt;Profile&gt;
// onIdChange、onDisplayNameChange、onAvatarUrlChange
</code></pre>
<p>映射类型适合表达机械、统一的属性变换。若新类型只有三四个稳定字段，直接写接口通常更易读，不必强行映射。</p>
<h2>八、条件类型：根据类型选择分支</h2>
<p>条件类型的形式类似三元表达式：</p>
<pre><code class="language-ts">type IdOf&lt;T&gt; = T extends { id: infer Id } ? Id : never

type UserId = IdOf&lt;{ id: string; name: string }&gt;
// string

type MissingId = IdOf&lt;{ name: string }&gt;
// never
</code></pre>
<p>当检查的是裸类型参数时，条件类型会对联合类型逐项分发：</p>
<pre><code class="language-ts">type ToArray&lt;T&gt; = T extends unknown ? T[] : never

type Distributed = ToArray&lt;string | number&gt;
// string[] | number[]
</code></pre>
<p>如果希望把联合整体判断，用方括号包住两侧：</p>
<pre><code class="language-ts">type ToArrayWhole&lt;T&gt; = [T] extends [unknown] ? T[] : never

type NotDistributed = ToArrayWhole&lt;string | number&gt;
// (string | number)[]
</code></pre>
<p>分发行为很强大，也很容易让结果与直觉不同。遇到复杂联合类型时，应先确认自己要“逐项转换”还是“整体判断”。</p>
<h2>九、<code>infer</code>：在条件类型中提取一部分</h2>
<p><code>infer</code> 只能出现在条件类型的 <code>extends</code> 分支中，用来为待匹配结构的一部分命名：</p>
<pre><code class="language-ts">type ElementOf&lt;T&gt; = T extends readonly (infer Item)[] ? Item : never

type Tag = ElementOf&lt;readonly ['news', 'tech']&gt;
// "news" | "tech"

type UnwrapPromise&lt;T&gt; = T extends PromiseLike&lt;infer Value&gt;
  ? UnwrapPromise&lt;Value&gt;
  : T

type Data = UnwrapPromise&lt;Promise&lt;Promise&lt;{ id: string }&gt;&gt;&gt;
// { id: string }
</code></pre>
<p>实际项目应优先使用标准库已提供的 <code>Awaited&lt;T&gt;</code>、<code>ReturnType&lt;T&gt;</code>、<code>Parameters&lt;T&gt;</code> 等工具，不必重复实现。自定义 <code>infer</code> 更适合提取项目特有结构，例如路由参数、事件载荷或响应包装中的数据。</p>
<h2>十、常用工具类型</h2>
<p>TypeScript 标准库已经用映射类型和条件类型实现了常见变换：</p>
<table>
 <thead>
  <tr>
   <th>工具类型</th>
   <th>作用</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>Partial&lt;T&gt;</code></td>
   <td>所有属性变为可选</td>
  </tr>
  <tr>
   <td><code>Required&lt;T&gt;</code></td>
   <td>所有属性变为必选</td>
  </tr>
  <tr>
   <td><code>Readonly&lt;T&gt;</code></td>
   <td>所有属性变为只读</td>
  </tr>
  <tr>
   <td><code>Pick&lt;T, K&gt;</code></td>
   <td>保留指定键</td>
  </tr>
  <tr>
   <td><code>Omit&lt;T, K&gt;</code></td>
   <td>排除指定键</td>
  </tr>
  <tr>
   <td><code>Record&lt;K, V&gt;</code></td>
   <td>由键联合生成对象</td>
  </tr>
  <tr>
   <td><code>Exclude&lt;U, M&gt;</code></td>
   <td>从联合中排除成员</td>
  </tr>
  <tr>
   <td><code>Extract&lt;U, M&gt;</code></td>
   <td>从联合中提取成员</td>
  </tr>
  <tr>
   <td><code>NonNullable&lt;T&gt;</code></td>
   <td>排除 <code>null</code> 与 <code>undefined</code></td>
  </tr>
  <tr>
   <td><code>Parameters&lt;F&gt;</code></td>
   <td>提取函数参数元组</td>
  </tr>
  <tr>
   <td><code>ReturnType&lt;F&gt;</code></td>
   <td>提取函数返回类型</td>
  </tr>
  <tr>
   <td><code>ConstructorParameters&lt;C&gt;</code></td>
   <td>提取构造参数元组</td>
  </tr>
  <tr>
   <td><code>InstanceType&lt;C&gt;</code></td>
   <td>提取构造器的实例类型</td>
  </tr>
  <tr>
   <td><code>Awaited&lt;T&gt;</code></td>
   <td>递归展开 Promise-like 类型</td>
  </tr>
 </tbody>
</table>
<p>一个更新接口可以组合 <code>Pick</code> 与 <code>Partial</code>：</p>
<pre><code class="language-ts">interface Article {
  id: string
  title: string
  body: string
  publishedAt: Date | null
}

type ArticlePatch = Partial&lt;Pick&lt;Article, 'title' | 'body'&gt;&gt;

function updateArticle(id: Article['id'], patch: ArticlePatch) {
  // 调用 API
}
</code></pre>
<p><code>Partial</code>、<code>Readonly</code> 等默认只转换第一层属性，并不会递归处理嵌套对象。需要深层版本时，应先确认业务语义，再谨慎自定义，避免产生昂贵而难懂的递归类型。</p>
<h2>十一、实战：安全地解析外部数据</h2>
<p>不要让无来源的泛型返回类型替代运行时检查。一个更可靠的边界是返回 <code>unknown</code>，再使用类型守卫：</p>
<pre><code class="language-ts">interface User {
  id: string
  name: string
}

function parseJson(text: string): unknown {
  return JSON.parse(text)
}

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false

  const record = value as Record&lt;string, unknown&gt;
  return typeof record.id === 'string' &amp;&amp; typeof record.name === 'string'
}

const value = parseJson('{"id":"u-1","name":"Ada"}')

if (isUser(value)) {
  console.log(value.name)
}
</code></pre>
<p>如果项目使用 Zod、Valibot 等 schema 库，泛型应连接“schema 输入”和“验证后的输出”，而不是允许调用者任意指定结果：</p>
<pre><code class="language-ts">interface Parser&lt;T&gt; {
  parse(value: unknown): T
}

function parseWith&lt;T&gt;(text: string, parser: Parser&lt;T&gt;): T {
  return parser.parse(JSON.parse(text))
}
</code></pre>
<p>此时 <code>T</code> 由实际解析器决定，类型关系有运行时行为支撑。</p>
<h2>十二、常见陷阱</h2>
<ol>
 <li><strong>类型参数只出现一次</strong>：它没有表达关系，优先改成具体类型或 <code>unknown</code>。</li>
 <li><strong>调用者任意指定返回类型</strong>：<code>parse&lt;T&gt;()</code>、<code>request&lt;T&gt;()</code> 本身不提供验证，外部数据仍然不可信。</li>
 <li><strong>约束写得过宽</strong>：<code>T extends object</code> 几乎没有提供可用信息，应约束真正需要的属性。</li>
 <li><strong>类型参数过多</strong>：每增加一个参数，调用和错误信息都会更复杂；只保留会连接两个以上位置的参数。</li>
 <li><strong>条件类型意外分发</strong>：联合类型结果不符合预期时，检查是否需要 <code>[T] extends [U]</code>。</li>
 <li><strong>深层递归工具滥用</strong>：复杂递归可能降低编辑器性能，也可能抹平可选、只读等真实业务边界。</li>
 <li><strong>把类型运算当运行时逻辑</strong>：<code>keyof</code>、条件类型和工具类型编译后都会消失，不能替代数据校验。</li>
</ol>
<h2>小结</h2>
<p>泛型首先用于表达关系，类型推断则让调用代码保持简洁。<code>keyof</code> 和索引访问把键与值连接起来，<code>typeof</code> 从运行时声明提取静态类型，映射类型批量转换属性，条件类型与 <code>infer</code> 根据结构选择和提取类型。标准工具类型已经覆盖大部分常见需求。</p>
<p>判断一个泛型 API 是否合理，可以问三个问题：类型参数是否连接了两个以上位置？约束是否反映实现真正使用的能力？外部数据是否仍有运行时验证？如果答案是否定的，简单、具体的类型往往更安全。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/generics.html">Generics</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/keyof-types.html">Keyof Type Operator</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/typeof-types.html">Typeof Type Operator</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html">Indexed Access Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/mapped-types.html">Mapped Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/conditional-types.html">Conditional Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/utility-types.html">Utility Types</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">返回目录</a> · <a href="https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces">上一篇</a> · <a href="https://likeyy.love/archives/typescript-modules-resolution-declaration-files">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-generics-keyof-mapped-utility-types</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FE04CC107-CE90-4AB5-9226-6E80D8ACBB8A.png&amp;size=m" type="image/jpeg" length="1627105"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:12 GMT</pubDate></item><item><title><![CDATA[TypeScript 类设计：修饰符、抽象类与接口契约]]></title><link>https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E7%B1%BB%E8%AE%BE%E8%AE%A1%EF%BC%9A%E4%BF%AE%E9%A5%B0%E7%AC%A6%E3%80%81%E6%8A%BD%E8%B1%A1%E7%B1%BB%E4%B8%8E%E6%8E%A5%E5%8F%A3%E5%A5%91%E7%BA%A6&amp;url=/archives/typescript-classes-modifiers-abstract-interfaces" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 类设计：修饰符、抽象类与接口契约</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum">类型组合</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types">泛型与类型运算</a></p>
</blockquote>
<p>JavaScript 的 <code>class</code> 提供构造器、继承和真正的私有字段，TypeScript 7.0.2 在此基础上增加参数属性、可见性修饰符、<code>readonly</code>、<code>implements</code>、抽象类和 <code>override</code> 检查。类最适合封装具有状态、行为和生命周期的对象；接口则描述调用方真正需要的能力。掌握两者的边界，才能避免把类型契约误当成运行时保护，也能避免为了复用几行代码建立脆弱的继承层级。</p>
<h2>一、类同时带来运行时值和实例类型</h2>
<p>类声明既会生成运行时可调用的构造器，也会创建实例侧类型：</p>
<pre><code class="language-ts">class Counter {
  count = 0

  increment(step = 1): number {
    this.count += step
    return this.count
  }
}

const counter: Counter = new Counter()
counter.increment()
</code></pre>
<p>与 <code>interface</code>、<code>type</code> 不同，<code>Counter</code> 在运行时真实存在，因此可以被 <code>new</code>、用于 <code>instanceof</code>，也可以拥有静态成员：</p>
<pre><code class="language-ts">class User {
  static guestName = '访客'

  constructor(public name: string) {}
}

console.log(User.guestName)
console.log(new User('小林') instanceof User)
</code></pre>
<p>类名出现在类型位置时默认表示实例侧；<code>typeof User</code> 才表示包含构造器和静态成员的类本身。</p>
<h2>二、参数属性减少重复声明</h2>
<p>普通写法需要先声明字段，再在构造器中赋值：</p>
<pre><code class="language-ts">class Article {
  id: string
  title: string

  constructor(id: string, title: string) {
    this.id = id
    this.title = title
  }
}
</code></pre>
<p>给构造器参数添加 <code>public</code>、<code>protected</code>、<code>private</code> 或 <code>readonly</code> 后，参数会同时成为实例属性：</p>
<pre><code class="language-ts">class Article {
  constructor(
    public readonly id: string,
    public title: string,
  ) {}

  rename(title: string) {
    this.title = title
  }
}
</code></pre>
<p>参数属性适合简单的数据承载类，但它会在构造器签名中同时声明字段。参数很多、初始化需要校验，或希望字段定义一眼可见时，显式声明往往更易读。</p>
<h2>三、<code>public</code>、<code>protected</code> 与 TypeScript <code>private</code></h2>
<p>TypeScript 提供三种访问级别：</p>
<table>
 <thead>
  <tr>
   <th>修饰符</th>
   <th style="text-align:center">类内部</th>
   <th style="text-align:center">子类</th>
   <th style="text-align:center">类外部</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>public</code></td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">✓</td>
  </tr>
  <tr>
   <td><code>protected</code></td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">—</td>
  </tr>
  <tr>
   <td><code>private</code></td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">—</td>
   <td style="text-align:center">—</td>
  </tr>
 </tbody>
</table>
<p>未写修饰符的类成员默认是 <code>public</code>：</p>
<pre><code class="language-ts">class Account {
  constructor(
    public displayName: string,
    protected accountId: string,
    private balance: number,
  ) {}

  public getBalance(): number {
    return this.balance
  }

  protected auditMessage(): string {
    return `${this.accountId}: ${this.balance}`
  }
}

class BusinessAccount extends Account {
  printAudit() {
    console.log(this.auditMessage())
    // console.log(this.balance) // 错误：private 仅当前类可见
  }
}
</code></pre>
<p>这些 TypeScript 修饰符主要在编译期限制访问。普通 <code>private balance</code> 编译成 JavaScript 后通常仍是常规属性，不能把它当作抵御恶意运行时代码的安全边界。</p>
<p><code>private</code> 和 <code>protected</code> 还会影响类类型之间的结构兼容：若两个类各自声明了私有或受保护成员，即使公开结构相同，也通常要求这些成员源自同一个类声明。这个规则让带私有状态的类具有一部分名义类型特征。</p>
<h2>四、JavaScript <code>#private</code> 是运行时私有字段</h2>
<p>需要由 JavaScript 引擎强制隔离的状态时，使用 <code>#</code> 私有字段：</p>
<pre><code class="language-ts">class TokenVault {
  #token: string

  constructor(token: string) {
    this.#token = token
  }

  matches(token: string): boolean {
    return this.#token === token
  }
}

const vault = new TokenVault('secret')
vault.matches('secret')
// vault.#token // 语法错误：类外不可访问
</code></pre>
<p><code>#token</code> 不只是 TypeScript 检查，它具有 JavaScript 规定的运行时私有语义，也不能通过 <code>vault['#token']</code> 读取。它与 <code>private token</code> 不是两种拼写风格，而是不同的运行时模型。</p>
<p>如何选择：</p>
<ul>
 <li>只需要 TypeScript 项目中的 API 可见性约束，可以使用 <code>private</code>。</li>
 <li>需要运行时隐私，或对象可能被普通 JavaScript 代码接触，使用 <code>#private</code>。</li>
 <li>子类需要访问时使用 <code>protected</code>；<code>#private</code> 不能被子类直接访问，可通过受控的 <code>protected</code> 方法暴露必要行为。</li>
</ul>
<h2>五、<code>readonly</code> 约束初始化后的重新赋值</h2>
<p>类字段可以在声明处或构造器中初始化只读值，之后不能重新赋值：</p>
<pre><code class="language-ts">class Session {
  readonly createdAt = new Date()

  constructor(
    public readonly id: string,
    public user: { name: string },
  ) {}

  renameUser(name: string) {
    this.user.name = name
  }
}

const session = new Session('s_001', { name: '小林' })
// session.id = 's_002' // 错误
session.user.name = '林同学' // 允许：readonly 没有递归到对象内部
</code></pre>
<p>类中的 <code>readonly</code> 仍是 TypeScript 编译期、浅层约束。需要深层不可变数据时，应设计只读嵌套类型并选择合适的运行时冻结策略，而不是只给最外层字段加一个修饰符。</p>
<h2>六、<code>implements</code> 检查实例契约</h2>
<p>接口可以规定类实例必须提供哪些能力：</p>
<pre><code class="language-ts">interface Serializable {
  serialize(): string
}

interface Renamable {
  rename(name: string): void
}

class Project implements Serializable, Renamable {
  constructor(public name: string) {}

  rename(name: string): void {
    this.name = name
  }

  serialize(): string {
    return JSON.stringify({ name: this.name })
  }
}
</code></pre>
<p>一个类可以实现多个接口。<code>implements</code> 只执行类型检查，不会复制实现，不会在运行时留下接口对象，也不会改变类本身的类型。</p>
<p>它也不会替类体补全类型推断。方法参数和返回值仍应显式、准确地声明，尤其是在 <code>noImplicitAny</code> 下：</p>
<pre><code class="language-ts">interface Formatter {
  format(value: number): string
}

class PriceFormatter implements Formatter {
  format(value: number): string {
    return `¥${value.toFixed(2)}`
  }
}
</code></pre>
<p><code>implements</code> 默认检查实例侧。若 API 接收的是“类构造器”而不是实例，应单独声明构造签名：</p>
<pre><code class="language-ts">interface Task {
  run(): Promise&lt;void&gt;
}

type TaskConstructor = new (id: string) =&gt; Task

function createTask(TaskClass: TaskConstructor, id: string): Task {
  return new TaskClass(id)
}
</code></pre>
<h2>七、抽象类共享状态和基础实现</h2>
<p>抽象类不能直接实例化，但可以包含字段、构造器、已实现方法和要求子类实现的抽象成员：</p>
<pre><code class="language-ts">abstract class Job {
  constructor(public readonly id: string) {}

  abstract run(): Promise&lt;void&gt;

  describe(): string {
    return `job:${this.id}`
  }
}

class EmailJob extends Job {
  override async run(): Promise&lt;void&gt; {
    console.log(`发送邮件任务 ${this.id}`)
  }
}
</code></pre>
<p>接口只描述结构，抽象类则能够共享实现和受保护状态。可以用下面的判断辅助选择：</p>
<table>
 <thead>
  <tr>
   <th>需求</th>
   <th style="text-align:center">接口</th>
   <th style="text-align:center">抽象类</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>只描述能力，不提供实现</td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">可行但偏重</td>
  </tr>
  <tr>
   <td>一个类需要遵守多个契约</td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">类只能继承一个基类</td>
  </tr>
  <tr>
   <td>共享构造流程、状态或默认实现</td>
   <td style="text-align:center">—</td>
   <td style="text-align:center">✓</td>
  </tr>
  <tr>
   <td>希望普通对象也可满足契约</td>
   <td style="text-align:center">✓</td>
   <td style="text-align:center">通常不合适</td>
  </tr>
  <tr>
   <td>运行时需要基类构造器或 <code>instanceof</code></td>
   <td style="text-align:center">—</td>
   <td style="text-align:center">✓</td>
  </tr>
 </tbody>
</table>
<p>抽象类适合确实存在“是一个”关系、子类可安全替代基类，并且共享实现具有稳定语义的场景。仅仅因为两个类碰巧有几行相同代码，不足以建立继承关系。</p>
<h2>八、用 <code>override</code> 和 <code>noImplicitOverride</code> 防止静默失配</h2>
<p>派生类重写基类成员时，<code>override</code> 明确表达意图：</p>
<pre><code class="language-ts">class ConsoleJob extends Job {
  override async run(): Promise&lt;void&gt; {
    console.log(this.describe())
  }
}
</code></pre>
<p>如果基类中不存在对应成员，<code>override</code> 会报错。这能捕获基类重命名后，子类方法悄悄变成无关新方法的问题。</p>
<p>建议在项目中开启 <code>noImplicitOverride</code>：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "strict": true,
    "noImplicitOverride": true
  }
}
</code></pre>
<p>开启后，只要派生类成员覆盖了基类成员，就必须显式写 <code>override</code>。它不会阻止所有继承设计错误，但能让重写关系在代码审查和重构时清晰可见。</p>
<h2>九、优先组合，谨慎继承</h2>
<p>组合让对象依赖一个小契约，而不是继承完整实现：</p>
<pre><code class="language-ts">interface MessageSender {
  send(message: string): Promise&lt;void&gt;
}

class NotificationService {
  constructor(private readonly sender: MessageSender) {}

  async welcome(name: string): Promise&lt;void&gt; {
    await this.sender.send(`欢迎，${name}`)
  }
}

const service = new NotificationService({
  async send(message) {
    console.log(message)
  },
})
</code></pre>
<p><code>NotificationService</code> 不关心消息由邮件、短信还是测试替身发送。调用方只要满足 <code>MessageSender</code> 的结构即可，替换实现和单元测试都很直接。</p>
<p>继承更适合以下情况：</p>
<ul>
 <li>子类在语义上确实是基类的一种，并能替代基类。</li>
 <li>基类维护稳定的不变量或生命周期模板。</li>
 <li>子类需要共享受保护状态或默认实现，而不只是复用工具函数。</li>
</ul>
<p>出现多层继承、子类频繁覆盖父类细节、为了复用而继承不相关类时，应重新评估组合、委托或普通函数是否更简单。</p>
<h2>十、结构契约让类与普通对象协作</h2>
<p>接口描述的是能力，而不是来源。类实例和对象字面量都可以满足同一个契约：</p>
<pre><code class="language-ts">interface Runnable {
  run(): Promise&lt;void&gt;
}

async function execute(task: Runnable): Promise&lt;void&gt; {
  await task.run()
}

execute(new EmailJob('email_001'))

execute({
  async run() {
    console.log('运行一次性任务')
  },
})
</code></pre>
<p>这种结构化兼容让函数依赖最小公开能力，而不必要求调用方继承某个基类。它也说明 <code>implements</code> 不是使用接口的必要条件：显式写 <code>implements</code> 有助于在类声明处尽早验证设计，但任何结构兼容的值都能交给 <code>Runnable</code>。</p>
<p>私有成员是重要例外。带 TypeScript <code>private</code>、<code>protected</code> 或 JavaScript <code>#private</code> 的类兼容性会受到成员来源约束，不能只比较公开字段后就认为任意两个类可互换。</p>
<h2>十一、实战：接口负责边界，抽象类负责流程</h2>
<p>下面让接口定义可替换的持久化能力，抽象类复用任务执行流程：</p>
<pre><code class="language-ts">interface JobStore {
  save(id: string, status: 'done' | 'failed'): Promise&lt;void&gt;
}

abstract class PersistedJob {
  constructor(
    public readonly id: string,
    private readonly store: JobStore,
  ) {}

  protected abstract perform(): Promise&lt;void&gt;

  async execute(): Promise&lt;void&gt; {
    try {
      await this.perform()
      await this.store.save(this.id, 'done')
    } catch (error: unknown) {
      await this.store.save(this.id, 'failed')
      throw error
    }
  }
}

class ReportJob extends PersistedJob {
  protected override async perform(): Promise&lt;void&gt; {
    console.log(`生成报表 ${this.id}`)
  }
}

const memoryStore: JobStore = {
  async save(id, status) {
    console.log(id, status)
  },
}

await new ReportJob('report_001', memoryStore).execute()
</code></pre>
<p><code>JobStore</code> 是结构契约，测试时可以传入普通对象；<code>PersistedJob</code> 则封装成功与失败都要持久化的稳定流程。子类只实现变化点 <code>perform()</code>，并通过 <code>override</code> 明确重写关系。</p>
<h2>十二、常见陷阱</h2>
<h3>1. 把 TypeScript <code>private</code> 当作安全机制</h3>
<p>它主要阻止类型检查通过，运行时强隔离应使用 JavaScript <code>#private</code>。无论哪一种，都不应在前端对象中保存真正的服务端秘密。</p>
<h3>2. 误以为 <code>implements</code> 会生成或继承代码</h3>
<p>接口会在编译后擦除。<code>implements</code> 只检查实例结构，默认不检查静态侧，也不会替方法参数提供实现或运行时校验。</p>
<h3>3. 对接口使用 <code>instanceof</code></h3>
<p>接口没有运行时值，不能写 <code>value instanceof SomeInterface</code>。应检查真实类、判别字段或用类型守卫验证结构。</p>
<h3>4. 误以为 <code>readonly</code> 是深层且运行时不可变</h3>
<p>类字段的 <code>readonly</code> 仍然是浅层类型约束。嵌套对象可能可变，编译后的 JavaScript 也不会自动冻结字段。</p>
<h3>5. 省略重写意图</h3>
<p>未使用 <code>override</code> 时，基类改名可能让派生类静默失去覆盖关系。开启 <code>noImplicitOverride</code>，让编译器协助重构。</p>
<h3>6. 为代码复用建立过深继承树</h3>
<p>继承会把子类绑定到基类状态、生命周期和变化方向。优先依赖小接口并使用组合；只有稳定的“是一个”关系才值得继承。</p>
<h2>小结</h2>
<p>TypeScript 类既是运行时构造器，也是实例类型。参数属性能减少样板，<code>public</code>、<code>protected</code>、<code>private</code> 管理编译期可见性，JavaScript <code>#private</code> 提供运行时私有语义，<code>readonly</code> 则表达初始化后的浅层不可重新赋值。用 <code>implements</code> 在类声明处检查结构契约，用抽象类共享稳定流程和实现，并通过 <code>override</code> 与 <code>noImplicitOverride</code> 保护继承重构。默认依赖最小结构接口并优先组合，能让类、普通对象和测试替身在同一契约下自然协作。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/classes.html">Classes</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/classes.html#class-heritage">Class Heritage：<code>implements</code> 与 <code>extends</code></a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/classes.html#member-visibility">Member Visibility</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/noImplicitOverride.html"><code>override</code> 与 <code>noImplicitOverride</code></a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/type-compatibility.html">Type Compatibility</a></li>
 <li><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Classes/Private_elements">MDN：Private Elements</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum">上一篇</a> · <a href="https://likeyy.love/archives/typescript-generics-keyof-mapped-utility-types">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-classes-modifiers-abstract-interfaces</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FE219AFD8-3B8C-4A22-BC87-AABC52E440E8.png&amp;size=m" type="image/jpeg" length="1702533"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:11 GMT</pubDate></item><item><title><![CDATA[TypeScript 类型组合：联合、交叉、type、interface 与 enum]]></title><link>https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E7%B1%BB%E5%9E%8B%E7%BB%84%E5%90%88%EF%BC%9A%E8%81%94%E5%90%88%E3%80%81%E4%BA%A4%E5%8F%89%E3%80%81type%E3%80%81interface%20%E4%B8%8E%20enum&amp;url=/archives/typescript-unions-intersections-type-interface-enum" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 类型组合：联合、交叉、type、interface 与 enum</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/typescript-object-types-index-signatures-readonly">对象建模</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces">类与契约</a></p>
</blockquote>
<p>真实业务类型很少彼此孤立：状态由几个固定值组成，事件有多种形态，对象需要复用基础字段，常量还可能同时服务于类型检查和运行时代码。TypeScript 7.0.2 提供字面量、联合、交叉、类型别名、接口和枚举等工具。它们不是互相取代的语法，而是在回答不同问题：值可以是哪一种、对象必须同时具备什么、契约是否需要开放扩展，以及运行时是否需要一个真实对象。</p>
<h2>一、字面量类型与类型拓宽</h2>
<p>字面量类型把值本身当作类型：</p>
<pre><code class="language-ts">type Theme = 'light' | 'dark'
type RetryCount = 0 | 1 | 2 | 3

type Enabled = true
</code></pre>
<p><code>const</code> 变量通常会保留原始值的字面量类型，而 <code>let</code> 变量需要为后续赋值留出空间，因此通常会拓宽：</p>
<pre><code class="language-ts">const fixedMode = 'production' // 类型为 'production'
let currentMode = 'production' // 类型通常为 string

currentMode = 'development'
</code></pre>
<p>对象属性默认仍被认为可能修改，所以即使对象变量使用 <code>const</code>，属性也可能被拓宽：</p>
<pre><code class="language-ts">const config = {
  mode: 'production', // 通常推断为 string
  retry: 3,           // 通常推断为 number
}
</code></pre>
<p>如果这组数据就是不会变化的常量表，可使用 <code>as const</code>：</p>
<pre><code class="language-ts">const config = {
  mode: 'production',
  retry: 3,
} as const

// mode 为 'production'，retry 为 3，属性同时变为 readonly
</code></pre>
<p><code>as const</code> 适合保留整段字面量表达式的具体值，不应拿来替代业务校验。需要同时验证形状时，可以配合 <code>satisfies</code>：</p>
<pre><code class="language-ts">const routes = {
  home: '/',
  settings: '/settings',
} as const satisfies Record&lt;string, `/${string}`&gt;
</code></pre>
<h2>二、联合类型：值可以是多种形态之一</h2>
<p>联合类型使用 <code>|</code> 表示“或”：</p>
<pre><code class="language-ts">type Id = string | number

function normalizeId(id: Id): string {
  return typeof id === 'number' ? String(id) : id.trim()
}
</code></pre>
<p>使用联合值时，只能直接访问所有成员都支持的能力。通过 <code>typeof</code>、<code>in</code>、相等性判断或自定义类型守卫收窄后，才能使用某个分支特有的成员。</p>
<p>对象联合最实用的形式是可辨识联合：每个成员共享一个字面量判别字段。</p>
<pre><code class="language-ts">type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string[] }
  | { status: 'error'; message: string }

function stateText(state: RequestState): string {
  switch (state.status) {
    case 'idle':
      return '尚未请求'
    case 'loading':
      return '加载中'
    case 'success':
      return `共 ${state.data.length} 条`
    case 'error':
      return state.message
  }
}
</code></pre>
<p>相比把 <code>data?</code>、<code>message?</code> 全部堆在一个接口里，可辨识联合能排除“成功却没有数据”“空闲却带错误消息”等非法组合。需要在分支增加时获得编译提醒，还可以结合 <code>never</code> 做穷尽检查。</p>
<h2>三、交叉类型：同时满足多个契约</h2>
<p>交叉类型使用 <code>&amp;</code> 表示“并且”，常用于组合对象能力：</p>
<pre><code class="language-ts">type Timestamped = {
  createdAt: Date
  updatedAt: Date
}

type Article = {
  id: string
  title: string
}

type StoredArticle = Article &amp; Timestamped

const article: StoredArticle = {
  id: 'a_001',
  title: '类型组合',
  createdAt: new Date(),
  updatedAt: new Date(),
}
</code></pre>
<p>交叉发生在类型层面，不会在运行时自动合并两个对象。创建值时仍需要实际提供全部成员，或者显式执行对象合并：</p>
<pre><code class="language-ts">const stored = {
  ...article,
  updatedAt: new Date(),
}
</code></pre>
<p>相同属性的类型如果互不兼容，交叉结果可能无法构造：</p>
<pre><code class="language-ts">type StringId = { id: string }
type NumberId = { id: number }
type Impossible = StringId &amp; NumberId

// Impossible['id'] 是 never
</code></pre>
<p>因此，交叉不是解决字段冲突的覆盖操作。组合前应确认同名成员兼容，或使用 <code>Omit</code> 等工具先明确移除旧字段。</p>
<h2>四、类型别名：为任意类型命名</h2>
<p><code>type</code> 可以给原始类型、联合、元组、函数、对象和类型运算结果命名：</p>
<pre><code class="language-ts">type UserId = string
type Coordinates = readonly [x: number, y: number]
type SaveHandler = (content: string) =&gt; Promise&lt;void&gt;

type ApiResult&lt;T&gt; =
  | { ok: true; data: T }
  | { ok: false; error: string }
</code></pre>
<p>类型别名不会创建新的运行时值，也不会天然创建名义类型。<code>type UserId = string</code> 仍然与普通 <code>string</code> 兼容。</p>
<p>类型别名当然可以复用和扩展已有对象类型，通常通过交叉类型完成：</p>
<pre><code class="language-ts">type Named = {
  name: string
}

type Employee = Named &amp; {
  employeeId: string
}
</code></pre>
<p>因此，不要把差异概括为“<code>type</code> 不能扩展”。更准确的说法是：<code>interface</code> 使用 <code>extends</code> 声明对象契约的继承，类型别名通常使用 <code>&amp;</code> 组合类型；此外，条件类型和泛型约束中也会使用 <code>extends</code> 关键字。</p>
<h2>五、接口：可继承、可开放合并的对象契约</h2>
<p><code>interface</code> 主要描述对象、函数和类实例的结构：</p>
<pre><code class="language-ts">interface Named {
  name: string
}

interface User extends Named {
  id: string
  login(): Promise&lt;void&gt;
}
</code></pre>
<p>接口可以继承接口，也可以继承成员静态已知的对象类型别名：</p>
<pre><code class="language-ts">type Identified = {
  id: string
}

interface Admin extends Identified {
  permissions: string[]
}
</code></pre>
<p>反过来，类型别名也能与接口组合：</p>
<pre><code class="language-ts">type AuditedAdmin = Admin &amp; {
  updatedAt: Date
}
</code></pre>
<p>接口最独特的能力是声明合并。同一作用域内的同名接口声明会合为一个契约：</p>
<pre><code class="language-ts">interface PluginContext {
  appName: string
}

interface PluginContext {
  log(message: string): void
}

const context: PluginContext = {
  appName: 'Blog',
  log(message) {
    console.log(message)
  },
}
</code></pre>
<p>声明合并常用于扩展全局对象或第三方库类型，但在普通业务代码中也可能因重名而意外扩大契约。类型别名不能用重复声明来合并；需要扩展时应创建一个新名称并显式组合。</p>
<h2>六、<code>type</code> 还是 <code>interface</code></h2>
<p>两者都能描述普通对象，大多数时候团队一致性比语法偏好更重要。可以按能力选择：</p>
<table>
 <thead>
  <tr>
   <th>需求</th>
   <th>更自然的选择</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>联合、元组、函数组合、条件类型、映射类型</td>
   <td><code>type</code></td>
  </tr>
  <tr>
   <td>面向对象公开契约，希望使用 <code>extends</code></td>
   <td><code>interface</code></td>
  </tr>
  <tr>
   <td>需要声明合并或第三方模块扩展</td>
   <td><code>interface</code></td>
  </tr>
  <tr>
   <td>从多个已有类型组合出新对象</td>
   <td><code>type</code> + <code>&amp;</code>，或 <code>interface extends</code></td>
  </tr>
  <tr>
   <td>普通封闭业务对象</td>
   <td>两者都可以，遵循项目约定</td>
  </tr>
 </tbody>
</table>
<p>接口的报错和编辑器展示有时更接近原始声明，类型别名则更擅长表达类型运算。这不是绝对的性能或能力高下，也没有必要为了统一而把所有类型机械改成同一种写法。</p>
<h2>七、<code>enum</code> 同时存在于类型和运行时</h2>
<p>普通 <code>enum</code> 与多数 TypeScript 类型不同：它通常会生成一个真实的 JavaScript 对象。</p>
<pre><code class="language-ts">enum LogLevel {
  Info = 'info',
  Warning = 'warning',
  Error = 'error',
}

function writeLog(level: LogLevel, message: string) {
  console.log(level, message)
}

writeLog(LogLevel.Info, '应用启动')
console.log(Object.values(LogLevel))
</code></pre>
<p>这个运行时对象在以下场景有实际价值：</p>
<ul>
 <li>现有库、生成代码或 <code>.d.ts</code> 已经把参数声明为某个枚举。</li>
 <li>需要在运行时枚举成员，且希望类型和值共用同一命名空间。</li>
 <li>需要与明确采用数字或字符串枚举的协议、SDK 互操作。</li>
</ul>
<p>代价是编译产物中会多出枚举初始化代码。数字枚举还会生成反向映射，运行时对象比字符串枚举更复杂。这个开销通常不大，但它说明 <code>enum</code> 不是纯类型别名；是否采用应由运行时和互操作需求决定。</p>
<p><code>const enum</code> 可以把成员引用内联，但跨包发布、版本错配、声明文件消费以及 <code>isolatedModules</code> 工具链可能带来限制。应用内部若没有统一编译边界，不要只为少量体积收益贸然使用它。</p>
<h2>八、应用常量优先考虑 <code>as const</code> 对象</h2>
<p>很多前端应用只需要一组可导入、可遍历的运行时常量，以及由这些值生成的联合类型。此时 <code>as const</code> 对象更直接：</p>
<pre><code class="language-ts">export const OrderStatus = {
  Pending: 'pending',
  Paid: 'paid',
  Cancelled: 'cancelled',
} as const

export type OrderStatus =
  (typeof OrderStatus)[keyof typeof OrderStatus]

function updateStatus(status: OrderStatus) {
  console.log(status)
}

updateStatus(OrderStatus.Paid)
</code></pre>
<p>它生成的 JavaScript 就是普通对象，容易与 JavaScript 项目、JSON 值和构建工具互操作。如果只需要类型、不需要运行时容器，直接使用字面量联合更简单：</p>
<pre><code class="language-ts">type OrderStatus = 'pending' | 'paid' | 'cancelled'
</code></pre>
<p>推荐 <code>as const</code> 对象不等于“永远禁用枚举”。面对已有枚举 API 时继续使用对应 <code>enum</code> 最清晰；应用自身的新常量则优先从普通 JavaScript 数据结构出发。</p>
<h2>九、实战：组合一个类型安全的动作模型</h2>
<p>下面用 <code>as const</code> 对象提供运行时动作名，用可辨识联合描述不同负载：</p>
<pre><code class="language-ts">const ActionType = {
  Add: 'todo/add',
  Toggle: 'todo/toggle',
} as const

type Action =
  | {
      type: typeof ActionType.Add
      payload: { text: string }
    }
  | {
      type: typeof ActionType.Toggle
      payload: { id: string }
    }

interface Todo {
  id: string
  text: string
  done: boolean
}

type TodoState = {
  items: Todo[]
} &amp; {
  updatedAt: Date
}

function reduce(state: TodoState, action: Action): TodoState {
  switch (action.type) {
    case ActionType.Add:
      return {
        items: [
          ...state.items,
          { id: crypto.randomUUID(), text: action.payload.text, done: false },
        ],
        updatedAt: new Date(),
      }
    case ActionType.Toggle:
      return {
        items: state.items.map((todo) =&gt;
          todo.id === action.payload.id
            ? { ...todo, done: !todo.done }
            : todo,
        ),
        updatedAt: new Date(),
      }
  }
}
</code></pre>
<p>动作名在运行时可用，<code>Action</code> 则保证每种动作携带正确负载。<code>TodoState</code> 展示了交叉类型的组合能力，但若状态结构本来就只在一个位置定义，直接写成单个接口也完全合理。</p>
<h2>十、常见陷阱</h2>
<h3>1. 误以为联合值拥有所有成员的属性</h3>
<p>联合表示“当前是其中一种”，不是“同时是全部”。先根据判别字段或运行时检查收窄。</p>
<h3>2. 把交叉类型当成对象覆盖</h3>
<p><code>A &amp; B</code> 不执行对象展开，也不会让后一个同名属性覆盖前一个。冲突属性可能变成 <code>never</code>。</p>
<h3>3. 宣称 <code>type</code> 不能扩展</h3>
<p>类型别名可以通过交叉类型复用和扩展，接口也可以继承合适的对象类型别名。真正的差异是组合语法和声明合并能力。</p>
<h3>4. 无意触发接口声明合并</h3>
<p>公共接口扩展很有用，业务作用域中的重复命名却可能隐藏错误。对不希望开放的模型使用唯一名称，并让模块边界清晰。</p>
<h3>5. 为所有常量默认使用 <code>enum</code></h3>
<p>普通枚举会进入运行时产物。应用级字符串常量通常可用字面量联合或 <code>as const</code> 对象；已有枚举协议和需要运行时枚举对象的场景仍适合 <code>enum</code>。</p>
<h3>6. 用 <code>as const</code> 假装验证了外部数据</h3>
<p><code>as const</code> 只改变编译器对字面量表达式的推断，不会验证网络响应，也不会运行时冻结深层对象。</p>
<h2>小结</h2>
<p>字面量类型描述精确值，联合表达多选一，交叉表达同时满足；<code>type</code> 能为任意类型运算命名，<code>interface</code> 擅长可继承、可合并的对象契约。两者都能扩展已有结构，只是语法与开放性不同。普通 <code>enum</code> 有真实运行时对象，适合互操作和确实需要枚举值对象的场景；多数应用内部常量可以优先使用字面量联合或 <code>as const</code> 对象，让类型自然来源于普通 JavaScript 数据。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html">Everyday Types：Literal Types 与 Unions</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions">Narrowing：Discriminated Unions</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/objects.html">Object Types：Intersection Types 与 Interfaces</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/enums.html">Enums</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/declaration-merging.html">Declaration Merging</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-object-types-index-signatures-readonly">上一篇</a> · <a href="https://likeyy.love/archives/typescript-classes-modifiers-abstract-interfaces">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-unions-intersections-type-interface-enum</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F662A2255-31C0-4D24-8B3C-F23027116DD4.png&amp;size=m" type="image/jpeg" length="1674313"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:11 GMT</pubDate></item><item><title><![CDATA[TypeScript 对象建模：对象类型、索引签名与只读数据]]></title><link>https://likeyy.love/archives/typescript-object-types-index-signatures-readonly</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E5%AF%B9%E8%B1%A1%E5%BB%BA%E6%A8%A1%EF%BC%9A%E5%AF%B9%E8%B1%A1%E7%B1%BB%E5%9E%8B%E3%80%81%E7%B4%A2%E5%BC%95%E7%AD%BE%E5%90%8D%E4%B8%8E%E5%8F%AA%E8%AF%BB%E6%95%B0%E6%8D%AE&amp;url=/archives/typescript-object-types-index-signatures-readonly" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 对象建模：对象类型、索引签名与只读数据</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness">类型收窄与安全边界</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum">类型组合</a></p>
</blockquote>
<p>对象是 JavaScript 应用最常见的数据载体：组件参数、接口响应、配置项和领域模型最终都要落到属性上。TypeScript 7.0.2 能从对象字面量推断结构，也允许我们用可选属性、只读属性、索引签名和 <code>Record</code> 明确约束。真正重要的不是把每个对象写得越复杂越好，而是准确表达“哪些属性固定、哪些键动态、谁可以修改数据”。</p>
<h2>一、从对象字面量开始理解类型推断</h2>
<p>TypeScript 会根据初始化值推断对象的属性名和属性类型：</p>
<pre><code class="language-ts">const draft = {
  title: 'TypeScript 对象建模',
  published: false,
  views: 0,
}

// 推断为 string、boolean 和 number
draft.title = '对象类型实战'
draft.published = true
</code></pre>
<p>这里的 <code>const</code> 只禁止给变量 <code>draft</code> 重新赋值，并不会让对象属性自动变成只读，也不会把 <code>title</code> 永远限制为最初的字符串字面量。</p>
<p>当对象是公开 API、函数返回值或领域数据时，显式声明契约通常更清晰：</p>
<pre><code class="language-ts">interface ArticleDraft {
  title: string
  published: boolean
  tags: string[]
}

const draft: ArticleDraft = {
  title: 'TypeScript 对象建模',
  published: false,
  tags: ['TypeScript'],
}
</code></pre>
<p>如果希望检查对象是否满足某个类型，同时尽量保留表达式自身的推断结果，可以使用 <code>satisfies</code>：</p>
<pre><code class="language-ts">interface BuildOptions {
  mode: 'development' | 'production'
  minify: boolean
}

const options = {
  mode: 'production',
  minify: true,
} satisfies BuildOptions

// options.mode 保留为更具体的字面量类型 'production'
</code></pre>
<p>类型注解强调“这个变量按某个类型使用”，<code>satisfies</code> 强调“检查这个表达式满足某个类型”。两者都比无依据的类型断言更可靠。</p>
<h2>二、必选、可选与只读属性</h2>
<p>对象类型默认要求声明过的属性全部存在。属性名后的 <code>?</code> 表示该属性可以缺省，<code>readonly</code> 表示通过这个类型观察对象时不能重新赋值：</p>
<pre><code class="language-ts">interface UserProfile {
  readonly id: string
  name: string
  avatarUrl?: string
}

const user: UserProfile = {
  id: 'u_001',
  name: '小林',
}

user.name = '林同学'
// user.id = 'u_002' // 错误：id 是只读属性
</code></pre>
<p>读取可选属性时，必须考虑它不存在的情况：</p>
<pre><code class="language-ts">function avatarText(profile: UserProfile): string {
  return profile.avatarUrl?.toUpperCase() ?? 'NO AVATAR'
}
</code></pre>
<p>在推荐的严格配置下，还可以开启 <code>exactOptionalPropertyTypes</code>，区分“属性没有出现”和“属性存在但值为 <code>undefined</code>”：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true
  }
}
</code></pre>
<p>开启后，<code>avatarUrl?: string</code> 允许省略 <code>avatarUrl</code>，但不自动允许显式写入 <code>avatarUrl: undefined</code>。如果业务确实需要后一种状态，应明确写成 <code>avatarUrl?: string | undefined</code>。</p>
<p>需要注意，<code>readonly</code> 是赋值检查，不是运行时冻结机制；它也只作用于当前属性这一层。更深层的只读数据将在本文后面单独讨论。</p>
<h2>三、额外属性检查不是“精确对象类型”</h2>
<p>把新鲜的对象字面量直接交给目标类型时，TypeScript 会检查多写的属性。这能很好地捕获拼写错误：</p>
<pre><code class="language-ts">interface ConnectionOptions {
  endpoint: string
  timeout?: number
}

function connect(options: ConnectionOptions) {
  console.log(options.endpoint)
}

connect({
  endpoint: '/api',
  tiemout: 3000,
  // 错误：对象字面量包含未知属性 tiemout
})
</code></pre>
<p>但额外属性检查不是一般意义上的“对象只能有这些键”。如果值先存入变量，只要它至少具备目标类型要求的结构，额外属性通常不会阻止赋值：</p>
<pre><code class="language-ts">const options = {
  endpoint: '/api',
  tiemout: 3000,
}

connect(options) // 结构上满足 ConnectionOptions，拼写错误可能被漏掉
</code></pre>
<p>配置对象适合在声明处使用 <code>satisfies</code>，让错误尽早暴露：</p>
<pre><code class="language-ts">const safeOptions = {
  endpoint: '/api',
  timeout: 3000,
} satisfies ConnectionOptions
</code></pre>
<p>不要用 <code>as ConnectionOptions</code> 压掉检查。断言表达的是“开发者掌握了编译器不知道的信息”，不是修复对象结构错误的工具。</p>
<h2>四、索引签名：为动态键设置边界</h2>
<p>当属性名来自用户输入、环境变量或后端字段时，无法提前枚举所有键，可以使用索引签名：</p>
<pre><code class="language-ts">interface Environment {
  [key: string]: string | number | boolean | undefined
  MODE: 'development' | 'production'
  PORT?: number
}

const env: Environment = {
  MODE: 'production',
  PORT: 8080,
  FEATURE_SEARCH: true,
}
</code></pre>
<p>字符串索引签名表示：所有字符串键对应的值都必须落在声明的值类型中。因此，显式属性也必须兼容索引签名：</p>
<pre><code class="language-ts">interface BrokenEnvironment {
  [key: string]: string | number
  // enabled: boolean // 错误：boolean 不属于索引签名的值类型
}
</code></pre>
<p>不要为了省事让索引签名返回 <code>any</code>，这会使任意属性访问失去检查。数据真正未知时，使用 <code>unknown</code> 并在读取后收窄：</p>
<pre><code class="language-ts">type Metadata = Record&lt;string, unknown&gt;

function readString(meta: Metadata, key: string): string | undefined {
  const value = meta[key]
  return typeof value === 'string' ? value : undefined
}
</code></pre>
<p>如果动态值只可能来自有限集合，就把集合精确写出来：</p>
<pre><code class="language-ts">type FieldValue = string | number | boolean | null

interface FormValues {
  [field: string]: FieldValue
}
</code></pre>
<p>对于不保证键一定存在的字典，建议同时开启 <code>noUncheckedIndexedAccess</code>。这样读取索引签名时，类型会把可能缺失的 <code>undefined</code> 计算在内。</p>
<h2>五、用 <code>Record</code> 表达键和值的映射</h2>
<p><code>Record&lt;Keys, Value&gt;</code> 是描述映射对象的常用工具类型。键集合已知时，它可以要求每个键都存在：</p>
<pre><code class="language-ts">type Permission = 'read' | 'write' | 'delete'

const permissions: Record&lt;Permission, boolean&gt; = {
  read: true,
  write: true,
  delete: false,
}
</code></pre>
<p>漏掉 <code>delete</code> 或多写未知键，都会在对象字面量处得到提示。若只保存部分键，可以组合 <code>Partial</code>：</p>
<pre><code class="language-ts">const overrides: Partial&lt;Record&lt;Permission, boolean&gt;&gt; = {
  delete: true,
}
</code></pre>
<p>键完全动态时，也可以使用 <code>Record&lt;string, Value&gt;</code>：</p>
<pre><code class="language-ts">type PriceTable = Record&lt;string, number&gt;

const prices: PriceTable = {
  apple: 8,
  banana: 5,
}
</code></pre>
<p><code>Record&lt;string, number&gt;</code> 不代表任意字符串在运行时都真的存在。开启 <code>noUncheckedIndexedAccess</code> 后，<code>prices[name]</code> 会得到更符合现实的 <code>number | undefined</code>。</p>
<p>选择方式可以归纳为：</p>
<table>
 <thead>
  <tr>
   <th>场景</th>
   <th>推荐写法</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>固定属性，各属性类型可能不同</td>
   <td><code>interface</code> 或对象类型别名</td>
  </tr>
  <tr>
   <td>有固定属性，也允许动态属性</td>
   <td>显式属性 + 索引签名</td>
  </tr>
  <tr>
   <td>有限键集合到同一种值的映射</td>
   <td><code>Record&lt;KeyUnion, Value&gt;</code></td>
  </tr>
  <tr>
   <td>有限键集合，但只出现一部分</td>
   <td><code>Partial&lt;Record&lt;KeyUnion, Value&gt;&gt;</code></td>
  </tr>
  <tr>
   <td>值来自不可信边界</td>
   <td><code>Record&lt;string, unknown&gt;</code>，读取后收窄</td>
  </tr>
 </tbody>
</table>
<h2>六、只读对象与只读数组</h2>
<p>除了在单个属性上写 <code>readonly</code>，还可以使用 <code>Readonly&lt;T&gt;</code> 创建浅只读视图：</p>
<pre><code class="language-ts">interface Settings {
  theme: 'light' | 'dark'
  editor: {
    fontSize: number
  }
}

const settings: Readonly&lt;Settings&gt; = {
  theme: 'dark',
  editor: { fontSize: 16 },
}

// settings.theme = 'light' // 错误
settings.editor.fontSize = 18 // 允许：Readonly 只处理第一层
</code></pre>
<p>数组可使用 <code>readonly T[]</code> 或 <code>ReadonlyArray&lt;T&gt;</code>，表明函数不会修改调用者的数据：</p>
<pre><code class="language-ts">function firstTag(tags: readonly string[]): string | undefined {
  return tags[0]
}

const tags: ReadonlyArray&lt;string&gt; = ['TypeScript', 'JavaScript']
// tags.push('Web') // 错误：只读数组没有可变更方法
</code></pre>
<p>这类只读约束仍然只存在于类型系统。需要运行时阻止修改时，可以根据业务使用 <code>Object.freeze()</code>；它默认同样是浅冻结。深只读类型和深冻结都要处理数组、函数、集合以及循环引用，不应把一个简单递归类型当成万能方案。</p>
<p>只读尤其适合输入参数和共享状态快照，因为它能直接表达函数的数据所有权：</p>
<pre><code class="language-ts">function renderProfile(profile: Readonly&lt;UserProfile&gt;) {
  return `${profile.id}: ${profile.name}`
}
</code></pre>
<h2>七、结构类型：看能力，而不是看名字</h2>
<p>TypeScript 的对象类型主要采用结构化类型系统。只要属性结构兼容，类型名称和创建方式可以不同：</p>
<pre><code class="language-ts">interface Point {
  x: number
  y: number
}

function distanceFromOrigin(point: Point): number {
  return Math.hypot(point.x, point.y)
}

const pixel = {
  x: 3,
  y: 4,
  color: '#0057b8',
}

distanceFromOrigin(pixel) // 5
</code></pre>
<p>这让普通对象、类实例和第三方数据可以自然协作，也解释了为什么变量中的额外属性通常不会造成错误。类型兼容关注调用方真正需要的成员，而不是要求两个对象由同一个声明创建。</p>
<p>结构化不等于“所有对象都能互换”。属性类型、可选性、只读视图和函数签名仍需兼容；类的 <code>private</code>、<code>protected</code> 以及 JavaScript 私有字段还会对兼容性施加额外限制，后续“类与契约”一篇会继续展开。</p>
<h2>八、实战：设计可扩展的更新请求</h2>
<p>下面的更新请求同时使用只读标识、有限值联合和动态字段：</p>
<pre><code class="language-ts">type ProfileFieldValue = string | number | boolean | null

interface ProfileUpdate {
  readonly userId: string
  fields: Record&lt;string, ProfileFieldValue&gt;
}

function createUpdate(
  userId: string,
  fields: Readonly&lt;Record&lt;string, ProfileFieldValue&gt;&gt;,
): ProfileUpdate {
  return {
    userId,
    fields: { ...fields },
  }
}

const update = createUpdate('u_001', {
  displayName: '小林',
  age: 20,
  newsletter: true,
})
</code></pre>
<p>这里没有使用 <code>any</code>：调用方只能提交协议允许的值；函数用只读输入声明自己不会改写调用者的对象，再复制一份数据交给返回结果。若字段名本身也是固定集合，可以把 <code>Record&lt;string, ...&gt;</code> 进一步收紧为 <code>Partial&lt;Record&lt;ProfileField, ...&gt;&gt;</code>。</p>
<p>真实 API 响应仍应先作为 <code>unknown</code> 进入系统，并通过运行时校验后再成为 <code>ProfileUpdate</code>。TypeScript 类型不会自动验证网络数据。</p>
<h2>九、常见陷阱</h2>
<h3>1. 把 <code>object</code>、<code>Object</code> 或 <code>{}</code> 当作业务模型</h3>
<p>这些类型范围过宽，不能表达对象有哪些可用属性。业务数据应声明具体结构；未知键值映射优先用 <code>Record&lt;string, unknown&gt;</code>。</p>
<h3>2. 用 <code>any</code> 索引签名掩盖不确定性</h3>
<p><code>any</code> 会把一次动态读取传播成不受检查的值。优先使用精确联合；确实未知时使用 <code>unknown</code> 并收窄。</p>
<h3>3. 误以为额外属性检查会封闭对象</h3>
<p>它主要针对新鲜对象字面量，TypeScript 的一般赋值规则仍是结构兼容。需要验证配置声明时，使用类型注解或 <code>satisfies</code>。</p>
<h3>4. 误以为 <code>readonly</code> 会冻结数据</h3>
<p><code>readonly</code> 默认是浅层、编译期约束。它既不递归，也不会自动生成 <code>Object.freeze()</code>。</p>
<h3>5. 滥用类型断言</h3>
<p><code>as SomeType</code> 可以绕过本应暴露的拼写和缺失属性错误。先修正数据或添加运行时校验，只在确有额外事实时使用断言。</p>
<h2>小结</h2>
<p>对象建模的核心是让类型贴近数据所有权和运行时事实：让推断处理局部实现，用显式结构描述公开契约；用可选属性表达缺省，用 <code>readonly</code> 表达不可重新赋值；用索引签名或 <code>Record</code> 描述动态映射，并以 <code>unknown</code> 或精确联合代替 <code>any</code>。同时要记住，额外属性检查不是精确对象类型，TypeScript 的常规兼容规则仍然以结构为中心。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/objects.html">Object Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#object-types">Everyday Types：Object Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/utility-types.html">Utility Types：<code>Record</code> 与 <code>Readonly</code></a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/exactOptionalPropertyTypes.html"><code>exactOptionalPropertyTypes</code></a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html"><code>noUncheckedIndexedAccess</code></a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness">上一篇</a> · <a href="https://likeyy.love/archives/typescript-unions-intersections-type-interface-enum">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-object-types-index-signatures-readonly</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FC91EAFED-F161-4145-B805-1E2A62A5C39D.png&amp;size=m" type="image/jpeg" length="1548817"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:10 GMT</pubDate></item><item><title><![CDATA[TypeScript 类型收窄：unknown、never、断言与穷尽检查]]></title><link>https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E7%B1%BB%E5%9E%8B%E6%94%B6%E7%AA%84%EF%BC%9Aunknown%E3%80%81never%E3%80%81%E6%96%AD%E8%A8%80%E4%B8%8E%E7%A9%B7%E5%B0%BD%E6%A3%80%E6%9F%A5&amp;url=/archives/typescript-narrowing-unknown-never-exhaustiveness" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 类型收窄：unknown、never、断言与穷尽检查</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples">日常类型</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-object-types-index-signatures-readonly">对象建模</a></p>
</blockquote>
<p>类型收窄是 TypeScript 日常开发的核心：一个值进入函数时可能有多种形态，经过运行时判断后，编译器在当前分支中把它缩小为更具体的类型。边界越不可信，越应该从 <code>unknown</code> 开始验证，而不是用 <code>any</code> 或断言跳过检查。</p>
<p>本文覆盖内置收窄、自定义类型守卫、可辨识联合、<code>never</code> 穷尽检查、断言、非空断言和 <code>satisfies</code>。</p>
<h2>一、<code>any</code> 会关闭检查</h2>
<p><code>any</code> 可以接收任何值，也可以执行任何操作：</p>
<pre><code class="language-ts">function unsafeUpper(value: any) {
  return value.toUpperCase()
}

unsafeUpper(42) // 编译通过，运行时失败
</code></pre>
<p>它还会向外传播：<code>any</code> 可以赋给几乎任何类型，让一次不安全边界污染后续代码。只在迁移遗留代码或类型系统确实无法表达的狭窄位置临时使用，并尽快把它封装起来。</p>
<h2>二、<code>unknown</code> 迫使使用前验证</h2>
<p><code>unknown</code> 同样能接收任意值，但读取属性、调用或赋给更具体类型前必须收窄：</p>
<pre><code class="language-ts">function upper(value: unknown): string {
  if (typeof value === 'string') {
    return value.toUpperCase()
  }

  return String(value)
}
</code></pre>
<p>网络响应、<code>JSON.parse()</code> 结果、消息队列载荷和用户输入都适合作为 <code>unknown</code> 进入系统。类型安全边界应完成运行时验证，再把确定的值交给内部业务代码。</p>
<h2>三、<code>typeof</code>、相等性与真值收窄</h2>
<p><code>typeof</code> 适合原始类型：</p>
<pre><code class="language-ts">function format(value: string | number): string {
  if (typeof value === 'number') {
    return value.toFixed(2)
  }

  return value.trim()
}
</code></pre>
<p>相等性判断也会关联两个值的类型：</p>
<pre><code class="language-ts">function compare(left: string | number, right: string | boolean) {
  if (left === right) {
    left.toUpperCase() // 两者相等时只能共同为 string
  }
}
</code></pre>
<p>真值判断适合排除 <code>null</code>、<code>undefined</code> 等假值，但可能误伤 <code>0</code>、<code>false</code> 和空字符串：</p>
<pre><code class="language-ts">function printLength(text: string | null) {
  if (text !== null) {
    console.log(text.length) // 空字符串仍是合法输入
  }
}
</code></pre>
<p>需要判断“是否缺失”时，优先显式检查 <code>null</code> / <code>undefined</code>，不要默认把所有假值当作无效。</p>
<h2>四、<code>in</code> 与 <code>instanceof</code></h2>
<p><code>in</code> 检查对象是否具有某个属性：</p>
<pre><code class="language-ts">type Cat = { meow(): void }
type Dog = { bark(): void }

function speak(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow()
  } else {
    animal.bark()
  }
}
</code></pre>
<p><code>instanceof</code> 依赖真实的运行时构造器：</p>
<pre><code class="language-ts">function formatDate(value: Date | string): string {
  return value instanceof Date ? value.toISOString() : value
}
</code></pre>
<p>接口在编译后不存在，不能写 <code>value instanceof SomeInterface</code>。来自另一个 iframe、重复安装的包或反序列化数据也可能不适合依赖 <code>instanceof</code>，此时判别字段或结构验证更可靠。</p>
<h2>五、自定义类型守卫</h2>
<p>重复验证逻辑可以封装为返回 <code>value is Type</code> 的函数：</p>
<pre><code class="language-ts">interface User {
  id: string
  name: string
}

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false

  const record = value as Record&lt;string, unknown&gt;
  return typeof record.id === 'string' &amp;&amp; typeof record.name === 'string'
}

const payload: unknown = JSON.parse('{"id":"u_001","name":"Ada"}')

if (isUser(payload)) {
  console.log(payload.name)
}
</code></pre>
<p>类型谓词是一项承诺：若函数返回 <code>true</code>，值必须真的满足类型。验证条件与声明不一致时，编译器会被错误信息误导。复杂数据优先使用经过测试的 schema 库，并从 schema 推导类型。</p>
<p>断言函数用于验证失败时直接抛错：</p>
<pre><code class="language-ts">function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new TypeError('Invalid user payload')
  }
}

const data: unknown = JSON.parse('{"id":"u_002","name":"Grace"}')
assertUser(data)
console.log(data.name)
</code></pre>
<h2>六、可辨识联合让状态合法</h2>
<p>给每个成员一个共享的字面量字段，TypeScript 就能精确收窄：</p>
<pre><code class="language-ts">type RequestState&lt;T&gt; =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }

function render&lt;T&gt;(state: RequestState&lt;T&gt;): string {
  switch (state.status) {
    case 'idle':
      return '尚未加载'
    case 'loading':
      return '加载中'
    case 'success':
      return JSON.stringify(state.data)
    case 'error':
      return state.message
  }
}
</code></pre>
<p>这比一个同时包含 <code>loading: boolean</code>、<code>data?</code>、<code>error?</code> 的宽接口更安全，因为非法状态根本无法构造。</p>
<h2>七、<code>never</code> 与穷尽检查</h2>
<p><code>never</code> 表示不可能存在的值。总是抛错或永不正常返回的函数可返回 <code>never</code>：</p>
<pre><code class="language-ts">function fail(message: string): never {
  throw new Error(message)
}
</code></pre>
<p>它更常用于保证联合分支被完整处理：</p>
<pre><code class="language-ts">type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number }

function assertNever(value: never): never {
  throw new Error(`Unexpected shape: ${JSON.stringify(value)}`)
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.size ** 2
    default:
      return assertNever(shape)
  }
}
</code></pre>
<p>以后给 <code>Shape</code> 增加新成员，<code>assertNever(shape)</code> 会产生编译错误，提醒开发者补齐分支。直接声明一个 <code>never</code> 变量通常没有业务意义。</p>
<h2>八、类型断言不会转换数据</h2>
<p><code>as Type</code> 只影响静态检查，不生成运行时代码：</p>
<pre><code class="language-ts">const element = document.querySelector('#app') as HTMLElement | null
</code></pre>
<p>它适合表达开发者确实掌握、但编译器无法推导的事实。它不适合把未验证响应强行声明成业务类型：</p>
<pre><code class="language-ts">interface Article {
  id: string
  title: string
}

const raw: unknown = JSON.parse('null')
// const article = raw as Article
// 编译器会相信，但运行时仍然是 null
</code></pre>
<p>尽量先通过控制流和运行时验证收窄。双重断言 <code>value as unknown as Target</code> 几乎总是在绕过不兼容事实，应视为需要重新设计边界的信号。</p>
<h2>九、谨慎使用非空断言</h2>
<p>后缀 <code>!</code> 告诉编译器值不是 <code>null</code> 或 <code>undefined</code>：</p>
<pre><code class="language-ts">const app = document.querySelector('#app')!
</code></pre>
<p>它同样不会生成检查。元素不存在时，错误只是推迟到下一次访问。更稳妥的做法是显式验证：</p>
<pre><code class="language-ts">const app = document.querySelector('#app')

if (!app) {
  throw new Error('Missing #app element')
}

app.textContent = 'Ready'
</code></pre>
<p>测试夹具、框架生命周期或静态模板能严格保证存在时可以少量使用 <code>!</code>；普通业务数据不要依赖它掩盖初始化问题。</p>
<h2>十、<code>satisfies</code> 检查而不粗暴改型</h2>
<p>类型注解会让变量按目标类型使用；断言会要求编译器相信开发者；<code>satisfies</code> 则检查表达式满足目标约束，同时尽量保留表达式自己的精确信息：</p>
<pre><code class="language-ts">type RouteName = 'home' | 'settings'
type RouteTable = Record&lt;RouteName, `/${string}`&gt;

const routes = {
  home: '/',
  settings: '/settings',
} as const satisfies RouteTable

const settingsPath = routes.settings // '/settings'
</code></pre>
<p>如果漏掉键、拼错键或路径不以 <code>/</code> 开头，声明处就会报错。<code>satisfies</code> 仍然只是静态检查，不能验证网络传来的对象。</p>
<h2>十一、设计安全边界</h2>
<p>推荐的数据流是：</p>
<pre><code class="language-text">外部数据（unknown）
  → 运行时解析 / schema 验证
  → 已收窄的领域类型
  → 内部业务函数
</code></pre>
<p>例如请求函数不要用一个没有验证支持的 <code>&lt;T&gt;</code> 让调用者“指定答案”：</p>
<pre><code class="language-ts">interface Parser&lt;T&gt; {
  parse(value: unknown): T
}

async function request&lt;T&gt;(url: string, parser: Parser&lt;T&gt;): Promise&lt;T&gt; {
  const response = await fetch(url)
  const payload: unknown = await response.json()
  return parser.parse(payload)
}
</code></pre>
<p>这里的 <code>T</code> 由真实解析器产生，类型关系有运行时行为支撑。</p>
<h2>十二、常见误区</h2>
<ol>
 <li><strong>把 <code>unknown</code> 立即断言成具体类型</strong>：这只是换了 <code>any</code> 的写法，没有完成验证。</li>
 <li><strong>用真值判断过滤所有输入</strong>：合法的 <code>0</code>、<code>false</code> 和空字符串会被一起排除。</li>
 <li><strong>让类型守卫承诺过多</strong>：谓词必须由完整、经过测试的检查支撑。</li>
 <li><strong>把断言当类型转换</strong>：<code>as number</code> 不会把字符串变成数字。</li>
 <li><strong>到处写非空断言</strong>：它常常是在隐藏生命周期或状态建模问题。</li>
 <li><strong>只在 <code>switch</code> 里写 <code>default</code></strong>：没有 <code>never</code> 检查时，新联合成员可能被静默吞掉。</li>
</ol>
<h2>小结</h2>
<p>不可信数据从 <code>unknown</code> 开始，通过 <code>typeof</code>、<code>in</code>、<code>instanceof</code>、判别字段或类型守卫逐步收窄。用可辨识联合排除非法状态，用 <code>never</code> 固定穷尽处理。断言和非空断言不会产生运行时保护，应该只表达确有依据的额外事实；配置对象则优先使用 <code>satisfies</code>，既验证形状又保留精确推断。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">Narrowing</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html">Everyday Types：<code>any</code> 与 Type Assertions</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html">TypeScript 4.9：<code>satisfies</code></a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/functions.html#never">The <code>never</code> Type</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples">上一篇</a> · <a href="https://likeyy.love/archives/typescript-object-types-index-signatures-readonly">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-narrowing-unknown-never-exhaustiveness</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F645B248B-EDDB-4B5A-8319-56DFB6E2A25F.png&amp;size=m" type="image/jpeg" length="1677281"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:10 GMT</pubDate></item><item><title><![CDATA[TypeScript 日常类型：推断、函数、数组与元组]]></title><link>https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%20%E6%97%A5%E5%B8%B8%E7%B1%BB%E5%9E%8B%EF%BC%9A%E6%8E%A8%E6%96%AD%E3%80%81%E5%87%BD%E6%95%B0%E3%80%81%E6%95%B0%E7%BB%84%E4%B8%8E%E5%85%83%E7%BB%84&amp;url=/archives/typescript-everyday-types-inference-functions-tuples" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 日常类型：推断、函数、数组与元组</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/typescript-7-project-setup-tsconfig">工程起步</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness">类型收窄</a></p>
</blockquote>
<p>日常 TypeScript 不需要给每个变量都写类型。编译器会从初始值、函数返回值和上下文推断类型；开发者更应该在函数边界、公开 API 和需要约束设计意图的位置写清契约。</p>
<p>本文从最常用的原始类型开始，覆盖字面量、函数、数组、只读数组、元组、空值以及容易误解的 <code>void</code>。</p>
<h2>一、优先让推断完成局部工作</h2>
<p>初始化值足够明确时，不必重复类型：</p>
<pre><code class="language-ts">const title = 'TypeScript 日常类型' // string
const views = 120                   // number
const published = false            // boolean

const article = {
  title,
  views,
  published,
}
</code></pre>
<p>下面的注解没有增加信息：</p>
<pre><code class="language-ts">const count: number = 1
const enabled: boolean = true
</code></pre>
<p>需要显式注解的典型位置包括：</p>
<ul>
 <li>函数参数和公开返回值；</li>
 <li>变量先声明、后赋值；</li>
 <li>值需要比初始化结果更宽或更窄；</li>
 <li>对象需要满足某个稳定契约；</li>
 <li>空数组或空对象没有足够的推断信息。</li>
</ul>
<pre><code class="language-ts">let currentUserId: string | null = null

function loadArticle(id: string): Promise&lt;string&gt; {
  return Promise.resolve(`article:${id}`)
}
</code></pre>
<p>推断并非“复杂时就不可靠”。只要输入关系清楚，TypeScript 很擅长推断复杂对象和泛型结果；盲目添加注解反而可能拓宽本来精确的类型。</p>
<h2>二、原始类型使用小写名称</h2>
<p>常用原始类型是：</p>
<pre><code class="language-ts">const name: string = 'Ada'
const age: number = 36
const active: boolean = true
const total: bigint = 10n
const token: symbol = Symbol('token')
</code></pre>
<p>日常类型声明使用小写 <code>string</code>、<code>number</code>、<code>boolean</code>，不要使用包装对象类型 <code>String</code>、<code>Number</code>、<code>Boolean</code>：</p>
<pre><code class="language-ts">let text: string = 'hello'

// text = new String('hello')
// 错误：包装对象不是原始 string
</code></pre>
<p>JavaScript 会在访问 <code>'hello'.toUpperCase()</code> 时临时提供包装行为，不意味着业务代码应该主动创建包装对象。</p>
<h2>三、字面量类型表达有限取值</h2>
<p>字面量可以直接成为类型：</p>
<pre><code class="language-ts">type Theme = 'light' | 'dark' | 'system'
type RetryCount = 0 | 1 | 2 | 3

function setTheme(theme: Theme): void {
  console.log(theme)
}

setTheme('dark')
</code></pre>
<p><code>const</code> 变量通常保留更具体的字面量，<code>let</code> 为后续赋值保留空间：</p>
<pre><code class="language-ts">const fixedMode = 'production' // 'production'
let mode = 'production'        // string

mode = 'development'
</code></pre>
<p>对象属性默认仍可能变化。需要保留整组常量的字面量时使用 <code>as const</code>：</p>
<pre><code class="language-ts">const Status = {
  Draft: 'draft',
  Published: 'published',
} as const

type Status = (typeof Status)[keyof typeof Status]
</code></pre>
<h2>四、函数参数和返回值</h2>
<p>参数通常需要注解，返回值可以推断；公开 API 显式写返回值有助于防止实现变化意外改变契约：</p>
<pre><code class="language-ts">function sum(left: number, right: number): number {
  return left + right
}
</code></pre>
<p>函数类型使用箭头表示参数和返回值：</p>
<pre><code class="language-ts">type Formatter = (value: number) =&gt; string

const formatPrice: Formatter = (value) =&gt; `¥${value.toFixed(2)}`
</code></pre>
<p>可选参数放在必选参数之后，调用时必须处理 <code>undefined</code>：</p>
<pre><code class="language-ts">function greet(name: string, prefix?: string): string {
  return `${prefix ?? '你好'}，${name}`
}
</code></pre>
<p>默认参数在函数体内已经有值：</p>
<pre><code class="language-ts">function repeat(text: string, times = 1): string {
  return text.repeat(times)
}
</code></pre>
<p>剩余参数使用数组类型：</p>
<pre><code class="language-ts">function join(separator: string, ...parts: string[]): string {
  return parts.join(separator)
}

join('/', 'posts', 'typescript', '7')
</code></pre>
<h2>五、数组和只读数组</h2>
<p>数组有两种等价写法：</p>
<pre><code class="language-ts">const names: string[] = ['Ada', 'Linus']
const scores: Array&lt;number&gt; = [95, 88]
</code></pre>
<p>函数只读取数组时，用 <code>readonly T[]</code> 表达不会修改调用方数据：</p>
<pre><code class="language-ts">function first&lt;T&gt;(items: readonly T[]): T | undefined {
  return items[0]
}

const tags: readonly string[] = ['TypeScript', 'JavaScript']
// tags.push('Web') // 错误：只读数组没有 push
</code></pre>
<p><code>readonly</code> 是浅层类型约束，不会运行时冻结数组，也不会自动让元素对象的内部属性只读。</p>
<p>开启 <code>noUncheckedIndexedAccess</code> 后，数组索引会考虑越界：</p>
<pre><code class="language-ts">const values = [10, 20]
const maybeValue = values[5] // number | undefined
</code></pre>
<p>这比默认假设每次索引都存在更符合 JavaScript 的运行时事实。</p>
<h2>六、元组描述固定位置</h2>
<p>元组适合长度和每个位置语义都稳定的数据：</p>
<pre><code class="language-ts">type Coordinate = readonly [x: number, y: number]

const point: Coordinate = [120.1, 30.2]
</code></pre>
<p>可选元素和剩余元素也受支持：</p>
<pre><code class="language-ts">type Command = [name: string, verbose?: boolean]
type Route = [path: string, ...middlewares: string[]]

const build: Command = ['build', true]
const admin: Route = ['/admin', 'auth', 'audit']
</code></pre>
<p>如果元素数量会自由变化，应该使用普通数组；如果每个字段需要名称、可选性和未来扩展，使用对象通常比很长的元组更清楚。</p>
<h2>七、<code>null</code>、<code>undefined</code> 与可选链</h2>
<p>在严格模式下，<code>null</code> 和 <code>undefined</code> 不能直接赋给其他类型：</p>
<pre><code class="language-ts">function findName(id: string): string | undefined {
  return id === 'u_001' ? 'Ada' : undefined
}

const upperName = findName('u_002')?.toUpperCase() ?? 'UNKNOWN'
</code></pre>
<p>用联合类型表达缺失，用可选链 <code>?.</code> 安全访问，用空值合并 <code>??</code> 提供默认值。不要用 <code>value || fallback</code> 代替所有空值处理，因为 <code>0</code>、<code>false</code> 和空字符串也会被 <code>||</code> 当作假值。</p>
<h2>八、正确理解 <code>void</code></h2>
<p>函数返回类型 <code>void</code> 表示调用者不应使用返回值：</p>
<pre><code class="language-ts">function log(message: string): void {
  console.log(message)
}
</code></pre>
<p>它不等于“实现绝不能返回任何东西”。当一个函数被赋给 <code>() =&gt; void</code> 类型时，实现可以返回值，但调用方看到的仍是 <code>void</code>：</p>
<pre><code class="language-ts">type Listener = (message: string) =&gt; void

const messages: string[] = []
const listener: Listener = (message) =&gt; messages.push(message)

const result = listener('hello')
// result 的类型是 void，调用方不能依赖 push 返回的长度
</code></pre>
<p>这个规则让 <code>forEach((item) =&gt; output.push(item))</code> 等回调自然成立。它不意味着显式声明 <code>function run(): void</code> 后可以随意返回数字；函数声明本身的返回类型仍会检查实现。</p>
<h2>九、函数重载只在调用形态确实不同时使用</h2>
<p>同一函数有几个关联的调用签名时可以重载：</p>
<pre><code class="language-ts">function parse(value: string): string[]
function parse(value: readonly string[]): string
function parse(value: string | readonly string[]): string[] | string {
  return typeof value === 'string' ? value.split(',') : value.join(',')
}

const list = parse('a,b')       // string[]
const text = parse(['a', 'b'])  // string
</code></pre>
<p>实现签名必须覆盖所有重载，但不会直接暴露给调用方。简单的“参数可以是 A 或 B，返回值不随输入变化”优先使用联合类型，避免不必要的重载。</p>
<h2>十、常见误区</h2>
<ol>
 <li><strong>给每个局部变量写类型</strong>：重复注解增加噪声，还可能丢失字面量精度。</li>
 <li><strong>使用大写包装类型</strong>：业务声明应使用小写原始类型。</li>
 <li><strong>把 <code>const</code> 当深只读</strong>：它只禁止变量重新赋值，对象和数组内容仍可能变化。</li>
 <li><strong>用长元组代替对象</strong>：位置语义难维护时，应改用具名属性。</li>
 <li><strong>忽略数组越界</strong>：对动态索引开启 <code>noUncheckedIndexedAccess</code>，并处理 <code>undefined</code>。</li>
 <li><strong>依赖 <code>void</code> 回调的实现返回值</strong>：调用方契约明确忽略它。</li>
</ol>
<h2>小结</h2>
<p>让 TypeScript 推断局部实现，在函数边界和公开契约处明确类型。原始类型使用小写名称，有限状态用字面量联合；数组长度可变，元组位置固定，纯读取输入使用只读数组。严格空值检查和 <code>noUncheckedIndexedAccess</code> 能让类型更贴近运行时，而 <code>void</code> 表达的是“调用者忽略返回值”的契约。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html">Everyday Types</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/functions.html">More on Functions</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types">Object Types：Tuple Types</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html"><code>noUncheckedIndexedAccess</code></a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-7-project-setup-tsconfig">上一篇</a> · <a href="https://likeyy.love/archives/typescript-narrowing-unknown-never-exhaustiveness">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-everyday-types-inference-functions-tuples</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FC1427B90-4E61-414E-A3D6-623DA008E6C5.png&amp;size=m" type="image/jpeg" length="1552842"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:09 GMT</pubDate></item><item><title><![CDATA[TypeScript 7 工程起步：安装、tsconfig 与编译流程]]></title><link>https://likeyy.love/archives/typescript-7-project-setup-tsconfig</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=TypeScript%207%20%E5%B7%A5%E7%A8%8B%E8%B5%B7%E6%AD%A5%EF%BC%9A%E5%AE%89%E8%A3%85%E3%80%81tsconfig%20%E4%B8%8E%E7%BC%96%E8%AF%91%E6%B5%81%E7%A8%8B&amp;url=/archives/typescript-7-project-setup-tsconfig" width="1" height="1" alt="" style="opacity:0;">
<h1>TypeScript 7 工程起步：安装、tsconfig 与编译流程</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">TypeScript 7 现代开发指南</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples">日常类型</a></p>
</blockquote>
<p>TypeScript 的类型检查发生在代码运行前，浏览器和 Node.js 最终执行的仍是 JavaScript。2026 年开始一个 TypeScript 项目时，最重要的不是先背语法，而是固定编译器版本、让编辑器和 CI 使用同一份 <code>tsconfig.json</code>，并根据真实运行环境选择模块配置。</p>
<p>本文以 TypeScript 7.0.2 为基线。TypeScript 7 是使用 Go 实现的原生编译器和语言服务，普通项目仍安装 <code>typescript</code> 包、运行 <code>tsc</code>，但完整构建通常比旧版快 8～12 倍。</p>
<h2>一、TypeScript 解决什么问题</h2>
<p>JavaScript 允许同一个变量保存不同形态的值，很多错误只能在相关分支真正运行后暴露：</p>
<pre><code class="language-js">const article = { title: 'TypeScript 7', views: 12 }

console.log(article.titel.toUpperCase())
// 运行到这里才发现 titel 拼错了
</code></pre>
<p>TypeScript 在 JavaScript 之上增加静态类型分析：</p>
<pre><code class="language-ts">const article = { title: 'TypeScript 7', views: 12 }

// article.titel.toUpperCase()
// 编译错误：对象上不存在 titel
</code></pre>
<p>它擅长提前发现属性拼写、参数数量、空值处理和不可能分支等问题，并让编辑器提供可靠的补全与重构。它不会自动验证网络响应，也不会修复业务逻辑；类型在编译后通常会被擦除。</p>
<h2>二、在项目中安装，不要依赖全局版本</h2>
<p>创建最小项目：</p>
<pre><code class="language-bash">mkdir ts-app
cd ts-app
npm init -y
npm install --save-dev typescript
</code></pre>
<p>确认项目实际使用的版本：</p>
<pre><code class="language-bash">npx tsc --version
</code></pre>
<p>把 TypeScript 放在 <code>devDependencies</code> 中有三个好处：</p>
<ul>
 <li><code>package.json</code> 和 lockfile 固定团队、CI 使用的版本；</li>
 <li>不会因为开发机的全局 <code>tsc</code> 不同而出现结果漂移；</li>
 <li>升级可以作为一次明确的依赖变更进行审查和回滚。</li>
</ul>
<p>也可以把命令写入脚本：</p>
<pre><code class="language-json">{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.json",
    "typecheck:watch": "tsc --noEmit --watch"
  }
}
</code></pre>
<p>之后使用 <code>npm run typecheck</code>，不必要求每个人手动选择编译器。</p>
<h2>三、从一个可执行文件开始</h2>
<p>创建 <code>src/index.ts</code>：</p>
<pre><code class="language-ts">interface User {
  id: string
  name: string
}

function greeting(user: User): string {
  return `你好，${user.name}`
}

console.log(greeting({ id: 'u_001', name: 'Ada' }))
</code></pre>
<p>单独给 <code>tsc</code> 传文件适合临时实验：</p>
<pre><code class="language-bash">npx tsc src/index.ts --outDir dist
node dist/index.js
</code></pre>
<p>正式项目应让 <code>tsconfig.json</code> 成为唯一配置入口：</p>
<pre><code class="language-bash">npx tsc --init
npx tsc -p tsconfig.json
</code></pre>
<p><code>-p</code> 指定项目配置；在当前目录已有 <code>tsconfig.json</code> 时，直接运行 <code>npx tsc</code> 也会使用它。TypeScript 7 中，命令行构建若发现当前目录存在配置文件，就不能再模糊地同时传入源码路径；确实要忽略配置时需显式使用 <code>--ignoreConfig</code>。</p>
<h2>四、先区分“编译器默认值”和初始化模板</h2>
<p>TypeScript 7 没有配置时采用的关键默认值包括：</p>
<ul>
 <li><code>strict: true</code>；</li>
 <li><code>module: "esnext"</code>；</li>
 <li><code>target</code> 为 <code>esnext</code> 之前的当前稳定 ECMAScript 版本，TypeScript 7.0 对应 ES2025；</li>
 <li><code>noUncheckedSideEffectImports: true</code>；</li>
 <li><code>types: []</code>；</li>
 <li><code>rootDir: "./"</code>；</li>
 <li><code>stableTypeOrdering: true</code>，且不能关闭。</li>
</ul>
<p><code>tsc --init</code> 写出的则是一份面向通用 Node 开发的推荐模板，会显式包含 <code>module: "nodenext"</code>、<code>target: "esnext"</code>、<code>strict</code>、<code>verbatimModuleSyntax</code>、<code>isolatedModules</code> 等选项。模板不是“所有项目都应该照抄的唯一答案”，仍需根据运行方式调整。</p>
<h2>五、构建工具项目使用 bundler 配置</h2>
<p>Vite、Rolldown、webpack 或 esbuild 会处理模块和 JavaScript 输出，<code>tsc</code> 通常只做类型检查：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedSideEffectImports": true,
    "types": []
  },
  "include": ["src"]
}
</code></pre>
<p>这里的职责很清楚：构建工具转译和打包，TypeScript 检查类型。Vite 项目需要客户端环境类型时，可按框架模板把 <code>"vite/client"</code> 加入 <code>types</code>。</p>
<h2>六、现代 Node.js 项目使用 NodeNext</h2>
<p>若 Node.js 直接执行 <code>tsc</code> 输出的 ESM，使用成对的 <code>nodenext</code>：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
</code></pre>
<p>同时安装 Node 类型，并让 <code>package.json</code> 与模块格式一致：</p>
<pre><code class="language-bash">npm install --save-dev @types/node
</code></pre>
<pre><code class="language-json">{
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js"
  }
}
</code></pre>
<p>Node ESM 的源码相对导入通常写运行时扩展名，例如 <code>import { add } from './math.js'</code>，即使源文件实际叫 <code>math.ts</code>。不要用只适合 bundler 的无扩展名导入测试 Node 产物。</p>
<h2>七、理解几个关键选项</h2>
<h3>1. <code>strict</code></h3>
<p><code>strict</code> 打开一组相互配合的严格检查，包含 <code>strictNullChecks</code>、<code>noImplicitAny</code> 等。TypeScript 7 已默认开启，但项目仍建议显式写出，方便读者理解契约。</p>
<h3>2. <code>types</code></h3>
<p>TypeScript 7 默认 <code>types: []</code>，不再把所有可见 <code>@types</code> 包自动注入全局作用域。需要 Node、测试框架或构建工具全局类型时显式列出；它不会禁止导入普通依赖。</p>
<h3>3. <code>rootDir</code> 与 <code>outDir</code></h3>
<p>TypeScript 7 的 <code>rootDir</code> 默认是项目根目录。希望 <code>src/a.ts</code> 稳定输出到 <code>dist/a.js</code> 时，应明确写 <code>rootDir: "src"</code> 和 <code>outDir: "dist"</code>。</p>
<h3>4. <code>noEmit</code> 与 <code>noEmitOnError</code></h3>
<ul>
 <li>构建工具负责输出时使用 <code>noEmit: true</code>；</li>
 <li><code>tsc</code> 自己输出 JavaScript 时，可使用 <code>noEmitOnError: true</code>，避免类型错误时生成新的产物；</li>
 <li><code>--watch</code> 只改变持续监听方式，不改变配置语义。</li>
</ul>
<h2>八、TypeScript 7 升级检查</h2>
<p>从较老项目升级时，重点检查：</p>
<ol>
 <li><code>target: "es5"</code> 与 <code>downlevelIteration</code> 已不再支持；</li>
 <li><code>moduleResolution: "node"</code>、<code>"node10"</code> 和 <code>"classic"</code> 已不再支持，改用 <code>bundler</code> 或 <code>nodenext</code>；</li>
 <li><code>baseUrl</code> 已不再支持，<code>paths</code> 可直接相对项目根目录配置，但运行工具也要认识同一别名；</li>
 <li><code>types</code> 默认变为空数组，需要显式列出依赖的全局声明；</li>
 <li><code>rootDir</code> 的新默认值可能改变输出目录结构；</li>
 <li><code>strict</code> 默认开启后会暴露旧代码中的隐式 <code>any</code> 和空值问题。</li>
</ol>
<p>升级应在独立分支执行类型检查、测试和真实构建。不要用关闭 <code>strict</code> 或堆叠断言来“通过升级”，那只会把风险推回运行时。</p>
<h2>九、日常开发流程</h2>
<p>一个简单而可靠的循环是：</p>
<pre><code class="language-bash">npm run typecheck:watch
npm run build
</code></pre>
<p>编辑器负责即时提示，watch 模式负责项目级检查，CI 再执行一次干净的类型检查和构建。需要标记某一行“预期会报错”时，测试代码优先使用 <code>@ts-expect-error</code>；错误消失后它会反过来提醒你删除过期抑制。<code>@ts-ignore</code> 会无条件隐藏下一行错误，应尽量避免。</p>
<h2>小结</h2>
<p>新项目应本地安装并固定 TypeScript，通过 <code>npx tsc</code> 或 npm scripts 使用同一版本。<code>tsconfig.json</code> 必须匹配真实运行环境：构建工具项目选择 <code>esnext + bundler + noEmit</code>，现代 Node 项目选择成对的 <code>nodenext</code>。TypeScript 7 的原生编译器提升了速度，也收紧了默认配置和过时选项；升级时应修正环境边界，而不是关闭检查。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/">TypeScript 7.0 发布说明</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/2/basic-types.html">The Basics</a></li>
 <li><a href="https://www.typescriptlang.org/tsconfig/">TSConfig Reference</a></li>
 <li><a href="https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html">Modules：Choosing Compiler Options</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/typescript-7-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/typescript-everyday-types-inference-functions-tuples">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/typescript-7-project-setup-tsconfig</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FFBDC3CE7-9190-44A3-BDAB-9D6EE982E913.png&amp;size=m" type="image/jpeg" length="1390670"/><category>TypeScript 与 API 工具</category><pubDate>Mon, 31 Aug 2026 17:42:09 GMT</pubDate></item><item><title><![CDATA[Vue 3 现代开发指南：系列导航]]></title><link>https://likeyy.love/archives/vue3-modern-development-guide</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E7%8E%B0%E4%BB%A3%E5%BC%80%E5%8F%91%E6%8C%87%E5%8D%97%EF%BC%9A%E7%B3%BB%E5%88%97%E5%AF%BC%E8%88%AA&amp;url=/archives/vue3-modern-development-guide" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 现代开发指南：系列导航</h1>
<blockquote>
 <p>更新日期：2026-08-31
  <br>
  技术基线：Vue 3.5、Vite 8、Vue Router 5、Pinia 4、TypeScript 7</p>
</blockquote>
<p>原来的 Vue 3 长文同时讲工程搭建、响应式、路由、状态管理和组件通信，不适合一次阅读，也不方便单独更新。本系列将这些内容拆成 9 篇可以独立发布的文章：每篇只解决一组相关问题，并统一使用当前 Vue 官方推荐的 Composition API、<code>&lt;script setup&gt;</code> 和 TypeScript 写法。</p>
<h2>系列目录</h2>
<table>
 <thead>
  <tr>
   <th>篇目</th>
   <th>内容</th>
   <th>发布链接</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>01</td>
   <td>Vue 3 现代工程搭建：create-vue、Vite 8 与 TypeScript</td>
   <td><a href="https://likeyy.love/archives/vue3-modern-project-setup-vite-8-typescript">开始阅读</a></td>
  </tr>
  <tr>
   <td>02</td>
   <td>Composition API 与 <code>&lt;script setup&gt;</code></td>
   <td><a href="https://likeyy.love/archives/vue3-composition-api-script-setup">开始阅读</a></td>
  </tr>
  <tr>
   <td>03</td>
   <td>响应式基础：<code>ref</code>、<code>reactive</code>、<code>toRef</code> 与 <code>toRefs</code></td>
   <td><a href="https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs">开始阅读</a></td>
  </tr>
  <tr>
   <td>04</td>
   <td>派生状态与副作用：<code>computed</code>、<code>watch</code>、生命周期和组合式函数</td>
   <td><a href="https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables">开始阅读</a></td>
  </tr>
  <tr>
   <td>05</td>
   <td>组件契约：Props、Emits、<code>defineModel</code> 与插槽</td>
   <td><a href="https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots">开始阅读</a></td>
  </tr>
  <tr>
   <td>06</td>
   <td>跨层通信：模板引用、<code>provide/inject</code> 与事件总线</td>
   <td><a href="https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus">开始阅读</a></td>
  </tr>
  <tr>
   <td>07</td>
   <td>Vue Router 5：手写路由与类型安全的文件路由</td>
   <td><a href="https://likeyy.love/archives/vue-router-5-type-safe-file-routing">开始阅读</a></td>
  </tr>
  <tr>
   <td>08</td>
   <td>Pinia 4：状态、Getter、Action、订阅与持久化</td>
   <td><a href="https://likeyy.love/archives/pinia-4-state-management">开始阅读</a></td>
  </tr>
  <tr>
   <td>09</td>
   <td>进阶能力：浅层响应式、原始对象、Teleport 与 Suspense</td>
   <td><a href="https://likeyy.love/archives/vue3-advanced-reactivity-teleport-suspense">开始阅读</a></td>
  </tr>
 </tbody>
</table>
<h2>2026 年的技术基线</h2>
<p>以下版本是在 2026-08-31 核对 npm <code>latest</code> 标签后得到的快照。补丁版本会继续更新，实际项目应优先让最新的 <code>create-vue</code> 生成相互兼容的依赖，不必照抄补丁号。</p>
<table>
 <thead>
  <tr>
   <th>工具</th>
   <th style="text-align:right">核对时的稳定版本</th>
   <th style="text-align:right">本系列采用的主版本</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Vue</td>
   <td style="text-align:right">3.5.42</td>
   <td style="text-align:right">3.5</td>
  </tr>
  <tr>
   <td>create-vue</td>
   <td style="text-align:right">3.23.0</td>
   <td style="text-align:right">3</td>
  </tr>
  <tr>
   <td>Vite</td>
   <td style="text-align:right">8.2.2</td>
   <td style="text-align:right">8</td>
  </tr>
  <tr>
   <td>Vue Router</td>
   <td style="text-align:right">5.3.0</td>
   <td style="text-align:right">5</td>
  </tr>
  <tr>
   <td>Pinia</td>
   <td style="text-align:right">4.0.3</td>
   <td style="text-align:right">4</td>
  </tr>
  <tr>
   <td>TypeScript</td>
   <td style="text-align:right">7.0.2</td>
   <td style="text-align:right">7</td>
  </tr>
 </tbody>
</table>
<p>当前 Vue 3.5 中值得纳入日常写法的能力包括：响应式 Props 解构、<code>useTemplateRef()</code>、<code>onWatcherCleanup()</code>、数字形式的 <code>deep</code> 监听深度，以及 Vue 3.4 起推荐的 <code>defineModel()</code>。Vue Router 5 已把文件路由和类型生成能力合并进核心包；Vite 8 则改用 Rolldown 作为统一打包器。</p>
<h2>阅读建议</h2>
<ul>
 <li>第一次学习 Vue：按 01 → 09 顺序阅读。</li>
 <li>已会 Vue 2：重点阅读 02、03、05、07、08。</li>
 <li>已在使用 Vue 3.2/3.3：重点关注 01、04、05、06、07，里面包含 3.4/3.5 之后的新写法。</li>
 <li>只做中小型 SPA：Vue + Vite + Router + Pinia 足够；需要 SSR、服务端路由或全栈能力时，再评估 Nuxt，不必为“技术栈完整”而盲目增加框架。</li>
</ul>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/quick-start.html">Vue 快速上手</a></li>
 <li><a href="https://vuejs.org/guide/typescript/overview.html">Vue TypeScript 指南</a></li>
 <li><a href="https://vite.dev/guide/">Vite 文档</a></li>
 <li><a href="https://router.vuejs.org/">Vue Router 文档</a></li>
 <li><a href="https://pinia.vuejs.org/">Pinia 文档</a></li>
</ul>
<blockquote>
 <p>下一篇：<a href="https://likeyy.love/archives/vue3-modern-project-setup-vite-8-typescript">Vue 3 现代工程搭建</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-modern-development-guide</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FCCA4FCBB-AEFE-479B-A621-CDB968FB2464.png&amp;size=m" type="image/jpeg" length="1700558"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:43 GMT</pubDate></item><item><title><![CDATA[Vue 3 进阶能力：浅层响应式、原始对象、Teleport 与 Suspense]]></title><link>https://likeyy.love/archives/vue3-advanced-reactivity-teleport-suspense</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E8%BF%9B%E9%98%B6%E8%83%BD%E5%8A%9B%EF%BC%9A%E6%B5%85%E5%B1%82%E5%93%8D%E5%BA%94%E5%BC%8F%E3%80%81%E5%8E%9F%E5%A7%8B%E5%AF%B9%E8%B1%A1%E3%80%81Teleport%20%E4%B8%8E%20Suspense&amp;url=/archives/vue3-advanced-reactivity-teleport-suspense" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 进阶能力：浅层响应式、原始对象、Teleport 与 Suspense</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/pinia-4-state-management">Pinia 4 状态管理</a></p>
</blockquote>
<p>Vue 默认的深层响应式适合绝大多数业务状态。只有在大型不可变数据、第三方类实例、外部状态系统或特殊渲染边界中，才需要 <code>shallowRef</code>、<code>markRaw</code> 等逃生舱 API。本篇还介绍 Teleport、异步组件、KeepAlive 和仍处于实验状态的 Suspense。</p>
<h2>一、<code>shallowRef</code>：只追踪 <code>.value</code> 替换</h2>
<p><code>shallowRef()</code> 不会把内部对象转成深层响应式。只有替换整个 <code>.value</code> 才会触发依赖：</p>
<pre><code class="language-ts">import { shallowRef } from 'vue'

interface Dashboard {
  title: string
  widgets: Array&lt;{ id: string; visible: boolean }&gt;
}

const dashboard = shallowRef&lt;Dashboard&gt;({
  title: 'Overview',
  widgets: [],
})

// 不会因为内部属性本身而触发更新
dashboard.value.title = 'Metrics'

// 推荐：替换根值
dashboard.value = {
  ...dashboard.value,
  title: 'Metrics',
}
</code></pre>
<p>它适合：</p>
<ul>
 <li>把大型不可变对象作为整体替换；</li>
 <li>保存图表、编辑器等第三方实例；</li>
 <li>与外部状态系统集成；</li>
 <li>避免 Vue 深度代理不需要观察的数据。</li>
</ul>
<p>如果确实在原对象上修改，并且需要手工通知依赖，可以使用 <code>triggerRef()</code>：</p>
<pre><code class="language-ts">import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({ count: 0 })
state.value.count++
triggerRef(state)
</code></pre>
<p>频繁依赖 <code>triggerRef()</code> 通常说明数据更新方式不够清晰，优先考虑不可变替换。</p>
<h2>二、<code>shallowReactive</code>：只有根属性响应式</h2>
<pre><code class="language-ts">import { shallowReactive } from 'vue'

const state = shallowReactive({
  count: 0,
  nested: { enabled: false },
})

state.count++ // 会触发更新
state.nested.enabled = true // 嵌套对象本身不是响应式的
state.nested = { enabled: true } // 替换根属性会触发更新
</code></pre>
<p>不要把浅层响应式对象嵌进深层响应式树中，这会产生难以解释的混合行为。它更适合作为清晰的状态根边界。</p>
<h2>三、<code>readonly</code> 与 <code>shallowReadonly</code></h2>
<p><code>readonly()</code> 返回一个深层只读代理：</p>
<pre><code class="language-ts">import { reactive, readonly } from 'vue'

const state = reactive({ count: 0 })
const publicState = readonly(state)

state.count++ // 合法，publicState 会同步反映变化
// publicState.count++ // 开发环境警告
</code></pre>
<p>它保护的是通过只读代理发生的修改，不会冻结原对象。常见用途是在 <code>provide()</code> 时提供只读状态，并同时提供修改动作。</p>
<p><code>shallowReadonly()</code> 只保护根属性，嵌套对象仍可修改。除非边界明确，否则深层 <code>readonly()</code> 更符合“使用者不可写”的直觉。</p>
<h2>四、<code>toRaw()</code>：临时访问原始对象</h2>
<p><code>toRaw()</code> 返回 Vue Proxy 背后的原始对象：</p>
<pre><code class="language-ts">import { reactive, toRaw } from 'vue'

const form = reactive({ name: 'Ada' })
const rawForm = toRaw(form)
</code></pre>
<p>适合临时传给严格检查对象身份、不能接收 Proxy 的外部 API。不要长期保存 <code>rawForm</code>，也不要通过它修改状态：对原始对象的写入会绕过响应式触发，容易造成界面与数据不一致。</p>
<p>序列化普通业务对象时通常不需要先调用 <code>toRaw()</code>，<code>JSON.stringify()</code> 可以读取响应式代理的可枚举属性。</p>
<h2>五、<code>markRaw()</code>：让对象保持非响应式</h2>
<pre><code class="language-ts">import { markRaw, reactive } from 'vue'

class MapController {
  destroy() {}
}

const state = reactive({
  controller: markRaw(new MapController()),
})
</code></pre>
<p><code>markRaw()</code> 适合第三方类实例、Vue 组件定义或不应被代理的复杂对象。它只保证被标记的根对象不会转为 Proxy；内部未标记对象如果之后进入响应式系统，仍可能被代理。不要对普通业务数据大面积使用它来“优化性能”。</p>
<h2>六、<code>customRef()</code>：自定义追踪与触发</h2>
<p>下面封装一个防抖 ref：</p>
<pre><code class="language-ts">// composables/useDebouncedRef.ts
import { customRef, onScopeDispose, type Ref } from 'vue'

export function useDebouncedRef&lt;T&gt;(
  initialValue: T,
  delay = 300,
): Ref&lt;T&gt; {
  let value = initialValue
  let timer: ReturnType&lt;typeof setTimeout&gt; | undefined

  onScopeDispose(() =&gt; {
    if (timer) clearTimeout(timer)
  })

  return customRef&lt;T&gt;((track, trigger) =&gt; ({
    get() {
      track()
      return value
    },
    set(nextValue) {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() =&gt; {
        value = nextValue
        trigger()
      }, delay)
    },
  }))
}
</code></pre>
<p>使用：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { useDebouncedRef } from '@/composables/useDebouncedRef'

const keyword = useDebouncedRef('', 300)
&lt;/script&gt;

&lt;template&gt;
  &lt;input v-model="keyword" type="search" /&gt;
  &lt;p&gt;防抖后的关键词：{{ keyword }}&lt;/p&gt;
&lt;/template&gt;
</code></pre>
<p><code>customRef()</code> 的 getter 不应每次创建新的对象，否则父子组件比较和更新可能出现意外行为。</p>
<h2>七、Teleport：改变 DOM 位置，不改变组件关系</h2>
<p>弹窗在组件树中可能嵌得很深，但 DOM 通常希望放在 <code>body</code> 下，以避开祖先的 <code>overflow</code>、<code>transform</code> 和层叠上下文。Teleport 可以移动渲染位置：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
defineProps&lt;{ open: boolean }&gt;()

const emit = defineEmits&lt;{
  close: []
}&gt;()
&lt;/script&gt;

&lt;template&gt;
  &lt;Teleport to="body"&gt;
    &lt;div v-if="open" class="backdrop" @click.self="emit('close')"&gt;
      &lt;section
        aria-labelledby="dialog-title"
        aria-modal="true"
        class="dialog"
        role="dialog"
      &gt;
        &lt;h2 id="dialog-title"&gt;确认操作&lt;/h2&gt;
        &lt;slot /&gt;
        &lt;button type="button" @click="emit('close')"&gt;关闭&lt;/button&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  &lt;/Teleport&gt;
&lt;/template&gt;
</code></pre>
<p>Teleport 只改变真实 DOM 的位置，Props、Emits、注入和逻辑父子关系仍按组件树工作。生产级对话框还要处理焦点圈定、Esc 关闭、关闭后恢复焦点和页面滚动锁定。</p>
<p><code>to</code> 指向的目标在挂载时必须存在。也可以通过 <code>:disabled</code> 暂时关闭传送。</p>
<h2>八、异步组件</h2>
<p>路由页面通常直接使用动态导入；普通组件可以使用 <code>defineAsyncComponent()</code>：</p>
<pre><code class="language-ts">import { defineAsyncComponent } from 'vue'
import LoadingPanel from './LoadingPanel.vue'
import ErrorPanel from './ErrorPanel.vue'

const AnalyticsPanel = defineAsyncComponent({
  loader: () =&gt; import('./AnalyticsPanel.vue'),
  loadingComponent: LoadingPanel,
  errorComponent: ErrorPanel,
  delay: 200,
  timeout: 10_000,
})
</code></pre>
<p>不要把首屏必需的小组件全部异步化。代码分割有网络请求和调度成本，应优先用于体积大、低频或条件出现的功能。</p>
<h2>九、Suspense 仍是实验性功能</h2>
<p><code>&lt;Suspense&gt;</code> 可以等待组件树中的异步 <code>setup()</code> 或异步组件，并显示统一 fallback：</p>
<pre><code class="language-vue">&lt;template&gt;
  &lt;Suspense&gt;
    &lt;DashboardPanel /&gt;

    &lt;template #fallback&gt;
      &lt;p&gt;加载中…&lt;/p&gt;
    &lt;/template&gt;
  &lt;/Suspense&gt;
&lt;/template&gt;
</code></pre>
<p>使用 <code>&lt;script setup&gt;</code> 顶层 <code>await</code> 的组件会成为异步依赖：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
const response = await fetch('/api/dashboard')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const dashboard = await response.json()
&lt;/script&gt;
</code></pre>
<p>需要明确三个边界：</p>
<ol>
 <li>截至本文更新时，Vue 官方仍把 Suspense 标为实验性 API；</li>
 <li>Suspense 本身不提供错误 UI，应结合 <code>onErrorCaptured()</code> 或上层错误处理；</li>
 <li>Vue Router 的路由懒加载与 Suspense 异步依赖不同，不会仅因为动态导入路由就自动触发 fallback。</li>
</ol>
<p>常规请求页面自行维护 <code>loading / error / data</code> 往往更直接。只有需要协调一棵异步组件树时，再考虑 Suspense。</p>
<h2>十、KeepAlive：缓存动态组件实例</h2>
<p><code>&lt;KeepAlive&gt;</code> 可以在组件切换后保留实例状态：</p>
<pre><code class="language-vue">&lt;KeepAlive :include="['UserList', 'UserDetail']" :max="5"&gt;
  &lt;component :is="activeView" /&gt;
&lt;/KeepAlive&gt;
</code></pre>
<p>被缓存组件会触发 <code>onActivated()</code> 和 <code>onDeactivated()</code>。缓存不是越多越好：实例、DOM 和订阅仍会占用资源，应通过 <code>include</code>、<code>exclude</code> 和 <code>max</code> 控制范围。</p>
<h2>十一、全局 API 属于应用实例</h2>
<p>Vue 3 的全局注册和配置都放在 <code>createApp()</code> 返回的应用实例上：</p>
<pre><code class="language-ts">const app = createApp(App)

app.component('BaseButton', BaseButton)
app.directive('focus', focusDirective)
app.use(router)
app.config.errorHandler = reportError
app.mount('#app')
</code></pre>
<p>这让同一页面上的多个 Vue 应用可以拥有隔离配置。全局注册会降低依赖可见性，普通业务组件仍优先局部导入。</p>
<h2>十二、使用逃生舱 API 的原则</h2>
<ul>
 <li>先用默认深层响应式，确认真实性能问题后再引入浅层 API；</li>
 <li>在状态根边界使用浅层 API，不要随意混合深层和浅层代理；</li>
 <li>第三方实例优先 <code>shallowRef</code> 或 <code>markRaw</code>；</li>
 <li><code>toRaw</code> 只做临时互操作，不作为长期写入口；</li>
 <li>Suspense 是实验性能力，升级 Vue 时要验证行为；</li>
 <li>性能优化应以测量为依据，而不是根据对象“看起来很大”猜测。</li>
</ul>
<h2>小结</h2>
<p><code>shallowRef</code>、<code>shallowReactive</code>、<code>toRaw</code>、<code>markRaw</code> 和 <code>customRef</code> 都是边界工具，不是默认状态 API。Teleport 解决 DOM 挂载位置，异步组件解决代码分割，KeepAlive 解决实例缓存；Suspense 能协调异步依赖，但目前仍需谨慎采用。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/api/reactivity-advanced.html">Advanced Reactivity API</a></li>
 <li><a href="https://vuejs.org/guide/built-ins/teleport.html">Teleport</a></li>
 <li><a href="https://vuejs.org/guide/components/async.html">Async Components</a></li>
 <li><a href="https://vuejs.org/guide/built-ins/suspense.html">Suspense</a></li>
 <li><a href="https://vuejs.org/guide/built-ins/keep-alive.html">KeepAlive</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">返回目录</a> · <a href="https://likeyy.love/archives/pinia-4-state-management">上一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-advanced-reactivity-teleport-suspense</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F4EE0076F-9F7A-41DA-B80D-6CB4472B324D.png&amp;size=m" type="image/jpeg" length="1458017"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:43 GMT</pubDate></item><item><title><![CDATA[Vue 3 跨层通信：模板引用、provide/inject 与事件总线]]></title><link>https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E8%B7%A8%E5%B1%82%E9%80%9A%E4%BF%A1%EF%BC%9A%E6%A8%A1%E6%9D%BF%E5%BC%95%E7%94%A8%E3%80%81provide%2Finject%20%E4%B8%8E%E4%BA%8B%E4%BB%B6%E6%80%BB%E7%BA%BF&amp;url=/archives/vue3-template-refs-provide-inject-event-bus" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 跨层通信：模板引用、<code>provide/inject</code> 与事件总线</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots">组件契约：Props、Emits、<code>defineModel</code> 与插槽</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue-router-5-type-safe-file-routing">Vue Router 5</a></p>
</blockquote>
<p>大多数组件通信都应先使用 Props、Emits、<code>v-model</code> 和插槽。只有当需求确实越过普通父子契约时，才需要模板引用、<code>provide/inject</code>、Pinia 或事件总线。选择标准不是“哪个 API 最省代码”，而是谁拥有状态、谁能修改状态，以及依赖是否足够明确。</p>
<h2>一、Vue 3.5 使用 <code>useTemplateRef()</code></h2>
<p>模板引用适合访问 DOM 或组件公开的方法。Vue 3.5 引入的 <code>useTemplateRef()</code> 比“变量名必须和 ref 字符串同名”的旧写法更清楚，也能得到更好的类型推断。</p>
<p>访问输入框：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { onMounted, useTemplateRef } from 'vue'

const searchInput = useTemplateRef&lt;HTMLInputElement&gt;('search-input')

onMounted(() =&gt; {
  searchInput.value?.focus()
})
&lt;/script&gt;

&lt;template&gt;
  &lt;input ref="search-input" type="search" /&gt;
&lt;/template&gt;
</code></pre>
<p>引用值在挂载前是 <code>null</code>，元素被 <code>v-if</code> 卸载后也会回到 <code>null</code>，所以访问时要使用可选链或类型守卫。</p>
<p>旧项目常见写法仍然有效：</p>
<pre><code class="language-ts">const searchInput = ref&lt;HTMLInputElement | null&gt;(null)
</code></pre>
<p>新代码优先使用 <code>useTemplateRef()</code>，除非项目需要兼容 Vue 3.4 或更早版本。</p>
<h2>二、组件引用只暴露必要能力</h2>
<p>使用 <code>&lt;script setup&gt;</code> 的组件默认是私有的。子组件通过 <code>defineExpose()</code> 显式声明公开 API。</p>
<p>子组件 <code>UserForm.vue</code>：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { reactive } from 'vue'

const form = reactive({ name: '', email: '' })

function validate() {
  return form.name.trim() !== '' &amp;&amp; form.email.includes('@')
}

function reset() {
  form.name = ''
  form.email = ''
}

defineExpose({ validate, reset })
&lt;/script&gt;

&lt;template&gt;
  &lt;input v-model="form.name" placeholder="姓名" /&gt;
  &lt;input v-model="form.email" type="email" placeholder="邮箱" /&gt;
&lt;/template&gt;
</code></pre>
<p>父组件：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { useTemplateRef } from 'vue'
import UserForm from './UserForm.vue'

const userForm = useTemplateRef&lt;InstanceType&lt;typeof UserForm&gt;&gt;('user-form')

function submit() {
  if (userForm.value?.validate()) {
    console.log('可以提交')
  }
}
&lt;/script&gt;

&lt;template&gt;
  &lt;UserForm ref="user-form" /&gt;
  &lt;button type="button" @click="submit"&gt;提交&lt;/button&gt;
&lt;/template&gt;
</code></pre>
<p>组件引用适合“聚焦、滚动、播放、校验、重置”等命令式能力，不适合把子组件内部状态全部暴露给父组件。能通过 Props 和 Emits 表达的数据流，仍应优先使用显式契约。</p>
<h2>三、不要把 <code>$parent</code> 和 <code>$refs</code> 当状态管理</h2>
<p>通过 <code>$parent</code> 修改父组件，或遍历 <code>$refs</code> 批量修改子组件，看似直接，却把组件和当前树结构绑死：一旦中间增加包装组件、重构布局或异步加载，关系就可能失效。</p>
<p>推荐替代方式：</p>
<ul>
 <li>父子数据：Props、Emits、<code>defineModel()</code>；</li>
 <li>祖先提供能力：<code>provide/inject</code>；</li>
 <li>跨页面业务状态：Pinia；</li>
 <li>少量命令式组件 API：模板引用 + <code>defineExpose()</code>。</li>
</ul>
<h2>四、类型安全的 <code>provide/inject</code></h2>
<p><code>provide/inject</code> 适合一棵组件子树共享上下文，例如表单、主题、当前用户能力或组件库内部状态。使用 <code>InjectionKey</code> 能让提供方和注入方共享类型。</p>
<p>先定义上下文：</p>
<pre><code class="language-ts">// context/counter.ts
import type { InjectionKey, Ref } from 'vue'

export interface CounterContext {
  count: Readonly&lt;Ref&lt;number&gt;&gt;
  increment: () =&gt; void
}

export const counterKey: InjectionKey&lt;CounterContext&gt; = Symbol('counter')
</code></pre>
<p>祖先组件提供只读状态和修改动作：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { provide, readonly, ref } from 'vue'
import { counterKey } from '@/context/counter'

const count = ref(0)

function increment() {
  count.value++
}

provide(counterKey, {
  count: readonly(count),
  increment,
})
&lt;/script&gt;

&lt;template&gt;
  &lt;slot /&gt;
&lt;/template&gt;
</code></pre>
<p>任意后代组件注入：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { inject } from 'vue'
import { counterKey } from '@/context/counter'

const counter = inject(counterKey)

if (!counter) {
  throw new Error('CounterButton 必须在 CounterProvider 内使用')
}
&lt;/script&gt;

&lt;template&gt;
  &lt;button type="button" @click="counter.increment"&gt;
    {{ counter.count }}
  &lt;/button&gt;
&lt;/template&gt;
</code></pre>
<p>让提供方保留修改权，并把动作一起提供出去，比后代任意修改共享 ref 更容易维护。</p>
<h2>五、什么时候用 Pinia，而不是 <code>provide/inject</code></h2>
<p><code>provide/inject</code> 的作用域跟组件树绑定；Pinia 的 Store 跟应用绑定，并有 DevTools、插件、SSR 约定和更明确的业务状态模型。</p>
<table>
 <thead>
  <tr>
   <th>场景</th>
   <th>推荐</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>组件库内部上下文</td>
   <td><code>provide/inject</code></td>
  </tr>
  <tr>
   <td>某个页面子树的临时共享能力</td>
   <td><code>provide/inject</code></td>
  </tr>
  <tr>
   <td>当前用户、购物车、跨路由草稿</td>
   <td>Pinia</td>
  </tr>
  <tr>
   <td>需要 DevTools、持久化插件或 SSR 水合</td>
   <td>Pinia</td>
  </tr>
  <tr>
   <td>普通父子通信</td>
   <td>Props / Emits</td>
  </tr>
 </tbody>
</table>
<p>不要仅仅为了避免一层 Props 就引入全局 Store；也不要用 <code>provide/inject</code> 隐藏整个应用的业务状态。</p>
<h2>六、事件总线只处理真正的瞬时事件</h2>
<p>Vue 3 移除了实例上的 <code>$on</code>、<code>$off</code> 和 <code>$once</code>。如果极少数场景确实需要无直接关系组件之间广播瞬时事件，可以使用 <code>mitt</code>：</p>
<pre><code class="language-bash">npm install mitt
</code></pre>
<p>创建类型安全的事件器：</p>
<pre><code class="language-ts">// events/app-events.ts
import mitt from 'mitt'

export interface AppEvents {
  'toast:show': {
    message: string
    kind: 'success' | 'error'
  }
  'session:expired': void
}

export const appEvents = mitt&lt;AppEvents&gt;()
</code></pre>
<p>发送事件：</p>
<pre><code class="language-ts">appEvents.emit('toast:show', {
  message: '保存成功',
  kind: 'success',
})
</code></pre>
<p>订阅组件必须使用同一个函数引用解绑：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { onMounted, onUnmounted } from 'vue'
import { appEvents, type AppEvents } from '@/events/app-events'

function showToast(payload: AppEvents['toast:show']) {
  console.log(payload.message)
}

onMounted(() =&gt; {
  appEvents.on('toast:show', showToast)
})

onUnmounted(() =&gt; {
  appEvents.off('toast:show', showToast)
})
&lt;/script&gt;
</code></pre>
<p>事件总线不应保存状态。组件晚于事件挂载时会错过消息，事件来源也难以追踪。当前用户、加载结果等“需要随时读取的事实”应放在 Props、Pinia 或其他明确状态容器里。</p>
<h2>七、选择通信方式的判断顺序</h2>
<p>遇到通信需求时，可以依次问：</p>
<ol>
 <li>数据是否只属于一个父组件？使用 Props / Emits。</li>
 <li>是否是受控输入？使用 <code>defineModel()</code>。</li>
 <li>是否只是让父组件决定渲染结构？使用插槽。</li>
 <li>是否是某棵子树的上下文？使用 <code>provide/inject</code>。</li>
 <li>是否跨路由、需要长期存在和调试？使用 Pinia。</li>
 <li>是否是无需保存、可能有多个监听者的瞬时广播？最后再考虑事件总线。</li>
 <li>是否必须调用 DOM 或子组件命令？使用模板引用，并缩小公开 API。</li>
</ol>
<h2>小结</h2>
<p>Vue 3.5 的 <code>useTemplateRef()</code> 是访问 DOM 和公开组件能力的现代方式；<code>provide/inject</code> 适合树形上下文，Pinia 适合应用级业务状态。事件总线应是少数瞬时广播的工具，而不是默认通信方案。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/essentials/template-refs.html">Template Refs</a></li>
 <li><a href="https://vuejs.org/api/composition-api-helpers.html#usetemplateref"><code>useTemplateRef()</code></a></li>
 <li><a href="https://vuejs.org/guide/components/provide-inject.html">Provide / Inject</a></li>
 <li><a href="https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject">TypeScript with Provide / Inject</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots">上一篇</a> · <a href="https://likeyy.love/archives/vue-router-5-type-safe-file-routing">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-template-refs-provide-inject-event-bus</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FB379DFF4-4692-4752-BABA-CD140C25DC22.png&amp;size=m" type="image/jpeg" length="1628317"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:43 GMT</pubDate></item><item><title><![CDATA[Vue Router 5：手写路由与类型安全的文件路由]]></title><link>https://likeyy.love/archives/vue-router-5-type-safe-file-routing</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%20Router%205%EF%BC%9A%E6%89%8B%E5%86%99%E8%B7%AF%E7%94%B1%E4%B8%8E%E7%B1%BB%E5%9E%8B%E5%AE%89%E5%85%A8%E7%9A%84%E6%96%87%E4%BB%B6%E8%B7%AF%E7%94%B1&amp;url=/archives/vue-router-5-type-safe-file-routing" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue Router 5：手写路由与类型安全的文件路由</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus">跨层通信与模板引用</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/pinia-4-state-management">Pinia 4 状态管理</a></p>
</blockquote>
<p>Vue Router 5 是 Vue 的官方路由方案。它保留了 Vue Router 4 的手写路由 API，并把原 <code>unplugin-vue-router</code> 的文件路由和类型生成能力合并进核心包。普通 Router 4 项目若没有使用该插件，升级到 5 通常不需要修改路由代码。</p>
<p>本文先讲所有项目都适用的核心 API，再介绍 Router 5 的文件路由。两种模式选一种即可，不需要同时维护两套路由表。</p>
<h2>一、安装和注册</h2>
<p>使用 <code>create-vue</code> 时可以直接选择 Vue Router。手工安装：</p>
<pre><code class="language-bash">npm install vue-router@5
</code></pre>
<p>创建 <code>src/router/index.ts</code>：</p>
<pre><code class="language-ts">import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: { name: 'home' },
  },
  {
    path: '/home',
    name: 'home',
    component: () =&gt; import('@/views/HomeView.vue'),
  },
  {
    path: '/about',
    name: 'about',
    component: () =&gt; import('@/views/AboutView.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior: () =&gt; ({ top: 0 }),
})

export default router
</code></pre>
<p>路由页面使用动态导入，可以在生产构建中按路由拆包。入口文件注册路由：</p>
<pre><code class="language-ts">import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App)
  .use(router)
  .mount('#app')
</code></pre>
<p>根组件放置导航和出口：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { RouterLink, RouterView } from 'vue-router'
&lt;/script&gt;

&lt;template&gt;
  &lt;nav aria-label="主导航"&gt;
    &lt;RouterLink :to="{ name: 'home' }"&gt;首页&lt;/RouterLink&gt;
    &lt;RouterLink :to="{ name: 'about' }"&gt;关于&lt;/RouterLink&gt;
  &lt;/nav&gt;

  &lt;RouterView /&gt;
&lt;/template&gt;
</code></pre>
<p><code>RouterLink</code> 最终会渲染可访问的链接；普通站内跳转应优先使用它，而不是给按钮绑定 <code>router.push()</code>。</p>
<h2>二、History 与 Hash 模式</h2>
<p><code>createWebHistory()</code> 生成没有 <code>#</code> 的常规 URL：</p>
<pre><code class="language-ts">history: createWebHistory(import.meta.env.BASE_URL)
</code></pre>
<p>生产服务器必须把未知前端路径回退到 <code>index.html</code>，同时不要错误吞掉真正的静态资源和 API 404。Nginx 常见配置思路是：</p>
<pre><code class="language-nginx">location / {
  try_files $uri $uri/ /index.html;
}
</code></pre>
<p>无法配置服务器回退时，可以使用 Hash：</p>
<pre><code class="language-ts">import { createWebHashHistory } from 'vue-router'

history: createWebHashHistory(import.meta.env.BASE_URL)
</code></pre>
<p>Hash 后的内容不会发送给服务器，部署简单，但 URL 中会出现 <code>#</code>。</p>
<h2>三、动态参数与 Props</h2>
<p>声明用户详情路由：</p>
<pre><code class="language-ts">import type { RouteRecordRaw } from 'vue-router'

const userDetailRoute = {
  path: '/users/:id',
  name: 'user-detail',
  component: () =&gt; import('@/views/UserDetailView.vue'),
  props: (route) =&gt; ({ id: String(route.params.id) }),
} satisfies RouteRecordRaw
</code></pre>
<p>通过命名路由跳转：</p>
<pre><code class="language-vue">&lt;RouterLink
  :to="{
    name: 'user-detail',
    params: { id: user.id },
  }"
&gt;
  {{ user.name }}
&lt;/RouterLink&gt;
</code></pre>
<p>页面把路由参数当普通 Prop 接收：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
defineProps&lt;{ id: string }&gt;()
&lt;/script&gt;
</code></pre>
<p>这种方式让页面组件更容易测试，也减少组件对 <code>useRoute()</code> 的直接依赖。</p>
<p>使用对象跳转并携带 <code>params</code> 时，应使用 <code>name</code>；如果提供 <code>path</code>，额外的 <code>params</code> 会被忽略。未在路径中声明的临时信息应放在 <code>query</code>，需要长期存在的数据则应由 API 或 Store 管理。</p>
<h2>四、Query 参数和编程式导航</h2>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

const keyword = computed(() =&gt; {
  const value = route.query.q
  return typeof value === 'string' ? value : ''
})

function search(q: string) {
  router.push({
    name: 'search',
    query: { q, page: '1' },
  })
}

function closeModal() {
  router.back()
}
&lt;/script&gt;
</code></pre>
<p>Query 值可能是字符串、字符串数组、<code>null</code> 或缺失值，使用前要做类型收窄。URL 参数最终都是文本，不要假设 <code>route.params.id</code> 或 <code>route.query.page</code> 自动成为数字。</p>
<p>常用导航方法：</p>
<ul>
 <li><code>router.push()</code>：增加一条历史记录；</li>
 <li><code>router.replace()</code>：替换当前记录；</li>
 <li><code>router.back()</code> / <code>router.forward()</code>：前进后退；</li>
 <li><code>&lt;RouterLink replace&gt;</code>：声明式替换当前记录。</li>
</ul>
<h2>五、嵌套路由</h2>
<p>父页面必须包含自己的 <code>&lt;RouterView&gt;</code>：</p>
<pre><code class="language-ts">import type { RouteRecordRaw } from 'vue-router'

const settingsRoute = {
  path: '/settings',
  component: () =&gt; import('@/views/settings/SettingsLayout.vue'),
  children: [
    {
      path: '',
      name: 'settings-profile',
      component: () =&gt; import('@/views/settings/ProfileSettings.vue'),
    },
    {
      path: 'security',
      name: 'settings-security',
      component: () =&gt; import('@/views/settings/SecuritySettings.vue'),
    },
  ],
} satisfies RouteRecordRaw
</code></pre>
<p>子路由的 <code>path</code> 不以 <code>/</code> 开头，最终路径分别是 <code>/settings</code> 和 <code>/settings/security</code>。</p>
<h2>六、导航守卫</h2>
<p>导航守卫可以返回目标位置或 <code>false</code>，不必调用旧式 <code>next()</code>：</p>
<pre><code class="language-ts">router.beforeEach(async (to) =&gt; {
  const requiresAuth = to.meta.requiresAuth === true
  const signedIn = await checkSession()

  if (requiresAuth &amp;&amp; !signedIn) {
    return {
      name: 'login',
      query: { redirect: to.fullPath },
    }
  }
})
</code></pre>
<p>避免在模块顶层、Pinia 尚未安装前创建 Store。需要在守卫中访问 Store 时，在守卫回调里调用 <code>useXxxStore()</code>，或显式传入已创建的 Pinia 实例。</p>
<p>元信息可以通过模块扩展类型化：</p>
<pre><code class="language-ts">import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
  }
}
</code></pre>
<h2>七、Router 5 的文件路由</h2>
<p>Router 5 内置文件路由插件，可以根据 <code>src/pages</code> 自动生成路由和类型。配置 <code>vite.config.ts</code>：</p>
<pre><code class="language-ts">import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import VueRouter from 'vue-router/vite'

export default defineConfig({
  plugins: [
    VueRouter({
      dts: 'src/route-map.d.ts',
    }),
    // Vue 插件必须放在 VueRouter 后面
    vue(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})
</code></pre>
<p>创建文件：</p>
<pre><code class="language-text">src/pages/
├── index.vue          # /
├── about.vue          # /about
└── users/
    └── [id].vue       # /users/:id
</code></pre>
<p>入口使用生成的路由：</p>
<pre><code class="language-ts">import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { handleHotUpdate, routes } from 'vue-router/auto-routes'
import App from './App.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

if (import.meta.hot) {
  handleHotUpdate(router)
}

createApp(App).use(router).mount('#app')
</code></pre>
<p>启动开发服务器后会生成类型文件。把生成文件提交到仓库，并确保它包含在 TypeScript 配置中。官方还提供 Vue 语言工具插件，让页面内的 <code>useRoute()</code> 根据当前文件推断参数类型。</p>
<p>文件路由适合路由较多、重视参数类型和约定式目录的项目；路由很少或有大量动态配置时，手写 <code>routes</code> 数组依然简单可靠。</p>
<h2>八、从 Router 4 升级</h2>
<p>Router 5 是过渡版本：</p>
<ul>
 <li>未使用 <code>unplugin-vue-router</code> 的 Router 4 项目没有业务 API 破坏性变化；</li>
 <li>使用文件路由插件的项目主要需要把导入路径迁到 <code>vue-router/vite</code>、<code>vue-router/auto-routes</code> 等新入口；</li>
 <li>Router 5 为未来 ESM-only 的 Router 6 提供迁移窗口，应逐步清理弃用 API。</li>
</ul>
<h2>小结</h2>
<p>核心路由 API 仍围绕 <code>createRouter()</code>、History、路由记录、<code>RouterLink</code> 和 <code>RouterView</code>。页面组件优先懒加载，动态参数优先通过 Props 接收。Router 5 的新增价值主要是内置类型安全的文件路由；小项目继续手写路由完全没有问题。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://router.vuejs.org/guide/">Vue Router 指南</a></li>
 <li><a href="https://router.vuejs.org/file-based-routing/">Vue Router 文件路由</a></li>
 <li><a href="https://router.vuejs.org/guide/migration/v4-to-v5">从 Vue Router 4 迁移到 5</a></li>
 <li><a href="https://router.vuejs.org/guide/essentials/history-mode.html">History 模式部署</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus">上一篇</a> · <a href="https://likeyy.love/archives/pinia-4-state-management">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue-router-5-type-safe-file-routing</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FA4CF1EDA-57C2-4A1D-BAD2-9A2AE5139400.png&amp;size=m" type="image/jpeg" length="1638015"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:43 GMT</pubDate></item><item><title><![CDATA[Pinia 4：状态、Getter、Action、订阅与持久化]]></title><link>https://likeyy.love/archives/pinia-4-state-management</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Pinia%204%EF%BC%9A%E7%8A%B6%E6%80%81%E3%80%81Getter%E3%80%81Action%E3%80%81%E8%AE%A2%E9%98%85%E4%B8%8E%E6%8C%81%E4%B9%85%E5%8C%96&amp;url=/archives/pinia-4-state-management" width="1" height="1" alt="" style="opacity:0;">
<h1>Pinia 4：状态、Getter、Action、订阅与持久化</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue-router-5-type-safe-file-routing">Vue Router 5</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-advanced-reactivity-teleport-suspense">Vue 3 进阶响应式与内置组件</a></p>
</blockquote>
<p>Pinia 是 Vue 官方推荐的状态管理库。一个 Store 包含状态、派生状态和业务动作，并通过 Vue DevTools 提供可追踪的更新记录。它适合当前用户、购物车、跨路由草稿等应用级状态，不应取代组件内部的 <code>ref</code> 或普通父子通信。</p>
<p>本文基于 Pinia 4。它的日常 Store API 与 Pinia 3 基本一致，主要升级点是只发布 ESM，并把 <code>@vue/devtools-api</code> 升级为需要一同安装的 peer dependency。</p>
<h2>一、安装和注册</h2>
<p>使用当前 <code>create-vue</code> 并选择 Pinia 时，脚手架会处理兼容依赖。手工安装 Pinia 4：</p>
<pre><code class="language-bash">npm install pinia @vue/devtools-api
</code></pre>
<p>在 <code>src/main.ts</code> 注册：</p>
<pre><code class="language-ts">import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)

app.use(createPinia())
app.mount('#app')
</code></pre>
<p>Pinia 4 是 ESM-only。如果旧构建脚本仍通过 <code>require()</code> 加载 Pinia，应先把构建和配置迁到 ESM。</p>
<h2>二、什么状态应该进入 Store</h2>
<p>适合进入 Pinia：</p>
<ul>
 <li>登录用户和权限；</li>
 <li>购物车、跨页面筛选条件；</li>
 <li>多个远距离组件共同读写的业务状态；</li>
 <li>需要 DevTools、持久化或 SSR 水合的状态。</li>
</ul>
<p>不必进入 Pinia：</p>
<ul>
 <li>只属于一个组件的弹窗开关；</li>
 <li>输入框即时值；</li>
 <li>可由 Props 或现有状态直接计算出的值；</li>
 <li>DOM 节点、第三方库实例等不可序列化对象。</li>
</ul>
<p>Store 越全局，修改它的影响范围越大。不要把所有 <code>ref</code> 搬进 Pinia 来追求“统一”。</p>
<h2>三、Setup Store</h2>
<p>Setup Store 与组合式函数写法一致：<code>ref</code> 对应 state，<code>computed</code> 对应 getter，函数对应 action。</p>
<pre><code class="language-ts">// stores/todos.ts
import { computed, ref } from 'vue'
import { acceptHMRUpdate, defineStore } from 'pinia'

export interface Todo {
  id: string
  title: string
  done: boolean
}

export const useTodoStore = defineStore('todos', () =&gt; {
  const items = ref&lt;Todo[]&gt;([])
  const loading = ref(false)

  const completed = computed(
    () =&gt; items.value.filter((item) =&gt; item.done),
  )
  const pendingCount = computed(
    () =&gt; items.value.length - completed.value.length,
  )

  function add(title: string) {
    const normalized = title.trim()
    if (!normalized) return

    items.value.push({
      id: crypto.randomUUID(),
      title: normalized,
      done: false,
    })
  }

  function toggle(id: string) {
    const todo = items.value.find((item) =&gt; item.id === id)
    if (todo) todo.done = !todo.done
  }

  function $reset() {
    items.value = []
    loading.value = false
  }

  return {
    items,
    loading,
    completed,
    pendingCount,
    add,
    toggle,
    $reset,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useTodoStore, import.meta.hot))
}
</code></pre>
<p>Setup Store 必须返回需要被 Pinia 管理的状态，否则 DevTools、SSR 和插件无法看到它。Options Store 自带 <code>$reset()</code>，Setup Store 则像上面一样自行实现。</p>
<h2>四、在组件中使用 Store</h2>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTodoStore } from '@/stores/todos'

const draft = ref('')
const todoStore = useTodoStore()
const { items, pendingCount } = storeToRefs(todoStore)
const { add, toggle } = todoStore

function submit() {
  add(draft.value)
  draft.value = ''
}
&lt;/script&gt;

&lt;template&gt;
  &lt;form @submit.prevent="submit"&gt;
    &lt;input v-model="draft" placeholder="新增任务" /&gt;
    &lt;button&gt;添加&lt;/button&gt;
  &lt;/form&gt;

  &lt;p&gt;待完成：{{ pendingCount }}&lt;/p&gt;
  &lt;ul&gt;
    &lt;li v-for="item in items" :key="item.id"&gt;
      &lt;label&gt;
        &lt;input
          :checked="item.done"
          type="checkbox"
          @change="toggle(item.id)"
        /&gt;
        {{ item.title }}
      &lt;/label&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
&lt;/template&gt;
</code></pre>
<p>直接解构 Store 的 state 或 getter 会失去响应式连接，所以使用 <code>storeToRefs()</code>。Action 已经绑定到 Store，可以直接解构函数。Vue 的 <code>toRefs()</code> 会把 Store 方法等也纳入处理，不适合替代 <code>storeToRefs()</code>。</p>
<h2>五、Options Store</h2>
<p>喜欢 <code>state / getters / actions</code> 结构时，可以使用 Options Store：</p>
<pre><code class="language-ts">import { defineStore } from 'pinia'

interface CounterState {
  count: number
  step: number
}

export const useCounterStore = defineStore('counter', {
  state: (): CounterState =&gt; ({
    count: 0,
    step: 1,
  }),
  getters: {
    doubled: (state) =&gt; state.count * 2,
    summary(): string {
      return `${this.count} × 2 = ${this.doubled}`
    },
  },
  actions: {
    increment() {
      this.count += this.step
    },
  },
})
</code></pre>
<p>两种 Store 形式能力相近。团队可以按逻辑复杂度和代码风格选择，不需要把 Options Store 描述成“旧写法”。</p>
<h2>六、修改状态的方式</h2>
<p>Pinia 允许直接修改：</p>
<pre><code class="language-ts">counterStore.count++
</code></pre>
<p>批量修改使用 <code>$patch()</code>：</p>
<pre><code class="language-ts">counterStore.$patch({
  count: 10,
  step: 2,
})
</code></pre>
<p>集合修改可以使用函数形式，多个变化会归为一次 DevTools 记录：</p>
<pre><code class="language-ts">todoStore.$patch((state) =&gt; {
  state.items.push(firstTodo, secondTodo)
})
</code></pre>
<p>涉及校验、异步流程或会被多处调用的修改，放进 Action。即使直接赋值合法，也不意味着所有业务规则都应散落在组件里。</p>
<h2>七、异步 Action</h2>
<p>Action 可以直接使用 <code>async/await</code>，并集中维护加载和错误状态：</p>
<pre><code class="language-ts">const error = ref&lt;string | null&gt;(null)

async function fetchTodos() {
  loading.value = true
  error.value = null

  try {
    const response = await fetch('/api/todos')
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    items.value = await response.json()
  } catch (cause) {
    error.value = cause instanceof Error ? cause.message : '加载失败'
    throw cause
  } finally {
    loading.value = false
  }
}
</code></pre>
<p>是否在 Store 中捕获错误取决于职责：Store 可以保存可共享的错误状态，但页面仍应决定如何呈现通知、重试按钮或错误边界。</p>
<h2>八、订阅与持久化</h2>
<p><code>$subscribe()</code> 可以监听状态变更：</p>
<pre><code class="language-ts">const stop = todoStore.$subscribe((mutation, state) =&gt; {
  console.log(mutation.type)
  localStorage.setItem('todos', JSON.stringify(state.items))
})
</code></pre>
<p>不再需要时调用 <code>stop()</code>。组件 <code>setup()</code> 中创建的订阅默认会跟随组件作用域；应用级持久化通常应在初始化模块或 Pinia 插件中安装一次，避免多个组件重复写入。</p>
<p>一个最小的客户端恢复过程：</p>
<pre><code class="language-ts">const saved = localStorage.getItem('todos')

if (saved) {
  try {
    todoStore.items = JSON.parse(saved)
  } catch {
    localStorage.removeItem('todos')
  }
}
</code></pre>
<p>真实项目还要处理：</p>
<ul>
 <li>数据结构版本和迁移；</li>
 <li>存储配额、JSON 解析失败；</li>
 <li>SSR 环境没有 <code>window</code> / <code>localStorage</code>；</li>
 <li>多标签页同步；</li>
 <li>用户退出时清理数据；</li>
 <li>敏感信息不能因为“方便”就持久化到浏览器。</li>
</ul>
<p>需求复杂时，选择维护活跃且支持当前 Pinia 主版本的持久化插件，并在升级前验证其 ESM 和 SSR 兼容性。</p>
<h2>九、在组件外使用 Store</h2>
<p><code>useXxxStore()</code> 依赖已激活的 Pinia。不要在路由、请求模块的顶层过早调用：</p>
<pre><code class="language-ts">// 容易在 app.use(pinia) 之前执行
// const userStore = useUserStore()
</code></pre>
<p>在函数内部调用，确保应用已经安装 Pinia：</p>
<pre><code class="language-ts">router.beforeEach(() =&gt; {
  const userStore = useUserStore()
  // ...
})
</code></pre>
<p>SSR 或多应用实例中，应显式传递 Pinia 实例，避免请求之间共享状态。</p>
<h2>十、Pinia 4 迁移检查</h2>
<p>从 Pinia 3 升级到 4 时重点检查：</p>
<ol>
 <li>构建、测试和 Node 脚本是否都能消费 ESM；</li>
 <li>是否安装了兼容版本的 <code>@vue/devtools-api</code>；</li>
 <li>Pinia 插件和持久化插件是否支持 4.x；</li>
 <li>CI 是否覆盖生产构建、Store 测试和 SSR（如有）；</li>
 <li>旧依赖是否偷偷通过 CommonJS <code>require('pinia')</code> 加载。</li>
</ol>
<h2>小结</h2>
<p>Pinia Store 应围绕跨组件的业务状态建模。<code>storeToRefs()</code> 用于解构 state 和 getter，Action 承担可复用业务修改，<code>$patch()</code> 适合批量变化。Pinia 4 的业务 API 延续性很强，但 ESM-only 和 DevTools peer dependency 是升级时必须验证的边界。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://pinia.vuejs.org/introduction.html">Pinia Introduction</a></li>
 <li><a href="https://pinia.vuejs.org/core-concepts/">Defining a Store</a></li>
 <li><a href="https://pinia.vuejs.org/core-concepts/state.html">State</a></li>
 <li><a href="https://pinia.vuejs.org/core-concepts/actions.html">Actions</a></li>
 <li><a href="https://github.com/vuejs/pinia/releases/tag/v4.0.0">Pinia 4 Release</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue-router-5-type-safe-file-routing">上一篇</a> · <a href="https://likeyy.love/archives/vue3-advanced-reactivity-teleport-suspense">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/pinia-4-state-management</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F7653E5F0-53B7-4202-A7D3-16D34917EA31.png&amp;size=m" type="image/jpeg" length="1896719"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:43 GMT</pubDate></item><item><title><![CDATA[Vue 3 现代工程搭建：create-vue、Vite 8 与 TypeScript]]></title><link>https://likeyy.love/archives/vue3-modern-project-setup-vite-8-typescript</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E7%8E%B0%E4%BB%A3%E5%B7%A5%E7%A8%8B%E6%90%AD%E5%BB%BA%EF%BC%9Acreate-vue%E3%80%81Vite%208%20%E4%B8%8E%20TypeScript&amp;url=/archives/vue3-modern-project-setup-vite-8-typescript" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 现代工程搭建：create-vue、Vite 8 与 TypeScript</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-composition-api-script-setup">Composition API 与 <code>&lt;script setup&gt;</code></a></p>
</blockquote>
<p>2026 年新建 Vue 单页应用时，推荐起点是官方脚手架 <code>create-vue</code>。它生成 Vue 3 + Vite 项目，并按选择加入 TypeScript、Vue Router、Pinia、Vitest、端到端测试、ESLint 和 Prettier。Vue CLI 已进入维护模式，不再适合作为新项目教程的主线。</p>
<p>本文以 2026-08-31 的稳定技术栈为基线：Vue 3.5、Vite 8、Vue Router 5、Pinia 4 和 TypeScript 7。版本会变化，所以项目里应提交 lockfile，并通过自动化依赖更新逐步升级。</p>
<h2>一、先确认该不该直接使用 Vite</h2>
<p>直接使用 Vue + Vite 很适合以下项目：</p>
<ul>
 <li>后台管理、桌面 Web、嵌入式页面等 SPA；</li>
 <li>前后端分离，后端已经提供 API；</li>
 <li>不依赖搜索引擎抓取首屏 HTML；</li>
 <li>希望自己掌控路由、状态管理和部署方式。</li>
</ul>
<p>如果项目明确需要 SSR、服务端路由、服务端数据获取、混合渲染或完整的全栈约定，可以优先评估 Nuxt。不要因为 Nuxt 功能更多就默认使用它；纯 SPA 用 Vite 的结构更轻。</p>
<h2>二、准备 Node.js</h2>
<p>当前 Vue 官方快速上手页面要求 <code>create-vue</code> 使用：</p>
<pre><code class="language-text">Node.js ^22.18.0 || &gt;=24.12.0
</code></pre>
<p>先检查本机版本：</p>
<pre><code class="language-bash">node --version
npm --version
</code></pre>
<p>Vite 8 自身的最低要求是 Node 20.19+ 或 22.12+，但 <code>create-vue</code> 的要求更高。新建项目时应以脚手架要求为准，否则可能出现“Vite 能运行，但脚手架不能启动”的情况。</p>
<p>推荐使用 fnm、nvm 或 Volta 管理 Node 版本，并在团队项目中提交 <code>.nvmrc</code>、<code>.node-version</code> 或 Volta 配置，避免开发机和 CI 使用不同主版本。</p>
<h2>三、创建项目</h2>
<p>在准备存放项目的目录运行：</p>
<pre><code class="language-bash">npm create vue@latest
</code></pre>
<p>一个偏工程化、但不过度配置的选择可以是：</p>
<pre><code class="language-text">Project name: vue-app
Add TypeScript? Yes
Add JSX Support? No
Add Vue Router? Yes（多页面视图时选择）
Add Pinia? Yes（确实有跨页面共享状态时选择）
Add Vitest? Yes
Add an End-to-End Testing Solution? 按团队选择
Add ESLint? Yes
Add Prettier? 按团队选择
Add Vue DevTools extension? 按需选择
</code></pre>
<p>这里没有一套对所有项目都正确的答案：</p>
<ul>
 <li>JSX 不是使用 Vue 的前提，普通项目使用 SFC 模板即可；</li>
 <li>只有一个页面时可以先不装 Router；</li>
 <li>只在父子组件间传值时不需要 Pinia；</li>
 <li>ESLint 负责发现问题，Prettier 负责统一格式，两者职责不同。</li>
</ul>
<p>安装依赖并启动：</p>
<pre><code class="language-bash">cd vue-app
npm install
npm run dev
</code></pre>
<p>生产构建和本地预览：</p>
<pre><code class="language-bash">npm run build
npm run preview
</code></pre>
<p><code>preview</code> 只用于本地检查构建产物，不是生产服务器。</p>
<h2>四、认识生成的目录</h2>
<p>典型目录如下：</p>
<pre><code class="language-text">vue-app/
├── public/              # 原样复制的静态资源
├── src/
│   ├── assets/          # 会进入构建管线的资源
│   ├── components/      # 可复用组件
│   ├── router/          # 选择 Router 后生成
│   ├── stores/          # 选择 Pinia 后生成
│   ├── App.vue          # 根组件
│   └── main.ts          # 应用入口
├── index.html           # Vite 的 HTML 入口
├── vite.config.ts
└── package.json
</code></pre>
<p>入口文件通常非常短：</p>
<pre><code class="language-ts">import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'

createApp(App).mount('#app')
</code></pre>
<p>如果选择了 Router 和 Pinia，插件要在 <code>mount()</code> 前注册：</p>
<pre><code class="language-ts">import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')
</code></pre>
<h2>五、用现代 SFC 写第一个组件</h2>
<p>官方模板默认使用 Composition API 和 <code>&lt;script setup&gt;</code>。一个最小组件可以写成：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { ref } from 'vue'

const count = ref(0)
&lt;/script&gt;

&lt;template&gt;
  &lt;main class="counter"&gt;
    &lt;h1&gt;Vue 3 + TypeScript&lt;/h1&gt;
    &lt;button type="button" @click="count++"&gt;
      已点击 {{ count }} 次
    &lt;/button&gt;
  &lt;/main&gt;
&lt;/template&gt;

&lt;style scoped&gt;
.counter {
  max-width: 40rem;
  margin: 4rem auto;
}
&lt;/style&gt;
</code></pre>
<p>模板中会自动解包 <code>ref</code>，所以写 <code>count</code>；脚本中读取或修改时则写 <code>count.value</code>。事件表达式里的 <code>count++</code> 属于模板，因此也不需要 <code>.value</code>。</p>
<h2>六、编辑器和类型检查</h2>
<p>VS Code 推荐安装官方扩展 <strong>Vue - Official</strong>（扩展 ID：<code>Vue.volar</code>）。旧项目如果仍安装 Vetur，应避免让两个 Vue 语言服务同时处理同一工作区。</p>
<p>Vite 只转译 TypeScript，不负责完整的类型检查。构建脚本通常会结合 <code>vue-tsc</code>：</p>
<pre><code class="language-json">{
  "scripts": {
    "dev": "vite",
    "build": "run-p type-check \"build-only {@}\" --",
    "build-only": "vite build",
    "type-check": "vue-tsc --build"
  }
}
</code></pre>
<p>具体脚本以 <code>create-vue</code> 生成结果为准，不必手工复制旧教程里的配置。</p>
<h2>七、Vite 8 带来了什么</h2>
<p>Vite 8 使用 Rolldown 作为统一的 Rust 打包器，取代过去“开发阶段由 esbuild、生产阶段由 Rollup”这一双打包器结构。大多数普通 Vue 项目不需要修改业务代码，但从旧 Vite 升级时仍应：</p>
<ol>
 <li>阅读对应版本的迁移指南；</li>
 <li>检查自定义 <code>rollupOptions</code>、构建插件和 CommonJS 依赖；</li>
 <li>在 CI 中执行类型检查、单元测试和生产构建；</li>
 <li>对大型项目比较构建产物、分包和运行时行为。</li>
</ol>
<p>不要仅凭开发服务器能启动就认为升级完成。</p>
<h2>八、旧 Vue CLI 项目怎么处理</h2>
<p>旧项目可以继续维护，但新功能开发前应评估迁移到 Vite：</p>
<ul>
 <li><code>process.env.VUE_APP_*</code> 改为 <code>import.meta.env.VITE_*</code>；</li>
 <li><code>require.context</code> 通常改为 <code>import.meta.glob</code>；</li>
 <li><code>vue.config.js</code> 中的 webpack 配置需要映射到 Vite/Rolldown 配置；</li>
 <li>检查依赖是否依赖 Node polyfill 或 webpack 专有 loader；</li>
 <li>先迁构建工具，再做 Composition API 等业务重构，降低一次性变更风险。</li>
</ul>
<h2>小结</h2>
<p>新项目使用 <code>create-vue</code>，让脚手架选择相互兼容的 Vue、Vite 和 TypeScript 依赖；根据实际需求选择 Router、Pinia 和测试工具。Vite 8 是当前构建基线，Vue CLI 只应出现在旧项目维护章节中。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/quick-start.html">Vue：Quick Start</a></li>
 <li><a href="https://vuejs.org/guide/scaling-up/tooling.html">Vue：Tooling</a></li>
 <li><a href="https://vite.dev/guide/">Vite：Getting Started</a></li>
 <li><a href="https://vite.dev/blog/announcing-vite8">Vite 8 发布说明</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-composition-api-script-setup">下一篇：Composition API 与 <code>&lt;script setup&gt;</code></a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-modern-project-setup-vite-8-typescript</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F971C9F89-639C-4411-8F6E-3C7DE74C930D.png&amp;size=m" type="image/jpeg" length="1515841"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:25 GMT</pubDate></item><item><title><![CDATA[Vue 3 响应式基础：ref、reactive、toRef 与 toRefs]]></title><link>https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E5%93%8D%E5%BA%94%E5%BC%8F%E5%9F%BA%E7%A1%80%EF%BC%9Aref%E3%80%81reactive%E3%80%81toRef%20%E4%B8%8E%20toRefs&amp;url=/archives/vue3-reactivity-ref-reactive-toref-torefs" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 响应式基础：<code>ref</code>、<code>reactive</code>、<code>toRef</code> 与 <code>toRefs</code></h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-composition-api-script-setup">Composition API 与 <code>&lt;script setup&gt;</code></a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables">派生状态与副作用</a></p>
</blockquote>
<p>Vue 3 使用 Proxy 实现对象响应式，并通过 <code>ref</code> 为任意值提供统一的响应式容器。实际项目中，最稳妥的默认策略是：优先用 <code>ref</code> 表达可能被整体替换的状态，使用 <code>reactive</code> 表达边界清晰、主要按属性修改的对象。</p>
<h2>一、<code>ref</code>：统一的响应式容器</h2>
<p><code>ref()</code> 可以接收基本类型或对象：</p>
<pre><code class="language-ts">import { ref } from 'vue'

const count = ref(0)
const title = ref('Vue 3')
const user = ref({ id: 1, name: 'Ada' })
</code></pre>
<p>脚本中通过 <code>.value</code> 读写：</p>
<pre><code class="language-ts">count.value++
title.value = 'Vue 3.5'
user.value.name = 'Grace'
user.value = { id: 2, name: 'Lin' }
</code></pre>
<p>模板会自动解包顶层 <code>ref</code>：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { ref } from 'vue'

const count = ref(0)
&lt;/script&gt;

&lt;template&gt;
  &lt;button type="button" @click="count++"&gt;{{ count }}&lt;/button&gt;
&lt;/template&gt;
</code></pre>
<p>变量本身通常声明为 <code>const</code>。<code>const</code> 限制的是 <code>count</code> 不能指向另一个 ref，不妨碍修改 <code>count.value</code>。</p>
<h2>二、<code>reactive</code>：对象的深层响应式代理</h2>
<p><code>reactive()</code> 只接受对象类型，返回原对象的 Proxy：</p>
<pre><code class="language-ts">import { reactive } from 'vue'

const form = reactive({
  profile: { name: '', city: '' },
  tags: [] as string[],
})

form.profile.name = 'Ada'
form.tags.push('vue')
</code></pre>
<p>默认情况下，嵌套对象也会在访问时转为响应式代理。基本类型应使用 <code>ref</code>，不要写 <code>reactive(0)</code>。</p>
<h2>三、为什么不能直接替换 <code>reactive</code> 变量</h2>
<p>响应式连接存在于 Proxy 上。把变量改指向普通对象，会失去原代理：</p>
<pre><code class="language-ts">let form = reactive({ name: '', city: '' })

// 不推荐：新对象没有沿用模板正在追踪的原代理
form = { name: 'Ada', city: 'London' }
</code></pre>
<p>如果对象经常整体替换，用 <code>ref</code>：</p>
<pre><code class="language-ts">const form = ref({ name: '', city: '' })

form.value = { name: 'Ada', city: 'London' }
</code></pre>
<p>如果希望保留同一个代理，更新已有属性：</p>
<pre><code class="language-ts">const form = reactive({ name: '', city: '' })

Object.assign(form, { name: 'Ada', city: 'London' })
</code></pre>
<p><code>Object.assign()</code> 不会自动删除源对象里不存在的旧字段，因此它不等价于严格的对象替换。</p>
<h2>四、如何选择 <code>ref</code> 和 <code>reactive</code></h2>
<table>
 <thead>
  <tr>
   <th>场景</th>
   <th>推荐</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>字符串、数字、布尔值</td>
   <td><code>ref</code></td>
  </tr>
  <tr>
   <td>数据可能整体替换</td>
   <td><code>ref</code></td>
  </tr>
  <tr>
   <td>异步请求结果</td>
   <td><code>ref</code></td>
  </tr>
  <tr>
   <td>表单对象主要按字段修改</td>
   <td><code>reactive</code> 或 <code>ref</code> 均可</td>
  </tr>
  <tr>
   <td>希望保持统一心智模型</td>
   <td>优先 <code>ref</code></td>
  </tr>
 </tbody>
</table>
<p>“对象层级深就一定用 <code>reactive</code>”不是必要规则。对象放进普通 <code>ref</code> 后，内部同样会转为深层响应式。</p>
<h2>五、解构为什么会丢失响应式</h2>
<p>直接解构 <code>reactive</code> 对象的基本类型属性，只会得到当时的普通值：</p>
<pre><code class="language-ts">const state = reactive({ count: 0, name: 'Ada' })
const { count } = state

state.count++
console.log(count) // 仍然是 0
</code></pre>
<p>需要独立引用属性时，使用 <code>toRef()</code>：</p>
<pre><code class="language-ts">import { reactive, toRef } from 'vue'

const state = reactive({ count: 0, name: 'Ada' })
const count = toRef(state, 'count')

count.value++
console.log(state.count) // 1
</code></pre>
<p>批量转换现有属性时，使用 <code>toRefs()</code>：</p>
<pre><code class="language-ts">import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Ada' })
const { count, name } = toRefs(state)

count.value++
name.value = 'Grace'
</code></pre>
<p><code>toRefs()</code> 只处理调用时对象上可枚举的属性。对于当时不存在、之后才加入的属性，应直接使用 <code>toRef(object, key)</code>。</p>
<h2>六、Props 解构是一个特殊情况</h2>
<p>Vue 3.5 中，编译器会让 <code>defineProps()</code> 的解构变量保持响应式：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
interface Props {
  page?: number
  pageSize?: number
}

const { page = 1, pageSize = 20 } = defineProps&lt;Props&gt;()
&lt;/script&gt;
</code></pre>
<p>这段代码不同于普通 <code>reactive</code> 对象解构，是 <code>&lt;script setup&gt;</code> 编译器提供的能力。不要据此推断所有响应式对象都可以直接解构。</p>
<p>如果要把解构后的 prop 传给 <code>watch</code>，传 getter：</p>
<pre><code class="language-ts">watch(() =&gt; page, (newPage) =&gt; {
  console.log(newPage)
})
</code></pre>
<p>直接 <code>watch(page, ...)</code> 传入的是当前数值，不是可监听的数据源。</p>
<h2>七、<code>ref</code> 的自动解包边界</h2>
<p>模板中的顶层 ref 会自动解包，但数组、Map 等原生集合里的 ref 不会自动解包：</p>
<pre><code class="language-ts">const books = reactive([ref('Vue Guide')])
</code></pre>
<pre><code class="language-vue">&lt;template&gt;
  &lt;p&gt;{{ books[0].value }}&lt;/p&gt;
&lt;/template&gt;
</code></pre>
<p>作为深层响应式普通对象属性的 ref 会被解包并与原 ref 保持连接：</p>
<pre><code class="language-ts">const count = ref(1)
const state = reactive({ count })

console.log(state.count) // 1
state.count = 2
console.log(count.value) // 2
</code></pre>
<p>不要为了省略 <code>.value</code> 而刻意把 ref 塞进复杂对象。</p>
<h2>八、TypeScript 类型写法</h2>
<p>通常让 TypeScript 从初始值推断类型即可：</p>
<pre><code class="language-ts">const count = ref(0) // Ref&lt;number&gt;
const user = reactive({ id: 1, name: 'Ada' })
</code></pre>
<p>值可能为空时显式声明联合类型：</p>
<pre><code class="language-ts">interface User {
  id: number
  name: string
}

const selectedUser = ref&lt;User | null&gt;(null)
</code></pre>
<p>不要用不真实的类型断言掩盖运行时空值。</p>
<h2>九、一个实际表单示例</h2>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { computed, reactive, ref } from 'vue'

interface ProfileForm {
  name: string
  email: string
}

const form = reactive&lt;ProfileForm&gt;({ name: '', email: '' })
const submitting = ref(false)

const isValid = computed(
  () =&gt; form.name.trim().length &gt; 0 &amp;&amp; form.email.includes('@'),
)

async function submit() {
  if (!isValid.value || submitting.value) return

  submitting.value = true
  try {
    await saveProfile({ ...form })
  } finally {
    submitting.value = false
  }
}

async function saveProfile(payload: ProfileForm) {
  console.log(payload)
}
&lt;/script&gt;

&lt;template&gt;
  &lt;form @submit.prevent="submit"&gt;
    &lt;input v-model.trim="form.name" placeholder="姓名" /&gt;
    &lt;input v-model.trim="form.email" type="email" placeholder="邮箱" /&gt;
    &lt;button :disabled="!isValid || submitting"&gt;
      {{ submitting ? '提交中…' : '保存' }}
    &lt;/button&gt;
  &lt;/form&gt;
&lt;/template&gt;
</code></pre>
<p>表单字段适合放在 <code>reactive</code> 对象里，提交状态则是独立的 <code>ref</code>。两种 API 可以自然组合，不需要选边站。</p>
<h2>小结</h2>
<p><code>ref</code> 能表达所有类型并支持整体替换，是很好的默认选择；<code>reactive</code> 适合围绕同一对象逐字段修改。普通响应式对象直接解构会断开基本类型属性的响应式连接，需要 <code>toRef()</code> 或 <code>toRefs()</code>；Vue 3.5 的 Props 响应式解构属于编译器特例。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/essentials/reactivity-fundamentals.html">Reactivity Fundamentals</a></li>
 <li><a href="https://vuejs.org/api/reactivity-core.html">Reactivity API: Core</a></li>
 <li><a href="https://vuejs.org/api/reactivity-utilities.html">Reactivity API: Utilities</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-composition-api-script-setup">上一篇</a> · <a href="https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-reactivity-ref-reactive-toref-torefs</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F5D566367-8CC8-4343-8799-5436910EE2B6.png&amp;size=m" type="image/jpeg" length="1347505"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:25 GMT</pubDate></item><item><title><![CDATA[Vue 3 Composition API 与 script setup]]></title><link>https://likeyy.love/archives/vue3-composition-api-script-setup</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20Composition%20API%20%E4%B8%8E%20script%20setup&amp;url=/archives/vue3-composition-api-script-setup" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 Composition API 与 <code>&lt;script setup&gt;</code></h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-modern-project-setup-vite-8-typescript">Vue 3 现代工程搭建</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs">Vue 3 响应式基础</a></p>
</blockquote>
<p>Composition API 的核心价值不是“代码更新”，而是把同一业务能力相关的状态、派生值、副作用和操作组织在一起，并将它们抽成可复用的组合式函数。Options API 在 Vue 3 中仍然受支持；小组件使用哪一种都可以，但新建 TypeScript 项目通常优先采用 <code>&lt;script setup&gt;</code>。</p>
<h2>一、两种 API 的组织方式</h2>
<p>Options API 按选项类型组织代码：</p>
<pre><code class="language-vue">&lt;script lang="ts"&gt;
export default {
  data() {
    return { count: 0 }
  },
  computed: {
    doubled(): number {
      return this.count * 2
    },
  },
  methods: {
    increment() {
      this.count++
    },
  },
}
&lt;/script&gt;
</code></pre>
<p>Composition API 按功能组织代码：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { computed, ref } from 'vue'

const count = ref(0)
const doubled = computed(() =&gt; count.value * 2)

function increment() {
  count.value++
}
&lt;/script&gt;
</code></pre>
<p>后者在功能复杂、需要复用逻辑或类型较多时更容易维护。它并不意味着所有代码都要塞进一个巨大 <code>setup()</code>；逻辑变长时，应抽成 <code>useXxx()</code> 组合式函数。</p>
<h2>二、普通 <code>setup()</code> 做了什么</h2>
<p>理解底层形式有助于读旧代码：</p>
<pre><code class="language-vue">&lt;script lang="ts"&gt;
import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'CounterPanel',
  setup() {
    const count = ref(0)

    function increment() {
      count.value++
    }

    return { count, increment }
  },
})
&lt;/script&gt;
</code></pre>
<p><code>setup()</code> 返回的属性可以在模板中使用。在 <code>setup()</code> 里不要依赖组件实例 <code>this</code>；Composition API 通过导入函数和显式参数访问能力。</p>
<p><code>&lt;script setup&gt;</code> 是编译时语法糖，它自动把顶层绑定暴露给模板：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
&lt;/script&gt;

&lt;template&gt;
  &lt;button type="button" @click="increment"&gt;
    {{ count }}
  &lt;/button&gt;
&lt;/template&gt;
</code></pre>
<p>这不是运行时把所有变量挂到组件实例上，而是编译器直接生成对应代码，因此更简洁，也有更好的类型推断。</p>
<h2>三、组件名怎么处理</h2>
<p>组件通常会根据 <code>.vue</code> 文件名自动推断名称。例如 <code>UserProfile.vue</code> 会得到 <code>UserProfile</code> 名称，所以大多数文件不需要额外声明。</p>
<p>确实需要显式选项时，使用内置宏 <code>defineOptions()</code>：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
defineOptions({
  name: 'UserProfile',
  inheritAttrs: false,
})
&lt;/script&gt;
</code></pre>
<p>Vue 3.3 起已经内置 <code>defineOptions()</code>，不需要 <code>vite-plugin-vue-setup-extend</code>，也不应为了写组件名再增加一个普通 <code>&lt;script&gt;</code> 块。</p>
<h2>四、编译器宏不需要导入</h2>
<p>以下常用 API 是 <code>&lt;script setup&gt;</code> 编译器宏：</p>
<ul>
 <li><code>defineProps()</code></li>
 <li><code>defineEmits()</code></li>
 <li><code>defineModel()</code></li>
 <li><code>defineExpose()</code></li>
 <li><code>defineOptions()</code></li>
 <li><code>defineSlots()</code></li>
 <li><code>withDefaults()</code></li>
</ul>
<p>它们只在 SFC 编译阶段存在，不要写：</p>
<pre><code class="language-ts">// 不需要，也不应该这样导入
import { defineProps, defineEmits } from 'vue'
</code></pre>
<p>而 <code>ref</code>、<code>computed</code>、<code>watch</code>、<code>onMounted</code>、<code>provide</code> 等是运行时 API，必须从 <code>vue</code> 导入。</p>
<h2>五、一个完整的 TypeScript 组件</h2>
<p>下面的搜索面板同时展示 Props、Emits、响应式状态和派生值：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { computed, ref } from 'vue'

interface Props {
  initialQuery?: string
  minLength?: number
}

const { initialQuery = '', minLength = 2 } = defineProps&lt;Props&gt;()

const emit = defineEmits&lt;{
  search: [query: string]
  clear: []
}&gt;()

const query = ref(initialQuery)
const normalizedQuery = computed(() =&gt; query.value.trim())
const canSubmit = computed(
  () =&gt; normalizedQuery.value.length &gt;= minLength,
)

function submit() {
  if (canSubmit.value) {
    emit('search', normalizedQuery.value)
  }
}

function clear() {
  query.value = ''
  emit('clear')
}
&lt;/script&gt;

&lt;template&gt;
  &lt;form @submit.prevent="submit"&gt;
    &lt;input v-model="query" type="search" aria-label="搜索关键词" /&gt;
    &lt;button type="submit" :disabled="!canSubmit"&gt;搜索&lt;/button&gt;
    &lt;button type="button" @click="clear"&gt;清空&lt;/button&gt;
  &lt;/form&gt;
&lt;/template&gt;
</code></pre>
<p>Vue 3.5 中，从 <code>defineProps()</code> 解构得到的变量会保持响应式。默认值也可以直接写在解构表达式中；这比旧教程一律使用 <code>withDefaults()</code> 更自然。</p>
<h2>六、什么时候抽组合式函数</h2>
<p>当一段逻辑满足以下任一条件时，可以抽成 <code>useXxx()</code>：</p>
<ul>
 <li>被两个以上组件复用；</li>
 <li>同时包含状态、计算属性、监听和清理逻辑；</li>
 <li>组件已经难以一眼看出 UI 事件和业务流程；</li>
 <li>逻辑可以独立测试。</li>
</ul>
<p>例如：</p>
<pre><code class="language-ts">// composables/useCounter.ts
import { computed, ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const doubled = computed(() =&gt; count.value * 2)

  function increment(step = 1) {
    count.value += step
  }

  return { count, doubled, increment }
}
</code></pre>
<p>组件中直接使用：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { useCounter } from '@/composables/useCounter'

const { count, doubled, increment } = useCounter(1)
&lt;/script&gt;

&lt;template&gt;
  &lt;p&gt;{{ count }} × 2 = {{ doubled }}&lt;/p&gt;
  &lt;button type="button" @click="increment()"&gt;加一&lt;/button&gt;
&lt;/template&gt;
</code></pre>
<p>组合式函数以 <code>use</code> 开头是生态约定，不是 Vue 的强制语法。</p>
<h2>七、常见误区</h2>
<h3>1. 把 Composition API 当成必须重写的理由</h3>
<p>Options API 没有被弃用。稳定的旧组件不必为了“现代化”全部重写。优先在新增功能、难复用逻辑和类型问题明显的区域渐进迁移。</p>
<h3>2. 在模板里写过多业务逻辑</h3>
<p>模板适合声明视图。长条件、数组处理和多步表达式应放进 <code>computed</code> 或函数中。</p>
<h3>3. 滥用响应式</h3>
<p>不会影响模板或其他副作用的数据用普通 <code>const</code> 即可。并非所有变量都需要 <code>ref()</code>。</p>
<h3>4. 一个组合式函数做所有事情</h3>
<p><code>usePage()</code> 如果同时管理请求、权限、弹窗、表格和埋点，只是把大组件搬到了大函数里。组合式函数仍应有清晰、单一的职责。</p>
<h2>小结</h2>
<p><code>&lt;script setup&gt;</code> 是当前 Vue 3 + TypeScript 项目的默认表达方式。组件名通常由文件名推断，编译器宏无需导入；功能变复杂时，用组合式函数按业务能力拆分，而不是按 <code>data</code>、<code>methods</code>、<code>watch</code> 的类别拆分。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/extras/composition-api-faq.html">Composition API FAQ</a></li>
 <li><a href="https://vuejs.org/api/sfc-script-setup.html"><code>&lt;script setup&gt;</code> API</a></li>
 <li><a href="https://vuejs.org/guide/typescript/composition-api.html">TypeScript with Composition API</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-modern-project-setup-vite-8-typescript">上一篇</a> · <a href="https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-composition-api-script-setup</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FDFC0381E-AD40-48BD-B92A-2D597C1E2021.png&amp;size=m" type="image/jpeg" length="1613306"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:25 GMT</pubDate></item><item><title><![CDATA[Vue 3 派生状态与副作用：computed、watch、生命周期和组合式函数]]></title><link>https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E6%B4%BE%E7%94%9F%E7%8A%B6%E6%80%81%E4%B8%8E%E5%89%AF%E4%BD%9C%E7%94%A8%EF%BC%9Acomputed%E3%80%81watch%E3%80%81%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%92%8C%E7%BB%84%E5%90%88%E5%BC%8F%E5%87%BD%E6%95%B0&amp;url=/archives/vue3-computed-watch-lifecycle-composables" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 派生状态与副作用：<code>computed</code>、<code>watch</code>、生命周期和组合式函数</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs">Vue 3 响应式基础</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots">组件契约：Props、Emits、<code>defineModel</code> 与插槽</a></p>
</blockquote>
<p>响应式状态解决“数据变化后视图如何更新”，但应用还需要派生数据、异步请求、订阅和资源清理。Vue 对这些职责做了明确区分：用 <code>computed</code> 描述纯派生状态，用 <code>watch</code> 或 <code>watchEffect</code> 执行副作用，用生命周期钩子连接浏览器或第三方资源。</p>
<h2>一、优先用 <code>computed</code> 表达派生状态</h2>
<p>计算属性根据响应式依赖生成新值，并按依赖缓存：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { computed, ref } from 'vue'

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(
  () =&gt; `${firstName.value} ${lastName.value}`,
)
&lt;/script&gt;

&lt;template&gt;
  &lt;p&gt;{{ fullName }}&lt;/p&gt;
&lt;/template&gt;
</code></pre>
<p>只要 <code>firstName</code> 和 <code>lastName</code> 没变，多次读取 <code>fullName</code> 不会重复计算。计算 getter 应保持纯净：不要在里面发请求、写其他状态、操作 DOM 或记录依赖执行次数。</p>
<p>需要双向转换时，可以创建可写计算属性：</p>
<pre><code class="language-ts">const fullName = computed({
  get: () =&gt; `${firstName.value} ${lastName.value}`,
  set: (value: string) =&gt; {
    const [first = '', ...rest] = value.trim().split(/\s+/)
    firstName.value = first
    lastName.value = rest.join(' ')
  },
})
</code></pre>
<p>可写计算属性适合格式转换，不应被用来掩盖复杂的多状态同步。</p>
<h2>二、<code>watch</code> 能监听哪些数据</h2>
<p><code>watch()</code> 的数据源可以是：</p>
<ul>
 <li>一个 <code>ref</code> 或计算属性；</li>
 <li>一个响应式对象；</li>
 <li>返回待监听值的 getter；</li>
 <li>由上述数据源组成的数组。</li>
</ul>
<p>监听 <code>ref</code>：</p>
<pre><code class="language-ts">const page = ref(1)

watch(page, (newPage, oldPage) =&gt; {
  console.log({ newPage, oldPage })
})
</code></pre>
<p>监听对象的某个基本类型属性时，必须使用 getter：</p>
<pre><code class="language-ts">const filters = reactive({ keyword: '', category: 'all' })

watch(
  () =&gt; filters.keyword,
  (keyword) =&gt; {
    console.log(keyword)
  },
)
</code></pre>
<p>监听多个数据源：</p>
<pre><code class="language-ts">watch(
  [page, () =&gt; filters.category],
  ([newPage, newCategory], [oldPage, oldCategory]) =&gt; {
    console.log({ newPage, newCategory, oldPage, oldCategory })
  },
)
</code></pre>
<p>不要写 <code>watch(filters.keyword, ...)</code>，因为这会把调用当下的字符串传进去，而不是一个可监听的数据源。</p>
<h2>三、深度监听与 Vue 3.5 的数字深度</h2>
<p>直接监听 <code>reactive</code> 对象时，Vue 会深度遍历它：</p>
<pre><code class="language-ts">const settings = reactive({
  theme: 'dark',
  editor: { fontSize: 16 },
})

watch(settings, () =&gt; {
  console.log('设置发生变化')
})
</code></pre>
<p>深度监听中的 <code>newValue</code> 与 <code>oldValue</code> 可能指向同一个对象，因为内部属性变化并没有替换根对象。需要可靠快照时，应监听具体字段，或在边界处自行创建快照。</p>
<p>Vue 3.5 起，<code>deep</code> 可以是最大遍历深度：</p>
<pre><code class="language-ts">watch(
  () =&gt; settings,
  () =&gt; {
    console.log('最多检查两层')
  },
  { deep: 2 },
)
</code></pre>
<p>大型对象的无限深度监听成本很高。优先监听真正会触发业务动作的字段，不要用 <code>deep: true</code> 代替状态设计。</p>
<h2>四、常用监听选项</h2>
<pre><code class="language-ts">watch(source, callback, {
  immediate: true,
  once: true,
  flush: 'post',
})
</code></pre>
<ul>
 <li><code>immediate</code>：创建监听器时立即执行一次，首次旧值为 <code>undefined</code>；</li>
 <li><code>once</code>：Vue 3.4+，在数据第一次变化后自动停止；</li>
 <li><code>flush: 'pre'</code>：默认值，在组件 DOM 更新前执行；</li>
 <li><code>flush: 'post'</code>：需要读取更新后的组件 DOM 时使用；</li>
 <li><code>flush: 'sync'</code>：同步触发，容易造成重复执行，应谨慎使用。</li>
</ul>
<p><code>watch()</code> 返回的句柄可以暂停、恢复或停止：</p>
<pre><code class="language-ts">const handle = watch(page, loadPage)

handle.pause()
handle.resume()
handle.stop()
</code></pre>
<p>在组件 <code>setup()</code> 同步阶段创建的监听器，会随组件卸载自动停止。异步回调里延迟创建的监听器则需要自行管理。</p>
<h2>五、异步请求一定要清理过期副作用</h2>
<p>搜索词快速变化时，旧请求可能比新请求更晚返回，导致界面显示过期结果。Vue 3.5 的 <code>onWatcherCleanup()</code> 可以取消上一轮请求：</p>
<pre><code class="language-ts">import { onWatcherCleanup, ref, watch } from 'vue'

interface SearchResult {
  id: number
  title: string
}

const keyword = ref('')
const results = ref&lt;SearchResult[]&gt;([])
const loading = ref(false)

watch(keyword, async (value) =&gt; {
  const normalized = value.trim()
  if (!normalized) {
    results.value = []
    loading.value = false
    return
  }

  const controller = new AbortController()
  onWatcherCleanup(() =&gt; controller.abort())

  loading.value = true
  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(normalized)}`,
      { signal: controller.signal },
    )
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    results.value = await response.json()
  } catch (error) {
    if (!(error instanceof DOMException &amp;&amp; error.name === 'AbortError')) {
      throw error
    }
  } finally {
    if (!controller.signal.aborted) loading.value = false
  }
})
</code></pre>
<p><code>onWatcherCleanup()</code> 必须在监听回调的同步执行阶段调用，不能放在第一个 <code>await</code> 之后。旧版本或需要把清理注册逻辑传给其他函数时，也可以使用回调第三个参数 <code>onCleanup</code>。</p>
<h2>六、<code>watchEffect</code> 适合自动收集依赖</h2>
<p><code>watchEffect()</code> 会立即执行，并自动跟踪同步执行期间读取的响应式依赖：</p>
<pre><code class="language-ts">const width = ref(window.innerWidth)
const compact = ref(false)

watchEffect(() =&gt; {
  compact.value = width.value &lt; 768
})
</code></pre>
<p>这个例子实际上更适合写成计算属性：</p>
<pre><code class="language-ts">const compact = computed(() =&gt; width.value &lt; 768)
</code></pre>
<p>这体现了选择原则：</p>
<ul>
 <li>能返回一个值的纯计算，使用 <code>computed</code>；</li>
 <li>需要明确来源、新旧值和触发条件，使用 <code>watch</code>；</li>
 <li>副作用依赖很多，且自动收集更清楚时，使用 <code>watchEffect</code>。</li>
</ul>
<p>异步 <code>watchEffect</code> 只会追踪第一个 <code>await</code> 之前同步读取的依赖，不能假设整个异步函数里的读取都会被收集。</p>
<h2>七、生命周期钩子</h2>
<p>Composition API 常用生命周期如下：</p>
<pre><code class="language-ts">import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
} from 'vue'
</code></pre>
<p>日常最常见的是：</p>
<ul>
 <li><code>onMounted()</code>：首次 DOM 已创建后访问 DOM 或初始化只接受真实元素的库；</li>
 <li><code>onBeforeUnmount()</code> / <code>onUnmounted()</code>：移除事件、断开连接和销毁第三方实例；</li>
 <li><code>onUpdated()</code>：任意响应式更新后触发，通常不应在这里继续修改状态。</li>
</ul>
<p>如果只想等待本轮状态对应的 DOM 更新，使用 <code>nextTick()</code>，通常比全局的 <code>onUpdated()</code> 更精确：</p>
<pre><code class="language-ts">import { nextTick, ref } from 'vue'

const open = ref(false)

async function openPanel() {
  open.value = true
  await nextTick()
  document.querySelector&lt;HTMLElement&gt;('[data-panel]')?.focus()
}
</code></pre>
<h2>八、把资源生命周期封装进组合式函数</h2>
<p>组合式函数应自己管理它创建的资源：</p>
<pre><code class="language-ts">// composables/useWindowWidth.ts
import { onMounted, onUnmounted, ref } from 'vue'

export function useWindowWidth() {
  const width = ref(0)

  function update() {
    width.value = window.innerWidth
  }

  onMounted(() =&gt; {
    update()
    window.addEventListener('resize', update)
  })

  onUnmounted(() =&gt; {
    window.removeEventListener('resize', update)
  })

  return { width }
}
</code></pre>
<p>这样每个使用者只关心返回值，不需要记住额外的清理步骤。若应用会 SSR，浏览器全局对象应只在挂载后访问，或先检查运行环境。</p>
<h2>小结</h2>
<p>派生值用 <code>computed</code>，有明确来源的副作用用 <code>watch</code>，适合自动收集依赖的副作用用 <code>watchEffect</code>。异步监听要取消过期任务，资源要在卸载时清理。Vue 3.5 的 <code>onWatcherCleanup()</code> 和数字 <code>deep</code> 能让这些边界更明确，但仍不能替代良好的状态设计。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/essentials/computed.html">Computed Properties</a></li>
 <li><a href="https://vuejs.org/guide/essentials/watchers.html">Watchers</a></li>
 <li><a href="https://vuejs.org/api/reactivity-core.html">Reactivity API: Core</a></li>
 <li><a href="https://vuejs.org/api/composition-api-lifecycle.html">Lifecycle Hooks</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-reactivity-ref-reactive-toref-torefs">上一篇</a> · <a href="https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-computed-watch-lifecycle-composables</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2FEFF084F9-3A5C-4558-A20F-87E4720C3C7F.png&amp;size=m" type="image/jpeg" length="1762981"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:24 GMT</pubDate></item><item><title><![CDATA[Vue 3 组件契约：Props、Emits、defineModel 与插槽]]></title><link>https://likeyy.love/archives/vue3-component-contracts-props-emits-definemodel-slots</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Vue%203%20%E7%BB%84%E4%BB%B6%E5%A5%91%E7%BA%A6%EF%BC%9AProps%E3%80%81Emits%E3%80%81defineModel%20%E4%B8%8E%E6%8F%92%E6%A7%BD&amp;url=/archives/vue3-component-contracts-props-emits-definemodel-slots" width="1" height="1" alt="" style="opacity:0;">
<h1>Vue 3 组件契约：Props、Emits、<code>defineModel</code> 与插槽</h1>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">Vue 3 现代开发指南</a>
  <br>
  上一篇：<a href="https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables">派生状态与副作用</a>
  <br>
  下一篇：<a href="https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus">跨层通信与模板引用</a></p>
</blockquote>
<p>组件通信首先是 API 设计。父组件通过 Props 提供输入，子组件通过 Emits 报告事件，<code>v-model</code> 表达受控的双向绑定，插槽把一部分渲染权交还给使用者。把这四种契约设计清楚，通常不需要事件总线或直接操作组件实例。</p>
<h2>一、Props 是只读输入</h2>
<p>使用类型声明 Props：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
interface User {
  id: number
  name: string
}

interface Props {
  user: User
  compact?: boolean
  tags?: string[]
}

const {
  user,
  compact = false,
  tags = [],
} = defineProps&lt;Props&gt;()
&lt;/script&gt;
</code></pre>
<p>Vue 3.5 的响应式 Props 解构会让 <code>user</code>、<code>compact</code> 和 <code>tags</code> 在父组件更新后继续更新。使用解构默认值时，数组和对象不需要包装成工厂函数。</p>
<p>如果保留整个 Props 对象，也可以继续使用 <code>withDefaults()</code>：</p>
<pre><code class="language-ts">const props = withDefaults(defineProps&lt;Props&gt;(), {
  compact: false,
  tags: () =&gt; [],
})
</code></pre>
<p>此时可变默认值需要工厂函数，确保每个组件实例得到独立对象。</p>
<p>Props 是单向数据流，子组件不应直接修改：</p>
<pre><code class="language-ts">// 不要这样做
// props.compact = true
</code></pre>
<p>对象 Prop 的嵌套属性在 JavaScript 层面仍可能被修改，但这会让数据所有权变得模糊。需要修改时，应发事件让父组件处理，或者在子组件中创建明确的本地副本。</p>
<h2>二、用类型安全的 Emits 报告事件</h2>
<p>子组件声明自己会发出的事件及参数：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
interface Draft {
  title: string
  content: string
}

const emit = defineEmits&lt;{
  save: [draft: Draft]
  cancel: []
}&gt;()

function submit(draft: Draft) {
  emit('save', draft)
}
&lt;/script&gt;
</code></pre>
<p>父组件监听：</p>
<pre><code class="language-vue">&lt;EditorForm
  @save="saveDraft"
  @cancel="closeEditor"
/&gt;
</code></pre>
<p>模板中的事件名推荐 kebab-case，例如 <code>@update-profile</code>。声明和脚本调用通常使用对应字符串：<code>emit('update-profile', payload)</code>。</p>
<p>不要把回调函数当成普通 Prop 来模拟子传父。Emits 能明确事件契约，也能避免回调被误当作透传属性。</p>
<h2>三、Vue 3.4+ 使用 <code>defineModel()</code></h2>
<p>从 Vue 3.4 起，官方推荐通过 <code>defineModel()</code> 实现组件 <code>v-model</code>。</p>
<p>子组件 <code>TextInput.vue</code>：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
const model = defineModel&lt;string&gt;({ required: true })
&lt;/script&gt;

&lt;template&gt;
  &lt;input v-model="model" type="text" /&gt;
&lt;/template&gt;
</code></pre>
<p>父组件：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
import { ref } from 'vue'
import TextInput from './TextInput.vue'

const title = ref('')
&lt;/script&gt;

&lt;template&gt;
  &lt;TextInput v-model="title" /&gt;
&lt;/template&gt;
</code></pre>
<p><code>defineModel()</code> 返回一个 ref：父组件传入的值变化时它会更新，子组件修改它时会通知父组件。</p>
<p>底层仍然是一个 <code>modelValue</code> Prop 和一个 <code>update:modelValue</code> 事件：</p>
<pre><code class="language-vue">&lt;TextInput
  :model-value="title"
  @update:model-value="title = $event"
/&gt;
</code></pre>
<p>理解底层协议有助于维护 Vue 3.3 及更早代码，但新代码不必手写这组样板。</p>
<h2>四、命名模型与多个 <code>v-model</code></h2>
<p>组件可以暴露多个独立模型：</p>
<pre><code class="language-vue">&lt;!-- UserName.vue --&gt;
&lt;script setup lang="ts"&gt;
const firstName = defineModel&lt;string&gt;('firstName', { required: true })
const lastName = defineModel&lt;string&gt;('lastName', { required: true })
&lt;/script&gt;

&lt;template&gt;
  &lt;input v-model="firstName" aria-label="名" /&gt;
  &lt;input v-model="lastName" aria-label="姓" /&gt;
&lt;/template&gt;
</code></pre>
<pre><code class="language-vue">&lt;UserName
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/&gt;
</code></pre>
<p>不要随意给 <code>defineModel()</code> 设置默认值。如果父组件没有传值，子组件默认值可能与父组件中的 <code>undefined</code> 不同步。对于真正受控的输入，优先使用 <code>{ required: true }</code> 或在父组件初始化状态。</p>
<h2>五、默认插槽和具名插槽</h2>
<p>默认插槽用于传入主体内容：</p>
<pre><code class="language-vue">&lt;!-- PanelCard.vue --&gt;
&lt;script setup lang="ts"&gt;
defineProps&lt;{ title: string }&gt;()
&lt;/script&gt;

&lt;template&gt;
  &lt;section class="panel-card"&gt;
    &lt;h2&gt;{{ title }}&lt;/h2&gt;
    &lt;slot /&gt;
  &lt;/section&gt;
&lt;/template&gt;
</code></pre>
<pre><code class="language-vue">&lt;PanelCard title="账户信息"&gt;
  &lt;p&gt;这里由父组件决定。&lt;/p&gt;
&lt;/PanelCard&gt;
</code></pre>
<p>具名插槽适合组件有多个渲染区域：</p>
<pre><code class="language-vue">&lt;!-- ModalDialog.vue --&gt;
&lt;template&gt;
  &lt;section role="dialog" aria-modal="true"&gt;
    &lt;header&gt;&lt;slot name="header" /&gt;&lt;/header&gt;
    &lt;div&gt;&lt;slot /&gt;&lt;/div&gt;
    &lt;footer&gt;&lt;slot name="footer" /&gt;&lt;/footer&gt;
  &lt;/section&gt;
&lt;/template&gt;
</code></pre>
<pre><code class="language-vue">&lt;ModalDialog&gt;
  &lt;template #header&gt;删除文章&lt;/template&gt;
  &lt;p&gt;该操作无法撤销。&lt;/p&gt;
  &lt;template #footer&gt;
    &lt;button type="button"&gt;取消&lt;/button&gt;
    &lt;button type="button"&gt;确认删除&lt;/button&gt;
  &lt;/template&gt;
&lt;/ModalDialog&gt;
</code></pre>
<h2>六、作用域插槽把数据交给渲染者</h2>
<p>当数据和行为属于子组件，但渲染结构应由父组件决定时，使用作用域插槽：</p>
<pre><code class="language-vue">&lt;!-- DataList.vue --&gt;
&lt;script setup lang="ts" generic="T extends { id: string | number }"&gt;
defineProps&lt;{ items: T[] }&gt;()

defineSlots&lt;{
  default(props: { item: T; index: number }): unknown
  empty(): unknown
}&gt;()
&lt;/script&gt;

&lt;template&gt;
  &lt;ul v-if="items.length"&gt;
    &lt;li v-for="(item, index) in items" :key="item.id"&gt;
      &lt;slot :item="item" :index="index" /&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;slot v-else name="empty" /&gt;
&lt;/template&gt;
</code></pre>
<p>使用组件：</p>
<pre><code class="language-vue">&lt;DataList :items="users"&gt;
  &lt;template #default="{ item, index }"&gt;
    {{ index + 1 }}. {{ item.name }}
  &lt;/template&gt;
  &lt;template #empty&gt;暂无用户&lt;/template&gt;
&lt;/DataList&gt;
</code></pre>
<p><code>defineSlots()</code> 主要提供 IDE 和类型检查能力，不改变运行时插槽行为。</p>
<h2>七、<code>$attrs</code> 与属性透传</h2>
<p>未被 Props 或 Emits 消费的属性和监听器会进入 <code>$attrs</code>。单根组件默认会把它们透传到根元素：</p>
<pre><code class="language-vue">&lt;BaseButton class="primary" aria-label="保存" @click="save" /&gt;
</code></pre>
<p>如果组件有多个根节点，或要把属性传给特定元素，应显式处理：</p>
<pre><code class="language-vue">&lt;script setup lang="ts"&gt;
defineOptions({ inheritAttrs: false })
&lt;/script&gt;

&lt;template&gt;
  &lt;label&gt;
    &lt;span&gt;&lt;slot name="label" /&gt;&lt;/span&gt;
    &lt;input v-bind="$attrs" /&gt;
  &lt;/label&gt;
&lt;/template&gt;
</code></pre>
<p><code>$attrs</code> 适合属性转发，不适合构造隐蔽的祖孙状态通道。跨层共享依赖应使用 <code>provide/inject</code> 或 Pinia。</p>
<h2>八、如何选择组件契约</h2>
<table>
 <thead>
  <tr>
   <th>需求</th>
   <th>首选方式</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>父组件传只读数据给子组件</td>
   <td>Props</td>
  </tr>
  <tr>
   <td>子组件通知父组件发生了动作</td>
   <td>Emits</td>
  </tr>
  <tr>
   <td>父子共同控制一个表单值</td>
   <td><code>defineModel()</code> / <code>v-model</code></td>
  </tr>
  <tr>
   <td>父组件决定一块 UI 如何渲染</td>
   <td>插槽</td>
  </tr>
  <tr>
   <td>透传 HTML 属性和监听器</td>
   <td><code>$attrs</code></td>
  </tr>
  <tr>
   <td>跨多层提供依赖</td>
   <td><code>provide/inject</code></td>
  </tr>
  <tr>
   <td>跨页面共享业务状态</td>
   <td>Pinia</td>
  </tr>
 </tbody>
</table>
<h2>小结</h2>
<p>把组件看作一个有类型的公开 API：Props 是输入，Emits 是输出，<code>defineModel</code> 是明确的双向绑定，插槽是渲染扩展点。优先使用这些显式契约，可以避免回调 Prop、实例互调和全局事件总线造成的隐式耦合。</p>
<h2>官方资料</h2>
<ul>
 <li><a href="https://vuejs.org/guide/components/props.html">Props</a></li>
 <li><a href="https://vuejs.org/guide/components/events.html">Component Events</a></li>
 <li><a href="https://vuejs.org/guide/components/v-model.html">Component <code>v-model</code></a></li>
 <li><a href="https://vuejs.org/guide/components/slots.html">Slots</a></li>
 <li><a href="https://vuejs.org/guide/components/attrs.html">Fallthrough Attributes</a></li>
</ul>
<blockquote>
 <p>系列导航：<a href="https://likeyy.love/archives/vue3-modern-development-guide">目录</a> · <a href="https://likeyy.love/archives/vue3-computed-watch-lifecycle-composables">上一篇</a> · <a href="https://likeyy.love/archives/vue3-template-refs-provide-inject-event-bus">下一篇</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/vue3-component-contracts-props-emits-definemodel-slots</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2Fhalo%2F2BC18A5A-2933-4356-BA16-F0C6984DEE60.png&amp;size=m" type="image/jpeg" length="1539574"/><category>前端框架</category><pubDate>Mon, 31 Aug 2026 15:59:24 GMT</pubDate></item></channel></rss>