在这个快速入门中,我们将简短的介绍api单词和一些示例代码,这就是你想在15分钟内学习的Remix全部的内容。

环境准备

  • node.js 14 或 更高
  • npm 7 或 更高
  • 一个代码编辑器

创建项目

初始化一个新的Remix项目

  1. npx create-remix@latest
  2. # 选择Remix App Server
  3. npm install
  4. npm run dev
  5. # 打开 http://localhost:3000

你的第一个路由

添加一个/posts路由

  1. // app/routes/index.tsx
  2. import { Link } from "remix";
  3. // 在你想要的地方 将以下代码写入tsx内
  4. <Link to="/posts">去Posts</Link>
  5. // 例如:
  6. export default function Index() {
  7. return (
  8. <Link to="/posts">去Posts</Link>
  9. )
  10. }

返回浏览器,单击链接。你应该会看到一个404页面,因为我们还未创建该路由。现在让我们来创建posts路由的代码文件
创建一个新文件 文件路径为 app/routes/posts/index.tsx
或者在命令行内输入以下命令
mkdir app/routes/posts
touch app/routes/posts/index.tsx

给posts路由编写UI

  1. // app/routes/posts/index.tsx
  2. export default function Posts() {
  3. return (
  4. <div>
  5. <h1>Posts</h1>
  6. </div>
  7. );
  8. }

刷新浏览器后再查看新的路由效果

加载数据

Remix中内置了数据加载

如果在过去的几年中你从事过web开发的话,你可能习惯在这里创建两个东西:一个提供数据的API路由和一个使用它的前端组件。但在Remix中,你的前端组件也是它自己的API路由,并且它已经知道如何通过浏览器在服务器上与自己通信,也就是说你不需要去获取它。

如果你的工作经验更久远,并且是使用像Rails这样落后一点的 MVC Web 框架,那么你可以把Remix路由看作是使用React进行模板制作的后台界面,但是它们知道如何在浏览器中无缝的添加一些特性,而不是编写前后端分离的jQuery代码来修饰用户交互,这就是渐进式增强的充分体现,此外你的路由就是它们自己的控制器。

写一个 posts 路由 loader

  1. // app/routes/posts/index.tsx
  2. import { useLoaderData } from "remix";
  3. export const loader = async () => {
  4. return [
  5. {
  6. slug: "my-first-post",
  7. title: "My First Post"
  8. },
  9. {
  10. slug: "90s-mixtape",
  11. title: "A Mixtape I Made Just For You"
  12. }
  13. ];
  14. };
  15. export default function Posts() {
  16. const posts = useLoaderData();
  17. console.log('posts', posts);
  18. return (
  19. <div>
  20. <h1>Posts</h1>
  21. </div>
  22. );
  23. }

loader是组件的后端API,它已经通过useLoaderData为你连接起来。在Remix路由中,客户端和服务器之间的界限有点模糊,如果你的服务器和浏览器的控制台都打开了,你会注意到它们都记录了我们的post数据,这是因为Remix在服务器上渲染,可以像传统的web框架一样发送一个完整的html,同样的它在客户端也记录这些。

在posts内渲染数据

添加Post类型和useloaderData范型,这样即使在网络请求中,也能获取可靠的类型安全性,因为它都是在同一个文件中定义的,除非在Remix的网络请求瘫痪了,否则这个组件和它的API都具有类型安全(请记住,该组件已经是它自己的API路由)

  1. import { useLoaderData } from "remix";
  2. export type Post = {
  3. slug: string;
  4. title: string;
  5. };
  6. export const loader = async () => {
  7. const posts: Post[] = [
  8. {
  9. slug: "my-first-post",
  10. title: "My First Post",
  11. },
  12. {
  13. slug: "90s-mixtape",
  14. title: "A Mixtape I Made Just For You",
  15. },
  16. ];
  17. return posts;
  18. };
  19. export default function Posts() {
  20. const posts = useLoaderData<Post[]>();
  21. console.log("posts", posts);
  22. return (
  23. <div>
  24. <h1>Posts</h1>
  25. </div>
  26. );
  27. }

重构:将异步获取数据逻辑抽离为getPosts

创建文件
touch app/post.ts

  1. // app/post.ts
  2. export type Post = {
  3. slug: string;
  4. title: string;
  5. };
  6. export function getPosts() {
  7. const posts: Post[] = [
  8. {
  9. slug: "my-first-post",
  10. title: "My First Post"
  11. },
  12. {
  13. slug: "90s-mixtape",
  14. title: "A Mixtape I Made Just For You"
  15. }
  16. ];
  17. return posts;
  18. }
  1. // app/routes/posts/index.tsx
  2. import { useLoaderData } from "remix";
  3. import { getPosts } from "~/post";
  4. import type { Post } from "~/post";
  5. export const loader = async () => {
  6. return getPosts();
  7. };
  8. export default function Posts() {
  9. const posts = useLoaderData<Post[]>();
  10. console.log("posts", posts);
  11. return (
  12. <div>
  13. <h1>Posts</h1>
  14. </div>
  15. );
  16. }

从markdown文件中获取数据

在本章节,我们将只使用文件系统,从中读取数据。

  1. # 在项目的根目录创建一个 posts 文件夹
  2. mkdir posts
  3. # 添加一些posts文件
  4. touch posts/my-first-post.md
  5. touch posts/my-second.md

写一些你想要的东西进去,但要确保它们的包含标题
my-first-post.md

  1. ---
  2. title: My First Post
  3. ---
  4. # This is my first post
  5. Isn't it great?

my-second.md

  1. ---
  2. title: My Second Post
  3. ---
  4. # 90s Mixtape
  5. - I wish (Skee-Lo)
  6. - This Is How We Do It (Montell Jordan)
  7. - Everlong (Foo Fighters)
  8. - Ms. Jackson (Outkast)
  9. - Interstate Love Song (Stone Temple Pilots)
  10. - Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
  11. - Just a Friend (Biz Markie)
  12. - The Man Who Sold The World (Nirvana)
  13. - Semi-Charmed Life (Third Eye Blind)
  14. - ...Baby One More Time (Britney Spears)
  15. - Better Man (Pearl Jam)
  16. - It's All Coming Back to Me Now (Céline Dion)
  17. - This Kiss (Faith Hill)
  18. - Fly Away (Lenny Kravits)
  19. - Scar Tissue (Red Hot Chili Peppers)
  20. - Santa Monica (Everclear)
  21. - C'mon N' Ride it (Quad City DJ's)
  1. # 解析md文件
  2. npm add front-matter
  3. # 校验数据真假
  4. npm add tiny-invariant
  1. // app/post.ts
  2. import path from "path";
  3. import fs from "fs/promises";
  4. import parseFrontMatter from "front-matter";
  5. import invariant from "tiny-invariant";
  6. export type Post = {
  7. slug: string;
  8. title: string;
  9. };
  10. // post md文件属性
  11. export type PostMarkdownAttributes = {
  12. title: string;
  13. };
  14. /**
  15. * post文件夹路径
  16. * path.join(__dirname, "..", "posts") 这段官方代码文件路径有误
  17. */
  18. // const postsPath = path.join(__dirname, "..", "posts");
  19. const postsPath = path.resolve("posts");
  20. /**
  21. * 校验md文件是否有title属性
  22. */
  23. function isValidPostAttributes(
  24. attributes: any
  25. ): attributes is PostMarkdownAttributes {
  26. return attributes?.title;
  27. }
  28. export async function getPosts() {
  29. // 读取文件夹
  30. const dir = await fs.readdir(postsPath);
  31. return Promise.all(
  32. dir.map(async (filename) => {
  33. const file = await fs.readFile(path.join(postsPath, filename));
  34. // 格式化md文件 读取文件元属性
  35. const { attributes } = parseFrontMatter(file.toString());
  36. // 如果md文件不包含校验的属性 就会抛出错误提示
  37. invariant(
  38. isValidPostAttributes(attributes),
  39. `${filename} 有错误的元数据`
  40. );
  41. return {
  42. slug: filename.replace(/\.md$/, ""),
  43. title: attributes?.title,
  44. };
  45. })
  46. );
  47. }

动态路由参数

本章节我们将实现以下两个路由
/posts/my-first-post
/posts/my-second-post
我们可以在url中使用动态变量,而不是为每一个文章创建一个新的路由,Remix会解析并传给我们,让我们可以动态的查询路由

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

  1. // app/routes/posts/\$slug.tsx
  2. import { useLoaderData } from "remix";
  3. import type { LoaderFunction } from "remix";
  4. import invariant from "tiny-invariant";
  5. import { getPost } from "~/post";
  6. export type Post = {
  7. slug: string;
  8. title: string;
  9. };
  10. export const loader: LoaderFunction = async ({ params }) => {
  11. invariant(params.slug, "expected params.slug");
  12. return getPost(params.slug);
  13. };
  14. export default function PostSlug() {
  15. const post = useLoaderData<Post>();
  16. console.log("post", post);
  17. return (
  18. <div>
  19. <h1>{post.title}</h1>
  20. </div>
  21. );
  22. }
  23. import { useLoaderData } from "remix";
  24. import type { LoaderFunction } from "remix";
  25. import invariant from "tiny-invariant";
  26. import { getPost } from "~/post";
  27. export const loader: LoaderFunction = async ({ params }) => {
  28. invariant(params.slug, "expected params.slug");
  29. return getPost(params.slug);
  30. };
  31. export default function PostSlug() {
  32. const post = useLoaderData();
  33. return (
  34. <div>
  35. <h1>{post.title}</h1>
  36. </div>
  37. );
  38. }
  1. // app/post.ts
  2. import path from "path";
  3. import fs from "fs/promises";
  4. import parseFrontMatter from "front-matter";
  5. import invariant from "tiny-invariant";
  6. export type Post = {
  7. slug: string;
  8. title: string;
  9. };
  10. // post md文件属性
  11. export type PostMarkdownAttributes = {
  12. title: string;
  13. };
  14. /**
  15. * post文件夹路径
  16. * path.join(__dirname, "..", "posts") 这段官方代码文件路径有误
  17. */
  18. // const postsPath = path.join(__dirname, "..", "posts");
  19. const postsPath = path.resolve("posts");
  20. /**
  21. * 校验md文件是否有title属性
  22. */
  23. function isValidPostAttributes(
  24. attributes: any
  25. ): attributes is PostMarkdownAttributes {
  26. return attributes?.title;
  27. }
  28. export async function getPost(slug: string) {
  29. const filepath = path.join(postsPath, slug + ".md");
  30. const file = await fs.readFile(filepath);
  31. const { attributes } = parseFrontMatter(file.toString());
  32. invariant(
  33. isValidPostAttributes(attributes),
  34. `Post ${filepath} is missing attributes`
  35. );
  36. return { slug, title: attributes.title };
  37. }
  38. export async function getPosts() {
  39. // 读取文件夹
  40. const dir = await fs.readdir(postsPath);
  41. return Promise.all(
  42. dir.map(async (filename) => {
  43. const file = await fs.readFile(path.join(postsPath, filename));
  44. // 格式化md文件 读取文件元属性
  45. const { attributes } = parseFrontMatter(file.toString());
  46. // 如果md文件不包含校验的属性 就会抛出错误提示
  47. invariant(
  48. isValidPostAttributes(attributes),
  49. `${filename} 有错误的元数据`
  50. );
  51. return {
  52. slug: filename.replace(/\.md$/, ""),
  53. title: attributes?.title,
  54. };
  55. })
  56. );
  57. }
  1. // app/routes/posts/index.tsx
  2. import { useLoaderData } from "remix";
  3. import { getPosts } from "~/post";
  4. import type { Post } from "~/post";
  5. export const loader = async () => {
  6. return getPosts();
  7. };
  8. export default function Posts() {
  9. const posts = useLoaderData<Post[]>();
  10. console.log("posts", posts);
  11. return (
  12. <div>
  13. <h1>Posts</h1>
  14. </div>
  15. );
  16. }

个人博客效果

效果:
image.png

  1. mkdir app/styles
  2. touch app/styles/admin.css
  3. touch app/routes/admin.tsx
  1. .admin {
  2. display: flex;
  3. }
  4. .admin > nav {
  5. padding-right: 2rem;
  6. }
  7. .admin > main {
  8. flex: 1;
  9. border-left: solid 1px #ccc;
  10. padding-left: 2rem;
  11. }
  12. em {
  13. color: red;
  14. }
  1. // app/routes/admin.tsx
  2. import { Link, useLoaderData } from "remix";
  3. import { getPosts } from "~/post";
  4. import type { Post } from "~/post";
  5. import adminStyles from "~/styles/admin.css";
  6. export const links = () => {
  7. return [{ rel: "stylesheet", href: adminStyles }];
  8. };
  9. export const loader = async () => {
  10. return getPosts();
  11. };
  12. export default function Admin() {
  13. const posts = useLoaderData<Post[]>();
  14. return (
  15. <div className="admin">
  16. <nav>
  17. <h1>Admin</h1>
  18. <ul>
  19. {posts.map((post) => (
  20. <li key={post.slug}>
  21. <Link to={`/posts/${post.slug}`}>{post.title}</Link>
  22. </li>
  23. ))}
  24. </ul>
  25. </nav>
  26. <main>...</main>
  27. </div>
  28. );
  29. }

访问:http://localhost:3000/admin 查看效果

嵌套路由

在上一章 在routes/admin.tsx 匹配了 /admin 路由地址
在本章节将基于上一章的代码来演示如何编写 /admin的子路由 /admin/news

  1. # 首先在routes下创建admin文件夹,并在目录下创建index.tsx文件
  2. # 之前的 /routes/admin.tsx不用删除 我们将在此处添加子路由入口
  3. mkdir app/routes/admin
  4. touch app/routes/admin/index.tsx
  1. // app/routes/admin/index.tsx
  2. import { Link } from "remix";
  3. export default function AdminIndex() {
  4. return (
  5. <p>
  6. <Link to="new">Create a New Post</Link>
  7. </p>
  8. );
  9. }

写完以上代码后 即使你刷新浏览器也不会看到它,因为你还没给这段代码放置一个出口

  1. // app/routes/admin.tsx
  2. import { Outlet, Link, useLoaderData } from "remix";
  3. // ...省略代码别复制
  4. export default function Admin() {
  5. // ......省略代码别复制
  6. return (
  7. // ... 省略部分tsx代码
  8. <main>
  9. // 将Outlet组件放在你想让你的子路由代码展现的位置、
  10. // 这里会将 app/routes/admin/**.tsx 映射为对应的 /admin/** 路由地址
  11. // 对应的子路由文件内的tsx将会在Outlet处渲染
  12. <Outlet />
  13. </main>
  14. )
  15. }

最终效果:
image.png

在线写博客

本章节将会演示如何在/admin/new中使用表单创建新的markdown文件

  1. // /app/post.ts
  2. // 在之前代码后面添加以下代码
  3. type NewPost = {
  4. title: string;
  5. slug: string;
  6. markdown: string;
  7. };
  8. export async function createPost(post: NewPost) {
  9. const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  10. await fs.writeFile(path.join(postsPath, post.slug + ".md"), md);
  11. return getPost(post.slug);
  12. }
  1. // app/routes/admin/new.tsx
  2. import { useActionData, Form, redirect } from "remix";
  3. import type { ActionFunction } from "remix";
  4. import invariant from "tiny-invariant";
  5. import { createPost } from "~/post";
  6. type PostError = {
  7. title?: boolean;
  8. slug?: boolean;
  9. markdown?: boolean;
  10. };
  11. export const action: ActionFunction = async ({ request }) => {
  12. await new Promise((res) => setTimeout(res, 1000));
  13. const formData = await request.formData();
  14. const title = formData.get("title");
  15. const slug = formData.get("slug");
  16. const markdown = formData.get("markdown");
  17. const errors: PostError = {};
  18. if (!title) errors.title = true;
  19. if (!slug) errors.slug = true;
  20. if (!markdown) errors.markdown = true;
  21. if (Object.keys(errors).length) {
  22. return errors;
  23. }
  24. invariant(typeof title === "string");
  25. invariant(typeof slug === "string");
  26. invariant(typeof markdown === "string");
  27. await createPost({ title, slug, markdown });
  28. return redirect("/admin");
  29. };
  30. export default function NewPost() {
  31. const errors = useActionData();
  32. return (
  33. <Form method="post">
  34. <p>
  35. <label>
  36. Post Title: {errors?.title ? <em>Title is required</em> : null}{" "}
  37. <input type="text" name="title" />
  38. </label>
  39. </p>
  40. <p>
  41. <label>
  42. Post Slug: {errors?.slug ? <em>Slug is required</em> : null}{" "}
  43. <input type="text" name="slug" />
  44. </label>
  45. </p>
  46. <p>
  47. <label htmlFor="markdown">Markdown:</label>{" "}
  48. {errors?.markdown ? <em>Markdown is required</em> : null}
  49. <br />
  50. <textarea id="markdown" rows={20} name="markdown" />
  51. </p>
  52. <p>
  53. <button type="submit">Create Post</button>
  54. </p>
  55. </Form>
  56. );
  57. }

源码

my-remix.zip
这份代码不是根据教程的命令行生成的,而是使用vercel创建的模板
但本章节读取文件的代码在vercel是不生效的,在vercel的server less function 是不支持文件系统api访问的,但不要担心,源码可在本地环境访问,最后请勿用在生产环境。