新闻资讯应用实战案例
本文将介绍如何使用 uni-app 开发一个功能完善的新闻资讯应用,包括应用架构设计、核心功能实现和关键代码示例。
1. 应用概述
1.1 功能特点
新闻资讯应用是移动端常见的应用类型,主要功能包括:
- 内容浏览:新闻列表、新闻详情、分类筛选
- 搜索功能:关键词搜索、热门搜索、搜索历史
- 个性化服务:推荐阅读、收藏与历史记录
- 互动功能:评论、点赞、分享
- 用户系统:登录注册、个人中心
1.2 技术架构
前端技术栈
- uni-app:跨平台开发框架,实现一次开发多端运行
- Vue.js:响应式数据绑定,提供组件化开发模式
- Vuex:状态管理,处理复杂组件间通信
- uni-ui:官方UI组件库,提供统一的界面设计
后端技术栈
- 云函数:处理业务逻辑,提供无服务器计算能力
- 云数据库:存储新闻内容和用户数据
- 第三方API:获取新闻数据源(如天行数据、聚合数据等)
2. 项目结构
├── components // 自定义组件
│ ├── news-item // 新闻列表项组件
│ ├── comment-item // 评论项组件
│ └── category-tabs // 分类标签组件
├── pages // 页面文件夹
│ ├── index // 首页(新闻列表)
│ ├── detail // 新闻详情页
│ ├── search // 搜索页面
│ ├── category // 分类页面
│ └── user // 用户中心
├── static // 静态资源
├── store // Vuex 状态管理
│ ├── index.js // 组装模块并导出
│ ├── news.js // 新闻相关状态
│ └── user.js // 用户相关状态
├── utils // 工具函数
│ ├── request.js // 请求封装
│ ├── date.js // 日期处理
│ └── share.js // 分享功能
├── App.vue // 应用入口
├── main.js // 主入口
├── manifest.json // 配置文件
└── pages.json // 页面配置
3. 核心功能实现
3.1 状态管理设计
使用 Vuex 管理新闻数据和用户数据,实现数据的集中管理和组件间通信。
js
// store/news.js - 新闻状态管理
export default {
namespaced: true,
state: {
categories: [],
newsList: [],
banners: [],
currentNews: null,
loading: false,
refreshing: false,
hasMore: true,
page: 1
},
mutations: {
SET_CATEGORIES(state, categories) {
state.categories = categories;
},
SET_NEWS_LIST(state, list) {
state.newsList = list;
},
ADD_NEWS_LIST(state, list) {
state.newsList = [...state.newsList, ...list];
},
SET_BANNERS(state, banners) {
state.banners = banners;
},
SET_CURRENT_NEWS(state, news) {
state.currentNews = news;
},
SET_LOADING(state, status) {
state.loading = status;
},
SET_REFRESHING(state, status) {
state.refreshing = status;
},
SET_HAS_MORE(state, status) {
state.hasMore = status;
},
SET_PAGE(state, page) {
state.page = page;
}
},
actions: {
// 获取新闻分类
async getCategories({ commit }) {
try {
const { result } = await uniCloud.callFunction({
name: 'news',
data: { action: 'getCategories' }
});
commit('SET_CATEGORIES', result.data);
return result.data;
} catch (error) {
console.error('获取分类失败', error);
throw error;
}
},
// 获取新闻列表
async getNewsList({ commit, state }, { categoryId, refresh = false }) {
if (refresh) {
commit('SET_PAGE', 1);
commit('SET_HAS_MORE', true);
} else {
if (!state.hasMore || state.loading) return [];
}
commit('SET_LOADING', true);
try {
const { result } = await uniCloud.callFunction({
name: 'news',
data: {
action: 'getNewsList',
categoryId,
page: state.page,
pageSize: 10
}
});
const list = result.data || [];
if (refresh) {
commit('SET_NEWS_LIST', list);
} else {
commit('ADD_NEWS_LIST', list);
}
commit('SET_HAS_MORE', list.length === 10);
commit('SET_PAGE', state.page + 1);
return list;
} catch (error) {
console.error('获取新闻列表失败', error);
throw error;
} finally {
commit('SET_LOADING', false);
}
},
// 获取轮播图
async getBanners({ commit }) {
try {
const { result } = await uniCloud.callFunction({
name: 'news',
data: { action: 'getBanners' }
});
commit('SET_BANNERS', result.data);
return result.data;
} catch (error) {
console.error('获取轮播图失败', error);
throw error;
}
},
// 获取新闻详情
async getNewsDetail({ commit }, id) {
try {
const { result } = await uniCloud.callFunction({
name: 'news',
data: {
action: 'getNewsDetail',
id
}
});
commit('SET_CURRENT_NEWS', result.data);
return result.data;
} catch (error) {
console.error('获取新闻详情失败', error);
throw error;
}
},
// 搜索新闻
async searchNews({ commit }, { keyword, page = 1, sortOrder = 'time' }) {
try {
const { result } = await uniCloud.callFunction({
name: 'news',
data: {
action: 'searchNews',
keyword,
page,
pageSize: 10,
sortOrder
}
});
return result.data || [];
} catch (error) {
console.error('搜索新闻失败', error);
throw error;
}
}
}
};
// store/user.js - 用户状态管理
export default {
namespaced: true,
state: {
userInfo: uni.getStorageSync('userInfo') ? JSON.parse(uni.getStorageSync('userInfo')) : null,
token: uni.getStorageSync('token') || '',
isLogin: Boolean(uni.getStorageSync('token')),
collections: uni.getStorageSync('collections') ? JSON.parse(uni.getStorageSync('collections')) : [],
readHistory: uni.getStorageSync('readHistory') ? JSON.parse(uni.getStorageSync('readHistory')) : [],
searchHistory: uni.getStorageSync('searchHistory') ? JSON.parse(uni.getStorageSync('searchHistory')) : []
},
mutations: {
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo;
state.isLogin = Boolean(userInfo);
uni.setStorageSync('userInfo', JSON.stringify(userInfo));
},
SET_TOKEN(state, token) {
state.token = token;
uni.setStorageSync('token', token);
},
LOGOUT(state) {
state.userInfo = null;
state.token = '';
state.isLogin = false;
uni.removeStorageSync('userInfo');
uni.removeStorageSync('token');
},
SET_COLLECTIONS(state, collections) {
state.collections = collections;
uni.setStorageSync('collections', JSON.stringify(collections));
},
ADD_COLLECTION(state, news) {
state.collections.unshift(news);
uni.setStorageSync('collections', JSON.stringify(state.collections));
},
REMOVE_COLLECTION(state, id) {
state.collections = state.collections.filter(item => item.id !== id);
uni.setStorageSync('collections', JSON.stringify(state.collections));
},
ADD_READ_HISTORY(state, news) {
// 如果已存在,先移除
state.readHistory = state.readHistory.filter(item => item.id !== news.id);
// 添加到最前面
state.readHistory.unshift({
...news,
readTime: Date.now()
});
// 最多保存50条
if (state.readHistory.length > 50) {
state.readHistory = state.readHistory.slice(0, 50);
}
uni.setStorageSync('readHistory', JSON.stringify(state.readHistory));
},
ADD_SEARCH_HISTORY(state, keyword) {
// 如果已存在,先移除
state.searchHistory = state.searchHistory.filter(item => item !== keyword);
// 添加到最前面
state.searchHistory.unshift(keyword);
// 最多保存10条
if (state.searchHistory.length > 10) {
state.searchHistory = state.searchHistory.slice(0, 10);
}
uni.setStorageSync('searchHistory', JSON.stringify(state.searchHistory));
},
CLEAR_SEARCH_HISTORY(state) {
state.searchHistory = [];
uni.removeStorageSync('searchHistory');
}
},
actions: {
// 登录
async login({ commit }, params) {
try {
const { result } = await uniCloud.callFunction({
name: 'user',
data: {
action: 'login',
...params
}
});
commit('SET_USER_INFO', result.data.userInfo);
commit('SET_TOKEN', result.data.token);
return result.data;
} catch (error) {
console.error('登录失败', error);
throw error;
}
},
// 退出登录
logout({ commit }) {
commit('LOGOUT');
},
// 收藏新闻
collectNews({ commit, state }, news) {
commit('ADD_COLLECTION', news);
},
// 取消收藏
uncollectNews({ commit, state }, id) {
commit('REMOVE_COLLECTION', id);
},
// 添加阅读历史
addReadHistory({ commit }, news) {
commit('ADD_READ_HISTORY', news);
},
// 添加搜索历史
addSearchHistory({ commit }, keyword) {
commit('ADD_SEARCH_HISTORY', keyword);
},
// 清空搜索历史
clearSearchHistory({ commit }) {
commit('CLEAR_SEARCH_HISTORY');
}
},
getters: {
// 判断新闻是否已收藏
isNewsCollected: state => id => {
return state.collections.some(item => item.id === id);
}
}
};
3.2 新闻列表页实现
新闻列表页是应用的首页,展示各类新闻内容,支持下拉刷新和上拉加载更多。
vue
<!-- pages/index/index.vue -->
<template>
<view class="news-page">
<!-- 顶部搜索栏 -->
<view class="search-bar" @tap="goToSearch">
<text class="iconfont icon-search"></text>
<text class="placeholder">搜索感兴趣的内容</text>
</view>
<!-- 分类标签 -->
<category-tabs
:categories="categories"
:current="currentCategory"
@change="changeCategory"
></category-tabs>
<!-- 新闻列表 -->
<scroll-view
scroll-y
class="news-list"
@scrolltolower="loadMore"
@refresherrefresh="refresh"
refresher-enabled
:refresher-triggered="refreshing"
>
<!-- 轮播图 -->
<swiper
class="banner"
indicator-dots
autoplay
circular
v-if="currentCategory === 0 && banners.length > 0"
>
<swiper-item v-for="(item, index) in banners" :key="index">
<image
:src="item.image"
mode="aspectFill"
class="banner-image"
@tap="goToDetail(item)"
></image>
<view class="banner-title">{{item.title}}</view>
</swiper-item>
</swiper>
<!-- 新闻列表 -->
<view class="list-container">
<news-item
v-for="(item, index) in newsList"
:key="index"
:news="item"
@click="goToDetail(item)"
></news-item>
</view>
<!-- 加载更多 -->
<uni-load-more :status="loadMoreStatus"></uni-load-more>
</scroll-view>
</view>
</template>
<script>
import categoryTabs from '@/components/category-tabs/category-tabs.vue';
import newsItem from '@/components/news-item/news-item.vue';
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue';
import { mapState, mapActions } from 'vuex';
export default {
components: {
categoryTabs,
newsItem,
uniLoadMore
},
data() {
return {
currentCategory: 0,
refreshing: false,
loadMoreStatus: 'more' // more, loading, noMore
}
},
computed: {
...mapState('news', ['categories', 'newsList', 'banners', 'loading', 'hasMore'])
},
onLoad() {
// 初始化数据
this.initData();
},
methods: {
...mapActions('news', ['getCategories', 'getNewsList', 'getBanners']),
// 初始化数据
async initData() {
uni.showLoading({ title: '加载中' });
try {
// 加载分类
await this.getCategories();
// 加载轮播图
await this.getBanners();
// 加载新闻列表
await this.loadNewsList(true);
} catch (e) {
console.error('初始化数据失败', e);
} finally {
uni.hideLoading();
}
},
// 加载新闻列表
async loadNewsList(refresh = false) {
try {
this.loadMoreStatus = 'loading';
await this.getNewsList({
categoryId: this.categories[this.currentCategory].id,
refresh
});
this.loadMoreStatus = this.hasMore ? 'more' : 'noMore';
} catch (e) {
console.error('加载新闻列表失败', e);
uni.showToast({
title: '加载新闻列表失败',
icon: 'none'
});
this.loadMoreStatus = 'more';
}
},
// 切换分类
changeCategory(index) {
if (this.currentCategory === index) return;
this.currentCategory = index;
this.loadNewsList(true);
},
// 下拉刷新
async refresh() {
this.refreshing = true;
await this.loadNewsList(true);
this.refreshing = false;
},
// 加载更多
loadMore() {
if (this.loadMoreStatus !== 'more') return;
this.loadNewsList();
},
// 跳转到详情页
goToDetail(news) {
uni.navigateTo({
url: `/pages/detail/detail?id=${news.id}`
});
},
// 跳转到搜索页
goToSearch() {
uni.navigateTo({
url: '/pages/search/search'
});
}
}
}
</script>
3.3 新闻列表项组件
新闻列表项组件用于展示单条新闻的预览信息,支持多种布局方式。
vue
<!-- components/news-item/news-item.vue -->
<template>
<view class="news-item" :class="{ 'no-image': !hasImage, 'multi-image': hasMultiImage }">
<!-- 标题 -->
<text class="title">{{news.title}}</text>
<!-- 单图布局 -->
<view class="content-wrapper" v-if="hasImage && !hasMultiImage">
<view class="content">
<text class="summary" v-if="news.summary">{{news.summary}}</text>
<view class="info">
<text class="source">{{news.source}}</text>
<text class="time">{{formatTime(news.publishTime)}}</text>
<text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
</view>
</view>
<image
:src="news.images[0]"
mode="aspectFill"
class="image"
@error="onImageError"
></image>
</view>
<!-- 多图布局 -->
<view class="multi-image-wrapper" v-else-if="hasMultiImage">
<view class="image-list">
<image
v-for="(img, index) in news.images.slice(0, 3)"
:key="index"
:src="img"
mode="aspectFill"
class="multi-image"
@error="onImageError"
></image>
</view>
<view class="info">
<text class="source">{{news.source}}</text>
<text class="time">{{formatTime(news.publishTime)}}</text>
<text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
</view>
</view>
<!-- 无图布局 -->
<view class="content" v-else>
<text class="summary" v-if="news.summary">{{news.summary}}</text>
<view class="info">
<text class="source">{{news.source}}</text>
<text class="time">{{formatTime(news.publishTime)}}</text>
<text class="comment-count" v-if="news.commentCount > 0">{{news.commentCount}} 评论</text>
</view>
</view>
</view>
</template>
<script>
import { formatTimeAgo } from '@/utils/date.js';
export default {
props: {
news: {
type: Object,
required: true
}
},
computed: {
hasImage() {
return this.news.images && this.news.images.length > 0;
},
hasMultiImage() {
return this.news.images && this.news.images.length >= 3;
}
},
methods: {
formatTime(timestamp) {
return formatTimeAgo(timestamp);
},
onImageError(e) {
// 图片加载失败时的处理
const index = e.currentTarget.dataset.index || 0;
if (this.news.images && this.news.images[index]) {
// 可以设置一个默认图片
this.news.images[index] = '/static/images/default-news.png';
}
}
}
}
</script>
3.4 新闻详情页实现
新闻详情页展示完整的新闻内容,包括标题、正文、图片、评论等,并支持收藏、分享功能。
vue
<!-- pages/detail/detail.vue -->
<template>
<view class="detail-page">
<!-- 新闻内容 -->
<scroll-view scroll-y class="content-scroll">
<view class="news-content">
<view class="title">{{newsDetail.title}}</view>
<view class="info">
<text class="source">{{newsDetail.source}}</text>
<text class="time">{{formatTime(newsDetail.publishTime)}}</text>
</view>
<!-- 正文内容 -->
<rich-text :nodes="newsDetail.content" class="content"></rich-text>
<!-- 标签 -->
<view class="tags" v-if="newsDetail.tags && newsDetail.tags.length > 0">
<text
class="tag"
v-for="(tag, index) in newsDetail.tags"
:key="index"
>{{tag}}</text>
</view>
</view>
<!-- 相关推荐 -->
<view class="related-news" v-if="relatedNews.length > 0">
<view class="section-title">相关推荐</view>
<news-item
v-for="(item, index) in relatedNews"
:key="index"
:news="item"
@click="goToDetail(item)"
></news-item>
</view>
<!-- 评论区 -->
<view class="comments-section">
<view class="section-title">
评论 <text class="comment-count">({{comments.length}})</text>
</view>
<view class="empty-tip" v-if="comments.length === 0">
<text>暂无评论,快来发表你的看法吧</text>
</view>
<comment-item
v-for="(item, index) in comments"
:key="index"
:comment="item"
@reply="replyComment"
@like="likeComment"
></comment-item>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="footer">
<view class="comment-input">
<input
type="text"
v-model="commentText"
:placeholder="replyTo ? `回复 ${replyTo.user.nickname}` : '写评论...'"
confirm-type="send"
@confirm="submitComment"
/>
</view>
<view class="action-icons">
<view class="action-item" @tap="toggleCollect">
<text
class="iconfont"
:class="isCollected ? 'icon-star-filled' : 'icon-star'"
></text>
<text>收藏</text>
</view>
<view class="action-item" @tap="shareNews">
<text class="iconfont icon-share"></text>
<text>分享</text>
</view>
</view>
</view>
</view>
</template>
<script>
import newsItem from '@/components/news-item/news-item.vue';
import commentItem from '@/components/comment-item/comment-item.vue';
import { formatTime } from '@/utils/date.js';
import { share } from '@/utils/share.js';
import { mapActions, mapGetters } from 'vuex';
export default {
components: {
newsItem,
commentItem
},
data() {
return {
newsId: '',
newsDetail: {
title: '',
source: '',
publishTime: 0,
content: '',
tags: []
},
relatedNews: [],
comments: [],
commentText: '',
replyTo: null
}
},
computed: {
...mapGetters('user', ['isNewsCollected']),
isCollected() {
return this.isNewsCollected(this.newsId);
}
},
onLoad(options) {
this.newsId = options.id;
// 加载新闻详情
this.loadNewsDetail();
// 加载评论
this.loadComments();
// 加载相关推荐
this.loadRelatedNews();
// 添加到阅读历史
this.addToHistory();
},
methods: {
...mapActions('news', ['getNewsDetail', 'getNewsComments', 'getRelatedNews', 'addComment']),
...mapActions('user', ['collectNews', 'uncollectNews', 'addReadHistory']),
// 加载新闻详情
async loadNewsDetail() {
try {
const detail = await this.getNewsDetail(this.newsId);
this.newsDetail = detail;
} catch (e) {
console.error('加载新闻详情失败', e);
uni.showToast({
title: '加载新闻详情失败',
icon: 'none'
});
}
},
// 加载评论
async loadComments() {
try {
const comments = await this.getNewsComments(this.newsId);
this.comments = comments;
} catch (e) {
console.error('加载评论失败', e);
}
},
// 加载相关推荐
async loadRelatedNews() {
try {
const related = await this.getRelatedNews(this.newsId);
this.relatedNews = related;
} catch (e) {
console.error('加载相关推荐失败', e);
}
},
// 添加到阅读历史
addToHistory() {
this.addReadHistory({
id: this.newsId,
title: this.newsDetail.title,
source: this.newsDetail.source,
publishTime: this.newsDetail.publishTime,
image: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
});
},
// 格式化时间
formatTime(timestamp) {
return formatTime(timestamp);
},
// 回复评论
replyComment(comment) {
this.replyTo = comment;
// 聚焦输入框
this.$nextTick(() => {
const inputEl = this.$el.querySelector('input');
if (inputEl) {
inputEl.focus();
}
});
},
// 点赞评论
likeComment(comment) {
// 实现点赞逻辑
},
// 提交评论
async submitComment() {
if (!this.commentText.trim()) return;
try {
const commentData = {
newsId: this.newsId,
content: this.commentText,
replyTo: this.replyTo ? this.replyTo.id : null
};
await this.addComment(commentData);
// 清空输入
this.commentText = '';
this.replyTo = null;
// 重新加载评论
this.loadComments();
uni.showToast({
title: '评论成功',
icon: 'success'
});
} catch (e) {
console.error('提交评论失败', e);
uni.showToast({
title: '评论失败,请重试',
icon: 'none'
});
}
},
// 切换收藏状态
toggleCollect() {
if (this.isCollected) {
this.uncollectNews(this.newsId);
uni.showToast({
title: '已取消收藏',
icon: 'none'
});
} else {
this.collectNews({
id: this.newsId,
title: this.newsDetail.title,
source: this.newsDetail.source,
publishTime: this.newsDetail.publishTime,
image: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
});
uni.showToast({
title: '已收藏',
icon: 'success'
});
}
},
// 分享新闻
shareNews() {
share({
title: this.newsDetail.title,
path: `/pages/detail/detail?id=${this.newsId}`,
imageUrl: this.newsDetail.images && this.newsDetail.images.length > 0 ? this.newsDetail.images[0] : ''
});
},
// 跳转到详情页
goToDetail(news) {
uni.navigateTo({
url: `/pages/detail/detail?id=${news.id}`
});
}
}
}
</script>
3.5 搜索功能实现
搜索页面提供新闻搜索功能,包括搜索历史和热门搜索推荐。
vue
<!-- pages/search/search.vue -->
<template>
<view class="search-page">
<!-- 搜索栏 -->
<view class="search-header">
<view class="search-input">
<text class="iconfont icon-search"></text>
<input
type="text"
v-model="keyword"
placeholder="搜索感兴趣的内容"
confirm-type="search"
focus
@confirm="search"
/>
<text
class="clear-icon"
v-if="keyword"
@tap="clearKeyword"
>×</text>
</view>
<text class="cancel-btn" @tap="goBack">取消</text>
</view>
<!-- 搜索历史和热门搜索 -->
<view class="search-content" v-if="!showResults">
<!-- 搜索历史 -->
<view class="search-section" v-if="searchHistory.length > 0">
<view class="section-header">
<text class="section-title">搜索历史</text>
<text class="clear-btn" @tap="clearHistory">清空</text>
</view>
<view class="keyword-list">
<text
class="keyword-item"
v-for="(item, index) in searchHistory"
:key="index"
@tap="useKeyword(item)"
>{{item}}</text>
</view>
</view>
<!-- 热门搜索 -->
<view class="search-section">
<view class="section-header">
<text class="section-title">热门搜索</text>
</view>
<view class="keyword-list">
<text
class="keyword-item"
v-for="(item, index) in hotKeywords"
:key="index"
@tap="useKeyword(item)"
>{{item}}</text>
</view>
</view>
</view>
<!-- 搜索结果 -->
<view class="search-results" v-else>
<view class="result-header">
<text class="result-count">共找到 {{searchResults.length}} 条结果</text>
<text class="sort-btn" @tap="toggleSortOrder">
{{sortOrder === 'time' ? '按时间排序' : '按相关度排序'}}
<text class="iconfont icon-down"></text>
</text>
</view>
<scroll-view
scroll-y
class="result-list"
@scrolltolower="loadMoreResults"
>
<news-item
v-for="(item, index) in searchResults"
:key="index"
:news="item"
@click="goToDetail(item)"
></news-item>
<!-- 加载更多 -->
<uni-load-more :status="loadMoreStatus"></uni-load-more>
</scroll-view>
</view>
</view>
</template>
<script>
import newsItem from '@/components/news-item/news-item.vue';
import uniLoadMore from '@/components/uni-load-more/uni-load-more.vue';
import { mapState, mapActions } from 'vuex';
export default {
components: {
newsItem,
uniLoadMore
},
data() {
return {
keyword: '',
showResults: false,
searchResults: [],
sortOrder: 'time', // time, relevance
page: 1,
loadMoreStatus: 'more', // more, loading, noMore
hotKeywords: ['疫情防控', '经济复苏', '科技创新', '教育改革', '体育赛事', '娱乐新闻']
}
},
computed: {
...mapState('user', ['searchHistory'])
},
methods: {
...mapActions('news', ['searchNews']),
...mapActions('user', ['addSearchHistory', 'clearSearchHistory']),
// 返回上一页
goBack() {
uni.navigateBack();
},
// 清空关键词
clearKeyword() {
this.keyword = '';
this.showResults = false;
},
// 使用关键词
useKeyword(keyword) {
this.keyword = keyword;
this.search();
},
// 搜索
async search() {
if (!this.keyword.trim()) return;
// 添加到搜索历史
this.addSearchHistory(this.keyword);
this.showResults = true;
this.page = 1;
this.loadMoreStatus = 'loading';
try {
const results = await this.searchNews({
keyword: this.keyword,
page: this.page,
sortOrder: this.sortOrder
});
this.searchResults = results;
this.loadMoreStatus = results.length < 10 ? 'noMore' : 'more';
} catch (e) {
console.error('搜索失败', e);
uni.showToast({
title: '搜索失败,请重试',
icon: 'none'
});
this.loadMoreStatus = 'more';
}
},
// 加载更多结果
async loadMoreResults() {
if (this.loadMoreStatus !== 'more') return;
this.loadMoreStatus = 'loading';
this.page++;
try {
const results = await this.searchNews({
keyword: this.keyword,
page: this.page,
sortOrder: this.sortOrder
});
if (results.length > 0) {
this.searchResults = [...this.searchResults, ...results];
}
this.loadMoreStatus = results.length < 10 ? 'noMore' : 'more';
} catch (e) {
console.error('加载更多结果失败', e);
this.loadMoreStatus = 'more';
}
},
// 切换排序方式
toggleSortOrder() {
this.sortOrder = this.sortOrder === 'time' ? 'relevance' : 'time';
this.search();
},
// 清空搜索历史
clearHistory() {
uni.showModal({
title: '提示',
content: '确定要清空搜索历史吗?',
success: (res) => {
if (res.confirm) {
this.clearSearchHistory();
}
}
});
},
// 跳转到详情页
goToDetail(news) {
uni.navigateTo({
url: `/pages/detail/detail?id=${news.id}`
});
}
}
}
</script>
4. 应用优化与最佳实践
4.1 性能优化
新闻资讯应用需要处理大量的内容和图片,性能优化尤为重要:
- 图片懒加载:只加载可视区域内的图片,减少初始加载时间
- 数据缓存:缓存已加载的新闻数据,避免重复请求
- 分页加载:采用分页方式加载数据,减轻服务器压力
- 骨架屏:在内容加载过程中显示骨架屏,提升用户体验
- 资源预加载:预加载可能需要的资源,如下一页的新闻列表
js
// 图片懒加载示例
export default {
data() {
return {
observer: null
}
},
mounted() {
// 创建交叉观察器
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入可视区域,加载图片
const img = entry.target;
const realSrc = img.dataset.src;
if (realSrc) {
img.src = realSrc;
img.removeAttribute('data-src');
// 停止观察该元素
this.observer.unobserve(img);
}
}
});
});
// 开始观察所有图片元素
this.$nextTick(() => {
const imgs = document.querySelectorAll('img[data-src]');
imgs.forEach(img => {
this.observer.observe(img);
});
});
},
beforeDestroy() {
// 销毁观察器
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
4.2 离线支持
为了提升用户体验,可以实现基本的离线浏览功能:
- 本地存储:将已加载的新闻内容存储在本地
- 离线提示:在无网络状态下提示用户正在使用离线模式
- 数据同步:网络恢复后自动同步最新内容
js
// 离线支持示例
export default {
data() {
return {
isOnline: navigator.onLine
}
},
created() {
// 监听网络状态变化
window.addEventListener('online', this.updateOnlineStatus);
window.addEventListener('offline', this.updateOnlineStatus);
},
destroyed() {
// 移除监听器
window.removeEventListener('online', this.updateOnlineStatus);
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
// 更新网络状态
updateOnlineStatus() {
this.isOnline = navigator.onLine;
if (this.isOnline) {
uni.showToast({
title: '网络已恢复',
icon: 'none'
});
// 同步最新数据
this.syncData();
} else {
uni.showToast({
title: '网络已断开,正在使用离线模式',
icon: 'none'
});
}
},
// 同步数据
syncData() {
// 实现数据同步逻辑
},
// 获取新闻数据(优先使用缓存)
async getNewsData(params) {
try {
if (this.isOnline) {
// 在线模式,从服务器获取数据
const data = await this.fetchFromServer(params);
// 缓存数据
this.cacheData(params, data);
return data;
} else {
// 离线模式,从缓存获取数据
return this.getFromCache(params);
}
} catch (e) {
// 获取失败,尝试从缓存获取
return this.getFromCache(params);
}
}
}
}
4.3 个性化推荐
基于用户的阅读历史和兴趣标签,实现个性化内容推荐:
- 用户画像:根据用户阅读历史和行为构建用户画像
- 内容标签:为新闻内容添加标签,便于匹配用户兴趣
- 协同过滤:基于相似用户的行为推荐内容
- 实时更新:根据用户最新行为动态调整推荐内容
js
// 个性化推荐示例
export default {
methods: {
// 获取推荐新闻
async getRecommendNews() {
try {
// 获取用户兴趣标签
const userTags = this.getUserTags();
// 获取用户阅读历史
const readHistory = this.getReadHistory();
const { result } = await uniCloud.callFunction({
name: 'news',
data: {
action: 'getRecommendNews',
userTags,
readHistory
}
});
return result.data || [];
} catch (error) {
console.error('获取推荐新闻失败', error);
throw error;
}
},
// 获取用户兴趣标签
getUserTags() {
// 从用户阅读历史中提取兴趣标签
const readHistory = uni.getStorageSync('readHistory') || '[]';
const history = JSON.parse(readHistory);
// 统计标签出现频率
const tagCount = {};
history.forEach(item => {
if (item.tags) {
item.tags.forEach(tag => {
tagCount[tag] = (tagCount[tag] || 0) + 1;
});
}
});
// 按频率排序
const sortedTags = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
// 返回前5个标签
return sortedTags.slice(0, 5);
},
// 获取用户阅读历史ID列表
getReadHistory() {
const readHistory = uni.getStorageSync('readHistory') || '[]';
const history = JSON.parse(readHistory);
// 返回最近20条阅读记录的ID
return history.slice(0, 20).map(item => item.id);
}
}
}
5. 总结与拓展
5.1 开发要点总结
- 模块化设计:将应用拆分为多个功能模块,提高代码可维护性
- 状态管理:使用Vuex集中管理应用状态,处理复杂的数据流
- 组件复用:设计可复用的组件,如新闻列表项、评论项等
- 性能优化:实现图片懒加载、数据缓存等优化措施
- 用户体验:注重细节,如骨架屏、下拉刷新、上拉加载等
5.2 功能拓展方向
基于新闻资讯应用的基础功能,可以考虑以下拓展方向:
- 个性化推荐:基于机器学习的内容推荐系统
- 语音播报:支持新闻内容的语音播报功能
- 视频新闻:集成短视频形式的新闻内容
- 社区互动:增加用户讨论区,提高用户粘性
- 数据可视化:通过图表展示热点新闻和数据分析
5.3 商业化思路
新闻资讯应用的商业化路径通常包括:
- 广告变现:信息流广告、开屏广告、原生广告等
- 会员订阅:提供优质内容和增值服务的付费订阅
- 电商导流:与电商平台合作,导流获取佣金
- 数据服务:在合规前提下,提供数据分析服务
- 内容付费:优质原创内容的付费阅读