《从零开发校园墙微信小程序》第 4 篇。后端有了登录,现在到前端。这一篇讲项目怎么组织,重点是请求封装 ——它决定了整个小程序的稳定性。

为什么选 Taro + Vue3

微信小程序原生开发(WXML/WXSS)能做,但写大项目会很累:没有组件库生态、没有 TypeScript 类型提示、代码复用差。Taro 允许你用 Vue3 的语法写,然后编译成小程序代码:

  • <script setup> + TypeScript,开发体验和写网页一致;
  • 配合 NutUI 4 (京东出品的 Taro 版组件库)有现成的按钮、弹窗、表单;
  • 将来想出支付宝 / 抖音 / H5 版,同一份代码改个编译目标就行。

版本

"@tarojs/taro": "3.6.25",
"vue": "^3.2.40",
"@nutui/nutui-taro": "^4.3.0",
"pinia": "^3.0.3"

Taro 的几个包版本号必须一致 (@tarojs/* 全部锁 3.6.25),混用不同版本是编译报错的常见原因。

构建与运行

npm install
npm run dev:weapp      # 监听模式,改代码自动重编译
npm run build:weapp    # 产物在 dist/,用「微信开发者工具」导入这个目录

目录结构

src/
├── app.config.ts        # 页面注册、窗口样式
├── app.ts               # 入口
├── pages/
│   ├── index/           # 首页:投稿墙 + 课表弹窗
│   ├── sendweibo/       # 投稿页
│   ├── setting/         # 设置
│   ├── article/         # 文章列表
│   ├── articleDetail/   # 文章详情
│   ├── share/           # 分享落地页(免登录)
│   └── exam/            # 考试倒计时
├── components/          # 复用组件(列表项、通知、分享课表)
├── services/            # ← 所有后端接口封装(api.ts / weibo.ts / article.ts / share.ts)
├── stores/user.ts       # Pinia:登录态
└── utils/               # 纯函数工具(倒计时、日历导出、封面生成……)

一个原则:页面里不直接写**Taro.request**,所有网络请求都放在 services/。这样接口地址、鉴权、错误处理只有一处,改起来不会漏。
页面要在 app.config.ts 里注册才能访问:

export default defineAppConfig({
  pages: [
    'pages/index/index',
    'pages/sendweibo/index',
    'pages/setting/index',
    'pages/article/index',
    'pages/articleDetail/index',
    'pages/share/index',   // 分享落地页:好友点开分享卡片后看到的内容,免登录
    'pages/exam/index'
  ],
})

登录态:Pinia store

export const useUserStore = defineStore('user', {
  state: () => ({
    tokenName: '' as string,
    tokenValue: '' as string,
    isWeChatLoggedIn: false as boolean,
    isSchoolLoggedIn: false as boolean,
  }),
  actions: {
    init() { /* 从 Taro 本地存储恢复 */ },
    login(tokenName: string, tokenValue: string) {
      this.tokenName = tokenName;
      this.tokenValue = tokenValue;
      this.isWeChatLoggedIn = true;
      Taro.setStorageSync('tokenName', tokenName);
      Taro.setStorageSync('tokenValue', tokenValue);
    },
    logout() { /* 清状态 + 清存储 */ },
  },
});

logout 里有一个细节:除了 token,还要把课表缓存、**loginId**、教务会话 一并清掉,否则换账号登录后,会看到上一个人的课表——串数据是最严重的体验事故之一。

核心:requestWithAuth

所有需要登录的接口都走这个封装。它做三件事:

  1. 自动带上 token 请求头;
  2. 遇到 HTTP 401 (token 过期)时,静默重新登录 ;
  3. 重登成功后重试原请求一次 ,用户完全无感。

第一步:组装请求头

export const getHeader = () => {
  const tokenName = Taro.getStorageSync('tokenName');
  const tokenValue = Taro.getStorageSync('tokenValue');
  const header = {};
  if (tokenName != undefined && tokenName != '') {
    header[tokenName] = tokenValue;     // 头名由后端决定,前端不写死
  }
  return header;
};

第二步:重登,且同一时刻只重登一次

let refreshing = false;
let refreshPromise: Promise<void> | null = null;

const doRefresh = (): Promise<void> => {
  if (!refreshing) {
    refreshing = true;
    const store = useUserStore();
    refreshPromise = (async () => {
      const { code } = await Taro.login();                 // 拿新的微信 code
      const res = await Taro.request({
        url: `${API_ROOT}/user/login`,
        method: 'POST',
        data: { code },
      });
      const data = (res.data as any).data;
      if (data && data.tokenName && data.tokenValue) {
        store.login(data.tokenName, data.tokenValue);
      } else {
        throw new Error('微信重新登录失败');
      }
    })()
      .catch((e) => { store.logout(); throw e; })           // 重登都失败,清登录态
      .finally(() => { refreshing = false; });
  }
  return refreshPromise!;                                   // 并发的请求共用同一个 Promise
};

这里有个很关键的设计:**refreshing**标志 + 共享的**refreshPromise**。
想象首页一次性发了 5 个请求,token 刚好过期,5 个全部返回 401。如果每个都去重登,就会连续换 5 次 code、写 5 次 token,互相覆盖,还可能触发微信限频。有了共享 Promise,第一个触发重登,其余 4 个等它的结果 ,然后各自重试。

第三步:只有「HTTP 401」才重登,重试只一次

export const requestWithAuth = (options: any, retried = false): Promise<any> => {
  return new Promise((resolve, reject) => {
    Taro.request({
      ...options,
      header: { ...(options.header || {}), ...getHeader() },
      success: async (res: any) => {
        const body = res.data;
        // 仅 HTTP 401 才算 token 过期/未登录,触发重新登录;
        // 业务拦截(如评论违规)返回 HTTP 200 + body.code=401,不算登录失效,直接透传给业务层。
        if (res.statusCode === 401 && body && typeof body === 'object' && body.code === 401) {
          if (retried) {                       // 已经重试过:放弃,退出登录
            useUserStore().logout();
            reject(new Error('登录已失效,请稍后重试'));
            return;
          }
          try {
            await doRefresh();                 // 静默重登(并发请求共用同一次)
            requestWithAuth(options, true)     // 带 retried=true 重发一次
              .then((r) => resolve(r))
              .catch((e) => { useUserStore().logout(); reject(e); });
            return;
          } catch (e) {
            useUserStore().logout();
            const detail = (e && ((e as any).errMsg || (e as any).message)) || '未知原因';
            reject(new Error('重新登录失败:' + detail));
            return;
          }
        }
        resolve(res);
      },
      fail: (err: any) => reject(err),
    });
  });
};

这段代码里有两个非常容易写错 的地方:
① 判断条件是**res.statusCode === 401**且**body.code === 401**,而不是只看**body.code**。
后端有两种「401」,含义完全不同:

情形HTTP 状态码body.code含义前端动作
token 过期 / 未登录401401登录失效静默重登,再重试
业务拦截(内容违规、被封号)200401业务规则不通过不重登 ,把提示展示给用户

如果只看 body.code === 401,用户发了一条违规内容、被后端拒绝,前端会误以为「登录失效」,白白重新登录一次,然后再把同样违规的内容重发一次。所以一定要连 HTTP 状态码一起判断。
②**retried**参数防死循环。
第一次 401 → 重登 → 带 retried = true 重发。如果重发后依然 是 401(比如账号被封、后端有别的问题),就直接退出登录并报错,不会无限循环把后端打挂。
文件上传也有一份几乎一模一样的 uploadWithAuth(用 Taro.uploadFile),区别只是返回的 res.data 是字符串,需要先 JSON.parse。

另一个小细节:「失败」和「空」要能区分

后端对「上游不可用」用了专门的状态码。以获取微博评论为例:

// 后端约定:拿到评论返回 code=200;上游不可用(微博频控/配额耗尽)返回 code=502 且 data=null。
// 必须在这里抛出,否则会被当成「加载成功但没有评论」——
// 评论区静默显示「还没有评论」,用户完全看不出是故障。
if (body.code !== 200) {
  const err: any = new Error(body.message || '评论加载失败,请稍后重试');
  err.code = body.code;
  throw err;
}

这是一条通用经验:「失败」和「空」一定要能区分 。把接口故障静默当成空列表,是最难排查的一类 bug。

小结

  1. Taro + Vue3 + NutUI 是写小程序的舒适组合,Taro 全家桶版本要锁一致;
  2. 页面不直接发请求,统一放 services/;
  3. 请求封装三件事:带 token、HTTP 401 时重登、只重试一次;
  4. 重登要去重 (共享 Promise),重试要限次 ,并且区分 HTTP 401 与业务 401 ;
  5. 退出登录要清所有 账号相关缓存;
  6. 失败和空数据必须能区分。

下一篇是整个系列的重头戏:一条投稿,从点击「发布」到出现在墙上,中间经历了什么。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏