使用 RedwoodJS、Fauna 和 Vercel 部署无服务器 Jamstack 网站

Avatar of Anthony Campolo
Anthony Campolo

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

本文面向任何对与 Jamstack无服务器 相关的工具和技术新兴生态系统感兴趣的人。 我们将使用 Fauna 的 GraphQL API 作为使用 Redwood 框架构建的 Jamstack 前端的无服务器后端,并在 Vercel 上通过一键式部署进行部署。

换句话说,有很多东西要学习! 到最后,您不仅可以深入了解 Jamstack 和无服务器的概念,还可以亲身体验我认为您会喜欢的非常棒的技术组合。

创建 Redwood 应用程序

Redwood 是一个用于无服务器应用程序的框架,它将 React(用于前端组件)、GraphQL(用于数据)和 Prisma(用于数据库查询)整合在一起。

我们也可以在这里使用其他前端框架。 一个例子是 Bison,由 Chris Ball 创建。 它以类似于 Redwood 的方式利用 GraphQL,但使用略微不同的 GraphQL 库阵容,例如 Nexus 代替 Apollo Client,以及 GraphQL Codegen 代替 Redwood CLI。 但它只是在几个月前发布的,因此与 Redwood 相比,该项目仍然很新,Redwood 自 2019 年 6 月开始开发。

我们有很多 很棒的 Redwood 入门模板 可用于引导我们的应用程序,但我希望从生成 Redwood 模板项目并查看构成 Redwood 应用程序的不同部分开始。 然后我们将逐个构建项目。

我们需要 安装 Yarn 以使用 Redwood CLI 开始。 一旦准备就绪,以下是您在终端中运行的内容

yarn create redwood-app ./csstricks

我们现在将 cd 到我们的新项目目录并启动我们的开发服务器。

cd csstricks
yarn rw dev

我们项目的 frontend 现在运行在 localhost:8910 上。 我们的 backend 运行在 localhost:8911 上,可以接收 GraphQL 查询。 默认情况下,Redwood 附带一个 GraphiQL playground,我们将在本文结尾使用它。

让我们在浏览器中前往 localhost:8910。 如果一切正常,Redwood 着陆页应该会加载。

Redwood 启动页表明我们的应用程序 frontend 已准备好。 它还提供了一个很好的说明,说明如何开始为应用程序创建自定义路由。

截至撰写本文时,Redwood 的当前版本为 0.21.0。 文档 警告不要在生产环境中使用它,直到它正式达到 1.0 版本。 他们还有一个 社区论坛,在那里他们欢迎来自您这样的开发人员的反馈和意见。

目录结构

Redwood 重视约定优于配置,并为我们做出许多决定,包括技术选择、文件组织方式,甚至命名约定。 这可能导致大量生成的样板代码难以理解,尤其是在您第一次接触它时。

以下是项目的结构

├── api
│   ├── prisma
│   │   ├── schema.prisma
│   │   └── seeds.js
│   └── src
│       ├── functions
│       │   └── graphql.js
│       ├── graphql
│       ├── lib
│       │   └── db.js
│       └── services
└── web
    ├── public
    │   ├── favicon.png
    │   ├── README.md
    │   └── robots.txt
    └── src
        ├── components
        ├── layouts
        ├── pages
        │   ├── FatalErrorPage
        │   │   └── FatalErrorPage.js
        │   └── NotFoundPage
        │       └── NotFoundPage.js
        ├── index.css
        ├── index.html
        ├── index.js
        └── Routes.js

不要太担心这一切的含义; 最重要的是要注意,事情被分成两个主要目录:webapi。 Yarn workspaces 允许每侧在代码库中拥有自己的路径。

web 包含我们用于以下内容的 frontend 代码

  • 页面
  • 布局
  • 组件

api 包含我们用于以下内容的 backend 代码

  • 函数处理程序
  • 模式定义语言
  • 用于 backend 业务逻辑的服务
  • 数据库客户端

Redwood 假设 Prisma 作为数据存储,但我们将改为使用 Fauna。 为什么选择 Fauna 而不是 Firebase? 好吧,这只是个人喜好。 Google 收购 Firebase 后,他们推出了实时文档数据库 Cloud Firestore 作为原始 Firebase 实时数据库的继任者。 通过与更大的 Firebase 生态系统集成,我们可以访问比 Fauna 提供的更广泛的功能。 同时,还有一些社区项目尝试过 Firestore 和 GraphQL,但 Google 没有提供一流的 GraphQL 支持。

由于我们将直接查询 Fauna,因此可以删除 prisma 目录及其中的所有内容。 我们还可以删除 db.js 中的所有代码。 只是不要删除该文件,因为我们将使用它来连接到 Fauna 客户端。

index.html

我们首先看看 web 侧,因为它对于有 React 或其他单页应用程序框架经验的开发人员来说应该很熟悉。

但是,当我们构建 React 应用程序时,究竟发生了什么? 它将整个站点全部塞入 index.js 中的一个大 JavaScript 球中,然后将该 JavaScript 球塞入“根”DOM 节点,该节点位于 index.html 的第 11 行。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/png" href="/favicon.png" />
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>

  <body>
    <div id="redwood-app"></div> // HIGHLIGHT
  </body>
</html>

虽然 Redwood 在其文档和营销中使用 Jamstack,但 Redwood 尚未进行预渲染(例如 NextGatsby 可以),但它仍然是 Jamstack,因为它正在发送静态文件并使用 JavaScript 与 API 进行数据交互。

index.js

index.js 包含我们的根组件(那个大 JavaScript 球),它被渲染到根 DOM 节点。 document.getElementById() 选择一个包含 redwood-appid 的元素,ReactDOM.render() 将我们的应用程序渲染到根 DOM 元素中。

RedwoodProvider

<Routes /> 组件(以及所有应用程序页面)包含在 <RedwoodProvider> 标记内。 Flash 使用 Context API 在深度嵌套的组件之间传递消息对象。 它提供了一个典型的消息显示单元,用于渲染提供给 FlashContext 的消息。

FlashContext 的提供者组件与 <RedwoodProvider /> 组件打包在一起,因此可以开箱即用。 组件通过订阅它(想想,“发送和接收”)来传递消息对象,方法是使用提供的 useFlash hook。

FatalErrorBoundary

提供者本身然后包含在 <FatalErrorBoundary> 组件中,该组件将 <FatalErrorPage> 作为 prop 输入。 这会在其他所有方法都失败时将您的网站默认设置为错误页面。

import ReactDOM from 'react-dom'
import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import './index.css'

ReactDOM.render(
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider>
      <Routes />
    </RedwoodProvider>
  </FatalErrorBoundary>,

  document.getElementById('redwood-app')
)

Routes.js

Router 包含我们所有的路由,每个路由都由一个 Route 指定。 Redwood Router 尝试将当前 URL 与每个路由进行匹配,并在找到匹配项时停止,然后仅渲染该路由。 唯一的例外是 notfound 路由,当没有其他路由匹配时,它会渲染一个带有 notfound prop 的 Route

import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

页面

现在我们的应用程序已设置好,让我们开始创建页面!我们将使用 Redwood CLI 的 `generate page` 命令创建一个名为 `home` 的命名路由函数。当它将 URL 路径匹配到 `/` 时,这将呈现 `HomePage` 组件。

我们也可以使用 `rw` 代替 `redwood`,`g` 代替 `generate` 来节省一些输入。

yarn rw g page home /

此命令执行四个独立的操作

  • 它创建了 `web/src/pages/HomePage/HomePage.js`。第一个参数中指定的名称将被大写,并在末尾追加“Page”。
  • 它在 `web/src/pages/HomePage/HomePage.test.js` 中创建一个测试文件,其中包含一个通过的测试,以便你可以假装自己在进行测试驱动的开发。
  • 它在 `web/src/pages/HomePage/HomePage.stories.js` 中创建一个 Storybook 文件。
  • 它在 `web/src/Routes.js` 中添加一个新的 ``,它将 `/` 路径映射到 `HomePage` 组件。

HomePage

如果我们进入 `web/src/pages`,我们会看到一个包含 `HomePage.js` 文件的 `HomePage` 目录。以下是它的内容

// web/src/pages/HomePage/HomePage.js

import { Link, routes } from '@redwoodjs/router'

const HomePage = () => {
  return (
    <>
      <h1>HomePage</h1>
      <p>
        Find me in <code>./web/src/pages/HomePage/HomePage.js</code>
      </p>
      <p>
        My default route is named <code>home</code>, link to me with `
        <Link to={routes.home()}>Home</Link>`
      </p>
    </>
  )
}

export default HomePage
`HomePage.js` 文件已设置为主要路由,`/`。

我们将把页面导航移动到一个可重用的布局组件中,这意味着我们可以删除 `Link` 和 `routes` 导入以及 `Home`。这是我们剩下的部分

// web/src/pages/HomePage/HomePage.js

const HomePage = () => {
  return (
    <>
      <h1>RedwoodJS+FaunaDB+Vercel 🚀</h1>
      <p>Taking Fullstack to the Jamstack</p>
    </>
  )
}

export default HomePage

AboutPage

要创建我们的 `AboutPage`,我们将输入几乎与我们刚刚执行的命令完全相同的命令,但使用 `about` 而不是 `home`。我们也不需要指定路径,因为它与我们路由的名称相同。在这种情况下,名称和路径都将设置为 `about`。

yarn rw g page about
`AboutPage.js` 现在可以在 `/about` 中使用。
// web/src/pages/AboutPage/AboutPage.js

import { Link, routes } from '@redwoodjs/router'

const AboutPage = () => {
  return (
    <>
      <h1>AboutPage</h1>
      <p>
        Find me in <code>./web/src/pages/AboutPage/AboutPage.js</code>
      </p>
      <p>
        My default route is named <code>about</code>, link to me with `
        <Link to={routes.about()}>About</Link>`
      </p>
    </>
  )
}

export default AboutPage

我们将对 About 页面进行一些修改,就像我们对 Home 页面所做的那样。这包括删除 `` 和 `routes` 导入以及删除 `Link to={routes.about()}>About`。

以下是最终结果

// web/src/pages/AboutPage/AboutPage.js

const AboutPage = () => {
  return (
    <>
      <h1>About 🚀🚀</h1>
      <p>For those who want to stack their Jam, fully</p>
    </>
  )
}

如果我们回到 `Routes.js`,我们会看到我们为 `home` 和 `about` 创建的新路由。Redwood 为我们做到了这一点,真是太棒了!

const Routes = () => {
  return (
    <Router>
      <Route path="/about" page={AboutPage} name="about" />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

Layouts

现在我们想创建一个带有导航链接的页眉,我们可以轻松地将其导入不同的页面。我们想使用布局,这样我们就可以通过导入组件而不是在每个页面上都编写代码来为任意数量的页面添加导航。

BlogLayout

你现在可能想知道,“是否有布局的生成器?” 答案是……当然!该命令几乎与我们到目前为止一直在使用的命令相同,只是使用 `rw g layout` 后跟布局的名称,而不是 `rw g page` 后跟路由的名称和路径。

yarn rw g layout blog
// web/src/layouts/BlogLayout/BlogLayout.js

const BlogLayout = ({ children }) => {
  return <>{children}</>
}

export default BlogLayout

要创建不同页面之间的链接,我们需要

  • 从 `@redwoodjs/router` 中将 `Link` 和 `routes` 导入到 `BlogLayout.js` 中
  • 为每个链接创建一个 `` 组件
  • 将命名路由函数(例如 `routes.home()`)传递到每个路由的 `to={} ` 属性中
// web/src/layouts/BlogLayout/BlogLayout.js

import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
  return (
    <>
      <header>
        <h1>RedwoodJS+FaunaDB+Vercel 🚀</h1>

        <nav>
          <ul>
            <li>
              <Link to={routes.home()}>Home</Link>
            </li>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>

      </header>

      <main>
        <p>{children}</p>
      </main>
    </>
  )
}

export default BlogLayout

我们目前在浏览器中不会看到任何变化。我们创建了 `BlogLayout`,但尚未将其导入任何页面。因此,让我们将 `BlogLayout` 导入 `HomePage`,并将整个 `return` 语句用 `BlogLayout` 标签包装起来。

// web/src/pages/HomePage/HomePage.js

import BlogLayout from 'src/layouts/BlogLayout'

const HomePage = () => {
  return (
    <BlogLayout>
      <p>Taking Fullstack to the Jamstack</p>
    </BlogLayout>
  )
}

export default HomePage
看,导航正在成型!

如果我们点击到 About 页面的链接,我们会转到该页面,但我们无法返回到上一个页面,因为我们还没有将 `BlogLayout` 导入到 `AboutPage` 中。我们现在就来做这件事

// web/src/pages/AboutPage/AboutPage.js

import BlogLayout from 'src/layouts/BlogLayout'

const AboutPage = () => {
  return (
    <BlogLayout>
      <p>For those who want to stack their Jam, fully</p>
    </BlogLayout>
  )
}

export default AboutPage

现在,我们可以通过点击导航链接在页面之间来回导航!接下来,我们将创建 GraphQL 架构,以便我们可以开始处理数据。

Fauna schema definition language

要使它正常工作,我们需要创建一个名为 `sdl.gql` 的新文件,并将以下架构输入该文件。Fauna 将采用此架构并进行一些转换。

// sdl.gql

type Post {
  title: String!
  body: String!
}

type Query {
  posts: [Post]
}

保存该文件并将其上传到 Fauna 的 GraphQL Playground。请注意,此时,您需要一个 Fauna 帐户才能继续。有一个免费层非常适合我们正在做的事情。

GraphQL Playground 位于选定的数据库中。
Fauna shell 允许我们编写、运行和测试查询。

Redwood 和 Fauna 在 SDL 上达成一致非常重要,因此我们无法使用输入 Fauna 的原始 SDL,因为它不再是 Fauna 数据库中存在的类型的准确表示。

如果我们在 shell 中运行默认查询,`Post` 集合和帖子 `Index` 将保持不变,但 Fauna 创建了一个中间的 `PostPage` 类型,它具有一个 `data` 对象。

Redwood schema definition language

这个 `data` 对象包含一个数组,其中包含数据库中的所有 `Post` 对象。我们将使用这些类型来创建另一个架构定义语言,该语言位于我们 Redwood 项目 `api` 端的 `graphql` 目录中。

// api/src/graphql/posts.sdl.js

import gql from 'graphql-tag'

export const schema = gql`
  type Post {
    title: String!
    body: String!
  }

  type PostPage {
    data: [Post]
  }

  type Query {
    posts: PostPage
  }
`

Services

`posts` 服务向 Fauna GraphQL API 发送一个查询。此查询请求一个帖子数组,具体而言是每个帖子的 `title` 和 `body`。这些都包含在 `PostPage` 的 `data` 对象中。

// api/src/services/posts/posts.js

import { request } from 'src/lib/db'
import { gql } from 'graphql-request'

export const posts = async () => {
  const query = gql`
  {
    posts {
      data {
        title
        body
      }
    }
  }
  `

  const data = await request(query, 'https://graphql.fauna.com/graphql')

  return data['posts']
}

此时,我们可以安装 `graphql-request`,这是一个基于承诺的 API 的最小 GraphQL 客户端,可用于发送 GraphQL 请求

cd api
yarn add graphql-request graphql

Attach the Fauna authorization token to the request header

到目前为止,我们有用于数据的 GraphQL、用于建模该数据的 Fauna 以及 `graphql-request` 来查询它。现在我们需要在 `graphql-request` 和 Fauna 之间建立连接,我们将通过将 `graphql-request` 导入 `db.js` 来完成此操作,并使用它来查询设置为 `https://graphql.fauna.com/graphql` 的 `endpoint`。

// api/src/lib/db.js

import { GraphQLClient } from 'graphql-request'

export const request = async (query = {}) => {
  const endpoint = 'https://graphql.fauna.com/graphql'

  const graphQLClient = new GraphQLClient(endpoint, {
    headers: {
      authorization: 'Bearer ' + process.env.FAUNADB_SECRET
    },
  })

  try {
    return await graphQLClient.request(query)
  } catch (error) {
    console.log(error)
    return error
  }
}

实例化一个 `GraphQLClient` 来设置具有授权令牌的标头,从而允许数据流向我们的应用程序。

Create

我们将使用 Fauna Shell 并运行一些 Fauna 查询语言 (FQL) 命令来填充数据库。首先,我们将创建一个具有 `title` 和 `body` 的博客文章。

Create(
  Collection("Post"),
  {
    data: {
      title: "Deno is a secure runtime for JavaScript and TypeScript.",
      body: "The original creator of Node, Ryan Dahl, wanted to build a modern, server-side JavaScript framework that incorporates the knowledge he gained building out the initial Node ecosystem."
    }
  }
)
{
  ref: Ref(Collection("Post"), "282083736060690956"),
  ts: 1605274864200000,
  data: {
    title: "Deno is a secure runtime for JavaScript and TypeScript.",
    body:
      "The original creator of Node, Ryan Dahl, wanted to build a modern, server-side JavaScript framework that incorporates the knowledge he gained building out the initial Node ecosystem."
  }
}

让我们再创建一个。

Create(
  Collection("Post"),
  {
    data: {
      title: "NextJS is a React framework for building production grade applications that scale.",
      body: "To build a complete web application with React from scratch, there are many important details you need to consider such as: bundling, compilation, code splitting, static pre-rendering, server-side rendering, and client-side rendering."
    }
  }
)
{
  ref: Ref(Collection("Post"), "282083760102441484"),
  ts: 1605274887090000,
  data: {
    title:
      "NextJS is a React framework for building production grade applications that scale.",
    body:
      "To build a complete web application with React from scratch, there are many important details you need to consider such as: bundling, compilation, code splitting, static pre-rendering, server-side rendering, and client-side rendering."
  }
}

再创建一个,让事情更充实。

Create(
  Collection("Post"),
  {
    data: {
      title: "Vue.js is an open-source front end JavaScript framework for building user interfaces and single-page applications.",
      body: "Evan You wanted to build a framework that combined many of the things he loved about Angular and Meteor but in a way that would produce something novel. As React rose to prominence, Vue carefully observed and incorporated many lessons from React without ever losing sight of their own unique value prop."
    }
  }
)
{
  ref: Ref(Collection("Post"), "282083792286384652"),
  ts: 1605274917780000,
  data: {
    title:
      "Vue.js is an open-source front end JavaScript framework for building user interfaces and single-page applications.",
    body:
      "Evan You wanted to build a framework that combined many of the things he loved about Angular and Meteor but in a way that would produce something novel. As React rose to prominence, Vue carefully observed and incorporated many lessons from React without ever losing sight of their own unique value prop."
  }
}

Cells

Cells 提供了一种简单且声明式的数据获取方法。它们包含 GraphQL 查询以及加载、空、错误和成功状态。每个单元格都会根据自身所处的状态自动渲染。

BlogPostsCell

yarn rw generate cell BlogPosts


export const QUERY = gql`
  query BlogPostsQuery {
    blogPosts {
      id
    }
  }
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>

export const Success = ({ blogPosts }) => {
  return JSON.stringify(blogPosts)
}

默认情况下,我们让查询在导入单元格的页面上使用 `JSON.stringify` 渲染数据。我们将进行一些更改,以使查询并渲染我们所需的数据。所以,让我们

  • 将 `blogPosts` 更改为 `posts`。
  • 将 `BlogPostsQuery` 更改为 `POSTS`。
  • 将查询本身更改为返回每个帖子的 `title` 和 `body`。
  • 在成功组件中遍历 `data` 对象。
  • 创建一个组件,该组件包含通过 `data` 对象返回的 `posts` 的 `title` 和 `body`。

以下是其外观

// web/src/components/BlogPostsCell/BlogPostsCell.js

export const QUERY = gql`
  query POSTS {
    posts {
      data {
        title
        body
      }
    }
  }
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>

export const Success = ({ posts }) => {
  const {data} = posts
  return data.map(post => (
    <>
      <header>
        <h2>{post.title}</h2>
      </header>
      <p>{post.body}</p>
    </>
  ))
}

`POSTS` 查询正在发送对 `posts` 的查询,当查询时,我们得到一个包含帖子数组的 `data` 对象。我们需要提取 `data` 对象,以便我们能够循环遍历它并获取实际的帖子。我们使用对象解构来获取 `data` 对象,然后使用 `map()` 函数遍历 `data` 对象并提取每个帖子。每个帖子的 `title` 在 `<header>` 内使用 `<h2>` 渲染,而正文则使用 `<p>` 标签渲染。

将 BlogPostsCell 导入主页

// web/src/pages/HomePage/HomePage.js

import BlogLayout from 'src/layouts/BlogLayout'
import BlogPostsCell from 'src/components/BlogPostsCell/BlogPostsCell.js'

const HomePage = () => {
  return (
    <BlogLayout>
      <p>Taking Fullstack to the Jamstack</p>
      <BlogPostsCell />
    </BlogLayout>
  )
}

export default HomePage
看看!帖子被返回到应用程序并在前端渲染。

Vercel

我们确实在本文标题中提到了 Vercel,我们终于到了需要它的时刻。具体来说,我们使用它来构建项目并将其部署到 Vercel 的托管平台,该平台在代码被推送到项目存储库时提供构建预览。所以,如果你还没有,获取一个 Vercel 帐户。同样,免费价格层对于这项工作来说已经足够好了。

为什么选择 Vercel 而不是 Netlify?这是一个好问题。Redwood 甚至开始时就将 Netlify 作为其最初的部署目标。Redwood 仍然拥有许多记录良好的 Netlify 集成。尽管与 Netlify 集成紧密,但 Redwood 寻求尽可能地通用地移植到尽可能多的部署目标。现在包括对 Vercel 的官方支持,以及对 Serverless 框架、AWS Fargate 和 PM2 的社区集成。所以,是的,我们可以在此处使用 Netlify,但可以选择可用的服务是件好事。

我们只需要对项目的配置进行一个更改,以将其与 Vercel 集成。让我们打开 `netlify.toml` 并将 `apiProxyPath` 更改为 `"/api"`。然后,让我们登录 Vercel 并单击“导入项目”按钮,以将其服务连接到项目存储库。在这里,我们输入存储库的 URL,以便 Vercel 可以监视它,然后在发现更改时触发构建和部署。

我使用 GitHub 托管我的项目,但 Vercel 也能够与 GitLab 和 Bitbucket 一起使用。

Redwood 具有一个预设的构建命令,它在 Vercel 中开箱即用

只需从预设选项中选择“Redwood”,我们就可以开始了。

我们已经走得很远了,但即使网站现在“上线”了,数据库也未连接

要解决此问题,我们将从我们的 Fauna 帐户中将 `FAUNADB_SECRET` 令牌添加到 Vercel 中的环境变量中

现在我们的应用程序已完成!

我们做到了!我希望这不仅能让你对使用 Jamstack 和无服务器感到非常兴奋,而且还能让你体验一些新技术的滋味。