本文已参与「新人创作礼」活动,一起开启掘金创作之路
1. 项目搭建
使用antf大佬的vitesse作为模板创建项目,省去很多搭建项目的时间
npx degit antfu/vitesse-lite vue3-ts-news-headline
cd vue3-ts-news-headline
pnpm i # If you don't have pnpm installed, run: npm install -g pnpm
2. vue-router配置
首先定义路由
// src/router/routes/index.ts
import type { RouteRecordRaw } from 'vue-router';
import Home from '@/pages/Home.vue';
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/detail',
name: 'Detail',
component: () => import('@/pages/Detail.vue'),
},
{
path: '/collection',
name: 'Collection',
component: () => import('@/pages/Collection.vue'),
},
];
然后创建并导出router实例,同时创建一个setupRouter函数用于在vue实例中注册router
// src/router/index.ts
import { App } from 'vue';
import { createRouter, createWebHashHistory } from 'vue-router';
import { routes } from './routes';
const router = createRouter({
routes,
history: createWebHashHistory(),
});
export function setupRouter(app: App<Element>) {
app.use(router);
}
export default router;
在main.ts中调用setupRouter进行注册
import { createApp } from 'vue';
import App from './App.vue';
import '@unocss/reset/tailwind.css';
import './styles/main.css';
import 'uno.css';
import { setupRouter } from './router';
function bootstrap() {
const app = createApp(App);
// vue-router
setupRouter(app);
app.mount('#app');
}
bootstrap();
3. 封装axios
封装的思路很简单,只用添加一个响应拦截器,获取到api返回的数据而不是AxiosResponse
// src/utils/http.ts
import axios from 'axios';
import qs from 'qs';
import { BASE_URL } from '@/config/apiConfig';
import { ContentType } from '@/config/httpConfig';
const instance = axios.create({
headers: {
'Content-Type': ContentType.URL_ENCODED,
},
baseURL: BASE_URL,
});
instance.interceptors.response.use((res) => {
return res.data;
});
export function axiosGet<T = any>(url: string, params: any): Promise<T> {
return instance.get(url, { params });
}
export function axiosPost<T = any>(url: string, data: any): Promise<T> {
return instance.post(url, qs.stringify(data));
}
注意,直接调用axios实例的get、post方法,返回的是一个Promise
ContentType是一个枚举,放在配置文件中
// src/config/httpConfig.ts
export enum ContentType {
URL_ENCODED = 'application/x-www-form-urlencoded',
}
BASE_URL是一个常量,也放在配置文件中
// src/config/apiConfig.ts
export const BASE_URL = 'http://api.jsplusplus.com';
export enum API {
GET_NEWS_LIST = '/Juhe/getNewsList',
}
4. 封装API
4.1 根据接口返回结果抽象成interface
目前要用到的api就只有获取新闻列表
接口返回的数据格式如下:
{
"code": 0,
"message": "success",
"data": {
"newsList": [
{
"uniquekey": "098df67baa107c434145726701515779",
"title": "四川:海拔4800米巴朗山突降大雪 消防紧急营救被困游客",
"date": "2022-04-24 20:34:00",
"category": "头条",
"author_name": "人民资讯",
"url": "https://mini.eastday.com/mobile/220424203441180310082.html",
"thumbnail_pic_s": "https://dfzximg02.dftoutiao.com/news/20220424/20220424203441_b681f9259e006b863e12ecf09c34aafd_1_mwpm_03201609.jpeg",
"thumbnail_pic_s02": "https://dfzximg02.dftoutiao.com/news/20220424/20220424203441_b681f9259e006b863e12ecf09c34aafd_2_mwpm_03201609.jpeg",
"is_content": "1"
},
{
"uniquekey": "e20703db884d5d4b7079cd43ef6a79d6",
"title": "热夏不贪水,东昌府区黄庄小学开展防溺水安全教育",
"date": "2022-04-24 20:30:00",
"category": "头条",
"author_name": "人民资讯",
"url": "https://mini.eastday.com/mobile/220424203011906484693.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "9374ce30f5036efce91f280fffb61446",
"title": "15死25伤!吉林省应急管理厅公布《吉林省长春市李氏婚纱梦想城“7·24”重大火灾事故调查报告》",
"date": "2022-04-24 20:30:00",
"category": "头条",
"author_name": "人民资讯",
"url": "https://mini.eastday.com/mobile/220424203006021340389.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "ebfeb45e32f48241bf51f1d29b374be2",
"title": "民房起火冒出浓烟,民警冲入室内抢运生活物资",
"date": "2022-04-24 20:29:00",
"category": "头条",
"author_name": "人民资讯",
"url": "https://mini.eastday.com/mobile/220424202952136492393.html",
"thumbnail_pic_s": "https://dfzximg02.dftoutiao.com/news/20220424/20220424202952_cdef6cf7c9b9155ea09e04dffc4ef386_1_mwpm_03201609.jpeg",
"is_content": "1"
},
{
"uniquekey": "802634146e192e7637cffc6d763f5e4e",
"title": "方舱生命“小公寓”,民警驻守“大家庭”",
"date": "2022-04-24 20:20:00",
"category": "头条",
"author_name": "人民资讯",
"url": "https://mini.eastday.com/mobile/220424202036313655935.html",
"thumbnail_pic_s": "https://dfzximg02.dftoutiao.com/news/20220424/20220424202036_7d7bf3690824c0f14b55f19775ee2872_1_mwpm_03201609.jpeg",
"thumbnail_pic_s02": "https://dfzximg02.dftoutiao.com/news/20220424/20220424202036_7d7bf3690824c0f14b55f19775ee2872_2_mwpm_03201609.jpeg",
"is_content": "1"
},
{
"uniquekey": "c035574a604843f301a1b5a929c44be7",
"title": "财鑫闻丨大逆转!泰禾预计2021年巨亏46亿,业绩突变公司股票恐将被“ST”",
"date": "2022-04-24 20:14:00",
"category": "头条",
"author_name": "大众网",
"url": "https://mini.eastday.com/mobile/220424201419422734973.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "05dc32f508a8ec16fda67b00be687441",
"title": "“重症八仙”已有3位奔赴上海支援",
"date": "2022-04-24 20:14:00",
"category": "头条",
"author_name": "人民日报微信",
"url": "https://mini.eastday.com/mobile/220424201404046501787.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "0dd84c8f30d3ab47f823cb423c0f94af",
"title": "专车接送 上海奉贤开设60岁以上老年人疫苗接种专场",
"date": "2022-04-24 20:13:00",
"category": "头条",
"author_name": "央视新闻客户端",
"url": "https://mini.eastday.com/mobile/220424201338912800741.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "a2d8871d4eda80bed9be77154ba666ff",
"title": "稳价保供|近百人称吃了分发物资腹泻,所涉多家企业屡被处罚",
"date": "2022-04-24 20:13:00",
"category": "头条",
"author_name": "澎湃新闻",
"url": "https://mini.eastday.com/mobile/220424201319022673822.html",
"thumbnail_pic_s": "https://dfzximg02.dftoutiao.com/news/20220424/20220424201319_249cb5aff6dc377f877e5cbde6a0f9e4_1_mwpm_03201609.jpeg",
"thumbnail_pic_s02": "https://dfzximg02.dftoutiao.com/news/20220424/20220424201319_249cb5aff6dc377f877e5cbde6a0f9e4_2_mwpm_03201609.jpeg",
"thumbnail_pic_s03": "https://dfzximg02.dftoutiao.com/news/20220424/20220424201319_249cb5aff6dc377f877e5cbde6a0f9e4_3_mwpm_03201609.jpeg",
"is_content": "1"
},
{
"uniquekey": "57a732f74fed307853cebfd94af62c51",
"title": "下水道不可过量倒消毒剂!下水道如果传播病毒怎么办?",
"date": "2022-04-24 20:13:00",
"category": "头条",
"author_name": "新民晚报",
"url": "https://mini.eastday.com/mobile/220424201300732762308.html",
"thumbnail_pic_s": "",
"is_content": "1"
},
{
"uniquekey": "9375973144d2f01901ab33c7461010e7",
"title": "方舱教室:努力给孩子心头留下一道光",
"date": "2022-04-24 20:12:00",
"category": "头条",
"author_name": "新民晚报",
"url": "https://mini.eastday.com/mobile/220424201205637962909.html",
"thumbnail_pic_s": "",
"is_content": "1"
}
],
"hasMore": false
}
}
将接口返回的结果放到一些json转interface的网站转成interface,不用手写那么麻烦
由于该接口是newsList的,因此我们在api目录下新建一个newsList目录,并在index.ts中封装接口,在typing.ts中定义类型
// src/api/newsList/typing.ts
export type NewsType =
| 'top'
| 'guonei'
| 'guoji'
| 'yule'
| 'tiyu'
| 'junshi'
| 'keji'
| 'caijing'
| 'youxi'
| 'qiche'
| 'jiankang';
export interface INewsData {
uniquekey: string;
title: string;
date: string;
category: string;
author_name: string;
url: string;
thumbnail_pic_s: string;
thumbnail_pic_s02?: string;
thumbnail_pic_s03?: string;
is_content: string;
}
export interface INewsListInfo {
newsList: INewsData[];
hasMore: boolean;
}
由于api接口返回的数据格式是统一的,因此还需要在src/api/typing.ts中定义一下统一响应的数据格式接口
// src/api/typing.ts
export interface IApiResult<T = any> {
code: number;
message: string;
data: T;
}
4.2 封装获取新闻列表接口
// src/api/newsList/index.ts
import { API } from '@/config/apiConfig';
import { axiosGet } from '@/utils/http';
import { INewsListInfo, NewsType } from './typing';
import { IApiResult } from '../typing';
/**
* 获取新闻列表
* @param type 新闻类型 -- NewsListTypeParams 中定义了所有新闻类型
* @param pageCount 每次加载到页面上的新闻条数
* @returns 新闻列表数据
*/
export async function getNewsList(type: NewsType, page = 1, pageSize = 30) {
const apiResult = await axiosGet<IApiResult<INewsListInfo>>(API.GET_NEWS_LIST, {
type,
page,
pageSize,
});
return apiResult.data;
}
使用async、await去处理,也可以用Promise的链式调用的方式,但是我更喜欢async/await的写法。
调用axiosPost时传入了一个泛型,这个泛型就是返回的data的类型。
NewsListTypeParams是一个类型,对应新闻接口的所有type参数值
然后在src/api/index.ts中统一导出
// src/api/index.ts
export { getNewsList } from './newsList';
5. header路由逻辑
新闻头条首页的header
新闻详情的
header
收藏列表的
header
5.1 定义header相关信息的接口
根据三个页面的header的特征,我们可以定义出如下的接口
interface IHeaderInfo {
name: string; // 路由的 name
title: string;
leftIcon?: string;
rightIcon?: string;
showLeftIcon: boolean; // 是否显示左边图标
showRightIcon: boolean; // 是否显示右边图标
leftPath?: string; // 左边图标路由
rightPath?: string; // 右边图标路由
}
以及每个页面具体的hederInfo
export const headerRouteInfo: IHeaderInfo[] = [
{
name: 'Home',
title: '新闻头条',
rightIcon: 'fas fa-user',
showLeftIcon: false,
showRightIcon: true,
rightPath: '/collection',
},
{
name: 'Detail',
title: '新闻详情',
leftIcon: 'fas fa-arrow-left',
rightIcon: 'fas fa-star',
showLeftIcon: true,
showRightIcon: true,
},
{
name: 'Collection',
title: '收藏列表',
leftIcon: 'fas fa-arrow-left',
showLeftIcon: true,
showRightIcon: false,
},
];
5.2 重构接口和对象的存放位置
那么接口和headerInfo应当放到哪里呢?为了方便管理,在项目根目录下新建一个types目录,里面存放d.ts类型声明文件
// types/header.d.ts
export interface IHeaderInfo {
name: string; // 路由的 name
title: string;
leftIcon?: string;
rightIcon?: string;
showLeftIcon: boolean; // 是否显示左边图标
showRightIcon: boolean; // 是否显示右边图标
leftPath?: string; // 左边图标路由
rightPath?: string; // 右边图标路由
}
还要在tsconfig.json中将types添加到typeRoots配置项中
// tsconfig.json
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./types"],
"paths": {
"@/*": ["src/*"],
"#/*": ["types/*"]
}
},
"include": [
"types/**/*.ts",
"types/**/*.d.ts",
"vite.config.ts",
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
],
}
vite.config.ts中也给类型路径配置上别名
export default defineConfig({
resolve: {
alias: {
'@/': `${path.resolve(__dirname, 'src')}/`,
'#/': `${path.resolve(__dirname, 'types')}/`,
},
},
}
而headerRouteInfo则会为其单独存放到一个文件中,创建src/router/routeInfo/headerRouteInfo.ts,在其中定义headerRouteInfo对象,并在src/router/routeInfo/index.ts中将其导出
// src/router/routeInfo/headerRouteInfo.ts
import { IHeaderInfo } from '#/header';
export const headerRouteInfo: IHeaderInfo[] = [
{
name: 'Home',
title: '新闻头条',
rightIcon: 'fas fa-user',
showLeftIcon: false,
showRightIcon: true,
rightPath: '/collection',
},
{
name: 'Detail',
title: '新闻详情',
leftIcon: 'fas fa-arrow-left',
rightIcon: 'fas fa-star',
showLeftIcon: true,
showRightIcon: true,
},
{
name: 'Collection',
title: '收藏列表',
leftIcon: 'fas fa-arrow-left',
showLeftIcon: true,
showRightIcon: false,
},
];
// src/router/routeInfo/index.ts
export { headerRouteInfo } from './headerRouteInfo';
6. Header组件
6.1 模板视图以及样式
先将Header会用到的数据拿来,前面定义的IHeaderInfo接口中就是,默认值就是新闻头条首页的数据
<script setup lang="ts">
import { IHeaderInfo } from '#/header';
// data
const headerInfo = reactive<IHeaderInfo>({
name: 'Home',
title: '新闻头条',
rightIcon: 'person',
showLeftIcon: false,
showRightIcon: true,
rightPath: '/collection',
});
</script>
然后就是根据数据去判断heder要显示哪些内容
- 无论是哪个页面,左边的按钮只有箭头或不存在,因此只需要直接将图标当成类名放进去即可,不存在的图标则不会渲染
- 而右边的按钮分为两种类型
- 路由跳转类型,在新闻头条首页的时候点击右边按钮会跳转到收藏列表中
- 收藏文章类型,不会进行路由跳转,只会触发点击事件
根据这两个特点,使用v-if条件判断指令即可完成模板的编写
<template>
<header class="fixed top-0 left-0 w-full h-10 bg-[#1D3557] text-[#F1FAEE]">
<!-- header 左边按钮 -->
<div class="icon-area left-0">
<i
v-if="headerInfo.showLeftIcon"
:class="`iconfont ${headerInfo.leftIcon}`"
@click="goBackPage"
></i>
</div>
<h1 class="leading-10 text-center">{{ headerInfo.title }}</h1>
<!-- header 右边按钮 -->
<div class="icon-area right-0">
<!-- 新闻详情页的 header -->
<!-- 新闻详情页右边的按钮是收藏新闻 -->
<i
v-if="headerInfo.showRightIcon && headerInfo.name === 'Detail'"
:class="`iconfont ${headerInfo.rightIcon}`"
@click="handleFollowClick"
></i>
<!-- 新闻头条首页 -->
<!-- 头条首页右边的按钮是一个路由,需要跳转 仅当路由存在才会显示 -->
<router-link
v-else-if="headerInfo.showRightIcon && headerInfo.rightPath && headerInfo.name !== 'Detail'"
:to="headerInfo.rightPath"
>
<i :class="`iconfont ${headerInfo.rightIcon}`"></i>
</router-link>
</div>
</header>
</template>
由于使用了unocss,样式可以直接写在html中,以类名的方式编写
图标则是用的font-awesome的,由于font-awesome暂不支持vue3,因此我采用的是cdn的方式使用
两个按钮的icon-area样式是在unocss.config.ts的shortcuts中配置的
export default defineConfig({
shortcuts: [['icon-area', 'absolute top-0 h-10 w-10 flex justify-center items-center']],
});
6.2 监听路由变化改变Header展示形式
由于要监听路由,因此要拿到路由对象,Composition API中使用vue-router的useRoute即可拿到。
<script setup lang="ts">
// watch
watch(
() => route.name,
(routeName) => {
// 根据当前的 route.name 获取到相应的 IHeaderInfo 对象
const routeHeaderInfo: IHeaderInfo = useHeaderInfo(routeName);
},
);
</script>
监听器的使用参考vue3官方文档,使用很简单,这里就不多赘述,主要是实现useHeaderInfo这个hooks,在src/composables目录下创建useHeaderInfo.ts
// src/composables/useHeaderInfo.ts
import { IHeaderInfo } from '#/header';
import { headerRouteInfo } from '@/router/routes';
/**
* 根据 routeName 到 headerRouteInfo 中查找相应的 header 信息
* @param routeName route.name
* @returns headerInfo
*/
export function useHeaderInfo(routeName: string): IHeaderInfo | undefined {
const headerInfo = headerRouteInfo.find((item: IHeaderInfo) => item.name === routeName);
return headerInfo;
}
7. 新闻头条首页导航栏
7.1 NavBar导航栏组件
<!-- src/components/NavBar/index.vue -->
<template>
<nav>
<!-- scroll-area -->
<div class="overflow-scroll">
<!-- 滚动栏总长度 = 每一项长度 * 有多少项,NavItem 中的每一项都是 3rem -->
<div class="flex py-2" :style="{ width: navList.length * 3 + 'rem' }">
<NavItem v-for="item in navList" :key="item.type" :item="item" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { navList } from './data';
import NavItem from './NavItem.vue';
</script>
数据全都抽离到组件同级目录下的data.ts中
// src/components/NavBar/data.ts
import type { INavListItem } from './typing';
export const navList = ref<INavListItem[]>([
{
type: 'top',
name: '头条',
},
{
type: 'guonei',
name: '国内',
},
{
type: 'guoji',
name: '国际',
},
{
type: 'yule',
name: '娱乐',
},
{
type: 'tiyu',
name: '体育',
},
{
type: 'junshi',
name: '军事',
},
{
type: 'keji',
name: '科技',
},
{
type: 'caijing',
name: '财经',
},
{
type: 'youxi',
name: '游戏',
},
{
type: 'qiche',
name: '汽车',
},
{
type: 'jiankang',
name: '健康',
},
]);
7.2 NavItem组件
<!-- src/components/NavBar/NavItem.vue -->
<template>
<div class="nav-item flex justify-center items-center h-full w-[3rem] text-[16rem]">
<span>{{ item.name }}</span>
</div>
</template>
<script setup lang="ts">
import { INavListItem } from './typing';
defineProps<{ item: INavListItem }>();
</script>
<style scoped>
.active {
color: #1d3557;
font-weight: bold;
}
</style>
因为使用到了setup语法糖,且用的是ts,因此defineProps可以用泛型去声明,更加方便
7.2.1 使用vue自定义指令实现点击切换激活颜色功能
由于点击切换item的激活颜色涉及到每个item的dom元素的修改,因此最好是用自定义指令去完成,事实上vue官方文档也有说到
自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑
可以给NavBar组件的nav原生标签添加一个自定义指令v-active-item(官方文档建议不要直接给组件使用自定义指令),该指令的作用是根据item的下标去修改它的样式,比如当前下标为0,则会让第一个item的样式变为激活时的样式。
v-active-item指令中做的事情主要有:
mounted的时候,给itemIndex对应的item添加active类名,表明激活了updated的时候,将旧的itemIndex的item的active类名移除,并给新的curIndex的item添加active类名
<template>
<nav v-active-item="{ activeClass: 'active', itemClass: 'nav-item', itemIndex: 0 }">
<!-- scroll-area -->
<div class="overflow-scroll">
<!-- 滚动栏总长度 = 每一项长度 * 有多少项,NavItem 中的每一项都是 3rem -->
<div class="flex py-2" :style="{ width: navList.length * 3 + 'rem' }">
<NavItem v-for="item in navList" :key="item.type" :item="item" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { navList } from './data';
import NavItem from './NavItem.vue';
import type { Directive } from 'vue';
/**
* @description v-active-item 指令的 binding 中 value 的类型
*/
interface IVActiveItemValue {
activeClass: string;
itemClass: string;
itemIndex: number;
}
/**
* @description v-active-item 指令的 binding 参数类型
*/
interface IVActiveItemBinding {
value: IVActiveItemValue;
oldValue?: IVActiveItemValue; // 刚加载的时候,第一个 item 就没有 oldValue
}
// derectives
const vActiveItem: Directive = {
/**
* @description 给 itemIndex 对应的 item 添加 active 类名,表明激活了
*
* 由于对象解构时添加 冒号 是起别名
* 因此不能用 value: IVActiveItemValue 的方式声明类型
* 只能够是给解构出来的对象单独声明一个类型
* 这就是为什么要声明一个 IVActiveItemBinding
*/
mounted(el: HTMLElement, { value }: IVActiveItemBinding) {
const { activeClass, itemClass, itemIndex } = value;
// 获取所有的 item DOM 元素
const oNavItems = el.getElementsByClassName(itemClass);
// 找出 itemIndex 对应的那个 item 并给它添加上 activeClass 类名
oNavItems[itemIndex].classList.add(activeClass);
},
/**
* @description 将旧的 itemIndex 的 item 的 active 类名移除,并给新的 itemIndex 的 item 添加 active 类名
*/
updated(el: HTMLElement, { value, oldValue }: IVActiveItemBinding) {
const { activeClass, itemClass } = value;
const oNavItems = el.getElementsByClassName(itemClass);
// 获取旧的 item 元素 -- 移除 activeClass
oNavItems[oldValue!.itemIndex].classList.remove(activeClass); // ts 的 ! 表示 oldValue 一定存在
// 获取新的 item 元素 -- 添加 activeClass
oNavItems[value.itemIndex].classList.add(activeClass);
},
};
</script>
现在的自定义指令derective是写在组件里的,显得太长了些,我们可以将它们拆分到单个文件中
自定义指令拆分到同级目录下的derective.ts文件中
// src/components/NavBar/derective.ts
import type { Directive } from 'vue';
import { IVActiveItemBinding } from './typing';
export const vActiveItem: Directive = {
/**
* @description 给 itemIndex 对应的 item 添加 active 类名,表明激活了
*
* 由于对象解构时添加 冒号 是起别名
* 因此不能用 value: IVActiveItemValue 的方式声明类型
* 只能够是给解构出来的对象单独声明一个类型
* 这就是为什么要声明一个 IVActiveItemBinding
*/
mounted(el: HTMLElement, { value }: IVActiveItemBinding) {
const { activeClass, itemClass, itemIndex } = value;
// 获取所有的 item DOM 元素
const oNavItems = el.getElementsByClassName(itemClass);
// 找出 itemIndex 对应的那个 item 并给它添加上 activeClass 类名
oNavItems[itemIndex].classList.add(activeClass);
},
/**
* @description 将旧的 itemIndex 的 item 的 active 类名移除,并给新的 itemIndex 的 item 添加 active 类名
*/
updated(el: HTMLElement, { value, oldValue }: IVActiveItemBinding) {
const { activeClass, itemClass } = value;
const oNavItems = el.getElementsByClassName(itemClass);
// 获取旧的 item 元素 -- 移除 activeClass
oNavItems[oldValue!.itemIndex].classList.remove(activeClass); // ts 的 ! 表示 oldValue 一定存在
// 获取新的 item 元素 -- 添加 activeClass
oNavItems[value.itemIndex].classList.add(activeClass);
},
};
接口类型放到typing.ts中
// src/components/NavBar/typing.ts
/**
* @description 导航栏菜单项的类型
*/
export interface INavListItem {
type: NewsListType;
name: string;
}
/**
* @description v-active-item 指令的 binding 中 value 的类型
*/
export interface IVActiveItemValue {
activeClass: string;
itemClass: string;
itemIndex: number;
}
/**
* @description v-active-item 指令的 binding 参数类型
*/
export interface IVActiveItemBinding {
value: IVActiveItemValue;
oldValue?: IVActiveItemValue; // 刚加载的时候,第一个 item 就没有 oldValue
}
用到的数据curIndex放到data.ts中
// src/components/NavBar/data.ts
import type { INavListItem } from './typing';
// 导航栏菜单项的数据
export const navList = ref<INavListItem[]>([ //... ]);
// 当前激活的导航栏菜单下标
export const curIndex = ref(0);
组件中只要导入即可,这样就会显得简洁很多,而且也方便之后维护
<!-- src/components/NavBar/index.vue -->
<template>
<nav v-active-item="{ activeClass: 'active', itemClass: 'nav-item', itemIndex: curIndex }">
<!-- scroll-area -->
<div class="overflow-scroll">
<!-- 滚动栏总长度 = 每一项长度 * 有多少项,NavItem 中的每一项都是 3rem -->
<div class="flex py-2" :style="{ width: navList.length * 3 + 'rem' }">
<NavItem v-for="item in navList" :key="item.type" :item="item" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { navList, curIndex } from './data';
import NavItem from './NavItem.vue';
import { vActiveItem } from './derective';
</script>
7.2.2 使用emit实现点击后切换激活的菜单
现在自定义指令能够保证只要itemIndex改变了就会改变对应的样式,那么我们接下来要做的就是实现点击各个item后修改curIndex响应式变量即可,让数据驱动视图改变,具体的dom操作交给自定义指令实现,这样子我认为是最合理的。
既然是点击item后要修改curIndex,而curIndex又是父组件NavBar中的数据,因此涉及到子组件修改父组件的数据,可以通过emit自定义事件changeItemIndex出去,然后父组件监听changeItemIndex事件,修改curIndex即可
- 子组件
emit自定义事件changeItemIndex
// src/components/NavBar/NavItem.vue
const emit = defineEmits<{
// 修改 v-active-item 的 binding.value.itemIndex 为当前组件的 props.index
(e: 'changeItemIndex', index: number, item: INavListItem): void;
}>();
强烈推荐使用泛型给defineEmits提供类型标注,这样能获得一下几个好处:
- 子组件中
emit触发事件的时候,会有类型提示,避免手写自定义事件名出现拼写错误的低级错误,也避免去复制事件名浪费时间
- 父组件中给子组件添加监听事件的时候也会有提示,避免拼写错误和复制事件名,并且能够自动将驼峰转成短横线分隔!
- 子组件给
item项添加点击事件
点击时使用emit触发changeItemIndex事件,将当前item的index和item属性都传给父组件
index用来给父组件修改父组件中的curIndexitem主要用到item.type,作为参数去请求api接口获取对应类别下的新闻列表
<!-- src/components/NavBar/NavItem.vue -->
<template>
<div
class="nav-item flex justify-center items-center h-full w-[3rem] text-[16rem]"
@click="handleItemClick"
>
<span>{{ item.name }}</span>
</div>
</template>
<script setup lang="ts">
import { INavListItem } from './typing';
const props = defineProps<{
item: INavListItem;
index: number;
}>();
/**
* @description 触发 changeItemIndex 给父组件
*/
const handleItemClick = () => {
emit('changeItemIndex', props.index, props.item);
};
</script>
- 父组件中给子组件添加
changeItemIndex事件的监听器
<template>
<nav v-active-item="{ activeClass: 'active', itemClass: 'nav-item', itemIndex: curIndex }">
<!-- scroll-area -->
<div class="overflow-scroll">
<!-- 滚动栏总长度 = 每一项长度 * 有多少项,NavItem 中的每一项都是 3rem -->
<div class="flex py-2" :style="{ width: navList.length * 3 + 'rem' }">
<NavItem
v-for="(item, index) in navList"
:key="item.type"
:item="item"
:index="index"
@change-item-index="handleChangeItemIndex" --> 关键在这里!!!
/>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { navList, curIndex } from './data';
import NavItem from './NavItem.vue';
import { INavListItem } from './typing';
import { vActiveItem } from './derective';
/**
* @description 处理子组件 emit 的 change-item-index 事件
*/
const handleChangeItemIndex = (index: number, item: INavListItem) => {
curIndex.value = index;
};
</script>
效果图
现在
NavBar组件中能够拿到导航栏的数据了,包括type和name,type是获取新闻列表时的新闻类型参数,name则是对应的中文名,接下来要做的就是将这个数据传给父组件Home,然后父组件调用封装好的接口得到数据渲染到页面上。
为了学习使用pinia,本项目使用pinia来存储新闻列表信息,以及一些额外信息,比如新闻列表是否处于加载状态,是否有更多新闻从而可以下拉进行懒加载等。
8. pinia模块化
7.1 安装pinia
pnpm i pinia
7.2 注册pinia
要使用pinia,首先需要创建pinia实例,然后将其注册到vue app中
// src/store/index.ts
import { createPinia } from 'pinia';
import { App } from 'vue';
export const pinia = createPinia();
export function setupPinia(app: App<Element>) {
app.use(pinia);
}
// src/main.ts
import { setupPinia } from './store';
function bootstrap() {
const app = createApp(App);
// pinia
setupPinia(app);
app.mount('#app');
}
bootstrap();
7.3 home模块
每一个模块都创建一个相应的文件夹,以home模块为例,其入口文件为src/store/home/index.ts
// src/store/home/index.ts
import { defineStore } from 'pinia';
import state from './state';
const useHomeStore = defineStore('home', {
state: () => state,
});
export default useHomeStore;
state抽离到单个文件中去定义
7.3.1 state
// src/store/home/state.ts
import { INewsState } from './typing';
const state: INewsState = {
currentNewsType: 'top',
newsListInfo: {
hasMore: true,
isLoading: false,
page: 1,
pageSize: 30,
newsList: [],
},
};
export default state;
首先定义一下在首页时候的state,然后根据定义的state去抽象出对应的interface,放到typing.ts中
// src/store/home/typing.ts
import { INewsData, NewsType } from '@/api/newsList/typing';
export interface INewsListInfo {
/**
* @description 是否有更多数据
*/
hasMore: boolean;
/**
* @description 是否正在加载
*/
isLoading: boolean;
/**
* @description 第几页
*/
page: number;
/**
* @description 每页多少条数据
*/
pageSize: number;
/**
* @description 新闻列表
*/
newsList: Partial<INewsData[]>;
}
export interface INewsState {
/**
* @description 当前新闻类型
*/
currentNewsType: NewsType;
/**
* @description 新闻列表信息
*/
newsListInfo: INewsListInfo;
}
7.3.2 actions
主要是在切换导航栏的时候修改currentType并将新闻列表信息初始化
以及根据currentType去请求新闻列表数据
要注意**isLoading**和**!hasMore**的时候是不需要请求数据的
import { defineStore } from 'pinia';
import type { NewsType } from '@/api/newsList/typing';
import { getNewsList } from '@/api';
import state from './state';
const useHomeStore = defineStore('home', {
state: () => state,
actions: {
/**
* @description 每次切换新闻类型,都要将 state 中的信息初始化
* @param type 新闻类型
*/
setCurrentType(type: NewsType) {
this.currentNewsType = type;
this.newsListInfo = {
hasMore: true,
isLoading: false,
newsList: [],
page: 1,
pageSize: 30,
};
},
async setNewsList() {
const { currentNewsType, newsListInfo } = this.$state;
const { page, pageSize } = newsListInfo;
// 不需要加载数据的情况
if (newsListInfo.isLoading) return; // 已经在加载状态
if (!newsListInfo.hasMore) return; // 没有数据需要加载了
newsListInfo.isLoading = true; // 开始加载
// 获取新闻列表
const res = await getNewsList(currentNewsType, page, pageSize);
this.newsListInfo.hasMore = res.hasMore;
this.newsListInfo.newsList = [...this.newsListInfo.newsList, ...res.newsList];
this.newsListInfo.page += 1;
newsListInfo.isLoading = false; // 加载完毕 -- 关闭加载状态
},
},
});
export default useHomeStore;
9. 新闻头条首页列表
列表的设计是这样的,有一个父组件NewsList,里面渲染四种子组件,分别是无封面图片的新闻、有一张封面图片的新闻、有两张、有三张,如下图所示
9.1 NewsList组件
首先NewsList组件肯定要拿到新闻列表的数据,这个我们在Home组件中已经有了,只需要作为props让NewsList接收即可,即NewsList是Home的子组件
<!-- src/components/NewsList/index.vue -->
<template>
<div>
<template v-for="item in newsListInfo.newsList">
<!-- 没有图片 -->
<NewsItem0 v-if="item && !item.thumbnail_pic_s" :key="item.uniquekey" :news-item="item" />
<!-- 有一张图片 -->
<NewsItem1
v-else-if="item && !item.thumbnail_pic_s02"
:key="item.uniquekey"
:news-item="item"
/>
<!-- 有两张图片 -->
<NewsItem2
v-else-if="item && !item.thumbnail_pic_s03"
:key="item.uniquekey"
:news-item="item"
/>
<!-- 有三张图片 -->
<NewsItem3 v-else-if="item" :key="item.uniquekey" :news-item="item" />
</template>
</div>
</template>
<script setup lang="ts">
import { INewsListInfo } from '@/store/home/typing';
import NewsItem0 from './NewsItem/NewsItem0.vue';
import NewsItem1 from './NewsItem/NewsItem1.vue';
import NewsItem2 from './NewsItem/NewsItem2.vue';
import NewsItem3 from './NewsItem/NewsItem3.vue';
defineProps<{
newsListInfo: INewsListInfo;
}>();
</script>
<style scoped></style>
根据thumbnail_pic_sxx是否存在来判断应当使用哪个子组件
9.2 NewsItem0组件
无图片的新闻项子组件
<!-- src/components/NewsList/NewsItem/NewsItem0.vue -->
<template>
<div class="border-b-2 border-[#fbfbfb] p-4">
<!-- 标题 -->
<h1>{{ newsItem.title }}</h1>
<!-- 文章信息 -- 作者、时间 -->
<div class="flex gap-3 text-[#aeaeae] text-xs mt-2">
<!-- 作者 -->
<span>{{ newsItem.author_name }}</span>
<!-- 时间 -->
<span> {{ newsItem.date }} </span>
</div>
</div>
</template>
<script setup lang="ts">
import { INewsData } from '@/api/newsList/typing';
defineProps<{
newsItem: INewsData;
}>();
</script>
<style scoped></style>
9.3 NewsItem1组件
有一张封面图片的新闻项子组件
<!-- src/components/NewsList/NewsItem/NewsItem1.vue -->
<template>
<div class="news-item">
<!-- 标题 -->
<h1>{{ newsItem.title }}</h1>
<!-- 图片区域 -->
<div class="news-item-image">
<img :src="newsItem.thumbnail_pic_s" alt="" />
</div>
<!-- 文章信息 -- 作者、时间 -->
<div class="flex gap-3 text-[#aeaeae] text-xs mt-2">
<!-- 作者 -->
<span>{{ newsItem.author_name }}</span>
<!-- 时间 -->
<span> {{ newsItem.date }} </span>
</div>
</div>
</template>
<script setup lang="ts">
import { INewsData } from '@/api/newsList/typing';
defineProps<{
newsItem: INewsData;
}>();
</script>
<style scoped>
img {
height: 100%;
opacity: 0;
transition: opacity 0.5s;
}
</style>
9.4 NewsItem2组件
有两张封面图片的新闻项子组件
<!-- src/components/NewsList/NewsItem/NewsItem2.vue -->
<template>
<div class="news-item">
<!-- 标题 -->
<h1>{{ newsItem.title }}</h1>
<!-- 图片区域 -->
<div class="news-item-image-area">
<div class="news-item-image news-item-multi-image">
<img :src="newsItem.thumbnail_pic_s" alt="" />
</div>
<div class="news-item-image news-item-multi-image">
<img :src="newsItem.thumbnail_pic_s02" alt="" />
</div>
</div>
<!-- 文章信息 -- 作者、时间 -->
<div class="flex gap-3 text-[#aeaeae] text-xs mt-2">
<!-- 作者 -->
<span>{{ newsItem.author_name }}</span>
<!-- 时间 -->
<span> {{ newsItem.date }} </span>
</div>
</div>
</template>
<script setup lang="ts">
import { INewsData } from '@/api/newsList/typing';
defineProps<{
newsItem: INewsData;
}>();
</script>
<style scoped>
img {
height: 100%;
opacity: 0;
transition: opacity 0.5s;
}
</style>
9.5 NewsItem3组件
有三张封面图片的新闻项子组件
<!-- src/components/NewsList/NewsItem/NewsItem3.vue -->
<template>
<div class="news-item">
<!-- 标题 -->
<h1>{{ newsItem.title }}</h1>
<!-- 图片区域 -->
<div class="news-item-image-area">
<div class="news-item-image news-item-multi-image">
<img :src="newsItem.thumbnail_pic_s" alt="" />
</div>
<div class="news-item-image news-item-multi-image">
<img :src="newsItem.thumbnail_pic_s02" alt="" />
</div>
<div class="news-item-image news-item-multi-image">
<img :src="newsItem.thumbnail_pic_s03" alt="" />
</div>
</div>
<!-- 文章信息 -- 作者、时间 -->
<div class="flex gap-3 text-[#aeaeae] text-xs mt-2">
<!-- 作者 -->
<span>{{ newsItem.author_name }}</span>
<!-- 时间 -->
<span> {{ newsItem.date }} </span>
</div>
</div>
</template>
<script setup lang="ts">
import { INewsData } from '@/api/newsList/typing';
defineProps<{
newsItem: INewsData;
}>();
</script>
<style scoped>
img {
height: 100%;
opacity: 0;
transition: opacity 0.5s;
}
</style>
图片容器的样式在unocss.config.ts的shortcuts配置项中定义
shortcuts: [
['icon-area', 'absolute top-0 h-10 w-10 flex justify-center items-center'],
['news-item', 'border-b-2 border-[#fbfbfb] p-4'],
['news-item-image-area', 'flex gap-1 mt-2 bg-[#fff]'],
['news-item-image', 'rounded-sm bg-[#eee]'],
['news-item-multi-image', 'w-33% flex-auto'],
]
9.6 图片加载动画
目前的效果是这样的:
图片容器设置成灰色背景,并且图片的
opacity设置为0,变成不可见,等图片加载完毕后,再将opacity设置为1,由于样式中给opacity设置了transition为0.5s,从而实现图片加载动画的效果
9.6.1 useImgShow hooks
当图片加载完毕后进行显示,这一功能可以作为一个hooks,将其命名为useImgShow
// src/composables/useImgShow.ts
import { Ref } from 'vue';
/**
* 给 imgRefs 中的所有图片绑定 load 事件,当 load 完毕后将图片的 opacity 设置为 1
* @param imgRefs 图片 refs
*/
export function useImgShow(imgRefs: Ref<null | HTMLElement>[]): void {
imgRefs.forEach((item) => {
const oImg = item.value;
if (oImg) {
oImg.addEventListener('load', () => {
oImg.style.opacity = '1';
});
}
});
}
给各个NewsItem组件使用
<template>
<div class="news-item">
<!-- 图片区域 -->
<div class="news-item-image">
<!-- 添加 ref -->
<img ref="imgRef" :src="newsItem.thumbnail_pic_s" alt="" />
</div>
</div>
</template>
<script setup lang="ts">
import { useImgShow } from '@/composables/useImgShow';
// 获取图片 dom 元素
const imgRef = ref<null | HTMLElement>(null);
// mounted 的时候给图片 dom 元素绑定 load 事件监听器
onMounted(() => {
useImgShow([imgRef]);
});
</script>
其他的新闻项子组件也是类似的操作,不重复演示了
完成后的效果如下:
10. 上滑加载更多新闻
10.1 滚动条问题
首先很明显的是需要监听滚动事件,那么我们应当让滚动条出现在NewsList组件中而不是body元素上,因此需要先将body的滚动条隐藏
/* src/styles/main.css */
body{
overflow: hidden;
}
然后给NewsList组件设置固定高度 -- 85vh,剩下的15vh是给加载框预留的
将overflow-y设置为scroll
<div ref="newsListRef" class="news-list h-screen overflow-y-scroll">
10.2 加载新闻hooks
加载新闻的逻辑可以抽离到hooks中,定义一个useLodingMore的hooks,其接收的是新闻列表的DOM元素
给新闻列表监听滚动事件,当滚动到距离屏幕底部还剩30px的时候,就触发加载下一页新闻的逻辑,由于scroll是一个高频事件,为了避免频繁触发加载下一页新闻的逻辑,需要使用防抖函数处理一下先,这里直接使用lodash提供的防抖函数_.debounce
// src/composables/common.ts
/**
* @description 当滚动到距离新闻列表底部 30px 时开始加载下一页新闻列表
*/
export function useLoadingMore(el: Ref<HTMLElement | null>) {
const homeStore = useHomeStore();
let newsListEl: HTMLElement;
function _loadMore() {
const listHeight = newsListEl.clientHeight;
const scrollHeight = newsListEl.scrollHeight;
const scrollTop = newsListEl.scrollTop;
// 滚动到距离屏幕底部 30px 时开始加载下一页新闻
if (listHeight + scrollTop >= scrollHeight - 30) {
homeStore.setNewsList();
}
}
// 给新闻列表添加滚动事件,并用防抖实现至少停止滚动 300ms 后才开始加载新的新闻列表
onMounted(() => {
newsListEl = el.value as HTMLElement;
newsListEl.addEventListener('scroll', _.debounce(_loadMore, 300), false);
});
return {
isLoading: computed(() => homeStore.newsListInfo.isLoading),
hasMore: computed(() => homeStore.newsListInfo.hasMore),
};
}
useLoadingMore钩子会返回isLoading和hasMore,并且一定要用computed去包裹,否则得到的数据不是响应式的,这一点可以使用watch验证一下:
我试了一下,即便是用
ref也不行,只能是用computed
返回的isLoading和hasMore用于决定是否要渲染Loading组件和NoMore组件
最后在新闻列表组件NewsList中使用即可
<!-- src/components/NewsList/index.vue -->
<script setup lang="ts">
const newsListRef = ref<HTMLElement | null>(null);
useLoadingMore(newsListRef);
</script>
完成后的效果图如下:
10.3 加载动画和无更多新闻数据
首先要写一个Loading组件
<!-- src/components/NewsList/NewsListLoading.vue -->
<template>
<div class="flex justify-center items-center">
<p class="text-[#aeaeae]">正在加载中...</p>
</div>
</template>
<script setup lang="ts"></script>
以及一个NoMore组件
<!-- src/components/NewsList/NewsListNoMore.vue -->
<template>
<div class="flex justify-center items-center">
<p class="text-[#aeaeae]">没有更多了</p>
</div>
</template>
<script setup lang="ts"></script>
放到NewsList组件中,根据isLoading和hasMore动态渲染
<!-- src/components/NewsList/index.vue -->
<template>
<div ref="newsListRef" class="news-list h-[87vh] overflow-scroll">
<template v-for="item in newsListInfo.newsList">
<!-- 没有图片 -->
<NewsItem0 v-if="item && !item.thumbnail_pic_s" :key="item.uniquekey" :news-item="item" />
<!-- 有一张图片 -->
<NewsItem1
v-else-if="item && !item.thumbnail_pic_s02"
:key="item.uniquekey"
:news-item="item"
/>
<!-- 有两张图片 -->
<NewsItem2
v-else-if="item && !item.thumbnail_pic_s03"
:key="item.uniquekey"
:news-item="item"
/>
<!-- 有三张图片 -->
<NewsItem3 v-else-if="item" :key="item.uniquekey" :news-item="item" />
</template>
<div class="py-4 h-[10vh]">
<NewsListLoading v-if="isLoading" />
<NewsListNoMore v-if="!hasMore" />
</div>
</div>
</template>
完成后的效果如下:
11. 新闻详情页
11.1 重构detail路由
由于要点击新闻后跳转到详情页,那么需要给详情页一些信息去获取新闻的详细内容,这时候可以通过路由的url来实现
首先修改/detail路由,给它添加两个路径参数uniquekey和pageFrom
uniquekey用于获取新闻内容pageFrom用于判断新闻列表获取的途径,因为如果是从首页跳转进来的话,就需要从pinia中去取,而如果是从收藏列表中跳转来的话,pinia其实是没有对应的新闻列表数据的,收藏列表中的新闻数据是存放在localStorage中的,因此要到localStorage中去取
// src/router/routes/index.ts
import type { RouteRecordRaw } from 'vue-router';
export const routes: RouteRecordRaw[] = [
{
path: '/detail/:uniquekey/:pageFrom',
name: 'Detail',
component: () => import('@/pages/Detail.vue'),
},
];
11.2 重构NewsItem
应当给每一个新闻项包裹一层router-link,这样就能够点击跳转了,以NewsItem0为例,其他几个NewsItem也是类似的
<!-- src/components/NewsList/NewsItem/NewsItem0.vue -->
<template>
<div class="news-item">
<!-- 包裹 router-link -->
<router-link :to="`/detail/${newsItem.uniquekey}/${pageFrom}`">
<!-- 标题 -->
<h1>{{ newsItem.title }}</h1>
<!-- 文章信息 -- 作者、时间 -->
<div class="flex gap-3 text-[#aeaeae] text-xs mt-2">
<!-- 作者 -->
<span>{{ newsItem.author_name }}</span>
<!-- 时间 -->
<span> {{ newsItem.date }} </span>
</div>
</router-link>
</div>
</template>
<script setup lang="ts">
import { INewsData } from '@/api/newsList/typing';
import type { PageFrom } from './typing';
defineProps<{
newsItem: INewsData;
pageFrom: PageFrom;
}>();
</script>
PageFrom是一个联合类型,由字符串联合而成,用于声明可以接受的新闻列表数据来源
// src/components/NewsList/NewsItem/typing.ts
export type PageFrom = 'home' | 'collection';
然后还需要在NewsList组件中给子组件们提供pageFrom属性
<NewsItem0
v-if="item && !item.thumbnail_pic_s"
:key="item.uniquekey"
:news-item="item"
page-from="home"
/>
11.3 hooks -- 获取新闻详情
给hooks传入路由,然后hooks中可以获取到路由中的新闻的uniquekey和pageFrom,根据这两个信息去获取具体的新闻详情
// src/composables/detail.ts
import { INewsData } from '@/api/newsList/typing';
import { RouteLocationNormalizedLoaded } from 'vue-router';
import { PageFrom } from '@/components/NewsList/NewsItem/typing';
import useHomeStore from '@/store/home';
export function useNewsDetail(route: RouteLocationNormalizedLoaded): INewsData | undefined {
const { uniquekey, pageFrom } = <{ uniquekey: string; pageFrom: PageFrom }>route.params;
let newsData = undefined;
switch (pageFrom) {
// 从新闻首页来 --> newsData 从 pinia 中获取
case 'home':
const homeStore = useHomeStore();
const newsList = homeStore.newsListInfo.newsList;
newsData = newsList.find((item) => item?.uniquekey === uniquekey);
break;
// 从收藏列表来 --> newsData 从 localStorage 中获取
case 'collection':
break;
}
return newsData;
}
详情页组件Detail.vue中使用
<!-- src/pages/Detail.vue -->
<template>
<iframe v-if="newsData" class="min-h-[100vh]" :src="newsData.url" />
<div v-else class="flex justify-center items-center min-h-[100vh]">
<p class="text-[#aeaeae] text-2xl">新闻不存在</p>
</div>
</template>
<script setup lang="ts">
import { useNewsDetail } from '@/composables';
const route = useRoute();
const newsData = useNewsDetail(route);
</script>