Next.js 強(qiáng)勁對(duì)手來(lái)了! Remix 正式宣布開(kāi)源

以下文章來(lái)源于程序員巴士 ,作者一只圖雀

周五翻 Github 趨勢(shì)榜看到了 Remix 這個(gè)內(nèi)容,覺(jué)得挺有發(fā)展前景的,初步了解了一下具體的特性,分享給大家。

近期,由 React Router 原班團(tuán)隊(duì)打造,基于 TypeScript 與 React,內(nèi)建 React Router V6 特性的全棧 Web 框架 Remix 正式開(kāi)源。目前占據(jù) Github 趨勢(shì)總榜前 3,Github 標(biāo)星 5K+ Star:

Remix 開(kāi)源之后可以說(shuō)是在 React 全??蚣茴I(lǐng)域激起千層浪,絕對(duì)可以算是 Next.js 的強(qiáng)勁對(duì)手。Remix 的特性如下:

  • 追求速度,然后是用戶體驗(yàn)(UX),支持任何 SSR/SSG 等

  • 基于 Web 基礎(chǔ)技術(shù),如 HTML/CSS 與 HTTP 以及 Web Fecth API,在絕大部分情況可以不依賴于 JavaScript 運(yùn)行,所以可以運(yùn)行在任何環(huán)境下,如 Web Browser、Cloudflare Workers、Serverless 或者 Node.js 等

  • 客戶端與服務(wù)端一致的開(kāi)發(fā)體驗(yàn),客戶端代碼與服務(wù)端代碼寫(xiě)在一個(gè)文件里,無(wú)縫進(jìn)行數(shù)據(jù)交互,同時(shí)基于 TypeScript,類型定義可以跨客戶端與服務(wù)端共用

  • 內(nèi)建文件即路由、動(dòng)態(tài)路由、嵌套路由、資源路由等

  • 干掉 Loading、骨架屏等任何加載狀態(tài),頁(yè)面中所有資源都可以預(yù)加載(Prefetch),頁(yè)面幾乎可以立即加載

  • 告別以往瀑布式(Waterfall)的數(shù)據(jù)獲取方式,數(shù)據(jù)獲取在服務(wù)端并行(Parallel)獲取,生成完整 HTML 文檔,類似 React 的并發(fā)特性

  • 提供開(kāi)發(fā)網(wǎng)頁(yè)需要所有狀態(tài),開(kāi)箱即用;提供所有需要使用的組件,包括 <Links> 、<Link><Meta> 、<Form><Script/> ,用于處理元信息、腳本、CSS、路由和表單相關(guān)的內(nèi)容

  • 內(nèi)建錯(cuò)誤處理,針對(duì)非預(yù)期錯(cuò)誤處理的 <ErrorBoundary> 和開(kāi)發(fā)者拋出錯(cuò)誤處理的 <CatchBoundary>

特性這么多?不明覺(jué)厲!接下來(lái)我們就嘗試一一來(lái)展示這些 Remix 的特性??。

?? 一致的開(kāi)發(fā)體驗(yàn)

Remix 提供基于文件的路由,將讀取數(shù)據(jù)、操作數(shù)據(jù)和渲染數(shù)據(jù)的邏輯都寫(xiě)在同一個(gè)路由文件里,方便一致性處理,這樣可以跨客戶端和服務(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>
  );
}

上述是一個(gè)路由文件,如果它是 src/routes/posts/index.tsx 文件,那么我們開(kāi)啟服務(wù)器,通過(guò) localhost:3000/posts 就可以訪問(wèn)到這個(gè)文件,這就是文件即路由,而默認(rèn)導(dǎo)出的 Projects 函數(shù),即為一個(gè) React 函數(shù)式組件,此函數(shù)的返回模板則為訪問(wèn)這個(gè)路由的 HTML 文檔。

  • 每個(gè)路由函數(shù),如 Projects 可以定義一個(gè) loader 函數(shù),類似處理 GET 請(qǐng)求的服務(wù)端函數(shù),可以獲取到路由信息,為初次服務(wù)端渲提供數(shù)據(jù),在這個(gè)函數(shù)中可以獲取文件系統(tǒng)、請(qǐng)求數(shù)據(jù)庫(kù)、進(jìn)行其他網(wǎng)絡(luò)請(qǐng)求,然后返回?cái)?shù)據(jù),在我們的 Projects 組件里,可以通過(guò) Remix 提供的 useLoaderData 鉤子拿到 loader 函數(shù)獲取到的數(shù)據(jù)。

  • 每個(gè)路由函數(shù)也可以定義一個(gè) action 函數(shù),用于進(jìn)行實(shí)際的操作,類似處理非 GET 請(qǐng)求,如 POST/PUT/PATCH/DELETE 的操作的函數(shù),它可以操作修改數(shù)據(jù)庫(kù)、寫(xiě)入文件系統(tǒng)等,同時(shí)其返回的結(jié)果可能是實(shí)際的數(shù)據(jù)或是重定向到某個(gè)新頁(yè)面,如 redirect("/admin")。當(dāng) action 函數(shù)返回?cái)?shù)據(jù)或錯(cuò)誤信息時(shí),我們可以通過(guò) Remix 提供的 useActionData 鉤子拿到這個(gè)返回的錯(cuò)誤信息,進(jìn)行前端的展示等。

值得注意的是,action 函數(shù)是在 <Form method="post"> 表單里,用戶點(diǎn)擊提交按鈕之后自動(dòng)調(diào)用,Remix 通過(guò) Fetch API 的形式去調(diào)用,然后在前端不斷的輪詢獲取調(diào)用結(jié)果,且自動(dòng)處理用戶多次點(diǎn)擊時(shí)的競(jìng)爭(zhēng)情況。

你的瀏覽器網(wǎng)絡(luò)面板將呈現(xiàn)如下情況,自動(dòng) Remix 發(fā)起 POST 請(qǐng)求,然后處理重定向到 /post/${post.id} ,同時(shí)加載對(duì)應(yīng)的 /posts/posts/${post.id} 對(duì)應(yīng)的路由頁(yè)面內(nèi)容。

通過(guò) Remix 提供的 useTransition 鉤子,我們可以拿到表單提交的狀態(tài),當(dāng)請(qǐng)求還未返回結(jié)果時(shí),我們可以通過(guò)這個(gè)狀態(tài) state 判斷是否要展示一個(gè)加載狀態(tài),提示用戶當(dāng)前的請(qǐng)求進(jìn)展。

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

有同學(xué)可能注意到了,上面我們整個(gè)頁(yè)面渲染、到發(fā)起創(chuàng)建 Post 請(qǐng)求、到后臺(tái)創(chuàng)建 Post,到重定向到 Post 詳情,這整個(gè)過(guò)程,我們無(wú)需在前端使用任何 JavaScript 相關(guān)的內(nèi)容,僅僅通過(guò) HTML 與 HTTP 就完成了這個(gè)交互,所以 Remix 的網(wǎng)站在 Disbaled JavaScript 運(yùn)行環(huán)境下也可以正常工作。

通過(guò)上圖我們可以看到,即使 JavaScript 已經(jīng)關(guān)閉了,我們的網(wǎng)站依然可以正常運(yùn)行。

?? 強(qiáng)大的嵌套路由體系

基于文件即路由的理念,我們無(wú)需集中的維護(hù)一套路由定義,當(dāng)我們創(chuàng)建了對(duì)應(yīng)的文件之后,Remix 就為我們注冊(cè)了對(duì)應(yīng)的路由。

而 Remix 最具特色的功能之一就是嵌套路由。在 Remix 中,一個(gè)頁(yè)面通常包含多層級(jí)頁(yè)面,每個(gè)子頁(yè)面控制自身的 UI 展現(xiàn),而且獨(dú)立控制自身的數(shù)據(jù)加載和代碼分割。

拿官網(wǎng)的例子來(lái)看如下:

上述頁(yè)面的對(duì)應(yīng)關(guān)系如下:

  • 整個(gè)頁(yè)面模塊為 / 、而對(duì)應(yīng)到 /sales 則是右邊的整塊天藍(lán)色內(nèi)容、/sales/invoices 對(duì)應(yīng)到黃色的部分、/sales/invoices/102000 則對(duì)應(yīng)到右下角的紅色部分

整個(gè)路由分層,對(duì)應(yīng)到整個(gè)頁(yè)面的分層視圖,而每個(gè)分層下的代碼都是獨(dú)立編寫(xiě),視圖渲染獨(dú)立渲染,數(shù)據(jù)獨(dú)立獲取,錯(cuò)誤獨(dú)立展示。

來(lái)看一個(gè)實(shí)際例子:

// 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 className="admin">
      <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" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug: {errors?.slug && <em>Slug is required</em>}{" "}
          <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>{" "}
        {errors?.markdown && <em>Markdown is required</em>}
        <br />
        <textarea rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">
          {transition.submission ? "Create..." : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

上述代碼渲染的頁(yè)面如下:

整個(gè) App 網(wǎng)站是由 <Document> 嵌套 <Layout> 組成,其中 <Outlet> 是路由的填充處,即上圖中綠色的部分。當(dāng)我們?cè)L問(wèn) localhost:3000/ 時(shí),其中填充的內(nèi)容為 src/routes/index.tsx 路由文件對(duì)應(yīng)的渲染內(nèi)容,而當(dāng)我們?cè)L問(wèn) localhost:3000/admin 時(shí),對(duì)應(yīng)的是 src/routes/admin.tsx 路由文件對(duì)應(yīng)的渲染內(nèi)容。

而我們?cè)?的 src/routes/admin.tsx 繼續(xù)提供了 <Outlet> 路由顯然組件,意味著當(dāng)我們繼續(xù)添加分級(jí)(嵌套)路由時(shí),如訪問(wèn) http://localhost:3000/admin/new 那么這個(gè) <Outlet> 會(huì)渲染 src/routes/admin/new.tsx 對(duì)應(yīng)路由文件的渲染內(nèi)容,而訪問(wèn) http://localhost:3000/admin 時(shí),<Outlet> 部分會(huì)渲染 src/routes/admin/index.tsx 對(duì)應(yīng)路由文件的渲染內(nèi)容,見(jiàn)下圖:

而這種嵌套路由是自動(dòng)發(fā)生的,當(dāng)你創(chuàng)建了一個(gè) src/routes/admin.tsx 之后,又創(chuàng)建了一個(gè)同名的文件夾,并在文件夾下建立了其它文件,那么這些文件的文件名會(huì)被注冊(cè)為下一級(jí)的嵌套路由名:

  • localhost:3000/admin 同時(shí)注冊(cè) src/routes/admin.tsxsrc/routes/admin/index.tsx

  • localhost:3000/admin/new 注冊(cè) src/routes/admin/new.tsx

通過(guò)這種文件即路由,同名文件夾下文件即嵌套路由的方式,然后通過(guò)在父頁(yè)面里面通過(guò) <Outlet> 的方式渲染根據(jù)子路由渲染子頁(yè)面內(nèi)容,極大的增加了靈活性,且每個(gè)子路由對(duì)應(yīng)獨(dú)立的路由文件,具有獨(dú)立的數(shù)據(jù)處理邏輯、內(nèi)容渲染邏輯、錯(cuò)誤處理邏輯。

上述嵌套路由一個(gè)顯而易見(jiàn)的優(yōu)點(diǎn)就是,某個(gè)部分如果報(bào)錯(cuò)了,結(jié)合后續(xù)會(huì)提到的 ErrorBoundaryCatchBoundary 這個(gè)部分可以顯示錯(cuò)誤的頁(yè)面,而用戶仍然可以操作其他部分,而不需要刷新整個(gè)頁(yè)面以重新加載使用,極大提高網(wǎng)站容錯(cuò)性。

???? 再見(jiàn),加載狀態(tài)

通過(guò)嵌套路由,Remix 可以干掉幾乎所有的加載狀態(tài)、骨架屏,現(xiàn)在很多應(yīng)用都是在前端組件里進(jìn)行數(shù)據(jù)獲取,獲取前置數(shù)據(jù)之后,然后用前置數(shù)據(jù)去獲取后置的數(shù)據(jù),形成了一個(gè)瀑布式的獲取形式,當(dāng)數(shù)據(jù)量大的時(shí)候,頁(yè)面加載就需要很長(zhǎng)時(shí)間,所以絕大部分網(wǎng)站都會(huì)放一個(gè)加載的狀態(tài),如小菊花轉(zhuǎn)圈圈,或者體驗(yàn)更好一點(diǎn)的骨架屏,如下:

這是因?yàn)檫@些應(yīng)用缺乏類似 Remix 這樣的嵌套路由的概念,訪問(wèn)某個(gè)路由時(shí),就是訪問(wèn)這個(gè)路由對(duì)應(yīng)的頁(yè)面,只有這個(gè)頁(yè)面加載出來(lái)之后,里面的子組件渲染時(shí),再進(jìn)行數(shù)據(jù)的獲取,再加載子組件,如此往復(fù),就呈現(xiàn)瀑布流式的加載,帶來(lái)了很多中間的加載狀態(tài)。

而 Remix 提供了嵌套路由,當(dāng)訪問(wèn)路由 localhost:3000/admin/new 時(shí),會(huì)加載三級(jí)路由,同時(shí)這三個(gè)路由對(duì)應(yīng)的頁(yè)面獨(dú)立、并行加載,獨(dú)立、并行獲取數(shù)據(jù),最后發(fā)送給客戶端的是一個(gè)完整的 HTML 文檔,如下過(guò)程:

可見(jiàn)雖然我們首屏拿到內(nèi)容可能會(huì)慢一點(diǎn),但是再也不需要加載狀態(tài),再見(jiàn),菊花圖 ????,再見(jiàn),骨架屏????。

同時(shí)借助嵌套路由,當(dāng)我們鼠標(biāo) Hover 到某個(gè)鏈接準(zhǔn)備點(diǎn)擊切換某個(gè)子路由時(shí),Remix 提供了預(yù)獲?。≒refetch)功能,可以提前并行獲取子路由文檔和各種資源,包括 CSS、圖片、相關(guān)數(shù)據(jù)等,這樣當(dāng)我們實(shí)際點(diǎn)擊這個(gè)鏈接切換子路由時(shí),頁(yè)面可以立即呈現(xiàn)出來(lái):

?? 完善的錯(cuò)誤處理

我們的網(wǎng)站經(jīng)常會(huì)遇到問(wèn)題,使用其他框架編寫(xiě)時(shí),網(wǎng)站遇到問(wèn)題可能用戶就需要重新刷新網(wǎng)站,而對(duì)于 Remix 來(lái)說(shuō),基于嵌套路由的理念,則無(wú)需重新刷新,只需要在對(duì)應(yīng)的錯(cuò)誤的子路由展示錯(cuò)誤信息,而頁(yè)面的其他部分仍然可以正常工作:

比如我們上圖的右下角子路由出現(xiàn)了問(wèn)題,那么這塊會(huì)展示出問(wèn)題時(shí)的錯(cuò)誤頁(yè)面,而其他頁(yè)面部分仍然展示正常的信息。

正因?yàn)殄e(cuò)誤經(jīng)常發(fā)生,且處理錯(cuò)誤異常困難,包含客戶端、服務(wù)端的各種錯(cuò)誤,包含預(yù)期的、非預(yù)期的錯(cuò)誤等,所以 Remix 內(nèi)建了完善的錯(cuò)誤處理機(jī)制,提供了類似 React 的 ErrorBoundary 的理念。

在 Remix 中,每個(gè)路由函數(shù)對(duì)應(yīng)一個(gè) 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ù)代表處理那些來(lái)自 loader 和 action,客戶端或服務(wù)端的非預(yù)期的錯(cuò)誤,當(dāng)出現(xiàn)這些非預(yù)期的錯(cuò)誤時(shí),就會(huì)激活這個(gè)函數(shù),顯示對(duì)應(yīng)函數(shù)的表示錯(cuò)誤信息的 UI。

同時(shí)每個(gè)路由函數(shù)對(duì)應(yīng)著一個(gè) 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ù)對(duì)應(yīng)著預(yù)期的錯(cuò)誤,即你在 loader、action 函數(shù)中,在客戶端或服務(wù)端,手動(dòng)拋出的 Response 錯(cuò)誤,這些錯(cuò)誤的路徑是可預(yù)期的,在 CatchBoundary 中,通過(guò) useCatch 鉤子獲取這些拋出的 Response 錯(cuò)誤,然后展示對(duì)于的錯(cuò)誤信息的 UI。

當(dāng)我們沒(méi)有在子路由中添加 ErrorBoundary 或 CatchBoundary 函數(shù)時(shí),一旦遇到錯(cuò)誤,這些錯(cuò)誤就會(huì)向更上一級(jí)的路由冒泡,直至最頂層的路由頁(yè)面,所以你只最好在最頂層的路由文件里聲明一個(gè) ErrorBoundary 和 CatchBoundary 函數(shù),用于捕獲所有可能的錯(cuò)誤,然后在代碼審查( Code Review)時(shí)及時(shí)排查出來(lái)。

?? 基于 Web 基礎(chǔ)技術(shù)

Remix 專注于用 Web 基礎(chǔ)技術(shù),HTML/CSS + HTTP 等解決問(wèn)題,同時(shí)提供了在 Web 全棧開(kāi)發(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()

// 錯(cuò)誤抓取狀態(tài)等
useCatch()

以及 Web 網(wǎng)站組成的基礎(chǔ)組件:

  • <Meta> 用于動(dòng)態(tài)的設(shè)置網(wǎng)頁(yè)的元信息,方便 SEO

  • <Script> 用于告知 Remix 是否需要在加載網(wǎng)頁(yè)時(shí)導(dǎo)入相關(guān) JS,因?yàn)榇蟛糠智闆r下 Remix 編寫(xiě)的頁(yè)面無(wú)需 JS 也能正常工作

  • <Form> 用于替代原生的 <form> 方便在客戶端和服務(wù)端進(jìn)行表單操作,接管提交時(shí)的相應(yīng)功能,使用 Fetch API 發(fā)起請(qǐng)求等,以及處理多次重復(fù)提交的競(jìng)爭(zhēng)狀態(tài)等

同時(shí)在路由函數(shù)所在文件里,可以通過(guò)聲明 link 、metalinks 、headers 等函數(shù)來(lái)聲明對(duì)應(yīng)的功能:

  • links 變量函數(shù):表示此頁(yè)面需要加載的資源,如 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 的頁(yè)面,當(dāng)用戶點(diǎn)擊之前就加載好資源
export function links() {
  return [{ page: "/posts/public" }];
}
  • meta 函數(shù):與 <Meta> 組件類似,聲明頁(yè)面需要的元信息
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ù):定義此頁(yè)面發(fā)送 HTTP 請(qǐng)求時(shí),帶上的請(qǐng)求頭信息
export function headers({ loaderHeaders, parentHeaders }) {
  return {
    "X-Stretchy-Pants": "its for fun",
    "Cache-Control": "max-age=300, s-maxage=3600"
  };
}

由此可見(jiàn),Remix 提供了整個(gè)全棧 Web 開(kāi)發(fā)生命周期所需要的幾乎的一切內(nèi)容,且內(nèi)置最佳實(shí)踐,確保你付出很少的努力就能開(kāi)發(fā)出性能卓越、體驗(yàn)優(yōu)秀的網(wǎng)站!

當(dāng)然這篇文章并不能包含所有 Remix 的特性,看到這里仍然對(duì) Remix 感興趣的同學(xué)可以訪問(wèn)官網(wǎng)(https://remix.run/)詳細(xì)了解哦~ 官網(wǎng)提供了非常詳細(xì)的實(shí)戰(zhàn)教程幫助你使用 Remix 開(kāi)發(fā)實(shí)際的應(yīng)用。

了解了 Remix 的特性之后,你對(duì) Remix 有什么看法呢?你覺(jué)得它能超過(guò) Next.js ???

開(kāi)源前哨 日常分享熱門(mén)、有趣和實(shí)用的開(kāi)源項(xiàng)目。參與維護(hù) 10萬(wàn)+ Star 的開(kāi)源技術(shù)資源庫(kù),包括:Python、Java、C/C++、Go、JS、CSS、Node.js、PHP、.NET 等。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容