這一篇來講顯示新聞的區域,從建立一個後端來獲得 RSS 並將它們整理好,再由 Vue 來呼叫 API 獲得並在最後顯示出來。

整個請求的流程會像是這樣:

vue-project-frontend-backend-flow.png

後端建立

一開始我想直接向各大有提供 RSS 的新聞網站直接請求,不過遇到了一個問題:

vue-project-proxy-cors-error.png

CORS (Cross-Origin Resource Sharing) 跨站來源請求的錯誤,解決的方法通常都是請該伺服器端做 CORS 標頭設定,但是我們並沒有辦法去告訴伺服器端幫我們開通,所以這裡我使用插件的方式來獲得 RSS。

這裡會在 root 下建立一個 server 資料夾,並放入 index.ts 檔案:

import express, { Express, Request, Response } from 'express';
import Parser, { type Item } from 'rss-parser';

const port = process.env.PORT || 3000;

const app: Express = express();
const parser = new Parser();

// 獲得文章的最大數
const MAX_POST: number = 5;

// 要獲得的 RSS 們
const urls: string[] = [
  'https://www.nasa.gov/rss/dyn/breaking_news.rss',
  'https://news.ltn.com.tw/rss/all.xml',
  'https://spacenews.com/feed/',
];

app.get('/api/v1/rss', async (req: Request, res: Response) => {
  let feeds: Item[] = [];
  
  for (let i = 0; i < urls.length; i++) {
    const rss = await parser.parseURL(urls[i]);

    // If items < 5, then get all elements
    if (rss.items.length < MAX_POST) {
      rss.items.forEach((item) => {
        item.origin_title = rss.title;
        item.origin_link = rss.link;
        feeds.push(item);
      });
    }

    // Get 5 posts from every rss feed
    for (let i = 0; i < MAX_POST; i++) {
      rss.items[i].origin_title = rss.title;
      rss.items[i].origin_link = rss.link;
      feeds.push(rss.items[i]);
    }
  }

  // return post like: [1,2,3,1,2,3,1,2,3]
  res.json({ feeds: feeds });
});

app.listen(port, () => {
  console.log(`[server]: Server is running at http://localhost:${port} in dev`);
});
  • 使用 rss-parser 來獲得 RSS
  • 使用 for looping 來依序獲得資料
  • origin_titleorigin_link 來設定這些文章來自哪裡

上面程式我曾經遇到的一個問題:最開始我使用 forEach 來獲得 looping 我的 RSS 們,並把獲得的資料放進預先設定的 Array。但是不管我怎麼樣跑,它都不能把獲得的 RSS 資料放進我預先設定好的 Array。最後我在 Cannot push into array from inside forEach loop 查到原來 forEach 方法不會等待 async 呼叫,它會直接執行,所以它才無法獲得資料並推進 Array 裡。

Vite 設定檔設定 Proxy

在 Vite 設定要獲得的後端 API 網址,可以預先進行一些設定:

export default defineConfig({
  //...
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
});
  • 在前端就只要使用 /api/v1/rss 就可以獲得資料了
  • changeOrigin 將 host 改成 target 的 URL

News 元件建立

後端完成後,建立 News.vue 元件到 compoents 資料夾下:

<template>
  <NewsCardGroup>
    <NewsCard v-for="(news, key) in news_list.items" :item="news" :key="key" />
  </NewsCardGroup>
</template>

<script setup lang="ts">
import NewsCardGroup from './news/NewsCardGroup.vue';
import NewsCard from './news/NewsCard.vue';
import { reactive, onMounted } from 'vue';
import axios from 'axios';

const news_list: any = reactive({ items: [] });

// 從後端 Server 獲得 RSS API
const fetchRSS = async () => {
  try {
    // Get RSS from server
    const rss = await axios.get('/api/v1/rss');

    // Copy all feeds into 'news_list'
    news_list.items = [];
    news_list.items = Array.from(rss.data.feeds);
  } catch (err) {
    console.log(err);
  }
};

onMounted(() => {
  fetchRSS();
});
</script>

NewsCardGroup.vue

<template>
  <div class="container grid grid-cols-1 lg:grid-cols-2 gap-4 my-5">
    <slot></slot>
  </div>
</template>

NewsCard.vue

<template>
  <div class="rounded-lg text-white shadow-2xl" :class="{ 'row-span-1': isImage, 'row-span-2': !isImage }">
    <a :href="props.item.link">
      <h2 class="text-2xl lg:text-3xl text-center p-4" :class="classObject.a_tag_hover">{{ props.item.title }}</h2>
    </a>
    <a :href="props.item.link" v-if="props.item.enclosure !== undefined">
      <img class="max-h-13 w-full p-3 md:px-7" :src="props.item.enclosure?.url" alt="rss-picture" />
    </a>
    <div class="p-3 md:px-7 text-sm md:text-base">
      <div class="flex justify-between items-center">
        <p>
          來源: <a :href="props.item.origin_link" :class="classObject.a_tag_hover">{{ props.item.origin_title }}</a>
        </p>
        <p class="text-end text-gray-300 py-3">{{ dateFormat() }}</p>
      </div>
      <div class="item-content" v-html="props.item.content"></div>
      <p class="text-blue-400 text-right">
        <a :href="props.item.link" class="text-blue-400 text-right" :class="classObject.a_tag_hover">Read More</a>
      </p>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, type Ref, reactive } from 'vue';
import { type Item } from 'rss-parser';

// Controll class
const classObject = reactive({
  a_tag_hover: 'hover:underline underline-offset-8',
});

// Extends more detail of item
interface CustomItem extends Item {
  origin_title: string;
  origin_link: string;
}

const props = defineProps<{
  item: CustomItem;
}>();

// Format date
const dateFormat = () => {
  return new Intl.DateTimeFormat('zh-TW', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  }).format(new Date(props.item.isoDate!));
};

// Check post have image or not
const isImage: Ref<boolean> = ref(!props.item.content!.includes(`<img`) && props.item.enclosure == undefined);
</script>

<style>
.item-content img {
  width: 100%;
  height: full;
  margin: 0 auto;
}
</style>

將 News 元件加入 HomeView

與天氣元件一樣,將 News 元件加入到 HomeView:

<script setup lang="ts">
import Weather from '../components/Weather.vue';
import News from '../components/News.vue';
</script>

<template>
  <Weather />
  <News />
</template>

啟動專案

現在不單單要啟動 Vite 還必須要同時啟動 server,所以這裡需要 concurrently 來同時啟動兩個腳本:

"scripts": {
    "dev:frontend": "vite --host",
    "dev:server": "nodemon server/index.ts",
    "dev": "concurrently 'pnpm:dev:frontend' 'pnpm:dev:server'",
    //...
}

這樣後端就會根據前端的請求來獲得相應的資料。


結語

最近都在面試,所以這禮拜跟下禮拜更新的幅度都會減少一些 🫠。