<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>JCAlways</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://blog.zhangsifan.com/</id>
  <link href="https://blog.zhangsifan.com/" rel="alternate"/>
  <link href="https://blog.zhangsifan.com/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, JCAlways</rights>
  <subtitle>前端开发与编程技术笔记 | 张思帆的博客</subtitle>
  <title>JCAlways</title>
  <updated>2026-10-14T16:00:00.000Z</updated>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="Electron" scheme="https://blog.zhangsifan.com/categories/Electron/"/>
    <category term="Electron" scheme="https://blog.zhangsifan.com/tags/Electron/"/>
    <category term="桌面应用" scheme="https://blog.zhangsifan.com/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
    <category term="Node.js" scheme="https://blog.zhangsifan.com/tags/Node-js/"/>
    <content>
      <![CDATA[<h2 id="Electron-是什么"><a href="#Electron-是什么" class="headerlink" title="Electron 是什么"></a>Electron 是什么</h2><p>Electron 用 Chromium（渲染界面）+ Node.js（系统能力）构建跨平台桌面应用。前端技术栈（HTML&#x2F;CSS&#x2F;JS、Vue、React）可以直接用来做桌面软件，VS Code、Slack、飞书桌面版都是它的代表。</p><h2 id="一、理解三个进程"><a href="#一、理解三个进程" class="headerlink" title="一、理解三个进程"></a>一、理解三个进程</h2><p>Electron 应用由三部分协作：</p><table><thead><tr><th>部分</th><th>作用</th><th>能访问什么</th></tr></thead><tbody><tr><td>主进程（main）</td><td>应用入口，创建窗口、管理系统事件</td><td>Node.js 全部能力（文件系统、网络、系统 API）</td></tr><tr><td>渲染进程（renderer）</td><td>页面 UI，运行在 Chromium 里</td><td>DOM、一部分受限 Node 能力</td></tr><tr><td>预加载脚本（preload）</td><td>在渲染进程加载前执行，桥接主进程与渲染进程</td><td>通过 contextBridge 暴露安全 API</td></tr></tbody></table><p>安全模型：渲染进程不能直接碰 Node.js API，所有系统操作都要通过 IPC（进程间通信）找主进程完成。</p><h2 id="二、初始化项目"><a href="#二、初始化项目" class="headerlink" title="二、初始化项目"></a>二、初始化项目</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">mkdir</span> my-electron-app &amp;&amp; <span class="built_in">cd</span> my-electron-app</span><br><span class="line">npm init -y</span><br><span class="line">npm install --save-dev electron</span><br></pre></td></tr></table></figure><p>目录结构：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">my-electron-app/</span><br><span class="line">├── main.js       # 主进程</span><br><span class="line">├── preload.js    # 预加载脚本</span><br><span class="line">├── index.html    # 渲染进程页面</span><br><span class="line">└── package.json</span><br></pre></td></tr></table></figure><p>package.json 里声明入口：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;main&quot;</span><span class="punctuation">:</span> <span class="string">&quot;main.js&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;start&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron .&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h2 id="三、主进程：创建窗口"><a href="#三、主进程：创建窗口" class="headerlink" title="三、主进程：创建窗口"></a>三、主进程：创建窗口</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// main.js</span></span><br><span class="line"><span class="keyword">const</span> &#123; app, <span class="title class_">BrowserWindow</span>, ipcMain &#125; = <span class="built_in">require</span>(<span class="string">&#x27;electron&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> path = <span class="built_in">require</span>(<span class="string">&#x27;path&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">createWindow</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> win = <span class="keyword">new</span> <span class="title class_">BrowserWindow</span>(&#123;</span><br><span class="line">    <span class="attr">width</span>: <span class="number">1000</span>,</span><br><span class="line">    <span class="attr">height</span>: <span class="number">700</span>,</span><br><span class="line">    <span class="attr">webPreferences</span>: &#123;</span><br><span class="line">      <span class="comment">// 关键安全配置：开启上下文隔离，渲染进程无法直接访问 Node</span></span><br><span class="line">      <span class="attr">contextIsolation</span>: <span class="literal">true</span>,</span><br><span class="line">      <span class="comment">// 使用 preload 脚本做桥接</span></span><br><span class="line">      <span class="attr">preload</span>: path.<span class="title function_">join</span>(__dirname, <span class="string">&#x27;preload.js&#x27;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  win.<span class="title function_">loadFile</span>(<span class="string">&#x27;index.html&#x27;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">whenReady</span>().<span class="title function_">then</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">createWindow</span>()</span><br><span class="line"></span><br><span class="line">  <span class="comment">// macOS：点击 Dock 图标时重建窗口</span></span><br><span class="line">  app.<span class="title function_">on</span>(<span class="string">&#x27;activate&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="title class_">BrowserWindow</span>.<span class="title function_">getAllWindows</span>().<span class="property">length</span> === <span class="number">0</span>) <span class="title function_">createWindow</span>()</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 除 macOS 外，全部窗口关闭时退出</span></span><br><span class="line">app.<span class="title function_">on</span>(<span class="string">&#x27;window-all-closed&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (process.<span class="property">platform</span> !== <span class="string">&#x27;darwin&#x27;</span>) app.<span class="title function_">quit</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="四、IPC-通信：渲染进程请求主进程做事"><a href="#四、IPC-通信：渲染进程请求主进程做事" class="headerlink" title="四、IPC 通信：渲染进程请求主进程做事"></a>四、IPC 通信：渲染进程请求主进程做事</h2><p>场景：页面里点按钮，读取本机文件内容。</p><p><strong>主进程监听（main.js 追加）</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 处理渲染进程发来的 read-file 请求</span></span><br><span class="line">ipcMain.<span class="title function_">handle</span>(<span class="string">&#x27;read-file&#x27;</span>, <span class="title function_">async</span> (event, filePath) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> fs = <span class="built_in">require</span>(<span class="string">&#x27;fs/promises&#x27;</span>)</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> content = <span class="keyword">await</span> fs.<span class="title function_">readFile</span>(filePath, <span class="string">&#x27;utf-8&#x27;</span>)</span><br><span class="line">    <span class="keyword">return</span> &#123; <span class="attr">ok</span>: <span class="literal">true</span>, content &#125;</span><br><span class="line">  &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line">    <span class="keyword">return</span> &#123; <span class="attr">ok</span>: <span class="literal">false</span>, <span class="attr">message</span>: e.<span class="property">message</span> &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>preload 脚本暴露安全 API（preload.js）</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// preload.js</span></span><br><span class="line"><span class="keyword">const</span> &#123; contextBridge, ipcRenderer &#125; = <span class="built_in">require</span>(<span class="string">&#x27;electron&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 只暴露白名单方法，不直接暴露 ipcRenderer 本身</span></span><br><span class="line">contextBridge.<span class="title function_">exposeInMainWorld</span>(<span class="string">&#x27;api&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">readFile</span>: <span class="function">(<span class="params">filePath</span>) =&gt;</span> ipcRenderer.<span class="title function_">invoke</span>(<span class="string">&#x27;read-file&#x27;</span>, filePath)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>渲染进程调用（index.html）</strong>：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">&lt;!DOCTYPE <span class="keyword">html</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">html</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">head</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">meta</span> <span class="attr">charset</span>=<span class="string">&quot;UTF-8&quot;</span> /&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">head</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">body</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">button</span> <span class="attr">id</span>=<span class="string">&quot;btn&quot;</span>&gt;</span>读取文件<span class="tag">&lt;/<span class="name">button</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">pre</span> <span class="attr">id</span>=<span class="string">&quot;result&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">pre</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="tag">&lt;<span class="name">script</span>&gt;</span><span class="language-javascript"></span></span><br><span class="line"><span class="language-javascript">      <span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;btn&#x27;</span>).<span class="title function_">addEventListener</span>(<span class="string">&#x27;click&#x27;</span>, <span class="title function_">async</span> () =&gt; &#123;</span></span><br><span class="line"><span class="language-javascript">        <span class="comment">// 通过 preload 暴露的 api 调用，而不是直接 require(&#x27;fs&#x27;)</span></span></span><br><span class="line"><span class="language-javascript">        <span class="keyword">const</span> res = <span class="keyword">await</span> <span class="variable language_">window</span>.<span class="property">api</span>.<span class="title function_">readFile</span>(<span class="string">&#x27;/etc/hosts&#x27;</span>)</span></span><br><span class="line"><span class="language-javascript">        <span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;result&#x27;</span>).<span class="property">textContent</span> = res.<span class="property">ok</span></span></span><br><span class="line"><span class="language-javascript">          ? res.<span class="property">content</span></span></span><br><span class="line"><span class="language-javascript">          : <span class="string">&#x27;读取失败：&#x27;</span> + res.<span class="property">message</span></span></span><br><span class="line"><span class="language-javascript">      &#125;)</span></span><br><span class="line"><span class="language-javascript">    </span><span class="tag">&lt;/<span class="name">script</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">body</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">html</span>&gt;</span></span><br></pre></td></tr></table></figure><p>通信模式总结：</p><ul><li>渲染 → 主（请求-响应）：ipcRenderer.invoke + ipcMain.handle</li><li>渲染 → 主（单向通知）：ipcRenderer.send + ipcMain.on</li><li>主 → 渲染（推送）：win.webContents.send + ipcRenderer.on（如后台任务进度）</li></ul><p>主进程主动推送示例：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 主进程</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">sendProgress</span>(<span class="params">win, percent</span>) &#123;</span><br><span class="line">  win.<span class="property">webContents</span>.<span class="title function_">send</span>(<span class="string">&#x27;progress&#x27;</span>, percent)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 渲染进程：preload 里加</span></span><br><span class="line"><span class="comment">// onProgress: (cb) =&gt; ipcRenderer.on(&#x27;progress&#x27;, (_e, p) =&gt; cb(p))</span></span><br></pre></td></tr></table></figure><h2 id="五、安全配置清单（生产环境必看）"><a href="#五、安全配置清单（生产环境必看）" class="headerlink" title="五、安全配置清单（生产环境必看）"></a>五、安全配置清单（生产环境必看）</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">webPreferences</span>: &#123;</span><br><span class="line">  <span class="attr">contextIsolation</span>: <span class="literal">true</span>,   <span class="comment">// 必须开启，隔离 Node 环境</span></span><br><span class="line">  <span class="attr">nodeIntegration</span>: <span class="literal">false</span>,   <span class="comment">// 禁止渲染进程直接使用 Node</span></span><br><span class="line">  <span class="attr">preload</span>: path.<span class="title function_">join</span>(__dirname, <span class="string">&#x27;preload.js&#x27;</span>),</span><br><span class="line">  <span class="attr">sandbox</span>: <span class="literal">true</span>             <span class="comment">// 开启沙箱（Electron 20+ 默认）</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li>不要在渲染进程里 require(‘fs’)、require(‘child_process’)，一律通过 preload 白名单走 IPC；</li><li>preload 只暴露必要的白名单方法，不要直接暴露整个 ipcRenderer；</li><li>加载外部页面时（win.loadURL）要格外小心 XSS，本地页面也要转义用户输入。</li></ul><h2 id="六、打包发布"><a href="#六、打包发布" class="headerlink" title="六、打包发布"></a>六、打包发布</h2><p>推荐用 electron-builder：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install --save-dev electron-builder</span><br></pre></td></tr></table></figure><p>package.json 追加：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;appId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;com.example.myapp&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;productName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;MyApp&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;files&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;main.js&quot;</span><span class="punctuation">,</span> <span class="string">&quot;preload.js&quot;</span><span class="punctuation">,</span> <span class="string">&quot;index.html&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;win&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;target&quot;</span><span class="punctuation">:</span> <span class="string">&quot;nsis&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;mac&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;target&quot;</span><span class="punctuation">:</span> <span class="string">&quot;dmg&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron-builder&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm run build</span><br></pre></td></tr></table></figure><p>产物在 dist&#x2F; 目录：Windows 是 .exe 安装包，macOS 是 .dmg。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li>渲染进程拿不到 Node 模块：先检查 contextIsolation&#x2F;nodeIntegration 配置，再检查是否通过 preload 暴露；</li><li>IPC 通道名拼错：invoke 和 handle 的名字必须完全一致；</li><li>打包后白屏：检查 files 配置是否把页面和 preload 打进去了；</li><li>macOS 打包签名：正式分发需要 Apple Developer 证书，本地测试可跳过签名。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Electron 开发的关键是先理解三进程模型，再遵守”一切系统操作走 IPC + preload 白名单”的安全规范。按”主进程建窗口 → preload 暴露 API → 渲染进程调用”的路径，就能搭出规范的桌面应用。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015el/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015el/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>Electron 桌面应用开发入门教程：理解主进程与渲染进程架构，掌握 ipcMain/ipcRenderer 通信与 contextIsolation 安全配置。</summary>
    <title>Electron 开发入门：主进程与渲染进程的通信</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="安卓" scheme="https://blog.zhangsifan.com/categories/%E5%AE%89%E5%8D%93/"/>
    <category term="Android" scheme="https://blog.zhangsifan.com/tags/Android/"/>
    <category term="Kotlin" scheme="https://blog.zhangsifan.com/tags/Kotlin/"/>
    <category term="Jetpack Compose" scheme="https://blog.zhangsifan.com/tags/Jetpack-Compose/"/>
    <category term="安卓" scheme="https://blog.zhangsifan.com/tags/%E5%AE%89%E5%8D%93/"/>
    <content>
      <![CDATA[<h2 id="为什么选择-Jetpack-Compose"><a href="#为什么选择-Jetpack-Compose" class="headerlink" title="为什么选择 Jetpack Compose"></a>为什么选择 Jetpack Compose</h2><p>Jetpack Compose 是 Google 官方的声明式 UI 工具包，用 Kotlin 写界面，替代传统的 XML 布局。核心区别：</p><table><thead><tr><th>传统 View 体系</th><th>Jetpack Compose</th></tr></thead><tbody><tr><td>XML 写布局</td><td>纯 Kotlin 声明 UI</td></tr><tr><td>数据变化手动 <code>findViewById</code> 更新</td><td>状态变化 UI 自动重组</td></tr><tr><td>命令式（告诉系统”怎么改”）</td><td>声明式（告诉系统”长什么样”）</td></tr></tbody></table><h2 id="一、创建项目"><a href="#一、创建项目" class="headerlink" title="一、创建项目"></a>一、创建项目</h2><p>打开 Android Studio，选择 <strong>New Project → Empty Activity</strong>，语言选 <strong>Kotlin</strong>，最低 SDK 建议 API 24+。新版 Android Studio 会自动引入 Compose 依赖。</p><p>关键依赖（<code>app/build.gradle.kts</code>）：</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">dependencies &#123;</span><br><span class="line">    implementation(platform(<span class="string">&quot;androidx.compose:compose-bom:2024.09.03&quot;</span>))</span><br><span class="line">    implementation(<span class="string">&quot;androidx.compose.ui:ui&quot;</span>)</span><br><span class="line">    implementation(<span class="string">&quot;androidx.compose.material3:material3&quot;</span>)</span><br><span class="line">    implementation(<span class="string">&quot;androidx.activity:activity-compose:1.9.2&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="二、第一个-Composable-页面"><a href="#二、第一个-Composable-页面" class="headerlink" title="二、第一个 Composable 页面"></a>二、第一个 Composable 页面</h2><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.myapp</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> android.os.Bundle</span><br><span class="line"><span class="keyword">import</span> androidx.activity.ComponentActivity</span><br><span class="line"><span class="keyword">import</span> androidx.activity.compose.setContent</span><br><span class="line"><span class="keyword">import</span> androidx.compose.foundation.layout.*</span><br><span class="line"><span class="keyword">import</span> androidx.compose.material3.*</span><br><span class="line"><span class="keyword">import</span> androidx.compose.runtime.*</span><br><span class="line"><span class="keyword">import</span> androidx.compose.ui.Alignment</span><br><span class="line"><span class="keyword">import</span> androidx.compose.ui.Modifier</span><br><span class="line"><span class="keyword">import</span> androidx.compose.ui.unit.dp</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MainActivity</span> : <span class="type">ComponentActivity</span>() &#123;</span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onCreate</span><span class="params">(savedInstanceState: <span class="type">Bundle</span>?)</span></span> &#123;</span><br><span class="line">        <span class="keyword">super</span>.onCreate(savedInstanceState)</span><br><span class="line">        setContent &#123;</span><br><span class="line">            MyApp()</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">MyApp</span><span class="params">()</span></span> &#123;</span><br><span class="line">    MaterialTheme &#123;</span><br><span class="line">        GreetingScreen()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">GreetingScreen</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// remember：状态在重组时保留；mutableStateOf：状态变化触发重组</span></span><br><span class="line">    <span class="keyword">var</span> count <span class="keyword">by</span> remember &#123; mutableStateOf(<span class="number">0</span>) &#125;</span><br><span class="line"></span><br><span class="line">    Column(</span><br><span class="line">        modifier = Modifier</span><br><span class="line">            .fillMaxSize()</span><br><span class="line">            .padding(<span class="number">16.</span>dp),</span><br><span class="line">        horizontalAlignment = Alignment.CenterHorizontally,</span><br><span class="line">        verticalArrangement = Arrangement.Center</span><br><span class="line">    ) &#123;</span><br><span class="line">        Text(</span><br><span class="line">            text = <span class="string">&quot;点击次数：<span class="variable">$count</span>&quot;</span>,</span><br><span class="line">            style = MaterialTheme.typography.headlineMedium</span><br><span class="line">        )</span><br><span class="line">        Spacer(modifier = Modifier.height(<span class="number">16.</span>dp))</span><br><span class="line">        Button(onClick = &#123; count++ &#125;) &#123;</span><br><span class="line">            Text(<span class="string">&quot;点我&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>几个关键点：</p><ul><li><code>@Composable</code> 标记可组合函数，UI 全由这些函数组成；</li><li><code>remember { mutableStateOf(...) }</code> 是 Compose 的状态管理核心，<strong>状态变了，使用它的 UI 自动重组</strong>；</li><li><code>Column</code> &#x2F; <code>Row</code> &#x2F; <code>Box</code> 是三大布局容器，<code>Modifier</code> 链式设置尺寸、边距、对齐。</li></ul><h2 id="三、状态提升与回调"><a href="#三、状态提升与回调" class="headerlink" title="三、状态提升与回调"></a>三、状态提升与回调</h2><p>子组件不应持有全局状态，把状态”提升”到父组件，通过参数 + 回调传递：</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">CounterScreen</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">var</span> count <span class="keyword">by</span> remember &#123; mutableStateOf(<span class="number">0</span>) &#125;</span><br><span class="line"></span><br><span class="line">    Column(horizontalAlignment = Alignment.CenterHorizontally) &#123;</span><br><span class="line">        CounterDisplay(count = count)</span><br><span class="line">        CounterButton(onIncrement = &#123; count++ &#125;)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">CounterDisplay</span><span class="params">(count: <span class="type">Int</span>)</span></span> &#123;</span><br><span class="line">    Text(<span class="string">&quot;当前值：<span class="variable">$count</span>&quot;</span>, style = MaterialTheme.typography.titleLarge)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">CounterButton</span><span class="params">(onIncrement: () -&gt; <span class="type">Unit</span>)</span></span> &#123;</span><br><span class="line">    Button(onClick = onIncrement) &#123;</span><br><span class="line">        Text(<span class="string">&quot;+1&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="四、列表：LazyColumn"><a href="#四、列表：LazyColumn" class="headerlink" title="四、列表：LazyColumn"></a>四、列表：LazyColumn</h2><p>列表用 <code>LazyColumn</code>（对应 RecyclerView），只渲染可见项，性能好：</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">TodoList</span><span class="params">(todos: <span class="type">List</span>&lt;<span class="type">String</span>&gt;)</span></span> &#123;</span><br><span class="line">    LazyColumn(</span><br><span class="line">        modifier = Modifier.fillMaxSize(),</span><br><span class="line">        contentPadding = PaddingValues(<span class="number">16.</span>dp),</span><br><span class="line">        verticalArrangement = Arrangement.spacedBy(<span class="number">8.</span>dp)</span><br><span class="line">    ) &#123;</span><br><span class="line">        items(todos.size) &#123; index -&gt;</span><br><span class="line">            Card(modifier = Modifier.fillMaxWidth()) &#123;</span><br><span class="line">                Text(</span><br><span class="line">                    text = todos[index],</span><br><span class="line">                    modifier = Modifier.padding(<span class="number">16.</span>dp)</span><br><span class="line">                )</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>items</code> 也支持 <code>items(todos)</code> 传数据列表，配合 <code>key</code> 参数提供稳定 id：</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">items(items = todos, key = &#123; it.id &#125;) &#123; todo -&gt;</span><br><span class="line">    TodoItem(todo)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="五、主题配置"><a href="#五、主题配置" class="headerlink" title="五、主题配置"></a>五、主题配置</h2><p>Compose 的主题基于 <code>MaterialTheme</code> 的三个维度：<code>colorScheme</code>、<code>typography</code>、<code>shapes</code>。</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">val</span> LightColors = lightColorScheme(</span><br><span class="line">    primary = Color(<span class="number">0xFF3F51B5</span>),</span><br><span class="line">    secondary = Color(<span class="number">0xFFFF9800</span>)</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">val</span> DarkColors = darkColorScheme(</span><br><span class="line">    primary = Color(<span class="number">0xFF7986CB</span>)</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">MyAppTheme</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    darkTheme: <span class="type">Boolean</span> = isSystemInDarkTheme()</span></span>,</span><br><span class="line">    content: <span class="meta">@Composable</span> () -&gt; <span class="built_in">Unit</span></span><br><span class="line">) &#123;</span><br><span class="line">    MaterialTheme(</span><br><span class="line">        colorScheme = <span class="keyword">if</span> (darkTheme) DarkColors <span class="keyword">else</span> LightColors,</span><br><span class="line">        typography = Typography(),</span><br><span class="line">        content = content</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>isSystemInDarkTheme()</code> 自动跟随系统深色模式。</p><h2 id="六、-Preview-实时预览"><a href="#六、-Preview-实时预览" class="headerlink" title="六、@Preview 实时预览"></a>六、@Preview 实时预览</h2><p>Compose 支持不跑模拟器直接在 IDE 里预览：</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Preview(showBackground = true, showSystemUi = true)</span></span><br><span class="line"><span class="meta">@Composable</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">GreetingScreenPreview</span><span class="params">()</span></span> &#123;</span><br><span class="line">    MyAppTheme &#123;</span><br><span class="line">        GreetingScreen()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong><code>remember</code> 与 <code>rememberSaveable</code></strong>：旋转屏幕后 <code>remember</code> 状态会丢，需要跨配置保留时用 <code>rememberSaveable</code>；</li><li><strong>重组范围过大</strong>：尽量把状态往下传、把不变的 Composable 用 <code>Modifier</code> 或其他手段隔离，减少不必要的重组；</li><li><strong>协程</strong>：异步操作在 <code>LaunchedEffect</code> 里做，不要直接在 Composable 里 <code>Thread.sleep</code> 之类的阻塞操作。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Compose 的核心思维转变是”状态驱动 UI”：<strong>先声明状态，再声明状态到 UI 的映射</strong>。熟练掌握 <code>remember</code> + <code>mutableStateOf</code>、<code>LazyColumn</code>、状态提升这三板斧，就能开始写业务页面了。下一步可以学 Navigation 导航和 ViewModel 架构组件。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015ak/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015ak/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>Jetpack Compose 入门教程：从 Android Studio 创建项目到写出第一个声明式 UI 页面，涵盖状态管理、列表与主题配置。</summary>
    <title>Android 开发入门：Jetpack Compose 从零搭建第一个页面</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="后端" scheme="https://blog.zhangsifan.com/categories/%E5%90%8E%E7%AB%AF/"/>
    <category term="Node.js" scheme="https://blog.zhangsifan.com/tags/Node-js/"/>
    <category term="MySQL" scheme="https://blog.zhangsifan.com/tags/MySQL/"/>
    <category term="后端" scheme="https://blog.zhangsifan.com/tags/%E5%90%8E%E7%AB%AF/"/>
    <category term="JWT" scheme="https://blog.zhangsifan.com/tags/JWT/"/>
    <content>
      <![CDATA[<h2 id="技术栈与目标"><a href="#技术栈与目标" class="headerlink" title="技术栈与目标"></a>技术栈与目标</h2><ul><li><strong>Node.js + Express</strong>：Web 框架</li><li><strong>mysql2</strong>：MySQL 驱动（使用 Promise 版本）</li><li><strong>bcryptjs</strong>：密码加盐哈希，不存储明文密码</li><li><strong>jsonwebtoken</strong>：签发与校验 JWT</li></ul><p>最终实现三个接口：</p><table><thead><tr><th>接口</th><th>方法</th><th>说明</th></tr></thead><tbody><tr><td><code>/api/register</code></td><td>POST</td><td>用户注册，密码加密入库</td></tr><tr><td><code>/api/login</code></td><td>POST</td><td>用户登录，签发 JWT</td></tr><tr><td><code>/api/user/info</code></td><td>GET</td><td>携带 Token 获取用户信息（受保护）</td></tr></tbody></table><h2 id="一、项目初始化"><a href="#一、项目初始化" class="headerlink" title="一、项目初始化"></a>一、项目初始化</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">mkdir</span> user-auth &amp;&amp; <span class="built_in">cd</span> user-auth</span><br><span class="line">npm init -y</span><br><span class="line">npm install express mysql2 bcryptjs jsonwebtoken dotenv</span><br></pre></td></tr></table></figure><p>目录结构：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">user-auth/</span><br><span class="line">├── app.js          # 入口</span><br><span class="line">├── db.js           # 数据库连接池</span><br><span class="line">├── auth.js         # 鉴权中间件</span><br><span class="line">└── .env            # 环境变量</span><br></pre></td></tr></table></figure><h2 id="二、数据库与连接池"><a href="#二、数据库与连接池" class="headerlink" title="二、数据库与连接池"></a>二、数据库与连接池</h2><p>先建表：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> DATABASE IF <span class="keyword">NOT</span> <span class="keyword">EXISTS</span> user_auth <span class="keyword">DEFAULT</span> CHARSET utf8mb4;</span><br><span class="line"></span><br><span class="line">USE user_auth;</span><br><span class="line"></span><br><span class="line"><span class="keyword">CREATE TABLE</span> users (</span><br><span class="line">  id <span class="type">INT</span> UNSIGNED AUTO_INCREMENT <span class="keyword">PRIMARY KEY</span>,</span><br><span class="line">  username <span class="type">VARCHAR</span>(<span class="number">50</span>) <span class="keyword">NOT NULL</span> <span class="keyword">UNIQUE</span>,</span><br><span class="line">  password <span class="type">VARCHAR</span>(<span class="number">100</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;bcrypt 哈希&#x27;</span>,</span><br><span class="line">  created_at <span class="type">TIMESTAMP</span> <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span></span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB;</span><br></pre></td></tr></table></figure><p><code>db.js</code> 使用连接池，避免频繁建立连接：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// db.js</span></span><br><span class="line"><span class="keyword">const</span> mysql = <span class="built_in">require</span>(<span class="string">&#x27;mysql2/promise&#x27;</span>)</span><br><span class="line"><span class="built_in">require</span>(<span class="string">&#x27;dotenv&#x27;</span>).<span class="title function_">config</span>()</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> pool = mysql.<span class="title function_">createPool</span>(&#123;</span><br><span class="line">  <span class="attr">host</span>: process.<span class="property">env</span>.<span class="property">DB_HOST</span> || <span class="string">&#x27;localhost&#x27;</span>,</span><br><span class="line">  <span class="attr">user</span>: process.<span class="property">env</span>.<span class="property">DB_USER</span> || <span class="string">&#x27;root&#x27;</span>,</span><br><span class="line">  <span class="attr">password</span>: process.<span class="property">env</span>.<span class="property">DB_PASSWORD</span> || <span class="string">&#x27;&#x27;</span>,</span><br><span class="line">  <span class="attr">database</span>: process.<span class="property">env</span>.<span class="property">DB_NAME</span> || <span class="string">&#x27;user_auth&#x27;</span>,</span><br><span class="line">  <span class="attr">waitForConnections</span>: <span class="literal">true</span>,</span><br><span class="line">  <span class="attr">connectionLimit</span>: <span class="number">10</span>,</span><br><span class="line">  <span class="attr">charset</span>: <span class="string">&#x27;utf8mb4&#x27;</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = pool</span><br></pre></td></tr></table></figure><h2 id="三、注册接口：密码必须加盐哈希"><a href="#三、注册接口：密码必须加盐哈希" class="headerlink" title="三、注册接口：密码必须加盐哈希"></a>三、注册接口：密码必须加盐哈希</h2><p>明文密码一旦数据库泄露就全部暴露，必须用 <code>bcryptjs</code> 加密（自带盐，同样的密码每次哈希结果都不同）：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// app.js（部分）</span></span><br><span class="line"><span class="keyword">const</span> express = <span class="built_in">require</span>(<span class="string">&#x27;express&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> bcrypt = <span class="built_in">require</span>(<span class="string">&#x27;bcryptjs&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> jwt = <span class="built_in">require</span>(<span class="string">&#x27;jsonwebtoken&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> pool = <span class="built_in">require</span>(<span class="string">&#x27;./db&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> app = <span class="title function_">express</span>()</span><br><span class="line">app.<span class="title function_">use</span>(express.<span class="title function_">json</span>())</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="variable constant_">JWT_SECRET</span> = process.<span class="property">env</span>.<span class="property">JWT_SECRET</span> || <span class="string">&#x27;dev-secret-change-me&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 注册</span></span><br><span class="line">app.<span class="title function_">post</span>(<span class="string">&#x27;/api/register&#x27;</span>, <span class="title function_">async</span> (req, res) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> &#123; username, password &#125; = req.<span class="property">body</span></span><br><span class="line"></span><br><span class="line">  <span class="comment">// 1. 参数校验</span></span><br><span class="line">  <span class="keyword">if</span> (!username || !password) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">400</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户名和密码不能为空&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">if</span> (password.<span class="property">length</span> &lt; <span class="number">6</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">400</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;密码至少 6 位&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 2. 检查用户名是否已存在</span></span><br><span class="line">  <span class="keyword">const</span> [exist] = <span class="keyword">await</span> pool.<span class="title function_">query</span>(<span class="string">&#x27;SELECT id FROM users WHERE username = ?&#x27;</span>, [username])</span><br><span class="line">  <span class="keyword">if</span> (exist.<span class="property">length</span> &gt; <span class="number">0</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">409</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户名已存在&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 3. bcrypt 加盐哈希（10 是计算成本，越高越慢越安全）</span></span><br><span class="line">  <span class="keyword">const</span> hash = <span class="keyword">await</span> bcrypt.<span class="title function_">hash</span>(password, <span class="number">10</span>)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 4. 入库</span></span><br><span class="line">  <span class="keyword">await</span> pool.<span class="title function_">query</span>(<span class="string">&#x27;INSERT INTO users (username, password) VALUES (?, ?)&#x27;</span>, [username, hash])</span><br><span class="line">  res.<span class="title function_">status</span>(<span class="number">201</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;注册成功&#x27;</span> &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="四、登录接口：校验密码并签发-JWT"><a href="#四、登录接口：校验密码并签发-JWT" class="headerlink" title="四、登录接口：校验密码并签发 JWT"></a>四、登录接口：校验密码并签发 JWT</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 登录</span></span><br><span class="line">app.<span class="title function_">post</span>(<span class="string">&#x27;/api/login&#x27;</span>, <span class="title function_">async</span> (req, res) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> &#123; username, password &#125; = req.<span class="property">body</span></span><br><span class="line">  <span class="keyword">if</span> (!username || !password) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">400</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户名和密码不能为空&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> [rows] = <span class="keyword">await</span> pool.<span class="title function_">query</span>(<span class="string">&#x27;SELECT * FROM users WHERE username = ?&#x27;</span>, [username])</span><br><span class="line">  <span class="keyword">if</span> (rows.<span class="property">length</span> === <span class="number">0</span>) &#123;</span><br><span class="line">    <span class="comment">// 不暴露&quot;用户不存在&quot;，统一提示，防止用户名枚举</span></span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">401</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户名或密码错误&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> user = rows[<span class="number">0</span>]</span><br><span class="line">  <span class="keyword">const</span> ok = <span class="keyword">await</span> bcrypt.<span class="title function_">compare</span>(password, user.<span class="property">password</span>)</span><br><span class="line">  <span class="keyword">if</span> (!ok) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">401</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户名或密码错误&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 签发 JWT：payload 只放非敏感信息</span></span><br><span class="line">  <span class="keyword">const</span> token = jwt.<span class="title function_">sign</span>(</span><br><span class="line">    &#123; <span class="attr">id</span>: user.<span class="property">id</span>, <span class="attr">username</span>: user.<span class="property">username</span> &#125;,</span><br><span class="line">    <span class="variable constant_">JWT_SECRET</span>,</span><br><span class="line">    &#123; <span class="attr">expiresIn</span>: <span class="string">&#x27;7d&#x27;</span> &#125;</span><br><span class="line">  )</span><br><span class="line"></span><br><span class="line">  res.<span class="title function_">json</span>(&#123; token &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="五、鉴权中间件：保护受保护接口"><a href="#五、鉴权中间件：保护受保护接口" class="headerlink" title="五、鉴权中间件：保护受保护接口"></a>五、鉴权中间件：保护受保护接口</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// auth.js</span></span><br><span class="line"><span class="keyword">const</span> jwt = <span class="built_in">require</span>(<span class="string">&#x27;jsonwebtoken&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">auth</span>(<span class="params">req, res, next</span>) &#123;</span><br><span class="line">  <span class="comment">// 约定格式：Authorization: Bearer &lt;token&gt;</span></span><br><span class="line">  <span class="keyword">const</span> header = req.<span class="property">headers</span>.<span class="property">authorization</span></span><br><span class="line">  <span class="keyword">if</span> (!header || !header.<span class="title function_">startsWith</span>(<span class="string">&#x27;Bearer &#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">401</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;未登录&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> token = header.<span class="title function_">slice</span>(<span class="number">7</span>)</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    <span class="comment">// 校验签名与过期时间，解析出 payload</span></span><br><span class="line">    req.<span class="property">user</span> = jwt.<span class="title function_">verify</span>(token, process.<span class="property">env</span>.<span class="property">JWT_SECRET</span> || <span class="string">&#x27;dev-secret-change-me&#x27;</span>)</span><br><span class="line">    <span class="title function_">next</span>()</span><br><span class="line">  &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">401</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;Token 无效或已过期&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = auth</span><br></pre></td></tr></table></figure><p>受保护接口只需要在路由上挂中间件：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// app.js（部分）</span></span><br><span class="line"><span class="keyword">const</span> auth = <span class="built_in">require</span>(<span class="string">&#x27;./auth&#x27;</span>)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">get</span>(<span class="string">&#x27;/api/user/info&#x27;</span>, auth, <span class="title function_">async</span> (req, res) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> [rows] = <span class="keyword">await</span> pool.<span class="title function_">query</span>(</span><br><span class="line">    <span class="string">&#x27;SELECT id, username, created_at FROM users WHERE id = ?&#x27;</span>,</span><br><span class="line">    [req.<span class="property">user</span>.<span class="property">id</span>]</span><br><span class="line">  )</span><br><span class="line">  <span class="keyword">if</span> (rows.<span class="property">length</span> === <span class="number">0</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">404</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;用户不存在&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line">  res.<span class="title function_">json</span>(rows[<span class="number">0</span>])</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">listen</span>(<span class="number">3000</span>, <span class="function">() =&gt;</span> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Server running at http://localhost:3000&#x27;</span>))</span><br></pre></td></tr></table></figure><h2 id="六、接口测试"><a href="#六、接口测试" class="headerlink" title="六、接口测试"></a>六、接口测试</h2><p>用 curl 完整走一遍流程：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 1. 注册</span></span><br><span class="line">curl -X POST http://localhost:3000/api/register \</span><br><span class="line">  -H <span class="string">&quot;Content-Type: application/json&quot;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;username&quot;:&quot;zhangsan&quot;,&quot;password&quot;:&quot;123456&quot;&#125;&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 2. 登录，拿到 token</span></span><br><span class="line">curl -X POST http://localhost:3000/api/login \</span><br><span class="line">  -H <span class="string">&quot;Content-Type: application/json&quot;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;username&quot;:&quot;zhangsan&quot;,&quot;password&quot;:&quot;123456&quot;&#125;&#x27;</span></span><br><span class="line"><span class="comment"># =&gt; &#123;&quot;token&quot;:&quot;eyJhbGciOiJIUzI1NiIs...&quot;&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 3. 带 token 访问受保护接口</span></span><br><span class="line">curl http://localhost:3000/api/user/info \</span><br><span class="line">  -H <span class="string">&quot;Authorization: Bearer &lt;上一步的token&gt;&quot;</span></span><br></pre></td></tr></table></figure><h2 id="安全要点总结"><a href="#安全要点总结" class="headerlink" title="安全要点总结"></a>安全要点总结</h2><ol><li><strong>密码永远不存明文</strong>：用 bcrypt 加盐哈希，成本因子建议 10-12；</li><li><strong>登录失败统一提示</strong>：不要区分”用户不存在”和”密码错误”，防止撞库枚举；</li><li><strong>JWT payload 不放敏感数据</strong>：只放 <code>id</code>、<code>username</code> 这类标识信息；</li><li><strong><code>JWT_SECRET</code> 放环境变量</strong>：不要写死在代码里，更不要提交到 Git；</li><li><strong>SQL 全部用参数化查询（<code>?</code> 占位）</strong>：防止 SQL 注入；</li><li><strong>Token 设置过期时间</strong>：<code>expiresIn: &#39;7d&#39;</code>，生产环境还可考虑 refresh token 机制。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>到这里，一个可用的注册&#x2F;登录&#x2F;JWT 鉴权闭环就完成了。后续可以在这个基础上扩展：refresh token 刷新机制、角色权限（RBAC）、接口限流、统一错误处理中间件等。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015jwt/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015jwt/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>使用 Node.js + Express + MySQL 从零实现用户注册、登录与 JWT 身份鉴权的完整教程，含数据库设计、密码加密和鉴权中间件。</summary>
    <title>Node.js + MySQL 实现用户登录注册与 JWT 鉴权</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="iOS" scheme="https://blog.zhangsifan.com/categories/iOS/"/>
    <category term="iOS" scheme="https://blog.zhangsifan.com/tags/iOS/"/>
    <category term="Swift" scheme="https://blog.zhangsifan.com/tags/Swift/"/>
    <category term="SwiftUI" scheme="https://blog.zhangsifan.com/tags/SwiftUI/"/>
    <content>
      <![CDATA[<h2 id="什么是-SwiftUI"><a href="#什么是-SwiftUI" class="headerlink" title="什么是 SwiftUI"></a>什么是 SwiftUI</h2><p>SwiftUI 是 Apple 于 2019 年推出的声明式 UI 框架，用 Swift 一门语言同时构建 iOS、macOS、watchOS、tvOS 的界面。和 UIKit 的命令式写法不同，SwiftUI 是”声明界面长什么样，状态变了界面自动更新”。</p><table><thead><tr><th>UIKit</th><th>SwiftUI</th></tr></thead><tbody><tr><td>Storyboard&#x2F;代码 + Auto Layout</td><td>纯 Swift 声明式布局</td></tr><tr><td>delegate&#x2F;target-action 回调</td><td>闭包、@State 驱动</td></tr><tr><td>手动刷新 UI</td><td>状态变化自动刷新</td></tr></tbody></table><h2 id="一、创建工程"><a href="#一、创建工程" class="headerlink" title="一、创建工程"></a>一、创建工程</h2><p>打开 Xcode → File → New → Project → App，Interface 选 SwiftUI，Language 选 Swift。生成的核心文件是 AppNameApp.swift（App 入口）和 ContentView.swift（首页）。</p><p>入口文件：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> SwiftUI</span><br><span class="line"></span><br><span class="line"><span class="keyword">@main</span></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">MyAppApp</span>: <span class="title class_ inherited__">App</span> &#123;</span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">Scene</span> &#123;</span><br><span class="line">        <span class="type">WindowGroup</span> &#123;</span><br><span class="line">            <span class="type">ContentView</span>()</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="二、第一个页面：布局与文本"><a href="#二、第一个页面：布局与文本" class="headerlink" title="二、第一个页面：布局与文本"></a>二、第一个页面：布局与文本</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> SwiftUI</span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">ContentView</span>: <span class="title class_ inherited__">View</span> &#123;</span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">View</span> &#123;</span><br><span class="line">        <span class="type">VStack</span>(spacing: <span class="number">16</span>) &#123;</span><br><span class="line">            <span class="type">Text</span>(<span class="string">&quot;Hello, SwiftUI!&quot;</span>)</span><br><span class="line">                .font(.largeTitle)</span><br><span class="line">                .fontWeight(.bold)</span><br><span class="line"></span><br><span class="line">            <span class="type">Text</span>(<span class="string">&quot;这是我用 SwiftUI 写的第一个页面&quot;</span>)</span><br><span class="line">                .font(.body)</span><br><span class="line">                .foregroundColor(.secondary)</span><br><span class="line"></span><br><span class="line">            <span class="type">HStack</span>(spacing: <span class="number">12</span>) &#123;</span><br><span class="line">                <span class="type">Text</span>(<span class="string">&quot;左&quot;</span>)</span><br><span class="line">                <span class="type">Spacer</span>() <span class="comment">// 弹性占位，把两边撑开</span></span><br><span class="line">                <span class="type">Text</span>(<span class="string">&quot;右&quot;</span>)</span><br><span class="line">            &#125;</span><br><span class="line">            .padding()</span><br><span class="line">            .background(<span class="type">Color</span>.gray.opacity(<span class="number">0.1</span>))</span><br><span class="line">            .cornerRadius(<span class="number">8</span>)</span><br><span class="line">        &#125;</span><br><span class="line">        .padding()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>布局三件套：VStack（垂直）、HStack（水平）、ZStack（层叠）。修饰符（.font、.padding、.background）链式叠加，顺序影响效果。</p><h2 id="三、状态管理：-State"><a href="#三、状态管理：-State" class="headerlink" title="三、状态管理：@State"></a>三、状态管理：@State</h2><p>SwiftUI 中用 @State 声明视图私有状态，状态改变视图自动刷新：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">CounterView</span>: <span class="title class_ inherited__">View</span> &#123;</span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> count <span class="operator">=</span> <span class="number">0</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">View</span> &#123;</span><br><span class="line">        <span class="type">VStack</span>(spacing: <span class="number">16</span>) &#123;</span><br><span class="line">            <span class="type">Text</span>(<span class="string">&quot;点击次数：<span class="subst">\(count)</span>&quot;</span>)</span><br><span class="line">                .font(.title)</span><br><span class="line"></span><br><span class="line">            <span class="type">Button</span>(action: &#123;</span><br><span class="line">                count <span class="operator">+=</span> <span class="number">1</span></span><br><span class="line">            &#125;) &#123;</span><br><span class="line">                <span class="type">Text</span>(<span class="string">&quot;点我 +1&quot;</span>)</span><br><span class="line">                    .padding()</span><br><span class="line">                    .background(<span class="type">Color</span>.blue)</span><br><span class="line">                    .foregroundColor(.white)</span><br><span class="line">                    .cornerRadius(<span class="number">8</span>)</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>@State 对应视图内部状态；跨视图共享用 @ObservedObject &#x2F; @EnvironmentObject + ObservableObject（MVVM 中的 ViewModel）。</p><h2 id="四、列表与导航"><a href="#四、列表与导航" class="headerlink" title="四、列表与导航"></a>四、列表与导航</h2><p>List 是 SwiftUI 的列表组件，配合 NavigationStack 实现页面跳转：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">Todo</span>: <span class="title class_ inherited__">Identifiable</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> id <span class="operator">=</span> <span class="type">UUID</span>()</span><br><span class="line">    <span class="keyword">let</span> title: <span class="type">String</span></span><br><span class="line">    <span class="keyword">let</span> done: <span class="type">Bool</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">TodoListView</span>: <span class="title class_ inherited__">View</span> &#123;</span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> todos <span class="operator">=</span> [</span><br><span class="line">        <span class="type">Todo</span>(title: <span class="string">&quot;学习 SwiftUI&quot;</span>, done: <span class="literal">true</span>),</span><br><span class="line">        <span class="type">Todo</span>(title: <span class="string">&quot;写第一个 App&quot;</span>, done: <span class="literal">false</span>),</span><br><span class="line">        <span class="type">Todo</span>(title: <span class="string">&quot;上架 App Store&quot;</span>, done: <span class="literal">false</span>)</span><br><span class="line">    ]</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">View</span> &#123;</span><br><span class="line">        <span class="type">NavigationStack</span> &#123;</span><br><span class="line">            <span class="type">List</span>(<span class="variable">$todos</span>) &#123; <span class="variable">$todo</span> <span class="keyword">in</span></span><br><span class="line">                <span class="type">HStack</span> &#123;</span><br><span class="line">                    <span class="type">Image</span>(systemName: todo.done <span class="operator">?</span> <span class="string">&quot;checkmark.circle.fill&quot;</span> : <span class="string">&quot;circle&quot;</span>)</span><br><span class="line">                        .foregroundColor(todo.done <span class="operator">?</span> .green : .gray)</span><br><span class="line">                        .onTapGesture &#123;</span><br><span class="line">                            todo.done.toggle()</span><br><span class="line">                        &#125;</span><br><span class="line">                    <span class="type">Text</span>(todo.title)</span><br><span class="line">                        .strikethrough(todo.done)</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line">            .navigationTitle(<span class="string">&quot;待办清单&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>List($todos) 的 $ 是双向绑定（Binding），子元素可以直接修改父状态。navigationTitle 设置导航栏标题。</p><h2 id="五、表单与输入"><a href="#五、表单与输入" class="headerlink" title="五、表单与输入"></a>五、表单与输入</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">LoginFormView</span>: <span class="title class_ inherited__">View</span> &#123;</span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> username <span class="operator">=</span> <span class="string">&quot;&quot;</span></span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> password <span class="operator">=</span> <span class="string">&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">View</span> &#123;</span><br><span class="line">        <span class="type">Form</span> &#123;</span><br><span class="line">            <span class="type">Section</span>(<span class="string">&quot;账号&quot;</span>) &#123;</span><br><span class="line">                <span class="type">TextField</span>(<span class="string">&quot;用户名&quot;</span>, text: <span class="variable">$username</span>)</span><br><span class="line">                    .textInputAutocapitalization(.never)</span><br><span class="line">                <span class="type">SecureField</span>(<span class="string">&quot;密码&quot;</span>, text: <span class="variable">$password</span>)</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="type">Section</span> &#123;</span><br><span class="line">                <span class="type">Button</span>(<span class="string">&quot;登录&quot;</span>) &#123;</span><br><span class="line">                    <span class="comment">// 登录逻辑</span></span><br><span class="line">                &#125;</span><br><span class="line">                .disabled(username.isEmpty <span class="operator">||</span> password.isEmpty)</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Form 自带分组和样式，SecureField 是密码输入框，.disabled 在条件不满足时禁用按钮。</p><h2 id="六、-Preview-实时预览"><a href="#六、-Preview-实时预览" class="headerlink" title="六、@Preview 实时预览"></a>六、@Preview 实时预览</h2><p>Xcode 右侧的 Canvas 可以实时预览：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">#<span class="type">Preview</span> &#123;</span><br><span class="line">    <span class="type">ContentView</span>()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>支持深色模式预览：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">#<span class="type">Preview</span> &#123;</span><br><span class="line">    <span class="type">ContentView</span>()</span><br><span class="line">        .preferredColorScheme(.dark)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li>body 只能返回一个 View：需要多内容时用 Group 或 VStack 包起来；</li><li>@State 只用于视图私有状态：跨界面共享数据要放 ObservableObject，否则数据不同步；</li><li>模拟器&#x2F;真机跑不起来：优先检查 Xcode 版本与最低部署目标是否匹配；</li><li>预览不显示：很多时候是预览编译缓存问题，Product → Clean Build Folder 后重试。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>SwiftUI 的上手路径很顺：先掌握 VStack&#x2F;HStack&#x2F;ZStack 布局、@State 状态、List 列表，就能拼出大多数界面。下一步建议学 ObservableObject + @EnvironmentObject 的 MVVM 架构，以及 NavigationStack 的路径导航。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015sw/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015sw/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>SwiftUI 入门教程：从 Xcode 创建工程到写出第一个声明式界面，涵盖布局、@State 状态、List 列表与导航。</summary>
    <title>iOS 开发入门：SwiftUI 构建第一个 App</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="React" scheme="https://blog.zhangsifan.com/tags/React/"/>
    <category term="Hooks" scheme="https://blog.zhangsifan.com/tags/Hooks/"/>
    <content>
      <![CDATA[<h2 id="为什么要封装自定义-Hook"><a href="#为什么要封装自定义-Hook" class="headerlink" title="为什么要封装自定义 Hook"></a>为什么要封装自定义 Hook</h2><p>React 内置的 <code>useState</code>、<code>useEffect</code> 等 Hook 解决的是”状态”和”副作用”问题，但业务里大量重复逻辑——发请求、防抖、读写本地存储——如果每个组件都写一遍，代码会迅速失控。自定义 Hook 就是<strong>把可复用的逻辑抽出来，本质是一个以 <code>use</code> 开头的普通函数，内部可以调用其他 Hook</strong>。</p><h2 id="自定义-Hook-的基本规则"><a href="#自定义-Hook-的基本规则" class="headerlink" title="自定义 Hook 的基本规则"></a>自定义 Hook 的基本规则</h2><ol><li><strong>必须以 <code>use</code> 开头</strong>（React 靠命名识别 Hook，配合 ESLint 插件检查）；</li><li><strong>内部可以调用其他 Hook</strong>，也可以组合多个自定义 Hook；</li><li>遵循 Hook 规则：只能在组件顶层调用 Hook，不能在条件、循环里调用；</li><li>返回什么由自己决定，通常返回状态和操作方法。</li></ol><h2 id="案例一：useFetch-请求封装"><a href="#案例一：useFetch-请求封装" class="headerlink" title="案例一：useFetch 请求封装"></a>案例一：useFetch 请求封装</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useFetch.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; useState, useEffect, useCallback &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useFetch</span>(<span class="params">url</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [data, setData] = <span class="title function_">useState</span>(<span class="literal">null</span>)</span><br><span class="line">  <span class="keyword">const</span> [loading, setLoading] = <span class="title function_">useState</span>(<span class="literal">true</span>)</span><br><span class="line">  <span class="keyword">const</span> [error, setError] = <span class="title function_">useState</span>(<span class="literal">null</span>)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">useEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> ignore = <span class="literal">false</span> <span class="comment">// 防止竞态：组件卸载或 url 变化后不更新旧结果</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">load</span>(<span class="params"></span>) &#123;</span><br><span class="line">      <span class="title function_">setLoading</span>(<span class="literal">true</span>)</span><br><span class="line">      <span class="title function_">setError</span>(<span class="literal">null</span>)</span><br><span class="line">      <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="keyword">const</span> res = <span class="keyword">await</span> <span class="title function_">fetch</span>(url)</span><br><span class="line">        <span class="keyword">if</span> (!res.<span class="property">ok</span>) <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`HTTP <span class="subst">$&#123;res.status&#125;</span>`</span>)</span><br><span class="line">        <span class="keyword">const</span> json = <span class="keyword">await</span> res.<span class="title function_">json</span>()</span><br><span class="line">        <span class="keyword">if</span> (!ignore) <span class="title function_">setData</span>(json)</span><br><span class="line">      &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line">        <span class="keyword">if</span> (!ignore) <span class="title function_">setError</span>(e)</span><br><span class="line">      &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (!ignore) <span class="title function_">setLoading</span>(<span class="literal">false</span>)</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="title function_">load</span>()</span><br><span class="line">    <span class="keyword">return</span> <span class="function">() =&gt;</span> &#123;</span><br><span class="line">      ignore = <span class="literal">true</span> <span class="comment">// 清理函数：下一次 effect 运行前标记旧请求失效</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;, [url])</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> refresh = <span class="title function_">useCallback</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// 触发重新请求：改一个内部 state 即可</span></span><br><span class="line">    <span class="title function_">setVersion</span>(<span class="function">(<span class="params">v</span>) =&gt;</span> v + <span class="number">1</span>)</span><br><span class="line">  &#125;, [])</span><br><span class="line">  <span class="comment">// 实际使用时可把 version 也放进依赖里</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> &#123; data, loading, error, refresh &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>组件里一行代码就拿到请求状态：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; useFetch &#125; <span class="keyword">from</span> <span class="string">&#x27;./useFetch&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">UserList</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> &#123; data, loading, error &#125; = <span class="title function_">useFetch</span>(<span class="string">&#x27;/api/users&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (loading) <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span>加载中...<span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  <span class="keyword">if</span> (error) <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span>出错了：&#123;error.message&#125;<span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">ul</span>&gt;</span>&#123;data?.map((u) =&gt; <span class="tag">&lt;<span class="name">li</span> <span class="attr">key</span>=<span class="string">&#123;u.id&#125;</span>&gt;</span>&#123;u.name&#125;<span class="tag">&lt;/<span class="name">li</span>&gt;</span>)&#125;<span class="tag">&lt;/<span class="name">ul</span>&gt;</span></span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>关于竞态</strong>：请求是异步的，如果 <code>url</code> 很快变化或组件卸载，旧请求的结果可能覆盖新状态。上面的 <code>ignore</code> 标记是 React 官方推荐的防竞态写法。</p><h2 id="案例二：useDebounce-防抖"><a href="#案例二：useDebounce-防抖" class="headerlink" title="案例二：useDebounce 防抖"></a>案例二：useDebounce 防抖</h2><p>搜索输入场景，防抖是最高频的需求：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useDebounce.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; useState, useEffect &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useDebounce</span>(<span class="params">value, delay = <span class="number">300</span></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [debounced, setDebounced] = <span class="title function_">useState</span>(value)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">useEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> timer = <span class="built_in">setTimeout</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">      <span class="title function_">setDebounced</span>(value)</span><br><span class="line">    &#125;, delay)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 每次 value 变化都会清除上一个定时器，实现防抖</span></span><br><span class="line">    <span class="keyword">return</span> <span class="function">() =&gt;</span> <span class="built_in">clearTimeout</span>(timer)</span><br><span class="line">  &#125;, [value, delay])</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> debounced</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; useState, useEffect &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; useDebounce &#125; <span class="keyword">from</span> <span class="string">&#x27;./useDebounce&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">SearchBox</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [keyword, setKeyword] = <span class="title function_">useState</span>(<span class="string">&#x27;&#x27;</span>)</span><br><span class="line">  <span class="keyword">const</span> debouncedKeyword = <span class="title function_">useDebounce</span>(keyword, <span class="number">500</span>)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">useEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (!debouncedKeyword) <span class="keyword">return</span></span><br><span class="line">    <span class="comment">// 只有停止输入 500ms 后才发起搜索请求</span></span><br><span class="line">    <span class="title function_">fetch</span>(<span class="string">`/api/search?q=<span class="subst">$&#123;debouncedKeyword&#125;</span>`</span>)</span><br><span class="line">  &#125;, [debouncedKeyword])</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">input</span> <span class="attr">value</span>=<span class="string">&#123;keyword&#125;</span> <span class="attr">onChange</span>=<span class="string">&#123;(e)</span> =&gt;</span> setKeyword(e.target.value)&#125; /&gt;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="案例三：useLocalStorage-同步本地存储"><a href="#案例三：useLocalStorage-同步本地存储" class="headerlink" title="案例三：useLocalStorage 同步本地存储"></a>案例三：useLocalStorage 同步本地存储</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useLocalStorage.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; useState, useEffect &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useLocalStorage</span>(<span class="params">key, initialValue</span>) &#123;</span><br><span class="line">  <span class="comment">// 惰性初始化：第一次渲染时从 localStorage 读取</span></span><br><span class="line">  <span class="keyword">const</span> [value, setValue] = <span class="title function_">useState</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="keyword">const</span> stored = <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">getItem</span>(key)</span><br><span class="line">      <span class="keyword">return</span> stored !== <span class="literal">null</span> ? <span class="title class_">JSON</span>.<span class="title function_">parse</span>(stored) : initialValue</span><br><span class="line">    &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line">      <span class="keyword">return</span> initialValue</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 值变化时写回，并监听其他标签页的修改</span></span><br><span class="line">  <span class="title function_">useEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="variable language_">window</span>.<span class="property">localStorage</span>.<span class="title function_">setItem</span>(key, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(value))</span><br><span class="line">    &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line">      <span class="comment">// 隐私模式或存储满时静默失败</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;, [key, value])</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> [value, setValue]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">ThemeSwitch</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [theme, setTheme] = <span class="title function_">useLocalStorage</span>(<span class="string">&#x27;theme&#x27;</span>, <span class="string">&#x27;light&#x27;</span>)</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">button</span> <span class="attr">onClick</span>=<span class="string">&#123;()</span> =&gt;</span> setTheme(theme === &#x27;light&#x27; ? &#x27;dark&#x27; : &#x27;light&#x27;)&#125;&gt;</span></span><br><span class="line"><span class="language-xml">      当前主题：&#123;theme&#125;</span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">button</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="组合多个-Hook"><a href="#组合多个-Hook" class="headerlink" title="组合多个 Hook"></a>组合多个 Hook</h2><p>自定义 Hook 可以互相组合，形成更上层的抽象：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useSearchUsers.js —— 把防抖 + 请求 + 状态串成一条完整的搜索链路</span></span><br><span class="line"><span class="keyword">import</span> &#123; useState, useEffect &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; useDebounce &#125; <span class="keyword">from</span> <span class="string">&#x27;./useDebounce&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useSearchUsers</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [keyword, setKeyword] = <span class="title function_">useState</span>(<span class="string">&#x27;&#x27;</span>)</span><br><span class="line">  <span class="keyword">const</span> debouncedKeyword = <span class="title function_">useDebounce</span>(keyword, <span class="number">400</span>)</span><br><span class="line">  <span class="keyword">const</span> [users, setUsers] = <span class="title function_">useState</span>([])</span><br><span class="line">  <span class="keyword">const</span> [loading, setLoading] = <span class="title function_">useState</span>(<span class="literal">false</span>)</span><br><span class="line"></span><br><span class="line">  <span class="title function_">useEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (!debouncedKeyword) &#123;</span><br><span class="line">      <span class="title function_">setUsers</span>([])</span><br><span class="line">      <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">let</span> ignore = <span class="literal">false</span></span><br><span class="line">    <span class="title function_">setLoading</span>(<span class="literal">true</span>)</span><br><span class="line">    <span class="title function_">fetch</span>(<span class="string">`/api/users?q=<span class="subst">$&#123;debouncedKeyword&#125;</span>`</span>)</span><br><span class="line">      .<span class="title function_">then</span>(<span class="function">(<span class="params">r</span>) =&gt;</span> r.<span class="title function_">json</span>())</span><br><span class="line">      .<span class="title function_">then</span>(<span class="function">(<span class="params">data</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (!ignore) <span class="title function_">setUsers</span>(data)</span><br><span class="line">      &#125;)</span><br><span class="line">      .<span class="title function_">finally</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (!ignore) <span class="title function_">setLoading</span>(<span class="literal">false</span>)</span><br><span class="line">      &#125;)</span><br><span class="line">    <span class="keyword">return</span> <span class="function">() =&gt;</span> &#123;</span><br><span class="line">      ignore = <span class="literal">true</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;, [debouncedKeyword])</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> &#123; keyword, setKeyword, users, loading &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="常见误区"><a href="#常见误区" class="headerlink" title="常见误区"></a>常见误区</h2><ol><li><strong>在自定义 Hook 里返回 <code>useCallback</code>&#x2F;<code>useMemo</code> 的依赖漏写</strong>：依赖数组不全会导致闭包捕获旧值，用 eslint-plugin-react-hooks 的 exhaustive-deps 规则可以兜底；</li><li><strong>把所有逻辑都塞进一个 Hook</strong>：Hook 也讲究单一职责，一个 Hook 做一件事；</li><li><strong>忘记清理副作用</strong>：定时器、订阅、事件监听都要在清理函数中解除，否则组件卸载后仍会执行。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>自定义 Hook 让 React 组件”瘦身”、逻辑”复用”。建议按这个顺序上手：先封装 <code>useFetch</code> 类的基础逻辑，再封装 <code>useDebounce</code> 这类通用工具，最后根据业务组合出领域 Hook。命名统一 <code>useXxx</code>，放在 <code>src/hooks/</code> 目录下管理。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015react/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015react/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>React 自定义 Hook 实战教程：通过 useFetch、useDebounce、useLocalStorage 等案例，学会封装和组合自定义 Hook 复用业务逻辑。</summary>
    <title>React Hooks 进阶：封装属于自己的自定义 Hook</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="Tauri" scheme="https://blog.zhangsifan.com/categories/Tauri/"/>
    <category term="Tauri" scheme="https://blog.zhangsifan.com/tags/Tauri/"/>
    <category term="桌面应用" scheme="https://blog.zhangsifan.com/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
    <category term="Rust" scheme="https://blog.zhangsifan.com/tags/Rust/"/>
    <content>
      <![CDATA[<h2 id="为什么用-Tauri"><a href="#为什么用-Tauri" class="headerlink" title="为什么用 Tauri"></a>为什么用 Tauri</h2><p>Tauri 是新一代桌面应用框架，用系统自带的 WebView 渲染前端，用 Rust 提供系统能力。与 Electron 的核心区别：</p><table><thead><tr><th>对比项</th><th>Electron</th><th>Tauri 2</th></tr></thead><tbody><tr><td>前端渲染</td><td>内置 Chromium（约 150MB+）</td><td>系统 WebView（macOS 用 WKWebView，Windows 用 WebView2）</td></tr><tr><td>后端语言</td><td>Node.js</td><td>Rust</td></tr><tr><td>安装包体积</td><td>通常 60-150MB</td><td>通常 3-10MB</td></tr><tr><td>内存占用</td><td>高</td><td>低</td></tr><tr><td>安全性</td><td>需自行配置</td><td>默认更安全（权限模型）</td></tr></tbody></table><p>适用场景：工具类软件、对体积和内存敏感的应用、愿意用 Rust 做底层逻辑的团队。</p><h2 id="一、环境准备"><a href="#一、环境准备" class="headerlink" title="一、环境准备"></a>一、环境准备</h2><p>需要安装：</p><ol><li>Rust：<a href="https://rustup.rs/">https://rustup.rs</a> 下载安装，验证 rustc –version；</li><li>Node.js（前端部分用）；</li><li>平台依赖：<ul><li>macOS：Xcode Command Line Tools（xcode-select –install）；</li><li>Windows：VS Build Tools 的 C++ 组件 + WebView2；</li><li>Linux：webkit2gtk 等依赖包。</li></ul></li></ol><h2 id="二、创建项目"><a href="#二、创建项目" class="headerlink" title="二、创建项目"></a>二、创建项目</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm create tauri-app@latest</span><br></pre></td></tr></table></figure><p>按提示选择：</p><ul><li>项目名称，如 my-tauri-app</li><li>前端模板：Vue &#x2F; React &#x2F; Svelte &#x2F; 纯 HTML 均可</li><li>包管理器：npm &#x2F; pnpm 等</li></ul><p>然后安装依赖并启动：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> my-tauri-app</span><br><span class="line">npm install</span><br><span class="line">npm run tauri dev</span><br></pre></td></tr></table></figure><p>首次运行会编译 Rust 代码，需要等几分钟。</p><h2 id="三、项目结构"><a href="#三、项目结构" class="headerlink" title="三、项目结构"></a>三、项目结构</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">my-tauri-app/</span><br><span class="line">├── src/                  # 前端代码（Vue/React 等）</span><br><span class="line">├── src-tauri/            # Rust 后端</span><br><span class="line">│   ├── src/</span><br><span class="line">│   │   ├── main.rs       # 入口</span><br><span class="line">│   │   └── lib.rs        # 命令定义</span><br><span class="line">│   ├── Cargo.toml        # Rust 依赖配置</span><br><span class="line">│   ├── tauri.conf.json   # Tauri 配置（窗口、打包、权限）</span><br><span class="line">│   └── capabilities/     # 权限能力配置（Tauri 2 新增）</span><br><span class="line">└── package.json</span><br></pre></td></tr></table></figure><h2 id="四、前端调用-Rust-命令"><a href="#四、前端调用-Rust-命令" class="headerlink" title="四、前端调用 Rust 命令"></a>四、前端调用 Rust 命令</h2><p><strong>Rust 端定义命令（src-tauri&#x2F;src&#x2F;lib.rs）</strong>：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 定义一个简单的命令：打招呼</span></span><br><span class="line"><span class="meta">#[tauri::command]</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">greet</span>(name: &amp;<span class="type">str</span>) <span class="punctuation">-&gt;</span> <span class="type">String</span> &#123;</span><br><span class="line">    <span class="built_in">format!</span>(<span class="string">&quot;你好，&#123;&#125;！我是从 Rust 返回的。&quot;</span>, name)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 一个更&quot;系统级&quot;的例子：读取文件</span></span><br><span class="line"><span class="meta">#[tauri::command]</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">read_file</span>(path: &amp;<span class="type">str</span>) <span class="punctuation">-&gt;</span> <span class="type">Result</span>&lt;<span class="type">String</span>, <span class="type">String</span>&gt; &#123;</span><br><span class="line">    std::fs::<span class="title function_ invoke__">read_to_string</span>(path).<span class="title function_ invoke__">map_err</span>(|e| e.<span class="title function_ invoke__">to_string</span>())</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">#[cfg_attr(mobile, tauri::mobile_entry_point)]</span></span><br><span class="line"><span class="keyword">pub</span> <span class="keyword">fn</span> <span class="title function_">run</span>() &#123;</span><br><span class="line">    tauri::Builder::<span class="title function_ invoke__">default</span>()</span><br><span class="line">        .<span class="title function_ invoke__">invoke_handler</span>(tauri::generate_handler![greet, read_file])</span><br><span class="line">        .<span class="title function_ invoke__">run</span>(tauri::generate_context!())</span><br><span class="line">        .<span class="title function_ invoke__">expect</span>(<span class="string">&quot;error while running tauri application&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>前端调用（src&#x2F;App.vue，Vue3 示例）</strong>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; invoke &#125; from &#x27;@tauri-apps/api/core&#x27;</span><br><span class="line">import &#123; ref &#125; from &#x27;vue&#x27;</span><br><span class="line"></span><br><span class="line">const result = ref(&#x27;&#x27;)</span><br><span class="line"></span><br><span class="line">async function callRust() &#123;</span><br><span class="line">  // invoke 第一个参数是命令名，第二个参数传参</span><br><span class="line">  result.value = await invoke(&#x27;greet&#x27;, &#123; name: &#x27;张思帆&#x27; &#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">async function readFile() &#123;</span><br><span class="line">  try &#123;</span><br><span class="line">    const content = await invoke(&#x27;read_file&#x27;, &#123; path: &#x27;/etc/hosts&#x27; &#125;)</span><br><span class="line">    result.value = content</span><br><span class="line">  &#125; catch (e) &#123;</span><br><span class="line">    result.value = &#x27;读取失败：&#x27; + e</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;main&gt;</span><br><span class="line">    &lt;button @click=&quot;callRust&quot;&gt;调用 Rust 打招呼&lt;/button&gt;</span><br><span class="line">    &lt;button @click=&quot;readFile&quot;&gt;读取文件&lt;/button&gt;</span><br><span class="line">    &lt;pre&gt;&#123;&#123; result &#125;&#125;&lt;/pre&gt;</span><br><span class="line">  &lt;/main&gt;</span><br><span class="line">&lt;/template&gt;</span><br></pre></td></tr></table></figure><p>注意参数命名：Rust 端用 snake_case（如 path），前端 invoke 传参时建议保持一致，避免命名转换踩坑。</p><h2 id="五、Tauri-2-的权限模型（重要）"><a href="#五、Tauri-2-的权限模型（重要）" class="headerlink" title="五、Tauri 2 的权限模型（重要）"></a>五、Tauri 2 的权限模型（重要）</h2><p>Tauri 2 引入了 capabilities 权限系统：前端能调用哪些插件能力，需要在 src-tauri&#x2F;capabilities&#x2F;default.json 里显式声明，而不是全开放。</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;$schema&quot;</span><span class="punctuation">:</span> <span class="string">&quot;../gen/schemas/desktop-schema.json&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;identifier&quot;</span><span class="punctuation">:</span> <span class="string">&quot;default&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;主窗口默认权限&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;windows&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;main&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;permissions&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="string">&quot;core:default&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="string">&quot;opener:default&quot;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>例如要用文件对话框插件，需要安装并添加对应权限：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">npm install @tauri-apps/plugin-dialog</span><br><span class="line"><span class="comment"># Rust 端 Cargo.toml 加 tauri-plugin-dialog</span></span><br></pre></td></tr></table></figure><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;permissions&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;core:default&quot;</span><span class="punctuation">,</span> <span class="string">&quot;dialog:allow-open&quot;</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>这种”默认最小权限”的设计，让 Tauri 应用比 Electron 天生更安全。</p><h2 id="六、打包发布"><a href="#六、打包发布" class="headerlink" title="六、打包发布"></a>六、打包发布</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm run tauri build</span><br></pre></td></tr></table></figure><p>产物位置：</p><ul><li>macOS：src-tauri&#x2F;target&#x2F;release&#x2F;bundle&#x2F;dmg&#x2F; 或 app&#x2F;；</li><li>Windows：src-tauri&#x2F;target&#x2F;release&#x2F;bundle&#x2F;nsis&#x2F; 或 msi&#x2F;；</li><li>Linux：.deb &#x2F; .AppImage。</li></ul><p>跨平台打包建议：在对应平台上各自打包（macOS 的 dmg 只能在 macOS 上打，Windows 同理），或用 GitHub Actions 的 tauri-action 做 CI 流水线。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li>首次编译慢：Rust 编译 + 下载依赖需要几分钟，是正常现象，后续增量编译快很多；</li><li>invoke 报 “not allowed”：大概率是 capabilities 权限没配，检查 default.json；</li><li>命令名不一致：Rust #[tauri::command] 的函数名就是 invoke 的命令名，改名要两边同步；</li><li>Linux 依赖报错：webkit2gtk-4.1、libappindicator 等系统包没装全，按官方文档装齐即可。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Tauri 2 适合追求”小体积、低内存、强安全”的桌面应用。上手路径：装好 Rust → create-tauri-app 建项目 → 定义 #[tauri::command] → 前端 invoke 调用 → 配置 capabilities 权限 → 打包。前端逻辑和 Web 完全一致，把精力花在 Rust 命令的设计上即可。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015ta/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015ta/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>Tauri 2 入门教程：对比 Electron 的优势、环境准备、创建项目、前端调用 Rust 命令与打包发布。</summary>
    <title>Tauri 2 入门：用 Rust 构建轻量桌面应用</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="uni-app" scheme="https://blog.zhangsifan.com/categories/uni-app/"/>
    <category term="微信小程序" scheme="https://blog.zhangsifan.com/tags/%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F/"/>
    <category term="Vue" scheme="https://blog.zhangsifan.com/tags/Vue/"/>
    <category term="uni-app" scheme="https://blog.zhangsifan.com/tags/uni-app/"/>
    <category term="跨端开发" scheme="https://blog.zhangsifan.com/tags/%E8%B7%A8%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <content>
      <![CDATA[<h2 id="什么是-uni-app"><a href="#什么是-uni-app" class="headerlink" title="什么是 uni-app"></a>什么是 uni-app</h2><p>uni-app 是 DCloud 推出的跨端框架，使用 Vue 语法编写，一套代码可编译到微信小程序、H5、App（iOS&#x2F;Android）、支付宝小程序等十余个平台。底层原理是：编写时用统一 API，编译时针对各平台生成对应代码。</p><h2 id="一、创建项目"><a href="#一、创建项目" class="headerlink" title="一、创建项目"></a>一、创建项目</h2><p>两种方式：</p><p><strong>方式一：HBuilderX（官方 IDE，可视化）</strong><br>新建项目 → 选择 uni-app 模板（推荐 Vue3 + Vite 版）。</p><p><strong>方式二：命令行 CLI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">npx degit dcloudio/uni-preset-vue#vite my-uni-app</span><br><span class="line"><span class="built_in">cd</span> my-uni-app</span><br><span class="line">npm install</span><br><span class="line">npm run dev:mp-weixin   <span class="comment"># 编译到微信小程序</span></span><br><span class="line">npm run dev:h5          <span class="comment"># 编译到 H5</span></span><br></pre></td></tr></table></figure><p>推荐 CLI 方式，方便用自己熟悉的编辑器。</p><h2 id="二、目录结构"><a href="#二、目录结构" class="headerlink" title="二、目录结构"></a>二、目录结构</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">my-uni-app/</span><br><span class="line">├── src/</span><br><span class="line">│   ├── pages/          # 页面，每个页面一个目录</span><br><span class="line">│   │   └── index/</span><br><span class="line">│   │       └── index.vue</span><br><span class="line">│   ├── static/         # 静态资源</span><br><span class="line">│   ├── App.vue         # 应用入口（全局生命周期、全局样式）</span><br><span class="line">│   ├── main.js         # Vue 入口</span><br><span class="line">│   ├── pages.json      # 页面路由与导航栏配置（类似小程序的 app.json）</span><br><span class="line">│   ├── manifest.json   # 应用配置（appid、各平台配置）</span><br><span class="line">│   └── uni.scss        # 全局样式变量</span><br><span class="line">└── package.json</span><br></pre></td></tr></table></figure><h2 id="三、pages-json：路由与导航栏"><a href="#三、pages-json：路由与导航栏" class="headerlink" title="三、pages.json：路由与导航栏"></a>三、pages.json：路由与导航栏</h2><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;pages&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;path&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pages/index/index&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;style&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;navigationBarTitleText&quot;</span><span class="punctuation">:</span> <span class="string">&quot;首页&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;enablePullDownRefresh&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;path&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pages/detail/detail&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;style&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;navigationBarTitleText&quot;</span><span class="punctuation">:</span> <span class="string">&quot;详情&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;globalStyle&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;navigationBarTextStyle&quot;</span><span class="punctuation">:</span> <span class="string">&quot;black&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;navigationBarBackgroundColor&quot;</span><span class="punctuation">:</span> <span class="string">&quot;#ffffff&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;tabBar&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;color&quot;</span><span class="punctuation">:</span> <span class="string">&quot;#7A7E83&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;selectedColor&quot;</span><span class="punctuation">:</span> <span class="string">&quot;#3cc51f&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;list&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">      <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;pagePath&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pages/index/index&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;text&quot;</span><span class="punctuation">:</span> <span class="string">&quot;首页&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;pagePath&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pages/detail/detail&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;text&quot;</span><span class="punctuation">:</span> <span class="string">&quot;详情&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">]</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>第一项 pages 是首页（启动页），tabBar 配置底部导航。</p><h2 id="四、写一个页面"><a href="#四、写一个页面" class="headerlink" title="四、写一个页面"></a>四、写一个页面</h2><p>页面就是一个 Vue 单文件组件，用 template + script + style：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;view class=&quot;container&quot;&gt;</span><br><span class="line">    &lt;text class=&quot;title&quot;&gt;今日待办&lt;/text&gt;</span><br><span class="line"></span><br><span class="line">    &lt;view class=&quot;item&quot; v-for=&quot;todo in todos&quot; :key=&quot;todo.id&quot;&gt;</span><br><span class="line">      &lt;checkbox :checked=&quot;todo.done&quot; @click=&quot;toggle(todo)&quot; /&gt;</span><br><span class="line">      &lt;text :class=&quot;&#123; done: todo.done &#125;&quot;&gt;&#123;&#123; todo.title &#125;&#125;&lt;/text&gt;</span><br><span class="line">    &lt;/view&gt;</span><br><span class="line"></span><br><span class="line">    &lt;input v-model=&quot;newTodo&quot; placeholder=&quot;输入新待办&quot; confirm-type=&quot;done&quot; @confirm=&quot;addTodo&quot; /&gt;</span><br><span class="line">    &lt;button type=&quot;primary&quot; @click=&quot;addTodo&quot;&gt;添加&lt;/button&gt;</span><br><span class="line">  &lt;/view&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; ref &#125; from &#x27;vue&#x27;</span><br><span class="line"></span><br><span class="line">const todos = ref([</span><br><span class="line">  &#123; id: 1, title: &#x27;学习 uni-app&#x27;, done: false &#125;,</span><br><span class="line">  &#123; id: 2, title: &#x27;发布到微信小程序&#x27;, done: false &#125;</span><br><span class="line">])</span><br><span class="line">const newTodo = ref(&#x27;&#x27;)</span><br><span class="line"></span><br><span class="line">function toggle(todo) &#123;</span><br><span class="line">  todo.done = !todo.done</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">function addTodo() &#123;</span><br><span class="line">  const title = newTodo.value.trim()</span><br><span class="line">  if (!title) return</span><br><span class="line">  todos.value.push(&#123; id: Date.now(), title, done: false &#125;)</span><br><span class="line">  newTodo.value = &#x27;&#x27;</span><br><span class="line">&#125;</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;style scoped&gt;</span><br><span class="line">.container &#123;</span><br><span class="line">  padding: 24rpx;</span><br><span class="line">&#125;</span><br><span class="line">.title &#123;</span><br><span class="line">  font-size: 40rpx;</span><br><span class="line">  font-weight: bold;</span><br><span class="line">&#125;</span><br><span class="line">.done &#123;</span><br><span class="line">  text-decoration: line-through;</span><br><span class="line">  color: #999;</span><br><span class="line">&#125;</span><br><span class="line">&lt;/style&gt;</span><br></pre></td></tr></table></figure><p>注意事项：</p><ul><li>标签用 view、text、input、button 等 uni-app 内置组件，不是 HTML 的 div&#x2F;span；</li><li>单位用 rpx（响应式像素），750rpx &#x3D; 屏幕宽度，跨端自适应；</li><li>部分 Web 特性（如 v-html）在各端支持不一致，跨端场景尽量不用。</li></ul><h2 id="五、API-调用：uni-xxx-统一接口"><a href="#五、API-调用：uni-xxx-统一接口" class="headerlink" title="五、API 调用：uni.xxx 统一接口"></a>五、API 调用：uni.xxx 统一接口</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 请求</span></span><br><span class="line">uni.<span class="title function_">request</span>(&#123;</span><br><span class="line">  <span class="attr">url</span>: <span class="string">&#x27;https://api.example.com/todos&#x27;</span>,</span><br><span class="line">  <span class="attr">method</span>: <span class="string">&#x27;GET&#x27;</span>,</span><br><span class="line">  <span class="attr">success</span>: <span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(res.<span class="property">data</span>)</span><br><span class="line">  &#125;,</span><br><span class="line">  <span class="attr">fail</span>: <span class="function">(<span class="params">err</span>) =&gt;</span> &#123;</span><br><span class="line">    uni.<span class="title function_">showToast</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;请求失败&#x27;</span>, <span class="attr">icon</span>: <span class="string">&#x27;none&#x27;</span> &#125;)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 本地存储</span></span><br><span class="line">uni.<span class="title function_">setStorageSync</span>(<span class="string">&#x27;token&#x27;</span>, token)</span><br><span class="line"><span class="keyword">const</span> token = uni.<span class="title function_">getStorageSync</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 页面跳转</span></span><br><span class="line">uni.<span class="title function_">navigateTo</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/pages/detail/detail?id=1&#x27;</span> &#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 提示</span></span><br><span class="line">uni.<span class="title function_">showToast</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;保存成功&#x27;</span>, <span class="attr">icon</span>: <span class="string">&#x27;success&#x27;</span> &#125;)</span><br><span class="line">uni.<span class="title function_">showLoading</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;加载中&#x27;</span> &#125;)</span><br></pre></td></tr></table></figure><h2 id="六、条件编译：处理平台差异"><a href="#六、条件编译：处理平台差异" class="headerlink" title="六、条件编译：处理平台差异"></a>六、条件编译：处理平台差异</h2><p>少数场景各平台实现不同，用条件编译注释区分：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- #ifdef MP-WEIXIN --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">view</span>&gt;</span>只在微信小程序编译<span class="tag">&lt;/<span class="name">view</span>&gt;</span></span><br><span class="line"><span class="comment">&lt;!-- #endif --&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- #ifdef H5 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">view</span>&gt;</span>只在 H5 编译<span class="tag">&lt;/<span class="name">view</span>&gt;</span></span><br><span class="line"><span class="comment">&lt;!-- #endif --&gt;</span></span><br></pre></td></tr></table></figure><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// #ifdef APP-PLUS</span></span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;App 端（iOS/Android）专用逻辑&#x27;</span>)</span><br><span class="line"><span class="comment">// #endif</span></span><br></pre></td></tr></table></figure><p>常用条件值：MP-WEIXIN（微信小程序）、H5、APP-PLUS（App）、MP-ALIPAY（支付宝小程序）。</p><h2 id="七、运行与发布"><a href="#七、运行与发布" class="headerlink" title="七、运行与发布"></a>七、运行与发布</h2><p><strong>微信小程序</strong>：</p><ol><li>npm run dev:mp-weixin 生成 dist&#x2F;dev&#x2F;mp-weixin；</li><li>用微信开发者工具”导入项目”选择该目录，填入自己的小程序 AppID；</li><li>真机预览调试；</li><li>发布时 npm run build:mp-weixin，在微信开发者工具里上传审核。</li></ol><p><strong>H5</strong>：</p><ol><li>npm run dev:h5 本地调试；</li><li>npm run build:h5 构建 dist&#x2F;build&#x2F;h5，部署到任意静态服务器即可。</li></ol><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li>样式不一致：各端对 CSS 支持有差异，涉及兼容性的样式用条件编译单独处理；</li><li>uni.request 在小程序里需要配置合法域名（微信公众平台 → 开发设置 → 服务器域名），本地调试要勾选”不校验合法域名”；</li><li>组件不能随便用 HTML 标签：跨端场景务必使用 uni-app 内置组件；</li><li>App 端发布：需要 DCloud 开发者账号打包，或者用云打包生成安装包。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>uni-app 的核心价值是”一份代码，多端覆盖”。上手重点：pages.json 路由配置、内置组件、uni.xxx API、条件编译。业务简单、追求多端覆盖的项目，用 uni-app 比分别维护小程序和 H5 高效得多。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015ua/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015ua/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>uni-app 跨端开发实战教程：用 Vue 语法一套代码同时发布微信小程序和 H5，涵盖项目创建、pages.json 配置、条件编译与发布流程。</summary>
    <title>uni-app 跨端开发实战：一套代码跑微信小程序和 H5</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="Vue" scheme="https://blog.zhangsifan.com/tags/Vue/"/>
    <category term="Vue3" scheme="https://blog.zhangsifan.com/tags/Vue3/"/>
    <content>
      <![CDATA[<h2 id="为什么用组合式-API"><a href="#为什么用组合式-API" class="headerlink" title="为什么用组合式 API"></a>为什么用组合式 API</h2><p>Vue 2 的 Options API 把同一功能的逻辑分散在 <code>data</code>、<code>methods</code>、<code>watch</code>、<code>computed</code> 等选项里，组件一复杂，”一个功能的相关代码被拆得七零八落”的问题就很明显。Vue 3 的组合式 API（Composition API）允许我们<strong>按逻辑关注点组织代码</strong>，还能把可复用的逻辑抽成独立函数。</p><h2 id="script-setup-语法糖"><a href="#script-setup-语法糖" class="headerlink" title="script setup 语法糖"></a>script setup 语法糖</h2><p>从 Vue 3.2 开始，推荐使用 <code>&lt;script setup&gt;</code>，它让组合式 API 的写法更简洁：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; ref, computed &#125; from &#x27;vue&#x27;</span><br><span class="line"></span><br><span class="line">// 顶层声明的变量、函数自动暴露给模板</span><br><span class="line">const count = ref(0)</span><br><span class="line"></span><br><span class="line">function increment() &#123;</span><br><span class="line">  count.value++</span><br><span class="line">&#125;</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;button @click=&quot;increment&quot;&gt;count = &#123;&#123; count &#125;&#125;&lt;/button&gt;</span><br><span class="line">&lt;/template&gt;</span><br></pre></td></tr></table></figure><p>使用 <code>&lt;script setup&gt;</code> 后不需要 <code>export default</code>，也不需要 <code>return</code>，组件自动变成”开箱即用”。</p><h2 id="核心-API-速览"><a href="#核心-API-速览" class="headerlink" title="核心 API 速览"></a>核心 API 速览</h2><h3 id="ref-与-reactive"><a href="#ref-与-reactive" class="headerlink" title="ref 与 reactive"></a>ref 与 reactive</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; ref, reactive &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ref 用于基本类型，也可以包对象（访问时自动解包）</span></span><br><span class="line"><span class="keyword">const</span> name = <span class="title function_">ref</span>(<span class="string">&#x27;张三&#x27;</span>)</span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(name.<span class="property">value</span>) <span class="comment">// 张三</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// reactive 用于对象，成员访问无需 .value</span></span><br><span class="line"><span class="keyword">const</span> user = <span class="title function_">reactive</span>(&#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&#x27;张三&#x27;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">25</span></span><br><span class="line">&#125;)</span><br><span class="line">user.<span class="property">age</span>++ <span class="comment">// 直接改，无需 .value</span></span><br></pre></td></tr></table></figure><p><strong>一个容易踩的坑</strong>：<code>reactive</code> 对解构后的属性会丢失响应性：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 错误写法：解构后 age 不再是响应式的</span></span><br><span class="line"><span class="keyword">const</span> &#123; age &#125; = user</span><br><span class="line"></span><br><span class="line"><span class="comment">// 正确写法：用 toRefs 解构</span></span><br><span class="line"><span class="keyword">import</span> &#123; toRefs &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"><span class="keyword">const</span> &#123; age &#125; = <span class="title function_">toRefs</span>(user)</span><br></pre></td></tr></table></figure><h3 id="computed-计算属性"><a href="#computed-计算属性" class="headerlink" title="computed 计算属性"></a>computed 计算属性</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; ref, computed &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> price = <span class="title function_">ref</span>(<span class="number">100</span>)</span><br><span class="line"><span class="keyword">const</span> quantity = <span class="title function_">ref</span>(<span class="number">2</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> total = <span class="title function_">computed</span>(<span class="function">() =&gt;</span> price.<span class="property">value</span> * quantity.<span class="property">value</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 计算属性默认是只读的，需要可写时传入 get/set</span></span><br><span class="line"><span class="keyword">const</span> totalEditable = <span class="title function_">computed</span>(&#123;</span><br><span class="line">  <span class="attr">get</span>: <span class="function">() =&gt;</span> price.<span class="property">value</span> * quantity.<span class="property">value</span>,</span><br><span class="line">  <span class="attr">set</span>: <span class="function">(<span class="params">val</span>) =&gt;</span> &#123;</span><br><span class="line">    price.<span class="property">value</span> = val / quantity.<span class="property">value</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h3 id="watch-侦听器"><a href="#watch-侦听器" class="headerlink" title="watch 侦听器"></a>watch 侦听器</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; ref, watch, watchEffect &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> keyword = <span class="title function_">ref</span>(<span class="string">&#x27;&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 侦听单个数据源</span></span><br><span class="line"><span class="title function_">watch</span>(keyword, <span class="function">(<span class="params">newVal, oldVal</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;搜索词变化：&#x27;</span>, oldVal, <span class="string">&#x27;-&gt;&#x27;</span>, newVal)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 侦听多个数据源</span></span><br><span class="line"><span class="title function_">watch</span>([keyword, page], <span class="function">(<span class="params">[newK, newP], [oldK, oldP]</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// ...</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 侦听对象中的某个属性，需要传 getter</span></span><br><span class="line"><span class="title function_">watch</span>(</span><br><span class="line">  <span class="function">() =&gt;</span> user.<span class="property">age</span>,</span><br><span class="line">  <span class="function">(<span class="params">val</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;年龄变为&#x27;</span>, val)</span><br><span class="line">  &#125;</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// watchEffect：自动追踪依赖，依赖变化即执行</span></span><br><span class="line"><span class="title function_">watchEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;当前搜索词：&#x27;</span>, keyword.<span class="property">value</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>注意：<code>watch</code> 是惰性的（依赖变化才触发），<code>watchEffect</code> 一进来就会执行一次。</p><h2 id="生命周期"><a href="#生命周期" class="headerlink" title="生命周期"></a>生命周期</h2><p>组合式 API 中生命周期钩子统一改为 <code>onXxx</code> 形式，且只能在 <code>setup</code> 顶层调用：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; onMounted, onUpdated, onUnmounted &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">onMounted</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// DOM 已挂载，适合发请求、初始化第三方库</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="title function_">onUpdated</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// 组件更新后</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="title function_">onUnmounted</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// 组件卸载，清理定时器、取消订阅</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="自定义组合函数：逻辑复用的核心"><a href="#自定义组合函数：逻辑复用的核心" class="headerlink" title="自定义组合函数：逻辑复用的核心"></a>自定义组合函数：逻辑复用的核心</h2><p>把可复用的逻辑抽成以 <code>use</code> 开头的函数，这是组合式 API 最大的价值。</p><p>下面封装一个”计数器”组合函数：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useCounter.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; ref, computed &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useCounter</span>(<span class="params">initialValue = <span class="number">0</span></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> count = <span class="title function_">ref</span>(initialValue)</span><br><span class="line">  <span class="keyword">const</span> double = <span class="title function_">computed</span>(<span class="function">() =&gt;</span> count.<span class="property">value</span> * <span class="number">2</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">function</span> <span class="title function_">increment</span>(<span class="params"></span>) &#123;</span><br><span class="line">    count.<span class="property">value</span>++</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">function</span> <span class="title function_">reset</span>(<span class="params"></span>) &#123;</span><br><span class="line">    count.<span class="property">value</span> = initialValue</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> &#123; count, double, increment, reset &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>组件中直接使用：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; useCounter &#125; from &#x27;./useCounter&#x27;</span><br><span class="line"></span><br><span class="line">const &#123; count, double, increment, reset &#125; = useCounter(10)</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;p&gt;count: &#123;&#123; count &#125;&#125;，double: &#123;&#123; double &#125;&#125;&lt;/p&gt;</span><br><span class="line">  &lt;button @click=&quot;increment&quot;&gt;+1&lt;/button&gt;</span><br><span class="line">  &lt;button @click=&quot;reset&quot;&gt;重置&lt;/button&gt;</span><br><span class="line">&lt;/template&gt;</span><br></pre></td></tr></table></figure><p>再比如常见的”请求数据”组合函数：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// useRequest.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; ref &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">useRequest</span>(<span class="params">fetcher</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> data = <span class="title function_">ref</span>(<span class="literal">null</span>)</span><br><span class="line">  <span class="keyword">const</span> loading = <span class="title function_">ref</span>(<span class="literal">false</span>)</span><br><span class="line">  <span class="keyword">const</span> error = <span class="title function_">ref</span>(<span class="literal">null</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">run</span>(<span class="params">...args</span>) &#123;</span><br><span class="line">    loading.<span class="property">value</span> = <span class="literal">true</span></span><br><span class="line">    error.<span class="property">value</span> = <span class="literal">null</span></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      data.<span class="property">value</span> = <span class="keyword">await</span> <span class="title function_">fetcher</span>(...args)</span><br><span class="line">    &#125; <span class="keyword">catch</span> (e) &#123;</span><br><span class="line">      error.<span class="property">value</span> = e</span><br><span class="line">    &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">      loading.<span class="property">value</span> = <span class="literal">false</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> &#123; data, loading, error, run &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; useRequest &#125; from &#x27;./useRequest&#x27;</span><br><span class="line"></span><br><span class="line">const &#123; data, loading, error, run &#125; = useRequest((id) =&gt;</span><br><span class="line">  fetch(`/api/user/$&#123;id&#125;`).then((res) =&gt; res.json())</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">run(1) // 页面加载时触发</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;div v-if=&quot;loading&quot;&gt;加载中...&lt;/div&gt;</span><br><span class="line">  &lt;div v-else-if=&quot;error&quot;&gt;出错了：&#123;&#123; error.message &#125;&#125;&lt;/div&gt;</span><br><span class="line">  &lt;div v-else&gt;&#123;&#123; data &#125;&#125;&lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br></pre></td></tr></table></figure><h2 id="组合式-API-的几个注意点"><a href="#组合式-API-的几个注意点" class="headerlink" title="组合式 API 的几个注意点"></a>组合式 API 的几个注意点</h2><ol><li><strong>只能在 <code>&lt;script setup&gt;</code> 或 <code>setup()</code> 顶层调用</strong>，不能在 <code>if</code>、<code>for</code> 里调用响应式 API；</li><li><code>ref</code> 在模板中自动解包，写 <code>&#123;&#123; count &#125;&#125;</code> 而不是 <code>&#123;&#123; count.value &#125;&#125;</code>；在 JS 中必须写 <code>.value</code>；</li><li>组合函数内部如果创建了定时器、事件监听，记得在 <code>onUnmounted</code> 里清理，避免内存泄漏；</li><li>项目级复用逻辑放到 <code>src/composables/</code> 目录统一管理。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>组合式 API 的核心价值是<strong>按逻辑组织代码 + 函数级复用</strong>。先用好 <code>ref / reactive / computed / watch</code> 这四件套，再养成分层抽取 <code>useXxx</code> 组合函数的习惯，组件会越写越清晰。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20261015vue/</id>
    <link href="https://blog.zhangsifan.com/posts/20261015vue/"/>
    <published>2026-10-14T16:00:00.000Z</published>
    <summary>Vue 3 组合式 API 实战教程：script setup 语法糖、ref/reactive/computed/watch 的使用，以及如何用自定义组合函数实现逻辑复用。</summary>
    <title>Vue 3 组合式 API 实战：setup 语法糖与逻辑复用</title>
    <updated>2026-10-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="后端" scheme="https://blog.zhangsifan.com/categories/%E5%90%8E%E7%AB%AF/"/>
    <category term="后端" scheme="https://blog.zhangsifan.com/tags/%E5%90%8E%E7%AB%AF/"/>
    <category term="Redis" scheme="https://blog.zhangsifan.com/tags/Redis/"/>
    <category term="缓存" scheme="https://blog.zhangsifan.com/tags/%E7%BC%93%E5%AD%98/"/>
    <content>
      <![CDATA[<h2 id="Redis-是什么"><a href="#Redis-是什么" class="headerlink" title="Redis 是什么"></a>Redis 是什么</h2><p>Redis 是<strong>内存型键值数据库</strong>，读写速度在微秒级，是后端缓存的绝对主力。核心价值：<strong>把高频读的数据放内存，减轻数据库压力</strong>。</p><h2 id="一、五大数据类型"><a href="#一、五大数据类型" class="headerlink" title="一、五大数据类型"></a>一、五大数据类型</h2><h3 id="1-String（字符串）"><a href="#1-String（字符串）" class="headerlink" title="1. String（字符串）"></a>1. String（字符串）</h3><p>最基础的类型，存 JSON 序列化结果、计数器、验证码：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">SET user:1 <span class="string">&#x27;&#123;&quot;name&quot;:&quot;张三&quot;,&quot;age&quot;:25&#125;&#x27;</span></span><br><span class="line">GET user:1</span><br><span class="line"></span><br><span class="line"><span class="comment"># 计数器（原子自增，秒杀/点赞）</span></span><br><span class="line">INCR visit:count</span><br><span class="line">INCRBY article:100:like 2</span><br></pre></td></tr></table></figure><p><strong>注意</strong>：<code>INCR</code> 是原子操作，并发下不会出错，这是 String 做计数器的原因。</p><h3 id="2-Hash（哈希）"><a href="#2-Hash（哈希）" class="headerlink" title="2. Hash（哈希）"></a>2. Hash（哈希）</h3><p>适合存对象，字段可单独操作：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">HSET user:100 name 张三 age 25</span><br><span class="line">HGET user:100 name</span><br><span class="line">HGETALL user:100</span><br><span class="line">HINCRBY user:100 age 1</span><br></pre></td></tr></table></figure><p>场景：用户信息、购物车（<code>HSET cart:1001 sku_1 2</code>）。</p><h3 id="3-List（列表）"><a href="#3-List（列表）" class="headerlink" title="3. List（列表）"></a>3. List（列表）</h3><p>双向链表，适合<strong>消息队列</strong>和<strong>时间线</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 右侧入队，左侧出队 = 先进先出队列</span></span><br><span class="line">LPUSH queue:task task1 task2</span><br><span class="line">RPOP queue:task</span><br><span class="line"></span><br><span class="line"><span class="comment"># 分页：取最新 N 条（比如最新评论）</span></span><br><span class="line">LRANGE feed:news 0 9</span><br></pre></td></tr></table></figure><h3 id="4-Set（集合）"><a href="#4-Set（集合）" class="headerlink" title="4. Set（集合）"></a>4. Set（集合）</h3><p>自动去重，适合<strong>标签、关注关系</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">SADD user:1:tags 前端 后端</span><br><span class="line">SISMEMBER user:1:tags 前端        <span class="comment"># 是否包含</span></span><br><span class="line">SINTER user:1:tags user:2:tags    <span class="comment"># 共同标签（交集）</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 抽奖去重</span></span><br><span class="line">SADD lottery 1001 1002 1003</span><br><span class="line">SPOP lottery</span><br></pre></td></tr></table></figure><h3 id="5-ZSet（有序集合）"><a href="#5-ZSet（有序集合）" class="headerlink" title="5. ZSet（有序集合）"></a>5. ZSet（有序集合）</h3><p>带分数（score）排序，适合<strong>排行榜</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">ZADD rank:game 9500 player_a</span><br><span class="line">ZADD rank:game 8800 player_b</span><br><span class="line">ZINCRBY rank:game 500 player_a     <span class="comment"># 加分自动重排</span></span><br><span class="line"></span><br><span class="line">ZREVRANGE rank:game 0 9            <span class="comment"># 前十名</span></span><br><span class="line">ZSCORE rank:game player_a          <span class="comment"># 查分数</span></span><br><span class="line">ZRANK rank:game player_a           <span class="comment"># 查名次</span></span><br></pre></td></tr></table></figure><h2 id="二、过期时间：缓存的命根子"><a href="#二、过期时间：缓存的命根子" class="headerlink" title="二、过期时间：缓存的命根子"></a>二、过期时间：缓存的命根子</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">SET session:token abc123 EX 3600    <span class="comment"># 1 小时过期</span></span><br><span class="line">EXPIRE cache:key 60                  <span class="comment"># 已有 key 设过期</span></span><br><span class="line">TTL cache:key                        <span class="comment"># 查剩余秒数（-1 永不过期，-2 不存在）</span></span><br></pre></td></tr></table></figure><p><strong>所有缓存都必须设过期时间</strong>，否则一旦数据变了，用户永远读到旧值。</p><h2 id="三、Node-js-接入（ioredis）"><a href="#三、Node-js-接入（ioredis）" class="headerlink" title="三、Node.js 接入（ioredis）"></a>三、Node.js 接入（ioredis）</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install ioredis</span><br></pre></td></tr></table></figure><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title class_">Redis</span> = <span class="built_in">require</span>(<span class="string">&#x27;ioredis&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> redis = <span class="keyword">new</span> <span class="title class_">Redis</span>(&#123;</span><br><span class="line">  <span class="attr">host</span>: <span class="string">&#x27;127.0.0.1&#x27;</span>,</span><br><span class="line">  <span class="attr">port</span>: <span class="number">6379</span>,</span><br><span class="line">  <span class="attr">password</span>: process.<span class="property">env</span>.<span class="property">REDIS_PASSWORD</span>,</span><br><span class="line">  <span class="comment">// 连接池 + 重试</span></span><br><span class="line">  <span class="attr">maxRetriesPerRequest</span>: <span class="number">3</span>,</span><br><span class="line">  <span class="attr">retryStrategy</span>: <span class="function">(<span class="params">times</span>) =&gt;</span> <span class="title class_">Math</span>.<span class="title function_">min</span>(times * <span class="number">200</span>, <span class="number">2000</span>)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 基本的缓存读写</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">getUser</span>(<span class="params">id</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> cached = <span class="keyword">await</span> redis.<span class="title function_">get</span>(<span class="string">`user:<span class="subst">$&#123;id&#125;</span>`</span>)</span><br><span class="line">  <span class="keyword">if</span> (cached) <span class="keyword">return</span> <span class="title class_">JSON</span>.<span class="title function_">parse</span>(cached)   <span class="comment">// 缓存命中</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> user = <span class="keyword">await</span> db.<span class="title function_">query</span>(<span class="string">&#x27;SELECT * FROM users WHERE id = ?&#x27;</span>, [id])</span><br><span class="line">  <span class="keyword">await</span> redis.<span class="title function_">set</span>(<span class="string">`user:<span class="subst">$&#123;id&#125;</span>`</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(user), <span class="string">&#x27;EX&#x27;</span>, <span class="number">600</span>)  <span class="comment">// 10 分钟</span></span><br><span class="line">  <span class="keyword">return</span> user</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="四、三个经典缓存问题"><a href="#四、三个经典缓存问题" class="headerlink" title="四、三个经典缓存问题"></a>四、三个经典缓存问题</h2><h3 id="1-缓存穿透：查不存在的数据"><a href="#1-缓存穿透：查不存在的数据" class="headerlink" title="1. 缓存穿透：查不存在的数据"></a>1. 缓存穿透：查不存在的数据</h3><p>请求一个<strong>永远查不到</strong>的 key（如恶意遍历 id），每次都打到数据库。</p><p><strong>解决</strong>：缓存空值 + 布隆过滤器</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> cached = <span class="keyword">await</span> redis.<span class="title function_">get</span>(<span class="string">`user:<span class="subst">$&#123;id&#125;</span>`</span>)</span><br><span class="line"><span class="keyword">if</span> (cached !== <span class="literal">null</span>) &#123;</span><br><span class="line">  <span class="comment">// 缓存了空值（&#x27;null&#x27;）也直接返回，不查库</span></span><br><span class="line">  <span class="keyword">return</span> cached === <span class="string">&#x27;NULL&#x27;</span> ? <span class="literal">null</span> : <span class="title class_">JSON</span>.<span class="title function_">parse</span>(cached)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">const</span> user = <span class="keyword">await</span> db.<span class="title function_">query</span>(...)</span><br><span class="line"><span class="comment">// 查不到也缓存空值，短过期防止穿透</span></span><br><span class="line"><span class="keyword">await</span> redis.<span class="title function_">set</span>(<span class="string">`user:<span class="subst">$&#123;id&#125;</span>`</span>, user ? <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(user) : <span class="string">&#x27;NULL&#x27;</span>, <span class="string">&#x27;EX&#x27;</span>, <span class="number">300</span>)</span><br></pre></td></tr></table></figure><h3 id="2-缓存击穿：热点-key-过期"><a href="#2-缓存击穿：热点-key-过期" class="headerlink" title="2. 缓存击穿：热点 key 过期"></a>2. 缓存击穿：热点 key 过期</h3><p>某个<strong>超高并发的 key</strong> 过期的瞬间，大量请求同时打数据库。</p><p><strong>解决</strong>：互斥锁（只让一个请求去重建缓存）</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">getHotData</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> cached = <span class="keyword">await</span> redis.<span class="title function_">get</span>(<span class="string">&#x27;hot:data&#x27;</span>)</span><br><span class="line">  <span class="keyword">if</span> (cached) <span class="keyword">return</span> <span class="title class_">JSON</span>.<span class="title function_">parse</span>(cached)</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 抢锁：拿不到锁说明别人正在重建，短暂等待后重试</span></span><br><span class="line">  <span class="keyword">const</span> lock = <span class="keyword">await</span> redis.<span class="title function_">set</span>(<span class="string">&#x27;lock:hot:data&#x27;</span>, <span class="string">&#x27;1&#x27;</span>, <span class="string">&#x27;EX&#x27;</span>, <span class="number">10</span>, <span class="string">&#x27;NX&#x27;</span>)</span><br><span class="line">  <span class="keyword">if</span> (lock) &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="keyword">const</span> data = <span class="keyword">await</span> db.<span class="title function_">query</span>(...)</span><br><span class="line">      <span class="keyword">await</span> redis.<span class="title function_">set</span>(<span class="string">&#x27;hot:data&#x27;</span>, <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(data), <span class="string">&#x27;EX&#x27;</span>, <span class="number">600</span>)</span><br><span class="line">      <span class="keyword">return</span> data</span><br><span class="line">    &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">      <span class="keyword">await</span> redis.<span class="title function_">del</span>(<span class="string">&#x27;lock:hot:data&#x27;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="keyword">await</span> <span class="title function_">sleep</span>(<span class="number">50</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="title function_">getHotData</span>()   <span class="comment">// 重试读缓存</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="3-缓存雪崩：大量-key-同时过期"><a href="#3-缓存雪崩：大量-key-同时过期" class="headerlink" title="3. 缓存雪崩：大量 key 同时过期"></a>3. 缓存雪崩：大量 key 同时过期</h3><p>很多 key 同一时刻过期，数据库瞬时压力爆表。</p><p><strong>解决</strong>：过期时间加随机偏移</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 过期时间 = 基准 + 随机 0~60 秒</span></span><br><span class="line"><span class="keyword">await</span> redis.<span class="title function_">set</span>(key, value, <span class="string">&#x27;EX&#x27;</span>, <span class="number">600</span> + <span class="title class_">Math</span>.<span class="title function_">floor</span>(<span class="title class_">Math</span>.<span class="title function_">random</span>() * <span class="number">60</span>))</span><br></pre></td></tr></table></figure><h2 id="五、缓存一致性问题（必知）"><a href="#五、缓存一致性问题（必知）" class="headerlink" title="五、缓存一致性问题（必知）"></a>五、缓存一致性问题（必知）</h2><p>缓存和数据库双写，怎么保证一致？<strong>业界共识：先更新数据库，再删缓存</strong>（Cache Aside）：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 更新</span></span><br><span class="line"><span class="keyword">await</span> db.<span class="title function_">query</span>(<span class="string">&#x27;UPDATE users SET name=? WHERE id=?&#x27;</span>, [name, id])</span><br><span class="line"><span class="keyword">await</span> redis.<span class="title function_">del</span>(<span class="string">`user:<span class="subst">$&#123;id&#125;</span>`</span>)   <span class="comment">// 删缓存，下次读取时重建</span></span><br></pre></td></tr></table></figure><p><strong>不要先删缓存再更新库</strong>：中间会有并发读到旧数据写回，缓存永远脏。</p><h2 id="六、常见坑"><a href="#六、常见坑" class="headerlink" title="六、常见坑"></a>六、常见坑</h2><ol><li><strong>Redis 当数据库用</strong>：内存贵、有淘汰策略（LRU），只放高频热数据；</li><li><strong>没设过期时间</strong>：缓存永不失效，数据更新后一直脏；</li><li><strong>大 key 问题</strong>：单个 value 几 MB 会导致 Redis 阻塞，大数据要拆分；</li><li><strong>连接数打满</strong>：每个请求都 new Redis 实例会耗尽连接，用连接池（ioredis 默认复用）；</li><li><strong>生产环境 Redis 没密码&#x2F;没绑定内网</strong>：默认可被外网扫描爆破，务必配置。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Redis 的掌握路径：<strong>五大类型记场景（String 缓存&#x2F;计数、Hash 对象、List 队列、Set 去重、ZSet 排行榜）→ 过期时间必须设 → 用 ioredis 接入 → 解决穿透&#x2F;击穿&#x2F;雪崩 → 记住”先更库再删缓存”</strong>。这套是后端高频缓存的标准答案。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260912redis/</id>
    <link href="https://blog.zhangsifan.com/posts/20260912redis/"/>
    <published>2026-09-11T16:00:00.000Z</published>
    <summary>Redis 入门实战教程：string/hash/list/set/zset 五大类型及使用场景、Node.js 接入 Redis、缓存穿透/击穿/雪崩的解决方案。</summary>
    <title>Redis 入门：五大数据类型与缓存实战</title>
    <updated>2026-09-11T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="性能优化" scheme="https://blog.zhangsifan.com/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    <category term="加载优化" scheme="https://blog.zhangsifan.com/tags/%E5%8A%A0%E8%BD%BD%E4%BC%98%E5%8C%96/"/>
    <content>
      <![CDATA[<h2 id="性能优化整体思路"><a href="#性能优化整体思路" class="headerlink" title="性能优化整体思路"></a>性能优化整体思路</h2><p>优化的本质是<strong>减少关键路径上的工作量</strong>。按用户体感分三个阶段：加载（白屏到可交互）、渲染（页面流畅度）、运行时（交互响应）。</p><h2 id="一、加载阶段：让首屏更快"><a href="#一、加载阶段：让首屏更快" class="headerlink" title="一、加载阶段：让首屏更快"></a>一、加载阶段：让首屏更快</h2><h3 id="1-资源体积瘦身"><a href="#1-资源体积瘦身" class="headerlink" title="1. 资源体积瘦身"></a>1. 资源体积瘦身</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 生产构建开启压缩</span></span><br><span class="line"><span class="comment"># Vite：vite-plugin-compression 生成 .gz/.br 文件</span></span><br><span class="line"><span class="comment"># Nginx 配合：</span></span><br><span class="line">gzip on;</span><br><span class="line">gzip_types text/css application/javascript application/json image/svg+xml;</span><br><span class="line">gzip_min_length 1k;</span><br></pre></td></tr></table></figure><p><strong>图片是体积大户</strong>：能 WebP&#x2F;AVIF 就用，能 CDN 就上 CDN，能懒加载就懒加载。</p><h3 id="2-缓存策略"><a href="#2-缓存策略" class="headerlink" title="2. 缓存策略"></a>2. 缓存策略</h3><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 带 hash 的静态资源：长缓存</span></span><br><span class="line"><span class="section">location</span> /assets/ &#123;</span><br><span class="line">    <span class="attribute">expires</span> <span class="number">30d</span>;</span><br><span class="line">    <span class="attribute">add_header</span> Cache-Control <span class="string">&quot;public, immutable&quot;</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 入口 html：不缓存，保证发布后用户拿到新版本</span></span><br><span class="line"><span class="section">location</span> / &#123;</span><br><span class="line">    <span class="attribute">add_header</span> Cache-Control <span class="string">&quot;no-cache&quot;</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="3-懒加载：图片和路由"><a href="#3-懒加载：图片和路由" class="headerlink" title="3. 懒加载：图片和路由"></a>3. 懒加载：图片和路由</h3><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 图片懒加载：进入视口才加载 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">img</span> <span class="attr">loading</span>=<span class="string">&quot;lazy&quot;</span> <span class="attr">src</span>=<span class="string">&quot;photo.jpg&quot;</span> <span class="attr">alt</span>=<span class="string">&quot;&quot;</span> /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- iframe 懒加载 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">iframe</span> <span class="attr">loading</span>=<span class="string">&quot;lazy&quot;</span> <span class="attr">src</span>=<span class="string">&quot;https://example.com&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">iframe</span>&gt;</span></span><br></pre></td></tr></table></figure><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 路由懒加载（Vue）</span></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">Admin</span> = (<span class="params"></span>) =&gt; <span class="keyword">import</span>(<span class="string">&#x27;./views/Admin.vue&#x27;</span>)</span><br><span class="line"><span class="comment">// 配合 webpackChunkName 控制分包命名</span></span><br></pre></td></tr></table></figure><h3 id="4-预加载关键资源"><a href="#4-预加载关键资源" class="headerlink" title="4. 预加载关键资源"></a>4. 预加载关键资源</h3><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 首屏关键字体/图片提前加载 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;preload&quot;</span> <span class="attr">href</span>=<span class="string">&quot;font.woff2&quot;</span> <span class="attr">as</span>=<span class="string">&quot;font&quot;</span> <span class="attr">type</span>=<span class="string">&quot;font/woff2&quot;</span> <span class="attr">crossorigin</span> /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- 下一屏页面提前预热（用户即将点击时） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;prefetch&quot;</span> <span class="attr">href</span>=<span class="string">&quot;/about&quot;</span> /&gt;</span></span><br></pre></td></tr></table></figure><h3 id="5-减少请求数"><a href="#5-减少请求数" class="headerlink" title="5. 减少请求数"></a>5. 减少请求数</h3><ul><li>小图标合并成 <strong>SVG sprite</strong> 或直接用 iconfont&#x2F;icon 组件；</li><li>小的装饰图片 <strong>base64 内联</strong>（阈值 8KB 左右）；</li><li>API 合并：首屏多个接口能合并就合并，减少往返。</li></ul><h2 id="二、渲染阶段：让页面不卡"><a href="#二、渲染阶段：让页面不卡" class="headerlink" title="二、渲染阶段：让页面不卡"></a>二、渲染阶段：让页面不卡</h2><h3 id="1-减少重排（Layout）"><a href="#1-减少重排（Layout）" class="headerlink" title="1. 减少重排（Layout）"></a>1. 减少重排（Layout）</h3><p>重排代价极高，常见的”罪魁祸首”：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 反模式：循环里反复读写布局属性，强制同步重排</span></span><br><span class="line"><span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; items.<span class="property">length</span>; i++) &#123;</span><br><span class="line">  el.<span class="property">style</span>.<span class="property">left</span> = items[i].<span class="property">x</span> + <span class="string">&#x27;px&#x27;</span>   <span class="comment">// 每行都触发重排</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 好做法：用 transform 做动画（GPU 合成，不触发重排）</span></span><br><span class="line">el.<span class="property">style</span>.<span class="property">transform</span> = <span class="string">`translateX(<span class="subst">$&#123;items[i].x&#125;</span>px)`</span></span><br></pre></td></tr></table></figure><p><strong>经验法则</strong>：动画用 <code>transform</code> + <code>opacity</code>，别用 <code>top/left/width</code>。</p><h3 id="2-虚拟列表：长列表必备"><a href="#2-虚拟列表：长列表必备" class="headerlink" title="2. 虚拟列表：长列表必备"></a>2. 虚拟列表：长列表必备</h3><p>渲染 1 万个 DOM 节点页面必卡。虚拟列表<strong>只渲染可视区域的节点</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 简单思路（社区库 vue-virtual-scroller / react-window 同理）</span></span><br><span class="line"><span class="comment">// 1. 计算可视区起始/结束 index</span></span><br><span class="line"><span class="comment">// 2. 只渲染 [start, end] 之间的项</span></span><br><span class="line"><span class="comment">// 3. 外层容器 padding 撑出总高度，保持滚动条正确</span></span><br></pre></td></tr></table></figure><p>业务里出现”一次性渲染几百上千条数据”的列表，直接上虚拟列表。</p><h3 id="3-减少不必要的渲染"><a href="#3-减少不必要的渲染" class="headerlink" title="3. 减少不必要的渲染"></a>3. 减少不必要的渲染</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// React：memo 防止无关 props 变化触发渲染</span></span><br><span class="line"><span class="keyword">const</span> <span class="title class_">Item</span> = <span class="title class_">React</span>.<span class="title function_">memo</span>(<span class="keyword">function</span> <span class="title function_">Item</span>(<span class="params">&#123; data &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span>&#123;data.name&#125;<span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Vue：computed 缓存计算结果，避免模板里反复计算</span></span><br><span class="line"><span class="keyword">const</span> filteredList = <span class="title function_">computed</span>(<span class="function">() =&gt;</span></span><br><span class="line">  list.<span class="property">value</span>.<span class="title function_">filter</span>(<span class="function">(<span class="params">i</span>) =&gt;</span> i.<span class="property">visible</span>)</span><br><span class="line">)</span><br></pre></td></tr></table></figure><h3 id="4-防抖节流"><a href="#4-防抖节流" class="headerlink" title="4. 防抖节流"></a>4. 防抖节流</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 防抖：停止触发后才执行（搜索输入）</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">debounce</span>(<span class="params">fn, delay = <span class="number">300</span></span>) &#123;</span><br><span class="line">  <span class="keyword">let</span> timer</span><br><span class="line">  <span class="keyword">return</span> <span class="function">(<span class="params">...args</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="built_in">clearTimeout</span>(timer)</span><br><span class="line">    timer = <span class="built_in">setTimeout</span>(<span class="function">() =&gt;</span> <span class="title function_">fn</span>(...args), delay)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 节流：固定频率执行（滚动、resize）</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">throttle</span>(<span class="params">fn, interval = <span class="number">100</span></span>) &#123;</span><br><span class="line">  <span class="keyword">let</span> last = <span class="number">0</span></span><br><span class="line">  <span class="keyword">return</span> <span class="function">(<span class="params">...args</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> now = <span class="title class_">Date</span>.<span class="title function_">now</span>()</span><br><span class="line">    <span class="keyword">if</span> (now - last &gt;= interval) &#123;</span><br><span class="line">      last = now</span><br><span class="line">      <span class="title function_">fn</span>(...args)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>搜索用防抖，滚动用节流</strong>，别搞反。</p><h2 id="三、运行时优化"><a href="#三、运行时优化" class="headerlink" title="三、运行时优化"></a>三、运行时优化</h2><h3 id="1-事件委托"><a href="#1-事件委托" class="headerlink" title="1. 事件委托"></a>1. 事件委托</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 反模式：1000 个 li 每个绑一个监听</span></span><br><span class="line"><span class="variable language_">document</span>.<span class="title function_">querySelectorAll</span>(<span class="string">&#x27;.item&#x27;</span>).<span class="title function_">forEach</span>(<span class="function">(<span class="params">el</span>) =&gt;</span></span><br><span class="line">  el.<span class="title function_">addEventListener</span>(<span class="string">&#x27;click&#x27;</span>, handler)</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 好做法：一个监听挂到父元素</span></span><br><span class="line">list.<span class="title function_">addEventListener</span>(<span class="string">&#x27;click&#x27;</span>, <span class="function">(<span class="params">e</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> item = e.<span class="property">target</span>.<span class="title function_">closest</span>(<span class="string">&#x27;.item&#x27;</span>)</span><br><span class="line">  <span class="keyword">if</span> (item) <span class="title function_">handler</span>(item)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h3 id="2-大数据计算放-Worker"><a href="#2-大数据计算放-Worker" class="headerlink" title="2. 大数据计算放 Worker"></a>2. 大数据计算放 Worker</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 主线程不卡</span></span><br><span class="line"><span class="keyword">const</span> worker = <span class="keyword">new</span> <span class="title class_">Worker</span>(<span class="string">&#x27;compute.js&#x27;</span>)</span><br><span class="line">worker.<span class="title function_">postMessage</span>(bigData)</span><br><span class="line">worker.<span class="property">onmessage</span> = <span class="function">(<span class="params">e</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// 拿到计算结果</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>图片压缩、加密、复杂排序这类 CPU 密集任务丢给 Worker。</p><h3 id="3-内存注意"><a href="#3-内存注意" class="headerlink" title="3. 内存注意"></a>3. 内存注意</h3><ul><li>组件卸载时<strong>清掉定时器和事件监听</strong>（React useEffect 清理函数、Vue onUnmounted）；</li><li>大对象用后置空引用；</li><li>用 <code>WeakMap</code>&#x2F;<code>WeakSet</code> 缓存数据，避免强引用阻止 GC。</li></ul><h2 id="四、测量：先量化再优化"><a href="#四、测量：先量化再优化" class="headerlink" title="四、测量：先量化再优化"></a>四、测量：先量化再优化</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 性能预算检查</span></span><br><span class="line">npx lighthouse https://example.com --preset=desktop</span><br></pre></td></tr></table></figure><p>Chrome DevTools 三个关键面板：</p><ul><li><strong>Performance</strong>：录制交互，看长任务（Long Task）和主线程占用；</li><li><strong>Network</strong>：看阻塞时间和资源体积；</li><li><strong>Lighthouse</strong>：Core Web Vitals 分数（LCP、INP、CLS）。</li></ul><p><strong>优化顺序建议</strong>：LCP（首屏最大内容）→ 图片体积 → 缓存 → 懒加载 → 长列表虚拟化 → 动画 transform 化。先测后改，改完复测。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>只优化不测量</strong>：凭感觉改，可能白忙；先记录优化前后指标；</li><li><strong>过度懒加载</strong>：首屏关键图片&#x2F;内容被懒加载反而拖慢 LCP；</li><li><strong>缓存配错</strong>：html 也长缓存，导致发布后用户一直看旧版本；</li><li><strong>虚拟列表滚动抖动</strong>：行高不定时高度估算不准确，建议固定行高或预测量。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>性能优化是系统工程：<strong>加载阶段抓体积和缓存，渲染阶段抓重排和渲染数量，运行时抓事件和内存</strong>。记住”先测量、再优化、后复测”的铁律，对照 Core Web Vitals 逐个击破。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260830perf/</id>
    <link href="https://blog.zhangsifan.com/posts/20260830perf/"/>
    <published>2026-08-29T16:00:00.000Z</published>
    <summary>前端性能优化实战清单：加载阶段（压缩、CDN、缓存、懒加载）、渲染阶段（减少重排、虚拟列表）、运行时优化（事件委托、防抖节流）全梳理。</summary>
    <title>前端性能优化清单：从加载到渲染</title>
    <updated>2026-08-29T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="后端" scheme="https://blog.zhangsifan.com/categories/%E5%90%8E%E7%AB%AF/"/>
    <category term="MySQL" scheme="https://blog.zhangsifan.com/tags/MySQL/"/>
    <category term="索引优化" scheme="https://blog.zhangsifan.com/tags/%E7%B4%A2%E5%BC%95%E4%BC%98%E5%8C%96/"/>
    <category term="EXPLAIN" scheme="https://blog.zhangsifan.com/tags/EXPLAIN/"/>
    <category term="后端" scheme="https://blog.zhangsifan.com/tags/%E5%90%8E%E7%AB%AF/"/>
    <content>
      <![CDATA[<h2 id="一、索引为什么快"><a href="#一、索引为什么快" class="headerlink" title="一、索引为什么快"></a>一、索引为什么快</h2><p>MySQL InnoDB 的索引是 <strong>B+ 树</strong>结构。没有索引时查询要全表扫描（逐行比对），有了索引就像查字典——通过树形结构<strong>二分定位</strong>，查询次数从 O(n) 降到 O(log n)。</p><p>但索引不是越多越好：<strong>每次写操作都要同步维护索引树</strong>，索引太多会拖慢插入和更新。</p><h2 id="二、怎么建索引"><a href="#二、怎么建索引" class="headerlink" title="二、怎么建索引"></a>二、怎么建索引</h2><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 单列索引</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_username <span class="keyword">ON</span> users(username);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 联合索引（最常用，顺序很重要）</span></span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_user_status <span class="keyword">ON</span> orders(user_id, status, created_at);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 唯一索引（业务上不允许重复的字段）</span></span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX uk_email <span class="keyword">ON</span> users(email);</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 删除索引</span></span><br><span class="line"><span class="keyword">DROP</span> INDEX idx_username <span class="keyword">ON</span> users;</span><br></pre></td></tr></table></figure><p>建索引的优先级：</p><ol><li><strong>WHERE 条件字段</strong>（过滤最狠的先放）</li><li><strong>JOIN 关联字段</strong></li><li><strong>ORDER BY &#x2F; GROUP BY 字段</strong></li><li>区分度高的字段（性别这种只有两种值的，索引意义不大）</li></ol><h2 id="三、EXPLAIN：看执行计划"><a href="#三、EXPLAIN：看执行计划" class="headerlink" title="三、EXPLAIN：看执行计划"></a>三、EXPLAIN：看执行计划</h2><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">EXPLAIN <span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> orders <span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">AND</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span>;</span><br></pre></td></tr></table></figure><p>返回的关键列：</p><table><thead><tr><th>列</th><th>含义</th><th>重点</th></tr></thead><tbody><tr><td><code>type</code></td><td>访问类型</td><td>从好到差：<code>system &gt; const &gt; eq_ref &gt; ref &gt; range &gt; index &gt; ALL</code>，出现 <code>ALL</code>（全表扫描）要警惕</td></tr><tr><td><code>key</code></td><td>实际用到的索引</td><td><code>NULL</code> 说明没走索引</td></tr><tr><td><code>rows</code></td><td>预估扫描行数</td><td>越小越好，和实际行数差距大说明统计信息过期</td></tr><tr><td><code>Extra</code></td><td>额外信息</td><td><code>Using index</code>（覆盖索引，好）；<code>Using filesort</code>（文件排序，要优化）；<code>Using temporary</code>（临时表，要优化）</td></tr></tbody></table><p><strong>优化信号</strong>：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 慢的：type=ALL，全表扫描</span></span><br><span class="line">EXPLAIN <span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> orders <span class="keyword">WHERE</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span>;</span><br><span class="line"><span class="comment">-- 建索引后：type=ref</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 慢的：Extra 有 Using filesort</span></span><br><span class="line">EXPLAIN <span class="keyword">SELECT</span> <span class="operator">*</span> <span class="keyword">FROM</span> orders <span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at;</span><br><span class="line"><span class="comment">-- 建联合索引 (user_id, created_at) 后 filesort 消失</span></span><br></pre></td></tr></table></figure><h2 id="四、最左前缀原则（联合索引核心）"><a href="#四、最左前缀原则（联合索引核心）" class="headerlink" title="四、最左前缀原则（联合索引核心）"></a>四、最左前缀原则（联合索引核心）</h2><p>联合索引 <code>(user_id, status, created_at)</code> 相当于建了三个索引：<code>(user_id)</code>、<code>(user_id, status)</code>、<code>(user_id, status, created_at)</code>。</p><p><strong>能用上</strong>的情况：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span>                     <span class="comment">-- ✅ 用到第一列</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">AND</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span> <span class="comment">-- ✅ 用到前两列</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">AND</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span> <span class="keyword">AND</span> created_at <span class="operator">&gt;</span> <span class="string">&#x27;2026-01-01&#x27;</span>  <span class="comment">-- ✅ 全用</span></span><br></pre></td></tr></table></figure><p><strong>用不上</strong>的情况：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">WHERE</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span>                   <span class="comment">-- ❌ 跳过了第一列，索引失效</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">AND</span> created_at <span class="operator">&gt;</span> <span class="string">&#x27;2026-01-01&#x27;</span>  <span class="comment">-- ⚠️ 只用到 user_id，中间跳了 status</span></span><br></pre></td></tr></table></figure><p><strong>经验</strong>：查询条件里最常用、区分度最高的字段放联合索引最前面。</p><h2 id="五、覆盖索引：查询只读索引"><a href="#五、覆盖索引：查询只读索引" class="headerlink" title="五、覆盖索引：查询只读索引"></a>五、覆盖索引：查询只读索引</h2><p>如果查询的<strong>所有字段都在索引里</strong>，MySQL 不用回表查数据行，这就是 <code>Using index</code>：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 建了联合索引 (user_id, status)，下面的查询只读索引即可</span></span><br><span class="line">EXPLAIN <span class="keyword">SELECT</span> user_id, status <span class="keyword">FROM</span> orders <span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span>;</span><br><span class="line"><span class="comment">-- Extra: Using index ✅</span></span><br></pre></td></tr></table></figure><p>覆盖索引是超高并发场景的核心优化手段，比回表快一个数量级。</p><h2 id="六、常见索引失效场景"><a href="#六、常见索引失效场景" class="headerlink" title="六、常见索引失效场景"></a>六、常见索引失效场景</h2><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 1. 对索引列做运算或函数</span></span><br><span class="line"><span class="keyword">WHERE</span> <span class="type">DATE</span>(created_at) <span class="operator">=</span> <span class="string">&#x27;2026-08-01&#x27;</span>   <span class="comment">-- ❌ 改写成范围查询</span></span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">&gt;=</span> <span class="string">&#x27;2026-08-01&#x27;</span> <span class="keyword">AND</span> created_at <span class="operator">&lt;</span> <span class="string">&#x27;2026-08-02&#x27;</span>  <span class="comment">-- ✅</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 2. 隐式类型转换（字段是字符串，传了数字）</span></span><br><span class="line"><span class="keyword">WHERE</span> phone <span class="operator">=</span> <span class="number">13800138000</span>   <span class="comment">-- ❌ phone 是 varchar</span></span><br><span class="line"><span class="keyword">WHERE</span> phone <span class="operator">=</span> <span class="string">&#x27;13800138000&#x27;</span> <span class="comment">-- ✅</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 3. 前缀模糊</span></span><br><span class="line"><span class="keyword">WHERE</span> name <span class="keyword">LIKE</span> <span class="string">&#x27;%张%&#x27;</span>  <span class="comment">-- ❌ 以 % 开头不走索引</span></span><br><span class="line"><span class="keyword">WHERE</span> name <span class="keyword">LIKE</span> <span class="string">&#x27;张%&#x27;</span>   <span class="comment">-- ✅</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 4. OR 连接非索引列</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">OR</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span>  <span class="comment">-- ❌ 两边都要有索引</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 5. 联合索引中间列用了范围查询，后面的列失效</span></span><br><span class="line"><span class="keyword">WHERE</span> user_id <span class="operator">=</span> <span class="number">100</span> <span class="keyword">AND</span> status <span class="operator">&gt;</span> <span class="string">&#x27;a&#x27;</span> <span class="keyword">AND</span> created_at <span class="operator">&gt;</span> <span class="string">&#x27;2026-01-01&#x27;</span></span><br><span class="line"><span class="comment">-- 只用到 user_id + status，created_at 失效</span></span><br></pre></td></tr></table></figure><h2 id="七、慢查询日志"><a href="#七、慢查询日志" class="headerlink" title="七、慢查询日志"></a>七、慢查询日志</h2><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 查看慢查询设置</span></span><br><span class="line"><span class="keyword">SHOW</span> VARIABLES <span class="keyword">LIKE</span> <span class="string">&#x27;slow_query_log&#x27;</span>;</span><br><span class="line"><span class="keyword">SHOW</span> VARIABLES <span class="keyword">LIKE</span> <span class="string">&#x27;long_query_time&#x27;</span>;   <span class="comment">-- 默认 10 秒</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 临时开启（生产要写到配置文件持久化）</span></span><br><span class="line"><span class="keyword">SET</span> <span class="keyword">GLOBAL</span> slow_query_log <span class="operator">=</span> <span class="string">&#x27;ON&#x27;</span>;</span><br><span class="line"><span class="keyword">SET</span> <span class="keyword">GLOBAL</span> long_query_time <span class="operator">=</span> <span class="number">1</span>;          <span class="comment">-- 超过 1 秒记录</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 查看慢日志文件位置</span></span><br><span class="line"><span class="keyword">SHOW</span> VARIABLES <span class="keyword">LIKE</span> <span class="string">&#x27;slow_query_log_file&#x27;</span>;</span><br></pre></td></tr></table></figure><p>拿到慢 SQL 后的标准流程：</p><ol><li><code>EXPLAIN</code> 看执行计划；</li><li>确认有没有走索引、扫描行数多少；</li><li>按”最左前缀 + 覆盖索引 + 去掉函数&#x2F;隐式转换”三板斧改 SQL 或建索引；</li><li>改完复测，对比 <code>rows</code> 和执行时间。</li></ol><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>索引建了但不走</strong>：大概率是隐式转换、函数包裹、或统计信息过旧（<code>ANALYZE TABLE</code>）；</li><li><strong>联合索引顺序错了</strong>：最常用的字段没放前面，导致索引部分失效；</li><li><strong>过度索引</strong>：一张表 10+ 个索引，写放大严重，用 <code>SHOW INDEX FROM 表名</code> 定期清理冗余；</li><li><strong>只信 rows 不信实测</strong>：rows 是估算值，最终以 <code>EXPLAIN ANALYZE</code>（MySQL 8.0.18+）实测为准。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>索引优化的核心流程：<strong>先 EXPLAIN 看现状 → 按最左前缀设计联合索引 → 用覆盖索引避免回表 → 排查函数&#x2F;类型转换导致的失效 → 用慢查询日志定位真问题</strong>。把这几招练熟，90% 的慢查询都能自己解决。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260815mysql/</id>
    <link href="https://blog.zhangsifan.com/posts/20260815mysql/"/>
    <published>2026-08-14T16:00:00.000Z</published>
    <summary>MySQL 索引优化实战教程：索引原理、创建索引的原则、EXPLAIN 执行计划逐字段解读、最左前缀与覆盖索引、慢查询日志排查。</summary>
    <title>MySQL 索引优化实战：EXPLAIN 分析与慢查询</title>
    <updated>2026-08-14T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="后端" scheme="https://blog.zhangsifan.com/categories/%E5%90%8E%E7%AB%AF/"/>
    <category term="Node.js" scheme="https://blog.zhangsifan.com/tags/Node-js/"/>
    <category term="Express" scheme="https://blog.zhangsifan.com/tags/Express/"/>
    <category term="Koa" scheme="https://blog.zhangsifan.com/tags/Koa/"/>
    <category term="中间件" scheme="https://blog.zhangsifan.com/tags/%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    <content>
      <![CDATA[<h2 id="一、中间件到底是什么"><a href="#一、中间件到底是什么" class="headerlink" title="一、中间件到底是什么"></a>一、中间件到底是什么</h2><p>中间件（middleware）是 Node Web 框架的核心机制：<strong>每个请求按顺序经过一系列函数，每个函数可以处理请求、决定是否放行给下一个，最后响应返回</strong>。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Express 里最经典的中间件</span></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">`<span class="subst">$&#123;req.method&#125;</span> <span class="subst">$&#123;req.url&#125;</span>`</span>)   <span class="comment">// 记录日志</span></span><br><span class="line">  <span class="title function_">next</span>()                                     <span class="comment">// 放行给下一个中间件</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="二、Express：线性中间件"><a href="#二、Express：线性中间件" class="headerlink" title="二、Express：线性中间件"></a>二、Express：线性中间件</h2><p>Express 中间件按注册顺序线性执行，<code>next()</code> 决定是否进入下一个：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> express = <span class="built_in">require</span>(<span class="string">&#x27;express&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> app = <span class="title function_">express</span>()</span><br><span class="line"></span><br><span class="line"><span class="comment">// 1. 日志中间件</span></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;中间件 1：日志&#x27;</span>)</span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. 鉴权中间件</span></span><br><span class="line">app.<span class="title function_">use</span>(<span class="string">&#x27;/api&#x27;</span>, <span class="function">(<span class="params">req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> token = req.<span class="property">headers</span>.<span class="property">authorization</span></span><br><span class="line">  <span class="keyword">if</span> (!token) &#123;</span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">status</span>(<span class="number">401</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;未登录&#x27;</span> &#125;)  <span class="comment">// 拦截，不 next</span></span><br><span class="line">  &#125;</span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. 路由</span></span><br><span class="line">app.<span class="title function_">get</span>(<span class="string">&#x27;/api/user&#x27;</span>, <span class="function">(<span class="params">req, res</span>) =&gt;</span> &#123;</span><br><span class="line">  res.<span class="title function_">json</span>(&#123; <span class="attr">name</span>: <span class="string">&#x27;张三&#x27;</span> &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. 404 兜底</span></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res</span>) =&gt;</span> &#123;</span><br><span class="line">  res.<span class="title function_">status</span>(<span class="number">404</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;接口不存在&#x27;</span> &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 5. 错误处理中间件（4 个参数，必须带 err）</span></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">err, req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(err)</span><br><span class="line">  res.<span class="title function_">status</span>(<span class="number">500</span>).<span class="title function_">json</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;服务器错误&#x27;</span> &#125;)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong><code>next(err)</code> 的语义</strong>：传给 <code>next</code> 任何参数（除了字符串 ‘route’），Express 会跳过普通中间件，直接跳到错误处理中间件。</p><h2 id="三、Koa：洋葱模型"><a href="#三、Koa：洋葱模型" class="headerlink" title="三、Koa：洋葱模型"></a>三、Koa：洋葱模型</h2><p>Koa 的中间件可以 <code>await</code>，执行流程是”<strong>进进出出</strong>“的洋葱：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title class_">Koa</span> = <span class="built_in">require</span>(<span class="string">&#x27;koa&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> app = <span class="keyword">new</span> <span class="title class_">Koa</span>()</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">use</span>(<span class="title function_">async</span> (ctx, next) =&gt; &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;1. 进入 A&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">next</span>()                    <span class="comment">// 等下游执行完</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;4. 离开 A&#x27;</span>)        <span class="comment">// 下游返回后再执行</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">use</span>(<span class="title function_">async</span> (ctx, next) =&gt; &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;2. 进入 B&#x27;</span>)</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">next</span>()</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;3. 离开 B&#x27;</span>)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 执行顺序：1 → 2 → 3 → 4</span></span><br></pre></td></tr></table></figure><p><strong>为什么这样设计</strong>：<code>await next()</code> 之后的代码在”响应回来之后”执行，非常适合：</p><ul><li>计算响应耗时</li><li>统一包装响应格式</li><li>记录响应状态</li></ul><p>经典案例——响应耗时统计：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">app.<span class="title function_">use</span>(<span class="title function_">async</span> (ctx, next) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> start = <span class="title class_">Date</span>.<span class="title function_">now</span>()</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">next</span>()</span><br><span class="line">  <span class="keyword">const</span> ms = <span class="title class_">Date</span>.<span class="title function_">now</span>() - start</span><br><span class="line">  ctx.<span class="title function_">set</span>(<span class="string">&#x27;X-Response-Time&#x27;</span>, <span class="string">`<span class="subst">$&#123;ms&#125;</span>ms`</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="四、对比与选型"><a href="#四、对比与选型" class="headerlink" title="四、对比与选型"></a>四、对比与选型</h2><table><thead><tr><th>对比</th><th>Express</th><th>Koa</th></tr></thead><tbody><tr><td>中间件模型</td><td>线性（next 只前进）</td><td>洋葱（可出可入）</td></tr><tr><td>异步支持</td><td>回调为主（新版支持 Promise）</td><td>原生 async&#x2F;await</td></tr><tr><td>内置能力</td><td>路由、静态资源齐全</td><td>极简，路由要装 koa-router</td></tr><tr><td>生态</td><td>最丰富</td><td>较丰富</td></tr><tr><td>适用</td><td>快速上手、老项目</td><td>需要精细控制、追求现代写法</td></tr></tbody></table><p>日常选型：<strong>快速开发用 Express；对请求生命周期控制要求高、想要干净异步代码用 Koa</strong>。</p><h2 id="五、手写一个迷你中间件框架"><a href="#五、手写一个迷你中间件框架" class="headerlink" title="五、手写一个迷你中间件框架"></a>五、手写一个迷你中间件框架</h2><p>理解了原理，自己实现一个也很简单：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// mini-framework.js</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MiniApp</span> &#123;</span><br><span class="line">  <span class="title function_">constructor</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="variable language_">this</span>.<span class="property">middlewares</span> = []</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">use</span>(<span class="params">fn</span>) &#123;</span><br><span class="line">    <span class="variable language_">this</span>.<span class="property">middlewares</span>.<span class="title function_">push</span>(fn)</span><br><span class="line">    <span class="keyword">return</span> <span class="variable language_">this</span></span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 模拟处理一个请求</span></span><br><span class="line">  <span class="title function_">handle</span>(<span class="params">req, res</span>) &#123;</span><br><span class="line">    <span class="keyword">let</span> index = <span class="number">0</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// next 递归推进中间件</span></span><br><span class="line">    <span class="keyword">const</span> <span class="title function_">next</span> = (<span class="params">err</span>) =&gt; &#123;</span><br><span class="line">      <span class="keyword">if</span> (err) &#123;</span><br><span class="line">        <span class="comment">// 跳到错误处理</span></span><br><span class="line">        <span class="keyword">const</span> errorHandler = <span class="variable language_">this</span>.<span class="property">middlewares</span>.<span class="title function_">find</span>(<span class="function">(<span class="params">fn</span>) =&gt;</span> fn.<span class="property">length</span> === <span class="number">4</span>)</span><br><span class="line">        <span class="keyword">return</span> errorHandler ? <span class="title function_">errorHandler</span>(err, req, res, <span class="function">() =&gt;</span> &#123;&#125;) : res.<span class="title function_">end</span>(<span class="string">&#x27;500&#x27;</span>)</span><br><span class="line">      &#125;</span><br><span class="line"></span><br><span class="line">      <span class="keyword">const</span> fn = <span class="variable language_">this</span>.<span class="property">middlewares</span>[index++]</span><br><span class="line">      <span class="keyword">if</span> (!fn) <span class="keyword">return</span></span><br><span class="line"></span><br><span class="line">      <span class="comment">// 4 个参数的是错误处理中间件，跳过</span></span><br><span class="line">      <span class="keyword">if</span> (fn.<span class="property">length</span> === <span class="number">4</span>) <span class="keyword">return</span> <span class="title function_">next</span>()</span><br><span class="line"></span><br><span class="line">      <span class="title function_">fn</span>(req, res, next)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="title function_">next</span>()</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 使用</span></span><br><span class="line"><span class="keyword">const</span> app = <span class="keyword">new</span> <span class="title class_">MiniApp</span>()</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;日志：&#x27;</span>, req.<span class="property">url</span>)</span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (req.<span class="property">url</span> === <span class="string">&#x27;/private&#x27;</span> &amp;&amp; !req.<span class="property">token</span>) &#123;</span><br><span class="line">    res.<span class="property">statusCode</span> = <span class="number">401</span></span><br><span class="line">    <span class="keyword">return</span> res.<span class="title function_">end</span>(<span class="string">&#x27;Unauthorized&#x27;</span>)   <span class="comment">// 不调 next，直接结束</span></span><br><span class="line">  &#125;</span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">use</span>(<span class="function">(<span class="params">req, res</span>) =&gt;</span> &#123;</span><br><span class="line">  res.<span class="title function_">end</span>(<span class="string">&#x27;Hello &#x27;</span> + req.<span class="property">url</span>)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">handle</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/private&#x27;</span>, <span class="attr">token</span>: <span class="string">&#x27;&#x27;</span> &#125;, &#123; <span class="attr">statusCode</span>: <span class="number">200</span>, <span class="attr">end</span>: <span class="function">(<span class="params">s</span>) =&gt;</span> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;响应：&#x27;</span>, s) &#125;)</span><br></pre></td></tr></table></figure><p><code>fn.length</code> 是函数参数个数，Express 就靠这个区分错误处理中间件（4 个参数）和普通中间件——这也是为什么<strong>错误处理中间件必须写满 4 个参数</strong>。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>忘记 <code>next()</code></strong>：请求会卡住直到超时，中间件必须在合适时机放行或终结；</li><li><strong>异步错误没交给框架</strong>：Express 4 里 async 中间件抛错要自己 <code>next(err)</code>，或装 <code>express-async-errors</code>；</li><li><strong>中间件顺序错误</strong>：<code>express.json()</code>（解析 body）必须在用 <code>req.body</code> 的路由之前注册；</li><li><strong>错误中间件放在最前面</strong>：它要放在所有可能抛错的路由<strong>之后</strong>，才能兜住错误。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>中间件的本质是”<strong>请求的管道 + 责任的传递</strong>“：每个中间件做一件事，<code>next()</code> 决定放行，错误统一收口。理解了 Express 的线性模型和 Koa 的洋葱模型，看任何 Node 框架的源码都能快速抓住主线。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260728node/</id>
    <link href="https://blog.zhangsifan.com/posts/20260728node/"/>
    <published>2026-07-27T16:00:00.000Z</published>
    <summary>Node.js 中间件原理教程：Express 线性中间件与 Koa 洋葱模型的实现原理、next 的执行时机，以及如何手写一个简单中间件框架。</summary>
    <title>Node.js 中间件原理：从 Express 到 Koa</title>
    <updated>2026-07-27T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="构建工具" scheme="https://blog.zhangsifan.com/tags/%E6%9E%84%E5%BB%BA%E5%B7%A5%E5%85%B7/"/>
    <category term="Webpack" scheme="https://blog.zhangsifan.com/tags/Webpack/"/>
    <content>
      <![CDATA[<h2 id="一、Webpack-在做什么"><a href="#一、Webpack-在做什么" class="headerlink" title="一、Webpack 在做什么"></a>一、Webpack 在做什么</h2><p>Webpack 是一个模块打包器：<strong>把项目里所有资源（JS、CSS、图片、字体）当作模块，通过 loader 处理、plugin 增强，最终打包成浏览器能直接加载的静态文件</strong>。</p><h2 id="二、四大核心概念"><a href="#二、四大核心概念" class="headerlink" title="二、四大核心概念"></a>二、四大核心概念</h2><h3 id="entry：入口"><a href="#entry：入口" class="headerlink" title="entry：入口"></a>entry：入口</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// webpack.config.js</span></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">entry</span>: <span class="string">&#x27;./src/main.js&#x27;</span>,</span><br><span class="line">  <span class="comment">// 多页面应用：多入口</span></span><br><span class="line">  <span class="attr">entry</span>: &#123;</span><br><span class="line">    <span class="attr">home</span>: <span class="string">&#x27;./src/home.js&#x27;</span>,</span><br><span class="line">    <span class="attr">admin</span>: <span class="string">&#x27;./src/admin.js&#x27;</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="output：出口"><a href="#output：出口" class="headerlink" title="output：出口"></a>output：出口</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">output</span>: &#123;</span><br><span class="line">    <span class="attr">path</span>: path.<span class="title function_">resolve</span>(__dirname, <span class="string">&#x27;dist&#x27;</span>),</span><br><span class="line">    <span class="comment">// [name] 对应 entry 的 key，[contenthash] 内容变了文件名就变（缓存利器）</span></span><br><span class="line">    <span class="attr">filename</span>: <span class="string">&#x27;js/[name].[contenthash:8].js&#x27;</span>,</span><br><span class="line">    <span class="attr">clean</span>: <span class="literal">true</span>   <span class="comment">// 每次构建先清空 dist</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="loader：处理非-JS-资源"><a href="#loader：处理非-JS-资源" class="headerlink" title="loader：处理非 JS 资源"></a>loader：处理非 JS 资源</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">module</span>: &#123;</span><br><span class="line">    <span class="attr">rules</span>: [</span><br><span class="line">      <span class="comment">// 处理 JS/JSX（含 TS）</span></span><br><span class="line">      &#123;</span><br><span class="line">        <span class="attr">test</span>: <span class="regexp">/\.(js|jsx)$/</span>,</span><br><span class="line">        <span class="attr">exclude</span>: <span class="regexp">/node_modules/</span>,</span><br><span class="line">        <span class="attr">use</span>: <span class="string">&#x27;babel-loader&#x27;</span></span><br><span class="line">      &#125;,</span><br><span class="line">      <span class="comment">// 处理 CSS + 兼容前缀</span></span><br><span class="line">      &#123;</span><br><span class="line">        <span class="attr">test</span>: <span class="regexp">/\.css$/</span>,</span><br><span class="line">        <span class="attr">use</span>: [<span class="string">&#x27;style-loader&#x27;</span>, <span class="string">&#x27;css-loader&#x27;</span>, <span class="string">&#x27;postcss-loader&#x27;</span>]</span><br><span class="line">      &#125;,</span><br><span class="line">      <span class="comment">// 处理图片（小于 limit 转 base64 内联）</span></span><br><span class="line">      &#123;</span><br><span class="line">        <span class="attr">test</span>: <span class="regexp">/\.(png|jpg|jpeg|svg)$/</span>,</span><br><span class="line">        <span class="attr">type</span>: <span class="string">&#x27;asset&#x27;</span>,</span><br><span class="line">        <span class="attr">parser</span>: &#123; <span class="attr">dataUrlCondition</span>: &#123; <span class="attr">maxSize</span>: <span class="number">8</span> * <span class="number">1024</span> &#125; &#125;</span><br><span class="line">      &#125;</span><br><span class="line">    ]</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注意 <code>use</code> 数组是<strong>从右往左执行</strong>的（css-loader 先解析，style-loader 再注入）。</p><h3 id="plugin：做-loader-做不到的事"><a href="#plugin：做-loader-做不到的事" class="headerlink" title="plugin：做 loader 做不到的事"></a>plugin：做 loader 做不到的事</h3><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title class_">HtmlWebpackPlugin</span> = <span class="built_in">require</span>(<span class="string">&#x27;html-webpack-plugin&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> <span class="title class_">MiniCssExtractPlugin</span> = <span class="built_in">require</span>(<span class="string">&#x27;mini-css-extract-plugin&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">plugins</span>: [</span><br><span class="line">    <span class="comment">// 自动生成 index.html 并注入打包产物</span></span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">HtmlWebpackPlugin</span>(&#123;</span><br><span class="line">      <span class="attr">template</span>: <span class="string">&#x27;./public/index.html&#x27;</span></span><br><span class="line">    &#125;),</span><br><span class="line">    <span class="comment">// 把 CSS 抽成独立文件（生产环境）</span></span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">MiniCssExtractPlugin</span>(&#123;</span><br><span class="line">      <span class="attr">filename</span>: <span class="string">&#x27;css/[name].[contenthash:8].css&#x27;</span></span><br><span class="line">    &#125;)</span><br><span class="line">  ]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="三、开发模式与生产模式"><a href="#三、开发模式与生产模式" class="headerlink" title="三、开发模式与生产模式"></a>三、开发模式与生产模式</h2><p>用环境变量区分配置：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// webpack.config.js</span></span><br><span class="line"><span class="keyword">const</span> isProd = process.<span class="property">env</span>.<span class="property">NODE_ENV</span> === <span class="string">&#x27;production&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">mode</span>: isProd ? <span class="string">&#x27;production&#x27;</span> : <span class="string">&#x27;development&#x27;</span>,</span><br><span class="line">  <span class="attr">devtool</span>: isProd ? <span class="string">&#x27;source-map&#x27;</span> : <span class="string">&#x27;eval-cheap-module-source-map&#x27;</span>,</span><br><span class="line"></span><br><span class="line">  <span class="attr">devServer</span>: &#123;</span><br><span class="line">    <span class="attr">port</span>: <span class="number">8080</span>,</span><br><span class="line">    <span class="attr">hot</span>: <span class="literal">true</span>,</span><br><span class="line">    <span class="attr">proxy</span>: &#123;</span><br><span class="line">      <span class="string">&#x27;/api&#x27;</span>: &#123;</span><br><span class="line">        <span class="attr">target</span>: <span class="string">&#x27;http://localhost:3000&#x27;</span>,</span><br><span class="line">        <span class="attr">changeOrigin</span>: <span class="literal">true</span></span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="attr">optimization</span>: &#123;</span><br><span class="line">    <span class="attr">minimize</span>: isProd   <span class="comment">// 生产压缩</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// package.json</span></span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;dev&quot;</span><span class="punctuation">:</span> <span class="string">&quot;webpack serve --mode development&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="string">&quot;NODE_ENV=production webpack --mode production&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h2 id="四、代码分割：cacheGroups"><a href="#四、代码分割：cacheGroups" class="headerlink" title="四、代码分割：cacheGroups"></a>四、代码分割：cacheGroups</h2><p>公共依赖（react、lodash 等）单独拆 chunk，利用浏览器缓存：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">optimization</span>: &#123;</span><br><span class="line">    <span class="attr">splitChunks</span>: &#123;</span><br><span class="line">      <span class="attr">cacheGroups</span>: &#123;</span><br><span class="line">        <span class="comment">// 三方库独立 chunk</span></span><br><span class="line">        <span class="attr">vendors</span>: &#123;</span><br><span class="line">          <span class="attr">test</span>: <span class="regexp">/[\\/]node_modules[\\/]/</span>,</span><br><span class="line">          <span class="attr">name</span>: <span class="string">&#x27;vendors&#x27;</span>,</span><br><span class="line">          <span class="attr">chunks</span>: <span class="string">&#x27;all&#x27;</span></span><br><span class="line">        &#125;,</span><br><span class="line">        <span class="comment">// 被多个页面引用的公共代码</span></span><br><span class="line">        <span class="attr">common</span>: &#123;</span><br><span class="line">          <span class="attr">name</span>: <span class="string">&#x27;common&#x27;</span>,</span><br><span class="line">          <span class="attr">minChunks</span>: <span class="number">2</span>,</span><br><span class="line">          <span class="attr">chunks</span>: <span class="string">&#x27;all&#x27;</span></span><br><span class="line">        &#125;</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>配合<strong>动态 import 的懒加载</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 路由懒加载，页面代码单独 chunk</span></span><br><span class="line"><span class="keyword">const</span> <span class="title function_">Admin</span> = (<span class="params"></span>) =&gt; <span class="keyword">import</span>(<span class="string">&#x27;./pages/Admin&#x27;</span>)</span><br></pre></td></tr></table></figure><h2 id="五、持久化缓存：构建提速关键"><a href="#五、持久化缓存：构建提速关键" class="headerlink" title="五、持久化缓存：构建提速关键"></a>五、持久化缓存：构建提速关键</h2><p>Webpack 5 内置持久化缓存，二次构建时间大幅下降：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">cache</span>: &#123;</span><br><span class="line">    <span class="attr">type</span>: <span class="string">&#x27;filesystem&#x27;</span>,      <span class="comment">// 默认开启，缓存写到 node_modules/.cache</span></span><br><span class="line">    <span class="attr">buildDependencies</span>: &#123;</span><br><span class="line">      <span class="attr">config</span>: [__filename]   <span class="comment">// 配置变更时自动失效缓存</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>配合 <code>moduleIds: &#39;deterministic&#39;</code>（Webpack 5 默认），模块 id 稳定，改代码不会让所有 chunk 的 hash 全变。</p><h2 id="六、性能分析"><a href="#六、性能分析" class="headerlink" title="六、性能分析"></a>六、性能分析</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install -D webpack-bundle-analyzer</span><br></pre></td></tr></table></figure><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title class_">BundleAnalyzerPlugin</span> = <span class="built_in">require</span>(<span class="string">&#x27;webpack-bundle-analyzer&#x27;</span>).<span class="property">BundleAnalyzerPlugin</span></span><br><span class="line"></span><br><span class="line"><span class="variable language_">module</span>.<span class="property">exports</span> = &#123;</span><br><span class="line">  <span class="attr">plugins</span>: [</span><br><span class="line">    <span class="comment">// 构建后自动打开体积分析图</span></span><br><span class="line">    ...(process.<span class="property">env</span>.<span class="property">ANALYZE</span> ? [<span class="keyword">new</span> <span class="title class_">BundleAnalyzerPlugin</span>()] : [])</span><br><span class="line">  ]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ANALYZE=<span class="literal">true</span> npm run build</span><br></pre></td></tr></table></figure><p>看分析图定位体积大户：<strong>换用体积更小的库（dayjs 替代 moment）→ 按需引入 → 拆 chunk → 懒加载</strong>。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>图片路径 404</strong>：<code>publicPath</code> 没配或配错，<code>output.publicPath: &#39;/&#39;</code> 是常见修复；</li><li><strong>HMR 不生效</strong>：确认 <code>devServer.hot: true</code> 且入口引用了热更新入口；</li><li><strong>构建内存溢出</strong>：大项目 <code>--max-old-space-size</code> 调 Node 内存，或开启持久化缓存；</li><li><strong>contenthash 全变</strong>：检查是否用了 <code>moduleIds: &#39;named&#39;</code>（开发默认），生产要用 deterministic。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Webpack 5 的掌握路径：<strong>四大概念（entry&#x2F;output&#x2F;loader&#x2F;plugin）→ 开发&#x2F;生产双模式 → splitChunks 分包 + 懒加载 → 持久化缓存提速 → 分析插件瘦身</strong>。虽然新项目多用 Vite，但维护老项目、做深度构建定制时，Webpack 的知识仍然值钱。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260712webpack/</id>
    <link href="https://blog.zhangsifan.com/posts/20260712webpack/"/>
    <published>2026-07-11T16:00:00.000Z</published>
    <summary>Webpack 5 核心概念实战教程：entry/output/loader/plugin 四大概念、开发服务器、代码分割、持久化缓存与性能优化配置。</summary>
    <title>Webpack 5 核心概念与配置实战</title>
    <updated>2026-07-11T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="Tauri" scheme="https://blog.zhangsifan.com/categories/Tauri/"/>
    <category term="Tauri" scheme="https://blog.zhangsifan.com/tags/Tauri/"/>
    <category term="桌面应用" scheme="https://blog.zhangsifan.com/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
    <category term="Rust" scheme="https://blog.zhangsifan.com/tags/Rust/"/>
    <category term="系统托盘" scheme="https://blog.zhangsifan.com/tags/%E7%B3%BB%E7%BB%9F%E6%89%98%E7%9B%98/"/>
    <content>
      <![CDATA[<h2 id="一、Tauri-2-的插件体系"><a href="#一、Tauri-2-的插件体系" class="headerlink" title="一、Tauri 2 的插件体系"></a>一、Tauri 2 的插件体系</h2><p>Tauri 2 把很多系统能力（对话框、剪贴板、通知、托盘、窗口管理）做成了官方插件。插件的使用分三步：<strong>npm 装前端包 → Cargo 装 Rust 依赖 → capabilities 配权限</strong>。</p><h2 id="二、文件对话框插件"><a href="#二、文件对话框插件" class="headerlink" title="二、文件对话框插件"></a>二、文件对话框插件</h2><p><strong>1. 安装</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install @tauri-apps/plugin-dialog</span><br></pre></td></tr></table></figure><p>Rust 端（<code>src-tauri/Cargo.toml</code>）：</p><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">[dependencies]</span></span><br><span class="line"><span class="attr">tauri-plugin-dialog</span> = <span class="string">&quot;2&quot;</span></span><br></pre></td></tr></table></figure><p>注册插件（<code>lib.rs</code>）：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">tauri::Builder::<span class="title function_ invoke__">default</span>()</span><br><span class="line">    .<span class="title function_ invoke__">plugin</span>(tauri_plugin_dialog::<span class="title function_ invoke__">init</span>())</span><br><span class="line">    .<span class="title function_ invoke__">invoke_handler</span>(tauri::generate_handler![...])</span><br><span class="line">    .<span class="title function_ invoke__">run</span>(tauri::generate_context!())</span><br></pre></td></tr></table></figure><p><strong>2. 配置权限</strong>（<code>src-tauri/capabilities/default.json</code>）：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;identifier&quot;</span><span class="punctuation">:</span> <span class="string">&quot;default&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;windows&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;main&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;permissions&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="string">&quot;core:default&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="string">&quot;dialog:allow-open&quot;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>3. 前端调用</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; open, save &#125; <span class="keyword">from</span> <span class="string">&#x27;@tauri-apps/plugin-dialog&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 选择文件</span></span><br><span class="line"><span class="keyword">const</span> file = <span class="keyword">await</span> <span class="title function_">open</span>(&#123;</span><br><span class="line">  <span class="attr">multiple</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">directory</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">filters</span>: [&#123; <span class="attr">name</span>: <span class="string">&#x27;图片&#x27;</span>, <span class="attr">extensions</span>: [<span class="string">&#x27;png&#x27;</span>, <span class="string">&#x27;jpg&#x27;</span>] &#125;]</span><br><span class="line">&#125;)</span><br><span class="line"><span class="comment">// file 为 null 表示用户取消了</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 选择目录</span></span><br><span class="line"><span class="keyword">const</span> dir = <span class="keyword">await</span> <span class="title function_">open</span>(&#123; <span class="attr">directory</span>: <span class="literal">true</span> &#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 保存对话框</span></span><br><span class="line"><span class="keyword">const</span> path = <span class="keyword">await</span> <span class="title function_">save</span>(&#123; <span class="attr">defaultPath</span>: <span class="string">&#x27;untitled.txt&#x27;</span> &#125;)</span><br></pre></td></tr></table></figure><h2 id="三、系统托盘插件"><a href="#三、系统托盘插件" class="headerlink" title="三、系统托盘插件"></a>三、系统托盘插件</h2><p>托盘是桌面应用”最小化到托盘 + 右键菜单”的标配。</p><p><strong>1. 安装</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install @tauri-apps/plugin-tray-icon</span><br></pre></td></tr></table></figure><p><code>Cargo.toml</code>：</p><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">[dependencies]</span></span><br><span class="line"><span class="attr">tauri-plugin-tray-icon</span> = <span class="string">&quot;2&quot;</span></span><br></pre></td></tr></table></figure><p><strong>2. 创建托盘（lib.rs）</strong>：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">use</span> tauri::&#123;</span><br><span class="line">    menu::&#123;Menu, MenuItem&#125;,</span><br><span class="line">    tray::&#123;TrayIconBuilder, TrayIconEvent&#125;,</span><br><span class="line">    Manager,</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="keyword">pub</span> <span class="keyword">fn</span> <span class="title function_">run</span>() &#123;</span><br><span class="line">    tauri::Builder::<span class="title function_ invoke__">default</span>()</span><br><span class="line">        .<span class="title function_ invoke__">setup</span>(|app| &#123;</span><br><span class="line">            <span class="comment">// 托盘菜单</span></span><br><span class="line">            <span class="keyword">let</span> <span class="variable">show</span> = MenuItem::<span class="title function_ invoke__">with_id</span>(app, <span class="string">&quot;show&quot;</span>, <span class="string">&quot;显示主窗口&quot;</span>, <span class="literal">true</span>, None::&lt;&amp;<span class="type">str</span>&gt;)?;</span><br><span class="line">            <span class="keyword">let</span> <span class="variable">quit</span> = MenuItem::<span class="title function_ invoke__">with_id</span>(app, <span class="string">&quot;quit&quot;</span>, <span class="string">&quot;退出&quot;</span>, <span class="literal">true</span>, None::&lt;&amp;<span class="type">str</span>&gt;)?;</span><br><span class="line">            <span class="keyword">let</span> <span class="variable">menu</span> = Menu::<span class="title function_ invoke__">with_items</span>(app, &amp;[&amp;show, &amp;quit])?;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 创建托盘图标（图标放 src-tauri/icons/）</span></span><br><span class="line">            <span class="keyword">let</span> <span class="variable">_tray</span> = TrayIconBuilder::<span class="title function_ invoke__">new</span>()</span><br><span class="line">                .<span class="title function_ invoke__">icon</span>(app.<span class="title function_ invoke__">default_window_icon</span>().<span class="title function_ invoke__">unwrap</span>().<span class="title function_ invoke__">clone</span>())</span><br><span class="line">                .<span class="title function_ invoke__">menu</span>(&amp;menu)</span><br><span class="line">                .<span class="title function_ invoke__">show_menu_on_left_click</span>(<span class="literal">false</span>)</span><br><span class="line">                .<span class="title function_ invoke__">on_menu_event</span>(|app, event| <span class="keyword">match</span> event.id.<span class="title function_ invoke__">as_ref</span>() &#123;</span><br><span class="line">                    <span class="string">&quot;show&quot;</span> =&gt; &#123;</span><br><span class="line">                        <span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">Some</span>(win) = app.<span class="title function_ invoke__">get_webview_window</span>(<span class="string">&quot;main&quot;</span>) &#123;</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">_</span> = win.<span class="title function_ invoke__">show</span>();</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">_</span> = win.<span class="title function_ invoke__">set_focus</span>();</span><br><span class="line">                        &#125;</span><br><span class="line">                    &#125;</span><br><span class="line">                    <span class="string">&quot;quit&quot;</span> =&gt; app.<span class="title function_ invoke__">exit</span>(<span class="number">0</span>),</span><br><span class="line">                    _ =&gt; &#123;&#125;</span><br><span class="line">                &#125;)</span><br><span class="line">                .<span class="title function_ invoke__">on_tray_icon_event</span>(|tray, event| &#123;</span><br><span class="line">                    <span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">TrayIconEvent</span>::DoubleClick &#123; .. &#125; = event &#123;</span><br><span class="line">                        <span class="comment">// 双击托盘也显示窗口</span></span><br><span class="line">                        <span class="keyword">let</span> <span class="variable">app</span> = tray.<span class="title function_ invoke__">app_handle</span>();</span><br><span class="line">                        <span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">Some</span>(win) = app.<span class="title function_ invoke__">get_webview_window</span>(<span class="string">&quot;main&quot;</span>) &#123;</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">_</span> = win.<span class="title function_ invoke__">show</span>();</span><br><span class="line">                            <span class="keyword">let</span> <span class="variable">_</span> = win.<span class="title function_ invoke__">set_focus</span>();</span><br><span class="line">                        &#125;</span><br><span class="line">                    &#125;</span><br><span class="line">                &#125;)</span><br><span class="line">                .<span class="title function_ invoke__">build</span>(app)?;</span><br><span class="line"></span><br><span class="line">            <span class="title function_ invoke__">Ok</span>(())</span><br><span class="line">        &#125;)</span><br><span class="line">        .<span class="title function_ invoke__">run</span>(tauri::generate_context!())</span><br><span class="line">        .<span class="title function_ invoke__">expect</span>(<span class="string">&quot;error while running tauri application&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>3. 前端配合”关闭时隐藏到托盘”</strong>：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; getCurrentWindow &#125; <span class="keyword">from</span> <span class="string">&#x27;@tauri-apps/api/window&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> appWindow = <span class="title function_">getCurrentWindow</span>()</span><br><span class="line"></span><br><span class="line"><span class="comment">// 监听前端事件：关闭按钮时隐藏而不是退出</span></span><br><span class="line"><span class="comment">// 在窗口的 before-close 事件里拦截</span></span><br><span class="line">appWindow.<span class="title function_">onCloseRequested</span>(<span class="title function_">async</span> (event) =&gt; &#123;</span><br><span class="line">  <span class="comment">// 这里可以判断是否允许真正关闭（如弹确认）</span></span><br><span class="line">  <span class="comment">// 需要阻止默认关闭时：event.preventDefault()</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>配合 Rust 端在 <code>tauri.conf.json</code> 里配置窗口关闭行为，可以实现”点关闭最小化到托盘”。</p><h2 id="四、通知插件"><a href="#四、通知插件" class="headerlink" title="四、通知插件"></a>四、通知插件</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install @tauri-apps/plugin-notification</span><br></pre></td></tr></table></figure><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">[dependencies]</span></span><br><span class="line"><span class="attr">tauri-plugin-notification</span> = <span class="string">&quot;2&quot;</span></span><br></pre></td></tr></table></figure><p>权限 + 使用：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;permissions&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;core:default&quot;</span><span class="punctuation">,</span> <span class="string">&quot;notification:default&quot;</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; isPermissionGranted, requestPermission, sendNotification &#125; <span class="keyword">from</span> <span class="string">&#x27;@tauri-apps/plugin-notification&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">let</span> granted = <span class="keyword">await</span> <span class="title function_">isPermissionGranted</span>()</span><br><span class="line"><span class="keyword">if</span> (!granted) &#123;</span><br><span class="line">  granted = <span class="keyword">await</span> <span class="title function_">requestPermission</span>()</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (granted) &#123;</span><br><span class="line">  <span class="title function_">sendNotification</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;Tauri 通知&#x27;</span>, <span class="attr">body</span>: <span class="string">&#x27;这是一条系统通知&#x27;</span> &#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="五、权限配置的排查思路"><a href="#五、权限配置的排查思路" class="headerlink" title="五、权限配置的排查思路"></a>五、权限配置的排查思路</h2><p>Tauri 2 前端调用报 <code>not allowed</code>，几乎都是权限问题。排查顺序：</p><ol><li>插件有没有在 <code>lib.rs</code> 注册（<code>.plugin(...)</code>）；</li><li><code>capabilities/default.json</code> 的 <code>permissions</code> 有没有对应的 <code>xxx:default</code> 或 <code>xxx:allow-xxx</code>；</li><li>权限标识写没写错（插件文档里都有 <code>Permissions</code> 一节）；</li><li>改完配置<strong>要重启 <code>npm run tauri dev</code></strong>（capabilities 变更不会热加载）。</li></ol><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>托盘图标不显示</strong>：图标格式或路径问题，确认 <code>default_window_icon()</code> 有值，或手动指定 <code>icon</code>；</li><li><strong>关闭窗口进程还在但界面没了</strong>：需要 Rust 端配合 <code>win.close()</code> 拦截逻辑，只隐藏不销毁；</li><li><strong>macOS 通知权限</strong>：首次调用会弹系统授权，用户拒绝后要去系统设置里手动开；</li><li><strong><code>window.show()</code> 不生效</strong>：多窗口场景确认窗口 label 是 <code>main</code>。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Tauri 插件的套路高度一致：<strong>安装 → 注册 → 配权限 → 前端调用</strong>。先把 dialog（文件选择）、tray-icon（托盘）、notification（通知）这三个高频插件跑通，桌面应用的”系统级体验”就基本齐了。记得任何新能力的第一排查点永远是 capabilities 权限。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260625ta/</id>
    <link href="https://blog.zhangsifan.com/posts/20260625ta/"/>
    <published>2026-06-24T16:00:00.000Z</published>
    <summary>Tauri 插件开发实战教程：安装使用 dialog、tray-icon 等官方插件，配置 capabilities 权限，并实现系统托盘与窗口交互。</summary>
    <title>Tauri 插件开发实战：对话框与系统托盘</title>
    <updated>2026-06-24T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="Vite" scheme="https://blog.zhangsifan.com/tags/Vite/"/>
    <category term="构建工具" scheme="https://blog.zhangsifan.com/tags/%E6%9E%84%E5%BB%BA%E5%B7%A5%E5%85%B7/"/>
    <content>
      <![CDATA[<h2 id="一、为什么选-Vite"><a href="#一、为什么选-Vite" class="headerlink" title="一、为什么选 Vite"></a>一、为什么选 Vite</h2><p>Vite 用<strong>原生 ESM</strong> 做开发服务器：改代码毫秒级热更新（HMR），大项目也不卡。构建用 Rollup，产物小、现代。对比 Webpack：</p><table><thead><tr><th>对比</th><th>Vite</th><th>Webpack</th></tr></thead><tbody><tr><td>开发启动</td><td>秒级（按需编译）</td><td>分钟级（全量打包）</td></tr><tr><td>HMR</td><td>快</td><td>慢</td></tr><tr><td>配置</td><td>简单</td><td>复杂</td></tr><tr><td>生态</td><td>常用插件齐全</td><td>插件最全</td></tr></tbody></table><h2 id="二、创建项目"><a href="#二、创建项目" class="headerlink" title="二、创建项目"></a>二、创建项目</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Vue3 + TS</span></span><br><span class="line">npm create vite@latest my-app -- --template vue-ts</span><br><span class="line"></span><br><span class="line"><span class="comment"># React + TS</span></span><br><span class="line">npm create vite@latest my-app -- --template react-ts</span><br><span class="line"></span><br><span class="line"><span class="built_in">cd</span> my-app</span><br><span class="line">npm install</span><br><span class="line">npm run dev</span><br></pre></td></tr></table></figure><h2 id="三、核心配置：别名、代理、环境变量"><a href="#三、核心配置：别名、代理、环境变量" class="headerlink" title="三、核心配置：别名、代理、环境变量"></a>三、核心配置：别名、代理、环境变量</h2><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// vite.config.ts</span></span><br><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;vite&#x27;</span></span><br><span class="line"><span class="keyword">import</span> vue <span class="keyword">from</span> <span class="string">&#x27;@vitejs/plugin-vue&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; fileURLToPath, <span class="variable constant_">URL</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;node:url&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">plugins</span>: [<span class="title function_">vue</span>()],</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 路径别名：@/ 指向 src/</span></span><br><span class="line">  <span class="attr">resolve</span>: &#123;</span><br><span class="line">    <span class="attr">alias</span>: &#123;</span><br><span class="line">      <span class="string">&#x27;@&#x27;</span>: <span class="title function_">fileURLToPath</span>(<span class="keyword">new</span> <span class="title function_">URL</span>(<span class="string">&#x27;./src&#x27;</span>, <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">url</span>))</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 开发代理：/api 转发到后端，绕过跨域</span></span><br><span class="line">  <span class="attr">server</span>: &#123;</span><br><span class="line">    <span class="attr">port</span>: <span class="number">5173</span>,</span><br><span class="line">    <span class="attr">proxy</span>: &#123;</span><br><span class="line">      <span class="string">&#x27;/api&#x27;</span>: &#123;</span><br><span class="line">        <span class="attr">target</span>: <span class="string">&#x27;http://localhost:3000&#x27;</span>,</span><br><span class="line">        <span class="attr">changeOrigin</span>: <span class="literal">true</span>,</span><br><span class="line">        <span class="comment">// rewrite: (path) =&gt; path.replace(/^\/api/, &#x27;&#x27;)</span></span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 生产构建</span></span><br><span class="line">  <span class="attr">build</span>: &#123;</span><br><span class="line">    <span class="attr">outDir</span>: <span class="string">&#x27;dist&#x27;</span>,</span><br><span class="line">    <span class="attr">sourcemap</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">chunkSizeWarningLimit</span>: <span class="number">1500</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>tsconfig 也要配别名</strong>，否则 TS 报找不到 <code>@/</code>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;compilerOptions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;baseUrl&quot;</span><span class="punctuation">:</span> <span class="string">&quot;.&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;paths&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;@/*&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;src/*&quot;</span><span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h2 id="四、环境变量"><a href="#四、环境变量" class="headerlink" title="四、环境变量"></a>四、环境变量</h2><p>项目根目录放 <code>.env</code> &#x2F; <code>.env.development</code> &#x2F; <code>.env.production</code>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"># .env.development</span><br><span class="line">VITE_API_BASE=/api</span><br><span class="line">VITE_APP_TITLE=开发环境</span><br><span class="line"></span><br><span class="line"># .env.production</span><br><span class="line">VITE_API_BASE=https://api.example.com</span><br><span class="line">VITE_APP_TITLE=生产环境</span><br></pre></td></tr></table></figure><p>代码里用 <code>import.meta.env</code> 读取（<strong>必须以 <code>VITE_</code> 开头</strong>才会暴露）：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> base = <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">VITE_API_BASE</span></span><br><span class="line"><span class="keyword">const</span> isProd = <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">PROD</span>   <span class="comment">// 内置：是否生产构建</span></span><br><span class="line"><span class="keyword">const</span> mode = <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">MODE</span>     <span class="comment">// development / production</span></span><br></pre></td></tr></table></figure><h2 id="五、依赖预构建与分包"><a href="#五、依赖预构建与分包" class="headerlink" title="五、依赖预构建与分包"></a>五、依赖预构建与分包</h2><p>Vite 会把第三方依赖预构建缓存到 <code>node_modules/.vite</code>，改依赖版本后残留缓存可能导致奇怪问题，清一下：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">rm</span> -rf node_modules/.vite</span><br><span class="line">npm run dev</span><br></pre></td></tr></table></figure><p><strong>生产构建分包优化</strong>：把体积大的第三方库单独拆 chunk，利用浏览器缓存：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// vite.config.ts</span></span><br><span class="line"><span class="attr">build</span>: &#123;</span><br><span class="line">  <span class="attr">rollupOptions</span>: &#123;</span><br><span class="line">    <span class="attr">output</span>: &#123;</span><br><span class="line">      <span class="comment">// 手动分包：三方库独立 chunk</span></span><br><span class="line">      <span class="attr">manualChunks</span>: &#123;</span><br><span class="line">        <span class="attr">vue</span>: [<span class="string">&#x27;vue&#x27;</span>, <span class="string">&#x27;vue-router&#x27;</span>, <span class="string">&#x27;pinia&#x27;</span>],</span><br><span class="line">        <span class="attr">echarts</span>: [<span class="string">&#x27;echarts&#x27;</span>]</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>更稳妥的方式是用插件自动分析，如 <code>vite-plugin-chunk-split</code>，或直接依赖路由级懒加载产生的天然分包。</p><h2 id="六、常用插件"><a href="#六、常用插件" class="headerlink" title="六、常用插件"></a>六、常用插件</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">npm install -D @vitejs/plugin-legacy   <span class="comment"># 兼容老浏览器</span></span><br><span class="line">npm install -D vite-plugin-compression <span class="comment"># 生产 gzip 预压缩</span></span><br><span class="line">npm install -D unplugin-auto-import    <span class="comment"># API 自动导入（ref 不用手动 import）</span></span><br></pre></td></tr></table></figure><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> legacy <span class="keyword">from</span> <span class="string">&#x27;@vitejs/plugin-legacy&#x27;</span></span><br><span class="line"><span class="keyword">import</span> viteCompression <span class="keyword">from</span> <span class="string">&#x27;vite-plugin-compression&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="title function_">defineConfig</span>(&#123;</span><br><span class="line">  <span class="attr">plugins</span>: [</span><br><span class="line">    <span class="title function_">vue</span>(),</span><br><span class="line">    <span class="comment">// 兼容不支持 ESM 的旧浏览器（会额外打一份系统包）</span></span><br><span class="line">    <span class="title function_">legacy</span>(&#123; <span class="attr">targets</span>: [<span class="string">&#x27;defaults&#x27;</span>, <span class="string">&#x27;ie &gt;= 11&#x27;</span>] &#125;),</span><br><span class="line">    <span class="comment">// 构建时预生成 .gz 文件，配合 Nginx gzip_static 直接生效</span></span><br><span class="line">    <span class="title function_">viteCompression</span>(&#123; <span class="attr">algorithm</span>: <span class="string">&#x27;gzip&#x27;</span>, <span class="attr">threshold</span>: <span class="number">10240</span> &#125;)</span><br><span class="line">  ]</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><code>unplugin-auto-import</code> 配置后，<code>ref</code>、<code>computed</code> 等 API 无需手动 import，编辑器装插件还能自动补全。</p><h2 id="七、部署：静态服务器"><a href="#七、部署：静态服务器" class="headerlink" title="七、部署：静态服务器"></a>七、部署：静态服务器</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm run build   <span class="comment"># 产物在 dist/</span></span><br></pre></td></tr></table></figure><p>Nginx 部署（SPA 刷新 404 处理）：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">server</span> &#123;</span><br><span class="line">    <span class="attribute">listen</span> <span class="number">80</span>;</span><br><span class="line">    <span class="attribute">root</span> /var/www/myapp/dist;</span><br><span class="line">    <span class="attribute">index</span> index.html;</span><br><span class="line"></span><br><span class="line">    <span class="section">location</span> / &#123;</span><br><span class="line">        <span class="attribute">try_files</span> <span class="variable">$uri</span> <span class="variable">$uri</span>/ /index.html;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 带 hash 的静态资源长缓存</span></span><br><span class="line">    <span class="section">location</span> /assets/ &#123;</span><br><span class="line">        <span class="attribute">expires</span> <span class="number">30d</span>;</span><br><span class="line">        <span class="attribute">add_header</span> Cache-Control <span class="string">&quot;public, immutable&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>别名配了但 TS 报错</strong>：tsconfig 的 paths 没同步，或没重启编辑器&#x2F;tsc；</li><li><strong>代理不生效</strong>：检查 <code>changeOrigin</code>、目标地址，浏览器缓存也要清；</li><li><strong>环境变量读不到</strong>：确认前缀是 <code>VITE_</code>，且修改 .env 后<strong>重启 dev server</strong>；</li><li><strong>构建体积警告</strong>：<code>chunkSizeWarningLimit</code> 只是压制警告，真正要处理的是把大依赖独立分包 + 按需引入。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Vite 的日常套路：<strong>别名 + 代理 + 环境变量三件套打底，懒加载 + manualChunks 做分包，插件补齐兼容与压缩</strong>。上手比 Webpack 平滑得多，遇到体积问题优先”按需引入 + 独立 chunk”。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260610vite/</id>
    <link href="https://blog.zhangsifan.com/posts/20260610vite/"/>
    <published>2026-06-09T16:00:00.000Z</published>
    <summary>Vite 实战教程：创建项目、路径别名、代理、环境变量、依赖预构建、构建分包与体积优化的完整实践。</summary>
    <title>Vite 实战：从项目配置到构建优化</title>
    <updated>2026-06-09T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="React" scheme="https://blog.zhangsifan.com/tags/React/"/>
    <category term="React Router" scheme="https://blog.zhangsifan.com/tags/React-Router/"/>
    <content>
      <![CDATA[<h2 id="一、安装与基本结构"><a href="#一、安装与基本结构" class="headerlink" title="一、安装与基本结构"></a>一、安装与基本结构</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install react-router-dom</span><br></pre></td></tr></table></figure><p>React Router 6 推荐的 API 是 <code>createBrowserRouter</code>（数据路由），路由配置集中管理：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// main.jsx</span></span><br><span class="line"><span class="keyword">import</span> &#123; createRoot &#125; <span class="keyword">from</span> <span class="string">&#x27;react-dom/client&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">RouterProvider</span>, createBrowserRouter &#125; <span class="keyword">from</span> <span class="string">&#x27;react-router-dom&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">Layout</span> <span class="keyword">from</span> <span class="string">&#x27;./Layout&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">Home</span> <span class="keyword">from</span> <span class="string">&#x27;./pages/Home&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">About</span> <span class="keyword">from</span> <span class="string">&#x27;./pages/About&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">NotFound</span> <span class="keyword">from</span> <span class="string">&#x27;./pages/NotFound&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/&#x27;</span>,</span><br><span class="line">    <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Layout</span> /&gt;</span></span>,</span><br><span class="line">    <span class="attr">children</span>: [</span><br><span class="line">      &#123; <span class="attr">index</span>: <span class="literal">true</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Home</span> /&gt;</span></span> &#125;,</span><br><span class="line">      &#123; <span class="attr">path</span>: <span class="string">&#x27;about&#x27;</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">About</span> /&gt;</span></span> &#125;,</span><br><span class="line">      &#123; <span class="attr">path</span>: <span class="string">&#x27;*&#x27;</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">NotFound</span> /&gt;</span></span> &#125;</span><br><span class="line">    ]</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br><span class="line"></span><br><span class="line"><span class="title function_">createRoot</span>(<span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;root&#x27;</span>)).<span class="title function_">render</span>(</span><br><span class="line">  <span class="language-xml"><span class="tag">&lt;<span class="name">RouterProvider</span> <span class="attr">router</span>=<span class="string">&#123;router&#125;</span> /&gt;</span></span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>关键点：</p><ul><li><strong>嵌套路由</strong>：<code>Layout</code> 作为父路由，用 <code>&lt;Outlet /&gt;</code> 渲染子路由；</li><li><strong><code>index: true</code></strong>：父路径的默认子路由（访问 <code>/</code> 时渲染 Home）；</li><li><strong><code>*</code></strong>：通配符兜底 404。</li></ul><h2 id="二、Layout-与-Outlet"><a href="#二、Layout-与-Outlet" class="headerlink" title="二、Layout 与 Outlet"></a>二、Layout 与 Outlet</h2><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Layout.jsx</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">Outlet</span>, <span class="title class_">NavLink</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;react-router-dom&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> <span class="keyword">function</span> <span class="title function_">Layout</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">nav</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        &#123;/* NavLink 自动处理激活态 */&#125;</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">NavLink</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">          <span class="attr">to</span>=<span class="string">&quot;/&quot;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">          <span class="attr">className</span>=<span class="string">&#123;(&#123;</span> <span class="attr">isActive</span> &#125;) =&gt;</span> (isActive ? &#x27;active&#x27; : &#x27;&#x27;)&#125;</span></span><br><span class="line"><span class="language-xml">          end   // end 让首页只在完全匹配时高亮</span></span><br><span class="line"><span class="language-xml">        &gt;</span></span><br><span class="line"><span class="language-xml">          首页</span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;/<span class="name">NavLink</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">        <span class="tag">&lt;<span class="name">NavLink</span> <span class="attr">to</span>=<span class="string">&quot;/about&quot;</span>&gt;</span>关于<span class="tag">&lt;/<span class="name">NavLink</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;/<span class="name">nav</span>&gt;</span></span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">      &#123;/* 子路由渲染的位置 */&#125;</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">Outlet</span> /&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="三、编程式导航与传参"><a href="#三、编程式导航与传参" class="headerlink" title="三、编程式导航与传参"></a>三、编程式导航与传参</h2><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; useNavigate, useParams, useSearchParams &#125; <span class="keyword">from</span> <span class="string">&#x27;react-router-dom&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">UserDetail</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> navigate = <span class="title function_">useNavigate</span>()</span><br><span class="line">  <span class="keyword">const</span> &#123; id &#125; = <span class="title function_">useParams</span>()               <span class="comment">// 路径参数：/user/123 → id=123</span></span><br><span class="line">  <span class="keyword">const</span> [searchParams, setSearchParams] = <span class="title function_">useSearchParams</span>()</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> page = searchParams.<span class="title function_">get</span>(<span class="string">&#x27;page&#x27;</span>) || <span class="string">&#x27;1&#x27;</span>   <span class="comment">// ?page=2</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">p</span>&gt;</span>用户：&#123;id&#125;，页码：&#123;page&#125;<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">button</span> <span class="attr">onClick</span>=<span class="string">&#123;()</span> =&gt;</span> setSearchParams(&#123; page: &#x27;3&#x27; &#125;)&#125;&gt;下一页<span class="tag">&lt;/<span class="name">button</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">button</span> <span class="attr">onClick</span>=<span class="string">&#123;()</span> =&gt;</span> navigate(&#x27;/&#x27;)&#125;&gt;回首页<span class="tag">&lt;/<span class="name">button</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="四、路由守卫：未登录跳登录页"><a href="#四、路由守卫：未登录跳登录页" class="headerlink" title="四、路由守卫：未登录跳登录页"></a>四、路由守卫：未登录跳登录页</h2><p>数据路由里可以用 <code>loader</code> + 重定向做守卫：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; redirect &#125; <span class="keyword">from</span> <span class="string">&#x27;react-router-dom&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 守卫逻辑</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">requireAuth</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> token = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line">  <span class="keyword">if</span> (!token) &#123;</span><br><span class="line">    <span class="comment">// 重定向到登录页，并记录来源</span></span><br><span class="line">    <span class="keyword">return</span> <span class="title function_">redirect</span>(<span class="string">`/login?redirect=<span class="subst">$&#123;<span class="built_in">encodeURIComponent</span>(location.pathname)&#125;</span>`</span>)</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> <span class="literal">null</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/dashboard&#x27;</span>,</span><br><span class="line">    <span class="attr">loader</span>: requireAuth,          <span class="comment">// 进入前先执行</span></span><br><span class="line">    <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Dashboard</span> /&gt;</span></span></span><br><span class="line">  &#125;</span><br><span class="line">])</span><br></pre></td></tr></table></figure><p>或者更直观的组件式守卫：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">RequireAuth</span>(<span class="params">&#123; children &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> token = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line">  <span class="keyword">const</span> location = <span class="title function_">useLocation</span>()</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (!token) &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">Navigate</span> <span class="attr">to</span>=<span class="string">&quot;/login&quot;</span> <span class="attr">replace</span> <span class="attr">state</span>=<span class="string">&#123;&#123;</span> <span class="attr">from:</span> <span class="attr">location</span> &#125;&#125; /&gt;</span></span></span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> children</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 使用</span></span><br><span class="line">&lt;<span class="title class_">Route</span></span><br><span class="line">  path=<span class="string">&quot;/dashboard&quot;</span></span><br><span class="line">  element=&#123;</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">RequireAuth</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">Dashboard</span> /&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">RequireAuth</span>&gt;</span></span></span><br><span class="line">  &#125;</span><br><span class="line">/&gt;</span><br></pre></td></tr></table></figure><h2 id="五、懒加载与代码分割"><a href="#五、懒加载与代码分割" class="headerlink" title="五、懒加载与代码分割"></a>五、懒加载与代码分割</h2><p><code>React.lazy</code> + <code>Suspense</code> 让路由级组件按需加载，首屏只下载必要代码：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">Suspense</span>, lazy &#125; <span class="keyword">from</span> <span class="string">&#x27;react&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">RouterProvider</span>, createBrowserRouter &#125; <span class="keyword">from</span> <span class="string">&#x27;react-router-dom&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 每个页面独立 chunk</span></span><br><span class="line"><span class="keyword">const</span> <span class="title class_">Home</span> = <span class="title function_">lazy</span>(<span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;./pages/Home&#x27;</span>))</span><br><span class="line"><span class="keyword">const</span> <span class="title class_">About</span> = <span class="title function_">lazy</span>(<span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;./pages/About&#x27;</span>))</span><br><span class="line"><span class="keyword">const</span> <span class="title class_">Dashboard</span> = <span class="title function_">lazy</span>(<span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;./pages/Dashboard&#x27;</span>))</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/&#x27;</span>,</span><br><span class="line">    <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Layout</span> /&gt;</span></span>,</span><br><span class="line">    <span class="attr">children</span>: [</span><br><span class="line">      &#123; <span class="attr">index</span>: <span class="literal">true</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Home</span> /&gt;</span></span> &#125;,</span><br><span class="line">      &#123; <span class="attr">path</span>: <span class="string">&#x27;about&#x27;</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">About</span> /&gt;</span></span> &#125;,</span><br><span class="line">      &#123; <span class="attr">path</span>: <span class="string">&#x27;dashboard&#x27;</span>, <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Dashboard</span> /&gt;</span></span> &#125;</span><br><span class="line">    ]</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br><span class="line"></span><br><span class="line"><span class="comment">// 包一层 Suspense 显示加载态</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">App</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">Suspense</span> <span class="attr">fallback</span>=<span class="string">&#123;</span>&lt;<span class="attr">div</span>&gt;</span>页面加载中...<span class="tag">&lt;/<span class="name">div</span>&gt;</span>&#125;&gt;</span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">RouterProvider</span> <span class="attr">router</span>=<span class="string">&#123;router&#125;</span> /&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">Suspense</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>构建后看产物：每个路由页面对应一个独立 chunk，首屏只加载入口 + 首页。</p><h2 id="六、错误边界：路由级容错"><a href="#六、错误边界：路由级容错" class="headerlink" title="六、错误边界：路由级容错"></a>六、错误边界：路由级容错</h2><p>数据路由支持配置 <code>errorElement</code>，路由渲染或 loader 出错时展示错误页：</p><figure class="highlight jsx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">ErrorPage</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> error = <span class="title function_">useRouteError</span>()</span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">div</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">h1</span>&gt;</span>出错了<span class="tag">&lt;/<span class="name">h1</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">      <span class="tag">&lt;<span class="name">p</span>&gt;</span>&#123;error.status === 404 ? &#x27;页面不存在&#x27; : &#x27;加载失败，请刷新重试&#x27;&#125;<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span></span><br><span class="line"><span class="language-xml">    <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span></span><br><span class="line">  )</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> router = <span class="title function_">createBrowserRouter</span>([</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/&#x27;</span>,</span><br><span class="line">    <span class="attr">element</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">Layout</span> /&gt;</span></span>,</span><br><span class="line">    <span class="attr">errorElement</span>: <span class="language-xml"><span class="tag">&lt;<span class="name">ErrorPage</span> /&gt;</span></span>,</span><br><span class="line">    <span class="attr">children</span>: [<span class="comment">/* ... */</span>]</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br></pre></td></tr></table></figure><h2 id="七、部署注意：SPA-刷新-404"><a href="#七、部署注意：SPA-刷新-404" class="headerlink" title="七、部署注意：SPA 刷新 404"></a>七、部署注意：SPA 刷新 404</h2><p>React Router 用 history 模式，服务器必须把所有路径回退到 index.html：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Nginx</span></span><br><span class="line"><span class="section">location</span> / &#123;</span><br><span class="line">    <span class="attribute">try_files</span> <span class="variable">$uri</span> <span class="variable">$uri</span>/ /index.html;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>不然直接访问 <code>/about</code> 会 404。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>NavLink 高亮错乱</strong>：忘记 <code>end</code> 属性，首页在任意路径都高亮；</li><li><strong><code>useParams</code> 取不到值</strong>：确认路由路径写的是 <code>:id</code> 且当前路由确实匹配；</li><li><strong>懒加载白屏</strong>：Suspense 没包住懒组件，或组件内部抛错，配合 errorElement 排查；</li><li><strong>跳转后滚动位置不对</strong>：用 <code>useScrollRestoration</code>（数据路由内置）或自己监听。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>React Router 6 的实践核心：<strong>createBrowserRouter 集中配置 → 嵌套路由 + Outlet → 守卫（loader&#x2F;组件式）→ lazy 分包 → errorElement 容错</strong>。这套组合能支撑中大型前端应用的导航和权限体系。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260522react/</id>
    <link href="https://blog.zhangsifan.com/posts/20260522react/"/>
    <published>2026-05-21T16:00:00.000Z</published>
    <summary>React Router 6 使用指南：createBrowserRouter 数据路由、嵌套路由、路由守卫、懒加载与代码分割的完整实践。</summary>
    <title>React Router 6 使用指南：路由配置与代码分割</title>
    <updated>2026-05-21T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="Electron" scheme="https://blog.zhangsifan.com/categories/Electron/"/>
    <category term="Electron" scheme="https://blog.zhangsifan.com/tags/Electron/"/>
    <category term="electron-builder" scheme="https://blog.zhangsifan.com/tags/electron-builder/"/>
    <category term="自动更新" scheme="https://blog.zhangsifan.com/tags/%E8%87%AA%E5%8A%A8%E6%9B%B4%E6%96%B0/"/>
    <category term="桌面应用" scheme="https://blog.zhangsifan.com/tags/%E6%A1%8C%E9%9D%A2%E5%BA%94%E7%94%A8/"/>
    <content>
      <![CDATA[<h2 id="一、为什么用-electron-builder"><a href="#一、为什么用-electron-builder" class="headerlink" title="一、为什么用 electron-builder"></a>一、为什么用 electron-builder</h2><p>Electron 打包方案里，<code>electron-builder</code> 是最主流的：支持 Windows&#x2F;macOS&#x2F;Linux 三平台，自动处理安装包（NSIS&#x2F;DMG&#x2F;deb）、图标、依赖收集，还内置了自动更新能力。</p><h2 id="二、基础配置"><a href="#二、基础配置" class="headerlink" title="二、基础配置"></a>二、基础配置</h2><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// package.json</span></span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;my-desktop-app&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;version&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1.2.0&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;main&quot;</span><span class="punctuation">:</span> <span class="string">&quot;main.js&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;dist&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron-builder&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;dist:win&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron-builder --win&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;dist:mac&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron-builder --mac&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;dist:linux&quot;</span><span class="punctuation">:</span> <span class="string">&quot;electron-builder --linux&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;appId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;com.example.mydesktopapp&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;productName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;MyDesktopApp&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;directories&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;output&quot;</span><span class="punctuation">:</span> <span class="string">&quot;dist&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;files&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">      <span class="string">&quot;main.js&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="string">&quot;preload.js&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="string">&quot;renderer/**/*&quot;</span></span><br><span class="line">    <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;win&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;target&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;nsis&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;icon&quot;</span><span class="punctuation">:</span> <span class="string">&quot;build/icon.ico&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;mac&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;target&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;dmg&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;icon&quot;</span><span class="punctuation">:</span> <span class="string">&quot;build/icon.icns&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;public.app-category.utilities&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;linux&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;target&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;AppImage&quot;</span><span class="punctuation">,</span> <span class="string">&quot;deb&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;icon&quot;</span><span class="punctuation">:</span> <span class="string">&quot;build/icon.png&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;nsis&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;oneClick&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;allowToChangeInstallationDirectory&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;createDesktopShortcut&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h2 id="三、图标要求"><a href="#三、图标要求" class="headerlink" title="三、图标要求"></a>三、图标要求</h2><ul><li>Windows：<code>icon.ico</code>（256x256 起步，多尺寸）</li><li>macOS：<code>icon.icns</code>（或提供 512x512 png 让工具自动生成）</li><li>Linux：<code>icon.png</code>（512x512）</li></ul><p>没有现成图标时，可以用 <code>electron-icon-builder</code> 从一张 1024x1024 png 生成全平台图标：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npx electron-icon-builder --input=./icon.png --output=./build</span><br></pre></td></tr></table></figure><p><strong>不配图标的后果</strong>：打包能过，但应用会显示默认 Electron 图标，上不了架、观感很差。</p><h2 id="四、代码签名（发布必备）"><a href="#四、代码签名（发布必备）" class="headerlink" title="四、代码签名（发布必备）"></a>四、代码签名（发布必备）</h2><p>未签名应用在 Windows 会弹 SmartScreen 警告，macOS 会被 Gatekeeper 拦截（”无法验证开发者”）。</p><p><strong>Windows 签名</strong>：需要代码签名证书（OV&#x2F;EV 证书），在 CI 里配置：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">win:</span></span><br><span class="line">  <span class="attr">certificateFile:</span> <span class="string">cert.pfx</span></span><br><span class="line">  <span class="attr">certificatePassword:</span> <span class="string">$&#123;CSC_KEY_PASSWORD&#125;</span></span><br></pre></td></tr></table></figure><p><strong>macOS 签名 + 公证（notarization）</strong>：需要 Apple Developer 证书，electron-builder 支持环境变量配置：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">CSC_LINK=/path/to/cert.p12 CSC_KEY_PASSWORD=xxx npm run dist:mac</span><br></pre></td></tr></table></figure><p>macOS 建议开公证（<code>notarize: true</code>），否则用户首次打开会被系统拦。</p><h2 id="五、自动更新：electron-updater"><a href="#五、自动更新：electron-updater" class="headerlink" title="五、自动更新：electron-updater"></a>五、自动更新：electron-updater</h2><p>安装：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install electron-updater</span><br></pre></td></tr></table></figure><p><strong>生成更新元数据</strong>：electron-builder 打包时默认生成 <code>latest.yml</code>（win）&#x2F; <code>latest-mac.yml</code>（mac），里面有版本号和安装包 hash——<strong>自动更新靠的就是这个文件</strong>。</p><p>主进程接入（main.js）：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> &#123; autoUpdater &#125; = <span class="built_in">require</span>(<span class="string">&#x27;electron-updater&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 配置更新源：你的静态服务器地址</span></span><br><span class="line">autoUpdater.<span class="title function_">setFeedURL</span>(&#123;</span><br><span class="line">  <span class="attr">provider</span>: <span class="string">&#x27;generic&#x27;</span>,</span><br><span class="line">  <span class="attr">url</span>: <span class="string">&#x27;https://download.example.com/myapp/&#x27;</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 检查更新（建议启动后延迟几秒再查，别拖慢首屏）</span></span><br><span class="line"><span class="built_in">setTimeout</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  autoUpdater.<span class="title function_">checkForUpdates</span>()</span><br><span class="line">&#125;, <span class="number">5000</span>)</span><br><span class="line"></span><br><span class="line">autoUpdater.<span class="title function_">on</span>(<span class="string">&#x27;update-available&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// 提示用户有新版本</span></span><br><span class="line">  dialog.<span class="title function_">showMessageBox</span>(&#123; <span class="attr">message</span>: <span class="string">&#x27;发现新版本，正在后台下载...&#x27;</span> &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">autoUpdater.<span class="title function_">on</span>(<span class="string">&#x27;update-downloaded&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  dialog.<span class="title function_">showMessageBox</span>(&#123;</span><br><span class="line">    <span class="attr">message</span>: <span class="string">&#x27;更新已下载，重启安装？&#x27;</span>,</span><br><span class="line">    <span class="attr">buttons</span>: [<span class="string">&#x27;立即重启&#x27;</span>, <span class="string">&#x27;稍后&#x27;</span>]</span><br><span class="line">  &#125;).<span class="title function_">then</span>(<span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (res.<span class="property">response</span> === <span class="number">0</span>) autoUpdater.<span class="title function_">quitAndInstall</span>()</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">autoUpdater.<span class="title function_">on</span>(<span class="string">&#x27;error&#x27;</span>, <span class="function">(<span class="params">err</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;自动更新失败：&#x27;</span>, err)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>发版流程</strong>：开发完 → <code>npm run dist</code> → 把新版本安装包 + <code>latest*.yml</code> 上传到更新服务器 → 用户下次启动自动收到更新。</p><h2 id="六、CI-自动化：GitHub-Actions"><a href="#六、CI-自动化：GitHub-Actions" class="headerlink" title="六、CI 自动化：GitHub Actions"></a>六、CI 自动化：GitHub Actions</h2><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># .github/workflows/release.yml</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">Build</span> <span class="string">&amp;</span> <span class="string">Release</span></span><br><span class="line"></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">push:</span></span><br><span class="line">    <span class="attr">tags:</span> [<span class="string">&quot;v*&quot;</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">build:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">$&#123;&#123;</span> <span class="string">matrix.os</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">strategy:</span></span><br><span class="line">      <span class="attr">matrix:</span></span><br><span class="line">        <span class="attr">os:</span> [<span class="string">windows-latest</span>, <span class="string">macos-latest</span>, <span class="string">ubuntu-latest</span>]</span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/checkout@v4</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/setup-node@v4</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">node-version:</span> <span class="number">20</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npm</span> <span class="string">ci</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">dist</span></span><br><span class="line">        <span class="attr">env:</span></span><br><span class="line">          <span class="attr">CSC_LINK:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.CSC_LINK</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">CSC_KEY_PASSWORD:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.CSC_KEY_PASSWORD</span> <span class="string">&#125;&#125;</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">softprops/action-gh-release@v2</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">files:</span> <span class="string">|</span></span><br><span class="line"><span class="string">            dist/*.exe</span></span><br><span class="line"><span class="string">            dist/*.dmg</span></span><br><span class="line"><span class="string">            dist/*.AppImage</span></span><br><span class="line"><span class="string">            dist/latest*.yml</span></span><br></pre></td></tr></table></figure><p>打 tag 即自动构建三平台安装包并发布。</p><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong><code>files</code> 配置漏文件导致白屏</strong>：渲染进程页面和 preload 没打进包，运行一片白，检查 <code>files</code> 列表；</li><li><strong>NSIS 安装后无法运行</strong>：多数是杀毒软件误报或签名问题，先本地 <code>electron .</code> 验证主进程正常；</li><li><strong>macOS 提示”已损坏”</strong>：没签名或签名被 Gatekeeper 拦截，<code>xattr -cr</code> 只能本地临时解决，正式分发必须签名 + 公证；</li><li><strong>自动更新一直失败</strong>：先确认 <code>latest*.yml</code> 和安装包在同目录、URL 可访问、版本号比本机高。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>打包发布的完整链路：<strong>electron-builder 配置 → 图标 → 签名（win&#x2F;mac）→ 产物 + latest.yml 上传 → electron-updater 自动更新 → CI 打 tag 自动化</strong>。前两次手工跑通后，后面交给 CI 就好。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260508el/</id>
    <link href="https://blog.zhangsifan.com/posts/20260508el/"/>
    <published>2026-05-07T16:00:00.000Z</published>
    <summary>Electron 打包发布实战教程：electron-builder 多平台配置、图标与资源打包、代码签名、以及 electron-updater 自动更新全流程。</summary>
    <title>Electron 打包实战：electron-builder 与自动更新</title>
    <updated>2026-05-07T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="uni-app" scheme="https://blog.zhangsifan.com/categories/uni-app/"/>
    <category term="uni-app" scheme="https://blog.zhangsifan.com/tags/uni-app/"/>
    <category term="Pinia" scheme="https://blog.zhangsifan.com/tags/Pinia/"/>
    <category term="状态管理" scheme="https://blog.zhangsifan.com/tags/%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86/"/>
    <category term="跨端开发" scheme="https://blog.zhangsifan.com/tags/%E8%B7%A8%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <content>
      <![CDATA[<h2 id="为什么要状态管理"><a href="#为什么要状态管理" class="headerlink" title="为什么要状态管理"></a>为什么要状态管理</h2><p>多页面应用里，登录状态、用户信息、购物车等数据需要全局共享。uni-app 的 Vue3 版本推荐使用 <strong>Pinia</strong>（Vue 官方状态库），用法和 Web 端一致。</p><h2 id="一、安装与初始化"><a href="#一、安装与初始化" class="headerlink" title="一、安装与初始化"></a>一、安装与初始化</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm install pinia</span><br></pre></td></tr></table></figure><p><code>main.js</code> 注册：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// main.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; createSSRApp &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; createPinia &#125; <span class="keyword">from</span> <span class="string">&#x27;pinia&#x27;</span></span><br><span class="line"><span class="keyword">import</span> <span class="title class_">App</span> <span class="keyword">from</span> <span class="string">&#x27;./App.vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">createApp</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> app = <span class="title function_">createSSRApp</span>(<span class="title class_">App</span>)</span><br><span class="line">  app.<span class="title function_">use</span>(<span class="title function_">createPinia</span>())</span><br><span class="line">  <span class="keyword">return</span> &#123; app &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="二、定义-user-store：登录状态"><a href="#二、定义-user-store：登录状态" class="headerlink" title="二、定义 user store：登录状态"></a>二、定义 user store：登录状态</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// stores/user.js</span></span><br><span class="line"><span class="keyword">import</span> &#123; defineStore &#125; <span class="keyword">from</span> <span class="string">&#x27;pinia&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> useUserStore = <span class="title function_">defineStore</span>(<span class="string">&#x27;user&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">state</span>: <span class="function">() =&gt;</span> (&#123;</span><br><span class="line">    <span class="attr">token</span>: uni.<span class="title function_">getStorageSync</span>(<span class="string">&#x27;token&#x27;</span>) || <span class="string">&#x27;&#x27;</span>,</span><br><span class="line">    <span class="attr">userInfo</span>: <span class="literal">null</span></span><br><span class="line">  &#125;),</span><br><span class="line"></span><br><span class="line">  <span class="attr">getters</span>: &#123;</span><br><span class="line">    <span class="attr">isLoggedIn</span>: <span class="function">(<span class="params">state</span>) =&gt;</span> !!state.<span class="property">token</span></span><br><span class="line">  &#125;,</span><br><span class="line"></span><br><span class="line">  <span class="attr">actions</span>: &#123;</span><br><span class="line">    <span class="title function_">setToken</span>(<span class="params">token</span>) &#123;</span><br><span class="line">      <span class="variable language_">this</span>.<span class="property">token</span> = token</span><br><span class="line">      uni.<span class="title function_">setStorageSync</span>(<span class="string">&#x27;token&#x27;</span>, token)</span><br><span class="line">    &#125;,</span><br><span class="line"></span><br><span class="line">    <span class="keyword">async</span> <span class="title function_">login</span>(<span class="params">username, password</span>) &#123;</span><br><span class="line">      <span class="comment">// 实际开发走封装好的 request</span></span><br><span class="line">      <span class="keyword">const</span> res = <span class="keyword">await</span> <span class="title function_">request</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/api/login&#x27;</span>, <span class="attr">method</span>: <span class="string">&#x27;POST&#x27;</span>, <span class="attr">data</span>: &#123; username, password &#125; &#125;)</span><br><span class="line">      <span class="variable language_">this</span>.<span class="title function_">setToken</span>(res.<span class="property">token</span>)</span><br><span class="line">      <span class="variable language_">this</span>.<span class="property">userInfo</span> = res.<span class="property">user</span></span><br><span class="line">      <span class="keyword">return</span> res</span><br><span class="line">    &#125;,</span><br><span class="line"></span><br><span class="line">    <span class="title function_">logout</span>(<span class="params"></span>) &#123;</span><br><span class="line">      <span class="variable language_">this</span>.<span class="property">token</span> = <span class="string">&#x27;&#x27;</span></span><br><span class="line">      <span class="variable language_">this</span>.<span class="property">userInfo</span> = <span class="literal">null</span></span><br><span class="line">      uni.<span class="title function_">removeStorageSync</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line">      uni.<span class="title function_">reLaunch</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/pages/login/login&#x27;</span> &#125;)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>页面中使用：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; useUserStore &#125; from &#x27;@/stores/user&#x27;</span><br><span class="line"></span><br><span class="line">const userStore = useUserStore()</span><br><span class="line">console.log(&#x27;是否登录：&#x27;, userStore.isLoggedIn)</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><h2 id="三、统一请求封装：拦截器-token-错误处理"><a href="#三、统一请求封装：拦截器-token-错误处理" class="headerlink" title="三、统一请求封装：拦截器 + token + 错误处理"></a>三、统一请求封装：拦截器 + token + 错误处理</h2><p>一个带拦截器的 <code>request</code>，是跨端项目的标配：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// utils/request.js</span></span><br><span class="line"><span class="keyword">const</span> <span class="variable constant_">BASE_URL</span> = <span class="string">&#x27;https://api.example.com&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">request</span>(<span class="params">options</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Promise</span>(<span class="function">(<span class="params">resolve, reject</span>) =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> token = uni.<span class="title function_">getStorageSync</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    uni.<span class="title function_">request</span>(&#123;</span><br><span class="line">      <span class="attr">url</span>: <span class="variable constant_">BASE_URL</span> + options.<span class="property">url</span>,</span><br><span class="line">      <span class="attr">method</span>: options.<span class="property">method</span> || <span class="string">&#x27;GET&#x27;</span>,</span><br><span class="line">      <span class="attr">data</span>: options.<span class="property">data</span> || &#123;&#125;,</span><br><span class="line">      <span class="attr">header</span>: &#123;</span><br><span class="line">        <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span>,</span><br><span class="line">        ...(token ? &#123; <span class="title class_">Authorization</span>: <span class="string">`Bearer <span class="subst">$&#123;token&#125;</span>`</span> &#125; : &#123;&#125;),</span><br><span class="line">        ...options.<span class="property">header</span></span><br><span class="line">      &#125;,</span><br><span class="line"></span><br><span class="line">      <span class="attr">success</span>: <span class="function">(<span class="params">res</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="comment">// 业务码约定：code === 0 成功</span></span><br><span class="line">        <span class="keyword">if</span> (res.<span class="property">statusCode</span> === <span class="number">200</span> &amp;&amp; res.<span class="property">data</span>.<span class="property">code</span> === <span class="number">0</span>) &#123;</span><br><span class="line">          <span class="title function_">resolve</span>(res.<span class="property">data</span>.<span class="property">data</span>)</span><br><span class="line">        &#125; <span class="keyword">else</span> <span class="keyword">if</span> (res.<span class="property">statusCode</span> === <span class="number">401</span>) &#123;</span><br><span class="line">          <span class="comment">// token 失效：清登录态回登录页</span></span><br><span class="line">          uni.<span class="title function_">removeStorageSync</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line">          uni.<span class="title function_">reLaunch</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/pages/login/login&#x27;</span> &#125;)</span><br><span class="line">          <span class="title function_">reject</span>(<span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">&#x27;登录已过期&#x27;</span>))</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">          uni.<span class="title function_">showToast</span>(&#123; <span class="attr">title</span>: res.<span class="property">data</span>.<span class="property">message</span> || <span class="string">&#x27;请求失败&#x27;</span>, <span class="attr">icon</span>: <span class="string">&#x27;none&#x27;</span> &#125;)</span><br><span class="line">          <span class="title function_">reject</span>(<span class="keyword">new</span> <span class="title class_">Error</span>(res.<span class="property">data</span>.<span class="property">message</span> || <span class="string">&#x27;请求失败&#x27;</span>))</span><br><span class="line">        &#125;</span><br><span class="line">      &#125;,</span><br><span class="line"></span><br><span class="line">      <span class="attr">fail</span>: <span class="function">(<span class="params">err</span>) =&gt;</span> &#123;</span><br><span class="line">        uni.<span class="title function_">showToast</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;网络异常，请检查网络&#x27;</span>, <span class="attr">icon</span>: <span class="string">&#x27;none&#x27;</span> &#125;)</span><br><span class="line">        <span class="title function_">reject</span>(err)</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>为什么封装成 Promise</strong>：<code>uni.request</code> 是回调式 API，封装后页面里可以 <code>await</code>，配合 async&#x2F;await 让代码和 Web 端一样清爽：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">loadOrders</span>(<span class="params"></span>) &#123;</span><br><span class="line">  uni.<span class="title function_">showLoading</span>(&#123; <span class="attr">title</span>: <span class="string">&#x27;加载中&#x27;</span> &#125;)</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> list = <span class="keyword">await</span> <span class="title function_">request</span>(&#123; <span class="attr">url</span>: <span class="string">&#x27;/api/orders&#x27;</span> &#125;)</span><br><span class="line">    orders.<span class="property">value</span> = list</span><br><span class="line">  &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">    uni.<span class="title function_">hideLoading</span>()</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="四、组合使用：登录-→-存状态-→-跳转"><a href="#四、组合使用：登录-→-存状态-→-跳转" class="headerlink" title="四、组合使用：登录 → 存状态 → 跳转"></a>四、组合使用：登录 → 存状态 → 跳转</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line">&lt;script setup&gt;</span><br><span class="line">import &#123; ref &#125; from &#x27;vue&#x27;</span><br><span class="line">import &#123; useUserStore &#125; from &#x27;@/stores/user&#x27;</span><br><span class="line"></span><br><span class="line">const userStore = useUserStore()</span><br><span class="line">const username = ref(&#x27;&#x27;)</span><br><span class="line">const password = ref(&#x27;&#x27;)</span><br><span class="line">const loading = ref(false)</span><br><span class="line"></span><br><span class="line">async function handleLogin() &#123;</span><br><span class="line">  if (!username.value || !password.value) &#123;</span><br><span class="line">    uni.showToast(&#123; title: &#x27;请输入账号密码&#x27;, icon: &#x27;none&#x27; &#125;)</span><br><span class="line">    return</span><br><span class="line">  &#125;</span><br><span class="line">  loading.value = true</span><br><span class="line">  try &#123;</span><br><span class="line">    await userStore.login(username.value, password.value)</span><br><span class="line">    uni.showToast(&#123; title: &#x27;登录成功&#x27;, icon: &#x27;success&#x27; &#125;)</span><br><span class="line">    setTimeout(() =&gt; uni.switchTab(&#123; url: &#x27;/pages/index/index&#x27; &#125;), 500)</span><br><span class="line">  &#125; catch (e) &#123;</span><br><span class="line">    // 错误提示已在 request 里统一处理</span><br><span class="line">  &#125; finally &#123;</span><br><span class="line">    loading.value = false</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><h2 id="五、跨端差异注意"><a href="#五、跨端差异注意" class="headerlink" title="五、跨端差异注意"></a>五、跨端差异注意</h2><ol><li><strong>H5 端可以 localStorage，小程序不行</strong>：统一用 <code>uni.setStorageSync/getStorageSync</code>，别混用；</li><li><strong><code>uni.request</code> 的 header 区分大小写</strong>：<code>Content-Type</code> 各端表现可能不同，建议按上面示例统一设置；</li><li><strong>小程序合法域名</strong>：正式发布必须在微信公众平台配置 request 合法域名，否则请求全挂；</li><li><strong>Pinia 在跨端</strong>：Vue3 版 uni-app 完全兼容 Pinia，Vue2 版则用 Vuex。</li></ol><h2 id="六、常见坑"><a href="#六、常见坑" class="headerlink" title="六、常见坑"></a>六、常见坑</h2><ol><li><strong><code>uni.showLoading</code> 没 hide</strong>：loading 弹窗会一直挂着，用 try&#x2F;finally 保证隐藏；</li><li><strong>401 重复弹窗</strong>：多个请求同时 401 会跳多次登录页，可加”是否正在跳转”标记去重；</li><li><strong>token 过期但接口继续打</strong>：请求拦截器里 401 直接清 token 并中断，别让后续请求带着旧 token 继续打。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>跨端项目的状态管理套路：<strong>Pinia 管全局状态（登录态持久化到 storage）→ 统一 request 封装（token 注入、401 处理、错误提示）→ 页面里 async&#x2F;await 消费</strong>。这套组合在 H5、微信小程序、App 端行为一致，是 uni-app 项目的标准架构。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260425ua/</id>
    <link href="https://blog.zhangsifan.com/posts/20260425ua/"/>
    <published>2026-04-24T16:00:00.000Z</published>
    <summary>uni-app 状态管理实战教程：在 uni-app 中使用 Pinia 管理全局状态，封装带拦截器、token 刷新和错误提示的统一请求层。</summary>
    <title>uni-app 状态管理实战：Pinia 与请求封装</title>
    <updated>2026-04-24T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="前端" scheme="https://blog.zhangsifan.com/categories/%E5%89%8D%E7%AB%AF/"/>
    <category term="前端" scheme="https://blog.zhangsifan.com/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="Vue" scheme="https://blog.zhangsifan.com/tags/Vue/"/>
    <category term="Vue Router" scheme="https://blog.zhangsifan.com/tags/Vue-Router/"/>
    <content>
      <![CDATA[<h2 id="场景引入"><a href="#场景引入" class="headerlink" title="场景引入"></a>场景引入</h2><p>大部分后台管理系统都有这类需求：</p><ol><li>未登录用户跳转任何页面都强制回登录页；</li><li>不同角色看到的菜单不同、能进的页面不同；</li><li>路由按需加载，首屏不打包全部页面。</li></ol><p>这些需求都靠 Vue Router 的<strong>守卫</strong>和<strong>动态路由</strong>解决。</p><h2 id="一、路由元信息：给路由打标签"><a href="#一、路由元信息：给路由打标签" class="headerlink" title="一、路由元信息：给路由打标签"></a>一、路由元信息：给路由打标签</h2><p><code>meta</code> 是挂在路由上的自定义数据，权限、标题、缓存标记都放这里：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> routes = [</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/admin&#x27;</span>,</span><br><span class="line">    <span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;@/views/Admin.vue&#x27;</span>),</span><br><span class="line">    <span class="attr">meta</span>: &#123;</span><br><span class="line">      <span class="attr">requiresAuth</span>: <span class="literal">true</span>,   <span class="comment">// 需要登录</span></span><br><span class="line">      <span class="attr">roles</span>: [<span class="string">&#x27;admin&#x27;</span>],     <span class="comment">// 允许的角色</span></span><br><span class="line">      <span class="attr">title</span>: <span class="string">&#x27;管理后台&#x27;</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/login&#x27;</span>,</span><br><span class="line">    <span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;@/views/Login.vue&#x27;</span>),</span><br><span class="line">    <span class="attr">meta</span>: &#123; <span class="attr">title</span>: <span class="string">&#x27;登录&#x27;</span> &#125;</span><br><span class="line">  &#125;</span><br><span class="line">]</span><br></pre></td></tr></table></figure><h2 id="二、全局守卫：登录拦截"><a href="#二、全局守卫：登录拦截" class="headerlink" title="二、全局守卫：登录拦截"></a>二、全局守卫：登录拦截</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> router <span class="keyword">from</span> <span class="string">&#x27;./router&#x27;</span></span><br><span class="line"><span class="keyword">import</span> &#123; useUserStore &#125; <span class="keyword">from</span> <span class="string">&#x27;@/stores/user&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 每次路由跳转前执行</span></span><br><span class="line">router.<span class="title function_">beforeEach</span>(<span class="function">(<span class="params">to, <span class="keyword">from</span>, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// 动态设置页面标题</span></span><br><span class="line">  <span class="variable language_">document</span>.<span class="property">title</span> = to.<span class="property">meta</span>.<span class="property">title</span> ? <span class="string">`<span class="subst">$&#123;to.meta.title&#125;</span> - 我的后台`</span> : <span class="string">&#x27;我的后台&#x27;</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> userStore = <span class="title function_">useUserStore</span>()</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 需要登录但没登录 → 去登录页，并记录来源</span></span><br><span class="line">  <span class="keyword">if</span> (to.<span class="property">meta</span>.<span class="property">requiresAuth</span> &amp;&amp; !userStore.<span class="property">token</span>) &#123;</span><br><span class="line">    <span class="title function_">next</span>(&#123; <span class="attr">path</span>: <span class="string">&#x27;/login&#x27;</span>, <span class="attr">query</span>: &#123; <span class="attr">redirect</span>: to.<span class="property">fullPath</span> &#125; &#125;)</span><br><span class="line">    <span class="keyword">return</span></span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 角色校验</span></span><br><span class="line">  <span class="keyword">if</span> (to.<span class="property">meta</span>.<span class="property">roles</span> &amp;&amp; !to.<span class="property">meta</span>.<span class="property">roles</span>.<span class="title function_">includes</span>(userStore.<span class="property">role</span>)) &#123;</span><br><span class="line">    <span class="title function_">next</span>(<span class="string">&#x27;/403&#x27;</span>)   <span class="comment">// 无权限页</span></span><br><span class="line">    <span class="keyword">return</span></span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>守卫的三参数：</p><ul><li><code>to</code>：目标路由（即将进入）</li><li><code>from</code>：来源路由</li><li><code>next()</code>：放行；<code>next(false)</code> 取消；<code>next(&#39;/xx&#39;)</code> 重定向</li></ul><h2 id="三、登录后跳回原页面"><a href="#三、登录后跳回原页面" class="headerlink" title="三、登录后跳回原页面"></a>三、登录后跳回原页面</h2><p>登录页拿到 <code>redirect</code> 参数，登录成功后回跳：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> router = <span class="title function_">useRouter</span>()</span><br><span class="line"><span class="keyword">const</span> route = <span class="title function_">useRoute</span>()</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">handleLogin</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">login</span>()</span><br><span class="line">  <span class="comment">// 有来源就回去，没有就去首页</span></span><br><span class="line">  router.<span class="title function_">push</span>(route.<span class="property">query</span>.<span class="property">redirect</span> || <span class="string">&#x27;/&#x27;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="四、动态路由：按角色添加菜单"><a href="#四、动态路由：按角色添加菜单" class="headerlink" title="四、动态路由：按角色添加菜单"></a>四、动态路由：按角色添加菜单</h2><p>静态路由只放公共页面，业务页面登录后根据权限动态添加：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 后端按角色返回菜单配置</span></span><br><span class="line"><span class="keyword">const</span> menus = [</span><br><span class="line">  &#123; <span class="attr">path</span>: <span class="string">&#x27;/user&#x27;</span>, <span class="attr">name</span>: <span class="string">&#x27;User&#x27;</span>, <span class="attr">component</span>: <span class="string">&#x27;user/UserList&#x27;</span> &#125;,</span><br><span class="line">  &#123; <span class="attr">path</span>: <span class="string">&#x27;/order&#x27;</span>, <span class="attr">name</span>: <span class="string">&#x27;Order&#x27;</span>, <span class="attr">component</span>: <span class="string">&#x27;order/OrderList&#x27;</span> &#125;</span><br><span class="line">]</span><br><span class="line"></span><br><span class="line"><span class="comment">// 把组件名字映射到实际组件</span></span><br><span class="line"><span class="keyword">const</span> modules = <span class="keyword">import</span>.<span class="property">meta</span>.<span class="title function_">glob</span>(<span class="string">&#x27;@/views/**/*.vue&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">buildRoutes</span>(<span class="params">menus</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> menus.<span class="title function_">map</span>(<span class="function">(<span class="params">menu</span>) =&gt;</span> (&#123;</span><br><span class="line">    <span class="attr">path</span>: menu.<span class="property">path</span>,</span><br><span class="line">    <span class="attr">name</span>: menu.<span class="property">name</span>,</span><br><span class="line">    <span class="comment">// 通过映射动态加载组件</span></span><br><span class="line">    <span class="attr">component</span>: modules[<span class="string">`/src/views/<span class="subst">$&#123;menu.component&#125;</span>.vue`</span>]</span><br><span class="line">  &#125;))</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 登录成功后动态添加路由</span></span><br><span class="line">router.<span class="title function_">addRoute</span>(&#123;</span><br><span class="line">  <span class="attr">path</span>: <span class="string">&#x27;/&#x27;</span>,</span><br><span class="line">  <span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;@/layout/MainLayout.vue&#x27;</span>),</span><br><span class="line">  <span class="attr">children</span>: <span class="title function_">buildRoutes</span>(menus)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>重点坑</strong>：<code>addRoute</code> 添加的路由<strong>刷新页面就没了</strong>（内存态），所以刷新后要在入口重新拉菜单、重新 addRoute，否则 404。常见方案是在 <code>beforeEach</code> 里加”是否已初始化”判断：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">router.<span class="title function_">beforeEach</span>(<span class="title function_">async</span> (to, <span class="keyword">from</span>, next) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> userStore = <span class="title function_">useUserStore</span>()</span><br><span class="line">  <span class="keyword">if</span> (userStore.<span class="property">token</span> &amp;&amp; !userStore.<span class="property">menusLoaded</span>) &#123;</span><br><span class="line">    <span class="keyword">await</span> userStore.<span class="title function_">fetchAndRegisterRoutes</span>()   <span class="comment">// 拉菜单 + addRoute</span></span><br><span class="line">    <span class="title function_">next</span>(&#123; ...to, <span class="attr">replace</span>: <span class="literal">true</span> &#125;)             <span class="comment">// 重新进入目标路由</span></span><br><span class="line">    <span class="keyword">return</span></span><br><span class="line">  &#125;</span><br><span class="line">  <span class="title function_">next</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="五、路由懒加载与打包优化"><a href="#五、路由懒加载与打包优化" class="headerlink" title="五、路由懒加载与打包优化"></a>五、路由懒加载与打包优化</h2><p>Vue Router 4 默认推荐路由级懒加载，页面按需打包：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 基础写法：单独 chunk</span></span><br><span class="line"><span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;@/views/Home.vue&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 把相关页面合成一个 chunk（webpackChunkName 注释）</span></span><br><span class="line"><span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="comment">/* webpackChunkName: &quot;user&quot; */</span> <span class="string">&#x27;@/views/user/UserList.vue&#x27;</span>)</span><br></pre></td></tr></table></figure><p>Vite 里用 <code>build.rollupOptions.output.manualChunks</code> 控制分包，路由级懒加载天然就会产生多个 chunk。</p><h2 id="六、组件内守卫与路由滚动"><a href="#六、组件内守卫与路由滚动" class="headerlink" title="六、组件内守卫与路由滚动"></a>六、组件内守卫与路由滚动</h2><p>组件内守卫适合”离开前确认”：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 在组件里</span></span><br><span class="line"><span class="title function_">onBeforeRouteLeave</span>(<span class="function">(<span class="params">to, <span class="keyword">from</span>, next</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (hasUnsavedChanges.<span class="property">value</span>) &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="title function_">confirm</span>(<span class="string">&#x27;有未保存的修改，确定离开吗？&#x27;</span>)) &#123;</span><br><span class="line">      <span class="title function_">next</span>()</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">      <span class="title function_">next</span>(<span class="literal">false</span>)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="title function_">next</span>()</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>路由切换保持滚动位置：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> router = <span class="title function_">createRouter</span>(&#123;</span><br><span class="line">  <span class="attr">history</span>: <span class="title function_">createWebHistory</span>(),</span><br><span class="line">  routes,</span><br><span class="line">  <span class="title function_">scrollBehavior</span>(<span class="params">to, <span class="keyword">from</span>, savedPosition</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> savedPosition || &#123; <span class="attr">top</span>: <span class="number">0</span> &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>守卫里访问 store 时机</strong>：<code>beforeEach</code> 里用 Pinia 要在 <code>app.use(pinia)</code> 之后，否则拿不到实例；</li><li><strong><code>addRoute</code> 重复添加报错</strong>：同名路由重复 add 会警告，初始化前先 <code>router.hasRoute(name)</code> 判断；</li><li><strong>动态路由刷新 404</strong>：几乎所有人都会踩，务必处理”菜单初始化”逻辑；</li><li><strong>守卫死循环</strong>：<code>next(&#39;/login&#39;)</code> 但 login 页面也满足拦截条件时，会无限重定向，拦截逻辑要排除登录页。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Vue Router 4 的进阶能力就三块：<strong>meta 元信息打标签 → 全局守卫做登录&#x2F;权限拦截 → 动态 addRoute 按角色挂路由</strong>。配合路由懒加载，后台管理系统的权限骨架就完整了。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260410vr/</id>
    <link href="https://blog.zhangsifan.com/posts/20260410vr/"/>
    <published>2026-04-09T16:00:00.000Z</published>
    <summary>Vue Router 4 进阶教程：全局守卫、路由元信息、基于角色的权限控制、动态添加路由与路由懒加载的最佳实践。</summary>
    <title>Vue Router 4 进阶：路由守卫与动态路由</title>
    <updated>2026-04-09T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>JCAlways</name>
    </author>
    <category term="iOS" scheme="https://blog.zhangsifan.com/categories/iOS/"/>
    <category term="iOS" scheme="https://blog.zhangsifan.com/tags/iOS/"/>
    <category term="Swift" scheme="https://blog.zhangsifan.com/tags/Swift/"/>
    <category term="URLSession" scheme="https://blog.zhangsifan.com/tags/URLSession/"/>
    <category term="网络请求" scheme="https://blog.zhangsifan.com/tags/%E7%BD%91%E7%BB%9C%E8%AF%B7%E6%B1%82/"/>
    <content>
      <![CDATA[<h2 id="一、环境与基础概念"><a href="#一、环境与基础概念" class="headerlink" title="一、环境与基础概念"></a>一、环境与基础概念</h2><p>iOS 网络请求的标准方案是 <strong>URLSession</strong>（苹果官方网络框架）。配合 Swift 5.5+ 的 <code>async/await</code>，写法已经非常简洁。</p><p>三个核心类型：</p><ul><li><code>URLSession</code>：发起请求的会话对象</li><li><code>URLRequest</code>：请求配置（方法、头、body）</li><li><code>URLSessionDataTask</code>：一次数据请求任务</li></ul><h2 id="二、基础-GET-请求（传统回调版）"><a href="#二、基础-GET-请求（传统回调版）" class="headerlink" title="二、基础 GET 请求（传统回调版）"></a>二、基础 GET 请求（传统回调版）</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">func</span> <span class="title function_">fetchUsers</span>(<span class="params">completion</span>: <span class="keyword">@escaping</span> (<span class="type">Result</span>&lt;[<span class="type">User</span>], <span class="type">Error</span>&gt;) -&gt; <span class="type">Void</span>) &#123;</span><br><span class="line">    <span class="keyword">guard</span> <span class="keyword">let</span> url <span class="operator">=</span> <span class="type">URL</span>(string: <span class="string">&quot;https://api.example.com/users&quot;</span>) <span class="keyword">else</span> &#123;</span><br><span class="line">        completion(.failure(<span class="type">NSError</span>(domain: <span class="string">&quot;bad url&quot;</span>, code: <span class="operator">-</span><span class="number">1</span>)))</span><br><span class="line">        <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">let</span> task <span class="operator">=</span> <span class="type">URLSession</span>.shared.dataTask(with: url) &#123; data, response, error <span class="keyword">in</span></span><br><span class="line">        <span class="comment">// 1. 网络错误</span></span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">let</span> error <span class="operator">=</span> error &#123;</span><br><span class="line">            completion(.failure(error))</span><br><span class="line">            <span class="keyword">return</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 检查 HTTP 状态码</span></span><br><span class="line">        <span class="keyword">guard</span> <span class="keyword">let</span> http <span class="operator">=</span> response <span class="keyword">as?</span> <span class="type">HTTPURLResponse</span>,</span><br><span class="line">              (<span class="number">200</span><span class="operator">...</span><span class="number">299</span>).contains(http.statusCode) <span class="keyword">else</span> &#123;</span><br><span class="line">            completion(.failure(<span class="type">NSError</span>(domain: <span class="string">&quot;bad status&quot;</span>, code: <span class="operator">-</span><span class="number">2</span>)))</span><br><span class="line">            <span class="keyword">return</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 解码</span></span><br><span class="line">        <span class="keyword">do</span> &#123;</span><br><span class="line">            <span class="keyword">let</span> users <span class="operator">=</span> <span class="keyword">try</span> <span class="type">JSONDecoder</span>().decode([<span class="type">User</span>].<span class="keyword">self</span>, from: data <span class="operator">??</span> <span class="type">Data</span>())</span><br><span class="line">            completion(.success(users))</span><br><span class="line">        &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line">            completion(.failure(error))</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    task.resume()   <span class="comment">// 别忘了，任务默认是挂起的</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="三、async-await-版本：简洁得多"><a href="#三、async-await-版本：简洁得多" class="headerlink" title="三、async&#x2F;await 版本：简洁得多"></a>三、async&#x2F;await 版本：简洁得多</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">func</span> <span class="title function_">fetchUsers</span>() <span class="keyword">async</span> <span class="keyword">throws</span> -&gt; [<span class="type">User</span>] &#123;</span><br><span class="line">    <span class="keyword">guard</span> <span class="keyword">let</span> url <span class="operator">=</span> <span class="type">URL</span>(string: <span class="string">&quot;https://api.example.com/users&quot;</span>) <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="type">URLError</span>(.badURL)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">let</span> (data, response) <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> <span class="type">URLSession</span>.shared.data(from: url)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 状态码校验</span></span><br><span class="line">    <span class="keyword">guard</span> <span class="keyword">let</span> http <span class="operator">=</span> response <span class="keyword">as?</span> <span class="type">HTTPURLResponse</span>,</span><br><span class="line">          (<span class="number">200</span><span class="operator">...</span><span class="number">299</span>).contains(http.statusCode) <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="type">URLError</span>(.badServerResponse)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">try</span> <span class="type">JSONDecoder</span>().decode([<span class="type">User</span>].<span class="keyword">self</span>, from: data)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>调用：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 在 SwiftUI 里</span></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">UserListView</span>: <span class="title class_ inherited__">View</span> &#123;</span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> users: [<span class="type">User</span>] <span class="operator">=</span> []</span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> isLoading <span class="operator">=</span> <span class="literal">false</span></span><br><span class="line">    <span class="meta">@State</span> <span class="keyword">private</span> <span class="keyword">var</span> errorMessage: <span class="type">String</span>?</span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> body: <span class="keyword">some</span> <span class="type">View</span> &#123;</span><br><span class="line">        <span class="type">List</span>(users) &#123; user <span class="keyword">in</span></span><br><span class="line">            <span class="type">Text</span>(user.name)</span><br><span class="line">        &#125;</span><br><span class="line">        .task &#123;</span><br><span class="line">            <span class="keyword">await</span> load()</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">func</span> <span class="title function_">load</span>() <span class="keyword">async</span> &#123;</span><br><span class="line">        isLoading <span class="operator">=</span> <span class="literal">true</span></span><br><span class="line">        <span class="keyword">defer</span> &#123; isLoading <span class="operator">=</span> <span class="literal">false</span> &#125;</span><br><span class="line">        <span class="keyword">do</span> &#123;</span><br><span class="line">            users <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> fetchUsers()</span><br><span class="line">        &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line">            errorMessage <span class="operator">=</span> error.localizedDescription</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>.task</code> 修饰符是 SwiftUI 里发起异步加载的标准入口，视图消失时自动取消任务。</p><h2 id="四、Codable：模型解码"><a href="#四、Codable：模型解码" class="headerlink" title="四、Codable：模型解码"></a>四、Codable：模型解码</h2><p>模型遵循 <code>Codable</code>（或 <code>Decodable</code>）即可：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">User</span>: <span class="title class_ inherited__">Identifiable</span>, <span class="title class_ inherited__">Decodable</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> id: <span class="type">Int</span></span><br><span class="line">    <span class="keyword">let</span> name: <span class="type">String</span></span><br><span class="line">    <span class="keyword">let</span> email: <span class="type">String</span></span><br><span class="line">    <span class="keyword">let</span> isVip: <span class="type">Bool</span></span><br><span class="line">    <span class="keyword">let</span> joinDate: <span class="type">Date</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>JSON 字段名与属性不一致</strong>时用 <code>CodingKeys</code>：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">User</span>: <span class="title class_ inherited__">Decodable</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> id: <span class="type">Int</span></span><br><span class="line">    <span class="keyword">let</span> avatar: <span class="type">String</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">enum</span> <span class="title class_">CodingKeys</span>: <span class="title class_ inherited__">String</span>, <span class="title class_ inherited__">CodingKey</span> &#123;</span><br><span class="line">        <span class="keyword">case</span> id</span><br><span class="line">        <span class="keyword">case</span> avatar <span class="operator">=</span> <span class="string">&quot;avatar_url&quot;</span>   <span class="comment">// 映射下划线命名</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>日期格式</strong>需要自定义解码策略：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> decoder <span class="operator">=</span> <span class="type">JSONDecoder</span>()</span><br><span class="line">decoder.dateDecodingStrategy <span class="operator">=</span> .iso8601   <span class="comment">// 标准 ISO 8601 格式</span></span><br><span class="line">decoder.keyDecodingStrategy <span class="operator">=</span> .convertFromSnakeCase  <span class="comment">// 自动转驼峰</span></span><br></pre></td></tr></table></figure><h2 id="五、POST-请求与-JSON-body"><a href="#五、POST-请求与-JSON-body" class="headerlink" title="五、POST 请求与 JSON body"></a>五、POST 请求与 JSON body</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">func</span> <span class="title function_">createUser</span>(<span class="params">name</span>: <span class="type">String</span>) <span class="keyword">async</span> <span class="keyword">throws</span> -&gt; <span class="type">User</span> &#123;</span><br><span class="line">    <span class="keyword">var</span> request <span class="operator">=</span> <span class="type">URLRequest</span>(url: <span class="type">URL</span>(string: <span class="string">&quot;https://api.example.com/users&quot;</span>)<span class="operator">!</span>)</span><br><span class="line">    request.httpMethod <span class="operator">=</span> <span class="string">&quot;POST&quot;</span></span><br><span class="line">    request.setValue(<span class="string">&quot;application/json&quot;</span>, forHTTPHeaderField: <span class="string">&quot;Content-Type&quot;</span>)</span><br><span class="line">    request.setValue(<span class="string">&quot;Bearer <span class="subst">\(token)</span>&quot;</span>, forHTTPHeaderField: <span class="string">&quot;Authorization&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">let</span> body <span class="operator">=</span> [<span class="string">&quot;name&quot;</span>: name]</span><br><span class="line">    request.httpBody <span class="operator">=</span> <span class="keyword">try</span> <span class="type">JSONEncoder</span>().encode(body)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">let</span> (data, response) <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> <span class="type">URLSession</span>.shared.data(for: request)</span><br><span class="line">    <span class="keyword">guard</span> <span class="keyword">let</span> http <span class="operator">=</span> response <span class="keyword">as?</span> <span class="type">HTTPURLResponse</span>, http.statusCode <span class="operator">==</span> <span class="number">201</span> <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="type">URLError</span>(.badServerResponse)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">try</span> <span class="type">JSONDecoder</span>().decode(<span class="type">User</span>.<span class="keyword">self</span>, from: data)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="六、封装通用网络层（实战级）"><a href="#六、封装通用网络层（实战级）" class="headerlink" title="六、封装通用网络层（实战级）"></a>六、封装通用网络层（实战级）</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">APIClient</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> baseURL <span class="operator">=</span> <span class="string">&quot;https://api.example.com&quot;</span></span><br><span class="line">    <span class="keyword">let</span> session: <span class="type">URLSession</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 泛型方法：传入路径和模型类型，返回解码结果</span></span><br><span class="line">    <span class="keyword">func</span> <span class="title function_">request</span>&lt;<span class="type">T</span>: <span class="type">Decodable</span>&gt;(</span><br><span class="line">        <span class="keyword">_</span> <span class="params">path</span>: <span class="type">String</span>,</span><br><span class="line">        <span class="params">method</span>: <span class="type">String</span> <span class="operator">=</span> <span class="string">&quot;GET&quot;</span>,</span><br><span class="line">        <span class="params">body</span>: <span class="type">Encodable</span>? <span class="operator">=</span> <span class="literal">nil</span></span><br><span class="line">    ) <span class="keyword">async</span> <span class="keyword">throws</span> -&gt; <span class="type">T</span> &#123;</span><br><span class="line">        <span class="keyword">var</span> request <span class="operator">=</span> <span class="type">URLRequest</span>(url: <span class="type">URL</span>(string: baseURL <span class="operator">+</span> path)<span class="operator">!</span>)</span><br><span class="line">        request.httpMethod <span class="operator">=</span> method</span><br><span class="line">        request.setValue(<span class="string">&quot;application/json&quot;</span>, forHTTPHeaderField: <span class="string">&quot;Content-Type&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">let</span> body &#123;</span><br><span class="line">            request.httpBody <span class="operator">=</span> <span class="keyword">try</span> <span class="type">JSONEncoder</span>().encode(body)</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">let</span> (data, response) <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> session.data(for: request)</span><br><span class="line">        <span class="keyword">guard</span> <span class="keyword">let</span> http <span class="operator">=</span> response <span class="keyword">as?</span> <span class="type">HTTPURLResponse</span>,</span><br><span class="line">              (<span class="number">200</span><span class="operator">...</span><span class="number">299</span>).contains(http.statusCode) <span class="keyword">else</span> &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="type">APIError</span>.invalidResponse</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">try</span> <span class="type">JSONDecoder</span>().decode(<span class="type">T</span>.<span class="keyword">self</span>, from: data)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">enum</span> <span class="title class_">APIError</span>: <span class="title class_ inherited__">Error</span> &#123;</span><br><span class="line">    <span class="keyword">case</span> invalidResponse</span><br><span class="line">    <span class="keyword">case</span> decodeFailed</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 使用</span></span><br><span class="line"><span class="keyword">let</span> client <span class="operator">=</span> <span class="type">APIClient</span>(session: .shared)</span><br><span class="line"><span class="keyword">let</span> users: [<span class="type">User</span>] <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> client.request(<span class="string">&quot;/users&quot;</span>)</span><br></pre></td></tr></table></figure><h2 id="常见坑"><a href="#常见坑" class="headerlink" title="常见坑"></a>常见坑</h2><ol><li><strong>忘了 <code>task.resume()</code></strong>：回调版请求永远不执行，排查半天；</li><li><strong>在 <code>async</code> 里用了回调 API</strong>：可以用 <code>withCheckedContinuation</code> 桥接，或直接换 async 版本；</li><li><strong>解码失败</strong>：优先检查字段名映射（<code>CodingKeys</code>）、日期格式、类型是否匹配；</li><li><strong>主线程更新 UI</strong>：SwiftUI 的 <code>.task</code>&#x2F;<code>@MainActor</code> 会自动回主线程，UIKit 场景要 <code>DispatchQueue.main.async</code>。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>现代 iOS 网络请求的标准姿势：<strong>URLSession + async&#x2F;await + Codable 泛型封装</strong>。掌握”GET&#x2F;POST + 状态码校验 + 模型解码 + 通用网络层”这套组合，就足够覆盖绝大多数 App 的后端交互需求。</p>]]>
    </content>
    <id>https://blog.zhangsifan.com/posts/20260328ios/</id>
    <link href="https://blog.zhangsifan.com/posts/20260328ios/"/>
    <published>2026-03-27T16:00:00.000Z</published>
    <summary>iOS 网络请求实战教程：URLSession 的基础用法、async/await 版本、Codable 模型解码、错误处理与通用网络层封装。</summary>
    <title>iOS 网络请求实战：URLSession 与 Codable 解码</title>
    <updated>2026-03-27T16:00:00.000Z</updated>
  </entry>
</feed>
