在这个快速入门中,我们将简短的介绍api单词和一些示例代码,这就是你想在15分钟内学习的Remix全部的内容。
环境准备
- node.js 14 或 更高
- npm 7 或 更高
- 一个代码编辑器
创建项目
初始化一个新的Remix项目
npx create-remix@latest
# 选择Remix App Server
npm install
npm run dev
# 打开 http://localhost:3000
你的第一个路由
添加一个/posts路由
// app/routes/index.tsx
import { Link } from "remix";
// 在你想要的地方 将以下代码写入tsx内
<Link to="/posts">去Posts</Link>
// 例如:
export default function Index() {
return (
<Link to="/posts">去Posts</Link>
)
}
返回浏览器,单击链接。你应该会看到一个404页面,因为我们还未创建该路由。现在让我们来创建posts路由的代码文件
创建一个新文件 文件路径为 app/routes/posts/index.tsx
或者在命令行内输入以下命令mkdir app/routes/posts
touch app/routes/posts/index.tsx
给posts路由编写UI
// app/routes/posts/index.tsx
export default function Posts() {
return (
<div>
<h1>Posts</h1>
</div>
);
}
刷新浏览器后再查看新的路由效果
加载数据
Remix中内置了数据加载
如果在过去的几年中你从事过web开发的话,你可能习惯在这里创建两个东西:一个提供数据的API路由和一个使用它的前端组件。但在Remix中,你的前端组件也是它自己的API路由,并且它已经知道如何通过浏览器在服务器上与自己通信,也就是说你不需要去获取它。
如果你的工作经验更久远,并且是使用像Rails这样落后一点的 MVC Web 框架,那么你可以把Remix路由看作是使用React进行模板制作的后台界面,但是它们知道如何在浏览器中无缝的添加一些特性,而不是编写前后端分离的jQuery代码来修饰用户交互,这就是渐进式增强的充分体现,此外你的路由就是它们自己的控制器。
写一个 posts 路由 loader
// app/routes/posts/index.tsx
import { useLoaderData } from "remix";
export const loader = async () => {
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', posts);
return (
<div>
<h1>Posts</h1>
</div>
);
}
loader是组件的后端API,它已经通过useLoaderData为你连接起来。在Remix路由中,客户端和服务器之间的界限有点模糊,如果你的服务器和浏览器的控制台都打开了,你会注意到它们都记录了我们的post数据,这是因为Remix在服务器上渲染,可以像传统的web框架一样发送一个完整的html,同样的它在客户端也记录这些。
在posts内渲染数据
添加Post类型和useloaderData范型,这样即使在网络请求中,也能获取可靠的类型安全性,因为它都是在同一个文件中定义的,除非在Remix的网络请求瘫痪了,否则这个组件和它的API都具有类型安全(请记住,该组件已经是它自己的API路由)
import { useLoaderData } from "remix";
export type Post = {
slug: string;
title: string;
};
export const loader = async () => {
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[]>();
console.log("posts", posts);
return (
<div>
<h1>Posts</h1>
</div>
);
}
重构:将异步获取数据逻辑抽离为getPosts
创建文件
touch app/post.ts
// app/post.ts
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;
}
// app/routes/posts/index.tsx
import { useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
export const loader = async () => {
return getPosts();
};
export default function Posts() {
const posts = useLoaderData<Post[]>();
console.log("posts", posts);
return (
<div>
<h1>Posts</h1>
</div>
);
}
从markdown文件中获取数据
在本章节,我们将只使用文件系统,从中读取数据。
# 在项目的根目录创建一个 posts 文件夹
mkdir posts
# 添加一些posts文件
touch posts/my-first-post.md
touch posts/my-second.md
写一些你想要的东西进去,但要确保它们的包含标题 my-first-post.md
---
title: My First Post
---
# This is my first post
Isn't it great?
my-second.md
---
title: My Second Post
---
# 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)
# 解析md文件
npm add front-matter
# 校验数据真假
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;
};
// post md文件属性
export type PostMarkdownAttributes = {
title: string;
};
/**
* post文件夹路径
* path.join(__dirname, "..", "posts") 这段官方代码文件路径有误
*/
// const postsPath = path.join(__dirname, "..", "posts");
const postsPath = path.resolve("posts");
/**
* 校验md文件是否有title属性
*/
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));
// 格式化md文件 读取文件元属性
const { attributes } = parseFrontMatter(file.toString());
// 如果md文件不包含校验的属性 就会抛出错误提示
invariant(
isValidPostAttributes(attributes),
`${filename} 有错误的元数据`
);
return {
slug: filename.replace(/\.md$/, ""),
title: attributes?.title,
};
})
);
}
动态路由参数
本章节我们将实现以下两个路由
/posts/my-first-post
/posts/my-second-post
我们可以在url中使用动态变量,而不是为每一个文章创建一个新的路由,Remix会解析并传给我们,让我们可以动态的查询路由
touch app/routes/posts/\$slug.tsx
// app/routes/posts/\$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import invariant from "tiny-invariant";
import { getPost } from "~/post";
export type Post = {
slug: string;
title: string;
};
export const loader: LoaderFunction = async ({ params }) => {
invariant(params.slug, "expected params.slug");
return getPost(params.slug);
};
export default function PostSlug() {
const post = useLoaderData<Post>();
console.log("post", post);
return (
<div>
<h1>{post.title}</h1>
</div>
);
}
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import invariant from "tiny-invariant";
import { getPost } from "~/post";
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>
);
}
// 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;
};
// post md文件属性
export type PostMarkdownAttributes = {
title: string;
};
/**
* post文件夹路径
* path.join(__dirname, "..", "posts") 这段官方代码文件路径有误
*/
// const postsPath = path.join(__dirname, "..", "posts");
const postsPath = path.resolve("posts");
/**
* 校验md文件是否有title属性
*/
function isValidPostAttributes(
attributes: any
): attributes is PostMarkdownAttributes {
return attributes?.title;
}
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 };
}
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));
// 格式化md文件 读取文件元属性
const { attributes } = parseFrontMatter(file.toString());
// 如果md文件不包含校验的属性 就会抛出错误提示
invariant(
isValidPostAttributes(attributes),
`${filename} 有错误的元数据`
);
return {
slug: filename.replace(/\.md$/, ""),
title: attributes?.title,
};
})
);
}
// app/routes/posts/index.tsx
import { useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
export const loader = async () => {
return getPosts();
};
export default function Posts() {
const posts = useLoaderData<Post[]>();
console.log("posts", posts);
return (
<div>
<h1>Posts</h1>
</div>
);
}
个人博客效果
效果:
mkdir app/styles
touch app/styles/admin.css
touch app/routes/admin.tsx
.admin {
display: flex;
}
.admin > nav {
padding-right: 2rem;
}
.admin > main {
flex: 1;
border-left: solid 1px #ccc;
padding-left: 2rem;
}
em {
color: red;
}
// 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 const links = () => {
return [{ rel: "stylesheet", href: adminStyles }];
};
export const loader = async () => {
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>
);
}
访问:http://localhost:3000/admin 查看效果
嵌套路由
在上一章 在routes/admin.tsx 匹配了 /admin 路由地址
在本章节将基于上一章的代码来演示如何编写 /admin的子路由 /admin/news
# 首先在routes下创建admin文件夹,并在目录下创建index.tsx文件
# 之前的 /routes/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.tsx
import { Outlet, Link, useLoaderData } from "remix";
// ...省略代码别复制
export default function Admin() {
// ......省略代码别复制
return (
// ... 省略部分tsx代码
<main>
// 将Outlet组件放在你想让你的子路由代码展现的位置、
// 这里会将 app/routes/admin/**.tsx 映射为对应的 /admin/** 路由地址
// 对应的子路由文件内的tsx将会在Outlet处渲染
<Outlet />
</main>
)
}
在线写博客
本章节将会演示如何在/admin/new中使用表单创建新的markdown文件
// /app/post.ts
// 在之前代码后面添加以下代码
type NewPost = {
title: string;
slug: string;
markdown: string;
};
export async function createPost(post: NewPost) {
const 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 { useActionData, Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import invariant from "tiny-invariant";
import { createPost } from "~/post";
type PostError = {
title?: boolean;
slug?: boolean;
markdown?: boolean;
};
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");
const errors: PostError = {};
if (!title) errors.title = true;
if (!slug) errors.slug = true;
if (!markdown) errors.markdown = true;
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");
};
export default function NewPost() {
const errors = useActionData();
return (
<Form method="post">
<p>
<label>
Post Title: {errors?.title ? <em>Title is required</em> : null}{" "}
<input type="text" name="title" />
</label>
</p>
<p>
<label>
Post Slug: {errors?.slug ? <em>Slug is required</em> : null}{" "}
<input type="text" name="slug" />
</label>
</p>
<p>
<label htmlFor="markdown">Markdown:</label>{" "}
{errors?.markdown ? <em>Markdown is required</em> : null}
<br />
<textarea id="markdown" rows={20} name="markdown" />
</p>
<p>
<button type="submit">Create Post</button>
</p>
</Form>
);
}
源码
my-remix.zip
这份代码不是根据教程的命令行生成的,而是使用vercel创建的模板
但本章节读取文件的代码在vercel是不生效的,在vercel的server less function 是不支持文件系统api访问的,但不要担心,源码可在本地环境访问,最后请勿用在生产环境。