Vue3 + TS + Vite + Pinia + Vitesse打造新闻头条项目

1,687 阅读12分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

源码地址:github.com/Plasticine-…

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;
}

使用asyncawait去处理,也可以用Promise的链式调用的方式,但是我更喜欢async/await的写法。

调用axiosPost时传入了一个泛型,这个泛型就是返回的data的类型。

NewsListTypeParams是一个类型,对应新闻接口的所有type参数值

然后在src/api/index.ts中统一导出

// src/api/index.ts
export { getNewsList } from './newsList';

5. header路由逻辑

新闻头条首页的header image.png 新闻详情的header image.png 收藏列表的header image.png

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要显示哪些内容

  1. 无论是哪个页面,左边的按钮只有箭头或不存在,因此只需要直接将图标当成类名放进去即可,不存在的图标则不会渲染
  2. 而右边的按钮分为两种类型
    1. 路由跳转类型,在新闻头条首页的时候点击右边按钮会跳转到收藏列表中
    2. 收藏文章类型,不会进行路由跳转,只会触发点击事件

根据这两个特点,使用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.tsshortcuts中配置的

export default defineConfig({
  shortcuts: [['icon-area', 'absolute top-0 h-10 w-10 flex justify-center items-center']],
});

6.2 监听路由变化改变Header展示形式

由于要监听路由,因此要拿到路由对象,Composition API中使用vue-routeruseRoute即可拿到。

<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的激活颜色涉及到每个itemdom元素的修改,因此最好是用自定义指令去完成,事实上vue官方文档也有说到

自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑

可以给NavBar组件的nav原生标签添加一个自定义指令v-active-item(官方文档建议不要直接给组件使用自定义指令),该指令的作用是根据item的下标去修改它的样式,比如当前下标为0,则会让第一个item的样式变为激活时的样式。

v-active-item指令中做的事情主要有:

  1. mounted的时候,给itemIndex对应的item添加active类名,表明激活了
  2. updated的时候,将旧的itemIndexitemactive类名移除,并给新的curIndexitem添加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即可

  1. 子组件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提供类型标注,这样能获得一下几个好处:

  1. 子组件中emit触发事件的时候,会有类型提示,避免手写自定义事件名出现拼写错误的低级错误,也避免去复制事件名浪费时间

image.png

  1. 父组件中给子组件添加监听事件的时候也会有提示,避免拼写错误和复制事件名,并且能够自动将驼峰转成短横线分隔!

image.png

  1. 子组件给item项添加点击事件

点击时使用emit触发changeItemIndex事件,将当前itemindexitem属性都传给父组件

  1. index用来给父组件修改父组件中的curIndex
  2. item主要用到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>
  1. 父组件中给子组件添加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>

效果图

点击切换item效果.gif 现在NavBar组件中能够拿到导航栏的数据了,包括typenametype是获取新闻列表时的新闻类型参数,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,里面渲染四种子组件,分别是无封面图片的新闻、有一张封面图片的新闻、有两张、有三张,如下图所示 image.png image.png image.png image.png

9.1 NewsList组件

首先NewsList组件肯定要拿到新闻列表的数据,这个我们在Home组件中已经有了,只需要作为propsNewsList接收即可,即NewsListHome的子组件

<!-- 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.tsshortcuts配置项中定义

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 图片加载动画

目前的效果是这样的: image.png 图片容器设置成灰色背景,并且图片的opacity设置为0,变成不可见,等图片加载完毕后,再将opacity设置为1,由于样式中给opacity设置了transition0.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>

其他的新闻项子组件也是类似的操作,不重复演示了

完成后的效果如下: 图片加载动画.gif


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钩子会返回isLoadinghasMore,并且一定要用computed去包裹,否则得到的数据不是响应式的,这一点可以使用watch验证一下: image.png 我试了一下,即便是用ref也不行,只能是用computed

返回的isLoadinghasMore用于决定是否要渲染Loading组件和NoMore组件

最后在新闻列表组件NewsList中使用即可

<!-- src/components/NewsList/index.vue -->
<script setup lang="ts">
  const newsListRef = ref<HTMLElement | null>(null);

  useLoadingMore(newsListRef);
</script>

完成后的效果图如下: 上滑加载下一页新闻.gif


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组件中,根据isLoadinghasMore动态渲染

<!-- 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>

完成后的效果如下: 加载动画.gif


11. 新闻详情页

11.1 重构detail路由

由于要点击新闻后跳转到详情页,那么需要给详情页一些信息去获取新闻的详细内容,这时候可以通过路由的url来实现

首先修改/detail路由,给它添加两个路径参数uniquekeypageFrom

  • 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中可以获取到路由中的新闻的uniquekeypageFrom,根据这两个信息去获取具体的新闻详情

// 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>