<?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, 17 Apr 2026 10:17:06 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[什么是浏览器 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>
        <item>
            <title><![CDATA[云南游记]]></title>
            <link>https://july.icu/articles/2024-yunnan-travelogue</link>
            <guid isPermaLink="false">2024-yunnan-travelogue</guid>
            <pubDate>Thu, 11 Apr 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[2024 年的春节我们出发去了云南 5 日游。]]></description>
            <content:encoded><![CDATA[2024 年的春节我们出发去了云南 5 日游。

由于太久没有长途旅游（自工作以来基本没怎么出门玩耍），我这次的野心很大，准备按顺序游览 3 个城市：

**昆明 - 大理 - 丽江**

整体体验下来美中不足的是花了比较多的时间在交通上，5 天玩 3 个城市还是有些捉襟见肘了，个人觉得可能 2 个城市或者只玩丽江就蛮好的。当然这些是后话了。

## 行程总览

由于我们要跑好几个城市，所以事前列了一份重要的时刻表，主要是与飞机、动车的发车、到达强相关（实在是怕错过某班车以至于计划出岔子）

- **2 月 13 日 杭州至昆明**
    - 【09:10 - 12:35】落地昆明长水国际机场
    - 【下午】昆明老街 + 海埂大坝
- **2 月 14 日 昆明至大理**
    - 【10:09 - 13:27】动车至大理，公交车至大理古城
    - 【下午 + 晚上】大理古城
- **2 月 15 日 大理至丽江**
    - 【16: 58 - 19:43】动车至丽江
- **2 月 16 日 丽江**
    - 【全天】玉龙雪山
- **2 月 17 日 丽江至杭州**
    - 【07:32 - 12:04】丽江至昆明
    - 【15:25 - 18:00】落地杭州萧山机场

## 昆明

第一天在昆明落脚，我们落地昆明大约是在 12 点 43 分。这个时间对于旅游来说是有点晚了，不过这是我们能赶上的最早的时间。

需要说明一下的是我们从住所乘坐地铁到萧山国际机场大约需要两个小时，因此我们是大约早晨 6 点半左右出门，乘坐地铁早班车（2 月份的杭州这个点天还是黑的）。所以说行程也是旅行的重要组成部分（笑。

出了机场之后，地铁 6 号线直达市中心，先去酒店办理入住放行李。

酒店选的位置现在看来还是不错的，下楼就可以逛逛步行街，比较热闹。离高铁站也不远（第二天还要早起去大理）

![[2024-yunnan-travelogue-attachments/20260109235201416.png]]

### 滇池

略微在酒店休息了一下，就准备启程前往滇池。

本着充分信任地方文旅局和交通局的思想下，我们选择了在老街附近乘坐「景观一号线」公交车。

**然后我们等了 40 分钟没有来一辆车！**

到也不能完全怪相关部门，一方面是旅游旺季交通压力大也是正常的，指不定车是有的只不过都堵在了路上；另一方面也是我们先入为主，想着乘坐观景公交车也许可以顺便游览景观路线的景色（至少杭州的 1314 号公交车是推荐坐哒！虽然可能也有点堵）。

然后我们果断不再坚持，选择了打车。

然后的然后景区附近交通管制了，小汽车不让进、自行车也不让行。于是我们开始步行前进，大约有个 20 分钟的脚程。

期间有个不太愉快的小插曲：

步行的路线比较拥挤，路况极差。马路上车基本上动不了，人行道上分布了不少小摊小贩，大多是售卖差不多的特产，然后本人馋虫就上来了准备买一份包浆石屏豆腐尝尝。

随机问了一家小摊说是 10 元 12 个，本人寻思怕吃不完浪费就商量着能不能 5 元 6 个，大妈摇摇头表示不卖。然后我问隔壁卖相同商品的大叔卖不卖，还没等大叔犹豫完，另一位大叔（相邻的另一位小贩）就插嘴道：”不卖不卖，要么 1 元 1 个要不要？“大叔被他一插嘴也附和道 1 元 1 个。

wtf?? 我瞬间没了兴致转头就准备走了，然后听到插嘴的大叔在那嘀咕：又要便宜又要少哪有这种好事。（其实之前我大概知道包浆豆腐的市场价大约是 10 元 24 个，景区贵了一倍我就不说什么了，只是不能总是把游客都当冤大头吧）

然后我们晃荡晃荡到了滇池

![[2024-yunnan-travelogue-attachments/20260110002529875.png]]

滇池感觉是个大水库，有海鸥但不多（不过我和我女友倒是对喂海鸥没有表现出太多兴趣）。

主要的观景区域是一段大坝，一上来的感觉非常像上海外滩沿江那一段水泥堤坝，且人非常多。

![[2024-yunnan-travelogue-attachments/20260110003026114.png]]

我个人的评价是不如杭州下沙江滨那一段钱塘江的沿江大道。有图有真相嗷，钱塘江水浑浊的要死，但吹着江风风景独好（下图是钱塘江）~

![[2024-yunnan-travelogue-attachments/20260110003112089.png]]

### 市中心步行街

在前往滇池的前后，我们也略微逛了一下市中心的步行街。可能是过年期间整体氛围比较喜庆热闹，人流量适中，步行体验还不错。

沿街的综合体建筑风格略显年代感，比如有许多使用上世纪末比较流行的蓝色玻璃的建筑。

![[2024-yunnan-travelogue-attachments/20260110003550821.png]]

在步行街附近吃了一家叫“云南甩碗米线”的米线。店面不大，但奈何客流量比较大，店家又是采用最原始的付钱记账制度，等了很久（期间被不少说本地话的朋友插队，比较无奈）。

![[2024-yunnan-travelogue-attachments/20260110013802298.png]]

米线的味道比较寡淡，有点像兰州拉面；口感上也没什么嚼劲。个人觉得不如公司食堂的小锅米线。

在逛街的时候看到一家叫“ZAKUZAKU”的甜品点，主要是卖甜筒和冰淇淋泡芙。

店面很小，顾客不多。在我们前面排队点单的就 2 位，但似乎都是回头客，点单相当轻车熟路都是打包带走。

出于好奇点了一个巧克力甜筒试试水。然后发现味道是相当不错。巧克力酱甜而不腻，冰淇淋比较绵密。

![[2024-yunnan-travelogue-attachments/20260110014252192.png]]

回到杭州之后还查了一下发现杭州没有（遗憾）

在我们随便逛逛的前提下，昆明的体验整体非常一般（还是在首日游玩的兴奋劲加持下）。

简单做个评价：

- **人文：★☆☆☆☆**
- **景观：★☆☆☆☆**

本来想人文能给到两星，因为在昆明吃到了好吃的鲜花饼是加分项。

但是在嘉华买鲜花饼礼盒邮寄的时候，我千叮咛万嘱咐是需要送人的需要礼品袋子，且店员当面点了三个袋子一起放在货篮里。但收到快递之后发现没有礼品袋（送人都送不出手）。

## 大理

第二天我们早早的就坐上了前往大理的高铁。

非常惊喜的是：我们买到的是二等座的票但由于车次调整等原因实际坐的是一等座。放在平时可能感觉没怎么明显，但从极端拥挤的候车室挤到车上之后真的会由衷的感谢一等座的宽敞！

大理高铁站比较老旧，比杭州城站还要老旧一些，有许多人拎着大包小包席地而坐，有点像以前的长途汽车站（看到有在扩建，希望下次去的时候能体验到新车站）。

到达大理高铁站之后需要经过一个天桥，然后走一小段路到达汽车始发站，坐 4 路车可以直达大理古城南门。

大理地处横断山脉的西南侧，故地势呈西高东低，从公交车上往东看可以很明显的看到地势一路走低，甚至可以直接看到洱海。

> 大理古城西接苍山、东临洱海，可谓是“依山傍水”。

这个地形真的很有意思，放在古代绝对是易守难攻之地。

![[2024-yunnan-travelogue-attachments/20260110015556699.png]]

旅游旺季公交车基本上是满负荷运作的，没有座位，我们俩是全程站着的。交通也不是太好，公交车走走停停约 1 个半小时，我们在玉局路口下车步行。

古城外围沿南北主干道中轴分布，两边都是村子，基本上家家户户都经营民宿、特色小吃。

我们在巷子里穿行，走了约 20 分钟到达预定的民宿。古城周边的民宿大多是普通的农村自建房改造，还是老样子先放行李然后准备去洱海。

### 洱海

从古城到洱海西岸大约还有 4 到 5 公里的路程，路上非常堵，且多农村小道计程车其实不会太快，步行又稍微偏远，最好的方式是租一辆小电驴。

我们非常幸运的在村里的小超市租到了仅剩的一辆小电驴，120 元租一天，支付宝免押金~

![[2024-yunnan-travelogue-attachments/20260110015645663.png]]

没有提前规划目的地，力求到达湖边最短捷径，所以我们开着开着就真的开进了农村。

![[2024-yunnan-travelogue-attachments/20260110015805229.png]]

最后的一段路非常难骑，我们一度以为是断头路，不过在越过了一个“小土坡”后发现还有一段弯弯曲曲的泥土小径可以通行（差点把车开到边上池塘里去）。

然后，我们终于到洱海边了！

![[2024-yunnan-travelogue-attachments/20260110015840846.png]]

由于我们的时间并不多，属于走马观花性质的，所以不存在“环游洱海”这种桥段。

洱海本身是非常美丽的，主要体现在水质、生态、景观三个方面吧。

![[2024-yunnan-travelogue-attachments/20260110015853744.png]]

水质远看是体现不出来的，洱海沿岸大部分不适合直接接触水（地势比较陡峭或者有护栏），我们在靠近才村的一个废弃码头真正近距离接触到了水，浅滩清澈见底。

洱海虽然名字带海，其实是一个湖泊。即使是环境治理较好的湖泊在岸边芦苇荡也容易富集垃圾或者白沫子，但洱海岸边相当干净，在居民区离湖边如此之近的情况下实属不易。

生态好主要体现在小动物，比如鸭子、海鸥、鸳鸯啥的在湖面上随处可见，当然在不少其他景区也会人为的畜养一些动物，不过养殖的和野生的观感差距蛮大的。洱海边这些动物非常放松自然，野性十足，我们甚至目睹了几次海鸥大战黑鸭子，非常有趣。

人也会被这种氛围感染，感觉身心都得到了净化。

湖、动物、人共同构成了宁静安详的洱海。不过我们作为拿金钱换时间的游客心态上还是会浮躁不少，我们租了一辆自行车略微骑行了一路，大部分游客朋友们都忙着在拍照（一生要出图的女孩们~hhh）。

而且人流量太大导致湖岸边踩踏比较严重，几处网红打卡点被踩的寸草不生甚至有荒漠化的赶脚，一阵湖风吹来沙尘洗脸，以至于我们后面不得不买了两副墨镜来挡挡风沙。我相信旅游淡季的洱海一定更美丽。

### 古城

游完洱海之后我们回到民宿稍作休息就出发游览大理古城啦。

到达南门口后首先映入眼帘的自然是郭沫若题的“大理”两字，然后便是古朴的城墙城楼，然后再是无数的人头。

![[2024-yunnan-travelogue-attachments/20260110015909738.jpeg]]

古城的人流量、车流量更大，想要进出城都是一件颇为不容易的事情。古城南门口的纵横两条主干道是允许车辆通行的双向一车道，没有步行街、没有红绿灯，行人车辆随意穿行，所以就出现了严重的人挤车、车挤人现象（北门口也是差不多的情况）。

古城内虽然不能进车，但到晚上饭点时间，人民路、复兴路等几条主干道也基本上走不动道，只能慢慢的随着人流挪动。

于是乎只有深入人流较少的小巷子才能真正感受到大理古城的文艺气息。大理官方在规划时似乎有意分割居民区域和旅游区域，想要从满是店铺的主干道岔到真正的古城街道其实颇为不易，好几个岔路都是突兀的断头路（被人为的堵住了，应该是不想游客和原住民互相打扰）。

我们一整天都没有坐下来正式的吃一顿，到了夜色降临两人已经饥肠辘辘。

为了找点吃的果腹，我们像无头苍蝇一样在古城里乱窜，无意间走到苍坪街尽头发现了 **「大理床单厂」** 的招牌。

**「大理床单厂」** 其实是一个废弃厂改造的艺术园区（让我想起了成都的东郊广场），说是园区其实就是横竖两三条巷子，有一些以创意为主打的书店、画廊、咖啡店，最多的还是工艺品小摊。

在被“义乌批发”占领的时代，其实工艺品等小玩意不具备太强的竞争力。即便有摊主声称她的蝴蝶标本发卡是自己设计手工制作的，也很难摆脱“批发品”的标签（至少我和我女友是不太心动的）。

**但是我们吃到了非常好吃的生煎！**

![[2024-yunnan-travelogue-attachments/20260110015921422.jpeg]]

摊位在入口附近，摊主是一个三十来岁（如果猜错了只能说声抱歉 hhh）的小哥。

我们决定要买一份的时候已经剩下最后半份了，摊主把准备自己吃的给了我们。所以我们吃到了猪肉、韭菜双拼生煎。

撒在上面的辣椒粉是小哥自制的，我的本意是不要的（我不爱咯牙齿的颗粒的口感），他非常固执的给我们撒上了许多，说是非常好吃。

也许有我们俩空腹的加成，生煎表皮的软、底的脆、肉馅的香鲜基本上都做到了九分以上。摊主小哥的手艺不错，并且我觉得他的美食品味也一定不错。

我个人在小吃点心方面也算是老饕了，这种水平的生煎和我以前很爱吃的一家烤饺店基本平分秋色。

**都说大理是一座充满文艺气息的古城。** 电影《心花路放》我非常喜欢，看了不下五遍。这回真正来逛了夜晚的大理古城，古城墙、酒吧、创意街、满街的游客都差点意思。

但唯独对这盘生煎感触颇多，摊主小哥比较健谈，临近收摊在给我们煎最后一份生煎和他自己的晚餐，不断的给我们吹嘘他的生煎多好吃，期间还不忘给邻居摊主尝了一个。我和我女友比较社恐，基本上在“嗯嗯”、“嗷嗷”并且报以僵硬的微笑。

但看了他的言行，吃了他的料理，那种放松的心态真切地传染了给我，仿佛此时此刻来了大理真的可以短暂的从工作的神经质中解放出来，远离烦恼和喧嚣。

大理之行我个人的评价：

- **人文：★★★☆☆**
- **景观：★★★☆☆**

过度的不规范的商业化、巨大的客流量和不太走心的旅游建设都非常破坏大理的旅游体验。

就从食物上来举例子，我相信大理肯定有非常好吃的菌子、米线。但家家户户都打着地道美食的招牌时，游客基本无从辨别，最终只能从海量饭馆中随机选出来的选择作为判别大理食物是否美味的标准。

我们在古城里排队吃的米线真不如大学城食堂里的米线；在古城南门外吃的炒当季菌子不客气地说狗吃了都摇头。

但是瑕不掩瑜，洱海、古城都还是拿得出手的地方特色，有机会还会来玩的。

## 丽江

由于高铁实在是抢不到好的时间段，我们从大理到丽江已经是快晚上 8 点了。

订的民宿有非常贴心的接送服务，我们是在去民宿的车上开始抢玉龙雪山的索道票。大索道是晚上 8 点整开售，没有意外的是抢不到。所以退而求其次抢到了云杉坪索道的票（9 点整开售）。

旅游高峰期玉龙雪山是限流的，如果没有索道票是直接进不去景区大门的。所以云杉坪的票意义非凡，虽然不能登顶，但至少可以带我们初步领略一下雪山的巍峨。

我们买到的索道票是中午 12 点，所以吃了一顿丰盛的、慢节奏的早餐（来云南最丰盛的早餐！）

![[2024-yunnan-travelogue-attachments/20260110020010925.jpeg]]

事实上我们是可以提早去的，因为景区基本上不看票的时间段，并且排队上索道需要等 1 个多小时，当然这是后话了。

![[2024-yunnan-travelogue-attachments/20260110020025817.jpeg]]

我们住的地方离雪山已经非常近了，直接在门口附近打车就出发雪山啦。

在车上我们已经提前看到了雪山，非常壮观。可惜手机景深有限拍不出效果。

**玉龙雪山的主角就是玉龙雪山。** 其他比如云杉坪、牦牛坪、蓝月湖都是从不同的角度欣赏巍峨的雪山，本身的景点没有太多的意思。

![[2024-yunnan-travelogue-attachments/20260110020037576.jpeg]]

我们去的那天非常不凑巧，雪山顶笼罩着一片乌云，因此我们没能看到**日出金山**。不过当我们爬到云杉坪后，它很给面子的露出了山巅的峥嵘~

玉龙雪山给我的第一感受并不是“高山”，而是“大山”、“山脉”。

它不是突兀的立在那里，从全局看它融于连绵不绝的山脉地形，仿佛是地球宽厚的肩膀；但从下往上看，高中地理课本中的「**山地垂直自然带**」跃然而出：草坪带、针叶林带、灌木带，最后的冰冻带。一座山承载了温带到寒带的所有生态，如此我才后知后觉的感知到：我现在是在一个怎么样的“庞然大物”脚边。

![[2024-yunnan-travelogue-attachments/20260110020051505.png]]

身临其脚下，是一种无法言语的磅礴和神圣感，仿佛胸口郁结的浊气得以畅抒而出，最终化为一句感叹：“有机会再来玩”。

我们的丽江之行始于雪山也终于雪山，没有再多的时间去逛逛丽江古城等闹市。不过就我个人而言已经满载而归，无需更多。

其实前文也提到了我们在丽江订的民宿是比较贵的，相对的它的地理位置非常好，在房间里可以直接看到雪山景色。

![[2024-yunnan-travelogue-attachments/20260110020125405.jpeg]]

它门前的水库的风景也不错，我和我女友在那驻足看了好久的鸭子游泳。

丽江之行我个人的评价：

- **人文：★★★★☆**
- **景观：★★★★★**

我本身就是农村小伙，成长于山水之间，一般的山山水水之景是很难征服我的。但是横断山脉真的大大的震撼到我，苍山洱海地形已经玉珠在前，然后来到丽江领略了“一山又比一山高”的玉龙山。景观不给满分很难说得过去。

不过相对而丽江和大理都有相似的问题，交通方面对旅客的接待能力真的比较弱。比如说缴纳进山费和高速收费站似的，需要一辆一辆车停车人工验票。说句难听的现在高速都有 ETC 了，丽江还怎么原始，能不堵车才怪。

## 预算与开销

旅游主要的开销是在「住」和「行」上。而这两者会很大程度上的影响整体的行程安排和体验，所以预算与开销是最先要考虑的方面。

### 机票

杭州与云南之间往返我们选择乘坐飞机，原因是春节假期余额不足，不允许我们花太多时间在途中。

飞昆明会比直飞大理或者丽江要便宜不少。在 2 月份那会飞丽江单程要比飞昆明贵 500+ 元，两个人来回起码得多 2000+ 元开支。所以我选择了昆明。

- **杭州飞昆明：1500 元 * 2（海航）**
- **昆明飞杭州：2286 元 * 2（昆航）**

感觉机票还是很贵的，几乎要占总开销的 50%。

由于旅游行程是春节档避免不了要和春运冲突，我是提前两个月订的票，如果再早一些还能再便宜个 200+ 元（涨价的超快）。

### 火车票

火车票才是货真价实的和春运抢票，因为我们的返程时间点和春节返程时间点基本重合，这也是本次旅游规划的问题之一。

由于 12306 只能提前 15 天购票，所以一直迟迟没有敲定坐哪班动车，然后我就上班忙忘了，等到想起来了已经到了大年二十九，打开一看能选择的票已经非常少了，甚至回程只有站票。

- **昆明至大理：231 元 * 2（一等）**
- **大理至丽江：64 元 * 2（二等）**
- **丽江至大理：182 元 * 2（站票）**

### 酒店\民宿

酒店基本上是和机票在同一时间订的。在昆明是住的市中心的汉庭，定的早便宜了 200+ 元。大理和丽江因为想要靠近景区一些所以住的民宿。

- **昆明：558 元（汉庭\一晚）**
- **大理：429.02 元（民宿\一晚）**
- **丽江：4079.9 元（民宿\两晚）**

### 礼品\其他消费

出门旅游按惯例是要带一些特产送亲朋好友的，起初我还担心因为是乘坐飞机带货量有限，后来发现是我白担心了，现在的特产店都是可以邮寄的（嗯，产业链完善）。

因为我本人比较喜欢吃鲜花饼，所以我就买了很多鲜花饼 HHH…

- **特产：306 + 136 * 2 元（鲜花饼）**
- **景点门票：120 + 200 元（玉龙雪山云杉坪 + 进山费）**
- **其他：1820.86 + 663.96 元（公交地铁打车、吃吃喝喝）**

### 总计

总共开销大约是在 **16976** 元，其中大头还是机票和住宿，这也符合旺季旅游的基本规律。

![[2024-yunnan-travelogue-attachments/20260110022016789.png]]

## 总结

此次云南之行还是非常值得的，尤其的最后的丽江之行让人流连忘返。

比较失误的点是在有限的时间里安排了太多的城市，导致城市间的交通通行占据浪费了很多宝贵的时间。

云南有机会还会再去，下次可能就只游玩一到两个城市足以。类似的游记我还会继续更新，感觉很有意义（如果我有空的话）。还得养成旅行多做记录的习惯~

最后希望我们国家的休假制度能越来越好，让劳动人民从调休这个特殊时期的畸形产物中解放出来，也让各地的旅游业从极端淡旺季的现象中解放出来。相信看人头式旅游体验也好、竭泽而渔式旅游建设也好，这些现象都会有所改善。]]></content:encoded>
        </item>
    </channel>
</rss>