A Simple Pre-Rendered Web App Using Vue + Nuxt

Michael Lynch

使用 Vue + Nuxt 构建一个简单的预渲染 Web 应用

在这篇文章中,我将向你展示如何使用 Vue 和 Nuxt 预渲染(预先渲染)页面。这种方法结合了 Vue 便捷的开发体验,同时不会牺牲社交分享或搜索引擎优化等关键功能。

本教程假定你没有 Vue 或 Nuxt 的使用经验。我会在讲解过程中说明所有内容。

Vue 的问题

和 Angular、React 一样,Vue 是用于构建单页应用(single-page apps,SPA)的框架。传统网站要求浏览器每次用户点击站内链接时都下载一个全新的页面,而 SPA 将所有内容保留在同一个页面中。当用户在网站中导航时,JavaScript 只需绘制新页面,而不必再次从服务器下载全部内容。这样可以减少用户浏览器与 Web 服务器之间缓慢的网络请求,从而带来快速流畅的用户体验。

Vue 响应迅速,但代价是你对页面初始 HTML 的控制更少。当浏览器从服务器获取 SPA 时,收到的 HTML 大致如下:

<html>
<head>
  <title>My Awesome Website</title>
</head>
<body>
  <div id="app"></div>
  <!-- app.js populates the rest of the page after the browser executes the script. -->
  <script type="text/javascript" src="app.js">
</body>
</html>

因为它是一个单页应用,所以这个 HTML 桩对网站上的每个页面都相同。换句话说,无论用户访问 yoursite.com/about 还是 yoursite.com/contact,服务器为这两个页面发送的都是同一个 HTML 桩。JavaScript 负责解析路径,并在用户的浏览器中执行后绘制相应的页面。

动态页面渲染是一项很棒的创新,能让网站导航更快,但当你将网站连接到社交网络或搜索引擎时,它也会带来一些问题。

SPA 问题 #1:社交分享

我在 Twitter 上分享博客文章时,显示效果如下:

富媒体 Twitter 卡片示例

使用 Open Graph 标签,让 Twitter 为我的文章生成富媒体卡片。

Twitter 根据页面中遵循 Open Graph 标准的 HTML 标签生成这张卡片。例如,要指定卡片中的图片,我会添加如下标签:

<meta property="og:image" content="https://mtlynch.io/post-42/cover.jpg" />

如果你的网站是 SPA,那么所有页面共享同一个 HTML 骨架,因此也共享同一组 Open Graph 标签。Twitter 和 Facebook 等主流社交网络要求在执行任何 JavaScript 之前就存在 Open Graph 标签。结果是,你无法为网站上的不同页面创建各自独有的 Twitter 卡片或 Facebook 卡片。

SPA 问题 #2:搜索引擎优化(SEO)

与社交网站不同,搜索引擎确实会使用 JavaScript 渲染网站。问题在于,它们无法完美地做到这一点

许多网站会使用 JavaScript,在用户浏览页面时持续更新页面内容。从 Google 的角度来看,页面什么时候才算渲染“完成”并可以编入索引?对于普通 SPA,Google 会尝试将你的页面编入索引,但你无法保证它能正确完成索引。

Nuxt 来解决问题

在现代 Web 中,社交网络和 SEO 相当重要,因此如果使用 Vue 意味着你的应用无法与这些服务完整集成,那就太令人失望了。

Nuxt.js 徽标

Nuxt 是解决这一问题的框架。它在 Vue 之上增加了一层,将浏览器的一部分工作移回服务器。Nuxt 不再只发送一个 HTML 桩并等待客户端 JavaScript 渲染所有内容,而是会在服务器端预处理页面,生成渲染得更完整的 HTML。

服务器端渲染的问题

大多数人会在 Web 服务器上运行 Nuxt,这称为“server-side rendering(服务器端渲染)”。当用户向服务器请求页面时,Nuxt 会在服务器端即时构建页面,然后发送到用户的浏览器。

服务器端渲染将浏览器的一部分工作转移给服务器,因此可以缩短应用的初始页面加载时间。但如果你只是想填充几个用于社交分享和 SEO 的 HTML 标签,那么在技术栈中加入 Nuxt 和一整套 Node.js 服务器就太不划算了。

SPA 最大的优势之一是它们只由静态 HTML、CSS 和 JavaScript 组成,因而完全不需要应用服务器。Google Cloud Storage 和 Amazon S3 这样的简单文件托管服务就可以托管标准 SPA。如果使用服务器端渲染,你就必须从静态文件托管升级到完整的应用服务器,这会带来更高的成本和复杂度。

幸运的是,服务器端渲染还有一种替代方案:预渲染。Nuxt 不会响应 HTTP 请求按需渲染页面,而是直接提前渲染网站上的每个页面。这个过程会生成静态文件,因此你仍然可以在任何能够托管标准 SPA 的地方托管应用。

你应该使用预渲染吗?

预渲染并不适合所有情况。你需要判断应用的需求,以及这些需求是否要求使用预渲染或服务器端渲染,或者是否应该继续使用普通 Vue。下面列出了一些优缺点,帮助你决定何时采用预渲染。

预渲染的优点

  • 允许你为网站上的每个页面使用独有的社交分享卡片
  • 改善页面加载时间
    • 标准 SPA 必须等到浏览器下载并执行 JavaScript 后才开始渲染页面。使用预渲染时,用户在浏览器执行任何 JavaScript 之前就能看到页面。

预渲染的缺点

  • 相比标准 Vue,复杂度更高
    • 虽然预渲染比在 Node 服务器上运行 Nuxt 简单,但仍然比运行完全在客户端执行的 Vue 应用复杂。
    • 使用预渲染时,你必须在脑中跟踪代码是在服务器端还是客户端运行,以及代码执行时有哪些上下文可用。
  • 无法生成用户专属页面
    • 预渲染要求你在构建页面时就知道所有页面路由。它明确不支持动态路由
    • 如果你的网站包含用户生成的内容,并且希望用户加入后拥有自己的 URL(例如 yoursite.com/users/michael123),那么使用预渲染无法实现这一点。
    • 你可以将部分路由移到 URL 查询参数中来绕过这一限制(例如 yoursite.com/users?id=michael123),然后在客户端获取动态数据,但你仍然无法为这些页面生成独有的社交分享标签。

一个预渲染的“Hello, world”

为了演示预渲染,我将用三个文件展示一个基本的预渲染“Hello, world!”应用。

唯一的前置条件是 Node.js。我使用的是 Node v12.13.1,这是本文撰写时最新的稳定版本。

pages/index.vue

第一个文件定义 Web 应用中的一个页面。pages/ 文件夹对 Nuxt 有特殊含义。它会为 pages/ 文件夹中找到的每个 .vue 文件预渲染单独的页面。index.vue 这个名称表示它是根页面,因此当用户未指定任何路径时看到的就是它。

index.vue 会生成一个简单的“Hello, world!”页面,其中显示欢迎消息和一个按钮。为了展示 Vue 的一些客户端功能,用户每点击一次按钮,按钮文字就会更新。

<template>
  <div>
    <h1>Hello, world!</h1>
    <p>I'm an example of a pre-rendered Vue webpage.</p>
    <button v-on:click="count++">I have been clicked {{ count }} times</button>
  </div>
</template>

<script>
  export default {
    data: function () {
      return {
        count: 0,
      };
    },
  };
</script>

package.json

package.json 文件会告诉 Node.js 如何构建此应用:

{
  "name": "hello-world-vue-pre-rendered",
  "dependencies": {
    "nuxt": "latest"
  },
  "scripts": {
    "dev": "nuxt --port 3600",
    "generate": "nuxt generate"
  }
}

nuxt.config.js

最后,Nuxt 要求提供一个配置文件,即使它是空的:

// Even though we have no Nuxt settings, this file is required.

运行“Hello, world”

你可以在 Codesandbox 上运行此应用:

或者,你也可以使用以下命令在本地计算机上运行应用:

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered
git checkout step-1

npm install
npm run dev

应用会运行在 http://localhost:3600

预渲染应用

运行 npm run dev 时,你使用的是服务器端渲染。Node 会运行一个本地开发服务器,并在你请求页面时按需生成页面。

但我答应要展示的是预渲染页面。对于预渲染页面,你甚至不需要 Web 服务器,因为它只是一组静态文件。

要预渲染应用,请运行以下命令:

npm run generate

检查 dist/ 文件夹,你会看到 Nuxt 已经预渲染了页面:

$ find ./dist/ -type f
./dist/.nojekyll
./dist/200.html
./dist/index.html
./dist/_nuxt/7cef7880379068a94897.js
./dist/_nuxt/b10d0692e6306468ee9f.js
./dist/_nuxt/cae55ee8b1125819f113.js
./dist/_nuxt/ee10340617a3beab9da2.js
./dist/_nuxt/LICENSES

你可以通过简单的 HTTP 服务器查看这些文件,例如 Python2 的 SimpleHTTPServer:

cd dist
python -m SimpleHTTPServer 8123

随后 Python 会启动一个 Web 服务器,让你可以在 http://localhost:8123 查看预渲染的应用。之后我会向你展示如何将此应用发布到静态文件托管服务。

添加 About 页面

为了让事情更有趣,我会为这个应用添加第二个页面。

pages/about.vue

这个页面使用 Vue hooks 来显示页面是如何渲染的。我会在下面的内容中更详细地解释代码。

<template>
  <div>
    <h1>About this Build</h1>
    <p v-if="buildTime">
      Nuxt pre-rendered this page at
      <b>{{ buildTime }}</b> (before the browser ever saw it).
    </p>
    <template v-else>
      <p>
        Vue generated this page client-side because you navigated here from
        another route on the same site.
      </p>
      <p>
        <a href="/about">Refresh the page</a> to see the pre-rendered version.
      </p>
    </template>
    <p>
      The browser loaded this page at
      <b>{{ loadTime }}</b>.
    </p>
    <p><nuxt-link to="/">Home</nuxt-link></p>
  </div>
</template>

<script>
  export default {
    asyncData() {
      // Don't re-evaluate buildTime when the client loads this page in the
      // browser.
      if (!process.client) {
        return {
          buildTime: new Date().toUTCString(),
        };
      }
    },
    // Vue evaluates data variables at page render time and again every time the
    // browser loads this page.
    data: function () {
      return {
        loadTime: new Date().toUTCString(),
      };
    },
  };
</script>

下面是 About 页面的在线版本

理解 About 页面的两个版本

About 页面展示了 Nuxt 和 Vue 如何协作创建预渲染页面。根据你在网站中的导航方式,你应该会看到这个页面的两个版本。

About 页面不同版本的截图

根据你进入页面的方式不同,About 页面会显示不同的信息。

如果你从 /about 页面开始,应该会看到左侧的版本。如果你从根页面开始,然后点击“about page”链接,应该会看到右侧的版本。

为什么会看到两个不同版本的页面?答案就在 asyncData hook 中。这个函数会在两个时机执行:

  1. (服务器端)Nuxt 预渲染页面时
  2. (客户端)浏览器从网站的其他位置导航到此页面时

下面再次给出它的定义:

asyncData() {
  // Don't re-evaluate buildTime when the client loads this page in the
  // browser.
  if (!process.client) {
    return {
      buildTime: new Date().toUTCString(),
    };
  }
},

Nuxt 预渲染网站时,服务器会执行 asyncData 方法。在服务器环境中,process.client 为 null,因此它会将 buildTime 设置为当前时间,并在预渲染页面 HTML 时使用这个变量。

当你从网站上的其他页面导航到 /about 路径时,浏览器会在页面加载时执行 asyncData 方法。此时由于代码在客户端运行,process.client 不再是 null,因此该方法不会定义 buildTime,Vue 会在 buildTime 未定义的情况下渲染页面模板:

<p v-if="buildTime">...</p>
<template v-else>
  <p>
    Vue generated this page client-side because you navigated here from another
    route on the same site.
  </p>
  <p><a href="/about">Refresh the page</a> to see the pre-rendered version.</p>
</template>

预渲染只针对第一个页面

About 页面展示了预渲染的一个微妙之处:Nuxt 只会预渲染用户访问的第一个页面。此后,Vue 会像普通 SPA 一样运行,用户在网站内导航时,页面始终由客户端重新绘制。这是件好事,因为它意味着你的应用既保留了 Vue 页面间即时导航的能力,又不会牺牲与要求服务器端渲染的服务之间的兼容性。

在本地运行 About 页面

要试用 About 页面,请运行以下命令:

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered

npm install
npm run dev

当你导航到 https://localhost:3600/about 时,会注意到构建时间和加载时间大致相同。这是因为运行 npm run dev 时,Nuxt 使用服务器端渲染及时创建页面。

使用服务器端渲染的 About 页面截图

npm run dev 会在用户请求时渲染页面,因此构建时间和加载时间相同。

与只生成一次页面并持续提供同一页面的预渲染不同,服务器端渲染会在用户每次访问时生成页面的新版本。

发布应用

使用预渲染时,你不需要 Node.js 服务器来托管应用。你只需要一个支持静态文件托管的服务。

下面是使用几个热门服务提供商发布静态文件的说明:

源代码

此示例的全部代码都以 MIT license 发布在 GitHub 上:

功能更丰富的示例

如果你正在使用 Vue 和 Nuxt 构建实际应用,那么你需要的功能肯定不只是两个预渲染页面。我创建了一个模板项目 pre-vue,其中包含 SEO 和社交分享所需的全部样板代码:

它具备以下功能:

  • 生成 robots.txt 文件
  • 生成站点地图
  • 支持为每个页面设置独有的 <title> 标签以及其他与 SEO 相关的 <meta> 标签
  • 为每个页面添加独有的 Open Graph 标签
  • 添加 Google Analytics 支持
  • 添加 favicon
  • 处理 404 错误

我使用 pre-vue 模板重写了 Zestful 演示网站,它之前是一个 Angular SPA。README 解释了如何使用 pre-vue,但如果大家感兴趣,我会再发布一篇详细的博客文章,介绍其中的细节。

原文由 Michael Lynch 发布

本文章由 openai/gpt-5.6-luna 进行翻译