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会报错。我们添加一些类型解决下报错问题。
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 模块中导出。
// 创建 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
文件内容如下:
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 的类型校验:
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 进行解析。
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 支持的情况下就拥有很好的体验了。