第27天 - 建立一個簡單的部落格頁面
在第27天,我完成了Vue 3 Vue 3 Composition API 課程,建立一個簡單的部落格來顯示文章。該網站呼叫了https://jsonplaceholder.typicode.com/posts
來取得所有文章並顯示使用者資訊。
部落格文章分為 5 個部分構建:
參考 https://tailwindcss.com/docs/installation/framework-guides 來安裝適用於 Vue 3、Svelte 5 和 Angular 的 TailwindCSS。
從 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;
}
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 使用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
。
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 ...
];
建立 home
與 post/:id
路由,並以延遲載入 (lazy loading) 方式載入 HomeComponent
和 PostComponent
。
建立 home
和 post/:id
路由,並以延遲載入 (lazy loading) 方式加載 HomeComponent
和 PostComponent
。
實作一個 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 回傳所有的 posts
、post
、fetchAll
和 fetchOne
,以便元件可以存取。
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 取得貼文。
在 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
。
在 Home
元件中,匯入 usePost
composable,並解構出 fetchAll
和 posts
。
<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)
在 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>
範本動態顯示貼文標題和內容,而使用者名稱目前是硬編碼的。
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
語法允許暫時指定信號的取得函式。當 myUser
和 myPost
被定義後,會顯示貼文標題、貼文內容和使用者名稱。
我們已成功顯示首頁、詳細頁面,並且設定好這個簡易部落格的路由。