Next.js 强劲对手来了! Remix 正式宣布开源

时间:2021-09-21 18:54:48

Next.js 强劲对手来了! Remix 正式宣布开源

大家好,我是皮汤。周五翻 Github 趋势榜看到了 Remix 这个内容,觉得挺有发展前景的,初步了解了一下具体的特性,分享给大家。

Next.js 强劲对手来了! Remix 正式宣布开源

近期,由 React Router 原班团队打造,基于 TypeScript 与 React,内建 React Router V6 特性的全栈 Web 框架 Remix 正式开源。目前占据 Github 趋势总榜前 3,Github 标星 5K+ Star:

Next.js 强劲对手来了! Remix 正式宣布开源

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 提供基于文件的路由,将读取数据、操作数据和渲染数据的逻辑都写在同一个路由文件里,方便一致性处理,这样可以跨客户端和服务端逻辑共享同一套类型定义。

看一段官网的代码:

  1. import type { Post } from "~/post"
  2. import { Outlet, Link, useLoaderData, useTransition } from "remix"
  3.  
  4. let postsPath = path.join(__dirname, "..""posts"); 
  5.  
  6. async function getPosts() { 
  7.   let dir = await fs.readdir(postsPath); 
  8.   return Promise.all
  9.     dir.map(async (filename) => { 
  10.       let file = await fs.readFile(path.join(postsPath, filename)); 
  11.       let { attributes } = parseFrontMatter(file.toString()); 
  12.       invariant( 
  13.         isValidPostAttributes(attributes), 
  14.         `${filename} has bad meta data!` 
  15.       ); 
  16.       return { 
  17.         slug: filename.replace(/.md$/, ""), 
  18.         title: attributes.title, 
  19.       }; 
  20.     }) 
  21.   ); 
  22.  
  23. async function createPost(post: Post) { 
  24.   let md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`; 
  25.   await fs.writeFile(path.join(postsPath, post.slug + ".md"), md); 
  26.   return getPost(post.slug); 
  27.  
  28. export async function loader({ request }) { 
  29.   return getProjects(); 
  30.  
  31. export async function action({ request }) { 
  32.   let form = await request.formData(); 
  33.   const post = createPost({ title: form.get("title") }); 
  34.   return redirect(`/posts/${post.id}`); 
  35.  
  36. export default function Projects() { 
  37.   let posts = useLoaderData<Post[]>(); 
  38.   let { state } = useTransition(); 
  39.   let busy = state === "submitting"
  40.  
  41.   return ( 
  42.     <div> 
  43.       {posts.map((post) => ( 
  44.         <Link to={post.slug}>{post.title}</Link> 
  45.       ))} 
  46.  
  47.       <Form method="post"
  48.         <input name="title" /> 
  49.         <button type="submit" disabled={busy}> 
  50.           {busy ? "Creating..." : "Create New Post"
  51.         </button> 
  52.       </Form> 
  53.        
  54.       <Outlet /> 
  55.     </div> 
  56.   ); 

上述是一个路由文件,如果它是 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} 对应的路由页面内容。

Next.js 强劲对手来了! Remix 正式宣布开源

通过 Remix 提供的 useTransition 钩子,我们可以拿到表单提交的状态,当请求还未返回结果时,我们可以通过这个状态 state 判断是否要展示一个加载状态,提示用户当前的请求进展。

Next.js 强劲对手来了! Remix 正式宣布开源

同时 Post 类型在 useLoaderData

有同学可能注意到了,上面我们整个页面渲染、到发起创建 Post 请求、到后台创建 Post,到重定向到 Post 详情,这整个过程,我们无需在前端使用任何 JavaScript 相关的内容,仅仅通过 HTML 与 HTTP 就完成了这个交互,所以 Remix 的网站在 Disbaled JavaScript 运行环境下也可以正常工作。

Next.js 强劲对手来了! Remix 正式宣布开源

通过上图我们可以看到,即使 JavaScript 已经关闭了,我们的网站依然可以正常运行。

 强大的嵌套路由体系

基于文件即路由的理念,我们无需集中的维护一套路由定义,当我们创建了对应的文件之后,Remix 就为我们注册了对应的路由。

而 Remix 最具特色的功能之一就是嵌套路由。在 Remix 中,一个页面通常包含多层级页面,每个子页面控制自身的 UI 展现,而且独立控制自身的数据加载和代码分割。

拿官网的例子来看如下:

Next.js 强劲对手来了! Remix 正式宣布开源

Next.js 强劲对手来了! Remix 正式宣布开源

上述页面的对应关系如下:

  • 整个页面模块为 / 、而对应到 /sales 则是右边的整块天蓝色内容、/sales/invoices 对应到黄色的部分、/sales/invoices/102000 则对应到右下角的红色部分

整个路由分层,对应到整个页面的分层视图,而每个分层下的代码都是独立编写,视图渲染独立渲染,数据独立获取,错误独立展示。

来看一个实际例子:

  1. // src/root.tsx 
  2. import { 
  3.   Outlet, 
  4.    
  5. export default function App() { 
  6.   return ( 
  7.     <Document> 
  8.       <Layout> 
  9.         <Outlet /> 
  10.       </Layout> 
  11.     </Document> 
  12.   ); 
  13.  
  14. function Document() {} 
  15. function Layout() {} 
  1. // src/routes/admin.tsx 
  2. import { Outlet, Link, useLoaderData } from "remix"
  3. import { getPosts } from "~/post"
  4. import type { Post } from "~/post"
  5. import adminStyles from "~/styles/admin.css"
  6.  
  7. export let links = () => { 
  8.   return [{ rel: "stylesheet", href: adminStyles }]; 
  9. }; 
  10.  
  11. export let loader = () => { 
  12.   return getPosts(); 
  13. }; 
  14.  
  15. export default function Admin() { 
  16.   let posts = useLoaderData<Post[]>(); 
  17.   return ( 
  18.     <div className="admin"
  19.       <nav> 
  20.         <h1>Admin</h1> 
  21.         <ul> 
  22.           {posts.map((post) => ( 
  23.             <li key={post.slug}> 
  24.               <Link to={post.slug}>{post.title}</Link> 
  25.             </li> 
  26.           ))} 
  27.         </ul> 
  28.       </nav> 
  29.       <main> 
  30.         <Outlet /> 
  31.       </main> 
  32.     </div> 
  33.   ); 
  1. // src/routes/admin/index.tsx 
  2. import { Link } from "remix"
  3.  
  4. export default function AdminIndex() { 
  5.   return ( 
  6.     <p> 
  7.       <Link to="new">Create a New Post</Link> 
  8.     </p> 
  9.   ); 
  1. // src/routes/admin/new.tsx 
  2. import { useTransition, useActionData, redirect, Form } from "remix"
  3. import type { ActionFunction } from "remix"
  4. import { createPost } from "~/post"
  5. import invariant from "tiny-invariant"
  6.  
  7. export let action: ActionFunction = async ({ request }) => { 
  8.   await new Promise((res) => setTimeout(res, 1000)); 
  9.   let formData = await request.formData(); 
  10.  
  11.   let title = formData.get("title"); 
  12.   let slug = formData.get("slug"); 
  13.   let markdown = formData.get("markdown"); 
  14.  
  15.   let errors = {}; 
  16.   if (!title) errors.title = true
  17.   if (!slug) errors.slug = true
  18.   if (!markdown) errors.markdown = true
  19.  
  20.   if (Object.keys(errors).length) { 
  21.     return errors; 
  22.   } 
  23.  
  24.   await createPost({ title, slug, markdown }); 
  25.  
  26.   return redirect("/admin"); 
  27. }; 
  28.  
  29. export default function NewPost() { 
  30.   let errors = useActionData(); 
  31.   let transition = useTransition(); 
  32.  
  33.   return ( 
  34.     <Form method="post"
  35.       <p> 
  36.         <label> 
  37.           Post Title: {errors?.title && <em>Title is required</em>} 
  38.           <input type="text" name="title" /> 
  39.         </label> 
  40.       </p> 
  41.       <p> 
  42.         <label> 
  43.           Post Slug: {errors?.slug && <em>Slug is required</em>}{" "
  44.           <input type="text" name="slug" /> 
  45.         </label> 
  46.       </p> 
  47.       <p> 
  48.         <label htmlFor="markdown">Markdown:</label>{" "
  49.         {errors?.markdown && <em>Markdown is required</em>} 
  50.         <br /> 
  51.         <textarea rows={20} name="markdown" /> 
  52.       </p> 
  53.       <p> 
  54.         <button type="submit"
  55.           {transition.submission ? "Create..." : "Create Post"
  56.         </button> 
  57.       </p> 
  58.     </Form> 
  59.   ); 

上述代码渲染的页面如下:

Next.js 强劲对手来了! Remix 正式宣布开源

整个 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 对应路由文件的渲染内容,见下图:

Next.js 强劲对手来了! Remix 正式宣布开源

而这种嵌套路由是自动发生的,当你创建了一个 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

通过这种文件即路由,同名文件夹下文件即嵌套路由的方式,然后通过在父页面里面通过 的方式渲染根据子路由渲染子页面内容,极大的增加了灵活性,且每个子路由对应独立的路由文件,具有独立的数据处理逻辑、内容渲染逻辑、错误处理逻辑。

上述嵌套路由一个显而易见的优点就是,某个部分如果报错了,结合后续会提到的 ErrorBoundary 和 CatchBoundary 这个部分可以显示错误的页面,而用户仍然可以操作其他部分,而不需要刷新整个页面以重新加载使用,极大提高网站容错性。

 再见,加载状态

通过嵌套路由,Remix 可以干掉几乎所有的加载状态、骨架屏,现在很多应用都是在前端组件里进行数据获取,获取前置数据之后,然后用前置数据去获取后置的数据,形成了一个瀑布式的获取形式,当数据量大的时候,页面加载就需要很长时间,所以绝大部分网站都会放一个加载的状态,如小菊花转圈圈,或者体验更好一点的骨架屏,如下:

Next.js 强劲对手来了! Remix 正式宣布开源

这是因为这些应用缺乏类似 Remix 这样的嵌套路由的概念,访问某个路由时,就是访问这个路由对应的页面,只有这个页面加载出来之后,里面的子组件渲染时,再进行数据的获取,再加载子组件,如此往复,就呈现瀑布流式的加载,带来了很多中间的加载状态。

而 Remix 提供了嵌套路由,当访问路由 localhost:3000/admin/new 时,会加载三级路由,同时这三个路由对应的页面独立、并行加载,独立、并行获取数据,最后发送给客户端的是一个完整的 HTML 文档,如下过程:

Next.js 强劲对手来了! Remix 正式宣布开源

可见虽然我们首屏拿到内容可能会慢一点,但是再也不需要加载状态,再见,菊花图,再见,骨架屏。

Next.js 强劲对手来了! Remix 正式宣布开源

同时借助嵌套路由,当我们鼠标 Hover 到某个链接准备点击切换某个子路由时,Remix 提供了预获取(Prefetch)功能,可以提前并行获取子路由文档和各种资源,包括 CSS、图片、相关数据等,这样当我们实际点击这个链接切换子路由时,页面可以立即呈现出来:

Next.js 强劲对手来了! Remix 正式宣布开源

 完善的错误处理

我们的网站经常会遇到问题,使用其他框架编写时,网站遇到问题可能用户就需要重新刷新网站,而对于 Remix 来说,基于嵌套路由的理念,则无需重新刷新,只需要在对应的错误的子路由展示错误信息,而页面的其他部分仍然可以正常工作:

Next.js 强劲对手来了! Remix 正式宣布开源

比如我们上图的右下角子路由出现了问题,那么这块会展示出问题时的错误页面,而其他页面部分仍然展示正常的信息。

正因为错误经常发生,且处理错误异常困难,包含客户端、服务端的各种错误,包含预期的、非预期的错误等,所以 Remix 内建了完善的错误处理机制,提供了类似 React 的 ErrorBoundary 的理念。

在 Remix 中,每个路由函数对应一个 ErrorBoundary 函数:

  1. export default function RouteFunction() {} 
  2.  
  3. export function ErrorBoundary({ error }) { 
  4.   console.error(error); 
  5.   return ( 
  6.     <div> 
  7.       <h2>Oh snap!</h2> 
  8.       <p> 
  9.         There was a problem loading this invoice 
  10.       </p> 
  11.     </div> 
  12.   ); 

ErrorBoundary 函数代表处理那些来自 loader 和 action,客户端或服务端的非预期的错误,当出现这些非预期的错误时,就会激活这个函数,显示对应函数的表示错误信息的 UI。

同时每个路由函数对应着一个 CatchBoundary 函数:

  1. import { useCatch } from "remix"
  2.  
  3. export function CatchBoundary() { 
  4.   let caught = useCatch(); 
  5.  
  6.   return ( 
  7.     <div> 
  8.       <h1>Caught</h1> 
  9.       <p>Status: {caught.status}</p> 
  10.       <pre> 
  11.         <code>{JSON.stringify(caught.data, null, 2)}</code> 
  12.       </pre> 
  13.     </div> 
  14.   ); 

CatchBoundary 函数对应着预期的错误,即你在 loader、action 函数中,在客户端或服务端,手动抛出的 Response 错误,这些错误的路径是可预期的,在 CatchBoundary 中,通过 useCatch 钩子获取这些抛出的 Response 错误,然后展示对于的错误信息的 UI。

当我们没有在子路由中添加 ErrorBoundary 或 CatchBoundary 函数时,一旦遇到错误,这些错误就会向更上一级的路由冒泡,直至最顶层的路由页面,所以你只最好在最顶层的路由文件里声明一个 ErrorBoundary 和 CatchBoundary 函数,用于捕获所有可能的错误,然后在代码审查( Code Review)时及时排查出来。

Next.js 强劲对手来了! Remix 正式宣布开源

 基于 Web 基础技术

Remix 专注于用 Web 基础技术,HTML/CSS + HTTP 等解决问题,同时提供了在 Web 全栈开发框架中所需要的所有状态和所有基础组件。

其中相关状态包含:

  1. // 加载数据的状态 
  2. useLoaderData() 
  3.  
  4. // 更新数据的状态 
  5. useActionData() 
  6.  
  7. // 提交表单等相关状态 
  8. useFormAction() 
  9. useSubmit() 
  10.  
  11. // 统一的加载状态 
  12. useTransition() 
  13.  
  14. // 错误抓取状态等 
  15. useCatch() 

以及 Web 网站组成的基础组件:

  • <Meta> 用于动态的设置网页的元信息,方便 SEO
  • <Script> 用于告知 Remix 是否需要在加载网页时导入相关 JS,因为大部分情况下 Remix 编写的页面无需 JS 也能正常工作
  • <Form> 用于替代原生的 <form> 方便在客户端和服务端进行表单操作,接管提交时的相应功能,使用 Fetch API 发起请求等,以及处理多次重复提交的竞争状态等

同时在路由函数所在文件里,可以通过声明 link 、meta 、links 、headers 等函数来声明对应的功能:

  • links 变量函数:表示此页面需要加载的资源,如 CSS、图片等
  1. import type { LinksFunction } from "remix"
  2. import stylesHref from "../styles/something.css"
  3.  
  4. export let links: LinksFunction = () => { 
  5.   return [ 
  6.     // add a favicon 
  7.     { 
  8.       rel: "icon"
  9.       href: "/favicon.png"
  10.       type: "image/png" 
  11.     }, 
  12.  
  13.     // add an external stylesheet 
  14.     { 
  15.       rel: "stylesheet"
  16.       href: "https://example.com/some/styles.css"
  17.       crossOrigin: "true" 
  18.     }, 
  19.  
  20.     // add a local stylesheet, remix will fingerprint the file name for 
  21.     // production caching 
  22.     { rel: "stylesheet", href: stylesHref }, 
  23.  
  24.     // prefetch an image into the browser cache that the user is likely to see 
  25.     // as they interact with this page, perhaps they click a button to reveal in 
  26.     // a summary/details element 
  27.     { 
  28.       rel: "prefetch"
  29.       as"image"
  30.       href: "/img/bunny.jpg" 
  31.     }, 
  32.  
  33.     // only prefetch it if they're on a bigger screen 
  34.     { 
  35.       rel: "prefetch"
  36.       as"image"
  37.       href: "/img/bunny.jpg"
  38.       media: "(min-width: 1000px)" 
  39.     } 
  40.   ]; 
  41. }; 
  • links 函数:声明需要 Prefetch 的页面,当用户点击之前就加载好资源
  1. export function links() { 
  2.   return [{ page: "/posts/public" }]; 
  • meta 函数:与 组件类似,声明页面需要的元信息
  1. import type { MetaFunction } from "remix"
  2.  
  3. export let meta: MetaFunction = () => { 
  4.   return { 
  5.     title: "Josie's Shake Shack", // <title>Josie's Shake Shack</title> 
  6.     description: "Delicious shakes", // <meta name="description" content="Delicious shakes"
  7.     "og:image""https://josiesshakeshack.com/logo.jpg" // <meta property="og:image" content="https://josiesshakeshack.com/logo.jpg"
  8.   }; 
  9. }; 
  • headers 函数:定义此页面发送 HTTP 请求时,带上的请求头信息
  1. export function headers({ loaderHeaders, parentHeaders }) { 
  2.   return { 
  3.     "X-Stretchy-Pants""its for fun"
  4.     "Cache-Control""max-age=300, s-maxage=3600" 
  5.   }; 

由此可见,Remix 提供了整个全栈 Web 开发生命周期所需要的几乎的一切内容,且内置最佳实践,确保你付出很少的努力就能开发出性能卓越、体验优秀的网站!

当然这篇文章并不能包含所有 Remix 的特性,看到这里仍然对 Remix 感兴趣的同学可以访问官网(https://remix.run/)详细了解哦~ 官网提供了非常详细的实战教程帮助你使用 Remix 开发实际的应用。

了解了 Remix 的特性之后,你对 Remix 有什么看法呢?你觉得它能超过 Next.js?

【编辑推荐】https://mp.weixin.qq.com/s/_LHjkkupb-ZMLEI5RhANvg