uniapp 开发博客应用实战:从基础到进阶的技术探索
在跨端开发的浪潮中,uniapp 凭借一套代码多端运行的优势,成为开发者构建全平台应用的首选框架。本文将围绕博客应用开发,从网络请求、组件化设计到路由与状态管理,逐层拆解技术实现细节,深度剖析开发中的核心问题与解决方案。
2.3 blogs 列表视图与分页加载:构建流畅的内容流
一、需求拆解与技术选型
目录
2.3.1 uni.request 网络请求封装:打造健壮的通信层
2.3.2 CommonJS 与 ES6 模块规范:模块化开发的底层逻辑
2.5 uni 组件使用及 blogs 搜索功能:组件化思维与交互优化
博客列表需支撑 实时更新(下拉刷新)、批量加载(触底分页)、长列表渲染 三大核心场景:
- 下拉刷新:利用 uniapp 原生生命周期
onPullDownRefresh
,配合 UI 反馈(如刷新动画)。 - 触底分页:通过
onReachBottom
监听滚动,结合分页参数(page
/size
)实现数据增量加载。 - 长列表优化:借助
uni-ui
的uni-list
组件(内置懒渲染)或虚拟列表(处理万级数据),避免内存溢出。
二、核心功能实现细节
1. 下拉刷新的完整配置
-
步骤 1:页面配置启用
在pages.json
中为目标页面开启下拉刷新,并定义背景样式:{ "path": "pages/blog/list", "style": { "enablePullDownRefresh": true, "backgroundColor": "#f5f5f5", // 下拉背景色 "backgroundTextStyle": "dark" // 刷新文字颜色(light/dark) } }
-
步骤 2:生命周期处理
在页面的onPullDownRefresh
中重置分页参数,请求数据后必须停止刷新动画:onPullDownRefresh() { this.page = 1 this.fetchBlogs().finally(() => { uni.stopPullDownRefresh() // 关键:结束刷新动画 }) }
2. 触底分页的防重复请求
用户快速滑动到底部时,可能连续触发onReachBottom
,导致重复请求。通过isLoading
标记解决:
js
data() {
return {
page: 1,
isLoading: false, // 请求中标记
loadingStatus: 'more' // uni-load-more状态:more/noMore/loading
}
},
onReachBottom() {
if (this.isLoading || this.loadingStatus === 'noMore') return
this.isLoading = true
this.page++
this.fetchBlogs().finally(() => {
this.isLoading = false
})
}
3. 长列表渲染优化(虚拟列表实践)
若博客数量极多(如万条),直接v-for
会导致内存溢出。采用 虚拟列表 方案(以vue-virtual-scroller
为例):
-
安装依赖:
npm install vue-virtual-scroller --save
-
组件封装:
vue
<template> <VirtualScroller :items="blogs" :item-size="50" class="virtual-list" > <template #default="{ item }"> <uni-list-item :title="item.title" :note="item.desc" /> </template> </VirtualScroller> </template> <script> import { VirtualScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' export default { components: { VirtualScroller }, props: { blogs: Array } } </script> <style scoped> .virtual-list { height: 100vh; } /* 固定高度,否则虚拟滚动不生效 */ </style>
2.3.1 uni.request 网络请求封装:打造健壮的通信层
一、封装的三层价值
- 统一管理:请求头(如 Token)、错误处理、BaseURL 集中配置。
- 易于扩展:新增拦截器(如日志、性能监控)只需修改封装层。
- 环境适配:通过
process.env
区分开发、生产环境的接口地址。
二、深度封装实践
1. 环境变量动态切换
在utils/request.js
中通过process.env.NODE_ENV
判断环境:
let baseUrl = ''
if (process.env.NODE_ENV === 'development') {
baseUrl = 'https://dev.api.example.com' // 开发环境
} else {
baseUrl = 'https://prod.api.example.com' // 生产环境
}
需在package.json
中配置脚本(借助cross-env
实现跨平台环境变量):
"scripts": {
"dev": "cross-env NODE_ENV=development vue-cli-service uni-build --watch",
"build": "cross-env NODE_ENV=production vue-cli-service uni-build"
}
2. 响应拦截的精细处理
除 HTTP 状态码,还需处理业务错误码(如后端code: 4001
代表 Token 失效):
success(res) {
if (res.statusCode === 200) {
if (res.data.code === 0) { // 业务成功码
resolve(res.data.data) // 只返回有效数据
} else {
// 业务错误:如参数错误、权限不足
uni.showToast({ title: res.data.msg, icon: 'none' })
reject(res.data)
}
} else {
// HTTP状态码错误:如500服务器异常
uni.showToast({ title: `网络错误:${res.statusCode}`, icon: 'none' })
reject(res)
}
}
3. 超时与重试机制
为请求添加超时时间和失败重试(最多 3 次,间隔递增):
const DEFAULT_TIMEOUT = 10000 // 10秒超时
const MAX_RETRIES = 3
export default (options) => {
let retryCount = 0
const requestWithRetry = () => {
return new Promise((resolve, reject) => {
uni.request({
...options,
url: baseUrl + options.url,
timeout: DEFAULT_TIMEOUT,
success: (res) => { /* 之前的success逻辑 */ },
fail: (err) => {
if (retryCount < MAX_RETRIES && err.errMsg.includes('timeout')) {
retryCount++
setTimeout(requestWithRetry, 1000 * retryCount) // 递增延迟
} else {
uni.showToast({ title: '请求超时,请稍后再试', icon: 'none' })
reject(err)
}
}
})
})
}
return requestWithRetry()
}
2.3.2 CommonJS 与 ES6 模块规范:模块化开发的底层逻辑
一、本质差异与 uniapp 支持
维度 | CommonJS | ES6 Modules |
---|---|---|
加载机制 | 运行时动态加载(同步) | 编译时静态分析(异步加载) |
作用域 | 模块作用域是module 对象 | 模块作用域是文件级作用域 |
uniapp 支持 | 全端支持(小程序 / APP/H5) | H5/APP 完全支持,小程序需注意 |
小程序注意事项:微信小程序对 ES6 模块的静态导入支持较好,但旧版工具可能存在Tree Shaking 失效问题。可通过webpack
配置优化:
// vue.config.js
module.exports = {
configureWebpack: {
optimization: {
usedExports: true // 启用Tree Shaking
}
}
}
二、最佳实践:模块导入的路径规范
-
绝对路径配置:
在vue.config.js
中配置别名,避免嵌套目录的../../
混乱:const path = require('path') module.exports = { configureWebpack: { resolve: { alias: { '@': path.resolve(__dirname, 'src') } } } }
导入时:
import request from '@/utils/request.js'
-
扩展名省略规则:
uniapp 支持省略.js
,但建议保留.vue
(区分组件和工具类),避免与小程序的json
文件冲突。
2.4 iconfont 字体图标:从接入到个性化定制
一、三种使用模式对比
模式 | Unicode | Font Class | Symbol |
---|---|---|---|
优点 | 体积最小 | 兼容旧版浏览器 | 支持多色、矢量 |
缺点 | 不支持多色 | 需管理 class | 体积稍大 |
适用场景 | 单色图标 | 兼容需求 | 复杂图标 |
二、Symbol 模式完整接入(推荐)
-
图标库配置:
在阿里图标库中选择Symbol 模式,下载包含iconfont.js
的包(自动注入 SVG sprite)。 -
全局引入:
在App.vue
的onLaunch
中动态引入(或直接在index.html
中引入):onLaunch() { const script = document.createElement('script') script.src = '/static/iconfont/iconfont.js' // 假设文件在static目录 document.head.appendChild(script) }
-
组件化使用:
封装Icon
组件,支持动态切换图标和颜色:vue
<template> <svg class="icon" aria-hidden="true"> <use :xlink:href="`#icon-${name}`"></use> </svg> </template> <script> export default { props: { name: { type: String, required: true }, color: { type: String, default: '#333' } } } </script> <style scoped> .icon { width: 20px; height: 20px; fill: v-bind(color); /* 动态绑定颜色 */ } </style>
使用:
<Icon name="home" color="#ff0000" />
三、动态换肤技巧
通过CSS 变量控制图标颜色,配合主题切换:
:root {
--icon-color: #333;
}
.dark-mode {
--icon-color: #fff;
}
.icon {
fill: var(--icon-color);
}
2.5 uni 组件使用及 blogs 搜索功能:组件化思维与交互优化
一、uni 组件体系深度解析
-
内置组件:
由 uniapp 原生提供(如<scroll-view>
),全端兼容,但样式需自行定制。 -
uni-ui 组件:
官方维护的 UI 库,需通过npm install @dcloudio/uni-ui
安装,支持按需引入(减少包体积):// main.js中全局注册 import { UniList, UniListItem } from '@dcloudio/uni-ui' Vue.component('UniList', UniList) Vue.component('UniListItem', UniListItem)
或局部引入(推荐,避免全局污染):
import { UniList } from '@dcloudio/uni-ui' export default { components: { UniList } }
-
自定义组件:
业务专属组件(如搜索框),需注意小程序的样式隔离:<style scoped> /* 小程序中,scoped样式不会穿透到子组件,需用>>> */ .search-box >>> .uni-easyinput { border-radius: 20px; } </style>
二、搜索功能的极致体验优化
1. 手写防抖函数(替代 lodash)
methods: {
debounceSearch: function() {
let timer = null
return (keyword) => {
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
const res = await request({ url: '/blogs/search', data: { keyword } })
this.blogs = res.list
}, 300)
}
}() // 立即执行,返回防抖函数
}
2. 加载状态可视化
在搜索框右侧添加加载图标,请求时显示:
<uni-easyinput
v-model="keyword"
placeholder="搜索博客"
@input="debounceSearch"
>
<template #suffix>
<image v-if="isSearching" src="/static/loading.gif" class="loading-icon" />
</template>
</uni-easyinput>
js
data() { return { isSearching: false } },
methods: {
debounceSearch() {
this.isSearching = true
// ...请求结束后设置this.isSearching = false
}
}
2.5.1 项目检查过程:从功能到工程化的全面保障
一、功能测试的边界场景覆盖
场景 | 测试方法 |
---|---|
空搜索 | 输入空字符串,验证 “无结果” 提示 |
网络中断 | 开启飞行模式,验证错误提示和重试逻辑 |
分页最后一页 | 模拟page=100 ,验证loadingStatus 为noMore |
并发请求 | 快速下拉 + 触底,验证isLoading 防止重复请求 |
二、性能优化的量化指标
- 首屏加载时间:通过
uni.getSystemInfoSync().launchTime
统计,目标≤1.5 秒。 - 列表渲染性能:使用 Chrome DevTools 的Performance 面板,监控
v-for
的渲染耗时,优化后≤50ms / 百条。
三、工程化规范落地
-
ESLint + Prettier:
配置.eslintrc.js
统一代码风格:js
module.exports = { extends: ['@vue/standard'], rules: { 'vue/multi-word-component-names': 'off' // 关闭组件命名规则(非必须) } }
结合
prettier
实现自动格式化,在package.json
添加脚本:json
"scripts": { "lint": "eslint --ext .js,.vue src", "format": "prettier --write ." }
-
Git Hooks:
通过husky
在提交代码前自动运行lint
:bash
npm install husky --save-dev npx husky add .husky/pre-commit "npm run lint"
2.6 使用缓存提升用户体验:策略与陷阱
一、缓存分层设计
缓存类型 | 存储位置 | 有效期 | 适用场景 |
---|---|---|---|
内存缓存 | Vue 实例 /data | 页面销毁前 | 高频访问的临时数据 |
本地缓存 | uni.setStorage | 长期 / 短期 | 需持久化的数据(如 Token) |
服务端缓存 | CDN/Redis | 由后端控制 | 静态资源 / 公共数据 |
二、内存缓存的实现(以博客列表为例)
js
data() {
return {
memoryCache: {}, // 键:url,值:{data, time}
}
},
async fetchBlogs() {
const url = `/blogs?page=${this.page}&size=${this.size}`
const cache = this.memoryCache[url]
if (cache && Date.now() - cache.time < 60 * 1000) { // 1分钟有效期
this.blogs = cache.data
return
}
const res = await request({ url })
this.memoryCache[url] = { data: res, time: Date.now() }
this.blogs = res
}
三、缓存与实时性的平衡
- 下拉刷新时:必须清除内存和本地缓存,强制获取最新数据。
- 用户主动操作(如发布新博客):通过
uni.$emit
通知列表页更新缓存:js
// 发布成功后 uni.$emit('blogUpdated', newBlogId) // 列表页监听 uni.$on('blogUpdated', (id) => { this.fetchBlogs() // 或局部更新 })
2.6.1 移动端请求类型:跨域、预检与性能优化
一、OPTIONS 预检的完整解决方案
后端需配置以下响应头(以 Node.js + Express 为例):
js
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, token')
if (req.method === 'OPTIONS') {
res.status(204).end() // 预检请求直接返回204
return
}
next()
})
二、GET 请求的缓存陷阱
浏览器会对相同 URL 的 GET 请求进行缓存(即使后端数据已变)。解决方案:
- 添加随机参数:
url: '/blogs?page=1&_t=' + Date.now()
- 后端设置缓存策略:
res.setHeader('Cache-Control', 'no-cache')
三、请求优先级控制
对于首屏关键数据(如博客列表),使用uni.request
的priority
参数(仅 APP 端支持):
js
uni.request({
url: '/blogs',
priority: 1 // 1-5,1为最高优先级
})
2.7 详细信息页面 & uni 路由:跳转、传参与守卫
一、路由传参的三种方式
-
URL 参数(query):
js
uni.navigateTo({ url: '/pages/detail/detail?id=1&title=xxx' }) // 详情页onLoad接收:options.id, options.title
局限:参数暴露在 URL 中,且小程序对 URL 长度有限制(约 1024 字符)。
-
Vuex 暂存:
适合传递复杂数据(如整篇博客对象):js
// 列表页 this.$store.commit('SET_TEMP_BLOG', blog) // 详情页onLoad this.blog = this.$store.state.tempBlog this.$store.commit('CLEAR_TEMP_BLOG') // 用完清除,避免污染
-
本地缓存:
结合uni.setStorageSync
临时存储,详情页读取后清除:js
uni.setStorageSync('tempBlog', blog) // 详情页 const blog = uni.getStorageSync('tempBlog') uni.removeStorageSync('tempBlog')
二、模拟路由守卫(权限校验)
在详情页的onLoad
中检查登录状态:
js
onLoad(options) {
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '需登录查看详情',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/login' })
} else {
uni.navigateBack()
}
}
})
return
}
this.fetchDetail(options.id)
}
2.7.1 实现页面通知的两种方式:原理与最佳实践
一、事件总线(Event Bus)的内存泄漏问题
问题:页面 A 监听事件后未取消,销毁后事件仍存在,再次触发会报错。
解决方案:在onUnload
中取消监听:
js
onLoad() {
this.eventHandler = (data) => { /* 处理逻辑 */ }
uni.$on('updateBlog', this.eventHandler)
},
onUnload() {
uni.$off('updateBlog', this.eventHandler) // 精准取消
}
二、Vuex 的模块拆分实践
将博客相关状态独立为blog
模块(store/modules/blog.js
):
js
const blogModule = {
namespaced: true, // 开启命名空间,避免冲突
state: { currentBlog: {} },
mutations: {
SET_CURRENT_BLOG(state, blog) {
state.currentBlog = blog
}
},
actions: {
fetchBlog({ commit }, id) {
return request({ url: `/blogs/${id}` }).then(res => {
commit('SET_CURRENT_BLOG', res)
})
}
}
}
export default blogModule
使用时:
js
// 提交mutation
this.$store.commit('blog/SET_CURRENT_BLOG', newBlog)
// 调用action
this.$store.dispatch('blog/fetchBlog', id)
// 获取状态
computed: {
blog() { return this.$store.state.blog.currentBlog }
}
2.7.2 复杂列表 & 评论显示:递归组件与性能优化
一、递归组件的深度限制
当评论嵌套层数≥10 层时,可能触发栈溢出或渲染卡顿。解决方案:
-
限制最大深度:通过
props
传递depth
,超过 5 层时折叠显示:vue
<template> <view v-if="depth <= 5"> <!-- 渲染评论内容 --> <Comment v-for="reply in comment.replies" :depth="depth + 1" /> </view> <view v-else> <text @click="showAll = !showAll">点击展开更多回复</text> <Comment v-if="showAll" v-for="reply in comment.replies" :depth="depth + 1" /> </view> </template>
-
扁平化数据结构:
后端返回评论时,将嵌套结构转为扁平数组(通过parentId
关联),前端用非递归方式渲染,减少组件嵌套层级。
二、虚拟列表结合递归
使用vue-virtual-scroller
库,仅渲染可视区域的评论,同时处理递归:
vue
<template>
<VirtualScroller :items="flatComments" :item-size="50">
<template #default="{ item }">
<Comment :comment="item" :depth="item.depth" />
</template>
</VirtualScroller>
</template>
<script>
import { VirtualScroller } from 'vue-virtual-scroller'
export default {
components: { VirtualScroller },
computed: {
flatComments() {
// 将嵌套评论转为扁平数组,添加depth字段
return this.flattenComments(this.rootComment, 1)
}
},
methods: {
flattenComments(comment, depth) {
const result = []
const traverse = (c, d) => {
result.push({ ...c, depth: d })
c.replies.forEach(r => traverse(r, d + 1))
}
traverse(comment, depth)
return result
}
}
}
</script>
2.8 自定义组件和用户登录:封装与安全实践
一、自定义组件的 props 高级用法
1. 类型校验与默认值
js
props: {
type: {
type: String,
required: true,
validator: (value) => { // 自定义校验
return ['primary', 'warning', 'danger'].includes(value)
}
},
loading: {
type: Boolean,
default: false
}
}
2. 作用域插槽(Scoped Slot)
让父组件可自定义按钮内容,同时获取子组件状态:
vue
<template>
<button :class="`btn-${type}`" @click="handleClick">
<slot :loading="loading"></slot> <!-- 传递loading状态 -->
</button>
</template>
使用:
vue
<LoginButton type="primary" :loading="isLoading">
<template #default="{ loading }">
{{ loading ? '登录中...' : '立即登录' }}
</template>
</LoginButton>
二、登录功能的安全加固
1. 密码安全传输
- 前端:使用 HTTPS 协议(必须),密码可进行非对称加密(如 RSA),公钥由后端提供。
- 后端:使用 BCrypt 进行加盐哈希存储,避免明文泄露。
2. 自动登录与 Token 续期
- 自动登录:将 Token 存入本地缓存,每次请求携带,同时设置
expires
时间,过期前自动刷新。 - Token 续期:在响应拦截器中检查 Token 有效期,若剩余时间 < 5 分钟,自动调用续期接口:
js
// request.js响应拦截 if (res.data.code === 401 && res.data.msg === 'Token即将过期') { await refreshToken() // 调用续期接口 return request(options) // 重新发起原请求 }
2.9 注册页面:流程优化与体验细节
一、表单校验的正则强化
-
手机号:严格匹配国内手机号规则(含 16、19 号段):
js
const PHONE_REG = /^1[3-9]\d{9}$/
-
密码:至少 8 位,包含大小写字母、数字、特殊字符:
js
const PWD_REG = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
二、验证码倒计时的精准控制
vue
<template>
<button
class="code-btn"
:disabled="countdown > 0"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}秒后重发` : '获取验证码' }}
</button>
</template>
<script>
export default {
data() { return { countdown: 0, timer: null } },
methods: {
sendCode() {
if (this.countdown > 0) return
this.countdown = 60
this.timer = setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
clearInterval(this.timer)
}
}, 1000)
// 调用短信接口...
}
},
onUnload() {
clearInterval(this.timer) // 页面销毁时清除定时器
}
}
</script>
三、错误提示的场景化处理
js
async handleSubmit() {
if (this.pwd !== this.confirmPwd) {
return uni.showToast({ title: '两次密码不一致', icon: 'none' })
}
try {
const res = await request({
url: '/auth/register',
method: 'POST',
data: {
phone: this.phone,
code: this.code,
pwd: this.pwd
}
})
if (res.code === 0) {
uni.navigateTo({ url: '/pages/login/login' })
} else {
let msg = '注册失败'
if (res.code === 1001) msg = '手机号已注册'
else if (res.code === 1002) msg = '验证码错误'
uni.showToast({ title: msg, icon: 'none' })
}
} catch (err) {
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
}
}
终章:技术沉淀与未来展望
通过博客应用的开发,我们不仅掌握了 uniapp 的核心 API,更理解了 模块化、分层架构、性能优化 的底层逻辑。在实际项目中,需根据场景灵活选择方案:
- 轻量交互:优先使用事件总线和内存缓存。
- 复杂状态:采用Vuex和持久化缓存。
- 性能敏感:结合虚拟列表、懒加载等技术。
未来,可进一步探索:
- uniapp 跨端编译优化:通过条件编译区分小程序和 H5 的差异代码。
- CI/CD 持续集成:借助 GitHub Actions 实现自动构建、测试与部署。
让开发流程更高效,产品体验更卓越 —— 这正是技术迭代的终极目标。