Remix 快速体验

2021-12-11 23:20:25 浏览数 (1)

Remix 体验

该文章是基于Remix 官网快速开始进行体验并翻译的。所以内容跟官网上是一样的。

  • 创建项目
  • 你的第一个路由
  • 加载数据(Loading Data)
  • 一点小小的重构
  • 从数据源拉取数据
  • 动态路由参数
  • 创建博客文章
  • 根路由
  • 提交表单

创建项目

初始化一个新的 Remix 项目

代码语言:javascript复制
npx create-remix@latest
# 选择 Remix App Server
cd [你自己命名的项目目录]
npm run dev
复制代码

注意此处选择 Remix App Server

运行npx create-remix@latest之后,选择Remix App Server,开发语言选择 TypeScript,之后选择运行npm install。然后就可以等待下载依赖包。依赖包下载完成之后,浏览器打开 http://localhost:3000,就能看到如下的界面:

你的第一个路由

我们将新增一个路由 /posts。在这之前,我们需要先创建一个 Link,用于跳转到这个路由。

Home,紧挨着它新建一个链接到/posts的链接">首先,打开 app/root.tsx,找到<Link to="/">Home</Link>,紧挨着它新建一个链接到/posts的链接

添加一个跳转到文章的 link 链接

代码语言:javascript复制
<li>
  <Link to="/posts">Posts</Link>
</li>
复制代码

此时,如果我们在页面中点击这个链接的时候,我们将会看到一个404的页面。因为我们还没有添加路由。那接下来就让我们添加这个路由:

创建一个新的文件: app/routes/posts/index.tsx

路由文件都是放置在 routes 下的。一个文件就代表一个路由。我们也可以直接创建一个 posts.jsx 的文件,不过如果以后还会有文章详情之类的路由我们可能会创建类似post-detail.tsx 的路由,或者有路由嵌套的时候,不太好管理。所以我们可以在 posts 文件夹下创建一个 index.tsx 作为当前文件夹下的路由入口,就像 index.html 那样作为入口文件。

此时访问该链接的时候,我们会看到如下的页面。因为此时我们还没有添加任何的组件。

创建博客文章页组件

代码语言:javascript复制
export default function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
复制代码

添加完以上的代码之后,我们再点击 Posts 链接的时候,在页面中就能看到 Posts 已经渲染出来了。

加载数据

数据加载是内置的 Remix 中的。

传统的 web 项目中,我们获取数据的 api 和用于渲染数据的前端组件是分开的。在 Remix 中,前端组件就是我们的 API 路由。当然如果我们获取数据的 api 接口是通过其他服务来提供的, 那也可以把 Remix 中的路由层作为前端的数据渲染控制器。接下来我们就为我们的组件设置一些数据。

为 posts 路由添加 useLoaderData

代码语言:javascript复制
import { useLoaderData } from "remix";

export const 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() {
  const posts = useLoaderData();
  console.log(posts);
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
复制代码

Loaders 就是当前组件的 API,并且已经通过 useLoaderData 进行的封装。如果你同时打开浏览器控制台和后台控制台,你会发现日志里都打印了 posts 的内容。这是由于Remix是在服务端渲染完页面,然后把 html 发送到浏览器端显示的,同时也会在前端里注入并输入日志数据。

在文章列表里添加链接

代码语言:javascript复制
  <ul>
    {posts.map(post => (
      <li key={post.slug}>
        <Link to={post.slug}>{post.title}</Link>
      </li>
    ))}
  </ul>
复制代码

记得在文件头部引入Link。此时TypeScript会报错。我们添加一些类型解决下报错问题。

代码语言:javascript复制
import { Link, useLoaderData } from "remix";

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

export const loader = () => {
  const 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() {
  const 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>
  );
}
复制代码

一点小小的重构

根据以往的经验来说,我们最好是创建一个模块来处理特定的场景。在我们的例子中,会涉及到读取博客以及添加博客。让我们开始创建他们。创建一个 getPosts 方法并在我们的 post 模块中导出。

代码语言:javascript复制
// 创建 post 文件: app/post.ts
复制代码
代码语言:javascript复制
export type Post = {
  slug: string;
  title: string;
};

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

修改 posts 路由。在路由中使用我们的 Post 模块

代码语言:javascript复制
// posts/index.jsx
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

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

// ...
复制代码

从数据源拉取数据

在实际的项目中,我们将会根据实际需要选择数据存储方式。会选择使用合适的数据库,比如Postgres, FaunaDB, Supabase。不过在该体验中,我们将使用文件系统。

在项目根目录下创建 posts 文件夹以及在文件夹里创建一些MarkDown 格式的博客文章

代码语言:javascript复制
mkdir posts

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

复制代码

在这些md 文件里随意放一些内容。不过确保里面有带 title 的 front matter的属性。

修改 getPosts 方法,从文件系统里读取内容

我们将会用到一个 node 模块:

代码语言:javascript复制
npm add front-matter
复制代码

修改 app/posts 文件,内容如下:

代码语言:javascript复制
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!
const postsPath = path.join(__dirname, "..", "posts");

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      return {
        slug: filename.replace(/.md$/, ""),
        title: attributes.title
      };
    })
  );
}
复制代码

此时 TypeScript 应该会报错了。让我们来解决下错误。

由于我们是通过读取文件获取到内容,所以类型检查不知道里面有什么类型的数据。所以我们需要运行时检查。我们将引入 invariant 来帮助我们更加容易的处理这个问题。

app/post.ts 文件内容如下:

代码语言:javascript复制
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;
};

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

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

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      invariant(
        isValidPostAttributes(attributes),
        `${filename} has bad meta data!`
      );
      return {
        slug: filename.replace(/.md$/, ""),
        title: attributes.title
      };
    })
  );
}
复制代码

即便我们没有使用 TS,我们也会想要通过使用 invariant 来知道具体哪个地方报错了。此时我们再去访问 http://localhost:3000/posts 的时候,我们就可以看到从文件系统里读取的文章列表。你可以自由的添加其他的文章来观察数据的变化。

动态路由参数

接下来让我们创建访问具体文章的路由。我们希望下面的路由能够生效:

代码语言:javascript复制
/posts/my-first-post
/posts/90s-mix-cdr
复制代码

我们不用为每一篇文章都创建一个路由。取而代之的是在 url 中通过动态路由标识来进行处理。 Remix 会解析并传递动态的参数到路由中。

创建一个动态的路由文件: app/routes/posts/$slug.tsx

代码语言:javascript复制
export default function PostSlug() {
  return (
    <div>
      <h1>Some Post</h1>
    </div>
  );
}
复制代码

添加一个 loader 访问参数

代码语言:javascript复制
import { useLoaderData } from "remix";

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

export default function PostSlug() {
  const slug = useLoaderData();
  return (
    <div>
      <h1>Some Post: {slug}</h1>
    </div>
  );
}
复制代码

路由上$符号后面的值会作为 loader 参数 params里的 key 值。添加一些 TypeScript 的类型校验:

代码语言:javascript复制
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";

export const loader: LoaderFunction = async ({
  params
}) => {
  return params.slug;
};
复制代码

接下来让我们从文件系统里读取文章内容。

在 post 模块里添加 getPost 方法

代码语言:javascript复制
// ...
export async function getPost(slug: string) {
  const filepath = path.join(postsPath, slug   ".md");
  const file = await fs.readFile(filepath);
  const { attributes } = parseFrontMatter(file.toString());
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  return { slug, title: attributes.title };
}
复制代码

在路由中使用新的 getPost 方法

代码语言:javascript复制
// routes/posts/$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import { getPost } from "~/post";
import invariant from "tiny-invariant";

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

export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div>
      <h1>{post.title}</h1>
    </div>
  );
}
复制代码

由于params 里的参数不一定是什么值,有可能不是 slug, 所以我们依然使用 invariant来进行错误判断。同时也能够让 TS不报错。

我们使用 marked 来对 markdown 进行解析。

代码语言:javascript复制
npm add marked
# if using typescript (如果使用typescript的话,还需要安装以下的包)
npm add @types/marked
复制代码

在路由中渲染 HTML

代码语言:javascript复制
// ...
export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div dangerouslySetInnerHTML={{ __html: post.html }} />
  );
}
复制代码

至此,我们可以撒花开香槟庆祝一下,我们拥有了自己的博客!

创建博客文章

我们以上的博客系统就开发完成可以进行部署了。但是我们最好的方式是把博客文章数据存储到数据库中,这样我们有什么修改也不用对系统进行修改上线。所以我们需要一个创建文章的入口,我们将会使用到表单提交。

创建一个 admin 路由

代码语言:javascript复制
touch app/routes/admin.tsx
复制代码
代码语言:javascript复制
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

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

export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>...</main>
    </div>
  );
}
复制代码

除了添加的一些额外的 html结构之外,你会发现admin.tsx 里的大部分内容都是从 posts 路由里拷贝过来的。我们接下来将会进行一些样式的修改。

创建一个admin.css样式文件

代码语言:javascript复制
touch app/styles/admin.css
复制代码
代码语言:javascript复制
.admin {
  display: flex;
}

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

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

em {
  color: red;
}
复制代码

在 admin 路由中关联样式文件

代码语言:javascript复制
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
import adminStyles from "~/styles/admin.css";

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

// ...
复制代码

预览admin 路由,效果如下:

每一个路由都可以导出一个返回 link 数组的 links 方法。 我们使用 { rel: "stylesheet", href: adminStyles} 来代替 <link rel="stylesheet" href="..." />。这允许 Remix 合并已经渲染的路由集合并在页面顶部的 <Links/>中渲染出来。现在我们就能够看到一个左侧有文章列表,右侧有一个展位的页面呈现出来。 你可以手动的访问 http://localhost:3000/admin 这个路由。

根路由(Index Routes)

让我们为 admin 创建一个index route。我们将会介绍嵌套路由的使用方法。

为 admin 路由的子路由创建一个文件夹,同时在里面创建一个 index.tsx

代码语言:javascript复制
mkdir app/routes/admin
touch app/routes/admin/index.tsx
复制代码
代码语言:javascript复制
import { Link } from "remix";

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

此时如果刷新浏览器,我们并不能看到刚才创建的内容。在app/routes/admin/下面的每一个路由,当他们的路由匹配的时候,都会在app/routes/admin.tsx里面渲染出来。你得控制 admin 中如何去展示这些匹配的路由。

在 admin 页面里添加 outlet

代码语言:javascript复制
// admin.tsx

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

//...
export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
复制代码

当 URL匹配父路由的路径的时候,index routes将会被渲染到 outlet 中。接下来让我们添加 /admin/new 路由,然后点击Create a New Post,看看会发生什么。

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

代码语言:javascript复制
touch app/routes/admin/new.tsx
复制代码
代码语言:javascript复制
export default function NewPost() {
  return <h2>New Post</h2>;
}
复制代码

当我们点击 <Link to="new">Create a New Post</Link>的时候,会发现,路由到了admin/new,同时内容也发生了变化,在 outlet中渲染除了admin/new的内容。

表单提交(Actions)

接下来我们将要干一件大事,在new路由中创建一个 form 表单来提交新的博客文章。

在 new 路由中添加一个 form 表单

代码语言:javascript复制
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 id="markdown" rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}
复制代码

这跟我们之前写的提交 form 表单没什么两样。让我们在 post.ts 模块里创建提交一个文章的必要代码。

在 app/post.ts 的任何位置添加 createPost 方法

代码语言:javascript复制
// ...
export async function createPost(post) {
  const md = `---ntitle: ${post.title}n---nn${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug   ".md"),
    md
  );
  return getPost(post.slug);
}
复制代码

在 new 路由的 action 中调用 createPost 方法

代码语言:javascript复制
import { redirect, Form } from "remix";
import { createPost } from "~/post";

export const action = async ({ request }) => {
  const formData = await request.formData();

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

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

  return redirect("/admin");
};

export default function NewPost() {
  // ...
}
复制代码

解决 TS 报错:

代码语言:javascript复制
// app/post.ts
type NewPost = {
  title: string;
  slug: string;
  markdown: string;
};

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

//...
复制代码
代码语言:javascript复制
import { Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";

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

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

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

  return redirect("/admin");
};
复制代码

不管我们是否使用 TS,当用户并没有输入表单字段的时候就进行提交将会有问题。在我们提交表单之前,让我们添加一些校验。

校验表单是否包含我们需要的数据,如果校验失败,则返回错误信息

代码语言:javascript复制
//...
export const action: ActionFunction = async ({
  request
}) => {
  const formData = await request.formData();

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

  const 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");
};
复制代码

注意此时我们并没有返回 redirect 信息。而是返回了错误信息。在组件中,这些信息可以通过 useActionData 进行访问。它跟 useLoaderData 很像。不过只是数据是在表单提交之后通过 action获取到的。

在 UI 上添加校验信息显示

代码语言:javascript复制
import {
  useActionData,
  Form,
  redirect,
  ActionFunction
} from "remix";

// ...

export default function NewPost() {
  const 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>
  );
}
复制代码

有意思的地方在于:当在开发者工具中禁用JavaScript,然后再试试。由于 Remix 是基于 HTTP以及 HTML来构建的,我们禁用 JavaScript 之后,程序在浏览器中依然可以很好的工作。这还不是重点,当我们减慢数据的处理,在 form表单中添加一些加载中的UI。

通过一个模拟的延迟来让我们的 action 变慢

代码语言:javascript复制
// ...
export const action: ActionFunction = async ({
  request
}) => {
  await new Promise(res => setTimeout(res, 1000));

  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");
  // ...
};
//...
复制代码

通过 useTransition 添加加载中的 UI

代码语言:javascript复制
import {
  useTransition,
  useActionData,
  Form,
  redirect
} from "remix";

// ...

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

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

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

现在用户能够在没有 JavaScript 支持的情况下就拥有很好的体验了。

0 人点赞