最近 remix 的出現在推特上引起廣大迴響,有蠻多前端的社群都在討論,剛好最近也有內部的工具要開發,心血來潮也來試試看。在體感上很像 next.js,以 route base 的方式為主,透過類似 getServerSideProps
等規範好的函數讓伺服器抓資料做 SSR。
remix 是由 react-router 的作者 Ryan Florence 寫的,當初原本是要走付費路線,後來改為開源。雖然說可能不少人又會覺得「哎呀,怎麼又一個框架」,不過 remix 的確有它的獨特之處,如果有翻過文件而且追過最顯而易見的是 remix 對於 form 的執著,這應該也是 remix 跟其他 SSR 框架最大的不同之處。現在回想起來,表單的處理也是一些剛入門前端時很容易混肴的事情。
咦,可是瀏覽器的表單不是會重新整理嗎?這樣不符合需求!
remix 在表單處理的部分下了很多功夫,除了可以用 form 來實作非同步請求之外,如果沒有開啟 JavaScript 還會 fallback 到瀏覽器內建的表單操作,雖然需要重整但至少提交功能還是可以運作。(而且我覺得有時候直接提交表單重整瀏覽器體驗反而比非同步好很多🤔)
這篇文章不會深入介紹 remix 的各個功能,而是會針對表單處理跟資料讀取這兩個部分做探討。
表單處理有什麼好處?
不需要寫一堆 JavaScript 程式碼,也不需要另外用變數(或是 state)來存放輸入值。例如下面的範例:
<form action="/my/api" method="post">
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
在預設的情況下(沒有任何程式碼介入),按下按鈕時瀏覽器會自動發出一個請求到 /my/api
,方法為 POST
,並以 form data 的方式將 user_name
及 world
欄位送出。
然而預設的行為需要伺服器的幫助才能完整實現,例如伺服器如果只回傳 JSON,那麼使用者的頁面就會停在同一頁。所以在一般的應用當中,伺服器通常會回傳 301 轉導到其他頁面,例如成功頁面、失敗頁面。
有時候直接重整整個頁面反而會讓事情變得單純許多,因為在前端就不用維護各種狀態以及對 history 的操作。然而對於像是即時留言、聊天這種強調即時性的應用,頁面重整就不是那麼理想。
那麼要怎麼把表單變成非同步操作?透過 submit 事件禁止預設行為後,再撰寫程式碼送出就可以了:
<form action="/my/api" method="post" onSubmit={e => {
e.preventDefault()
// call API!
}}>
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
這樣寫最大的好處是將整個表單操作交由瀏覽器機制來處理,而且在瀏覽器當中可以透過 FormData
輕鬆拿到表單的值:
<form action="/my/api" method="post" onSubmit={e => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const userName = formData.get('user_name')
const world = formData.get('world')
}}>
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
這樣子一來不需要另外用 ref 或是用 state 也可以拿到輸入值。如果不利用表單原生機制的話,在 React 當中可能會這樣寫:
const Component = () => {
const [value, setVal1] = useState()
const [value, setVal2] = useState()
const [value, setVal3] = useState()
return <>
<input value={value1} onChange={handleChange} />
<input value={value2} onChange={handleChange} />
<input value={value3} onChange={handleChange} />
</>
}
可以發現,當 input 欄位增加時,useState
也會增加,寫起來就會比較繁瑣一些。當然這部分也可以做一層抽象化來解決,但這樣就多加上一層抽象化跟 overhead。
不過一旦將表單改為非同步之後,就有許多狀態需要考慮:
- 載入中:表單正在提交,這時候要將欄位都設為 disabled 防止使用者操作
- 表單驗證錯誤:需要保留欄位的值並顯示錯誤訊息
- API 成功:顯示對應提示並清空表單
在 remix 當中對於表單提交後跳轉跟非同步的表單應用這兩種操作都有提供 API 支援。我會喜歡這樣做的原因是,很多時候真的不需要大費周章做非同步,寫了一大堆程式碼只為了做簡單的表單提交。
同步(使用表單原生機制)
在原生的情況下,只要按部就班定義好表單內容即可,不需要宣告任何 state 就可以做到。
// routes/users/index.js
export default function UserProfile() {
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
<button>Submit</button>
</form>
}
在 server 端需要加入對應的處理,remix 的做法是在同一個檔案當中加入叫做 action
的函數:
import { redirect } from 'remix';
export async function action({ request }) {
const formData = await request.formData()
const user = await createUser(formData)
return redirect(`/users/${user.id}`)
}
// routes/users/index.js
export default function UserProfile() {
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
<button>Submit</button>
</form>
}
在 HTTP 方法不為 GET 的時候,action 函數就會被呼叫,所以要在 action 函數裏頭判斷 HTTP 方法為何。
這個 redirect
函數是由 remix 所提供的,會在伺服器端轉導至 users/id
頁面。這邊很重要的一點是轉導這件事是發生在伺服器端,而不是使用 history.push
。如果表單驗證或是伺服器回傳錯誤,可以透過 useActionData
拿到伺服器回傳的資料。
export async function action({ request }) {
const formData = await request.formData()
+ if (someError) {
+ json({ error: message })
+ }
return redirect(`/users/${user.id}`)
}
// routes/users/index.js
export default UserProfile() {
+ const actionData = useActionData()
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
+ {actionData.error && <p>{actionData.error}</p>}
<button>Submit</button>
</form>
}
非同步
對於非同步的需求,remix 提供了 Form
元件給開發者使用。為了顯示當前的表單繳交狀態,可以另外用 useTransition
來表示:
import { Form, useTransition } from 'remix'
// routes/users/index.js
export default function UserProfile() {
const actionData = useActionData()
const transition = useTransition()
return <form method="post" action="/users">
<label>
Name:
<input
type="text"
name="userName"
disabled={transition.state === 'submitting'}
/>
</label>
{actionData.error && <p>{actionData.error}</p>}
<button>Submit</button>
</form>
}
除了這兩種機制以外,也可以用 useSubmit
自行決定發送表單的時機點。不管是同步與非同步,remix 對表單處理的思想是,當你發送了一個表單處理後會先經過一番操作,然後跳轉到某個頁面。
資料讀取機制 - loader 與 fetcher
在網頁當中常見的資料讀取時機主要有兩種:
- 當使用者透過
<a>
或是網址列存取頁面時就讀取頁面資料,如部落格文章 - 當使用者進行某個操作後才呼叫 API 存取資料,如點擊評論欄後呼叫 API 拿資料
第一種情況下 remix 提供了 loader 機制,只會在伺服器端渲染,所以元件在渲染時都已經是有資料的狀態,就不用做 loading 後渲染資料的操作跟程式碼實作。
第二種情況下 remix 則提供了 fetcher 機制,你可以用 useFetcher
做 mutation 跟讀取資料:
const MyComponent = () => {
const fetcher = useFetcher()
useEffect(() => {
fetcher.load('/comments')
}, [])
if (fetcher.state === 'loading') {
return <p>loading...</p>
}
return <div>
<Comments comments={fetcher.data.comments} />
</div>
}
不只讀取資料而已,fetcher 也可以用來做 mutation:
const MyComponent = ({ content }) => {
const fetcher = useFetcher()
return <fetcher.Form method="post" action="/comments">
<input type="text" name="content"></input>
<button type="submit">Submit</button>
</fetcher.Form>
}
在 remix 背後會幫你處理上述提到的操作:
- 點擊 submit 時將狀態改為 submitting 方便做載入中的 UI
- 回傳資料後可以用
fetcher.data
取得 fethcer.submit
直接提交表單內容,但不會做跳轉(背後其實就是呼叫 API 跟各種狀態處理)
Edge Computing
另外一點則是 remix 不斷強調所謂 Edge server(CDN)的概念,也就是不單單只用純靜態網頁部署到 CDN,而是將伺服器部署各個節點來減少 Network traffic。
的確,如果使用者的設備效能已經不再是瓶頸的話,那麼唯一有影響的就是網路速度了。這邊的 edge computing 是指把 server 部署到離使用者較近的地區,進而減少封包走的 routing。不過對於某些應用來說,服務的對象都是當地的使用者,區別可能沒有那麼大就是了。
總結
我會喜歡 remix 的處理方式,是因為它對表單的執著讓我想起以前也曾經有這樣的想法,透過原生表單機制可以省下一大堆 value 狀態宣告,透過表單的處理機制也可以應付一些簡單的應用場景,而不需要寫各種非同步 API,在開發時間不足的情況下,非同步要考慮的事情很多,往往一個沒處理好體驗就比傳統的表單還要差。
remix 兼顧了各種場景,在沒有 JavaScript 的情況下可以走原生表單機制,需要即時性可以用 Form 元件來組裝,需要更多 SPA 的互動場景可以用 fetcher,對於前端最在意的資料處理來說問題解決了一大半。