<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>July's Website</title>
        <link>https://july.icu</link>
        <description>July 的个人网站，记录技术、生活、思考等内容</description>
        <lastBuildDate>Fri, 03 Jul 2026 09:51:53 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-CN</language>
        <copyright>Copyright (c) 2026, July</copyright>
        <item>
            <title><![CDATA[GFW 对 icu 顶级域名的临时性封锁]]></title>
            <link>https://july.icu/articles/gfw-icu-tld</link>
            <guid isPermaLink="false">gfw-icu-tld</guid>
            <pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[本站在 4 月 28 日突发性无法访问，排查下来是遭遇了 GFW 的干扰，这里对过程做一个记录。]]></description>
            <content:encoded><![CDATA[在 4 月 28 日 icu 顶级域名遭遇了 GFW 的封锁，导致本站点在国内网环境无法直接访问。

> https://www.safwire.net/p/gfw-icu-tld

一开始发现站点无法访问一度以为问题是出在 Vercel 上，或许是 Vercel 的 NS 宕机了或者是被 GFW 封锁了。（因为最近 Vercel 的 AI Gateway 很火，很容易联想到因此上了 GFW 的名单）

## Vercel DNS 托管方式

在查问题之前让我们回顾一下 Vercel 的域名解析是怎么运作的。

在 Vercel 某次控制台大改版之后域名管理被提升到了团队账号维度，Vercel 的 NS 会自动更新两条 `ALIAS` 类型的 DNS Records 用于路由所有的子域名到对应的项目域名配置。

| Name | Type  | Value                   |
| ---- | ----- | ----------------------- |
| *    | ALIAS | cname.vercel-dns-x.com. |
|      | ALIAS | x.vercel-dns-x.com      |

前者是所有子域名路由，后者是 `@` 根域名的解析路由，通常会指向其中一个项目。

```
域名 NS
↓
ns1.vercel-dns.com / ns2.vercel-dns.com
↓
ALIAS cname.vercel-dns.com.
↓
IP project server
```

### DNS Records 概念

有几个相关概念做一些补充：

- **A 记录**：是我最早接触的 DNS Records 配置方式，实际上就是将一个子域名影射到一个固定 IP 上。概念最简单、效率最高、配置完全固定。
- **CNAME 记录**：记录值从固定 IP 变化为固定域名，优势就是其背后的 IP 可以是动态的，劣势就是需要多几次查询效率变低，不能用于根域名。
- **ALIAS 记录**：这是一种非标准的记录，是 DNS 服务商提出来的扩展类型。实际上 ALIAS 是一种变相的 A 记录，由 DNS 服务商后台做解析并缓存，浏览器可以直接得到 IP 无需二次查询。
  因此它解决了 CNAME 记录需要多次查询和不能用于根域名这两个问题。
- **Nameservers**：权威服务器，缩写为 NS。早年间不少 DNS 服务商会屏蔽掉这个概念不怎么了解，最近几年 DNS 服务商都开放了 NS 的配置允许用户跨服务商解析域名。

## 排查思路

使用 **Domain Information Groper** 命令行工具查看 DNS 解析过程

通过 `dig july.icu +short` 查询 IP 解析是否正确

```
216.198.79.65
64.29.17.65
```

光看 IP 数字看不出问题，通过 `whois 216.198.79.65` 查看 IP 归属：

![[gfw-icu-tld-attachments/20260429151829070.png]]

从结果来看 IP 确实属于 Vercel，使用 `dig july.icu NS +short` 查看 NS 也确实解析到了 Vercel 的权威服务器。

```
ns2.vercel-dns.com.
ns1.vercel-dns.com.
```

看起来域名解析的链路也没有问题：

![[gfw-icu-tld-attachments/20260429152911887.png]]

到这一步最大的嫌疑是 IP 有误，虽然 IP 归属于 Vercel 但不一定是正确的项目 IP 也有可能是“占位 IP”，譬如域名停放或配置错误情况的默认 IP。

毕竟 NextJS 和 Cloudflare 最近也接连出过大事故（世界就是个巨大的草台班子），遂又在 Vercel 后台重置了 DNS 配置、重新绑定项目等操作仍然无法解决问题。

**然后我灵机一动在电脑上开了网络代理之后居然可以正常访问了！！**

这里有两种可能性：

1. 代理之后请求的 DNS 根服务器不同，代理的服务器返回了“较早缓存的正确 IP 值”；
2. 是 GFW 在作怪，而代理绕过了 GFW 所以没有问题。

第一种可能性在核对了解析 IP 相同之后被排除，只剩下第二种情况。那么 GFW 是通过何种手段作怪是解决问题的关键。

回顾之前的结论，域名解析路径是通畅无阻的，那么猜测是 GFW 屏蔽了所属 Vercel 的部分 IP。

## 反向代理

使用 Cloudflare 可以快速配置 [域名反向代理](https://developers.cloudflare.com/fundamentals/concepts/how-cloudflare-works/?preferred-color-scheme=light#cloudflare-as-a-reverse-proxy)（**并且免费**）。

![[gfw-icu-tld-attachments/20260429160855223.png]]

具体配置方式是：

1. 修改 Vercel 后台域名管理的 NS 到 Cloudflare 的 NS
2. 配置 Cloudflare 后台 DNS Records ，填入 Vercel 指定的 CNAME 或 IP。

这样一通操作之后 GFW 监测到的 IP 就从 Vercel 变成了国内友好的 Cloudflare 名下的 IP，理论上可以解决问题。

> NS 的变更通常需要几分钟到几个小时，通过 `dig july.icu @1.1.1.1 +short` 查询配置是否传播到全球 DNS。

然后的然后问题仍然没能解决！所以 IP 的问题被排除。

## SNI 污染

另一个重要特征是当尝试使用 `curl` 请求 `july.icu` 时，Vercel 和 Cloudflare 都表现出相同的特征：**在 TLS 握手阶段被重置**

```
Recv failure: Connection reset by peer
* LibreSSL/3.3.6: error:02FFF036:system library:func(4095):Connection reset by peer
* Closing connection
```

将相关的线索询问了 Claude 之后得到一种可能性：**我的域名遭遇了 SNI 级别的封锁**

### SNI 污染原理

SNI 是在 TLS 握手阶段的一次明文通讯，作用是告诉服务器要访问的域名方便其返回证书：

```
Client Hello:
  SNI = www.july.icu
```

GFW 可以通过审查此次明文通讯来对部分域名实行拦截，拦截的方式通常有：断开（丢包）、TCP RST 注入（连接重置）。

从特征来看我的情况非常符合后者。当然我的网站本身不具备如此影响力，所以很容易推断出来可能是 `.icu` 这个顶级域名整个被屏蔽了。

截止 4 月 29 日，针对 `.icu` 的封锁似乎已经解除。不过 `.icu` 本身是一个“较敏感”的顶级域名，所以这个事件促使我更换了域名。]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[什么是浏览器 LNA 和 PNA 安全限制？]]></title>
            <link>https://july.icu/articles/browser-pna</link>
            <guid isPermaLink="false">browser-pna</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[请求 OpenAPI 被浏览器拦截了，到底是普通的 CORS、还是 PNA、还是 LNP 呢？]]></description>
            <content:encoded><![CDATA[要理解 LNA 之前一定要先了解已经被 Chrome 弃用的功能 PNA。

>[专用网络访问处于暂停状态](https://developer.chrome.com/blog/pna-on-hold?hl=zh-cn)：[我们之前曾宣布](https://developer.chrome.com/blog/private-network-access-update-2024-03?hl=zh-cn)，从 Chrome 130 开始，系统将强制执行 PNA 预检查请求。由于存在一些兼容性问题，此功能目前暂停发布。

[PNA](https://developer.chrome.com/blog/private-network-access-update-2024-03?hl=zh-cn) 全称：Private Network Access，是浏览器一项安全功能，用于限制网站向专用网络上的服务器发送请求的功能，该功能从 Chrome123 开始显示警告、Chrome130 之后强制执行。

详细来讲就是说浏览器会把网络按照公开程度分为：

**公网 public => 内网 private => 本地 local**

当从一个较公开的网络访问一个更私有的网络就会触发 PNA 保护机制。这项保护机制非常重要，可以防止公共网络借由宿主机器向宿主的私有网络发起 CSRF 攻击。

## PNA 运行原理

浏览器划分网络类型的依据是 IP 地址，最典型的 `127.0.0.1` 就是本地网段，常见的 `129.168.x.x` \ `10.x.x.x` 等均为内网网段。

当浏览器判断请求符合跨网段条件时会在真正发起请求前发送 [预检请求](https://developer.mozilla.org/zh-CN/docs/Glossary/Preflight_request)，这一点和 CORS 非常类似。

浏览器会自动为预检请求带上 PNA 请求头：

```
Access-Control-Request-Private-Network: true
```

若在响应头中没有得到 [相应的豁免头](https://developer.chrome.com/blog/private-network-access-preflight?hl=zh-cn#new-in-pna) 则浏览器会阻止发送真正的请求：

```
# CORS 豁免
Access-Control-Allow-Origin: https://foo.example
# PNA 豁免
Access-Control-Allow-Private-Network: true
```

大部分情况下 PNA 会和 CORS 叠加触发（因为跨网段访问大概率是跨域请求），所以会得到类似这样的报错：

```
Access to fetch at 'aaaaaa' from origin 'bbbbbb' has been blocked by CORS policy:
Permission was denied for this request to access the local address space.
```

它不是简单的跨域拦截而可能是 PNA 拦截～

### 注意点

1. PNA 还涵盖 Web Worker 和浏览器扩展；
2. PNA 的预检发送早于所有其他模式，譬如 `cors` 和 `no-cors` ；
3. 使用代理工具可能会导致 PNA 失效；
4. 判断浏览器是否使用了 PNA 需要查看预检请求头上是否携带 `Access-Control-Request-Private-Network`。

## 最新的标准 LNA

[LNA](https://developer.chrome.com/blog/local-network-access?hl=zh-cn) 全称：Local Network Access，它被提出用于解决和 PNA 相同的问题。它与 PNA 存在两个主要差异：

1. 于用户而言，用户会明确感知到访问的网站存在 LNA 行为并有同意/拒绝的权利；
2. 于开发者而言，无需额外的请求/响应头配置，权限的请求和拦截完全由浏览器完成；

![[browser-pna-attachments/20260417110950471.png]]

### LNA 排查技巧

正因为 LNA 的授权行为完全由浏览器控制，对于开发者而言整个过程完全黑盒加大了排查难度：

1. 浏览器可能因为一些原因不发起授权弹窗而直接拒绝 LNA 请求；
2. DevTools 中不会有明确的 LNA 拒绝提示且浏览器会隐藏请求响应头。

尤其是我一开始不了解 LNA 相关知识，一直在 PNA 层面做协议的调试。

值得一提的是抓包工具/代理工具会使 LNA 无效。

因为这类工具的原理都是代理浏览器请求做转发，所以浏览器会将请求都解析到代理工具的 IP 和端口使得不存在“跨网络类型”访问的情景，LNA 就不会触发。

所以用抓包工具去查响应头时浏览器就放行请求，而关闭抓包工具时请求又被浏览器意味不明的拦截，一根筋两头堵了。

我们必须使用 Chrome 内置的调试工具：

1. 内置网络导出工具：`chrome://net-export/`
2. 网络日志解析工具：`https://netlog-viewer.appspot.com/#import`

前者可以录制并将网络日志导出成 JSON 文件；而后者可以解析导出的文件。

> 在老版本的 Chrome 浏览器中可以使用 `chrome://net-internals/#events` 直接观察和调试，但在新版本已经被废弃。

日志信息可以会比较多，需要善用页面上的搜索功能用域名或者网址做过滤，可以看到我调试的 OpenApi 接口确实发起了两次 URL_REQUEST 请求符合 CORS 的特征。

![[browser-pna-attachments/20260417145035306.png]]

日志详情的内容比较多，我们只需要注意 `source_dependency` 把握整体的调用链路即可很快找到问题，上图的调用链路如下：

```
URL_REQUEST(GET) => URL_REQUEST(OPTIONS) =>
HTTP_STREAM_JOB_CONTROLLER => HTTP_STREAM_JOB => QUIC_SESSION_POOL_DIRECT_JOB =>
HOST_RESOLVER_IMPL_JOB => URL_REQUEST(OPTIONS)
```

在 `HOST_RESOLVER_IMPL_JOB` 步骤中 Chrome 调用 `HOST_RESOLVER_SYSTEM_TASK` 解析到 IP 地址形如：

```
 --> address_list = [
   "10.x.x.x:0",
   "[fdbd:x:x:x::x]:0"
 ]
```

得到所需信息之后 `Callback` 回到 `URL_REQUEST(OPTIONS)` 请求查询到如下日志：

![[browser-pna-attachments/20260417150725065.png]]

看到 `lna-permission-required` 以及 `denied` 之后马上就就能联想到是触发了 LNA 拦截且被拒绝。

**那么第二个问题来了：为什么浏览器直接拒绝了 LNA 而没有触发弹窗给到我们选择呢？**

原因是在我的网站中嵌套至少三层 Iframe，而 OpenAPI 的调用是在其中一层 Iframe 发起的。

### Iframe 中的 LNA 机制

其实 Google 有一篇专门的文档 [《LNA Adoption Guide》](https://docs.google.com/document/d/1QQkqehw8umtAgz5z0um7THx-aoU251p705FbIQjDuGs/edit?hl=zh-cn&pli=1&tab=t.0#heading=h.denb9idl4lm0) 讲解 LNA 机制。对于 Iframe 中的 LNA 必须开启相应的权限：

```
<iframe src="domainB.example" allow="local-network; loopback-network"></iframe>
```

如果 Iframe 本身存在跳转则必须声明跳转之后的域名 `local-network domainB domainC` 也可以直接使用 `local-network *`。

`local-network` 和 `loopback-network` 是从 `local-network-access` 拆分出来的权限点，如果需要兼容 Chrome 145 以下的浏览器可以考虑把三个权限点都写上。

另外在 Chrome 145+ 的浏览器可以直接调用 API 查询用户对权限点的授权状态用于展示对用户的提示：

```JavaScript
navigator.permissions.query({ name: "local-network-access" })
.then((result) => {
  console.log(`LNA permission state: ${result.state}`)
});
```

## 最后的吐槽

Chrome 的知识点学不完，根本学不完。另外 Claude、Gemini 和 GPT 都没有根据已知信息快速推测出是 LNA 拦截，这也是我陆陆续续耗费了三天才把问题锁定的原因。

其他模型就不说了，建议严查 Gemini 连自家的文档都没有好好喂给模型学习是吧。]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[养猫笔记]]></title>
            <link>https://july.icu/articles/cat-care</link>
            <guid isPermaLink="false">cat-care</guid>
            <pubDate>Thu, 25 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[持续更新的养猫人笔记]]></description>
            <content:encoded><![CDATA[## 呼吸道疾病及其治疗方法

呼吸道疾病类似人类的感冒，一般来说并不致命。呼吸道基本最有效的检测方法是做 PCR。

| 疾病            | 症状                                               | 治疗                    |
| ------------- | ------------------------------------------------ | --------------------- |
| 支原体           | 没有特异症状，结膜、上呼吸道                                   | **口服多西环素**            |
| 波氏杆菌          | 显著的咳嗽；喷嚏、鼻涕；轻微的结膜炎                               | **口服多西环素**            |
| 衣原体           | **严重持续的结膜炎；**呼吸道轻微症状                             | **口服多西环素**            |
| 杯状病毒（FCV）     | **口腔溃疡症状显著；**呼吸道轻微症状                             | 无特效药，止痛或抗生素           |
| 疱疹病毒-1（FHV-1） | 终身携带。**眼部症状显著**，结膜炎，**较多分泌物**；呼吸道如打喷嚏、鼻涕；食欲不振、发烧 | **口服泛昔洛韦**；抗病毒眼药如更昔洛韦 |]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WezTerm 在 Sequoia 中文字符乱码问题]]></title>
            <link>https://july.icu/articles/wezterm-chinese-garbled-text-macos-sequoia</link>
            <guid isPermaLink="false">wezterm-chinese-garbled-text-macos-sequoia</guid>
            <pubDate>Thu, 16 Oct 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[最近把 MacOS 升级到 Sequoia 之后突然发现在 WezTerm 中个别中文字符无法正常显示：

![[wezterm-chinese-garbled-text-macos-sequoia-attachments/20251223212534489.png]]

## 解决方案

> 参考：[https://github.com/wezterm/wezterm/issues/6045](https://github.com/wezterm/wezterm/issues/6045)

打开 MacOS “**字体册**” app，在简体中文语言中找到“**苹方-简**”字体

![[wezterm-chinese-garbled-text-macos-sequoia-attachments/20251226163933870.png]]

下载完成后重启 WezTerm 即可！

![[wezterm-chinese-garbled-text-macos-sequoia-attachments/20251223213009917.png]]]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[RPG Maker MV 游戏画面卡死的解决方法]]></title>
            <link>https://july.icu/articles/rpg-maker-mv-crash-fix</link>
            <guid isPermaLink="false">rpg-maker-mv-crash-fix</guid>
            <pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[找到 `www ⇒ js ⇒ rpg_core.js` 文件中的 `Graphics.render` 方法，将第一个 if 语句的 `===` 判断改为 `<=` 即可。具体代码片段如下：

```js
Graphics.render = function(stage) {
  if (this._skipCount === 0) { // [!code --]
  if (this._skipCount <= 0) { // [!code ++]
    var startTime = Date.now();
    if (stage) {
      this._renderer.render(stage);
      if (this._renderer.gl && this._renderer.gl.flush) {
        this._renderer.gl.flush();
      }
    }
    var endTime = Date.now();
    var elapsed = endTime - startTime;
    this._skipCount = Math.min(Math.floor(elapsed / 15), this._maxSkip);
    this._rendered = true;
  } else {
      this._skipCount--;
      this._rendered = false;
  }
  this.frameCount++;
};
```

这段代码为游戏画面提供基于设备性能自适应的帧率渲染，当设备性能较差时可通过跳过更多的画面渲染来维持游戏运行。

实际上 `_skipCount` 可能会因为计算误差变成负值，就会导致游戏画面卡死，而声音、按键等其他功能却不受影响。]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[记一次 esbuild-register + npx 的踩坑]]></title>
            <link>https://july.icu/articles/esbuild-register-with-npx</link>
            <guid isPermaLink="false">esbuild-register-with-npx</guid>
            <pubDate>Fri, 25 Jul 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[之前常用 ts-node 或者 sucrase 作为 Typescript 运行库。某一次在配置 Rsbuild 时，在无意之间发现了 esbuild-register 这个工具之后简直如获至宝。]]></description>
            <content:encoded><![CDATA[## 前言

之前常用 ts-node 或者 sucrase 作为 Typescript 运行库。某一次在配置 Rsbuild 时在无意之间在 [Rsbuild 官网文档](https://rspack.rs/zh/config/#%E4%BD%BF%E7%94%A8-esbuild) 发现了 **esbuild-register** 这个工具之后简直如获至宝。

首先它不像 ts-node 那样非常的慢且需要配置正确的 TSConfig 才能正常工作。而 sucrase 是另一个极端，它支持的语法太少了，尤其是不支持装饰器是一大问题。

esbuild-register 在开箱即用、运行效率、可配置三个方面达到了完美的平衡：

```jsx
#!/usr/bin/env node
require('esbuild-register');
require('./index.ts');
```

## 坑点

在为 SDK 编写脚手架时，引入 esbuild-register 可以去掉需要将 TS 构建为 JS 的步骤，化简之后的设计如下：

```
# 目录结构
├ src/
│ └─ cli.ts
├ cli.js
├ package.json

```

```jsx
// cli.js
#!/usr/bin/env node
require('esbuild-register');
require('./src/cli.ts');
```

```json
// package.json
{
  "bin": {
    "create-app": "cli.js"
  }
}
```

在脚手架项目中运行 `node ./cli.js` 或者 `npx create-app` 都没有任何问题，直到我将它发布到 npm 上然后尝试下载安装：

```bash
npx @coze-workflow/create-sdk@latest
```

然后就报错了！

![[esbuild-register-with-npx-attachments/20251228182326194.png]]

解决的方法是需要关闭 `hookIgnoreNodeModules` 配置：

```jsx
require('esbuild-register/dist/node').register({
  hookIgnoreNodeModules: false,
});
```

这并非是 esbuild 内部的配置而是描述 esbuild-register 行为的配置。目前还没有任何文档有详细的说明，从 [源码](https://github.com/egoist/esbuild-register/blob/dev/src/node.ts#L98) 的注释解释是自动忽略对 node_modules 下文件的翻译

![[esbuild-register-with-npx-attachments/20251228182346936.png]]

然后再回过头看报错信息就恍然大悟了，`cli.ts` 文件被过滤掉了。

不过这属于马后炮了，实际上我先入为主认为是需要额外的 tsconfig 配置（因为 ts-node 会因为 tsconfig 原因报类似的错误），这花费了我大量的时间，究其原因还是我因为不熟悉 `npx` 的原理。

## npx 原理

**npx** 也是一个 npm 包，从 node16 开始整合进 npm 中。它按照如下优先级执行：

1. package.json 中的 `bin` 字段
2. node_modules/.bin 文件夹中的脚本
3. 远程的 npm 包 package.json 的 `bin` 字段

其中 `node_modules/.bin` 中的脚本是在安装依赖的时候，npm 会逐个为依赖的 package.json 的 bin 字段指向的文件创建软连接（symbolic link）。

而执行远程的包是基于上述原理进一步封装，npx 会在创建一个缓存文件夹，路径如 `~/.npm/_npx/abcde`。

**这个缓存文件夹同时是一个“虚拟包”，它包含 package.json、package-lock.json 和 node_modules。这个“虚拟包”有且只有一个 dependencies 就是需要被下载安装的包。**

> 创建“虚拟包”这种形式可以最大程度保证 npm 的一致性，譬如被下载的包的依赖树也会遵循规则被下载。

最后 npx 使用 child_process.spawn 执行文件，并设置 cwd 指向当前的工作目录。使得远程包和本地包使用起来没有太多区别。

所以 npx 并不是直接执行脚手架包的 bin 文件，而是让脚手架作为依赖被执。esbuild-register 的 `hookIgnoreNodeModules` 字段默认为 `true`，导致自己把自己给过滤掉了。

还是让我们期待一下 [Running TypeScript code with Node.js](https://nodejs.org/en/learn/typescript/run-natively#running-typescript-code-with-nodejs) 的普及吧！]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[NAS 视频媒体资源的整理心得]]></title>
            <link>https://july.icu/articles/nas-video-media-organization-tips</link>
            <guid isPermaLink="false">nas-video-media-organization-tips</guid>
            <pubDate>Tue, 17 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[最近略有闲暇，开始捣鼓 NAS 的影视库，记录下一些心得。]]></description>
            <content:encoded><![CDATA[最近略有闲暇，开始捣鼓 NAS 的影视库，记录下一些心得。

## 硬件条件

搭建 NAS 影视库的前提是需要有一定规格的硬件条件，否则做这件事的收益非常低，体验并不会比在线看盗版电影或者爱奇艺腾讯之流好多少。

### NAS - 群晖 DS923+

群晖 DS923+ 有几个比较突出的点：

- 4 盘位。完全可以满足日常的存储容量，我还特意空置了一个盘位专门用于更换硬盘时用于数据迁移。（海量的数据迁移非常麻烦，请务必要考虑这一点）
- 双网口，支持扩展万兆。两个网口可以合并以提升传输速度，不过我是将两个网口拆分到两个网段，一个网口接入家庭局域网的路由器，另一个网口接入传入数据的台式电脑。这样当电脑满速向 NAS 传输媒体时并不会占用家庭局域网带宽，家里人不会突然感觉网卡。
- M.2 插槽 x 2 & 内存可扩展至 32 GB

作为家庭影视库已经完全够用了。实际上 NAS 的硬件条件实际上是决定影视库的上限，所以若考虑性价比也可以用一台老主机代替。

### 播放器 - AppleTV（7代 A15）

许多人会选择 NAS 直连电视机，认为 AppleTV 纯属鸡肋，它能做到的电视机也能做到。实际上并非如此，AppleTV 有两个重要优势：

- 解码
    
    虽然市面上 4K 分辨率的电视机很多，但并不代表它们有足够强的芯片性能和内存预算来解码一个高码率高动态的视频文件。真正性能强劲如索尼 X95L 的电视机基本上是万元级别的。
    
    而 AppleTV 在 **A15** 芯片的加持下解码 4K HDR、 杜比视界、无损音轨等高规格功能易如反掌，这也是为什么 2022 年至今库克还没有推出新款 AppleTV（完完全全的性能过剩）。
    
- 生态
    
    Infuse/Plex 等海报墙带来的体验无需多数用过都说好。而电视机连接局域网大多以安卓资源管理器形式展示。
    

AppleTV 的另一个重要功能就是苹果生态的**【屏幕镜像】**。日常生活我们经常把手机视频投屏到大电视机上观看，如果投屏机器是国产电视机、机顶盒或者投影仪大概率会被劫持，要求下载对应的电视端 APP。而屏幕镜像可以轻松绕过国内视频平台的投屏封锁，并且要高清的多。

除此之外 AppleTV 的流媒体、Netflix 等也勉强可以充当本地影视库的补全，不过这些次时代的流媒体对梯子的带宽有一定要求（不由得感叹，在巨头们垄断下的国内流媒体什么时候才能迈向次时代，让平头老百姓也吃吃细糠）。

群晖 DS923+ 与 AppleTV 的组合基本不占空间，可以放在电视机柜上。唯一比较麻烦的是静电带来的吸灰和吸猫毛问题

![[nas-video-media-organization-tips-attachments/7C4699173964383D383C895F4FFCBBE2.png]]

### 电视机 - 小米 S75

电视机结合实际选择即可，为了不让 AppleTV 明珠蒙尘我选择的是 4K 杜比视界 miniLED 的小米电视，如果不考虑性价比就上索尼或者 LG。

特别注意，若无特殊情况尽量不要选择投影仪作为终端设备，实测下来截至目前中高端投影仪的亮度和细腻程度完完全全比不上电视机。

> 价值 6217￥的极米 RS20 Plus 已京东 7 天无理由退款

---

## 软件条件

软件分为两种：一种是管理媒体资源的服务端软件；一种是连接和播放的客户端软件。

**有条件的一定要两种都使用，一定要两种都使用，一定要两种都使用！！！**

陆陆续续捣鼓了三年下来得出的经验，有和没有软件支持完完全全是两种体验。

### 管理服务器 - Emby

Emby、Plex 或者 Jellyfin 各有优劣，我没有太多纠结直接选择了 Emby。配置上唯一需要注意的就是将元数据和图像字段持久化到媒体资源的目录中而非 Emby Server 的目录中，这样会方便管理很多，并且可以使用其他工具做预处理或二次加工。对应的设置是：

- ✅ 元数据读取器 - Nfo
- ✅ 元数据存储方式 - Nfo
- ✅ 将媒体图像保存到媒体文件夹中
- ✅ 将已下载的字幕保存到媒体文件夹中

### 终端 - infuse

infuse 是支持 Emby Server 的，所以有了 Emby 之后 infuse 可以连接 Emby Server 而不要直接去连接 NAS 的共享文件夹。另外 infuse 的刮削远远没有 Emby 强大，且数据无法持久化存储，所以只把它当作海报墙 + 播放器即可。

Emby 本身也有移动端和电视端 App，但功能远远没有 infuse 强大。

---

## 媒体文件目录结构

文件目录结构和命名直接关系到刮削器的准确程度，需要重点关注。

另外，电影和剧集是两种媒体类型建议分两个媒体库存储。

同理动漫、里番、成人电影等类型的媒体刮削源可能完全不同，也推荐各自建立独立的媒体库。Emby 的账号具有权限体系，可以建立多个账号以便在客厅电视、私人电脑、私人手机上做权限管控。

Emby 官方推荐的 [命名规范](https://emby.media/support/articles/Movie-Naming.html) 是以空格作为元数据的分割符，不过我个人还是推荐使用 `.` 作为分割符，另外电影名称的英文单词之间无需使用 `.` 仍然使用空格作为分隔符。

```bash
# 电影，eg: Moives/Let the Bullets Fly.2010/Let the Bullets Fly.2010.mkv
Movies/电影名.年份/电影名.年份.扩展名

# 电视剧，eg: TVShows/The Last of Us/The Last of Us.S01/The Last of Us.S01E01.mkv
TVShows/电视剧名/电视剧名.S01/电视剧名.S01E01.扩展名
```

这里特别要遵循的关键点是：

1. 每一部电影都要放在同名文件夹内
2. 电视剧的每一季都需要单独建立文件夹

这样做的原因在上文有所铺垫，Emby 会将刮削的元数据以文件的形式保存在媒体文件附近，以电视剧《最后生还者》为例，最终的文件结构如下：

```bash
The Last of Us/
 - The Last of Us.S01                   # 第一季
	 - season.nfo                         # 季元数据
	 - The Last of Us.S01E01.mkv          # 第一季第一集视频文件
	 - The Last of Us.S01E01.nfo          # 第一季第一集元数据
	 - The Last of Us.S01E01.ass          # 第一季第一集字幕
	 - The Last of Us.S01E01-thumb.jpg    # 第一季第一集缩略图
 - The Last of Us.S02                   # 第二季
 - clearlogo.png                        # logo
 - fanart.jpg                           # 背景壁纸
 - landscape.jpg                        # 缩略图
 - poster.jpg                           # 封面图
 - tvshow.nfo                           # 电视剧元数据
```

可以发现 Emby 会刮削产生大量文件，如果没有预先为其建立文件夹做隔离，那么将会变得一团糟。

需要说明的是这类文件夹结构算是业界较为通用的做法，也就是说若有一天我们需要做资源的迁移或者把 Emby 更换成 Plex 也无需做过多结构改变。

**这些文件数据已经被持久化成为我们家庭的数据资产了。**

![[nas-video-media-organization-tips-attachments/20251228183821965.png]]

---

## 视频媒体类型术语扫盲

当我们在互联网高清片源下载资源时可能会遇到各种类型的资源文件，譬如下图：

![[nas-video-media-organization-tips-attachments/20251228183843245.png]]

如果我们不太懂 X264、BluRay 等专业术语大概率可能会直接通过文件体积大小来下载，对于用于长时间收藏的媒体资源自然是越大越高清越好咯！

但真的是`越大 => 越高清`吗？

实际上还需要结合我们的实际情况来挑选，有时候过大体积的媒体资源并无法带来更好的观影体验，尤其是当下 AI 修复、AI 补帧盛行，很多经过二次粗制滥造加工的资源体积变得巨大但画质却反而下降的现象并不少见。

### 分辨率 - 720p、1080p、2160p、4k

分辨率是首要关注的参数

|分辨率|别名|像素|
|---|---|---|
|720p|HD（高清）|1280 x 720|
|1080p|FHD（全高清）|1290 x 1080|
|2160p|4K UHD（超高清）|3840 x 2160|
|4K|DCI 4K（影院级）|4096 x 2160|

可以发现 2160p 和 4K 的参数非常相似，实际上消费级的电视机、流媒体宣传的“4K”通常是 2160p，只有专业影院、电影母带才会有真 4K 的概念。

所以对于家庭影视库而言我们以 2160p 为目标去收集媒体资源足矣！

**同时注意 AI 修复与原片的辨别与取舍。**

AI 修复会随着技术的发展而不断产出更加清晰流畅的加工片源，所以一般来说原片会更有收藏价值。

查阅 4K 的发展历史有以下关键节点：

- 2012 年《霍比特人》是首部 4K 48fps 拍摄的商业电影
- 2015 年 4K 蓝光标准发布，索尼、LG 大规模推出 4K 电视机
- 2016 年 Netflix、Disney+、AppleTV+ 将 4K HDR 设为标准配置

国内的流媒体发展还要比海外落后一些时间。

**所以可得出结论：2012 年以前的国产电影大概率是没有 4K 原片的，若出现此类资源一定是 AI 修复。**

譬如我在找姜文的《让子弹飞》（2010年）时找遍全网都没有正经的 4K，而《邪不压正》（2018年）就可以轻松找到，如果了解上述知识点，就无需费尽心思寻找了。

> 部分大厂 IP 例外，如索尼 1997 年拍摄的《黑衣人》在 4K 铺开之后由官方扫描原始胶片加入 HDR 后重制为 4K。所以若为海外电影可以查阅 [blu-ray.com](https://www.blu-ray.com/) 来确认发行信息。

### 编码方式 - x264 与 x265

- x264（H.264/AVC）：兼容性强，文件体积更大
- x265（H.265/HEVC）：需要更强解码设备，文件体积小

该参数与硬件条件有关，所以按照已有硬件选择即可。

另外 x265 经常与 10bit 绑定出现，10bit 和 8bit 代表色彩深度，10bit 有 1024 级色阶，色彩过渡更平滑，同样的它对解码器要求更高（主流 4K 基本已支持）。

所以在硬件条件中游以上的情况可以无脑优先选择 `x265.10bit` 的媒体资源。

### 视频来源 - BluRay/DB、WEB-DL/WEBRip

|来源标识|画质|体积|
|---|---|---|
|BluRay.Complete|无损蓝光|体积巨大，包含花絮、菜单等|
|REMUX|无损蓝光|体积较大，仅去除多余音轨、字幕等|
|BDRip|轻微损失，从蓝光压缩转码而来|体积中等|
|WEB-DL|接近蓝光，流媒体下载|体积中等|
|WEBRip|稍差，流媒体录制或者转码|体积小|

一般来说 REMUX、DBRip、WEB-DL 都可以接受，家中局域网带宽可以承受的话 BluRay 也完全没有问题。

### SDR/HDR/DV

无需多言：**`DV > HDR > SDR`**

### 音轨

这方面我并不是发烧友，加上我听力非常一般所以没有太多研究，仅做参数罗列：

- **AAC / AC3**：普通音效（2.0 或 5.1）。
- **DDP5.1（E-AC-3）**：流媒体常用5.1环绕声（如Disney+）。
- **DTS-HD MA / TrueHD**：无损音轨（蓝光常用）。

### 总结

综上所述，理想的家庭影视库媒体资源参数为：

---

```bash
Movie Name.2160p.x265.10bit.BluRay.TrueHD.7.1
# 一部上述规格单音轨电影大约在 15GB - 20GB 之间（仅供参考）
```

仍然以 2025 年 Netflix 的新剧《最后生还者2》为例。

![[nas-video-media-organization-tips-attachments/20251228183918525.png]]

这样子的配置就已经有了非常好的观影体验！

---

## 工具

预先善其事必先利其器，使用好的工具可以事半功倍。

### 下载工具 - 夸克网盘

其实写本文不久之前我还是以迅雷为主，但我发现夸克网盘的功能可以全方面平替迅雷之后，我就果断换成了夸克网盘。主要是迅雷的广告实在是太多了，而且迅雷播放器简直流氓！

我认为一款下载工具的优势在于：

- 【必须】边下边播。这个功能可以避免下载一整晚的资源发现带水印或者澳门皇家赌场的片头。
- 【必须】高速的磁链下载。在付费的情况下能够能够满速下载磁链，若自带私有缓存则用 HTTP 可以更快。
- 【必须】纯净。可以接受软件界面上存在牛皮癣，但篡改计算机配置或者夹带小软件是不能接受的。
- 【可选】网盘功能

目前来看，夸克网盘还算不错，值得推荐。（迅雷太流氓、百度云盘太烂）

### 文件批量重命名 - Advanced Renamer

放弃幻想，请学会使用至少一种批处理软件。不要妄图一个一个去修改文件名称！！！

### 混流器 - MKVToolNix

主要用于把字幕混流到 MKV 文件中，但我现在基本上不做这种蠢事了。

有些无良平台会把广告写入到视频的元数据中，表面上看不出来，但会被 Emby 等服务器解析到显示到界面上，MKVToolNix 可以方便的修改 MKV 文件的元数据，删除这些垃圾数据

![[nas-video-media-organization-tips-attachments/20251228183937274.png]]

修改完记得在头部编辑器中保存。

### 字幕编辑 - Subtitle Edit

遇到外挂字幕对不上轴可以用它轻微调整一下，非常方便。

如果出现字幕有时快、有时慢的情况，如果多次调轴仍然对不上需要考虑可能是视频文件帧损坏（不要傻乎乎对一晚上轴啦😭）

## 刮削库

当刮削有问题时可以直接去对应的刮削网站搜索，查看是否存在命名问题。]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[群晖 QuickConnect 代理 Emby 配置]]></title>
            <link>https://july.icu/articles/synology-quick-connect-proxy-emby-setup</link>
            <guid isPermaLink="false">synology-quick-connect-proxy-emby-setup</guid>
            <pubDate>Thu, 12 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[如何通过群晖的 QuickConnect 访问 Emby 服务]]></description>
            <content:encoded><![CDATA[QuickConnect 是群晖的一个局域网穿透服务，它最大的优势就是自带公网端口。

> 如果能从运营商处申请到公网 IP，那么就不用看这个方案了。

无论是局域网方案还是 QuickConnect 建立的 TCP 通道都是通过 DSM 的 Nginx 服务做流量代理。

默认情况下 QuickConnect 会直接代理 DSM 的 5000 和 5001 端口，这是 DSM 系统的后台界面，我们没有办法直接饶过它去访问 NAS 上部署的其他服务。

因此我们需要手动修改 NAS 上 Nginx 的配置

Nginx 位于 `/etc/nginx/nginx.conf` 配置包括：

```bash
include app.d/dsm.*.conf;
include conf.d/dsm.*.conf;
include /usr/syno/share/nginx/conf.d/dsm.*.conf;
```

其中前两个是自动生成的配置会重置，只有 `/usr/syno/share/nginx/conf.d` 是持久化保存的。

因此我们只需要在该文件夹下创建配置文件 `dsm.emby.conf`

```bash
location ^~ /emby {
  proxy_pass      http://127.0.0.1:8096$request_uri;
  proxy_redirect  off;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```

然后重启一下 Nginx 服务

```bash
nginx -t
nginx -s reload
```

然后启动 QuickConnect 即可在它提供的域名拼接上我们设置的路径访问 Emby，例如 [https://xxxxxx.quickconnect.cn/emby](https://xxxxxx.quickconnect.cn/emby) 这样。

**友情提醒：QuickConnect 提供的公网环境也有审查和监管，请谨慎公开内容。**]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ESLint 9.0 配置打平（flat）改造]]></title>
            <link>https://july.icu/articles/eslint-v9-config</link>
            <guid isPermaLink="false">eslint-v9-config</guid>
            <pubDate>Mon, 09 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[ESLint 升级到 9.0 之后配置文件名由.eslintrc.js 更改为 eslint.config.js ，并且配置项由原本的嵌套对象结构更改为了打平数组结构，废弃了原本的 extends、override 等组合式字段。]]></description>
            <content:encoded><![CDATA[ESlint 升级到 9.0 之后配置文件名由`.eslintrc.js` 更改为 `eslint.config.js` ，并且配置项由原本的嵌套对象结构更改为了打平数组结构，废弃了原本的 `extends`、`override` 等组合式字段。姑且称这种新模式为 **flat mode**。

> https://eslint.org/docs/latest/use/configure/configuration-files

之前写过一篇文章 [[eslint-config-best-practices | ESLint 配置最佳实践]]，内容包括 eslint 的语言配置、与 prettier 的协同、IDE 的格式化能力等。来到 9.0 之后配置大改，因此单独更新一文专门记录配置的调整。

## 前置的思考

虽然我是「极左派」（会及时把软件或者系统升级到最新以尝鲜），但对于依赖包而言，在真正动手之前还是需要改动的 ROI 有几分底气。

**关于性能**

9.0 之后 ESlint 的配置检索只限定于指定名称的文件，不再从 `package.json`等内部读取。所以配置性能读取可以说有质的提升。包括 `extends`一些隐晦的别名写法实际上也会降低配置文件路径的精确度，而需要多次遍历。

**关于工程集成性**

工程集成性其实分为两个方面：多语种和复用性。

在这之前可以说 ESlint 是专为 Javascript 设计的。一方面 Javascript 本身比较能“整活”，ES5、ES6 等语法迭代太快都快成两种语言了，更别说当今又是 Typescript 的天下，混合着 React TSX 等多种框架特有语法简直欲仙欲死； 另一方面对于工程而言 JSON、YAML、SVG、CSS 等多种格式的文件也有了不同程度的 Lint 要求。

在大型的 Monorepo 工程下，不同类型的项目（webApp、node、unit test）等不同性质的项目对 Lint 的要求也有所不同。 `extends` 和 `override`配置是“继承覆盖”的思路，相对繁琐的多。

## 各类插件的兼容性

自己写 rule 是不可能自己写的，用 ESlint 还是以配置市面上已有的插件为主。

那么问题来了，几个主流插件是否支持扁平化配置呢？

### ✅ eslint-plugin-prettier

Prettier 作为代码风格的基石，充满了老一辈的艺术家从容，从 5.1.0 版本起 eslint-plugin-prettier 包中增加一个 recommended 导出支持扁平配置

![[eslint-v9-config-attachments/20260105220402045.png]]

> 别忘记了使用它需要安装 prettier, eslint-config-prettier, eslint-plugin-prettier 三个包

```ts
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default [
  eslintPluginPrettierRecommended,
  {
    rules: {
      'prettier/prettier': [
        // custom rules
      ]
    }
  }
]
```

### ❌ @typescript-eslint/eslint-plugin

> https://typescript-eslint.io/getting-started/legacy-eslint-setup

使用 @typescript-eslint/parser 直接配置 parser 的时代已经过去了，parser 等语言相关的配置项都被收到了 languageOptions 里面。所以 @typescript-eslint/parser 搭配 @typescript-eslint/eslint-plugin 的配置方式已经不兼容。

### ✅ typescript-eslint@v8

> https://typescript-eslint.io/getting-started/

早在 v7 版本typescript-eslint 就开始有意收紧各类依赖的开放程度来改善各种版本配置的兼容性，在[官方博客](https://typescript-eslint.io/blog/announcing-typescript-eslint-v7)中的描述为「Into the Future」。当 ESlint 迈向 9.0 时，v8 版本的 typescript-eslint 用一个 breaking change 告别了过去繁琐且充满不确定性的配置。

至此 Typescript 的 ESlint 配置可以简化为：

```ts
import eslintJs from '@eslint/js';
import tsEslint from 'typescript-eslint';

export default [
  eslintJs.configs.recommended,
  ...tsEslint.configs.recommended,
]
```

### ✅ eslint-plugin-import-x 与 eslint-plugin-import

截止到目前两者都已经支持了 ESlint 9。

要吐槽的是 eslint-plugin-import 动作要慢的很多，可以看到[相关的 issues](https://github.com/import-js/eslint-plugin-import/issues/2948) 在 23 年就已经提出，而 eslint-plugin-import 在 24 年的 9 月才发布 2.30.0 版本支持，对它的持续维护性和活跃度感到担忧。

我在升级的时候它还没有支持计划，迫不得已我只能换成 eslint-plugin-import-x，作为更轻量级的平替用起来差别不大、体感不明显：

```ts
import eslintPluginImportX from 'eslint-plugin-import-x';

export default [
  eslintPluginImportX.flatConfigs.recommended,
  eslintPluginImportX.flatConfigs.typescript,
]
```

### ✅ eslint-plugin-jsonc

我把它用于 package.json 中依赖的自动排序，Monorepo 仓库中的永远的神。我们只需要用它 prettier 的插件即可

```ts
import eslintPluginJsonc from 'eslint-plugin-jsonc';
export default [
  ...eslintPluginJsonc.configs['flat/prettier'],
]
```

## 按照语言/功能性分类

基于扁平化的设计以及官方的 files、languageOptions 等资源，按照语言来分类是一件非常自然而言的事情，除了 prettier 本身是功能性插件以外（JS 和 TS 都会用到）

```
- prettier        # 公共功能性插件
- javascript
- typescript
- package-json
```

### export default config[] 约定

可以发现上文在讲插件时，有的插件导出是一个对象需要这样使用 `[someConfig]`；有的插件导出是一个数组这样使用`[...someConfig]`。

没有统一的导出约定也会极大提高使用成本，所以内部封装的插件统一是导出数组。

### configMerge

该函数的主要功能是为所有的数组添加上 files 选项，保证不同规则之间的隔离

```ts
const configMerge = (configs, config) => {
  return configs.map((conf) => ({ ...conf, ...config }));
};
```

比如最终的 tsLintConfig 如下：

```ts
const config = [
  eslintJs.configs.recommended,
  ...tsEslint.configs.recommended,
  ...tsEslint.configs.stylistic,
  ...eslintPrettier,
  eslintPluginImportX.flatConfigs.recommended,
  eslintPluginImportX.flatConfigs.typescript,
]

export default configMerge(config, {
  files: ['**/*.{ts,tsx}'],
})
```

## 后言

从历史包袱和活跃性而言，JS 和 PHP 是何其相似的两兄弟，成也萧何败也萧何。

工具链的统一规范对一门语言重要性不言而喻。抛开其他因素不谈，ESlint 9 有勇气甩开历史包袱重构是值得点赞的，而且社区的插件能这么快响应是我意料之外的（点名批评 import-js），我以为会像 rollup 一样先烂一阵子。]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[TypeScript Decorators 原理解析]]></title>
            <link>https://july.icu/articles/typescript-decorators-implementation-explained</link>
            <guid isPermaLink="false">typescript-decorators-implementation-explained</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[InversifyJS 的依赖定义和注入依赖 TypeScript 的装饰器实现。因为用到了，所以花了点时间学习了一下。有所收获，所以可以简单聊一聊。]]></description>
            <content:encoded><![CDATA[InversifyJS 的依赖定义和注入依赖 TypeScript 的装饰器实现。因为用到了，所以花了点时间学习了一下。有所收获，所以可以简单聊一聊。

装饰器模式是一种设计模式，指在不改变现有对象结构的情况下，动态的给该对象增加、移除一些功能。

TypeScript 的装饰器从提案到到如今的 stage3 阶段经历了整整九年。其中 stage1 在 TypeScript 1.5 就已经支持，通过 `--experimentailDecorators` 开启，也是用的最多的；2016 年的 stage2 并没有被广泛使用。

本文讨论的 TypeScript Decorators 是 TypeScript 5.0 及以上支持的 stage3 阶段。（stage3 与 stage1 已存在部分不兼容）。

---

## 什么是装饰器？

装饰器本质是一个函数，接收被装饰的对象（target）返回类型相同的对象（result）；在被装饰的对象定义前使用 `@` 符号表明装饰器，可以用空格或者换行分隔。最简单的实现可以如下：

```tsx
/** 装饰器 */
function exampleDecorator<T>(target: T): T {
  return target;
}

/** 使用 */
@exampleDecorator
class ExampleClassA {}

/** 也可以同一行，用空格分隔 */
@exampleDecorator class ExampleClassA {}
```

### 多个装饰器

同一个被装饰对象可以被多个装饰器装饰，装饰器的执行逻辑为倒序执行。

```tsx
/** 装饰器 */
function exampleDecoratorA<T>(target: T): T {
  console.log("A");
  return target;
}
function exampleDecoratorB<T>(target: T): T {
  console.log("B");
  return target;
}

/** 可以被多个装饰器装饰 */
@exampleDecoratorA
@exampleDecoratorB
class ExampleClassB {}

// log：B、A
```

### 装饰器工厂

装饰器被真正用于生产时，我们通常会习惯编写装饰器工厂返回一个装饰器来增强其代码重用性和可配置性。

```tsx
function log(type: string) {
  console.log('factory: ', type);
  const decorator: any = function (target: any) {
    console.log('decorator: ', type);
    return target;
  };
  return decorator;
}

@log('class')
class ClassA {
  @log('field') fieldA = 1;
}
```

值得一题的是，装饰器工厂的运行时机要远早于装饰器函数的运行时机，并且不同被装饰对象的运行时机各不相同，其中类对象的装饰器工厂是最早被执行的，同时它的装饰器函数是最晚被执行的。

即上述示例代码的日志输出为：_**factory: class => factory: field => decorator: field => decorator: class**_

具体的原因在后文还会提到。

---

## 被装饰对象

上文频繁提到的「被装饰的对象」并非特指「Object」。装饰器一共有 6 种类型，对应 6 种可被装饰的对象，其中 `accessor` 是 stage3 新增的一种用法。

```tsx
type Kind = "class" | "method" | "getter" | "setter" | "field" | "accessor"
```

不同的被装饰对象所需要实现的装饰器也存在细微差别。我们把接口都列出来

### Class

```tsx
type ClassDecorator = (
  value: Function,
  context: {
    kind: 'class';
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;
```

### Method

```tsx
type ClassMethodDecorator = (
  value: Function,
  context: {
    kind: 'method';
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;
```

### Getter/Setter

```tsx
type ClassGetterDecorator = (
  value: Function,
  context: {
    kind: 'getter';
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;

type ClassSetterDecorator = (
  value: Function,
  context: {
    kind: 'setter';
    name: string | symbol;
    access: { set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;
```

### Field

```tsx
type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: 'field';
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => (initialValue: unknown) => unknown | void;
```

## Accessor

```tsx
type ClassAutoAccessorDecorator = (
  value: {
    get: () => unknown;
    set: (value: unknown) => void;
  },
  context: {
    kind: 'accessor';
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => {
  get?: () => unknown;
  set?: (value: unknown) => void;
  init?: (initialValue: unknown) => unknown;
} | void;
```

---

## ES Decorators Helpers

由于装饰器目前还仅属于 TypeScript 标准，是无法直接在浏览器或者 Node 环境跑起来的。所以 TSC 在把 TypeScript 编译到 JavaScript 的过程中遇到了装饰器语法需要把它翻译成 JavaScript。

从 TypeScript 源码中我们可以找到翻译装饰器的逻辑集中在 [esDecorators.ts](https://github.com/microsoft/TypeScript/blob/main/src/compiler/transformers/esDecorators.ts) 文件中。

找到入口函数 `transformSourceFile`，观察发现翻译前 TSC 会先插入一段帮助代码：

![[typescript-decorators-implementation-explained-attachments/Pasted image 20251228202946.png]]

这段帮助代码叫做 ES Decorators Helper，是一个名叫 `__esDecorate` 的函数。这个函数的作用就是把装饰器的语法糖翻译成符合 ECMAScript 标准的 JS 代码。

接下来我们就来仔细研究一下这段垫片函数：

![[typescript-decorators-implementation-explained-attachments/20251228203001728.png]]

由于这段代码会在 TSC 编译的过程中直接插入到源码中，所以可读性是比较差的。我们重点盘一下逻辑：

### 数据预处理

在真正开始循环运行装饰器函数前，会针对不同类型的属性做一些数据预处理：

1. **获取属性描述符**

    属性的描述其实就是 `Object.defineProperty()` 函数返回的数据结构，数据属性通常会把关联的值放在 `value` 字段上，而访问器就是我们熟悉的 `get` 或者 `set` 字段。

    > 什么是属性描述符？看这里：[Object.defineProperty() - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)

    ```jsx
    const { kind } = contextIn;
    const key = kind === 'getter' ? 'get' : kind === 'setter' ? 'set' : 'value';
    ```

    但是和其他属性不同的是 accessor 同时具有 get 和 set 字段，所以后面有一段硬编码

    ```jsx
    const value = kind === 'accessor' ? { get: decriptor.get, set: decriptor.set } : descriptor[key]
    ```
    
2. **获取属性的宿主和属性的描述**

    「属性的宿主」指代该属性真正被挂载的地方。

    到这里我们需要回忆一下原型知识：

    - 所有未被显式定义在实例上的属性都被挂载在原型上
    - 静态的属性挂载在构造函数上

    我们以一个 Person 对象为例：

    ```tsx
    class Person {
      age = 10;
      static getAge(persion: Person) {
        return persion.age;
      }
      getAge() {
        return this.age;
      }
    }
    const person = new Person();
    console.log(person.getAge() === Person.getAge(person)); // true
    ```

    整个 `Class` 的语法糖翻译和原型链构建流程图如下：

    ![[typescript-decorators-implementation-explained-attachments/20251228203032620.png]]

    装饰器的预处理时机是远早于对象实例化的时机的，普通的 Field 的宿主为实例，在此时是拿不到的；另外 Class 本身也没有宿主一说。所以 Field 和 Class 的宿主都为 `null`。

    其他的属性的宿主是原型还是构造函数取决于属性是否是**静态**的。

    有了这个前置知识点，我们再来看源码就一目了然了。

    ```jsx
    /**
     * 1. kind === class || kind === field   => target = null
     * 2. static                             => target = constructor
     * 3. others                             => target = prototype
     */
    const target = !descriptorIn && ctor ? (contextIn.static ? ctor : ctor.prototype) : null;
    ```

    有了 `target`，我们可以借助 `Object.getOwnPropertyDescriptor` 静态方法拿到属性的描述

    Class 没有描述的说法，为了让它能够像普通属性一样被装饰器栈迭代处理，会从外部传入一个参数 `descriptorIn` 来模拟属性描述。

    ```jsx
    /**
     * 1. kind === field && !static  => descriptor = {}
     * 2. kind === class             => descriptor = { value: constructor }
     * 3. others                     => descriptor = Object.getOwnPropertyDescriptor(target, contextIn.name)
     */
    const descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
    ```

    前面提到过装饰器函数的运行时机早于实例化时机，所以普通的 Field 没有描述且在此处不用做任何处理。

### 装载装饰器

有了 `target` 和 `decriptor` 就可以真正的开始装载装饰器了。

装饰器本质是一个函数，并且我们在使用的时候允许对一个属性添加多个装饰器。

所以装载的核心代码是遍历运行一次装饰器函数队列，上文提到过多个装饰器是倒序运行的，所以遍历也是反向的：

```jsx
for (const i = decorators.length - 1; i >=0; i--) {
  const result = decorators[i]();
}
```

是不是瞬间觉得很简单。

还有一个需要明确的点是：**装饰器的装载过程是一个 reduce 的过程，下个装饰器消费上个装饰器的加工结果产生新的结果，以此遍历得到的最终结果会覆盖原有属性。**

在循环中需要处理两个东西：上下文（context）和结果（result）。

- **构造上下文**

    上下文是装饰器 stage3 提案新增的入参。

    不同类型属性的上下文会有些不同，具体的不同点在上文的「被装饰对象」接口都已经列举出来了，这里不多赘述。

    这里要关注的是两点：

    - 上下文变量 `context` 来自语法糖翻译时直接传入的参数 `contextIn` 的浅拷贝，且浅拷贝在每个装饰器装载时都会进行。这意味 `context` 对于装饰器是只读的，且不参与 「reduce」的。
    - 所有类型的属性公共函数 `context.addInitializer` 会尝试往 `extraInitializers` **队列末尾**添加额外的装载器。**该行为仅能在装载过程中进行**。

    ```jsx
    /** 浅拷贝，但 access 字段需要下钻一层浅拷贝 */
    const context = {
      ...contextIn,
      access: contextIn.access ? { ...contextIn.access } : undefined,
      addInitializer: function(f) {
        /**
         * 如果已经装载完成就不允许入队
         * 滞后的调用、异步的调用都是非法的
         */
        if (done) throw new TypeError('Cannot add initializers after decoration has completed');
        extraInitializers.push(f);
      }
    };
    ```

    `extraInitializers` 队列的执行时机视属性类型而定，此处暂时按下不表。

- **迭代结果**

    把装饰器函数运行的结果作为描述符的值直到运行完成之后回写到宿主对应的属性上。

    类型为 `field` 的属性比较特别，此时它的值还不存在。所以 `field` 类型的装饰器的返回值只能是空值或者函数，若是函数则会被添加到 `initializers` 队列的**开头**。

    除此之外 stage3 新增的语法糖 `accessor` 也很特别，它是 `field` 和访问器的结合。因此它不但要覆写描述符还同样需要往 `initializers` 队列开头添加 `init` 函数。

    ```tsx
    /** 循环内 */
    const value = kind === 'accessor' ? { get: decriptor.get, set: decriptor.set } : descriptor[key];
    const result = decorators[i](value, context);
    
    if (kind === 'accessor') {
      if (result.get) descriptor.get = result.get;
      if (result.set) descriptor.set = result.set;
      if (result.init) initializers.unshift(result.init);
    } else if (kind === 'field') {
      initializers.unshift(result);
    } else {
      descriptor[key] = result;
    }
    
    /** 循环外 */
    if (target) {
      Object.defineProperty(target, contextIn.name, descriptor);
    }
    ```

---

## 语法糖翻译

上述的 `__esDecorate` 函数帮我们完成了 90% 的翻译工作。剩下的工作在对象内完成。

仍然以上述的 Person 对象为例，我们为它增加几个属性补全装饰器的所有场景：

```tsx
@d("class") class Person {
  @d("static field") static isAnimal = true;
  @d("field") age = 10;
  @d("accessor") accessor sex = "man";
  constructor(age: number, sex: string) {
    this.age = age;
    this.sex = sex;
  }
  @d("static function") static getAge(person: Person) {
    return person.age;
  }
  @d("function") getAge() {
    return this.age;
  }
}
```

Accessor 基本涵盖了 Getter/Setter 的能力，在示例中 Getter/Setter 就精简掉了，下文涉及到的地方会提到。

### 调用帮助函数

每一个被装饰器装饰的属性都调用一次帮助函数，调用的格式和顺序如下：

```tsx
__esDecorate(Person, null, [d("static method")], { kind: "method", name: "getAge", static: true }, null, staticMethodExtraInitializers);
__esDecorate(Person, null, [d("accessor")], { kind: "accessor", name: "sex" }, accessorInitializers, accessorExtraInitializers);
__esDecorate(Person, null, [d("method")], { kind: "method", name: "getAge" }, null, methodExtraInitializers);
__esDecorate(null, null, [d('static field')], { kind: "field", name: "isAnimal", static: true }, staticMethodInitializers, staticMethodExtraInitializers);
__esDecorate(null, null, [d('field')], { kind: "field", name: "age" }, fieldInitializers, fieldExtraInitializers);
__esDecorate(null, descriptor = { value: Person }, [d('class')], { kind: "class" }, null, classExtraInitializers);
```

翻译顺序没有太多意义，只需要注意一下 Class 本身是最后被调用的即可，不过还是列一下： **static method => accessor => method => getter => setter => static field => field => class**

好了，接下来我们要逐渐回收上文留下的一些伏笔！

### Class 覆盖

上文提到普通的属性在最终会调用 `Object.defineProperty` 函数，把装饰器迭代的最终结果覆盖到宿主的同名属性上。而 Class 是覆盖它本身，这也是为什么它是放在最后的。

```tsx
Person = descriptor.value;
```

### Static ExtraInitializers & Static Field

上文提到在装饰器中调用 `context.addInitializer` 函数可以往 `extraInitializers` 队尾添加一个初始化函数，静态属性的 `extraInitializers` 就是在此处被运行，Class 的 `extraInitializers` 队列在最后运行。

Static Field 和普通的 Field 一样会在此处初始化值。

```tsx
__runInitializers(Person, staticMethodExtraInitializers);
Person.isAnimal = __runInitializers(Person, staticMethodInitializers, true);
__runInitializers(Person, staticMethodExtraInitializers);
__runInitializers(Person, classExtraInitializers);
```

这里的 `__runInitializers` 也是一个帮助函数，它的功能基本等效于 `Array.reduce`。由于它非常简单这里不做过多介绍，在后面我们还会多次用到它：

![[typescript-decorators-implementation-explained-attachments/20251228203121482.png]]

至此，所有在对象声明时要做的事情就已经做完了。剩下几个属性的逻辑在构造函数中处理，也就是说接下来的逻辑仅会在对象实例化时被执行。

### 构造函数语法糖

Accessor 和普通 Field 的值在构造函数中迭代赋值。

**其他类型的 `extraInitializers` 也是在此时被执行的，且 `initializers` 会先于 `extraInitializers`**。这是需要特别注意的，很容易陷入在声明时执行的误区。

```tsx
constructor(age, sex) {
  __runInitializers(this, methodExtraInitializers);

  this.age = __runInitializers(this, fieldInitializers, 10);
  __runInitializers(this, fieldExtraInitializers);

  sexAccessorStorage.set(this, __runInitializers(this, accessorInitializers), 'man');
  __runInitializers(this, accessorExtraInitializers);

  this.age = age;
  this.sex = sex;
}
```

另外，原本构造函数中的逻辑会在随后执行，如果有赋值操作也会直接覆盖装饰器的结果。

---

## Accessor

相信大家会对上文代码块中的 `sexAccessorStorage` 感到疑惑。它实际也是对 Accessor 语法糖的翻译，考虑到 Accessor 本身是为装饰器服务的关键字，所以也算是本文的范畴，这里做额外的解释。

Accessor 相当于 Field + Getter + Setter 的缩略写法。便于理解的翻译如下：

```tsx
class Person {
  accessor sex = 'man';
}

/** 不完全等效于 */
class Person {
  private _sex = 'man'
  get sex() {
    return this._sex;
  }
  set sex(v: string) {
    this._sex = v;
  }
}
```

不完全等效的原因是 `_sex` 并不是 `Person` 的属性，它只是一个在对象声明范围内的局部变量。

TSC 使用 `WeakMap` 来存储这一变量值

```tsx
/** 这里做了简化，实际上这个变量是被包裹在对象声明范围内的闭包里 */
const sexAccessorStorage = new WeakMap();

class Person {
  constructor() {
    sexAccessorStorage.set(this, 'man');
  }
  get sex() {
    return sexAccessorStorage.get(this);
  }
  set sex(v: string) {
    sexAccessorStorage.set(this, v);
  }
}
```

使用 `WeakMap` 的意义很明确，Accessor 的值和对象实例应当有相同的生命周期，当实例被销毁时，Accessor 的值也可以被 GC。

> Q: 试想一下，如果使用 Map 来维护会有什么问题？ A: 每次 new Person() 都会触发 Map.set()，但又缺乏一个合适的时机 Map.delete()，随着实例数量不断增多很容易导致内存泄露。

---

## 总结

小小的装饰器有巨大的学问。

装饰器在复杂的业务场景中有巨大的作用，本文算是一个抛砖引玉，只讲了原理没有讲用法。

不过吃透原理尤其是内部实现是非常重要的，应用的场景万变不离其宗，后面我会结合 Reflect metadata 讲讲 InversifyJS 的依赖注入是怎么实现的。]]></content:encoded>
        </item>
    </channel>
</rss>