Next.js 強勁對手來了! Remix 正式宣布開源
大家好,我是皮湯。周五翻 Github 趨勢榜看到了 Remix 這個內(nèi)容,覺得挺有發(fā)展前景的,初步了解了一下具體的特性,分享給大家。
近期,由 React Router 原班團隊打造,基于 TypeScript 與 React,內(nèi)建 React Router V6 特性的全棧 Web 框架 Remix 正式開源。目前占據(jù) Github 趨勢總榜前 3,Github 標星 5K+ Star:
Remix 開源之后可以說是在 React 全??蚣茴I(lǐng)域激起千層浪,絕對可以算是 Next.js 的強勁對手。Remix 的特性如下:
- 追求速度,然后是用戶體驗(UX),支持任何 SSR/SSG 等
- 基于 Web 基礎(chǔ)技術(shù),如 HTML/CSS 與 HTTP 以及 Web Fecth API,在絕大部分情況可以不依賴于 JavaScript 運行,所以可以運行在任何環(huán)境下,如 Web Browser、Cloudflare Workers、Serverless 或者 Node.js 等
- 客戶端與服務(wù)端一致的開發(fā)體驗,客戶端代碼與服務(wù)端代碼寫在一個文件里,無縫進行數(shù)據(jù)交互,同時基于 TypeScript,類型定義可以跨客戶端與服務(wù)端共用
- 內(nèi)建文件即路由、動態(tài)路由、嵌套路由、資源路由等
- 干掉 Loading、骨架屏等任何加載狀態(tài),頁面中所有資源都可以預(yù)加載(Prefetch),頁面幾乎可以立即加載
- 告別以往瀑布式(Waterfall)的數(shù)據(jù)獲取方式,數(shù)據(jù)獲取在服務(wù)端并行(Parallel)獲取,生成完整 HTML 文檔,類似 React 的并發(fā)特性
- 提供開發(fā)網(wǎng)頁需要所有狀態(tài),開箱即用;提供所有需要使用的組件,包括 <Links> 、<Link>、 <Meta> 、<Form> 、<Script/> ,用于處理元信息、腳本、CSS、路由和表單相關(guān)的內(nèi)容
- 內(nèi)建錯誤處理,針對非預(yù)期錯誤處理的 <ErrorBoundary> 和開發(fā)者拋出錯誤處理的 <CatchBoundary>
特性這么多?不明覺厲!接下來我們就嘗試一一來展示這些 Remix 的特性🚀。
一致的開發(fā)體驗
Remix 提供基于文件的路由,將讀取數(shù)據(jù)、操作數(shù)據(jù)和渲染數(shù)據(jù)的邏輯都寫在同一個路由文件里,方便一致性處理,這樣可以跨客戶端和服務(wù)端邏輯共享同一套類型定義。
看一段官網(wǎng)的代碼:
- import type { Post } from "~/post";
- import { Outlet, Link, useLoaderData, useTransition } from "remix";
- let postsPath = path.join(__dirname, "..", "posts");
- async function getPosts() {
- let dir = await fs.readdir(postsPath);
- return Promise.all(
- dir.map(async (filename) => {
- let file = await fs.readFile(path.join(postsPath, filename));
- let { attributes } = parseFrontMatter(file.toString());
- invariant(
- isValidPostAttributes(attributes),
- `${filename} has bad meta data!`
- );
- return {
- slug: filename.replace(/.md$/, ""),
- title: attributes.title,
- };
- })
- );
- }
- async function createPost(post: Post) {
- let md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
- await fs.writeFile(path.join(postsPath, post.slug + ".md"), md);
- return getPost(post.slug);
- }
- export async function loader({ request }) {
- return getProjects();
- }
- export async function action({ request }) {
- let form = await request.formData();
- const post = createPost({ title: form.get("title") });
- return redirect(`/posts/${post.id}`);
- }
- export default function Projects() {
- let posts = useLoaderData<Post[]>();
- let { state } = useTransition();
- let busy = state === "submitting";
- return (
- <div>
- {posts.map((post) => (
- <Link to={post.slug}>{post.title}</Link>
- ))}
- <Form method="post">
- <input name="title" />
- <button type="submit" disabled={busy}>
- {busy ? "Creating..." : "Create New Post"}
- </button>
- </Form>
- <Outlet />
- </div>
- );
- }
上述是一個路由文件,如果它是 src/routes/posts/index.tsx 文件,那么我們開啟服務(wù)器,通過 localhost:3000/posts 就可以訪問到這個文件,這就是文件即路由,而默認導(dǎo)出的 Projects 函數(shù),即為一個 React 函數(shù)式組件,此函數(shù)的返回模板則為訪問這個路由的 HTML 文檔。
- 每個路由函數(shù),如 Projects 可以定義一個 loader 函數(shù),類似處理 GET 請求的服務(wù)端函數(shù),可以獲取到路由信息,為初次服務(wù)端渲提供數(shù)據(jù),在這個函數(shù)中可以獲取文件系統(tǒng)、請求數(shù)據(jù)庫、進行其他網(wǎng)絡(luò)請求,然后返回數(shù)據(jù),在我們的 Projects 組件里,可以通過 Remix 提供的 useLoaderData 鉤子拿到 loader 函數(shù)獲取到的數(shù)據(jù)。
- 每個路由函數(shù)也可以定義一個 action 函數(shù),用于進行實際的操作,類似處理非 GET 請求,如 POST/PUT/PATCH/DELETE 的操作的函數(shù),它可以操作修改數(shù)據(jù)庫、寫入文件系統(tǒng)等,同時其返回的結(jié)果可能是實際的數(shù)據(jù)或是重定向到某個新頁面,如 redirect("/admin")。當 action 函數(shù)返回數(shù)據(jù)或錯誤信息時,我們可以通過 Remix 提供的 useActionData 鉤子拿到這個返回的錯誤信息,進行前端的展示等。
值得注意的是,action 函數(shù)是在 <Form method="post"> 表單里,用戶點擊提交按鈕之后自動調(diào)用,Remix 通過 Fetch API 的形式去調(diào)用,然后在前端不斷的輪詢獲取調(diào)用結(jié)果,且自動處理用戶多次點擊時的競爭情況。
上述代碼渲染的頁面如下:
整個 App 網(wǎng)站是由 <Document> 嵌套 <Layout> 組成,其中 <Outlet> 是路由的填充處,即上圖中綠色的部分。當我們訪問 localhost:3000/ 時,其中填充的內(nèi)容為 src/routes/index.tsx 路由文件對應(yīng)的渲染內(nèi)容,而當我們訪問 localhost:3000/admin 時,對應(yīng)的是 src/routes/admin.tsx 路由文件對應(yīng)的渲染內(nèi)容。
而我們在 的 src/routes/admin.tsx 繼續(xù)提供了 <Outlet> 路由顯然組件,意味著當我們繼續(xù)添加分級(嵌套)路由時,如訪問 http://localhost:3000/admin/new 那么這個 <Outlet> 會渲染 src/routes/admin/new.tsx 對應(yīng)路由文件的渲染內(nèi)容,而訪問 http://localhost:3000/admin 時,<Outlet> 部分會渲染 src/routes/admin/index.tsx 對應(yīng)路由文件的渲染內(nèi)容,見下圖:
而這種嵌套路由是自動發(fā)生的,當你創(chuàng)建了一個 src/routes/admin.tsx 之后,又創(chuàng)建了一個同名的文件夾,并在文件夾下建立了其它文件,那么這些文件的文件名會被注冊為下一級的嵌套路由名:
- localhost:3000/admin 同時注冊 src/routes/admin.tsx 和 src/routes/admin/index.tsx
- localhost:3000/admin/new 注冊 src/routes/admin/new.tsx
通過這種文件即路由,同名文件夾下文件即嵌套路由的方式,然后通過在父頁面里面通過
上述嵌套路由一個顯而易見的優(yōu)點就是,某個部分如果報錯了,結(jié)合后續(xù)會提到的 ErrorBoundary 和 CatchBoundary 這個部分可以顯示錯誤的頁面,而用戶仍然可以操作其他部分,而不需要刷新整個頁面以重新加載使用,極大提高網(wǎng)站容錯性。
再見,加載狀態(tài)
通過嵌套路由,Remix 可以干掉幾乎所有的加載狀態(tài)、骨架屏,現(xiàn)在很多應(yīng)用都是在前端組件里進行數(shù)據(jù)獲取,獲取前置數(shù)據(jù)之后,然后用前置數(shù)據(jù)去獲取后置的數(shù)據(jù),形成了一個瀑布式的獲取形式,當數(shù)據(jù)量大的時候,頁面加載就需要很長時間,所以絕大部分網(wǎng)站都會放一個加載的狀態(tài),如小菊花轉(zhuǎn)圈圈,或者體驗更好一點的骨架屏,如下:
這是因為這些應(yīng)用缺乏類似 Remix 這樣的嵌套路由的概念,訪問某個路由時,就是訪問這個路由對應(yīng)的頁面,只有這個頁面加載出來之后,里面的子組件渲染時,再進行數(shù)據(jù)的獲取,再加載子組件,如此往復(fù),就呈現(xiàn)瀑布流式的加載,帶來了很多中間的加載狀態(tài)。
而 Remix 提供了嵌套路由,當訪問路由 localhost:3000/admin/new 時,會加載三級路由,同時這三個路由對應(yīng)的頁面獨立、并行加載,獨立、并行獲取數(shù)據(jù),最后發(fā)送給客戶端的是一個完整的 HTML 文檔,如下過程:
可見雖然我們首屏拿到內(nèi)容可能會慢一點,但是再也不需要加載狀態(tài),再見,菊花圖 👋🏻,再見,骨架屏👋🏻。
同時借助嵌套路由,當我們鼠標 Hover 到某個鏈接準備點擊切換某個子路由時,Remix 提供了預(yù)獲取(Prefetch)功能,可以提前并行獲取子路由文檔和各種資源,包括 CSS、圖片、相關(guān)數(shù)據(jù)等,這樣當我們實際點擊這個鏈接切換子路由時,頁面可以立即呈現(xiàn)出來:
完善的錯誤處理
我們的網(wǎng)站經(jīng)常會遇到問題,使用其他框架編寫時,網(wǎng)站遇到問題可能用戶就需要重新刷新網(wǎng)站,而對于 Remix 來說,基于嵌套路由的理念,則無需重新刷新,只需要在對應(yīng)的錯誤的子路由展示錯誤信息,而頁面的其他部分仍然可以正常工作:
比如我們上圖的右下角子路由出現(xiàn)了問題,那么這塊會展示出問題時的錯誤頁面,而其他頁面部分仍然展示正常的信息。
正因為錯誤經(jīng)常發(fā)生,且處理錯誤異常困難,包含客戶端、服務(wù)端的各種錯誤,包含預(yù)期的、非預(yù)期的錯誤等,所以 Remix 內(nèi)建了完善的錯誤處理機制,提供了類似 React 的 ErrorBoundary 的理念。
在 Remix 中,每個路由函數(shù)對應(yīng)一個 ErrorBoundary 函數(shù):
- export default function RouteFunction() {}
- export function ErrorBoundary({ error }) {
- console.error(error);
- return (
- <div>
- <h2>Oh snap!</h2>
- <p>
- There was a problem loading this invoice
- </p>
- </div>
- );
- }
ErrorBoundary 函數(shù)代表處理那些來自 loader 和 action,客戶端或服務(wù)端的非預(yù)期的錯誤,當出現(xiàn)這些非預(yù)期的錯誤時,就會激活這個函數(shù),顯示對應(yīng)函數(shù)的表示錯誤信息的 UI。
同時每個路由函數(shù)對應(yīng)著一個 CatchBoundary 函數(shù):
- import { useCatch } from "remix";
- export function CatchBoundary() {
- let caught = useCatch();
- return (
- <div>
- <h1>Caught</h1>
- <p>Status: {caught.status}</p>
- <pre>
- <code>{JSON.stringify(caught.data, null, 2)}</code>
- </pre>
- </div>
- );
- }
CatchBoundary 函數(shù)對應(yīng)著預(yù)期的錯誤,即你在 loader、action 函數(shù)中,在客戶端或服務(wù)端,手動拋出的 Response 錯誤,這些錯誤的路徑是可預(yù)期的,在 CatchBoundary 中,通過 useCatch 鉤子獲取這些拋出的 Response 錯誤,然后展示對于的錯誤信息的 UI。
當我們沒有在子路由中添加 ErrorBoundary 或 CatchBoundary 函數(shù)時,一旦遇到錯誤,這些錯誤就會向更上一級的路由冒泡,直至最頂層的路由頁面,所以你只最好在最頂層的路由文件里聲明一個 ErrorBoundary 和 CatchBoundary 函數(shù),用于捕獲所有可能的錯誤,然后在代碼審查( Code Review)時及時排查出來。
基于 Web 基礎(chǔ)技術(shù)
Remix 專注于用 Web 基礎(chǔ)技術(shù),HTML/CSS + HTTP 等解決問題,同時提供了在 Web 全棧開發(fā)框架中所需要的所有狀態(tài)和所有基礎(chǔ)組件。
其中相關(guān)狀態(tài)包含:
- // 加載數(shù)據(jù)的狀態(tài)
- useLoaderData()
- // 更新數(shù)據(jù)的狀態(tài)
- useActionData()
- // 提交表單等相關(guān)狀態(tài)
- useFormAction()
- useSubmit()
- // 統(tǒng)一的加載狀態(tài)
- useTransition()
- // 錯誤抓取狀態(tài)等
- useCatch()
以及 Web 網(wǎng)站組成的基礎(chǔ)組件:
- <Meta> 用于動態(tài)的設(shè)置網(wǎng)頁的元信息,方便 SEO
- <Script> 用于告知 Remix 是否需要在加載網(wǎng)頁時導(dǎo)入相關(guān) JS,因為大部分情況下 Remix 編寫的頁面無需 JS 也能正常工作
- <Form> 用于替代原生的 <form> 方便在客戶端和服務(wù)端進行表單操作,接管提交時的相應(yīng)功能,使用 Fetch API 發(fā)起請求等,以及處理多次重復(fù)提交的競爭狀態(tài)等
同時在路由函數(shù)所在文件里,可以通過聲明 link 、meta 、links 、headers 等函數(shù)來聲明對應(yīng)的功能:
- links 變量函數(shù):表示此頁面需要加載的資源,如 CSS、圖片等
- import type { LinksFunction } from "remix";
- import stylesHref from "../styles/something.css";
- export let links: LinksFunction = () => {
- return [
- // add a favicon
- {
- rel: "icon",
- href: "/favicon.png",
- type: "image/png"
- },
- // add an external stylesheet
- {
- rel: "stylesheet",
- href: "https://example.com/some/styles.css",
- crossOrigin: "true"
- },
- // add a local stylesheet, remix will fingerprint the file name for
- // production caching
- { rel: "stylesheet", href: stylesHref },
- // prefetch an image into the browser cache that the user is likely to see
- // as they interact with this page, perhaps they click a button to reveal in
- // a summary/details element
- {
- rel: "prefetch",
- as: "image",
- href: "/img/bunny.jpg"
- },
- // only prefetch it if they're on a bigger screen
- {
- rel: "prefetch",
- as: "image",
- href: "/img/bunny.jpg",
- media: "(min-width: 1000px)"
- }
- ];
- };
- links 函數(shù):聲明需要 Prefetch 的頁面,當用戶點擊之前就加載好資源
- export function links() {
- return [{ page: "/posts/public" }];
- }
- meta 函數(shù):與 組件類似,聲明頁面需要的元信息
- import type { MetaFunction } from "remix";
- export let meta: MetaFunction = () => {
- return {
- title: "Josie's Shake Shack", // <title>Josie's Shake Shack</title>
- description: "Delicious shakes", // <meta name="description" content="Delicious shakes">
- "og:image": "https://josiesshakeshack.com/logo.jpg" // <meta property="og:image" content="https://josiesshakeshack.com/logo.jpg">
- };
- };
- headers 函數(shù):定義此頁面發(fā)送 HTTP 請求時,帶上的請求頭信息
- export function headers({ loaderHeaders, parentHeaders }) {
- return {
- "X-Stretchy-Pants": "its for fun",
- "Cache-Control": "max-age=300, s-maxage=3600"
- };
- }
由此可見,Remix 提供了整個全棧 Web 開發(fā)生命周期所需要的幾乎的一切內(nèi)容,且內(nèi)置最佳實踐,確保你付出很少的努力就能開發(fā)出性能卓越、體驗優(yōu)秀的網(wǎng)站!
當然這篇文章并不能包含所有 Remix 的特性,看到這里仍然對 Remix 感興趣的同學可以訪問官網(wǎng)(https://remix.run/)詳細了解哦~ 官網(wǎng)提供了非常詳細的實戰(zhàn)教程幫助你使用 Remix 開發(fā)實際的應(yīng)用。
了解了 Remix 的特性之后,你對 Remix 有什么看法呢?你覺得它能超過 Next.js 🐴?