iT邦幫忙

2025 iThome 鐵人賽

DAY 28
1
Vue.js

作為 Angular 專家探索 Vue 3 和 Svelte 5系列 第 28

第27天 - 建立一個簡單的部落格頁面

  • 分享至 

  • xImage
  •  

第27天 - 建立一個簡單的部落格頁面

在第27天,我完成了Vue 3 Vue 3 Composition API 課程,建立一個簡單的部落格來顯示文章。該網站呼叫了https://jsonplaceholder.typicode.com/posts 來取得所有文章並顯示使用者資訊。

部落格文章分為 5 個部分構建:

  • 第1部分:建立一個usePost可組合函式來取得所有文章及單篇文章
  • 第2部分:建立一個useUser可組合函式來取得使用者
  • 第3部分:使用watch/watchEffect監視文章並取得使用者
  • 第4部分:建立一個可重複使用的可組合函式來取得文章與使用者資源
  • 第5部分:新增載入指示器

安裝 Tailwindcss

參考 https://tailwindcss.com/docs/installation/framework-guides 來安裝適用於 Vue 3、Svelte 5 和 Angular 的 TailwindCSS。

複製 Vue 範本

https://github.com/vueschool/vue-3-composition-api/tree/boilerplate/ 複製範本到你的 Vue 專案。作者以 JavaScript 撰寫程式碼,並且未使用 <script setup lang="ts"> 的語法糖c(syntatic sugar),因此我用 TypeScript 改寫了它。

建立共用類型

建立一個 Post 類型來取得 id、title、body 和 userId。

export type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
}

建立一個 User 類型來取得 id 和 name。

export type User = {
  id: number;
  name: string;
}

Vue 3 application

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home,
    },
    {
      path: '/post/:id',
      name: 'Post',
      component: () => import('../views/Post.vue'),
    },
  ],
})

export default router

我還沒有學習 Vue Router,所以我使用 Vue 3 應用程式所產生的路由。

SvelteKit application

SvelteKit 使用file based routing。

routes/
   |-----posts/[id]
      |-----+layout.svelte
      |-----+page.svelte
      |-----+page.ts
   |-----+layout.svelte
   |-----+page.server.ts
   |-----+page.svelte

首頁是在 +page.svelte 中設計,+page.server.ts 定義了一個 load 函式來在伺服器端取得所有貼文。

posts/[id] 資料夾下,+page.svelte 是詳細頁面,+page.ts 用來透過 id 取得特定貼文。我無法讓 +page.server.ts 正常運作,所以改用了 +page.svelte

Angular 20 application

import { Routes } from '@angular/router';
import { postResolver } from './post/resolvers/post.resolver';

export const routes: Routes = [
    {
        path: 'home',
        loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
    },
    {
        path: 'post/:id',
        loadComponent: () => import('./post/post.component'),
    },
    ... other routes ...
];

建立 homepost/:id 路由,並以延遲載入 (lazy loading) 方式載入 HomeComponentPostComponent

Load Posts and Post data

載入 Posts 與 Post 資料

建立 homepost/:id 路由,並以延遲載入 (lazy loading) 方式加載 HomeComponentPostComponent

Vue 3 application

實作一個 fetchAll 函式,並宣告一個 posts ref 用來取得所有貼文。

import type { Post } from '@/types/post'
import { ref } from 'vue'

export function usePost() {
    const posts = ref<Post[]>([])
    
    const baseUrl = 'https://jsonplaceholder.typicode.com/posts'

    function fetchAll() {
        return fetch(baseUrl)
          .then((response) => response.json() as Promise<Post[]>)
          .then((data) => (posts.value = data))
          .catch((err) => alert(err))
      }
     
    return {
        posts,
        fetchAll,
    }
}

fetchAll 會向 baseUrl 發出請求以取得所有貼文,並將貼文賦值給 posts.value。

import type { Post } from '@/types/post'
import { ref } from 'vue'

export function usePost() {
    const post = ref<Post | null>(null)
    
    function fetchOne(id: number) {
        return fetch(`${baseUrl}/${id}`)
          .then((response) => response.json() as Promise<Post>)
          .then((data) => (post.value = data))
     }
    
    return {
        posts,
        post,
        fetchAll,
        fethcOne,    
    }
}

接著,宣告一個 post 的 ref 並實作一個 fetchOne 函式。fetchOne 函式接受一個貼文 ID 來取得該部落格貼文,並將結果賦值給 post.value

然後,該 composable 回傳所有的 postspostfetchAllfetchOne,以便元件可以存取。

SvelteKit application

export const BASE_URL = 'https://jsonplaceholder.typicode.com';
import type { PageServerLoad } from './$types';
import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import type { RequestHandler } from '@sveltejs/kit';

// retreive all posts
export const load: PageServerLoad = async ({ fetch }: RequestHandler) => {
	const posts = await fetch(`${BASE_URL}/posts`)
		.then((response) => response.json() as Promise<Post[]>)
		.catch((error) => {
			console.error('Error fetching posts:', error);
			return [] as Post[];
		});

	return {
		posts
	};
};

load 函式使用原生的 fetch 函式來取得所有部落格貼文。

import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import type { PageLoad } from './$types';

// retreive a post by an ID
export const load: PageLoad = async ({ params, fetch }): Promise<{ post: Post | undefined }> => {
	console.log('params', params);

    const post = await fetch(`${BASE_URL}/posts/${params.id}`)
		.then((response) => response.json() as Promise<Post>)
		.catch((error) => {
			console.error('Error fetching posts:', error);
			return undefined;
		});

	return {
		post
	};
};

這個 load 函式在瀏覽器端執行,並發出 HTTP 請求以透過 ID 取得貼文。

Angular 20 application

ApplicationConfig 中提供 provideHttpClient 供應者。

import { provideRouter, withComponentInputBinding } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(),
  ]
};

這是因為 httpResource 在底層使用 HttpClient,而我也需要它來進行 GET 請求以取得貼文。

provideRouter 是一個用來配置路由的供應者。withComponentInputBinding 是一個功能,能將路由資料、路徑參數和查詢參數轉換為輸入信號 (input signal),極大地簡化了路由資料傳遞至被路由元件的過程。

建立一個 PostsService

import { httpResource } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Post } from '../types/post.type';

const BASE_URL = 'https://jsonplaceholder.typicode.com/posts';

@Injectable({
    providedIn: 'root'
})
export class PostsService {

    posts = httpResource<Post[]>(() => BASE_URL, {
        defaultValue: [] as Post[]
    });
}

我使用實驗性的 httpResource 來建立一個 posts 資源以取得所有貼文。

import { httpResource } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Post } from '../types/post.type';

const BASE_URL = 'https://jsonplaceholder.typicode.com/posts';

@Injectable({
    providedIn: 'root'
})
export class PostsService {

    readonly httpService = inject(HttpClient);

    getPost(id: number): Observable<Post> {
        return this.httpService.get<Post>(`${BASE_URL}/${id}`);
    }
}

我實作了一個 getPost 方法來取得單一貼文。此方法會在路由導航時用於路由解析器 (route resolver)。

import { inject } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { of } from 'rxjs';
import { PostsService } from '../services/posts.service';

export const postResolver = (route: ActivatedRouteSnapshot) => {
    const postId = route.paramMap.get('id');
  
    if (!postId) {
      return of(undefined);
    }
  
    return inject(PostsService).getPost(+postId);
}

建立一個 postResolver,透過 ID 路徑參數來取得貼文。

更新 post/:id 路由,使其呼叫 postResolver

{
        path: 'post/:id',
        loadComponent: () => import('./post/post.component'),
        resolve: {
            post: postResolver
        }
}

PostComponent 有一個 post 輸入信號 (input signal),其類型為 Post

Replace the Post mock data with the fetch calls

Vue 3 application

Home 元件中,匯入 usePost composable,並解構出 fetchAllposts

<script setup lang="ts">
import PostCard from '../components/PostCard.vue'
import { usePost } from '@/composables/usePost'

const { posts, fetchAll } = usePost()

fetchAll()
</script>

呼叫 fetchAll 函式以非同步方式取得所有貼文。然後,刪除 posts 陣列的模擬資料,以顯示真實資料。

<template>
  <div class="flex flex-wrap flex-grow">
    <PostCard v-for="post in items" :key="post.id" :post="post" />
  </div>
</template>

Post 元件中,匯入 usePost,並呼叫 fetchOne 來透過 ID 取得貼文。刪除 post 的模擬資料,以顯示真實資料。

import { useRoute } from 'vue-router'
import { usePost } from '@/composables/usePost'

const { post, fetchOne } = usePost()

const { params } = useRoute();
fetchOne(+params.id)

SvelteKit application

routes/+page.svelte 中,會執行 load 函式,頁面資料可以透過 $props() 巨集取得。

data 解構出 posts 以取得所有貼文。

<script lang="ts">
	import type { PageProps } from './$types';
	import PostCard from '$lib/components/post-card.svelte';

	const { data }: PageProps = $props();
	const { posts } = data;
</script>
<div class="flex flex-grow flex-wrap">
	{#each posts as post (post.id)}
		<PostCard {post} />
	{/each}
</div>

範本迭代 posts,並將每個貼文傳遞給 PostCard 元件。

<script lang="ts">
	import type { Post } from '$lib/types/post';
	import { resolve } from '$app/paths';

	type Props = {
		post: Post;
	};

	const { post }: Props = $props();

	const postUrl = resolve('/posts/[id]', { id: `${post.id}` });
</script>

PostCard 元件使用 resolve 函式來決定導向的 URL。/posts/[id] 是路由路徑,id 是路徑參數。

<div>
    <img src="https://placehold.co/150" alt="placeholder" style="background: #cccccc" width="150" height="150" />
    <a href={postUrl}>
		{post.title}
	</a>
</div>
<script lang="ts">
    import type { PageProps } from './$types';

    const { data }: PageProps = $props();
    const { post } = data;
</script>

posts/[id]/+page.svelte 中,load 函式會透過 ID 取得貼文。範本動態顯示貼文標題和內容,而使用者名稱目前是硬編碼的。

<div class="mb-10">
    <h1 class="text-3xl">{ post.title }</h1>
    <div class="text-gray-500 mb-10">by Connie</div>
    <div class="mb-10">{ post.body }</div>
</div>

範本動態顯示貼文標題和內容,而使用者名稱目前是硬編碼的。

Angular 20 application

import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { Post } from './types/post.type';
import { User } from './types/user.type';

@Component({
  selector: 'app-post',
  styles: `
    @reference "../../styles.css";

    :host {
      @apply flex m-2  gap-2 items-center w-1/4 shadow-md flex-grow rounded overflow-hidden
    }
  `,
  template: `...inline template...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class PostComponent {
  post = input<Post>();

  user = signal<User>({
    id: 1,
    name: 'Connie',
  });
}

貼文會在路由導航期間被解析,因此它會作為輸入信號 (input signal) 提供給 PostComponent。使用者信號目前是硬編碼的值,但將會發出請求以取得貼文的使用者。

@let myUser = user();
@let myPost = post();
@if (myPost && myUser) {
  <div class="mb-10">
    <h1 class="text-3xl">{{ myPost.title }}</h1>
    <div class="text-gray-500 mb-10">by {{ myUser.name }}</div>
    <div class="mb-10">{{ myPost.body }}</div>
  </div>
}

@let 語法允許暫時指定信號的取得函式。當 myUsermyPost 被定義後,會顯示貼文標題、貼文內容和使用者名稱。

我們已成功顯示首頁、詳細頁面,並且設定好這個簡易部落格的路由。

Github Repositories

資源


上一篇
第26天 - 從 AlertBar 中抽取邏輯和元件
下一篇
第 28 天 - 取得貼文作者
系列文
作為 Angular 專家探索 Vue 3 和 Svelte 530
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言