Vue2-Web-开发项目-全-

Vue2 Web 开发项目(全)

原文:zh.annas-archive.org/md5/632F664CBB74089B16065B30D26C6055

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

作为一个相对较新的 UI 库,Vue 是当前领先的库(如 Angular 和 React)的一个非常严肃的挑战者。它有很多优点--它简单、灵活、非常快速,但它仍然提供了构建现代 Web 应用程序所需的所有功能。

它的渐进性使得很容易上手,然后您可以使用更高级的功能来扩展您的应用程序。Vue 还拥有一个丰富的生态系统,包括官方的一级库,用于路由和状态管理、引导和单元测试。Vue 甚至支持开箱即用的服务器端渲染!

所有这些都得益于一个令人惊叹的社区和一个驱动网络创新的出色核心团队,使 Vue 成为一个可持续的开源项目。

为了帮助您学习 Vue 并使用它构建应用程序,本书被构建为一系列六个指南。每个指南都是一个具体的项目,在其中您将自己构建一个真正的应用程序。这意味着到最后,您将有六个 Vue 应用程序正在运行!

就像 Vue 一样,这些项目是渐进的,并逐步引入新的主题,以使您的学习体验更加轻松。最初的项目不需要大量的配置或构建工具,因此您可以立即制作具体的应用程序。然后,更高级的主题将逐步添加到项目中,以便您在本书结束时将拥有完整的技能。

本书涵盖的内容

第一章,开始使用 Vue,介绍了如何使用指令创建一个具有动态模板和基本交互性的基本 Vue 应用程序。

第二章,项目 1 - Markdown 笔记本,探讨了如何创建一个完整的 Vue 应用程序,具有计算属性、方法、生命周期钩子、列表显示、DOM 事件、动态 CSS、模板条件和过滤器格式。

第三章,项目 2 - 城堡决斗浏览器游戏,解释了作为可重用组件树的浏览器卡牌游戏的创建,这些组件相互通信。它还具有动画和动态 SVG 图形。

第四章,高级项目设置,着重介绍如何使用官方的 Vue 命令行工具来使用 webpack、babel 和更多构建工具来启动一个完整的项目。它还涵盖了单文件组件格式,使读者能够创建组件作为构建模块。

第五章,项目 3 - 支持中心,带您了解如何使用官方路由库来构建多页面应用程序--嵌套路由、动态参数、导航守卫等。该项目还包括一个自定义用户登录系统。

第六章,项目 4 - 地理定位博客,介绍了一个特色是 Google OAuth 登录和 Google Maps API 的应用程序的创建过程。本章还涵盖了使用官方 VueX 库进行状态管理以及快速功能组件的重要主题。

第七章,项目 5 - 在线商店和扩展,概述了高级开发技术,如使用 ESLint 检查代码质量,使用 Jest 对 Vue 组件进行单元测试,将应用程序翻译成多种语言,以及通过服务器端渲染来提高速度和 SEO。

第八章,项目 6 - 使用 Meteor 实时仪表板,教您如何在 Meteor 应用程序中使用 Vue,以利用这个全栈框架的实时能力。

这本书需要什么

要遵循这本书,您只需要一个文本或代码编辑器(推荐使用 Visual Studio Code 和 Atom)和一个网络浏览器(最好使用 Firefox 或 Chrome 的最新版本进行开发工具)。

这本书适合谁

如果您是一名网页开发人员,现在想要使用 Vue.js 创建丰富和交互式的专业应用程序,那么这本书适合您。假定您具有 JavaScript 的先验知识。熟悉 HTML、Node.js 和 npm、webpack 等工具将有所帮助,但并非必需。

约定

在这本书中,您将找到许多不同类型信息的文本样式。以下是一些样式的示例,以及它们的含义解释。

文本中的代码单词显示如下: "我们可以通过d3.select函数选择 HTML 元素。"

代码块设置如下:

class Animal
{
public:
virtual  void Speak(void) const //virtual in the base class {
  //Using the Mach 5 console print
  M5DEBUG_PRINT("...\n");
}
New terms and important words are shown in bold. Words that you see on the screen, in menus or dialog boxes for example, appear in the text like this: "Clicking the Next button moves you to the next screen."

警告或重要说明会以这样的方式出现在一个框中。

提示和技巧会以这种方式出现。

第一章:开始使用 Vue

Vue (vuejs.org/)是一个专注于构建 Web 用户界面的 JavaScript 库。在本章中,我们将了解这个库,并在简要介绍之后,我们将开始创建一个 Web 应用程序,为我们在本书中一起构建的不同项目奠定基础。

为什么需要另一个前端框架?

Vue 在 JavaScript 前端领域是一个相对新手,但是对当前主要的库来说是一个非常严肃的挑战者。它简单、灵活、非常快速,同时还提供了许多功能和可选工具,可以帮助您高效地构建现代 Web 应用程序。它的创造者Evan You称其为渐进式框架

  • Vue 是可以逐步采用的,核心库专注于用户界面,您可以在现有项目中使用它

  • 你可以制作小型原型,一直到大型复杂的 Web 应用程序

  • Vue 是易于接近的-初学者可以轻松掌握这个库,而经验丰富的开发人员可以很快提高生产力

Vue 大致遵循模型-视图-视图模型架构,这意味着视图(用户界面)和模型(数据)是分开的,视图模型(Vue)是两者之间的中介。它会自动处理更新,并已经为您进行了优化。因此,您不必指定视图的哪一部分应该更新,因为 Vue 会选择正确的方式和时间来进行更新。

该库还从其他类似的库(如 React、Angular 和 Polymer)中汲取灵感。以下是其核心特性的概述:

  • 一个反应灵敏的数据系统可以自动更新您的用户界面,具有轻量级的虚拟 DOM 引擎和最小的优化工作是必需的

  • 灵活的视图声明-艺术家友好的 HTML 模板、JSX(JavaScript 内的 HTML)或超文本渲染函数(纯 JavaScript)

  • 可组合的用户界面,具有可维护和可重用的组件

  • 官方伴随库提供了路由、状态管理、脚手架和更高级的功能,使 Vue 成为一个非武断但完全成熟的前端框架

一个热门项目

Evan You在 2013 年开始在谷歌工作时着手开发了 Vue 的第一个原型,当时他正在使用 Angular。最初的目标是拥有 Angular 的所有很酷的特性,比如数据绑定和数据驱动的 DOM,但不包含使这个框架武断和难以学习和使用的额外概念。

2014 年 2 月,第一个公开版本发布,第一天就取得了巨大成功,在 HackerNews 的首页、/r/javascript排名第一,并且官方网站访问量达到了 1 万次。

第一个主要版本 1.0 于 2015 年 10 月发布,到年底,npm 下载量飙升至 382k,GitHub 仓库获得了 11k 颗星,官方网站访问量达到了 363k,流行的 PHP 框架 Laravel 选择了 Vue 作为其官方前端库,而不是 React。

第二个主要版本 2.0 于 2016 年 9 月发布,采用了基于虚拟 DOM 的新渲染器和许多新功能,如服务器端渲染和性能改进。这就是本书中将使用的版本。现在它是最快的前端库之一,甚至在与 React 团队精心比较后,超过了 React(vuejs.org/v2/guide/comparison)。撰写本书时,Vue 是 GitHub 上第二受欢迎的前端库,拥有 72k 颗星,仅次于 React,领先于 Angular 1(github.com/showcases/front-end-javascript-frameworks)。

路线图上该库的下一个发展阶段包括更多与 Vue 原生库(如 Weex 和 NativeScript)的集成,以创建具有 Vue 的原生移动应用程序,以及新功能和改进。

如今,Vue 被许多公司使用,如微软、Adobe、阿里巴巴、百度、小米、Expedia、任天堂和 GitLab。

兼容性要求

Vue 没有任何依赖,可以在任何符合 ECMAScript 5 最低标准的浏览器中使用。这意味着它与 Internet Explorer 8 或更低版本不兼容,因为它需要相对较新的 JavaScript 功能,如Object.defineProperty,这在旧版浏览器上无法进行 polyfill。

在本书中,我们使用 JavaScript 版本 ES2015(以前是 ES6)编写代码,因此在前几章中,您需要一个现代浏览器来运行示例(如 Edge、Firefox 或 Chrome)。在某个时候,我们将介绍一个名为Babel的编译器,它将帮助我们使我们的代码与旧版浏览器兼容。

一分钟设置

话不多说,让我们开始用一个非常快速的设置创建我们的第一个 Vue 应用程序。Vue 足够灵活,可以通过简单的script标签包含在任何网页中。让我们创建一个非常简单的网页,其中包括该库,一个简单的div元素和另一个script标签:

<html>
<head>
  <meta charset="utf-8">
  <title>Vue Project Guide setup</title>
</head>
<body>

  <!-- Include the library in the page -->
  <script src="https://unpkg.com/vue/dist/vue.js"></script>

  <!-- Some HTML -->
  <div id="root">
    <p>Is this an Hello world?</p>
  </div>

  <!-- Some JavaScript -->
  <script>
  console.log('Yes! We are using Vue version', Vue.version)
  </script>

</body>
</html>

在浏览器控制台中,我们应该有类似这样的东西:

Yes! We are using Vue version 2.0.3

正如您在前面的代码中所看到的,该库公开了一个包含我们需要使用它的所有功能的Vue对象。我们现在准备好了。

创建一个应用程序

目前,我们的网页上没有任何 Vue 应用程序在运行。整个库都是基于Vue 实例的,它们是视图和数据之间的中介。因此,我们需要创建一个新的 Vue 实例来启动我们的应用程序:

// New Vue instance
var app = new Vue({
  // CSS selector of the root DOM element
  el: '#root',
  // Some data
  data () {
    return {
      message: 'Hello Vue.js!',
    }
  },
})

使用new关键字调用 Vue 构造函数来创建一个新实例。它有一个参数--选项对象。它可以有多个属性(称为选项),我们将在接下来的章节中逐渐发现。目前,我们只使用了其中的两个。

使用el选项,我们告诉 Vue 在哪里使用 CSS 选择器在我们的网页上添加(或“挂载”)实例。在这个例子中,我们的实例将使用<div id="root"> DOM 元素作为其根元素。我们也可以使用 Vue 实例的$mount方法而不是el选项:

var app = new Vue({
  data () {
    return {
      message: 'Hello Vue.js!',
    }
  },
})
// We add the instance to the page
app.$mount('#root')

大多数 Vue 实例的特殊方法和属性都以美元符号开头。

我们还将在data选项中初始化一些数据,其中包含一个包含字符串的message属性。现在 Vue 应用程序正在运行,但它还没有做太多事情。

您可以在单个网页上添加尽可能多的 Vue 应用程序。只需为它们中的每一个创建一个新的 Vue 实例,并将它们挂载在不同的 DOM 元素上。当您想要将 Vue 集成到现有项目中时,这将非常方便。

Vue devtools

Vue 的官方调试工具在 Chrome 上作为一个名为 Vue.js devtools 的扩展可用。它可以帮助您查看您的应用程序的运行情况,以帮助您调试您的代码。您可以从 Chrome Web Store(chrome.google.com/webstore/search/vue)或 Firefox 附加组件注册表(addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/?src=ss)下载它。

对于 Chrome 版本,您需要设置一个额外的设置。在扩展设置中,启用允许访问文件 URL,以便它可以检测到从本地驱动器打开的网页上的 Vue:

在您的网页上,使用F12快捷键(或在 OS X 上使用Shift + command + c)打开 Chrome Dev Tools,并搜索 Vue 标签(它可能隐藏在 More tools...下拉菜单中)。一旦打开,您可以看到一个树,其中包含我们的 Vue 实例,按照惯例命名为 Root。如果单击它,侧边栏将显示实例的属性:

您可以随意拖动devtools选项卡。不要犹豫将其放在前面的选项卡中,因为在 Vue 不处于开发模式或根本没有运行时,它将被隐藏在页面中。

您可以使用name选项更改实例的名称:

var app = new Vue({
  name: 'MyApp',
  // ...
})

这将帮助您在拥有更多实例时看到它们在开发工具中的位置:

模板使您的 DOM 动态化

使用 Vue,我们有几个系统可供编写我们的视图。现在,我们将从模板开始。模板是描述视图的最简单方式,因为它看起来很像 HTML,但有一些额外的语法,使 DOM 动态更新非常容易。

显示文本

我们将看到的第一个模板功能是文本插值,用于在网页内显示动态文本。文本插值语法是一对双大括号,其中包含任何类型的 JavaScript 表达式。当 Vue 处理模板时,其结果将替换插值。用以下内容替换<div id="root">元素:

<div id="root">
  <p>{{ message }}</p>
</div>

此示例中的模板具有一个<p>元素,其内容是message JavaScript 表达式的结果。它将返回我们实例的 message 属性的值。现在,您的网页上应该显示一个新的文本--Hello Vue.js!。看起来不像什么,但 Vue 在这里为我们做了很多工作--我们现在的 DOM 与我们的数据连接起来了。

为了证明这一点,打开浏览器控制台,更改app.message的值,然后按键盘上的Enter

app.message = 'Awesome!'

消息已更改。这称为数据绑定。这意味着 Vue 能够在数据更改时自动更新 DOM,而无需您的任何操作。该库包括一个非常强大和高效的响应系统,可以跟踪所有数据,并能够在某些内容更改时更新所需的内容。所有这些都非常快速。

使用指令添加基本交互性

让我们为我们原本相当静态的应用程序添加一些互动,例如,一个文本输入框,允许用户更改显示的消息。我们可以在模板中使用特殊的 HTML 属性,称为指令来实现这一点。

Vue 中的所有指令都以v-开头,并遵循 kebab-case 语法。这意味着您应该用破折号分隔单词。请记住,HTML 属性不区分大小写(它们是大写还是小写都无关紧要)。

我们在这里需要的指令是v-model,它将绑定我们的<input>元素的值与我们的message数据属性。在模板中添加一个带有v-model="message"属性的新的<input>元素:

<div id="root">
  <p>{{ message }}</p>
  <!-- New text input -->
  <input v-model="message" />
</div>

当输入值发生变化时,Vue 现在会自动更新message属性。您可以尝试更改输入内容,以验证文本随着您的输入而更新,devtools 中的值也会发生变化:

Vue 中有许多其他指令可用,甚至可以创建自己的指令。不用担心,我们将在后面的章节中介绍这些内容。

摘要

在本章中,我们快速设置了一个网页,开始使用 Vue 并编写了一个简单的应用程序。我们创建了一个 Vue 实例来挂载 Vue 应用程序,并编写了一个模板来使 DOM 动态化。在这个模板中,我们使用了 JavaScript 表达式来显示文本,感谢文本插值。最后,我们通过一个输入元素添加了一些互动,将其与v-model指令绑定到我们的数据上。

在下一章中,我们将使用 Vue 创建我们的第一个真正的 Web 应用程序--一个 Markdown 笔记本。我们将需要更多的 Vue 超能力,将这个应用程序的开发变成一个有趣而迅速的体验。

第二章:项目 1 - Markdown 笔记本

我们将创建的第一个应用是一个 Markdown 笔记本,逐步使用几个 Vue 功能。我们将重复使用我们在第一章中看到的内容,使用 Vue 入门,并在此基础上添加更多元素,如有用的指令,用户交互的事件,更多实例选项和用于处理值的过滤器。

在我们开始编写代码之前,让我们谈谈这个应用并回顾我们的目标:

  • 笔记本应用将允许用户以 Markdown 格式编写笔记

  • Markdown 将实时预览

  • 用户可以添加任意数量的笔记

  • 下次用户访问应用时,笔记将被恢复

为了做到这一点,我们将把用户界面分成三个部分:

  • 中间的主要部分,带有笔记编辑器

  • 右侧窗格,预览当前笔记的 Markdown

  • 左侧窗格,显示笔记列表和添加新笔记的按钮

在本章结束时,它将看起来像这样:

一个基本的笔记编辑器

我们将从一个非常简单的 Markdown 笔记应用开始,只在左侧显示文本编辑器和右侧显示 Markdown 预览。然后,我们将把它变成一个具有多个笔记支持的完整笔记本。

设置项目

对于这个项目,我们将准备好一些文件来帮助我们开始:

  1. 首先,下载simple-notebook项目文件并将其解压缩到同一个文件夹中。打开index.html文件,并添加一个带有notebook ID 的div元素和一个带有main类的嵌套section元素。文件内应包含以下内容:
      <html>
      <head>
        <title>Notebook</title>
        <!-- Icons & Stylesheets -->
        <link href="https://fonts.googleapis.com/icon?                   
        family=Material+Icons" rel="stylesheet">
        <link rel="stylesheet" href="style.css" />
      </head>
      <body>
        <!-- Include the library in the page -->
        <script src="https://unpkg.com/vue/dist/vue.js"></script>

        <!-- Notebook app -->
        <div id="notebook">

          <!-- Main pane -->
          <section class="main">

          </section>

        </div>

        <!-- Some JavaScript -->
        <script src="script.js"></script>
      </body>
      </html>
  1. 现在,打开script.js文件添加一些 JavaScript。就像你在第一章中所做的那样,使用 Vue 入门,创建一个 Vue 实例,挂载在#notebook元素上,使用 Vue 构造函数:
      // New VueJS instance
      new Vue({
        // CSS selector of the root DOM element
        el: '#notebook',
      })
  1. 然后,添加一个名为content的数据属性,用于保存笔记的内容:
      new Vue({
        el: '#notebook',

        // Some data
        data () {
          return {
            content: 'This is a note.',
          }
        },
      })

现在你已经准备好创建你的第一个真正的 Vue 应用了。

笔记编辑器

现在我们的应用正在运行,让我们添加文本编辑器。我们将使用一个简单的textarea元素和我们在第一章中看到的v-model指令,使用 Vue 入门

创建一个section元素并将textarea放入其中,然后添加绑定到我们的content属性的v-model指令:

<!-- Main pane -->
<section class="main">
  <textarea v-model="content"></textarea>
</section>

现在,如果你改变笔记编辑器中的文本,content的值应该会自动在 devtools 中改变。

v-model指令不仅限于文本输入。你还可以在其他表单元素中使用它,比如复选框、单选按钮,甚至是自定义组件,正如我们将在本书中看到的那样。

预览窗格

要将笔记 markdown 编译为有效的 HTML,我们将需要一个名为 Marked 的额外库(www.npmjs.com/package/marked):

  1. 在引用 Vue 的script标签后,将库包含在页面中:
      <!-- Include the library in the page -->
      <script src="https://unpkg.com/vue/dist/vue.js"></script>
      <!-- Add the marked library: -->
      <script src="https://unpkg.com/marked"></script>

marked非常容易使用--只需用 markdown 文本调用它,它就会返回相应的 HTML。

  1. 尝试使用一些 markdown 文本来测试库:
      const html = marked('**Bold** *Italic* [link]   
      (http://vuejs.org/)')
      console.log(html)

你应该在浏览器控制台中看到以下输出:

<p><strong>Bold</strong> <em>Italic</em>
<a href="http://vuejs.org/">link</a></p>

计算属性

Vue 非常强大的一个特性是计算属性。它允许我们定义新的属性,结合任意数量的属性并使用转换,比如将 markdown 字符串转换为 HTML--这就是为什么它的值由一个函数定义。计算属性具有以下特点:

  • 该值被缓存,因此如果不需要,函数就不会重新运行,从而防止无用的计算

  • 当函数内部使用的属性发生变化时,它会根据需要自动更新

  • 计算属性可以像任何属性一样使用(你可以在其他计算属性中使用计算属性)

  • 直到在应用程序的某个地方真正使用它之前,它都不会被计算。

这将帮助我们自动将笔记 markdown 转换为有效的 HTML,这样我们就可以实时显示预览。我们只需要在computed选项中声明我们的计算属性:

// Computed properties
computed: {
  notePreview () {
    // Markdown rendered to HTML
    return marked(this.content)
  },
},

文本插值转义

让我们尝试使用文本插值在新窗格中显示我们的笔记:

  1. 创建一个带有preview类的<aside>元素,显示我们的notePreview计算属性:
      <!-- Preview pane -->
      <aside class="preview">
        {{ notePreview }}
      </aside>

现在我们应该在应用程序的右侧看到预览窗格显示我们的笔记。如果你在笔记编辑器中输入一些文本,你应该会看到预览自动更新。然而,我们的应用程序存在一个问题,当你使用 markdown 格式时会出现问题。

  1. 尝试使用**将文本加粗,如下所示:
      I'm in **bold**!

我们的计算属性应该返回有效的 HTML,并且我们的预览窗格中应该呈现一些粗体文本。相反,我们可以看到以下内容:

I'm in <strong>bold</strong>!

我们刚刚发现文本插值会自动转义 HTML 标记。这是为了防止注入攻击并提高我们应用程序的安全性。幸运的是,有一种方法可以显示一些 HTML,我们马上就会看到。然而,这会迫使您考虑使用它来包含潜在有害的动态内容。

例如,您可以创建一个评论系统,任何用户都可以在您的应用页面上写一些评论。如果有人在评论中写入一些 HTML,然后在页面上显示为有效的 HTML,会怎么样?他们可以添加一些恶意的 JavaScript 代码,您的应用程序的所有访问者都会变得脆弱。这被称为跨站脚本攻击,或者 XSS 攻击。这就是为什么文本插值总是转义 HTML 标记。

不建议在应用程序的用户创建的内容上使用v-html。他们可以在<script>标签内编写恶意 JavaScript 代码,这将被执行。然而,通过正常的文本插值,您将是安全的,因为 HTML 不会被执行。

显示 HTML

现在我们知道出于安全原因文本插值无法渲染 HTML,我们将需要另一种方式来渲染动态 HTML--v-html指令。就像我们在第一章中看到的v-model指令一样,这是一个特殊的属性,为我们的模板添加了一个新功能。它能够将任何有效的 HTML 字符串渲染到我们的应用程序中。只需将字符串作为值传递,如下所示:

<!-- Preview pane -->
<aside class="preview" v-html="notePreview">
</aside>

现在,markdown 预览应该可以正常工作,并且 HTML 会动态插入到我们的页面中。

我们aside元素内的任何内容都将被v-html指令的值替换。您可以使用它来放置占位内容。

这是您应该得到的结果:

对于文本插值,还有一个等效的指令v-text,它的行为类似于v-html,但会像经典文本插值一样转义 HTML 标记。

保存笔记

目前,如果关闭或刷新应用程序,您的笔记将丢失。在下次打开应用程序时保存和加载它是个好主意。为了实现这一点,我们将使用大多数浏览器提供的标准localStorage API。

观察变化

我们希望在笔记内容发生变化时立即保存笔记。这就是为什么我们需要一些在content数据属性发生变化时被调用的东西,比如观察者。让我们向我们的应用程序添加一些观察者!

  1. 在 Vue 实例中添加一个新的watch选项。

这个选项是一个字典,其中键是被观察属性的名称,值是一个观察选项对象。这个对象必须有一个handler属性,它可以是一个函数或者一个方法的名称。处理程序将接收两个参数--被观察属性的新值和旧值。

这是一个带有简单处理程序的例子:

new Vue({
  // ...

  // Change watchers
  watch: {
    // Watching 'content' data property
    content: {
      handler (val, oldVal) {
        console.log('new note:', val, 'old note:', oldVal)
      },
    },
  },
})

现在,当你在笔记编辑器中输入时,你应该在浏览器控制台中看到以下消息:

new note: This is a **note**! old note: This is a **note**

这将在笔记发生变化时非常有帮助。

你可以在handler旁边使用另外两个选项:

  • deep是一个布尔值,告诉 Vue 递归地观察嵌套对象内的变化。这在这里并不有用,因为我们只观察一个字符串。

  • immediate也是一个布尔值,强制处理程序立即被调用,而不是等待第一次变化。在我们的情况下,这不会有实质性的影响,但我们可以尝试一下来注意它的影响。

这些选项的默认值是false,所以如果你不需要它们,你可以完全跳过它们。

  1. immediate选项添加到观察者中:
      content: {
        handler (val, oldVal) {
          console.log('new note:', val, 'old note:', oldVal)      
        },
        immediate: true,
      },

一旦你刷新应用程序,你应该在浏览器控制台中看到以下消息弹出:

new note: This is a **note** old note: undefined

毫不奇怪,笔记的旧值是undefined,因为观察者处理程序在实例创建时被调用。

  1. 我们这里真的不需要这个选项,所以继续删除它:
      content: {
        handler (val, oldVal) {
          console.log('new note:', val, 'old note:', oldVal)
        },
      },

由于我们没有使用任何选项,我们可以通过跳过包含handler选项的对象来使用更短的语法:

content (val, oldVal) {
  console.log('new note:', val, 'old note:', oldVal)
},

这是当你不需要其他选项时观察者的最常见语法,比如deepimmediate

  1. 让我们保存我们的笔记。使用localStorage.setItem() API 来存储笔记内容:
      content (val, oldVal) {
        console.log('new note:', val, 'old note:', oldVal)
        localStorage.setItem('content', val)
      },

要检查这是否起作用,编辑笔记并在应用程序或存储选项卡中打开浏览器开发工具(取决于你的浏览器),你应该在本地存储部分下找到一个新的条目:

使用一个方法

有一个很好的编码原则说不要重复自己,我们真的应该遵循它。这就是为什么我们可以在可重用的函数中写一些逻辑,称为methods。让我们把我们的保存逻辑移到一个方法中:

  1. 在 Vue 实例中添加一个新的methods选项,并在那里使用localStorage API:
      new Vue({
        // ...

        methods: {
          saveNote (val) {
            console.log('saving note:', val)
            localStorage.setItem('content', val)
          },
        },
      })
  1. 我们现在可以在观察者的handler选项中使用方法名:
      watch: {
        content: {
          handler: 'saveNote',
        },
      },

或者,我们可以使用更短的语法:

watch: {
  content: 'saveNote',
},

访问 Vue 实例

在方法内部,我们可以使用this关键字访问 Vue 实例。例如,我们可以调用另一个方法:

methods: {
  saveNote (val) {
    console.log('saving note:', val)
    localStorage.setItem('content', val)
    this.reportOperation('saving')
  },
  reportOperation (opName) {
    console.log('The', opName, 'operation was completed!')
  },
},

在这里,saveNote方法将从contentChanged方法中调用。

我们还可以通过this访问 Vue 实例的其他属性和特殊函数。我们可以删除saveNote参数并直接访问content数据属性:

methods: {
  saveNote () {
    console.log('saving note:', this.content)
    localStorage.setItem('content', this.content)
  },
},

这也适用于我们在监视更改部分创建的观察程序处理程序:

watch: {
  content (val, oldVal) {
    console.log('new note:', val, 'old note:', oldVal)
    console.log('saving note:', this.content)
    localStorage.setItem('content', this.content)
  },
},

基本上,您可以在任何绑定到它的函数中使用this访问 Vue 实例:方法、处理程序和其他钩子。

加载保存的笔记

现在我们每次更改时保存笔记内容,当应用程序重新打开时,我们需要恢复它。我们将使用localStorage.getItem() API。在您的 JavaScript 文件末尾添加以下行:

console.log('restored note:', localStorage.getItem('content'))

当您刷新应用程序时,您应该在浏览器控制台中看到保存的笔记内容。

生命周期钩子

恢复我们的笔记内容到 Vue 实例的第一种方式是在创建实例时设置内容数据属性。

每个 Vue 实例都遵循一个精确的生命周期,有几个步骤--它将被创建、挂载到页面上、更新,最后销毁。例如,在创建步骤期间,Vue 将使实例数据具有反应性。

钩子是一组特定的函数,在某个时间点自动调用。它们允许我们自定义框架的逻辑。例如,我们可以在创建 Vue 实例时调用一个方法。

我们有多个可用的钩子来在每个步骤发生时执行逻辑,或者在这些步骤之前执行逻辑:

  • beforeCreate:在 Vue 实例对象创建时调用(例如,使用new Vue({})),但在 Vue 对其进行任何操作之前调用。

  • created:在实例准备就绪并完全运行后调用。请注意,在此时,实例尚未在 DOM 中。

  • beforeMount:在实例添加(或挂载)到网页上之前调用。

  • mounted:当实例在页面上可见时调用。

  • beforeUpdate:当实例需要更新时调用(通常是在数据或计算属性发生变化时)。

  • updated:在数据更改应用到模板后调用。请注意,DOM 可能尚未更新。

  • beforeDestroy:在实例被拆除之前调用。

  • destroyed:在实例完全移除时调用。

目前,我们将仅使用created钩子来恢复笔记内容。要添加生命周期钩子,只需将具有相应名称的函数添加到 Vue 实例选项中:

new Vue({
  // ...

  // This will be called when the instance is ready
  created () {
    // Set the content to the stored value
    // or to a default string if nothing was saved
    this.content = localStorage.getItem('content') || 'You can write in **markdown**'
  },
})

现在,当您刷新应用程序时,created钩子将在实例创建时自动调用。这将把content数据属性值设置为恢复的结果,或者如果结果为假,则设置为'You can write in **markdown**',以防我们之前没有保存任何内容。

在 JavaScript 中,当值等于false0、空字符串、nullundefinedNaN(不是一个数字)时,该值为假。在这里,如果对应的键在浏览器本地存储数据中不存在,localStorage.getItem()函数将返回null

我们设置的观察者也被调用,因此笔记被保存,您应该在浏览器控制台中看到类似于这样的内容:

new note: You can write in **markdown** old note: This is a note
saving note: You can write in **markdown**
The saving operation was completed!

我们可以看到,当调用 created 钩子时,Vue 已经设置了数据属性及其初始值(这里是This is a note)。

直接在数据中初始化

另一种方法是直接使用恢复的值初始化content数据属性:

new Vue({
  // ...

  data () {
    return {
      content: localStorage.getItem('content') || 'You can write in **markdown**',
    }
  },

  // ...
})

在上述代码中,观察者处理程序不会被调用,因为我们初始化了content值而不是改变它。

多个笔记

只有一个笔记的笔记本并不那么有用,所以让我们将其变成一个多笔记本。我们将在左侧添加一个新的侧边栏,其中包含笔记列表,以及一些额外的元素,例如重命名笔记的文本字段和一个收藏切换按钮。

笔记列表

现在,我们将为包含笔记列表的侧边栏奠定基础:

  1. 在主要部分之前添加一个带有side-bar类的新aside元素:
      <!-- Notebook app -->
      <div id="notebook">

        <!-- Sidebar -->
        <aside class="side-bar">
          <!-- Here will be the note list -->
        </aside>

        <!-- Main pane -->
        <section class="main">
      ...
  1. 添加一个名为notes的新数据属性--它将是包含所有笔记的数组:
      data () {
        return {
          content: ...
          // New! A note array
          notes: [],
        }
      },

创建一个新笔记的方法

我们的每个笔记将是一个具有以下数据的对象:

  • id:这将是笔记的唯一标识符

  • title:这将包含在列表中显示的笔记名称

  • content:这将是笔记的 markdown 内容

  • created:这将是笔记创建的日期

  • favorite:这将是一个布尔值,允许将要在列表顶部显示的笔记标记为收藏

让我们添加一个方法,它将创建一个新的笔记并将其命名为addNote,它将创建一个具有默认值的新笔记对象:

methods:{
  // Add a note with some default content and select it
  addNote () {
    const time = Date.now()
    // Default new note
    const note = {
      id: String(time),
      title: 'New note ' + (this.notes.length + 1),
      content: '**Hi!** This notebook is using [markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) for formatting!',
      created: time,
      favorite: false,
    }
    // Add to the list
    this.notes.push(note)
  },
}

我们获取当前时间(这意味着自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的毫秒数),这将是在每个笔记上具有唯一标识符的完美方式。我们还设置默认值,比如标题和一些内容,以及created日期和favorite布尔值。最后,我们将笔记添加到 notes 数组属性中。

使用 v-on 绑定按钮和点击事件

现在,我们需要一个按钮来调用这个方法。在具有 toolbar 类的div元素内创建一个新的按钮元素:

<aside class="side-bar">
  <!-- Toolbar -->
  <div class="toolbar">
    <!-- Add note button -->
    <button><i class="material-icons">add</i> Add note</button>
  </div>
</aside>

当用户单击按钮时调用addNote方法时,我们将需要一个新的指令--v-on。值将是在捕获事件时调用的函数,但它还需要一个参数来知道要监听哪个事件。但是,你可能会问,我们如何将参数传递给指令呢?这很简单!在指令名称后添加一个:字符,然后是参数。这是一个例子:

<button v-directive:argument="value">

在我们的情况下,我们正在使用v-on指令,事件名称作为参数,更具体地说,是click事件。它应该是这样的:

<button v-on:click="callback">

当我们点击按钮时,我们的按钮应该调用addNote方法,所以继续修改我们之前添加的按钮:

<button v-on:click="addNote"><i class="material-icons">add</i> Add note</button>

v-on指令还有一个可选的特殊快捷方式--@字符,允许你将前面的代码重写为以下内容:

<button @click="addNote"><i class="material-icons">add</i> Add note</button>

现在我们的按钮已经准备好了,试着添加一些笔记。我们还没有在应用程序中看到它们,但你可以打开开发工具并注意到笔记列表的变化:

使用 v-bind 绑定属性

如果工具提示显示了我们在“添加笔记”按钮上已经有的笔记数量,那将会很有帮助,不是吗?至少我们可以介绍另一个有用的指令!

工具提示是通过 title HTML 属性添加的。这是一个例子:

<button title="3 note(s) already">

在这里,它只是一个静态文本,但我们希望使它动态。幸运的是,有一个指令允许我们将 JavaScript 表达式绑定到属性--v-bind。像v-on指令一样,它需要一个参数,这个参数是目标属性的名称。

我们可以用 JavaScript 表达式重写前面的例子如下:

<button v-bind:title="notes.length + ' note(s) already'">

现在,如果你把鼠标光标放在按钮上,你会得到笔记的数量:

就像v-on指令一样,v-bind有一个特殊的快捷语法(两者都是最常用的指令)--你可以跳过v-bind部分,只放置带有属性名称的:字符。示例如下:

<button :title="notes.length + ' note(s) already'">

使用v-bind绑定的 JavaScript 表达式将在需要时自动重新评估,并更新相应属性的值。

我们也可以将表达式移到一个计算属性中并使用它。计算属性可以如下所示:

computed: {
  ...

  addButtonTitle () {
    return notes.length + ' note(s) already'
  },
},

然后,我们将重写绑定的属性,如下所示:

<button :title="addButtonTitle">

使用v-for显示列表

现在,我们将在工具栏下方显示笔记列表。

  1. 在工具栏正下方,添加一个带有notes类的新的div元素:
      <aside class="side-bar">
        <div class="toolbar">
          <button @click="addNote"><i class="material-icons">add</i>        
          Add note</button>
        </div>
        <div class="notes">
          <!-- Note list here -->
        </div>
      </aside>

现在,我们想要显示多个 div 元素的列表,每个笔记一个。为了实现这一点,我们需要v-for指令。它以item of items的形式接受一个特殊的表达式作为值,将迭代items数组或对象,并为模板的这一部分公开一个item值。以下是一个示例:

<div v-for="item of items">{{ item.title }}</div>

你也可以使用in关键字代替of

<div v-for="item in items">{{ item.title }}</div>

假设我们有以下数组:

data () {
  return {
    items: [
      { title: 'Item 1' },
      { title: 'Item 2' },
      { title: 'Item 3' },
    ]
  }
}

最终呈现的 DOM 将如下所示:

<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>

正如你所看到的,放置v-for指令的元素在 DOM 中重复出现。

  1. 让我们回到我们的笔记本,并在侧边栏显示笔记。我们将它们存储在 notes 数据属性中,所以我们需要对它进行迭代:
      <div class="notes">
        <div class="note" v-for="note of notes">{{note.title}}</div>
      </div>

现在,我们应该在按钮下方看到笔记列表显示出来:

使用按钮添加几个笔记,你应该看到列表正在自动更新!

选择一个笔记

当选择一个笔记时,它将成为应用程序中间和右侧窗格的上下文--文本编辑器修改其内容,预览窗格显示其格式化的 markdown。让我们实现这个行为!

  1. 添加一个名为selectedId的新数据属性,它将保存所选笔记的 ID:
      data () {
        return {
          content: localStorage.getItem('content') || 'You can write in         
          **markdown**',
          notes: [],
          // Id of the selected note
          selectedId: null,
        }
      },

我们也可以创建一个selectedNote属性,保存笔记对象,但这将使保存逻辑更复杂,没有任何好处。

  1. 我们需要一个新的方法,当我们点击列表中的一个笔记时将被调用以选择 ID。让我们称之为selectNote
      methods: {
        ...

        selectNote (note) {
          this.selectedId = note.id
        },
      }
  1. 就像我们为添加笔记按钮所做的那样,我们将使用v-on指令在列表中的每个笔记项上监听click事件:
      <div class="notes">
        <div class="note" v-for="note of notes"         
        @click="selectNote(note)">{{note.title}}</div>
      </div>

现在,当你点击一个笔记时,你应该看到更新的selectedId数据属性。

当前的笔记

现在我们知道当前选中的笔记是哪一个,我们可以替换一开始创建的旧content数据属性。很有用的是,我们可以创建一个计算属性来轻松访问选中的笔记,所以我们现在将创建一个:

  1. 添加一个新的计算属性叫做selectedNote,它返回与我们的selectedId属性匹配的笔记:
      computed: {
        ...

        selectedNote () {
          // We return the matching note with selectedId
          return this.notes.find(note => note.id === this.selectedId)
        },
      }

note => note.id === this.selectedId是来自 ES2015 JavaScript 版本的箭头函数。在这里,它接受一个note参数,并返回note.id === this.selectedId表达式的结果。

我们需要在我们的代码中用selectedNote.content替换旧的content数据属性。

  1. 首先修改模板中的编辑器:
      <textarea v-model="selectedNote.content"></textarea>
  1. 然后,将notePreview计算属性改为现在使用selectedNote
      notePreview () {
        // Markdown rendered to HTML
        return this.selectedNote ? marked(this.selectedNote.content) :          
        ''
      },

现在,当你在列表中点击一个笔记时,文本编辑器和预览窗格将显示所选的笔记。

你可以安全地移除不再在应用程序中使用的content数据属性、它的观察者和saveNote方法。

动态 CSS 类

当笔记在笔记列表中被选中时,添加一个selectedCSS 类会很好(例如,显示不同的背景颜色)。幸运的是,Vue 有一个非常有用的技巧来帮助我们实现这一点--v-bind指令(:字符是它的简写)有一些魔法可以使 CSS 类的操作更容易。你可以传递一个字符串,也可以传递一个字符串数组:

<div :class="['one', 'two', 'three']">

我们将在 DOM 中得到以下内容:

<div class="one two three">

然而,最有趣的特性是你可以传递一个对象,其键是类名,值是布尔值,用于确定是否应该应用每个类。以下是一个例子:

<div :class="{ one: true, two: false, three: true }">

这个对象表示法将产生以下 HTML:

<div class="one three">

在我们的情况下,我们只想在笔记被选中时应用选中的类。因此,我们将简单地写成如下形式:

<div :class="{ selected: note === selectedNote }">

笔记列表现在应该是这样的:

<div class="notes">
  <div class="note" v-for="note of notes" @click="selectNote(note)"
  :class="{selected: note === selectedNote}">{{note.title}}</div>
</div>

你可以将静态的class属性与动态的属性结合起来。建议将非动态类放入静态属性中,因为 Vue 会优化静态值。

现在,当你点击列表中的一个笔记来选择它时,它的背景颜色会改变:

带有 v-if 的条件模板

在测试我们的更改之前,我们还需要最后一件事;如果没有选择笔记,主窗格和预览窗格不应该显示--对用户来说没有意义,让用户拥有指向空白的编辑器和预览窗格,并且会使我们的代码崩溃,因为selectedNote将是null。幸运的是,v-if指令可以在我们希望时动态地从模板中取出部分。它的工作原理就像 JavaScript 的if关键字一样,有一个条件。

在这个例子中,只要loading属性为假,div元素根本不会出现在 DOM 中:

<div v-if="loading">
  Loading...
</div>

还有另外两个有用的指令,v-elsev-else-if,它们将按照你的预期工作:

<div v-if="loading">
  Loading...
</div>

<div v-else-if="processing">
  Processing
</div>

<div v-else>
  Content here
</div>

回到我们的应用程序中,在主窗格和预览窗格中添加v-if="selectedNote"条件,以便它们在没有选择笔记时不会添加到 DOM 中:

<!-- Main pane -->
<section class="main" v-if="selectedNote">
  ...
</section>

<!-- Preview pane -->
<aside class="preview" v-if="selectedNote" v-html="notePreview">
</aside>

这里的重复有点不幸,但 Vue 已经为我们做好了准备。你可以用一个特殊的<template>标签将两个元素包围起来,它的作用就像 JavaScript 中的大括号:

<template v-if="selectedNote">
  <!-- Main pane -->
  <section class="main">
    ...
  </section>

  <!-- Preview pane -->
  <aside class="preview" v-html="notePreview">
  </aside>
</template>

此时,应用程序应该看起来像这样:

<template>标签不会出现在 DOM 中;它更像是一个幽灵元素,用于将真实元素聚集在一起。

使用深度选项保存笔记

现在,我们想要在会话之间保存和恢复笔记,就像我们为笔记内容所做的那样:

  1. 让我们创建一个新的saveNotes方法。由于我们不能直接将对象数组保存到localStorage API 中(它只接受字符串),我们需要先用JSON.stringify将其转换为 JSON 格式:
      methods: {
        ...

        saveNotes () {
          // Don't forget to stringify to JSON before storing
          localStorage.setItem('notes', JSON.stringify(this.notes))
          console.log('Notes saved!', new Date())
        },
      },

就像我们为之前的content属性所做的那样,我们将监视notes数据属性的更改来触发saveNotes方法。

  1. 在观察选项中添加一个观察者:
      watch: {
        notes: 'saveNotes',
      }

现在,如果你尝试添加一些任务,你应该在控制台中看到类似这样的东西:

Notes saved! Mon Apr 42 2042 17:40:23 GMT+0100 (Paris, Madrid)
Notes saved! Mon Apr 42 2016 17:42:51 GMT+0100 (Paris, Madrid)
  1. data钩子中更改notes属性的初始化,从localStorage中加载存储的列表:
      data () {
        return {
          notes: JSON.parse(localStorage.getItem('notes')) || [],
          selectedId: null,
        }
      },

刷新页面后,新添加的笔记应该被恢复。然而,如果你尝试更改一个笔记的内容,你会注意到它不会触发notes观察者,因此,笔记不会被保存。这是因为,默认情况下,观察者只观察目标对象的直接更改--分配简单值,向数组中添加、删除或移动项目。例如,以下操作将被默认检测到:

// Assignment
this.selectedId = 'abcd'

// Adding or removing an item in an array
this.notes.push({...})
this.notes.splice(index, 1)

// Sorting an array
this.notes.sort(...)

然而,所有其他操作,比如这些,都不会触发观察者:

// Assignment to an attribute or a nested object
this.myObject.someAttribute = 'abcd'
this.myObject.nestedObject.otherAttribute = 42

// Changes made to items in an array
this.notes[0].content = 'new content'

在这种情况下,您需要在观察者中添加deep选项:

watch: {
  notes: {
    // The method name
    handler: 'saveNotes',
    // We need this to watch each note's properties inside the array
    deep: true,
  },
}

这样,Vue 也将递归地监视我们notes数组内部的对象和属性。现在,如果你在文本编辑器中输入,笔记列表应该被保存--v-model指令将修改所选笔记的content属性,并且使用deep选项,观察者将被触发。

保存选择

如果我们的应用程序能够选择上次选择的笔记,那将非常方便。我们只需要存储和加载selectedId数据属性,用于存储所选笔记的 ID。没错!再一次,我们将使用一个观察者来触发保存:

watch: {
  ...

  // Let's save the selection too
  selectedId (val) {
    localStorage.setItem('selected-id', val)
  },
}

此外,我们将在属性初始化时恢复值:

data () {
  return {
    notes: JSON.parse(localStorage.getItem('notes')) || [],
    selectedId: localStorage.getItem('selected-id') || null,
  }
},

好了!现在,当你刷新应用程序时,它应该看起来和你上次离开时一样,选择的笔记也是一样的。

带有额外功能的笔记工具栏

我们的应用程序仍然缺少一些功能,比如删除或重命名所选笔记。我们将在一个新的工具栏中添加这些功能,就在笔记文本编辑器的上方。继续创建一个带有toolbar类的新的div元素;放在主要部分内部:

<!-- Main pane -->
<section class="main">
  <div class="toolbar">
    <!-- Our toolbar is here! -->
  </div>
  <textarea v-model="selectedNote.content"></textarea>
</div>

我们将在这个工具栏中添加三个新功能:

  • 重命名笔记

  • 删除笔记

  • 将笔记标记为收藏

重命名笔记

这个第一个工具栏功能也是最简单的。它只包括一个与所选笔记的title属性绑定的文本输入,使用v-model指令。

在我们刚创建的工具栏div元素中,添加这个带有v-model指令和placeholderinput元素,以通知用户其功能:

<input v-model="selectedNote.title" placeholder="Note title" />

你应该在文本编辑器上方有一个功能性的重命名字段,并且在输入时,你应该看到笔记名称在笔记列表中自动更改:

由于我们在notes观察者上设置了deep选项,所以每当您更改所选笔记的名称时,笔记列表都将被保存。

删除笔记

这个第二个功能有点复杂,因为我们需要一个新的方法:

  1. 在重命名文本输入框后添加一个button元素:
      <button @click="removeNote" title="Remove note"><i        
      class="material-icons">delete</i></button>

正如你所看到的,我们使用v-on简写(@字符)来监听click事件,调用我们即将创建的removeNote方法。此外,我们将适当的图标放在按钮内容中。

  1. 添加一个新的removeNote方法,询问用户确认,然后使用splice标准数组方法从notes数组中删除当前选择的笔记:
      removeNote () {
        if (this.selectedNote && confirm('Delete the note?')) {
          // Remove the note in the notes array
          const index = this.notes.indexOf(this.selectedNote)
          if (index !== -1) {
            this.notes.splice(index, 1)
          }
        }
      }

现在,如果您尝试删除当前笔记,您应该注意到以下三件事情发生了:

  • 笔记从左侧的笔记列表中删除

  • 文本编辑器和预览窗格被隐藏

  • 笔记列表已根据浏览器控制台保存

收藏的笔记

最后一个工具栏功能是最复杂的。我们希望重新排列笔记列表,使收藏的笔记首先显示出来。为此,每个笔记都有一个favorite布尔属性,可以通过按钮切换。除此之外,笔记列表中还会显示一个星形图标,以明确显示哪些笔记是收藏的,哪些不是:

  1. 首先,在删除笔记按钮之前的工具栏中添加另一个按钮:
      <button @click="favoriteNote" title="Favorite note"><i        
      class="material-icons">{{ selectedNote.favorite ? 'star' :               
      'star_border' }}</i></button>

再次使用v-on简写来调用我们将在下面创建的favoriteNote方法。我们还将根据所选笔记的favorite属性的值显示一个图标--如果为true,则显示一个实心星形图标,否则显示一个轮廓星形图标。

最终结果将如下所示:

在左侧,有一个按钮,用于当笔记不是收藏时,右侧是当笔记是收藏时,点击它后。

  1. 让我们创建一个非常简单的favoriteNote方法,它只是反转所选笔记上的favorite布尔属性的值:
      favoriteNote () {
        this.selectedNote.favorite = !this.selectedNote.favorite
      },

我们可以使用异或运算符重写这个:

favoriteNote () {
  this.selectedNote.favorite = this.selectedNote.favorite ^ true
},

这可以很好地简化,如下所示:

favoriteNote () {
  this.selectedNote.favorite ^= true
},

现在,您应该能够切换收藏按钮,但它目前还没有任何实际效果。

我们需要以两种方式对笔记列表进行排序--首先,我们按创建日期对所有笔记进行排序,然后对它们进行排序,使收藏的笔记排在最前面。幸运的是,我们有一个非常方便的标准数组方法--sort。它接受一个参数,即一个具有两个参数的函数--要比较的两个项目。结果是一个数字,如下所示:

  • 0,如果两个项目处于等价位置

  • -1,如果第一个项目应该在第二个项目之前

  • 1,如果第一个项目应该在第二个项目之后

您不仅限于1这个数字,因为您可以返回任意的数字,正数或负数。例如,如果您返回-42,它将与-1相同。

第一个排序操作将通过这个简单的减法代码实现:

sort((a, b) => a.created - b.created)

在这里,我们比较了两个笔记的创建日期,我们将其存储为毫秒数,感谢Date.now()。我们只需将它们相减,这样如果ba之后创建,我们就会得到一个负数,或者如果ab之后创建,我们就会得到一个正数。

第二次排序是用两个三元操作符完成的:

sort((a, b) => (a.favorite === b.favorite)? 0 : a.favorite? -1 : 1)

如果两个笔记都是收藏的,我们不改变它们的位置。如果a是收藏的,我们返回一个负数将其放在b之前。在另一种情况下,我们返回一个正数,所以b会在列表中放在a之前。

最好的方法是创建一个名为sortedNotes的计算属性,它将被 Vue 自动更新和缓存。

  1. 创建新的sortedNotes计算属性:
      computed: {
        ...

        sortedNotes () {
          return this.notes.slice()
            .sort((a, b) => a.created - b.created)
            .sort((a, b) => (a.favorite === b.favorite)? 0
              : a.favorite? -1    
              : 1)
        },
      }

由于sort直接修改源数组,我们应该使用slice方法创建一个副本。这将防止notes观察者的不必要触发。

现在,我们可以在用于显示列表的v-for指令中简单地将notes替换为sortedNotes--它现在会自动按我们的预期对笔记进行排序:

<div v-for="note of sortedNotes">

我们还可以使用之前介绍的v-if指令,只有在笔记被收藏时才显示星标图标:

<i class="icon material-icons" v-if="note.favorite">star</i>
  1. 使用上述更改修改笔记列表:
      <div class="notes">
        <div class="note" v-for="note of sortedNotes"
        :class="{selected: note === selectedNote}"
        @click="selectNote(note)">
          <i class="icon material-icons" v-if="note.favorite">
          star</i> 
          {{note.title}}
        </div>
      </div>

应用程序现在应该如下所示:

状态栏

我们将添加到应用程序的最后一个部分是状态栏,在文本编辑器底部显示一些有用的信息--笔记创建日期,以及行数、单词数和字符数。

创建一个带有toolbarstatus-bar类的新div元素,并将其放在textarea元素之后:

<!-- Main pane -->
<section class="main">
  <div class="toolbar">
    <!-- ... -->
  </div>
  <textarea v-model="selectedNote.content"></textarea>
  <div class="toolbar status-bar">
    <!-- The new status bar here! -->
  </div>
</section>

带有过滤器的创建日期

我们现在将在状态栏中显示所选笔记的创建日期。

  1. 在状态栏div元素中,创建一个新的span元素如下:
      <span class="date">
        <span class="label">Created</span>
        <span class="value">{{ selectedNote.created }}</span>
      </span>

现在,如果你在浏览器中查看结果,你应该看到表示笔记创建日期的毫秒数:

这一点一点也不用户友好!

我们需要一个新的库来帮助我们将日期格式化为更易读的结果--momentjs,这是一个非常流行的时间和日期处理库。

  1. 像我们为marked库所做的那样将其包含在页面中:
      <script src="https://unpkg.com/moment"></script>

要格式化日期,我们首先会创建一个moment对象,然后我们将使用format方法,就像下面这样:

      moment(time).format('DD/MM/YY, HH:mm')

现在是介绍本章最后一个 Vue 特性的时候--过滤器。这些是在模板内部使用的函数,用于在显示或传递给属性之前轻松处理数据。例如,我们可以有一个大写过滤器,将字符串转换为大写字母,或者一个货币过滤器,在模板中实时转换货币。该函数接受一个参数--要由过滤器处理的值。它返回处理后的值。

因此,我们将创建一个新的date过滤器,它将接受一个日期时间并将其格式化为人类可读的格式。

  1. 使用Vue.filter全局方法注册此过滤器(在 Vue 实例创建代码之外,例如在文件开头):
 Vue.filter('date', time => moment(time)
        .format('DD/MM/YY, HH:mm'))

现在,我们可以在模板中使用这个date过滤器来显示日期。语法是 JavaScript 表达式,后跟一个管道运算符和过滤器的名称,就像我们之前使用的那样:

{{ someDate | date }}

如果someDate包含一个日期,它将在 DOM 中输出类似于我们之前定义的DD/MM/YY, HH:mm格式的内容:

12/02/17, 12:42
  1. 将 stat 模板更改为这样:
      <span class="date">
        <span class="label">Created</span>
        <span class="value">{{ selectedNote.created | date }}</span>
      </span>

我们应该在我们的应用程序中有一个格式良好的日期显示:

文本统计

我们可以显示的最后统计数据更多地面向“写作者”--行数、单词数和字符数:

  1. 让我们为每个计数器创建三个新的计算属性,使用一些正则表达式来完成工作:
      computed: {
        linesCount () {
          if (this.selectedNote) {
            // Count the number of new line characters
            return this.selectedNote.content.split(/\r\n|\r|\n/).length
          }
        },

        wordsCount () {
          if (this.selectedNote) {
            var s = this.selectedNote.content
            // Turn new line cahracters into white-spaces
            s = s.replace(/\n/g, ' ')
            // Exclude start and end white-spaces
            s = s.replace(/(^\s*)|(\s*$)/gi, '')
            // Turn 2 or more duplicate white-spaces into 1
            s = s.replace(/\s\s+/gi, ' ')
            // Return the number of spaces
            return s.split(' ').length
          }
        },

        charactersCount () {
          if (this.selectedNote) {
            return this.selectedNote.content.split('').length
          }
        },
      }

在这里,我们添加了一些条件,以防止代码在当前未选择任何笔记时运行。这将避免在这种情况下使用 Vue devtools 检查应用程序时出现崩溃,因为它将尝试计算所有属性。

  1. 现在,您可以添加三个新的 stat span元素,带有相应的计算属性:
      <span class="lines">
        <span class="label">Lines</span>
        <span class="value">{{ linesCount }}</span>
      </span>
      <span class="words">
        <span class="label">Words</span>
        <span class="value">{{ wordsCount }}</span>
      </span>
      <span class="characters">
        <span class="label">Characters</span>
        <span class="value">{{ charactersCount }}</span>
      </span>

最终的状态栏应该是这样的:

总结

在本章中,我们创建了我们的第一个真正的 Vue 应用程序,具有几个有用的功能,如实时的 Markdown 预览,笔记列表以及笔记的本地持久化。我们介绍了不同的 Vue 功能,比如计算属性,它们会根据需要自动更新和缓存,方法可以在函数内重复使用逻辑,观察者可以在属性更改时触发代码,生命周期钩子可以在 Vue 实例创建时执行代码,过滤器可以轻松处理模板中的表达式。我们还在模板中使用了许多 Vue 指令,比如v-model来绑定表单输入,v-html来显示来自 JavaScript 属性的动态 HTML,v-for来重复元素并显示列表,v-on(或@)来监听事件,v-bind(或:)来动态绑定 HTML 属性到 JavaScript 表达式或动态应用 CSS 类,以及v-if来根据 JavaScript 表达式包含或不包含模板部分。我们看到所有这些功能共同构建了一个完全功能的 Web 应用程序,Vue 的超能力帮助我们完成工作而不会妨碍。

在下一章中,我们将开始一个新项目——基于卡片的浏览器游戏。我们将介绍一些新的 Vue 功能,并将继续重复利用我们所知道的一切,以继续构建更好、更漂亮的 Web 应用程序。

第三章:项目 2 - 城堡决斗浏览器游戏

在本章中,我们将创建一个完全不同的应用程序--一个浏览器游戏。它将由两名玩家组成,每个玩家指挥一座令人印象深刻的城堡,并试图通过行动卡将对手的食物或伤害水平降低到零来摧毁对方。

在这个项目和接下来的项目中,我们将把我们的应用程序分成可重用的组件。这是框架的核心,其所有 API 都是围绕这个想法构建的。我们将看到如何定义和使用组件,以及如何使它们相互通信。结果将是我们应用程序的更好结构。

游戏规则

以下是我们将在游戏中实施的规则:

  • 两名玩家轮流进行游戏

  • 每个玩家游戏开始时拥有 10 点健康值,10 点食物和 5 张手牌

  • 玩家的健康和食物值不能超过 10 点。

  • 当玩家的食物或健康值达到零时,玩家将失败。

  • 两名玩家都可以在平局中失败。

  • 在一个玩家的回合中,每个玩家唯一可能的行动是打出一张卡牌,然后将其放入弃牌堆

  • 每个玩家在回合开始时从抽牌堆中抽一张牌(除了他们的第一回合)

  • 由于前两条规则,每个玩家在开始他们的回合时手中正好有五张牌

  • 如果玩家抽牌时抽牌堆为空,则将弃牌堆重新填满抽牌堆

  • 卡片可以修改玩家或对手的健康和食物值

  • 有些卡片还可以让玩家跳过他们的回合。

游戏玩法建立在玩家每回合只能打出一张卡牌,并且大多数卡牌会对他们产生负面影响(最常见的是失去食物)。你必须在出牌前考虑好你的策略。

应用程序将由两层组成--世界,游戏对象(如风景和城堡)在其中绘制,以及用户界面。

世界将有两座城堡彼此对峙,一个地面和一个天空,有多个动画云;每座城堡将有两面旗帜--绿色的代表玩家的食物,红色的代表玩家的健康--并显示剩余食物或健康值的气泡:

UI 界面顶部将有一个条形菜单,显示回合计数器和两名玩家的姓名。屏幕底部,手牌将显示当前玩家的卡牌。

除此之外,还会定期显示一些叠加层,隐藏手牌。其中一个将显示接下来轮到的玩家的名字:

接下来将是另一个叠加层,显示对手上一轮打出的牌。这将允许游戏在同一屏幕上进行(例如,平板电脑)。

![](assets/0f3ed8f3-ae57-404b-863a-bd3f274f1e13.png)

第三个叠加层只有在游戏结束时才会显示,显示玩家是赢了还是输了。单击此叠加层将重新加载页面,允许玩家开始新游戏。

设置项目

下载第二章文件并将项目设置提取到一个空文件夹中。您应该有以下内容:

  • index.html:网页

  • style.css:CSS 文件

  • svg:包含游戏的所有 SVG 图像

  • cards.js:所有卡片数据都已准备好使用

  • state.js:我们将在这里整合游戏的主要数据属性

  • utils.js:我们将编写有用的函数的地方

  • banner-template.svg:我们稍后将使用此文件的内容

我们将从我们的主 JavaScript 文件开始--创建一个名为main.js的新文件。

打开index.html文件,并在state.js之后添加一个引用新文件的新脚本标记:

<!-- Scripts -->
<script src="img/utils.js"></script>
<script src="img/cards.js"></script>
<script src="img/state.js"></script>
<script src="img/main.js"></script>

让我们在main.js文件中创建我们应用程序的主要实例:

new Vue({
  name: 'game',
  el: '#app',
})

我们现在已经准备好了!

风平浪静

在这一部分,我们将介绍一些新的 Vue 功能,这些功能将帮助我们构建游戏,比如组件、props 和事件发射!

模板选项

如果您查看index.html文件,您会看到#app元素已经存在且为空。实际上,我们不会在里面写任何东西。相反,我们将直接在定义对象上使用模板选项。让我们尝试一个愚蠢的模板:

new Vue({
  name: 'game',
  el: '#app',

  template: `<div id="#app">
    Hello world!
  </div>`,
})

在这里,我们使用了新的 JavaScript 字符串,带有`字符(反引号)。它允许我们编写跨越多行的文本,而不必编写冗长的字符串连接。

现在,如果你打开应用程序,你应该看到'Hello world!'文本显示出来。正如你猜到的那样,从现在开始我们不会将模板内联到#app元素中。

应用程序状态

正如之前解释的那样,state.js 文件将帮助我们将应用程序的主要数据整合到一个地方。这样,我们将能更容易地编写游戏逻辑函数,而不会用大量方法污染定义对象。

  1. state.js 文件声明了我们将用作应用程序数据的数据变量。我们可以直接将其用作数据选项,如下所示:
      new Vue({
        // …
        data: state,
      })

现在,如果你打开开发工具,你应该看到状态对象中已经声明的唯一数据属性:

世界比例是一个表示我们应该如何缩放游戏对象以适应窗口的数字。例如,.6表示世界应该以其原始大小的 60%进行缩放。它是在utils.js文件中使用getWorldRatio函数计算的。

有一件事情还缺少 - 当窗口大小调整时它不会被重新计算。这是我们必须自己实现的。在 Vue 实例构造函数之后,添加一个事件监听器到窗口对象,以便在窗口大小调整时检测。

  1. 在处理程序内,更新状态的worldRatio数据属性。你也可以在模板中显示worldRatio
      new Vue({
        name: 'game',
        el: '#app',

        data: state,

        template: `<div id="#app">
          {{ worldRatio }}
        </div>`,
      })

      // Window resize handling
      window.addEventListener('resize', () => {
        state.worldRatio = getWorldRatio()
      })

尝试水平调整浏览器窗口大小 - worldRatio 数据属性在 Vue 应用中被更新。

等等!我们正在修改状态对象,而不是 Vue 实例...

你是对的!然而,我们使用state对象设置了 Vue 实例数据属性。这意味着 Vue 已经在其上设置了响应性,并且我们可以改变它的属性来更新我们的应用程序,正如我们将在下面看到的那样。

  1. 为了确保state是应用的反应性数据,请尝试比较实例数据对象和全局状态对象:
      new Vue({
        // ...
        mounted () {
          console.log(this.$data === state)
        },
      })

这些是我们使用数据选项设置的相同对象。所以当你这样做时:

this.worldRatio = 42

你也在做这个:

this.$data.worldRatio = 42

这实际上和以下一样:

state.worldRatio = 42

这将在游戏功能中非常有用,该功能将使用状态对象来更新游戏数据。

全能的组件

组件是构成我们应用程序的构建块 - 这是 Vue 应用程序的核心概念。它们是视图的小部分,应该相对小,可重用,并且尽可能自包含 - 使用组件来构建应用程序将有助于维护和发展它,特别是当应用程序变得庞大时。事实上,这已经成为以高效和可管理的方式创建庞大 Web 应用程序的标准方法。

具体而言,你的应用程序将是一个由小组件组成的巨大树:

例如,你的应用程序可能有一个表单组件,其中可以包含多个输入组件和按钮组件。每个组件都是 UI 的一个非常具体的部分,并且它们可以在整个应用程序中重复使用。作用域非常小,它们很容易理解和推理,因此更容易维护(修复问题)或发展。

构建用户界面

我们将创建的第一个组件是 UI 层的一部分。它将包括一个带有玩家姓名和回合计数器的顶端栏、带有名称和描述的卡片、当前玩家卡片的手牌和三个叠加层。

我们的第一个组件 - 顶端栏

顶端栏,我们的第一个组件,将被放置在页面的顶部,并且在中间显示两个玩家的姓名和回合计数器。它还将显示一个箭头指向当前正在进行回合的玩家的姓名。

它将如下所示:

在状态中添加一些游戏数据

在创建组件之前,我们需要一些新的数据属性:

  • turn: 当前回合数;从 1 开始

  • players: 玩家对象的数组

  • currentPlayerIndex: players 数组中当前玩家的索引

state.js 文件中将它们添加到状态中:

// The consolidated state of our app
var state = {
  // World
  worldRatio: getWorldRatio(),
  // Game
  turn: 1,
  players: [
    {
      name: 'Anne of Cleves',
    },
    {
      name: 'William the Bald',
    },
  ],
  currentPlayerIndex: Math.round(Math.random()),
}

Math.round(Math.random()) 将使用随机选择 01 来确定谁先行。

我们将使用这些属性来在顶端栏中显示玩家姓名和回合计数器。

定义和使用组件

我们将在一个新的文件中编写我们的 UI 组件:

  1. 创建一个 components 文件夹并在其中创建一个新的 ui.js 文件。在主 index.html 页面中引入它,就在主要脚本之前:
      <!-- Scripts -->
      <script src="img/utils.js"></script>
      <script src="img/cards.js"></script>
      <script src="img/state.js"></script>
      <script src="img/ui.js"></script>
      <script src="img/main.js"></script>

在这个文件中,我们将注册我们的组件,所以主要的 Vue 实例创建在后面而不是前面,否则,我们将得到组件不存在的错误。

要注册一个组件,我们可以使用全局的 Vue.component() 函数。它接受两个参数;我们注册组件的名称,以及它的定义对象,该对象使用了我们已经了解的 Vue 实例的完全相同的选项。

  1. 让我们在 ui.js 文件中创建top-bar组件:
 Vue.component('top-bar', {
        template: `<div class="top-bar">
          Top bar
        </div>`,
      })

现在,我们可以在模板中使用 top-bar 组件,就像使用任何其他 HTML 标签一样,例如 <top-bar>

  1. 在主模板中,添加一个新的 top-bar 标签:
      new Vue({
        // ...
        template: `<div id="#app">
          <top-bar/>
        </div>`,
      })

这个模板将创建一个新的 top-bar 组件,并使用我们刚刚定义的定义对象在 #app 元素内呈现它。如果你打开开发工具,你应该会看到两个条目:

每个都是一个 Vue 实例--Vue 实际上使用我们为顶端栏组件提供的定义创建了第二个实例。

使用 props 进行从父组件到子组件的通信

正如我们在强大的组件部分中所见,我们基于组件的应用程序将具有一系列组件,并且我们需要它们相互通信。目前,我们只关注下行、从父级到子级的通信。这通过"props"完成。

我们的top-bar组件需要知道玩家是谁,当前正在玩谁,以及当前回合数是多少。因此,我们将需要三个 props--playerscurrentPlayerIndexturn

要向组件定义添加 props,请使用props选项。目前,我们只会简单列出我们的 props 的名称。但是,你应该知道还有一种更详细的符号,使用对象代替,我们将在接下来的章节中介绍。

  1. 让我们将 props 添加到我们的组件中:
      Vue.component('top-bar', {
        // ...
        props: ['players', 'currentPlayerIndex', 'turn'],
      })

在父组件中,即根应用程序中,我们可以以与 HTML 属性相同的方式设置 props 值。

  1. 继续使用v-bind简写将 props 值与主模板中的应用程序数据进行连接:
      <top-bar :turn="turn" :current-player-index="currentPlayerIndex"         
      :players="players" />

请注意,由于 HTML 不区分大小写并且按照惯例,建议在 JavaScript 代码中使用连字符的 kebab-case(带有短横线)名称和 props 的骆驼式命名。

现在,我们可以像数据属性一样在我们的top-bar组件中使用 props。例如,你可以这样写:

Vue.component('top-bar', {
  // ...
  created () {
    console.log(this.players)
  },
})

这将在浏览器控制台中打印由父组件(我们的应用程序)发送的players数组。

我们模板中的 props

现在,我们将在top-bar组件的模板中使用我们创建的 props。

  1. 更改top-bar模板以使用players prop 显示玩家的名称:
      template: `<div class="top-bar">
        <div class="player p0">{{ players[0].name }}</div>
        <div class="player p1">{{ players[1].name }}</div>
      </div>`,

正如你在上述代码中所看到的,我们也像在模板中使用属性一样使用 props。你应该在应用程序中看到玩家名称显示。

  1. 继续使用turn prop 在players之间显示回合计数器:
      template: `<div class="top-bar">
        <div class="player p0">{{ players[0].name }}</div>
        <div class="turn-counter">
        <div class="turn">Turn {{ turn }}</div>
        </div>
        <div class="player p1">{{ players[1].name }}</div>
        </div>`,

除了标签外,我们还希望显示一个面向当前玩家的大箭头,以使其更加明显。

  1. .turn-counter元素内添加箭头图像,并使用我们在第二章Markdown 笔记本中使用的v-bind简写为currentPlayerIndex prop 添加动态类:
      template: `<div class="top-bar" :class="'player-' + 
 currentPlayerIndex">
        <div class="player p0">{{ players[0].name }}</div>
        <div class="turn-counter">
          <img class="arrow" src="img/turn.svg" />
          <div class="turn">Turn {{ turn }}</div>
        </div>
        <div class="player p1">{{ players[1].name }}</div>
      </div>`,

现在,应用程序应该显示具有两个玩家、名称和它们之间的回合计数器的完整功能顶栏。你可以通过在浏览器控制台中输入这些命令来测试 Vue 自动反应性:

state.currentPlayerIndex = 1
state.currentPlayerIndex = 0

你应该看到箭头转向正确的玩家名称,这将被强调:

显示一张卡片

所有的卡片都在cards.js文件中声明的卡片定义对象中描述。你可以打开它,但你不应该修改其内容。每个卡片定义都具有以下字段:

  • id:每张卡片的唯一标识符

  • type:更改颜色背景以帮助区分卡片

  • title:卡片的显示名称

  • description:解释卡片功能的 HTML 文本

  • note:一个可选的 HTML 风格文本

  • play:当卡片被玩时我们将调用的函数

我们需要一个新组件来显示任何卡片,无论是在玩家手中还是在覆盖层中,描述对手上一轮玩的是什么牌。它将如下所示:

  1. components/ui.js 文件中,创建一个新的 card 组件:
 Vue.component('card', {
        // Definition here
      })
  1. 此组件将接收一个 def 属性,该属性将是我们上面描述的卡片定义对象。声明它与我们为 top-bar 组件所做的方式相同的 props 选项:
      Vue.component('card', {
        props: ['def'],
      })
  1. 现在,我们可以添加模板。从主要的 div 元素开始,带有 card 类:
      Vue.component('card', {
        template: `<div class="card">
        </div>`,
        props: ['def'],
      })
  1. 根据卡片类型更改背景颜色,添加一个使用卡片对象的 type 属性的动态 CSS 类:
      <div class="card" :class="'type-' + def.type">

例如,如果卡片具有 'attack' 类型,则元素将获得 type-attack 类。然后,它将具有红色背景。

  1. 现在,添加带有相应类的卡片标题:
      <div class="card" :class="'type-' + def.type">
        <div class="title">{{ def.title }}</div>
      </div>
  1. 添加分隔图像,该图像将在卡片标题和描述之间显示一些线条:
      <div class="title">{{ def.title }}</div>
      <img class="separator" src="img/card-separator.svg" />

图像后附加描述元素。

注意,由于卡片对象的 description 属性是 HTML 格式化的文本,我们需要使用第二章介绍的特殊 v-html 指令。

  1. 使用 v-html 指令来显示描述:
      <div class="description"><div v-html="def.description"></div>             
      </div>

你可能已经注意到我们添加了一个嵌套的 div 元素,它将包含描述文本。这是为了使用 CSS flexbox 垂直居中文本。

  1. 最后,添加卡片注释(也是 HTML 格式化的文本)。注意,有些卡片没有注释,因此我们必须在这里使用 v-if 指令:
      <div class="note" v-if="def.note"><div v-html="def.note"></div>        
      </div>

现在卡片组件应该看起来像这样:

Vue.component('card', {
  props: ['def'],
  template: `<div class="card" :class="'type-' + def.type">
    <div class="title">{{ def.title }}</div>
    <img class="separator" src="img/card-separator.svg" />
    <div class="description"><div v-html="def.description"></div></div>
    <div class="note" v-if="def.note"><div v-html="def.note"></div></div>
  </div>`,
})

现在,我们可以在主应用程序组件中尝试我们的新卡片组件。

  1. 编辑主模板如下,并在顶部栏后添加一个 card 组件:
      template: `<div id="#app">
        <top-bar :turn="turn" :current-player-             
         index="currentPlayerIndex" :players="players" />
        <card :def="testCard" />
      </div>`,
  1. 我们还需要定义一个临时计算属性:
 computed: {
        testCard () {
          return cards.archers
        },
      },

现在,您应该看到一个红色的攻击卡片,带有标题、描述和口味文本:

监听组件上的原生事件

让我们尝试在我们的卡片上添加一个点击事件处理程序:

<card :def="testCard" @click="handlePlay" />

在主要组件中使用愚蠢的方法:

methods: {
  handlePlay () {
    console.log('You played a card!')
  }
}

如果你在浏览器中测试这个,你可能会惊讶地发现它不像预期那样工作。控制台什么也没有输出……

这是因为 Vue 有自己的组件事件系统,称为“自定义事件”,我们马上就会学到。该系统与浏览器事件分开,因此在这里 Vue 期望一个自定义的 'click' 事件,而不是浏览器事件。因此,handler 方法不会被调用。

为了解决这个问题,你应该在 v-on 指令上使用 .native 修饰符,如下所示:

<card :def="testCard" @click.native="handlePlay" />

现在,当你点击卡片时,handlePlay 方法会按预期调用。

使用自定义事件进行子到父的通信

以前,我们使用 props 从父组件向其子组件通信。现在,我们想要做相反的事情,即从一个子组件向其父组件通信。对于我们的卡片组件,我们想要告诉父组件,当玩家点击卡片时,卡片正在被玩家播放。我们不能在这里使用 props,但是我们可以使用自定义事件。在我们的组件中,我们可以发出事件,父组件可以使用$emit特殊方法捕获。它接受一个必需的参数,即事件类型:

this.$emit('play')

我们可以使用$on特殊方法在同一个 Vue 实例内监听自定义事件:

this.$on('play', () => {
  console.log('Caught a play event!')
})

$emit方法还向父组件发送一个'play'事件。我们可以像以前一样在父组件模板中使用v-on指令来监听它:

<card v-on:play="handlePlay" />

您还可以使用v-bind的快捷方式:

<card @play="handlePlay" />

我们也可以添加任意数量的参数,这些参数将传递给处理程序方法:

this.$emit('play', 'orange', 42)

在这里,我们发出了一个带有以下两个参数的'play'事件-- 'orange'42

在处理中,我们可以通过参数获取它们,如下所示:

handlePlay (color, number) {
  console.log('handle play event', 'color=', color, 'number=', number)
}

color参数将具有'orange'值,number参数将具有42值。

正如我们在前一节中所看到的,自定义事件与浏览器事件系统完全分开。特殊方法--$on$emit--不是标准addEventListenerdispatchEvent的别名。这就解释了为什么我们需要在组件上使用.native修饰符来监听浏览器事件,如'click'

回到我们的卡片组件,我们只需要发出一个非常简单的事件,告诉父组件卡片正在被播放:

  1. 首先,添加会触发事件的方法:
 methods: {
        play () {
          this.$emit('play')
        },
      },
  1. 我们想在用户点击卡片时调用此方法。只需在主卡片div元素上监听浏览器点击事件:
      <div class="card" :class="'type-' + def.type" @click="play">
  1. 我们完成了卡片组件。要测试这一点,在主组件模板中监听'play'自定义事件:
      <card :def="testCard" @play="handlePlay" />

现在,每当发出'play'事件时,将调用handlePlay方法。

我们本可以只监听本机点击事件,但通常最好使用自定义事件在组件之间进行通信。例如,当用户使用其他方法时,例如使用键盘选择卡片并按Enter键,我们也可以发出'play'事件;尽管我们不会在本书中实现该方法。

手牌

我们的下一个组件将是当前玩家的手牌,持有他们手中的五张牌。它将使用 3D 过渡进行动画处理,并且还将负责卡片动画(当卡片被抽取时,以及当它被打出时)。

  1. components/ui.js文件中,添加一个具有'hand'ID 和一个基本模板的组件注册,带有两个div元素:
 Vue.component('hand', {
        template: `<div class="hand">
          <div class="wrapper">
            <!-- Cards -->
          </div>
        </div>`,
      })

包装元素将帮助我们定位和动画处理卡片。

手中的每张卡片将由一个对象表示。目前,它将具有以下属性:

  • id:卡片定义的唯一标识符

  • def:卡片定义对象

作为提醒,所有的卡片定义都在 cards.js 文件中声明。

  1. 我们的手部组件将通过一个名为 cards 的新数组属性接收代表玩家手牌的卡对象:
      Vue.component('hand', {
        // ...
        props: ['cards'],
      })
  1. 现在,我们可以使用 v-for 指令添加卡片组件了:
      <div class="wrapper">
        <card v-for="card of cards" :def="card.def" />
      </div>
  1. 为了测试我们的手部组件,我们将在应用程序状态中创建一个名为 testHand 的临时属性(在 state.js 文件中):
      var state = {
        // ...
        testHand: [],
      }
  1. 在主组件中添加一个 createTestHand 方法(在 main.js 文件中):
      methods: {
        createTestHand () {
          const cards = []
          // Get the possible ids
          const ids = Object.keys(cards)

          // Draw 5 cards
          for (let i = 0; i < 5; i++) {
            cards.push(testDrawCard())
          }

          return cards
        },
      },
  1. 为了测试手部,我们还需要这个临时的 testDrawCard 方法来模拟随机抽卡:
      methods: {
        // ...
        testDrawCard () {
          // Choose a card at random with the ids
          const ids = Object.keys(cards)
          const randomId = ids[Math.floor(Math.random() * ids.length)]
          // Return a new card with this definition
          return {
            // Unique id for the card
            uid: cardUid++,
            // Id of the definition
            id: randomId,
            // Definition object
            def: cards[randomId],
          }
        }
      }
  1. 使用 created 生命周期钩子来初始化手部:
 created () {
        this.testHand = this.createTestHand()
      },

cardUid 是玩家抽取的卡片上的唯一标识符,对于识别手中的每张卡片都很有用,因为许多卡片可能共享完全相同的卡片定义,我们需要一种区分它们的方法。

  1. 在主模板中,添加手部组件:
      template: `<div id="#app">
        <top-bar :turn="turn" :current-player-           
         index="currentPlayerIndex" :players="players" />
        <hand :cards="testHand" />
      </div>`,

在您的浏览器中的结果应如下所示:

使用过渡动画手部

在游戏过程中,当显示任何叠加时,手部将被隐藏。为了使应用程序更美观,当手部从 DOM 中添加或移除时,我们将对其进行动画处理。为此,我们将与强大的 Vue 工具--特殊的 <transition> 组件一起使用 CSS 过渡。它将帮助我们在使用 v-ifv-show 指令添加或移除元素时使用 CSS 过渡。

  1. 首先,在 state.js 文件中的应用程序状态中添加一个新的 activeOverlay 数据属性:
      // The consolidated state of our app
      var state = {
        // UI
        activeOverlay: null,
        // ...
      }
  1. 在主模板中,我们将仅在 activeOverlay 未定义时显示手部组件,感谢 v-if 指令:
      <hand :cards="testHand" v-if="!activeOverlay" />
  1. 现在,如果您在浏览器控制台中将 state.activeOverlay 更改为任何真值,手部将消失:
      state.activeOverlay = 'player-turn'
  1. 另外,如果将其设置回 null,手部将再次显示:
      state.activeOverlay = null
  1. 当使用 v-ifv-show 指令添加或移除组件时应用过渡,请像这样将其包裹在过渡组件中:
      <transition>
        <hand v-if="!activeOverlay" />
      </transition>

注意,这也适用于 HTML 元素:

<transition>
  <h1 v-if="showTitle">Title</h1>
</transition>

<transition> 特殊组件不会出现在 DOM 中,就像我们在第二章 Markdown Notebook 中使用的 <template> 标签一样。

当元素被添加到 DOM 中(进入阶段)时,过渡组件将自动向元素应用以下 CSS 类:

  • v-enter-active:在进入过渡处于活动状态时应用此类。此类在元素插入到 DOM 中之前添加,并在动画完成时删除。您应该在此类中添加一些 transition CSS 属性,并定义它们的持续时间。

  • v-enter:元素的起始状态。此类在元素插入前添加,在元素插入后一帧删除。例如,你可以在此类中将不透明度设置为 0

  • v-enter-to:元素的目标状态。此类在元素插入后一帧添加,与删除 v-enter 时同时发生。在动画完成时删除。

当元素从 DOM 中移除时(离开阶段),它们将被以下内容替换:

  • v-leave-active:在离开过渡处于活动状态时应用。此类在离开过渡触发时添加,并在元素从 DOM 中移除后删除。您应该在此类中添加一些transition CSS 属性并定义它们的持续时间。

  • v-leave:元素被移除时的起始状态。这个类也会在离开过渡触发时添加,并在一帧后删除。

  • v-leave-to:元素的目标状态。此类在离开过渡触发一帧后添加,与v-leave同时删除。当元素从 DOM 中移除时,它将被删除。

在离开阶段,元素不会立即从 DOM 中移除。在过渡完成后才会移除它,以便用户可以看到动画。

这里是一个总结了两个进入和离开阶段以及相应的 CSS 类的模式图:

图过渡组件将自动检测应用在元素上的 CSS 过渡的持续时间。

  1. 我们需要编写一些 CSS 来制作我们的动画。创建一个新的transitions.css文件并将其包含在网页中:
      <link rel="stylesheet" href="transitions.css" />

首先尝试基本的淡入淡出动画。我们希望在 1 秒钟内对不透明度 CSS 属性应用 CSS 过渡。

  1. 为此,请同时使用v-enter-activev-leave-active类,因为它们是相同的动画:
      .hand.v-enter-active,
      .hand.v-leave-active {
        transition: opacity 1s;
      }

当手被添加或从 DOM 中移除时,我们希望它的不透明度为0(因此它将完全透明)。

  1. 使用v-enterv-leave-to类来应用这种完全透明:
      .hand.v-enter,
      .hand.v-leave-to {
        opacity: 0;
      }
  1. 回到主模板,使用过渡特殊组件将手组件包围起来:
      <transition>
        <hand v-if="!activeOverlay" :cards="testHand" />
      </transition>

现在,当您隐藏或显示手时,它将淡入淡出。

  1. 由于我们可能需要重用此动画,我们应该给它一个名称:
      <transition name="fade">
        <hand v-if="!activeOverlay" :cards="testHand" />
      </transition>

我们必须更改我们的 CSS 类,因为 Vue 现在将使用fade-enter-active而不是v-enter-active

  1. transition.css文件中,修改 CSS 选择器以匹配此更改:
      .fade-enter-active,
      .fade-leave-active {
        transition: opacity 1s;
      }

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

现在,我们可以在任何带有<transition name="fade">的元素上重复使用此动画。

更漂亮的动画

现在我们将制作一个更复杂但更好的动画,带有一些 3D 效果。除了手之外,我们还将为.wrapper元素(用于 3D 翻转)和.card元素添加动画。卡片将开始堆叠,并逐渐扩展到手中的预期位置。最后,它将以玩家从桌子上拿起卡片的方式进行动画。

  1. 首先创建新的过渡 CSS 类,使用'hand'名称代替'fade'
      .hand-enter-active,
      .hand-leave-active {
        transition: opacity .5s;
      }

      .hand-enter,
      .hand-leave-to {
        opacity: 0;
      }
  1. 在主模板中也更改过渡名称:
      <transition name="hand">
        <hand v-if="!activeOverlay" :cards="testHand" />
      </transition>
  1. 让我们对.wrapper 元素进行动画处理。使用 CSS transform 属性将 3D 变换应用于元素:
      .hand-enter-active .wrapper,
      .hand-leave-active .wrapper {
        transition: transform .8s cubic-bezier(.08,.74,.34,1);
        transform-origin: bottom center;
      }

      .hand-enter .wrapper,
      .hand-leave-to .wrapper {
        transform: rotateX(90deg);
      }

右旋转轴是水平轴,即x。这将使卡片动画看起来就像被玩家拿起一样。请注意,定义了一个立方贝塞尔缓动函数,以使动画更平滑。

  1. 最后,通过设置负的水平边距来为卡片本身添加动画,这样它们看起来就像是堆叠起来的:
      .hand-enter-active .card,
      .hand-leave-active .card {
        transition: margin .8s cubic-bezier(.08,.74,.34,1);
      }

      .hand-enter .card,
      .hand-leave-to .card {
        margin: 0 -100px;
      }

现在,如果您像以前那样使用浏览器控制台隐藏和显示手牌,它将有一个漂亮的动画。

打出一张牌

现在,我们需要处理手牌组件中的'play'事件,当用户点击它们时,我们会发出一个新的'card-play'事件到主组件,并附加一个额外的参数--所打出的牌。

  1. 首先,创建一个名为handlePlay的新方法。它接受一个card参数,并向父组件发出新事件:
      methods: {
        handlePlay (card) {
          this.$emit('card-play', card)
        },
      },
  1. 然后,为我们的卡片添加一个对'play'事件的监听器:
      <card v-for="card of cards" :def="card.def" 
      @play="handlePlay(card) />

正如您在这里看到的,我们直接使用了v-for循环的迭代变量card。这样,我们就不需要卡片组件发出它的card项目,因为我们已经知道它是什么。

为了测试牌的打出,我们现在只会从手牌中移除它。

  1. main.js文件的主组件中创建一个名为testPlayCard的新临时方法:
      methods: {
        // ...
        testPlayCard (card) {
          // Remove the card from player hand
          const index = this.testHand.indexOf(card)
          this.testHand.splice(index, 1)
        }
      },
  1. 在主模板中的hand组件上添加'card-play'事件的事件侦听器:
      <hand v-if="!activeOverlay" :cards="testHand" @card-play="testPlayCard" />

如果您点击一张卡片,它现在应该向手牌组件发出一个'play'事件,然后手牌组件将向主组件发出一个'card-play'事件。接着,它将从手牌中移除该卡片,使其消失。为了帮助您调试这种情况,开发工具有一个事件选项卡:

为卡片列表添加动画

对于我们的手牌,有三个缺失的动画--当一张牌被添加或从玩家手牌中移除时,以及当它被移动时。当回合开始时,玩家会抽一张牌。这意味着我们将向手牌列表中添加一张牌,并且它将从右侧滑入手牌。当打出一张牌时,我们希望它上移并变大。

要为一系列元素添加动画,我们将需要另一个特殊的组件--<transition-group>。当它们被添加、移除和移动时,它会对子元素进行动画处理。在模板中,它看起来像这样:

<transition-group>
  <div v-for="item of items" />
</transition-group>

<transition>元素不同,过渡组在 DOM 中默认显示为一个<span>元素。您可以使用tag属性更改 HTML 元素:

<transition-group tag="ul">
  <li v-for="item of items" />
</transition-group>

在我们hand组件的模板中,用一个过渡组件将卡片组件包起来,指定我们将调用的过渡的名称为"card",并添加"cards" CSS 类:

<transition-group name="card" tag="div" class="cards">
  <card v-for="card of cards" :def="card.def" @play="handlePlay(card) />
</transition-group>

在我们继续之前,还缺少一个重要的事情--过渡组的子元素必须通过唯一键来标识。

关键特殊属性

当 Vue 在v-for循环中更新 DOM 元素列表时,它会尽量减少应用于 DOM 的操作数量,如添加或移除元素。这是在大多数情况下更新 DOM 的非常高效的方法,可以提高性能。

为了做到这一点,Vue 会尽可能地重复使用元素,并且只在需要的地方打补丁来达到所需的结果。这意味着重复的元素将在原地打补丁,如果列表中添加或删除项,则不会被移动。然而,这也意味着如果我们对它们应用过渡,它们就不会发生动画。

以下是这样运作的示意图:

在这个示例中,我们移除了列表中的第三个项目,即c。然而,第三个div元素不会被销毁--它将会被重复利用,使用列表中的第四个项目d。实际上,这是第四个被销毁的div元素。

幸运的是,我们可以告诉 Vue 每个元素如何被识别,这样它就可以重复使用和重新排序它们。为了做到这一点,我们需要使用key特殊属性指定一个唯一标识。例如,我们的每个项目都可以有一个我们将用作关键字的唯一 ID:

在这里,我们指定了关键字,以便 Vue 知道第三个div元素应该被销毁,第四个 div 元素应该被移动。

关键特殊属性的工作方式与标准属性类似,因此,如果我们想为其分配动态值,就需要使用v-bind指令。

回到我们的卡片,我们可以使用卡片上的唯一标识作为关键字:

<card v-for="card of cards" :def="card.def" :key="card.uid" @play="handlePlay(card) />

现在,如果我们在 JavaScript 中添加、移动或删除一个卡片项,它将会在 DOM 中以正确的顺序体现出来。

CSS 过渡

与之前一样,我们有以下六个可用的 CSS 类,以我们的组过渡名称'card'为前缀:card-enter-active, card-enter, card-enter-to, card-leave-active, card-leave, 和 card-leave-to。它们将被应用于组过渡的直接子元素,也就是我们的卡片组件。

  1. 组过渡器中对于移动的元素有一个额外的类名--v-move。Vue 会使用 CSS 的transform属性来使它们移动,所以我们只需要为其应用至少带有持续时间的 CSS 过渡就行了:
      .card-move {
        transition: transform .3s;
      }      

现在,当你点击卡片进行打出时,它应该消失,而其余的卡片将移动到它们的新位置。你也可以向手牌中添加卡片。

  1. 在 Vue devtools 中选择主组件,并在浏览器控制台中执行以下内容:
      state.testHand.push($vm.testDrawCard())

在 devtools 中选择一个组件会在浏览器控制台中公开它作为$vm

就像我们对手牌做的那样,当卡片进入手牌时,我们也会为它们添加动画,当它们被打出时(因此离开手牌)。

  1. 由于我们需要在卡片上始终以相同时机过渡多个 CSS 属性(除了在离开过渡期间),我们将刚刚写的.card-move规则改成这样:
 .card {
        /* Used for enter, move and mouse over animations */
        transition: all .3s;
      }
  1. 对于进入动画,请指定卡片的状态作为过渡的开始:
 .card-enter {
        opacity: 0;
        /* Slide from the right */
        transform: scale(.8) translateX(100px);
      }
  1. 离开动画需要更多规则,因为打出卡片的动画更复杂,涉及将卡片向上缩放:
 .card-leave-active {
        /* We need different timings for the leave transition */
        transition: all 1s, opacity .5s .5s;
        /* Keep it in the same horizontal position */
        position: absolute !important;
        /* Make it painted over the other cards */
        z-index: 10;
        /* Unclickable during the transition */
        pointer-events: none;
      }

      .card-leave-to {
        opacity: 0;
        /* Zoom the card upwards */
        transform: translateX(-106px) translateY(-300px) scale(1.5);
      }

这就足以使你的卡片都正确地动画化。你可以尝试再次玩耍并添加卡片到手中,看看结果。

叠加层

我们需要的最后一个 UI 元素是叠加层。以下是其中的三个:

  • '新回合'叠加层在轮到当前玩家时显示当前玩家的名字。点击'新回合'玩家会切换到'上一回合'叠加层。

  • '上一回合'叠加层显示玩家之前对手做的事情。它显示以下内容之一:

    • 上一回合对手出的卡片

    • 提醒玩家他们的回合被跳过了

  • '游戏结束'叠加层显示玩家或两个玩家输掉时。它显示玩家的名字与短语“获胜”或“被击败”。点击'游戏结束'叠加层重新加载游戏。

所有这些叠加层有两个共同点--当用户点击它们时它们会执行某些操作,并且它们具有类似的布局设计。因此,我们应该在这里做得更聪明,结构化我们的组件以在合适的地方尽可能地重用代码。这里的想法是创建一个通用的叠加层组件,该组件将负责处理点击事件和布局以及三个特定的叠加层内容组件,用于我们需要的每个叠加层。

在开始之前,在state.js文件中的应用状态中添加一个新的activeOverlay属性:

// The consolidated state of our app
var state = {
  // UI
  activeOverlay: null,
  // ...
}

这将保存当前显示的叠加层的名称,如果没有显示叠加层,则为null

使用插槽进行内容分发

如果我们可以在主模板中的叠加层组件中放置内容,这将非常方便,就像这样:

<overlay>
  <overlay-content-player-turn />
</overlay>

我们将在overlay组件中封装额外的布局和逻辑,同时仍然能够放置任何内容。这是通过一个特殊的元素--<slot>完成的。

  1. 让我们创建我们的overlay组件,并加上两个div元素:
 Vue.component('overlay', {
        template: `<div class="overlay">
          <div class="content">
            <!-- Our slot will be there -->
          </div>
        </div>`,
      })
  1. .overlay div 上添加点击事件监听器,调用handleClick方法:
      <div class="overlay" @click="handleClick">
  1. 然后,在我们发出自定义'close'事件的地方添加上述方法:
      methods: {
        handleClick () {
          this.$emit('close')
        },
      },

此事件将有助于知道何时在回合开始时从一个叠加层切换到下一个。

  1. 现在,在.content div 中放置一个<slot>元素:
      template: `<div class="overlay" @click="handleClick">
        <div class="content">
          <slot />
        </div>
      </div>`,

现在,如果我们在使用我们的组件时在overlay标签之间放置了一些内容,它将被包含在 DOM 中,并替换<slot>标签。例如,我们可以这样做:

<overlay>
  Hello world!
</overlay>

另外,它将在页面中呈现如下:

<div class="overlay">
  <div class="content">
    Hello world!
  </div>
</div>

它与任何内容一起使用,因此您也可以放置 HTML 或 Vue 组件,它仍将以相同的方式工作!

  1. 该组件已准备好在主模板中使用,因此将其添加到最后:
      <overlay>
        Hello world!
      </overlay>

这三个叠加层内容将是独立的组件:

  • overlay-content-player-turn 显示回合的开始

  • overlay-content-last-play 显示上一回合对手打出的最后一张卡片

  • overlay-content-game-over在游戏结束时显示

在深入研究这些内容之前,我们需要有关状态中两个玩家的一些更多数据。

  1. 回到state.js文件,并为每个玩家添加以下属性:
      // Starting stats
      food: 10,
      health: 10,
      // Is skipping is next turn
      skipTurn: false,
      // Skiped turn last time
      skippedTurn: false,
      hand: [],
      lastPlayedCardId: null,
      dead: false,

现在,你应该在players数组中有两个具有相同属性的项目,除了玩家名称。

'玩家回合'叠加层

第一个叠加层将向当前玩家显示两条不同的消息,具体取决于是否跳过了他们的回合。玩家属性将接收当前玩家,以便我们可以访问其数据。我们将使用v-if指令与v-else指令和刚刚添加到玩家的skipTurn属性:

 Vue.component('overlay-content-player-turn', {
        template: `<div>
          <div class="big" v-if="player.skipTurn">{{ player.name }},      <br>your turn is skipped!</div>
          <div class="big" v-else>{{ player.name }},<br>your turn has       come!</div>
          <div>Tap to continue</div>
        </div>`,
        props: ['player'],
      })

'最后一次出牌'叠加层

这个比较复杂。我们需要一个新函数来获取玩家最后打出的卡片。在utils.js文件中,添加新函数getLastPlayedCard

function getLastPlayedCard (player) {
  return cards[player.lastPlayedCardId]
}

我们现在可以通过传递opponentprop 在lastPlayedCard计算属性中使用此函数:

Vue.component('overlay-content-last-play', {
  template: `<div>
    <div v-if="opponent.skippedTurn">{{ opponent.name }} turn was skipped!</div>
    <template v-else>
      <div>{{ opponent.name }} just played:</div>
      <card :def="lastPlayedCard" />
    </template>
  </div>`,
  props: ['opponent'],
  computed: {
    lastPlayedCard () {
      return getLastPlayedCard(this.opponent)
    },
  },
})

请注意,我们是直接重用了之前创建的card组件来展示卡片。

'游戏结束'叠加层

对于这个,我们将创建另一个组件,名为player-result,它将显示玩家是胜利还是失败。我们将通过一个 prop 传递玩家的名称。我们将使用计算属性为该玩家计算结果,并将其作为动态 CSS 类使用:

Vue.component('player-result', {
  template: `<div class="player-result" :class="result">
    <span class="name">{{ player.name }}</span> is
    <span class="result">{{ result }}</span>
  </div>`,
  props: ['player'],
  computed: {
    result () {
      return this.player.dead ? 'defeated' : 'victorious'
    },
  },
})

现在,我们可以通过循环遍历players属性并使用player-result组件来创建游戏结束叠加层:

Vue.component('overlay-content-game-over', {
  template: `<div>
    <div class="big">Game Over</div>
    <player-result v-for="player in players" :player="player" />
  </div>`,
  props: ['players'],
})

动态组件

现在,是时候将所有这些内容放入我们的叠加层组件中,并使用之前定义的activeOverlay属性。

  1. 添加组件并在主模板中使用相应的activeOverlay值来显示它们:
      <overlay v-if="activeOverlay">
        <overlay-content-player-turn
          v-if="activeOverlay === 'player-turn'" />
        <overlay-content-last-play
          v-else-if="activeOverlay === 'last-play'" />
        <overlay-content-game-over
          v-else-if="activeOverlay === 'game-over'" />
      </overlay>

如果activeOverlay属性等于null,我们将完全移除叠加层。

在添加 props 之前,我们将需要修改state.js文件中的应用程序状态,并添加一些 getter 函数。

  1. 第一个将从currentPlayerIndex属性返回player对象:
      get currentPlayer () {
        return state.players[state.currentPlayerIndex]
      },
  1. 第二个将返回对手的player索引:
      get currentOpponentId () {
        return state.currentPlayerIndex === 0 ? 1 : 0
      },
  1. 最后,第三个将返回相应的玩家对象:
      get currentOpponent () {
        return state.players[state.currentOpponentId]
      },
  1. 现在,我们可以为叠加层内容添加 props:
      <overlay v-if="activeOverlay">
        <overlay-content-player-turn
          v-if="activeOverlay === 'player-turn'"
          :player="currentPlayer" />
        <overlay-content-last-play
          v-else-if="activeOverlay === 'last-play'"
          :opponent="currentOpponent" />
        <overlay-content-game-over
          v-else-if="activeOverlay === 'game-over'"
          :players="players" />
      </overlay>

你可以通过在浏览器控制台中设置activeOverlay属性来测试这些叠加层:

state.activeOverlay = 'player-turn'
state.activeOverlay = 'last-play'
state.activeOverlay = 'game-over'
state.activeOverlay = null

如果你想测试last-play叠加层,你需要为玩家的lastPlayedCardId属性指定一个有效的值,如'catapult''farm'

我们的代码开始变得杂乱,在三个条件语句中。幸运的是,有一个特殊的组件可以将自身转换为任何组件 - 那就是component组件。只需将其is属性设置为组件名称,组件定义对象,甚至是 HTML 标签,它将变形为该组件:

<component is="h1">Title</component>
<component is="overlay-content-player-turn" />

它就像任何其他的 prop 一样,所以我们可以使用v-bind指令来通过 JavaScript 表达式动态改变组件的本质。如果我们使用我们的activeOverlay属性来做到这一点会怎么样?我们的覆盖层内容组件是否方便地以相同的'over-content-'前缀命名?看一下:

<component :is="'overlay-content-' + activeOverlay" />

就是这样。现在,通过改变activeOverlay属性的值,我们将改变覆盖层内显示的组件。

  1. 在添加回 props 后,主模板中的覆盖层应该如下所示:
      <overlay v-if="activeOverlay">
        <component :is="'overlay-content-' + activeOverlay"
          :player="currentPlayer" :opponent="currentOpponent"
          :players="players" />
      </overlay>

别担心,未使用的 props 不会影响不同覆盖层的工作方式。

覆盖层动画

就像我们用手做的那样,我们将使用过渡来动画显示覆盖层。

  1. 在覆盖层组件周围添加一个名为“zoom”的过渡:
      <transition name="zoom">
        <overlay v-if="activeOverlay">
          <component :is="'overlay-content-' + activeOverlay"                    
          :player="currentPlayer" :opponent="currentOpponent"                      
          :players="players" />
        </overlay>
      </transition>
  1. transition.css文件中添加以下 CSS 规则:
      .zoom-enter-active,
      .zoom-leave-active {
        transition: opacity .3s, transform .3s;
      }

      .zoom-enter,
      .zoom-leave-to {
        opacity: 0;
        transform: scale(.7);
      }

这是一个简单的动画,会在淡出的同时缩小覆盖层。

关键属性

现在,如果您在浏览器中尝试动画,它应该只在两种情况下起作用:

  • 当您没有显示任何覆盖层,并且您设置了一个时

  • 当您有一个显示的覆盖层,并且您将activeOverlay设置为null以隐藏它时

如果您在不同的覆盖层之间切换,动画将不起作用。这是由于 Vue 更新 DOM 的方式;正如我们在关键特殊属性部分中看到的那样,它将尽可能地重用 DOM 元素以优化性能。在这种情况下,我们需要使用关键特殊属性来向 Vue 发出提示,表明我们希望将不同的覆盖层视为单独的元素。因此,当我们从一个覆盖层过渡到另一个覆盖层时,两者都将存在于 DOM 中,并且可以播放动画。

让我们给我们的覆盖层组件添加键,这样 Vue 在更改activeOverlay值时将其视为多个单独的元素:

<transition name="zoom">
  <overlay v-if="activeOverlay" :key="activeOverlay">
    <component :is="'overlay-content-' + activeOverlay" :player="currentPlayer" :opponent="currentOpponent" :players="players" />
  </overlay>
</transition>

现在,如果我们将activeOverlay设置为'player-turn',覆盖层将具有键为'player-turn'。然后,如果我们将activeOverlay设置为'last-play',将创建一个完全新的键为'last-play'的覆盖层,我们可以在两者之间进行过渡动画。您可以在浏览器中通过将不同的值设置为state.activeOverlay来尝试此操作。

覆盖层背景

此时,有些东西丢失了--覆盖层背景。我们不能将其包含在覆盖层组件内部,因为在动画期间它会被放大--这会非常尴尬。相反,我们将使用我们已经创建的简单fade动画。

在主模板中,在zoom过渡和overlay组件之前添加一个带有overlay-background类的新的div元素:

<transition name="fade">
  <div class="overlay-background" v-if="activeOverlay" />
</transition>

使用v-if指令,只有在显示任何覆盖层时才会显示它。

游戏世界和场景

我们大部分完成了 UI 元素,所以现在我们可以进入游戏场景组件。我们将有一些新组件要做--玩家城堡,每个城堡都有一个健康和食物气泡,以及一些背景中的动画云,以增加乐趣。

components文件夹中创建一个新的world.js文件,并在页面中包含它:

<!-- ... -->
<script src="img/ui.js"></script>
<script src="img/world.js"></script>
<script src="img/main.js"></script>

让我们从城堡开始。

城堡们

这个实际上相当简单,因为它仅包含两个图像和一个城堡旗帜组件,主要负责显示健康和食物状态:

  1. world.js文件中,创建一个新的城堡组件,其中包含接受playersindex属性的两个图像:
 Vue.component('castle', {
        template: `<div class="castle" :class="'player-' + index">
          <img class="building" :src="img/castle' + index + '.svg'" />
          <img class="ground" :src="img/ground' + index + '.svg'" />
          <!-- Later, we will add a castle-banners component here -->
        </div>`,
        props: ['player', 'index'],
      })

对于此组件,每个玩家有一个城堡和一个地面图像;这意味着总共有四幅图像。例如,对于索引为0的玩家,有castle0.svgground0.svg图像。

  1. 在主模板中,在top-bar组件的正下方,创建一个具有world CSS 类的新div元素,循环遍历玩家以显示两座城堡,并添加另一个具有land类的div元素:
      <div class="world">
        <castle v-for="(player, index) in players" :player="player"                 
         :index="index" />
        <div class="land" />
      </div>

在浏览器中,应该看到每个玩家的一个城堡,如下所示:

城堡旗帜

城堡旗帜将显示城堡的健康和食物。castle-banners组件内部将包含两个组件:

  • 一个垂直的横幅,其高度根据状态的数量而变化

  • 一个显示实际数字的气泡

它将看起来像这样:

  1. 首先,创建一个只包含状态图标和一个player属性的新castle-banners组件:
 Vue.component('castle-banners', {
        template: `<div class="banners">
          <!-- Food -->
          <img class="food-icon" src="img/food-icon.svg" />
          <!-- Bubble here -->
          <!-- Banner bar here -->

          <!-- Health -->
          <img class="health-icon" src="img/health-icon.svg" />
          <!-- Bubble here -->
          <!-- Banner bar here -->
        </div>`,
        props: ['player'],
      })
  1. 我们还需要两个计算属性来计算健康和食物比例:
      computed: {
        foodRatio () {
          return this.player.food / maxFood
        },
        healthRatio () {
          return this.player.health / maxHealth
        },
      }

maxFoodmaxHealth变量在state.js文件的开头定义。

  1. castle组件中添加新的castle-banners组件:
      template: `<div class="castle" :class="'player-' + index">
        <img class="building" :src="img/castle' + index + '.svg'" />
        <img class="ground" :src="img/ground' + index + '.svg'" />
        <castle-banners :player="player" />
      </div>`,

食物和健康气泡

此组件包含一个图像和一个显示城堡食物或健康当前数量的文本。其位置将根据此数量变化--当数量减少时会上升,而在重新补充时会下降。

对于这个组件,我们将需要三个属性:

  • type是食物或健康之一;它将用于 CSS 类和图像路径

  • value是在气泡中显示的数量

  • ratio是数量除以最大数量

我们还需要一个计算属性来计算气泡随ratio属性的垂直位置。位置将在 40 像素到 260 像素之间变化。因此,位置值由以下表达式给出:

(this.ratio * 220 + 40) * state.worldRatio + 'px'

记得要用worldRatio值将每个位置或大小相乘,这样游戏才能考虑窗口大小(如果窗口更大,则它变大,反之亦然)。

  1. 让我们编写我们的新bubble组件:
 Vue.component('bubble', {
        template: `<div class="stat-bubble" :class="type + '-bubble'"               
        :style="bubbleStyle">
          <img :src="img/' + type + '-bubble.svg'" />
          <div class="counter">{{ value }}</div>
        </div>`,
        props: ['type', 'value', 'ratio'],
        computed: {
          bubbleStyle () {
            return {
              top: (this.ratio * 220 + 40) * state.worldRatio + 'px',
            }
          },
        },
      })

它有一个根div元素,具有stat-bubble CSS 类,以及一个动态类(根据type属性值,可以是'food-bubble''health-bubble'),再加上我们用bubbleStyle计算属性设置的动态 CSS 样式。

它包含一个 SVG 图像,食物和健康不一样,并且一个具有counter类的div元素显示数量。

  1. castle-banners组件添加一个食物和一个健康气泡:
      template: `<div class="banners">
        <!-- Food -->
        <img class="food-icon" src="img/food-icon.svg" />
        <bubble type="food" :value="player.food" :ratio="foodRatio" />
        <!-- Banner bar here -->

        <!-- Health -->
        <img class="health-icon" src="img/health-icon.svg" />
        <bubble type="health" :value="player.health"             
      :ratio="healthRatio" />
        <!-- Banner bar here -->
      </div>`,

横幅条

我们需要的另一个组件是挂在城堡塔楼上的垂直横幅。其长度将根据食物或健康的数量而变化。这一次,我们将创建一个动态 SVG 模板,以便我们可以修改横幅的高度。

  1. 首先,使用两个属性(颜色和比例)和计算属性height创建组件:
 Vue.component('banner-bar', {
        props: ['color', 'ratio'],
        computed: {
          height () {
            return 220 * this.ratio + 40
          },
        },
      })

到目前为止,我们以两种不同的方式定义了我们的模板——我们要么使用了我们页面的 HTML,要么将字符串设置为组件的template选项。我们将使用另一种编写组件模板的方法——在 HTML 中使用特殊的脚本标签。通过在此脚本标签内部编写带有唯一 ID 的模板,并在定义组件时引用此 ID,它的工作原理是。

  1. 打开banner-template.svg文件,其中包含我们将用作动态模板的横幅图像的 SVG 标记。复制文件的内容。

  2. <div id="app">元素后的index.html文件中,添加一个script标签,类型为text/x-template,并带有bannerID,然后粘贴svg内容:

      <script type="text/x-template" id="banner">
        <svg viewBox="0 0 20 260">
          <path :d="`m 0,0 20,0 0,${height} -10,-10 -10,10 z`"                    
          :style="`fill:${color};stroke:none;`" />
        </svg>
      </script>

正如您所看到的,这是一个标准模板,具有可用于使用的所有语法和指令。在这里,我们两次使用了v-bind指令的缩写。请注意,您可以在所有 Vue 模板中使用 SVG 标记。

  1. 现在,回到我们的组件定义中,添加template选项,并使用井号标记前面的脚本标签模板的 ID:
      Vue.component('banner-bar', {
        template: '#banner',
        // ...
      })

完成!该组件现在将查找页面中带有bannerID 的脚本标签模板,并将其用作模板。

  1. castle-banners组件中,使用相应的颜色和比例添加另外两个banner-bar组件:
      template: `<div class="banners">
        <!-- Food -->
        <img class="food-icon" src="img/food-icon.svg" />
        <bubble type="food" :value="player.food" :ratio="foodRatio" />
        <banner-bar class="food-bar" color="#288339" :ratio="foodRatio"        
        />

        <!-- Health -->
        <img class="health-icon" src="img/health-icon.svg" />
        <bubble type="health" :value="player.health"                   
        :ratio="healthRatio" />
        <banner-bar class="health-bar" color="#9b2e2e"                         
       :ratio="healthRatio" />
      </div>`,

现在,您应该能够看到悬挂在城堡上的横幅,并且如果您更改食物和健康值,则它们会收缩。

动画化数值

如果我们可以在它们收缩或扩展时对它们进行动画处理,这些横幅会更漂亮。我们不能依赖于 CSS 过渡,因为我们需要动态更改 SVG 路径,所以我们需要另一种方式——我们将动画化模板中使用的height属性的值。

  1. 首先,让我们将模板的计算属性重命名为targetHeight
      computed: {
        targetHeight () {
          return 220 * this.ratio + 40
        },
      },

targetHeight属性将在比例变化时仅计算一次。

  1. 添加一个新的height数据属性,我们将能够在targetHeight更改时对其进行动画处理:
 data () {
        return {
          height: 0,
        }
      },
  1. 在组件创建后,在created钩子中将height的值初始化为targetHeight的值:
 created () {
        this.height = this.targetHeight
      },

为了使高度值动画化,我们将使用流行的**TWEEN.js**库,该库已经包含在index.html文件中。该库通过创建一个新的Tween对象来工作,该对象采用起始值、缓动函数和结束值。它提供了诸如onUpdate之类的回调,我们将使用这些回调来更新动画的height属性。

  1. 我们希望在targetHeight属性更改时启动动画,因此添加一个带有以下动画代码的监视程序:
 watch: {
        targetHeight (newValue, oldValue) {
          const vm = this
          new TWEEN.Tween({ value: oldValue })
            .easing(TWEEN.Easing.Cubic.InOut)
            .to({ value: newValue }, 500)
            .onUpdate(function () {
              vm.height = this.value.toFixed(0)
            })
            .start()
        },
      },

onUpdate 回调中的 this 上下文是 Tween 对象,而不是 Vue 组件实例。这就是为什么我们需要一个好的临时变量来保存组件实例 this(这里,vm 变量就是那个)。

  1. 我们需要最后一件事来使我们的动画工作。在 main.js 文件中,请求浏览器从浏览器请求绘画帧以使 TWEEN.js 库滴答作响,感谢浏览器的 requestAnimationFrame 函数:
      // Tween.js
      requestAnimationFrame(animate);

      function animate(time) {
        requestAnimationFrame(animate);
        TWEEN.update(time);
      }

如果标签在后台,则 requestAnimationFrame 函数将等待标签再次变为可见。这意味着如果用户看不到页面,动画将不会播放,从而节省计算机资源和电池电量。请注意,CSS 过渡和动画也是如此。

现在当你改变玩家的食物或健康状态时,横幅将逐渐缩小或增大。

动态云

为了为游戏世界增添一些生气,我们将创建一些在天空中滑动的云。它们的位置和动画持续时间将是随机的,它们将从窗口的左侧移动到右侧。

  1. world.js 文件 中,添加云动画的最小和最大持续时间:
      const cloudAnimationDurations = {
        min: 10000, // 10 sec
        max: 50000, // 50 sec
      }
  1. 然后,创建云组件,包括图像和 type 属性:
 Vue.component('cloud', {
        template: `<div class="cloud" :class="'cloud-' + type" >
          <img :src="img/strong> + '.svg'" />
        </div>`,
        props: ['type'],
      })

将有五个不同的云,因此 type 属性将从 1 到 5。

  1. 我们将需要使用一个响应式的 style 数据属性来更改组件的 z-indextransform CSS 属性:
 data () {
        return {
          style: {
            transform: 'none',
            zIndex: 0,
          },
        }
      },
  1. 使用 v-bind 指令应用这些样式属性:
      <div class="cloud" :class="'cloud-' + type" :style="style">
  1. 让我们创建一个方法来使用 transform CSS 属性设置云组件的位置:
      methods: {
        setPosition (left, top) {
          // Use transform for better performance
          this.style.transform = `translate(${left}px, ${top}px)`
        },
      }
  1. 当图片加载时,我们需要初始化云的水平位置,使其位于视口之外。创建一个新的 initPosition,它使用 setPosition 方法:
      methods: {
        // ...
        initPosition () {
          // Element width
          const width = this.$el.clientWidth
          this.setPosition(-width, 0)
        },
      }
  1. 在图像上添加一个事件监听器,使用 v-on 指令缩写监听 load 事件并调用 initPosition 方法:
      <img :src="img/cloud' + type + '.svg'" @load="initPosition" />

动画

现在,让我们继续进行动画本身。就像我们为城堡横幅所做的那样,我们将使用 TWEEN.js 库:

  1. 首先,创建一个新的 startAnimation 方法,计算一个随机的动画持续时间,并接受一个延迟参数:
      methods: {
        // ...

        startAnimation (delay = 0) {
          const vm = this

          // Element width
          const width = this.$el.clientWidth

          // Random animation duration
          const { min, max } = cloudAnimationDurations
          const animationDuration = Math.random() * (max - min) + min

          // Bing faster clouds forward
          this.style.zIndex = Math.round(max - animationDuration)

          // Animation will be there
        },
      }

云越快,其动画持续时间就越低。更快的云将在较慢的云之前显示,这要归功于 z-index CSS 属性。

  1. startAnimation 方法内部,计算云的随机垂直位置,然后创建一个 Tween 对象。它将以延迟动画水平位置,并在每次更新时设置云的位置。当它完成时,我们将以随机延迟启动另一个动画:
      // Random position
      const top = Math.random() * (window.innerHeight * 0.3)

      new TWEEN.Tween({ value: -width })
        .to({ value: window.innerWidth }, animationDuration)
        .delay(delay)
        .onUpdate(function () {
          vm.setPosition(this.value, top)
        })
        .onComplete(() => {
          // With a random delay
          this.startAnimation(Math.random() * 10000)
        })
        .start()
  1. 在组件的 mounted 钩子中,调用 startAnimation 方法开始初始动画(带有随机延迟):
 mounted () {
        // We start the animation with a negative delay
        // So it begins midway
        this.startAnimation(-Math.random() *                   
      cloudAnimationDurations.min)
      },

我们的云组件已准备好。

  1. world 元素的主模板中添加一些云:
      <div class="clouds">
        <cloud v-for="index in 10" :type="(index - 1) % 5 + 1" />
      </div>

要小心将值传递给 type 属性,其取值范围为 1 到 5。在这里,我们使用 % 运算符来返回 5 的除法余数。

它应该是这样的:

图片

游戏过程

所有的组件都完成了! 我们只需要为应用添加一些游戏逻辑,使其可玩。 游戏开始时,每个玩家都会抽取他们的初始手牌。

然后,每个玩家的回合都按照以下步骤进行:

  1. player-turn覆盖层显示,以便玩家知道轮到他们了。

  2. last-play覆盖层显示了上次游戏中另一位玩家打出的牌。

  3. 玩家通过点击卡片来出牌。

  4. 卡片从他们的手中移除,并应用其效果。

  5. 我们稍等一下,以便玩家可以看到这些效果的发生。

  6. 然后,回合结束,并将当前玩家切换到另一个玩家。

抽牌

在抽牌之前,我们需要在state.js文件中的应用状态中添加两个属性:

var state = {
  // ...
  drawPile: pile,
  discardPile: {},
}

drawPile属性是玩家可以抽取的牌堆。 它使用在cards.js文件中定义的pile对象进行初始化。 每个键都是卡片定义的 ID,值是此类型卡片在堆叠中的数量。

discardPile属性是drawPile属性的等价物,但它有不同的用途--玩家打出的所有卡片都将从他们的手中移除并放入弃牌堆中。 在某个时刻,如果抽牌堆为空,它将被弃牌堆重新填充(弃牌堆将被清空)。

初始手牌

游戏开始时,每个玩家都会抽取一些牌。

  1. utils.js文件中,有一个函数用于抽取玩家的手牌:
      drawInitialHand(player)
  1. main.js文件中,添加一个调用drawInitialHand函数为每个玩家发牌的新的beginGame函数:
      function beginGame () {
        state.players.forEach(drawInitialHand)
      }
  1. 当应用准备就绪时,在main.js文件中我们的主组件的mounted钩子内调用此函数:
 mounted () {
        beginGame()
      },

手牌

要显示当前玩家手中的卡片,我们需要在应用状态中添加一个新的 getter:

  1. state.js文件中的state对象中添加currentHand的 getter:
      get currentHand () {
        return state.currentPlayer.hand
      },
  1. 我们现在可以在主模板中删除testHand属性,并用currentHand替换它:
      <hand v-if="!activeOverlay" :cards="currentHand" @card-            
      play="testPlayCard" />
  1. 你也可以移除main组件上为测试目的编写的createTestHand方法和这个created钩子:
      created () {
        this.testHand = this.createTestHand()
      },

出牌

出牌分为以下三个步骤:

  1. 我们将卡片从玩家手中移除并将其添加到堆叠中。 这会触发卡片动画。

  2. 我们等待卡片动画完成。

  3. 我们应用卡片的效果。

不允许作弊

在游戏中,不应允许作弊。 在编写游戏逻辑时,我们应该记住这一点:

  1. 让我们首先在state.js文件中的应用状态中添加一个新的canPlay属性:
      var state = {
        // ...
        canPlay: false,
      }

这将阻止玩家在他们的回合中重复出牌--我们有很多动画和等待,所以我们不希望他们作弊。

我们将在玩家出牌时使用它来检查他们是否已经出过牌,并且还将在 CSS 中使用它来禁用手牌上的鼠标事件。

  1. 因此,在主组件中添加一个 cssClass 计算属性,如果 canPlay 属性为真,则添加 can-play CSS 类:
      computed: {
        cssClass () {
          return {
            'can-play': this.canPlay,
          }
        },
      },
  1. 并在主模板的根 div 元素上添加一个动态 CSS 类:
      <div id="#app" :class="cssClass">

从手牌中移除卡牌

当卡牌被打出时,它应该从当前玩家手中移除;按照以下步骤执行:

  1. main.js 文件中创建一个新的 playCard 函数,接受一张卡牌作为参数,检查玩家是否可以打出卡牌,然后将卡牌从手牌中移除,放入弃牌堆中使用 utils.js 文件中定义的 addCardToPile 函数:
      function playCard (card) {
        if (state.canPlay) {
          state.canPlay = false
          currentPlayingCard = card

          // Remove the card from player hand
          const index = state.currentPlayer.hand.indexOf(card)
          state.currentPlayer.hand.splice(index, 1)

          // Add the card to the discard pile
          addCardToPile(state.discardPile, card.id)
        }
      }

我们将玩家打出的卡牌存储在 currentPlayingCard 变量中,因为我们稍后需要应用其效果。

  1. 在主组件中,用一个新的 handlePlayCard 方法替换 testPlayCard 方法,调用 playCard 函数:
      methods: {
        handlePlayCard (card) {
          playCard(card)
        },
      },
  1. 别忘了在主模板中更改对 hand 组件的事件监听器:
      <hand v-if="!activeOverlay" :cards="currentHand" @card- 
 play="handlePlayCard" />

等待卡牌过渡结束

当卡牌被打出时,也就是从手牌列表中移除时,它会触发一个离开动画。我们希望在继续之前等待它完成。幸运的是,transitiontransition-group 组件会发出事件。

我们这里需要的是 'after-leave' 事件,但是每个转换阶段都对应着其他事件——'before-enter''enter''after-enter'等。

  1. hand 组件中,添加一个 'after-leave' 类型的事件监听器:
      <transition-group name="card" tag="div" class="cards" @after- 
 leave="handleLeaveTransitionEnd">
  1. 创建相应的方法,向主模板发出 'card-leave-end' 事件:
      methods: {
        // ...
        handleLeaveTransitionEnd () {
          this.$emit('card-leave-end')
        },
      },
  1. 在主模板中,在 hand 组件上添加一个 'card-leave-end' 类型的新事件监听器:
      <hand v-if="!activeOverlay" :cards="currentHand" @card-                
      play="handlePlayCard" @card-leave-end="handleCardLeaveEnd" />
  1. 创建相应的方法:
      methods: {
        // ...

        handleCardLeaveEnd () {
          console.log('card leave end')
        },
      }

我们稍后会编写它的逻辑。

应用卡牌效果

动画播放后,将为玩家应用卡牌效果。例如,它可能增加当前玩家的食物量或减少对手的生命值。

  1. main.js 文件中,添加使用 utils.js 文件中定义的 applyCardEffectapplyCard 函数:
      function applyCard () {
        const card = currentPlayingCard

        applyCardEffect(card)
      }

然后,我们将等待一段时间,以便玩家能够看到效果被应用,并了解正在发生的事情。然后,我们将检查至少有一名玩家是否已死亡以结束游戏(借助 utils.js 中定义的 checkPlayerLost 函数),或者继续下一回合。

  1. applyCard 函数中,添加以下相应逻辑:
      // Wait a bit for the player to see what's going on
      setTimeout(() => {
        // Check if the players are dead
        state.players.forEach(checkPlayerLost)

        if (isOnePlayerDead()) {
          endGame()
        } else {
          nextTurn()
        }
      }, 700)
  1. 现在,就在 applyCard 函数之后添加空的 nextTurnendGame 函数:
      function nextTurn () {
        // TODO
      }

      function endGame () {
        // TODO
      }
  1. 现在我们可以在主组件中修改 handleCardLeaveEnd 方法,调用我们刚刚创建的 applyCard 函数:
      methods: {
        // ...

        handleCardLeaveEnd () {
          applyCard()
        },
      }

下一个回合

nextTurn 函数非常简单——我们将回合计数器增加一,更改当前玩家,并显示玩家回合覆盖层。

将相应的代码添加到 nextTurn 函数中:

function nextTurn () {
  state.turn ++
  state.currentPlayerIndex = state.currentOpponentId
  state.activeOverlay = 'player-turn'
}

新的回合

在覆盖层之后,回合开始时我们还需要一些逻辑:

  1. 首先是newTurn函数,它隐藏了任何活动的叠加层;它要么跳过当前玩家的回合,因为有一张卡片,要么开始回合:
      function newTurn () {
        state.activeOverlay = null
        if (state.currentPlayer.skipTurn) {
          skipTurn()
        } else {
          startTurn()
        }
      }

如果玩家的skipTurn属性为 true,那么他们的回合将被跳过——这个属性将由一些卡片设置。他们还有一个skippedTurn属性,我们需要在last-play叠加层中向下一个玩家显示,告诉他们对手已经跳过了上一回合。

  1. 创建skipTurn函数,将skippedTurn设置为true,将skipTurn属性设置为false并直接进入下一轮:
      function skipTurn () {
        state.currentPlayer.skippedTurn = true
        state.currentPlayer.skipTurn = false
        nextTurn()
      }
  1. 创建startTurn函数,它重置了玩家的skippedTurn属性,并使他们在第二轮时抽一张卡片(这样他们每回合开始时都有五张卡片):
      function startTurn () {
        state.currentPlayer.skippedTurn = false
        // If both player already had a first turn
        if (state.turn > 2) {
          // Draw new card
          setTimeout(() => {
            state.currentPlayer.hand.push(drawCard())
            state.canPlay = true
          }, 800)
        } else {
          state.canPlay = true
        }
      }

就在这一刻,我们可以使用canPlay属性允许玩家打出一张卡片。

叠加关闭动作

现在,我们需要处理当用户点击每个叠加层时触发的动作。我们将创建一个映射,键为叠加层类型,值为触发动作时调用的函数。

  1. 将其添加到main.js文件中:
      var overlayCloseHandlers = {
        'player-turn' () {
          if (state.turn > 1) {
            state.activeOverlay = 'last-play'
          } else {
            newTurn()
          }
        },

        'last-play' () {
          newTurn()
        },
        'game-over' () {
          // Reload the game
          document.location.reload()
        },
      }

对于玩家回合叠加层,只有在第二轮或更多轮时才切换到last-play叠加层,因为在第一轮开始时,对手不会打出任何卡片。

  1. 在主组件中,添加handleOverlayClose方法,该方法调用与当前活动叠加层对应的动作函数,并传入activeOverlay属性:
      methods: {
        // ...
        handleOverlayClose () {
          overlayCloseHandlers[this.activeOverlay]()
        },
      },
  1. 在叠加层组件上,添加一个'close'类型的事件侦听器,当用户点击叠加层时触发:
      <overlay v-if="activeOverlay" :key="activeOverlay"                  
      @close="handleOverlayClose">

游戏结束!

最后,在endGame函数中将activeOverlay属性设置为'game-over'

function endGame () {
  state.activeOverlay = 'game-over'
}

如果至少有一个玩家死亡,这将显示game-over叠加层。

Summary

我们的纸牌游戏结束了。我们看到了 Vue 提供的许多新功能,使我们能够轻松创建丰富和交互式的体验。然而,在本章中介绍和使用的最重要的一点是基于组件的 Web 应用程序开发方法。这有助于我们通过将前端逻辑拆分为小型、隔离和可重用的组件来开发更大的应用程序。我们介绍了如何使组件彼此通信,从父组件到子组件使用 props,从子组件到父组件使用自定义事件。我们还为游戏添加了动画和过渡(使用<transition><transition-group>特殊组件),使其更加生动。我们甚至在模板中操纵了 SVG,并使用特殊的<component>组件动态显示了一个组件。

在下一章中,我们将使用 Vue 组件文件等其他功能来设置一个更高级的应用程序,这些功能将帮助我们构建更大的应用程序。

第四章:高级项目设置

在本章之后,我们将开始构建更复杂的应用程序,我们将需要一些额外的工具和库。我们将涵盖以下主题:

  • 设置我们的开发环境

  • 使用 vue-cli 搭建 Vue 应用程序

  • 编写和使用单文件组件

设置我们的开发环境

为了创建更复杂的单页应用程序,建议使用一些工具来简化开发。在本节中,我们将安装它们以准备好良好的开发环境。您需要在计算机上安装 Node.js 和 npm。确保您至少拥有 Node 8.x,但建议使用最新的 Node 版本。

安装 vue-cli,官方命令行工具

我们首先需要的包是 vue-cli,这是一个命令行工具,将帮助我们创建 Vue 应用程序:

  1. 在终端中输入此命令,它将安装 vue-cli 并将其保存为全局包:
 npm install -g vue-cli

您可能需要以管理员身份运行此命令。

  1. 要测试 vue-cli 是否正常工作,请使用以下命令打印其版本:
 vue --version

代码编辑器

任何文本编辑器都可以,但我建议使用 Visual Studio Code(code.visualstudio.com/)或 Atom(atom.io/)。对于 Visual Studio Code,您需要来自 octref 的vetur扩展(github.com/vuejs/vetur),对于 Atom,您需要来自 hedefalk 的language-vue扩展(atom.io/packages/language-vue)。

最近版本的 Jetbrains 的 WebStorm IDE 支持 Vue。

您还可以安装添加对预处理器语言(如 Sass、Less 或 Stylus)的支持的扩展。

我们的第一个完整的 Vue 应用程序

之前的应用程序都是以相当老式的方式制作的,使用script标签和简单的 JavaScript。在本节中,我们将发现使用一些强大功能和工具创建 Vue 应用程序的新方法。在这部分中,我们将创建一个迷你项目来演示我们将在接下来使用的新工具。

搭建项目脚手架

vue-cli 工具使我们能够创建即用型的应用程序框架,以帮助我们开始新项目。它使用一个项目模板系统,可以向您询问一些问题,以自定义框架以满足您的需求:

  1. 用以下命令列出官方项目模板:
 vue list

以下是终端中显示的列表:

官方模板有三种主要类型:

  • simple:不使用构建工具

  • webpack:使用非常流行的 webpack 打包工具(推荐)

  • browserify:使用 browserify 构建工具

推荐的官方模板是webpack模板。它包含了创建一个具有 Vue 的全尺寸 SPA 所需的一切。为了本书的目的,我们将使用webpack-simple并逐步介绍功能。

要使用这些模板之一创建一个新的应用程序项目,请使用npm init命令:

vue init <template> <dir>

我们将在一个新的demo文件夹中使用webpack-simple官方模板:

  1. 运行以下命令:
 vue init webpack-simple demo

这个项目模板具有一个准备好使用的最小 webpack 配置。该命令将会询问一些问题。

  1. 像这样回答 vue-cli 的问题:
      ? Project name demo
      ? Project description Trying out Vue.js!
      ? Author Your Name <your-mail@mail.com>
      ? License MIT
      ? Use sass? No

Vue-cli 现在应该已经创建了一个demo文件夹。它有一个package.json文件和其他配置文件已经为我们填充。package.json文件非常重要;它包含有关项目的主要信息;例如,它列出了项目所依赖的所有软件包。

  1. 转到新创建的demo文件夹,并安装webpack-simple模板已经在package.json文件中声明的默认依赖项(如 vue 和 webpack):
 cd demo
 npm install

我们的应用现在已经设置好了!

从现在开始,我们将完全使用 ECMAScript 2015 语法和import/export关键字来使用或公开模块(这意味着导出 JavaScript 元素的文件)。

创建应用程序

任何 Vue 应用程序都需要一个 JavaScript 入口文件,代码将从那里开始:

  1. 删除src文件夹的内容。

  2. 创建一个名为main.js的新的 JavaScript 文件,内容如下:

      import Vue from 'vue'

      new Vue({
        el: '#app',
        render: h => h('div', 'hello world'),
      })

首先,我们将 Vue 核心库导入文件中。然后,我们创建一个新的根 Vue 实例,将其附加到页面中id为 app 的元素上。

vue-cli 提供了一个默认的index.html文件,其中包含一个空的<div id="app"></div>标签。您可以编辑它以更改页面 HTML 以满足您的喜好。

最后,我们通过render选项显示包含'hello world'文本的div元素,这要归功于我们将在“渲染函数”部分介绍的render选项。

运行我们的应用程序

运行由 vue-cli 生成的dev npm 脚本以在开发模式下启动应用程序:

npm run dev

这将在 web 服务器端口上启动一个 web 应用程序。终端应该显示编译成功以及访问应用程序的 URL:

在浏览器中打开此 URL 以查看结果:

渲染函数

Vue 使用虚拟 DOM 实现,由 JavaScript 对象组成的元素树。然后通过计算两者之间的差异,将虚拟 DOM 应用于真实的浏览器 DOM。这有助于尽量避免 DOM 操作,因为它们通常是主要的性能瓶颈。

实际上,当您使用模板时,Vue 会将其编译为渲染函数。如果您需要 JavaScript 的全部功能和灵活性,可以直接编写渲染函数,或编写稍后将讨论的 JSX。

渲染函数返回树的一小部分,该部分特定于其组件。它使用createElement方法作为第一个参数。

按照惯例,hcreateElement的别名,这是非常常见的,也是编写 JSX 所需的。它来自于用 JavaScript 描述 HTML 的技术名称--Hyperscript。

createElement(或h)方法最多接受三个参数:

  1. 第一个是元素的类型。它可以是 HTML 标签名(如'div'),在应用程序中注册的组件名称,或直接是组件定义对象。

  2. 第二个参数是可选的。它是定义属性、props、事件监听器等的数据对象。

  3. 第三个参数也是可选的。它可以是简单的纯文本,也可以是用h创建的其他元素数组。

考虑以下render函数作为示例:

render (h) {
  return h('ul', { 'class': 'movies' }, [
    h('li', { 'class': 'movie' }, 'Star Wars'),
    h('li', { 'class': 'movie' }, 'Blade Runner'),
  ])
}

它将在浏览器中输出以下 DOM:

<ul class="movies">
  <li class="movie">Star Wars</li>
  <li class="movie">Blade Runner</li>
</ul>

我们将在第六章中更详细地介绍渲染函数,项目 4-地理定位博客

配置 babel

Babel 是一个非常流行的工具,它编译 JavaScript 代码,以便我们可以在较旧和当前的浏览器中使用新功能(如 JSX 或箭头函数)。建议在任何严肃的 JavaScript 项目中使用 babel。

默认情况下,webpack-simple模板带有默认的 babel 配置,使用支持 ES2015 中所有稳定 JavaScript 版本的env babel 预设。它还包括另一个名为stage-3的 babel 预设,支持即将到来的 JavaScript 特性,如async/await关键字和 Vue 社区常用的对象扩展运算符。

我们需要添加第三个特定于 Vue 的预设,它将为 JSX 添加支持(我们将在本章后面的“JSX”部分中需要它)。

我们还需要包括 babel 提供的 polyfills,以便在旧的浏览器中使用新功能,比如Promise和生成器。

Polyfill 是一种代码,用于检查浏览器中是否有某个功能,如果没有,则实现此功能,使其像本地功能一样工作。

Babel Vue 预设

我们现在将在应用程序的 Babel 配置中安装和使用babel-preset-vue

  1. 首先,我们需要在开发依赖项中安装这个新的预设:
 npm i -D babel-preset-vue

主要的 babel 配置已经在项目根目录中的.babelrc JSON 文件中完成。

这个文件可能在您的文件资源管理器中被隐藏,这取决于系统(它的名称以点开头)。但是,如果它有文件树视图,它应该在您的代码编辑器中可见。

  1. 打开这个.babelrc文件,并将vue预设添加到相应的列表中:
      {
        "presets": [
          ["env", { "modules": false }],
          "stage-3",
          "vue"
        ]
      }

Polyfills

让我们还添加 Babel polyfills,以在旧的浏览器中使用新的 JavaScript 功能。

  1. 在开发依赖项中安装babel-polyfill软件包:
 npm i -D babel-polyfill
  1. src/main.js文件的开头导入它:
 import  'babel-polyfill'

这将为浏览器启用所有必要的 polyfills。

更新依赖项

项目脚手架完成后,您可能需要更新它使用的软件包。

手动更新

要检查项目中使用的软件包是否有新版本可用,请在根文件夹中运行此命令:

npm outdated

如果检测到新版本,将显示一个表格:

Wanted列是与package.json文件中指定的版本范围兼容的版本号。要了解更多信息,请访问 npm 文档http s://docs.npmjs.com/getting-started/semantic-versioning

要手动更新软件包,请打开package.json文件并找到相应的行。更改版本范围并保存文件。然后,运行此命令以应用更改:

npm install

不要忘记阅读您更新的软件包的更改日志!可能会有重大变化或改进,您会很乐意了解。

自动更新

要自动更新软件包,请在项目的根文件夹中使用此命令:

npm update

这个命令只会更新与package.json文件中指定版本兼容的版本。如果您想要更新包到其他版本,您需要手动进行。

更新 Vue

当您更新包含核心库的vue包时,您也应该更新vue-template-compiler包。这是一个在使用 webpack(或其他构建工具)时编译所有组件模板的包。

这两个包必须始终处于相同的版本。例如,如果您使用vue 2.5.3,那么vue-template-compiler也应该是版本2.5.3

为生产构建

当您需要将您的应用程序部署到真实服务器上时,您需要运行这个命令来编译您的项目:

npm run build

默认情况下,使用webpack-simple模板时,它会将 JavaScript 文件输出到项目的/dist文件夹中。您只需要上传这个文件夹和根文件夹中存在的index.html文件。您的服务器上应该有以下文件树:

- index.html
- favicon.png
- [dist] - build.js
         ∟ build.map.js

单文件组件

在这一部分,我们将介绍一个在创建真实生产 Vue 应用程序中广泛使用的重要格式。

Vue 有自己的格式称为单文件组件SFC)。这个格式是由 Vue 团队创建的,文件扩展名是.vue。它允许您在一个地方编写一个文件的模板、逻辑和样式。这个地方的主要优势是每个组件都是清晰自包含的,更易维护和共享。

SFC 使用类似 HTML 的语法描述了一个 Vue 组件。它可以包含三种类型的根块:

  • <template>,描述了组件的模板,使用了我们已经使用过的模板语法

  • <script>,其中包含组件的 JavaScript 代码

  • <style>,其中包含组件使用的样式

以下是一个 SFC 的示例:

<template>
  <div>
    <p>{{ message }}</p>
    <input v-model="message"/>
  </div>
</template>

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

<style>
p {
  color: grey;
}
</style>

现在让我们尝试这个组件!

  1. 将上述组件源代码放入src文件夹中的新Test.vue文件中。

  2. 编辑main.js文件,并使用import关键字导入 SFC:

      import Test from './Test.vue'
  1. 删除render选项,而是使用对象扩展运算符复制Test组件的定义:
      new Vue({
        el: '#app',
        ...Test,
      })

在前面的片段中,我演示了将根组件添加到应用程序的另一种方法--使用 JavaScript Spread 运算符--因此...App表达式将将属性复制到应用程序定义对象。主要优点是我们将不再在开发工具中有一个无用的顶级组件;它现在将是我们的直接根组件。

  1. 继续打开终端中显示的 URL 以查看结果:

Template

<template>标签包含组件的模板。与之前一样,它是 HTML,具有 Vue 特殊语法(指令,文本插值,快捷方式等)。

以下是 SFC 中<template>标签的示例:

<template>
  <ul class="movies">
    <li v-for="movie of movies" class="movie">
      {{ movie.title }}
    </li>
  </ul>
</template>

在此示例中,我们组件的模板将包含一个包含显示电影标题的li元素列表的ul元素。

如果在 SFC 中不放置<template>标签,您将需要编写一个渲染函数,否则您的组件将无效。

使用 Pug

Pug(以前叫 Jade)是一种编译成 HTML 的语言。我们可以在<template>标签中使用它,lang属性设置为“pug”:

<template lang="pug">
ul.movies
  li.movie Star Wars
  li.movie Blade Runner
</template>

要编译 SFC 中的 Pug 代码,我们需要安装这些包:

npm install --save-dev pug pug-loader

开发所需的包称为开发依赖项,并应使用--save-dev标志安装。应使用--save标志安装应用程序运行所需的直接依赖项(例如,将 markdown 编译为 HTML 的包)。

Script

<script>标签包含与组件关联的 JavaScript 代码。它应该导出组件定义对象。

以下是<script>标签的示例:

<script>
export default {
  data () {
    return {
      movies: [
        { title: 'Star Wars' },
        { title: 'Blade Runner' },
      ],
    }
  },
}
</script>

在此示例中,组件将具有返回具有movies数组的初始状态的data钩子。

如果您不需要组件选项中的任何选项,默认为空对象,则<script>标签是可选的。

JSX

JSX 是在 JavaScript 代码中使用的一种特殊表示法,用于表示 HTML 标记。它使代码负责描述视图的方式更接近纯 HTML 语法,同时仍然具有 JavaScript 的全部功能。

以下是使用 JSX 编写的渲染函数示例:

<script>
export default {
  data () {
    return {
      movies: [
        { title: 'Star Wars' },
        { title: 'Blade Runner' },
      ],
    }
  },
  render (h) {
    const itemClass = 'movie'
    return <ul class='movies'>
      {this.movies.map(movie =>
        <li class={ itemClass }>{ movie.title }</li>
      )}
    </ul>
  },
}
</script>

您可以在单括号内使用任何 JavaScript 表达式。

正如您在此示例中所看到的,我们可以使用任何 JavaScript 代码来组成我们的视图。我们甚至可以使用movies数组的map方法为每个项目返回一些 JSX。我们还使用了一个变量来动态设置电影元素的 CSS 类。

在编译过程中,真正发生的是一个名为babel-plugin-transform-vue-jsx的特殊模块包含在babel-preset-vue中,将 JSX 代码转换为纯 JavaScript 代码。编译后,前面的渲染函数将如下所示:

render (h) {
  const itemClass = 'movie'
  return h('ul', { class: 'movies' },
    this.movies.map(movie =>
      h('li', { class: itemClass }, movie.title)
    )
  )
},

如您所见,JSX 是一种帮助编写渲染函数的语法。最终的 JavaScript 代码将与我们手动使用h(或createElement)编写的代码非常接近。

我们将在第六章中更详细地介绍渲染函数,项目 4 - 地理定位博客

样式

单文件组件可以包含多个<style>标签,以向应用程序添加与此组件相关的 CSS。

以下是一个非常简单的组件样式应用一些 CSS 规则到.movies类:

<style>
.movies {
  list-style: none;
  padding: 12px;
  background: rgba(0, 0, 0, .1);
  border-radius: 3px;
}
</style>

作用域样式

我们可以使用作用域属性将包含在<style>标签中的 CSS 限定为当前组件。这意味着此 CSS 仅应用于此组件模板的元素。

例如,我们可以使用通用的类名,如 movie,并确保它不会与应用程序的其余部分发生冲突:

<style scoped>
.movie:not(:last-child) {
  padding-bottom: 6px;
  margin-bottom: 6px;
  border-bottom: solid 1px rgba(0, 0, 0, .1);
}
</style>

结果将如下所示:

这是有效的,多亏了应用于模板和 CSS 的特殊属性,使用 PostCSS(一种处理工具)。例如,考虑以下作用域样式组件:

<template>
  <h1 class="title">Hello</h1>
</template>

<style scoped>
.title {
  color: blue;
}
</style>

它相当于以下内容:

<template>
  <h1 class="title" data-v-02ad4e58>Hello</h1>
</template>

<style>
.title[data-v-02ad4e58] {
  color: blue;
}
</style>

如您所见,为所有模板元素和所有 CSS 选择器添加了一个唯一的属性,以便它只匹配此组件的模板,不会与其他组件冲突。

作用域样式并不能消除对类的需求;由于浏览器呈现 CSS 的方式,当选择一个带有属性的普通元素时,可能会出现性能损失。例如,li { color: blue; }在组件范围内将比.movie { color: blue; }慢得多。

添加预处理器

现在,CSS 很少被直接使用。通常会使用功能更强大、功能更丰富的预处理器语言来编写样式。

<style>标签上,我们可以使用lang属性指定其中一种语言。

我们将以此模板作为我们组件的基础:

<template>
  <article class="article">
    <h3 class="title">Title</h3>
  </article>
</template>

Sass

Sass 是许多技术公司使用的知名 CSS 预处理器:

  1. 要在组件中启用 Sass,请安装以下软件包:
 npm install --save-dev node-sass sass-loader
  1. 然后,在您的组件中,添加一个<style>标签,其中lang属性设置为"sass"
      <style lang="sass" scoped>
      .article
        .title
          border-bottom: solid 3px rgba(red, .2)
      </style>
  1. 现在,使用vue build命令测试您的组件。您应该有一个类似于这样的结果:

如果您想使用 Sass 的 SCSS 语法变体,您需要使用lang="scss"

Less

Less 的语法比其他 CSS 预处理语言更简单:

  1. 要使用 Less,您需要安装以下包:
 npm install --save-dev less less-loader
  1. 然后,在您的组件中,将lang属性设置为"less"
      <style lang="less" scoped>
      .article {
        .title {
          border-bottom: solid 3px fade(red, 20%);
        }
      }
      </style>

Stylus

Stylus 比 Less 和 Sass 更新,也非常受欢迎:

  1. 最后,对于 Stylus,您需要这些包:
 npm install --save-dev stylus stylus-loader
  1. <style>标签上,将lang属性设置为"stylus"
      <style lang="stylus" scoped>
      .article
        .title
          border-bottom solid 3px rgba(red, .2)
      </style>

组件内部的组件

现在我们知道如何编写单文件组件,我们希望在其他组件中使用它们来组成应用程序的界面。

要在另一个组件中使用组件,我们需要导入它并将其公开给模板:

  1. 首先,创建一个新的组件。例如,这是一个Movie.vue组件:
      <template>
        <li class="movie">
          {{ movie.title }}
        </li>
      </template>

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

      <style scoped>
      .movie:not(:last-child) {
        padding-bottom: 6px;
        margin-bottom: 6px;
        border-bottom: solid 1px rgba(0, 0, 0, .1);
      }
      </style>

如果您还没有创建Movies.vue组件,我们还需要一个。它应该是这样的:

<template>
  <ul class="movies">
    <li v-for="movie of movies" class="movie">
      {{ movie.title }}
    </li>
  </ul>
</template>

<script>
export default {
  data () {
    return {
      movies: [
        { id: 0, title: 'Star Wars' },
        { id: 1, title: 'Blade Runner' },
      ],
    }
  },
}
</script>
  1. 然后,在Movies组件的脚本中导入Movie SFC:
      <script>
      import Movie from './Movie.vue'

      export default {
        // ...
      }
      </script>
  1. components选项设置为向模板公开一些组件,使用一个对象(键是我们将在模板中使用的名称,值是组件定义):
      export default {
        components: {
          Movie,
          // Equivalent to `Movie: Movie,`
        },

        // ...
      }
  1. 我们现在可以在模板中使用Movie标签使用组件:
      <template>
        <ul class="movies">
          <Movie v-for="movie of movies"
            :key="movie.id"
            :movie="movie" />
        </ul>
      </template>

如果您使用 JSX,则不需要components选项,因为如果以大写字母开头,可以直接使用组件定义:

import Movies from './Movies.vue'

export default {
  render (h) {
    return <Movies/>
    // no need to register Movies via components option
  }
}

总结

在本章中,我们安装了几个工具,这些工具将允许我们使用推荐的方法编写真正的生产就绪应用程序。现在,我们可以搭建整个项目骨架来开始构建出色的新应用程序。我们可以以各种方式编写组件,但是我们可以使用单文件组件以一致和可维护的方式进行编写。我们可以在我们的应用程序内部或在其他组件内部使用这些组件来组成我们的用户界面,其中包含多个可重用组件。

在下一章中,我们将使用我们到目前为止学到的所有知识创建我们的第三个应用程序,还有一些新的主题,比如路由!

第五章:项目 3 - 支持中心

在本章中,我们将构建一个更复杂的应用程序,具有路由系统(这意味着多个虚拟页面)。这将是一个名为“我的衬衫店”的虚构公司的支持中心。它将有两个主要部分:

  • 一个包含一些问题和答案的常见问题页面

  • 一个支持工单管理页面,用户将能够显示和创建新工单

该应用程序将具有身份验证系统,允许用户创建帐户或登录。

我们将首先创建一些基本路由,然后将集成此帐户系统,最后涉及更高级的路由主题。在整个章节中,我们将尽可能重用我们的代码并应用最佳实践。

一般应用程序结构

在第一部分中,我们将创建项目结构并了解更多关于路由和页面的知识。

设置项目

为了设置项目,需要按照以下步骤进行操作:

  1. 首先,使用 vue init webpack-simple <folder> 命令生成一个 Vue 项目,就像我们在第四章中所做的那样,高级项目设置
 vue init webpack-simple support-center
 cd support-center
 npm install
      npm install --save babel-polyfill
  1. 安装编译 Stylus 代码所需的软件包(我们的样式将使用 Stylus 编写):
  • stylus

  • stylus-loader

 npm install --save-dev stylus stylus-loader

不要忘记使用 --save-dev 标志将开发工具包保存在 package.json 文件的开发依赖项中。

  1. 删除 src 文件夹的内容,我们将把所有应用程序源代码放在其中。

  2. 然后创建一个 main.js 文件,其中包含创建 Vue 应用程序所需的代码:

      import 'babel-polyfill'
      import Vue from 'vue'

      new Vue({
        el: '#app',
        render: h => h('div', 'Support center'),
      })

现在可以使用 npm run dev 命令运行应用程序了!

  1. 大多数应用程序的样式已经可用。下载它(github.com/Akryum/packt-vue-project-guide/tree/master/chapter5-download) 并将 Stylus 文件提取到 src 目录内的 style 文件夹中。也提取 assets 文件夹。

路由和页面

我们的应用程序将组织在六个主要页面中:

  • 主页

  • 公共常见问题页面

  • 登录页面

  • 工单页面

  • 发送新工单的页面

  • 显示一个工单详情和对话的页面

路由是表示应用程序状态的路径,通常以页面的形式存在。每个路由都与一个 URL 模式相关联,当地址匹配时将触发路由。然后,相应的页面将呈现给用户。

Vue 插件

为了在我们的应用程序中启用路由,我们需要一个名为vue-router的官方 Vue 插件。Vue 插件是一些设计用来为 Vue 库添加更多功能的 JavaScript 代码。您可以在 npm 注册表上找到许多插件,我推荐使用 awesome-vue GitHub 存储库(github.com/vuejs/awesome-vue)按类别列出它们:

  1. 在项目目录中使用以下命令从 npm 下载vue-router包:
 npm install --save vue-router

我们将把所有与路由相关的代码放在main.js文件旁边的新router.js文件中,您需要创建该文件。然后,我们需要使用全局的Vue.use()方法安装我们想要使用的插件(在我们的情况下是vue-router)。

  1. 创建router.js文件,并从它们对应的包中导入Vue库和VueRouter插件:
      import Vue from 'vue'
      import VueRouter from 'vue-router'
  1. 然后将插件安装到 Vue 中:
 Vue.use(VueRouter)

vue-router插件现在已准备好使用!

我们的第一个使用 vue-router 的路由

在本节中,我们将介绍在 Vue 应用程序中设置路由所需的步骤。

使用 router-view 的布局

在添加路由之前,我们需要为应用程序设置一个布局,路由组件将在其中呈现。

  1. 让我们在src目录内的新components文件夹中创建一个名为AppLayout.vue的组件。

  2. 编写组件的模板--包含一个带有图像和一些文本的<header><div>元素。然后,在标题后添加一个<router-view />组件:

      <template>
        <div class="app-layout">
          <header class="header">
            <div><img class="img"
              src="../assets/logo.svg"/></div>
            <div>My shirt shop</div>
          </header>

          <!-- Menu will be here -->

          <router-view />
        </div>
      </template>

<router-view />组件是由vue-router插件提供的特殊组件,它将呈现当前匹配路由的组件。它不是一个真正的组件,因为它没有自己的模板,并且不会出现在 DOM 中。

  1. 在模板之后,添加一个style标签,从设置项目部分中下载的styles文件夹中导入主 Stylus 文件。不要忘记使用lang属性指定我们正在使用stylus
      <style lang="stylus">
      @import '../style/main';
      </style>
  1. 由于我们可以在 SFC 中拥有尽可能多的style标签,因此再添加一个,但这次是有作用域的。我们将在第二个样式部分中指定header标志的大小:
      <style lang="stylus" scoped>
      .header {
        .img {
          width: 64px;
          height: 64px;
        }
      }
      </style>

为了提高性能,建议在有作用域的样式中使用类。

我们的布局组件已准备好包含在我们的应用程序中!

  1. main.js文件中导入它并在rootVue 实例上呈现它:
      import AppLayout from './components/AppLayout.vue'

      new Vue({
        el: '#app',
        render: h => h(AppLayout),
      })

我们还不能启动应用程序,因为我们的路由还没有完成!

如果你查看浏览器的控制台,你可能会看到一个错误消息,抱怨<router-view />组件丢失了。这是因为我们没有导入router.js文件,我们在其中将vue-router插件安装到 Vue 中,所以代码还没有包含在我们的应用程序中。

创建路由

让我们为测试路由创建一些愚蠢的页面:

  1. components文件夹中,创建一个Home.vue组件,其中包含一个非常简单的模板,包括一个<main>元素,一个标题和一些文本:
      <template>
        <main class="home">
          <h1>Welcome to our support center</h1>
          <p>
            We are here to help! Please read the <a>F.A.Q</a> first,
            and if you don't find the answer to your question, <a>send                  
            us a ticket!</a>
          </p>
        </main>
      </template>
  1. 然后,在Home.vue旁边创建一个FAQ.vue组件。它也应该包含一个<main>元素,其中你可以添加一个简单的标题:
      <template>
        <main class="faq">
          <h1>Frenquently Asked Questions</h1>
        </main>
      </template>

现在我们有了创建一些路由所需的东西。

  1. router.js文件中,导入我们刚刚创建的两个组件:
      import Home from './components/Home.vue'
      import FAQ from './components/FAQ.vue'
  1. 然后,创建一个routes数组:
      const routes = [
        // Routes will be here
      ]

路由是一个包含路径、名称和要渲染的组件的对象:

{ path: '/some/path', name: 'my-route', component: ... }

路径是当前 URL 应该匹配的模式,以激活路由。组件将呈现在特殊的<router-view />组件中。

路由名称是可选的,但我强烈建议使用它。它允许您指定路由的名称而不是路径,这样您就可以移动和更改路由而不会出现断开的链接。

  1. 考虑到这一点,我们现在可以在routes数组中添加我们的两个路由:
      const routes = [
        { path: '/', name: 'home', component: Home },
        { path: '/faq', name: 'faq', component: FAQ },
      ]

让我们来回顾一下它将会做什么:

  • 当浏览器 URL 为http://localhost:4000/时,将呈现Home.vue组件

  • 当 URL 为http://localhost:4000/faq/时,将显示FAQ.vue组件

路由对象

有了我们准备好的路由,我们需要创建一个router对象来负责管理路由。我们将使用vue-router包中的VueRouter构造函数。它接受一个options参数,现在,我们将使用routes参数:

  1. router.js文件中的routes数组之后,创建一个新的router对象并指定routes参数:
      const router = new VueRouter({
        routes,
      })

我们安装的插件也是路由构造函数,所以我们使用相同的VueRouter变量。VueRouter实际上是一个有效的 Vue 插件,因为它有一个install方法。在本章中,我们将创建自己的插件!

  1. router对象导出为模块的默认导出值:
 export default router
  1. 现在回到我们的main.js文件,我们需要将router对象提供给 Vue 应用程序。导入我们刚刚创建的router
      import router from './router'
  1. 然后将其作为根 Vue 实例的定义选项添加:
      new Vue({
        el: '#app',
        render: h => h(AppLayout),
        // Provide the router to the app
        router,
      })

这就是我们让路由工作所需的全部!现在你可以尝试在浏览器中更改 URL 为http://localhost:4000/#/http://localhost:4000/#/faq,每次都会得到不同的页面:

不要忘记 URL 中的尖号#字符;在不更改真正的网页的情况下,需要伪造路由更改。这是称为hash的默认路由器模式,并且可以与任何浏览器和服务器一起使用。

路由器模式

我们可以通过构造函数选项中的mode参数来更改路由器模式。它可以是'hash'(默认值)、'history''abstract'

hash模式是我们已经在使用的默认模式。这是“最安全”的选择,因为它与任何浏览器和服务器兼容。它包括使用 URL 的hash部分(也就是尖号后面的部分)并更改它或对其进行反应。最大的优势是更改哈希部分不会更改我们的应用程序正在运行的真正网页(这将是非常不幸的)。显而易见的缺点是它强迫我们用不太漂亮的尖号符号将 URL 分成两部分。

由于 HTML5 的history.pushState API,我们可以摆脱这个尖锐字符,并为我们的应用程序获得一个真正的 URL!我们需要在构造函数中将模式更改为'history'

const router = new VueRouter({
  routes,
  mode: 'history',
})

现在我们可以在我们的单页面应用程序中使用漂亮的 URL,比如http://localhost:4000/faq!不过有两个问题:

  • 浏览器需要支持这个 HTML5 API,这意味着它在 Internet Explorer 9 或更低版本上无法工作(其他主要浏览器已经支持了相当长的时间)。

  • 服务器必须配置为在访问/faq这样的路由时发送主页,而不是抛出 404 错误,因为它实际上并不存在(你没有一个名为faq.html的文件)。这也意味着我们将不得不自己实现 404 页面。

值得庆幸的是,由vue build使用的 webpack 服务器默认配置为支持这一点。所以你可以继续尝试新的http://localhost:4000/faq URL!

有第三种模式称为“抽象”,可以在任何 JavaScript 环境中使用(包括 Node.js)。如果没有浏览器 API 可用,路由将被迫使用此模式。

创建导航菜单

与手动输入 URL 相比,在我们的应用程序中拥有一个合适的导航菜单将会很棒!让我们在我们的components文件夹中创建一个新的NavMenu.vue文件:

<template>
  <nav class="menu">
    <!-- Links here -->
  </nav>
</template>

接下来,我们将在布局中添加它。在AppLayout中导入新组件:

<script>
import NavMenu from './NavMenu.vue'
export default {
  components: {
    NavMenu,
  },
}
</script>

然后将其添加到AppLayout模板中:

<header class="header">
  <div><img class="img" src="../assets/logo.svg"/></div>
  <div>My shirt shop</div>
</header>

<NavMenu />

路由链接

vue-router插件为我们提供了另一个方便的特殊组件--<router-link>。这是一个组件,当单击时将切换到指定的路由,这要归功于它的to属性。默认情况下,它将是一个<a> HTML 元素,但可以使用tag属性进行自定义。

例如,指向 FAQ 页面的链接将是:

<router-link to="/faq">FAQ</router-link>

to属性也可以获得一个具有名称属性而不是路径的对象:

<router-link :to="{ name: 'faq' }">FAQ</router-link>

这将动态生成路由的正确路径。我建议您使用这种第二种方法,而不是仅指定路径--这样,如果您更改路由的路径,您的导航链接仍将起作用。

在使用对象表示法时,不要忘记使用v-bind:简写将to属性绑定到router-link组件,否则router-link组件将获得一个字符串,并且不会理解它是一个对象。

现在我们可以将链接添加到我们的NavMenu组件中:

<template>
  <nav class="menu">
    <router-link :to="{ name: 'home' }">Home</router-link>
    <router-link :to="{ name: 'faq' }">FAQ</router-link>
  </nav>
</template>

现在您应该在应用程序中有一个可用的菜单:

活动类

当与其关联的路由当前处于活动状态时,路由链接将获得活动类。默认情况下,组件会获得router-link-active CSS 类,因此您可以相应地更改其外观:

  1. 在我们的NavMenu.vue组件中,声明一些作用域样式,使用 Stylus 为活动链接添加底部边框:
      <style lang="stylus" scoped>
      @import '../style/imports';

      .router-link-active {
        border-bottom-color: $primary-color;
      }
      </style>

我们在@import '../style/imports';语句中包含了$primary-color变量,该语句导入了包含 Stylus 变量的imports.styl文件。

如果您现在尝试该应用程序,您会发现我们的菜单出现了一些奇怪的情况。如果您转到主页,它会按预期工作:

但当您转到 FAQ 页面时,主页和 FAQ 链接都会被突出显示:

这是因为默认情况下,活动类匹配行为是包容的!这意味着<router-link to="/faq">如果路径是/faq或以/faq/开头,将获得活动类。但这也意味着<router-link to="/">如果当前路径以/开头,将获得该类,这些都是可能的路径!这就是为什么我们的主页链接总是会获得该类。

为了防止这种情况发生,有一个exact属性,它是一个布尔值。如果设置为true,则只有在当前路径完全匹配时,链接才会获得活动类。

  1. exact属性添加到主页链接:
      <router-link :to="{ name: 'home' }" exact>Home</router-link>

现在,只有 FAQ 链接应该被突出显示:

FAQ - 使用 API

在本节中,我们将创建 FAQ 页面,该页面将从服务器获取数据。它将显示加载动画,然后显示问题和答案列表。

服务器设置

这是我们的第一个与服务器通信的应用程序。您将获得一个带有可用 API 的服务器。

您可以下载服务器文件(github.com/Akryum/packt-vue-project-guide/tree/master/chapter5-download)。将它们解压到与我们的应用程序不同的文件夹中,并运行以下命令来安装依赖项并启动服务器:

cd server_folder
npm install
npm start

现在您应该在端口 3000 上运行服务器。完成后,我们可以继续构建我们的应用程序,这次使用真正的后端!

使用 fetch

FAQ.vue单文件组件中,我们将使用 Web 浏览器的标准fetch API 从服务器检索问题。请求将是一个非常简单的GET请求到http://localhost:3000/questions,不需要身份验证。每个问题对象将有titlecontent字段:

  1. 打开FAQ.vue,并首先在组件脚本中添加questions数据属性,该属性将保存从服务器检索的问题数组。我们还需要一个error属性,在网络请求期间出现问题时显示消息:
      <script>
      export default {
        data () {
          return {
            questions: [],
            error: null,
          }
        },
      }
      </script>
  1. 现在我们可以使用v-for循环将问题和答案添加到模板中,并显示以下错误消息:
      <template>
        <main class="faq">
          <h1>Frequently Asked Questions</h1>

          <div class="error" v-if="error">
            Can't load the questions
          </div>

          <section class="list">
            <article v-for="question of questions">
              <h2 v-html="question.title"></h2>
              <p v-html="question.content"></p>
            </article>
          </section>
        </main>
      </template>

我们准备好进行获取了!fetch API 是基于 promise 的,非常简单易用。以下是fetch用法示例:

fetch(url).then(response => {
  if (response.ok) {
    // Returns a new promise
    return response.json()
  } else {
    return Promise.reject('error')
  }
}).then(result => {
  // Success
  console.log('JSON:', result)
}).catch(e => {
  // Failure
  console.error(e)
})

我们首先使用请求的 URL 作为第一个参数调用fetch。它返回一个带有response对象的 promise,该对象保存有关请求结果的信息。如果成功,我们使用response.json(),它返回一个解析后的 JSON 结果对象的新 promise。

请求将在组件内部进行,一旦创建时路由匹配,这意味着您应该在组件定义中使用created生命周期钩子:

data () {
  // ...
},
created () {
  // fetch here
},

如果一切顺利,我们将使用 JSON 解析后的结果设置问题属性。否则,我们将显示错误消息。

  1. 从正确的 URL 调用fetch开始:
 created () {
        fetch('http://localhost:3000/questions')
      },
  1. 添加第一个then回调与response对象:
      fetch('http://localhost:3000/questions').then(response => {
        if (response.ok) {
          return response.json()
        } else {
          return Promise.reject('error')
        }
      })
  1. 由于response.json()返回一个新的 promise,我们需要另一个then回调:
        // ...
      }).then(result => {
        // Result is the JSON parsed object from the server
        this.questions = result
      })
  1. 最后,我们捕获所有可能的错误以显示错误消息:
        // ...
      }).catch(e => {
        this.error = e
      })

以下是我们created钩子的摘要:

created () {
  fetch('http://localhost:3000/questions').then(response => {
    if (response.ok) {
      return response.json()
    } else {
      return Promise.reject('error')
    }
  }).then(result => {
    this.questions = result
  }).catch(e => {
    this.error = e
  })
},

我们可以使用asyncawait JavaScript 关键字重写此代码,使其看起来像顺序代码:

async created () {
  try {
    const response = await fetch('http://localhost:3000/questions')
    if (response.ok) {
      this.questions = await response.json()
    } else {
      throw new Error('error')
    }
  } catch (e) {
    this.error = e
  }
},

现在您可以尝试该页面,该页面应该显示一个问题和答案的列表:

要查看我们的错误管理是否有效,您可以转到运行服务器的控制台,并停止它(例如,使用 Ctrl+C键盘快捷键)。然后,您可以重新加载应用程序,应该显示以下错误消息:

加载动画

还有一件事情遗漏了--我们应该显示一个加载动画来通知用户操作正在进行中,而不是一个空屏幕。为此,服务器在/questions请求上伪造了 1.5 秒的延迟,这样我们就可以轻松看到加载动画。

由于我们将在多个组件中显示加载动画,我们将创建一个新的全局组件:

  1. components文件夹中,创建一个名为Loading.vue的新文件,内容如下模板:
      <template>
        <div class="loading">
          <div></div>
        </div>
      </template>
  1. main文件夹中的main.js文件旁边创建一个新的global-components.js文件。在这个文件中,我们将使用Vue.component()方法全局注册Loading组件:
      import Vue from 'vue'
      import Loading from './components/Loading.vue'

      Vue.component('Loading', Loading)

这是我们将注册所有应用程序中使用的全局组件的文件。

  1. 然后,在main.js文件中,导入global-components.js模块:
      import './global-components'
  1. 回到我们的FAQ.vue组件,我们需要一个新的loading布尔数据属性来切换动画的显示:
      data () {
        return {
          questions: [],
          error: null,
          loading: false,
        }
      },
  1. 在模板中添加加载动画:
      <Loading v-if="loading" />
  1. 最后,通过在created钩子中将loading设置为true开头,当一切都完成时设置为false,稍微改变created钩子:
      async created () {
        this.loading = true
        try {
          const response = await                             
       fetch('http://localhost:3000/questions')
          // ...
        } catch (e) {
          this.error = e
        }
        this.loading = false
      }

现在您可以重新加载页面,在问题出现之前短暂地看到加载动画:

使用我们自己的插件扩展 Vue

由于我们将在应用程序的多个组件中使用 fetch,并且我们希望尽可能多地重用代码,因此最好在所有组件上都有一个向服务器发出预定义 URL 请求的方法。

这是一个很好的自定义 Vue 插件使用案例!别担心,编写插件实际上非常简单。

创建插件

要创建一个插件,只有一个规则--插件应该是一个带有install方法的对象,该方法以 Vue 构造函数作为第一个参数,并且可选的options参数。然后,该方法将通过修改构造函数来为框架添加新功能:

  1. src文件夹中创建一个新的plugins文件夹。

  2. plugins文件夹中,创建一个fetch.js文件,我们将在这里编写我们的插件。在这种情况下,我们的插件将在所有组件上添加一个新的$fetch特殊方法。我们将通过改变 Vue 的原型来实现这一点。

  3. 让我们尝试创建一个非常简单的插件,通过导出一个带有install方法的对象:

      export default {
        install (Vue) {
          console.log('Installed!')
        }
      }

就是这样!我们已经创建了一个 Vue 插件!现在,我们需要将其安装到我们的应用程序中。

  1. main.js文件中,导入插件,然后调用Vue.use()方法,就像我们为vue-router做的那样:
      import VueFetch from './plugins/fetch'
      Vue.use(VueFetch)

现在你应该在浏览器控制台中看到'Installed!'消息。

插件选项

我们可以使用options参数配置插件:

  1. 编辑install方法,在Vue之后添加这个参数:
      export default {
        install (Vue, options) {
          console.log('Installed!', options)
        },
      }

现在,我们可以在main.js文件中的Vue.use()方法中添加一个配置对象。

  1. 让我们在配置中添加一个baseUrl属性:
      Vue.use(VueFetch, {
        baseUrl: 'http://localhost:3000/',
      })

现在你应该在浏览器控制台中看到options对象。

  1. baseUrl存储到一个变量中,以便我们以后可以使用它:
      let baseUrl

      export default {
        install (Vue, options) {
          console.log('Installed!', options)

          baseUrl = options.baseUrl
        },
      }

获取方法

现在,我们将编写$fetch方法。我们将使用 FAQ 组件的created钩子中使用的大部分代码:

  1. 使用fetch实现$fetch方法:
      export async function $fetch (url) {
        const response = await fetch(`${baseUrl}${url}`)
        if (response.ok) {
          const data = await response.json()
          return data
        } else {
          const error = new Error('error')
          throw error
        }
      }

我们将其导出,以便我们也可以在我们的纯 JavaScript 代码中使用它。现在url参数只是查询的路径,不包括域名,域名现在在我们的baseUrl变量中--这使我们可以轻松地更改它,而不必重构每个组件。我们还负责 JSON 解析,因为服务器上的所有数据都将以 JSON 编码。

  1. 为了使它在所有组件中可用,只需将其添加到Vue的原型中(这是用于创建组件的构造函数):
      export default {
        install (Vue, options) {
          // Plugin options
          baseUrl = options.baseUrl

          Vue.prototype.$fetch = $fetch
        },
      }
  1. 然后,重构 FAQ 组件,使用我们新的特殊$fetch方法在创建钩子中:
      this.loading = true
      try {
        this.questions = await this.$fetch('questions')
      } catch (e) {
        this.error = e
      }
      this.loading = false

我们组件中的代码现在更短、更易读,并且更具可扩展性,因为我们可以轻松地更改基本 URL。

使用 mixin 重用代码

我们已经看到了如何创建插件,但还有另一种改进我们的代码的方法--如果我们可以在多个组件之间重用组件定义,比如计算属性、方法或观察者,会怎么样?这就是 mixin 的作用!

混合是一个可以应用到其他定义对象(包括其他混合)的组件定义对象。它非常简单,因为它看起来和常规组件定义完全一样!

我们的目标是有一个RemoteData混合,它将允许任何组件向服务器发出请求以获取数据。让我们在src目录下添加一个新的mixins文件夹,并创建一个新的RemoteData.js文件:

  1. 我们将从导出一个带有数据属性的定义开始简单:
      export default {
        data () {
          return {
            remoteDataLoading: 0,
          }
        },
      }

这个remoteDataLoading属性将用于计算当前正在加载的请求数量,以帮助我们显示加载动画。

  1. 现在,要在我们的 FAQ 组件中使用这个混合,我们需要导入它并将其添加到mixins数组中:
      <script>
      import RemoteData from '../mixins/RemoteData'

      export default {
        mixins: [
          RemoteData,
        ],

        // ...
      }
      </script>

如果你检查组件,你现在应该看到一个额外的remoteDataLoading属性被显示出来:

发生了什么?混合被应用并合并到了FAQ.vue的组件定义中,这意味着数据钩子被调用了两次--首先是来自混合,然后是来自 FAQ 定义,并且添加了一个新属性!

Vue 将自动合并标准选项,如钩子、数据、计算属性、方法和监视器,但是如果你有,例如,一个具有相同名称的属性或方法,最后一个应用的将覆盖之前的。

  1. 让我们尝试用另一个值覆盖组件中的新属性:
      data () {
        return {
          questions: [],
          error: null,
          loading: false,
          remoteDataLoading: 42,
        }
      },

正如你在组件检查器中所看到的,最终的组件定义比混合具有更高的优先级。另外,你可能已经注意到mixins选项是一个数组,因此我们可以将多个混合应用到定义中,它们将按顺序合并。例如,假设我们有两个混合并希望将它们应用到组件定义中。下面是会发生的事情:

    1. 定义对象包含混合 1 的选项。
  1. 混合 2 的选项被合并到定义对象中(现有属性/方法名称被覆盖)。

  2. 同样,组件的选项会合并到最终的定义对象中。

你现在可以从 FAQ 组件定义中删除重复的remoteDataLoading: 42,

datacreatedmounted这样的钩子会按照它们被应用到最终定义的顺序分别被调用。这也意味着最终组件定义的钩子将会最后被调用。

获取远程数据

我们有一个问题--每个使用我们的RemoteData mixin 的组件将具有不同的数据属性需要获取。因此,我们需要向我们的 mixin 传递参数。由于 mixin 本质上是一个定义对象,为什么不使用一个可以接受参数并返回定义对象的函数呢?这就是我们在这种情况下要做的事情!

  1. 将我们定义的对象包装在一个带有resources参数的函数中:
 export default function (resources) {
        return {
          data () {
            return {
              remoteDataLoading: 0,
            }
          },
        }
      }

resources参数将是一个对象,其中每个键都是我们要添加的数据属性的名称,值是需要向服务器发出的请求的路径。

  1. 因此,我们需要更改我们在FAQ.vue组件中使用 mixin 的方式,改为函数调用:
      mixins: [
        RemoteData({
          questionList: 'questions',
        }),
      ],

在这里,我们将获取http://localhost:3000/questions URL(使用我们之前创建的特殊$fetch方法)并将结果放入questionList属性中。

现在让我们来看看我们的RemoteData mixin!

  1. 首先,我们需要将每个数据属性初始化为null值,这样 Vue 才能在其上设置响应性:
 data () {
        let initData = {
          remoteDataLoading: 0,
        }

        // Initialize data properties
        for (const key in resources) {
          initData[key] = null
        }

        return initData
      },

这一步很重要--如果您不初始化数据,Vue 不会使其具有响应性,因此当属性更改时,组件将不会更新。

您可以尝试该应用程序,并在组件检查器中查看FAQ组件中已添加了一个新的questionList数据属性:

  1. 然后,我们将创建一个新的fetchResource方法,该方法获取一个资源并更新相应的数据属性:
 methods: {
        async fetchResource (key, url) {
          try {
            this.$data[key] = await this.$fetch(url)
          } catch (e) {
            console.error(e)
          }
        },
      },

我们的组件现在可以直接访问这个新方法并使用它。

  1. 为了使我们的 mixin 更智能,我们将在created钩子内自动调用它(将被合并):
 created () {
        for (const key in resources) {
          let url = resources[key]
          this.fetchResource(key, url)
        }
      },

您现在可以验证questionList数据属性是否随着向服务器发出的新请求而更新:

  1. 然后,您可以在FAQ.vue组件中删除具有questions属性的旧代码,并更改模板以使用新属性:
      <article v-for="question of questionList">

加载管理

我们接下来要做的事情是提供一种方法来知道是否应该显示加载动画。由于我们可能会有多个请求,所以我们将使用一个数字计数器而不是布尔值--remoteDataLoading,我们已经在data钩子中声明了。每次发出请求时,我们都会递增计数器,当请求完成时,我们会递减计数器。这意味着如果它等于零,当前没有挂起的请求,如果大于或等于一,我们应该显示加载动画:

  1. fetchResource方法中添加两个语句,递增和递减remoteDataLoading计数器:
      async fetchResource (key, url) {
        this.$data.remoteDataLoading++
        try {
          this.$data[key] = await this.$fetch(url)
        } catch (e) {
          console.error(e)
        }
        this.$data.remoteDataLoading--
      },
  1. 为了在使用 mixin 时使我们的生活更轻松,让我们添加一个名为remoteDataBusy的计算属性,当我们需要显示加载动画时将为true
 computed: {
        remoteDataBusy () {
          return this.$data.remoteDataLoading !== 0
        },
      },
  1. 回到我们的 FAQ 组件,现在我们可以删除loading属性,更改Loading组件的v-if表达式,并使用remoteDataLoading计算属性:
      <Loading v-if="remoteDataBusy" />

您可以尝试刷新页面,以查看在检索数据之前显示的加载动画。

错误管理

最后,我们可以管理可能发生的任何资源请求的错误。

  1. 我们将为每个资源存储错误在一个新的remoteErrors对象中,这需要初始化:
      // Initialize data properties
      initData.remoteErrors = {}
      for (const key in resources) {
        initData[key] = null
        initData.remoteErrors[key] = null
      }

remoteErrors对象的键将与资源相同,值将是错误或null(如果没有错误)。

接下来,我们需要修改fetchResource方法:

  • 在请求之前,通过将其设置为null来重置错误

  • 如果在 catch 块中有错误,请将其放入正确的键的remoteErrors对象中

  1. fetchResource方法现在应该如下所示:
      async fetchResource (key, url) {
        this.$data.remoteDataLoading++
        // Reset error
        this.$data.remoteErrors[key] = null
        try {
          this.$data[key] = await this.$fetch(url)
        } catch (e) {
          console.error(e)
          // Put error
          this.$data.remoteErrors[key] = e
        }
        this.$data.remoteDataLoading--
      },

我们现在可以为每个资源显示特定的错误消息,但在这个项目中我们将简单地显示一个通用的错误消息。让我们添加另一个名为hasRemoteErrors的计算属性,如果至少有一个错误,则返回 true。

  1. 使用 JavaScript 的“Object.keys()”方法,我们可以迭代remoteErrors对象的键,并检查某些值是否不是null(这意味着它们为真):
      computed: {
        // ...

        hasRemoteErrors () {
          return Object.keys(this.$data.remoteErrors).some(
            key => this.$data.remoteErrors[key]
          )
        },
      },
  1. 现在我们可以再次通过用新的替换 FAQ 组件模板error属性:
      <div class="error" v-if="hasRemoteErrors">

就像以前一样,您可以关闭服务器以查看显示的错误消息。

我们现在已经完成了 FAQ 组件,其脚本现在应该如下所示:

<script>
import RemoteData from '../mixins/RemoteData'

export default {
  mixins: [
    RemoteData({
      questionList: 'questions',
    }),
  ],
}
</script>

如您所见,现在非常简洁!

支持票

在最后一部分中,我们将创建应用程序的经过身份验证的部分,用户将能够添加和查看支持票。您已经下载的服务器上已经有所有必要的请求,如果您对在 node 中如何使用passport.js完成这些操作感到好奇,您可以查看源代码!

用户认证

在这个第一部分,我们将处理应用程序的用户系统。我们将有登录和注册组件,以便能够创建新用户。

将用户存储在集中状态中

我们将像我们在第三章中所做的那样,将用户数据存储在状态对象中,项目 2 - 城堡决斗浏览器游戏,这样我们就可以在应用程序的任何组件中访问它:

  1. main.js旁边创建一个新的state.js文件,导出状态对象:
      export default {
        user: null,
      }

当没有用户登录时,user属性将为 null,否则它将包含用户数据。

  1. 然后,在main.js文件中,导入状态:
      import state from './state'
  1. 然后,将其用作根实例的数据,这样 Vue 就会使其具有反应性:
      new Vue({
        el: '#app',
        data: state,
        router,
        render: h => h(AppLayout),
      })

另一个插件

然后,我们可以在组件文件中导入状态,但能够像我们为fetch插件做的那样,在 Vue 原型上使用一个特殊的 getter$state来访问它会更方便。我们将状态对象传递给插件选项,getter 将返回它。

  1. plugins文件夹中,创建一个导出新插件的state.js文件:
      export default {
        install (Vue, state) {
          Object.defineProperty(Vue.prototype, '$state', {
            get: () => state,
          })
        }
      }

在这里,我们使用 JavaScript 的Object.defineProperty()方法在 Vue 原型上设置一个 getter,所以每个组件都会继承它!

最后一件事——我们需要安装状态插件!

  1. main.js文件中,导入新插件:
      import VueState from './plugins/state'
  1. 然后使用状态对象作为选项参数安装它:
 Vue.use(VueState, state)

现在我们可以在组件中使用$state来访问全局状态了!这里是一个例子:

console.log(this.$state)

这应该输出带有user属性的状态对象。

登录表单

在这一部分,我们将首先创建新的组件来帮助我们更快地构建表单,然后我们将使用Login.vue组件将注册和登录表单添加到应用程序中。在后面的部分,我们将创建另一个表单来提交新的支持票。

智能表单

这个通用组件将负责我们表单组件的非常一般的结构,并且会自动调用一个operation函数,显示一个加载动画和操作抛出的错误消息。大多数情况下,操作将是向服务器发出的POST请求。

模板本质上是一个带有标题的表单,一个默认插槽,用于呈现输入,一个用于按钮的actions插槽,一个加载动画,以及一个用于错误消息的位置。这将足够通用,适用于应用程序中我们需要的两个表单:

  1. components文件夹中创建一个新的SmartForm.vue组件:
      <template>
        <form @submit.prevent="submit">
          <section class="content">
            <h2>{{ title }}</h2>

            <!-- Main content -->
            <slot />

            <div class="actions">
              <!-- Action buttons -->
              <slot name="actions" />
            </div>

            <div class="error" v-if="error">{{ error }}</div>
          </section>

          <transition name="fade">
            <!-- Expanding over the form -->
            <Loading v-if="busy" class="overlay" />
          </transition>
        </form>
      </template>

<form>元素上,我们在'submit'事件上设置了一个事件监听器,使用prevent修饰符阻止了浏览器的默认行为(重新加载页面)。

目前,SmartForm组件将有三个 props:

  • 标题:这将显示在<h2>元素中。

  • operation:表单提交时调用的异步函数。它应该返回一个 promise。

  • valid:一个布尔值,用于防止在表单无效时调用操作。

  1. 将它们添加到组件的script部分:
      <script>
      export default {
        props: {
          title: {
            type: String,
            required: true,
          },
          operation: {
            type: Function,
            required: true,
          },
          valid: {
            type: Boolean,
            required: true,
          },
        },
      }
      </script>

正如你所看到的,我们现在正在使用一种不同的方式来声明 props--通过使用对象,我们可以指定 props 的更多细节。例如,使用required: true,Vue 会在我们忘记一个 prop 时警告我们。我们还可以放置 Vue 将检查的类型。这种语法是推荐的,因为它既有助于理解组件的 props,又能避免错误。

我们还需要两个数据属性:

  • busy:一个布尔值,用于切换加载动画的显示

  • error:这是错误消息,如果没有则为null

  1. 使用data钩子添加它们:
 data () {
        return {
          error: null,
          busy: false,
        }
      },
  1. 最后,我们需要编写在表单提交时调用的submit方法:
 methods: {
        async submit () {
          if (this.valid && !this.busy) {
            this.error = null
            this.busy = true
            try {
              await this.operation()
            } catch (e) {
              this.error = e.message
            }
            this.busy = false
          }
        },
      },

如果表单无效或仍在忙碌中,我们不调用操作。否则,我们重置error属性,然后调用operation prop,使用await关键字,因为它应该是一个返回 promise 的异步函数。如果我们捕获到错误,我们将消息设置为error属性,以便显示。

  1. 现在我们的通用表单已经准备好了,我们可以在global-components.js文件中注册它:
      import SmartForm from './components/SmartForm.vue'
      Vue.component('SmartForm', SmartForm)

表单输入组件

在我们的表单中,我们将有许多具有相同标记和功能的输入。这是制作另一个通用且可重用组件的绝佳机会。它将有一个小模板,主要是一个<input>元素,并且能够通过红色边框向用户显示它是无效的:

  1. 首先创建一个新的FormInput.vue组件,具有以下 props:
  • name是输入的 HTML 名称,需要用于浏览器自动完成功能。

  • type默认为'text',但最终我们需要设置为'password'

  • value是输入框的当前值。

  • placeholder是显示在输入框内部的标签。

  • invalid是一个布尔值,用于切换无效显示(红色边框)。它默认为false

脚本应该像这样使用 prop 对象表示法:

<script>
export default {
  props: {
    name: {
      type: String,
    },
    type: {
      type: String,
      default: 'text',
    },
    value: {
      required: true,
    },
    placeholder: {
      type: String,
    },
    invalid: {
      type: Boolean,
      default: false,
    },
  },
}
</script>
  1. 对于无效显示,我们将添加一个计算属性来动态更改输入框的 CSS 类:
 computed: {
        inputClass () {
          return {
            'invalid': this.invalid,
          }
        },
      },
  1. 现在我们可以编写我们的模板。它将包含一个包含<input><div>元素:
      <template>
        <div class="row">
          <input
            class="input"
            :class="inputClass"
            :name="name"
            :type="type"
            :value.prop="value"
            :placeholder="placeholder"
          />
        </div>
      </template>

我们在v-bind:value指令上使用prop修饰符,告诉 Vue 直接设置 DOM 节点的value属性,而不是设置 HTML 属性。在处理诸如输入 HTML 元素的value等属性时,这是一个很好的做法。

  1. 为了开始测试它,我们可以在global-components.js文件中注册组件:
      import FormInput from './components/FormInput.vue'
      Vue.component('FormInput', FormInput)
  1. 使用FormInput组件创建一个新的Login.vue组件:
      <template>
        <main class="login">
          <h1>Please login to continue</h1>
          <form>
            <FormInput
              name="username"
              :value="username"
              placeholder="Username" />
          </form>
        </main>
      </template>

      <script>
      export default {
        data () {
          return {
            username: '',
          }
        },
      }
      </script>
  1. 不要忘记在router.js文件中添加相应的路由:
      import Login from './components/Login.vue'

      const routes [
        // ...
        { path: '/login', name: 'login', component: Login },
      ]

您可以通过在 URL 中使用/login路径打开应用程序来测试组件:

目前,FormInput组件是只读的,因为当用户在字段中输入时,我们不做任何操作。

  1. 让我们添加一个方法来处理这个问题:
 methods: {
        update (event) {
          console.log(event.currentTarget.value)
        },
      },
  1. 然后我们可以监听文本字段上的input事件:
 @input="update"

现在,如果你在文本框中输入,内容应该会打印到控制台上。

  1. update方法中,我们将发出一个事件来将新值发送到父组件。默认情况下,v-model指令监听input事件,新值是第一个参数:
      methods: {
        update (event) {
          this.$emit('input', event.currentTarget.value)
        },
      },

为了理解事情是如何工作的,我们暂时不会使用v-model

  1. 我们现在可以监听input事件并更新username属性:
      <FormInput
       name="username"
       :value="username"
       @input="val => username = val"
       placeholder="Username" />

username属性的值应该在Login组件上更新:

  1. 使用v-model指令,我们可以简化这段代码:
      <FormInput
       name="username"
       v-model="username"
       placeholder="Username" />

它将使用value属性并为我们监听input事件!

自定义 v-model

默认情况下,v-model使用value属性和input事件,正如我们刚才看到的,但我们可以自定义:

  1. FormInput组件内部,添加model选项:
 model: {
       prop: 'text',
       event: 'update',
      },
  1. 然后我们需要将我们的value属性的名称更改为text
      props: {
        // ...
        text: {
          required: true,
        },
      },
  1. 在模板中:
      <input
       ...
       :value="text"
       ... />
  1. 另外,input事件应该被重命名为update
      this.$emit('update', event.currentTarget.value)

该组件应该仍然在Login组件中工作,因为我们告诉v-model使用text属性和update事件!

我们的输入组件现在已经准备好了!对于这个项目,我们将这个组件保持简单,但如果您愿意,您可以添加更多功能,比如图标、错误消息、浮动标签等。

登录组件

我们现在可以继续构建Login组件,该组件将负责登录和注册用户。

这个组件状态需要几个数据属性:

  • 模式:这可以是'login''signup'。我们将根据此更改布局。

  • 用户名:在两种模式下使用。

  • 密码:也在两种模式下使用。

  • password2:用于在注册时验证密码。

  • 电子邮件:用于注册模式。

  1. 我们的data钩子现在应该是这样的:
 data () {
        return {
          mode: 'login',
          username: '',
          password: '',
          password2: '',
          email: '',
        }
      },
  1. 然后,我们可以添加一个title计算属性,根据模式更改表单标题:
 computed: {
        title () {
          switch (this.mode) {
            case 'login': return 'Login'
            case 'signup': return 'Create a new account'
          }
        },
      },

我们还将添加一些基本的输入验证。首先,当重新输入的密码与第一个密码不相等时,我们希望突出显示它。

  1. 让我们为此添加另一个计算属性:
 retypePasswordError () {
        return this.password2 && this.password !== this.password2
      },

然后,我们还将检查没有字段为空,因为它们都是必填的。

  1. 这次,我们将将其分解为两个计算属性,因为我们不希望在login模式下检查注册特定字段:
 signupValid () {
        return this.password2 && this.email &&             
        !this.retypePasswordError
      },
      valid () {
        return this.username && this.password &&
        (this.mode !== 'signup' || this.signupValid)
      },
  1. 接下来,添加我们将用于登录注册用户的方法(我们将在注册操作登录操作部分中稍后实现它们):
 methods: {
        async operation() {
          await this[this.mode]()
        },
        async login () {
          // TODO
        },
        async signup () {
          // TODO
        },
      }
  1. 我们现在可以转到模板。首先添加一个SmartForm组件:
      <template>
        <main class="login">
          <h1>Please login to continue</h1>
          <SmartForm
            class="form"
            :title="title"
            :operation="operation"
            :valid="valid">
            <!-- TODO -->
          </SmartForm>
        </main>
      </template>
  1. 然后我们可以添加input字段:
      <FormInput
        name="username"
        v-model="username"
        placeholder="Username" />
      <FormInput
        name="password"
        type="password"
        v-model="password"
        placeholder="Password" />
      <template v-if="mode === 'signup'">
        <FormInput
          name="verify-password"
          type="password"
          v-model="password2"
          placeholder="Retype Password"
          :invalid="retypePasswordError" />
        <FormInput
          name="email"
          type="email"
          v-model="email"
          placeholder="Email" />
      </template>

不要忘记name属性--它将允许浏览器自动完成字段。

  1. input字段下面,我们需要两个不同的按钮,用于每种模式。对于登录模式,我们需要一个注册登录按钮。对于注册模式,我们需要一个返回按钮和一个创建帐户按钮:
      <template slot="actions">
        <template v-if="mode === 'login'">
          <button
            type="button"
            class="secondary"
            @click="mode = 'signup'">
            Sign up
          </button>
          <button
            type="submit"
            :disabled="!valid">
            Login
          </button>
        </template>
        <template v-else-if="mode === 'signup'">
          <button
            type="button"
            class="secondary"
            @click="mode = 'login'">
            Back to login
          </button>
          <button
            type="submit"
            :disabled="!valid">
            Create account
          </button>
        </template>
      </template>

现在您可以测试组件并在登录注册模式之间切换:

样式作用域元素的子元素

表单目前占用了所有可用空间。最好将其缩小一点。

为了使本节起作用,您需要在项目中安装最新的vue-loader包。

让我们添加一些样式来给表单设置最大宽度:

<style lang="stylus" scoped>
.form {
  >>> .content {
    max-width: 400px;
  }
}
</style>

>>>组合器允许我们定位模板中使用的组件内的元素,同时仍然限定其余的CSS选择器。在我们的示例中,生成的CSS将如下所示:

.form[data-v-0e596401] .content {
  max-width: 400px;
}

如果我们没有使用这个组合器,我们将会有这个CSS

.form .content[data-v-0e596401] {
  max-width: 400px;
}

这不起作用,因为.content元素在我们在模板中使用的SmartForm组件内部。

如果您使用 SASS,则需要使用/deep/选择器而不是>>>组合器。

现在表单应该是这样的:

改进我们的 fetch 插件

目前,我们的$fetch方法只能向服务器发出GET请求。对于加载 FAQ 来说已经足够了,但现在我们需要为其添加更多功能:

  1. plugins/fetch.js文件中,编辑函数的签名以接受一个新的options参数:
      export async function $fetch (url, options) {
        // ...
      }

options参数是浏览器fetch方法的可选对象,它允许我们更改不同的参数,比如使用的 HTTP 方法,请求体等。

  1. $fetch函数的开头,我们想为这个options参数设置一些默认值:
      const finalOptions = Object.assign({}, {
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include',
      }, options)

默认选项告诉服务器我们将始终在请求体中发送 JSON,并告诉浏览器,如果用户已登录,我们还将包括必要的授权令牌。然后,如果有提供options参数,将其值添加到finalOptions对象中(例如method属性或body属性)。

  1. 接下来,我们将新的选项添加到fetch浏览器方法中:
      const response = await fetch(`${baseUrl}${url}`, finalOptions)
  1. 此外,服务器将始终以文本形式发送错误,因此我们可以捕获并向用户显示它们:
      if (response.ok) {
        const data = await response.json()
        return data
      } else {
        const message = await response.text()
 const error = new Error(message)
 error.response = response
        throw error
      }

现在我们准备向服务器发出第一个POST请求,以为用户创建一个新帐户,然后登录!

注册操作

我们将从帐户创建开始,因为我们还没有任何用户。在服务器上调用的路径是/signup,它期望一个带有新帐户的用户名、密码和电子邮件的 JSON 对象的POST请求:

让我们使用我们刚刚改进的$fetch方法来实现这一点:

async signup () {
  await this.$fetch('signup', {
    method: 'POST',
    body: JSON.stringify({
      username: this.username,
      password: this.password,
      email: this.email,
    }),
  })
  this.mode = 'login'
},

我们不在这里处理错误,因为这是我们之前构建的SmartForm组件的工作。

就是这样!现在您可以使用一个简单的密码创建一个新帐户,以便以后记住。如果帐户创建成功,表单将返回到登录模式。

这里我们没有做的一件事是让用户知道他们的帐户已经创建,他们现在可以登录。您可以在表单下方添加一条消息,甚至让浮动通知出现!

登录操作

登录方法几乎与注册相同。区别在于:

  • 我们只在请求体中发送usernamepassword/login路径

  • 响应是我们需要设置到全局状态中的用户对象,以便每个组件都可以知道是否有连接的用户(使用我们制作的插件暴露的$state属性)

  • 然后重定向到主页

现在它应该是这样的:

async login () {
  this.$state.user = await this.$fetch('login', {
    method: 'POST',
    body: JSON.stringify({
      username: this.username,
      password: this.password,
    }),
  })
  this.$router.push({ name: 'home' })
},

您现在可以尝试使用之前用来创建帐户的用户名密码进行登录。如果登录成功,您应该通过router.push()方法被重定向到主页。

此请求返回的user对象包含将显示在导航菜单中的username字段。

用户菜单

现在是时候将与用户相关的功能添加到我们在NavMenu.vue文件开头制作的导航菜单中了:

  1. 我们希望它们出现在菜单的最右侧,因此我们将在我们已经编写的路由链接之后添加这个元素:
      <div class="spacer"></div>

这将简单地扩展以占用菜单中所有可用的空间,使用 CSS flexbox 属性,这样我们放在后面的任何东西都会被推到右边。

由于我们在将用户存储在集中状态部分中制作的插件,我们可以通过$state属性访问全局状态。它包含user对象,允许我们知道用户是否已登录,并显示他们的usernamelogout链接。

  1. NavMenu.vue组件中添加用户菜单:
      <template v-if="$state.user">
        <a>{{ $state.user.username }}</a>
        <a @click="logout">Logout</a>
      </template>
  1. 如果用户未连接,我们只显示一个登录链接(在我们刚刚添加的template下面添加这个):
      <router-link v-else :to="{name: 'login'}">Login</router-link>

logout链接需要一个新的logout方法,我们现在将创建它。

登出方法

登出方法包括简单地调用服务器上的/logout路径,该路径应返回一个带有status属性等于'ok'的对象:

<script>
export default {
  methods: {
    async logout () {
      const result = await this.$fetch('logout')
      if (result.status === 'ok') {
        this.$state.user = null
      }
    },
  },
}
</script>

如果用户成功登出,我们会重置全局状态中的user值。

带有导航守卫的私有路由

现在我们已经准备好认证系统,我们可以有不同类型的路由:

  • 公共路由始终可访问

  • 私有路由仅限于已登录用户

  • 访客路由仅对尚未连接的用户可访问

我们将提前创建一个路由组件来测试我们的代码:

  1. 让我们创建TicketsLayout.vue组件,稍后我们将用它来显示用户支持票据:
      <template>
        <main class="tickets-layout">
          <h1>Your Support tickets</h1>
          <!-- TODO -->
        </main>
      </template>
  1. 然后,在router.js文件中添加相应的路由:
      import TicketsLayout from './components/TicketsLayout.vue'

      const routes = [
        // ...
        { path: '/tickets', name: 'tickets',
 component: TicketsLayout },
      ]
  1. 最后,在导航菜单中添加到这个新页面的链接:
      <router-link :to="{ name: 'tickets' }">
        Support tickets</router-link>

路由元属性

我们可以在router.js文件中的受影响路由的meta对象中添加页面访问类型信息。

我们刚刚创建的路由应该是私有的,只能由已连接的用户访问:

  • 在路由上的meta对象中添加private属性:
      { path: '/tickets', /* ... */, meta: { private: true } },

现在,如果您转到票务页面并检查任何组件,您应该看到vue-router插件公开的$route对象。它在meta对象中包含private属性:

您可以在路由的meta对象中放入任何额外的信息,以扩展路由器的功能。

路由器导航守卫

现在我们知道票务路线是私人的,我们想在路线解析之前执行一些逻辑,以检查用户是否已连接。这就是导航守卫派上用场的地方--它们是在路由方面发生某些事情时调用的函数钩子,它们可以改变路由器的行为。

我们需要的导航守卫是beforeEach,它在每次解析路由之前运行。它允许我们根据需要替换目标路由。它接受一个带有三个参数的回调函数:

  • to是当前正在定位的路由

  • from是上一个路由

  • next是一个我们必须在某个时候调用以便解析继续进行的函数

如果您忘记在导航守卫中调用next,您的应用程序将会被卡住。这是因为您可以在调用它之前执行异步操作,所以路由器不会自行做出任何假设。

  1. 在导出路由实例之前,在router.js文件中添加beforeEach导航守卫:
      router.beforeEach((to, from, next) => {
        // TODO
        console.log('to', to.name)
        next()
      })
  1. 现在我们需要确定我们要定位的路由是否是私有路由:
      if (to.meta.private) {
        // TODO Redirect to login
      }
  1. 要检查用户是否已连接,我们需要全局状态--您可以在文件开头导入它:
      import state from './state'
  1. 更改条件以检查用户状态:
      if (to.meta.private && !state.user) {
        // TODO Redirect to login
      }

下一个函数可以使用路由参数调用,将导航重定向到另一个路由。

  1. 因此,在这里,我们可以像使用router.push()方法一样重定向到登录路由:
      if (to.meta.private && !state.user) {
        next({ name: 'login' })
 return
      }

不要忘记返回,否则您将在函数结束时第二次调用next

现在我们可以尝试注销并点击支持票链接。您应该立即被重定向到登录页面。

使用next重定向时,每次重定向都不会向浏览器历史记录中添加额外的条目。只有最终路由有历史记录条目。

正如您在浏览器控制台中所看到的,每次我们尝试解析到一个路由时,导航守卫都会被调用:

这就解释了为什么这个函数被称为 next--解析过程将继续,直到我们不再重定向到另一个路由。

这意味着导航守卫可以被多次调用,但这也意味着您应该小心,不要创建无限的解析“循环”!

重定向到想要的路由

用户登录后,应用程序应将其重定向到他们最初想要浏览的页面:

  1. 将当前想要的 URL 作为参数传递给登录路由:
      next({
        name: 'login',
        params: {
          wantedRoute: to.fullPath,
        },
      })

现在,如果您单击支持票链接并被重定向到登录页面,您应该在任何组件的 $route 对象中看到 wantedRoute 参数:

  1. Login 组件中,我们可以在 login 方法中更改重定向,并使用此参数:
      this.$router.replace(this.$route.params.wantedRoute ||
        { name: 'home' })

router.replace() 方法与 router.push() 方法非常相似,不同之处在于它用新路由替换浏览器历史记录中的当前条目,而不是添加新条目。

现在,如果您登录,应该被重定向到支持票务页面,而不是主页。

初始化用户身份验证

当页面加载和应用程序启动时,我们需要检查用户是否已连接。出于这个原因,服务器有一个 /user 路径,如果用户已登录,则返回用户对象。我们将把它放在全局状态中,就像我们已经登录一样。然后,我们将启动 Vue 应用程序:

  1. main.js 文件中,从我们的插件中导入 $fetch
      import VueFetch, { $fetch } from './plugins/fetch'
  1. 然后,我们需要创建一个名为 main 的新异步函数,在其中我们将请求用户数据,然后启动应用程序:
      async function main () {
        // Get user info
        try {
          state.user = await $fetch('user')
        } catch (e) {
          console.warn(e)
        }
        // Launch app
        new Vue({
          el: '#app',
          data: state,
          router,
          render: h => h(AppLayout),
        })
      }

      main()

现在,如果您登录然后刷新页面,您仍然应该保持连接!

访客路由

还有另一种情况我们尚未处理--我们不希望已连接的用户访问登录路由!

  1. 这就是为什么我们将其标记为访客路由的原因:
      { path: '/login', name: 'login', component: Login,
        meta: { guest: true } },
  1. beforeEach 导航守卫中,我们将检查路由是否仅限访客,以及用户是否已连接,然后重定向到主页:
      router.beforeEach((to, from, next) => {
        // ...
        if (to.meta.guest && state.user) {
          next({ name: 'home' })
          return
        }
        next()
      })

如果您已登录,可以尝试转到登录 URL--您应该立即被重定向到主页!只有在未登录时才能访问此页面。

显示和添加票务

在本节中,我们将向应用程序添加票务支持内容。首先我们将显示它们,然后构建一个表单让用户创建新的票务。我们将为此创建两个组件,嵌套在我们之前创建的TicketsLayout组件中。

不用担心!当您创建您的账户时,一个示例支持票务会自动为您的用户创建。

票务列表

可以在服务器上的/tickets请求票务:

  1. 创建一个新的Tickets.vue组件,它将与 FAQ 组件非常相似。

  2. 使用RemoteData mixin 来获取票务:

      <script>
      import RemoteData from '../mixins/RemoteData'

      export default {
        mixins: [
          RemoteData({
            tickets: 'tickets',
          }),
        ],
      }
      </script>
  1. 然后添加一个带有加载动画、空消息和票务列表的模板:
      <template>
        <div class="tickets">
          <Loading v-if="remoteDataBusy"/>

          <div class="empty" v-else-if="tickets.length === 0">
            You don't have any ticket yet.
          </div>

          <section v-else class="tickets-list">
            <div v-for="ticket of tickets" class="ticket-item">
              <span>{{ ticket.title }}</span>
              <span class="badge">{{ ticket.status }}</span>
              <span class="date">{{ ticket.date }}</span>
            </div>
          </section>
        </div>
      </template>

我们需要一个过滤器来显示票务日期!

  1. 终止客户端编译,并使用以下命令安装momentjs
 npm install --save moment
  1. main.js文件旁边创建一个新的filters.js文件,其中包含一个date过滤器:
      import moment from 'moment'

      export function date (value) {
        return moment(value).format('L')
      }
  1. 然后在main.js中,导入filters并使用一个方便的循环进行注册:
      import * as filters from './filters'

      for (const key in filters) {
        Vue.filter(key, filters[key])
      }
  1. 现在我们可以在Tickets组件中以更加人性化的方式显示日期:
      <span class="date">{{ ticket.date | date }}</span>

然后将这个新组件添加到TicketsLayout组件中并获取票务列表:

不要忘记导入Tickets并将其设置在components选项中!

会话过期

一段时间后,用户会话可能会变得无效。这可能是因为定时到期(对于这个服务器,设置为三个小时),或者仅仅是因为服务器重新启动。让我们尝试重现这种情况--我们将重新启动服务器并尝试再次加载票务:

  1. 确保您已登录到应用程序中。

  2. 在运行服务器的终端中键入rs,然后按Return键以重新启动它。

  3. 在应用程序中点击主页按钮。

  4. 点击支持票务按钮返回到票务列表页面。

您应该在控制台中看到一个卡住的加载动画和一个错误消息:

服务器返回了未经授权的错误--这是因为我们已经退出登录了!

为了解决这个问题,如果我们在私人路线上,我们需要注销用户并将其重定向到登录页面。

放置我们代码的最佳位置是plugins/fetch.js文件中的所有组件中使用的$fetch方法。当尝试访问连接用户限制的路径时,服务器将始终返回 403 错误。

  1. 在修改方法之前,我们需要导入状态和路由:
      import state from '../state'
      import router from '../router'
  1. 让我们在响应处理中添加一个新的情况:
      if (response.ok) {
        // ...
      } else if (response.status === 403) {
        // If the session is no longer valid
        // We logout
        state.user = null

        // If the route is private
        // We go to the login screen
        if (router.currentRoute.matched.some(r => r.meta.private)) {
          router.replace({ name: 'login', params: {
            wantedRoute: router.currentRoute.fullPath,
          }})
        }
      } else {
        // ...
      }

我们使用replace方法而不是push,因为我们不希望在浏览器历史记录中创建新的导航。想象一下,如果用户单击返回按钮,它将再次重定向到登录页面,用户将无法返回到私人页面之前的页面。

现在您可以再试一次--当您重新启动服务器并单击支持票务链接时,您应该会被重定向到登录页面,并且导航菜单不应再显示您的用户名。

嵌套路由

由于我们还想在此页面切换到一个表单,因此将组件结构化为嵌套路由是一个好主意--如果至少有一个路由视图,每个路由都可以有子路由!因此,在/tickets路由器下,我们现在将有两个子路由:

  • ''将是票务列表(完整路径将是/tickets/)。它就像是/tickets下的默认路由。

  • '/new'将是发送新票务的表单(完整路径将是/tickets/new/)。

  1. 创建一个临时模板的新NewTicket.vue组件:
      <template>
        <div class="new-ticket">
          <h1>New ticket</h1>
        </div>
      </template>
  1. routes.js文件中,在children属性下的/tickets路由下添加两个新路由:
      import Tickets from './components/Tickets.vue'
      import NewTicket from './components/NewTicket.vue'

      const routes = [
        // ...
        { path: '/tickets', component: TicketsLayout,
          meta: { private: true }, children: [
          { path: '', name: 'tickets', component: Tickets },
          { path: 'new', name: 'new-ticket', component: NewTicket },
        ] },
      ]

由于第一个子路由是空字符串,当解析父路由时它将成为默认路由。这意味着您应该将路由的名称('tickets')从父级移到它。

  1. 最后,我们可以更改TicketsLayout组件,使用路由器视图以及一些按钮在子路由之间切换:
      <template>
        <main class="tickets-layout">
          <h1>Your Support tickets</h1>

          <div class="actions">
            <router-link
              v-if="$route.name !== 'tickets'"
              tag="button"
              class="secondary"
              :to="{name: 'tickets'}">
              See all tickets
            </router-link>
            <router-link
              v-if="$route.name !== 'new-ticket'"
              tag="button"
              :to="{name: 'new-ticket'}">
              New ticket
            </router-link>
          </div>

          <router-view />
        </main>
      </template>

您可以在路由链接上使用tag属性来更改用于呈现它的 HTML 标签。

正如您所看到的,我们根据当前路由名称隐藏每个按钮--当我们已经在票务页面时,我们不希望显示显示票务按钮,当我们已经在相应的表单上时,我们也不希望显示新票务按钮!

现在您可以在两个子路由之间切换,并相应地看到 URL 更改:

修复我们的导航守卫

如果您注销然后转到票务页面,您应该会惊讶地发现能够访问该页面!这是因为我们的beforeEach导航守卫实现存在缺陷--我们设计不当,没有考虑到可能存在嵌套路由!出现这个问题的原因是to参数只是目标路由,即/tickets路由的第一个子路由--它没有private元属性!

因此,我们不应该仅仅依赖于目标路由,还应该检查所有匹配的嵌套路由对象。幸运的是,每个路由对象都可以通过matched属性让我们访问这些路由对象的列表。然后我们可以使用some数组方法来验证是否至少有一个路由对象具有所需的 meta 属性。

我们可以在router.js文件中的beforeEach导航守卫中将条件代码更改为这样:

router.beforeEach((to, from, next) => {
  if (to.matched.some(r => r.meta.private) && !state.user) {
    // ...
  }
  if (to.matched.some(r => r.meta.guest) && state.user) {
    // ...
  }
  next()
})

现在我们的代码可以在任意嵌套路由的情况下工作了!

强烈建议每次都使用matched属性来避免错误。

发送表单

在这一部分,我们将完成NewTicket组件,允许用户发送新的支持票。我们需要两个字段来创建一个新的票--titledescription

  1. NewTicket.vue组件的模板中,我们已经可以添加一个标题为InputFormSmartForm组件:
      <SmartForm
       title="New ticket"      
       :operation="operation"
       :valid="valid">
        <FormInput
          name="title"
          v-model="title"
          placeholder="Short description (max 100 chars)"
          maxlength="100"
          required/>
      </SmartForm>
  1. 我们还可以添加两个数据属性,operation方法和一些输入验证,使用valid计算属性:
      <script>
      export default {
        data () {
          return {
            title: '',
            description: '',
          }
        },

        computed: {
          valid () {
            return !!this.title && !!this.description
          },
        },

        methods: {
          async operation () {
            // TODO
          },
        },
      }
      </script>

表单文本框

对于description字段,我们需要一个<textarea>元素,这样用户就可以编写多行文本。不幸的是,我们的FormInput组件还不支持这一点,所以我们需要稍微修改一下。我们将使用组件的typeprop,值为'textarea'来将<input>元素更改为<textarea>元素:

  1. 让我们创建一个新的计算属性来确定我们将要渲染哪种 HTML 元素:
      computed: {
        // ...
        element () {
          return this.type === 'textarea' ? this.type : 'input'
        },
      },

当传递值'textarea'时,我们需要渲染一个<textarea>。所有其他类型都会使组件渲染一个<input>元素。

现在我们可以使用特殊的<component>组件,它可以根据is属性渲染元素,而不是静态的<input>元素。

  1. 模板中的这一行现在应该是这样的:
      <component
        :is="element"
        class="input"
        :class="inputClass"
        :name="name"
        :type="type"
        :value.prop="text"
        @input="update"
        :placeholder="placeholder"
      />
  1. 现在我们可以在NewTicket表单中添加description文本框,就在title输入框之后:
      <FormInput
        type="textarea"
        name="description"
        v-model="description"
        placeholder="Describe your problem in details"/>

绑定属性

除了其他元素,<textarea>有一些方便的属性,我们想要使用,比如rows属性。我们可以为每个属性创建一个 prop,但这可能会很快变得乏味。相反,我们将使用 Vue 组件的特殊$attrs属性,它将所有设置在组件上的非 prop 属性作为一个对象获取,键是属性的名称。

这意味着如果你在组件上有一个textprop,然后在另一个组件中写入这样的内容:

<FormInput :text="username" required>

Vue 将把required视为属性,因为它不在FormInput组件公开的 props 列表中。然后您可以使用$attrs.required访问它!

v-bind指令可以获取一个对象,其中键是要设置的 props 和属性的名称。这将非常有用!

  1. 我们可以在FormInput.vue组件中的<component>上写入这个:
      <component
        ...
        v-bind="$attrs" />
  1. 现在可以在NewTicket.vue组件的description输入上添加rows属性:
      <FormInput
        ...
        rows="4"/>

您应该在渲染的 HTML 中看到该属性已设置在FormInput组件内的<textarea>元素上:

<textarea data-v-ae2eb904="" type="textarea" placeholder="Describe your problem in details" rows="4" class="input"></textarea>

用户操作

现在我们将实现用户在表单中可以执行的几个操作:

  1. SmarForm组件中,在输入框后添加这两个按钮:
      <template slot="actions">
        <router-link
          tag="button"
          :to="{name: 'tickets'}"
          class="secondary">
          Go back
        </router-link>
        <button
          type="submit"
          :disabled="!valid">
          Send ticket
        </button>
      </template>
  1. 然后实现operation方法,这将类似于我们在Login组件中所做的。我们需要将POST请求发送到的服务器路径是/tickets/new
      async operation () {
        const result = await this.$fetch('tickets/new', {
          method: 'POST',
          body: JSON.stringify({
            title: this.title,
            description: this.description,
          }),
        })
        this.title = this.description = ''
      },

现在可以创建新的票!

备份用户输入

为了改善用户体验,我们应该自动备份用户在表单中输入的内容,以防出现问题--例如,浏览器可能会崩溃,或者用户可能会意外刷新页面。

我们将编写一个 mixin,它将自动将一些数据属性保存到浏览器本地存储中,并在组件创建时恢复它们:

  1. mixins文件夹中创建一个新的PersistantData.js文件。

  2. 与我们之前做的另一个 mixin 一样,它将具有一些参数,因此我们需要将其导出为一个函数:

      export default function (id, fields) {
        // TODO
      }

id参数是用来存储这个特定组件数据的唯一标识符。

首先,我们将监视 mixin 中传递的所有字段。

  1. 为此,我们将动态创建watch对象,每个键都是字段,值是将值保存到本地存储的处理程序函数:
      return {
        watch: fields.reduce((obj, field) => {
          // Watch handler
          obj[field] = function (val) {
            localStorage.setItem(`${id}.${field}`, JSON.stringify(val))
          }
          return obj
        }, {}),
      }
  1. 返回NewTicket组件并添加 mixin:
      import PersistantData from '../mixins/PersistantData'

      export default {
        mixins: [
          PersistantData('NewTicket', [
            'title',
            'description',
          ]),
        ],

       // ...
      }

因此,mixin 为组件添加了观察者,reduce生成了相当于这个的等价物:

{
  watch: {
    title: function (val) {
      let field = 'title'
      localStorage.setItem(`${id}.${field}`, JSON.stringify(val))
    },
    description: function (val) {
      let field = 'description'
      localStorage.setItem(`${id}.${field}`, JSON.stringify(val))
    },
  },
}

我们将属性值保存为 JSON,因为本地存储只支持字符串。

您可以尝试在字段中输入,然后查看浏览器开发工具,看到已保存了两个新的本地存储项:

  1. 在 mixin 中,当组件被销毁时,我们还可以保存字段:
 methods: {
        saveAllPersistantData () {
          for (const field of fields) {
            localStorage.setItem(`${id}.${field}`,             
            JSON.stringify(this.$data[field]))
          }
        },
      },
      beforeDestroy () {
        this.saveAllPersistantData()
      },
  1. 最后,我们需要在组件创建时恢复这些值:
 created () {
        for (const field of fields) {
          const savedValue = localStorage.getItem(`${id}.${field}`)
          if (savedValue !== null) {
            this.$data[field] = JSON.parse(savedValue)
          }
        }
      },

现在,如果你在表单中输入一些内容,然后刷新页面,你输入的内容应该仍然在表单中!

通过我们添加到$fetch的会话过期管理,如果您在不再连接的情况下尝试发送新票务,您将被重定向到登录页面。然后,一旦您再次登录,您应该回到表单,并且您输入的内容仍然存在!

高级路由功能

这是本章的最后一节,我们将更深入地探讨路由!

带参数的动态路由

我们将在应用程序中添加的最后一个组件是Ticket,它通过其 ID 显示一个票务的详细视图。它将显示用户输入的标题和描述,以及日期和状态。

  1. 创建一个新的Ticket.vue文件,并添加这个模板,其中包括通常的加载动画和未找到提示:
      <template>
        <div class="ticket">
          <h2>Ticket</h2>

          <Loading v-if="remoteDataBusy"/>

          <div class="empty" v-else-if="!ticket">
            Ticket not found.
          </div>

          <template v-else>
            <!-- General info -->
            <section class="infos">
              <div class="info">
                Created on <strong>{{ ticket.date | date }}</strong>
              </div>
              <div class="info">
                Author <strong>{{ ticket.user.username }}</strong>
              </div>
              <div class="info">
                Status <span class="badge">{{ ticket.status }}</span>
              </div>
            </section>
            <!-- Content -->
            <section class="content">
              <h3>{{ ticket.title }}</h3>
              <p>{{ ticket.description }}</p>
            </section>
          </template>
        </div>
      </template>
  1. 然后在组件中添加一个id prop:
      <script>
      export default {
        props: {
          id: {
            type: String,
            required: true,
          },
        },
      }
      </script>

动态远程数据

id prop 将是我们将获取详细信息的票务的 ID。服务器以/ticket/<id>的形式提供动态路由,其中<id>是票务的 ID。

能够使用我们的RemoteData mixin 会很好,但它目前不支持动态路径!我们可以做的是将函数传递给 mixin 参数的值,而不是普通的字符串:

  1. RemoteData mixin 中,我们只需要修改created钩子中处理参数的方式。如果值是一个函数,我们将使用$watch方法来观察它的值,而不是直接调用fetchResource方法:
      created () {
        for (const key in resources) {
          let url = resources[key]
          // If the value is a function
          // We watch its result
          if (typeof url === 'function') {
            this.$watch(url, (val) => {
              this.fetchResource(key, val)
            }, {
              immediate: true,
            })
          } else {
            this.fetchResource(key, url)
          }
        }
      },

不要忘记在观察者中添加immediate: true选项,因为我们希望在观察值之前首先调用fetchResource方法。

  1. Ticket组件中,我们现在可以使用这个 mixin 根据id prop 加载票务数据:
      import RemoteData from '../mixins/RemoteData'

      export default {
        mixins: [
          RemoteData({
            ticket () {
              return `ticket/${this.id}`
            },
          }),
        ],
        // ...
      }

让我们在Tickets组件中尝试这个。

  1. 将新的Ticket组件添加到其中,并添加一个新的id数据属性:
      import Ticket from './Ticket.vue'

      export default {
        //...
        components: {
          Ticket,
        },
        data () {
          return {
            id: null,
          }
        },
      }
  1. 然后在模板中添加一个Ticket组件:
      <Ticket v-if="id" :id="id"/>
  1. 在票务列表中,将标题更改为在click事件上设置id数据属性的链接:
      <a @click="id = ticket._id">{{ ticket.title }}</a>

如果你在应用程序中点击票务,你应该能够在下面的列表中看到详细信息:

动态路由

由于我们将在另一个路由中放置票务详情,你可以撤消我们在Tickets组件中刚刚做的事情。

该路由将是票务路线的子路线,并且将采用/tickets/<id>的形式,其中<id>是正在显示的票的 ID。这要归功于 vue-router 的动态路由匹配功能!

您可以使用分号将动态段添加到路由路径中。然后,每个段都将暴露在路由params对象中。以下是一些带参数的路由示例:

模式 示例路径 $route.params
/tickets/:id /tickets/abc { id: 'abc' }
/tickets/:id/comments/:comId /tickets/abc/comments/42 { id: 'abc', comId: '42' }
  1. 让我们将新路由添加到router.js文件中,作为/tickets的子路由:
      import Ticket from './components/Ticket.vue'

      const routes = [
        // ...
        { path: '/tickets', component: TicketsLayout,
          meta: { private: true }, children: [
          // ...
          { path: ':id', name: 'ticket', component: Ticket },
        ] },
      ]
  1. Tickets组件列表中,我们需要将标题元素更改为指向新路由的链接:
<router-link :to="{name: 'ticket', params: { id: ticket._id }}">        {{ ticket.title }}</router-link>

现在,如果您点击一张票,$route.params对象将具有id属性设置为票的 ID。

我们可以更改我们的Ticket组件,使用计算属性而不是 prop:

computed: {
  id () {
    return $route.params.id
  },
},

但这是一个坏主意--我们正在将组件与路由耦合!这意味着我们将无法轻松地以另一种方式重用它。最佳实践是使用 props 将信息传递给组件,所以让我们继续这样做!

  1. 因此,我们将保留Ticket组件的 ID 属性,并告诉vue-router将所有路由参数作为带有props属性的 prop 传递给它:
      { path: ':id', /* ... */, props: true },

这相当于基于函数的更灵活的语法,该函数将路由对象作为参数:

{ path: ':id', /* ... */, props: route => ({ id: route.params.id }) },

还有一种基于对象的语法也存在(当 props 是静态的时候很有用):

{ path: ':id', /* ... */, props: { id: 'abc' } },

我们不会使用这种第三种语法,因为我们的id prop 应该等于路由的动态参数。

如果您需要组合静态和动态 props,请使用函数语法!如果路由参数和组件 props 名称不匹配,这也很有用。

现在,id参数作为 prop 传递给组件,当在列表中点击票时,您应该看到票的详细信息页面:

未找到页面

目前,如果您在应用程序中输入无效的 URL,您将看到一个无聊的空白页面。这是vue-router的默认行为,但幸运的是它可以更改!我们现在将自定义我们应用程序的“未找到”页面!

  1. 让我们创建一个更好的“未找到”页面,使用一个新的NotFound.vue组件:
      <template>
        <main class="not-found">
          <h1>This page can't be found</h1>
          <p class="more-info">
            Sorry, but we can't find the page you're looking for.<br>
            It might have been moved or deleted.<br>
            Check your spelling or click below to return to the                           
            homepage.
          </p>
          <div class="actions">
            <router-link tag="button" :to="{name: 'home'}">Return to             
            home</router-link>
          </div>
        </main>
      </template>

      <style lang="stylus" scoped>
      .more-info {
        text-align: center;
      }
      </style>
  1. 现在在router.js文件中,我们只需要添加一个匹配'*'路径的新路由:
      import NotFound from './components/NotFound.vue'

      const routes = [
        // ...
        { path: '*', component: NotFound },
      ]

这意味着对于任何路由,我们都会显示NotFound组件。非常重要的事实是,我们将这个路由放在routes数组的末尾 - 这确保在匹配这个最后一个特定的捕获所有路由之前,所有合法的路由都会被匹配。

您现在可以尝试一个不存在的 URL,比如/foo,来显示页面:

过渡

路由变化的动画非常容易 - 这与我们以前做的方式完全相同:

  • AppLayout组件中,用这个过渡包装路由视图:
      <transition name="fade" mode="out-in">
        <router-view />
      </transition>

router-view特殊组件将被路由的不同组件替换,从而触发过渡。

滚动行为

路由器的历史模式允许我们在路由更改时管理页面滚动。我们可以每次重置位置到顶部,或者在更改路由之前恢复用户之前的位置(当他们在浏览器中返回时非常有用)。

在创建路由器实例时,我们可以传递一个scrollBehavior函数,该函数将获得三个参数:

  • to是目标路由对象。

  • from是先前的路由对象。

  • savedPosition是为浏览器历史记录中的每个条目自动保存的滚动位置。直到路由更改之前,每个新条目都不会有这个。

scrollBehavior函数期望一个可以采用两种不同形式的对象。第一个是我们想应用的滚动的坐标;例如:

{ x: 100, y: 200 }

第二个是我们要滚动到的 HTML 元素的选择器,带有可选的偏移量:

{ selector: '#foo', offset: { x: 0, y: 200 } }
  1. 因此,当路由更改时滚动到页面顶部,我们需要编写这样的代码:
      const router = new VueRouter({
        routes,
        mode: 'history',
        scrollBehavior (to, from, savedPosition) {
          return { x: 0, y: 0 }
        },
      })

要每次滚动到<h1>元素,我们可以这样做:

return { selector: 'h1' }
  1. 相反,我们将检查路由是否有哈希来模仿浏览器的行为:
      if (to.hash) {
        return { selector: to.hash }
      }
      return { x: 0, y: 0 }
  1. 最后,如果有滚动位置,我们可以恢复滚动位置:
 if (savedPosition) {
 return savedPosition
 }
      if (to.hash) {
        return { selector: to.hash }
      }
      return { x: 0, y: 0 }

就是这么简单!现在应用程序应该像一个旧的多页面网站一样运行。然后,您可以使用偏移或路由元属性来自定义滚动行为的方式。

总结

在本章中,我们借助 Vue 和官方的vue-router库创建了一个相当大的应用程序。我们创建了一些路由,并用链接将它们连接起来,形成了一个真正的导航菜单。然后,我们创建了一个通用且可重用的组件来构建应用程序表单,这帮助我们创建了登录和注册表单。然后,我们将用户认证系统与路由器集成,这样我们的应用程序就可以智能地对页面刷新或会话过期做出反应。最后,我们深入了解了vue-router的功能和能力,以进一步增强我们的应用程序和用户体验。

我们已经完成了这个应用,但请随意对其进行改进!以下是一些你可以实现的想法:

  • 为工单添加评论。显示评论列表,并显示对应用户的名称。

  • 添加关闭此工单按钮,防止用户添加新评论。

  • 在工单列表中,为已关闭的工单旁边显示一个特殊图标!

  • 给用户添加角色。例如,普通用户可以打开工单,但只有管理员用户可以关闭工单。

在下一章中,我们将创建一个地理定位的博客应用程序,并学习如何通过集中式状态解决方案扩展我们的应用程序,并集成第三方库以扩展 Vue 的功能。

第六章:项目 4 - 地理定位博客

在本章中,我们将构建我们的第四个应用程序。我们将涵盖新的主题,例如:

  • 使用官方的 Vuex 库来管理应用程序的状态的集中式存储

  • 使用 Google OAuth API 将我们的用户连接到应用程序

  • 使用vue-googlemaps第三方库将 Google 地图集成到我们的应用程序中

  • 渲染函数和 JSX

  • 功能组件--制作更轻量和更快的组件

该应用程序将是一个地理定位博客,主要显示一个大地图,用户将在其中添加博客文章。以下是该应用程序的主要功能:

  • 登录页面将要求用户使用他们的 Google 帐户进行身份验证

  • 主视图将是嵌入应用程序中的 Google 地图,每个帖子都有一个标记

  • 单击标记将在右侧显示内容,包括位置描述、帖子、点赞计数和评论列表

  • 在地图的其他任何位置单击将在侧边栏中显示一个表单,以便用户可以在此位置创建新的帖子

  • 应用程序的顶部栏将显示当前用户的头像和名称,以及一个按钮,用于将地图居中显示在他们的位置,并另一个按钮用于注销

最终应用程序将如下所示:

Google 身份验证和状态管理

在这个第一部分中,我们将创建我们的第一个 Vuex 存储,以帮助我们管理应用程序的状态。我们将使用它来存储通过 Google OAuth API 登录的当前用户,这允许他们使用他们的 Google 帐户连接到我们的应用程序。

项目设置

首先,让我们设置新项目的基本结构。我们将继续使用路由器和第五章的一些部分,项目 3 - 支持中心

创建应用程序

在本节中,我们将为我们的地理定位博客设置基本应用程序结构。

  1. 就像我们在第五章中所做的那样,项目 3 - 支持中心,我们将使用vue-init初始化一个 Vue 项目,并安装 babel、routing 和 stylus 包:
 vue init webpack-simple geoblog</strong>
 **cd geoblog**
 **npm install**
 **npm install --save vue-router babel-polyfill**
 **npm install --save-dev stylus stylus-loader babel-preset-vue**

不要忘记在.babelrc文件中添加"vue"预设。

  1. 然后删除src目录的内容。

  2. 我们将重用我们在第五章中制作的$fetch插件,因此也将src/plugins/fetch.js文件复制到新项目中。

  3. src文件夹中,添加启动我们应用程序的main.js文件,就像我们在第五章,项目 3-支持中心中所做的那样:

      import 'babel-polyfill'
      import Vue from 'vue'
      import VueFetch, { $fetch } from './plugins/fetch'
      import App from './components/App.vue'
      import router from './router'
      import * as filters from './filters'

      // Filters
      for (const key in filters) {
        Vue.filter(key, filters[key])
      }

      Vue.use(VueFetch, {
        baseUrl: 'http://localhost:3000/',
      })

      function main () {
        new Vue({
          ...App,
          el: '#app',
          router,
        })
      }

      main()
  1. 我们仍然会使用moment.js来显示日期,所以您可以使用以下命令安装它:
 npm i -S moment

这种更短的表示法等同于npm install --save。对于开发依赖项,您可以使用npm i -D而不是npm install --save-dev

  1. 在新的src/filters.js文件中创建与之前相同的简单日期过滤器:
      import moment from 'moment'

      export function date (value) {
        return moment(value).format('L')
      }
  1. $fetch插件中,您可以删除对state.js文件的引用,因为这次我们不会有这个文件:
      // Remove this line
      import state from '../state'
  1. 如果请求收到403 HTTP 代码时登出用户的方式也将不同,因此您也可以删除相关代码:
      } else if (response.status === 403) {
        // If the session is no longer valid
        // We logout
        // TODO
      } else {
  1. 最后,下载(github.com/Akryum/packt-vue-project-guide/tree/master/chapter6-full/client/src/styles)并将它们放在src/styles目录中。

一些路由

应用程序将有三个页面:

  • 带有“使用 Google 登录”按钮的登录页面

  • 带有地图的主地理定位博客页面

  • 一个“未找到”页面

现在我们将创建主组件并使用简单组件设置这些页面:

  1. 创建一个新的src/components文件夹,并从第五章,项目 3-支持中心中复制NotFound.vue组件。

  2. 然后添加App.vue文件,其中包含router-view组件和主要的 stylus 文件:

      <template>
        <div class="app">
          <router-view />
        </div>
      </template>

      <style lang="stylus">
      @import '../styles/main';
      </style>
  1. 添加GeoBlog.vue文件,目前将非常简单:
      <template>
        <div class="geo-blog">
          <!-- More to come -->
        </div>
      </template>
  1. 添加带有“使用 Google 登录”按钮的Login.vue文件。按钮调用openGoogleSignin方法:
      <template>
        <div class="welcome">
          <h1>Welcome</h1>

          <div class="actions">
            <button @click="openGoogleSignin">
              Sign in with Google
            </button>
          </div>
        </div>
      </template>

      <script>
      export default {
        methods: {
          openGoogleSignin () {
            // TODO
          },
        },
      }
      </script>
  1. 创建一个类似于我们在第五章中所做的router.js文件,项目 3-支持中心。它将包含三个路由:
      import Vue from 'vue'
      import VueRouter from 'vue-router'

      import Login from './components/Login.vue'
      import GeoBlog from './components/GeoBlog.vue'
      import NotFound from './components/NotFound.vue'

      Vue.use(VueRouter)

      const routes = [
        { path: '/', name: 'home', component: GeoBlog,
          meta: { private: true } },
        { path: '/login', name: 'login', component: Login },
        { path: '*', component: NotFound },
      ]

      const router = new VueRouter({
        routes,
        mode: 'history',
        scrollBehavior (to, from, savedPosition) {
          if (savedPosition) {
            return savedPosition
          }
          if (to.hash) {
            return { selector: to.hash }
          }
          return { x: 0, y: 0 }
        },
      })

      // TODO Navigation guards
      // We will get to that soon

      export default router

路由应该已经在主文件中导入并注入到应用程序中。我们现在准备继续!

使用 Vuex 进行状态管理

这是本章的激动人心的部分,我们将使用第二个非常重要的官方 Vue 库--Vuex!

Vuex 允许我们使用集中式存储来管理应用程序的全局状态。

我为什么需要这个?

一个重要的问题是为什么我们首先需要一个集中式状态管理解决方案。您可能已经注意到在以前的项目中,我们已经使用了一个非常简单的state.js文件,其中包含我们在组件之间需要的全局数据的对象。Vuex 是朝着这个方向迈出的下一步。它引入了一些新概念,以帮助我们以正式和高效的方式管理和调试应用程序的状态。

当您的应用程序增长时,您或您的团队将添加许多更多的功能和组件(可能超过一百个)。其中许多将共享数据。随着组件之间相互连接的复杂性增加,您最终会陷入一团糟,有太多组件需要保持同步的数据。在这一点上,您的应用程序状态将不再可预测和可理解,您的应用程序将变得非常难以发展或维护。例如,想象一下,在组件树中深藏的四五个组件中的一个按钮需要打开位于远处的侧边栏--您可能需要使用大量事件和 props 通过许多组件传递信息。您实际上有两个数据源,这意味着两个组件共享数据,必须以某种方式同步,否则您的应用程序将崩溃,因为您不再知道哪个组件是正确的。

这个问题的推荐解决方案是来自 Veu 的 Vuex。它受到了 Facebook 开发的 Flux 概念的启发,这一概念诞生了 Redux 库(在 React 社区中非常知名)。Flux 是一组指导原则,强调通过集中式存储在组件之间使用单向信息流。好处是,您的应用逻辑和流程将更容易理解,因此大大提高了可维护性。缺点是您可能需要理解一些新概念,并且可能需要写更多的代码。Vuex 有效地实现了这些原则中的一些,以帮助您改进应用程序的架构。

一个真实的例子是 Facebook 的通知系统--聊天系统已经足够复杂,以至于很难确定您已经看过哪条消息。有时,您可能会收到一条您已经阅读过的新消息的通知,因此 Facebook 致力于通过改变应用程序架构来解决这个问题。

对于我们的第一个示例,按钮和侧面板组件不需要在整个应用程序中同步其状态。相反,它们使用集中式存储获取数据并分派操作--这意味着它们不需要彼此了解,也不依赖其祖先或子组件来同步其数据。这意味着现在有一个单一的真相来源,即集中式存储--您不再需要在组件之间同步数据。

我们现在将围绕 Vuex 库及其原则来设计我们的应用程序。

Vuex 建议用于大多数应用程序,但如果不必要,您不必使用它,例如原型或简单小部件等非常小的项目。

Vuex 存储库

Vuex 的中心元素是存储库。它是一个特殊对象,允许您将应用程序的数据集中到遵循良好设计模式并有助于防止我们在上一节中看到的错误的模型中。它将是我们的数据的主要架构以及我们对其的处理方式。

存储库包含以下内容:

  • 状态,这是一个包含应用程序状态的响应式数据对象

  • 获取器,这是存储库的计算属性等效物

  • 变异,用于修改应用程序状态的函数

  • 操作,通常调用异步 API,然后变异的函数

因此,存储库应该如下所示:

这是很多新词汇需要理解的,所以让我们在学习这些新概念的同时创建一个存储库。您会发现这并不像看起来那么困难:

  1. 使用'npm i -S vuex'命令下载 vuex。创建一个新的store文件夹,并添加一个安装 Vuex 插件的index.js文件:
      import Vue from 'vue'
      import Vuex from 'vuex'

      Vue.use(Vuex)
  1. 使用Vuex.Store构造函数创建存储库:
      const store = new Vuex.Store({
        // TODO Options
      })
  1. 像路由器一样将其默认导出:
      export default store
  1. 在主main.js文件中,导入存储库:
      import store from './store'

Webpack 将检测到store是一个文件夹,并将自动导入其中的index.js文件。

  1. 要在我们的应用程序中启用存储库,我们需要像路由器一样注入它:
      new Vue({
        ...App,
        el: '#app',
        router,
        // Injected store
        store,
      })
  1. 所有组件现在都可以通过$store特殊属性访问存储库,类似于vue-router特殊对象,如$router$route。例如,您可以在组件内部编写以下内容:
    this.$store

状态是真相的来源

存储的主要部分是它的状态。它代表了应用程序中组件之间共享的数据。第一个原则是--这是您共享数据的唯一数据源。由于组件将从中读取数据,并且它将始终是正确的。

目前,状态将只有一个user属性,其中包含已登录用户的数据:

  1. 在存储选项中,将一个返回对象的函数添加到状态中:
      const store = new Vuex.Store({
        state () {
          return {
            user: null,
          }
        },
      })

另一个非常重要的原则是--状态是只读的。您不应直接修改状态,否则将失去使用 Vuex 的好处(使共享状态易于理解)。如果有很多组件在应用程序的任何地方随意修改状态,那么将更难以跟踪数据流并使用开发工具进行调试。更改状态的唯一有效方式是通过 mutations,我们将很快看到。

  1. 为了尝试读取状态,让我们在components文件夹中创建AppMenu.vue组件。它将显示用户信息,center-on-user按钮和logout按钮:
      <template>
        <div class="app-menu">
          <div class="header">
            <i class="material-icons">place</i>
            GeoBlog
          </div>

          <div class="user">
            <div class="info" v-if="user">
              <span class="picture" v-if="userPicture">
                <img :src="userPicture" />
              </span>
              <span class="username">{{ user.profile.displayName }}
              </span>
            </div>
            <a @click="centerOnUser"><i class="material-
            icons">my_location</i>                  
            </a>
            <a @click="logout"><i class="material-
            icons">power_settings_new</i>              
            </a>
          </div>
        </div>
      </template>

      <script>
      export default {
        computed: {
          user () {
            return this.$store.state.user
          },
          userPicture () {
            return null // TODO
          },
        },
        methods: {
          centerOnUser () {
            // TODO
          },
          logout () {
            // TODO
          },
        },
      }
      </script>

user对象将具有来自 Google 的 profile 属性,其中包含用户的显示名称和照片。

  1. GeoBlog.vue中添加这个新的AppMenu组件:
      <template>
        <div class="geo-blog">
          <AppMenu />
          <!-- Map & content here -->
        </div>
      </template>

      <script>
      import AppMenu from './AppMenu.vue'

      export default {
        components: {
          AppMenu,
        },
      }
      </script>

目前我们的用户没有登录,所以什么也没有显示。

Mutations 更新状态

由于我们将状态视为只读,修改它的唯一方式是通过 mutations。Mutation 是一个同步函数,它以状态作为第一个参数和一个可选的有效负载参数,然后更新状态。这意味着您不允许在 mutation 中执行异步操作(比如向服务器发出请求)。

  1. 让我们添加我们的第一个 mutation,类型为'user',它将更新状态中的用户:
      const store = new Vuex.Store({
        state () { /* ... */ },

        mutations: {
          user: (state, user) => {
            state.user = user
          },
        },
      })

Mutations 非常类似于事件--它们有一个类型(这里是'user')和一个处理程序函数。

用于指示我们正在调用 mutation 的词是commit。我们不能直接调用它们--就像事件一样,我们要求存储触发与特定类型对应的 mutations。

要调用我们的 mutation 处理程序,我们需要使用commit存储方法:

store.commit('user', userData)
  1. 让我们在AppMenu组件的logout函数中尝试这样做,这样我们就可以测试 mutation:
      logout () {
        // TODO
        if (!this.user) {
          const userData = {
            profile: {
              displayName: 'Mr Cat',
            },
          }
          this.$store.commit('user', userData)
        } else {
          this.$store.commit('user', null)
        }
      },

现在,如果您点击注销按钮,您应该看到用户信息被切换。

严格模式

出于调试原因,突变是同步的。状态处理方式使得很容易跟踪和调试应用程序中的故障行为,因为开发工具可以对其进行快照。但是,如果您的突变进行异步调用,那么调试器无法知道突变之前和之后的状态,使其无法追踪:

  1. 为了帮助您避免在同步突变之外修改状态,您可以这样启用严格模式:
      const store = new Vuex.Store({
        strict: true,
        // ...
      })

当状态在同步突变之外被修改时,这将引发错误,阻止调试工具正常工作。

您不应该在生产中启用严格模式,因为它会影响性能。使用这个表达式来做到这一点--strict: process.env.NODE_ENV !== 'production',这将确保NODE_ENV标准环境变量告诉您处于哪种开发模式(通常是开发、测试或生产)。

  1. 让我们尝试直接在logout测试方法中更改状态:
      logout () {
        if (!this.user) {
          // ...
          this.$store.state.user = userData
        } else {
          this.$store.state.user = null
        }
      },

然后再次点击登出按钮并打开浏览器控制台--您应该看到 Vuex 抛出了错误,因为您正在修改状态而不是通过正确的突变:

时间旅行调试

使用 Vuex 方法的一个好处是调试体验。在更复杂的应用程序中,这对于逐个突变跟踪应用程序状态非常有用。

回到logout方法中的突变调用。点击几次登出按钮,然后打开 Vue 开发工具并打开 Vuex 选项卡。您应该看到一系列提交到存储的突变列表:

在右侧,您可以看到为所选突变记录的状态及其有效负载(传递给它的参数)。

您可以通过悬停在突变上并单击时间旅行图标按钮来返回任何状态快照:

您的应用程序将恢复到最初的状态!现在您可以逐步回放应用程序状态的演变,就像突变被提交一样。

Getter 计算并返回数据

Getter 就像计算属性一样工作。它们是接受状态和 getter 作为参数的函数,并返回一些状态数据:

  1. 让我们创建一个返回状态中保存的用户的user getter:
      const store = new Vuex.Store({
        // ...
        getters: {
          user: state => state.user,
        },
      })
  1. 在我们的AppMenu组件中,我们可以使用这个 getter 来代替直接访问状态:
      user () {
        return this.$store.getters.user
      },

这似乎与以前没有什么不同。但直接访问状态并不推荐——你应该始终使用 getter,因为它允许你修改获取数据的方式,而无需更改使用它的组件。例如,你可以更改状态的结构并调整相应的 getter,而不会对组件产生影响。

  1. 让我们还添加一个userPicture getter,我们将在有真实的 Google 个人资料时实现它:
      userPicture: () => null,
  1. AppMenu组件中,我们已经可以使用它:
      userPicture () {
        return this.$store.getters.userPicture
      },

存储操作的操作

组成存储的最终元素是动作。它们与突变不同,因为它们不直接修改状态,但它们可以提交突变并进行异步操作。与突变类似,动作是用类型和处理程序声明的。处理程序不能直接调用,你需要像这样调度一个动作类型:

store.dispatch('action-type', payloadObject)

动作处理程序需要两个参数:

  • context,它提供了与存储相关的commitdispatchstategetters实用程序

  • 有效负载,这是提供给dispatch调用的参数

  1. 让我们添加我们的第一个动作,类型为'login'和'logout',它们不需要有效负载:
      const store = new Vuex.Store({
        // ...
        actions: {
          login ({ commit }) {
            const userData = {
             profile: {
                displayName: 'Mr Cat',
              },
            }
            commit('user', userData)
          },

          logout ({ commit }) {
            commit('user', null)
          },
        }
      })
  1. AppMenu组件中,我们可以通过替换与两个按钮对应的方法的代码来测试它们:
      methods: {
        centerOnUser () {
          // TODO
          // Testing login action
          this.$store.dispatch('login')
        },
        logout () {
          this.$store.dispatch('logout')
        },
      },

现在,如果你点击菜单中的按钮,你应该看到用户资料出现和消失。

与 getter 类似,你应该始终在组件内部使用动作而不是突变。你的应用程序的功能很可能会发展,所以能够更改动作代码而不是组件代码是一个好主意(例如,如果你需要调用一个新的额外突变)。把动作看作是你的一般应用逻辑的抽象。

映射助手

Vuex 提供了一些辅助函数来添加状态、getter、mutation 和 action。由于我们应该只在组件中使用 getter 和 action 来帮助将状态和相关逻辑与组件分离,所以我们只会使用mapGettersmapActions

这些函数为依赖于存储中相应 getter 和动作的组件生成适当的计算属性和方法,因此您不必每次都输入this.$store.gettersthis.$store.dispatch。参数要么是:

  • 与组件相同名称的映射的类型数组

  • 一个对象,其键是组件上的别名,值是类型

例如,使用数组语法的以下代码:

mapGetters(['a', 'b'])

在组件中相当于这个:

{
  a () { return this.$store.getters.a },
  b () { return this.$store.getters.b },
}

并使用对象语法的以下代码:

mapGetters({ x: 'a', y: 'b' })

相当于这个:

{
  x () { return this.$store.getters.a },
  y () { return this.$store.getters.b },
}

让我们重构我们的AppMenu组件来使用这些帮助程序:

  1. 首先在组件中导入这些:
      import { mapGetters, mapActions } from 'vuex'
  1. 然后,我们可以像这样重写组件:
      export default {
        computed: mapGetters([
          'user',
          'userPicture',
        ]),
        methods: mapActions({
          centerOnUser: 'login',
          logout: 'logout',
        }),
      }

现在,组件将有两个计算属性,返回相应的存储器 getter,并且两个方法分派'login''logout'动作类型。

用户状态

在这一部分,我们将添加用户系统,允许用户使用他们的 Google 账户登录。

设置 Google OAuth

在我们可以使用 Google API 之前,我们必须在 Google 开发者控制台中配置一个新项目:

  1. 转到console.developers.google.com的开发者控制台。

  2. 使用页面顶部的项目下拉菜单创建一个新项目,并给它命名。项目创建完成后,选择它。

  3. 要检索用户配置文件,我们需要启用 Google+ API。转到 API 和服务 | 图书馆,然后在社交 API 部分下点击 Google+ API。在 Google+ API 页面上,点击启用按钮。然后你应该看到一个带有一些空图表的使用仪表板。

  4. 接下来,我们需要创建应用凭据,以便将我们的服务器验证到 Google。转到 API 和服务 | 凭据,然后选择 OAuth 同意屏幕选项卡。确保你选择一个电子邮件地址,并输入一个向用户显示的产品名称

  5. 选择凭证选项卡,点击创建凭证下拉菜单,然后选择 OAuth 客户端 ID。选择 Web 应用程序作为应用程序类型,然后在授权的 JavaScript 起源字段中输入服务器将启动的 URL。目前,它将是http://localhost:3000。按下Enter键将其添加到列表中。然后将 Google 登录屏幕后 Google 将重定向用户的 URL 添加到授权重定向 URI--http://localhost:3000/auth/google/callback并按下Enter键。此 URL 对应服务器上的特殊路由。完成后,点击创建客户端 ID 按钮。

  1. 然后复制或下载包含客户端 ID 和秘钥的凭证,这些信息不应该与团队外的任何人分享。这两个密钥将允许 Google API 对您的应用进行身份验证,并在用户通过 Google 登录页面登录时显示其名称。

  2. 下载项目的 API 服务器(github.com/Akryum/packt-vue-project-guide/tree/master/chapter6-full/server),并将其提取到Vue app目录之外。在这个新文件夹中打开一个新的终端,并使用通常的命令安装服务器依赖项。

 npm install
  1. 接下来,您需要导出两个GOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRET环境变量,并在从 Google Developers Console 下载的凭证文件中使用相应的值。例如,在 Linux 上:
      export GOOGLE_CLIENT_ID=xxx
      export GOOGLE_CLIENT_SECRET=xxx

或者在 Windows 上:

      set GOOGLE_CLIENT_ID=xxx
      set GOOGLE_CLIENT_SECRET=xxx

每次您想要在新的终端会话中启动服务器时都需要这样做。

  1. 您可以使用start脚本启动服务器:
 npm run start

登录按钮

Login组件包含应该打开弹出窗口显示 Google 登录页面的按钮。弹出窗口将首先加载 Node.js 服务器上的路由,然后将重定向到 Google OAuth 页面。当用户登录并授权我们的应用程序时,弹出窗口将再次重定向到我们的 nodejs 服务器,并在关闭之前向主页面发送消息:

  1. 编辑openGoogleSignin方法以在服务器上打开到/auth/google路由的弹出窗口,该路由将重定向用户到 Google:
 openGoogleSignin () {
        const url = 'http://localhost:3000/auth/google'
        const name = 'google_login'
        const specs = 'width=500,height=500'
        window.open(url, name, specs)
      },

用户通过 Google 成功验证后,服务器上的回调页面将使用标准 postMessage API 向 Vue 应用窗口发送消息。

当我们收到消息时,我们需要检查它是否来自正确的域(我们服务器的localhost:3000)。

  1. 创建一个带有解构消息参数的新handleMessage方法:
 handleMessage ({data, origin}) {
        if (origin !== 'http://localhost:3000') {
          return
        }

        if (data === 'success') {
          this.login()
        }
      },
  1. 我们将向存储分发'login'动作类型,它将很快获取用户数据。将其映射到组件:
      import { mapActions } from 'vuex'

      export default {
        methods: {
          ...mapActions([
            'login',
          ]),

          // ...
        },
      }
  1. 然后我们使用mounted生命周期钩子(在方法之外)向窗口添加事件侦听器:
 mounted () {
        window.addEventListener('message', this.handleMessage)
      },
  1. 最后,在组件被销毁时,不要忘记删除此侦听器:
 beforeDestroy () {
        window.removeEventListener('message', this.handleMessage)
      },

存储中的用户

存储将有两个与用户相关的动作--loginlogout。我们已经有了它们,现在我们需要实现它们将做什么。我们还将在本节中添加一些与用户相关的功能,例如在应用程序启动时加载用户会话并在顶部栏显示其个人资料图片:

  1. 让我们在存储中实现login动作。它将获取用户数据,就像我们在第五章中的项目 3-支持中心中所做的那样,然后将数据commit到状态中(不要忘记导入$fetch):
      async login ({ commit }) {
        try {
          const user = await $fetch('user')
          commit('user', user)

          if (user) {
            // Redirect to the wanted route if any or else to home
            router.replace(router.currentRoute.params.wantedRoute ||
              { name: 'home' })
          }
        } catch (e) {
          console.warn(e)
        }
      },

如您所见,动作可以执行异步操作,例如在此处向服务器请求数据。如果用户已连接,我们将重定向他们到他们想要的页面或主页,就像我们在第五章中的项目 3-支持中心中所做的那样。

  1. 'logout'动作需要向服务器发送/logout请求,并且如果当前路由是私有的,则将用户重定向回登录页面:
      logout ({ commit }) {
        commit('user', null)

        $fetch('logout')

        // If the route is private
        // We go to the login screen
        if (router.currentRoute.matched.some(r => r.meta.private)) {
          router.replace({ name: 'login', params: {
            wantedRoute: router.currentRoute.fullPath,
          }})
        }
      },

根据我们在router.js文件中放置的信息,如果用户在'home'路由上,将被重定向到登录页面。

调整路由

现在我们需要像在第五章中的项目 3-支持中心一样,将导航守卫恢复到路由中,这样用户就无法在未连接时进入私有路由:

router.js文件中,通过使用user存储获取器恢复beforeEach导航守卫,以检查用户是否已连接。这应该与我们已经实现的那个非常相似:

      import store from './store'

      router.beforeEach((to, from, next) => {
        console.log('to', to.name)
        const user = store.getters.user
        if (to.matched.some(r => r.meta.private) && !user) {
          next({
            name: 'login',
            params: {
              wantedRoute: to.fullPath,
            },
          })
          return
        }
        if (to.matched.some(r => r.meta.guest) && user) {
          next({ name: 'home' })
          return
        }
        next()
      })

调整 fetch 插件

由于我们需要在会话过期时注销用户,$fetch插件也需要一些更改:

  1. 在这种情况下,我们只需要分发'logout'动作:
      } else if (response.status === 403) {
        // If the session is no longer valid
        // We logout
        store.dispatch('logout')
      } else {
  1. 不要忘记导入存储:
      import store from '../store'

您现在可以尝试通过 Google 登录到您的应用程序!

检查用户会话的开始

应用程序启动时,我们希望检查用户是否有活动会话,就像我们在第五章中所做的那样,项目 3 - 支持中心

  1. 为此,我们将在存储中创建一个新的通用的'init'操作;这将分派'login'操作,但最终可能会分派更多操作:
      actions: {
        async init ({ dispatch }) {
          await dispatch('login')
        },

        // ...
      },
  1. main.js文件中,我们现在可以分发并等待此操作:
      async function main () {
        await store.dispatch('init')

        new Vue({
          ...App,
          el: '#app',
          router,
          store,
        })
      }

      main()

现在,您可以通过 Google 登录并在不被带回登录页面的情况下刷新页面。

个人资料图片

最后,我们可以实现userPicture getter,以返回 Google 个人资料的photos数组中包含的第一个值:

userPicture: (state, getters) => {
  const user = getters.user
  if (user) {
    const photos = user.profile.photos
    if (photos.length !== 0) {
      return photos[0].value
    }
  }
},

如您所见,我们可以使用第二个参数在其他 getter 中重用现有的 getter!

当您连接时,应用程序中应显示完整的工具栏:

同步存储和路由器

我们可以使用官方的vuex-router-sync包将路由器集成到存储中。它将在状态(state.route)中公开当前路由,并且每次路由更改时都会提交一个 mutation:

  1. 使用常规命令进行安装:
 npm i -S vuex-router-sync
  1. 要使用它,我们需要在主main.js文件中使用sync方法:
      import { sync } from 'vuex-router-sync'

      sync(store, router)

现在,您可以访问state.route对象,并且时间旅行调试也将适用于路由器。

嵌入 Google 地图

在这第二部分中,我们将在主页上添加一个地图,并通过 Vuex 存储对其进行控制。

安装

要集成 Google 地图,我们将需要一个 API 和一个名为vue-googlemaps的第三方包。

获取 API 密钥

要在我们的应用程序中使用 Google 地图,我们需要启用相应的 API 并生成 API 密钥:

  1. 在 Google 开发者控制台中,返回到 API 和服务|库,然后单击 Google 地图 API 下的 Google 地图 JavaScript API。在 API 页面上,单击启用按钮。

  2. 然后转到凭据并创建一个新的 API 密钥。

安装库

我们现在将安装vue-googlemaps库,这将帮助我们将 Google 地图集成到我们的应用程序中。

  1. 在应用程序中,使用以下命令安装vue-googlemaps包:
 npm i -S vue-googlemaps
  1. 在主main.js文件中,您可以使用来自 Google 的 API 密钥在应用程序中启用它:
      import VueGoogleMaps from 'vue-googlemaps'

      Vue.use(VueGoogleMaps, {
        load: {
          apiKey: '*your_api_key_here*',
          libraries: ['places'],
        },
      })

我们还指定要加载 Google 地图 Places 库,用于显示位置信息。

现在我们可以访问库的组件了!

  1. App.vue组件中,添加库的样式:
      <style lang="stylus">
      @import '~vue-googlemaps/dist/vue-googlemaps.css'
      @import '../styles/main'
      </style>

我们使用~字符,因为 Stylus 不支持绝对路径。在这里,我们想要访问一个 npm 模块,所以我们添加这个来告诉stylus-loader这是一个绝对路径。

添加地图

地图将是应用程序的主要组件,它将包含:

  • 用户位置指示器

  • 每个帖子的标记

  • 最终正在创建的帖子的“幽灵”标记

我们现在将设置一个简单的地图,将填充主页面:

  1. 创建一个新的BlogMap.vue组件,具有centerzoom属性:
      <template>
        <div class="blog-map">
          <googlemaps-map
            :center="center"
            :zoom="zoom"
            :options="mapOptions"
            @update:center="setCenter"
            @update:zoom="setZoom"
          />
        </div>
      </template>

      <script>
      export default {
        data () {
          return {
            center: {
              lat: 48.8538302,
              lng: 2.2982161,
            },
            zoom: 15,
          }
        },

        computed: {
          mapOptions () {
            return {
              fullscreenControl: false,
            }
          },
        },

        methods: {
         setCenter (value) {
            this.center = value
          },
          setZoom (value) {
            this.zoom = value
          },
        },
      }
      </script>
  1. 然后,您需要将其添加到GeoBlog.vue组件中:
      <template>
        <div class="geo-blog">
          <AppMenu />
          <div class="panes">
            <BlogMap />
            <!-- Content here -->
          </div>
        </div>
      </template>

不要忘记导入它并将其放入components选项中!

连接 BlogMap 和 store

目前与地图相关的状态是局部的BlogMap组件--让我们将其移到 store 中!

Vuex 模块

在 Vuex store 中,我们可以将我们的状态分成模块,以便更好地组织。一个模块包含一个状态、getter、mutation 和 action,就像主 store 一样。store 和每个模块都可以包含任意数量的模块,因此 store 可以在其他模块内包含嵌套模块--这取决于您找到适合项目的最佳结构。

在此应用程序中,我们将创建两个模块:

  • 与地图相关的maps

  • 与博客帖子和评论相关的posts

现在,我们将专注于maps模块。最好至少将每个模块分开放在不同的文件或目录中:

  1. store文件夹中创建一个新的maps.js文件,将模块定义和地图的状态作为默认导出:
      export default {
        namespaced: true,

        state () {
          return {
            center: {
              lat: 48.8538302,
              lng: 2.2982161,
            },
            zoom: 15,
          }
        },
      }
  1. 然后将模块添加到 store 中,在store/index.js文件中的新modules选项中放置它:
      import maps from './maps'

      const store = new Vuex.Store({
        // ...
        modules: {
          maps,
        },
      })

默认情况下,模块的 getter、mutation 和 action 的状态将是此模块的状态。这里将是store.state.maps

命名空间模块

namespaced选项告诉 Vuex 在模块的 getter、mutation 和 action 类型之前也添加'maps/'命名空间。它还会将它们添加到命名空间模块内的commitdispatch调用中。

让我们添加一些 getter,这些 getter 将被BlogMap组件使用:

getters: {
  center: state => state.center,
  zoom: state => state.zoom,
},

maps/centermaps/zoom getter 将被添加到 store 中。要读取它们,您可以这样做:

this.$store.getters['maps/center']

使用 getter 帮助程序:

mapGetters({
  center: 'maps/center',
  zoom: 'maps/zoom',
})

您还可以指定命名空间参数:

...mapGetters('maps', [
  'center',
  'zoom',
]),
...mapGetters('some/nested/module', [
  // ...
]),

这样做的最后一种方法是使用createNamespacedHelpers方法基于特定命名空间生成帮助程序:

import { createNamespacedHelpers } from vuex
const { mapGetters } = createNamespacedHelpers('maps')

export default {
  computed: mapGetters([
    'center',
    'zoom',
  ]),
}

访问全局元素

在命名空间模块的 getter 中,你可以像这样访问根状态和根 getter(也就是任何 getter):

someGetter: (state, getters, rootState, rootGetters) => { /* ... */ }

在动作中,你可以在上下文中访问rootGetters,并且你可以在commitdispatch调用中使用{ root: true }选项:

myAction ({ dispatch, commit, getters, rootGetters }) {
  getters.a // store.getters['maps/a']
  rootGetters.a // store.getters['a']
  commit('someMutation') // 'maps/someMutation'
  commit('someMutation', null, { root: true }) // 'someMutation'
  dispatch('someAction') // 'maps/someAction'
  dispatch('someAction', null, { root: true }) // 'someAction'
}

BlogMap 模块和组件

在这一部分,我们将把BlogMap组件与maps命名空间模块连接起来。

变化

让我们在maps模块中添加centerzoom的变化:

mutations: {
  center (state, value) {
    state.center = value
  },
  zoom (state, value) {
    state.zoom = value
  },
},

动作

然后,我们设置提交这些变化的动作:

actions: {
  setCenter ({ commit }, value) {
    commit('center', value)
  },

  setZoom ({ commit }, value) {
    commit('zoom', value)
  },
},

组件中的映射

回到我们的BlogMap组件;我们可以使用辅助工具来映射 getter 和动作:

import { createNamespacedHelpers } from 'vuex'

const {
  mapGetters,
  mapActions,
} = createNamespacedHelpers('maps')

export default {
  computed: {
    ...mapGetters([
      'center',
      'zoom',
    ]),

    mapOptions () {
      // ...
    },
  },

  methods: mapActions([
    'setCenter',
    'setZoom',
  ]),
}

现在地图的状态是在 Vuex 存储中管理的!

用户位置

现在,我们将添加用户位置指示器,这样我们就可以获取位置并将其存储在存储中:

  1. 在地图中添加googlemaps-user-position组件:
      <googlemaps-map
        ...
      >
        <!-- User Position -->
        <googlemaps-user-position
          @update:position="setUserPosition"
        />
      </googlemaps-map>
  1. 现在我们需要在maps模块中添加userPosition信息:
      state () {
        return {
          // ...
          userPosition: null,
        }
      },
      getters: {
        // ...
        userPosition: state => state.userPosition,
      },
      mutations: {
        // ...
        userPosition (state, value) {
          state.userPosition = value
        },
      },
      actions: {
        // ...
        setUserPosition ({ commit }, value) {
          commit('userPosition', value)
        },
      }
  1. 然后在BlogMap组件中映射setUserPosition的动作,使用适当的辅助工具。

现在我们应该在存储中提交用户位置(假设你已经给予浏览器访问你位置的权限)。

居中用户

这个用户位置将非常有用,可以把地图居中在用户位置上:

  1. 让我们在maps模块中创建一个新的centerOnUser动作:
      async centerOnUser ({ dispatch, getters }) {
        const position = getters.userPosition
        if (position) {
          dispatch('setCenter', position)
        }
      },

有了这个,我们还可以改变setUserPosition的动作——如果这是我们第一次获取用户位置(也就是在状态中是null),我们应该把地图居中在用户位置上。

  1. setUserPosition动作现在应该是这样的:
      setUserPosition ({ dispatch, commit, getters }, value) {
        const position = getters.userPosition
        commit('userPosition', value)
        // Initial center on user position
        if (!position) {
          dispatch('centerOnUser')
        }
      },

现在你可以尝试一下,地图会以一个小蓝点居中在你的位置上。

默认情况下,如果你的位置精度超过 1,000 米,用户指示器将被禁用,所以这取决于你的硬件,可能不起作用。你可以使用googlemaps-user-position组件的minmumAccuracy属性来使用更高的值。

  1. 我们在工具栏中还有一个“居中用户”按钮,所以我们需要替换AppMenu组件中的centerOnUser动作映射:
      methods: mapActions({
        logout: 'logout',
        centerOnUser: 'maps/centerOnUser',
      }),

博客文章和评论

在最后一部分中,我们将把博客内容添加到应用程序中。每篇博客文章都将有一个位置和一个来自 Google 地图的可选地点 ID(因此可以描述地点,例如“餐厅 A”)。我们将加载适合地图可见范围的帖子,并且每个帖子都将显示为带有自定义图标的标记。单击标记时,右侧面板将显示帖子内容和评论列表。单击地图上的其他任何位置将在 Vuex 存储中创建一个草稿帖子,并显示一个表单来编写其内容并将其保存在右侧面板中。

帖子存储模块

让我们首先创建一个新的posts命名空间的 Vuex 模块,以管理与博客帖子相关的共享数据:

  1. 创建一个新的store/posts.js文件,其中包含以下状态属性:
      export default {
        namespaced: true,

        state () {
          return {
            // New post being created
            draft: null,
            // Bounds of the last fetching
            // To prevent refetching
            mapBounds: null,
            // Posts fetched in those map bounds
            posts: [],
            // ID of the selected post
            selectedPostId: null,
          }
        },
      }
  1. 接下来我们需要一些 getter:
 getters: {
        draft: state => state.draft,
        posts: state => state.posts,
        // The id field on posts is '_id' (MongoDB style)
        selectedPost: state => state.posts.find(p => p._id ===                  
        state.selectedPostId),
        // The draft has more priority than the selected post
        currentPost: (state, getters) => state.draft || 
        getters.selectedPost,
      },
  1. 还有一些 mutations(请注意,我们同时改变postsmapBounds,以便它们保持一致):
 mutations: {
        addPost (state, value) {
          state.posts.push(value)
        },

        draft (state, value) {
          state.draft = value
        },

        posts (state, { posts, mapBounds }) {
          state.posts = posts
          state.mapBounds = mapBounds
        },

        selectedPostId (state, value) {
          state.selectedPostId = value
        },

        updateDraft (state, value) {
          Object.assign(state.draft, value)
        },
      },
  1. 最后,像我们为maps模块做的那样将其添加到商店中:
      import posts from './posts'

      const store = new Vuex.Store({
        // ...
        modules: {
          maps,
          posts,
        },
      })

渲染函数和 JSX

在第四章中,高级项目设置,我已经写过关于渲染函数和 JSX 的内容,这些是除了模板之外编写组件视图的不同方式。在继续之前,我们将更详细地了解这些内容,然后将它们付诸实践。

使用渲染函数在 JavaScript 中编写视图

Vue 将我们的模板编译成render函数。这意味着所有组件视图最终都是 JavaScript 代码。这些渲染函数将组成要在页面真实 DOM 中显示的虚拟 DOM 树。

大多数情况下,模板都很好用,但您可能会遇到需要使用 JavaScript 的全部编程能力来创建组件视图的情况。您可以编写一个render函数来代替指定模板给您的组件。例如:

export default {
  props: ['message'],
  render (createElement) {
    return createElement(
      // Element or Component
      'p',
      // Data Object
      { class: 'content' },
      // Children or Text content
      this.message
    )
  },
}

第一个参数是createElement,这是您需要调用以创建元素(可以是 DOM 元素或 Vue 组件)的函数。它最多接受三个参数:

  • element(必需),可以是 HTML 标签的名称,已注册组件的 ID,或者直接是组件定义对象。它可以是返回其中一个的函数。

  • data(可选)是数据对象,用于指定诸如 CSS 类、props、事件等内容。

  • children(可选)可以是文本字符串,也可以是使用createElement构建的子元素数组。

我们将使用h作为createElement的别名,render函数的参数,因为这是每个人都使用的常用名称(并且在稍后我们将看到,它是 JSX 所必需的)。h来自于描述“使用 JavaScript 编写 HTML”的超文本术语。

第一个示例将等同于此模板:

<template>
  <p class="content">{{ message }}</p>
</template>

动态模板

直接编写渲染函数的主要优势在于它们更接近编译器,并且您可以充分利用 JavaScript 的全部功能来操作模板。显而易见的缺点是它看起来不再像 HTML,但这可以通过 JSX 来缓解,我们将在什么是 JSX部分中看到。

例如,您可以创建一个在任何级别渲染标题的组件:

Vue.component('my-title', {
  props: ['level'],
  render (h) {
    return h(
      // Tag name
      `h${this.level}`,
      // Default slot content
      this.$slots.default,
    )
  }
})

在这里,我们省略了数据对象参数,因为它是可选的。我们只传递了标签名称和内容。

然后,例如,我们可以在我们的模板中使用它来渲染一个<h2>标题元素:

<my-title level="2">Hello</my-title>

在模板中的等效写法将会相当冗长:

<template>
  <h1 v-if="level === 1">
    <slot></slot>
  </h1>
  <h2 v-else-if="level === 2">
    <slot></slot>
  </h2>
  <h3 v-else-if="level === 3">
    <slot></slot>
  </h3>
  <h4 v-else-if="level === 4">
    <slot></slot>
  </h4>
  <h5 v-else-if="level === 5">
    <slot></slot>
  </h5>
  <h6 v-else-if="level === 6">
    <slot></slot>
  </h6>
</template>

数据对象

第二个可选参数是数据对象,它允许您传递有关要传递给createElement(或h)的元素的其他信息。例如,您可以以与我们在经典模板中使用v-bind:class指令相同的方式指定 CSS 类,或者可以添加事件侦听器。

这是一个覆盖大多数功能的数据对象的示例:

{
  // Same API as `v-bind:class`
  'class': {
    foo: true,
    bar: false
  },
  // Same API as `v-bind:style`
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // Normal HTML attributes
  attrs: {
    id: 'foo'
  },
  // Component props
  props: {
    myProp: 'bar'
  },
  // DOM properties
  domProps: {
    innerHTML: 'baz'
  },
  // Event handlers are nested under "on", though
  // modifiers such as in v-on:keyup.enter are not
  // supported. You'll have to manually check the
  // keyCode in the handler instead.
  on: {
    click: this.clickHandler
  },
  // For components only. Allows you to listen to
  // native events, rather than events emitted from
  // the component using vm.$emit.
  nativeOn: {
    click: this.nativeClickHandler
  },
  // Custom directives. Note that the binding's
  // oldValue cannot be set, as Vue keeps track
  // of it for you.
  directives: [
    {
      name: 'my-custom-directive',
      value: '2'
      expression: '1 + 1',
      arg: 'foo',
      modifiers: {
        bar: true
      }
    }
  ],
  // The name of the slot, if this component is the
  // child of another component
  slot: 'name-of-slot'
  // Other special top-level properties
  key: 'myKey',
  ref: 'myRef'
}

例如,如果标题级别低于特定数字,我们可以应用特殊的 CSS 类:

Vue.component('my-title', {
  props: ['level'],
  render (h) {
    return h(
      // Tag name
      `h${this.level}`,
      // Data object
      {
        'class': {
          'important-title': this.level <= 3,
        },
      },
      // Default slot content
      this.$slots.default,
    )
  }
})

我们还可以添加一个点击事件侦听器,调用组件的一个方法:

Vue.component('my-title', {
  props: ['level'],
  render (h) {
    return h(
      // Tag name
      `h${this.level}`,
      // Data object
      {
        on: {
          click: this.clickHandler,
        },
      },
      // Default slot content
      this.$slots.default,
    )
  },
  methods: {
    clickHandler (event) {
      console.log('You clicked')
    },
  },
})

您可以在官方文档中找到此对象的完整描述(vuejs.org/v2/guide/render-function.html#The-Data-Object-In-Depth)。

正如我们所见,Vue 在我们的模板底层使用纯 JavaScript 的渲染函数!我们甚至可以编写自己的渲染函数,使用createElement(或h)函数来构造要添加到虚拟 DOM 中的元素。

这种编写视图的方式比模板更灵活、更强大,但也更复杂、更冗长。当您感到舒适时,请使用它!

虚拟 DOM

render函数的结果是使用createElement(或h)函数创建的节点树;在 Vue 中,这些被称为VNodes。它代表了 Vue 持有的虚拟 DOM 中组件的视图。DOM 中的每个元素都是一个节点--HTML 元素、文本,甚至注释都是节点:

Vue 不会直接用新的虚拟 DOM 树替换真实的 DOM 树,因为这可能会产生大量的 DOM 操作(添加或删除节点),这是昂贵的。为了更高效,Vue 将在两个树之间创建差异,并且只会执行必要的 DOM 操作,以使真实 DOM 与虚拟 DOM 匹配。

所有这些都是自动发生的,这样 Vue 在应用程序中的数据发生变化时,就可以保持真实 DOM 的最新状态。

什么是 JSX?

JSX 是一种语言,用于在render函数的 JavaScript 代码中编写更像 HTML 的代码。它实际上是 JavaScript 的一种类似 XML 的扩展。我们之前的例子在 JSX 中看起来是这样的:

export default {
  props: ['message'],
  render (h) {
    return <p class="content">
      {this.message}
    </p>
  },
}

这得益于 Babel,这个库负责将我们的 ES2015 JavaScript(或更高版本)代码编译成旧的 ES5 JavaScript,这样就可以在旧浏览器(如 Internet Explorer)中运行。Babel 还可以用来实现 JavaScript 语言的新功能(比如可能出现在以后版本中的草案功能)或者完全新的扩展,比如 JSX。

babel-preset-vue中包含的babel-plugin-transform-vue-jsx负责将 JSX 代码转换为使用h函数的真实 JavaScript 代码。因此,之前的 JSX 示例将被转换回:

export default {
  props: ['message'],
  render (h) {
    return h('p', { class: 'content' }, this.message)
  },
}

这就是为什么在使用 JSX 时,我们需要使用h而不是createElement

感谢,vue-cli 已经启用了这个功能,所以我们可以在.vue文件中编写 JSX 代码!

博客内容结构(在 JSX 中!)

让我们创建一个新的src/components/content文件夹,并在其中创建一个新的BlogContent.vue文件。这个组件代表右侧面板,负责显示右侧组件:

  • 如果在地图上选择了位置,则可能会显示位置地址和名称的LocationInfo.vue组件

  • 下面,它将显示以下内容之一:

  • 如果没有选择位置,则会出现NoContent.vue组件,点击地图提示

  • 如果有草稿帖子,则会出现CreatePost.vue组件,带有表单

  • 如果选择了真实的帖子,则会出现PostContent.vue组件,带有内容和评论列表

  1. 让我们在content目录中也创建这些组件,带有空模板:
      <template></template>

回到我们的BlogContent.vue组件!我们将使用 JSX 编写这个新组件来练习它。

  1. 首先创建命名空间助手:
      <script>
      import { createNamespacedHelpers } from 'vuex'

      // posts module
      const {
        mapGetters: postsGetters,
        mapActions: postsActions,
      } = createNamespacedHelpers('posts')

      </script>

最好将命名空间助手重命名,因为将来可能会为另一个模块添加助手。例如,如果不这样做,你可能会得到两个mapGetters,这是不可能的。在这里,我们将mapGetters重命名为postsGetters,将mapActions重命名为postsActions

  1. 然后让我们编写组件定义:
      export default {
        computed: {
          ...postsGetters([
            'draft',
            'currentPost',
          ]),

          cssClass () {
            return [
              'blog-content',
              {
                'has-content': this.currentPost,
              },
            ]
          },
        },
      }

has-content CSS 类将在智能手机上使用,当没有选择帖子或没有编辑草稿时,它将隐藏面板(它将全屏显示)。

  1. 接下来,我们需要用 JSX 编写渲染函数:
 render (h) {
        let Content
        if (!this.currentPost) {
          Content = NoContent
        } else if (this.draft) {
          Content = CreatePost
        } else {
          Content = PostContent
        }

        return <div class={this.cssClass}>
          <LocationInfo />
          <Content />
        </div>
      },

不要忘记也导入其他四个组件!

在 JSX 中,标签的第一个字母的大小写很重要!如果以小写字母开头,它将被视为createElement函数的字符串参数,并将解析为 HTML 元素或已注册的组件(例如<div>)。另一方面,如果第一个字母是大写的,它将被视为变量!在我们之前的代码中,LocationInfo直接从导入中使用。例如:

import LocationInfo from './LocationInfo.vue'

export default {
  render (h) {
    return <LocationInfo />
  }
}

我们还使用这个来动态选择将显示哪个组件,感谢Component本地变量(注意大写的C)。如果变量名的第一个字母是小写的话,它是行不通的。

  1. 让我们将GeoBlog.vue组件改写为 JSX,并添加BlogContent组件:
      <script>
      import AppMenu from './AppMenu.vue'
      import BlogMap from './BlogMap.vue'
      import BlogContent from './content/BlogContent.vue'

      export default {
        render (h) {
          return <div class="geo-blog">
            <AppMenu />
            <div class="panes">
              <BlogMap />
              <BlogContent />
            </div>
          </div>
        }
      }
      </script>

不要忘记在文件中删除<template>部分!你不能同时拥有渲染函数和模板。

没有内容

在继续之前,让我们快速添加NoContent.vue组件的模板,它只在没有选择帖子时显示提示:

<template>
  <div class="no-content">
    <i class="material-icons">explore</i>
    <div class="hint">Click on the map to add a post</div>
  </div>
</template>

创建帖子

当用户在地图上点击没有标记的位置时,我们创建一个草稿帖子;然后右侧面板中的表单将编辑其内容。当用户点击创建按钮时,我们将草稿发送到服务器,并将结果(新帖子数据)添加到帖子列表中。

草稿存储操作

posts命名空间存储模块中,我们将需要一些新的操作来创建、更新和清除草稿帖子:

添加clearDraftcreateDraftsetDraftLocationupdateDraft操作:

 actions: {
        clearDraft ({ commit }) {
          commit('draft', null)
        },

        createDraft ({ commit }) {
          // Default values
          commit('draft', {
            title: '',
            content: '',
            position: null,
            placeId: null,
          })
        },

        setDraftLocation ({ dispatch, getters }, { position, placeId }) {
          if (!getters.draft) {
            dispatch('createDraft')
          }
          dispatch('updateDraft', {
            position,
            placeId,
          })
        },

        updateDraft ({ dispatch, commit, getters }, draft) {
          commit('updateDraft', draft)
        },
      },

用户点击地图时,我们称之为setDraftLocation的操作将自动创建一个新的草稿,如果没有的话,并更新其位置。

博客地图变更

我们需要对BlogMap组件进行一些更改,以整合我们的 Vuex 存储。

  1. BlogMap.vue组件中,我们可以为posts命名空间模块添加 Vuex 助手,同时重命名我们已经为maps模块拥有的助手:
      // Vuex mappers
      // maps module
      const {
        mapGetters: mapsGetters,
        mapActions: mapsActions,
      } = createNamespacedHelpers('maps')
      // posts module
      const {
        mapGetters: postsGetters,
        mapActions: postsActions,
      } = createNamespacedHelpers('posts')
  1. 添加draft getter:
 computed: {
        ...mapsGetters([
          'center',
          'zoom',
        ]),
        ...postsGetters([
          'draft',
        ]),
        // ...
      },
  1. 也添加setDraftLocation动作:
 methods: {
        ...mapsActions([
          'setCenter',
          'setUserPosition',
          'setZoom',
        ]),

        ...postsActions([
          'setDraftLocation',
        ]),
      },

点击处理程序

我们还需要处理地图上的点击,以创建新的博客帖子。

  1. 在地图上添加click处理程序:
      <googlemaps-map
        :center="center"
        :zoom="zoom"
        :options="mapOptions"
        @update:center="setCenter"
        @update:zoom="setZoom"
        @click="onMapClick"
      >
  1. 添加相应的方法,调度setDraftLocation动作,并带有可能的latLng(位置)和来自 Google Maps 的placeId
 onMapClick (event) {
        this.setDraftLocation({
          position: event.latLng,
          placeId: event.placeId,
        })
      },

现在您可以尝试在地图上点击--两个变化(一个用于创建草稿,一个用于更新其位置)应该在开发工具中记录下来。

幽灵标记

我们想在草稿的位置显示一个透明标记。要使用的组件是googlemaps-marker

googlemaps-map组件中添加一个新的标记,该标记使用draft getter 的信息:

      <!-- New post marker -->
      <googlemaps-marker
        v-if="draft"
        :clickable="false"
        :label="{
          color: 'white',
          fontFamily: 'Material Icons',
          text: 'add_circle',
        }"
        :opacity=".75"
        :position="draft.position"
        :z-index="6"
      />

如果您看不到新的标记,请刷新页面。

尝试在地图上点击,看看幽灵标记的效果:

帖子表单

继续到CreatePost.vue组件!这个组件将显示一个表单,用于输入新帖子的详细信息,比如标题和内容。

  1. 让我们首先用一个简单的表单创建它的模板:
      <template>
        <form
          class="create-post"
          @submit.prevent="handleSubmit">
    <input
            name="title"
            v-model="title"
            placeholder="Title"
            required />

          <textarea
            name="content"
            v-model="content"
            placeholder="Content"
            required />

          <div class="actions">
            <button
              type="button"
              class="secondary"
              @click="clearDraft">
              <i class="material-icons">delete</i>
              Discard
            </button>
            <button
              type="submit"
              :disabled="!formValid">
              <i class="material-icons">save</i>
              Post
            </button>
          </div>
        </form>
      </template>
  1. 然后映射来自posts模块的 Vuex 助手:
      <script>
      import { createNamespacedHelpers } from 'vuex'

      // posts module
      const {
        mapGetters: postsGetters,
        mapActions: postsActions,
      } = createNamespacedHelpers('posts')
      </script>
  1. 添加必要的 getter 和方法:
      export default {
        computed: {
          ...postsGetters([
            'draft',
          ]),
        },
        methods: {
          ...postsActions([
            'clearDraft',
            'createPost', // We will create this one very soon
            'updateDraft',
          ]),
        },
      }
  1. 然后我们将添加一些计算属性,这些属性与表单输入元素绑定,使用v-model指令:
 title: {
        get () {
          return this.draft.title
        },
        set (value) {
          this.updateDraft({
            ...this.draft,
            title: value,
          })
        },
      },

      content: {
        get () {
          return this.draft.content
        },
        set (value) {
          this.updateDraft({
            ...this.draft,
            content: value,
          })
        },
      },

      formValid () {
        return this.title && this.content
      },

如您所见,我们可以用两种方式在此对象表示法中使用计算属性:使用 getter 和使用 setter!这样,我们可以用它们来读取一个值,但也可以轻松地改变它:

  • get()在首次读取计算属性时或者需要重新计算时被调用

  • 当属性被赋值时调用set(value),例如this.a = 'new value'

这在使用 Vuex 和表单时非常有用,因为它允许我们为get部分使用 Vuex getter,为set部分使用 Vuex 动作!

  1. 我们还需要一个handleSubmit方法,该方法调度createPost动作,我们很快将创建它:
      handleSubmit () {
        if (this.formValid) {
          this.createPost(this.draft)
        }
      },

发出请求

我们现在将实现一个动作,将新的地理位置博客帖子发送到服务器。

  1. 让我们在posts Vuex 模块中创建新的createPost动作(不要忘记导入$fetch):
      async createPost ({ commit, dispatch }, draft) {
        const data = {
          ...draft,
          // We need to get the object form
          position: draft.position.toJSON(),
        }

        // Request
        const result = await $fetch('posts/new', {
          method: 'POST',
          body: JSON.stringify(data),
        })
        dispatch('clearDraft')

        // Update the posts list
        commit('addPost', result)
        dispatch('selectPost', result._id)
      },

这是我们迄今为止最复杂的操作!它准备数据(注意我们如何将 Google Maps 的position对象序列化为与 JSON 兼容的普通对象)。然后我们向服务器的/posts/new路径发送 POST 请求,并检索结果,这是新的真实帖子对象(其_id字段已设置)。最后,草稿被清除,新帖子被添加到存储并被选中。

  1. 我们还需要一个新的selectPost操作,这样新帖子将自动被选中:
      async selectPost ({ commit }, id) {
        commit('selectedPostId', id)
        // TOTO fetch the post details (comments, etc.)
      },

现在你可以通过点击地图来创建帖子!

获取帖子

在这一部分,我们将从服务器获取帖子并在地图上显示它们。

存储操作

每当地图边界由于用户平移或缩放地图而改变时,我们都会获取帖子。

获取帖子操作

让我们创建获取帖子的操作,但首先我们需要解决一个问题。以下会发生什么:

  1. 用户移动地图。

  2. 向服务器发出请求 A。

  3. 用户再次移动地图。

  4. 发送请求 B。

  5. 由于某种原因,我们在请求 A 之前收到了请求 B 的响应。

  6. 我们从请求 B 设置帖子列表。

  7. 收到了请求 A 的响应。

  8. 帖子列表被替换为不再是最新的请求。

这就是为什么我们需要在发出新请求时中止先前的请求。为了做到这一点,我们将为每个请求使用一个唯一标识符:

  1. posts.js文件的顶部声明唯一标识符:
      let fetchPostsUid = 0
  1. 现在我们可以添加新的fetchPosts操作,它只在地图边界与上次不同的情况下获取帖子(在负载中有一个额外的force参数):
      async fetchPosts ({ commit, state }, { mapBounds, force }) {
        let oldBounds = state.mapBounds
        if (force || !oldBounds || !oldBounds.equals(mapBounds)) {
          const requestId = ++fetchPostsUid

          // Request
          const ne = mapBounds.getNorthEast()
          const sw = mapBounds.getSouthWest()
          const query = `posts?ne=${
            encodeURIComponent(ne.toUrlValue())
          }&sw=${
            encodeURIComponent(sw.toUrlValue())
          }`
          const posts = await $fetch(query)

          // We abort if we started another query
          if (requestId === fetchPostsUid) {
            commit('posts', {
              posts,
              mapBounds,
            })
          }
        }
      },

++fetchPostsUid表达式将 1 添加到fetchPostsUid,然后返回新值。我们将地图边界编码为两个点:东北和西南。

我们中止查询的方式是通过比较我们在发出请求之前存储的唯一 ID(requestId)和当前 ID 计数器(fetchPostsUid)。如果它们不同,我们就不提交结果,因为这意味着另一个请求已经发出(因为我们每次增加计数器)。

操作分派

maps存储中,让我们创建一个setBounds操作,当地图在平移或缩放后处于空闲状态时将被分派。这个操作将从posts模块中分派fetchPosts

  1. 使用{ root: true }选项以非命名空间方式分派操作,这样你就可以访问posts模块:
 setBounds ({ dispatch }, value) {
        dispatch('posts/fetchPosts', {
          mapBounds: value,
        }, {
          root: true,
        })
      },

我们在maps模块中创建了另一个动作,因为它与地图有关,而且将来可能会做更多的事情,而不仅仅是分派另一个动作。

  1. BlogMap.vue组件中,在右侧辅助器上映射新的setBounds动作,并在地图上添加一个'map'引用和一个'idle'事件监听器:
      <googlemaps-map
        ref="map"
        :center="center"
        :zoom="zoom"
        :options="mapOptions"
        @update:center="setCenter"
        @update:zoom="setZoom"
        @click="onMapClick"
        @idle="onIdle"
      >
  1. 并添加相应的onIdle方法来分派setBounds动作并传递地图边界:
 onIdle () {
        this.setBounds(this.$refs.map.getBounds())
      },

刷新应用程序,并在您平移或缩放地图时查看开发工具中的posts突变。

显示标记

仍然在BlogMap组件中,我们将再次使用googlemaps-marker来循环遍历帖子并为每个帖子显示一个标记。在右侧辅助器上映射postscurrentPost获取器,以及selectPost动作,并在googlemaps-map组件内部添加标记循环:

<googlemaps-marker
  v-for="post of posts"
  :key="post._id"
  :label="{
    color: post === currentPost ? 'white' : 'black',
    fontFamily: 'Material Icons',
    fontSize: '20px',
    text: 'face',
  }"
  :position="post.position"
  :z-index="5"
  @click="selectPost(post._id)"
/>

您现在可以刷新应用程序,并看到您之前添加的帖子出现在地图上!如果您点击帖子标记,其图标也应该变成白色。

登录和注销

我们还没有完成帖子获取--我们需要对用户登录或退出做出反应:

  • 当用户注销时,我们将清除帖子列表和上次注册的地图边界,以便可以再次获取帖子

  • 用户登录时,我们将再次获取帖子,并最终重新选择先前选择的帖子

注销

首先,我们将实现注销动作。

  1. 让我们在posts Vuex 模块中添加一个logout动作,清除帖子获取数据:
 logout ({ commit }) {
        commit('posts', {
          posts: [],
          mapBounds: null,
        })
      },
  1. 我们可以从主存储中的logout动作(在store/index.js文件中)调用此动作:
      logout ({ commit, dispatch }) {
        commit('user', null)
        $fetch('logout')
        // ...
        dispatch('posts/logout')
      },

这将起作用,但我们可以改进这段代码--我们可以将posts命名空间子模块的logout动作定义为根动作。这样,当我们分派'logout'动作时,将同时调用logoutposts/logout

  1. posts模块中使用此对象表示法来进行logout动作:
      logout: {
        handler ({ commit }) {
          commit('posts', {
            posts: [],
            mapBounds: null,
          })
        },
        root: true,
      },

handler属性是在此动作上调用的函数,root属性指示此是否为根动作。现在,logout动作在动作分派系统方面不再是命名空间的,并且如果分派了非命名空间的'logout'动作,将被调用。

在此logout动作中进行的状态、获取器、提交和分派仍然是命名空间的。只有它的调用不再是命名空间的!

  1. 您可以从主存储中的logout动作中删除dispatch('posts/logout')行。

登录

当用户成功登录时,我们将调度一个非命名空间的'logged-in'动作。

  1. 回到posts模块,在新对象表示法中添加logged-in动作:
 'logged-in': {
        handler ({ dispatch, state }) {
          if (state.mapBounds) {
            dispatch('fetchPosts', {
              mapBounds: state.mapBounds,
              force: true,
            })
          }
          if (state.selectedPostId) {
            dispatch('selectPost', state.selectedPostId)
          }
        },
        root: true,
      },
  1. 在主存储login动作中,如果用户成功验证,则调度这个新的logged-in动作:
      if (user) {
        // ...
        dispatch('logged-in')
      }

选择一篇文章

这是本章的最后一部分!我们现在将创建文章内容组件,它将显示标题、内容、位置信息和评论列表。文章详情对象与文章对象相同,还包括作者数据、评论列表和每条评论的作者。

文章详情

让我们首先修改我们的posts Vuex 模块,为文章详情做准备。

文章选择和发送的存储更改

  1. 在状态中添加一个selectedPostDetails数据属性,并添加相应的 getter 和 mutation:
      state () {
        return {
          // ...
          // Fetched details for the selected post
          selectedPostDetails: null,
        }
      },

      getters: {
        // ...
        selectedPostDetails: state => state.selectedPostDetails,
      },

      mutations: {
        // ...
        selectedPostDetails (state, value) {
          state.selectedPostDetails = value
        },
      },
  1. selectPost中,使用对服务器上/post/<id>路由的请求获取详情:
      async selectPost ({ commit }, id) {
        commit('selectedPostDetails', null)
        commit('selectedPostId', id)
        const details = await $fetch(`posts/${id}`)
        commit('selectedPostDetails', details)
      },
  1. 还要添加一个新的unselectPost动作:
 unselectPost ({ commit }) {
        commit('selectedPostId', null)
      },

文章内容组件

当用户在地图上点击博客标记时,我们需要在侧边栏中显示其内容。我们将在一个专用的PostContent组件中实现这一点。

  1. 让我们通过开始初始模板来实现content/PostContent.vue组件:
      <template>
        <div class="post-content">
          <template v-if="details">
            <div class="title">
              <img :src="details.author.profile.photos[0].value" />
              <span>
                <span>{{ details.title }}</span>
                <span class="info">
                  <span class="name">
                    {{ details.author.profile.displayName }}</span>
                  <span class="date">{{ details.date | date }}</span>
                </span>
              </span>
            </div>
            <div class="content">{{ details.content }}</div>
            <!-- TODO Comments -->
            <div class="actions">
              <button
                type="button"
                class="icon-button secondary"
                @click="unselectPost">
                <i class="material-icons">close</i>
              </button>
              <!-- TODO Comment input -->
            </div>
          </template>
          <div class="loading-animation" v-else>
            <div></div>
          </div>
        </div>
      </template>

第一部分是带有作者头像、标题、作者姓名和创建日期的标题。然后我们显示文章内容,接着是评论列表,以及底部的操作工具栏。在我们从服务器接收到文章详情响应之前,它还会显示一个加载动画。

  1. 然后我们需要一个带有details getter 和posts模块中的unselectPost动作的脚本部分:
      <script>
      import { createNamespacedHelpers } from 'vuex'

      // posts module
      const {
        mapGetters: postsGetters,
        mapActions: postsActions,
      } = createNamespacedHelpers('posts')

      export default {
        computed: {
          ...postsGetters({
            details: 'selectedPostDetails',
          }),
        },

        methods: {
          ...postsActions([
            'unselectPost',
          ]),
        },
      }
      </script>

现在你可以尝试选择一篇文章标记,并在右侧面板中看到其内容显示出来:

位置信息和作用域插槽

我们将在右侧边栏顶部显示关于当前文章位置的信息,包括名称和地址。我们将要使用vue-googlemaps中的组件来利用 Vue 的一个特性,叫做“作用域插槽”。

作用域插槽以将数据传递给父组件

你应该已经知道什么是插槽——它们允许我们将元素或组件放入其他组件中。有了作用域插槽,声明<slot>部分的组件可以将数据传递给嵌入在插槽中的视图。

例如,我们可以有一个带有默认插槽的组件,其中results属性中有一系列结果:

<template>
  <div class="search">
    <slot />
  </div>
</template>

<script>
export default {
  computed: {
    results () {
      return /* ... */
    },
  },
}
</script>

我们可以通过插槽将此属性传递给包含模板部分的外部视图,就像这样:

<slot :results="results" />

在使用此组件时,您可以通过在带有slot-scope属性的模板中包装代码来检索作用域数据。所有作用域数据将在此属性对象中可用:

<Search>
  <template slot-scope="props">
    <div>{{props.results.length}} results</div>
  </template>
</Search>

如果只有一个子元素,则不需要<template>标签。

这是vue-googlemaps库的组件,我们很快将从中获取来自 Google Maps 的数据。

作用域插槽在与循环结合时也非常有用:

<slot v-for="r of results" :result="r" />

在使用它时,插槽的内容将被重复,并将传递当前项目:

<Search>
  <div slot-scope="props" class="result">{{props.result.label}}</div>
</Search>

在这个例子中,如果results计算属性返回三个项目,我们将有三个显示结果标签的<div>

组件的实现

我们现在将使用这个新的作用域插槽概念来显示与博客帖子相关的地点信息。

  1. 让我们在components/content文件夹中创建一个名为PlaceDetails.vue的小组件,显示位置的名称和地址:
      <script>
      export default {
        props: {
          name: String,
          address: String,
        },

        render (h) {
          return <div class="details">
            <div class="name"><i class="material-icons">place</i>   
             {this.name}</div>
            <div class="address"> {this.address}</div>
          </div>
        },
      }
      </script>

然后我们将实现LocationInfo.vue组件。

  1. 首先是模板,我们在其中使用googlemaps-place-details组件,如果我们在帖子上存储了 Google Maps 的placeId,或者使用googlemaps-geocoder组件,它将从帖子的位置找到最相关的对应地址,并通过作用域插槽检索结果:
      <template>
        <div class="location-info" v-if="currentPost">
          <!-- Place -->
          <googlemaps-place-details
            v-if="currentPost.placeId"
            :request="{
              placeId: currentPost.placeId
            }">
            <PlaceDetails
              slot-scope="props"
              v-if="props.results"
              :name="props.results.name"
              :address="props.results.formatted_address" />
          </googlemaps-place-details>

          <!-- Position only -->
          <googlemaps-geocoder
            v-else
            :request="{
              location: currentPost.position,
            }">
            <PlaceDetails
              slot-scope="props"
              v-if="props.results"
              :name="props.results[1].placeDetails.name"
              :address="props.results[0].formatted_address" />
          </googlemaps-geocoder>
        </div>
        <div v-else></div>
      </template>
  1. 在脚本部分,将posts模块中的currentPost getter 映射到,并导入我们刚刚创建的PlaceDetails组件:
      <script>
      import PlaceDetails from './PlaceDetails.vue'
      import { createNamespacedHelpers } from 'vuex'

      // posts module
      const {
        mapGetters: postsGetters,
      } = createNamespacedHelpers('posts')

      export default {
        components: {
          PlaceDetails,
        },

        computed: postsGetters([
    'currentPost',
      ]),
      }
      </script>

现在,如果您选择或起草一篇帖子,您应该在右侧面板顶部看到位置信息显示:

评论 - 功能组件

这是本章的最后一部分,我们将在其中实现帖子组件,并了解更多关于更快的功能组件的知识。

存储更改以供评论使用

在进入功能组件之前,我们需要在 Vue 中奠定基础

  1. posts Vuex 模块中,我们需要一个新的 mutation,它将直接向帖子添加评论:
 addComment (state, { post, comment }) {
        post.comments.push(comment)
      },
  1. 还要添加新的sendComment动作,它将向服务器发送查询到/posts/<id>/comment路由,并将其添加到所选的帖子中:
      async sendComment({ commit, rootGetters }, { post, comment }) {
        const user = rootGetters.user
        commit('addComment', {
          post,
          comment: {
            ...comment,
            date: new Date(),
            user_id: user._id,
            author: user,
          },
        })

        await $fetch(`posts/${post._id}/comment`, {
          method: 'POST',
          body: JSON.stringify(comment),
        })
      },

我们从操作上下文中使用rootGetters来检索用户数据,因为它不在这个命名空间模块中。

功能组件

Vue 中的每个组件实例在创建时都必须设置一些内容,例如数据反应性系统、组件生命周期等。还有一种称为函数组件的轻量级组件。它们没有自己的状态(无法使用this关键字),也无法在开发工具中显示,但在某些情况下有一个非常好的优势——它们速度更快,占用的内存更少!

我们博客文章上的评论是很好的函数组件候选,因为我们可能需要显示很多评论。

要创建一个函数组件,将functional: true选项添加到其定义对象中:

export default {
  functional: true,
  render (h, { props, children }) {
    return h(`h${props.level}`, children)
  },
}

由于组件没有状态,我们无法访问thisrender函数会得到一个新的context参数,其中包含 props、事件监听器、子内容、插槽和其他数据。您可以在官方文档中找到完整的列表(vuejs.org/v2/guide/render-function.html#Functional-Components)。

在编写函数组件时,您不总是需要声明 props。您可以将所有内容作为 props 获取,但它们也会在context.data中传递下来。

请注意,您还可以使用带有functional属性的模板,而不是functional: true选项:

<template functional>
  <div class="my-component">{{ props.message }}</div>
</template>
  1. 现在在PostContent.vue旁边创建一个新的Comment.vue组件:
      <script>
      import { date } from '../../filters'

      export default {
        functional: true,

        render (h, { props }) {
          const { comment } = props
          return <div class="comment">
            <img class="avatar" src=
            {comment.author.profile.photos[0].value} /&gt;
            <div class="message">
              <div class="info">
              <span class="name">{comment.author.profile.displayName}
              </span>
                <span class="date">{date(comment.date)}</span>
              </div>
              <div class="content">{comment.content}</div>
            </div>
          </div>
        },
      }
      </script>
  1. 回到我们的PostContent组件;让我们在窗格中央添加评论列表,并在窗格底部添加评论表单:
      <div class="comments">
        <Comment
          v-for="(comment, index) of details.comments"
          :key="index"
          :comment="comment" />
      </div>
      <div class="actions">
        <!-- ... -->
        <input
          v-model="commentContent"
          placeholder="Type a comment"
          @keyup.enter="submitComment" />
        <button
          type="button"
          class="icon-button"
          @click="submitComment"
          :disabled="!commentFormValid">
          <i class="material-icons">send</i>
        </button>
      </div>
  1. 然后在脚本部分添加Comment组件、commentContent数据属性、commentFormValid计算属性、sendComment Vuex 动作和submitComment方法:
      import Comment from './Comment.vue'

      export default {
        components: {
          Comment,
        },
        data () {
          return {
            commentContent: '',
          }
        },
        computed: {
          ...postsGetters({
            details: 'selectedPostDetails',
          }),
          commentFormValid () {
            return this.commentContent
          },
        },
        methods: {
          ...postsActions([
            'sendComment',
            'unselectPost',
          ]),
          async submitComment () {
            if (this.commentFormValid) {
              this.sendComment({
                post: this.details,
                comment: {
                  content: this.commentContent,
                },
              })
              this.commentContent = ''
            }
          },
        },
      }

您现在可以向所选的帖子添加评论:

总结

在本章中,我们介绍了通过使用官方的 Vuex 库来进行状态管理的非常重要的概念。这将帮助您构建更复杂的应用程序,并大大提高其可维护性。我们使用 Google OAuth API 对用户进行身份验证,嵌入 Google 地图和整个地理定位博客!所有这些都是通过在我们的应用程序中集成 Vuex 存储来实现的,使我们的组件更简单,代码更易于演变。

如果您想进一步改进应用程序,以下是一些想法:

  • 显示帖子标记上的点赞数

  • 允许编辑或删除评论

  • 使用 Web-sockets 添加实时更新

在下一章中,我们将学习更多关于服务器端渲染、国际化、测试和部署的知识。

第七章:项目 5 - 在线商店和扩展

在本章中,我们将快速设置一个“时尚商店”应用程序,以便专注于更高级的主题,例如以下内容:

  • 改进我们的 CSS 代码与 PostCSS 和 autoprefixer 的兼容性

  • 使用 ESLint 对我们的代码进行 linting 以提高其质量和风格

  • 对我们的 Vue 组件进行单元测试

  • 本地化应用程序并利用 webpack 的代码拆分功能

  • 在 Nodejs 中启用应用程序的服务器端渲染

  • 为生产构建应用程序

该应用程序将是一个简单的可穿戴在线商店,看起来像这样:

高级开发工作流程

在本节中,我们将使用新的工具和包来改进我们的开发工作流程。但是,首先,我们需要设置我们的时尚商店项目。

设置项目

  1. 使用vue init命令生成一个新项目,就像我们在第五章中所做的那样,项目 3 - 支持中心,以及第六章中所做的那样,项目 4 - 地理定位博客
 vue init webpack-simple e-shop
      cd e-shop
      npm install
      npm install -S babel-polyfill
  1. 我们还将安装 stylus:
 npm i -D stylus stylus-loader
  1. 删除src文件夹的内容。然后,下载源文件(github.com/Akryum/packt-vue-project-guide/tree/master/chapter7-download/src)并将其解压缩到src文件夹中。这些包含了已经完成的所有应用程序源代码,以便我们可以更快地前进。

  2. 我们需要在依赖项中安装一些更多的包:

 npm i -S axios vue-router vuex vuex-router-sync

axios 是一个很棒的库,用于向服务器发出请求,并且被 Vue.js 团队推荐使用。

生成一个快速开发 API

以前,我们有一个完整的用于后端的 node 服务器,但这次我们不会专注于应用程序功能。因此,我们将使用json-server包为本章的目的生成一个非常简单的本地 API:

  1. 安装json-server作为开发依赖:
 npm i -D json-server
  1. 当我们运行这个包时,它将在本地公开一个简单的 REST API,并使用db.json文件来存储数据。您可以下载它(github.com/Akryum/packt-vue-project-guide/blob/master/chapter7-download/db.json)并将其放在项目根目录中。如果您打开它,您将看到一些待售物品和评论。

  2. 然后,我们需要添加一个脚本来启动 json 服务器。在package.json文件中添加一个新的db脚本:

 "db": "json-server --watch db.json"

上述命令将运行json-server包的命令行工具,并监视您刚刚下载的db.json文件以进行更改,以便您可以轻松编辑它。要尝试它,请使用npm run

npm run db

默认情况下,它将监听端口3000。您可以通过在浏览器中打开http://localhost:3000/items REST 地址来尝试它:

启动应用程序

我们现在准备启动应用程序。打开一个新的终端,像往常一样使用npm run

npm run dev

它应该打开一个新的浏览器窗口,显示正确的地址,您应该能够使用该应用程序:

使用 PostCSS 自动添加 CSS 前缀

在编写 CSS(或 Stylus)代码时,我们希望它与大多数浏览器兼容。幸运的是,有一些工具可以自动为我们完成这项工作,例如,通过添加 CSS 属性的供应商前缀版本(例如-webkit-user-select-moz-user-select)。

PostCSS 是一个专门用于 CSS 后处理的库。它具有非常模块化的架构;它通过向其中添加处理 CSS 的插件来工作。

我们不必安装它。vue-loader已经包含了 PostCSS。我们只需要安装我们想要的插件。在我们的情况下,我们需要autoprefixer包来使我们的 CSS 代码与更多浏览器兼容。

  1. 安装autoprefixer包:
 npm i -D autoprefixer
  1. 为了使 PostCSS 生效,我们需要在项目根目录下添加一个名为postcss.config.js的配置文件。让我们在这个文件中告诉 PostCSS 我们想要使用autoprefixer
      module.exports = {
        plugins: [
          require('autoprefixer'),
        ],
      }

就是这样!我们的代码现在将由autoprefixer处理。例如,考虑这段 Stylus 代码:

.store-cart-item
  user-select none

最终的 CSS 将如下所示:

.store-item[data-v-1af8c5dc] {
  -webkit-user-select: none;
 -moz-user-select: none;
 -ms-user-select: none;
  user-select: none;
}

使用 browserslist 来定位特定的浏览器

我们可以使用browserslist配置更改autoprefixer所定位的浏览器。它包括一系列规则,用于确定要支持哪些浏览器。打开package.json文件,查找browserslist字段。它应该已经具有webpack-simple模板的默认值,如下所示:

"> 1%",
"last 2 versions",
"not ie <= 8"

第一个规则获取在互联网上使用份额超过 1%的浏览器。第二个规则另外选择每个浏览器的最后两个版本。最后,我们声明不支持 Internet Explorer 8 或更早版本。

使用的数据由专门从事浏览器兼容性数据的网站(caniuse.com/)提供。

您现在可以通过自定义此字段来针对甚至更旧的浏览器。例如,要针对 Firefox 20 及更高版本进行定位,您将添加以下规则:

"Firefox >= 20"

您可以在其存储库中找到有关browserslist的更多信息(github.com/ai/browserslist)。

使用 ESLint 改进代码质量和风格

在与其他开发人员一起开发项目时,强制执行良好的编码实践和质量至关重要。它确保不会出现语法或基本错误(例如忘记声明变量),并有助于保持源代码清洁和一致。这个过程称为linting

ESLint 是 Vue.js 团队推荐的 linting 工具。它提供了一组可以打开和关闭以检查代码质量的 linting 规则。通过插件可以添加更多规则,并且一些软件包定义了启用规则的预设。

  1. 我们将使用 StandardJS 预设和eslint-plugin-vue软件包,该软件包添加了更多规则,有助于遵循官方 Vue 风格指南(vuejs.org/v2/style-guide/)。
 npm i -D eslint eslint-config-standard eslint-plugin-vue@beta
  1. eslint-config-standard软件包有四个需要安装的对等依赖项:
 npm i -D eslint-plugin-import eslint-plugin-node eslint-plugin- 
       promise eslint-plugin-standard
  1. 为了在 ESLint 解析文件时对 JavaScript 代码使用 babel,我们需要安装额外的软件包:
 npm i -D babel-eslint

配置 ESLint

在项目根目录中创建一个新的.eslintrc.js文件,并编写以下配置:

module.exports = {
  // Use only this configuration
  root: true,
  // File parser
  parser: 'vue-eslint-parser',
  parserOptions: {
    // Use babel-eslint for JavaScript
    'parser': 'babel-eslint',
    'ecmaVersion': 2017,
    // With import/export syntax
    'sourceType': 'module'
  },
  // Environment global objects
  env: {
    browser: true,
    es6: true,
  },
  extends: [
    // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
    'standard',
    // https://github.com/vuejs/eslint-plugin-vue#bulb-rules
    'plugin:vue/recommended',
  ],
}

首先,我们使用vue-eslint-parser来读取文件(包括.vue文件)。在解析 JavaScript 代码时,它使用babel-eslint。我们还指定了 JavaScript 的 EcmaScript 版本,以及我们使用import/export语法进行模块化。

然后,我们告诉 ESLint 我们期望在浏览器和 ES6(或 ES2015)JavaScript 环境中,这意味着我们应该能够访问全局变量,如window或 Promise,而不会引发 ESLint 未定义变量错误。

我们还指定了我们想要使用的配置(或预设)--standardvue/recommended

自定义规则

我们可以使用rules对象更改启用的规则以及修改它们的选项。将以下内容添加到 ESLint 配置中:

rules: {
  // https://github.com/babel/babel-eslint/issues/517
  'no-use-before-define': 'off',
  'comma-dangle': ['error', 'always-multiline'],
},

第一行禁用了no-use-before-define规则,在使用...解构运算符时会出现 bug。第二行将commad-dangle规则更改为强制在所有数组和对象行的末尾放置逗号,

规则有一个状态,可以取这三个值--'off'(或0),'warn'(或1),和'error'(或2)。

运行 ESLint

要在src文件夹上运行 eslint,我们需要在package.json中添加一个新的脚本:

"eslint": "eslint --ext .js,.jsx,.vue src"

你应该在控制台中注意到一些错误:

通过在前面的eslint命令中添加--fix参数,可以修复其中一些问题:

"eslint": "eslint --ext .js,.jsx,.vue src --fix"

再次运行它,你应该只看到一个错误剩下:

ESLint 告诉我们不应该创建新对象而不保留它们的引用变量。如果我们查看相应的代码,我们会看到我们确实在main.js文件中创建了 Vue 的新实例:

new Vue({
  el: '#app',
  router,
  store,
  ...App,
})

如果你查看 ESLint 错误,你可以看到规则的代码--no-new。你可以打开eslint.org/网站并在搜索字段中输入它以获取规则定义。如果它是由插件添加的规则,它应该有插件名称后跟一个斜杠,例如vue/require-v-for-key

这段代码是按预期编写的,因为这是声明 Vue 应用程序的标准方式。因此,我们需要通过在代码的特定行之前添加一个特殊注释来禁用此规则:

// **eslint-disable-next-line no-new**
new Vue({
  ...
}) 

Webpack 中的 ESLint

目前,我们必须手动运行eslint脚本来检查我们的代码。如果我们能够在 Webpack 处理代码时检查我们的代码,那将更好,这样它将是完全自动的。幸运的是,这是可能的,这要归功于eslint-loader

  1. 将其安装在friendly-errors-webpack-plugin包的开发依赖项中,这将改善控制台消息:
 npm i -D eslint-loader friendly-errors-webpack-plugin

现在我们必须更改 webpack 配置以添加新的 ESLint 加载器规则。

  1. 编辑webpack.config.js文件并在module.rules选项的顶部添加这个新规则:
      module: {
        rules: [
          {
 test: /\.(jsx?|vue)$/,
 loader: 'eslint-loader',
 enforce: 'pre',
 },
          // ...
  1. 此外,我们可以启用friendly-errors-webpack-plugin包。在文件顶部导入它:
      const FriendlyErrors = require('friendly-errors-webpack-plugin')

我们不能在这里使用import/export语法,因为它将在 nodejs 中执行。

  1. 然后,在配置文件的末尾添加一个else条件时,当我们处于开发模式时添加这个插件:
      } else {
        module.exports.plugins = (module.exports.plugins ||            
        []).concat([
          new FriendlyErrors(),
        ])
      }

通过重新运行dev脚本重新启动 webpack 并删除代码中的逗号。你应该在 webpack 输出中看到 ESLint 错误显示:

在浏览器中,现在你应该看到错误叠加:

如果您通过重新添加逗号来修复错误,覆盖层将关闭,并且控制台将显示友好的消息:

使用 Jest 进行单元测试

重要的代码和组件应该进行单元测试,以确保它们按预期工作,并在代码演变时防止大多数回归。Vue 组件的推荐测试运行器是来自 Facebook 的 Jest。它具有缓存系统,速度相当快,并且具有一个方便的快照功能,可以帮助检测更多的回归。

  1. 首先,安装 Jest 和官方的 Vue 单元测试工具:
 npm i -D jest vue-test-utils
  1. 我们还需要一些与 Vue 相关的实用程序,以使用jest-vue编译.vue文件并对组件进行快照:
 npm i -D vue-jest jest-serializer-vue vue-server-renderer

在节点中获取组件的 HTML 渲染的推荐方式是使用vue-server-renderer包,用于进行服务器端渲染,我们将在本章后面看到。

  1. 最后,我们将需要一些 babel 包来支持 Jest 内部的 babel 编译和 webpack 动态导入:
 npm i -D babel-jest babel-plugin-dynamic-import-node

配置 Jest

要配置 Jest,让我们在项目根目录中创建一个新的jest.config.js文件:

module.exports = {
  transform: {
    '.+\\.jsx?$': '<rootDir>/node_modules/babel-jest',
    '.+\\.vue$': '<rootDir>/node_modules/vue-jest',
  },
  snapshotSerializers: [
    '<rootDir>/node_modules/jest-serializer-vue',
  ],
  mapCoverage: true,
}

transform选项定义了 JavaScript 和 Vue 文件的处理器。然后,我们告诉 Jest 使用jest-serializer-vue来序列化组件的快照。我们还将使用mapCoverage选项启用源映射。

您可以在 Jest 网站(facebook.github.io/jest/)上找到更多配置选项。

Jest 的 Babel 配置

为了支持 Jest 内部的 JavaScript import/export模块和动态导入,我们需要在测试运行时更改我们的 babel 配置。

在使用 Jest 时,我们不使用 webpack 和我们用来构建真实应用程序的加载器。

NODE_ENV环境变量设置为"test"时,我们需要向配置中添加两个 babel 插件:

{
  "presets": [
    ["env", { "modules": false }],
    "stage-3"
  ],
 "env": {
 "test": {
 "plugins": [
 "transform-es2015-modules-commonjs",
 "dynamic-import-node"
 ]
 }
 }
}

transform-es2015-modules-commonjs插件为 Jest 添加了对import/export语法的支持,dynamic-import-node为动态导入添加了支持。

当运行时,Jest 会自动将NODE_ENV环境变量设置为'test'

我们的第一个单元测试

为了让 Jest 默认识别任何地方,我们需要将我们的测试文件命名为.test.js.spec.js。我们将测试BaseButton.vue组件;继续在src/components文件夹中创建一个新的BaseButton.spec.js文件。

  1. 首先,我们将从vue-test-utils中导入组件和shallow方法:
      import BaseButton from './BaseButton.vue'
      import { shallow } from 'vue-test-utils'
  1. 接下来,我们将使用describe函数创建一个测试套件:
 describe('BaseButton', () => {
        // Tests here
      })
  1. 在测试套件内部,我们可以使用test函数添加我们的第一个单元测试:
      describe('BaseButton', () => {
        test('click event', () => {
          // Test code
        })
      })
  1. 我们将测试在点击组件时是否会触发click事件。我们需要在组件周围创建一个包装对象,该对象将提供有用的函数来测试组件:
 const  wrapper  =  shallow(BaseButton)
  1. 然后,我们将模拟点击组件:
 wrapper.trigger('click')
  1. 最后,我们将使用 Jest 的expect方法检查click事件是否被触发:
 expect(wrapper.emitted().click).toBeTruthy()
  1. 现在,让我们在package.json文件中添加一个脚本来运行 Jest:
 "jest": "jest"
  1. 然后,使用通常的npm run命令:
 npm run jest

测试已启动并应该通过如下:

要了解有关单元测试 Vue 组件的更多信息,您可以访问官方指南vue-test-utils.vuejs.org/

ESLint 和 Jest 全局变量

如果现在运行 ESLint,我们将会收到与 Jest 关键字(如describetestexpect)相关的错误:

我们需要对 ESLint 配置进行微小更改--我们必须指定jest环境;编辑.eslintrc.js文件:

// Environment global objects
env: {
  browser: true,
  es6: true,
  jest: true,
},

现在,ESLint 将了解 Jest 关键字,并将停止抱怨。

Jest 快照

快照是保存并比较每次运行测试时的字符串,以检测潜在的回归。它们主要用于保存组件的 HTML 渲染,但只要在测试之间存储它并进行比较就可以用于任何值。

对于我们的 Vue 组件,我们将使用名为vue-server-renderer的服务器端渲染工具对其进行 HTML 渲染快照。我们将需要来自此软件包的createRenderer方法:

import { createRenderer } from  'vue-server-renderer'

在测试开始时,我们实例化一个渲染器实例,然后用shallow包装组件并开始将组件渲染为字符串。最后,我们将结果与先前的结果进行比较。以下是对BaseButton组件进行快照测试的示例,传递一些 props 值和默认插槽内容:

test('snapshot', () => {
  const renderer = createRenderer()
  const wrapper = shallow(BaseButton, {
    // Props values
    propsData: {
      icon: 'add',
      disabled: true,
      badge: '3',
    },
    // Slots content
    slots: {
      default: '<span>Add Item</span>',
    },
  })
  renderer.renderToString(wrapper.vm, (err, str) => {
    if (err) throw new Error(err)
    expect(str).toMatchSnapshot()
  })
})

如果首次运行快照测试,它将创建并保存快照到其旁边的__snapshots__文件夹中。如果您正在使用 git 等版本控制系统,则需要将这些快照文件添加到其中。

更新快照

如果您修改了一个组件,那么它的 HTML 渲染也有可能会发生变化。这意味着它的快照将不再有效,Jest 测试将失败。幸运的是,jest命令有一个--updateSnapshots参数。当使用时,所有失败的快照将被重新保存并通过测试。

  1. 让我们在package.json文件中添加一个新的脚本:
 "jest:update": "jest **--updateSnapshot**"
  1. 通过更改 CSS 类来修改BaseButton组件,例如。如果再次运行 Jest 测试,您应该会收到一个错误,指出快照不再匹配。

  1. 现在,使用新的脚本更新快照:
 npm run jest:update

所有的测试现在应该都通过了,BaseButton的快照应该已经更新了:

您应该仅在确定其他地方没有回归时运行此命令。一个好主意是在更新快照之前正常运行测试,以确保只有修改的组件快照失败,这是预期的。更新快照后,使用正常的测试命令。

补充主题

在本节中,我们将涵盖一些对于更大型应用程序可能有用的主题。

国际化和代码拆分

如果应用程序将被不同国家的人使用,应该进行翻译以使其更加用户友好和吸引人。为了本地化应用程序的文本,您可以使用推荐的vue-i18n包:

npm i -S vue-i18n

使用vue-i18n,我们将在AppFooter组件中添加一个链接到一个新页面,用户可以在该页面中选择语言。只有链接和此页面将被翻译,但如果您愿意,您可以翻译应用程序的更多部分。vue-i18n通过使用翻译消息创建一个i18n对象,并将其注入到 Vue 应用程序中。

  1. src/plugins.js文件中,将新的插件安装到 Vue 中:
      import VueI18n from 'vue-i18n'

      // ...

      Vue.use(VueI18n)
  1. 让我们在项目目录中创建一个名为i18n的新文件夹。下载包含翻译文件的locales文件夹(github.com/Akryum/packt-vue-project-guide/tree/master/chapter7-download/locales)并将其放入其中。例如,您应该在i18n/locales/en.js文件中有en的翻译。

  2. 创建一个新的index.js文件,导出可用语言的列表:

      export default [
        'en',
        'fr',
        'es',
        'de',
      ]

我们将需要两个新的实用函数:

  • createI18n:创建i18n对象,带有locale参数。

  • getAutoLang:返回用户在浏览器中设置的两字母语言代码,例如enfr。大多数情况下,这将是操作系统的语言设置。

  1. src/utils文件夹中,创建一个新的i18n.js文件,并导入之前定义的VueI18n和可用区域设置列表:
      import VueI18n from 'vue-i18n'
      import langs from '../../i18n'
  1. 在撰写本文时,我们需要babel-preset-stage-2(或更低版本)来允许 Babel 解析动态导入。在package.json文件中,更改babel-preset-stage-3包:
      "babel-preset-stage-2": "⁶.24.1",
  1. 运行npm install来更新您的包。

  2. 编辑根文件夹中的.babelrc文件,并将stage-3更改为stage-2

  3. 为了切换到阶段 2,进行以下安装:

npm install --save-dev babel-preset-stage-2

使用动态导入进行代码拆分

当我们创建i18n对象时,我们希望仅通过locale参数加载所选语言环境的翻译。为此,我们将使用import函数对文件进行动态导入。它以路径作为参数,并返回一个 Promise,一旦从服务器加载,它将最终解析为相应的 JavaScript 模块。

在 webpack 中,这种动态导入功能有时被称为'代码拆分',因为 webpack 将将异步模块移动到另一个编译后的 JavaScript 文件中,称为块。

以下是使用动态导入加载的异步模块的示例:

async function loadAsyncModule () {
  await module = await import('./path/to/module')
  console.log('default export', module.default)
  console.log('named export', module.myExportedFunction)
}

您可以在导入路径中使用变量,只要它包含有关 webpack 可以找到文件的一些信息。例如,这段代码将无法工作:

import(myModulePath)

然而,只要变量路径简单(没有../),以下内容将正常工作:

import(`./data/${myFileName}.json`)

在这个例子中,data文件夹中所有带有json扩展名的文件将被添加到构建中作为异步块,因为 webpack 无法猜测您在运行时真正会使用哪些文件。

使用动态导入异步加载大型 JavaScript 模块可以减少在打开页面时发送到浏览器的初始 JavaScript 代码的大小。在我们的应用程序中,它允许我们仅加载所选语言环境的相关翻译文件,而不是在初始 JavaScript 文件中包含它们。

如果一个模块已经在主代码(初始块)中使用普通的import导入,它将已经被加载,不会被拆分成另一个块。在这种情况下,你将无法享受代码拆分功能的好处,初始文件大小也不会减小。请注意,你可以在动态加载的模块中同步使用其他模块,使用普通的import关键字:它们将被放在同一个块中(如果它们尚未包含在初始块中)。

i18n对象是使用vue-i18n包中的VueI18n构造函数创建的。我们将传递locale参数。

createI18n函数应该是这样的:

export async function createI18n (locale) {
  const { default: localeMessages } = await import(`../../i18n/locales/${locale}`)
  const messages = {
    [locale]: localeMessages,
  }

  const i18n = new VueI18n({
    locale,
    messages,
  })

  return i18n
}

如你所见,我们需要取模块的default值,因为我们使用export default导出了消息。

上面使用async/await的代码可以使用 Promises 来编写:

export function createI18n (locale) {
  return import(`../../i18n/locales/${locale}`)
    .then(module => {
      const localeMessages = module.default
      // ...
    })
}

自动加载用户语言环境

接下来,我们可以使用navigator.language(或userLanguage用于兼容 Internet Explorer)来检索语言环境代码。然后,我们将检查它是否在langs列表中可用,或者我们是否必须使用默认的en语言环境。

  1. getAutoLang函数应该是这样的:
      export function getAutoLang () {
        let result = window.navigator.userLanguage || 
        window.navigator.language
        if (result) {
          result = result.substr(0, 2)
        }
        if (langs.indexOf(result) === -1) {
          return 'en'
        } else {
          return result
        }
      }

一些浏览器可能以en-US格式返回代码,但我们只需要前两个字符。

  1. src/main.js文件中,导入这两个新的实用函数:
      import { createI18n, getAutoLang } from './utils/i18n'
  1. 然后,修改main函数:

  2. 使用getAutoLang检索首选语言环境。

  3. 使用createI18n函数创建并等待i18n对象。

  4. i18n对象注入到根 Vue 实例中。

现在它应该是这样的:

async function main () {
  const locale = getAutoLang()
 const i18n = await createI18n(locale)
  await store.dispatch('init')

  // eslint-disable-next-line no-new
  new Vue({
   el: '#app',
    router,
    store,
    i18n, // Inject i18n into the app
    ...App,
  })
}

不要忘记在createI18n前面加上await关键字,否则你将得到一个 Promise。

现在你可以在浏览器开发工具的网络面板中打开,并刷新页面。webpack 将会通过单独的请求加载对应于所选语言环境的翻译模块。在这个示例截图中,这是异步加载的2.build.js文件:

更改语言页面

目前,应用程序并没有真正改变,所以让我们添加一个页面,让我们可以选择语言。

  1. src/router.js文件中,导入PageLocale组件:
      import PageLocale from './components/PageLocale.vue'
  1. 然后,在routes数组中添加locale路由,就在最后一个路由(带有*路径)之前:
      { path: '/locale', name: 'locale', component: PageLocale },
  1. AppFooter.vue组件中,将这个路由链接添加到模板中:
      <div v-if="$route.name !== 'locale'">
        <router-link :to="{ name: 'locale' }">{{ $t('change-lang') }}
        </router-link>
      </div>

正如您在前面的代码中所看到的,我们使用了vue-i18n提供的$t来显示翻译文本。参数对应于区域文件中的键。现在您应该在应用程序页脚中看到该链接:

链接将我们带到语言选择页面,该页面已经完全使用vue-i18n进行了翻译:

您可以在components/PageLocale.vue文件中查看其源代码。

当您单击区域按钮时,如果尚未加载,将加载相应的翻译。在浏览器开发工具的网络面板中,每次都应该看到对其他块的请求:

服务器端渲染

服务器端渲染SSR)包括在服务器上运行和渲染应用程序,然后将 HTML 发送回浏览器。这有两个主要优点:

  • 更好的搜索引擎优化SEO),因为应用程序的初始内容将在页面 HTML 中呈现。这很重要,因为没有搜索引擎正在索引异步 JavaScript 应用程序(例如,当您有一个旋转器时)。

  • 较慢的网络或设备将更快地显示内容——渲染的 HTML 不需要 JavaScript 才能显示给用户。

然而,使用 SSR 也带来了一些权衡:

  • 代码需要能够在服务器上运行(除非它是在客户端专用的挂钩,比如mounted)。此外,一些库可能在浏览器上表现不佳,可能需要特殊处理。

  • 服务器的负载将增加,因为它要做更多的工作。

  • 开发设置有点复杂。

因此,使用 SSR 并不总是一个好主意,特别是如果第一次显示内容的时间不是关键的话(例如,管理仪表板)。

通用应用程序结构

编写一个可以在客户端和服务器上运行的通用应用程序需要改变源代码的架构。

在客户端运行时,每次加载页面时我们都处于一个新的上下文。这就是为什么我们到目前为止都使用根实例、路由器和存储的单例实例。然而,现在我们也需要在服务器上有一个新的上下文——问题是,Node.js 是有状态的。解决方案是为服务器处理的每个请求创建一个全新的根实例、路由器和存储。

  1. 让我们从路由器开始。在src/router.js文件中,将路由器创建包装成一个新的导出的createRouter函数:
 export function createRouter () {
        const router = new VueRouter({
          routes,
          mode: 'history',
          scrollBehavior (to, from, savedPosition) {
            // ...
          },
        })

        return router
 }
  1. 我们将对 Vuex 存储执行相同的操作。在src/store/index.js文件中,将代码包装到一个新的导出的createStore函数中:
 export function createStore () {
        const store = new Vuex.Store({
          strict: process.env.NODE_ENV !== 'production',

          // ...

          modules: {
            cart,
            item,
            items,
            ui,
          },
        })

        return store
 }
  1. 让我们也将src/main.js文件重命名为src/app.js。这将是我们的通用文件,用于创建路由器、存储器和 Vue 根实例。将main函数改为导出的createApp函数,该函数接受一个context参数并返回应用程序、路由器和存储器:
 export async function createApp (context) {
        const router = createRouter()
        const store = createStore()

        sync(store, router)

        const i18n = await createI18n(context.locale)
        await store.dispatch('init')

        const app = new Vue({
          router,
          store,
          i18n,
          ...App,
        })

        return {
 app,
 router,
 store,
 }
      }

不要忘记更改createRoutercreateStore的导入。

在服务器上,我们不会像在客户端那样选择初始区域设置,因为我们无法访问window.navigator。这就是为什么我们在context参数中传递区域设置的原因:

const i18n = await createI18n(context.locale)

我们还从根实例定义中删除了el选项,因为在服务器上没有意义。

客户端入口

在浏览器上,代码将在我们现在将编写的客户端入口文件中启动。

  1. 创建一个新的src/entry-client.js文件,它将成为客户端包的入口点。它将获取用户语言,调用createApp函数,然后将应用程序挂载到页面上:
      import { createApp } from './app'
      import { getAutoLang } from './utils/i18n'

      const locale = getAutoLang()
      createApp({
        locale,
      }).then(({ app }) => {
        app.$mount('#app')
      })
  1. 现在您可以更改webpack.config.js文件中的入口路径:
 entry: './src/entry-client.js',

您可以重新启动dev脚本,并检查应用程序是否仍然在浏览器中运行。

服务器入口

创建一个新的src/entry-server.js文件,它将成为服务器包的入口点。它将导出一个从我们稍后将构建的 HTTP 服务器获取context对象的函数。它应该返回一个 Promise,在 Vue 应用程序准备就绪时解析该 Promise。

我们将在context中传递一个url属性,以便我们可以设置当前路由,就像这样:

router.push(context.url)

与客户端入口类似,我们还使用createApp函数来创建根应用程序实例、路由器和存储器。entry-server.js应该是这样的:

import { createApp } from './app'

export default context => {
  return new Promise(async (resolve, reject) => {
    const { app, router, store } = await createApp(context)
    // Set the current route
    router.push(context.url)
    // TODO get matched components to preload data
    // TODO resolve(app)
  })
}

我们返回一个 Promise,因为当我们完成所有操作时,我们将发送应用程序app

app根实例将通过resolve(app)发送回我们称之为渲染器的地方(有点像我们做 Jest 快照时)。首先,我们需要处理预加载 Vuex 存储。

状态管理

在处理请求时,我们需要在渲染应用程序之前在相关组件上获取数据。这样,当浏览器加载 HTML 时,数据已经显示出来。例如,PageHome.vue获取存储项,PageStoreItem.vue检索项目详细信息和评论。

我们将为这些组件添加一个新的asyncData自定义选项,这样我们可以在进行 SSR 时在服务器上调用它。

  1. 通过添加此函数来编辑PageHome.vue组件,该函数会调度items存储模块的fetchItems操作:
 asyncData ({ store }) {
        return store.dispatch('items/fetchItems')
      },
  1. PageStoreItem.vue组件中,我们需要调用服务器传递的路由的id参数,调用item存储模块的fetchStoreItemDetails操作:
 asyncData ({ store, route }) {
        return store.dispatch('item/fetchStoreItemDetails', {
          id: route.params.id,
        })
      },
  1. 现在我们的组件已经准备好了,我们将回到entry-server.js。我们可以使用router.getMatchedComponents()方法获取与当前路由匹配的组件列表:
      export default context => {
        return new Promise(async (resolve, reject) => {
          const { app, router, store } = await createApp(context)
          router.push(context.url)
          // Wait for the component resolution
          router.onReady(() => {
 const matchedComponents = router.getMatchedComponents()
            // TODO pre-load data
            // TODO resolve(app)
          }, reject)
        })
      }
  1. 然后我们可以调用这些组件的所有asyncData选项并等待它们完成。我们将 store 和当前路由传递给它们,当它们全部完成时,我们使用context.state = store.state将 Vuex 存储状态发送回渲染器。使用Promise.all(array)等待所有asyncData调用:
      router.onReady(() => {
        const matchedComponents = router.getMatchedComponents()

        Promise.all(matchedComponents.map(Component => {
 if (Component.asyncData) {
 return Component.asyncData({
 store,
 route: router.currentRoute,
 })
 }
 })).then(() => {
          // Send back the store state
          context.state = store.state

          // Send the app to the renderer
          resolve(app)
 }).catch(reject)
      }, reject)

如果发生错误,它将拒绝我们返回给渲染器的 Promise。

在客户端恢复 Vuex 状态

服务器将 store 状态序列化为 HTML 页面中的__INITIAL_STATE__变量。我们可以使用这个来在应用挂载之前设置状态,这样组件将可以访问它。

编辑entry-client.js文件,并在挂载应用之前使用store.replaceState方法:

createApp({
  locale,
}).then(({ app, store }) => {
  if (window.__INITIAL_STATE__) {
 store.replaceState(window.__INITIAL_STATE__)
 }

  app.$mount('#app')
})

现在,存储将拥有服务器发送的数据。

Webpack 配置

我们的应用代码现在已经准备好了。在继续之前,我们需要重构我们的 webpack 配置。

我们需要为客户端和服务器准备稍有不同的 webpack 配置。最好有一个通用的配置文件,然后为客户端和服务器进行扩展。我们可以使用webpack-merge包轻松实现这一点,该包将多个 webpack 配置对象合并为一个。

对于服务器配置,我们还需要webpack-node-externals包来防止 webpack 打包node_modules中的包--这是不必要的,因为我们将在 nodejs 中运行而不是在浏览器中。所有相应的导入将保留为require语句,以便 node 自己加载它们。

  1. 在开发依赖项中安装这些包:
 npm i -D webpack-merge webpack-node-externals
  1. 在项目根目录中创建一个新的webpack文件夹,然后将webpack.config.js文件移动并重命名为webpack/common.js。需要一些更改。

  2. 从配置中删除entry选项。这将在特定的扩展配置中指定。

  3. 更新 output 选项以定位到正确的文件夹并生成更好的 chunk 名称:

      output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        filename: '[name].[chunkhash].js',
      },

客户端配置

webpack/common.js 旁边,创建一个新的 client.js 文件,扩展基本配置:

const webpack = require('webpack')
const merge = require('webpack-merge')
const common = require('./common')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(common, {
  entry: './src/entry-client',
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity,
    }),
    // Generates the client manifest file
    new VueSSRClientPlugin(),
  ],
})

VueSSRClientPlugin 将生成一个 vue-ssr-client-manifest.json 文件,我们将其提供给渲染器。这样,它将更多地了解客户端。此外,它将自动将脚本标签和关键 CSS 注入到 HTML 中。

关键 CSS 是服务器渲染的组件的样式。这些样式将直接注入到页面 HTML 中,这样浏览器就不必等待 CSS 加载,可以更早地显示这些组件。

CommonsChunkPlugin 将 webpack 运行时代码放入一个主要的 chunk 中,这样异步 chunk 就可以在它之后被注入。它还改善了应用程序和供应商代码的缓存。

服务器配置

webpack/common.js 旁边,创建一个新的 server.js 文件,扩展基本配置:

const merge = require('webpack-merge')
const common = require('./common')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(common, {
  entry: './src/entry-server',
  target: 'node',
  devtool: 'source-map',
  output: {
    libraryTarget: 'commonjs2',
  },
  // Skip webpack processing on node_modules
  externals: nodeExternals({
    // Force css files imported from no_modules
    // to be processed by webpack
    whitelist: /\.css$/,
  }),
  plugins: [
    // Generates the server bundle file
    new VueSSRServerPlugin(),
  ],
})

在这里,我们更改多个选项,例如 targetoutput.libraryTarget,以适应 node.js 环境。

使用 webpack-node-externals 包,我们告诉 webpack 忽略位于 node_modules 文件夹中的模块(这意味着依赖项)。由于我们在 nodejs 中而不是在浏览器中,我们不必将所有依赖项捆绑到包中,因此这将改善构建时间。

最后,我们使用 VueSSRServerPlugin 生成将被渲染器使用的服务器包文件。它包含编译后的服务器端代码和许多其他信息,以便渲染器可以支持源映射(使用 devtoolsource-map 值)、热重新加载、关键 CSS 注入以及与客户端清单数据一起的其他注入。

服务器端设置

在开发中,我们不能再直接使用 webpack-dev-server 来进行 SSR。相反,我们将使用 webpack 设置 express 服务器。下载 server.dev.js 文件(github.com/Akryum/packt-vue-project-guide/blob/master/chapter7-download/server.dev.js)并将其放在项目根目录中。该文件导出一个 setupDevServer 函数,我们将使用它来运行 webpack 并更新服务器。

我们还需要一些用于开发设置的包:

npm i -D memory-fs chokidar webpack-dev-middleware webpack-hot-middleware

我们可以使用memory-fs创建虚拟文件系统,使用chokidar监视文件,并在 express 服务器中使用最后两个中间件启用 webpack 热模块替换。

页面模板

index.html旁边创建一个新的index.template.html文件,并复制其内容。然后,用特殊的<!--vue-ssr-outlet-->注释替换 body 内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Fashion Store</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html&gt;

这个特殊的注释将被服务器上的渲染标记替换。

Express 服务器

在 nodejs 端,我们将使用express包来创建我们的 HTTP 服务器。我们还需要reify包,以便我们可以在 nodejs 中要求使用import/export语法的文件(它不支持原生支持)。

  1. 安装新的包:
 npm i -S express reify
  1. 下载这个不完整的server.js文件(github.com/Akryum/packt-vue-project-guide/blob/master/chapter7-download/server.dev.js)并将其放在项目根目录中。它已经创建了一个 express 服务器并配置了必要的 express 路由。

现在,我们将专注于开发部分。

创建和更新渲染器

要渲染我们的应用程序,我们将需要使用vue-server-renderer包中的createBundleRenderer函数创建的渲染器。

捆绑渲染器与普通渲染器有很大不同。它使用一个服务器捆绑文件(这将由我们的新 webpack 配置生成),还有一个可选的客户端清单,允许渲染器对代码有更多的信息。这使得更多功能成为可能,比如源映射和热重新加载。

server.js文件中,用这段代码替换// TODO development注释:

const setupDevServer = require('./server.dev')
readyPromise = setupDevServer({
  server,
  templatePath,
  onUpdate: (bundle, options) => {
    // Re-create the bundle renderer
    renderer = createBundleRenderer(bundle, {
      runInNewContext: false,
      ...options,
    })
  },
})

由于server.dev.js文件,我们可以为我们的 express 服务器添加 webpack 热重新加载支持。我们还指定了 HTML 页面模板的路径,因此当更改时我们也可以重新加载它。

当设置触发更新时,我们将创建或重新创建捆绑渲染器。

渲染 Vue 应用程序

接下来,我们需要实现渲染应用程序的代码,并将 HTML 结果发送回客户端。

用这个替换// TODO render注释:

const context = {
  url: req.url,
  // Languages sent by the browser
  locale: req.acceptsLanguages(langs) || 'en',
}
renderer.renderToString(context, (err, html) => {
  if (err) {
    // Render Error Page or Redirect
    res.status(500).send('500 | Internal Server Error')
    console.error(`error during render : ${req.url}`)
    console.error(err.stack)
  }
  res.send(html)
})

由于 express 的req.acceptsLanguages方法,我们可以轻松地选择用户的首选语言。

在执行请求时,Web 浏览器将发送用户的“接受的语言”列表。这通常是他们的浏览器或操作系统设置的语言。

然后我们使用 renderToString 方法,该方法将调用我们在 entry-server.js 文件中导出的函数,等待返回的 Promise 完成,然后将应用程序渲染为 HTML 字符串。最后,我们将结果发送给客户端(除非在渲染过程中出现错误)。

运行我们的 SSR 应用程序

现在是运行应用程序的时候了。将 dev 脚本更改为运行我们的 express 服务器,而不是 webpack-dev-server

"dev": "node server",

重新启动脚本并刷新应用程序。为了确保 SSR 正常工作,请查看页面的源代码:

应用程序已经由服务器呈现为 HTML。

不必要的获取

不幸的是,我们的应用出了问题。服务器将 Vuex 存储数据与页面的 HTML 一起发送,这意味着应用程序在第一次运行时已经具有了所有需要的数据,只是仍在进行检索项目详细信息和评论的请求。您可以通过加载动画来看到这一点,该动画在首次加载或刷新相应页面时出现。

解决此问题的方法是防止组件在不必要时获取数据:

  1. PageHome.vue 组件中,我们只需要在没有这些数据时才获取项目:
      mounted () {
        if (!this.items.length) {
          this.fetchItems()
        }
      },
  1. PageStoreItem.vue 组件中,只有在没有数据时才应获取详细信息和评论:
      fetchData () {
        if (!this.details || this.details.id !== this.id) {
          this.fetchStoreItemDetails({
            id: this.id,
          })
        }
      },

我们现在不再有这个问题了。

要继续了解 SSR,您可以访问官方文档 ssr.vuejs.org/,或者使用一个易于使用的框架 nuxtjs (nuxtjs.org/),该框架为您抽象了许多样板代码。

生产构建

我们的应用在开发中运行得很好。假设我们已经完成了它,并且想要将其部署到真实服务器上。

额外配置

我们需要为应用程序的生产构建添加一些配置,以确保其优化。

将样式提取到 CSS 文件中

到目前为止,样式是通过 JavaScript 代码添加到页面中的。这在开发中非常好,因为它允许使用 webpack 进行热重载。然而,在生产中,建议将其提取到单独的 CSS 文件中。

  1. 在开发依赖中安装 extract-text-webpack-plugin 包:
 npm i -D extract-text-webpack-plugin
  1. webpack/common.js 配置文件中,添加一个新的 isProd 变量:
      const isProd = process.env.NODE_ENV === 'production'
  1. 修改vue-loader规则,以在生产环境下启用 CSS 提取,并忽略 HTML 标签之间的空白:
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          extractCSS: isProd,
          preserveWhitespace: false,
        },
      },
  1. 在文件底部的仅限生产的插件列表中添加ExtractTextPluginModuleConcatenationPlugin
      if (isProd) {
        module.exports.devtool = '#source-map'
        module.exports.plugins = (module.exports.plugins || 
        []).concat([
          // ...
          new webpack.optimize.ModuleConcatenationPlugin(),
 new ExtractTextPlugin({
 filename: 'common.[chunkhash].css',
 }),
        ])
      } else {
       // ...
      }

ExtractTextPlugin将样式放入 CSS 文件中,而ModuleConcatenationPlugin将优化编译后的 JavaScript 代码以提高速度。

生产环境 express 服务器

我们需要对我们的代码进行的最后更改是在 express 服务器中创建包渲染器。

server.js文件中,用以下内容替换// TODO production注释:

const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template,
  clientManifest,
})

我们将读取 HTML 页面模板、服务器包和客户端清单。然后,我们创建一个新的包渲染器,因为在生产环境中我们不会有热重载。

新的 npm 脚本

编译后的代码将输出到项目根目录中的dist目录。在每次构建之间,我们需要将其删除,以便处于干净的状态。为了以跨平台的方式做到这一点,我们将使用可以递归删除文件和文件夹的rimraf包。

  1. 安装rimraf包到开发依赖项中:
 npm i -D rimraf
  1. 为客户端和服务器包添加一个build脚本:
      "build:client": "cross-env NODE_ENV=production webpack --progress 
       --hide-modules --config webpack/client.js",
      "build:server": "cross-env NODE_ENV=production webpack --progress 
       --hide-modules --config webpack/server.js",

我们将NODE_ENV环境变量设置为'production',并使用相应的 webpack 配置文件运行webpack命令。

  1. 创建一个新的build脚本,清空dist文件夹,并运行另外两个build:clientbuild:server脚本:
      "build": "rimraf dist && npm run build:client && npm run 
        build:server",
  1. 添加一个名为start的最后一个脚本,以在生产模式下运行 express 服务器:
      "start": "cross-env NODE_ENV=production node server",
  1. 现在可以运行构建;使用通常的npm run命令:
 npm run build

dist文件夹现在应该包含 webpack 生成的所有块,以及服务器包和客户端清单 json 文件:

这些是需要上传到真实 nodejs 服务器的文件。

  1. 现在可以启动 express 服务器:
 npm start

您还应该上传server.jspackage.jsonpackage-lock.json文件到真实服务器。不要忘记使用npm install安装依赖项。

总结

在这一章中,我们通过学习如何使用 PostCSS 自动添加 CSS 前缀,使用 ESLint 对代码进行质量检查,以及使用 Jest 对组件进行单元测试,改进了我们的开发工作流程。我们甚至进一步添加了vue-i18n包和动态导入的本地化,并通过重构项目实现了服务器端渲染,同时仍然利用了 webpack 的热重载、代码分割和优化等强大功能。

在最后一章中,我们将使用 Meteor 全栈框架和 Vue 创建一个简单的实时应用程序。

第八章:项目 6 - 使用 Meteor 的实时仪表板

在这最后一章中,我们将使用 Vue 与完全不同的堆栈--Meteor!

我们将发现这个全栈 JavaScript 框架,并构建一个实时监控一些产品生产的仪表板。我们将涵盖以下主题:

  • 安装 Meteor 并设置项目

  • 使用 Meteor 方法将数据存储到 Meteor 集合中

  • 订阅集合并在我们的 Vue 组件中使用数据

该应用程序将有一个主页面,其中包含一些指标,例如:

它还将有另一个页面,其中有按钮可以生成虚假的测量数据,因为我们没有真正的传感器可用。

设置项目

在这第一部分中,我们将介绍 Meteor,并在该平台上运行一个简单的应用程序。

什么是 Meteor?

Meteor 是一个用于构建 Web 应用程序的全栈 JavaScript 框架。

Meteor 堆栈的主要元素如下:

  • Web 客户端(可以使用任何前端库,如 React 或 Vue);它有一个名为 Minimongo 的客户端数据库

  • 基于 nodejs 的服务器;支持现代的 ES2015+功能,包括import/export语法

  • 在服务器上使用 MongoDB 的实时数据库

  • 客户端和服务器之间的通信是抽象的;客户端和服务器端数据库可以轻松实时同步

  • 可选的混合移动应用程序(Android 和 iOS),一条命令构建

  • 集成开发工具,如强大的命令行实用程序和易于使用的构建工具

  • Meteor 特定的包(但您也可以使用 npm 包)

如您所见,JavaScript 随处可见。Meteor 还鼓励您在客户端和服务器之间共享代码。

由于 Meteor 管理整个堆栈,它提供了非常强大且易于使用的系统。例如,整个堆栈是完全反应式和实时的--如果客户端发送更新到服务器,所有其他客户端将接收新数据,并且他们的用户界面将自动更新。

Meteor 有自己的构建系统称为"IsoBuild",并且不使用 Webpack。它专注于易用性(无需配置),但结果也较不灵活。

安装 Meteor

如果您的系统上没有 Meteor,您需要打开官方 Meteor 网站上的安装指南www.meteor.com/install。按照您的操作系统在那里安装 Meteor。

完成后,您可以使用以下命令检查 Meteor 是否已正确安装:

meteor --version

应显示 Meteor 的当前版本。

创建项目

现在 Meteor 已安装,让我们设置一个新项目:

  1. 让我们使用meteor create命令创建我们的第一个 Meteor 项目:
 meteor create --bare <folder>
 cd <folder>

--bare参数告诉 Meteor 我们想要一个空项目。默认情况下,Meteor 会生成一些我们不需要的样板文件,因此这样可以避免我们不得不删除它们。

  1. 然后,我们需要两个特定于 Meteor 的软件包——一个用于编译 Vue 组件,一个用于在这些组件内部编译 Stylus。使用meteor add命令安装它们:
 meteor add akryum:vue-component akryum:vue-stylus
  1. 我们还将从 npm 安装vuevue-router软件包:
 meteor npm i -S vue vue-router

请注意,我们使用meteor npm命令,而不是只用npm。这是为了与 Meteor(nodejs 和 npm 版本)保持相同的环境。

  1. 要在开发模式下启动我们的 Meteor 应用程序,只需运行meteor命令:
 meteor

Meteor 应该启动一个 HTTP 代理、一个 MongoDB 和 nodejs 服务器:

它还显示了应用程序可用的 URL;但是,如果您现在打开它,它将是空白的。

我们的第一个 Vue Meteor 应用程序

在本节中,我们将在我们的应用程序中显示一个简单的 Vue 组件:

  1. 在项目目录中创建一个新的index.html文件,并告诉 Meteor 我们希望在页面主体中有app id 的div
      <head>
        <title>Production Dashboard</title>
      </head>
      <body>
        <div id="app"></div>
      </body>

这不是一个真正的 HTML 文件。这是一种特殊的格式,我们可以向最终 HTML 页面的headbody部分注入附加元素。在这里,Meteor 将在head部分添加一个title,在body部分添加<div>

  1. client文件夹中创建一个新的components子文件夹,并创建一个名为App.vue的新组件,其中包含一个简单的模板:
      <!-- client/components/App.vue -->
      <template>
        <div id="#app">
          <h1>Meteor</h1>
        </div>
      </template>
  1. client文件夹中下载(github.com/Akryum/packt-vue-project-guide/tree/master/chapter8-full/client)这个 stylus 文件,并将其添加到主App.vue组件中:
      <style lang="stylus" src="../style.styl" />
  1. client文件夹中创建一个main.js文件,该文件在Meteor.startup钩子内启动 Vue 应用程序:
      import { Meteor } from 'meteor/meteor'
      import Vue from 'vue'
      import App from './components/App.vue'

      Meteor.startup(() => {
        new Vue({
          el: '#app',
          ...App,
        })
      })

在 Meteor 应用程序中,建议您在Meteor.startup钩子内创建 Vue 应用程序,以确保在启动前端之前所有 Meteor 系统都已准备就绪。此代码仅在客户端上运行,因为它位于client文件夹中。

现在您应该在浏览器中看到一个简单的应用程序。您还可以打开 Vue devtools 并检查页面上是否有 App 组件。

路由

让我们为应用程序添加一些路由;我们将有两个页面--带有指标的仪表板和一个带有生成虚假数据按钮的页面:

  1. client/components 文件夹中,创建两个新组件--ProductionGenerator.vueProductionDashboard.vue

  2. main.js 文件旁边,创建一个 router.js 文件来创建路由:

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

      import ProductionDashboard from 
      './components/ProductionDashboard.vue'
      import ProductionGenerator from 
      './components/ProductionGenerator.vue'

      Vue.use(VueRouter)

      const routes = [
        { path: '/', name: 'dashboard', component: ProductionDashboard 
        },
        { path: '/generate', name: 'generate',
          component: ProductionGenerator },
      ]

      const router = new VueRouter({
        mode: 'history',
        routes,
      })

      export default router
  1. 然后,在 main.js 文件中导入路由并将其注入到应用程序中,就像我们在 第五章 中所做的那样,项目 3 - 支持中心

  2. App.vue 主组件中,添加导航菜单和路由视图:

      <nav>
        <router-link :to="{ name: 'dashboard' }" exact>Dashboard
          </router-link>
        <router-link :to="{ name: 'generate' }">Measure</router-link>
      </nav>
      <router-view />

我们应用程序的基本结构现在已经完成:

生产度量

我们将制作的第一个页面是度量页面,我们将在其中有两个按钮:

  • 第一个将生成一个带有当前 date 和随机 value 的虚假生产度量

  • 第二个将生成一个度量,但 error 属性设置为 true

所有这些度量将存储在一个名为 "Measures" 的集合中。

Meteor 集合集成

Meteor 集合是一个响应式对象列表,类似于 MongoDB 集合(实际上,它在内部使用 MongoDB)。

我们需要使用一个 Vue 插件来将 Meteor 集合集成到我们的 Vue 应用程序中,以便自动更新它:

  1. 添加 vue-meteor-tracker npm 包:
 meteor npm i -S vue-meteor-tracker
  1. 然后,将库安装到 Vue 中:
      import VueMeteorTracker from 'vue-meteor-tracker'

      Vue.use(VueMeteorTracker)
  1. 使用 meteor 命令重新启动 Meteor。

应用程序现在知道了 Meteor 集合,我们可以在我们的组件中使用它们,就像我们马上要做的那样。

设置数据

下一步是设置我们将存储度量数据的 Meteor 集合。

添加一个集合

我们将把我们的度量存储到一个 Measures Meteor 集合中。在项目目录中创建一个新的 lib 文件夹。该文件夹中的所有代码将首先在客户端和服务器上执行。创建一个 collections.js 文件,在其中我们将声明我们的 Measures 集合:

import { Mongo } from 'meteor/mongo'

export const Measures = new Mongo.Collection('measures')

添加一个 Meteor 方法

Meteor 方法是一个特殊的函数,将在客户端和服务器上都被调用。这对于更新集合数据非常有用,并将改善应用程序的感知速度--客户端将在 minimongo 上执行,而不必等待服务器接收和处理它。

这种技术称为“乐观更新”,在网络质量不佳时非常有效。

  1. lib文件夹中的collections.js文件旁边,创建一个新的methods.js文件。然后,添加一个measure.add方法,将新的测量插入到Measures集合中:
      import { Meteor } from 'meteor/meteor'
      import { Measures } from './collections'

      Meteor.methods({
        'measure.add' (measure) {
          Measures.insert({
            ...measure,
            date: new Date(),
          })
        },
      })

我们现在可以使用Meteor.call函数调用这个方法:

Meteor.call('measure.add', someMeasure)

该方法将在客户端(使用名为 minimongo 的客户端数据库)和服务器上运行。这样,客户端的更新将是即时的。

模拟测量

不要再拖延了,让我们构建一个简单的组件,调用这个measure.add Meteor 方法:

  1. ProductionGenerator.vue的模板中添加两个按钮:
      <template>
        <div class="production-generator">
          <h1>Measure production</h1>

          <section class="actions">
            <button @click="generateMeasure(false)">Generate 
            Measure</button>
            <button @click="generateMeasure(true)">Generate 
            Error</button>
          </section>
        </div>
      </template>
  1. 然后,在组件脚本中,创建generateMeasure方法来生成一些虚拟数据,然后调用measure.add Meteor 方法:
      <script>
      import { Meteor } from 'meteor/meteor'

      export default {
        methods: {
          generateMeasure (error) {
            const value = Math.round(Math.random() * 100)
            const measure = {
              value,
              error,
            }
            Meteor.call('measure.add', measure)
          },
        },
      }
      </script>

组件应该是这样的:

如果您点击按钮,不应该有任何可见的变化。

检查数据

有一种简单的方法可以检查我们的代码是否有效,并验证您是否可以在Measures集合中添加项目。我们可以用一条命令连接到MongoDB数据库。

在另一个终端中,运行以下命令连接到应用程序的数据库:

meteor mongo

然后,输入这个 MongoDB 查询,以获取measures集合的文档(在创建MeasuresMeteor 集合时使用的参数):

db.measures.find({})

如果您点击了按钮,应该显示一列测量文档:

这意味着我们的 Meteor 方法有效,并且对象已插入到我们的 MongoDB 数据库中。

仪表板和报告

现在我们的第一页做好了,我们可以继续实时仪表板。

进度条库

为了显示一些漂亮的指示器,让我们安装另一个 Vue 库,允许沿 SVG 路径绘制进度条;这样,我们可以有半圆形的进度条:

  1. vue-progress-pathnpm 包添加到项目中:
 meteor npm i -S vue-progress-path

我们需要告诉 Meteor 的 Vue 编译器不要处理安装包的node_modules中的文件。

  1. 在项目根目录创建一个新的.vueignore文件。这个文件像.gitignore一样工作:每一行都是忽略某些路径的规则。如果以斜杠/结尾,它将只忽略相应的文件夹。因此,.vueignore的内容应该如下所示:
      node_modules/
  1. 最后,在client/main.js文件中安装vue-progress-path插件:
 import 'vue-progress-path/dist/vue-progress-path.css'
      import VueProgress from 'vue-progress-path'

      Vue.use(VueProgress, {
        defaultShape: 'semicircle',
      })

Meteor 发布

为了同步数据,客户端必须订阅服务器上声明的发布。Meteor 发布是一个返回 Meteor 集合查询的函数。它可以接受参数来过滤将要同步的数据。

对于我们的应用程序,我们只需要一个简单的measures发布,它发送Measures集合的所有文档:

  1. 这段代码应该只在服务器上运行。因此,在project文件夹中创建一个新的server,并在该文件夹内创建一个新的publications.js文件:
      import { Meteor } from 'meteor/meteor'
      import { Measures } from '../lib/collections'

      Meteor.publish('measures', function () {
        return Measures.find({})
      })

这段代码只会在服务器上运行,因为它位于一个名为server的文件夹中。

创建仪表板组件

我们已经准备好构建我们的ProductionDashboard组件。由于我们之前安装的vue-meteor-tracker,我们有一个新的组件定义选项--meteor。这是一个描述需要订阅的发布和需要为该组件检索的集合数据的对象。

  1. 添加以下带有meteor定义选项的脚本部分:
      <script>
      export default {
        meteor: {
          // Subscriptions and Collections queries here
        },
      }
      </script>
  1. meteor选项内,使用$subscribe对象订阅measures发布:
      meteor: {
        $subscribe: {
          'measures': [],
        },
      },

空数组意味着我们没有向发布传递参数。

  1. 使用meteor选项内的Measures Meteor 集合上的查询来检索测量值:
      meteor: {
        // ...

        measures () {
          return Measures.find({}, {
            sort: { date: -1 },
          })
        },
      },

find方法的第二个参数是一个选项对象,非常类似于 MongoDB JavaScript API。在这里,我们通过选项对象的sort属性,按照它们的日期降序排序文档。

  1. 最后,创建measures数据属性,并将其初始化为空数组。

组件的脚本现在应该是这样的:

      <script>
      import { Measures } from '../../lib/collections'

      export default {
        data () {
          return {
            measures: [],
          }
        },

        meteor: {
          $subscribe: {
            'measures': [],
          },

          measures () {
            return Measures.find({}, {
              sort: { date: -1 },
            })
          },
        },
      }
      </script>

在浏览器开发工具中,您现在可以检查组件是否已从集合中检索到项目。

指标

我们将为仪表板指标创建一个单独的组件,如下所示:

  1. components文件夹中,创建一个新的ProductionIndicator.vue组件。

  2. 声明一个模板,显示进度条、标题和额外的信息文本:

      <template>
        <div class="production-indicator">
          <loading-progress :progress="value" />
          <div class="title">{{ title }}</div>
          <div class="info">{{ info }}</div>
        </div>
      </template>
  1. 添加valuetitleinfo属性:
      <script>
      export default {
        props: {
          value: {
            type: Number,
            required: true,
          },
          title: String,
          info: [String, Number],
        },
      }
      </script>
  1. 回到我们的ProductionDashboard组件,让我们计算平均值和错误率:
      computed: {
        length () {
          return this.measures.length
        },

        average () {
          if (!this.length) return 0
          let total = this.measures.reduce(
            (total, measure) => total += measure.value,
            0
          )
          return total / this.length
        },

        errorRate () {
          if (!this.length) return 0
          let total = this.measures.reduce(
            (total, measure) => total += measure.error ? 1 : 0,
            0
          )
          return total / this.length
        },
      },

在前面的代码片段中,我们使用length计算属性缓存了measures数组的长度。

  1. 在模板中添加两个指标 - 一个用于平均值,一个用于错误率:
      <template>
        <div class="production-dashboard">
          <h1>Production Dashboard</h1>

          <section class="indicators">
            <ProductionIndicator
              :value="average / 100"
              title="Average"
              :info="Math.round(average)"
            />
            <ProductionIndicator
              class="danger"
              :value="errorRate"
              title="Errors"
              :info="`${Math.round(errorRate * 100)}%`"
            />
          </section>
        </div>
      </template>

不要忘记将ProductionIndicator导入到组件中!

指标应该是这样的:

列出测量

最后,我们将在指示器下方显示测量列表:

  1. 为每个测量添加一个简单的<div>元素列表,如果有错误则显示日期和值:
      <section class="list">
        <div
          v-for="item of measures"
          :key="item._id"
        >
          <div class="date">{{ item.date.toLocaleString() }}</div>
          <div class="error">{{ item.error ? 'Error' : '' }}</div>
          <div class="value">{{ item.value }}</div>
        </div>
      </section>

应用程序现在应该如下所示,带有导航工具栏、两个指示器和测量列表:

如果您在另一个窗口中打开应用程序并将窗口并排放置,您可以看到 Meteor 的全栈响应性。在一个窗口中打开仪表板,在另一个窗口中打开生成器页面。然后,添加虚拟测量,并观察另一个窗口中的数据实时更新。

如果您想了解更多关于 Meteor 的信息,请访问官方网站(www.meteor.com/developers)和 Vue 集成存储库(github.com/meteor-vue/vue-meteor)。

总结

在这最后一章中,我们使用了一个名为 Meteor 的新全栈框架创建了一个项目。我们将 Vue 集成到应用程序中,并设置了一个 Meteor 响应式集合。使用 Meteor 方法,我们将文档插入到集合中,并实时在仪表板组件中显示数据。

这本书可能已经结束了,但你使用 Vue 的旅程才刚刚开始。我们从模板和响应式数据的基本概念开始,编写简单的应用程序,而无需任何构建工具。即使没有太多负担,我们也能制作一个 Mardown 笔记本,甚至是带有动画的浏览器卡牌游戏。然后,我们开始使用我们可以使用的全部工具来制作更大的应用程序。官方命令行工具--vue-cli--在搭建项目方面非常有帮助。单文件组件(.vue文件)使组件易于维护和演变。我们甚至可以非常轻松地使用预处理语言,比如 stylus。vue-router 官方库是管理多个页面的必备工具,就像我们在第五章中所做的那样,项目 3-支持中心,具有良好的用户系统和私有路由。接下来,我们通过使用官方的 Vuex 库,在可扩展和安全的方式上构建了具有高级功能的地理定位博客,比如 Google OAuth 和 Google Maps。然后,我们通过使用 ESLint 提高了我们在线商店代码的质量,并为我们的组件编写了单元测试。我们甚至为应用程序添加了本地化和服务器端渲染,所以现在它具有非常专业的感觉。

你现在可以通过改进我们构建的项目来练习,甚至可以开始你自己的项目。使用 Vue 将提高你的技能,但你也可以参加活动,在线与社区交流,参与其中(github.com/vuejs/vue),或帮助他人学习 Vue。分享你的知识只会增加你自己的知识,你会变得更擅长你所做的事情。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报