多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vue3实战仿今日头条APP:从架构设计到性能优化全解析

Vue3实战仿今日头条APP:从架构设计到性能优化全解析 简介这是一套基于Vue3开发的移动端新闻资讯类应用实战源码专为前端初学者及高校学生设计适用于课程设计、期末大作业或毕业设计等实践场景。项目完整复刻今日头条App核心功能模块涵盖首页推荐、热点频道、视频流、搜索与用户交互等典型业务逻辑帮助学习者掌握Composition API、Pinia状态管理、Axios请求封装、路由守卫及响应式布局等Vue3关键技术点。压缩包共22个文件含6个Vue组件文件实现页面结构与交互、7个TS类型定义与逻辑脚本保障类型安全与模块化、3个JSON配置文件如mock数据与路由配置以及HTML、MD文档、图标与配置文件等整体仅80KB轻量易读。目前已有1271人下载学习配套README.md提供清晰的项目说明与运行指引目录结构规范代码注释充分便于快速上手、二次开发与教学演示。 说实话拿Vue3仿一个今日头条APP一开始我以为就是拼页面、走路由、调接口的三件套活儿。结果真做下来才发现信息流、频道管理、视频播放、用户体系、缓存策略每一块单独拆出来都不难但组合到一起非常考验对Vue3生态的熟练度。这个项目做完以后我对组合式API、Pinia状态管理、移动端适配和接口Mock的理解明显比刷十遍教程都扎实。这篇博文就基于我最近完成的一个Vue3仿今日头条APP项目来写。我会按“为什么做、技术选型、模块实现、状态管理、性能优化、问题排查”这条线把整个项目的设计思路、核心代码和踩坑记录都摊开讲。无论你是刚学完Vue3基础想找项目练手的前端新人还是准备用Vue3做移动端资讯类应用的开发者这篇文章都能给你一份能直接落地的参考方案。1. 项目概述与核心需求拆解1.1 为什么选今日头条作为仿制对象这几年Vue3实战项目很多电商、后台管理、聊天工具都有但我一直觉得资讯类App是特别适合用来练手的方向。拿今日头条来说它的页面形态非常典型顶部频道栏可以横向滚动每个频道对应一种内容分类内容以信息流卡片的形式无限加载间或插入视频流和图文内容。把这些机制复刻一遍基本就覆盖了移动端资讯产品最常见的所有交互场景。再有就是头条的内容分类比较多涉及到的数据模型并不复杂但展示形式变化很大。同一条数据可能在推荐频道里是纯图文在热点频道里变成了带热度标签的榜单在视频频道里又变成了滑动播放的短视频。这种“数据同源、形态各异”的设计做前端项目时必须通过合理的组件分层来消化而不是每个页面各写一套。能把这个抽象做好你在真实业务里的组件复用能力会上一个台阶。最后一点资讯类应用天然适合做纯前端项目。后端接口完全可以用Mock数据模拟不需要真的搭服务端这样一来项目可以跑在任何环境下分享出去别人拿到源码也能直接启动。对于写博客、做作品集、或者准备面试作品的人来说这比依赖一个远程后端接口要稳妥得多。1.2 功能清单与项目边界我做的这个版本没有盲目追求大而全而是先圈定了一个“核心闭环”再逐步补细节。功能清单如下登录页手机号验证码登录带表单校验和token本地持久化首页信息流顶部频道栏支持横向滑动、选中切换频道可编辑排序内容列表图文卡片、视频卡片、带热榜标识的榜单卡片三种形态统一渲染下拉刷新与上拉加载分页数据加载带加载更多和没有更多状态详情页图文详情展示作者信息区点赞收藏操作视频页瀑布流视频列表点击进入全屏播放支持滑动切换微头条短内容发布流类似朋友圈的动态卡片个人中心用户信息展示、我的收藏、浏览记录、设置项搜索页热门搜索关键词展示输入联想与搜索结果列表边界方面我没有做评论系统、没有做兴趣标签推荐算法、也没有做真正的用户注册体系。这些功能要么需要后端支撑要么算法成分大于前端成分硬塞进来反而会把项目拖成一个“什么都沾一点但什么都没讲透”的缝合怪。要说清楚的是这套功能做下来工作量并不小但如果按模块去拆每个模块的实现难度都在可控范围内。核心难点集中在三处信息流组件的性能、频道管理的状态同步以及Mock数据与真实接口之间的无缝切换。2. 技术选型与工程化搭建2.1 为什么是Vue3Vite而不是其他组合项目没有用Vue CLI直接上了Vite。原因很简单Vite基于ESModule的按需编译机制在开发环境下的冷启动速度和热更新体验比Webpack系的方案快出一个量级。尤其在项目组件数量变多以后Vite的更新基本是毫秒级响应保存代码立刻就能看到效果这个体感对开发效率的提升非常明显。Vue3本身当然也是核心。组合式APIComposition API带来的最大好处是逻辑复用更自然。比如把“加载列表数据”封装成一个useList组合式函数首页信息流、搜索列表、我的收藏都能复用同一套逻辑只传入不同的请求函数和参数即可。这在Vue2的Options API里通常要靠mixin但mixin的命名冲突和来源不直观问题一直很头疼。选型时我还考虑了React Native、Flutter这些跨端方案但目标很明确这是一个纯前端Web项目核心目的是吃透Vue3生态。React Native和Flutter虽然也能实现类似界面但技术栈完全不同对Vue3学习者没有参考价值。既然是“仿今日头条”实际上做的是一个移动端H5应用跑在浏览器和WebView里Vue3就是最合适的选择。2.2 工程化配置细节脚手架初始化用的是Vite自带的vue模板然后我额外加了ESLint和Prettier。很多初学者会跳过这步觉得“配置麻烦”“不配置也能跑”。但项目一旦上了规模统一代码风格能省下的时间远超配置成本尤其是团队协作时。我自己的习惯是ESLint负责规则校验Prettier负责格式化两者配合使用。# 初始化项目 npm create vitelatest toutiao-app -- --template vue # 安装基础依赖 npm install vue-router4 pinia axios # 安装开发依赖 npm install -D eslint prettier eslint-plugin-vue eslint-config-prettierESLint配置我直接采用了eslint-plugin-vue的vue3-strongly-recommended规则集在此基础上关掉了几个跟Prettier冲突的规则。这里有个值得说的细节一定要装eslint-config-prettier否则ESLint的缩进规则和Prettier的格式化规则互相打架保存代码时格式化一次、提交时又被ESLint报错十分折磨。2.3 目录结构规划一个实战项目的目录结构某种程度上决定了后续维护的体验。我采用的是“按业务模块划分”的方式而不是纯按文件类型划分src/ ├── api/ # 接口请求定义 │ ├── modules/ │ │ ├── article.js # 文章相关接口 │ │ ├── user.js # 用户相关接口 │ │ └── video.js # 视频相关接口 │ └── request.js # axios封装实例 ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── ListCard/ # 信息流卡片按类型拆分 │ ├── ChannelBar/ # 频道栏组件 │ ├── NavBar/ # 导航栏组件 │ └── ... ├── composables/ # 组合式函数 │ ├── useList.js # 列表加载逻辑 │ ├── useAuth.js # 登录态逻辑 │ └── useScroll.js # 滚动监听逻辑 ├── router/ # 路由配置 ├── stores/ # Pinia状态 ├── styles/ # 全局样式 └── views/ # 页面级组件 ├── Home/ ├── Login/ ├── Detail/ ├── Video/ ├── Search/ └── User/按业务模块划分的好处是改一个功能时你只需要关注一个目录。比如修改文章详情页就直接进views/Detail/相关组件、样式、请求都在附近不需要在components和views之间来回跳。这和Vue官方推荐的“功能导向”组织方式一致实践下来维护效率比按文件类型划分高很多。3. 核心功能模块设计与实现3.1 首页信息流与频道管理首页是整个项目里最核心的页面也是我花时间最多的地方。页面结构分两块顶部是频道栏下面是对应频道的内容列表。这个结构看着简单但实现时有一个很关键的设计决策频道切换时内容列表是保留还是重建如果切换频道就销毁列表再重建好处是内存占用低坏处是用户滑动到第5屏后切走再切回来位置丢失了体验很差。我的做法是保留所有访问过的频道列表实例用v-show来控制显示隐藏配合keep-alive缓存组件状态。频道很多时内存会涨但对移动端H5来说同时存五六个频道列表完全可以接受。频道数据用Pinia管理基本结构是这样// stores/channel.js import { defineStore } from pinia export const useChannelStore defineStore(channel, { state: () ({ channels: [ { id: recommend, name: 推荐, type: feed }, { id: hot, name: 热榜, type: rank }, { id: video, name: 视频, type: video }, // ... 更多频道 ], currentChannelId: recommend, }), getters: { currentChannel: (state) state.channels.find((item) item.id state.currentChannelId), }, actions: { setChannel(id) { this.currentChannelId id }, }, })列表加载逻辑被抽成了一个组合式函数这也是整个项目复用率最高的部分// composables/useList.js import { ref } from vue export function useList(fetcher) { const list ref([]) const loading ref(false) const finished ref(false) const page ref(1) const pageSize 10 async function loadMore() { if (loading.value || finished.value) return loading.value true try { const res await fetcher({ page: page.value, pageSize: pageSize.value }) list.value.push(...res.list) page.value 1 if (list.value.length res.total) { finished.value true } } finally { loading.value false } } function reset() { list.value [] page.value 1 finished.value false } return { list, loading, finished, loadMore, reset } }这个组合式函数接收一个fetcher函数作为参数返回列表数据和加载方法。任何页面需要分页列表时只需要一行代码接入。比如首页信息流const { list, loading, finished, loadMore, reset } useList(fetchArticleList) // 监听频道切换时重置列表 watch(() channelStore.currentChannelId, () { reset() loadMore() })下拉刷新和上拉加载我直接用了一个移动端友好的滚动方案。下拉刷新用的是vueuse/core里的useScroll配合useEventListener手写没有引入额外的下拉刷新库。上拉加载的逻辑很简单监听滚动事件当scrollTop clientHeight scrollHeight - 100时就触发loadMore()。这里有一个需要注意的细节滚动事件非常频繁如果每次都直接执行加载逻辑很容易在列表底部触发多次请求。所以组合式函数里的loading标志非常关键它保证同一时间只有一个请求在途。另外100px的提前量也值得调一调设太大用户还没滑到底就开始加载设太小的话滚动到最底部时会出现瞬间空白等待我实测100px到150px之间体验比较合适。3.2 内容卡片同数据多形态首页信息流里会出现纯图文卡片、视频卡片、带榜单标识的热点卡片三种形式。我的设计是一个ListCard组件作为容器内部根据item.type动态渲染不同的卡片内容。!-- components/ListCard/index.vue -- template div classlist-card clickgoDetail ArticleCard v-ifitem.type article :dataitem / VideoCard v-else-ifitem.type video :dataitem / RankCard v-else-ifitem.type rank :dataitem / /div /template script setup import ArticleCard from ./ArticleCard.vue import VideoCard from ./VideoCard.vue import RankCard from ./RankCard.vue const props defineProps({ item: { type: Object, required: true }, }) /script注意这里我用的是v-if/v-else-if链而不是component :is...动态组件。虽然动态组件更简洁但三种卡片的props结构差异比较大用v-if链可以更清晰地表达“哪种类型渲染哪个组件”的映射关系调试时也更直观。项目里卡片种类只有三种这样写完全够用。卡片组件内部是纯展示逻辑所有数据都通过props传入点击事件通过emit向外暴露。这样做的原因是信息流卡片的点击行为在不同页面可能不一样首页点击进详情页收藏页点击可以进详情也可以取消收藏如果卡片组件内部直接跳路由复用性就大打折扣了。3.3 视频页与沉浸式播放视频频道在头条里是一个独立的沉浸式体验模块竖屏全屏滑动、自动播放。这个模块我拆分成了视频列表页和全屏播放页两个部分。列表页采用的还是useList那套逻辑但卡片展示的是视频封面图封面图用video.cover字段。点击封面图后进入全屏播放页这时页面拿到视频ID再通过接口获取视频的播放地址。播放器我选择了原生video标签配合video.js的H5皮肤没有引入vue-video-player这类封装库。原因是这个项目只需要最简单的播放、暂停、进度条控制原生能力完全覆盖引入封装库反而增加了一层学习和排错成本。播放页的代码大致长这样!-- views/VideoPlayer.vue -- template div classvideo-player-page video refvideoRef :srcvideoUrl classvideo-player autoplay controls playsinline webkit-playsinline /video !-- 下滑/上滑切换视频 -- /div /template滑动切换视频的实现我用的是touchstart和touchmove事件记录起始Y坐标和结束Y坐标如果位移超过80px就触发切到上一条或下一条。这里是全屏播放页页面本身不做滚动所以手势逻辑相对简单。要特别提醒的是playsinline属性。在iOS Safari里如果不加这个属性视频会自动进入全屏播放导致自定义的上滑切换手势完全失效。安卓WebView一般没有这个问题但做移动端H5时必须把playsinline和webkit-playsinline都加上。3.4 登录鉴权和用户体系用户体系用手机号验证码模拟。考虑到没有真实后端验证码环节我在Mock层做了处理固定返回验证码123456前端弹出提示框告知用户输入这个验证码。登录成功后后端返回一个JWT格式的token前端存到localStorage后续请求在Axios拦截器里统一加上Authorization头。// api/request.js import axios from axios import router from /router const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (response) { // 统一处理响应数据格式 if (response.data.code 401) { localStorage.removeItem(token) router.push(/login) } return response.data }, (error) { // 统一错误提示 return Promise.reject(error) } ) export default request路由守卫做了一个简单的登录校验进入个人中心和收藏页前如果本地没有token就跳转到登录页。这里有一点值得展开token过期和用户手动清除token的场景容易忽略。我的处理是在响应拦截器里统一捕获401状态码一旦发现就清掉本地token并跳转登录页。这样一个逻辑覆盖所有接口不用每个请求单独判断。4. 状态管理与数据通信4.1 Pinia状态管理设计Pinia的使用逻辑一开始很容易走偏容易把组件通信的数据都塞进store里。我的原则很简单全局共享的才放store局部相关的留在组件内部。具体到这个项目放进store的数据只有四类用户信息多个页面需要展示登录状态路由守卫和页面跳转都要判断频道列表首页、编辑频道页、内容加载处都要用收藏列表文章卡片和收藏页需要同步以收藏列表为例如果文章卡片在列表页点击收藏收藏页必须同步更新。如果不放store就得通过事件总线或者provide/inject传一遍既绕又容易漏。用Pinia后无论是列表页收藏、收藏页取消、还是详情页操作全部走同一个store action数据自动同步// stores/user.js import { defineStore } from pinia import { fetchUserInfo, fetchUserFavorites } from /api/user export const useUserStore defineStore(user, { state: () ({ userInfo: null, token: localStorage.getItem(token) || , favorites: [], }), actions: { async login(phone, code) { const res await fetchLogin({ phone, code }) this.token res.token localStorage.setItem(token, res.token) this.userInfo res.userInfo }, async loadFavorites() { const res await fetchUserFavorites() this.favorites res.list }, toggleFavorite(articleId) { const index this.favorites.findIndex((item) item.id articleId) if (index -1) { this.favorites.splice(index, 1) } else { this.favorites.push({ id: articleId }) } }, }, })另外Pinia的store里不要存组件实例、不要存路由对象这些对象本身是外部依赖存进去会导致状态管理混乱。store里只存可以被序列化的普通数据。4.2 接口请求与Mock数据方案这个项目是纯前端没有真实后端所以Mock方案直接决定了开发体验。我试过json-server它可以快速把JSON文件变成RESTful接口但有一个问题项目要分享出去其他人拿到源码后必须额外启动一个json-server服务运行成本高了。后来我换成了Vite插件vite-plugin-mock它把Mock数据编译进打包产物里前端启动时Mock接口就自动生效对使用者来说没有任何额外启动步骤。// vite.config.js import { viteMockServe } from vite-plugin-mock export default defineConfig({ plugins: [ vue(), viteMockServe({ mockPath: mock, enable: true, }), ], })Mock文件的写法非常直观// mock/article.js export default [ { url: /api/article/list, method: get, response: ({ query }) { const { page, pageSize } query const start (page - 1) * pageSize const list mockArticleList.slice(start, start Number(pageSize)) return { code: 0, data: { list, total: mockArticleList.length, }, } }, }, ]数据方面我写了一个生成逻辑按一定规则随机生成封面图URL、标题、摘要、作者名称等字段。封面图用了picsum.photos的随机图片地址这样每个卡片看起来都是真实内容观感比纯占位图好很多。4.3 组件通信的几种方式项目里实际用到的组件通信方式有四种每种都有明确的使用场景。最常见的是props向下传数据、emit向上抛事件这种“单向数据流”的通信方式用于父子组件。比如ListCard接收item对象点击时emit(click, item)告诉父组件用户点击了哪个卡片。跨层级传递用provide/inject。比如全局的主题色配置、用户头像地址这些属性如果逐层传props会写很多样板代码用provide在根组件注入一次任何子组件都能inject拿到。共享数据用Pinia。上文已经详细说过不做重复。最后是v-model双向绑定用于表单类组件。登录页的手机号输入框我封装成了BaseInput组件通过v-model实现父子组件数据同步!-- components/BaseInput.vue -- script setup defineProps({ modelValue: String, }) const emit defineEmits([update:modelValue]) /script template input :valuemodelValue inputemit(update:modelValue, $event.target.value) / /template这四种方式各司其职实践中很少出现“不知道用什么方式通信”的困惑。如果发现某个数据的传递路径绕了很多层大概率是设计问题应该考虑把它提升到Pinia里。5. 移动端适配与性能优化5.1 移动端适配方案移动端H5适配的核心是解决不同屏幕宽度下页面元素尺寸不一致的问题。项目采用的是viewport布局配合postcss-px-to-viewport插件将px单位自动转换为vw单位。// postcss.config.js module.exports { plugins: { postcss-px-to-viewport: { viewportWidth: 375, // 设计稿宽度 unitPrecision: 5, viewportUnit: vw, selectorBlackList: [.ignore-], minPixelValue: 1, mediaQuery: false, }, }, }设置之后代码里写的font-size: 28px在编译时自动变成font-size: 7.46667vw不同屏幕宽度下会自动等比缩放。这种方案比rem方案更省事不需要在JS里动态计算根字号。但有一个例外要处理有些元素不希望随屏幕缩放比如1px的边框线。如果也转成vw在部分机型上可能出现显示不全或消失的情况。解决办法是在selectorBlackList里加一个标记类名或者在样式中写死px值时用/* px */注释来阻止转换。5.2 列表渲染优化与虚拟滚动信息流页面最怕的就是列表变长之后滑动掉帧。头条的信息流卡片包含图片、文字、多个按钮如果一次性渲染几百条DOM再强的手机也会卡。项目里做了两件事来优化。第一件是图片懒加载。我用v-lazy指令配合intersection-observer库实现图片进入视口时才加载。这不仅减少了请求量也极大降低了初始渲染的DOM复杂度。// 全局注册懒加载指令 import { createApp } from vue import lazyPlugin from vue3-lazy const app createApp(App) app.use(lazyPlugin, { loading: require(/assets/loading.png), error: require(/assets/error.png), })第二件是列表分区渲染。当列表长度超过50条时我会把列表拆成“可视区”和“缓冲区”通过滚动位置动态计算需要渲染的区间。这个方案比完整的虚拟滚动简单但效果已经足够好。实现思路是监听滚动计算当前视口对应在列表中的索引范围然后用slice截取需要渲染的数据。// composables/useVirtualList.js import { computed, ref } from vue export function useVirtualList(totalList, itemHeight, containerHeight) { const startOffset ref(0) const startIndex ref(0) const visibleCount computed(() Math.ceil(containerHeight / itemHeight) 5) function handleScroll(scrollTop) { startIndex.value Math.floor(scrollTop / itemHeight) startOffset.value scrollTop - (scrollTop % itemHeight) } computed(() { return { visibleList: totalList.slice(startIndex.value, startIndex.value visibleCount.value), startOffset: startOffset.value, } }) }实际上因为Mock数据量不算特别大我把虚拟滚动作为可选优化项放在独立分支里默认分支用的还是普通懒加载。但如果你对接真实接口数据量动辄上百条这个虚拟滚动逻辑就非常有必要了。5.3 构建打包优化构建优化主要做了三件事。第一件是路由懒加载。把每个页面级组件都改成动态导入这样首屏只加载当前页面的代码不会把所有页面一次性打包进去// router/index.js const routes [ { path: /, component: () import(/views/Home/index.vue), }, { path: /detail/:id, component: () import(/views/Detail/index.vue), }, // ... ]第二件是第三方库的按需引入。项目里用到的vant组件库移动端UI组件库可以按需引入而不是全量打包。在Vite项目里直接用unplugin-vue-components插件自动按需引入不用手动配置每个组件// vite.config.js import Components from unplugin-vue-components/vite import { VantResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ Components({ resolvers: [VantResolver()], }), ], })第三件是压缩构建产物。Vite默认使用esbuild做代码压缩但css压缩不是默认开启的。我手动加了vite-plugin-compression做gzip压缩部署到Nginx后能显著减少传输体积。构建优化做完后我实测项目首屏JS从原始1.8MB降到500KB左右再经过gzip后网络传输约150KB。对于一个移动端H5应用来说这个体积已经可以接受了。6. 常见问题与排查实录6.1 接口跨域问题用Vite做开发时接口请求最容易遇到的坑就是跨域。如果Mock接口是本地服务浏览器跨域限制会挡住请求此时需要在Vite配置中设置proxy代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, // 实际接口地址 changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ), }, }, }, })但要注意Mock数据方案用的是vite-plugin-mock时请求会由Vite的开发服务器直接处理通常不会遇到跨域问题。真正跨域多发生在后端接口已在远程部署、而前端开发服务器在本地时。如果遇到跨域第一步先看Network面板确认请求是否真的发出去了、响应是不是被CORS拦截。有时候是请求头带了Authorization后端没配Access-Control-Allow-Headers导致的。6.2 组件渲染闪烁问题项目开发过程中出现了一个很典型的渲染闪烁问题页面从A状态切换到B状态时内容会先闪一下旧数据再更新。排查后发现是异步数据加载导致的。比如从首页切到详情页详情页首先用ref(null)初始化此时模板里读取article.title就会报错或者闪出默认值。解决方法是加一个v-if判断只有数据加载完成后才渲染内容template div v-ifarticle classdetail-page h1{{ article.title }}/h1 /div div v-else classdetail-loading加载中.../div /template这个经验其实很朴素任何异步数据驱动的渲染都必须先判断数据是否就绪。很多初级开发者写代码时数据一秒钟就返回了不写判断也看不出问题但真实网络环境下几百毫秒的延迟就能让用户看到一片空白或者错误闪现。6.3 图片加载闪烁与布局抖动图片加载是移动端页面布局抖动的最大来源。卡片封面图没有固定高度时图片加载完成后会把下面的标题挤下去用户体验极差。我的解决办法是给图片容器设置一个占位宽高比.article-cover { width: 100%; aspect-ratio: 16 / 9; /* 按设计稿设置宽高比 */ background-color: #f5f5f5; overflow: hidden; } .article-cover img { width: 100%; height: 100%; object-fit: cover; }使用aspect-ratio后图片容器在加载前就占好了位置加载完成后不会产生布局偏移。这个方法现在浏览器支持度已经很高可以直接用不用再自己计算padding-top。另外一个与之相关的坑是object-fit: cover。如果图片实际比例和容器比例不一致不加这个属性就会出现拉伸变形。这个属性在移动端WebView里表现基本稳定但在非常老旧的安卓WebView里可能失效如果必须兼容老旧设备可以在上传图片时就统一裁剪好尺寸。6.4 路由跳转后滚动位置丢失从首页信息流划到第20条点进详情页再返回我希望回到原来第20条的位置。这在浏览器里有两种处理方式一是用原生scrollRestoration属性二是手动记录滚动位置。原生方式在部分浏览器上不可靠所以我用了手动记录// 在信息流页离开前记录scrollTop onBeforeRouteLeave(() { const scrollTop document.documentElement.scrollTop sessionStorage.setItem(scroll-${channelId}, String(scrollTop)) }) // 重新进入时恢复 onMounted(() { const savedScroll sessionStorage.getItem(scroll-${channelId}) if (savedScroll) { nextTick(() { window.scrollTo(0, Number(savedScroll)) }) } })不同频道的滚动位置分别用频道ID做key存储互不干扰。这个细节虽然不大但直接影响用户的真实使用体验是移动端页面必不可少的一环。7. 项目扩展方向项目做完基本功能后有几个方向可以继续延伸。这里挑几个我认为投入产出比最高的说说。第一是接入真实后端。Mock数据虽然开发方便但和真实接口的差异会在联调时暴露出来比如字段命名规范、错误码设计、分页返回格式等。如果后续想做成完整作品集建议用Node.js写一个简单的后端服务将接口从Mock切换到真实实现这个过程能让你理解前后端联调的全貌。第二是增加内容推荐机制。头条最核心的产品逻辑是“千人千面”虽然前端做不到真正的推荐算法但可以在前端根据用户的浏览记录做一个简单的本地推荐用户多看某类文章就多展示同类内容。这个功能不涉及后端用Pinia存储用户行为日志再在请求参数里带上偏好权重Mock接口按权重调整返回内容即可。第三是PWA化。把项目通过vite-plugin-pwa增强为一个可安装的PWA应用支持离线缓存和消息推送。头条本身就是内容阅读产品离线场景的价值非常大这个方向做起来也很有意思。第四是数据可视化。头条的创作者后台有大量的阅读量、评论量趋势图你可以在这个仿头条项目里增加一个“创作者中心”模块用ECharts展示统计数据。这个模块在技术栈上是全新的能让项目覆盖到的知识面更广。最后一点个人体会写这个项目源码的过程中我最深的一个感受是仿一个成熟产品并不等于照着UI抄一遍。真正的难点在于把一个看似简单的页面拆解成合理的数据流和可复用的组件结构。首页信息流里那三种卡片如果一开始不做组件抽象后面加频道、加搜索、加收藏时一定会越写越乱。组件抽象和状态设计才是这类实战项目最值钱的部分。另外还有一点体会移动端H5开发真正决定体验的往往不是那些花哨的动效而是图片懒加载、滚动位置恢复、加载状态管理这些看起来不起眼的细节。我在调试图片闪烁和滚动位置问题时花的时间比写任何一个页面组件都多但修完之后整个应用就是不一样了。如果读者也想自己动手写一个类似的Vue3实战项目我的建议是先别急着写代码花半天时间把功能清单列出来把数据模型确定好把组件树画出来然后从最简单的登录页做起一屏一屏地往下推。等你把整个项目跑通一遍再回头去看Vue3的文档和面试题很多概念都会突然变得特别好理解。本文还有配套的精品资源点击获取
返回列表