Next-js 強勁對手來了! Remix 正式宣佈開源

大家好,我是皮湯。週五翻 Github 趨勢榜看到了 Remix 這個內容,覺得挺有發展前景的,初步瞭解了一下具體的特性,分享給大家。

近期,由 React Router 原班團隊打造,基於 TypeScript 與 React,內建 React Router V6 特性的全棧 Web 框架 Remix 正式開源。目前佔據 Github 趨勢總榜前 3,Github 標星 5K+ Star:

Remix 開源之後可以說是在 React 全棧框架領域激起千層浪,絕對可以算是 Next.js 的強勁對手。Remix 的特性如下:

特性這麼多?不明覺厲!接下來我們就嘗試一一來展示這些 Remix 的特性🚀。

🌈 一致的開發體驗

Remix 提供基於文件的路由,將讀取數據、操作數據和渲染數據的邏輯都寫在同一個路由文件裏,方便一致性處理,這樣可以跨客戶端和服務端邏輯共享同一套類型定義。

看一段官網的代碼:

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  />
        <button type="submit" disabled={busy}>
          {busy ? "Creating..." : "Create New Post"}
        </button>
      </Form>
      
      <Outlet />
    </div>
  );
}

上述是一個路由文件,如果它是 src/routes/posts/index.tsx 文件,那麼我們開啓服務器,通過 localhost:3000/posts 就可以訪問到這個文件,這就是文件即路由,而默認導出的 Projects 函數,即爲一個 React 函數式組件,此函數的返回模板則爲訪問這個路由的 HTML 文檔。

值得注意的是,action 函數是在 <Form method="post"> 表單裏,用戶點擊提交按鈕之後自動調用,Remix 通過 Fetch API 的形式去調用,然後在前端不斷的輪詢獲取調用結果,且自動處理用戶多次點擊時的競爭情況。

你的瀏覽器網絡面板將呈現如下情況,自動 Remix 發起 POST 請求,然後處理重定向到 /post/${post.id} ,同時加載對應的 /posts/posts/${post.id} 對應的路由頁面內容。

通過 Remix 提供的 useTransition 鉤子,我們可以拿到表單提交的狀態,當請求還未返回結果時,我們可以通過這個狀態 state 判斷是否要展示一個加載狀態,提示用戶當前的請求進展。

同時 Post 類型在 useLoaderData<Post[]>()createPost(post: Post)時可以共用。

有同學可能注意到了,上面我們整個頁面渲染、到發起創建 Post 請求、到後臺創建 Post,到重定向到 Post 詳情,這整個過程,我們無需在前端使用任何 JavaScript 相關的內容,僅僅通過 HTML 與 HTTP 就完成了這個交互,所以 Remix 的網站在 Disbaled JavaScript 運行環境下也可以正常工作。

通過上圖我們可以看到,即使 JavaScript 已經關閉了,我們的網站依然可以正常運行。

🤯 強大的嵌套路由體系

基於文件即路由的理念,我們無需集中的維護一套路由定義,當我們創建了對應的文件之後,Remix 就爲我們註冊了對應的路由。

而 Remix 最具特色的功能之一就是嵌套路由。在 Remix 中,一個頁面通常包含多層級頁面,每個子頁面控制自身的 UI 展現,而且獨立控制自身的數據加載和代碼分割。

拿官網的例子來看如下:

上述頁面的對應關係如下:

整個路由分層,對應到整個頁面的分層視圖,而每個分層下的代碼都是獨立編寫,視圖渲染獨立渲染,數據獨立獲取,錯誤獨立展示。

來看一個實際例子:

// src/root.tsx
import {
  Outlet,
  
export default function App() {
  return (
    <Document>
      <Layout>
        <Outlet />
      </Layout>
    </Document>
  );
}

function Document() {}
function Layout() {}
// src/routes/admin.tsx
import { Outlet, Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
import adminStyles from "~/styles/admin.css";

export let links = () ={
  return [{ rel: "stylesheet", href: adminStyles }];
};

export let loader = () ={
  return getPosts();
};

export default function Admin() {
  let posts = useLoaderData<Post[]>();
  return (
    <div class>
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map((post) =(
            <li key={post.slug}>
              <Link to={post.slug}>{post.title}</Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
// src/routes/admin/index.tsx
import { Link } from "remix";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new">Create a New Post</Link>
    </p>
  );
}
// src/routes/admin/new.tsx
import { useTransition, useActionData, redirect, Form } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";
import invariant from "tiny-invariant";

export let action: ActionFunction = async ({ request }) ={
  await new Promise((res) => setTimeout(res, 1000));
  let formData = await request.formData();

  let title = formData.get("title");
  let slug = formData.get("slug");
  let markdown = formData.get("markdown");

  let 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");
};

export default function NewPost() {
  let errors = useActionData();
  let transition = useTransition();

  return (
    <Form method="post">
      <p>
        <label>
          Post Title: {errors?.title && <em>Title is required</em>}
          <input type="text"  />
        </label>
      </p>
      <p>
        <label>
          Post Slug: {errors?.slug && <em>Slug is required</em>}{" "}
          <input type="text"  />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>{" "}
        {errors?.markdown && <em>Markdown is required</em>}
        <br />
        <textarea rows={20}  />
      </p>
      <p>
        <button type="submit">
          {transition.submission ? "Create..." : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

上述代碼渲染的頁面如下:

整個 App 網站是由 <Document> 嵌套 <Layout> 組成,其中 <Outlet> 是路由的填充處,即上圖中綠色的部分。當我們訪問 localhost:3000/ 時,其中填充的內容爲 src/routes/index.tsx 路由文件對應的渲染內容,而當我們訪問 localhost:3000/admin 時,對應的是 src/routes/admin.tsx 路由文件對應的渲染內容。

而我們在 的 src/routes/admin.tsx 繼續提供了 <Outlet> 路由顯然組件,意味着當我們繼續添加分級(嵌套)路由時,如訪問 http://localhost:3000/admin/new 那麼這個 <Outlet> 會渲染 src/routes/admin/new.tsx 對應路由文件的渲染內容,而訪問 http://localhost:3000/admin 時,<Outlet> 部分會渲染 src/routes/admin/index.tsx 對應路由文件的渲染內容,見下圖:

而這種嵌套路由是自動發生的,當你創建了一個 src/routes/admin.tsx 之後,又創建了一個同名的文件夾,並在文件夾下建立了其它文件,那麼這些文件的文件名會被註冊爲下一級的嵌套路由名:

通過這種文件即路由,同名文件夾下文件即嵌套路由的方式,然後通過在父頁面裏面通過 <Outlet> 的方式渲染根據子路由渲染子頁面內容,極大的增加了靈活性,且每個子路由對應獨立的路由文件,具有獨立的數據處理邏輯、內容渲染邏輯、錯誤處理邏輯。

上述嵌套路由一個顯而易見的優點就是,某個部分如果報錯了,結合後續會提到的 ErrorBoundaryCatchBoundary 這個部分可以顯示錯誤的頁面,而用戶仍然可以操作其他部分,而不需要刷新整個頁面以重新加載使用,極大提高網站容錯性。

👋🏻 再見,加載狀態

通過嵌套路由,Remix 可以幹掉幾乎所有的加載狀態、骨架屏,現在很多應用都是在前端組件裏進行數據獲取,獲取前置數據之後,然後用前置數據去獲取後置的數據,形成了一個瀑布式的獲取形式,當數據量大的時候,頁面加載就需要很長時間,所以絕大部分網站都會放一個加載的狀態,如小菊花轉圈圈,或者體驗更好一點的骨架屏,如下:

這是因爲這些應用缺乏類似 Remix 這樣的嵌套路由的概念,訪問某個路由時,就是訪問這個路由對應的頁面,只有這個頁面加載出來之後,裏面的子組件渲染時,再進行數據的獲取,再加載子組件,如此往復,就呈現瀑布流式的加載,帶來了很多中間的加載狀態。

而 Remix 提供了嵌套路由,當訪問路由 localhost:3000/admin/new 時,會加載三級路由,同時這三個路由對應的頁面獨立、並行加載,獨立、並行獲取數據,最後發送給客戶端的是一個完整的 HTML 文檔,如下過程:

可見雖然我們首屏拿到內容可能會慢一點,但是再也不需要加載狀態,再見,菊花圖 👋🏻,再見,骨架屏👋🏻。

同時藉助嵌套路由,當我們鼠標 Hover 到某個鏈接準備點擊切換某個子路由時,Remix 提供了預獲取(Prefetch)功能,可以提前並行獲取子路由文檔和各種資源,包括 CSS、圖片、相關數據等,這樣當我們實際點擊這個鏈接切換子路由時,頁面可以立即呈現出來:

😇 完善的錯誤處理

我們的網站經常會遇到問題,使用其他框架編寫時,網站遇到問題可能用戶就需要重新刷新網站,而對於 Remix 來說,基於嵌套路由的理念,則無需重新刷新,只需要在對應的錯誤的子路由展示錯誤信息,而頁面的其他部分仍然可以正常工作:

比如我們上圖的右下角子路由出現了問題,那麼這塊會展示出問題時的錯誤頁面,而其他頁面部分仍然展示正常的信息。

正因爲錯誤經常發生,且處理錯誤異常困難,包含客戶端、服務端的各種錯誤,包含預期的、非預期的錯誤等,所以 Remix 內建了完善的錯誤處理機制,提供了類似 React 的 ErrorBoundary 的理念。

在 Remix 中,每個路由函數對應一個 ErrorBoundary 函數:

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 函數代表處理那些來自 loader 和 action,客戶端或服務端的非預期的錯誤,當出現這些非預期的錯誤時,就會激活這個函數,顯示對應函數的表示錯誤信息的 UI。

同時每個路由函數對應着一個 CatchBoundary 函數:

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 函數對應着預期的錯誤,即你在 loader、action 函數中,在客戶端或服務端,手動拋出的 Response 錯誤,這些錯誤的路徑是可預期的,在 CatchBoundary 中,通過 useCatch 鉤子獲取這些拋出的 Response 錯誤,然後展示對於的錯誤信息的 UI。

當我們沒有在子路由中添加 ErrorBoundary 或 CatchBoundary 函數時,一旦遇到錯誤,這些錯誤就會向更上一級的路由冒泡,直至最頂層的路由頁面,所以你只最好在最頂層的路由文件裏聲明一個 ErrorBoundary 和 CatchBoundary 函數,用於捕獲所有可能的錯誤,然後在代碼審查( Code Review)時及時排查出來。

🌟 基於 Web 基礎技術

Remix 專注於用 Web 基礎技術,HTML/CSS + HTTP 等解決問題,同時提供了在 Web 全棧開發框架中所需要的所有狀態和所有基礎組件。

其中相關狀態包含:

// 加載數據的狀態
useLoaderData()

// 更新數據的狀態
useActionData()

// 提交表單等相關狀態
useFormAction()
useSubmit()

// 統一的加載狀態
useTransition()

// 錯誤抓取狀態等
useCatch()

以及 Web 網站組成的基礎組件:

同時在路由函數所在文件裏,可以通過聲明 linkmetalinksheaders 等函數來聲明對應的功能:

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)"
    }
  ];
};
export function links() {
  return [{ page: "/posts/public" }];
}
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 >
    "og:image""https://josiesshakeshack.com/logo.jpg" // <meta property="og:image" content="https://josiesshakeshack.com/logo.jpg">
  };
};
export function headers({ loaderHeaders, parentHeaders }) {
  return {
    "X-Stretchy-Pants""its for fun",
    "Cache-Control""max-age=300, s-maxage=3600"
  };
}

由此可見,Remix 提供了整個全棧 Web 開發生命週期所需要的幾乎的一切內容,且內置最佳實踐,確保你付出很少的努力就能開發出性能卓越、體驗優秀的網站!

當然這篇文章並不能包含所有 Remix 的特性,看到這裏仍然對 Remix 感興趣的同學可以訪問官網 (https://remix.run/) 詳細瞭解哦~ 官網提供了非常詳細的實戰教程幫助你使用 Remix 開發實際的應用。

瞭解了 Remix 的特性之後,你對 Remix 有什麼看法呢?你覺得它能超過 Next.js 🐴?感興趣的同學可以加入程序員巴士交流羣一起探討哦~

我是皮湯,一個酷愛編程,樂於分享的小夥子,下期見~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_LHjkkupb-ZMLEI5RhANvg