近期,由 React Router 原班团队打造,基于 TypeScript 与 React,内建 React Router V6 特性的全栈 Web 框架 Remix 正式开源。目前占据 Github 趋势总榜前 3,Github 标星 5K Star:
Remix 开源之后可以说是在 React 全栈框架领域激起千层浪,绝对可以算是 Next.js 的强劲对手。Remix 的特性如下:
- 追求速度,然后是用户体验(UX),支持任何 SSR/SSG 等
- 基于 Web 基础技术,如 HTML/CSS 与 HTTP 以及 Web Fecth API,在绝大部分情况可以不依赖于 JavaScript 运行,所以可以运行在任何环境下,如 Web Browser、Cloudflare Workers、Serverless 或者 Node.js 等
- 客户端与服务端一致的开发体验,客户端代码与服务端代码写在一个文件里,无缝进行数据交互,同时基于 TypeScript,类型定义可以跨客户端与服务端共用
- 内建文件即路由、动态路由、嵌套路由、资源路由等
- 干掉 Loading、骨架屏等任何加载状态,页面中所有资源都可以预加载(Prefetch),页面几乎可以立即加载
- 告别以往瀑布式(Waterfall)的数据获取方式,数据获取在服务端并行(Parallel)获取,生成完整 HTML 文档,类似 React 的并发特性
- 提供开发网页需要所有状态,开箱即用;提供所有需要使用的组件,包括
<Links>
、<Link>
、<Meta>
、<Form>
、<Script/>
,用于处理元信息、脚本、CSS、路由和表单相关的内容 - 内建错误处理,针对非预期错误处理的
<ErrorBoundary>
和开发者抛出错误处理的<CatchBoundary>
特性这么多?不明觉厉!接下来我们就尝试一一来展示这些 Remix 的特性。
一致的开发体验
Remix 提供基于文件的路由,将读取数据、操作数据和渲染数据的逻辑都写在同一个路由文件里,方便一致性处理,这样可以跨客户端和服务端逻辑共享同一套类型定义。
看一段官网的代码:
代码语言:javascript复制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---nn${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
文件,那么我们开启服务器,通过 localhost:3000/posts 就可以访问到这个文件,这就是文件即路由,而默认导出的 Projects 函数,即为一个 React 函数式组件,此函数的返回模板则为访问这个路由的 HTML 文档。
- 每个路由函数,如
Projects
可以定义一个 loader 函数,类似处理 GET 请求的服务端函数,可以获取到路由信息,为初次服务端渲提供数据,在这个函数中可以获取文件系统、请求数据库、进行其他网络请求,然后返回数据,在我们的 Projects 组件里,可以通过 Remix 提供的useLoaderData
钩子拿到 loader 函数获取到的数据。 - 每个路由函数也可以定义一个 action 函数,用于进行实际的操作,类似处理非 GET 请求,如 POST/PUT/PATCH/DELETE 的操作的函数,它可以操作修改数据库、写入文件系统等,同时其返回的结果可能是实际的数据或是重定向到某个新页面,如
redirect("/admin")
。当 action 函数返回数据或错误信息时,我们可以通过 Remix 提供的useActionData
钩子拿到这个返回的错误信息,进行前端的展示等。
值得注意的是,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 展现,而且独立控制自身的数据加载和代码分割。
拿官网的例子来看如下:
上述页面的对应关系如下:
- 整个页面模块为
/
、而对应到/sales
则是右边的整块天蓝色内容、/sales/invoices
对应到黄色的部分、/sales/invoices/102000
则对应到右下角的红色部分
整个路由分层,对应到整个页面的分层视图,而每个分层下的代码都是独立编写,视图渲染独立渲染,数据独立获取,错误独立展示。
来看一个实际例子:
代码语言:javascript复制// src/root.tsx
import {
Outlet,
export default function App() {
return (
<Document>
<Layout>
<Outlet />
</Layout>
</Document>
);
}
function Document() {}
function Layout() {}
复制代码
代码语言:javascript复制// 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>
);
}
复制代码
代码语言:javascript复制// src/routes/admin/index.tsx
import { Link } from "remix";
export default function AdminIndex() {
return (
<p>
<Link to="new">Create a New Post</Link>
</p>
);
}
复制代码
代码语言:javascript复制// 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>
);
}
复制代码
上述代码渲染的页面如下:
整个 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
之后,又创建了一个同名的文件夹,并在文件夹下建立了其它文件,那么这些文件的文件名会被注册为下一级的嵌套路由名:
- localhost:3000/admin 同时注册
src/routes/admin.tsx
和src/routes/admin/index.tsx
- localhost:3000/admin/new 注册
src/routes/admin/new.tsx
通过这种文件即路由,同名文件夹下文件即嵌套路由的方式,然后通过在父页面里面通过 <Outlet>
的方式渲染根据子路由渲染子页面内容,极大的增加了灵活性,且每个子路由对应独立的路由文件,具有独立的数据处理逻辑、内容渲染逻辑、错误处理逻辑。
上述嵌套路由一个显而易见的优点就是,某个部分如果报错了,结合后续会提到的 ErrorBoundary
和 CatchBoundary
这个部分可以显示错误的页面,而用户仍然可以操作其他部分,而不需要刷新整个页面以重新加载使用,极大提高网站容错性。
再见,加载状态
通过嵌套路由,Remix 可以干掉几乎所有的加载状态、骨架屏,现在很多应用都是在前端组件里进行数据获取,获取前置数据之后,然后用前置数据去获取后置的数据,形成了一个瀑布式的获取形式,当数据量大的时候,页面加载就需要很长时间,所以绝大部分网站都会放一个加载的状态,如小菊花转圈圈,或者体验更好一点的骨架屏,如下:
这是因为这些应用缺乏类似 Remix 这样的嵌套路由的概念,访问某个路由时,就是访问这个路由对应的页面,只有这个页面加载出来之后,里面的子组件渲染时,再进行数据的获取,再加载子组件,如此往复,就呈现瀑布流式的加载,带来了很多中间的加载状态。
而 Remix 提供了嵌套路由,当访问路由 localhost:3000/admin/new 时,会加载三级路由,同时这三个路由对应的页面独立、并行加载,独立、并行获取数据,最后发送给客户端的是一个完整的 HTML 文档,如下过程:
可见虽然我们首屏拿到内容可能会慢一点,但是再也不需要加载状态,再见,菊花图 ,再见,骨架屏。
同时借助嵌套路由,当我们鼠标 Hover 到某个链接准备点击切换某个子路由时,Remix 提供了预获取(Prefetch)功能,可以提前并行获取子路由文档和各种资源,包括 CSS、图片、相关数据等,这样当我们实际点击这个链接切换子路由时,页面可以立即呈现出来: