NuxtJS-Web-开发实用指南-全-

NuxtJS Web 开发实用指南(全)

原文:zh.annas-archive.org/md5/95454EEF6B1A13DFE0FAD028BE716A19

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Nuxt.js(本书中将其称为 Nuxt)是建立在 Vue.js 之上的渐进式 Web 框架(本书中将其称为 Vue)用于服务器端渲染(SSR)。使用 Nuxt 和 Vue,构建通用和静态生成的应用程序比以往任何时候都更容易。本书将帮助您快速了解 Nuxt 的基础知识以及如何将其与最新版本的 Vue 集成,使您能够使用 Nuxt 和 Vue.js 构建整个项目,包括身份验证、测试和部署。您将探索 Nuxt 的目录结构,并通过使用 Nuxt 的页面、视图、路由和 Vue 组件以及编写插件、模块、Vuex 存储和中间件来创建您的 Nuxt 项目。此外,您还将学习如何使用 Koa.js(本书中将其称为 Koa)、PHP 标准建议(PSRs)、MongoDB 和 MySQL 从头开始创建 Node.js 和 PHP API 或数据平台,以及使用 WordPress 作为无头 CMS 和 REST API。您还将使用 Keystone.js 作为 GraphQL API 来补充 Nuxt。您将学习如何使用 Socket.IO 和 RethinkDB 创建实时 Nuxt 应用程序和 API,最后使用 Nuxt 从远程 API 生成静态站点并流式传输资源(图像和视频),无论是 REST API 还是 GraphQL API。

本书适合对象

这本书适用于任何想要构建服务器端渲染的 Vue.js 应用程序的 JavaScript 或全栈开发人员。对 Vue.js 框架的基本理解将有助于理解本书涵盖的关键概念。

本书内容

第一章《介绍 Nuxt》是您将了解 Nuxt 的主要特点的地方。您将了解今天有哪些类型的 Web 应用程序,以及 Nuxt 属于哪些类别。然后,您将在接下来的章节中了解您可以使用 Nuxt 做什么。

第二章《开始使用 Nuxt》是您将安装 Nuxt 的地方,使用脚手架工具,或者从头开始创建您的第一个基本 Nuxt 应用程序。您将了解 Nuxt 项目中的默认目录结构,配置 Nuxt 以适应您的项目,并理解资源服务。

第三章《添加 UI 框架》是您将添加自定义 UI 框架,例如 Zurb Foundation,Motion UI,Less CSS 等等,以使您在 Nuxt 中的 UI 开发更加轻松和有趣。

第四章,“添加视图、路由和过渡”,是您将在 Nuxt 应用程序中创建导航路由、自定义页面、布局和模板的地方。您将学习如何添加过渡和动画,创建自定义错误页面,自定义全局 meta 标签,并为单个页面添加特定标签。

第五章,“添加 Vue 组件”,是您将在 Nuxt 应用程序中添加 Vue 组件的地方。您将学习如何创建全局和局部组件并重用它们,编写基本和全局 mixin,并定义符合命名约定的组件名称。

第六章,“编写插件和模块”,是您将在 Nuxt 应用程序中创建和添加插件、模块和模块片段的地方。您将学习如何创建 Vue 插件并将其安装在您的 Nuxt 项目中,编写全局函数并注册它们。

第七章,“添加 Vue 表单”,是您将使用v-modelv-bind创建表单的地方,验证表单元素并通过使用修饰符进行动态值绑定。您还将学习如何使用 Vue 插件 VeeValidate,使前端验证变得更加简单。

第八章,“添加服务器端框架”,是您将使用 Koa 作为服务器端框架创建 API 来补充您的 Nuxt 应用程序的地方。您将学习如何安装 Koa 及其必要的 Node.js 包以创建一个完全可用的 API,并将其与您的 Nuxt 应用程序集成。此外,您还将学习如何在 Nuxt 中使用异步数据从 Koa API 获取数据,通过异步数据访问 Nuxt 上下文,监听查询变化,处理错误,并使用 Axios 作为请求 API 数据的 HTTP 客户端。

第九章,“添加服务器端数据库”,是您将使用 MongoDB 管理 Nuxt 应用程序的数据库的地方。您将学习如何安装 MongoDB,编写基本的 MongoDB 查询,向 MongoDB 数据库添加一些虚拟数据,将 MongoDB 与上一章的 Koa API 集成,然后从 Nuxt 应用程序中获取数据。

第十章,添加 Vuex 存储,是您将使用 Vuex 管理和集中存储 Nuxt 应用程序数据的地方。您将了解 Vuex 架构,使用存储的变异和操作方法来改变存储数据,当存储变得更大时如何以模块化的方式构建您的存储程序,以及如何在 Vuex 存储中处理表单。

第十一章,编写路由中间件和服务器中间件,是您将在 Nuxt 应用程序中创建路由中间件和服务器中间件的地方。您将学习如何使用 Vue Router 创建中间件,使用 Vue CLI 创建 Vue 应用程序,并使用 Express.js(本书中称为 Express)、Koa 和 Connect.js(本书中称为 Connect)作为服务器中间件。

第十二章,创建用户登录和 API 身份验证,是您将在 Nuxt 应用程序中为受限页面添加身份验证的地方,使用会话、cookies、JSON Web Tokens(JWTs)、Google OAuth 以及您在上一章中学到的路由中间件。您将学习如何使用 JWT 在后端进行身份验证,在 Nuxt 应用程序中在客户端和服务器端使用 cookies(前端身份验证),以及在后端和前端身份验证中添加 Google OAuth。

第十三章,编写端到端测试,是您将使用 AVA、jsdom 和 Nightwatch.js 创建端到端测试的地方。您将学习如何安装这些工具,设置测试环境,并为上一章中 Nuxt 应用程序的页面编写测试。

第十四章,使用 Linter、格式化程序和部署命令,是您将使用 ESLint、Prettier 和 StandardJS 来保持代码清洁、可读和格式化的地方。您将学习如何安装和配置这些工具以满足您的需求,并在 Nuxt 应用程序中集成不同的 linter。最后,您将学习如何使用 Nuxt 命令部署您的 Nuxt 应用程序,并了解发布应用程序的托管服务。

第十五章,使用 Nuxt 创建 SPA,您将学习如何在 Nuxt 中开发单页应用程序SPA),了解 Nuxt 中 SPA 与经典 SPA 的区别,并生成静态 SPA 以部署到静态托管服务器 GitHub Pages。

第十六章,为 Nuxt 创建一个与框架无关的 PHP API,您将使用 PHP 创建 API 来补充您的 Nuxt 应用程序。您将学习如何安装 Apache 服务器和 PHP 引擎,了解 HTTP 消息和 PHP 标准,将 MySQL 安装为您的数据库系统,为 MySQL 编写 CRUD 操作,通过遵守 PHP 标准创建与框架无关的 PHP API,然后将您的 API 与 Nuxt 应用程序集成。

第十七章,使用 Nuxt 创建实时应用程序,您将使用 RethinkDB、Socket.IO 和 Koa 开发实时 Nuxt 应用程序。您将学习如何安装 RethinkDB,介绍 ReQL,将 RethinkDB 集成到您的 Koa API 中,将 Socket.IO 添加到 API 和 Nuxt 应用程序中,最终将您的 Nuxt 应用程序转换为具有 RethinkDB changefeeds 的实时 Web 应用程序。

第十八章,使用 CMS 和 GraphQL 创建 Nuxt 应用程序,您将使用(无头)CMS 和 GraphQL 来补充您的 Nuxt 应用程序。您将学习如何将 WordPress 转换为无头 CMS,在 WordPress 中创建自定义文章类型并扩展 WordPress REST API。您将学习如何在 Nuxt 应用程序中使用 GraphQL,了解 GraphQL 模式和解析器,使用 Appolo Server 创建 GraphQL API,并使用 Keystone.js GraphQL API。此外,您还将学习如何安装和保护 PostgreSQL 和 MongoDB,使用 Nuxt 生成静态站点,并从远程 API 流式传输资源(图像和视频),无论是 REST API 还是 GraphQL API。

本书最大的收获

在整本书中,您将需要一个 Nuxt.js 的版本-最新版本,如果可能的话。所有代码示例都是在 Ubuntu 20.10 上使用 Nuxt 2.14.x 进行测试的。以下是本书的其他必要软件、框架和技术列表:

书中涵盖的软件/硬件 操作系统要求
Koa.js v2.13.0 任何平台
Axios v0.19.2 任何平台
Keystone.js v11.2.0 任何平台
Socket.IO v2.3.0 任何平台
MongoDB v4.2.6 任何平台
MySQL v10.3.22-MariaDB 任何平台
RethinkDB v2.4.0 Linux, macOS
PHP v7.4.5 任何平台
Foundation v6.6.3 任何平台
Swiper.js v6.0.0 任何平台
Node.js v12.18.2 LTS (至少 v8.9.0) 任何平台
NPM v6.14.7 任何平台

如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的潜在错误。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册网址为www.packt.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并按照屏幕上的说明进行操作。

下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-on-Nuxt.js-Web-Development。如果代码有更新,将在现有的 GitHub 存储库中进行更新。

我们还有来自丰富书籍和视频目录的其他代码包,可在**github.com/PacktPublishing/**上找到。快去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"然后,您可以在 .css 文件中创建过渡样式。"

代码块设置如下:

// pages/about.vue
<script>
export default {
  transition: {
    name: 'fade'

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都以如下形式书写:

$ npm i less --save-dev
$ npm i less-loader --save-dev

粗体:表示一个新术语,一个重要词,或者屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“选择手动选择功能以从提示你选择的选项中选择路由器,以选择你需要的功能。”

警告或重要提示会显示为这样。提示和技巧会显示为这样。

第一部分:你的第一个 Nuxt 应用程序

在本节中,我们将简要介绍 Nuxt,其特点,文件夹结构等。然后,我们将通过一些简单的步骤开始构建我们的第一个 Nuxt 应用程序,并集成 Nuxt 路由,配置,Vue 组件等。

本节包括以下章节:

  • 第一章,介绍 Nuxt

  • 第二章,开始使用 Nuxt

  • 第三章,添加 UI 框架

介绍 Nuxt

欢迎来到您的Nuxt.js Web 开发实践之旅。在本章中,我们将深入了解 Nuxt,看看构成这个框架的是什么。我们将带您了解 Nuxt 的特性,您将了解 Nuxt 所属的不同类型应用程序的优缺点。最后但同样重要的是,您将发现使用 Nuxt 作为通用 SSR 应用程序、静态站点生成器和单页面应用程序的巨大潜力。

在本章中,我们将涵盖以下主题:

  • 从 Vue 到 Nuxt

  • 为什么使用 Nuxt?

  • 应用程序类型

  • Nuxt 作为通用 SSR 应用程序

  • Nuxt 作为静态站点生成器

  • Nuxt 作为单页面应用程序

让我们开始吧!

第一章:从 Vue 到 Nuxt

Nuxt 是一个更高级的 Node.js Web 开发框架,用于创建可以以两种不同模式开发和部署的 Vue 应用程序:通用(SSR)或单页面应用程序(SPA)。此外,您可以在 Nuxt 中部署 SSR 和 SPA 作为静态生成的应用程序。尽管您可以选择 SPA 模式,但 Nuxt 的全部潜力在于其通用模式或用于构建通用应用程序的服务器端渲染(SSR)。通用应用程序用于描述可以在客户端和服务器端都执行的 JavaScript 代码。但是,如果您希望开发经典(或标准/传统)的 SPA,仅在客户端执行的 SPA,您可能希望考虑使用纯 Vue。

请注意,SPA 模式的 Nuxt 应用程序与经典 SPA 略有不同。您将在本书的后面和本章中简要了解更多信息。

Nuxt 是建立在 Vue 之上的,具有一些额外功能,如异步数据、中间件、布局、模块和插件,可以先在服务器端执行您的应用程序,然后再在客户端执行。这意味着应用程序通常比传统的服务器端(或多页面)应用程序渲染更快。

Nuxt 预装了以下软件包,因此您无需像在标准 Vue 应用程序中那样安装它们:

除此之外,Nuxt 使用 webpack 和 Babel 来编译和捆绑您的代码,使用以下 webpack 加载器:

简而言之,webpack 是一个模块打包工具,它将 JavaScript 应用程序中的所有脚本、样式、资产和图像捆绑在一起,而 Babel 是一个 JavaScript 编译器,它将下一代 JavaScript(ES2015+)编译或转译为浏览器兼容的 JavaScript(ES5),以便您可以在当前浏览器上运行您的代码。

有关 webpack 和 Babel 的更多信息,请分别访问webpack.js.org/babeljs.io/

webpack 使用他们称之为加载器的东西来预处理您通过 JavaScript import语句或require方法导入的文件。您可以编写自己的加载器,但在编译 Vue 文件时,您无需这样做,因为它们已经由 Babel 社区和 Vue 团队为您创建。我们将在下一节中发现 Nuxt 带来的伟大功能以及这些加载器贡献的功能。

为什么使用 Nuxt?

由于传统 SPA 和多页面应用MPA)的缺点,存在诸如 Nuxt 之类的框架。我们可以将 Nuxt 视为服务器端渲染 MPA 和传统 SPA 的混合体。因此,它被称为“通用”或“同构”。因此,能够进行服务器端渲染是 Nuxt 的定义特性。在本节中,我们将为您介绍 Nuxt 的其他突出特性,这将使您的应用开发变得简单而有趣。我们将首先介绍的功能允许您通过在文件中使用.vue扩展名来编写单文件 Vue 组件。

编写单文件组件

我们可以使用几种方法来创建 Vue 组件。全局 Vue 组件是通过使用Vue.component创建的,如下所示:

Vue.component('todo-item', {...})

另一方面,可以使用普通 JavaScript 对象创建本地 Vue 组件,如下所示:

const TodoItem = {...}

这两种方法在小型项目中使用 Vue 是可管理和可维护的,但是当你一次拥有大量具有不同模板、样式和 JavaScript 方法的组件时,对于大型项目来说,管理变得困难。

因此,单文件组件来拯救,我们只使用一个.vue文件来创建每个 Vue 组件。如果您的应用程序需要多个组件,只需将它们分开成多个.vue文件。在每个文件中,您可以只编写与该特定组件相关的模板、脚本和样式,如下所示:

// pages/index.vue
<template>
  <p>{{ message }}</p>
</template>

<script>
export default {
  data () {
    return { message: 'Hello World' }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

在这里,您可以看到我们有一个 HTML 模板,它从 JavaScript 脚本中打印消息,并且描述模板的 CSS 样式,全部在一个.vue文件中。这使得您的代码更加结构化、可读和可组织。很棒,不是吗?这只能通过vue-loader和 webpack 实现。在 Nuxt 中,我们只在.vue文件中编写组件,无论它们是/components//pages/还是/layouts/目录中的组件。我们将在第二章中更详细地探讨这一点,开始使用 Nuxt。现在,我们将看一下 Nuxt 功能,它允许您直接编写 ES6 JavaScript。

编写 ES2015+

Nuxt 在不需要您担心配置和安装 Babel 在 webpack 的情况下,即可编译您的 ES6+代码。这意味着您可以立即编写 ES6+代码,并且您的代码将被编译为可以在旧版浏览器上运行的 JavaScript。例如,当使用asyncData方法时,您经常会看到以下解构赋值语法:

// pages/about.vue
<script>
export default {
  async asyncData ({ params, error }) {
    //...
  }
}
</script>

在前面的代码中,使用解构赋值语法将 Nuxt 上下文中的属性解包到不同的变量中,以便我们可以在asyncData方法中使用这些变量进行逻辑处理。

有关 Nuxt 上下文和 ECMAScript 2015 功能的更多信息,请分别访问nuxtjs.org/api/contextbabeljs.io/docs/en/learn/

在 Nuxt 中编写 ES6 只能通过babel-loader和 webpack 实现。在 Nuxt 中,您可以编写更多内容,包括async函数、await运算符、箭头函数、import语句等。那么 CSS 预处理器呢?如果您使用 Sass、Less 或 Stylus 等流行的 CSS 预处理器编写 CSS 样式,但如果您是 Sass 用户而不是 Less 用户或 Stylus 用户,Nuxt 是否支持它们中的任何一个?简短的答案是是。我们将在下一节中找出这个问题的长答案。

使用预处理器编写 CSS

在 Nuxt 中,您可以选择喜欢的 CSS 预处理器来编写应用程序的样式,无论是 Sass、Less 还是 Stylus。它们已经在 Nuxt 中为您预配置。您可以在github.com/nuxt/nuxt.js/blob/dev/packages/webpack/src/config/base.js查看它们的配置。因此,您只需要在 Nuxt 项目中安装预处理器及其 webpack 加载程序。例如,如果您想将 Less 作为 CSS 预处理器,只需在 Nuxt 项目中安装以下依赖项:

$ npm i less --save-dev
$ npm i less-loader --save-dev

然后,您可以通过在<style>块中将lang属性设置为"less"来开始编写您的 Less 代码,如下所示:

// pages/index.vue
<template>
  <p>Hello World</p>
</template>

<style scoped lang="less">
@align: center;
p {
  text-align: @align;
}
</style>

从这个例子中,您可以看到在 Nuxt 中编写现代 CSS 样式就像在 Nuxt 中编写现代 JavaScript 一样容易。您只需要安装您喜欢的 CSS 预处理器及其 webpack 加载程序。在本书的后续章节中,我们将使用 Less,但现在让我们找出 Nuxt 提供了哪些其他功能。

有关这些预处理器及其 webpack 加载程序的更多信息,请访问以下链接:

尽管 PostCSS 不是预处理器,但如果您想在 Nuxt 项目中使用它,请访问提供的指南nuxtjs.org/faq/postcss-plugins

使用模块和插件扩展 Nuxt

Nuxt 是建立在模块化架构之上的。这意味着您可以使用无数的模块和插件来扩展它,适用于您的应用程序或 Nuxt 社区。这也意味着您可以从 Nuxt 和 Vue 社区中选择大量的模块和插件,这样您就不必为您的应用程序重新发明它们。这些链接如下:

模块和插件只是 JavaScript 函数。现在不用担心它们之间的区别;我们将在第六章中讨论这个问题,编写插件和模块

在路由之间添加过渡

与传统的 Vue 应用程序不同,在 Nuxt 中,您不必使用包装器<transition>元素来处理元素或组件上的 JavaScript 动画、CSS 动画和 CSS 过渡。例如,如果您想在导航到特定页面时应用fade过渡,您只需将过渡名称(例如fade)添加到该页面的transition属性中:

// pages/about.vue
<script>
export default {
  transition: {
    name: 'fade'
  }
}
</script>

然后,你可以在.css文件中创建过渡样式:

// assets/transitions.css
.fade-enter,
.fade-leave-to {
  opacity: 0;
}

.fade-leave,
.fade-enter-to {
  opacity: 1;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 3s;
}

当导航到/about路由时,“fade”过渡将自动应用于about页面。很酷,不是吗?如果此时代码或类名看起来有点令人不知所措,不要担心;我们将在第四章中更详细地了解和探索这个过渡特性。

此外,与传统的 Vue 应用程序不同,您可以直接管理应用程序的<head>块,而无需安装额外处理它的 Vue 包vue-meta。您只需通过head属性向任何页面添加所需的<title><meta><link>数据。例如,您可以通过应用程序的 Nuxt 配置文件管理全局<head>元素:

// nuxt.config.js
export default {
  head: {
    title: 'My Nuxt App',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 'My Nuxt app is 
       about...' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  }
}

Nuxt 将为您将此数据转换为 HTML 标记。同样,我们将在第四章中更详细地了解和探索此功能,添加视图、路由和过渡

使用 webpack 捆绑和拆分代码

Nuxt 使用 webpack 将您的代码捆绑、缩小并拆分为可以加快应用程序加载时间的块。例如,在一个简单的 Nuxt 应用程序中有两个页面,index/home 和 about,您将为客户端获得类似的块:

Hash: 0e9b10c17829e996ef30 
Version: webpack 4.43.0 
Time: 4913ms 
Built at: 06/07/2020 21:02:26 
                         Asset       Size  Chunks                         Chunk Names 
../server/client.manifest.json   7.77 KiB          [emitted]               
                      LICENSES  389 bytes          [emitted]               
                app.3d81a84.js   51.2 KiB       0  [emitted] [immutable]  app 
        commons/app.9498a8c.js    155 KiB       1  [emitted] [immutable]  commons/app 
commons/pages/index.8dfce35.js   13.3 KiB       2  [emitted] [immutable]  commons/pages/index 
        pages/about.c6ca234.js  357 bytes       3  [emitted] [immutable]  pages/about 
        pages/index.f83939d.js   1.21 KiB       4  [emitted] [immutable]  pages/index 
            runtime.3d677ca.js   2.38 KiB       5  [emitted] [immutable]  runtime 
 + 2 hidden assets 
Entrypoint app = runtime.3d677ca.js commons/app.9498a8c.js app.3d81a84.js 

您将为服务器端获取的块如下所示:

Hash: 8af8db87175486cd8e06 
Version: webpack 4.43.0 
Time: 525ms 
Built at: 06/07/2020 21:02:27 
               Asset       Size  Chunks             Chunk Names 
      pages/about.js   1.23 KiB       1  [emitted]  pages/about 
      pages/index.js   6.06 KiB       2  [emitted]  pages/index 
           server.js   80.9 KiB       0  [emitted]  app 
server.manifest.json  291 bytes          [emitted]   
 + 3 hidden assets 
Entrypoint app = server.js server.js.map 

这些块和构建信息是在使用 Nuxt npm run build 命令构建应用以进行部署时生成的。我们将在第十四章中更详细地了解这一点,使用 Linters、Formatters 和部署命令

除此之外,Nuxt 还使用了 webpack 的其他出色功能和插件,比如静态文件和资源服务(资源管理),热模块替换,CSS 提取(extract-css-chunks-webpack-plugin),构建和监视时的进度条(webpackbar)等等。更多信息,请访问以下链接:

来自 webpack、Babel 和 Nuxt 本身的这些出色功能将使您的现代项目开发变得有趣且轻松。现在,让我们看看各种应用类型,看看在构建下一个 web 应用时,为什么应该或不应该使用 Nuxt。

应用类型

今天的 web 应用与几十年前的应用非常不同。在那些日子里,我们的选择和解决方案更少。今天,它们正在蓬勃发展。无论我们称它们为“应用”还是“应用程序”,它们都是一样的。在本书中,我们将称它们为“应用”。因此,我们可以将我们当前的 web 应用分类如下:

  • 传统的服务器端渲染应用

  • 传统的单页应用

  • 通用 SSR 应用

  • 静态生成的应用

让我们逐个了解它们,并了解其利弊。我们首先来看最古老的应用类型 - 传统的服务器端渲染应用。

传统的服务器端渲染应用

服务器端呈现是向浏览器客户端传递数据和 HTML 的最常见方法。在网络行业刚刚开始时,这是唯一的做事方式。在传统的服务器呈现的应用程序或动态网站中,每个请求都需要从服务器重新呈现新页面到浏览器。这意味着您将在每次发送请求到服务器时重新加载所有脚本、样式和模板。重新加载和重新呈现的想法一点也不吸引人。尽管如今可以通过使用 AJAX 来减轻一些重新加载和重新呈现的负担,但这会给应用程序增加更多复杂性。

让我们来看看这些类型应用程序的优缺点。

优势

  • **更好的 SEO 性能:**因为客户端(浏览器)得到了包含所有数据和 HTML 标记的完成页面,特别是属于页面的元标记,搜索引擎可以爬取页面并对其进行索引。

  • **更快的初始加载时间:**因为页面和内容是由服务器端脚本语言(如 PHP)在发送到客户端浏览器之前在服务器端呈现的,所以我们在客户端很快就能得到呈现的页面。此外,无需像传统的单页应用程序那样在 JavaScript 文件中编译网页和内容,因此应用程序在浏览器上加载更快。

缺点

  • **用户体验较差:**因为每个页面都必须重新呈现,这个过程在服务器上需要时间,用户必须等待直到在浏览器上重新加载所有内容,这可能会影响用户体验。大多数情况下,我们只希望在提供新请求时获得新数据;我们不需要重新生成 HTML 基础,例如导航栏和页脚,但仍然会重新呈现这些基本元素。我们可以利用 AJAX 来仅呈现特定组件,但这会使开发变得更加困难和复杂。

  • 后端和前端逻辑的紧密耦合:视图和数据通常在同一个应用程序中处理。例如,在典型的 PHP 框架应用程序中,如 Laravel,您可以在路由中使用模板引擎(如 Laravel Pug)渲染视图。或者,如果您正在为传统的服务器端渲染应用程序使用 Express,您可以使用模板引擎(如 Pug 或 vuexpress)来渲染视图。在这两个框架中,视图与后端逻辑耦合在一起,即使我们可以使用模板引擎提取视图层。后端开发人员必须知道每个特定路由或控制器要使用的视图(例如home.pug)。另一方面,前端开发人员必须在与后端开发人员相同的框架中处理视图。这给项目增加了更多复杂性。

传统的单页面应用程序(SPA)

与服务器端渲染应用程序相反,SPA 是客户端渲染(CSR)应用程序,它使用 JavaScript 在浏览器中渲染内容,而不需要在使用过程中重新加载新页面。因此,您不会将内容呈现到 HTML 文档中,而是从服务器获取基本的 HTML,然后在浏览器中使用 JavaScript 加载内容。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Vue App</title>
</head>
<body>
  <div id="app"></div>
  <script src="https://unpkg.com/vue/dist/vue.js" type="text/javascript"></script>
  <script src="/path/to/app.js"type="text/javascript"></script>
</body>
</html>

这是一个非常简单的 Vue 应用程序,其中您有一个容器<div>,只有app作为其 ID,里面没有其他内容,然后是两个<script>元素。第一个<script>元素将加载 Vue.js 库,而第二个将加载渲染应用程序内容的 Vue 实例。

// path/to/app.js
const app = new Vue({
  data: {
    greeting:'Hello World!'
  },
  template: '<p>{{ greeting }}</p>'
}).$mount('#app')

让我们来看看这种类型应用程序的优缺点。

优点:

  • 更好的用户体验:SPA 在初始加载后渲染内容时非常快速。大多数资源,如 CSS 样式、JavaScript 代码和 HTML 模板,在应用程序的整个生命周期中只加载一次。之后只是来回发送数据;基本的 HTML 和布局保持不变,从而提供了流畅和更好的用户体验。

  • 开发和部署更容易: 单页应用程序的开发相对容易,无需服务器和服务器端脚本语言。您可以从本地机器上简单地启动开发,使用file://URI。部署也更容易,因为它由 HTML、JavaScript 和 CSS 文件组成;您只需将它们放到远程服务器上,立即上线。

缺点:

  • 搜索引擎性能差: 单页应用程序通常是裸骨的单个 HTML 页面,大多数情况下没有标题和段落标签供搜索引擎爬虫爬取。SPA 内容是通过 JavaScript 加载的,而爬虫通常无法执行 JavaScript,因此 SPA 在搜索引擎优化方面通常表现不佳。

  • 初始加载时间慢: 大多数资源,如 CSS 样式、JavaScript 代码和 HTML 模板,在应用程序的整个生命周期中只加载一次,因此我们需要在开始时一次性加载大量这些资源文件。通过这样做,应用程序通常在初始加载时间方面变慢,特别是在大型单页应用程序中。

通用服务器端渲染应用(SSR)

正如我们在前一节中所学到的,传统的服务器端渲染应用程序和单页应用程序都有优点和缺点。编写单页应用程序有好处,但也有一些你会失去的东西:网络爬虫遍历您的应用程序的能力以及应用程序初始加载时的性能较慢。这与编写传统的服务器端渲染应用程序相反,还有一些你没有的东西,比如更好的用户体验和单页应用程序中客户端开发的乐趣。理想情况下,客户端和服务器端渲染可以平衡用户体验和性能。这就是通用服务器端渲染(SSR)的用武之地。

自从 2009 年 Node.js 发布以来,JavaScript 已经成为一种等同的语言。通过等同,我们指的是代码可以在客户端和服务器端都运行。等同(通用)JavaScript 可以被定义为客户端和服务器端应用程序的混合体。这是网页应用程序的一种新方法,以弥补传统 SSR 应用程序和传统 SPA 的缺点。这就是 Nuxt 所属的类别。

在通用 SSR 中,您的应用程序将首先在服务器端预加载,预渲染页面,并在切换到客户端操作其余寿命之前将呈现的 HTML 发送到浏览器。从头开始构建通用 SSR 应用程序可能会很繁琐,因为在实际开发过程开始之前需要大量的配置。这就是 Nuxt 的目标,它通过为您预设创建 SSR Vue 应用程序所需的所有配置来轻松实现。

尽管通用 SSR 应用程序在我们现代的 Web 开发中是一个很好的解决方案,但这些类型的应用程序仍然有优点和缺点。让我们来看看它们。

优点

  • **更快的初始加载时间:**在通用 SSR 中,JavaScript 和 CSS 被分割成块,资源被优化,并且页面在服务器端呈现后再提供给客户端浏览器。所有这些选项都有助于加快初始加载时间。

  • **更好的 SEO 支持:**由于所有页面在服务器端呈现时都带有适当的元内容、标题和段落,然后再提供给客户端,搜索引擎爬虫可以遍历页面,以提高应用程序的 SEO 性能。

  • **更好的用户体验:**通用 SSR 应用程序在初始加载后的工作方式类似于传统的 SPA,因为页面和路由之间的转换是无缝的。只有数据来回传输,而不重新渲染 HTML 内容持有者。所有这些功能都有助于提供更好的用户体验。

缺点

  • **需要 Node.js 服务器:**在服务器端运行 JavaScript 需要一个 Node.js 服务器,因此在使用 Nuxt 和编写应用程序之前必须设置服务器。

  • **复杂的开发:**在通用 SSR 应用程序中运行 JavaScript 代码可能会令人困惑,因为一些 JavaScript 插件和库只能在客户端运行,比如用于样式和 DOM 操作的 Bootstrap 和 Zurb Foundation。

静态生成的应用程序

静态生成的应用程序是通过静态站点生成器预先生成的,并存储为静态 HTML 页面在托管服务器上。Nuxt 带有一个nuxt generate命令,可以为您从您在 Nuxt 中开发的通用 SSR 或 SPA 应用程序生成静态页面。它在构建过程中为每个路由预渲染 HTML 页面到生成的/dist/文件夹中,如下所示:

-| dist/
----| index.html
----| favicon.ico
----| about/
------| index.html
----| contact/
------| index.html
----| _nuxt/
------| 2d3427ee2a5aa9ed16c9.js
------| ...

您可以将这些静态文件部署到静态托管服务器,而无需 Node.js 或任何服务器端支持。因此,当应用程序最初在浏览器上加载时 - 无论您请求的是哪个路由 - 您都将立即获得完整的内容(如果它是从通用 SSR 应用程序中导出的),之后应用程序将像传统的单页面应用程序一样运行。

让我们来看看这些类型应用程序的优势和劣势。

优势:

  • 快速的初始加载时间: 由于每个路由都被预先生成为具有自己内容的静态 HTML 页面,因此在浏览器上加载速度很快。

  • 有利于 SEO: 静态生成的 Web 应用程序允许您的 JavaScript 应用程序被搜索引擎完美地抓取,就像传统的服务器端渲染应用程序一样。

  • 部署更容易: 因为静态生成的 Web 应用程序只是静态文件,这使它们易于部署到静态托管服务器,如 GitHub Pages。

劣势:

  • 没有服务器端支持: 因为静态生成的 Web 应用程序只是静态 HTML 页面,并且仅在客户端上运行,这意味着没有运行时支持 Nuxt 的nuxtServerInit动作方法和 Node.js HTTP 请求和响应对象,这些仅在服务器端可用。所有数据将在构建步骤中预先呈现。

  • 没有实时渲染: 静态生成的 Web 应用程序适用于只提供静态页面的应用程序,这些页面在构建时预先呈现。如果您正在开发一个需要从服务器实时渲染的复杂应用程序,那么您应该使用通用 SSR 来充分利用 Nuxt 的全部功能。

从这些类别中,你可能已经发现 Nuxt 符合通用 SSR 应用程序和静态生成的应用程序。除此之外,它也符合单页面应用程序,但与传统的单页面应用程序不同,你将在第十五章“使用 Nuxt 创建单页面应用程序”中了解更多信息。

现在,让我们更好地了解 Nuxt 在本书中将要创建的应用程序类型。我们将从 Nuxt 作为通用 SSR 应用程序开始。

Nuxt 作为通用 SSR 应用程序

许多年前,我们有服务器端脚本语言,如 ASP、Java、服务器端 JavaScript、PHP 和 Python 来创建具有模板引擎的传统服务器端应用程序来渲染我们应用程序的视图。这导致了我们在前一节中经历的紧耦合的缺点。

因此,随着 Nuxt、Next(nextjs.org/)和 Angular Universal(angular.io/guide/universal)等通用 SSR 框架的兴起,我们可以充分利用它们的力量,通过替换模板引擎(如 Pug(pugjs.org/)、Handlebars(handlebarsjs.com/)、Twig(twig.symfony.com/)等)来彻底解耦视图和服务器端脚本应用。如果我们将 Nuxt 视为前端服务器端应用程序,Express(或其他)视为后端服务器端应用程序,我们可以看到它们如何完美地互补。例如,我们可以使用 Express 在 API 路由(例如/)上创建一个后端服务器端应用程序,以 JSON 格式提供数据,位于localhost:4000上。

{
  "message": "Hello World"
}

然后,在前端服务器端,我们可以使用 Nuxt 作为一个通用的 SSR 应用程序,在localhost:3000上运行,通过从 Nuxt 应用程序中的页面发送 HTTP 请求来消耗上述数据,如下所示:

// pages/index.vue
async asyncData ({ $http }) {
  const { message } = await $http.$get('http://127.0.0.1:4000')
  return { message }
}

现在,我们将 Nuxt 作为服务器和客户端来处理我们应用的视图和模板,而 Express 只处理我们的服务器端逻辑。我们不再需要模板引擎来呈现我们的内容。因此,也许我们不需要学习那么多模板引擎,也不需要担心它们之间的竞争,因为现在我们有了通用的 Nuxt。

我们将向您展示如何使用 Nuxt 和 Koa(另一个类似于 Express 的 Node.js 服务器端框架)创建跨域应用程序,详见第十二章,创建用户登录和 API 身份验证

请注意,在上述代码中,我们使用了 Nuxt HTTP 模块来发出 HTTP 请求。然而,在本书中,我们将主要使用原始的 Axios 或 Nuxt Axios 模块来进行 HTTP 请求。有关 Nuxt HTTP 模块的更多信息,请访问http.nuxtjs.org/

您还可以使用 Nuxt Content 模块作为无头 CMS,以便从 Markdown、JSON、YAML、XML 和 CSV 文件中提供应用程序内容,这些文件可以“本地”存储在您的 Nuxt 项目中。但是,在本书中,我们将使用和创建外部 API 来提供我们的内容,以避免我们在传统服务器端应用程序中发现的紧密耦合问题。有关 Nuxt Content 模块的更多信息,请访问content.nuxtjs.org/

Nuxt 作为静态站点生成器

尽管服务器端渲染是 Nuxt 的主要特性,但它也是一个静态站点生成器,可以在静态站点中预渲染您的 Nuxt 应用程序,就像静态生成的应用程序类别中提供的示例一样。它可能是传统单页面应用程序和服务器端渲染应用程序之间最好的结合。通过静态 HTML 内容获益,以获得更好的 SEO,您不再需要来自 Node.js 和 Nuxt 的运行时支持。但是,您的应用程序仍将像 SPA 一样运行。

更重要的是,在静态生成期间,Nuxt 具有一个爬虫,用于爬取应用程序中的链接以生成动态路由,并将它们的数据从远程 API 保存为payload.js文件,存储在/dist/文件夹内的/static/文件夹中。然后使用这些负载来提供最初从 API 请求的数据。这意味着您不再调用 API。这可以保护您的 API 免受公众和可能的攻击者的侵害。

您将学习如何在第十四章中使用远程 API 从 Nuxt 生成静态站点,以及在本书的最后一章第十八章中创建具有 CMS 和 GraphQL 的 Nuxt 应用程序。

Nuxt 作为单页面应用程序

如果您有任何原因不希望将 Nuxt 用作服务器端渲染应用程序,那么 Nuxt 非常适合开发单页面应用程序。正如我们在本章开头提到的,Nuxt 有两种开发应用程序的模式:universalspa。这意味着您只需在项目配置的mode属性中指定spa,我们将在下一章中更详细地探讨这一点。

因此,你可能会想,如果我们可以使用 Nuxt 开发 SPA,那为什么还要费心使用 Vue 呢?事实上,你从 Nuxt 开发的 SPA 与从 Vue 开发的 SPA 略有不同。你从 Vue 构建的 SPA 是传统的 SPA,而从 Nuxt 构建的 SPA 是“静态”SPA(让我们称之为 Nuxt SPA)——你的应用页面在构建时进行了预渲染。这意味着部署 Nuxt SPA 在技术上与静态生成 Nuxt 通用 SSR 应用是相同的——两者都需要相同的 Nuxt 命令:nuxt generate

这可能会让人感到困惑,你可能想问静态生成的 SSR 应用和静态生成的 SPA 之间有什么区别?区别非常明显——与静态生成的 SSR 应用相比,静态生成的 SPA 没有页面内容。静态生成的 SPA 是使用你的应用页面和“空”HTML 预渲染的,就像传统的 SPA 一样——没有页面内容。这很令人困惑,但请放心,我们将在本书的后续章节中弄清楚这一切。特别是,你将了解在 Nuxt 中开发 SPA 的权衡以及如何克服它们。

你将学习如何开发 Nuxt SPA,并在第十五章中使用远程 API 生成静态 Nuxt SPA,使用 Nuxt 创建 SPA

总结

干得好!你已经完成了进入 Nuxt 的旅程的第一章。在本章中,你了解了 Nuxt 框架的组成部分;即 Vue(Nuxt 的起源)、webpack 和 Babel。你了解了 Nuxt 提供的各种功能,比如你可以编写 Vue 单文件组件(.vue文件)、ES2015+ JavaScript(ES6)、使用预处理器的 CSS(Sass、Less、Stylus)。你还可以通过模块和插件扩展你的应用,为应用的路由添加过渡效果,管理<head>元素和应用中每个路由或页面的元内容。除此之外,你还涵盖了从 webpack 和 Babel 导入的大量出色功能,比如打包、压缩和代码分割。你还了解到,你可以从 Nuxt 社区获取大量插件和模块用于你的 Nuxt 项目。

除了这些强大的功能之外,您还了解了每种可用应用类型的优缺点:传统的服务器端渲染应用程序、传统的单页面应用(SPA)、通用服务器端渲染应用程序(SSR)和静态生成应用程序。您还了解到 Nuxt 应用实际上符合通用 SSR 应用程序和静态生成应用程序的类别。然后,您了解到 Nuxt 也符合单页面应用的类别,但与传统的 SPA 不同。最后,您还了解了如何在本书中更多地了解如何使用 Nuxt 来进行通用 SSR 应用程序、静态生成应用程序和单页面应用。

在下一章中,您将学习如何安装 Nuxt 并创建一个简单的 Nuxt 应用程序,并了解 Nuxt 脚手架工具提供的默认目录结构。您还将学习如何自定义您的 Nuxt 应用程序,并了解 Nuxt 中提供的资源。所以,请继续关注!

开始使用 Nuxt

本章将指导您完成从头开始安装 Nuxt 项目或使用 Nuxt 脚手架工具的过程。在开发 Nuxt 应用程序时,安装 Nuxt 是您应该做的第一件事。在本书中,我们将为所有示例应用程序使用 Nuxt 脚手架工具,因为它会自动生成必要的项目文件夹和文件(我们将在本章中探讨),但当然,您也可以从头开始进行小型应用程序开发。我们将介绍目录结构以及每个目录的用途和目的。如果您从头开始安装 Nuxt 项目,您仍需要了解目录结构和 Nuxt 将自动从您的项目中读取的官方目录。您还将学习如何配置 Nuxt 以满足您的应用程序特定的需求,即使 Nuxt 已经默认配置以涵盖大多数实际情况。因此,我们将指导您了解配置的要点。此外,我们将介绍 Nuxt 应用程序中的资源服务,特别是用于提供图像。

本章我们将涵盖的主题如下:

  • 安装 Nuxt

  • 了解目录结构

  • 了解自定义配置

  • 了解资源服务

第二章:技术要求

您应该熟悉以下术语:

  • JavaScript ES6

  • 服务器端和客户端开发基础知识

  • 应用程序编程接口(API)

支持的操作系统如下:

  • Windows 10 或更高版本,带有 PowerShell

  • 带有终端的 macOS(Bash 或 Oh My Zsh)

  • 具有终端的 Linux 系统(如 Ubuntu)

建议的跨平台软件如下:

安装 Nuxt

有两种简单的方法可以开始使用 Nuxt。最简单的方法是使用 Nuxt 脚手架工具create-nuxt-app,它会自动为您安装所有 Nuxt 依赖项和默认目录。另一种方法是仅使用package.json文件从头开始。让我们来了解如何做到这一点。

使用 create-nuxt-app

create-nuxt-app是 Nuxt 团队创建的一个脚手架工具,您可以使用它快速安装项目。您需要做的是在您喜欢的终端上使用npx来运行create-nuxt-app

$ npx create-nuxt-app <project-name>

npx 从 npm 5.2.0 开始默认安装,但您可以通过在终端上检查其版本来确保已安装:

$ npx --version
6.14.5

在安装 Nuxt 项目的过程中,您将被要求回答一些问题,以便与 Nuxt 集成,如下所示:

  • 选择一种编程语言:
JavaScript 
TypeScript 
  • 选择一个包管理器:
Yarn 
Npm 
  • 选择一个 UI 框架:
None
Ant Design Vue  
Bootstrap Vue 
...
  • 选择一个测试框架:
None
Jest
AVA
WebdriverIO 

让我们使用 npx 创建您的第一个 Nuxt 应用程序,名为first-nuxt。因此,请选择您机器上的本地目录,在该目录上打开终端,并运行npx create-nuxt-app first-nuxt。在安装过程中遇到类似之前提到的问题时,请选择JavaScript作为编程语言,Npm 作为包管理器,以及None作为 UI 框架和测试框架。然后,跳过其余的问题(只是不要选择任何选项),以便我们在需要时稍后添加它们。您的终端上应该有一个类似以下问题的问题列表,以及我们建议的相同选项:

**$ npx create-nuxt-app first-nuxt** 
create-nuxt-app v3.1.0
:: Generating Nuxt.js project in /path/to/your/project/first-nuxt 
? Project name: first-nuxt 
? Programming language: JavaScript 
? Package manager: Npm 
? UI framework: None 
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection) 
? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection) 
? Testing framework: None 
? Rendering mode: Universal (SSR / SSG) 
? Deployment target: Server (Node.js hosting) 
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection) 

对于有关渲染模式的问题,您应该选择Universal (SSR / SSG)。我们将在第十五章中涵盖单页面应用程序(SPA)的选项,使用 Nuxt 创建 SPA。在本书中的所有示例应用程序中,除了第十五章中的示例之外,我们将使用 SSR。我们还将在本书中使用npm作为我们的包管理器,因此请确保您选择此选项。安装完成后,我们可以启动它:

$ cd first-nuxt
$ npm run dev

该应用现在正在localhost:3000上运行。当您在您喜爱的浏览器中运行该地址时,您应该会看到 Nuxt 生成的默认索引页面。使用脚手架工具安装 Nuxt 项目是不是很容易?但有时您可能不需要像这样的完整安装;您可能只需要一个“最基本”的安装。如果是这样,请让我们在下一节中了解如何从头开始安装 Nuxt。

您可以在我们的 GitHub 存储库的/nuxt-packt/chapter-2/scaffolding/中找到此简单应用程序的源文件。

从头开始

如果您不想使用 Nuxt 脚手架工具,您可以使用package.json文件和npm为您安装 Nuxt 应用程序。让我们通过以下步骤了解如何操作:

  1. 在您的根项目中创建一个package.json文件:
{
  "name": "nuxt-app",
  "scripts": {
    "dev": "nuxt"
  }
}
  1. 通过 npm 在项目中安装 Nuxt:
$ npm i nuxt
  1. 在您的根项目中创建一个/pages/目录,然后在其中创建一个index.vue页面:
// pages/index.vue
<template>
  <h1>Hello world!</h1>
</template>
  1. 使用 npm 启动项目:
$ npm run dev

应用程序现在正在localhost:3000上运行。当你在你喜欢的浏览器中运行该地址时,你应该会看到你创建的带有Hello world!消息的索引页面。

然而,无论你选择“最基本”还是完整的堆栈选项,你都应该了解 Nuxt 运行应用程序所需的默认目录。因此,让我们在下一节中找出这些目录是什么。

你可以在我们的 GitHub 存储库的/nuxt-packt/chapter-2/scratch/中找到这个简单的应用程序。

理解目录结构

如果你成功使用create-nuxt-app脚手架工具安装了一个 Nuxt 项目,你应该在项目文件夹中得到以下默认目录和文件:

-| your-app-name/
---| assets/
---| components/
---| layouts/
---| middleware/
---| node_modules/
---| pages/
---| plugins/
---| static/
---| store/
---| nuxt.config.js
---| package.json
---| README.md

让我们逐个了解它们,并在接下来的章节中理解它们的用途。

资源目录

/assets/目录用于包含项目的资源,例如图片、字体和 Less、Stylus 或 Sass 文件,这些文件将由 webpack 编译。例如,你可能有一个 Less 文件,如下所示:

// assets/styles.less
@width: 10px;
@height: @width + 10px;

header {
  width: @width;
  height: @height;
}

webpack 将把前面的代码编译成你的应用程序的以下 CSS:

header {
  width: 10px;
  height: 20px;
}

我们将在本章后面讨论在该目录中提供图像的好处,并在本书中生成静态页面时经常使用该目录。

静态目录

/static/目录用于包含不希望被 webpack 编译或无法被编译的文件,例如 favicon 文件。如果你不想在/assets/目录中提供你的资源,比如图片、字体和样式,你可以将它们放在/static/目录中。该目录中的所有文件都直接映射到服务器根目录,因此可以直接在根 URL 下访问。例如,/static/1.jpg被映射为/1.jpg,因此可以通过以下方式访问它:

http://localhost:3000/1.jpg

我们将在本章后面讨论在/assets//static/目录之间提供图像的区别。请注意,当你使用 Nuxt 脚手架工具时,默认情况下会在该目录中得到一个favicon.ico文件,但你可以创建自己的 favicon 文件来替换它。

页面目录

/pages/目录用于包含应用程序的视图和路由。Nuxt 将读取并转换该目录内的所有.vue文件,并为你自动生成应用程序路由。例如,看下面的例子:

/pages/about.vue
/pages/contact.vue

Nuxt 将采用前面的文件名(不带.vue扩展名)并为你的应用程序创建以下路由:

localhost:3000/about
localhost:3000/contact

如果您通过create-nuxt-app安装 Nuxt,将会自动为您创建一个index.vue文件,并且您可以在localhost:3000上看到这个页面。

我们将在第四章中更详细地查看这个目录,添加视图、路由和过渡

布局目录

/layouts/目录用于包含应用程序的布局。当您使用 Nuxt 脚手架工具时,默认情况下会得到一个名为default.vue的布局。您可以修改这个默认布局或者在这个目录中添加新的布局。

我们将在第四章中更详细地查看这个目录,添加视图、路由和过渡

组件目录

/components/目录用于包含 Vue 组件。当您使用 Nuxt 脚手架工具时,默认情况下会得到一个名为Logo.vue的组件。这个目录中的.vue文件与/pages/目录中的文件的明显和重要区别在于,您不能为这个目录中的组件使用asyncData方法;但是,如果需要,您可以使用fetch方法来设置它们。您应该将小型和可重用的组件放在这个目录中。

我们将在第五章中更详细地查看这个目录,添加 Vue 组件

插件目录

/plugins/目录用于包含 JavaScript 函数,比如您想要在根 Vue 实例实例化之前运行的全局函数。例如,您可能想要创建一个新的axios实例,专门发送 API 请求到jsonplaceholder.typicode.com,并且您可能希望在全局范围内使用这个实例,而不是每次导入axios并创建一个新实例。您可以创建一个插件,将其注入和插入到 Nuxt 上下文中,如下所示:

// plugins/axios-typicode.js
import axios from 'axios'

const instance = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com'
})

export default (ctx, inject) => {
  ctx.$axiosTypicode = instance
  inject('axiosTypicode', instance)
}

然后,您可以通过调用$axiosTypicode在任何页面上使用这个axios实例,如下所示:

// pages/users/index.vue
export default {
  async asyncData ({ $axiosTypicode, error }) {
    let { data } = await $axiosTypicode.get('/users')
  }
}

我们将在第六章中更详细地查看这个目录,编写插件和模块

请注意,axios是一个我们在本书中经常使用的 HTTP 客户端。在导入前,您需要在项目目录中安装它。有关这个 Node.js 包的更多信息,请访问github.com/axios/axios

存储目录

/store/目录用于包含 Vuex 存储文件。您不需要在 Nuxt 中安装 Vuex,因为它已经与 Nuxt 一起提供。它默认情况下是禁用的,您只需在此目录中添加一个index.js文件即可启用它。例如,如果您想要一个名为auth的属性,可以在整个应用程序中访问。

您将在index.js文件中将该属性存储在state变量中,如下所示:

// store/index.js:
export const state = () => ({
  auth: null
})

我们将在第十章中更详细地查看此目录,添加 Vuex 存储

中间件目录

/middleware/目录用于包含中间件文件,这些文件是在渲染页面或一组页面之前运行的 JavaScript 函数。例如,您可能希望有一个只有在用户经过身份验证时才能访问的秘密页面。您可以使用 Vuex 存储来存储经过身份验证的数据,并创建一个中间件,如果state存储中的auth属性为空,则抛出403错误:

// middleware/auth.js
export default function ({ store, error }) {
  if (!store.state.auth) {
    error({
      message: 'You are not connected',
      statusCode: 403
    })
  }
}

我们将在第十一章中更详细地查看此目录,编写路由中间件和服务器中间件

package.json 文件

package.json文件用于包含 Nuxt 应用程序的依赖项和脚本。例如,如果您使用 Nuxt 脚手架工具,则在此文件中会获得以下默认脚本和依赖项:

// package.json
{
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "start": "nuxt start",
    "generate": "nuxt generate"
  },
  "dependencies": {
    "nuxt": "².14.0"
  }
}

我们将在第八章中大量使用此文件,添加服务器端框架,以及在第十四章中,使用检查器、格式化程序和部署命令

nuxt.config.js 文件

nuxt.config.js文件用于包含应用程序特定的自定义配置。例如,当您使用 Nuxt 脚手架工具时,默认情况下会为 HTML 的<head>块获取这些自定义的元标记、标题和链接:

export default {
  head: {
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 
        process.env.npm_package_description || '' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  }
}

我们可以修改前面的自定义头部块。您将在第四章中学习如何做到这一点,添加视图、路由和转换。除了head之外,还有其他关键属性可用于进行自定义配置,我们将在接下来的部分中介绍。

别名

在 Nuxt 中,~@ 别名用于与 srcDir 属性关联,~~@@ 别名用于与 rootDir 属性关联。例如,如果您想将图像链接到 /assets/ 目录,可以使用 ~ 别名,如下所示:

<template>
  <img src="~/assets/sample-1.jpg"/>
</template>

另一方面,如果您想将图像链接到 /static/ 目录,可以使用 ~ 别名,如下所示:

<template>
  <img src="~/static/sample-1.jpg"/>
</template>

请注意,您也可以在不使用这些别名的情况下链接到 /static/ 目录中的资源:

<template>
  <img src="/sample-1.jpg"/>
</template>

srcDir 的值默认与 rootDir 的值相同,即 process.cwd()。我们将在下一节中介绍这两个选项,您将学习如何更改它们的默认值。因此,让我们探讨如何在项目中自定义配置。

理解自定义配置

您可以通过在项目的根目录中添加一个 nuxt.config.js 文件(本书中将其称为Nuxt 配置文件)来配置您的 Nuxt 应用以适应您的项目。如果您使用 Nuxt 脚手架工具,默认情况下会得到这个文件。当您打开此文件时,应该会得到以下选项(或属性):

// nuxt.config.js
export default {
  mode: 'universal',
  target: 'server',
  head: { ... },
  css: [],
  plugins: [],
  components: true,
  buildModules: [],
  modules: [],
  build: {}
}

其中大多数为空,除了 modetargetheadcomponents。您可以通过这些选项定制 Nuxt 以适应您的项目。让我们逐个了解它们,然后再看看其他选项,看看您可以如何使用它们。

mode 选项

mode 选项用于定义应用的“性质” - 无论是通用应用还是单页应用。其默认值为 universal。如果您正在使用 Nuxt 开发单页应用,那么将此值更改为 spa。在本书的即将到来的章节中,我们将专注于通用模式,除了 第十五章 使用 Nuxt 创建单页应用

target 选项

target 选项用于设置应用的部署目标 - 无论是作为服务器端渲染应用还是静态生成应用进行部署。其默认值为服务器端渲染部署的 server。本书中大多数示例应用的部署目标是服务器端渲染。在最后一章 - 第十八章 使用 CMS 和 GraphQL 创建 Nuxt 应用 中,我们也会针对静态生成部署进行目标设置。

head 选项

head选项用于在我们应用程序的<head>块中定义所有默认的元标签。如果您使用 Nuxt 脚手架工具,您将在 Nuxt 配置文件中获得以下自定义head配置:

// nuxt.config.js
export default {
  head: {
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 
        process.env.npm_package_description || '' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  }
}

您可以修改上述配置或添加更多自定义配置 - 例如,添加一些对项目所需的 JavaScript 和 CSS 库:

// nuxt.config.js
export default {
  head: {
    titleTemplate: '%s - Nuxt App',
    meta: [
      //...
    ],
    script: [
      { src: 'https://cdnjs.cloudflare.com/.../jquery.min.js' },
      { src: 'https://cdn.jsdelivr.net/.../foundation.min.js' },
    ],
    link: [
      { rel: 'stylesheet', href: 
      'https://cdn.jsdelivr.net/.../foundation.min.css' },
    ]
  }
}

我们将在第三章的添加 UI 框架和第四章的添加视图、路由和转换中更详细地介绍这个选项。请注意,jQuery 是 Foundation(Zurb)的核心依赖项,我们将在第三章的添加 UI 框架中进行探讨。因此,目前需要在项目中安装 jQuery 才能使用 Foundation。这在未来的版本中可能会变成可选项。

css 选项

css选项用于添加全局 CSS 文件。这些可以是.css.less.scss文件。它们也可以是直接从项目中的 Node.js /node_modules/目录加载的模块和库。例如,看下面的例子:

// nuxt.config.js
export default {
  css: [
    'jquery-ui-bundle/jquery-ui.min.css',
    '@/assets/less/styles.less',
    '@/assets/scss/styles.scss'
  ]
}

在上述配置中,我们从安装在/node_modules/目录中的 jQuery UI 模块加载 CSS 文件,以及存储在/assets/目录中的 Less 和 Sass 文件。请注意,如果您使用.less.scss文件编写样式,您需要安装 Less 和 Sass 模块以及它们的 webpack 加载器,如下所示:

$ npm i less less-loader --save-dev
$ npm i node-sass --save-dev
$ npm i sass-loader --save-dev

我们将在第三章的添加 UI 框架第四章添加视图、路由和转换中更多地使用这个选项。

插件选项

plugins选项用于添加在根 Vue 实例之前运行的 JavaScript 插件。例如,看下面的例子:

// nuxt.config.js
export default {
  plugins: ['~/plugins/vue-notifications']
}

我们经常与前面的章节中介绍的/plugins/目录一起使用这个选项。我们将在第六章的编写插件和模块中大量使用这个选项。

组件选项

components 选项用于设置/components/目录中的组件是否应该自动导入。如果你有大量组件需要导入到布局或页面中,这个选项非常有用。如果将此选项设置为 true,则无需手动导入它们。它的默认值为 false。我们在本书中为所有应用程序将此选项设置为 true。

有关此选项的更多信息和(高级)用法,请访问 https://github.com/nuxt/components。

buildModules 选项

buildModules 选项用于注册仅构建的模块 - 仅在开发和构建时需要的模块。在本书中,请注意我们将仅利用 Nuxt 社区中的一些模块,并创建在 Node.js 运行时需要的自定义模块。但是,有关 buildModules 选项和仅构建时需要的模块的更多信息,请访问 https://nuxtjs.org/guide/modules#build-only-modules。

模块选项

modules 选项用于向项目添加 Nuxt 模块。例如,可以使用以下内容:

// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/axios',
    '~/modules/example.js'
  ]
}

我们还可以直接使用此选项创建内联模块:

// nuxt.config.js
export default {
  modules: [
    function () {
      //...
    }
  ]
}

Nuxt 模块本质上是 JavaScript 函数,就像插件一样。我们将在第六章《编写插件和模块》中讨论它们之间的区别。就像经常与/plugins/目录一起使用的 plugins 选项一样,modules 选项经常与/modules/目录一起使用。我们将在第六章《编写插件和模块》中经常使用这个选项。

构建选项

build 选项用于自定义 webpack 配置,以便按照您喜欢的方式构建 Nuxt 应用程序。例如,您可能希望在项目中全局安装 jQuery,这样每次需要时就不必使用 import。您可以使用 webpack 的 ProvidePlugin 函数自动加载 jQuery,如下所示:

// nuxt.config.js
import webpack from 'webpack'

export default {
  build: {
    plugins: [
      new webpack.ProvidePlugin({
        $: "jquery"
      })
    ]
  }
}

我们将在第四章中再次使用build选项,添加视图、路由和转换,在第六章中,编写插件和模块,以及在第十四章中,使用 Linter、格式化程序和部署命令

有关你的 Nuxt 应用可以使用这个选项做些什么的更多细节和示例,请访问nuxtjs.org/api/configuration-build。有关 webpack 的ProvidePlugin函数的更多信息,请访问webpack.js.org/plugins/provide-plugin/。如果你是 webpack 的新手,我们鼓励你访问并从webpack.js.org/guides/学习。

以下部分概述了一些额外的选项,可以用来进一步和更具体地定制你的 Nuxt 应用。让我们探索一些在你的项目中可能有用的选项。其中一些在本书中经常使用。所以,让我们开始吧!

dev 选项

dev选项用于定义你的应用的开发生产模式。它不会被添加到 Nuxt 配置文件中,但当你需要时可以手动添加。它只接受布尔类型,其默认值设置为true。它总是被nuxt命令强制为true,并且总是被nuxt buildnuxt startnuxt generate命令强制为false

因此,从技术上讲,你不能自定义它,但你可以在 Nuxt 模块中使用这个选项,如下所示:

// modules/sample.js
export default function (moduleOptions) {
  console.log(this.options.dev)
}

你将得到truefalse,取决于你使用哪个 Nuxt 命令。我们将在第六章中介绍这个模块,编写插件和模块。或者,你可以在将 Nuxt 作为包导入服务器端框架时使用这个选项,如下所示:

// server/index.js
import { Nuxt, Builder } from 'nuxt'
import config from './nuxt.config.js'

const nuxt = new Nuxt(config)

if (nuxt.options.dev) {
  new Builder(nuxt).build()
}

dev选项为true时,new Builder(nuxt).build()行将被运行。我们将在第八章中介绍服务器端框架,添加服务器端框架

您可以在我们的 GitHub 存储库的/chapter-2/configuration/dev/中找到此选项的示例应用程序。

rootDir 选项

rootDir选项用于定义 Nuxt 应用程序的工作空间。例如,假设您的项目位于以下位置:

/var/www/html/my-project/

然后,您的项目的rootDir选项的默认值为/var/www/html/my-project/。但是,您可以按以下方式在package.json文件中使用 Nuxt 命令更改它:

// my-project/package.json
{
  "scripts": {
    "dev": "nuxt ./app/"
  }
}

现在,您的 Nuxt 应用程序的工作空间位于/var/www/html/my-project/app/,您的应用程序结构已变为以下内容:

-| my-project/
---| node_modules/
---| app/
------| nuxt.config.js
------| pages/
------| components/
------| ...
---| package.json

请注意,现在 Nuxt 配置文件必须放在/app/目录中。我们将在第十四章中介绍 Nuxt 命令,使用 Linter、Formatter 和部署命令

您可以在我们的 GitHub 存储库的/chapter-2/configuration/rooDir/中找到此选项的示例应用程序。

srcDir 选项

srcDir选项用于定义 Nuxt 应用程序的源目录。srcDir的默认值是rootDir的值。您可以按以下方式更改它:

// nuxt.config.js
export default {
  srcDir: 'src/'
}

现在,您的应用程序结构已变为以下内容:

-| my-project/
---| node_modules/
---| src/
------| pages/
------| components/
------| ...
---| nuxt.config.js
---| package.json

请注意,Nuxt 配置文件位于/src/目录之外。

您可以在我们的 GitHub 存储库的/chapter-2/configuration/srcDir/中找到此选项的示例应用程序。

服务器选项

server选项用于配置 Nuxt 应用程序的服务器连接变量。它具有以下默认服务器连接详细信息:

export default {
  server: {
    port: 3000,
    host: 'localhost',
    socket: undefined,
    https: false,
    timing: false
  }
}

您可以按以下方式更改它们:

export default {
  server: {
    port: 8080,
    host: '0.0.0.0'
  }
}

现在,您的应用程序正在0.0.0.0:8080上运行。

您可以在我们的 GitHub 存储库的/chapter-2/configuration/server/中找到此选项的示例应用程序。

env 选项

env选项用于为 Nuxt 应用程序的客户端和服务器端设置环境变量。此选项的默认值为空对象{}。当您在项目中使用axios时,此选项非常有用。

采用以下示例:

// nuxt.config.js
export default {
  env: {
    baseUrl: process.env.BASE_URL || 'http://localhost:3000'
  }
}

然后,您可以按以下方式在axios插件中设置env属性:

// plugins/axios.js
import axios from 'axios'

export default axios.create({
  baseURL: process.env.baseUrl
})

现在,baseURL选项设置为localhost:3000,或者如果定义了BASE_URL,则为BASE_URL。我们可以在package.json中设置BASE_URL,如下所示:

// package.json
"scripts": {
  "start": "cross-env BASE_URL=https://your-domain-name.com nuxt start"
}

您需要在 Windows 上安装cross-env才能使上述示例工作:

$ npm i cross-env --save-dev

我们将在第六章中介绍插件,编写插件和模块。在创建跨域应用程序时,我们将在本书中经常使用env选项。

您可以在我们的 GitHub 存储库的/chapter-2/configuration/env/中找到此选项的示例应用程序。

路由器选项

router选项用于覆盖 Vue 路由器上的默认 Nuxt 配置。默认 Vue 路由器配置如下:

{
  mode: 'history',
  base: '/',
  routes: [],
  routeNameSplitter: '-',
  middleware: [],
  linkActiveClass: 'nuxt-link-active',
  linkExactActiveClass: 'nuxt-link-exact-active',
  linkPrefetchedClass: false,
  extendRoutes: null,
  scrollBehavior: null,
  parseQuery: false,
  stringifyQuery: false,
  fallback: false,
  prefetchLinks: true
}

您可以按以下方式更改此配置:

// nuxt.config.js
export default {
  router: {
    base: '/app/'
  }
}

现在,您的应用正在localhost:3000/app/上运行。

有关此属性及其余配置的更多信息,请访问nuxtjs.org/api/configuration-router

您可以在我们的 GitHub 存储库的/chapter-2/configuration/router/中找到此选项的示例应用程序。

dir 选项

dir选项用于定义 Nuxt 应用中的自定义目录。默认目录如下:

{
  assets: 'assets',
  layouts: 'layouts',
  middleware: 'middleware',
  pages: 'pages',
  static: 'static',
  store: 'store'
}

您可以按以下方式更改它们:

// nuxt.config.js
export default {
  dir: {
    assets: 'nuxt-assets',
    layouts: 'nuxt-layouts',
    middleware: 'nuxt-middleware',
    pages: 'nuxt-pages',
    static: 'nuxt-static',
    store: 'nuxt-store'
  }
}

现在,您可以按以下方式使用前面的自定义目录:

-| app/
---| nuxt-assets/
---| components/
---| nuxt-layouts/
---| nuxt-middleware/
---| node_modules/
---| nuxt-pages/
---| plugins/
---| modules/
---| nuxt-static/
---| nuxt-store/
---| nuxt.config.js
---| package.json
---| README.md

您可以在我们的 GitHub 存储库的/chapter-2/configuration/dir/中找到此选项的示例应用程序。

加载选项

loading选项用于自定义 Nuxt 应用中的默认加载组件。如果您不想使用这个默认加载组件,可以将其设置为false,如下所示:

// nuxt.config.js
export default {
  loading: false
}

我们将在第四章中更详细地介绍这个选项,添加视图、路由和转换

页面转换和布局转换选项

pageTransitionlayoutTransition选项用于自定义 Nuxt 应用中页面和布局转换的默认属性。页面转换的默认属性设置如下:

{
  name: 'page',
  mode: 'out-in',
  appear: false,
  appearClass: 'appear',
  appearActiveClass: 'appear-active',
  appearToClass: 'appear-to'
}

布局转换的默认属性设置如下:

{
  name: 'layout',
  mode: 'out-in'
}

您可以按以下方式更改它们:

// nuxt.config.js
export default {
  pageTransition: {
    name: 'fade'
  },
  layoutTransition: {
    name: 'fade-layout'
  }
}

我们将在第四章中更详细地介绍这些选项,添加视图、路由和转换

生成选项

generate选项用于告诉 Nuxt 如何为静态 Web 应用程序生成动态路由。动态路由是通过在 Nuxt 中使用下划线创建的路由。我们将在第四章 添加视图、路由和过渡中介绍这种类型的路由。如果我们希望将 Nuxt 应用导出为静态 Web 应用程序或 SPA,而不是将 Nuxt 用作通用应用程序(SSR),则使用generate选项来处理动态路由,这些动态路由无法被 Nuxt 爬虫自动检测到。例如,如果爬虫无法检测到您的应用中的以下动态路由(分页):

/posts/pages/1
/posts/pages/2
/posts/pages/3

然后,您可以使用此generate选项将每个路由的内容生成和转换为 HTML 文件,如下所示:

// nuxt.config.js
export default {
  generate: {
    routes: [
      '/posts/pages/1',
      '/posts/pages/2',
      '/posts/pages/3'
    ]
  }
}

我们将向您展示如何使用此选项来生成路由,如果爬虫无法检测到它们,可以在第十五章 创建 Nuxt SPA和第十八章 使用 CMS 和 GraphQL 创建 Nuxt 应用中找到。

有关此generate选项的更多信息和更高级的用法,请访问nuxtjs.org/api/configuration-generate

随着我们的学习,我们将在接下来的章节中涵盖和发现其他配置选项。然而,这些是您现在应该了解的基本自定义配置选项。现在,让我们在下一个主题中进一步探索 webpack 中的资源服务。

了解资源服务

Nuxt 使用vue-loaderfile-loaderurl-loaderwebpack 加载程序来提供应用程序中的资产。首先,Nuxt 将使用vue-loader处理<template><style>块,使用css-loadervue-template-compiler来编译这些块中的元素,例如<img src="...">background-image: URL(...)和这些块中的 CSS @import为模块依赖项。举个例子:

// pages/index.vue
<template>
  <img src="~/assets/sample-1.jpg">
</template>

<style>
.container {
  background-image: url("~assets/sample-2.jpg");
}
</style>

在前述<template><style>块中的图像元素和资产将被编译和转换为以下代码和模块依赖项:

createElement('img', { attrs: { src: require('~/assets/sample-1.jpg') }})
require('~/assets/sample-2.jpg')

请注意,从 Nuxt 2.0 开始,~/别名在样式中将无法正确解析,因此请改用~assets@/别名。

在前面的编译和翻译之后,Nuxt 将使用file-loader来解析import/require模块依赖关系为 URL,并将资产发射(复制并粘贴)到输出目录 - 或者,使用url-loader将资产转换为 Base64 URI,如果资产小于 1KB。然而,如果资产大于 1KB 的阈值,它将退回到file-loader。这意味着任何小于 1KB 的文件将被url-loader内联为 Base64 数据 URL,如下所示:

<img src="data:image/png;base64,iVBO...">

这可以让您更好地控制应用程序向服务器发出的 HTTP 请求的数量。内联资产会减少 HTTP 请求,而任何超过 1KB 的文件都将被复制并粘贴到输出目标,并以版本哈希命名以获得更好的缓存。例如,前述<template><style>块中的图像将被发射如下(通过npm run build):

img/04983cb.jpg 67.3 KiB [emitted]
img/cc6fc31.jpg 85.8 KiB [emitted]

您将在浏览器的前端看到以下图像:

<div class="links">
  <img src="/_nuxt/img/04983cb.jpg">
</div>

以下是这两个 webpack 加载程序(url-loaderfile-loader)的默认配置:

[
  {
    test: /\.(png|jpe?g|gif|svg|webp)$/i,
    use: [{
      loader: 'url-loader',
      options: Object.assign(
        this.loaders.imgUrl,
        { name: this.getFileName('img') }
      )
    }]
  },
  {
    test: /\.(woff2?|eot|ttf|otf)(\?.)?$/i,
    use: [{
      loader: 'url-loader',
      options: Object.assign(
        this.loaders.fontUrl,
        { name: this.getFileName('font') }
      )
    }]
  },
  {
    test: /\.(webm|mp4|ogv)$/i,
    use: [{
      loader: 'file-loader',
      options: Object.assign(
        this.loaders.file,
        { name: this.getFileName('video') }
      )
    }]
  }
]

您可以像我们在前面的主题中所做的那样使用 webpack 配置的build选项来自定义此默认配置。

有关file-loaderurl-loader的更多信息,请访问webpack.js.org/loaders/file-loader/webpack.js.org/loaders/url-loader/

有关vue-loadervue-template-compiler的更多信息,请访问vue-loader.vuejs.org/www.npmjs.com/package/vue-template-compiler

如果您对 webpack 不熟悉,请访问webpack.js.org/concepts/。另请访问webpack.js.org/guides/asset-management/了解其资产管理指南。简而言之,webpack 是 JavaScript 应用程序的静态模块打包工具。它的主要目的是捆绑 JavaScript 文件,但也可以用于转换 HTML、CSS、图像和字体等资产。如果您不想以 webpack 为您提供的方式提供资产,您也可以使用/static/目录用于静态资产,就像我们在前一节“理解目录结构”中提到的那样。然而,使用 webpack 提供资产也有好处。让我们在下一节中了解它们是什么。

webpack 资产与静态资产

使用 webpack 提供资产的好处之一是它会为生产进行优化,无论是图像、字体还是预处理样式,如 Less、Sass 或 Stylus。webpack 可以将 Less、Sass 和 Stylus 转换为通用 CSS,而静态文件夹只是一个放置所有静态资产的地方,这些资产将永远不会被 webpack 触及。在 Nuxt 中,如果您不想为项目使用/assets/目录中的 webpack 资产,可以使用/static/目录代替。

例如,我们可以从/static/目录中使用静态图像,如下所示:

// pages/index.vue
<template>
  <img src="/sample-1.jpg"/>
</template>

另一个很好的例子是 Nuxt 配置文件中的 favicon 文件:

// nuxt.config.js
export default {
  head: {
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  }
}

请注意,如果您使用~别名来链接/static/目录中的资产,webpack 处理这些资产,就像/assets/目录中的资产一样,如下所示:

// pages/index.vue
<template>
  <img src="~/static/sample-1.jpg"/>
</template>

我们将在第三章中大量使用/assets/目录来提供资产,添加 UI 框架,以及在第四章中,添加视图、路由和转换,以及在第五章中,添加 Vue 组件,以动态方式提供资产。现在,让我们总结一下您在本章中学到的内容。

您可以在我们的 GitHub 存储库的/chapter-2/assets/中找到一个用于从这两个目录提供资产和文件的示例应用程序。

总结

在本章中,您学会了如何使用create-nuxt-app安装 Nuxt,以及如何从头开始安装它,以及 Nuxt 脚手架工具安装的默认目录。您还学会了如何使用nuxt.config.js文件来自定义您的应用程序。最后,您学会了了解 Nuxt 中资产的工作方式以及使用 webpack 和/static/文件夹进行资产提供之间的区别。

在即将到来的章节中,您将学习如何为您的应用程序安装自定义 UI 框架、库和工具,例如 Zurb Foundation、Motion UI、jQuery UI 和 Less CSS。您将编写一些基本代码来为您的首页添加样式并为其添加一些动画。您还将开始使用我们在本章中刚刚介绍的一些目录,如/assets//plugins//pages/目录,来开发您的 Nuxt 应用程序。

添加 UI 框架

在本章中,我们将指导您安装一些前端 UI 框架到您的 Nuxt 项目中,这些框架将为您的应用模板添加样式。本书中我们选择的框架有 Foundation 用于设计布局,Motion UI 用于创建动画,Less 作为样式表语言,jQuery UI 用于为 DOM 添加动画,AOS 用于在滚动时为内容添加动画,以及 Swiper 用于创建轮播图像。这些框架可以加快 Nuxt 项目的前端开发速度,使其变得有趣且简单。

本章我们将涵盖的主题如下:

  • 添加基础和 Motion UI

  • 添加 Less(更轻量的样式表)

  • 添加 jQuery UI

  • 添加 AOS

  • 添加 Swiper

第三章:添加基础和 Motion UI

Foundation 是一个用于创建响应式网站的前端框架。它提供了用于网格布局、排版、按钮、表格、导航、表单等的 HTML 和 CSS 模板,以及可选的 JavaScript 插件。它适用于任何设备,移动或桌面,并且是 Bootstrap(https://getbootstrap.com/)的另一种流行的前端框架。我们在本书中专注于 Foundation。因此,就像在上一章中一样,当使用create-nuxt-app脚手架安装 Nuxt 项目的骨架时,我们有一系列建议的 UI 框架供您选择。我们应该选择None,以便我们可以添加 Foundation 作为 UI 框架:

? Choose UI framework (Use arrow keys)
❯ None
  Ant Design Vue
  Bootstrap Vue
  ...

一旦您回答了安装过程中的问题,导航到您的项目目录,然后您可以安装并集成 Foundation 到您的 Nuxt 应用程序中。最简单的方法是使用内容交付网络CDN),但不鼓励这样做。最简单的原因是,如果您离线开发,CDN 链接将无法工作。此外,您将失去对源文件的控制,因为它们由大型网络公司(如 Google、Microsoft 和 Amazon)处理。但是,如果您想在 Nuxt 项目中使用 CDN 快速启动,只需将 CDN 源添加到 Nuxt 配置文件中的head选项中,如下所示:

// nuxt.config.js
export default {
  head: {
    script: [
      { src: 'https://cdn.jsdelivr.net/.../foundation.min.js' },
    ],
    link: [
      { rel: 'stylesheet', href: 
      'https://cdn.jsdelivr.net/.../foundation.min.css' },
    ],
  },
}

您可以在官方 Foundation 网站上找到最新的 CDN 链接:https://get.foundation/sites/docs/installation.html#cdn-links。

这很容易,不是吗?但如果您想要在本地托管源文件,这并不理想。让我们在以下步骤中找出与 Nuxt 集成的正确方法:

  1. 通过 npm 在终端上安装 Foundation 及其依赖项(jQuery 和 what-input):
$ npm i foundation-sites
$ npm i jquery
$ npm i what-input
  1. /node_modules/文件夹中的 Foundation CSS 源添加到 Nuxt 配置文件中的css选项中,如下所示:
// nuxt.config.js
export default {
  css: [
    'foundation-sites/dist/css/foundation.min.css'
  ],
}
  1. /plugins/目录中创建一个foundation.client.js文件,并添加以下代码:
// plugins/client-only/foundation.client.js
import 'foundation-sites'

这个插件将确保 Foundation 仅在客户端运行。我们将在第六章中更详细地介绍插件和模块。

  1. 在 Nuxt 配置文件的plugins选项中注册上述 Foundation 插件,如下所示:
// nuxt.config.js
export default {
  plugins: [
    '~/plugins/client-only/foundation.client.js',
  ],
}
  1. 然后,您可以在需要的任何页面中使用 Foundation 的 JavaScript 插件,例如:
// layouts/form.vue
<script>
import $ from 'jquery'

export default {
  mounted () {
    $(document).foundation()
  }
}
</script>

就是这样。您已经成功在您的 Nuxt 项目中安装并成功集成了它。现在,让我们在下一节中探讨如何使用 Foundation 创建网格结构布局和网站导航,以加速前端网页开发。

使用 Foundation 创建网格布局和网站导航

我们应该首先看一下 Foundation 的网格系统,它被称为 XY Grid。在网页开发中,网格系统是一种将我们的 HTML 元素结构化为基于网格的布局的系统。Foundation 带有我们可以轻松使用的 CSS 类来结构化我们的 HTML 元素,例如:

<div class="grid-x">
  <div class="cell medium-6">left</div>
  <div class="cell medium-6">right</div>
</div

这将在大屏幕上(例如 iPad,Windows Surface)将我们的元素响应地结构化为两列,但在小屏幕上(例如 iPhone)将其结构化为单列。让我们在默认的index.vue页面和由create-nuxt-app脚手架工具生成的default.vue布局中创建一个响应式布局和网站导航:

  1. 删除/components/目录中的Logo.vue组件。

  2. 删除/pages/目录中index.vue页面中的<style><script>块,但用以下元素和网格类替换<template>块:

// pages/index.vue
<template>
  <div class="grid-x">
    <div class="medium-6 cell">
      <img src="~/assets/images/sample-01.jpg">
    </div>
    <div class="medium-6 cell">
      <img src="~/assets/images/sample-02.jpg">
    </div>
  </div>
</template>

在这个模板中,当页面在大屏幕上加载时,图像会并排结构。但当页面调整大小或在小屏幕上加载时,它们会自适应地堆叠在一起。

  1. 删除/layouts/目录中default.vue布局中的<style><script>块,但用以下导航替换<template>块:
// layouts/default.vue
<template>
  <div>
    <ul class="menu align-center">
      <li><nuxt-link to="/">Home</nuxt-link></li>
      <li><nuxt-link to="/form">Form</nuxt-link></li>
      <li><nuxt-link to="/motion-ui">Motion UI</nuxt-link></li>
    </ul>
    <nuxt />
  </div>
</template>

在这个新布局中,我们只是创建了一个基本的网站水平菜单,其中包含一个填充有三个<li>元素和<nuxt-link>组件的<ul>元素,并通过在<ul>元素后添加.align-center类将菜单项对齐到中心。

就是这样。现在您拥有一个可以在任何设备上完美运行的具有响应式布局和导航的网站。您可以看到,您可以在不编写任何 CSS 样式的情况下快速完成它。很棒,不是吗?但 JavaScript 呢?Foundation 还附带了一些 JavaScript 实用程序和插件,我们也可以利用它们。让我们在下一节中找出。

有关 Foundation 中 XY 网格和导航的更多信息,请访问get.foundation/sites/docs/xy-grid.htmlget.foundation/sites/docs/menu.html

使用 Foundation 的 JavaScript 实用程序和插件

Foundation 附带许多有用的 JavaScript 实用程序,例如 MediaQuery。此 MediaQuery 实用程序可用于获取应用程序中创建响应式布局所需的屏幕大小断点(小,中,大,超大)。让我们在以下步骤中找出如何使用它:

  1. 创建一个utils.js文件,将您的自定义全局实用程序保存在/plugins/目录中,并添加以下代码:
// plugins/utils.js
import Vue from 'vue'
Vue.prototype.$getCurrentScreenSize = () => {
  window.addEventListener('resize', () => {
    console.log('Current screen size: ' +
     Foundation.MediaQuery.current)
  })
}

在这段代码中,我们创建了一个全局插件(即 JavaScript 函数),它将从 MediaQuery 实用程序的current属性中获取当前屏幕大小,并在浏览器的屏幕大小更改时记录输出。通过使用 JavaScript 的EventTarget方法addEventListener,将调整大小事件监听器添加到 window 对象中。然后通过将其命名为$getCurrentScreenSize将此插件注入到 Vue 实例中。

  1. 在默认布局中调用$getCurrentScreenSize函数如下:
// layouts/default.vue
<script>
export default {
  mounted () {
    this.$getCurrentScreenSize()
  }
}
</script>

因此,如果您在 Chrome 浏览器上打开控制台选项卡,当您调整屏幕大小时,您应该会看到当前屏幕大小的日志,例如当前屏幕大小:中等

有关 Foundation MediaQuery 和其他实用程序的更多信息,请访问get.foundation/sites/docs/javascript-utilities.html#mediaqueryget.foundation/sites/docs/javascript-utilities.html

有关 JavaScript EventTarget 和 addEventListener 的更多信息,请访问developer.mozilla.org/en-US/docs/Web/API/EventTargetdeveloper.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

除了 JavaScript 实用程序之外,Foundation 还提供了许多 JavaScript 插件,例如 Dropdown Menu 用于创建下拉导航,Abide 用于表单验证,Tooltip 用于在 HTML 页面中显示元素的扩展信息。这些插件可以通过简单地将它们的类名添加到您的元素中来激活。此外,您可以通过编写 JavaScript 来修改和与它们交互,就像我们在本节中向您展示的那样。让我们在以下步骤中看一下 Abide 插件:

  1. 创建包含提交按钮和重置按钮的最后一个<div>块,如下所示:
// pages/form.vue
<template>
  <form data-abide novalidate>
    <div class="grid-container">
      <div class="grid-x">
        <div class="cell small-12">
          <div data-abide-error class="alert callout" 
           style="display: none;">
            <p><i class="fi-alert"></i> There are errors in your 
             form.</p>
          </div>
        </div>
      </div>
    </div>
    <div class="grid-container">
      <div class="grid-x">
        //...
      </div>
    </div>
  </form>
</template>

在这个表单中,第一个网格容器包含一般错误消息,而第二个容器将包含表单输入字段。我们通过向表单元素添加data-abide来激活 Abide 插件。我们还向表单元素添加了一个novalidate属性,以防止浏览器的本机验证,这样我们就可以将工作交给 Abide 插件。

  1. 创建一个包含.cell.small-12类的<div>块,其中包含一个电子邮件<input>元素和两个默认错误消息<span>元素,如下所示:
// pages/form.vue
<div class="cell small-12">
  <label>Email (Required)
    <input type="text" placeholder="hello@example.com" required
      pattern="email">
    <span class="form-error" data-form-error-on="required">
      Sorry, this field is required.
    </span>
    <span class="form-error" data-form-error-on="pattern">
      Sorry, invalid Email
    </span>
  </label>
</div>

创建两个包含两个<input>元素的<div>块,用于收集密码,其中第二个密码用于通过在第二个密码<input>元素中添加data-equalto属性来匹配第一个密码,如下所示:

  1. /pages/目录中创建一个form.vue页面,其中包含以下 HTML 元素,以创建包含两个.grid-container元素的表单:
// pages/form.vue
<div class="cell small-12">
  <label>Password Required
    <input type="password" placeholder="chewieR2D2" required >
    <span class="form-error">
      Sorry, this field is required.
    </span>
  </label>
</div>
<div class="cell small-12">
  <label>Re-enter Password
    <input type="password" placeholder="chewieR2D2" required
      pattern="alpha_numeric"
      data-equalto="password">
    <span class="form-error">
      Sorry, passwords are supposed to match!
    </span>
  </label>
</div>
  1. 在这个单元格块中,有三个来自 Foundation 的自定义属性:pattern属性用于验证电子邮件字符串,data-form-error-on属性用于显示与requiredpattern属性相应的输入错误,placeholder属性用于在输入字段中显示输入提示。请注意,required属性是 HTML5 的默认属性。
// pages/form.vue
<div class="cell small-12">
  <button class="button" type="submit" value="Submit">Submit</button>
  <button class="button" type="reset" value="Reset">Reset</button>
</div>
  1. 在 Vue 组件挂载时,在<script>块中初始化 Foundation JavaScript 插件:
// pages/form.vue
<script>
import $ from 'jquery'

export default {
  mounted () {
    $(document).foundation()
  }
}
</script>

就是这样。不需要编写任何 JavaScript,只需添加带有类和属性的 HTML 元素,就可以创建一个漂亮的前端表单验证。这非常有用!

有关 Foundation 中 Abide 插件的更多信息,请访问get.foundation/sites/docs/abide.html

除了 JavaScript 实用程序和插件外,Zurb Foundation 还有一些有用的库,我们可以从中受益:Motion UI 用于创建 Sass/CSS 动画,Panini 用于使用可重用部分创建页面和布局,Style Sherpa 用于为代码库创建样式指南。我们将在下一节中探讨如何使用 Motion UI 创建 CSS 动画和过渡。让我们找出!

使用 Motion UI 创建 CSS 动画和过渡

Motion UI 是 Zurb Foundation 的一个方便的 Sass 库,用于快速创建 CSS 过渡和动画。您可以从 Motion UI 网站下载 Starter Kit 并进行调试,但这缺乏自己的控制,因为它带有许多内置的默认值和效果,您必须遵循。因此,如果您想要更多的控制并充分利用 Motion UI,您必须知道如何自定义和编译 Sass 代码。让我们在以下步骤中找出如何编写您的 Sass 动画:

  1. 通过 npm 在终端上安装 Motion UI 及其依赖项(Sass 和 Sass loader):
$ npm i motion-ui --save-dev
$ npm i node-sass --save-dev
$ npm i sass-loader --save-dev
  1. /assets/目录中的/css/文件夹中创建一个main.scss文件,并按以下方式导入 Motion UI:
// assets/scss/main.scss
@import 'motion-ui/src/motion-ui';
@include motion-ui-transitions;
@include motion-ui-animations;
  1. 随后是自定义 CSS 动画如下:
// assets/scss/main.scss
.welcome {
  @include mui-animation(fade);
  animation-duration: 2s;
}
  1. 在 Nuxt 配置文件的css选项中注册自定义 Motion UI CSS 资源:
// nuxt.config.js
export default {
  css: [
    'assets/scss/main.scss'
  ]
}
  1. 通过使用其类名将动画应用于任何元素,例如:
// pages/index.vue
<img class="welcome" src="~/assets/images/sample-01.jpg">

然后,您应该看到上述图像在页面加载时逐渐淡入需要 2 秒钟的时间。

Motion UI 还提供了两个公共函数,我们可以与其交互以触发其内置动画和过渡:animationInanimateOut。让我们在以下步骤中找出如何使用它们:

  1. /plugins/目录中创建一个motion-ui.client.js文件,其中包含以下代码:
// plugins/client-only/motion-ui.client.js
import Vue from 'vue'
import MotionUi from 'motion-ui'
Vue.prototype.$motionUi = MotionUi

此插件将确保 Motion UI 仅在客户端运行。我们将在第六章中更详细地介绍插件和模块的内容。

  1. 在 Nuxt 配置文件的plugins选项中注册上述 Motion UI 插件如下:
// nuxt.config.js
export default {
  plugins: [
    '~/plugins/client-only/motion-ui.client.js',
  ],
}
  1. 在模板中随意使用 Motion UI 函数,例如:
// pages/motion-ui.vue
<template>
  <h1 data-animation="spin-in">Hello Motion UI</h1>
</template>

<script>
import $ from 'jquery'

export default {
  mounted () {
    $('h1').click(function() {
      var $animation = $('h1').data('animation')
      this.$motionUi.animateIn($('h1'), $animation)
    })
  }
}
</script>

在此页面中,我们将过渡名称spin-in存储在元素的data属性中,然后将其传递给 Motion UI 的animateIn函数,在元素被点击时应用动画。请注意,我们使用 jQuery 从data属性中获取数据。

如果您想了解其余内置过渡名称,请访问get.foundation/sites/docs/motion-ui.html#built-in-transitions

这很酷,不是吗?如果您需要在元素上使用 CSS 动画或过渡,而又不想自己编写大量的 CSS 代码,这将非常方便。这可以使您的 CSS 样式保持简洁,并专注于模板的主要和自定义呈现。说到节省时间和不必亲自编写通用代码,还值得一提的是 Zurb Foundation 提供的常用图标字体——Foundation Icon Font 3。让我们在下一节中了解一下您可以从中受益的方式。

有关 Motion UI 的更多信息,请访问get.foundation/sites/docs/motion-ui.html。至于 Panini 和 Style Sherpa,请访问get.foundation/sites/docs/panini.htmlget.foundation/sites/docs/style-sherpa.html

使用 Foundation Icon Fonts 3 添加图标

Foundation Icon Fonts 3 是我们可以在前端开发项目中使用的有用图标字体集之一。它可以帮助您避免自己创建常见的图标,例如社交媒体图标(Facebook、Twitter、YouTube)、箭头图标(向上箭头、向下箭头等)、辅助功能图标(轮椅、电梯等)、电子商务图标(购物车、信用卡等)和文本编辑器图标(加粗、斜体等)。让我们在以下步骤中了解如何在您的 Nuxt 项目中安装它:

  1. 通过 npm 安装 Foundation Icon Fonts 3:
$ npm i foundation-icon-fonts
  1. 在 Nuxt 配置文件中全局添加 Foundation Icon Fonts 的路径:
// nuxt.config.js
export default {
  css: [
    'foundation-icon-fonts/foundation-icons.css',
  ]
}
  1. 使用图标名称前缀为fi的任何<i>元素应用图标,例如:
<i class="fi-heart"></i>

您可以在zurb.com/playground/foundation-icon-fonts-3找到其余图标名称。

干得好!在本节和之前关于将 Foundation 添加到您的 Nuxt 项目的章节中,您已经成功地使用了网格系统来构建您的布局,并使用 Sass 创建了 CSS 动画。但是,添加网格系统和编写 CSS 动画还不足以构建一个应用程序;我们需要特定的 CSS 来描述 Nuxt 应用程序中 HTML 文档和 Vue 页面的呈现。我们可以在整个项目中使用 Sass 来创建无法仅通过使用 Foundation 完成的自定义样式,但让我们尝试另一种流行的样式预处理器并将其添加到您的 Nuxt 项目中——Less。让我们在下一节中找出。

您可以在我们的 GitHub 存储库的/chapter-3/nuxt-universal/adding-foundation/中找到到目前为止学到的所有示例代码。

添加 Less(Leaner Style Sheets

Less 代表 Leaner Style Sheets,是 CSS 的语言扩展。它看起来就像 CSS,因此非常容易学习。Less 只对 CSS 语言进行了一些方便的添加,这也是它可以被迅速学习的原因之一。您可以在使用 Less 编写 CSS 时使用变量、mixin、嵌套、嵌套 at-rules 和冒泡、操作、函数等等;例如,以下是变量的样子:

@width: 10px;
@height: @width + 10px;

这些变量可以像其他编程语言中的变量一样使用;例如,您可以在普通的 CSS 中以以下方式使用前面的变量:

#header {
  width: @width;
  height: @height;
}

上述代码将转换为以下 CSS,我们的浏览器将理解:

#header {
  width: 10px;
  height: 20px;
}

这非常简单和整洁,不是吗?在 Nuxt 中,您可以通过在<style>块中使用lang属性来将 Less 作为您的 CSS 预处理器使用:

<style lang="less">
</style>

如果您想要将本地样式应用于特定页面或布局,这种方式是很好和可管理的。您应该在lang属性之前添加一个scoped属性,以便本地样式仅在特定页面上本地应用,并且不会干扰其他页面的样式。但是,如果您有多个页面和布局共享一个公共样式,那么您应该在项目的/assets/目录中全局创建样式。因此,让我们看看您如何在以下步骤中使用 Less 创建全局样式:

  1. 通过终端在 npm 上安装 Less 及其 webpack 加载器:
$ npm i less --save-dev
$ npm i less-loader --save-dev
  1. /assets/目录中创建一个main.less文件,并添加以下样式:
// assets/less/main.less @borderWidth: 1px;
@borderStyle: solid;

.cell {
  border: @borderWidth @borderStyle blue;
}

.row {
  border: @borderWidth @borderStyle red;
}

  1. 在 Nuxt 配置文件中安装上述全局样式如下:
// nuxt.config.js
export default {
  css: [
    'assets/less/main.less'
  ]
}
  1. 例如,在项目的任何地方应用上述样式:
// pages/index.vue
<template>
  <div class="row">
    <div class="grid-x">
      <div class="medium-6 cell">
        <img class="welcome" src="~/assets/images/sample-01.jpg">
      </div>
      <div class="medium-6 cell">
        <img class="welcome" src="~/assets/images/sample-02.jpg">
      </div>
    </div>
  </div>
</template>

当你在浏览器上启动应用程序时,你应该看到刚刚添加到 CSS 类的边框。这些边框在开发布局时可以作为指南,因为网格系统下面的网格线是“不可见的”,没有可见的线可能很难将它们可视化。

你可以在我们的 GitHub 存储库的/chapter-3/nuxt-universal/adding-less/中找到上述代码。

由于我们在本节中涵盖了 CSS 预处理器,值得一提的是我们可以在<style>块、<template>块或<script>块中使用任何预处理器,例如:

  • 如果你想用 CoffeeScript 编写 JavaScript,可以按照以下步骤进行:
<script lang="coffee">
export default data: ->
  { message: 'hello World' }
</script>

有关 CoffeeScript 的更多信息,请访问coffeescript.org/

  • 如果你想在 Nuxt 中使用 Pug 编写 HTML 标签,可以按照以下步骤进行:
<template lang="pug">
  h1.blue Greet {{ message }}!
</template>

有关 Pug 的更多信息,请访问pugjs.org/

  • 如果你想使用 Sass(Syntactically Awesome Style Sheets)或 Scss(Sassy Cascaded Style Sheets)来编写 CSS 样式,可以按照以下步骤进行:
<style lang="sass">
.blue
  color: blue
</style>

<style lang="scss">
.blue {
  color: blue;
}
</style>

有关 Sass 和 Scss 的更多信息,请访问sass-lang.com/

在本书中,我们在各章节中主要使用 Less、原生 HTML 和 JavaScript(主要是 ECMAScript 6 或 ECMAScript 2015)。但你可以自由选择任何我们提到的预处理器。现在让我们来看看在 Nuxt 项目中为 HTML 元素添加效果和动画的另一种方法——jQuery UI。

添加 jQuery UI

jQuery UI 是建立在 jQuery 之上的一组用户界面(UI)交互、效果、小部件和实用工具。它对设计师和开发人员都是一个有用的工具。与 Motion UI 和 Foundation 一样,jQuery UI 可以帮助你用更少的代码在项目中做更多事情。它可以通过使用 CDN 资源和以 jQuery 为依赖项轻松地添加到普通 HTML 页面中,例如:

<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">

<div id="accordion">...</div>

<script>
  $('#accordion').accordion()
</script>

再次强调,与 Foundation 一样。当你想要将 jQuery UI 与 Nuxt 集成时会有一些复杂。我们可以使用上述 CDN 资源,并将它们添加到 Nuxt 配置文件中的head选项中,如下所示:

// nuxt.config.js
export default {
  head: {
    script: [
      { src: 'https://cdnjs.cloudflare.com/.../jquery.min.js' },
      { src: 'https://code.jquery.com/.../jquery-ui.js' },
    ],
    link: [
      { rel: 'stylesheet', href: 
       'https://code.jquery.com/.../jquery-ui.css' },
    ]
  }
}

但是,就像与 Foundation 集成一样,不鼓励这样做。以下是正确的做法:

  1. 在终端上通过 npm 安装 jQuery UI:
$ npm i jquery-ui-bundle
  1. 将 jQuery UI 的 CSS 源文件从/node_modules/文件夹添加到 Nuxt 配置文件的css选项中:
// nuxt.config.js
module.exports = {
  css: [
    'jquery-ui-bundle/jquery-ui.min.css'
  ]
}
  1. /plugins/目录中创建一个名为jquery-ui-bundle.js的文件,并按以下方式导入 jQuery UI:
// plugins/client-only/jquery-ui-bundle.client.js
import 'jquery-ui-bundle'

再次强调,此插件将确保 jQuery UI 仅在客户端上运行,并且我们将在第六章中更详细地介绍插件和模块的内容。

  1. 在 Nuxt 配置文件的plugins选项中注册前面的 jQuery UI 插件,如下所示:
// nuxt.config.js
export default {
  plugins: [
    '~/plugins/client-only/jquery-ui-bundle.client.js',
  ],
}
  1. 现在您可以在任何地方使用 jQuery UI,例如:
// pages/index.vue
<template>
  <div id="accordion">...</div>
</template>

<script>
import $ from 'jquery'

export default {
  mounted () {
    $('#accordion').accordion()
  }
}
</script>

在此示例中,我们使用了 jQuery UI 的一个小部件 Accordion 来显示可折叠的内容面板。您可以在jqueryui.com/accordion/找到 HTML 代码的详细信息。

除了小部件,jQuery UI 还带有动画缓动效果等效果。让我们看看如何在以下步骤中使用缓动效果创建动画:

  1. /pages/目录中创建一个名为animate.vue的新页面,并在<template>块中添加以下元素:
// pages/animate.vue
<h1>Hello jQuery UI</h1>
  1. <template>块中使用 jQuery 的animate函数和 jQuery UI 的缓动效果创建动画,如下所示:
// pages/animate.vue
import $ from 'jquery'

export default {
  mounted () {
    var state = true
    $('h1').on('click', function() {
      if (state) {
        $(this).animate({
          color: 'red', fontSize: '10em'
        }, 1000, 'easeInQuint', () => {
          console.log('easing in done')
        })
      } else {
        $(this).animate({
          color: 'black', fontSize: '2em'
        }, 1000, 'easeOutExpo', () => {
          console.log('easing out done')
        })
      }
      state = !state
    })
  }
}

在此代码中,当单击元素时,我们使用easeInQuint缓动效果,再次单击时使用easeOutExpo缓动效果。单击时,元素的字体大小从2em变化到10em,再次单击时从10em变化到2em。对于文本颜色也是一样,当单击元素时,它在redblack之间进行动画变化。

  1. 刷新您的浏览器,您应该会看到我们已经将动画和缓动效果应用到H1上。

有关更多缓动效果,请访问api.jqueryui.com/easings/,有关 jQuery 动画函数的更多信息,请访问api.jquery.com/animate/

如果您想了解 jQuery UI 的其他效果、小部件和实用工具,请访问jqueryui.com/

尽管您可以使用 CSS 使用 Motion UI 创建动画和过渡效果,但是 jQuery UI 是另一种选项,可以使用 JavaScript 对 HTML 元素应用动画。除了 jQuery 和 jQuery UI 之外,还有其他 JavaScript 库,我们可以从中受益,以特定方式交互地和有趣地呈现我们的内容,例如在向上或向下滚动页面时对内容进行动画处理,以及从左侧或右侧滑入内容。我们将在接下来的部分中了解这两个动画工具,即 AOS 和 Swiper。让我们在下一节中进行。

您可以在我们的 GitHub 存储库的/chapter-3/nuxt-universal/adding-jquery-ui/中找到本节中使用的所有代码。

添加 AOS

AOS 是一个 JavaScript 动画库,可以在您向下(或向上)滚动页面时将 DOM 元素美观地动画显示出来。这是一个小型库,非常容易使用,可以在滚动页面时触发动画,而无需自己编写代码。要对元素进行动画处理,只需使用data-aos属性:

<div data-aos="fade-in">...</div>

就像这样简单,当您滚动页面时,元素将逐渐淡入。您甚至可以设置动画完成的持续时间。因此,让我们找出如何在以下步骤中将此库添加到您的 Nuxt 项目中:

  1. 在终端上通过 npm 安装 AOS:
$ npm i aos
  1. 将以下元素添加到index.vue中:
// pages/index.vue
<template>
  <div class="grid-x">
    <div class="medium-6 medium-offset-3 cell" data-aos="fade-up">
      <img src="~/assets/images/sample-01.jpg">
    </div>
    <div class="medium-6 medium-offset-3 cell" data-aos="fade-up">
      <img src="~/assets/images/sample-02.jpg">
    </div>
    <div class="medium-6 medium-offset-3 cell" data-aos="fade-up">
      <img src="~/assets/images/sample-03.jpg">
    </div>
  </div>
</template>

在此模板中,我们使用 Foundation 为元素添加网格结构,并通过使用data-aos属性在每个元素上应用 AOS fade-up动画。

  1. <script>块中导入 AOS JavaScript 和 CSS 资源,并在 Vue 组件挂载时初始化 AOS:
// pages/index.vue
<script>
import 'aos/dist/aos.css'
import aos from 'aos'

export default {
  mounted () {
    aos.init()
  }
}
</script>

当您刷新屏幕时,您应该看到元素逐个向上淡入,按顺序出现,就像您向下滚动页面一样。这样可以让您如此轻松地美观地呈现您的内容,是不是很棒?

然而,我们刚刚应用 AOS 的方式并不适合如果您还有其他页面需要进行动画处理。您需要将前面的脚本复制到需要 AOS 动画的每个页面上。因此,如果您有多个页面需要使用 AOS 进行动画处理,那么您应该进行全局注册和初始化。让我们在以下步骤中找出如何做到这一点:

  1. /plugins/目录中创建一个aos.client.js插件,导入 AOS 资源,并初始化 AOS 如下:
// plugins/client-only/aos.client.js
import 'aos/dist/aos.css'
import aos from 'aos'

aos.init({
  duration: 2000,
})

在这个插件中,我们指示 AOS 全局地花费 2 秒来动画化我们的元素。您可以在 https://github.com/michalsnik/aos#1-initialize-aos 找到其余的设置选项。

  1. 在 Nuxt 配置文件的plugins选项中注册前面的 AOS 插件如下:
// nuxt.config.js
module.exports = {
  plugins: [
    '~/plugins/client-only/aos.client.js',
  ],
}

就是这样。现在您可以将 AOS 动画应用于多个页面,而无需复制脚本。

请注意,我们在 AOS 插件中直接导入 CSS 资源,而不是通过 Nuxt 配置文件中的css选项全局导入,与我们在以前的部分中为 Foundation 和 Motion UI 所做的相反。因此,如果您想为 Foundation 做同样的事情,可以直接将其 CSS 资源导入到插件文件中,如下所示:

// plugins/client-only/foundation-site.client.js
import 'foundation-sites/dist/css/foundation.min.css'
import 'foundation-sites'

然后,您无需在 Nuxt 配置文件中使用全局的css选项。如果您希望保持配置文件“轻量”并将 UI 框架的 CSS 和 JavaScript 资源保留在其插件文件中,这种方式是首选。

您可以在我们的 GitHub 存储库的/chapter-3/nuxt-universal/adding-aos/中找到此示例 Nuxt 应用程序的源代码。

如果您想了解有关 AOS 和其余动画名称的更多信息,请访问 https://michalsnik.github.io/aos/。

现在让我们探索最后一个 JavaScript 助手,可以帮助加速您的前端开发 - Swiper

添加 Swiper

Swiper 是一个 JavaScript 触摸滑块,可用于现代 Web 应用程序(桌面或移动)和移动本机或混合应用程序。它是 Framework7(https://framework7.io/)和 Ionic Framework(https://ionicframework.com/)的一部分,用于构建移动混合应用程序。我们可以像在以前的部分中使用 CDN 资源一样轻松地为 Web 应用程序设置 Swiper。但让我们看看您如何在以下步骤中以正确的方式在 Nuxt 中安装和使用它:

  1. 在您的 Nuxt 项目中通过终端使用 npm 安装 Swiper:
$ npm i swiper
  1. 添加以下 HTML 元素以在<template>块中创建图像滑块:
// pages/index.vue
<template>
  <div class="swiper-container">
    <div class="swiper-wrapper">
      <div class="swiper-slide"><img 
       src="~/assets/images/sample-01.jpg">
      </div>
      <div class="swiper-slide"><img 
       src="~/assets/images/sample-02.jpg">
      </div>
      <div class="swiper-slide"><img 
       src="~/assets/images/sample-03.jpg">
      </div>
    </div>
    <div class="swiper-button-next"></div>
    <div class="swiper-button-prev"></div>
  </div>
</template>

从这些元素中,我们希望创建一个图像滑块,其中包含三个图像,可以从左侧或右侧滑入视图,以及两个按钮 - 下一个按钮和上一个按钮。

  1. <script>块中导入 Swiper 资源并在页面挂载时创建一个新的 Swiper 实例:
// pages/index.vue
<script>
import 'swiper/swiper-bundle.css'
import Swiper from 'swiper/bundle'

export default {
  mounted () {
    var swiper = new Swiper('.swiper-container', {
      navigation: {
        nextEl: '.swiper-button-next',
        prevEl: '.swiper-button-prev',
      },
    })
  }
}
</script>

在这个脚本中,我们向 Swiper 提供了我们图像滑块的类名,以便可以初始化一个新实例。此外,我们通过 Swiper 的pagination选项将下一个和上一个按钮注册到新实例。

您可以在swiperjs.com/api/找到用于初始化 Swiper 和与实例交互的 API 的其余设置选项。

  1. <style>块中添加以下 CSS 样式来自定义图像滑块:
// pages/index.vue
<style>
  .swiper-container {
    width: 100%;
    height: 100%;
  }
  .swiper-slide {
    display: flex;
    justify-content: center;
    align-items: center;
  }
</style>

在这个样式中,我们只想通过在 CSS 的widthheight属性上使用 100%,并通过使用 CSS 的flex属性将图像置于滑块容器中央,使幻灯片占据整个屏幕。

  1. 现在,您可以运行 Nuxt 并在浏览器中加载页面,您应该会看到一个交互式图像滑块很好地工作。

您可以在 Swiper 官方网站swiperjs.com/demos/找到一些很棒的示例幻灯片。

请注意,我们刚刚使用的 Swiper 方式仅适用于单个页面。如果您想在多个页面上创建滑块,则可以通过插件全局注册 Swiper。因此,让我们在以下步骤中了解如何做到这一点:

  1. /plugins/目录中创建一个名为swiper.client.js的插件,导入 Swiper 资源,并创建一个名为$swiper的属性。将 Swiper 附加到此属性,并将其注入到 Vue 实例中,如下所示:
// plugins/client-only/swiper.client.js
import 'swiper/swiper-bundle.css'
import Vue from 'vue'
import Swiper from 'swiper/bundle'

Vue.prototype.$swiper = Swiper
  1. 在 Nuxt 配置文件的plugins选项中注册此 Swiper 插件:
// nuxt.config.js
export default {
  plugins: [
    '~/plugins/client-only/swiper.client.js',
  ],
}
  1. 现在,您可以通过使用this关键字调用$swiper属性,在应用的多个页面中创建 Swiper 的新实例,例如:
// pages/global.vue
<script>
export default {
  mounted () {
    var swiper = new this.$swiper('.swiper-container', { ... })
  }
}
</script>

同样,我们将 CSS 资源组织在插件文件中,而不是通过 Nuxt 配置文件中的css选项全局注册它。但是,如果您想要全局覆盖这些 UI 框架和库中的一些样式,那么通过在css选项中全局注册它们的 CSS 资源,然后在/assets/目录中存储的 CSS 文件中添加自定义样式,更容易覆盖它们。

您可以从我们的 GitHub 存储库的/chapter-3/nuxt-universal/adding-swiper/中下载本章的源代码。如果您想了解更多关于 Swiper 的信息,请访问swiperjs.com/

干得好!您已经成功掌握了我们为您选择的一些流行的 UI 框架和库,以加速您的前端开发。我们希望它们将对您未来创建的 Nuxt 项目有所帮助。在接下来的章节中,我们将偶尔使用这些框架和库,特别是在最后一章 - [第十八章]《使用 CMS 和 GraphQL 创建 Nuxt 应用》中。现在,让我们总结一下您在本章学到的内容。

总结

在本章中,您已经将 Foundation 安装为 Nuxt 项目中的主要 UI 框架,并使用 Foundation 的网格系统、JavaScript 实用程序和插件来创建简单的网格布局、表单和导航。您已经使用 Foundation 的 Motion UI 来创建 Sass 动画和过渡,还使用了 Foundation Icon Fonts 3 来向 HTML 页面添加常见和有用的图标。您已经安装了 Less 作为样式预处理器,并在 Less 样式表中创建了一些变量。

您已经安装了 jQuery UI,并将其手风琴小部件添加到您的应用程序中,并使用其缓动效果创建了动画。您已经安装了 AOS,并在向下或向上滚动页面时使用它来使元素动画进入视口。最后,您已经安装了 Swiper 来创建一个简单的图像幻灯片。最后但同样重要的是,您已经学会了如何通过 Nuxt 配置文件全局安装这些框架和库,或者仅在特定页面上局部使用它们。

在接下来的章节中,我们将介绍 Nuxt 中的视图、路由和过渡。您将创建自定义页面、路由和 CSS 过渡,并学习如何使用/assets/目录来提供图像和字体等资源。此外,您还将学习如何自定义默认布局并在/layouts/目录中添加新的布局。我们将提供一个简单的网站示例,该示例使用了所有这些 Nuxt 功能,以便您可以从本书中学到的内容中获得具体用途的感觉。因此,让我们在下一章中进一步探索 Nuxt!

第二部分:视图、路由、组件、插件和模块

在本节中,我们将开始添加路由、页面、模板、组件、插件、模块和 Vue 表单,使我们的 Nuxt 应用程序变得更加复杂和有趣。

本节包括以下章节:

  • 第四章,添加视图、路由和过渡

  • 第五章,添加 Vue 组件

  • 第六章,编写插件和模块

  • 第七章,添加 Vue 表单

添加视图、路由和过渡

在前一章中,您为前端 UI 框架和库创建了一些简单的页面、路由,甚至布局,但它们只是非常基本的。因此,在本章中,我们将深入研究每一个,以及 Nuxt 中的模板。您将自定义默认模板和布局,并创建自定义模板。您还将学习如何自定义全局 meta 标签,并将特定的 meta 标签添加到应用程序的各个页面。如果信息是有用的。您将为页面过渡创建 CSS 和 JavaScript 过渡和动画。因此,在本章结束时,您将能够通过本章学到的知识以及在上一章中学到的知识,交付一个简单但完全功能的 Web 应用程序或网站(带有一些虚拟数据)。

本章我们将涵盖的主题如下:

  • 创建自定义路由

  • 创建自定义视图

  • 创建自定义过渡

第四章:创建自定义路由

如果我们要了解 Nuxt 中路由器的工作原理,我们首先应该了解它在 Vue 中的工作原理。然后我们可以理解如何在我们的 Nuxt 应用程序中实现它。传统 Vue 应用程序中的自定义路由是通过 Vue Router 创建的。因此,让我们首先了解一下 Vue Router 是什么。

介绍 Vue Router

Vue Router 是一个 Vue 插件,允许您在单页面应用程序(SPA)中创建强大的路由,而无需刷新页面即可在页面之间导航。一个快速的用法是,例如,如果我们想要一个用于所有用户但具有不同用户 ID 的User组件。您可以如下使用此组件:

const User = {
  template: '<div>User {{ $route.params.id }}</div>'
}

const router = new VueRouter({
  routes: [
    { path: '/user/:id', component: User }
  ]
})

在这个例子中,任何以/user开头的路由后跟一个 ID(例如,/user/1user/2)将被定向到User组件,该组件将使用该 ID 呈现模板。这仅在安装了 Vue 插件时才可能,因此让我们看看如何在下一节为 Vue 应用程序安装它,然后学习它在 Nuxt 应用程序中的工作原理。

有关 Vue Router 的更多信息,请访问router.vuejs.org/

安装 Vue Router

在 Vue 中,您必须显式安装 Vue Router 以在传统的 Vue 应用程序中创建路由。即使您使用 Vue CLI(我们将在第十一章中介绍编写路由中间件和服务器中间件),您也必须选择手动选择功能以从提示您选择的选项中选择 Router,以选择您需要的功能。因此,让我们看看如何在本节中手动安装它。安装 Vue Router 有两种选项:

  • 您可以使用 npm:
$ npm install vue-router

然后,在应用程序根目录中,通过Vue.use()显式导入vue-router

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)
  • 或者,您可以使用 CDN 或直接下载:
<script src="/path/to/vue.js"></script>
<script src="/path/to/vue-router.js"></script>

如果您使用 CDN,只需在 Vue 核心之后添加vue-router,其余安装将自行处理。安装 Vue Router 完成后,您可以使用它创建路由。

使用 Vue Router 创建路由

如果您使用 CDN 选项,首先在项目根目录中创建一个.html文件,其中包含以下基本 HTML 结构,并在<head>块中包含 CDN 链接:

<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/vue/dist/vue.js"></script>
    <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
  </head>
  <body>
    //...
  </body>
</html>

之后,您可以通过以下步骤快速启动 Vue Router:

  1. <body>块中使用以下标记创建应用程序基础:
<div id="app">
  <h1>Hello App!</h1>
  <p>
    <router-link to="/about">About</router-link>
    <router-link to="/contact">Contact</router-link>
  </p>
  <router-view></router-view>
</div>
<script type="text/javascript">
  //...
</script>

<router-link>组件用于指定目标位置,并将呈现为带有href<a>标记,而<router-view>组件用于呈现请求的内容,这是我们将在下一步中创建的 Vue 组件。

  1. <script>块中定义两个 Vue 组件:
const About = { template: '<div>About</div>' }
const Contact = { template: '<div>Contact</div>' }
  1. 创建一个名为routes的常量变量,并将 Vue 组件添加到component属性中,该属性与<router-link>中的链接匹配:
const routes = [
  { path: '/about', component: About },
  { path: '/contact', component: Contact }
]
  1. 使用new运算符创建路由实例,并传入routes常量:
const router = new VueRouter({
  routes
})

请注意,上述代码块中的route是 ES6/ES2015 中routes: routes的简写形式(简写属性名称)。有关简写属性名称的更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer

  1. 使用new运算符创建 Vue 实例,并传入router实例,然后将#app元素挂载到根实例:
const app = new Vue({
  router
}).$mount('#app')
  1. 在浏览器中运行应用程序,然后您应该在屏幕上看到关于和联系链接。当您导航到/about/contact时,您应该看到它们的组件如预期般成功呈现在屏幕上。

您可以在我们的 GitHub 存储库中的/chapter-4/vue/vue-route/basic.html中找到前面应用的代码,并在您喜欢的浏览器中运行它,以查看它的工作原理。

现在,让我们探索 Nuxt 如何通过 Vue Router 为我们生成前面的路由。在 Nuxt 中创建路由的过程更简单,因为vue-router在 Nuxt 中已经预装。这意味着从技术上讲,您可以跳过传统 Vue 应用程序中的安装步骤。您还可以跳过前面的 JavaScript 步骤-步骤 3 到 5。Nuxt 将自动扫描/pages/目录中的.vue文件树,并为您生成路由。因此,让我们探索 Nuxt 如何为您创建和处理路由。我们将首先开始创建基本路由。

创建基本路由

基本路由是通过简单地将具有固定文件名的.vue文件添加到/pages/目录中来创建的。您还可以通过将.vue文件组织到不同的文件夹中来创建子路由。接下来看一个例子:

pages/
--| users/
-----| index.vue
-----| john-doe.vue
--| index.vue

然后,Nuxt 将为您生成以下路由,而无需您编写任何路由:

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'users',
      path: '/users',
      component: 'pages/users/index.vue'
    },
    {
      name: 'users-john-doe',
      path: '/users/john-doe',
      component: 'pages/users/john-doe.vue'
    }
  ]
}

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/routing/basic-routes/中找到此示例应用程序。

您应该熟悉上一章中的这些基本路由。这种类型的路由适用于顶级页面,例如/about/contact/posts。但是,如果在每个顶级页面中有多个子页面,并且它们会随着时间动态增加,那么您应该使用动态路由来处理这些子页面的路由。让我们在下一节中了解如何创建动态路由。

创建动态路由

当使用下划线时,Nuxt 会生成动态路由。在更复杂的应用程序中,动态路由是有用且不可避免的。因此,如果您想创建动态路由,只需创建一个带有前缀下划线后跟文件名(或目录)的.vue文件(或目录)。接下来看一个例子:

pages/
--| _slug/
-----| index.vue
--| users/
-----| _id.vue
--| index.vue

然后,您将从 Nuxt 获得以下路由,而无需您编写任何路由:

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'users-id',
      path: '/users/:id?',
      component: 'pages/users/_id.vue'
    },
    {
      name: 'slug',
      path: '/:slug',
      component: 'pages/_slug/index.vue'
    }
  ]
}

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/routing/dynamic-routes/中找到此示例应用程序。

动态路由适用于共享相同布局的页面。例如,如果您使用相同布局的/about/contact路由(这是相当不太可能的情况),那么前面动态路由示例代码中的/_slug/目录是一个不错的选择。因此,就像在/users路由下共享相同布局的子页面一样,/_id.vue文件方法对于这种情况是一个不错的选择。

除了使用这个(简单的)动态路由为/users路由下的子页面创建子路由之外,我们还可以为它们使用更复杂的动态路由-嵌套路由。这是一种情况,当渲染子页面时,您不希望父布局完全被子布局替换;换句话说,当您希望在父布局呈现子页面时。让我们在下一节中了解如何实现这一点。

创建嵌套路由

简而言之,从嵌套组件生成的路由称为嵌套路由。在某些情况下,您可能希望组合嵌套在其他组件(父组件)内的组件(子组件),并且您希望在父组件的特定视图中呈现这些子组件,而不是将父组件替换为子组件。

要在 Vue 应用程序中执行此操作,您需要在父组件中插入一个<router-view>组件,用于加载子组件的内容。例如,假设您有一个Users父组件,并且当调用特定用户时,您希望加载单个用户的内容到此父组件中。然后,您可以按照以下步骤为它们创建嵌套路由:

  1. 创建一个父组件:
const Users = {
  template: `
    <div class="user">
      <h2>Users</h2>
      <router-link to="/user/1">1</router-link>
      <router-link to="/user/2">2</router-link>
      <router-link to="/user/3">3</router-link>
      <router-view></router-view>
    </div>
  `
}

如果将前面的代码放入图表中,可以如下进行可视化解释:

+-------------------+
| users             |
| +---------------+ |
| | 1, 2, 3       | |
| +---------------+ |
| +---------------+ |
| | <router-view> | |
| +---------------+ |
+-------------------+
  1. 创建一个子组件,用于显示单个用户的内容或信息:
const User = { template: '<div>User {{ $route.params.id }}</div>' }
  1. 使用children属性创建嵌套路由,如下所示:
const routes = [
  {
    path: '/users',
    component: Users,
    children: [
      {
        path: ':id',
        component: User,
        name: 'user-id'
      }
    ]
  }
]
  1. 定义路由实例并传入前面的嵌套路由,然后将路由器注入 Vue 根实例:
const router = new VueRouter({
  routes
})

const app = new Vue({
  router
}).$mount('#app')

然后,当单击子链接时,前面的代码将生成以下可视化内容;例如,子编号1/users/1将动态生成为其路由:

/users/1
+-------------------+
| users             |
| +---------------+ |
| | 1, 2, 3       | |
| +---------------+ |
| +---------------+ |
| | User 1        | |
| +---------------+ |
+-------------------+
  1. 但我们还没有完成,因为当尚未调用任何用户时,我们仍然需要处理/users中的空视图。因此,为了解决这个问题,您将创建一个索引子组件,如下所示:
const Index = { template: '<div>Users Index</div>' }
  1. children块中添加前面的索引组件,使用空字符串''作为path键的值:
const routes = [
  {
    path: '/users',
    component: Users,
    children: [
      {
        path: '',
        component: Index,
        name: 'user-index'
      },
      //...
    ]
  }
]
  1. 现在,如果你在浏览器中导航到/users,你应该会得到以下结果:
/users
+-------------------+
| users             |
| +---------------+ |
| | 1, 2, 3       | |
| +---------------+ |
| +---------------+ |
| | Users Index   | |
| +---------------+ |
+-------------------+

你可以看到children选项只是另一个路由配置对象的数组,就像routes常量本身一样。因此,你可以根据需要保持嵌套视图的层级。但是,为了更好地维护,我们应该尽量避免深层嵌套,保持我们的应用尽可能简单。

你可以在我们的 GitHub 仓库的/chapter-4/vue/vue-route/nested-route.html中找到前面示例的代码。

在 Nuxt 中也是一样的;你可以通过使用vue-router的子路由来创建嵌套路由。如果你想定义嵌套路由的父组件,你只需要创建一个与包含子视图的目录同名的 Vue 文件。看下面的例子:

pages/
--| users/
-----| _id.vue
-----| index.vue
--| users.vue

Nuxt 将自动生成以下路由:

router: {
  routes: [
    {
      path: '/users',
      component: 'pages/users.vue',
      children: [
        {
          path: '',
          component: 'pages/users/index.vue',
          name: 'users'
        },
        {
          path: ':id',
          component: 'pages/users/_id.vue',
          name: 'users-id'
        }
      ]
    }
  ]
}

你可以看到 Nuxt 生成的路由与在 Vue 应用中一样。请注意,在 Nuxt 中,我们在父组件(.vue文件)中包含<nuxt-child/>,而在 Vue 中,我们在父组件中包含<router-view></router-view>,就像前面的User示例一样。让我们通过一个练习更好地理解这一点,就像我们在 Vue 应用中做的那样:

  1. 创建一个带有<nuxt-child/>组件的父组件:
// pages/users.vue
<template>
  <div>
    <h1>Users</h1>
    <nuxt-child/>
  </div>
</template>
  1. 创建一个索引子组件来保存用户列表:
// pages/users/index.vue
<template>
  <ul>
    <li v-for="user in users" v-bind:key="user.id">
      <nuxt-link :to="`users/${user.id}`">
        {{ user.name }}
      </nuxt-link>
    </li>
  </ul>
</template>

<script>
import axios from 'axios'
export default {
  async asyncData () {
    let { data } = await 
    axios.get('https://jsonplaceholder.typicode.com/users')
    return { users: data }
  }
}
</script>

请注意,在本章的即将到来的部分中,我们将介绍asyncData方法和第五章中的axios,所以现阶段不用担心它们。

  1. 创建一个单独的子组件,其中包含返回到子索引页面的链接:
// pages/users/_id.vue
<template>
  <div v-if="user">
    <h2>{{ user.name }}</h2>
    <nuxt-link class="button" to="/users">
      Users
    </nuxt-link>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  async asyncData ({ params }) {
    let { data } = await 
    axios.get('https://jsonplaceholder.typicode.com/users/'
     + params.id)
    return { user: data }
  }
}
</script>

你可以看到 Nuxt 已经帮你省去了在 Vue 应用中配置嵌套路由的步骤,使用了children属性(如前面 Vue 应用示例中的步骤 3所示)。

因此,在这个 Nuxt 应用中,当子页面渲染后,users.vue中的<h1>Users</h1>元素将始终可见。包含列表元素的<ul>元素将始终被子页面替换。如果父级信息在子页面中是持久的,这将非常有用,因为在子页面渲染时不需要重新请求父级信息。

你可以在我们的 GitHub 仓库的/chapter-4/nuxt-universal/routes/nested-routes/中找到这个示例应用。

由于存在用于“升级”基本路由的动态路由,您可能会问,嵌套路由的动态路由呢?从技术上讲,是的,这是可能的,它们被称为动态嵌套路由。因此,让我们在下一节中了解更多关于它们的信息。

创建动态嵌套路由

我们已经看到了动态路由和嵌套路由的工作原理,因此从理论上和技术上讲,可以将这两个选项结合起来,通过在动态父级(例如_topic)中拥有动态子级(例如_subTopic)来创建动态嵌套路由。以下是最佳示例结构:

pages/
--| _topic/
-----| _subTopic/
--------| _slug.vue
--------| index.vue
-----| _subTopic.vue
-----| index.vue
--| _topic.vue
--| index.vue

Nuxt 将自动生成以下路由:

router: {
  routes: [
    {
      path: '/',
      component: 'pages/index.vue',
      name: 'index'
    },
    {
      path: '/:topic',
      component: 'pages/_topic.vue',
      children: [
        {
          path: '',
          component: 'pages/_topic/index.vue',
          name: 'topic'
        },
        {
          path: ':subTopic',
          component: 'pages/_topic/_subTopic.vue',
          children: [
            {
              path: '',
              component: 'pages/_topic/_subTopic/index.vue',
              name: 'topic-subTopic'
            },
            {
              path: ':slug',
              component: 'pages/_topic/_subTopic/_slug.vue',
              name: 'topic-subTopic-slug'
            }
          ]
        }
      ]
    }
  ]
}

您可以看到路由变得更加复杂,这可能会使您的应用程序在阅读和尝试理解文件目录树时更难开发,因为它相当抽象,如果增长“更大”,它可能会变得过于抽象。将应用程序设计和结构化为尽可能简单是一个良好的实践。以下示例路由是这种类型路由的一个很好的例子:

  • /_topic/的一些示例如下:
/science
/arts
  • /_topic/_subTopic/的一些示例如下:
/science/astronomy
/science/geology
/arts/architecture
/arts/performing-arts
  • /_topic/_subTopic/_slug.vue的一些示例如下:
/science/astronomy/astrophysics
/science/astronomy/planetary-science
/science/geology/biogeology
/science/geology/geophysics
/arts/architecture/interior-architecture
/arts/architecture/landscape-architecture
/arts/performing-arts/dance
/arts/performing-arts/music

您可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/routing/dynamic-nested-routes/中找到此类型路由的示例应用程序。

创建动态路由和页面始终需要路由中的参数(换句话说,路由参数),以便我们可以将它们(无论是 ID 还是 slug)传递给动态页面进行处理。但在处理和响应参数之前,验证它们是一个好主意。因此,让我们看看如何在下一个主题中验证路由参数。

验证路由参数

您可以在组件中使用validate方法来验证动态路由的参数,在进一步处理或异步获取数据之前验证参数。这种验证应该始终返回true以继续前进;如果得到false布尔值,Nuxt 将停止路由并立即抛出 404 错误页面。例如,您希望确保 ID 必须是一个数字:

// pages/users/_id.vue
export default {
  validate ({ params }) {
    return /^\d+$/.test(params.id)
  }
}

因此,如果您使用localhost:3000/users/xyz请求页面,您将收到一个带有“此页面找不到”消息的 404 页面。如果要自定义 404 消息,可以使用throw语句抛出带有Error对象的异常,如下所示:

// pages/users/_id.vue
export default {
  validate ({ params }) {
    let test = /^\d+$/.test(params.id)
    if (test === false) {
      throw new Error('User not found')
    }
    return true
  }
}

你还可以在validate方法中使用async进行await操作:

async validate({ params }) {
  // ...
}

你还可以在validate方法中使用return承诺:

validate({ params }) {
  return new Promise(...)
}

你可以在/chapter-4/nuxt-universal/routing/validate-route-params/中找到前面示例应用程序的 ID 验证。

在我们的 GitHub 存储库中。

验证路由参数是处理无效或未知路由的一种方式,但另一种处理它们的方式是使用_.vue文件来捕捉它们。所以,让我们在下一节中找出如何做。

使用 _.vue 文件处理未知路由

除了使用validate方法抛出通用404 页面外,你还可以使用_.vue文件抛出自定义错误页面。让我们通过以下步骤来探讨这是如何工作的:

  1. /pages/目录中创建一个空的_.vue文件,如下所示:
pages/
--| _.vue
--| index.vue
--| users.vue
--| users/
-----| _id.vue
-----| index.vue
  1. 为这个_.vue文件添加任何自定义内容,如下所示:
// pages/_.vue
<template>
  <div>
    <h1>Not found</h1>
    <p>Sorry, the page you are looking for is not found.</p>
  </div>
</template>
  1. 启动应用程序并导航到以下路由,你会发现 Nuxt 将调用这个_.vue文件来处理这些请求,这些请求不匹配正确的路由级别:
/company
/company/careers
/company/careers/london
/users/category/subject
/users/category/subject/type
  1. 如果你想在特定级别上抛出一个更具体的 404 页面 - 例如,仅在/users路由中 - 那么在/users/文件夹中创建另一个_.vue文件,如下所示:
pages/
--| _.vue
--| index.vue
--| users.vue
--| users/
-----| _.vue
-----| _id.vue
-----| index.vue
  1. 为这个_.vue文件添加自定义内容,如下所示:
// pages/users/_.vue
<template>
  <div>
    <h1>User Not found</h1>
    <p>Sorry, the user you are looking for is not found.</p>
  </div>
</template>
  1. 再次导航到以下路由,你会发现 Nuxt 不再调用这个/pages/_.vue文件来处理不匹配的请求:
/users/category/subject
/users/category/subject/type

相反,Nuxt 现在调用/pages/users/_.vue文件来处理它们。

你可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/routing/unknown-routes/中找到这个示例应用程序。

我们希望到现在为止,你应该知道如何以各种方式创建适合你的应用程序的路由,但是在 Nuxt 中,路由和页面是密不可分的,不可分割的,所以你还需要知道如何创建 Nuxt 页面,这些页面是自定义视图。你将在下一个主题中学习如何做到这一点。

创建自定义视图

你在上面创建的自定义路由中的每个路由都将落在一个“页面”上,该页面具有我们希望在前端显示的所有 HTML 标记和内容。从软件架构的角度来看,这些 HTML 标记和内容,包括元信息、图像和字体,都是你的应用程序的视图或呈现层。在 Nuxt 中,我们可以轻松地创建和自定义我们的视图。让我们来了解一下 Nuxt 视图的组成部分以及如何自定义它。

了解 Nuxt 视图

Nuxt 中的视图结构包括应用程序模板、HTML 头部、布局和页面层。你可以使用它们来为应用程序路由创建视图。在一个更复杂的应用程序中,你可以使用来自 API 的数据填充它们,而在一个简单的应用程序中,你可以直接手动嵌入虚拟数据。我们将在接下来的章节中逐一介绍这些层。在深入了解之前,请花一点时间研究下面的图表,它将为你提供 Nuxt 视图的完整视图:

参考来源:nuxtjs.org/guide/views

你可以看到文档-HTML 文件是 Nuxt 视图的最外层,其后是布局、页面和可选的页面子层和 Vue 组件层。文档-HTML 文件是你的 Nuxt 应用程序的应用程序模板。让我们首先从这个最基本的层开始,学习如何在下一节中自定义它。

自定义应用程序模板

Nuxt 在幕后为你创建 HTML 应用程序模板,因此基本上你不必费心去创建它。然而,你仍然可以自定义它,比如添加脚本或样式,如果你想的话。默认的 Nuxt HTML 模板就是这么简单:

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
 <head>
 {{ HEAD }}
 </head>
 <body {{ BODY_ATTRS }}>
 {{ APP }}
 </body>
</html>

如果你想要更改或覆盖这个默认值,只需在根目录中创建一个app.html文件。看下面的例子:

// app.html
<!DOCTYPE html>
<!--[if IE 9]><html lang="en-US" class="lt-ie9 ie9" {{ HTML_ATTRS }}><![endif]-->
<!--[if (gt IE 9)|!(IE)]><!--><html {{ HTML_ATTRS }}><!--<![endif]-->
  <head>
    {{ HEAD }}
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

重新启动你的应用程序,你会看到你的自定义应用程序 HTML 模板已经替换了 Nuxt 的默认模板。

你可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/view/app-template/中找到这个例子。

接下来最接近 HTML 文档(即<html>元素)的一层是 HTML 头部,即<head>元素,其中包含重要的元信息以及页面的脚本和样式。我们不会直接在应用程序模板中添加或自定义这些数据,而是在 Nuxt 配置文件和/pages/目录中的文件中进行。因此,让我们在下一节中了解如何做到这一点。

创建自定义 HTML 头部

一个 HTML 的<head>元素由<title><style><link><meta>元素组成。手动添加这些元素可能是一项繁琐的任务。因此,Nuxt 会在你的应用程序中为你处理这些元素。在第二章中,开始使用 Nuxt,你学到了它们是由 Nuxt 从 JavaScript 对象中的数据生成的,这些数据是用花括号({})在 Nuxt 配置文件中编写的,如下所示:

// nuxt.config.js
export default {
  head: {
    title: 'Default Title',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 'parent' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  }
}

在本主题中,我们对 Nuxt 配置文件中的meta块和/pages/目录中的页面感兴趣。Nuxt 使用 Vue Meta 插件来管理这些元属性。因此,要了解它在 Nuxt 中的工作原理,我们首先应该了解它在传统 Vue 应用程序中的工作原理。

介绍 Vue Meta

Vue Meta 是一个用于在 Vue 中管理和创建 HTML 元数据的 Vue 插件,具有内置的 Vue 响应性。您只需向任何 Vue 组件添加metaInfo特殊属性,它将自动呈现为 HTML 元标记,如下所示:

// Component.vue
export default {
  metaInfo: {
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' }
    ]
  }
}

上述的 JavaScript 代码块将被呈现为您页面中的以下 HTML 标签:

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

有关 Vue Meta 的更多信息,请访问vue-meta.nuxtjs.org/

您可以看到,您只需要在 JavaScript 对象中提供元数据。现在,让我们安装它并学习如何为 Vue 应用程序配置它。

安装 Vue Meta

像所有其他 Vue 插件一样,您可以通过以下步骤安装 Vue Meta 并将其连接到您的 Vue 应用程序中:

  1. 通过 npm 安装 Vue Meta:
$ npm i vue-meta

或者,您可以通过 CDN 使用<script>元素安装它,如下所示:

<script src="https://unpkg.com/vue-meta@1.5.8/lib/vue-meta.js"></script>
  1. 在您的主应用程序文件中使用 Vue Router 导入 Vue Meta,如果您正在编写一个 ES6 JavaScript 应用程序:
//main.js
import Vue from 'vue'
import Router from 'vue-router'
import Meta from 'vue-meta'

Vue.use(Router)
Vue.use(Meta)
export default new Router({
  //...
})
  1. 然后,您可以在任何 Vue 组件中使用它,如下所示:
// app.vue
var { data } = await axios.get(...)
export default {
  metaInfo () {
    return {
      title: 'Nuxt',
      titleTemplate: '%s | My Awesome Webapp',
      meta: [
        { vmid: 'description', name: 'description', content: 'My
          Nuxt portfolio' }
      ]
    }
  }
}

在这个例子中,因为我们使用axios来异步获取数据,我们必须使用metaInfo方法从异步数据中注入元信息,而不是使用metaInfo属性。您甚至可以使用titleTemplate选项为您的页面标题添加模板,就像前面的例子一样。接下来,我们将创建一个简单的 Vue 应用程序,并使用这个插件,以便您可以更全面地了解如何使用它。

在 Vue 应用程序中使用 Vue Meta 创建元数据

像往常一样,我们可以在单个 HTML 页面上启动和运行 Vue 应用程序。让我们开始吧:

  1. <head>块中包含 CND 链接:
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="https://unpkg.com/vue-meta@1.5.8/lib/vue-meta.js"></script>
  1. <script>块中创建以下带有元数据的组件:
const About = {
  name: 'about',
  metaInfo: {
    title: 'About',
    titleTemplate: null,
    meta: [
      { vmid: 'description', name: 'description', content: 'About 
        my Nuxt...' 
      }
    ]
  }
}
const Contact = {
  name: 'contact',
  metaInfo: {
    title: 'Contact',
    meta: [
      { vmid: 'description', name: 'description', content: 
       'Contact me...' }
    ]
  }
}
  1. 然后,在根实例中添加默认的元数据:
const app = new Vue({
  metaInfo: {
    title: 'Nuxt',
    titleTemplate: '%s | My Awesome Webapp',
    meta: [
      { vmid: 'description', name: 'description', content: 'My 
        Nuxt portfolio' 
      }
    ]
  },
  router
}).$mount('#app')

请注意,我们可以通过在子组件的titleTemplate选项中简单地添加null来覆盖组件中的默认元数据模板,就像前面的About组件一样。

您可以在我们的 GitHub 存储库的/chapter-4/vue/vue-meta/basic.html中找到此示例应用程序。

在此示例中,由于我们不使用axios异步获取数据,因此可以直接使用metaInfo属性,而不是使用metaInfo方法来使用异步数据注入 meta 信息。然后,当您在刚刚创建的路由周围导航时,您将看到页面标题和 meta 信息在浏览器中发生变化。在 Vue 应用程序中使用此插件非常容易,不是吗?现在,我们应该看看它在 Nuxt 应用程序中的工作原理。

自定义 Nuxt 应用程序中的默认 meta 标签

在 Nuxt 应用程序中创建和自定义 meta 信息更简单,因为 Vue Meta 默认包含在 Nuxt 中。这意味着您无需像在 Vue 应用程序中那样安装它。您只需在 Nuxt 配置文件中使用head属性来定义应用程序的默认<meta>标签,如下所示:

// nuxt.config.js
head: {
  title: 'Nuxt',
  titleTemplate: '%s | My Awesome Webapp',
  meta: [
    { charset: 'utf-8' },
    { name: 'viewport', content: 'width=device-width, initial-scale=1' },
    { hid: 'description', name: 'description', content: 'My 
      Nuxt portfolio' }
  ]
}

然而,Nuxt 和 Vue 应用程序之间的区别在于,在 Nuxt 中必须使用hid键,而在 Vue 中使用vmid。您应该始终使用hid来防止在子组件中定义它们(meta 标签)时重复 meta 标签的发生。另外,请注意,metaInfo键仅在 Vue 中使用,而title键在 Nuxt 中使用,用于添加我们的元信息。

这就是您如何为您的 Nuxt 应用程序添加和自定义标题和 meta 标签。但是,它们是全局添加的,这意味着它们适用于应用程序中的所有页面。那么,如何将它们特定地添加到页面并在 Nuxt 配置文件中覆盖全局的呢?让我们在下一节中找出答案。

为 Nuxt 页面创建自定义 meta 标签

如果要为特定页面添加自定义 meta 标签或在 Nuxt 配置文件中覆盖默认的 meta 标签,只需直接在该特定页面上使用head方法,该方法将返回一个包含titlemeta选项数据的 JavaScript 对象,如下所示:

// pages/index.vue
export default {
  head () {
    return {
      title: 'Hello World!',
      meta: [
        { hid: 'description', name: 'description', content: 'My 
          Nuxt portfolio' }
      ]
    }
  }
}

然后,您将获得此页面的输出:

<title data-n-head="true">Hello World! | My Awesome Webapp</title>
<meta data-hid="description" name="description" content="My Nuxt portfolio" data-n-head="true">

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/view/html-head/中找到此示例应用程序。

所以,就是这样。这就是关于 Nuxt 中应用模板和 HTML 头的全部内容。Nuxt 视图中的下一个内部层是布局,我们将在下一节中指导您如何创建自定义布局。让我们开始吧。

创建自定义布局

布局是您的页面和组件的支柱。您可能希望在应用程序中拥有多个不同的布局。在使用npx create-nuxt-app脚手架工具安装应用程序时,/layouts/目录中会自动生成一个名为default.vue的布局。就像应用程序模板一样,您可以修改此默认布局或创建自己的自定义布局。

修改默认布局

默认布局始终用于没有特定或自定义布局的页面。如果您转到/layouts/目录并打开此布局,您应该看到其中只有三行代码用于呈现您的页面组件:

// layouts/default.vue
<template>
  <nuxt/>
</template>

让我们修改这个默认布局,如下所示:

// layouts/default.vue
<template>
  <div>
    <div>...add a navigation bar here...</div>
    <nuxt/>
  </div>
</template>

您应该看到您在那里添加的任何内容 - 例如,应用程序中所有页面上的导航栏。请注意,无论您是修改此布局还是创建新布局,都要确保在您想要 Nuxt 导入页面组件的地方有<nuxt/>组件。让我们在下一节中探讨如何创建自定义布局。

创建新的自定义布局

有时,我们需要更多的布局来构建更复杂的应用程序。我们可能需要为某些页面创建不同的布局。对于这种情况,您需要创建自定义布局。您可以使用.vue文件创建自定义布局,然后将它们放在/layouts/目录中。看下面的例子:

// layouts/about.vue
<template>
  <div>
    <div>...add an about navigation bar here....</div>
    <nuxt/>
  </div>
</template>

然后,您可以在页面组件中使用layout属性将此自定义布局分配给该页面,如下所示:

// pages/about.vue
export default {
  layout: 'about'
  // OR
  layout (context) {
    return 'about'
  }
}

现在,Nuxt 将使用/layouts/about.vue文件作为此页面组件的基本布局。但是用于显示未知和无效路由的错误页面的布局又是什么呢?让我们在下一节中找出这是如何制作的。

创建自定义错误页面

每个安装的 Nuxt 应用程序都带有一个默认错误页面,存储在@nuxt包中的/node_modules/目录中,Nuxt 用于显示错误,如 404、500 等。您可以通过在/layouts/目录中添加error.vue文件来自定义它。让我们通过以下步骤了解如何实现这一点:

  1. /layouts/目录中创建自定义错误页面,如下所示:
// layouts/error.vue
<template>
  <div>
    <h2 v-if="error.statusCode === 404">Page not found</h2>
    <h2 v-else>An error occurred</h2>
    <nuxt-link to="/">Home page</nuxt-link>
  </div>
</template>

<script>
export default {
  props: ['error']
}
</script>

请注意,错误页面是一个页面组件。起初,它似乎有违直觉和令人困惑,因为它放在/layouts/目录而不是/pages/目录中。但是,即使它在/layouts/目录中,它也应该被视为一个页面。

  1. 就像其他页面组件一样,您可以为此错误页面创建自定义布局,如下所示:
// layouts/layout-error.vue
<template>
  <div>
    <h1>Error!</h1>
    <nuxt/>
  </div>
</template>
  1. 然后,只需将layout-error添加到错误页面的layout选项中:
// layouts/error.vue
<script>
export default {
  layout: 'layout-error'
}
</script>
  1. 现在,如果您导航到任何以下未知路由,Nuxt 将调用此自定义错误页面和自定义错误布局:
/company
/company/careers
/company/careers/london
/users/category/subject
/users/category/subject/type

你可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/view/custom-layouts/404/中找到这个 404 示例。

就是这样。这就是 Nuxt 中关于布局的全部内容。Nuxt 视图中的下一个内部层是页面,您将在下一节中学习如何为您的应用程序创建自定义页面。所以,请继续阅读。

创建自定义页面

页面是 Nuxt 视图的一部分,就像应用程序模板、HTML 头部和布局一样,我们已经介绍过了。/pages/目录是您存储页面的地方。您将在这个目录中花费大部分时间来为您的 Nuxt 应用程序创建页面。但是,创建页面并不是什么新鲜事——在上一节中,我们在/layouts/目录中创建了一个简单的错误页面,并且在学习如何为我们的应用程序创建自定义路由时创建了许多页面。因此,当您想为特定路由创建自定义页面时,只需在/pages/目录中创建一个.vue文件;例如,我们可以创建以下页面:

pages/
--| index.vue
--| about.vue
--| contact.vue

但是,创建自定义页面需要更多。我们需要了解 Nuxt 中附带的页面属性和函数。尽管页面是 Nuxt 应用程序开发的重要部分,但在 Vue 应用程序开发中并没有强调,它们与 Vue 组件密切相关,并且与组件的工作方式有些不同。因此,要创建页面并充分利用它,我们首先需要了解 Nuxt 中的页面是什么。让我们找出来。

理解页面

页面本质上是一个 Vue 组件。它与标准 Vue 组件的区别在于仅在 Nuxt 中添加的属性和函数。我们使用这些特殊的属性和函数在呈现页面之前设置或获取数据,如下所示:

<template>
  <p>{{ message }}!</p>
</template>

<script>
export default {
  asyncData (context) {
    return { message: 'Hello World' }
  }
}
</script>

在前面的示例中,我们使用了一个名为asyncData的函数来设置消息键中的数据。这个asyncData函数是您在 Nuxt 应用程序中经常看到并经常使用的函数之一。让我们深入了解专门为 Nuxt 页面设计的属性和函数。

asyncData 方法

asyncData方法是页面组件中最重要的函数。Nuxt 总是在初始化页面组件之前调用这个函数。这意味着每次请求页面时,这个函数都会在页面渲染之前首先被调用。它以 Nuxt 上下文作为第一个参数,并且可以异步使用,如下所示:

<h1>{{ title }}</h1>

export default {
  async asyncData ({ params }) {
    let { data } = await axios.get(
    'https://jsonplaceholder.typicode.com/posts/' + params.id)
    return { title: data.title }
  }
}

在这个例子中,我们使用 ES6 解构赋值语法来解开 Nuxt 上下文中打包的属性,而这个特定的属性是params。换句话说,

{ params }context.params的简写。我们还可以使用解构赋值语法来解开从axios的异步结果中的data属性。请注意,如果在页面组件的data函数中设置了数据集,它将始终与asyncData中的数据合并。然后,合并后的数据可以在<template>块中使用。让我们创建一个简单的示例来演示asyncData如何与data函数合并:

<h1>{{ title }}</h1>

export default {
  data () {
    return { title: 'hello world!' }
  },
  asyncData (context) {
    return { title: 'hey nuxt!' }
  }
}

dataasynData方法返回的数据对象有两组,但是你将得到以下代码的输出:

<h1>hey nuxt!</h1>

你可以看到,如果它们都使用相同的数据键,asyncData函数中的数据将始终替换data函数中的数据。另外,请注意,我们不能在asyncData方法中使用this关键字,因为这个方法在页面组件初始化之前被调用。因此,你不能使用这个方法来更新数据,比如this.title = data.title。我们将在第八章中更详细地介绍asyncData添加服务器端框架

有关解构赋值的更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

fetch 方法

fetch方法与asyncData方法的工作方式相同,只是它在createdVue 生命周期钩子之后被调用——换句话说,在组件初始化之后。与asyncData方法一样,它也可以异步使用;例如,你也可以使用它来设置页面组件中的数据。

// pages/users/index.vue
<li v-for="user in users" v-bind:key="user.id">
  <nuxt-link :to="`users/${user.id}`">
    {{ user.name }}
  </nuxt-link>
</li>

import axios from 'axios'
export default {
  data () {
    return { users: [] }
  },
  async fetch () {
    let { data } = await axios.get
    ('https://jsonplaceholder.typicode.com/users')
    this.users = data
  }
}

请注意,data方法必须与fetch方法一起使用来设置数据。由于它在页面组件初始化后调用,我们可以使用this关键字来访问data方法中的对象。我们还可以使用这个方法从页面组件中将数据设置到 Vuex 存储中,如下所示:

// pages/posts/index.vue
<li v-for="post in $store.state.posts" v-bind:key="post.id">
  <nuxt-link :to="`posts/${post.id}`">
    {{ post.title }}
  </nuxt-link>
</li>

import axios from 'axios'
export default {
  async fetch () {
    let { data } = await axios.get(
    'https://jsonplaceholder.typicode.com/posts')
    const { store } = this.$nuxt.context
    store.commit('setPosts', data)
  }
}

我们将在第十章中更详细地介绍与 Vuex 存储一起使用的fetch方法,添加一个 Vuex 存储

您可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/view/custom-pages/fecth-method/部分找到上述代码。

有关fetch方法的更多信息,请访问nuxtjs.org/api/pages-fetchnuxtjs.org/blog/understanding-how-fetch-works-in-nuxt-2-12/

头部方法

head方法用于在页面上设置<meta>标签,我们在创建自定义 HTML 头部部分中已经介绍过。它也可以与/components/目录中的组件一起使用。

布局属性

layout键(或属性)用于指定页面在/layouts/目录中的布局,我们在创建自定义布局部分中已经介绍过。

加载属性

loading属性允许您禁用默认的加载进度条或在特定页面上设置自定义加载进度条。我们在第二章中简要介绍过,因此我们知道可以在 Nuxt 配置文件中配置全局默认加载组件,如下所示:

// nuxt.config.js
export default {
  loading: {
    color: '000000'
  }
}

然而,由于我们在localhost上,并且不需要太多时间来处理数据,通常我们不会看到这个加载条在工作中。为了看到它的工作情况,让我们演示一下这个加载组件是如何工作和看起来的,通过延迟组件中数据的加载时间来演示(但请注意,这个演示不应该在生产环境中进行):

  1. /pages/目录中创建一个名为index.vue的页面,其中包含以下代码:
// pages/index.vue
<template>
  <div class="container">
    <p>Hello {{ name }}!</p>
    <NuxtLink to="/about">
      Go to /about
    </NuxtLink>
  </div>
</template>

<script>
export default {
  asyncData () {
    return new Promise((resolve) => {
      setTimeout(function () {
        resolve({ name: 'world' })
      }, 1000)
    })
  }
}
</script>
  1. /pages/目录中创建另一个名为about.vue的页面,其中包含以下代码:
// pages/about.vue
<template>
  <div class="container">
    <p>About Page</p>
    <NuxtLink to="/">
      Go to /
    </NuxtLink>
  </div>
</template>

<script>
export default {
  asyncData () {
    return new Promise((resolve) => {
      setTimeout(function () {
        resolve({})
      }, 1000)
    })
  }
}
</script>

在这两个页面中,我们使用setTimeout来延迟 1 秒钟的数据响应。因此,当在页面之间导航时,您应该在请求页面加载之前看到顶部出现黑色加载条。

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/view/custom-pages/loading-page/中找到此示例。

  1. 当然,我们可以通过在/components/目录中创建组件来创建自定义加载栏或层。参考以下示例:
// components/loading.vue
<template>
  <div v-if="loading" class="loading-page">
    <p>Loading...</p>
  </div>
</template>

<script>
export default {
  data () {
    return { loading: false }
  },
  methods: {
    start () { this.loading = true },
    finish () { this.loading = false },
  }
}
</script>

<style scoped>
.loading-page {
  position: fixed;
  //...
}
</style>

请注意,自定义加载组件中必须暴露startfinish方法,以便 Nuxt 在路由更改时调用您的组件并使用这些方法(调用start方法)并加载(调用finish方法)。

因此,在这个组件中,加载元素始终是隐藏的,因为data方法中默认将loading属性设置为false。只有在路由更改时loading属性设置为true时,加载元素才会变为可见。然后在路由完成加载后,loading属性再次设置为false时,它会再次隐藏。

有关这些和其他可用方法的更多信息,请访问nuxtjs.org/api/configuration-loading

  1. 在 Nuxt 配置文件的loading属性中包含前面自定义组件的路径:
// nuxt.config.js
export default {
  loading: '~/components/loading.vue'
}

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/view/custom-pages/loading-global-custom/中找到此示例。

  1. 我们还可以根据特定页面配置加载行为,如下所示:
// pages/about.vue
export default {
  loading: false
}
  1. 如果页面上loading键的值为false,它将自动停止调用this.$nuxt.$loading.finish()this.$nuxt.$loading.start()方法,这允许您在脚本中手动控制它们,如下所示:
// pages/about.vue
<span class="link" v-on:click="goToFinal">
  click here
</span>

export default {
  loading: false,
  mounted () {
    setTimeout(() => {
      this.$nuxt.$loading.finish()
    }, 5000)
  },
  methods: {
    goToFinal () {
      this.$nuxt.$loading.start()
      setTimeout(() => {
        this.$router.push('/final')
      }, 5000)
    }
  }
}

  1. 然后,在/pages/目录中创建final.vue页面:
// pages/final.vue
<template>
  <div class="container">
    <p>Final Page</p>
    <NuxtLink to="/">
      Go to /
    </NuxtLink>
  </div>
</template>

在这个例子中,您可以看到您可以使用this.$nuxt.$loading.finish()this.$nuxt.$loading.start()手动控制加载栏。加载栏在mounted方法中需要 5 秒钟才能完成。当您触发goToFinal方法时,加载栏立即开始,并且需要 5 秒钟才能将路由更改为/final

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/view/custom-pages/loading-page/中找到此示例。

过渡属性

transition属性用于指定页面的过渡。您可以使用字符串、对象或带有此键的函数,如下所示:

// pages/about.vue
export default {
  transition: ''
  // or
  transition: {}
  // or
  transition (to, from) {}
}

我们将在本章后面的创建自定义过渡部分深入介绍transition属性。

scrollToTop 属性

scrollToTop键用于在呈现页面之前使嵌套路由中的页面从顶部开始。默认情况下,当您转到另一个页面时,Nuxt 会滚动到顶部,但在嵌套路由的子页面上,Nuxt 会保持在前一个子路由的相同滚动位置。因此,如果您希望告诉 Nuxt 在这些页面上滚动到顶部,则将scrollToTop设置为true,如下所示:

// pages/users/_id.vue
export default {
  scrollToTop: true
}

验证方法

validate方法是动态路由的验证器,我们已经在验证路由参数部分中进行了介绍。

中间件属性

middleware属性用于指定页面的中间件。分配的中间件将始终在页面呈现之前执行,如下所示:

// pages/secured.vue
export default {
  middleware: 'auth'
}

在此示例中,auth是您将在/middleware/目录中创建的中间件的文件名,如下所示:

// middleware/auth.js
export default function ({ route }) {
  //...
}

我们将在第十一章中深入讨论中间件,编写路由中间件和服务器中间件

所以,就是这样。您已经完成了关于 Nuxt 视图的部分,从应用程序模板、HTML 头部和布局到页面。干得好!我们将在下一章中讨论 Vue 组件。但现在,我们应该看的下一件事是在 Nuxt 中创建自定义页面之间的过渡,因为过渡和页面是密切相关的,就像您之前简要介绍过的页面transition属性一样。所以,让我们继续进行本章的最后一个主题,您将在其中了解如何创建自定义过渡。

创建自定义过渡

到目前为止,您已经成功为 Nuxt 应用程序创建了多个路由和页面,并在页面之间切换时添加了一个加载栏。这已经使得应用程序看起来相当不错。但这并不是您在 Nuxt 中可以做的全部。您可以在页面之间添加更多令人惊叹的效果和过渡。这就是页面中的transition属性(例如,/pages/about.vue)以及 Nuxt 配置文件中的pageTransitionlayoutTransition选项的用处。

我们可以通过 Nuxt 配置文件全局应用过渡,或者特定应用于某些页面。我们将指导您了解这个主题。但是,要理解 Nuxt 中过渡的工作原理,我们首先应该了解它在 Vue 中是如何工作的,然后我们可以学习如何在路由更改时在我们的页面上实现它。让我们开始吧。

理解 Vue 过渡

Vue 依赖于 CSS 过渡,并使用<transition>Vue 组件来包裹 HTML 元素或 Vue 组件以添加 CSS 过渡,如下所示:

<transition>
  <p>hello world</p>
</transition>

你可以看到这有多么容易 - 你可以像吃蛋糕一样轻松地用<transition>组件包裹任何元素。当这种情况发生时,Vue 会在适当的时间添加和移除以下 CSS 过渡类到该元素:

  • .v-enter.v-leave类定义了过渡开始之前你的元素的外观。

  • .v-enter-to.v-leave-to类是你的元素的“完成”状态。

  • .v-enter-active.v-leave-active类是元素的活动状态。

这些类是 CSS 过渡发生的地方。例如,在 HTML 页面中进行的过渡可能如下所示:

.element {
  opacity: 1;
  transition: opacity 300ms;
}
.element:hover {
  opacity: 0;
}

如果我们将前面的过渡转换为 Vue 上下文,我们将得到以下结果:

.v-enter,
.v-leave-to {
  opacity: 0;
}
.v-leave,
.v-enter-to {
  opacity: 1;
}
.v-enter-active,
.v-leave-active {
  transition: opacity 300ms;
}

我们可以将这些 Vue 过渡类简单地可视化为以下图表:

参考来源:vuejs.org/v2/guide/transitions.html

Vue 默认使用v-作为过渡类的前缀,但如果你想更改这个前缀,只需在<transition>组件上使用name属性来指定一个名称 - 例如,<transition name="fade">;然后,你可以"重构"你的 CSS 过渡类,如下所示:

.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-leave,
.fade-enter-to {
  opacity: 1;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 300ms;
}

让我们将前面的过渡应用到一个简单的 Vue 应用程序中,步骤如下:

  1. 创建两个简单的路由,并用<transition>组件包裹<router-view>组件,如下所示:
<div id="app">
  <p>
    <router-link to="/about">About</router-link>
    <router-link to="/contact">Contact</router-link>
  </p>
  <transition name="fade" mode="out-in">
    <router-view></router-view>
  </transition>
</div>
  1. 添加一个带有前缀fade-的 CSS 过渡类的<style>块:
<style type="text/css">
  .fade-enter,
  //...
</style>

当你在浏览器上运行应用程序时,你会发现在切换路由时,路由组件淡入淡出需要 300 毫秒。

你可以在我们的 GitHub 存储库的/chapter-4/vue/transitions/basic.html中找到这个例子。

你可以看到,过渡需要一些 CSS 类来使其工作,但对于 Vue 应用程序来说,掌握它们并不难。现在,让我们看看如何在下一节中在 Nuxt 中应用过渡。

使用 pageTransition 属性进行过渡

在 Nuxt 中,不再需要<transition>组件。它会默认为您添加,所以您只需在/assets/目录或任何特定页面的<style>块中创建过渡效果。在 Nuxt 配置文件中使用pageTransition属性来设置页面过渡的默认属性。Nuxt 中过渡属性的默认值如下:

{
  name: 'page',
  mode: 'out-in'
}

因此,Nuxt 默认使用page-作为过渡类的前缀,而 Vue 使用v-作为前缀。Nuxt 中默认的过渡模式设置为out-in。让我们通过以下步骤来看看 Nuxt 中的过渡是如何实现的:为所有页面创建全局过渡,以及为特定页面创建局部过渡。

  1. /assets/目录中创建一个transition.css文件,并添加以下过渡效果:
// assets/css/transitions.css
.page-enter,
.page-leave-to {
  opacity: 0;
}
.page-leave,
.page-enter-to {
  opacity: 1;
}
.page-enter-active,
.page-leave-active {
  transition: opacity 300ms;
}
  1. 将前面的 CSS 过渡资源的路径添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
  css: [
    'assets/css/transitions.css'
  ]
}
  1. 请记住,默认前缀是page-,所以如果您想使用不同的前缀,可以在 Nuxt 配置文件中使用pageTransition属性来更改前缀:
// nuxt.config.js
export default {
  pageTransition: 'fade'
  // or
  pageTransition: {
    name: 'fade',
    mode: 'out-in'
  }
}
  1. 然后,在transitions.css中将所有默认类名的前缀更改为fade,如下所示:
// assets/css/transitions.css
.fade-enter,
.fade-leave-to {
  opacity: 0;
}

当路由改变时,这个例子将在所有页面上全局应用过渡效果。

  1. 然而,如果我们想要为特定页面应用不同的过渡效果,或者在页面中覆盖全局过渡效果,可以在该页面的transition属性中进行设置,如下所示:
// pages/about.vue
export default {
  transition: {
    name: 'fade-about',
    mode: 'out-in'
  }
}
  1. 然后,在transitions.css中为fade-about创建 CSS 过渡效果:
// assets/css/transitions.css
.fade-about-enter,
.fade-about-leave-to {
  opacity: 0;
}
.fade-about-leave,
.fade-about-enter-to {
  opacity: 1;
}
.fade-about-enter-active,
.fade-about-leave-active {
  transition: opacity 3s;
}

在这个例子中,淡入和淡出about页面需要 3 秒,而其他页面只需要 300 毫秒。

您可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/transition/page-transition-property/中找到这个特定页面的例子和全局例子。

您可以看到,Nuxt 再次为您解决了一些重复的任务,并为您提供了创建自定义前缀类名的灵活性。而且,您甚至可以创建布局之间的过渡效果!让我们在下一节中了解如何做到这一点。

使用layoutTransition属性进行过渡

CSS 过渡不仅适用于页面组件,还适用于布局。layoutTransition属性的默认值如下:

{
  name: 'layout',
  mode: 'out-in'
}

因此,默认情况下布局转换类的前缀是layout,默认转换模式是out-in。让我们看看如何通过以下步骤为所有布局创建全局转换:

  1. /layouts/目录中创建about.vueuser.vue布局,如下所示:
// layouts/about.vue
<template>
  <div>
    <p>About layout</p>
    //...
    <nuxt />
  </div>
</template>
  1. 将前述布局应用于/pages/目录中的about.vueusers.vue页面,如下所示:
// pages/about.vue
<script>
export default {
  layout: 'about'
}
</script>
  1. /assets/目录中创建transition.css文件,并将以下转换添加到其中:
// assets/css/transitions.css
.layout-enter, 
.layout-leave-to {
  opacity: 0;
}
.layout-leave,
.layout-enter-to {
  opacity: 1;
}
.layout-enter-active,
.layout-leave-active {
  transition: opacity .5s;
}
  1. 将前面的 CSS 转换资源的路径添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
  css: [
    'assets/css/transitions.css'
  ]
}
  1. 默认前缀是layout-,但如果您想使用不同的前缀,可以在 Nuxt 配置文件中使用layoutTransition属性进行更改:
// nuxt.config.js
export default {
  layoutTransition: 'fade-layout'
  // or
  layoutTransition: {
    name: 'fade-layout',
    mode: 'out-in'
  }
}
  1. transitions.css中将所有默认类名的前缀更改为fade-layout,如下所示:
// assets/css/transitions.css
.fade-layout-enter,
.fade-layout-leave-to {
  opacity: 0;
}

在此示例中,淡入淡出整个布局(包括导航)需要 0.5 秒。当您在使用不同布局的页面之间导航时,您将看到此转换,但不会在使用相同布局的页面上看到;例如,如果您在//contact之间导航,您将不会得到前面的布局转换,因为它们都使用相同的布局,即/layouts/default.vue

您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/transition/layout-transition-property/中找到此示例。

再次,您可以看到为布局创建转换非常容易,并且可以自定义其前缀类名称,就像页面转换一样。除了使用 CSS 转换来转换页面和布局之外,我们还可以使用 CSS 动画。因此,让我们在下一节中了解一下。

使用 CSS 动画进行转换

CSS 转换仅在两个状态之间执行动画:开始和结束。但是,当您需要更多中间状态时,应该使用 CSS 动画,以便通过在开始和结束状态之间添加多个百分比的关键帧来获得更多控制。请看以下示例:

@keyframes example {
  0% { // 1st keyframe or start state.
    background-color: red;
  }
  25% { // 2nd keyframe.
    background-color: yellow;
  }
  50% { // 3rd keyframe.
    background-color: blue;
  }
  100% { // 4th keyframe end state.
    background-color: green;
  }
}

0%是动画的起始状态,而100%是结束状态。您可以通过添加增量百分比(例如10%20%30%等)在这两个状态之间添加更多中间状态。但是,CSS 转换没有这种添加关键帧的能力。因此,我们可以说 CSS 转换是 CSS 动画的简单形式。

由于 CSS 过渡实际上是“实际上”是 CSS 动画,我们可以在 Vue/Nuxt 应用程序中应用 CSS 动画,就像我们应用 CSS 过渡一样。让我们通过以下步骤了解如何做到这一点:

  1. 将以下 CSS 动画代码添加到transitions.css文件中,就像您在上一节中所做的那样:
// assets/css/transitions.css
.bounce-enter-active {
  animation: bounce-in .5s;
}
.bounce-leave-active {
  animation: bounce-in .5s reverse;
}
@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.5);
  }
  100% {
    transform: scale(1);
  }
}
  1. 在 Nuxt 配置文件中将全局默认的page-前缀更改为bounce-
// nuxt.config.js
export default {
  pageTransition: 'bounce'
}

一旦您添加了上述代码,请刷新您的浏览器,您将会看到在页面之间切换时页面会弹入和弹出。

您可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/transition/css-animations/中找到这个示例。

根据您想要进行动画的复杂程度和详细程度,以及您的 CSS 动画技能水平,您可以为页面和布局创建非常令人惊叹的过渡效果。您只需专注于编写代码并通过 Nuxt 配置文件注册它,然后 Nuxt 将负责在适当的时间添加和删除 CSS 动画类的其余工作。但 JavaScript 呢?我们可以使用 jQuery,或者任何其他 JavaScript 动画库来创建页面和布局的过渡动画吗?答案是肯定的,您可以。让我们在下一节中了解如何做到这一点。

使用 JavaScript 钩子进行过渡

除了使用 CSS 进行过渡,您还可以通过在 Vue 应用程序的<transition>组件中添加以下钩子来使用 JavaScript 进行过渡:

<transition
  v-on:before-enter="beforeEnter"
  v-on:enter="enter"
  v-on:after-enter="afterEnter"
  v-on:enter-cancelled="enterCancelled"
  v-on:before-leave="beforeLeave"
  v-on:leave="leave"
  v-on:after-leave="afterLeave"
  v-on:leave-cancelled="leaveCancelled"
>
  //..
</transition>

请注意,您也可以在开头不添加v-on的情况下声明钩子。因此,将钩子写为:before-enter与写v-on:before-enter是相同的。

然后,在 JavaScript 方面,您应该在methods属性中有以下默认方法,以对应上述的钩子:

methods: {
  beforeEnter (el) { ... },
  enter (el, done) {
    // ...
    done()
  },
  afterEnter (el) { ... },
  enterCancelled (el) { ... },
  beforeLeave (el) { ... },
  leave (el, done) {
    // ...
    done()
  },
  afterLeave (el) { ... },
  leaveCancelled (el) { ... }
}

您可以单独使用这些 JavaScript 钩子,也可以与 CSS 过渡一起使用。如果您单独使用它们,则必须在enterleave钩子(方法)中使用done回调,否则这两个方法将同步运行,并且您尝试应用的动画或过渡将立即结束。此外,如果它们单独使用,您还应该在<transition>包装器上使用v-bind:css="false",这样 Vue 将安全地忽略您的元素,以防万一您的应用程序中也有 CSS 过渡,但它正在用于其他元素。让我们通过以下步骤创建一个简单的 Vue 应用程序,使用这些 JavaScript 钩子:

  1. 将以下 CDN 链接添加到 HTML 的<head>块中:
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
  1. <body>块中添加应用标记和<transition>组件与钩子:
<div id="app">
  <p>
    <router-link to="/about">About</router-link>
    <router-link to="/contact">Contact</router-link>
  </p>
  <transition
    appear
    v-bind:css="false"
    v-on:before-enter="beforeEnter"
    v-on:enter="enter"
    v-on:leave="leave"
    v-on:after-leave="afterLeave">
    <router-view></router-view>
  </transition>
</div>
  1. 接下来使用以下方法在<script>块中协调前面的钩子:
const app = new Vue({
  name: 'App',
  methods: {
    beforeEnter (el) { $(el).hide() },
    enter (el, done) {
      $(el).fadeTo('slow', 1)
      done()
    },
    leave (el, done) {
      $(el).fadeTo('slow', 0, function () {
        $(el).hide()
      })
      done()
    },
    afterLeave (el) { $(el).hide() }
  }
}).$mount('#app')

在这个例子中,我们使用 jQuery 的fadeTo方法来控制过渡,而不是使用纯 CSS。当在路由组件之间切换时,您应该看到它们淡入淡出,就像.v-enter.v-leaveCSS 过渡一样。

您可以在我们的 GitHub 存储库的/chapter-4/vue/transition/js-hooks.html中找到这个例子。

在 Nuxt 中,我们不需要将 JavaScript 钩子定义为<transition>组件,只需要在 Nuxt 配置文件的pageTransition中定义 JavaScript 方法,以及在/pages/目录中的任何.vue文件中定义transition。让我们在 Nuxt 应用程序中创建一个快速示例,步骤如下:

  1. 在终端上通过 npm 安装 jQuery:
$ npm i jquery
  1. 由于我们在 Nuxt 配置文件和其他页面中使用 jQuery,我们可以通过 Nuxt 配置文件在 webpack 中全局加载 jQuery:
// nuxt.config.js
import webpack from 'webpack'

export default {
  build: {
    plugins: [
      new webpack.ProvidePlugin({
        $: "jquery"
      })
    ]
  }
}
  1. 在 Nuxt 配置文件的pageTransition选项中使用 jQuery 创建全局过渡:
// nuxt.config.js
export default {
  pageTransition: {
    mode: 'out-in',
    css: false,
    beforeEnter: el => { $(el).hide() },
    enter: (el, done) => {
      $(el).fadeTo(1000, 1)
      done()
    },
    //...
  }
}

当路由更改时,此示例将在所有页面全局应用过渡。此外,我们通过将css选项设置为false来关闭 CSS 过渡。

请注意,我们将 JavaScript 函数写为对象键的替代方法,以便与过渡组件中的属性钩子关联。

  1. /pages/目录中创建一个about.vue页面,并通过about.vue页面上的transition属性应用不同的过渡,以覆盖前面的全局过渡:
// pages/about.vue
export default {
  transition: {
    mode: 'out-in',
    css: false,
    beforeEnter: el => { $(el).hide() },
    enter: (el, done) => {
      $(el).fadeTo(3000, 1)
      done()
    },
    //...
  }
}

因此,在这个例子中,这个特定页面的过渡需要 3 秒钟,而其他页面只需要 1 秒钟。

请注意,如果在 Nuxt 配置文件中未加载 jQuery,则必须将 jQuery 导入.vue页面;例如,假设您只想在这个特定页面上设置过渡:

// pages/about.vue
import $ from 'jquery'

export default {
  transition: {
    beforeEnter (el) { $(el).hide() },
    //...
  }
}

一旦代码就位,刷新您的浏览器,您应该看到页面在页面路由更改时淡入淡出,就像 Vue 应用程序在页面之间切换时一样。

您可以在我们的 GitHub 存储库的/chapter-4/nuxt-universal/transition/js-hooks/中找到这个例子。

干得好;您已经完成了在 Nuxt 中创建过渡的部分!您可以看到 JavaScript 是在 Nuxt 应用程序中编写过渡和动画的另一种很好的方式。但在结束本章之前,让我们来看看我们在过渡部分一直看到的过渡模式。因此,让我们找出它们的用途。

请注意,尽管如今不鼓励使用 jQuery,但在本书中偶尔会使用它,因为它是 Foundation 的依赖项,您在上一章中了解过。因此,我们有时会重新使用它。或者,您可以使用 Anime.js 来制作 JavaScript 动画。有关此库的更多信息,请访问animejs.com/

理解过渡模式

您可能想知道mode="out-in"(在 Vue 中)或mode: 'out-in'(在 Nuxt 中)是什么意思-例如,在我们以前的 Vue 应用程序中,其中包含<div>about</div><div>contact</div>组件。它们存在是因为<div>about</div><div>contact</div>之间的过渡是同时渲染的。这是<transition>的默认行为:同时进入和离开。但有时,您可能不希望这种同时过渡,因此 Vue 提供了以下过渡模式的解决方案:

  • in-out模式

此模式用于让新元素首先过渡进入,直到其过渡完成,然后当前元素将过渡出去。

  • out-in模式

此模式用于让当前元素首先过渡出去,直到其过渡完成,然后新元素将过渡进入。

因此,您可以按以下方式使用这些模式:

  • 在 Vue.js 应用程序中,使用它们如下:
<transition name="fade" mode="out-in">
  //...
</transition>
  • 在 Nuxt 应用程序中,使用它们如下:
export default {
  transition: {
    name: 'fade',
    mode: 'out-in'
  }
}
  • 在 JavaScript hooks 中,使用它们如下:
export default {
  methods: {
    enter (el, done) {
      done() // equivalent to mode="out-in"
    },
    leave (el, done) {
      done() // equivalent to mode="out-in"
    }
  }
}

在创建 Nuxt/Vue 应用程序的自定义过渡方面,我们已经走了很长的路。您现在可以根据本章学到的知识制作一些体面的过渡和动画。因此,由于本书空间有限,我们不会进一步深入探讨这个主题,但是有关 Vue 过渡和动画的更多信息和进一步阅读,请访问vuejs.org/v2/guide/transitions.html。现在让我们总结一下您在本章学到的内容!

总结

在本章中,您学习了 Nuxt 中页面的概念以及如何为应用程序创建不同类型的路由。您学会了如何自定义默认应用程序模板和布局,以及如何创建新的布局和 404 页面。您学会了如何使用 CSS 过渡和动画,以及 JavaScript 钩子和方法,使应用程序页面之间的过渡变得有趣。如果您从头开始一直在跟随指南,那么现在您应该能够交付一个外观漂亮的小项目了。您可以在我们的 GitHub 存储库中的/chapter-4/nuxt-universal/sample-website/找到一个网站示例,该示例使用了我们在本章和之前章节中学到的知识。

在下一章中,我们将探索/components/目录。您将学习如何在 Nuxt 应用程序中使用它,以便通过更详细地了解 Vue 组件来完善本章中涵盖的布局和页面,包括从页面和布局组件向它们传递数据,创建单文件 Vue 组件,注册全局和本地 Vue 组件等。此外,您还将学习如何使用 mixin 编写可重用的代码,使用 Vue 风格指南中的命名约定来定义组件名称,以便使您的组件组织和标准化,以便更好地进行未来维护。所有这些知识和探索都是值得的。所以,让我们开始吧。

添加 Vue 组件

正如我们在上一章中所述,Vue 组件是 Nuxt 视图的可选部分。您已经了解了 Nuxt 视图的各种组成部分:应用程序模板、HTML 头部、布局和页面。但是,我们还没有涵盖 Nuxt 中最小的单位-Vue 组件。因此,在本章中,您将学习它的工作原理以及如何利用/components/创建自定义组件。然后,您将学习如何创建全局和本地组件,以及基本和全局 mixin,并了解一些用于开发 Vue 或 Nuxt 应用程序的命名约定。最令人兴奋的是,您将发现如何将数据从父组件传递到子组件,以及如何从子组件向父组件发出数据。

在本章中,我们将涵盖以下主题:

  • 理解 Vue 组件

  • 创建单文件 Vue 组件

  • 注册全局和本地组件

  • 编写基本和全局 mixin

  • 定义组件名称并使用命名约定

让我们开始吧!

第五章:理解 Vue 组件

我们在第二章中简要介绍了/components/目录,开始使用 Nuxt,但我们还没有亲自动手。到目前为止,我们知道如果使用 Nuxt 脚手架工具安装 Nuxt 项目,则该目录中有一个Logo.vue组件。该目录中的所有组件都是Vue 组件,就像/pages/目录中的页面组件一样。主要区别在于/components/目录中的这些组件不支持asyncData方法。让我们以/chapter-4/nuxt-universal/sample-website/中的copyright.vue组件为例:

// components/copyright.vue
<template>
  <p v-html="copyright"></p>
</template>

<script>
export default {
  data () {
    return { copyright: '&copy; Lau Tiam Kok' }
  }
}
</script>

让我们尝试用asyncData函数替换前面代码中的data函数,如下所示:

// components/copyright.vue
export default {
  asyncData () {
    return { copyright: '&copy; Lau Tiam Kok' }
  }
}

您将收到警告错误,浏览器控制台上会显示“属性或方法“copyright”未定义...”。那么,我们如何动态获取版权目的的数据呢?我们可以使用fetch方法直接在组件中使用 HTTP 客户端(例如axios)请求数据,如下所示:

  1. 在项目目录中通过 npm 安装axios包:
$ npm i axios
  1. 导入axios并在fetch方法中请求数据,如下所示:
// components/copyright.vue
import axios from 'axios'

export default {
  data () {
    return { copyright: null }
  },
  fetch () {
    const { data } = axios.get('http/path/to/site-info.json')
    this.copyright = data.copyright  
  }
}

这种方法可以正常工作,但是最好不要使用 HTTP 请求从有效负载中获取少量数据,最好是请求一次,然后将数据从父作用域传递到其子组件中,如下所示:

// components/copyright.vue
export default {
  props: ['copyright']
}

在前面的片段中,子组件是/components/目录中的copyright.vue文件。这个解决方案的奥妙就在于在组件中使用props属性。这样更简单、更整洁,因此是一个优雅的解决方案!但是,如果我们要理解它是如何工作的,以及如何使用它,我们需要了解 Vue 的组件系统。

什么是组件?

组件是单一的、自包含的、可重用的 Vue 实例,具有自定义名称。我们使用 Vue 的component方法来定义组件。例如,如果我们想定义一个名为post-item的组件,我们会这样做:

Vue.component('post-item', {
  data () {
    return { text: 'Hello World!' }
  },
  template: '<p>{{ text }}</p>'
})

做完这些之后,当使用new语句创建根 Vue 实例时,我们可以在 HTML 文档中将此组件用作<post-item>,如下所示:

<div id="post">
  <post-item></post-item>
</div>

<script type="text/javascript">
  Vue.component('post-item', { ... }
  new Vue({ el: '#post' })
</script>

所有组件本质上都是 Vue 实例。这意味着它们具有与new Vue相同的选项(datacomputedwatchmethods等),只是少了一些根特定的选项,比如el。此外,组件可以嵌套在其他组件中,并最终成为类似树状的组件。然而,当这种情况发生时,传递数据变得棘手。因此,在特定组件中直接使用fetch方法获取数据可能更适合这种情况。或者,您可以使用 Vuex 存储,您将在第十章中发现它,“添加 Vuex 存储”。

然而,我们将暂时搁置深度嵌套的组件,专注于本章中简单的父子组件,并学习如何在它们之间传递数据。数据可以从父组件传递到它们的子组件,也可以从子组件传递到父组件。但是我们如何做到这一点呢?首先,让我们找出如何从父组件向子组件传递数据。

使用 props 将数据传递给子组件

让我们通过创建一个名为user-item的子组件来创建一个小型的 Vue 应用,如下所示:

Vue.component('user-item', {
  template: '<li>John Doe</li>'
})

你可以看到它只是一个静态组件,没有太多功能;你根本无法抽象或重用它。只有在我们可以动态地将数据传递到模板内部的template属性中时,它才变得可重用。这可以通过props属性来实现。让我们对组件进行重构,如下所示:

Vue.component('user-item', {
  props: ['name'],
  template: '<li>{{ name }}</li>'
})

在某种意义上,props的行为类似于变量,我们可以使用v-bind指令为它们设置数据,如下所示:

<ol>
  <user-item
    v-for="user in users"
    v-bind:name="user.name"
    v-bind:key="user.id"
  ></user-item>
</ol>

在这个重构的组件中,我们使用v-bind指令将item.name绑定到name,如v-bind:name。组件内的 props 必须接受name作为该组件的属性。然而,在一个更复杂的应用程序中,我们可能需要传递更多的数据,为每个数据写多个 props 可能会适得其反。因此,让我们重构<user-item>组件,使其接受一个名为user的单个 prop:

<ol>
  <user-item
    v-for="user in users"
    v-bind:user="user"
    v-bind:key="user.id"
  ></user-item>
</ol>

现在,让我们再次重构组件代码,如下所示:

Vue.component('user-item', {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
})

让我们将我们在这里所做的事情放到一个单页 HTML 中,这样您就可以看到更大的图片:

  1. <head>块中包含以下 CDN 链接:
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  1. <body>块中创建以下标记:
<div id="app">
  <ol>
    <user-item
      v-for="user in users"
      v-bind:user="user"
      v-bind:key="user.id"
    ></user-item>
  </ol>
</div>
  1. 将以下代码添加到<script>块中:
Vue.component('user-item', {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
})

new Vue({
  el: '#app',
  data: {
    users: [
      { id: 0, name: 'John Doe' },
      { id: 1, name: 'Jane Doe' },
      { id: 2, name: 'Mary Moe' }
    ]
  }
})

在这个例子中,我们将应用程序分解成了更小的单元:一个子组件和一个父组件。然而,它们通过props属性进行绑定。现在,我们可以进一步完善它们,而不用担心它们相互干扰。

您可以在本书的 GitHub 存储库中的/chapter-5/vue/component/basic.html中找到这个示例代码。

然而,在一个真实而复杂的应用程序中,我们应该将这个应用程序分成更可管理的单独文件(单文件组件)。我们将在创建单文件 Vue 组件部分向您展示如何创建它们。但现在,让我们发现如何从子组件将数据传递给父组件。

监听子组件事件

到目前为止,您已经学会了如何使用props属性将数据传递给子组件。但是如何从子组件将数据传递给父组件呢?我们可以通过使用$emit方法和自定义事件来实现这一点,如下所示:

$emit(<event>)

您可以选择任何名称作为要在子组件中广播的自定义事件的名称。然后,父组件可以使用v-on指令来监听这个广播事件,并决定接下来要做什么。

v-on:<event>="<event-handler>"

因此,如果您正在发出一个名为done的自定义事件,那么父组件将使用v-on:done指令来监听这个done事件,然后是一个事件处理程序。这个事件处理程序可以是一个简单的 JavaScript 函数,比如v-on:done=handleDone。让我们创建一个简单的应用程序来演示这一点:

  1. 创建应用程序的标记,如下所示:
<div id="todos">
  <todo-item
    v-on:completed="handleCompleted"
  ></todo-item>
</div>
  1. 创建一个子组件,如下所示:
Vue.component('todo-item', {
  template: '<button v-on:click="clicked">Task completed</button>',
  methods: {
    clicked () {
      this.$emit('completed')
    }
  }
})
  1. 创建一个 Vue 根实例作为父级:
new Vue({
  el: '#todos',
  methods: {
    handleCompleted () {
      alert('Task Done')
    }
  }
})

在这个例子中,当clicked方法在子组件中触发时,子组件将发出一个completed事件。在这里,父组件通过v-on接收事件,然后在其端触发handleCompleted方法。

您可以在本书的 GitHub 存储库中的/chapter-5/vue/component/emit/emit-basic.html中找到这个例子。

通过事件发出值

然而,有时仅仅发出一个事件是不够的。在某些情况下,使用带有值的事件更有用。我们可以通过在$emit方法中使用第二个参数来实现这一点,如下所示:

$emit(<event>, <value>)

然后,当父组件监听事件时,可以以以下格式使用$event访问发出的事件的值:

v-on:<event>="<event-handler> = $event"

如果事件处理程序是一个方法,那么该值将是该方法的第一个参数,格式如下:

methods: {
  handleCompleted (<value>) { ... }
}

因此,现在,我们可以简单地修改前面的应用程序,如下所示:

// Child
clicked () {
  this.$emit('completed', 'Task done')
}

// Parent
methods: {
  handleCompleted (value) {
    alert(value)
  }
}

在这里,您可以看到在父组件和子组件之间传递数据是有趣且容易的。但是,如果您的子组件中有一个<input>元素,如何将输入字段中的值传递给父组件进行双向数据绑定呢?如果我们了解 Vue 中双向数据绑定的“底层”发生了什么,这并不难。我们将在下一节中学习这个知识点。

您可以在本书的 GitHub 存储库中的/chapter-5/vue/component/emit/value.html中找到这个简单的例子,以及在/chapter-5/vue/component/emit/emit-value-with-props.html中找到更复杂的例子。

使用 v-model 创建自定义输入组件

我们还可以使用组件创建自定义的双向绑定输入,其工作方式与v-model指令相同,用于向父组件发出事件。让我们创建一个基本的自定义输入组件:

<custom-input v-model="newTodoText"></custom-input>

Vue.component('custom-input', {
  props: ['value'],
  template: `<input v-on:input="$emit('input', $event.target.value)">`,
})

它是如何工作的?要理解这一点,我们需要了解v-model在幕后是如何工作的。让我们使用一个简单的v-model输入:

<input v-model="handler">

前面的<input>元素是以下内容的简写:

<input
  v-bind:value="handler"
  v-on:input="handler = $event.target.value"
>

因此,在我们的自定义输入中编写v-model="newTodoText"是以下内容的简写:

v-bind:value="newTodoText"
v-on:input="newTodoText = $event.target.value"

这意味着这个简写下面的组件必须在props属性中具有value属性,以便让数据从顶部传递下来。它必须发出一个带有$event.target.valueinput事件,以便将数据传递到顶部。

因此,在这个例子中,当用户在custom-input子组件中输入时,我们发出值,而父组件通过v-model="newTodoText"监听更改,并更新data对象中newTodoText的值:

<p>{{ newTodoText }}</p>

new Vue({
  el: '#todos',
  data: {
    newTodoText: null
  }
})

当你了解 Vue 中双向数据绑定的机制——v-model指令时,这就变得很合理了,不是吗?但是,如果你不想使用复选框输入和单选按钮元素的默认值呢?在这种情况下,你会想要将自定义的值发送到父组件中。我们将在下一节中学习如何做到这一点。

你可以在/chapter-5/vue/component/custom-inputs/basic.html找到这个简单的例子,在/chapter-5/vue/component/custom-inputs/props.html中找到一个更复杂的例子,这两个例子都可以在这本书的 GitHub 存储库中找到。

自定义输入组件中的模型定制

默认情况下,自定义输入组件中的模型使用value属性作为 prop,input作为事件。使用我们之前例子中的custom-input组件,可以写成如下形式:

Vue.component('custom-input', {
  props: {
    value: null
  },
  model: {
    prop: 'value', // <-- default
    event: 'input' // <-- default
  }
})

在这个例子中,我们不需要指定propevent属性,因为它们是该组件模型的默认行为。但是当我们不想对某些输入类型使用这些默认值时,这将变得很有用,比如复选框和单选按钮。

我们可能希望在这些输入中使用value属性来实现不同的目的,比如在提交的数据中与复选框的name一起发送特定的值,如下所示:

Vue.component('custom-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    checked: Boolean
  },
  template: `
    <input
      type="checkbox"
      v-bind:checked="checked"
      v-on:change="changed"
      name="subscribe"
      value="newsletter"
    >
  `
  ,
  methods: {
    changed ($event) {
      this.$emit('change', $event.target.checked)
    }
  }
})

在这个例子中,我们想要将这两个数据发送到服务器:

name="subscribe"
value="newsletter"

我们还可以在使用JSON.stringify进行序列化后以 JSON 格式进行:

[{
  "name":"subscribe",
  "value":"newsletter"
}]

所以,假设我们在这个组件中没有设置以下自定义模型:

model: {
  prop: 'checked',
  event: 'change'
}

在这种情况下,我们只能将以下默认数据发送到服务器:

[{
  "name":"subscribe",
  "value":"on"
}]

你可以在这本书的 GitHub 存储库中的/chapter-5/vue/component/custom-inputs/checkbox.html中找到这个例子。

当你知道 Vue 组件底层是什么,并且可以通过一点努力进行定制时,这就变得合理了。/components/目录中的 Vue 组件与你刚刚学习的组件的工作方式相同。但在深入编写 Nuxt 应用程序的组件之前,你应该了解在使用v-for指令时为什么key属性很重要。让我们找出来。

理解v-for循环中的key属性

在这本书的许多先前的例子和练习中,你可能注意到了所有v-for循环中的key属性,如下所示:

<ol>
  <user-item
    v-for="user in users"
    v-bind:user="user"
    v-bind:key="user.id"
  ></user-item>
</ol>

也许你会想知道它是什么,它是用来做什么的。key属性是每个 DOM 节点的唯一标识,以便 Vue 可以跟踪它们的变化,从而重用和重新排序现有元素。使用index作为 key 属性的跟踪是 Vue 默认的行为,因此像这样使用index作为 key 属性是多余的:

<div v-for="(user, index) in users" :key="index">
  //...
</div>

因此,如果我们希望 Vue 准确地跟踪每个项目的标识,我们必须通过使用v-bind指令将每个 key 属性绑定到一个唯一的值,如下所示:

<div v-for="user in users" :key="user.id">
  //...
</div>

我们可以使用缩写:key来绑定唯一的值,就像前面的例子中所示的那样。还要记住,key是一个保留属性。这意味着它不能作为组件 prop 使用:

Vue.component('user-item', {
  props: ['key', 'user']
})

props属性中使用key将导致浏览器控制台中出现以下错误:

[Vue warn]: "key" is a reserved attribute and cannot be used as 
component prop.

当使用v-for与组件时,key属性是必需的。因此,最好在可能的情况下明确地使用keyv-for,无论您是否将其与组件一起使用。

为了演示这个问题,让我们创建一个 Vue 应用程序,我们将在其中使用index作为我们的key,并借助一点 jQuery 的帮助:

  1. <head>块中包含所需的 CDN 链接,以及一些 CSS 样式:
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="http://code.jquery.com/jquery-3.3.1.js"></script>
<style type="text/css">
  .removed {
    text-decoration: line-through;
  }
  .removed button {
    display: none;
  }
</style>
  1. <body>块中创建所需的应用程序 HTML 标记:
<div id="todo-list-example">
  <form v-on:submit.prevent="addNewTodo">
    <label for="new-todo">Add a todo</label>
    <input
      v-model="newTodoText"
      id="new-todo"
      placeholder="E.g. Feed the cat"
    >
    <button>Add</button>
  </form>
  <ul>
    <todo-item
      v-for="(todo, index) in todos"
      v-bind:key="index"
      v-bind:title="todo.title"
    ></todo-item>
  </ul>
</div>
  1. <script>块中创建所需的组件:
Vue.component('todo-item', {
  template: `<li>{{ title }} <button v-
   on:click="remove($event)">Remove</button></li>`,
  props: ['title'],
  methods: {
    remove: function ($event) {
      $($event.target).parent().addClass('removed')
    }
  }
})
  1. 创建所需的待办任务列表,如下所示:
new Vue({
  el: '#todo-list-example',
  data: {
    newTodoText: '',
    todos: [
      { id: 1, title: 'Do the dishes' },
      //...
    ],
    nextTodoId: 4
  },
  methods: {
    addNewTodo: function () {
      this.todos.unshift({
        id: this.nextTodoId++,
        title: this.newTodoText
      })
      this.newTodoText = ''
    }
  }
})

在这个例子中,我们通过在我们的todos数组上发生unshift来将一个新的待办任务添加到列表的顶部。我们通过向li元素添加removed类名来删除一个待办任务。然后,我们使用 CSS 为已删除的待办任务添加删除线,并隐藏删除按钮。

  1. 让我们移除洗碗。你会看到以下内容:
Do the dishes  (with a strike-through)
  1. 现在,添加一个名为喂猫的新任务。你会看到以下内容:
Feed the cat (with a strike-through)

这是因为喂猫现在已经占据了洗碗的索引,即 0。Vue 只是重用元素而不是渲染新元素。换句话说,无论对项目进行了何种更改,Vue 都将根据数组中的索引更新 DOM 元素。这意味着我们得到了一个意外的结果。

你可以在这本书的 GitHub 存储库中的/chapter-5/vue/component/key/using-index.html中找到这个例子。在浏览器上运行它,看看问题出在哪里。然后,将其与在/chapter-5/vue/component/key/using-id.html中使用id作为键的情况进行比较。你会发现你得到了正确的行为。

使用索引作为 key 的问题也可以通过以下伪代码来解释,其中正在生成一组数字,并为每个数字设置索引作为 key:

let numbers = [1,2,3]

<div v-for="(number, index) in numbers" :key="index">
  // Which turns into number - index
  1 - 0
  2 - 1
  3 - 2
</div>

这看起来很棒,乍一看工作正常。但是如果你添加数字 4,索引信息就变得无用了。这是因为现在每个数字都得到了一个新的索引:

<div v-for="(number, index) in numbers" :key="index">
  4 - 0
  1 - 1
  2 - 2
  3 - 3
</div>

如你所见,1、2 和 3 失去了它们的状态,必须重新渲染。这就是为什么对于这种情况,使用唯一的 key 是必需的。对于每个项目来说,保持其索引号并且不在每次更改时重新分配是很重要的:

<user-item
  v-for="(user, index) in users"
  v-bind:key="user.id"
  v-bind:name="user.name"
></user-item>

作为一个经验法则,每当你以一种导致索引变化的方式操纵列表时,使用 key,这样 Vue 可以在之后正确地更新 DOM。这些操纵包括以下内容:

  • 在数组中的任何位置添加一个项目

  • 从数组中删除一个项目,从除数组末尾以外的任何位置

  • 以任何方式重新排序数组

如果你的列表在组件的生命周期内从未改变过,或者你只是使用 push 函数而不是unshift函数添加项目,就像前面的例子一样,那么使用索引作为 key 是可以的。但是如果你试图追踪你何时需要使用索引和何时不需要使用索引,最终你会遇到“bug”,因为你可能会误解 Vue 的行为。

如果你不确定是否要使用索引作为 key,那么最好在v-for循环中使用带有不可变 ID 的key属性。使用具有唯一值的key属性不仅对v-for指令很重要,而且对 HTML 表单中的<input>元素也很重要。我们将在下一节中讨论这个问题。

使用 key 属性控制可重用的元素

为了提供更好的性能,我们发现 Vue 总是重用 DOM 节点而不是重新渲染,这可能会产生一些不良结果,正如前一节所示。这里有另一个没有使用v-for的示例,以演示为什么拥有 key 属性是相当重要的:

<div id="app">
  <template v-if="type === 'fruits'">
    <label>Fruits</label>
    <input />
  </template>
  <template v-else>
    <label>Vegetables</label>
    <input />
  </template>
  <button v-on:click="toggleType">Toggle Type</button>
</div>

<script type="text/javascript">
  new Vue({
    el: '#app',
    data: { type: 'fruits' },
    methods: {
      toggleType: function () {
        return this.type = this.type === 'fruits' ? 'vegetables' : 'fruits'
      }
    }
  })
</script>

在这个例子中,如果你在输入水果的名字并切换类型,你仍然会在vegetables输入框中看到你刚刚输入的名字。这是因为 Vue 试图尽可能地重用相同的<input>元素以获得最快的结果。但这并不总是理想的。你可以通过为每个<input>元素添加key属性以及一个唯一值来告诉 Vue 不要重用相同的<input>元素,如下所示:

<template v-if="type === 'fruits'">
  <label>Fruits</label>
  <input key="fruits-input"/>
</template>
<template v-else>
  <label>Vegetables</label>
  <input key="vegetables-input"/>
</template>

因此,如果您刷新页面并再次测试,输入字段现在应该按预期工作,而不会在切换它们时“重用”彼此。这不适用于<label>元素,因为它们没有key属性。但是,从视觉上来看,这不是问题。

您可以在本书的 GitHub 存储库的/chapter-5/vue/component/key/目录中的toggle-with-key.htmltoggle-without-key.html文件中找到此示例代码。

这就是您需要了解的关于 Vue 组件基本性质的全部内容。因此,到目前为止,您应该已经掌握了足够的基本知识,可以开始使用单文件组件创建 Vue 组件的下一个级别。让我们开始吧!

如果您想了解更多关于 Vue 组件以及 Vue 组件更深入的部分,例如插槽,请访问vuejs.org/v2/guide/components.html

创建单文件 Vue 组件

我们一直在使用单个 HTML 页面编写 Vue 应用程序,以便快速获得我们想要看到的结果。但是在 Vue 或 Nuxt 的实际开发项目中,我们不希望编写这样的东西:

const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

在前面的代码中,我们在一个地方(例如在单个 HTML 页面中)使用 JavaScript 对象创建了两个 Vue 组件,但最好将它们分开,并在单独的.js文件中创建每个组件,如下所示:

// components/foo.js
Vue.component('page-foo', {
  data: function () {
    return { message: 'foo' }
  },
  template: '<div>{{ count }}</div>'
})

这对于简单的组件可以很好地工作,其中 HTML 布局很简单。但是,在涉及更复杂的 HTML 标记的更复杂布局中,我们希望避免在 JavaScript 文件中编写 HTML。这个问题可以通过具有.vue扩展名的单文件组件来解决,如下所示:

// index.vue
<template>
  <p>{{ message }}</p>
</template>

<script>
export default {
  data () {
    return { message: 'Hello World!' }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

然而,我们不能只是在浏览器上运行该文件,而不使用构建工具(如 webpack 或 rollup)进行编译。在本书中,我们使用 webpack。这意味着,从现在开始,我们将不再使用 CDN 或单个 HTML 页面来创建复杂的 Vue 应用程序。相反,我们将使用.vue.js文件,只有一个.html文件来创建我们的 Vue 应用程序。我们将在接下来的部分指导您如何使用 webpack 来帮助我们做到这一点。让我们开始吧。

使用 webpack 编译单文件组件

要编译.vue组件,我们需要将vue-loadervue-template-compiler安装到 webpack 构建过程中。但在此之前,我们必须在项目目录中创建一个package.json文件,列出我们项目依赖的 Node.js 包。您可以在docs.npmjs.com/creating-a-package-json-file上查看package.json字段的详细信息。最基本和必需的是nameversion字段。让我们开始吧:

  1. 在项目目录中创建一个package.json文件,其中包含以下必填字段和值:
// package.json
{
  "name": "vue-single-file-component",
  "version": "1.0.0"
}
  1. 打开一个终端,将目录更改为您的项目,并安装vue-loadervue-template-compiler
$ npm i vue-loader --save-dev 
$ npm i vue-template-compiler --save-dev

您应该在终端上看到一个警告,因为您在此处安装的 Node.js 包需要其他 Node.js 包,其中最显着的是 webpack 包。在本书中,我们在本书的 GitHub 存储库中的/chapter-5/vue/component-webpack/basic/中设置了一个基本的 webpack 构建过程。我们将在大多数即将推出的 Vue 应用程序中使用此设置。我们已将 webpack 配置文件分成了三个较小的配置文件:

  • webpack.common.js包含在开发和生产过程中共享的常见 webpack 插件和配置。

  • webpack.dev.js仅包含开发过程的插件和配置。

  • webpack.prod.js仅包含生产过程的插件和配置。

以下代码显示了我们如何在script命令中使用这些文件:

// package.json
"scripts": {
  "start": "webpack-dev-server --open --config webpack.dev.js",
  "watch": "webpack --watch",
  "build": "webpack --config webpack.prod.js"
}

请注意,在本书中,我们假设您已经知道如何使用 webpack 来编译 JavaScript 模块。如果您对 webpack 还不熟悉,请访问webpack.js.org/获取更多信息。

  1. 因此,在安装了vue-loadervue-template-compiler之后,我们需要在webpack.common.js(或webpack.config.js,如果您使用单个配置文件)中配置module.rules,如下所示:
// webpack.common.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}
  1. 然后,我们可以使用在package.json中设置的以下命令来查看我们的应用程序运行情况:
  • $ npm run start用于在localhost:8080进行实时重新加载和开发

  • $ npm run watch用于在/path/to/your/project/dist/进行开发

  • $ npm run build用于在/path/to/your/project/dist/编译我们的代码

就是这样。现在你有了一个基本的构建流程来开发 Vue 应用程序与 webpack。所以,从现在开始,在更复杂的应用程序中,我们将编写单文件组件,并使用这种方法来编译它们。我们将在下一节中创建一个简单的 Vue 应用程序。

在单文件组件中传递数据和监听事件

到目前为止,我们一直在使用单个 HTML 页面进行我们的“todo”演示。这一次,我们将使用单文件组件来创建一个简单的“todo”购物清单。让我们开始:

  1. <div>元素中创建一个带有"todos"ID 的index.html文件,以便 Vue 运行 Vue 实例:
// index.html
<!doctype html>
<html>
  <head>
    <title>Todo Grocery Application (Single File 
     Components)</title>
  </head>
  <body>
    <div id="todos"></div>
  </body>
</html>
  1. 在项目根目录中创建一个/src/目录,并在其中创建一个entry.js文件作为文件入口点,以指示 webpack 应该使用哪些模块来开始构建我们的应用程序内部依赖图。webpack 还将使用此文件来找出入口点依赖的其他模块和库(直接和间接)。
// src/entry.js
'use strict'

import Vue from 'vue/dist/vue.js'
import App from './app.vue'

new Vue({
  el: 'todos',
  template: '<App/>',
  components: {
    App
  }
})
  1. <script>块中创建一个提供虚拟数据的父组件,其中包含项目列表:
// src/app.vue
<template>
  <div>
    <ol>
      <TodoItem
        v-for="thing in groceryList"
        v-bind:item="thing"
        v-bind:key="item.id"
        v-on:add-item="addItem"
        v-on:delete-item="deleteItem"
      ></TodoItem>
    </ol>
    <p><span v-html="&pound;"></span>{{ total }}</p>
  </div>
</template>

<script>
import TodoItem from './todo-item.vue'
export default {
  data () {
    return {
      cart: [],
      total: 0,
      groceryList: [
        { id: 0, text: 'Lentils', price: 2 },
        //...
      ]
    }
  },
  components: {
    TodoItem
  }
}
</script>

在上面的代码中,我们简单地将子组件作为TodoItem导入,并使用v-forgroceryList中生成它们的列表。

  1. methods对象中添加以下方法以添加和删除项目。然后,在computed对象中添加一个方法,计算购物车中项目的总成本:
// src/app.vue
methods: {
  addItem (item) {
    this.cart.push(item)
    this.total = this.shoppingCartTotal
  },
  deleteItem (item) {
    this.cart.splice(this.cart.findIndex(e => e === item), 1)
    this.total = this.shoppingCartTotal
  }
},
computed: {
  shoppingCartTotal () {
    let prices = this.cart.map(item => item.price)
    let sum = prices.reduce((accumulator, currentValue) =>
     accumulator + currentValue, 0)
    return sum
  }
}
  1. 创建一个子组件,通过props显示从父级传递下来的项目:
// src/todo-item.vue
<template>
  <li>
    <input type="checkbox" :name="item.id" v-model="checked"> {{
     item.text }}
    <span v-html="&pound;"></span>{{ item.price }}
  </li>
</template>

<script>
export default {
  props: ['item'],
  data () {
    return { checked: false }
  },
  methods: {
    addToCart (item) {
      this.$emit('add-item', item)
    }
  },
  watch: {
    checked (boolean) {
      if (boolean === false) {
        return this.$emit('delete-item', this.item)
      }
      this.$emit('add-item', this.item)
    }
  }
}
</script>

在这个组件中,我们还有一个checkbox按钮。这用于发出delete-itemadd-item事件,并将项目数据传递给父级。现在,如果你用$ npm run start运行应用程序,你应该看到它在localhost:8080加载。

干得好!你已经成功构建了一个使用 webpack 的 Vue 应用程序,这就是 Nuxt 在幕后使用的编译和构建你的 Nuxt 应用程序。了解已建立系统下方运行的内容总是有用的。当你知道如何使用 webpack 时,你可以使用刚学到的 webpack 构建设置来进行各种 JavaScript 和 CSS 相关的项目。

你可以在本书的 GitHub 存储库中的/chapter-5/vue/component-webpack/todo/中找到这个示例。

在下一节中,我们将把前面几节学到的内容应用到/chapter-5/nuxt-universal/local-components/sample-website/中的sample website示例网站中,这个示例可以在本书的 GitHub 存储库中找到。

在 Nuxt 中添加 Vue 组件

在示例网站中,我们只有两个.vue文件可以使用 Vue 组件进行改进:/layouts/default.vue/pages/work/index.vue。首先,我们应该改进/layouts/default.vue。在这个文件中,我们只需要改进三件事:导航、社交媒体链接和版权。

重构导航社交链接

我们将从重构导航和社交媒体链接开始:

  1. /components/目录中创建一个导航组件,如下所示:
// components/nav.vue
<template>
  <li>
    <nuxt-link :to="item.link" v-html="item.name">
    </nuxt-link>
  </li>
</template>

<script>
export default {
  props: ['item']
}
</script>
  1. /components/目录中也创建一个社交链接组件,如下所示:
// components/social.vue
<template>
  <li>
    <a :href="item.link" target="_blank">
      <i :class="item.classes"></i>
    </a>
  </li>
</template>

<script>
export default {
  props: ['item']
}
</script>
  1. 将它们导入到布局的<script>块中,如下所示:
// layouts/default.vue
import Nav from '~/components/nav.vue'
import Social from '~/components/social.vue'

components: {
  Nav,
  Social
}

请注意,如果您在 Nuxt 配置文件中将components选项设置为true,则可以跳过此步骤。

  1. <template>块中删除现有的导航和社交链接块:
// layouts/default.vue
<template v-for="item in nav">
  <li><nuxt-link :to="item.link" v-html="item.name">
  </nuxt-link></li>
</template>

<template v-for="item in social">
  <li>
    <a :href="item.link" target="_blank">
      <i :class="item.classes"></i>
    </a>
  </li>
</template>
  1. 用导入的NavSocial组件替换它们,如下所示:
// layouts/default.vue
<Nav
  v-for="item in nav"
  v-bind:item="item"
  v-bind:key="item.slug"
 ></Nav>

<Social
  v-for="item in social"
  v-bind:item="item"
  v-bind:key="item.name"
 ></Social>

有了这些,你就完成了!

重构版权组件

现在,我们将重构已经存在于/components/目录中的版权组件。让我们开始吧:

  1. /components/base-copyright.vue文件的<script>块中删除data函数:
// components/copyright.vue
export default {
  data () {
    return { copyright: '&copy; Lau Tiam Kok' }
  }
}
  1. props属性替换前面的data函数,如下所示:
// components/copyright.vue
export default {
  props: ['copyright']
}
  1. 将版权数据添加到<script>块中,而不是/layouts/default.vue中:
// layouts/default.vue
data () {
  return {
    copyright: '&copy; Lau Tiam Kok',
  }
}
  1. <template>块中删除现有的<Copyright />组件:
// layouts/default.vue
<Copyright />
  1. 添加一个新的<Copyright />组件,并将版权数据绑定到它:
// layouts/default.vue
<Copyright v-bind:copyright="copyright" />

到此为止,您应该已经成功将数据从默认页面(父级)传递给组件(子级),在默认页面中保留了您的数据。干得好!这就是/layouts/default.vue的全部内容。我们还可以改进工作页面,我们已经为您在/chapter-5/nuxt-universal/local-components/sample-website/中完成了这项工作,这可以在本书的 GitHub 存储库中找到。如果您在本地安装此示例网站并在本地机器上运行它,您将看到我们已经很好地应用了我们的组件。通过这个例子,您可以看到在理解了 Vue 组件系统的工作原理后,将布局中的元素抽象化为组件是多么容易。但是,如何将数据传递给父组件呢?为此,我们创建了一个示例应用程序,其中子组件向父组件发出事件,位于/chapter-5/nuxt-universal/local-components/emit-events/,这可以在本书的 GitHub 存储库中找到。我们还向应用程序添加了自定义输入和复选框组件,请查看一下。以下是一个示例片段:

// components/input-checkbox.vue
<template>
  <input
    type="checkbox"
    v-bind:checked="checked"
    v-on:change="changed"
    name="subscribe"
    value="newsletter"
  >
</template>

<script>
export default {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: { checked: Boolean },
  methods: {
    changed ($event) {
      this.$emit('change', $event.target.checked)
    }
  }
}
</script>

在这里,您可以看到我们在 Nuxt 应用程序中使用的组件代码与我们在 Vue 应用程序中编写的代码相同。这些类型的组件是嵌套组件。props属性和$emit方法用于在父组件和子组件之间传递数据。这些嵌套组件也是本地的,因为它们只在导入它们的组件(父级)的范围内可用。因此,从另一个角度来看,Vue 组件可以被归类为本地组件和全局组件。自从*什么是组件?*部分以来,您一直在学习全局组件。但是,您只学会了如何在 Vue 应用程序中使用它们。在接下来的部分中,我们将看看如何为 Nuxt 应用程序注册全局组件。但在跳入其中之前,让我们从整体的角度重新审视 Vue 组件:全局组件和本地组件。

注册全局和本地组件

我们已经创建了许多组件,无论是使用Vue.component()、纯 JavaScript 对象还是单文件组件引擎。我们创建的一些组件是全局组件,而另一些是本地组件。例如,在上一节中刚刚创建的/components/目录中的所有重构组件都是本地组件,而在*什么是组件?*部分中创建的组件都是全局组件。无论它们是本地组件还是全局组件,如果您想使用它们,都必须进行注册。其中一些在创建时注册,而另一些则需要手动注册。在接下来的部分中,您将学习如何全局和本地注册它们。您还将了解两种类型的注册将如何影响您的应用程序。我们将学习如何注册 Vue 组件,而不是传递它们。

在 Vue 中注册全局组件

全局组件,正如它们的名称所示,可以在整个应用程序中全局使用。当您使用Vue.component()创建它们时,它们会被全局注册:

Vue.component('my-component-name', { ... })

全局组件必须在根 Vue 实例实例化之前注册。注册后,它们可以在根 Vue 实例的模板中使用,如下所示:

Vue.component('component-x', { ... })
Vue.component('component-y', { ... })
Vue.component('component-z', { ... })

new Vue({ el: '#app' })

<div id="app">
  <component-x></component-x>
  <component-y></component-y>
  <component-z></component-z>
</div>

在这里,您可以看到注册全局组件非常容易 - 在创建它们时,您甚至可能意识不到注册过程。我们将很快在Nuxt 中注册全局组件部分中研究这种类型的注册。但现在,我们将学习如何注册本地组件。

在 Vue/Nuxt 中注册本地组件

在本章中,我们已经看到并使用了 Vue 和 Nuxt 应用中的本地组件。这些组件是通过使用纯 JavaScript 对象创建的,如下所示:

var ComponentX = { ... }
var ComponentY = { ... }
var ComponentZ = { ... }

然后,它们可以通过components选项进行注册,如下所示:

new Vue({
  el: '#app',
  components: {
    'component-x': ComponentX,
    'component-y': ComponentY,
    'component-z': ComponentZ
  }
})

还记得我们在本书的 GitHub 存储库中的/chapter-5/vue/component/basic.html文件中创建的 Vue 应用吗?该应用中的user-item组件是一个全局组件。现在,让我们对其进行重构并将其变成一个本地组件:

  1. 移除以下全局组件:
Vue.component('user-item', {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
})
  1. 使用以下方式替换为本地组件:
const UserItem = {
  props: ['user'],
  template: '<li>{{ user.name }}</li>'
}
  1. 使用components选项注册本地组件:
new Vue({
  el: '#app',
  data: {
    users: [
      { id: 0, name: 'John Doe' },
      //...
    ]
  },
  components: {
    'user-item': UserItem
  }
})

该应用程序将与以前的方式相同工作。唯一的区别是user-item不再全局可用。这意味着它在任何其他子组件中都不可用。例如,如果您想要在ComponentZ中使ComponentX可用,那么您必须手动"附加"它:

var ComponentX = { ... }

var ComponentZ = {
  components: {
    'component-x': ComponentX
  }
}

如果您正在使用 babel 和 webpack 编写 ES2015 模块,您可以将ComponentX作为单文件组件,然后导入它,如下所示:

// components/ComponentZ.vue
import Componentx from './Componentx.vue'

export default {
  components: {
    'component-x': ComponentX
  }
}

<component-x
  v-for="item in items"
  ...
></component-x>

您还可以从components选项中省略component-x,并直接在其中使用ComponentX变量,如下所示:

// components/ComponentZ.vue
export default {
  components: {
    ComponentX
  }
}

在 ES2015+中使用诸如ComponentX之类的变量作为 JavaScript 对象的简写形式为ComponentX: ComponentX。由于component-x从未注册过,所以您需要在模板中使用<ComponentX>而不是<component-x>

<ComponentX
  v-for="item in items"
  ...
></ComponentX>

在前面的单文件组件中编写 ES2015 与我们在 Nuxt 中编写.vue文件的方式相同。因此,到目前为止,您应该已经意识到我们一直在 Nuxt 应用程序中编写本地组件,例如/components/copyright.vue/components/nav.vue。但是在 Nuxt 应用程序中如何编写全局组件呢?这就是/plugins/目录发挥作用的地方。在下一节中,您将学习如何在 Nuxt 中进行此操作。

您可以在本书的 GitHub 存储库中的/chapter-5/vue/component/registering-local-components.html中找到前面的应用程序。

在 Nuxt 中注册全局组件

我们在第二章中学习了目录结构,开始使用 Nuxt/plugins/目录是我们可以创建 JavaScript 文件并在实例化根 Vue 应用程序之前运行的最佳位置。因此,这是注册我们的全局组件的最佳位置。

让我们创建我们的第一个全局组件:

  1. /plugins/目录中创建一个简单的 Vue 组件,如下所示:
// components/global/sample-1.vue
<template>
  <p>{{ message }}</p>
</template>

<script>
export default {
  data () {
    return {
      message: 'A message from sample global component 1.'
    }
  }
}
</script>
  1. /plugins/目录中创建一个.js文件,并导入前面的组件,如下所示:
// plugins/global-components.js
import Vue from 'vue'
import Sample from '~/components/global/sample-1.vue'

Vue.component('sample-1', Sample)
  1. 我们还可以直接在/plugins/global-components.js中创建第二个全局组件,如下所示:
Vue.component('sample-2', {
  render (createElement) {
    return createElement('p', 'A message from sample global
     component 2.')
  }
})
  1. 告诉 Nuxt 在 Nuxt 配置文件中在实例化根应用程序之前先运行它们,如下所示:
// nuxt.config.js
plugins: [
  '~/plugins/global-components.js',
]

请注意,此组件将在 Nuxt 应用程序的客户端和服务器端都可用。如果您只想在特定端上运行此组件,例如仅在客户端上运行,则可以注册它,如下所示:

// nuxt.config.js
plugins: [
  { src: '~/plugins/global-components.js',  mode: 'client' }
]

现在,这个组件只能在客户端使用。但是,如果你只想在服务器端运行它,只需在前面的mode选项中使用server

  1. 我们可以在任何地方使用这些全局组件,而无需手动再次导入它们,如下面的代码所示:
// pages/about.vue
<sample-1 />
<sample-2 />
  1. 在浏览器上运行应用程序。你应该得到以下输出:
<p>A message from sample global component 1.</p>
<p>A message from sample global component 2.</p>

就是这样!这就是你可以通过涉及各种文件在 Nuxt 中注册全局组件的方法。全局注册的底线是使用Vue.component,就像我们在 Vue 应用程序中所做的那样。然而,全局注册通常不是理想的,就像它的“表兄弟”全局混入一样,我们将在下一节中介绍。例如,全局注册组件但在大多数情况下不需要它们对于服务器和客户端来说都是不必要的。现在,让我们继续看看混入是什么,以及如何编写它们。

你可以在本书的 GitHub 存储库中的/chapter-5/nuxt-universal/global-components/中找到这个例子。

编写基本和全局混入

混入只是一个 JavaScript 对象,可以用来包含任何组件选项,比如createdmethodsmounted等等。它们可以用来使这些选项可重用。我们可以通过将它们导入到组件中,并将它们与该组件中的其他选项“混合”来实现这一点。

在某些情况下,使用混入可能是有用的,比如在第二章中,开始使用 Nuxt。我们知道,当 Vue Loader 编译单文件组件中的<template>块时,它会将遇到的任何资源 URL 转换为 webpack 模块请求,如下所示:

<img src="~/assets/sample-1.jpg">

前面的图像将被转换为以下 JavaScript 代码:

createElement('img', {
  attrs: {
    src: require('~/assets/sample-1.jpg') // this is now a module request
  }
})

如果您手动插入图像,这并不难。但在大多数情况下,我们希望动态插入图像,如下所示:

// pages/about.vue
<template>
  <img :src="'~/assets/images' + post.image.src" :alt="post.image.alt">
</template>

const post = {
  title: 'About',
  image: {
    src: '/about.jpg',
    alt: 'Sample alt 1'
  }
}

export default {
  data () {
    return { post }
  }
}

在这个例子中,当您在控制台上使用:src指令时,图像将会得到 404 错误,因为 Vue Loader 在构建过程中从未编译它。为了解决这个问题,我们需要手动将模块请求插入到:src指令中。

<img :src="require('~/assets/images/about.jpg')" :alt="post.image.alt">

然而,这也不好,因为更倾向于动态图像解决方案。因此,这里的解决方案如下:

<img :src="loadAssetImage(post.image.src)" :alt="post.image.alt">

在这个解决方案中,我们编写了一个可重用的loadAssetImage函数,以便在任何需要的 Vue 组件中调用它。因此,在这种情况下,我们需要混合。有几种使用混合的方法。我们将在接下来的几节中看一些常见的用法。

创建基本混合/非全局混合

在非单文件组件 Vue 应用程序中,我们可以这样定义一个混合对象:

var myMixin = {
  created () {
    this.hello()
  },
  methods: {
    hello () { console.log('hello from mixin!') }
  }
}

然后,我们可以使用Vue.extend()将其“附加”到一个组件中:

const Foo = Vue.extend({
  mixins: [myMixin],
  template: '<div>foo</div>'
})

在这个例子中,我们只将这个混合附加到Foo,所以当调用这个组件时,你只会看到console.log消息。

你可以在这本书的 GitHub 存储库的/chapter-5/vue/mixins/basic.html中找到这个例子。

对于 Nuxt 应用程序,我们在/plugins/目录中创建并保存混合对象,保存在.js文件中。让我们来演示一下:

  1. /plugins/目录中创建一个mixin-basic.js文件,其中包含一个在浏览器控制台上打印消息的函数,当 Vue 实例被创建时:
// plugins/mixin-basic.js
export default {
  created () {
    this.hello()
  },
  methods: {
    hello () {
      console.log('hello from mixin!')
    }
  }
}
  1. 在需要的地方随时导入它,如下所示:
// pages/about.vue
import Mixin from '~/plugins/mixin-basic.js'

export default {
  mixins: [Mixin]
}

在这个例子中,只有当你在/about路由上时,你才会得到console.log消息。这就是我们创建和使用非全局混合的方法。但在某些情况下,我们需要全局混合适用于应用程序中的所有组件。让我们看看我们如何做到这一点。

你可以在这本书的 GitHub 存储库的/chapter-5/nuxt-universal/mixins/basic/中找到这个例子。

创建全局混合

我们可以通过使用Vue.mixin()来创建和应用全局混合:

Vue.mixin({
  mounted () {
    console.log('hello from mixin!')
  }
})

全局混合必须在实例化 Vue 实例之前定义:

const app = new Vue({
  //...
}).$mount('#app')

现在,你创建的每个组件都将受到影响并显示该消息。你可以在这本书的 GitHub 存储库的/chapter-5/vue/mixins/global.html中找到这个例子。如果你在浏览器上运行它,你会看到console.log消息出现在每个路由上,因为它在所有路由组件中传播。通过这种方式,我们可以看到如果被滥用可能造成的潜在危害。在 Nuxt 中,我们以相同的方式创建全局混合;也就是使用Vue.mixin()。让我们来看一下:

  1. /plugins/目录中创建一个mixin-utils.js文件,以及用于从/assets/目录加载图像的函数:
// plugins/mixin-utils.js
import Vue from 'vue'

Vue.mixin({
  methods: {
    loadAssetImage (src) {
      return require('~/assets/images' + src)
    }
  }
})
  1. 在 Nuxt 配置文件中包含前面的全局混合路径:
// nuxt.config.js
module.exports = {
  plugins: [
    '~/plugins/mixin-utils.js'
  ]
}
  1. 现在,你可以在你的组件中随意使用loadAssetImage函数,如下所示:
// pages/about.vue
<img :src="loadAssetImage(post.image.src)" :alt="post.image.alt">

请注意,我们不需要像导入基本混入那样导入全局混入,因为我们已经通过nuxt.config.js全局注入了它们。但同样,要谨慎而谨慎地使用它们。

您可以在本书的 GitHub 存储库中的/chapter-5/nuxt-universal/mixins/global/中找到这个混入的示例。

混入非常有用。全局混入如全局 Vue 组件在数量过多时很难管理,因此会使您的应用难以预测和调试。因此,明智而谨慎地使用它们。我们希望您现在知道 Vue 组件是如何工作的以及如何编写它们。然而,仅仅知道它们是如何工作和如何编写它们是不够的 - 我们应该了解编写可读性和未来可管理性时需要遵守的标准规则。因此,在结束本章之前,我们将看一些这些规则。

定义组件名称和使用命名约定

在本章和前几章中,我们已经看到并创建了许多组件。我们创建的组件越多,我们就越需要遵循组件的命名约定。否则,我们将不可避免地会遇到混淆和错误,以及反模式。我们的组件将不可避免地会相互冲突 - 甚至与 HTML 元素相冲突。幸运的是,有一个官方的 Vue 风格指南,我们可以遵循以提高我们应用的可读性。在本节中,我们将介绍一些特定于本书的规则。

多词组件名称

我们现有和未来的 HTML 元素都是单词(例如articlemainbody等),因此为了防止冲突发生,我们在命名组件时应该使用多个单词(除了根应用组件)。例如,以下做法被认为是不好的:

// .js
Vue.component('post', { ... })

// .vue
export default {
  name: 'post'
}

组件的名称应该按照以下方式书写:

// .js
Vue.component('post-item', { ... })

// .vue
export default {
  name: 'PostItem'
}

组件数据

我们应该始终使用data函数而不是data属性来处理组件的数据,除了在根 Vue 实例中。例如,以下做法被认为是不好的:

// .js
Vue.component('foo-component', {
  data: { ... }
})

// .vue
export default {
  data: { ... }
}

上述组件中的数据应该按照以下方式书写:

// .js
Vue.component('foo-component', {
  data () {
    return { ... }
  }
})

// .vue
export default {
  data () {
    return { ... }
  }
}

// .js or .vue
new Vue({
  data: { ... }
})

但是为什么呢?这是因为当 Vue 初始化数据时,它会从vm.$options.data创建一个对data的引用。因此,如果数据是一个对象,并且一个组件有多个实例,它们都将使用相同的data。更改一个实例中的数据将影响其他实例。这不是我们想要的。因此,如果data是一个函数,Vue 将使用getData方法返回一个只属于当前初始化实例的新对象。因此,根实例中的数据在所有其他组件实例中共享,这些实例包含它们自己的数据。你可以通过this.$root.$data从任何组件实例中访问根数据。你可以在本书的 GitHub 存储库中的/chapter-5/vue/component-webpack/data//chapter-5/vue/data/basic.html中查看一些示例。

你可以在github.com/vuejs/vue/blob/dev/src/core/instance/state.js#L112上查看 Vue 源代码,了解数据是如何初始化的。

属性定义

我们应该在props属性中定义属性,以便尽可能详细地指定它们的类型(至少)。只有在原型设计时才可以不进行详细定义。例如,以下做法被认为是不好的:

props: ['message']

这应该这样写:

props: {
  message: String
}

或者,更好的做法是这样写:

props: {
  message: {
    type: String,
    required: false,
    validator (value) { ... }
  }
}

组件文件

我们应该始终遵守“一个文件一个组件”的政策;也就是说,一个文件中只写一个组件。这意味着你不应该在一个文件中有多个组件。例如,以下做法被认为是不好的:

// .js
Vue.component('PostList', { ... })

Vue.component('PostItem', { ... })

它们应该拆分成多个文件,如下所示:

components/
|- PostList.js
|- PostItem.js

如果你在.vue中编写组件,应该这样做:

components/
|- PostList.vue
|- PostItem.vue

单文件组件文件名大小写

我们应该只为单文件组件的文件名使用 PascalCase 或 kebab-case。例如,以下做法被认为是不好的:

components/
|- postitem.vue

components/
|- postItem.vue

它们应该这样写:

// PascalCase
components/
|- PostItem.vue

// kebab-case
components/
|- post-item.vue

自闭合组件

当我们的单文件组件中没有内容时,应该使用自闭合格式,除非它们在 DOM 模板中使用。例如,以下做法被认为是不好的:

// .vue
<PostItem></PostItem>

// .html
<post-item/>

它们应该这样写:

// .vue
<PostItem/>

// .html
<post-item></post-item>

这些只是一些基本的规则。还有更多规则,比如编写多属性元素的规则,指令简写,带引号的属性值等等。但是我们在这里突出显示的选定规则应该足够让你完成本书。你可以在vuejs.org/v2/style-guide/找到其他规则和完整的样式指南。

摘要

干得好!在本章中,你学会了全局和局部 Vue 组件之间的区别,如何在 Nuxt 应用程序中注册全局组件,以及如何创建局部和全局 mixin。你还学会了如何通过props属性将数据传递给子组件,如何使用$emit方法从子组件向父组件发出数据,以及如何创建自定义输入组件。然后,你学会了为组件使用key属性的重要性。之后,你学会了如何使用 webpack 编写单文件组件。最后但同样重要的是,你了解了在 Nuxt 和 Vue 应用程序开发中应该遵循的一些规则。

在下一章中,我们将进一步探讨/plugins/目录的使用,通过编写 Vue 中的自定义插件并导入它们来扩展 Nuxt 应用程序。我们还将研究如何从 Vue 社区导入外部 Vue 插件,通过将它们注入到 Nuxt 的$rootcontext组件中创建全局函数,编写基本/异步模块和模块片段,并使用 Nuxt 社区的外部 Nuxt 模块。我们将会对这些进行详细的指导,敬请关注!

编写插件和模块

还记得自从第三章以来在 Nuxt 应用程序中编写一些简单的插件吗,添加 UI 框架?正如我们之前提到的,插件本质上是JavaScript 函数。在 web 开发中,您总是需要编写自定义函数以适应您的情况,在本书中我们将创建相当多的函数。在本章中,我们将更详细地了解为您的 Nuxt 应用程序创建自定义插件,以及自定义模块。您将学习在 Vue 应用程序中创建自定义插件并在 Nuxt 应用程序中实现它们。然后,您将学习如何在插件之上创建自定义 Nuxt 模块。您还将学习导入和安装现有的 Vue 插件和 Nuxt 模块,这些插件和模块是来自 Vue 和 Nuxt 社区的贡献,可以在您的 Nuxt 应用程序中使用。无论是自定义的还是外部导入的,学习和理解 Vue 插件和 Nuxt 模块都很重要,因为在接下来的章节中我们将经常使用其中的一些。

本章我们将涵盖以下主题:

  • 编写 Vue 插件

  • 在 Nuxt 中编写全局函数

  • 编写 Nuxt 模块

  • 编写异步 Nuxt 模块

  • 编写 Nuxt 模块片段

第六章:编写 Vue 插件

插件是封装在.js文件中的全局 JavaScript 函数,可以通过使用Vue.use全局方法在应用程序中安装。我们在第四章的过去示例中使用了一些 Vue 插件,例如vue-routervue-meta。这些插件必须在使用new语句初始化根 Vue 之前通过Vue.use方法安装,如下例所示:

// src/entry.js
import Vue from 'vue'
import Meta from 'vue-meta'

Vue.use(Meta)
new VueRouter({ ... })

您可以通过Vue.use将选项传递到插件中以配置插件的格式:

Vue.use(<plugin>, <options>)

例如,我们可以将以下选项传递到vue-meta插件中:

Vue.use(Meta, {
  keyName: metaData, // default => metaInfo
  refreshOnceOnNavigation: true // default => false
})

选项是可选的。这意味着您可以在不传递任何选项的情况下使用插件本身。Vue.use还可以防止您意外多次注入相同的插件,因此多次调用插件将只安装一次。

您可以查看awesome-vue,这是一个庞大的社区贡献的插件和库集合,网址为https://github.com/vuejs/awesome-vuecomponents--libraries

现在让我们在下一节中探讨如何创建 Vue 插件。

在 Vue 中编写自定义插件

编写 Vue 插件相当容易。您只需要在插件中使用一个install方法来接受Vue作为第一个参数和options作为第二个参数:

// plugin.js
export default {
  install (Vue, options) {
    // ...
  }
}

让我们为标准 Vue 应用程序创建一个简单的自定义问候插件,以不同的语言。可以通过options参数配置语言;当没有提供选项时,将使用英语作为默认语言:

  1. /src/目录中创建一个/plugins/文件夹,并在其中创建一个basic.js文件,其中包含以下代码:
// src/plugins/basic.js
export default {
  install (Vue, options) {
    if (options === undefined) {
      options = {}
    }
    let { language } = options
    let languages = {
      'EN': 'Hello!',
      'ES': 'Hola!'
    }
    if (language === undefined) {
      language = 'EN'
    }
    Vue.prototype.$greet = (name) => {
      return languages[language] + ' ' + name
    }
    Vue.prototype.$message = 'Helló Világ!'
  }
}

在这个简单的插件中,我们还添加了一个名为$message的实例属性,其默认值为匈牙利语的“Hello World!”(Helló Világ!),当此插件在组件中使用时可以进行修改。请注意,{ language } = options是使用 ES6 编写language = options.language的方式。此外,我们应该使用$作为方法和属性的前缀,因为这是一种惯例。

  1. 安装和配置此插件如下:
// src/entry.js
import PluginSample from './plugins/basic'
Vue.use(PluginBasic, {
  language: 'ES'
})
  1. 然后我们可以在任何 Vue 组件中全局使用该插件,就像以下示例中一样:
// src/components/home.vue
<p>{{ $greet('John') }}</p>
<p>{{ $message }}</p>
<p>{{ messages }}</p>

export default {
  data () {
    let helloWorld = []
    helloWorld.push(this.$message)

    this.$message = 'Ciao mondo!'
    helloWorld.push(this.$message)

    return { messages: helloWorld }
  }
}

因此,当您在浏览器上运行应用程序时,您应该在屏幕上看到以下输出:

Hola! John
Ciao mondo!
[ "Helló Világ!", "Ciao mondo!" ]

您还可以在插件中使用componentdirective,就像以下示例中一样:

// src/plugins/component.js
export default {
  install (Vue, options) {
    Vue.component('custom-component', {
     // ...
    })
  }
}

// src/plugins/directive.js
export default {
  install (Vue, options) {
    Vue.directive('custom-directive', {
      bind (el, binding, vnode, oldVnode) {
        // ...
      }
    })
  }
}

我们还可以使用Vue.mixin()将插件注入到所有组件中,如下所示:

// src/plugins/plugin-mixin.js
export default {
  install (Vue, options) {
    Vue.mixin({
      // ...
    })
  }
}

您可以在我们的 GitHub 存储库的/chapter-6/vue/webpack/中找到前面的示例 Vue 应用程序。

就是这样。创建一个可以在 Vue 应用程序中安装和使用的 Vue 插件非常简单,不是吗?那么在 Nuxt 应用程序中呢?我们如何在 Nuxt 应用程序中安装前面的自定义 Vue 插件?让我们在下一节中找出答案。

将 Vue 插件导入到 Nuxt 中

在 Nuxt 应用程序中,该过程基本相同。所有插件都必须在初始化根 Vue 之前运行。因此,如果我们想要使用 Vue 插件,就像之前的示例插件一样,我们需要在启动 Nuxt 应用程序之前设置插件。让我们将我们的自定义basic.js插件复制到 Nuxt 应用程序的/plugins/目录中,然后执行以下步骤来安装它:

  1. 创建一个basic-import.js文件,以以下方式在/plugins/目录中导入basic.js
// plugins/basic-import.js
import Vue from 'vue'
import PluginSample from './basic'

Vue.use(PluginSample)

这次在使用Vue.use方法安装插件时,我们跳过了选项。

  1. basic-import.js的文件路径添加到 Nuxt 配置文件的plugins选项中,如下所示:
export default {
  plugins: [
    '~/plugins/basic-import',
  ]
}
  1. 在任何喜欢的页面中使用此插件-就像我们在 Vue 应用程序中所做的那样:
// pages/index.vue
<p>{{ $greet('Jane') }}</p>
<p>{{ $message }}</p>
<p>{{ messages }}</p>

export default {
  data () {
    let helloWorld = []
    helloWorld.push(this.$message)

    this.$message = 'Olá Mundo!'
    helloWorld.push(this.$message)

    return { messages: helloWorld }
  }
}
  1. 在浏览器上运行 Nuxt 应用程序,您应该在屏幕上看到以下输出:
Hello! Jane
Olá Mundo!
[ "Helló Világ!", "Olá Mundo!" ]

这次我们使用$greet方法得到了英文版的“Hello!”,因为在安装插件时没有设置任何语言选项。此外,在这个索引页面的<template>块中,你将得到“Olá Mundo!”的$message,而在其他页面(例如/about/contact)上,你将得到“Helló Világ!”,因为我们只在索引页面上设置了这个葡萄牙语版本的“Hello World!”,即this.$message = 'Olá Mundo!'

正如我们在本章开头提到的,有一个庞大的社区贡献的 Vue 插件集合,可能对你的 Nuxt 应用程序有用,但是一些插件可能只在浏览器中工作,因为它们缺乏 SSR(服务器端渲染)支持。因此,在接下来的部分,我们将看看如何解决这种类型的插件。

导入没有 SSR 支持的外部 Vue 插件

在 Nuxt 中,有一些 Vue 插件已经预先安装好了,比如vue-routervue-metavuexvue-server-renderer。未安装的插件可以按照我们在上一节中安装自定义 Vue 插件的步骤轻松排序。以下是我们如何在 Nuxt 应用程序中使用vue-notifications的示例:

  1. 使用 npm 安装插件:
$ npm i vue-notification
  1. 导入并注入插件,就像我们使用自定义插件一样:
// plugins/vue-notifications.js
import Vue from 'vue'
import VueNotifications from 'vue-notifications'

Vue.use(VueNotifications)
  1. 将文件路径包含到 Nuxt 配置文件中:
// nuxt.config.js:
export default {
  plugins: ['~/plugins/vue-notifications']
}

对于没有 SSR 支持的插件,或者当你只想在客户端上使用这个插件时,你可以在plugins选项中使用mode: 'client'选项,以确保这个插件不会在服务器端执行,就像下面的例子一样:

// nuxt.config.js
export default {
  plugins: [
    { src: '~/plugins/vue-notifications', mode: 'client' }
  ]
}

如你所见,安装 Vue 插件只需要三个步骤,无论是外部插件还是自定义插件。总之,Vue 插件是通过使用Vue.use方法将全局 JavaScript 函数注入到 Vue 实例中,并通过在插件内部暴露install方法来实现的。但在 Nuxt 本身中,还有其他创建全局函数的方法,可以将它们注入到 Nuxt 上下文(context)和 Vue 实例($root)中,而无需使用install方法。我们将在接下来的部分中探讨这些方法。

有关vue-notifications的更多信息,请访问https://github.com/euvl/vue-notification

在 Nuxt 中编写全局函数

在 Nuxt 中,我们可以通过将它们注入到以下三个项目中来创建“插件”或全局函数:

  • Vue 实例(客户端):
// plugins/<function-name>.js
import Vue from 'vue'
Vue.prototype.$<function-name> = () => {
  //...
}
  • Nuxt 上下文(服务器端):
// plugins/<function-name>.js
export default (context, inject) => {
  context.app.$<function-name> = () => {
    //...
  }
}
  • Vue 实例和 Nuxt 上下文:
// plugins/<function-name>.js
export default (context, inject) => {
  inject('<function-name>', () => {
    //...
  })
}

使用上述格式,你可以轻松地为你的应用编写全局函数。在接下来的章节中,我们将指导你通过一些示例函数。所以让我们开始吧。

将函数注入到 Vue 实例中

在这个例子中,我们将创建一个用于计算两个数字之和的函数,例如,1 + 2 = 3。我们将通过以下步骤将这个函数注入到 Vue 实例中:

  1. 创建一个.js文件,导入vue,并将函数附加到vue.prototype中的/plugins/目录中:
// plugins/vue-injections/sum.js
import Vue from 'vue'
Vue.prototype.$sum = (x, y) => x + y
  1. 将函数文件路径添加到 Nuxt 配置文件的plugins属性中:
// nuxt.config.js
export default {
  plugins: ['~/plugins/vue-injections/sum']
}
  1. 在任何你喜欢的地方使用这个函数,例如:
// pages/vue-injections.vue
<p>{{ this.$sum(1, 2) }}</p>
<p>{{ sum }}</p>

export default {
  data () {
    return {
      sum: this.$sum(2, 3)
    }
  }
}
  1. 在浏览器上运行页面,你应该在屏幕上得到以下输出(即使刷新页面):
3
5

将函数注入到 Nuxt 上下文中

在这个例子中,我们将创建一个用于计算一个数字的平方的函数,例如,5 * 5 = 25。我们将通过以下步骤将这个函数注入到 Nuxt 上下文中:

  1. 创建一个.js文件,并将函数附加到context.app中:
// plugins/ctx-injections/square.js
export default ({ app }, inject) => {
  app.$square = (x) => x * x
}
  1. 将函数文件路径添加到 Nuxt 配置文件的plugins选项中:
// nuxt.config.js
export default {
  plugins: ['~/plugins/ctx-injections/square']
}
  1. 在任何你喜欢的页面上使用这个函数,只要你可以访问到上下文,例如在asyncData方法中:
// pages/ctx-injections.vue
<p>{{ square }}</p>

export default {
  asyncData (context) {
    return {
      square: context.app.$square(5)
    }
  }
}
  1. 在浏览器上运行页面,你应该在屏幕上得到以下输出(即使刷新页面):
25

请注意,asyncData总是在页面组件初始化之前调用,你不能在这个方法中访问this。因此,你不能在asyncData方法中使用你注入到 Vue 实例($root)中的函数,比如我们在前面例子中创建的$sum函数(我们将在第八章中更详细地了解asyncData)。同样,我们也不能在 Vue 的生命周期钩子/方法(例如mountedupdated等)中调用上下文注入的函数,比如这个例子中的$square函数。但是,如果你想要一个可以从thiscontext中使用的函数,让我们看看如何通过在下一节中将这种函数注入到 Vue 实例和 Nuxt 上下文中来实现。

将函数注入到 Vue 实例和 Nuxt 上下文中

在这个例子中,我们将创建一个用于计算两个数字之积的函数,例如,2 * 3 = 6。我们将通过以下步骤将这个函数注入到 Vue 实例和 Nuxt 上下文中:

  1. 创建一个.js文件,并使用inject函数封装您的函数:
// plugins/combined-injections/multiply.js
export default ({ app }, inject) => {
  inject('multiply', (x, y) => x  y)
}

请注意,$会自动添加到您的函数前缀,因此您不必担心将其添加到您的函数中。

  1. 将函数文件路径添加到 Nuxt 配置文件的plugins属性中:
// nuxt.config.js
export default {
  plugins: ['~/plugins/combined-injections/multiply']
}
  1. 在任何您可以访问contextthis(Vue 实例)的页面上使用该函数,例如以下示例:
// pages/combined-injections.vue
<p>{{ this.$multiply(4, 3) }}</p>
<p>{{ multiply }}</p>

export default {
  asyncData (context) {
    return { multiply: context.app.$multiply(2, 3) }
  }
}
  1. 在浏览器上运行页面,您应该在屏幕上得到以下输出(即使在刷新页面时也是如此):
12
6

您可以在任何 Vue 生命周期钩子中使用该函数,例如以下示例:

mounted () {
  console.log(this.$multiply(5, 3))
}

您应该在浏览器控制台上得到15的输出。此外,您还可以从Vuex storeactionsmutations对象/属性中访问该函数,我们将在第十章中介绍添加一个 Vuex Store

  1. 创建一个.js文件,并将以下函数封装在actionsmutations对象中:
// store/index.js
export const state = () => ({
  xNumber: 1,
  yNumber: 3
})

export const mutations = {
  changeNumbers (state, newValue) {
    state.xNumber = this.$multiply(3, 8)
    state.yNumber = newValue
  }
}

export const actions = {
  setNumbers ({ commit }) {
    const newValue = this.$multiply(9, 6)
    commit('changeNumbers', newValue)
  }
}
  1. 在任何您喜欢的页面上使用前面的存储action方法,例如以下示例:
// pages/combined-injections.vue
<p>{{ $store.state }}</p>
<button class="button" v-on:click="updateStore">Update Store</button>

export default {
  methods: {
    updateStore () {
      this.$store.dispatch('setNumbers')
    }
  }
}
  1. 在浏览器上运行页面,您应该在屏幕上得到以下输出(即使在刷新页面时也是如此):
{ "xNumber": 1, "yNumber": 3 }
  1. 单击“更新存储”按钮,前面的数字将更改为存储默认状态如下:
{ "xNumber": 24, "yNumber": 54 }

这很棒。通过这种方式,我们可以编写一个在客户端和服务器端都能工作的插件。但有时,我们需要能够在服务器端或客户端独占地使用的函数。为了做到这一点,我们必须指示 Nuxt 如何专门运行我们的函数。让我们在下一节中找出如何做到这一点。

注入仅客户端或仅服务器端的插件

在这个例子中,我们将创建一个用于除法的函数,例如,8 / 2 = 4,以及另一个用于减法的函数,例如,8 - 2 = 6。我们将将第一个函数注入到 Vue 实例中,并使其专门用于客户端使用,而将第二个函数注入到 Nuxt 上下文中,并使其专门用于服务器端使用。

  1. 创建两个函数,并将它们分别附加.client.js.server.js
// plugins/name-conventions/divide.client.js
import Vue from 'vue'
Vue.prototype.$divide = (x, y) => x / y

// plugins/name-conventions/subtract.server.js
export default ({ app }, inject) => {
  inject('subtract', (x, y) => x - y)
}

附加.client.js的函数文件将仅在客户端运行,而附加.server.js的函数文件将仅在服务器端运行。

  1. 将函数文件路径添加到 Nuxt 配置文件的plugins属性中:
// nuxt.config.js:
export default {
  plugins: [
    '~/plugins/name-conventions/divide.client.js',
    '~/plugins/name-conventions/subtract.server.js'
  ]
}
  1. 在任何你喜欢的页面上使用这些插件,比如以下示例:
// pages/name-conventions.vue
<p>{{ divide }}</p>
<p>{{ subtract }}</p>

export default {
  data () {
    let divide = ''
    if (process.client) {
      divide = this.$divide(8, 2)
    }
    return { divide }
  },
  asyncData (context) {
    let subtract = ''
    if (process.server) {
      subtract = context.app.$subtract(10, 4)
    }
    return { subtract }
  }
}
  1. 在浏览器上运行页面,你应该在屏幕上得到以下输出:
4
6

请注意,当你在浏览器上首次运行页面时,你将得到前面的结果,即使在刷新页面时也是如此。但是在第一次加载后,如果你通过<nuxt-link>导航到这个页面,你将在屏幕上得到以下输出:

4

另外,请注意我们必须将$divide方法包裹在process.client的 if 条件中,因为它是一个只在客户端执行的函数。如果你移除process.client的 if 条件,你将在浏览器中得到一个服务器端错误:

this.$divide is not a function

对于$subtract方法也是一样的:我们必须将其包裹在process.server的 if 条件中,因为它是一个只在服务器端执行的函数。如果你移除process.server的 if 条件,你将在浏览器上得到一个客户端错误:

this.$subtract is not a function

将函数包裹在process.server中可能不是理想的做法

process.client的 if 条件每次使用时都会被阻塞。但是在仅在客户端被调用的 Vue 生命周期钩子/方法上,比如mounted钩子,你不需要使用process.client的 if 条件。因此,你可以在不使用 if 条件的情况下安全地使用你的仅客户端函数,就像以下示例中一样:

mounted () {
  console.log(this.$divide(8, 2))
}

你将在浏览器控制台中得到4的输出。下表显示了八个 Vue 生命周期钩子/方法,值得知道的是在 Nuxt 应用中只有其中两个会在两端被调用:

服务器和客户端 仅客户端

|

  • beforeCreate ()

  • created ()

|

  • beforeMount ()

  • mounted ()

  • beforeUpdate ()

  • updated ()

  • beforeDestroy ()

  • destroyed ()

|

请注意,我们在 Vue 和 Nuxt 应用中一直在使用的data方法会在两端被调用,就像asyncData方法一样。因此,你可以在仅客户端列表下的钩子中使用$divide方法,它是专门为客户端使用而制作的,而不需要 if 条件。而对于$subtract方法,它是专门为仅在服务器端使用而制作的,你可以在nuxtServerInit动作中安全地使用它,就像以下示例中一样:

export const actions = {
  nuxtServerInit ({ commit }, context) {
    console.log(context.app.$subtract(10, 4))
  }
}

当您的应用在服务器端运行时,即使刷新页面(任何页面),您将得到6的输出。值得知道的是,只能通过这些方法访问 Nuxt 上下文:nuxtServerInitasyncDatanuxtServerInit操作可以作为第二个参数访问上下文,而asyncData方法可以作为第一个参数访问上下文。我们将在第十章中介绍nuxtServerInit操作,添加一个 Vuex Store,但是,现在在下一节中,我们将看一下在nuxtServerInit操作之后,但在 Vue 实例和插件之前以及在$root和 Nuxt 上下文注入的函数之前注入到 Nuxt 上下文中的 JavaScript 函数。这种类型的函数称为 Nuxt 模块,通过本章末尾,您将知道如何编写这些模块。让我们开始吧。

编写 Nuxt 模块

模块只是一个顶级的 JavaScript 函数,在 Nuxt 启动时执行。Nuxt 会按顺序调用每个模块,并在继续调用 Vue 实例、Vue 插件和要注入到$root和 Nuxt 上下文中的全局函数之前等待所有模块完成。因为模块在它们之前被调用(即 Vue 实例等),我们可以使用模块来覆盖模板、配置 webpack 加载器、添加 CSS 库以及执行其他应用所需的任务。此外,模块也可以打包成 npm 包并与 Nuxt 社区共享。您可以查看以下链接,了解由 Nuxt 社区制作的生产就绪模块:

github.com/nuxt-community/awesome-nuxt#official

让我们试试 Axios 模块,这是一个与 Axios 集成的模块(github.com/axios/axios)用于 Nuxt。它具有一些功能,例如自动设置客户端和服务器端的基本 URL。我们将在接下来的章节中发现它的一些特性。如果您想了解更多关于这个模块的信息,请访问axios.nuxtjs.org/。现在,让我们看看如何在以下步骤中使用这个模块:

  1. 使用 npm 安装它:
$ npm install @nuxtjs/axios
  1. 在 Nuxt 配置文件中进行配置:
// nuxt.config.js
module.exports = {
  modules: [
    '@nuxtjs/axios'
  ]
}
  1. 在任何地方使用,例如在页面的asyncData方法中:
// pages/index.vue
async asyncData ({ $axios }) {
  const ip = await $axios.$get('http://icanhazip.com')
  console.log(ip)
}

您还可以在mounted方法(或createdupdated等)中使用它,如下所示:

// pages/index.vue
async mounted () {
  const ip = await this.$axios.$get('http://icanhazip.com')
  console.log(ip)
}

每次导航到/about页面时,您应该在浏览器控制台上看到您的 IP 地址。您应该注意到现在您可以像使用原始 Axios 一样发送 HTTP 请求,而无需在需要时导入它,因为它现在通过模块全局注入。很棒,不是吗?接下来,我们将通过从基本模块开始编写您的模块来指导您。

编写基本模块

正如我们已经提到的,模块是函数,它们可以选择地打包为 npm 模块。这是您创建模块所需的非常基本的结构:

// modules/basic.js
export default function (moduleOptions) {
  // ....
}

您只需在项目根目录中创建一个/modules/目录,然后开始编写您的模块代码。请注意,如果您想将模块发布为 npm 包,必须包含以下行:

module.exports.meta = require('./package.json')

如果您想创建模块并将其发布为 npm 包,请按照 Nuxt 社区的此模板:

github.com/nuxt-community/module-template/tree/master/template

无论您是为 Nuxt 社区还是仅为自己的项目创建模块,每个模块都可以访问以下内容:

  • 模块选项:

我们可以从配置文件中向模块传递 JavaScript 对象中的一些选项,例如:

// nuxt.config.js
export default {
  modules: [
    ['~/modules/basic/module', { language: 'ES' }],
  ]
}

然后,您可以在模块函数的第一个参数中将前述选项作为moduleOptions访问,如下所示:

// modules/basic/module.js
export default function (moduleOptions) {
  console.log(moduleOptions)
}

您将获得从配置文件中传递的以下选项:

{
  language: 'ES'
}
  • 配置选项:

我们还可以创建一个自定义选项(例如tokenproxybasic),并将一些特定选项传递给模块(这个自定义选项可以在模块之间共享使用),如下例所示:

// nuxt.config.js
export default {
  modules: [
    ['~/modules/basic/module'],
  ],
  basic: { // custom option
    option1: false,
    option2: true,
  }
}

然后,您可以使用this.options访问前述自定义选项,如下所示:

// modules/basic/module.js
export default function (moduleOptions) {
  console.log(this.options['basic'])
}

您将获得从配置文件中传递的以下选项:

{
  option1: false,
  option2: true
}

然后我们可以将moduleOptionsthis.options组合如下:

// modules/basic/module.js
export default function (moduleOptions) {
  const options = {
    ...this.options['basic'],
    ...moduleOptions
  }
  console.log(options)
}

您将获得从配置文件中传递的以下组合选项:

{
  option1: false,
  option2: true
}
  • Nuxt 实例:

您可以使用this.nuxt来访问 Nuxt 实例。请访问以下链接以获取可用方法(例如hook方法,我们可以使用它在 Nuxt 启动时创建特定事件的某些任务):

nuxtjs.org/api/internals-nuxt

  • ModuleContainer实例:

您可以使用 this 来访问 ModuleContainer 实例。请访问以下链接以获取可用方法(例如,addPlugin 方法,我们在模块中经常使用它来注册插件):

nuxtjs.org/api/internals-module-container

  • module.exports.meta 代码行:

如果您将您的模块发布为 npm 包,则此行是必需的,正如我们之前提到的。但在本书中,我们将指导您完成为您的项目创建模块的步骤。让我们通过以下步骤开始创建一个非常基本的模块:

  1. 创建一个带有以下代码的 module 文件:
// modules/basic/module.js
const { resolve } = require('path')

export default function (moduleOptions) {
  const options = {
    ...this.options['basic'],
    ...moduleOptions
  }

  // Add plugin.
  this.addPlugin({
    src: resolve(__dirname, 'plugin.js'),
    fileName: 'basic.js',
    options
  })
}
  1. 创建一个带有以下代码的 plugin 文件:
// modules/basic/plugin.js
var options = []

<% if (options.option1 === true) { %>
  options.push('option 1')
<% } %>

<% if (options.option2 === true) { %>
  options.push('option 2')
<% } %>

<% if (options.language === 'ES') { %>
  options.push('language ES')
<% } %>

const basic = function () {
  return options
}

export default ({ app }, inject) => {
  inject('basic', basic)
}

请注意,<%= %> 符号是 Lodash 用于在 template 函数中插入数据属性的插值分隔符。我们稍后将在本章再次介绍它们。有关 Lodash template 函数的更多信息,请访问 lodash.com/docs/4.17.15#template

  1. 仅在 Nuxt 配置文件中包含模块文件路径(/modules/basic/module.js),并提供一些选项,如下所示使用 basic 自定义选项:
// nuxt.config.js
export default {
  modules: [
    ['~/modules/basic/module', { language: 'ES' }],
  ],

  basic: {
    option1: false,
    option2: true,
  }
}
  1. 您可以在任何地方使用它,例如:
// pages/index.vue
mounted () {
  const basic = this.$basic()
  console.log(basic)
}
  1. 每次访问主页时,您应该在浏览器控制台上看到以下输出:
["option 2", "language ES"]

请注意 module.js 如何处理高级配置细节,例如语言和选项。它还负责注册 plugin.js 文件,该文件执行实际工作。正如您所看到的,该模块是围绕插件的一个包装器。我们将在接下来的章节中更详细地学习这一点。

请注意,如果您只为构建时间和开发编写模块,则应在 Nuxt 配置文件中使用 buildModules 选项来注册您的模块,而不是在 Node.js 运行时使用 modules 选项。有关此选项的更多信息,请访问 nuxtjs.org/guide/modules#build-only-modulesnuxtjs.org/api/configuration-modules

编写异步 Nuxt 模块

如果您需要在模块中使用 Promise 对象,例如,使用 HTTP 客户端从远程 API 获取一些异步数据,那么 Nuxt 可以完美支持。以下是一些选项,您可以使用这些选项编写您的 async 模块。

使用 async/await

您可以在您的模块中使用 ES6 的 async/await 与 Axios,这是我们自第四章以来一直在使用的 HTTP 客户端,例如以下示例中:

// modules/async-await/module.js
import axios from 'axios'

export default async function () {
  let { data } = await axios.get(
   'https://jsonplaceholder.typicode.com/posts')
  let routes = data.map(post => '/posts/' + post.id)
  console.log(routes)
}

// nuxt.config.js
modules: [
  ['~/modules/async-await/module']
]

在前面的例子中,我们使用 Axios 的get方法从远程 API JSONPlaceholder(jsonplaceholder.typicode.com/)获取所有帖子。当您第一次启动 Nuxt 应用程序时,您应该在终端上看到以下输出:

[
  '/posts/1',
  '/posts/2',
  '/posts/3',
  ...
]

返回一个 Promise

您可以在您的模块中使用 promise 链并返回Promise对象,就像以下示例中一样:

// modules/promise-sample/module.js
import axios from 'axios'

export default function () {
  return axios.get('https://jsonplaceholder.typicode.com/comments')
    .then(res => res.data.map(comment => '/comments/' + comment.id))
    .then(routes => {
      console.log(routes)
    })
}

// nuxt.config.js
modules: [
  ['~/modules/promise-sample/module']
]

在这个例子中,我们使用 Axios 的get方法从远程 API 获取所有评论。然后我们使用then方法来Promise 并打印结果。当您第一次启动 Nuxt 应用程序时,您应该在终端上看到以下输出:

[
  '/comments/1',
  '/comments/2',
  '/comments/3',
  ...
]

您可以在我们的 GitHub 存储库的/chapter-6/nuxt-universal/modules/async/中找到这两个示例。

有了这两个异步选项和您从前面部分学到的基本模块编写技能,您可以轻松开始创建您的 Nuxt 模块。我们将在下一节中通过编写模块的小片段来查看更多示例 - 片段

编写 Nuxt 模块片段

在这个主题中,我们将把我们创建的自定义模块分解成小片段。

您可以在我们的 GitHub 存储库的/chapter-6/nuxt-universal/module-snippets/中找到所有以下代码。

使用顶级选项

记住我们在编写基本模块部分中说过的可以传递到模块中的配置选项吗?模块选项是在 Nuxt 配置文件中注册我们的模块的顶级选项。我们甚至可以结合来自不同模块的多个选项,并且它们的选项可以共享。让我们尝试在以下步骤中一起使用@nuxtjs/axios@nuxtjs/proxy的示例:

  1. 使用 npm 一起安装这两个模块:
$ npm i @nuxtjs/axios
$ npm i @nuxtjs/proxy

这两个模块被很好地集成在一起,以防止 CORS 问题,我们将在本书的后面看到并讨论跨域应用程序的开发。不需要手动注册@nuxtjs/proxy模块,但它确实需要在您的package.json文件的依赖项中。

  1. 在 Nuxt 配置文件中注册@nuxtjs/axios模块并设置这两个模块的顶级选项:
// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/axios'
  ],
  axios: {
    proxy: true
  },
  proxy: {
    '/api/': { target: 'https://jsonplaceholder.typicode.com/', 
     pathRewrite: {'^/api/': ''} },
  }
}

axios 自定义选项中的 proxy: true 选项告诉 Nuxt 使用 @nuxtjs/proxy 模块作为代理。proxy 自定义选项中的 /api/: {...} 选项告诉 @nuxtjs/axios 模块将 jsonplaceholder.typicode.com/ 作为 API 服务器的目标地址,而 pathRewrite 选项告诉 @nuxtjs/axios 模块在 HTTP 请求期间从地址中删除 /api/,因为目标 API 中没有带有 /api 的路由。

  1. 接下来,在任何组件中无缝使用它们,就像以下示例中一样:
// pages/index.vue
<template>
  <ul>
    <li v-for="user in users">
      {{ user.name }}
    </li>
  </ul>
</template>

<script>
export default {
  async asyncData({ $axios }) {
    const users = await $axios.$get('/api/users')
    return { users }
  }
}
</script>

现在,使用这两个模块,我们可以在请求方法(例如 getpost 和 put)中只写更短的 API 地址,比如 /api/users 而不是 https://jsonplaceholder.typicode.com/users。这样可以使我们的代码更整洁,因为我们不必在每次调用时都写完整的 URL。请注意,我们在 Nuxt 配置文件中配置的 /api/ 地址将被添加到对 API 端点的所有请求中。因此,我们使用 pathRewrite,如我们已经解释的那样,在发送请求时删除它。

你可以在以下链接中找到这两个模块提供的更多信息和顶层选项:

你可以在我们的 GitHub 仓库的 /chapter-6/nuxt-universal/module-snippets/top-level/ 中找到我们刚创建的示例模块片段。

使用 addPlugin 辅助函数

还记得我们在 编写基本模块 部分介绍过的 ModuleContainer 实例和 this.addPlugin 辅助方法吗?在这个示例中,我们将使用这个辅助函数创建一个模块,该模块通过这个辅助函数提供了一个插件,这个插件就是 bootstrap-vue,它将被注册到 Vue 实例中。让我们按照以下步骤创建这个模块片段:

  1. 安装 Bootstrap 和 BootstrapVue:
$ npm i bootstrap-vue
$ npm i bootstrap
  1. 创建一个插件文件来导入 vuebootstrap-vue,然后使用 use 方法注册 bootstrap-vue
// modules/bootstrap/plugin.js
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue/dist/bootstrap-vue.esm'

Vue.use(BootstrapVue)
  1. 创建一个模块文件,使用 addPlugin 方法添加我们刚创建的插件文件:
// modules/bootstrap/module.js
import path from 'path'

export default function (moduleOptions) {
  this.addPlugin(path.resolve(__dirname, 'plugin.js'))
}
  1. 在 Nuxt 配置文件中添加这个 bootstrap 模块的文件路径:
// nuxt.config.js
export default {
  modules: [
    ['~/modules/bootstrap/module']
  ]
}
  1. 在任何喜欢的组件上开始使用 bootstrap-vue;例如,让我们创建一个按钮来切换 Bootstrap 中的警报文本,如下所示:
// pages/index.vue
<b-button @click="toggle">
  {{ show ? 'Hide' : 'Show' }} Alert
</b-button>
<b-alert v-model="show">
  Hello {{ name }}!
</b-alert>

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

export default {
  data () {
    return {
      name: 'BootstrapVue',
      show: true
    }
  }
}

有了这个模块片段,我们不必每次在组件上需要时导入bootstrap-vue,因为它已经通过前面的片段模块全局添加了。我们只需要导入它的 CSS 文件。在使用示例中,我们使用 Bootstrap 的自定义<b-button>组件来切换 Bootstrap 的自定义<b-alert>组件。然后,<b-button>组件将在该按钮上切换文本“隐藏”或“显示”。

有关 BootstrapVue 的更多信息,请访问bootstrap-vue.js.org/。您可以在我们的 GitHub 存储库中的/chapter-6/nuxt-universal/module-snippets/provide-plugin/中找到我们刚刚创建的示例模块片段。

使用 Lodash 模板

再次,这是我们在编写基本模块部分创建的自定义模块中熟悉的内容-利用 Lodash 模板通过使用 if 条件块来改变注册插件的输出。再次,Lodash 模板是一段代码,我们可以用<%= %>插值分隔符插入数据属性。让我们在以下步骤中尝试另一个简单的例子:

  1. 创建一个插件文件来导入axios并添加 if 条件块,以确保为axios提供请求 URL,并在您的 Nuxt 应用程序以dev模式(npm run dev)运行时在终端上打印请求结果以进行调试:
// modules/users/plugin.js
import axios from 'axios'

let users = []
<% if (options.url) { %>
  users = axios.get('<%= options.url %>')
<% } %>

<% if (options.debug) { %>
  // Dev only code
  users.then((response) => {
    console.log(response);
  })
  .catch((error) => {
    console.log(error);
  })
<% } %>

export default ({ app }, inject) => {
  inject('getUsers', async () => {
    return users
  })
}
  1. 创建一个module文件,使用addPlugin方法添加我们刚刚创建的插件文件,使用options选项传递请求 URL 和this.options.dev的布尔值给这个插件:
// modules/users/module.js
import path from 'path'

export default function (moduleOptions) {
  this.addPlugin({
    src: path.resolve(__dirname, 'plugin.js'),
    options: {
      url: 'https://jsonplaceholder.typicode.com/users',
      debug: this.options.dev
    }
  })
}
  1. 将此模块的文件路径添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
  modules: [
      ['~/modules/users/module']
    ]
}
  1. 在任何您喜欢的组件上开始使用$getUsers方法,就像以下示例中一样:
// pages/index.vue
<li v-for="user in users">
  {{ user.name }}
</li>

export default {
  async asyncData({ app }) {
    const { data: users } = await app.$getUsers()
    return { users }
  }
}

在上面的示例中,Nuxt 将在将插件复制到项目时将options.url替换为https://jsonplaceholder.typicode.com/usersoptions.debug的 if 条件块将在生产构建时从插件代码中剥离,因此您在生产模式(npm run buildnpm run start)中将看不到终端上的console.log输出。

您可以在我们的 GitHub 存储库中的/chapter-6/nuxt-universal/module-snippets/template-plugin/中找到我们刚刚创建的示例模块片段。

添加 CSS 库

使用 addPlugin 助手部分的模块片段示例中,我们创建了一个模块,允许我们在应用程序中全局使用bootstrap-vue插件,而无需使用import语句来引入此插件,如下例所示:

// pages/index.vue
<b-button size="sm" @click="toggle">
  {{ show ? 'Hide' : 'Show' }} Alert
</b-button>

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
export default {
  //...
}

这看起来非常整洁,因为我们不必每次都导入bootstrap-vue,而只需导入 CSS 样式即可。但是,通过模块,我们仍然可以节省几行代码,将样式添加到应用程序的全局 CSS 堆栈中。让我们创建一个新的示例,并看看我们如何在以下步骤中执行该操作:

  1. 创建一个模块文件,其中包含一个名为optionsconst变量,用于将模块和顶层选项传递给插件文件,以及一个 if 条件块,用于确定是否使用原始 JavaScript 的push方法将 CSS 文件推送到 Nuxt 配置文件中的css选项中:
// modules/bootstrap/module.js
import path from 'path'
export default function (moduleOptions) {
  const options = Object.assign({}, this.options.bootstrap, 
    moduleOptions)

  if (options.styles !== false) {
    this.options.css.push('bootstrap/dist/css/bootstrap.css')
    this.options.css.push('bootstrap-vue/dist/bootstrap-vue.css')
  }

  this.addPlugin({
    src: path.resolve(__dirname, 'plugin.js'),
    options
  })
}
  1. 创建一个插件文件,其中注册了bootstrap-vue插件,并使用 if 条件 Lodash-template 块打印从模块文件处理的选项:
// modules/bootstrap/plugin.js
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue/dist/bootstrap-vue.esm'

Vue.use(BootstrapVue)

<% if (options.debug) { %>
  <% console.log (options) %>
<% } %>
  1. 将模块文件的文件路径添加到 Nuxt 配置文件中,模块选项指定是否在模块文件中禁用 CSS 文件。还要添加顶层选项bootstrap,以将布尔值传递给debug选项:
// nuxt.config.js
export default {
  modules: [
    ['~/modules/bootstrap/module', { styles: true }]
  ],

  bootstrap: {
    debug: process.env.NODE_ENV === 'development' ? true : false
  }
}
  1. 从我们的组件中删除 CSS 文件:
// pages/index.vue
<script>
- import 'bootstrap/dist/css/bootstrap.css'
- import 'bootstrap-vue/dist/bootstrap-vue.css'
export default {
  //...
}
</script>

因此,最终,我们可以在组件中使用bootstrap-vue插件及其 CSS 文件,而无需全部导入它们。以下是将 Font Awesome 的css选项快速推送到 Nuxt 配置文件的模块片段的另一个示例:

// modules/bootstrap/module.js
export default function (moduleOptions) {
  if (moduleOptions.fontAwesome !== false) {
    this.options.css.push('font-awesome/css/font-awesome.css')
  }
}

如果您想了解有关 Font Awesome 的更多信息,请访问fontawesome.com/

您可以在我们的 GitHub 存储库的/chapter-6/nuxt-universal/module-snippets/css-lib/中找到我们刚刚创建的示例模块片段。

注册自定义 webpack 加载器

当我们想要在 Nuxt 中扩展 webpack 配置时,通常会在nuxt.config.js中使用build.extend来完成。但是,我们也可以通过使用this.extendBuild和以下模块/加载器模板来通过模块执行相同的操作:

export default function (moduleOptions) {
  this.extendBuild((config, { isClient, isServer }) => {
    //...
  })
}

例如,假设我们想要使用svg-transform-loader扩展我们的 webpack 配置,这是一个用于添加或修改 SVG 图像中标记和属性的 webpack 加载器。该加载器的主要目的是允许我们在 SVG 图像上使用fillstroke和其他操作。我们还可以在 CSS、Sass、Less、Stylus 或 PostCSS 中使用它;例如,如果您想要用白色填充 SVG 图像,可以使用fillfff(CSS 颜色白色代码)添加到图像中,如下所示:

.img {
  background-image: url('./img.svg?fill=fff');
}

如果您想要在 Sass 中使用变量stroke SVG 图像,可以这样做:

$stroke-color: fff;

.img {
  background-image: url('./img.svg?stroke={$stroke-color}');
}

让我们创建一个示例模块,将此加载器注册到 Nuxt webpack 默认配置中,以便我们可以通过以下步骤在 Nuxt 应用程序中操作 SVG 图像:

  1. 使用 npm 安装加载器:
$ npm i svg-transform-loader
  1. 使用我们之前提供的模块/加载器模板创建一个模块文件,如下所示:
// modules/svg-transform-loader/module.js
export default function (moduleOptions) {
  this.extendBuild((config, { isClient, isServer }) => {
    //...
  })
}
  1. this.extendBuild的回调函数中,添加以下行以查找文件加载器并从其现有规则测试中删除svg
const rule = config.module.rules.find(
  r => r.test.toString() === '/\\.(png|jpe?g|gif|svg|webp)$/i'
)
rule.test = /\.(png|jpe?g|gif|webp)$/i
  1. 在前面的代码块之后添加以下代码块,将svg-transform-loader加载器推入默认 webpack 配置的模块规则:
config.module.rules.push({
  test: /\.svg(\?.)?$/, // match img.svg and img.svg?param=value
  use: [
    'url-loader',
    'svg-transform-loader'
  ]
})

模块现在已经完成,我们可以继续步骤 5

  1. 将此模块的文件路径添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
  modules: [
    ['~/modules/svg-transform-loader/module']
  ]
}
  1. 开始转换我们组件中的任何 SVG 图像,例如以下内容:
// pages/index.vue
<template>
  <div>
    <div class="background"></div>
    <img src="~/assets/bug.svg?stroke=red&stroke-
     width=4&fill=blue">
  </div>
</template>

<style lang="less">
.background {
   height: 100px;
   width: 100px;
   border: 4px solid red;
   background-image: url('~assets/bug.svg?stroke=red&stroke-
    width=2');
}
</style>

您可以在www.npmjs.com/package/svg-transform-loader找到有关svg-transform-loader的更多信息。如果您想了解有关规则测试的更多信息,并查看 Nuxt 默认 webpack 配置的完整内容,请访问以下链接:

您可以在我们的 GitHub 存储库中的/chapter-6/nuxt-universal/module-snippets/webpack-loader/中找到我们刚刚创建的示例模块片段。

注册自定义 webpack 插件

Nuxt 模块不仅允许我们注册 webpack 加载器,还允许我们使用以下模块/插件架构注册 webpack 插件:this.options.build.plugins.push

export default function (moduleOptions) {
  this.options.build.plugins.push({
    apply(compiler) {
      compiler.hooks.<hookType>.<tap>('<PluginName>', (param) => {
        //...
      })
    }
  })
}

<tap>取决于挂钩类型;它可以是tapAsynctapPromisetap。让我们按照以下步骤通过 Nuxt 模块创建一个非常简单的“Hello World”webpack 插件:

  1. 使用我们提供的模块/插件架构创建一个模块文件,以打印“Hello World!”,如下所示:
// modules/hello-world/module.js
export default function (moduleOptions) {
  this.options.build.plugins.push({
    apply(compiler) {
      compiler.hooks.done.tap('HelloWordPlugin', (stats) => {
        console.log('Hello World!')
      })
    }
  })
}

请注意,在done挂钩被触发时,stats(统计信息)被传递为参数。

  1. 将此模块的文件路径添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
 modules: [
 ['~/modules/hello-world/module']
}
  1. 使用$ npm run dev运行你的 Nuxt 应用程序,你应该在终端上看到“Hello World!”。

请注意,apply方法,compilerhookstap都是构建 webpack 插件的关键部分。

如果你是新手 webpack 插件开发者,并想了解更多关于如何为 webpack 开发插件,请访问webpack.js.org/contribute/writing-a-plugin/

你可以在我们的 GitHub 存储库中的/chapter-6/nuxt-universal/module-snippets/webpack-plugin/中找到我们刚刚创建的示例模块片段。

在特定挂钩上创建任务

如果你需要在 Nuxt 启动时对特定生命周期事件(例如,当所有模块加载完成时)执行某些任务,你可以创建一个模块,并使用hook方法来监听该事件,然后执行任务。请考虑以下示例:

  • 如果你想在所有模块加载完成后做一些事情,请尝试以下操作:
export default function (moduleOptions) {
  this.nuxt.hook('modules:done', moduleContainer => {
    //...
  })
}
  • 如果你想在渲染器创建后做一些事情,请尝试以下操作:
export default function (moduleOptions) {
  this.nuxt.hook('render:before', renderer => {
    //...
  })
}
  • 如果你想在编译器(webpack 是默认值)启动之前做一些事情,请尝试以下操作:
export default function (moduleOptions) {
  this.nuxt.hook('build:compile', async ({ name, compiler }) => {
    //...
  })
}
  • 如果你想在 Nuxt 生成页面之前做一些事情,请尝试以下操作:
export default function (moduleOptions) {
  this.nuxt.hook('generate:before', async generator => {
    //...
  })
}
  • 如果你想在 Nuxt 准备就绪时做一些事情,请尝试以下操作:
export default function (moduleOptions) {
  this.nuxt.hook('ready', async nuxt => {
    //...
  })
}

让我们按照以下步骤创建一个简单的模块来监听modules:done挂钩/事件:

  1. 创建一个模块文件,在所有模块加载完成时打印'All modules are loaded'
// modules/tasks/module.js
export default function (moduleOptions) {
  this.nuxt.hook('modules:done', moduleContainer => {
    console.log('All modules are loaded')
  })
}
  1. 创建几个模块来打印'Module 1''Module 2''Module 3'等,如下所示:
// modules/module1.js
export default function (moduleOptions) {
  console.log('Module 1')
}
  1. 将挂钩模块的文件路径和其他模块添加到 Nuxt 配置文件中:
// nuxt.config.js
export default {
  modules: [
    ['~/modules/tasks/module'],
    ['~/modules/module3'],
    ['~/modules/module1'],
    ['~/modules/module2']
  ]
}
  1. 使用$ npm run dev运行你的 Nuxt 应用程序,你应该在终端上看到以下输出:
Module 3
Module 1
Module 2
All modules are loaded

你可以看到挂钩模块总是最后打印,而其余的根据它们在modules选项中的顺序打印。

挂钩模块可以是异步的,无论你是使用async/await函数还是返回Promise

有关上述钩子和 Nuxt 生命周期事件中的其他钩子的更多信息,请访问以下链接:

您可以在我们的 GitHub 存储库的/chapter-6/nuxt-universal/module-snippets/hooks/中找到我们刚刚创建的示例模块片段。

总结

在本章中,我们已成功涵盖了 Nuxt 中的插件和模块。您已经了解到它们在技术上是您可以为项目创建的 JavaScript 函数,或者从外部来源导入它们。此外,您已经学会了通过将它们注入到 Vue 实例或 Nuxt 上下文中(或两者都有)来为您的 Nuxt 应用创建全局函数,以及创建仅客户端和仅服务器端的函数。最后,您已经学会了通过使用addPlugin助手添加 JavaScript 库的模块片段,全局添加 CSS 库,使用 Lodash 模板有条件地更改已注册插件的输出,向 Nuxt 默认 webpack 配置添加 webpack 加载器和插件,以及使用 Nuxt 生命周期事件钩子创建任务,例如modules:done

在接下来的章节中,我们将探索 Vue 表单并将其添加到 Nuxt 应用程序中。您将了解v-model在 HTML 元素(如texttextareacheckboxradioselect)中的工作原理。您将学会如何在 Vue 应用程序中验证这些元素,绑定默认和动态数据,并使用.lazy.trim等修饰符来修改或强制输入值。您还将学会使用 Vue 插件vee-validate对它们进行验证,然后将其应用到 Nuxt 应用程序中。我们将引导您顺利地完成所有这些领域。敬请关注。

添加 Vue 表单

在本章中,您将使用v-modelv-bind创建表单。您将学习在将表单数据发送到服务器之前在客户端验证表单。您将创建具有基本元素的表单,绑定动态值,并使用修饰符修改输入元素的行为。您还将学习如何使用vee-validate插件验证表单并将其应用于 Nuxt 应用程序。在本章中学习如何在 Vue 表单中使用v-modelv-bind非常重要,因为我们将在接下来的章节中使用表单,例如在“添加 Vuex 存储”第十章和“创建用户登录和 API 身份验证”第十二章中。

本章中我们将涵盖以下主题:

  • 理解v-model

  • 使用基本数据绑定验证表单

  • 创建动态值绑定

  • 使用vee-validate验证表单

  • 在 Nuxt 中应用 Vue 表单

第七章:理解v-model

v-model是 Vue 指令(自定义内置 Vue HTML 属性),允许我们在表单的inputtextareaselect元素上创建双向绑定。您可以将表单输入与 Vue 数据绑定,以便在用户与输入字段交互时更新数据。v-model始终会跳过您在表单元素上设置的初始值,而将 Vue 数据视为真相的来源。因此,您应该在 Vue 端,在data选项或函数内声明初始值。

v-model将根据输入类型选择适当的方式来更新元素,这意味着如果您在type="text"的表单输入上使用它,它将使用value作为属性,并使用input作为事件来执行双向绑定。让我们看看在接下来的部分中包括哪些内容。

在文本和文本区域元素中使用 v-model

记得我们在《添加 Vue 组件》的第五章中使用v-model实现双向绑定来创建自定义输入组件吗?在该章节的“创建自定义输入组件”部分,我们学到了输入框的v-model语法 - <input v-model="username"> - 实际上是以下内容的简写:

<input
  v-bind:value="username"
  v-on:input="username = $event.target.value"
>

这个文本input元素在幕后绑定了value属性,该属性从处理程序username中获取值,而username又从input事件中获取值。因此,自定义的文本输入组件也必须始终在model属性中使用value属性和input事件,如下所示:

Vue.component('custom-input', {
  props: {
    value: String
  },
  model: {
    prop: 'value',
    event: 'input'
  },
  template: `<input v-on:input="$emit('input', $event.target.value)">`,
})

这仅仅是因为v-model输入的性质是由v-bind:valuev-on:input组成。当在textarea元素中使用v-model指令时,情况也是一样的,如下例所示:

<textarea v-model="message"></textarea>

这个v-model textarea元素是以下内容的简写:

<textarea
  v-bind:value="message"
  v-on:input="message = $event.target.value"
></textarea>

这个textarea输入元素在幕后绑定了value属性,该属性从处理程序message中获取值,而message又从input事件中获取值。因此,自定义的textarea组件也必须始终遵守v-model textarea元素的性质,通过使用value属性和input事件在model属性中,如下所示:

Vue.component('custom-textarea', {
  props: {
    value: null
  },
  model: {
    prop: 'value',
    event: 'input'
  }
})

简而言之,v-model文本input元素和v-model textarea输入元素始终将value属性与处理程序绑定,以在输入事件上获取新值,因此自定义输入组件也必须采用相同的属性和事件。那么复选框和单选按钮元素中的v-model又是怎样的呢?让我们在下一节中深入了解它们。

在复选框和单选按钮元素中使用 v-model

另一方面,v-model复选框和单选按钮输入元素始终将checked属性与在change事件上更新的布尔值绑定,如下例所示:

<input type="checkbox" v-model="subscribe" value="yes" name="subscribe">

在上面的代码片段中,v-model checkbox输入元素确实是以下内容的简写:

<input
  type="checkbox"
  name="subscribe"
  value="yes"
  v-bind:checked="false"
  v-on:change="subscribe = $event.target.checked"
>

因此,自定义的复选框输入元素也必须始终遵守v-model复选框输入元素的性质(如前面的代码块中所示),通过在model属性中采用checked属性和change事件,如下所示:

Vue.component('custom-checkbox', {
  props: {
    checked: Boolean,
  },
  model: {
    prop: 'checked',
    event: 'change'
  }
})

同样适用于v-model单选按钮输入元素,如下所示:

<input type="radio" v-model="answer" value="yes" name="answer">

前面的v-model元素是以下内容的另一种简写:

<input
  type="radio"
  name="answer"
  value="yes"
  v-bind:checked="answer == 'yes'"
  v-on:change="answer = $event.target.value"
>

因此,自定义的单选按钮输入元素也必须始终遵守v-model元素的性质,如下所示:

Vue.component('custom-radio', {
  props: {
    checked: String,
    value: String
  },
  model: {
    prop: 'checked',
    event: 'change'
  }
})

简而言之,v-modelcheckboxradio 按钮输入元素总是绑定 value 属性,并在 change 事件上更新,因此自定义输入组件也必须采用相同的属性和事件。现在,让我们看看 v-model 在下一节中如何在 select 元素中工作。

在选择元素中使用 v-model

毫不奇怪,v-model select 输入元素总是将 value 属性与在 change 事件上获取其选定值的处理程序绑定,如下例所示:

<select
  v-model="favourite"
  name="favourite"
>
  //...
</select>

前面的 v-model checkbox 输入元素只是以下内容的另一种简写:

<select
  v-bind:value="favourite"
  v-on:change="favourite = $event.target.value"
  name="favourite"
>
  //...
</select>

因此,自定义的 checkbox 输入元素也必须始终遵守 v-model 元素的特性,使用 value 属性和 model 属性中的 change 事件,如下所示:

Vue.component('custom-select', {
  props: {
    value: String
  },
  model: {
    prop: 'value',
    event: 'change'
  }
})

正如你所看到的,v-modelv-bind 的基础上是一种语法糖,它将一个值绑定到标记上,并在用户输入事件上更新数据,这些事件可以是 changeinput 事件。简而言之,v-model 在幕后结合了 v-bindv-on,但重要的是要理解语法下面的内容,作为 Vue/Nuxt 应用程序开发者。

你可以在我们的 GitHub 存储库的/chapter-7/vue/html/目录中找到本节中涵盖的示例。

现在你已经了解了 v-model 指令在表单输入元素中的工作方式,让我们在下一节中在表单上使用这些 v-model 元素并对其进行验证。

使用基本数据绑定验证表单

表单是收集信息的文件。HTML <form> 元素是一个可以从网页用户那里收集数据或信息的表单。这个元素需要在其中使用 <input> 元素来指定我们想要收集的数据。但在接受数据之前,我们通常会希望对其进行验证和过滤,以便从用户那里获得真实和正确的数据。

Vue 允许我们轻松地从 v-model 输入元素中验证数据,因此让我们从单文件组件(SFC)Vue 应用程序和 webpack 开始,你可以在第五章中了解到,添加 Vue 组件,在使用 webpack 编译单文件组件部分。首先,我们将创建一个非常简单的表单,其中包括一个 submit 按钮和在 <template> 块中显示错误消息的标记,如下所示:

// src/components/basic.vue
<form v-on:submit.prevent="checkForm" action="/" method="post">
  <p v-if="errors.length">
    <b>Please correct the following error(s):</b>
    <ul>
      <li v-for="error in errors">{{ error }}</li>
    </ul>
  </p>
  <p>
    <input type="submit" value="Submit">
  </p>
</form>

稍后我们将在<form>中添加其余的输入元素。现在,让我们设置基本结构并了解我们将需要什么。我们使用v-on:submit.prevent来防止浏览器默认发送表单数据,因为我们将在 Vue 实例的<script>块中使用checkForm方法来处理提交:

// src/components/basic.vue
export default {
  data () {
    return {
      errors: [],
      form: {...}
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.errors.length) {
        this.processForm(e)
      }
    },
    processForm (e) {...}
  }
}

在 JavaScript 方面,我们定义一个数组来保存在验证过程中可能遇到的错误。checkForm逻辑验证我们稍后将在本节中添加的必填字段。如果必填字段未能通过验证,我们将错误消息推送到errors中。当表单填写正确和/或未发现错误时,它将被传递到processForm逻辑,在那里我们可以在将其发送到服务器之前对表单数据进行进一步处理。

验证文本元素

让我们开始添加一个用于单行文本的<input>元素:

// src/components/basic.vue
<label for="name">Name</label>
<input v-model="form.name" type="text">

export default {
  data () {
    return {
      form: { name: null }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.form.name) {
        this.errors.push('Name required')
      }
    }
  }
}

<script>块中,我们在data函数中定义了一个name属性,它保存初始的null值,并将在<input>元素的input事件上进行更新。当您点击submit按钮时,我们在if条件块中验证name数据;如果没有提供数据,那么我们将错误消息pusherrors中。

验证文本区域元素

我们要添加的下一个元素是<textarea>,用于多行文本,其工作方式与<input>相同:

// src/components/basic.vue
<label for="message">Message</label>
<textarea v-model="form.message"></textarea>

export default {
  data () {
    return {
      form: { message: null }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.form.message) {
        this.errors.push('Message required')
      }
    }
  }
}

<script>块中,我们在data函数中定义了一个message属性,它保存初始的null值,并将在<textarea>元素的input事件上进行更新。当您点击submit按钮时,我们在if条件块中验证message数据;如果没有提供数据,那么我们将错误消息pusherrors中。

验证复选框元素

下一个元素是一个单个复选框<input>元素,它将保存默认的布尔值:

// src/components/basic.vue
<label class="label">Subscribe</label>
<input type="checkbox" v-model="form.subscribe">

export default {
  data () {
    return {
      form: { subscribe: false }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.form.subscribe) {
        this.errors.push('Subscription required')
      }
    }
  }
}

我们还将添加以下多个复选框<input>元素,它们绑定到同一个数组books: []

// src/components/basic.vue
<input type="checkbox" v-model="form.books" value="On the Origin of Species">
<label for="On the Origin of Species">On the Origin of Species</label>

<input type="checkbox" v-model="form.books" value="A Brief History of Time">
<label for="A Brief History of Time">A Brief History of Time</label>

<input type="checkbox" v-model="form.books" value="The Selfish Gene">
<label for="The Selfish Gene">The Selfish Gene</label>

export default {
  data () {
    return {
      form: { books: [] }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (this.form.books.length === 0) {
        this.errors.push('Books required')
      }
    }
  }
}

<script>块中,我们在data函数中定义了一个subscribe属性,它保存初始的布尔值false,并将在复选框<input>元素的change事件上进行更新。当您点击submit按钮时,我们在if条件块中验证subscribe数据;如果没有提供数据或者为false,那么我们将错误消息pusherrors中。

我们通过定义一个books属性来实现多个复选框<input>元素的相同功能,它保存了初始的空数组,并将在复选框<input>元素的change事件上进行更新。我们在if条件块中验证books数据;如果长度为0,那么我们将错误消息pusherrors中。

验证单选按钮元素

接下来是绑定到相同属性名称的多个单选按钮<input>元素,即gender

// src/components/basic.vue
<label for="male">Male</label>
<input type="radio" v-model="form.gender" value="male">

<label for="female">Female</label>
<input type="radio" v-model="form.gender" value="female">

<label for="other">Other</label>
<input type="radio" v-model="form.gender" value="other">

export default {
  data () {
    return {
      form: { gender: null }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.form.gender) {
        this.errors.push('Gender required')
      }
    }
  }
}

<script>块中,我们在data函数中定义了一个gender属性,它保存了初始的null值,并将在选定的<input>单选按钮元素的change事件上进行更新。当点击submit按钮时,我们在if条件块中验证gender数据。如果没有提供数据,那么我们将错误消息pusherrors中。

验证选择元素

接下来是一个单个<select>元素,其中包含多个<option>元素,如下所示:

// src/components/basic.vue
<select v-model="form.favourite">
  <option disabled value="">Please select one</option>
  <option value="On the Origin of Species">On the Origin of 
   Species</option>
  <option value="A Brief History of Time">A Brief History of Time</option>
  <option value="The Selfish Gene">The Selfish Gene</option>
</select>

export default {
  data () {
    return {
      form: { favourite: null }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (!this.form.favourite) {
        this.errors.push('Favourite required')
      }
    }
  }
}

最后是多个绑定到相同Array的多个<option>元素的多个<select>元素,即favourites: []

// src/components/basic.vue
<select v-model="form.favourites" multiple >
  <option value="On the Origin of Species">On the Origin of 
   Species</option>
  <option value="A Brief History of Time">A Brief History of Time</option>
  <option value="The Selfish Gene">The Selfish Gene</option>
</select>

export default {
  data () {
    return {
      form: { favourites: [] }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (this.form.favourites.length === 0) {
        this.errors.push('Favourites required')
      }
    }
  }
}

<script>块中,我们在data函数中定义了一个favourites属性,它保存了初始的null值,并将在<select>元素的change事件上进行更新。当点击submit按钮时,我们在if条件块中验证favourites数据。如果没有提供数据,那么我们将错误消息pusherrors中。对于多个<select>元素,我们也是通过定义一个favourites属性来实现相同的功能,它保存了初始的空数组,并将在<select>元素的change事件上进行更新。我们在if条件块中验证favourites数据;如果长度为0,那么我们将错误消息pusherrors中。

然后我们将使用processForm逻辑完成这个表单,只有在checkForm逻辑中没有发现错误时才会调用。我们使用 Node.js 包qsthis.form对象进行字符串化,以便以以下格式将数据发送到服务器:

name=John&message=Hello%20World&subscribe=true&gender=other

让我们使用 npm 安装qs

$ npm i qs

然后我们可以按照以下方式使用它:

import axios from 'axios'
import qs from 'qs'

processForm (e) {
  var data = qs.stringify(this.form)
  axios.post('../server.php', data)
  .then((response) => {
    // success callback
  }, (response) => {
    // error callback
  })
}

我们使用axios发送数据,并从服务器获取响应(通常是 JSON 格式),然后您可以对响应数据进行操作,例如在服务器端显示“成功”或“失败”消息。

有关qs的更多信息,请访问www.npmjs.com/package/qs,有关axios,请访问github.com/axios/axios

您可以在我们的 GitHub 存储库的/chapter-7/vue/webpack/中找到前面的示例应用程序。

然而,我们还没有完全完成,因为有时我们可能希望将动态值绑定到表单输入中,而不是从v-model中获取默认值。例如,在我们的示例应用程序中,我们只使用单个复选框<input>元素获取subscribe属性的布尔值,但我们希望使用字符串值yesno。我们将在接下来的部分探讨如何更改默认值。

进行动态值绑定

在前一节的示例应用程序中,我们仅使用v-model获取radiocheckboxselect选项的字符串或布尔值。我们可以通过使用true-valuefalse-valuev-bind来更改此默认值。让我们深入了解。

用布尔值替换-复选框元素

我们可以通过使用true-valuefalse-value将我们的自定义值绑定到单个checkbox元素。例如,我们可以使用true-valueyes值绑定到替换默认的true布尔值,使用false-valueno值绑定到替换默认的false布尔值:

// src/components/dynamic-values.vue
<input
  type="checkbox"
  v-model="form.subscribe"
  true-value="yes"
  false-value="no"
>

export default {
  data () {
    return {
      form: { subscribe: 'no' }
    }
  },
  methods:{
    checkForm (e) {
      this.errors = []
      if (this.form.subscribe !== 'yes') {
        this.errors.push('Subscription required')
      }
    }
  }
}

现在,当您将subscribe输入的值发送到服务器时,您将得到yesno的响应。在<script>块中,我们现在将no声明为subscribe属性的初始值,并在if条件块中对其进行验证,以确保在点击submit按钮时它始终为yes,否则我们将错误消息推送到errors

用动态属性替换字符串-单选按钮元素

关于单选按钮<input>元素,我们可以使用v-bind将它们的值绑定到 Vue 实例中的动态属性:

// src/components/dynamic-values.vue
<input type="radio" v-model="form.gender" v-bind:value="gender.male">

export default {
  data () {
    return {
      gender: {
        male: 'm',
        female: 'f',
        other: 'o',
      },
      form: { gender: null }
    }
  }
}

现在,当选择此单选按钮时,您将得到m,验证逻辑与以前相同。

用对象替换字符串-选择选项元素

我们还可以使用v-bind非字符串值(如Object)绑定到表单输入。请参阅以下示例:

// src/components/dynamic-values.vue
<select v-model="form.favourite">
  <option v-bind:value="{ title: 'On the Origin of Species' }">On 
   the Origin of Species</option>
</select>

export default {
  data () {
    return {
      form: {
        favourite: null
      }
    }
  }
}

现在当选择此选项时,您将得到typeof this.favouriteobjectthis.favourite.titleOn the Origin of Species。验证逻辑没有变化。

我们还可以使用动态值和v-for动态呈现<option>元素:

// src/components/dynamic-values.vue
<select v-model="form.favourites" name="favourites_array[]" multiple >
  <option v-for="book in options.books" v-bind:value="book.value">
    {{ book.text }}
  </option>
</select>

data () {
  return {
    form: { favourites: [] },
    options: {
      books: [
        { value: { title: 'On the Origin of Species' }, text: 'On the 
         Origin of Species'},
        { value: { title: 'A Brief History of Time' }, text: 'A Brief 
         History of Time'},
        { value: { title: 'The Selfish Gene' }, text: 'The Selfish Gene'}
      ]
    }
  }
}

现在我们不再需要硬编码<option>元素了。我们可以从其他地方(如 API)获取books数据。

除了将动态值绑定到表单输入外,我们还可以修改输入元素上v-model的默认行为。例如,我们可以使用它们上的change事件,而不是将输入与数据同步。让我们在下一个主题中发现如何做到这一点。

使用修饰符

Vue 提供了这三个修饰符,.lazy.number.trim,我们可以与v-model一起使用,改变默认事件或为表单输入添加额外功能。让我们深入了解。

添加.lazy

我们可以使用.lazyv-model来将<input><textarea>元素上的input事件改为change事件。看下面的例子:

// src/components/modifiers.vue
<input v-model.lazy="form.name" type="text">

现在输入与数据在change之后同步,而不是默认的input事件。

添加.number

我们可以使用.numberv-model来改变<input>元素上type="number"的默认类型转换,将string转换为number。看下面的例子:

// src/components/modifiers.vue
<input v-model.number="form.age" type="number">

现在你得到typeof this.form.agenumber,而不是没有.numberstring

添加.trim

我们可以使用.trimv-model来修剪用户输入的空白。看下面的例子:

// src/components/modifiers.vue
<textarea v-model.lazy.trim="form.message"></textarea>

现在用户输入的文本会自动修剪。文本开头和结尾的任何额外空白都将被修剪掉。

虽然编写自定义验证逻辑是可能的,但已经有一个很棒的插件可以帮助轻松验证输入并显示相应的错误。这个插件叫做 VeeValidate,是一个基于 Vue 的模板验证框架。让我们在下一节中发现如何利用这个插件。

使用 VeeValidate 验证表单

使用 VeeValidate,我们将使用 VeeValidate 的组件来验证我们的 HTML 表单,并使用 Vue 的作用域插槽来暴露错误消息。例如,这是一个我们已经熟悉的v-model输入元素:

<input v-model="username" type="text" />

如果你想用 VeeValidate 验证它,你只需要用<ValidationProvider>组件包装输入:

<ValidationProvider name="message" rules="required" v-slot="{ errors }">
  <input v-model="username" name="username" type="text" />
  <span>{{ errors[0] }}</span>
</ValidationProvider>

通常情况下,我们使用<ValidationProvider>组件来验证<input>元素。我们可以使用rules属性将验证规则附加到这个组件上,并使用v-slot指令显示错误。让我们在以下步骤中发现如何利用这个插件来加快验证过程:

  1. 使用 npm 安装 VeeValidate:
$ npm i vee-validate
  1. /src/目录中创建一个.js文件,并使用 VeeValidate 的extend函数添加规则:
// src/vee-validate.js
import { extend } from 'vee-validate'
import { required } from 'vee-validate/dist/rules'

extend('required', {
  ...required,
  message: 'This field is required'
})

VeeValidate 提供了许多内置的验证规则,如requiredemailminregex等,因此我们可以导入我们应用程序所需的特定规则。因此,在上述代码中,我们导入required规则,并通过extend函数安装它,然后在message属性中添加我们的自定义消息。

  1. /src/vee-validate.js导入到初始化 Vue 实例的主入口文件中:
// src/main.js
import Vue from 'vue'
import './vee-validate'
  1. ValidationProvider组件本地导入到页面中,并开始验证该页面上的输入字段:
// src/components/vee-validation.vue
<ValidationProvider name="name" rules="required|min:3" v-slot="{ errors }">
  <input v-model.lazy="name" type="text" name="name">
  <span>{{ errors[0] }}</span>
</ValidationProvider>

import { ValidationProvider } from 'vee-validate'

export default {
  components: {
    ValidationProvider
  }
}

我们还可以在/src/main.js/src/plugins/vee-validate.js中全局注册ValidationProvider

import Vue from 'vue'
import { ValidationProvider, extend } from 'vee-validate'

Vue.component('ValidationProvider', ValidationProvider)

但是,如果您不需要在应用程序的每个页面上使用此组件,这可能不是一个好主意。因此,如果您只需要在某个页面上使用它,则将其本地导入。

  1. 本地导入ValidationObserver组件,并将passes对象添加到v-slot指令中。因此,让我们按照以下方式重构步骤 4中的 JavaScript 代码:
// src/components/vee-validation.vue
<ValidationObserver v-slot="{ passes }">
  <form v-on:submit.prevent="passes(processForm)" novalidate="true">
    //...
    <input type="submit" value="Submit">
  </form>
</ValidationObserver>

import {
  ValidationObserver,
  ValidationProvider
} from 'vee-validate'

export default {
  components: {
    ValidationObserver,
    ValidationProvider
  },
  methods:{
    processForm () {
      console.log('Posting to the server...')
    }
  }
}

我们使用<ValidationObserver>组件来包装<form>元素,以在提交之前告知其是否有效。我们还在<ValidationObserver>组件的作用域插槽对象中使用passes属性,该属性用于在表单无效时阻止提交。然后我们将我们的processForm方法传递给表单元素上的v-on:submit事件中的passes函数。如果表单无效,则不会调用我们的processForm方法。

就是这样。我们完成了。您可以看到,我们不再需要methods属性中v-on:submit事件上的checkForm方法,因为 VeeValidate 已经为我们验证了元素,并且现在我们的 JavaScript 代码变得更短了。我们只需要使用<ValidationProvider><ValidationObserver>组件包装我们的输入字段。

如果您想了解有关 Vue 插槽和 VeeValidate 的更多信息,请访问以下链接:

您可以在我们的 GitHub 存储库的/chapter-7/vue/cli/中找到我们先前的 Vue 应用程序的示例。

接下来,我们将在下一节中了解如何在 Nuxt 应用程序中应用 VeeValidate。

将自定义验证应用到 Nuxt 应用程序

让我们将自定义验证应用到我们已经有的示例网站中的联系页面。您可能已经注意到,现有的联系表单已经安装了 Foundation(Zurb)的验证。使用 Foundation 的表单验证是另一种提升我们的 HTML 表单验证的好方法。

如果您想了解更多关于 Foundation 的信息,可以在它们的官方指南中找到更多信息:foundation.zurb.com/sites/docs/abide.html

但是,如果我们想要在 Nuxt 中进行自定义验证,我们刚刚学习了在 Vue 应用程序中使用的 VeeValidate,那么让我们按照以下步骤安装和设置我们需要的内容:

  1. 通过 npm 安装 VeeValidate:
$ npm i vee-validate
  1. /plugins/目录中创建一个插件文件,并添加我们需要的规则,如下所示:
// plugins/vee-validate.js
import { extend } from 'vee-validate'
import {
  required,
  email
} from 'vee-validate/dist/rules'

extend('required', {
  ...required,
  message: 'This field is required'
})

extend('email', {
  ...email,
  message: 'This field must be a valid email'
})

这个文件中的一切都和我们在 Vue 应用程序中做的文件一样。

  1. 在 Nuxt 配置文件的plugins选项中包含插件路径:
// nuxt.config.js
plugins: [
  '~/plugins/vee-validate'
]
  1. 在 Nuxt 配置文件的build选项中为/vee-validate/dist/rules.js文件添加一个例外。
// nuxt.config.js
build: {
  transpile: [
    "vee-validate/dist/rules"
  ],
  extend(config, ctx) {}
}

在 Nuxt 中,默认情况下,/node_modules/文件夹被排除在转译之外,当使用vee-validate时,您将会收到一个错误消息Unexpected token export,因此在运行 Nuxt 应用程序之前,我们必须将/vee-validate/dist/rules.js添加到转译中。

  1. 像我们在 Vue 应用程序中所做的那样,导入ValidationObserverValidationProvider组件:
// pages/contact.vue
import {
  ValidationObserver,
  ValidationProvider
} from 'vee-validate'

export default {
  components: {
    ValidationObserver,
    ValidationProvider
  }
}
  1. <form>元素中删除 Foundation 的data-abide属性,但使用<ValidationObserver>组件将其包装起来,并将submit事件与passesprocessForm方法绑定到<form>元素,如下所示:
// pages/contact.vue
<ValidationObserver v-slot="{ passes }" ref="observer">
  <form v-on:submit.prevent="passes(processForm)" novalidate>
  //...
  </form>
</option>

这一步和我们在 Vue 应用程序中所做的步骤是一样的,但在这个例子中,我们在步骤 8中添加了ref="observer",因为我们将在后面需要它。

  1. 开始使用<ValidationProvider>组件重构<form>元素内的所有<input>元素,如下所示:
// pages/contact.vue
<ValidationProvider name="name" rules="required|min:3" v-slot="{ errors, invalid, validated }">
  <label v-bind:class="[invalid && validated ? {'is-invalid-label': 
   '{_field_}'} : '']">Name
    <input
      type="text"
      name="name"
      v-model.trim="name"
      v-bind:class="[invalid && validated ? {'is-invalid-input': 
       '{_field_}'} : '']"
    >
    <span class="form-error">{{ errors[0] }}</span>
  </label>
</ValidationProvider>

这一步和我们在 Vue 应用程序中所做的步骤是一样的,但在这个例子中,我们在v-slot指令中添加了两个作用域插槽数据属性,invalidvalidated,以便根据条件将类绑定到<label><input>元素。因此,如果invalidvalidated都为true,那么我们将分别将is-invalid-labelis-invalid-input类绑定到元素上。

有关验证提供程序的作用域插槽数据属性的更多信息,请访问vee-validate.logaretm.com/v2/guide/components/validation-provider.html#scoped-slot-data

  1. 通过向<script>块中的data函数添加以下数据属性来重构,以与v-model输入元素同步。我们还将在methods选项中添加两个方法,如下所示:
// pages/contact.vue
export default {
  data () {
    return {
      name: null,
      email: null,
      subject: null,
      message: null
    }
  },
  methods:{
    clear () {
      this.name = null
      this.email = null
      this.subject = null
      this.message = null
    },
    processForm (event) {
      alert('Processing!')
      console.log('Posting to the server...')
      this.clear()
      this.$refs.observer.reset()
    }
  }
}

这一步与我们在 Vue 应用中所做的相同,但在这个例子中,我们在methods选项中的processForm中添加了clear方法和reset方法。<ValidationObserver>组件在提交后不会重置表单的状态,因此我们必须手动进行,通过在步骤 6中将观察者作为引用传递,然后我们可以通过this.$refs从 Vue 实例中访问它。

  1. 将这三个作用域插槽数据属性dirtyinvalidvalidated添加到<ValidationObserver>组件中,以切换警报和成功消息,然后让我们按照以下方式重构这个组件:
// pages/contact.vue
<ValidationObserver v-slot="{ passes, dirty, invalid, validated }" ref="observer">
  <div class="alert callout" v-if="invalid && validated">
    <p><i class="fi-alert"></i> There are some errors in your 
     form.</p>
  </div>
  //...
  <div class="success callout" v-if="submitted && !dirty">
    <p><i class="fi-like"></i>&nbsp; Thank you for contacting
      me.</p>
  </div>
</ValidationObserver>

export default {
  data () {
    return {
      submitted: false
      //...
    }
  },
  methods:{
    processForm (event) {
      console.log('Posting to the server...')
      this.submitted = true
      //...
    }
  }
}

在最后一步中,我们添加了一个默认为falsesubmitted数据属性,当表单在processForm方法中提交时,它将被设置为true。另一方面,当作用域插槽中的invalidvalidated都为true时,警报消息块将可见,当submitted属性为truedirty作用域插槽数据属性为false时,成功消息块将可见。如果输入字段中有一个字母存在,我们将从dirty属性中得到一个true。换句话说,当输入字段中存在字母时。

您可以看到,在我们的 Nuxt 应用中重构的代码与我们在 Vue 标准应用中所做的非常相似。但是在 Nuxt 应用中,我们为表单添加了更复杂的逻辑,例如切换警报和成功消息,有条件地将类绑定到<label><input>元素,并在表单提交时重置<ValidationObserver>组件。对于其余的输入元素,重构过程是相同的,您可以在书的 GitHub 存储库/chapter-7/nuxt-universal/sample-website/中找到。

摘要

在本章中,我们已经介绍了使用v-model在各种表单输入上进行 Vue 表单验证。您已经学会了基本和动态值绑定,以及如何使用修饰符来更改默认的输入事件和类型转换。您还学会了使用vee-validate插件来简化验证过程。最后,我们成功将这些应用到了 Nuxt 应用程序中。

在下一章中,我们将探讨如何在 Nuxt 应用程序中添加服务器端框架。您将学会使用 Koa 创建一个简单的 API,并将其与 Nuxt 集成,使用 HTTP 客户端 Axios 来请求 API 数据。此外,您还将介绍一个基于 webpack 的极简构建系统,称为 Backpack,它将简化我们用于单文件组件 Vue 应用程序的自定义 webpack 配置。您还将学会如何在 Nuxt 应用程序中使用这个构建系统。敬请关注!

第三部分:服务器端开发和数据管理

在本节中,我们将开始向 Nuxt 项目添加一个服务器端框架和数据库系统,以便我们可以在服务器端管理和获取数据。我们还将添加一个 Vuex 存储库,用于在 Nuxt 中管理全局数据。

本节包括以下章节:

  • 第八章,添加一个服务器端框架

  • 第九章,添加一个服务器端数据库

  • 第十章,添加一个 Vuex Store

添加服务器端框架

在本章中,您将学习如何配置 Nuxt 与服务器端框架,以及如何使用asyncData方法从服务器端框架(如 Koa 或 Express)获取数据。使用 Nuxt 设置服务器端框架相当容易。我们只需要选择一个框架作为一等公民,并将 Nuxt 用作中间件。我们可以使用npx create-nuxt-app <project-name>来为我们设置,但我们将手把手地教您如何手动操作,以便更好地理解这两个应用是如何协同工作的。此外,在本章中,我们将使用Backpack作为我们应用的构建系统。

本章我们将涵盖以下主题:

  • 介绍背包

  • 介绍 Koa

  • 将 Koa 与 Nuxt 集成

  • 理解异步数据

  • 在 asyncData 中访问上下文

  • 使用 Axios 获取异步数据

第八章:介绍背包

Backpack 是一个用于构建现代 Node.js 应用的构建系统,零配置或最小配置。它支持最新的 JavaScript,并处理文件监视、实时重新加载、转译和打包,这些都是我们在前几章中使用 webpack 进行的操作。我们可以将其视为 webpack 的包装器,是我们在本书中迄今为止一直在使用的 webpack 配置的简化版本。您可以在github.com/jaredpalmer/backpack找到有关 Backpack 的更多信息。现在,让我们看看如何在接下来的章节中使用它来加快我们的应用开发。

安装和配置 Backpack

使用 Backpack 创建现代 Node.js 应用可以像实现以下步骤一样简单:

  1. 通过 npm 安装 Backpack:
$ npm i backpack-core
  1. 在项目根目录中创建一个/src/目录和一个package.json文件,并在dev脚本中添加backpack,如下所示:
{
  "scripts": {
    "dev": "backpack"
  }
}

请注意,您必须将/src/作为应用的默认入口目录

  1. 在项目根目录创建一个 Backpack 配置文件,并配置 webpack 的函数如下:
// backpack.config.js
module.exports = {
  webpack: (config, options, webpack) => {
    // ....
    return config
  }
}

这一步是可选的,但如果您想要将应用的默认入口目录(即您在步骤 2中创建的/src/目录)更改为其他目录,例如/server/目录,可以按以下方式进行:

webpack: (config, options, webpack) => {
  config.entry.main = './server/index.js'
  return config
}
  1. 使用以下命令以开发模式启动您的应用:
$ npm run dev

然后你可以在/server/目录中开发你的应用程序的源代码,并在浏览器上浏览到你设置的任何端口的应用程序。让我们在下一节中使用 Backpack 创建一个简单的 Express 应用程序。

使用 Backpack 创建一个简单的应用程序

使用 Backpack 创建一个 Express 应用程序可以像实现以下步骤一样简单:

  1. 通过 npm 安装 Express:
$ npm i express
  1. package.json文件的dev脚本之后添加buildstart脚本:
// package.json
"scripts": {
  "dev": "backpack",
  "build": "backpack build",
  "start": "cross-env NODE_ENV=production node build/main.js"
}
  1. 创建 Backpack 配置文件,并将/server/作为应用程序的入口文件夹,就像我们在上一节中向你展示的那样:
// backpack.config.js
module.exports = {
  webpack: (config, options, webpack) => {
    config.entry.main = './server/index.js'
    return config
  }
}
  1. 创建一个带有'Hello World'消息的简单路由:
// server/index.js
import express from 'express'
const app = express()
const port = 3000

app.get('/', (req, res) =>
  res.send('Hello World')
)

app.listen(port, () =>
  console.log(Example app listening on port ${port}!)
)
  1. 在开发模式下运行你的应用程序:
$ npm run dev

现在你可以在浏览器上浏览127.0.0.1:3000上的应用程序。你应该在屏幕上看到 Hello World。你可以在我们的 GitHub 存储库的/chapter-8/backpack/中找到这个例子。接下来,让我们在下一节中使用 Koa 作为服务器端框架,允许我们以比 Express 更少的行数编写 ES2015 代码和异步函数。

介绍 Koa

Koa 是由带给你 Express 的同一个团队设计的 Node.js web 框架。该框架的主要目标是成为 Web 应用程序和 API 的更小、更具表现力的基础。如果你曾经在 Express 上工作过,并且在应用程序变得更大时厌倦了回调地狱,Koa 允许你摆脱回调,并通过利用异步函数大大增加错误处理。Koa 中另一个很酷的东西是级联 - 你添加的中间件将会“下游”运行,然后再“上游”流动,这给你更可预测的控制。我们稍后将在本章中演示这一点。

如果你想了解更多关于 Koa 的信息,请访问koajs.com/

安装和配置 Koa

现在,让我们创建一个 Koa 应用程序,使用 Backpack 的默认配置(不创建 Backpack 配置文件),如下所示:

  1. 通过 npm 安装 Koa:
$ npm i koa
  1. 使用/src/作为 Backpack 的默认入口目录,并在该目录中创建一个以 Koa 风格的最小代码的入口文件,如下所示:
// src/index.js
const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  ctx.body = 'Hello World'
})
app.listen(3000)
  1. 在开发模式下运行 Koa 应用程序:
$ npm run dev

当在浏览器上浏览127.0.0.1:3000时,你应该在屏幕上看到 Hello World。如果你一直在使用 Express 来创建你的 Node.js 应用程序,你会发现 Koa 是一个可以用来以更整洁的代码做同样事情的替代方案。接下来,让我们在接下来的章节中学习 Koa 上下文是什么,以及 Koa 中级联是如何工作的。

ctx 是什么?

您可能想知道在我们在上一节中创建的最小代码中,ctx是什么,以及reqres对象在哪里,因为它们在 Express 应用程序中存在。它们在 Koa 中并没有消失。它们只是封装在 Koa 中的一个单一对象中,这就是 Koa 上下文,称为ctx。我们可以按如下方式访问requestresponse对象:

app.use(async ctx => {
  ctx.request
  ctx.response
})

因此,您可以看到我们可以轻松使用ctx.request来访问 Node.js 的request对象,以及ctx.response来访问 Node.js 的response对象。这两个重要的 HTTP 对象在 Koa 中并没有消失!它们只是隐藏在 Koa 上下文 - ctx中。接下来,让我们在下一节中了解 Koa 中级联的工作原理。

了解 Koa 中级联的工作原理

简而言之,Koa 中的级联工作是通过按顺序调用下游中间件,然后控制它们按顺序向上游流动。最好创建一个简单的 Koa 应用程序来演示 Koa 中的这一重要特性:

  1. /src/目录中创建一个index.js文件,就像我们在上一节中所做的那样:
// src/index.js
const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  console.log('Hello World')
  ctx.body = 'Hello World'
})
app.listen(3000)
  1. Hello World中间件之前创建三个中间件,以便我们可以先运行它们:
app.use(async (ctx, next) => {
  console.log('Time started at: ', Date.now())
  await next()
})

app.use(async (ctx, next) => {
  console.log('I am the first')
  await next()
  console.log('I am the last')
})

app.use(async (ctx, next) => {
  console.log('I am the second')
  await next()
  console.log('I am the third')
})
  1. 在开发模式下运行应用程序,您应该在终端上获得以下输出:
Time started at: 1554647742894
I am the first
I am the second
Hello World
I am the third
I am the last

在这个演示中,请求通过Time started at:流向I am the firstI am the second,并到达Hello World。当没有更多的中间件需要向下执行(下游)时,每个中间件将按以下顺序向上解开并恢复(上游):I am the thirdI am the last

您可以在我们的 GitHub 存储库的/chapter-8/koa/cascading/中找到这个示例。

接下来,我们将为您介绍一些依赖项,您应该安装这些依赖项来开发一个全栈 Koa 应用程序,使其可以像 Express 应用程序一样工作。

安装 Koa 应用程序的依赖项

Koa 是极简的。它本质上是一个基本框架。因此,它的核心中没有任何中间件。Express 自带路由器,默认情况下,Koa 没有。这在使用 Koa 编写应用程序时可能会有挑战,因为你需要选择一个第三方包或从它们的 GitHub 主页上列出的包中选择一个。你可能会尝试一些包,发现它们不符合你的要求。有一些 Koa 包可用于路由;koa-router在本书中被广泛使用,以及其他用于使用 Koa 开发 API 的基本依赖项。让我们通过安装它们并创建一个骨架应用程序来发现它们是什么以及它们的作用,如下所示:

  1. 安装koa-router模块并使用如下:
$ npm i koa-router

在入口文件中导入koa-router,并创建一个主页路由/,如下所示:

// src/index.js
const Router = require('koa-router')
const router = new Router()

router.get('/', (ctx, next) => {
  ctx.body = 'Hello World'
})

app
  .use(router.routes())
  .use(router.allowedMethods())

你可以在 Koa 的 GitHub 存储库中找到有关此中间件的更多信息。此模块是从ZijianHe/koa-router(https://github.com/ZijianHe/koa-router)分叉而来。它是 Koa 社区中最广泛使用的路由器模块。它提供了使用app.getapp.putapp.post等的 Express 风格路由。它还支持其他重要功能,如多个路由中间件和多个可嵌套的路由器。

  1. 安装koa-bodyparser模块并使用如下:
$ npm i koa-bodyparser

在入口文件中导入koa-bodyparser,注册它,并创建一个主页路由/post,如下所示:

// src/index.js
const bodyParser = require('koa-bodyparser')
app.use(bodyParser())

router.post('/post', (ctx, next) => {
  ctx.body = ctx.request.body
})

你可以在 Koa 的 GitHub 存储库中找到有关此中间件的更多信息。也许你会想:什么是 body parser?当我们处理 HTML 表单时,我们使用application/x-www-form-urlencodingmultipart/form-data在客户端和服务器端之间传输数据,例如:

// application/x-www-form-urlencoding
<form action="/update" method="post">
  //...
</form>

// multipart/form-data
<form action="/update" method="post" encrypt="multipart/form-data">
  //...
</form>

HTML 表单的默认类型是application/x-www-urlencoded,如果我们想要读取 HTTP POSTPATCHPUT的数据,我们使用一个 body parser,它是一个解析传入请求的中间件,组装包含表单数据的,然后创建一个填充有表单数据的 body 对象,以便我们可以从请求对象中的ctx对象中访问它们,如下所示:

ctx.body = ctx.request.body
  1. 安装koa-favicon模块并使用如下:
$ npm i koa-favicon

在入口文件中导入koa-favicon并注册它,路径为favicon,如下所示:

// src/index.js
const favicon = require('koa-favicon')
app.use(favicon('public/favicon.ico'))

您可以在 Koa 的 GitHub 存储库中的github.com/koajs/favicon找到有关此中间件的更多信息。这是一个提供favicon的中间件,因此让我们创建一个favicon.ico文件并将其保存在项目根目录中的/public文件夹中。当您刷新主页时,您应该在浏览器标签上看到favicon

  1. 安装koa-static模块并按以下方式使用它:
$ npm i koa-static

在入口文件中导入koa-static并按照以下路径进行注册:

const serve = require('koa-static')
app.use(serve('.'))
app.use(serve('static/fixtures'))

您可以在 Koa 的 GitHub 存储库中的github.com/koajs/static找到有关此中间件的更多信息。默认情况下,Koa 不允许您提供静态文件。因此,此中间件将允许您从 API 中提供静态文件。例如,我们刚刚设置的路径将允许我们从项目根目录中的/static文件夹访问以下文件:

  • 127.0.0.1:3000/package.json处获取/package.json

  • 127.0.0.1:3000/hello.txt处获取/hello.txt

在未来的章节中,我们将在创建 Koa API 时使用这个框架。现在,让我们在下一节中发现如何将 Koa 与 Nuxt 集成。

您可以在我们的 GitHub 存储库的/chapter-8/koa/skeleton/中找到此框架应用。

将 Koa 与 Nuxt 集成

将 Koa 和 Nuxt 集成可以在单域应用程序的单个端口上完成,也可以在跨域应用程序的不同端口上完成。在本章中,我们将进行单域集成,然后我们将指导您完成第十二章中的跨域集成,创建用户登录和 API 身份验证。我们将使用在上一节中开发的 Koa 框架来进行这两种类型的集成。单域集成需要在以下步骤中进行一些配置。让我们开始吧:

  1. 在 Nuxt 项目的根目录中创建一个/server/目录,并在使用create-nuxt-app脚手架工具创建项目后,按以下方式构建服务器端目录:
├── package.json
├── nuxt.config.js
├── server
│ ├── config
│ │ └── ...
│ ├── public
│ │ └── ...
│ ├── static
│ │ └── ...
│ └── index.js
└── pages
    └── ...
  1. 修改默认的package.json文件中的默认脚本以使用默认的Backpack,该文件与脚手架工具一起提供。
// package.json
"scripts": {
  "dev": "backpack",
  "build": "nuxt build && backpack build",
  "start": "cross-env NODE_ENV=production node build/main.js",
  "generate": "nuxt generate"
}
  1. 在根目录中创建一个 Backpack 配置文件(我们在其中有 Nuxt 配置文件),将 Backpack 默认的入口目录更改为我们刚刚创建的/server/目录:
// backpack.config.js
module.exports = {
  webpack: (config, options, webpack) => {
    config.entry.main = './server/index.js'
    return config
  }
}
  1. /server/目录中创建一个index.js文件,以以下方式将 Koa(确保您已经安装了 Koa)作为主应用程序导入,并将 Nuxt 作为 Koa 中的中间件:
// server/index.js
import Koa from 'koa'
import consola from 'consola'
import { Nuxt, Builder } from 'nuxt'
const app = new Koa()
const nuxt = new Nuxt(config)

async function start() {
  app.use((ctx) => {
    ctx.status = 200
    ctx.respond = false
    ctx.req.ctx = ctx
    nuxt.render(ctx.req, ctx.res)
  })
}
start()

请注意,我们创建了一个异步函数来使用 Nuxt 作为中间件,以便在下一步中可以使用await语句来运行 Nuxt 构建过程。

请注意,Consola 是一个控制台记录器,您必须在使用之前通过 npm 安装它。有关此软件包的更多信息,请访问github.com/nuxt-contrib/consola

  1. 在将 Nuxt 注册为中间件之前,在开发模式下导入 Nuxt 构建过程的配置:
// server/index.js
let config = require('../nuxt.config.js')
config.dev = !(app.env === 'production')

if (config.dev) {
  const builder = new Builder(nuxt)
  await builder.build()
} else {
  await nuxt.ready()
}
  1. 通过监听其端口和主机来运行应用程序,并使用 Consola 记录服务器状态如下:
app.listen(port, host)
consola.ready({
  message: `Server listening on http://${host}:${port}`,
  badge: true
})
  1. 在开发模式下启动应用程序:
$ npm run dev

我们的 Nuxt 和 Koa 应用现在作为一个单一应用程序运行。您可能已经意识到,Nuxt 现在作为中间件在 Koa 下运行。我们所有的 Nuxt 页面仍然像以前一样在localhost:3000上运行,但是我们将在接下来的部分中将localhost:3000/api配置为 API 的主要端点。

添加路由和其他必要的中间件

在上一节中,我们建立了集成并构建了服务器端目录结构。现在让我们在接下来的步骤中完善一些 API 路由和其他中间件:

  1. 通过 npm 安装 Koa Router 和 Koa Static 包:
$ npm i koa-route
$ npm i koa-static
  1. 创建一个服务器端配置文件:
// server/config/index.js
export default {
  static_dir: {
    root: '../static'
  }
}
  1. /server/目录中创建一个routes.js文件,用于定义我们将向公众公开的路由,并附带一些虚拟用户数据:
// server/routes.js
import Router from 'koa-router'
const router = new Router({ prefix: '/api' })

const users = [
  { id: 1, name: 'Alexandre' },
  { id: 2, name: 'Pooya' },
  { id: 3, name: 'Sébastien' }
]

router.get('/', async (ctx, next) => {
  ctx.type = 'json'
  ctx.body = {
    message: 'Hello World!'
  }
})

router.get('/users', async (ctx, next) => {
  ctx.type = 'json'
  ctx.body = users
})

router.get('/users/:id', async (ctx, next) => {
  const id = parseInt(ctx.params.id)
  const found = users.find(function (user) {
    return user.id == id
  })
  if (found) {
    ctx.body = found
  } else {
    ctx.throw(404, 'user not found')
  }
})
  1. 在单独的middlewares.js文件中导入其他中间件,并从步骤 12中导入路由和配置文件:
// server/middlewares.js
import serve from 'koa-static'
import bodyParser from 'koa-bodyparser'
import config from './config'
import routes from './routes'

export default (app) => {
  app.use(serve(config.static_dir.root))
  app.use(bodyParser())
  app.use(routes.routes(), routes.allowedMethods())
}

我们不会在 API 中使用koa-favicon,因为我们以 JSON 格式导出数据,而favicon.ico的图像不会显示在浏览器标签上。此外,Nuxt 已经在 Nuxt 配置文件中为我们处理了favicon.ico,因此我们可以从骨架中删除koa-favicon中间件。相反,我们将创建一个中间件来将我们的 JSON 数据装饰成这两个最终的 JSON 输出

  • 200 输出的格式:
{"status":<status code>,"data":<data>}
  • 所有错误输出的格式(例如 400,500):
{"status":<status code>,"message":<error message>}
  1. app.use(serve(config.static_dir.root))行之前添加以下代码以创建前述格式:
app.use(async (ctx, next) => {
  try {
    await next()
    if (ctx.status === 404) {
      ctx.throw(404)
    }
    if (ctx.status === 200) {
      ctx.body = {
        status: 200,
        data: ctx.body
      }
    }
  } catch (err) {
    ctx.status = err.status || 500
    ctx.type = 'json'
    ctx.body = {
      status: ctx.status,
      message: err.message
    }
    ctx.app.emit('error', err, ctx)
  }
})

因此,现在有了这个中间件,我们将不再获得诸如{"message":"Hello World!"}的输出,而是会得到以下装饰过的输出:

{"status":200,"data":{"message":"Hello World!"}}
  1. 在注册 Nuxt 之前,在主index.js文件中导入middlewares.js文件:
// server/index.js
import middlewares from './middlewares'

middlewares(app)
app.use(ctx => {
  ...
  nuxt.render(ctx.req, ctx.res)
})
  1. 以开发模式重新运行应用程序:
$ npm run dev
  1. 然后,如果您访问localhost:3000/api上的应用程序,您将在屏幕上获得以下输出:
{"status":200,"data":{"message":"Hello World!"}}

如果您访问localhost:3000/api/users上的用户索引页面,您将在屏幕上获得以下输出:

{"status":200,"data":[{"id":1,"name":"Alexandre"},{"id":2,"name":"Pooya"},{"id":3,"name":"Sébastien"}]}

您还可以使用localhost:3000/api/users/<id>来获取特定用户。例如,如果您使用/api/users/1,您将在屏幕上获得以下输出:

{"status":200,"data":{"id":1,"name":"Alexandre"}}

您可以在我们的 GitHub 存储库的/chapter-8/nuxt-universal/skeletons/koa/中找到这个集成示例应用程序。

接下来,我们将看看如何在接下来的部分从 Nuxt 页面上的客户端使用asyncData方法请求前面的 API 数据。

理解异步数据

asyncData方法允许我们在组件初始化之前异步获取数据并在服务器端渲染它。这是一个额外的方法,只在 Nuxt 中可用。这意味着您不能在 Vue 中使用它,因为 Vue 没有这个默认方法。Nuxt 总是在渲染页面组件之前执行这个方法。当通过<nuxt-link>组件生成的路由重新访问该页面时,该方法将在服务器端的页面上执行一次,然后在客户端上执行。Nuxt 将从asyncData方法中返回的数据与data方法或data属性中的组件数据合并。该方法将context对象作为第一个参数,如下所示:

export default {
  asyncData (context) {
    // ...
  }
}

请记住,这个方法总是在页面组件初始化之前执行,所以我们无法通过this关键字在这个方法内访问组件实例。有两种不同的使用方法;让我们在接下来的部分中探讨它们。

返回一个承诺

我们可以通过返回PromiseasyncData方法中使用Promise对象,例如:

// pages/returning-promise.vue
asyncData (context) {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Hello World by returning a Promise')
    }, 1000)
  })

  return promise.then((value) => {
    return { message: value }
  })
}

在前面的代码中,Nuxt 将等待 1 秒钟,直到承诺被解决,然后再使用'通过返回 Promise 来打招呼的 Hello World'渲染页面组件。

使用 async/await

我们还可以在asyncData方法中使用async/await语句,例如:

// pages/using-async.vue
async asyncData (context) {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Hello World by using async/await')
    }, 2000)
  })

  const result = await promise
  return { message: result }
}

在上述代码中,Nuxt 将等待 2 秒钟,直到承诺被解决,然后使用'Hello World by using async/await'消息呈现页面组件。使用async/await语句是编写异步 JavaScript 代码的新方法。它建立在Promise对象之上,并使我们的异步代码更易读。我们将在整本书中经常使用这个语句。

合并数据

正如我们之前提到的,asyncData方法中的异步数据将与data方法或data属性中的组件数据合并。这意味着如果您在组件数据中使用了与asyncData方法中相同对象键的一些默认数据,它们将被asyncData方法覆盖。以下是一个例子:

// pages/merging-data.vue
<p>{{ message }}</p>

export default {
  data () {
    return { message: 'Hello World' }
  },
  asyncData (context) {
    return { message: 'Data Merged' }
  }
}

在上述代码中,Nuxt 将合并两组数据,并在屏幕上得到以下结果:

<p>Data Merged</p>

您可以在我们的 GitHub 存储库的/chapter-8/nuxt-universal/koa-nuxt/understanding-asyncdata/中找到这些示例。

接下来,我们将看看如何在接下来的部分中从asyncData方法中访问context对象。

在 asyncData 中访问上下文

我们可以从 Nuxt 上下文中访问大量有用的数据。它们存储在上下文对象中,作为以下键:

|

  • 应用

  • 路由

  • 存储

  • 参数

  • 查询

|

  • 请求

  • res

  • 重定向

  • 错误

  • env

|

  • isDev

  • isHMR

  • beforeNuxtRender(fn)

  • 来自

  • nuxtState

|

它们是额外提供的,特别是在 Nuxt 中,因此我们在 Vue 中找不到它们。我们可以使用context.<key>{ <key> }来访问它们。让我们探索一些这些键,并看看我们如何在接下来的部分中利用它们。

有关 Nuxt 上下文的更多信息,请访问nuxtjs.org/api/context

访问 req/res 对象

当在服务器端执行asyncData方法时,我们可以访问reqres对象。它们包含用户发送的 HTTP 请求的有用信息。但在访问它们之前,我们应该始终检查if条件:

// pages/index.vue
<p>{{ host }}</p>

export default {
  asyncData ({ req, res }) {
    if (process.server) {
     return { host: req.headers.host }
    }
    return { host: '' }
  }
}

在上述代码中,我们使用if条件来确保在获取请求头信息之前在服务器端调用asyncData方法。这两个对象在客户端不可用,因此在客户端访问它们时会得到undefined。因此,当页面在浏览器上首次加载时,我们将得到上述代码的结果localhost:3000,但是除非刷新该页面,否则当通过<nuxt-link>组件生成的路由重新访问此页面时,您将不会再看到该信息。

访问动态路由数据

当我们的应用程序中有动态路由时,我们可以通过params键访问动态路由数据。例如,如果我们在/pages/目录中有一个_id.vue文件,那么我们可以通过context.params.id来访问路由参数的值。

// pages/users/_id.vue
<p>{{ id }}</p>

export default {
  asyncData ({ params }) {
    return { id: params.id }
  }
}

在上述代码中,当在浏览器上调用users/1时,id将得到1

监听查询变化

默认情况下,asyncData方法不会在查询字符串更改时执行。例如,如果您在使用<nuxt-link>组件的路由上使用诸如/users?id=<id>的查询,那么当通过<nuxt-link>组件路由从一个查询更改到另一个查询时,asyncData将不会被调用。这是因为 Nuxt 默认禁用了查询更改的监听以提高性能。如果您想覆盖此默认行为,可以使用watchQuery属性来监听特定参数:

// pages/users/index.vue
<p>{{ id }}</p>
<ul>
  <li>
    <nuxt-link :to="'users?id=1'">1</nuxt-link>
    <nuxt-link :to="'users?id=2'">2</nuxt-link>
  </li>
</ul>

export default {
  asyncData ({ query }) {
    return { id: query.id }
  },
  watchQuery: ['id']
}

在上述代码中,我们正在监听id参数,因此当导航到/users?id=1时,您将得到1,而当导航到/users?id=2时,您将得到2。如果您想为所有查询字符串设置一个观察器,只需将watchQuery设置为true

处理错误

我们可以使用context对象中的error方法来调用 Nuxt 默认的错误页面并显示错误。您可以通过默认的params.statusCodeparams.message属性传递错误代码和消息:

// pages/users/error.vue
export default {
  asyncData ({ error }) {
    return error({
      statusCode: 404,
      message: 'User not found'
    })
  }
}

如果您想要更改传递给error方法的默认属性,您可以创建一个自定义错误页面,您在第四章中学到了如何创建自定义错误属性和布局。让我们按照以下步骤创建这些自定义错误属性和布局:

  1. 创建一个要抛出自定义属性的页面:
// pages/users/error-custom.vue
export default {
  asyncData ({ error }) {
    return error({
      status: 404,
      text: 'User not found'
    })
  }
}
  1. /layouts/目录中创建一个自定义错误页面:
// layouts/error.vue
<template>
  <div>
    <h1>Custom Error Page</h1>
    <h2>{{ error.status }} Error</h2>
    <p>{{ error.text }}</p>
    <nuxt-link to="/">Home page</nuxt-link>
  </div>
</template>

<script>
export default {
  props: ['error'],
  layout: 'layout-error'
}
</script>
  1. 为这个错误页面创建一个自定义布局页面:
// layouts/layout-error.vue
<template>
  <nuxt />
</template>

当访问/users/error-custom时,您应该看到自定义属性和布局。

您可以在我们的 GitHub 存储库的/chapter-8/nuxt-universal/koa-nuxt/accessing-context/中看到所有示例。

接下来,我们将看看如何在接下来的部分中使用 Axios,一个 HTTP 客户端,与asyncData方法一起请求 API 数据。

使用 Axios 获取异步数据

我们创建了一个简单的 API,使用 Koa 暴露了一些公共路由,用于访问其数据,比如/api/users/api/users/1。我们还将这个 API 与 Nuxt 集成到一个单一的应用程序中,其中 Nuxt 充当中间件。您还学会了asyncData方法的工作原理以及如何利用 Nuxt 上下文。现在,让我们通过在请求 API 数据时使用 Axios 和asyncData方法将这三个部分整合在一起。

安装和配置 Axios

Axios 是一个基于 Promise 的 Node.js 应用程序的 HTTP 客户端。在上一节中,我们使用了asyncData方法与原始的 Promise 一起工作。我们可以使用 Axios 进一步简化我们的代码,并节省一些行数,它是由异步 JavaScript 和 XML(AJAX)支持的,用于进行异步 HTTP 请求。让我们在接下来的步骤中开始吧:

  1. 通过 npm 安装 Axios:
$ npm i axios

在使用 Axios 进行 HTTP 请求时,我们应该始终使用完整路径。

axios.get('https://jsonplaceholder.typicode.com/posts')

但是在每个请求的路径中包含https://jsonplaceholder.typicode.com/可能会重复。此外,这个基本 URL 可能会随时间改变。因此,我们应该将其抽象出来并简化请求:

axios.get('/posts')
  1. /plugins/目录中创建一个 Axios 实例:
// plugins/axios-api.js
import axios from 'axios'

export default axios.create({
  baseURL: 'http://localhost:3000'
})
  1. 在组件中需要时导入这个插件:
import axios from '~/plugins/axios-api'

安装和配置完成后,我们准备在下一节中获取异步数据。

使用 Axios 和 asyncData 获取数据

让我们在接下来的步骤中创建需要呈现数据的页面:

  1. 创建一个用于列出所有用户的索引用户页面:
// pages/users/index.vue
<li v-for="user in users" v-bind:key="user.id">
  <nuxt-link :to="'users/' + user.id">
    {{ user.name }}
  </nuxt-link>
</li>

<script>
import axios from '~/plugins/axios-api'
export default {
  async asyncData({error}) {
    try {
      let { data } = await axios.get('/api/users')
      return { users: data.data }
    } catch (e) {
      // handle error
    }
  }
}
</script>

在这个页面上,我们使用 Axios 的get方法来调用/api/users的 API 端点,它将被转换为localhost:3000/api/users,用户列表可以如下输出:

{"status":200,"data":[{"id":1,"name":"Alexandre"},{"id":2,"name":"Pooya"},{"id":3,"name":"Sébastien"}]}

然后我们使用 JavaScript 的解构赋值{ data }来解开输出中的data键。在使用async/await语句时,将代码放在try/catch块中是一个好习惯。接下来,我们需要请求单个用户的数据。

  1. 创建一个用于呈现单个用户数据的单个用户页面:
// pages/users/_id.vue
<h2>
  {{ user.name }}
</h2>

<script>
import axios from '~/plugins/axios-api'
export default {
  name: 'id',
  async asyncData ({ params, error }) {
    try {
      let { data } = await axios.get('/api/users/' + params.id)
      return { user: data.data }
    } catch (e) {
      // handle error
    }
  }
}
</script>

在这个页面上,我们再次使用 Axios 的get方法来调用/api/users/<id>的 API 端点,这将被转换为localhost:3000/api/users/<id>,以获取单个用户的数据:

{"status":200,"data":{"id":1,"name":"Alexandre"}}

再次使用 JavaScript 的解构赋值{ data }来解包输出中的data键,并将async/await代码包装在try/catch块中。

在下一节中,我们希望实现与本节相同的结果,即获取用户列表和特定用户的数据。但是我们将在单个页面上使用watchQuery属性,这是您在上一节中学到的。

监听查询变化

在本节中,我们将创建一个页面来监听查询字符串的变化并获取单个用户的数据。为此,我们只需要一个.vue页面来列出所有用户并监视查询,如果查询有任何变化,我们将从查询中获取id并使用asyncData方法中的 Axios 获取具有该id的用户。让我们开始吧:

  1. /pages/目录中创建一个users-query.vue页面,并将以下模板添加到<template>块中:
// pages/users-query.vue
<ul>
  <li v-for="user in users" v-bind:key="user.id">
    <nuxt-link :to="'users-query?id=' + user.id">
      {{ user.name }}
    </nuxt-link>
  </li>
</ul>
<p>{{ user }}</p>

在这个模板中,我们使用v-for指令来循环遍历每个users中的user,并将每个用户的查询添加到<nuxt-link>组件中。单个用户的数据将在<ul>标签之后的<p>标签内呈现。

  1. 将以下代码添加到<script>块中:
// pages/users-query.vue
import axios from '~/plugins/axios-api'

export default {
  async asyncData ({ query, error }) {
    var user = null
    if (Object.keys(query).length > 0) {
      try {
        let { data } = await axios.get('/api/users/' + query.id)
        user = data.data
      } catch (e) {
        // handle error
      }
    }

    try {
      let { data } = await axios.get('/api/users')
      return {
        users: data.data,
        user: user
      }
    } catch (e) {
      // handle error
    }
  },
  watchQuery: true
}

这段代码与/pages/users/index.vue相同;我们只是在asyncData中添加了一个query对象,并根据查询中的信息获取用户数据。当然,我们还添加了watchQuery: truewatchQuery: ['id']来监视查询的变化。因此,在浏览器中,当您从列表中点击一个用户,比如users-query?id=1,该用户的数据将呈现在<p>标签内,如下所示:

{ "id": 1, "name": "Alexandre" }

干得好!您已经到达了本章的结尾。我们希望这对您来说是一个简单而容易的章节。除了使用 Axios 向 API 后端发出 HTTP 请求,我们还可以使用这些 Nuxt 模块之一:Axios 和 HTTP。在本书中,我们专注于原始的 Axios 和 Axios 模块。您还记得我们在第六章中介绍过 Axios 模块吗,编写插件和模块?我们将在接下来的章节中经常使用这个模块。现在,让我们总结一下您在本章学到的内容。

你可以在我们的 GitHub 存储库中的/chapter-8/nuxt-universal/koa-nuxt/using-axios/axios-vanilla/找到上述代码。如果您想了解更多关于 Nuxt HTTP 模块的信息,请访问http.nuxtjs.org/

总结

在本章中,您已经学会了如何配置 Nuxt 与服务器端框架,本书中使用的是 Koa。您已经安装了 Koa 及其依赖项,以便创建 API。然后,您使用asyncData和 Axios 从 API 查询和获取数据。此外,您还了解了 Nuxt 上下文中的属性,可以从asyncData方法中解构和访问,例如paramsqueryreqreserror。最后,您开始在应用程序中使用 Backpack 作为一个极简的构建工具。

在下一章中,您将学习如何设置 MongoDB 并编写一些基本的 MongoDB 查询,如何向 MongoDB 数据库添加数据,如何将其与刚刚在本章中学习的服务器端框架 Koa 集成,最后,如何将其与 Nuxt 页面集成。我们将指导您学习一切,以便创建一个更完整的 API。所以,请继续关注。

添加服务器端数据库

在上一章中,我们为 Nuxt 应用程序添加了 Koa 作为服务器端框架,并添加了一些虚拟数据。在本章中,我们将设置 MongoDB 作为服务器端数据库,以替换虚拟数据。我们将编写一些 MongoDB CRUD 查询,向数据库添加数据,并使用asyncData从数据库中获取数据。

本章我们将涵盖的主题如下:

  • 介绍 MongoDB

  • 编写基本的 MongoDB 查询

  • 编写 MongoDB CRUD 操作

  • 使用 MongoDB CRUD 查询注入数据

  • 将 MongoDB 集成到 Koa 中

  • 与 Nuxt 页面集成

第九章:介绍 MongoDB

MongoDB 是一个开源的面向文档的数据库管理系统(DBMS),它以类似 JSON 的文档形式存储数据,称为二进制 JSON(BSON)- MongoDB 的 JSON 文档的二进制表示,可以比普通 JSON 更快地解析。自 2009 年以来,它是最受欢迎的 NoSQL 数据库系统之一,不使用表和行,与关系数据库管理系统(RDBMS)相反。在 MongoDB 中,您的数据记录是由名称-值对(或字段和值对)组成的文档,类似于 JSON 对象,但是二进制编码以支持 JSON 范围之外的数据类型,例如 ObjectId、Date 和 Binary data(https://docs.mongodb.com/manual/reference/bson-types/)。因此,它被称为二进制 JSON。例如,{"hello":"world"}的文档将存储在.bson文件中,如下所示:

1600 0000 0268 656c 6c6f 0006 0000 0077
 6f72 6c64 0000

实际上,BSON 中编码的数据不是人类可读的,但是在使用 MongoDB 时,我们不必过多担心,因为它们将由 MongoDB 驱动程序为您自动编码和解码。您只需要使用 MongoDB 的语法、方法、操作和选择器与您熟悉的 JSON 文档一起构建 BSON 存储的文档。让我们安装 MongoDB 并开始编写。

安装 MongoDB

根据版本(社区版或企业版)和平台(Windows、Ubuntu 或 macOS),安装 MongoDB 有几种方法。您可以按照这里提供的链接进行操作:

在 Ubuntu 20.04 上安装

在本书中,我们将在 Ubuntu 20.04(Focal Fossa)上安装 MongoDB 4.2(社区版)。如果您使用的是 Ubuntu 19.10(Eoan Ermine),它也是一样的。如果您使用其他旧版本的 Ubuntu,比如 14.04 LTS(Trusty Tahr)、16.04 LTS(Xenial Xerus)或 18.04 LTS(Bionic Beaver),请在上一节中的链接中查看《在 Ubuntu 上安装 MongoDB 社区版》。所以,让我们开始吧:

  1. mongodb.org导入公钥:
$ wget -qO - https://www.mongodb.org/static/pgp/server-4.2.asc | sudo apt-key add -

您应该会得到一个OK的响应。

  1. 为 MongoDB 创建一个列表文件:
$ echo "deb [ arch=amd64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/4.2 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.2.list
  1. 更新系统中的所有本地软件包:
$ sudo apt-get update
  1. 安装 MongoDB 软件包:
$ sudo apt-get install -y mongodb-org

启动 MongoDB

一旦您安装了 MongoDB 软件包,您接下来应该做的是查看您是否可以从终端启动和连接 MongoDB 服务器。所以,让我们开始吧:

  1. 在以下命令中手动或自动启动 MongoDB:
$ sudo systemctl start mongod
$ sudo systemctl enable mongod
  1. 通过检查其版本来验证它:
$ mongo --version

您应该会在终端上得到类似的输出:

MongoDB shell version v4.2.1
git version: edf6d45851c0b9ee15548f0f847df141764a317e
OpenSSL version: OpenSSL 1.1.1d 10 Sep 2019
allocator: tcmalloc
modules: none
build environment:
    distmod: ubuntu1804
    distarch: x86_64
    target_arch: x86_64
  1. 可选地,使用以下命令检查 MongoDB 服务器的状态:
$ sudo service mongod status

您应该会在终端上得到类似的输出:

● mongod.service - MongoDB Database Server
   Loaded: loaded (/lib/systemd/system/mongod.service; enabled;
     vendor preset: enabled)
   Active: active (running) since Fri 2019-08-30 03:37:15 UTC;
     29s ago
     Docs: https://docs.mongodb.org/manual
 Main PID: 31961 (mongod)
   Memory: 68.2M
   CGroup: /system.slice/mongod.service
           └─31961 /usr/bin/mongod --config /etc/mongod.conf
  1. 可选地,使用netstat命令检查 MongoDB 是否已在端口 27017 上启动:
$ sudo netstat -plntu

您应该会看到类似的输出:

Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:27017 0.0.0.0: LISTEN 792/mongod
  1. 连接到 MongoDB Shell:
$ mongo
  1. 退出 MongoDB Shell(当您想要时):
> exit

如果出于任何原因,您想要从系统完全删除 MongoDB,那么使用此命令:

$ sudo apt-get purge mongodb-org*

在下一节中,您将开始从您刚刚学习的 MongoDB Shell 中编写一些基本查询。让我们开始吧。

编写基本的 MongoDB 查询

在您可以编写 MongoDB 查询和注入一些数据之前,首先您必须连接到 MongoDB,因此打开一个终端并输入以下内容:

$ mongo

然后您可以列出 MongoDB 系统中的数据库:

> show dbs

您应该会得到以下输出:

admin 0.000GB
config 0.000GB

这两个数据库(adminconfig)是 MongoDB 的默认数据库。但是,根据我们的需求和目的,我们应该创建新的数据库。

创建一个数据库

一旦你登录了 MongoDB shell,你可以使用use命令在 MongoDB 中创建一个新的数据库:

> use nuxt-app

你应该得到以下结果:

switched to db nuxt-app

但是,请注意,当你想选择一个现有的数据库时,情况是一样的:

> use admin

你应该得到以下结果:

switched to db admin

如果你想删除一个数据库,首先使用use命令选择数据库,例如,use nuxt-app,然后使用dropDatabase函数:

> db.dropDatabase()

你应该得到以下结果:

{ "dropped" : "nuxt-app", "ok" : 1 }

我们接下来要学习的是如何在我们创建的数据库中创建或添加集合。

创建一个新的集合

什么是 MongoDB 集合?如果你熟悉关系型数据库,集合类似于关系型数据库表,可以包含不同的字段,除了强制执行模式。我们使用createCollection方法以以下格式创建一个集合:

> db.createCollection(<name>, <options>)

<name>参数是集合的名称,比如 user、article 或其他。<options>参数是可选的,用于指定创建一个固定大小的集合或验证更新和插入的集合字段。有关这些选项的更多信息,请访问docs.mongodb.com/manual/reference/method/db.createCollection/。让我们创建一个文档,看看在接下来的步骤中还可以做什么:

  1. 创建一个没有任何选项的集合:
> db.createCollection("users", {})

你应该得到以下结果:

{ "ok" : 1 }
  1. 使用getCollectionNames方法列出数据库中的所有集合:
> db.getCollectionNames()

你应该得到以下结果:

[ "users" ]
  1. 使用drop方法删除users集合:
> db.users.drop()

你应该得到以下结果:

true

既然我们知道如何创建一个集合,下一件你应该知道的事情是如何向集合中添加文档。让我们在下一节中来看看。

编写 MongoDB CRUD 操作

当涉及到在数据库系统中管理和操作数据时,我们必须创建、读取、更新和删除(CRUD)文档。我们可以使用 MongoDB 的 CRUD 操作来实现这一点。你可以在docs.mongodb.com/manual/crud/上阅读更多关于 MongoDB CRUD 操作的信息。在本书中,我们只会看到如何使用每一个的简单示例:

  • 创建操作:我们可以使用以下方法向集合中创建或插入新文档:
db.<collection>.insertOne(<document>)
db.<collection>.insertMany([<document>, <document>, <document>, ...])

请注意,如果您的数据库中不存在该集合,这些insert操作将自动为您创建它。

  • 读取操作

我们可以使用以下方法从集合中获取文档:

db.<collection>.find(<query>, <projection>)
  • 更新操作:我们可以使用以下方法修改集合中现有的文档:
db.<collection>.updateOne(<filter>, <update>, <options>)
db.<collection>.updateMany(<filter>, <update>, <options>)
db.<collection>.replaceOne(<filter>, <replacement>, <options>)
  • 删除操作:我们可以使用以下方法从集合中删除文档:
db.<collection>.deleteOne(<filter>, <options>)
db.<collection>.deleteMany(<filter>, <options>)

通过这些简化的 CRUD 操作,您可以开始在下一节向数据库注入数据,然后您离创建一个完全功能的 API 又近了一步。让我们开始吧!

使用 MongoDB CRUD 注入数据

我们将使用您在上一节中学到的 MongoDB CRUD 操作向 nuxt-app 数据库中注入一些数据。

插入文档

我们可以使用insertOneinsertMany方法插入新文档如下:

  • 插入单个文档:我们可以像这样插入新文档:
> db.<collection>.insertOne(<document>)

让我们使用以下代码插入一个文档:

db.user.insertOne(
  {
    name: "Alexandre",
    age: 30,
    slug: "alexandre",
    role: "admin",
    status: "ok"
  }
)

您应该得到类似于这样的结果:

{
  "acknowledged" : true,
  "insertedId" : ObjectId("5ca...")
}
  • 插入多个文档:我们可以像这样插入多个新文档:
> db.<collection>.insertMany([<document>,<document>,<document>,...])

让我们使用以下代码插入两个文档:

> db.user.insertMany([
  {
    name: "Pooya",
    age: 25,
    slug: "pooya",
    role: "admin",
    status: "ok"
  },
  {
    name: "Sébastien",
    age: 22,
    slug: "sebastien",
    role: "writer",
    status: "pending"
  }
])

您应该得到类似于这样的结果:

{
  "acknowledged" : true,
  "insertedIds" : [
    ObjectId("5ca..."),
    ObjectId("5ca...")
  ]
}

在向user集合添加文档后,我们希望获取它们,这可以通过下一节中的读取操作简单完成。

查询文档

我们可以按以下方式使用find方法获取文档:

  • 选择集合中的所有文档:我们可以像这样从集合中获取所有文档:
> db.<collection>.find()

这个操作与以下 SQL 语句相同:

SELECT  FROM <table>

让我们按以下方式从user集合中获取所有文档:

> db.user.find()

您应该得到类似于这样的结果:

{ "_id" : ObjectId("5ca..."), "name" : "Alexandre", "slug" :
 "alexandre", ... }
{ "_id" : ObjectId("5ca..."), "name" : "Pooya", "slug" : "pooya", ... }
{ "_id" : ObjectId("5ca..."), "name" : "Sébastien", "slug" : 
 "sebastien", ... }
  • 指定相等条件:我们可以像这样从集合中获取特定文档:
> db.<collection>.find(<query>, <projection>)

您可以看到,我们使用与上一个示例相同的find方法,但是在<query>参数中传入选项来过滤匹配特定查询的文档。例如,以下行选择了status等于ok的文档:

> db.user.find( { status: "ok" } )

这个操作与以下 SQL 语句相同:

SELECT  FROM user WHERE status = "ok"

您应该得到类似于这样的结果:

{ "_id" : ObjectId("5ca..."), "name" : "Alexandre", ... "status" : "ok" }
{ "_id" : ObjectId("5ca..."), "name" : "Pooya", ... "status" : "ok" }
  • 使用查询操作符指定条件:我们还可以在find方法的<query>参数中使用 MongoDB 查询选择器,例如$eq$gt$in。例如,以下行获取了status等于okpending的文档:
> db.user.find( { status: { $in: [ "ok", "pending" ] } } )

此操作与以下 SQL 语句相同:

SELECT  FROM user WHERE status in ("ok", "pending")

您可以在docs.mongodb.com/manual/reference/operator/query/query-selectors找到有关查询选择器的更多信息。

  • 指定 AND 条件:您还可以将过滤器与查询选择器混合使用。例如,以下行获取status等于ok 并且 age小于($lt) 30 的文档:
> db.user.find( { status: "ok", age: { $lt: 30 } } )

您应该得到类似于这样的结果:

{ "_id" : ObjectId("5ca..."), "name" : "Pooya", "age" : 25, ... }

此操作与以下 SQL 语句相同:

SELECT  FROM user WHERE status = "ok" AND age < 30
  • 指定 OR 条件:您还可以使用$or选择器创建 OR 条件,以获取至少满足一个条件的文档。例如,以下行获取status等于ok age小于($lt) 30 的文档:
> db.user.find( { $or: [ { status: "ok" }, { age: { $lt: 30 } } ] } )

此操作与以下 SQL 语句相同:

SELECT  FROM user WHERE status = "ok" OR age < 30

您应该得到类似于这样的结果:

{ "_id" : ObjectId("5ca..."), "name" : "Pooya", "age" : 25, ... }

您可以在docs.mongodb.com/manual/reference/operator/query/找到有关查询和投影操作符的更多信息,以及在docs.mongodb.com/manual/reference/operator/query/logical找到$or选择器的更多信息。

现在,我们感兴趣的下一件事是更新现有文档,所以让我们继续下一节。

更新文档

我们可以使用updateOneupdateMany方法更新现有文档,如下所示:

  • 更新单个文档:我们可以像这样更新现有文档:
> db.<collection>.updateOne(<filter>, <update>, <options>)

让我们使用$set操作符在<update>参数中更新数据,更新<filter>参数中name等于Sébastien的文档,如下所示:

> db.user.updateOne(
   { name: "Sébastien" },
   {
     $set: { status: "ok" },
     $currentDate: { lastModified: true }
   }
)

您应该得到以下结果:

{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

$set操作符用于用新值替换字段的值。它采用以下格式:

{ $set: { <field1>: <value1>, ... } }

$currentDate操作符用于将字段的值设置为当前日期。它返回的值可以是人类可读的日期(默认值),例如2013-10-02T01:11:18.965Z,也可以是时间戳,例如1573612039

您可以在docs.mongodb.com/manual/reference/operator/update/set/找到有关$set运算符的更多信息。您可以在docs.mongodb.com/manual/reference/operator/update/currentDate/找到有关$currentDate的更多信息。

  • 更新多个文档:我们可以像这样更新多个现有文档:
> db.<collection>.updateMany(<filter>, <update>, <options>)

让我们更新statusok的文档:

> db.user.updateMany(
   { "status": "ok" },
   {
     $set: { status: "pending" },
     $currentDate: { lastModified: true }
   }
)

你应该得到以下结果:

{ "acknowledged" : true, "matchedCount" : 3, "modifiedCount" : 3 }

您可以在docs.mongodb.com/manual/reference/operator/update/找到有关更新运算符的更多信息。

  • 替换文档:我们可以像这样替换现有文档的内容,除了_id字段:
> db.<collection>.replaceOne(<filter>, <replacement>, <options>)

让我们按如下方式用<replacement>参数替换name等于Pooya的文档为全新的文档:

> db.user.replaceOne(
    { name: "Pooya" },
    {
      name: "Paula",
      age: "31",
      slug: "paula",
      role: "admin",
      status: "ok"
    }
)

你应该得到以下结果:

{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

在学习如何更新现有文档之后,你应该学会的下一件事是如何删除现有文档。让我们深入下一节。

删除文档

我们可以通过以下方式使用deleteOnedeleteMany方法删除现有文档:

  • 仅删除匹配条件的一个文档:我们可以像这样删除现有文档:
> db.<collection>.deleteOne(<filter>, <options>)

让我们按如下方式删除status字段等于pending的文档:

> db.user.deleteOne( { status: "pending" } )

你应该得到以下结果:

{ "acknowledged" : true, "deletedCount" : 3 }
  • 删除匹配条件的文档:我们可以像这样删除多个现有文档:
> db.<collection>.deleteMany(<filter>, <options>)

让我们删除status字段等于ok的文档:

> db.user.deleteMany({ status : "ok" })

你应该得到以下结果:

{ "acknowledged" : true, "deletedCount" : 2 }
  • 删除所有文档:我们可以通过将空过滤器传递给deleteMany方法来删除集合中的所有文档,如下所示:
> db.<collection>.deleteMany({})

让我们使用以下代码从user集合中删除所有文档:

> db.user.deleteMany({})

你应该得到以下结果:

{ "acknowledged" : true, "deletedCount" : 1 }

干得好!您已经成功完成了这些部分中的 MongoDB CRUD 操作。您可以在docs.mongodb.com/manual/reference/method/js-collection/找到更多其他方法。在下一节中,我们将指导您如何使用 MongoDB 驱动程序将 CRUD 操作与服务器端框架集成。让我们开始吧。

将 MongoDB 与 Koa 集成

我们已经学习了一些通过 MongoDB Shell 执行 CRUD 操作的 MongoDB 查询。现在我们只需要 MongoDB 驱动程序来帮助我们连接到 MongoDB 服务器,并执行与 MongoDB Shell 相同的 CRUD 操作。我们将在我们的服务器端框架 Koa 中将此驱动程序安装为依赖项。

安装 MongoDB 驱动程序

Node.js 应用程序的官方 MongoDB 驱动程序是mongodb。它是一个高级 API,构建在 MongoDB 核心驱动程序mongodb-core之上,后者是一个低级 API。前者是为最终用户而设计的,而后者是为 MongoDB 库开发人员而设计的。mongodb包含了使 MongoDB 连接、CRUD 操作和身份验证变得容易的抽象和辅助功能,而mongodb-core只包含 MongoDB 拓扑连接的基本管理、核心 CRUD 操作和身份验证。

关于这两个软件包的更多信息,请访问以下网站:

我们可以使用 npm 安装 MongoDB 驱动程序:

$ npm i mongodb

接下来,我们将在接下来的部分中通过一个快速示例来了解如何使用它。

使用 MongoDB 驱动程序创建一个简单的应用程序

让我们使用 MongoDB 驱动程序设置一个简单的应用程序来执行简单的连接检查。在这个测试中,我们将使用我们在上一章中介绍的 Backpack 构建系统来运行我们的测试。所以,让我们按照以下步骤开始:

  1. 按照前一节中所示的步骤安装 MongoDB 驱动程序,然后安装 Backpack 和 cross-env:
$ npm i backpack-core
$ npm i cross-env
  1. 创建一个/src/文件夹作为默认入口目录,并在其中创建一个index.js文件,然后从 Node.js 中导入 MongoDB 驱动程序和 Assert 模块,如下所示:
// src/index.js
import { MongoClient } from 'mongodb'
import assert from 'assert'

const url = 'mongodb://localhost:27017'
const dbName = 'nuxt-app'

在这一步中,我们还应该提供 MongoDB 连接详细信息:MongoDB 服务器的默认地址是mongodb://localhost:27017,我们要连接的数据库是nuxt-app

请注意,Assert 是一个 Node.js 内置模块,其中包含一组用于单元测试代码的断言函数,因此我们不必安装此模块。如果您想了解更多关于此模块的信息,请访问nodejs.org/api/assert.html#assert_assert

  1. 接下来,在 MongoDB 服务器中建立与数据库的连接,并使用 Assert 来确认连接,如下所示:
// src/index.js
MongoClient.connect(url, {
  useUnifiedTopology: true,
  useNewUrlParser: true 
  }, (err, client) => {
  assert.equal(null, err)
  console.log('Connected to the MongoDB server')

  const db = client.db(dbName)
  client.close()
})

在这个例子中,我们使用了assert模块中的equal方法来确保在使用client回调创建数据库实例之前,err回调是null的。每当完成一个任务时,我们应该始终使用close方法关闭连接。

  1. 如果您在终端上使用npm run dev运行此连接测试,您应该在终端上获得以下输出:
Connected successfully to server 

您可以在我们的 GitHub 存储库的/chapter-9/mongo-driver/中找到这个简单的例子。

请注意,我们连接到 MongoDB 时没有进行任何身份验证,因为我们尚未保护我们的 MongoDB。您将在本书的最后一章[第十八章]“使用 CMS 和 GraphQL 创建 Nuxt 应用程序”中学习如何设置新的管理用户来保护您的 MongoDB。为了降低学习曲线并加快本章后续部分的开发过程,我们将选择不保护 MongoDB。现在,让我们更深入地了解如何配置 MongoDB 驱动程序。

配置 MongoDB 驱动程序

从前一节的代码中,您可以看到我们在执行 MongoDB CRUD 任务时应该始终导入MongoClient,提供 MongoDB 服务器 URL、数据库名称等。这可能很繁琐且低效。让我们将前面的 MongoDB 连接代码抽象成一个类,以下是具体步骤:

  1. 将数据库连接细节抽象到一个文件中:
// server/config/mongodb.js
const database = {
  host: 'localhost',
  port: 27017,
  dbname: 'nuxt-app'
}

export default {
  host: database.host,
  port: database.port,
  dbname: database.dbname,
  url: 'mongodb://' + database.host + ':' + database.port
}
  1. 创建一个class函数来构建数据库连接,这样我们在执行 CRUD 操作时就不必重复这个过程。我们还在class函数中构建了一个objectId属性,用于存储我们需要的ObjectId方法,以便解析来自客户端的 ID 数据,使这个 ID 数据从字符串变为对象:
// server/mongo.js
import mongodb from 'mongodb'
import config from './config/mongodb'

const MongoClient = mongodb.MongoClient

export default class Mongo {
  constructor () {
    this.connection = null
    this.objectId = mongodb.ObjectId
  }

  async connect () {
    this.connection = await MongoClient.connect(config.url, {
      useUnifiedTopology: true,
      useNewUrlParser: true
    })
    return this.connection.db(config.dbname)
  }

  close () {
    this.connection.close()
  }
}
  1. 导入class并使用new语句进行实例化,如下所示:
import Mongo from './mongo'
const mongo = new Mongo()

例如,我们可以在需要连接到 MongoDB 数据库执行 CRUD 操作的 API 路由中导入它,如下所示:

// server/routes.js
import Router from 'koa-router'
import Mongo from './mongo'
const mongo = new Mongo()
const router = new Router({ prefix: '/api' })

router.post('/user', async (ctx, next) => {
  //...
})

在使用 MongoDB 驱动程序和我们的服务器端框架 Koa 创建 CRUD 操作之前,我们应该了解ObjectIdObjectId方法。让我们开始吧。

理解 ObjectId 和 ObjectId 方法

ObjectId是 MongoDB 在集合中用作主键的快速生成且可能唯一的值。它由 12 个字节组成;时间戳占据前 4 个字节,记录了ObjectId值创建时的时间。它存储在集合中每个文档的唯一_id字段中。如果在注入文档时没有声明,此_id字段将自动生成。另一方面,ObjectId(<十六进制>)是我们可以使用的 MongoDB 方法,用于返回一个新的ObjectId值,并将ObjectId值从字符串解析为对象。这里有一个例子:

// Pseudo code
var id = '5d2ba2bf089a7754e9094af5'
console.log(typeof id) // string
console.log(typeof ObjectId(id)) // object

在前面的伪代码中,您可以看到我们使用ObjectId方法创建的对象中的getTimestamp方法来从ObjectId值中获取时间戳。这里有一个例子:

// Pseudo code
var object = ObjectId(id)
var timestamp = object.getTimestamp()
console.log(timestamp) // 2019-07-14T21:46:39.000Z

有关ObjectIdObjectId方法的更多信息,请查看以下链接:

现在,让我们在接下来的部分中使用 MongoDB 驱动程序编写一些 CRUD 操作。首先,我们将编写注入文档的操作。

注入一个文档

在开始之前,我们应该看一下我们将要创建的每个路由所需的代码结构:

// server/routes.js
router.get('/user', async (ctx, next) => {
  let result
  try {
    const connection = await mongo.connect()
    const collectionUsers = connection.collection('users')
    result = await collectionUsers...
    mongo.close()
  } catch (err) {
    ctx.throw(500, err)
  }
  ctx.type = 'json'
  ctx.body = result
})

让我们讨论一下结构:

  • 捕获和抛出错误:当我们使用async/await语句而不是Promise对象进行异步操作时,我们必须始终将它们包装在try/catch块中以处理错误。
try {
  // async/await code
} catch (err) {
  // handle error
}
  • 连接到 MongoDB 数据库和集合:在执行任何 CRUD 操作之前,我们必须建立连接并连接到我们想要操作的特定集合。在我们的情况下,集合是users
const connection = await mongo.connect()
const collectionUsers = connection.collection('users')
  • 执行 CRUD 操作:这是我们使用 MongoDB API 方法读取、注入、更新和删除用户的地方:
result = await collectionUsers...
  • 关闭 MongoDB 连接:在执行 CRUD 操作后,我们必须确保关闭连接:
mongo.close()

现在让我们使用前面的代码结构来在以下步骤中注入新用户:

  1. 创建一个使用post方法来注入新用户文档的路由:
// server/routes.js
router.post('/user', async (ctx, next) => {
  let result
  //...
})
  1. post路由内,在执行与 MongoDB 的 CRUD 操作之前,对从客户端接收到的键和值进行检查:
let body = ctx.request.body || {}

if (body.name === undefined) {
  ctx.throw(400, 'name is undefined')
}
if (body.slug === undefined) {
  ctx.throw(400, 'slug is undefined')
}
if (body.name === '') {
  ctx.throw(400, 'name is required')
}
if (body.slug === '') {
  ctx.throw(400, 'slug is required')
}
  1. 在允许将新文档注入到“用户”集合之前,我们希望确保slug值尚不存在。为此,我们需要使用带有slug键的findOneAPI 方法。如果结果是积极的,那意味着slug值已被其他用户文档占用,因此我们向客户端抛出错误:
const found = await collectionUsers.findOne({
  slug: body.slug
})
if (found) {
  ctx.throw(404, 'slug has been taken')
}
  1. 如果slug是唯一的,那么我们使用insertOneAPI 方法来注入具有提供的数据的新文档:
result = await collectionUsers.insertOne({
  name: body.name,
  slug: body.slug
})

在注入文档之后,我们需要做的下一件事是获取和查看我们已经注入的文档,这将在下一节中进行。

获取所有文档

在将用户添加到users集合后,我们可以通过在第八章中创建的路由中检索所有或其中一个用户。现在我们只需要重构它们,使用与上一节中获取数据库中真实数据相同的代码结构:

  1. 重构使用get方法列出所有用户文档的路由:
// server/routes.js
router.get('/users', async (ctx, next) => {
  let result
  //...
})
  1. get路由内,使用findAPI 方法从user集合中获取所有文档:
result = await collectionUser.find({
}, {
  // Exclude some fields
}).toArray()

如果要从查询结果中排除字段,请使用projection键和值0来表示不想在结果中显示的字段。例如,如果不希望在结果中的每个文档中看到_id字段,可以这样做:

projection:{ _id: 0 }
  1. 重构使用get方法获取用户文档的路由:
// server/routes.js
router.get('/users/:id', async (ctx, next) => {
  let result
  //...
})
  1. 使用findOne方法通过_id获取单个文档。我们必须使用ObjectId方法解析id字符串,我们在constructor函数中的class函数中有一个副本,名为objectId
const id = ctx.params.id
result = await collectionUsers.findOne({
  _id: mongo.objectId(id)
}, {
  // Exclude some fields
})

mongo.objectId(id)方法将id字符串解析为ObjectID对象,然后我们可以使用它来从集合中查询文档。现在我们可以获取我们创建的文档,接下来需要做的是更新它们。让我们在下一节中进行。

更新一个文档

在将用户添加到users集合后,我们还可以在以下步骤中使用与上一节中相同的代码结构来更新它们:

  1. 创建一个带有put方法的路由,用于更新现有用户文档如下:
// server/routes.js
router.put('/user', async (ctx, next) => {
  let result
  //...
})
  1. 在更新文档之前,我们希望确保slug值是唯一的。因此,在put路由内,我们使用findOne API 和$ne来排除我们正在更新的文档。如果没有匹配项,那么我们将使用updateOne API 方法来更新文档:
const found = await collectionUser.findOne({
  slug: body.slug,
  _id: { $ne: mongo.objectId(body.id) }
})
if (found) {
  ctx.throw(404, 'slug has been taken')
}

result = await collectionUser.updateOne({
  _id: mongo.objectId(body.id)
}, {
   $set: { name: body.name, slug: body.slug },
   $currentDate: { lastModified: true }
})

我们在这个 CRUD 操作中使用了三个操作符:$set操作符,$currentDate操作符和$ne选择器。这些是您经常用于更新文档的一些更新操作符和查询选择器:

  • 更新操作符$set操作符用于以以下格式替换字段的值为新指定的值:
{ $set: { <field1>: <value1>, ... } }

$currentDate操作符用于将当前日期设置为指定字段,可以是 BSON 日期类型(默认)或 BSON 时间戳类型,格式如下:

{ $currentDate: { <field1>: <typeSpecification1>, ... } }

有关这两个和其他更新操作符的更多信息,请访问docs.mongodb.com/manual/reference/operator/update/

  • 查询选择器$ne选择器用于选择字段值不等于指定值的文档,包括那些不包含该字段的文档。以下是一个例子:
db.user.find( { age: { $ne: 18 } } )

此查询将选择user集合中所有age字段值不等于18的文档,包括那些不包含age字段的文档。

有关此及其他查询选择器的更多信息,请访问docs.mongodb.com/manual/reference/operator/query/

现在,让我们看看如何在下一节中删除我们创建的文档。

删除一个文档

最后,我们还将使用与上一节相同的代码结构来从users集合中删除现有用户,步骤如下:

  1. 创建一个带有del方法的路由来删除现有用户文档:
// server/routes.js
router.del('/user', async (ctx, next) => {
  let result
  //...
})
  1. del路由内使用deleteOne API 方法删除文档之前,我们通常会使用findOne API 方法来查找user集合中的文档,以确保我们首先拥有它:
let body = ctx.request.body || {}
const found = await collectionUser.findOne({
  _id: mongo.objectId(body.id)
})
if (!found) {
  ctx.throw(404, 'no user found')
}

result = await collectionUser.deleteOne({
  _id: mongo.objectId(body.id)
})

干得好!您已成功编写了 MongoDB CRUD 操作并将其集成到 API(Koa)中。本章的最后一部分涉及将这些操作与 Nuxt 页面集成。让我们在下一节中进行。

与 Nuxt 页面集成

我们已经准备好了服务器端,现在我们需要在客户端上创建用户界面,以便我们可以发送和获取数据。我们将在/pages/users/目录中创建三个新页面。这是我们的结构:

users
├── index.vue
├── _id.vue
├── add
│ └── index.vue
├── update
│ └── _id.vue
└── delete
  └── _id.vue

一旦我们有了结构,我们就准备好在接下来的部分从 Nuxt 端(客户端)创建页面并编写 CRUD 任务。让我们从下一部分的创建 CRUD 任务开始。

创建一个添加新用户的添加页面

我们将按照以下步骤创建此页面与服务器端的POST路由/api/user/进行通信,以添加新用户:

  1. <template>块中创建一个表单来收集新用户数据,如下所示:
// pages/users/add/index.vue
<form v-on:submit.prevent="add">
  <p>Name: <input v-model="name" type="text" name="name"></p>
  <p>Slug: <input v-model="slug" type="text" name="slug"></p>
  <button type="submit">Add</button>
  <button v-on:click="cancel">Cancel</button>
</form>
  1. <script>块中创建一个add方法来将数据发送到服务器,并创建一个cancel方法来取消表单,如下所示:
// pages/users/add/index.vue
export default {
  methods: {
    async add () {
      let { data } = await axios.post('/api/user/', {
        name: this.name,
        slug: this.slug,
      })
    },
    cancel () {
      this.$router.push('/users/')
    }
  }
}

通过这两个步骤,我们已经成功在客户端(Nuxt)与服务器端(API)上建立了创建 CRUD 任务。现在,您可以使用刚刚创建的表单从客户端在localhost:3000/users/add向数据库添加新用户,并将它们发送到 API 的POST路由localhost:3000/api/user/。在能够添加新用户之后,我们应该继续在客户端进行更新 CRUD 任务。让我们开始吧。

为更新现有用户创建一个更新页面

更新页面基本上与添加页面非常相似。此页面将与服务器端的PUT路由/api/user/通信,以更新现有用户,步骤如下:

  1. <template>块中创建一个表单来显示现有数据并收集新数据。更新页面的区别在于我们绑定到<form>元素的方法:
// pages/users/update/_id.vue
<form v-on:submit.prevent="update">
  //...
  <button type="submit">Update</button>
</form>
  1. <script>块中创建一个update方法来将数据发送到服务器。我们将使用asyncData方法来获取现有数据,如下所示:
// pages/users/update/_id.vue
export default {
  async asyncData ({ params, error }) {
    let { data } = await axios.get('/api/users/' + params.id)
    let user = data.data
    return { 
      id: user._id, 
      name: user.name, 
      slug: user.slug,
    }
  },
  methods: {
    async update () {
      let { data } = await axios.put('/api/user/', {
        name: this.name,
        slug: this.slug,
        id: this.id,
      })
    }
  }
}

同样,在客户端(Nuxt)与服务器端(API)上成功建立了更新 CRUD 任务的这两个步骤。现在,您可以使用表单从客户端在localhost:3000/users/update更新数据库中的现有用户,并将它们发送到 API 的PUT路由localhost:3000/api/user/。在能够更新用户之后,我们现在应该继续在客户端进行删除 CRUD 任务。让我们开始吧。

创建一个删除页面来删除现有用户

此页面将与服务器端的DELETE路由/api/user/通信,以删除现有用户:

  1. <template>块中创建一个<button>元素,我们可以使用它来删除文档。我们不需要一个表单来发送数据,因为我们可以在remove方法中收集数据(仅为文档_id数据)。我们只需要按钮来触发这个方法,如下所示:
// pages/users/delete/_id.vue
<button v-on:click="remove">Delete</button>
  1. 创建remove方法,将数据发送到服务器,就像我们在<script>块中解释的那样。但首先,我们需要使用asyncData方法来获取现有数据。
// pages/users/delete/_id.vue
export default {
 async asyncData ({ params, error }) {
    // Fetch the existing user
    // Same as in update page
  },
  methods: {
    async remove () {
      let payload = { id: this.id }
      let { data } = await axios.delete('/api/user/', {
        data: payload,
      })
    }
  }
}

最后,我们已经成功在客户端(Nuxt)和服务器端(API)上完成了删除 CRUD 任务的两个步骤。现在,您可以通过在localhost:3000/users/delete发送用户数据(仅为 ID),并将其发送到 API 的DELETE路由localhost:3000/api/user/,从客户端删除数据库中的现有用户。因此,如果您使用npm run dev启动应用程序,您应该可以在localhost:3000上看到它运行。

导航到以下路由以添加、更新、读取和删除用户:

  • localhost:3000/users 用于读取/列出所有用户

  • localhost:3000/users/add 用于添加新用户

  • localhost:3000/users/update/<id> 用于按 ID 更新现有用户

  • localhost:3000/users/delete/<id> 用于按 ID 删除现有用户

干得好!您终于成功完成了本章设定的里程碑。对于初学者来说,MongoDB 可能是一个令人难以置信的主题,但是如果您按照本章中设定的指南和里程碑进行操作,您可以轻松创建一个相当不错的 API。当您需要超越我们在本书中解释的 CRUD 操作时,请使用我们提供的链接。现在让我们总结一下您在本章学到的内容。

您可以在我们的 GitHub 存储库的/chapter-9/nuxt-universal/koa-mongodb/axios/中找到我们为本章创建的代码。

总结

在本章中,您已经学会了如何在本地计算机上安装 MongoDB,并在 MongoDB Shell 上使用一些基本的 MongoDB 查询进行 CRUD 操作。您还学会了如何安装和使用 MongoDB 驱动程序来从服务器端框架连接到 MongoDB,并编写了在 Koa 环境中执行 CRUD 操作的代码。最后,您已经从客户端 Nuxt 创建了前端页面,用于向 MongoDB 数据库添加新用户,并通过与使用 Koa 开发的 API 进行通信来更新和删除现有用户。

在下一章中,我们将探索 Vuex 存储并在 Nuxt 应用程序中使用它。在安装和编写 Vue 应用程序中的简单 Vuex 存储之前,您将了解 Vuex 架构。您还将学习有关 Vuex 核心概念的知识,包括状态、获取器、操作和模块,然后使用这些概念在 Nuxt 应用程序中编写 Vuex 存储。我们将引导您完成这些内容,敬请关注。

添加 Vuex 存储

拥有像 MongoDB 这样的数据库系统来管理我们的数据是很棒的,因为我们可以使用它在需要时远程请求我们路由的数据。然而,偶尔我们需要在页面或组件之间共享一些数据,并且我们不希望为这种数据进行额外和不必要的 HTTP 请求。理想情况下,我们希望在本地应用程序中有一个中心位置来存储这种“无处不在”的和集中的数据。幸运的是,我们有一个名为 Vuex 的系统来为我们存储这种数据,这就是你将在本章中探索的内容。因此,在本章中,您将学习如何在应用程序中使用 Vuex 进行状态管理(集中式数据管理)。您将了解 Vuex 的架构、其核心概念以及管理模块化 Vuex 存储的建议目录结构。最后,您将学习如何在 Nuxt 应用程序中激活和使用 Vuex 存储。

本章我们将涵盖的主题如下:

  • 理解 Vuex 的架构

  • 开始使用 Vuex

  • 理解 Vuex 的核心概念

  • 构建 Vuex 存储模块

  • 在 Vuex 存储中处理表单

  • 在 Nuxt 中使用 Vuex 存储

第十章:理解 Vuex 架构

在学习如何在 Nuxt 应用程序中使用 Vuex 存储之前,我们应该了解它在标准 Vue 应用程序中的工作原理。但是什么是 Vuex?让我们在接下来的部分中找出来。

什么是 Vuex?

简而言之,Vuex 是一个集中式数据(也称为状态)管理系统,具有一些规则(我们稍后会详细了解),以确保状态只能可预测地从需要访问共同数据的多个(远程)组件中进行变异。这种信息集中的想法在 Redux 等工具中很常见。它们都与 Vuex 共享类似的状态管理模式。让我们在下一节看看这种模式是什么。

状态管理模式

为了理解 Vuex 中的状态管理模式,让我们看一个我们已经熟悉的简单 Vue 应用程序:

<div id="app"></div>

new Vue({
  // state
  data () {
    return { message: '' }
  },

  // view
  template: `
    <div>
      <p>{{ message }}</p>
      <button v-on:click="greet">Greet</button>
    </div>
  `,

  // actions
  methods: {
    greet () {
      this.message = 'Hello World'
    }
  }
}).$mount('#app')

这个简单的应用程序有以下部分:

  • state,保存应用程序的源

  • view,映射状态

  • actions,可以用于从视图中变异状态

它们在这样一个小应用程序中运行得很好,并且很容易管理,但是当我们有两个或更多组件共享相同状态时,或者当我们想要使用来自不同视图的操作来变异状态时,这种简单性就变得不可持续和有问题了。

传递 props 可能是你脑海中浮现的解决方案,但对于嵌套组件来说这很繁琐。这就是 Vuex 的作用,将通用状态提取出来并在一个特定位置全局管理,称为store,以便任何组件都可以从任何地方访问它,无论嵌套多深。

因此,使用状态管理进行分离并强制执行一些规则可以保持视图和状态的独立性。使用这种方法,我们可以使我们的代码更加结构化和可维护。让我们来看一下 Vuex 的架构,如下图所示:

参考来源:vuex.vuejs.org/

简而言之,Vuex 由 actions、mutations 和 state 组成。状态始终通过 mutations 进行变化,而 mutations 则始终通过 Vuex 生命周期中的 actions 进行提交。变化后的状态然后被渲染到组件中,同时,actions 通常会从组件中派发。与后端 API 的通信通常发生在 actions 中。让我们在下一节开始使用 Vuex,并深入了解其构成。

开始使用 Vuex

正如我们在前一节中提到的,所有 Vuex 活动都发生在一个 store 中,这个 store 可以简单地在项目根目录中创建。然而,虽然看起来很简单,但 Vuex store 与普通的 JavaScript 对象不同,因为 Vuex store 是响应式的,就像使用 v-model 指令在 <input> 元素上进行的双向绑定一样。因此,当你在 Vue 组件中访问任何状态数据时,当它在 store 中发生变化时,它会被响应式地更新。在 store 的状态中的数据必须通过 mutations 显式地提交,就像我们在前一节的图表中解释的那样。

在这个练习中,我们将使用单文件组件骨架来构建一些简单的带有 Vuex 的 Vue 应用程序。我们将把所有示例代码放在我们的 GitHub 仓库的 /chapter-10/vue/vuex-sfc/ 中。让我们开始吧。

安装 Vuex

在我们创建 Vuex store 之前,我们必须通过以下步骤安装 Vuex 并导入它:

  1. 使用 npm 安装 Vuex:
$ npm i vuex
  1. 使用 Vue.use() 方法导入并注册它:
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

请记住,前面的安装步骤是为了在模块系统中使用 Vuex,这也是我们在本章中要做的。但在跳入模块系统应用程序之前,我们应该看一下如何通过 CDN 或直接下载来创建 Vuex 应用程序。

请注意,Vuex 需要 Promise 支持。如果您的浏览器不支持 Promise,请查看如何为您的应用安装 polyfill 库的方法,网址为vuex.vuejs.org/installation.html#promise

创建一个简单的 store

我们可以通过以下步骤使用 CDN 或直接下载开始一个简单的 store:

  1. 使用 HTML 的<script>块安装 Vue 和 Vuex:
<script src="/path/to/vue.js"></script>
<script src="/path/to/vuex.js"></script>
  1. 在 HTML 的<body>块中激活 Vuex store:
<script type="text/javascript">
  const store = new Vuex.Store({
    state: { count: 0 },
    mutations: {
      increment (state) { state.count++ }
    }
  })
  store.commit('increment')
  console.log(store.state.count) // -> 1
</script>

从这段代码中可以看出,你只需要在一个 JavaScript 对象中创建 Vuex 状态,一个 mutation 方法,然后你可以通过 store 的state键访问状态对象,并使用 store 的commit方法触发状态的改变,如下所示:

store.commit('increment')
console.log(store.state.count)

在这个简单的例子中,我们遵守了 Vuex 中的一个强制规则,即通过提交 mutation 来改变状态数据,而不是直接改变它。让我们在下一节通过创建模块系统应用来深入了解 Vuex 的核心概念和其他规则。

理解 Vuex 的核心概念

在本节中,我们将指导您了解 Vuex 中的五个核心概念。它们是 state、getters、mutations、actions 和 modules。我们将从下一节开始首先研究 state 的概念。

状态

状态是 Vuex 存储的核心。它是我们可以以结构化和可预测的方式管理和维护的“全局”数据的来源。在 Vuex 中,状态是一个单一的状态树——一个包含所有应用状态数据的单一 JavaScript 对象。因此,通常每个应用程序只有一个 store。让我们看看如何在下面的部分中将状态传递给组件。

访问状态

正如我们在上一节中提到的,Vuex 存储是响应式的,但如果我们想在视图中访问响应式值,我们应该使用computed属性而不是data方法,如下所示:

// vuex-sfc/state/basic/src/app.vue
<p>{{ number }}</p>

import Vue from 'vue/dist/vue.js'
import Vuex from 'vuex'
Vue.use(Vuex)

const store = new Vuex.Store({
  state: { number: 1 }
})

export default {
  computed: {
    number () {
      return store.state.number
    }
  }
}

现在,在<template>块中的number字段是响应式的,computed属性将重新评估并更新 DOM,每当store.state.number发生变化时。但是这种模式会导致耦合问题,并违背了 Vuex 的抽取理念。因此,让我们通过以下步骤重构前面的代码:

  1. 将 store 提取到根组件中:
// vuex-sfc/state/inject/src/entry.js
import Vue from 'vue/dist/vue.js'
import App from './app.vue'

import Vuex from 'vuex'
Vue.use(Vuex)

const store = new Vuex.Store({
  state: { number: 0 }
})

new Vue({
  el: 'app',
  template: '<App/>',
  store,
  components: {
    App
  }
})
  1. 从子组件中移除 store,但保持computed属性不变:
// vuex-sfc/state/inject/src/app.vue
<p>{{ number }}</p>

export default {
  computed: {
    number () {
      return this.$store.state.number
    }
  }
}

在更新的代码中,存储现在被注入到子组件中,您可以通过从组件中使用this.$store来访问它。但是,当您需要使用computed属性计算许多存储状态属性时,这种模式可能会变得重复和冗长。在这种情况下,我们可以使用mapState助手来减轻负担。让我们在下一节中看看如何使用它。

mapState 助手

我们可以使用mapState助手来帮助我们生成计算状态函数,以节省一些代码行和按键次数,具体步骤如下:

  1. 创建一个具有多个状态属性的存储:
// vuex-sfc/state/mapstate/src/entry.js
const store = new Vuex.Store({
  state: {
    experience: 1,
    name: 'John',
    age: 20,
    job: 'designer'
  }
})
  1. 从 Vuex 中导入mapState助手,并将状态属性作为数组传递给mapState方法:
// vuex-sfc/state/mapstate/src/app.vue
import { mapState } from 'vuex'

export default {
  computed: mapState([
    'experience', 'name', 'age', 'job'
  ])
}

只要映射的计算属性的名称与状态属性名称相同,这就可以完美地工作。但是,最好使用对象扩展运算符,以便我们可以在computed属性中混合多个mapState助手:

computed: {
  ...mapState({
    // ...
  })
}

例如,您可能希望根据子组件中的数据计算状态数据,如下所示:

// vuex-sfc/state/mapstate/src/app.vue
import { mapState } from 'vuex'

export default {
  data () {
    return { localExperience: 2 }
  },
  computed: {
    ...mapState([
      'experience', 'name', 'age', 'job'
    ]),
    ...mapState({
      experienceTotal (state) {
        return state.experience + this.localExperience
      }
    })
  }
}

您还可以传递一个字符串值来为experience状态属性创建一个别名,如下所示:

...mapState({
  experienceAlias: 'experience'
})
  1. 将计算状态属性添加到<template>中,如下所示:
// vuex-sfc/state/mapstate/src/app.vue
<p>{{ name }}, {{ age }}, {{ job }}</p>
<p>{{ experience }}, {{ experienceAlias }}, {{ experienceTotal }}</p>

您应该在浏览器上获得以下结果:

John, 20, designer
1, 1, 3

您可能会想知道,既然我们可以在子组件中计算状态数据,那么我们是否可以在存储本身中计算状态数据?答案是肯定的,我们可以通过 getter 来实现,我们将在下一节中介绍。让我们开始吧。

getter

您可以在存储的getters属性中定义 getter 方法,以在子组件中使用视图之前计算状态。就像computed属性一样,getter 中的计算结果是响应式的,但它是被缓存的,并且会在其依赖项发生更改时更新。getter 以状态作为第一个参数,以getters作为第二个参数。让我们创建一些 getter 并在子组件中使用它们,以下是具体步骤:

  1. 创建一个具有项目列表的state属性的存储,并为访问这些项目创建一些 getter:
// vuex-sfc/getters/basic/src/entry.js
const store = new Vuex.Store({
  state: {
    fruits: [
      { name: 'strawberries', type: 'berries' },
      { name: 'orange', type: 'citrus' },
      { name: 'lime', type: 'citrus' }
    ]
  },
  getters: {
    getCitrus: state => {
      return state.fruits.filter(fruit => fruit.type === 'citrus')
    },
    countCitrus: (state, getters) => {
      return getters.getCitrus.length
    },
    getFruitByName: (state, getters) => (name) => {
      return state.fruits.find(fruit => fruit.name === name)
    }
  }
})

在此存储中,我们创建了getCitrus方法来获取所有类型为citrus的项目,并且countCitrus方法依赖于getCitrus方法的结果。第三个方法getFruitByName用于通过柑橘名称获取列表中的特定项目。

  1. computed属性中创建一些方法来执行存储中的 getter,如下所示:
// vuex-sfc/getters/basic/src/app.vue
export default {
  computed: {
    totalCitrus () {
      return this.$store.getters.countCitrus
    },
    getOrange () {
      return this.$store.getters.getFruitByName('orange')
    }
  }
}
  1. 将计算状态属性添加到<template>中,如下所示:
// vuex-sfc/getters/basic/src/app.vue
<p>{{ totalCitrus }}</p>
<p>{{ getOrange }}</p>

您应该在浏览器中获得以下结果:

2
{ "name": "orange", "type": "citrus" }

mapState助手一样,我们可以在computed属性中使用mapGetters助手,这样可以节省一些行和按键。让我们在下一节中进行。

mapGetters 助手

就像mapState助手一样,我们可以使用mapGetters助手将存储获取器映射到computed属性中。让我们看看如何在以下步骤中使用它:

  1. 从 Vuex 中导入mapGetters助手,并将获取器作为数组传递给mapGetters方法,使用对象扩展运算符,以便我们可以在computed属性中混合多个mapGetters助手:
// vuex-sfc/getters/mapgetters/src/app.vue
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters([
      'countCitrus'
    ]),
    ...mapGetters({
      totalCitrus: 'countCitrus'
    })
  }
}

在上述代码中,我们通过将字符串值传递给totalCitrus键为countCitrus获取器创建了别名。请注意,使用对象扩展运算符,我们还可以在computed属性中混合其他原始方法。因此,让我们在这些mapGetters助手之上的computed选项中添加一个原始的getOrange获取器方法,如下所示:

// vuex-sfc/getters/mapgetters/src/app.vue
export default {
  computed: {
    // ... mapGetters
    getOrange () {
      return this.$store.getters.getFruitByName('orange')
    }
  }
}
  1. 将计算状态属性添加到<template>中,如下所示:
// vuex-sfc/getters/mapgetters/src/app.vue
<p>{{ countCitrus }}</p>
<p>{{ totalCitrus }}</p>
<p>{{ getOrange }}</p>

您应该在浏览器中获得以下结果:

2
2
{ "name": "orange", "type": "citrus" }

到目前为止,您已经学会了如何通过使用计算方法和获取器来访问存储中的状态。那么改变状态呢?让我们在下一节中进行。

变异

就像我们在前面的部分中提到的一样,存储状态必须通过变异显式提交。变异就像存储属性中学到的任何其他函数一样,它必须在存储的mutations属性中定义,它总是以状态作为第一个参数。让我们创建一些变异并在子组件中使用它们,如下所示:

  1. 创建一个带有state属性和一些变异方法的存储,我们可以使用这些方法来改变状态,如下所示:
// vuex-sfc/mutations/basic/src/entry.js
const store = new Vuex.Store({
  state: { number: 1 },
  mutations: {
    multiply (state) {
      state.number = state.number * 2
    },
    divide (state) {
      state.number = state.number / 2
    },
    multiplyBy (state, n) {
      state.number = state.number  n
    }
  }
})
  1. 在组件中创建以下方法,通过使用this.$store.commit来调用提交变异:
// vuex-sfc/mutations/basic/src/app.js
export default {
  methods: {
    multiply () {
      this.$store.commit('multiply')
    },
    multiplyBy (number) {
      this.$store.commit('multiply', number)
    },
    divide () {
      this.$store.commit('divide')
    }
  }
}

就像获取方法一样,您还可以在变异方法上使用mapMutations助手,所以让我们在下一节中进行。

mapMutations 助手

我们可以使用mapMutations助手将组件方法映射到变异方法,以便我们可以在method属性中混合多个mapMutations助手。让我们看看如何在以下步骤中做到这一点:

  1. 从 Vuex 中导入mapMutations辅助程序,并使用对象扩展运算符将变异作为数组传递给mapMutations方法,如下所示:
// vuex-sfc/mutations/mapmutations/src/app.vue
import { mapMutations } from 'vuex'

export default {
  computed: {
    number () {
      return this.$store.state.number
    }
  },
  methods: {
    ...mapMutations([
      'multiply',
      'multiplyBy',
      'divide'
    ]),
    ...mapMutations({
      square: 'multiply'
    })
  }
}
  1. 将计算状态属性和方法添加到<template>中,如下所示:
// vuex-sfc/mutations/mapmutations/src/app.vue
<p>{{ number }}</p>
<p>
  <button v-on:click="multiply">x 2</button>
  <button v-on:click="divide">/ 2</button>
  <button v-on:click="square">x 2 (square)</button>
  <button v-on:click="multiplyBy(10)">x 10</button>
</p>

当您单击上述按钮时,您应该看到number状态在浏览器中被动态地乘以或除以。在这个例子中,我们已经通过变异来改变状态值,这是 Vuex 中的规则之一。另一个规则是不要在变异中进行异步调用。换句话说,变异必须是同步的,以便可以通过 DevTool 进行调试。如果要进行异步调用,请使用操作,我们将在下一节中为您介绍。让我们开始吧。

操作

操作和变异一样,都是函数,不同的是它们不用于改变状态,而是用于提交变异。与变异不同,操作可以是异步的。我们在存储的actions属性中创建操作方法。操作方法以上下文对象作为第一个参数,您的自定义参数作为第二个参数等等。您可以使用context.commit来提交一个变异,context.state来访问状态,以及context.getters来访问获取器。让我们通过以下步骤添加一些操作方法:

  1. 创建一个带有state属性和操作方法的存储,如下所示:
// vuex-sfc/actions/basic/src/entry.js
const store = new Vuex.Store({
  state: { number: 1 },
  mutations: { ... },
  actions: {
    multiplyAsync (context) {
      setTimeout(() => {
        context.commit('multiply')
      }, 1000)
    },
    multiply (context) {
      context.commit('multiply')
    },
    multiplyBy (context, n) {
      context.commit('multiplyBy', n)
    },
    divide (context) {
      context.commit('divide')
    }
  }
})

在这个例子中,我们使用了上一节中的相同变异,并创建了操作方法,其中一个创建了一个异步操作方法,以演示为什么我们需要操作来进行异步调用,尽管它们一开始看起来有点麻烦。

请注意,如果您愿意,您可以使用 ES6 JavaScript 解构赋值来解构context并直接导入commit属性,如下所示:

divide ({ commit }) {
  commit('divide')
}
  1. 创建一个组件,并使用this.$store.commit分派前面的操作,如下所示:
// vuex-sfc/actions/basic/src/app.js
export default {
  methods: {
    multiply () {
      this.$store.dispatch('multiply')
    },
    multiplyAsync () {
      this.$store.dispatch('multiplyAsync')
    },
    multiplyBy (number) {
      this.$store.dispatch('multiply', number)
    },
    divide () {
      this.$store.dispatch('divide')
    }
  }
}

与变异和获取器方法一样,您还可以在操作方法上使用mapActions辅助程序,因此让我们在下一节中进行操作。

mapActions 辅助程序

我们可以使用mapActions辅助程序将组件方法映射到操作方法,使用对象扩展运算符,以便我们可以在method属性中混合多个mapActions辅助程序。让我们看看如何通过以下步骤来实现这一点:

  1. 从 Vuex 中导入mapActions辅助程序,并使用对象扩展运算符将变异作为数组传递给mapActions方法,如下所示:
// vuex-sfc/actions/mapactions/src/app.vue
import { mapActions } from 'vuex'

export default {
  methods: {
    ...mapActions([
      'multiply',
      'multiplyAsync',
      'multiplyBy',
      'divide'
    ]),
    ...mapActions({
      square: 'multiply'
    })
  }
}
  1. 添加计算状态属性并将方法绑定到<template>,如下所示:
// vuex-sfc/mapactions/src/app.vue
<p>{{ number }}</p>
<p>
  <button v-on:click="multiply">x 2</button>
  <button v-on:click="square">x 2 (square)</button>
  <button v-on:click="multiplyAsync">x 2 (multiplyAsync)</button>
  <button v-on:click="divide">/ 2</button>
  <button v-on:click="multiplyBy(10)">x 10</button>
</p>

export default {
  computed: {
    number () {
      return this.$store.state.number
    }
  },
}

当您点击前面的按钮时,您应该看到number状态在浏览器上被动地进行乘法或除法运算。在这个例子中,我们再次通过提交 mutations 来改变状态值,而这些 mutations 只能通过使用 store 的 dispatch 方法来分发。这些是我们在应用 store 时必须遵守的强制规则。

然而,当 store 和应用程序增长时,我们可能希望将状态、mutations 和 actions 分成组。在这种情况下,我们将需要 Vuex 中的最后一个概念-模块-这将在下一节中介绍。让我们开始吧。

模块

我们可以将我们的 store 分成模块以扩展应用程序。每个模块可以有状态、mutations、actions 和 getters,如下所示:

const module1 = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const module2 = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: module1,
    b: module2
  }
})

然后,您可以访问每个模块的状态或其他属性,如下所示:

store.state.a
store.state.b

在为您的 store 编写模块时,您应该理解本地状态、根状态和 store 模块中的命名空间。让我们在接下来的章节中看一下它们。

理解本地状态和根状态

每个模块中的 mutations 和 getters 将接收模块的本地状态作为它们的第一个参数,如下所示:

const module1 = {
  state: { number: 1 },
  mutations: {
    multiply (state) {
      console.log(state.number)
    }
  },

  getters: {
    getNumber (state) {
      console.log(state.number)
    }
  }
}

在这段代码中,mutation 和 getter 方法中的状态是本地模块状态,因此您将得到1作为console.log(state.number)的输出,而在每个模块的 actions 中,您将得到上下文作为第一个参数,您可以使用它来访问本地状态和根状态,如context.statecontext.rootState,如下所示:

const module1 = {
  actions: {
    doSum ({ state, commit, rootState }) {
      //...
    }
  }
}

根状态也可以作为每个模块的 getters 的第三个参数使用,如下所示:

const module1 = {
  getters: {
    getSum (state, getters, rootState) {
      //...
    }
  }
}

当我们有多个模块时,来自模块的本地状态和来自 store 根的根状态可能会混淆和变得令人困惑。这就引出了命名空间,它可以使我们的模块更加自包含,减少与其他模块冲突的可能性。让我们在下一节中讨论它。

理解命名空间

默认情况下,每个模块中的actionsmutationsgetters属性都在全局命名空间下注册,因此这些属性中的键或方法名必须是唯一的。换句话说,一个方法名不能在两个不同的模块中重复,如下所示:

// entry.js
const module1 = {
  getters: {
    getNumber (state) {
      return state.number
    }
  }
}

const module2 = {
  getters: {
    getNumber (state) {
      return state.number
    }
  }
}

对于上面的例子,由于在 getters 中使用了相同的方法名,您将看到以下错误:

[vuex] duplicate getter key: getNumber

因此,为了避免重复,必须为每个模块显式命名方法名称,如下所示:

getNumberModule1
getNumberModule2

然后,您可以在子组件中访问这些方法并进行映射,如下所示:

// app.js
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters({
      getNumberModule1: 'getNumberModule1',
      getNumberModule2: 'getNumberModule2'
    })
  }
}

如果您不想像前面的代码中那样使用mapGetters,这些方法也可以写成如下形式:

// app.js
export default {
  computed: {
    getNumberModule1 (state) {
      return this.$store.getters.getNumberModule1
    },
    getNumberModule2 (state) {
      return this.$store.getters.getNumberModule2
    }
  }
}

然而,这种模式可能看起来很冗长,因为我们必须为存储中创建的每个方法重复编写this.$store.gettersthis.$store.actions。访问每个模块的状态也是如此,如下所示:

// app.js
export default {
  computed: {
    ...mapState({
      numberModule1 (state) {
        return this.$store.state.a.number
      }
    }),
    ...mapState({
      numberModule2 (state) {
        return this.$store.state.b.number
      }
    })
  }
}

因此,解决这种情况的方法是通过在每个模块中设置namespaced键为true来为每个模块使用命名空间,如下所示:

const module1 = {
  namespaced: true
}

当模块注册时,其所有 getter、action 和 mutation 将根据模块注册的路径自动命名空间化。接下来是一个示例:

// entry.js
const module1 = {
  namespaced: true
  state: { number:1 }
}

const module2 = {
  namespaced: true
  state: { number:2 }
}

const store = new Vuex.Store({
  modules: {
    a: module1,
    b: module2
  }
})

现在,您可以更轻松地访问每个模块的状态,而代码量更少,如下所示:

// app.js
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState('a', {
      numberModule1 (state) {
        return state.number
      }
    }),
    ...mapState('b', {
      numberModule2 (state) {
        return state.number
      }
    })
  }
}

对于上述示例代码,您将获得numberModule11numberModule22。此外,您还可以通过使用命名空间来消除“重复的 getter 键”错误。因此,现在,您可以为方法使用更“抽象”的名称,如下所示:

// entry.js
const module1 = {
  getters: {
    getNumber (state) {
      return state.number
    }
  }
}

const module2 = {
  getters: {
    getNumber (state) {
      return state.number
    }
  }
}

现在,您可以精确地调用和映射这些方法,并使用它们注册的命名空间,如下所示:

// app.js
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters('a', {
      getNumberModule1: 'getNumber',
    }),
    ...mapGetters('b', {
      getNumberModule2: 'getNumber',
    })
  }
}

我们一直在根文件entry.js中编写存储。无论您是写入模块化存储还是不写入,当随着时间的推移状态属性和 mutations、getters 和 actions 中的方法增长时,这个根文件将变得臃肿。因此,这将引导我们进入下一节,在那里您将学习如何将这些方法和状态属性分离和结构化到它们自己的单独文件中。让我们开始吧。

结构化 Vuex 存储模块

在 Vue 应用程序中,只要您遵守我们在前几节中介绍的强制规则,对于如何构建您的存储结构,没有严格的限制。根据您的存储结构的复杂程度,本书中有两种推荐的结构,您可以在接下来的章节中使用。让我们开始吧。

创建一个简单的存储模块结构

在这种简单的模块结构中,您可以有一个包含此文件夹中所有模块的/store/目录,其中包含一个/modules/目录。以下是创建此简单项目结构的步骤:

  1. 创建一个包含存储模块的/store/目录,并在其中包含一个/modules/目录,如下所示:
// vuex-sfc/structuring-modules/basic/
├── index.html
├── entry.js
├── components
│ ├── app.vue
│ └── ...
└── store
    ├── index.js
    ├── actions.js
    ├── getters.js
    ├── mutations.js
    └── modules
        ├── module1.js
        └── module2.js

在这个简单的结构中,/store/index.js 是我们从 /modules/ 目录中组装模块并导出 store,以及根状态、actions、getters 和 mutations 的地方,如下所示:

// store/index.js
import Vue from 'vue'
import actions from './actions'
import getters from './getters'
import mutations from './mutations'
import module1 from './modules/module1'
import module2 from './modules/module2'

import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    number: 3
  },
  actions,
  getters,
  mutations,
  modules: {
    a: module1,
    b: module2
  }
})
  1. 将根的 actions、mutations 和 getters 拆分为单独的文件,并在根索引文件中组装它们,如下所示:
// store/mutations.js
export default {
  mutation1 (state) {
    //...
  },
  mutation2 (state, n) {
    //...
  }
}
  1. 创建 .js 文件的模块,其中包含它们的状态、actions、mutations 和 getters,就像你在前面的部分中学到的那样,如下所示:
// store/modules/module1.js
export default {
  namespaced: true,
  state: {
    number: 1
  },
  mutations: { ... },
  getters: { ... },
  actions: { ... }
}

如果一个模块文件变得太大,我们可以将模块的状态、actions、mutations 和 getters 拆分为单独的文件。这将带我们进入一个高级的 store 模块结构,我们将在下一节中进行讨论。让我们来看看。

创建一个高级的 store 模块结构

在这个高级模块结构中,你可以有一个包含 /modules/ 目录的 /store/ 目录,该目录的子文件夹中包含所有模块。我们可以将模块的状态、actions、mutations 和 getters 拆分为单独的文件,然后将它们保存在模块文件夹中,具体步骤如下:

  1. 创建一个包含 /modules/ 目录的 /store/ 目录,用于存放 store 模块,如下所示:
// vuex-sfc/structuring-modules/advanced/
├── index.html
├── entry.js
├── components
│ └── app.vue
└── store
    ├── index.js
    ├── action.js
    └── ...
      ├── module1
      │ ├── index.js
      │ ├── state.js
      │ ├── mutations.js
      │ └── ...
      └── module2
          ├── index.js
          ├── state.js
          ├── mutations.js
          └── ...

在这个更复杂的项目结构中,/store/module1/index.js 是我们组装 module1 的地方,而 /store/module2/index.js 是我们组装 module2 的地方,如下所示:

// store/module1/index.js
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
}

我们还可以将一个模块的状态拆分为单独的文件,如下所示:

// store/module1/state.js
export default () => ({
  number: 1
})
  1. 将模块的 actions、mutations 和 getters 拆分为单独的文件,然后在前面的模块索引文件中组装它们,如下所示:
// store/module1/mutations.js
export default {
  mutation1 (state) {
    //...
  },
  mutation2 (state, n) {
    //...
  }
}
  1. 将模块索引文件导入到 store 根目录,我们在那里组装模块并导出 store,如下所示:
// store/index.js
import module1 from './module1'
import module2 from './module2'
  1. 打开严格模式以确保 store 状态只在 mutations 属性中被改变,如下所示:
const store = new Vuex.Store({
  strict: true,
  ...
})

使用严格模式是一个好习惯,提醒我们只在 mutations 属性内改变任何状态。因此,在开发过程中,如果在 mutations 属性之外改变 store 状态,将会抛出错误。然而,我们应该在生产环境中禁用它,因为当 store 中有大量状态变化时,它可能会影响性能。因此,我们可以使用构建工具动态关闭它,具体步骤如下:

// store/index.js
const debug = process.env.NODE_ENV !== 'production'

const store = new Vuex.Store({
  strict: debug,
  ...
})

然而,在处理 store 中的表单时,使用严格模式有一个注意事项,我们将在下一节中介绍。

在 Vuex store 中处理表单

当我们在 Vue 应用中使用v-model进行双向数据绑定时,Vue 实例中的数据将与 v-model 输入字段同步。因此,当你在输入字段中输入任何内容时,数据将立即更新。然而,在 Vuex 存储中,这将会创建问题,因为我们绝对不能mutations属性之外改变存储状态(数据)。让我们看一个在 Vuex 存储中的简单双向数据绑定:

// vuex-non-sfc/handling-forms/v-model.html
<input v-model="user.message" />

const store = new Vuex.Store({
  strict: true,
  state: {
    message: ''
  }
})

new Vue({
  el: 'demo',
  store: store,
  computed: {
    user () {
      return this.$store.state.user
    }
  }
})

在这个例子中,当你在输入字段中输入消息时,你会在浏览器的调试工具中看到以下错误消息:

Error: [vuex] do not mutate vuex store state outside mutation handlers.

这是因为当你输入时,v-model试图直接改变存储状态中的message,所以在严格模式下会导致错误。让我们在接下来的部分看看我们有哪些选项来解决这个问题。

使用 v-bind 和 v-on 指令

在大多数情况下,双向绑定并不总是合适的。在 Vuex 中,更合理的做法是使用单向绑定和显式数据更新,通过将<input>inputchange事件上的value属性进行绑定。你可以通过以下步骤轻松实现这一点:

  1. 创建一个用于改变mutations属性中状态的方法,就像你在之前的部分学到的那样:
// vuex-sfc/form-handling/value-event/store/index.js
export default new Vuex.Store({
  strict: true,
  state: {
    message: ''
  },
  mutations: {
    updateMessage (state, message) {
      state.message = message
    }
  }
})
  1. <input>元素与value属性和input事件与方法进行绑定,如下所示:
// vuex-sfc/form-handling/value-event/components/app.vue
<input v-bind:value="message" v-on:input="updateMessage" />

import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState({
      message: state => state.message
    })
  },
  methods: {
    updateMessage (e) {
      this.$store.commit('updateMessage', e.target.value)
    }
  }
}

在这个解决方案中,我们在子组件中使用updateMessage方法来提交存储中的updateMessage变异方法,并传递输入事件的值。通过像这样显式地提交变异,我们不违反 Vuex 中必须遵守的强制规则。因此,采用这个解决方案意味着你不能使用 v-model 来处理 Vuex 存储的表单。然而,如果你使用 Vue 本身的计算 getter 和 setter,你仍然可以使用它。让我们在下一节中看看这个。

使用双向计算属性

我们可以使用 Vue 内置的双向计算属性和 setter 来处理带有 v-model 的表单,以下是帮助的步骤:

  1. 创建一个用于改变mutations属性中状态的方法,就像在前一节中一样。

  2. getset方法应用于message键,如下所示:

// vuex-sfc/form-handling/getter-setter/components/app.vue
<input v-model="message" />

export default {
  computed: {
    message: {
      get () {
        return this.$store.state.message
      },
      set (value) {
        this.$store.commit('updateMessage', value)
      }
    }
  }
}

然而,这对于简单的计算属性可能效果很好。如果你有一个深层级对象,需要更新超过 10 个键,你将需要 10 组双向计算属性(getter 和 setter)。代码最终会比基于事件的解决方案更加重复和冗长。

干得好!您已经成功掌握了 Vuex 存储的基础和概念。您已经学会了如何在 Vue 应用程序中使用存储。现在,是时候继续在 Nuxt 中应用存储了。因此,让我们在下一节中开始吧。

如果您想了解更多关于 Vuex 的信息,请访问vuex.vuejs.org/

在 Nuxt 中使用 Vuex 存储

在 Nuxt 中,Vuex 已经为您安装好了。您只需要确保项目根目录中存在/store/目录。如果您使用create-nuxt-app安装 Nuxt 项目,此目录将在项目安装期间为您自动生成。在 Nuxt 中,您可以以两种不同的模式创建您的存储:

  • 模块

  • 经典模式(已弃用)

由于经典模式已经弃用,本书将只关注模块模式。因此,让我们在下一节中开始吧。

您可以在我们的 GitHub 存储库的/chapter-10/nuxt-universal/中找到所有以下 Nuxt 示例的源代码。

使用模块模式

不同于 Vue 应用,在 Nuxt 中,默认情况下,每个模块的namespaced键都设置为true,以及根模块。此外,在 Nuxt 中,您不需要在存储根中组装模块;您只需要将状态作为函数导出,并将变化、获取器和操作作为对象在根和模块文件中。让我们按照以下步骤开始:

  1. 创建一个存储根,如下所示:
// store/index.js
export const state = () => ({
  number: 3
})

export const mutations = {
  mutation1 (state) { ... }
}

export const getters = {
  getter1 (state, getter) { ... }
}

export const actions = {
  action1 ({ state, commit }) { ... }
}

在 Nuxt 中,默认情况下,Vuex 的strict模式在开发过程中设置为true,并在生产模式下自动关闭,但您可以在开发过程中禁用它,如下所示:

// store/index.js
export const strict = false
  1. 创建一个模块,如下所示:
// store/module1.js
export const state = () => ({
  number: 1
})

export const mutations = {
  mutation1 (state) { ... }
}

export const getters = {
  getter1 (state, getter, rootState) { ... }
}

export const actions = {
  action1 ({ state, commit, rootState }) { ... }
}

然后,就像我们在上一节中在 Vue 应用程序中手动做的那样,存储将被自动生成,如下所示:

new Vuex.Store({
  state: () => ({
    number: 3
  }),
  mutations: {
    mutation1 (state) { ... }
  },
  getters: {
    getter1 (state, getter) { ... }
  },
  actions: {
    action1 ({ state, commit }) { ... }
  },
  modules: {
    module1: {
      namespaced: true,
      state: () => ({
        number: 1
      }),
      mutations: {
        mutation1 (state) { ... }
      }
      ...
    }
  }
})
  1. 在任何页面的<script>块中映射所有存储状态、获取器、变化和操作,如下所示:
// pages/index.vue
import { mapState, mapGetters, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState({
      numberRoot: state => state.number,
    }),
    ...mapState('module1', {
      numberModule1: state => state.number,
    }),
    ...mapGetters({
      getNumberRoot: 'getter1'
    }),
    ...mapGetters('module1', {
      getNumberModule1: 'getter1'
    })
  },
  methods: {
    ...mapActions({
      doNumberRoot:'action1'
    }),
    ...mapActions('module1', {
      doNumberModule1:'action1'
    })
  }
}
  1. <template>块中显示计算属性和方法以提交变化,如下所示:
// pages/index.vue
<p>{{ numberRoot }}, {{ getNumberRoot }}</p>
<button v-on:click="doNumberRoot">x 2 (root)</button>

<p>{{ numberModule1 }}, {{ getNumberModule1 }}</p>
<button v-on:click="doNumberModule1">x 2 (module1)</button>

您应该在屏幕上看到以下初始结果,并且当您点击屏幕上显示的前面按钮时,它们将被改变:

3, 3
1, 1

正如我们之前提到的,您不需要在 Nuxt 的存储根中组装模块,因为它们会被 Nuxt“自动组装”给您,只要您使用以下结构:

// chapter-10/nuxt-universal/module-mode/
└── store
    ├── index.js
    ├── module1.js
    ├── module2.js
    └── ...

但是,如果您要像我们为 Vue 应用程序手动组装模块一样,在存储根中使用以下结构:

// chapter-10/vuex-sfc/structuring-modules/basic/
└── store
    ├── index.js
    ├── ...
    └── modules
        ├── module1.js
        └── module2.js

您将在 Nuxt 应用程序中看到以下错误:

ERROR [vuex] module namespace not found in mapState(): module1/
ERROR [vuex] module namespace not found in mapGetters(): module1/

要修复这些错误,您需要明确告诉 Nuxt 这些模块存放在哪里。

export default {
  computed: {
    ..mapState('modules/module1', {
      numberModule1: state => state.number,
    }),
    ...mapGetters('modules/module1', {
      getNumberModule1: 'getter1'
    })
  },
  methods: {
    ...mapActions('modules/module1', {
      doNumberModule1:'action1'
    })
  }
}

就像在 Vue 应用程序中的 Vuex 一样,我们也可以在 Nuxt 应用程序中将状态、操作、突变和获取器拆分为单独的文件。让我们看看我们如何做到这一点,以及 Nuxt 的区别在下一节中。

使用模块文件

我们可以将模块中的大文件拆分为单独的文件 - state.jsactions.jsmutations.jsgetters.js - 用于商店根目录和每个模块。因此,让我们按照以下步骤进行:

  1. 为商店根目录创建状态、操作、突变和获取器的单独文件,如下所示:
// store/state.js
export default () => ({
  number: 3
})

// store/mutations.js
export default {
  mutation1 (state) { ... }
}
  1. 为模块创建状态、操作、突变和获取器的单独文件,如下所示:
// store/module1/state.js
export default () => ({
  number: 1
})

// store/module1/mutations.js
export default {
  mutation1 (state) { ... }
}

同样,在 Nuxt 中,我们不需要像在 Vue 应用程序中那样使用index.js来组装这些单独的文件。只要我们使用以下结构,Nuxt 就会为我们完成这些工作:

// chapter-10/nuxt-universal/module-files/
└── store
    ├── state.js
    ├── action.js
    └── ...
      ├── module1
      │ ├── state.js
      │ ├── mutations.js
      │ └── ...
      └── module2
          ├── state.js
          ├── mutations.js
          └── ...

我们可以将这与我们为 Vue 应用程序使用的以下结构进行比较,其中我们需要一个index.js文件用于商店根目录和每个模块,以从单独的文件中组装状态、操作、突变和获取器:

// chapter-10/vuex-sfc/structuring-modules/advanced/
└── store
    ├── index.js
    ├── action.js
    └── ...
      ├── module1
      │ ├── index.js
      │ ├── state.js
      │ ├── mutations.js
      │ └── ...
      └── module2
          ├── index.js
          ├── state.js
          ├── mutations.js
          └── ...

所以,商店在 Nuxt 中是开箱即用的,它为您节省了一些代码行来组装文件和注册模块。很棒,不是吗?现在,让我们再进一步,看看我们如何在 Nuxt 中使用fetch方法动态填充商店状态

使用fetch方法

我们可以使用fetch方法在页面呈现之前填充商店状态。它的工作方式与我们已经介绍过的asyncData方法相同 - 在加载组件之前每次都会被调用。它在服务器端调用一次,然后在客户端导航到其他路由时再次调用。就像asyncData一样,我们可以在fetch方法中使用async/await来处理异步数据。它在组件创建后被调用,因此我们可以通过thisfetch方法中访问组件实例。因此,我们可以通过this.$nuxt.context.store访问商店。让我们使用以下步骤使用这种方法创建一个简单的 Nuxt 应用程序:

  1. 使用fetch方法在任何页面异步请求远程 API 的用户列表,如下所示:
// pages/index.vue
import axios from 'axios'

export default {
  async fetch () {
    const { store } = this.$nuxt.context
    await store.dispatch('users/getUsers')
  }
}
  1. 创建一个带有状态、突变和操作的user模块,如下所示:
// store/users/state.js
export default () => ({
  list: {}
})

// store/users/mutations.js
export default {
  setUsers (state, data) {
    state.list = data
  },
  removeUser (state, id) {
    let found = state.list.find(todo => todo.id === id)
    state.list.splice(state.list.indexOf(found), 1)
  }
}

// store/users/actions.js
export default {
  setUsers ({ commit }, data) {
    commit('setUsers', data)
  },
  removeUser ({ commit }, id) {
    commit('removeUser', id)
  }
}

在突变和操作中使用setUsers方法将用户列表设置到状态中,而removeUser方法用于逐个从状态中移除用户。

  1. 将状态和动作从页面映射到方法,如下所示:
// pages/index.vue
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState ('users', {
      users (state) {
        return state.list
      }
    })
  },
  methods: {
    ...mapActions('users', {
      removeUser: 'removeUser'
    })
  }
}
  1. <template>块中循环并显示用户列表,如下所示:
// pages/index.vue
<li v-for="(user, index) in users" v-bind:key="user.id">
  {{ user.name }}
  <button class="button" v-on:click="removeUser(user.id)">Remove</button>
</li>

当您在浏览器中加载应用程序时,您应该在屏幕上看到用户列表,并且可以单击“删除”按钮来删除用户。我们还可以在动作中使用async/await来获取远程数据,如下所示:

// store/users/actions.js
import axios from 'axios'

export const actions = {
  async getUsers ({ commit }) {
    const { data } = await axios.get('https://jsonplaceholder.typicode.com/users')
    commit('setUsers', data)
  }
}

然后,我们可以像下面这样调度getUsers动作:

// pages/index.vue
export default {
  async fetch () {
    const { store } = this.$nuxt.context
    await store.dispatch('users/getUsers')
  }
}

除了在 Nuxt 中使用fetch方法获取和填充状态之外,我们还可以使用nuxtServerInit动作,这只在 Nuxt 中可用。让我们继续在下一节中看一下它。

使用nuxtServerInit动作

asyncData方法不同,它仅在页面级组件中可用,以及fetch方法可用于所有 Vue 组件(包括页面级组件),nuxtServerInit动作是一个保留的存储动作方法,仅在 Nuxt 存储中定义时可用。它只能在存储根目录的index.js文件中定义,并且仅在 Nuxt 应用程序初始化之前在服务器端调用。与在服务器端调用然后在后续路由上的客户端端调用的asyncDatafetch方法不同,nuxtServerInit动作方法仅在服务器端调用一次,除非您在浏览器中刷新任何页面。此外,与asyncData方法不同,它将 Nuxt 上下文对象作为其第一个参数,nuxtServerInit动作方法将其作为其第二个参数。它接收的第一个参数是存储上下文对象。让我们将这些上下文对象放入以下表格中:

第一个参数 第二个参数

|

  • dispatch

  • commit

  • getters

  • state

  • rootGetters

  • rootState

|

  • isStatic

  • isDev

  • isHMR

  • 应用

  • req

  • res

  • ...

|

因此,当我们想要从应用程序的任何页面从服务器端获取数据,然后使用服务器数据填充存储状态时,nuxtServerInit动作方法非常有用,例如,我们在用户登录到我们的应用程序时在服务器端存储在会话中的经过身份验证的用户数据。这个会话数据可以存储为 Express 中的req.session.authUser或 Koa 中的ctx.session.authUser。然后,我们可以通过req对象将ctx.session传递给nuxtServerInit

让我们使用这种方法动作创建一个简单的用户登录应用,并使用 Koa 作为服务器端 API,你在第八章中学到了关于 Koa 的内容,添加服务器端框架。在我们可以将任何数据注入会话并使用nuxtServerIni动作方法创建存储之前,我们只需要对服务器端进行一点修改,以下是具体步骤:

  1. 安装会话包koa-session,使用 npm:
$ npm install koa-session
  1. 导入并注册会话包作为中间件,如下所示:
// server/middlewares.js
import session from 'koa-session'

app.keys = ['some secret hurr']
app.use(session(app))
  1. 在服务器端创建两个路由,如下所示:
// server/routes.js
router.post('/login', async (ctx, next) => {
  let request = ctx.request.body || {}
  if (request.username === 'demo' && request.password === 'demo') {
    ctx.session.authUser = { username: 'demo' }
    ctx.body = { username: 'demo' }
  } else {
    ctx.throw(401)
  }
})

router.post('/logout', async (ctx, next) => {
  delete ctx.session.authUser
  ctx.body = { ok: true }
})

在前面的代码中,我们使用/login路由将经过认证的用户数据authUser注入到 Koa 上下文ctx中,同时/logout用于取消认证数据。

  1. 创建存储状态,包含一个authUser键来保存认证数据:
// store/state.js
export default () => ({
  authUser: null
})
  1. 创建一个变异方法,在前述状态中设置数据到authUser键:
// store/mutations.js
export default {
  setUser (state, data) {
    state.authUser = data
  }
}
  1. 在存储根目录创建一个index.js文件,包含以下动作:
// store/index.js
export const actions = {
  nuxtServerInit({ commit }, { req }) {
    if (req.ctx.session && req.ctx.session.authUser) {
      commit('setUser', req.ctx.session.authUser)
    }
  },
  async login({ commit }, { username, password }) {
    const { data } = await axios.post('/api/login', { username, 
     password })
    commit('setUser', data.data)
  },
  async logout({ commit }) {
    await axios.post('/api/logout')
    commit('setUser', null)
  }
}

在前面的代码中,nuxtServerInit动作方法用于从服务器访问会话数据,并通过提交setUser变异方法来填充存储状态。loginlogout动作方法用于验证用户登录凭据并取消认证。请注意,会话数据存储在req.ctx中,因为本书使用 Koa 作为服务器 API。如果你使用 Express,请使用以下代码:

actions: {
  nuxtServerInit ({ commit }, { req }) {
    if (req.session.user) {
      commit('user', req.session.user)
    }
  }
}

就像asyncDatafetch方法一样,nuxtServerInit动作方法也可以是异步的。你只需要返回一个 Promise,或者使用async/await语句,让 Nuxt 服务器等待动作异步完成,如下所示:

actions: {
  async nuxtServerInit({ commit }) {
    await commit('setUser', req.ctx.session.authUser)
  }
}

  1. 创建一个表单来使用存储的动作方法,如下所示:
// pages/index.vue
<form v-on:submit.prevent="login">
  <input v-model="username" type="text" name="username" />
  <input v-model="password" type="password" name="password" />
  <button class="button" type="submit">Login</button>
</form>

export default {
  data() {
    return {
      username: '',
      password: ''
    }
  },
  methods: {
    async login() {
      await this.$store.dispatch('login', {
        username: this.username,
        password: this.password
      })
    },
    async logout() {
      await this.$store.dispatch('logout')
    }
  }
}

我们已经简化了前面的代码和步骤 6的代码以适应这个页面,但你可以在我们的 GitHub 存储库的/chapter-10/nuxt-universal/nuxtServerInit/中找到它们的完整版本。

干得好!你终于通过了 Nuxt 和 Vue 的一个激动人心的特性-Vuex 存储。这是一个很长的章节,但它是非常重要的,因为我们将需要在接下来的章节中经常回到 Vuex 并经常使用它。现在,让我们总结一下你在这一章学到了什么。

总结

我们已经走了很长的路。在本章中,您了解了 Vuex 存储中的架构、核心概念、模块结构和表单处理。在这一点上,您应该知道 Vuex 存储只是与状态(或数据)集中化和管理有关,并且有一些必须遵守的强制规则。因此,对于您可能在存储中拥有的任何状态属性,正确的访问方式是通过在组件的computed属性中计算它。如果您想要更改状态属性的值,必须通过 mutations 对其进行变化,这必须是同步的。如果您想要进行异步调用以改变状态,则必须使用 actions 在组件中分发操作以提交 mutations。

您还学会了在 Nuxt 应用程序中创建存储比在 Vue 应用程序中更容易和简单,因为 Vuex 默认预安装在 Nuxt 上。此外,在 Nuxt 中,您无需手动组装模块及其所有方法,因为它们默认为您完成。此外,在 Nuxt 中,您可以使用fetchnuxtServerInit方法在呈现页面组件和启动 Nuxt 应用程序之前使用服务器端 API 填充存储状态。最后,您已经成功使用nuxtServerInit操作方法创建了一个简单的用户登录应用程序,并为在即将到来的章节中创建用户登录和 API 身份验证铺平了道路。

在下一章中,我们将研究 Nuxt 中的中间件 - 具体来说,路由中间件和服务器中间件。您将学会区分这两种类型的 Nuxt 中间件。您将在 Vue 应用程序中使用导航守卫创建一些路由中间件,然后在 Nuxt 应用程序中创建中间件。然后,您将在serverMiddleware配置选项中编写一些 Nuxt 服务器中间件,作为您在第八章中学习创建的服务器端 API 的替代服务器 API。最后但并非最不重要的是,您将学习如何使用 Vue CLI 创建 Vue 应用程序,而不是使用自定义 webpack 配置创建的 Vue 应用程序。所以,让我们开始吧。

第四部分:中间件和安全性

在本节中,我们将学习有关中间件的知识 - 更具体地说,是路由中间件和服务器中间件。然后,我们将学习如何使用中间件添加身份验证,以创建用户登录会话。

本节包括以下章节:

  • 第十一章,编写路由中间件和服务器中间件

  • 第十二章,创建用户登录和 API 身份验证

编写路由中间件和服务器中间件

还记得在第八章中使用 Koa 在服务器端创建中间件吗?中间件非常有用且强大,正如你在 Koa 应用程序中注意到的那样,你可以预测和控制整个应用程序的流程。那么在 Nuxt 中呢?嗯,在 Nuxt 中有两种类型的中间件:路由中间件和服务器中间件。在本章中,您将学习如何区分它们,并在进入下一章关于身份验证的章节之前创建一些基本的中间件,那里中间件是非常需要的。我们还将在接下来的章节中使用中间件。因此,在本章中,就像在许多以前的章节中一样,您将在 Vue 应用程序中创建一些带有导航守卫的中间件,以便在创建 Nuxt 应用程序中的路由中间件和服务器中间件之前掌握 Vue/Nuxt 系统中的中间件机制。

在这一章中,我们将涵盖以下主题:

  • 使用 Vue Router 编写中间件

  • 介绍 Vue CLI

  • 在 Nuxt 中编写路由中间件

  • 编写 Nuxt 服务器中间件

第十一章:使用 Vue Router 编写中间件

在学习 Nuxt 应用程序中的中间件如何工作之前,我们应该了解它在标准 Vue 应用程序中是如何工作的。此外,在 Vue 应用程序中创建中间件之前,让我们先了解它们是什么。

什么是中间件?

简而言之,中间件是位于两个或多个软件之间的软件层。这是软件开发中的一个古老概念。中间件是一个自 1968 年以来就一直在使用的术语。它在 1980 年代作为将新应用程序链接到旧的遗留系统的解决方案而变得流行。对于它有许多定义,例如(来自Google 字典)“[中间件是]在操作系统或数据库与应用程序之间充当桥梁的软件,尤其是在网络上。”

在 Web 开发世界中,服务器端软件或应用程序(如 Koa 和 Express)接收请求并输出响应。中间件是在接收请求后执行的程序或函数,它们产生的输出可以是最终输出,也可以被下一个中间件使用,直到循环完成。这也意味着我们可以有多个中间件,并且它们将按照声明的顺序执行:

此外,中间件不仅限于服务器端技术。当您的应用程序中有路由时,在客户端中也非常常见。Vue.js 的 Vue Router 就是使用这种中间件概念的一个很好的例子。我们已经在第四章 添加视图、路由和过渡中学习和使用了 Vue Router,为我们的 Vue 应用程序创建了路由器。现在,让我们深入了解 Vue Router 的高级用法 - 导航守卫。

安装 Vue Router

如果您从本书的开头开始就已经跟着章节走了,那么您应该已经知道如何从第四章 添加视图、路由和过渡中安装 Vue Router。然而,这里是一个快速回顾。

按照以下步骤直接下载 Vue Router:

  1. 单击以下链接并下载源代码:
https://unpkg.com/vue-router/dist/vue-router.js
  1. 在 Vue 之后包含路由器,这样它就可以自动安装:
<script src="/path/to/vue.js"></script>
<script src="/path/to/vue-router.js"></script>

或者,您可以通过 npm 安装 Vue Router:

  1. 使用 npm 将路由器安装到您的项目中:
$ npm i vue-router
  1. 使用use方法显式注册路由器:
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)
  1. 一旦你安装好了路由器,你就可以开始使用 Vue Router 提供的导航守卫来创建中间件:
const router = new VueRouter({ ... })
router.beforeEach((to, from, next) => {
  // ...
})

在前面的示例中,beforeEach导航守卫是一个全局导航守卫,当导航到任何路由时都会被调用。除了全局守卫,还有特定路由的导航守卫,我们将在下一节中更详细地探讨这一点。所以,让我们开始吧!

如果您想了解更多关于 Vue Router 的信息,请访问router.vuejs.org/

使用导航守卫

导航守卫用于保护应用程序中的导航。这些守卫允许我们在进入、更新和离开路由之前调用函数。当某些条件不满足时,它们可以重定向或取消路由。有几种方式可以连接到路由导航过程中:全局、每个路由或在组件中。让我们在下一节中探索全局守卫。

请注意,您可以在我们的 GitHub 存储库的/chapter-11/vue/non-sfc/中找到以下所有示例。

创建全局守卫

Vue Router 提供了两种全局守卫 - 全局前置守卫和全局后置守卫。让我们学习如何在应用程序中应用它们之前先了解如何使用它们:

  • 全局前置守卫:全局前置守卫在路由进入时调用。它们按特定顺序调用,并且可以是异步的。导航总是等待直到所有守卫都被解析。我们可以使用 Vue Router 的beforeEach方法注册这些守卫,如下所示:
const router = new VueRouter({ ... })
router.beforeEach((to, from, next) => { ... })
  • 全局后置守卫:全局后置守卫在路由进入后调用。与全局前置守卫不同,全局后置守卫没有next函数,因此它们不会影响导航。我们可以使用 Vue Router 的afterEach方法注册这些守卫,如下所示:
const router = new VueRouter({ ... })
router.afterEach((to, from) => { ... })

让我们创建一个 Vue 应用程序,使用一个简单的 HTML 页面,并在以下步骤中使用这些守卫:

  1. 使用<router-link>元素创建两个路由,如下所示:
<div id="app">
  <p>
    <router-link to="/page1">Page 1</router-link>
    <router-link to="/page2">Page 2</router-link>
  </p>
  <router-view></router-view>
</div>
  1. 为路由定义组件(Page1Page2),并将它们传递给<script>块中的路由实例:
const Page1 = { template: '<div>Page 1</div>' }
const Page2 = { template: '<div>Page 2</div>' }

const routes = [
  { path: '/page1', component: Page1 },
  { path: '/page2', component: Page2 }
]

const router = new VueRouter({
  routes
})
  1. 在路由实例之后声明全局前置守卫和全局后置守卫,如下所示:
router.beforeEach((to, from, next) => {
  console.log('global before hook')
  next()
})

router.afterEach((to, from,) => {
  console.log('global after hook')
})
  1. 在守卫之后挂载根实例并运行我们的应用程序:
const app = new Vue({
  router
}).$mount('#app')
  1. 在浏览器中运行应用程序,当您在路由之间切换时,您应该在浏览器控制台中获得以下日志:
global before hook
global after hook

全局守卫在你想要应用到所有路由的共同内容时非常有用。然而,有时我们只需要特定路由的特定内容。为此,您应该使用每个路由的守卫。让我们在下一节中学习如何部署它们。

创建每个路由的守卫

我们可以通过在路由的配置对象上直接使用beforeEnter方法或属性来创建每个路由的守卫。例如,看一下以下示例:

beforeEnter: (to, from, next) => { ... }
// or:
beforeEnter (to, from, next) { ... }

让我们复制我们之前的 Vue 应用程序,并更改路由配置以使用这些每个路由的守卫,如下所示:

const routes = [
  {
    path: '/page1',
    component: Page1,
    beforeEnter: (to, from, next) => {
      console.log('before entering page 1')
      next()
    }
  },
  {
    path: '/page2',
    component: Page2,
    beforeEnter (to, from, next) {
      console.log('before entering page 2')
      next()
    }
  }
]

当您导航到/page1时,您应该在浏览器控制台上获得“进入页面 1 之前”的日志,当您在/page2上时,您应该获得“进入页面 2 之前”的日志。因此,我们可以将守卫应用于页面的路由,那么将守卫应用于路由组件本身呢?答案是肯定的,我们可以。让我们继续下一节,学习如何使用组件内守卫来保护特定组件。

创建组件内守卫

我们可以在路由组件内部单独或一起使用以下方法来创建特定组件的导航守卫。

beforeRouteEnter 守卫

就像在全局前置守卫和beforeEnter每个路由守卫中一样,beforeRouteEnter守卫在路由渲染组件之前调用,但它适用于组件本身。我们可以使用beforeRouteEnter方法注册这种类型的守卫,如下所示:

beforeRouteEnter (to, from, next) { ... }

因为它在组件实例之前被调用,所以无法通过this关键字访问 Vue 组件。但可以通过将 Vue 组件的回调传递给next参数来解决这个问题:

beforeRouteEnter (to, from, next) {
  next(vueComponent => { ... })
}

beforeRouteLeave 守卫

相比之下,当由路由渲染的组件即将从中导航离开时,将调用beforeRouteLeave守卫。由于它在 Vue 组件渲染时被调用,因此可以通过this关键字访问 Vue 组件。我们可以使用beforeRouteLeave方法注册这种类型的守卫,如下所示:

beforeRouteLeave (to, from, next) { ... }

通常,这种类型的守卫最适合用于防止用户意外离开路由。因此,可以通过调用next(false)来取消导航:

beforeRouteLeave (to, from, next) {
  const confirmed = window.confirm('Are you sure you want to leave?')
  if (confirmed) {
    next()
  } else {
    next(false)
  }
}

beforeRouteUpdate 守卫

当由路由渲染的组件已更改但组件在新路由中被重用时,将调用beforeRouteUpdate守卫;例如,如果您有使用相同路由组件的子路由组件:/page1/foo/page1/bar。因此,从/page1/foo导航到/page1/bar将触发此方法。由于它在组件渲染时被调用,因此可以通过this关键字访问 Vue 组件。我们可以使用beforeRouteUpdate方法注册这种类型的守卫:

beforeRouteUpdate (to, from, next) { ... }

请注意,beforeRouteEnter方法是唯一支持在next方法中使用回调的守卫。在调用beforeRouteUpdatebeforeRouteLeave方法之前,Vue 组件已经可用。因此,在这两种情况下在next方法中使用回调是不受支持的,因为这是不必要的。因此,如果要访问 Vue 组件,只需使用this关键字:

beforeRouteUpdate (to, from, next) {
  this.name = to.params.name
  next()
}

现在,让我们使用以下守卫创建一个带有简单 HTML 页面的 Vue 应用:

  1. 创建一个页面组件,其中包含beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave方法,如下所示:
const Page1 = {
  template: '<div>Page 1 {{ $route.params.slug }}</div>',
  beforeRouteEnter (to, from, next) {
    console.log('before entering page 1')
    next(vueComponent => {
      console.log('before entering page 1: ', 
       vueComponent.$route.path)
    })
  },
  beforeRouteUpdate (to, from, next) {
    console.log('before updating page 1: ', this.$route.path)
    next()
  },
  beforeRouteLeave (to, from, next) {
    console.log('before leaving page 1: ', this.$route.path)
    next()
  }
}
  1. 创建另一个页面组件,只包含beforeRouteEnterbeforeRouteLeave方法,如下所示:
const Page2 = {
  template: '<div>Page 2</div>',
  beforeRouteEnter (to, from, next) {
    console.log('before entering page 2')
    next(vueComponent => {
      console.log('before entering page 2: ', 
       vueComponent.$route.path)
    })
  },
  beforeRouteLeave (to, from, next) {
    console.log('before leaving page 2: ', this.$route.path)
    next()
  }
}
  1. 在初始化路由器实例之前定义主路由和子路由,如下所示:
const routes = [
  {
    path: '/page1',
    component: Page1,
    children: [
      {
        path: ':slug'
      }
    ]
  },
  {
    path: '/page2',
    component: Page2
  }
]
  1. 使用<router-link> Vue 组件创建导航链接,如下所示:
<div id="app">
  <ul>
    <li><router-link to="/">Home</router-link></li>
    <li><router-link to="/page1">Page 1</router-link></li>
    <li><router-link to="/page1/foo">Page 1: foo</router-link></li>
    <li><router-link to="/page1/bar">Page 1: bar</router-link></li>
    <li><router-link to="/page2">Page 2</router-link></li>
  </ul>
  <router-view></router-view>
</div>
  1. 在浏览器中运行应用程序,当在路由之间切换时,你应该在浏览器控制台中得到以下日志:
  • 当从/导航到/page1时,你应该看到以下内容:
before entering page 1
before entering page 1: /page1
  • 当从/page1导航到/page2时,你应该看到以下内容:
before leaving page 1: /page1
before entering page 2
before entering page 2: /page2
  • 当从/page2导航到/page1/foo时,你应该看到以下内容:
before leaving page 2: /page2
before entering page 1
before entering page 1: /page1/foo
  • 当从/page1/foo导航到/page1/bar时,你应该看到以下内容:
before updating page 1: /page1/foo
  • 当从/page1/bar导航到/时,你应该看到以下内容:
before leaving page 1: /page1/bar

正如你所看到的,Vue 中的导航守卫只是允许我们创建中间件的 JavaScript 函数,带有一些默认参数。现在,让我们在下一节更仔细地看看每个守卫方法得到的参数(tofromnext)。

理解导航守卫的参数:to、from 和 next

你已经在前面的部分中看到了这些参数在导航守卫中的使用,但我们还没有向你详细介绍它们。所有守卫,除了afterEach全局守卫,都使用这三个参数:tofromnext

to参数

这个参数是你要导航到的路由对象(因此被称为to参数)。这个对象保存了 URL 和路由的解析信息:

namemetapathhash queryparamsfullPathmatched

如果你想了解每个这些对象属性的更多信息,请访问router.vuejs.org/api/the-route-object

from参数

这个参数是你从中导航的当前路由对象。同样,这个对象保存了 URL 和路由的解析信息:

namemetapathhash queryparamsfullPathmatched

next参数

这个参数是一个函数,你必须调用它才能继续到队列中的下一个守卫(中间件)。如果你想中止当前的导航,你可以向这个函数传递一个false布尔值:

next(false)

如果你想重定向到不同的位置,你可以使用以下代码:

next('/')
// or
next({ path: '/' })

如果你想用Error的实例中止导航,你可以使用以下代码:

const error = new Error('An error occurred!')
next(error)

然后,你可以从根目录捕获错误:

router.onError(err
 => { ... })

现在,让我们创建一个带有简单 HTML 页面的 Vue 应用程序,并在以下步骤中尝试使用 next 函数:

  1. 按照以下方式创建带有beforeRouteEnter方法的页面组件:
const Page1 = {
  template: '<div>Page 1</div>',
  beforeRouteEnter (to, from, next) {
    const error = new Error('An error occurred!')
    error.statusCode = 500
    console.log('before entering page 1')
    next(error)
  }
}

 const Page2 = {
  template: '<div>Page 2</div>',
  beforeRouteEnter (to, from, next) {
    console.log('before entering page 2')
    next({ path: '/' })
  }
}

在上述代码中,我们将Error实例传递给Page1的下一个函数,同时将路由重定向到Page2的主页。

  1. 在初始化路由实例之前定义路由,如下所示:
const routes = [
  {
    path: '/page1',
    component: Page1
  },
  {
    path: '/page2',
    component: Page2
  }
]
  1. 创建路由实例并使用onError方法监听错误:
const router = new VueRouter({
  routes
})

router.onError(err => {
  console.error('Handling this error: ', err.message)
  console.log(err.statusCode)
})
  1. 使用<router-link> Vue 组件创建以下导航链接:
<div id="app">
  <ul>
    <li><router-link to="/">Home</router-link></li>
    <li><router-link to="/page1">Page 1</router-link></li>
    <li><router-link to="/page2">Page 2</router-link></li>
  </ul>
  <router-view></router-view>
</div>
  1. 在浏览器中运行应用程序,当在路由之间切换时,你应该在浏览器控制台中看到以下日志:
  • 当从/导航到/page1时,你应该看到以下内容:
before entering page 1
Handling this error: An error occurred!
500
  • /page1导航到/page2时,你应该看到以下内容:
before entering page 2

当从/page1导航到/page2时,你也会注意到被重定向到/,因为有这行代码:next({ path: '/' })

到目前为止,我们在单个 HTML 页面中创建了中间件。然而,在实际项目中,我们应该尝试使用你在之前章节中学到的 Vue 单文件组件(SFC)来创建它们。因此,在下一节中,你将学习如何使用 Vue CLI 在 Vue SFC 中创建中间件,而不是你到目前为止学到的自定义 webpack 构建过程。所以,让我们开始吧。

介绍 Vue CLI

我们在第五章中使用 webpack 创建了我们的自定义 Vue SFC 应用程序,添加 Vue 组件。作为开发人员,了解如何查看复杂事物的机制非常有用,我们还必须了解如何使用常见和标准模式与他人合作。因此,这些天,我们倾向于使用框架。Vue CLI 是 Vue 应用程序开发的标准工具。它可以执行我们的 webpack 自定义工具以及更多操作。如果你不想创建自己的 Vue SFC 开发工具,Vue CLI 是一个很好的选择。它支持 Babel、ESLint、TypeScript、PostCSS、PWA、单元测试和端到端测试。要了解更多关于 Vue CLI 的信息,请访问cli.vuejs.org/

安装 Vue CLI

使用 Vue CLI 非常容易入门。执行以下步骤:

  1. 使用 npm 全局安装它:
$ npm i -g @vue/cli
  1. 在你想要的时候创建一个项目:
$ vue create my-project
  1. 您将被提示选择预设 - default手动选择功能,如下所示:
Vue CLI v4.4.6
? Please pick a preset: (Use arrow keys)
> default (babel, eslint) 
  Manually select features 
  1. 选择default预设,因为我们可以随后手动安装所需的内容。当安装完成时,你应该在终端中看到类似以下输出的最后部分:
Successfully created project my-project. 
Get started with the following commands: 

 **$ cd my-project**
 **$ npm run serve** 
  1. 将目录更改为my-project并开始开发过程:
$ npm run serve

你应该得到类似于这样的东西:

 DONE Compiled successfully in 3469ms

  App running at:
  - Local: http://localhost:8080/
  - Network: http://199.188.0.44:8080/

  Note that the development build is not optimized.
  To create a production build, run npm run build.

在接下来的几节中,我们将把你在前几节中学到的导航守卫转换成使用 Vue CLI 的适当中间件。这意味着我们将把所有的钩子和守卫分开成单独的.js文件,并将它们保存在一个名为middlewares的常见文件夹中。然而,在我们这样做之前,我们应该先了解 Vue CLI 为我们生成的项目目录结构,然后添加我们自己需要的目录。让我们开始吧。

理解 Vue CLI 的项目结构

使用 Vue CLI 创建项目后,如果你查看项目目录,你会发现它为我们提供了一个基本的结构,如下所示:

├── package.json
├── babel.config.js
├── README.md
├── public
│ ├── index.html
│ └── favicon.ico
└── src
    ├── App.vue
    ├── main.js
    ├── router.js
    ├── components
    │ └── HelloWorld.vue
    └── assets
        └── logo.png

从这个基本结构开始,我们可以构建和发展我们的应用程序。因此,让我们在/src/目录中开发我们的应用程序,并使用路由文件添加以下目录:

└── src
    ├── middlewares/
    ├── store/
    ├── routes/
    └── router.js

我们将创建两个路由组件,登录和安全,作为 SFC 页面,并将安全页面设置为 403 受保护页面,这将要求用户登录以提供其姓名和年龄以访问页面。以下是我们这个简单的 Vue 应用程序所需的/src/目录中的文件和结构:

└── src
    ├── App.vue
    ├── main.js
    ├── router.js
    ├── components
    │ ├── secured.vue
    │ └── login.vue
    ├── assets
    │ └── ...
    ├── middlewares
    │ ├── isLoggedIn.js
    │ └── isAdult.js
    ├── store
    │ ├── index.js
    │ ├── mutations.js
    │ └── actions.js
    └── routes
        ├── index.js
        ├── secured.js
        └── login.js

现在我们知道了我们的应用程序需要哪些目录和文件。接下来,我们将继续编写这些文件的代码。

使用 Vue CLI 编写中间件和 Vuex 存储

如果你看一下package.json,你会发现 Vue CLI 默认的依赖项非常基本和最小:

// package.json
"dependencies": {
  "core-js": "².6.5",
  "vue": "².6.10"
}

因此,我们将安装我们项目的依赖项,并按以下步骤编写我们需要的代码:

  1. 通过 npm 安装以下软件包:
$ npm i vuex
$ npm i vue-router
$ npm i vue-router-multiguard

请注意,Vue 不支持每个路由多个守卫。因此,如果您想为一个路由创建多个守卫,Vue Router Multiguard 允许您这样做。有关此软件包的更多信息,请访问github.com/atanas-dev/vue-router-multiguard

  1. 创建状态、操作和变异以在 Vuex 存储中存储经过身份验证的用户详细信息,以便任何组件都可以访问这些详细信息:
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import actions from './actions'
import mutations from './mutations'

Vue.use(Vuex)

export default new Vuex.Store({
  state: { user: null },
  actions,
  mutations
})

为了可读性和简单性,我们将把存储的操作分成一个单独的文件,如下所示:

// src/store/actions.js
const actions = {
  async login({ commit }, { name, age }) {
    if (!name || !age) {
      throw new Error('Bad credentials')
    }
    const data = {
      name: name,
      age: age
    }
    commit('setUser', data)
  },

  async logout({ commit }) {
    commit('setUser', null)
  }
}
export default actions

我们还将把存储的变异分成一个单独的文件,如下所示:

// src/store/mutations.js
const mutations = {
  setUser (state, user) {
    state.user = user
  }
}
export default mutations
  1. 创建一个中间件来确保用户已登录:
// src/middlewares/isLoggedIn.js
import store from '../store'

export default (to, from, next) => {
  if (!store.state.user) {
    const err = new Error('You are not connected')
    err.statusCode = 403
    next(err)
  } else {
    next()
  }
}
  1. 创建另一个中间件来确保用户年满 18 岁:
// src/middlewares/isAdult.js
import store from '../store'

export default (to, from, next) => {
  if (store.state.user.age < 18) {
    const err = new Error('You must be over 18')
    err.statusCode = 403
    next(err)
  } else {
    next()
  }
}
  1. 通过使用vue-router-multiguardbeforeEnter中插入多个中间件,将这两个中间件导入到 secured 路由中:
// src/routes/secured.js
import multiguard from 'vue-router-multiguard'
import secured from '../components/secured.vue'
import isLoggedIn from '../middlewares/isLoggedIn'
import isAdult from '../middlewares/isAdult'

export default {
  name: 'secured',
  path: '/secured',
  component: secured,
  beforeEnter: multiguard([isLoggedIn, isAdult])
}
  1. 创建一个简单的登录页面进行客户端身份验证。以下是我们需要的loginlogout方法的基本输入字段:
// src/components/login.vue
<form @submit.prevent="login">
  <p>Name: <input v-model="name" type="text" name="name"></p>
  <p>Age: <input v-model="age" type="number" name="age"></p>
  <button type="submit">Submit</button>
</form>

export default {
  data() {
    return {
      error: null,
      name: '',
      age: ''
    }
  },
  methods: {
    async login() { ... },
    async logout() { ... }
  }
}
  1. 通过在trycatch块中分派loginlogout动作方法来完成上述loginlogout方法,如下所示:
async login() {
  try {
    await this.$store.dispatch('login', {
      name: this.name,
      age: this.age
    })
    this.name = ''
    this.age = ''
    this.error = null
  } catch (e) {
    this.error = e.message
  }
},
async logout() {
  try {
    await this.$store.dispatch('logout')
  } catch (e) {
    this.error = e.message
  }
}
  1. 将完成的login组件导入到登录路由中,如下所示:
// src/routes/login.js
import Login from '../components/login.vue'

export default {
  name: 'login',
  path: '/',
  component: Login
}

请注意,我们将此路由命名为login,因为我们稍后需要此名称来在前面的中间件中从导航路由重定向时使用。

  1. loginsecured路由导入到索引路由中,如下所示:
// src/routes/index.js
import login from './login'
import secured from './secured'

const routes = [
  login,
  secured
]

export default routes
  1. 将前面的索引路由导入到 Vue Router 实例中,并使用router.onError捕获路由错误,如下所示:
// src/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Routes from './routes'

Vue.use(VueRouter)

const router = new VueRouter({
  routes: Routes
})

router.onError(err => {
  alert(err.message)
  router.push({ name: 'login' })
})

export default router

在这一步中,我们使用router.onError来处理从中间件传递的Error对象,并使用router.push在不满足身份验证条件时将导航路由重定向到登录页面。对象的名称必须与步骤 7中的登录路由名称相同,即login

  1. main文件中导入路由并存储:
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#app')
  1. 使用npm run serve运行项目,您应该看到该应用程序加载在localhost:8080上。如果您在主页的输入字段中输入一个名称和小于 18 的数字,然后点击登录按钮,您应该会收到一个警告,指出“您必须年满 18 岁”当尝试访问 secured 页面时。另一方面,如果您输入一个大于 18 的数字,您应该会在 secured 页面上看到名称和数字。
Name: John
Age: 20

您可以在我们的 GitHub 存储库的/chapter-11/vue/vue-cli/basic/中找到此应用程序的完整代码。您还可以在/chapter-11/vue/webpack/中找到具有自定义 webpack 的应用程序。

干得好!您已经成功完成了关于 Vue 项目中间件的所有章节。现在,让我们在接下来的章节中应用您刚刚学到的关于 Nuxt 项目的知识。

在 Nuxt 中编写路由中间件

理解了 Vue 中间件的工作原理后,就更容易在 Nuxt 中使用它,因为 Nuxt 已经为我们处理了 Vue Router。在接下来的章节中,我们将学习如何在 Nuxt 应用程序中使用全局和每个路由的中间件。

在 Nuxt 中,所有中间件都应该保存在/middleware/目录中,中间件的文件名将是中间件的名称。例如,/middleware/user.js是用户中间件。中间件将 Nuxt 上下文作为其第一个参数:

export default (context) => { ... }

此外,中间件可以是异步的。

export default async (context) => {
   const { data } = await axios.get('/api/path')
}

在通用模式中,中间件在服务器端只调用一次(例如,当首次请求 Nuxt 应用程序或刷新页面时),然后在客户端导航到其他路由时再次调用。另一方面,无论您是首次请求应用程序还是在首次请求后导航到更多路由时,中间件始终在客户端调用。中间件首先在 Nuxt 配置文件中执行,然后在布局中执行,最后在页面中执行。我们现在将在下一节开始编写一些全局中间件。

编写全局中间件

添加全局中间件非常简单;您只需在config文件的“路由器”选项中的“中间件”键中声明它们。例如,看一下以下内容:

// nuxt.config.js
export default {
  router: {
    middleware: 'auth'
  }
}

现在,让我们按照以下步骤创建一些全局中间件。在这个练习中,我们想要从 HTTP 请求头中获取用户代理的信息,并跟踪用户正在导航到的路由:

  1. /middleware/目录中创建两个中间件,一个用于获取用户代理信息,另一个用于获取用户正在导航到的路由路径信息:
// middleware/user-agent.js
export default (context) => {
  context.userAgent = process.server ? context.req.headers[
    'user-agent'] : navigator.userAgent
}

// middleware/visits.js
export default ({ store, route, redirect }) => {
  store.commit('addVisit', route.path)
}
  1. 在“路由器”选项中的“中间件”键中声明前面的中间件,如下所示:
// nuxt.config.js
module.exports = {
  router: {
    middleware: ['visits', 'user-agent']
  }
}

请注意,在 Nuxt 中,我们不需要像在 Vue 应用程序中那样调用多个守卫的第三方包。

  1. 创建存储访问路由的存储器状态和变化:
// store/state.js
export default () => ({
  visits: []
})

// store/mutations.js
export default {
  addVisit (state, path) {
    state.visits.push({
      path,
      date: new Date().toJSON()
    })
  }
}
  1. about页面中使用user-agent中间件:
// pages/about.vue
<p>{{ userAgent }}</p>

export default {
  asyncData ({ userAgent }) {
    return {
      userAgent
    }
  }
}
  1. 至于visits中间件,我们希望在组件上使用它,然后将该组件注入到我们的布局中,即default.vue布局。首先,在/components/目录中创建visits组件:
// components/visits.vue
<li v-for="(visit, index) in visits" :key="index">
  <i>{{ visit.date | dates }} | {{ visit.date | times }}</i> - {{ 
    visit.path }}
</li>

export default {
  filters: {
    dates(date) {
      return date.split('T')[0]
    },
    times(date) {
      return date.split('T')[1].split('.')[0]
    }
  },
  computed: {
    visits() {
      return this.$store.state.visits.slice().reverse()
    }
  }
}

因此,我们在此组件中创建了两个过滤器。date过滤器用于从字符串中获取日期。例如,我们将从2019-05-24T21:55:44.673Z中获得2019-05-24。相比之下,time过滤器用于从字符串中获取时间。例如,我们将从2019-05-24T21:55:44.673Z中获得21:55:44

  1. visits组件导入到我们的布局中:
// layouts/default.vue
<template>
  <Visits />
</template>

import Visits from '~/components/visits.vue'
export default {
  components: {
    Visits
  }
}

当我们在路由之间导航时,我们应该在浏览器中获得以下结果:

2019-06-06 | 01:55:44 - /contact
2019-06-06 | 01:55:37 - /about
2019-06-06 | 01:55:30 - /

此外,当您在关于页面时,应该从请求头中获取用户代理的信息:

Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36

您可以在我们的 GitHub 存储库中的/chapter-11/nuxt-universal/route-middleware/global/中找到上述源代码。

全局中间件就介绍到这里。现在,让我们继续下一节的路由中间件。

编写路由中间件

添加路由中间件也非常简单;您只需在特定布局或页面的middleware键中声明它们。例如,看一下以下内容:

// pages/index.vue or layouts/default.vue
export default {
  middleware: 'auth'
}

因此,在接下来的步骤中,让我们创建一些路由中间件。在这个练习中,我们将使用会话和 JSON Web Tokens(JWT)来访问受限页面或受保护的 API。虽然在现实生活中,我们可以只使用会话或令牌进行身份验证系统,但我们将在练习中同时使用两者,以便了解如何将它们一起用于潜在更复杂的生产系统。在我们的练习中,我们希望用户登录并从服务器获取令牌。当令牌过期或无效时,用户将无法访问受保护的路由。

此外,当会话时间结束时,用户将被注销:

  1. 创建一个auth中间件来检查我们存储中是否有任何数据的状态。如果没有经过身份验证的数据,则我们使用 Nuxt 上下文中的error函数将错误发送到前端:
// middleware/auth.js
export default function ({ store, error }) {
  if (!store.state.auth) {
    error({
      message: 'You are not connected',
      statusCode: 403
    })
  }
}
  1. 创建一个token中间件来确保令牌在存储中;否则,它将错误发送到前端。如果存储中存在令牌,我们将使用令牌将Authorization设置为默认的axios标头:
// middleware/token.js
export default async ({ store, error }) => {
  if (!store.state.auth.token) {
    error({
      message: 'No token',
      statusCode: 403
    })
  }
  axios.defaults.headers.common['Authorization'] = `Bearer: ${store.state.auth.token}`
}
  1. 将这两个前置中间件添加到受保护页面的middleware键上:
// pages/secured.vue
<p>{{ greeting }}</p>

export default {
  async asyncData ({ redirect }) {
    try {
      const { data } = await axios.get('/api/private')
      return {
        greeting: data.data.message
      }
    } catch (error) {
      if(process.browser){
        alert(error.response.data.message)
      }
      return redirect('/login')
    }
  },
  middleware: ['auth', 'token']
}

在请求头中设置带有 JWT 的Authorization标头后,我们可以访问受保护的 API 路由,这些路由由服务器端中间件保护(我们将在第十二章中了解更多,创建用户登录和 API 身份验证)。我们将从受保护的 API 路由获取我们想要访问的数据,并且如果令牌不正确或已过期,将收到错误消息提示。

  1. /store/目录中创建存储的状态、mutations 和 actions 以存储经过身份验证的数据:
// store/state.js
export default () => ({
  auth: null
})

// store/mutations.js
export default {
  setAuth (state, data) {
    state.auth = data
  }
}

// store/actions.js
export default {
  async login({ commit }, { username, password }) {
    try {
      const { data } = await axios.post('/api/public/users/login', 
      { username, password })
      commit('setAuth', data.data)
    } catch (error) {
      // handle error
    }
  },

  async logout({ commit }) {
    await axios.post('/api/public/users/logout')
    commit('setAuth', null)
  }
}

已知并且预期的行为是,当页面刷新时,存储的状态会重置为默认值。如果我们想要保持状态,有一些解决方案可以使用:

  1. localStorage

  2. sessionStorage

  3. vuex-persistedstate(一个 Vuex 插件)

然而,在我们的情况下,由于我们使用会话来存储认证信息,我们实际上可以通过以下方式从会话中重新获取我们的数据:

  1. req.ctx.session(Koa)或 req.session(Express)

  2. req.headers.cookie

一旦我们决定要选择哪种解决方案或选项(比如 req.headers.cookie),然后我们可以按照以下方式重新填充状态:

// store/index.js
const cookie = process.server ? require('cookie') : undefined

export const actions = {
  nuxtServerInit({ commit }, { req }) {
    var session = null
    var auth = null
    if (req.headers.cookie && req.headers.cookie.indexOf('koa:sess') > -1) {
      session = cookie.parse(req.headers.cookie)['koa:sess']
    }
    if (session) {
      auth = JSON.parse(Buffer.from(session, 'base64'))
      commit('setAuth', auth)
    }
  }
}

您可以在我们的 GitHub 存储库中的 /chapter-11/nuxt-universal/route-middleware/per-route/ 中找到前面的源代码。

当所有前面的步骤都遵循并且中间件已经创建好后,我们可以通过 npm run dev 来运行这个简单的认证应用程序,看看它是如何工作的。我们将在下一章中介绍服务器端认证。现在,我们只需要专注于中间件并理解它的工作原理,这将有助于我们在下一章中。现在,让我们继续本章的最后一部分 - 服务器中间件。

编写 Nuxt 服务器中间件

简而言之,服务器中间件是在 Nuxt 中用作中间件的服务器端应用程序。自从第八章以来,我们一直在使用像 Koa 这样的服务器端框架来运行我们的 Nuxt 应用程序,添加服务器端框架。如果您使用 Express,这是您 package.json 文件中的 scripts 对象:

// package.json
"scripts": {
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch 
   server",
  "build": "nuxt build",
  "start": "cross-env NODE_ENV=production node server/index.js",
  "generate": "nuxt generate"
}

在这个 npm 脚本中,devstart 脚本指示服务器从 /server/index.js 运行您的应用程序。这可能不是理想的,因为我们将 Nuxt 和服务器端框架紧密耦合在一起,这会导致在配置中额外的工作。但是,我们可以告诉 Nuxt 不要附加到 /server/index.js 中的服务器端框架配置,并保持我们原始的 Nuxt 运行脚本如下所示:

// package.json
"scripts": {
  "dev": "nuxt",
  "build": "nuxt build",
  "start": "nuxt start",
  "generate": "nuxt generate"
}

相反,我们可以在 Nuxt 配置文件中使用 serverMiddleware 属性,使服务器端框架在 Nuxt 下运行。例如,看一下以下内容:

// nuxt.config.js
export default {
  serverMiddleware: [
    '~/api'
  ]
}

与路由中间件不同,路由中间件在客户端每个路由之前调用,而服务器中间件总是在 vue-server-renderer 之前在服务器端调用。因此,服务器中间件可以用于服务器特定的任务,就像我们在之前的章节中使用 Koa 或 Express 一样。因此,让我们在接下来的章节中探讨如何在 Express 和 Koa 中使用作为我们的服务器中间件。

使用 Express 作为 Nuxt 的服务器中间件

让我们使用 Express 作为 Nuxt 的服务器中间件来创建一个简单的身份验证应用程序。我们将继续使用身份验证练习中的客户端代码,以及你在前一节中学到的每个路由中间件,其中用户需要提供用户名和密码才能访问受保护的页面。此外,我们将使用 Vuex 存储来集中存储认证用户数据,就像以前一样。这个练习的主要区别在于,我们的 Nuxt 应用程序将作为中间件移出服务器端应用程序,而服务器端应用程序将作为中间件移入Nuxt 应用程序。所以,让我们按照以下步骤开始:

  1. 安装cookie-sessionbody-parser作为服务器中间件,并在 Nuxt 的config文件中添加它们之后的 API 路径,如下所示:
// nuxt.config.js
import bodyParser from 'body-parser'
import cookieSession from 'cookie-session'

export default {
  serverMiddleware: [
    bodyParser.json(),
    cookieSession({
      name: 'express:sess',
      secret: 'super-secret-key',
      maxAge: 60000
    }),
    '~/api'
  ]
}

请注意,cookie-session 是 Express 的基于 cookie 的会话中间件,它将会话存储在客户端的 cookie 中。相比之下,body-parser 是 Express 的一个用于解析请求体的中间件,就像你在第八章中学到的 Koa 的koa-bodyparser一样。

有关 Express 的cookie-sessionbody-parser的更多信息,请访问github.com/expressjs/cookie-sessiongithub.com/expressjs/body-parser

  1. 使用index.js文件创建一个/api/目录,在其中导入 Express 并将其导出为另一个服务器中间件:
// api/index.js
import express from 'express'
const app = express()

app.get('/', (req, res) => res.send('Hello World!'))

// Export the server middleware
export default {
  path: '/api',
  handler: app
}
  1. 使用npm run dev运行应用程序,你应该在localhost:3000/api中收到“Hello World!”消息。

  2. 按照以下步骤在/api/index.js中添加loginlogout的 post 方法:

// api/index.js
app.post('/login', (req, res) => {
  if (req.body.username === 'demo' && req.body.password === 'demo') {
    req.session.auth = { username: 'demo' }
    return res.json({ username: 'demo' })
  }
  res.status(401).json({ message: 'Bad credentials' })
})

app.post('/logout', (req, res) => {
  delete req.session.auth
  res.json({ ok: true })
})

在上述代码中,当用户成功登录时,我们将认证有效载荷存储到 Express 会话中作为 HTTP 请求对象中的auth。然后,当用户注销时,我们将通过删除它来清除auth会话。

  1. 创建一个包含state.jsmutations.js的存储,就像你为编写每个路由中间件所做的那样,如下所示:
// store/state.js
export default () => ({
  auth: null,
})

// store/mutations.js
export default {
  setAuth (state, data) {
    state.auth = data
  }
}
  1. 就像编写每个路由中间件一样,在存储中的actions.js文件中创建loginlogout动作方法,如下所示:
// store/actions.js
import axios from 'axios'

export default {
  async login({ commit }, { username, password }) {
    try {
      const { data } = await axios.post('/api/login', { username,
        password })
      commit('setAuth', data)
    } catch (error) {
      // handle error...
    }
  },

  async logout({ commit }) {
    await axios.post('/api/logout')
    commit('setAuth', null)
  }
}
  1. 在存储的index.js中添加一个nuxtServerInit动作,以便在刷新页面时从 Express 会话中重新填充状态到 HTTP 请求对象中:
// store/index.js
export const actions = {
  nuxtServerInit({ commit }, { req }) {
    if (req.session && req.session.auth) {
      commit('setAuth', req.session.auth)
    }
  }
}
  1. 最后,就像在逐路由中间件身份验证中一样,在/pages/目录中创建一个登录页面,并使用相同的loginlogout方法来调度存储中的loginlogout操作方法:
// pages/index.vue
<form v-if="!$store.state.auth" @submit.prevent="login">
  <p v-if="error" class="error">{{ error }}</p>
  <p>Username: <input v-model="username" type="text"
     name="username"></p>
  <p>Password: <input v-model="password" type="password" 
     name="password"></p>
  <button type="submit">Login</button>
</form>

export default {
  data () {
    return {
      error: null,
      username: '',
      password: ''
    }
  },
  methods: {
    async login () { ... },
    async logout () { ... }
  }
}
  1. 使用npm run dev运行应用程序。您应该有一个与以前一样工作的身份验证应用程序,但它不再是从/server/index.js运行的。

你可以在我们的 GitHub 存储库的/chapter-11/nuxt-universal/server-middleware/express/中找到前面的源代码。

使用serverMiddleware属性使我们的 Nuxt 应用程序看起来整洁,感觉轻盈,不是吗?通过这种方法,我们也可以使其更加灵活,因为我们可以使用任何服务器端框架或应用程序。例如,我们可以使用 Koa,而不是使用 Express,我们将在下一节中讨论。

使用 Koa 作为 Nuxt 的服务器中间件

就像 Koa 和 Express 一样,Connect 是一个简单的框架,用于粘合各种中间件来处理 HTTP 请求。Nuxt 在内部使用 Connect 作为服务器,因此大多数 Express 中间件都可以与 Nuxt 的服务器中间件一起使用。相比之下,Koa 中间件要作为 Nuxt 的服务器中间件工作要困难一些,因为在 Koa 中,reqres对象被隐藏并保存在ctx中。我们可以通过一个简单的“Hello World”消息来比较这三个框架,如下所示:

// Connect
const connect = require('connect')
const app = connect()
app.use((req, res, next) => res.end('Hello World'))

// Express
const express = require('express')
const app = express()
app.get('/', (req, res, next) => res.send('Hello World'))

// Koa
const Koa = require('koa')
const app = new Koa()
app.use(async (ctx, next) => ctx.body = 'Hello World')

请注意,req是一个 Node.js HTTP 请求对象,而res是一个 Node.js HTTP 响应对象。它们可以被命名为任何你喜欢的东西,例如,request而不是reqresponse而不是res。从前面的比较中,你可以看到 Koa 如何与其他框架不同地处理这两个对象。因此,我们不能像在 Express 中那样将 Koa 用作 Nuxt 的服务器中间件,也不能在serverMiddleware属性中定义任何 Koa 中间件,而只能添加 Koa API 所在目录的路径。请放心,让它们作为 Nuxt 应用程序中的中间件工作并不困难。让我们继续以下步骤:

  1. 添加我们想要使用 Koa 创建 API 的路径,如下所示:
// nuxt.config.js
export default {
  serverMiddleware: [
    '~/api'
  ]
}
  1. 导入koakoa-router,使用路由创建一个Hello World!消息,然后将它们导出到/api/目录中的index.js文件中:
// api/index.js
import Koa from 'koa'
import Router from 'koa-router'

router.get('/', async (ctx, next) => {
  ctx.type = 'json'
  ctx.body = {
    message: 'Hello World!'
  }
})

app.use(router.routes())
app.use(router.allowedMethods())

// Export the server middleware
export default {
  path: '/api',
  handler: app.listen()
}
  1. 导入koa-bodyparserkoa-session,并在/api/index.js文件中将它们注册为中间件,如下所示:
// api/index.js
import bodyParser from 'koa-bodyparser'
import session from 'koa-session'

const CONFIG = {
  key: 'koa:sess',
  maxAge: 60000,
}

app.use(session(CONFIG, app))
app.use(bodyParser())
  1. 使用 Koa 路由创建loginlogout路由,如下所示:
// api/index.js
router.post('/login', async (ctx, next) => {
  let request = ctx.request.body || {}
  if (request.username === 'demo' && request.password === 'demo') {
    ctx.session.auth = { username: 'demo' }
    ctx.body = {
      username: 'demo'
    }
  } else {
    ctx.throw(401, 'Bad credentials')
  }
})

router.post('/logout', async (ctx, next) => {
  ctx.session = null
  ctx.body = { ok: true }
})

在上述代码中,就像在上一节中的 Express 示例中一样,当用户成功登录时,我们将经过身份验证的有效负载存储到 Koa 会话中的auth中。然后,当用户注销时,我们将通过将会话设置为null来清除auth会话。

  1. 创建一个带有状态、变异和操作的存储,就像您在 Express 示例中所做的那样。此外,在存储中的index.js文件中创建nuxtServerInit,就像您在编写每个路由中间件时所做的那样:
// store/index.js
export const actions = {
  nuxtServerInit({ commit }, { req }) {
    // ...
  }
}
  1. 就像以前一样,在/pages/目录中创建loginlogout方法来调度存储中的操作方法:
// pages/index.vue
<form v-if="!$store.state.auth" @submit.prevent="login">
  //...
</form>

export default {
  methods: {
    async login () { ... },
    async logout () { ... }
  }
}
  1. 使用npm run dev运行应用程序。您应该有一个身份验证应用程序,其工作方式与上一节中 Express 中的应用程序相同,但它不再是从/server/index.js运行的。

您可以在我们的 GitHub 存储库的/chapter-11/nuxt-universal/server-middleware/koa/中找到此示例的整个源代码。

根据您的喜好,您可以在下一个项目中使用 Express 或 Koa 作为 Nuxt 的服务器中间件。在本书中,我们主要使用 Koa 因为它简单易用。您甚至可以创建自定义服务器中间件,而无需使用它们中的任何一个。让我们在下一节中看看如何创建自定义服务器中间件。

创建自定义服务器中间件

由于 Nuxt 在内部使用 Connect 作为服务器,因此我们可以添加自定义中间件,而无需外部服务器,如 Koa 或 Express。您可以开发一个复杂的 Nuxt 服务器中间件,就像我们在前几节中使用 Koa 和 Express 一样。但是,让我们不要无休止地重复我们已经做过的事情。让我们创建一个非常基本的自定义中间件,以打印“Hello World”消息来确认从基本中间件构建复杂中间件的可行性:

  1. 添加我们想要创建自定义中间件的路径:
// nuxt.config.js
serverMiddleware: [
  { path: '/api', handler: '~/api/index.js' }
]
  1. 将 API 路由添加到/api/目录中的index.js文件中:
// api/index.js
export default function (req, res, next) {
  res.end('Hello world!')
}
  1. 使用npm run dev运行应用程序,并导航到localhost:3000/api。您应该在屏幕上看到打印的“Hello World!”消息。

您可以在github.com/senchalabs/connect上查找 Connect 文档以获取更多信息。此外,您可以在我们的 GitHub 存储库的/chapter-11/nuxt-universal/server-middleware/custom/中找到此示例的源代码。

干得好! 你已经成功完成了 Nuxt 的另一个重要章节。在继续下一章之前,让我们总结一下你到目前为止学到的东西。

总结

在本章中,你学到了路由中间件和服务器中间件之间的区别。你使用了 Vue Router 的导航守卫来为 Vue 应用程序创建中间件。你还使用了 Vue CLI 来开发一个简单的 Vue 身份验证应用程序。根据你对 Vue 应用程序的学习,你在 Nuxt 应用程序中使用了全局和每个路由的中间件来实现相同的概念(路由中间件)。之后,你学习了 Nuxt 的服务器中间件以及如何使用 Express 和 Koa 作为服务器中间件。中间件对于身份验证和安全非常重要和有用。我们已经制作了一些身份验证应用程序,并将在下一章中更详细地研究和理解它们。

在下一章中,你将详细学习有关开发用户登录和身份验证 API 的内容,以改进你在本章中创建的身份验证应用程序。我们将为你介绍基于会话的身份验证和基于令牌的身份验证。虽然你已经使用这两种技术创建了身份验证应用程序,但我们还没有解释它们是什么。但请放心,你将在下一章更好地理解它们。除此之外,你还将学习如何为你的 Nuxt 应用程序创建后端和前端身份验证,并使用 Google OAuth 进行登录。所以,请继续关注!

创建用户登录和 API 身份验证

在过去的两章中,我们开始在 Nuxt 应用程序中使用会话和 JSON Web Token(JWT)进行身份验证。我们在第十章中使用会话进行身份验证,添加 Vuex Store,以练习nuxtServerInit。然后我们在第十一章中使用会话和令牌一起进行身份验证,编写路由中间件和服务器中间件,以练习按路由中间件,例如:

// store/index.js
nuxtServerInit({ commit }, { req }) {
  if (req.ctx.session && req.ctx.session.authUser) {
    commit('setUser', req.ctx.session.authUser)
  }
}

// middleware/token.js
export default async ({ store, error }) => {
  if (!store.state.auth.token) {
    // handle error
  }
  axios.defaults.headers.common['Authorization'] = Bearer: ${store.state.auth.token}
}

如果您是新手,它们可能会让人感到不知所措,但不用担心。简而言之,身份验证是验证您是谁的过程。身份验证系统允许您在您的凭据与数据库或数据身份验证服务器中的凭据匹配时访问资源。有几种身份验证方法。基于会话和基于令牌的身份验证是最常见的,或者这两种的组合。所以,让我们深入了解它们。

本章我们将涵盖以下主题:

  • 理解基于会话的身份验证

  • 理解基于令牌的身份验证

  • 创建后端身份验证

  • 创建前端身份验证

  • 使用 Google OAuth 进行登录

第十二章:理解基于会话的身份验证

超文本传输协议(HTTP)是无状态的。因此,所有 HTTP 请求都是无状态的。这意味着它不记住任何我们已经验证过的东西或任何用户,我们的应用程序也不知道它是否是上一个请求的同一个人。因此,我们将不得不在下一个请求上再次进行身份验证。这并不理想。

因此,基于会话和基于 Cookie 的身份验证(通常仅称为基于会话的身份验证)被引入以在 HTTP 请求之间存储用户数据,以消除 HTTP 请求的无状态性质。它们使身份验证过程“有状态”。这意味着经过身份验证的记录或会话存储在服务器和客户端两侧。服务器可以将活动会话保存在数据库或服务器内存中,因此它被称为基于会话的身份验证。客户端可以创建一个 Cookie 来保存会话标识符(会话 ID),因此它被称为基于 Cookie 的身份验证。

但是会话和 Cookie 到底是什么?让我们在接下来的章节中深入了解它们。

会话是在两个或多个通信设备之间,或者在计算机和用户之间交换的临时信息片段。它在特定时间建立,然后在将来的某个时间到期。当用户关闭浏览器或离开网站时,会话也会到期。建立会话时,在服务器的临时目录(或数据库或服务器内存)中创建一个文件,用于存储注册的会话值。然后在整个访问期间,这些数据都可用,并且浏览器会接收一个会话 ID,该 ID 将通过 cookie 或GET变量发送回服务器进行验证。

简而言之,cookie 和会话只是数据。Cookie 仅存储在客户端机器上,而会话既存储在客户端又存储在服务器上。会话被认为比 cookie 更安全,因为数据可以仅保存在服务器上。当会话建立时通常会创建 cookie,并且它们保存在客户端计算机上。它们可以是经过身份验证的用户的名称、年龄或 ID,并且由浏览器发送回服务器以识别用户。让我们在下一节通过示例流程来看看它们是如何工作的。

会话身份验证流程

基于会话和基于 cookie 的身份验证可以通过以下示例身份验证流程来理解:

  1. 用户从其浏览器上的客户端应用程序发送其凭据,例如用户名和密码,到服务器。

  2. 服务器检查凭据并向客户端发送一个唯一的令牌(会话 ID)。此令牌还将保存在服务器端的数据库或内存中。

  3. 客户端应用程序将令牌存储在客户端的 cookie 中,并在每个 HTTP 请求中使用它并发送回服务器。

  4. 服务器接收令牌并对用户进行身份验证,然后将请求的数据返回给客户端应用程序。

  5. 客户端应用程序在用户注销时销毁令牌。在注销之前,客户端还可以向服务器发送请求以删除会话,或者会话将根据设置的到期时间自行结束。

在基于会话的身份验证中,服务器承担了所有繁重的工作。它是有状态的。它将会话标识符与用户账户关联起来(例如,在数据库中)。基于会话的身份验证的缺点是,在大量用户同时使用系统时,可伸缩性会受到影响,因为会话存储在服务器的内存中,因此涉及大量的内存使用。此外,cookie 在单个域或子域上运行良好,但通常在跨域共享(跨域资源共享)时被浏览器禁用。因此,当客户端从不同的域中进行 API 请求时,这会给客户端造成问题。但是,这个问题可以通过基于令牌的身份验证来解决,我们将在下一节中详细介绍。

理解基于令牌的身份验证

基于令牌的身份验证更简单。有一些令牌的实现,但是 JSON Web Tokens 是最常见的一种。基于令牌的身份验证是无状态的。这意味着服务器端不会保留任何会话,因为状态存储在客户端的令牌中。服务器的责任只是使用秘钥创建一个 JWT 并将其发送给客户端。客户端将 JWT 存储在本地存储中,或者客户端的 cookie 中,并在发出请求时将其包含在标头中。服务器然后验证 JWT 并发送响应。

但是 JWT 是什么,它是如何工作的?让我们在下一节中找出答案。

什么是 JSON Web Tokens?

要理解 JWT 的工作原理,我们首先应该了解它是什么。简而言之,JWT 是一个由标头、有效载荷和签名组成的哈希 JSON 对象的字符串。JWT 的生成格式如下:

header.payload.signature

标头通常由两部分组成:类型和算法。类型是 JWT,算法可以是 HMAC、SHA256 或 RSA,这是一种使用秘钥对令牌进行签名的哈希算法,例如:

{
  "typ": "JWT",
  "alg": "HS256"
}

有效载荷是 JWT 中存储信息(或声明)的部分,例如:

{
  "userId": "b08f86af-35da-48f2-8fab-cef3904660bd",
  "name": "Jane Doe"
}

在这个例子中,我们在有效载荷中只包括了两个声明。您可以放置任意多个声明。您包含的声明越多,JWT 的大小就越大,这可能会影响性能。还有其他可选的声明,比如iss(发行者)、sub(主题)和exp(过期时间)。

如果您想了解有关 JWT 标准字段的更多详细信息,请访问tools.ietf.org/html/rfc7519

签名是使用编码的标头、编码的有效负载、一个密钥和标头中指定的算法计算的。无论您在标头部分选择了什么算法,您必须使用该算法来加密 JWT 的前两部分:base64(header) + '.' + base64(payload),例如,在这个伪代码中:

// signature algorithm
data = base64urlEncode(header) + '.' + base64urlEncode(payload)
hashedData = hash(data, secret)
signature = base64urlEncode(hashedData)

签名是 JWT 中唯一不公开可读的部分,因为它是用一个秘钥加密的。除非有人有秘钥,否则他们无法解密这些信息。因此,前面伪代码的示例输出是由三个由点分隔的 Base64-URL 字符串,可以在 HTTP 请求中轻松传递。

// JWT Token
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

让我们在下一节看看这个令牌认证是如何工作的,附带一个示例流程。

令牌认证流程

基于令牌的认证可以通过以下示例认证流程来理解:

  1. 用户从他们的浏览器上的客户端应用发送他们的凭据,例如用户名和密码,到服务器。

  2. 服务器检查用户名和密码,如果凭据正确,则返回一个签名令牌(JWT)。

  3. 这个令牌存储在客户端。它可以存储在本地存储、会话存储或者 cookie 中。

  4. 客户端应用通常会在任何后续请求到服务器时将该令牌作为附加标头包含进去。

  5. 服务器接收并解码 JWT,然后如果令牌有效就允许请求访问。

  6. 当用户注销并且不再需要与服务器进行进一步交互时,令牌将在客户端销毁。

在基于令牌的认证中,通常不应在有效负载中包含任何敏感信息,并且令牌不应保留太长时间。您用于包含令牌的附加标头应该是这种格式:

Authorization: Bearer <token>

基于令牌的认证中的可扩展性不是一个问题,因为令牌存储在客户端。跨域共享也不是一个问题,因为 JWT 是一个包含所有必要信息的字符串,包含在请求标头中,由服务器检查每个客户端发出的请求。在 Node.js 应用中,我们可以使用 Node.js 模块之一,比如jsonwebtoken,来为我们生成令牌。让我们在下一节看看我们如何使用这个 Node.js 模块。

使用 Node.js 模块进行 JWT

正如我们之前提到的,jsonwebtoken可以用于在服务器端生成 JWT。您可以在以下简化的步骤中同步或异步地使用这个模块:

  1. 通过 npm 安装jsonwebtoken
$ npm i jsonwebtoken
  1. 在服务器端导入并签署令牌:
import jwt from 'jsonwebtoken'
var token = jwt.sign({ name: 'john' }, 'secret', { expiresIn: '1h' })
  1. 在服务器端异步验证来自客户端的令牌:
try {
  var verified = jwt.verify(token, 'secret')
} catch(err) {
  // handle error
}

如果您想了解有关此模块的更多信息,请访问github.com/brianloveswords/node-jws

所以,现在您对基于会话和基于令牌的身份验证有了基本的了解,我们将指导您如何在使用 Koa 和 Nuxt 的服务器端和客户端应用程序中应用它们。在本章中,我们将使用基于令牌的身份验证在我们的应用程序中创建两种身份验证选项:本地身份验证和 Google OAuth 身份验证。本地身份验证是我们在应用程序内部和本地验证用户的选项,而 Google OAuth 身份验证是我们使用 Google OAuth 验证用户的选项。所以,让我们在接下来的章节中找出来!

创建后端身份验证

在第十章和第十一章中的先前练习,添加 Vuex 存储编写路由中间件和服务器中间件,我们在后端身份验证中使用了一个虚拟用户,特别是在/chapter-11/nuxt-universal/route-middleware/per-route/中用于每个路由中间件的虚拟用户,例如:

// server/modules/public/user/_routes/login.js
router.post('/login', async (ctx, next) => {
  let request = ctx.request.body || {}

  if (request.username === 'demo' && request.password === 'demo') {
    let payload = { id: 1, name: 'Alexandre', username: 'demo' }
    let token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: 1 * 60 })
    //...
  }
})

但在本章中,我们将使用一个带有一些用户数据的数据库进行身份验证。此外,在第九章中,添加服务器端数据库,我们使用 MongoDB 作为我们的数据库服务器。但这一次,让我们尝试一种不同的数据库系统,以增加多样性 – MySQL。所以,让我们开始吧。

使用 MySQL 作为服务器数据库

确保您的本地计算机上安装了 MySQL 服务器。在撰写本书时,最新的 MySQL 版本是 5.7。根据您使用的操作系统,您可以在dev.mysql.com/doc/mysql-installation-excerpt/5.7/en/installing.html找到系统的具体指南。如果您使用的是 Linux,您可以在dev.mysql.com/doc/mysql-installation-excerpt/5.7/en/linux-installation.html找到 Linux 发行版的安装指南。如果您使用的是 Linux Ubuntu 并且使用 APT 存储库,您可以按照dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/apt-repo-fresh-install中的指南操作。

或者,您可以安装 MariaDB 服务器,而不是 MySQL 服务器,以在项目中使用关系数据库管理系统(DBMS)。同样,根据您使用的操作系统,您可以在mariadb.com/downloads/找到系统的具体指南。如果您使用的是 Linux,您可以在downloads.mariadb.org/mariadb/repositories/找到特定 Linux 发行版的指南。如果您使用的是 Linux Ubuntu 19.10,您可以按照downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=eoan--ubuntu_eoan&mirror=bme&version=10.4中的指南操作。

无论您选择哪种方式,都很方便在浏览器中使用管理工具来管理您的 MySQL 数据库。您可以使用 phpMyAdmin 或 Adminer(www.adminer.org/latest.php);两者都需要在您的计算机上安装 PHP。如果您对 PHP 不熟悉,可以在第十六章中使用安装指南,为 Nuxt 创建一个与框架无关的 PHP API。本书中更倾向于使用 Adminer。您可以在www.phpmyadmin.net/downloads/下载该程序。如果您想使用 phpMyAdmin,请访问www.phpmyadmin.net/了解更多信息。一旦您有了管理工具,请按照以下步骤设置我们在本章中将需要的数据库:

  1. 使用 Adminer 创建一个名为“nuxt-auth”的数据库。

  2. 在数据库中插入以下表格和示例数据:

DROP TABLE IF EXISTS users;
CREATE TABLE users (
  id int(11) NOT NULL AUTO_INCREMENT,
  name varchar(255) NOT NULL,
  email varchar(255) NOT NULL,
  username varchar(255) NOT NULL,
  password varchar(255) NOT NULL,
  created_on datetime NOT NULL,
  last_on datetime NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY email (email),
  UNIQUE KEY username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO users (id, name, email, username, password, created_on, last_on) VALUES
(1, 'Alexandre', 'demo@gmail.com', 'demo', '$2a$10$pyMYtPfIvE.PAboF3cIx9.IsyW73voMIRxFINohzgeV0I2BxwnrEu', '2019-06-17 00:00:00', '2019-01-21 23:32:58');

前面示例数据中的用户密码是123123,并且以$2a$10$pyMYtPfIvE.PAboF3cIx9.IsyW73voMIRxFINohzgeV0I2BxwnrEu的形式进行了 bcrypt 加密。我们将安装并使用bcryptjs Node.js 模块来在服务器端对此密码进行哈希和验证。但在跳转到bcryptjs之前,让我们先看一下我们将在下一节中创建的应用程序的结构。

您可以在我们的 GitHub 存储库的/chapter-12/中找到我们导出的数据库副本nuxt-auth.sql

跨域应用目录结构

我们一直在为单个域制作 Nuxt 应用程序。自从第八章以来,我们的服务器端 API 与 Nuxt 紧密耦合,添加服务器端框架,在这一章中,我们使用 Koa 作为处理和为 Nuxt 应用程序提供数据的服务器端框架和 API。如果你回顾一下我们在 GitHub 存储库中的/chapter-8/nuxt-universal/koa-nuxt/,你应该记得我们将服务器端程序和文件保存在/server/目录中。我们还将我们的包/模块依赖项保存在一个package.json文件中,并在同一个/node_modules/目录中安装它们。当我们的应用程序变得更大时,混合两个框架(Nuxt 和 Koa)的模块依赖项在同一个package.json文件中可能会令人困惑。这也可能使调试过程变得更加困难。因此,将由 Nuxt 和 Koa(或其他服务器端框架,如 Express)制作的单个应用程序分开可能更有利于可扩展性和维护。现在,是时候制作一个跨域 Nuxt 应用程序了。我们将重用并重组我们在第八章中制作的 Nuxt 应用程序,添加服务器端框架。让我们称我们的 Nuxt 应用程序为前端应用程序,Koa 应用程序为后端应用程序。随着我们的进展,我们将分别在这两个应用程序中添加新的模块。

后端应用程序将进行后端身份验证,而前端应用程序将分别进行前端身份验证,但最终它们将作为一个整体。为了使您更容易学习和重组这个过程,我们将仅使用 JWT 进行身份验证。因此,让我们按照以下步骤创建我们的新工作目录:

  1. 创建一个项目目录,并以您喜欢的任何名称命名,其中包含两个子目录。一个称为frontend,另一个称为backend,如下所示:
<project-name>
├── frontend
└── backend
  1. 使用脚手架工具create-nuxt-app/frontend/目录中安装 Nuxt 应用程序,以便获得您已经熟悉的 Nuxt 目录,如下所示:
frontend
├── package.json
├── nuxt.config.js
├── store
│ ├── index.js
│ └── ...
└── pages
    ├── index.vue
    └── ...
  1. /backend/目录中创建一个package.json文件,一个backpack.config.js文件,一个/static/文件夹和一个/src/文件夹,然后在/src/文件夹中按照以下方式添加其他文件和子文件夹(我们将在接下来的部分中更详细地介绍它们):
backend
├── package.json
├── backpack.config.js
├── assets
│ └── ...
├── static
│ └── ...
└── src
    ├── index.js
    ├── ...
    ├── modules
    │ └── ...
    └── core
        └── ...

后端目录是我们的 API 所在的地方,可以使用 Express 或 Koa 来创建。我们仍然会使用 Koa,这是您已经熟悉的。我们将在这个目录中安装服务器端的依赖,比如mysqlbcryptjsjsonwebtoken,这样它们就不会与 Nuxt 应用的前端模块混在一起。

正如您所看到的,在这种新的结构中,我们成功地完全分离和解耦了我们的 API 和 Nuxt 应用。这对于调试和开发有好处。从技术上讲,我们现在将一次开发和测试一个应用。在单个环境中开发两个应用可能会令人困惑,当应用变得更大时,协作可能会变得困难,就像我们之前提到的那样。

在深入研究如何在服务器端使用 JWT 之前,让我们首先在下一节深入研究如何在/src/目录中结构化 API 路由和模块。

创建 API 公共/私有路由及其模块

请注意,在本书中,不是强制遵循此处建议的目录结构。关于如何使用 Koa 来构建应用程序的官方或任意规则是没有的。Koa 社区提供了一些骨架、样板和框架,您可以访问github.com/koajs/koa/wiki了解更多信息。现在让我们更仔细地看一下/src/目录中的目录结构,在接下来的步骤中,我们将在这里开发我们的 API 源代码。

  1. 按照以下方式在/src/目录中创建以下文件夹和空的.js文件:
└── src
    ├── index.js
    ├── middlewares.js
    ├── routes-private.js
    ├── routes-public.js
    ├── config
    │ └── index.js
    ├── core
    │ └── database
    ├── middlewares
    │ ├── authenticate.js
    │ ├── errorHandler.js
    │ └── ...
    └── modules
        └── ...

/src/目录中,/middlewares/目录是存放所有中间件的地方,比如authenticate.js,我们希望将其注册到 Kao 的app.use方法中,而/modules/目录是存放所有 API 端点组的地方,比如homeuserlogin

  1. 创建两个主要目录,privatepublic,每个目录中都有子目录,如下所示:
└── modules
    ├── private
    │ └── home
    └── public
        ├── home
        ├── user
        └── login

/public/目录用于无需 JWT 的公共访问,例如登录路由,而/private/目录用于需要 JWT 保护模块的访问。正如你所看到的,我们已将 API 路由分为两个主要组,因此/private/组将在routes-private.js中处理,而/public/组将在routes-public.js中处理。我们有/config/目录来保存所有配置文件,以及/core/目录来保存可以在整个应用程序中共享和使用的抽象程序或模块,例如你将在本章后面发现的 mysql 连接池。因此,从前面的目录树中,我们将在我们的 API 中使用这些公共模块:homeuserlogin,以及一个私有模块:home

  1. 在每个模块中,例如user模块,创建一个/_routes/目录来配置属于该特定模块(或组)的所有路由(或端点):
└── user
    ├── index.js
    └── _routes
        ├── index.js
        └── fetch-user.js

user模块中,/user/index.js文件是该模块的所有路由被组装和分组的地方,例如:

// src/modules/public/user/index.js
import Router from 'koa-router'
import fetchUsers from './_routes'
import fetchUser from './_routes/fetch-user'

const router = new Router({
  prefix: '/users'
})
const routes = [fetchUsers, fetchUser]

for (var route of routes) {
  router.use(route.routes(), route.allowedMethods())
}

prefix键设置为/users是该用户模块的模块路由。在每个导入的子路由内部是我们开发代码的地方,例如登录路由的代码。

  1. 在每个模块的每个.js文件中,例如user模块,添加以下用于在后期构建我们的代码的基本代码结构:
// src/modules/public/user/_routes/index.js
import Router from 'koa-router'
import pool from 'core/database/mysql'

const router = new Router()

router.get('/', async (ctx, next) => {
  // code goes here....
})
export default router
  1. 让我们创建home模块,它将返回一个包含'Hello World!'消息的响应。
// src/modules/public/home/_routes/index.js
import Router from 'koa-router'
const router = new Router()

router.get('/', async (ctx, next) => {
  ctx.type = 'json'
  ctx.body = {
    message: 'Hello World!'
  }
})
export default router
  1. home模块只有一个路由,但我们仍然需要在该模块的index.js文件中组装此路由,以便我们的代码与其他模块保持一致,如下所示:
// src/modules/public/home/index.js
import Router from 'koa-router'
import index from './_routes'

const router = new Router() // no prefix
const routes = [index]

for (var route of routes) {
  router.use(route.routes(), route.allowedMethods())
}
export default router

请注意,此home模块未添加前缀,因此我们可以直接在localhost:4000/public上访问其唯一路由。

  1. /src/目录中创建routes-public.js文件,并从/modules/目录中的公共模块导入所有公共路由,如下所示:
// src/routes-public.js
import Router from 'koa-router'

import home from './modules/public/home'
import user from './modules/public/user'
import login from './modules/public/login'

const router = new Router({ prefix: '/public' })
const modules = [home, user, login]

for (var module of modules) {
  router.use(module.routes(), module.allowedMethods())
}
export default router

正如你所看到的,我们导入了刚刚创建的home模块。我们将在接下来的部分中创建userlogin模块。导入这些模块后,我们应该将它们的路由注册到路由器,然后导出路由器。请注意,这些路由都添加了前缀/public。还要注意,每个路由都使用纯 JavaScript 的for循环函数进行循环注册到路由器。

  1. /src/目录中创建routes-private.js文件,并从/modules/目录中导入所有私有模块中的私有路由,如下所示:
// src/routes-private.js
import Router from 'koa-router'

import home from './modules/private/home'
import authenticate from './middlewares/authenticate'

const router = new Router({ prefix: '/private' })
const modules = [home]

for (var module of modules) {
  router.use(authenticate, module.routes(), module.allowedMethods())
}
export default router

在这个文件中,你可以看到我们将在接下来的章节中只创建一个私有home模块。此外,这个文件中导入了一个authenticate中间件,并将其添加到私有路由中,以便保护私有模块。之后,我们应该导出带有路由的私有路由,并用/private前缀。我们也将在接下来的章节中创建这个authenticate中间件。现在,让我们用 Backpack 配置我们的模块文件路径,并安装我们的 API 基本依赖的 Node.js 模块。

  1. 通过 Backpack 配置文件向 webpack 配置中添加以下额外的文件路径(./src, ./src/core, 和 ./src/modules):
// backpack.config.js
module.exports = {
  webpack: (config, options, webpack) => {
    config.resolve.modules = ['./src', './src/core',
      './src/modules']
    return config
  }
}

有了这些额外的文件路径,我们可以简单地用import pool from 'core/database/mysql'导入我们的模块,而不是以下方式:

import pool from '../../../../core/database/mysql'

有关使用 webpack 中的modules选项解析模块的更多信息,请访问webpack.js.org/configuration/resolve/#resolvemodules

  1. 现在我们应该在我们的项目中安装 Backpack,以及其他基本和必要的 Node.js 模块,以便开发这个后端应用程序:
$ npm i backpack-core
$ npm i cross-env
$ npm i koa
$ npm i koa-bodyparser
$ npm i koa-favicon
$ npm i koa-router
$ npm i koa-static

你应该熟悉这些模块,因为你已经在第八章中学习过它们并安装了它们,添加服务器端框架,你可以在我们的 GitHub 存储库的/chapter-8/nuxt-universal/koa-nuxt/中重新访问它,还有第十章,添加 Vuex Store,在/chapter-10/nuxt-universal/nuxtServerInit/,以及第十一章,编写路由中间件和服务器中间件,在/chapter-11/nuxt-universal/route-middleware/per-route/

  1. /backend/目录中的package.json中添加以下运行脚本:
// package.json 
{
  "scripts": {
    "dev": "backpack",
    "build": "backpack build",
    "start": "cross-env NODE_ENV=production node build/main.js"
  }
}

因此,"dev"运行脚本用于开发我们的 API,"build"运行脚本用于在完成时构建我们的 API,"start"脚本用于构建后为 API 提供服务。

  1. /config/目录中的index.js文件中添加以下服务器配置:
// src/config/index.js
export default {
  server: {
    port: 4000
  },
}

这个配置文件只有一个非常简单的配置,即服务器配置为在端口4000上运行。

  1. 导入您刚刚安装的以下模块,并在/src/目录中的middlewares.js文件中注册它们如下:
// src/middlewares.js
import serve from 'koa-static'
import favicon from 'koa-favicon'
import bodyParser from 'koa-bodyparser'

export default (app) => {
  app.use(serve('assets'))
  app.use(favicon('static/favicon.ico'))
  app.use(bodyParser())
}
  1. /middlewares/目录中创建一个处理具有200 HTTP 状态的 HTTP 响应的中间件:
// src/middlewares/okOutput.js
export default async (ctx, next) => {
  await next()
  if (ctx.status === 200) {
    ctx.body = {
      status: 200,
      data: ctx.body
    }
  }
}

如果响应正常,我们将获得以下 JSON 输出:

{"status":200,"data":{"message":"Hello World!"}}
  1. 创建一个处理 HTTP 错误状态(例如400404500)的中间件:
export default async (ctx, next) => {
  try {
    await next()
  } catch (err) {
    ctx.status = err.status || 500

    ctx.type = 'json'
    ctx.body = {
      status: ctx.status,
      message: err.message
    }

    ctx.app.emit('error', err, ctx)
  }
}

对于400错误响应,您将获得以下 JSON 响应:

{"status":400,"message":"username param is required."}
  1. 创建一个专门处理 HTTP 404 响应的中间件,通过抛出一个'Not found'消息:
// src/middlewares/notFound.js
export default async (ctx, next) => {
  await next()
  if (ctx.status === 404) {
    ctx.throw(404, 'Not found')
  }
}

对于未知路由,我们将获得以下 JSON 输出:

{"status":404,"message":"Not found"}
  1. 将这三个中间件导入middlewares.js并像其他中间件一样注册到 Koa 实例中:
// src/middlewares.js
import errorHandler from './middlewares/errorHandler'
import notFound from './middlewares/notFound'
import okOutput from './middlewares/okOutput'

export default (app) => {
  app.use(errorHandler)
  app.use(notFound)
  app.use(okOutput)
}

请注意我们如何按顺序安排这些中间件 - 即使errorHandler中间件首先注册,但如果 HTTP 响应中出现错误,它将是最后一个重新执行的中间件。如果 HTTP 响应状态为200,上游级联将在okOutput中间件处停止。还要注意,这些中间件必须在staticfaviconbodyparser中间件之后注册,这些中间件必须首先在下游级联中调用和公开服务。

  1. routes-public.jsroutes-private.js导入公共和私有路由,并在前述中间件之后注册它们如下:
// Import custom local middlewares.
import routesPublic from './routes-public'
import routesPrivate from './routes-private'

export default (app) => {
  app.use(routesPublic.routes(), routesPublic.allowedMethods())
  app.use(routesPrivate.routes(), routesPrivate.allowedMethods())
}
  1. /config/目录中的index.js文件中导入 Koa、middlewares.js文件中的所有中间件和服务器配置,实例化一个 Koa 实例并将其传递给middlewares.js文件,然后使用这个 Koa 实例启动服务器:
// index.js
import Koa from 'koa'
import config from './config'
import middlewares from './middlewares'

const app = new Koa()
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || config.server.port

middlewares(app)
app.listen(port, host)
  1. 使用npm run dev运行此 API,您应该在localhost:4000上在浏览器中看到应用程序正在运行。当您在localhost:4000上时,您应该在浏览器中获得以下输出:
{"status":404,"message":"Not found"}

这是因为在/上不再设置路由 - 我们已经将所有路由前缀设置为/public/private。但是,如果您导航到localhost:4000/public,您将获得以下 JSON 输出:

{"status":200,"data":{"message":"Hello World!"}}

这是我们刚刚在前面步骤中创建的home模块的响应。此外,您应该看到您的网站图标和资源在localhost:4000上正确提供 - 如果您将它们放在/static//assets/目录中的任何一个,例如:

localhost:4000/sample-asset.jpg
localhost:4000/favicon.ico

您可以在localhost:4000这两个目录中看到您的文件。这是因为staticfavicon中间件已安装并注册为在 Koa 中进行下游级联时首先执行的中间件堆栈。

干得好!现在您已经准备好了新的工作目录,并且基本的 API 正在运行,就像第八章中一样,添加服务器端框架。接下来,您需要在/backend/目录中安装其他服务器端依赖项,并开始向公共userlogin模块以及私有home模块的路由添加代码。让我们从下一节开始使用bcryptjs

您可以在我们的 GitHub 存储库中的/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/backend/中找到具有前述结构的示例应用程序。

使用 Node.js 的 bcryptjs 模块

如前所述,bcryptjs用于对密码进行哈希和验证。请查看有关如何在我们的应用程序中使用此模块的进一步建议的简化步骤:

  1. 通过 npm 安装bcryptjs模块:
$ npm i bcryptjs
  1. 通过在请求体(请求)中添加salt与来自客户端的密码一起对密码进行哈希处理,例如,在user模块中进行新用户创建时:
// src/modules/public/user/_routes/create-user.js
import bcrypt from 'bcryptjs'

const saltRounds = 10
const salt = bcrypt.genSaltSync(saltRounds)
const hashed = bcrypt.hashSync(request.password, salt)

请注意,在本章中为了加快我们的身份验证课程,我们跳过了创建新用户的过程。但在更完整的 CRUD 中,您可以使用此步骤来对用户提供的密码进行哈希处理。

  1. 通过将来自客户端的密码(请求)与数据库中存储的密码进行比较来验证密码,例如,在login模块中进行登录验证过程如下:
// src/modules/public/login/_routes/local.js
import bcrypt from 'bcryptjs'

const isMatched = bcrypt.compareSync(request.password,
  user.password)
if (isMatched === false) { ... }

请注意,您可以在我们的 GitHub 存储库中的/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/backend/src/modules/public/login/_routes/local.js中找到此步骤在我们后端应用程序中的应用方式。

我们将向您展示如何在接下来的部分中使用bcryptjs来验证来自客户端的密码。但在对客户端的密码进行哈希和验证之前,首先,我们需要连接到我们的 MySQL 数据库,以确定是要注入新用户还是查询现有用户。为此,我们将需要在我们的应用程序中使用下一个 Node.js 模块:mysql - 一个 MySQL 客户端。所以让我们继续前进到下一部分,看看您如何安装和使用它。

如果您想找到关于这个模块和一些异步示例的更多信息,请访问github.com/dcodeIO/bcrypt.js

使用 Node.js 的 mysql 模块

我们有在上一节中安装的 MySQL 服务器。现在我们需要一个 MySQL 客户端,我们可以连接到 MySQL 服务器并从服务器端程序执行 SQL 查询。mysql 是标准的 MySQL Node.js 模块,实现了 MySQL 协议,因此我们可以使用这个模块来处理 MySQL 连接和 SQL 查询,无论你是在 MySQL 服务器还是 MariaDB 服务器上。所以,让我们按照以下步骤开始:

  1. 通过 npm 安装mysql模块:
$ npm i mysql
  1. /src/目录的子目录中,使用你的 MySQL 连接详细信息在mysql.js文件中创建 MySQL 连接实例,如下所示:
// src/core/database/mysql.js
import util from 'util'
import mysql from 'mysql'

const pool = mysql.createPool({
  connectionLimit: 10,
  host : 'localhost',
  user : '<username>',
  password : '<password>',
  database : '<database>'
})

pool.getConnection((err, connection) => {
  if (error) {
    // Handle errors ...
  }
  // Release the connection to the pool if no error.
  if (connection) {
    connection.release()
  }
  return
})
pool.query = util.promisify(pool.query)
export default pool

让我们在以下笔记中浏览我们刚刚创建的代码:

  • mysql 不支持async/await,所以我们使用了 Node.js 的promisify实用程序来包装 MySQL 的pool.querypool.query是 mysql 中处理我们的 SQL 查询的函数,它通过回调返回结果,例如:
connection.query('SELECT ...', function (error, results, fields) {
  if (error) {
    throw error
  }
  // Do something ...
})

通过 promisify 实用程序,我们已经消除了回调,现在我们可以使用async/await,如下所示:

let result = null
try {
  result = await pool.query('SELECT ...')
} catch (error) {
  // Handle errors ...
}
  • pool.query是这三个函数的快捷方式,pool.getConnectionconnection.queryconnection.release,我们应该一起使用它们在 mysql 模块的连接池中执行 SQL 查询。通过使用pool.query,当你完成时,连接会自动释放回连接池。这是pool.query函数的基本底层结构:
import mysql from 'mysql'
const pool = mysql.createPool(...)

pool.getConnection(function(error, connection) {
  if (error) { throw error }

  connection.query('SELECT ...', function (error, results,
   fields) {
    connection.release()
    if (error) { throw error }
  })
})
  • 在这个 mysql 模块中,我们可以使用mysql.createPool进行连接池,而不是通过mysql.createConnection逐个创建和管理 MySQL 连接,这可能是一个昂贵的操作。连接池是一个可重用的数据库连接缓存,用于减少每次连接到数据库时建立新连接的成本。有关连接池的更多信息,请访问github.com/mysqljs/mysqlpooling-connections
  1. 所以,我们已经将 MySQL 连接抽象成了/core/目录中的前述文件。现在我们可以使用它来获取user模块中用户列表,如下所示:
// backend/src/modules/public/user/_routes/index.js
import Router from 'koa-router'
import pool from 'core/database/mysql'
const router = new Router()

router.get('/', async (ctx, next) => {
  try {
    var users = await pool.query(
     'SELECT `id`, `name`, `created_on`
      FROM `users`'
    )
  } catch (err) { ... }

  ctx.type = 'json'
  ctx.body = users
})

export default router

您可以看到,我们使用了与前一节中所述的相同代码结构,通过 MySQL 连接池将我们的请求发送到 MySQL 服务器。在我们发送的查询中,我们告诉 MySQL 服务器仅为我们从users表中返回idnamecreated_on字段的结果。

  1. 如果您访问localhost:4000/public/users上的用户路由,您应该在屏幕上看到以下输出:
{"status":200,"data":[{"id":1,"name":"Alexandre","created_on":"2019-06-16T22:00:00.000Z"}]}

现在我们有了用于连接到 MySQL 服务器和数据库的 mysql 模块,以及用于对客户端密码进行哈希和验证的 bcryptjs 模块,因此我们可以重构和改进我们在上一章中粗略创建的登录代码。让我们在下一节中找出如何做。

如果您想了解更多关于 mysql 模块的信息,请访问github.com/mysqljs/mysql

在服务器端重构登录代码

我们已经在前几节中收集了所有必要的要素,一旦我们创建了 MySQL 连接池,我们就可以重构和改进我们的登录代码,从第十章 添加一个 Vuex Store 和第十一章 编写路由中间件和服务器中间件,按照以下步骤进行:

  1. 导入所有依赖项,如koa-routerjsonwebtokenbcryptjs和 MySQL 连接池,用于登录路由如下:
// src/modules/public/login/_routes/local.js
import Router from 'koa-router'
import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import pool from 'core/database/mysql'
import config from 'config'

const router = new Router()

router.post('/login', async (ctx, next) => {
  let request = ctx.request.body || {}
  //...
})

export default router

我们在这里导入了配置文件,用于 API 的配置选项,其中包含了 MySQL 数据库连接详细信息、服务器和静态目录的选项,以及我们稍后需要用于签署令牌的 JWT 的秘密代码。

  1. 在登录路由的post方法中验证用户输入,以确保它们已定义且不为空:
if (request.username === undefined) {
  ctx.throw(400, 'username param is required.')
}
if (request.password === undefined) {
  ctx.throw(400, 'password param is required.')
}
if (request.username === '') {
  ctx.throw(400, 'username is required.')
}
if (request.password === '') {
  ctx.throw(400, 'password is required.')
}
  1. 当它们通过验证时,将用户名和密码分配给变量以查询数据库:
let username = request.username
let password = request.password

let users = []
try {
  users = await pool.query('SELECT  FROM users WHERE 
   username = ?', [username])
} catch(err) {
  ctx.throw(400, err.sqlMessage)
}

if (users.length === 0) {
  ctx.throw(404, 'no user found')
}
  1. 如果从 MySQL 查询中有结果,就使用 bcryptjs 比较存储的密码和用户输入的密码:
let user = users[0]
let match = false

try {
  match = await bcrypt.compare(password, user.password)
} catch(err) {
  ctx.throw(401, err)
}
if (match === false) {
  ctx.throw(401, 'invalid password')
}
  1. 如果用户通过了所有先前的步骤和验证,就对 JWT 进行签名并将其发送给客户端:
let payload = { name: user.name, email: user.email }
let token = jwt.sign(payload, config.JWT_SECRET, { expiresIn:
  1 * 60 })

ctx.body = {
  user: payload,
  message: 'logged in ok',
  token: token
}
  1. 使用npm run dev运行 API,并在终端上手动使用curl测试上一个路由,如下所示:
$ curl -X POST -d "username=demo&password=123123" -H "Content-Type: application/x-www-form-urlencoded" http://localhost:4000/public/login/local

如果您成功登录,您应该得到以下结果:

{"status":200,"data":{"user":{"name":"Alexandre","email":"thiamkok.lau@gmail.com"},"message":"logged in ok","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWxleGFuZHJlIiwiZW1haWwiOiJ0aGlhbWtvay5sYXVAZ21haWwuY29tIiwiaWF0IjoxNTgwMDExNzAwLCJleHAiOjE1ODAwMTE3NjB9.Lhd78jokSGALup6DUYAqWAjl7C-8dLhXjEba-KAxy4k"}}

当然,每当成功签署时,您将在前面的响应中获得不同的令牌。现在,您已经成功地重构和改进了登录代码。接下来,我们将看一下如何在下一节中验证前面的令牌,该令牌将从客户端以请求头的形式发送回来。所以,请继续阅读!

在服务器端验证传入的令牌

我们成功地签署了一个令牌,并在凭据与我们在数据库中存储的内容匹配时将其返回给客户端。但这只是故事的一半。每次客户端使用令牌进行请求时,我们都应该验证这个令牌,以便访问服务器端中间件保护的所有受保护路由。

因此,让我们按照以下步骤创建中间件和受保护的路由:

  1. /src/目录内的/middlewares/目录中创建一个中间件文件,并使用以下代码:
// src/middlewares/authenticate.js
import jwt from 'jsonwebtoken'
import config from 'config'

export default async (ctx, next) => {
  if (!ctx.headers.authorization) {
    ctx.throw(401, 'Protected resource, use Authorization header 
    to get access')
  }
  const token = ctx.headers.authorization.split(' ')[1]

  try {
    ctx.state.jwtPayload = jwt.verify(token, config.JWT_SECRET)
  } catch (err) {
    // handle error.
  }
  await next()
}

if条件!ctx.headers.authorization用于确保客户端已在请求头中包含了令牌。由于authorizationBearer: [token]的格式带有值,其中有一个单个空格,我们通过该空格拆分值,并仅在trycatch块中获取[token]进行验证。如果令牌有效,则我们允许请求通过到下一个路由,使用await next()

  1. 导入并注入此中间件到我们想要用 JWT 保护的路由组中:
// src/routes-private.js
import Router from 'koa-router'
import home from './modules/private/home'
import authenticate from './middlewares/authenticate'

const router = new Router({ prefix: '/private' })
const modules = [home]

for (var module of modules) {
  router.use(authenticate, module.routes(), module.allowedMethods())
}

在这个 API 中,我们希望保护所有属于/private路由的路由。因此,我们将在这个文件中导入我们想要保护的任何路由,例如前面的/home路由。因此,当您使用/private/home请求此路由时,您必须在请求头中包含令牌以访问此路由。

就是这样。你已经成功地在服务器端创建并验证了 JWT。接下来,我们应该看一下如何在下一节中使用 Nuxt 在客户端完成 JWT 认证。让我们开始吧!

创建前端身份验证

你会发现这一部分很容易和熟悉,因为在前两章中你已经用虚拟后端身份验证构建了一些认证 Nuxt 应用。本章的不同之处在于我们正在制作跨域应用,而不是像前两章那样的单域应用。你可以在/chapter-10/nuxt-universal/nuxtServerInit//chapter-11/nuxt-universal/route-middleware/per-route/中重新访问这些单域 Nuxt 应用。

此外,我们将再次使用我们在第六章中已经介绍过的 Nuxt 模块:@nuxtjs/axios@nuxtjs/proxy。你可以在/chapter-6/nuxt-universal/module-snippets/top-level/中查看采用这两个模块的 Nuxt 应用。但现在,让我们安装并配置它们用于这个 Nuxt 应用,我们将在接下来的步骤中重构它,以创建客户端身份验证:

  1. 通过 npm 安装@nuxtjs/axios@nuxtjs/proxy
$ npm i @nuxtjs/axios
$ npm i @nuxtjs/proxy
  1. 在 Nuxt 配置文件中配置这两个模块如下:
// nuxt.config.js
module.exports = {
  modules: [
    '@nuxtjs/axios',
  ],

  axios: {
    proxy: true
  },

  proxy: {
    '/api/': { target: 'http://localhost:4000/', pathRewrite:
     {'^/api/': ''} },
  }
}

由于我们知道我们在之前章节中创建的远程 API 服务器运行在localhost:4000,在这个配置中,我们将这个 API 地址分配给proxy选项中的/api/键。

  1. 移除我们之前用来导入 axios Node.js 模块的任何import语句;例如,在安全页面上:
// pages/secured.vue
import axios from '~/plugins/axios'

这是因为我们现在使用@nuxtjs/axios(Nuxt Axios 模块)了,我们将不再需要直接在我们的代码中导入原始的 axios Node.js 模块。

  1. 通过使用$axios调用 Nuxt Axios 模块,并替换我们之前在我们的代码中用于 HTTP 请求的原始 axios Node.js 模块中的axios;例如,在安全页面上:
// pages/secured.vue
async asyncData ({ $axios, redirect }) {
  const { data } = await $axios.$get('/api/private')
}

Nuxt Axios 模块通过Nuxt 配置文件中的步骤 2 加载到我们的 Nuxt 应用中,所以我们可以通过 Nuxt 上下文或this来使用$axios访问它。

我们还应该使用这两个 Nuxt 模块@nuxtjs/axios@nuxtjs/proxy以及 cookies、Node.js 模块(客户端和服务器端)来重构这个应用中存储和中间件的其余代码。所以让我们在以下部分开始吧。

在(Nuxt)客户端使用 cookies

在这个应用中,我们不再使用会话来“记住”认证数据。相反,我们将使用js-cookie Node.js 模块来创建 cookies 来存储来自远程服务器的数据。

使用这个 Node.js 模块非常容易创建一个在整个站点上都存在的 cookie;例如:

  1. 使用以下格式设置 cookie:
Cookies.set(<name>, <value>)

以下是如果你想创建一个 30 天后过期的 cookie 的代码:

Cookies.set(<name>, <value>, { expires: 30 })
  1. 使用以下格式读取 cookie:
Cookies.get(<name>)

使用这个 Node.js 模块是多么容易 - 你只需要使用setget方法在客户端设置和检索你的 cookies。所以,让我们按照以下步骤重构我们存储中的代码:

  1. 只有在 Nuxt 应用程序在客户端处理时,才使用if三元条件来导入 js-cookie Node.js 模块:
// store/actions.js
const cookies = process.client ? require('js-cookie') : undefined
  1. 使用 js-cookie 的set函数将服务器端的数据存储为auth,在login操作中如下所示:
// store/actions.js
export default {
  async login(context, { username, password }) {
    const { data } = await 
     this.$axios.$post('/api/public/login/local', 
     { username, password })
    cookies.set('auth', data)
    context.commit('setAuth', data)
  }
}
  1. 使用 js-cookie 的remove函数在logout操作中删除auth cookie,如下所示:
// store/actions.js
export default {
  logout({ commit }) {
    cookies.remove('auth')
    commit('setAuth', null)
  }
}

这很简单,不是吗?但是,你可能会问:我们用这个auth cookie 做什么,以及如何使用?让我们在下一节中了解如何在 Nuxt 服务器端使用 cookie。

有关 Node.js 模块的更多信息和代码示例,请访问github.com/js-cookie/js-cookie

由于我们使用 JWT 进行身份验证的数据已经被js-cookieauth的形式哈希并存储在 cookie 中,因此我们需要在需要时读取和解析此 cookie。这就是 Node.js 模块cookie的用武之地。同样,我们在过去的章节中使用了这个 Node.js 模块,但我们还没有讨论过它。

cookie Node.js 模块是用于 HTTP 服务器的 HTTP cookie 解析器和序列化程序。它用于在服务器端解析 cookie 标头。让我们看看如何在以下步骤中在auth cookie 上使用它:

  1. 只有在 Nuxt 应用程序在服务器端处理时,才使用if三元条件来导入 cookie Node.js 模块:
// store/index.js
const cookie = process.server ? require('cookie') : undefined
  1. 使用 cookie Node.js 模块的parse函数来解析nuxtServerInit操作中 HTTP 请求头中的auth cookie,如下所示:
// store/index.js
export const actions = {
  nuxtServerInit({ commit }, { req }) {
    if (req.headers.cookie && req.headers.cookie.indexOf('auth') >
      -1) {
      let auth = cookie.parse(req.headers.cookie)['auth']
      commit('setAuth', JSON.parse(auth))
    }
  }
}
  1. 通过$axios使用 Nuxt Axios 模块的setHeader函数将令牌(JWT)包含在远程服务器上的令牌中间件的 HTTP 标头中,以访问私有 API 路由,如下所示:
// middleware/token.js
export default async ({ store, error, $axios }) => {
  if (!store.state.auth.token) {
    // handle error
  }
  $axios.setHeader('Authorization', Bearer: ${store.state.auth.token})
}
  1. 使用npm run dev运行 Nuxt 应用程序。您应该在localhost:3000上的浏览器中运行该应用程序。您可以使用登录页面上的凭据登录,然后访问受 JWT 保护的受限安全页面。

干得好!您已经完成了基于令牌的本地身份验证。您已经重构了存储和中间件中的代码,使得js-cookiecookie Node.js 模块可以在 Nuxt 应用程序的前端身份验证中完美地在客户端和服务器端协同工作并相互补充。此外,您已成功将 Nuxt 应用程序与跨域方法解耦 API。

正如您所看到的,使用js-cookiecookie Node.js 模块进行前端身份验证非常简单且非常好。但是也可以通过 Google OAuth 实现,我们将在下一节中进行讨论。将 Google OAuth 添加到前端身份验证可以为用户提供额外的登录选项。所以,让我们开始吧。

您可以在我们的 GitHub 存储库的/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/frontend/中找到此 Nuxt 应用程序的源代码。

有关cookie Node.js 模块的更多信息和代码示例,请访问github.com/jshttp/cookie

有关助手的更多信息,例如 Nuxt Axios 模块中的setHeader助手,请访问axios.nuxtjs.org/helpers

使用 Google OAuth 登录

OAuth 是一种开放的委托授权协议,允许网站或应用程序之间进行访问,而不会将用户密码暴露给已被授予访问权限的各方。它是许多公司和网站用来识别用户的常见访问委托。让我们让我们的用户使用 Google OAuth 登录我们的应用程序。此选项需要来自 Google 开发者控制台的客户端 ID 和客户端密钥。可以通过以下步骤获得它们:

  1. console.developers.google.com/的谷歌开发者控制台中创建一个新项目。

  2. 在 OAuth 同意屏幕选项卡上选择 External。

  3. 在凭据选项卡上的“创建凭据”下拉选项中选择 OAuth 客户端 ID,然后选择 Web 应用程序作为应用程序类型。

  4. 在“名称”字段中提供您的 OAuth 客户端 ID 的名称,在“授权重定向 URI”字段中提供重定向 URI,以便谷歌在用户在谷歌同意页面上进行身份验证后重定向用户。

  5. 在库选项卡中启用 Google People API,该 API 提供对 API 库中有关配置文件和联系人的信息的访问权限。

一旦您设置了开发者帐户并按照上述步骤创建了客户端 ID客户端密钥,您就可以准备在下一节中将 Google OAuth 添加到后端身份验证中。让我们开始吧。

将 Google OAuth 添加到后端身份验证

为了让某人登录谷歌,我们需要将他们发送到谷歌登录页面。从那里,他们将登录他们的账户,并将被重定向到我们的应用程序,并携带他们的谷歌登录详细信息,我们将提取谷歌代码并将其发送回谷歌以获取我们可以在应用程序中使用的用户数据。这个过程需要googleapis Node.js 模块,这是一个用于使用谷歌 API 的客户端库。

让我们按照以下步骤在我们的代码中安装并采用它:

  1. 通过 npm 安装googleapis Node.js 模块:
$ npm i googleapis
  1. 创建一个文件,包含你的凭证,这样谷歌就知道是谁在发出请求。
// backend/src/config/google.js
export default {
  clientId: '<client ID>',
  clientSecret: '<client secret>',
  redirect: 'http://localhost:3000/login'
}

请注意,您必须用从谷歌开发者控制台获得的 ID 和密钥替换上述的<client ID><client secret>值。另外,请注意redirect选项中的 URL 必须与您的谷歌应用 API 设置中的授权重定向 URI 中的重定向 URI 匹配。

  1. 使用 Google OAuth 生成 Google 身份验证 URL,将用户发送到谷歌同意页面,以获取用户检索访问令牌的权限,如下所示:
// backend/src/modules/public/login/_routes/google/url.js
import Router from 'koa-router'
import { google } from 'googleapis'
import googleConfig from 'config/google'

const router = new Router()

router.get('/google/url', async (ctx, next) => {

  const oauth = new google.auth.OAuth2(
    googleConfig.clientId,
    googleConfig.clientSecret,
    googleConfig.redirect
  )

  const scopes = [
    'https://www.googleapis.com/auth/userinfo.email',
    'https://www.googleapis.com/auth/userinfo.profile',
  ]

  const url = oauth.generateAuthUrl({
    access_type: 'offline',
    prompt: 'consent',
    scope: scopes
  })

  ctx.body = url
})

当用户登录并生成 URL 时,范围决定了我们在用户登录时需要什么信息和权限。在我们的情况下,我们希望获得检索用户电子邮件和个人资料信息的权限:userinfo.emailuserinfo.profile。用户在谷歌同意页面上进行了身份验证后,谷歌将用户重定向回我们的应用程序,并携带了一堆经过身份验证的数据和用于访问用户数据的授权代码。

  1. 从谷歌在上一步返回的 URL 中附加的经过身份验证的数据中提取code参数中的值。我们将在下一节中回到 Node.js 模块,它可以帮助我们从 URL 查询中提取code参数。现在,让我们假设我们已经提取了code值,并将其发送到服务器端,以请求使用 Google OAuth2 实例的令牌,如下所示:
// backend/src/modules/public/login/_routes/google/me.js
import Router from 'koa-router'
import { google } from 'googleapis'
import jwt from 'jsonwebtoken'
import pool from 'core/database/mysql'
import config from 'config'
import googleConfig from 'config/google'

const router = new Router()

router.get('/google/me', async (ctx, next) => {

  // Get the code from url query.
  const code = ctx.query.code

  // Create a new google oauth2 client instance.
  const oauth2 = new google.auth.OAuth2(
    googleConfig.clientId,
    googleConfig.clientSecret,
    googleConfig.redirect
  )
  //...
})
  1. 使用我们刚刚提取的代码从谷歌获取令牌,并将它们传递给 Google People,google.people,使用get方法获取用户数据,并指定在personFields查询参数中需要返回的与人相关的字段。
// backend/src/modules/public/login/_routes/google/me.js
...
const {tokens} = await oauth2.getToken(code)
oauth.setCredentials(tokens)

const people = google.people({
  version: 'v1',
  auth: oauth2,
})

const me = await people.people.get({
  resourceName: 'people/me',
  personFields: 'names,emailAddresses'
})

您可以看到我们在前面的代码中只想要与 Google 中的人相关的两个字段,即namesemailAddresses。您可以在developers.google.com/people/api/rest/v1/people/get上找到您想要从 Google 获取的与人相关的其他字段。如果访问成功,我们应该从 Google 以 JSON 格式获取用户数据,然后我们可以从该数据中提取电子邮件,以确保它将在下一步中与我们数据库中的用户匹配。

  1. 仅从 Google 人员数据中检索第一个电子邮件,并查询我们的数据库,以查看是否已经有任何使用该电子邮件的用户:
// backend/src/modules/public/login/_routes/google/me.js
...
let email = me.data.emailAddresses[0].value
let users = []

try {
  users = await pool.query('SELECT  FROM users WHERE email = ?',
   [email])
} catch(err) {
  ctx.throw(400, err.sqlMessage)
}
  1. 如果没有该电子邮件的用户,请向客户端发送来自 Google 的用户数据的'signup required'消息,并要求用户在我们的应用程序中注册帐户:
// backend/src/modules/public/login/_routes/google/me.js
...
if (users.length === 0) {
  ctx.body = {
    user: me.data,
    message: 'signup required'
  }
  return
}
let user = users[0]
  1. 如果匹配,则使用有效载荷和 JWT 密钥签署 JWT,然后将令牌(JWT)发送到客户端:
// backend/src/modules/public/login/_routes/google/me.js
...
let payload = { name: user.name, email: user.email }
let token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: 1 * 60 })

ctx.body = {
  user: payload,
  message: 'logged in ok',
  token: token
}

就是这样。在前面的几个步骤中,您已经成功在服务器端添加了 Google OAuth。接下来,我们应该看看如何在下一节中使用 Nuxt 完成 Google OAuth 的客户端身份验证。让我们开始吧。

有关 googleapis Node.js 模块的更多信息,请访问github.com/googleapis/google-api-nodejs-client

为 Google OAuth 创建前端身份验证

当 Google 将用户重定向回我们的应用程序时,我们将在重定向 URL 上获得大量数据,例如:

http://localhost:3000/login?code=4%2F1QGpS37E21TcgQhhIvJZlK1cG4M1jpPJ0I_XPQgrFjvKUFUJQ3aYuO1zYsqPmKgNb4Wfd8ito88yDjUTD6CKD3E&scope=email%20profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%20openid&authuser=1&prompt=consent

当您第一次看到它时,它很难阅读和解密,但它只是一个带有参数附加到我们重定向 URL 的查询字符串:

<redirect URL>?
code=4/1QFvWYDSrW...
&scope=email profile...
&authuser=1
&prompt=consent

我们可以使用 Node.js 模块query-string来解析 URL 中的查询字符串,例如:

const queryString = require('query-string')
const parsed = queryString.parse(location.search)
console.log(parsed)

然后您将在浏览器控制台中获得以下 JavaScript 对象:

{authuser: "1", code: "4/1QFvWYDSrWLklhIgRfVR0LJy6Pk0gn5TkjTKWKlRr9pdZveGAHV_pMrxBhicy7Zd6d9nfz0IQrcLl-VGS-Gu9Xk", prompt: "consent", scope: "email profile https://www.googleapis.com/auth/user…//www.googleapis.com/auth/userinfo.profile openid"}

在前面的重定向 URL 中,code参数是我们最感兴趣的,因为我们需要将其发送到服务器端,以便通过 googleapis Node.js 模块获取 Google 用户数据,正如您在上一节中学到的。因此,让我们安装query-string并在接下来的步骤中在我们的 Nuxt 应用程序中创建前端身份验证:

  1. 通过 npm 安装query-string Node.js 模块:
$ npm i query-string
  1. 在登录页面上创建一个按钮,并绑定一个名为loginWithGoogle的方法,以调度存储中的getGoogleUrl方法,如下所示:
// frontend/pages/login.vue
<button v-on:click="loginWithGoogle">Google Login</button>

export default {
  methods: {
    async loginWithGoogle() {
      try {
        await this.$store.dispatch('getGoogleUrl')
      } catch (error) {
        let errorData = error.response.data
        this.formError = errorData.message
      }
    }
  }
}
  1. 在 API 中调用/api/public/login/google/url路由,在getGoogleUrl方法中如下所示:
// frontend/store/actions.js
export default {
  async getGoogleUrl(context) {
    const { data } = await this.$axios.$get('/api/public/login/
     google/url')
    window.location.replace(data)
  }
}

/api/public/login/google/url路由将返回一个 Google URL,然后我们可以使用它将用户重定向到 Google 登录页面。从那里,用户将决定要登录到哪个 Google 帐户(如果有多个)。

  1. 从返回的 URL 中提取查询部分,并在 Google 将用户重定向回登录页面时将其发送到 store 中的loginWithGoogle方法中,如下所示:
// frontend/pages/login.vue
export default {
  async mounted () {
    let query = window.location.search

    if (query) {
      try {
        await this.$store.dispatch('loginWithGoogle', query)
      } catch (error) {
        // handle error
      }
    }
  }
}
  1. 使用query-string从前面的查询部分中提取code参数的代码,并使用$axios将其发送到我们的 API/api/public/login/google/me,如下所示:
// frontend/store/actions.js
import queryString from 'query-string'

export default {
  async loginWithGoogle (context, query) {
    const parsed = queryString.parse(query)
    const { data } = await this.$axios.$get('/api/public/login/
     google/me', {
      params: {
        code: parsed.code
      }
    })

    if (data.message === 'signup required') {
      localStorage.setItem('user', JSON.stringify(data.user))
      this.$router.push({ name: 'signup'})
    } else {
      cookies.set('auth', data)
      context.commit('setAuth', data)
    }
  }
}

当我们从服务器收到'signup required'消息时,我们将用户重定向到注册页面。但是,如果我们收到带有 JWT 的消息,那么我们可以将 cookie 和经过身份验证的数据设置到 store 状态中。我们将留下注册页面让您自己想象和努力,因为这是一个用于收集用户数据以存储在数据库中的表单。

  1. 最后,使用npm run dev运行 Nuxt 应用程序。您应该可以在localhost:3000上在浏览器中运行该应用程序。您可以使用 Google 登录,然后访问受 JWT 保护的受限页面,就像本地认证一样。

所以,这就是你使用 Google OAuth API 登录用户的基本步骤。这一点并不难,是吗?我们还可以使用 Nuxt Auth 模块来实现几乎与我们在这里完成的相同的功能。使用此模块,您可以使用 Auth0、Facebook、GitHub、Laravel Passport 和 Google 登录用户。如果您正在寻找 Nuxt 的快速、简单和零样板认证支持,这可能是您项目的一个不错的选择。有关此 Nuxt 模块的更多信息,请访问auth.nuxtjs.org/。现在让我们在下一节总结一下您在本章中学到的内容。

您可以在我们的 GitHub 存储库中的/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/中找到前面使用 Google OAuth 的登录选项。

有关query-string Node.js 模块的使用信息,请访问www.npmjs.com/package/query-string

摘要

干得好!您已经走了这么远。毕竟,在网页身份验证上工作并不难。在本章中,您已经了解了基于会话的身份验证和基于令牌的身份验证,特别是关于 JSON Web Token(JWT)。您现在应该知道它们之间的区别以及 JWT 的组成部分,以及如何使用jsonwebtoken Node.js 模块生成 JWT。我们还介绍了 MySQL Node.js 模块,并将其用作我们身份验证系统的一部分。您还集成了 Google OAuth 以便用户登录,然后使用 Nuxt 创建了前端身份验证。

在下一章中,您将学习如何在您的 Nuxt 应用程序中编写端到端测试。您将了解可以安装和使用的用于编写端到端测试的工具,特别是 AVA 和 Nightwatch。除此之外,您还将学习如何使用一个 Node.js 模块,即jsdom,使您的端到端测试在服务器端成为可能。这是因为 Nuxt 在技术上是一种服务器端技术,并在服务器端呈现我们的 HTML 页面,但在服务器端没有 DOM,因此我们可以利用jsdom来实现。但请放心,我们将引导您完成设置所有这些工具并编写您的测试的步骤。所以,请继续关注!

第五部分:测试和部署

在本节中,我们将编写测试并将 Nuxt 应用程序部署到托管服务器上。我们还将学习如何使用一些 JavaScript 工具保持我们的代码整洁,同时遵守编码标准。

本节包括以下章节:

  • 第十三章,编写端到端测试

  • 第十四章,使用检查器、格式化程序和部署命令

编写端到端测试

编写测试是 Web 开发的一部分。您的应用程序变得越来越复杂和庞大,您就越需要测试应用程序,否则它将在某个时候出现故障,并且您将花费大量时间修复错误和补丁。在本章中,您将使用 AVA 和 jsdom 为 Nuxt 应用编写端到端测试,并且还将亲身体验使用 Nightwatch 进行浏览器自动化测试。您将学习如何安装这些工具并设置测试环境-让我们开始吧。

本章我们将涵盖的主题如下:

  • 端到端测试与单元测试

  • 端到端测试工具

  • 使用jsdomn和 AVA 为 Nuxt 应用编写测试

  • 介绍 Nightwatch

  • 使用 Nightwatch 为 Nuxt 应用编写测试

第十三章:端到端测试与单元测试

通常有两种类型的测试用于 Web 应用程序:单元测试和端到端测试。你可能听说过很多关于单元测试,并且在过去做过一些(或者很多)。单元测试用于测试应用程序的小部分和个体部分,而相反,端到端测试用于测试应用程序的整体功能。端到端测试涉及确保应用程序的集成组件按预期运行。换句话说,整个应用程序在类似于真实用户与应用程序交互的真实场景中进行测试。例如,用户登录页面的简化端到端测试可能涉及以下内容:

  1. 加载登录页面。

  2. 在登录表单中输入有效的详细信息。

  3. 点击“提交”按钮。

  4. 成功登录到页面并看到问候消息。

  5. 退出系统。

那么单元测试呢?单元测试运行速度快,可以精确识别确切的问题和错误。单元测试的主要缺点是为应用程序的每个方面编写测试非常耗时。而且尽管您的应用程序已通过了单元测试,但整体应用程序仍可能出现故障。

端到端测试可以隐式测试许多方面,并确保您拥有一个正常工作的系统。与单元测试相比,端到端测试运行速度较慢,并且无法明确指出应用程序失败的根本原因。应用程序中看似不重要的部分发生微小变化可能会破坏整个测试套件。

将应用程序的单元测试和端到端测试结合在一起可能是理想和令人信服的,因为这样可以更彻底地测试您的应用程序,但这可能会耗费时间和金钱。在本书中,我们专注于端到端测试,因为默认情况下,Nuxt 与端到端测试工具无缝配置,您将在下一节中发现。

端到端测试工具

Nuxt 通过将 AVA 和 jsdom Node.js 模块一起使用,使端到端测试变得非常简单和有趣。但在 Nuxt 应用程序中实现并结合它们进行测试之前,让我们深入了解这些 Node.js 模块的每一个,看看它们是如何分开工作的,这样您就可以对这些工具有一个扎实的基本理解。让我们从下一节开始学习 jsdom。

jsdom

简而言之,jsdom是基于 JavaScript 的 W3C 文档对象模型(DOM)在 Node.js 中的实现。但是,这意味着什么?我们需要它做什么?想象一下,您需要在 Node.js 应用程序中从原始 HTML 的服务器端操作 DOM,例如 Express 和 Koa 应用程序,但服务器端没有 DOM,因此您无法做太多事情。这就是 jsdom 拯救我们的时候。它将原始 HTML 转换为在 Node.js 中像客户端 DOM 一样工作的 DOM 片段。然后,您可以使用客户端 JavaScript 库,如 jQuery,在 Node.js 上操作 DOM。以下是用于服务器端应用程序的基本用法示例:

  1. 在服务器端应用程序上导入 jsdom:
import jsdom from 'jsdom'
const { JSDOM } = jsdom
  1. 将任何原始 HTML 字符串传递给JSDOM构造函数,您将获得一个 DOM 对象:
const dom = new JSDOM(<!DOCTYPE html><p>Hello World</p>)
console.log(dom.window.document.querySelector('p').textContent)

您从前面的代码片段中获得的 DOM 对象具有许多有用的属性,特别是window对象,然后您可以开始像在客户端上一样操作传递的 HTML 字符串。现在让我们在Koa API上应用这个工具,您在上一章中了解过,并且可以在我们的 GitHub 存储库中的/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/backend/中找到,以打印Hello world消息。按照以下步骤进行:

  1. 通过 npm 安装jsdom和 jQuery:
$ npm i jsdom --save-dev
$ npm i jquery --save-dev
  1. 导入jsdom并传递 HTML 字符串,就像我们在前面的基本用法示例中所做的那样:
// src/modules/public/home/_routes/index.js
import Router from 'koa-router'
import jsdom from 'jsdom'

const { JSDOM } = jsdom
const router = new Router()

const html = '<!DOCTYPE html><p>Hello World</p>'
const dom = new JSDOM(html)
const window = dom.window
const text = window.document.querySelector('p').textContent
  1. text输出到端点:
router.get('/', async (ctx, next) => {
  ctx.type = 'json'
  ctx.body = {
    message: text
  }
})

当您在终端上运行npm run dev时,您应该在localhost:4000/public看到以 JSON 格式显示的“Hello world”消息(在下面的代码片段中显示):

{"status":200,"data":{"message":"Hello world"}}
  1. 在我们的 API 中创建一个movie模块,并使用 Axios 从 IMDb 网站获取 HTML 页面,将 HTML 传递给 JSDOM 构造函数,导入 jQuery,然后将其应用于由 jsdom 创建的 DOM 窗口对象如下:
// src/modules/public/movie/_routes/index.js
const url = 'https://www.imdb.com/movies-in-theaters/'
const { data } = await axios.get(url)

const dom = new JSDOM(data)
const $ = (require('jquery'))(dom.window)

请注意,Axios 必须通过 npm 在您的项目目录中安装,您可以使用npm i axios进行安装。

  1. 将 jQuery 对象应用于所有具有list_item类的电影,并提取数据(每部电影的名称和放映时间)如下:
var items = $('.list_item')
var list = []
$.each(items, function( key, item ) {
  var movieName = $('h4 a', item).text()
  var movieShowTime = $('h4 span', item).text()
  var movie = {
    name: movieName,
    showTime: movieShowTime
  }
  list.push(movie)
})
  1. list输出到端点:
ctx.type = 'json'
ctx.body = {
  list: list
}

您应该在localhost:4000/public/movies看到以下 JSON 格式的类似电影列表:

{
  "status": 200,
  "data": {
    "list": [{
      "name": " Onward (2020)",
      "showTime": ""
    }, {
      "name": " Finding the Way Back (2020)",
      "showTime": ""
    },
    ...
    ...
    ]
  }
}

你可以在我们的 GitHub 存储库的/chapter-13/jsdom/中找到这些示例。有关此 npm 包的更多信息,请访问github.com/jsdom/jsdom

您可以看到此工具在服务器端有多有用。它使我们能够像在客户端一样操纵原始 HTML。现在让我们在下一节中继续学习 AVA 的一些基本用法,然后在我们的 Nuxt 应用程序中与jsdom一起使用。

AVA

简而言之,AVA(不是 Ava 或 ava,发音为/ˈeɪvə/)是一个用于 Node.js 的 JavaScript 测试运行器。有很多测试运行器:Mocha、Jasmine 和 tape 等。AVA 是现有列表的另一种选择。首先,AVA 很简单。它真的很容易设置。此外,默认情况下它并行运行测试,这意味着您的测试将运行得很快。它适用于前端和后端的 JavaScript 应用程序。总而言之,它绝对值得一试。让我们通过以下步骤开始创建一个简单的基本 Node.js 应用程序:

  1. 通过 npm 安装 AVA 并将其保存到package.json文件的devDependencies选项中:
$ npm i ava --save-dev
  1. 安装 Babel 核心和其他 Babel 包,以便我们可以在应用程序的测试中编写 ES6 代码:
$ npm i @babel/polyfill
$ npm i @babel/core --save-dev
$ npm i @babel/preset-env --save-dev
$ npm i @babel/register --save-dev
  1. package.json文件中配置test脚本如下:
// package.json
{
  "scripts": {
    "test": "ava --verbose",
    "test:watch": "ava --watch"
  },
  "ava": {
    "require": [
      "./setup.js",
      "@babel/polyfill"
    ],
    "files": [
      "test/**/*"
    ]
  }
}
  1. 在根目录中创建一个setup.js文件,其中包含以下代码:
// setup.js
require('@babel/register')({
  babelrc: false,
  presets: ['@babel/preset-env']
})
  1. 在我们的应用程序中的两个单独文件中创建以下类和函数,以便稍后进行测试:
// src/hello.js
export default class Greeter {
  static greet () {
    return 'hello world'
  }
}

// src/add.js
export default function (num1, num2) {
  return num1 + num2
}
  1. /test/目录中为测试/src/hello.js创建一个hello.js测试:
// test/hello.js
import test from 'ava'
import hello from '../src/hello'

test('should say hello world', t => {
  t.is('hello world', hello.greet())
})
  1. /test/目录中的另一个文件中创建另一个测试,用于测试/src/add.js
// test/add.js
import test from 'ava'
import add from '../src/add'

test('amount should be 50', t => {
  t.is(add(10, 50), 60)
})
  1. 在终端上运行所有测试:
$ npm run test

您还可以使用--watch标志运行测试,以启用 AVA 的观察模式:

$ npm run test:watch

如果测试通过,您应该会得到以下结果:

✓ add › amount should be 50
✓ hello › should say hello world

2 tests passed

您可以在我们的 GitHub 存储库的/chapter-13/ava/中找到前面的示例。有关此 npm 包的更多信息,请访问github.com/avajs/ava

这很容易也很有趣,不是吗?看到我们的代码通过测试总是令人满意的。现在您已经对这个工具有了基本的了解,所以现在是时候在 Nuxt 应用程序中使用 jsdom 来实现它了。让我们在下一节中开始吧。

使用 jsdomn 和 AVA 为 Nuxt 应用程序编写测试

您已经独立学习了jsdomAVA,并进行了一些简单的测试。现在,我们可以将这两个包合并到我们的 Nuxt 应用程序中。让我们在我们在上一章中创建的 Nuxt 应用程序中安装它们,路径为/chapter-12/nuxt-universal/cross-domain/jwt/axios-module/frontend/,使用以下步骤:

  1. 通过 npm 安装这两个工具,并将它们保存到package.json文件中的devDependencies选项中:
$ npm i ava --save-dev
$ npm i jsdom --save-dev
  1. 安装Babel核心和其他 Babel 包,以便我们可以在应用程序中编写 ES6 代码:
$ npm i @babel/polyfill
$ npm i @babel/core --save-dev
$ npm i @babel/preset-env --save-dev
$ npm i @babel/register --save-dev
  1. 将 AVA 配置添加到package.json文件中,如下所示:
// package.json
{
  "scripts": {
    "test": "ava --verbose",
    "test:watch": "ava --watch"
  },
  "ava": {
    "require": [
      "./setup.js",
      "@babel/polyfill"
    ],
    "files": [
      "test/**/*"
    ]
  }
}
  1. 在根目录中创建一个setup.js文件,就像您在上一节中所做的那样,但使用以下代码:
// setup.js
require('@babel/register')({
  babelrc: false,
  presets: ['@babel/preset-env']
})
  1. 准备以下测试模板,以便在/test/目录中编写测试:
// test/tests.js
import test from 'ava'
import { Nuxt, Builder } from 'nuxt'
import { resolve } from 'path'

let nuxt = null

test.before('Init Nuxt.js', async t => {
  const rootDir = resolve(__dirname, '..')
  let config = {}
  try { config = require(resolve(rootDir, 'nuxt.config.js')) } 
   catch (e) {}
  config.rootDir = rootDir
  config.dev = false
  config.mode = 'universal'
  nuxt = new Nuxt(config)
  await new Builder(nuxt).build()
  nuxt.listen(5000, 'localhost')
})

// write your tests here...

test.after('Closing server', t => {
  nuxt.close()
})

测试将在localhost:5000上运行(或者您喜欢的任何端口)。您应该在生产构建上进行测试,因此在config.dev键中关闭开发模式,并在config.mode键中使用universal,如果您的应用程序同时为服务器端和客户端开发。然后,在测试过程完成后,请确保关闭 Nuxt 服务器。

  1. 编写第一个测试,测试我们的主页,以确保在此页面上呈现了正确的 HTML:
// test/tests.js
test('Route / exits and renders correct HTML', async (t) => {
  let context = {}
  const { html } = await nuxt.renderRoute('/', context)
  t.true(html.includes('<p class="blue">My marvelous Nuxt.js 
   project</p>'))
})
  1. /about路由编写第二个测试,以确保在此页面上呈现了正确的 HTML。
// test/tests.js
test('Route /about exits and renders correct HTML', async (t) => {
  let context = {}
  const { html } = await nuxt.renderRoute('/about', context)
  t.true(html.includes('<h1>About page</h1>'))
  t.true(html.includes('<p class="blue">Something awesome!</p>'))
})
  1. /about页面编写第三个测试,以确保通过服务器端的jsdom进行 DOM 操作,文本内容、类名和样式符合预期。
// test/tests.js
test('Route /about exists and renders correct HTML and style', 
async (t) => {

  function hexify (number) {
    const hexChars = 
     ['0','1','2','3','4','5','6','7','8','9','a','b',
      'c','d','e','f']
    if (isNaN(number)) {
      return '00'
    }
    return hexChars[(number - number % 16) / 16] + 
     hexChars[number % 16]
  }

  const window = await nuxt.renderAndGetWindow(
   'http://localhost:5000/about')
  const element = window.document.querySelector('.blue')
  const rgb = window.getComputedStyle(element).color.match(/\d+/g)
  const hex = '' + hexify(rgb[0]) + hexify(rgb[1]) + hexify(rgb[2])

  t.not(element, null)
  t.is(element.textContent, 'Something awesome!')
  t.is(element.className, 'blue')
  t.is(hex, '0000ff')
})

如果测试通过npm run test,您应该会得到以下结果:

✓ Route / exits and renders correct HTML (369ms)
✓ Route /about exits and renders correct HTML (369ms)
✓ Route /about exists and renders correct HTML and style (543ms)

3 tests passed

您可以看到,在我们的第三个测试中,我们创建了一个hexify函数,用于将由Window.getComputedStyle方法计算的十进制代码(R、G、B)转换为十六进制代码。例如,您将得到rgb(255, 255, 255),对应于您在 CSS 样式中设置为color: white的颜色。因此,对于0000ff,您将得到rgb(0, 0, 255),应用程序必须将其转换以通过测试。

您可以在我们的 GitHub 存储库的/chapter-13/nuxt-universal/ava/中找到这些测试。

干得好。您已经成功为 Nuxt 应用程序编写了简单的测试。我们希望您发现在 Nuxt 中编写测试很容易且有趣。您的测试的复杂性取决于您想要测试什么。因此,首先了解您想要测试的内容非常重要。然后,您可以开始编写一个合理、有意义和相关的测试。

然而,使用 jsdom 与 AVA 测试 Nuxt 应用程序存在一些限制,因为它不涉及浏览器。请记住,jsdom 用于在服务器端将原始 HTML 转换为 DOM,因此我们在前面的练习中使用了 async/await 语句来异步请求页面进行测试。如果您想要使用浏览器来测试您的 Nuxt 应用程序,Nightwatch 可能是一个很好的解决方案,因此我们将在下一节中介绍它。让我们继续。

介绍 Nightwatch

Nightwatch 是一个自动化测试框架,为基于 Web 的应用程序提供端到端的测试解决方案。它在幕后使用 W3C WebDriver API(以前称为 Selenium WebDriver)来打开web 浏览器,对 DOM 元素执行操作和断言。如果您想要使用浏览器来测试您的 Nuxt 应用程序,这是一个很好的工具。但在 Nuxt 应用程序中使用它之前,让我们按照以下步骤单独使用它来编写一些简单的测试,以便您对其工作原理有一个基本的了解:

  1. 通过 npm 安装 Nightwatch,并将其保存到package.json文件的devDependencies选项中:
$ npm i nightwatch --save-dev
  1. 通过 npm 安装 GeckoDriver,并将其保存到package.json文件的devDependencies选项中:
$ npm install geckodriver --save-dev

Nightwatch 依赖于 WebDriver,因此我们需要根据您的目标浏览器安装特定的 WebDriver 服务器-例如,如果您只想针对 Firefox 编写测试,则需要安装 GeckoDriver。

在本书中,我们专注于针对单个浏览器编写测试。但是,如果您想要并行地针对多个浏览器(如 Chrome、Edge、Safari 和 Firefox)进行测试,那么您需要安装Selenium Standalone Server(也称为 Selenium Grid),如下所示:

$ npm i selenium-server --save-dev

请注意,在本书中我们将在 Firefox 和 Chrome 上进行测试,因此不会使用selenium-server包。

  1. package.json文件的test脚本中添加nightwatch
// package.json
{
  "scripts": {
    "test": "nightwatch"
  }
}
  1. 创建一个nightwatch.json文件来配置 Nightwatch 如下:
// nightwatch.json
{
  "src_folders" : ["tests"],

  "webdriver" : {
    "start_process": true,
    "server_path": "node_modules/.bin/geckodriver",
    "port": 4444
  },

  "test_settings" : {
    "default" : {
      "desiredCapabilities": {
        "browserName": "firefox"
      }
    }
  },

  "launch_url": "https://github.com/lautiamkok"
}

在这个简单的练习中,我们想要测试 github.com 上特定贡献者Lau Tiam Kok的仓库搜索功能,所以我们在这个配置中的launch_url选项中设置了https://github.com/lautiamkok

我们将在/tests/目录中编写测试,所以我们在src_folders选项中指定了目录位置。我们将仅针对 Firefox 进行测试,端口为4444,所以我们在webdrivertest_settings选项中设置了这些信息。

你可以在nightwatchjs.org/gettingstarted/configuration/找到其余测试设置的选项,比如output_folder。如果你想找出 Selenium 服务器的测试设置,请访问nightwatchjs.org/gettingstarted/configuration/selenium-server-settings

  1. 在项目根目录中创建一个nightwatch.conf.js文件,用于将驱动程序路径动态设置为服务器路径:
// nightwatch.conf.js
const geckodriver = require("geckodriver")
module.exports = (function (settings) {
  settings.test_workers = false
  settings.webdriver.server_path = geckodriver.path
  return settings
})(require("./nightwatch.json"))
  1. /tests/目录中的一个.js文件(例如demo.js)中准备以下 Nightwatch 测试模板,如下所示:
// tests/demo.js
module.exports = {
  'Demo test' : function (browser) {
    browser
      .url(browser.launchUrl)
      // write your tests here...
      .end()
  }
}
  1. /tests/目录中创建一个github.js文件,其中包含以下代码:
// tests/github.js
module.exports = {
  'Demo test GitHub' : function (browser) {
    browser
      .url(browser.launchUrl)
      .waitForElementVisible('body', 1000)
      .assert.title('lautiamkok (LAU TIAM KOK) · GitHub')
      .assert.visible('input[type=text][placeholder=Search]')
      .setValue('input[type=text][placeholder=Search]', 'nuxt')
      .waitForElementVisible('li[id=jump-to-suggestion-
        search-scoped]', 1000)
      .click('li[id=jump-to-suggestion-search-scoped]')
      .pause(1000)
      .assert.visible('ul[class=repo-list]')
      .assert.containsText('em:first-child', 'nuxt')
      .end()
  }
}

在这个测试中,我们想要断言仓库搜索功能是否按预期工作,所以我们需要确保某些元素和文本内容存在并可见,比如<body><input>元素,以及nuxtlautiamkok (LAU TIAM KOK) · GitHub的文本。当你用npm run test运行它时,你应该得到以下结果(假设测试通过):

[Github] Test Suite
===================
Running: Demo test GitHub

✓ Element <body> was visible after 34 milliseconds.
✓ Testing if the page title equals "lautiamkok (LAU TIAM KOK) · 
   GitHub" - 4 ms.
✓ Testing if element <input[type=text][placeholder=Search]> is 
   visible - 18 ms.
✓ Element <li[id=jump-to-suggestion-search-scoped]> was visible 
   after 533 milliseconds.
✓ Testing if element <ul[class=repo-list]> is visible - 25 ms.
✓ Testing if element <em:first-child> contains text: "nuxt"
  - 28 ms.

OK. 6 assertions passed. (5.809s)

你可以在我们的 GitHub 仓库的/chapter-13/nightwatch/中找到上述测试。有关 Nightwatch 的更多信息,请访问nightwatchjs.org/

与 AVA 相比,Nightwatch 并不那么简洁,因为它需要一些可能会很冗长和复杂的配置,但如果你遵循最简单的nightwatch.json文件,它应该能让你很快地开始使用 Nightwatch。所以,让我们在下一节将你刚学到的内容应用到 Nuxt 应用中。

使用 Nightwatch 为 Nuxt 应用编写测试。

在这个练习中,我们希望针对Chrome 浏览器测试我们在上一章第十二章中创建的用户登录验证和 API 身份验证。我们希望确保用户可以使用他们的凭据登录并按预期获取他们的用户数据。我们将在存放 Nuxt 应用程序的/frontend/目录中编写测试,因此我们需要相应地修改package.json文件,并按以下步骤编写测试:

  1. 通过 npm 安装 ChromeDriver 并将其保存到package.json文件中的devDependencies选项中:
$ npm install chromedriver --save-dev
  1. nightwatch.json文件中将启动 URL 更改为localhost:3000,并根据以下代码块中显示的其他设置修改 Nightwatch 配置文件,以便针对 Chrome 进行测试:
// nightwatch.json
{
  "src_folders" : ["tests"],

  "webdriver" : {
    "start_process": true,
    "server_path": "node_modules/.bin/chromedriver",
    "port": 9515
  },

  "test_settings" : {
    "default" : {
      "desiredCapabilities": {
        "browserName": "chrome"
      }
    }
  },

  "launch_url": "http://localhost:3000"
}
  1. 在项目根目录中创建一个nightwatch.conf.js文件,用于将驱动程序路径动态设置为服务器路径:
// nightwatch.conf.js
const chromedriver = require("chromedriver")
module.exports = (function (settings) {
  settings.test_workers = false
  settings.webdriver.server_path = chromedriver.path
  return settings
})(require("./nightwatch.json"))
  1. /tests/目录中创建一个login.js文件,其中包含以下代码:
// tests/login.js
module.exports = {
  'Local login test' : function (browser) {
    browser
      .url(browser.launchUrl + '/login')
      .waitForElementVisible('body', 1000)
      .assert.title('nuxt-e2e-tests')
      .assert.containsText('h1', 'Please login to see the 
       secret content')
      .assert.visible('input[type=text][name=username]')
      .assert.visible('input[type=password][name=password]')
      .setValue('input[type=text][name=username]', 'demo')
      .setValue('input[type=password][name=password]', 
       '123123')
      .click('button[type=submit]')
      .pause(1000)
      .assert.containsText('h2', 'Hello Alexandre!')
      .end()
  }
}

这个测试的逻辑与上一节的测试相同。我们希望在登录前后确保登录页面上存在某些元素和文本。

  1. 在运行测试之前,在终端上运行 Nuxt 和 API 应用程序,分别在localhost:3000localhost:4000上运行,然后在/frontend/目录中打开另一个终端并运行npm run test。如果测试通过,您应该会得到以下结果:
[Login] Test Suite
==================
Running: Local login test

✓ Element <body> was visible after 28 milliseconds.
✓ Testing if the page title equals "nuxt-e2e-tests" - 4 ms.
✓ Testing if element <h1> contains text: "Please login to see the 
   secret content" - 27 ms.
✓ Testing if element <input[type=text][name=username]> is 
   visible - 25 ms.
✓ Testing if element <input[type=password][name=password]> is 
   visible - 25 ms.
✓ Testing if element <h2> contains text: "Hello Alexandre!" 
  - 75 ms.

OK. 6 assertions passed. (1.613s)

请注意,在运行测试之前,您必须同时运行 Nuxt 应用程序和 API。您可以在我们的 GitHub 存储库的/chapter-13/nuxt-universal/nightwatch/中找到前面的测试。

做得好。您已经完成了关于为 Nuxt 应用程序编写测试的简短章节。本章中的步骤和练习为您提供了扩展测试的基本基础,使您的应用程序变得更大更复杂。让我们在最后一节总结一下您在本章学到的内容。

总结

在本章中,您已经学会了使用 jsdom 进行服务器端 DOM 操作,并分别使用 AVA 和 Nightwatch 编写简单的测试,然后尝试使用这些工具一起在我们的 Nuxt 应用程序上运行端到端测试。您还学会了端到端测试和单元测试之间的区别以及它们各自的优缺点。最后但同样重要的是,您从本章的练习中学到,Nuxt 默认配置完美,可以让您使用 jsdom 和 AVA 轻松编写端到端测试。

在接下来的章节中,我们将介绍如何使用诸如 ESLint、Prettier 和 StandardJS 等代码检查工具来保持我们的代码整洁,以及如何将它们集成和混合到 Vue 和 Nuxt 应用程序中。最后,您将学习 Nuxt 部署命令,并使用它们将您的应用程序部署到实时服务器上。所以,请继续关注。

使用 Linter、Formatter 和部署命令

除了编写测试(无论是端到端测试还是单元测试),代码检查和格式化也是 Web 开发的一部分。所有开发人员,无论您是 Java、Python、PHP 还是 JavaScript 开发人员,都应该了解其领域的编码标准,并遵守这些标准,以保持您的代码清洁、可读,并为将来更好地维护格式化。我们通常用于 JavaScript、Vue 和 Nuxt 应用的工具是 ESLint、Prettier 和 StandardJS。在本章中,您将学习如何安装、配置和使用它们。最后,在构建、测试和检查您的应用程序之后,您将学习 Nuxt 部署命令,以将您的应用程序部署到主机。

本章我们将涵盖以下主题:

  • 介绍 linter - Prettier、ESLint 和 StandardJS

  • 集成 ESLint 和 Prettier

  • 为 Vue 和 Nuxt 应用程序使用 ESLint 和 Prettier

  • 部署 Nuxt 应用程序

第十四章:介绍 linter - Prettier、ESLint 和 StandardJS

简而言之,linter 是一种分析源代码并标记代码和样式中的错误和错误的工具。这个术语起源于 1978 年的一个名为lint的 Unix 实用程序,它评估了用 C 编写的源代码,并由贝尔实验室的计算机科学家 Stephen C. Johnson 开发,用于调试他正在编写的 Yacc 语法。今天,我们在本书中关注的工具是 Prettier、ESLint 和 StandardJS。让我们来看看它们各自的情况。

Prettier

Prettier 是一个支持许多语言的代码格式化程序,如 JavaScript、Vue、JSX、CSS、HTML、JSON、GraphQL 等。它提高了代码的可读性,并确保您的代码符合它为您设置的规则。它为您的代码行设置了长度限制;例如,看一下以下单行代码:

hello(reallyLongArg(), omgSoManyParameters(), IShouldRefactorThis(), isThereSeriouslyAnotherOne())

上面的代码被认为是单行代码过长且难以阅读,因此 Prettier 将为您重新打印成多行,如下所示:

hello(
  reallyLongArg(),
  omgSoManyParameters(),
  IShouldRefactorThis(),
  isThereSeriouslyAnotherOne()
);

此外,任何自定义或混乱的样式也会被解析和重新打印,如下例所示:

fruits({ type: 'citrus' },
  'orange', 'kiwi')

fruits(
  { type: 'citrus' },
  'orange',
  'kiwi'
)

Prettier 会将其打印并重新格式化为以下更整洁的格式:

fruits({ type: 'citrus' }, 'orange', 'kiwi');

fruits({ type: 'citrus' }, 'orange', 'kiwi');

但是,如果 Prettier 在您的代码中找不到分号,它将为您插入分号,就像前面的示例代码一样。如果您喜欢代码中没有分号,您可以关闭此功能,就像本书中使用的所有代码一样。让我们通过以下步骤关闭这个规则:

  1. 通过 npm 将 Prettier 安装到您的项目中:
$ npm i prettier --save-dev --save-exact
  1. 解析特定的 JavaScript 文件:
$ npx prettier --write src/index.js

或者,解析递归文件夹中的所有文件:

$ npx prettier --write "src/**/*"

甚至尝试解析并行文件夹中的文件:

$ npx prettier --write "{scripts,config,bin}/**/*"

在提交任何原地更改(注意!)之前,您可以使用其他输出选项,例如以下选项:

  • 使用-c--check来检查给定的文件是否格式化,并在之后打印一个人性化的摘要消息,其中包含未格式化文件的路径。

  • 使用-l--list-different来打印与 Prettier 格式不同的文件的名称。

有关此工具的更多信息,请访问prettier.io/

现在让我们看看如何在下一节中配置这个工具。

配置 Prettier

Prettier 具有许多自定义选项。您可以通过以下选项配置 Prettier:

  • 一个 JavaScript 对象中的prettier.config.js.prettierrc.js脚本

  • 使用prettier键的package.json文件

  • 一个 YAML 或 JSON 中的.prettierrc文件,可选扩展名:.json.yaml.yml

  • 一个 TOML 中的.prettierrc.toml文件

即使您可以选择不这样做,但定制 Prettier 是一个好主意。例如,Prettier 默认强制使用双引号,并在语句末尾打印分号。如果我们不想要这些默认设置,我们可以在项目根目录中创建一个prettier.config.js文件。让我们在以下步骤中使用 Prettier 在我们创建的 API 中(我们在 GitHub 存储库的/chapter-14/apps-to-fix/koa-api/中制作了一份副本)使用此配置:

  1. 在我们的项目根目录中创建一个prettier.config.js文件,其中包含以下代码:
// prettier.config.js
module.exports = {
  semi: false,
  singleQuote: true
}
  1. 使用以下命令解析/src/目录中的所有 JavaScript 代码:
$ npx prettier --write "src/**/*"

如您所见,当您运行npx prettier --write "src/**/*"时,所有我们的文件都会在终端上列出:

src/config/google.js 40ms
src/config/index.js 11ms
src/core/database/mysql.js 18ms
src/index.js 8ms
...

Prettier 将突出显示已重新打印和格式化的文件。

有关更多格式选项,请查看prettier.io/docs/en/options.html。您可以在我们的 GitHub 存储库的/chapter-14/prettier/中找到此示例。

当您如此轻松地看到您的代码被“美化”时,这是相当不错的,不是吗?让我们继续下一个 linter,ESLint,看看它如何在下一节中帮助整理我们的代码。

ESLint

ESLint 是一个用于 JavaScript 的可插拔代码检查工具。它被设计成所有规则都是完全可插拔的,并允许开发人员自定义代码检查规则。ESLint 附带了一些内置规则,使其从一开始就很有用,但你可以在任何时候动态加载规则。例如,ESLint 禁止对象字面量中的重复键(no-dupe-keys),你将会得到以下代码的错误:

var message = {
  text: "Hello World",
  text: "qux"
}

根据这个规则的正确代码将如下所示:

var message = {
  text: "Hello World",
  words: "Hello World"
}

ESLint 将标记前面的错误,我们将不得不手动修复它。但是,可以在命令行上使用--fix选项来自动修复一些更容易在没有人为干预的情况下修复的问题。让我们看看如何在以下步骤中做到这一点:

  1. 在你的项目中通过 npm 安装 ESLint:
$ npm i eslint --save-dev
  1. 设置一个配置文件:
$ ./node_modules/.bin/eslint --init

你将被要求回答类似以下的问题列表:

? How would you like to use ESLint? To check syntax, find problems,
  and enforce code style
? What type of modules does your project use? JavaScript modules (import/export)
? Which framework does your project use? None of these
? Where does your code run? (Press <space> to select, <a> to 
  toggle all, <i> to invert selection)Browser
? How would you like to define a style for your project? Use 
  a popular style guide
? Which style guide do you want to follow? Standard (https://github.com/standard/standard)
? What format do you want your config file to be in? JavaScript
...

Successfully created .eslintrc.js file in /path/to/your/project

这些问题可能会根据你为每个问题选择的选项/答案而有所不同。

  1. lintlint-fix脚本添加到package.json文件中:
"scripts": {
  "lint": "eslint --ignore-path .gitignore .",
  "lint-fix": "eslint --fix --ignore-path .gitignore ."
}
  1. 创建一个.gitignore文件,包含我们希望 ESLint 忽略的路径和文件:
// .gitignore
node_modules
build
backpack.config.js
  1. 启动 ESLint 进行错误扫描:
$ npm run lint
  1. 使用lint-fix来修复这些错误:
$ npm run lint-fix

你可以在eslint.org/docs/rules/查看 ESLint 规则列表。ESLint 的规则按类别分组:可能的错误、最佳实践、变量、风格问题、ECMAScript 6 等等。默认情况下没有启用任何规则。你可以在配置文件中使用"extends": "eslint:recommended"属性来启用报告常见问题的规则,这些规则在列表中有一个勾号(✓)。

有关此工具的更多信息,请访问eslint.org/

现在让我们看看如何在下一节中配置这个工具。

配置 ESLint

正如我们之前提到的,ESLint 是一个可插拔的代码检查工具。这意味着它是完全可配置的,你可以关闭每个规则,或其中一些规则,或混合自定义规则,使 ESLint 特别适用于你的项目。让我们在我们创建的 API 中使用 ESLint,并选择以下配置之一。有两种方法来配置 ESLint:

  • 在文件中直接使用 JavaScript 注释与 ESLint 配置信息,就像下面的例子一样:
// eslint-disable-next-line no-unused-vars
import authenticate from 'middlewares/authenticate'
  • 使用 JavaScript、JSON 或 YAML 文件来指定整个目录及其所有子目录的配置信息。

使用第一种方法可能会耗费时间,因为您可能需要在每个.js文件中提供 ESLint 配置信息,而在第二种方法中,您只需要在.json文件中一次配置它。因此,在以下步骤中,让我们使用第二种方法来为我们的 API 进行配置:

  1. 创建一个.eslintrc.js文件,或者在根目录中使用--init生成它,其中包含以下规则:
// .eslintrc.js
module.exports = {
  'rules': {
    'no-undef': ['off'],
    'no-console': ['error']
    'quotes': ['error', 'double']
  }
}

在这些规则中,我们希望确保执行以下操作:

  • 通过将no-undef选项设置为off来允许未声明的变量(no-undef)

  • 通过将no-console选项设置为error来禁止使用控制台(no-console)

  • 强制使用反引号、双引号或单引号(quotes),将quotes选项设置为errordouble

  1. lintlint-fix脚本添加到package.json文件中:
// package.json
"scripts": {
  "lint": "eslint --ignore-path .gitignore .",
  "lint-fix": "eslint --fix --ignore-path .gitignore ."
}
  1. 启动 ESLint 进行错误扫描:
$ npm run lint

如果有任何错误,您将收到以下类似的报告:

/src/modules/public/login/_routes/google/me.js 
   36:11  error  A space is required after '{'  object-
          curly-spacing 
   36:18  error  A space is required before '}' object-
          curly-spacing 

尽管 ESLint 可以使用--fix选项自动修复您的代码,但您仍然需要手动修复一些,就像以下示例中一样:

/src/modules/public/user/_routes/fetch-user.js 
  9:9  error  'id' is assigned a value but never used  
       no-unused-vars 

有关配置的更多信息,请查看eslint.org/docs/user-guide/configuring。您可以在我们的 GitHub 存储库的/chapter-14/eslint/中找到此示例。

它用户友好,不是吗?它确实是另一个像 Prettier 一样令人敬畏的工具。让我们继续介绍最后一个代码检查器 StandardJS,看看它如何整理我们的代码。

StandardJS

StandardJS 或 JavaScript 标准样式是 JavaScript 样式指南、代码检查器和格式化程序。它完全是主观的,这意味着它是完全不可定制的 - 不需要配置,因此没有.eslintrc.jshintrc.jscsrc文件来管理。它是不可定制和不可配置的。使用 StandardJS 的最简单方法是将其作为 Node 命令行程序全局安装。让我们看看您可以如何在以下步骤中使用此工具:

  1. 通过 npm 全局安装 StandardJS:
$ npm i standard --global

您还可以为单个项目在本地安装它:

$ npm i standard --save-dev
  1. 导航到要检查的目录,并在终端中输入以下命令:
$ standard
  1. 如果您在本地安装了 StandardJS,则使用npx来运行它:
$ npx standard

您还可以将其添加到package.json文件中,如下所示:

// package.json
{
  scripts": {
    "jss": "standard",
    "jss-fix": "standard --fix"
  },
  "devDependencies": {
    "standard": "¹².0.1"
  },
  "standard": {
    "ignore": [
      "/node_modules/",
      "/build/",
      "backpack.config.js"
    ]
  }
}
  1. 然后,当您使用 npm 运行 JavaScript 项目的代码时,代码将被自动检查:
$ npm run jss

要修复任何混乱或不一致的代码,请尝试以下命令:

$ npm run jss-fix

尽管 StandardJS 是不可定制的,但它依赖于 ESLint。StandardJS 使用的 ESLint 包如下:

  • eslint

  • standard-engine

  • eslint-config-standard

  • eslint-config-standard-jsx

  • eslint-plugin-standard

虽然 Prettier 是一个格式化工具,StandardJS 大多是一个类似 ESLint 的 linter。如果你在你的代码上使用--fix来修复 StandardJS 或 ESLint,然后再用 Prettier 运行它,你会发现任何长行(这些行被 StandardJS 和 ESLint 忽略)将被 Prettier 格式化。

有关此工具的更多信息,请访问standardjs.com/。你还应该查看标准 JavaScript 规则的摘要,网址为standardjs.com/rules.html。你可以在我们的 GitHub 存储库的/chapter-14/standard/中找到一个使用 StandardJS 的示例。

然而,如果你正在寻找一个更灵活和可定制的解决方案,介于这些工具之间,你可以为你的项目结合使用 Prettier 和 ESLint。让我们在下一节看看你如何实现这一点。

集成 ESLint 和 Prettier

Prettier 和 ESLint 相辅相成。我们可以将 Prettier 集成到 ESLint 的工作流中。这样你就可以使用 Prettier 来格式化你的代码,同时让 ESLint 专注于 linting 你的代码。因此,为了集成它们,首先我们需要从 ESLint 中使用eslint-plugin-prettier插件来使用 Prettier。然后我们可以像往常一样使用 Prettier 来添加格式化代码的规则。

然而,ESLint 包含与 Prettier 冲突的格式相关的规则,比如arrow-parensspace-before-function-paren,在一起使用时可能会引起一些问题。为了解决这些冲突问题,我们需要使用eslint-config-prettier配置来关闭与 Prettier 冲突的 ESLint 规则。让我们在以下步骤中看看你如何实现这一点:

  1. 通过 npm 安装eslint-plugin-prettiereslint-config-prettier
$ npm i eslint-plugin-prettier --save-dev
$ npm i eslint-config-prettier --save-dev
  1. .eslintrc.json文件中启用eslint-plugin-prettier的插件和规则:
{
  "plugins": ["prettier"],
  "rules": {
    "prettier/prettier": "error"
  }
}
  1. 使用eslint-config-prettier.eslintrc.json文件中通过扩展 Prettier 的规则来覆盖 ESLint 的规则:
{
  "extends": ["prettier"]
}

请注意,值"prettier"应该放在extends数组的最后,以便 Prettier 的配置可以覆盖 ESLint 的配置。此外,我们可以使用.eslintrc.js文件而不是 JSON 文件来进行上述配置,因为我们可以在 JavaScript 文件中添加有用的注释。因此,以下是我们在 ESLint 下使用 Prettier 的配置:

// .eslintrc.js
module.exports = {
  //...
  'extends': ['prettier']
  'plugins': ['prettier'],
  'rules': {
    'prettier/prettier': 'error'
  }
}
  1. package.json文件(或prettier.config.js文件)中配置 Prettier,以便 Prettier 不会在我们的代码中打印分号,并始终使用单引号:
{
  "scripts": {
    "lint": "eslint --ignore-path .gitignore .",
    "lint-fix": "eslint --fix --ignore-path .gitignore ."
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  }
}
  1. 在终端上运行npm run lint-fix以一次性修复和格式化我们的代码。之后,您可以使用npx prettier命令再次仅使用 Prettier 检查代码:
$ npx prettier --c "src/**/*"

然后您应该在终端上获得以下结果:

Checking formatting...
All matched files use Prettier code style!

这意味着我们的代码没有格式问题,并且在 Prettier 代码样式中成功编译。将这两个工具结合起来以满足我们的需求和偏好是非常酷的,不是吗?但是您仍然只完成了一半-让我们在下一节中为 Vue 和 Nuxt 应用程序应用这些配置。

您可以在我们的 GitHub 存储库的/chapter-14/eslint+prettier/中找到此集成示例。

在 Vue 和 Nuxt 应用程序中使用 ESLint 和 Prettier

eslint-plugin-vue 插件是 Vue 和 Nuxt 应用程序的官方 ESLint 插件。它允许我们使用 ESLint 检查.vue文件中<template><script>块中的代码,以查找任何语法错误,错误使用 Vue 指令以及违反 Vue 风格指南的 Vue 样式。此外,我们正在使用 Prettier 来强制执行代码格式,因此像我们在上一节中所做的那样安装eslint-plugin-prettiereslint-config-prettier以获取我们喜欢的基本特定配置。让我们在以下步骤中解决所有这些问题:

  1. 使用 npm 安装eslint-plugin-vue插件:
$ npm i eslint-plugin-vue --save-dev

您可能会收到一些警告:

npm WARN eslint-plugin-vue@5.2.3 requires a peer of eslint@⁵.0.0
 but none is installed. You must install peer dependencies
  yourself.
npm WARN vue-eslint-parser@5.0.0 requires a peer of eslint@⁵.0.0 
 but none is installed. You must install peer dependencies 
  yourself.

忽略它们,因为eslint-plugin-vue的最低要求是 ESLint v5.0.0 或更高版本和 Node.js v6.5.0 或更高版本,而您应该已经拥有最新版本。

您可以在eslint.vuejs.org/user-guide/installation查看最低要求。除了 Vue 风格指南,您还应该查看eslint.vuejs.org/rules/上的 Vue 规则。

  1. 在 ESLint 配置文件中添加eslint-plugin-vue插件及其通用规则集:
// .eslintrc.js
module.exports = {
  extends: [
    'plugin:vue/recommended'
  ]
}
  1. 安装 eslint-plugin-prettiereslint-config-prettier 并将它们添加到 ESLint 配置文件中:
// .eslintrc.js
module.exports = {
  'extends': [
    'plugin:vue/recommended',
    'plugin:prettier/recommended'
  ],
  'plugins': [
    'prettier'
  ]
}

但这些还不够。您可能希望配置一些 Vue 规则以适应您的偏好。让我们在下一节中找出一些默认的 Vue 关键规则,我们可能希望配置。

有关此 eslint-plugin-vue 插件的更多信息,请访问 eslint.vuejs.org/。有关 Vue 指令,请访问 vuejs.org/v2/api/Directives,有关 Vue 风格指南,请访问 vuejs.org/v2/style-guide/

配置 Vue 规则

在本书中,我们只想覆盖四个默认的 Vue 规则。您只需要在 .eslintrc.js 文件的 'rules' 选项中添加首选规则,就像我们在上一节中为 eslint-plugin-prettier 插件所做的那样。让我们按照以下步骤进行:

  1. vue/v-on-style 规则配置为 "longform" 如下:
// .eslintrc.js
'rules': {
  'vue/v-on-style': ['error', 'longform']
}

vue/v-on-style 规则强制在 v-on 指令样式上使用 shorthandlongform。默认设置为 shorthand,例如:

<template>
  <!-- ✓ GOOD -->
  <div @click="foo"/>

  <!-- ✗ BAD -->
  <div v-on:click="foo"/>
</template>

但在本书中,首选 longform,如下例所示:

<template>
  <!-- ✓ GOOD -->
  <div v-on:click="foo"/>

  <!-- ✗ BAD -->
  <div @click="foo"/>
</template>

有关此规则的更多信息,请访问 eslint.vuejs.org/rules/v-on-style.htmlvue-v-on-style

  1. vue/html-self-closing 规则配置为允许在空元素上使用自闭合符号如下:
// .eslintrc.js
'rules': {
  'vue/html-self-closing': ['error', {
    'html': {
      'void': 'always'
    }
  }]
}

空元素是 HTML 元素,在任何情况下都不允许有内容,例如 <br><hr><img><input><link><meta>。在编写 XHTML 时,必须自闭这些元素,例如 <br/><img src="..." />。在本书中,即使在 HTML5 中,/ 字符被认为是可选的,我们也希望允许这样做。

根据 vue/html-self-closing 规则,自闭合这些空元素将导致错误,尽管它旨在强制 HTML 元素中的自闭合符号。这相当令人困惑,对吧?在 Vue.js 模板中,我们可以使用以下两种样式来表示没有内容的元素:

    • <YourComponent></YourComponent>
  • <YourComponent/>(自闭合)

根据此规则,第一个选项将被拒绝,如下例所示:

<template>
  <!-- ✓ GOOD -->
  <MyComponent/>

  <!-- ✗ BAD -->
  <MyComponent></MyComponent>
</template>

然而,它也拒绝了自闭合的空元素:

<template>
  <!-- ✓ GOOD -->
  <img src="...">

  <!-- ✗ BAD -->
  <img src="..." />
</template>

换句话说,在 Vue 规则中,不允许空元素具有自闭合标记。因此,默认情况下,html.void选项的值设置为'never'。因此,如果您想要允许这些空元素上的自闭合标记,就像本书中一样,那么将值设置为'always'

有关此规则的更多信息,请访问eslint.vuejs.org/rules/html-self-closing.htmlvue-html-self-closing

  1. vue/max-attributes-per-line规则配置为关闭此规则如下:
// .eslintrc.js
'rules': {
  'vue/max-attributes-per-line': 'off'
}

vue/max-attributes-per-line规则旨在强制每行一个属性。默认情况下,当两个属性之间有换行时,认为属性在新行中。以下是在此规则下的示例:

<template>
  <!-- ✓ GOOD -->
  <MyComponent lorem="1"/>
  <MyComponent
    lorem="1"
    ipsum="2"
  />
  <MyComponent
    lorem="1"
    ipsum="2"
    dolor="3"
  />

  <!-- ✗ BAD -->
  <MyComponent lorem="1" ipsum="2"/>
  <MyComponent
    lorem="1" ipsum="2"
  />
  <MyComponent
    lorem="1" ipsum="2"
    dolor="3"
  />
</template>

然而,此规则与 Prettier 冲突。我们应该让 Prettier 处理这样的情况,这就是为什么我们会关闭这个规则。

有关此规则的更多信息,请访问eslint.vuejs.org/rules/max-attributes-per-line.htmlvue-max-attributes-per-line

  1. 配置eslint/space-before-function-paren规则:
// .eslintrc.js
'rules': {
  'space-before-function-paren': ['error', 'always']
}

eslint/space-before-function-paren规则旨在强制在函数声明的括号前添加一个空格。ESLint 默认行为是添加空格,这也是 StandardJS 中定义的规则。请参阅以下示例:

function message (text) { ... } // ✓ ok
function message(text) { ... } // ✗ avoid

message(function (text) { ... }) // ✓ ok
message(function(text) { ... }) // ✗ avoid

然而,在前述规则下,当您使用 Prettier 时,您将会收到以下错误:

/middleware/auth.js
  1:24 error Delete · prettier/prettier

我们将忽略 Prettier 的错误,因为我们想要遵循 Vue 中的规则。但是目前,Prettier 还没有选项来禁用这个规则,可以从prettier.io/docs/en/options.html查看。如果因为 Prettier 而删除了空格,您可以通过在 Vue 规则下将值设置为'always'来添加回来。

有关此规则的更多信息,请访问eslint.org/docs/rules/space-before-function-parenstandardjs.com/rules.html

  1. 因为 ESLint 默认只针对.js文件,所以在 ESLint 命令中使用--ext选项(或者 glob 模式)包含.vue扩展名,以在终端上运行 ESLint 并使用前述配置。
$ eslint --ext .js,.vue src
$ eslint "src/**/*.{js,vue}"

您还可以在package.json文件中的.gitignore选项中使用自定义命令来运行它,如下所示:

// package.json
"scripts": {
  "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
  "lint-fix": "eslint --fix --ext .js,.vue --ignore-path 
   .gitignore ."
}

// .gitignore
node_modules
build
nuxt.config.js
prettier.config.js

ESLint 将忽略在前面的.gitignore片段中定义的文件,同时对所有 JavaScript 和 Vue 文件进行 lint。通过 webpack 进行热重载时对文件进行 lint 是一个好主意。只需将以下片段添加到 Nuxt 配置文件中,以便在保存代码时运行 ESLint:

// nuxt.config.js
...
build: {
 extend(config, ctx) {
    if (ctx.isDev && ctx.isClient) {
      config.module.rules.push({
        enforce: "pre",
        test: /\.(js|vue)$/,
        loader: "eslint-loader",
        exclude: /(node_modules)/
      })
    }
  }
}

您可以在我们的 GitHub 存储库的/chapter-14/eslint-plugin-vue/integrate/中找到一个使用此插件与 ESLint 的示例。

正如您在本节和前几节中所看到的,将 ESLint 和 Prettier 混合在单个配置文件中可能会有问题。您可能得到的麻烦可能不值得让它们“作为一个团队”一起工作。为什么不尝试在不耦合它们的情况下分别运行它们呢?让我们在下一节中找出如何为 Nuxt 应用程序做到这一点。

在 Nuxt 应用程序中分别运行 ESLint 和 Prettier

另一个解决 ESLint 和 Prettier 之间冲突的可能解决方案,特别是在space-before-function-paren上,是根本不集成它们,而是分别运行它们来格式化和检查我们的代码。所以让我们在以下步骤中让它们正常工作:

  1. package.json文件中分别为 Prettier 和 ESLint 创建脚本,如下所示:
// package.json
"scripts": {
"prettier": "prettier --check \"
 {components,layouts,pages,store,middleware,plugins}/**/*.{vue,js}
   \"", "prettier-fix": "prettier --write 
   {components,layouts,pages,store,middleware,plugins}
    /**/*.{vue,js}\"", "lint": "eslint --ext .js,.vue 
    --ignore-path .gitignore .",
   "lint-fix": "eslint --fix --ext .js,.vue --ignore-path
     .gitignore ."
}

现在我们可以完全忘记eslint-plugin-prettier和我们工作流程中的eslint-config-prettier配置。我们仍然保留eslint-plugin-vue和在本章中已经配置的规则,但是完全从.eslintrc.js文件中删除 Prettier:

// .eslintrc.js
module.exports = {
  //...
  'extends': [
    'standard',
    'plugin:vue/recommended',
    // 'prettier' // <- removed this.
  ]
}
  1. 当我们想要分析我们的代码时,先运行 Prettier,然后运行 ESLint:
$ npm run prettier
$ npm run lint
  1. 再次,当我们想要修复格式并对我们的代码进行 lint 时,先运行 Prettier,然后运行 ESLint:
$ npm run prettier-fix
$ npm run lint-fix

您可以看到,这个解决方案以这种方式使我们的工作流程更清晰和更干净。不再有冲突-一切都很顺利。很好。

您可以在我们的 GitHub 存储库的/chapter-14/eslint-plugin-vue/separate/中找到一个分别运行 ESLint 和 Prettier 的示例。

干得好。您已经完成了本章的第一个重要部分。我们希望您将开始或已经开始为您的 Vue 和 Nuxt 应用程序编写美观且易读的代码,并利用这些令人惊叹的格式化程序和代码检查工具。随着本书中关于 Nuxt 的学习接近尾声,我们将在下一节中向您介绍如何部署 Nuxt 应用程序。所以请继续阅读。

部署 Nuxt 应用程序

除了代码检查和格式化之外,应用程序部署也是 Web 开发工作流的一部分。我们需要将应用程序部署到远程服务器或主机上,以便公众可以公开访问应用程序。Nuxt 带有内置命令,我们可以使用这些命令来部署我们的应用程序。它们如下:

  • nuxt

  • nuxt build

  • nuxt start

  • nuxt generate

nuxt命令是您现在在终端上熟悉使用的命令:

$ npm run dev

如果您打开使用create-nuxt-app脚手架工具安装项目时 Nuxt 生成的package.json文件,您会看到这些命令预先配置在"scripts"片段中,如下所示:

// package.json
"scripts": {
  "dev": "nuxt",
  "build": "nuxt build",
  "start": "nuxt start",
  "generate": "nuxt generate"
}

您可以使用以下 Node.js 命令行在终端上启动命令:

$ npm run <command>

nuxt命令用于在开发服务器上进行开发,并具有热重新加载功能,而其他命令用于生产部署。让我们看看如何在下一节中使用它们来部署您的 Nuxt 应用。

您还可以在任何这些命令中使用常见参数,例如--help。如果您想了解更多信息,请访问nuxtjs.org/guide/commandslist-of-commands

部署 Nuxt 通用服务器端渲染应用程序

希望通过学习之前的所有章节,您知道自己一直在开发 Nuxt 通用服务器端渲染SSR)应用程序。SSR 应用程序是在服务器端呈现应用程序内容的应用程序。这种应用程序需要特定的服务器来运行您的应用程序,例如 Node.js 和 Apache 服务器,而像您使用 Nuxt 创建的通用 SSR 应用程序在服务器端和客户端上都可以运行。这种应用程序也需要特定的服务器。使用 Nuxt 通用 SSR 应用程序可以在终端上使用两个命令进行部署。让我们看看您可以在以下步骤中如何执行此操作:

  1. 通过 npm 启动nuxt build命令来使用 webpack 构建应用程序并压缩 JavaScript 和 CSS:
$ npm run build

您应该获得以下构建结果:

> [your-app-name]@[your-app-name] start /var/path/to/your/app
> nuxt build
ℹ Production build
ℹ Bundling for server and client side
ℹ Target: server 
✓ Builder initialized
✓ Nuxt files generated
...
...
  1. 通过 npm 启动nuxt start命令以生产模式启动服务器:
$ npm run start

您应该获得以下启动状态:

> [your-app-name]@[your-app-name] start /var/path/to/your/app
> nuxt start

Nuxt.js @ v2.14.0

> Environment: production
> Rendering: server-side
> Target: server

Memory usage: 28.8 MB (RSS: 88.6 MB)

部署 Nuxt 通用 SSR 应用程序只需要两条命令行。这很容易,不是吗?但是,如果您没有 Node.js 服务器来托管您的应用程序,或者出于任何原因,您只想将应用程序部署为静态站点,您可以从 Nuxt 通用 SSR 应用程序生成它。让我们在下一节中了解如何实现这一点。

部署 Nuxt 静态生成(预渲染)应用程序

要从 Nuxt 通用 SSR 应用程序生成 Nuxt 静态生成的应用程序,我们将使用我们在前几章中为此练习创建的示例网站。您可以在我们的 GitHub 存储库的/chapter-14/deployment/sample-website/中找到此示例。因此,让我们按照以下步骤开始:

  1. 确保您的package.json文件中有以下"generate"运行脚本:
"scripts": {
  "generate": "nuxt generate"
} 
  1. 将 Nuxt 配置文件中的target项目更改为static
// nuxt.config.js
export default {
  target: 'static'
}
  1. 通过在 Nuxt 配置文件中配置generate选项来生成 404 页面:
// nuxt.config.js
export default {
  generate: {
    fallback: true
  }
}

Nuxt 不会生成您的自定义 404 页面,也不会生成默认页面。如果您想在静态应用程序中包含此页面,可以在配置文件中的generate选项中设置fallback: true

  1. 通过 npm 启动nuxt generate命令来构建应用程序并为每个路由生成 HTML 文件:
$ npm run generate

Nuxt 具有一个爬虫,它会扫描链接并为您自动生成动态路由及其异步内容(使用asyncDatafetch方法呈现的数据)。因此,您应该按以下方式获取应用程序的每个路由:

ℹ Generating output directory: dist/
ℹ Generating pages with full static mode
✓ Generated route "/contact"
✓ Generated route "/work-nested"
✓ Generated route "/about"
✓ Generated route "/work"
✓ Generated route "/"
✓ Generated route "/work-nested/work-sample-4"
✓ Generated route "/work-nested/work-sample-1"
✓ Generated route "/work-nested/work-sample-3"
✓ Generated route "/work-nested/work-sample-2"
✓ Generated route "/work/work-sample-1"
✓ Generated route "/work/work-sample-4"
✓ Generated route "/work/work-sample-2"
✓ Generated route "/work/work-sample-3"
✓ Client-side fallback created: 404.html
i Ready to run nuxt serve or deploy dist/ directory 

请注意,您仍然需要使用generate.routes来生成爬虫无法检测到的路由。

  1. 如果您查看项目根目录,您应该会发现 Nuxt 生成的/dist/文件夹,其中包含部署应用程序到静态托管服务器所需的一切。但在此之前,您可以使用终端上的nuxt serve命令从/dist/目录测试生产静态应用程序:
$ npm run start

您应该在终端上获得以下输出:

Nuxt.js @ v2.14.0 

> Environment: production
> Rendering: server-side
> Target: static
Listening: http://localhost:3000/

ℹ Serving static application from dist/ 
  1. 现在,您可以将浏览器指向localhost:3000,并查看应用程序是否像 SSR 一样运行,但实际上,它是一个静态生成的应用程序。

我们将在下一章回到这个配置,用于部署 Nuxt 单页面应用程序SPA)应用。您可以看到,选择这种部署方式只需要做一点工作,但完全值得,因为以“静态”方式部署您的应用程序有好处,比如您可以将静态文件托管在静态托管服务器上,这相对便宜于 Node.js 服务器。我们将在下一章向您展示如何在这种服务器上为您的静态站点提供服务,就像GitHub Pages一样。尽管以“静态”方式部署 Nuxt 通用 SSR 应用程序有好处,但您必须考虑以下注意事项:

  • asyncDatafetch方法的 Nuxt 上下文将失去来自 Node.js 的 HTTP reqres对象。

  • nuxtServerInit操作将不可用于存储。

因此,如果您的 Nuxt 应用程序在上述列表中严重依赖这些项目,那么将 Nuxt 通用 SSR 应用程序生成为静态文件可能不是一个好主意,因为它们是服务器端功能。但是,我们可以在客户端使用客户端 cookie 模仿nuxtServerInit操作,我们也将在下一章向您展示。但现在,让我们继续前进到下一节,找出您可以选择的托管服务器类型来托管您的 Nuxt 应用程序。

如果您想了解有关generate属性/选项和其他选项的更多信息,例如您可以使用此属性进行配置的fallbackroutes选项,请访问nuxtjs.org/api/configuration-generate

在虚拟专用服务器上托管 Nuxt 通用 SSR 应用程序。

在托管 Node.js 应用程序时,虚拟专用服务器VPS)和专用服务器是更好的选择,因为您将完全自由地为您的应用程序设置 Node.js 环境。每当 Node.js 发布新版本时,您也应该更新您的环境。只有使用 VPS 服务器,您才能随时升级和调整您的环境。

如果您正在寻找 Linux 服务器并且希望从头开始安装您需要的基础设施,VPS 提供商如 Linode 或 Vultr 提供了实惠的 VPS 主机定价。这些 VPS 提供商提供给您的是一个空的虚拟机,您可以选择您喜欢的 Linux 发行版,例如 Ubuntu。构建您所需基础设施的过程与在本地机器上刚刚安装 Linux 发行版时安装 Node.js、MongoDB、MySQL 等的过程是一样的。有关这些 VPS 提供商的更多信息,请访问以下链接:

在满足您的要求的 Node.js 环境和基础设施设置好之后,您可以将 Nuxt 应用程序上传到这种类型的主机,然后通过这些主机提供的安全外壳SSH)功能在终端上轻松构建和启动应用程序:

$ npm run build
$ npm run start

共享主机服务器怎么样?让我们看看下一节中你可以选择的内容。

在共享主机服务器上托管 Nuxt 通用 SSR 应用程序

记住,并非所有主机都支持 Node.js,并且与支持 PHP 的共享主机服务器相比,支持 Node.js 的共享主机服务器相对较少。但所有共享主机服务器都是一样的-通常你所能做的事情受到严格限制,你必须遵循提供者制定的严格规则。您可以查看以下共享主机服务器提供商:

例如,在 Reclaim Hosting 的共享主机服务器上,您很可能无法运行 Nuxt 命令来启动您的应用程序。相反,您需要向服务器提供一个应用程序启动文件,这个文件必须被称为app.js并放在您的项目根目录中。

如果您想选择 Reclaim Hosting,您可以使用他们的测试环境stateu.org/来看看它对您的工作方式。但请记住,高级设置是不可能的。好消息是,Nuxt 提供了一个 Node.js 模块nuxt-start,可以在这样的共享主机服务器上以生产模式启动 Nuxt 应用程序。所以让我们在以下步骤中找出如何做:

  1. 通过 npm 在本地安装nuxt-start
$ npm i nuxt-start
  1. 在你的项目根目录中创建一个app.js文件,并使用以下代码启动 Nuxt 应用程序:
// app.js
const { Nuxt } = require('nuxt-start')
const config = require('./nuxt.config.js')

const nuxt = new Nuxt(config)
const { host, port } = nuxt.options.server

nuxt.listen(port, host)

或者,你可以使用 Express 或 Koa 来启动你的 Nuxt 应用。以下示例假设你正在使用 Express:

// app.js
const express = require('express')
const { Nuxt } = require('nuxt')
const app = express()

let config = require('./nuxt.config.js')
const nuxt = new Nuxt(config)
const { host, port } = nuxt.options.server

app.use(nuxt.render)
app.listen(port, host)

在这段代码中,我们导入了expressnuxt模块以及nuxt.config.js文件,然后将 Nuxt 应用程序用作中间件。如果你使用 Koa,情况也是一样的 - 你只需要将 Nuxt 用作中间件。

  1. 使用app.js文件将 Nuxt 应用程序上传到服务器,并按照主机的说明通过 npm 安装应用程序依赖项,然后运行app.js启动你的应用程序。

这就是你需要做的全部。这些共享主机服务器存在一些限制。在这些服务器中,你对 Node.js 环境的控制较少。但是,如果你遵循服务器提供商设定的严格规则,你可以让你的通用 SSR Nuxt 应用程序快速运行起来。

你可以在我们的 GitHub 存储库中的/chapter-14/deployment/shared-hosting/reclaimhosting.com/中找到上述示例代码和其他示例代码,用于在 Reclaim Hosting 上托管 Nuxt 通用 SSR 应用程序。

有关nuxt-start的更多信息,请访问www.npmjs.com/package/nuxt-start

你可以看到它并不完美,并且有其局限性,但如果你正在寻找共享主机,这是合理的。如果这对你来说不理想,那么最后的选择是选择静态站点主机,我们将在下一节中看到。

在静态站点主机上托管 Nuxt 静态生成的应用程序

通过这种方式,你将失去 Nuxt 的服务器端。但好消息是,有许多流行的主机可以托管静态生成的 Nuxt 应用程序,并且你可以快速在几乎任何在线主机上提供服务。让我们在以下步骤中看看如何做到这一点:

  1. 在 Nuxt 配置文件中将server更改为static作为目标。
// nuxt.config.js
export default {
  target: 'static'
}
  1. 通过 npm 在本地启动nuxt generate命令来生成 Nuxt 应用程序的静态文件:
$ npm run generate
  1. 将 Nuxt 生成的/dist/文件夹中的所有内容上传到主机。

以下列表详细介绍了你可以选择的主机。所有这些主机的部署过程都在 Nuxt 网站上有详细说明。你应该查看 Nuxt FAQ nuxtjs.org/faq 来查看部署示例,并了解如何将静态生成的 Nuxt 应用程序部署到这些特定主机中的任何一个:

在下一章中,我们将指导您在 GitHub Pages 上部署 Nuxt SPA 应用程序。但现在,这是本章关于格式化、检查和部署 Nuxt 通用 SSR 应用程序的结束。让我们总结一下您在本章中学到的内容。

摘要

干得好。你已经走了这么远。这是一段相当漫长的旅程。在本章中,我们涵盖了 JavaScript 的检查器和格式化程序,特别是 ESLint,Prettier 和 StandardJS 用于 Nuxt 应用程序以及一般的 JavaScript 应用程序。您已经学会了如何安装和配置它们以满足您的需求和偏好。我们还介绍了部署 Nuxt 应用程序的 Nuxt 命令以及可用于托管 Nuxt 应用程序的选项,无论是通用 SSR 应用程序还是静态生成的站点。

在接下来的章节中,我们将学习如何使用 Nuxt 创建 SPA,并将其部署到 GitHub Pages。您将看到传统 SPA 和 Nuxt 中 SPA(让我们称之为Nuxt SPA)之间的细微差别。我们将指导您完成在 Nuxt 中设置 SPA 开发环境的过程,重构您在前几章中创建的通用 SSR Nuxt 身份验证应用程序,并将其转换为 Nuxt SPA 和静态生成的 Nuxt SPA。最后,您将学会将静态生成的 SPA 部署到 GitHub Pages。所以请继续阅读。

第六部分:更进一步的领域

在本节中,我们将学习如何在 Nuxt 中做更多事情。我们将学习如何在 Nuxt 中开发单页面应用(SPA),使用 PHP 而不是 JavaScript 来创建跨域和外部 API 数据平台来供养我们的 Nuxt 应用,开发实时应用程序,并在 Nuxt 中使用(无头)CMS 和 GraphQL。

本节包括以下章节:

  • 第十五章,使用 Nuxt 创建 SPA

  • 第十六章,为 Nuxt 创建一个与框架无关的 PHP API

  • 第十七章,使用 Nuxt 创建实时应用

  • 第十八章,使用 CMS 和 GraphQL 创建 Nuxt 应用

使用 Nuxt 创建 SPA

在之前的章节中,我们创建了各种 Nuxt 应用程序,以universal模式。这些是通用服务器端渲染(SSR)应用程序。这意味着它们是在服务器端和客户端上运行的应用程序。Nuxt 为我们提供了另一种开发单页面应用程序SPA)的选项,就像我们可以使用 Vue 和其他 SPA 框架(如 Angular 和 React)一样。在本章中,我们将指导您如何在 Nuxt 中开发、构建和部署 SPA,并了解它与现有的传统 SPA 有何不同。

在这一章中,我们将涵盖以下主题:

  • 理解经典 SPA 和 Nuxt SPA

  • 安装 Nuxt SPA

  • 开发 Nuxt SPA

  • 部署 Nuxt SPA

让我们开始吧!

第十五章:理解经典 SPA 和 Nuxt SPA

SPA,也称为经典 SPA,是一种应用程序,它在浏览器上加载一次,不需要我们在应用程序的整个生命周期内重新加载和重新渲染页面。这与多页面应用程序(MPA)不同,在多页面应用程序中,每次更改和与服务器的每次数据交换都需要我们重新从服务器到浏览器重新渲染整个页面。

在经典/传统的 SPA 中,提供给客户端的 HTML 相对为空。一旦到达客户端,JavaScript 将动态渲染 HTML 和内容。React、Angular 和 Vue 是创建经典 SPA 的流行选择。然而,不要与 spa 模式的 Nuxt 应用程序混淆(让我们称之为Nuxt SPA),尽管 Nuxt 为您提供了使用一行配置开发“SPA”的选项,如下所示:

// nuxt.config.js
export default {
  mode: 'spa'
}

Nuxt 的 SPA 模式简单地意味着您失去了 Nuxt 和 Node.js 的服务器端特性,就像我们在第十四章中学到的,在将通用 SSR Nuxt 应用程序转换为静态生成(预渲染)Nuxt 应用程序时,使用了代码检查器、格式化程序和部署命令。对于 spa 模式的 Nuxt 应用程序也是一样-当您使用上述配置时,您的 spa 模式 Nuxt 应用程序将成为纯粹的客户端应用程序

但是,spa 模式 Nuxt 应用程序与您从 Vue CLI、React 或 Angular 创建的经典 SPA 有很大不同。这是因为构建应用程序后,您(经典)SPA 的页面和路由将在运行时由 JavaScript 动态呈现。另一方面,spa 模式 Nuxt 应用程序中的页面将在构建时进行预渲染,并且每个页面中的 HTML 与经典 SPA 一样“空”。这就是事情开始变得混乱的地方。让我们看看以下示例。假设您的 Vue 应用程序中有以下页面和路由:

src
├── favicon.ico
├── index.html
├── components
│ ├── about.vue
│ ├── secured.vue
│ └── ...
└── routes
  ├── about.js
  ├── secured.js
  └── ...

您的应用程序将构建到以下分发中:

dist
├── favicon.ico
├── index.html
├── css
│ └── ...
└── js
  └── ...

在这里,您可以看到只有index.html/css//js/文件夹构建到/dust/文件夹中。这意味着您的应用程序的页面和路由将在运行时由 JavaScript 动态呈现。然而,假设您的 spa 模式 Nuxt 应用程序中有以下页面:

pages
├── about.vue
├── secured.vue
├── ...
└── users
  ├── about.js
  ├── index.vu
  └── _id.vue

您的应用程序将构建到以下分发中:

dist
├── index.html
├── favicon.ico
├── about
│ └── index.html
├── secured
│ └── index.html
├── users
│ └── index.html
└── ...

正如您所看到的,您应用程序的每个页面和路由都是使用index.html文件构建并放置在/dust/文件夹中 - 就像您为通用 SSR Nuxt 应用程序生成的静态站点一样。因此,在这里,我们可以说您将构建和部署的 spa 模式 Nuxt 应用程序是一个“静态”SPA,而不是经典的“动态”SPA。当然,您仍然可以使用以下部署命令将您的 spa 模式 Nuxt 应用程序部署为通用 SSR Nuxt 应用程序。这将使其在运行时变得“动态”:

$ npm run build
$ npm run start

但是在 Node.js 主机上部署 Nuxt SPA 应用可能会过度,因为您选择 spa 模式 Nuxt 应用程序并且不想为您的 SPA 使用 Node.js 主机,必须有一些充分的理由。因此,将 Nuxt SPA预渲染为静态生成的应用程序(让我们称之为静态生成的 Nuxt SPA)可能更合理。您可以像通用 SSR Nuxt 应用程序一样轻松地使用nuxt export命令预渲染您的 Nuxt SPA。

这就是本章的全部内容:在 spa 模式下开发 Nuxt 应用程序,并在部署到静态托管服务器(如 GitHub Pages)之前生成所需的静态 HTML 文件。因此,让我们开始安装和设置环境。

安装 Nuxt SPA

安装 Nuxt SPA 与使用create-nuxt-app脚手架工具安装 Nuxt 通用 SSR 相同。让我们开始吧:

  1. 通过终端使用 Nuxt 脚手架工具安装 Nuxt 项目:
$ npx create-nuxt-app <project-name>
  1. 回答出现的问题,并在要求渲染模式时选择单页面应用选项:
? Project name
? Project description
//...
? Rendering mode:  
  Universal (SSR / SSG)  
> Single Page App 

安装完成后,如果您检查项目根目录中的 Nuxt 配置文件,您应该会看到在安装过程中已经为您配置了mode选项为 SPA:

// nuxt.config.js
export default {
  mode: 'spa'
}
  1. 在您的终端中启动 Nuxt 开发模式:
$ npm run dev

您应该看到您的终端上只有客户端端的代码被编译:

✓ Client
  Compiled successfully in 1.76s

您将不再看到在服务器端编译的代码,这是您通常在universal模式下看到的 Nuxt 应用程序的代码:

✓ Client
  Compiled successfully in 2.75s

✓ Server
  Compiled successfully in 2.56s

如您所见,在 Nuxt 中很容易启动 spa 模式环境。您也可以通过在 Nuxt 配置文件中的mode选项中添加spa值来手动设置 spa 模式。现在,让我们开发一个 Nuxt SPA。

开发 Nuxt SPA

在开发 Nuxt SPA 时需要牢记的一个重要事项是,给予asyncDatafetch方法的 Nuxt 上下文将失去它们的reqres对象,因为这些对象是 Node.js HTTP 对象。在本节中,我们将创建一个简单的用户登录身份验证,您应该已经熟悉。但是,这一次,我们将在 Nuxt SPA 中进行。我们还将创建一个用于使用动态路由列出用户的页面,就像我们在第四章中学到的那样,添加视图、路由和过渡。让我们开始吧:

  1. 准备以下.vue文件,或者只需从上一章中复制,如下所示:
-| pages/
---| index.vue
---| about.vue
---| login.vue
---| secret.vue
---| users/
-----| index.vue
-----| _id.vue
  1. 准备具有存储状态、突变、动作和处理用户登录身份验证的索引文件的 Vuex 存储,如下所示:
-| store/
---| index.js
---| state.js
---| mutations.js
---| actions.js

在前一章中提到,当我们静态生成 Nuxt 通用 SSR 应用程序时,存储中的nuxtServerInit动作将会丢失,因此在 Nuxt SPA 中也是一样的-我们在客户端不会有这个服务器动作。因此,我们需要一个客户端nuxtServerInit动作来模拟服务器端的nuxtServerInit动作。我们接下来将学习如何做到这一点。

创建客户端 nuxtServerInit 动作

这些文件中的方法和属性与我们在过去的练习中所拥有的方法和属性相同,除了nuxtServerInit动作:

// store/index.js
const cookie = process.server ? require('cookie') : undefined

export const actions = {
  nuxtServerInit ({ commit }, { req }) {
    if (
      req 
      && req.headers 
      && req.headers.cookie 
      && req.headers.cookie.indexOf('auth') > -1
    ) {
      let auth = cookie.parse(req.headers.cookie)['auth']
      commit('setAuth', JSON.parse(auth))
    }
  }
}

在 Nuxt SPA 中,没有涉及服务器,因为nuxtServerInit只能由 Nuxt 从服务器端调用。因此,我们需要一个解决方案。我们可以使用 Node.js 的js-cookie模块在用户登录时在客户端存储经过身份验证的数据,这使其成为替代服务器端 cookie 的最佳选择。让我们学习如何实现这一点:

  1. 通过 npm 安装 Node.js 的js-cookie模块:
$ npm i js-cookie
  1. 在存储操作中创建一个名为nuxtClientInit(如果愿意,也可以选择其他名称)的自定义方法,以从 cookie 中检索用户数据。然后,在用户刷新浏览器时将其设置回所需的状态。
// store/index.js
import cookies from 'js-cookie'

export const actions = {
  nuxtClientInit ({ commit }, ctx) {
    let auth = cookies.get('auth')
    if (auth) {
      commit('setAuth', JSON.parse(auth))
    }
  }
}

正如您可能记得的那样,在刷新页面时,商店的nuxtServerInit动作总是在服务器端调用。nuxtClientInit方法也是如此;每次在客户端刷新页面时都应该被调用。然而,它不会被自动调用,因此我们可以使用插件在 Vue 根实例初始化之前每次调用它。

  1. /plugins/目录中创建一个名为nuxt-client-init.js的插件,该插件将通过存储中的dispatch方法调用nuxtClientInit方法:
// plugins/nuxt-client-init.js
export default async (ctx) => {
  await ctx.store.dispatch('nuxtClientInit', ctx)
}

请记住,在 Vue 根实例初始化之前,我们可以在插件中访问 Nuxt 上下文。存储被添加到 Nuxt 上下文中,因此我们可以访问存储操作,而这里我们感兴趣的是nuxtClientInit方法。

  1. 现在,将此插件添加到 Nuxt 配置文件中以安装该插件:
// nuxt.config.js
export default {
  plugins: [
    { src: '~/plugins/nuxt-client-init.js', mode: 'client' }
  ]
}

现在,每次刷新浏览器时,nuxtClientInit方法都将被调用,并且在 Vue 根实例初始化之前,状态将被此方法重新填充。正如您所看到的,当我们失去 Nuxt 作为通用 JavaScript 应用程序的全部功能时,模仿nuxtClientInit动作并不是一件简单的事情。但是,如果您必须选择 Nuxt SPA,那么我们刚刚创建的nuxtClientInit方法可以解决这个问题。

接下来,我们将使用 Nuxt 插件创建一些自定义的 Axios 实例。这应该是您已经非常熟悉的内容。然而,能够创建自定义的 Axios 实例是有用的,因为当需要时,您总是可以回退到原始版本的 Axios,即使我们也有Nuxt Axios 模块。所以,让我们继续!

使用插件创建多个自定义的 Axios 实例

在这个 spa 模式的练习中,我们将需要两个 Axios 实例来对以下地址进行 API 调用:

  • localhost:4000用于用户认证

  • jsonplaceholder.typicode.com用于获取用户

我们将使用原始的 Axios (github.com/axios/axios),因为它给了我们灵活性来创建带有一些自定义配置的多个 Axios 实例。让我们开始吧:

  1. 通过 npm 安装原始的axios
$ npm i axios
  1. 在需要的页面上创建一个axios实例:
// pages/users/index.vue
const instance = axios.create({
  baseURL: '<api-address>',
  timeout: <value>,
  headers: { '<x-custom-header>': '<value>' }
})

但直接在页面上创建axios实例并不理想。理想情况下,我们应该能够提取这个实例并在任何地方重用它。通过 Nuxt 插件,我们可以创建提取的 Axios 实例。我们可以遵循两种方法来创建它们。我们将在下一节中看一下第一种方法。

在 Nuxt 配置文件中安装自定义的 Axios 插件

在之前的章节中,你学会了我们可以使用inject方法创建一个插件,并通过 Nuxt 的config文件安装插件。除了使用inject方法,值得知道的是我们也可以直接将插件注入到 Nuxt 上下文中。让我们看看如何做到这一点:

  1. /plugins/目录下创建一个axios-typicode.js文件,导入原始的axios,并创建实例,如下所示:
// plugins/axios-typicode.js
import axios from 'axios'

const instance = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com'
})

export default (ctx, inject) => {
  ctx.$axiosTypicode = instance
  inject('axiosTypicode', instance)
}

如你所见,在创建了axios实例之后,我们通过 Nuxt 上下文(ctx)注入了插件,使用了inject方法,然后导出它。

  1. 在 Nuxt 配置文件中安装这个插件:
// nuxt.config.js
export default {
  plugins: [
    { src: '~/plugins/axios-typicode.js', mode: 'client' }
  ]
}

你必须将mode选项设置为client,因为我们需要它在客户端。

  1. 你可以在任何你喜欢的地方访问这个插件。在这个例子中,我们想在用户索引页面上使用这个插件来获取用户列表:
// pages/users/index.vue
export default {
  async asyncData({ $axiosTypicode }) {
    let { data } = await $axiosTypicode.get('/users')
    return { users: data }
  }
}

在这个插件中,我们将自定义的axios实例直接注入到 Nuxt 上下文(ctx)中,命名为$axiosTypicode,这样我们可以使用 JavaScript 解构赋值语法直接调用它作为$axiosTypicode。我们还使用inject方法注入了插件,所以我们也可以通过ctx.app来调用这个插件,如下所示:

// pages/users/index.vue
export default {
  async asyncData({ app }) {
    let { data } = await app.$axiosTypicode.get('/users')
    return { users: data }
  }
}

创建自定义的 Axios 插件并不太难,是吗?如果你通过 Nuxt 配置文件安装插件,这意味着它是一个全局的 JavaScript 函数,你可以从任何地方访问它。但如果你不想将它安装为全局插件,你可以跳过在 Nuxt 配置文件中安装它。这就引出了创建 Nuxt 插件的第二种方法。

手动导入自定义的 Axios 插件

创建自定义 Axios 实例的另一种方法根本不涉及 Nuxt 配置。我们可以将自定义实例导出为常规的 JavaScript 函数,然后直接在需要它的页面中导入。让我们看看如何做到这一点:

  1. /plugins/目录中创建一个axios-api.js文件,导入原始的axios,并创建实例,如下所示:
// plugins/axios-api.js
import axios from 'axios'

export default axios.create({
  baseURL: 'http://localhost:4000',
  withCredentials: true
})

如您所见,我们不再使用inject方法;相反,我们直接导出实例。

  1. 现在,我们可以在需要时手动导入它。在这个例子中,我们需要在login动作方法中使用它,如下所示:
// store/actions.js
import axios from '~/plugins/axios-api'

async login({ commit }, { username, password }) {
  const { data } = await axios.post('/public/users/login', { 
   username, password })
  //...
}

如您所见,我们必须手动导入此插件,因为它没有插入到 Nuxt 生命周期中。

  1. 导入它并在token中间件中设置Authorization头部,如下所示:
// middleware/token.js
import axios from '~/plugins/axios-api'

export default async ({ store, error }) => {
  //...
  axios.defaults.headers.common['Authorization'] = Bearer: 
  ${store.state.auth.token}
}

尽管在遵循这种方法时我们必须手动导入插件,但至少我们已经将以下设置提取到了一个可以在需要时重用的插件中:

{
  baseURL: 'http://localhost:4000',
  withCredentials: true
}

您可以在本书的 GitHub 存储库的/chapter-15/frontend/中找到 Nuxt SPA 的代码以及这两种方法。

一旦您创建、测试和 lint 所有代码和文件,您就可以准备部署 Nuxt SPA 了。所以,让我们开始吧!

部署 Nuxt SPA

如果我们有一个 Node.js 运行时服务器,我们可以像部署通用 SSR Nuxt 应用程序一样部署 Nuxt SPA。如果没有,那么我们只能将 SPA 部署为静态站点到静态托管服务器,比如 GitHub Pages。您可以按照以下步骤部署静态生成的 Nuxt SPA:

  1. 确保在 Nuxt 配置文件的mode选项中将值设置为spa
// nuxt.config.js
export default {
  mode: 'spa'
}
  1. 确保package.json文件中有以下运行脚本:
{
  "scripts": {
    "generate": "nuxt generate"
  }
}
  1. 运行npm run generate,就像您为通用 SSR Nuxt 应用程序一样。您应该在终端中看到以下输出:
ℹ Generating output directory: dist/
ℹ Generating pages 
✓ Generated /about
✓ Generated /login
✓ Generated /secret
✓ Generated /users
✓ Generated /

在上述输出中,如果您导航到项目内的/dist/文件夹,您将在根目录找到一个index.html文件,以及在每个子文件夹中找到带有路由名称的index.html文件。但是,您将在生成的动态路由中找不到任何页面,比如/users/1。这是因为与通用模式相反,在 spa 模式下不会生成动态路由。

此外,如果您在/dist/文件夹中打开index.html文件,您会发现所有的index.html文件都是完全相同的-只是一些“空”的 HTML 元素,类似于经典的 SPA。此外,每个index.html文件都不包含自己的元信息,只包含来自nuxt.config.js的公共元信息。这些页面的元信息将在运行时进行填充和更新。由于这个原因,对于“静态”SPA 来说,这可能看起来有些违反直觉和“半成品”。除此之外,没有生成静态有效负载。这意味着,如果您在浏览器中导航到localhost:3000/users,您会注意到该页面仍然从jsonplaceholder.typicode.com/users请求其数据,而不是像通用 SSR Nuxt 应用程序那样从有效负载中获取数据。这是因为 Nuxt 在 spa 模式下不生成静态内容,即使您已经在 Nuxt 配置文件中为目标属性设置了static。为了解决这些问题,我们可以从通用模式生成我们需要的静态内容。

  1. 在 Nuxt 配置文件中将mode选项的spa更改为universal
// nuxt.config.js
export default {
  mode: 'universal'
}
  1. 运行npm run generate,这样 Nuxt 将对 API 进行 REST API 调用,以检索用户并将其内容导出到本地静态有效负载。您将看到以下输出:
ℹ Generating output directory: dist/
ℹ Generating pages with full static mode 
✓ Generated /about
✓ Generated /secret
✓ Generated /login
✓ Generated /users
✓ Generated /users/1
✓ Generated /users/2
...
...
✓ Generated /users/10
✓ Generated /

请注意,前面的输出中没有生成动态路由。如果您再次导航到/dist/文件夹,您会看到/users/文件夹现在包含多个文件夹,每个文件夹都有自己的用户 ID。每个文件夹都包含一个包含该特定用户内容的index.html文件。现在,每个index.html文件都包含自己的独立元信息和在/dist/_nuxt/static/中生成的有效负载。

  1. 在 Nuxt 配置文件中将mode选项的universal改回spa
// nuxt.config.js
export default {
  mode: 'spa'
}
  1. 现在,在终端上运行npm run build。您应该会看到以下输出:
Hash: c36ee9714ee9427ac1ff 
Version: webpack 4.43.0 
Time: 5540ms 
Built at: 11/07/2020 07:58:09 
                         Asset       Size  Chunks                         Chunk Names 
../server/client.manifest.json   9.31 KiB          [emitted]               
                      LICENSES  617 bytes          [emitted]               
                app.922dbd1.js     57 KiB       0  [emitted] 
                [immutable] app 
        commons/app.7236c86.js    182 KiB       1  [emitted] 
        [immutable] commons/app 
        pages/about.75fcd06.js  667 bytes       2  [emitted] 
        [immutable] pages/about 
        pages/index.76b5c20.js  784 bytes       3  [emitted] 
        [immutable] pages/index 
        pages/login.09e509e.js   3.14 KiB       4  [emitted]
        [immutable] pages/login 
      pages/secured.f086299.js   1.36 KiB       5  [emitted] 
       [immutable] pages/secured 
    pages/users/_id.e1c568c.js   1.69 KiB       6  [emitted] 
      [immutable] pages/users/_id 
  pages/users/index.b3e7aa8.js    1.5 KiB       7  [emitted]
    [immutable] pages/users/index 
            runtime.266b4bf.js   2.47 KiB       8  [emitted] 
            [immutable] runtime 
+ 1 hidden asset 
Entrypoint app = runtime.266b4bf.js commons/app.7236c86.js app.922dbd1.js 
ℹ Ready to run nuxt generate 
  1. 忽略“准备运行 nuxt generate”消息。相反,首先使用终端上的nuxt start命令从/dist/目录中测试您的生产静态 SPA:
$ npm run start

您应该会得到以下输出:

Nuxt.js @ v2.14.0

> Environment: production
> Rendering: client-side
> Target: static
Listening: http://localhost:3000/

ℹ Serving static application from dist/ 

现在,诸如localhost:3000/users之类的路由将不再从jsonplaceholder.typicode.com请求其数据。相反,它们将从/dist/文件夹中的有效负载中获取数据,该文件夹位于/static/文件夹内。

  1. 最后,只需将/dist/目录部署到您的静态托管服务器。

如果您正在寻找免费的静态托管服务器,请考虑使用 GitHub Pages。使用此功能,您可以为您的站点获得以下格式的域名:

<username>.github.io/<app-name>

GitHub 还允许您使用自定义域名而不是使用他们的域名来提供站点。有关更多信息,请参阅 GitHub 帮助网站的指南:help.github.com/en/github/working-with-github-pages/configuring-a-custom-domain-for-your-github-pages-site。但是,在本书中,我们将向您展示如何在 GitHub 的github.io域名上提供站点。我们将在下一节中学习如何做到这一点。

您可以在本书的 GitHub 存储库中的/chapter-15/frontend/中找到此部分的代码。

部署到 GitHub Pages

GitHub Pages 是 GitHub 提供的静态站点托管服务,用于托管和发布 GitHub 存储库中的静态文件(仅限 HTML、CSS 和 JavaScript)。只要您在 GitHub 上拥有用户帐户并为您的站点创建了 GitHub 存储库,就可以在 GitHub Pages 上托管您的静态站点。

请访问guides.github.com/features/pages/,了解如何开始使用 GitHub Pages。

您只需要转到 GitHub 存储库的设置部分,然后向下滚动到GitHub Pages部分。然后,您需要单击选择主题按钮,以开始创建静态站点的过程。

将 Nuxt SPA 的静态版本部署到 GitHub Pages 非常简单-您只需要对 Nuxt 配置文件进行一些微小的配置更改,然后使用git push命令将其上传到 GitHub 存储库。当您创建 GitHub 存储库并创建 GitHub Pages 时,默认情况下,静态页面的 URL 将以以下格式提供:

<username>.github.io/<repository-name>

因此,您需要将此<repository-name>添加到 Nuxt 配置文件中router基本选项,如下所示:

export default {
  router: {
    base: '/<repository-name>/'
  }
}

但是更改基本名称将干扰 Nuxt 应用程序的开发时的localhost:3000。让我们学习如何解决这个问题:

  1. 在 Nuxt 配置文件中为开发和生产 GitHub Pages 创建一个if条件,如下所示:
// nuxt.config.js
const routerBase = process.env.DEPLOY_ENV === 'GH_PAGES' ? {
  router: {
    base: '/<repository-name>/'
  }
} : {}

如果在进程环境中DEPLOY_ENV选项具有GH_PAGES,则此条件只是将/<repository-name>/添加到router选项的base键。

  1. 使用spread操作符在配置文件中的 Nuxt 配置中添加routerBase常量:
// nuxt.config.js
export default {
  ...routerBase
}
  1. package.json文件中设置DEPLOY_ENV='GH_PAGES'脚本:
// package.json
"scripts": {
  "build:gh-pages": "DEPLOY_ENV=GH_PAGES nuxt build",   
  "generate:gh-pages": "DEPLOY_ENV=GH_PAGES nuxt generate"
}

使用这两个 npm 脚本中的一个,/<repository-name>/的值不会被注入到你的 Nuxt 配置中,并且在运行npm run dev进行开发时不会干扰开发过程。

  1. 在 Nuxt 配置文件中,将mode选项更改为universal,就像在上一节的步骤 4中一样,使用nuxt generate命令生成静态负载和页面:
$ npm run generate:gh-pages
  1. 将 Nuxt 配置文件中的mode选项从universal改回spa,就像在上一节的步骤 6中一样,使用nuxt build命令构建 SPA:
$ npm run build:gh-pages
  1. 通过你的 GitHub 仓库将 Nuxt 生成的/dist/文件夹中的文件推送到 GitHub Pages。

部署 Nuxt SPA 到 GitHub Pages 就是这样。但是,在将静态站点推送到 GitHub Pages 时,请确保在/dist/文件夹中包含一个empty .nojekyll文件。

Jekyll 是一个简单的、博客感知的静态站点生成器。它将纯文本转换为静态网站和博客。GitHub Pages 在幕后由 Jekyll 提供支持,默认情况下不会构建任何以点“.”、下划线“_”开头或以波浪符“~”结尾的文件或目录。这在为 GitHub Pages 提供静态站点时会成为问题,因为在构建 Nuxt SPA 时,/_nuxt/文件夹也会在/dist/文件夹内生成;Jekyll 会忽略这个/_nuxt/文件夹。为了解决这个问题,我们需要在/dist/文件夹中包含一个空的.nojekyll文件来关闭 Jekyll。当我们为 Nuxt SPA 构建静态页面时,会生成这个文件,所以确保将它推送到你的 GitHub 仓库中。

干得好 - 你已经完成了本书的另一短章节!如果你想在 Nuxt 中构建 SPA 而不是使用 Vue 或其他框架(如 Angular 和 React),Nuxt SPA 是一个很好的选择。但是,如果你提供需要立即或实时发布的社交媒体等网络服务,静态生成的 Nuxt SPA 可能不是一个好选择。这完全取决于你的业务性质,以及你是想要充分利用 Nuxt 的全能 SSR,还是只想使用 Nuxt 的客户端版本 - Nuxt SPA。接下来,我们将总结本章学到的内容。

总结

在本章中,我们学习了如何在 Nuxt 中开发、构建和部署 SPA,并了解了它与经典 SPA 的区别。我们还了解到,Nuxt SPA 可以是开发应用程序的一个很好选择,但是开发 Nuxt SPA 意味着我们将失去nuxtServerInit动作和reqres HTTP 对象。然而,我们可以使用客户端的js-cookies(或localStorage)和 Nuxt 插件来模拟nuxtServerInit动作。最后但并非最不重要的是,我们学习了如何在 GitHub Pages 上发布和提供静态生成的 Nuxt SPA。

到目前为止,在本书中,我们一直在为所有 Nuxt 应用程序和 API 使用 JavaScript。然而,在接下来的章节中,我们将探讨如何进一步使用 Nuxt,以便我们可以使用另一种语言PHP。我们将带领您了解 HTTP 消息和 PHP 标准,使用 PHP 数据库框架编写 CRUD 操作,并为 Nuxt 应用程序提供 PHP API。敬请期待!

为 Nuxt 创建一个与框架无关的 PHP API

在之前的章节中,比如第八章《添加服务器端框架》和第九章《添加服务器端数据库》,你学习了如何使用 Nuxt 的默认服务器与 Node.js JavaScript 框架(如 Koa 和 Express)创建 API。在第十二章《创建用户登录和 API 身份验证》中,你学习了如何使用相同的 Node.js JavaScript 框架 Koa 在外部服务器上创建 API。

在这一章中,我们将指导你如何使用 PHP(超文本预处理器)在外部服务器上创建 API。在第九章《添加服务器端数据库》中,你还学习了如何使用 MongoDB 来管理数据库。然而,在这一章中,我们将使用 MySQL,而你在第十二章《创建用户登录和 API 身份验证》中使用了 Koa。

在这一章中,最重要的是,你将学习关于 PHP 标准和 PHP 标准建议(PSRs)的所有知识。特别是,你将学习关于 PSR-4 用于自动加载,PSR-7 用于 HTTP 消息,以及 PSR-15 用于组合中间件组件和处理 HTTP 服务器请求。我们将整合来自不同供应商(如 Zend Framework 和 The PHP League)基于这些 PSR 标准的包,为我们的 Nuxt 应用创建一个与框架无关的 PHP RESTful API。

在这一章中,我们将涵盖以下主题:

  • 介绍 PHP

  • 理解 HTTP 消息和 PHP 标准

  • 使用 PHP 数据库框架编写 CRUD 操作

  • 与 Nuxt 集成

让我们开始吧!

第十六章:介绍 PHP

PHP 已经走过了很长的路。它早在 Node.js 之前就存在了,由 Rasmus Lerdorf 于 1994 年创建。最初它代表的是“个人主页”。PHP 的参考实现现在由 PHP 组织(https://www.php.net/)生产。PHP 最初是作为一个模板语言开发的,允许我们将 HTML 与 PHP 代码本身混合在一起,就像 Twig(https://twig.symfony.com/)和 Pug(https://pugjs.org/)现在所做的那样。

现在,PHP 不仅仅是一个模板语言。多年来,它已经发展成为一种通用脚本语言和面向对象语言,特别适用于服务器端 Web 开发。您仍然可以用它来制作模板,但在现代 PHP 开发中,我们应该充分利用它的全部功能。如果您想了解 PHP 还能做什么,请访问www.php.net/manual/en/intro-whatcando.php

在撰写本书时,PHP 的当前稳定版本是 7.4.x。如果您刚开始使用 PHP,请从 PHP 7.4 开始。如果您正在使用 PHP 7.2 或 7.3,您应该考虑将其升级到 PHP 7.4,因为它包含了几个错误修复。有关此版本更改的更多信息,请访问www.php.net/ChangeLog-7.php

在本书中,我们将指导您如何在支持 Apache2 的 Ubuntu 上安装或升级到 PHP 7.4。让我们开始吧!

安装或升级 PHP

如果您使用的是 macOS,请使用此指南:phptherightway.com/mac_setup。如果您使用的是 Windows,请使用此指南:phptherightway.com/windows_setup

我们正在使用 Apache2 HTTP 服务器,但如果您的计算机上已安装了 Nginx HTTP 服务器,也可以使用它。现在,按照以下简单步骤安装 PHP:

  1. 运行以下命令更新 Ubuntu 服务器上的本地软件包并安装 Apache2:
$ sudo apt update
$ sudo apt install apache2
  1. 安装 Apache2 后,使用-v选项进行验证:
$ apache2 -v
Server version: Apache/2.4.41 (Ubuntu)
Server built: 2019-08-14T14:36:32

您可以使用以下命令停止、启动和启用 Apache2 服务,以便在服务器启动时始终启动:

$ sudo systemctl stop apache2
$ sudo systemctl start apache2
$ sudo systemctl enable apache2

您可以使用以下命令检查 Apache2 的状态:

$ sudo systemctl status apache2

您应该始终在终端中获得active (running)的输出:

apache2.service - The Apache HTTP Server
 Loaded: loaded (/lib/systemd/system/apache2.service; enabled; vendor preset: enabled)
 Active: active (running) since Thu 2020-08-06 13:17:25 CEST; 52min ago
 //...
  1. 运行以下命令安装 PHP 7.4:
$ sudo apt update
$ sudo apt install php
  1. 您还应该安装与 PHP 7.4 相关的可能在开发 PHP 应用程序时需要的模块和扩展:
$ sudo apt install -y php7.4-{bcmath,bz2,curl,gd,intl,json,mbstring,xml,zip,mysql}
  1. 禁用 PHP 7.3(如果您使用的是 PHP 7.3),然后启用 PHP 7.4:
$ sudo a2dismod php7.3
$ sudo a2enmod php7.4

如果您是第一次安装 PHP,则无需禁用旧版本。如果您想卸载 PHP 及其所有相关模块,可以使用以下命令:

$ sudo apt-get purge 'php*'
  1. 重新启动 Apache2 和 PHP 服务:
$ sudo service apache2 restart
  1. 现在,您可以使用以下命令验证刚刚安装的 PHP:
$ php -v

您应该获得以下版本信息:

PHP 7.4.8 (cli) (built: Jul 13 2020 16:46:22) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
 with Zend OPcache v7.4.8, Copyright (c), by Zend Technologies

现在您已经安装了 Apache2 和 PHP 7.4,接下来应该做的是配置 PHP。我们将在下一节中进行。

配置 PHP 7.4

现在 Apache2 和 PHP 已安装,您可能希望配置 PHP,以便根据您的 PHP 应用程序的需要使用它。默认的 PHP 配置文件位于/etc/php/7.4/apache2/php.ini,因此请按照以下步骤配置您的 PHP 7.4 版本:

  1. 运行以下命令以编辑或配置 PHP 7.4:
$ sudo nano /etc/php/7.4/apache2/php.ini

您可能需要更改已上传文件的upload_max_filesize的默认允许量:

upload_max_filesize = 2M

您可以在php.net/upload-max-filesize找到有关此配置的更多信息。

对于 PHP 应用程序来说,上传文件的最大限制为 2 MB 可能被认为是很小的。因此,请根据您的需求进行更改,如下所示:

upload_max_filesize = 32M

以下是一些其他重要的行/PHP 指令需要考虑:

post_max_size = 48M
memory_limit = 256M
max_execution_time = 600

您可以在www.php.net/manual/en/ini.core.php找到有关上述 PHP 指令和其他配置 PHP 的指令的更多信息。

  1. 重启 Apache 以使上述修改的 PHP 设置生效:
$ sudo service apache2 restart

PHP 7.4 非常强大。如果您不想在本地开发机器上安装 Apache,您可以只安装它并用于开发站点。在下一节中,您将学习如何在没有 Apache 服务器的情况下使用 PHP 7.4。

使用内置的 PHP Web 服务器运行 PHP 应用程序

自 PHP 5.4 以来,您可以使用内置的 PHP Web 服务器运行 PHP 脚本和应用程序,而无需像 Apache 或 Nginx 这样的常见 Web 服务器。只要安装了 PHP 7.4,您就可以跳过上述 Apache 安装。要启动 PHP 服务器,只需从项目的根目录打开终端并运行以下命令:

$ php -S 0.0.0.0:8181

如果您想从特定的文档根目录开始应用程序,例如从名为public的项目目录中的www目录开始,请执行以下操作:

$ cd ~/www
$ php -S localhost:8181 -t public

让我们创建一个经典的“Hello World”示例,这个内置的 PHP Web 服务器将提供,以查看是否一切设置正确:

  1. 创建一个简单的 PHP 文件中的“Hello World”消息页面,如下所示:
// public/index.php
<?php
echo 'Hello world!';
  1. 转到您的项目目录,并使用上述命令启动内置的 PHP Web 服务器。终端应显示以下信息:
[Sun Mar 22 09:12:37 2020] PHP 7.4.4 Development Server (http://localhost:8181) started
  1. 现在,在浏览器上加载localhost:8181。您应该在屏幕上看到 Hello world!,没有任何错误。

如果您想了解这个内置的 Web 服务器,请访问www.php.net/features.commandline.webserver

接下来,您将学习如何使用一些 PHP 标准。您还将了解 HTTP 消息是什么,以及为什么我们需要为现代 PHP 应用程序使用 PSR。

理解 HTTP 消息和 PSR

超文本传输协议HTTP)是客户端计算机和 Web 服务器之间的通信协议。诸如 Chrome、Safari 或 Firefox 之类的网络浏览器可以是 Web 客户端或用户代理,而计算机上监听某个端口的 Web 应用程序可以是 Web 服务器。Web 客户端不仅仅是浏览器,还包括任何可以与 Web 服务器通信的应用程序,比如 cURL 或 Telnet。

客户端通过互联网打开连接,向服务器发出请求,并等待直到收到服务器的响应。请求包含请求信息,而响应包含状态信息和请求的内容。这两种交换的数据称为 HTTP 消息。它们只是用 ASCII 编码的文本体,并且跨越多行,具有以下结构:

Start-line
HTTP Headers

Body

这看起来非常简单和直接,不是吗?尽管可能是这样,让我们详细说明一下这个结构:

  • Start-line描述了实现的请求方法(例如GETPUTPOST)、请求目标(通常是 URI)和响应的 HTTP 版本或状态(例如 200、404 或 500)以及 HTTP 版本。Start-line始终是单行。

  • HTTP Headers行描述了请求或响应的特定细节(元信息),例如HostUser-AgentServerContent-type等。

  • 空白行表示请求的所有元信息已经发送。

  • Body(或消息体)包含请求的交换数据(例如 HTML 表单的内容)或响应的内容(例如 HTML 文档的内容)。消息体是可选的(有时,在请求中不需要它来请求服务器的数据)。

现在,让我们使用 cURL 来看看 HTTP 请求和响应的数据是如何交换的:

  1. 使用内置的 PHP Web 服务器在localhost:8181上提供您在上一节中学到的 PHP“Hello World”应用程序:
$ php -S localhost:8181 -t public
  1. 在您的终端上打开一个新标签,并运行以下 cURL 脚本:
$ curl http://0.0.0.0:8181 \
 --trace-ascii \
 /dev/stdout

您应该看到请求消息显示在第一部分中,如下所示:

== Info: Trying 0.0.0.0:8181...
== Info: TCP_NODELAY set
== Info: Connected to 0.0.0.0 (127.0.0.1) port 8181 (0)
=> Send header, 76 bytes (0x4c)
0000: GET / HTTP/1.1
0010: Host: 0.0.0.0:8181
0024: User-Agent: curl/7.65.3
003d: Accept: /
004a:

在这里,您可以看到空行表示为004a:,请求中根本没有消息正文。响应消息显示在第二部分中,如下所示:

== Info: Mark bundle as not supporting multiuse
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 20 bytes (0x14)
0000: Host: 0.0.0.0:8181
<= Recv header, 37 bytes (0x25)
0000: Date: Sat, 21 Mar 2020 20:33:09 GMT
<= Recv header, 19 bytes (0x13)
0000: Connection: close
<= Recv header, 25 bytes (0x19)
0000: X-Powered-By: PHP/7.4.4
<= Recv header, 40 bytes (0x28)
0000: Content-type: text/html; charset=UTF-8
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 12 bytes (0xc)
0000: Hello world!
== Info: Closing connection 0

在响应的起始行中,您可以看到状态是200 OK。但在前面的示例中,我们没有发送任何数据,因此请求消息中没有消息正文。让我们创建另一个非常基本的 PHP 脚本,如下所示:

  1. 创建一个带有 PHP print函数的 PHP 页面,以便显示POST数据,如下所示:
// public/index.php
<?php
print_r($_POST);
  1. 使用内置的 PHP Web 服务器在localhost:8181上提供页面:
$ php -S localhost:8181 -t public
  1. 在终端上使用 cURL 发送一些数据:
$ curl http://0.0.0.0:8181 \
 -d "param1=value1&param2=value2" \
 --trace-ascii \
 /dev/stdout

这次,请求消息将显示在第一部分中,以及消息正文:

== Info: Trying 0.0.0.0:8181...
== Info: TCP_NODELAY set
== Info: Connected to 0.0.0.0 (127.0.0.1) port 8181 (0)
=> Send header, 146 bytes (0x92)
0000: POST / HTTP/1.1
0011: Host: 0.0.0.0:8181
0025: User-Agent: curl/7.65.3
003e: Accept: /
004b: Content-Length: 27
005f: Content-Type: application/x-www-form-urlencoded
0090:
=> Send data, 27 bytes (0x1b)
0000: param1=value1&param2=value2
== Info: upload completely sent off: 27 out of 27 bytes

响应消息显示在第二部分中,如下所示:

== Info: Mark bundle as not supporting multiuse
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 20 bytes (0x14)
0000: Host: 0.0.0.0:8181
<= Recv header, 37 bytes (0x25)
0000: Date: Sat, 21 Mar 2020 20:43:06 GMT
<= Recv header, 19 bytes (0x13)
0000: Connection: close
<= Recv header, 25 bytes (0x19)
0000: X-Powered-By: PHP/7.4.4
<= Recv header, 40 bytes (0x28)
0000: Content-type: text/html; charset=UTF-8
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 56 bytes (0x38)
0000: Array.(. [param1] => value1\. [param2] => value2.).
Array
(
    [param1] => value1
    [param2] => value2
)
== Info: Closing connection 0
  1. 在这里,您还可以在终端上使用 cURL 查看PUT方法的请求消息和请求消息:
$ curl -X PUT http://0.0.0.0:8181 \
 -d "param1=value1&param2=value2" \
 --trace-ascii \
 /dev/stdout
  1. 对于 cURL 上的DELETE方法也是如此,如下所示:
$ curl -X DELETE http://0.0.0.0:8181 \
 -d "param1=value1&param2=value2" \
 --trace-ascii \
 /dev/stdout
  1. 最后但同样重要的是,我们还可以使用 Google Chrome 中的开发者工具来帮助我们检查交换的数据。让我们创建另一个简单的 PHP 脚本,它将从 URI 接收数据:
// public/index.php
<?php
print_r($_GET);
  1. 通过使用0.0.0.0:8181/?param1=value1&param2=value2在浏览器上发送一些数据。通过这样做,数据将作为param1=value1&param2=value2发送,如下截图所示:

如果您想了解更多关于 HTTP 和 HTTP 消息的信息,请访问developer.mozilla.org/en-US/docs/Web/HTTP了解 HTTP 的一般信息,以及developer.mozilla.org/en-US/docs/Web/HTTP/Messages了解特定的 HTTP 消息。

在涉及服务器端开发时,HTTP 消息最好封装在对象中,以便更容易处理。例如,Node.js 具有内置的 HTTP 模块(nodejs.dev/the-nodejs-http-module)用于 HTTP 通信,在其中,您可以从http.createServer()方法的回调中获取 HTTP 消息对象,用于创建 HTTP 服务器:

const http = require('http')

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/plain'})
  response.end('Hello World')
}).listen(8080)

如果您正在使用 Node.js 框架,例如 Koa,您可以在ctx中找到 HTTP 消息对象,如下所示:

const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  ctx
  ctx.request
  ctx.response
})

在前面的代码中,ctx是 Koa 上下文,而ctx.request是 HTTP 请求消息,ctx.response是 HTTP 响应消息。在 Express 中也是一样的;您可以按如下方式找到 HTTP 消息:

const express = require('express')
const app = express()

app.get('/', (req, res) => res.send('Hello World!'))

与 Node.js 不同,PHP 从来没有内置的 HTTP 消息对象。有很多方法可以手动和直接获取和设置 Web 数据,就像我们在之前的 PHP 示例中看到的那样,通过使用超全局变量($_GET$_POST)和内置函数(echoprint_r)。如果您想捕获传入的请求,您可以根据情况使用$_GET$_POST$_FILE$_COOKIE$_SESSION或其他超全局变量(www.php.net/manual/en/language.variables.superglobals.php)。

返回响应也是一样的:您可以使用全局函数,如echoprintheader手动设置响应头。过去,PHP 开发人员和框架有他们自己实现 HTTP 消息的方式。这导致了不同框架有不同的抽象来表示HTTP 消息的时代,任何基于特定实现的 HTTP 消息的应用程序几乎无法在项目中与其他框架一起使用。这种行业标准的缺乏使得框架的组件紧密耦合。如果您没有从框架开始,最终您会自己构建一个框架。

但是今天,PHP 社区已经学习并执行了 PHP 标准和建议。您不必完全遵守这些标准和建议;如果您有哲学原因要求您这样做,您可以忽略它们。但它们是一个良好意图的衡量标准,至少在商业和合作方面结束 PHP 战争。最重要的是,PHP 开发人员可以专注于 PHP 标准,而不是以框架无关的方式。当我们谈论 PHP 标准时,我们倾向于指的是 PSR,这是由 PHP 框架互操作性组(PHP-FIG)定义和发布的 PHP 规范。PSR-7:HTTP 消息接口是 PHP-FIG 成员提出的规范之一,并根据他们同意的既定协议进行了投票。

PSR-7 于 2015 年 5 月正式被接受。它基本上用于标准化 HTTP 消息接口。在深入研究 PSR-7 之前,我们还应该了解一些其他 PSR 编号,特别是 PSR-12(取代 PSR-2),PSR-4 和 PSR-15。我们将在本书中为您介绍它们,以便您可以编写可重用的,与框架无关的应用程序和组件,这些应用程序和组件可以独立使用,也可以与其他框架进行互操作,无论它们是全栈还是微框架。让我们开始吧!

为什么要使用 PSR?

在内部,PHP 从不告诉开发人员他们应该如何编写他们的 PHP 代码。例如,Python 使用缩进来指示一块代码,而对于其他编程语言,如 PHP 和 JavaScript,代码中的缩进是为了可读性。以下是 Python 将接受的示例:

age = 20
if age == 20:
  print("age is 20")

如果没有缩进,Python 将返回错误:

if age == 20:
print("age is 20")

空格的数量取决于编码者的偏好,但您必须至少使用一个空格,并且在同一块中的其他行中使用相同数量的空格;否则,Python 将返回错误:

if age == 20:
 print("age is 20")
  print("age is 20")

另一方面,在 PHP 中,您可以编写以下内容:

if (age == 20) {
print("age is 20");
}

PHP 中也可以使用以下内容:

if (age == 20) {
 print("age is 20");
  print("age is 20");
}

Python 在内部强制执行代码的可读性和整洁性。PHP 没有。您可以想象,如果没有一些基本的强制措施,并且根据编码者的经验,PHP 代码可能会变得非常混乱,丑陋和难以阅读。也许 PHP Web 开发的低门槛在其中起了作用。因此,您的 PHP 代码必须遵循通用的代码风格,以便于协作和维护。

有一些特定框架的 PHP 编码标准,但它们基本上是基于(或类似于)PSR 标准的:

从实用的角度来看,您的代码应该遵循您所依赖的框架,以及特定的框架。但是,如果您只是从框架中使用一些组件或库,那么您可以遵守任何组合的 PSR,或者由 PEAR 制定的编码标准。PEAR 编码标准可以在pear.php.net/manual/en/standards.php找到。

本书侧重于各种 PSR,因为本章旨在创建与框架无关的 PHP 应用程序。您不必同意 PSR,但如果您正在寻找一个标准来开始项目,并且在您的组织内没有自己的标准,那么这可能是一个很好的开始。您可以在www.php-fig.org/psr/找到更多关于 PSR 的信息。

除了我们在这里提到的内容之外,您还应该查看phptherightway.com/PHP: The Right Way。它概述了现代 PHP 编码人员可以用作参考的事项,从设置 PHP,使用Composer进行依赖管理(我们将在本章后面介绍),编码风格指南(其中推荐使用 PSR),依赖注入,数据库,模板化,测试框架等等。对于想要避免过去错误并在网络上找到权威 PHP 教程链接的新 PHP 编码人员来说,这是一个很好的开始。对于需要快速参考和来自 PHP 社区的更新的经验丰富的 PHP 编码人员来说,这也是一个很好的地方,或者是他们在过去几年中可能错过的任何内容。

现在,让我们开始研究 PSR,从PSR-12开始。

PSR-12 - 扩展编码风格指南

PSR-12 是 PSR-2 的修订编码风格指南,考虑了 PHP 7。PSR-12 规范于 2019 年 8 月 9 日获得批准。自 2012 年接受 PSR-2 以来,PHP 已经进行了许多更改,对编码风格指南产生了一些影响,其中最显着的是返回类型声明,这是在 PHP 7 中引入的,而在 PSR-2 中没有描述。因此,应该定义一个标准来使用它们,以便它们可以被更广泛的 PHP 社区采用,然后再由个别的 PHP 编码人员实施他们的标准,这可能最终会发生冲突。

例如,PHP 7 中添加的返回类型声明简单地指定了函数应该返回的值的类型。让我们看一下以下采用返回类型声明的函数:

declare(strict_types = 1);

function returnInt(int $value): int
{
    return $value;
}

print(returnInt(2));

您将得到2作为整数的正确结果。但是,让我们看看如果您改变returnInt函数内的代码会发生什么,如下所示:

function returnInt(int $value): int
{
    return $value + 1.0;
}

PHP 将放弃以下错误:

PHP Fatal error: Uncaught TypeError: Return value of returnInt() must be of the type int, float returned in ...

因此,为了满足 PHP 7 的这一新功能的需求,PSR-12 要求您在冒号后使用一个空格,后面是带有返回类型声明的方法的类型声明。此外,冒号和声明必须与参数列表的右括号在同一行,两个字符之间没有空格。让我们看一个简单的例子,其中有一个return类型声明:

class Fruit
{
    public function setName(int $arg1, $arg2): string
    {
        return 'kiwi';
    }
}

在 PSR-2 和 PSR-12 中有一些规则保持不变。例如,在这两个 PSR 中,您不能使用制表符进行缩进,而是使用四个单个空格。但是在 PSR-2 中关于块列表的规则已经修订。现在,在 PSR-12 中,使用语句导入类、函数和常量的块必须用单个空行分隔,即使只有一个导入它们的地方。让我们快速看一下符合此规则的一些代码:

<?php

/**
 * The block of comments...
 */

declare(strict_types=1);

namespace VendorName\PackageName;

use VendorName\PackageName\{ClassX as X, ClassY, ClassZ as Z};
use VendorName\PackageName\SomeNamespace\ClassW as W;

use function VendorName\PackageName\{functionX, functionY, functionZ};

use const VendorName\PackageName\{ConstantX, ConstantY, ConstantZ};

/**
 * The block of comments...
 */
class Fruit
{
    //...
}

现在,您应该注意,在 PSR-12 中,您必须在开头的<?php标记后使用一个空行。但是,在 PSR-2 中,这是不必要的。例如,您可以编写以下内容:

<?php
namespace VendorName\PackageName;

use FruitClass;
use VegetableClass as Veg;

值得知道,PSR-2 是从 PSR-1 扩展而来的,它是一个基本的编码标准,但自从 PSR-12 被接受以来,PSR-2 现在已正式弃用。

要为您的代码实施这些 PSR,请访问以下网站:

如果您想了解 PHP 7 的新功能,例如标量类型声明和返回类型声明,请访问www.php.net/manual/en/migration70.new-features.php

PSR-12 帮助 PHP 编码人员编写更易读和结构化的代码,因此在使用 PHP 编写代码时值得采用它。现在,让我们继续讨论PSR-4,它允许我们在 PHP 中使用自动加载。

PSR-4 – 自动加载器

在 PHP 的旧日子里,如果您想将第三方库引入您的 PHP 项目,或者从单独的 PHP 文件中引入您的函数和类,您将使用includerequire语句。随着 PHP 自动加载的到来,您将使用__autoload魔术方法(自 PHP 7.2 起已弃用)或spl_autoload来自动调用您的代码。然后在 PHP 5.3 中出现了真正的命名空间支持,开发人员和框架可以设计他们的方法来防止命名冲突。但仍然远非理想,因为不同方法之间存在冲突。您可以想象一种情况,您有两个框架 - 框架 A 和框架 B - 以及个别开发人员彼此不同意并实施自己的方法来实现相同的结果。这是疯狂的。

今天,我们遵守 PSR-4(它是 PSR-0 的后继者)来标准化自动加载的方法,并将开发人员和框架绑定在一起。它指定了从文件路径自动加载类的标准。它还描述了文件的位置。因此,一个完全限定的类名应该遵循以下形式:

\<NamespaceName>(\<SubNamespaceNames>)\<ClassName>

在这个规则中,我们有以下内容:

  • 完全限定类的命名空间必须具有顶级供应商命名空间,这是上述代码中的<NamespaceName>部分。

  • 在上面的代码中,您可以使用一个或多个子命名空间,如<SubNamespaceNames>部分所示。

  • 然后,您必须使用您的类名结束命名空间,如上述代码中的<ClassName>部分所示。

因此,如果您正在编写自动加载程序,建议使用此标准。但是,您不必(也可能不应该)费力地编写自己的自动加载程序来符合 PSR-4。这是因为您可以使用Composer来帮助您做到这一点。Composer 是 PHP 的包管理器。它类似于 Node.js 中的 npm。它最初是在 2012 年发布的。从那时起,它已被所有现代 PHP 框架和 PHP 编码人员使用。这意味着您可以更多地专注于您的代码开发,而不必过多担心要引入项目环境中的不同包和库的互操作性。

在开始之前,请确保您的系统上已安装 Composer。根据您的系统,您可以按照以下指南安装 Composer:

当前版本为 1.10.9。按照以下步骤安装 Composer 并利用其提供的自动加载程序:

  1. 通过在终端中运行以下脚本在当前目录中安装 Composer:
$ php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
$ php -r "if (hash_file('sha384', 'composer-setup.php') === 'e5325b19b381bfd88ce90a5ddb7823406b2a38cff6bb704b0acc289a09c8128d4a8ce2bbafcd1fcbdc38666422fe2806') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
  1. 按照以下步骤运行 Composer 设置文件:
$ sudo php composer-setup.php

您应该在终端中获得以下输出:

All settings correct for using Composer
Downloading...

Composer (version 1.10.9) successfully installed to: /home/lau/composer.phar
Use it: php composer.phar
  1. 按照以下步骤删除 Composer 设置文件:
$ php -r "unlink('composer-setup.php');"
  1. 通过在终端上运行php composer.phar来验证安装。如果您想全局使用 Composer,则将 Composer 移动到/usr/local/bin(如果您使用 Linux/Unix):
$ sudo mv composer.phar /usr/local/bin/composer
  1. 现在,您可以全局运行 Composer。要验证它,只需运行以下命令:
$ composer

您应该看到 Composer 的标志,以及其可用的命令和选项:

   ______
  / ____/___ ____ ___ ____ ____ ________ _____
 / / / __ \/ __ __ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__ ) __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
                    /_/
Composer version 1.10.9 2020-07-16 12:57:00
...
...

或者,您可以使用-V选项直接检查您安装的版本:

$ composer -V
Composer version 1.10.9 2020-07-16 12:57:00
  1. 现在,您已经在系统上安装了 Composer,只需通过终端导航到项目的根目录,并使用composer require,然后是<package-name>,来安装您项目中需要的任何第三方软件包(也称为依赖项),如下所示:
$ composer require monolog/monolog
  1. 安装所需的软件包后,您可以转到项目根目录。您应该看到已创建一个包含项目依赖项的composer.json文件,其中包含require键:
{
    "require": {
        "monolog/monolog": "².0"
    }
}
  1. 如果您想下次再次安装所有依赖项,只需运行install命令,如下所示:
$ composer install
  1. 当您安装了项目的依赖项,无论是使用require还是install命令,Composer 都会生成一个包含所有依赖项的/vendor/文件夹。autoload.php文件将始终生成并位于/vendor/文件夹内。然后,您可以包含此文件并立即开始使用这些软件包提供的类,如下所示:
require __DIR__ . '/vendor/autoload.php';

$log = new Monolog\Logger('name');
$log->pushHandler(new Monolog\Handler\StreamHandler('path/to/your.log', Monolog\Logger::WARNING));
$log->addWarning('Foo');
$log->error('Bar');
  1. 最重要的是,您甚至可以通过向composer.json文件添加autoload键以及自定义命名空间来将您的类添加到自动加载程序。例如,您可以将类存储在项目根目录中的/src/文件夹中,与/vendor/目录位于同一级别:
{
    "autoload": {
        "psr-4": {"Spectre\\": "src/"}
    }
}

如果您的源文件位于多个位置,您可以使用数组[]将其与您的自定义命名空间关联起来,如下所示:

{
    "autoload": {
        "psr-4": {
            "Spectre\\": [
                "module1/",
                "module2/"
            ]
        }
    }
}

Composer 将为Spectre命名空间注册一个 PSR-4 自动加载程序。之后,您可以开始编写您的类。例如,您可以创建一个包含Spectre\Foo类的/src/Foo.php文件。之后,只需在终端上运行dump-autoload来重新生成/vendor/目录中的autoload.php文件。您还可以添加多个自定义命名空间到autoload字段,如下所示:

{
    "autoload": {
        "psr-4": {
            "Spectre\\": [
                //...
            ],
            "AnotherNamespace\\": [
                //...
            ]
        }
    }
}

除了 PSR-4,Composer 还支持 PSR-0。您可以在composer.json文件中添加 PSR-0 键。

有关如何在 Composer 中使用 PSR-0 的更多信息和示例,请访问getcomposer.org/doc/04-schema.mdautoload。但是,请注意,PSR-0 现在已经不推荐使用。如果您想阅读有关这两个 PSR 的更多信息,请访问www.php-fig.org/psr/psr-0/了解 PSR 0(不推荐使用),以及www.php-fig.org/psr/psr-4/了解 PSR-4。如果您想了解我们在 PHP 中用于记录的 Monolog,请访问github.com/Seldaek/monolog。如果您想了解 PHP 中自动加载类的更多信息,请访问www.php.net/manual/en/language.oop5.autoload.php

一旦您掌握了关于 PSR-12 和 PSR-4 的知识,您将更容易构建符合其他 PSR 的 PHP 应用程序。本书关注的另外两个 PSR 是 PSR-7 和 PSR-15。让我们先来看一下PSR-7

PSR-7 - HTTP 消息接口

早些时候,我们提到 PHP 没有 HTTP 请求和响应消息对象,这就是为什么 PHP 框架和编码人员在过去提出了不同的抽象来表示(或“模拟”)HTTP 消息。幸运的是,在 2015 年,PSR-7 出现了,结束了这些“分歧”和差异。

PSR-7 是一组通用接口(抽象),用于在 HTTP 通信中指定 HTTP 消息和 URI 的公共方法。在面向对象编程(OOP)中,接口实际上是对象(类)必须实现的操作(公共方法)的抽象,而不定义这些操作的复杂性和细节。例如,以下表格显示了当组合 HTTP 消息类时,必须实现的方法,以便符合 PSR-7 规范。

用于访问和修改请求和响应对象的指定方法如下:

访问 修改
getProtocolVersion() withProtocolVersion($version)
getHeaders() withHeader($name, $value)
hasHeader($name) withAddedHeader($name, $value)
getHeader($name)``getHeaderLine($name) withoutHeader($name)
getBody() withBody(StreamInterface $body)

用于访问和修改请求对象的指定方法如下:

访问 修改

|

  • getRequestTarget()

  • getMethod()

  • getUri()

  • getServerParams()

  • getCookieParams()

  • getQueryParams()

  • getUploadedFiles()

  • 获取解析后的主体getParsedBody()

  • 获取属性getAttributes()

  • getAttribute($name, $default = null)

|

  • 使用withMethod($method)方法

  • withRequestTarget($requestTarget)

  • 使用withUri(UriInterface $uri, $preserveHost = false)方法

  • withCookieParams(array $cookies)

  • withQueryParams(array $query)

  • withUploadedFiles(array $uploadedFiles)

  • withParsedBody($data)

  • withAttribute($name, $value)

  • withoutAttribute($name)

|

用于访问和修改响应对象的指定方法如下:

访问 修改

|

  • 获取状态码getStatusCode()

  • getReasonPhrase()

|

  • withStatus($code, $reasonPhrase = '')

|

自从 2015 年 5 月 18 日接受了 PSR-7 以来,许多基于它制作的软件包已经问世。只要实现了 PSR-7 中指定的接口和方法,就可以开发自己的版本。然而,除非你有充分的理由这样做,否则你可能会“重复造轮子”,因为已经有了 PSR-7 HTTP 消息软件包。因此,为了快速开始,让我们使用 Zend Framework 中的zend-diactoros。我们将“重用”你在前几节中学到的 PSR 知识(PSR-12 和 PSR-4)来创建一个简单的基于 HTTP 消息的“Hello World”服务器端应用程序。让我们开始吧:

  1. 在应用程序根目录中创建一个/public/目录,并在其中添加一个index.php文件。将以下行添加到其中以引导应用程序环境:
// public/index.php
chdir(dirname(__DIR__));
require_once 'vendor/autoload.php';

在这两行代码中,我们已将当前目录从/path/to/public更改为/path/to,以便我们可以通过写vendor/autoload.php而不是../vendor/autoload.php来导入autoload.php文件。

__DIR__(魔术)常量用于获取当前文件的目录路径,即index.php,位于/path/to/public/目录中。然后使用dirname函数获取父目录的路径,即/path/to。然后使用chdir函数来改变当前目录。

请注意,在接下来关于 PSR 的章节中,我们将使用这种模式来引导应用程序环境并导入自动加载文件。请访问以下链接以了解更多关于之前提到的常量和函数:

还要注意,您必须通过使用内置的 PHP Web 服务器在终端上运行所有传入的 PHP 应用程序,如下所示:

**$ php -S localhost:8181 -t public** 
  1. 通过 Composer 将zend-diactoros安装到应用程序的根目录:
$ composer require zendframework/zend-diactoros
  1. 要整理传入的请求,您应该在/public/目录中的index.php文件中创建一个请求对象,如下所示:
$request = Zend\Diactoros\ServerRequestFactory::fromGlobals(
    $_SERVER,
    $_GET,
    $_POST,
    $_COOKIE,
    $_FILES
);
  1. 现在,我们可以创建一个响应对象并对响应进行操作,如下所示:
$response = new Zend\Diactoros\Response();
$response->getBody()->write("Hello ");
  1. 请注意write方法在流接口(StreamInterface)中指定,我们也可以通过多次调用此方法来追加更多数据:
$response->getBody()->write("World!");
  1. 如果需要,我们可以操作标头。
$response = $response
    ->withHeader('Content-Type', 'text/plain');
  1. 请注意,头部应在数据写入主体后添加。然后,您已经成功将您在本章开头学到的简单 PHP“Hello World”应用程序转换为具有 PSR-7 的现代 PHP 应用程序!但是,如果您在终端中使用php -S localhost:8181 -t public运行此 PSR-7“Hello World”应用程序在浏览器上,您将看不到任何内容。这是因为我们没有使用PSR-15 HTTP 服务器请求处理程序PSR-7 HTTP 响应发射器将响应发送到浏览器,我们将在下一节中介绍。如果您现在想看到输出,可以使用getBody方法访问数据,然后使用echo
echo $response->getBody();
  1. 如果您通过 Chrome 的开发者工具检查页面的Content-type,您将得到text/html而不是我们用withHeader方法修改的text/plain。我们将在下一章中使用发射器获得正确的内容类型。

有关zend-diactoros及其高级用法的更多信息,请访问docs.zendframework.com/zend-diactoros/。除了 Zend Framework 的zend-diactoros之外,您还可以使用其他框架和库的 HTTP 消息包:

您应该查看www.php-fig.org/psr/psr-7/上的 PSR-7 文档,以获取有关此 PSR 的更多信息。如果您是 PHP 接口的新手,请访问www.php.net/manual/en/language.oop5.interfaces.php进行进一步阅读。

从 PSR-7 文档中,您可以找到本书中未提及的其他公共方法。它们应该在任何 PSR-7 HTTP 消息包中都可以找到,比如zend-diactoros。了解这些方法很有用,这样您就知道可以用它们做什么。您还可以在运行时使用内置的 PHP get_class_methods 方法列出您可以在请求和响应对象中使用的所有方法。例如,对于request对象,您可以执行以下操作:

$request = Zend\Diactoros\ServerRequestFactory::fromGlobals(
    //...
);
print_r(get_class_methods($request));

您将获得一个可以调用的请求方法列表的数组。对于response对象也是一样;通过这样做,您将获得一个响应方法列表的数组:

$response = new Zend\Diactoros\Response();
print_r(get_class_methods($response));

现在,让我们继续并查看PSR-15,在那里我们将了解如何向客户端(浏览器)发出响应。

PSR-15 - HTTP 服务器请求处理程序(请求处理程序)

PSR-7 是 PHP 社区的一个重要进步,但它只是实现目标的一半,可能使 PHP 编码人员摆脱庞大的 MVC 框架,并允许他们从一系列可重用的中间件中组合出不可知的 PHP 应用。它只定义了 HTTP 消息(请求和响应);它从未定义如何处理它们。因此,我们需要一个请求处理程序来处理请求以产生响应。

与 PSR-7 一样,PSR-15 是一组常见接口,但它们更进一步,并指定了请求处理程序(HTTP 服务器请求处理程序)和中间件(HTTP 服务器请求中间件)的标准。它于 2018 年 1 月 22 日被接受。我们将在下一节中介绍 HTTP 服务器请求中间件。现在,让我们了解 PSR-15 接口中的 HTTP 服务器请求处理程序RequestHandlerInterface

// Psr\Http\Server\RequestHandlerInterface

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request) : 
     ResponseInterface;
}

正如您所看到的,这是一个非常简单的接口。它只有一个指定的公共方法handle,它只接受一个 PSR-7 HTTP 请求消息,并且必须返回一个 PSR-7 HTTP 响应消息。我们将使用 Zend Framework 的zend-httphandlerrunner组件来实现这个接口,以提供我们可以用来发出 PSR-7 响应的实用工具。让我们将其连接到应用程序:

  1. 通过 Composer 安装zend-httphandlerrunner
$ composer require zendframework/zend-httphandlerrunner
  1. 一旦我们在项目环境中安装好了,我们可以将之前创建的响应发送到浏览器,如下所示:
//...
$response = $response
    ->withHeader('Content-Type', 'text/plain');

(new Zend\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);

如果您通过 Chrome 的开发者工具再次检查页面的Content-Type,您将获得正确的内容类型,即text/plain

有关zend-httphandlerrunner的更多信息,请访问docs.zendframework.com/zend-httphandlerrunner/。有关 PSR-15 的更多信息,请访问www.php-fig.org/psr/psr-15/

除了zend-httphandlerrunner,您还可以使用 Narrowspark 的 Http Response Emitter github.com/narrowspark/http-emitter 来处理请求并发出响应。现在,让我们继续看一下 PSR-15 的第二个接口MiddlewareInterface

PSR-15 - HTTP 服务器请求处理程序(中间件)

PSR-15 中的中间件接口具有以下抽象:

// Psr\Http\Server\MiddlewareInterface

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface;
}

再次,您可以看到这是一个非常简单的接口。它只有一个指定的公共方法process用于中间件实现。实现这个接口的组件(中间件)将只接受一个 PSR-7 HTTP 请求消息和一个 PSR-15 HTTP 服务器请求处理程序,然后必须返回一个 PSR-7 HTTP 响应消息。

我们将使用 Zend Framework 的zend-stratigility组件来实现这个接口,以便我们可以在我们的应用程序中创建 PSR-15 中间件。让我们学习如何将其连接到应用程序:

  1. 通过 Composer 安装zend-stratigility
$ composer require zendframework/zend-stratigility
  1. 一旦我们在项目环境中安装了它,我们将导入middleware函数和MiddlewarePipe类,如下所示:
use function Zend\Stratigility\middleware;

$app = new Zend\Stratigility\MiddlewarePipe();

// Create a request
$request = Zend\Diactoros\ServerRequestFactory::fromGlobals(
    //...
);
  1. 然后,我们可以使用这个middleware函数创建三个中间件并将它们附加到管道中,如下所示:
$app->pipe(middleware(function ($request, $handler) {
    $response = $handler->handle($request);
    return $response
        ->withHeader('Content-Type', 'text/plain');
}));

$app->pipe(middleware(function ($request, $handler) {
    $response = $handler->handle($request);
    $response->getBody()->write("User Agent: " . 
     $request->getHeader('user-agent')[0]);
    return $response;
}));

$app->pipe(middleware(function ($request, $handler) {
    $response = new Zend\Diactoros\Response();
    $response->getBody()->write("Hello world!\n");
    $response->getBody()->write("Request method: " .
     $request->getMethod() . "\n");
    return $response;
}));
  1. 正如您所看到的,“Hello World”代码块我们之前创建的现在是一个与其他中间件堆叠在一起的中间件。最后,我们可以从这些中间件生成一个最终响应并将其发出到浏览器,如下所示:
$response = $app->handle($request);
(new Zend\HttpHandlerRunner\Emitter\SapiEmitter)->
  emit($response);

您应该在0.0.0.0:8181的浏览器上获得类似以下的结果:

Hello world!
Request method: GET
User Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 
 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36

有关zend-stratigility的更多信息,请访问docs.zendframework.com/zend-stratigility/

除了zend-stratigility,您还可以使用以下软件包来创建您的中间件:

所以,就是这样。借助几个可互操作的组件,我们已经启动了一个符合 PSR-12、PSR-7 和 PSR-15 的现代 PHP 应用程序,这意味着您可以自由地从广泛的供应商实现中(不可知论)选择用于 HTTP 消息、请求处理程序和中间件的标准。但我们还没有完成。您可能已经注意到,我们创建的应用程序只是一个在0.0.0.0:8181上运行的单页面应用程序。它没有其他路由,如/about/contact等。因此,我们需要一个实现 PSR-15 的路由器。我们将在下一节中介绍这个。

PSR-7/PSR-15 路由器

我们将使用来自 The League of Extraordinary Packages(一个 PHP 开发者组)的 Route,以便我们拥有一个 PSR-7 路由系统,并在其上调度我们的 PSR-15 中间件。简而言之,Route 是一个快速的 PSR-7 路由/调度程序包。

它是一个 PSR-15 服务器请求处理程序,可以处理一系列中间件的调用。它是建立在 Nikita Popov 的 FastRoute (github.com/nikic/FastRoute)之上。

让我们学习如何将其连接到应用程序:

  1. 通过 Composer 安装league/route
$ composer require league/route
  1. 安装后,我们可以按如下方式重构我们的“Hello World”组件以使用路由:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

$request = Zend\Diactoros\ServerRequestFactory::fromGlobals(
    //...
);

$router = new League\Route\Router;

$router->map('GET', '/', function (ServerRequestInterface $request) : ResponseInterface {
    $response = new Zend\Diactoros\Response;
    $response->getBody()->write('<h1>Hello, World!</h1>');
    return $response;
});
  1. 然后,我们只需使用 Route 的dispatch方法创建一个 PSR-7 HTTP 响应,并将其发送到浏览器:
$response = $router->dispatch($request);
(new Zend\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);

查看您可以在route.thephpleague.com/4.x/route使用的 HTTP 请求方法列表(getpostputdelete等)。此外,我们可以将中间件附加到我们的应用程序。

  1. 如果您想锁定整个应用程序,可以将中间件添加到路由器,如下所示:
use function Zend\Stratigility\middleware;

$router = new League\Route\Router;
$router->middleware(<middleware>);
  1. 如果您想锁定一组路由,可以将中间件添加到该组,如下所示:
$router
    ->group('/private', function ($router) {
        // ... add routes
    })
    ->middleware(<middleware>)
;
  1. 如果您想锁定特定路由,可以将中间件添加到该路由,如下所示:
$router
    ->map('GET', '/secret', <SomeController>)
    ->middleware(<middleware>)
;
  1. 例如,您可以使用zend-stratigility与 Route:
use function Zend\Stratigility\middleware;

$router = new League\Route\Router;
$router->middleware(middleware(function ($request, $handler) {
    //...
}));
  1. 如果您不想使用middleware函数,或者根本不想使用zend-stratigility,您可以创建匿名中间件,如下所示:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

$router = new League\Route\Router;

$router->middleware(new class implements MiddlewareInterface {
    public function process(ServerRequestInterface $request, 
    RequestHandlerInterface $handler) : ResponseInterface
    {
        $response = $handler->handle($request);
        return $response->withHeader('X-Clacks-Overhead', 
        'GNU Terry Pratchett');
    }
});

只要您遵守 PSR7 和 PSR-15,通过在中间件中实现process方法,就无需zend-stratigility。如果您想在单独的 PHP 文件中创建基于类的中间件,请查看提供的示例route.thephpleague.com/4.x/middleware/

有关 The League of Extraordinary Packages 的 Route 的更多信息,请访问route.thephpleague.com/。您还可以查看由这个开发人员组创建的其他软件包,网址为thephpleague.com/。除了 The League of Extraordinary 的 Route 之外,您还可以使用以下基于 PSR-7 和 PSR-15 的 HTTP 路由器软件包:

您可能需要一个分发器来与其中一些软件包一起使用。使用 The League of Extraordinary Packages 的 Route 的优势在于它提供了一个路由器和一个分发器在一个软件包中。

有了这个,我们通过使用 PSR-12、PSR-4、PSR-7 和 PSR-15,编写了一个不可知的 PHP 应用程序。但是我们的 PHP API 还没有完成。还有一项任务要做——我们需要为 CRUD 操作添加一个数据库框架。我们将在下一节中指导您完成这项任务。

使用 PHP 数据库框架编写 CRUD 操作

正如您可能还记得的那样第九章,添加服务器端数据库CRUD代表create,read,update 和delete。在那一章中,我们使用 MongoDB 来创建 CRUD 操作。在本节中,我们将使用 MySQL 来创建后端身份验证。我们将在刚刚使用 PSRs 创建的 PHP 应用程序中使用 MySQL 和 PHP。因此,让我们首先创建我们在 MySQL 数据库中需要的表。

创建 MySQL 表

确保您已在本地计算机上安装了 MySQL 服务器并创建了一个名为nuxt-php的数据库。完成这些操作后,请按照以下步骤完成我们 API 的第一部分:

  1. 在数据库中插入以下 SQL 查询以创建表:
CREATE TABLE user (
  uuid varchar(255) NOT NULL,
  name varchar(255) NOT NULL,
  slug varchar(255) NOT NULL,
  created_on int(10) unsigned NOT NULL,
  updated_on int(10) unsigned NOT NULL,
  UNIQUE KEY slug (slug)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

您可能已经注意到的第一件事是,我们使用的是uuid而不是像我们在第十二章中所做的id。UUID 代表通用唯一标识符。可能有一些原因和好处会让您选择 UUID 而不是自动递增键来索引数据库表中的记录。例如,您可以在不连接到数据库的情况下创建 UUID。它在应用程序中几乎是唯一的,因此您可以轻松地从不同的数据库中组合数据而永远不会发生冲突。为了在 PHP 应用程序中生成 UUID,我们可以使用 Ben Ramsey 的ramsey/uuid来帮助我们生成 RFC 4122(tools.ietf.org/html/rfc4122)版本 1、3、4 和 5 的 UUID。

  1. 所以,让我们通过 Composer 安装ramsey/uuid
$ composer require ramsey/uuid
  1. 现在,您可以使用这个包来生成 UUID 的第一个版本,如下所示:
use Ramsey\Uuid\Uuid;

$uuid1 = Uuid::uuid1();
echo $uuid1->toString();

如果您想了解更多关于这个包的信息,请访问github.com/ramsey/uuid

现在,让我们学习如何使用 PHP 来处理 MySQL 数据库,并了解为什么我们需要一个数据库框架来加快 PHP 开发速度。

使用 Medoo 作为数据库框架

在 PHP 的旧时代,开发人员使用 MySQL 函数(www.php.net/manual/en/ref.mysql.php)来管理 MySQL 数据库。然后,MySQLi 扩展(www.php.net/manual/en/book.mysqli.php)取代了现在已经弃用的 MySQL 函数。然而,现在,开发人员被鼓励使用PHP 数据对象PDO)(www.php.net/manual/en/book.pdo.php)。PDO 是一个内置的 PHP 接口抽象,就像 PSR-7 和 PSR-15 一样。它是一个数据访问抽象层,为访问和管理数据库(例如 MySQL 和 PostgreSQL)提供了一个一致的接口(统一的 API),这意味着无论你使用哪种数据库,你都可以使用相同的函数来查询和获取数据。它支持以下数据库:

|

  • CUBRID

  • MS SQL Server

  • Firebird

  • IBM

|

  • Informix

  • MySQL

  • Oracle

  • ODBC 和 DB2

|

  • PostgreSQL

  • SQLite

  • 4D

|

请注意,PDO 是一个数据访问抽象层,而不是数据库抽象层。因此,取决于您使用的数据库,必须安装该数据库的 PDO 驱动程序才能使用 PDO。我们正在使用 MySQL 数据库,因此必须确保安装了PDO_MYSQL驱动程序。在 Ubuntu 中,您可以使用以下命令来检查您是否已启用 PDO 扩展,并且PDO_MYSQL驱动程序已安装在您的环境中:

$ php -m

您应该会得到一系列 PHP 模块。查找PDOpdo_mysql

[PHP Modules]
...
PDO
pdo_mysql
...

另一个更具体的选项是检查 PDO 及其驱动程序,如下所示:

$ php -m|grep -i pdo
PDO
pdo_mysql

如果您只想搜索 PDO 驱动程序,请执行以下操作:

$ php -m|grep -i pdo_
pdo_mysql

您还可以创建一个带有phpinfo()的 PHP 页面来查找它们。或者,您可以使用getAvailableDrivers方法,如下所示:

print_r(PDO::getAvailableDrivers());

您应该会得到一系列 PDO 驱动程序,如下所示:

Array
(
    [0] => mysql
)

或者,还有一些内置的 PHP 函数可以帮助您:

extension_loaded ('PDO'); // returns boolean
extension_loaded('pdo_mysql'); // returns boolean
get_loaded_extensions(); // returns array

如果您没有看到任何 PDO 驱动程序,则必须安装 MySQL 支持的驱动程序。请按照以下步骤执行:

  1. 搜索软件包名称(Ubuntu):
$ apt-cache search php7.4|grep mysql
php7.4-mysql - MySQL module for PHP
  1. 安装php7.4-mysql并重新启动 Apache 服务器:
$ sudo apt-get install php7.4-mysql
$ sudo service apache2 restart

一旦您安装了PDO_MYSQL驱动程序,就可以立即开始编写 CRUD 操作。例如,让我们编写一个insert操作,如下所示:

  1. 创建 MySQL 数据库连接:
$servername = "localhost";
$username = "<username>";
$password = "<password>";
$dbname = "<dbname>";
$connection = new PDO(
    "mysql:host=$servername;dbname=$dbname",
    $username,
    $password
)

请注意,<username><password><dbname>是实际连接详细信息的占位符。您必须根据自己的数据库设置进行更改。

  1. 准备 SQL 查询并“绑定”参数:
$stmt = $connection->prepare("
    INSERT INTO user (
        uuid,
        name,
        slug,
        created_on,
        updated_on
    ) VALUES (
        :uuid,
        :name,
        :slug,
        :created_on,
        :updated_on
    )
");
$stmt->bindParam(':uuid', $uuid);
$stmt->bindParam(':name', $name);
$stmt->bindParam(':slug', $slug);
$stmt->bindParam(':created_on', $createdOn);
$stmt->bindParam(':updated_on', $updatedOn);
  1. 插入一行新数据:
$uuid = "25769c6c-d34d-4bfe-ba98-e0ee856f3e7a";
$name = "John Doe";
$slug = "john-doe";
$createdOn = (new DateTime())->getTimestamp();
$updatedOn = $createdOn;
$stmt->execute();

这并不理想,因为您必须每次“准备”语句并绑定参数,这需要相当多的行来操作。因此,我们应该选择一个 PHP 数据库框架来加速开发。Medoo (medoo.in/)是其中的一个选择。它非常轻量级,非常容易集成和使用。

让我们安装并连接到我们的应用程序:

  1. 通过 Composer 安装 Medoo:
$ composer require catfan/medoo
  1. 如果一切都设置好了,您可以导入 Medoo 并传递一个配置数组来启动数据库连接,就像我们之前在原始方法中所做的那样:
use Medoo\Medoo;

$database = new Medoo([
  'database_type' => 'mysql',
  'database_name' => '<dbname>',
  'server' => 'localhost',
  'username' => '<username>',
  'password' => '<password>'
]);

通过这个数据库框架建立与 MySQL 数据库的连接就到此为止。您可以在本书的 GitHub 存储库中的/chapter-16/nuxt-php/proxy/backend/core/mysql.php中找到此片段的实际用法。我们将在接下来的部分中向您展示如何实现它,但现在让我们探索如何使用 Medoo 编写一些基本的 CRUD 操作。

插入记录

当您想要向表中插入新记录时,可以使用insert方法,如下所示:

$database->insert('user', [
    'uuid' => '41263659-3c1f-305a-bfac-6a7c9eab0507',
    'name' => 'Jane',
    'slug' => 'jane',
    'created_on' => '1568072289'
]);

如果您想了解有关此方法的更多细节,请访问medoo.in/api/insert

查询记录

当您想要列出表中的记录时,可以使用select方法,如下所示:

$database->select('user', [
    'uuid',
    'name',
    'slug',
    'created_on',
    'updated_on',
]);

select方法会给您一个记录列表。如果您只想选择特定行,可以使用get方法,如下所示:

$database->get('user', [
    'uuid',
    'name',
    'slug',
    'created_on',
    'updated_on',
    ], [
    'slug' => 'jane'
]);

如果您想了解更多细节,请访问medoo.in/api/select查看select方法和medoo.in/api/get查看get方法。

更新记录

当您想要修改表中记录的数据时,可以使用update方法,如下所示:

$database->update('user', [
    'name' => 'Janey',
    'slug' => 'jane',
    'updated_on' => '1568091701'
], [
    'uuid' => '41263659-3c1f-305a-bfac-6a7c9eab0507'
]);

如果您想了解有关此方法的更多细节,请访问medoo.in/api/update

删除记录

当您想要从表中删除记录时,可以使用delete方法,如下所示:

$database->delete('user', [
    'uuid' => '41263659-3c1f-305a-bfac-6a7c9eab0507'
]);

如果您想了解有关此方法的更多细节,请访问medoo.in/api/delete

这就是如何使用 Medoo 和 PDO 编写基本 CRUD 操作的全部内容。

请查看 Medoo 的文档medoo.in/doc以了解您可以使用的其他方法。还有其他替代方案,如github.com/doctrine/dbal上的 Doctrine DBAL 和github.com/illuminate/database上的 Eloquent。

在本节中,您学习了一些 PSR 和 CRUD 操作。接下来,我们将介绍如何将它们全部整合到 Nuxt 中。由于 PHP 和 JavaScript 是两种不同的语言,它们之间唯一的交流方式是通过 API 中的 JSON。

但在编写启用该功能的脚本之前,我们应该研究这两个程序的跨域应用程序结构。自从第十二章以来,我们一直在为我们的 Nuxt 应用程序使用跨域应用程序结构,创建用户登录和 API 身份验证,所以这对您来说应该很熟悉。让我们开始吧!

构建跨域应用程序目录

再次,就像构建跨域应用程序目录时一样,以下是我们对 Nuxt 和我们的 PHP API 的整体视图:

// Nuxt app
front-end
├── package.json
├── nuxt.config.js
└── pages
    ├── index.vue
    └── ...

// PHP API
backend
├── composer.json
├── vendor
│ └── ...
├── ...
└── ...

就 Nuxt 的目录结构而言,它保持不变。我们只需对 API 目录的结构进行轻微更改,如下所示:

// PHP API
backend
├── composer.json
├── middlewares.php
├── routes.php
├── vendor
│ └── ...
├── public
│ └── index.php
├── static
│ └── ...
├── config
│ └── ...
├── core
│ └── ...
├── middleware
│ └── ...
└── module
    └── ...

PHP API 的目录结构是一个建议。您可以始终设计自己喜欢并最适合您的结构。因此,一目了然,我们有以下内容:

  • /vendor/目录是存放所有第三方包或依赖项的地方。

  • /public/目录只包含一个index.php文件,该文件启动我们的 API。

  • /static/目录用于静态文件,例如网站图标。

  • /config/目录存储配置文件,例如 MySQL 文件。

  • /core/目录存储我们可以在整个应用程序中使用的常见对象和函数。

  • /middleware/目录存储我们的 PSR-15 中间件。

  • /module/目录存储我们稍后将创建的自定义模块,就像我们在第十二章中所做的那样,创建用户登录和 API 身份验证,使用 Koa。

  • composer.json文件始终位于根级别。

  • middlewares.php文件是从/middleware/目录导入中间件的核心位置。

  • routes.php文件是从/module/目录导入路由的核心位置。

一旦您准备好结构,就可以开始编写顶级代码,将来自不同位置和目录的其他代码粘合到/public/目录中的index.php文件中,从而形成一个单一的应用程序。所以,让我们开始吧:

  1. foreach循环放在routes.php文件中,以迭代稍后将创建的每个模块:
// backend/routes.php
$modules = require './config/routes.php';

foreach ($modules as $module) {
    require './module/' . $module . 'index.php';
}
  1. /config/目录中创建一个routes.php文件,该文件将列出您的模块的文件名,如下所示:
// backend/config/routes.php
return [
    'Home/',
    'User/'.
    //...
];
  1. 在这个 PHP API 中,middlewares.php文件将导入一个用于装饰 CRUD 操作输出的中间件片段:
// backend/middlewares.php
require './middleware/outputDecorator.php';

此装饰器将以以下格式以 JSON 格式打印 CRUD 操作的输出:

{"status":<status code>,"data":<data>}
  1. /middleware/目录中创建一个名为outputDecorator.php的文件,其中包含以下代码。这将以前述格式包装操作的输出:
// backend/middleware/outputDecorator.php
use function Zend\Stratigility\middleware;

$router->middleware(middleware(function ($request, $handler) {
    $response = $handler->handle($request);
    $existingContent = (string) $response->getBody();
    $contentDecoded = json_decode($existingContent, true);
    $status = $response->getStatusCode();
    $data = [
        "status" => $status,
        "data" => $contentDecoded
    ];
    $payload = json_encode($data);

    $response->getBody()->rewind();
    $response->getBody()->write($payload);

    return $response
        ->withHeader('Content-Type', 'application/json')
        ->withStatus($status);
}));

在这里,我们使用zend-stratigility组件的middleware方法来创建装饰器中间件。然后,我们使用 The League of Extraordinary 的league/route路由器来使用此中间件锁定整个应用程序。

  1. /core/目录中创建一个名为mysql.php的文件,该文件返回 MySQL 连接的 Medoo 实例:
// backend/core/mysql.php
$dbconfig = require './config/mysql.php';
$mysql = new Medoo\Medoo([
    'database_type' => $dbconfig['type'],
    'database_name' => $dbconfig['name'],
    'server' => $dbconfig['host'],
    'username' => $dbconfig['username'],
    'password' => $dbconfig['password']
]);
return $mysql;
  1. 正如我们之前提到的,/public/目录只包含一个index.php文件。这用于启动我们的程序,因此它包含了您之前学习的关于 PSRs 的脚本:
// backend/public/index.php
chdir(dirname(__DIR__));
require_once 'vendor/autoload.php';

$request = Zend\Diactoros\ServerRequestFactory::fromGlobals(
    //...
);

$router = new League\Route\Router;
try {
    require 'middlewares.php';
    require 'routes.php';
    $response = $router->dispatch($request);
} catch(Exception $exception) {
    // handle errors
}

(new Zend\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);

在这里,您可以看到middlewares.phproutes.php文件被导入到这个文件中以生成一个 PSR-7 响应。它们被包裹在trycatch块中,以捕获任何 HTTP 错误,比如 404 和 506 错误。因此,模块的任何输出和错误都将通过最后一行传递到浏览器。希望这给您提供了一个对这个简单 API 的整体了解。现在,让我们继续深入学习/module/目录,以更详细地了解如何创建模块和路由。

创建 API 的公共路由及其模块

创建 API 的公共路由及其模块与您在本书前几章中学习构建的 API 非常相似;主要区别在于语言。以前我们使用 JavaScript 和 Node.js 框架 Koa,而在本章中的 API 中,我们使用 PHP 和 PSRs 来创建一个与框架无关的 API。所以,让我们开始吧:

  1. /module/目录中创建两个目录:一个名为Home,另一个名为User。这两个子目录是 API 中的模块。在每个模块中,创建一个/_routes/目录和一个index.php文件,该文件将从/_routes/目录导入路由,如下所示:

└── module
    ├── Home
    │ ├── index.php
    │ └── _routes
    │ └── hello_world.php
    └── User
        ├── index.php
        └── _routes
           └── ...
  1. Home模块中,输出一个“Hello world!”消息,并将其映射到/路由,如下所示:
// module/Home/_routes/hello_world.php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

$router->get('/', function (ServerRequestInterface $request) : 
  ResponseInterface {
    return new Zend\Diactoros\Response\JsonResponse(
     'Hello world!');
});
  1. User模块中,编写 CRUD 操作,以便我们可以创建、读取、更新和删除用户。因此,在/_routes/目录中,创建五个文件,分别为fetch_user.phpfetch_users.phpinsert_user.phpupdate_user.phpdelete_user.php。在这些文件中,我们将在/Controller/目录中为每个 CRUD 操作映射路由:
└── User
    ├── index.php
    ├── _routes
    │ ├── delete_user.php
    │ ├── fetch_user.php
    │ └── ...
    └── Controller
        └── ...
  1. 例如,在fetch_users.php文件中,我们将定义一个/users路由来列出所有用户,如下所示:
// module/User/_routes/fetch_users.php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

$router->get('/users', function (ServerRequestInterface $request) : ResponseInterface {
    $database = require './core/mysql.php';
    $users = (new Spectre\User\Controller\
     Fetch\Users($database))->fetch();
    return new Zend\Diactoros\Response\JsonResponse($users);
});

在这里,您可以看到我们将 Medoo 实例作为$database导入,并将其传递给执行Read操作的控制器,然后调用fetch方法以获取所有可用用户。

  1. 接下来,我们要做的是创建一些 CRUD 目录:InsertFetchUpdateDelete。在每个 CRUD 目录中,我们将把 PSR-4 类存储在/Controller/目录中,如下所示:
└── Controller
    ├── Controller.php
    ├── Insert
    │ └── User.php
    ├── Fetch
    │ ├── User.php
    │ └── Users.php
    ├── Update
    │ └── User.php
    └── Delete
        └── User.php
  1. 首先,创建一个abstract类,可以被 CRUD 目录中的类扩展。这个类将只在其构造函数中接受Medoo\Medoo数据库,如下所示:
// module/User/Controller/Controller.php
namespace Spectre\User\Controller;

use Medoo\Medoo;

abstract class Controller
{
    protected $database;

    public function __construct(Medoo $database)
    {
        $this->database = $database;
    }
}
  1. 导入上述abstract类并将其扩展到需要连接到 MySQL 数据库的任何其他类中,如下所示:
// module/User/Controller/Fetch/Users.php
namespace Spectre\User\Controller\Fetch;

use Spectre\User\Controller\Controller;

class Users extends Controller
{
    public function fetch()
    {
        $columns = [
            'uuid',
            'name',
            'slug',
            'created_on',
            'updated_on',
        ];
        return $this->database->select('user', $columns);
    }
}

在这个类中,我们使用select方法从 MySQL 数据库的user表中获取所有用户。Medoo 将返回一个包含用户列表的Array,如果没有用户,则返回一个空的Array。然后,使用zend-diactoros中的JsonResponse方法将结果转换为 JSON,在fetch_users.php文件中。

最后,它将被装饰在/middleware/目录中的中间件。这将产生以下输出:

{"status":200,"data":[{"uuid":"...","name":"Jane","slug":"jane",...},{...},{...}]}

关于 PHP API 就是这样了。很简单,不是吗?在这个练习中,我们将跳过在 API 端处理 CORS 的任务,因为我们将使用 Nuxt Axios 和 Proxy 模块在我们即将创建的 Nuxt 应用程序中无缝轻松地处理 CORS。所以,让我们开始吧!

您可以在本书的 GitHub 存储库中的/chapter-16/nuxt-php/proxy/backend/中找到这个 PHP API,以及这个 API 的其余 CRUD 类在/chapter-16/nuxt-php/proxy/backend/module/User/Controller/中。

与 Nuxt 集成

@nuxtjs/axios模块与@nuxtjs/proxy模块很好地集成在一起,在许多情况下非常有用。防止 CORS 问题是使用这两个模块的好处之一。您在第六章中学习了如何安装和使用它们,编写插件和模块。让我们回顾一下:

  1. 通过 npm 安装@nuxtjs/axios@nuxtjs/proxy模块:
$ npm install @nuxtjs/axios
$ npm install @nuxtjs/proxy
  1. 在 Nuxt 配置文件的modules选项中注册@nuxtjs/axios,如下所示:
// nuxt.config.js
module.exports = {
  modules: [
    '@nuxtjs/axios'
  ],

  axios: {
    proxy: true
  },

  proxy: {
    '/api/': { target: 'http://0.0.0.0:8181', 
     pathRewrite: {'^/api/': ''} }
  }
}

请注意,当您与@nuxtjs/axios一起使用@nuxtjs/proxy时,不需要注册@nuxtjs/proxy模块,只要它已安装并在package.jsondependencies字段中。

在上述配置中,我们使用/api/作为http://0.0.0.0:8181的代理,这是我们的 PHP API 运行的地方。因此,每当我们在任何 API 端点请求中使用/api/时,它都会调用0.0.0.0:8181。例如,假设您正在进行 API 调用,如下所示:

$axios.get('/api/users')

@nuxtjs/axios@nuxtjs/proxy模块将把/api/users端点转换为以下内容:

http://0.0.0.0:8181/api/users

但由于我们在 PHP API 的路由中不使用/api/,所以我们在配置中使用pathRewrite在调用过程中将其移除。然后,由@nuxtjs/axios@nuxtjs/proxy模块发送到 API 的实际 URL 如下:

http://0.0.0.0:8181/users

再次访问以下链接,了解有关这两个模块的更多信息:

安装和配置完成后,我们可以开始创建用于与 PHP API 通信的前端 UI。我们将在下一节中讨论这个问题。

创建 CRUD 页面

再次强调,这对您来说并不是完全新的任务,因为这几乎与您在第九章中学习创建的 CRUD 页面相同,即添加服务器端数据库。让我们回顾一下:

  1. /pages/users/目录中创建以下页面以发送和获取数据:
users
├── index.vue
├── _slug.vue
├── add
│ └── index.vue
├── update
│ └── _slug.vue
└── delete
    └── _slug.vue
  1. 例如,使用以下脚本来获取所有可用用户:
// pages/users/index.vue
export default {
  async asyncData ({ error, $axios }) {
    try {
      let { data } = await $axios.get('/api/users')
      return {
        users: data.data
      }
    } catch (err) {
      // handle errors.
    }
  }
}

这个 Nuxt 应用程序中的脚本、模板和目录结构与您在第九章中学习创建的应用程序相同,即添加服务器端数据库。不同之处在于在那一章中使用了_id,但在这一章中,我们使用_slug。到目前为止,您应该能够独立完成其余的 CRUD 页面。但是,您可以随时回顾第九章中的以下部分,添加服务器端数据库,以获取更多信息:

  • 创建添加页面

  • 创建更新页面

  • 创建删除页面

创建了这些页面后,可以使用npm run dev运行 Nuxt 应用程序。您应该在浏览器上看到应用程序在localhost:3000上运行。

你可以在本书的 GitHub 存储库中的/chapter-16/nuxt-php/proxy/frontend/nuxt-universal/中找到此应用程序的完整源代码。

如果你不想在这个 Nuxt 应用中使用@nuxtjs/axios@nuxtjs/proxy模块,你可以在本书的 GitHub 存储库中的/chapter-16/nuxt-php/cors/中找到有关如何在 Nuxt 应用中为 PHP API 启用 CORS 的完整源代码。

你还可以在本书的 GitHub 存储库中的/chapter-16/nuxt-php/中找到名为user.sql的数据库副本。

现在,让我们总结一下你在这一长章节中学到的东西。我们希望你喜欢这一章,并且觉得它很有启发性。

总结

在本章中,你不仅成功地将 Nuxt 应用程序与 API 解耦,类似于你在第十二章中所做的,创建用户登录和 API 身份验证,而且还成功地用不同的语言 PHP 编写了一个 API,PHP 是 Web 开发中最流行的服务器端脚本语言之一。你学会了如何安装 PHP 和 Apache 以运行 PHP 应用程序,或者使用内置的 PHP Web 服务器进行开发,同时遵守 PSR-12、PSR4、PSR7 和 PSR-15,以构建一个现代的框架无关的应用程序。你还学会了使用 PHP 数据库框架 Medoo 来编写 CRUD 操作,重用了来自第九章的 Nuxt 应用程序,添加服务器端数据库,但进行了一些修改,并完美地将前端 UI 和后端 API 粘合在一起。现在,你还更详细地了解了 HTTP 消息,并知道如何使用 PDO 进行现代 PHP 数据库管理。干得好。

在下一章中,你将了解 Nuxt 在实时应用方面还能做些什么。在那里,你将学习Socket.ioRethinkDB。我们将带你了解这两种技术的安装过程。然后,你将学习如何在 RethinkDB 数据库中执行实时 CRUD 操作,在 JavaScript 中使用 Socket.io 编写实时代码,并将它们与 Nuxt 应用集成。这将是另一个有趣和激动人心的章节,我们将会引导你完成。所以,敬请关注!

使用 Nuxt 创建实时应用程序

在本章中,我们将进一步探讨 Nuxt,看看如何使用它与其他框架一起制作实时应用程序。我们将继续使用 Koa 作为后端 API,但是通过 RethinkDB 和 Socket.IO 来“增强”它。换句话说,我们将使用这两个令人敬畏的框架和工具将我们的后端 API 转换为实时 API。同时,我们还将借助它们将我们的前端 Nuxt 应用程序转换为实时 Nuxt 应用程序。如果您愿意,您可以在单域方法上开发这两个实时应用程序。但是,本书更倾向于跨域方法,以便我们不会混淆前端和后端的依赖关系,并随着时间的推移而感到困惑。因此,这将是另一个您可以从中学习的有趣而令人兴奋的章节!

在本章中,我们将涵盖以下主题:

  • 介绍 RethinkDB

  • 将 RethinkDB 与 Koa 集成

  • 介绍 Socket.IO

  • 将 Socket.IO 与 Nuxt 集成

让我们开始吧!

第十七章:介绍 RethinkDB

RethinkDB 是用于实时应用程序的开源 JSON 数据库。每当数据库表中发生更改时,它会从数据库实时推送 JSON 数据到您的应用程序,您可以订阅这些实时订阅 - changefeeds。尽管 changefeeds 是 RethinkDB 实时功能的核心,但如果您愿意,您可以跳过此功能。您可以像使用 MongoDB 一样使用 RethinkDB 来存储和查询您的 NoSQL 数据库。

尽管您可以使用 MongoDB 中的更改流来访问实时数据更改,但这需要一些配置才能启动,而实时订阅在 RethinkDB 中默认情况下已准备就绪,您可以立即开始使用,无需任何配置。让我们首先在您的系统中安装RethinkDB 服务器,然后看看您如何在下一节中使用它。

安装 RethinkDB 服务器

在撰写本书时,RethinkDB 的当前稳定版本是2.4.0活死人之夜),于 2019 年 12 月 19 日发布。根据平台(Ubuntu 或 OS),有几种安装 RethinkDB 服务器的方法。您可以在rethinkdb.com/docs/install/上查看您平台的指南。请注意,Windows 在 2.4.0 中尚不受支持。有关 Windows 的更多信息,请访问rethinkdb.com/docs/install/windows

在本书中,我们将在Ubuntu 20.04 LTS(Focal Fossa)上安装 RethinkDB 2.4.0。如果您使用的是 Ubuntu 19.10(Eoan Ermine)、Ubuntu 19.04(Disco Dingo)或较旧版本的 Ubuntu,如 18.04 LTS(Bionic Beaver),操作方式是相同的。让我们开始吧:

  1. 将 RethinkDB 存储库添加到 Ubuntu 存储库列表中,如下所示:
$ source /etc/lsb-release && echo "deb https://download.rethinkdb.com/apt $DISTRIB_CODENAME main" | sudo tee /etc/apt/sources.list.d/rethinkdb.list
  1. 使用wget获取 RethinkDB 的公钥:
$ wget -qO- https://download.rethinkdb.com/apt/pubkey.gpg | sudo apt-key add -

对于上述命令行,您应该在终端上收到一个 OK 消息。

  1. 更新您的 Ubuntu 版本并安装 RethinkDB:
$ sudo apt update
$ sudo apt install rethinkdb
  1. 验证 RethinkDB:
$ rethinkdb -v

您应该在终端上获得以下输出:

rethinkdb 2.4.0~0eoan (CLANG 9.0.0 (tags/RELEASE_900/final))

RethinkDB 附带了一个管理 UI,供您在浏览器上管理数据库,地址为localhost:8080。这在项目开发过程中非常方便和有用。如果您想要卸载 RethinkDB 并删除所有数据库,可以使用以下命令进行操作:

$ sudo apt purge rethinkdb.
$ sudo rm -r /var/lib/rethinkdb

安装时附带的管理 UI 类似于您在上一章中用于管理 PHP API 的 MySQL 数据库的 PHP Adminer。您可以使用 RethinkDB 管理 UI 通过 UI 上的图形按钮或使用 JavaScript 中的 RethinkDB 查询语言(ReQL)添加数据库和表。我们将在下一节中探索管理 UI 和 ReQL。

介绍 ReQL

ReQL 是 RethinkDB 的查询语言,用于操作 RethinDB 数据库中的 JSON 文档。查询是通过在服务器端调用 RethinkDB 的内置可链接函数自动构建的。这些函数嵌入在各种编程语言的驱动程序中,包括 JavaScript、Python、Ruby 和 Java。您可以在以下链接中查看 ReQL 命令/函数:

本书将使用 JavaScript。让我们在管理 UI 上使用数据资源管理器,通过使用相应的 ReQL 命令执行一些 CRUD 操作。您可以导航到数据资源管理器所在的页面,或者将浏览器指向localhost:8080/#dataexplorer并开始使用查询,如下所示。数据资源管理器上的默认顶级命名空间是r,因此 ReQL 命令必须链接到此命名空间。

但是,在我们的应用程序中使用驱动程序时,我们可以更改这个r命名空间,并在下一节中使用任何我们喜欢的名称。现在,让我们在这个练习中坚持使用默认命名空间r

  1. 创建数据库:
r.dbCreate('nuxtdb')

点击运行按钮。您应该在屏幕上看到类似以下的结果,显示已创建一个数据库,数据库名称由您选择,并且 RethinkDB 生成了一个 ID:

{
  "config_changes": [
    {
      "new_val": {
      "id": "353d11a4-adc8-4958-a4ae-a82c996dcb9f" ,
      "name": "nuxtdb"
    } ,
      "old_val": null
    }
  ] ,
  "dbs_created": 1
}

如果您想了解有关dbCreate ReQL 命令的更多信息,请访问rethinkdb.com/api/javascript/db_create/

  1. 在现有数据库中创建表;例如,在nuxtdb数据库中创建一个user表:
r.db('nuxtdb').tableCreate('user')

点击运行按钮。您应该在屏幕上看到类似以下的结果,显示 RethinkDB 为您生成的一个带有 ID 的表已被创建,并显示您创建的表的其他信息:

{
  "config_changes": [{
    "new_val": {
      "db": "nuxtdb",
      "durability": "hard",
      "id": "259e0066-1ffe-4064-8b24-d1c82e515a4a",
      "indexes": [],
      "name": "user",
      "primary_key": "id",
      "shards": [{
        "nonvoting_replicas": [],
        "primary_replica": "lau_desktop_opw",
        "replicas": ["lau_desktop_opw"]
      }],
      "write_acks": "majority",
      "write_hook": null
    },
    "old_val": null
  }],
  "tables_created": 1
}

如果您想了解有关tableCreate ReQL 命令的更多信息,请访问rethinkdb.com/api/javascript/table_create/

  1. 将新文档插入user表中:
r.db('nuxtdb').table('user').insert([
 { name: "Jane Doe", slug: "jane" },
 { name: "John Doe", slug: "john" }
])

点击运行按钮。您应该在屏幕上看到类似以下的结果,显示 RethinkDB 为您生成的两个带有键的文档已被插入:

{
  "deleted": 0,
  "errors": 0,
  "generated_keys": [
    "7f7d768d-0efd-447d-8605-2d460a381944",
    "a144001c-d47e-4e20-a570-a29968980d0f"
  ],
  "inserted": 2,
  "replaced": 0,
  "skipped": 0,
  "unchanged": 0
}

如果您想了解有关tableinsert ReQL 命令的更多信息,请分别访问rethinkdb.com/api/javascript/table/rethinkdb.com/api/javascript/insert/

  1. user表中检索文档:
r.db('nuxtdb').table('user')

点击运行按钮。您应该在屏幕上看到类似以下的结果,显示user表中的两个文档:

[{
  "id": "7f7d768d-0efd-447d-8605-2d460a381944",
  "name": "Jane Doe",
  "slug": "jane"
}, {
  "id": "a144001c-d47e-4e20-a570-a29968980d0f",
  "name": "John Doe",
  "slug": "john"
}]

如果要计算表中的总文档数,可以将count方法链接到查询中,如下所示:

r.db('nuxtdb').table('user').count()

在注入新文档后,user表中应该有2个文档。

如果您想了解有关count ReQL 命令的更多信息,请访问rethinkdb.com/api/javascript/count/

  1. 更新user表中的文档,通过使用slug键过滤表:
r.db('nuxtdb').table('user')
.filter(
  r.row("slug").eq("john")
)
.update({
  name: "John Wick"
})

点击运行按钮。您应该在屏幕上看到以下结果,显示已替换一个文档:

{
  "deleted": 0,
  "errors": 0,
  "inserted": 0,
  "replaced": 1,
  "skipped": 0,
  "unchanged": 0
}

如果您想了解有关filterupdate ReQL 命令的更多信息,请分别访问rethinkdb.com/api/javascript/filter/rethinkdb.com/api/javascript/update/

另外,如果您想了解有关roweq ReQL 命令的更多信息,请分别访问rethinkdb.com/api/javascript/row/rethinkdb.com/api/javascript/eq/

  1. 通过使用slug键过滤表格来从user表中删除文档:
r.db('nuxtdb').table('user')
.filter(
  r.row("slug").eq("john")
)
.delete()

点击运行按钮。您应该在屏幕上看到以下结果,显示已删除一个文档:

{
  "deleted": 1,
  "errors": 0,
  "inserted": 0,
  "replaced": 0,
  "skipped": 0,
  "unchanged": 0
}

如果您想删除表中的所有文档,那么只需将delete方法链接到表而不进行过滤,如下所示:

r.db('nuxtdb').table('user').delete()

如果您想了解有关delete ReQL 命令的更多信息,请访问rethinkdb.com/api/javascript/delete/

在使用 ReQL 命令时,这很有趣也很容易,不是吗?您不必阅读所有 ReQL 命令并详细研究每个命令以提高生产力。您只需要知道您想要做什么,并根据您已经了解的编程语言从 ReQL 命令参考/API 页面中找到您需要的命令。接下来,您将找出如何将RethinkDB 客户端或驱动程序添加到您的应用程序中。让我们开始吧!

将 RethinkDB 与 Koa 集成

在本节中,我们将构建一个简单的 API,按照我们在上一章中创建的 PHP API 的方式列出、添加、更新和删除用户。在之前的 API 中,我们使用了 PHP 和 MySQL,而在本章中,我们将使用 JavaScript 和 RethinkDB。我们仍将使用 Koa 作为 API 的框架。但这一次,我们将重新构建 API 目录,使其结构与您已经熟悉的 Nuxt 应用程序和 PHP API 的目录结构保持一致(尽可能)。所以,让我们开始吧!

重构 API 目录

请记住,当使用 Vue CLI 时,您在项目中获得的默认目录结构,您在第十一章中了解过,编写路由中间件和服务器中间件?使用 Vue CLI 安装项目后,如果您查看项目目录,您将看到一个基本的项目结构,其中包含/src/目录,用于开发组件、页面和路由,如下所示:

├── package.json
├── babel.config.js
├── README.md
├── public
│ ├── index.html
│ └── favicon.ico
└── src
    ├── App.vue
    ├── main.js
    ├── router.js
    ├── components
    │ └── HelloWorld.vue
    └── assets
        └── logo.png

自第十二章以来,我们一直在为跨域应用程序使用这种标准结构,创建用户登录和 API 身份验证。例如,以下是您之前创建的 Koa API 的目录结构:

backend
├── package.json
├── backpack.config.js
├── static
│ └── ...
└── src
    ├── index.vue
    ├── ...
    ├── modules
    │ └── ...
    └── core
        └── ...

但是这一次,我们将在本章中要创建的 API 中消除/src/目录。因此,让我们将/src/目录中的所有内容移动到顶层,并重新配置应用程序的引导方式,如下所示:

  1. 在项目的根目录中创建以下文件和文件夹:
backend
├── package.json
├── backpack.config.js
├── middlewares.js
├── routes.js
├── configs
│ ├── index.js
│ └── rethinkdb.js
├── core
│ └── ...
├── middlewares
│ └── ...
├── modules
│ └── ...
└── public
    └── index.js

再次强调,这里的目录结构仅仅是一个建议;您可以根据自己的需求设计自己的目录结构,使其最适合您。但让我们来看看这个建议的目录,并研究这些文件和文件夹的用途:

  • /configs/目录用于存储应用程序的基本信息和 RethinkDB 数据库连接的详细信息。

  • /public/目录用于存储启动应用程序的文件。

  • /modules/目录用于存储应用程序的模块,例如我们将在接下来的章节中创建的'user'模块。

  • /core/目录用于存储可以在整个应用程序中使用的常用函数或类。

  • middlewares.js文件是从/middlewares//node_modules/目录导入中间件的核心位置。

  • routes.js文件是从/modules目录导入路由的核心位置。

  • backpack.config.js文件用于自定义我们应用程序的 webpack 配置。

  • package.json文件包含我们应用程序的脚本和依赖项,并始终位于根目录。

  1. 将入口文件指向/public/目录中的index.js文件:
// backpack.config.js
module.exports = {
  webpack: (config, options, webpack) => {
    config.entry.main = './public/index.js'
    return config
  }
}

请记住,Backpack 中的默认入口文件是/src/目录中的index.js文件。由于我们已将此索引文件移动到/public/目录,因此必须通过 Backpack 配置文件配置此入口点。

如果您想了解 webpack 中的入口点,请访问webpack.js.org/concepts/entry-points/

  1. 在返回 Backpack 配置文件中的config对象之前,在 webpack 配置中的resolve选项中为/configs/core/modules/middlewares路径添加别名:
// backpack.config.js
const path = require('path')

config.resolve = {
  alias: {
    Configs: path.resolve(__dirname, 'configs/'),
    Core: path.resolve(__dirname, 'core/'),
    Modules: path.resolve(__dirname, 'modules/'),
    Middlewares: path.resolve(__dirname, 'middlewares/')
  }
}

在我们的应用程序中使用别名来解析文件路径非常有用和方便。通常,我们使用相对路径导入文件,就像这样:

import notFound from '../../Middlewares/notFound'

现在,我们可以使用别名从任何地方导入文件,这样可以隐藏相对路径,从而使我们的代码更整洁:

import notFound from 'Middlewares/notFound'

如果您想了解 webpack 中的别名和解析选项,请访问webpack.js.org/configuration/resolve/resolvealias

一旦您准备好了上述结构并且入口文件已经排序好,您就可以开始将 CRUD 操作应用到此 API 中。但首先,您需要将RethinkDB JavaScript 客户端安装到您的项目中。所以,让我们开始吧!

添加并使用 RethinkDB JavaScript 客户端

根据您拥有的编程知识,您可以选择几个官方客户端驱动程序,包括 JavaScript、Ruby、Python 和 Java。还有许多社区支持的驱动程序,如 PHP、Perl 和 R。您可以在rethinkdb.com/docs/install-drivers/上查看它们。

在本书中,我们将使用 RethinkDB JavaScript 客户端驱动程序。我们将指导您通过以下步骤安装并使用此驱动程序进行 CRUD 操作:

  1. 通过 npm 安装 RethinkDB JavaScript 客户端驱动程序:
$ npm i rethinkdb
  1. 创建一个rethinkdb.js文件,其中包含 RethinkDB 服务器连接详细信息,放在/configs/目录中,如下所示:
// configs/rethinkdb.js
export default {
  host: 'localhost',
  port: 28015,
  dbname: 'nuxtdb'
}
  1. 创建一个名为connection.js的文件,其中包含在/core/目录中的上述连接详细信息,用于打开 RethinkDB 服务器连接,如下所示:
// core/database/rethinkdb/connection.js
import config from 'Configs/rethinkdb'
import rethink from'rethinkdb'

const c = async() => {
  const connection = await rethink.connect({
    host: config.host,
    port: config.port,
    db: config.dbname
  })
  return connection
}
export default c
  1. 此外,还可以创建一个名为open.js的开放连接中间件文件,并将其绑定到 Koa 上下文中作为连接到 RethinkDB 的另一种选择,放在/middlewares/目录中。
// middlewares/database/rdb/connection/open.js
import config from 'Configs/rethinkdb'
import rdb from'rethinkdb'

export default async (ctx, next) => {
  ctx._rdbConn = await rdb.connect({
    host: config.host,
    port: config.port,
    db: config.dbname
  })
  await next()
}

使用目录路径来描述你的中间件(或 CRUD 操作)是一个很好的做法,我们从 PHP 的 PSR-4 中学到了这一点,这样你就不必使用一个很长的名称来描述你的文件。例如,如果你没有使用描述性的目录路径,你可能想将这个中间件命名为rdb-connection-open.js,以尽可能清楚地描述它。但如果你使用目录路径来描述中间件,那么你可以简单地将文件命名为open.js

  1. /middlewares/目录中创建一个close.js文件,创建一个关闭连接中间件,并将其绑定到 Koa 上下文作为最后一个中间件,如下所示:
// middlewares/database/rdb/connection/close.js
import config from 'Configs/rethinkdb'
import rdb from'rethinkdb'

export default async (ctx, next) => {
  ctx._rdbConn.close()
  await next()
}
  1. 在根目录的middlewares.js文件中导入openclose连接中间件,并将它们注册到应用程序中,如下所示:
// middlewares.js
import routes from './routes'
import rdbOpenConnection from 'Middlewares/database/rdb/connection/open'
import rdbCloseConnection from 'Middlewares/database/rdb/connection/close'

export default (app) => {
  //...
  app.use(rdbOpenConnection)
  app.use(routes.routes(), routes.allowedMethods())
  app.use(rdbCloseConnection)
}

在这里,你可以看到open连接中间件在所有模块路由之前注册,而close连接中间件则在最后注册,这样它们分别被首先和最后调用。

  1. 在接下来的步骤中,我们将使用以下模板代码与 Koa 路由器和 RethinkDB 客户端驱动进行 CRUD 操作。例如,以下代码显示了我们如何将模板代码应用于从user模块中的user表中获取所有用户的操作:
// modules/user/_routes/index.js
import Router from 'koa-router'
import rdb from 'rethinkdb'

const router = new Router()
router.get('/', async (ctx, next) => {
  try {
    // perform verification on the incoming parameters...
    // perform a CRUD operation:
    let result = await rdb.table('user')
      .run(ctx._rdbConn)

    ctx.type = 'json'
    ctx.body = result
    await next()

  } catch (err) {
    ctx.throw(500, err)
  }
})
export default router

让我们来看看这段代码,了解它的作用。在这里,你可以看到我们在应用中使用了自定义的顶级命名空间rdb,用于 RethinkDB 客户端驱动,而不是你在localhost:8080上练习过的r命名空间。此外,在我们的应用中使用 RethinkDB 客户端驱动时,我们必须始终在 ReQL 命令的末尾调用run方法,以及 RethinkDB 服务器连接,以构造查询并将其传递到服务器执行。

此外,我们必须在代码的末尾调用next方法,以便将应用的执行传递给下一个中间件,特别是用于关闭 RethinkDB 连接的close连接中间件。在执行任何 CRUD 操作之前,我们应该对来自客户端的传入参数和数据进行检查。然后,我们应该将我们的代码包装在try-catch块中,以捕获和抛出任何潜在的错误。

请注意,在接下来的步骤中,我们将跳过编写参数验证和 try-catch 语句的代码,以避免冗长和重复的代码行和代码块,但你应该在实际代码中包含它们。

  1. user模块的/_routes/文件夹中创建一个名为create-user.js的文件,其中包含以下代码,用于将新用户注入到数据库中的user表中:
// modules/user/_routes/create-user.js
router.post('/user', async (ctx, next) => {
  let result = await rdb.table('user')
    .insert(document, {returnChanges: true})
    .run(ctx._rdbConn)

  if (result.inserted !== 1) {
    ctx.throw(404, 'insert user failed')
  }

  ctx.type = 'json'
  ctx.body = result
  await next()
})

如果插入失败,我们应该抛出错误,并将错误消息传递给 Koa 的throw方法,以便我们可以在前端使用try-catch块捕获它们并显示出来。

  1. user模块的/_routes/文件夹中创建一个名为fetch-user.js的文件,用于通过使用slug键从user表中获取特定用户,如下所示:
// modules/user/_routes/fetch-user.js
router.get('/:slug', async (ctx, next) => {
  const slug = ctx.params.slug
  let user = await rdb.table('user')
    .filter(searchQuery)
    .nth(0)
    .default(null)
    .run(ctx._rdbConn)

  if (!user) {
    ctx.throw(404, 'user not found')
  }

  ctx.type = 'json'
  ctx.body = user
  await next()
})

我们在查询中添加了nth命令,以显示文档的位置。在我们的情况下,我们只想获取第一个文档,因此我们将一个0整数传递给此方法。我们还添加了default命令,以便在user表中找不到用户时返回一个null异常。

  1. user模块的/_routes/文件夹中创建一个名为update-user.js的文件,用于通过使用文档 ID 更新user表中的现有用户,如下所示:
// modules/user/_routes/update-user.js
router.put('/user', async (ctx, next) => {
  let body = ctx.request.body || {}
  let objectId = body.id

  let timestamp = Date.now()
  let updateQuery = {
    name: body.name,
    slug: body.slug,
    updatedAt: timestamp
  }

  let result = await rdb.table('user')
    .get(objectId)
    .update(updateQuery, {returnChanges: true})
    .run(ctx._rdbConn)

  if (result.replaced !== 1) {
    ctx.throw(404, 'update user failed')
  }

  ctx.type = 'json'
  ctx.body = result
  await next()
})

我们在查询中添加了get命令,首先通过其 ID 获取特定文档,然后再运行更新。

  1. user模块的/_routes/文件夹中创建一个名为delete-user.js的文件,用于通过使用文档 ID 从user表中删除现有用户,如下所示:
// modules/user/_routes/delete-user.js
router.del('/user', async (ctx, next) => {
  let body = ctx.request.body || {}
  let objectId = body.id

  let result = await rdb.table('user')
    .get(objectId)
    .delete()
    .run(ctx._rdbConn)

  if (result.deleted !== 1) {
    ctx.throw(404, 'delete user failed')
  }

  ctx.type = 'json'
  ctx.body = result
  await next()
})
  1. 最后,在index.js文件中对刚刚在步骤 7中创建的用于列出user表中所有用户的 CRUD 操作进行重构,通过在查询中添加orderBy命令,该文件位于/_routes/文件夹中,如下所示:
// modules/user/_routes/index.js
router.get('/', async (ctx, next) => {
  let cursor = await rdb.table('user')
    .orderBy(rdb.desc('createdAt'))
    .run(ctx._rdbConn)

  let users = await cursor.toArray()

  ctx.type = 'json'
  ctx.body = users
  await next()
})

我们在查询中添加了orderBy命令,以便我们可以按创建日期降序(最新的在前)对文档进行排序。此外,RethinkDB 数据库返回的文档始终作为 CRUD 操作的回调包含在一个游标对象中,因此我们必须使用toArray命令来遍历游标并将对象转换为数组。

如果您想了解更多关于orderBytoArray命令,请访问rethinkdb.com/api/javascript/order_by/rethinkdb.com/api/javascript/to_array/

通过这样,您已成功在 API 中使用 RethinkDB 实现了 CRUD 操作。再次强调,这很容易且有趣,不是吗?但是我们仍然可以通过在 RethinkDB 数据库中强制执行模式来提高我们存储的文档的“质量”。我们将在下一部分学习如何做到这一点。

在 RethinkDB 中强制执行模式

就像 MongoDB 中的 BSON 数据库一样,RethinkDB 中的 JSON 数据库也是无模式的。这意味着数据库上没有蓝图,也没有强加在数据库上的公式或完整性约束。数据库的构造方式没有组织规则可能会引发数据库完整性的问题。同一张表(或 MongoDB 中的“集合”)中的某些文档可能包含不同和不需要的键,以及具有正确键的文档。您可能会错误地注入一些键,或者忘记注入所需的键和值。因此,如果您希望保持文档中的数据有组织,强制执行 JSON 或 BSON 数据库中的某种模式可能是一个好主意。RethinkDB(或 MongoDB)没有内部功能来强制执行模式,但我们可以使用 Node.js Lodash 模块创建自定义函数来强制执行一些基本模式。让我们探讨如何做到这一点:

  1. 通过 npm 安装 Lodash 模块:
$ npm i lodash
  1. /core/目录中创建一个utils.js文件,并导入lodash以创建一个名为sanitise的函数,如下所示:
// core/utils.js
import lodash from 'lodash'

function sanitise (options, schema) {
  let data = options || {}

  if (schema === undefined) {
    const err = new Error('Schema is required.')
    err.status = 400
    err.expose = true
    throw err
  }

  let keys = lodash.keys(schema)
  let defaults = lodash.defaults(data, schema)
  let picked = lodash.pick(defaults, keys)

  return picked
}
export { sanitise }

这个函数简单地选择您设置的默认键,并忽略任何不在“模式”中的额外键。

我们正在使用 Lodash 中的以下方法。有关每种方法的更多信息,请访问以下链接:lodash.com/docs/4.17.15#keys 获取keys方法的信息 lodash.com/docs/4.17.15#defaults 获取defaults方法的信息 lodash.com/docs/4.17.15#pick 获取pick方法的信息

  1. user模块中创建一个user模式,只接受以下键:
// modules/user/schema.js
export default {
  slug: null,
  name: null,
  createdAt: null,
  updatedAt: null
}
  1. 在要强制执行模式的路由中导入sanitise方法和前面的模式;例如,在create-user.js文件中:
// modules/user/_routes/create-user.js
let timestamp = Date.now()
let options = {
  name: body.name,
  slug: body.slug,
  createdAt: timestamp,
  username: 'marymoe',
  password: '123123'
}

let document = sanitise(options, schema)
let result = await rdb.table('user')
  .insert(document, {returnChanges: true})
  .run(ctx._rdbConn)

在上述代码中,示例字段usernamepassword在插入数据之前对数据进行清理时不会被注入到user表中的文档中。

您可以看到这个sanitise函数只执行简单的验证。如果您需要更复杂和高级的数据验证,可以使用 hapi web 框架的 Node.js joi 模块。

如果您想了解更多关于这个模块的信息,请访问hapi.dev/module/joi/

您接下来必须探索 RethinkDB 中的changefeeds。这是本章的主要目的 - 展示如何利用 RethinkDB 的实时功能创建实时应用程序。因此,让我们探索并玩转 RethinkDB 中的 changefeeds!

介绍 RethinkDB 中的 changefeeds

在使用 RethinkDB 客户端驱动程序在我们的应用程序中应用 changefeeds 之前,让我们再次在localhost:8080/#dataexplorer的管理 UI 中使用数据浏览器,实时在屏幕上查看实时 feeds:

  1. 粘贴以下的 ReQL 查询,并单击“运行”按钮:
r.db('nuxtdb').table('user').changes()

您应该在浏览器屏幕上看到以下信息:

Listening for events...
Waiting for more results
  1. 在浏览器上打开另一个标签,并将其指向localhost:8080/#dataexplorer。现在,您有两个数据浏览器。将其中一个从浏览器标签中拖出来,以便您可以将它们并排放置。然后,从其中一个数据浏览器中将新文档插入user表中:
r.db('nuxtdb').table('user').insert([
  { name: "Richard Roe", slug: "richard" },
  { name: "Marry Moe", slug: "marry" }
])

您应该得到以下结果:

{
  "deleted": 0,
  "errors": 0,
  "generated_keys": [
    "f7305c97-2bc9-4694-81ec-c5acaed1e757",
    "5862e1fa-e51c-4878-a16b-cb8c1f1d91de"
  ],
  "inserted": 2,
  "replaced": 0,
  "skipped": 0,
  "unchanged": 0
}

与此同时,您应该立即在另一个数据浏览器中看到以下 feeds 的实时显示:

{
  "new_val": {
    "id": "f7305c97-2bc9-4694-81ec-c5acaed1e757",
    "name": "Richard Roe",
    "slug": "richard"
  },
  "old_val": null
}

{
  "new_val": {
    "id": "5862e1fa-e51c-4878-a16b-cb8c1f1d91de",
    "name": "Marry Moe",
    "slug": "marry"
  },
  "old_val": null
}

万岁!您刚刚轻松地使用 RethinkDB 创建了实时 feeds!请注意,您将始终在每个实时 feed 中获得这两个键,new_valold_val。它们具有以下含义:

  • 如果您在new_val中获取数据,但在old_val中获取的是null,这意味着新文档被注入到数据库中。

  • 如果您在new_valold_val中都获取到数据,这意味着现有文档在数据库中已更新。

  • 如果您在old_val中获取数据,但在new_val中获取的是null,这意味着现有文档已从数据库中删除。

当我们在本章的最后一节中在 Nuxt 应用程序中使用它们时,您将可以使用这些键。因此,现在不要太担心它们。相反,下一个挑战是在 API 和 Nuxt 应用程序中实现它。为此,我们将需要另一个 Node.js 模块 - Socket.IO。因此,让我们探索一下这个模块如何帮助您实现这一目标。

介绍 Socket.IO

就像 HTTP 一样,WebSocket 是一种通信协议,但它提供了客户端和服务器之间的全双工(双向)通信。与 HTTP 不同,WebSocket 连接始终保持开放状态,用于实时数据传输。因此,在 WebSocket 应用程序中,服务器可以在没有客户端发起请求的情况下向客户端发送数据。

另外,与以 HTTP 或 HTTPS 开头的 HTTP 模式不同,WebSocket 协议模式以wswss开头,例如:

ws://example.com:4000

Socket.IO 是一个使用 WebSocket 协议和轮询作为创建实时 Web 应用的备用选项的 JavaScript 库。它支持任何平台、浏览器或设备,并处理服务器和客户端的所有降级,以实现实时的全双工通信。大多数浏览器现在都支持 WebSocket 协议,包括 Google Chrome、Microsoft Edge、Firefox、Safari 和 Opera。但是在使用 Socket.IO 时,我们必须同时使用其客户端和服务器端库。客户端库在浏览器内运行,而服务器端库在服务器端的 Node.js 应用程序上运行。因此,让我们在我们的应用程序中让这两个库一起工作。

如果您想了解更多关于 Socket.IO 的信息,请访问socket.io/

添加和使用 Socket.IO 服务器和客户端

我们将把 Socket.IO 服务器添加到我们在最近几节中构建的 API 中,然后最终将 Socket.IO 客户端添加到 Nuxt 应用程序中。但在将其添加到 Nuxt 应用程序之前,我们将其添加到一个简单的 HTML 页面中,以便我们可以全面了解 Socket.IO 服务器和 Socket.IO 客户端是如何一起工作的。让我们学习如何做到这一点:

  1. 通过 npm 安装 Socket.IO 服务器:
$ npm i socket.io
  1. 如果您还没有这样做,可以在/configs/目录中创建一个index.js文件来存储服务器设置:
// configs/index.js
export default {
  server: {
    port: 4000
  },
}

从这个简单的设置中,我们将在端口 4000 上提供我们的 API。

  1. 导入socket.io并将其绑定到 Node.js HTTP 对象,使用 Koa 的新实例创建一个新的 Socket.IO 实例,如下所示:
// backend/koa/public/index.js
import Koa from 'koa'
import socket from 'socket.io'
import http from 'http'
import config from 'Configs'
import middlewares from '../middlewares'

const app = new Koa()
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || config.server.port
middlewares(app)

const server = http.createServer(app.callback())
const io = socket(server)

io.sockets.on('connection', socket => {
  console.log('a user connected: ' + socket.id)
  socket.on('disconnect', () => {
    console.log('user disconnected: ' + socket.id)
  })
})
server.listen(port, host)

创建 Socket.IO 的新实例后,我们可以开始监听 Socket.IO 的connection事件,以接收来自socket回调的传入 socket。我们将传入的 socket 记录到控制台并附带其 ID。当 socket 断开连接时,我们还会记录传入 socket 的disconnect事件。最后,请注意,我们使用原生 Node.js HTTP 来启动和提供应用程序在localhost:4000上,而不是使用我们以前使用的 Koa 内部的 HTTP:

app.listen(4000)
  1. 创建一个socket-client.html页面,并通过 CDN 导入 Socket.IO 客户端。通过将localhost:4000作为特定 URL 传递来创建一个新的实例,如下所示:
// frontend/html/socket-client.html
<script src="https://cdn.jsdelivr.net/npm/socket.io-
 client@2/dist/socket.io.js"></script>

<script>
  var socket = io('http://localhost:4000/')
</script>

现在,如果你在浏览器上浏览这个 HTML 页面,或者当你刷新页面时,你应该看到控制台打印出带有 socket ID 的日志,如下所示:

a user connected: abeGnarBnELo33vQAAAB

当你关闭 HTML 页面时,你也应该看到控制台打印出带有 socket ID 的日志,如下所示:

user disconnected: abeGnarBnELo33vQAAAB

这就是连接 Socket.IO 服务器和客户端的全部操作。这非常简单易懂,不是吗?但我们在这里所做的只是连接和断开服务器和客户端。我们需要更多的功能——我们希望能够同时传输数据。为了做到这一点,我们只需要相互发射和接收事件,这将在接下来的步骤中完成。

如果你想使用 Socket.IO 客户端的本地版本,你可以将脚本标签的 URL 源指向/node_modules/socket.io-client/dist/socket.io.js

  1. 通过使用 Socket.IO 服务器的emit方法,从服务器创建一个发射事件,如下所示:
// backend/koa/public/index.js
io.sockets.on('connection', socket => {
  io.emit('emit.onserver', 'Hi client, what you up to?')
  console.log('Message to client: ' + socket.id)
})

在这里,你可以看到我们通过名为emit.onserver的自定义事件发射了一个简单的消息,并将活动记录到控制台中。请注意,我们只能在连接建立时发射事件。然后,我们可以在客户端监听这个自定义事件,并记录来自服务器的消息,如下所示:

// frontend/html/socket-client.html
socket.on('emit.onserver', function (message) {
  console.log('Message from server: ' + message)
})
  1. 所以,现在,如果你再次在浏览器上刷新页面,你应该看到控制台打印出带有 socket ID 的日志,如下所示:
Message to client: abeGnarBnELo33vQAAAB // server side
Message from server: Hi client, what you up to? // client side
  1. 通过使用 Socket.IO 客户端的emit方法,从客户端创建一个发射事件,如下所示:
// frontend/html/socket-client.html
<script
  src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
  integrity="sha256-pasqAKBDmFT4eHoN2ndd6lN370kFiGUFyTiUHWhU7k8="
  crossorigin="anonymous"></script>

<button class="button-sent">Send</button>

$('.button-sent').click(function(e){
  e.preventDefault()

  var message = 'Hi server, how are you holding up?'
  socket.emit('emit.onclient', message)
  console.log('Message sent to server.')

  return false
})

在这里,你可以看到,首先,我们通过 CDN 安装 jQuery,并创建一个带有 jQuery click事件的<button>。其次,当按钮被点击时,我们发射名为emit.onclient的 Socket.IO 自定义事件,并附带一个简单的消息。最后,我们将活动记录到控制台中。

  1. 之后,我们可以在服务器端监听 Socket.IO 自定义事件,并记录来自客户端的消息,如下所示:
// backend/koa/public/index.js
socket.on('emit.onclient', (message) => {
  console.log('Message from client, '+ socket.id + ' :' + message);
})
  1. 如果你再次在浏览器上刷新页面,你应该看到控制台打印出日志,以及 socket ID,如下所示:
Message sent to server. // client side
Message from client, abeGnarBnELo33vQAAAB: Hi server, 
how are you holding up? // server side

现在你知道如何通过 Socket.IO 实时传输数据——只需发射自定义事件并监听它们。你接下来应该了解的是如何将 Socket.IO 与 RethinkDB 的 changefeeds 集成,以便将实时数据从数据库传输到客户端。所以,请继续阅读!

集成 Socket.IO 服务器和 RethinkDB changefeeds

请记住,您之前曾在localhost:8080/#dataexplorer的管理 UI 中再次使用 Data Explorer 调整 RethinkDB changefeeds。要订阅 changefeed,您只需将 ReQL 的changes命令链接到查询,如下所示:

r.db('nuxtdb').table('user').changes()

RethinkDB changefeeds 包含从 RethinkDB 数据库发出的实时数据,这意味着我们需要在服务器端使用 Socket.IO 服务器捕获这些 feed,并将它们发出到客户端。因此,让我们学习如何通过重构我们在本章中一直在开发的 API 来捕获它们:

  1. 通过 npm 将 Socket.IO 服务器安装到您的 API 中:
$ npm i socket.io
  1. /core/目录中的changefeeds.js文件中创建一个异步匿名箭头函数,代码如下:
// core/database/rethinkdb/changefeeds.js
import rdb from 'rethinkdb'
import rdbConnection from './connection'

export default async (io, tableName, eventName) => {
  try {
    const connection = await rdbConnection()
    var cursor = await rdb.table(tableName)
      .changes()
      .run(connection)

    cursor.each(function (err, row) {
      if (err) {
        throw err
      }
      io.emit(eventName, row)
    })
  } catch( err ) {
    console.error(err);
  }
}

在此函数中,我们将rethinkdb导入为rdb,将我们的 RethinkDB 数据库连接导入为rdbConnection,然后将以下项目用作此函数的参数:

  • Socket.IO 服务器的实例

  • 您将要使用的 Socket.IO 发出的自定义事件名称

  • 您要订阅其 changefeed 的 RethinkDB 表名

changefeed 将以回调的形式将文档返回为游标对象,因此我们通过游标对象进行迭代,并使用自定义事件名称发出每个文档的行。

  1. 在应用程序根目录的/public/目录中将changefeeds函数导入为rdbChangeFeeds,并将其与index.js文件中的其余现有代码集成,如下所示:
// public/index.js
import Koa from 'koa'
import socket from 'socket.io'
import http from 'http'
import config from 'Configs'
import middlewares from '../middlewares'
import rdbChangeFeeds from 'Core/database/rethinkdb/changefeeds'

const app = new Koa()
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || config.server.port
middlewares(app)

const server = http.createServer(app.callback())
const io = socket(server)
io.sockets.on('connection', socket => {
  //...
})

rdbChangeFeeds(io, 'user', 'user.changefeeds')
server.listen(port, host)

在上述代码中,我们要订阅的表名是user,我们要调用的发出事件名称是user.changefeeds。因此,我们将它们传递给rdbChangeFeeds函数,并使用socket.io实例。这就是您一次性全局集成 Socket.IO 和 RethinkDB 所需做的一切。

干得好!您已成功在服务器端集成了 Koa、RethinkDB 和 Socket.IO,并创建了一个实时 API。但是客户端怎么样,我们如何监听从 API 发出的事件?我们将在下一节中找出答案。

将 Socket.IO 与 Nuxt 集成

我们要构建的 Nuxt 应用程序与上一章中的应用程序非常相似,在那里我们有一个包含以下 CRUD 页面的/users/目录,该目录位于/pages/目录中,用于添加、更新、列出和删除用户:

users
├── index.vue
├── _slug.vue
├── add
│ └── index.vue
├── update
│ └── _slug.vue
└── delete
    └── _slug.vue

您可以从上一章复制这些文件。这个应用程序中唯一的主要变化和不同之处在于<script>块,我们将通过监听来自 Socket.IO 服务器的 emit 事件实时列出用户。为此,我们需要使用 Socket.IO 客户端,这是您在添加和使用 Socket.IO 服务器和客户端部分学到的,该部分使用简单的 HTML 页面。因此,让我们看看如何将我们已经知道的内容实现到 Nuxt 应用中:

  1. 通过 npm 将 Socket.IO 客户端安装到您的 Nuxt 项目中:
$ npm i socket.io-client
  1. 在 Nuxt 配置文件中创建以下变量,以便稍后重用应用的协议、主机名和跨域端口:
// nuxt.config.js
const protocol = 'http'
const host = process.env.NODE_ENV === 'production' ? 'a-cool-domain-name.com' : 'localhost'

const ports = {
  local: '8000',
  remote: '4000'
}

const remoteUrl = protocol + '://' + host + ':' + ports.remote + '/'

这些变量适用于以下情况:

  • host变量用于在 Nuxt 应用处于生产环境时获取a-cool-domain-name.com的值;也就是说,当您使用npm run start运行应用时。否则,它只会将localhost作为默认值。

  • ports变量中的local键用于为 Nuxt 应用设置服务器端口,设置为8000。请记住,Nuxt 提供应用的默认端口是3000

  • ports变量中的remote键用于告诉 Nuxt 应用 API 所在的服务器端口,即4000

  • remoteUrl变量用于将 API 与前面的变量连接起来。

  1. 将前述变量应用于 Nuxt 配置文件中的envserver选项,如下所示:
// nuxt.config.js
export default {
  env: {
    remoteUrl
  },
  server: {
    port: ports.local,
    host: host
  }
}

因此,通过这种配置,我们可以通过以下方法再次访问remoteUrl变量:

  • process.env.remoteUrl

  • context.env.remoteUrl

此外,在这个配置中,我们已将 Nuxt 应用的默认服务器端口更改为8000,在server选项中。默认端口是3000,默认主机是localhost。但是您可能出于某种原因想要使用不同的端口。这就是为什么我们在这里看如何更改它们。

如果您想了解更多关于server配置和其他选项(如timinghttps)的信息,请访问nuxtjs.org/api/configuration-server

如果您想了解更多关于env配置的信息,请访问nuxtjs.org/api/configuration-envthe-env-property

  1. 安装 Nuxt Axios 和 Proxy 模块,并在 Nuxt 配置文件中进行配置,如下所示:
// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/axios'
  ],

  axios: {
    proxy: true
  },

  proxy: {
    '/api/': {
      target: remoteUrl,
      pathRewrite: {'^/api/': ''}
    }
  }
}

请注意,我们在proxy选项中重用了remoteUrl变量。因此,我们发出的每个以/api/开头的 API 请求都将转换为http://localhost:4000/api/。但由于我们在 API 的路由中没有/api/,在将其发送到 API 之前,我们使用pathRewrite选项从请求 URL 中删除这个/api/部分。

  1. /plugin/目录中创建一个插件,用于抽象 Socket.IO 客户端的实例,以便我们可以在任何地方重用它:
// plugins/socket.io.js
import io from 'socket.io-client'

const remoteUrl = process.env.remoteUrl
const socket = io(remoteUrl)

export default socket

请注意,我们通过process.env.remoteUrl重用了remoteUrl变量在 Socket.IO 客户端实例中。这意味着 Socket.IO 客户端将在localhost:4000调用 Socket.IO 服务器。

  1. socket.io客户端插件导入到<script>块中,并使用@nuxtjs/axios模块在index文件中获取用户列表。此索引文件位于/users/目录下的pages中:
// pages/users/index.vue
import socket from '~/plugins/socket.io'

export default {
  async asyncData ({ error, $axios }) {
    try {
      let { data } = await $axios.get('/api/users')
      return { users: data.data }
    } catch (err) {
      // Handle the error.
    }
  }
}
  1. 使用asyncData方法获取并设置用户后,使用 Socket.IO 插件在mounted方法中监听user.changefeeds事件,以获取来自服务器的任何新的实时反馈,如下所示:
// pages/users/index.vue
export default {
  async asyncData ({ error, $axios }) {
    //...
  },
  mounted () {
    socket.on('user.changefeeds', data => {
      if (data.new_val === undefined && data.old_val === undefined) {
        return
      }
      //...
    })
  }
}

在这里,您可以看到我们始终检查data回调,以确保传入的反馈中new_valold_val被定义。换句话说,在继续下一行之前,我们希望确保这两个键始终出现在反馈中。

  1. 检查后,如果我们在new_val键中收到数据,但old_val键为空,这意味着新用户已添加到服务器。如果我们从服务器端获取新的反馈,我们将使用 JavaScript 的unshift函数将新用户数据添加到user数组的顶部,如下所示:
// pages/users/index.vue
mounted () {
  //...
  if(data.old_val === null && data.new_val !== null) {
    this.users.unshift(data.new_val)
  }
}

然后,如果我们在old_val键中收到数据,但new_val键为空,这意味着现有用户已从服务器中删除。因此,要从数组中弹出现有用户,我们可以使用 JavaScript 的splice函数,通过其索引(在数组中的位置/位置)来删除。但首先,我们必须使用 JavaScript 的map函数按其 ID 找到用户的索引,如下所示:

// pages/users/index.vue
mounted () {
  //...
  if(data.new_val === null && data.old_val !== null) {
    var id = data.old_val.id
    var index = this.users.map(el => {
      return el.id
    }).indexOf(id)
    this.users.splice(index, 1)
  }
}

最后,如果我们在new_valold_val键中都收到数据,这意味着当前用户已更新。因此,如果用户已更新,我们必须首先找到数组中用户的索引,然后使用 JavaScript 的splice函数替换它,如下所示:

// pages/users/index.vue
mounted () {
  //...
  if(data.new_val !== null && data.old_val !== null) {
    var id = data.new_val.id
    var index = this.users.findIndex(item => item.id === id)
    this.users.splice(index, 1, data.new_val)
  }
}

请注意,我们使用 JavaScript 的findIndex函数作为map函数的另一种替代方法。

如果您想了解更多关于我们在这里使用的用于操作 JavaScript 数组的 JavaScript 标准内置函数的信息,请访问以下链接:

  1. 将以下模板添加到<template>块中以显示用户,如下所示:
// pages/users/index.vue
<div>
  <h1>Users</h1>
  <ul>
    <li v-for="user in users" v-bind:key="user.uuid">
      <nuxt-link :to="'/users/' + user.slug">
        {{ user.name }}
      </nuxt-link>
    </li>
  </ul>
  <nuxt-link to="/users/add">
    Add New
  </nuxt-link>
</div>

在此模板中,您可以看到我们只是简单地使用v-forasyncData方法获取的用户数据,并将用户uuid绑定到每个循环的元素上。之后,发生在mounted方法中的任何实时反馈都将以响应式方式更新用户数据和模板。

  1. 使用npm run dev运行 Nuxt 应用程序。您应该在终端上看到以下信息:
Listening on: http://localhost:8000/
  1. 在浏览器中打开两个标签并排,或者在两个不同的浏览器中并排,并将它们指向localhost:8000/users。从其中一个标签(或浏览器)在localhost:8000/users/add添加一个新用户。您应该看到新添加的用户立即并同时显示在所有标签(或浏览器)上,而无需刷新它们。

您可以在本书的 GitHub 存储库中的/chapter-17/frontend//chapter-17/backend/中找到本章中的所有代码和应用程序。

干得好 - 您成功了!我们希望您觉得这个应用程序有趣且易于操作,并且它能激励您进一步探索您迄今为止所学到的知识。让我们总结一下本章学到的内容。

摘要

在本章中,您成功安装并使用 RethinkDB 和 Socket.IO,将普通的后端 API 和前端 Nuxt 应用程序转变为实时应用程序。您学会了如何通过 RethinkDB 管理 UI 在服务器端创建、读取、更新和删除 JSON 数据,然后使用 Koa 与 RethinkDB 客户端驱动程序。最重要的是,您学会了如何通过 RethinkDB 管理 UI 操作 RethinkDB 中的实时订阅,称为 changefeeds,然后在服务器端将其与 Socket.IO 服务器和 Koa 集成。此外,您使用 Socket.IO 服务器发出自定义事件的数据,并使用 Socket.IO 客户端在 Nuxt 应用程序的客户端端实时监听事件并捕获数据。这难道不是一次有趣的旅程吗?

在下一章中,我们将通过第三方 API、内容管理系统(CMS)和 GraphQL 进一步了解 Nuxt。您将了解 WordPress API、Keystone 和 GraphQL。然后,您将学习如何创建自定义内容类型和自定义路由,以扩展 WordPress API,以便将其与 Nuxt 集成,并从 WordPress 项目中流式传输远程图像。您将使用 Keystone 开发自定义 CMS,安装和保护用于 Keystone 应用程序开发的 PostgreSQL,以及保护 MongoDB,您将学习如何在第九章中安装的内容,即添加服务器端数据库。最重要且令人兴奋的是,您将学习 REST API 和 GraphQL API 之间的区别;使用 GraphQL.js、Express 和 Apollo Server 构建 GraphQL API;了解 GraphQL 模式及其解析器;使用 Keystone GraphQL API;然后将它们与 Nuxt 集成。这绝对会是另一次有趣的旅程,所以系好安全带,准备好!

使用 CMS 和 GraphQL 创建 Nuxt 应用

在前几章中,您一直在从头开始创建 API,以便它们与 Nuxt 应用一起工作。构建个性化的 API 可能是有益的和令人满足的,但它可能并不适合每种情况。从底层构建 API 是耗时的。在本章中,我们将探索可以为我们提供所需 API 服务的第三方系统,而无需从头开始构建它们。理想情况下,我们希望使用一个可以帮助我们管理内容的系统 - 内容管理系统CMS)。

WordPress 和 Drupal 是流行的 CMS。它们都包含值得研究的 API。在本书中,我们将使用WordPress。除了 WordPress 这样的 CMS,我们还将研究无头 CMS。无头 CMS 就像 WordPress 一样,但是是一个纯粹的 API 服务,没有前端呈现,这可以在 Nuxt 中完成,就像我们在整本书中一直在做的那样。Keystone将是我们在本书中探索的无头 CMS。然而,WordPress API 和 Keystone API 是两种不同类型的 API。具体来说,前者是REST API,而后者是GraphQL API。但它们是什么?简而言之,REST API 是使用 HTTP 请求来GETPUTPOSTDELETE数据的 API。您在前几章中创建的 API 都是 REST API。GraphQL 是实现 GraphQL 规范(技术标准)的 API。

GraphQL API 是 REST API 的一种替代方案。为了演示如何使用这两种不同类型的 API 来实现相同的结果,我们将使用我们在第四章中提供的示例 Nuxt 应用网站,添加视图、路由和过渡。这可以在本书的 GitHub 存储库中的/chapter-4/nuxt-universal/sample-website/中找到。我们将重构现有页面(主页、关于、项目、内容和项目子页面),这些页面包括文本和图片(特色图片、全屏图片和单独的项目图片)。我们还将通过从 API 获取数据而不是硬编码来重构导航,就像我们在前几章中为其他 Nuxt 应用所做的那样。通过 CMS,我们可以通过 API 动态获取导航数据,无论是 REST 还是 GraphQL API。

此外,我们将使用这些 CMS 生成静态 Nuxt 页面(您在第十四章中了解了这些内容,使用 Linter、格式化程序和部署命令,以及第十五章中,使用 Nuxt 创建 SPA)。因此,到本章结束时,您将对本书中学到的内容有一个完整而最终的了解。

在本章中,我们将涵盖以下主题:

  • 在 WordPress 中创建无头 REST API

  • 介绍 Keystone

  • 介绍 GraphQL

  • 集成 Keystone、GraphQL 和 Nuxt

让我们开始研究 WordPress REST API。

第十八章:在 WordPress 中创建无头 REST API

WordPress(WordPress.org)是一个用于通用网站开发的开源 PHP CMS。它默认情况下不是“无头”的;它堆叠了一个模板系统。这意味着视图和数据是交织在一起的。然而,自 2015 年以来(WordPress 4.4),REST API 基础设施已经集成到 WordPress 核心中供开发人员使用,现在如果您在基于网站的 URL 后附加/wp-json/,则可以访问所有默认端点。您还可以扩展 WordPress REST API 并添加自己的自定义端点。因此,我们可以通过忽略视图轻松地将 WordPress 用作“无头”REST API。您将在接下来的章节中了解如何实现这一点。为了加快开发过程,我们将安装以下 WordPress 插件:

如果您不想使用任何这些,您可以创建自己的插件和元框。请查看如何在developer.wordpress.org/plugins/metadata/custom-meta-boxes/创建自定义元框。还可以查看如何在developer.wordpress.org/plugins/intro/开发自定义插件。

有关 WordPress REST API 的更多信息,请访问developer.wordpress.org/rest-api/

要使用这些插件或您自己的插件开发和扩展 WordPress REST API,首先需要下载 WordPress 并在您的计算机上安装该程序。我们将在下一节中学习如何做到这一点。

安装 WordPress 并创建我们的第一个页面

我们可以通过几种方式安装和提供 WordPress:

  • 通过解压下载的 WordPress .zip文件并从目录中安装它

  • 使用 WordPress CLI(make.wordpress.org/cli/handbook/wp-cli.org/

  • 通过使用 Apache 设置端口(可能有点麻烦)

  • 通过使用内置的 PHP 服务器

在本书中,我们将使用内置的 PHP 服务器,因为这是启动 WordPress 的最简单方式,并且如果需要的话,将来移动它会更容易,只要它在同一个端口上提供服务;例如,localhost:4000。因此,让我们找出如何做到这一点:

  1. 创建一个目录(也使其可写),并在其中下载并解压 WordPress。您可以从wordpress.org/下载 WordPress。您应该在解压后的 WordPress 目录中看到一些带有/wp-admin//wp-content//wp-includes/目录的.php文件。

  2. 通过 PHP Adminer 创建一个 MySQL 数据库(例如,nuxt-wordpress)。

  3. 转到目录并使用内置的 PHP 提供 WordPress,如下所示:

$ php -S localhost:4000
  1. 将浏览器指向localhost:4000,并使用所需的 MySQL 凭据(数据库名称,用户名和密码)以及您的 WordPress 用户帐户信息(用户名,密码和电子邮件地址)安装 WordPress。

  2. 使用您的用户凭据登录到localhost:4000/wp-admin/的 WordPress 管理 UI,并在“页面”标签下创建一些主要页面(主页,关于,项目,联系)。

  3. 外观下的菜单导航到菜单,通过将menu-main添加到菜单名称输入字段来创建站点导航。

  4. 选择所有出现在“添加菜单项”下的页面(联系人、关于、项目、主页),然后点击“添加到菜单”将它们添加到menu-main作为导航项。您可以拖动和排序这些项目,使它们按照这个顺序排列:主页、关于、项目、联系人。然后,点击“保存菜单”按钮。

  5. (可选)将 WordPress 永久链接从普通选项更改为自定义结构(例如/%postname%/)在永久链接下的设置中。

  6. 下载我们之前提到的插件并解压缩它们到/plugins/目录。这可以在/wp-content/目录中找到。然后通过管理界面激活它们。

如果您检查nuxt-wordpress数据库中的wp_options表,您应该看到端口4000已成功记录在siteurlhome字段中。因此,从现在开始,只要在此端口上使用内置的 PHP 服务器运行,您可以将 WordPress 项目目录移动到任何您喜欢的地方。

虽然我们在 WordPress 中有主页面和导航的数据,但我们仍然需要“项目”页面的子页面数据。我们可以将它们添加到“页面”标签,然后将它们附加到“项目”页面。但是这些页面将共享相同的内容类型(在 WordPress 中称为文章类型)-“页面”文章类型。最好将它们组织在一个单独的文章类型中,以便更容易管理。我们将在下一节中了解如何在 WordPress 中创建自定义文章类型。

有关 WordPress 安装过程的更多详细信息,请访问wordpress.org/support/article/how-to-install-wordpress/

在 WordPress 中创建自定义文章类型

我们可以从任何 WordPress 主题的functions.php文件中创建自定义文章类型。但是,由于我们不打算使用 WordPress 模板系统来提供我们内容的视图,我们可以从 WordPress 提供的默认主题中扩展一个子主题。然后,在外观下的主题中激活子主题。我们将使用“Twenty Nineteen”主题来扩展我们的子主题,然后从那里创建自定义文章类型。让我们开始吧:

  1. /themes/目录中创建一个名为twentynineteen-child的目录,并创建一个包含以下内容的style.css文件:
// wp-content/themes/twentynineteen-child/style.css
/*
 Theme Name: Twenty Nineteen Child
 Template: twentynineteen
 Text Domain: twentynineteenchild
*/

@import url("../twentynineteen/style.css");

Theme NameTemplateText Domain是扩展主题的最低要求的头部注释,然后导入其父级的style.css文件。这些头部注释必须放在文件顶部。

如果您想在这个子主题中包含更多的头部注释,请访问developer.wordpress.org/themes/advanced-topics/child-themes/

  1. /twentynineteen-child/目录中创建一个functions.php文件,并使用以下格式和 WordPress 的register_post_type函数创建自定义文章类型,方法如下:
// wp-content/themes/twentynineteen-child/functions.php
function create_something () {
    register_post_type('<name>', <args>);
}
add_action('init', 'create_something');

因此,要添加我们的自定义文章类型,只需将project作为类型名称,并提供一些参数:

// wp-content/themes/twentynineteen-child/functions.php
function create_project_post_type () {
    register_post_type('project', $args);
}
add_action('init', 'create_project_post_type');

我们可以向自定义文章类型 UI 添加标签和我们想要支持的内容字段,方法如下:

$args = [
    'labels' => [
        'name' => __('Project (Pages)'),
        'singular_name' => __('Project'),
        'all_items' => 'All Projects'
    ],
    //...
    'supports' => ['title', 'editor', 'thumbnail', 'page-attributes'],
];

有关register_post_type函数的更多信息,请访问developer.wordpress.org/reference/functions/register_post_type/

有关自定义文章类型 UI 的更多信息,请访问wordpress.org/plugins/custom-post-type-ui/

  1. (可选)我们还可以为这种自定义文章类型添加对categorytag的支持,方法如下:
'taxonomies' => [
    'category',
    'post_tag'
],

然而,这些是全局的类别和标签实例,这意味着它们与其他文章类型(如PagePost文章类型)共享。因此,如果您想为Project文章类型指定特定的类别,只需使用以下代码:

// wp-content/themes/twentynineteen-child/functions.php
add_action('init', 'create_project_categories');
function create_project_categories() {
    $args = [
        'label' => __('Categories'),
        'has_archive' => true,
        'hierarchical' => true,
        'rewrite' => [
            'slug' => 'project',
            'with_front' => false
        ],
    ];
    $postTypes = ['project'];
    $taxonomy = 'project-category';
    register_taxonomy($taxonomy, $postTypes, $args);
}

有关注册分类法的更多信息,请访问developer.wordpress.org/reference/functions/register_taxonomy/

  1. (可选)如果您发现难以使用,可能会完全禁用 Gutenberg 对所有文章类型:
// wp-content/themes/twentynineteen-child/functions.php
add_filter('use_block_editor_for_post', '__return_false', 10);
add_filter('use_block_editor_for_post_type', '__return_false', 10);
  1. 在 WordPress 管理界面中激活子主题,并开始向“项目”标签添加project类型页面。

您会注意到,您可以使用的内容字段(titleeditorthumbnailpage-attributes)非常有限,用于向项目页面添加内容。我们需要更多特定的内容字段,例如用于添加多个项目图片和全屏图片的内容字段。这与我们在home页面上遇到的问题相同,因为我们需要另一个内容字段,以便我们也可以添加多个幻灯片图片。要添加更多这些内容字段,我们将需要自定义元框。您可以使用 ACF 插件或创建自己的自定义元框并将其包含在functions.php文件中,或者将其创建为插件。或者,您可以使用另一个不同的元框插件,如 Meta Box (metabox.io/)。这完全取决于您。

一旦您创建了自定义内容字段并向每个项目页面添加了所需的内容,您可以扩展 WordPress REST API 以用于项目页面、主页面和导航。我们将在下一节中学习如何做到这一点。

扩展 WordPress REST API

WordPress REST API 可以通过/wp-json/访问,并且是附加到基于站点的 URL 的入口路由。例如,您可以通过将浏览器指向localhost:4000/wp-json/来查看所有其他可用的路由。您将看到每个路由中可用的端点,因为这些可以是 GET 或 POST 端点。例如,/wp-json/wp/v2/pages路由具有用于列出页面的 GET 端点和用于创建页面的 POST 端点。您可以在developer.wordpress.org/rest-api/reference/找到有关这些默认路由和端点的更多信息。

然而,如果您有自定义的文章类型和自定义内容字段,那么您将需要自定义路由和端点。我们可以通过在functions.php文件中使用register_rest_route函数注册它们来创建这些的自定义版本,如下所示:

add_action('rest_api_init', function () { , and then followed by the available endpoint
    $args = [
        'methods' => 'GET',
        'callback' => '<do_something>',
    ];
    register_rest_route(<namespace>, <route>, $args);
});

让我们学习如何扩展 WordPress REST API:

  1. 创建用于获取导航和单个页面的全局命名空间和端点:
// wp-content/themes/twentynineteen-child/functions.php
$namespace = 'api/v1/';

add_action('rest_api_init', function () use ($namespace) {
    $route = 'menu';
    $args = [
        'methods' => 'GET',
        'callback' => 'fetch_menu',
    ];
    register_rest_route($namespace, $route, $args);
});

add_action('rest_api_init', function () use ($namespace) {
    $route = 'page/(?P<slug>[a-zA-Z0-9-]+)';
    $args = [
        'methods' => 'GET',
        'callback' => 'fetch_page',
    ];
    register_rest_route($namespace, $route, $args);
});

请注意,我们通过在匿名函数中使用 PHP 的use关键字将全局命名空间传递给每个add_action块。有关 PHP 的use关键字和匿名函数的更多信息,请访问www.php.net/manual/en/functions.anonymous.php

有关 WordPress 的register_rest_route函数的更多信息,请访问developer.wordpress.org/reference/functions/register_rest_route/

  1. 为获取单个项目页面和列出项目页面创建端点:
// wp-content/themes/twentynineteen-child/functions.php
add_action('rest_api_init', function () use ($namespace) {
    $route = 'project/(?P<slug>[a-zA-Z0-9-]+)';
    $args = [
        'methods' => 'GET',
        'callback' => 'fetch_project',
    ];
    register_rest_route($namespace, $route, $args);
});

add_action('rest_api_init', function () use ($namespace) {
    $route = 'projects/(?P<page_number>\d+)';
    $args = [
        'methods' => 'GET',
        'callback' => 'fetch_projects',
    ];
    register_rest_route($namespace, $route, $args);
});
  1. 创建一个fetch_menu函数来获取menu-main导航项:
// wp-content/themes/twentynineteen-child/functions.php
function fetch_menu ($data) {
    $menu_items = wp_get_nav_menu_items('menu-main');

    if (empty($menu_items)) {
        return [];
    }

    return $menu_items;
}

我们使用 WordPress 的wp_get_nav_menu_items函数来帮助我们获取导航。

有关wp_get_nav_menu_items函数的更多信息,请访问developer.wordpress.org/reference/functions/wp_get_nav_menu_items/

  1. 创建一个fetch_page函数来按 slug(或路径)获取页面:
// wp-content/themes/twentynineteen-child/functions.php
function fetch_page ($data) {
    $post = get_page_by_path($data['slug'], OBJECT, 'page');

    if (!count((array)$post)) {
        return [];
    }
    $post->slides = get_field('slide_items', $post->ID);

    return $post;
}

在这里,我们使用 WordPress 的get_page_by_path函数来获取页面。有关此函数的更多信息,请访问developer.wordpress.org/reference/functions/get_page_by_path/

我们还使用 ACF 插件的get_field函数来获取附加到页面的幻灯片图片列表,然后将它们作为slides推送到$post对象中。有关此函数的更多信息,请访问www.advancedcustomfields.com/resources/get_field/

  1. 创建一个fetch_project函数来获取单个项目页面:
// wp-content/themes/twentynineteen-child/functions.php
function fetch_project ($data) {
    $post = get_page_by_path($data['slug'], OBJECT, 'project');

    if (!count((array)$post)) {
        return [];
    }
    $post->fullscreen = get_field('full_screen_image', $post->ID);
    $post->images = get_field('image_items', $post->ID);

    return $post;
}

同样,我们使用 WordPress 的get_page_by_path函数来为我们获取页面,并使用 ACF 的get_field函数来获取附加到项目页面的图片(全屏图片和项目图片),然后将它们作为fullscreenimages推送到$post对象中。

  1. 创建一个fetch_projects函数来获取项目页面列表,每页 6 个项目:
// wp-content/themes/twentynineteen-child/functions.php
function fetch_projects ($data) {
    $paged = $data['page_number'] ? $data['page_number'] : 1;
    $posts_per_page = 6;
    $post_type = 'project';
    $args = [
        'post_type' => $post_type,
        'post_status' => ['publish'],
        'posts_per_page' => $posts_per_page,
        'paged' => $paged,
        'orderby' => 'date'
    ];
    $posts = get_posts($args);

    if (empty($posts)) {
        return [];
    }

    foreach ($posts as &$post) {
        $post->featured_image = get_the_post_thumbnail_url($post->ID);
    }
    return $posts;
}

在这里,我们使用 WordPress 的get_posts函数以所需的参数来获取列表。有关此函数的更多信息,请访问developer.wordpress.org/reference/functions/get_posts/

然后,我们循环每个项目页面,并将它们的特色图片推送到 WordPress 的get_the_post_thumbnail_url函数中。有关此函数的更多信息,请访问developer.wordpress.org/reference/functions/get_the_post_thumbnail_url/

  1. 我们还需要计算数据(上一页编号和下一页编号),以便为项目页面进行分页,因此,不仅返回$posts,还要将其作为以下数组中的items返回,并包含分页数据:
$total = wp_count_posts($post_type);
$total_max_pages = ceil($total->publish / $posts_per_page);

return [
    'items' => $posts,
    'total_pages' => $total_max_pages,
    'current_page' => (int)$paged,
    'next_page' => (int)$paged === (int)$total_max_pages ? null :
     $paged + 1,
    'prev_page' => (int) $paged === 1 ? null : $paged - 1,
];

在这里,我们使用wp_count_posts函数来计算总发布的项目页面数。有关此功能的更多信息,请访问developer.wordpress.org/reference/functions/wp_count_posts/

  1. 登录 WordPress 管理界面,转到工具下的重写规则,并单击刷新规则按钮以刷新 WordPress 重写规则。

  2. 转到浏览器,测试您刚刚创建的自定义 API 路由:

/wp-json/api/v1/menu
/wp-json/api/v1/page/<slug>
/wp-json/api/v1/projects/<number>
/wp-json/api/v1/project/<slug>

您应该在浏览器屏幕上看到一堆 JSON 原始数据。JSON 原始数据可能很难阅读,但您可以使用JSONLint,一个 JSON 验证器,对您的数据进行漂亮的打印,网址是jsonlint.com/。或者,您也可以使用Firefox,它有漂亮打印数据的选项。

您可以在本书的 GitHub 存储库中的/chapter-18/cross-domain/backend/wordpress/中找到此代码的全部内容。您也可以在其中找到一个示例数据库(nuxt-wordpress.sql)。用于登录 WordPress 管理界面的此示例数据库的默认用户名密码admin

干得好!您已成功扩展了 WordPress REST API,以便支持自定义文章类型。我们不需要在 WordPress 中开发任何新主题来查看我们的内容,因为这将由 Nuxt 处理。我们可以保留 WordPress 的现有主题来预览内容。这意味着我们只是使用 WordPress 远程托管我们网站的内容,包括所有媒体文件(图片、视频等)。此外,我们可以使用 Nuxt 生成静态页面(就像我们在前几章中所做的那样),并从 WordPress 流式传输所有媒体文件到我们的 Nuxt 项目,以便我们可以在本地托管它们。我们将在下一节学习如何做到这一点。

使用 Nuxt 集成和从 WordPress 流式传输图像

将 Nuxt 与 WordPress REST API 集成类似于您在前几章中学习和创建跨域 API 集成时的操作。但是,在本节中,我们将改进我们用于加载图像的插件,通过从/assets/目录中要求它们来加载。但由于我们的图像是上传到 WordPress CMS 并保存在我们的 WordPress 项目的/uploads/目录中,因此我们需要重构我们的资产加载器插件,以便在那里找到图像时从/assets/目录中要求它们;否则,我们将从 WordPress 远程加载它们。让我们开始吧:

  1. 在 Nuxt 配置文件中为 Axios 实例设置remote URL,如下所示:
// nuxt.config.js
const protocol = 'http'
const host = process.env.NODE_ENV === 'production' ? 'your-domain.com' : 'localhost'
const ports = {
  local: '3000',
  remote: '4000'
}
const remoteUrl = protocol + '://' + host + ':' + ports.remote

module.exports = {
  env: {
    remoteUrl: remoteUrl,
  }
}
  1. 创建一个 Axios 实例,并将其直接注入到 Nuxt 上下文中作为$axios。还可以使用inject函数将此 Axios 实例添加到上下文中的app选项中:
// plugins/axios.js
import axios from 'axios'

let baseURL = process.env.remoteUrl
const api = axios.create({ baseURL })

export default (ctx, inject) => {
  ctx.$axios = api
  inject('axios', api)
}
  1. 重新设计资产加载器插件,如下所示:
// plugins/utils.js
import Vue from 'vue'

Vue.prototype.$loadAssetImage = src => {
  var array = src.split('/')
  var last = [...array].pop()
  if (process.server && process.env.streamRemoteResource === true) {
    var { streamResource } = require('~/assets/js/stream-resource')
    streamResource(src, last)
    return
  }

  try {
    return require('~/assets/images/' + last)
  } catch (e) {
    return src
  }
}

在这里,我们将图像 URL 字符串拆分为一个数组,从数组的最后一项中获取图像的文件名(例如my-image.jpg),并将其存储在last变量中。然后我们使用文件名(last)在本地获取图像。如果抛出错误,这意味着图像在/assets/目录中不存在,因此我们只是返回图像的 URL(src)。

然而,当我们的应用在服务器端运行且streamRemoteResource选项为true时,我们将使用streamResource函数从远程 URL 流式传输图像到/assets/目录。您将在接下来的步骤中了解如何创建此选项(就像remoteURL选项一样)。

  1. /assets/目录中创建一个带有streamResource函数的stream-resource.js文件,如下所示:
// assets/js/stream-resource.js
import axios from 'axios'
import fs from 'fs'

export const streamResource = async (src, last) => {
  const file = fs.createWriteStream('./assets/images/' + last)
  const { data } = await axios({
    url: src,
    method: 'GET',
    responseType: 'stream'
  })
  data.pipe(file)
}

在此函数中,我们使用普通的 Axios 来请求远程资源的数据,并将stream作为响应类型。然后,我们使用 Node.js 内置的文件系统(fs)包中的createWriteStream函数以及必要的文件路径来在/assets/目录中创建图像。

有关fs包及其createWriteStream函数的更多信息,请访问nodejs.org/api/fs.htmlnodejs.org/api/fs.htmlfs_fs_createwritestream_path_options

有关 Node.js 流的pipe事件和 Node.js 流本身的更多信息,请访问nodejs.org/api/stream.htmlstream_event_pipenodejs.org/api/stream.htmlstream_stream

  1. 在 Nuxt 配置文件中注册这两个插件:
// nuxt.config.js
plugins: [
  '~/plugins/axios.js',
  '~/plugins/utils.js',
],
  1. 重构/pages/目录中的主页index.vue以使用这两个插件,如下所示:
// pages/index.vue
async asyncData ({ error, $axios }) {
  let { data } = await $axios.get('/wp-json/api/v1/page/home')
  return {
    post: data
  }
}

<template v-for="slide in post.slides">
  <img :src="$loadAssetImage(slide.image.sizes.medium_large)">
</template>

在这里,我们使用了我们的插件中的$axios来请求 WordPress API。在接收到数据后,我们将其填充到<template>块中。$loadAssetImage函数用于运行关于如何加载和处理图像的逻辑。

/pages/目录中的其余页面应该进行重构,并遵循我们为主页所遵循的相同模式。它们是/about.vue/contact.vue/projects/index.vue/projects/_slug.vue/projects/pages/_number.vue。此外,你还需要对/components/目录中的组件进行相同的操作;也就是说,/projects/project-items.vue。你可以在本节末尾提供的 GitHub 存储库中找到这些已完成文件的存储库路径。

  1. 在我们的 Nuxt 项目的package.json文件中,使用自定义环境变量NUXT_ENV_GEN创建另一个脚本命令,并将stream作为其值:
// package.json
"scripts": {
  "generate": "nuxt generate",
  "stream": "NUXT_ENV_GEN=stream nuxt generate"
}

在 Nuxt 中,如果你在package.json文件中创建一个以NUXT_ENV_为前缀的环境变量,它将自动注入到 Node.js 进程环境中。这样做后,你可以通过process.env对象在整个应用程序中访问它,包括你可能在 Nuxt 配置文件的env属性中设置的其他自定义属性。

有关 Nuxt 中env属性的更多信息,请访问nuxtjs.org/api/configuration-env/

  1. 在 Nuxt 配置文件的env属性中为资产加载器插件(我们在步骤 3中重构的)定义streamRemoteResource选项,如下所示:
// nuxt.config.js
env: {
  streamRemoteResource: process.env.NUXT_ENV_GEN === 'stream' ? 
   true : false
},

当我们从NUXT_ENV_GEN环境变量获取stream值时,streamRemoteResource选项将设置为true;否则,它始终设置为false。因此,当此选项设置为true时,资产加载器插件将开始将远程资源流式传输到/assets/目录。

  1. (可选)如果 Nuxt 爬虫由于某些未知原因无法检测到动态路由,则可以在 Nuxt 配置文件的generate选项中手动生成这些路由,方法如下:
// nuxt.config.js 
import axios from 'axios'
export default {
  generate: {
    routes: async function () {
      const projects = await axios.get(remoteUrl + '/wp-json/api/v1/projects')
      const routesProjects = projects.data.map((project) => {
        return {
          route: '/projects/' + project.post_name,
          payload: project
        }
      })

      let totalMaxPages = Math.ceil(routesProjects.length / 6)
      let pagesProjects = []
      Array(totalMaxPages).fill().map((item, index) => {
        pagesProjects.push({
          route: '/projects/pages/' + (index + 1),
          payload: null
        })
      })

      const routes = [ ...routesProjects, ...pagesProjects ]
      return routes
    }
  }
}

在这个可选的步骤中,我们使用 Axios 来获取属于projects文章类型的所有子页面,并使用 JavaScript 的map方法循环这些页面以生成它们的路由。然后,我们通过将子页面除以六(每页六个项目)来计算出子页面的最大页面数(totalMaxPages)。然后,我们使用 JavaScript 的Array对象将totalMaxPages数字转换为数组,然后使用 JavaScript 的fillmappush方法循环数组以生成分页的动态路由。最后,我们使用 JavaScript 的展开运算符将子页面和分页的路由连接起来,然后将它们作为单个数组返回,以便 Nuxt 为我们生成动态路由。

有关 JavaScript mapfillpush方法的更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/mapdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fill,以及developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push

  1. 首先在终端上运行stream命令,然后运行generate命令,如下所示:
$ npm run stream && npm run generate

我们使用stream命令将远程资源流式传输到/assets/目录,生成第一批静态页面,然后使用generate命令重新生成静态页面。此时,webpack 将处理/assets/目录中的图像,并将其与静态页面一起导出到/dist/文件夹中。因此,在运行这两个命令后,您应该看到远程资源在/assets//dist/中被流式传输和处理。您可以转到这两个目录并检查已下载的资源。

您可以在本书的 GitHub 存储库中的/chapter-18/cross-domain/frontend/nuxt-universal/nuxt-wordpress/axios-vanilla/中找到本节的 Nuxt 应用程序。

干得好!您已成功将 Nuxt 与 WordPress REST API 集成,并为静态页面流式传输远程资源。WordPress 可能不是每个人的选择,因为它不符合PHP 标准建议PSRs)(www.php-fig.org/),并且有其自己的完成任务的方式。但它是在 2003 年发布的,早于 PSR 和许多现代 PHP 框架。自那时以来,它已能够支持无数的企业和个人。当然,它已经发展,并为编辑和开发人员提供了最用户友好的管理 UI 之一。

如果这没有说服您使用 WordPress 作为 API,还有其他选择。在下一节中,我们将看看 REST API 的替代方案 - GraphQL API - 以及 Node.js 中 WordPress 的替代方案 - Keystone。Keystone 使用 GraphQL 来提供其 API。在深入研究 GraphQL 之前,我们将看看 Keystone,并学习如何开发定制的 CMS。

介绍 Keystone

Keystone 是一个可扩展的无头 CMS,用于在 Node.js 中构建 GraphQL API。它是开源的,并配备了一个非常体面的管理 UI,您可以在其中管理您的内容。就像 WordPress 一样,您可以在 Keystone 中创建称为列表的自定义内容类型,然后通过 GraphQL API 查询您的内容。您可以从源代码创建列表,就像创建 REST API 一样。您可以为 API 添加所需的内容,使其具有高度可扩展性和可扩展性。

使用 Keystone,首先,您需要准备一个用于存储内容的数据库。Keystone 支持 MongoDB 和 PostgreSQL。您需要安装和配置其中一个,然后找出 Keystone 的连接字符串。您在第九章中了解了 MongoDB,添加服务器端数据库,因此再次将其用作 Keystone 的数据库不应该成为问题。但是 PostgreSQL 呢?让我们找出来。

有关 Keystone 的更多信息,请访问www.keystonejs.com/

安装和保护 PostgreSQL(Ubuntu)

PostgreSQL,也称为 Postgres,是一种面向对象的关系数据库系统,通常与 MySQL 进行比较,后者是一种(纯粹的)关系数据库管理系统(RDBMS)。两者都是开源的,使用表格,但它们有各自的区别。

例如,Postgres 在很大程度上符合 SQL 标准,而 MySQL 在部分方面符合标准,MySQL 在读取速度方面更快,而 PostgreSQL 在注入复杂查询方面更快。有关 Postgres 的更多信息,请访问www.postgresql.org/

您可以在许多不同的操作系统上安装 Postgres,包括 Linux、macOS 和 Windows。根据您的操作系统,您可以按照官方指南在您的计算机上安装它。我们将向您展示如何在 Linux 上安装和保护它,特别是 Ubuntu,具体步骤如下:

  1. 更新您的本地软件包索引,并使用 Ubuntu 的apt包装系统从 Ubuntu 的默认存储库安装 Postgres:
$ sudo apt update
$ sudo apt install postgresql postgresql-contrib
  1. 通过检查其版本来验证 Postgres:
$ psql -v

如果您获得以下输出,则表示您已成功安装:

/usr/lib/postgresql/12/bin/psql: option requires an argument -- 'v'
Try "psql --help" for more information.

路径中的数字12表示您的计算机上安装了 Postgres 12 版本。

  1. 从终端输入 Postgres shell:
$ sudo -u postgres psql

您应该在终端上获得类似以下的输出:

postgres@lau-desktop:~$ psql
psql (12.2 (Ubuntu 12.2-2.pgdg19.10+1))
Type "help" for help.

postgres=
  1. 使用 Postgres 的\du命令列出默认用户:
postgres= \du

您应该获得两个默认用户,如下所示:

Role name 
-----------
postgres 
root

我们将使用终端上的交互提示向列表中添加一个新的管理用户(或角色)。但是,我们首先需要退出 Postgres shell:

postgres= \q
  1. 使用--interactive标志输入以下命令:
$ sudo -u postgres createuser --interactive

关于新角色名称以及角色是否应该具有超级用户权限,您应该看到以下两个问题:

Enter name of role to add: user1
Shall the new role be a superuser? (y/n) y

在这里,我们将新用户称为user1。它具有超级用户权限,就像默认用户一样。

  1. 使用sudo -u postgres psql登录 Postgres shell 以验证新用户是否已添加到列表中。

  2. 使用以下 SQL 查询为新用户添加密码:

ALTER USER user1 PASSWORD 'password';

如果您获得以下输出,则已成功为此用户添加了密码:

ALTER ROLE
  1. 退出 Postgres shell。现在,您可以使用 PHP 的 Adminer (www.adminer.org/)使用此用户登录 Postgres,并在那里添加一个新的数据库,这将在稍后安装 Keystone 时需要。然后,您可以使用以下格式的 Postgres 连接字符串连接刚刚创建的数据库:
postgres://<username>:<password>@localhost/<dbname>

请注意,出于安全原因,任何用户要从 Adminer 登录到数据库都需要密码。因此,无论是 MySQL、Postgres 还是 MongoDB 数据库,都最好添加安全性。MongoDB 呢?您在之前的章节中学习了如何安装和使用它,但尚未进行安全保护。我们将在下一节中了解如何做到这一点。

安装和保护 MongoDB(Ubuntu)

到目前为止,您应该知道如何安装 MongoDB。因此,在本节中,我们将专注于保护 MongoDB 中的数据库。为了保护 MongoDB,我们将首先添加一个管理用户到 MongoDB,如下所示:

  1. 从终端连接到 Mongo shell:
$ mongo
  1. 选择admin数据库,并向该数据库添加一个具有用户名和密码(例如,root 和 password)的新用户,如下所示:
> use admin
> db.createUser(
  {
    user: "root",
    pwd: "password",
    roles: [ { role: "userAdminAnyDatabase", db: "admin" }, 
     "readWriteAnyDatabase" ]
  }
)
  1. 退出 shell 并从终端打开 MongoDB 配置文件:
$ sudo nano /etc/mongod.conf
  1. 查找security部分,删除哈希,并添加authorization设置,如下所示:
// mongodb.conf
security:
  authorization: "enabled"
  1. 保存并退出文件,然后重新启动 MongoDB:
$ sudo systemctl restart mongod
  1. 通过检查 MongoDB 的状态来验证配置:
$ sudo systemctl status mongod

如果看到"active"状态,这意味着您已正确配置。

  1. 使用密码和--authenticationDatabase选项以"root"身份登录。此外,在此情况下,提供存储用户的数据库的名称,即"admin"
$ mongo --port 27017 -u "root" -p "password" --authenticationDatabase "admin"
  1. 创建一个新的数据库(例如,test)并将一个新用户附加到其中:
> use test
db.createUser(
  {
    user: "user1",
    pwd: "password",
    roles: [ { role: "readWrite", db: "test" } ]
  }
)
  1. 退出并以user1身份登录以测试数据库:
$ mongo --port 27017 -u "user1" -p "password" --authenticationDatabase "test"
  1. 测试是否可以访问此test数据库,但不能访问其他数据库:
> show dbs

如果没有输出,这意味着您只有在经过身份验证后才有权访问此数据库。您可以使用以下格式为 Keystone 或任何其他应用程序(例如 Express、Koa 等)提供 MongoDB 连接字符串:

mogodb://<username>:<password>@localhost:27017/<dbname>

再次强调,为数据库添加安全性是一个好习惯,尤其是对于生产环境,但在 MongoDB 中启用身份验证会使开发应用程序变得更加简单和快速。您可以在本地开发时始终禁用它,并在生产服务器上启用它。

现在,两个数据库系统(Postgres 和 MongoDB)都已准备就绪,您可以选择其中任何一个来构建 Keystone 应用程序。所以,让我们开始吧!

安装和创建 Keystone 应用程序

有两种方法可以启动 Keystone 项目 - 从头开始或使用称为keystone-app的 Keystone 搭建工具。如果您要从头开始,您需要手动安装任何与 Keystone 相关的软件包。这些包括最低要求的 Keystone 软件包和您构建应用程序所需的其他 Keystone 软件包。让我们来看看这个手动安装:

  1. 创建一个项目目录并安装最低要求的软件包 - Keystone 软件包本身,Keystone GraphQL 软件包(在 Keystone 中被视为应用程序),以及数据库适配器:
$ npm i @keystonejs/keystone
$ npm i @keystonejs/app-graphql
$ npm i @keystonejs/adapter-mongoose
  1. 安装您需要的其他 Keystone 软件包,例如 Keystone Admin UI 软件包(在 Keystone 中被视为应用程序)和用于注册列表的 Keystone 字段软件包:
$ npm i @keystonejs/app-admin-ui
$ npm i @keystonejs/fields
  1. 在根目录中创建一个空的index.js文件,并导入您刚刚安装的软件包:
// index.js
const { Keystone } = require('@keystonejs/keystone')
const { GraphQLApp } = require('@keystonejs/app-graphql')
const { AdminUIApp } = require('@keystonejs/app-admin-ui')
const { MongooseAdapter } = require('@keystonejs/adapter-mongoose')
const { Text } = require('@keystonejs/fields')
  1. 创建 Keystone 的新实例,并将新的数据库适配器实例传递给它,如下所示:
const keystone = new Keystone({
  name: 'My Keystone Project',
  adapter: new MongooseAdapter({ mongoUri: 'mongodb://localhost/your-
    db-name' }),
})

查看以下指南,了解如何配置 Mongoose 适配器:www.keystonejs.com/keystonejs/adapter-mongoose/。当我们使用搭建工具安装 Keystone 时,我们将再次介绍这个。

  1. 创建一个简单的列表 - 例如Page列表,并定义您需要的字段,以便存储此列表中每个单个项目的数据:
keystone.createList('Page', {
  fields: {
    name: { type: Text },
  },
})

对于 GraphQL,将列表的名称大写是一种惯例。我们很快会介绍这个。

  1. 导出keystone实例和应用程序,以便可以执行它们:
module.exports = {
  keystone,
  apps: [new GraphQLApp(), new AdminUIApp()]
}
  1. 创建一个package.json文件(如果您还没有这样做),并添加以下keystone命令到脚本中,如下所示:
"scripts": {
  "dev": "keystone"
}
  1. 通过在终端上运行dev脚本来启动应用程序:
$ npm run dev

您应该在终端上看到以下输出。这意味着您已成功启动了应用程序:

 Command: keystone dev
✓ Validated project entry file ./index.js
✓ Keystone server listening on port 3000
✓ Initialised Keystone instance
✓ Connected to database
✓ Keystone instance is ready at http://localhost:3000
∞ Keystone Admin UI: http://localhost:3000/admin
∞ GraphQL Playground: http://localhost:3000/admin/graphiql
∞ GraphQL API: http://localhost:3000/admin/api

干得好!您的第一个和最简单的 Keystone 应用程序已经开始运行。在这个应用程序中,您在localhost:3000/admin/api上有一个 GraphQL API,在localhost:3000/admin/graphiql上有一个 GraphQL Playground,在localhost:3000/admin上有一个 Keystone Admin UI。但是我们如何使用 GraphQL API 和 GraphQL Playground 呢?放心,我们将在接下来的部分中介绍。

开始一个新的 Keystone 应用并不难,是吗?您只需要安装 Keystone 所需的内容。然而,启动 Keystone 应用的最简单方法是使用脚手架工具。使用脚手架工具的好处是,在安装过程中它附带了一些 Keystone 应用的可选示例,它们可以作为指南和模板非常有用。这些可选示例如下:

  • 入门:这个示例演示了使用 Keystone 进行基本用户身份验证。

  • Todo:这个示例演示了一个简单的应用程序,用于向Todo列表添加项目,以及一些前端集成(HTML、CSS 和 JavaScript)。

  • 空白:这个示例提供了一个基本的起点,以及 Keystone 管理 UI、GraphQL API 和 GraphQL Playground。这些与手动安装中的内容一样,但没有 Keystone field包。

  • Nuxt:这个示例演示了与 Nuxt.js 的简单集成。

我们将选择空白选项,因为它为我们提供了我们需要的基本包,这样我们就可以在其基础上构建我们的列表。让我们来看一下:

  1. 在终端上创建一个任何名称的全新 Keystone 应用程序:
$ npm init keystone-app <app-name>
  1. 回答 Keystone 提出的问题,如下:
✓ What is your project name?
✓ Select a starter project: Starter / Blank / Todo / Nuxt
✓ Select a database type: MongoDB / Postgre
  1. 安装完成后,进入您的项目目录:
$ cd <app-name>
  1. 如果您正在使用安全的 Postgres,只需提供连接字符串,以及 Keystone 的用户名、密码和数据库:
// index.js
const adapterConfig = { knexOptions: { connection: 'postgres://
 <username>:<password>@localhost/<dbname>' } }

请注意,如果您没有启用身份验证,只需从字符串中删除<username>:<password>@。然后,运行以下命令来安装数据库表:

$ npm run create-tables

有关 Knex 数据库适配器的更多信息,请访问www.keystonejs.com/quick-start/adapters或访问 knex.js 网站knexjs.org/。它是用于 PostgreSQL、MySQL 和 SQLite3 的查询构建器。

  1. 如果您正在使用安全的 MongoDB,只需提供连接字符串,以及 Keystone 的用户名、密码和数据库:
// index.js
const adapterConfig = { mongoUri: 'mogodb://<username>:<password>@localhost:27017/<dbname>' }

请注意,如果您没有启用身份验证,只需从字符串中删除<username>:<password>@

有关 Mongoose 数据库适配器的更多信息,请访问www.keystonejs.com/keystonejs/adapter-mongoose/或访问 Mongoose mongoosejs.com/。MongoDB 本质上是一个无模式的数据库系统,因此该适配器用作模式解决方案,以对我们应用程序中的数据进行建模。

  1. 将服务器默认端口从3000更改为4000以提供 Keystone 应用程序。您可以通过简单地将PORT=4000添加到dev脚本中来实现这一点,如下所示:
// package.json
"scripts": {
  "dev": "cross-env NODE_ENV=development PORT=4000 ...",
}

我们将 Keystone 的端口更改为4000的原因是因为我们为 Nuxt 应用程序保留了端口3000

  1. 在我们的项目中安装nodemon。这将允许我们监视 Keystone 应用程序中的更改,以便它可以为我们重新加载服务器:
$ npm i nodemon --save-dev
  1. 安装此软件包后,将nodemon --exec命令添加到dev脚本中,如下所示:
// package.json
"scripts": {
  "dev": "... nodemon --exec keystone dev",
}

有关 nodemon 的更多信息,请访问nodemon.io/

  1. 使用以下命令启动我们的 Keystone 应用程序的开发服务器:
$ npm run dev

您应该在终端上看到以下输出。这意味着您已成功安装了 Keystone 应用程序:

✓ Keystone instance is ready at http://localhost:4000
∞ Keystone Admin UI: http://localhost:4000/admin
∞ GraphQL Playground: http://localhost:4000/admin/graphiql
∞ GraphQL API: http://localhost:4000/admin/api

这与在不同端口上执行手动安装相同。在此应用程序中,您可以在localhost:4000/admin/api上找到 GraphQL API,在localhost:4000/admin/graphiql上找到 GraphQL Playground,在localhost:4000/admin上找到 Keystone Admin UI。在我们可以对 GraphQL API 和 GraphQL Playground 执行任何操作之前,我们必须向 Keystone 应用程序添加列表,并开始从 Keystone Admin UI 注入数据。我们将在下一节中开始向应用程序添加列表和字段。

您可以在本书的 GitHub 存储库的/chapter-18/keystone/中找到我们从这两种安装技术创建的应用程序。

创建列表和字段

在 Keystone 中,列表是模式。模式是一个具有描述我们数据的类型的数据模型。在 Keystone 中也是一样的:列表模式由具有描述其接受的数据的类型的字段组成,就像我们在手动安装中所做的那样,在那里我们有一个由单个Text类型的name字段组成的Page列表。

Keystone 中有许多不同的字段类型,例如FileFloatCheckboxContentDateTimeSlugRelationships。您可以在www.keystonejs.com/的文档中找到您需要的其余 Keystone 字段类型的信息。

要向列表添加字段及其类型,您只需在项目目录中安装包含这些字段类型的 Keystone 软件包。例如,@keystonejs/fields软件包包含CheckboxTextFloatDateTime字段类型,以及其他字段类型。您可以在www.keystonejs.com/keystonejs/fields/fields了解其余字段类型。安装所需的字段类型软件包后,您只需导入它们,并使用 JavaScript 解构赋值来解压所需的字段类型,以便创建列表。

然而,列表可能会随着时间的推移而增长,这意味着它们可能变得混乱和难以跟上。因此,最好将列表创建在/list/目录中的单独文件中,以便更好地维护,如下所示:

// lists/Page.js
const { Text } = require('@keystonejs/fields')

module.exports = {
  fields: {...},
}

然后,您只需将其导入到index.js文件中。因此,让我们找出我们构建 Keystone 应用程序所需的模式/列表和其他 Keystone 软件包。我们将要创建的列表如下:

  • 用于存储主要页面(如homeaboutcontactprojects)的Page模式/列表

  • 用于存储项目页面的Project模式/列表

  • 用于存储主要和项目页面图像的Image模式/列表

  • 用于仅存储主要页面图像的Slide Image模式/列表

  • 用于存储站点链接的Nav Link模式/列表

我们将要使用的 Keystone 软件包来创建这些列表如下:

现在,让我们安装并使用它们来创建我们的列表:

  1. 通过 npm 安装我们之前提到的 Keystone 软件包:
$ npm i @keystonejs/app-static
$ npm i @keystonejs/file-adapters
$ npm i @keystonejs/fields-wysiwyg-tinymce
  1. @keystonejs/app-static导入index.js,并定义您希望保留静态文件的路径和文件夹名称:
// index.js
const { StaticApp } = require('@keystonejs/app-static');

module.exports = {
  apps: [
    new StaticApp({
      path: '/public',
      src: 'public'
    }),
  ],
}
  1. /lists/目录中创建一个File.js文件。然后,使用@keystonejs/fields中的FileTextSlug字段类型以及@keystonejs/file-adapters中的LocalFileAdapter定义Image列表的字段。这将允许您将文件上传到本地位置;即/public/files/
// lists/File.js
const { File, Text, Slug } = require('@keystonejs/fields')
const { LocalFileAdapter } = require('@keystonejs/file-adapters')

const fileAdapter = new LocalFileAdapter({
  src: './public/files',
  path: '/public/files',
})

module.exports = {
  fields: {
    title: { type: Text, isRequired: true },
    alt: { type: Text },
    caption: { type: Text, isMultiline: true },
    name: { type: Slug },
    file: { type: File, adapter: fileAdapter, isRequired: true },
  }
}

在上述代码中,我们定义了一系列字段(titlealtcaptionnamefile),以便我们可以存储有关每个上传文件的元信息。在每个列表模式中都有name字段是一个良好的做法,以便我们可以在此字段中存储一个唯一名称,我们可以在 Keystone Admin UI 中用作标签。我们可以使用它轻松地识别每个注入的列表项。为此字段生成唯一名称,我们可以使用Slug类型,默认情况下,它从title字段生成唯一名称。

有关我们在上述代码中使用的字段类型的更多信息,请访问以下链接:

有关LocalFileAdapter的更多信息,请访问www.keystonejs.com/keystonejs/file-adapters/localfileadapter

我们的应用文件可以使用CloudinaryFileAdapter上传到 Cloudinary。

有关如何设置帐户以便您可以在 Cloudinary 上托管文件的更多信息,请访问cloudinary.com/

  1. /lists/目录中创建一个SlideImage.js文件,并定义与File.js文件中相同的字段,还有一个额外的字段类型Relationship,以便您可以将幻灯片图像链接到项目页面:
// lists/SlideImage.js
const { Relationship } = require('@keystonejs/fields')

module.exports = {
  fields: {
    // ...
    link: { type: Relationship, ref: 'Project' },
  },
}

有关Relationship字段的更多信息,请访问www.keystonejs.com/keystonejs/fields/src/types/relationship/

  1. /lists/目录中创建一个Page.js文件,并使用@keystonejs/fields@keystonejs/fields-wysiwyg-tinymce中的TextRelationshipSlugWysiwyg字段类型定义Page列表的字段,如下所示:
// lists/Page.js
const { Text, Relationship, Slug } = require('@keystonejs/fields')
const { Wysiwyg } = require('@keystonejs/fields-wysiwyg-tinymce')

module.exports = {
  fields: {
    title: { type: Text, isRequired: true },
    excerpt: { type: Text, isMultiline: true },
    content: { type: Wysiwyg },
    name: { type: Slug },
    featuredImage: { type: Relationship, ref: 'Image' },
    slideImages: { type: Relationship, ref: 'SlideImage', many:
     true },
  },
}

在上述代码中,我们定义了一系列字段(titleexcerptcontentnamefeaturedImageslideImages),以便我们可以存储将注入到此内容类型中的每个主页面的数据。请注意,我们将featuredImage链接到Image列表,并将slideImages链接到SlideImage列表。我们希望允许将多个图像放置在slideImages字段中,因此我们将many选项设置为true

有关这些一对多和多对多关系的更多信息,请访问www.keystonejs.com/guides/new-schema-cheatsheet

  1. /lists/目录中创建一个Project.js文件,并定义与File.js文件中的字段相同的字段,用于Project列表,另外再加上两个字段(fullscreenImageprojectImages):
// lists/Project.js
const { Text, Relationship, Slug } = require('@keystonejs/fields')
const { Wysiwyg } = require('@keystonejs/fields-wysiwyg-tinymce')

module.exports = {
  fields: {
    //...
    fullscreenImage: { type: Relationship, ref: 'Image' },
    projectImages: { type: Relationship, ref: 'Image', many:
     true },
  },
}
  1. /lists/目录中创建一个NavLink.js文件,并使用@keystonejs/fields中的TextRelationshipSlugInteger字段类型定义NavLink列表的字段(titleordernamelinksubLinks),如下所示:
// lists/NavLink.js
const { Text, Relationship, Slug, Integer } = require('@keystonejs/fields')

module.exports = {
  fields: {
    title: { type: Text, isRequired: true },
    order: { type: Integer, isRequired: true },
    name: { type: Slug },
    link: { type: Relationship, ref: 'Page' },
    subLinks: { type: Relationship, ref: 'Project', many: true },
  },
}

在这里,我们使用order字段来按照 GraphQL 查询中的数字位置对链接项进行排序。您很快就会了解这一点。subLinks字段是一个示例,演示了如何在 Keystone 中创建简单的子链接。因此,我们可以通过将项目页面附加到此字段来向主链接添加多个子链接,该字段使用Relationship字段类型与Project列表关联。

有关Integer字段类型的更多信息,请访问www.keystonejs.com/keystonejs/fields/src/types/integer/

  1. /lists/目录导入文件,并开始从中创建列表模式,如下所示:
// index.js
const PageSchema = require('./lists/Page.js')
const ProjectSchema = require('./lists/Project.js')
const FileSchema = require('./lists/File.js')
const SlideImageSchema = require('./lists/SlideImage.js')
const NavLinkSchema = require('./lists/NavLink.js')

const keystone = new Keystone({ ... })

keystone.createList('Page', PageSchema)
keystone.createList('Project', ProjectSchema)
keystone.createList('Image', FileSchema)
keystone.createList('SlideImage', SlideImageSchema)
keystone.createList('NavLink', NavLinkSchema)
  1. 通过在终端上运行dev脚本启动应用程序:
$ npm run dev

您应该在终端上看到与上一节中显示的相同的 URL 列表。这意味着您已成功在localhost:4000上启动了该应用程序。因此,现在,您可以将浏览器指向localhost:4000/admin,并开始从 Keystone Admin UI 注入内容和上传文件。一旦您准备好内容和数据,就可以使用 GraphQL API 和 GraphQL Playground 进行查询。但在这样做之前,您应该了解什么是 GraphQL 以及如何独立于 Keystone 创建和使用它。所以,让我们找出来!

您可以在本书的 GitHub 存储库中的/chapter-18/cross-domain/backend/keystone/中找到此应用程序的源代码。

介绍 GraphQL

GraphQL 是一种开放源查询语言、服务器端运行时(执行引擎)和规范(技术标准)。但这意味着什么?它是什么?GraphQL 是一种查询语言,这就是 GraphQL 中“QL”部分的含义。具体来说,它是一种客户端查询语言。但再次,这意味着什么?以下示例将解决您对 GraphQL 查询的任何疑问:

{
 planet(name: "earth") {
   id
   age
   population
 }
}

像前面的那样的 GraphQL 查询在 HTTP 客户端(如 Nuxt 或 Vue)中使用,以交换 JSON 响应发送查询到服务器,如下所示:

{
  "data": {
    "planet": {
      "id": 3,
      "age": "4543000000",
      "population": "7594000000"
    }
  }
}

正如您所看到的,您获取了您请求的字段(agepopulation)的特定数据,而不多不少。这就是使 GraphQL 与众不同的地方,它赋予客户端精确请求他们想要的能力。这很酷,很令人兴奋,不是吗?但是在服务器上返回 GraphQL 响应的是什么?一个 GraphQL API 服务器(服务器端运行时)。

GraphQL 查询由客户端通过 HTTP 端点发送到 GraphQL API 服务器,使用POST方法将查询作为字符串发送到服务器。服务器提取并处理查询字符串。然后,就像任何典型的 API 服务器一样,GraphQL API 将从数据库或其他服务/ API 获取数据并以 JSON 响应返回给客户端。

那么,我们可以将 Express 等服务器用作 GraphQL API 服务器吗?是和否。所有合格的 GraphQL 服务器必须实现 GraphQL 规范中指定的两个核心组件,用于验证、处理和返回数据:模式和解析器。

GraphQL 模式是一组类型定义的集合,其中包括客户端可以请求的对象以及对象具有的字段。另一方面,GraphQL 解析器是附加到字段的函数,在客户端进行查询或突变时返回值。例如,以下是查找行星的类型定义:

type Planet {
  id: Int
  name: String
  age: String
  population: String
}

type Query {
  planet(name: String): Planet
}

在这里,您可以看到 GraphQL 使用了强类型模式 - 每个字段必须用可以是标量类型(即可以是整数、布尔值或字符串的单个值)或对象类型定义。PlanetQuery类型是对象类型,而StringInt是标量类型。对象类型中的每个字段都必须通过函数解析,如下所示:

Planet: {
  id: (root, args, context, info) => root.id,
  name: (root, args, context, info) => root.name,
  age: (root, args, context, info) => root.age,
  population: (root, args, context, info) => root.population,
}

Query: {
  planet: (root, args, context, info) => {
    return planets.find(planet => planet.name === args.name)
  },
}

前面的示例是用 JavaScript 编写的,但是 GraphQL 服务器可以用任何编程语言编写,只要您遵循并实现了 GraphQL 规范中概述的内容。以下是不同语言中的一些 GraphQL 实现的示例:

只要符合 GraphQL 规范,您可以自由创建新的实现,但是在本书中我们只会使用 GraphQL.js。现在,您可能有一些更深入的问题 - 查询类型到底是什么?我们知道它是一个object类型,但为什么我们需要它?我们需要在模式中拥有它吗?简短的答案是是。

我们将在下一节中更详细地讨论这个问题,并找出为什么无论如何都需要它。我们还将找出如何将 Express 用作 GraphQL API 服务器。所以,请继续阅读。

理解 GraphQL 模式和解析器

在前一节中,我们讨论了查找行星的示例模式和解析器,假设我们使用 GraphQL 模式语言,这有助于我们创建 GraphQL 服务器所需的 GraphQL 模式。我们可以使用 Node.js 包 GraphQL Tools 中的makeExecutableSchema函数从 GraphQL 模式语言轻松创建一个 GraphQL.js GraphQLSchema实例。

您可以在www.graphql-tools.com/github.com/ardatan/graphql-tools找到有关此包的更多信息。

GraphQL 模式语言是一种“快捷方式” - 一种用于构建 GraphQL 模式和其类型系统的简写符号。在使用这种简写符号之前,我们应该看一下 GraphQL 模式是如何从低级对象和函数(如GraphQLObjectTypeGraphQLStringGraphQLList等)构建的,这些函数来自实现了 GraphQL 规范的 GraphQL.js。让我们安装这些包并使用 Express 创建一个简单的 GraphQL API 服务器:

  1. 通过 npm 安装 Express、GraphQL.js 和 GraphQL HTTP 服务器中间件:
$ npm i express
$ npm i express-graphql
$ npm i graphql

GraphQL HTTP 服务器中间件是一种中间件,允许我们使用任何实现了 Connect 支持中间件的 HTTP Web 框架(如 Express、Restify 和 Connect 本身)创建 GraphQL HTTP 服务器。

有关这些包的更多信息,请访问以下链接:

  1. 在项目的根目录下创建一个index.js文件,并使用require方法导入expressexpress-graphqlgraphql
// index.js
const express = require('express')
const graphqlHTTP = require('express-graphql')
const graphql = require('graphql')

const app = express()
const port = process.env.PORT || 4000
  1. 创建一个包含行星列表的虚拟数据:
// index.js
const planets = [
  { id: 3, name: "earth", age: 4543000000, population:
    7594000000 },
  { id: 4, name: "mars", age: 4603000000, population: 0 },
]
  1. 定义Planet对象类型以及客户端可以查询的字段:
// index.js
const planetType = new graphql.GraphQLObjectType({
  name: 'Planet',
  fields: {
  id: { ... },
  name: { ... },
  age: { ... },
  population: { ... },
})

注意,在 GraphQL 模式创建时,将对象类型在name字段中大写是一种约定。

  1. 定义各种类型以及如何解析每个字段的值:
// index.js
id: {
  type: graphql.GraphQLInt,
  resolve: (root, orgs, context, info) => root.id,
},
name: {
  type: graphql.GraphQLString,
  resolve: (root, orgs, context, info) => root.name,
},
age: {
  type: graphql.GraphQLString,
  resolve: (root, orgs, context, info) => root.age,
},
population: {
  type: graphql.GraphQLString,
  resolve: (root, orgs, context, info) => root.population,
},

注意,每个解析器函数都接受以下四个参数:

  • root:从父对象类型(步骤 6中的查询)解析出的对象或值。

  • args:如果设置了,字段可以接收的参数。见步骤 8

  • context:一个可变的 JavaScript 对象,保存着所有解析器之间共享的顶层数据。在我们使用 Express 时,默认情况下是 Node.js 的 HTTP 请求对象(IncomingMessage)。我们可以修改这个上下文对象,并添加我们想要共享的一般数据,比如认证和数据库连接。见步骤 10

  • info:一个 JavaScript 对象,包含有关当前字段的信息,例如字段名称、返回类型、父类型(在本例中为Planet)和一般模式详细信息。

如果它们对于解析当前字段的值不需要,我们可以省略它们。

  1. 定义Query对象类型和客户端可以查询的字段:
// index.js
const queryType = new graphql.GraphQLObjectType({
  name: 'Query',
  fields: {
    hello: { ... },
    planet: { ... },
  },
})
  1. 定义类型并解析您希望返回hello字段的值:
// index.js
hello: {
  type: graphql.GraphQLString,
  resolve: (root, args, context, info) => 'world',
}
  1. 定义类型并解析您希望返回planet字段的值:
// index.js
planet: {
  type: planetType,
  args: {
    name: { type: graphql.GraphQLString }
  },
  resolve: (root, args, context, info) => {
    return planets.find(planet => planet.name === args.name)
  },
}

请注意,我们将创建并存储在planetType变量中的Planet对象类型传递给Query对象类型中的planet字段,以便它们之间可以建立关系。

  1. 使用所需的query字段和您刚刚定义的带有字段、类型、参数和解析器的**Query**对象类型构造一个 GraphQL 模式实例,如下所示:
// index.js
const schema = new graphql.GraphQLSchema({ query: queryType })

请注意,必须提供query键作为 GraphQL 查询根类型,以便我们的查询可以链接到Planet对象类型中的字段。我们可以说Planet对象类型是Query对象类型(根类型)的子类型或子对象,并且它们的关系必须在父对象(Query)中使用planet字段中的type字段来建立。

  1. 使用 GraphQL HTTP 服务器中间件作为中间件与 GraphQL 模式实例一起,在 Express 允许的端点上建立 GraphQL 服务器,称为/graphiql,如下所示:
// index.js
app.use(
  '/graphiql',
  graphqlHTTP({ schema, graphiql: true }),
)

建议将graphiql选项设置为true,以便在浏览器加载 GraphQL 端点时可以使用 GraphQL IDE。

在这个顶层,您还可以使用graphqlHTTP中间件内的context选项修改您的 GraphQL API 的上下文,如下所示:

context: {
  something: 'something to be shared',
}

通过这样做,您可以从任何解析器中访问这个顶层数据。这可能非常有用。很酷,不是吗?

  1. 最后,在加载所有数据后,在终端上使用node index.js命令启动服务器,并在index.js文件中添加以下行:
// index.js
app.listen(port)
  1. 将浏览器指向localhost:4000/graphiql。您应该看到 GraphQL IDE,一个 UI,您可以在其中测试您的 GraphQL API。因此,在左侧的输入区域中键入以下查询:
// localhost:4000/graphiql
{
  hello
  planet (name: "earth") {
    id
    age
    population
  }
}

当您点击播放按钮时,您应该看到前面的 GraphQL 查询已经在右侧与 JSON 对象交换:

// localhost:4000/graphiql
{
  "data": {
    "hello": "world",
    "planet": {
      "id": 3,
      "age": "4543000000",
      "population": "7594000000"
    }
  }
}

干得好 - 你已经成功地使用低级方法在 Express 中创建了一个基本的 GraphQL API 服务器!我们希望这给了你一个完整的图景,了解了如何使用 GraphQL 模式和解析器创建 GraphQL API 服务器。我们也希望你能看到 GraphQL 中这两个核心组件之间的关系,并且我们已经回答了你的问题;也就是说,Query类型到底是什么?我们为什么需要它?我们需要在模式中拥有它吗?答案是肯定的,查询(对象)类型是一个根对象类型(通常称为根Query类型),在创建 GraphQL 模式时必须提供。

但是您可能仍然有一些问题和抱怨,特别是关于解析器 - 您肯定会觉得在Planet对象类型的字段中定义解析器在步骤 5中是乏味和愚蠢的,因为它们除了返回从查询对象解析的值之外什么也不做。有没有办法避免这种痛苦的重复?答案是肯定的:您不需要为模式中的每个字段指定它们,这就是默认解析器。但是我们该如何做呢?我们将在下一节中找到答案。

您可以在本书的 GitHub 存储库中的/chapter-18/graphql-api/graphql-express/中找到此类示例和其他示例。

理解 GraphQL 默认解析器

当没有为字段指定解析器时,默认情况下,该字段将采用在幕后由父对象解析的对象的属性的值 - 也就是说,如果该对象具有与字段名称匹配的属性名称。因此,可以将Planet对象类型中的字段重构如下:

fields: {
  id: { type: graphql.GraphQLInt },
  name: { type: graphql.GraphQLString },
  age: { type: graphql.GraphQLString },
  population: { type: graphql.GraphQLString },
}

这些字段的值将回退到在幕后由父对象(查询类型)解析的对象的属性中,如下所示:

root.id
root.name
root.age
root.population

换句话说,当为字段明确指定解析器时,即使父解析器为该字段返回任何值,此解析器也将始终被使用。例如,让我们为Planet对象类型中的id字段明确指定一个值,如下所示:

fields: {
  id: {
    type: graphql.GraphQLInt,
    resolve: (root, orgs, context, info) => 2,
  },
}

我们已经知道地球和火星的默认 ID 值分别为 3 和 4,并且它们由Query对象类型(父对象)解析,如前一节的步骤 8所示。但这些解析的值将永远不会被使用,因为它们被 ID 的解析器覆盖了。因此,让我们查询地球或火星,如下所示:

{
  planet (name: "mars") {
    id
  }
}

在这种情况下,您将始终在 JSON 响应中获得2

{
  "data": {
    "planet": {
      "id": 2
    }
  }
}

这非常聪明,不是吗?它可以避免我们痛苦的重复 - 也就是说,如果你在对象类型中有大量字段的话。然而,到目前为止,我们一直在用最痛苦的方式来构建我们的模式,通过使用 GraphQL.js。这是因为我们想要看到并理解 GraphQL 模式是如何从低级类型创建的。在现实生活中,我们可能不想走这么漫长和曲折的道路,特别是在一个大项目中。相反,我们应该倾向于使用 GraphQL 模式语言来为我们构建模式和解析器。在下一节中,我们将向您展示如何使用 GraphQL 模式语言和 Apollo Server 轻松创建 GraphQL API 服务器,作为 GraphQL HTTP 服务器中间件的替代方案。所以,请继续阅读!

使用 Apollo Server 创建 GraphQL API

Apollo Server 是 Apollo 平台开发的符合 GraphQL 规范的开源服务器,用于构建 GraphQL API。我们可以单独使用它,也可以与其他 Node.js web 框架一起使用,比如 Express、Koa、Hapi 等等。在本书中,我们将直接使用 Apollo Server,但如果您想在其他框架中使用它,请访问github.com/apollographql/apollo-server#installation-integrations

在这个 GraphQL API 中,我们将创建一个服务器,通过标题和作者查询一系列书籍。让我们开始吧:

  1. 通过 npm 安装 Apollo Server 和 GraphQL.js 作为项目依赖:
$ npm i apollo-server
$ npm i graphql
  1. 在项目根目录中创建一个index.js文件,并从apollo-server包中导入ApolloServergql函数:
// index.js
const { ApolloServer, gql } = require('apollo-server')

gql函数用于解析 GraphQL 操作和模式语言,通过使用模板文字标签(或标记模板文字)将它们包装起来。有关模板文字和标记模板的更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

  1. 创建以下静态数据,其中包含作者和帖子的列表:
// index.js
const authors = [
  { id: 1, name: 'author A' },
  { id: 2, name: 'author B' },
]

const posts = [
  { id: 1, title: 'Post 1', authorId: 1 },
  { id: 2, title: 'Post 2', authorId: 1 },
  { id: 3, title: 'Post 3', authorId: 2 },
]
  1. 定义AuthorPostQuery对象类型,以及客户端可以查询的字段:
// index.js
const typeDefs = gql`
  type Author {
   id: Int
   name: String
  }

  type Post {
   id: Int
   title: String
   author: Author
  }

  type Query {
    posts: [Post]
  }
`

请注意,我们可以将AuthorPostQuery对象类型简写为Author类型,Post类型和Query类型。这比使用“对象类型”来描述它们更清晰,因为它们就是这样。请记住,除了天生是对象类型之外,Query类型还是 GraphQL 模式创建中的根类型。

注意我们如何建立AuthorPost以及PostQuery之间的关系 - author字段的类型是Author类型。Author类型的字段(idname)是简单的标量类型,而Post类型的字段(idtitle)和Author类型(author)是简单的标量类型。Query类型只有一个字段,即posts,它是一个帖子列表,因此我们必须使用类型修饰符,用开放和关闭的方括号包裹Post类型,以指示这个posts字段将解析为一个Post对象数组。

有关类型修饰符的更多信息,请访问graphql.org/learn/schema/lists-and-non-null

  1. 定义解析器以指定如何解析Query类型中的posts字段和Post类型中的author字段的值:
// index.js
const resolvers = {
  Query: {
    posts: (root, args, context, info) => posts
  },

  Post: {
    author: root => authors.find(author => author.id === 
     root.authorId)
  },
}

注意 GraphQL 模式语言如何帮助我们将解析器与对象类型解耦,并且它们只是在单个 JavaScript 对象中简单定义。JavaScript 对象中的解析器与对象类型“神奇”地连接在一起,只要解析器的属性名称与类型定义中的字段名称相匹配。因此,这个 JavaScript 对象被称为解析器映射。在定义解析器之前,我们还必须在解析器映射中定义顶级属性名称(QueryPost),以便它们与类型定义中的对象类型(AuthorPostQuery)匹配。但是,在这个解析器映射中,我们不需要为Author类型定义任何特定的解析器,因为Author中字段(idname)的值会自动由默认解析器解析。

另一个需要注意的地方是Post类型中字段(idtitle)的值也是由默认解析器解析的。如果您不喜欢使用属性名称来定义解析器,您可以改用解析器函数,只要函数名称与类型定义中的字段名称对应即可。例如,author字段的解析器可以重写如下:

Post: {
  author (root) {
    return authors.find(author => author.id === root.authorId)
  },
}
  1. 使用ApolloServer从类型定义和解析器构建一个 GraphQL 模式实例。然后,启动服务器,如下所示:
// index.js
const server = new ApolloServer({ typeDefs, resolvers })

server.listen().then(({ url }) => {
  console.log(`Server ready at ${url}`)
})
  1. 使用终端上的node命令启动您的 GraphQL API:
$ node index.js
  1. 将浏览器指向localhost:4000。您应该在屏幕上看到加载的 GraphQL Playground。从那里,您可以测试您的 GraphQL API。因此,请在左侧的输入区域中输入以下查询:
{
  posts {
    title
    author {
      name
    }
  }
}

当您点击播放按钮时,您应该看到前面的 GraphQL 查询已经在右侧与 JSON 对象交换:

{
  "data": {
    "posts": [
      {
        "title": "Post 1",
        "author": {
          "name": "author A"
        }
      },
      ...
    ]
  }
}

这是美好而美妙的,不是吗?这就是我们如何使用 GraphQL 模式语言和 Apollo Server 轻松构建 GraphQL API。在采用速记方法之前,了解创建 GraphQL 模式和解析器的漫长而痛苦的方式是值得的。一旦您掌握了这些基本的具体知识,您应该能够轻松查询您使用 Keystone 存储的数据。在本书中,我们只涵盖了 GraphQL 的一些类型,包括标量类型、对象类型、查询类型和类型修改器。还有一些其他类型您应该查看,例如变异类型、枚举类型、联合和输入类型以及接口。请在graphql.org/learn/schema/上查看它们。

如果您想了解更多关于 GraphQL 的信息,请访问graphql.org/learn/。有关 Apollo Server 的更多信息,请访问www.apollographql.com/docs/apollo-server/

您可以在本书的 GitHub 存储库的/chapter-18/graphql-api/graphql-apollo/中找到本节中使用的代码,以及其他示例 GraphQL 类型定义。

现在,让我们学习如何使用 Keystone GraphQL API。

使用 Keystone GraphQL API

Keystone GraphQL API 的 GraphQL Playground 位于localhost:4000/admin/graphiql。在这里,我们可以在 Keystone 管理 UI 中测试我们创建的列表,位于localhost:4000/admin。Keystone 将为每个创建的列表自动生成四个顶级 GraphQL 查询。例如,我们将在上一节中创建的page列表中获得以下查询:

  • allPages

此查询可用于从Page列表中获取所有项目。我们还可以搜索、限制和过滤结果,如下所示:

{
  allPages (orderBy: "name_DESC", skip: 0, first: 6) {
    title
    content
  }
}
  • _allPagesMeta

此查询可用于获取Page列表中所有项目的所有元信息,例如所有匹配项目的总计数,这对分页可能很有用。我们还可以搜索、限制和过滤结果,如下所示:

{
  _allPagesMeta (search: "a") {
    count
  }
}
  • Page

此查询可用于从Page列表中获取单个项目。我们只能使用带有id键的where参数来获取页面,如下所示:

{
  Page (where: { id: $id }) {
    title
    content
  }
}
  • _PagesMeta

此查询可用于获取有关Page列表本身的元信息,例如其名称、访问权限、模式和字段,如下所示:

{
  _PagesMeta {
    name
    access {
      read
    }
    schema {
      queries
      fields {
        name
      }
    }
  }
}

正如您所看到的,这四个查询以及过滤器、限制和排序参数为我们提供了足够的能力来获取我们需要的特定数据,而不多。更重要的是,在 GraphQL 中,我们可以通过单个请求获取多个资源,如下所示:

{
  _allPagesMeta {
    count
  },
  allPages (orderBy: "name_DESC", skip: 0, first: 6) {
    title
    content
  }
}

这很神奇和有趣,不是吗?在 REST API 中,您可能需要向多个 API 端点发送多个请求以获取多个资源。GraphQL 为我们提供了一个解决这个困扰前端和后端开发人员的 REST API 的替代方案。请注意,这四个顶级查询也适用于我们创建的其他列表,包括ProjectImageNavLink

关于这四个顶级查询以及过滤器、限制和排序参数的更多信息,以及本书未涵盖的 GraphQL 变异和执行步骤,请访问www.keystonejs.com/guides/intro-to-graphql/

如果您想了解如何一般查询 GraphQL 服务器,请访问graphql.org/learn/queries/

现在您已经对 GraphQL 有了基本了解,并且了解了 Keystone 的顶级 GraphQL 查询,现在是时候学习如何在 Nuxt 应用程序中使用它们了。

集成 Keystone、GraphQL 和 Nuxt

Keystone 的 GraphQL API 端点位于localhost:4000/admin/api。与通常具有多个端点的 REST API 不同,GraphQL API 通常具有一个单一端点用于所有查询。因此,我们将使用此端点从 Nuxt 应用程序发送我们的 GraphQL 查询。在前端应用程序中,始终在 GraphQL Playground 上测试我们的查询以确认我们获得所需的结果,然后在前端应用程序中使用这些经过测试的查询是一个良好的做法。此外,我们应该始终在前端应用程序的查询中使用query关键字来从 GraphQL API 中获取数据。

在这个练习中,我们将重构为 WordPress API 构建的 Nuxt 应用程序。我们将查看/pages/index.vue/pages/projects/index.vue/pages/projects/_slug.vue/store/index.js文件。我们仍然将使用 Axios 来帮助我们发送 GraphQL 查询。让我们看看如何让 GraphQL 查询和 Axios 一起工作:

  1. 创建一个变量来存储 GraphQL 查询,以获取首页的标题和我们附加的幻灯片图片:
// pages/index.vue
const GET_PAGE = `
  query {
    allPages (search: "home") {
      title
      slideImages {
        alt
        link {
          name
        }
        file {
          publicUrl
        }
      }
    }
  }
`

我们只需要从图片链接到的项目页面中获取 slug,因此name字段是我们要查询的唯一字段。我们只需要图片文件对象的相对公共 URL,因此publicUrl字段是我们想要的唯一字段。此外,我们使用allPages查询而不是Page,因为通过 slug 获取页面更容易,而在这种情况下 slug 是home

  1. 使用 Axios 的post方法将查询发送到 GraphQL API 端点:
// pages/index.vue
export default {
  async asyncData ({ $axios }) {
    let { data } = await $axios.post('/admin/api', {
      query: GET_PAGE
    })
    return {
      post: data.data.allPages[0]
    }
  },
}

请注意,我们只需要从 GraphQL API 返回的数据中获取数组中的第一项,因此我们使用0来定位这第一项。

请注意,我们还应该重构/pages/about.vue/pages/contact.vue/pages/projects/index.vue/pages/projects/pages/_number.vue,遵循我们重构首页的相同模式。你可以在本节末尾找到这本书的 GitHub 存储库的路径,其中包含完整的代码。

  1. 创建一个变量来存储查询,并允许你从端点获取多个资源,如下所示:
// components/projects/project-items.vue
const GET_PROJECTS = `
  query {
    _allProjectsMeta {
      count
    }
    allProjects (orderBy: "name_DESC", skip: ${ skip }, first: ${ 
     postsPerPage }) {
      name
      title
      excerpt
      featuredImage {
        alt
        file {
          publicUrl
        }
      }
    }
  }
`

正如你所看到的,我们通过_allProjectsMeta获取项目页面的总数,并通过allProjects以及orderByskipfirst过滤器获取项目页面的列表。skipfirst过滤器的数据将作为变量传入;分别是skippostsPerPage

  1. 从路由参数计算skip变量的数据,将6设置为postsPerPage变量,然后使用 Axios 的post方法将查询发送到 GraphQL API 端点:
// components/projects/project-items.vue
data () {
  return {
    posts: [],
    totalPages: null,
    currentPage: null,
    nextPage: null,
    prevPage: null,
  }
},

async fetch () {
  const postsPerPage = 6
  const number = this.$route.params.number
  const pageNumber = number === undefined ? 1 : Math.abs(
    parseInt(number))
  const skip = number === undefined ? 0 : (pageNumber - 1) 
   * postsPerPage

  const GET_PROJECTS = `... `

  let { data } = await $axios.post('/admin/api', {
    query: GET_PROJECTS
  })

  //... continued in step 5.
}

正如您所看到的,我们从路由参数中的this.$route.params中访问pageNumber数据,然后在fetch方法中计算出来。skip数据是在将其传递给 GraphQL 查询并获取数据之前,从pageNumberpostsPerPage中计算出来的。在/projects/projects/pages/1路由上,我们将得到1作为pageNumber0作为skip,在/projects/pages/2路由上,我们将得到2作为pageNumber6作为skip,依此类推。此外,我们必须确保路由中的任何有意的负数据(例如/projects/pages/-100)都将使用 JavaScript 的Math.abs函数转换为正数。

有关 JavaScript 的Math.abs函数的更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/abs

  1. 从服务器返回的count字段创建分页(下一页和上一页),然后像往常一样返回数据给<template>块,如下所示:
// components/projects/project-items.vue
let totalPosts = data.data._allProjectsMeta.count
let totalMaxPages = Math.ceil(totalPosts / postsPerPage)

this.posts = data.data.allProjects
this.totalPages = totalMaxPages
this.currentPage = pageNumber
this.nextPage = pageNumber === totalMaxPages ? null : pageNumber + 1
this.prevPage = pageNumber === 1 ? null : pageNumber - 1
  1. 创建一个变量,用于存储从端点按slug获取单个项目页面的查询,如下所示:
// pages/projects/_slug.vue
const GET_PAGE = `
  query {
    allProjects (search: "${ params.slug }") {
      title
      content
      excerpt
      fullscreenImage { ... }
      projectImages { ... }
    }
  }
`

在这里,我们通过search过滤器从params.slug参数中传递数据来获取项目页面。我们将在fullscreenImagefullscreenImage中查询的字段与featuredImage中的字段相同;您可以在步骤 3中找到它们。

  1. 使用 Axios 的post方法将查询发送到 GraphQL API 端点:
// pages/projects/_slug.vue
async asyncData ({ params, $axios }) {
  const GET_PAGE = `...`

  let { data: { data: result } } = await $axios.post('/admin/api', 
   {
    query: GET_PAGE
  })

  return {
    post: result.allProjects[0],
  }
}

请注意,您还可以解构嵌套对象或数组并将变量分配给值。在前面的代码中,我们已经将result分配为变量,以便存储 GraphQL 返回的data属性的值。

  1. 创建一个变量,用于存储从带有orderBy过滤器的端点获取NavLinks列表的查询,如下所示:
// store/index.js
const GET_LINKS = `
  query {
    allNavLinks (orderBy: "order_ASC") {
      title
      link {
        name
      }
    }
  }
`
  1. 使用 Axios 的post方法将查询发送到 GraphQL API 端点,然后将数据提交到存储状态:
// store/index.js
async nuxtServerInit({ commit }, { $axios }) {
  const GET_LINKS = `...`
  let { data } = await $axios.post('/admin/api', {
    query: GET_LINKS
  })
  commit('setMenu', data.data.allNavLinks)
}
  1. (可选)就像在与 Nuxt 集成并从 WordPress 流式传输图像部分的步骤 9一样,如果 Nuxt 爬虫由于某些未知原因无法检测到动态路由,那么在 Nuxt 配置文件的生成选项中手动生成这些路由,如下所示:
// nuxt.config.js
import axios from 'axios'

export default {
  generate: {
    routes: async function () {
      const GET_PROJECTS = `
        query {
          allProjects { name }
        }
      `
      const { data } = await axios.post(remoteUrl + '/admin/api', {
        query: GET_PROJECTS
      })
      const routesProjects = data.data.allProjects.map(project => {
        return {
          route: '/projects/' + project.name,
          payload: project
        }
      })

      let totalMaxPages = Math.ceil(routesProjects.length / 6)
      let pagesProjects = []
      Array(totalMaxPages).fill().map((item, index) => {
        pagesProjects.push({
          route: '/projects/pages/' + (index + 1),
          payload: null
        })
      })

      const routes = [ ...routesProjects, ...pagesProjects ]
      return routes
    }
  },
}

在这个可选步骤中,您可以看到我们使用相同的 JavaScript 内置对象和方法 - Arraymapfillpush,就像在与 Nuxt 集成并从 WordPress 流式传输图像部分中一样,为我们解决子页面的动态路由和分页,然后将它们作为单个数组返回给 Nuxt 生成它们的动态路由。

  1. 运行以下脚本命令,无论是开发还是生产:
$ npm run dev
$ npm run build && npm run start
$ npm run stream && npm run generate

请记住,如果您想生成静态页面并将图像托管在同一位置,我们有能力将远程图像流式传输到/assets/目录,以便 webpack 可以为我们处理这些图像。因此,如果您想这样做,那么就像我们之前做的那样,首先运行npm run stream将远程图像流式传输到本地磁盘,然后运行npm run generate重新生成带有图像的静态页面,然后将它们托管在某个地方。

您可以在本书的 GitHub 存储库中的/chapter-18/cross-domain/frontend/nuxt-universal/nuxt-keystone中找到此练习的代码。

除了使用 Axios,您还可以使用 Nuxt Apollo 模块向服务器发送 GraphQL 查询。有关此模块及其用法的更多信息,请访问github.com/nuxt-community/apollo-module

干得好!您已成功将 Nuxt 与 Keystone GraphQL API 集成,并为静态页面流式传输远程资源 - 就像您在 WordPress REST API 中所做的那样。我们希望 Keystone 和 GraphQL 特别是向您展示了另一个令人兴奋的 API 选项。您甚至可以进一步利用本章学到的 GraphQL 知识,为 Nuxt 应用程序开发自己的 GraphQL API。您还可以通过本书介绍的许多其他技术,将 Nuxt 提升到更高的水平。这本书是一次相当艰难的旅程。我们希望它对您的 Web 开发有所裨益,并且您可以将从本书中学到的知识应用到更广泛的领域。现在,让我们总结一下您在本章学到的内容。

摘要

在这一章中,您成功创建了自定义文章类型和路由,以扩展 WordPress REST API,并与 Nuxt 集成,并从 WordPress 流式传输远程资源以生成静态页面。您还成功通过创建列表和字段来定制了 Keystone 的 CMS。然后,您学会了如何使用 GraphQL.js 在低级别创建 GraphQL API,并使用 GraphQL 模式语言和 Apollo Server 在高级别创建 GraphQL API。现在您已经掌握了 GraphQL 的基础知识,可以使用 GraphQL 查询和 Axios 从 Nuxt 应用程序查询 Keystone GraphQL API。最后,您还可以从 Keystone 项目流式传输远程资源到 Nuxt 项目以生成静态页面。干得好!

这是一个非常漫长的旅程。您从了解 Nuxt 的目录结构到添加页面、路由、过渡、组件、Vuex 存储、插件和模块,然后到创建用户登录和 API 身份验证,编写端到端测试,并创建 Nuxt SPA(静态页面)。您还将 Nuxt 与其他技术、工具和框架集成,包括 MongoDB、RethinkDB、MySQL、PostgreSQL 和 GraphQL;Koa、Express、Keystone 和 Socket.IO;PHP 和 PSRs;Zurb Foundation 和 Less CSS;以及 Prettier、ESLint 和 StandardJS。

我们希望这是一个激励人心的旅程,希望您在项目中采用 Nuxt,并进一步发展,使自己和社区受益。继续编码,激发灵感,保持激励。祝您一切顺利。

请注意,本书的最终应用程序示例可以在作者的网站上找到。这是一个完全由 Nuxt 的static目标和 GraphQL 制作的静态生成的网络应用程序!请查看并在lauthiamkok.net/上探索。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报