简介:基于Vue框架的农家书屋小程序设计源码,是一套面向小程序开发者和前端学习者的完整工程,适合用Vue技术栈构建乡村数字化阅读服务场景。资源共含941个文件,压缩包约25.94MB,主要涵盖248个JavaScript脚本、229个Vue组件、176个JSON配置、129个Markdown说明文档、86个PNG图片及SCSS/CSS样式文件等,覆盖页面结构、交互逻辑、路由配置、样式设计和文档说明,能支撑从环境配置到功能实现的全流程学习。已有201人学习下载。读者可从中获取系统的Vue组件化开发思路、小程序配置文件组织方式、样式布局与资源管理方法,以及真实项目中的目录结构与工程化细节,适合希望以完整项目为参照、提升小程序开发与前端工程能力的开发者。
1. 把 Vue 框架和农家书屋放在一起,说的是什么
微信原生小程序并不接受 Vue 的模板语法,所谓“基于 Vue 框架的小程序源码”,落地时基本走 uni-app 或 Taro 两条路。农家书屋这类业务选 uni-app 更常见,项目规模小、团队往往只有一两个人,Vue 写法和 H5 端复用能把维护成本压到最低。书屋分布在乡镇和村庄,藏书以农技、童书、大众读物为主,借阅量不大,但书目查询、预约借阅这类轻交互很刚性;做成小程序,村民不用跑到屋里扑空,管理员也能少做手工台账。
这篇面向两类读者:接乡村信息化项目的外包工程师,以及想用 Vue 认真写一遍小程序的新手。前者能快速出活,后者能看清数据、页面、配置在 uni-app 里是如何被组织起来的。
2. 技术选型与工程结构:用 uni-app 搭出农家书屋小程序
2.1 为什么选 uni-app,而不是直接写原生 Page
uni-app 属于编译时框架:开发时写 Vue 组件,<template>被编译成小程序的 WXML,<script setup>里的逻辑编译进 Page 的 methods 和 data,最终产物和原生小程序没有区别。这套机制决定了两件事:第一,组件复用、状态管理、computed 这些 Vue 心智模型可以继续用;第二,小程序的限制也原样保留,比如没有 window 和 document,不能直接操作 DOM,ref 拿到的只是组件实例而不是真实节点。
选 uni-app 还有一个现实理由:农家书屋的线上化往往不止一个小程序,村委会或者承接项目的人通常后续还要一个 H5 展示页,甚至要挂一个简单的图书购买入口。同一套 Vue 组件编译到 H5 和微信端,比维护两套原生代码省事得多。如果后续要扩展成小程序商城的形态,直接加一个商品模块的页面,购物车和订单接口往 api 目录里补就行。
如果团队里有人提出用 Taro,也说得通。Taro 的 React 写法和更细的多端适配在大型项目里更有优势,但书屋源码的典型形态是页面少、逻辑浅、依赖轻,uni-app 的报错更直白,周边插件也更贴近国内小程序生态。我的建议是:别为了“更先进”选 Taro,除非团队主力是 React 背景。
2.2 工程目录:源码里哪些文件是核心
一个标准 uni-app 工程解压后大概长这样:
my-book-app/ ├── pages/ │ ├── index/ # 书屋首页(书架与推荐) │ ├── books/ # 图书列表与筛选 │ ├── detail/ # 图书详情与借阅 │ └── mine/ # 我的借阅记录 ├── components/ # 自定义组件(书架卡、空状态) ├── api/ # 接口定义与请求封装 ├── utils/ # 本地缓存、日期格式化 ├── static/ # 图片等静态资源 ├── App.vue # 应用生命周期与全局样式 ├── main.js # Vue 实例入口 ├── pages.json # 页面路由与 tabBar 配置 ├── manifest.json # 各端 appid 与应用配置 └── uni.scss # 全局样式变量其中 pages.json 和 manifest.json 是 Vue 项目里没有的两个配置文件。pages.json 是路由表加页面样式的混合体,小程序没有 Vue Router,页面之间的跳转靠的是路径字符串,新增页面必须在 pages 数组里注册。manifest.json 则统一管理微信端 appid、H5 端路由模式这些平台差异信息,小程序端要改 AppID 或权限声明,都去这里找。
api 和 utils 这两个目录容易被新手忽略,但它们决定了后续能不能快速把 mock 数据换成真实接口。约定是:组件里不允许直接出现网络请求代码,所有请求都从 api 目录导出,这样替换接口、加日志、统一鉴权都只动一处。
2.3 pages.json 里的书架导航配置
首页、图书列表、我的借阅三个页面,用 tabBar 放在底部是最直观的做法。一个常见配置:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "农家书屋" } }, { "path": "pages/books/books", "style": { "navigationBarTitleText": "全部图书" } }, { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "图书详情" } }, { "path": "pages/mine/mine", "style": { "navigationBarTitleText": "我的借阅" } } ], "globalStyle": { "navigationBarTextStyle": "white", "navigationBarBackgroundColor": "#3A7D44", "backgroundColor": "#F5F5F5" }, "tabBar": { "color": "#666666", "selectedColor": "#3A7D44", "list": [ { "pagePath": "pages/index/index", "text": "书架" }, { "pagePath": "pages/books/books", "text": "找书" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] } }navigationBarBackgroundColor 建议和书屋的品牌色统一,绿色系最容易和“书屋”联想。globalStyle 里的配置是全部页面的默认值,单页要覆盖时,在 pages 数组对应项的 style 里写同名属性即可。tabBar 的 list 最多配五个,每个 pagePath 都必须已经出现在 pages 数组里,顺序不一致时编译直接报错。detail 页不要放进 tabBar,详情页从列表跳进来,再挂一个底部 tab 会很奇怪。
提示:tabBar 的 iconPath 和 selectedIconPath 是可选的,不配置时底部只显示文字,适合先跑通逻辑、后补设计的阶段。如果要配图标,图片必须是 png 且体积在 40KB 以内,否则部分基础库会静默丢失图标。
3. 书架首页的数据与渲染:从 mock 数据到真实列表
3.1 先定义图书的数据模型
小程序端的数据不建议一开始就接后端,先把静态数据跑通,后面只换接口。图书模型用一组 mock 就够:id 是传给后端的唯一标识,cover 是封面路径,stock 是当前可借册数。
// utils/book-data.js export const books = [ { id: 1, title: '水稻高产栽培技术', category: '农技', author: '省农科院专家组', stock: 5, cover: '/static/covers/rice.png', summary: '从育秧到收割的田间管理手册,适合本地区气候条件' }, { id: 2, title: '幼儿睡前故事集', category: '儿童', author: '儿童文学出版社', stock: 8, cover: '', summary: '适合 3-6 岁亲子共读的短篇故事合集' }, { id: 3, title: '常见病家庭护理', category: '健康', author: '乡村卫生室整理', stock: 2, cover: '', summary: '村民日常健康知识问答,通俗易懂' } ]字段设计上要刻意避开那些“看起来很全但用不上”的属性,比如出版社、ISBN 都先不建。书屋的书目量级通常只有几百本,一本书对应多条记录的关联表结构在小程序端没必要展开。任何你不想在小程序里维护的字段,就不要出现在这个模型里。
3.2 首页用组合式 API 拉取并渲染
页面代码同时包含模板、样式和逻辑:
<template> <view class="container"> <view class="category-bar"> <view v-for="cat in categories" :key="cat" class="category-item" :class="{ active: cat === currentCategory }" @click="switchCategory(cat)" >{{ cat }}</view> </view> <view class="book-list"> <view v-for="book in filteredBooks" :key="book.id" class="book-card"> <image class="book-cover" :src="book.cover || '/static/covers/default.png'" mode="aspectFill" /> <view class="book-info"> <text class="book-title">{{ book.title }}</text> <text class="book-meta">{{ book.author }} · 可借 {{ book.stock }} 本</text> </view> </view> </view> </view> </template> <script setup> import { ref, computed } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { books as mockBooks } from '@/utils/book-data.js' const categories = ['全部', '农技', '儿童', '健康'] const currentCategory = ref('全部') const bookList = ref([]) const filteredBooks = computed(() => { if (currentCategory.value === '全部') return bookList.value return bookList.value.filter((book) => book.category === currentCategory.value) }) function switchCategory(cat) { currentCategory.value = cat } onLoad(() => { bookList.value = mockBooks }) </script>onLoad 是 uni-app 从微信生命周期映射过来的钩子,页面创建后立即执行,适合做初始化。filteredBooks 是 computed 计算属性,分类切换时只改 currentCategory 这一个响应式变量,列表重新计算,不需要手动操作 DOM。这里刻意没有在模板里写 v-if 来判断空数据,是因为空状态用统一组件处理,后面要换成“暂无书目”时只改一处。image 的 mode 设为 aspectFill,封面图不会因为尺寸不一致而变形。
3.3 上拉加载、下拉刷新要配着设置
小程序原生提供 onReachBottom 和 onPullDownRefresh,但两个机制默认都不开。onPullDownRefresh 需要在页面 style 里开启:
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "农家书屋", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } }onReachBottomDistance 单位是 px,表示距离底部多少像素时触发触底。数值太小,手指还没划到就没了加载提示;太大,读者还没看完当前内容就开始请求下一页。书屋这类信息流页面我一般取 80。onPullDownRefresh 只在 json 里开启还不够,页面逻辑里必须调用 uni.stopPullDownRefresh() 手动收尾,否则下拉动画会一直转。
页面里的对接逻辑:
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { getBooks } from '@/api/books.js' const page = ref(1) const pageSize = 10 const loading = ref(false) async function loadBooks(reset = false) { if (loading.value) return loading.value = true const next = reset ? 1 : page.value const res = await getBooks({ page: next, pageSize }) bookList.value = reset ? res.list : [...bookList.value, ...res.list] page.value = next + 1 loading.value = false if (reset) uni.stopPullDownRefresh() } onPullDownRefresh(() => loadBooks(true)) onReachBottom(() => loadBooks(false))组合式 API 的生命周期钩子直接在 setup 顶层调用,不需要嵌套在 onLoad 里,这是 Vue3 写法和 Vue2 选项式最大的习惯差异。loading 锁防止触底事件和下拉刷新同时触发导致列表重复;reset 参数决定是整体替换还是追加。getBooks 就是下一步要做的接口封装。
4. 接口层与本地缓存:弱网环境下也能查书
4.1 用 uni.request 封装一个请求实例
小程序没有 fetch,统一走 uni.request。直接写业务请求会把超时、错误提示、loading 逻辑散落在每个页面,所以一般会在 api 目录里放一个基础封装:
// api/request.js const BASE_URL = 'https://api.example.com' export function request(path, options = {}) { return new Promise((resolve, reject) => { uni.request({ url: `${BASE_URL}${path}`, method: options.method || 'GET', data: options.data || {}, timeout: 8000, header: { 'Content-Type': 'application/json', ...options.header }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) } else if (res.statusCode === 401) { uni.navigateTo({ url: '/pages/login/login' }) reject(res) } else { uni.showToast({ title: `请求失败(${res.statusCode})`, icon: 'none' }) reject(res) } }, fail: (err) => { // 弱网或断网时兜底走本地缓存,由调用方决定 reject(err) } }) }) }timeout 在农家书屋场景里要重视,村委会的 Wi-Fi 和乡镇移动网络抖动都很常见,默认 60 秒超时会让页面卡住很久。8 秒是一个相对平衡的值:接口没到,缓存还没有,页面就显示骨架;接口到了再刷新。statusCode 的判断不能省略,uni.request 的 success 分支只代表 HTTP 层完成,不代表业务成功。401 分支单独处理,是为了将来接入读者证鉴权时不用回改每个页面。
4.2 书屋业务的接口设计
接口路径按资源命名,后端只要跟着这份约定实现,前端就能直接对接:
| 功能 | 方法 | 路径 | 主要参数 | 返回字段 | | 书目查询 | GET | /api/books | keyword, category, page, pageSize | list, total, hasMore | | 借阅登记 | POST | /api/borrow | bookId, readerId | borrowId, dueDate | | 归还登记 | POST | /api/return | borrowId | returnedAt | | 我的借阅 | GET | /api/borrows | readerId, status | list |
keyword 是书名模糊搜索,category 对应页面顶部分类。hasMore 是比 total 更实用的字段,它直接告诉前端“后面还有没有”,省去前端用 page*pageSize 和 total 比较的边界处理。借阅接口的返回里带 dueDate,前端能直接用它计算倒计时,不用自己维护借期规则。
页面调用时只关心数据,不关心 HTTP:
// api/books.js import { request } from './request' export const getBooks = (params) => request('/api/books', { method: 'GET', data: params }) export const borrowBook = (bookId, readerId) => request('/api/borrow', { method: 'POST', data: { bookId, readerId } })4.3 本地缓存兜底:没有网也要能看到上一批书目
缓存策略是书屋小程序比商城小程序更该做的一件事。读者可能站在书屋门口,信号只有一格,这时候书目列表已经是缓存的了,他至少能查到自己想借的书在不在架。基础写法是:
function getCached(key) { const raw = uni.getStorageSync(key) if (!raw) return null const { data, expire } = raw if (Date.now() > expire) { uni.removeStorageSync(key) return null } return data } function setCached(key, data, ttlMinutes = 60 * 24) { uni.setStorageSync(key, { data, expire: Date.now() + ttlMinutes * 60 * 1000 }) }缓存有效期设为 24 小时的理由:书屋书目变动频率很低,新书到馆一般以周为单位,一天刷新一次足够。ttl 参数保留出来,是因为借阅记录这类敏感数据不适合长时间缓存,调用时传 10 分钟即可。缓存的 key 建议带版本号,比如books_v1,将来数据结构变了,旧缓存读出来会直接因字段不符合预期而报错,带上版本号可以自然失效。具体使用时,在 getBooks 的 fail 分支里先 getCached('books_v1'),有值就 resolve 缓存,没有才 reject。
5. 源码上线的最后一公里:编译、备案与常见报错
5.1 从源码到微信开发者工具
uni-app 项目编译到微信小程序需要三步:安装依赖、编译、导入。命令行方式最通用:
npm install npm run build:mp-weixin产物默认在dist/build/mp-weixin目录。打开微信开发者工具,选择“导入项目”,目录指向这个文件夹,AppID 填小程序的 appid,没有就先选测试号。npm run dev:mp-weixin是开发模式,改动代码自动重新编译,适合连着开发者工具调试;build 是压缩产物,提交审核前必须再过一遍。如果拿到的是 HBuilderX 工程,目录里没有 package.json,直接用 HBuilderX 打开项目根目录,在“运行”菜单里选“运行到小程序模拟器”,效果一样。
导入后第一件事是确认“详情-本地设置”里的“不校验合法域名”是否打开。开发阶段这个选项能避免频繁被拦截,但上线前必须关闭它,并在小程序后台配置 request 的合法域名。域名必须备案,且只支持 HTTPS。冷启动默认展示的页面就是 pages.json 里第一个页面,如果想先放一个欢迎引导页,把引导页路径挪到 pages 数组首位即可,这是最省事的“修改刚进入的加载页面”的做法。
注意:编译成功不代表能正常运行。常见情况是开发者工具控制台没有报错,页面空白,先打开调试面板看 network 请求,绝大多数白屏来自域名校验失败。
5.2 小程序备案的备注怎么填
备案是上线绕不开的环节。农家书屋的性质要在“服务内容”里写清楚,别图省事只填两个字“阅读”。建议写“面向农村读者的图书借阅、书目查询和阅读活动信息发布”,十几秒的事,后台审核的语义理解会准确很多。
主办者类型按实际主体选。以书屋名义备案,证件就填单位的登记证书或营业执照,封面照片拍清楚即可;以个人名义备案,需要个人身份证和相关使用证明。类目选择上,“生活服务-图书馆”或者“教育-其他”都能覆盖图书借阅,审核通过率差别不大,关键是和备注里的描述保持一致。
5.3 Vue 打包后布局异常的排查顺序
“vue 打包后布局异常”是出现频率很高的搜索词,实际上多数和小程序运行时有关,和 Vue 本身无关。
第一类:图片拉伸或错位。小程序端 image 组件默认是 320px 高,不设置 height 就会把封面拉变形。解决方式是给 image 设置固定宽高,并加 mode="aspectFill",这在第 3 章的模板里已经示范过。
第二类:rpx 和 px 混用。rpx 在不同机型上等比缩放,px 是固定物理像素,混用容易出现“开发工具里正常,真机上右移一截”的情况。建议栅格和间距全部用 rpx,边框用 px,写一条约定进项目 README,比事后排查成本低。
第三类:白屏后下拉刷新恢复。多发生在 tabBar 页面,因为 tabBar 页面在启动时会被提前创建,onLoad 里的初始化如果依赖了另一个页面的数据,时序就不可控。对策是把初始化事件挂到 onShow 上,并加一个 beforeInit 的脏标记,只执行一次。
6. 提升成品感:动态标题、骨架屏与分享卡片
6.1 动态修改页面标题
书目详情页根据书名设置标题,避免所有页面都叫“农家书屋”:
uni.setNavigationBarTitle({ title: book.title })页面 config 里配置的 navigationBarTitleText 是默认值,运行时调用这个 API 会覆盖它。要特别注意执行时机,必须在数据加载完成后调用,否则拿到的 book 还是空对象,标题会被设成 undefined。
6.2 给书架列表加骨架屏
列表加载的几百毫秒里,骨架屏比 loading 转圈更有反馈感。用纯 CSS 实现,不引入组件库:
.skeleton-card { display: flex; padding: 24rpx; background: #fff; margin-bottom: 16rpx; } .skeleton-block { background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }配合 v-for 生成 6 个骨架卡片,数据返回后 bookList 有值,骨架就消失。首页交互完全依赖 Vue 的条件渲染,不需要额外状态管理。颜色用 #eee 和 #f5f5f5 这种低对比度组合,太亮的灰色在真机上会显得页面“脏”。
6.3 配置分享卡片
onShareAppMessage 是微信给页面的“自来水”入口,配置 title 和 path:
onShareAppMessage(() => ({ title: `我正在农家书屋借《${book.value.title}》,一起看看吧`, path: `/pages/detail/detail?id=${book.value.id}` }))path 必须带参数,否则好友点开只会落在首页,前面配置的动态标题和详情数据都会落空。卡片的 imageUrl 不传时,默认截图当前页面,对带封面图的详情页来说效果已经够用。分享出去后,如果目标是“让更多人用”,就在列表页和详情页都接上这个钩子;如果目标是“让管理后台统计转化”,记得在 App.vue 的 onLaunch 里读取 query 中的分享来源参数并上报。
本文还有配套的精品资源,点击获取