Remix 基础

一、创建 Remix 应用

npx create-remix@latest

yarn dev

二、创建文件路由

app/root.tsx Layout 组件中

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

创建 app/routes/posts/index.tsx

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

三、加载数据

Remix 的路由文件相当于后端的模板视图,也是控制器,Remix 建立在 HTTP 和 HTML 的基础之上,因此整个过程无需在浏览器中使用 JavaScript。这也是 Remix 的创新之处,我目前的理解是 Remix 类似 Nodejs中间层 ,只是 Remix 将 React 整合到了中间层中。这样做的好处是少了一层,提升性能的同时简化了开发成本,让前端更专注于前端,后端更专注于后端。

Image description

Step1. 项目根目录下(不是app目录) 创建本地文件模拟数据(真实项目往往是从服务器数据库请求数据)

posts/my-first-post.md

---
title: My First Post
---

# This is my first post

Isn't it great?

posts/90s-mix-cdr.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)

Step2. 创建 app/post.ts 这个文件是处理posts的模块,在这里模拟请求数据

首先安装两个模块 fron-matter tiny-invariant

yarn add front-matter //node模块

yarn add tiny-invariant //类型检查
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} 有错误的元数据!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}

Step3. app/routes/posts/index.tsx

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

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

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

四、动态路由

Step1. 首先安装 @types/marked 将 markdown 解析为 HTML

yarn add marked

yarn add @types/marked

Step2. 为 app/post.ts 模块添加一个函数 getPost

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

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} 有错误的元数据!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
};

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

Step3. 创建动态路由文件

app/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 dangerouslySetInnerHTML={{ __html: post.html }} />
    );

};

五、子路由(嵌套路由)

Step1. 创建一个 admin 路由文件

app/routes/admin.tsx 注意使用:Outlet

import { Outlet, 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 = () => {
  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>
          <Outlet/>
      </main>
    </div>
  );
};

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

Step2. 为 admin.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>
  );
}

Step3. 在 app/post.ts 处理新建Post的逻辑

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

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

export type PostMarkdownAttributes = {
  title: string;
};

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

// 相对于服务器输出而不是源!
const postsPath = path.join(__dirname, "..", "posts");

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


//Post列表
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} 有错误的元数据!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
};

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

// 新建Post
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);
};

Step4. 创建 app/routes/admin/new.tsx

import { useTransition, useActionData, Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";
import invariant from "tiny-invariant";

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();
    const transition = useTransition();

    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 id="markdown" rows={20} name="markdown" />
            </p>
            <p>
                <button type="submit">
                    {transition.submission
                        ? "Creating..."
                        : "Create Post"}
                </button>
            </p>
        </Form>
    );
}