Remix教程(一)-Developer Blog(译)

1,654 阅读13分钟

最近又看到了一个前端项目Remix.run, 用起来感觉十分的舒服, 不过在使用的时候想查找一些资料却发现其在国内的资料寥寥无几, 也不成体系. 故考虑根据官方的教程进行初步的译制. 如发现任何问题, 欢迎沟通 原文连接:https://remix.run/docs/en/v1/tutorials/blog 相关其它Remix内容会继续跟进

通过我的博客, 来查看此文章

Quickstart

在这里, 我们将用简短的文件和代码进行介绍, 如果你想用15分钟了解Remix的全部内容, 这里是个不错的地方

{% note info simple %} 💿 Hey I'm Derrick the Remix Compact Disc 👋 每当你应该做一些事情的时候, 你就会看到我 {% endnote %}

在这里我们使用了TypeScript, 但我们总是在完成了一些代码之后才确认方法.这不是我们的正常工作流程.为了不搞乱你们当中没有使用TypeScript那些人的代码.在开始的时候就确认了我们需要使用的代码(measure twice, cut once!)

创建项目

💿 初始化一个新的Remix项目1

npx create-remix@latest
# 选择 Remix App Server
cd [你为项目命名的文件夹]
npm run dev

{% note danger simple %} 选择Remix App Server这一步十分重要 {% endnote %}

我们将使用文件系统做一些工作, 因为并不是所有的设置都与本教程中的代码兼容.

打开https://localhost:3000,应用应该已经运行了.如果你愿意的话,可以花一点时间浏览入门模板, 那里有很多信息.

如果你的应用在https://localhost:3000中运行不正常, 可以参考项目中的README.md文档来查看你是否需要进行额外的配置.

你的第一个路由

我们将为"/posts"创建一个新的路由, 不过在开始之前, 我们需要先链接到它.

文件中发生了一些事情,找到Layout组件, 然后在指向"Home"的链接之后添加一个指向"/posts"的新链接

💿 在app/root.tsx文件中增加指向posts的链接

<li>
  <Link to="/posts">Posts</Link>
</li>

回到浏览器中你会看到顶部有一个新的连接,点击它,你会看到404页面,现在让我们为它创建一个新的路由:

💿 在以下路径中创建新的文件app/routes/posts/index.tsx

mkdir app/routes/posts
touch app/routes/posts/index.tsx

{% note info simple%} 每当你看到用于创建文件夹或者文件的终端指令时,你可以随意的进行操作,使用mkdirtouch只是为了让我们明确你应该建立哪些文件夹 {% endnote %}

我们将它命名为posts.tsx,但是我们很快就将有另一个路由,将他们放在一起会比较好.索引路由将在呈现在文件的路径上(就像web服务器中的index.html)

你可能会发现屏幕内容变为空, 你有了一个什么都没有的路由(至少它还拥有null), 让我们为其添加一个默认组件.

💿 制作posts组件 app/routes/posts/index.tsx:

export default function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}

你可能需要刷新一下才能看到我们新的,基本的路由信息.

加载数据

数据的加载内置于Remix.

如果你主要在过去的几年中从事web开发的话,你可能习惯于在这做两件事:提供数据的API路由和使用数据的前端组件.在Remix中,你的前端组件也是它自己的API路由,并且它知道如何在浏览器中与服务器进行通话,也就是说,你不必再获取它的的数据.

如果你的背景比使用Rails,PHP这些更远.那么你可以将你的Remix路由视为将React模块化的后端视图,但是他拥有在浏览器中无缝添加一些元素的天赋.它是渐进增加的最合适的表现.

所以让我们开始为我们的组件提供一些数据吧.

💿 为posts路由添加"loader"

app/routes/posts/index.tsx

import { useLoaderData } from "remix";

export let loader = () => {
  return [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
};

export default function Posts() {
  let posts = useLoaderData();
  console.log(posts);
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}

Loaders是其组件的后端"API",它通过useLoaderData为你链接起来. 在Remix的路由中,将客户端和服务器的界限模糊化的事情听起来有点疯狂.但是如果你将服务端和浏览器的consoles都打开,你会发现他们都记录我们提供的数据.那是因为我们在Remix的服务器上仍像传统Web框架一样发送完成的HTML文档,但它也在客户端中进行了融合2并进行了记录.

{% note info simple %} 我们使用了let是因为它只有三个字母, 如果你愿意的话你也可以使用const🙂 {% endnote %}

💿 为我们的posts更新链接 app/routes/posts/index.tsx

import { Link, useLoaderData } from "remix";

// ...
export default function Posts() {
  let posts = useLoaderData();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

TypeScript出错了,让我们来解决它:

💿 为你的请求添加 useLoaderData app/routes/posts/index.tsx

import { Link, useLoaderData } from "remix";

type Post = {
  slug: string;
  title: string;
};

export let loader = () => {
  let posts: Post[] = [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
  return posts;
};

export default function Posts() {
  let posts = useLoaderData<Post[]>();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

嘿,这很酷.即使进行了网络请求我们也能拥有相当可靠的类型安全性,因为他们都是定义在同一个文件中.除非在Remix获取数据时网络崩溃,否则你在这个组件中都是具有类型安全性,并且它是API(请记住, 该组件已经是它自己的API路由)

一点点的重构

一个可靠的做法时创建一个处理特定问题的模块.在我们的例子包含了阅读和编辑文章. 现在让我们更新它并未我们的模块添加一个getPosts

💿 创建 app/post.ts

touch app/post.ts

我们主要是从我们的路由中复制/粘贴以下内容: app/post.ts

export type Post = {
  slug: string;
  title: string;
};

export function getPosts() {
  let posts: Post[] = [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
  return posts;
}

💿 更新一下我们的路由以便他能使用新的模块 app/routes/posts/index.tsx

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

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

// ...

从数据源获取数据

如果我们真的想要构建它,我们希望我们的数据存在一个数据库中,比如Postgres,FaunaDB,Supabase等等.不过这仅仅是一个快速入门, 我们将只使用文件系统.

我们不会对数据进行硬编码,而是从文件系统中读取它们.

💿 在根目录下创建一个"posts/"文件夹, 不再app路径下, 而是在它的旁边.

mkdir posts

现在创建更多的post

touch posts/my-first-post.md
touch posts/90s-mixtape.md

在它们里面放任何你想要的东西, 但是确保它们带有title属性

posts/my-first-post.md

---
title: My First Post
---

# This is my first post

Isn't it great?

posts/90s-mixtape.md

---
title: 90s Mixtape
---

# 90s Mixtape

- I wish (Skee-Lo)
- This Is How We Do It (Montell Jordan)
- Everlong (Foo Fighters)
- Ms. Jackson (Outkast)
- Interstate Love Song (Stone Temple Pilots)
- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
- Just a Friend (Biz Markie)
- The Man Who Sold The World (Nirvana)
- Semi-Charmed Life (Third Eye Blind)
- ...Baby One More Time (Britney Spears)
- Better Man (Pearl Jam)
- It's All Coming Back to Me Now (Céline Dion)
- This Kiss (Faith Hill)
- Fly Away (Lenny Kravits)
- Scar Tissue (Red Hot Chili Peppers)
- Santa Monica (Everclear)
- C'mon N' Ride it (Quad City DJ's)

💿 更新getPosts以便其能从文件中读取

为此, 我们需要添加新的module:

npm add front-matter

app/post.ts

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";

export type Post = {
  slug: string;
  title: string;
};

// relative to the server output not the source!
let postsPath = path.join(__dirname, "..", "posts");

export 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()
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}

这不是一个Node文件系统的教程,所以你只需要相信我们的代码.如前所述,你可以从某个地方的数据库中获取markdown内容(我们将在后面的教程中向您展示).

{% note danger simple %} 如果你没有使用Remix App Server,你需要在路径中添加额外的"..",另外注意,你不能将环境部署和演示在一个没有持续化文件系统的. {% endnote %}

TypeScript对那些代码感到生气,让我们让它变得开心一些.

由于我们正在读取一个文件, 但是类型系统不知道里面有什么,所以我们需要一个运行时的检查,为此我们需要一个invariant 的方法来使运行时检查变得简单.

💿 确保我们的文章具有正确的元数据并获得类型安全

npm add tiny-invariant

app/post.ts

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";

export type Post = {
  slug: string;
  title: string;
};

export type PostMarkdownAttributes = {
  title: string;
};

let postsPath = path.join(__dirname, "..", "posts");

function isValidPostAttributes(
  attributes: any
): attributes is PostMarkdownAttributes {
  return attributes?.title;
}

export 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
      };
    })
  );
}

即使您不使用TypeScript,您也会希望对invariant 检查,以便你知道出了什么问题. 好的!回到用户界面, 我们应该看一下我们的帖子列表.随意添加更多帖子,刷新并观察列表的增长.

动态路由参数

现在让我们制作一个路由来查看文章, 我们希望这些URL有效:

/posts/my-first-post
/posts/90s-mix-cdr

我们可以在ur中使用"动态参数", 而不是为每个文章都创建一个路由, Remix将解析参数并传递给我们, 以便我们可以动态查找文章.

💿 创建一个动态路由"app/routes/posts/$slug.tsx"

touch app/routes/posts/\$slug.tsx

app/routes/posts/$slug.tsx

export default function PostSlug() {
  return (
    <div>
      <h1>Some Post</h1>
    </div>
  );
}

你可以点击其中的一个文章, 你会看到新的页面

💿 为加载器添加一个访问参数 app/routes/posts/$slug.tsx

import { useLoaderData } from "remix";

export let loader = async ({ params }) => {
  return params.slug;
};

export default function PostSlug() {
  let slug = useLoaderData();
  return (
    <div>
      <h1>Some Post: {slug}</h1>
    </div>
  );
}

文件路径中$开始的内容将成为加载器的参数, 这就是我们查找文章的方式.

💿 让我们通过TypeScript获取一些关于loader函数的帮助

app/routes/posts/$slug.tsx

import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";

export let loader: LoaderFunction = async ({ params }) => {
  return params.slug;
};

现在让我们实际从文件系统获取文章信息

💿 为我们的post模块添加一个getPost函数

将将此函数放在app/post.ts模块中的任何位置:

// ...
export async function getPost(slug: string) {
  let filepath = path.join(postsPath, slug + ".md");
  let file = await fs.readFile(filepath);
  let { attributes } = parseFrontMatter(file.toString());
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  return { slug, title: attributes.title };
}

💿 在路由中使用新的getPost app/routes/posts/$slug.tsx

import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import { getPost } from "~/post";
import invariant from "tiny-invariant";

export let loader: LoaderFunction = async ({ params }) => {
  invariant(params.slug, "expected params.slug");
  return getPost(params.slug);
};

export default function PostSlug() {
  let post = useLoaderData();
  return (
    <div>
      <h1>{post.title}</h1>
    </div>
  );
}

检查一下!我们将通过数据源来获取我们的文章,而不是将其作为JavaScript全部包含在浏览器中.

简要说明一下invariant, 因为params来自于URL,我们不能完全确认params.slug会被定义,也许你会将文件更改为$postId.ts!用invariant验证这些是一个很好的方法,它也会使TypeScript变的高兴的.

有许多markdown的解析器,我们将在本教程中使用"marked", 因为它非常容易上手.

💿 将markdown解析为HTML

app/post.ts

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";
import { marked } from "marked";

//...
export async function getPost(slug: string) {
  let filepath = path.join(postsPath, slug + ".md");
  let file = await fs.readFile(filepath);
  let { attributes, body } = parseFrontMatter(
    file.toString()
  );
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  let html = marked(body);
  return { slug, html, title: attributes.title };
}

💿 在路由渲染HTML

app/routes/posts/$slug.tsx

// ...
export default function PostSlug() {
  let post = useLoaderData();
  return (
    <div dangerouslySetInnerHTML={{ __html: post.html }} />
  );
}

Holy smokes,你做到了,你有了一个博客.

创建博客文章

现在我们的博客文章(包含类型修正)与部署有关.真麻烦,这里的想法是你的文章将由数据库支持, 因此我们需要一种新的创建博客文章的方法.我们下面将来操作这些.

让我们为应用程序创建一个新的"admin"部分

💿 创建一个admin路由

touch app/routes/admin.tsx

app/routes/admin.tsx

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

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>...</main>
    </div>
  );
}

你应该从post路由中识别出了许多代码.为了设置了一些额外的HTML,我们需要设置一些样式.

💿 创建admin样式表

touch app/styles/admin.css

app/styles/admin.css

.admin {
  display: flex;
}

.admin > nav {
  padding-right: 2rem;
}

.admin > main {
  flex: 1;
  border-left: solid 1px #ccc;
  padding-left: 2rem;
}

em {
  color: red;
}

💿 链接admin路由中的样式表 app/routes/admin.tsx

import { 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 }];
};

// ...

每一个路由到可以导出一个links返回值为<link>数组的方法, 以对象形式而不是HTML.我们用{ rel: "stylesheet", href: adminStyles}代替<link rel="stylesheet" href="..." />.这允许Remix将所有渲染的路由链接合并在一起,并在文档顶部的<Links/>元素中渲染它们.如果你好奇的话,你可以在root.tsx看到这些.

好的,现在你应该有一个外观不错的页面,其中左侧是文章,右侧是占位符.

索引路由

让我们用admin的索引填充该占位符.和我们一起,我们在这里引入"嵌套路由", 你的路由文件嵌套会变成UI的组件嵌套.

💿 为admin.tsx的子路由创建一个有索引的文件夹

mkdir app/routes/admin
touch app/routes/admin/index.tsx

app/routes/admin/index.tsx

import { Link } from "remix";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new">Create a New Post</Link>
    </p>
  );
}

如果你进行了刷新, 你还不会看到它,app/routes/admin/中的每个路由都可以在它们的URL匹配的时候呈现在app/routes/admin.tsx中.你可以控制子路由呈现在admin.tsx布局的哪个部分.

💿 在admin页面中添加一个outlet

import { Outlet, Link, useLoaderData } from "remix";

`app/routes/admin.tsx`
//...
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>
  );
}

稍等片刻,索引路由一开始可能会令人困惑.只要知道URL匹配父路由的路径时,索引就将呈现在outlet中.

也许这会有所帮助,让我们添加"/admin/new"路由,看看当我们单击链接时会发生什么.

💿 创建app/routes/admin/new.tsx路由

touch app/routes/admin/new.tsx

app/routes/admin/new.tsx

export default function NewPost() {
  return <h2>New Post</h2>;
}

现在点击索引路由中的链接, 并观察<Outlet/>将自动替换为"新"路由.

行动

现在我们要认真了. 让我们构建一个form以便我们可以通过"new"路由构建一个新的文章

💿 为new路由添加一个form app/routes/admin/new.tsx

import { Form } from "remix";

export default function NewPost() {
  return (
    <Form method="post">
      <p>
        <label>
          Post Title: <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug: <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown</label>
        <br />
        <textarea rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}

如果你像我们一样喜欢HTML,你应该会非常兴奋.如果您已经做了很多<form onSubmit><button onClick>, 那么您即将被HTML震撼.

对于这样的功能,你需要的只是一个是从用户那里获取数据的表单和处理它的后端操作的功能. 在Remix中, 这就是你需要做的.

让我们首先在post.ts模块中创建如何保存文章的基本代码.

💿 把createPost加到app/post.ts的任何位置中 app/post.ts

// ...
export async function createPost(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);
}

💿 从新的post路由中调用createPost app/routes/admin/new.tsx

// ...
type NewPost = {
  title: string;
  slug: string;
  markdown: string;
};

export async function createPost(post: NewPost) {
  let md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}

//...

就是这样, Remix(和浏览器)将完成剩下的工作. 点击提交按钮并查看新的文件将更新在我们文章页的侧边栏中.

在HTML中输入的name属性将通过网络发送, 并通过formData请求中相同的名称进行获取.

TypeScript又不开心了, 让我们添加一些类型.

💿 将相关修改添加在两个文件中

app/posts.ts

// ...
type NewPost = {
  title: string;
  slug: string;
  markdown: string;
};

export async function createPost(post: NewPost) {
  let md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}

//...

app/routes/admin/new.tsx

import { Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";

export let action: ActionFunction = async ({ request }) => {
  let formData = await request.formData();

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

  await createPost({ title, slug, markdown });

  return redirect("/admin");
};

无论您是否使用TypeScript,当用户没有在对其中一些字段进行设置时,我们都会遇到问题(并且TS仍然对调用createPost感到愤怒)

让我们在创建文章之前添加一些验证.

💿 验证表单数据是否包含我们需要的数据,如果没有则返回错误

app/routes/admin/new.tsx

//...
export let action: ActionFunction = async ({ request }) => {
  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");
};

注意我们这次没有返回重定向,我们实际上返回了错误.这些错误可通过useActionData提供给组件.就像useLoaderData一样,但数据来自表单POST后的操作.

💿 向UI添加验证消息 app/routes/admin/new.tsx

import { useActionData, Form, redirect } from "remix";

// ...

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

  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">Create Post</button>
      </p>
    </Form>
  );
}

TypeScript仍然不开心, 所以让我们添加一些invariants来让它开心. app/routes/admin/new.tsx

//...
import invariant from "tiny-invariant";

export let action: ActionFunction = async ({ request }) => {
  // ...

  if (Object.keys(errors).length) {
    return errors;
  }

  invariant(typeof title === "string");
  invariant(typeof slug === "string");
  invariant(typeof markdown === "string");
  await createPost({ title, slug, markdown });

  return redirect("/admin");
};

为了找点乐子, 在你的开发工具中禁用JavaScript后再尝试一下.由于Remix建立在Http和HTML的基础之上, 因此整个过程无需再浏览器中使用JavaScript.这不是重点, 让我们放慢下速度并在我们的form中添加一些"代办的UI"

app/routes/admin/new.tsx

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

💿 使用useTransition添加一些待办的UI app/routes/admin/new.tsx

import {
  useTransition,
  useActionData,
  Form,
  redirect
} from "remix";

// ...

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

  return (
    <Form method="post">
      {/* ... */}

      <p>
        <button type="submit">
          {transition.submission
            ? "Creating..."
            : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

与我们在浏览器中完全没有JavaScript的情况下完成此操作相比,现在的用户获得了更好的体验.您可以做的其他一些事情比如将标题自动插入到slug字段中,或者让用户覆盖它(也许我们稍后会添加)

今天就到这里!你的作业是为你的文章制作一个/admin/edit页面.这些链接已经在侧边栏中,但它们是404!创建一个读取文章的新路由,将它们放入字段中. 你需要的所有代码已经在 app/routes/posts/$slug.tsapp/routes/posts/new.ts 中.你只需要把它放在一起.

我们希望你会喜欢Remix.

Footnotes

  1. 如果你这一步出错了, 可以参考一下Super Simple Start to Remix 译者注

  2. 原文此处使用了hydrated, 直译应该为水合的 译者注