<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://endless-solo-blog.pages.dev/</id>
  <title type="text">Endless Solo</title>
  <subtitle type="text">不休独舞</subtitle>
  <updated>2026-10-30T00:00:00.000Z</updated>
  <author><name>Lin</name></author>
  <link rel="alternate" href="https://endless-solo-blog.pages.dev/"/>
  <link rel="self" href="https://endless-solo-blog.pages.dev/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.9</generator>
    <entry>
      <id>https://endless-solo-blog.pages.dev/posts/heart-echo-frontend/</id>
      <title type="text">HeartEcho 前端：先把契约、请求和登录态搭稳</title>
      <published>2026-10-30T00:00:00.000Z</published>
      <updated>2026-10-30T00:00:00.000Z</updated>
      <author><name>Lin</name></author>
      <link rel="alternate" href="https://endless-solo-blog.pages.dev/posts/heart-echo-frontend/"/>
      <summary type="text">三人小组作业里我负责的前端基础设施——类型契约、请求层封装和路由守卫是怎么设计的，以及踩过的几个坑。</summary>
      <content type="html"><![CDATA[<p>HeartEcho（心回响）是一个 AI 情感陪伴 Web 应用，三人团队四周做完。核心想法是做一个”像真人一样”的 AI 伴侣——它记得你说过的话，有情绪，会主动找你聊天，还能自个儿发朋友圈。</p>
<p>我负责前端全链路。但真开始写页面之前，我先花了一天时间摸索：把环境装起来，再把骨架搭好——<strong>定类型契约、封请求层、配路由守卫</strong>。事后看，这一天花得最值。</p>
<p>这篇就讲这三块，以及各自踩过的坑。</p>
<section><h2>一、先定契约，再写页面<a href="#一先定契约再写页面"><span>#</span></a></h2><p>前后端并行开发时最怕的不是需求变更，是<strong>字段名对不上</strong>——你写 <code>username</code>，后端写 <code>user_name</code>，联调时才发现，改一圈。</p><p>所以第一件事是把后端的响应结构原样镜像到 TypeScript 里：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/** 后端统一响应体，对应 Go 侧 `Response[T]` */</span></div></div><div><div><div>2</div></div><div><span>export</span><span> </span><span>interface</span><span> </span><span>ApiResponse</span><span>&lt;</span><span>T</span><span> </span><span>=</span><span><span> </span><span>unknown</span><span>&gt; {</span></span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>code</span><span>:</span><span> </span><span>number</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>message</span><span>:</span><span> </span><span>string</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>data</span><span>:</span><span> </span><span>T</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>timestamp</span><span>:</span><span> </span><span>number</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>就这么简单一个泛型，但它定了三件事：</p><ol>
<li><strong>所有接口的返回都是这个形状</strong>，前端不用为每个接口猜结构</li>
<li><code>data</code> 是泛型，各接口自己填自己的业务类型</li>
<li>失败时 <code>data</code> 是 <code>null</code>，所以<strong>不要直接访问</strong>，统一交给请求层解包</li>
</ol><p>文件顶部我写了一句狠话：</p><blockquote><p>本文件是所有接口响应的类型源头，请求层与业务层统一从这里 import，不允许在别处重复定义响应体结构。</p></blockquote><section><h3>错误码也镜像一份<a href="#错误码也镜像一份"><span>#</span></a></h3><p>后端遵循「一 code 一 msg」：一个错误码只对应一句固定文案。前端跟着建了一张对照表：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>ErrorCode</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>Success</span><span>:</span><span> </span><span>200</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>ErrUnauthorized</span><span>:</span><span> </span><span>4010</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>ErrTokenInvalid</span><span>:</span><span> </span><span>4011</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>ErrTokenExpired</span><span>:</span><span> </span><span>4012</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>ErrRefreshInvalid</span><span>:</span><span> </span><span>4014</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>8</div></div><div><span><span>} </span><span>as</span><span> </span></span><span>const</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两个细节：</p><ul>
<li><strong>常量名和后端逐字一致</strong>，含 <code>Err</code> 前缀。这样两边比对的时候，可以直接拿文件名 diff，不用在脑子里做翻译。</li>
<li><code>as const</code> 不是为了好看，是为了让值变成字面量类型，后面 <code>ErrorCodeValue</code> 就能做穷尽检查——漏写一个 case 编译器会告诉你。</li>
</ul></section></section>
<section><h2>二、请求层：把麻烦全关进一个文件<a href="#二请求层把麻烦全关进一个文件"><span>#</span></a></h2><p>如果不在请求层做封装，每个接口调用大概长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>res</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>axios</span><span>.</span></span><span>get</span><span>(</span><span>'/personas'</span><span>)</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>res</span><span>.</span></span><span>data</span><span>.</span><span>code</span><span> </span><span>!==</span><span> </span><span>200</span><span><span>) { </span><span>ElMessage</span><span>.</span></span><span>error</span><span><span>(</span><span>res</span><span>.</span></span><span>data</span><span>.</span><span>message</span><span>); </span><span>return</span><span> }</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>list</span><span> </span><span>=</span><span><span> </span><span>res</span><span>.</span></span><span>data</span><span>.</span><span>data</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>每个调用重复一遍，而且 token 过期、刷新、重试全得自己处理。所以我封了一个 <code>http&lt;T&gt;()</code>，<strong>调用方拿到的直接是业务数据</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>http</span><span>&lt;</span><span>T</span><span><span>&gt;(</span><span>config</span></span><span>:</span><span> </span><span>AxiosRequestConfig</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>T</span><span>&gt; {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>response</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>instance</span><span>.</span></span><span>request</span><span>&lt;</span><span>ApiResponse</span><span>&lt;</span><span>T</span><span><span>&gt;&gt;(</span><span>config</span><span>)</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span><span> </span><span>response</span><span>.</span></span><span>data</span><span>.</span><span>data</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>调用方写 <code>const list = await request.get&lt;Persona[]&gt;('/personas')</code>，<code>code</code> 和 <code>data</code> 的拆解在底层做完了。</p><p>真正麻烦的是 token 刷新。这里踩了三个坑。</p><section><h3>坑一：刷新请求自己触发刷新，无限循环<a href="#坑一刷新请求自己触发刷新无限循环"><span>#</span></a></h3><p>最直觉的写法是在响应拦截器里判断：如果 <code>code === 4012</code>（token 过期），就调刷新接口，成功后重放原请求。</p><p>问题在于——<strong>刷新接口本身也是用这个 axios 实例发的</strong>。如果刷新时 token 也过期了（或者后端判定异常），它会再次返回 4012，于是又触发刷新……无限循环。</p><p>解法是开一个<strong>不挂任何拦截器的”裸”实例</strong>，专门用来刷新：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/** 刷新令牌专用的「裸」实例：不挂任何拦截器。</span></div></div><div><div><div>2</div></div><div><span><span> </span></span><span>*  这样刷新请求永远不会被自己的拦截器再处理一遍，从根上杜绝死循环。 */</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>refreshClient</span><span>:</span><span> </span><span>AxiosInstance</span><span> </span><span>=</span><span><span> </span><span>axios</span><span>.</span></span><span>create</span><span>({</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>baseURL</span><span>, </span><span>timeout</span><span>:</span><span> </span><span>15000</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>headers</span><span>:</span><span> { </span><span>'Content-Type'</span><span>:</span><span> </span><span>'application/json'</span><span> },</span></div></div><div><div><div>6</div></div><div><span>})</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这比在拦截器里加 <code>if (url === '/auth/refresh') return</code> 之类的判断干净得多——<strong>从结构上就不可能出现循环</strong>，而不是靠条件绕开。</p></section><section><h3>坑二：五个请求同时过期，刷新五次<a href="#坑二五个请求同时过期刷新五次"><span>#</span></a></h3><p>页面初始化时经常并发好几个请求。如果 token 恰好过期，五个请求会同时收到 4012，然后各自发起一次刷新——五次刷新请求，四次是浪费，而且可能互相覆盖 token。</p><p>用一个并发锁解决：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>let</span><span><span> </span><span>refreshPromise</span></span><span>:</span><span> </span><span>Promise</span><span><span>&lt;</span><span>string</span><span>&gt; </span></span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>async</span><span> </span><span>function</span><span> </span><span>refreshAccessToken</span><span>()</span><span>:</span><span> </span><span>Promise</span><span><span>&lt;</span><span>string</span><span>&gt; {</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>// 已经有人在刷新 —— 复用它的结果，排队等就行</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span><span> (</span><span>refreshPromise</span><span> </span></span><span>!==</span><span> </span><span>null</span><span>) {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span><span> </span><span>refreshPromise</span></span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>const</span><span> </span><span>pending</span><span> </span><span>=</span><span> </span><span>doRefresh</span><span>()</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>refreshPromise</span><span> </span><span>=</span><span><span> </span><span>pending</span></span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>try</span><span> {</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span> </span><span>await</span><span><span> </span><span>pending</span></span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>} </span><span>finally</span><span> {</span></div></div><div><div><div>15</div></div><div><span>    </span><span>// 无论成功还是失败，都要把锁摘掉；否则后面的刷新会被永久卡住</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>refreshPromise</span><span> </span><span>=</span><span> </span><span>null</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><code>finally</code> 那一句很关键。如果只写 <code>try</code> 不写 <code>finally</code>，一次刷新失败之后 <code>refreshPromise</code> 会永远不为 null，后面所有请求都卡在”等一个已经死掉的 Promise”上。</p></section><section><h3>坑三：重放不能无限重放<a href="#坑三重放不能无限重放"><span>#</span></a></h3><p>token 刷新成功后要重放原请求，但万一重放还是 4012（比如后端刚把这个 token 拉黑了），会再次触发刷新、再次重放。给请求配置打个标记，只重放一次：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>type</span><span> </span><span>RetryConfig</span><span> </span><span>=</span><span> </span><span>InternalAxiosRequestConfig</span><span> </span><span>&amp;</span><span><span> { </span><span>_retry</span></span><span><span>?</span><span>:</span></span><span> </span><span>boolean</span><span> }</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>if</span><span><span> (</span><span>code</span><span> </span></span><span>===</span><span><span> </span><span>ErrorCode</span><span>.</span></span><span>ErrTokenExpired</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>originalConfig</span><span>.</span></span><span>_retry</span><span> </span><span>!==</span><span> </span><span>true</span><span>) {</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>originalConfig</span><span>.</span><span>_retry</span><span> </span><span>=</span><span> </span><span>true</span></div></div><div><div><div>5</div></div><div><span>  </span><span>try</span><span> {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>await</span><span> </span><span>refreshAccessToken</span><span>()</span></div></div><div><div><div>7</div></div><div><span>    </span><span>return</span><span> </span><span>await</span><span><span> </span><span>instance</span><span>.</span></span><span>request</span><span><span>(</span><span>originalConfig</span><span>)</span></span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>} </span><span>catch</span><span> {</span></div></div><div><div><div>9</div></div><div><span>    </span><span>forceLogout</span><span>()</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>顺带一提：为什么 store 要在函数体里获取<a href="#顺带一提为什么-store-要在函数体里获取"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>instance</span><span>.</span></span><span>interceptors</span><span>.</span><span>request</span><span>.</span><span>use</span><span><span>((</span><span>config</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>// 延迟获取 store：Pinia 的 store 只能在 app 挂载之后使用。</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 写在拦截器函数体里，既保证时机正确，也避免与 auth.ts 形成模块级循环依赖。</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>auth</span><span> </span><span>=</span><span> </span><span>useAuthStore</span><span>()</span></div></div><div><div><div>5</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>6</div></div><div><span>})</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果在文件顶层 <code>const auth = useAuthStore()</code>，一是 Pinia 还没初始化会报错，二是 <code>request.ts</code> 和 <code>auth.ts</code> 会互相 import 形成循环依赖——表现是某个模块莫名其妙变成 <code>undefined</code>，排查起来很痛苦。</p></section></section>
<section><h2>三、路由守卫：缺省就当需要登录<a href="#三路由守卫缺省就当需要登录"><span>#</span></a></h2><p>守卫逻辑很短，但有一处值得说：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>router</span><span>.</span></span><span>beforeEach</span><span><span>((</span><span>to</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>authStore</span><span> </span><span>=</span><span> </span><span>useAuthStore</span><span>()</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>  </span><span>// 缺省视为需要登录：新增页面漏写标记时宁可多拦一次，也不能少一道防线</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span><span> (</span><span>to</span><span>.</span></span><span>meta</span><span>.</span><span>requiresAuth</span><span> </span><span>!==</span><span> </span><span>false</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>authStore</span><span>.</span></span><span>isLogin</span><span>) {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> { </span><span>name</span><span>:</span><span> </span><span>'Login'</span><span>, </span><span>query</span><span>:</span><span> { </span><span>redirect</span><span>:</span><span><span> </span><span>to</span><span>.</span></span><span>fullPath</span><span> } }</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>// 已登录还去登录页，直接送回聊天页</span></div></div><div><div><div>10</div></div><div><span>  </span><span>if</span><span><span> (</span><span>authStore</span><span>.</span></span><span>isLogin</span><span> </span><span>&amp;&amp;</span><span><span> (</span><span>to</span><span>.</span></span><span>name</span><span> </span><span>===</span><span> </span><span>'Login'</span><span> </span><span>||</span><span><span> </span><span>to</span><span>.</span></span><span>name</span><span> </span><span>===</span><span> </span><span>'Register'</span><span>)) {</span></div></div><div><div><div>11</div></div><div><span>    </span><span>return</span><span> { </span><span>name</span><span>:</span><span> </span><span>'Chat'</span><span> }</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>  </span><span>return</span><span> </span><span>true</span></div></div><div><div><div>15</div></div><div><span>})</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>注意判断条件写的是 <code>!== false</code> 而不是 <code>=== true</code>。</p><p>差别在于：<strong>加了新页面忘记写 <code>requiresAuth: true</code> 时</strong>，前者会拦住（安全），后者会放行（危险）。凡是涉及权限的判断，“缺省拒绝”永远比”缺省允许”好——漏拦一次是数据泄露，多拦一次只是多点一次登录。</p></section>
<section><h2>回头看<a href="#回头看"><span>#</span></a></h2><p>这三个模块加起来不到 400 行，但它们决定了后面所有页面写起来顺不顺：</p><ul>
<li><strong>契约文件</strong>让联调时不用反复确认字段名，出问题直接 diff 两边定义</li>
<li><strong>请求层</strong>让业务代码里看不到 <code>if (res.code !== 200)</code></li>
<li><strong>路由守卫</strong>让”忘记加鉴权”这种低级错误不会发生</li>
</ul><p>如果重来一次，我可能会把错误码表做成从后端生成，而不是手写镜像——手写总有漏更新的时候。不过在四周的周期里，手写一份、在注释里标明”权威来源是 errcode.go，改前先确认后端”，已经是性价比最高的方案了。</p></section>]]></content>
    </entry>
</feed>
