我們廢話不多說直接進入程式碼的部分。如果您在找尋 15 分鐘快速實作,那本篇就是。
本篇會使用 TypeScript,但我們會在寫完程式之後才添加型別。這不是正常的流程,但因為部分讀者不使用 TypeScript 因此我們不想讓您混亂。一般來說我們應該在寫程式的時候就建立型別,才可以在一開始就享受 TypeScript 的好處。
建立專案 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $ npx create-remix@latest Welcome to Remix! Let's get you set up with a new project. ? Where would you like to create your app? [demo-remix] ? Where do you want to deploy? Choose Remix if you' re unsure, it's easy to change deployment targets. (Use arrow keys) ❯ Remix App Server Express Server Architect (AWS Lambda) Fly.io Netlify Vercel Cloudflare Pages ? TypeScript or JavaScript? (Use arrow keys) ❯ TypeScript JavaScript ? Do you want me to run `npm install`? (Y/n) Y $ cd demo-remix $ npm run dev
須選擇 Remix App Server
我們將對檔案系統做些設定,但不是全部的設定都和這篇教學相容。
瀏覽器開啟 http://localhost:3000 可以看到我們的應用程式正在執行。如果您想,可以花幾分鐘看一下預設的檔案。
如果您的程式沒有正常運作請查閱專案裡的 README.md 看看您是否需要額外的設定。
請確認 postinstall
在啟動應用程式之前正確執行,如果沒有可以手動執行 npm run postinstall
當您的 npm
設定了 ignore-scripts = true
時可能會發生。或者您使用 pnpm
或其他函式庫管理工具不會自動執行 postinstall
第一個路由 我們將建置第一個路有 /posts
,在開始之前我們先在 app/root.tsx
加入連結
1 2 {/* 記得載入 Link */} <Link to="/posts">Posts</Link>
您可以加到任何您喜歡的位置。回到瀏覽器點擊連結應該會看到 404,這是因為我們還沒建立路由。
接著,建立 app/routes/posts/index.tsx
1 2 $ mkdir app/routes/posts $ touch app/routes/posts/index.tsx
任何時候您看到終端機指令建立檔案或目錄,您當然可以使用您習慣的方式,mkdir
和 touch
只是我們比較習慣的方式,並且在這裡呈現比較清楚容易。
我們可以將檔案命名為 posts.tsx
,但很快我們就會有另一個路由,將它們放在一起會比較適合。而且當路徑指到目錄時 index
路由會被渲染,就跟伺服器 index.html
一樣。
當回傳為 null
時您可能會看到空白的頁面。您已經建立了路由但檔案裏面什麼都沒有,讓我們來加入元件 app/routes/posts/index.tsx
1 2 3 4 5 6 7 export default function Posts() { return ( <div> <h1>Posts</h1> </div> ); }
您可能需要重新載入。
載入資料 Remix 內建資料載入。如果您的網頁開發經驗是近幾年才開始的您大概會分成;一個後端 API 路由提供資料然後一個前端專案。在 Remix 一個前端元件同時也是自己的 API 路由,而且它已經知道如何從瀏覽器和伺服器端的自己溝通,因此您不需要自己 fetch
。
如果您的開發經驗稍微久一點可能熟悉 MVC 框架像是 Rails,那麼您可以將 Remix 路由看作是一個後端的 View 只是樣版採用 React,並且它知道如何無縫在瀏覽器進行 hydrate
,而不是寫入一些分離的 jQuery 程式碼來實作互動操作。它充分的展現了漸進式增強,此外您的路由也是它自己的 Controller。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { useLoaderData } from 'remix'; export const loader = () => { return [ { slug: 'my-first-post', title: 'My First Post' }, { slug: '90s-mixape', title: 'A Mixtape I Made Just For You' } ]; }; export default function Posts() { const posts = useLoaderData(); console.log(posts); return ( <div> <h1>Posts</h1> </div> ); }
Loaders 就是給元件自身使用的後端 API,而且他們已經通過 useLoaderData
連接起來了。在 Remix 路由中前端和伺服器之間有典模糊,這確實有點瘋狂。如果您的伺服器和瀏覽器的 Console 都開啟,您會看到兩者都輸出了文章資料。這是因為 Remix 像傳統的網頁框架一樣是在伺服器端渲染然後傳送完整的 HTML,但它也在客戶端進行 hydrate
(跟 render
一樣但是重複使用了已產生的 HTML Node)。讓我們來加入文章連結
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import { Link, useLoaderData } from 'remix'; export const loader = () => { return [ { slug: 'my-first-post', title: 'My First Post' }, { slug: '90s-mixape', title: 'A Mixtape I Made Just For You' } ]; }; export default function Posts() { const posts = useLoaderData(); console.log(posts); return ( <div> <h1>Posts</h1> <ul> {posts.map((post) => ( <li key={post.slug}> <Link to={post.slug}> {post.title} </Link> </li> ))} </ul> </div> ); }
發現 TypeScript 在警告了,我們來處理一下。加入 Post
型別
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import { Link, useLoaderData } from 'remix'; export type Post = { slug: string; title: string; } export const loader = () => { const posts: Post[] = [ { slug: 'my-first-post', title: 'My First Post' }, { slug: '90s-mixape', title: 'A Mixtape I Made Just For You' } ]; return posts; }; export default function Posts() { const posts = useLoaderData<Post[]>(); console.log(posts); return ( <div> <h1>Posts</h1> <ul> {posts.map((post) => ( <li key={post.slug}> <Link to={post.slug}> {post.title} </Link> </li> ))} </ul> </div> ); }
好的我們完成了型別的宣告,即使通過網路請求取得也沒問題,因為它們定義在同一份檔案。除非網路在 Remix 取得資料時中斷。現在您同時在元件和 API 得到型別的保護(記得元件已經是自己的 API 路由)
重構 建立模組一個穩固的實作就是只處理特定問題。在我們的例子就是讀取和撰寫文章。現在讓我們加入 getPosts
並匯出到我們的模組。建立 app/post.ts
接著我們從路由複製剛剛的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export type Post = { slug : string ; title: string ; }; export function getPosts ( ) { const posts: Post[] = [ { slug : "my-first-post" , title : "My First Post" }, { slug : "90s-mixtape" , title : "A Mixtape I Made Just For You" } ]; return posts; }
更新 app/routes/posts/index.tsx
路由使用我們剛建立的模組
1 2 3 4 5 6 7 8 9 import { Link, useLoaderData } from "remix"; import { getPosts } from "~/post"; import type { Post } from "~/post"; export const loader = () => { return getPosts(); }; // ...
讀取資料來源 假如是在真實的專案,我們通常會把資料放在資料庫例如 Postgres,FaunaDB,Supabase 之類的。本篇只是快速入門,因此我們只會是用本機的檔案系統。我們將會讀取本機檔案取代在程式碼寫死資料。
在專案根目錄建立 posts/
目錄,注意不是 app
目錄下。
然後加入一些文章
1 2 $ touch posts/my-first-post.md $ touch posts/90s-mixtape.md
在裡面寫入一些內容,但記得要有 Front Matter (Markdown 開頭的設定區塊)
posts/my-first-post.md
1 2 3 4 5 6 7 --- title: My First Post --- # This is my first post Isn't it great?
posts/90s-mixtape.md
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 --- title: 90s Mixtape --- # 90s Mixtape - I wish (Skee-Lo)- This Is How We Do It (Montell Jordan)- Everlong (Foo Fighters)- Ms. Jackson (Outkast)- Interstate Love Song (Stone Temple Pilots)- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)- Just a Friend (Biz Markie)- The Man Who Sold The World (Nirvana)- Semi-Charmed Life (Third Eye Blind)- ...Baby One More Time (Britney Spears)- Better Man (Pearl Jam)- It's All Coming Back to Me Now (Céline Dion)- This Kiss (Faith Hill)- Fly Away (Lenny Kravits)- Scar Tissue (Red Hot Chili Peppers)- Santa Monica (Everclear)- C'mon N' Ride it (Quad City DJ's)
更新 getPosts
,改成讀取檔案,這裡我們需要安裝 front-matter
更新 app/post.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import path from 'path' ;import fs from 'fs/promises' ;import parseFrontMatter from 'front-matter' ;export type Post = { slug : string ; title: string ; }; const postsPath = path.join(__dirname, '..' , 'posts' );export async function getPosts ( ) { const dir = await fs.readdir(postsPath); return Promise .all( dir.map(async (filename) => { const file = await fs.readFile( path.join(postsPath, filename) ); const { attributes } = parseFrontMatter( file.toString() ); return { slug : filename.replace(/\.md$/ , '' ), title : attributes.title }; }) ); }
這不是 Node 檔案系統教學,因此您需要信任這邊的程式碼。如之前提到的,您可以從資料庫或任何地方讀取 Markdown。
如果您不是使用 Remix App Server,您的路徑可能需要加入額外的 ‘..’ 。同時注意您不能在不包含儲存空間(系統硬碟)的主機(環境)部署這個範例。
您應該注意到 TypeScript 又警告了,讓我們接著處理。
因為我們讀取了檔案,型別不知道裡面有什麼,因此我們需要在執行時期檢查,為了這個我們需要 invariant
方法使其在執行環境檢查。
確保文章有正確的資料
1 $ npm add tiny-invariant
更新 app/post.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import path from 'path'; import fs from 'fs/promises'; import parseFrontMatter from 'front-matter'; import invariant from 'tiny-invariant'; export type Post = { slug: string; title: string; }; export type PostMarkdownAttributes = { title: string; }; const postsPath = path.join(__dirname, '..', 'posts'); function isValidPostAttributes(attributes: any): attributes is PostMarkdownAttributes { return attributes?.title; } export async function getPosts() { const dir = await fs.readdir(postsPath); return Promise.all( dir.map(async (filename) => { const file = await fs.readFile( path.join(postsPath, filename) ); const { attributes } = parseFrontMatter( file.toString() ); invariant(isValidPostAttributes(attributes), `${filename} has bad meta data!`); return { slug: filename.replace(/\.md$/, ''), title: attributes.title }; }) ); }
就算您不是使用 TypeScript 您也可以使用 invariant
檢查。
好! 回到介面上我們應該會看到文章列表。您可以多加入一些文章,重新載入看看變化。
動態路由參數 現在是時候讓我們的路由可以檢視文章了,我們希望網址如下
1 2 /posts/my -first-post/posts/ 90 s-mixtape
我們可以使用 “Dynamic segment”,而不是替每一篇文章都建立路由。Remix 會解析並傳給我們,如此我們就可以動態查詢對應的文章。建立動態路由
1 touch app/routes/posts/$slug .tsx
1 2 3 4 5 6 7 export default function PostSlug() { return ( <div> <h1>Some Post</h1> </div> ); }
現在您可以點擊文章連結應該會看到新頁面。
接著加入 Loader 來存取參數
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { useLoaderData } from 'remix'; export const loader = async ({ params }) => { return params.slug; }; export default function PostSlug() { const slug = useLoaderData(); return ( <div> <h1>Some Post: {slug}</h1> </div> ); }
我們在檔案名稱前面加上 $
會在 params
物件加入一個具名的屬性,然後會傳給 Loader,這就是我們查詢文章的方式。
又到了處理 TypeScript 的段落,在 app/routes/posts/$slug.tsx
補上
1 2 3 4 5 6 import { useLoaderData } from 'remix'; import type { LoaderFunction } from 'remix'; export const loader: LoaderFunction = async ({ params }) => { return params.slug; };
現在讓我們來讀取文章內容,加入 getPost
函式到文章模組 app/post.ts
1 2 3 4 5 6 7 8 9 10 export async function getPost(slug: string) { const filepath = path.join(postsPath, slug + '.md'); const file = await fs.readFile(filepath); const { attributes } = parseFrontMatter(file.toString()); invariant(isValidPostAttributes(attributes), `Post ${filepath} is missing attributes`); return { slug, title: attributes.title }; }
在 app/routes/posts/$slug.tsx
使用 getPost
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { useLoaderData } from 'remix'; import type { LoaderFunction } from 'remix'; import { getPost } from '~/post'; import invariant from 'tiny-invariant'; export const loader: LoaderFunction = async ({ params }) => { invariant(params.slug, 'expected params.slug'); return getPost(params.slug); }; export default function PostSlug() { const post = useLoaderData(); return ( <div> <h1>{post.title}</h1> </div> ); }
我們現在已經可以從資料來源讀取文章了。
注意到 invariant
,因為 params
的資料來自網址,我們不能保證 params.slug
一定是被我們定義過的,可能您將檔案換成是 $postId.tsx
。實務上最好使用 invariant
檢查,同時它讓 TypeScript 不會警告我們。
網路上有很多 Markdown 的解析器,這篇教學我們將使用 marked
因為它比較簡單。
1 2 3 npm add marked npm add @types/marked
然後回到 app/post.ts
我們要補上文章的內容資料
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { marked } from 'marked' ;export async function getPost (slug: string ) { const filepath = path.join(postsPath, slug + '.md' ); const file = await fs.readFile(filepath); const { attributes, body } = parseFrontMatter(file.toString()); invariant(isValidPostAttributes(attributes), `Post ${filepath} is missing attributes` ); const html = marked(body); return { slug, html, title : attributes.title }; }
更新 app/routes/posts/$slug.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { useLoaderData } from 'remix'; import type { LoaderFunction } from 'remix'; import { getPost } from '~/post'; import invariant from 'tiny-invariant'; export const loader: LoaderFunction = async ({ params }) => { invariant(params.slug, 'expected params.slug'); return getPost(params.slug); }; export default function PostSlug() { const post = useLoaderData(); return ( <div dangerouslySetInnerHTML={{ __html: post.html }} /> ); }
新增部落格文章 現在我們部落格的文章需要靠部屬才能新增。雖然這樣的流程還算是一種簡單的方式,不過如果只是打幾個錯字最好還是避免重新部署比較好。這裡的想法就是把文章放到資料庫,這樣我們就需要一個方式來建立文章我們將使用 Actions 來實現。建立管理路由
文章後面會說明 Actions
1 $ touch app/routes/admin.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { Link, useLoaderData } from 'remix'; import { getPosts } from '~/post'; import type { Post } from '~/post'; export const loader = () => { return getPosts(); }; export default function Admin() { const posts = useLoaderData<Post[]>(); return ( <div className="admin"> <nav> <h1>Administrator</h1> <ul> {posts.map((post) => ( <li key={post.slug}> <Link to={`/posts/${post.slug}`}> {post.title} </Link> </li> ))} </ul> </nav> <main> </main> </div> ); }
您應該認出很多程式碼是來自 posts
路由。我們還額外設定了一些 HTML 結構這是因為我們會快速的套用一些樣式
1 2 $ mkdir app/styles $ touch app/styles/admin.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 .admin { display : flex; } .admin > nav { padding-right : 2rem ; } .admin > main { flex : 1 ; border-left : solid 1px #ccc ; padding-left : 2rem ; } em { color : red; }
把樣式和路由關聯,到 app/routes/admin.tsx
加入
1 2 3 4 5 import adminStyles from '~/styles/admin.css'; // ... export const links = () => { return [{ rel: 'stylesheet', href: adminStyles}]; }
每一個路由可以匯出一個 links
函式,回傳 <link>
的陣列,除了格式是物件而不是 HTML。因此使用 { ref: 'stylesheet', href: adminStyles}
的格式,而不是 <link rel="stylesheet" href="..." />
。這麼做是為了讓 Remix 合併所有渲染路由的 links
,彙整在文件上方的 <Links />
元素裡。如果您好奇可以看看 root.tsx
。
OK,您應該要看到簡單的排版,左邊是文章列表,右邊留白我們還沒實作。現在您需要手動輸入 http://localhost:3000/admin 才能造訪管理頁面,因為我們還沒設定連結
Index 路由 讓我們是使用 Index 路由來實作右邊的區塊。現在我們要介紹的是”巢狀路由”即根據路由轉換成巢狀元件結構。
利用建立一個目錄設定 admin.tsx
的子路由
1 2 $ mkdir app/routes/admin $ touch app/routes/admin/index.tsx
如果您重新載入,應該看不到任何東西。在 app/routes/admin/
目錄下的每一個路由,一旦網址符合都可以渲染到 app/routes/admin.tsx
之中。您可以控制要渲染在哪裡。在 admin.tsx
加入 Outlet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import { Outlet, Link, useLoaderData } from 'remix'; import { getPosts } from '~/post'; import type { Post } from '~/post'; import adminStyles from '~/styles/admin.css'; export const loader = () => { return getPosts(); }; export const links = () => { return [{ rel: 'stylesheet', href: adminStyles}]; } export default function Admin() { const posts = useLoaderData<Post[]>(); return ( <div className="admin"> <nav> <h1>Administrator</h1> <ul> {posts.map((post) => ( <li key={post.slug}> <Link to={`/posts/${post.slug}`}> {post.title} </Link> </li> ))} </ul> </nav> <main> <Outlet /> </main> </div> ); }
給我們幾分鐘,剛開始 Index 路由可能會讓您困惑。只要知道當網址上層路由路徑,子路由的 Index
就會對應到 Outlet
。
讓我們先來實作 /admin/new
,也許對您的理解有幫助。建立 app/routes/admin/new.tsx
1 $ touch app/routes/admin/new.tsx
1 2 3 4 5 export default function NewPost() { return ( <h2>New Post</h2> ); }
現在點擊 Index 上面的 “Creae a New Post” ,<Outlet />
會自動切換到 “new” 路由。
Actions 然後我們要在這個 “new” 路由建置一個表單來建立文章
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import { Form } from 'remix'; export default function NewPost() { return ( <> <h2>New Post</h2> <Form method="post"> <p> <label> Post Title: <input type="text" name="title" /> </label> </p> <p> <label> Post Slug: <input type="text" name="slug" /> </label> </p> <p> <label htmlFor="markdown">Markdown:</label> <br /> <textarea id="markdown" rows={20} name="markdown" /> </p> <p> <button type="submit">Create Post</button> </p> </Form> </> ); }
要完成新增功能我們需要一個表單從使用者那取得資料,然後一個後端處理收到的資料。在 Remix 您一樣需要完成這些。讓我們先在 post.ts
建立儲存資料的功能。在 app/post.ts
新增 createPost
函式
1 2 3 4 5 6 7 8 export async function createPost (post ) { const md = `---\ntitle: ${post.title} \n---\n\n${post.markdown} ` ; await fs.writeFile( path.join(postsPath, post.slug + '.md' ), md ); return getPost(post.slug); }
在新增頁面使用 createPost
,編輯 app/routes/admin/new.tsx
加入 action
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import { Form, redirect } from 'remix'; import { createPost } from '~/post'; export const action = async ({ request }) => { const formData = await request.formData(); const title = formData.get('title'); const slug = formData.get('slug'); const markdown = formData.get('markdown'); await createPost({ title, slug, markdown }); return redirect('/admin'); } export default function NewPost() { return ( <> <h2>New Post</h2> <Form method="post"> <p> <label> Post Title: <input type="text" name="title" /> </label> </p> <p> <label> Post Slug: <input type="text" name="slug" /> </label> </p> <p> <label htmlFor="markdown">Markdown:</label> <br /> <textarea id="markdown" rows={20} name="markdown" /> </p> <p> <button type="submit">Create Post</button> </p> </Form> </> ); }
就這樣。Remix 會幫您處理剩下的事情。當點擊 submit 按鈕,會看到側邊欄自動多了一篇文章。
formData
可以使用和input
的 name
相同值取得 input
資料。好的過程中應該又看到一些 TypeScript 的警告。
在 app/post.ts
加入型別
1 2 3 4 5 6 7 8 9 10 11 12 13 14 export type NewPost = { title: string; slug: string; markdown: string; } export async function createPost(post: NewPost) { const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`; await fs.writeFile( path.join(postsPath, post.slug + '.md'), md ); return getPost(post.slug); }
app/routes/admin/new.tsx
也需要處理型別的問題
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import type { ActionFunction } from "remix"; export const action: ActionFunction = async ({ request }) => { const formData = await request.formData(); const title = formData.get('title'); const slug = formData.get('slug'); const markdown = formData.get('markdown'); await createPost({ title, slug, markdown }); return redirect('/admin'); }
無論是否使用 TypeScript 我們現在還是有個問題,就是當使用者不提供欄位值的時候(TypeScript 依然在警告 createPost
)。讓我們在建立文章之前加入驗證
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // app/routes/admin/new.tsx export const action: ActionFunction = async ({ request }) => { const formData = await request.formData(); const title = formData.get('title'); const slug = formData.get('slug'); const markdown = formData.get('markdown'); const errors = {}; if (!title) errors.title = true; if (!slug) errors.slug = true; if (!markdown) errors.markdown = true; if (Object.keys(errors).length) { return errors; } await createPost({ title, slug, markdown }); return redirect('/admin'); }
注意到我們這次沒有使用 redirect
,我們回傳 errors
。這些錯誤可以通過 useActionData
取得,就類似 useLoaderData
,但資料來自一個 Form Post 之後的 Action。接著在 app/routes/admin/new.tsx
加入驗證資訊的部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import { Form, redirect, useActionData } from 'remix'; import type { ActionFunction } from 'remix'; import { createPost } from '~/post'; //... export default function NewPost() { const errors = useActionData(); return ( <> <h2>New Post</h2> <Form method="post"> <p> <label> Post Title: {" "} {errors?.title ? ( <em>Title is required</em> ) : null} <input type="text" name="title" /> </label> </p> <p> <label> Post Slug: {" "} {errors?.slug ? ( <em>Slug is required</em> ) : null} <input type="text" name="slug" /> </label> </p> <p> <label htmlFor="markdown">Markdown:</label> {" "} {errors?.markdown ? ( <em>Markdown is required</em> ) : null} <br /> <textarea id="markdown" rows={20} name="markdown" /> </p> <p> <button type="submit">Create Post</button> </p> </Form> </> ); }
TypeScript 依然有警告,讓我們加入 invariant
和錯誤資訊的物件型別
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 import { Form, redirect, useActionData } from 'remix'; import type { ActionFunction } from 'remix'; import invariant from 'tiny-invariant'; import { createPost } from '~/post'; type PostError = { title?: boolean; slug?: boolean; markdown?: boolean; } export const action: ActionFunction = async ({ request }) => { const formData = await request.formData(); const title = formData.get('title'); const slug = formData.get('slug'); const markdown = formData.get('markdown'); const errors: PostError = {}; if (!title) errors.title = true; if (!slug) errors.slug = true; if (!markdown) errors.markdown = true; if (Object.keys(errors).length) { return errors; } invariant(typeof title === 'string'); invariant(typeof slug === 'string'); invariant(typeof markdown === 'string'); await createPost({ title, slug, markdown }); return redirect('/admin'); } export default function NewPost() { const errors = useActionData(); return ( <> <h2>New Post</h2> <Form method="post"> <p> <label> Post Title: {" "} {errors?.title ? ( <em>Title is required</em> ) : null} <input type="text" name="title" /> </label> </p> <p> <label> Post Slug: {" "} {errors?.slug ? ( <em>Slug is required</em> ) : null} <input type="text" name="slug" /> </label> </p> <p> <label htmlFor="markdown">Markdown:</label> {" "} {errors?.markdown ? ( <em>Markdown is required</em> ) : null} <br /> <textarea id="markdown" rows={20} name="markdown" /> </p> <p> <button type="submit">Create Post</button> </p> </Form> </> ); }
為了讓事情更有趣,在瀏覽器開發工具停用 JavaScript ,在試試看功能。因為 Remix 是根據 HTML 和 HTTP 基礎功能構成的,就算少了瀏覽器支援 JavaScript 還是可以正常運作,但這不是重點。讓我們放慢腳步為表單加入一些待處理狀態的介面。在 app/routes/admin/new.tsx
仿造一些延遲
1 2 3 4 5 6 7 8 9 10 11 export const action: ActionFunction = async ({ request }) => { await new Promise((reslove) => setTimeout(reslove, 1000)); const formData = await request.formData(); const title = formData.get('title'); const slug = formData.get('slug'); const markdown = formData.get('markdown'); // ... }
使用 useTransition
加入待處理的介面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import { Form, redirect, useActionData, useTransition } from 'remix'; import type { ActionFunction } from 'remix'; import invariant from 'tiny-invariant'; import { createPost } from '~/post'; // ... export default function NewPost() { const errors = useActionData(); const transition = useTransition(); return ( <> <h2>New Post</h2> <Form method="post"> {/ ... /} <p> <button type="submit"> {transition.submission ? 'Creating ...' : 'Create Post' } </button> </p> </Form> </> ); }
現在如果啟用 JavaScript 的話,使用者體驗好了一些。一些您可以做的優化像是新增時自動將 title
轉換填入 slug
,讓使用者可以自行在調整。
教學大致上到這裡,您可以思考一下如何建置 /admin/edit
編輯文章。側邊欄的連結可以使用。建立一個新的路由讀取文章,將資料放入欄位,全部需要的程式碼在 app/routes/posts/$slug.tsx
和 app/routes/admin/new.tsx
都有。
希望您會喜歡 Remix。