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)的价值。