<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>莫莫的博客</title><description>莫莫的小屋</description><link>https://blog.moxiaoshuai.fun/</link><language>zh_CN</language><item><title>Vue 3 响应式原理</title><link>https://blog.moxiaoshuai.fun/2026-05-vue-3%E5%93%8D%E5%BA%94%E5%BC%8F%E5%8E%9F%E7%90%86-vue-3%E5%93%8D%E5%BA%94%E5%BC%8F%E5%8E%9F%E7%90%86/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-05-vue-3%E5%93%8D%E5%BA%94%E5%BC%8F%E5%8E%9F%E7%90%86-vue-3%E5%93%8D%E5%BA%94%E5%BC%8F%E5%8E%9F%E7%90%86/</guid><description>梳理 Vue 3 响应式系统中 targetMap、depsMap、dep、effect、track、trigger、Proxy、ref 与 computed 的核心关系。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;Vue 3 的响应式核心，是通过 &lt;code&gt;Proxy&lt;/code&gt; 拦截读写操作，再借助 &lt;code&gt;effect&lt;/code&gt;、&lt;code&gt;track&lt;/code&gt;、&lt;code&gt;trigger&lt;/code&gt; 和依赖映射表，把数据读取和副作用函数重新执行关联起来。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B8%E5%BF%83%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84&quot;&gt;核心数据结构&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B8%E5%BF%83%E5%87%BD%E6%95%B0&quot;&gt;核心函数&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1-effect%E5%89%AF%E4%BD%9C%E7%94%A8%E5%87%BD%E6%95%B0&quot;&gt;1. effect：副作用函数&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-track%E4%BE%9D%E8%B5%96%E6%94%B6%E9%9B%86&quot;&gt;2. track：依赖收集&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-trigger%E6%B4%BE%E5%8F%91%E6%9B%B4%E6%96%B0&quot;&gt;3. trigger：派发更新&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%93%8D%E5%BA%94%E5%BC%8F%E6%9B%B4%E6%96%B0%E6%B5%81%E7%A8%8B&quot;&gt;响应式更新流程&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%A6%82%E4%BD%95%E6%8B%A6%E6%88%AA-set-%E5%92%8C-get&quot;&gt;如何拦截 set 和 get&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%8F%8D%E5%B0%84%E5%92%8C%E4%BB%A3%E7%90%86&quot;&gt;反射和代理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%8A%A0%E5%85%A5-track-%E5%92%8C-trigger&quot;&gt;加入 track 和 trigger&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%AF%A6%E7%BB%86%E8%BF%90%E8%A1%8C%E8%BF%87%E7%A8%8B&quot;&gt;详细运行过程&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1-%E5%BB%BA%E7%AB%8B%E5%93%8D%E5%BA%94%E5%BC%8F%E5%AF%B9%E8%B1%A1&quot;&gt;1. 建立响应式对象&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-%E5%88%9D%E6%AC%A1%E6%89%A7%E8%A1%8C-effect-%E5%B9%B6%E6%94%B6%E9%9B%86%E4%BE%9D%E8%B5%96track&quot;&gt;2. 初次执行 effect 并收集依赖（track）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-%E4%BF%AE%E6%94%B9%E6%95%B0%E6%8D%AE%E5%B9%B6%E8%87%AA%E5%8A%A8%E6%B4%BE%E5%8F%91%E6%9B%B4%E6%96%B0trigger&quot;&gt;3. 修改数据并自动派发更新（trigger）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%AC%AC%E4%B8%89%E6%AD%A5%E7%9A%84%E5%BB%B6%E4%BC%B8%E5%A6%82%E6%9E%9C%E5%9C%A8%E5%A4%96%E9%83%A8%E5%86%8D%E5%8A%A0%E5%85%A5%E4%B8%80%E4%B8%AA%E7%8B%AC%E7%AB%8B%E7%9A%84-get-%E6%93%8D%E4%BD%9C%E4%BC%9A%E6%9C%89%E4%BB%80%E4%B9%88%E9%97%AE%E9%A2%98%E5%90%97&quot;&gt;第三步的延伸：如果在外部再加入一个独立的 get 操作，会有什么问题吗？&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#ref-%E7%9A%84%E5%AE%9E%E7%8E%B0&quot;&gt;ref 的实现&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#computed-%E7%9A%84%E5%AE%9E%E7%8E%B0&quot;&gt;computed 的实现&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;核心数据结构&lt;/h2&gt;
&lt;p&gt;三个表：&lt;code&gt;targetMap&lt;/code&gt;（&lt;code&gt;WeakMap&lt;/code&gt;）、&lt;code&gt;depsMap&lt;/code&gt;（&lt;code&gt;Map&lt;/code&gt;）、&lt;code&gt;dep&lt;/code&gt;（&lt;code&gt;Set&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;Vue 内部维护了一个全局的弱引用映射字典（&lt;code&gt;WeakMap&lt;/code&gt;），数据结构大概是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;target（被代理对象） -&amp;gt; key（对象属性） -&amp;gt; Set&amp;lt;effect&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应关系如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;targetMap&lt;/code&gt;：&lt;code&gt;key&lt;/code&gt; 是对象，&lt;code&gt;value&lt;/code&gt; 是属性表。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;depsMap&lt;/code&gt;：&lt;code&gt;key&lt;/code&gt; 是属性，&lt;code&gt;value&lt;/code&gt; 是 &lt;code&gt;effect&lt;/code&gt; 表。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dep&lt;/code&gt;：&lt;code&gt;value&lt;/code&gt; 是 &lt;code&gt;effect&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;核心函数&lt;/h2&gt;
&lt;p&gt;三个函数：&lt;code&gt;effect&lt;/code&gt;、&lt;code&gt;track&lt;/code&gt;、&lt;code&gt;trigger&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;1. effect：副作用函数&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;effect&lt;/code&gt; 接收一个函数 &lt;code&gt;fn&lt;/code&gt;。当你调用 &lt;code&gt;effect(fn)&lt;/code&gt; 时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;它会立即执行一次 &lt;code&gt;fn&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;在执行 &lt;code&gt;fn&lt;/code&gt; 的过程中，如果读取了响应式数据（比如 &lt;code&gt;reactive&lt;/code&gt; 生成的 &lt;code&gt;Proxy&lt;/code&gt; 对象属性），就会触发该属性的读取拦截器（&lt;code&gt;getter&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;建立起被访问数据与这个 &lt;code&gt;fn&lt;/code&gt; 之间的联系，以便将来数据变化时，这个 &lt;code&gt;fn&lt;/code&gt; 能够被再次执行。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;2. track：依赖收集&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;track&lt;/code&gt; 一般在响应式对象（&lt;code&gt;Proxy&lt;/code&gt;）的 &lt;code&gt;get&lt;/code&gt;（读取）操作拦截器中被内部调用。&lt;/p&gt;
&lt;p&gt;其核心作用是把当前正在运行的 &lt;code&gt;effect&lt;/code&gt; 记录下来。&lt;/p&gt;
&lt;h3&gt;3. trigger：派发更新&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;trigger&lt;/code&gt; 一般在响应式对象（&lt;code&gt;Proxy&lt;/code&gt;）的 &lt;code&gt;set&lt;/code&gt;（修改）操作拦截器中被内部调用。&lt;/p&gt;
&lt;p&gt;其核心作用是取出对应的 &lt;code&gt;effect&lt;/code&gt; 并执行。&lt;/p&gt;
&lt;h2&gt;响应式更新流程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;effect(fn) -&amp;gt; 执行 fn()
读取数据触发 Proxy getter -&amp;gt; 调用 track 收集 activeEffect
修改数据触发 Proxy setter -&amp;gt; 调用 trigger 取出 effect -&amp;gt; 重新执行 effect(fn)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;如何拦截 set 和 get&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Vue 2：ES5 &lt;code&gt;Object.defineProperty()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Vue 3：ES6 &lt;code&gt;Proxy&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;反射和代理&lt;/h2&gt;
&lt;p&gt;代理（&lt;code&gt;Proxy&lt;/code&gt;）是一个对象的占位符，默认情况下对该对象进行委托。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/QQ_1779883966921.Yzis0Wh1_ZTuU1O.webp&quot; alt=&quot;使用 Proxy&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Proxy()&lt;/code&gt; 的第二个参数叫处理函数（&lt;code&gt;Handler&lt;/code&gt;），可以传递一个诱捕器（&lt;code&gt;trap&lt;/code&gt;），用于拦截各种基础操作，比如属性查找、枚举，我们就可以用来拦截 &lt;code&gt;get&lt;/code&gt; 和 &lt;code&gt;set&lt;/code&gt; 了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      console.log(&apos;触发了 Get，key = &apos; + key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log(&apos;触发了 Set，key = &apos; + key + &apos;，value = &apos; + value);
      return Reflect.set(target, key, value, receiver);
    }
  };

  // 返回一个代理对象，我们可以像使用原对象一样使用它。
  return new Proxy(target, handler);
}

let product = reactive({ price: 5, quantity: 2 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：&lt;code&gt;receiver&lt;/code&gt;（接收器）的作用是保证当我的对象有继承自其他对象的值或函数时，&lt;code&gt;this&lt;/code&gt; 指针能正确指向使用的对象。&lt;/p&gt;
&lt;h2&gt;加入 track 和 trigger&lt;/h2&gt;
&lt;p&gt;再加入 &lt;code&gt;track&lt;/code&gt; 和 &lt;code&gt;trigger&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key); // 收集依赖
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      // 只有当值发生改变时才触发更新
      if (oldValue !== value) {
        trigger(target, key); // 派发更新
      }
      return result;
    }
  };

  return new Proxy(target, handler);
}

let product = reactive({ price: 5, quantity: 2 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后我们尝试创建一个对象，并且使用上面的 &lt;code&gt;reactive&lt;/code&gt; 方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let product = reactive({ price: 5, quantity: 2 });
let total = 0;

let effect = () =&amp;gt; {
  total = product.price * product.quantity;
};

effect(); // 初始执行，触发 getter 依赖收集
console.log(total); // 输出 10

product.quantity = 3; // 触发 setter 派发更新
console.log(total); // 输出 15
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;详细运行过程&lt;/h2&gt;
&lt;h3&gt;1. 建立响应式对象&lt;/h3&gt;
&lt;p&gt;我们通过 &lt;code&gt;reactive({ price: 5, quantity: 2 })&lt;/code&gt; 创建对象，它返回了一个 &lt;code&gt;Proxy&lt;/code&gt; 代理对象 &lt;code&gt;product&lt;/code&gt;。此时 &lt;code&gt;product&lt;/code&gt; 的读取（&lt;code&gt;get&lt;/code&gt;）和修改（&lt;code&gt;set&lt;/code&gt;）操作都已经分别被拦截处理。&lt;/p&gt;
&lt;h3&gt;2. 初次执行 effect 并收集依赖（track）&lt;/h3&gt;
&lt;p&gt;手动调用执行 &lt;code&gt;effect()&lt;/code&gt; 时，它内部读取了 &lt;code&gt;product.price&lt;/code&gt; 和 &lt;code&gt;product.quantity&lt;/code&gt;。此时触发了代理对象 &lt;code&gt;product&lt;/code&gt; 的 &lt;code&gt;get&lt;/code&gt; 拦截器。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;get&lt;/code&gt; 拦截器中，内部调用了 &lt;code&gt;track(target, key)&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;track&lt;/code&gt; 把当前的 &lt;code&gt;effect&lt;/code&gt; 副作用函数收集到全局依赖字典（&lt;code&gt;targetMap&lt;/code&gt; -&amp;gt; &lt;code&gt;depsMap&lt;/code&gt; -&amp;gt; &lt;code&gt;dep&lt;/code&gt;）中，分别与 &lt;code&gt;price&lt;/code&gt; 和 &lt;code&gt;quantity&lt;/code&gt; 这被访问的属性建立联系。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;total&lt;/code&gt; 计算结果为 &lt;code&gt;5 * 2 = 10&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 修改数据并自动派发更新（trigger）&lt;/h3&gt;
&lt;p&gt;当代码走到 &lt;code&gt;product.quantity = 3&lt;/code&gt; 时，是对 &lt;code&gt;product&lt;/code&gt; 属性赋值，此操作触发了代理对象的 &lt;code&gt;set&lt;/code&gt; 操作拦截器。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;set&lt;/code&gt; 拦截器中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我们传入的新值 &lt;code&gt;3&lt;/code&gt;（&lt;code&gt;value&lt;/code&gt;）和原来的值 &lt;code&gt;2&lt;/code&gt;（&lt;code&gt;oldValue&lt;/code&gt;）不相等，即判定 &lt;code&gt;oldValue !== value&lt;/code&gt; 成立。&lt;/li&gt;
&lt;li&gt;由于值发生了变化，拦截器继续调用 &lt;code&gt;trigger(target, &apos;quantity&apos;)&lt;/code&gt; 发出更新派发信号。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trigger&lt;/code&gt; 去全局依赖字典找到订阅在当前对象 &lt;code&gt;&apos;quantity&apos;&lt;/code&gt; 属性上的所有 &lt;code&gt;effect&lt;/code&gt; 函数集合并执行它们。&lt;/li&gt;
&lt;li&gt;这样我们原本的 &lt;code&gt;effect&lt;/code&gt; 函数就会重新隐式运行一遍（&lt;code&gt;total = 5 * 3&lt;/code&gt;），从而使 &lt;code&gt;total&lt;/code&gt; 更新为 &lt;code&gt;15&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;所以最后一句 &lt;code&gt;console.log(total)&lt;/code&gt; 时，得到的是最新的结果 &lt;code&gt;15&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;这个例子完整串起了初始化执行、读取收集、赋值触发和副作用重跑四个环节。最终 &lt;code&gt;total&lt;/code&gt; 从 &lt;code&gt;10&lt;/code&gt; 更新为 &lt;code&gt;15&lt;/code&gt;，正是 &lt;code&gt;trigger&lt;/code&gt; 派发更新后的结果。&lt;/p&gt;
&lt;h2&gt;第三步的延伸：如果在外部再加入一个独立的 get 操作，会有什么问题吗？&lt;/h2&gt;
&lt;p&gt;假如我们在最下面直接加上一句：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;console.log(&apos;Updated quantity to = &apos; + product.quantity);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;答案是：有可能会导致错误收集。&lt;/p&gt;
&lt;p&gt;当你执行 &lt;code&gt;product.quantity&lt;/code&gt; 这一读取操作时，自然会再次触发 &lt;code&gt;Proxy&lt;/code&gt; 代理对象的 &lt;code&gt;get&lt;/code&gt; 拦截器。而在 &lt;code&gt;get&lt;/code&gt; 拦截器内，会不可避免地自动执行 &lt;code&gt;track(target, key)&lt;/code&gt; 去收集依赖。&lt;/p&gt;
&lt;p&gt;问题在于这里所处的上下文：这段 &lt;code&gt;console.log&lt;/code&gt; 代码是在全局下直接运行的，它并没有被包裹在任何副作用 &lt;code&gt;effect&lt;/code&gt; 函数内。&lt;/p&gt;
&lt;p&gt;如果没有进行安全判断：我们的 &lt;code&gt;track&lt;/code&gt; 函数可能会去收集那个根本不存在的依赖（可能是 &lt;code&gt;undefined&lt;/code&gt;，也可能是上次残留还没被清除的旧 &lt;code&gt;effect&lt;/code&gt;），将一个无效的依赖强行塞进我们的 &lt;code&gt;dep&lt;/code&gt; 集合里，这会导致内存泄漏，甚至是调用报错。&lt;/p&gt;
&lt;p&gt;这就引出了真正完善的依赖收集机制里非常关键的一环：在真正的 Vue 3 源码中，内部会维护一个全局变量（通常命名为 &lt;code&gt;activeEffect&lt;/code&gt;）来时刻保存当前正在执行的副作用函数。在进入 &lt;code&gt;effect&lt;/code&gt; 执行时赋值给它，出 &lt;code&gt;effect&lt;/code&gt; 时清空。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;track&lt;/code&gt; 在尝试收集依赖之前，做的第一件事就是检查 &lt;code&gt;activeEffect&lt;/code&gt; 是否存在。只有存在才会把它添加进弱映射字典，以防止脱离副作用的“越界收集”。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;依赖收集必须建立在当前确实存在 &lt;code&gt;activeEffect&lt;/code&gt; 的前提下。脱离副作用函数的读取操作不能被随意收集，否则可能产生无效依赖、内存泄漏或调用报错。&lt;/p&gt;
&lt;h2&gt;ref 的实现&lt;/h2&gt;
&lt;p&gt;对于基本数据类型（如字符串、数字等），由于它们不是对象，我们无法给它们应用 &lt;code&gt;Proxy&lt;/code&gt; 代理。因此，Vue 的方案是用一个对象包裹它，这就是 &lt;code&gt;ref&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ref&lt;/code&gt; 的核心是利用了对象访问器（&lt;code&gt;Getter&lt;/code&gt; / &lt;code&gt;Setter&lt;/code&gt; 计算属性）来获取或设置 &lt;code&gt;.value&lt;/code&gt; 的值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function ref(raw) {
  const r = {
    get value() {
      track(r, &apos;value&apos;); // 收集依赖
      return raw;
    },
    set value(newVal) {
      if (raw !== newVal) {
        raw = newVal;
        trigger(r, &apos;value&apos;); // 派发更新
      }
    }
  };
  return r;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;computed 的实现&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;function computed(getter) {
  let result = ref();

  // 每次 getter 里的依赖数据变化时，effect 都会重新执行，给 result.value 赋予新计算的值
  effect(() =&amp;gt; {
    result.value = getter();
  });

  return result;
}

let product = reactive({ price: 5, quantity: 2 });

let salePrice = computed(() =&amp;gt; {
  return product.price * 0.9;
});

let total = computed(() =&amp;gt; {
  return salePrice.value * product.quantity;
});

console.log(total.value); // 此时由于依赖响应式数据改变，总价会自动重算
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：源码实际上的处理方式（基于 &lt;code&gt;ComputedRefImpl&lt;/code&gt;）是真正的 &lt;code&gt;computed&lt;/code&gt; 是一个带有懒计算标志位（&lt;code&gt;_dirty&lt;/code&gt;）的对象。&lt;/p&gt;
&lt;p&gt;依赖发生改变时，它并不立即运算求新值，而是通过传入 &lt;code&gt;scheduler&lt;/code&gt; 调度器把 &lt;code&gt;_dirty&lt;/code&gt; 标记为 &lt;code&gt;true&lt;/code&gt;（脏数据），这相当于告诉 Vue：“我的值已经过期了”。&lt;/p&gt;
&lt;p&gt;只有当你再次读取 &lt;code&gt;salePrice.value&lt;/code&gt; 触发访问时，Vue 发现 &lt;code&gt;_dirty&lt;/code&gt; 为 &lt;code&gt;true&lt;/code&gt;，才会重新调用 &lt;code&gt;getter()&lt;/code&gt; 执行真正的计算，算出新值后存起来并将 &lt;code&gt;_dirty&lt;/code&gt; 置为 &lt;code&gt;false&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这期间如果没有依赖发生变更（&lt;code&gt;_dirty&lt;/code&gt; 依然是 &lt;code&gt;false&lt;/code&gt;），它会一直直接返回上次计算缓存的值，绝不重复运算。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;简化版 &lt;code&gt;computed&lt;/code&gt; 可以用 &lt;code&gt;ref&lt;/code&gt; 和 &lt;code&gt;effect&lt;/code&gt; 拼出来；源码里的 &lt;code&gt;computed&lt;/code&gt; 还会通过 &lt;code&gt;_dirty&lt;/code&gt; 和 &lt;code&gt;scheduler&lt;/code&gt; 做懒计算与缓存，避免每次读取都重复执行 &lt;code&gt;getter()&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;Vue 3 响应式系统可以按一条主线理解：用 &lt;code&gt;Proxy&lt;/code&gt; 拦截对象的读取和修改，在读取时通过 &lt;code&gt;track&lt;/code&gt; 收集当前 &lt;code&gt;activeEffect&lt;/code&gt;，在修改时通过 &lt;code&gt;trigger&lt;/code&gt; 找到并重新执行对应的副作用函数。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ref&lt;/code&gt; 解决的是基本数据类型不能直接被 &lt;code&gt;Proxy&lt;/code&gt; 代理的问题；&lt;code&gt;computed&lt;/code&gt; 则是在响应式依赖之上加入计算、缓存和懒更新机制。理解这几层关系后，再看 &lt;code&gt;reactive&lt;/code&gt;、&lt;code&gt;ref&lt;/code&gt;、&lt;code&gt;computed&lt;/code&gt; 的行为，就能更容易串起 Vue 3 响应式的整体流程。&lt;/p&gt;
</content:encoded></item><item><title>RAG 知识库搭建经验总结</title><link>https://blog.moxiaoshuai.fun/2026-05-rag%E7%9F%A5%E8%AF%86%E5%BA%93%E6%90%AD%E5%BB%BA-rag%E7%9F%A5%E8%AF%86%E5%BA%93%E6%90%AD%E5%BB%BA/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-05-rag%E7%9F%A5%E8%AF%86%E5%BA%93%E6%90%AD%E5%BB%BA-rag%E7%9F%A5%E8%AF%86%E5%BA%93%E6%90%AD%E5%BB%BA/</guid><description>记录一次搭建 RAG 知识库的经历。</description><pubDate>Thu, 21 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;这篇文章复盘了一套 RAG 知识库从分片、向量化、召回、重排到生成的落地过程，并整理了前后端技术选型、业务链路和实际踩坑。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%8C%E6%99%AF%E4%B8%8E%E9%97%AE%E9%A2%98&quot;&gt;背景与问题&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%85%B3%E9%94%AE%E6%80%9D%E8%B7%AF&quot;&gt;关键思路&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%AE%9E%E8%B7%B5%E8%BF%87%E7%A8%8B&quot;&gt;实践过程&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%BB%93%E6%9E%9C%E4%B8%8E%E5%A4%8D%E7%9B%98&quot;&gt;结果与复盘&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;背景与问题&lt;/h2&gt;
&lt;p&gt;标准 RAG（检索增强生成）的核心流程可以概括为：&lt;strong&gt;分片 -&amp;gt; 索引 -&amp;gt; 召回 -&amp;gt; 重排 -&amp;gt; 生成&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在这个流程里，系统会先把文章或资料切成 Chunk，再通过 Embedding 模型将每个片段转换为向量，并存入向量数据库。用户提问时，问题本身也会被转换为向量，系统再从向量数据库中检索出语义最相近的 $n$ 个片段。&lt;/p&gt;
&lt;p&gt;接下来，系统会用 Cross-encoder 模型对召回结果进行重排，筛选出相关度最高的 $m$ 个片段。最后，将用户问题和这些高相关片段一并输入给大模型，生成最终回答。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;RAG 的本质是把“可靠资料检索”接入大模型生成链路，本质就是上下文工程，给大模型提供参考资料。&lt;/p&gt;
&lt;h2&gt;关键思路&lt;/h2&gt;
&lt;p&gt;这次项目的后端用 Node.js / Hono，前端用 Vue3，向量数据库基于 &lt;strong&gt;Docker + PGVector&lt;/strong&gt; 搭建，AI 协调控制技术栈选择 &lt;strong&gt;LangChain + Vercel AI SDK&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它们在系统中的职责划分如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PGVector&lt;/strong&gt;：作为向量数据库，利用 Docker 快速容器化部署 PostgreSQL，并加载 PGVector 插件，提供本地化向量存储和相似度匹配服务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LangChain.js&lt;/strong&gt;：负责后端的数据加载、文本切片、文档对象构建，以及向向量数据库发起检索。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel AI SDK&lt;/strong&gt;：负责大模型调用、流式数据通讯和前端 UI 状态接管，减轻前端处理流式响应的负担。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;为什么需要 Vercel AI SDK&lt;/h3&gt;
&lt;p&gt;如果不使用 Vercel AI SDK，在 Vue3 中手工开发 AI 对话界面会遇到不少重复问题。&lt;/p&gt;
&lt;p&gt;首先是 SSE 或 ReadableStream 的解析。后端返回的通常不是完整 JSON，而是连续的数据流，前端需要手动处理 Chunk 解码、字符串拼接和异常状态。&lt;/p&gt;
&lt;p&gt;其次是打字机效果。每次收到数据碎片，都要手动更新消息数组；如果渲染节奏控制不好，很容易引发界面卡顿或频繁重绘。&lt;/p&gt;
&lt;p&gt;最后是状态管理。&lt;code&gt;isLoading&lt;/code&gt;、停止生成、重新生成、上下文消息数组等状态都需要手动维护，代码很容易变得松散。&lt;/p&gt;
&lt;p&gt;Vercel AI SDK 在 Vue 项目中可以通过 &lt;code&gt;@ai-sdk/vue&lt;/code&gt; 接管这些状态。UI 直接循环渲染 &lt;code&gt;messages&lt;/code&gt;，输入框绑定 &lt;code&gt;input&lt;/code&gt;，提交绑定 &lt;code&gt;handleSubmit&lt;/code&gt;，加载状态绑定 &lt;code&gt;isLoading&lt;/code&gt;，整体实现会轻很多。&lt;/p&gt;
&lt;p&gt;它也支持生成式结构化输出（Generative UI）。当需要 AI 返回“目标、热量、餐次”等结构化 JSON 数据时，SDK 可以在接收碎片流期间逐步处理内容，甚至驱动前端生成动态交互组件。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;后端用 LangChain 管检索链路，底层数据的向量存储交由 PGVector，前端和模型流式交互交给 Vercel AI SDK。这样分工后，系统边界清晰，前后端都不用重复处理多余的组件底座工作。&lt;/p&gt;
&lt;h2&gt;实践过程&lt;/h2&gt;
&lt;p&gt;在具体落地时，LangChain 服务端的各个核心组件（Loaders、Splitters、Embeddings、Vector Stores）并没有通过 &lt;code&gt;RunnableSequence&lt;/code&gt; 强行声明式串联，而是分散在导入脚本和 RAG 检索层中进行了灵活组装。&lt;/p&gt;
&lt;h3&gt;步骤1：业务链路与数据融合&lt;/h3&gt;
&lt;p&gt;在基础 RAG 问答能力之上，这个项目重点融合了用户本地数据。整体处理链路分为四步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;读取本地用户数据&lt;/strong&gt;：通过 SQL 从浏览器 IndexedDB 中读取身高、体重、近期体重变化、热量摄入等属性数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;知识库向量检索&lt;/strong&gt;：将用户问题向量化，在 PGVector 中检索相关的专业知识内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;融合提示词&lt;/strong&gt;：把“用户本地私有数据”、“向量库返回的专业资料”和“用户原始问题”汇总并拼装成完整的 Prompt。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;大模型生成&lt;/strong&gt;：后端将组装好的 Prompt 发给模型，然后利用 Vercel AI SDK 将数据平滑流式地返回给前端展示。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这条链路的重点突破：本地系统数据解决“个性化背景限制”，知识库检索解决“干货专业依据缺乏”，这两只手抓稳后双管齐下交由大模型统筹整合。&lt;/p&gt;
&lt;h3&gt;步骤2：数据加载与切块（Loaders &amp;amp; Splitters）&lt;/h3&gt;
&lt;p&gt;首先，需要从源文件（例如 PDF）中提取文本，并使用 &lt;code&gt;Document Loaders&lt;/code&gt; 转换为 LangChain 的 &lt;code&gt;Document&lt;/code&gt;。在此步骤我们同时注入了页码、书名等 Metadata，为的是日后给解答提供清晰的引用原出处：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { Document } from &apos;@langchain/core/documents&apos;

export const createKnowledgeDocumentsFromPages = ({ filePath, pages }: CreateKnowledgeDocumentsOptions) =&amp;gt; {
  const bookTitle = inferBookTitleFromFileName(filePath)

  return pages.flatMap(({ pageNumber, text }) =&amp;gt; {
    const pageContent = cleanKnowledgeText(text)

    return [
      new Document({
        metadata: { bookTitle, pageNumber, source: filePath },
        pageContent,
      }),
    ]
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;针对整块长文档，项目接着使用了分块器 &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt; 进行更小粒度的切割（Splitters）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new RecursiveCharacterTextSplitter({
  chunkOverlap: 300,
  chunkSize: 1500,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里限制每个文本块约保留 &lt;code&gt;1500&lt;/code&gt; 字符，冗余重叠 &lt;code&gt;300&lt;/code&gt; 字符。保留重叠字符是常见但也特别必须的做法，能有效减少连续语义段落正好被“一刀两断”从而在检索中匹配度过低的问题。&lt;/p&gt;
&lt;h3&gt;步骤3：向量化与入库（Embeddings &amp;amp; Vector Stores）&lt;/h3&gt;
&lt;p&gt;切分完毕后，再次利用 &lt;code&gt;OpenAIEmbeddings&lt;/code&gt; 以及 &lt;code&gt;PGVectorStore&lt;/code&gt; 发起 Embedding 从而在向量库（Vector Stores）进行落库存储。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { PGVectorStore } from &apos;@langchain/community/vectorstores/pgvector&apos;
import { OpenAIEmbeddings } from &apos;@langchain/openai&apos;

export const createKnowledgeVectorStore = async () =&amp;gt; {
  const { collectionName, connectionString, tableName } = getRagRuntimeConfig()

  return PGVectorStore.initialize(new OpenAIEmbeddings({...}), {
    collectionName,
    collectionTableName: `${tableName}_collections`,
    postgresConnectionOptions: { connectionString },
    tableName,
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际通过脚本批量写入时，必须使用分块批处理循环写入以控制节奏：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (let index = 0; index &amp;lt; chunks.length; index += batchSize) {
  await vectorStore.addDocuments(chunks.slice(index, index + batchSize))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;避坑踩雷点&lt;/strong&gt;：在起初导入时遇到了大量的超速失败返回，我最终把 &lt;code&gt;batchSize&lt;/code&gt; 强行从官方默认经验值的 50 压到了 10 才安全导入完成避免接口速率限制断连。&lt;/p&gt;
&lt;h3&gt;步骤4：检索召回流（Retriever Pipeline）&lt;/h3&gt;
&lt;p&gt;这一步，当用户提问时，我们会将上面初始化的 &lt;code&gt;PGVectorStore&lt;/code&gt; 实例转为正式的检索器（Retriever），并调用 &lt;code&gt;.invoke&lt;/code&gt; 向模型发起近似语句匹配。这也是虽然没用显式 &lt;code&gt;Runnable 管道&lt;/code&gt;串联，但这完全执行了相同手撕控制流的地方。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const createKnowledgeRetriever = async () =&amp;gt; {
  const { k } = getRagRuntimeConfig()
  const vectorStore = await createKnowledgeVectorStore()

  return vectorStore.asRetriever({ k })
}

export const getKnowledgeContext = async (question: string, {...}) =&amp;gt; {
  const retriever = await createRetriever()
  const documents = await retriever.invoke(question)

  return formatKnowledgeContext(documents)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;代码中的 &lt;code&gt;k&lt;/code&gt; 代表你要让他在库里回捞排名前几最相似的资料条（设为 5）。不能盲目求多：如果召回偏少固然会错失某些要点，但无限度扩大后更会引发严重的文不对题噪音，而且直接把一堆没用的废话喂给 LLM 会消耗大量不必要的 Token 计费。
这一个收尾逻辑，实现了真正的 RAG Pipeline 运作链路过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;存入流：PDF原始数据 -&amp;gt; Loaders转为Document -&amp;gt; Splitters分块切分 -&amp;gt; Embeddings化 -&amp;gt; PGVector存储
检索流：提问触发请求 -&amp;gt; retriever召回最相似片段 -&amp;gt; 附并私有数据合并出终极Prompt -&amp;gt; 交给聊天大模型 -&amp;gt; 前端流式收尾展示
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;回顾这个实践脉络会发现：数据层囊括了原文档解析与 Embedding 赋能，检索层揽收下了文本找回与大锅烩操作，再借由大模型与 Vercel 的生成能力构成生成展示层，由此跑通了一个有逻辑的完整 RAG 工作流架构。&lt;/p&gt;
&lt;h2&gt;结果与复盘&lt;/h2&gt;
&lt;h3&gt;PDF 解析问题&lt;/h3&gt;
&lt;p&gt;一开始下的书籍是 PDF 版本，用 &lt;code&gt;pdf-parse&lt;/code&gt; 转文本后发现很多书是空页。问ai才知道需要 OCR 处理的版本。然后又去网上找了 OCR 版本的 PDF 才成功导入。&lt;/p&gt;
&lt;h3&gt;批量导入过大&lt;/h3&gt;
&lt;p&gt;向量化导入数据库时，把每次导入的 Batch Size 从默认的 50 调小到 10，才没有触发 Embedding 接口速率限制。&lt;/p&gt;
&lt;h3&gt;结构化输出&lt;/h3&gt;
&lt;p&gt;最初允许 AI 一次生成任意时长的计划，但后来发现当计划超过 14 天时，输出规模会变得过大，后段很容易遭遇内容截断或 JSON 格式错误。&lt;/p&gt;
&lt;p&gt;解决方式有三点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从产品侧限制单次最多生成 1-14 天的计划。&lt;/li&gt;
&lt;li&gt;使用 JSON output 属性，增加预置 JSON 结构的 Prompt 要求。&lt;/li&gt;
&lt;li&gt;增加强验证机制。如果系统校验到错乱 JSON，就拒绝输出结果，并把错误输出和标准示例再次交给 AI 重整。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;TDD&lt;/h3&gt;
&lt;p&gt;这次实践也让我更深刻地认识到 TDD（测试驱动开发）的重要性。现在已经通过提示词限制，例如 Codex 的 &lt;code&gt;agent.md&lt;/code&gt;，强制要求 AI 每次动及源码时都遵循 TDD 模式开发。&lt;/p&gt;
&lt;p&gt;也就是：&lt;strong&gt;先写预期失败的前置测试 -&amp;gt; 编写业务逻辑 -&amp;gt; 本地测试通过后才视为完成&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这种约束方式配合全量回归测试，可以给后续迭代划定明确边界，也能最大程度降低 AI 修改新逻辑时悄悄破坏老功能的风险。&lt;/p&gt;
</content:encoded></item><item><title>从手动上传到自动发布：我给项目接入 CI/CD 的完整复盘</title><link>https://blog.moxiaoshuai.fun/2026-05-%E6%8C%81%E7%BB%AD%E9%9B%86%E6%88%90%E4%B8%8E%E6%8C%81%E7%BB%AD%E4%BA%A4%E4%BB%98-%E7%BB%99%E9%A1%B9%E7%9B%AE%E6%B7%BB%E5%8A%A0%E8%87%AA%E5%8A%A8%E9%83%A8%E7%BD%B2%E5%B7%A5%E4%BD%9C%E6%B5%81/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-05-%E6%8C%81%E7%BB%AD%E9%9B%86%E6%88%90%E4%B8%8E%E6%8C%81%E7%BB%AD%E4%BA%A4%E4%BB%98-%E7%BB%99%E9%A1%B9%E7%9B%AE%E6%B7%BB%E5%8A%A0%E8%87%AA%E5%8A%A8%E9%83%A8%E7%BD%B2%E5%B7%A5%E4%BD%9C%E6%B5%81/</guid><description>复盘一次从手动部署迁移到 GitHub Actions 自动发布的完整过程，以及 1Panel、OpenResty、软链接和 PWA 缓存带来的真实排查经验。</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;这篇文章复盘了我把一个 Vue + Vite + PWA 项目从手动上传改造成 GitHub Actions 自动发布的全过程。真正有价值的部分，不只是部署自动化本身，而是如何把发布链路变成一个可重复、可验证、可排查的流程。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%8C%E6%99%AF%E4%B8%8E%E9%97%AE%E9%A2%98&quot;&gt;背景与问题&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%80%E7%BB%88%E6%96%B9%E6%A1%88&quot;&gt;最终方案&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%AE%9E%E8%B7%B5%E8%BF%87%E7%A8%8B&quot;&gt;实践过程&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%B8%A9%E5%9D%91%E5%A4%8D%E7%9B%98&quot;&gt;踩坑复盘&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%A2%9E%E9%87%8F%E9%83%A8%E7%BD%B2%E7%9A%84%E5%AE%9E%E7%8E%B0&quot;&gt;增量部署的实现&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%B7%91%E9%80%9A%E5%90%8E%E7%9A%84%E5%AE%8C%E6%95%B4%E9%93%BE%E8%B7%AF&quot;&gt;跑通后的完整链路&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%90%8E%E7%BB%AD%E4%BC%98%E5%8C%96&quot;&gt;后续优化&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;背景与问题&lt;/h2&gt;
&lt;p&gt;这次折腾 CI/CD，起点其实很简单：我不想每次改完网站后，还要一遍遍手动上传。&lt;/p&gt;
&lt;p&gt;原来的流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;本地改完代码。&lt;/li&gt;
&lt;li&gt;手动上传到 GitHub。&lt;/li&gt;
&lt;li&gt;再手动上传到腾讯云。&lt;/li&gt;
&lt;li&gt;再手动刷新网站。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果只是偶尔改一次，这样做还能忍。但一旦进入频繁迭代阶段，这套流程会越来越烦，而且非常容易出错。&lt;/p&gt;
&lt;p&gt;所以这次的目标很明确：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;以后只要 &lt;code&gt;git push&lt;/code&gt;，网站就自动检查、自动打包、自动上传、自动切到新版本。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;这次改造主要是为了减少手动发布带来的重复劳动。&lt;/p&gt;
&lt;h2&gt;最终方案&lt;/h2&gt;
&lt;p&gt;最终跑通的链路是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;本地改代码，提交到 GitHub。&lt;/li&gt;
&lt;li&gt;GitHub Actions 自动执行安装依赖、类型检查、测试和打包。&lt;/li&gt;
&lt;li&gt;GitHub Actions 通过 SSH 把打包结果传到 Lighthouse。&lt;/li&gt;
&lt;li&gt;服务器执行发布脚本，把文件解压到新的版本目录。&lt;/li&gt;
&lt;li&gt;服务器把 &lt;code&gt;current&lt;/code&gt; 指向这个新版本。&lt;/li&gt;
&lt;li&gt;OpenResty 继续对外提供网站。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对应到仓库里，关键文件有两个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;yml工作流文件&lt;/li&gt;
&lt;li&gt;OpenResty 配置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单说，这套方案的核心思想就是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub 负责“打包和上传”，服务器负责“接收和切换版本”。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我的线上环境是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;腾讯云 Lighthouse&lt;/li&gt;
&lt;li&gt;1Panel&lt;/li&gt;
&lt;li&gt;OpenResty&lt;/li&gt;
&lt;li&gt;Vue + Vite + PWA 前端项目&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这意味着我不是纯静态托管平台，也不是 Docker 编排方案。我已经有一台稳定运行的网站服务器，所以最合适的方式不是重新迁移平台，而是保留 Lighthouse + 1Panel + OpenResty，只把“手动发布”改成“自动发布”。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;这次方案的关键不是引入更多基础设施，而是在现有环境上补齐自动化发布能力，让 GitHub Actions 和服务器各自负责清晰的一段。&lt;/p&gt;
&lt;h2&gt;实践过程&lt;/h2&gt;
&lt;h3&gt;步骤 1：调整服务器目录结构&lt;/h3&gt;
&lt;p&gt;以前网站文件直接放在一个固定目录里，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/opt/1panel/www/sites/calorie/index
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为了支持自动发布和多版本切换，我把目录改成了这种结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/opt/1panel/www/sites/calorie/
  index
  releases/
    first/
    1bc469d390d5d8f2094763ce9273f05aef7e1d59/
  current -&amp;gt; releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最关键的是两个概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;releases/&lt;/code&gt;：保存每次发布后的版本目录。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;current&lt;/code&gt;：当前线上正在使用的版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样以后每次发布，都只是新建一个版本目录，把新文件解压进去，再把 &lt;code&gt;current&lt;/code&gt; 切过去。&lt;/p&gt;
&lt;h3&gt;步骤 2：让 OpenResty 读取 &lt;code&gt;current&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;服务器这边不是系统自带 Nginx，而是 1Panel 管理的 OpenResty。&lt;/p&gt;
&lt;p&gt;最后生效的思路是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;宿主机路径用 &lt;code&gt;/opt/1panel/...&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;OpenResty 配置里的路径用 &lt;code&gt;/www/sites/...&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OpenResty 配置里核心是这两句：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root /www/sites/calorie/current;
index index.html;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此外，Vue Router 我使用的history路由模式，所以必须加上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location / {
    try_files $uri $uri/ /index.html;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;否则像 &lt;code&gt;/history&lt;/code&gt;、&lt;code&gt;/profile&lt;/code&gt; 这种页面，直接刷新就会 404。&lt;/p&gt;
&lt;h3&gt;步骤 3：编写服务器发布脚本&lt;/h3&gt;
&lt;p&gt;服务器上放了一个简单脚本，用来完成“解压新版本 + 切换 &lt;code&gt;current&lt;/code&gt;”。&lt;/p&gt;
&lt;p&gt;最终版本是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env bash
set -e

APP_DIR=&quot;/opt/1panel/www/sites/calorie&quot;
ARCHIVE_PATH=&quot;$1&quot;
VERSION_NAME=&quot;$2&quot;

RELEASE_DIR=&quot;$APP_DIR/releases/$VERSION_NAME&quot;

mkdir -p &quot;$RELEASE_DIR&quot;
tar -xzf &quot;$ARCHIVE_PATH&quot; -C &quot;$RELEASE_DIR&quot;
ln -sfn &quot;releases/$VERSION_NAME&quot; &quot;$APP_DIR/current&quot;
rm -f &quot;$ARCHIVE_PATH&quot;

cd &quot;$APP_DIR/releases&quot;
ls -1dt */ | tail -n +6 | xargs -r rm -rf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
这里最关键的一行是 &lt;code&gt;ln -sfn &quot;releases/$VERSION_NAME&quot; &quot;$APP_DIR/current&quot;&lt;/code&gt;。它必须使用相对路径软链接，不能写成指向宿主机绝对路径的软链接。
:::&lt;/p&gt;
&lt;h3&gt;步骤 4：生成 GitHub 用的 SSH 密钥&lt;/h3&gt;
&lt;p&gt;要让 GitHub 自动上传文件到 Lighthouse，就得让 GitHub 能通过 SSH 登录服务器。&lt;/p&gt;
&lt;p&gt;流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;本地生成一对专门用于部署的密钥。&lt;/li&gt;
&lt;li&gt;把公钥放到服务器的 &lt;code&gt;authorized_keys&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;把私钥放到 GitHub Secrets。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我在本地测试过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -i &quot;$env:USERPROFILE\.ssh\calorie_lighthouse&quot; root@服务器ip地址
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;能正常登录，就说明这一步成功。&lt;/p&gt;
&lt;h3&gt;步骤 5：配置 GitHub Secrets&lt;/h3&gt;
&lt;p&gt;在仓库的设置里添加这 4 个 Secrets：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LIGHTHOUSE_HOST&lt;/code&gt;：服务器的 IP 地址&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LIGHTHOUSE_PORT&lt;/code&gt;：服务器的 SSH 端口&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LIGHTHOUSE_USER&lt;/code&gt;：SSH 登录用户名&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LIGHTHOUSE_SSH_KEY&lt;/code&gt;：生成的私钥私钥内容&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::caution[小提醒]&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub 里放的是私钥文本。&lt;/li&gt;
&lt;li&gt;服务器里放的是公钥。&lt;/li&gt;
&lt;li&gt;不要把 &lt;code&gt;.pub&lt;/code&gt; 公钥文件填进 GitHub。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h3&gt;步骤 6：添加 GitHub Actions 工作流&lt;/h3&gt;
&lt;p&gt;工作流文件位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.github/workflows/deploy.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它做的事情是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;拉代码。&lt;/li&gt;
&lt;li&gt;安装依赖。&lt;/li&gt;
&lt;li&gt;跑 &lt;code&gt;pnpm type-check&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;跑 &lt;code&gt;pnpm test&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;跑 &lt;code&gt;pnpm build&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;dist&lt;/code&gt; 同步到服务器的新版本目录。&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;current&lt;/code&gt; 切到新版本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一开始我用的是 &lt;code&gt;tar -czf dist.tar.gz -C dist .&lt;/code&gt;，也就是先压缩，再上传，再解压。&lt;/p&gt;
&lt;p&gt;这个方案很直观，但如果站点里有大量图片、音频这类已经压缩过的静态资源，&lt;code&gt;tar.gz&lt;/code&gt; 的收益会很小。比如一个 699MB 的 &lt;code&gt;dist&lt;/code&gt;，压缩后可能仍然有 680MB 以上。&lt;/p&gt;
&lt;p&gt;后面我把上传方式改成了 &lt;code&gt;rsync&lt;/code&gt; 增量同步。核心命令是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Sync dist to server
  run: |
    rsync -az --delete \
      --link-dest=/opt/1panel/www/sites/calorie/current \
      -e &quot;ssh -i ~/.ssh/deploy_key -p ${{ secrets.LIGHTHOUSE_PORT }} -o ServerAliveInterval=30 -o ServerAliveCountMax=20&quot; \
      dist/ \
      &quot;${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}:/opt/1panel/www/sites/calorie/releases/${{ github.sha }}/&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的关键不是 &lt;code&gt;rsync&lt;/code&gt; 本身，而是 &lt;code&gt;--link-dest&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它会拿新版本目录和当前线上版本 &lt;code&gt;current&lt;/code&gt; 做比较：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;没变化的文件：直接硬链接上一版文件，不重新上传。&lt;/li&gt;
&lt;li&gt;有变化的文件：才从 GitHub Actions 传到服务器。&lt;/li&gt;
&lt;li&gt;服务器多出来的旧文件：通过 &lt;code&gt;--delete&lt;/code&gt; 清理掉。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;同步完成后，再执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Activate release
  run: |
    ssh -i ~/.ssh/deploy_key \
      -p &quot;${{ secrets.LIGHTHOUSE_PORT }}&quot; \
      &quot;${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}&quot; \
      &quot;cd /opt/1panel/www/sites/calorie &amp;amp;&amp;amp; test -f releases/${{ github.sha }}/index.html &amp;amp;&amp;amp; ln -sfn releases/${{ github.sha }} current &amp;amp;&amp;amp; cd releases &amp;amp;&amp;amp; ls -1dt */ | tail -n +6 | xargs -r rm -rf&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里先检查新版本目录里是否存在 &lt;code&gt;index.html&lt;/code&gt;，再切换 &lt;code&gt;current&lt;/code&gt;。这样即使同步中途失败，线上仍然停留在旧版本，不会切到半成品目录。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;整套实践过程可以拆成三层：GitHub Actions 负责构建和增量同步，服务器负责版本目录和软链接切换，OpenResty 负责读取当前版本并提供访问。&lt;/p&gt;
&lt;h2&gt;踩坑复盘&lt;/h2&gt;
&lt;p&gt;这部分是整次搭建里最有价值的部分。&lt;/p&gt;
&lt;h3&gt;坑 1：&lt;code&gt;pnpm/action-setup&lt;/code&gt; 版本冲突&lt;/h3&gt;
&lt;p&gt;第一次跑 GitHub Actions 时，卡在了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: Multiple versions of pnpm specified
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因是 &lt;code&gt;package.json&lt;/code&gt; 里已经有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;packageManager&quot;: &quot;pnpm@10.28.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但工作流里又写了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with:
  version: 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这相当于在同一个地方声明了两份版本信息。&lt;/p&gt;
&lt;p&gt;最后修法很简单：删除工作流里的 &lt;code&gt;version: 10&lt;/code&gt;，保留 &lt;code&gt;package.json&lt;/code&gt; 里的版本作为唯一来源。&lt;/p&gt;
&lt;h3&gt;坑 2：Lighthouse 上没有 &lt;code&gt;nginx&lt;/code&gt; 命令&lt;/h3&gt;
&lt;p&gt;一开始我想直接执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo: nginx: command not found
Failed to reload nginx.service: Unit nginx.service not found.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这并不是配置错了，而是因为这台服务器不是系统直接装的 Nginx，而是 1Panel 管理的 OpenResty。&lt;/p&gt;
&lt;p&gt;所以后面真正生效的操作方式不是 &lt;code&gt;systemctl reload nginx&lt;/code&gt;，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;通过 1Panel 修改站点配置。&lt;/li&gt;
&lt;li&gt;在 1Panel 里点击“重载”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;坑 3：宿主机路径和 OpenResty 容器路径不是一回事&lt;/h3&gt;
&lt;p&gt;这是本次最大的坑。&lt;/p&gt;
&lt;p&gt;我一开始以为网站真实目录是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/opt/1panel/www/sites/calorie/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 OpenResty 配置里也写成了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root /opt/1panel/www/sites/calorie/current;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果网站一直 500。&lt;/p&gt;
&lt;p&gt;后面才发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在服务器 shell 里看到的是宿主机路径：&lt;code&gt;/opt/1panel/...&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;OpenResty 实际工作时看到的是容器里的路径：&lt;code&gt;/www/sites/...&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以正确做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;shell 命令里用 &lt;code&gt;/opt/1panel/...&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;OpenResty 配置里用 &lt;code&gt;/www/sites/...&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这类容器映射问题，如果不搞清楚，很容易一直以为“目录都在，为什么网站还是坏的”。&lt;/p&gt;
&lt;h3&gt;坑 4：&lt;code&gt;current&lt;/code&gt; 软链接用了绝对路径，导致 500&lt;/h3&gt;
&lt;p&gt;这次最关键的根因是：一开始我让 &lt;code&gt;current&lt;/code&gt; 指向了宿主机绝对路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;current -&amp;gt; /opt/1panel/www/sites/calorie/releases/xxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来没问题，但 OpenResty 读这个链接时会顺着跳到 &lt;code&gt;/opt/1panel/...&lt;/code&gt;，而对它来说那个路径并不存在。&lt;/p&gt;
&lt;p&gt;于是它不断 fallback 到 &lt;code&gt;/index.html&lt;/code&gt;，最后在日志里出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rewrite or internal redirection cycle while internally redirecting to &quot;/index.html&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后修法是把 &lt;code&gt;current&lt;/code&gt; 改成相对路径软链接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;current -&amp;gt; releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而不是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;current -&amp;gt; /opt/1panel/www/sites/calorie/releases/...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改完后，网站立刻恢复正常。&lt;/p&gt;
&lt;h3&gt;坑 5：浏览器显示旧页面，不代表服务器正常&lt;/h3&gt;
&lt;p&gt;这是一个很迷惑人的现象。&lt;/p&gt;
&lt;p&gt;GitHub Actions 明明已经成功了，服务器里的 &lt;code&gt;current&lt;/code&gt; 也已经切到了新版本，但浏览器里看起来网站“完全没变”。&lt;/p&gt;
&lt;p&gt;最后排查发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务器真实响应其实已经 500 了。&lt;/li&gt;
&lt;li&gt;浏览器里显示的是旧的 PWA 缓存页面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为这个项目开了 PWA：&lt;/p&gt;
&lt;p&gt;这意味着电脑和手机都可能继续显示旧缓存。哪怕服务器已经挂了，用户表面上也可能还能打开旧页面。&lt;/p&gt;
&lt;p&gt;:::warning
排查线上问题时，应该先看服务器真实返回，再看浏览器表现。浏览器页面能打开，不等于服务器当前状态一定正常。
:::&lt;/p&gt;
&lt;p&gt;像这次，如果先执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I https://your-domain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就能很快发现服务器返回的是 &lt;code&gt;500&lt;/code&gt;，而不是继续围着“为什么页面没更新”打转。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;这次踩坑集中在三类问题上：工具链配置重复、服务环境与预期不一致、浏览器缓存掩盖真实状态。真正节省时间的不是反复试，而是尽快确认每一层的真实状态。&lt;/p&gt;
&lt;h2&gt;增量部署的实现&lt;/h2&gt;
&lt;p&gt;增量部署这件事，表面上看是“少传点文件”，但真正要解决的是两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;怎么判断哪些文件没变。&lt;/li&gt;
&lt;li&gt;怎么保证同步失败时不影响线上版本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最终我采用的是 &lt;code&gt;rsync + --link-dest + current 软链接&lt;/code&gt; 这套组合。&lt;/p&gt;
&lt;h3&gt;为什么不是继续用压缩包&lt;/h3&gt;
&lt;p&gt;压缩包方案的链路是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dist -&amp;gt; dist.tar.gz -&amp;gt; scp 到服务器 -&amp;gt; tar 解压 -&amp;gt; 切 current
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的优点是简单，所有文件都在一个包里，服务器只要解压就行。&lt;/p&gt;
&lt;p&gt;但它有一个明显缺点：每次都是全量上传。&lt;/p&gt;
&lt;p&gt;如果项目产物主要是 HTML、CSS、JS，&lt;code&gt;tar.gz&lt;/code&gt; 的压缩收益通常还不错。但如果项目里有大量 JPG、PNG、MP3 这类文件，它们本身已经压缩过了，再套一层 gzip 基本省不了多少体积。&lt;/p&gt;
&lt;p&gt;所以当 &lt;code&gt;dist&lt;/code&gt; 里有几百 MB 的相册图片时，压缩包部署就会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;每次 push，都重新上传几百 MB。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这会让 GitHub Actions 到服务器之间的网络抖动被无限放大。上传一旦中断，服务器上还可能留下一个不完整的 &lt;code&gt;.tar.gz&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;rsync --link-dest&lt;/code&gt; 怎么做到增量&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;rsync&lt;/code&gt; 本身可以比较源目录和目标目录，只传变化部分。&lt;/p&gt;
&lt;p&gt;但如果每次发布都同步到一个全新的 commit 目录，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;releases/e25e77d64d7eaac0dc81134f502456acade290de/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个目录一开始是空的，&lt;code&gt;rsync&lt;/code&gt; 仍然会认为所有文件都需要传一遍。&lt;/p&gt;
&lt;p&gt;真正让它变成增量的是 &lt;code&gt;--link-dest&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rsync -az --delete \
  --link-dest=/opt/1panel/www/sites/calorie/current \
  dist/ \
  root@服务器:/opt/1panel/www/sites/calorie/releases/&amp;lt;commit-sha&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行命令的意思是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;生成一个新的 release 目录，但如果某个文件和 &lt;code&gt;current&lt;/code&gt; 里的文件完全一样，就不要重新上传，而是在新 release 里创建一个指向旧文件 inode 的硬链接。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这样看起来每个 release 都是完整目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;releases/
  old-sha/
  new-sha/
current -&amp;gt; releases/new-sha
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但磁盘和网络层面并不是每次都复制一整份。没变的大图、音频、字体文件，会直接复用上一版。&lt;/p&gt;
&lt;h3&gt;为什么还要保留 &lt;code&gt;current&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;current&lt;/code&gt; 不只是给 OpenResty 读取当前版本，它还是增量部署的参照物。&lt;/p&gt;
&lt;p&gt;部署时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;rsync&lt;/code&gt; 参考 &lt;code&gt;current&lt;/code&gt;，生成新的 release 目录。&lt;/li&gt;
&lt;li&gt;同步完成后，检查新目录里是否有 &lt;code&gt;index.html&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;检查通过后，才执行 &lt;code&gt;ln -sfn releases/&amp;lt;commit-sha&amp;gt; current&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;OpenResty 继续读取 &lt;code&gt;/www/sites/calorie/current&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这个顺序很重要。&lt;/p&gt;
&lt;p&gt;如果同步失败，&lt;code&gt;current&lt;/code&gt; 还指向旧版本，线上不会受影响。只有新版本完整同步成功后，才会切换流量。&lt;/p&gt;
&lt;h3&gt;和压缩包部署相比，它解决了什么&lt;/h3&gt;
&lt;p&gt;增量部署解决了三个实际问题。&lt;/p&gt;
&lt;p&gt;第一，上传量变小。&lt;/p&gt;
&lt;p&gt;文章、样式、小脚本改动时，只需要传少量变化文件。几百 MB 的相册图片不会每次重新上传。&lt;/p&gt;
&lt;p&gt;第二，失败影响更小。&lt;/p&gt;
&lt;p&gt;同步失败时，新 release 最多是半成品，但 &lt;code&gt;current&lt;/code&gt; 没切过去，线上仍然是旧版本。&lt;/p&gt;
&lt;p&gt;第三，回滚方式不变。&lt;/p&gt;
&lt;p&gt;因为最终结构仍然是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;current -&amp;gt; releases/&amp;lt;version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以回滚还是同一套命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ln -sfn releases/&amp;lt;old-version&amp;gt; current
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;增量部署只是改变“新 release 怎么生成”，没有改变“线上版本怎么切换”。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;增量部署的核心不是简单地把 &lt;code&gt;scp&lt;/code&gt; 换成 &lt;code&gt;rsync&lt;/code&gt;，而是让 &lt;code&gt;rsync&lt;/code&gt; 通过 &lt;code&gt;--link-dest=current&lt;/code&gt; 复用上一版文件，再用 &lt;code&gt;current&lt;/code&gt; 软链接保证发布切换的原子性。这样既减少传输量，也保留了多版本发布和快速回滚的能力。&lt;/p&gt;
&lt;h2&gt;跑通后的完整链路&lt;/h2&gt;
&lt;p&gt;到最后，整套发布链路是这样工作的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;本地改代码。&lt;/li&gt;
&lt;li&gt;提交到 &lt;code&gt;main&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;GitHub Actions 自动运行。&lt;/li&gt;
&lt;li&gt;通过检查、测试、构建后，把 &lt;code&gt;dist/&lt;/code&gt; 增量同步到 Lighthouse 的新 release 目录。&lt;/li&gt;
&lt;li&gt;服务器确认新 release 存在 &lt;code&gt;index.html&lt;/code&gt;，再把 &lt;code&gt;current&lt;/code&gt; 切到新版本。&lt;/li&gt;
&lt;li&gt;OpenResty 继续从 &lt;code&gt;/www/sites/calorie/current&lt;/code&gt; 对外提供网站。&lt;/li&gt;
&lt;li&gt;网站更新完成。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这次成功的证据是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub Actions 成功。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deploy.sh&lt;/code&gt; 存在且可执行。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;current&lt;/code&gt; 指向最新提交目录。&lt;/li&gt;
&lt;li&gt;网站恢复正常访问。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这次经历里，我最终总结出几个经验。&lt;/p&gt;
&lt;p&gt;第一，先跑通最短链路，不要一开始追求完美。&lt;/p&gt;
&lt;p&gt;一开始我其实想做得很“高级”：版本目录、自动切换、可回滚、兼容 1Panel 都要一起上。结果越是这样，越容易踩环境差异坑。&lt;/p&gt;
&lt;p&gt;更稳的做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先做最短可用链路。&lt;/li&gt;
&lt;li&gt;每一步都单独验证。&lt;/li&gt;
&lt;li&gt;哪一步坏了就只查那一步。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二，服务器“看到目录”不等于服务“能读取目录”。&lt;/p&gt;
&lt;p&gt;宿主机上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls -l /opt/1panel/www/sites/calorie
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到一切正常，不代表 OpenResty 真能按同样路径读取。&lt;/p&gt;
&lt;p&gt;以后碰到 1Panel、Docker、容器环境时，第一反应应该是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我在 shell 里看到的路径，和服务进程看到的路径，是不是同一个？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;第三，浏览器看到的页面，不一定是真实线上状态。&lt;/p&gt;
&lt;p&gt;PWA、Service Worker、浏览器缓存都会让你误判。&lt;/p&gt;
&lt;p&gt;以后如果怀疑“网站没更新”，应该先执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I 域名
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先看真实响应是 &lt;code&gt;200&lt;/code&gt;、&lt;code&gt;404&lt;/code&gt; 还是 &lt;code&gt;500&lt;/code&gt;。如果浏览器还能打开，但 &lt;code&gt;curl&lt;/code&gt; 是 &lt;code&gt;500&lt;/code&gt;，那大概率就是缓存干扰了判断。&lt;/p&gt;
&lt;p&gt;第四，相对路径软链接在这种场景下比绝对路径更稳。&lt;/p&gt;
&lt;p&gt;这次问题最后其实就卡在一个很小的细节上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;绝对路径软链接：看上去明确，但跨宿主机和容器容易翻车。&lt;/li&gt;
&lt;li&gt;相对路径软链接：在同一挂载目录下更稳。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个坑看起来很小，但杀伤力非常大。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;跑通之后回看，CI/CD 的价值不只是自动上传文件，而是让发布过程中的每一步都有明确职责，也都有办法验证。&lt;/p&gt;
&lt;h2&gt;后续优化&lt;/h2&gt;
&lt;p&gt;虽然现在这套 CI/CD 已经能用了，但后面还有几件事可以继续优化。&lt;/p&gt;
&lt;h3&gt;1. 限制发布权限&lt;/h3&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;给 &lt;code&gt;main&lt;/code&gt; 分支加保护。&lt;/li&gt;
&lt;li&gt;不允许随便直接 push。&lt;/li&gt;
&lt;li&gt;至少减少误操作风险。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 不再直接用 &lt;code&gt;root&lt;/code&gt; 做部署&lt;/h3&gt;
&lt;p&gt;这次为了尽快打通链路，部署用户直接用了 &lt;code&gt;root&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;长期来说，更稳妥的方式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单独建一个部署用户。&lt;/li&gt;
&lt;li&gt;只给它必要权限。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 进一步处理 PWA 缓存更新体验&lt;/h3&gt;
&lt;p&gt;现在虽然项目已经有更新提示逻辑，但出了线上故障时，缓存也确实容易干扰判断。&lt;/p&gt;
&lt;p&gt;后面还可以继续优化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存更新策略。&lt;/li&gt;
&lt;li&gt;强制刷新提示。&lt;/li&gt;
&lt;li&gt;发布后的缓存验证方式。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;现在的链路已经可用，后续优化重点应该放在权限收敛、发布安全和缓存体验上，而不是继续堆复杂度。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次搭 CI/CD，表面上看只是“自动发布网站”，但实际踩到的问题非常真实：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CI 工具版本冲突。&lt;/li&gt;
&lt;li&gt;1Panel 和系统 Nginx 的差异。&lt;/li&gt;
&lt;li&gt;宿主机路径与容器路径不一致。&lt;/li&gt;
&lt;li&gt;绝对路径软链接带来的隐藏问题。&lt;/li&gt;
&lt;li&gt;PWA 缓存掩盖真实线上错误。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后真正让我把这套流程搞通的，不是“多改几次试试看”，而是这几个排查习惯：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先确认哪一层成功了，哪一层失败了。&lt;/li&gt;
&lt;li&gt;不拿浏览器现象当唯一依据。&lt;/li&gt;
&lt;li&gt;用日志和真实 HTTP 返回来判断问题。&lt;/li&gt;
&lt;li&gt;把环境差异搞清楚，而不是只看“目录在不在”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在回头看，这套 CI/CD 的价值不只是省掉手动上传，更重要的是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;它把发布过程变成了一个可重复、可验证、可排查的流程。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这才是自动化真正有意义的地方。&lt;/p&gt;
</content:encoded></item><item><title>深入理解浏览器的事件循环与异步机制</title><link>https://blog.moxiaoshuai.fun/2026-04-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-04-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF/</guid><description>探讨浏览器的多进程多线程模型，深入解析渲染主线程的工作原理、JavaScript 的异步实现以及事件循环的核心机制。</description><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;深入探讨浏览器的进程模型、渲染主线程的运作方式以及 JavaScript 中的异步与事件循环机制，这对于前端开发者来说是不可或缺的底层知识。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%B5%8F%E8%A7%88%E5%99%A8%E7%9A%84%E8%BF%9B%E7%A8%8B%E6%A8%A1%E5%9E%8B&quot;&gt;浏览器的进程模型&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%B8%B2%E6%9F%93%E4%B8%BB%E7%BA%BF%E7%A8%8B%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86&quot;&gt;渲染主线程的工作原理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3-javascript-%E7%9A%84%E5%BC%82%E6%AD%A5%E4%B8%8E%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF&quot;&gt;深入理解 JavaScript 的异步与事件循环&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%B0%8F%E7%BB%93&quot;&gt;小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;浏览器的进程模型&lt;/h2&gt;
&lt;p&gt;浏览器是一个&lt;strong&gt;多进程、多线程&lt;/strong&gt;的应用程序。进程与线程的关系是一对多。浏览器主要包含三个核心进程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;浏览器进程&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网络进程&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;渲染进程&lt;/strong&gt;（包含一个渲染主线程）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: 为什么渲染进程不使用多个线程来处理任务（如操作 DOM）？&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;DOM 线程不安全&lt;/strong&gt;：多线程同时修改 DOM 会导致不可预知的问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;锁的性能差&lt;/strong&gt;：为了保证线程安全而引入锁机制，会大幅降低渲染性能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JS 是单线程语言&lt;/strong&gt;：这决定了其执行环境必须是单线程的。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h2&gt;渲染主线程的工作原理&lt;/h2&gt;
&lt;p&gt;渲染主线程的生命周期可以概括为以下几个步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;无限循环&lt;/strong&gt;：主线程开启一个不会结束的 &lt;code&gt;for&lt;/code&gt; 循环。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;获取任务&lt;/strong&gt;：每次循环时，尝试从消息队列（任务队列）中取出一个任务执行。如果队列为空，主线程则进入休眠状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;添加任务&lt;/strong&gt;：其他线程在合适的时机将任务添加到消息队列的末尾，并唤醒处于休眠状态的主线程。&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;提示&lt;/strong&gt;：任务本身没有优先级之分，但&lt;strong&gt;任务队列有优先级&lt;/strong&gt;。在所有队列中，**微队列（Microtask Queue）**的优先级最高，必须优先调度执行。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;深入理解 JavaScript 的异步与事件循环&lt;/h2&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: 如何理解 JavaScript 的异步机制？&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt; JavaScript 是一门单线程语言，运行在浏览器唯一的渲染主线程中。如果采用同步方式执行耗时操作，会直接阻塞页面的渲染和交互。为了避免这种情况，浏览器采用了&lt;strong&gt;异步机制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;具体运作过程如下：
当发生诸如计时器（&lt;code&gt;setTimeout&lt;/code&gt;）、网络请求或事件监听等耗时任务时，主线程会将这些任务交由其他相关线程去处理，自身则立即结束该任务的执行，转而继续执行后续的同步代码。当其他线程完成处理后，会将预先传递的回调函数包装成一个任务，加入到消息队列的末尾排队，等待主线程的调度执行。&lt;/p&gt;
&lt;p&gt;在这种异步模式下，浏览器能够永不阻塞，从而最大程度地保证了单线程的流畅运行。
:::&lt;/p&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: 详细介绍一下浏览器的事件循环（Event Loop）&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt; 事件循环（或称消息循环）正是&lt;strong&gt;浏览器渲染主线程的核心工作方式&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 Chrome 的浏览器源码实现中，正是通过开启一个无尽的 &lt;code&gt;for&lt;/code&gt; 循环，不断从消息队列中取出首个任务执行。而其他线程只需将需要执行的回调任务追加至队列末尾。&lt;/p&gt;
&lt;p&gt;过去，我们常把消息队列简单地分为“宏队列”和“微队列”。然而，随着浏览器环境的日益复杂，这种简单的二分法已不再适用，取而代之的是一种更加灵活、精细的调度方式。&lt;/p&gt;
&lt;p&gt;根据 W3C 官方的解释：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个任务都具有不同的类型。&lt;/li&gt;
&lt;li&gt;同类型的任务必须放在同一个队列中。&lt;/li&gt;
&lt;li&gt;不同类型的任务可以属于不同的队列。&lt;/li&gt;
&lt;li&gt;不同的任务队列享有不同的优先级。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在每一次事件循环中，由浏览器自行决定从哪一个队列中取出任务执行。&lt;strong&gt;但是，浏览器必须包含一个微队列（Microtask Queue），并且微队列的任务始终具有最高的执行优先级，必须被优先调度和执行。&lt;/strong&gt;
:::&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“单线程是异步产生的原因，事件循环则是异步的具体实现方式。”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;理解这两者的关系与底层细节，是掌握 JavaScript 运行机制的重中之重。&lt;/p&gt;
</content:encoded></item><item><title>深入理解浏览器的渲染原理</title><link>https://blog.moxiaoshuai.fun/2026-04-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-04-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86/</guid><description>探讨浏览器的渲染原理，深入解析渲染主线程与合成线程的工作机制，以及 transform 高效的原因。</description><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我很喜欢这句话：浏览器，是人类历史上计算机与科学技术领域最伟大的工艺品之一。在它背后，你能真切地感受到顶尖科学家是如何优雅地解决复杂问题的。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%B5%8F%E8%A7%88%E5%99%A8%E7%9A%84%E6%B8%B2%E6%9F%93%E6%B5%81%E7%A8%8B&quot;&gt;浏览器的渲染流程&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%B8%B2%E6%9F%93%E4%B8%BB%E7%BA%BF%E7%A8%8B%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86&quot;&gt;渲染主线程的工作原理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%90%88%E6%88%90%E7%BA%BF%E7%A8%8B%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86&quot;&gt;合成线程的工作原理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%B0%8F%E7%BB%93&quot;&gt;小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;本文详细解析了浏览器从收到 HTML 文档到最终在屏幕上绘制出页面的全过程，涵盖了渲染主线程与合成线程的协同工作机制。&lt;/p&gt;
&lt;h2&gt;浏览器的渲染流程&lt;/h2&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: 浏览器是如何渲染页面的？&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt; 当浏览器的网络线程收到 HTML 文档后，会产生一个渲染任务，并将其传递给渲染主线程的消息队列。在事件循环机制的作用下，渲染主线程取出消息队列中的渲染任务，开启渲染流程。&lt;/p&gt;
&lt;p&gt;整个渲染流程分为多个阶段：&lt;strong&gt;HTML解析&lt;/strong&gt;、&lt;strong&gt;样式计算&lt;/strong&gt;、&lt;strong&gt;布局&lt;/strong&gt;、&lt;strong&gt;分层&lt;/strong&gt;、&lt;strong&gt;绘制&lt;/strong&gt;、&lt;strong&gt;分块&lt;/strong&gt;、&lt;strong&gt;光栅化&lt;/strong&gt;、&lt;strong&gt;画&lt;/strong&gt;。每个阶段都有明确的输入输出，上一个阶段的输出会成为下一个阶段的输入，形成了一套组织严密的生产流水线。
:::&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/QQ_1776327320238.Do3n39F6_Z24YBWP.webp&quot; alt=&quot;图1. 浏览器渲染过程全链路梳理&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;渲染主线程的工作原理&lt;/h2&gt;
&lt;p&gt;渲染主线程负责将 HTML 转换为带样式的 DOM 树，并生成绘制指令。（阶段 1-5）&lt;/p&gt;
&lt;h3&gt;1. HTML 解析 (Parse HTML)&lt;/h3&gt;
&lt;p&gt;从字符串到 DOM 树与 CSSOM 树。&lt;/p&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: CSS 会不会阻塞 HTML 解析？JS 呢？&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CSS 不会阻塞 HTML 解析&lt;/strong&gt;：如果主线程解析到 &lt;code&gt;link&lt;/code&gt; 位置，此时外部的 CSS 文件还没有下载解析好，主线程不会等待，而是继续解析后续的 HTML。这是因为下载和解析 CSS 的工作是在预解析线程中进行的。这就是 CSS 不会阻塞 HTML 解析的根本原因。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JS 会阻塞 HTML 解析&lt;/strong&gt;：如果主线程解析到 &lt;code&gt;script&lt;/code&gt; 位置，会停止解析 HTML，转而等待 JS 文件下载好，并将全局代码解析执行完成后，才能继续解析 HTML。这是因为 JS 代码的执行过程可能会修改当前的 DOM 树，所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h3&gt;2. 样式计算 (Recalculate Style)&lt;/h3&gt;
&lt;p&gt;从 DOM 树变成带样式的 DOM 树。&lt;/p&gt;
&lt;p&gt;遍历 DOM 树，依次为每个节点计算它的最终样式。在这一过程中，很多预设值会变成绝对值，比如 &lt;code&gt;red&lt;/code&gt; 会变成 &lt;code&gt;rgb(255,0,0)&lt;/code&gt;；相对单位会变成绝对单位（例如 &lt;code&gt;em&lt;/code&gt; 会变成 &lt;code&gt;px&lt;/code&gt;）。&lt;/p&gt;
&lt;h3&gt;3. 布局 (Layout)&lt;/h3&gt;
&lt;p&gt;带样式的 DOM 树变成布局树，计算元素的位置与几何信息。&lt;/p&gt;
&lt;h3&gt;4. 分层 (Layer)&lt;/h3&gt;
&lt;p&gt;产生图层。&lt;/p&gt;
&lt;p&gt;主线程会使用一套复杂的策略对整个布局树进行分层。分层的好处在于，将来某一个层改变后，仅会对该层进行后续处理，从而提升效率。&lt;/p&gt;
&lt;h3&gt;5. 绘制 (Paint)&lt;/h3&gt;
&lt;p&gt;图层到指令集。&lt;/p&gt;
&lt;p&gt;主线程会为每个层单独产生绘制指令集，用于描述这一层的内容该如何画出来。完成绘制后，主线程将每个图层的绘制信息提交给合成线程，此时渲染主线程的当前工作到此结束。&lt;/p&gt;
&lt;h2&gt;合成线程的工作原理&lt;/h2&gt;
&lt;p&gt;合成线程接手主线程的任务，将绘制指令转化为真实的像素并输出到屏幕。（阶段 6-8）&lt;/p&gt;
&lt;h3&gt;6. 分块 (Tiling)&lt;/h3&gt;
&lt;p&gt;图层到块。&lt;/p&gt;
&lt;p&gt;合成线程首先对每个图层进行分块，将其划分为更多的小区域。它会从线程池中拿取多个线程来完成分块工作。&lt;/p&gt;
&lt;h3&gt;7. 光栅化 (Raster)&lt;/h3&gt;
&lt;p&gt;块变成位图。&lt;/p&gt;
&lt;p&gt;光栅化在 GPU 里面进行，也会开启多个线程，将分好的块转化为实际的像素点（位图）。&lt;/p&gt;
&lt;h3&gt;8. 画 (Draw)&lt;/h3&gt;
&lt;p&gt;位图到指引（quad）。&lt;/p&gt;
&lt;p&gt;合成线程计算出每个位图在屏幕上的位置，交给 GPU 进行最终呈现，&lt;code&gt;transform&lt;/code&gt; 就在这里发生。&lt;/p&gt;
&lt;p&gt;:::tip[面试高频 Q&amp;amp;A]
&lt;strong&gt;Q: 为什么 transform 的效率高？&lt;/strong&gt;
&lt;strong&gt;A:&lt;/strong&gt; 因为 &lt;code&gt;transform&lt;/code&gt; 既不会影响布局，也不会影响绘制指令，它影响的只是渲染流程的最后一个 &lt;strong&gt;Draw&lt;/strong&gt; 阶段。由于 Draw 阶段在合成线程中执行，所以 &lt;code&gt;transform&lt;/code&gt; 的变化几乎不会影响渲染主线程。反之，渲染主线程无论如何忙碌，也不会影响 &lt;code&gt;transform&lt;/code&gt; 的变化。
:::&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“将复杂的网页渲染拆解为互相衔接的八个流水线步骤，这正是浏览器工程拆解能力的极致体现。”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;通过主线程与合成线程的精妙配合，以及 GPU 的加速渲染，浏览器保障了页面加载时的正确解析，也赋予了页面后续动画与交互的高性能潜力。&lt;/p&gt;
</content:encoded></item><item><title>从零手搓 AI 网页对话：原生流式传输技术实践总结</title><link>https://blog.moxiaoshuai.fun/2026-03-%E6%B5%81%E5%BC%8F%E4%BC%A0%E8%BE%93-%E6%B5%81%E5%BC%8F%E4%BC%A0%E8%BE%93/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-03-%E6%B5%81%E5%BC%8F%E4%BC%A0%E8%BE%93-%E6%B5%81%E5%BC%8F%E4%BC%A0%E8%BE%93/</guid><description>抛开现成框架与高度封装的 SDK，使用原生 HTML/JS 与 Node.js 从零搭建 AI 网页对话系统，解析流式请求背后的 TextDecoder 和异步生成器核心原理。</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在当今的大模型应用开发中，“流式传输”几乎是提升用户体验的必修课。如果不使用流式，用户必须死死盯着 Loading 图标等待大模型把几百上千个字全部生成完毕，体验极差。&lt;/p&gt;
&lt;p&gt;为了彻底搞懂流式请求的底层原理，我决定抛开现成的框架和高度封装的 SDK，使用纯粹的 &lt;strong&gt;HTML/原生 JS + Node.js (Express)&lt;/strong&gt;，从零开始搭建了一套 AI 对话系统。以下是整个演进和学习过程的技术复盘。&lt;/p&gt;
&lt;h2&gt;1. 架构与选型：化繁为简&lt;/h2&gt;
&lt;p&gt;为了专注核心技术——“推流”与“接流”，系统被设计为最精简的三层结构：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;前端&lt;/strong&gt;：负责处理用户输入、发送 POST 请求，并“像打字机一样”把接到的碎片文本拼接在 DOM 节点上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;中间代理 (Node.js)&lt;/strong&gt;：负责接收前端的诉求，向真实大模型发起请求，同时建立流式通道透传片段给前端。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;大模型 API&lt;/strong&gt;：接入符合业界标准的通用大模型服务端接口。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;2. 避坑指南：为什么不用原生的 EventSource ？&lt;/h2&gt;
&lt;p&gt;:::warning
最开始查阅资料时，发现很多教程推荐使用原生提供的 &lt;code&gt;new EventSource()&lt;/code&gt; 来实现 Server-Sent Events 流式接收。
但在实际技术调研时，发现一个问题：&lt;strong&gt;&lt;code&gt;EventSource&lt;/code&gt; 只支持 GET 请求&lt;/strong&gt;。
大模型对话需要把包含大量字符的 &lt;code&gt;history&lt;/code&gt; (历史上下文数组) 传给后端，GET 请求的 URL 长度限制会成为致命短板。
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;最终方案&lt;/strong&gt;：
采用 &lt;strong&gt;Fetch API + POST 请求 + 流式拦截&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;3. 后端如何“造流”？&lt;/h2&gt;
&lt;p&gt;为了不被繁杂的网络环境和限流报错打断思路，最好的练习方式是先手搓一个本地 Mock 推流接口。
普通的 HTTP 接口通常是把全部数据塞进变量最后一并发走。而流式接口的核心在于：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先告诉浏览器“我要持续发片段了”。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;res.write()&lt;/code&gt; 不断推送细小的数据。&lt;/li&gt;
&lt;li&gt;最后用 &lt;code&gt;res.end()&lt;/code&gt; 收尾。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Node.js 发流示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.post(&apos;/api/mock/chat/stream&apos;, (req, res) =&amp;gt; {
    res.setHeader(&apos;Content-Type&apos;, &apos;text/event-stream; charset=utf-8&apos;);
    res.setHeader(&apos;Cache-Control&apos;, &apos;no-cache&apos;);
    res.setHeader(&apos;Connection&apos;, &apos;keep-alive&apos;);

    const chars = &quot;这是一段用来测试流式打字机效果的 Mock 文本&quot;.split(&apos;&apos;);
    let currentIndex = 0;

    const timer = setInterval(() =&amp;gt; {
        if (currentIndex &amp;lt; chars.length) {
            res.write(chars[currentIndex++]); 
        } else {
            clearInterval(timer);
            res.end(); // 告诉前端结束了
        }
    }, 50);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 前端读取与深层解析：解码与数据的“三连操作”&lt;/h2&gt;
&lt;p&gt;前端拿到了流，普通 &lt;code&gt;fetch&lt;/code&gt; 会挂起直到 &lt;code&gt;res.end()&lt;/code&gt;，我们需要通过 &lt;code&gt;response.body.getReader()&lt;/code&gt; 打开“渐进式水管”，而此时我们拿到的实际上是包含底层字节编码的 &lt;code&gt;Uint8Array&lt;/code&gt;（无符号整数数组）。此时我们引入了一个关键对象——&lt;strong&gt;&lt;code&gt;TextDecoder&lt;/code&gt;&lt;/strong&gt; 解码器。&lt;/p&gt;
&lt;p&gt;让我们细致看看我们用于接收流的内部循环做了什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const reader = response.body.getReader();
const decoder = new TextDecoder(&apos;utf-8&apos;);

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    const chunkText = decoder.decode(value, { stream: true });
    aiMessageDiv.textContent += chunkText;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note[解析一：TextDecoder 与 stream: true 的玄机]
在此处，每次调用 &lt;code&gt;TextDecoder&lt;/code&gt; 的 &lt;code&gt;decode&lt;/code&gt; 方法对 &lt;code&gt;value&lt;/code&gt; 进行解码。此 &lt;code&gt;value&lt;/code&gt; 即来自响应体的数据片段，比如底层数组若是 &lt;code&gt;[100, 97, 116, 97]&lt;/code&gt; 四个数字合起来对应字符就是 &apos;data&apos;。所以 &lt;code&gt;decode&lt;/code&gt; 是在把无符号整数数组映射为字符串。&lt;/p&gt;
&lt;p&gt;特别需要注意 &lt;strong&gt;&lt;code&gt;stream: true&lt;/code&gt;&lt;/strong&gt; 参数，这对于流式传输极为重要，因为它专门用来处理&lt;strong&gt;多字节字符的残留问题&lt;/strong&gt;。比如中文字符在 UTF-8 编码规则下需要用到三个数字（例如表示某些汉字需要 &lt;code&gt;228, 189, 160&lt;/code&gt;）。由于流的数据是被硬生生一块块截断传输的，这段字节可能在两次接收之间“拦腰斩断”。如果不完整，&lt;code&gt;TextDecoder&lt;/code&gt; 会直接解出乱码；而加上 &lt;code&gt;stream: true&lt;/code&gt; 参数后，它会在内部将不完整的字节缓存起来，累积到下次凑齐完整数据后再进行解码，从而完美避免了中文乱码灾难。
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解析二：如何面对真实 API 的数据格式？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;目前的代码里后端发什么我们就直接拼什么。但在真实 API 和标准 SSE 约定里，包含多行带有 &lt;code&gt;data:&lt;/code&gt; 前缀的 JSON 字符串（例如 &lt;code&gt;data: {&quot;content&quot;:&quot;你好&quot;}&lt;/code&gt;），并且用空行分割，最后一行固定为 &lt;code&gt;data: [DONE]&lt;/code&gt;。
这时候在循环接到 &lt;code&gt;chunkText&lt;/code&gt; 之后，我们就需要紧接着进行数据清洗的操作：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;split(&apos;\n\n&apos;)&lt;/code&gt;&lt;/strong&gt;：为什么按换行符分割呢？因为每次循环拿到的 &lt;code&gt;value&lt;/code&gt; 一定包含一个或多个完整的 JSON 串区段。将多行响应片段分离出来备用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;filter&lt;/code&gt; &amp;amp; &lt;code&gt;map&lt;/code&gt;&lt;/strong&gt;：遍历判断是否是结束标识，将空行过滤，随后通过 &lt;code&gt;map&lt;/code&gt; 去掉文本头部的 &lt;code&gt;data:&lt;/code&gt; 前缀字符。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;反序列化解析&lt;/strong&gt;：最后转化为真正的 JS 对象（&lt;code&gt;JSON.parse&lt;/code&gt;），提取出对象中的 &lt;code&gt;content&lt;/code&gt; 字段去驱动 UI 显示。这里如果是在后端 Node.js 控制台打印流水的话，切记要使用 &lt;code&gt;process.stdout.write&lt;/code&gt; 而不是 &lt;code&gt;console.log&lt;/code&gt;，以免反复自动换行破坏连续阅读体验。当这一切循环结束后，我们就拿到了本轮对话的完整回复，后续逻辑便如寻常。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 进阶探讨：从循环驱动到 Async Generator (彻底解耦)&lt;/h2&gt;
&lt;p&gt;你可能发现了，这一段代码其实大部分都在做数据转换和格式处理的事情，这在应用设计上会暴露出一个问题：&lt;strong&gt;数据处理逻辑（解码、split、格式清洗）和业务逻辑（更新到 DOM 气泡）深深地耦合在一起&lt;/strong&gt;。
假设项目中有很多地方都需要进行这样的异步迭代（比如要复用流读取做分析、打点），如何避免重复代码？能否只在循环里保留核心业务代码，把相对通用的逻辑封装到专门的方法内部？这里我们要了解流背后的——&lt;strong&gt;迭代器（Iterator）和生成器（Generator）&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;理解迭代的本质：同步 vs 异步迭代器&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;ES6 同步迭代 (&lt;code&gt;for...of&lt;/code&gt;)&lt;/strong&gt;：规范规定了实现了 &lt;code&gt;Symbol.iterator&lt;/code&gt; 方法的对象都可以用 &lt;code&gt;for...of&lt;/code&gt; 进行遍历。我们常用的原生数组由于内置了该对象便具备了这个特性。实际上如果拆开来看，在这个 iterator 对象上有三个方法，重点关注 &lt;code&gt;next()&lt;/code&gt;，每次调用都会同步返回下个值以及是否结束 &lt;code&gt;return { value, done }&lt;/code&gt;。&lt;code&gt;for...of&lt;/code&gt; 就是我们在底层帮我们简化驱动这种对象的语法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 同步迭代示例
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;ES9 异步迭代 (&lt;code&gt;for await...of&lt;/code&gt;)&lt;/strong&gt;：规范规定了实现 &lt;code&gt;Symbol.asyncIterator&lt;/code&gt; 方法的对象适用。像 Fetch API 请求返回的 &lt;code&gt;ReadableStream&lt;/code&gt; 恰恰便实现了这个接口！同样去驱动它，不同的是这里的 &lt;code&gt;next()&lt;/code&gt; 方法返回的是一个需要 &lt;code&gt;await&lt;/code&gt; 的 Promise（包裹着和同步迭代一样的 &lt;code&gt;{ value, done }&lt;/code&gt;）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 异步迭代简易示例（模拟）
const asyncIterable = {
  [Symbol.asyncIterator]() {
    let i = 0;
    return {
      next: async () =&amp;gt; {
        await new Promise(resolve =&amp;gt; setTimeout(resolve, 100)); // 模拟异步延迟
        if (i &amp;lt; 3) return { value: ++i, done: false };
        return { value: undefined, done: true };
      }
    };
  }
};

// 使用 for await...of 消费
(async () =&amp;gt; {
  for await (const val of asyncIterable) {
    console.log(val); // 依次输出 1, 2, 3 (每隔100ms)
  }
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;引入异步生成器解耦&lt;/h3&gt;
&lt;p&gt;所以，最优雅的设计，是编写一个&lt;strong&gt;异步生成器函数 (Async Generator)&lt;/strong&gt;，利用 &lt;code&gt;yield&lt;/code&gt; 将复杂的网络流转变为业务所需的“纯净流”：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 
 注意到 async function 后面多了一个星号 *，这表示它是一个异步生成器函数。
 在函数内部使用 yield 关键字产出一个值，并暂停函数执行。
*/
async function* processStream(response) {
    const reader = response.body.getReader();
    const decoder = new TextDecoder(&apos;utf-8&apos;);

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        
        // 解析二进制
        const chunkText = decoder.decode(value, { stream: true });
        // (此处省略上方讲到的 split、map、filter 及去 data: 和 parse 的数据处理过程)
        const cleanContent = &quot;...(提取好的业务字符串)...&quot;;
        
        // 使用 yield 产出最终给到业务层食用的值
        yield cleanContent; 
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;很多人学了语法但觉得&quot;生成器好像没什么用”，实际上它其中一个极具价值的作用便在于&lt;strong&gt;流的转换&lt;/strong&gt;！调用生成器函数会返回一个自动实现了可迭代接口的新对象。这样我们就把 &lt;code&gt;fetch&lt;/code&gt; 丢出来的晦涩难懂的 &lt;code&gt;readableStream&lt;/code&gt; 转换成了自定义的流形态，完美屏蔽了内部数据格式处理细节。&lt;/p&gt;
&lt;p&gt;经过这身定制封装，在 UI 侧的业务代码就会变得清爽无比，只需要使用极为现代的 &lt;code&gt;for await...of&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 业务层视角：代码极致干净，只聚焦渲染处理自身关心的字符串
for await (const pureText of processStream(response)) {
    aiMessageDiv.textContent += pureText;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 收获与总结&lt;/h2&gt;
&lt;p&gt;在本次探索中，不仅从最底层的机制打通了 AI 对话项目从无到有的流式实践，更通过深挖背后的网络与语言特性，我们发现：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;流的断裂容错&lt;/strong&gt;：一个小小的 &lt;code&gt;{ stream: true }&lt;/code&gt;，巧夺天工地避免了底层基于包截断带来的错位乱码陷阱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;三段式拆解&lt;/strong&gt;：理解了真实场景下面对 &lt;code&gt;data: xxx&lt;/code&gt; 的 &lt;code&gt;split&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;map&lt;/code&gt; 拆解机制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;现代 JS 语法的优雅&lt;/strong&gt;：从繁琐的 &lt;code&gt;reader.read()&lt;/code&gt; 发动机模型，通过 &lt;code&gt;async function*&lt;/code&gt; (生成器) 和 &lt;code&gt;for await...of&lt;/code&gt; 的绝妙化学反应，优雅地完成了底层与业务的隔离。在 AI 开发中，往往前端不会直接去处理底层数据结构，我们要学会通过“流转换”为业务自定义的数据结构。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这是一次对 HTTP 流通信机制与 ES9 前沿迭代语法结合的深刻实践。感谢你看到这里，希望这篇文章能带给你启发！&lt;/p&gt;
</content:encoded></item><item><title>AI 名词总结：从 LLM 到 agent 的概念地图</title><link>https://blog.moxiaoshuai.fun/2026-03-ai%E5%90%8D%E8%AF%8D%E6%80%BB%E7%BB%93-ai%E5%90%8D%E8%AF%8D%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-03-ai%E5%90%8D%E8%AF%8D%E6%80%BB%E7%BB%93-ai%E5%90%8D%E8%AF%8D%E6%80%BB%E7%BB%93/</guid><description>把 prompt、context、memory、RAG、function calling、MCP、workflow、skill 放进一条演化链，理解它们为何出现、解决什么问题，以及为什么便利性会主导产品形态。</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;多数 AI 名词并非全新发明，而是围绕同一个目标逐层抽象出来的工程产物：让模型更可控，让系统更好用，让普通人更容易上手。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%88%87%E7%9A%84%E8%B5%B7%E7%82%B9llm&quot;&gt;一切的起点：LLM&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%BB%8E%E4%BC%9A%E8%AF%B4%E5%88%B0%E4%BC%9A%E5%81%9Aagent-%E4%B8%8E%E5%A4%96%E9%83%A8%E7%9F%A5%E8%AF%86&quot;&gt;从会说到会做：agent 与外部知识&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%8D%8F%E8%AE%AE%E5%B1%82function-calling-%E4%B8%8E-mcp&quot;&gt;协议层：function calling 与 MCP&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%BD%E5%8A%9B%E5%B0%81%E8%A3%85workflowskill-%E4%B8%8E-agentic-ai&quot;&gt;能力封装：workflow、skill 与 agentic AI&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-3&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%88%91%E7%9A%84%E5%88%A4%E6%96%AD%E6%97%A0%E9%85%8D%E7%BD%AE%E5%8F%AF%E7%94%A8%E6%89%8D%E6%98%AF%E7%BB%88%E5%B1%80&quot;&gt;我的判断：无配置可用才是终局&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-4&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;一切的起点：LLM&lt;/h2&gt;
&lt;p&gt;大语言模型（LLM），本质是“语义匹配”，是在上下文里预测下一个 token。它通过海量文本训练，学习语义关联，再根据输入给出概率上更合理的输出。&lt;/p&gt;
&lt;p&gt;但 LLM 天生只会“说”，不会“做”。为了让它在对话中更稳定，第一层工程包装自然出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;prompt：你给模型的任务指令；&lt;/li&gt;
&lt;li&gt;context：补充给模型的背景信息和约束；&lt;/li&gt;
&lt;li&gt;memory：把历史对话压缩后再注入，减少跑偏和遗忘。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些词有明确的工程目标：让多轮对话可持续、可复用、可控制。&lt;strong&gt;LLM、prompt、context、memory 是第一层名词，解决的是“怎么让对话可持续、可复用、可控制”。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;LLM 提供了语言能力底座，而 prompt、context、memory 是让这套能力可持续使用的第一层工程手段。&lt;/p&gt;
&lt;h2&gt;从会说到会做：agent 与外部知识&lt;/h2&gt;
&lt;p&gt;当你希望模型查网页、读本地文件、调用数据库或运行脚本时，问题就从“会不会说”变成了“会不会做”。模型本身不会直接执行动作，于是出现了中间层：agent。&lt;/p&gt;
&lt;p&gt;agent 可以理解为调度器：它把模型意图翻译为工具调用，再把工具结果返回给模型继续推理。&lt;/p&gt;
&lt;p&gt;这一层又带来了新名词，比如获取网络信息的 Web Search、获取本地知识库的 RAG（检索增强生成）。它们共同做的是把“模型参数外的信息”接进系统，提升答案的新鲜度和可靠性。&lt;strong&gt;agent、RAG 是第二层名词，旨在解决“让模型接入外部世界”的问题。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;当目标从“会说”升级到“会做”，agent 与 RAG 的价值就在于把模型接到真实世界的数据和动作上。&lt;/p&gt;
&lt;h2&gt;协议层：function calling 与 MCP&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;function calling&lt;/code&gt; 是模型与 agent 之间的约定，目标是让模型用结构化格式表达“我要调哪个工具、传什么参数”。它解决的是“模型怎么把意图说清楚”。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MCP&lt;/code&gt; 是 agent 与工具服务之间的约定，目标是标准化工具发现、参数传递与结果返回。它解决的是“系统怎么把这件事真正做出来”。&lt;/p&gt;
&lt;p&gt;:::important[不要混淆两层协议]
function calling 面向模型输出格式，MCP 面向工具服务协议。两者互补，不互相替代。
:::&lt;/p&gt;
&lt;p&gt;此时的 agent 像一个传话与调度中枢：接收模型意图，执行工具调用，再把结果回传给模型和用户。&lt;/p&gt;
&lt;p&gt;agent 与用户之间的交互形态也越来越多样，虽然底层交互方式还是文字，但表现形式有很多：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;命令行（CLI）：Claude Code、Codex、OpenCode；&lt;/li&gt;
&lt;li&gt;编程 IDE：Cursor、Trae、Antigravity；&lt;/li&gt;
&lt;li&gt;桌面助手：如 ClawBot（最近爆火的龙虾）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;function calling&lt;/code&gt; 负责“说清楚要做什么”，&lt;code&gt;MCP&lt;/code&gt; 负责“把事情标准化做出来”，两者合起来才是完整调用链。&lt;/p&gt;
&lt;h2&gt;能力封装：workflow、skill 与 agentic AI&lt;/h2&gt;
&lt;p&gt;当你反复让 AI 做同类任务时，第一反应通常是把流程固化成脚本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;编程方式：LangChain；&lt;/li&gt;
&lt;li&gt;低代码方式：workflow。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但固定流程在输入输出变化较大时会变得僵硬。于是会出现一个“能力目录”：把不同脚本和规则打包成可复用单元，让 agent 在执行前按需加载，这就是 skill 的价值。&lt;/p&gt;
&lt;p&gt;从自动化能力的谱系看，大致可以这样理解：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;LangChain：偏硬编码，稳定但灵活性最低。&lt;/li&gt;
&lt;li&gt;workflow：流程模块化，修改更快，但仍依赖预设路径。&lt;/li&gt;
&lt;li&gt;skill：把流程规则沉淀为能力包，让 agent 按任务调用。&lt;/li&gt;
&lt;li&gt;agent：在循环中动态决策、调用工具，并判断完成度。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这本质上是从 workflow 范式逐渐向 agentic AI 范式的转变。workflow 预先将节点串联，编排好完成一件事的具体流程，并在节点中调用大模型来完成一部分需要智能的任务。这需要“n 份提示词 + 手动工具调用 + n 个 if-else”，更像是一种自动化而非智能化。相反，agentic AI 则充分发挥模型的推理能力，让 AI 在任务循环中自主决策、使用工具，并判断任务的完成度。它只需要“1 份提示词 + 自动工具调用 + 1 个循环”。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;workflow 与 skill 解决的是复用效率，agentic AI 解决的是动态决策能力。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;workflow 与 skill 优化的是复用效率，而 agentic AI 真正放大的，是任务执行中的动态决策能力。&lt;/p&gt;
&lt;h2&gt;我的判断：无配置可用才是终局&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%90%8D%E8%AF%8D%E6%80%BB%E7%BB%93.SxENEGYF_2jRq72.webp&quot; alt=&quot;名词总结&quot; /&gt;
MCP、skill、桌面助手等被过度炒作的概念，很多时候只是技术演化中的中间产物。褪去包装后，它们仍然围绕同一件事：减少用户与模型沟通成本，把该自动化的部分自动化。&lt;/p&gt;
&lt;p&gt;从产品演化看，谁能降低上手门槛，谁就更容易获得规模化用户。普通用户不会长期为“术语先进”买单，而会为“能直接用、够稳定、性价比高”买单。以近期走红的工具为例，关键往往不是概念更新，而是连接能力、定时能力和可视化管理能力做得足够完整。&lt;/p&gt;
&lt;p&gt;token 成本今天仍是约束，但如果推理成本继续下降，系统设计重心会进一步转向体验与可靠性，而不是手工拼装中间层概念。未来更可能胜出的，是“打包完成”的 agent 产品：常用能力内置，复杂协议隐藏，交互尽量自然。&lt;strong&gt;下一阶段的竞争焦点不是术语数量，而是无配置可用的产品完成度。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;从用户侧看，真正可持续的竞争力不是新名词，而是“几乎零配置就能稳定完成任务”的产品体验。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;从 LLM 到 agent，从 function calling 到 MCP，从 workflow 到 skill，本质都在回答同一个问题：如何在可控与灵活之间，用更低成本交付可用能力。&lt;/p&gt;
&lt;p&gt;把这些词看成“能力分层”而不是“流行标签”，讨论会更清晰：先看需求，再谈架构。&lt;/p&gt;
</content:encoded></item><item><title>AI 工作流总结：从“写代码”到“管系统”</title><link>https://blog.moxiaoshuai.fun/2026-03-%E5%AF%B9ai%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%9A%84%E6%80%BB%E7%BB%93-%E5%AF%B9ai%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%9A%84%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-03-%E5%AF%B9ai%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%9A%84%E6%80%BB%E7%BB%93-%E5%AF%B9ai%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%9A%84%E6%80%BB%E7%BB%93/</guid><description>在 AI 编程时代，真正的竞争力不再只是写得快，而是通过文档、计划和测试，把 agent 的不确定行为约束为可复用、可验证、可迭代的工程流程。</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;在 AI 编程时代，个人生产力上限正在被抬高。真正的核心竞争力不再只是“写得快”，而是能否通过文档、计划和测试，把不确定的 agent 行为约束为可复用、可验证、可迭代的工程流程。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1-%E6%96%87%E6%A1%A3%E4%B8%BB%E5%AF%BC%E5%B7%A5%E4%BD%9C%E6%B5%81&quot;&gt;1. 文档主导工作流&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-%E5%8E%9F%E5%9E%8B%E4%B8%BB%E5%AF%BC%E5%B7%A5%E4%BD%9C%E6%B5%81&quot;&gt;2. 原型主导工作流&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-%E9%9B%B6%E6%95%A3%E7%9A%84%E5%AE%9E%E8%B7%B5%E7%BB%93%E8%AE%BA&quot;&gt;3. 零散的实践结论&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-%E6%80%BB%E7%BB%93&quot;&gt;4. 总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 文档主导工作流&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE1-%E6%96%87%E6%A1%A3%E4%B8%BB%E5%AF%BC%E5%B7%A5%E4%BD%9C%E6%B5%81.CeW0PtxH_Z2dbVUJ.webp&quot; alt=&quot;图1. 文档主导工作流&quot; /&gt;&lt;/p&gt;
&lt;p&gt;适用场景：需求复杂、影响范围大、多人协作、需要可追溯决策过程的项目。&lt;/p&gt;
&lt;h3&gt;流程步骤&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;发散需求
让 AI 讨论和挑刺、做头脑风暴、调研同类产品。
这一阶段不急着落代码，只处理业务目标、边界与优先级。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写 PRD（核心项目资产）
PRD 最少包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;术语表：统一项目语义，避免沟通歧义。&lt;/li&gt;
&lt;li&gt;交互方式：明确关键交互路径和用户反馈。&lt;/li&gt;
&lt;li&gt;验收标准/边界定义：为后续测试与回归提供锚点。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;plan + review
&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE2-plan-review.DBMQmd_m_oLLcC.webp&quot; alt=&quot;图2. plan + review&quot; /&gt;
先基于 PRD 生成计划，再进行多轮 review，确保任务拆分可执行、可验收。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现
进入编码阶段。这里强烈推荐配合 worktree 并行推进正交任务，减少上下文切换损耗。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;功能测试 + 迭代
如果出现偏差，优先回到文档检查“定义是否正确”，而不是只在代码层补丁式修复。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;人工 review
对业务一致性、架构风险、测试覆盖进行最终兜底。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;文档主导的本质是：先把“为什么做、做到什么算完成”说清，再让 AI 高速执行“怎么做”。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 原型主导工作流&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE3-%E5%8E%9F%E5%9E%8B%E4%B8%BB%E5%AF%BC%E5%B7%A5%E4%BD%9C%E6%B5%81.kh5j0UAU_Z1pajDC.webp&quot; alt=&quot;图3. 原型主导工作流&quot; /&gt;&lt;/p&gt;
&lt;p&gt;适用场景：需求模糊、体量较小、需要快速看效果的任务。&lt;/p&gt;
&lt;h3&gt;流程步骤&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;AI 快速开发原型&lt;/li&gt;
&lt;li&gt;按反馈快速迭代&lt;/li&gt;
&lt;li&gt;通过代码反向生成文档&lt;/li&gt;
&lt;li&gt;转入文档主导流程，进入规范化开发&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;原型主导更像“先看样子再收敛定义”，文档主导更像“先定规则再规模化执行”。两者不是对立关系，而是前后衔接关系。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 零散的实践结论&lt;/h2&gt;
&lt;h3&gt;3.1 显式要求 agent 采用 TDD&lt;/h3&gt;
&lt;p&gt;TDD 对 agent 的价值非常直接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每轮目标更小，agent 不容易“脑补过度”。&lt;/li&gt;
&lt;li&gt;测试先行，需求变成可执行文档。&lt;/li&gt;
&lt;li&gt;代码改动引入回归时更容易第一时间暴露。&lt;/li&gt;
&lt;li&gt;你始终知道“现在为什么改、改完是否正确”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.2 文档生命周期：用后即弃 vs 持续维护&lt;/h3&gt;
&lt;p&gt;二者都可以成立，但在长期项目中，持续维护文档的收益更高。&lt;/p&gt;
&lt;h4&gt;为什么持续维护文档重要&lt;/h4&gt;
&lt;p&gt;1 角色升级&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE4-%E8%A7%92%E8%89%B2%E5%8D%87%E7%BA%A7.BGlanfQ0_1iR3u1.webp&quot; alt=&quot;图4. 角色升级&quot; /&gt;&lt;/p&gt;
&lt;p&gt;业务需求 &amp;gt; 技术实现。工程师需要逐步从“执行者”升级为“方向与质量的监管者”。
在现阶段，agent 的自主性与长期记忆仍无法替代人类，因此你仍需理解项目深度。&lt;/p&gt;
&lt;p&gt;2 带宽扩大&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE5-%E5%B8%A6%E5%AE%BD%E6%89%A9%E5%A4%A7.D9jV-Yia_9f5kH.webp&quot; alt=&quot;图5. 带宽扩大&quot; /&gt;&lt;/p&gt;
&lt;p&gt;生产力提升后，单人可管理的项目规模和数量都会增加。
为了降低记忆与沟通成本，必须提升抽象层级，从代码视角转向文档视角。&lt;/p&gt;
&lt;p&gt;虽然会损失部分细节，但能保留核心骨架，且维护成本通常更低。
在 agent 协助下，文档到代码的转换成本持续下降，自然语言与代码实现之间的边界会越来越模糊。&lt;/p&gt;
&lt;p&gt;长期看，工程师会更多关注架构与行为逻辑，而不是把时间消耗在语法细节上。
在 agent 解决长期记忆和主动性问题之前，维护高质量文档仍是阶段性的最佳实践。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.moxiaoshuai.fun/_astro/%E5%9B%BE6-vibe-coding%E6%97%B6%E4%BB%A3.WdHDOrxc_Z21q5Pj.webp&quot; alt=&quot;图6. vibe coding 时代&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;3.3 其他操作建议&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;计划先行（plan）&lt;/li&gt;
&lt;li&gt;让 agent 写测试&lt;/li&gt;
&lt;li&gt;善用 skill
把固定手动流程脚本化，例如：改完自动编译、跑测试、读日志，减少重复劳动和上下文切换。&lt;/li&gt;
&lt;li&gt;关注骨架而非细节&lt;/li&gt;
&lt;li&gt;工具轻量化&lt;/li&gt;
&lt;li&gt;明确工作流应用场景&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;在 AI 时代，稳定产出的关键不是让 agent 一次写对所有代码，而是让计划、测试、文档和工具链形成闭环。这样才能在规模上升时保持质量与节奏。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 总结&lt;/h2&gt;
&lt;p&gt;对于大规模、定义明确的 feature，我们以文档为核心主导开发。&lt;/p&gt;
&lt;p&gt;对于重要且容易描述的 feature，我们持续维护文档，把文档作为长期资产。&lt;/p&gt;
&lt;p&gt;对于其他 feature，我们按成本和收益灵活选择策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当 feature 较小，或定义仍不清晰时，以原型开发为核心，快速试错与迭代。&lt;/li&gt;
&lt;li&gt;当小 feature 逐步演化为定义明确的大 feature 时，再切回文档主导流程，对其进行系统化重构与治理。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>解决跨域问题</title><link>https://blog.moxiaoshuai.fun/2026-03-%E8%A7%A3%E5%86%B3%E8%B7%A8%E5%9F%9F%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-03-%E8%A7%A3%E5%86%B3%E8%B7%A8%E5%9F%9F%E9%97%AE%E9%A2%98/</guid><description>记录 CORS 预检失败问题排查过程，以及 BFF 代理方案的取舍。</description><pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;Apifox 能通不代表浏览器能通；跨域问题的第一检查点应该是预检请求 &lt;code&gt;OPTIONS&lt;/code&gt;，不是业务接口参数。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E9%97%AE%E9%A2%98%E7%8E%B0%E5%9C%BA&quot;&gt;问题现场&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%85%B3%E9%94%AE%E8%AE%A4%E7%9F%A5&quot;&gt;关键认知&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%96%B9%E6%A1%88%E5%8F%96%E8%88%8D&quot;&gt;方案取舍&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-3&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;问题现场&lt;/h2&gt;
&lt;p&gt;我最开始被“Apifox 已经测通”误导了，默认认为后端接口没问题。&lt;br /&gt;
但浏览器联调时登录请求始终失败，进一步看 Network 才发现真实请求前的 &lt;code&gt;OPTIONS&lt;/code&gt; 预检直接返回了 &lt;code&gt;405 Method Not Allowed&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;“接口通了”必须明确是哪个运行环境通了，测试工具和浏览器是两套约束体系。&lt;/p&gt;
&lt;h2&gt;关键认知&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CORS 预检是浏览器行为，不是后端业务逻辑本身&lt;/strong&gt;：浏览器在非简单请求前会发 &lt;code&gt;OPTIONS&lt;/code&gt; 探路，后端必须返回正确的 &lt;code&gt;Access-Control-Allow-*&lt;/code&gt; 响应头。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apifox 不受同源策略约束&lt;/strong&gt;：它发的是裸 HTTP 请求，因此无法覆盖浏览器端的 CORS 问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;排查顺序要前移到 Network&lt;/strong&gt;：先看有无 &lt;code&gt;OPTIONS&lt;/code&gt;、状态码和响应头，再看业务参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;跨域问题要先确认“请求是否被浏览器放行”，再谈“业务接口是否正确”。&lt;/p&gt;
&lt;h2&gt;方案取舍&lt;/h2&gt;
&lt;p&gt;我评估了两条路径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;方案 A：后端加 CORS（治根）&lt;/strong&gt;&lt;br /&gt;
优点是长期干净；缺点是依赖后端团队排期。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;方案 B：前端走 BFF 代理（快速落地）&lt;/strong&gt;&lt;br /&gt;
浏览器请求同源 &lt;code&gt;/api/*&lt;/code&gt;，由 Next.js 服务端转发到真实后端，能立即绕开 CORS 阻塞。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这次我采用 BFF，因为当前阶段单人推进前端，跨团队沟通成本高，先保证业务联调闭环更务实。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;治根和治标不是对立关系，关键看当前交付目标和协作成本。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;从“无法登录”到“方案落地”，这次排障已经形成了可复用的 checklist。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次最大的收获不是修了一个 bug，而是校准了排障方法论：&lt;br /&gt;
先看浏览器链路，再看业务逻辑；先保证可交付，再逐步推进根治方案。后续项目里，我会默认把 CORS 预检检查纳入联调首步。&lt;/p&gt;
</content:encoded></item><item><title>SSR 与 CSR 在 Next.js 中的实践要点</title><link>https://blog.moxiaoshuai.fun/2026-01-ssr%E4%B8%8Ecsr%E5%9C%A8nextjs%E4%B8%AD%E7%9A%84%E5%AE%9E%E8%B7%B5%E8%A6%81%E7%82%B9/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-01-ssr%E4%B8%8Ecsr%E5%9C%A8nextjs%E4%B8%AD%E7%9A%84%E5%AE%9E%E8%B7%B5%E8%A6%81%E7%82%B9/</guid><description>总结 Next.js 中 SSR、CSR 与 Hydration 的关键机制和实践建议。</description><pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;这次整理让我更清楚地理解了 Next.js 的核心权衡：SSR 负责“快看到”，Hydration 负责“能交互”，而响应式冲突要优先用 CSS 方案规避。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%8C%E6%99%AF%E4%B8%8E%E9%97%AE%E9%A2%98&quot;&gt;背景与问题&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5&quot;&gt;核心概念&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#hydration-%E6%9C%BA%E5%88%B6&quot;&gt;Hydration 机制&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#ssr-%E4%B8%8B%E7%9A%84%E5%93%8D%E5%BA%94%E5%BC%8F%E5%AE%9E%E8%B7%B5&quot;&gt;SSR 下的响应式实践&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-3&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;背景与问题&lt;/h2&gt;
&lt;p&gt;在 Next.js（App Router）里，我最初的问题是：页面明明 SSR 首屏很快，但一涉及交互或响应式判断就容易出现 Hydration 警告，甚至内容闪动。&lt;/p&gt;
&lt;p&gt;核心难点不是“会不会写组件”，而是“服务端和客户端的首次输出是否一致”。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;SSR 场景下，渲染一致性是第一原则，交互能力是第二步再补。&lt;/p&gt;
&lt;h2&gt;核心概念&lt;/h2&gt;
&lt;p&gt;在 Next.js 里，组件分为 Server Component 和 Client Component：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Server Component（默认）&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;运行在服务端。&lt;/li&gt;
&lt;li&gt;不可使用 React Hooks、事件监听、浏览器 API。&lt;/li&gt;
&lt;li&gt;优点是减少客户端 bundle，首屏更轻。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client Component（&lt;code&gt;&quot;use client&quot;;&lt;/code&gt;）&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;需要 Hydration 后才具备完整交互。&lt;/li&gt;
&lt;li&gt;可使用状态、事件和浏览器能力。&lt;/li&gt;
&lt;li&gt;适合表单、按钮、动态交互区域。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最佳实践是“叶子节点 client 化”：页面尽量保持 server，只有必须交互的局部切到 client。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;组件边界划分正确，能同时拿到首屏性能和交互能力。&lt;/p&gt;
&lt;h2&gt;Hydration 机制&lt;/h2&gt;
&lt;p&gt;Hydration 可以理解为“把交互能力重新附着到已渲染的 HTML 上”。&lt;/p&gt;
&lt;p&gt;过程是：服务端先输出静态内容，客户端下载 JS 后再绑定事件和状态。&lt;br /&gt;
所以 SSR 的优势是内容先到，挑战是交互稍后到。&lt;/p&gt;
&lt;p&gt;这也是为什么 SSR 通常改善 FCP，但仍可能出现 TTI 体感延迟。Next.js 的 streaming 和选择性 hydration 就是在缓解这个落差。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;SSR 不是“全都更快”，而是“先可见，再可交互”，设计时要管理好用户预期。&lt;/p&gt;
&lt;h2&gt;SSR 下的响应式实践&lt;/h2&gt;
&lt;p&gt;常见 Hydration mismatch 报错本质是：服务端首次 HTML 与客户端首次渲染不一致。&lt;/p&gt;
&lt;p&gt;典型误区是直接在渲染阶段读取 &lt;code&gt;window.innerWidth&lt;/code&gt; 并分支输出不同组件。&lt;br /&gt;
在 SSR 场景下更稳妥的做法是优先用 CSS 媒体查询（或 Tailwind 断点类）处理展示差异，让初始 HTML 保持一致。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return (
  &amp;lt;&amp;gt;
    &amp;lt;div className=&quot;block md:hidden&quot;&amp;gt;
      &amp;lt;MobileNav /&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div className=&quot;hidden md:block&quot;&amp;gt;
      &amp;lt;DesktopNav /&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;响应式优先交给 CSS，能显著降低 mismatch 风险并提升渲染稳定性。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次整理后，我对 SSR 的判断更清晰了：&lt;br /&gt;
先保证首屏输出一致，再局部增强交互；先用 CSS 解决响应式，再用 JS 处理必须的行为逻辑。这样才能把 Next.js 的性能优势稳定地发挥出来。&lt;/p&gt;
</content:encoded></item><item><title>小程序真机图片失效与 OSS 防盗链排查</title><link>https://blog.moxiaoshuai.fun/2026-01-%E5%B0%8F%E7%A8%8B%E5%BA%8F%E7%9C%9F%E6%9C%BA%E5%9B%BE%E7%89%87%E5%A4%B1%E6%95%88%E4%B8%8Eoss%E9%98%B2%E7%9B%97%E9%93%BE%E6%8E%92%E6%9F%A5/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-01-%E5%B0%8F%E7%A8%8B%E5%BA%8F%E7%9C%9F%E6%9C%BA%E5%9B%BE%E7%89%87%E5%A4%B1%E6%95%88%E4%B8%8Eoss%E9%98%B2%E7%9B%97%E9%93%BE%E6%8E%92%E6%9F%A5/</guid><description>复盘真机图片加载失败问题，定位 OSS Referer 白名单配置误区。</description><pubDate>Fri, 16 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心表现摘要&lt;/h2&gt;
&lt;p&gt;这次真机故障的根因不是代码，而是 OSS 防盗链白名单缺失 &lt;code&gt;servicewechat.com&lt;/code&gt;，导致“开发者工具可见、真机不可见”的假象。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B8%E5%BF%83%E8%A1%A8%E7%8E%B0%E6%91%98%E8%A6%81&quot;&gt;核心表现摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E9%97%AE%E9%A2%98%E7%8E%B0%E5%9C%BA&quot;&gt;问题现场&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B9%E5%9B%A0%E6%8B%86%E8%A7%A3&quot;&gt;根因拆解&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%8E%92%E6%9F%A5%E4%B8%AD%E7%9A%84%E5%B9%B2%E6%89%B0%E9%A1%B9&quot;&gt;排查中的干扰项&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-3&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;问题现场&lt;/h2&gt;
&lt;p&gt;现象非常典型：开发者工具里图片正常，真机上图片全白。因为工具里看起来一切正常，我一度把排查方向放在代码和资源本身上。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;“工具正常、真机异常”通常不是偶发 bug，而是环境差异触发了同一配置的不同路径。&lt;/p&gt;
&lt;h2&gt;根因拆解&lt;/h2&gt;
&lt;p&gt;今天最大的认知变化是重新理解 OSS 防盗链逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;浏览器常见空 Referer 请求，命中“允许空 Referer”时可以访问资源。&lt;/li&gt;
&lt;li&gt;微信真机会带 &lt;code&gt;servicewechat.com&lt;/code&gt; Referer。&lt;/li&gt;
&lt;li&gt;白名单没有该域名时，OSS 会直接返回 403。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结论：&lt;code&gt;Allow Empty Referer&lt;/code&gt; 并不等于“完全公开”，它只是放行“没报来源”的请求。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;这不是“真机玄学”，而是 Referer 白名单策略的必然结果。&lt;/p&gt;
&lt;h2&gt;排查中的干扰项&lt;/h2&gt;
&lt;p&gt;中间还有一个干扰项：电脑浏览器也打不开资源，误导我以为 OSS 文件有问题。最后确认是本地代理影响了 DNS 或 IP 路由。&lt;/p&gt;
&lt;p&gt;经验是：排查这种网络问题时，手机 4G/5G 环境往往比本机浏览器更干净，更适合作为对照组。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;先区分“平台策略问题”和“本地网络问题”，能显著缩短排障路径。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;后续动作已经明确：先修配置，再做代码与文档收口，避免团队重复踩坑。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次故障让我重新建立了一个排查顺序：先看请求环境差异，再看平台策略，最后才是代码本身。真机问题并不可怕，可怕的是拿错对照组和排查优先级。&lt;/p&gt;
</content:encoded></item><item><title>富文本样式不生效排查总结</title><link>https://blog.moxiaoshuai.fun/2026-01-%E5%AF%8C%E6%96%87%E6%9C%AC%E6%A0%B7%E5%BC%8F%E4%B8%8D%E7%94%9F%E6%95%88%E6%8E%92%E6%9F%A5%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-01-%E5%AF%8C%E6%96%87%E6%9C%AC%E6%A0%B7%E5%BC%8F%E4%B8%8D%E7%94%9F%E6%95%88%E6%8E%92%E6%9F%A5%E6%80%BB%E7%BB%93/</guid><description>记录 uv-parse 富文本样式失效问题的根因分析与稳定方案。</description><pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一句话摘要&lt;/h2&gt;
&lt;p&gt;这次排查确认了一个关键点：在小程序富文本场景里，单靠 scoped + CSS 穿透不稳定，&lt;code&gt;tag-style&lt;/code&gt; 才是可控且跨端一致的方案。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%91%98%E8%A6%81&quot;&gt;一句话摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%8C%E6%99%AF%E4%B8%8E%E7%8E%B0%E8%B1%A1&quot;&gt;背景与现象&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B9%E5%9B%A0%E5%88%86%E6%9E%90&quot;&gt;根因分析&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94%E4%B8%8E%E9%80%89%E6%8B%A9&quot;&gt;方案对比与选择&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%90%BD%E5%9C%B0%E8%AE%A1%E5%88%92&quot;&gt;落地计划&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-3&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;背景与现象&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;.rich-text-content&lt;/code&gt; 里写了自定义样式，但运行后看起来“加不加样式都一样”，富文本区域没有被影响。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;问题表面是“样式不生效”，本质是样式作用域和渲染路径不一致。&lt;/p&gt;
&lt;h2&gt;根因分析&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;页面里使用了 &lt;code&gt;&amp;lt;style lang=&quot;scss&quot; scoped&amp;gt;&lt;/code&gt;，scoped 样式默认无法作用到子组件内部渲染出来的真实节点。&lt;/li&gt;
&lt;li&gt;uv-parse 的富文本最终由内部组件渲染（如 node.vue / rich-text），外层容器 &lt;code&gt;.rich-text-content&lt;/code&gt; 的 CSS 只会影响容器本身，不会自然影响到内部的 &lt;code&gt;p / span / img&lt;/code&gt; 等元素。&lt;/li&gt;
&lt;li&gt;即使尝试 &lt;code&gt;:deep(.uv-parse)&lt;/code&gt;，也可能仍不生效：
&lt;ul&gt;
&lt;li&gt;uv-parse 内部会用带下划线前缀的类名（如 &lt;code&gt;._p&lt;/code&gt;、&lt;code&gt;._h1&lt;/code&gt;），不是直接的 &lt;code&gt;p/h1&lt;/code&gt; 标签样式。&lt;/li&gt;
&lt;li&gt;小程序端不少内容走原生 &lt;code&gt;rich-text&lt;/code&gt; 渲染，CSS 穿透对原生组件经常不稳定或失效。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;只靠样式穿透，风险点多且难以预测，尤其在小程序端更容易出现“本地可用、真机失效”。&lt;/p&gt;
&lt;h2&gt;方案对比与选择&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;方案 A：用 &lt;code&gt;::v-deep&lt;/code&gt; / &lt;code&gt;/deep/&lt;/code&gt; / &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 做样式穿透（改动小，但端上不一定稳定，且要对准 uv-parse 内部类名）。&lt;/li&gt;
&lt;li&gt;方案 B：用 uv-parse 的 &lt;code&gt;container-style&lt;/code&gt; 传入容器内联样式（适合改整体容器）。&lt;/li&gt;
&lt;li&gt;方案 C（最终推荐）：用 uv-parse 的 &lt;code&gt;tag-style&lt;/code&gt; 给特定标签注入内联样式（最稳定、影响面最小）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最终推荐 &lt;code&gt;tag-style&lt;/code&gt;：解析时把样式直接写到节点上，不依赖 CSS 穿透。&lt;/p&gt;
&lt;p&gt;优点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;样式直接注入到 HTML 节点，优先级高&lt;/li&gt;
&lt;li&gt;只影响当前这个 uv-parse 实例（不会污染全局）&lt;/li&gt;
&lt;li&gt;小程序/H5/App 兼容性更好&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;在“可维护性 + 跨端稳定性”维度上，&lt;code&gt;tag-style&lt;/code&gt; 是当前最小代价、最高确定性的方案。&lt;/p&gt;
&lt;h2&gt;落地计划&lt;/h2&gt;
&lt;p&gt;修改 &lt;code&gt;detail.vue&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;code&gt;data()&lt;/code&gt; 增加 &lt;code&gt;parseTagStyle&lt;/code&gt;（为 &lt;code&gt;p/img/h1...&lt;/code&gt; 等标签定义内联样式）&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;&amp;lt;uv-parse&amp;gt;&lt;/code&gt; 上绑定 &lt;code&gt;:tag-style=&quot;parseTagStyle&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;改动点集中在单一页面，影响面可控，回归成本低。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次排查再次说明：富文本问题往往不是“CSS 写错了”，而是“渲染边界没看清”。以后遇到类似问题，我会优先确认渲染链路，再决定是用样式穿透还是组件能力。&lt;/p&gt;
</content:encoded></item><item><title>别再为了大而全的技术选型自我消耗</title><link>https://blog.moxiaoshuai.fun/2026-01-%E5%88%AB%E5%86%8D%E4%B8%BA%E4%BA%86%E5%A4%A7%E8%80%8C%E5%85%A8%E7%9A%84%E6%8A%80%E6%9C%AF%E9%80%89%E5%9E%8B%E8%87%AA%E6%88%91%E6%B6%88%E8%80%97/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2026-01-%E5%88%AB%E5%86%8D%E4%B8%BA%E4%BA%86%E5%A4%A7%E8%80%8C%E5%85%A8%E7%9A%84%E6%8A%80%E6%9C%AF%E9%80%89%E5%9E%8B%E8%87%AA%E6%88%91%E6%B6%88%E8%80%97/</guid><description>复盘富文本技术选型中的取舍，强调业务驱动与架构边界。</description><pubDate>Fri, 09 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心观点摘要&lt;/h2&gt;
&lt;p&gt;这次复盘让我确认了一件事：在内容消费场景里，技术选型的第一原则不是“功能最全”，而是“是否刚好满足业务并可长期维护”。&lt;/p&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%A0%B8%E5%BF%83%E8%A7%82%E7%82%B9%E6%91%98%E8%A6%81&quot;&gt;核心观点摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E7%9B%AE%E5%BD%95&quot;&gt;目录&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E8%83%8C%E6%99%AF%E4%B8%8E%E9%97%AE%E9%A2%98&quot;&gt;背景与问题&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%85%B3%E9%94%AE%E6%80%9D%E8%B7%AF&quot;&gt;关键思路&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-1&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%9C%AC%E7%AB%A0%E5%B0%8F%E7%BB%93-2&quot;&gt;本章小结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E6%80%BB%E7%BB%93&quot;&gt;总结&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;背景与问题&lt;/h2&gt;
&lt;p&gt;我之前陷入了典型的“工程师思维陷阱”：总想选一个能力最全、可扩展性最高的富文本方案，担心“只展示不编辑”显得技术含量不够。&lt;/p&gt;
&lt;p&gt;但回到业务本身，我当前做的是内容分发，不是内容生产。前端核心任务是稳定渲染与性能保障，而不是把后台编辑器能力搬到小程序端。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;问题不在技术能力不够，而在于目标错位：把消费端当成生产端来设计了。&lt;/p&gt;
&lt;h2&gt;关键思路&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;拆分“编辑”和“显示”职责&lt;/strong&gt;：编辑器服务后台管理系统，消费端只需要高性能渲染器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前置约束，减少背锅&lt;/strong&gt;：和后端提前约定 HTML 标签白名单（如 &lt;code&gt;p&lt;/code&gt;、&lt;code&gt;img&lt;/code&gt;、&lt;code&gt;video&lt;/code&gt;），避免脏数据进入客户端。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;入口统一容错&lt;/strong&gt;：在渲染入口做一次标准化处理（如 &lt;code&gt;max-width: 100%&lt;/code&gt;、空数据兜底），提升整体稳定性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;关注真实业务门槛&lt;/strong&gt;：网课场景真正难点在长视频播放体验，例如检查点、HLS、弱网策略，而不是“编辑器功能比拼”。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;技术选型要先回答“业务到底需要什么”，再谈“技术还能做什么”。&lt;/p&gt;
&lt;h3&gt;本章小结&lt;/h3&gt;
&lt;p&gt;下一步不追求“大改架构”，而是优先把规约、容错和性能三件事做到可执行、可复用。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次最大的收获不是学了新框架，而是建立了判断标准：业务驱动技术，复杂度服务目标。把 &lt;code&gt;uv-parse&lt;/code&gt; 这类现有工具用到稳定、可维护，就是当下最有价值的工程成长。&lt;/p&gt;
</content:encoded></item><item><title>实习第一天：uni-app 学习笔记</title><link>https://blog.moxiaoshuai.fun/2025-12-%E5%AE%9E%E4%B9%A0%E7%AC%AC%E4%B8%80%E5%A4%A9uni-app%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-12-%E5%AE%9E%E4%B9%A0%E7%AC%AC%E4%B8%80%E5%A4%A9uni-app%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</guid><description>整理实习首日的 uni-app 学习路径、任务难点与性能优化调研。</description><pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;面向实际业务的 uni-app 技术准备与性能优化考量&lt;/h1&gt;
&lt;p&gt;本次结合业务线技术栈（Vue 与 uni-app），整理对跨端框架的设计概念（如 nvue、nts、webview、weex），逻辑层与渲染层分离模型的理解，以及在遇到如大量视频与长列表等性能敏感场景下的解决方案调研。&lt;/p&gt;
&lt;h2&gt;开发侧的核心技术抓手&lt;/h2&gt;
&lt;p&gt;面对一套包含多端特性的新框架，首先应该建立快速交付抓手，不需要初期通读所有 API，优先把以下核心概念闭环：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;项目结构&lt;/strong&gt;：约定目录与文件构建原则及生命周期。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;页面与组件&lt;/strong&gt;：如何划分边界并组织基础的交互响应。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据绑定&lt;/strong&gt;：状态流转以及数据渲染双向机制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;事件处理&lt;/strong&gt;：触控与端能力的 API 调用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;路由与跳转&lt;/strong&gt;：App/页面级路由栈和跳转机制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生命周期&lt;/strong&gt;：不同端下的页面与组件初始化释放时机。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;条件编译&lt;/strong&gt;：多平台架构中最关键的跨端差异填平语法。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;场景难点与技术预研&lt;/h2&gt;
&lt;p&gt;当前负责的业务存在大量较重的流媒体播放和列表交互需求，主要存在两个渲染及加载难点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;视频资源过大、不能强依赖一次性全量加载并造成网络与设备负担；&lt;/li&gt;
&lt;li&gt;长列表的内存泄漏风险和前端 DOM（如小程序节点）上限的性能阻塞。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;经过技术调研，制定初步技术解法如下：&lt;/p&gt;
&lt;h3&gt;视频方面：性能优化方案&lt;/h3&gt;
&lt;p&gt;针对网课视频时长大（半小时至数小时）的特点，不能采用全量下载。经过调研，制定了分阶段的优化方案。&lt;/p&gt;
&lt;h4&gt;1. 核心技术原理&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HTTP Range Requests (范围请求)&lt;/strong&gt;：这是“边下边播”和“拖拽进度”的基石。播放器通过 &lt;code&gt;Range: bytes=start-end&lt;/code&gt; 请求头，只请求视频的特定片段，而非下载整个文件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;H.264 编码&lt;/strong&gt;：兼容性最好的视频编码格式，确保在各种设备上利用硬件解码，省电且流畅。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. 实施路线图&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;第一阶段：短期快速优化（前端主导）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;启用 Range 请求&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;确认 OSS 或后端接口支持 &lt;code&gt;Range&lt;/code&gt; 请求头（返回 206 状态码）。主流 OSS（阿里云、腾讯云）默认支持。&lt;/li&gt;
&lt;li&gt;前端 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 组件直接使用链接，会自动发送 Range 请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;智能预加载&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;列表页&lt;/strong&gt;：静默预加载下一节课的元数据（时长、大小）或首个分片（前 1-2MB），利用 &lt;code&gt;uni.downloadFile&lt;/code&gt; 或 XHR。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;详情页&lt;/strong&gt;：&lt;code&gt;onLoad&lt;/code&gt; 时立即请求视频地址并初始化播放器，设置 &lt;code&gt;preload=&quot;auto&quot;&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;体验优化&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;利用 &lt;code&gt;initial-time&lt;/code&gt; 实现断点续播。&lt;/li&gt;
&lt;li&gt;监控下载速率，若持续低于码率则提示用户切换清晰度（如果有多清晰度源）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;第二阶段：长期架构升级（服务端主导）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HLS 流媒体&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;将原始 MP4 转码为 &lt;strong&gt;HLS (.m3u8 + .ts)&lt;/strong&gt; 格式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;切片&lt;/strong&gt;：将大文件切成小的 &lt;code&gt;.ts&lt;/code&gt; 文件，秒开速度更快。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多码率适配&lt;/strong&gt;：生成不同清晰度（1080p, 720p, 480p）的流，播放器根据网络状况提醒用户切换,如果开了自动则自动切换（Adaptive Bitrate Streaming）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDN 加速&lt;/strong&gt;：配合 CDN 分发切片文件，降低延迟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. 决策建议&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;立即行动&lt;/strong&gt;：检查现有视频文件是否为 &lt;strong&gt;H.264 编码的 MP4&lt;/strong&gt;。
&lt;ul&gt;
&lt;li&gt;如果是：直接上 Range 请求 + 预加载方案。&lt;/li&gt;
&lt;li&gt;如果不是：优先转码为 H.264。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后续规划&lt;/strong&gt;：随着用户量增长，搭建服务端转码服务，向 HLS 方案迁移。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;列表方面：虚拟列表 vs 懒加载&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;懒加载（Lazy Loading）&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;本质：数据层按需加载（例如先加载前 20 条，滚动到底再加载下 20 条）；&lt;/li&gt;
&lt;li&gt;问题：虽然减少了初始请求，但最终仍可能渲染大量 DOM 元素，导致页面卡顿。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;虚拟列表（Virtual List）&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;本质：渲染层优化的进阶方案，只渲染可视区域内的少量 DOM 元素；&lt;/li&gt;
&lt;li&gt;核心：通过占位容器模拟总高度，滚动时回收离开可视区域的 DOM，用它们渲染新进入可视区域的数据；&lt;/li&gt;
&lt;li&gt;关键技术点：
&lt;ul&gt;
&lt;li&gt;计算每个项目高度（固定或动态）；&lt;/li&gt;
&lt;li&gt;根据滚动位置计算显示范围（startIndex、endIndex）；&lt;/li&gt;
&lt;li&gt;使用偏移（例如 transform: translateY(...)）来模拟正确位置。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>全栈进阶：手撸核心业务逻辑</title><link>https://blog.moxiaoshuai.fun/2025-12-%E5%85%A8%E6%A0%88%E8%BF%9B%E9%98%B6%E6%89%8B%E6%92%B8%E6%A0%B8%E5%BF%83%E9%80%BB%E8%BE%91/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-12-%E5%85%A8%E6%A0%88%E8%BF%9B%E9%98%B6%E6%89%8B%E6%92%B8%E6%A0%B8%E5%BF%83%E9%80%BB%E8%BE%91/</guid><description>围绕路由、状态管理与架构调整复盘一次全栈重构进阶。</description><pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心思考：理清前后端边界与状态流转&lt;/h2&gt;
&lt;p&gt;从纯粹依赖外部代码片段提示，走向自主重构与组件设计，对 Prisma 数据建模、前端 Pinia 状态管理以及路由架构有了更深的认知。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;路由的物理与逻辑边界&lt;/strong&gt;：
从功能定位上理清了前端与后端路由的职责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;前端路由 (Vue Router)&lt;/strong&gt;：控制视图层展现。基于 HTML5 History API 拦截 URL 变化并映射至对应视图组件，规避了全局重载。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端路由 (Express Router)&lt;/strong&gt;：控制资源与数据分发。遵循 RESTful 或其他规范对应至特定的业务逻辑/ Controller。
两者的解耦是现代前后端分离协作的地基。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;单一数据源 (Single Source of Truth)&lt;/strong&gt;：
在重构跨路由媒体播放器状态（播放、暂停、进度）过程中，再次印证了状态集约化管理的收效。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;散列化反模式&lt;/strong&gt;：视图容器、&lt;code&gt;Audio&lt;/code&gt; 实例本身以及 LocalStorage 互相监听同步，将不可逆地引发状态撕裂和循环依赖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;集约化正解&lt;/strong&gt;：Pinia 承担绝对且唯一的状态代理（大脑），播放器组件仅执行响应，LocalStorage 负责挂载/卸载时的数据快照。所有变更一律流经 Store 声明的 Action 函数。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;核心功能重构与实现路径解析&lt;/h2&gt;
&lt;h3&gt;1. 全栈开发流程 (Prisma + Express + Vue)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;场景&lt;/strong&gt;：实现一个“用户列表”功能。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Step 1: 数据建模 (后端)&lt;/strong&gt;
在 &lt;code&gt;schema.prisma&lt;/code&gt; 定义模型，这是数据的源头。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model User {
  id       Int    @id @default(autoincrement())
  username String @unique
  role     String @default(&quot;user&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行 &lt;code&gt;npx prisma db push&lt;/code&gt; 同步数据库。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Step 2: 接口开发 (后端)&lt;/strong&gt;
在 &lt;code&gt;userController.ts&lt;/code&gt; 中写逻辑，在 &lt;code&gt;index.ts&lt;/code&gt; 注册路由。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// controller
export const getUsers = async (req, res) =&amp;gt; {
  const users = await prisma.user.findMany();
  res.json(users);
};
// router
app.get(&apos;/api/users&apos;, getUsers);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Step 3: 状态管理 (前端)&lt;/strong&gt;
在 &lt;code&gt;stores/user.ts&lt;/code&gt; 定义 Store。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const useUserStore = defineStore(&apos;user&apos;, () =&amp;gt; {
  const userList = ref([]);
  const fetchUsers = async () =&amp;gt; {
    const res = await fetch(&apos;/api/users&apos;);
    userList.value = await res.json();
  };
  return { userList, fetchUsers };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Step 4: 视图渲染 (前端)&lt;/strong&gt;
组件三部曲：Store -&amp;gt; Action -&amp;gt; Template。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script setup&amp;gt;
const userStore = useUserStore();
onMounted(() =&amp;gt; userStore.fetchUsers());
&amp;lt;/script&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div v-for=&quot;user in userStore.userList&quot; :key=&quot;user.id&quot;&amp;gt;{{ user.username }}&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 播放器状态持久化 (Pinia + LocalStorage)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;场景&lt;/strong&gt;：刷新页面后，歌曲能接着上次的进度播放。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;核心逻辑&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;存&lt;/strong&gt;：监听切歌 (&lt;code&gt;watch&lt;/code&gt;) 和页面关闭 (&lt;code&gt;beforeunload&lt;/code&gt;)，将 &lt;code&gt;currentSong&lt;/code&gt; 和 &lt;code&gt;currentTime&lt;/code&gt; 存入 localStorage。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;取&lt;/strong&gt;：Store 初始化时 (&lt;code&gt;restoreState&lt;/code&gt;) 读取 localStorage。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同步&lt;/strong&gt;：利用 &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; 的 &lt;code&gt;loadedmetadata&lt;/code&gt; 事件，在音频元数据加载完成后，立即设置 &lt;code&gt;currentTime&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关键代码 (Store)&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 保存状态
const saveState = () =&amp;gt; {
  localStorage.setItem(&apos;player-state&apos;, JSON.stringify({
    song: currentSong.value,
    time: currentTime.value
  }));
};

// 恢复状态
const restoreState = () =&amp;gt; {
  const saved = localStorage.getItem(&apos;player-state&apos;);
  if (saved) {
    const { song, time } = JSON.parse(saved);
    currentSong.value = song;
    currentTime.value = time; // 恢复进度变量
  }
};

// 自动保存策略
watch(currentSong, saveState); // 切歌存
window.addEventListener(&apos;beforeunload&apos;, saveState); // 关窗存
restoreState(); // 启动取
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关键代码 (组件)&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;audio
  ref=&quot;audioRef&quot;
  @timeupdate=&quot;(e) =&amp;gt; playerStore.currentTime = e.target.currentTime&quot; &amp;lt;!-- DOM -&amp;gt; Store --&amp;gt;
  @loadedmetadata=&quot;audioRef.currentTime = playerStore.currentTime&quot;    &amp;lt;!-- Store -&amp;gt; DOM (断点续传) --&amp;gt;
&amp;gt;&amp;lt;/audio&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 每日推荐 (前端算法 + 缓存)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;场景&lt;/strong&gt;：每天展示 10 首随机歌曲，当天刷新不变，第二天自动刷新。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实现原理&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;检查缓存&lt;/strong&gt;：对比 &lt;code&gt;localStorage&lt;/code&gt; 里的日期和今天是否一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一致&lt;/strong&gt;：直接用缓存里的 ID 列表去 Store 里找歌。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不一致&lt;/strong&gt;：执行洗牌算法，生成新列表，存入缓存。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关键代码&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const generateDailyRecommend = () =&amp;gt; {
  const today = dayjs().format(&apos;YYYY-MM-DD&apos;);
  const cachedDate = localStorage.getItem(&apos;daily_date&apos;);

  if (cachedDate !== today) {
    // 洗牌算法：随机排序后取前10
    const randomSongs = [...songList.value]
      .sort(() =&amp;gt; 0.5 - Math.random())
      .slice(0, 10);

    // 更新缓存
    localStorage.setItem(&apos;daily_date&apos;, today);
    localStorage.setItem(&apos;daily_ids&apos;, JSON.stringify(randomSongs.map(s =&amp;gt; s.id)));
    recommendSongs.value = randomSongs;
  } else {
    // 走缓存逻辑...
  }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. 全局播放器架构 (Layout 提升)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;场景&lt;/strong&gt;：切换路由（如从首页进专辑页），音乐不中断。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;问题根因&lt;/strong&gt;：
原结构中，播放器组件 (&lt;code&gt;AppFooterPlayerBar&lt;/code&gt;) 放在各个页面 (&lt;code&gt;HomeView&lt;/code&gt;) 内部。路由切换会导致页面销毁 -&amp;gt; 播放器销毁 -&amp;gt; 音乐停止。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;解决方案&lt;/strong&gt;：
将播放器提升到 &lt;code&gt;App.vue&lt;/code&gt; 或全局 Layout 中，包裹 &lt;code&gt;RouterView&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;架构变更&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- App.vue (新结构) --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;BasicLayout&amp;gt;
    &amp;lt;!-- 播放器在这里，永远不销毁 --&amp;gt;
    &amp;lt;RouterView /&amp;gt; &amp;lt;!-- 只有这里的内容在变 --&amp;gt;
  &amp;lt;/BasicLayout&amp;gt;
&amp;lt;/template&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;阶段性的重构充分说明，“能够跑通”只满足工程阶段其一，组件树和全局状态树从一开始的合理化设计，对后续重构调试的增益要远远大于补丁代码产生的心智消耗。&lt;/p&gt;
</content:encoded></item><item><title>全栈第一步：前后端打通</title><link>https://blog.moxiaoshuai.fun/2025-12-%E5%85%A8%E6%A0%88%E7%AC%AC%E4%B8%80%E6%AD%A5%E5%89%8D%E5%90%8E%E7%AB%AF%E6%89%93%E9%80%9A/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-12-%E5%85%A8%E6%A0%88%E7%AC%AC%E4%B8%80%E6%AD%A5%E5%89%8D%E5%90%8E%E7%AB%AF%E6%89%93%E9%80%9A/</guid><description>记录从后端基架到前端渲染打通全链路的第一阶段实践。</description><pubDate>Mon, 08 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心思考&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;全栈的本质是“数据流转”&lt;/strong&gt;：
从宏观架构来看，后端工程相当于承担着“数据总线”与“管理中枢”的角色。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数据库 (SQLite)&lt;/strong&gt;：数据持久化下层，承载存储职责。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prisma&lt;/strong&gt;：ORM 工具，负责数据映射与数据库交互。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Express&lt;/strong&gt;：网关或控制层，处理 HTTP 请求与数据分发。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前端 (Vue)&lt;/strong&gt;：视图层，获取 JSON 序列化数据并渲染为客户端 UI。
打通全链路后，技术栈从单一孤岛合并成了数据流闭环。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TypeScript 的约束一致性&lt;/strong&gt;：
当前后端均切入 TypeScript 体系后，类型定义能够实现复用。在服务端定义的数据模型（如结构体），能够让前端在进行网络请求处理时直接做类型推断，极大降低了沟通成本与调试阻力。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;知识内化（技术栈复盘）&lt;/h2&gt;
&lt;h3&gt;🛠️ 后端基架 (Node + Express + TS)&lt;/h3&gt;
&lt;p&gt;从零搭建了一个标准的 TS 后端项目：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;环境配置&lt;/strong&gt;：&lt;code&gt;npm init&lt;/code&gt; 起手，装 &lt;code&gt;express&lt;/code&gt;、&lt;code&gt;cors&lt;/code&gt;、&lt;code&gt;dotenv&lt;/code&gt; 三件套。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TS 加持&lt;/strong&gt;：配置 &lt;code&gt;tsconfig.json&lt;/code&gt;，用 &lt;code&gt;ts-node&lt;/code&gt; 直接运行 TS 代码，配合 &lt;code&gt;nodemon&lt;/code&gt; 实现热更新（改一行代码，服务自动重启，不用手动切终端）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ORM 神器 (Prisma)&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;不用写 SQL 语句，直接定义 &lt;code&gt;schema.prisma&lt;/code&gt; 模型。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npx prisma db push&lt;/code&gt; 一键同步数据库结构。&lt;/li&gt;
&lt;li&gt;写了个 &lt;code&gt;seed.ts&lt;/code&gt; 脚本，一键注入测试数据，再也不用手动插数据了。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;🔗 前后端通信 (The Bridge)&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;跨域 (CORS) 与 代理 (Proxy)&lt;/strong&gt;：
虽然后端装了 &lt;code&gt;cors&lt;/code&gt;，但在开发环境我用了 Vite 的 &lt;code&gt;server.proxy&lt;/code&gt;。
&lt;ul&gt;
&lt;li&gt;前端请求 &lt;code&gt;/api/songs&lt;/code&gt; -&amp;gt; Vite 转发 -&amp;gt; &lt;code&gt;localhost:3000/api/songs&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;这样既解决了跨域，又让前端代码看起来像是在请求同源接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据消费 (Pinia)&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;在 Store 里写 &lt;code&gt;fetchSongList&lt;/code&gt;，拿到数据存进 &lt;code&gt;state&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;配合 &lt;code&gt;pinia-plugin-persistedstate&lt;/code&gt;，刷新页面数据还在，体验极佳。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>可视化大屏部署踩坑实录</title><link>https://blog.moxiaoshuai.fun/2025-12-%E5%8F%AF%E8%A7%86%E5%8C%96%E5%A4%A7%E5%B1%8F%E9%83%A8%E7%BD%B2%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-12-%E5%8F%AF%E8%A7%86%E5%8C%96%E5%A4%A7%E5%B1%8F%E9%83%A8%E7%BD%B2%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95/</guid><description>复盘命令行与 1Panel 两种部署路径的踩坑经验与取舍。</description><pubDate>Sun, 07 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;近期承接了一个可视化大屏的业务，并借助 AI 和文档支持，先后实践了&lt;strong&gt;命令行&lt;/strong&gt;和&lt;strong&gt;可视化面板&lt;/strong&gt;这两种前端项目部署方式。在此复盘部署过程中踩过的坑，以及不同路线的选型取舍，为纯静态站点的部署积累实践经验。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第一步：服务器选购配置建议&lt;/h2&gt;
&lt;p&gt;由于前期采用按需配置思路不够明确，入手了一台 &lt;strong&gt;4核 4G&lt;/strong&gt; 的服务器。但对于纯静态网站（Static Site）的部署而言，这个配置存在性能冗余。
如果只是部署前端静态页面，未引入服务端渲染（SSR）或数据库服务，入门级的 1核 2G 甚至更低配置完全够用。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第二步：命令行部署方式 (Bash / PowerShell + serve)&lt;/h2&gt;
&lt;p&gt;为了弄懂原理，我先尝试了最原始的命令行方式。虽然过程坎坷，但学到了很多底层逻辑。&lt;/p&gt;
&lt;h3&gt;流程复盘&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;环境准备&lt;/strong&gt;：登录服务器，安装 Node.js 和 npm。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文件上传&lt;/strong&gt;：使用 &lt;code&gt;scp -r&lt;/code&gt; 指令直接从本地把 &lt;code&gt;dist&lt;/code&gt; 文件夹传到服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;启动服务&lt;/strong&gt;：安装 &lt;code&gt;serve&lt;/code&gt; (&lt;code&gt;npm install -g serve&lt;/code&gt;)，然后监听端口启动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;防火墙设置&lt;/strong&gt;：这是最容易忘的一步！必须去腾讯云控制台的防火墙里放行对应端口。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;💡 踩坑与经验&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AI 虽好，尽信书不如无书&lt;/strong&gt;：腾讯云自带的 AI 助手在服务器相关问题很聪明，但是比较难进行提示词工程，外部的ai比较容易自定义，我用起来比较顺手，但是给出的指令有时候是通用的（比如其他地方默认用户名是 &lt;code&gt;root&lt;/code&gt;），而腾讯云 Ubuntu 实例的默认用户名其实是 &lt;code&gt;ubuntu&lt;/code&gt;，因为这个问题也害我多做不少无用功。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;执行指令前要“二审”&lt;/strong&gt;：AI 给出的指令，最好先问问另一个 AI “这条指令是干嘛的”，确认无误再执行，防止误操作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;排错优先级&lt;/strong&gt;：&lt;strong&gt;官方文档/说明书 &amp;gt; 独立思考 &amp;gt; 问 AI&lt;/strong&gt;。不要一上来就问 AI，很容易被带偏。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;第三步：可视化管理面板部署 (1Panel)&lt;/h2&gt;
&lt;p&gt;在折腾完命令行后，尝试了现代化的服务器图形化管理工具 &lt;strong&gt;1Panel&lt;/strong&gt;（类似宝塔等服务管理面板）。&lt;/p&gt;
&lt;h3&gt;体验与优势&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;界面化运维&lt;/strong&gt;：将常规文件上传、解压、移动等指令映射为可视化操作，降低失误率。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;便捷的拖拽上传&lt;/strong&gt;：越过基础 &lt;code&gt;scp&lt;/code&gt; 或者 &lt;code&gt;sftp&lt;/code&gt; 命令的路径配置痛点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;傻瓜式配置&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;安装 OpenResty / Nginx 容器环境。&lt;/li&gt;
&lt;li&gt;一键配置反向代理、静态根目录和端口监听。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;结果评估&lt;/h3&gt;
&lt;p&gt;依托可视化面板操作，能够快速构建出可复用的部署路径，将部署交付时间压缩至更低的维度。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;两种部署方式的对比总结：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若以&lt;strong&gt;夯实 Linux 基础&lt;/strong&gt;为目标，推荐使用 &lt;strong&gt;命令行部署&lt;/strong&gt;。能从中建立起包括文件权限、端口监听以及云端防火墙配置等一系列底层运维认知。&lt;/li&gt;
&lt;li&gt;若以&lt;strong&gt;业务系统快速上线&lt;/strong&gt;为目标，推荐使用 &lt;strong&gt;1Panel 等容器化或可视化运维工具&lt;/strong&gt;。其可显著减少不必要的开发配置时间。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>开源破冰：首个高星项目 PR</title><link>https://blog.moxiaoshuai.fun/2025-11-%E5%BC%80%E6%BA%90%E7%A0%B4%E5%86%B0%E9%A6%96%E4%B8%AA%E9%AB%98%E6%98%9F%E9%A1%B9%E7%9B%AEpr/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-11-%E5%BC%80%E6%BA%90%E7%A0%B4%E5%86%B0%E9%A6%96%E4%B8%AA%E9%AB%98%E6%98%9F%E9%A1%B9%E7%9B%AEpr/</guid><description>记录第一次向高星开源项目提交并合并 PR 的过程与方法。</description><pubDate>Sat, 29 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心思考&lt;/h2&gt;
&lt;p&gt;今天最大的感触是：&lt;strong&gt;开源贡献的门槛，其实都在“代码之外”。&lt;/strong&gt;
给大型项目（如 Dify、Ant Design）修 bug 未必需要极高的技术储备，有时只是给 CSS 加个 &lt;code&gt;z-index&lt;/code&gt;。
&lt;strong&gt;难的不是写那一行代码，而是敢去领 Issue，耐心配置环境（熟练使用 Docker 是关键），以及严格遵循开源社区的 PR 流程。&lt;/strong&gt; 只要迈出第一步，大型开源项目也是相对容易参与的。&lt;/p&gt;
&lt;h2&gt;知识内化与经验总结&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关于 &lt;code&gt;good first issue&lt;/code&gt;（新手村任务）&lt;/strong&gt;：
以前不知道从哪下手，今天才发现这个标签就是给新手的“入场券”。它不需要你懂整个项目的架构，可能只是修个文档、改个样式。
&lt;strong&gt;我的理解：&lt;/strong&gt; 不要看不起小修小补，这是熟悉开源流程的最佳练兵场。先混个脸熟，再图谋大事。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关于“环境配置”是最大的拦路虎&lt;/strong&gt;：
改代码 5 分钟，配环境 2 小时。为了跑起 Dify，我第一次被迫学会了用 Docker。
&lt;strong&gt;我的理解：&lt;/strong&gt; 以前觉得 Docker 麻烦，现在才懂它是开源协作的神器。如果没有它，光是统一几百个开发者的本地环境就能把维护者累死。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;关于 AI 结对编程&lt;/strong&gt;：
从“怎么领 Issue”到“怎么规范提交 Commit”，全靠 AI 手把手教。
&lt;strong&gt;我的理解：&lt;/strong&gt; AI 就像一个经验丰富的开源老兵，它填补了我对“潜规则”和“流程”的认知空白，让我能专注于解决问题本身。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>从类型契约到云端直传</title><link>https://blog.moxiaoshuai.fun/2025-11-%E4%BB%8E%E7%B1%BB%E5%9E%8B%E5%A5%91%E7%BA%A6%E5%88%B0%E4%BA%91%E7%AB%AF%E7%9B%B4%E4%BC%A0/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-11-%E4%BB%8E%E7%B1%BB%E5%9E%8B%E5%A5%91%E7%BA%A6%E5%88%B0%E4%BA%91%E7%AB%AF%E7%9B%B4%E4%BC%A0/</guid><description>梳理接口类型契约、云存储直传与 CDN 路径设计的核心理解。</description><pubDate>Thu, 27 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心架构思考&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;数据的单一来源&lt;/strong&gt; 与 &lt;strong&gt;规避代码坏味道&lt;/strong&gt; 构成了前后端规约及业务联调过程中的准则。如果在前端组件中随处将 CDN 协议及域名写死（例如 &lt;code&gt;http://cdn.xxx.cn/...&lt;/code&gt;）进行访问和展示的话，将会极大地增加工程长期维护的包袱。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心准则：数据库存“相对路径”获取物理灵活性，前端在运行时拼接“绝对路径”满足渲染需求。&lt;/strong&gt; 这种架构隔离方式，使得资源域名发生变化时，只需在基础配置常量处修改一层即可，业务层的多处调用完全解耦。&lt;/p&gt;
&lt;h2&gt;知识内化与经验总结&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;接口契约（&lt;code&gt;api.d.ts&lt;/code&gt;）的边界与作用&lt;/strong&gt;：
契约文件不仅仅是后端服务提供的“调用说明书”，更是自动化的“前置保镖”。配合 TypeScript 体系，可以在开发或编译时便能规避不规范或者突变的入参类型，提前防御潜在的调用灾难。自动生成的请求类型层更是能避免双端沟通的滞后问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;前端云存储直传流程解析&lt;/strong&gt;：
相比由后端接收资源流再传输至 OSS/COS 节点的方式，前端直传规避了由于后端宽带和业务阻塞导致的上传瓶颈。具体的流转时序如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;请求凭证（后端）&lt;/strong&gt;：生成包含上传有效期的 STS“通行证”（临时密钥），不承担文件实体流转（节省带宽成本）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;发起上传（前端）&lt;/strong&gt;：附带 STS 凭证，依照特定签名将表单格式的二进制文件推至腾讯云（COS 等）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;返回元数据（云存储）&lt;/strong&gt;：下发成功回执，返回文件上传最终落地的路径和名称。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;信息闭环&lt;/strong&gt;：前端将纯相对路径推给业务后端落库。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;CDN 存储最佳实践&lt;/strong&gt;：
&lt;strong&gt;“相对路径”是核心特征，“绝对路径”是端态映射。&lt;/strong&gt; 在数据库底层维护如 &lt;code&gt;cat/avatar/xx.jpg&lt;/code&gt; 的纯相对路径；在前端渲染或网络请求层，基于全局通用函数诸如 &lt;code&gt;getCDNUrl(path)&lt;/code&gt; 做协议或域名的动态组装。这种范式让基础设施或者提供商在未来具备无痛切流和迁移的能力。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;不管是“云开发机制”还是“CDN全链路”，其底层拆解后依然是受限于计算机网络通信与架构设计的字符串流转。通过剔除工程当中的“代码坏味道”，技术解构所带来的架构清爽度才是前端工程化的魅力所在。&lt;/p&gt;
</content:encoded></item><item><title>地图选点与瀑布流实践</title><link>https://blog.moxiaoshuai.fun/2025-11-%E5%9C%B0%E5%9B%BE%E9%80%89%E7%82%B9%E4%B8%8E%E7%80%91%E5%B8%83%E6%B5%81%E5%AE%9E%E8%B7%B5/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-11-%E5%9C%B0%E5%9B%BE%E9%80%89%E7%82%B9%E4%B8%8E%E7%80%91%E5%B8%83%E6%B5%81%E5%AE%9E%E8%B7%B5/</guid><description>总结地图拖拽选点与相册瀑布流实现中的交互设计与算法要点。</description><pubDate>Thu, 27 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心交互顿悟&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;交互模型：地图选点的逆向思维&lt;/strong&gt;：
通常可能会将选点视为“点击地图目标坐标以转移图钉”。实际上，目前最主流且流畅的体验是**“固定中心图钉，移动地图底图”**。即屏幕中心的交互反馈点始终如一，用户移动的是底层的数据映射图层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;布局模型：瀑布流的本质渲染调度&lt;/strong&gt;：
相较于依托复杂的 CSS 栅格，利用 JS 维护并行数组列去分配图片资源更加具备生产力与适应性。结合 &lt;code&gt;widthFix&lt;/code&gt; 或者宽带限制进行高度自适应延展，即可在移动端上建立流畅的渲染模型。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;技术内化复现解析&lt;/h2&gt;
&lt;h3&gt;🗺️ 技能一：拖动地图选址（Map Dragging）&lt;/h3&gt;
&lt;p&gt;这是一个“配置+布局+逻辑”的组合拳。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 权限配置（门票）&lt;/strong&gt;
在 &lt;code&gt;app.json&lt;/code&gt; 中必须声明，否则 &lt;code&gt;wx.getLocation&lt;/code&gt; 直接报错。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;permission&quot;: { &quot;scope.userLocation&quot;: { &quot;desc&quot;: &quot;用于定位&quot; } },
&quot;requiredPrivateInfos&quot;: [&quot;getLocation&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. 布局技巧（定海神针）&lt;/strong&gt;
核心是&lt;strong&gt;层叠布局&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;容器&lt;/strong&gt;：&lt;code&gt;position: relative&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;地图&lt;/strong&gt;：铺满容器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图钉&lt;/strong&gt;：&lt;code&gt;position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动画&lt;/strong&gt;：监听拖动状态，拖动时 &lt;code&gt;translate Y&lt;/code&gt; 向上位移更多（抬起），停止时复位（扎下）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3. 核心逻辑（取值）&lt;/strong&gt;
不要在拖动过程中疯狂更新，只在&lt;strong&gt;停下&lt;/strong&gt;那一刻取值。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;监听&lt;/strong&gt;：&lt;code&gt;bindregionchange&lt;/code&gt; 事件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;判断&lt;/strong&gt;：&lt;code&gt;e.type === &apos;end&apos;&lt;/code&gt; 且 &lt;code&gt;e.causedBy === &apos;drag&apos;&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;获取&lt;/strong&gt;：调用 &lt;code&gt;mapCtx.getCenterLocation()&lt;/code&gt; 拿到当前地图中心的经纬度。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;🌊 技能二：相册瀑布流（Waterfall Layout）&lt;/h3&gt;
&lt;p&gt;我们采用了&lt;strong&gt;JS分列 + Flex布局&lt;/strong&gt;的方案，比纯 CSS 更可控。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 核心原理（发牌算法）&lt;/strong&gt;
准备 3 个数组（左、中、右），遍历图片列表，按顺序轮询分配。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 伪代码：Round-Robin 分配
images.forEach((img, index) =&amp;gt; {
  if (index % 3 === 0) leftList.push(img);
  else if (index % 3 === 1) centerList.push(img);
  else rightList.push(img);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. 关键属性（自适应高度）&lt;/strong&gt;
在 WXML 中，图片组件必须设置 &lt;code&gt;mode=&quot;widthFix&quot;&lt;/code&gt;。
这样我们只需要用 CSS 固定每列的宽度（例如 32%），图片高度就会根据原图比例自动撑开，形成错落有致的效果。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;看似复杂的端上交互模式脱离表现层后，实际上依然是朴素的结构化算法支撑：地图拖曳是相对平移下的监听映射，瀑布流是列阵集合的任务调度分发。从工程角度建立模型从而实现降维解法，是前端走向进阶所需要掌握的重要能力。&lt;/p&gt;
</content:encoded></item><item><title>从手抄代码到初涉工程化</title><link>https://blog.moxiaoshuai.fun/2025-11-%E4%BB%8E%E6%89%8B%E6%8A%84%E4%BB%A3%E7%A0%81%E5%88%B0%E5%88%9D%E6%B6%89%E5%B7%A5%E7%A8%8B%E5%8C%96/</link><guid isPermaLink="true">https://blog.moxiaoshuai.fun/2025-11-%E4%BB%8E%E6%89%8B%E6%8A%84%E4%BB%A3%E7%A0%81%E5%88%B0%E5%88%9D%E6%B6%89%E5%B7%A5%E7%A8%8B%E5%8C%96/</guid><description>复盘如何从功能堆砌转向规则驱动的工程化开发思路。</description><pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;核心复盘与顿悟&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;写代码最难的不是语法，而是“定义需求”&lt;/strong&gt;：
以前遇到“继续游戏”这种功能，经常陷入上来就写 &lt;code&gt;router.push&lt;/code&gt; 的局限。事实上必须先反问“什么是继续游戏”、“中断点存在哪”等边界条件，&lt;strong&gt;在实现复杂需求前把业务状态流转梳理清晰，后续代码落地才能顺水推舟&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工程化开发不是堆砌功能，而是“制定规则”&lt;/strong&gt;：
“继续游戏”看似简单，实质是对&lt;strong&gt;游戏状态机甚至持久化体系的完整建模&lt;/strong&gt;。路由守卫不再是单纯的拦截器，而是维护整个系统一致性的规则关卡。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;知识内化与经验沉淀&lt;/h2&gt;
&lt;h3&gt;🛠️ 工程化思维&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;拒绝重复造轮子&lt;/strong&gt;：
以前开新项目靠复制粘贴，今天把模板传到 GitHub，配合 &lt;code&gt;new-ccb&lt;/code&gt; 脚本，一行命令拉取环境。&lt;strong&gt;GitHub 是仓库，脚本是物流车&lt;/strong&gt;，自动化才是程序员该干的事。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;🏗️ 架构与逻辑设计&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;显式优于隐式（以 Continue 功能为例）&lt;/strong&gt;：
纠结过用路由守卫自动记录路径，但最终选择在关键页面&lt;strong&gt;手动更新&lt;/strong&gt; &lt;code&gt;continuePath&lt;/code&gt;。虽然多写一行代码，但逻辑绝对可控，避免了误触等垃圾信息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pinia 的角色划分与持久化&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;持久化&lt;/strong&gt;：不用自己操心 &lt;code&gt;localStorage&lt;/code&gt;，Pinia 配合插件既有响应式又能自动存本地。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边界清晰&lt;/strong&gt;：队伍、关卡、游戏全局状态拆分成不同的 Store，各司其职，避免大杂烩。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Router 即流程&lt;/strong&gt;：
未选角不能进地图、未通关不能进下一关。这些规则写在守卫里，Router 就成了维护游戏流程完整性的核心骨架。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;将需求前置，通过清晰的状态管理和确定的规则执行，能够最大程度提升代码的可控性。从“手工作坊”般复制粘贴代码，切入到具备可组装、自动化流程的工程思维，这正是长期系统建设中的重要基石。&lt;/p&gt;
</content:encoded></item></channel></rss>