← 全部文章
uni-app实战综合面试

uni-app 系列(八):实战综合——从零搭一个「附近好物」小应用

2026年8月8日 · 20 分钟

写在前面

前七篇讲的是"点",这一篇把它们连成"线"——从零做一个**「附近好物」迷你应用**:首页展示附近物品列表(下拉刷新 + 触底加载),点进详情,登录后可发布。每一步都标注它用到系列里的哪个知识点。

一、需求拆解与页面规划

页面功能复用的知识点
首页 list附近物品列表、下拉刷新、触底加载生命周期、组件、rpx
详情 detail展示单个物品路由传参
发布 publish登录后发布物品API、原生能力
登录 login各端登录条件编译、状态管理

二、pages.json:先把路由骨架搭好(第二篇)

{
  "pages": [
    { "path": "pages/list/list", "style": { "navigationBarTitleText": "附近好物", "enablePullDownRefresh": true } },
    { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "详情" } },
    { "path": "pages/login/login", "style": { "navigationBarTitleText": "登录" } }
  ],
  "subPackages": [
    { "root": "pkg-publish", "pages": [ { "path": "publish/publish" } ] }
  ],
  "tabBar": {
    "list": [
      { "pagePath": "pages/list/list", "text": "附近" },
      { "pagePath": "pages/user/user", "text": "我的" }
    ]
  }
}

注意:enablePullDownRefresh: true 开启首页下拉刷新;把低频的发布页放进分包 pkg-publish(第六篇),主包更轻。

三、请求层与状态(第四篇)

// utils/request.js —— Promise 化 + token 注入 + 统一解包
const BASE = "https://api.example.com";
export function http(url, method, data, auth = true) {
  const header = { "Content-Type": "application/json" };
  if (auth) {
    const t = uni.getStorageSync("token");
    if (t) header.Authorization = "Bearer " + t;
  }
  return new Promise((resolve, reject) => {
    uni.request({ url: BASE + url, method, data, header,
      success: (res) => {
        if (res.statusCode === 401) { uni.navigateTo({ url: "/pages/login/login" }); return reject(); }
        res.data.code === 0 ? resolve(res.data.data)
          : (uni.showToast({ title: res.data.message, icon: "none" }), reject());
      },
      fail: reject,
    });
  });
}
// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useUserStore = defineStore("user", () => {
  const token = ref("");
  const isLogin = computed(() => !!token.value);
  function setToken(t) { token.value = t; uni.setStorageSync("token", t); }
  function restore() { token.value = uni.getStorageSync("token") || ""; }
  return { token, isLogin, setToken, restore };
});
// App.vue —— 启动恢复登录态(第二篇应用生命周期 + 第四篇持久化)
onLaunch(() => useUserStore().restore());

四、列表页:把生命周期 + 组件 + 分页串起来

<!-- pages/list/list.vue -->
<template>
  <view class="page">
    <goods-card v-for="it in list" :key="it.id" :item="it" @tap="openDetail" />
    <text v-if="loading" class="tip">加载中…</text>
    <text v-else-if="finished" class="tip">没有更多了</text>
  </view>
</template>

<script setup>
import { ref } from "vue";
import { onLoad, onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { http } from "@/utils/request";

const list = ref([]);
const loading = ref(false);
const finished = ref(false);
let cursor = null;

async function load(refresh = false) {
  if (loading.value) return;
  loading.value = true;
  try {
    const loc = await uni.getLocation({ type: "gcj02" });   // 第五篇:gcj02
    if (refresh) { cursor = null; finished.value = false; }
    const res = await http(`/items/nearby?lat=${loc.latitude}&lng=${loc.longitude}&cursor=${cursor ?? ""}`, "GET", null, false);
    list.value = refresh ? res.list : [...list.value, ...res.list];
    cursor = res.nextCursor;
    finished.value = res.nextCursor == null;
  } finally { loading.value = false; }
}

onLoad(() => load(true));                    // 首屏加载(只一次)
onShow(() => { /* 从发布页返回时可刷新 */ }); // 每次可见触发
onPullDownRefresh(async () => { await load(true); uni.stopPullDownRefresh(); }); // 下拉刷新
onReachBottom(() => { if (!finished.value) load(); });                          // 触底加载

function openDetail(id) { uni.navigateTo({ url: `/pages/detail/detail?id=${id}` }); }
</script>

<style>
.page { padding: 24rpx; }                    /* 第三篇:rpx */
.tip { text-align: center; color: #9ca3af; font-size: 24rpx; padding: 24rpx; }
</style>

这一页几乎用到了每一篇的知识:onLoad/onShow 区分(二)、easycom 组件 goods-card(三)、rpx(三)、请求层(四)、gcj02 定位(五)、触底分页(六)。

五、easycom 组件:goods-card(第三篇)

<!-- components/goods-card/goods-card.vue —— 免注册直接用 -->
<template>
  <view class="card" @tap="$emit('tap', item.id)">
    <image class="thumb" :src="item.cover" mode="aspectFill" />
    <view class="info">
      <text class="title">{{ item.title }}</text>
      <text class="free">免费 · {{ item.distanceText }}</text>
    </view>
  </view>
</template>
<script setup>
defineProps({ item: { type: Object, required: true } });
</script>
<style>
.card { flex-direction: row; background: #fff; border-radius: 16rpx; margin-bottom: 20rpx; overflow: hidden; }
.thumb { width: 200rpx; height: 200rpx; }
.info { flex: 1; padding: 20rpx; justify-content: space-between; }
.title { font-size: 30rpx; color: #1f2937; }
.free { font-size: 24rpx; color: #0d9488; }
</style>

六、详情页:接收路由参数(第二篇)

<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { http } from "@/utils/request";
const item = ref(null);
onLoad((query) => {                           // query.id 是字符串
  http(`/items/${query.id}`, "GET", null, false).then((d) => (item.value = d));
});
</script>

七、登录与发布:条件编译 + 原生能力(第三、五篇)

// 登录页:各端分流,后端换统一 token
async function login() {
  let credential;
  // #ifdef MP-WEIXIN
  credential = (await uni.login({ provider: "weixin" })).code;
  // #endif
  // #ifdef H5
  credential = await phoneCode();
  // #endif
  const { token } = await http("/auth/login", "POST", { credential }, false);
  useUserStore().setToken(token);
  uni.navigateBack();
}
// 发布页:登录校验 + 选图上传 + 定位
async function submit() {
  if (!useUserStore().isLogin) return uni.navigateTo({ url: "/pages/login/login" });
  const { tempFilePaths } = await uni.chooseImage({ count: 9 });
  // 生产:先直传 OSS 换 url(第五篇)
  const loc = await uni.getLocation({ type: "gcj02" });
  await http("/items", "POST", { title: title.value, images: tempFilePaths, lat: loc.latitude, lng: loc.longitude });
  uni.showToast({ title: "已提交审核" });     // UGC 需内容审核(第六篇合规)
  uni.switchTab({ url: "/pages/list/list" });
}

八、上线前 checklist(第六篇)

  • 主包是否 < 2MB?发布页等低频模块是否已分包?
  • 列表是否分页 + 图片 lazy-load?setData 数据量是否可控?
  • 登录态是否持久化 + 启动 restore?
  • UGC 是否接内容审核?定位/隐私权限是否在 manifest 声明?
  • 各端 appid、API 基址是否用条件编译区分?
  • 支付/上传签名是否都在后端?

九、本篇串起来的知识地图

pages.json 路由(二) ─┐
请求层 + Pinia(四) ──┼─► 列表页:生命周期(二)+组件(三)+rpx(三)+分页(六)+定位(五)
条件编译(三) ───────┤
原生能力(五) ───────┴─► 登录/发布 ─► 分包与发布合规(六)
                             跨端原理(一) 贯穿始终

十、综合面试题

Q1:让你从零搭一个 uni-app 列表页,你会怎么组织?

A:① 在 pages.json 注册页面并开 enablePullDownRefresh;② onLoad 拉首屏、onPullDownRefresh 下拉刷新、onReachBottom 触底加载下一页,用游标/页码分页;③ 列表项抽成 easycom 组件,父组件通过 props 传数据、$emit 上抛点击;④ 请求走统一封装的请求层(token 注入、错误处理);⑤ 样式用 rpx 适配;⑥ 从其他页返回需刷新的状态放 onShow。这样职责清晰、性能可控。

追问:从详情页返回列表页,想刷新某条数据的状态,放哪个钩子?

A:放 onShow。因为返回列表页不会再触发 onLoad(页面没被重新创建),只会触发 onShow。在 onShow 里做增量刷新(如重新拉取该条或全部的最新状态)。若数据是全局共享的,更好的方式是放 Pinia,详情页操作后直接改 store,列表页响应式自动更新,无需手动刷新。


Q2:这个小应用要上微信小程序,主包超了 2MB,你怎么处理?同时列表卡顿怎么办?

A:体积:把发布、设置等低频页面用 subPackages 拆分包,主包只留 tabBar 页和首页;图片资源放 CDN 不打进包;用 preloadRule 预下载高概率进入的分包。卡顿:列表触底分页而非全量渲染、图片 lazy-load、控制 setData(只更新变化字段、合并高频更新、避免一次性塞入超大数组),极端长列表可上虚拟列表或 nvue。


Q3:这个应用要同时上微信小程序和 App,登录怎么设计?

A:用条件编译分流各端登录拿临时凭证(微信小程序 uni.login 拿 code、App 拉起微信授权 / iOS 接 Apple、H5 手机号),统一交给后端;后端用凭证换 openid/unionid,并用 UnionID 或手机号归并到同一用户,签发自有 JWT;客户端存这个 token 并在请求层自动注入、App.vue 启动时 restore。全程业务只认自有 token,与登录方式解耦,新增一个端只需加一段条件编译。

追问:如果要求"用户在小程序登录后,装了 App 也能是同一个账号",靠什么打通?

A:靠微信开放平台的 UnionID——同一开放平台账号下,用户在你的小程序、App、公众号里 UnionID 是一致的,后端据此归并为同一 users.id。若还想跨微信/非微信打通(如又用手机号登录过),则以手机号作为账号锚点,把多个 provider 身份挂到同一用户。这正是"一人多身份"账号模型(users + user_identities)的价值。

相关水晶