静态优先:使用无服务器渲染作为回退的预生成 JAMstack 站点

Avatar of Phil Hawksworth
Phil Hawksworth

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

您可能会越来越频繁地看到 JAMstack 这个术语。我一直以来都非常喜欢它作为一种方法。

JAMstack 的原则之一是预渲染。换句话说,它会预先将您的站点生成成一系列静态资产,以便能够以最快的速度和最小的开销从 CDN 或其他优化的静态托管环境中提供给您的访问者。

但是,如果我们要提前预生成我们的站点,我们如何才能让它们感觉更具动态性呢?我们如何构建需要经常更改的站点?我们如何处理用户生成的内容之类的事情呢?

事实上,这可能是无服务器函数的一个很好的用例。JAMstack 和无服务器是最好的朋友。它们完美地互补。

在本文中,我们将探讨一种使用无服务器函数作为预生成页面回退的模式,该站点几乎完全由用户生成的内容组成。我们将使用一种乐观的 URL 路由技术,其中 404 页面是无服务器函数,以动态添加无服务器渲染。

是不是很时髦?也许吧。有效吗?绝对有效!

您可以去试用一下 演示站点,以帮助您想象这个用例。但前提是您答应回来。

https://vlolly.net

是你吗?你回来了?太好了。让我们深入了解。

这个小型示例站点的理念是,它允许您创建一条美好、愉快的消息和虚拟的鼓励信息,发送给您的朋友。您可以撰写一条消息,自定义棒棒糖(或者对于我的美国朋友来说是冰棒),并获取一个 URL 与您的目标收件人分享。就这样,您让他们的生活变得更加美好。还有什么不喜欢的呢?

传统上,我们会使用一些服务器端脚本构建此站点,以处理表单提交,将新的棒棒糖(我们的用户生成内容)添加到数据库中并生成唯一的 URL。然后,我们会使用更多服务器端逻辑来解析对这些页面的请求,查询数据库以获取填充页面视图所需的数据,使用合适的模板对其进行渲染,并将其返回给用户。

这一切似乎都很合乎逻辑。

但是扩展的成本是多少呢?

技术架构师和技术负责人经常在确定项目范围时会遇到这个问题。他们需要计划、支付和配置足够的算力以防万一成功。

这个虚拟棒棒糖网站绝非小玩意。由于我们都希望互相发送积极的信息,所以这东西会让我成为亿万富翁!随着消息的传开,流量水平将会激增。我最好有一个良好的策略来确保服务器能够处理繁重的负载。我可能会添加一些缓存层、一些负载均衡器,并且我会设计我的数据库和数据库服务器,以便能够共享负载,而不会因制造和提供所有这些棒棒糖的需求而发出呻吟声。

除了……我不知道如何做这些事情。

而且我不知道添加这些基础设施并保持其正常运行的成本是多少。这很复杂。

这就是为什么我喜欢通过尽可能多的预渲染来简化我的托管。

提供静态页面比从需要执行某些逻辑才能按需为每个访问者生成视图的 Web 服务器动态提供页面要简单得多,也便宜得多。

由于我们正在处理大量用户生成的内容,因此使用数据库仍然有意义,但我不会自己管理它。相反,我将选择众多按服务提供的数据库选项之一。我将通过其 API 与其进行通信。

我可能会选择 FirebaseMongoDB,或者其他许多选项。Chris 在一篇关于无服务器资源的优秀的 站点 上整理了一些这些资源,非常值得探索。

在本例中,我选择了 Fauna 作为我的数据存储。Fauna 有一个很好的 API 用于存储和查询数据。它是一个无 SQL 风格的数据存储,并能满足我的需求。

https://fauna.com

至关重要的是,Fauna 已将提供数据库服务作为一项完整的业务。他们拥有我永远无法拥有的深厚领域知识。**通过使用数据库即服务提供商,我为我的项目继承了一个专家数据服务团队**,包括高可用性基础设施、容量和合规性安心、熟练的支持工程师以及丰富的文档。

使用这样的第三方服务而不是自己开发,这就是它的优势。

架构 TL;DR

当我在处理概念验证时,我经常发现自己会涂鸦一些事物逻辑流程。这是我为该站点绘制的涂鸦

以及一些解释

  1. 用户通过填写一个普通的旧 HTML 表单来创建一个新的棒棒糖。
  2. 新内容保存在数据库中,并且其提交会触发新的站点生成和部署。
  3. 站点部署完成后,新的棒棒糖将在唯一的 URL 上可用。它将是一个静态页面,从 CDN 中非常快速地提供,并且不依赖于数据库查询或服务器。
  4. 在站点生成完成之前,任何新的棒棒糖都将无法作为静态页面使用。对棒棒糖页面的不成功请求将回退到一个页面,该页面通过动态查询数据库 API 来动态生成棒棒糖页面。

这种方法首先假设静态/预生成资产,然后仅在静态视图不可用时才回退到动态渲染,这被联合利华的 Markus Schork 有用地描述为“静态优先”,我非常喜欢这个说法。

更详细地介绍

您可以直接深入研究此站点的代码,该代码 是开源的,您可以随意探索,或者我们可以多聊聊。

您想更深入地研究一下,并探索此示例的实现吗?好的,我将更详细地解释一下

  • 从数据库获取数据以生成每个页面
  • 使用无服务器函数将数据发布到数据库 API
  • 触发整个站点重新生成
  • 在尚未生成页面时按需渲染

从数据库生成页面

稍后,我们将讨论如何将数据发布到数据库,但首先,让我们假设数据库中已经有一些条目。我们将要生成一个包含每个条目的页面的站点。

静态站点生成器 在这方面非常出色。它们会处理数据,将其应用于模板,并输出准备提供服务的 HTML 文件。我们可以使用任何生成器来作为此示例。我选择了 Eleventy,因为它相对简单且站点生成速度快。

为了向 Eleventy 提供一些数据,我们有很多选择。其中一个选项是 提供一些 JavaScript,它会返回结构化数据。这非常适合查询数据库 API

我们的 Eleventy 数据文件 将如下所示

// Set up a connection with the Fauna database.
// Use an environment variable to authenticate
// and get access to the database.
const faunadb = require('faunadb');
const q = faunadb.query;
const client = new faunadb.Client({
  secret: process.env.FAUNADB_SERVER_SECRET
});

module.exports = () => {
  return new Promise((resolve, reject) => {
    // get the most recent 100,000 entries (for the sake of our example)
    client.query(
      q.Paginate(q.Match(q.Ref("indexes/all_lollies")),{size:100000})
    ).then((response) => {
      // get all data for each entry
      const lollies = response.data;
      const getAllDataQuery = lollies.map((ref) => {
        return q.Get(ref);
      });
      return client.query(getAllDataQuery).then((ret) => {
        // send the data back to Eleventy for use in the site build
        resolve(ret);
      });
    }).catch((error) => {
      console.log("error", error);
      reject(error);
    });
  })
}

我将此文件命名为 lollies.js,这将使它返回的所有数据在名为 lollies 的集合中可用于 Eleventy。

我们现在可以在我们的模板中使用这些数据。如果您想查看获取这些数据并为每个项目生成页面的代码,您可以 在代码存储库中查看它

无需服务器即可提交和存储数据

当我们创建一个新的棒棒糖页面时,我们需要捕获数据库中的用户内容,以便将来可以将其用于填充给定 URL 上的页面。为此,我们使用了一个传统的 HTML 表单,该表单将数据发布到合适的表单处理程序。

表单如下所示(或 查看存储库中的完整代码

<form name="new-lolly" action="/new" method="POST">

  <!-- Default "flavors": 3 bands of colors with color pickers -->
  <input type="color" id="flavourTop" name="flavourTop" value="#d52358" />
  <input type="color" id="flavourMiddle" name="flavourMiddle" value="#e95946" />
  <input type="color" id="flavourBottom" name="flavourBottom" value="#deaa43" />

  <!-- Message fields -->
  <label for="recipientName">To</label>
  <input type="text" id="recipientName" name="recipientName" />

  <label for="message">Say something nice</label>
  <textarea name="message" id="message" cols="30" rows="10"></textarea>

  <label for="sendersName">From</label>
  <input type="text" id="sendersName" name="sendersName" />

  <!-- A descriptive submit button -->
  <input type="submit" value="Freeze this lolly and get a link">

</form>

在我们的托管方案中没有 Web 服务器,因此我们需要想办法处理从该表单提交的 HTTP POST 请求。这非常适合使用无服务器函数。我正在使用 Netlify Functions 来实现。如果您愿意,也可以使用 AWS Lambda、Google Cloud 或 Azure Functions,但我喜欢 Netlify Functions 的工作流程简单,并且它可以将我的无服务器 API 和我的 UI 都放在同一个代码库中。

最佳实践是避免将后端实现细节泄露到前端。清晰的分离有助于保持系统的可移植性和整洁性。请查看上面表单元素的 action 属性。它将数据发布到我网站上的一个名为 /new 的路径,该路径并没有真正暗示将与哪个服务进行通信。

我们可以使用重定向将请求路由到任何我们喜欢的服务。我将把它发送到一个无服务器函数,该函数将作为本项目的一部分进行配置,但如果需要,可以轻松地自定义它以将数据发送到其他地方。Netlify 为我们提供了一个简单且高度优化的 重定向引擎,它在 CDN 层面上引导我们的流量,因此用户可以非常快速地路由到正确的位置。

下面的重定向规则(位于我项目中的 netlify.toml 文件)将代理对 /new 的请求到由 Netlify Functions 托管的名为 newLolly.js 的无服务器函数。

# resolve the "new" URL to a function
[[redirects]]
  from = "/new"
  to = "/.netlify/functions/newLolly"
  status = 200

让我们来看看 那个无服务器函数,它

  • 将新数据存储到数据库中,
  • 为新页面创建一个新的 URL,以及
  • 将用户重定向到新创建的页面,以便他们可以看到结果。

首先,我们将需要各种实用程序来解析表单数据、连接到 Fauna 数据库并为新的棒棒糖创建易读的短唯一 ID

const faunadb = require('faunadb');          // For accessing FaunaDB
const shortid = require('shortid');          // Generate short unique URLs
const querystring = require('querystring');  // Help us parse the form data

// First we set up a new connection with our database.
// An environment variable helps us connect securely
// to the correct database.
const q = faunadb.query
const client = new faunadb.Client({
  secret: process.env.FAUNADB_SERVER_SECRET
})

现在,我们将向处理无服务器函数请求的代码中添加一些代码。处理程序函数将解析请求以获取从表单提交中所需的数据,然后为新的棒棒糖生成一个唯一 ID,然后将其作为新记录创建到数据库中。

// Handle requests to our serverless function
exports.handler = (event, context, callback) => {

  // get the form data
  const data = querystring.parse(event.body);
  // add a unique path id. And make a note of it - we'll send the user to it later
  const uniquePath = shortid.generate();
  data.lollyPath = uniquePath;

  // assemble the data ready to send to our database
  const lolly = {
    data: data
  };

  // Create the lolly entry in the fauna db
  client.query(q.Create(q.Ref('classes/lollies'), lolly))
    .then((response) => {
      // Success! Redirect the user to the unique URL for this new lolly page
      return callback(null, {
        statusCode: 302,
        headers: {
          Location: `/lolly/${uniquePath}`,
        }
      });
    }).catch((error) => {
      console.log('error', error);
      // Error! Return the error with statusCode 400
      return callback(null, {
        statusCode: 400,
        body: JSON.stringify(error)
      });
    });

}

让我们检查一下我们的进度。我们有了一种方法可以在数据库中创建新的棒棒糖页面。并且我们有一个自动构建,它为我们的每个棒棒糖生成一个页面。

为了确保为每个棒棒糖都有一套完整的预生成页面,我们应该在成功将新棒棒糖添加到数据库后触发重新构建。这做起来非常简单。由于我们的静态网站生成器,我们的构建已经自动化了。我们只需要一种触发它的方法。使用 Netlify,我们可以定义任意数量的构建钩子。它们是 Webhook,如果它们收到 HTTP POST 请求,它们将重建并部署我们的网站。这是我在 Netlify 的网站管理控制台中创建的一个

Netlify 构建钩子

要重新生成网站,包括数据库中记录的每个棒棒糖的页面,我们可以在将新数据保存到数据库后立即向此构建钩子发出 HTTP POST 请求。

这是执行此操作的代码

const axios = require('axios'); // Simplify making HTTP POST requests

// Trigger a new build to freeze this lolly forever
axios.post('https://api.netlify.com/build_hooks/5d46fa20da4a1b70XXXXXXXXX')
.then(function (response) {
  // Report back in the serverless function's logs
  console.log(response);
})
.catch(function (error) {
  // Describe any errors in the serverless function's logs
  console.log(error);
});

您可以在上下文中看到它,它已添加到数据库插入的成功处理程序中 完整的代码中

如果我们乐于等待构建和部署完成,然后再与预期接收者共享我们新棒棒糖的 URL,那么这一切都很好。但我们并不是很有耐心,当我们获得刚刚创建的棒棒糖的漂亮的新 URL 时,我们会希望立即共享它。

遗憾的是,如果在网站完成重新生成以包含新页面之前点击该 URL,我们将收到 404 错误。但幸运的是,我们可以利用这个 404 错误。

乐观 URL 路由和无服务器回退

使用自定义 404 路由,我们可以选择将每个对棒棒糖页面的失败请求发送到一个页面,该页面可以直接在数据库中查找棒棒糖数据。如果需要,我们可以在客户端 JavaScript 中执行此操作,但更好的方法是从无服务器函数动态生成一个准备查看的页面。

方法如下

首先,我们需要告诉所有希望获取棒棒糖页面的请求,如果请求为空,则改为转到我们的无服务器函数。我们通过在 Netlify 重定向配置中添加另一个规则来实现此目的

# unfound lollies should proxy to the API directly
[[redirects]]
  from = "/lolly/*"
  to = "/.netlify/functions/showLolly?id=:splat"
  status = 302

仅当对棒棒糖页面的请求未找到可供服务的静态页面时,才会应用此规则。它会创建一个临时重定向(HTTP 302)到我们的无服务器函数,该函数看起来像这样

const faunadb = require('faunadb');                  // For accessing FaunaDB
const pageTemplate = require('./lollyTemplate.js');  // A JS template litereal 

// setup and auth the Fauna DB client
const q = faunadb.query;
const client = new faunadb.Client({
  secret: process.env.FAUNADB_SERVER_SECRET
});

exports.handler = (event, context, callback) => {

  // get the lolly ID from the request
  const path = event.queryStringParameters.id.replace("/", "");

  // find the lolly data in the DB
  client.query(
    q.Get(q.Match(q.Index("lolly_by_path"), path))
  ).then((response) => {
    // if found return a view
    return callback(null, {
      statusCode: 200,
      body: pageTemplate(response.data)
    });

  }).catch((error) => {
    // not found or an error, send the sad user to the generic error page
    console.log('Error:', error);
    return callback(null, {
      body: JSON.stringify(error),
      statusCode: 301,
      headers: {
        Location: `/melted/index.html`,
      }
    });
  });
}

如果对任何其他页面(不在网站的 /lolly/ 路径中)的请求应返回 404,我们不会将该请求发送到我们的无服务器函数以检查棒棒糖。我们可以直接将用户发送到 404 页面。我们的 netlify.toml 配置允许我们通过在文件中的更下方添加回退规则来定义任意数量的 404 路由级别。文件中第一个成功的匹配项将被采用。

# unfound lollies should proxy to the API directly
[[redirects]]
  from = "/lolly/*"
  to = "/.netlify/functions/showLolly?id=:splat"
  status = 302

# Real 404s can just go directly here:
[[redirects]]
  from = "/*"
  to = "/melted/index.html"
  status = 404

我们完成了!我们现在拥有一个静态优先的网站,如果尚未将 URL 生成成静态文件,它将尝试使用无服务器函数动态呈现内容。

非常快速!

支持更大规模

我们每次创建新条目时都触发构建以重新生成棒棒糖页面的方法可能不会永远是最优的。虽然构建的自动化意味着重新部署网站非常简单,但当我们开始变得非常受欢迎时,我们可能希望开始限制和优化某些操作。(这仅仅是时间问题,对吧?)

没关系。当我们需要创建很多页面并且数据库添加的频率更高时,以下是一些需要考虑的事项

  • 我们可以将网站的重建作为计划任务,而不是为每个新条目触发重建。也许可以每小时或每天进行一次。
  • 如果每天构建一次,我们可能会决定只生成过去一天提交的新棒棒糖的页面,并缓存每天生成的页面以供将来使用。构建中的这种逻辑将帮助我们支持大量棒棒糖页面,而不会使构建时间过长。但我不会在这里讨论构建内缓存。如果您好奇,可以咨询 Netlify 社区论坛

通过将静态预生成资产与提供动态渲染的无服务器回退相结合,我们可以满足令人惊讶的广泛用例——同时避免需要配置和维护大量动态基础设施。

您还可以使用这种“静态优先”方法满足哪些其他用例?