<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<atom:link href="https://suemor.com/feed" rel="self" type="application/rss+xml"/>
<title>SuemorのBlog</title>
<link>https://suemor.com</link>
<description>在探索的过程中遇见更好的自己</description>
<language>zh-CN</language>
<copyright>© Suemor </copyright>
<pubDate>Tue, 10 Mar 2026 05:34:48 GMT</pubDate>
<generator>Mix Space CMS (https://github.com/mx-space)</generator>
<docs>https://mx-space.js.org</docs>
<image>
    <url>https://y.suemor.com/suemor-avatar.jpeg</url>
    <title>SuemorのBlog</title>
    <link>https://suemor.com</link>
</image>
<item>
    <title>ArrayBuffer、TypedArray 和 DataView 在 MP4 Box 解析中的运用</title>
    <link>https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box</link>
    <pubDate>Sat, 21 Feb 2026 12:04:18 GMT</pubDate>
    <description>JavaScript 处理二进制数据的 API 主要有三种：ArrayBuffer、TypedArr</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box'>https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box</a></blockquote>
      <p>JavaScript 处理二进制数据的 API 主要有三种：<code>ArrayBuffer</code>、<code>TypedArray</code> 和 <code>DataView</code>。在 MP4 Box 的解析和处理过程中，这些工具非常有用。本文结合实际的 MP4 box 结构，聊聊它们各自的定位和取舍。</p>
<h2>三者的关系</h2>
<h3>ArrayBuffer</h3>
<p><strong>ArrayBuffer</strong> 是一块原始的、固定长度的二进制内存，你不能直接读写它，必须通过&quot;视图&quot;来操作。当你 fetch 一个 fMP4 文件后，对其解析会先转为 ArrayBuffer。</p>
<pre><code class="language-typescript">const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
console.log(buffer); </code></pre><p></p>
<h3>TypedArray</h3>
<p><strong>TypedArray</strong> 是一组类型化数组视图（Uint8Array、Uint16Array、Uint32Array、Float32Array 等），它把 ArrayBuffer 当作同构的数组来访问——所有元素类型相同、等宽排列。它只是在已有的 ArrayBuffer 上建立一个视图，本身不复制也不额外分配数据内存。</p>
<pre><code class="language-typescript">const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
const uint8 = new Uint8Array(buffer); // 创建视图，不会占用额外内存
console.log(uint8); </code></pre><p></p>
<h3>DataView</h3>
<p><strong>DataView</strong> 也是建立在 ArrayBuffer 上的视图，同样不复制数据。与 TypedArray 不同的是，它不把 buffer 当数组看，而是提供了一组方法让你在任意偏移位置、以任意类型和字节序来读写数据——没有对齐限制，也不要求字段类型统一。</p>
<pre><code class="language-typescript">const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
const view = new DataView(buffer); 
const size = view.getUint32(0);     
const type = String.fromCharCode(
  view.getUint8(4), view.getUint8(5),
  view.getUint8(6), view.getUint8(7)
);  
console.log(size, type); </code></pre><p></p>
<p>三者的关系可以这样理解：ArrayBuffer 是仓库，TypedArray 和 DataView 是两种不同的取货方式——前者像传送带，只能运同一规格的货物；后者像叉车，想取什么取什么。</p>
<h2>MP4 Box 的结构特点</h2>
<p>MP4 文件遵循 ISO 14496-12（ISOBMFF）规范，整个文件由嵌套的 box 组成。每个 box 的基本结构是：</p>
<pre><code class="language-">[4 bytes] size        (uint32)
[4 bytes] type        (4个ASCII字符)
[可选]    largesize   (uint64，当 size==1 时)
[可选]    version     (uint8) + flags (uint24)
[...]     payload     (各种混合类型字段)</code></pre><p>这里有几个关键特征：</p>
<ol>
<li><strong>字段类型混杂</strong>：一个 box 里 uint8、uint16、uint24、uint32、uint64 混着来，没法用单一的 TypedArray 类型映射整个 box</li>
<li><strong>字段偏移不对齐</strong>：比如 1 字节的 version 后面紧跟 3 字节的 flags，再接 uint32 字段——中间穿插了奇数长度的字段后，后续偏移很容易不是 2、4、8 的倍数，TypedArray 的对齐要求就满足不了</li>
<li><strong>大端字节序</strong>：MP4 规范要求所有多字节整数使用大端字节序，而 TypedArray 使用平台原生字节序（绝大多数设备是小端），直接读出来的值是反的</li>
</ol>
<p>这三条，直接决定了各 API 的适用程度。</p>
<h2>Uint8Array 数据搬运和字节级操作</h2>
<p>在 MP4 解析中，Uint8Array 是用得最多的 TypedArray。它没有对齐和字节序的问题——每个元素就是一个字节，强项是数据搬运（切片、拷贝、拼接）和逐字节扫描。</p>
<pre><code class="language-js">// 切出某个 box 的 payload
const boxPayload = new Uint8Array(buffer, boxOffset + 8, boxSize - 8);

// 拼接两段 segment 数据
const merged = new Uint8Array(a.length + b.length);
merged.set(a, 0);
merged.set(b, a.length);

// 逐字节匹配 box type，找到 mdat box 的位置
const bytes = new Uint8Array(buffer);
for (let i = 0; i &lt; bytes.length - 7; i++) {
  if (bytes[i+4] === 0x6D && bytes[i+5] === 0x64 &&
      bytes[i+6] === 0x61 && bytes[i+7] === 0x74) {
    console.log('mdat box at offset', i);
    break;
  }
}</code></pre><h2>Uint32Array 为什么在 MP4 解析中几乎没用</h2>
<p>直觉上，MP4 box 里到处是 uint32 字段，Uint32Array 应该很适合？实际上它在 MP4 解析里几乎没有用武之地，最核心的原因是它的字节序不对。</p>
<p>MP4 是大端，而 Uint32Array 使用平台原生字节序。绝大多数设备（x86、ARM）是小端，这意味着直接读出来的值是字节反转的。</p>
<p>举个例子，要把值 23 写入 buffer，按 MP4 大端格式应该是 <code>00 00 00 17</code>：</p>
<pre><code class="language-typescript">// DataView：直接写 23，指定大端
const view = new DataView(buf);
view.setUint32(0, 23, false);  // buffer: 00 00 00 17 ✓

// Uint32Array：写 23 会变成小端排列
const arr = new Uint32Array(buf);
arr[0] = 23;  // 小端机器上 buffer: 17 00 00 00 ✗

// 要得到正确的字节排列，你得手动翻转
arr[0] = 0x17000000;  // 即 23 × 2²⁴ = 385875968
// buffer: 00 00 00 17 ✓</code></pre><p>把一个简单的 23 转成 385875968 才能写入，这样就太复杂了。</p>
<h2>DataView 的灵活性</h2>
<p>DataView 完美解决了上述问题。它允许你在任意偏移位置，以任意类型和字节序来读写数据。</p>
<p>DataView 提供了 10 对 getter/setter：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>字节数</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>getInt8</code> / <code>getUint8</code></td>
<td>1</td>
<td>无字节序参数（单字节不需要）</td>
</tr>
<tr>
<td><code>getInt16</code> / <code>getUint16</code></td>
<td>2</td>
<td>第二参数控制字节序</td>
</tr>
<tr>
<td><code>getInt32</code> / <code>getUint32</code></td>
<td>4</td>
<td>第二参数控制字节序</td>
</tr>
<tr>
<td><code>getFloat32</code> / <code>getFloat64</code></td>
<td>4 / 8</td>
<td>IEEE 754 浮点数</td>
</tr>
<tr>
<td><code>getBigInt64</code> / <code>getBigUint64</code></td>
<td>8</td>
<td>返回 BigInt</td>
</tr>
</tbody></table>
<p>每个 getter 都有对应的 setter，setter 多一个 value 参数。所有多字节方法的最后一个参数 <code>littleEndian</code> 默认为 <code>false</code>（大端），恰好和 MP4 的字节序一致。</p>
<h2>总结</h2>
<table>
<thead>
<tr>
<th>API</th>
<th>MP4 解析中的角色</th>
<th>典型场景</th>
</tr>
</thead>
<tbody><tr>
<td>ArrayBuffer</td>
<td>底层数据容器</td>
<td>承载所有二进制数据</td>
</tr>
<tr>
<td>Uint8Array</td>
<td>高频工具</td>
<td>切片、拷贝、扫描、读 box type</td>
</tr>
<tr>
<td>Uint32Array 等</td>
<td>几乎不用</td>
<td>字节序/对齐/混合类型三重限制</td>
</tr>
<tr>
<td>DataView</td>
<td>核心解析工具</td>
<td>读写任意类型、任意偏移、可控字节序</td>
</tr>
</tbody></table>
<p>写 MP4 parser 时，一个简单的原则：<strong>搬运数据用 Uint8Array，解析字段用 DataView</strong>。</p>

      <p style='text-align: right'>
      <a href='https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69999f428f0c7b2c3ea176b0</guid>
  <category>posts</category>
<category>编程</category>
 </item>
  <item>
    <title>2025 - 从校园到沪漂</title>
    <link>https://suemor.com/notes/9</link>
    <pubDate>Tue, 30 Dec 2025 16:44:47 GMT</pubDate>
    <description>找实习

对于转本上岸的我来说，入学已经是大三，这意味着一个学期之后就要开始投递暑期实习，但当时的我</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/notes/9'>https://suemor.com/notes/9</a></blockquote>
      <h2>找实习</h2>
<p>对于转本上岸的我来说，入学已经是大三，这意味着一个学期之后就要开始投递暑期实习，但当时的我对此毫无概念，依旧享受着校园生活，以为时间还很充裕。</p>
<p>年初的时候一直在维护我的 <a href="https://github.com/marchen-dev/marchen-player">Marchen Player</a>和一个 AI 博客项目，期间虽也有想找实习的想法，但看了看噩梦般的八股和算法题，瞬间便打消了斗志。转眼间就到了五月份，当时看到 <a href="https://github.com/innei">innei</a> 群里 <a href="https://github.com/LinMoQC">林陌青川</a> 大佬在讨论实习的事情，才发现自己已经错失投递暑期实习的黄金时间，只剩下些捡漏的 HC。此时的我八股还完全没背，LeetCode 一题没刷，再加上转本的三本学历，可以说 debuff 叠满了。</p>
<p>要找实习，八股和算法是必须要面对的。对于八股我花了几天的时间，对着<a href="https://juejin.cn/post/7061588533214969892">掘金上这篇八股文</a>勉强背了些，算法则是在 LeetCode 上做了几道两数之和这类的超简单题。在此期间每天不断在 Boss 直聘和企业官网上投递简历，同时也刷着牛客，看着大家纷纷上岸，分享着满是黑话的面经，焦虑感直接拉满。</p>
<p>背了 4 天八股后，即 5 月 16 日，一家在武汉叫 CmsTop 的公司在 Boss 上给了我面试机会，这是我人生中第一次面试，自然十分紧张，说话结结巴巴的，但好在顺利通过了。</p>
<p>5 月 19 日，面试了苏州星海图，是一家搞具身智能的公司，开局要求手撕两道算法题，一题也写不出来，直接挂了。</p>
<p>5 月 26 日，面试了来未来熙牛医疗，这次问的比较简单，都是些 React 的八股，15 分钟速通了。</p>
<p>5 月 22 日，哔哩哔哩给我了面试机会，从面试官得知是前端的日常实习。一面算法题被要求写「链表内指定区间反转」，我算法就两数之和水平，这自然是写不出来的。此时感觉多半凉了，但隔天竟收到二面的通知。</p>
<p>5 月 26 日，进行了哔哩哔哩二面，二面全程八股拷问，回答的中规中矩。面试结束后也是厚着脸皮给 HR 发微信，询问面试结果，以及后续是否有三面。没想到当天下午 HR 便通知我面试通过了，要求下周入职，晚上直接发了意向书。</p>
<p><a href="https://www.suemor.com/notes/7">https://www.suemor.com/notes/7</a></p>
<h2>沪漂</h2>
<p>前几天还在校园里上课，下周就要去上海了。</p>
<p>说实话，心情挺复杂的。之前看过 innei 大佬博文里写的沪漂经历，那些关于租房、通勤、独自生活的描述，让我既期待又有点担心。</p>
<p>和 HR 约定好是 6 月 3 日入职，考虑到上海的住宿费用比较高，我提前一天过来看了几家公寓房。上海的房租确实贵得离谱，公司附近的单间基本都得三四千，对于一个实习生来说压力不小。跑了一整天，最终定在了外高桥的一间公寓房——月租 2k，押一付一，通勤大概 40 分钟，勉强能接受。</p>
<p>入职当天先是新人培训，发了入职指南和一个小电视抱枕，讲了公司的基本情况和规章制度，最后领了台 Mac mini 2018 作为工作机。</p>
<p>就这样，沪漂生活正式开始了。</p>
<h2>实习后记</h2>
<p>实习期间发现自己对于前端音视频挺感兴趣的，开始逐步了解 HLS、fMP4、MSE 这些概念。以前觉得视频播放就是一个 video 标签的事，真正接触后才发现里面水很深的。</p>
<p>最近自己在写一个 HLS 播放器的库，期间发现部分 HLS 流的 fMP4 分片时间戳不是从 0 开始的，导致播放器出现卡死情况。于是硬着头皮去研究 MP4 的 Box 结构，最终成功写了一个处理 MP4 封装的模块来修正时间戳。</p>
<p><a href="https://www.suemor.com/posts/programming/tfdt-decode-time">https://www.suemor.com/posts/programming/tfdt-decode-time</a></p>
<p>转眼间实习已经过去几个月了。虽然过程有些曲折，但还是收到了转正答辩的通知。作为一个社恐的 I 人，表达和汇报一直是我的弱项。答辩的时候基本就是对着文档念了一遍，问答环节也是结结巴巴的，回答得很一般。好在最后还是顺利通过了，也算是给这段实习画上了一个还不错的句号。</p>
<h2>电子产品</h2>
<p>又到了每年的败家时刻。</p>
<h3>Surface Pro 9</h3>
<p>去年把 Surface Pro 7 挂闲鱼卖掉后，心里一直痒痒的，总觉得缺了点什么。今年终于没忍住，花 4k 多收了台二手 Surface Pro 9，i5-1245U + 16GB 配置，算是填上了这个坑。</p>
<h3>M4 Mac mini</h3>
<p>公司配的 Intel Mac mini 2018 实在太卡了，VS Code 动不动就无响应，代码补全更是完全出不来。看到 pdd 上全新 M4 Mac mini 只要 2.9k，还是没忍住直接冲了。</p>
<h3>KTC 5K 显示器</h3>
<p>在公寓里没有外接显示器确实不太方便。本想入手台 5K 显示器，但看着 Studio Display 那可望不可即的价格，还是算了。最后趁某东国补，1.8k 拿下了台 KTC 27 英寸 5K 显示器。实际用下来只能说一般，拖影比较明显。不过考虑到价格，以及支持 5K 和 2K 120Hz 双模切换，性价比还是有的。</p>
<h2>关于明年</h2>
<p>回顾往年的年终总结，那些信誓旦旦立下的 flag，最后基本都没能完成。今年也不例外。</p>
<p>工作之后愈发感觉时间不够用了，每天下班回来已经没什么精力折腾自己的项目，Github 提交也基本一片空白。再加上这一年 AI 发展得太快了，光是 Claude 生态就冒出一堆新概念：plugin、command、agent、MCP、hook、skills……看着这些层出不穷的名词，很难不焦虑，总担心自己会被时代甩在后面。</p>
<p>不过焦虑归焦虑，日子还是要过的。新的一年，希望自己能在音视频方向继续深入，把播放器的坑填完，同时也多关注下 AI 这边的动态，找机会做些结合 AI 的项目，至少别让自己掉队太远吧。</p>

      <p style='text-align: right'>
      <a href='https://suemor.com/notes/9#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">6954017f868dcd1e9d6bc3de</guid>
  <category>notes</category>
false
 </item>
  <item>
    <title>记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复</title>
    <link>https://suemor.com/posts/programming/tfdt-decode-time</link>
    <pubDate>Sun, 28 Dec 2025 16:46:13 GMT</pubDate>
    <description>最近尝试接触前端音视频领域。在开发 HLS（fMP4）播放器的过程中，遇到一个比较棘手的问题： 部分</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/posts/programming/tfdt-decode-time'>https://suemor.com/posts/programming/tfdt-decode-time</a></blockquote>
      <p>最近尝试接触前端音视频领域。在开发 HLS（fMP4）播放器的过程中，遇到一个比较棘手的问题： 部分 fmp4 分片的 <code>tfdt.baseMediaDecodeTime</code> 并不是从 0 开始连续递增。在直接通过 mediaSource 播放时，容易出现时间轴错位，甚至播放器卡死的情况。这篇文章记录一下修复过程。</p>
<h2>踩坑过程</h2>
<p>今天在写 fmp4 player 的时候发现部分 M3U8 播放不出来，这边用 ffprobe 看了下发现它的 start 比较诡异：</p>
<pre><code class="language-bash">ffprobe /Users/suemor/Downloads/xxxx.m4s 2&gt;&1 | grep Duration
Duration: 00:00:08.35, start: 4.189002, bitrate: 2071 kb/s</code></pre><p>可以看到 start 直接从第 4 秒开始了，这会导致浏览器 mediaSource 播放出现问题。</p>
<p>开始想了一个比较 hack 方式，但这有些问题，当用户 seek 到 0-4 秒之间会被强制 seek 到第 4 秒，体验上欠妥。</p>
<pre><code class="language-typescript">  const { buffered } = state.sourceBuffer
  const { currentTime } = state.media
  const GAP_TOLERANCE = 0.5 // 容忍 0.5s 的误差
  const JUMP_OFFSET = 0.1 // 跳入 Buffer 内部 0.1s 以确保安全
  if (buffered.length &gt; 0) {
    const bufferStart = buffered.start(0)
    if (
      currentTime &lt; bufferStart &&
      bufferStart - currentTime &gt; GAP_TOLERANCE
    ) {
      state.media.currentTime = bufferStart + JUMP_OFFSET
      return
    }
  }</code></pre><p>为了解决这个问题，我打算在播放链路中引入了一个简单的 transmuxer，在解封装阶段对 <code>baseMediaDecodeTime</code> 进行修正。</p>
<h2>Box 结构</h2>
<p>要开发一个 transmuxer 需要先了解下目前手里这个 fmp4 box 结构：</p>
<pre><code class="language-basic">[File] (3s Segment)
 ├── [ftyp]
 ├── [moov]
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -&gt; 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -&gt; 需要修正!
 ├── [mdat] (Media Data 1)
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 ├── [mdat] (Media Data 2)
 └── ... (重复多次)</code></pre><p>我查阅资料后发现：<code>TFDT</code> Box 里的 <code>baseMediaDecodeTime</code> 决定了这个片段的<strong>绝对解码时间</strong>。</p>
<p>后续需要读取整个流中<strong>第一个</strong> <code>moof</code> 的 <code>baseMediaDecodeTime</code> 作为基准偏移量，对于分片内的<strong>每一个</strong> <code>moof</code>（不仅仅是第一个），都执行 <code>当前时间 - Offset</code>，强行把时间轴整体“平移”回 0 起点。</p>
<h2>Box 解析器</h2>
<p>要修改二进制数据，首先得能读懂它。JavaScript 的 <code>DataView</code> 是处理二进制数据的神器，它允许我们直接操作内存并控制大端序。</p>
<p>下面实现了一个极简的 <code>MP4Parser</code> 类。它的核心逻辑是：读取 Box 的 Header（大小和类型），并提供访问内容的视图。</p>
<pre><code class="language-typescript">// mp4-parser.ts
export type BufferLike = ArrayBuffer | SharedArrayBuffer

export class MP4Parser {
  private readonly buffer: BufferLike
  private readonly fileEnd: number

  public readonly offset: number
  public readonly size: number
  public readonly type: number
  public readonly headerSize: number

  constructor(buffer: BufferLike, offset: number, fileEnd?: number) {
    this.buffer = buffer
    this.offset = offset
    this.fileEnd = fileEnd ?? buffer.byteLength
    
    // 读取前 16 字节来解析 Header
    const view = new DataView(
      buffer,
      offset,
      Math.min(16, this.fileEnd - offset),
    )
    
    const size32 = view.getUint32(0)
    this.type = view.getUint32(4) // Box 类型

    // 处理 size 的不同情况 (标准 MP4 协议)
    if (size32 === 1) {
      // size 为 1 表示这是个 Large Box，真实大小在后面 8 字节
      this.size = Number(view.getBigUint64(8))
      this.headerSize = 16
    } else if (size32 === 0) {
      // size 为 0 表示一直到文件末尾
      this.size = this.fileEnd - offset
      this.headerSize = 8
    } else {
      this.size = size32
      this.headerSize = 8
    }
  }

/**
   * 获取 Box 的内容部分（不包含 Header）
   * 返回 DataView，适合读取内部的具体数值（如 TrackID, TimeStamp）
   */
  public getContentDataView(): DataView {
    return new DataView(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }

	/**
   * 获取 Box 的内容部分（不包含 Header）
   * 返回 Uint8Array，适合作为下一次 findBoxes 的输入，或者进行字节级操作
   */
  public getContentView(): Uint8Array {
    return new Uint8Array(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }
}</code></pre><h2>查找 Box</h2>
<p>有了解析器，我们还需要开发一个快速查找指定 Box 的工具。</p>
<p>在下方 <code>BoxUtils</code> 中，实现了一个 <code>findBoxes</code> 函数。为了性能最大化，它采用了<strong>跳跃式遍历</strong>：读取一个 Box 的 header 拿到 <code>size</code>，如果不匹配，直接跳过 <code>size</code> 长度的字节，而不是逐字节扫描。</p>
<pre><code class="language-typescript">// box-utils.ts
import { MP4Parser } from './mp4-parser'

// 辅助函数：统一转为 Uint8Array
function asU8(data: BytesLike): Uint8Array {
  return data instanceof Uint8Array ? data : new Uint8Array(data)
}

export const BoxUtils = {
  /**
   * 在给定的数据范围内查找指定类型的 Box
   */
  findBoxes(
    data: BytesLike,
    type: number,
    start = 0,
    end?: number,
  ): MP4Parser[] {
    const u8 = asU8(data)
    const buf = u8.buffer
    const base = u8.byteOffset
    const limit = end ?? u8.byteLength

    const boxes: MP4Parser[] = []
    let offset = start
    
    // 循环遍历，直到范围结束
    while (offset &lt; limit) {
      // 剩余数据不足 header 长度，退出
      if (offset + 8 &gt; limit) {
        break
      }
      
      const box = new MP4Parser(buf, base + offset, base + limit)
      const boxSize = box.size === 0 ? limit - offset : box.size

      // 异常检查
      if (boxSize &lt; box.headerSize || offset + boxSize &gt; limit) {
        break
      }

      // 找到目标 Box，加入结果列表
      if (box.type === type) {
        boxes.push(box)
      }
      
      // 关键：直接跳过整个 Box 的大小，进入下一个 Box
      offset += boxSize
    }
    return boxes
  },
}</code></pre><h2>完成修正</h2>
<p>我们先梳理接下来查找 tfdt box 的链路。</p>
<p>根据下方链路图片可以发现：我们需要对 moof -&gt; traf -&gt; tfhd -&gt; tfdt 进行解析。</p>
<pre><code class="language-bash">[File] (3s Segment)
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -&gt; 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -&gt; 需要修正!
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 └── ... (重复多次)
 
// 对应的 16 进制表示
MOOF: 0x6d6f6f66
TRAF: 0x74726166
TFHD: 0x74666864
TFDT: 0x74666474</code></pre><h3>解析 tfhd trackId</h3>
<p>对于 traf 的 trackId 它位于 tfhd box body 的第 4 个字节处，它占据 4 个字节。</p>
<pre><code class="language-bash">[TFHD Body Layout]
+---------+---------+-----------+
| Version |  Flags  | Track ID  | ...
+---------+---------+-----------+
|  1 byte | 3 bytes |  4 bytes  |
+---------+---------+-----------+
^                   ^
offset 0            offset 4 (读取位置)</code></pre><pre><code class="language-typescript">parseTfhdTrackId(tfhdBox: MP4Parser): number {
  const view = tfhdBox.getContentDataView()
  // 偏移量 4 = Version(1) + Flags(3)
  // 紧接着就是 TrackID (4 bytes)
  return view.getUint32(4)
},</code></pre><h3>解析 tfdt baseMediaDecodeTime</h3>
<p><code>tfdt</code> (Track Fragment Decode Time) 存储了该分片的绝对解码时间。这里有一个必须注意的<strong>版本兼容性</strong>问题：</p>
<ul>
<li><strong>Version 0</strong>：使用 32 位整数（<code>UInt32</code>）。</li>
<li><strong>Version 1</strong>：使用 64 位整数（<code>UInt64</code>）。</li>
</ul>
<p>视频时间一长，时间戳很容易超过 32 位整数的范围（约 42 亿），因此现代 HLS 流大多使用 Version 1。为了防止精度丢失，我在代码中统一将其转换为 JavaScript 的 <strong><code>BigInt</code></strong>。</p>
<pre><code class="language-typescript">parseTfdtBaseMediaDecodeTime(tfdtBox: MP4Parser): bigint {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0) // 读取第1个字节：version 用于判断是否 32 位溢出
  
  if (version === 0) {
    // Version 0: 32位，转为 BigInt 统一处理
    return BigInt(view.getUint32(4))
  }
  // Version 1: 64位，必须用 getBigUint64
  return view.getBigUint64(4)
},</code></pre><h3>修正 tfdt baseMediaDecodeTime</h3>
<p>同理，这里也需要注意 32 位溢出的问题，需要用 version 进行额外判断</p>
<pre><code class="language-typescript">updateTfdtBaseMediaDecodeTime(
  tfdtBox: MP4Parser,
  newBaseMediaDecodeTime: bigint,
) {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0)
  
  if (version === 0) {
    // 32位写入：需要将 BigInt 转回 Number
    view.setUint32(4, Number(newBaseMediaDecodeTime))
  } else {
    // 64位写入：直接写入 BigInt
    view.setBigUint64(4, newBaseMediaDecodeTime)
  }
},</code></pre><h3>调用</h3>
<p>最后，我们需要在主类中调用上述方法。由于我的流结构包含多个 <code>moof</code> 且每个 <code>moof</code> 包含多个 <code>traf</code>，代码采用了双层循环结构。</p>
<p>这里需要注意，我的 fmp4 包含两个 traf 分别对于视频和音频，他们的 decodeTime 并不是一致的，所以需要解析 tfhd box 获取当前的 track id，来区分当前 traf 属于哪个轨道，从而能够使用到对于轨道的 decodeTime。</p>
<pre><code class="language-typescript">// transmuxer.ts
import { BoxUtils } from './box-utils'

export class Transmuxer {
  private baseTimestampOffset: Record&lt;number, bigint&gt; = {} // TrackID -&gt; Offset
  private fmp4: Fmp4 

  constructor(fmp4: Fmp4) { this.fmp4 = fmp4 }

  processTimeOffset(data: ArrayBuffer, isFirstSegment: boolean) {
    // 第一层循环：遍历分片内所有的 MOOF
    BoxUtils.findBoxes(data, BoxUtils.types.MOOF).forEach((moofBoxes) =&gt; {
      
      const trafBoxes = BoxUtils.findBoxes(moofBoxes.getContentView(), BoxUtils.types.TRAF)
      
      // 第二层循环：遍历 MOOF 内所有的 TRAF (通常是 Video 和 Audio)
      for (const trafBox of trafBoxes) {
        const trafContent = trafBox.getContentView()
        
        // 1. 获取 Track ID (音频/视频分开处理)
        const tfhdBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFHD)
        if (tfhdBoxes.length === 0) continue
        const trackId = BoxUtils.parseTfhdTrackId(tfhdBoxes[0])

        // 2. 找到 TFDT 读取时间
        const tfdtBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFDT)
        if (tfdtBoxes.length === 0) continue
        const tfdtBox = tfdtBoxes[0]
        
        // 使用 BigInt 读取，防止大整数溢出
        const baseMediaDecodeTime = BoxUtils.parseTfdtBaseMediaDecodeTime(tfdtBox)
          
        // 3. 仅在全流的第一个分片记录基准 Offset
        if (isFirstSegment && this.baseTimestampOffset[trackId] === undefined) {
          this.fmp4.logger.log(`[Transmuxer] Track ${trackId} set offset: ${baseMediaDecodeTime}`)
          this.baseTimestampOffset[trackId] = baseMediaDecodeTime
        }

        // 4. 修正时间：当前时间 - Offset
        const offset = this.baseTimestampOffset[trackId] ?? 0n
        const newBaseMediaDecodeTime = baseMediaDecodeTime - offset
        
        if (newBaseMediaDecodeTime &lt; 0n) continue 
        
        // 5. 原地修改 Buffer
        BoxUtils.updateTfdtBaseMediaDecodeTime(tfdtBox, newBaseMediaDecodeTime)
      }
    })
    return data
  }
}</code></pre>
      <p style='text-align: right'>
      <a href='https://suemor.com/posts/programming/tfdt-decode-time#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">69515ed5eea45dff9c8c4fec</guid>
  <category>posts</category>
<category>编程</category>
 </item>
  <item>
    <title>B 站实习的第一周</title>
    <link>https://suemor.com/notes/8</link>
    <pubDate>Sun, 08 Jun 2025 18:37:54 GMT</pubDate>
    <description>入职当天先是进行新人培训，工作地点在国正中心 3 号楼，领了台 Mac mini 2018（用起来发</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/notes/8'>https://suemor.com/notes/8</a></blockquote>
      <p>入职当天先是进行新人培训，工作地点在国正中心 3 号楼，领了台 Mac mini 2018（用起来发热量巨大）。</p>
<p>组里主要负责直播中心那块的维护，基本都是用 Vue 2 写的屎山，一个 App.vue 2000 行的那种。</p>
<p>这周主要阅读文档为主，之后 leader 叫我修个 Bug 并把项目给上线，当时慌得一批，自己对开发流程还不太清楚，涉及到多种环境，对应的文档也是年久失修，最后好在没出什么问题，也算是体验一次大厂项目的上线流程。</p>
<p>至于住房，选择了浦东那边一个 2k 的公寓，租了一个月，押一付一，通勤 40 分钟左右，勉强还能接受，先住一个月看看情况，之后再做打算。</p>

      <p style='text-align: right'>
      <a href='https://suemor.com/notes/8#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">6845d88238a18020bbd1b296</guid>
  <category>notes</category>
false
 </item>
  <item>
    <title>最近找实习的经历</title>
    <link>https://suemor.com/notes/7</link>
    <pubDate>Sat, 07 Jun 2025 13:55:05 GMT</pubDate>
    <description>转本上岸之后基本天天写些自己的小玩具，一下子就到了 5 月中旬，才发现自己暑期实习还没有找，简单调研</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/notes/7'>https://suemor.com/notes/7</a></blockquote>
      <p>转本上岸之后基本天天写些自己的小玩具，一下子就到了 5 月中旬，才发现自己暑期实习还没有找，简单调研后发现很多公司部门已经没有 HC 了，此时自己 LeetCode 基本为 0，八股也完全没背过，加上三本学历，boss 上投递自然没人鸟。</p>
<h2>武汉公司</h2>
<p>背了 4 天八股后，即 5 月 16 日，终于有一家武汉的公司给了我面试机会，同时也是我第一次参加面试。</p>
<p>面试期间全程社恐紧张，说话也是各种结巴，面经如下：</p>
<h3>问八股</h3>
<ol>
<li>先做一个自我介绍</li>
<li>你写样式的时候，比如你写css的样式的时候，用过哪些像素单位，他们有什么区别？</li>
<li>为什么要有 Less 和 Sass，他们是干什么用的，为什么 Less 不够还要有 Sass?</li>
<li>把一个 div 从 a 点拖到 b 点，比如 a(50,50) 拖动到 b(100,100) ，如何用原生 JavaScript 实现？</li>
<li>那你原生鼠标事件知道哪些？</li>
<li>CSS 你平常用过哪些布局？他们有什么区别？</li>
<li>用过 float 布局吗，干什么用的？为什么之前很多人用 float ，现在却没有人用了？</li>
<li>写 JavaScript 的时候，肯定经常去拷贝一些数据是吧？平常用过哪些拷贝？</li>
<li>你讲一下什么叫深拷贝，什么叫浅拷贝？</li>
<li>用什么方式可以实现这个浅拷贝，达到这个浅拷贝的目的？深拷贝呢，用什么方法手段达到深拷贝的目的？</li>
<li>浏览器里面有个概念叫做重排重绘，什么叫重排重绘</li>
<li>平常在去写功能的时候，肯定可能会用到一些请求是吧？但是有可能我们会遇到跨域，什么叫跨域？为什么会遇到跨域错误？如何解决？</li>
</ol>
<h3>问项目</h3>
<ol start="13">
<li>可以简单介绍一下你 Electron 开发的播放器吗？</li>
<li>你刚才有提到说支持自动更新，我看了一下代码好像没有找到相关的，你是用的 Electron builder 原生自带实现的吗？</li>
<li>Mac 里面的签名和公证的区别是什么</li>
<li>公证的应用和没公证的应用，在 Mac 上打开的时候会发生什么</li>
<li>这个 Electron 项目是自学还是有参考的？大概做了多长时间？</li>
<li>Web 和 Electron 开发你更喜欢哪一个？</li>
<li>我看你还有个博客项目，然后里面说集成的AI辅助写作主要是指什么呢？</li>
<li>跟大模型对接用的什么库？</li>
<li>vercel ai 里面 useChat 和 useCompletion 的区别</li>
<li>我如果希望你对 vercel ai 做一个二次开发，把聊天记录的功能直接原生集成在库里面，你会怎么去做？</li>
</ol>
<p>最后也是顺利通过一面了，二面是老板面，跟他讲了下平时如何使用 AI 工具和大模型的，大概讲了 Grok, Gemini, Claude, ChatGPT 平时是如何使用，个人感觉的优劣那些。</p>
<p>大概隔了两天，也是顺利拿下了 offer</p>
<h2>苏州公司</h2>
<p>大概背了一周八股后，即 5 月 19 日，一家苏州的 AI 公司给了我一次面试机会。</p>
<p>这次面试开局就是两道算法题，<strong>反转链表</strong>和<strong>括号匹配</strong>。由于自己没有刷过 LeetCode 自然是写不出来的，可以说面试场面非常尴尬。</p>
<p>之后又问了些八股，这次回答的还不错，基本都回答上来了。但由于算法题没写出来的缘故，也只能止于一面了。</p>
<h2>哔哩哔哩</h2>
<p>之前也通过官网投递了 20 多家公司，在 5 月 21 日哔哩哔哩给我安排了隔天的一面，当时看到面试安排挺激动的，直接翘掉了当天全部的课，用来复习八股和刷了 10 道 easy 算法题。</p>
<p>第一次体验大厂面试，也看了些牛客上的各种面经，害怕会被要求撕各种算法题，晚上焦虑的更是睡不着觉。</p>
<h3>一面</h3>
<p>面试官交代了面试的是直播部门，面经如下：</p>
<h4>1. 自我介绍</h4>
<h4>2. 做项目的背景是什么，有没有用户量？</h4>
<p>我自己比较喜欢一边看动漫，一边有着弹幕，但在 macos 没有类似本地视频弹幕播放器，所以我会去做一个这个</p>
<p>用户目前有几十个，他们有时候也会给我发起一些 Github Issues，我也会及时推进并改善 BUG</p>
<h4>3. 你的播放器项目是否支持在线播放？</h4>
<p>目前并不支持，我是有这个支持打算的，目前打算做一个媒体库功能</p>
<h4>4. 如果做这种本地的话，需要用户去自己去下载操作，会不会增加一些用户的心智？</h4>
<p>因为我自己看动漫的习惯还是下载到本地，这样码率会高很多，画面会更加清晰。并且有一套类似自动化的追番流程，有新番更新就会自动下载的那种。</p>
<h4>5. 如果做让你把这款项目作为一个商业化的产品，你会怎么去改造它呢？可以简单的跟我说说吗？</h4>
<ul>
<li>可以尝试接入大模型，为字幕文本提供翻译服务，或者可以利用 AI 语言识别来生成字幕（openai whisper），方便用户看生肉动漫</li>
<li>提供 AI 视频总结</li>
<li>支持挂载网盘，或者类似 emby 流媒体服务器</li>
<li>支持数据云同步，云端保存用户设置，视频进度等</li>
</ul>
<h4>6. 还有你觉得有什么功能可以去扩展？</h4>
<p>增加媒体库功能，支持对动漫进行元数据刮削</p>
<h4>7. 项目里面的一些最主要的一些难点</h4>
<p>chromium 在 mac windows linux 上硬件解码依赖用户电脑配置，chromium 自身的 ffmpeg 因为版权问题，软解缺乏对于 h265 的支持，对于不支持 h265 硬解的用户，会出现黑屏问题。所以要考虑降级到 wasm 软解，似乎哔哩哔哩也有这方面的实践（DashPlayer + WasmPlayer）</p>
<p>再提一些 linux 上硬件解码的坑，要打开实验 flag</p>
<p>firefox 不支持 mkv 视频容器，今年 1 月对 h265 硬件解码支持</p>
<h4>8. 有没有看过 FFmpeg 源码，知道哪些核心 API ?</h4>
<p>有简单了解过，它编解码的核心是 libavcodec，用于对音视频的编码和解码，就是 H.265 H.264 AAC</p>
<h4>9. 弹幕是如何进行缓存的？</h4>
<p>使用的是 IndexedDB（Dexie.js）进行缓存的，加载的时候把数据注入到 tanstack query 里面。因为项目目前是提供 web linux windows macos 四个版本，使用 IndexedDB 可以确保代码的一致性，减少不同平台之间的 bug。</p>
<p>同时 tanstack query 本身也具备的缓存机制，可以利用其 staleTime 和 gcTime 实现一些临时的缓存效果</p>
<h4>10. 简繁体转换是如何实现的？</h4>
<p>利用 opencc-js 实现</p>
<h4>11. ass/ssa 字幕功能是用什么实现的</h4>
<p>libass-wasm 实现的，它是一个用 c 语言编写解析 ASS/SSA 的库， 它是利用 canvas 把字幕画上去的，从而实现 ASS/SSA 字幕复杂样式和动画效果</p>
<h4>12. 弹幕功能是如何实现的？</h4>
<p>利用的是 danmu.js 实现的，它是利用 DOM 方式实现的，有碰撞算法，利用 requestAnimationFrame 来实现弹幕平滑滚动，它是根据用户设备刷新率来执行的，其中弹幕轨道可以根据<strong>播放器的有效高度/设备标准字号</strong>实现</p>
<h4>13. 如果满屏弹幕的情况下，比如说同时有数千条弹幕的情况下，你怎么去保证它的实时渲染不会卡顿？</h4>
<ul>
<li>利用 轨道数量 = 播放器有效高度 / 设备基准字号，单一屏幕是拥有最大弹幕限制的，这种算法不会出现满屏数千条弹幕</li>
<li>DOM节点复用</li>
<li>如果需要渲染数千条弹幕，可以利用 requestAnimationFrame 来驱动弹幕的移动，确保弹幕滚动与浏览器刷新率同步</li>
<li>利用 GPU 加速 transform ，减少重排和重绘</li>
<li>利用 Web Worker 进行复杂任务</li>
</ul>
<h4>14. 弹幕时间轴自动对齐怎么做，弹幕拖进来如果和本地视频时间对不上，该如何处理？</h4>
<p>用户可以单独设置每个弹幕的时间轴，来解决</p>
<p>后续或许可以考虑借用大模型辅助对齐</p>
<h4>15. 你是如何使用 ffmpeg 或者 ffprobe 实现视频关键帧提取的？</h4>
<p>目前历史记录的封面就是利用 ffmpeg 截取用户最后观看位置的图片，利用 -ss  定位时间点，-vframes 确保只提取一帧图像</p>
<h4>16. Electron builder 打包的时候，你是如何得知当前是什么平台的？</h4>
<p>打包的时候会执行 electron-builder --win 那些 flag，等于告诉 electron 当前是什么平台了，如果后续钩子需要，可以通过 context 获取出来</p>
<p>或者 nodejs os 模块，可以通过 os.platform() 获取平台</p>
<h4>17. Electron 项目初次启动时间是多久，后续的启动时间是多久？有没有算过？</h4>
<p>后续启动会更快一点，可能操作系统会有些缓存</p>
<h4>18. 播放器渲染过程有没有性能问题，比如说内存泄露，你是怎么去防止和监控的？</h4>
<p>在组件卸载的时候，我会及时清理 useEffect 里面的副作用，把它放到 useEffect 清理函数里面</p>
<p>react 的话可以利用 react devTools，观察组件的重新渲染变化，也可以使用浏览器控制台里面 performance tab 进行性能录制，从而分析出原因</p>
<h4>19. 弹幕如果要支持直播流的话，项目要进行哪些改造？</h4>
<p>直接弹幕通常是使用 WebSocket 进行与弹幕服务器双向通信的，在项目中引入 Websocket 客户端逻辑，即可</p>
<h4>20. 你对 Websocket 的理解？</h4>
<ul>
<li>TCP 全双工通信的协议</li>
<li>可以持久化连接</li>
<li>支持双向通信</li>
<li>低延迟</li>
<li>用的 ws:// 或者 wss://</li>
<li>适合直播弹幕，即时通讯软件</li>
</ul>
<h4>21. 项目有没有遇到过崩溃的例子，你是如何进行监控的？</h4>
<p>利用 sentry 和 electron-log 日志处理</p>
<h4>22. 除了 sentry 你还知道哪一个性能监控跟错误监控的系统？</h4>
<p>不知道</p>
<h4>23. 你是二次元所以才想做这个项目的吗？</h4>
<p>是的</p>
<h4>24. 你实习主要做的都是什么吗？以及你觉得实习让你收获了什么？</h4>
<p>说实习经历</p>
<h4>25. 我看你技术栈是 React 多，但我们这边都是 Vue 的，如果要上手 Vue，你会怎么做？</h4>
<p>他们都是现代前端的框架，很多思想都是相同的，比如组件化架构、虚拟 DOM、响应式数据绑定，并且都是基于 JavaScript。而且我之前也用过 Vue 开发一些后台管理系统和浏览器起始页，我相信我可以在几天之内，通过阅读官方文档快速上手 Vue 的</p>
<h4>26. 你是如何使用 monorepo 管理项目的？</h4>
<p>monorepo 一般会分为 app 和 packages 两个文件夹，我的项目....</p>
<h4>27. 有没有 UI 类组件的封装经验？</h4>
<p>比如我博客的 Markdown 双栏编辑器，左侧是封装的 codemirror 实现代码高亮，右侧是用 markdown-to-jsx 写的 markdown 渲染组件。左侧修改 markdown ，几乎可以无延迟的在右侧渲染出来。这里我使用到了 useDeferredValue，它是用到了 react 18 的并发特性，可以实现根据当前渲染压力，来动态实现一个防抖的效果，让用户基本感受不到渲染延迟。</p>
<h4>28. 性能优化的常见方式？</h4>
<p>减少重排和重绘，图片、组件懒加载，使用防抖和节流函数</p>
<h4>29. cdn 上你会托管哪些资源？</h4>
<p>CSS JS 图片 .m4s 切片</p>
<h4>30. html 为什么一般不用 cdn 托管？</h4>
<p>html 也可以放在 cdn 上托管，但很多网站 HTML 是动态生成的，会有延迟问题，所以一般不用 cdn 托管</p>
<h4>31. webpack 如何进行打包优化？</h4>
<ul>
<li>代码压缩，图片压缩</li>
<li>Tree shaking</li>
<li>代码分割</li>
<li>缓存优化，利用 chunkhash</li>
</ul>
<h4>32. 浏览器从输入到显示页面的全过程</h4>
<p>八股</p>
<h4>33. 重排和重绘发生的过程是什么？会不会对页面性能有一些影响？</h4>
<p>八股</p>
<h4>34. 重排和重绘的优化？</h4>
<p>八股</p>
<h4>35. 谈谈你对宏任务跟微任务理解？</h4>
<p>八股</p>
<h4>36. 事件循环题目，写出运行结果和 promise 状态</h4>
<p>大概长下方这样</p>
<pre><code class="language-javascript">const promise1 = Promise.resolve().then(() =&gt; {
  setTimeout(() =&gt; {
    console.log("111", promise2);
  }, 1000);
});

const promise2 = Promise.resolve().then(() =&gt; {
  throw new Error();
});

console.log("111", promise1);
console.log("111", promise2);

setTimeout(() =&gt; {
  console.log("111", promise1);
  Promise.resolve().then(() =&gt; {
    console.log("111", promise2);
  });
}, 3000);</code></pre><ul>
<li>111 Promise { &lt; pending &gt; }</li>
<li>111 Promise { &lt; pending &gt; }</li>
<li>111 Promise { &lt; rejected &gt;: Error }</li>
<li>111 Promise { &lt; fulfilled &gt;: undefined }</li>
<li>111 Promise { &lt; rejected &gt;: Error }</li>
</ul>
<p>这题挺麻烦的，还要写出 promise 状态。而且在 Node.js 环境下似乎会直接被 <code>  throw new Error()</code> 给中断掉，怪怪的。</p>
<h4>37. 算法题：链表内指定区间反转</h4>
<p>不会写，只写了个普通的反转链表，给面试官看傻眼了</p>
<h4>结果</h4>
<p>本以为一面要凉凉，没想到面试结束后 5 分钟就收到了 HR 电话，约了下周一的二面。</p>
<h3>二面</h3>
<p>这次八股问的多些，面经如下：</p>
<h4>问项目</h4>
<ol>
<li>项目支持发送弹幕功能吗？</li>
<li>项目有多少人在使用？他们是如何向你反馈问题的？</li>
<li>字幕功能是如何实现的？</li>
<li>错误日志是如何进行处理的？</li>
<li>历史记录里面的数据是如何进行存储的？动漫回看是如何实现的？</li>
<li>IndexedDB 最大能存储多少数据？不同域名下 IndexedDB 里面数据能够互相访问吗？</li>
</ol>
<h4>八股</h4>
<ol start="7">
<li>const let var 区别</li>
<li>Promise all allSettled race 用法</li>
<li>array object 有哪些常用方法？</li>
<li>React 常用 hooks 有哪些？</li>
<li>useEffect 用法</li>
<li>谈谈重排和理解定义，如何减少他们？</li>
<li>图片懒加载如何实现的？组件懒加载呢？</li>
<li>谈谈 HTTP 缓存</li>
<li>什么时候用强缓存，什么时候用协商缓存？</li>
<li>什么是跨域，跨域的解决方案，简单请求和复杂请求具体的区别</li>
<li>为什么 GET, POST 是简单请求，而 DELETE, PUT, PATCH 是复杂请求？</li>
<li>css 如何实现动画的？</li>
<li>css 有哪些方式脱离文档流？</li>
<li>bfc 是什么？</li>
<li>Framer Motion 这个动画库是如何实现的？</li>
<li>解构赋值是深拷贝还是浅拷贝?</li>
<li>箭头函数和普通函数的区别？</li>
<li>SSR 可以提高首屏加载速度吗？为什么？</li>
</ol>
<h4>结果</h4>
<p>这次问的还算简单，当天下午 HR 就联系我二面通过，因为是日常实习，没有 HR 面，当天给了 offer</p>
<h2>杭州公司</h2>
<p>5 月 26 日，杭州一家 500 人左右公司，给了我一面。</p>
<p>有了被大厂拷打经验之后，这次面试直接速通，基本都是秒答，面了 15 分钟就结束了，隔天 HR 面后，也是给了 offer</p>

      <p style='text-align: right'>
      <a href='https://suemor.com/notes/7#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">684444b938a18020bbd18ee5</guid>
  <category>notes</category>
false
 </item>
  <item>
    <title>理解 CORS、预检请求 (Preflight) 和跨域</title>
    <link>https://suemor.com/posts/programming/understand-cross-domain</link>
    <pubDate>Sat, 19 Apr 2025 17:46:40 GMT</pubDate>
    <description>这篇主要来聊一聊前端常见的跨域问题，以及后端如何处理 CORS 和预检请求 (Preflight)。</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/posts/programming/understand-cross-domain'>https://suemor.com/posts/programming/understand-cross-domain</a></blockquote>
      <p>这篇主要来聊一聊前端常见的跨域问题，以及后端如何处理 CORS 和预检请求 (Preflight)。</p>
<h2>浏览器在什么情况下会发生跨域</h2>
<p>浏览器通过“同源策略”限制不同源之间的资源交互，以保护用户隐私和安全。其中<strong>源</strong>由三个部分组成：<strong>协议</strong>、<strong>域名</strong> 和 <strong>端口</strong>。只有这三者同时满足才是<strong>同源</strong>。否则，就是<strong>跨域</strong>，向服务端发送请求时会触发浏览器的跨域限制，报以下错误：</p>
<pre><code class="language-text">Access to fetch at 'https://server.suemor.com/api/posts' from origin 'https://suemor.com' CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.</code></pre><p>具体看下方 4 个例子。</p>
<h3>跨域</h3>
<p>以下是三种跨域情况：</p>
<h4>不同协议</h4>
<pre><code class="language-http">https://suemor.com

http://suemor.com/api/posts</code></pre><h4>不同域名</h4>
<pre><code class="language-http">https://suemor.com

https://server.suemor.com/api/posts

//tips: 下方这个也是跨域
http://127.0.0.1:3000 -&gt; http://localhost:3000/api/posts</code></pre><h4>不同端口</h4>
<pre><code class="language-http">http://localhost:3000

http://localhost:5050/api/posts</code></pre><h4>同源</h4>
<p>下方这个是同源，没有跨域问题。</p>
<pre><code class="language-http">https://suemor.com

https://suemor.com/api/posts</code></pre><h2>解决跨域</h2>
<p>跨域问题通常在服务端解决，通过配置反向代理或修改后端代码。</p>
<p>跨域请求分为<strong>简单请求</strong>和<strong>复杂请求</strong>：</p>
<h3>简单请求</h3>
<p>对于同时满足以下三个条件的即为简单请求，服务器只需返回正确的 CORS 头，即 <strong>Access-Control-Allow-Origin</strong>：</p>
<ul>
<li><strong>请求方法</strong>：GET、POST 或 HEAD。</li>
<li><strong>Content-Type</strong>：限于 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。</li>
<li><strong>不包含自定义头</strong>：如 Authorization。</li>
</ul>
<p>看以下 Express 示例：</p>
<pre><code class="language-typescript">// 假设 web 端位于 http://localhost:3000
// 假设 server 端位于 http://localhost:5050

// web
fetch("http://localhost:5000/api/posts", { method: "GET" });

// server
app.use((req: Request, res: Response, next: NextFunction) =&gt; {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 或者  res.setHeader("Access-Control-Allow-Origin", "");

  next();
});</code></pre><h3>复杂请求</h3>
<p>符合以下任意一条，即为复杂请求：</p>
<ul>
<li>使用 PUT、DELETE 、PATCH 方法。</li>
<li>Content-Type: application/json。</li>
<li>包含自定义请求头（如 Authorization）。</li>
</ul>
<p>复杂请求比较特殊，浏览器会先发送一个 OPTIONS 方法的<strong>预检请求</strong>（Preflight），检查服务器是否允许该跨域请求。如果不允许，则直接抛出 CORS 错误，不再发送实际请求。</p>
<p></p>
<p>因此，我们需要单独处理这个<strong>预检请求</strong>（Preflight）：</p>
<pre><code class="language-typescript">// 假设 web 端位于 http://localhost:3000
// 假设 server 端位于 http://localhost:5050

// web
fetch("http://localhost:5050/api/data", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer token",
  },
  body: JSON.stringify({ data: "example" }),
});

// server
app.use((req: Request, res: Response, next: NextFunction) =&gt; {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
    res.header("Access-Control-Allow-Methods", "PUT");
    res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.status(200).end();
    return;
  }

  next();
});
</code></pre><h3>设置 Access-Control-Max-Age</h3>
<p>复杂请求在无缓存或缓存失效时会发送两次请求：Preflight（OPTIONS）和实际请求，这会增加网络开销。为此，服务器可以通过设置 Access-Control-Max-Age 响应头来控制浏览器缓存预检结果的时长。这个头字段的值表示缓存的有效期（以秒为单位）。在缓存有效期内，浏览器会复用之前的预检结果，跳过对相同接口的 Preflight 请求，从而提升性能。</p>
<pre><code class="language-typescript">res.header('Access-Control-Max-Age', '86400'); // 缓存 1 天</code></pre><h2>支持跨域 Cookie 的配置</h2>
<p>在跨域场景下，如果后端响应头返回 Set-Cookie，默认不会生效，因为设置 Cookie 需要额外配置以绕过浏览器的安全限制。核心是启用 Access-Control-Allow-Credentials 并明确指定 Access-Control-Allow-Origin。以下是一个 Express 示例：</p>
<pre><code class="language-typescript">app.use((req: Request, res: Response, next: NextFunction) =&gt; {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 一定要指定具体地址，不能为 *
  res.setHeader("Access-Control-Allow-Credentials", "true"); //添加这个
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");// 一定要指定具体地址，不能为 *
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "86400");
    res.status(200).end();
    return;
  }
  next();
});

app.post("/api/posts", (req: Request, res: Response) =&gt; {
  res.cookie("sessionId", "123456789", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "none",
    maxAge: 24 * 60 * 60 * 1000,
  });
  res.json({ success: true, message: "Cookie set" });
});</code></pre><p>注意这里 Access-Control-Allow-Origin 一定要指定具体的地址，不能设置为 <code>Access-Control-Allow-Origin: *</code>，否则 Cookie 无效。</p>
<p>前端如果使用 fetch 调用，则一定要加上 <code> credentials: &quot;include&quot;</code>否则无法设置 Cookie。如果是 axios 则加上 <code> withCredentials: true</code>。</p>
<pre><code class="language-typescript">//fetch
fetch("http://localhost:5050/api/posts", {
  method: "POST",
  credentials: "include", // 允许携带和接收 Cookie
}).then((res) =&gt; res.json());

//axios
axios({
  url: "http://localhost:5050/api/posts",
  method: "POST",
  withCredentials: true, // 允许携带和接收 Cookie
})
  .then((res) =&gt; res.data)
  .catch((err) =&gt; console.error(err));</code></pre>
      <p style='text-align: right'>
      <a href='https://suemor.com/posts/programming/understand-cross-domain#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">6803e180a6f8c9b4f446996b</guid>
  <category>posts</category>
<category>编程</category>
 </item>
  <item>
    <title>2024 年终总结</title>
    <link>https://suemor.com/notes/6</link>
    <pubDate>Wed, 08 Jan 2025 10:59:13 GMT</pubDate>
    <description>今年上半年经历在 迟来的 2023 年度总结基本讲过了，所以本文主要聚焦于 2024 后半年的事情。</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/notes/6'>https://suemor.com/notes/6</a></blockquote>
      <p>今年上半年经历在 <a href="https://www.suemor.com/notes/5">迟来的 2023 年度总结</a>基本讲过了，所以本文主要聚焦于 2024 后半年的事情。</p>
<p><a href="https://www.suemor.com/notes/5">https://www.suemor.com/notes/5</a></p>
<h2>第二次实习经历</h2>
<p>专转本结束后距离 9 月开学还有很长时间，毕竟闲着也是浪费时间，我准备找一个实习来提升一下自己的水平。遗憾的是经历了长时间的备考，之前掌握的很多技术都已记忆模糊，简单的温故一段时间后，<a href="https://github.com/MiaoMint">MiaoMint</a> 说他那边正好有个公司缺人，并且是远程工作的，于是我便欣然接受了。</p>
<p>我在 6 月初顺利入了职，这是一个 AI 初创公司，还处于起步阶段，我是公司里唯一的前端。老板挺热情的，和他沟通了下项目的具体规划后，我便主导了前端项目的开发。因为是前后端分离的，并且是远程工作，前后端及时沟通就显得尤其重要，起初我也是忽略了这一点，导致项目开发进度缓慢。于是我和后端们拉了一个 Discord 语音频道，沟通顺畅了不少，也是赶上了任务期限。</p>
<p>就这样持续到 10 月中旬，也算是挣到人生的第二桶金了。</p>
<h2>AdventureX 黑客松</h2>
<p>6 月中旬那会儿，MiaoMint 说他报名参加了一个黑客松， 7月15日-7月19日在杭州湖畔创研中心举办。我一开始没放在心上，毕竟有实习任务在身，但听说有不少推友参加，也想和 MiaoMint 面基一下，于是我也报名参加了。开始担心报名时间比较晚，会不会来不及，但好在运气比较好，没过几天就通过了筛选，收到了参赛邮件。</p>
<p>这应该是我人生中第一次独自出远门，所以有些紧张，直到和 MiaoMint 面基，Mint 人挺好的，和他聊得很来。他挺擅长社交的，托他的福，当天我们便组好了一个四人的队伍，取名为 Diet.AI，做一个分析饮食建议的 APP。我负责的是用 NestJS 后端部分的开发，整体没什么难度，就是调用下 Dify 的接口，MongoDB 存储一下数据。</p>
<p>最终得益于我们不错的 UI 设计，还是获得很多奖项的。</p>
<p>黑客松结束的时候也是得知 <a href="https://github.com/innei">Innei</a> 要来上海，于是我和 Mint 决定去上海面基 innei，和 innei 一起来的都是挺厉害的大佬，和他们交流之后，也是知道了自己有多菜 😭。</p>
<p><a href="https://x.com/Suemor233/status/1814620333443579960">https://x.com/Suemor233/status/1814620333443579960</a></p>
<h2>步入大学</h2>
<p>经历了漫长的假期之后，在 9 月 15 日的时候也是迎来了全新的大学生活，由于是转本班的缘故，我们班上一共有 60 个人，班主任是个男老师，对我们还是挺关心的，这点不错。</p>
<p>考虑到之前的经历，我没有选择住宿，而是在外租房走读，在泰州每月 1600 整租了一个 90 多平的房子。办了联通 20 元/月 1000M 的宽带，装宽带的小哥比较好说话，成功要到了公网 IP，配合 NAS 方便不少，顺便也跑了下 PCDN，基本每天 3.5 块收益。</p>
<p>至于课程安排，不出乎预料，都是一些水课，上课的时候我就坐在后排写一些自己的项目。</p>
<p>在此期间我主要写了 <a href="https://github.com/marchen-dev/marchen-player">Marchen Player</a>，这是一个<strong>可以自动匹配弹幕的动漫播放器</strong>。因为我个人看动漫比较喜欢有着弹幕，感觉这样热闹一些，虽然市面上也有<strong>弹弹play</strong>这种客户端，但 mac 版本一直缺乏官方支持，于是我便学习了下 Electron 开发，尝试写一个类似的播放器。得益于 Electron 浏览器套壳的特性，同时受到最近大火 <a href="https://github.com/RSSNext/follow">Follow</a> 的影响，我打算同时兼容 <code>Web macOS Windows Linux</code> 四个版本，其中兼容 Web 版本尤为烦人，受限于浏览器限制，直到至今 Web 版本也是一个残血的阉割版。</p>
<p><a href="https://github.com/marchen-dev/marchen-player">https://github.com/marchen-dev/marchen-player</a></p>
<h2>数码产品</h2>
<p>假期实习期间攒了一点小钱，一部分我买了些纳指和美股，另一部分也尝试更新了下数码产品。</p>
<h3>组装台式电脑</h3>
<p>8 月份的时候<strong>黑神话悟空</strong>热度很高，PS5 的二手价格涨的比全新价格还高，于是我便把 PS5 挂闲鱼给卖掉了，准备装一台台式电脑。</p>
<p>毕竟是我人生中第一次装机，但英特尔那边缩肛爆料不断，装机配置我一直挺纠结的，这个想法一直延续到了双十一，当时正逢<code>Intel Core Ultra 7 265K</code>和<code>Z890 主板</code>刚刚发售，虽然这 CPU 网上黑点挺多的，但考虑到不错的能耗控制和办公性能，以及后续 BIOS 更新的改善，我还是选择了这套组合。</p>
<p>具体配置如下，因为网上对于 Z890M 的装机配置基本没有，所以我自己搭配了一套，至于显卡等马上发售的 50 系了。</p>
<ul>
<li>CPU：Intel Core Ultra 7 265K </li>
<li>主板：技嘉冰雕 Z890M AORUS ELITE</li>
<li>GPU：核显 4 个 Xe-LPG 核心</li>
<li>内存：宏碁掠夺者冰刃 32G DDR5 8000</li>
<li>扇热：瓦尔基里 DL125 双塔风冷</li>
<li>硬盘：海力士 P41 1T + 希捷酷玩 520 1TB</li>
<li>电源：玄武 850k v2 白色</li>
<li>机箱：乔思伯 Z20</li>
<li>总花费：6638 元
</li>
</ul>
<h3>NAS</h3>
<p>NAS 作为男生的梦想，今年暑假的时候我入手了台丐版<strong>极空间 Z4Pro</strong>，虽然可玩性不如群辉，但更符合国内使用习惯，并且也有 Docker 和虚拟机功能，对我来说也足够了。</p>
<p>上了两块希捷的 4T 盘，组了 ZDR 动态双备份，搭配施耐德的 UPS，稳定运行了半年，目前没出什么问题。</p>
<p></p>
<h3>汇总</h3>
<ul>
<li>台式电脑</li>
<li>极空间 Z4Pro</li>
<li>京东京造 Z5 Soft</li>
<li>酷态科 15 号超级电能柱 Ultra</li>
<li>小米智能落地扇1X 升级版</li>
<li>米家卧室吸顶灯 450</li>
<li>施耐德 UPS BK650M2-CH</li>
<li>水星 SE106 Pro 2.5G 智能网管交换机</li>
<li>妙控鼠标</li>
<li>Apple TV 7 代 128GB</li>
</ul>
<h2>2025 年的目标</h2>
<ul>
<li>继续维护 Marchen Player</li>
<li>写一个博客主题</li>
<li>暑假期间找到线下实习</li>
<li>学习前端设计模式</li>
<li>学习 React Native</li>
</ul>

      <p style='text-align: right'>
      <a href='https://suemor.com/notes/6#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">677e5a81f7c1066229de396f</guid>
  <category>notes</category>
false
 </item>
  <item>
    <title>Electron 安装和打包不同平台的 FFmpeg</title>
    <link>https://suemor.com/posts/programming/electron-packaging-of-ffmpeg</link>
    <pubDate>Sat, 23 Nov 2024 18:52:34 GMT</pubDate>
    <description>最近在写一个自动匹配弹幕的动漫播放器，里面需要使用 FFmpeg 对视频进行解析，但发现如何根据不同</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/posts/programming/electron-packaging-of-ffmpeg'>https://suemor.com/posts/programming/electron-packaging-of-ffmpeg</a></blockquote>
      <p>最近在写一个<a href="https://github.com/marchen-dev/MarchenPlay">自动匹配弹幕的动漫播放器</a>，里面需要使用 FFmpeg 对视频进行解析，但发现如何根据不同的平台打包不同 FFmpeg 到 Electron 里，是个挺麻烦的问题，这篇文章就来讲述下我的解决思路。</p>
<h2>安装</h2>
<p>用户的电脑很有可能没有安装 FFmpeg，所以我们需要把 FFmpeg 打包进我们的应用里面。</p>
<p>想要在 Electron 开发环境里面导入 FFmpeg 还是比较简单的，只需要安装下面的包，然后就能够在 Electron 中使用了。</p>
<pre><code class="language-bash">pnpm add -D @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe fluent-ffmpeg</code></pre><p>创建 <code>ffmpeg.ts</code>，具体使用方式可以阅读 <a href="https://www.npmjs.com/package/fluent-ffmpeg">fluent-ffmpeg</a>。</p>
<pre><code class="language-typescript">import ffmpegPath from '@ffmpeg-installer/ffmpeg' // 安装 ffmpeg 的二进制文件
import ffprobePath from '@ffprobe-installer/ffprobe' // 安装 ffprobe 的二进制文件
import ffmpeg from 'fluent-ffmpeg' // 一个封装了 ffmpeg API 的库，当然可以选择不安装，直接使用字符串拼接的方式调用

ffmpeg.setFfmpegPath(ffmpegPath.path)
ffmpeg.setFfprobePath(ffprobePath.path)

export default class FFmpeg {
  ffmpeg: ffmpeg.FfmpegCommand

  constructor(inputPath: string) {
    this.ffmpeg = ffmpeg(inputPath)
  }
}
</code></pre><p>之后我们在开发环境里面就能正常使用 FFmpeg 了。</p>
<h2>打包</h2>
<h3>路径问题</h3>
<p>我的项目是使用 electron builder 进行打包的（具体的 <code>electron-builder.yml</code>配置可以在 <a href="https://www.suemor.com/posts/programming/electron-code-signing-and-notarization">Electron 代码签名和公证</a> 中查看），打包之后你会发现项目是无法正确使用 FFmpeg，但在 dev 环境下到是正常的。</p>
<p>这是因为 ffmpeg 是二进制文件，会被打包进 <code>app.asar.unpacked</code> 而非 <code>app.asar</code> 从而导致 setFfmpegPath 路径出现问题，所以修改对应的 path 即可，这个问题在 <a href="https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg#wrong-path-under-electron-with-asar-enabled">@ffmpeg-installer/ffmpeg</a> 中也有提到。</p>
<pre><code class="language-typescript">import ffmpegPath from '@ffmpeg-installer/ffmpeg' 
import ffprobePath from '@ffprobe-installer/ffprobe'
import ffmpeg from 'fluent-ffmpeg' 

ffmpeg.setFfmpegPath(ffmpegPath.path.replace('app.asar', 'app.asar.unpacked')) // 修改
ffmpeg.setFfprobePath(ffprobePath.path.replace('app.asar', 'app.asar.unpacked')) // 修改
export default class FFmpeg {
  ffmpeg: ffmpeg.FfmpegCommand

  constructor(inputPath: string) {
    this.ffmpeg = ffmpeg(inputPath)
  }
}</code></pre><blockquote>
<p>我使用的电脑是 MacBook Pro M1 Pro 即 macOS ARM64</p>
</blockquote>
<p>打包完成之后，此时运行 ARM64 版本的 .app 是没有问题的，FFmpeg 也能正确运行。</p>
<h3>FFmpeg 的架构版本问题</h3>
<h4>启动报错</h4>
<p>但可不要高兴的太早，我们换一台运行 macOS x64 的电脑，运行刚才用 macOS ARM64 电脑打包出来的 x64 版本的 .app 就会直接报错，然后显示一个完全摸不到头脑的错误。</p>
<p></p>
<h4>原因分析</h4>
<p>我一开始看到这个错误也是完全懵逼的，使用 <a href="https://github.com/pd4d10/debugtron">debugtron</a> 对主线程进行调试也完全没有输出。之后尝试对包进行分析，发现 x64 版本的 .app 打包的 FFmpeg 竟然是 ARM64 版本的，这不报错才怪呢。</p>
<p></p>
<p>这里我便对 <code>@ffmpeg-installer/ffmpeg</code>的实现感到了好奇，他是如何匹配不同的平台，从而安装对应其平台的 FFmpeg 二进制文件。通过阅读其源码：</p>
<pre><code class="language-json">{
  "name": "@ffmpeg-installer/ffmpeg",
  "optionalDependencies": {
    "@ffmpeg-installer/darwin-arm64": "4.1.5",
    "@ffmpeg-installer/darwin-x64": "4.1.0",
    "@ffmpeg-installer/linux-arm": "4.1.3",
    "@ffmpeg-installer/linux-arm64": "4.1.4",
    "@ffmpeg-installer/linux-ia32": "4.1.0",
    "@ffmpeg-installer/linux-x64": "4.1.0",
    "@ffmpeg-installer/win32-ia32": "4.1.0",
    "@ffmpeg-installer/win32-x64": "4.1.0"
  }
}
</code></pre><pre><code class="language-json">{
  "name": "@ffmpeg-installer/darwin-x64",
  "os": [
    "darwin"
  ],
  "cpu": [
    "x64"
  ],
}</code></pre><p>发现<code>@ffmpeg-installer/ffmpeg</code> 封装了多个平台 FFmpeg 依赖，然后放入 <code>optionalDependencies</code> 中。每个平台的 FFmpeg 包再通过设置 <code>cpu + os</code> 字段，从而实现用户安装 <code>@ffmpeg-installer/ffmpeg</code>  即可匹配用户系统，来安装对应的 ffmpeg，这也让我涨知识了。</p>
<p>因此也难怪 x64 版本的 .app 打包的 FFmpeg 是 ARM64 版本的，因为我们在最开始 pnpm install 的时候，就只安装了对应操作系统的 FFmpeg，build 的时候也只能打包当前安装的 FFmpeg。</p>
<p>举个例子，我是 ARM64 macOS， pnpm install 的时候只会安装 ARM 版本 FFmpeg，打包 x64 的时候，当然也只能打包 ARM 版本 FFmpeg 了，从而导致的错误。</p>
<p></p>
<h4>整理思路</h4>
<p>那么我们的思路就很明确了:</p>
<ul>
<li>ARM64 macOS -&gt; 打包 ARM64 应用 -&gt; 使用 ARM64 FFmpeg</li>
<li>ARM64 macOS -&gt; 打包 x64 应用      -&gt; 使用 x64 FFmpeg</li>
</ul>
<p>同理：</p>
<ul>
<li>x64 macOS -&gt; 打包 ARM64 应用 -&gt; 使用 ARM64 FFmpeg</li>
<li>x64 macOS -&gt; 打包 x64 应用      -&gt; 使用 x64 FFmpeg</li>
</ul>
<p>那么如何实现呢？</p>
<blockquote>
<p>这里思路完全是自己想的，或许有更好的方法，也请多多指教。</p>
</blockquote>
<p>说一下我的思路，首先我们在 pnpm install 的时候只安装当前操作系统的 FFmpeg 是不变的，这样可以节约我们电脑的空间和安装依赖的速度。</p>
<p>之后只需要在执行 <code>pnpm build:mac</code>的时候，执行一个安装 mac 平台全部架构的 FFmpeg 依赖脚本就可以了。</p>
<h4>解决问题</h4>
<p>编写 <code>scripts/install-darwin-deps.js</code></p>
<pre><code class="language-javascript">/* eslint-disable no-console */
import { exec } from 'node:child_process'
import os from 'node:os'

const platform = os.platform()

if (platform === 'darwin') {
  console.log('Detected macOS, installing darwin dependencies...')
  // 为了在 macos arm64 架构下进行打包 x64 架构的 APP, 所以需要同时安装 x64 arm64 架构的 ffmpeg 和 ffprobe
  exec(
    'pnpm i @ffmpeg-installer/darwin-x64@^4.1.0 @ffprobe-installer/darwin-x64@^5.1.0 @ffmpeg-installer/darwin-arm64@^4.1.5 @ffprobe-installer/darwin-arm64@^5.0.1 -D',
    (err, stdout, stderr) =&gt; {
      if (err) {
        console.error(`Error installing optional dependencies: ${stderr}`)
        throw new Error('Error installing optional dependencies')
      } else {
        console.log(`Optional dependencies installed: ${stdout}`)
      }
    },
  )
} else {
  console.log('Non-macOS platform detected, skipping optional darwin installation.')
}</code></pre><p>之后在 package.json 里面加上 <code>    &quot;build:mac&quot;: &quot;node scripts/install-darwin-deps.js &amp;&amp; electron-vite build &amp;&amp; electron-builder --mac --publish never&quot;</code> 即可。</p>
<p>执行打包命令之后，macOS x64 也是正确运行 macOS ARM64 打包出来的 x64 版本的 xxx.app ，不再会出现之前那个摸不着头脑报错了。</p>
<h3>不同平台只打包对应的 FFmpeg</h3>
<p>这里新的问题有又出现了，我们发现当前 xxx.dmg 包体积大了很多，那是因为所有平台的 FFmpeg 都被打包进去了。例如，ARM64 版本 xxx.app 把 x64 和 ARM64 FFmpeg 都打包进去了。</p>
<p></p>
<p>这里我们得写一个脚本，在 electron builder 打包之后，把与目标平台不相符的 FFmpeg 给删除掉，编写 <code>scripts/cleaned-unused-arch-deps.js</code></p>
<pre><code class="language-javascript">/* eslint-disable no-console */
import fs from 'node:fs'
import path from 'node:path'

export default async function cleanDeps(context) {
  const { packager, arch, appOutDir } = context
  const platform = packager.platform.nodeName
  if (platform !== 'darwin') {
    return
  }
  const archMap = {
    1: 'x64',
    3: 'arm64',
  }
  const currentArch = archMap[arch]

  if (!currentArch) {
    return
  }

  const unpackedPath = path.resolve(
    appOutDir,
    'Marchen.app',
    'Contents',
    'Resources',
    'app.asar.unpacked',
    'node_modules',
  )
  if (!fs.existsSync(unpackedPath)) {
    return
  }

  const ffmpegPath = path.resolve(unpackedPath, '@ffmpeg-installer')
  const ffprobePath = path.resolve(unpackedPath, '@ffprobe-installer')

  if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {
    return
  }

  const removeUnusedArch = (basePath, unusedArch) =&gt; {
    const unusedPath = path.resolve(basePath, `darwin-${unusedArch}`)
    if (fs.existsSync(unusedPath)) {
      fs.rmSync(unusedPath, { recursive: true })
    }
  }

  if (currentArch === 'x64') {
    removeUnusedArch(ffmpegPath, 'arm64')
    removeUnusedArch(ffprobePath, 'arm64')
  } else if (currentArch === 'arm64') {
    removeUnusedArch(ffmpegPath, 'x64')
    removeUnusedArch(ffprobePath, 'x64')
  }
  console.log('Cleaned unused arch dependencies.')
}
</code></pre><p>之后在 electron-builder.yml 里面使用 <code>afterPack: scripts/cleaned-unused-arch-deps.js</code> 导入脚本。</p>
<p>然后执行 <code>pnpm build:mac</code>就实现了不同平台只打包对应的 FFmpeg，并且运行都正常了。查看包内容，发现确实只包含了目标平台的 FFmpeg。</p>
<p></p>

      <p style='text-align: right'>
      <a href='https://suemor.com/posts/programming/electron-packaging-of-ffmpeg#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">67422472f7c1066229db031b</guid>
  <category>posts</category>
<category>编程</category>
 </item>
  <item>
    <title>Electron 代码签名和公证</title>
    <link>https://suemor.com/posts/programming/electron-code-signing-and-notarization</link>
    <pubDate>Sat, 23 Nov 2024 15:03:03 GMT</pubDate>
    <description>本文的方式仅限用于 macOS

最近尝试入门了下 Electron 开发，写了一个可以自动匹配弹幕</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/posts/programming/electron-code-signing-and-notarization'>https://suemor.com/posts/programming/electron-code-signing-and-notarization</a></blockquote>
      <blockquote>
<p>本文的方式仅限用于 macOS</p>
</blockquote>
<p>最近尝试入门了下 Electron 开发，写了一个可以<a href="https://github.com/marchen-dev/MarchenPlay">自动匹配弹幕的动漫播放器</a>，期间遇到挺多坑的，写篇文章来记录一下。</p>
<h2>代码签名和公证</h2>
<p>想要在 macOS 上运行一个桌面端应用，那就必须对它代码进行签名，否则是无法打开，会出现如下错误。</p>
<p></p>
<h3>Apple Developer 注册</h3>
<p>想要进行代码签名就得花 688 元去注册苹果开发者，这里注册也比较玄学，注册过程中运气不好就容易出现 <code>联系我们以继续流程</code>的弹窗，这我在用 MacBook 注册时候出现过一次，然后换 iPhone 上注册就没有这个问题了。</p>
<p>注册到最后一步，付完费用之后，你会发现还是没办法使用，打开 Developer APP 账户页面里面，会显示一个灰色的现在注册按钮，然后显示<strong>将很快收到相关邮件</strong>，打开邮箱会发现两封名为<code>你的订阅确认</code>和 <code>Apple 提供的收据</code>的邮件，但这个其实并不是上文 Apple 所提到的<code>相关邮件</code>。这里不用慌张，这其实就是 Apple 正在审核的意思，我是晚上注册的，等隔天早上 9 点之后，就会收到一份 <code>欢迎加入 Apple Developer Program</code>的邮件，这才表明注册成功了。</p>
<h3>代码签名</h3>
<p>关于具体如何生成和上传证书，网上相关教程有很多，这里就不展开说明了。之后就是把 Developer ID certificates 的私钥 .p12 文件，配置到终端环境变量中，填写 <code>CSC_LINK</code>和 <code>CSC_KEY_PASSWORD</code> 。</p>
<p>这里 Electron 打包我选择使用 <a href="https://www.electron.build/index.html">electron-builder</a> ，执行 <code>electron-vite build &amp;&amp; electron-builder --mac --publish never</code> 的时候，它会自动读取上方配置好的两个环境变量从而就行签名，无需进行额外配置。</p>
<p>下面是我的 <code>electron-builder.yml</code></p>
<pre><code class="language-yaml">appId: com.suemor.Marchen
productName: Marchen
directories:
  buildResources: build
files:
  - '!**/.vscode/*'
  - '!src/*'
  - '!electron.vite.config.{js,ts,mjs,cjs}'
  - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
  - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
  - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
  - resources/**
win:
  executableName: Marchen
nsis:
  artifactName: ${productName}-${version}-setup.${ext}
  shortcutName: ${productName}
  uninstallDisplayName: ${productName}
  createDesktopShortcut: always
  allowToChangeInstallationDirectory: true
  oneClick: false
mac:
  entitlementsInherit: build/entitlements.mac.plist
  extendInfo:
    - NSCameraUsageDescription: Application requests access to the device's camera.
    - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
    - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
    - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
  notarize: false
  target:
    - target: dmg
      arch:
        - arm64
        - x64
    - target: zip
      arch:
        - arm64
        - x64
dmg:
  artifactName: ${productName}-${version}-${arch}.${ext}
linux:
  target:
    - target: AppImage
      arch:
        - arm64
        - x64
  maintainer: github.com/suemor233
  category: Utility
appImage:
  artifactName: ${productName}-${version}-${arch}.${ext}
npmRebuild: false
publish:
  provider: github
  owner: marchen-dev
  repo: MarchenPlay
afterSign: scripts/notarize.js
releaseInfo:
  releaseNotes: |
    本次更新:
      可以切换动漫内嵌字幕和手动导入字幕
      新增视频播放器设置功能
      可以对历史记录动漫进行删除和从新识别弹幕库
</code></pre><h3>公证</h3>
<p>代码签完名之后，我们需要把打包后的程序上传给苹果，来确保我们程序的安全性和没有被其他人篡改。</p>
<p>这里我们安装 <code>@electron/notarize</code> 和 <code>dotenv</code>这两个包, 然后创建一个 <code>scripts/notarize.js</code>文件，写法如下，他可以读取我们 .env 里面的相关变量，然后进行公证。</p>
<pre><code class="language-javascript">import { notarize } from '@electron/notarize'
import { config } from 'dotenv'

config()

export default async function notarizing(context) {
  if (context.electronPlatformName !== 'darwin') {
    return
  }

  const appBundleId = process.env.APPLE_APP_BUNDLE_ID // 随便填一个，类似 com.suemor.Marchen
  const appleId = process.env.APPLE_ID // 你的 Apple ID 
  const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD // https://account.apple.com/account/manage 然后点击 App 专用密码
  const teamId = process.env.APPLE_TEAM_ID // https://developer.apple.com/account 里面 会员资格详细信息 卡片里面，有个 团队 ID

  if (!appBundleId || !appleId || !appleIdPassword || !teamId) {
    return
  }

  const appName = context.packager.appInfo.productFilename
  const appPath = `${context.appOutDir}/${appName}.app`
  // eslint-disable-next-line no-console
  console.log('Notarizing app:', appPath)

  await notarize({
    appPath,
    appBundleId,
    appleId,
    appleIdPassword,
    teamId,
  })
}</code></pre><p>之后在 <code>electron-builder.yml</code> 里面导入这个路径 <code>afterSign: scripts/notarize.js</code>。</p>
<p>然后执行<code>    &quot;build:mac&quot;: &quot;electron-vite build &amp;&amp; electron-builder --mac --publish never&quot;,</code>就可以完成公证了。</p>
<p>这里公证速度一般都挺慢的，因为要把你的应用上传到苹果服务器上去，取决于你宽带的上传速度，得耐心等待。另外我看别人说公证成功之后收到 Apple Developer 的相关邮件，不清楚什么原因，我从来没有收到过。</p>
<p>那么如何校验我们这个应用是否公证成功了呢？安装完 App 之后，我们只要去终端执行下方的命令，显示<code>The validate action worked!</code>就代表成功了。</p>
<pre><code class="language-bash">stapler validate /Applications/xxxx.app</code></pre><h3>配置 GitHub Actions</h3>
<p>为了方便发布版本，我们可能会利用 Github Actions 进行自动化打包，为了 Github Actions 能够在打包过程中进行签名和公证，如下图所示，我们需要把上文所配置的环境变量放到 Github Repository secrets 里面。</p>
<pre><code class="language-bash">CSC_LINK // 填写 base64
CSC_KEY_PASSWORD
APPLE_ID
APPLE_APP_SPECIFIC_PASSWORD
APPLE_TEAM_ID
APPLE_APP_BUNDLE_ID</code></pre><p></p>
<p>这里有个麻烦的地方，就是 CSC_LINK 这个字段的填写。之前我们在配置终端环境变量的时候，是直接使用绝对路径的方式来链接到 .p12 文件的，但这里的 Repository secrets 是不支持上传文件的，所以得把之前 Developer ID certificates 的私钥 .p12 文件转换为 base64 然后复制到 Repository secrets 的 CSC_LINK 的变量里面去。</p>
<pre><code class="language-bash">base64 -i xxxx.p12 | pbcopy </code></pre><p>下方是我完整的 <code>release.yml</code></p>
<pre><code class="language-yaml">name: Build/release Electron app

on:
  push:
    tags:
      - v*.*.*

jobs:
  release:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [20.x]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          lfs: true

      - name: Checkout LFS objects
        run: git lfs checkout

      - name: Setup pnpm
        uses: pnpm/action-setup@v4.0.0

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install

      - name: Install snapcraft
        if: matrix.os == 'ubuntu-latest'
        run: sudo snap install snapcraft --classic

      - name: Build for Linux
        if: matrix.os == 'ubuntu-latest'
        run: pnpm run build:linux

      - name: Build for macOS
        if: matrix.os == 'macos-latest'
        run: pnpm run build:mac
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPLE_APP_BUNDLE_ID: ${{ secrets.APPLE_APP_BUNDLE_ID }}
          CSC_LINK: ${{ secrets.CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
          GH_TOKEN: ${{ secrets.GH_TOKEN }}

      - name: Build for Windows
        if: matrix.os == 'windows-latest'
        run: pnpm run build:win

      - name: Generate Changelog
        if: github.event_name == 'push'
        run: npx @suemor/changelogithub
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          draft: false
          prerelease: true
          files: |
            dist/*.exe
            dist/*.zip
            dist/*.dmg
            dist/*.AppImage
            dist/*.snap
            dist/*.deb
            dist/*.rpm
            dist/*.tar.gz
            dist/latest*.yml
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}</code></pre><h2>下期预告</h2>
<p>下篇文章准备来讲一下如何打包 FFmpeg 到 Electron 里面，这里面的坑还是挺多的，特别是利用 Github Actions 打包的时候，得实现根据不同的平台打包不同 FFmpeg 才行，比如用 arm64 mac 打包 x64 的应用，如何正确打包 x64 版本的 FFmpeg。</p>

      <p style='text-align: right'>
      <a href='https://suemor.com/posts/programming/electron-code-signing-and-notarization#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">6741eea7f7c1066229dafb3b</guid>
  <category>posts</category>
<category>编程</category>
 </item>
  <item>
    <title>迟来的 2023 年度总结</title>
    <link>https://suemor.com/notes/5</link>
    <pubDate>Sat, 08 Jun 2024 13:16:25 GMT</pubDate>
    <description> 2023 的年度总结我一直搁置着没写，因为那时一直在忙于备考转本，并且心情极度焦虑，转眼间就过去半</description>
    <content:encoded><![CDATA[
      <blockquote>该渲染由 marked 生成，可能存在排版问题，最佳体验请前往：<a href='https://suemor.com/notes/5'>https://suemor.com/notes/5</a></blockquote>
      <blockquote>
<p> 2023 的年度总结我一直搁置着没写，因为那时一直在忙于备考转本，并且心情极度焦虑，转眼间就过去半年了，导致很多事情的结果都与 2024 年有些挂钩，所以这篇总结也会包含少许的 2024 年的事情。</p>
</blockquote>
<h2>正文</h2>
<p>​	2022 年末的时候我无意间了解到「字节青训营」的存在，当时正好有空，就报名参加了基础班，笔试很简单，基本人人都能过。整个流程大概就是去学习掘金里面的课程，最后组队写一个小项目。我们小队采用 Next.js 仿写掘金官网的项目，我也很幸运地被选为队长，因为涉及到多人协作，让我对使用 Git 有了些提升。项目开发整体还是挺顺利的，最后获得了第二名。</p>
<p>​	二月中旬，一天群里聊天的时候，偶然间听 <a href="https://github.com/akarachen">Akarachen</a> 说有一个远程实习的机会，当时我挺激动的，于是就去试试水。简单跟 <a href="https://x.com/lemon_hx">Lemon</a> 交流后，便被安排面试。当时其实挺不方便的，被学校领导叫去南京来陪学弟参加比赛，面试基本就一边走路一边完成，挺是尴尬。最终也是顺利入职，里面大佬挺多的，写的项目也很符合我的技术栈。就这样一直干到了五月份，因为要准备专转本，而且还要上学，时间越来越不够用了，所以只能无奈选择离职。这也算是我第一桶金，挣了蛮多钱的，狠狠消费了一波，报了个转本的全程班，抢了 NuPhy Air75 V2 的首发，买了台 N100 小主机等等。</p>
<p>​	期间还看到 <a href="https://github.com/innei">innei</a> 大佬多次参与 <a href="https://github.com/Crossbell-Box/xLog">xLog</a> 项目，吸引了我的兴趣，看到用的技术栈也挺切合我的，于是便 clone 项目学习了下。当时项目还是初期阶段，有几个明显的 bug，也因此水了好几个 PR。当时挺兴奋的，GitHub 涨了挺多 followers 的，也是第一次给大型项目提了好些 PR，同时还认识了些大佬。</p>
<p>​	大概从 6 月开始正式进入转本的学习，因为我手上并没有备考的资料，于是便听从往届上岸学长的建议，花了 4k 报了某机构的线上全程班，结果就被坑了。发的专业课资料上面错题很多，答疑速度也非常慢，不如直接去问 GPT，配套的视频播放器也很垃圾，各种离奇的 bug 和卡顿。英语的课程就更离谱了，报课后，就给了一大堆书和视频，完全没有侧重点，也没有相关的模拟卷。答疑的英语老师一看就是外边随便找的，对转本相关的东西完全不了解，很业余，纯纯被当成韭菜收割了。</p>
<p>​	我的英语很糟糕，基本就小学生水平，并且自己的记性还很差，凡是涉及到需要背诵的内容，就很难记住，这使我背诵单词和词组要花费了比其他人更多的时间，于是我从年初开始每天都坚持背单词，到暑假结束后，看到核心单词基本可以迅速明白意思。其次我对于语法则是一窍不通，暑假的时候便尝试跟着<a href="https://space.bilibili.com/388576777">英语的平行世界</a>的视频学习，从初级语法学到高级语法，他讲的还是挺不错的，不敢说有多深入，但确实是通俗易懂，让我对英语语法有了全新的理解。考虑到我挺喜欢玩 Galgame 的，闲暇之余找了款原始语言是英语的 <a href="https://store.steampowered.com/app/460430">The Letter</a> ，看它恰好有 Mac 版本，配合上 Bob 翻译，让我对英语的阅读有了挺大的提升。</p>
<p>​	暑假过后学校开双选会，里面基本都是些电子厂、客服之类的，还要求强制实习，不然不给毕业。而且每天都要定位打卡+实习日志/周志+拍照，有时学校领导还会实地走访，很是恶心。为了避免打扰到转本，我想了些方法糊弄了下，虽然全程都在斗智斗勇，但还是成功逃脱了实习。</p>
<p>​	之后就是在家从早到晚的刷题，随之而来问题也产生了，因为班上就我一个人在准备转本，很孤独，完全没有陪伴，缺乏动力感，导致早晨很容易起不来，或者起来后犯困，忍不住去睡回笼觉。再三考虑下，决定线下找个自习室去学习，再搭配上魔爪饮料，成功摆脱了犯困的问题。大概就这样一直持续到 2024 年 3 月 24 日，期间压力是很大的，经常看到许多比我优秀的人，有时候也会怀疑起自己就不是学习的料，害怕考不上，活生生浪费大半年写代码的时间，因此基本每天都在哭，网上测试心理状况基本都是重度抑郁，好在功夫不负有心人，最终考试分数还是挺不错的，比录取分数线高出很多，全班就我一个人上岸了。</p>
<h2>其他</h2>
<h3>闲鱼验货宝买 AirPods Max 被坑</h3>
<blockquote>
<p>此耳机在 2024 年 6 月 1 日去世，无法连接到手机，花了 580 元才维修好，下次再也不在闲鱼买贵重物品了。</p>
</blockquote>
<p>​	之前一直想买台耳机，当时正好发完实习工资，便想买台 AirPods Max，看了下官方价格太高，便想贪点便宜闲鱼买台在保的 AirPods Max，我也知道 AirPods Max 假货多，特地要求走闲鱼验货宝，结果验货宝验货一切正常，到手后发现耳机退不了 ID，查找功能用不了，找卖家和验货宝官方基本都是装死或者答非所问，因为走了验货宝也无法退货，只能自认倒霉。好在其他功能都正常，将就着用了。</p>
<h3>折腾小主机</h3>
<p>​	双11看零刻 N100 在打折，便入手了一台，第一次折腾 pve，尝试 All in boom，装了 OpenWrt、黑群晖、Home Assistant 之类的，把米家和家里的摄像头都接入到了 HomeKit 里，学习到挺多网络知识的。</p>
<h3>游玩 Galgame</h3>
<p>​	从去年开始就逐渐对 Galgame 感兴趣起来，今年玩了挺多作品的，其中「樱之诗」应该是印象最深刻的了，体会到了艺术与哲学交织，让我对哲学有了点兴趣。还有就是玩「装甲恶鬼村正」的时候提到「元日战争」和「正宗」，转本之后也是买了台 PS5 通关了「对马岛之魂」。</p>
<p>下面列举 2023 年玩过的 Galgame</p>
<ul>
<li>樱之诗</li>
<li>樱之刻</li>
<li>素晴日</li>
<li>星空列车与白的旅行</li>
<li>青空下的加缪</li>
<li>装甲恶鬼村正</li>
<li>你和她和她的恋爱</li>
<li>电波消逝之日</li>
<li>Muv-Luv 系列</li>
<li>兰斯3 重制版</li>
<li>兰斯10</li>
<li>拔作岛1</li>
<li>水仙</li>
<li>多娜多娜一起干坏事吧</li>
<li>恋爱绮谭<del>不存在的真相</del></li>
</ul>
<h3>新增的电子产品</h3>
<ul>
<li>零刻 N100</li>
<li>NuPhy Air75 V2</li>
<li>AirPods Max</li>
<li>PlayStation 5</li>
<li>HomePod mini * 2</li>
<li>奔图 P1 激光打印机</li>
<li>TP-LINK XDR6088 路由器</li>
<li>小米电动牙刷 T501</li>
</ul>
<h3>学会的新技能</h3>
<ul>
<li>VSCode Vim</li>
<li>Next.js App Router</li>
<li>TanStack Query</li>
</ul>
<h3>未来的期待</h3>
<p>提高代码质量，学习设计模式的使用，多阅读优秀的开源项目，并参与其中。</p>
<h2>往期</h2>
<ul>
<li><a href="https://www.suemor.com/notes/3">2022 年终总结</a></li>
</ul>

      <p style='text-align: right'>
      <a href='https://suemor.com/notes/5#comments'>看完了？说点什么呢</a>
      </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">666459a9c276c57e12eacba5</guid>
  <category>notes</category>
false
 </item>
  
</channel>
</rss>