跳到主要内容
Deno 2.4 发布,带来 deno bundle、bytes/text imports、稳定的 OTel 等功能
了解更多
A magical Denosaur.

单个 JavaScript 文件中的完整网站,续

早在四月,我们发布了“一个JavaScript文件里的整个网站”。然而,根据一些评论,我们意识到我们没有完全传达我们认为这项工作的独特之处——它的简洁性和性能,同时不牺牲用户对现代网站所期望的功能。

所以,这是一篇后续博客文章和一个新的 Playground,演示了一个功能齐全的 Web 应用,具有动态 API 端点、动态渲染、表单功能——所有这些都在一个 JavaScript 文件中。

Searching for a bagel

动态渲染

A screenshot of the landing page with dynamically rendered data.

着陆页显示您的位置、本地日期和时间。

我们先看看如何获取位置信息

const ip = (context.remoteAddr as Deno.NetAddr).hostname;
const res = await fetch("http://ip-api.com/json/" + ip);
const data: APIData = await res.json();
return ssr(() => <Landing {...data} />);

首先我们获取 IP 地址,在 router 的路由回调的第二个参数中可以获取。一旦我们有了 IP 地址,我们就会向 IP-API 发送 HTTP 请求,它会返回位置数据,例如城市和国家。我们将这些数据传递给 <Landing> 组件,并在那里进行渲染。

为了显示本地日期和时间,我们在组件中将 Date 构造函数与 timezone 作为参数进行渲染。请注意,timezone 是 IP 地址响应的一部分,与 citycountry 一起。

function Landing({
  country,
  city,
  timezone,
}: APIData) {
  return (
    <div class="min-h-screen p-4 flex gap-12 flex-col items-center justify-center">
      <h1 class="text-2xl font-semibold">
        Welcome to Bagel Search
      </h1>
      <p class="max-w-prose">
        It's currently {new Date().toLocaleString("en-US", {
          dateStyle: "full",
          timeStyle: "medium",
          timeZone: timezone,
        })} in {city}, {country}—the perfect time and place to look up a bagel.
      </p>
      <a
        href="/search"
        class="px-4 py-2.5 rounded-md font-medium leading-none bg-gray-100 hover:bg-gray-200"
      >
        Click here to search for bagels
      </a>
    </div>
  );
}

动态路由

A bagel page

为了展示动态路由,我们为每个百吉饼动态生成了一个遵循以下模式的路由:/bagels/:id

正如上一篇文章中提到的,router 在底层使用 URLPattern,因此我们可以使用适当的 URLPattern 语法进行模式匹配。在这种情况下,我们有 /bagels/:id,这意味着它将匹配任何以 /bagels/ 开头后跟任何有效值(例如 /bagels/foo)的路径,但它将无法匹配任何子路径,如 /bagels/foo/bar

serve(router(
  {
    "/bagels/:id": (_req, _context, matches) => {
      return ssr(() => <Bagel id={matches.id} />);
    },
    // Other routes removed in this example for simplification.
  },
));

我们可以通过 matches.id 从路径中获取 :id,因为 router 会在 matches 中创建一个以 id 为键、字符串为值的键值对。

然后,我们将 id 传递给 <Bagel> 组件。

function Bagel({ id }: { id: string }) {
  const name = id.replaceAll("-", " ");
  const bagel = bagels.find((bagel) =>
    bagel.name.toLowerCase() === name.toLowerCase()
  );

  if (bagel === undefined) {
    return (
      <div>
        The bagel '{name}' does not exist
      </div>
    );
  }

  return (
    <div class="min-h-screen p-4 flex flex-col items-center justify-center">
      <div class="w-3/4 lg:w-1/4">
        <div class="w-full bg-gray-200 rounded-lg overflow-hidden">
          <img
            src={bagel.image}
            class="w-full object-center object-cover"
            alt={bagel.name}
          />
        </div>
        <div class="mt-3 flex items-center justify-between">
          <h1 class="font-semibold">{bagel.name}</h1>
          <p class="text-lg font-medium text-gray-900">
            ${bagel.price.toFixed(2)}
          </p>
        </div>
        <p class="mt-1 text-gray-600">
          {bagel.description}
        </p>
        <div class="mt-3 flex items-center justify-between">
          <div>
            <a href="/" class="underline text-blue-400">Home</a>
          </div>
          <div>
            <a href="/search" class="underline text-blue-400">Back to Search</a>
          </div>
        </div>
      </div>
    </div>
  );
}

首先,我们将 id 进行 slug 化处理,使其更具可读性。接下来,我们使用该 slug 与数据中百吉饼名称的 slug 化版本进行匹配。

如果没有找到百吉饼,我们返回一个错误。否则,我们渲染匹配到的百吉饼的信息。

表单功能

“这都很棒,但我怎么才能找到有哪些百吉饼呢?” 好问题!

Searching for egg and ham in the form

我们添加了一个 /search 页面,其中包含一个文本输入框,您可以在其中输入内容。按下回车键后,页面上的结果将根据您的输入进行筛选。

这是通过使用表单实现的。默认情况下,<form> 标签使用 URL 编码的搜索参数作为值。当提交表单时(在我们的例子中,通过按下回车键),它会获取 <input> 标签的 name 属性和值,并将其编码为搜索参数。

例如,我们的文本输入框的属性是 name="search"。如果我们写入 foo 并提交,它会通过附加查询字符串 ?search=foo 重定向到当前页面。我们还将表单方法设置为 GET,这样它就会发起 GET 请求而不是 POST 请求。

然后,我们的路由只需要解析查询字符串

serve(router(
  {
    "/search": (req) => {
      const search = new URL(req.url).searchParams.get("search");
      return ssr(() => <Search search={search ?? ""} />);
    },
  },
));

然后,我们将 search 值传递给 <Search> 组件

function Search({ search }: { search: string }) {
  const foundBagels = bagels.filter((bagel) =>
    bagel.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <form
      method="get"
      class="min-h-screen p-4 flex gap-8 flex-col items-center justify-center"
    >
      <input
        type="text"
        name="search"
        value={search}
        class="h-10 w-96 px-4 py-3 bg-gray-100 rounded-md leading-4 placeholder:text-gray-400"
        placeholder="Search and press enter..."
      />
      <output name="result" for="search" class="w-10/12 lg:w-1/2">
        <ul class="space-y-2">
          {foundBagels.length > 0 &&
            foundBagels.map((bagel) => (
              <li class="hover:bg-gray-100 p-1.5 rounded-md">
                <a href={`/bagels/${bagel.name.replaceAll(" ", "-")}`}>
                  <div class="font-semibold">{bagel.name}</div>
                  <div class="text-sm text-gray-500">{bagel.description}</div>
                </a>
              </li>
            ))}
          <li>
            {foundBagels.length === 0 &&
              <div>No results found. Try again.</div>}
          </li>
        </ul>
      </output>
    </form>
  );
}

我们过滤掉所有不包含 search 值的百吉饼并显示它们。为了获得无缝的用户体验,我们还将 <input> 的值设置为 search

下一步是什么?

编程意味着管理复杂性。虽然构建网站的方法不胜枚举,但代码越简单、越清晰,编程就越容易、越快,也越有趣。

这里提到的所有内容都可以在这个playground中查看。