Vue2-Web-开发完全手册-全-

Vue2 Web 开发完全手册(全)

原文:zh.annas-archive.org/md5/E8B4B21F7ACD89D5DD2A27CD73B2E070

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这个学习路径分为三个部分,每个部分都会让您更接近使用 Vue.js 2 开发高端现代 Web 应用程序。它从构建示例应用程序开始,以便熟悉 Vue.js 生态系统。您将通过创建三个单页面应用程序来学习使用 Vue.js,这些应用程序将探索 Vuex 和 vue-router,这是用于缓存数据和为应用程序路由 URL 的标准 Vue 工具。进一步地,学习路径将解决使用 Vue.js 设计 Web 应用程序时遇到的一些挑战。

学习路径将提供易于跟随的示例,帮助您解决挑战并构建动态的前端。您将学习如何集成 Babel 和 Webpack 等 Web 实用工具,以增强开发工作流程。最后,在课程的最后,本书将向您介绍几种设计模式,以帮助您使用 Vue 框架编写干净、可维护和可重用的代码。

在学习路径的最后,您将自信地掌握利用 Vue.js 的所有组件和生产力功能,并将在设计您的 Web 应用程序并编写干净代码的过程中前进。

本书适用对象

本学习路径适用于任何水平的 JavaScript 开发人员,他们希望学习 Vue.js 并使用最新 Vue.js 的强大功能开发高效的 Web 应用程序。

本书内容

第一章开始使用 Vue.js,展示了如何通过包含 JavaScript 文件来开始使用 Vue。然后我们将进入初始化第一个 Vue 实例,并查看数据对象,以及检查计算函数和属性,最后学习 Vue 方法。

第二章显示、循环、搜索和过滤数据,描述了如何使用 Vue 显示列表和更复杂的数据,使用v-ifv-elsev-for。然后它介绍了如何使用表单元素过滤列表,然后根据数据应用条件性 CSS 类。

[第三章],优化我们的应用程序并使用组件显示数据,是关于通过减少重复和逻辑组织代码来优化我们的 Vue.js 代码。完成后,它将介绍如何创建 Vue 组件并与 Vue 一起使用它们,如何在组件中使用 props 和 slots,并利用事件在组件之间传递数据。

《第四章》,《使用 Dropbox API 获取文件列表》,介绍了加载和查询 Dropbox API 以及从 Dropbox 帐户中列出目录和文件。然后介绍了如何为应用程序添加加载状态以及使用 Vue 动画。

《第五章》,《通过文件树导航和从 URL 加载文件夹》,解释了如何为文件和文件夹创建组件,并在文件夹组件中添加链接以更新目录列表。还介绍了如何在文件组件中添加下载按钮,并创建了一个面包屑组件,以便用户可以轻松地向上导航树并动态更新浏览器 URL,因此如果文件夹被收藏夹或链接被分享,将加载正确的文件夹。

《第六章》,《使用 Vuex 缓存当前文件夹结构》,介绍了如何开始使用 Vuex,并从 Vuex 存储中存储和检索数据。然后介绍了如何将 Vuex 与我们的 Dropbox 应用程序集成,如何缓存当前 Dropbox 文件夹的内容,并在需要时从存储加载数据。

《第七章》,《预缓存其他文件夹和文件以加快导航速度》,描述了预缓存文件夹的过程,存储父文件夹的内容,以及如何缓存文件的下载链接。

《第八章》,《介绍 Vue-Router 和加载基于 URL 的组件》,探讨了 Vue-Router 的初始化及其选项,以及如何使用 Vue-Router 创建链接。然后介绍了如何根据 URL 创建动态路由来更新视图。然后,它描述了如何在 URL 中使用 props,嵌套和命名路由,并进行编程导航。

《第九章》,《使用 Vue-Router 动态路由加载数据》,介绍了我们的组件和路由的概述,加载产品 CSV 文件并创建具有图像和产品变体的单个产品页面。

《第十章》,《构建电子商务商店,浏览产品》,介绍了如何创建一个具有特定产品的主页列表页面,创建一个可重用组件的类别页面,创建一个订购机制,动态创建过滤器,并允许用户对产品进行过滤。

《第十一章》,《构建电子商务商店,添加结账功能》,介绍了构建功能的过程,允许用户将产品添加到购物篮中并删除,允许用户结账并添加订单确认页面。

第十二章使用 Vue Dev Tools 和测试您的 SPA,介绍了使用 Vue 开发工具与我们开发的应用程序,并概述了测试工具和应用程序。

第十三章过渡和动画,您将了解过渡和动画如何为应用程序带来更多生机。您还将与外部 CSS 库集成。

第十四章Vue 与互联网通信,是您进行第一个 AJAX 调用、创建表单和完整的 REST 客户端(以及服务器!)的地方。

第十五章单页应用程序,是您使用 vue-router 创建静态和动态路由以创建现代 SPA 的地方。

第十六章组织+自动化+部署=Webpack,是您将精确制作的组件发布到 npm 并学习 Webpack 和 Vue 如何一起使用的地方。

第十七章高级 Vue.js,是您探索指令、插件、函数式组件和 JSX 的地方。

第十八章使用 Vuex 构建大型应用程序模式,是您使用经过测试的模式来构建应用程序的地方,使用 Vuex 确保应用程序易于维护和高性能。

第十九章与其他框架集成,是您使用 Vue 和 Electron、Firebase、Feathers 和 Horizon 构建四个不同应用程序的地方。

第二十章Vue 路由模式,描述了路由是任何 SPA 中至关重要的一部分。本章重点介绍了 Vue 路由,并介绍了如何在多个页面之间进行路由。它涵盖了从匹配路径和组件到使用导航参数、正则表达式等进行动态匹配的所有内容。

第二十一章使用 Vuex 进行状态管理,演示了使用 Vuex 进行状态管理。它首先介绍了 Flux 架构和单向数据流。然后,它介绍了 Vue 的状态管理系统 Vuex。本章还介绍了如何在应用程序中实现这一功能,以及常见的陷阱和使用模式。它还介绍了Vue-devtools用于捕获操作和 Vue 实例数据。

为了充分利用本书

对于本书,读者需要以下内容:

  • 一个文本编辑器或 IDE 用于编写代码。它可以像记事本或 TextEdit 一样简单,但建议使用带有语法高亮的编辑器,如 Sublime Text、Atom 或 Visual Studio Code。

  • 一个网络浏览器。

  • 一个 Dropbox 用户帐户,其中包含文件和文件夹。

下载示例代码文件

您可以从您在www.packt.com上的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册以直接通过电子邮件接收文件。

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

  1. www.packt.com上登录或注册。

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

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

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

  • WinRAR/7-Zip 适用于 Windows

  • Mac 上的 Zipeg/iZip/UnRarX

  • 7-Zip/PeaZip 适用于 Linux

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

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上获取。请查看!

使用的约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"只需将要激活的图层的名称分配给VK_INSTANCE_LAYERS环境变量。"

代码块设置如下:

<div id="app">
        {{ calculateSalesTax(shirtPrice) }}
      </div>

命令行输入或输出显示如下:

$ npm install
$ npm run dev

粗体:新术语和重要单词以粗体显示。屏幕上显示的单词,例如菜单或对话框中的单词,以这种方式出现在文本中:"从“管理”面板中选择“系统信息”。"

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

第一章:开始使用 Vue.js

Vue(发音为 view)是一个非常强大的 JavaScript 库,用于构建交互式用户界面。尽管具有处理大型单页应用程序的能力,但 Vue 也非常适合为小型个别用例提供框架。它的文件大小很小,这意味着它可以集成到现有生态系统中而不会增加太多冗余。

它被设计为具有简单的 API,这使得与其竞争对手 React 和 Angular 相比更容易入门。尽管它借鉴了这些库的一些逻辑和方法,但它已经确定开发人员需要一个更简单的库来构建应用程序。

与 React 或 Angular 不同,Vue 的一个优点是它产生的 HTML 输出干净。其他 JavaScript 库往往会在代码中散布额外的属性和类,而 Vue 会删除这些内容以产生干净、语义化的输出。

在本章中,我们将看到:

  • 如何通过包含 JavaScript 文件来开始使用 Vue

  • 如何初始化第一个 Vue 实例并查看数据对象

  • 检查计算函数和属性

  • 学习 Vue 方法

创建工作空间

要使用 Vue,我们首先需要在 HTML 中包含该库并进行初始化。在本书的第一部分示例中,我们将在单个 HTML 页面中构建应用程序。这意味着用于初始化和控制 Vue 的 JavaScript 将放置在页面底部。这将使我们的所有代码都保持在一个地方,并且意味着它可以轻松在计算机上运行。打开您喜欢的文本编辑器并创建一个新的 HTML 页面。使用以下模板作为起点:

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

主要的 HTML 标签和结构对您来说应该是熟悉的。让我们再看一些其他方面。

应用空间

这是您的应用程序容器,并为 Vue 提供了一个工作画布。所有的 Vue 代码都将放置在这个标签中。实际的标签可以是任何 HTML 元素-主要是 main、section 等等。元素的 ID 需要是唯一的,但可以是任何您希望的。这允许您在一个页面上拥有多个 Vue 实例,或者确定哪个 Vue 实例与哪个 Vue 代码相关:

      <div id="app">
      </div>

在教程中,这个带有 ID 的元素将被称为应用空间或视图。需要注意的是,所有 HTML、标签和应用程序的代码都应放在这个容器中。

尽管您可以使用大多数 HTML 标签作为应用程序空间,但您不能在<body><HTML>标签上初始化 Vue-如果这样做,Vue 将抛出 JavaScript 错误并无法初始化。您必须在 body 内使用一个元素。

Vue 库

在本书的示例中,我们将使用来自 CDN(内容分发网络)unpkg 的 Vue.js 的托管版本。这确保我们的应用程序中有最新版本的 Vue,并且还意味着我们不需要创建和托管其他 JavaScript 文件。Unpkg 是一个独立的托管流行库的网站。它使您能够快速轻松地将 JavaScript 包添加到您的 HTML 中,而无需下载和托管文件:

      <script src="https://unpkg.com/vue"></script>

在部署代码时,最好从本地文件提供库,而不是依赖于 CDN。这样可以确保您的实现将与当前保存的版本一起工作,以防他们发布更新。它还会增加应用程序的速度,因为它不需要从另一个服务器请求文件。

在库包含之后的script块是我们将为 Vue 应用程序编写所有 JavaScript 的地方。

初始化 Vue 并显示第一条消息

现在我们已经设置好了一个模板,我们可以使用以下代码初始化 Vue 并将其绑定到 HTML 应用空间:

      const app = new Vue().$mount('#app');

此代码创建了一个 Vue 的新实例,并将其挂载在具有 ID 为app的 HTML 元素上。如果您保存文件并在浏览器中打开它,您会注意到没有发生任何事情。然而,在幕后,这一行代码将divapp变量链接在一起,app是 Vue 应用程序的一个实例。

Vue 本身有许多对象和属性,我们现在可以使用它们来构建我们的应用程序。您将遇到的第一个是el属性。使用 HTML ID,此属性告诉 Vue 应该绑定到哪个元素以及应用程序将被包含在哪里。这是挂载 Vue 实例的最常见方式,所有 Vue 代码都应该在此元素内进行:

      const app = new Vue({
        el: '#app'
      });

当实例中未指定el属性时,Vue 会以未挂载状态初始化 - 这允许您在挂载之前运行和完成任何指定的函数或方法。然后,您可以在准备好时独立调用挂载函数。在幕后,当使用el属性时,Vue 使用$.mount函数来挂载实例。如果您确实想要等待,可以单独调用$mount函数,例如:

      const app = new Vue();

      // When ready to mount app:
      app.$mount('#app');

然而,由于我们不需要在整本书中延迟执行我们的挂载时间,我们可以使用el元素与 Vue 实例。使用el属性也是挂载 Vue 应用程序的最常见方式。

除了el值之外,Vue 还有一个包含我们需要访问应用程序或应用程序空间的任何数据的data对象。在 Vue 实例中创建一个新的数据对象,并通过以下方式为属性分配一个值:

      const app = new Vue({
        el: '#app',

        data: {
 message: 'Hello!'
 }
      });

在应用程序空间中,我们现在可以访问message变量。为了在应用程序中显示数据,Vue 使用 Mustache 模板语言来输出数据或变量。这可以通过将变量名放在双花括号{{ variable }}之间来实现。逻辑语句,如ifforeach,获取 HTML 属性,这将在本章后面介绍。

在应用程序空间中,添加代码以输出字符串:

      <div id="app">
        {{ message }}
      </div>

保存文件,用浏览器打开,您应该看到 Hello!字符串。

如果您没有看到任何输出,请检查 JavaScript 控制台以查看是否有任何错误。确保远程 JavaScript 文件正确加载,因为某些浏览器和操作系统在本地计算机上查看页面时需要额外的安全步骤才能加载某些远程文件。

data对象可以处理多个键和数据类型。向数据对象添加一些更多的值并查看发生了什么 - 确保在每个值后面添加逗号。数据值是简单的 JavaScript,也可以处理基本的数学运算 - 尝试添加一个新的price键并将值设置为18 + 6,看看会发生什么。或者,尝试添加一个 JavaScript 数组并将其打印出来:

      const app = new Vue({
        el: '#app',

        data: {
         message: 'Hello!',
 price: 18 + 6,
 details: ['one', 'two', 'three']
       }
     });

在您的应用程序空间中,您现在可以输出每个值 - {{ price }}{{ details }}现在输出数据 - 尽管列表可能不是您所期望的。我们将在第二章“显示、循环、搜索和过滤数据”中介绍使用和显示列表。

Vue 中的所有数据都是响应式的,可以由用户或应用程序进行更新。可以通过打开浏览器的 JavaScript 控制台并自己更新内容来进行测试。尝试输入app.message = 'Goodbye!';并按下Enter键-您的应用程序的内容将更新。这是因为您直接引用了属性-第一个app指的是您在 JavaScript 中初始化应用程序的const app变量。句点表示其中的属性,而message表示数据键。您还可以将app.detailsprice更新为任何您想要的内容!

计算属性

Vue 中的data对象非常适合直接存储和检索数据,但是在应用程序中输出数据之前,可能有时需要对数据进行操作。我们可以使用 Vue 中的computed对象来实现这一点。使用这种技术,我们能够开始遵循MVVMModel-View-ViewModel)方法论。

MVVM 是一种软件架构模式,它将应用程序的各个部分分离为不同的部分。模型(或数据)是您的原始数据输入-无论是来自 API、数据库还是硬编码的数据值。在 Vue 的上下文中,这通常是我们之前使用的data对象。

视图是应用程序的前端。这只应用于从模型输出数据,并且不应包含任何逻辑或数据操作,除非有一些不可避免的if语句。对于 Vue 应用程序,这是放置在<div id="app"></div>标签中的所有代码。

视图模型是两者之间的桥梁。它允许您在视图输出之前对模型中的数据进行操作。例如,将字符串更改为大写或添加货币符号前缀,或者从列表中过滤出折扣产品或计算数组中字段的总值等。在 Vue 中,这就是computed对象的作用。

计算对象可以有任意数量的属性-但是它们必须是函数。这些函数可以利用 Vue 实例上已有的数据并返回一个值,无论是字符串、数字还是数组,然后可以在视图中使用。

第一步是在 Vue 应用程序中创建一个计算对象。在本例中,我们将使用计算属性将字符串转换为小写,因此将message的值设置回字符串:

      const app = new Vue({
          el: '#app',

        data: {
           message: 'Hello Vue!'
       },
          computed: {
 }
      });

不要忘记在数据对象的闭合大括号(})后面添加逗号(,),这样 Vue 就知道要期望一个新的对象。

下一步是在计算对象内创建一个函数。开发中最困难的部分之一是给事物命名 - 确保函数的名称具有描述性。由于我们的应用程序非常小且操作基本,我们将其命名为messageToLower

      const app = new Vue({
        el: '#app',
        data: {
          message: 'HelLO Vue!'
        },
        computed: {
          messageToLower() {
 return 'hello vue!';
 }
        }
     });

在上面的示例中,我设置它返回一个硬编码的字符串,该字符串是message变量内容的小写版本。计算函数可以像在视图中使用数据键一样使用。将视图更新为输出{{ messageToLower }}而不是{{ message }},然后在浏览器中查看结果。

然而,这段代码存在一些问题。首先,如果messageToLower的值是硬编码的,我们可以将其添加到另一个数据属性中。其次,如果message的值发生变化,小写版本将不再正确。

在 Vue 实例内部,我们可以使用this变量访问数据值和计算值 - 我们将更新函数以使用现有的message值:

      computed: {
        messageToLower() {
          return this.message.toLowerCase();
        }
      }

messageToLower函数现在引用现有的message变量,并使用原生 JavaScript 函数将字符串转换为小写。尝试在应用程序中或 JavaScript 控制台中更新message变量,以查看其更新。

计算函数不仅仅限于基本功能 - 记住,它们的设计目的是从视图中删除所有逻辑和操作。一个更复杂的例子可能是以下内容:

      const app = new Vue({
        el: '#app',
             data: {
          price: 25,
          currency: '$',
          salesTax: 16
        },
        computed: {
          cost() {
       // Work out the price of the item including 
          salesTax
            let itemCost = parseFloat(
              Math.round((this.salesTax / 100) * 
              this.price) + this.price).toFixed(2);
            // Add text before displaying the currency and   
             amount
            let output = 'This item costs ' + 
            this.currency + itemCost;
           // Append to the output variable the price 
             without salesTax
             output += ' (' + this.currency + this.price + 
        ' excluding salesTax)';
             // Return the output value
              return output;
           }
        }
     });

虽然乍一看这可能很高级,但代码是将一个固定价格与销售税相加来计算最终价格。pricesalesTax和货币符号都作为数据对象上的值存储,并在cost()计算函数中进行访问。视图输出{{ cost }},产生以下结果:

此项商品价格为$29.00(不含销售税为$25)

如果更新了任何数据,无论是用户还是应用程序本身,计算函数都会重新计算和更新。这使得我们的函数可以根据pricesalesTax值动态更新。在浏览器的控制台中尝试以下命令之一:

 app.salesTax = 20
 app.price = 99.99

段落和价格将立即更新。这是因为计算函数对data对象和应用程序的其余部分都是响应式的。

方法和可重用函数

在您的 Vue 应用程序中,您可能希望以一致或重复的方式计算或操作数据,或者运行不需要输出到视图的任务。例如,如果您想要计算每个价格的销售税或从 API 检索一些数据然后将其分配给某些变量。

与其为每次需要执行此操作时创建计算函数,Vue 允许您创建函数或方法。这些在您的应用程序中声明,并且可以从任何地方访问-类似于datacomputed函数。

在您的 Vue 应用程序中添加一个方法对象,并注意数据对象的更新:

      const app = new Vue({
        el: '#app',

        data: {
          shirtPrice: 25,
          hatPrice: 10,

          currency: '$',
          salesTax: 16
        },
        methods: {

 }
      });

data对象中,price键已被两个价格shirtPricehatPrice替换。我们将创建一个方法来计算这些价格的销售税。

类似于为计算对象创建函数,创建一个名为calculateSalesTax的方法函数。此函数需要接受一个参数,即price。在内部,我们将使用前面示例中的代码来计算销售税。请记住将this.price替换为参数名price,如下所示:

      methods: {
        calculateSalesTax(price) {
          // Work out the price of the item including   
          sales tax
          return parseFloat(
          Math.round((this.salesTax / 100) * price) 
         + price).toFixed(2);
        }
      }

保存不会对我们的应用程序产生任何影响-我们需要调用该函数。在您的视图中,更新输出以使用该函数并传入shirtPrice变量:

      <div id="app">
        {{ calculateSalesTax(shirtPrice) }}
      </div>

保存您的文档并在浏览器中检查结果-是否符合您的预期?下一个任务是在数字前面添加货币符号,我们可以通过添加第二个方法来实现,该方法返回带有货币符号的参数传递到函数中的数字。

      methods: {
        calculateSalesTax(price) {
          // Work out the price of the item including 
          sales tax
          return parseFloat(
            Math.round((this.salesTax / 100) * price) +   
            price).toFixed(2);
         },
         addCurrency(price) {
 return this.currency + price;
 }
      }

在我们的观点中,我们更新我们的输出以利用这两种方法。我们可以将第一个函数calculateSalesTax作为第二个addCurrency函数的参数传递,而不是赋值给一个变量。这是因为第一个方法calculateSalesTax接受shirtPrice参数并返回新的金额。我们不再将其保存为变量并将变量传递到addCurrency方法中,而是直接将结果传递到这个函数中,即计算出的金额:

      {{ addCurrency(calculateSalesTax(shirtPrice)) }}

然而,每次需要输出价格时都写这两个函数会变得很烦人。从这里开始,我们有两个选择:

  • 我们可以创建一个名为cost()的第三个方法,它接受价格参数并将输入通过这两个函数传递。

  • 创建一个计算属性函数,例如shirtCost,它使用this.shirtPrice而不是传入参数。

我们也可以创建一个名为shirtCost的方法,它与我们的计算函数相同;然而,在这种情况下最好使用计算属性进行练习。

这是因为computed函数是被缓存的,而method函数不是。如果想象一下我们的方法比它们目前更复杂,反复调用函数(例如,如果我们想在多个位置显示价格)可能会对性能产生影响。使用计算属性函数,只要数据不变,您可以随意调用它,应用程序会将结果缓存起来。如果数据发生变化,它只需要重新计算一次,并重新缓存该结果。

shirtPricehatPrice都创建计算属性函数,以便两个变量都可以在视图中使用。不要忘记在内部调用函数时必须使用this变量 - 例如,this.addCurrency()。使用以下 HTML 代码作为视图的模板:

      <div id="app">
        <p>The shirt costs {{ shirtCost }}</p>
        <p>The hat costs {{ hatCost }}</p>
      </div>

在与以下代码进行比较之前,请尝试自己创建计算属性函数。不要忘记在开发中有很多方法可以做事情,所以如果您的代码能够正常工作但与以下示例不匹配,也不要担心:

      const app = new Vue({
        el: '#app',
        data: {
          shirtPrice: 25,
          hatPrice: 10,

          currency: '$',
          salesTax: 16
        },
        computed: {
          shirtCost() {
            returnthis.addCurrency(this.calculateSalesTax(
              this.shirtPrice))
          },
          hatCost() {
          return this.addCurrency(this.calculateSalesTax(
          this.hatPrice))
          },
        },
        methods: {
          calculateSalesTax(price) {
            // Work out the price of the item including 
            sales tax
            return parseFloat(
              Math.round((this.salesTax / 100) * price) + 
              price).toFixed(2);
                },
                addCurrency(price) {
            return this.currency + price;
          }
        }
      });

尽管基本,但结果应该如下所示:

摘要

在本章中,我们学习了如何开始使用 Vue JavaScript 框架。我们检查了 Vue 实例中的datacomputedmethods对象。我们介绍了如何在框架中使用每个对象并利用它们的优势。

第二章:显示、循环、搜索和过滤数据

在第一章《开始使用 Vue.js》中,我们介绍了 Vue 中的datacomputedmethod对象以及如何显示静态数据值。在本章中,我们将介绍以下内容:

  • 使用v-ifv-elsev-for在 Vue 中显示列表和更复杂的数据

  • 使用表单元素过滤列表

  • 根据数据应用条件性 CSS 类

我们将使用 JSON 生成器服务(www.json-generator.com/)随机生成要使用的数据。这个网站允许我们获取虚拟数据进行练习。以下模板用于生成我们将使用的数据。将以下内容复制到左侧以生成相同格式的数据,以便属性与代码示例匹配,如下所示:

      [
        '{{repeat(5)}}',
        {
          index: '{{index()}}',
          guid: '{{guid()}}',
          isActive: '{{bool()}}',
          balance: '{{floating(1000, 4000, 2, "00.00")}}',
          name: '{{firstName()}} {{surname()}}',
          email: '{{email()}}',
          registered: '{{date(new Date(2014, 0, 1), new Date(), "YYYY-            
         MM-ddThh:mm:ss")}}'
        }
      ]

在构建我们的简单应用程序并显示用户之前,我们将介绍 Vue 的更多功能和视图中可用的 HTML 特定属性。这些功能从动态渲染内容到循环遍历数组等。

HTML 声明

Vue 允许您使用 HTML 标签和属性来控制和修改应用程序的视图。这包括动态设置属性,如althref。它还允许您根据应用程序中的数据来渲染标签和组件。这些属性以v-开头,并且如本书开头所提到的,在渲染时会从 HTML 中删除。在我们开始输出和过滤数据之前,我们将介绍一些常见的声明。

v-html

v-html指令允许您在不使用花括号语法的情况下输出内容。如果输出包含 HTML 标签,它也可以用于将输出呈现为 HTML 而不是纯文本。HTML 属性的值是数据键或计算函数名称的值:

View:

在您的视图应用空间中,将v-html属性添加到一个元素中:

      <div id="app">
        <div v-html="message"></div>
      </div>

JavaScript:

在 JavaScript 中,将message变量设置为包含一些 HTML 元素的字符串:

      const app = new Vue({
        el: '#app',

        data: {
          message: '<h1>Hello!</h1>'
        }
      });

您应该尽量避免向 Vue 实例中添加 HTML,因为这会混淆我们的 MVVM 结构中的视图、视图模型和模型。还存在一种危险,即在另一个 HTML 标签中输出无效的 HTML 标签。只有在您完全信任的数据上使用v-html,因为与外部 API 一起使用它可能会带来安全问题,因为它允许 API 控制您的应用程序。潜在的恶意 API 可以使用v-html来注入不需要的内容和 HTML。只使用您完全信任的数据来使用v-html

声明式渲染

使用v-bind:属性可以动态填充常规 HTML 属性,例如<img>标签的src属性。这允许您使用 Vue 应用程序中的数据填充任何现有属性。这可能是图像源或元素 ID。

bind选项通过在要填充的属性前面添加前缀来使用。例如,如果您希望使用名为imageSource的数据键的值填充图像源,则可以执行以下操作:

View:

在视图应用空间中创建一个 img 标签,其中包含一个动态的src属性,使用v-bind和一个名为imageSource的变量。

      <div id="app">
        <img v-bind:src="imageSource">
      </div>

JavaScript:

在 Vue JavaScript 代码中创建一个名为imageSource的变量,并添加所需图像的 URL:

      const app = new Vue({
        el: '#app',

        data: {
          imageSource: 'http://via.placeholder.com/350x150'
        }
      });

v-bind:属性可以缩写为:,例如,v-bind:src可以简写为:src

条件渲染

使用自定义 HTML 声明,Vue 允许您根据数据属性或 JavaScript 声明有条件地渲染元素和内容。这些包括v-if,用于在声明等于 true 时显示容器,以及v-else,用于显示替代内容。

v-if

最基本的示例是v-if指令-确定是否显示块的值或函数。

在视图中创建一个 Vue 实例,其中包含一个div元素和一个名为isVisible的数据键,其值设置为false

View:

从以下视图代码开始:

      <div id="app">
        <div>Now you see me</div>
      </div>

JavaScript:

在 JavaScript 中,初始化 Vue 并创建一个名为isVisible的数据属性:

      const app = new Vue({
        el: '#app',

        data: {
          isVisible: false
        }
      });

现在,您的 Vue 应用程序将显示元素的内容。现在在 HTML 元素中添加v-if指令,并将其值设置为isVisible

      <div id="app">
        <div v-if="isVisible">Now you see me</div>
      </div>

保存后,您的文本应该消失。这是因为标签根据当前值有条件地呈现,而当前值为false。如果您打开 JavaScript 控制台并运行以下代码,则应该重新显示元素:

      app.isVisible = true

v-if 不仅适用于布尔值 true/false。你可以检查一个数据属性是否等于特定的字符串:

      <div v-if="selected == 'yes'">Now you see me</div>

例如,上述代码检查一个选定的数据属性是否等于 yes 的值。v-if 属性接受 JavaScript 运算符,因此可以检查不等于、大于或小于。

这里的危险在于你的逻辑开始从 ViewModel 中渗入到 View 中。为了解决这个问题,该属性还可以接受函数作为值。该方法可以是复杂的,但最终必须返回 true(如果你希望显示代码)或 false(如果不希望显示代码)。请注意,如果函数返回除 false 值(如 0false)之外的任何值,则结果将被解释为 true。

这将看起来像这样:

      <div v-if="isSelected">Now you see me</div>

你的方法可以是这样的:

      isSelected() {
        return selected == 'yes';
      }

如果你不希望完全删除元素,只是隐藏它,有一个更合适的指令 v-show。它会应用 CSS 的 display 属性,而不是操作 DOM - v-show 将在本章后面介绍。

v-else

v-else 允许根据 v-if 语句的相反条件渲染一个替代元素。如果结果为 true,将显示第一个元素;否则,将显示包含 v-else 的元素。

具有 v-else 的元素需要直接跟在包含 v-if 的元素后面;否则,你的应用程序将抛出错误。

v-else 没有值,并且放置在元素标签内。

      <div id="app">
        <div v-if="isVisible">
          Now you see me
        </div>
        <div v-else>
          Now you don't
        </div>
      </div>

将上述 HTML 添加到你的应用空间中将只显示一个 <div> 元素 - 在控制台中切换值,就像我们之前做的那样,将显示另一个容器。如果你希望链接你的条件,你也可以使用 v-else-ifv-else-if 的一个示例如下:

      <div id="app">
        <div v-if="isVisible">
          Now you see me
        </div>
        <div v-else-if="otherVisible">
          You might see me
        </div>
        <div v-else>
          Now you don't
        </div>
      </div>

如果 isVisible 变量等于 false,则可能会显示 You might see me,但 otherVisible 变量等于 true

v-else 应该谨慎使用,因为它可能会产生歧义,并导致错误的情况。

v-for 和显示数据

下一个 HTML 声明意味着我们可以开始显示我们的数据并将其中一些属性应用到实践中。由于我们的数据是一个数组,我们需要循环遍历它以显示每个元素。为此,我们将使用 v-for 指令。

生成你的 JSON 并将其赋值给一个名为 people 的变量。在这些示例中,生成的 JSON 循环将显示在代码块中,如 [...]。你的 Vue 应用应该如下所示:

      const app = new Vue({
        el: '#app',

        data: {
          people: [...]
        }
      });

现在我们需要在我们的视图中以项目符号列表的形式显示每个人的姓名。这就是v-for指令的用途:

      <div id="app">
        <ul>
          <li v-for="person in people">
            {{ person }}
          </li>
        </ul>
      </div>

v-for循环遍历 JSON 列表,并临时将其分配给person变量。然后我们可以输出变量的值或属性。

v-for循环需要应用于要重复的 HTML 元素,本例中为<li>。如果没有包装元素或者不希望使用 HTML,可以使用 Vue 的<template>元素。这些元素在运行时被移除,同时仍然为您创建一个容器来输出数据:

      <div id="app">
        <ul>
          <template v-for="person in people">
            <li>
              {{ person }}
            </li>
          </template>
        </ul>
      </div>

模板标签还可以隐藏内容,直到应用程序初始化完成,这在您的网络速度较慢或 JavaScript 需要一段时间才能触发时可能会很方便。

仅仅将我们的视图输出为{{ person }}将创建一长串的信息,对我们没有任何用处。更新输出以定位person对象的name属性:

      <li v-for="person in people">
        {{ person.name }}
      </li>

在浏览器中查看结果应该会显示一个用户姓名的列表。更新 HTML 以在表格中列出用户的姓名、电子邮件地址和余额。将v-for应用于<tr>元素:

      <table>
        <tr v-for="person in people">
          <td>{{ person.name }}</td>
          <td>{{ person.email }}</td>
          <td>{{ person.balance }}</td>
          <td>{{ person.registered }}</td>
        </tr>
      </table>

在您的表格中添加一个额外的单元格。这将根据person对象上的isActive属性显示 Active 或 Inactive。可以通过两种方式实现这一点-使用v-if指令或者使用三元的if。三元的 if 是内联的 if 语句,可以放置在视图的花括号中。如果我们想要使用 HTML 元素来应用一些样式,我们将使用v-if

如果我们使用三元的'if',单元格将如下所示:

      <td>{{ (person.isActive) ? 'Active' : 'Inactive' }}</td>

如果我们选择使用v-if选项和v-else,允许我们使用我们希望的 HTML,它将如下所示:

      <td>
        <span class="positive" v-if="person.isActive">Active</span>
        <span class="negative" v-else>Inactive</span>
      </td>

这个 active 元素是 Vue 组件非常理想的一个例子-我们将在第三章中介绍,优化我们的应用程序并使用组件显示数据。作为符合我们的 MVVM 方法论的替代方案,我们可以创建一个方法,该方法返回状态文本。这将整理我们的视图并将逻辑移到我们的应用程序中:

      <td>{{ activeStatus(person) }}</td>

我们的方法将执行与我们的视图相同的逻辑:

activeStatus(person) {
  return (person.isActive) ? 'Active' : 'Inactive';
}

我们的表格现在将如下所示:

使用 v-html 创建链接

下一步是将电子邮件地址链接起来,以便用户在查看人员列表时可以点击。在这种情况下,我们需要在电子邮件地址之前添加mailto:来连接字符串。

第一反应是执行以下操作:

      <a href="mailto:{{person.email}}">{{ person.email }}</a>

但是 Vue 不允许在属性内插值。相反,我们必须在href属性上使用v-bind指令。这将属性转换为 JavaScript 变量,因此任何原始文本必须用引号括起来,并与所需的变量连接起来:

<a v-bind:href="'mailto:' + person.email">{{ person.email }}</a>

注意添加了v-bind:,单引号和连接符+

格式化余额

在继续筛选用户之前,添加一个方法来正确格式化余额,将货币符号定义在数据对象中,并确保小数点后有两个数字。我们可以从第一章“开始使用 Vue.js”中调整我们的方法来实现这一点。我们的 Vue 应用程序现在应该是这样的:

      const app = new Vue({
        el: '#app',

        data: {
          people: [...],
          currency: '$'
        },
        methods: {
          activeStatus(person) {
            return (person.isActive) ? 'Active' : 'Inactive';
          },
          formatBalance(balance) {
            return this.currency + balance.toFixed(2);
          }
        }
    });

我们可以在视图中使用这种新方法:

      <td>{{ formatBalance(person.balance) }}</td>

格式化注册日期

数据中的注册日期字段对计算机友好,但对人类来说不太友好。创建一个名为formatDate的新方法,它接受一个参数,类似于之前的formatBalance方法。

如果您想完全自定义日期的显示,有几个可用的库,例如moment.js,可以更灵活地控制任何日期和基于时间的数据的输出。对于这种方法,我们将使用一个本地的 JavaScript 函数toLocaleString()

      formatDate(date) {
        let registered = new Date(date);
        return registered.toLocaleString('en-US');
      }

有了注册日期,我们将其传递给本地的Date()函数,这样 JavaScript 就知道将字符串解释为日期。一旦存储在 registered 变量中,我们使用toLocaleString()函数将对象返回为字符串。该函数接受大量选项(如 MDN 上所述)来自定义日期的输出。现在,我们将传递所希望显示的区域设置,并使用该位置的默认值。现在我们可以在视图中使用我们的方法:

      <td>{{ formatDate(person.registered) }}</td>

现在每个表行应该如下所示:

筛选我们的数据

现在我们要构建过滤功能来列出我们的数据。这将允许用户选择一个字段进行过滤,并输入他们的查询。Vue 应用程序将在用户输入时过滤行。为此,我们将绑定一些表单输入到data对象中的各个值,创建一个新的方法,并在表格行上使用一个新的指令v-show

构建表单

首先在视图中创建 HTML。创建一个<select>框,为每个要过滤的字段创建一个<option>,一个用于查询的<input>,以及一对单选按钮 - 我们将使用这些按钮来过滤活动和非活动用户。确保每个<option>value属性反映用户数据中的键 - 这将减少所需的代码并使选择框的目的更明显。

要使过滤工作,您过滤的数据不需要显示出来,尽管在这里需要考虑用户体验。如果一个表格行在没有您正在过滤的数据的情况下显示出来,这是否有意义?

创建用于过滤的表单:

      <form>
        <label for="fiterField">
          Field:
          <select id="filterField">
            <option value="">Disable filters</option>
            <option value="isActive">Active user</option>
            <option value="name">Name</option>
            <option value="email">Email</option>
            <option value="balance">Balance</option>
            <option value="registered">Date registered</option>
          </select>
        </label>

        <label for="filterQuery">
          Query:
          <input type="text" id="filterQuery">
        </label>

        <span>
          Active:
          <label for="userStateActive">
            Yes:
            <input type="radio" value="true" id="userStateActive"                   
          selected>
          </label>
          <label for="userStateInactive">
            No:
            <input type="radio" value="false" id="userStateInactive">
          </label>
        </span>
      </form>

该表单包括一个选择框,用于选择要过滤的字段,一个输入框,允许用户输入要过滤的查询,以及一对单选按钮,用于过滤活动和非活动用户。想象中的用户流程是:用户将选择他们希望通过哪个字段来过滤数据,然后输入他们的查询或选择单选按钮。当在选择框中选择isActive(活动用户)选项时,单选按钮将显示,输入框将隐藏。我们已经确保默认选择第一个单选按钮以提供帮助。

过滤输入不需要包含在表单中才能工作;然而,保留语义化的 HTML 是一个好的实践,即使在 JavaScript 应用程序中。

绑定输入

要将输入绑定到可以通过 Vue 实例访问的变量,需要在字段中添加一个 HTML 属性,并在data对象中添加相应的键。为每个字段在data对象中创建一个变量,以便我们可以将表单元素绑定到它们:

      data: {
        people: [...],

        currency: '$',

        filterField: '',
 filterQuery: '',
 filterUserState: ''
      }

现在,data对象有了三个额外的键:filterField,用于存储下拉框的值;filterQuery,用于存储输入到文本框中的数据的占位符;以及filterUserState,允许我们存储单选按钮复选框的值。

现在有了要使用的数据键,我们可以将表单元素绑定到它们上。为每个表单字段应用一个v-model=""属性,其值为数据键。

这是一个例子:

      <input type="text" id="filterQuery" v-model="filterQuery">

确保两个单选按钮具有完全相同的v-model=""属性:这样它们才能更新相同的值。为了验证它是否起作用,你现在可以输出数据变量并获取字段的值。

尝试输出filterFieldfilterQuery并更改字段。

      {{ filterField }}

如果输出filterUserState变量,您可能会注意到它似乎在工作,但是它没有得到期望的实际结果。变量的输出将是在值属性中设置的truefalse

仔细检查后,实际上这些值是字符串,而不是布尔值。布尔值是硬性的truefalse10,您可以轻松地与之进行比较,而字符串则需要对硬编码字符串进行精确检查。可以通过输出typeof变量来验证它是什么类型的:

      {{ typeof filterUserState }}

可以通过使用v-bind:value属性绑定单选按钮的值来解决这个问题。该属性允许您指定 Vue 要解释的值,并且可以接受布尔值、字符串或对象值。现在,我们将传递truefalse,就像我们已经在标准值属性中做的那样,但是 Vue 将知道将其解释为布尔值:

      <span>
        Active:
        <label for="userStateActive">
          Yes:
          <input type="radio" v-bind:value="true" id="userStateActive"       
         v-model="filterUserState" selected>
        </label>
        <label for="userStateInactive">
          No:
          <input type="radio" v-bind:value="false"       
         id="userStateInactive" v-model="filterUserState">
        </label>
      </span>

下一步是根据这些过滤器显示和隐藏表格行。

显示和隐藏 Vue 内容

除了用于显示和隐藏内容的v-if之外,还可以使用v-show=""指令。v-showv-if非常相似;它们都会添加到 HTML 包装器中,并且都可以接受相同的参数,包括一个函数。

两者之间的区别是,v-if会改变标记,根据需要删除和添加 HTML 元素,而v-show无论如何都会渲染元素,通过内联 CSS 样式来隐藏和显示元素。v-if更适合运行时渲染或不频繁的用户交互,因为它可能会重构整个页面。当大量元素快速进入和退出视图时,例如在过滤时,v-show更可取!

当在方法中使用v-show时,函数需要返回一个truefalse。函数不知道它在哪里使用,所以我们需要传入当前正在渲染的人来计算是否应该显示它。

在您的 Vue 实例上创建一个名为filterRow()的方法,并在内部将其设置为return true

      filterRow(person) {
         return true;
      }

该函数接受一个参数,即我们从 HTML 中传递的人。在视图中,将v-show属性添加到具有filterRow作为值的<tr>元素中,同时传递人物对象:

      <table>
        <tr v-for="person in people" v-show="filterRow(person)">
          <td>{{ person.name }}</td>
          ...

作为一个简单的测试,将isActive值返回给人。这应该立即过滤掉任何不活动的人,因为他们的值将返回false

      filterRow(person) {
        return person.isActive;
      }

过滤我们的内容

现在我们对人员行和视图中的一些过滤器控件有了控制,我们需要使我们的过滤器起作用。我们已经通过我们的isActive键进行了过滤,所以单选按钮将是第一个被连接的。我们已经以布尔形式拥有了单选按钮的值和我们将进行过滤的键的值。为了使此过滤器起作用,我们需要将isActive键与单选按钮的值进行比较。

  • 如果filterUserState的值为true,则显示isActivetrue的用户。

  • 然而,如果filterUserState的值为false,则只显示isActive值为false的用户

可以通过比较两个变量来将其写成一行:

      filterRow(person) {
        return (this.filterUserState === person.isActive);
      }

页面加载时,不会显示任何用户,因为filterUserState键既不设置为true也不设置为false。单击其中一个单选按钮将显示相应的用户。

让我们只在下拉菜单中选择活动用户选项时使过滤器起作用:

      filterRow(person) {
        let result = true;

        if(this.filterField === 'isActive') {
          result = this.filterUserState === person.isActive;
        }

        return result;
      }

此代码将一个变量设置为默认值true。然后,我们可以立即返回变量,我们的行将显示。然而,在返回之前,它检查选择框的值,如果是所需的值,则通过我们的单选按钮进行过滤。由于我们的选择框绑定到filterField值,与filterUserState变量一样,它在我们与应用程序交互时更新。尝试在选择框中选择活动用户选项并更改单选按钮。

下一步是在未选择活动用户选项时使用输入查询框。我们还希望我们的查询是一个模糊搜索 - 例如,匹配包含搜索查询的单词而不是完全匹配。我们还希望它是不区分大小写的:

      filterRow(person) {
        let result = true;

        if(this.filterField) {

          if(this.filterField === 'isActive') {
            result = this.filterUserState === person.isActive;
          } else {
 let query = this.filterQuery.toLowerCase(),
 field =  person[this.filterField].toString().toLowerCase(); result = field.includes(query);
 }

        }

        return result;
      }

为了使其工作,我们必须添加一些内容到这个方法中。第一步是检查我们的选择字段是否有一个值来开始过滤。由于我们的选择字段中的第一个选项具有value = "",这等于false。如果是这种情况,该方法将返回默认值true

如果它有一个值,它然后进入我们最初的if语句。这将检查特定值是否与isActive匹配 - 如果匹配,则运行我们之前编写的代码。如果不匹配,则开始我们的备用过滤。建立一个名为query的新变量,它获取输入的值并转换为小写。

第二个变量是我们要进行过滤的数据。它使用选择框的值作为人员的字段键,提取要进行过滤的值。该值被转换为字符串(在日期或余额的情况下),转换为小写并存储为field变量。最后,我们使用includes函数来检查字段是否包含输入的查询。如果是,则返回true并显示该行;否则,隐藏该行。

下一个问题是使用数字进行过滤。对于用户来说,输入他们想要的用户的确切余额并不直观 - 更自然的搜索方式是找到余额低于或高于某个金额的用户,例如,< 2000

首先要做的是只在balance字段时应用这种类型的过滤。我们可以有两种方法来处理这个问题 - 我们可以检查字段名称是否为balance,类似于我们检查isActive字段,或者我们可以检查我们正在过滤的数据类型。

检查字段名称更简单。我们可以在我们的方法中使用else if(),甚至可以迁移到switch语句以便更容易阅读和扩展。然而,检查字段类型的替代方法更具可扩展性。这意味着我们可以通过添加更多的数字字段来扩展我们的数据集,而无需扩展或更改我们的代码。然而,这也意味着我们的代码中将有进一步的if语句。

首先要做的是修改我们的存储方法,因为我们不一定希望将字段或查询转换为小写:

      if(this.filterField === 'isActive') {
        result = this.filterUserState === person.isActive;
      } else {

        let query = this.filterQuery,
 field = person[this.filterField]; 
 }

下一步是确定字段变量中的数据类型。可以通过再次使用typeof运算符来确定。可以在if语句中使用它,以检查字段的类型是否为数字:

      if(this.filterField === 'isActive') {
        result = this.filterUserState === person.isActive;
      } else {

        let query = this.filterQuery,
            field = person[this.filterField];

        if(typeof field === 'number') {
          // Is a number
 } else {
 // Is not a number
          field = field.toLowerCase();
          result = field.includes(query.toLowerCase());
 }

      }

一旦我们的检查完成,我们可以回到我们最初的查询代码。如果选择选项不是isActive,并且我们正在过滤的数据不是数字,则将使用此代码。如果是这种情况,则会将字段转换为小写,并查看在转换为小写之前在查询框中写入的内容是否包含在内。

下一步是实际比较我们的数字数据与查询框中的内容。为此,我们将使用原生的 JavaScript eval函数。

eval函数可能是一个潜在危险的函数,在生产代码中不应该使用它而不进行一些严格的输入消毒检查,而且它的性能比较低。它会将所有内容作为原生 JavaScript 运行,因此可能会被滥用。然而,由于我们在这个虚拟应用程序中使用它,重点是 Vue 本身而不是创建一个完全安全的 Web 应用程序,所以在这种情况下是可以接受的。你可以在 24 ways 中阅读更多关于eval的内容。

      if(this.filterField === 'isActive') {
       result = this.filterUserState === person.isActive;
      } else {

        let query = this.filterQuery,
            field = person[this.filterField];

        if(typeof field === 'number') {
          result = eval(field + query);
        } else {
          field = field.toLowerCase();
          result = field.includes(query.toLowerCase());
        }

      }

这将字段和查询都传递给eval()函数,并将结果(truefalse)传递给我们的result变量,以确定行的可见性。eval函数会直接评估表达式,并确定其是否为truefalse。以下是一个示例:

      eval(500 > 300); // true
      eval(500 < 400); // false
      eval(500 - 500); // false

在这个例子中,数字500是我们的字段,或者在这个具体的例子中是balance。之后的任何内容都是我们的用户输入的内容。你的过滤代码现在已经准备好了。尝试从下拉菜单中选择余额,并过滤出余额大于2000的用户。

在继续之前,我们需要添加一些错误检查。如果你打开了 JavaScript 控制台,你可能会注意到在输入第一个大于或小于符号时出现了一个错误。这是因为eval函数无法评估X >(其中X是余额)。你可能还尝试输入*$2000*带有货币符号,并意识到这不起作用。这是因为货币是在渲染视图时应用的,而我们在此之前对数据进行了过滤。

为了解决这两个错误,我们必须删除查询中输入的任何货币符号,并在依赖它返回结果之前测试我们的eval函数。使用原生的 JavaScript replace()函数来删除货币符号。如果它发生了变化,使用应用程序中存储的货币符号,而不是硬编码当前使用的货币符号。

      if(typeof field == 'number') {
        query = query.replace(this.currency, '');
        result = eval(field + query);
      }

现在我们需要测试eval函数,以便它在每次按键时不会抛出错误。为此,我们使用try...catch语句:

      if(typeof field == 'number') {
        query = query.replace(this.currency, '');

        try {
          result = eval(field + query);
 } catch(e) {}
      }

由于我们不希望在输入错误时输出任何内容,所以我们可以将catch语句留空。我们可以将field.includes(query)语句放在这里,这样它就会回退到默认功能。我们完整的filterRow()方法现在看起来是这样的:

      filterRow(person) {
        let result = true;

        if(this.filterField) {

          if(this.filterField === 'isActive') {

            result = this.filterUserState === person.isActive;

          } else {

            let query = this.filterQuery,
          field = person[this.filterField];

            if(typeof field === 'number') {

              query = query.replace(this.currency, '');        
              try {
                result = eval(field + query);
              } catch (e) {}

            } else {

              field = field.toLowerCase();
              result = field.includes(query.toLowerCase());

            }
          }
        }

        return result;

      }

过滤我们的过滤器

现在我们已经完成了过滤,我们只需要在下拉菜单中选择isActive选项时才显示单选按钮。根据我们所学的知识,这应该相对简单。

创建一个新的方法,检查选择框的值,并在我们的下拉菜单中选择“Active User”时返回true

      isActiveFilterSelected() {
        return (this.filterField === 'isActive');
      }

现在我们可以使用v-show来控制输入框和单选按钮,当在查询框上时反转效果:

      <label for="filterQuery" v-show="!isActiveFilterSelected()">
        Query:
        <input type="text" id="filterQuery" v-model="filterQuery">
      </label>
      <span v-show="isActiveFilterSelected()">
        Active:
        <label for="userStateActive">
          Yes:
          <input type="radio" v-bind:value="true" id="userStateActive"           
         v-model="filterUserState">
        </label>
        <label for="userStateInactive">
          No:
     <input type="radio" v-bind:value="false" id="userStateInactive" v-
      model="filterUserState">
        </label>
      </span>

请注意输入字段上方法调用之前的感叹号。这意味着“不”,实际上是反转函数的结果,例如“不是真”等同于“假”。

为了改进用户体验,我们还可以在显示任何输入之前检查过滤是否处于活动状态。这可以通过在我们的v-show属性中包含一个次要检查来实现:

      <label for="filterQuery" v-show="this.filterField &&        
      !isActiveFilterSelected()">
        Query:
        <input type="text" id="filterQuery" v-model="filterQuery">
      </label>

这个现在检查filterField是否有值,并且选择框没有设置为isActive。确保你也将这个添加到单选按钮中。

进一步改进用户体验的方法是,确保当选择isActive选项时,所有用户都不会消失。目前这种情况发生是因为默认设置为一个字符串,它与字段的truefalse值不匹配。在过滤这个字段之前,我们应该检查filterUserState变量是否为truefalse,即布尔值。我们可以再次使用typeof来实现这一点:

      if(this.filterField === 'isActive') {
        result = (typeof this.filterUserState === 'boolean') ?                  
        (this.filterUserState === person.isActive) : true;
      }

我们使用三元运算符来检查要过滤的结果是否为布尔值。如果是,则按照我们之前的方式进行过滤;如果不是,则只显示该行。

更改 CSS 类

与任何 HTML 属性一样,Vue 能够操作 CSS 类。与 Vue 中的所有内容一样,这可以通过多种方式完成,从对象本身的属性到利用方法。我们将首先添加一个类,如果用户是活动的。

绑定 CSS 类与其他属性类似。值接受一个对象,可以在视图中计算逻辑或抽象到我们的 Vue 实例中。这完全取决于操作的复杂性。

首先,如果用户是活动的,让我们给包含isActive变量的单元格添加一个类:

      <td v-bind:class="{ active: person.isActive }">
        {{ activeStatus(person) }}
      </td>

class HTML 属性首先由v-bind:前缀,以让 Vue 知道它需要处理该属性。然后,值是一个对象,CSS 类作为键,条件作为值。此代码在表格单元格上切换active类,如果person.isActive变量等于true。如果我们想要在用户不活动时添加一个inactive类,我们可以将其添加到对象中:

      <td v-bind:class="{ active: person.isActive, inactive: 
      !person.isActive }">
        {{ activeStatus(person) }}
      </td>

这里我们再次使用感叹号来反转状态。如果运行此应用程序,您应该会发现 CSS 类按预期应用。

如果我们只是根据一个条件应用两个类,可以在类属性内部使用三元if语句:

      <td v-bind:class="person.isActive ? 'active' : 'inactive'">
        {{ activeStatus(person) }}
      </td>

请注意类名周围的单引号。然而,逻辑再次开始渗入我们的视图中,如果我们希望在其他地方也使用这个类,它并不是非常可扩展。

在我们的 Vue 实例上创建一个名为activeClass的新方法,并将逻辑抽象到其中 - 不要忘记传递 person 对象:

      activeClass(person) {
        return person.isActive ? 'active' : 'inactive';
      }

现在我们可以在我们的视图中调用该方法:

      <td v-bind:class="activeClass(person)">
        {{ activeStatus(person) }}
      </td>

我很欣赏这是一个相当简单的执行;让我们尝试一个稍微复杂一点的。我们想要根据数值向余额单元格添加一个条件类。如果他们的余额低于 2000 美元,我们将添加一个error类。如果在 2000 美元和 3000 美元之间,将应用一个warning类,如果超过 3000 美元,将添加一个success类。

除了errorwarningsuccess类之外,如果余额超过 500 美元,还将添加一个increasing类。例如,2600 美元的余额将同时获得warningincreasing类,而 2400 美元只会获得warning类。

由于这包含了几个逻辑部分,我们将在实例中创建一个方法来使用。创建一个名为balanceClass的方法,并将其绑定到包含余额的单元格的类 HTML 属性上。首先,我们将添加errorwarningsuccess类。

      <td v-bind:class="balanceClass(person)">
        {{ formatBalance(person.balance) }}
      </td>

在该方法中,我们需要访问传入的人的balance属性,并返回我们希望添加的类的名称。现在,我们将返回一个固定的结果来验证它是否工作:

      balanceClass(person) {
        return 'warning';
      }

现在我们需要评估我们的余额。由于它已经是一个数字,与我们的标准进行比较不需要进行任何转换:

      balanceClass(person) {
        let balanceLevel = 'success';

        if(person.balance < 2000) {
          balanceLevel = 'error';
        } else if (person.balance < 3000) {
          balanceLevel = 'warning';
        }

        return balanceLevel;
      }

在前面的方法中,默认情况下将类输出设置为success,因为我们只需要在小于3000时更改输出。第一个if检查余额是否低于我们的第一个阈值 - 如果是,则将输出设置为error。如果不是,则尝试第二个条件,即检查余额是否低于3000。如果成功,则应用的类变为warning。最后,它输出所选的类,直接应用于元素。

现在我们需要考虑如何使用increasing类。为了使其与现有的balanceLevel类一起输出,我们需要将输出从单个变量转换为数组。为了验证这是否有效,将额外的类硬编码到输出中:

      balanceClass(person) {
        let balanceLevel = 'success';
        if(person.balance < 2000) {
          balanceLevel = 'error';
        } else if (person.balance < 3000) {
          balanceLevel = 'warning';
        }
        return [balanceLevel, 'increasing'];
      }

这将两个类添加到元素中。将字符串转换为变量,并默认设置为false。如果在数组中传递了false值,Vue 不会输出任何内容。

为了确定我们是否需要增加的类,我们需要对余额进行一些计算。由于我们希望在余额高于 500 时使用增加的类,无论它在什么范围内,我们需要舍入数字并进行比较:

      let increasing = false,
          balance = person.balance / 1000;

      if(Math.round(balance) == Math.ceil(balance)) {
        increasing = 'increasing';
      }

最初,我们将increasing变量默认设置为false。我们还存储了余额除以1000的版本。这意味着我们的余额变成了 2.45643,而不是 2456.42。从那里,我们将 JavaScript 舍入后的数字(例如 2.5 变成 3,而 2.4 变成 2)与强制舍入的数字(例如 2.1 变成 3,以及 2.9)进行比较。

如果输出的数字相同,则将increasing变量设置为我们想要设置的类的字符串。然后,我们可以将此变量与balanceLevel变量一起作为数组传递出去。完整的方法现在看起来像下面这样:

      balanceClass(person) {
        let balanceLevel = 'success';

        if(person.balance < 2000) {
          balanceLevel = 'error';
        } else if (person.balance < 3000) {
          balanceLevel = 'warning';
        } 

        let increasing = false,
            balance = person.balance / 1000;

        if(Math.round(balance) == Math.ceil(balance)) {
          increasing = 'increasing';
        }

        return [balanceLevel, increasing];
      }

过滤和自定义类

现在,我们拥有了一个完整的用户列表/注册表,可以根据选择的字段进行过滤,并根据条件自定义 CSS 类。回顾一下,我们现在的视图是这样的:

      <div id="app">
        <form>
          <label for="fiterField">
            Field:
            <select id="filterField" v-model="filterField">
              <option value="">Disable filters</option>
              <option value="isActive">Active user</option>
              <option value="name">Name</option>
              <option value="email">Email</option>
              <option value="balance">Balance</option>
              <option value="registered">Date registered</option>
            </select>
          </label>

          <label for="filterQuery" v-show="this.filterField &&                  
          !isActiveFilterSelected()">
            Query:
            <input type="text" id="filterQuery" v-model="filterQuery">
          </label>

          <span v-show="isActiveFilterSelected()">
         Active:
        <label for="userStateActive">
        Yes:
        <input type="radio" v-bind:value="true" id="userStateActive" v-
         model="filterUserState">
      </label>
      <label for="userStateInactive">
        No:
        <input type="radio" v-bind:value="false" id="userStateInactive"          
      v-model="filterUserState">
      </label>
          </span>
        </form>

        <table>
          <tr v-for="person in people" v-show="filterRow(person)">
            <td>{{ person.name }}</td>
            <td>
         <a v-bind:href="'mailto:' + person.email">{{ person.email }}            
           </a>
            </td>
            <td v-bind:class="balanceClass(person)">
              {{ formatBalance(person.balance) }}
            </td>
            <td>{{ formatDate(person.registered) }}</td>
            <td v-bind:class="activeClass(person)">
              {{ activeStatus(person) }}
            </td>
          </tr>
        </table>

      </div>

我们的 Vue 应用程序的 JavaScript 应该是这样的:

      const app = new Vue({
        el: '#app',

        data: {
          people: [...],

          currency: '$',

          filterField: '',
          filterQuery: '',
          filterUserState: ''
        },
        methods: {
          activeStatus(person) {
            return (person.isActive) ? 'Active' : 'Inactive';
          },

          activeClass(person) {
            return person.isActive ? 'active' : 'inactive';
          },
          balanceClass(person) {
            let balanceLevel = 'success';

            if(person.balance < 2000) {
              balanceLevel = 'error';
            } else if (person.balance < 3000) {
              balanceLevel = 'warning';
            }

            let increasing = false,
          balance = person.balance / 1000;

            if(Math.round(balance) == Math.ceil(balance)) {
              increasing = 'increasing';
            }

            return [balanceLevel, increasing];
          },

          formatBalance(balance) {
            return this.currency + balance.toFixed(2);
          },
          formatDate(date) {
            let registered = new Date(date);
            return registered.toLocaleString('en-US');
          },

          filterRow(person) {
            let result = true;
            if(this.filterField) {

              if(this.filterField === 'isActive') {

              result = (typeof this.filterUserState === 'boolean') ?       
              (this.filterUserState === person.isActive) : true;
             } else {

          let query = this.filterQuery,
              field = person[this.filterField];

          if(typeof field === 'number') {
            query.replace(this.currency, '');
            try {
              result = eval(field + query);
            } catch(e) {}
          } else {
            field = field.toLowerCase();
            result = field.includes(query.toLowerCase());
            }
          }
        }

            return result;
          },

          isActiveFilterSelected() {
            return (this.filterField === 'isActive');
          }
        }
      });

通过少量的 CSS,我们的人员过滤应用程序现在看起来像下面这样:

总结

在本章中,我们学习了 Vue 的 HTML 声明,根据需要有条件地渲染我们的 HTML 并显示替代内容。我们还实践了我们学到的关于方法的知识。最后,我们为我们的表格构建了一个过滤组件,允许我们显示活动和非活动用户,查找具有特定名称和电子邮件的用户,并根据余额过滤行。

现在我们的应用程序已经达到了一个良好的状态,这是一个很好的机会来检查我们的代码,看看是否可以进行任何优化。通过优化,我指的是减少重复,尽可能简化代码,并将逻辑抽象成更小、更可读、更可重用的块。

在第三章中,我们将优化我们的代码,并将 Vue 组件作为将逻辑分离到单独的段落和部分的一种方式。

第三章:优化您的应用程序并使用组件显示数据

在第二章“显示、循环、搜索和过滤数据”中,我们让 Vue 应用程序显示了我们的人员目录,我们可以利用这个机会来优化我们的代码并将其分离成组件。这样可以使代码更易于管理,更容易理解,并且使其他开发人员能够更容易地了解数据流程(或者在几个月后再次查看代码时,您自己也能更容易理解)。

本章将涵盖以下内容:

  • 通过减少重复代码和逻辑组织代码来优化我们的 Vue.js 代码

  • 如何创建 Vue 组件并在 Vue 中使用它们

  • 如何在组件中使用 props 和 slots

  • 利用事件在组件之间传递数据

优化代码

当我们在解决问题时编写代码时,有一个时刻你需要退后一步,看看你的代码并对其进行优化。这可能包括减少变量和方法的数量或创建方法以减少重复功能。我们当前的 Vue 应用程序如下所示:

      const app = new Vue({
        el: '#app',
        data: {
          people: [...],
          currency: '$',
          filterField: '',
          filterQuery: '',
          filterUserState: ''
        },
        methods: {
          activeStatus(person) {
            return (person.isActive) ? 'Active' : 
             'Inactive';
          },
          activeClass(person) {
            return person.isActive ? 'active' : 
            'inactive';
          },
          balanceClass(person) {
            let balanceLevel = 'success';
            if(person.balance < 2000) {
              balanceLevel = 'error';
            } else if (person.balance < 3000) {
              balanceLevel = 'warning';
            }
            let increasing = false,
            balance = person.balance / 1000;
            if(Math.round(balance) == 
             Math.ceil(balance)) {
              increasing = 'increasing';
            }
            return [balanceLevel, increasing];
          },
          formatBalance(balance) {
            return this.currency + balance.toFixed(2);
          },
          formatDate(date) {
            let registered = new Date(date);
            return registered.toLocaleString('en-US');
          },
          filterRow(person) {
            let result = true;
            if(this.filterField) {
              if(this.filterField === 'isActive') {
                result = (typeof this.filterUserState 
                 === 'boolean') ? (this.filterUserState 
                 === person.isActive) : true;
              } else {
                let query = this.filterQuery,
                    field = person[this.filterField];
                if(typeof field === 'number') {
                  query.replace(this.currency, '');
                  try {
                    result = eval(field + query);
                  } catch(e) {}
                } else {
                  field = field.toLowerCase();
                  result =        
            field.includes(query.toLowerCase());
                }
              }
            }
            return result;
          },
          isActiveFilterSelected() {
            return (this.filterField === 'isActive');
          }
        }
      });

查看前面的代码,我们可以进行一些改进。这些改进包括:

  • 减少过滤变量的数量并进行逻辑分组

  • 组合格式化函数

  • 减少硬编码变量和属性的数量

  • 将方法重新排序为更合理的顺序

我们将逐个讨论这些要点,以便我们有一个干净的代码库来构建组件。

减少过滤变量的数量并进行逻辑分组

目前的过滤器使用了三个变量filterFieldfilterQueryfilterUserState。目前唯一将这些变量联系在一起的是名称,而不是它们自己的对象以系统地将它们链接在一起。这样做可以避免任何关于它们是否与同一个组件相关或仅仅是巧合的歧义。在数据对象中,创建一个名为filter的新对象,并将每个变量移动到其中:

      data: {
        people: [..],
        currency: '$',
        filter: {
          field: '',
          query: '',
          userState: '',
        }
      }

要访问数据,请将filterField的所有引用更新为this.filter.field。注意额外的点,表示它是过滤器对象的一个键。还要记得更新filterQueryfilterUserState的引用。例如,isActiveFilterSelected方法将变为:

      isActiveFilterSelected() {
        return (this.filter.field === 'isActive');
      }

您还需要在视图中更新v-modelv-show属性-有五个不同变量的出现。

在更新过滤变量的同时,我们可以利用这个机会删除一个变量。根据我们当前的过滤方式,一次只能有一个过滤器处于活动状态。这意味着queryuserState变量在任何时候只被使用一次,这给我们合并这两个变量的机会。为此,我们需要更新视图和应用程序代码以适应这种情况。

从过滤数据对象中删除userState变量,并将视图中的任何filter.userState出现更新为filter.query。现在,在 Vue JavaScript 代码中进行查找和替换,将filter.userState替换为filter.query

在浏览器中查看应用程序,最初似乎可以工作,可以按字段过滤用户。但是,如果按状态过滤,然后切换到任何其他字段,查询字段将不会显示。这是因为使用单选按钮将值设置为布尔值,当尝试将其转换为小写以进行查询字段时,无法执行此操作。为了解决这个问题,我们可以使用原生 JavaScript 的String()函数将filter.query变量中的任何值转换为字符串。这确保我们的过滤函数可以与任何过滤输入一起使用:

      if(this.filter.field === 'isActive') {
        result = (typeof this.filter.query ===        
       'boolean') ? (this.filter.query ===             
        person.isActive) : true;
         } else {
        let query = String(this.filter.query),
            field = person[this.filter.field];
           if(typeof field === 'number') {
           query.replace(this.currency, '');
          try {
            result = eval(field + query);
          } catch(e) {}
        } else {
          field = field.toLowerCase();
          result = field.includes(query.toLowerCase());
        }

现在将此添加到我们的代码中,确保我们的查询数据无论值如何都可以使用。现在创建的问题是,当用户在字段之间切换进行过滤时。如果选择活动用户并选择一个单选按钮,则过滤按预期工作,但是,如果现在切换到电子邮件或另一个字段,则输入框将预填充为truefalse。这会立即进行过滤,并且通常不会返回任何结果。当在两个文本过滤字段之间切换时,也会发生这种情况,这不是期望的效果。

我们希望的是,每当选择框更新时,过滤查询都应该清除。无论是单选按钮还是输入框,选择新字段都应该重置过滤查询,这样可以开始新的搜索。

这是通过删除选择框与filter.field变量之间的链接,并创建我们自己的方法来处理更新来完成的。然后,在选择框更改时触发该方法。该方法将清除query变量并将field变量设置为选择框的值。

在选择框上删除v-model属性,并添加一个新的v-on:change属性。我们将在其中传递一个方法名,每次选择框更新时都会触发该方法。

v-on是一个我们之前没有遇到过的新的 Vue 绑定。它允许您将元素的操作绑定到 Vue 方法。例如,v-on:click是最常用的一种,它允许您将click函数绑定到元素上。我们将在本书的下一节中详细介绍这个。

v-bind可以缩写为冒号,v-on可以缩写为@符号,允许您使用@click="",例如:

      <select v-on:change="changeFilter($event)"     
       id="filterField">
        <option value="">Disable filters</option>
        <option value="isActive">Active user</option>
        <option value="name">Name</option>
        <option value="email">Email</option>
        <option value="balance">Balance</option>
        <option value="registered">Date 
         registered</option>
      </select>

此属性在每次更新时触发changeFilter方法,并传递$event更改的数据。此默认的 Vue 事件对象包含了许多我们可以利用的信息,但我们关注的是target.value数据。

在您的 Vue 实例中创建一个新的方法,接受事件参数并更新queryfield变量。query变量需要被清除,所以将其设置为空字符串,而field变量可以设置为选择框的值:

      changeFilter(event) {
        this.filter.query = '';
        this.filter.field = event.target.value;
      }

现在查看您的应用程序应该清除过滤查询,同时仍然按预期运行。

组合格式化函数

我们的下一个优化将是在 Vue 实例中合并formatBalanceformatDate方法。这将允许我们扩展格式化函数,而不会用几个具有类似功能的方法使代码臃肿。有两种方法可以处理格式化样式函数-我们可以自动检测输入的格式,也可以将所需的格式选项作为第二个选项传递。两者都有其优缺点,但我们将逐步介绍两种方法。

自动检测格式化

当传入函数时,自动检测变量类型对于更清晰的代码非常有用。在视图中,您可以调用该函数并传递一个要格式化的参数。例如:

      {{ format(person.balance) }}

然后,该方法将包含一个switch语句,并根据typeof值格式化变量。switch语句可以评估单个表达式,然后根据输出执行不同的代码。switch语句非常强大,因为它允许构建子句-根据结果利用几个不同的代码片段。有关switch语句的更多信息可以在 MDN 上阅读。

如果要比较相同的表达式,switch语句是if语句的一个很好的替代方案。您还可以为一个代码块设置多个情况,并在之前的情况都不满足时包含一个默认情况。作为使用示例,我们的格式化方法可能如下所示:

      format(variable) {
        switch (typeof variable) {
          case 'string':
          // Formatting if the variable is a string
          break;
          case 'number':
          // Number formatting
          break;
          default:
          // Default formatting
          break;
        }
      }

需要注意的重要事项是break;行。这些结束了每个switch case。如果省略了 break,代码将继续执行下一个 case,这有时是期望的效果。

自动检测变量类型和格式化是简化代码的好方法。然而,对于我们的应用程序来说,这不是一个合适的解决方案,因为我们正在格式化日期,当输出typeof结果时,它会变成一个字符串,并且无法从我们可能希望格式化的其他字符串中识别出来。

传入第二个变量

前面自动检测的替代方法是将第二个变量传递给format函数。这样做可以提供更大的灵活性和可扩展性,以便我们可以格式化其他字段。有了第二个变量,我们可以传入一个固定的字符串,与我们switch语句中的预选列表匹配,或者我们可以传入字段本身。在视图中使用固定字符串方法的一个示例是:

      {{ format(person.balance, 'currency') }}

这个方法完全适用,并且如果我们有几个不同的字段都需要像balance一样进行格式化,那就太好了,但是在使用balance键和currency格式时似乎有一些轻微的重复。

为了妥协,我们将把person对象作为第一个参数传递,这样我们就可以访问所有的数据,将字段的名称作为第二个参数。然后我们将使用这个参数来识别所需的格式化方法并返回特定的数据。

创建方法

在您的视图中,用一个格式化函数替换formatDateformatBalance函数,将person变量作为第一个参数传入,并将字段用引号括起来作为第二个参数:

      <td v-bind:class="balanceClass(person)">
        {{ format(person, 'balance') }}
      </td>
      <td>
        {{ format(person, 'registered') }}
      </td>

在 Vue 实例中创建一个新的格式化方法,接受两个参数:personkey。作为第一步,使用 person 对象和key变量检索字段:

      format(person, key) {
        let field = person[key],
            output = field.toString().trim();      
        return output;
      }

我们还在函数内部创建了一个名为output的第二个变量,这将在函数结束时返回,并默认设置为field。这样可以确保如果我们的格式化键与传入的键不匹配,将返回未经处理的字段数据,但我们会将字段转换为字符串并修剪变量中的任何空格。现在运行应用程序将返回没有任何格式化的字段。

添加一个switch语句,将表达式设置为仅为key。在switch语句中添加两个情况,一个是balance,另一个是registered。由于我们不希望在输入不匹配情况时发生任何事情,所以我们不需要default语句:

      format(person, key) {
        let field = person[key],
            output = field.toString().trim();

        switch(key) {
 case 'balance':
 break;
 case 'registered':
 break;
 }
        return output;
      }

现在我们只需要将原始格式化函数的代码复制到各个情况中:

      format(person, key) {
        let field = person[key],
            output = field.toString().trim();

        switch(key) {
          case 'balance':
            output = this.currency + field.toFixed(2);
            break;

          case 'registered':
           let registered = new Date(field);
 output = registered.toLocaleString('en-US');
          break;
        }
        return output;
      }

这个格式化函数现在更加灵活。如果我们需要处理更多字段(例如处理name字段),我们可以添加更多的switch情况,或者我们可以在现有代码中添加新的情况。例如,如果我们的数据包含一个字段,详细说明用户“停用”帐户的日期,我们可以轻松地以与注册相同的格式显示它:

      case 'registered':
 case 'deactivated':
        let registered = new Date(field);
        output = registered.toLocaleString('en-US');
        break;

减少硬编码变量和属性的数量,减少冗余

当查看 Vue JavaScript 时,很快就会发现通过引入全局变量并在函数中设置更多的局部变量可以对其进行优化,使其更易读。我们还可以使用现有功能来避免重复。

第一个优化是在我们的filterRow()方法中,我们检查filter.field是否处于活动状态。这也在我们用于显示和隐藏单选按钮的isActiveFilterSelected方法中重复。更新if语句以使用此方法,代码如下:

      ...

    if(this.filter.field === 'isActive') {
    result = (typeof this.filter.query === 'boolean') ?       
    (this.filter.query === person.isActive) : true;
      } else {

      ...

上述代码已删除this.filter.field === 'isActive'代码,并替换为isActiveFilterSelected()方法。现在它应该是这样的:

      ...

    if(this.isActiveFilterSelected()) {
    result = (typeof this.filter.query === 'boolean') ?     
     (this.filter.query === person.isActive) : true;
     } else {

      ...

当我们在filterRow方法中时,我们可以通过在方法开始时将queryfield存储为变量来减少代码。result也不是这个的正确关键字,所以让我们将其更改为visible。首先,在开始处创建和存储我们的两个变量,并将result重命名为visible

      filterRow(person) {
        let visible = true,
 field = this.filter.field,
 query = this.filter.query;      ...

在该函数中替换所有变量的实例,例如,方法的第一部分将如下所示:

      if(field) {
          if(this.isActiveFilterSelected()) {
            visible = (typeof query === 'boolean') ?   
            (query === person.isActive) : true;
          } else {

          query = String(query),
          field = person[field];

保存文件并在浏览器中打开应用程序,以确保您的优化没有破坏功能。

最后一步是将方法按照你认为合理的顺序重新排序。可以随意添加注释来区分不同类型的方法,例如与 CSS 类或过滤相关的方法。我还删除了activeStatus方法,因为我们可以利用我们的format方法来格式化此字段的输出。优化后,JavaScript 代码现在如下所示:

      const app = new Vue({
        el: '#app',
         data: {
          people: [...],
          currency: '$',
          filter: {
            field: '',
            query: ''
          }
        },
        methods: {
          isActiveFilterSelected() {
            return (this.filter.field === 'isActive');
          },
          /**
           * CSS Classes
           */
          activeClass(person) {
             return person.isActive ? 'active' : 
             'inactive';
          },
           balanceClass(person) {
            let balanceLevel = 'success';
            if(person.balance < 2000) {
              balanceLevel = 'error';
            } else if (person.balance < 3000) {
              balanceLevel = 'warning';
            }
                let increasing = false,
                balance = person.balance / 1000;
            if(Math.round(balance) == 
             Math.ceil(balance)) {
              increasing = 'increasing';
            }
            return [balanceLevel, increasing];
          },
          /**
           * Display
           */
          format(person, key) {
            let field = person[key],
            output = field.toString().trim();
            switch(key) {
              case 'balance':
                output = this.currency + 
              field.toFixed(2);
                break;
              case 'registered':
          let registered = new Date(field);
          output = registered.toLocaleString('en-US');
          break;  
        case 'isActive':
          output = (person.isActive) ? 'Active' : 
          'Inactive';
            }
        return output;
          },  
          /**
           * Filtering
           */
          changeFilter(event) {
            this.filter.query = '';
            this.filter.field = event.target.value;
          },
          filterRow(person) {
            let visible = true,
                field = this.filter.field,
                query = this.filter.query; 
            if(field) {  
              if(this.isActiveFilterSelected()) {
                visible = (typeof query === 'boolean') ?
               (query === person.isActive) : true;
              } else { 
                query = String(query),
                field = person[field];
                if(typeof field === 'number') {
                  query.replace(this.currency, '');  
                  try {
                    visible = eval(field + query);
                  } catch(e) {}  
                } else {  
                  field = field.toLowerCase();
                  visible = 
                  field.includes(query.toLowerCase());         
                }
              }
            }
            return visible;
          }
        }
      });

创建 Vue 组件

现在我们对代码的整洁性有信心了,我们可以开始为应用程序的各个部分创建 Vue 组件。暂时放下你的代码,打开一个新文档,同时熟悉组件的使用。

Vue 组件非常强大,是任何 Vue 应用程序的重要组成部分。它们允许你创建可重用代码的包,包括它们自己的数据、方法和计算值。

对于我们的应用程序,我们有机会创建两个组件:一个用于每个人,一个用于我们应用程序的过滤部分。我鼓励你在可能的情况下始终考虑将应用程序拆分为组件,这有助于将代码分组到相关的功能中。

组件看起来像是迷你的 Vue 实例,因为每个组件都有自己的数据、方法和计算属性,还有一些特定于组件的选项,我们将很快介绍。

当一个组件被注册时,你可以创建一个自定义的 HTML 元素在你的视图中使用,例如:

      <my-component></my-component>

在命名组件时,你可以使用短横线分隔的 kebab-case(连字符)、没有标点符号但每个单词首字母大写的 PascalCase 或类似 PascalCase 但第一个单词首字母不大写的 camelCase。Vue 组件不受 W3C Web 组件/自定义元素规则的限制或关联,但按照使用 kebab-case 的约定是一个好习惯。

创建和初始化你的组件

Vue 组件使用Vue.component(tagName, options)语法进行注册。每个组件必须有一个关联的标签名。Vue.component注册必须在初始化 Vue 实例之前发生。作为最低要求,每个组件应该有一个template属性,表示在使用组件时应该显示什么内容。模板必须始终有一个包装元素;这样自定义的 HTML 标签可以被父容器替换。

例如,你不能将以下内容作为你的模板:

      <div>Hello</div><div>Goodbye</div>

如果你传递了这种格式的模板,Vue 将在浏览器的 JavaScript 控制台中抛出错误警告。

自己创建一个简单的固定模板的 Vue 组件:

 Vue.component('my-component', {
 template: '<div>hello</div>'
 });

      const app = new Vue({
        el: '#app',

       // App options
      });

声明了这个组件后,它现在会给我们一个<my-component></my-component> HTML 标签,可以在我们的视图中使用。

您还可以在 Vue 实例本身上指定组件。如果您在一个站点上有多个 Vue 实例,并希望将组件限制在一个实例中,可以使用一个简单的对象创建组件,并在 Vue 实例的components对象中分配tagName

      let Child = {
        template: '<div>hello</div>'
      }

      const app = new Vue({
        el: '#app',

        // App options

        components: {
          'my-component': Child
        }
      });

对于我们的应用程序,我们将继续使用Vue.component()方法来初始化我们的组件。

使用您的组件

在您的视图中,添加您的自定义 HTML 元素组件:

      <div id="app">
        <my-component></my-component>
      </div>

在浏览器中查看,应该将<my-component> HTML 标签替换为<div>和一个 hello 消息。

可能有一些情况下,自定义 HTML 标签不会被解析和接受-这些情况往往出现在<table><ol><ul><select>元素中。如果是这种情况,您可以在标准 HTML 元素上使用is=""属性:

      <ol>
        <li is="my-component"></li>
      </ol>

使用组件数据和方法

由于 Vue 组件是 Vue 应用程序的自包含元素,它们各自具有自己的数据和函数。这在同一页面上重复使用组件时非常有用,因为信息是每个组件实例自包含的。methodscomputed函数的声明方式与在 Vue 应用程序上相同,但是数据键应该是一个返回对象的函数。

组件的数据对象必须是一个函数。这样每个组件都有自己独立的数据,而不会混淆并共享不同实例的相同组件之间的数据。该函数仍然必须返回一个对象,就像在 Vue 应用程序中一样。

创建一个名为balance的新组件,为您的组件添加一个data函数和computed对象,并为template属性添加一个空的<div>

      Vue.component('balance', {
        template: '<div></div>',
        data() {
          return {

          }
        },
        computed: {

        }
      });

接下来,在cost数据对象中添加一个键/值对,并将变量添加到模板中。在视图中添加<balance></balance>自定义 HTML 元素,您应该看到您的整数:

      Vue.component('balance', {
        template: '<div>{{ cost }}</div>',
        data() {
          return {
            cost: 1234
          }
        },
        computed: {

        }
      });

与第一章中的 Vue 实例一样,在computed对象中添加一个函数,将货币符号附加到整数上,并确保有两位小数。不要忘记将货币符号添加到您的数据函数中。

更新模板以输出计算值而不是原始成本:

      Vue.component('balance', {
        template: '<div>{{ formattedCost }}</div>',
        data() {
          return {
            cost: 1234,
            currency: '$'
          }
        },
        computed: {
          formattedCost() {
 return this.currency + this.cost.toFixed(2);
 }
        }
      });

这是一个基本的组件示例,但是它在组件本身上有固定的cost

向组件传递数据-props

将 balance 作为一个组件是很好的,但如果 balance 是固定的,那就不太好了。当您通过 HTML 属性传递参数和属性时,组件真正发挥作用。在 Vue 世界中,这些被称为props。Props 可以是静态的或变量的。为了使您的组件期望这些属性,您需要使用props属性在组件上创建一个数组。

一个例子是我们想要创建一个heading组件:

      Vue.component('heading', {
        template: '<h1>{{ text }}</h1>',

        props: ['text']
      });

然后,组件将在视图中使用如下:

      <heading text="Hello!"></heading>

使用 props,我们不需要在数据对象中定义text变量,因为在 props 数组中定义它会自动使其在模板中可用。props 数组还可以接受进一步的选项,允许您定义期望的输入类型,以及是否需要输入或省略时使用的默认值。

为 balance 组件添加一个 prop,以便我们可以将 cost 作为 HTML 属性传递。您的视图现在应该如下所示:

      <balance cost="1234"></balance> 

现在,我们可以在 JavaScript 中为组件添加 cost prop,并从数据函数中删除固定值:

      template: '<div>{{ formattedCost }}</div>',
 props: ['cost'],
      data() {
        return {
          currency: '$'
        }
      },

然而,在浏览器中运行此代码将在 JavaScript 控制台中引发错误。这是因为,本地情况下,传递的 props 被解释为字符串。我们可以通过两种方式解决这个问题;我们可以在formatCost()函数中将我们的 prop 转换为数字,或者我们可以使用v-bind:HTML 属性告诉 Vue 接受输入为它是什么。

如果您记得,我们在truefalse值的过滤器中使用了这种技术-允许它们作为布尔值而不是字符串使用。在您的costHTML 属性前面添加v-bind:

      <balance v-bind:cost="15234"></balance> 

还有一个额外的步骤可以确保 Vue 知道要期望什么样的输入,并通知其他用户您的代码应该传递什么。这可以在组件本身中完成,并且可以与格式一起,允许您指定默认值以及是否需要传递 prop。

将您的props数组转换为一个对象,以cost作为键。如果您只是定义字段类型,可以使用 Vue 的简写方式来声明,将值设置为字段类型。这些类型可以是 String、Number、Boolean、Function、Object、Array 或 Symbol。由于我们的 cost 属性应该是一个数字,所以将其添加为键:

      props: {
 cost: Number
 },

如果没有定义任何内容,组件渲染为$0.00会更好。我们可以通过将默认值设置为0来实现这一点。要定义默认值,我们需要将我们的属性转换为一个对象本身 - 包含一个type键,其值为Number。然后,我们可以定义另一个default键,并将值设置为0

      props: {
        cost: {
          type: Number,
 default: 0
 }
      },

在浏览器中渲染组件应该显示传递到 cost 属性的任何值,但是如果删除这个属性,将会渲染为$0.00

总结一下,我们的组件如下:

      Vue.component('balance', {
        template: '<div>{{ formattedCost }}</div>',

        props: {
          cost: {
            type: Number,
            default: 0
          }
        },

        data() {
          return {
            currency: '$'
          }
        },

        computed: {
          formattedCost() {
            return this.currency +       
            this.cost.toFixed(2);
          }
        }
      });

当我们制作列表应用程序的person组件时,我们应该能够在此示例上进行扩展。

向组件传递数据 - 插槽

有时您可能需要将 HTML 块传递给组件,这些 HTML 块不存储在属性中,或者您希望在组件中显示之前进行格式化。与其尝试在计算变量或类似变量中进行预格式化,不如使用组件的插槽。

插槽就像占位符,允许您在组件的开头和结尾标签之间放置内容,并确定它们将显示在哪里。

一个完美的例子是模态窗口。这些通常有几个标签,并且通常由大量的 HTML 组成,如果您希望在应用程序中多次使用它,就需要复制和粘贴。相反,您可以创建一个modal-window组件,并使用插槽传递您的 HTML。

创建一个名为modal-window的新组件。它接受一个名为visible的属性,默认为false,接受一个布尔值。对于模板,我们将使用Bootstrap modal中的 HTML 作为一个很好的示例,说明使用插槽的组件如何轻松简化您的应用程序。为了确保组件被样式化,请确保在文档中包含 bootstrap 的asset 文件

      Vue.component('modal-window', {
        template: `<div class="modal fade">
          <div class="modal-dialog" role="document">
            <div class="modal-content">
              <div class="modal-header">
               <button type="button" class="close" 
               data-dismiss="modal" aria-label="Close">
               <span aria-hidden="true">&times;</span>
              </button>
             </div>
          <div class="modal-body">
          </div>
           <div class="modal-footer">
            <button type="button" class="btn btn-  
             primary">Save changes</button>
            <button type="button" class="btn btn-      
             secondary" data-dismiss="modal">Close
            </button>
            </div>
          </div>
         </div>
      </div>`,

      props: {
        visible: {
          type: Boolean,
          default: false
        }
       }
    });

我们将使用 visible 属性来确定模态窗口是否打开。在外部容器中添加一个v-show属性,接受visible变量:

      Vue.component('modal-window', {
          template: `<div class="modal fade" v-
            show="visible">
          ...
        </div>`,

        props: {
          visible: {
            type: Boolean,
            default: false
          }
        }
      });

将您的modal-window组件添加到应用程序中,暂时将visible设置为true,以便我们可以理解和看到发生了什么:

      <modal-window :visible="true"></modal-window>

现在我们需要向我们的模态框传递一些数据。在两个标签之间添加一个标题和一些段落:

      <modal-window :visible="true">
        <h1>Modal Title</h1>
 <p>Lorem ipsum dolor sit amet, consectetur                
         adipiscing elit. Suspendisse ut rutrum ante, a          
         ultrices felis. Quisque sodales diam non mi            
         blandit dapibus. </p>
 <p>Lorem ipsum dolor sit amet, consectetur             
          adipiscing elit. Suspendisse ut rutrum ante, a             
          ultrices felis. Quisque sodales diam non mi             
          blandit dapibus. </p>
       </modal-window>

在浏览器中按刷新按钮不会有任何反应,因为我们需要告诉组件如何处理数据。在模板中,在您希望内容出现的位置添加一个<slot></slot>HTML 标签。将其添加到具有modal-body类的div中:

      Vue.component('modal-window', {
        template: `<div class="modal fade" v-      
        show="visible">
          <div class="modal-dialog" role="document">
            <div class="modal-content">
              <div class="modal-header">
          <button type="button" class="close" data-              
              dismiss="modal" aria-label="Close">
               <span aria-hidden="true">&times;</span>
             </button>
              </div>
              <div class="modal-body">
                <slot></slot>
              </div>
              <div class="modal-footer">
              <button type="button" class="btn btn-  
             primary">Save changes</button>
             <button type="button" class="btn btn-                   
               secondary" data-
            dismiss="modal">Close</button>
           </div>
           </div>
        </div>
        </div>`,

         props: {
          visible: {
            type: Boolean,
            default: false
          }
        }
      });

现在查看您的应用程序将在模态窗口中显示您传递的内容。通过这个新组件,应用程序看起来更清晰。

查看 Bootstrap 的 HTML,我们可以看到有一个头部、主体和底部的空间。我们可以使用命名插槽来标识这些部分。这样,我们就可以将特定的内容传递到组件的特定区域。

在模态窗口的头部和底部创建两个新的<slot>标签。给这些新的标签添加一个 name 属性,但保留现有的空标签:

      template: `<div class="modal fade" v-              
      show="visible">
        <div class="modal-dialog" role="document">
          <div class="modal-content">
            <div class="modal-header">
              <slot name="header"></slot>
              <button type="button" class="close" data-
               dismiss="modal" aria-label="Close">
              <span aria-hidden="true">&times;</span>
             </button>
          </div>
           <div class="modal-body">
            <slot></slot>
          </div>
          <div class="modal-footer">
            <slot name="footer"></slot>
            <button type="button" class="btn btn-  
            primary">Save changes</button><button type="button" class="btn btn-
           secondary" data-
           dismiss="modal">Close</button>
           </div>
        </div>
       </div>
     </div>`,

在我们的应用程序中,我们现在可以通过在 HTML 中指定slot属性来指定内容放在哪里。这可以放在特定的标签上,也可以放在几个标签周围的容器上。任何没有slot属性的 HTML 也将默认为无名插槽:

      <modal-window :visible="true">
        <h1 slot="header">Modal Title</h1>

        <p>Lorem ipsum dolor sit amet, consectetur             
        adipiscing elit. Suspendisse ut rutrum ante, a 
        ultrices felis. Quisque sodales diam non mi 
         blandit dapibus. </p>

        <p slot="footer">Lorem ipsum dolor sit amet,            
         consectetur adipiscing elit. Suspendisse ut 
         rutrum ante, a ultrices felis. Quisque sodales 
           diam non mi blandit dapibus. </p>
      </modal-window>

现在,我们可以指定并将我们的内容定向到特定的位置。

插槽的最后一件事是指定默认值。例如,您可能希望大部分时间在页脚显示按钮,但如果需要,可以替换它们。使用<slot>,在标签之间放置的任何内容都将显示,除非在应用程序中指定组件时进行覆盖。

创建一个名为buttons的新插槽,并将按钮放在底部。尝试用其他内容替换它们。

模板变为:

      template: `<div class="modal fade" v-
      show="visible">
        <div class="modal-dialog" role="document">
          <div class="modal-content">
            <div class="modal-header">
              <slot name="header"></slot>
              <button type="button" class="close" data-
              dismiss="modal" aria-label="Close">
                <span aria-hidden="true">&times;</span>
              </button>
            </div>
            <div class="modal-body">
              <slot></slot>
            </div>
            <div class="modal-footer">
              <slot name="footer"></slot>
              <slot name="buttons">
                <button type="button" class="btn btn-
                 primary">Save changes</button>
                <button type="button" class="btn btn-
                 secondary" data-
                 dismiss="modal">Close</button>
              </slot>
            </div>
          </div>
        </div>
      </div>`,

HTML 如下:


     <modal-window :visible="true">
     <h1 slot="header">Modal Title</h1>
      <p>Lorem ipsum dolor sit amet, consectetur 
      adipiscing elit. Suspendisse ut rutrum ante, a 
      ultrices felis. Quisque sodales diam non mi blandit 
      dapibus. </p>

        <p slot="footer">Lorem ipsum dolor sit amet, 
       consectetur adipiscing elit. Suspendisse ut rutrum 
       ante, a ultrices felis. Quisque sodales diam non mi 
       blandit dapibus. </p>

        <div slot="buttons">
 <button type="button" class="btn btn-      
           primary">Ok</button> </div>
       </modal-window>

虽然我们不会在人员列表应用程序中使用插槽,但了解 Vue 组件的功能是很好的。如果您希望使用这样的模态框,您可以将可见性设置为默认为 false 的变量。然后,您可以添加一个具有点击方法的按钮,将变量从false更改为true,从而显示模态框。

创建一个可重复使用的组件

组件的美妙之处在于可以在同一个视图中多次使用它们。这使您能够为该数据的布局拥有一个单一的“真实来源”。我们将为人员列表创建一个可重复使用的组件,并为过滤部分创建一个单独的组件。

打开您在前几章中创建的人员列表代码,并创建一个名为team-member的新组件。不要忘记在 Vue 应用程序初始化之前定义组件。为组件添加一个prop,以允许传递 person 对象。为了验证目的,只需指定它可以是一个Object

      Vue.component('team-member', {
        props: {
          person: Object
        }
      });

现在,我们需要将模板整合到组件中,即在我们的视图中(包括)tr内的所有内容。

组件中的模板变量只接受没有换行符的普通字符串,因此我们需要执行以下操作之一:

  • 内联我们的 HTML 模板-对于小模板非常好,但在这种情况下会牺牲可读性

  • 使用+字符串连接添加新行-适用于一两行,但会使我们的 JavaScript 膨胀

  • 创建一个模板块-Vue 允许我们使用在视图中使用text/x-template语法和 ID 定义的外部模板

由于我们的模板相当大,我们将选择第三个选项,在视图末尾声明我们的模板。

在您的 HTML 中,在您的应用程序之外,创建一个新的脚本块,并添加typeID属性:

      <script type="text/x-template" id="team-member-            
       template">
      </script>

然后,我们将人员模板移动到此块中,并删除v-for属性-我们仍将在应用程序本身中使用它:

      <script type="text/x-template" id="team-member-
      template">
        <tr v-show="filterRow(person)">
 <td>
 {{ person.name }}
 </td>
 <td>
 <a v-bind:href="'mailto:' + person.email">{{                
             person.email }}</a>
 </td>
 <td v-bind:class="balanceClass(person)">
 {{ format(person, 'balance') }}
 </td>
 <td>
 {{ format(person, 'registered') }}
 </td>
 <td v-bind:class="activeClass(person)">
 {{ format(person, 'isActive') }}
 </td>
 </tr>
      </script>

现在,我们需要更新视图,使用team-member组件代替固定代码。为了使我们的视图更清晰易懂,我们将利用前面提到的<template>HTML 属性。创建一个<template>标签,并添加我们之前使用的v-for循环。为避免混淆,更新循环以使用individual作为每个人的变量。它们可以相同,但如果变量、组件和属性具有不同的名称,代码更容易阅读。将v-for更新为v-for="individual in people"

      <table>
       <template v-for="individual in people">
       </template>
      </table>

在您的视图的template标签内,添加一个新的team-member组件实例,将individual变量传递给person属性。不要忘记在 person 属性前添加v-bind:,否则组件将将其解释为一个固定字符串,其值为 individual 的值:

      <table>
        <template v-for="individual in people">
          <team-member v-bind:person="individual"></team-           
            member>
        </template>
      </table>

现在,我们需要更新组件,使用我们声明的模板作为template属性和脚本块的 ID 作为值:

      Vue.component('team-member', {
        template: '#team-member-template',
        props: {
          person: Object
        }
      });

在浏览器中查看应用程序将在 JavaScript 控制台中创建多个错误。这是因为我们引用了一些不再可用的方法-因为它们在父 Vue 实例上,而不是在组件上。如果您想验证组件是否正常工作,请将代码更改为仅输出人员的名称,并按刷新:

      <script type="text/x-template" id="team-member-             
        template">
        <tr v-show="filterRow()">
          <td>
            {{ person.name }}
          </td>
        </tr>
      </script>

创建组件方法和计算函数

现在,我们需要在子组件上创建我们在 Vue 实例上创建的方法,以便可以使用它们。我们可以将方法从父组件剪切并粘贴到子组件中,希望它们能够正常工作;然而,这些方法依赖于父组件的属性(如过滤数据),我们还有机会利用computed属性,它可以缓存数据并加快应用程序的速度。

暂时从tr元素中删除v-show属性,因为这涉及到过滤,而这将在我们正确显示行后进行讨论。我们将逐步解决错误,并逐个解决它们,以帮助您理解使用 Vue 进行问题解决。

CSS 类函数

在浏览器中查看应用程序时,我们遇到的第一个错误是:

属性或方法“balanceClass”未定义。

第一个错误涉及到我们使用的balanceClassactiveClass函数。这两个函数根据人员的数据添加 CSS 类,一旦组件被渲染,这些数据就不会改变。

因此,我们可以使用 Vue 中的缓存。将方法移到组件中,但将它们放在一个新的computed对象中,而不是methods对象中。

对于组件,每次调用时都会创建一个新的实例,因此我们可以依赖于通过prop传递的person对象,不再需要将person传递给函数。从函数和视图中删除参数,还要将函数内部对person的任何引用更新为this.person,以引用存储在组件上的对象:

 computed: {
        /**
         * CSS Classes
         */
        activeClass() {
          return this.person.isActive ? 'active' : 
      'inactive';
        },

        balanceClass() {
          let balanceLevel = 'success';

          if(this.person.balance < 2000) {
            balanceLevel = 'error';
          } else if (this.person.balance < 3000) {
            balanceLevel = 'warning';
          }

          let increasing = false,
              balance = this.person.balance / 1000;

          if(Math.round(balance) == Math.ceil(balance)) {
            increasing = 'increasing';
          }

          return [balanceLevel, increasing];
        }
 },

使用此函数的组件模板部分现在应该如下所示:

      <td v-bind:class="balanceClass">
    {{ format(person, 'balance') }}
      </td>

格式化值函数

当将format()函数移动到组件中格式化我们的数据时,我们面临两个选择。我们可以像原样移动它并将其放在methods对象中,或者我们可以利用 Vue 的缓存和约定,为每个值创建一个computed函数。

我们正在构建可扩展性的应用程序,因此建议为每个值创建计算函数,这也有助于整理我们的模板。在计算对象中创建三个函数,分别命名为balancedateRegisteredstatus。将format函数的相应部分复制到每个函数中,再次将person的引用更新为this.person

我们之前是使用函数参数来检索字段,现在你可以在每个函数中修复该值。您还需要添加一个带有余额函数的货币符号的数据对象 - 将其添加到props之后:

      data() {
        return {
          currency: '$'
        }
      },

由于team-member组件是唯一使用货币符号的地方,我们可以将其从 Vue 应用程序本身中删除。我们还可以从父 Vue 实例中删除格式化函数。

总的来说,我们的 Vue team-member组件应该如下所示:

      Vue.component('team-member', {
        template: '#team-member-template',
        props: {
          person: Object 
       },
        data() {
          return {
            currency: '$'
          }
        },
        computed: {
          /**
           * CSS Classes
           */
          activeClass() {
            return this.person.isActive ? 'active' : 
            'inactive';
          },
          balanceClass() {
            let balanceLevel = 'success';   
            if(this.person.balance < 2000) {
              balanceLevel = 'error';
            } else if (this.person.balance < 3000) {
              balanceLevel = 'warning';
            }
          let increasing = false,
                balance = this.person.balance / 1000; 
            if(Math.round(balance) == Math.ceil(balance))                           
            {
              increasing = 'increasing';
            }
            return [balanceLevel, increasing];
          }, 
          /**
           * Fields
           */
          balance() {
            return this.currency +       
            this.person.balance.toFixed(2);
          },
          dateRegistered() {
            let registered = new 
            Date(this.person.registered);
            return registered.toLocaleString('en-US');
          },
          status() {
            return (this.person.isActive) ? 'Active' : 
            'Inactive';
          }
        }
      });

与之前相比,我们的team-member-template应该看起来非常简单:

      <script type="text/x-template" id="team-member-
      template">
        <tr v-show="filterRow()">
          <td>
            {{ person.name }}
          </td>
          <td>
            <a v-bind:href="'mailto:' + person.email">{{ 
            person.email }}</a>
          </td>
          <td v-bind:class="balanceClass">
            {{ balance }}
          </td>
          <td>
            {{ dateRegistered }}
          </td>
          <td v-bind:class="activeClass">
            {{ status }}
          </td>
        </tr>
      </script>

最后,我们的 Vue 实例应该显得更小:

      const app = new Vue({
        el: '#app',
        data: {
          people: [...],
          filter: {
            field: '',
            query: ''
          }
        },
        methods: {
          isActiveFilterSelected() {
            return (this.filter.field === 'isActive');
          },   
          /**
           * Filtering
           */
          filterRow(person) {
            let visible = true,
                field = this.filter.field,
                query = this.filter.query;  
            if(field) {   
              if(this.isActiveFilterSelected()) {
                visible = (typeof query === 'boolean') ? 
                  (query === person.isActive) : true;
              } else {
                query = String(query),
                field = person[field]; 
          if(typeof field === 'number') {
            query.replace(this.currency, '');
                  try {
                    visible = eval(field + query);
                  } catch(e) {}   
                } else {
                  field = field.toLowerCase();
                  visible = 
                  field.includes(query.toLowerCase())  
                }
              }
            }
            return visible;
          }
          changeFilter(event) {
            this.filter.query = '';
            this.filter.field = event.target.value;
          }
        }
      });

在浏览器中查看应用程序,我们应该看到我们的人员列表,并在表格单元格中添加了正确的类,并在字段中添加了格式。

使过滤再次起作用

在模板中的包含tr元素中重新添加v-show="filterRow()"属性。由于我们的组件在每个实例上都有缓存的 person,我们不再需要将 person 对象传递给该方法。刷新页面将在 JavaScript 控制台中给出新的错误:

Property or method "filterRow" is not defined on the instance but referenced during render

这个错误是因为我们的组件有v-show属性,根据我们的过滤和属性来显示和隐藏,但没有相应的filterRow函数。由于我们不在其他地方使用它,我们可以将该方法从 Vue 实例移动到组件中,并将其添加到methods组件中。删除 person 参数并更新方法以使用this.person

      filterRow() {
        let visible = true,
            field = this.filter.field,
            query = this.filter.query;
            if(field) {
            if(this.isActiveFilterSelected()) {
            visible = (typeof query === 'boolean') ?                 
           (query === this.person.isActive) : true;
            } else {

            query = String(query),
            field = this.person[field];

            if(typeof field === 'number') {
              query.replace(this.currency, '');
              try {
                visible = eval(field + query);
              } catch(e) {}
              } else {

              field = field.toLowerCase();
              visible = 
            field.includes(query.toLowerCase());
            }
          }
        }
        return visible;
      }

控制台中的下一个错误是:

Cannot read property 'field' of undefined

过滤不起作用的原因是filterRow方法在组件上寻找this.filter.fieldthis.filter.query,而不是它所属的父 Vue 实例。

作为一个快速修复,您可以使用this.$parent来引用父元素上的数据 - 但是,这不是推荐的做法,只能在极端情况下或快速传递数据时使用。

为了将数据传递给组件,我们将使用另一个 prop - 类似于我们如何将 person 传递给组件。幸运的是,我们已经将我们的过滤数据分组,所以我们可以传递一个对象而不是queryfield的单个属性。在组件上创建一个名为filter的新 prop,并确保只允许传递一个Object

      props: {
        person: Object,
        filter: Object
      },

我们可以将该属性添加到team-member组件中,从而允许我们传递数据:

      <table>
        <template v-for="individual in people">
          <team-member v-bind:person="individual" v-               
           bind:filter="filter"></team-member>
        </template>
      </table>

为了使我们的过滤器工作,我们需要传入另一个属性-isActiveFilterSelected()函数。创建另一个 prop,标题为statusFilter,只允许Boolean作为值(因为这是函数的等价值),并将函数传递进去。更新filterRow方法以使用这个新值。我们的组件现在看起来像这样:

      Vue.component('team-member', {
        template: '#team-member-template',
        props: {
          person: Object,
          filter: Object,
          statusFilter: Boolean
        },
        data() {
          return {
            currency: '$'
          }
        },
        computed: {
          /**
           * CSS Classes
           */
          activeClass() {
            return this.person.isActive ? 'active' : 
            'inactive';
            },
            balanceClass() {
            let balanceLevel = 'success';

         if(this.person.balance < 2000) {
           balanceLevel = 'error';
          } else if (this.person.balance < 3000) {
            balanceLevel = 'warning';
          }
          let increasing = false,
            balance = this.person.balance / 1000;
           if(Math.round(balance) == Math.ceil(balance)) {
             increasing = 'increasing';
          }
          return [balanceLevel, increasing];
        },
       /**
       * Fields
         */
       balance() {
       return this.currency +    
       this.person.balance.toFixed(2);
       },
      dateRegistered() {
       let registered = new Date(this.registered); 
        return registered.toLocaleString('en-US');
        },
        status() {
           return output = (this.person.isActive) ?    
          'Active' : 'Inactive';
         }
       },
       methods: {
        filterRow() {
         let visible = true,
            field = this.filter.field,
            query = this.filter.query;

         if(field) {  
           if(this.statusFilter) {
             visible = (typeof query === 'boolean') ? 
            (query === this.person.isActive) : true;
           } else {
             query = String(query),
            field = this.person[field];  
              if(typeof field === 'number') {
                query.replace(this.currency, '');  
                 try {
                 visible = eval(field + query);
                } catch(e) {
            } 
           } else {   
            field = field.toLowerCase();
            visible = field.includes(query.toLowerCase());
             }
            }
           }
           return visible;
        }
       }
     });

现在,视图中的组件带有额外的 props 如下所示。请注意,当作为 HTML 属性使用时,驼峰式的 prop 变为蛇形式(用连字符分隔):

      <template v-for="individual in people">
          <team-member v-bind:person="individual" v-               bind:filter="filter" v-bind:status-      
            filter="isActiveFilterSelected()"></team-
            member>
       </template>

将过滤器作为一个组件

现在我们需要将过滤部分作为一个独立的组件。在这种情况下,这并不是必需的,但这是一个好的实践,并且给我们带来了更多的挑战。

我们在将过滤器作为组件时面临的问题是在过滤组件和team-member组件之间传递过滤数据的挑战。Vue 通过自定义事件来解决这个问题。这些事件允许您从子组件向父组件或其他组件传递(或“发射”)数据。

我们将创建一个过滤组件,当过滤改变时,将数据传递回父 Vue 实例。这些数据已经通过team-member组件传递以进行过滤。

创建组件

team-member组件一样,在您的 JavaScript 中声明一个新的Vue.component(),引用模板 ID 为#filtering-template。在视图中创建一个新的<script>模板块,并给它相同的 ID。用一个<filtering>自定义 HTML 模板替换视图中的过滤表单,并将表单放在filtering-template脚本块中。

您的视图应该如下所示:

      <div id="app">
       <filtering></filtering>
       <table>
         <template v-for="individual in people">
           <team-member v-bind:person="individual" v-
            bind:filter="filter" v-
            bind:statusfilter="isActiveFilterSelected()">           </team-member>
         </template>
       </table>
      </div>

 <script type="text/x-template" id="filtering-
      template">
        <form>
          <label for="fiterField">
            Field:
            <select v-on:change="changeFilter($event)"                 id="filterField">
           <option value="">Disable filters</option>
           <option value="isActive">Active user</option>
           <option value="name">Name</option>
           <option value="email">Email</option>
           <option value="balance">Balance</option>
           <option value="registered">Date      
            registered</option>
           </select>
         </label>
        <label for="filterQuery" v-show="this.filter.field 
         && !isActiveFilterSelected()">
            Query:
            <input type="text" id="filterQuery" v-
            model="filter.query">
          </label>
          <span v-show="isActiveFilterSelected()">
            Active:
         <label for="userStateActive">
            Yes:
             <input type="radio" v-bind:value="true"       id="userStateActive" v-model="filter.query">
          </label>
            <label for="userStateInactive">
            No:
        <input type="radio" v-bind:value="false" 
        id="userStateInactive" v-model="filter.query">
         </label>
       </span>
      </form>
 </script>
      <script type="text/x-template" id="team-member-
       template">
       // Team member template
    </script>

你的 JavaScript 中应该有以下内容:

      Vue.component('filtering', {
        template: '#filtering-template'
      });

解决 JavaScript 错误

team-member组件一样,您将在 JavaScript 控制台中遇到一些错误。通过复制filter数据对象以及父实例中的changeFilterisActiveFilterSelected方法来解决这些错误。我们暂时将它们保留在组件和父实例中,但稍后将删除重复部分:

      Vue.component('filtering', {
        template: '#filtering-template',

        data() {
 return {
 filter: {
 field: '',
 query: ''
 }
 }
 },

 methods: {
 isActiveFilterSelected() {
 return (this.filter.field === 'isActive');
 },

 changeFilter(event) {
 this.filter.query = '';
 this.filter.field = event.target.value;
 }
 }
      });

运行应用程序将显示过滤器和人员列表,但过滤器不会更新人员列表,因为它们尚未进行通信。

使用自定义事件来更改过滤字段

使用自定义事件,您可以使用$on$emit函数将数据传递回父实例。对于这个应用程序,我们将在父 Vue 实例上存储过滤数据,并从组件中更新它。然后,team-member组件可以从 Vue 实例中读取数据并进行相应的过滤。

第一步是利用父 Vue 实例上的 filter 对象。从组件中删除data对象,并通过 prop 传递父对象 - 就像我们在team-member组件中所做的一样:

      <filtering v-bind:filter="filter"></filtering>

现在,我们将修改changeFilter函数以发出事件数据,以便父实例可以更新filter对象。

filtering组件中删除现有的changeFilter方法,并创建一个名为change-filter-field的新方法。在这个方法中,我们只需要使用$emit来发出在下拉菜单中选择的字段的名称。$emit函数接受两个参数:一个键和一个值。发出一个键为change-filter-field的事件,并将event.target.value作为数据传递。当使用多个单词的变量(例如changeFilterField)时,请确保这些变量在事件名称($emit函数的第一个参数)和 HTML 属性中使用连字符:

      changeFilterField(event) {
        this.$emit('change-filter-field', 
      event.target.value);
      }

为了将数据传递给父 Vue 实例上的 changeFilter 方法,我们需要在元素中添加一个新的 prop。这使用v-on并绑定到自定义事件名称。然后将父方法名称作为属性值。将属性添加到您的元素中:

      <filtering v-bind:filter="filter" v-on:change-filter-field="changeFilter"></filtering>

此属性告诉 Vue 在发出change-filter-field事件时触发changeFilter方法。然后,我们可以调整我们的方法来接受参数作为值:

      changeFilter(field) {
        this.filter.query = '';
        this.filter.field = field;
      }

然后清除过滤器并更新字段值,然后通过 props 传递给我们的组件。

更新过滤器查询

为了发出查询字段,我们将使用一个之前未使用过的新 Vue 键,称为watchwatch函数跟踪数据属性并根据输出运行方法。它还能够发出事件。由于我们的文本字段和单选按钮都设置为更新 field.query 变量,所以我们将在此上创建一个新的watch函数。

在组件的方法之后创建一个新的watch对象:

      watch: {
        'filter.query': function() {
        }
      }

关键是您希望监视的变量。由于我们的变量包含一个点,所以需要用引号括起来。在这个函数中,创建一个新的$emit事件change-filter-query,输出filter.query的值:

     watch: {
         'filter.query': function() {
         this.$emit('change-filter-query', 
         this.filter.query)
         }
       }

现在我们需要将这个方法和自定义事件绑定到视图中的组件,以便它能够将数据传递给父实例。将属性的值设置为changeQuery-我们将创建一个处理此操作的方法:

      <filtering v-bind:filter="filter" v-on:change-      
      filter-field="changeFilter" v-on:change-filter-          
      query="changeQuery"></filtering>

在父 Vue 实例中,创建一个名为changeQuery的新方法,简单地根据输入更新filter.query的值:

     changeQuery(query) {
       this.filter.query = query;
     }

我们的过滤现在又可以工作了。更新选择框和输入框(或单选按钮)现在都会更新我们的人员列表。我们的 Vue 实例变得更小了,我们的模板和方法都包含在单独的组件中。

最后一步是避免在team-member组件上重复使用isActiveFilterSelected()方法,因为这个方法只在team-member组件上使用一次,但在filtering组件上使用多次。从父 Vue 实例中删除该方法,从team-member HTML 元素中删除 prop,并将filterRow方法中的statusFilter变量替换为传递的函数的内容。

最终的 JavaScript 代码现在如下所示:

      Vue.component('team-member', {
        template: '#team-member-template',
        props: {
          person: Object,
          filter: Object
        },
        data() {
          return {
            currency: '$'
          }
        },
        computed: {
          /**
           * CSS Classes
           */
           activeClass() {
            return this.person.isActive ? 'active' : 'inactive';
          },
          balanceClass() {
            let balanceLevel = 'success';    
            if(this.person.balance < 2000) {
              balanceLevel = 'error';
            } else if (this.person.balance < 3000) {
              balanceLevel = 'warning';
            }
           let increasing = false,
            balance = this.person.balance / 1000;      
            if(Math.round(balance) == Math.ceil(balance))             {
             increasing = 'increasing';
            } 
            return [balanceLevel, increasing];
          },
          /**
           * Fields
           */
          balance() {
            return this.currency +       
          this.person.balance.toFixed(2);
          },
          dateRegistered() {
            let registered = new Date(this.registered);  
            return registered.toLocaleString('en-US');
          },
          status() {
            return output = (this.person.isActive) ? 
           'Active' : 'Inactive';
          }
        },
          methods: {
          filterRow() {
            let visible = true,
            field = this.filter.field,
            query = this.filter.query;         
            if(field) {      
              if(this.filter.field === 'isActive') {
              visible = (typeof query === 'boolean') ? 
             (query === this.person.isActive) : true;
              } else {   
                query = String(query),
                field = this.person[field]; 
                if(typeof field === 'number') {
                  query.replace(this.currency, '');
               try {
              visible = eval(field + query);
            } catch(e) {}

          } else {

            field = field.toLowerCase();
            visible = field.includes(query.toLowerCase());  
              }
           }
          }
            return visible;
          }
          }
         });

     Vue.component('filtering', {
     template: '#filtering-template',
       props: {
       filter: Object
     },
       methods: {
       isActiveFilterSelected() {
        return (this.filter.field === 'isActive');
       },     
        changeFilterField(event) {
        this.filedField = '';
       this.$emit('change-filter-field',                     
        event.target.value);
          },
        },
        watch: {
    'filter.query': function() {
      this.$emit('change-filter-query', this.filter.query)
          }
        }
      });

      const app = new Vue({
        el: '#app',

        data: {
          people: [...],
          filter: {
            field: '',
            query: ''
          }
        },
        methods: { 
          changeFilter(field) {
            this.filter.query = '';
            this.filter.field = field;
          },
          changeQuery(query) {
            this.filter.query = query;
          }
        }
      });

视图现在如下所示:

     <div id="app">
        <filtering v-bind:filter="filter" v-on:change-
         filter-field="changeFilter" v-on:change-filter-
          query="changeQuery"></filtering>
       <table>
         <template v-for="individual in people">
          <team-member v-bind:person="individual" v-  
          bind:filter="filter"></team-member>
         </template>
        </table>
     </div>
    <script type="text/x-template" id="filtering-
     template">
       <form>
      <label for="fiterField">
       Field:
      <select v-on:change="changeFilterField($event)" 
         id="filterField">
        <option value="">Disable filters</option>
        <option value="isActive">Active user</option>
        <option value="name">Name</option>
        <option value="email">Email</option>
        <option value="balance">Balance</option>
        <option value="registered">Date     
          registered</option>
         </select>
          </label>
         <label for="filterQuery" v-
         show="this.filter.field && 
          !isActiveFilterSelected()">
         Query:
        <input type="text" id="filterQuery" v-    
         model="filter.query">
          </label>

          <span v-show="isActiveFilterSelected()">
           Active:

            <label for="userStateActive">
              Yes:
            <input type="radio" v-bind:value="true"   
          id="userStateActive" v-model="filter.query">
           </label>
          <label for="userStateInactive">
           No:
            <input type="radio" v-bind:value="false"                 id="userStateInactive" v-model="filter.query">
            </label>
          </span>
        </form>
      </script>
      <script type="text/x-template" id="team-member-
      template">
        <tr v-show="filterRow()">
          <td>
            {{ person.name }}
          </td>
          <td>
            <a v-bind:href="'mailto:' + person.email">{{                person.email }}</a>
          </td>
          <td v-bind:class="balanceClass">
            {{ balance }}
          </td>
          <td>
            {{ dateRegistered }}
          </td>
          <td v-bind:class="activeClass">
            {{ status }}
          </td>
        </tr>
      </script>

总结

在过去的三章中,您已经学会了如何初始化一个新的 Vue 实例,computed、method 和 data 对象的含义,以及如何列出对象中的数据并对其进行正确显示的操作。您还学会了如何创建组件以及保持代码清洁和优化的好处。

在书的下一节中,我们将介绍 Vuex,它可以帮助我们更好地存储和操作存储的数据。

第四章:使用 Dropbox API 获取文件列表

在接下来的几章中,我们将构建一个基于 Vue 的 Dropbox 浏览器。这个应用程序将使用你的 Dropbox API 密钥,允许你浏览文件夹并下载文件。你将学习如何在 Vue 应用程序中与 API 进行交互,了解 Vue 的生命周期钩子,包括created()方法,最后我们将介绍一个名为Vuex的库来处理应用程序的缓存和状态。该应用程序将具有可共享的 URL,并通过#URL 参数检索文件夹的内容。

如果你想让用户访问你的 Dropbox 内容而不提供用户名和密码,这种应用程序将非常有用。但要注意,一个精通技术的用户可能会在代码中找到你的 API 密钥并滥用它,所以不要将这段代码发布到互联网上。

本章将涵盖以下内容:

  • 加载和查询 Dropbox API

  • 列出你的 Dropbox 账户中的目录和文件

  • 为你的应用程序添加加载状态

  • 使用 Vue 动画

在接下来的几章中,你将需要一个 Dropbox 账户。如果你没有账户,请注册并添加一些虚拟文件和文件夹。Dropbox 的内容并不重要,但有文件夹可以帮助你理解代码。

开始加载库

为你的应用程序创建一个新的 HTML 页面运行。创建一个网页所需的 HTML 结构,并包含你的应用程序视图包装器:

      <!DOCTYPE html>
      <html>
      <head>
        <title>Dropbox App</title>
      </head>
      <body>  
 <div id="app">
 </div>  
      </body>
      </html>

这里称之为#app,但你可以随意命名 - 只需记得更新 JavaScript。

由于我们的应用程序代码将变得相当庞大,所以创建一个单独的 JavaScript 文件,并将其包含在文档底部。你还需要包含 Vue 和 Dropbox API SDK。

与之前一样,你可以引用远程文件或下载库文件的本地副本。出于速度和兼容性的原因,下载一个本地副本。在 HTML 文件的底部包含你的三个 JavaScript 文件:

      <script src="js/vue.js"></script>
      <script src="js/dropbox.js"></script>
      <script src="js/app.js"></script>

创建你的app.js并初始化一个新的 Vue 实例,使用el标签将实例挂载到视图中的 ID 上。

      new Vue({
          el: '#app'
      });

创建一个 Dropbox 应用程序并初始化 SDK

在与 Vue 实例交互之前,我们需要通过 SDK 连接到 Dropbox API。这是通过 Dropbox 自动生成的 API 密钥来完成的,用于跟踪连接到你的账户的内容和位置,Dropbox 要求你创建一个自定义的 Dropbox 应用程序。

前往 Dropbox 开发者区域并选择创建你的应用。选择 Dropbox API 并选择一个受限文件夹或完全访问权限。这取决于你的需求,但是为了测试,选择完全访问权限。给你的应用取一个名字并点击创建应用按钮。

为你的应用生成一个访问令牌。要这样做,在查看应用详情页面时,点击“生成访问令牌”下的“生成”按钮。这将给你一个由数字和字母组成的长字符串 - 将其复制并粘贴到你的编辑器中,并将其作为一个变量存储在你的 JavaScript 的顶部。在本书中,API 密钥将被称为XXXX

      /**
       * API Access Token
       */
      let accessToken = 'XXXX';

现在我们有了 API 密钥,我们可以访问我们 Dropbox 中的文件和文件夹。初始化 API 并将你的accessToken变量传递给 Dropbox API 的accessToken属性:

      /**
      * Dropbox Client
      * @type {Dropbox}
      */
      const dbx = new Dropbox({
        accessToken: accessToken
      });

现在我们可以通过dbx变量访问 Dropbox。我们可以通过连接并输出根路径的内容来验证我们与 Dropbox 的连接是否正常:

      dbx.filesListFolder({path: ''})
          .then(response => {
            console.log(response.entries);
          })
          .catch(error => {
            console.log(error);
          });

这段代码使用了 JavaScript 的 Promise,它是一种在代码中添加操作而不需要回调函数的方法。如果你对 Promise 不熟悉,可以查看 Google 的这篇博文(developers.google.com/web/fundamentals/primers/promises)。

注意第一行,特别是path变量。这使我们可以传递一个文件夹路径来列出该目录中的文件和文件夹。例如,如果你的 Dropbox 中有一个名为images的文件夹,你可以将参数值更改为/images,返回的文件列表将是该目录中的文件和文件夹。

打开你的 JavaScript 控制台并检查输出;你应该得到一个包含多个对象的数组 - 每个对象对应你 Dropbox 根目录中的一个文件或文件夹。

显示你的数据并使用 Vue 获取它

现在我们可以使用 Dropbox API 检索我们的数据了,是时候在我们的 Vue 实例中检索它并在我们的视图中显示了。这个应用将完全使用组件构建,这样我们就可以利用分隔的数据和方法。这也意味着代码是模块化和可共享的,如果你想将其集成到其他应用中。

我们还将利用 Vue 的原生created()函数 - 我们将在稍后讨论它被触发的时候。

创建组件

首先,在你的视图中创建自定义的 HTML 元素<dropbox-viewer>。在页面底部创建一个<script>模板块,用于我们的 HTML 布局:

      <div id="app">
        <dropbox-viewer></dropbox-viewer>
      </div> 
      <script type="text/x-template" id="dropbox-viewer-          
       template">
        <h1>Dropbox</h1>
      </script>

app.js文件中初始化你的组件,将其指向模板 ID:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template'
      });

在浏览器中查看应用程序应该显示模板中的标题。下一步是将 Dropbox API 集成到组件中。

检索 Dropbox 数据

创建一个名为dropbox的新方法。在其中,将调用 Dropbox 类并返回实例的代码移动到这里。现在通过调用this.dropbox()可以通过组件访问 Dropbox API:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template',  
        methods: {
 dropbox() {
 return new Dropbox({
 accessToken: this.accessToken
 });
 }
 }
      });

我们还要将 API 密钥集成到组件中。创建一个返回包含你的访问令牌的对象的数据函数。更新 Dropbox 方法,使用本地版本的密钥:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template',  
        data() {
 return {
 accessToken: 'XXXX'
 }
 },
        methods: {
          dropbox() {
            return new Dropbox({
              accessToken: this.accessToken
            });
          }
        }
      });

现在我们需要为组件添加获取目录列表的功能。为此,我们将创建另一个方法,它接受一个参数-路径。这将使我们以后能够根据需要请求不同路径或文件夹的结构。

使用之前提供的代码-将dbx变量更改为this.dropbox()

      getFolderStructure(path) {
        this.dropbox().filesListFolder({path: path})
        .then(response => {
          console.log(response.entries);
        })
        .catch(error => {
          console.log(error);
        });
      }

更新 Dropbox 的filesListFolder函数,接受传入的路径参数,而不是固定值。在浏览器中运行此应用程序将显示 Dropbox 标题,但不会检索任何文件夹,因为尚未调用这些方法。

Vue 生命周期钩子

这就是created()函数的作用。created()函数在 Vue 实例初始化数据和方法后调用,但尚未将实例挂载到 HTML 组件上。在生命周期的不同阶段还有其他几个可用的函数;关于这些函数的更多信息可以在 Alligator.io 上阅读。生命周期如下:

使用created()函数使我们能够在 Vue 挂载应用程序时访问方法和数据,并能够启动我们的检索过程。这些不同阶段之间的时间是分秒必争的,但在性能和创建快速应用程序方面,每一刻都很重要。如果我们可以提前开始任务,就没有必要等待应用程序完全挂载。

在你的组件上创建created()函数,并调用getFolderStructure方法,传入一个空字符串作为获取 Dropbox 根目录的路径:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template',  
        data() {
          return {
            accessToken: 'XXXX'
          }
        }, 
        methods: {
         ...
        }, 
        created() {
 this.getFolderStructure('');
 }
      });

现在在浏览器中运行应用程序,将文件夹列表输出到控制台,结果应与之前相同。

现在我们需要在视图中显示文件列表。为此,我们将在组件中创建一个空数组,并用我们的 Dropbox 查询结果填充它。这样做的好处是,即使在没有任何内容之前,Vue 也可以在视图中循环遍历一个变量。

显示 Dropbox 数据

在数据对象中创建一个名为structure的新属性,并将其赋值为空数组。在文件夹检索的响应函数中,将response.entries赋值给this.structure。保留console.log,因为我们需要检查条目以确定在模板中输出什么:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template', 
        data() {
          return {
            accessToken: 'XXXX',
            structure: []
          }
        },
        methods: {
          dropbox() {
            return new Dropbox({
              accessToken: this.accessToken
            });
          },
          getFolderStructure(path) {
            this.dropbox().filesListFolder({path: path})
            .then(response => {
              console.log(response.entries);
              this.structure = response.entries;
            })
            .catch(error => {
              console.log(error);
            });
          }
        },  
        created() {
          this.getFolderStructure('');
        }
      });

现在我们可以更新视图以显示来自 Dropbox 的文件夹和文件。由于结构数组在视图中可用,因此创建一个可重复的<li>循环遍历结构的<ul>

由于我们现在添加了第二个元素,Vue 要求模板必须包含一个包含元素的容器,所以将标题和列表包装在一个<div>中:

      <script type="text/x-template" id="dropbox-viewer-         
        template">
        <div>
          <h1>Dropbox</h1>
          <ul>
 <li v-for="entry in structure">
 </li>
 </ul>
 </div>
      </script>

在浏览器中查看应用程序时,当数组出现在 JavaScript 控制台中时,将显示一些空的项目符号。为了确定可以显示哪些字段和属性,请展开 JavaScript 控制台中的数组,然后进一步展开每个对象。您应该注意到每个对象都有一组相似的属性和一些在文件夹和文件之间有所不同的属性。

第一个属性.tag帮助我们确定该项是文件还是文件夹。然后,这两种类型都具有以下共同属性:

  • id: Dropbox 的唯一标识符。

  • name: 文件或文件夹的名称,与该项所在位置无关。

  • path_display: 项目的完整路径,与文件和文件夹的大小写匹配。

  • path_lower: 与path_display相同,但全部小写。

.tag为文件的项还包含了一些更多的字段供我们显示:

  • client_modified: 文件添加到 Dropbox 的日期。

  • content_hash: 文件的哈希值,用于判断它是否与本地或远程副本不同。关于此内容可以在 Dropbox 网站上阅读更多信息。

  • rev: 文件版本的唯一标识符。

  • server_modified: 文件在 Dropbox 上最后修改的时间。

  • size: 文件的大小(以字节为单位)。

首先,我们将显示项目的名称和大小(如果有)。更新列表项以显示这些属性:

      <li v-for="entry in structure">
        <strong>{{ entry.name }}</strong>
        <span v-if="entry.size"> - {{ entry.size }}</span>
      </li>

更多文件元信息

为了使我们的文件和文件夹视图更有用,我们可以为图像等文件添加更多丰富的内容和元数据。通过在 Dropbox API 中启用include_media_info选项,可以获得这些详细信息。

返回到您的getFolderStructure方法,并在path之后添加该参数。以下是一些新的可读性行:

      getFolderStructure(path) {
        this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {
          console.log(response.entries);
          this.structure = response.entries;
        })
        .catch(error => {
          console.log(error);
        });
      }

检查这个新 API 调用的结果将显示视频和图像的media_info键。展开它将显示有关文件的更多信息,例如尺寸。如果您想添加这些信息,您需要在显示信息之前检查media_info对象是否存在:

      <li>
        <strong>{{ f.name }}</strong>
        <span v-if="f.size"> - {{ bytesToSize(f.size) }}          
        </span> - 
        <span v-if="f.media_info">
 [
 {{ f.media_info.metadata.dimensions.width }}px x 
 {{ f.media_info.metadata.dimensions.height }}px
 ]
 </span>
      </li>

尝试在从 Dropbox 检索数据时更新路径。例如,如果您有一个名为images的文件夹,将this.getFolderStructure参数更改为/images。如果您不确定路径是什么,请在 JavaScript 控制台中分析数据,并复制文件夹的path_lower属性的值,例如:

      created() {
        this.getFolderStructure('/images');
      }

格式化文件大小

由于文件大小以纯字节输出,对于用户来说可能很难理解。为了解决这个问题,我们可以添加一个格式化方法来输出一个更用户友好的文件大小,例如显示<q>1kb</q>而不是<q>1024</q>

首先,在数据对象上创建一个包含单位数组的新键byteSizes

      data() {
        return {
          accessToken: 'XXXX',
          structure: [],
          byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB']
        }
      }

这是将附加到图形的内容,所以可以将这些属性设置为小写或全词,例如megabyte

接下来,在您的组件中添加一个新的方法bytesToSize。它将接受一个bytes参数,并输出一个带有单位的格式化字符串:

      bytesToSize(bytes) {
        // Set a default
        let output = '0 Byte'; 
        // If the bytes are bigger than 0
        if (bytes > 0) {
          // Divide by 1024 and make an int
          let i = parseInt(Math.floor(Math.log(bytes) /              
           Math.log(1024)));
          // Round to 2 decimal places and select the                 
             appropriate unit from the array
            output = Math.round(bytes / Math.pow(1024, i), 
              2) + ' ' + this.byteSizes[i];
            }
            return output
          }

现在我们可以在视图中利用这个方法:

      <li v-for="entry in structure">
        <strong>{{ entry.name }}</strong>
        <span v-if="entry.size"> - {{ 
        bytesToSize(entry.size) }}</span>
      </li>

添加一个加载屏幕

本章的最后一步是为我们的应用程序创建一个加载屏幕。这将告诉用户应用程序正在加载,如果 Dropbox API 运行缓慢(或者您有很多数据要显示!)。

这个加载屏幕背后的理论相当基本。我们将默认将加载变量设置为true,一旦数据加载完成,它将被设置为false。根据这个变量的结果,我们将利用视图属性来显示和隐藏带有加载文本或动画的元素,并显示加载的数据列表。

在数据对象中创建一个名为isLoading的新键。将这个变量默认设置为true

      data() {
        return {
          accessToken: 'XXXX',
          structure: [],
          byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
          isLoading: true
        }
      }

在组件的getFolderStructure方法中,将isLoading变量设置为false。这应该在您设置结构之后的 promise 中发生:

      getFolderStructure(path) {
        this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {
          console.log(response.entries);
          this.structure = response.entries;
          this.isLoading = false;
        })
        .catch(error => {
          console.log(error);
        });
      }

现在我们可以在视图中利用这个变量来显示和隐藏加载容器。

在无序列表之前创建一个新的<div>,其中包含一些加载文本。随意添加 CSS 动画或动画 gif-任何东西都可以让用户知道应用程序正在检索数据:

      <h1>Dropbox</h1>
 <div>Loading...</div>
      <ul>
      ...

现在,我们只需要在应用程序加载时显示加载 div,数据加载后显示列表。由于这只是对 DOM 的一个更改,我们可以使用v-if指令。为了让您可以自由地重新排列 HTML,将属性添加到两者而不是使用v-else

要显示或隐藏,我们只需要检查isLoading变量的状态。我们可以在列表前面添加一个感叹号,只有在应用程序没有加载时才显示:

      <div>
        <h1>Dropbox</h1>
        <div v-if="isLoading">Loading...</div>
         <ul v-if="!isLoading">
          <li v-for="entry in structure">
            <strong>{{ entry.name }}</strong>
            <span v-if="entry.size">- {{ 
             bytesToSize(entry.size) }}</span>
          </li>
        </ul>
      </div>

我们的应用程序现在应该在挂载后显示加载容器,然后在收集到应用程序数据后显示列表。总结一下,我们完整的组件代码现在看起来像这样:

      Vue.component('dropbox-viewer', {
        template: '#dropbox-viewer-template',
        data() {
          return {
            accessToken: 'XXXX',
            structure: [],
            byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
            isLoading: true
          }
        },
        methods: {
          dropbox() {
            return new Dropbox({
              accessToken: this.accessToken
            });
          },
          getFolderStructure(path) {
            this.dropbox().filesListFolder({
              path: path, 
              include_media_info: true
            })
            .then(response => {
              console.log(response.entries);
              this.structure = response.entries;
              this.isLoading = false;
            })
            .catch(error => {
              console.log(error);
            });
          },

          bytesToSize(bytes) {
            // Set a default
            let output = '0 Byte';

            // If the bytes are bigger than 0
            if (bytes > 0) {
              // Divide by 1024 and make an int
              let i = parseInt(Math.floor(Math.log(bytes)               
              / Math.log(1024)));
              // Round to 2 decimal places and select the                 
                appropriate unit from the array
                output = Math.round(bytes / Math.pow(1024, 
                i), 2) + ' ' + this.byteSizes[i];
            }
           return output
          }
        },
        created() {
          this.getFolderStructure('');
        }
      });

在状态之间进行动画切换

作为对用户的一个很好的增强,我们可以在组件和状态之间添加一些过渡效果。幸运的是,Vue 包含了一些内置的过渡效果。通过使用 CSS,这些过渡效果允许您在插入 DOM 元素时轻松地添加淡入淡出、滑动和其他 CSS 动画。有关过渡的更多信息可以在 Vue 文档中找到。

第一步是添加 Vue 自定义 HTML <transition>元素。用单独的过渡元素包裹您的加载和列表,并给它一个name属性和一个fade值:

      <script type="text/x-template" id="dropbox-viewer-      
       template">
        <div>
          <h1>Dropbox</h1>
          <transition name="fade">
            <div v-if="isLoading">Loading...</div>
          </transition>
          <transition name="fade">
            <ul v-if="!isLoading">
              <li v-for="entry in structure">
                <strong>{{ entry.name }}</strong>
                <span v-if="entry.size">- {{         
                bytesToSize(entry.size) }}</span>
              </li>
            </ul>
          </transition>
        </div>
</script>

现在将以下 CSS 添加到您的文档头部或一个单独的样式表中(如果您已经有一个):

      .fade-enter-active,
      .fade-leave-active {
        transition: opacity .5s
      }
      .fade-enter, 
      .fade-leave-to {
        opacity: 0
      }

使用过渡元素,Vue 根据过渡的状态和时间添加和删除各种 CSS 类。所有这些类都以通过属性传递的名称开头,并附加有关过渡当前阶段的信息:

在浏览器中尝试该应用程序,您应该注意到加载容器的淡出和文件列表的淡入。尽管在这个基本示例中,列表在淡出完成后会跳动一次,但这是一个示例,可以帮助您了解在 Vue 中使用过渡效果。

总结

在本章中,我们学习了如何制作一个 Dropbox 查看器,它是一个单页面应用程序,可以列出我们 Dropbox 账户中的文件和文件夹,并允许我们通过更新代码来显示不同的文件夹内容。我们学习了如何为我们的应用程序添加基本的加载状态,并使用 Vue 动画进行导航。

在第五章中,通过文件树导航和从 URL 加载文件夹,我们将浏览我们的应用程序文件夹,并为我们的文件添加下载链接。

第五章:通过文件树导航和从 URL 加载文件夹

在第四章中,使用 Dropbox API 获取文件列表,我们创建了一个应用程序,列出了指定 Dropbox 文件夹的文件和文件夹内容。现在我们需要使我们的应用程序易于导航。这意味着用户将能够点击文件夹名称以进入并列出其内容,并且还能够下载文件。

在继续之前,请确保在 HTML 中包含了 Vue 和 Dropbox 的 JavaScript 文件。

在本章中,我们将会:

  • 为文件和文件夹创建一个组件

  • 为文件夹组件添加链接以更新目录列表

  • 为文件组件添加下载按钮

  • 创建一个面包屑组件,以便用户可以轻松地返回到上一级目录

  • 动态更新浏览器的 URL,这样如果一个文件夹被收藏夹或链接分享,正确的文件夹将被加载

分离文件和文件夹

在创建组件之前,我们需要将文件和文件夹分开放置在我们的结构中,这样我们就可以轻松地识别和显示不同类型的文件和文件夹。由于每个项目上都有.tag属性,我们可以将文件夹和文件分开。

首先,我们需要更新我们的structure数据属性,使其成为一个包含filesfolders数组的对象。

      data() {
        return {
          accessToken: 'XXXX',
          structure: {
 files: [],
 folders: []
 },
          byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
          isLoading: true
        }
      }

这使我们能够将文件和文件夹追加到不同的数组中,从而可以在视图中以不同的方式显示它们。

下一步是用当前文件夹的数据填充这些数组。所有以下代码都在getFolderStructure方法的第一个then()函数中执行。

创建一个 JavaScript 循环来遍历条目并检查项目的.tag属性。如果它等于folder,则将其追加到structure.folder数组中,否则将其添加到structure.files数组中:

      getFolderStructure(path) {
        this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {
          for (let entry of response.entries) {
 // Check ".tag" prop for type
 if(entry['.tag'] === 'folder') {
 this.structure.folders.push(entry);
 } else {
 this.structure.files.push(entry);
 }
 }
          this.isLoading = false;
        })
        .catch(error => {
          console.log(error);
        });
      },

这段代码通过循环遍历条目,并检查.tag属性。由于属性本身以.开头,我们无法像访问entry.name属性那样使用对象样式的表示法来访问该属性。然后,根据类型,我们使用 JavaScript 的 push 方法将条目追加到filesfolders数组中。

为了显示这些新数据,我们需要更新视图以循环遍历这两种类型的数组。这是使用<template>标签的一个完美用例,因为我们想要将这两个数组都追加到同一个无序列表中。

更新视图以单独列出这两个数组。我们可以从文件夹显示部分中删除size选项,因为它永远不会有size属性:

      <ul v-if="!isLoading">
        <template v-for="entry in structure.folders">
 <li>
 <strong>{{entry.name }}</strong>
 </li>
 </template>
 <template v-for="entry in structure.files">
 <li>
 <strong>{{ entry.name }}</strong>
 <span v-if="entry.size">- {{ bytesToSize(entry.size)       }}</span>
 </li>
 </template>
      </ul>

现在我们有机会为两种类型创建组件。

创建文件和文件夹组件

通过将数据类型分离出来,我们可以创建单独的组件来分隔数据和方法。创建一个folder组件,它接受一个属性,允许通过folder对象变量传递。由于模板非常小,所以不需要基于视图或<script>块的模板;相反,我们可以将其作为字符串传递给组件:

      Vue.component('folder', {
        template: '<li><strong>{{ f.name }}</strong>      
        </li>',
        props: {
          f: Object
        },
      });

为了使我们的代码更小、更少重复,属性被称为f。这样可以整理视图,并让组件名称确定显示类型,而不需要多次重复使用单词folder

更新视图以使用文件夹组件,并将entry变量传递给f属性:

      <template v-for="entry in structure.folders">
        <folder :f="entry"></folder>
      </template>

通过创建一个file组件来重复这个过程。创建file组件时,我们可以将bytesToSize方法和byteSizes数据属性从父dropbox-viewer组件中移动,因为它只会在显示文件时使用:

      Vue.component('file', {
        template: '<li><strong>{{ f.name }}</strong><span       v-if="f.size"> - {{ bytesToSize(f.size) }}</span>         </li>',
        props: {
          f: Object
        },
        data() {
          return {
            byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB']
          }
        }, 
        methods: {
          bytesToSize(bytes) {
            // Set a default
            let output = '0 Byte';      
            // If the bytes are bigger than 0
            if (bytes > 0) {
              // Divide by 1024 and make an int
              let i = parseInt(Math.floor(Math.log(bytes) 
              / Math.log(1024)));
             // Round to 2 decimal places and select the 
            appropriate unit from the array
            output = Math.round(bytes / Math.pow(1024, i), 
             2) + ' ' + this.byteSizes[i];
            }   
            return output
          }
        }
      });

再次,我们可以使用f作为属性名称来减少重复(和应用程序的文件大小)。再次更新视图以使用这个新组件:

      <template v-for="entry in structure.files">
        <file :f="entry"></file>
      </template>

链接文件夹并更新结构

现在我们已经将文件夹和文件分开了,我们可以将文件夹名称转换为链接。这些链接将更新结构以显示所选文件夹的内容。为此,我们将使用每个文件夹的path_lower属性来构建链接目标。

为每个文件夹的name创建一个动态链接,链接到文件夹的path_lower。由于我们对 Vue 越来越熟悉,v-bind属性已经缩短为冒号表示法:

      Vue.component('folder', {
        template: '<li><strong><a :href="f.path_lower">{{ 
        f.name }}</a></strong></li>',
        props: {
          f: Object
        },
      });

现在我们需要为此链接添加一个click监听器。当点击时,我们需要在dropbox-viewer组件上触发getFolderStructure方法。虽然点击方法将使用每个实例上的f变量来获取数据,但最好将href属性设置为文件夹的 URL。

使用本书早期章节中学到的知识,在folder组件上创建一个方法,当触发时将文件夹路径发送到父组件。当触发时,dropbox-viewer组件还需要一个新的方法来使用给定的参数更新结构。

folder组件上创建新方法,并将click事件添加到文件夹链接上。与v-bind指令一样,我们现在使用v-on的简写符号@表示:

      Vue.component('folder', {
        template: '<li><strong><a          
 @click.prevent="navigate()" :href="f.path_lower">{{ 
       f.name }}</a></strong></li>',
        props: {
          f: Object
        },
        methods: {
          navigate() {
 this.$emit('path', this.f.path_lower);
 }
        }
      });

除了定义click事件之外,还添加了一个事件修饰符。在点击事件之后使用.preventpreventDefault添加到链接操作中,这样可以阻止链接实际上转到指定的 URL,而是让click方法处理所有事情。有关更多事件修饰符及其详细信息,请参阅 Vue 文档。

当点击时,将触发navigate方法,该方法使用path变量发出文件夹的较低路径。

现在我们有了click处理程序和被发出的变量,我们需要更新视图以触发父dropbox-viewer组件上的方法:

      <template v-for="entry in structure.folders">
        <folder :f="entry" @path="updateStructure">      
        </folder>
      </template>

在 Dropbox 组件上创建一个与v-on属性的值相同的新方法,本例中为updateStructure。此方法将有一个参数,即我们之前发出的路径。从这里,我们可以使用路径变量触发我们原始的getFolderStructure方法:

      updateStructure(path) {
        this.getFolderStructure(path);
      }

在浏览器中查看我们的应用程序现在应该列出文件夹和链接,并在点击时显示新文件夹的内容。

但是,在这样做时,会引发一些问题。首先,文件和文件夹被附加到现有列表而不是替换它。其次,用户没有任何反馈,表明应用程序正在加载下一个文件夹。

可以通过在附加新结构之前清除文件夹和文件数组来解决第一个问题。可以通过使用应用程序开始时使用的加载屏幕来解决第二个问题-这将为用户提供一些反馈。

为了解决第一个问题,在getFolderStructure方法的成功 promise 函数中创建一个新的structure对象。该对象应该复制data对象中的structure对象。这将为文件和文件夹设置空数组。更新for循环以使用本地结构数组而不是组件数组。最后,使用更新后的文件和文件夹更新组件的structure对象:

      getFolderStructure(path) {
        this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {  
          const structure = {
 folders: [],
 files: []
 }
          for (let entry of response.entries) {
            // Check ".tag" prop for type
            if(entry['.tag'] == 'folder') {
              structure.folders.push(entry);
            } else {
              structure.files.push(entry);
            }
          } 
          this.structure = structure;
          this.isLoading = false;
        })
        .catch(error => {
          console.log(error);
        });
      }

当应用程序挂载并创建自己的结构对象时,会调用此方法,因此不需要在data函数中声明数组。将数据对象更新为仅将structure属性初始化为对象:

      data() {
        return {
          accessToken: 'XXXX',
          structure: {},
          isLoading: true
        }
      }

现在运行应用程序将渲染文件列表,当点击进入新文件夹时,文件列表将被清除并更新。为了给用户一些反馈并让他们知道应用程序正在工作,让我们在每次点击后切换加载屏幕。

然而,在此之前,让我们充分了解延迟来自何处以及何时最好触发加载屏幕。

点击链接是瞬间的,这会触发文件夹组件上的导航方法,进而触发 Dropbox 组件上的updateStructure方法。当应用程序到达 Dropbox 实例上的filesListFolder函数时,延迟发生在getFolderStructure方法内部。由于我们可能希望在以后的某个日期触发getFolderStucture方法而不触发加载屏幕,所以在updateStructure方法中将isLoading变量设置为true

      updateStructure(path) {
        this.isLoading = true;
        this.getFolderStructure(path);
      }

通过动画,应用程序在导航文件夹时在加载屏幕和文件夹结构之间淡入淡出。

从当前路径创建面包屑

在浏览文件夹或任何嵌套结构时,始终有一个可用的面包屑非常好,这样用户就知道他们在哪里,他们走了多远,也可以轻松返回到以前的文件夹。我们将为面包屑制作一个组件,因为它将包含各种属性、计算函数和方法。

面包屑组件将以链接的形式列出每个文件夹的深度,链接指向文件夹图标。点击链接将直接将用户导航到该文件夹 - 即使它在多个层级上。为了实现这一点,我们需要一个链接列表,我们可以循环遍历,每个链接都有两个属性 - 一个是文件夹的完整路径,另一个只是文件夹的名称。

例如,如果我们有文件夹结构为/images/holiday/summer/iphone,我们希望能够点击Holiday并且应用程序导航到/images/holiday

创建面包屑组件 - 现在,在模板属性中添加一个空的<div>

      Vue.component('breadcrumb', {
        template: '<div></div>'
      });

将组件添加到视图中。我们希望面包屑与结构列表一起淡入淡出,因此我们需要调整 HTML,将列表和面包屑组件都包裹在具有v-if声明的容器中:

      <transition name="fade">
        <div v-if="!isLoading">
          <breadcrumb></breadcrumb>
          <ul>
            <template v-for="entry in structure.folders">
              <folder :f="entry" @path="updateStructure">              </folder>
            </template>  
            <template v-for="entry in structure.files">
              <file :f="entry"></file>
            </template>
          </ul>
        </div>
      </transition>

现在我们需要一个变量来存储当前文件夹路径。然后我们可以在面包屑组件中操作这个变量。这个变量将被存储并在 Dropbox 组件上更新,并传递给面包屑组件。

dropbox-viewer组件上创建一个名为path的新属性:

      data() {
        return {
          accessToken: 'XXXXX',
          structure: {},
          isLoading: true,
          path: ''
        }
      }

现在我们需要确保当从 Dropbox API 检索到结构时,该路径会得到更新。在getFolderStructure方法中,在禁用isLoading变量之前执行此操作。这样可以确保它只在结构加载完成之后,但在文件和文件夹显示之前更新:

      getFolderStructure(path) {
        this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {    
          const structure = {
            folders: [],
            files: []
          }  
          for (let entry of response.entries) {
            // Check ".tag" prop for type
            if(entry['.tag'] == 'folder') {
              structure.folders.push(entry);
            } else {
              structure.files.push(entry);
            }
          } 
          this.path = path;
          this.structure = structure;
          this.isLoading = false;
        })
        .catch(error => {
          console.log(error);
        });
      },

现在我们有了一个填充了当前路径的变量,我们可以将其作为属性传递给面包屑组件。在面包屑上添加一个新的属性,将路径变量作为值:

      <breadcrumb :p="path"></breadcrumb>

更新组件以接受字符串作为属性:

      Vue.component('breadcrumb', {
        template: '<div></div>',
        props: {
 p: String
 }
      });

p属性现在包含了我们所在位置的完整路径(例如/images/holiday/summer)。我们想要将这个字符串分解,以便我们可以识别文件夹名称并构建面包屑以供组件渲染。

在组件上创建一个computed对象,并创建一个名为folders()的新函数。这将为我们创建面包屑数组,供我们在模板中循环遍历:

      computed: {
       folders() {   
        }
      }

现在我们需要设置一些变量供我们使用。创建一个新的空数组output。这是我们要构建面包屑的地方。我们还需要一个空字符串变量slugslug变量是指 URL 的一部分,它的使用在 WordPress 中很流行。最后一个变量是作为数组创建的路径。我们知道,每个文件夹都是由/分隔的,我们可以使用这个来将字符串分解成各个部分:

      computed: {
        folders() {
 let output = [],
 slug = '',
 parts = this.p.split('/');
        }
      }

如果我们查看Summer文件夹的parts变量,它会像下面这样:

      ['images', 'holiday', 'summer']

现在我们可以循环遍历数组来创建面包屑。每个面包屑项都将是一个对象,包含个别文件夹的name,例如holidaysummer,以及slug,前者为/images/holiday,后者为/images/holiday/summer

每个对象都将被构建并添加到output数组中。然后我们可以返回这个输出供我们的模板使用:

      folders() {
        let output = [],
          slug = '',
          parts = this.p.split('/'); 
 for (let item of parts) {
 slug += item;
 output.push({'name': item, 'path': slug});
 slug += '/';
 }  
        return output;
      }

这个循环通过以下步骤创建我们的面包屑。以这个例子为例,我们假设我们在/images/holiday文件夹中:

  1. parts现在将是一个包含三个项的数组,['', 'images', holiday']。如果你要拆分的字符串以你正在拆分的项开头,那么将创建一个空项作为第一项。

  2. 在循环开始时,第一个 slug 变量将等于'',因为它是第一个项。

  3. output数组将附加一个新项,其对象为{'name': '', 'path': ''}

  4. 然后,slug变量在末尾添加了一个斜杠。

  5. 循环遍历下一个项时,slug变量会将该项的名称(images)添加到其中。

  6. output现在添加了一个新对象,其值为{'name': 'images', 'path': '/images'}

  7. 对于最后一项,除了下一个名称holiday之外,还添加了一个/

  8. output获取最后添加的对象,其值为{'name': 'holiday', 'path': '/images/holiday'} - 注意路径正在构建,而名称仍然是单数文件夹名称。

现在我们有了可以在视图中循环遍历的面包屑输出数组。

我们在将项附加到输出数组之后添加斜杠的原因是 API 规定,要获取 Dropbox 的根目录,我们传入一个空字符串,而所有其他路径必须以/开头。

下一步是将面包屑输出到我们的视图中。由于这个模板很小,我们将使用多行 JavaScript 表示法。循环遍历folders计算变量中的项,为每个项输出一个链接。不要忘记在所有链接周围保留一个包含元素:

      template: '<div>' +
 '<span v-for="f in folders">' +
 '<a :href="f.path">{{ f.name }}</a>' +
 '</span>' + 
      '</div>'

在浏览器中渲染此应用程序应该显示一个面包屑 - 尽管有点挤在一起,缺少一个主页链接(因为第一个项没有名称)。返回到folders函数并添加一个if语句 - 检查该项是否有名称,如果没有,则添加一个硬编码的值:

      folders() {
        let output = [],
          slug = '',
          parts = this.p.split('/');
        console.log(parts);
        for (let item of parts) {
          slug += item;
          output.push({'name': item || 'home', 'path':      
            slug});
          slug += '/';
        }  
        return output;
      }

另一种选择是在模板本身中添加if语句:

      template: '<div>' +
        '<span v-for="f in folders">' +
          '<a :href="f.path">{{ f.name || 'Home' }}</a>' +
        '</span>' + 
      '</div>'

如果我们想在文件夹名称之间显示分隔符,比如斜杠或箭头,这可以很容易地添加。然而,当我们想在链接之间显示分隔符,但不在开头或结尾时,会出现一个小障碍。为了解决这个问题,我们将利用循环时可用的index关键字。然后,我们将将其与数组的长度进行比较,并在元素上操作v-if声明。

在循环遍历数组时,Vue 允许您利用另一个变量。默认情况下,这是索引(数组中项的位置);然而,如果您的数组是以键/值方式构建的,则索引可以设置为一个值。如果是这种情况,您仍然可以通过添加第三个变量来访问索引。由于我们的数组是一个简单的列表,我们可以轻松地使用这个变量:

      template: '<div>' +
        '<span v-for="(f, i) in folders">' +
          '<a :href="f.path">{{ f.name || 'Home' }}</a>' +
          '<span v-if="i !== (folders.length - 1)"> » 
            </span>' +
        '</span>' + 
      '</div>',

更新f变量为包含一个f和一个i的括号,用逗号分隔。f变量是循环中的当前文件夹,而已创建的i变量是该项的索引。请记住,数组索引从0开始,而不是从1开始。

我们添加的分隔符包含在一个带有v-if属性的 span 标签中,其内容可能看起来令人困惑。这是将当前索引与folders数组的长度(它有多少项)减 1 混淆了。- 1是因为索引从 0 开始,而不是从 1 开始,正如您所期望的那样。如果数字不匹配,则显示span元素。

我们需要做的最后一件事是使我们的面包屑导航到所选文件夹。我们可以通过修改我们为folder组件编写的导航函数来实现这一点。然而,由于我们的整个组件是面包屑,而不是每个单独的链接,所以我们需要修改它以接受一个参数。

首先,在链接上添加click事件,传入folder对象:

      template: '<div>' +
        '<span v-for="(f, i) in folders">' +
          '<a @click.prevent="navigate(f)"          
            :href="f.path"> 
            {{ f.name || 'Home' }}</a>' +
          '<i v-if="i !== (folders.length - 1)"> &raquo; 
           </i>' +
        '</span>' + 
      '</div>',

接下来,在面包屑组件上创建navigate方法,确保接受folder参数并发出路径:

      methods: {
        navigate(folder) {
          this.$emit('path', folder.path);
        }
      }

最后一步是在路径被发出时触发父方法。为此,我们可以在dropbox-viewer组件上利用相同的updateStructure方法:

      <breadcrumb :p="path" @path="updateStructure">      
      </breadcrumb>

现在我们有了一个完全可操作的面包屑,允许用户通过文件夹链接向下导航文件夹结构,并通过面包屑链接向上导航。

我们完整的面包屑组件如下所示:

      Vue.component('breadcrumb', {
        template: '<div>' +
          '<span v-for="(f, i) in folders">' +
            '<a @click.prevent="navigate(f)" 
             :href="f.path">{{ 
              f.name || 'Home' }}</a>' +
              '<i v-if="i !== (folders.length - 1)"> » 
              </i>' + '</span>' + 
             '</div>',

        props: {
    p: String
  },

  computed: {
    folders() {
      let output = [],
        slug = '',
        parts = this.p.split('/');
      console.log(parts);
      for (let item of parts) {
        slug += item;
        output.push({'name': item || 'home', 'path':   
        slug});
        slug += '/';
      }

      return output;
    }
  },

   methods: {
    navigate(folder) {
      this.$emit('path', folder.path);
    }
  }
});

添加下载文件的功能

现在,我们的用户可以通过文件夹结构导航,我们需要添加下载文件的功能。不幸的是,这并不像访问文件上的链接属性那样简单。为了获取下载链接,我们必须查询 Dropbox API 以获取每个文件。

在创建文件组件时,我们将查询 API,这将异步获取下载链接并在可用时显示。在此之前,我们需要将 Dropbox 实例提供给文件组件。

在视图中为文件组件添加一个新属性,并将 Dropbox 方法作为值传递:

      <file :d="dropbox()" :f="entry"></file>

d变量添加到接受对象的组件的props对象中:

    props: {
      f: Object,
      d: Object
    },

现在,我们将添加一个名为link的数据属性。默认情况下,它应该设置为false,以便我们可以隐藏链接,并在 API 返回值后填充它的下载链接。

created()函数添加到文件组件中,并在其中添加 API 调用:

     created() {
      this.d.filesGetTemporaryLink({path:    
       this.f.path_lower}).then(data => {
        this.link = data.link;
     });
    }

此 API 方法接受一个对象,类似于filesListFolder函数。我们正在传递当前文件的路径。一旦数据返回,我们可以将组件的link属性设置为下载链接。

现在我们可以在组件的模板中添加一个下载链接。只有在检索到下载链接后才添加v-if以显示<a>

   template: '<li><strong>{{ f.name }}</strong><span v-  
    if="f.size"> - {{ bytesToSize(f.size) }}</span><span    
    v-if="link"> - <a :href="link">Download</a></span>  
   </li>'

浏览文件时,我们现在可以看到每个文件旁边出现一个下载链接,其速度取决于您的互联网连接和 API 速度。

现在添加了下载链接的完整文件组件如下所示:

    Vue.component('file', {
     template: '<li><strong>{{ f.name }}</strong><span v-   
     if="f.size"> - {{ bytesToSize(f.size) }}</span><span 
     v-if="link"> - <a :href="link">Download</a></span>
     </li>',
    props: {
      f: Object,
      d: Object
      },

   data() {
     return {
       byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
      link: false
      }
   },

    methods: {
     bytesToSize(bytes) {
      // Set a default
      let output = '0 Byte';

      // If the bytes are bigger than 0
      if (bytes > 0) {
        // Divide by 1024 and make an int
        let i = parseInt(Math.floor(Math.log(bytes) / 
          Math.log(1024)));
        // Round to 2 decimal places and select the 
         appropriate unit from the array
        output = Math.round(bytes / Math.pow(1024, i), 2) 
         + ' ' + this.byteSizes[i];
      }

      return output
      }
     },

     created() {
    this.d.filesGetTemporaryLink({path:    
     this.f.path_lower}).then(data => {
      this.link = data.link;
      });
    },
  });

更新 URL 哈希并使用它来浏览文件夹:

通过结构列表和面包屑,我们现在可以完全浏览我们的 Dropbox Web 应用程序,并且可以添加和更新浏览器 URL 以便快速访问和共享文件夹。我们可以通过两种方式实现:我们可以更新哈希,例如www.domain.com/#/images/holiday/summer,或者我们可以将所有路径重定向到单个页面,并处理 URL 中的路由而不使用哈希。

对于这个应用程序,我们将在 URL 中使用#方法。当我们介绍vue-router时,我们将在本书的第三部分介绍 URL 路由技术。

在让应用程序显示 URL 对应的文件夹之前,我们首先需要在导航到新文件夹时更新 URL。我们可以使用原生的window.location.hash JavaScript 对象来实现这一点。我们希望在用户点击链接时立即更新 URL,而不是等待数据加载后再更新。

由于每当我们更新结构时都会触发getFolderStructure方法,因此将代码添加到此函数的顶部。这意味着 URL 会被更新,然后调用 Dropbox API 来更新结构:

    getFolderStructure(path) {
      window.location.hash = path;

      this.dropbox().filesListFolder({
       path: path, 
        include_media_info: true
      })
     .then(response => {

       const structure = {
        folders: [],
        files: []
       }

      for (let entry of response.entries) {
        // Check ".tag" prop for type
         if(entry['.tag'] == 'folder') {
          structure.folders.push(entry);
         } else {
          structure.files.push(entry);
         }
       }

      this.path = path;
      this.structure = structure;
      this.isLoading = false;
   })
     .catch(error => {
      console.log(error);
   });
 }

当您浏览应用程序时,应该更新 URL 以包括当前文件夹路径。

然而,当你按下刷新按钮时,你会发现一个问题,那就是 URL 会重置,只有一个哈希,后面没有文件夹,因为它是通过created()函数中传递的空路径重置的。

我们可以通过在created函数中将当前哈希传递给getFolderStructure来解决这个问题,但是如果这样做,我们需要进行一些检查和错误捕获。

首先,在调用window.location.hash时,你也会得到哈希作为字符串的一部分返回,所以我们需要将其删除。其次,我们需要处理 URL 不正确的情况,如果用户输入了不正确的路径或者文件夹被移动了。最后,我们需要让用户在浏览器中使用后退和前进按钮(或键盘快捷键)。

根据 URL 显示文件夹

当我们的应用程序挂载时,它已经调用了一个函数来请求基本文件夹的结构。我们编写了这个函数以允许路径被传递,并且在created()函数中,我们已经将值固定为根文件夹''。这使我们能够灵活地调整这个函数,以传递 URL 中的哈希值,而不是固定字符串。

更新函数以接受 URL 的哈希值,并且如果没有哈希值,则使用原始的固定字符串:

  created() {
    let hash = window.location.hash.substring(1);
    this.getFolderStructure(hash || '');
  }

创建一个名为hash的新变量,并将window.location.hash赋值给它。因为变量以#开头,对于我们的应用程序来说是不需要的,所以使用substring函数从字符串中删除第一个字符。然后我们可以使用逻辑运算符来使用 hash 变量,或者如果它等于空,使用原始的固定字符串。

现在你应该能够通过更新 URL 来浏览你的应用程序。如果你随时按下刷新按钮或将 URL 复制粘贴到不同的浏览器窗口中,你所在的文件夹应该会加载。

显示错误消息

由于我们的应用程序接受 URL,我们需要处理一种情况,即用户输入 URL 时出现错误,或者共享的文件夹已经被移动。

由于这个错误是一个边缘情况,如果加载数据时出现错误,我们将劫持isLoading参数。在getFolderStructure函数中,我们返回一个作为 promise 的catch函数,如果 API 调用出错,就会触发这个函数。在这个函数中,将isLoading变量设置为'error'

   getFolderStructure(path) {
     window.location.hash = path;

     this.dropbox().filesListFolder({
      path: path, 
      include_media_info: true
    })
    .then(response => {

      const structure = {
        folders: [],
        files: []
      }

      for (let entry of response.entries) {
        // Check ".tag" prop for type
        if(entry['.tag'] == 'folder') {
         structure.folders.push(entry);
       } else {
         structure.files.push(entry);
       }
     }

     this.path = path;
     this.structure = structure;
     this.isLoading = false;
   })
    .catch(error => {
      this.isLoading = 'error';
      console.log(error);
    });
  }

如果需要诊断除了错误文件路径之外的问题,可以保留console.log。尽管 API 可能会抛出多种不同的错误,但在这个应用程序中,我们假设错误是由于错误的路径。如果您想在应用程序中处理其他错误,可以通过其status_code属性来识别错误类型。有关此更多详细信息,请参阅 Dropbox API 文档。

更新视图以处理这个新的isLoading变量属性。当设置为错误时,isLoading变量仍然为"true",因此在加载元素中,添加一个新的v-if来检查加载变量是否设置为error

   <transition name="fade">
    <div v-if="isLoading">
      <div v-if="isLoading === 'error'">
 <p>There seems to be an issue with the URL entered.  
       </p>
 <p><a href="">Go home</a></p>
 </div>
 <div v-else>
 Loading...
 </div>
    </div>
  </transition>

如果isLoading变量的第一个元素设置为error,则显示该元素;否则,显示加载文本。在错误文本中,包含一个链接,将用户发送回当前 URL,不带任何 URL 哈希。这将将他们“重置”回文档树的顶部,以便他们可以向下导航。一个改进可以是将当前 URL 拆分,并建议删除最后一个文件夹后相同的 URL。

验证错误代码是否加载,方法是在 URL 的末尾添加一个不存在的路径,并确保显示错误消息。请注意,用户可能会在某种意义上对此错误消息产生误报,即如果 Dropbox API 抛出任何类型的错误,将显示此消息。

在浏览器中使用返回和前进按钮

为了在浏览器中使用返回和前进按钮,我们需要大幅更新我们的代码。目前,当用户从结构或面包屑中的任何一个文件夹中单击时,我们通过在click处理程序上使用.prevent来阻止浏览器的默认行为。然后,在处理文件夹之前,我们立即更新 URL。

然而,如果我们允许应用程序使用本机行为更新 URL,我们可以监听哈希 URL 的更新,并使用它来检索我们的新结构。使用这种方法,返回和前进按钮将无需任何进一步的干预即可工作,因为它们将更新 URL 哈希。

这也将提高我们应用程序的可读性,并减少代码量,因为我们将能够删除链接上的navigate方法和click处理程序。

删除不需要的代码

在添加更多代码之前,第一步是从组件中删除不必要的代码。从面包屑开始,从组件中删除navigate方法,并从模板中的链接中删除@click.prevent属性。

我们还需要更新每个项目的slug,在其前面添加# - 这样可以确保应用程序在点击时不会尝试导航到全新的页面。由于我们在文件夹的computed函数中循环遍历面包屑项,所以在将对象推送到output数组时,为每个slug添加一个哈希:

 Vue.component('breadcrumb', {
   template: '<div>' +
     '<span v-for="(f, i) in folders">' +
       '<a :href="f.path">{{ f.name || 'Home' }}</a>' +
       '<i v-if="i !== (folders.length - 1)"> &raquo;   
       </i>' + '</span>' + 
       '</div>',
    props: {
      p: String
     },
    computed: {
      folders() {
        let output = [],
          slug = '',
          parts = this.p.split('/');

         for (let item of parts) {
          slug += item;
            output.push({'name': item || 'home', 'path': '#' + slug});
            slug += '/';
         }

         return output;
       }
     }
   });

我们还可以在dropbox-viewer-template中的面包屑组件上删除v-on声明。它只应该有传递的路径作为属性:

    <breadcrumb :p="path"></breadcrumb>

现在我们可以为文件夹组件重复相同的模式。从链接中删除@click.prevent声明并删除navigate方法。

由于我们在显示之前不会循环遍历或编辑文件夹对象,所以我们可以在模板中添加#。由于我们告诉 Vue href 绑定到一个 JavaScript 对象(使用冒号),我们需要用引号将哈希封装起来,并使用 JavaScript 的+符号将其与文件夹路径连接起来。

我们已经在单引号和双引号之间,所以我们需要告诉 JavaScript 我们确实是一个单引号,这可以通过在单引号字符前面使用反斜杠来实现:

   Vue.component('folder', {
    template: '<li><strong><a :href="\'#\' +   
    f.path_lower">{{ f.name }}</a></strong></li>',
     props: {
      f: Object
     }
   });

我们还可以从视图中的<folder>组件中删除@path属性。

   <template v-for="entry in structure.folders">
     <folder :f="entry"></folder>
   </template>

现在我们的代码看起来更加清晰、简洁,并且文件大小更小。在浏览器中查看应用程序将呈现所在文件夹的结构;但是,点击链接将更新 URL 但不会更改显示内容。

通过更改 URL 并在实例外设置 Vue 数据来更新结构

现在我们的 URL 已经正确更新,我们可以在哈希更改时获取新的结构。这可以使用 JavaScript 的onhashchange函数来实现。

我们将创建一个函数,每当 URL 的哈希更新时触发,这将更新父 Vue 实例上的路径变量。这个变量将作为属性传递给子dropbox-viewer组件。该组件将监视变量的变化,并在更新时检索新的结构。

首先,更新父 Vue 实例,使其具有一个带有路径键的数据对象 - 设置为空字符串属性。我们还将将 Vue 实例分配给一个名为app的常量变量 - 这允许我们在实例外设置数据和调用方法:

 const app = new Vue({
    el: '#app',
 data: {
 path: ''
 }
 });

下一步是在每次 URL 更新时更新这个数据属性。这是通过使用window.onhashchange来实现的,它是一个原生的 JavaScript 函数,每当 URL 中的哈希值发生变化时就会触发。

从 Dropbox 组件的created函数中复制并粘贴哈希修改器,并使用它来修改哈希并将值存储在 Vue 实例上。如果哈希不存在,我们将传递一个空字符串给路径变量:

   window.onhashchange = () => {
    let hash = window.location.hash.substring(1);
    app.path = (hash || '');
   }

现在,我们需要将这个路径变量传递给 Dropbox 组件。在视图中添加一个名为p的 prop,将path变量作为值:

   <div id="app">
    <dropbox-viewer :p="path"></dropbox-viewer>
   </div>

在 Dropbox 组件中添加props对象,以接受一个字符串:

   props: {
     p: String
    },

现在,我们将在dropbox-viewer组件中添加一个watch函数。这个函数将监视p prop,并在更新时使用修改后的路径调用updateStructure()方法:

   watch: {
     p() {
      this.updateStructure(this.p);
     }
   }

回到浏览器,我们现在应该能够像以前一样通过 Dropbox 结构进行导航,使用文件夹链接和面包屑作为导航。我们现在应该能够使用浏览器的后退和前进按钮,以及任何键盘快捷键,来通过文件夹进行导航。

在我们进入第六章之前,使用vuex为我们的应用程序引入文件夹缓存,使用 Vuex 缓存当前文件夹结构,我们可以对 Dropbox 组件进行一些优化。

首先,在getFolderStructure函数中,我们可以删除设置 URL 哈希为路径的第一行代码。这是因为当链接被使用时,URL 已经被更新了。从你的代码中删除这一行:

   window.location.hash = path;

其次,在 Dropbox 组件中,现在存在this.path变量和p prop 的重复。消除这个重复需要进行一些微小的改动,因为你不能像处理路径那样直接修改 prop;然而,它需要保持同步,以便正确渲染面包屑。

从 Dropbox 组件的数据对象中删除path属性,并且还要从getFolderStructure函数中删除this.path = path这一行。

接下来,将prop更新为等于path,而不是p。这还需要更新watch函数,以监视path变量而不是p()

created方法更新为只使用this.path作为函数的参数。现在,Dropbox 组件应该如下所示:

   Vue.component('dropbox-viewer', {
     template: '#dropbox-viewer-template',

     props: {
      path: String
     },

     data() {
       return {
         accessToken: 'XXXX',
        structure: {},
         isLoading: true
       }
      },

     methods: {
       dropbox() {
         return new Dropbox({
            accessToken: this.accessToken
         });
       },

       getFolderStructure(path) { 
         this.dropbox().filesListFolder({
           path: path, 
          include_media_info: true
         })
          .then(response => {

           const structure = {
            folders: [],
            files: []
           }

          for (let entry of response.entries) {
            // Check ".tag" prop for type
            if(entry['.tag'] == 'folder') {
             structure.folders.push(entry);
             } else {
           }
          }

         this.structure = structure;
         this.isLoading = false;
       })
        .catch(error => {
         this.isLoading = 'error';
         console.log(error);
        });
      },

       updateStructure(path) {
        this.isLoading = true;
        this.getFolderStructure(path);
       }
    },

     created() {
       this.getFolderStructure(this.path);
     },

      watch: {
      path() {
        this.updateStructure(this.path);
      }
     },
   });

更新视图以接受prop作为path

   <dropbox-viewer :path="path"></dropbox-viewer>

现在,我们需要确保父级Vue实例在页面加载和哈希值变化时具有正确的路径。为了避免重复,我们将扩展我们的Vue实例,添加一个方法和一个created函数。

将路径变量设置为空字符串。创建一个名为updateHash()的新方法,它会从窗口哈希中删除第一个字符,然后将path变量设置为哈希值或空字符串。接下来,创建一个名为created()的函数,它会运行updateHash方法。

Vue实例现在看起来是这样的:

  const app = new Vue({
    el: '#app',

    data: {
      path: ''
    }, 
    methods: {
 updateHash() {
 let hash = window.location.hash.substring(1);
 this.path = (hash || '');
 }
 },
 created() {
 this.updateHash()
 }
  });

最后,为了避免重复,当地址栏中的哈希值发生变化时,我们可以触发updateHash方法:

   window.onhashchange = () => {
     app.updateHash();
   }

最终代码

我们的代码现在已经完成,你的视图和 JavaScript 文件应该如下所示。首先,视图应该是这样的:

   <div id="app">
      <dropbox-viewer :path="path"></dropbox-viewer>
    </div>

   <script type="text/x-template" id="dropbox-viewer- 
     template">
    <div>
      <h1>Dropbox</h1>

      <transition name="fade">
        <div v-if="isLoading">
          <div v-if="isLoading == 'error'">
            <p>There seems to be an issue with the URL 
            entered.</p>
            <p><a href="">Go home</a></p>
          </div>
          <div v-else>
            Loading...
          </div>
        </div>
      </transition>

      <transition name="fade">
        <div v-if="!isLoading">
          <breadcrumb :p="path"></breadcrumb>
          <ul>
            <template v-for="entry in structure.folders">
             <folder :f="entry"></folder>
            </template>

           <template v-for="entry in structure.files">
             <file :d="dropbox()" :f="entry"></file>
           </template>
         </ul>
       </div>
      </transition>

     </div>
    </script>

附带的 JavaScript 应用应该是这样的:

   Vue.component('breadcrumb', {
        template: '<div>' +
        '<span v-for="(f, i) in folders">' +
         '<a :href="f.path">{{ f.name || 'Home' }}</a>' +
          '<i v-if="i !== (folders.length - 1)"> &raquo; 
           </i>' + '</span>' + 
        '</div>',
      props: {
      p: String
     },
     computed: {
        folders() {
          let output = [],
           slug = '',
           parts = this.p.split('/');

        for (let item of parts) {
          slug += item;
            output.push({'name': item || 'home', 'path': 
            '#' + slug});
          slug += '/';
         }

         return output;
        }
      }
    });

    Vue.component('folder', {
       template: '<li><strong><a :href="\'#\' + 
       f.path_lower">{{ f.name }}</a></strong></li>',
      props: {
       f: Object
      }
   });

   Vue.component('file', {
         template: '<li><strong>{{ f.name }}</strong><span 
         v-if="f.size"> - {{ bytesToSize(f.size) }}</span>
         <span v-if="link"> - <a :href="link">Download</a>
         </span></li>',
        props: {
        f: Object,
         d: Object
       },

     data() {
      return {
        byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
        link: false
       }
      },

    methods: {
       bytesToSize(bytes) {
        // Set a default
        let output = '0 Byte';

        // If the bytes are bigger than 0
         if (bytes > 0) {
          // Divide by 1024 and make an int
          let i = parseInt(Math.floor(Math.log(bytes) / 
           Math.log(1024)));
        // Round to 2 decimal places and select the 
           appropriate unit from the array
         output = Math.round(bytes / Math.pow(1024, i), 2)   
         + ' ' + this.byteSizes[i];
       }

       return output
      }
    },

     created() {
       this.d.filesGetTemporaryLink({path:   
       this.f.path_lower}).then(data => {
         this.link = data.link;
       });
      },
    });

     Vue.component('dropbox-viewer', {
       template: '#dropbox-viewer-template',

     props: {
       path: String
      },

     data() {
       return {
       accessToken: 'XXXX',
       structure: {},
       isLoading: true
     }
    },

     methods: {
      dropbox() {
        return new Dropbox({
          accessToken: this.accessToken
        });
      },

     getFolderStructure(path) { 
      this.dropbox().filesListFolder({
        path: path, 
        include_media_info: true
      })
      .then(response => {

        const structure = {
          folders: [],
          files: []
        }

        for (let entry of response.entries) {
          // Check ".tag" prop for type
          if(entry['.tag'] == 'folder') {
            structure.folders.push(entry);
          } else {
            structure.files.push(entry);
          }
        }

          this.structure = structure;
          this.isLoading = false;
        })
        .catch(error => {
         this.isLoading = 'error';
         console.log(error);
        });
     },

     updateStructure(path) {
       this.isLoading = true;
       this.getFolderStructure(path);
      }
    },

    created() {
     this.getFolderStructure(this.path);
    },

   watch: {
     path() {
       this.updateStructure(this.path);
       }
     },
  });

     const app = new Vue({
      el: '#app',

       data: {
       path: ''
      }, 
    methods: {
     updateHash() {
        let hash = window.location.hash.substring(1);
        this.path = (hash || '');
      }
    },
     created() {
      this.updateHash()
     }
  });

   window.onhashchange = () => {
   app.updateHash();
 }

总结

我们现在有一个完全功能的 Dropbox 查看器应用程序,具有文件夹导航和文件下载链接。我们可以使用文件夹链接或面包屑进行导航,并使用后退和/或前进按钮。我们还可以共享或书签一个链接,并加载该文件夹的内容。

在第六章《使用 Vuex 缓存当前文件夹结构》中,我们将通过使用 Vuex 缓存当前文件夹的内容来加快导航过程。

第六章:使用 Vuex 缓存当前文件夹结构

在本章中,我们将介绍一个名为 Vuex 的官方 Vue 插件。Vuex 是一种状态管理模式和库,允许您为所有 Vue 组件创建一个集中的存储,无论它们是子组件还是 Vue 实例。它为我们提供了一种集中、简单的方式来保持整个应用程序中的数据同步。

本章将涵盖以下内容:

  • 开始使用 Vuex

  • 从 Vuex 存储中存储和检索数据

  • 将 Vuex 与我们的 Dropbox 应用程序集成

  • 缓存当前 Dropbox 文件夹内容,并在需要时从存储中加载数据

不再需要在每个组件上使用自定义事件和$emit函数,并尝试保持组件和子组件的更新,您的 Vue 应用程序的每个部分都可以更新中央存储,并且其他部分可以根据该信息来更新其数据和状态。它还为我们提供了一个共同的存储数据的地方,因此,我们不再需要决定将数据对象放在组件、父组件还是 Vue 实例上更具语义性,我们可以使用 Vuex 存储。

Vuex 还集成到 Vue 开发工具中,这将在第十二章“使用 Vue 开发工具和测试您的 SPA”中介绍。通过集成该库,可以轻松调试和查看存储的当前状态和过去状态。开发工具反映状态变化或数据更新,并允许您检查存储的每个部分。

如前所述,Vuex 是一种状态管理模式,是 Vue 应用程序的真相来源。例如,跟踪购物篮或已登录用户对于某些应用程序至关重要,如果这些数据在组件之间不同步,可能会造成严重问题。而且,如果没有使用父组件来处理数据交换,就无法在子组件之间传递数据。Vuex 通过处理数据的存储、变化和操作来解决这个问题。

当初次使用 Vuex 时,它可能看起来相当冗长,似乎超出了所需的范围;然而,这是一个很好的例子,可以帮助我们熟悉该库。有关 Vuex 的更多信息可以在其文档中找到。

对于我们的 Dropbox 应用程序,Vuex store 可以用来存储文件夹结构、文件列表和下载链接。这意味着如果用户多次访问同一个文件夹,API 将不需要再次查询,因为所有的信息已经存储好了。这将加快文件夹的导航速度。

包含和初始化 Vuex

Vuex 库的包含方式与 Vue 本身相同。您可以使用之前提到的 unpkg 服务(unpkg.com/vuex)使用托管版本,或者您可以从他们的github.com/vuejs/vuex下载 JavaScript 库。

在 HTML 文件的底部添加一个新的<script>块。确保在应用程序 JavaScript 之前,Vuex 库被包含在vue.js库之后:

<script type="text/javascript" src="js/vue.js"></script>
<script type="text/javascript" src="js/vuex.js"></script>
<script type="text/javascript" src="js/dropbox.js"></script>
<script type="text/javascript" src="js/app.js"></script>

如果您正在部署一个包含多个 JavaScript 文件的应用程序,值得研究一下是将它们合并压缩成一个文件更高效,还是配置服务器使用 HTTP/2 推送。

在包含了库之后,我们可以初始化并在我们的应用程序中包含 store。创建一个名为store的新变量,并初始化Vuex.Store类,并将其赋值给该变量:

const store = new Vuex.Store({

});

初始化了 Vuex store 之后,我们现在可以使用store变量来利用其功能。使用store,我们可以访问其中的数据,并通过 mutations 来修改这些数据。通过一个独立的store,许多 Vue 实例可以更新同一个store;这在某些情况下可能是需要的,但在其他情况下可能是一个不希望的副作用。

为了绕过这个问题,我们可以将一个 store 与特定的 Vue 实例关联起来。通过将store变量传递给我们的 Vue 类来实现这一点。这样做也会将store实例注入到所有的子组件中。虽然对于我们的应用程序来说并不是必需的,但将 store 与应用程序关联起来是一个好的实践习惯:

const app = new Vue({
  el: '#app',

  store,
  data: {
    path: ''
  }, 
  methods: {
    updateHash() {
      let hash = window.location.hash.substring(1);
      this.path = (hash || '');
    }
  },
  created() {
    this.updateHash()
  }
});

添加了store变量后,我们现在可以使用this.$store变量在我们的组件中访问store了。

利用 store

为了帮助我们掌握如何使用 store,让我们将当前存储在父 Vue 实例上的path变量移动起来。在我们开始编写和移动代码之前,有一些在使用 Vuex store 时不同的短语和词汇,我们应该熟悉一下:

  • state:这是 store 中数据对象的等效部分;原始数据存储在这个对象中。

  • getters:这些是 Vuex 中与计算值相当的函数;store的函数可以在返回组件中使用之前处理原始状态值。

  • mutations:Vuex 不允许直接在store之外修改状态对象,必须通过变异处理程序来完成;这些是store上的函数,然后允许更新状态。它们始终将state作为第一个参数。

这些对象直接属于store。但是,更新store并不像调用store.mutationName()那样简单。相反,我们必须使用一个新的commit()函数来调用该方法。此函数接受两个参数:变异的名称和传递给它的数据。

虽然最初很难理解,但 Vuex 存储的冗长性允许强大的功能。以下是一个使用存储的示例,将第一章“开始使用 Vue.js”的原始示例进行了调整:

const store = new Vuex.Store({
  state: {
    message: 'HelLO Vue!'
  },

  getters: {
    message: state => {
      return state.message.toLowerCase();
    }
  },

  mutations: {
    updateMessage(state, msg) {
      state.message = msg;
    }
  }
});

前面的store示例包括state对象,这是我们的原始数据存储;getters对象,其中包括我们对状态的处理;最后,mutations对象,允许我们更新消息。请注意,message getter 和updateMessage变异都将存储的状态作为第一个参数。

要使用此store,您可以执行以下操作:

new Vue({
  el: '#app',

  store,
  computed: {
    message() {
      return this.$store.state.message
    },
    formatted() {
      return this.$store.getters.message
    }
  }
});

检索消息

{{ message }}计算函数中,我们从状态对象中检索了原始的、未经处理的消息,并使用了以下路径:

this.$store.state.message

这实际上是访问store,然后是状态对象,然后是消息对象键。

类似地,{{ formatted }}计算值使用store中的 getter,将字符串转换为小写。这是通过访问getters对象来检索的:

this.$store.getters.message

更新消息

要更新消息,您需要调用commit函数。这将接受方法名称作为第一个参数,负载或数据作为第二个参数。负载可以是简单变量、数组或对象,如果需要传递多个变量,则可以使用对象。

store中的updateMessage变异接受一个参数,并将消息设置为相等,因此要更新我们的消息,代码应该是:

store.commit('updateMessage', 'VUEX Store');

这可以在应用程序的任何地方运行,并且将自动更新之前使用的值,因为它们都依赖于相同的store

现在返回我们的消息 getter 将返回 VUEX Store,因为我们已经更新了状态。考虑到这一点,让我们更新我们的应用程序,使用store中的路径变量,而不是 Vue 实例。

使用 Vuex store 来获取文件夹路径

在使用 Vue store 作为全局 Dropbox 路径变量的第一步是将数据对象从 Vue 实例移动到Store,并将其重命名为state

const store = new Vuex.Store({
  state: {
 path: ''
 }
});

我们还需要创建一个 mutation,以允许从 URL 的哈希值更新路径。在 store 中添加一个mutations对象,并将updateHash函数从 Vue 实例中移动-不要忘记更新函数以接受 store 作为第一个参数。还要更改方法,使其更新state.path而不是this.path

const store = new Vuex.Store({
  state: {
    path: ''
  },
  mutations: {
 updateHash(state) {
 let hash = window.location.hash.substring(1);
 state.path = (hash || '');
 }
 }
});

通过将路径变量和 mutation 移动到 store 中,可以使 Vue 实例变得更小,同时删除methodsdata对象:

const app = new Vue({
  el: '#app',

  store,
  created() {
    this.updateHash()
  }
});

我们现在需要更新我们的应用程序,使用store中的路径变量,而不是在 Vue 实例上。我们还需要确保调用storemutation函数来更新路径变量,而不是在 Vue 实例上的方法。

更新路径方法以使用 store commits

从 Vue 实例开始,将this.Updatehash更改为store.commit('updateHash')。不要忘记还要更新onhashchange函数中的方法。第二个函数应该引用我们 Vue 实例上的store对象,而不是直接引用store。这可以通过访问 Vue 实例变量app,然后在此实例中引用 Vuex store 来完成。

在 Vue 实例上引用 Vuex store 时,它保存在变量$store下,无论最初对该变量进行了什么样的声明:

const app = new Vue({
  el: '#app',

  store,
  created() {
    store.commit('updateHash');
  }
});

window.onhashchange = () => {
  app.$store.commit('updateHash');
}

使用路径变量

我们现在需要更新组件,使用store中的路径,而不是通过组件传递的路径。breadcrumbdropbox-viewer都需要更新以接受这个新变量。我们还可以从组件中删除不必要的 props。

更新面包屑组件

从 HTML 中删除:p prop,只留下一个简单的面包屑 HTML 标签:

<breadcrumb></breadcrumb>

接下来,从 JavaScript 文件中删除组件的props对象。parts变量也需要更新为使用this.$store.state.path,而不是this.p

Vue.component('breadcrumb', {
  template: '<div>' +
    '<span v-for="(f, i) in folders">' +
      '<a :href="f.path">[F] {{ f.name }}</a>' +
      '<i v-if="i !== (folders.length - 1)"> &raquo; </i>' +
    '</span>' + 
  '</div>',

  computed: {
    folders() {
      let output = [],
        slug = '',
        parts = this.$store.state.path.split('/');

      for (let item of parts) {
        slug += item;
        output.push({'name': item || 'home', 'path': '#' + slug});
        slug += '/';
      }

      return output;
    }
  }
});

更新 dropbox-viewer 组件以与 Vuex 一起使用

breadcrumb组件一样,第一步是从视图中删除 HTML 属性。这将进一步简化您的应用程序视图,您将只剩下一些 HTML 标签:

<div id="app">
  <dropbox-viewer></dropbox-viewer>
</div>

下一步是清理 JavaScript 代码,删除任何不必要的函数参数。从dropbox-viewer组件中删除props对象。接下来,更新getFolderStructure中的filesListFolder Dropbox 方法,以使用存储路径,而不是使用路径变量:

this.dropbox().filesListFolder({
  path: this.$store.state.path, 
  include_media_info: true
})

由于该方法现在使用store而不是函数参数,我们可以从方法声明本身中删除变量,以及从updateStructure方法和调用这两个函数的任何地方删除它。例如:

updateStructure(path) {
  this.isLoading = true;
  this.getFolderStructure(path);
}

这将变成以下内容:

updateStructure() {
  this.isLoading = true;
  this.getFolderStructure();
}

然而,我们仍然需要将路径存储为此组件上的变量。这是由于我们的watch方法调用了updateStructure函数。为此,我们需要将路径存储为计算值,而不是固定变量。这样,当store更新时,它可以动态更新,而不是在组件初始化时固定值。

dropbox-viewer组件上创建一个计算对象,其中包含一个名为path的方法,该方法只返回store的路径:

computed: {
  path() {
    return this.$store.state.path
  }
}

现在我们将其作为局部变量,因此 Dropbox 的filesListFolder方法可以更新为再次使用this.path

新更新的dropbox-viewer组件应如下所示。在浏览器中查看应用程序时,它应该看起来好像没有任何变化-然而,应用程序的内部工作现在依赖于新的 Vuex 存储,而不是存储在 Vue 实例上的变量。

Vue.component('dropbox-viewer', {
  template: '#dropbox-viewer-template',

  data() {
    return {
      accessToken: 'XXXX',
      structure: {},
      isLoading: true
    }
  },

  computed: {
 path() {
 return this.$store.state.path
 }
 },

  methods: {
    dropbox() {
      return new Dropbox({
        accessToken: this.accessToken
      });
    },

    getFolderStructure() { 
      this.dropbox().filesListFolder({
        path: this.path, 
        include_media_info: true
      })
      .then(response => {

        const structure = {
          folders: [],
          files: []
        }

        for (let entry of response.entries) {
          // Check ".tag" prop for type
          if(entry['.tag'] == 'folder') {
            structure.folders.push(entry);
          } else {
            structure.files.push(entry);
          }
        }

        this.structure = structure;
        this.isLoading = false;
      })
      .catch(error => {
        this.isLoading = 'error';
        console.log(error);
      });
    },

    updateStructure() {
      this.isLoading = true;
      this.getFolderStructure();
    }
  },

  created() {
    this.getFolderStructure();
  },

  watch: {
    path() {
      this.updateStructure();
    }
  },
});

缓存文件夹内容

现在我们在应用程序中使用 Vuex 并利用它来获取路径,我们可以开始考虑存储当前显示文件夹的内容,以便如果用户返回到相同的位置,API 不需要查询以检索结果。我们将通过将 API 返回的对象存储在 Vuex 存储中来实现这一点。

当请求文件夹时,应用程序将检查存储中是否存在数据。如果存在,则省略 API 调用,并从存储中加载数据。如果不存在,则查询 API 并将结果保存在 Vuex 存储中。

第一步是将数据处理分离到自己的方法中。这是因为文件和文件夹需要被拆分,无论数据是来自存储还是 API。

dropbox-viewer组件中创建一个名为createFolderStructure()的新方法,并将代码从 Dropbox 的filesListFolder方法的then()函数内移动到其中。在这个函数内调用新方法。

现在你的两个方法应该如下所示,并且你的应用程序应该像之前一样工作:

createFolderStructure(response) {
  const structure = {
    folders: [],
    files: []
  }

  for (let entry of response.entries) {
    // Check ".tag" prop for type
    if(entry['.tag'] == 'folder') {
      structure.folders.push(entry);
    } else {
      structure.files.push(entry);
    }
  }

  this.structure = structure;
  this.isLoading = false;
},

getFolderStructure() { 
  this.dropbox().filesListFolder({
    path: this.path, 
    include_media_info: true
  })
  .then(this.createFolderStructure)
  .catch(error => {
    this.isLoading = 'error';
    console.log(error);
  });
}

使用 promises,我们可以将createFolderStructure作为 API 调用的操作。

下一步是存储我们正在处理的数据。为此,我们将利用将对象传递给storecommit函数的能力,并使用路径作为存储对象中的键。我们将不再嵌套文件结构,而是将信息存储在一个扁平的结构中。例如,在我们浏览了几个文件夹之后,我们的存储将如下所示:

structure: {
  'images': [{...}],
  'images-holiday': [{...}],
  'images-holiday-summer': [{...}]
}

路径将进行几个转换,以使其适合作为对象键。它将被转换为小写,并删除任何标点符号。我们还将用连字符替换所有空格和斜杠。

首先,在 Vuex 存储状态对象中创建一个名为structure的空对象;这是我们将存储数据的地方:

state: {
  path: '',
  structure: {}
}

现在,我们需要创建一个新的mutation,以便在加载数据时存储它。在mutations对象中创建一个名为structure的新函数。它需要接受state作为参数,以及一个作为对象传递的payload变量:

structure(state, payload) {
}

路径对象将包括一个path变量和从 API 返回的data。例如:

{
  path: 'images-holiday',
  data: [{...}]
}

通过传入这个对象,我们可以使用路径作为键,数据作为值。将数据存储在突变中的路径键内:

structure(state, payload) {
  state.structure[payload.path] = payload.data;
}

我们现在可以在组件的createFolderStructure方法的末尾提交这些数据:

createFolderStructure(response) {
  const structure = {
    folders: [],
    files: []
  }

  for (let entry of response.entries) {
    // Check ".tag" prop for type
    if(entry['.tag'] == 'folder') {
      structure.folders.push(entry);
    } else {
      structure.files.push(entry);
    }
  }

  this.structure = structure;
  this.isLoading = false;

 this.$store.commit('structure', {
 path: this.path,
 data: response
 });
}

现在,当浏览应用程序时,它将存储每个文件夹的数据。可以通过在结构突变中添加console.log(state.structure)来验证这一点。

虽然目前的代码可以工作,但最好在将路径用作对象键时对其进行清理。为此,我们将删除任何标点符号,将任何空格和斜杠替换为连字符,并将路径转换为小写。

dropbox-viewer组件上创建一个名为slug的新计算函数。术语 slug 通常用于清理过的 URL,并源自报纸和编辑如何引用故事的方式。此函数将运行多个 JavaScript replace方法来创建一个安全的对象键:

slug() {
  return this.path.toLowerCase()
    .replace(/^\/|\/$/g, '')
    .replace(/ /g,'-')
    .replace(/\//g,'-')
    .replace(/[-]+/g, '-')
    .replace(/[^\w-]+/g,'');
}

slug 函数执行以下操作。例如路径/images/iPhone/mom's Birthday - 40th将受到以下影响:

  • 将字符串转换为小写:/images/iphone/mom's birthday - 40th

  • 删除路径开头和结尾的任何斜杠:images/iphone/mom birthday - 40th

  • 将任何空格替换为连字符:images/iphone/mom-birthday---40th

  • 将任何斜杠替换为连字符:images-iphone-mom-birthday---40th

  • 将任何多个连字符替换为单个连字符:images-iphone-mom-birthday-40th

  • 最后,删除任何标点符号:images-iphone-moms-birthday-40th

现在创建了 slug,我们可以将其用作存储数据时的键:

this.$store.commit('structure', {
  path: this.slug,
  data: response
});

现在,我们的文件夹内容已经被缓存在 Vuex 存储中,我们可以添加一个检查,看看数据是否存在于存储中,如果存在,则从存储中加载数据。

如果存在,则从存储中加载数据

从存储中加载数据需要对我们的代码进行一些更改。第一步是检查store中是否存在结构,如果存在,则加载它。第二步是仅在数据是新数据时将数据提交到存储中-调用现有的createFolderStructure方法将更新结构,但也会重新提交数据到存储中。尽管当前情况下对用户没有害处,但不必要地将数据写入store可能会在应用程序增长时引发问题。这也将在我们进行预缓存文件夹和文件时对我们有所帮助。

从存储中加载数据

由于store是一个 JavaScript 对象,而我们的slug变量是组件上的一个计算值,我们可以使用if语句来检查对象键是否存在:

if(this.$store.state.structure[this.slug]) {
  // The data exists
}

这使我们可以根据需要从store加载数据,使用createFolderStructure方法,如果不存在,则触发 Dropbox API 调用。

更新getFolderStructure方法以包含if语句,并在数据存在时添加方法调用:

getFolderStructure() {
  if(this.$store.state.structure[this.slug]) {
 this.createFolderStructure(this.$store.state.structure[this.slug]);
 } else {
    this.dropbox().filesListFolder({
      path: this.path, 
      include_media_info: true
    })
    .then(this.createFolderStructure)
    .catch(error => {
      this.isLoading = 'error';
      console.log(error);
    });
  }
}

数据的路径非常长,可能会导致我们的代码难以阅读。为了更容易理解,将数据赋值给一个变量,这样我们可以检查它是否存在,并使用更清晰、更简洁、更少重复的代码返回数据。这也意味着如果数据路径发生变化,我们只需要更新一行代码:

getFolderStructure() {
  let data = this.$store.state.structure[this.slug]; 
  if(data) {
    this.createFolderStructure(data);
  } else {
    this.dropbox().filesListFolder({
      path: this.path, 
      include_media_info: true
    })
    .then(this.createFolderStructure)
    .catch(error => {
      this.isLoading = 'error';
      console.log(error);
    });
  }
}

仅存储新数据

如前所述,当前的createFolderStructure方法既显示结构,又将响应缓存到store中,因此即使从缓存中加载数据,也会重新保存结构。

创建一个新的方法,当数据加载完成后,Dropbox API 将调用该方法。将其命名为createStructureAndSave。该方法应该只接受一个参数,即response变量:

createStructureAndSave(response) {

}

现在,我们可以将storecommit函数从createFolderStructure方法中移动到这个新方法中,同时调用现有方法并传递数据:

createStructureAndSave(response) {

  this.createFolderStructure(response)

 this.$store.commit('structure', {
 path: this.slug,
 data: response
 });
}

最后,更新 Dropbox API 函数以调用此方法:

getFolderStructure() {
  let data = this.$store.state.structure[this.slug]; 
  if(data) {
    this.createFolderStructure(data);
  } else {
    this.dropbox().filesListFolder({
      path: this.path, 
      include_media_info: true
    })
    .then(this.createStructureAndSave)
    .catch(error => {
      this.isLoading = 'error';
      console.log(error);
    });
  }

},

在浏览器中打开应用程序并浏览文件夹。当您使用面包屑导航返回上一级时,响应速度应该更快,因为它现在是从您创建的缓存中加载,而不是每次查询 API。

在第七章《预缓存其他文件夹和文件以加快导航速度》中,我们将研究预缓存文件夹,以尝试预测用户接下来的操作。我们还将研究缓存文件的下载链接。

我们的完整应用程序 JavaScript 现在应该如下所示:

Vue.component('breadcrumb', {
  template: '<div>' +
    '<span v-for="(f, i) in folders">' +
      '<a :href="f.path">[F] {{ f.name }}</a>' +
      '<i v-if="i !== (folders.length - 1)"> &raquo; </i>' +
    '</span>' + 
  '</div>',
  computed: {
    folders() {
      let output = [],
        slug = '',
        parts = this.$store.state.path.split('/');

      for (let item of parts) {
        slug += item;
        output.push({'name': item || 'home', 'path': '#' + slug});
        slug += '/';
      }

      return output;
    }
  }
});

Vue.component('folder', {
  template: '<li><strong><a :href="\'#\' + f.path_lower">{{ f.name }}</a></strong></li>',
  props: {
    f: Object
  }
});

Vue.component('file', {
  template: '<li><strong>{{ f.name }}</strong><span v-if="f.size"> - {{ bytesToSize(f.size) }}</span> - <a v-if="link" :href="link">Download</a></li>',
  props: {
    f: Object,
    d: Object
  },

  data() {
    return {
      byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
      link: false
    }
  },

  methods: {
    bytesToSize(bytes) {
      // Set a default
      let output = '0 Byte';

      // If the bytes are bigger than 0
      if (bytes > 0) {
        // Divide by 1024 and make an int
        let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
        // Round to 2 decimal places and select the appropriate unit from the array
        output = Math.round(bytes / Math.pow(1024, i), 2) + ' ' + this.byteSizes[i];
      }

      return output
    }
  },

  created() {
    this.d.filesGetTemporaryLink({path: this.f.path_lower}).then(data => {
      this.link = data.link;
    });
  },
});

Vue.component('dropbox-viewer', {
  template: '#dropbox-viewer-template',

  data() {
    return {
      accessToken: 'XXXX',
      structure: {},
      isLoading: true
    }
  },

  computed: {
    path() {
      return this.$store.state.path
    },
    slug() {
      return this.path.toLowerCase()
        .replace(/^\/|\/$/g, '')
        .replace(/ /g,'-')
        .replace(/\//g,'-')
        .replace(/[-]+/g, '-')
        .replace(/[^\w-]+/g,'');
    }
  },

  methods: {
    dropbox() {
      return new Dropbox({
        accessToken: this.accessToken
      });
    },

    createFolderStructure(response) {

      const structure = {
        folders: [],
        files: []
      }

      for (let entry of response.entries) {
        // Check ".tag" prop for type
        if(entry['.tag'] == 'folder') {
          structure.folders.push(entry);
        } else {
          structure.files.push(entry);
        }
      }

      this.structure = structure;
      this.isLoading = false;

    },

    createStructureAndSave(response) {

      this.createFolderStructure(response)

      this.$store.commit('structure', {
        path: this.slug,
        data: response
      });
    },

    getFolderStructure() {
      let data = this.$store.state.structure[this.slug]; 
      if(data) {
        this.createFolderStructure(data);
      } else {
        this.dropbox().filesListFolder({
          path: this.path, 
          include_media_info: true
        })
        .then(this.createStructureAndSave)
        .catch(error => {
          this.isLoading = 'error';
          console.log(error);
        });
      }

    },

    updateStructure() {
      this.isLoading = true;
      this.getFolderStructure();
    }
  },

  created() {
    this.getFolderStructure();
  },

  watch: {
    path() {
      this.updateStructure();
    }
  },
});

const store = new Vuex.Store({
  state: {
    path: '',
    structure: {}
  },
  mutations: {
    updateHash(state) {
      let hash = window.location.hash.substring(1);
      state.path = (hash || '');
    },
    structure(state, payload) {
      state.structure[payload.path] = payload.data;
    }
  }
});

const app = new Vue({
  el: '#app',

  store,
  created() {
    store.commit('updateHash');
  }
});

window.onhashchange = () => {
  app.$store.commit('updateHash');
}

摘要

在本章之后,您的应用程序现在应该与 Vuex 集成,并缓存 Dropbox 文件夹的内容。Dropbox 文件夹路径也应该利用store来使应用程序更高效。我们只在需要时查询 API。

第七章中,《预缓存其他文件夹和文件以加快导航速度》,我们将研究预缓存文件夹-预先查询 API 以加快应用程序的导航和可用性。

第七章:预缓存其他文件夹和文件以实现更快的导航

在本章中,本节的最后一章,我们将通过引入更多的缓存来进一步加快 Dropbox 文件浏览器的速度。到目前为止,我们已经构建了一个可以查询 Dropbox API 并返回文件和文件夹的应用程序。从那里开始,我们添加了文件夹导航,包括更新用于链接共享的 URL 以及能够使用后退和前进按钮。有了这个功能,我们在第六章《使用 Vuex 缓存当前文件夹结构》中引入了 Vuex 来存储当前文件夹路径和我们访问过的文件夹的内容。

本章将讨论以下内容:

  • 预缓存不仅用户当前所在的文件夹,还包括子文件夹。这将通过循环遍历当前显示的文件夹并检查它们是否已被缓存来完成。如果没有,我们可以从 API 中获取数据。

  • 如果用户通过直接 URL 进入,存储父文件夹的内容。这将通过利用面包屑路径向上遍历来完成。

  • 缓存文件的下载链接。目前,这需要为遇到的每个文件都进行一次 API 调用,无论该文件夹是否已被我们的代码缓存。

通过这些改进,我们可以确保应用程序每个项目只与 API 联系一次,而不是像原来那样无数次。

缓存子文件夹

通过对子文件夹和父文件夹进行缓存,我们不一定需要编写新代码,而是将现有代码重新组织和重新用途化为一个更模块化的系统,以便可以单独调用每个部分。

以下流程图应该能帮助您可视化缓存当前文件夹和子文件夹所需的步骤:

在查看流程图时,您可以立即看到应用程序所需的事件中存在一些重复。在两个点上,应用程序需要决定缓存中是否存在一个文件夹,如果不存在,则查询 API 以获取数据并存储结果。尽管在流程图上只出现两次,但这个功能需要多次,每个当前位置的文件夹都需要一次。

我们还需要将显示逻辑与查询和存储逻辑分开,因为我们可能需要从 API 加载和存储,而不更新视图。

规划应用程序方法

在考虑前一节的内容时,我们可以借此机会修订和重构我们的dropbox-viewer应用程序中的方法,确保每个操作都有自己的方法。这将允许我们在需要时调用每个操作。在进入代码之前,让我们根据前面的流程图规划需要创建的方法。

首先要注意的是,每次查询 API 时,我们都需要将结果存储在缓存中。由于我们不需要在缓存中存储任何东西,除非调用了 API,所以我们可以将这两个操作合并在同一个方法中。我们还经常需要检查特定路径的缓存中是否有内容,并加载它或从 API 中检索它。我们可以将此添加到自己的方法中,并返回数据。

让我们列出我们需要创建的方法:

  • getFolderStructure:此方法将接受一个路径参数,并返回一个包含文件夹条目的对象。它将负责检查数据是否在缓存中,如果不在,则查询 Dropbox API。

  • displayFolderStructure:这个方法将调用前面的函数,并使用数据来更新组件上的structure对象,以在视图中显示文件和文件夹。

  • cacheFolderStructure:这个方法将包含getFolderStructure方法来缓存每个子文件夹,我们将探讨几种触发它的方式。

我们可能需要创建更多的方法,但这三个方法将是组件的主干。我们将保留路径和 slug-computed 属性,以及dropbox()方法。删除其余的对象、方法和函数,使您的dropbox-viewer回到基本状态:

Vue.component('dropbox-viewer', {
  template: '#dropbox-viewer-template',

  data() {
    return {
      accessToken: 'XXXX',
      structure: {},
      isLoading: true
    }
  },

  computed: {
    path() {
      return this.$store.state.path
    },
    slug() {
      return this.path.toLowerCase()
        .replace(/^\/|\/$/g, '')
        .replace(/ /g,'-')
        .replace(/\//g,'-')
        .replace(/[-]+/g, '-')
        .replace(/[^\w-]+/g,'');
    }
  },

  methods: {
    dropbox() {
      return new Dropbox({
        accessToken: this.accessToken
      });
    },
  }
});

创建getFolderStructure方法

在组件上创建一个名为getFolderStructure的新方法。如前所述,此方法需要接受一个路径参数。这样我们就可以同时使用当前路径和子路径:

getFolderStructure(path) {

}

此方法需要检查缓存并返回数据。在方法内部创建一个名为output的新变量,并返回它:

getFolderStructure(path) {
 let output;

 return output;
}

第六章中缓存数据时,我们使用slug作为存储中的键。slug是通过使用当前路径生成的;然而,在新的方法中我们不能使用它,因为它固定在当前位置。

创建一个名为generateSlug的新方法。它将接受一个参数path,并返回使用 slug-computed 函数中的替换后的字符串:

generateSlug(path) {
  return path.toLowerCase()
    .replace(/^\/|\/$/g, '')
    .replace(/ /g,'-')
    .replace(/\//g,'-')
    .replace(/[-]+/g, '-')
    .replace(/[^\w-]+/g,'');
}

现在,我们可以删除计算的slug函数,这样我们就不会有重复的代码了。

回到我们的getFolderStructure方法,创建一个新变量,使用新方法存储路径的 slug 版本。为此,我们将使用const创建一个不可更改的变量:

getFolderStructure(path) {
  let output;

  const slug = this.generateSlug(path);

  return output;
}

我们将创建的最后一个变量是数据路径,就像在第八章“介绍 Vue-Router 和加载基于 URL 的组件”中所做的那样。这将使用我们刚刚创建的新slug变量:

getFolderStructure(path) {
  let output;

  const slug = this.generateSlug(path),
      data = this.$store.state.structure[slug];

  return output;
}

现在,我们可以在这里使用先前代码中的data if语句,并留出空间用于 Dropbox 函数调用。如果data存在于存储中,我们可以立即将其分配给output

getFolderStructure(path) {
  let output;

  const slug = this.generateSlug(path),
      data = this.$store.state.structure[slug];

 if(data) {
 output = data;
 } else {

 }

  return output;
}

然而,通过调整 Dropbox API 调用,我们可以使其适应这段新代码。以前,它是从 API 检索数据,然后触发一个方法来保存和显示结构。由于我们需要将检索到的数据存储在output变量中,我们将改变数据的流动方式。我们将使用这个机会首先将响应存储在缓存中,然后将数据返回给output变量。

由于我们只使用 API 调用的条目,我们还将更新存储以仅缓存响应的这部分。这将减少应用程序的代码和复杂性:

getFolderStructure(path) {
  let output;

  const slug = this.generateSlug(path),
      data = this.$store.state.structure[slug];

  if(data) {
    output = data;
  } else {

    output = this.dropbox().filesListFolder({
 path: path, 
 include_media_info: true
 })
 .then(response => {
 let entries = response.entries;
 this.$store.commit('structure', {
 path: slug,
 data: entries
 });

 return entries;
 })
 .catch(error => {
 this.isLoading = 'error';
 console.log(error);
 });

  }

  return output;
}

Dropbox 的filesListFolder方法使用传入的path变量,而不是之前使用的全局变量。然后将响应中的条目存储在一个变量中,然后使用相同的 mutation 将其缓存在 Vuex 存储中。然后,entries变量从 promise 中返回,该 promise 将结果存储在output中。catch()函数与之前相同。

通过从缓存或 API 返回的数据,我们可以在组件创建和路径更新时触发和处理这些数据。然而,在此之前,我们需要处理各种数据类型的混合。

从 API 返回的数据仍然是一个需要解析的 promise;将其分配给一个变量只是将 promise 传递给以后解析。然而,来自存储的数据是一个处理方式非常不同的普通数组。为了给我们一个单一的数据类型来处理,我们将把存储的数组作为 promise 来resolve,这意味着getFolderStructure无论数据从何处加载,都返回一个 promise:

getFolderStructure(path) {
  let output;

  const slug = this.generateSlug(path),
      data = this.$store.state.structure[slug];

  if(data) {
    output = Promise.resolve(data);
  } else {

    output = this.dropbox().filesListFolder({
      path: path, 
      include_media_info: true
    })
    .then(response => {
      let entries = response.entries;

      this.$store.commit('structure', {
        path: slug,
        data: entries
      });

      return entries;
    })
    .catch(error => {
      this.isLoading = 'error';
      console.log(error);
    });

  }
  return output;
}

通过这个getFolderStructure方法,我们现在可以从 API 中加载一些数据并将结果存储在全局缓存中,而不需要更新视图。然而,该函数确实返回信息,以便我们可以使用 JavaScript promise 进一步处理它。

现在我们可以继续创建我们的下一个方法displayFolderStructure,该方法将使用我们刚刚创建的方法的结果来更新我们的视图,以便应用程序可以再次导航。

使用displayFolderStructure方法显示数据

现在我们的数据已经准备好可以缓存并从存储中提供,我们可以继续使用我们的新方法显示数据。在你的dropbox-viewer组件中创建一个名为displayFolderStructure的新方法:

displayFolderStructure() {

} 

该方法将从此组件的先前版本中借用很多代码。请记住,该方法仅用于显示文件夹,与缓存内容无关。

该方法的过程将是:

  1. 在应用程序中将加载状态设置为active。这让用户知道有事情正在发生。

  2. 创建一个空的structure对象。

  3. 加载getFolderStructure方法的内容。

  4. 循环遍历结果,并将每个项目添加到foldersfiles数组中。

  5. 将全局结构对象设置为新创建的对象。

  6. 将加载状态设置为false,以便可以显示内容。

将加载状态设置为 true,并创建一个空的结构对象

该方法的第一步是隐藏结构树并显示加载消息。这可以像之前一样通过将isLoading变量设置为true来完成。我们还可以在这里创建一个空的structure对象,准备好由数据填充:

displayFolderStructure() {
 this.isLoading = true;

 const structure = {
 folders: [],
 files: []
 }
}

加载getFolderStructure方法的内容

由于getFolderStructure方法返回一个 promise,我们需要在继续操作之前解析结果。这可以通过.then()函数来完成;我们已经在 Dropbox 类中使用过这个函数。调用该方法,然后将结果分配给一个变量:

displayFolderStructure() {
  this.isLoading = true;

  const structure = {
    folders: [],
    files: []
  }

 this.getFolderStructure(this.path).then(data => {

 });
}

这段代码将组件的path对象传递给方法。这个路径是用户正在尝试查看的当前路径。一旦数据返回,我们可以将其赋值给data变量,然后在函数内部使用它。

循环遍历结果,并将每个项添加到文件夹或文件数组中。

我们已经熟悉了循环遍历条目并检查每个条目的.tag属性的代码。如果结果是一个文件夹,它将被添加到structure.folders数组中,否则将被追加到structure.files中。

我们只在缓存中存储条目,因此确保for循环更新为直接使用数据,而不是访问条目的属性:

displayFolderStructure() {
  this.isLoading = true;

  const structure = {
    folders: [],
    files: []
  }

  this.getFolderStructure(this.path).then(data => {

    for (let entry of data) {
 // Check ".tag" prop for type
 if(entry['.tag'] == 'folder') {
 structure.folders.push(entry);
 } else {
 structure.files.push(entry);
 }
 }
  });
}

更新全局结构对象并移除加载状态

这个方法中的最后一个任务是更新全局结构并移除加载状态。这段代码与之前的代码没有变化:

displayFolderStructure() {
  this.isLoading = true;

  const structure = {
    folders: [],
    files: []
  }

  this.getFolderStructure(this.path).then(data => {

    for (let entry of data) {
      // Check ".tag" prop for type
      if(entry['.tag'] == 'folder') {
        structure.folders.push(entry);
      } else {
        structure.files.push(entry);
      }
    }

    this.structure = structure;
 this.isLoading = false;
  });
}

现在我们有了一个显示数据检索结果的方法。

启动该方法

dropbox-viewer组件被创建时,现在可以调用这个方法。由于全局 Vue 实例的created函数将 URL 哈希提交到存储中,从而创建了路径变量,因此路径已经被填充。因此,我们不需要向函数传递任何内容。将created函数添加到您的组件中,并在其中调用新方法:

Vue.component('dropbox-viewer', {
  template: '#dropbox-viewer-template',

  data() {
    return {
      accessToken: 'XXXX',
      structure: {},
      isLoading: true
    }
  },

  computed: {
    ...
  },

  methods: {

    ...
  },

 created() {
 this.displayFolderStructure();
 }
});

现在刷新应用程序将加载您的文件夹内容。更新 URL 哈希并重新加载页面也将显示该文件夹的内容;然而,点击任何文件夹链接将更新面包屑,但不会更新数据结构。可以通过监视计算属性path变量来解决这个问题。当哈希更新时,它将被更新,因此可以触发watch对象中的一个函数。添加一个函数来监视path变量的更新,并在更新时触发新方法:

  created() {
    this.displayFolderStructure();
  },

  watch: {
 path() {
 this.displayFolderStructure();
 }
 }

通过这样做,我们创建了一个应用程序,再次缓存您访问过的任何文件夹。第一次点击结构时,速度可能会很慢,但是一旦您返回到树的上层并重新进入子文件夹,您几乎看不到加载屏幕。

尽管该应用程序的功能与本章开始时相同,但我们已经重构了代码,将数据检索和缓存与数据显示分开。让我们进一步增强我们的应用程序,通过预缓存所选路径的子文件夹。

缓存子文件夹

现在,我们可以在不更新 Vue 的情况下缓存文件夹,然后使用我们的structure对象获取子文件夹的内容。使用structure对象中的folders数组,我们可以循环遍历并依次缓存每个文件夹。

我们必须确保不会影响应用程序的性能;缓存必须是异步完成的,这样用户就不会意识到这个过程。我们还需要确保不会不必要地运行缓存。

为了实现这一点,我们可以监听structure对象。只有在数据从缓存或 API 加载并且 Vue 已更新后,才会更新此对象。当用户查看文件夹的内容时,我们可以继续循环遍历文件夹以存储其内容。

然而,有一个小问题。如果我们监听structure变量,我们的代码将永远不会运行,因为对象的直接内容不会更新,尽管我们每次都用新的对象替换structure对象。从一个文件夹到另一个文件夹,结构对象始终有两个键,即filesfolders,它们都是数组。就 Vue 和 JavaScript 而言,structure对象从不改变。

然而,Vue 可以检测到deep变量的嵌套更改。这可以在每个变量的基础上启用。与组件上的 props 类似,要在 watch 属性上启用更多选项,您需要将其传递给一个对象而不是直接函数。

structure创建一个新的watch键,它是一个包含两个值的对象,deephandlerdeep键将设置为true,而handler将是在变量改变时触发的函数:

watch: {
  path() {
    this.displayFolderStructure();
  },

  structure: {
 deep: true,
 handler() {

 }
 }
}

在这个handler中,我们现在可以循环遍历每个文件夹,并对每个文件夹运行getFolderStructure方法,使用每个文件夹的path_lower属性作为函数参数:

structure: {
  deep: true,
  handler() {
    for (let folder of this.structure.folders) {
 this.getFolderStructure(folder.path_lower);
 }
  }
}

通过这段简单的代码,我们的应用程序似乎加快了十倍。您导航到的每个子文件夹都会立即加载(除非您的文件夹列表特别长,并且您非常快速地导航到最后一个文件夹)。为了让您了解缓存的速度和时间,可以在getFolderStructure方法中添加一个console.log()并打开浏览器开发者工具:

if(data) {
  output = Promise.resolve(data);
} else {

  console.log(`API query for ${path}`);
  output = this.dropbox().filesListFolder({
    path: path, 
    include_media_info: true
  })
  .then(response => {
    console.log(`Response for ${path}`);

    ... 

这样可以让您看到所有的 API 调用都是异步完成的——应用程序在继续下一个文件夹之前不会等待前一个文件夹加载和缓存。这样做的好处是可以在不等待较大的文件夹从 API 返回的情况下缓存较小的文件夹。

替代缓存方法

与任何事物一样,在创建应用程序时,有许多方法可以实现相同的结果。这种方法的缺点是,即使您的文件夹只包含文件,这个函数也会被触发,尽管没有任何操作。

另一种方法是再次使用我们的created函数,这次在folder组件本身上,以路径作为参数触发父组件的方法。

一种方法是使用$parent属性来实现。在folder组件中,使用this.$parent可以访问dropbox-viewer组件上的变量、方法和计算属性。

folder组件中添加一个created函数,并从 Dropbox 组件中删除structurewatch属性。然后,调用父组件的getFolderStructure方法:

Vue.component('folder', {
  template: '<li><strong><a :href="\'#\' + f.path_lower">{{ f.name }}</a></strong></li>',
  props: {
    f: Object
  },
  created() {
 this.$parent.getFolderStructure(this.f.path_lower);
 }
});

预览应用程序可以证明这种方法的有效性。只有在结构中有文件夹时才触发,这种更清晰的技术将文件夹缓存与文件夹本身联系在一起,而不是与 Dropbox 代码混在一起。

然而,除非必要,否则应避免使用this.$parent,并且只应在特殊情况下使用。由于我们有机会使用 props,我们应该这样做。这还给了我们在文件夹上下文中给函数一个更有意义的名称的机会。

导航到 HTML 视图并更新文件夹组件以接受一个新的 prop。我们将称之为 cache,并将函数作为值传递。由于属性是动态的,请不要忘记添加一个前导冒号:

<folder :f="entry" :cache="getFolderStructure"></folder>

在 JavaScript 的folder组件中将cache关键字添加到 props 键中。告诉 Vue 输入将是一个函数:

Vue.component('folder', {
  template: '<li><strong><a :href="\'#\' + f.path_lower">{{ f.name }}</a></strong></li>',
  props: {
    f: Object,
    cache: Function
  }
});

最后,在created函数中调用我们的新cache()方法:

Vue.component('folder', {
  template: '<li><strong><a :href="\'#\' + f.path_lower">{{ f.name }}</a></strong></li>',
  props: {
    f: Object,
    cache: Function
  },
 created() {
 this.cache(this.f.path_lower);
 }
});

可以通过使用之前的控制台日志来验证缓存。这样可以创建更清晰的代码,更容易阅读,也方便你和其他开发人员使用。

现在我们的 Dropbox 应用程序正在进展,如果您使用 URL 中的哈希进入子文件夹,我们可以继续缓存父文件夹。

缓存父文件夹

缓存父结构是我们可以采取的下一个预防措施,以帮助加快应用程序的速度。假设我们导航到了我们的图像目录/images/holiday/summer,并希望与朋友或同事共享。我们会将带有此 URL 的 URL 哈希发送给他们,在页面加载时,他们将看到内容。如果他们然后使用面包屑向上导航到/images/holiday,例如,他们需要等待应用程序检索内容。

使用breadcrumb组件,我们可以缓存父目录,因此当用户导航到holiday文件夹时,将立即显示其内容。当用户浏览此文件夹时,所有子文件夹都将使用先前的方法进行缓存。

为了缓存父文件夹,我们已经有一个组件显示具有访问所有父文件夹的 slug 的路径,我们可以通过面包屑循环遍历。

在开始缓存过程之前,我们需要更新组件内的folders计算函数。这是因为目前我们存储的路径是带有散列前缀的,这会导致 Dropbox API 无效的路径。从被推送到输出数组的对象中删除散列,并在模板中以类似的方式添加它,就像folder组件一样:

Vue.component('breadcrumb', {
  template: '<div>' +
    '<span v-for="(f, i) in folders">' +
      '<a :href="\'#\' + f.path">{{ f.name || 'Home' }}</a>' +
      '<i v-if="i !== (folders.length - 1)"> &raquo; </i>' +
    '</span>' + 
  '</div>',
  computed: {
    folders() {
      let output = [],
        slug = '',
        parts = this.$store.state.path.split('/');

      for (let item of parts) {
        slug += item;
        output.push({'name': item || 'home', 'path': slug});
        slug += '/';
      }

      return output;
    }
  }
});

现在我们可以同时使用输出来显示面包屑和缓存父级结构。

第一步是允许breadcrumb组件访问缓存函数。类似于folder组件,将函数作为 prop 添加到你的视图中的breadcrumb组件中:

<breadcrumb :cache="getFolderStructure"></breadcrumb>

在 JavaScript 代码中,将props对象添加到组件中。将cache属性声明为一个函数,以便 Vue 知道要期望什么:

Vue.component('breadcrumb', {
  template: '...',
 props: {
 cache: Function
 },
  computed: {
    folders() {
      ...
  }
});

父结构将在breadcrumb组件创建时生成。然而,由于我们不希望这会阻碍加载过程,我们将在组件被mounted而不是created时触发它。

给你的组件添加一个mounted函数,并将文件夹的计算值赋给一个变量:

Vue.component('breadcrumb', {
  template: '...',
  props: {
    cache: Function
  },
  computed: {
    folders() {
      ...
    }
  },
  mounted() {
 let parents = this.folders;
 }
});

现在我们需要开始缓存文件夹;然而,我们可以在执行缓存的顺序上做得更聪明。我们可以假设用户通常会返回到文件夹树的上一级,所以我们应该在移动到其父级之前理想地缓存直接父级,依此类推。由于我们的文件夹变量是从上到下的,所以我们需要将其反转。

为了提高性能,我们还可以删除当前文件夹;因为我们已经在其中,应用程序已经缓存了它。在你的组件中,反转数组并删除第一个项:

mounted() {
  let parents = this.folders;
  parents.reverse().shift();
}

如果我们在父变量的函数中添加一个控制台日志,我们可以看到它包含了我们现在希望缓存的文件夹。现在,我们可以遍历这个数组,为数组中的每个项调用cache函数:

mounted() {
  let parents = this.folders;
  parents.reverse().shift();

  for(let parent of parents) {
 this.cache(parent.path);
 }
}

通过这样做,我们的父文件夹和子文件夹都被应用程序缓存,使得导航树的上下导航都非常快速。然而,在mounted函数内部运行console.log()会发现,每次导航到一个文件夹时,面包屑都会重新挂载。这是因为 View 中的v-if语句会每次删除和添加 HTML。

由于我们只需要在初始应用加载时缓存父文件夹一次,让我们看看如何改变触发它的位置。我们只需要在第一次运行此函数时运行它;一旦用户开始在树中向上和向下导航,所有访问过的文件夹都将被缓存。

缓存父文件夹一次

为了确保我们使用的资源最少,我们可以将用于面包屑的文件夹数组保留在 store 中。这意味着breadcrumb组件和我们的父级缓存函数都可以访问相同的数组。

在你的 store 状态中添加一个breadcrumb键,这是我们将存储数组的地方:

const store = new Vuex.Store({
  state: {
    path: '',
    structure: {},
    breadcrumb: []
  },
  mutations: {
    updateHash(state) {
      let hash = window.location.hash.substring(1);
      state.path = (hash || '');
    },
    structure(state, payload) {
      state.structure[payload.path] = payload.data;
    }
  }
});

接下来,将breadcrumb组件中的代码移动到updateHashmutation 中,这样我们就可以同时更新pathbreadcrumb变量:

updateHash(state) {
  let hash = window.location.hash.substring(1);
  state.path = (hash || '');

 let output = [],
 slug = '',
 parts = state.path.split('/');

 for (let item of parts) {
 slug += item;
 output.push({'name': item || 'home', 'path': slug});
 slug += '/';
 }

 state.breadcrumb = output;
},

请注意,我们不再返回output数组,而是将其存储在state对象中。现在我们可以更新breadcrumb组件上的文件夹计算函数,以返回存储的数据:

computed: {
  folders() {
 return this.$store.state.breadcrumb;
 }
}

现在,我们可以在dropbox-viewer组件上创建一个新的方法cacheParentFolders,触发我们为breadcrumb组件编写的代码。

Dropbox组件上创建一个新的方法,并将你的代码移到其中。更新父级的位置,并确保触发正确的路径:

cacheParentFolders() {
  let parents = this.$store.state.breadcrumb;
  parents.reverse().shift();
  for(let parent of parents) {
    this.getFolderStructure(parent.path);
  }
}

现在,当创建 Dropbox 组件时,我们可以触发此方法一次。在created函数中的现有方法调用之后添加它:

created() {
  this.displayFolderStructure();
  this.cacheParentFolders();
}

现在我们可以进行一些清理工作,删除breadcrumb组件中的mounted方法,以及视图中的props对象和:cache属性。这意味着我们的breadcrumb组件现在比以前更简单:

Vue.component('breadcrumb', {
  template: '<div>' +
    '<span v-for="(f, i) in folders">' +
      '<a :href="\'#\' + f.path">{{ f.name || 'Home' }}</a>' +
      '<i v-if="i !== (folders.length - 1)"> &raquo; </i>' +
    '</span>' + 
  '</div>',
  computed: {
    folders() {
      return this.$store.state.breadcrumb;
    }
  }
});

HTML 恢复到原来的状态:

<breadcrumb></breadcrumb>

我们还可以将存储中的updateHash变异整理得更整洁、更易理解:

updateHash(state, val) {
  let path = (window.location.hash.substring(1) || ''),
    breadcrumb = [],
    slug = '',
    parts = path.split('/');

  for (let item of parts) {
    slug += item;
    breadcrumb.push({'name': item || 'home', 'path': slug});
    slug += '/';
  }

  state.path = path
  state.breadcrumb = breadcrumb;
}

现在所有的变量都在顶部声明,state在底部更新。变量的数量也减少了。

现在查看应用程序,它似乎正常工作;然而,仔细检查后,breadcrumb在初始页面加载时似乎有点滞后于文件夹结构。一旦导航到一个文件夹,它就会追上来,但在第一次加载时,它似乎少了一个项目,在查看 Dropbox 的根目录时则没有任何项目。

这是因为在我们提交updateHash变异之前,存储还没有完全初始化。如果我们回忆一下 Vue 实例的生命周期,在第四章“使用 Dropbox API 获取文件列表”中介绍过,我们可以看到 created 函数在非常早期就被触发了。将主 Vue 实例更新为在mounted上触发变异可以解决这个问题:

const app = new Vue({
  el: '#app',

  store,
  mounted() {
    store.commit('updateHash');
  }
});

由于所有文件夹都已经被缓存,我们可以继续通过存储每个文件的下载链接来缓存更多的 API 调用。

我们还可以尝试缓存子文件夹的子文件夹,通过循环遍历每个缓存文件夹的内容,最终缓存整个树。我们不会详细介绍这个,但你可以自己尝试一下。

缓存文件的下载链接

当用户在文档树中导航时,Dropbox API 仍然被查询了多次。这是因为每次显示一个文件时,我们都会查询 API 来获取下载链接。通过将下载链接响应存储在缓存中,并在导航回所在的文件夹时重新显示,可以避免额外的 API 查询。

每次显示一个文件时,都会使用存储中的数据初始化一个新的组件实例。我们可以利用这一点,因为这意味着我们只需要更新组件实例,然后结果就会被缓存。

在文件组件中,更新 API 响应,不仅将结果保存在数据属性的link属性上,还保存在文件实例f上。这将作为一个新的键download_link存储。

在存储数据时,我们可以将两个单独的命令合并为一个命令,使用两个等号:

Vue.component('file', {
  template: '<li><strong>{{ f.name }}</strong><span v-if="f.size"> - {{ bytesToSize(f.size) }}</span> - <a v-if="link" :href="link">Download</a></li>',
  props: {
    f: Object,
    d: Object
  },

  data() {
    return {
      byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],
      link: false
    }
  },

  methods: {
    bytesToSize(bytes) {
      // Set a default
      let output = '0 Byte';

      // If the bytes are bigger than 0
      if (bytes > 0) {
        // Divide by 1024 and make an int
        let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
        // Round to 2 decimal places and select the appropriate unit from the array
        output = Math.round(bytes / Math.pow(1024, i), 2) + ' ' + this.byteSizes[i];
      }

      return output
    }
  },

  created() {
    this.d.filesGetTemporaryLink({path: this.f.path_lower})
      .then(data => {
        this.f.download_link = this.link = data.link;
      });
  }
});

这实际上意味着this.f.download_link等于this.link,也等于来自 API 的data.link下载链接。通过在导航到文件夹时存储和显示此信息,我们可以添加一个if语句来检查数据是否存在,如果不存在,则查询 API 获取它。

created() {
  if(this.f.download_link) {
 this.link = this.f.download_link;
 } else {
    this.d.filesGetTemporaryLink({path: this.f.path_lower})
      .then(data => {
        this.f.download_link = this.link = data.link;
      });
  }
}

在文件创建时这样做可以避免不必要地查询 API。如果我们在缓存文件夹时获取了这些信息,可能会减慢应用程序的速度并存储非必要的信息。想象一下一个包含数百张照片的文件夹-我们不希望为每个照片都查询 API,只是为了用户可能进入该文件夹。

这意味着我们应用程序中的所有内容只需要查询 API 一次以获取信息。用户可以随意在文件夹结构中上下导航,随着操作次数的增加,应用程序只会变得更快。

完整的代码-附加了文档

完成我们的应用程序后,我们现在可以添加一些非常需要的文档。文档化代码总是很好的,因为它给出了它的原因和解释。良好的文档不仅应该说明代码的功能,还应该说明为什么这样做,允许什么,不允许什么。

一种常用的文档方法是 JavaScript DocBlock 标准。这套约定规定了在文档化代码时要遵循的样式指南。DocBlock 以注释块的形式进行格式化,并以@开头的关键字为特色,例如@author@example,或者使用@param关键字列出函数可以接受的参数。一个示例是:

/**
 * Displays a folder with a link and cache its contents
 * @example <folder :f="entry" :cache="getFolderStructure"></folder>
 *
 * @param {object} f The folder entry from the tree
 * @param {function} cache The getFolderStructure method from the dropbox-viewer component
 */

从描述开始,DocBlock 有几个关键字可以帮助布置文档。我们将通过添加文档来完成我们的 Dropbox 应用程序。

让我们首先看一下breadcrumb组件:

/**
 * Displays the folder tree breadcrumb
 * @example <breadcrumb></breadcrumb>
 */
Vue.component('breadcrumb', {
  template: '<div>' +
    '<span v-for="(f, i) in folders">' +
      '<a :href="\'#\' + f.path">{{ f.name || 'Home' }}</a>' +
      '<i v-if="i !== (folders.length - 1)"> &raquo; </i>' +
    '</span>' + 
  '</div>',

  computed: {
    folders() {
      return this.$store.state.breadcrumb;
    }
  }
});

继续到folder组件:

/**
 * Displays a folder with a link and cache its contents
 * @example <folder :f="entry" :cache="getFolderStructure"></folder>
 *
 * @param {object} f The folder entry from the tree
 * @param {function} cache The getFolderStructure method from the dropbox-viewer component
 */
Vue.component('folder', {
  template: '<li><strong><a :href="\'#\' + f.path_lower">{{ f.name }}</a></strong></li>',
  props: {
    f: Object,
    cache: Function
  },
  created() {
    // Cache the contents of the folder
    this.cache(this.f.path_lower);
  }
});

接下来,在行中,我们看到file组件:

/**
 * File component display size of file and download link
 * @example <file :d="dropbox()" :f="entry"></file>
 * 
 * @param {object} f The file entry from the tree
 * @param {object} d The dropbox instance from the parent component
 */
Vue.component('file', {
  template: '<li><strong>{{ f.name }}</strong><span v-if="f.size"> - {{ bytesToSize(f.size) }}</span> - <a v-if="link" :href="link">Download</a></li>',
  props: {
    f: Object,
    d: Object
  },

  data() {
    return {
      // List of file size
      byteSizes: ['Bytes', 'KB', 'MB', 'GB', 'TB'],

      // The download link
      link: false
    }
  },

  methods: {
    /**
     * Convert an integer to a human readable file size
     * @param {integer} bytes
     * @return {string}
     */
    bytesToSize(bytes) {
      // Set a default
      let output = '0 Byte';

      // If the bytes are bigger than 0
      if (bytes > 0) {
        // Divide by 1024 and make an int
        let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
        // Round to 2 decimal places and select the appropriate unit from the array
        output = Math.round(bytes / Math.pow(1024, i), 2) + ' ' + this.byteSizes[i];
      }

      return output
    }
  },

  created() {
    // If the download link has be retrieved from the API, use it
    // if not, aquery the API
    if(this.f.download_link) {
      this.link = this.f.download_link;
    } else {
      this.d.filesGetTemporaryLink({path: this.f.path_lower})
        .then(data => {
          this.f.download_link = this.link = data.link;
        });
    }
  }
});

现在我们来看一下dropbox-viewer组件:

/**
 * The dropbox component
 * @example <dropbox-viewer></dropbox-viewer>
 */
Vue.component('dropbox-viewer', {
  template: '#dropbox-viewer-template',

  data() {
    return {
      // Dropbox API token
      accessToken: 'XXXX',

      // Current folder structure
      structure: {},
      isLoading: true
    }
  },

  computed: {
    // The current folder path
    path() {
      return this.$store.state.path
    }
  },

  methods: {

    /**
     * Dropbox API instance
     * @return {object}
     */
    dropbox() {
      return new Dropbox({
        accessToken: this.accessToken
      });
    },

    /**
     * @param {string} path The path to a folder
     * @return {string} A cache-friendly URL without punctuation/symbals
     */
    generateSlug(path) {
      return path.toLowerCase()
        .replace(/^\/|\/$/g, '')
        .replace(/ /g,'-')
        .replace(/\//g,'-')
        .replace(/[-]+/g, '-')
        .replace(/[^\w-]+/g,'');
    },

    /**
     * Retrieve the folder structure form the cache or Dropbox API
     * @param {string} path The folder path
     * @return {Promise} A promise containing the folder data
     */
    getFolderStructure(path) {
      let output;

      const slug = this.generateSlug(path),
          data = this.$store.state.structure[slug];

      if(data) {
        output = Promise.resolve(data);
      } else {
        output = this.dropbox().filesListFolder({
          path: path, 
          include_media_info: true
        })
        .then(response => {
          let entries = response.entries;

          this.$store.commit('structure', {
            path: slug,
            data: entries
          });

          return entries;
        })
        .catch(error => {
          this.isLoading = 'error';
          console.log(error);
        });

      }
      return output;
    },

    /**
     * Display the contents of getFolderStructure
     * Updates the output to display the folders and folders
     */
    displayFolderStructure() {
      // Set the app to loading
      this.isLoading = true;

      // Create an empty object
      const structure = {
        folders: [],
        files: []
      }

      // Get the structure
      this.getFolderStructure(this.path).then(data => {

        for (let entry of data) {
          // Check ".tag" prop for type
          if(entry['.tag'] == 'folder') {
            structure.folders.push(entry);
          } else {
            structure.files.push(entry);
          }
        }

        // Update the data object
        this.structure = structure;
        this.isLoading = false;
      });
    },

    /**
     * Loop through the breadcrumb and cache parent folders
     */
    cacheParentFolders() {
      let parents = this.$store.state.breadcrumb;
      parents.reverse().shift();

      for(let parent of parents) {
        this.getFolderStructure(parent.path);
      }
    }
  },

  created() {
    // Display the current path & cache parent folders
    this.displayFolderStructure();
    this.cacheParentFolders();
  },

  watch: {
    // Update the view when the path gets updated
    path() {
      this.displayFolderStructure();
    }
  }
});

让我们也检查一下 Vuex 存储:

/**
 * The Vuex Store
 */
const store = new Vuex.Store({
  state: {
    // Current folder path
    path: '',

    // The current breadcrumb
    breadcrumb: [],

    // The cached folder contents
    structure: {},
  },
  mutations: {
    /**
     * Update the path & breadcrumb components
     * @param {object} state The state object of the store
     */
    updateHash(state) {

      let path = (window.location.hash.substring(1) || ''),
        breadcrumb = [],
        slug = '',
        parts = path.split('/');

      for (let item of parts) {
        slug += item;
        breadcrumb.push({'name': item || 'home', 'path': slug});
        slug += '/';
      }

      state.path = path
      state.breadcrumb = breadcrumb;
    },

    /**
     * Cache a folder structure
     * @param {object} state The state objet of the store
     * @param {object} payload An object containing the slug and data to store
     */
    structure(state, payload) {
      state.structure[payload.path] = payload.data;
    }
  }
});

我们进一步转到 Vue 应用程序

/**
 * The Vue app
 */
const app = new Vue({
  el: '#app',

  // Initialize the store
  store,

  // Update the current path on page load
  mounted() {
    store.commit('updateHash');
  }
});

最后,我们通过window.onhashchange函数:

/**
 * Update the path & store when the URL hash changes
 */
window.onhashchange = () => {
  app.$store.commit('updateHash');
}

最后,视图中的 HTML 如下所示:

<div id="app">
  <dropbox-viewer></dropbox-viewer>
</div>

Dropbox 查看器的模板如下所示:

<script type="text/x-template" id="dropbox-viewer-template">
  <div>
    <h1>Dropbox</h1>

    <transition name="fade">
      <div v-if="isLoading">
        <div v-if="isLoading == 'error'">
          <p>There seems to be an issue with the URL entered.</p>
          <p><a href="">Go home</a></p>
        </div>
        <div v-else>
          Loading...
        </div>
      </div>
    </transition>

    <transition name="fade">
      <div v-if="!isLoading">
        <breadcrumb></breadcrumb>
        <ul>
          <template v-for="entry in structure.folders">
            <folder :f="entry" :cache="getFolderStructure"></folder>
          </template>

          <template v-for="entry in structure.files">
            <file :d="dropbox()" :f="entry"></file>
          </template>
        </ul>
      </div>
    </transition>

  </div>
</script>

您会注意到并非所有内容都已记录。一个简单的函数或变量赋值不需要重新解释它的作用,但是对主要变量的注释将帮助任何查看它的人在将来理解。

总结

在本书的这一部分,我们涵盖了很多内容!我们从查询 Dropbox API 以获取文件和文件夹列表开始。然后我们继续添加导航功能,允许用户点击文件夹并下载文件。接下来,我们介绍了 Vuex 和 store 到我们的应用程序中,这意味着我们可以集中路径、面包屑,最重要的是,缓存文件夹内容。最后,我们看了一下缓存子文件夹和文件下载链接。

在本书的下一部分,我们将看看如何创建一个商店。这将包括使用一个名为 Vue router 的新 Vue 插件浏览类别和产品页面。我们还将研究如何将产品添加到购物篮中,并将产品列表和偏好存储在 Vuex store 中。

第八章:介绍 Vue-Router 和加载基于 URL 的组件

在接下来的几章中,我们将创建一个商店界面。这个商店将结合我们迄今为止学到的所有知识,同时引入一些更多的技术、插件和功能。我们将学习如何从 CSV 文件中获取产品列表,显示它们及其变体,并按制造商或标签对产品进行过滤。我们还将学习如何创建产品详细视图,并允许用户向其在线购物篮中添加和删除产品和产品变体,例如尺寸或颜色。

所有这些都将使用 Vue、Vuex 和一个新的 Vue 插件 Vue-router 来实现。Vue-router 用于构建单页应用程序(SPA),它允许您将组件映射到 URL,或者在 VueRouter 术语中称为路由和路径。这是一个非常强大的插件,处理了许多处理 URL 所需的复杂细节。

本章将涵盖以下内容:

  • 初始化 Vue-router 及其选项

  • 使用 Vue-router 创建链接

  • 创建动态路由以根据 URL 更新视图

  • 在 URL 中使用 props

  • 嵌套和命名路由

  • 如何使用 Vue-router 进行编程导航

安装和初始化 Vue-router

与我们向应用程序添加 Vue 和 Vuex 的方式类似,您可以直接从 unpkg 中包含该库,或者转到以下 URL 并下载一个本地副本:unpkg.com/Vue-router。将 JavaScript 添加到新的 HTML 文档中,以及 Vue 和应用程序的 JavaScript。还要创建一个应用程序容器元素作为您的视图。在下面的示例中,我将 Vue-router 的 JavaScript 文件保存为router.js

<!DOCTYPE html>
<html>
<head>
  <title></title>
</head>
<body>
  <div id="app"></div>

  <script type="text/javascript" src="js/vue.js"></script>
  <script type="text/javascript" src="js/router.js"></script>
  <script type="text/javascript" src="js/app.js"></script>
</body>
</html>

在应用程序的 JavaScript 中初始化一个新的 Vue 实例:

new Vue({
  el: '#app'
});

现在我们已经准备好添加 VueRouter 并利用其功能了。然而,在此之前,我们需要创建一些非常简单的组件,根据 URL 加载和显示它们。由于我们将使用路由器加载组件,因此不需要使用Vue.component注册它们,而是创建具有与 Vue 组件相同属性的 JavaScript 对象。

在这个第一个练习中,我们将创建两个页面——主页和关于页面。在大多数网站上都可以找到这些页面,它们应该帮助您了解加载的内容以及何时加载。在您的 HTML 页面中创建两个模板供我们使用:

<script type="text/x-template" id="homepage">
  <div>
    <h1>Hello &amp; Welcome</h1>
    <p>Welcome to my website. Feel free to browse around.</p>
  </div>
</script>

<script type="text/x-template" id="about">
  <div>
    <h1>About Me</h1>
    <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus sed metus magna. Vivamus eget est nisi. Phasellus vitae nisi sagittis, ornare dui quis, pharetra leo. Nullam eget tellus velit. Sed tempor lorem augue, vitae luctus urna ultricies nec. Curabitur luctus sapien elit, non pretium ante sagittis blandit. Nulla egestas nunc sit amet tellus rhoncus, a ultrices nisl varius. Nam scelerisque lacus id justo congue maximus. Etiam rhoncus, libero at facilisis gravida, nibh nisi venenatis ante, sit amet viverra justo urna vel neque.</p>
    <p>Curabitur et arcu fermentum, viverra lorem ut, pulvinar arcu. Fusce ex massa, vehicula id eros vel, feugiat commodo leo. Etiam in sem rutrum, porttitor velit in, sollicitudin tortor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec ac sapien efficitur, pretium massa at, vehicula ligula. Vestibulum turpis quam, feugiat sed orci id, eleifend pretium urna. Nullam faucibus arcu eget odio venenatis ornare.</p>
  </div>
</script>

不要忘记将所有内容封装在一个“根”元素中(在这里用包裹的<div>标签表示)。您还需要确保在加载应用程序 JavaScript 之前声明模板。

我们创建了一个 Home 页面模板,其idhomepage,以及一个 About 页面,其中包含一些来自lorem ipsum的占位文本,其idabout。在您的 JavaScript 中创建两个引用这两个模板的组件:

const Home = {
  template: '#homepage'
};

const About = {
  template: '#about'
};

下一步是为路由器提供一个占位符来渲染视图中的组件。这可以通过使用自定义的<router-view> HTML 元素来完成。使用此元素可以控制内容的渲染位置。它允许我们在应用程序视图中拥有一个头部和页脚,而无需处理混乱的模板或包含组件本身。

在您的应用程序中添加一个headermainfooter元素。在 header 中放置一个 logo,在 footer 中放置 credits;在main HTML 元素中,放置router-view占位符:

<div id="app">
  <header>
    <div>LOGO</div>
  </header>

  <main>
    <router-view></router-view>
  </main>

  <footer>
    <small>© Myself</small>
  </footer>
</div>

应用程序视图中的所有内容都是可选的,除了router-view之外,但它可以让您了解如何将路由器 HTML 元素实现到站点结构中。

下一步是初始化 Vue-router 并指示 Vue 使用它。创建一个VueRouter的新实例并将其添加到Vue实例中,类似于我们在前一节中添加Vuex的方式:

const router = new VueRouter();

new Vue({
  el: '#app',

  router
});

现在我们需要告诉路由器我们的路由(或路径)以及在遇到每个路由时应加载的组件。在 Vue-router 实例内部创建一个具有routes键和数组值的对象。此数组需要为每个路由包含一个对象:

const router = new VueRouter({
  routes: [
    {
 path: '/',
 component: Home
 },
 {
 path: '/about',
 component: About
 }
  ]
});

每个路由对象都包含一个pathcomponent键。path是您要在其上加载component的 URL 字符串。Vue-router 根据先到先得的原则提供组件。例如,如果有多个具有相同路径的路由,则使用遇到的第一个路由。确保每个路由都有开始斜杠-这告诉路由器它是一个根页面而不是子页面,我们将在本章后面介绍子页面。

按下保存并在浏览器中查看你的应用程序。你应该看到Home模板组件的内容。如果你观察 URL,你会注意到在页面加载时,路径后面会添加一个哈希和斜杠(#/)。这是路由器创建的一种浏览组件和利用地址栏的方法。如果你将其更改为第二个路由的路径,#/about,你将看到About组件的内容。

Vue-router 还可以使用 JavaScript 历史 API 来创建更漂亮的 URL。例如,yourdomain.com/index.html#about将变成yourdomain.com/about。这是通过在你的VueRouter实例中添加mode: 'history'来激活的:

const router = new VueRouter({
  mode: 'history',

  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    }
  ]
});

然而,这也需要一些服务器配置来捕获所有请求并将它们重定向到你的index.html页面,这超出了本书的范围,但在 Vue-router 文档中有详细说明。

更改 Vue-router 的文件夹

有时候你可能想要将 Vue 应用程序托管在网站的子文件夹中。在这种情况下,你需要声明项目的基本文件夹,以便 Vue-router 可以构建和监听正确的 URL。

例如,如果你的应用程序基于一个/shop/文件夹,你可以使用 Vue-router 实例上的base参数来声明它:

const router = new VueRouter({
  base: '/shop/',

  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    }
  ]
});

这个值需要在开头和结尾都有斜杠。

除了base之外,Vue-router 还有其他几个配置选项可用,值得熟悉它们,因为它们可能会解决你以后遇到的问题。

链接到不同的路由

路由器正常工作后,我们现在可以继续向应用程序中添加链接,允许用户在网站中导航。链接可以通过两种方式实现:我们可以使用传统的<a href="#/about">标签,或者我们可以利用路由器提供的新的 HTML 元素<router-link to="/about">。当使用router-link元素时,它的工作方式与<a>标签相同,在浏览器中运行时实际上会被转换为<a>标签,但允许更多的自定义和与路由器的集成。

强烈建议在可能的情况下使用router-link元素,因为它比标准链接具有几个优点:

  • 模式更改:第一个优点与路由器的mode相关。使用路由链接可以更改路由器的模式,例如从哈希模式更改为历史模式,而不必更改应用程序中的每个链接。

  • CSS 类:使用路由链接的另一个优点是应用于“树”中活动链接和当前正在查看的页面的 CSS 类。树中的链接是父页面,也包括根页面(例如,任何链接到/的链接将始终具有活动类)。这是使用路由的一个重要优势,因为手动添加和删除这些类将需要复杂的编码。这些类可以进行自定义,我们将在稍后进行。

  • URL 参数和命名路由:使用路由元素的另一个优点是它使您能够使用命名路由和传递 URL 参数。这进一步允许您在页面的 URL 上拥有一个真实的来源,并使用名称和快捷方式引用路由。关于这一点,稍后在本章中将进行更详细的介绍。

在视图中添加页面链接,以便您可以在页面之间导航。在您的网站的<header>中,创建一个新的<nav>元素,其中包含一个无序列表。对于每个页面,添加一个包含router-link元素的新列表项。在链接路径上添加一个to属性:

<nav>
  <ul>
    <li>
      <router-link to="/">Home</router-link>
    </li>
    <li>
      <router-link to="/about">About</router-link>
    </li>
  </ul>
</nav>

在浏览器中查看应用程序应该显示两个链接,允许您在两个内容页面之间切换。您还会注意到,通过点击链接,URL 也会更新。

如果您使用浏览器的 HTML 检查器检查链接,您会注意到 CSS 类的变化。主页链接始终具有router-link-active类 - 这是因为它本身处于活动状态,或者它有一个活动的子页面,例如关于页面。当您在两个页面之间导航时,还会添加和删除另一个 CSS 类 - router-link-exact-active。这个类仅适用于当前活动页面上的链接。

让我们自定义应用于视图的类。在 JavaScript 中的路由初始化部分,向对象添加两个新键 - linkActiveClasslinkExactActiveClass

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    }
  ],

  linkActiveClass: 'active',
 linkExactActiveClass: 'current'
});

这些键应该相当容易理解,但是linkExactActiveClass应用于当前页面,即正在查看的页面,而linkActiveClass是当页面或其子页面之一处于活动状态时应用的类。

链接到子路由

有时您可能希望链接到子页面。例如/about/meet-the-team。幸运的是,不需要太多工作来实现这个功能。在routes数组中创建一个指向具有模板的新组件的新对象:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
 path: '/about/meet-the-team',
 component: MeetTheTeam
 }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});  

当导航到这个页面时,你会注意到 Home 和 About 链接都有active类,而没有我们创建的current类。如果你在导航中创建一个链接到这个页面,那么一个current类将被应用到它上面。

带参数的动态路由

Vue 路由器很容易让你拥有动态 URL。动态 URL 允许你使用相同的组件来显示不同的数据,同时使用相同的模板。一个例子是商店,所有的类别页面看起来都一样,但根据 URL 显示不同的数据。另一个例子是产品详情页面,你不想为每个产品都创建一个组件,所以你可以使用一个带有 URL 参数的组件。

URL 参数可以出现在路径的任何位置,可以有一个或多个。每个参数都被分配一个键,因此可以一致地创建和访问它们。我们将在第九章“使用 Vue-Router 动态路由加载数据”中更详细地介绍动态路由和参数。现在,我们将构建一个基本的示例。

在我们开始创建组件之前,让我们来看一下一个新的可用变量——this.$route。类似于我们如何使用 Vuex 访问全局存储一样,这个变量允许我们访问关于路由、URL 和参数的许多信息。

在你的 Vue 实例中,作为一个测试,添加一个mounted()函数。在console.log中插入this.$route参数:

new Vue({
  el: '#app',

  router,
  mounted() {
 console.log(this.$route);
 }
});

如果你打开浏览器并查看开发者工具,你应该会看到一个对象被输出。查看这个对象将显示一些信息,比如路径和与当前路径匹配的组件。前往/about URL 将显示关于该对象的不同信息:

让我们创建一个使用这个对象参数的组件。在你的路由数组中创建一个新对象:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
 path: '/user/:name',
 component: User
 }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
}); 

你会注意到这个路径与之前的路径不同的地方是在路径中name之前有一个冒号。这告诉 Vue-router 这个 URL 的这部分是动态的,但该部分的变量名是name

现在创建一个名为User的新组件,并为其创建一个模板。在这个例子中,我们的模板将是内联的,并且我们将使用 ES2015 模板语法。这种语法使用反引号,可以直接将变量和换行符传递到模板中,而无需对它们进行转义:

const User = {
  template: `<h1>Hello {{ $route.params.name }}</h1>`
};

模板中输出的变量来自全局路由实例,并且是参数对象中的name变量。变量name引用路由路径中冒号前面的变量,在routes数组中。在组件模板中,我们还可以省略$route中的this变量。

返回浏览器,然后在 URL 末尾输入#/user/sarah。您应该在网页的主体中看到 Hello sarah。查看 JavaScript 浏览器控制台,您应该看到params对象中有一个键/值对name: sarah

此变量也可在组件本身中使用。例如,如果我们想要将用户姓名的第一个字母大写,我们可以创建一个计算变量,该变量接受路由参数并进行转换:

const User = {
  template: `<h1>Hello {{ name }}</h1>`,

  computed: {
 name() {
 let name = this.$route.params.name;
 return name.charAt(0).toUpperCase() + name.slice(1);
 }
 }
};

如果您不熟悉前面的代码在做什么,它会获取字符串的第一个字符并将其大写。然后,它在大写字母后拆分字符串(即,单词的其余部分)并将其附加到大写字母上。

添加此computed函数并刷新应用程序将产生 Hello sarah。

如前所述,路由可以接受任意数量的参数,并且可以由静态或动态变量分隔。

将路径更改为以下内容(保持组件名称相同):

/:name/user/:emotion

这意味着您需要转到/sarah/user/happy才能看到用户组件。但是,您将可以访问一个名为emotion的新参数,这意味着您可以使用以下模板来呈现 sarah is happy!:

const User = {
  template: `<h1>{{ name }} is {{ $route.params.emotion }}</h1>`,

  computed: {
    name() {
      let name = this.$route.params.name;
      return name.charAt(0).toUpperCase() + name.slice(1);
    }
  }
};

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
 path: '/:name/user/:emotion',
      component: User
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

在接下来的几章中,当我们构建商店时,动态路由将非常有用,因为我们将同时用于产品和类别。

GET 参数

除了动态路由,Vue-router 还以一种非常简单的方式处理 GET 参数。GET 参数是您可以传递给网页的额外 URL 参数,它们显示为键/值对。使用 GET 参数,第一个参数前面有一个?-这告诉浏览器要期望参数。任何其他参数都由和号分隔。例如:

example.com/?name=sarah&amp;emotion=happy

此 URL 将返回name的值为sarahemotion的值为happy。它们通常用于过滤或搜索-下次在 Google 上搜索时,请查看 URL,您会注意到地址栏中的?q=Your+search+query

Vue 路由器将这些参数在this.$route变量的query对象中提供给开发者。尝试在 URL 末尾添加?name=sarah并打开 JavaScript 开发者工具。检查查询对象将显示一个以name为键,sarah为值的对象:

在构建商店类别的过滤器时,我们将使用查询对象。

使用 props

尽管直接在组件中使用路由参数完全可以正常工作,但这不是一个好的做法,因为它将组件直接与路由绑定在一起。相反,应该使用props,就像我们在本书中之前为 HTML 组件使用它们一样。当启用和声明后,通过 URL 传递的参数可以像通过 HTML 属性传递的参数一样使用。

使用 props 作为路由组件传递选项和参数是一种更好的方式,因为它有很多好处。首先,它将组件与特定的 URL 结构解耦-正如您将看到的,我们可以直接将 props 传递给组件本身。它还有助于使您的路由组件更清晰;传入的参数在组件本身中清晰地列出,并且整个组件的代码更清晰。

props 仅适用于动态路由-GET 参数仍然可以通过前面的技术访问。

使用前面的示例,为nameemotion参数声明props。当使用基于 URL 的变量时,您将希望使用String数据类型:

const User = {
  template: `<h1>{{ name }} is {{ $route.params.emotion }}</h1>`,
  props: {
 name: String,
 emotion: String
 },
  computed: {
    name() {
      let name = this.$route.params.name;
      return name.charAt(0).toUpperCase() + name.slice(1);
    }
  }
};

现在,我们有了this.name可以通过props和计算值两次使用。然而,由于我们通过props有了this.namethis.emotion,我们可以更新我们的组件来使用这些变量,而不是$route参数。

为了避免与 prop 冲突,将计算函数更新为formattedName()。我们还可以从函数中删除变量声明,因为新变量更易读:

const User = {
  template: `<h1>{{ formattedName }} is {{ this.emotion }}</h1>`,
  props: {
    name: String,
    emotion: String
  },
  computed: {
    formattedName() {
      return this.name.charAt(0).toUpperCase() + this.name.slice(1);
    }
  }
};

props起作用之前,需要告诉 Vue-router 在特定路由中使用它们。这在routes数组中启用,逐个路由设置,并且最初设置为props: true

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
      path: '/:name/user/:emotion',
      component: User,
      props: true
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

设置 prop 默认值

现在,由于路由参数可用作props,这使我们可以轻松创建默认值。如果我们想要使参数可选,我们需要添加几个if()语句来检查变量的存在。

然而,使用 props,我们可以像之前一样声明默认值。为情感变量添加一个默认值:

const User = {
  template: `<h1>{{ formattedName }} is {{ this.emotion }}</h1>`,
  props: {
    name: String,
    emotion: {
 type: String,
 default: 'happy'
 }
  },
  computed: {
    formattedName() {
      return this.name.charAt(0).toUpperCase() + this.name.slice(1);
    }
  }
};

我们现在可以在路由器中创建一个新的路由,该路由使用相同的组件,但没有最后的变量。不要忘记为新路由启用props

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
 path: '/:name/user',
 component: User,
 props: true
 }, 
    {
      path: '/:name/user/:emotion',
      component: User,
      props: true
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

现在,通过访问/sarah/user,我们应该看到声明 sarah 很开心的文本。

使用静态 props

除了布尔值之外,路由中的 props 参数还可以接受一个带有要传递的 props 列表的对象。这使您可以使用相同的组件并根据 URL 更改其状态,而无需通过路径传递变量,例如,如果您想要激活或停用模板的一部分。

当通过 URL 传递 props 对象时,它会覆盖整个 props 对象,这意味着您必须声明全部或全部。props 变量也将优先于动态的基于 URL 的变量。

将您的新的/:name/user路径更新为在路由中包含props - 从路径中删除:name变量,使其变为/user

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About
    },
    {
      path: '/user',
      component: User,
      props: {
 name: 'Sarah',
 emotion: 'happy'
 }
    }, 
    {
      path: '/:name/user/:emotion',
      component: User,
      props: true
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

导航到/user应该显示与之前相同的句子。在某些情况下,通过“幕后”传递props(而不是使用 URL)是理想的,因为您可能不希望用户共享特定的 URL 或根据易于更改的参数更改应用程序的状态。

嵌套路由

嵌套路由与子路由不同,因为它们存在于已经匹配路由开始部分的组件中。这使您可以在现有视图中显示不同的内容。

一个很好的例子是 Twitter。如果您访问 Twitter 用户的个人资料页面,您可以查看他们关注的人,关注他们的人以及他们创建的列表。如果您在浏览页面时观察 URL,您会注意到一个重复的模式:用户名后跟不同的页面。嵌套路由和子路由之间的区别在于,嵌套路由允许您在不同的子页面中保持组件相同(例如,标题和侧边栏)。

这样做的优点是用户可以收藏和分享链接,使页面更易访问,并且有利于 SEO。使用简单的切换或选项卡框来在视图中显示不同内容,很难实现这些优点。

要将 Twitter 模式复制到 Vue 路由中,可以按照以下方式进行设置:

https://twitter.com/:user/:page

如果我们使用之前的路由方法来创建这个,我们将不得不为每个页面构建组件,在它们的模板中包含侧边栏中的标题和用户信息——如果您需要更新代码,这将是一件麻烦的事情!

让我们为我们的 About 页面创建一些嵌套路由。我们不会在我们的购物应用程序中使用嵌套路由,但了解 Vue 路由的功能是很重要的。

创建两个新组件——AboutContact,用于显示联系信息,以及AboutFood,用于详细介绍您喜欢吃的食物!虽然不是必需的,但在组件名称中保留对父组件(在本例中为 About)的引用是一个好主意——这样可以在以后查看它们时将组件联系在一起!为每个组件添加一个带有一些固定内容的模板:

const AboutContact = {
  template: `<div>
    <h2>This is some contact information about me</h2>
    <p>Find me online, in person or on the phone</p>
  </div>`
};

const AboutFood = {
  template: `<div>
    <h2>Food</h2>
    <p>I really like chocolate, sweets and apples.</p>
  </div>`
};

下一步是在您的#about模板中创建用于渲染嵌套路由的占位符。该元素与我们之前看到的元素完全相同——<router-view>元素。为了证明它可以放置在任何位置,在模板的两个段落之间添加它:

<script type="text/x-template" id="about">
  <div>
    <h1>About Me</h1>
    <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus sed metus magna. Vivamus eget est nisi. Phasellus vitae nisi sagittis, ornare dui quis, pharetra leo. Nullam eget tellus velit. Sed tempor lorem augue, vitae luctus urna ultricies nec. Curabitur luctus sapien elit, non pretium ante sagittis blandit. Nulla egestas nunc sit amet tellus rhoncus, a ultrices nisl varius. Nam scelerisque lacus id justo congue maximus. Etiam rhoncus, libero at facilisis gravida, nibh nisi venenatis ante, sit amet viverra justo urna vel neque.</p>

    <router-view></router-view>

    <p>Curabitur et arcu fermentum, viverra lorem ut, pulvinar arcu. Fusce ex massa, vehicula id eros vel, feugiat commodo leo. Etiam in sem rutrum, porttitor velit in, sollicitudin tortor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec ac sapien efficitur, pretium massa at, vehicula ligula. Vestibulum turpis quam, feugiat sed orci id, eleifend pretium urna. Nullam faucibus arcu eget odio venenatis ornare.</p>
  </div>
</script>

在浏览器中查看 About 页面不会渲染任何内容,也不会破坏应用程序。下一步是为这些组件添加嵌套路由到路由器中。我们不是将它们添加到顶级routes数组中,而是在/about路由内创建一个数组,键为children。该数组的语法与主数组完全相同,即路由对象的数组。

为每个routes添加一个包含pathcomponent键的对象。需要注意的是,如果希望路径添加到父路径的末尾,路径不应以/开头。

例如,如果您希望 URL 为/about/contact来渲染AboutContact组件,您可以将路由组件设置如下:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About,
      children: [
 {
 path: 'contact', 
 component: AboutContact
 }, 
 {
 path: 'food', 
 component: AboutFood
 }
 ]
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

然而,如果你希望 URL 只是简单地为/contact,但仍然在About组件内渲染AboutContact组件,你可以添加前导斜杠。尝试在没有斜杠的情况下查看应用程序,然后添加斜杠,看看它所产生的差异。如果你希望在父级加载时显示一个子路由,而 URL 没有第二部分,你可以使用空路径——path: ''

现在,保留没有斜杠,并添加前导的children数组。转到浏览器并导航到 About 页面。在 URL 的末尾添加/contact/food,注意新内容出现在您之前添加到模板中的<router-link>元素的位置。

可以从任何地方创建到这些组件的链接,方式与您链接 Home 和 About 页面的方式相同。您可以将它们添加到about模板中,这样它们只会在导航到该页面时出现,或者将它们添加到应用程序视图中的主导航中。

创建一个 404 页面

在构建应用程序或网站时,尽管有着良好的意图,问题、错误和失误仍然会发生。因此,设置错误页面是一个好主意。最常见的页面是 404 页面——当链接不正确或页面已移动时显示的消息。404 是页面未找到的官方 HTTP 代码。

如前所述,Vue-router 将根据先到先得的原则匹配路由。我们可以利用这一点,将通配符(*)字符作为最后一个路由。由于通配符匹配每个路由,只有未匹配到先前路由的 URL 才会被此路由捕获。

创建一个名为PageNotFound的新组件,使用简单的模板,并添加一个新的路由,使用通配符作为路径:

const PageNotFound = {
 template: `<h1>404: Page Not Found</h1>`
};

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About,
      children: [
        {
          path: 'contact', 
          component: AboutContact
        }, 
        {
          path: 'food', 
          component: AboutFood
        }
      ]
    },
 {
 path: '*', 
 component: PageNotFound
 }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

在浏览器中打开应用程序,输入 URL 的末尾的任何内容(除了about),然后按下Enter键,您应该看到 404 标题。

尽管这是模拟一个页面未找到的请求,但它实际上没有向浏览器发送正确的 HTTP 代码。如果您在生产中使用 Vue Web 应用程序,建议设置服务器端错误检查,以便在 URL 不正确的情况下可以正确通知浏览器。

命名组件、路由和视图

在使用Vue-router时,不需要为路由和组件添加名称,但这是一个好的实践和一个好习惯。

命名组件

具有名称的组件使您能够更轻松地调试错误。在 Vue 中,当从组件中抛出 JavaScript 错误时,它将给出该组件的名称,而不是将Anonymous列为组件。

例如,如果您尝试在食品组件中输出一个不可用的变量{{ test }},默认情况下,JavaScript 控制台错误将如下所示:

请注意堆栈中的两个<Anonymous>组件。

通过为我们的组件添加名称,我们可以轻松地确定问题所在。在下面的示例中,已经为AboutAboutFood组件添加了名称:

您可以轻松地看到错误在<AboutFood>组件中。

为组件添加名称就像在对象中添加一个名为name的键,并将名称作为值一样简单。这些名称遵循与创建 HTML 元素组件时相同的规则:不允许空格,但允许连字符和字母。为了让我能够快速识别代码,我选择将组件命名为与定义它的变量相同:

const About = {
  name: 'About',
  template: '#about'
};

const AboutFood = {
  name: 'AboutFood',
  template: `<div>
    <h2>Food</h2>
    <p>I really like chocolate, sweets and apples.</p>
  </div>`
}

命名路由

在使用VueRouter时,您还可以为路由本身命名。这使您能够简化路由的位置并更新路径,而无需在应用程序中查找和替换所有实例。

请按照以下示例将name键添加到您的routes中:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      component: About,
      children: [
        {
          name: 'contact',
          path: 'contact', 
          component: AboutContact
        }, 
        {
          name: 'food',
          path: 'food', 
          component: AboutFood
        }
      ]
    },
    {
      path: '*', 
      component: PageNotFound
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

现在,您可以在创建router-link组件时使用该名称,如下所示:

<router-link :to="{name: 'food'}">Food</router-link>

注意to属性前面的冒号。这确保内容被解析为对象,而不是字面字符串。使用命名路由的另一个优点是能够向我们的动态路径传递特定属性。使用本章前面的示例,我们可以以编程方式构建 URL,将数据从路径构建中抽象出来。这就是命名路由真正发挥作用的地方。假设我们有以下路径:

{ name: 'user', path: '/:name/user/:emotion', component: User }

我们需要向 URL 传递一个名称和情感变量以供组件渲染。我们可以像之前那样直接传递给 URL,或者使用带有命名路由的to对象表示法:

<router-link :to="{name: 'user', params: { name: 'sarah', emotion: 'happy' }}">
  Sarah is Happy
</router-link>

在浏览器中查看将显示生成的锚链接:

/sarah/user/happy

这使我们能够重新排列 URL,使用变量,而无需更新应用的其余部分。如果您想在 URL 末尾传递参数(例如?name=sarah),则可以将params键更改为query,因为它遵循相同的格式:

<router-link :to="{name: 'user', query: { name: 'sarah', emotion: 'happy' }}">
  Sarah is Happy
</router-link>

通过重新配置路径以不接受参数,将生成以下链接:

/user?name=sarah&amp;emotion=happy

在交换paramsquery时要小心-它们可能会影响您使用path还是name。当使用path时,params对象将被忽略,而query对象不会被忽略。要使用params对象,您需要使用命名路由。或者,使用$变量将参数传递到path中。

命名视图

Vue 路由还允许您为视图命名,从而可以将不同的组件传递给应用程序的不同部分。一个例子是商店,其中有侧边栏和主要内容区域。不同的页面可以以不同的方式利用这些区域。

About 页面可以使用主要内容显示关于内容,同时使用侧边栏显示联系方式。然而,商店页面将使用主要内容列出产品,并使用侧边栏显示过滤器。

为此,请创建第二个router-view元素作为原始元素的兄弟元素。保留原始元素的位置,但是给第二个元素添加一个name属性,并给它一个适当的标题:

<main>
  <router-view></router-view>
</main>

<aside>
    <router-view name="sidebar"></router-view>
</aside>

在路由实例中声明路由时,我们现在将使用一个新的键components,并删除之前的单数component键。这个键接受一个对象,其中包含视图的名称和组件的名称的键值对。

建议将主路由保持未命名,这样您就不需要更新每个路由。如果您决定给主路由命名,那么您需要为应用中的每个路由执行下一步操作。

更新About路由以使用这个新的键,并将其转换为一个对象。下一步是告诉代码每个组件将放在哪里。

使用default作为键,将About组件设置为值。这将把 About 组件的内容放在未命名的router-view中,即主要的那个。这也是使用单数component键的简写方式:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      components: {
 default: About
 }
    },
    {
      path: '*', 
      component: PageNotFound
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

接下来,添加第二个键值对,指定第二个router-view的名称为sidebar。当导航到/about URL 时,将使用您想要填充此区域的组件,我们将使用AboutContact组件:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
      components: {
        default: About,
        sidebar: AboutContact
      }
    },
    {
      path: '*', 
      component: PageNotFound
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

在浏览器中运行应用程序将呈现两个组件,联系组件的内容将显示在侧边栏中。

以编程方式导航、重定向和添加别名

在构建应用程序时,可能会遇到一些需要不同导航技术的情况。这些可能是以编程方式导航,例如在组件或主 Vue 实例中,当用户访问特定 URL 时重定向用户,或者使用不同的 URL 加载相同的组件。

以编程方式导航

您可能希望从代码、组件或操作中更改路径、URL 或用户流程。一个例子是在用户添加了一个项目后将其发送到购物篮。

为此,您可以在路由器实例上使用push()函数。push 的值可以是直接 URL 的字符串,也可以是接受命名路由或路由参数的对象。push函数的允许内容与router-link元素上的to=""属性完全相同。例如:

const About = {
  name: 'About',
  template: '#about',
  methods: {
    someAction() {
      /* Some code here */

      // direct user to contact page
      this.$router.push('/contact');
    }
  }
};

或者,您可以使用带参数的命名路由进行重定向:

this.$router.push({name: 'user', params: { name: 'sarah', emotion: 'happy' }});

重定向

使用VueRouter进行重定向非常简单。重定向的一个例子可能是将您的/about页面移动到/about-us URL。您将希望将第一个 URL 重定向到第二个 URL,以防有人分享或收藏了您的链接,或者搜索引擎缓存了 URL。

您可能会想要创建一个基本组件,当创建时使用router.push()函数将用户发送到新的 URL。

相反,您可以添加一个路由并在其中指定重定向:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
 path: '/about',
 redirect: '/about-us'
 },
    {
      path: '/about-us',
      component: About
    },
    {
      path: '*', 
      component: PageNotFound
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

再次强调,重定向键的内容可以是一个字面字符串或一个对象,就像push()函数一样。在上述示例中,如果用户访问/about,他们将立即重定向到/about-us并显示About组件。

别名路由

可能有一些情况下,您希望在两个 URL 下显示相同的组件。虽然不推荐作为标准做法,但在某些边缘情况下,这是必需的。

别名键会添加到现有路由中,并接受一个路径的字符串。使用上述示例,无论用户访问/about还是/about-us,都将显示About组件:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/about',
 alias: '/about-us',
      component: About,
    },
    {
      path: '*', 
      component: PageNotFound
    }
  ],

  linkActiveClass: 'active',
  linkExactActiveClass: 'current'
});

摘要

现在,您应该熟悉 Vue-router,知道如何初始化它,了解可用的选项,以及如何创建静态和动态的新路由。在接下来的几章中,我们将开始创建我们的商店,首先加载一些商店数据并创建一个产品页面。

第九章:使用 Vue-Router 动态路由加载数据

在第八章《介绍 Vue-Router 和加载基于 URL 的组件》中,我们探索了 Vue-router 及其功能和功能。有了这些知识,我们现在可以继续使用 Vue 制作我们的商店。在我们进入代码并开始创建之前,我们应该首先计划我们的商店将如何工作,我们需要哪些 URL 以及我们需要制作哪些组件。一旦我们计划好我们的应用程序,我们就可以继续创建一个产品页面。

在本章中,我们将:

  • 概述我们的组件和路由,并创建占位符文件

  • 加载产品 CSV 文件,处理并缓存在 Vuex 中

  • 创建一个带有图像和产品变体的单独产品页面

概述和计划您的应用程序

首先,让我们考虑整个应用程序和用户流程。

我们将创建一个没有支付处理网关的商店。商店首页将显示一系列精选产品。用户将能够使用类别浏览产品,并使用我们制作的筛选器缩小选择范围。他们将能够选择一个产品并查看更多详细信息。产品将具有变体(大小、颜色等),并且可能有多个产品图像。用户将能够将变体添加到购物篮中。从那里,他们可以继续浏览产品并添加更多产品到购物篮中,或者进入结账流程,在那里他们将被要求提供姓名和地址,并进行支付。将显示订单确认屏幕。

整个商店应用程序将在 Vue 中创建并在客户端运行。这不涵盖任何用于付款、用户帐户、库存管理或验证的服务器端代码。

该应用程序将使用 Vue-router 处理 URL 和 Vuex 存储产品、购物篮内容和用户详细信息。

组件

在确定用户流程后,我们需要计划我们的商店需要制作哪些组件以及它们的名称。这有助于开发应用程序,因为我们清楚地知道需要创建哪些组件。我们还将决定组件的名称。根据 Vue 风格指南(vuejs.org/v2/style-guide/index.html),我们的所有组件都将由两个名称组成。

路由组件

以下组件将与 Vue-router 一起使用,形成我们应用程序的页面:

  • 商店首页HomePage:商店首页将显示由商店所有者精选的产品列表。这将使用预先选择的产品句柄列表进行显示。

  • 分类页面CategoryPage:这将列出特定类别的产品。类别列表页面还将具有过滤器。

  • 产品页面ProductPage:产品页面将显示产品的详细信息、图片和产品的变体。

  • 购物篮OrderBasket:在购物篮中,用户可以查看已添加的产品,删除不需要的项目,并更改每个项目的数量。它还将显示订单的总成本。

  • 结账OrderCheckout:结账将锁定购物篮,禁止删除和更新产品,并提供一个表单供用户输入地址。

  • 订单确认OrderConfirmation:下单后将显示此组件,确认购买的产品、交付地址和总价格。

  • 404 页面PageNotFound:当输入错误的 URL 时显示的错误页面。

HTML 组件

HTML 组件将在页面组件中使用,以帮助减少我们的代码中的重复布局。

  • 产品列表ListProducts:在列表视图中查看时,将显示产品的分页列表,例如在HomePageCategoryPage组件中。

  • 类别列表ListCategories:这将创建一个用于导航的类别列表。

  • 购买列表ListPurchases:此组件将出现在购物篮、结账和订单确认页面中;它将以表格形式列出产品的变体、价格和数量。它还将显示购物篮中所有产品的总价格。

  • 过滤ProductFiltering:在类别页面的侧边使用的组件将提供用户过滤的能力,并且将使用我们在第八章中介绍的 GET 参数更新 URL,介绍 Vue-Router 和加载基于 URL 的组件

路径

在我们概述了组件之后,我们可以规划商店的路径和 URL,以及它们将采取的组件或操作。我们还需要考虑错误的 URL 以及是否应该将用户重定向到适当的位置或显示错误消息:

  • /Home

  • /category/:slugCategoryPage,使用:slug唯一标识符来确定要显示的产品

  • /category:这将重定向到/

  • /product/:slugProductPage - 再次使用:slug来标识产品

  • /product:这将重定向到/

  • /basketOrderBasket

  • /checkoutOrderCheckout - 如果没有产品,它将重定向用户到/basket

  • /completeOrderConfirmation - 如果用户没有从OrderCheckout组件进入,则会重定向到/basket

  • *PageNotFound - 这将捕获任何未指定的路由

随着我们确定了路由和组件,我们可以开始创建我们的应用程序。

创建初始文件

在前一节中概述的应用程序中,我们可以为文件结构和组件创建框架。由于这个应用程序是一个大型应用程序,我们将把文件拆分为每个组件的单独文件。这意味着我们的文件更易管理,我们的主应用程序 JavaScript 文件不会变得无法控制。

尽管在开发过程中可以接受,但是部署具有这么多文件的应用程序可能会增加加载时间,这取决于服务器的设置方式。使用传统的 HTTP/1.1,浏览器必须请求和加载每个文件 - 如果有多个文件,这将是一个阻碍。然而,使用 HTTP/2,您可以同时向用户推送多个文件 - 在这种情况下,多个文件可以在一定程度上提高应用程序的性能。

无论您选择使用哪种部署方法,强烈建议在将代码部署到生产环境时对 JavaScript 进行缩小。这样可以确保在为用户提供服务时,代码尽可能小:

为每个组件、视图和库(如 Vue、Vuex 和 Vue-router)创建一个文件。然后,为每种类型的文件创建一个文件夹。最后,添加一个app.js - 这是初始化库的地方。

您还可以考虑使用 vue-cli (https://github.com/vuejs/vue-cli)来构建您的应用程序。超出了本书的范围,因为我们只涵盖了使用包含的 JavaScript 文件构建 Vue 应用程序,vue-cli 应用程序允许您以更模块化的方式开发应用程序,并在开发完成后以类似我们一直在开发应用程序的方式部署它。

创建一个index.html文件,并包含您的 JavaScript 文件,确保首先加载 Vue,最后加载您的应用程序的 JavaScript。添加一个容器来形成我们商店的视图:

<!DOCTYPE html>
<html>
<head>
  <title>Vue Shop</title>
</head>
<body>
  <div id="app"></div>

  <!-- Libraries -->
  <script type="text/javascript" src="js/libs/vue.js"></script>
  <script type="text/javascript" src="js/libs/vuex.js"></script>
  <script type="text/javascript" src="js/libs/router.js"></script>

  <!-- Components -->
  <script src="js/components/ListCategories.js"></script>
  <script src="js/components/ListProducts.js"></script>
  <script src="js/components/ListPurchases.js"></script>
  <script src="js/components/ProductFiltering.js"></script>

  <!-- Views -->
  <script src="js/views/PageNotFound.js"></script>
  <script src="js/views/CategoryPage.js"></script>
  <script src="js/views/HomePage.js"></script>
  <script src="js/views/OrderBasket.js"></script>
  <script src="js/views/OrderCheckout.js"></script>
  <script src="js/views/OrderConfirmation.js"></script>
  <script src="js/views/ProductPage.js"></script>

  <!-- App -->
  <script type="text/javascript" src="js/app.js"></script>
</body>
</html>

确保首先加载PageNotFound组件,因为我们将在其他组件中使用它,并将其指定为我们的 404 页面。

在每个文件中,通过声明变量或使用Vue.component来初始化组件的类型。对于视图,还要添加一个name属性,以便以后调试时使用。

例如,位于js/components/文件夹中的所有文件应该像下面这样初始化。确保这些组件是小写的,并且使用连字符分隔:

Vue.component('list-products', {

});

而位于js/views中的路由和视图组件应该如下所示:

const OrderConfirmation = {
name: 'OrderConfirmation'
};

最后一步是初始化我们的 Vuex 存储、Vue-router 和 Vue 应用程序。打开app.js并初始化这些库:

const store = new Vuex.Store({});

const router = new VueRouter({});

new Vue({
  el: '#app',

  store,
  router
});

准备好 Vue 组件和路由,初始化我们的存储、路由和应用程序后,让我们来看看如何设置服务器(如果需要)并加载数据。

服务器设置

在我们的商店中,我们将在页面加载时加载一个产品的 CSV 文件。这将模拟从数据库或 API 中收集库存和产品数据的过程,这是在线商店与实体店可能需要处理的事情。

与本书前面的 Dropbox 应用程序类似,我们将加载外部数据并将其保存到 Vuex 存储中。然而,我们将面临一个问题,即在通过 JavaScript 加载资源时,浏览器要求请求的文件的协议必须是 HTTP、HTTPS 或 CORS 请求。

这意味着我们无法使用我们在 Dropbox API 中使用的fetch()技术来加载本地文件,因为在浏览器中查看我们的应用程序时,我们是通过file://协议加载本地资源的。

我们可以通过几种不同的方式解决这个问题-你选择哪种方式取决于你的情况。我们将加载一个 CSV 文件,并使用两个插件将其转换为可用的 JSON 对象。你有三个选项:

  1. 将文件存储在本地

  2. 使用远程服务器或

  3. 使用本地服务器

让我们逐个讨论每个选项的优缺点。

将文件存储在本地

第一种选择是将 CSV 适当地转换为 JSON,然后将输出保存在文件中。您需要将其分配给文件中的变量,并在加载库之前加载 JSON。一个示例可能是创建一个data.json并将其更新为分配给变量:

const products = {...}

然后可以在您的 HTML 中加载 JSON 文件:

<script type="text/javascript" src="data.json"></script>

然后,在您的app.js中可以使用products变量。

优点:

  • 代码负载较少

  • 无需加载处理 CSV 所需的额外文件

  • 不需要额外的步骤

缺点:

  • 无法模拟真实世界

  • 如果要更新 CSV 数据,需要进行转换、保存并分配给变量

使用远程服务器

另一个选项是将文件上传到远程现有服务器并在那里开发您的应用程序。

优点:

  • 模拟真实世界中加载 CSV 的开发

  • 可以在任何地方、任何机器上开发

缺点:

  • 可能会很慢

  • 需要连接到互联网

  • 需要设置部署过程或在实时服务器上编辑文件

设置本地服务器

最后一种选择是在您的计算机上设置本地服务器。有几个小型、轻量级、零配置模块和应用程序,也有更大、更强大的应用程序。如果您的计算机上安装了 npm,则建议使用 Node HTTP 服务器。如果没有,还有其他选项可用。

另一种选择是使用更重量级的应用程序,它可以为您提供 SQL 数据库和运行 PHP 应用程序的能力。这种情况的一个例子是 MAMP 或 XAMPP。

优点:

  • 模拟真实世界中加载 CSV 的开发

  • 快速、即时更新

  • 可以离线开发

缺点:

  • 需要安装软件

  • 可能需要一些配置和/或命令行知识

我们将选择的选项是使用 HTTP 服务器。让我们加载和处理 CSV 文件,以便开始创建我们的商店。

加载 CSV

为了模拟从商店数据库或销售点收集数据,我们的应用程序将从 CSV 加载产品数据。CSV(逗号分隔值)是一种常用的文件格式,用于以数据库样式的方式共享数据。想象一下如何在 Excel 或 Numbers 中布置产品列表:这就是 CSV 文件的格式。

下一步需要下载并包含几个 JavaScript 文件。如果您在“服务器设置”部分选择了选项 1-将文件存储在本地的 JSON 文件中-则可以跳过此步骤。

我们将使用 Shopify 的示例商店数据。这些 CSV 文件有各种产品类型和不同的数据,这将测试我们的 Vue 技能。Shopify 已经将他们的示例数据放在了一个 GitHub 仓库中(github.com/shopifypartners/shopify-product-csvs-and-images)。下载任何你感兴趣的 CSV 文件,并将其保存在你的文件系统中的data/文件夹中。对于这个应用程序,我将使用bicycles.csv文件。

JavaScript 不能本地加载和处理 CSV 文件,除非进行大量的编码和处理逗号分隔和引号封装的值。为了避免本书偏离主题,介绍如何加载、解析和处理 CSV 文件,我们将使用一个库来完成这些繁重的工作。有两个值得注意的库,CSV 解析器(github.com/okfn/csv.js)和 d3(d3js.org/)。CSV 解析器只做 CSV 解析,而 d3 有生成图表和数据可视化的能力。

值得考虑哪个适合你;CSV 解析器只会给你的应用程序增加 3KB 多的负担,而 d3 大约是 60KB。除非你预计以后会添加可视化效果,否则建议你选择更小的库-尤其是它们执行相同的功能。然而,我们将为两个库运行示例。

我们希望在应用程序加载时加载我们的产品数据,这样我们的组件在需要数据时就可以加载和解析 CSV。因此,我们将在 Vue 的created()方法中加载数据。

使用 d3 加载 CSV

这两个插件以非常相似的方式加载数据,但返回的数据有所不同-然而,我们将在加载数据后处理这个问题。

加载 d3 库-如果你想尝试一下,你可以使用托管的版本:

<script src="https://d3js.org/d3.v4.min.js"></script>

使用 d3,我们使用d3对象上的csv()函数,它接受一个参数-CSV 文件的路径。将created()函数添加到你的 Vue 实例中,并初始化 CSV 加载器:

new Vue({
  el: '#app',

  store,
  router,

  created() {
    d3.csv('./data/csv-files/bicycles.csv', (error, data) => {
 console.log(data);
 });
  }
});

请记住,文件的路径是相对于包含 JavaScript 文件的 HTML 文件的路径-在这种情况下是index.html

在浏览器中打开文件不会显示任何输出。然而,如果你打开 Javascript 控制台并展开输出的对象,你会看到类似于这样的内容:

这将以key: value的格式为每个产品提供所有可用属性的详细信息。这使我们可以使用每个产品上的一致的key来访问每个value。例如,如果我们想要获取上面产品的15mm-combo-wrench,我们可以使用Handle键。稍后将详细介绍这个。

使用 CSV 解析器加载 CSV 文件

CSV 解析器的工作方式略有不同,它可以接受许多不同的参数,并且库包含几种不同的方法和函数。数据输出也是以不同的格式,以表格/CSV 样式的结构返回,包含headersfields对象:

new Vue({
  el: '#app',

  store,
  router,

  created() {
    CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
 console.log(data);
 });
  }
});

这次查看输出将显示一个非常不同的结构,并需要将字段的keyheaders对象的索引进行匹配。

统一 Shopify CSV 数据

在保存和使用 Shopify 数据之前,我们需要统一数据并将其转换为更易处理的状态。如果检查任一库输出的数据,您会注意到每个变体或产品的附加图像都有一个条目,而链接因子是 handle。例如,有大约 12 个 handle 为pure-fix-bar-tape的条目,每个条目都是不同的颜色。理想情况下,我们希望将每个变体分组到同一项下,并将图像显示为一个产品的列表。

Shopify CSV 数据的另一个问题是字段标题的标点符号和语法不适合作为对象键。理想情况下,对象键应该像 URL 的 slug 一样,小写且不包含空格。例如,Variant Inventory Qty理想情况下应该是variant-inventory-qty

为了避免手动处理数据并更新键,我们可以使用一个 Vue 插件来处理加载库的输出,并返回一个格式完全符合我们要求的产品对象。该插件是vue-shopify-products,可以从 unpkg 获取:

https://unpkg.com/vue-shopify-products

下载并将该库包含到您的index.html文件中。下一步是告诉 Vue 使用这个插件 - 在您的app.js文件的顶部,包含以下行:

Vue.use(ShopifyProducts);

这将在 Vue 实例的$formatProducts()上暴露一个新的方法,允许我们传入 CSV 加载库的输出,并获得一个更有用的对象集合:

Vue.use(ShopifyProducts);

const store = new Vuex.Store({});

const router = new VueRouter({});

new Vue({
  el: '#app',

  store,
  router,

  created() {
    CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
      let products = this.$formatProducts(data);
      console.log(products);
    });
  }
});

现在检查输出,可以看到按handle分组的集合,其中变体和图像作为对象:

通过更有效地分组我们的产品,我们可以按需存储和调用。

存储产品

一旦我们检索和格式化了 CSV 数据,我们就可以将内容缓存到 Vuex store 中。这将通过一个简单的 mutation 来完成,该 mutation 接受一个 payload 并将其存储在不进行任何修改的情况下。

在你的 store 中创建一个statemutations对象。在state中添加一个products键作为一个对象,并在mutations对象中创建一个名为products的函数。该 mutation 应该接受两个参数 - state 和 payload:

const store = new Vuex.Store({
  state: {
    products: {}
  },

  mutations: {
    products(state, payload) {

    }
  }
});

更新state.products对象为payload的内容:

const store = new Vuex.Store({
  state: {
    products: {}
  },

  mutations: {
    products(state, payload) {
      state.products = payload;
    }
  }
});

用一个 commit 函数替换主 Vue 实例中的console.log,调用新的 mutation 并传入格式化的产品数据:

new Vue({
  el: '#app',

  store,
  router,

  created() {
    CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
      let products = this.$formatProducts(data);
      this.store.commit('products', products);
    });
  }
});

可以通过直接将$formatProducts函数传递给 store 的commit()函数来减少一些代码,而不是将其存储为一个变量:

new Vue({
  el: '#app',

  store,
  router,

  created() {
    CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
      this.$store.commit('products', this.$formatProducts(data));
    });
  }
});

显示单个产品

现在我们的数据已经存储好了,我们可以开始制作组件并在前端显示内容了。我们将从制作产品视图开始 - 显示产品详情、变体和图片。接下来我们将在第十章中创建分类列表页面,构建电子商务商店 - 浏览产品

创建产品视图的第一步是创建路由,以允许通过 URL 显示组件。回顾一下本章开头的笔记,产品组件将加载在/product/:slug路径上。

在你的 Vue-router 中创建一个routes数组,指定路径和组件:

const router = new VueRouter({
  routes: [
 {
      path: '/product/:slug', 
      component: ProductPage
    }
 ]
});

通过解释products对象的布局,我们可以开始理解路由和产品之间的关联。我们将把产品的句柄传递到 URL 中。这将选择具有该句柄的产品并显示数据。这意味着我们不需要显式地将slugproducts关联起来。

页面未找到

创建第一个路由后,我们还应该创建我们的PageNotFound路由,以捕获任何不存在的 URL。当没有与之匹配的产品时,我们也可以重定向到此页面。

我们将以稍微不同的方式创建PageNotFound组件。我们将创建一个/404路径作为一个命名路由,而不是将组件放在*上。这样可以根据需要对多个不同的路由进行别名和重定向。

向路由数组添加一个新对象,将/404作为路径,将PageNotFound组件作为指定的组件。为您的路由添加一个名称,以便在需要时使用,并最后添加一个alias属性,其中包含我们的全局捕获所有路由。

不要忘记将此放在路由数组的末尾,以捕获任何先前未指定的路由。添加新路由时,始终记得将它们放在PageNotFound路由之前:

const router = new VueRouter({
  routes: [
    {
      path: '/product/:slug', 
      component: ProductPage
    },

    {
 path: '/404', 
 alias: '*',
 component: PageNotFound
 }
  ]
});

为您的PageNotFound组件添加一个模板。现在,给它一些基本内容 - 一旦我们设置好应用程序的其余部分,我们可以稍后改进它:

const PageNotFound = {
  name: 'PageNotFound',
  template: `<div>
 <h1>404 Page Not Found</h1>
 <p>Head back to the <router-link to="/">home page</router-link> and start again.</p>
 </div>`
};

注意内容中使用的路由链接。让我们启动应用程序的最后一件事是在我们的应用程序中添加<router-view>元素。转到视图,并将其包含在应用程序空间中:

<div id="app">
  <router-view></router-view>
</div> 

在浏览器中加载应用程序,不要忘记在需要时启动 HTTP 服务器。首先,您应该看到PageNotFound组件的内容。导航到以下产品 URL 应该导致 JavaScript 错误,而不是404页面。这表明路由正确地捕获了 URL,但错误是因为我们的ProductPage组件不包含模板:

#/product/15mm-combo-wrench

如果您看到PageNotFound组件,请检查您的路由代码,因为这意味着ProductPage路由没有被捕获。

选择正确的产品

设置初始路由后,我们现在可以继续加载所需的产品并显示存储中的信息。打开views/Product.js并创建一个模板键。首先,创建一个简单的<div>容器,显示产品的标题:

const ProductPage = {
  name: 'ProductPage',
  template: `<div>{{ product.title }}</div>`
};

在浏览器中查看此内容将立即引发 JavaScript 错误,因为 Vue 期望product变量是一个对象 - 但由于我们尚未声明它,它目前是未定义的。尽管此问题的修复目前似乎非常简单,但我们需要考虑产品尚未定义的情况。

我们的商店应用程序异步加载数据 CSV。这意味着在产品加载时,应用程序的其余部分不会停止执行。总体而言,这增加了我们应用程序的速度,一旦我们有了产品,我们就可以开始操作和显示列表,而不需要等待应用程序的其余部分启动。

因此,有可能用户在没有加载产品列表的情况下访问产品详情页面,无论是通过共享的链接还是搜索结果。为了防止应用程序在没有完全初始化的情况下尝试显示产品数据,可以在模板中添加一个条件属性,检查产品变量是否存在,然后再尝试显示其任何属性。

在加载产品数据时,可以确保产品变量设置为false,直到所有内容都加载完成。在模板中的包含元素上添加v-if属性:

const ProductPage = {
  name: 'ProductPage',
  template: `<div v-if="product">{{ product.title }}</div>`
};

现在我们可以从存储中加载正确的产品并将其赋值给一个变量。

创建一个带有product()函数的computed对象。在函数内部,创建一个空的变量来存储产品,并在之后返回它。现在默认返回false,这意味着我们的模板不会生成<div>标签:

const ProductPage = {
  name: 'ProductPage',
  template: `<div v-if="product">{{ product.title }}</div>`,

  computed: {
    product() {
 let product;

 return product;
 }
  }
};

由于我们有格式良好的产品存储和可在Product组件中使用的slug变量的帮助,选择产品现在变得相当简单。存储中的products对象以句柄作为键,以product details对象作为值进行格式化。有了这个想法,我们可以使用方括号格式来选择所需的产品。例如:

products[handle]

使用路由器的params对象,从存储中加载所需的产品并将其赋值给product变量以返回:

const ProductPage = {
  name: 'ProductPage',
  template: `<div v-if="product">{{ product.title }}</div>`,

  computed: {
    product() {
      let product;

      product = this.$store.state.products[this.$route.params.slug];

      return product;
    }
  }
};

我们不直接赋值给product的原因是我们可以添加一些条件语句。为了确保只有在存储中有可用数据时才加载产品,我们可以添加一个if()语句来确保产品对象有可用的键;换句话说,是否已加载数据?

添加一个if语句来检查存储产品键的长度。如果它们存在,则将存储中的数据赋值给product变量以返回。

const ProductPage = {
  name: 'ProductPage',
  template: `<div v-if="product">{{ product.title }}</div>`,

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {
        product = this.$store.state.products[this.$route.params.slug];
      }

      return product;
    }
  }
};

现在在浏览器中查看应用程序,一旦数据加载完成,您将看到产品的标题。这应该只需要一瞬间就能加载完成,并且应该由我们的if语句优雅地处理。

在继续显示所有产品数据之前,我们需要处理 URL 中不存在产品句柄的情况。因为我们的ProductPage路由会捕捉 URL 中/product之后的任何内容,所以无法使用PageNotFound通配符路径 - 因为我们的ProductPage组件正在加载数据并确定产品是否存在。

捕捉找不到的产品

为了在产品不可用时显示PageNotFound页面,我们将使用我们的ProductPage组件加载组件并有条件地显示它。

为了做到这一点,我们需要注册组件,以便在模板中使用它。我们需要注册它,因为我们的PageNotFound组件当前是一个对象,而不是一个 Vue 组件(例如,当我们使用Vue.component时)。

ProductPage组件中添加一个components对象,并包含PageNotFound

const ProductPage = {
  name: 'ProductPage',

  template: `<div v-if="product"><h1>{{ product.title }}</h1></div>`,

 components: {
 PageNotFound
 },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {
        product = this.$store.state.products[this.$route.params.slug];
      }

      return product;
    }
  }
};

现在,我们有了一个新的 HTML 元素可以在模板中使用,即<page-not-found>。在现有的<div>之后,在模板中添加此元素。由于我们的模板需要一个根元素,所以将它们都包装在一个额外的容器中:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product"><h1>{{ product.title }}</h1></div>
    <page-not-found></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },
  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {
        product = this.$store.state.products[this.$route.params.slug];
      }

      return product;
    }
  }
};

在浏览器中查看时,将呈现404页面模板,一旦数据加载完成,就会显示在其上方的产品标题。现在我们需要更新组件,只有在没有数据可显示时才显示PageNotFound组件。我们可以使用现有的产品变量和v-if属性,如果为 false,则显示错误消息,如下所示:

<page-not-found v-if="!product"></page-not-found>

然而,这意味着如果用户在产品数据加载之前访问产品页面,他们会先看到 404 信息,然后再被替换为产品信息。这不是一个很好的用户体验,所以我们应该只在确定产品数据已加载并且没有匹配项时显示错误。

为了解决这个问题,我们将创建一个新的变量来确定组件是否显示。在ProductPage组件中创建一个数据函数,返回一个键为productNotFound的对象,将其设置为 false。在<page-not-found>元素中添加一个v-if条件,检查新的productNotFound变量:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product"><h1>{{ product.title }}</h1></div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

 data() {
 return {
 productNotFound: false
 }
 },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {
        product = this.$store.state.products[this.$route.params.slug];
      }

      return product;
    }
  }
};

最后一步是将变量设置为true,如果产品不存在。由于我们只想在数据加载完成后执行此操作,因此将代码添加到$store.state.products检查中。我们已经将数据分配给了product变量,因此我们可以添加一个检查以查看此变量是否存在-如果不存在,则更改我们的productNotFound变量的极性:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product"><h1>{{ product.title }}</h1></div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {
        product = this.$store.state.products[this.$route.params.slug];

 if(!product) {
 this.productNotFound = true;
 }
      }

      return product;
    }
  }
};

尝试在 URL 末尾输入一个错误的字符串-您应该看到我们现在熟悉的404错误页面。

显示产品信息

有了我们的产品加载、过滤和错误捕捉,我们可以继续显示我们所需的产品信息。每个产品可能包含一个或多个图像,一个或多个变体以及任何组合之间的任何组合-因此我们需要确保我们为每种情况提供支持。

return之前添加一个console.log(product)以查看我们可用的数据:

product() {
  let product;

  if(Object.keys(this.$store.state.products).length) {
    product = this.$store.state.products[this.$route.params.slug];
    if(!product) {
      this.productNotFound = true;
    }
  }

  console.log(product);
  return product;
}

打开 JavaScript 控制台并检查应该存在的对象。熟悉可用的键和值。请注意,images键是一个数组,而variations是一个对象,包含一个字符串和一个进一步的数组。

在处理变体和图像之前-让我们输出简单的内容。我们需要记住的是,我们输出的每个字段可能不会存在于每个产品上-因此最好在必要时将其包装在条件标签中。

从产品详细信息中输出bodytypevendor.title。在供应商标题和类型之前添加它们的描述,但请确保仅在产品详细信息中存在时才呈现该文本:

template: `<div>
  <div v-if="product">
    <h1>{{ product.title }}</h1>
    <div class="meta">
 <span>
 Manufacturer: <strong>{{ product.vendor.title }}</strong>
 </span>
 <span v-if="product.type">
 Category: <strong>{{ product.type }}</strong>
 </span>
 </div>
 {{ product.body }}
  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

请注意,我们可以在类型和供应商之前添加更友好的名称。一旦我们设置好了我们的类别和过滤器,我们就可以将供应商和类型链接到适当的产品列表。

在浏览器中查看此内容将显示将所有 HTML 标签输出为文本-这意味着我们可以在页面上看到它们。如果你回想起我们在本书开头讨论输出类型时,我们需要使用v-html告诉 Vue 将该块呈现为原始 HTML:

template: `<div>
  <div v-if="product">
    <h1>{{ product.title }}</h1>
    <div class="meta">
      <span>
        Manufacturer: <strong>{{ product.vendor.title }}</strong>
      </span>
      <span v-if="product.type">
        Category: <strong>{{ product.type }}</strong>
      </span>
    </div>
    <div v-html="product.body"></div>
  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

产品图像

下一步是输出我们产品的图像。如果您使用的是自行车的 CSV 文件,则可以使用650c-micro-wheelset进行测试-导航到此产品,因为它有四个图像。不要忘记返回到原始产品以检查它是否适用于一个图像。

无论是一个图像还是 100 个图像,images 的值始终是一个数组,因此要显示它们,我们始终需要使用v-for。添加一个新的容器并循环遍历图像。为每个图像添加一个宽度,以免占用整个页面。

images 数组包含每个图像的对象。该对象具有altsource键,可以直接输入到 HTML 中。然而,有些情况下alt值是缺失的 - 如果缺失,将产品标题插入其中:

template: `<div>
  <div v-if="product">

    <div class="images" v-if="product.images.length">
 <template v-for="img in product.images">
 <img 
 :src="img.source" 
 :alt="img.alt || product.title" 
 width="100">
 </template>
 </div> 

    <h1>{{ product.title }}</h1>

    <div class="meta">
      <span>
        Manufacturer: <strong>{{ product.vendor.title }}</strong>
      </span>
      <span v-if="product.type">
        Category: <strong>{{ product.type }}</strong>
      </span>
    </div>

    <div v-html="product.body"></div>

  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

在显示图像的同时,创建一个画廊会是一个不错的补充。商店通常会显示一张大图像,下面是一组缩略图。点击每个缩略图,然后替换主图像,以便用户可以更好地查看更大的图像。让我们重新创建这个功能。我们还需要确保如果只有一个图像,不显示缩略图。

我们通过将图像变量设置为 images 数组中的第一个图像来实现这一点,这是将形成大图像的图像。如果数组中有多个图像,我们将显示缩略图。然后,我们将创建一个点击方法,用选定的图像更新图像变量。

在数据对象中创建一个新变量,并在产品加载完成时使用 images 数组的第一项进行更新。在尝试分配值之前,确保images键实际上是一个项目数组是一个好的做法:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product">
      <div class="images" v-if="product.images.length">
        <template v-for="img in product.images">
          <img 
            :src="img.source" 
            :alt="img.alt || product.title" 
            width="100">
        </template>
      </div> 
      <h1>{{ product.title }}</h1>
      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
      </div>
      <div v-html="product.body"></div>
    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false,
      image: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.$route.params.slug];
        this.image = (product.images.length) ? product.images[0] : false;

        if(!product) {
          this.productNotFound = true;
        }
      }

      console.log(product);
      return product;
    }
  }
};

接下来,在模板中更新现有的图像循环,只有在数组中有多个图像时才显示。还要将第一个图像添加为模板中的主图像 - 不要忘记先检查它是否存在:

template: `<div>
  <div v-if="product">

    <div class="images" v-if="image">
      <div class="main">
        <img 
 :src="image.source" 
 :alt="image.alt || product.title">
      </div>

      <div class="thumbnails" v-if="product.images.length > 1">
        <template v-for="img in product.images">
          <img 
            :src="img.source" 
            :alt="img.alt || product.title" 
            width="100">
        </template>
      </div>
    </div> 

    <h1>{{ product.title }}</h1>

    <div class="meta">
      <span>
        Manufacturer: <strong>{{ product.vendor.title }}</strong>
      </span>
      <span v-if="product.type">
        Category: <strong>{{ product.type }}</strong>
      </span>
    </div>

    <div v-html="product.body"></div>

  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

最后一步是为每个缩略图像添加一个点击处理程序,以在与之交互时更新图像变量。由于图像本身不会具有cursor: pointer的 CSS 属性,因此考虑添加这个属性可能是值得的。

点击处理程序将是一个接受缩略图循环中的每个图像作为参数的方法。点击时,它将简单地使用传递的对象更新图像变量:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product">
      <div class="images" v-if="image">
        <div class="main">
          <img 
            :src="image.source" 
            :alt="image.alt || product.title">
        </div>

        <div class="thumbnails" v-if="product.images.length > 1">
          <template v-for="img in product.images">
            <img 
              :src="img.source" 
              :alt="img.alt || product.title" 
              width="100" 
              @click="updateImage(img)">
          </template>
        </div>
      </div> 

      <h1>{{ product.title }}</h1>

      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
      </div>

      <div v-html="product.body"></div>

    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false,
      image: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.$route.params.slug];
        this.image = (product.images.length) ? product.images[0] : false;

        if(!product) {
          this.productNotFound = true;
        }
      }

      console.log(product);
      return product;
    }
  },

  methods: {
 updateImage(img) {
 this.image = img;
 }
 }
};

在浏览器中加载产品,并尝试点击任何缩略图 - 您应该能够更新主图像。不要忘记在只有一个图像甚至零个图像的产品上验证您的代码,以确保用户不会遇到任何错误。

不要害怕使用空格和添加新行以提高可读性。能够轻松理解您的代码比在文件加载时节省几个字节更重要。在部署到生产环境时,文件应该被压缩,但在开发过程中,空白占据主导地位。

产品变体

对于这个特定的数据集,我们的每个产品至少包含一个变体,但可以包含多个变体。这通常与图像数量相对应,但并不总是相关的。变体可以是颜色或尺寸等。

在我们的Product对象上,我们有两个键将帮助我们显示变体。它们是variationTypes,列出了变体的名称,如尺寸和颜色,以及variationProducts,其中包含所有的变体。variationProducts对象中的每个产品都有一个更进一步的variant对象,列出了所有可变的属性。例如,如果一件夹克有两种颜色,每种颜色有三种尺寸,那么将有六个variationProducts,每个产品都有两个variant属性。

每个产品都将包含至少一个变体,尽管如果只有一个变体,我们可能需要考虑产品页面的用户体验。我们将在表格和下拉菜单中显示产品的变体,这样您就可以体验创建这两个元素。

变体显示表格

在产品模板中创建一个新的容器,用于显示变体。在此容器中,我们可以创建一个表格来显示产品的不同变体。这将通过v-for声明来实现。然而,现在您对功能更加熟悉,我们可以引入一个新的属性。

使用循环键

在 Vue 中使用循环时,建议您使用额外的属性来标识每个项,即:key。这有助于 Vue 在重新排序、排序或过滤时识别数组的元素。使用:key的示例如下:

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

键属性应该是项本身的唯一属性,而不是数组中的项的索引,以帮助 Vue 识别特定的对象。有关使用循环键的更多信息,请参阅官方 Vue 文档

在显示变体时,我们将使用key属性,但使用barcode属性。

在表格中显示变体

在您的变体容器中添加一个表格元素,并循环遍历items数组。现在,显示titlequantityprice。添加一个包含值为“添加到购物篮”的按钮的额外单元格。我们将在第十一章“构建电子商务商店 - 添加结账”中进行配置。不要忘记在价格前面添加$货币符号,因为它目前只是一个“原始”数字。

注意 - 当在模板文字中使用$符号时,JavaScript 将尝试解释它,以及花括号,作为 JavaScript 变量。为了抵消这一点,用反斜杠在货币前面 - 这告诉 JavaScript 下一个字符是字面的,不应以任何其他方式解释:

template: `<div>
  <div v-if="product">
    <div class="images" v-if="image">
      <div class="main">
        <img 
          :src="image.source" 
          :alt="image.alt || product.title">
      </div>

      <div class="thumbnails" v-if="product.images.length > 1">
        <template v-for="img in product.images">
          <img 
            :src="img.source" 
            :alt="img.alt || product.title" 
            width="100" 
            @click="updateImage(img)">
        </template>
      </div>
    </div> 

    <h1>{{ product.title }}</h1>

    <div class="meta">
      <span>
        Manufacturer: <strong>{{ product.vendor.title }}</strong>
      </span>
      <span v-if="product.type">
        Category: <strong>{{ product.type }}</strong>
      </span>
    </div>

    <div class="variations">
 <table>
 <tr v-for="variation in product.variationProducts" :key="variation.barcode">
 <td>{{ variation.quantity }}</td>
 <td>\${{ variation.price }}</td>
 <td><button>Add to basket</button></td>
 </tr>
 </table>
 </div>

    <div v-html="product.body"></div>

  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

尽管我们显示了价格和数量,但我们没有输出变体的实际属性(如颜色)。为了做到这一点,我们需要对变体进行一些处理,使用一个方法。

变体对象包含每个变体类型的子对象,每个类型都有一个名称和一个值。它们还以 slug 转换后的键存储在对象中。有关更多详细信息,请参见以下屏幕截图:

在表格开头添加一个新的单元格,将变体传递给名为variantTitle()的方法:

<div class="variations">
  <table>
    <tr v-for="variation in product.variationProducts" :key="variation.barcode">
      <td>{{ variantTitle(variation) }}</td>
      <td>{{ variation.quantity }}</td>
      <td>\${{ variation.price }}</td>
      <td><button>Add to basket</button></td>
    </tr>
  </table>
</div>

methods对象中创建新的方法:

methods: {
  updateImage(img) {
    this.image = img;
  },

 variantTitle(variation) {

 }
}

现在,我们需要构建一个包含变体标题的字符串,显示所有可用选项。为此,我们将构建一个包含每个类型的数组,然后将它们连接成一个字符串。

variants存储为一个变量,并创建一个空数组。现在我们可以循环遍历variants对象中可用的键,并创建一个输出字符串。如果您决定在字符串中添加 HTML,如下面的示例所示,我们需要更新模板以输出 HTML 而不是原始字符串:

variantTitle(variation) {
  let variants = variation.variant,
 output = [];

 for(let a in variants) {
 output.push(`<b>${variants[a].name}:</b> ${variants[a].value}`);
 } 
}

我们的输出数组将有一个项目,格式如下:

["<b>Color:</b> Alloy", "<b>Size:</b> 49 cm"]

现在我们可以将它们连接在一起,将输出从数组转换为字符串。您可以选择使用的字符、字符串或 HTML 取决于您。现在,使用两边带有空格的/。或者,您可以使用</td><td>标签创建一个新的表格单元格。添加join()函数并更新模板以使用v-html

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product">
      <div class="images" v-if="image">
        <div class="main">
          <img 
            :src="image.source" 
            :alt="image.alt || product.title">
        </div>

        <div class="thumbnails" v-if="product.images.length > 1">
          <template v-for="img in product.images">
            <img 
              :src="img.source" 
              :alt="img.alt || product.title" 
              width="100" 
              @click="updateImage(img)">
          </template>
        </div>
      </div> 

      <h1>{{ product.title }}</h1>

      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
      </div>

      <div class="variations">
        <table>
          <tr v-for="variation in product.variationProducts" :key="variation.barcode">
            <td v-html="variantTitle(variation)"></td>
            <td>{{ variation.quantity }}</td>
            <td>\${{ variation.price }}</td>
            <td><button>Add to basket</button></td>
          </tr>
        </table>
      </div>

      <div v-html="product.body"></div>

    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false,
      image: false
    }
  },

  computed: {
    ...
  },

  methods: {
    updateImage(img) {
      this.image = img;
    },

    variantTitle(variation) {
      let variants = variation.variant,
        output = [];

      for(let a in variants) {
        output.push(`<b>${variants[a].name}:</b> ${variants[a].value}`);
      }

      return output.join(' / ');
    }

  }
};

将点击事件附加到“添加到购物篮”按钮,并在组件上创建一个新的方法。此方法将需要传入variation对象,以便将正确的对象添加到购物篮中。现在,添加一个 JavaScript alert()来确认您是否选择了正确的对象:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product">
      <div class="images" v-if="image">
        <div class="main">
          <img 
            :src="image.source" 
            :alt="image.alt || product.title">
        </div>

        <div class="thumbnails" v-if="product.images.length > 1">
          <template v-for="img in product.images">
            <img 
              :src="img.source" 
              :alt="img.alt || product.title" 
              width="100" 
              @click="updateImage(img)">
          </template>
        </div>
      </div> 

      <h1>{{ product.title }}</h1>

      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
      </div>

      <div class="variations">
        <table>
          <tr v-for="variation in product.variationProducts" :key="variation.barcode">
            <td v-html="variantTitle(variation)"></td>
            <td>{{ variation.quantity }}</td>
            <td>\${{ variation.price }}</td>
            <td><button @click="addToBasket(variation)">Add to basket</button></td>
          </tr>
        </table>
      </div>

      <div v-html="product.body"></div>

    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false,
      image: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.$route.params.slug];
        this.image = (product.images.length) ? product.images[0] : false;

        if(!product) {
          this.productNotFound = true;
        }
      }

      console.log(product);
      return product;
    }
  },

  methods: {
    updateImage(img) {
      this.image = img;
    },

    variantTitle(variation) {
      let variants = variation.variant,
        output = [];

      for(let a in variants) {
        output.push(`<b>${variants[a].name}:</b> ${variants[a].value}`);
      }

      return output.join(' / ');
    },

    addToBasket(variation) {
 alert(`Added to basket: ${this.product.title} - ${this.variantTitle(variation)}`);
 }

  }
};

注意在警告框中使用的模板字面量-这允许我们使用 JavaScript 变量,而无需使用字符串连接技术。现在,点击“添加到购物篮”按钮将生成一个弹出窗口,列出产品的名称和所点击的变体。

在选择框中显示变体

产品页面上更常见的界面模式是在下拉列表或选择框中显示和选择您的变体。

使用选择框时,我们将有一个变体,它要么是默认选择的,要么是用户已经与之交互并专门选择的。因此,当用户更改选择框时,我们可以更改图像,并在产品页面上显示有关变体的其他信息,包括价格和数量。

我们不会依赖于将变体传递给addToBasket方法,因为它将作为产品组件上的一个对象存在。

将您的<table>元素更新为<select>,将<tr>更新为<option>。将按钮移动到此元素之外,并从click事件中删除参数。从variantTitle()方法中删除任何 HTML。因为它现在在选择框内,所以不需要它。

<div class="variations">
 <select>
 <option 
 v-for="variation in product.variationProducts" 
 :key="variation.barcode" 
 v-html="variantTitle(variation)"
 ></option>
 </select>

  <button @click="addToBasket()">Add to basket</button>
</div>

下一步是创建一个新的变量,可供组件使用。与图片类似,这将在variationProducts数组的第一项完成,并在选择框更改时进行更新。

在数据对象中创建一个名为variation的新项。在数据加载到product计算变量中时填充此变量:

const ProductPage = {
  name: 'ProductPage',

  template: `...`,

  components: {
    PageNotFound
  },

  data() {
    return {
      productNotFound: false,
      image: false,
      variation: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.$route.params.slug];

        this.image = (product.images.length) ? product.images[0] : false;
        this.variation = product.variationProducts[0];

        if(!product) {
          this.productNotFound = true;
        }
      }

      console.log(product);
      return product;
    }
  },

  methods: {
    ...
  }
};

更新addToBasket方法,使用ProductPage组件的variation变量,而不是依赖于参数:

addToBasket() {
  alert(`Added to basket: ${this.product.title} - ${this.variantTitle(this.variation)}`);
}

尝试点击“添加到购物篮”按钮-它应该添加第一个变体,而不管在下拉列表中选择了什么。为了在更改时更新变量,我们可以将variations变量绑定到选择框上-就像我们在本书开始时对文本框进行过滤一样。

select元素上添加v-model属性。当选择时,我们还需要告诉 Vue 要绑定到此变量的内容。默认情况下,它将使用<option>的内容,即我们当前的自定义变体标题。但是,我们希望绑定整个variation对象。在<option>元素上添加一个:value属性:

<div class="variations">
  <select v-model="variation">
    <option 
      v-for="variation in product.variationProducts" 
      :key="variation.barcode" 
      :value="variation"
      v-html="variantTitle(variation)"
    ></option>
  </select>

  <button @click="addToBasket()">Add to basket</button>
</div>

更改选择框并单击“添加到购物篮”按钮现在将生成正确的变体。这种方法使我们在表格中显示变体更加灵活。

它允许我们在产品的其他位置显示变体数据。尝试在产品标题旁边添加价格,并在meta容器中显示数量:

template: `<div>
  <div v-if="product">
    <div class="images" v-if="image">
      <div class="main">
        <img 
          :src="image.source" 
          :alt="image.alt || product.title">
      </div>

      <div class="thumbnails" v-if="product.images.length > 1">
        <template v-for="img in product.images">
          <img 
            :src="img.source" 
            :alt="img.alt || product.title" 
            width="100" 
            @click="updateImage(img)">
        </template>
      </div>
    </div> 

    <h1>{{ product.title }} - \${{ variation.price }}</h1>

    <div class="meta">
      <span>
        Manufacturer: <strong>{{ product.vendor.title }}</strong>
      </span>
      <span v-if="product.type">
        Category: <strong>{{ product.type }}</strong>
      </span>
      <span>
 Quantity: <strong>{{ variation.quantity }}</strong>
 </span>
    </div>

    <div class="variations">
      <select v-model="variation">
        <option 
          v-for="variation in product.variationProducts" 
          :key="variation.barcode" 
          :value="variation"
          v-html="variantTitle(variation)"
        ></option>
      </select>

      <button @click="addToBasket()">Add to basket</button>
    </div>

    <div v-html="product.body"></div>

  </div>
  <page-not-found v-if="productNotFound"></page-not-found>
</div>`,

这两个新属性将在更改变体时更新。如果有选择的变体,则还可以更新图像。为此,请在组件中添加一个watch对象,该对象监视变体变量。更新后,我们可以检查变体是否有图像,如果有,则使用此属性更新图像变量:

const ProductPage = {
  name: 'ProductPage',

  template: `...`,

  components: {
    ...
  },

  data() {
    ...
  },

  computed: {
    ...
  },

 watch: {
 variation(v) {
 if(v.hasOwnProperty('image')) {
 this.updateImage(v.image);
 }
 }
 },

  methods: {
    ...
  }
};

在使用watch时,函数将新项作为第一个参数传递。我们可以使用此参数来收集图像信息,而不是引用组件上的参数。

我们可以进行的另一个改进是,如果变体缺货,则禁用“添加到购物篮”按钮并在下拉菜单中添加注释。此信息从变体的quantity键中获取。

检查数量,如果小于 1,则在选择框中显示缺货消息,并使用disabled HTML 属性禁用“添加到购物篮”按钮。我们还可以更新按钮的值:

template: `<div>
    <div v-if="product">
      <div class="images" v-if="image">
        <div class="main">
          <img 
            :src="image.source" 
            :alt="image.alt || product.title">
        </div>

        <div class="thumbnails" v-if="product.images.length > 1">
          <template v-for="img in product.images">
            <img 
              :src="img.source" 
              :alt="img.alt || product.title" 
              width="100" 
              @click="updateImage(img)">
          </template>
        </div>
      </div> 

      <h1>{{ product.title }} - \${{ variation.price }}</h1>

      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
        <span>
          Quantity: <strong>{{ variation.quantity }}</strong>
        </span>
      </div>

      <div class="variations">
        <select v-model="variation">
          <option 
            v-for="variation in product.variationProducts" 
            :key="variation.barcode" 
            :value="variation"
            v-html="variantTitle(variation) + ((!variation.quantity) ? ' - out of stock' : '')"
          ></option>
        </select>

        <button @click="addToBasket()" :disabled="!variation.quantity">
          {{ (variation.quantity) ? 'Add to basket' : 'Out of stock' }}
        </button>
      </div>

      <div v-html="product.body"></div>

    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

如果使用bicycles.csv数据集,则 Keirin Pro Track Frameset 产品(/#/product/keirin-pro-track-frame)包含几种变体,其中一些没有库存。这样可以测试“缺货”功能以及图像更改。

我们可以对产品页面进行的另一项操作是仅在存在多个变体时显示下拉菜单。一个只有一个变体的产品的示例是 15 mm Combo 扳手(#/product/15mm-combo-wrench)。在这种情况下,显示<select>框没有意义。由于我们在加载时在Product组件上设置了variation变量,因此我们不依赖于选择来最初设置变量。因此,当只有一个备选产品时,我们可以使用v-if=""完全删除选择框。

与图像一样,检查数组的长度是否大于 1,这次是variationProducts数组:

<div class="variations">
  <select v-model="variation" v-if="product.variationProducts.length > 1">
    <option 
      v-for="variation in product.variationProducts" 
      :key="variation.barcode" 
      :value="variation"
      v-html="variantTitle(variation) + ((!variation.quantity) ? ' - out of stock' : '')"
    ></option>
  </select>

  <button @click="addToBasket()" :disabled="!variation.quantity">
    {{ (variation.quantity) ? 'Add to basket' : 'Out of stock' }}
  </button>
</div>

通过在不需要时删除元素,我们现在有了一个更简洁的界面。

在切换 URL 时更新产品详细信息

在浏览不同的产品 URL 以检查变体时,您可能已经注意到点击后退和前进不会更新页面上的产品数据。

这是因为Vue-router意识到在页面之间使用相同的组件,所以它不会销毁和创建一个新实例,而是重用组件。这样做的缺点是数据不会更新;我们需要触发一个函数来包含新的产品数据。好处是代码更高效。

为了告诉 Vue 检索新数据,我们需要创建一个watch函数;而不是观察一个变量,我们将观察$route变量。当它更新时,我们可以加载新数据。

在数据实例的slug中创建一个新变量,并将默认值设置为路由参数。更新product计算函数以使用此变量而不是路由:

const ProductPage = {
  name: 'ProductPage',

  template: `...`,

  components: {
    PageNotFound
  },

  data() {
    return {
      slug: this.$route.params.slug,
      productNotFound: false,
      image: false,
      variation: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.slug];

        this.image = (product.images.length) ? product.images[0] : false;
        this.variation = product.variationProducts[0];

        if(!product) {
          this.productNotFound = true;
        }
      }

      console.log(product);
      return product;
    }
  },

  watch: {
    ...
  },

  methods: {
    ...
  }
};

现在我们可以创建一个watch函数,监视$route变量。当它改变时,我们可以更新slug变量,从而更新显示的数据。

在观察路由时,函数有两个参数传递给它:tofromto变量包含有关我们要去的路由的所有内容,包括参数和使用的组件。from变量包含有关当前路由的所有内容。

通过在路由更改时将slug变量更新为新参数,我们强制组件使用来自存储的新数据重新绘制:

const ProductPage = {
  name: 'ProductPage',

  template: `<div>
    <div v-if="product">
      <div class="images" v-if="image">
        <div class="main">
          <img 
            :src="image.source" 
            :alt="image.alt || product.title">
        </div>

        <div class="thumbnails" v-if="product.images.length > 1">
          <template v-for="img in product.images">
            <img 
              :src="img.source" 
              :alt="img.alt || product.title" 
              width="100" 
              @click="updateImage(img)">
          </template>
        </div>
      </div> 

      <h1>{{ product.title }} - \${{ variation.price }}</h1>

      <div class="meta">
        <span>
          Manufacturer: <strong>{{ product.vendor.title }}</strong>
        </span>
        <span v-if="product.type">
          Category: <strong>{{ product.type }}</strong>
        </span>
        <span>
          Quantity: <strong>{{ variation.quantity }}</strong>
        </span>
      </div>

      <div class="variations">
        <select v-model="variation" v-if="product.variationProducts.length > 1">
          <option 
            v-for="variation in product.variationProducts" 
            :key="variation.barcode" 
            :value="variation"
            v-html="variantTitle(variation) + ((!variation.quantity) ? ' - out of stock' : '')"
          ></option>
        </select>

        <button @click="addToBasket()" :disabled="!variation.quantity">
          {{ (variation.quantity) ? 'Add to basket' : 'Out of stock' }}
        </button>
      </div>

      <div v-html="product.body"></div>

    </div>
    <page-not-found v-if="productNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  data() {
    return {
      slug: this.$route.params.slug,
      productNotFound: false,
      image: false,
      variation: false
    }
  },

  computed: {
    product() {
      let product;

      if(Object.keys(this.$store.state.products).length) {

        product = this.$store.state.products[this.slug];

        this.image = (product.images.length) ? product.images[0] : false;
        this.variation = product.variationProducts[0];

        if(!product) {
          this.productNotFound = true;
        }
      }

      return product;
    }
  },

  watch: {
    variation(v) {
      if(v.hasOwnProperty('image')) {
        this.updateImage(v.image);
      }
    },

    '$route'(to) {
 this.slug = to.params.slug;
 }
  },

  methods: {
    updateImage(img) {
      this.image = img;
    },

    variantTitle(variation) {
      let variants = variation.variant,
        output = [];

      for(let a in variants) {
        output.push(`${variants[a].name}: ${variants[a].value}`);
      }

      return output.join(' / ');
    },

    addToBasket() {
      alert(`Added to basket: ${this.product.title} - ${this.variantTitle(this.variation)}`);
    }

  }
};

完成产品页面后,我们可以继续创建一个类别列表,包括typevendor变量。还要删除代码中的任何console.log()调用,以保持代码整洁。

总结

本章涵盖了很多内容。我们将产品的 CSV 文件加载并存储到 Vuex 存储中。从那里,我们创建了一个产品详细页面,该页面使用 URL 中的动态变量加载特定产品。我们创建了一个产品详细视图,允许用户浏览图库并从下拉列表中选择变体。如果变体有关联的图像,主图像会更新。

在第十章《构建电子商务商店-浏览产品》中,

我们将创建一个分类页面,创建过滤和排序功能,帮助用户找到他们想要的产品。

第十章:构建电子商务商店-浏览产品

在第九章中,我们将产品数据加载到 Vuex 存储中,并创建了一个产品详细页面,用户可以在该页面查看产品及其变体。在查看产品详细页面时,用户可以从下拉菜单中更改变体,价格和其他详细信息将更新。

在本章中,我们将:

  • 创建一个具有特定产品的主页列表页面

  • 创建一个具有可重用组件的类别页面

  • 创建一个排序机制

  • 动态创建过滤器,并允许用户过滤产品

列出产品

在创建任何过滤、精选列表、排序组件和功能之前,我们需要创建一个基本的产品列表-首先显示所有产品,然后我们可以创建一个分页组件,然后在整个应用程序中重复使用它。

添加一个新的路由

让我们向我们的routes数组添加一个新的路由。现在,我们将在HomePage组件上工作,它将有一个/路由。确保将其添加到routes数组的顶部,这样它就不会被其他组件覆盖:

const router = new VueRouter({
  routes: [
 {
 path: '/',
 name: 'Home',
 component: HomePage
 },
    {
      path: '/product/:slug', 
      component: ProductPage
    },

    {
      path: '/404', 
      alias: '*',
      component: PageNotFound
    }
  ]
});

HomePage组件内部,创建一个新的computed属性,并从store中收集所有产品。在模板中显示任何内容之前,请确保产品已加载。使用以下代码填充HomePage组件:

const HomePage = {
  name: 'HomePage',

  template: `<div v-if="products"></div>`,

  computed: {
 products() {
 return this.$store.state.products;
 }
 }
};

循环遍历产品

在查看任何商店的类别列表时,显示的数据往往具有重复的主题。通常包括图像、标题、价格和制造商。

在模板中添加一个有序列表-由于产品将按顺序排列,将它们放在有序列表中在语义上是有意义的。在<ol>中,添加一个v-for循环遍历产品,并显示每个产品的标题,如下所示。在继续显示之前,确保product变量存在也是一个好习惯:

template: `<div v-if="products">
  <ol>
 <li v-for="product in products" v-if="product">
 <h3>{{ product.title }}</h3>
 </li>
 </ol>
</div>`,

在浏览器中查看页面时,您可能会注意到产品列表非常长。为所有这些产品加载图像将对用户的计算机造成巨大负担,并且会用那么多产品来压倒用户。在向我们的模板添加更多信息(如价格和图像)之前,我们将查看对产品进行分页,允许以更可管理的方式访问数据。

创建分页

创建分页,最初似乎相当简单——只需要返回一定数量的产品即可。然而,如果我们希望使我们的分页与产品列表交互并对其进行响应,它就需要更加先进一些。我们需要构建我们的分页能够处理不同长度的产品——在产品列表被过滤为较少产品的情况下。

计算数值

创建分页组件和显示正确产品的算术依赖于四个主要变量:

  • 每页项目数:通常由用户设置;然而,我们将首先使用一个固定的数值 12

  • 总项目数:这是要显示的产品总数

  • 页数:这可以通过将产品数除以每页项目数来计算得到

  • 当前页码:这个与其他变量结合起来,将允许我们返回我们需要的确切产品

通过这些数字,我们可以计算出我们分页所需的一切。这包括要显示的产品、是否显示下一页/上一页链接,以及如果需要的话,跳转到不同链接的组件。

在继续之前,我们将把我们的products对象转换成一个数组。这样我们就可以在其上使用 split 方法,从而返回一个特定的产品列表。这也意味着我们可以很容易地计算出总项目数。

更新你的products计算函数,将其返回一个数组而不是一个对象。这可以通过使用map()函数来实现——这是一个 ES2015 中用来替代简单for循环的函数。这个函数现在返回一个包含产品对象的数组:

products() {
  let products = this.$store.state.products;
 return Object.keys(products).map(key => products[key]);
},

在计算对象中创建一个名为pagination的新函数。这个函数将返回一个包含有关我们分页的各种数据的对象,例如总页数。这将允许我们创建一个产品列表并更新导航组件。只有在我们的products变量有数据时才需要返回这个对象。下面的代码片段显示了这个函数:

computed: {
  products() {
    let products = this.$store.state.products;
    return Object.keys(products).map(key => products[key]);
  },

  pagination() {
 if(this.products) {

 return {

 }
 }
 }
},

现在我们需要跟踪两个变量——每页项目数和当前页码。在你的HomePage组件上创建一个data函数并存储这两个变量。我们将在稍后给用户更新perPage变量的能力。下面的代码部分显示了我们的data函数:

const HomePage = {
  name: 'HomePage',

  template: `...`,

 data() {
 return {
 perPage: 12, 
 currentPage: 1
 }
 },

  computed: {
    ...
  }
};

你可能想知道何时在组件上使用本地数据,何时将信息存储在 Vuex 存储中。这完全取决于您将在何处使用数据以及将对其进行何种操作。一般规则是,如果只有一个组件使用数据并对其进行操作,则使用本地的data()函数。然而,如果有多个组件将与变量进行交互,则将其保存在中央存储中。

回到pagination()计算函数,存储一个变量,其值为products数组的长度。有了这个变量,我们现在可以计算总页数。为了做到这一点,我们将进行以下方程计算:

产品总数 / 每页项目数

一旦我们得到了这个结果,我们需要将其四舍五入到最近的整数。这是因为如果有任何余数,我们需要为其创建一个新页面。

例如,如果您每页显示 12 个项目,并且您有 14 个产品,那么结果将是 1.1666 页-这不是一个有效的页码。将其四舍五入确保我们有两个页面来显示我们的产品。为此,使用Math.ceil() JavaScript 函数。我们还可以将产品的总数添加到输出中。请查看以下代码以使用Math.ceil()函数:

pagination() {
  if(this.products) {
    let totalProducts = this.products.length;

    return {
 totalProducts: totalProducts,
 totalPages: Math.ceil(totalProducts / this.perPage)
    }
  }
}

我们需要做的下一个计算是确定当前页面的产品范围是什么。这有点复杂,因为我们不仅需要确定我们从页码中需要什么,而且数组切片是基于项目索引的-这意味着第一个项目是0

为了确定从哪里获取切片,我们可以使用以下计算:

(当前页码 * 每页项目数) - 每页项目数

最后的减法可能看起来很奇怪,但它意味着在第一页上,结果是 0。这使我们能够计算出我们需要从products数组中切片的索引位置。

举个例子,如果我们在第三页,结果将是 24,这是第三页的起始位置。切片的结束位置是这个结果加上每页的项目数。这种方法的优点是我们可以更新每页的项目数,所有的计算都会更新。

pagination结果中创建一个对象,其中包含这两个结果-这将使我们以后可以轻松访问它们:

pagination() {
  if(this.products) {
    let totalProducts = this.products.length,
      pageFrom = (this.currentPage * this.perPage) - this.perPage;

    return {
      totalProducts: totalProducts,
      totalPages: Math.ceil(totalProducts / this.perPage),
      range: {
 from: pageFrom,
 to: pageFrom + this.perPage
 }
    }
  }
}

显示分页列表

计算出我们的分页属性后,我们现在可以使用起始点和结束点来操作我们的products数组。我们将使用一个方法来截断产品列表,而不是使用硬编码的值或使用另一个计算函数。这样做的好处是可以传递任何产品列表,并且意味着 Vue 不会缓存结果。

在组件中创建一个新的方法对象,其中包含一个名为paginate的新方法。这个方法应该接受一个参数,这个参数将是我们要切片的products数组。在函数内部,我们可以使用之前计算的两个变量来返回正确数量的产品:

methods: {
  paginate(list) {
    return list.slice(
      this.pagination.range.from, 
      this.pagination.range.to
    );
  }
}

更新模板以在循环遍历产品时使用此方法:

template: `<div v-if="products">
  <ol>
    <li v-for="product in paginate(products)" v-if="product">
      <h3>{{ product.title }}</h3>
    </li>
  </ol>
</div>`,

现在我们可以在浏览器中查看它,并注意它返回我们对象中的前 12 个产品。在data对象中更新currentPage变量为 2 或 3 将显示不同的产品列表,具体取决于数量。

为了继续我们对产品列表的语义化方法,当不在第一页时,我们应该更新有序列表的起始位置。这可以使用start的 HTML 属性来完成 - 这允许您指定应该从哪个数字开始有序列表。

使用pagination.range.from变量来设置有序列表的起始点 - 记住在第一页上要添加1,因为它将是0

template: `<div v-if="products">
  <ol :start="pagination.range.from + 1">
    <li v-for="product in paginate(products)" v-if="product">
      <h3>{{ product.title }}</h3>
    </li>
  </ol>
</div>`

现在在代码中递增页面编号时,您会注意到有序列表从每个页面的适当位置开始。

创建分页按钮。

通过代码更新页面编号不够用户友好,所以我们应该添加一些页面来增加和减少页面编号变量。为此,我们将创建一个函数,将currentPage变量更改为其值。这样我们就可以同时用于“下一页”和“上一页”按钮,以及一个带有编号的页面列表(如果需要)。

首先,在您的pagination容器中创建两个按钮。如果我们处于导航的极限位置,我们希望禁用这些按钮 - 例如,当返回时,您不希望能够低于1,并且在向前时超过最大页面数。我们可以通过在按钮上设置disabled属性来实现这一点 - 就像我们在产品详细信息页面上所做的,并将当前页面与这些限制进行比较。

在上一页按钮上添加disabled属性,并检查当前页面是否为第一页。在下一页按钮上,将其与我们的pagination方法的totalPages值进行比较。实现前面提到的属性的代码如下所示:

<button :disabled="currentPage == 1">Previous page</button>
<button :disabled="currentPage == pagination.totalPages">Next page</button>

currentPage变量设置回1,并在浏览器中加载主页。您会注意到上一页按钮被禁用了。如果您更改了currentPage变量,您会注意到按钮会按预期地变为激活或非激活状态。

现在我们需要为按钮创建一个点击方法来更新currentPage。创建一个名为toPage()的新函数。它应该接受一个变量 - 这将直接更新currentPage变量:

methods: {
 toPage(page) {
 this.currentPage = page;
 },

  paginate(list) {
    return list.slice(this.pagination.range.from, this.pagination.range.to);
  }
}

为按钮添加点击处理程序,对于下一页按钮,传递currentPage + 1,对于上一页按钮,传递currentPage - 1

template: `<div v-if="products">
  <button @click="toPage(currentPage - 1)" :disabled="currentPage == 1">Previous page</button>
  <button @click="toPage(currentPage + 1)" :disabled="currentPage == pagination.totalPages">Next page</button>

  <ol :start="pagination.range.from + 1">
    <li v-for="product in paginate(products)" v-if="product">
      <h3>{{ product.title }}</h3>
    </li>
  </ol>
</div>`

现在我们可以在产品之间来回导航了。作为用户界面的一个很好的补充,我们可以使用代码中提到的可用变量来指示页面编号和剩余页面数量:

template: `<div v-if="products">
  <p>
 Page {{ currentPage }} out of {{ pagination.totalPages }}
 </p>
  <button @click="toPage(currentPage - 1)" :disabled="currentPage == 1">Previous page</button>
  <button @click="toPage(currentPage + 1)" :disabled="currentPage == pagination.totalPages">Next page</button>

  <ol :start="pagination.range.from + 1">
    <li v-for="product in paginate(products)" v-if="product">
      <h3>{{ product.title }}</h3>
    </li>
  </ol>
</div>`

在导航时更新 URL

改进用户体验的另一个方法是在页面导航时更新 URL - 这将允许用户分享 URL、将其加入书签并稍后返回。在分页时,页面是一个临时状态,不应该是 URL 的主要终点。相反,我们可以利用 Vue 路由的查询参数。

更新toPage方法,在页面更改时将参数添加到 URL 中。这可以通过使用$router.push来实现,但是,我们需要小心,不要删除可能用于将来的过滤的任何现有参数。这可以通过将路由的当前查询对象与包含page变量的新对象合并来实现:

toPage(page) {
  this.$router.push({
 query: Object.assign({}, this.$route.query, {
 page
 })
 }); 
  this.currentPage = page;
},

在从页面到页面的导航过程中,您会注意到 URL 获取了一个新的参数?page=,其值等于当前页面的名称。然而,按下刷新按钮将不会得到正确的页面结果,而是重新回到第一页。这是因为我们需要将当前的page查询参数传递给我们的HomePage组件中的currentPage变量。

这可以通过created()函数来完成 - 更新变量 - 确保我们首先检查了它的存在。created函数是 Vue 生命周期的一部分,在第四章中有介绍,使用 Dropbox API 获取文件列表

created() {
 if(this.$route.query.page) {
 this.currentPage = parseInt(this.$route.query.page);
 }
}

我们需要确保currentPage变量是一个整数,以帮助我们进行后续的算术运算,因为string不喜欢计算。

创建分页链接

在查看分页产品时,通常最好的做法是有一个截断的页面数字列表,允许用户跳转到多个页面。我们已经有了在页面之间导航的机制 - 这可以扩展它。

作为一个简单的入口点,我们可以通过循环直到达到totalPages值来为每个页面创建一个链接。Vue 允许我们在没有任何 JavaScript 的情况下做到这一点。在组件底部创建一个带有列表的nav元素。使用v-for,并为totalPages变量中的每个项目创建一个page变量:

<nav>
  <ol>
    <li v-for="page in pagination.totalPages">
      <button @click="toPage(page)">{{ page }}</button>
    </li>
  </ol>
</nav>

这将为每个页面创建一个按钮 - 例如,如果总共有 24 个页面,这将创建 24 个链接。这不是期望的效果,因为我们希望在当前页面之前和之后显示几个页面。例如,如果当前页面是 15,页面链接应该是 12、13、14、15、16、17 和 18。这意味着链接较少,对用户来说不那么压倒性。

首先,在data对象中创建一个新变量,用于记录选定页面两侧要显示的页面数量 - 一个好的起始值是三:

data() {
  return {
    perPage: 12, 
    currentPage: 1,
    pageLinksCount: 3
  }
},

接下来,创建一个名为pageLinks的新计算函数。这个函数需要获取当前页面,并计算出比它小三个和比它大三个的页面数字。从那里,我们需要检查较低范围是否小于 1,较高范围是否大于总页数。在继续之前,检查产品数组是否有项目:

pageLinks() {
  if(this.products.length) {
    let negativePoint = parseInt(this.currentPage) - this.pageLinksCount,
      positivePoint = parseInt(this.currentPage) + this.pageLinksCount;

    if(negativePoint < 1) {
      negativePoint = 1;
    }

    if(positivePoint > this.pagination.totalPages) {
      positivePoint = this.pagination.totalPages;
    }

    return pages;
  }
}

最后一步是创建一个数组和一个for循环,从较低范围循环到较高范围。这将创建一个包含最多七个数字的页面范围的数组:

pageLinks() {
  if(this.products.length) {
    let negativePoint = parseInt(this.currentPage) - this.pageLinksCount,
      positivePoint = parseInt(this.currentPage) + this.pageLinksCount,
      pages = [];

    if(negativePoint < 1) {
      negativePoint = 1;
    }

    if(positivePoint > this.pagination.totalPages) {
      positivePoint = this.pagination.totalPages;
    }

    for (var i = negativePoint; i <= positivePoint; i++) {
 pages.push(i)
 }

 return pages;
  }
}

现在,我们可以用新的pageLinks变量替换导航组件中的pagination.totalPages变量,将创建正确数量的链接,如下所示:

<nav>
  <ul>
    <li v-for="page in pageLinks">
      <button @click="toPage(page)">{{ page }}</button>
    </li>
  </ul>
</nav>

然而,在浏览器中查看时,会呈现一些奇怪的行为。虽然会生成正确数量的链接,但点击它们或使用下一个/上一个按钮将导致按钮保持不变 - 即使您导航到超出按钮范围的位置。这是因为计算值被缓存了。我们可以通过两种方式解决这个问题 - 要么将函数移动到method对象中,要么在路由上添加一个watch函数来监听并更新当前页面。

选择第二个选项意味着我们可以确保没有其他结果和输出被缓存并相应地更新。在组件中添加一个watch对象,并将currentPage变量更新为页面查询变量的值。确保它存在,否则默认为 1。watch方法如下所示:

watch: {
  '$route'(to) {
    this.currentPage = parseInt(to.query.page) || 1;
  }
}

这样可以确保在导航到不同页面时,所有计算的变量都会更新。打开您的HomePage组件,并确保所有分页组件按预期工作并更新列表。

更新每页的项目

我们需要创建的最后一个用户界面添加是允许用户更新每页产品数量。为了最初设置这个,我们可以创建一个带有v-model属性的<select>框,直接更新值。这按预期工作,并相应地更新产品列表,如下所示:

template: `<div v-if="products">
  <p>
    Page {{ currentPage }} out of {{ pagination.totalPages }}
  </p>

 Products per page: 
 <select v-model="perPage">
 <option>12</option>
 <option>24</option>
 <option>48</option>
 <option>60</option>
 </select>

  <button @click="toPage(currentPage - 1)" :disabled="currentPage == 1">Previous page</button>
  <button @click="toPage(currentPage + 1)" :disabled="currentPage == pagination.totalPages">Next page</button>

  <ol :start="pagination.range.from + 1">
    <li v-for="product in paginate(products)" v-if="product">
      <h3>{{ product.title }}</h3>
    </li>
  </ol>

  <nav>
    <ul>
      <li v-for="page in pageLinks">
        <button @click="toPage(page)">{{ page }}</button>
      </li>
    </ul>
  </nav>
</div>

这个问题是,如果用户在值改变后处于一个超过可能的页面。例如,如果有 30 个产品,每页 12 个产品,这将创建三个页面。如果用户导航到第三页,然后选择每页 24 个产品,只需要两个页面,第三页将为空。

这可以再次通过一个watch函数解决。当perPage变量更新时,我们可以检查当前页面是否高于totalPages变量。如果是,我们可以将其重定向到最后一页:

watch: {
  '$route'(to) {
    this.currentPage = parseInt(to.query.page);
  },

  perPage() {
 if(this.currentPage > this.pagination.totalPages) {
 this.$router.push({
 query: Object.assign({}, this.$route.query, {
 page: this.pagination.totalPages
 })
 })
 }
 }
}

创建 ListProducts 组件

在继续创建过滤和排序之前,我们需要提取我们的产品列表逻辑并将其模板化到我们的组件中 - 以便我们可以轻松重用它。这个组件应该接受一个名为products的 prop,它应该能够列出和分页。

打开ListProducts.js文件,将代码从HomePage.js文件复制到组件中。将数据对象和复制paginationpageLinks计算函数。将 watch 和 methods 对象以及created()函数从HomePage移动到ListProducts文件中。

更新HomePage模板,使用带有products属性的<list-products>组件,并传入products计算值。相比之下,HomePage组件现在应该更小:

const HomePage = {
  name: 'HomePage',

  template: `<div>
    <list-products :products="products"></list-products>
  </div>`,

  computed: {
    products() {
      let products = this.$store.state.products;
      return Object.keys(products).map(key => products[key]);
    }
  }
};

ListProducts组件中,我们需要添加一个 props 对象,让组件知道要期望什么。这个组件现在很重要。我们还需要添加一些东西到这个组件中,使其更加通用。它们包括:

  • 如果有多个页面,则显示下一页/上一页链接

  • 如果有超过 12 个产品,则显示“每页产品”组件,并且只在比前一步骤中有更多产品时显示每个步骤

  • 只有当pageLinks组件超过我们的pageLinksCount变量时才显示

所有这些添加都已添加到以下组件代码中。我们还删除了不必要的products计算值:

Vue.component('list-products', {
  template: `<div v-if="products">
    <p v-if="pagination.totalPages > 1">
      Page {{ currentPage }} out of {{ pagination.totalPages }}
    </p>

    <div v-if="pagination.totalProducts > 12">
      Products per page: 
      <select v-model="perPage">
        <option>12</option>
        <option>24</option>
        <option v-if="pagination.totalProducts > 24">48</option>
        <option v-if="pagination.totalProducts > 48">60</option>
      </select>
    </div>

    <button 
      @click="toPage(currentPage - 1)" 
      :disabled="currentPage == 1" 
      v-if="pagination.totalPages > 1"
    >
      Previous page
    </button>
    <button 
      @click="toPage(currentPage + 1)" 
      :disabled="currentPage == pagination.totalPages" 
      v-if="pagination.totalPages > 1"
    >
      Next page
    </button>

    <ol :start="pagination.range.from + 1">
      <li v-for="product in paginate(products)" v-if="product">
        <h3>{{ product.title }}</h3>
      </li>
    </ol>

    <nav v-if="pagination.totalPages > pageLinksCount">
      <ul>
        <li v-for="page in pageLinks">
          <button @click="toPage(page)">{{ page }}</button>
        </li>
      </ul>
    </nav>
  </div>`,

 props: {
 products: Array
 },

  data() {
    return {
      perPage: 12, 
      currentPage: 1,
      pageLinksCount: 3
    }
  },

  computed: {
    pagination() {
      if(this.products) {
        let totalProducts = this.products.length,
          pageFrom = (this.currentPage * this.perPage) - this.perPage,
          totalPages = Math.ceil(totalProducts / this.perPage);

        return {
          totalProducts: totalProducts,
          totalPages: Math.ceil(totalProducts / this.perPage),
          range: {
            from: pageFrom,
            to: pageFrom + this.perPage
          }
        }
      }
    },

    pageLinks() {
      if(this.products.length) {
        let negativePoint = this.currentPage - this.pageLinksCount,
          positivePoint = this.currentPage + this.pageLinksCount,
          pages = [];

        if(negativePoint < 1) {
          negativePoint = 1;
        }

        if(positivePoint > this.pagination.totalPages) {
          positivePoint = this.pagination.totalPages;
        }

        for (var i = negativePoint; i <= positivePoint; i++) {
          pages.push(i)
        }

        return pages;
      }
    }
  },

  watch: {
    '$route'(to) {
      this.currentPage = parseInt(to.query.page);
    },
    perPage() {
      if(this.currentPage > this.pagination.totalPages) {
        this.$router.push({
          query: Object.assign({}, this.$route.query, {
            page: this.pagination.totalPages
          })
        })
      }
    }
  },

  created() {
    if(this.$route.query.page) {
      this.currentPage = parseInt(this.$route.query.page);
    }
  },

  methods: {
    toPage(page) {
      this.$router.push({
        query: Object.assign({}, this.$route.query, {
          page
        })
      });

      this.currentPage = page;
    },

    paginate(list) {
      return list.slice(this.pagination.range.from, this.pagination.range.to)
    }
  }
});

您可以通过在HomePage模板中临时截断产品数组来验证您的条件渲染标签是否正常工作-完成后不要忘记删除它:

products() {
  let products = this.$store.state.products;
  return Object.keys(products).map(key => products[key]).slice(1, 10);
}

为主页创建一个精选列表

有了我们的产品列表组件,我们可以继续为主页创建一个精选产品列表,并向产品列表添加更多信息。

在这个例子中,我们将在主页组件上硬编码一个产品句柄数组,我们希望显示这些产品。如果这是在开发中,你会期望这个列表通过内容管理系统或类似方式进行控制。

HomePage组件上创建一个data函数,其中包含一个名为selectedProducts的数组:

data() {
  return {
    selectedProducts: []
  }
},

用产品列表中的几个handles填充数组。尽量选择六个,但如果超过 12 个,请记住它会与我们的组件分页。将您选择的句柄添加到selectedProducts数组中:

data() {
  return {
    selectedProducts: [
      'adjustable-stem',
 'colorful-fixie-lima',
 'fizik-saddle-pak',
 'kenda-tube',
 'oury-grip-set',
 'pure-fix-pedals-with-cages'
    ]
  }
},

使用我们选择的句柄,我们现在可以将产品列表过滤为仅包含在我们的selectedProducts数组中的产品列表。最初的直觉可能是在产品数组上结合使用 JavaScript 的filter()函数和includes()函数:

products() {
  let products = this.$store.state.products;

  products = Object.keys(products).map(key => products[key]);
  products = products.filter(product => this.selectedProducts.includes(product.handle));

  return products;
}

问题在于,尽管它似乎工作正常,但它不遵守所选产品的顺序。过滤函数只是删除不匹配的项目,并按加载的顺序保留剩余的产品。

幸运的是,我们的产品以键/值对的形式保存,键是句柄。利用这一点,我们可以利用产品对象并使用for循环返回一个数组。

在计算函数中创建一个空数组output。遍历selectedProducts数组,找到每个所需的产品并添加到output数组中:

products() {
  let products = this.$store.state.products,
    output = [];

  if(Object.keys(products).length) {
 for(let featured of this.selectedProducts) {
 output.push(products[featured]);
 }
 return output;
 }
}

这将创建相同的产品列表,但这次是按正确的顺序。尝试重新排序、添加和删除项目,以确保您的列表能够相应地做出反应。

显示更多信息

现在我们可以在ListProduct组件中显示更多的产品信息了。正如在本章开头提到的,我们应该显示:

  • 图像

  • 标题

  • 价格

  • 制造商

我们已经显示了标题,并且可以轻松地从产品信息中提取出图像和制造商。不要忘记始终从images数组中检索第一个图像。打开ListProducts.js文件并更新产品以显示这些信息 - 确保在显示之前检查图像是否存在。制造商标题在产品数据的vendor对象下列出:

<ol :start="pagination.range.from + 1">
  <li v-for="product in paginate(products)" v-if="product">
    <img v-if="product.images[0]" :src="product.images[0].source" :alt="product.title" width="120">
    <h3>{{ product.title }}</h3>
    <p>Made by: {{ product.vendor.title }}</p>
  </li>
</ol>

价格会更复杂一些。这是因为产品的每个变体可能有不同的价格,但通常是相同的。如果有不同的价格,我们应该显示最便宜的价格,并在前面加上from

我们需要创建一个循环遍历变体并计算出最低价格的函数,如果有价格范围,还要添加from这个词。为了实现这个目标,我们将遍历变体并建立一个唯一价格的数组 - 如果价格在数组中不存在的话。完成后,我们可以检查数组的长度 - 如果有多个价格,我们可以添加前缀,如果没有,这意味着所有的变体都是相同的价格。

ListProducts组件上创建一个名为productPrice的新方法。这个方法接受一个参数,即变体。在方法内部,创建一个空数组prices

productPrice(variations) {
  let prices = [];
}

遍历变体,如果价格在prices数组中不存在,则将价格添加到prices数组中。创建一个使用includes()函数检查价格是否存在于数组中的for循环:

productPrice(variations) {
  let prices = [];

  for(let variation of variations) {
 if(!prices.includes(variation.price)) {
 prices.push(variation.price);
 }
 }
}

有了价格数组,我们现在可以提取最低的数字并检查是否有多个项目。

要从数组中提取最小的数字,我们可以使用 JavaScript 的Math.min()函数。使用.length属性来检查数组的长度。最后,返回price变量:

productPrice(variations) {
  let prices = [];

  for(let variation of variations) {
    if(!prices.includes(variation.price)) {
      prices.push(variation.price);
    }
  }

 let price = '$' + Math.min(...prices);

 if(prices.length > 1) {
 price = 'From: ' + price;
 }

  return price;
}

将您的productPrice方法添加到模板中,记得将product.variationProducts传递给它。我们需要在模板中添加的最后一件事是一个指向产品的链接:

<ol :start="pagination.range.from + 1">
  <li v-for="product in paginate(products)" v-if="product">
    <router-link :to="'/product/' + product.handle">
      <img v-if="product.images[0]" :src="product.images[0].source" :alt="product.title" width="120">
    </router-link> 
    <h3>
      <router-link :to="'/product/' + product.handle">
        {{ product.title }}
      </router-link>
    </h3>

    <p>Made by: {{ product.vendor.title }}</p>
    <p>Price {{ productPrice(product.variationProducts) }}</p>
  </li>
</ol>

理想情况下,产品链接应该使用命名路由而不是硬编码的链接,以防路由发生变化。给产品路由添加一个名称,并更新to属性以使用该名称。

{
  path: '/product/:slug',
  name: 'Product',
  component: ProductPage
}

更新模板,现在使用路由名称和params对象:

<ol :start="pagination.range.from + 1">
  <li v-for="product in paginate(products)" v-if="product">
    <router-link :to="{name: 'Product', params: {slug: product.handle}}">
      <img v-if="product.images[0]" :src="product.images[0].source" :alt="product.title" width="120">
    </router-link>
    <h3>
      <router-link :to="{name: 'Product', params: {slug: product.handle}}">
        {{ product.title }}
      </router-link>
    </h3>
    <p>Made by: {{ product.vendor.title }}</p>
    <p>Price {{ productPrice(product.variationProducts) }}</p>
  </li>
</ol>

创建类别

如果商店没有可导航的类别,那么它实际上不是一个可用的商店。幸运的是,我们的每个产品都有一个type键,指示它所属的类别。现在我们可以创建一个类别页面,列出该特定类别的产品。

创建类别列表

在我们能够显示特定类别的产品之前,我们首先需要生成一个可用类别的列表。为了提高应用程序的性能,我们还将在每个类别中存储产品的句柄。类别结构将如下所示:

categories = {
  tools: {
    name: 'Tools',
    handle: 'tools',
    products: ['product-handle', 'product-handle'...]
  },
  freewheels: {
    name: 'Freewheels',
    handle: 'freewheels',
    products: ['another-product-handle', 'product'...]
  }
};

创建这样的类别列表意味着我们可以轻松地获取类别内的产品列表,同时可以循环遍历类别并输出titlehandle以创建一个链接到类别的列表。由于我们已经有了这些信息,所以我们将在获取产品列表后创建类别列表。

打开app.js并导航到Vue实例上的created()方法。我们将不再在products存储方法下面创建第二个$store.commit,而是将使用 Vuex 的另一个功能-actions

Actions 允许您在存储库本身中创建函数。Actions 无法直接改变状态-这仍然是 mutations 的工作,但它允许您将多个 mutations 组合在一起,这在这种情况下非常适合我们。如果您想在改变状态之前运行异步操作,例如使用setTimeout JavaScript 函数,那么 Actions 也非常适合。

导航到您的Vuex.Store实例,并在 mutations 之后添加一个新的actions对象。在其中,创建一个名为initializeShop的新函数:

const store = new Vuex.Store({
  state: {
    products: {}
  },

  mutations: {
    products(state, payload) {
      state.products = payload;
    }
  },

 actions: {
 initializeShop() {

 }
 }
});

在动作参数中,第一个参数是存储本身,我们需要使用它来利用突变。有两种方法可以做到这一点,第一种是使用一个变量并在函数内部访问其属性。例如:

actions: {
  initializeShop(store) {
    store.commit('products');
  }
}

然而,使用 ES2015,我们可以使用参数解构并利用我们需要的属性。对于这个动作,我们只需要commit函数,就像这样:

actions: {
  initializeShop({commit}) {
    commit('products');
  }
}

如果我们还想要存储中的状态,我们可以将其添加到花括号中:

actions: {
  initializeShop({state, commit}) {
    commit('products');
    // state.products
  }
}

使用这种“分解”的方法访问属性使我们的代码更清晰,减少了重复。删除state属性,并在花括号后面添加一个标记为products的第二个参数。这将是我们格式化的产品数据。将该变量直接传递给产品的commit函数:

initializeShop({commit}, products) {
  commit('products', products);
}

使用动作与使用mutations一样简单,只是不使用$store.commit,而是使用$store.dispatch。更新您的created方法-不要忘记更改函数名称,并检查您的应用程序是否仍然正常工作:

created() {
  CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
    this.$store.dispatch('initializeShop', this.$formatProducts(data));
  });
}

下一步是为我们的类别创建一个突变。由于我们可能希望独立于我们的产品更新我们的类别-我们应该在mutations中创建第二个函数。它也应该是这个函数遍历产品并创建类别列表。

首先,在状态对象中创建一个名为categories的新属性。这应该是一个默认的对象:

state: {
  products: {},
  categories: {}
}

接下来,创建一个名为categories的新突变。除了状态之外,这应该有一个第二个参数。为了保持一致,将其命名为payload-因为这是 Vuex 所指的:

mutations: {
  products(state, payload) {
    state.products = payload;
  },

 categories(state, payload) {

 }
},

现在是功能的时间。这个突变需要遍历产品。对于每个产品,它需要隔离type。一旦它有了标题和 slug,它就需要检查是否存在具有该 slug 的条目;如果存在,则将产品句柄附加到products数组中,如果不存在-它需要创建一个新数组和详细信息。

创建一个空的categories对象,并循环遍历payload,为产品和类型设置一个变量:

categories(state, payload) {
 let categories = {}; 
 Object.keys(payload).forEach(key => {
 let product = payload[key],
 type = product.type;
 });
}

现在,我们需要检查是否存在具有当前type.handle键的条目。如果不存在,我们需要创建一个新条目。该条目需要具有标题、句柄和一个空的产品数组:

categories(state, payload) {
  let categories = {};

  Object.keys(payload).forEach(key => {
    let product = payload[key],
      type = product.type;

 if(!categories.hasOwnProperty(type.handle)) {
 categories[type.handle] = {
 title: type.title,
 handle: type.handle,
 products: []
 }
 }
  });
}

最后,我们需要将当前产品句柄附加到条目的产品数组中:

categories(state, payload) {
  let categories = {};

  Object.keys(payload).forEach(key => {
    let product = payload[key],
      type = product.type;

    if(!categories.hasOwnProperty(type.handle)) {
      categories[type.handle] = {
        title: type.title,
        handle: type.handle,
        products: []
      }
    }

    categories[type.handle].products.push(product.handle);
  });
}

您可以通过在函数末尾添加console.log来查看categories输出:


categories(state, payload) {
  let categories = {};

  Object.keys(payload).forEach(key => {
    ...
  });

  console.log(categories);
}

将突变添加到initializeShop动作中:

initializeShop({commit}, products) {
  commit('products', products);
  commit('categories', products);
}

在浏览器中查看应用程序时,您将面临一个 JavaScript 错误。这是因为一些产品没有包含我们用来对其进行分类的“类型”。即使解决了 JavaScript 错误,仍然有很多类别被列出。

为了帮助处理类别的数量,并将未分类的产品分组,我们应该创建一个“杂项”类别。这将汇总所有包含两个或更少产品的类别,并将产品分组到它们自己的组中。

创建一个“杂项”类别

我们需要解决的第一个问题是无名称的类别。在循环遍历产品时,如果找不到类型,则应插入一个类别,以便对所有内容进行分类。

categories方法中创建一个新对象,其中包含一个新类别的标题和句柄。对于句柄和变量,称其为“other”。通过将标题称为“杂项”来使其更加用户友好。

let categories = {},
  other = {
 title: 'Miscellaneous',
 handle: 'other'
 };

在循环遍历产品时,我们可以检查type键是否存在,如果不存在,则创建一个other类别并将其附加到其中:

Object.keys(payload).forEach(key => {
  let product = payload[key],
    type = product.hasOwnProperty('type') ? product.type : other;

  if(!categories.hasOwnProperty(type.handle)) {
    categories[type.handle] = {
      title: type.title,
      handle: type.handle,
      products: []
    }
  }

  categories[type.handle].products.push(product.handle);
});

现在查看应用程序将在 JavaScript 控制台中显示所有类别-让您看到有多少类别。

让我们将任何包含两个或更少产品的类别合并到“other”类别中-不要忘记之后删除该类别。在产品循环之后,循环遍历类别,检查可用产品的数量。如果少于三个,则将它们添加到“other”类别中:

Object.keys(categories).forEach(key => {
  let category = categories[key];

  if(category.products.length < 3) {
    categories.other.products = categories.other.products.concat(category.products);
  }
});

然后,我们可以删除刚刚从中窃取产品的类别:

Object.keys(categories).forEach(key => {
  let category = categories[key];

  if(category.products.length < 3) {
    categories.other.products = categories.other.products.concat(category.products);
    delete categories[key];
  }
});

有了这个,我们有了一个更易于管理的类别列表。我们可以做的另一个改进是确保类别按字母顺序排列。这有助于用户更快地找到他们想要的类别。在 JavaScript 中,数组比对象更容易排序,因此我们需要再次循环遍历对象键的数组并对其进行排序。创建一个新对象,并将排序后的类别添加到其中。然后,将其存储在state对象上,以便我们可以使用这些类别:

categories(state, payload) {
  let categories = {},
    other = {
      title: 'Miscellaneous',
      handle: 'other'
    };

  Object.keys(payload).forEach(key => {
    let product = payload[key],
      type = product.hasOwnProperty('type') ? product.type : other;

    if(!categories.hasOwnProperty(type.handle)) {
      categories[type.handle] = {
        title: type.title,
        handle: type.handle,
        products: []
      }
    }

    categories[type.handle].products.push(product.handle);
  });

  Object.keys(categories).forEach(key => {
    let category = categories[key];

    if(category.products.length < 3) {
      categories.other.products =      categories.other.products.concat(category.products);
      delete categories[key];
    }
  });

  let categoriesSorted = {}
 Object.keys(categories).sort().forEach(key => {
 categoriesSorted[key] = categories[key]
 });
 state.categories = categoriesSorted;
}

有了这个,我们现在可以在HomePage模板中添加一个类别列表。为此,我们将创建命名的router-view组件-允许我们将东西放在选定页面的商店侧边栏中。

显示类别

有了我们存储的类别,我们现在可以继续创建我们的ListCategories组件。我们希望在主页的侧边栏和商店类别页面中显示我们的类别导航。由于我们希望在多个位置显示它,我们有几个选项可以选择如何显示它。

我们可以像使用<list-products>组件一样在模板中使用该组件。但是,问题在于如果我们想在侧边栏中显示我们的列表,并且我们的侧边栏需要在整个站点上保持一致,我们将不得不在视图之间复制和粘贴大量的 HTML。

更好的方法是使用命名路由,并在我们的index.html中设置模板。

更新应用程序模板以包含一个<main>和一个<aside>元素。在其中创建一个router-view,将main内部的一个未命名,而将aside元素内部的一个命名为sidebar

<div id="app">
  <main>
    <router-view></router-view>
  </main>
 <aside>
 <router-view name="sidebar"></router-view>
 </aside>
</div>

在我们的路由对象中,我们现在可以为不同的命名视图添加不同的组件。在Home路由上,将component键更改为components,并添加一个对象-指定每个组件及其视图:

{
  path: '/',
  name: 'Home',
  components: {
 default: HomePage,
 sidebar: ListCategories
 }
}

默认情况下,组件将进入未命名的router-view中。这使我们仍然可以使用单数的component键(如果需要的话)。为了正确加载组件到侧边栏视图中,我们需要修改ListCategories组件的初始化方式。不要使用Vue.component,而是像初始化view组件一样进行初始化:

const ListCategories = {
  name: 'ListCategories'

};

现在我们可以继续创建类别列表的模板了。由于我们的类别保存在 store 中,加载和显示它们应该是熟悉的。建议您将类别从状态加载到计算函数中-这样可以使模板代码更清晰,并且在需要以任何方式操作它时更容易适应。

在创建模板之前,我们需要为该类别创建一个路由。回顾一下第九章中的计划,使用 Vue-Router 动态路由加载数据,我们可以看到路由将是/category/:slug - 添加此路由并启用 props,因为我们将在slug中使用它们。确保您已经创建了CategoryPage文件并初始化了组件。

const router = new VueRouter({
  routes: [
    {
      path: '/',
      name: 'Home',
      components: {
        default: HomePage,
        sidebar: ListCategories
      }
    },
    {
 path: '/category/:slug',
 name: 'Category',
 component: CategoryPage,
 props: true
 },
    {
      path: '/product/:slug',
      name: 'Product',
      component: ProductPage
    },

    {
      path: '/404', 
      alias: '*',
      component: PageNotFound
    }
  ]
});

回到我们的ListCategories组件;循环遍历存储的类别,并为每个类别创建一个链接。在每个名称后面用括号显示产品数量:

const ListCategories = {
  name: 'ListCategories',

 template: `<div v-if="categories">
 <ul>
 <li v-for="category in categories">
 <router-link :to="{name: 'Category', params: {slug: category.handle}}">
 {{ category.title }} ({{ category.products.length }})
 </router-link>
 </li>
 </ul>
 </div>`,

 computed: {
 categories() {
 return this.$store.state.categories;
 }
 } 
};

现在我们的类别链接已经显示在主页上,我们可以继续创建一个类别页面。

显示类别中的产品

点击其中一个类别链接(即/#/category/grips)将导航到一个空白页面 - 这要归功于我们的路由。我们需要创建一个模板并设置类别页面以显示产品。作为起点,创建一个类似于产品页面的CategoryPage组件。

创建一个带有空容器和PageNotFound组件的模板。创建一个名为categoryNotFound的数据变量,并确保如果它设置为true,则显示PageNotFound组件。创建一个props对象,允许传递slug属性,并最后创建一个category计算函数。

CategoryPage组件应该如下所示:

const CategoryPage = {
  name: 'CategoryPage',

  template: `<div>
    <div v-if="category"></div>
    <page-not-found v-if="categoryNotFound"></page-not-found>
  </div>`,

  components: {
    PageNotFound
  },

  props: {
    slug: String
  },

  data() {
    return {
      categoryNotFound: false,
    }
  },

  computed: {
    category() {
    }
  }
};

category计算函数内,根据 slug 从存储中加载正确的类别。如果它不在列表中,则将categoryNotFound变量标记为 true - 类似于我们在ProductPage组件中所做的操作:

computed: {
  category() {
    let category;

 if(Object.keys(this.$store.state.categories).length) {

 category = this.$store.state.categories[this.slug];

 if(!category) {
 this.categoryNotFound = true;
 }
 }

 return category;
  }
}

在加载了我们的类别后,我们可以在模板中输出标题:

template: `<div>
  <div v-if="category">
    <h1>{{ category.title }}</h1>
  </div>
  <page-not-found v-if="categoryNotFound"></page-not-found>
</div>`,

现在,我们可以继续在类别页面上显示产品。为此,我们可以使用HomePage组件中的代码,因为我们有完全相同的情况 - 一个产品句柄数组。

创建一个新的computed函数,它接受当前类别产品并像在主页上那样对它们进行处理:

computed: {
  category() {
    ...
  },

  products() {
    if(this.category) {
 let products = this.$store.state.products,
 output = [];

 for(let featured of this.category.products) {
 output.push(products[featured]);
 }

 return output; 
 }
  }
}

在这个函数中,我们不需要检查产品是否存在,因为我们正在检查类别是否存在,只有在数据加载完成时才会返回 true。将组件添加到 HTML 中并传入products变量:

template: `<div>
  <div v-if="category">
    <h1>{{ category.title }}</h1>
    <list-products :products="products"></list-products>
  </div>
  <page-not-found v-if="categoryNotFound"></page-not-found>
</div>`

有了这个,我们就可以为每个类别列出我们的类别产品。

代码优化

完成CategoryPage组件后,我们可以看到它与主页之间有很多相似之处 - 唯一的区别是主页有一个固定的产品数组。为了避免重复,我们可以将这两个组件合并 - 这意味着如果需要,我们只需要更新一个组件。

我们可以通过在识别我们是否在主页时显示它来解决固定数组问题。做法是检查 slug 属性是否有值。如果没有,我们可以假设我们在主页上。

首先,将Home路由更新为指向CategoryPage组件并启用 props。在使用命名视图时,您必须为每个视图启用 props。更新 props 值为一个对象,其中包含每个命名视图,为每个视图启用 props:

{
  path: '/',
  name: 'Home',
  components: {
    default: CategoryPage,
    sidebar: ListCategories
  },
  props: {
 default: true, 
 sidebar: true
 }
}

接下来,在CategoryPagedata函数中创建一个名为categoryHome的新变量。这将是一个对象,其结构与类别对象相同,包含一个products数组,标题和句柄。虽然句柄不会被使用,但遵循惯例是一个好习惯:

data() {
  return {
    categoryNotFound: false,
    categoryHome: {
 title: 'Welcome to the Shop',
 handle: 'home',
 products: [
 'adjustable-stem',
 'fizik-saddle-pak',
 'kenda-tube',
 'colorful-fixie-lima',
 'oury-grip-set',
 'pure-fix-pedals-with-cages'
 ]
 }
  }
}

我们需要做的最后一件事是检查 slug 是否存在。如果不存在,则将我们的新对象分配给计算函数中的类别变量:

category() {
  let category;

  if(Object.keys(this.$store.state.categories).length) {
    if(this.slug) {
 category = this.$store.state.categories[this.slug];
 } else {
 category = this.categoryHome;
 }

    if(!category) {
      this.categoryNotFound = true;
    }
  }

  return category;
}

转到主页并验证您的新组件是否正常工作。如果是,请删除HomePage.js并从index.html中删除它。更新类别路由以在侧边栏中包含类别列表并使用props对象:

{
  path: '/category/:slug',
  name: 'Category',
  components: {
 default: CategoryPage,
 sidebar: ListCategories
 },
  props: {
 default: true, 
 sidebar: true
 }
},

对类别中的产品进行排序

当我们的类别页面显示正确的产品时,现在是时候在ListProducts组件中添加一些排序选项了。在在线商店中查看产品时,通常可以按以下方式对产品进行排序:

  • 标题:升序(A - Z)

  • 标题:降序(Z - A)

  • 价格:升序($1 - $999)

  • 价格:降序($999 - $1)

然而,一旦我们有了这个机制,您可以添加任何您想要的排序标准。

首先,在ListProducts组件中创建一个选择框,其中包含上述每个值。添加一个额外的第一个值为“按产品排序...”:

<div class="ordering">
  <select>
    <option>Order products</option>
    <option>Title - ascending (A - Z)</option>
    <option>Title - descending (Z - A)</option>
    <option>Price - ascending ($1 - $999)</option>
    <option>Price - descending ($999 - $1)</option>
  </select>
</div>

现在我们需要在data函数中为选择框创建一个变量以便更新。添加一个名为ordering的新键,并为每个选项添加一个值,以便更容易解释该值。通过使用字段和顺序,用连字符分隔来构造值。例如,Title - ascending (A - Z)将变为title-asc

<div class="ordering">
  <select v-model="ordering">
    <option value="">Order products</option>
    <option value="title-asc">Title - ascending (A - Z)</option>
    <option value="title-desc">Title - descending (Z - A)</option>
    <option value="price-asc">Price - ascending ($1 - $999)</option>
    <option value="price-desc">Price - descending ($999 - $1)</option>
  </select>
</div>

更新后的data函数如下:

data() {
  return {
    perPage: 12, 
    currentPage: 1,
    pageLinksCount: 3,

    ordering: ''
  }
}

要更新产品的顺序,我们现在需要操作产品列表。这需要在列表被分割为分页之前完成 - 因为用户期望整个列表被排序,而不仅仅是当前页面。

存储产品价格

在继续之前,我们需要解决一个问题。要按价格排序,最好将价格作为产品本身的属性而不是专门为模板计算的属性。为了解决这个问题,我们将在将产品添加到存储之前计算价格。这意味着它将作为产品本身的属性可用,而不是动态创建的属性。

我们需要知道的细节是最便宜的价格以及产品是否有多个价格。后者意味着我们知道在列出产品时是否需要显示"From:"。我们将为每个产品创建两个新属性:pricehasManyPrices

转到存储中的productsmutation,并创建一个新对象和产品的循环:

products(state, payload) {
 let products = {};

 Object.keys(payload).forEach(key => {
 let product = payload[key];

 products[key] = product;
 });

  state.products = payload;
}

ListProducts组件的productPrice方法中复制代码,并将其放置在循环内。更新第二个for循环,使其循环遍历product.variationProducts。完成此for循环后,我们可以向产品添加新属性。最后,使用新的产品对象更新状态:

products(state, payload) {
  let products = {};

  Object.keys(payload).forEach(key => {
    let product = payload[key];

    let prices = [];
 for(let variation of product.variationProducts) {
 if(!prices.includes(variation.price)) {
 prices.push(variation.price);
 }
 }

 product.price = Math.min(...prices);
 product.hasManyPrices = prices.length > 1;

    products[key] = product;
  });

  state.products = products;
}

现在我们可以更新ListProducts组件上的productPrice方法。更新函数,使其接受产品而不是变体。从函数中删除for循环,并更新变量,使其使用产品的pricehasManyPrices属性:

productPrice(product) {
  let price = '$' + product.price;

  if(product.hasManyPrices) {
    price = 'From: ' + price;
  }

  return price;
}

更新模板,以便将产品传递给函数:

<p>Price {{ productPrice(product) }}</p>

连接排序

有了我们需要的价格,我们可以继续连接排序。创建一个名为orderProducts的新的computed函数,它返回this.products。我们希望确保我们始终从源头进行排序,而不是对之前已经排序过的东西进行排序。从paginate函数中调用这个新函数,并从该方法和模板中删除参数:

computed: {
 ...

  orderProducts() {
 return this.products;
 }, },

methods: {
  paginate() {
    return this.orderProducts.slice(
      this.pagination.range.from,  
      this.pagination.range.to
    );
  },
}

为了确定我们需要如何对产品进行排序,我们可以使用this.ordering的值。如果存在,我们可以在连字符上拆分字符串,这意味着我们有一个包含字段和排序类型的数组。如果不存在,我们只需返回现有的产品数组:

orderProducts() {
  let output;

 if(this.ordering.length) {
 let orders = this.ordering.split('-');
 } else {
 output = this.products;
 }
 return output;
}

根据排序数组的第一个项目的值对products数组进行排序。如果它是一个字符串,我们将使用localCompare进行比较,它在比较时忽略大小写。否则,我们将简单地从另一个值中减去一个值 - 这是sort函数所期望的:

orderProducts() {
  let output;

  if(this.ordering.length) {
    let orders = this.ordering.split('-');

    output = this.products.sort(function(a, b) {
 if(typeof a[orders[0]] == 'string') {
 return a[orders[0]].localeCompare(b[orders[0]]);
 } else {
 return a[orders[0]] - b[orders[0]];
 }
 });

  } else {
    output = this.products;
  }
  return output;
}

最后,我们需要检查orders数组中的第二个项目是asc还是desc。默认情况下,当前排序函数将以“升序”方式返回排序后的项目,因此如果值为desc,我们可以反转数组:

orderProducts() {
  let output;

  if(this.ordering.length) {
    let orders = this.ordering.split('-');

    output = this.products.sort(function(a, b) {
      if(typeof a[orders[0]] == 'string') {
        return a[orders[0]].localeCompare(b[orders[0]]);
      } else {
        return a[orders[0]] - b[orders[0]];
      }
    });

 if(orders[1] == 'desc') {
 output.reverse();
 }
  } else {
    output = this.products;
  }
  return output;
}

前往浏览器,查看产品的排序!

创建 Vuex 的 getter

使我们的类别页面与其他任何商店一样的最后一步是引入过滤。过滤允许您查找具有特定尺寸、颜色、标签或制造商的产品。我们的过滤选项将从页面上的产品构建。例如,如果没有产品具有 XL 尺寸或蓝色,那么将其显示为过滤器就没有意义。

为了实现这一点,我们还需要将当前类别的产品传递给过滤组件。然而,产品在CategoryPage组件上进行处理。我们可以将这个功能移动到 Vuex 存储的 getter 中,而不是重复这个处理过程。Getter 允许您从存储中检索数据并像在组件的函数中一样操作它。然而,因为它是一个中心位置,这意味着多个组件可以从处理中受益。

Getter 是 Vuex 中计算函数的等效物。它们被声明为函数,但作为变量调用。然而,它们可以通过返回一个函数来进行参数化处理。

我们将把CategoryPage组件中的categoryproducts函数都移动到 getter 中。然后,getter函数将返回一个包含类别和产品的对象。

在您的存储中创建一个名为getters的新对象。在其中创建一个名为categoryProducts的新函数:

getters: {
  categoryProducts: () => {

  }
}

Getter 本身接收两个参数,第一个是状态,第二个是任何其他 getter。要将参数传递给 getter,必须在 getter 内部返回一个接收参数的函数。幸运的是,在 ES2015 中,可以使用双箭头(=>)语法实现这一点。由于在此函数中不会使用任何其他 getter,因此我们不需要调用第二个参数。

由于我们正在将所有逻辑抽象出来,所以将slug变量作为第二个函数的参数传入:

categoryProducts: (state) => (slug) => {

}

由于我们正在将选择和检索类别和产品的逻辑转移到存储中,所以将HomePage类别内容存储在state本身是有意义的。

state: {
  products: {},
  categories: {},

  categoryHome: {
 title: 'Welcome to the Shop',
 handle: 'home',
 products: [
 'adjustable-stem',
 'fizik-saddle-pak',
 'kenda-tube',
 'colorful-fixie-lima',
 'oury-grip-set',
 'pure-fix-pedals-with-cages'
 ]
 }
}

CategoryPage组件中的category计算函数中的类别选择逻辑移动到 getter 中。更新slugcategoryHome变量,使用相关位置的内容:

categoryProducts: (state) => (slug) => {
  if(Object.keys(state.categories).length) {
    let category = false;

    if(slug) {
      category = this.$store.state.categories[this.slug];
    } else {
      category = state.categoryHome;
    }
  }
}

现在,我们可以根据类别分配加载基于类别中存储的句柄的产品。将代码从products计算函数移动到 getter 中。将变量赋值合并在一起,并删除存储产品检索变量,因为我们已经有了可用的状态。确保检查类别是否存在的代码仍然存在:

categoryProducts: (state) => (slug) => {
  if(Object.keys(state.categories).length) {
    let category = false,
      products = [];

    if(slug) {
      category = this.$store.state.categories[this.slug];
    } else {
      category = state.categoryHome;
    }

    if(category) {
 for(let featured of category.products) {
 products.push(state.products[featured]);
 }
 }
  }
}

最后,在category上添加一个新的productDetails数组,其中包含详细的产品数据。在函数的末尾返回category。如果slug变量输入存在作为一个类别,我们将得到所有的数据。如果没有,它将返回false - 我们可以显示我们的PageNotFound组件:

categoryProducts: (state) => (slug) => {
  if(Object.keys(state.categories).length) {
    let category = false,
      products = [];

    if(slug) {
      category = state.categories[slug];
    } else {
      category = state.categoryHome;
    }

    if(category) {
      for(let featured of category.products) {
        products.push(state.products[featured]);
      }

      category.productDetails = products;
    }

    return category;
  }
}

在我们的CategoryPage组件中,我们可以删除products()计算函数并更新category()函数。要调用一个getter函数,可以引用this.$store.getters

computed: {
  category() {
    if(Object.keys(this.$store.state.categories).length) {
      let category = this.$store.getters.categoryProducts(this.slug);

      if(!category) {
        this.categoryNotFound = true;
      }
      return category;
    }
  }
}

不幸的是,我们仍然需要在继续之前检查类别是否存在。这样我们就可以知道是否有一个名为的类别,而不是一个未加载的类别。

为了使这个更整洁,我们可以将这个检查提取到另一个 getter 中,并在我们的其他 getter 和组件中使用它。

创建一个名为categoriesExist的新 getter,并返回if语句的内容。

categoriesExist: (state) => {
  return Object.keys(state.categories).length;
},

更新categoryProductsgetter 以接受第一个函数的 getter 作为参数,并使用这个新的 getter 来指示其输出:

categoryProducts: (state, getters) => (slug) => {
  if(getters.categoriesExist) {
    ...
  }
}

在我们的CategoryPage组件中,我们现在可以使用this.$store.getters.categoriesExist()调用新的 getter。为了避免在这个函数中重复使用this.$store.getters两次,我们可以将 getter 映射为本地访问。这允许我们调用this.categoriesExist()作为一个更可读的函数名。

computed对象的开头,添加一个名为...Vuex.mapGetters()的新函数。这个函数接受一个数组或一个对象作为参数,而开头的三个点确保内容被展开以与computed对象合并。

传入一个包含两个 getter 名称的数组:

computed: {
 ...Vuex.mapGetters([
 'categoryProducts',
 'categoriesExist'
 ]),

  category() {
    ...
  }
}

这意味着我们现在可以使用this.categoriesExistthis.categoryProducts。更新类别函数以使用这些新函数:

computed: {
  ...Vuex.mapGetters([
    'categoriesExist',
    'categoryProducts'
  ]),

  category() {
    if(this.categoriesExist) {
      let category = this.categoryProducts(this.slug);

      if(!category) {
        this.categoryNotFound = true;
      }
      return category;
    }
  }
}

更新模板以反映计算数据的更改:

template: `<div>
  <div v-if="category">
    <h1>{{ category.title }}</h1>
    <list-products :products="category.productDetails"></list-products>
  </div>
  <page-not-found v-if="categoryNotFound"></page-not-found>
</div>`,

基于产品构建过滤组件

如前所述,我们所有的过滤器都将根据当前类别中的产品创建。这意味着如果没有IceToolz制造的产品,它将不会显示为可用的过滤器。

首先打开ProductFiltering.js组件文件。我们的产品过滤将放在侧边栏中,所以将组件定义从Vue.component更改为一个对象。我们仍然希望在过滤之后显示我们的类别,所以将ListCategories组件添加为ProductFiltering中的一个声明组件。添加一个模板键并包含<list-categories>组件:

const ProductFiltering = {
  name: 'ProductFiltering',

  template: `<div>
    <list-categories />
  </div>`,

  components: {
    ListCategories
  }
}

更新类别路由,将ProductFiltering组件放在侧边栏中,而不是ListCategories

{
  path: '/category/:slug',
  name: 'Category',
  components: {
    default: CategoryPage,
    sidebar: ProductFiltering
  },
  props: {
    default: true, 
    sidebar: true
  }
}

现在,您应该有Home路由,其中包括CategoryPageListCategories组件,以及Category路由,其中包括ProductFiltering组件。

CategoryPage组件中复制 props 和 computed 对象-因为我们将使用大量现有代码。将category计算函数重命名为filters。删除返回语句和componentNotFound的 if 语句。您的组件现在应该如下所示:

const ProductFiltering = {
  name: 'ProductFiltering',

  template: `<div>
    <list-categories />
  </div>`,

  components: {
    ListCategories
  },

  props: {
 slug: String
 },

 computed: {
 ...Vuex.mapGetters([
 'categoriesExist',
 'categoryProducts'
 ]),
 filters() {
 if(this.categoriesExist) {
 let category = this.categoryProducts(this.slug);

 }
 }
 }
}

现在,我们需要根据类别中的产品构建我们的过滤器。我们将通过循环遍历产品,收集来自预选值的信息并显示它们来完成这个过程。

创建一个包含topics键的data对象。这将是一个包含子对象的对象,每个子对象都有一个现在熟悉的模式'handle':{},用于我们要过滤的每个属性。

每个子对象将包含一个handle,它是要过滤的产品的值(例如,供应商),一个title,它是键的用户友好版本,以及一个将被填充的值数组。

我们将从两个开始,vendortags;然而,随着我们处理产品的过程,会动态添加更多:

data() {
  return {
    topics: {
      vendor: {
        title: 'Manufacturer',
        handle: 'vendor',
        values: {}
      },
      tags: {
        title: 'Tags',
        handle: 'tags',
        values: {}
      }
    }
  }
},

现在,我们将开始循环遍历产品。除了值之外,我们还将跟踪具有相同值的产品数量,以便向用户指示将显示多少产品。

filters方法中循环遍历类别中的产品,并首先找到每个产品的vendor。对于每个遇到的产品,检查它是否存在于values数组中。

如果没有,添加一个新对象,其中包含namehandlecountcount是一个产品句柄的数组。我们存储句柄数组是为了验证产品是否已经被查看过。如果我们只是保留一个原始的数字计数,可能会遇到触发过滤器两次的情况,导致计数翻倍。通过检查产品句柄是否已经存在,我们可以确保它只被查看过一次。

如果已存在具有该名称的过滤器,则在检查其不存在后将句柄添加到数组中:

filters() {
  if(this.categoriesExist) {

    let category = this.categoryProducts(this.slug),
      vendors = this.topics.vendor;

 for(let product of category.productDetails) {

        if(product.hasOwnProperty('vendor')) {
 let vendor = product.vendor; 
 if(vendor.handle) { if(!vendor.handle.count.includes(product.handle)) {
              category.values[item.handle].count.push(product.handle);
            }
          } else {
 vendors.values[vendor.handle] = {
 ...vendor,
 count: [product.handle]
 }
 }
 } 
 }

 }
  }
}

这里使用了之前使用的对象展开省略号(...),这样我们就不必写:

vendors.values[product.vendor.handle] = {
  title: vendor.title,
 handle: vendor.handle,
  count: [product.handle]
}

如果您对此更熟悉,可以随意使用它。

复制代码以处理tags,但由于tags本身是一个数组,我们需要循环遍历每个标签并相应地添加:

for(let product of category.productDetails) {

  if(product.hasOwnProperty('vendor')) {
    let vendor = product.vendor;

    if(vendor.handle) {
      if(!vendor.handle.count.includes(product.handle)) {
        category.values[item.handle].count.push(product.handle);
      }
    } else {
      vendors.values[vendor.handle] = {
        ...vendor,
        count: [product.handle]
      }
    }
  }

 if(product.hasOwnProperty('tags')) {
 for(let tag of product.tags) {
 if(tag.handle) {
 if(topicTags.values[tag.handle]) {
 if(!topicTags.values[tag.handle].count.includes(product.handle)) {
            topicTags.values[tag.handle].count.push(product.handle);
          }
 } else {
 topicTags.values[tag.handle] = {
 ...tag,
 count: [product.handle]
 }
 }
 }
 }
 }

}

我们的代码已经变得重复和复杂,让我们通过创建一个处理重复代码的方法来简化它。

创建一个methods对象,其中包含一个名为addTopic的函数。它将接受两个参数:要附加到的对象和单个项目。例如,使用方式如下:

if(product.hasOwnProperty('vendor')) {
  this.addTopic(this.topics.vendor, product.vendor, product.handle);
}

创建函数并将hasOwnProperty内部的逻辑抽象出来。将两个参数命名为categoryitem,并相应地更新代码:

methods: {
  addTopic(category, item, handle) {
    if(item.handle) {

      if(category.values[item.handle]) {
        if(!category.values[item.handle].count.includes(handle)) {
          category.values[item.handle].count.push(handle);
        }

      } else {

        category.values[item.handle] = {
          ...item,
          count: [handle]
        }
      }
    }
  }
}

更新filters计算属性函数,使用新的addTopic方法。删除函数顶部的变量声明,因为它们直接传递给方法:

filters() {
  if(this.categoriesExist) {

    let category = this.categoryProducts(this.slug);

    for(let product of category.productDetails) {

      if(product.hasOwnProperty('vendor')) {
        this.addTopic(this.topics.vendor, product.vendor, product.handle);
      }

      if(product.hasOwnProperty('tags')) {
        for(let tag of product.tags) {
          this.addTopic(this.topics.tags, tag, product.handle);
        }
      }

    }
  }
}

在函数末尾返回this.topics。虽然我们可以直接在模板中引用topics,但我们需要确保filters计算属性被触发:

filters() {
  if(this.categoriesExist) {
    ...
  }

  return this.topics;
}

在继续创建基于不同类型的动态过滤器之前,让我们显示当前的过滤器。

由于topics对象的设置方式,我们可以循环遍历每个子对象,然后遍历每个对象的values。我们将使用复选框来创建过滤器,输入的值将是每个过滤器的句柄:


template: `<div>
 <div class="filters">
 <div class="filterGroup" v-for="filter in filters">
 <h3>{{ filter.title }}</h3>

 <label class="filter" v-for="value in filter.values">
 <input type="checkbox" :value="value.handle">
 {{ value.title }} ({{ value.count }})
 </label>
 </div> 
 </div>

  <list-categories />
</div>`,

为了跟踪已检查的内容,我们可以使用v-model属性。如果有多个复选框使用相同的v-model,Vue 会创建一个包含每个项目的数组。

在数据对象的每个topic对象中添加一个checked数组:

data() {
  return {
    topics: {
      vendor: {
        title: 'Manufacturer',
        handle: 'vendor',
        checked: [],
        values: {}
      },
      tags: {
        title: 'Tags',
        handle: 'tags',
        checked: [],
        values: {}
      }
    }
  }
}

接下来,为每个复选框添加一个v-model属性,引用filter对象上的该数组,并添加一个点击绑定器,引用一个updateFilters方法:

<div class="filters">
  <div class="filterGroup" v-for="filter in filters">
    <h3>{{ filter.title }}</h3>

    <label class="filter" v-for="value in filter.values">
      <input type="checkbox" :value="value.handle" v-model="filter.checked"  @click="updateFilters">
      {{ value.title }} ({{ value.count }})
    </label>
  </div> 
</div>

暂时创建一个空方法,稍后我们将配置它:

methods: {
    addTopic(category, item) {
      ...
    },

 updateFilters() {

 }
}

动态创建过滤器

通过创建和监视固定的过滤器,我们可以利用这个机会创建动态过滤器。这些过滤器将观察产品上的variationTypes(例如颜色和尺寸),并列出选项 - 再次包括每个选项的计数。

为了实现这一点,我们首先需要循环遍历产品上的variationTypes。在添加任何内容之前,我们需要检查该变体类型是否存在于topics对象上,如果不存在 - 我们需要添加一个骨架对象。这会展开变体(其中包含titlehandle),并且还包括空的checkedvalue属性:

filters() {
  if(this.categoriesExist) {

    let category = this.categoryProducts(this.slug);

    for(let product of category.productDetails) {

      if(product.hasOwnProperty('vendor')) {
        this.addTopic(this.topics.vendor, product.vendor);
      }

      if(product.hasOwnProperty('tags')) {
        for(let tag of product.tags) {
          this.addTopic(this.topics.tags, tag);
        }
      }

 Object.keys(product.variationTypes).forEach(vkey => {
 let variation = product.variationTypes[vkey];

 if(!this.topics.hasOwnProperty(variation.handle)) {
 this.topics[variation.handle] = {
 ...variation,
 checked: [],
 values: {}
 }
 }
 });

    }
  }

  return this.topics;
}

创建了空对象后,我们现在可以循环遍历产品对象上的variationProducts。对于每个产品,我们可以通过当前变体的句柄访问变体。从那里,我们可以使用我们的addTopic方法在过滤器中包含值(例如,蓝色或 XL):

Object.keys(product.variationTypes).forEach(vkey => {
  let variation = product.variationTypes[vkey];

  if(!this.topics.hasOwnProperty(variation.handle)) {
    this.topics[variation.handle] = {
      ...variation,
      checked: [],
      values: {}
    }
  }

  Object.keys(product.variationProducts).forEach(pkey => {
 let variationProduct = product.variationProducts[pkey]; 
 this.addTopic(
 this.topics[variation.handle],
 variationProduct.variant[variation.handle],      product.handle
 );
 });

});

但是,我们确实需要更新我们的addTopic方法。这是因为动态属性具有value,而不是标题。

在您的addTopic方法中添加一个if语句,检查是否存在一个value,如果存在 - 将其设置为title

addTopic(category, item, handle) {
  if(item.handle) {

    if(category.values[item.handle]) {
      if(!category.values[item.handle].count.includes(handle)) {
        category.values[item.handle].count.push(handle);
      }

    } else {

 if(item.hasOwnProperty('value')) {
 item.title = item.value;
 }

      category.values[item.handle] = {
        ...item,
        count: [handle]
      }
    }
  }
}

在浏览器中查看应用程序应该显示出您动态添加的过滤器,以及我们添加的原始过滤器。

重置过滤器

在导航到不同类别之间,您会注意到,当前的过滤器不会重置。这是因为我们在每次导航之间没有清除过滤器,数组仍然存在。这并不理想,因为这意味着随着您的导航,过滤器会变得越来越长,并且不适用于产品列表。

为了解决这个问题,我们可以创建一个返回默认主题对象的方法,并在“slug”更新时调用该方法来重置topics对象。将topics对象移动到一个名为defaultTopics的新方法中:

methods: {
 defaultTopics() {
 return {
 vendor: {
 title: 'Manufacturer',
 handle: 'vendor',
 checked: [],
 values: {}
 },
 tags: {
 title: 'Tags',
 handle: 'tags',
 checked: [],
 values: {}
 }
 }
 },

  addTopic(category, item) {
    ...
  }

  updateFilters() {

  }
}

data函数中,将 topics 的值更改为this.defaultTopics()来调用该方法:

data() {
  return {
    topics: this.defaultTopics()
  }
},

最后,在slug更新时,添加一个监视函数来重置主题键:

watch: {
  slug() {
 this.topics = this.defaultTopics();
 }
}

在复选框过滤器更改时更新 URL

当与过滤器组件交互时,将更新 URL 查询参数。这允许用户看到过滤器的效果,将其加为书签,并在需要时共享 URL。我们已经在分页中使用了查询参数,将用户放回第一页进行过滤是有意义的 - 因为可能只有一页。

为了构建我们的过滤器查询参数,我们需要循环遍历每个过滤器类型,并为每个具有checked数组中的项目的过滤器添加一个新参数。然后,我们可以调用router.push()来更新 URL,并相应地更改显示的产品。

updateFilters方法中创建一个空对象。循环遍历主题并使用选中的项目填充filters对象。将路由器中的query参数设置为filters对象:

updateFilters() {
  let filters = {};

 Object.keys(this.topics).forEach(key => {
 let topic = this.topics[key];
 if(topic.checked.length) {
 filters[key] = topic.checked;
 }
 });

 this.$router.push({query: filters});
}

在右侧勾选和取消勾选过滤器应该更新 URL 中的已选项目。

在页面加载时预选过滤器

在加载已经在 URL 中具有过滤器的类别时,我们需要确保右侧的复选框被选中。这可以通过循环遍历现有的查询参数,并将任何匹配的键和数组添加到主题参数中来完成。由于query可以是数组或字符串,我们需要确保无论如何,选中的属性都是一个数组。我们还需要确保查询键确实是一个过滤器,而不是一个页面参数。

filters() {
  if(this.categoriesExist) {

    let category = this.categoryProducts(this.slug);

    for(let product of category.productDetails) {
      ...
    }

 Object.keys(this.$route.query).forEach(key => {
      if(Object.keys(this.topics).includes(key)) {
        let query = this.$route.query[key];
        this.topics[key].checked = Array.isArray(query) ? query : [query];
      }
    });
  }

  return this.topics;
}

在页面加载时,URL 中的过滤器将被选中。

过滤产品

我们现在正在动态创建和附加过滤器,并且激活过滤器会更新 URL 中的查询参数。现在我们可以根据 URL 参数显示和隐藏产品。我们将通过在传递给ListProducts组件之前对产品进行过滤来实现这一点。这确保了分页的正确工作。

在过滤产品时,打开ListProducts.js并为每个列表项添加一个:key属性,其值为handle

<ol :start="pagination.range.from + 1">
  <li v-for="product in paginate(products)" :key="product.handle">
    ...
  </li>
</ol>

打开CategoryPage视图,并在methods对象中创建一个名为filtering()的方法,并添加return true以开始。该方法应接受两个参数,一个product和一个query对象:

methods: {
  filtering(product, query) {

 return true;
 }
}

接下来,在category计算函数内部,如果存在查询参数,我们需要过滤产品。但是,我们需要小心,如果页面号也是一个查询参数,我们不要触发过滤器。

创建一个名为filters的新变量,它是来自路由的查询对象的副本。接下来,如果页面参数存在,则从我们的新对象中删除它。从那里,我们可以检查查询对象是否有其他内容,如果有,就可以在我们的产品数组上运行原生 JavaScript 的filter()函数 - 将产品和新的查询/过滤器对象传递给我们的方法:

category() {
  if(this.categoriesExist) {
    let category = this.categoryProducts(this.slug),
 filters = Object.assign({}, this.$route.query);

 if(Object.keys(filters).length && filters.hasProperty('page')) {
 delete filters.page;
 }

 if(Object.keys(filters).length) {
 category.productDetails = category.productDetails.filter(
 p => this.filtering(p, filters)
 );
 }

    if(!category) {
      this.categoryNotFound = true;
    }
    return category;
  }
}

刷新您的应用程序以确保产品仍然显示。

要过滤产品,涉及到一个相当复杂的过程。我们想要检查一个属性是否在查询参数中;如果是,我们将设置一个占位值为false。如果产品上的属性与查询参数相匹配,我们将将占位符设置为true。然后我们对每个查询参数重复这个过程。完成后,我们只显示符合所有条件的产品。

我们构建的方式允许产品在类别内为“或”,但在不同部分为“与”。例如,如果用户选择了多种颜色(红色和绿色)和一个标签(配件),它将显示所有红色或绿色配件的产品。

我们的过滤器是通过标签、供应商和动态过滤器创建的。由于其中两个属性是固定的,我们将首先检查这些属性。动态过滤器将通过重构它们的构建方式进行验证。

创建一个hasProperty对象,它将是我们用来跟踪产品具有的查询参数的占位符对象。我们将从vendor开始 - 因为这是最简单的属性。

我们首先通过循环遍历查询属性 - 如果有多个属性(例如红色和绿色),接下来,我们需要确认query中是否存在vendor - 如果存在,我们将在hasProperty对象中将vendor属性设置为false,然后我们检查vendor句柄是否与查询属性相同,如果匹配,我们将更改hasProperty.vendor属性为true

filtering(product, query) {
  let display = false,
 hasProperty = {};

 Object.keys(query).forEach(key => {
 let filter = Array.isArray(query[key]) ? query[key] : [query[key]];

 for(attribute of filter) {
 if(key == 'vendor') {

 hasProperty.vendor = false;
 if(product.vendor.handle == attribute) {
 hasProperty.vendor = true;
 }

 }      }
 });

 return display;
}

这将根据供应商是否与所选过滤器匹配来更新hasProperty对象。我们可以使用tags复制该功能 - 记住产品上的标签是我们需要过滤的对象。

还需要检查过滤器构建的动态属性。这是通过检查每个variationProduct上的变体对象,并在匹配时更新hasProperty对象来完成的。

filtering(product, query) {
  let display = false,
    hasProperty = {};

    Object.keys(query).forEach(key => {
      let filter = Array.isArray(query[key]) ? query[key] : [query[key]];

      for(attribute of filter) {
        if(key == 'vendor') {

          hasProperty.vendor = false;
          if(product.vendor.handle == attribute) {
            hasProperty.vendor = true;
          }

        } else if(key == 'tags') {
 hasProperty.tags = false;

 product[key].map(key => {
 if(key.handle == attribute) {
 hasProperty.tags = true;
 }
 });

 } else {
 hasProperty[key] = false;

 let variant = product.variationProducts.map(v => {
 if(v.variant[key] && v.variant[key].handle == attribute) {
 hasProperty[key] = true;
 }
 });
 }
 }
    });

  return display;
}

最后,我们需要检查hasProperty对象的每个属性。如果所有值都设置为true,我们可以将产品的显示设置为true - 表示它将显示。如果其中一个值为false,则产品将不会显示,因为它不符合所有条件。

filtering(product, query) {
  let display = false,
    hasProperty = {};

    Object.keys(query).forEach(key => {
      let filter = Array.isArray(query[key]) ? query[key] : [query[key]];

      for(attribute of filter) {
        if(key == 'vendor') {

          hasProperty.vendor = false;
          if(product.vendor.handle == attribute) {
            hasProperty.vendor = true;
          }

        } else if(key == 'tags') {
          hasProperty.tags = false;

          product[key].map(key => {
            if(key.handle == attribute) {
              hasProperty.tags = true;
            }
          });

        } else {
          hasProperty[key] = false;

          let variant = product.variationProducts.map(v => {
            if(v.variant[key] && v.variant[key].handle == attribute) {
              hasProperty[key] = true;
            }
          });
        }
      }

 if(Object.keys(hasProperty).every(key => hasProperty[key])) {
 display = true;
 }

    });

  return display;
}

现在我们有了一个成功的过滤产品列表。在浏览器中查看您的应用程序并更新过滤器 - 注意每次单击时产品的显示和隐藏。请注意,即使您按下刷新,只有过滤后的产品会显示。

总结

在本章中,我们创建了一个分类列表页面,允许用户查看某一类别中的所有产品。该列表可以进行分页,并且可以改变排序。我们还创建了一个筛选组件,允许用户缩小结果范围。

现在我们的产品可以浏览、筛选和查看,我们可以继续制作购物车和结账页面。

第十一章:构建电子商务商店-添加结账功能

在过去的几章中,我们创建了一个电子商务商店。到目前为止,我们已经创建了一个产品页面,允许我们查看图像和产品变体,可能是尺寸或样式。我们还创建了一个带有过滤器和分页的类别页面,包括一个主页类别页面,其中包含特定的选定产品。

我们的用户可以浏览和筛选产品,并查看有关特定产品的更多信息。现在我们要做的是:

  • 构建功能,允许用户将产品添加到购物篮中并从中删除

  • 允许用户结账

  • 添加订单确认页面

提醒一下-我们不会获取任何账单详细信息,但我们会创建一个订单确认屏幕。

创建购物篮数组占位符

为了帮助我们在整个应用程序中持久保存购物篮中的产品,我们将把用户选择的产品存储在 Vuex 存储中。这将以对象数组的形式存在。每个对象将包含几个关键信息,这些信息将允许我们在不必每次查询 Vuex 存储时都能显示购物篮中的产品。它还允许我们存储有关产品页面当前状态的详细信息-当选择变体时,记住图像更新。

我们将为添加到购物篮中的每个产品存储以下详细信息:

  • 产品标题

  • 产品句柄,以便我们可以链接回产品

  • 选定的变体标题(在选择框中显示)

  • 当前选定的图像,以便我们可以在结账时显示适当的图像

  • 变体详细信息,包括价格、重量和其他细节

  • 变体 SKU,这将帮助我们确定产品是否已经添加

  • 数量,用户已添加到购物篮中的物品数量

由于我们将把所有这些信息存储在一个对象中,该对象包含在一个数组中,我们需要在存储中创建一个占位符数组。在存储的state对象中添加一个名为basket的新键,并将其设置为空数组:

const store = new Vuex.Store({
  state: {
    products: {},
    categories: {},

    categoryHome: {
      title: 'Welcome to the Shop',
      handle: 'home',
      products: [
        ...
      ]
    },

    basket: []

  },

  mutations: {
    ...
  },

  actions: {
    ...
  },

  getters: {
    ...
  }
});

将产品信息添加到存储中

准备好我们的basket数组来接收数据后,我们现在可以创建一个 mutation 来添加产品对象。打开ProductPage.js文件,更新addToBasket方法,调用$store的 commit 函数,而不是我们放置的alert

我们需要将产品添加到购物篮的所有所需信息都存储在ProductPage组件上,因此我们可以使用this关键字将组件实例传递给commit()函数。当我们构建突变时,这将变得清晰。

将函数调用添加到ProductPage方法中:

methods: {
  ...

 addToBasket() {
 this.$store.commit('addToBasket', this);
 }
}

创建存储突变以将产品添加到购物篮中

转到 Vuex 存储并创建一个名为addToBasket的新突变。这将接受state作为第一个参数,组件实例作为第二个参数。通过传递实例,我们可以访问组件上的变量、方法和计算值。

mutations: {
  products(state, payload) {
    ...
  },

  categories(state, payload) {
    ...
  },

 addToBasket(state, item) {

 }
}

现在,我们可以继续将产品添加到basket数组中。第一步是添加具有所述属性的产品对象。由于它是一个数组,我们可以使用push()函数来添加对象。

接下来,使用item及其属性构建对象,将对象添加到数组中。通过访问ProductPage组件,我们可以使用variantTitle方法构建变体标题,该标题将显示在选择框中。默认情况下,将数量设置为1

addToBasket(state, item) {
  state.basket.push({
 sku: item.variation.sku,
 title: item.product.title,
 handle: item.slug,
 image: item.image,
 variationTitle: item.variantTitle(item.variation),
 variation: item.variation,
 quantity: 1
 });
}

现在,将产品添加到basket数组中。然而,当您将两个相同的商品添加到购物篮时,会出现一个问题。它不会增加quantity,而是简单地添加第二个产品。

可以通过检查数组中是否已存在sku来解决此问题。如果存在,我们可以增加该商品的数量,如果不存在,我们可以将新商品添加到basket数组中。每个产品的每个变体的sku是唯一的。或者,我们可以使用条形码属性。

使用原生的find JavaScript 函数,我们可以识别出具有与传入的 SKU 相匹配的任何产品:

addToBasket(state, item) {
 let product = state.basket.find(p => {
 if(p.sku == item.variation.sku) {
 }
 });

  state.basket.push({
    sku: item.variation.sku,
    title: item.product.title,
    handle: item.slug,
    image: item.image,
    variationTitle: item.variantTitle(item.variation),
    variation: item.variation,
    quantity: 1
  });
}

如果匹配,我们可以使用 JavaScript 中的++符号将该对象的数量增加一。如果不匹配,我们可以将新对象添加到basket数组中。使用find函数时,如果产品存在,我们可以返回该产品。如果不存在,我们可以添加一个新商品:

addToBasket(state, item) {
  let product = state.basket.find(p => {
    if(p.sku == item.variation.sku) {
      p.quantity++;

 return p;
    }
  });

  if(!product) {
    state.basket.push({
      sku: item.variation.sku,
      title: item.product.title,
      handle: item.slug,
      image: item.image,
      variationTitle: item.variantTitle(item.variation),
      variation: item.variation,
      quantity: 1
    });
 }
}

现在,当商品添加到购物篮中时,购物篮会被填充,并且在已存在时会递增。

为了提高应用的可用性,当用户将商品添加到购物篮时,我们应该给予用户一些反馈。可以通过更新“添加到购物篮”按钮并在网站头部显示一个带有指向购物篮的链接的产品计数来实现这一点。

在添加商品时更新“添加到购物篮”按钮

作为对我们商店的可用性改进,当用户点击“添加到购物篮”按钮时,我们将更新该按钮。它将变为“已添加到您的购物篮”,并在一段时间内应用一个类,例如两秒钟,然后返回到之前的状态。CSS 类将允许您对按钮进行不同的样式设置,例如将背景改为绿色或稍微进行变换。

通过在组件上使用一个数据属性来实现这一点,将其设置为truefalse,当商品被添加时。CSS 类和文本将使用此属性来确定要显示的内容,而setTimeout JavaScript 函数将更改属性的状态。

打开ProductPage组件,并在数据对象中添加一个名为addedToBasket的新键。默认将其设置为false

data() {
  return {
    slug: this.$route.params.slug,
    productNotFound: false,
    image: false,
    variation: false,
    addedToBasket: false
  }
}

更新按钮文本以适应这个变化。由于已经有一个三元if,我们将在其中嵌套另一个。如果需要,这可以抽象成一个方法。

在按钮中,用一个额外的三元运算符替换Add to basket条件,取决于addedToBasket变量是否为 true。我们还可以根据此属性添加一个条件类:

<button 
  @click="addToBasket()" 
  :class="(addedToBasket) ? 'isAdded' : ''" 
  :disabled="!variation.quantity"
>
  {{ 
    (variation.quantity) ? 
    ((addedToBasket) ? 'Added to your basket' : 'Add to basket') : 
    'Out of stock'
  }}
</button>

刷新应用并导航到一个产品页面,确保正确的文本被显示。将addedToBasket变量更新为true,以确保一切都显示正常。然后将其设置回false

接下来,在addToBasket()方法中,将该属性设置为 true。当商品被添加到购物篮时,这将更新文本:

addToBasket() {
  this.$store.commit('addToBasket', this);

 this.addedToBasket = true;
}

当您点击按钮时,文本现在会更新,但它永远不会重置。在之后添加一个setTimeout JavaScript 函数,它会在一定时间后将其设置回false

addToBasket() {
  this.$store.commit('addToBasket', this);

  this.addedToBasket = true;
  setTimeout(() => this.addedToBasket = false, 2000);
}

setTimeout的时间单位是毫秒,所以2000等于两秒。根据需要随意调整和修改这个数字。

最后一个添加是,如果变体被更新或产品被更改,将此值重置为false。将该语句添加到两个watch函数中:

watch: {
  variation(v) {
    if(v.hasOwnProperty('image')) {
      this.updateImage(v.image);
    }

    this.addedToBasket = false;
  },

  '$route'(to) {
    this.slug = to.params.slug;
    this.addedToBasket = false;
  }
}

在应用的页眉中显示产品数量

在网站的页眉中显示购物车链接以及购物车中的商品数量是常见的做法。为了实现这一点,我们将使用一个 Vuex getter 来计算并返回购物篮中的商品数量。

打开index.html文件并在应用程序 HTML 中添加一个<header>元素,并插入一个占位符span - 一旦我们设置了路由,我们将把它转换为链接。在 span 中,输出一个cartQuantity变量:

<div id="app">
  <header>
 <span>Cart {{ cartQuantity }}</span>
 </header>
  <main>
    <router-view></router-view>
  </main>
  <aside>
    <router-view name="sidebar"></router-view>
  </aside>
</div>

转到你的Vue实例并创建一个包含cartQuantity函数的computed对象:

new Vue({
  el: '#app',

  store,
  router,

 computed: {
 cartQuantity() {

 }
 },

  created() {
    CSV.fetch({url: './data/csv-files/bicycles.csv'}).then(data => {
      this.$store.dispatch('initializeShop', this.$formatProducts(data));
    });
  }
});

如果我们的标题中的商品比购物车链接多,最好将其抽象为一个单独的组件,以保持方法、布局和函数的封装。然而,由于在我们的示例应用程序中只会显示这一个链接,将函数添加到Vue实例中就足够了。

在 store 中创建一个名为cartQuantity的新 getter。作为占位符,返回1state将被用于计算数量,所以现在确保将其传递给函数:

getters: {
  ...

 cartQuantity: (state) => { 
 return 1;
 }
}

返回到你的Vue实例并返回 getter 的结果。理想情况下,我们希望在括号中显示basket的数量,但只有在有商品时才显示括号。在计算函数中,检查这个 getter 的结果,并在结果存在时输出带有括号的结果:

cartQuantity() {
  const quantity = this.$store.getters.cartQuantity;
 return quantity ? `(${quantity})` : '';
}

在 Vuex getter 中更改结果应该会显示带括号的数字或根本不显示任何内容。

计算购物篮数量

有了显示逻辑,我们现在可以继续计算购物篮中有多少商品。我们可以计算basket数组中的商品数量,但是这只会告诉我们现在有多少不同的商品,而不是同一种商品被添加了多次。

相反,我们需要遍历购物篮中的每个商品并将数量相加。创建一个名为quantity的变量并将其设置为0。遍历购物篮中的商品并将item.quantity变量添加到quantity变量中。最后,返回我们的变量与正确的总和:

cartQuantity: (state) => {
 let quantity = 0;
 for(let item of state.basket) {
 quantity += item.quantity;
 }
 return quantity;
}

转到应用程序并添加一些商品到购物篮中,以验证购物篮数量是否被正确计算。

最终确定 Shop Vue-router 的 URL

我们现在处于一个可以最终确定我们商店的 URL 的阶段 - 包括创建重定向和结账链接。回顾第八章,介绍 Vue-Router 和加载基于 URL 的组件,我们可以看到我们缺少哪些。这些是:

  • /category - 重定向到/

  • /product - 重定向到/

  • /basket - 加载OrderBasket组件

  • /checkout - 加载OrderCheckout组件

  • /complete - 加载OrderConfirmation组件

在路由数组的适当位置创建重定向。在路由数组的底部,为Order组件创建三个新路由:

routes: [
  {
    path: '/',
    name: 'Home',
    ...
  },
  {
 path: '/category',
 redirect: {name: 'Home'}
 },
  {
    path: '/category/:slug',
    name: 'Category',
    ...
  },
  {
 path: '/product',
 redirect: {name: 'Home'}
 },
  {
    path: '/product/:slug',
    name: 'Product',
    component: ProductPage
  },
  {
path: '/basket',
 name: 'Basket',
 component: OrderBasket
 },
 {
 path: '/checkout',
 name: 'Checkout',
 component: OrderCheckout
 },
 {
 path: '/complete',
 name: 'Confirmation',
 component: OrderConfirmation
 },

  {
    path: '/404', 
    alias: '*',
    component: PageNotFound
  }
]

我们现在可以使用router-link来更新应用程序标题中的占位符<span>

<header>
  <router-link :to="{name: 'Basket'}">Cart {{ cartQuantity }}</router-link>
</header>

构建订单流程和 ListProducts 组件

对于结账的三个步骤,我们将在所有三个步骤中使用相同的组件:ListProducts组件。在OrderCheckoutOrderConfirmation组件中,它将处于固定的、不可编辑的状态,而在OrderBasket组件中,用户需要能够更新数量和删除物品。

由于我们将在结账时进行操作,我们需要在basket数组中存在产品。为了避免每次刷新应用程序时都要查找产品并将其添加到购物篮中,我们可以通过在存储中硬编码一个数组来确保basket数组中有一些产品。

为了实现这一点,导航到一些产品并将它们添加到购物篮中。确保有一些产品和数量进行测试。接下来,在浏览器中打开 JavaScript 控制台并输入以下命令:

console.log(JSON.stringify(store.state.basket));

这将输出一个您的产品数组的字符串。将其复制并粘贴到您的存储中,替换basket数组:

state: {
  products: {},
  categories: {},

  categoryHome: {
    title: 'Welcome to the Shop',
    handle: 'home',
    products: [
      ...
    ]
  },

  basket: [{"sku":...}]
},

页面加载时,标题中的购物车计数应更新为您添加的正确数量的物品。

现在我们可以继续构建我们的结账流程了。购物篮中的产品显示比结账和订单确认屏幕更复杂,因此我们将反向工作。从订单确认页面开始,然后转到结账页面,在前往购物篮之前增加更多复杂性,添加退出产品的功能。

订单确认屏幕

订单确认屏幕是在订单完成后显示的屏幕。它确认购买的物品,并可能包括预计的交货日期。

OrderConfirmation.js文件中创建一个包含<h1>和与订单完成相关的一些相关内容的模板:

const OrderConfirmation = {
  name: 'OrderConfirmation',

  template: `<div>
    <h1>Order Complete!</h1>
    <p>Thanks for shopping with us - you can expect your products within 2 - 3 working days</p>
  </div>`
};

在浏览器中打开应用程序,将产品添加到购物篮中并完成订单以确认其是否正常工作。下一步是包含ListProducts组件。首先,确保ListProducts组件正确初始化并具有初始模板:

const ListPurchases = {
  name: 'ListPurchases',

  template: `<table></table>`
};

components对象添加到OrderConfirmation组件中,并包含ListProducts组件。接下来,在模板中包含它:

const OrderConfirmation = {
  name: 'OrderConfirmation',

  template: `<div>
    <h1>Order Complete!</h1>
    <p>Thanks for shopping with us - you can expect your products within 2 - 3 working days</p>
    <list-purchases />
  </div>`,

 components: {
 ListPurchases
 }
};

再次打开ListPurchases组件以开始显示产品。该组件的默认状态将是列出购物篮中的产品,以及所选的变体。每个产品的价格将被显示出来,如果数量大于一,则还会显示价格。最后,将显示一个总计。

第一步是将购物篮列表放入我们的组件中。创建一个带有products函数的computed对象。这个函数应该返回购物篮中的产品:

const ListPurchases = {
  name: 'ListPurchases',

  template: `<table></table>`,

  computed: {
 products() {
 return this.$store.state.basket;
 }
 }
};

现在我们可以在表格中循环遍历购物篮中的产品,并显示所需的信息。这包括缩略图、产品和变体标题、价格、数量和项目的总价格。还要在表格中添加一个标题行,以便用户知道每列的内容:

  template: `<table>
    <thead>
      <tr>
        <th></th>
        <th>Title</th>
        <th>Unit price</th>
        <th>Quantity</th>
        <th>Price</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="product in products">
        <td>
          <img 
            :src="product.image.source" 
            :alt="product.image.alt || product.variationTitle"
            width="80"
          >
        </td>
        <td>
          <router-link :to="{name: 'Product', params: {slug: product.handle}}">
            {{ product.title }}
          </router-link><br>
          {{ product.variationTitle }}
        </td>
        <td>{{ product.variation.price }}</td>
        <td>{{ product.quantity }}</td>
        <td>{{ product.variation.price * product.quantity }}</td>
      </tr>
    </tbody>
  </table>`,

请注意,每行的价格只是单位价格乘以数量。现在我们有了用户购买的标准产品列表。

使用 Vue 过滤器格式化价格

价格目前是一个整数,因为它在数据中是这样的。在产品页面上,我们只是在价格前面加了一个$符号来表示价格,然而,现在正是利用 Vue 过滤器的绝佳机会。过滤器允许您在模板中操作数据,而无需使用方法。过滤器可以链接在一起,用于执行通常的单一修改,例如将字符串转换为小写或将数字格式化为货币。

过滤器使用管道(|)运算符。例如,如果我们有一个将文本转换为小写的过滤器,可以像下面这样使用它:

{{ product.title | lowercase }}

过滤器在组件的filters对象中声明,并接受一个输出前置参数。

ListPurchases组件中创建一个filters对象,并在其中创建一个名为currency()的函数。这个函数接受一个名为val的参数,并应该返回该变量的值:

filters: {
  currency(val) {
    return val;
  }
},

现在我们可以使用这个函数来操作价格整数。在模板中将过滤器添加到单位价格和总价格中:

<td>{{ product.variation.price | currency }}</td>
<td>{{ product.quantity }}</td>
<td>{{ product.variation.price * product.quantity | currency }}</td>

在浏览器中您不会注意到任何变化,因为我们还没有对值进行操作。更新函数以确保数字保留两位小数,并在前面加上$符号:

filters: {
  currency(val) {
    return ' + val.toFixed(2);
  }
},

我们的价格现在已经很好地格式化并正确显示。

计算总价格

购物清单的下一个添加是购物篮的总价值。这需要以与我们之前计算购物篮数量的方式进行计算。

创建一个新的computed函数标题为totalPrice。该函数应该循环遍历产品并累加价格,考虑到任何多个数量:

totalPrice() {
  let total = 0;

  for(let p of this.products) {
    total += (p.variation.price * p.quantity);
  }

  return total;
}

现在我们可以更新模板以包括总价格 - 确保我们通过currency过滤器传递它:

template: `<table>
  <thead>
    <tr>
      <th></th>
      <th>Title</th>
      <th>Unit price</th>
      <th>Quantity</th>
      <th>Price</th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="product in products">
      <td>
        <img 
          :src="product.image.source" 
          :alt="product.image.alt || product.variationTitle"
          width="80"
        >
      </td>
      <td>
        <router-link :to="{name: 'Product', params: {slug: product.handle}}">
          {{ product.title }}
        </router-link><br>
        {{ product.variationTitle }}
      </td>
      <td>{{ product.variation.price | currency }}</td>
      <td>{{ product.quantity }}</td>
      <td>{{ product.variation.price * product.quantity | currency }}</td>
    </tr>
  </tbody>
  <tfoot>
 <td colspan="4">
 <strong>Total:</strong>
 </td>
 <td>{{ totalPrice | currency }}</td>
 </tfoot>
</table>`,

创建一个订单结账页面

我们的OrderCheckout页面的构成与OrderConfirmation页面类似 - 但是,在真实的商店中,这将是付款之前的页面。该页面允许用户在导航到付款页面之前填写其帐单和交付详细信息。复制OrderConfirmation页面并更新标题和信息文本:

const OrderCheckout = {
  name: 'OrderCheckout',

  template: '<div>;
    <h1>Order Confirmation</h1>
    <p>Please check the items below and fill in your details to complete your order</p>
    <list-purchases />
  </div>',

  components: {
    ListPurchases
  }
};

<list-purchases />组件下方,创建一个表单,包含几个字段,以便我们可以收集帐单和交付名称和地址。对于这个示例,只需收集姓名、地址的第一行和邮政编码:

template: '<div>
  <h1>Order Confirmation</h1>
  <p>Please check the items below and fill in your details to complete your order</p>
  <list-purchases />

  <form>
 <fieldset>
 <h2>Billing Details</h2>
 <label for="billingName">Name:</label>
 <input type="text" id="billingName">
 <label for="billingAddress">Address:</label>
 <input type="text" id="billingAddress">
 <label for="billingZipcode">Post code/Zip code:</label>
 <input type="text" id="billingZipcode">
 </fieldset>
 <fieldset>
 <h2>Delivery Details</h2>
 <label for="deliveryName">Name:</label>
 <input type="text" id="deliveryName">
 <label for="deliveryAddress">Address:</label>
 <input type="text" id="deliveryAddress">
 <label for="deliveryZipcode">Post code/Zip code:</label>
 <input type="text" id="deliveryZipcode">
 </fieldset>
 </form>
</div>',

现在我们需要创建一个数据对象,并将每个字段绑定到一个键。为了帮助分组每个集合,为deliverybilling分别创建一个对象,并在内部创建正确名称的字段:

data() {
  return {
    billing: {
      name: '',
      address: '',
      zipcode: ''
    },
    delivery: {
      name: '',
      address: '',
      zipcode: ''
    }
  }
}

为每个输入添加v-model,将其链接到相应的数据键:

<form>
  <fieldset>
    <h2>Billing Details</h2>
    <label for="billingName">Name:</label>
    <input type="text" id="billingName" v-model="billing.name">
    <label for="billingAddress">Address:</label>
    <input type="text" id="billingAddress" v-model="billing.address">
    <label for="billingZipcode">Post code/Zip code:</label>
    <input type="text" id="billingZipcode" v-model="billing.zipcode">
  </fieldset>
  <fieldset>
    <h2>Delivery Details</h2>
    <label for="deliveryName">Name:</label>
    <input type="text" id="deliveryName" v-model="delivery.name">
    <label for="deliveryAddress">Address:</label>
    <input type="text" id="deliveryAddress" v-model="delivery.address">
    <label for="deliveryZipcode">Post code/Zip code:</label>
    <input type="text" id="deliveryZipcode" v-model="delivery.zipcode">
  </fieldset>
</form>

下一步是创建一个submit方法并整理数据以便能够将其传递给下一个屏幕。创建一个名为submitForm()的新方法。由于本示例中不处理付款,所以可以在该方法中路由到确认页面:

methods: {
  submitForm() {
    // this.billing = billing details
    // this.delivery = delivery details

    this.$router.push({name: 'Confirmation'});
  }
}

现在我们可以将submit事件绑定到表单上,并添加一个提交按钮。与v-bind:click属性(或@click)类似,Vue 允许您使用@submit=""属性将submit事件绑定到一个方法上。

<form>元素中添加声明并在表单中创建一个提交按钮:

<form @submit="submitForm()">
  <fieldset>
    ...
  </fieldset>

  <fieldset>
    ...
  </fieldset>

  <input type="submit" value="Purchase items">
</form>

在提交表单时,应用程序应将您重定向到我们的确认页面。

在地址之间复制详细信息

几个商店都有的一个功能是将交付地址标记为与帐单地址相同。我们可以采用几种方法来实现这一点,您可以根据自己的选择来做。即时选项有:

  • 有一个“复制详细信息”按钮 - 这将从帐单复制详细信息到交付,但不会保持它们同步

  • 有一个复选框,可以保持两者同步 - 勾选该框会禁用交付框字段,但会填充帐单详细信息

对于这个示例,我们将编写第二个选项。

在两个字段集之间创建一个复选框,通过v-model将其绑定到数据对象中的一个属性sameAddress

<form @submit="submitForm()">
  <fieldset>
     ...
  </fieldset>
 <label for="sameAddress">
 <input type="checkbox" id="sameAddress" v-model ="sameAddress">
 Delivery address is the same as billing
 </label>
  <fieldset>
    ...
  </fieldset>

  <input type="submit" value="Purchase items">
</form>

在数据对象中创建一个新的键,并将其默认设置为false

data() {
  return {
    sameAddress: false,

    billing: {
      name: '',
      address: '',
      zipcode: ''
    },
    delivery: {
      name: '',
      address: '',
      zipcode: ''
    }
  }
},

下一步是如果复选框被选中,则禁用 delivery 字段。这可以通过根据复选框的结果激活disabledHTML 属性来实现。类似于我们在产品页面上禁用“添加到购物车”按钮的方式,将 delivery 字段上的 disabled 属性绑定到sameAddress变量上:

<fieldset>
  <h2>Delivery Details</h2>
  <label for="deliveryName">Name:</label>
  <input type="text" id="deliveryName" v-model="delivery.name" :disabled="sameAddress">
  <label for="deliveryAddress">Address:</label>
  <input type="text" id="deliveryAddress" v-model="delivery.address" :disabled="sameAddress">
  <label for="deliveryZipcode">Post code/Zip code:</label>
  <input type="text" id="deliveryZipcode" v-model="delivery.zipcode" :disabled="sameAddress">
</fieldset>

现在勾选复选框将禁用字段,使用户无法输入任何数据。下一步是在两个部分之间复制数据。由于我们的数据对象具有相同的结构,我们可以创建一个watch函数,当复选框被选中时,将delivery对象设置为与billing对象相同。

创建一个新的watch对象和函数来处理sameAddress变量。如果它为true,则将 delivery 对象设置为与 billing 对象相同:

watch: {
  sameAddress() {
    if(this.sameAddress) {
      this.delivery = this.billing;
    }
  }
}

添加了watch函数后,我们可以输入数据到 billing 地址,勾选复选框,然后 delivery 地址会自动填充。最好的是它们现在保持同步,所以如果你更新 billing 地址,delivery 地址会实时更新。问题出现在当你取消勾选复选框并编辑 billing 地址时,delivery 地址仍然会更新。这是因为我们将这两个对象绑定在一起。

添加一个else语句,当复选框未选中时,复制 billing 地址:

watch: {
  sameAddress() {
    if(this.sameAddress) {
      this.delivery = this.billing;
    } else {
 this.delivery = Object.assign({}, this.billing);
 }
  }
}

现在我们有一个功能完善的订单确认页面,可以收集账单和交付细节。

创建可编辑的购物篮

现在我们需要创建我们的购物篮。它需要以类似于结账和确认页面的方式显示产品,但它需要给用户编辑购物篮内容的能力-删除项目或更新数量。

作为起点,打开OrderBasket.js并包含list-purchases组件,就像我们在确认页面上所做的那样:

const OrderBasket = {
  name: 'OrderBasket',

  template: `<div>
    <h1>Basket</h1>
    <list-purchases />
  </div>`,

  components: {
    ListPurchases
  }
};

接下来我们需要编辑list-purchases组件。为了确保我们可以区分视图,我们将添加一个editable属性。默认情况下设置为false,在购物篮中设置为true。在购物篮中的组件中添加这个属性:

template: `<div>
  <h1>Basket</h1>
  <list-purchases :editable="true" />
</div>`,

现在我们需要告诉ListPurchases组件接受这个参数,以便我们可以在组件内部对其进行操作:

props: {
  editable: {
    type: Boolean,
    default: false
  }
},

创建可编辑字段

现在我们有一个属性来确定我们的购物篮是否可编辑。这允许我们显示删除链接并使数量成为可编辑框。

ListPurchases组件中,在数量旁边创建一个新的表格单元格,并仅在购买可见时显示它。在这种状态下,将静态数量隐藏。在新的单元格中,添加一个值设置为数量的输入框。我们还将绑定一个blur事件到该框。blur事件是一个原生 JavaScript 事件,当输入框失去焦点时触发。在失去焦点时,触发一个updateQuantity方法。该方法应该接受两个参数:事件,其中包含新的数量,以及该特定产品的 SKU:

<tbody>
  <tr v-for="product in products">
    <td>
      <img 
        :src="product.image.source" 
        :alt="product.image.alt || product.variationTitle"
        width="80"
      >
    </td>
    <td>
      <router-link :to="{name: 'Product', params: {slug: product.handle}}">
        {{ product.title }}
      </router-link><br>
      {{ product.variationTitle }}
    </td>
    <td>{{ product.variation.price | currency }}</td>
    <td v-if="!editable">{{ product.quantity }}</td>
    <td v-if="editable">
      <input 
 type="text"
 :value="product.quantity" 
 @blur="updateQuantity($event, product.sku)"
 >
    </td>
    <td>{{ product.variation.price * product.quantity | currency }}</td>
  </tr>
</tbody>

在组件上创建新的方法。该方法应该循环遍历产品,找到具有匹配 SKU 的产品并将数量更新为整数。我们还需要更新存储的结果,以便在页面顶部更新数量。我们将创建一个通用的 mutation,接受带有新值的完整basket数组,以允许相同的 mutation 用于产品删除。

创建更新数量并提交名为updatePurchases的 mutation:

methods: {
  updateQuantity(e, sku) {
    let products = this.products.map(p => {
      if(p.sku == sku) {
        p.quantity = parseInt(e.target.value);
      }
      return p;
    });

    this.$store.commit('updatePurchases', products);
  }
}

在 store 中,创建将state.basket设置为 payload 的 mutation:

updatePurchases(state, payload) {
  state.basket = payload;
}

现在,更新数量应该更新页面顶部的商品总价和购物车数量。

从购物车中删除商品

下一步是让用户能够从购物车中删除商品。在ListPurchases组件中创建一个带有点击绑定的按钮。这个按钮可以放在任何你想要的地方 - 我们的示例将其显示为行末的额外单元格。将点击操作绑定到一个名为removeItem的方法。这只需要接受一个 SKU 的参数。在ListPurchases组件中添加以下内容:

<tbody>
  <tr v-for="product in products">
    <td>
      <img 
        :src="product.image.source" 
        :alt="product.image.alt || product.variationTitle"
        width="80"
      >
    </td>
    <td>
      <router-link :to="{name: 'Product', params: {slug: product.handle}}">
        {{ product.title }}
      </router-link><br>
      {{ product.variationTitle }}
    </td>
    <td>{{ product.variation.price | currency }}</td>
    <td v-if="!editable">{{ product.quantity }}</td>
    <td v-if="editable"><input 
      type="text"
      :value="product.quantity" 
      @blur="updateQuantity($event, product.sku)"
    ></td>
    <td>{{ product.variation.price * product.quantity | currency }}</td>
    <td v-if="editable">
 <button @click="removeItem(product.sku)">Remove item</button>
 </td>
  </tr>
</tbody>

创建removeItem方法。该方法应该过滤basket数组,只返回不匹配传入的 SKU 的对象。一旦结果被过滤,将结果传递给与updateQuantity()方法中使用的相同的 mutation:

removeItem(sku) {
  let products = this.products.filter(p => {
    if(p.sku != sku) {
      return p;
    }
  });

  this.$store.commit('updatePurchases', products);
}

我们可以做的最后一个改进是如果数量设置为 0,则触发removeItem方法。在updateQuantity方法中,在循环遍历产品之前检查值。如果它是0或不存在,则运行removeItem方法 - 通过传递 SKU:

updateQuantity(e, sku) {
  if(!parseInt(e.target.value)) {
 this.removeItem(sku);
 } else {
    let products = this.products.map(p => {
      if(p.sku == sku) {
        p.quantity = parseInt(e.target.value);
      }
      return p;
    });

    this.$store.commit('updatePurchases', products);
  }
},

完成购物 SPA

最后一步是从OrderBasket组件添加到OrderCheckout页面的链接。这可以通过链接到Checkout路由来完成。有了这个,您的结账就完成了,您的商店也完成了!在购物篮中添加以下链接:

template: `<div>
  <h1>Basket</h1>
  <list-purchases :editable="true" />
  <router-link :to="{name: 'Checkout'}">Proceed to Checkout</router-link>
</div>`,

摘要

干得好!您已经使用Vue.js创建了一个完整的商店单页面应用程序。您已经学会了如何列出产品及其变体,以及如何将特定的变体添加到购物篮中。您已经学会了如何创建商店过滤器和类别链接,以及创建可编辑的购物篮。

就像任何事情一样,总是有改进的空间。为什么不尝试一些这些想法呢?

  • 使用localStorage持久化购物篮-这样添加到购物篮中的产品在访问和用户按下刷新之间保留

  • 根据购物篮中产品的重量属性计算运费-使用 switch 语句创建带有不同范围的运费

  • 允许从类别列表页面将没有变体的产品添加到购物篮中

  • 当在类别页面上过滤某个变体时,指示哪些产品缺货

  • 您自己的任何想法!

第十二章:使用 Vue Dev Tools 和测试您的 SPA

在过去的 11 章中,我们使用Vue.js开发了几个单页应用程序SPAs)。尽管开发是创建 SPA 的重要部分,但测试也是创建任何 JavaScript Web 应用程序的重要组成部分。

Vue 开发者工具在 Chrome 和 Firefox 中提供了对在某个视图中使用的组件或 Vuex 存储的当前状态的深入洞察 - 以及从 JavaScript 中发出的任何事件。这些工具允许您在开发过程中检查和验证应用程序中的数据,以确保一切都正常。

SPA 测试的另一方面是自动化测试。您编写的条件、规则和路由用于自动化应用程序中的任务,允许您指定输出应该是什么,并且测试运行条件以验证结果是否匹配。

在本章中,我们将:

  • 涵盖使用我们开发的应用程序的 Vue 开发者工具的使用

  • 了解测试工具和应用程序的概述

使用 Vue.js 开发者工具

Vue 开发者工具适用于 Chrome 和 Firefox,并可以从 GitHub(github.com/vuejs/vue-devtools)下载。安装后,它们成为浏览器开发者工具的扩展。例如,在 Chrome 中,它们出现在审核标签之后。

只有在使用 Vue 的开发模式时,Vue 开发者工具才能正常工作。默认情况下,未压缩版本的 Vue 已启用开发模式。然而,如果您使用的是代码的生产版本,则可以通过在代码中将devtools变量设置为true来启用开发工具:

Vue.config.devtools = true

在整本书中,我们一直使用的是 Vue 的开发版本,所以开发工具应该可以与我们开发的所有三个单页应用程序一起使用。打开 Dropbox 示例并打开 Vue 开发者工具。

检查 Vue 组件的数据和计算值

Vue 开发者工具提供了对页面上使用的组件的很好的概述。您还可以深入到组件中,并预览该特定实例中使用的数据。这非常适合在任何给定时间检查页面上每个组件的属性。

例如,如果我们检查 Dropbox 应用程序并导航到组件选项卡,我们可以看到 Vue 实例和组件。点击这个将显示组件的所有数据属性 - 以及任何计算属性。这样我们就可以验证结构是否正确构建,以及计算路径属性:

深入研究每个组件,我们可以访问单个数据对象和计算属性。

使用 Vue 开发者工具来检查应用程序是一种更高效的验证数据的方式,因为它可以避免使用多个console.log()语句。

查看 Vuex 的 mutations 和时间旅行

导航到下一个选项卡 Vuex,可以实时观察存储变化的发生。每次发生变化时,左侧面板中都会创建一行。这个元素允许我们查看发送的数据以及数据提交之前和之后的 Vuex 存储的样子。

它还提供了几个选项来还原、提交和时间旅行到任何点。加载 Dropbox 应用程序后,左侧面板中立即出现几个结构变化,列出了变化的名称和发生的时间。这是预缓存文件夹的代码。点击每个变化将显示 Vuex 存储状态 - 以及包含的负载的变化。状态显示是在负载发送和变化提交之后。要预览在该变化之前状态的样子,选择前面的选项:

在每个条目旁边,您会注意到三个符号,允许您执行几个操作并直接在浏览器中改变存储:

  • 提交此变化:这允许您提交到该点的所有数据。这将从开发工具中删除所有的变化,并将基本状态更新到此点。如果有多个变化发生,您希望跟踪它们,这将非常方便。

  • 撤销此突变:这将撤销该突变和此点之后的所有突变。这样可以让你一遍又一遍地执行相同的操作,而无需刷新或丢失当前位置。例如,在我们的商店应用程序中将产品添加到购物篮时,会发生突变。使用此功能可以让你从购物篮中删除产品并撤销任何后续的突变,而无需离开产品页面。

  • 时间旅行到此状态:这允许您预览应用程序和该特定突变时的状态,而不会撤销所选点之后发生的任何突变。

突变选项卡还允许您在左侧面板顶部提交或撤销所有突变。在右侧面板中,您还可以导入和导出存储状态的 JSON 编码版本。当您想要重新测试多种情况和实例而无需重现多个步骤时,这非常方便。

预览事件数据

Vue 开发者工具的事件选项卡与 Vuex 选项卡的工作方式类似,允许您检查应用程序中发出的任何事件。我们的 Dropbox 应用程序不使用事件,因此打开我们在本书的第二章“显示、循环、搜索和过滤数据”中创建的 people-filtering 应用程序,以及本书的第三章“优化我们的应用程序并使用组件显示数据”。

更改此应用程序中的过滤器会在每次更新过滤器类型时发出一个事件,以及过滤器查询:

左侧面板再次列出了事件的名称和发生的时间。右侧面板包含有关事件的信息,包括其组件来源和有效负载。这些数据可以确保事件数据与您预期的一样,并且如果不是,可以帮助您找到触发事件的位置。

Vue 开发工具是非常宝贵的,特别是当你的 JavaScript 应用程序变得越来越大和复杂时。打开我们开发的商店 SPA,检查各个组件和 Vuex 数据,了解这个工具如何帮助你创建只提交所需的突变并发出所需的事件的应用程序。

测试你的 SPA

大多数 Vue 测试套件都围绕着具备命令行知识并使用CLI命令行界面)创建 Vue 应用程序。除了使用前端兼容的 JavaScript 创建应用程序外,Vue 还有一个 CLI,允许您使用基于组件的文件创建应用程序。这些文件具有.vue扩展名,包含模板 HTML 以及组件所需的 JavaScript。它们还允许您创建作用域 CSS-仅适用于该组件的样式。如果选择使用 CLI 创建应用程序,则本书中学到的所有理论和大部分实践知识都可以轻松移植过来。

命令行单元测试

除了组件文件,Vue CLI 还允许您更轻松地集成命令行单元测试,例如 Jest、Mocha、Chai 和 TestCafe(testcafe.devexpress.com/)。例如,TestCafe 允许您指定多个不同的测试,包括检查内容是否存在,点击按钮以测试功能等。一个 TestCafe 测试的示例是检查我们第一个应用程序中的过滤组件是否包含单词Field

test('The filtering contains the word "filter"', async testController => {
  const filterSelector = await new Selector('body > #app > form > label:nth-child(1)');

  await testController.expect(paragraphSelector.innerText).eql('Filter');
});

这个测试将返回truefalse。单元测试通常与组件本身一起编写,允许组件在隔离环境中被重用和测试。这样可以确保外部因素对测试结果没有影响。

大多数命令行 JavaScript 测试库都可以与 Vue.js 集成;在 awesome Vue GitHub 存储库(github.com/vuejs/awesome-vue#test)中有一个很棒的列表可用。

浏览器自动化

使用命令行单元测试的替代方法是使用测试套件自动化浏览器。这种测试仍然通过命令行触发,但不是直接与 Vue 应用程序集成,而是在浏览器中打开页面并像用户一样与之交互。一个常用的工具是Nightwatch.jsnightwatchjs.org/)。

您可以使用这个套件来打开您的商店,并与过滤组件或产品列表排序进行交互,并比较结果。这些测试用例使用非正式的英语编写,并不限于与要测试的站点在同一域名或文件网络上。该库也是语言无关的,适用于任何网站,无论它是用什么构建的。

Nightwatch.js在他们的网站上给出的示例是打开 Google 并确保rembrandt van rijn的谷歌搜索结果的第一个结果是维基百科条目:

module.exports = {
  'Demo test Google' : function (client) {
    client
      .url('http://www.google.com')
      .waitForElementVisible('body', 1000)
      .assert.title('Google')
      .assert.visible('input[type=text]')
      .setValue('input[type=text]', 'rembrandt van rijn')
      .waitForElementVisible('button[name=btnG]', 1000)
      .click('button[name=btnG]')
      .pause(1000)
      .assert.containsText('ol#rso li:first-child',
        'Rembrandt - Wikipedia')
      .end();
  }
};

Nightwatch 的替代品是 Selenium(www.seleniumhq.org/)。 Selenium 的优点是有一个 Firefox 扩展,可以让您可视化地创建测试和命令。

测试,特别是对于大型应用程序来说,是至关重要的,尤其是在将应用程序部署到开发环境时。无论您选择单元测试还是浏览器自动化,都有大量关于这个主题的文章和书籍可供参考。

摘要

到目前为止,我们创建了一个模拟商店。使用 Shopify CSV 文件中的真实数据,我们创建了一个允许单独查看产品的应用程序。我们还创建了一个可以进行过滤和排序的类别列表页面,使用户可以找到他们想要的特定产品。为了完善体验,我们构建了一个可编辑的购物篮、结账和订单确认屏幕。在本章中,我们介绍了 Vue 开发工具的使用,以及如何构建测试。

第十三章:过渡和动画

本章将介绍以下内容:

  • 与 animate.css 等第三方 CSS 动画库集成

  • 添加自定义的过渡类

  • 使用 JavaScript 而不是 CSS 进行动画处理

  • 在初始渲染上进行过渡

  • 元素之间的过渡

  • 在过渡中,在进入阶段之前让元素离开

  • 为列表中的元素添加进入和离开过渡

  • 在列表中移动的元素进行过渡

  • 对组件的状态进行动画处理

  • 将可重用的过渡打包到组件中

  • 动态过渡

介绍

本章包含与过渡和动画相关的示例。Vue 具有用于处理元素进入或离开场景的过渡的标签:。您将学习如何使用它们,以便为您的客户提供更好的用户体验。

Vue 过渡非常强大,因为它们是完全可定制的,并且可以轻松地结合 JavaScript 和 CSS 样式,同时具有非常直观的默认值,这样您就可以在不需要所有花哨效果的情况下编写更少的代码。

即使没有过渡标签,您也可以对组件中发生的大部分内容进行动画处理,因为您只需要将状态变量绑定到某些可见属性即可。

最后,一旦您掌握了关于 Vue 过渡和动画的所有知识,您可以轻松地将它们打包到分层组件中,并在整个应用程序中重复使用它们。这不仅使它们功能强大,而且易于使用和维护。

与 animate.css 等第三方 CSS 动画库集成

图形界面不仅需要可用性和易于理解,还应提供可负担性和愉悦性。通过提供过渡效果,可以以有趣的方式提供网站的工作方式的线索,这对于帮助很大。在这个示例中,我们将介绍如何在应用程序中使用 CSS 库。

准备工作

在开始之前,您可以查看daneden.github.io/animate.css/,如图所示,以了解可用的动画效果,但您实际上不需要任何特殊的知识来继续:

如何操作...

想象一下,您正在创建一个预订出租车的应用程序。我们将创建的界面将简单而有趣。

首先,将animate.css库添加到依赖列表中(参考选择开发环境教程来了解如何做)。

为了继续,我们需要我们通常的包装器:

<div id="app"> 
</div>

在其中,我们将放置一个按钮来叫出租车:

<button @click="taxiCalled = true"> 
  Call a cab 
</button>

您已经可以看出我们将使用taxiCalled变量来跟踪按钮是否已被按下

让我们添加一个表情符号,以向用户确认出租车已被叫到:

<p v-if="taxiCalled"></p>

此时,我们可以添加一些 JavaScript 代码:

new Vue({ 
  el: '#app', 
  data: { 
    taxiCalled: false 
  } 
})

运行应用程序,当您按下按钮时,您将立即看到出租车出现。我们是一家很酷的出租车公司,所以让我们让出租车通过过渡来到我们这里:

<transition  
  enter-active-class="animated slideInRight"> 
  <p v-if="taxiCalled"></p> 
</transition>

现在运行您的应用程序;如果您叫出租车,它将从右侧滑动到您这里:

出租车将从右向左滑动,如图所示:

它是如何工作的...

每个过渡应用四个类。两个类应用于元素进入场景时,另外两个类应用于元素离开时:

名称 应用于 移除于
v-enter 元素插入之前 一帧后
v-enter-active 元素插入之前 过渡结束时
v-enter-to 一帧后 过渡结束时
v-leave 过渡开始 一帧后
v-leave-active 过渡开始 过渡结束时
v-leave-to 一帧后 过渡结束时

在这里,初始的v代表您的过渡名称。如果您没有指定名称,将使用v

虽然过渡的开始是一个明确定义的瞬间,但过渡的结束对于浏览器来说需要一些工作。例如,如果 CSS 动画循环,动画的持续时间只有一个迭代。此外,这可能会在将来的版本中发生变化,所以请记住这一点。

在我们的情况下,我们希望提供一个第三方的v-enter-active,而不是编写我们自己的。问题是,我们的库已经为我们想要使用的动画类(slideInRight)命名为不同的名称。由于我们无法更改类的名称,我们告诉 Vue 使用slideInRight而不是寻找v-enter-active类。

为了做到这一点,我们使用了以下代码:

<transition enter-active-class="animated slideInRight">

这意味着我们的v-enter-active现在被称为animated slideInRight。Vue 将在元素插入之前附加这两个类,并在过渡结束时删除它们。只需注意,animated 是一种带有animate.css的辅助类。

添加自己的过渡类

如果您的应用程序富含动画,并且希望通过混合和匹配在其他项目中重用您的 CSS 类,那么这就是适合您的示例。您还将了解一种重要的性能动画技术,称为 FLIP(First Last Invert Play)。虽然后一种技术通常由 Vue 自动触发,但我们将手动实现它,以更好地理解其工作原理。

准备工作

要完成此示例,您应该了解 CSS 动画和过渡的工作原理。这超出了本书的范围,但您可以在css3.bradshawenterprises.com/上找到一个很好的入门指南。这个网站也很棒,因为它会解释何时可以使用动画和过渡。

如何操作...

我们将为出租车公司构建一个界面(类似于前面的示例),用户可以通过点击按钮来叫出租车,并在叫出租车时提供一个漂亮的动画反馈。

要编写按钮,请编写以下 HTML 代码:

<div id="app"> 
  <button @click="taxiCalled = true"> 
    Call a cab 
  </button> 
  <p v-if="taxiCalled"></p> 
</div>

然后,您可以在 JavaScript 中将taxiCalled变量初始化为false,如下所示:

new Vue({ 
  el: '#app', 
  data: { 
    taxiCalled: false 
  } 
})

此时,我们将在 CSS 中创建自定义过渡:

.slideInRight { 
  transform: translateX(200px); 
} 

.go { 
  transition: all 2s ease-out; 
}

将您的汽车表情包装在 Vue 过渡中:

<transition  
  enter-class="slideInRight" 
  enter-active-class="go"> 
  <p v-if="taxiCalled"></p> 
</transition>

当您运行代码并点击“叫出租车”按钮时,您将看到一辆出租车停在旁边。

工作原理...

当我们点击按钮时,taxiCalled变量变为true,Vue 会将出租车插入到您的页面中。在实际执行此操作之前,它会读取您在enter-class中指定的类(在本例中仅为slideInRight),并将其应用于包装元素(带有出租车表情的<p>元素)。它还会应用在enter-class-active中指定的类(在本例中仅为 go)。

enter-class中的类在第一帧后被移除,enter-class-active中的类在动画结束时也被移除。

此处创建的动画遵循 FLIP 技术,由四个要点组成:

  • First (F):您将属性保持在动画的第一帧中;在我们的例子中,我们希望出租车从屏幕右侧的某个位置开始。

  • Last (L):你将属性保持在动画的最后一帧中,对于我们的情况来说,就是屏幕左侧的出租车。

  • Invert (I):你反转在第一帧和最后一帧之间注册的属性变化。由于我们的出租车向左移动,在最后一帧中它将位于-200 像素的偏移位置。我们反转这个并设置slideInRight类,使得 transform 为translateX(200px),这样出租车出现时将位于+200 像素的偏移位置。

  • Play (P):我们为每个已触摸的属性创建一个过渡效果。在出租车的例子中,我们使用了 transform 属性,因此我们使用writetransition: all 2s ease-out来使出租车平滑过渡。

Vue 在内部自动使用这种技术来使得在<transition-group>标签内的过渡效果正常工作。关于这一点,我们将在为列表中的元素添加进入和离开过渡效果的食谱中详细介绍。

使用 JavaScript 而不是 CSS 进行动画

有一个普遍的误解,即使用 JavaScript 进行动画会更慢,而动画应该在 CSS 中完成。事实是,如果使用正确,JavaScript 中的动画可以具有相似或更好的性能。在这个食谱中,我们将使用简单但强大的 Velocity.js(velocityjs.org/)库创建一个动画:

准备工作

这个食谱假设你对 Velocity 库没有任何了解,但假设你对 CSS 或 JavaScript 库(如 jQuery)中的动画非常熟悉。如果你从未见过 CSS 动画并且想要快速入门,只需完成前两个食谱,你就能跟上。

如何实现...

我们仍在寻找一个完美的过渡效果,用于等待出租车时娱乐我们的客户(与前一个食谱中相同)。我们有一个按钮来呼叫出租车,当我们预订时会出现一个小的出租车表情符号。

在任何其他操作之前,将 Velocity 库作为项目的依赖项添加进来--cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js

这是创建界面框架的 HTML 代码:

<div id="app"> 
  <button @click="taxiCalled = true"> 
    Call a cab 
  </button> 
  <p v-if="taxiCalled"></p> 
</div>

我们的 Vue 模型非常简单,只包含一个taxiCalled变量:

new Vue({ 
  el: '#app', 
  data: { 
    taxiCalled: false 
  } 
})

通过将小出租车包装在 Vue 过渡中来创建动画:

<transition 
  @enter="enter" 
  :css="false" 
> 
<p v-if="taxiCalled"></p> 
</transition>

当按下按钮插入出租车表情符号时,将调用 enter 方法。

enter 方法,您需要将其添加到 Vue 实例中,如下所示:

methods: { 
    enter (el) { 
      Velocity(el,  
      { opacity: [1, 0], translateX: ["0px", "200px"] }, 
      { duration: 2000, easing: "ease-out" }) 
    } 
  }

运行代码并按下按钮预订您的出租车!

它是如何工作的...

正如你可能已经注意到的,你的代码中没有 CSS。动画完全由 JavaScript 驱动。让我们稍微解析一下我们的 Vue 过渡:

<transition 
  @enter="enter" 
  :css="false" 
> 
  <p v-if="taxiCalled"></p> 
</transition>

虽然这仍然是一个可以使用 CSS 的过渡,但我们想告诉 Vue 关闭 CSS 并节省宝贵的 CPU 周期,通过设置:css="false"。这将使 Vue 跳过与 CSS 动画相关的所有代码,并防止 CSS 干扰我们纯粹的 JavaScript 动画。

多汁的部分在@enter="enter"这一部分。我们将触发元素插入时的钩子绑定到enter方法上。方法本身如下:

enter (el) { 
  Velocity(el,  
    { opacity: [1, 0], translateX: ["0px", "200px"] }, 
    { duration: 2000, easing: "ease-out" }
  ) 
}

在这里,我们调用了 Velocity 库。el参数由 Vue 免费传递,并且它指的是被插入的元素(在我们的例子中,是包含汽车表情的<p>元素)。

Velocity 函数的语法如下所示:

Velocity( elementToAnimate, propertiesToAnimate, [options] )

还有其他语法可能,但我们将坚持使用这种语法。

在调用此函数时,我们将段落元素作为第一个参数传递;然后我们说不透明度应该从 0 变为 1,并且同时,元素应该从 x 轴上的起始位置 200 像素移动到其原点。作为选项,我们指定动画持续时间为两秒,并且我们希望在接近结束时缓和动画。

我认为一切都很清楚,除了我们如何传递opacitytranslateX参数。

这就是 Velocity 所谓的强制喂食--我们告诉 Velocity 不透明度应该从 0 开始到 1 结束。同样,我们告诉 VelocitytranslateX属性应该从 200 像素开始,结束于 0 像素。

通常情况下,我们可以避免传递数组来指定属性的初始值;Velocity 会计算如何过渡。

例如,我们可以有以下 CSS 类:

p { 
  opacity: 0; 
}

如果我们将 Velocity 调用重写如下:

Velocity(el,  
  { opacity: 1 } 
)

汽车将慢慢出现。Velocity 查询了元素的初始值,然后将其过渡到 1。这种方法的问题是,由于涉及对 DOM 的查询,某些动画可能会变慢,特别是当您有很多并发动画时。

我们可以通过使用 begin 选项来获得与强制喂食相同的效果,如下所示:

Velocity(el,  
  { opacity: 1 }, 
  { begin: () => { el.style.opacity = 0 } } 
)

这将在动画开始之前(因此在元素插入之前)将不透明度设置为零。这将有助于在较慢的浏览器中,强制显示仍然会在将其完全移到右侧并开始动画之前显示一闪而过的汽车。

JavaScript 动画的可能钩子在下表中总结:

属性 描述
@before-enter 在元素插入之前调用此函数。
@enter 当元素插入时调用此函数。
@after-enter 当元素插入并且动画完成时调用此函数。
@enter-cancelled 当动画仍在进行中但元素必须离开时调用此函数。如果使用 Velocity,可以执行类似于Velocity(el, "stop")的操作。
@before-leave 在触发离开函数之前调用此函数。
@leave 当元素离开时调用此函数。
@after-leave 当元素离开页面时调用此函数。
@leave-cancelled 在离开调用完成之前,如果必须插入元素,则调用此函数。仅适用于 v-show。

请记住,这些钩子对于任何库都有效,不仅仅适用于 Velocity。

还有更多...

我们可以尝试使用这个界面来实现一个取消按钮。如果用户错误地预订了出租车,点击取消按钮将删除预订,并且通过小的出租车表情消失来表明。

首先,让我们添加一个取消按钮:

<button @click="taxiCalled = false">
  Cancel
</button>

这很容易;现在我们添加离开过渡效果:

<transition 
  @enter="enter" 
  @leave="leave" 
  :css="false" 
> 
  <p v-if="taxiCalled"></p> 
</transition>

这将引导我们到离开方法:

leave (el) { 
  Velocity(el, 
    { opacity: [0, 1], 'font-size': ['0.1em', '1em'] }, 
    { duration: 200}) 
}

我们正在做的是使表情符号消失并缩小。

如果您尝试运行代码,您将遇到一些问题。

当您点击取消按钮时,应该发生的是离开动画应该开始,出租车应该变小并最终消失。相反,什么都不会发生,出租车会突然消失。

取消动画无法按计划播放的原因是因为动画是用 JavaScript 编写的,而不是 CSS,Vue 无法判断动画何时完成。特别是,发生的情况是 Vue 认为离开动画在开始之前就已经完成了。这就是我们的汽车消失的原因。

关键在于第二个参数。每个钩子都调用一个带有两个参数的函数。我们已经看到了第一个参数el,它是动画的主体。第二个参数是一个回调函数,当调用时,告诉 Vue 动画已经完成。

我们将利用 Velocity 有一个名为complete的选项,它期望在动画(从 Velocity 的角度)完成时调用一个函数。

让我们使用这些新信息重写我们的代码:

leave (el, done) { 
  Velocity(el, 
  { opacity: [0, 1], 'font-size': ['0.1em', '1em'] }, 
  { duration: 200 }) 
}

向我们的函数添加done参数,让 Vue 知道我们希望在动画完成时调用回调函数。我们不需要显式使用回调函数,因为 Vue 会自动找到它,但是由于依赖默认行为总是一个坏主意(如果它们没有记录,它们可能会改变),让我们在动画完成时调用done函数:

leave (el, done) { 
  Velocity(el, 
  { opacity: [0, 1], 'font-size': ['0.1em', '1em'] }, 
  { duration: 200, complete: done }) 
}

运行代码并按下取消按钮来取消您的出租车!

在初始渲染上进行过渡

通过使用appear关键字,我们可以在元素首次加载时为其添加过渡效果。这有助于提高用户体验,因为它给人一种页面更具响应性和加载速度更快的印象,尤其是当应用于多个元素时。

准备工作

这个示例不假设任何特定的知识,但如果您至少完成了使用 CSS 过渡为您的应用程序增添一些乐趣示例,那么这将是小菜一碟。

操作步骤...

我们将建立一个关于美国演员 Fill Murray 的页面;不,不是 Bill Murray。您可以在www.fillmurray.com找到关于他的更多信息。我们将使用这个网站的图片来填充我们关于他的页面。

在我们的 HTML 中,让我们写一个标题作为页面的标题:

<h1> 
  The Fill Murray Page 
</h1>

在标题之后,我们将放置我们的 Vue 应用程序:

<div id="app"> 
  <img src="https://fillmurray.com/50/70"> 
  <p> 
    The internet was missing the ability to 
    provide custom-sized placeholder images of Bill Murray. 
    Now it can. 
  </p> 
</div>

在浏览器中呈现时,将显示如下:

我们的页面现在非常简单。我们希望 Fill Murray 的图片淡入。我们必须将其包装在一个过渡中:

<transition appear> 
  <img src="https://fillmurray.com/50/70"> 
</transition>

以下是 CSS 类:

img { 
  float: left; 
  padding: 5px 
} 
.v-enter { 
  opacity: 0 
} 
.v-enter-active { 
  transition: opacity 2s 
}

现在运行我们的页面将使图片慢慢出现,但也会移动文本。为了修复这个问题,我们必须提前指定图片的大小:

<transition appear> 
  <img src="https://fillmurray.com/50/70" width="50" height="70"> 
</transition>

这样,我们的浏览器将为即将出现的图片预留一些空间。

工作原理...

transition标签中的appear指令将使组件在首次出现时带有关联的过渡效果(如果找到)。

在组件的第一次渲染中,有很多可能的方法来指定一个过渡。在所有情况下,必须指定appear指令。

当这个指令存在时,Vue 将首先寻找 JavaScript 钩子或在标签中指定的 CSS 类:

<transition 
  appear 
  @before-appear="customBeforeAppearHook" 
  @appear="customAppearHook" 
  @after-appear="customAfterAppearHook" 
  appear-class="custom-appear-class" 
  appear-active-class="custom-appear-active-class" 
> 
  <p>My element</p> 
</transition>

之后,如果指定了一个名称,Vue 将会寻找该元素的入场过渡:

<transition appear name="myTransition"> 
  <p>My element</p> 
</transition>

上述代码将寻找以下命名的类:

.myTransition-enter {...} 
.myTransition-enter-active {...}

如果其他方法都失败了,Vue 将会寻找元素插入的默认 CSS 类(v-enterv-enter-active)。顺便说一句,这就是我们在这个示例中所做的。

依赖这些默认值并不是一个好的实践;在这里,我们只是作为演示而这样做。你应该总是给你的过渡命名。

也许值得一提的是,为什么我们必须为图像添加宽度和高度。原因是当我们在 HTML 中指定一个图像 URL 时,浏览器不知道图像的大小,所以默认情况下不会为其保留任何空间。只有通过提前指定图像的大小,浏览器才能在图像加载之前正确地组合页面。

元素之间的过渡

网页上的每个东西都是一个元素。通过 Vue 的v-ifv-show指令,你可以轻松地使它们出现和消失。通过过渡,你甚至可以轻松地控制它们的出现,并添加魔法效果。本示例将解释如何做到这一点。

准备工作

对于这个示例,你应该对 Vue 过渡和 CSS 的工作原理有一些了解。

如何做到这一点...

既然我们谈到了魔法,我们将把一只青蛙变成一位公主。变化本身将是一个过渡。

我们将实例化一个按钮,当按下时,它将代表对青蛙的一个吻:

<div id="app"> 
  <button @click="kisses++">Kiss!</button> 
</div>

每次按下按钮时,变量 kisses 都会增加。变量将被初始化为零,如下面的代码所示:

new Vue({ 
   el: '#app', 
  data: { 
   kisses: 0 
  } 
})

接下来,我们需要青蛙和公主,我们将在按钮之后立即添加它们:

<transition name="fade"> 
  <p v-if="kisses < 3" key="frog">frog</p> 
  <p v-if="kisses >= 3" key="princess">princess</p> 
</transition>

淡入淡出过渡的 CSS 代码如下:

.fade-enter-active, .fade-leave-active { 
  transition: opacity .5s 
} 
.fade-enter, .fade-leave-active { 
  opacity: 0 
}

为了使其正常工作,我们需要添加一个最后的 CSS 选择器:

p { 
  margin: 0; 
  position: absolute; 
  font-size: 3em; 
}

如果你运行应用程序并点击足够多次的吻按钮,你应该会看到你的青蛙变成一位公主:

这个过渡将有一个淡入淡出的效果:

青蛙表情符号将变成公主表情符号:

它是如何工作的...

当我们写下这两个元素时,我们使用了key属性来指定谁是青蛙,谁是公主。这是因为,否则 Vue 优化系统将会启动。它会看到两个元素的内容可以互换,而不必交换元素本身,并且由于元素是相同的,只有内容发生了变化,所以不会发生过渡。

如果我们移除key属性,我们可以亲眼看到青蛙和公主会发生变化,但没有任何过渡效果:

<transition name="fade"> 
  <p v-if="kisses < 3">frog</p> 
  <p v-if="kisses >= 3">princess</p> 
</transition>

考虑到我们使用了两个不同的元素,如下所示:

<p v-if="kisses < 3" >frog</p> 
<span v-if="kisses >= 3">princess</span>

此外,我们相应地修改了<p>的 CSS 选择器:

p, span { 
  margin: 0; 
  position: absolute; 
  font-size: 3em; 
  display: block; 
}

现在,如果我们再次启动应用程序,一切都可以正常工作,而不需要使用任何key属性。

即使在不必要的情况下,使用 key 通常也是推荐的,就像前面的情况一样。这尤其适用于项目具有不同的语义含义的情况。这样做的原因有几个。主要原因是,当多个人在同一行代码上工作时,修改key属性不会像将span元素切换回p元素那样容易破坏应用程序,这会破坏我们刚刚看到的过渡效果。

还有更多...

在这里,我们涵盖了前面示例的两个子情况:在多个元素之间切换和绑定key属性。

在多个元素之间进行过渡

我们可以按照简单的方式扩展我们刚刚完成的示例。

假设如果我们亲吻公主太多次,她会变成圣诞老人,这可能会或可能不会吸引人,这取决于你的年龄。

首先,我们添加第三个元素:

<transition name="fade"> 
  <p v-if="kisses < 3" key="frog">frog</p> 
  <p v-else-if="kisses >= 3 && kisses <= 5" key="princess">princess</p> 
  <p v-else key="santa">santa</p> 
</transition>

我们可以立即启动应用程序,当我们亲吻公主/青蛙超过五次时,圣诞老人将以相同的淡入淡出过渡效果出现:

使用这种设置,我们在使用第一个和第二个元素之间使用相同的过渡时受到限制。

动态过渡的示例中,有一个解决方法。

动态设置 key 属性

如果我们已经有一些可用的数据,我们不必为所有元素编写 key。我们可以以以下方式编写相同的应用程序,但不重复元素:

<transition name="fade">
  <p :key="transformation">{{emoji}}{{transformation}}</p>
</transition>

当然,这意味着我们必须根据亲吻的次数为transformationemoji变量提供一个合理的值。

为了做到这一点,我们将把它们与计算属性绑定起来:

computed: { 
  transformation () { 
    if (this.kisses < 3) { 
      return 'frog' 
    } 
    if (this.kisses >= 3 && this.kisses <= 5) { 
      return 'princess' 
    } 
    if (this.kisses > 5) { 
      return 'santa' 
    } 
  }, 
  emoji () { 
    switch (this.transformation) { 
      case 'frog': return '' 
      case 'princess': return '' 
      case 'santa': return '' 
    } 
  } 
}

我们在模板中牺牲了一些复杂性,换取了 Vue 实例中更多的逻辑。如果我们预计将来会有更复杂的逻辑或者转换数量增加,这可能是有益的。

在过渡中让元素在进入阶段之前离开

元素之间过渡的示例中,我们探讨了如何在两个元素之间进行过渡。Vue 的默认行为是在第一个元素离开的同时开始进入元素的过渡;这并不总是理想的。

您将在本示例中了解到这个重要的特殊情况以及如何解决它。

准备工作

这个示例是在两个元素之间的过渡的基础上构建的,解决了一个特定的问题。如果您不知道我们在谈论什么,返回上一个示例,您将很快跟上。

如何做...

首先,如果您还没有遇到这个问题,您将看到问题。接下来,我们将看到 Vue 为我们提供的解决方案。

两个元素的问题

让我们在我们的网站上创建一个轮播效果。用户一次只能查看一个产品,然后他将滑动到下一个产品。要滑动到下一个产品,用户需要点击一个按钮。

首先,我们需要在 Vue 实例中有我们的产品列表:

new Vue({ 
  el: '#app', 
  data: { 
    product: 0, 
    products: ['umbrella', 'computer', 'ball', 'camera'] 
  } 
})

在我们的 HTML 中,我们只需要一个按钮和一个元素的视图:

<div id="app"> 
  <button @click="product++">next</button> 
  <transition name="slide"> 
    <p :key="products[product % 4]">{{products[product % 4]}}</p> 
  </transition> 
</div>

模 4(product % 4)只是因为我们希望在产品列表结束时重新开始。

为了设置我们的滑动过渡,我们需要以下规则:

.slide-enter-active, .slide-leave-active { 
  transition: transform .5s 
} 
.slide-enter { 
  transform: translateX(300px) 
} 
.slide-leave-active { 
  transform: translateX(-300px); 
}

此外,为了使一切看起来好看,我们最后完成了以下内容:

p { 
  position: absolute; 
  margin: 0; 
  font-size: 3em; 
}

如果现在运行代码,您将看到一个漂亮的轮播图:

现在,让我们尝试从最后一个规则中删除position: absolute

p { 
  margin: 0; 
  font-size: 3em; 
}

如果您现在尝试您的代码,您将看到产品之间的奇怪跳动:

这是我们试图解决的问题。第二个过渡在第一个产品离开之前就开始了。如果定位不是绝对的,我们会看到一些奇怪的效果。

过渡模式

为了解决这个问题,我们将改变过渡模式。让我们修改<transition>的代码:

<transition name="slide" mode="out-in"> 
  <p :key="products[product%4]">{{products[product%4]}}</p> 
</transition>

现在运行您的程序,您将看到产品在滑入屏幕之前需要更长的时间。它们在进入之前等待上一个项目离开。

工作原理...

总之,您有两种不同的方法来管理 Vue 组件之间的过渡。默认方式是同时开始“in”过渡和“out”过渡。我们可以通过以下方式明确表示:

<transition mode="in-out"> 
  <!-- elements --> 
</transition>

我们可以通过等待“out”部分完成后再开始“in”动画来改变这种默认行为。我们通过以下方式实现了这一点:

<transition mode="out-in"> 
  <!-- elements --> 
</transition>

前者在元素具有绝对样式位置时很有用,而后者在我们真正需要等待在页面上放置更多内容之前有一个清晰的方法时更相关。

绝对定位不会在意元素之间的重叠,因为它们不遵循页面的流动。另一方面,静态定位将在第一个元素之后追加第二个元素,如果两个元素同时显示,则过渡会变得尴尬。

为列表中的元素添加进入和离开过渡

在这里,我们将尝试添加一种视觉方式来暗示列表中的元素是添加还是删除。这可以为用户体验增添很多,因为您有机会向用户建议为什么添加或删除元素。

准备工作

对 CSS 和过渡有一些了解会有所帮助。如果您觉得有必要,只需浏览本章中的其他示例。

如何做...

我们将建立一个学习编程的教学大纲。当我们完成一个主题时,我们会感到宽慰,并希望通过让主题从教学大纲中飘走来将这种感觉融入我们的应用程序中。

列表的数据将在我们的 Vue 实例中:

new Vue({ 
  el: '#app', 
  data: { 
    syllabus: [ 
      'HTML', 
      'CSS', 
      'Scratch', 
      'JavaScript', 
      'Python' 
    ] 
  } 
})

列表将在我们的 HTML 中打印出以下代码:

<div id="app"> 
  <h3>Syllabus</h3> 
  <ul> 
    <li v-for="topic in syllabus"> 
      {{topic}} 
    </li> 
  </ul> 
</div>

当我们按下按钮时,我们希望主题从列表中消失。为了实现这一点,我们需要修改我们已经编写的代码。

首先,在每个主题之前添加一个“完成”按钮:

<li v-for="topic in syllabus"> 
  <button @click="completed(topic)">Done</button>{{topic}} 
</li>

在这里,completed 方法将如下所示:

methods: { 
  completed (topic) { 
    let index = this.syllabus.indexOf(topic) 
    this.syllabus.splice(index, 1) 
  } 
}

现在运行代码将显示一个简单的应用程序,用于勾选我们已经学习过的主题。不过,我们想要的是一种让我们感到宽慰的动画。

为此,我们需要编辑我们列表的容器。我们删除<ul>标签,并告诉<transition-group>编译为<ul>标签:

<transition-group tag="ul"> 
  <li v-for="topic in syllabus" :key="topic"> 
    <button @click="completed(topic)">Done</button>{{topic}} 
  </li> 
</transition-group>

请注意,我们还根据主题为每个列表元素添加了一个键。我们需要做的最后一件事是将过渡规则添加到我们的 CSS 中:

.v-leave-active { 
  transition: all 1s; 
  opacity: 0; 
  transform: translateY(-30px); 
}

现在,主题将在点击“完成”按钮时以过渡方式消失,如下所示:

它是如何工作的...

<transition-group>标签表示一组元素的容器,这些元素将同时显示。默认情况下,它表示<span>标签,但通过将标签属性设置为ul,我们使其表示无序列表。

列表中的每个元素必须具有唯一的键,否则转换将无法工作。Vue 将负责对每个进入或离开的元素应用转换。

在列表中移动的过渡元素

在这个教程中,您将构建一个元素列表,根据列表的变化而移动。当您想告诉用户某些内容已经改变并且列表已相应更新时,这种特定的动画是有用的。它还将帮助用户识别插入元素的位置。

准备工作

这个教程有点高级;如果您对 Vue 中的过渡不是很熟悉,我建议您先完成本章中的一些教程。如果您可以轻松完成为列表元素添加进入和离开过渡教程,那就可以继续了。

如何做...

您将构建一个小游戏--一个公交车站模拟器!

每当一辆公交车--由其表情符号表示--离开车站时,所有其他公交车都会稍微前进以占据它的位置。每辆公交车都由一个数字标识,您可以从 Vue 实例数据中看到:

new Vue({ 
  el: '#app', 
  data: { 
    buses: [1,2,3,4,5], 
    nextBus: 6 
  } 
})

每当新的公交车到达时,它将被分配一个递增的编号。我们希望每两秒钟有一辆新的公交车离开或到达。我们可以通过在组件挂载到屏幕时挂接一个计时器来实现这一点。在数据之后,立即编写以下内容:

mounted () { 
  setInterval(() => { 
    const headOrTail = () => Math.random() > 0.5 
    if (headOrTail()) { 
      this.buses.push(this.nextBus) 
      this.nextBus += 1 
    } else { 
      this.buses.splice(0, 1) 
    } 
  }, 2000) 
}

我们应用的 HTML 将如下所示:

<div id="app"> 
  <h3>Bus station simulator</h3> 
  <transition-group tag="p" name="station"> 
    <span v-for="bus in buses" :key="bus"></span> 
  </transition-group> 
</div>

为了让公交车四处移动,我们需要在前缀站下指定一些 CSS 规则:

.station-leave-active, .station-enter-active { 
  transition: all 2s; 
  position: absolute; 
} 

.station-leave-to { 
  opacity: 0; 
  transform: translateX(-30px); 
} 

.station-enter { 
  opacity: 0; 
  transform: translateX(30px); 
} 

.station-move { 
  transition: 2s; 
} 

span { 
  display: inline-block; 
  margin: 3px; 
}

现在启动应用将导致一个有序的公交车队列,每两秒钟有一辆公交车离开或到达:

工作原理...

我们应用的核心是<transition-group>标签。它管理所有通过它们的键标识的公交车:

<transition-group tag="p" name="station"> 
  <span v-for="bus in buses" :key="bus"></span> 
</transition-group>

每当一辆公交车进入或离开场景时,Vue 将自动触发 FLIP 动画(参见添加自己的过渡类教程)。

为了更好地理解,假设我们有公交车[1, 2, 3],公交车 1 离开了。接下来发生的是,在动画实际开始之前,将记住第一辆公交车的<span>元素的属性。因此,我们可以检索到描述属性的以下对象:

{ 
  bottom:110.4375 
  height:26 
  left:11 
  right:27 
  top:84.4375 
  width:16 
}

Vue 对<transition-group>标签内的所有元素都这样做。

之后,station-leave-active类将应用于第一辆公交车。让我们简要回顾一下规则是什么:

.station-leave-active, .station-enter-active { 
  transition: all 2s; 
  position: absolute; 
}

我们注意到位置变为绝对定位。这意味着元素从页面的正常流程中移除。这又意味着所有在它后面的公交车将突然移动填补留下的空间。Vue 在这个阶段记录所有公交车的属性,这被认为是动画的最终帧。这个帧实际上并不是一个真正显示的帧;它只是用来计算元素的最终位置的抽象:

Vue 将计算最终帧和起始帧之间的差异,并将应用样式,使公交车出现在初始帧,即使它们实际上并不在那里。这些样式将在一帧后被移除。公交车缓慢爬向它们的最终位置,而不是立即移动到它们的新位置,原因是它们是span元素,我们指定了任何变换样式(Vue 用来伪造它们位置一帧的样式)必须过渡两秒:

.station-move { 
  transition: 2s; 
}

换句话说,在第-1 帧,三辆公交车都在原位,并记录了它们的位置。

在第 0 帧,第一辆公交车从页面流中移除,其他公交车立即移动到它的后面。在同一帧中,Vue 记录它们的新位置,并应用一个变换,将公交车移回到它们在第-1 帧的位置,从视觉上看就好像没有人移动过。

在第 1 帧,变换被移除,但由于我们有一个过渡,公交车将缓慢移动到它们的最终位置。

动画化组件的状态

在计算机中,一切都是数字。在 Vue 中,一切都是数字的东西都可以以某种方式进行动画。在这个配方中,您将控制一个弹跳球,它将通过缓动动画平稳地定位自己。

准备工作

完成这个配方,您至少需要对 JavaScript 有一定的了解。JavaScript 的技术细节超出了本书的范围,但我会在它是如何工作的...部分为您解释代码,所以不要太担心。

如何做...

在我们的 HTML 中,我们只会添加两个元素:一个输入框,我们将在其中输入我们的弹跳球的期望位置,以及球本身:

<div id="app"> 
  <input type="number"> 
  <div class="ball"></div> 
</div>

为了正确渲染小球,写下这个 CSS 规则,它将出现在屏幕上:

.ball { 
  width: 3em; 
  height: 3em; 
  background-color: red; 
  border-radius: 50%; 
  position: absolute; 
  left: 10em; 
}

我们想要控制球的Y位置。为此,我们将绑定球的top属性:

<div id="app"> 
  <input type="number"> 
  <div class="ball" :style="'top: ' + height + 'em'"></div> 
</div>

高度将成为我们 Vue 实例模型的一部分:

new Vue({ 
   el: '#app', 
   data: { 
     height: 0 
   } 
})

现在,由于我们希望每当enteredHeight更改时,球在新位置上进行动画,一个想法是绑定输入元素的@change事件:

<div id="app"> 
  <input type="number" @input="move"> 
  <div class="ball" :style="'top: ' + height + 'em'"></div> 
</div>

move 方法将负责将球的当前高度缓慢过渡到指定值。

在执行此操作之前,您将将Tween.js库添加为依赖项。官方存储库位于github.com/tweenjs/tween.js。如果您在使用 JSFiddle,可以添加 README.md 页面中指定的 CDN 链接。

在添加库之后添加 move 方法,就像这样:

methods: { 
  move (event) { 
    const newHeight = Number(event.target.value) 
    const _this = this 
    const animate = (time) => { 
      requestAnimationFrame(animate) 
      TWEEN.update(time) 
    } 
    new TWEEN.Tween({ H: this.height }) 
      .easing(TWEEN.Easing.Bounce.Out) 
      .to({ H: newHeight }, 1000) 
      .onUpdate(function () { 
        _this.height = this.H 
      }) 
      .start() 
    animate() 
  } 
}

尝试启动应用程序,看到球在您编辑其高度时弹跳:

当我们改变高度时,球的位置也会改变:

它是如何工作的...

这里的一般原则是,您有一个元素或组件的状态。当状态是数字性质时,您可以根据特定的曲线或加速度从一个值“tween”(在之间)到另一个值。

让我们来分解代码,好吗?

我们要做的第一件事是将指定的新高度保存到newHeight变量中:

const newHeight = Number(event.target.value)

在下一行,我们还将 Vue 实例保存在_this辅助变量中:

const _this = this

我们这样做的原因一分钟后就会清楚:

const animate = (time) => { 
  requestAnimationFrame(animate) 
  TWEEN.update(time) 
}

在前面的代码中,我们将所有的动画包装在一个函数中。这是 Tween.js 库的惯用法,并且确定了我们将用于动画的主循环。如果我们有其他 Tween,这就是触发它们的地方:

new TWEEN.Tween({ H: this.height }) 
  .easing(TWEEN.Easing.Bounce.Out) 
  .to({ H: newHeight }, 1000) 
  .onUpdate(function () { 
    _this.height = this.H 
  }) 
.start()

这是对我们库的 API 调用。首先,我们创建一个对象,它将保存状态值的副本,而不是我们组件的状态。通常,在这里,您放置代表状态本身的对象。由于 Vue 的限制(或 Tween.js 的限制),我们使用了一种不同的策略;我们正在动画化状态的副本,并且我们正在为每一帧同步真实状态:

Tween({ H: this.height })

第一行将此副本初始化为球的当前实际高度:

easing(TWEEN.Easing.Bounce.Out)

我们选择缓动效果来模拟弹跳球:

.to({ H: newHeight }, 1000)

这行设置了目标高度和动画应持续的毫秒数:

onUpdate(function () { 
  _this.height = this.H 
})

在这里,我们将动画的高度复制回真实的事物。由于此函数将 this 绑定到复制的状态,我们被迫使用 ES5 语法来访问它。这就是为什么我们有一个变量准备好引用 Vue 实例的原因。如果我们使用了 ES6 语法,我们将无法直接获取H的值。

将可重用的过渡效果打包到组件中

我们可能在网站中有一个重要的过渡效果,我们希望在用户漏斗中重复使用。如果您试图保持代码有序,将过渡效果打包到组件中可能是一个很好的策略。在这个示例中,您将构建一个简单的过渡组件。

准备工作

如果您已经通过 Vue 的过渡效果工作过,那么遵循这个示例是有意义的。此外,由于我们正在使用组件,您至少应该对它们有所了解。浏览下一章以了解组件的基础知识。特别是,我们将创建一个功能性组件,其解剖结构在创建功能性组件示例中有详细说明。

操作步骤...

我们将为新闻门户构建一个特色过渡效果。实际上,我们将使用优秀的 magic 库中的预制过渡效果(github.com/miniMAC/magic),因此您应该将其添加到项目中作为依赖项。您可以在cdnjs.com/libraries/magic找到 CDN 链接(转到页面查找链接,不要将其复制为链接)。

首先,您将构建网站页面,然后构建过渡效果本身。最后,您将只需将过渡效果添加到不同的元素中。

构建基本网页

我们的网页将包括两个按钮,每个按钮将显示一个卡片:一个是食谱,另一个是最新的突发新闻:

<div id="app"> 
  <button @click="showRecipe = !showRecipe"> 
    Recipe 
  </button> 
  <button @click="showNews= !showNews"> 
    Breaking News 
  </button> 
  <article v-if="showRecipe" class="card"> 
    <h3> 
      Apple Pie Recipe 
    </h3> 
    <p> 
      Ingredients: apple pie. Procedure: serve hot. 
    </p> 
  </article> 
  <article v-if="showNews" class="card"> 
    <h3> 
      Breaking news 
    </h3> 
    <p> 
      Donald Duck is the new president of the USA. 
    </p> 
  </article> 
</div>

由于以下 CSS 规则,卡片将具有其独特的触感:

.card { 
  position: relative; 
  background-color: FloralWhite; 
  width: 9em; 
  height: 9em; 
  margin: 0.5em; 
  padding: 0.5em; 
  font-family: sans-serif; 
  box-shadow: 0px 0px 10px 2px rgba(0,0,0,0.3); 
}

JavaScript 部分将是一个非常简单的 Vue 实例:

new Vue({ 
  el: '#app', 
  data: { 
    showRecipe: false, 
    showNews: false 
  } 
})

运行此代码将会显示您的网页:

构建可重用的过渡效果

我们决定我们的网站在显示卡片时将会有一个过渡效果。由于我们打算在网站的所有地方重复使用动画,最好将其打包在一个组件中。

在 Vue 实例之前,我们声明了以下组件:

Vue.component('puff', { 
  functional: true, 
  render: function (createElement, context) { 
    var data = { 
      props: { 
        'enter-active-class': 'magictime puffIn', 
        'leave-active-class': 'magictime puffOut' 
      } 
    } 
    return createElement('transition', data, context.children) 
  } 
})

puffInpuffOut动画在magic.css中定义。

在我们网页中使用我们的过渡效果

现在,我们将编辑我们的网页,向卡片中添加<puff>组件:

<div id="app"> 
  <button @click="showRecipe = !showRecipe"> 
    Recipe 
  </button> 
  <button @click="showNews = !showNews"> 
    Breaking News 
  </button> 
 <puff> 
    <article v-if="showRecipe" class="card"> 
      <h3> 
        Apple Pie Recipe 
      </h3> 
      <p> 
        Ingredients: apple pie. Procedure: serve hot. 
      </p> 
    </article> 
 </puff> <puff> 
    <article v-if="showNews" class="card"> 
      <h3> 
        Breaking news 
      </h3> 
      <p> 
        Donald Duck is the new president of the USA. 
      </p> 
    </article> 
 </puff> 
</div>

现在,当按下按钮时,卡片将以“puff”效果出现和消失。

它是如何工作的...

我们代码中唯一棘手的部分是构建<puff>组件。一旦我们把它放在那里,无论我们放进去什么都会根据我们的过渡效果出现和消失。在我们的例子中,我们使用了一个已经制作好的过渡。在现实世界中,我们可能会制作一个非常复杂的动画,每次以相同的方式应用可能会很困难。将其打包在一个组件中更容易维护。

有两件事使<puff>组件作为可重用的过渡工作:

props: { 
  'enter-active-class': 'magictime puffIn', 
  'leave-active-class': 'magictime puffOut' 
}

在这里,我们指定了组件在进入和离开时必须采用的类;这里没有什么特别的,我们已经在与第三方 CSS 动画库集成,比如 animate.css配方中做过了。

最后我们返回实际元素:

return createElement('transition', data, context.children)

这一行创建了我们元素的根,是一个<transition>标签,只有一个子元素--context.children。这意味着子元素是未指定的;组件将把模板中传递的实际子元素作为子元素。在我们的例子中,我们传递了一些卡片,它们很快就显示出来了。

动态过渡

在 Vue 中,一个常数主题是反应性,当然,由于这个原因,过渡可以是动态的。不仅过渡本身,而且所有它们的属性都可以绑定到响应式变量上。这使我们对在任何给定时刻使用哪种过渡有很多控制。

准备工作

这个配方是建立在元素之间过渡配方之上的。如果你已经了解过渡,你不需要回去,但如果你觉得有所遗漏,最好先完成那个。

如何做...

我们将用一些吻把青蛙变成公主,但如果我们亲吻得太多,公主就会变成圣诞老人。当然,我们说的是表情符号。

我们的 HTML 设置非常简单:

<div id="app"> 
  <button @click="kisses++">Kiss!</button> 
  <transition :name="kindOfTransformation" :mode="transformationMode"> 
    <p :key="transformation">{{emoji}}{{transformation}}</p> 
  </transition> 
</div>

只需注意这里大多数属性都绑定到变量上。以下是 JavaScript 的展开方式。

首先,我们将创建一个包含所有数据的简单 Vue 实例:

new Vue({ 
el: '#app', 
  data: { 
    kisses: 0, 
    kindOfTransformation: 'fade', 
    transformationMode: 'in-out' 
  } 
})

我们所指的淡入淡出效果是以下 CSS:

.fade-enter-active, .fade-leave-active { 
  transition: opacity .5s 
} 
.fade-enter, .fade-leave-active { 
  opacity: 0 
}

变量 transformation 和 emoji 由两个计算属性定义:

computed: { 
  transformation () { 
    if (this.kisses < 3) { 
      return 'frog' 
    } 
    if (this.kisses >= 3 && this.kisses <= 5) { 
      return 'princess' 
    } 
    if (this.kisses > 5) { 
         return 'santa' 
    } 
  }, 
  emoji () { 
    switch (this.transformation) { 
      case 'frog': return '' 
      case 'princess': return '' 
      case 'santa': return '' 
    } 
  } 
}

当我们在青蛙和公主之间使用淡入淡出过渡时,我们希望在公主和青蛙之间使用其他过渡。我们将使用以下过渡类:

.zoom-leave-active, .zoom-enter-active { 
  transition: transform .5s; 
} 

.zoom-leave-active, .zoom-enter { 
  transform: scale(0) 
}

现在,由于我们将过渡的名称绑定到一个变量,我们可以很容易地以编程方式进行切换。我们可以通过将以下突出显示的行添加到计算属性中来实现这一点:

transformation () { 
  if (this.kisses < 3) { 
    return 'frog' 
  } 
  if (this.kisses >= 3 && this.kisses <= 5) { 
 this.transformationMode = 'out-in' 
    return 'princess' 
  } 
  if (this.kisses > 5) { 
 this.kindOfTransformation = 'zoom' 
    return 'santa' 
  } 
}

第一行添加的是为了避免在缩放转换开始时出现重叠(关于这一点,可以参考让元素在过渡期离开之前离开的内容)。

第二行添加的代码将动画切换为“缩放”。

为了使一切都以正确的方式出现,我们需要再添加一个 CSS 规则:

p { 
  margin: 0; 
  position: absolute; 
  font-size: 3em; 
}

这样好多了。

现在运行应用程序,看看两种不同的转换是如何动态使用的:

随着亲吻的次数增加,公主会缩小:

有了这个,圣诞老人会放大:

它是如何工作的...

如果你了解 Vue 中响应式的工作原理,就没有太多要补充的了。我们将过渡的名称绑定到kindOfTransformation变量,并在代码中从淡入淡出切换到缩放。我们还演示了<transition>标签的其他属性也可以随时更改。

第十四章:Vue 与互联网通信

在本章中,将涵盖以下配方:

  • 使用 Axios 发送基本的 AJAX 请求

  • 在发送之前验证用户数据

  • 创建一个表单并将数据发送到服务器

  • 在请求期间从错误中恢复

  • 创建 REST 客户端(和服务器!)

  • 实现无限滚动

  • 在发送请求之前处理请求

  • 防止 XSS 攻击到您的应用程序

介绍

Web 应用程序很少能够独立运行。使它们变得有趣的实际上是它们使我们能够以几年前不存在的创新方式与世界进行交流。

Vue 本身不包含任何机制或库来发起 AJAX 请求或打开网络套接字。因此,在本章中,我们将探讨 Vue 如何与内置机制和外部库进行交互,以连接到外部服务。

您将首先使用外部库发起基本的 AJAX 请求。然后,您将探索一些在表单中发送和获取数据的常见模式。最后,有一些具有真实应用程序的配方以及如何构建 RESTful 客户端。

使用 Axios 发送基本的 AJAX 请求

Axios 是 Vue 推荐的用于发起 HTTP 请求的库。它是一个非常简单的库,但它具有一些内置功能,可以帮助您执行常见操作。它实现了使用 HTTP 动词进行请求的 REST 模式,并且还可以在函数调用中处理并发(同时发起多个请求)。您可以在github.com/mzabriskie/axios找到更多信息。

准备工作

对于这个配方,您不需要对 Vue 有任何特定的了解。我们将使用 Axios,它本身使用 JavaScript promises。如果您从未听说过 promises,您可以在developers.google.com/web/fundamentals/getting-started/primers/promises上了解一些基础知识。

如何做...

您将构建一个简单的应用程序,每次访问网页时都会给您一条明智的建议。

您需要做的第一件事是在应用程序中安装 Axios。如果您使用 npm,只需发出以下命令:

    npm install axios

如果您正在处理单个页面,您可以从 CDN 导入以下文件,网址为unpkg.com/axios/dist/axios.js

不幸的是,我们将使用的建议服务在 JSFiddle 上无法工作,因为服务运行在 HTTP 上,而 JSFiddle 在 HTTPS 上,你的浏览器很可能会抱怨。你可以在本地 HTML 文件上运行这个教程。

我们的 HTML 如下所示:

<div id="app"> 
  <h2>Advice of the day</h2> 
  <p>{{advice}}</p> 
</div>

我们的 Vue 实例如下:

new Vue({ 
  el: '#app', 
  data: { 
    advice: 'loading...' 
  }, 
  created () { 
    axios.get('http://api.adviceslip.com/advice') 
      .then(response => { 
        this.advice = response.data.slip.advice 
      }) 
      .catch(error => { 
        this.advice = 'There was an error: ' + error.message 
      }) 
  } 
})

打开你的应用程序,获得一条令人耳目一新的智慧建议:

它是如何工作的...

当我们的应用程序启动时,创建的钩子被激活,并将使用 Axios 运行代码。第一行执行一个 GET 请求到 API 端点:

axios.get('http://api.adviceslip.com/advice')

这将返回一个 promise。我们可以在任何 promise 上使用then方法来处理结果,如果 promise 成功解决:

.then(response => { 
  this.advice = response.data.slip.advice 
})

响应对象将包含关于我们请求结果的一些数据。一个可能的响应对象如下:

{ 
  "data": { 
    "slip": { 
      "advice": "Repeat people's name when you meet them.", 
      "slip_id": "132" 
    } 
  }, 
  "status": 200, 
  "statusText": "OK", 
  "headers": { 
    "content-type": "text/html; charset=UTF-8", 
    "cache-control": "max-age=0, no-cache" 
  }, 
  "config": { 
    "transformRequest": {}, 
    "transformResponse": {}, 
    "timeout": 0, 
    "xsrfCookieName": "XSRF-TOKEN", 
    "xsrfHeaderName": "X-XSRF-TOKEN", 
    "maxContentLength": -1, 
    "headers": { 
      "Accept": "application/json, text/plain, */*" 
    }, 
    "method": "get", 
    "url": "http://api.adviceslip.com/advice" 
  }, 
  "request": {} 
}

我们导航到我们想要交互的属性;在我们的例子中,我们想要response.data.slip.advice,这是一个字符串。我们将字符串复制到实例状态中的建议变量中。

最后一部分是当我们的请求或者第一分支内的代码出现问题时:

.catch(error => { 
  this.advice = 'There was an error: ' + error.message 
})

我们将在在请求期间从错误中恢复的教程中更深入地探讨错误处理。现在,让我们手动触发一个错误,看看会发生什么。

触发错误的最便宜的方法是在 JSFiddle 上运行应用程序。由于浏览器检测到 JSFiddle 是在安全连接上,而我们的 API 是在 HTTP 上(不安全),现代浏览器会抱怨并阻止连接。你应该看到以下文本:

There was an error: Network Error

这只是你可以尝试的许多可能错误之一。考虑到你将 GET 端点编辑为一些不存在的页面:

axios.get('http://api.adviceslip.com/non-existent-page')

在这种情况下,你会得到一个 404 错误:

There was an error: Request failed with status code 404

有趣的是,即使请求顺利进行,但第一分支中出现错误,你最终会进入错误分支。

then分支更改为这样:

.then(response => { 
  this.advice = undefined.hello 
})

众所周知,JavaScript 无法读取未定义对象的“hello”属性:

There was an error: Cannot read property 'hello' of undefined

就像我告诉你的那样。

在发送用户数据之前验证它的有效性

一般来说,用户讨厌表单。虽然我们无法改变这一点,但我们可以通过提供有关如何填写表单的相关说明来减少他们的挫败感。在这个教程中,我们将创建一个表单,并利用 HTML 标准为用户提供如何完成它的良好指导。

准备工作

这个教程不需要先前的知识就可以完成。虽然我们将构建一个表单(使用 Axios 发送基本 AJAX 请求教程),但我们将伪造 AJAX 调用并集中在验证上。

如何做...

我们将构建一个非常简单的表单:一个用于用户名的字段,一个用于用户电子邮件的字段,以及一个用于提交信息的按钮。

在 HTML 中输入:

<div id="app"> 
  <form @submit.prevent="vueSubmit"> 
    <div> 
      <label>Name</label> 
      <input type="text" required> 
    </div> 
    <div> 
      <label>Email</label> 
      <input type="email" required> 
    </div> 
    <div> 
      <label>Submit</label> 
      <button type="submit">Submit</button> 
    </div> 
  </form> 
</div>

Vue 实例很简单,如下所示:

new Vue({ 
  el: '#app', 
  methods: { 
    vueSubmit() { 
      console.info('fake AJAX request') 
    } 
  } 
})

运行这个应用程序,尝试提交一个空字段或错误的电子邮件。您应该会看到浏览器本身的帮助:

然后,如果您尝试输入一个无效的电子邮件地址,您将看到以下内容:

它是如何工作的...

我们正在使用原生的 HTML5 验证 API,它在内部使用模式匹配来检查我们输入的内容是否符合某些规则。

考虑以下行中的 required 属性:

<input type="text" required>

这样可以确保当我们提交表单时,字段实际上是填充的,而在另一个输入元素中使用type="email"可以确保内容类似于电子邮件格式。

这个 API 非常丰富,您可以在developer.mozilla.org/en-US/docs/Web/Guide/HTML/Forms/Data_form_validation上阅读更多。

很多时候,问题在于要利用这个 API,我们需要触发原生验证机制。这意味着我们不能阻止提交按钮的默认行为:

<button type="submit" @click.prevent="vueSubmit">Submit</button>

这不会触发原生验证,表单将始终被提交。另一方面,如果我们这样做:

<button type="submit" @click="vueSubmit">Submit</button>

表单将被验证,但由于我们没有阻止提交按钮的默认行为,表单将被发送到另一个页面,这将破坏单页面应用程序的体验。

诀窍是在表单级别拦截提交:

<form @submit.prevent="vueSubmit">

这样,我们可以拥有表单的原生验证和我们真正喜欢的现代浏览体验。

创建一个表单并将数据发送到您的服务器

HTML 表单是与用户交互的标准方式。您可以收集他们的数据以在网站内注册,让他们登录,甚至进行更高级的交互。在这个教程中,您将使用 Vue 构建您的第一个表单。

准备工作

这个教程非常简单,但它假设您已经了解 AJAX,并且希望将您的知识应用到 Vue 上。

如何做...

假设我们有一个博客,并且我们想写一篇新文章。为此,我们需要一个表单。以下是 HTML 的布局方式:

<div id="app"> 
  <h3>Write a new post</h3> 
  <form> 
    <div> 
      <label>Title of your post:</label> 
      <input type="text" v-model="title"> 
    </div> 
    <div> 
      <label>Write your thoughts for the day</label> 
      <textarea v-model="body"></textarea> 
    </div> 
    <div> 
      <button @click.prevent="submit">Submit</button> 
    </div> 
  </form> 
</div>

我们有一个用于标题的框,一个用于我们新帖子的正文的框,以及一个发送我们的帖子的按钮。

在我们的 Vue 实例中,这三个东西以及用户 ID 将成为应用程序状态的一部分:

new Vue({ 
  el: '#app', 
  data: { 
    userId: 1, 
    title: '', 
    body: '' 
  } 
})

在这一点上,我们只需要在单击“提交”按钮时向服务器发送数据的方法。由于我们没有服务器,我们将使用Typicode提供的非常有用的服务。它基本上是一个虚假的 REST 服务器。我们将发送一个请求,服务器将以真实的方式做出响应,即使实际上什么都不会发生。

这是我们的方法:

methods: { 
  submit () { 
    const xhr = new XMLHttpRequest() 
    xhr.open('post', 'https://jsonplaceholder.typicode.com/posts') 
    xhr.setRequestHeader('Content-Type',  
                         'application/json;charset=UTF-8') 
    xhr.onreadystatechange = () => { 
    const DONE = 4 
    const CREATED = 201 
    if (xhr.readyState === DONE) { 
      if (xhr.status === CREATED) { 
          this.response = xhr.response 
        } else { 
          this.response = 'Error: ' + xhr.status 
        } 
      } 
    } 
    xhr.send(JSON.stringify({ 
      title: this.title, 
      body: this.body, 
      userId: this.userId 
    })) 
  } 
}

为了查看服务器的实际响应,我们将把响应变量添加到我们的状态中:

data: { 
  userId: 1, 
  title: '', 
  body: '', 
 response: '...' 
}

在我们的 HTML 表单之后,添加以下内容:

<h3>Response from the server</h3> 
<pre>{{response}}</pre>

当您启动页面时,您应该能够与服务器进行交互。当您写一篇文章时,服务器将回显该文章并回复帖子 ID:

它是如何工作的...

大部分魔法发生在submit方法中。在第一行,我们创建了一个XMLHttpRequest对象,这是一个用于发出 AJAX 请求的本机 JavaScript 机制:

const xhr = new XMLHttpRequest()

然后我们使用opensetRequestHeader方法来配置一个新的连接;我们要发送一个 POST 请求,并且我们将随之发送一些 JSON:

xhr.open('post', 'http://jsonplaceholder.typicode.com/posts') 
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8')

由于我们正在与 RESTful 接口交互,POST 方法意味着我们期望我们的请求修改服务器上的数据(特别是创建一个新的帖子),并且多次发出相同的请求将每次都得到不同的结果(换句话说,我们将创建一个新的、不同的帖子 ID)。

这与更常见的 GET 请求不同,后者不会修改服务器上的数据(除了可能的日志),并且始终会产生相同的结果(假设服务器上的数据在请求之间没有发生变化)。

有关 REST 的更多细节,请查看创建 REST 客户端(和服务器!)的内容。

接下来的几行都是关于响应的:

xhr.onreadystatechange = () => { 
  const DONE = 4 
  const CREATED = 201 
  if (xhr.readyState === DONE) { 
    if (xhr.status === CREATED) { 
      this.response = xhr.response 
    } else { 
      this.response = 'Error: ' + xhr.status 
    } 
  } 
}

这将在我们的对象发生某种变化时安装一个处理程序。如果readyState变为DONE,这意味着我们从服务器得到了响应。接下来,我们检查状态码,应该是201,表示已创建了一个新资源(我们的新帖子)。如果是这种情况,我们设置放在双大括号中的变量以获得快速反馈。否则,我们将接收到的错误消息放入同一个变量中。

在设置事件处理程序之后,我们需要做的最后一件事是实际发送请求以及我们新帖子的数据:

xhr.send(JSON.stringify({ 
  title: this.title, 
  body: this.body, 
  userId: this.userId 
}))

更多内容...

另一种解决相同问题的方法是使用 Axios 发送 AJAX 请求。如果你需要了解 Axios 是什么,可以看一下使用 Axios 发送基本的 AJAX 请求这个教程。

submit方法的代码将变成如下(记得将 Axios 添加为依赖项):

submit () { 
  axios.post('http://jsonplaceholder.typicode.com/posts', { 
    title: this.title, 
    body: this.body, 
    userId: this.userId 
  }).then(response => { 
    this.response = JSON.stringify(response,null,'  ') 
  }).catch(error => { 
    this.response = 'Error: ' + error.response.status 
  }) 
}

这段代码完全等效,但比使用原生浏览器对象更具表现力和简洁。

在请求期间从错误中恢复

从计算机的角度来看,对外部服务的请求需要很长时间。从人类的角度来看,就像是把卫星送到木星,然后等待它返回地球。你无法百分之百确定旅行是否会完成,以及旅行实际需要多长时间。网络经常不稳定,最好提前做好准备,以防我们的请求无法成功完成。

准备工作

这个教程有点复杂,但并不使用高级概念。然而,你应该熟悉使用 Vue。

我们将在这个教程中使用 Axios。如果你不确定它具体包含什么,可以完成使用 Axios 发送基本的 AJAX 请求这个教程。

操作步骤

你将为在珠穆朗玛峰上订购比萨的网站建立一个网站。该地区的互联网连接非常差,所以在放弃我们的比萨之前,我们可能需要重试几次。

这是我们的 HTML 代码:

<div id="app"> 
  <h3>Everest pizza delivery</h3> 
  <button @click="order"  
          :disabled="inProgress">Order pizza!</button> 
  <span class="spinner" v-show="inProgress"></span> 
  <h4>Pizza wanted</h4> 
  <p>{{requests}}</p> 
  <h4>Pizzas ordered</h4> 
  <span v-for="pizza in responses"> 
    {{pizza.id}}:{{pizza.req}} 
  </span> 
</div>

我们有一个用于下订单的按钮,当订单正在进行时会被禁用--一个正在进行中的订单列表(目前只包含一个订单)和一个已经订购的比萨列表。

我们可以添加一个旋转的小比萨饼来让等待变得更愉快。添加这个 CSS 来让小比萨饼旋转:

@keyframes spin { 
  100% {transform:rotate(360deg);} 
} 
.spinner { 
  width: 1em; 
  height: 1em; 
  padding-bottom: 12px; 
  display: inline-block; 
  animation: spin 2s linear infinite; 
}

我们的 Vue 实例将跟踪一些东西;写下这段代码来开始构建实例:

new Vue({ 
  el: '#app', 
  data: { 
    inProgress: false, 
    requests: new Object(null), 
    responses: new Object(null), 
    counter: 0, 
    impatientAxios: undefined 
  } 
})

我想要使用 JavaScript 集来处理请求和响应;不幸的是,在 Vue 中,集合不是响应式的;我们可以使用的最接近的东西是一个对象,目前是空的,也就是说,我们正在将请求和响应初始化为空对象。

impatientAxios变量将在创建时填充。通常,Axios 会等待浏览器等待响应的时间。由于我们心急,我们将创建一个在 3 秒后断开连接的 Axios:

created () { 
  this.impatientAxios = axios.create({ 
    timeout: 3000  
  }) 
}

我们需要构建的最后一件事是订单方法。由于我们没有一个用于实际请求的网络服务器,我们将使用 http://httpstat.us/200 端点,它对我们所有的请求都简单地回答 200 OK:

methods: { 
  order (event, oldRequest) { 
    let request = undefined 
    if (oldRequest) { 
      request = oldRequest 
    } else { 
      request = { req: '', id: this.counter++} 
   } 
   this.inProgress = true 
   this.requests[request.id] = request 
   this.impatientAxios.get('http://httpstat.us/200') 
    .then(response => { 
      this.inProgress = false 
      this.responses[request.id] = this.requests[request.id] 
      delete this.requests[request.id] 
    }) 
    .catch(e => { 
      this.inProgress = false 
      console.error(e.message) 
      console.error(this.requests.s) 
      setTimeout(this.order(event, request), 1000) 
    }) 
}

为了按预期运行这个程序,用 Chrome 打开它,并用Cmd + Opt + I(在 Windows 上是F12)打开开发者工具:

切换到网络选项卡,并打开下拉菜单,你会看到没有节流:

点击它以显示下拉菜单:

添加一个名为Everest的新自定义节流,下载和上传速度为1kb/s,延迟为1,000毫秒,如下面的屏幕截图所示:

然后你可以选择那种类型的节流并尝试订购一些披萨。如果你幸运的话,你最终应该能够订购一些,这要归功于 Axios 的持久性。

如果你没有成功或者你的所有披萨都被正确地订购了,尝试调整参数;这个过程中很大一部分实际上是随机的,而且高度依赖于机器。

它是如何工作的...

有许多处理不稳定连接的方法,也有许多与 Axios 集成并具有更高级重试和重试策略的库。在这里,我们只看到了一种基本策略,但是像Patience JS这样的库有更高级的策略,它们并不难使用。

创建一个 REST 客户端(和服务器!)

在这个教程中,我们将学习关于 REST 以及如何构建 REST 客户端。要构建一个 REST 客户端,我们需要一个暴露 REST 接口的服务器;我们也将构建它。等一下!在一本关于 Vue 的书中,一个完整的 REST 服务器只是一个附注?只要跟着做,你就不会失望。

准备工作

这个示例在某种意义上相当高级,您需要熟悉客户端和服务器的架构,并且至少听说过或阅读过 REST 接口。您还需要熟悉命令行并安装 npm。您可以在选择开发环境示例中了解所有相关信息。

还需要安装 Axios;在本章的第一个示例中可以了解更多信息。

如何做...

我还记得几年前,构建 REST 服务器可能需要花费几天甚至几周的时间。您可以使用Feather.js,它将快速且(希望是)无痛的。打开命令行并使用以下命令通过 npm 安装它:

    npm install -g feathers-cli

之后,创建一个目录,在其中运行服务器,然后进入该目录并启动 Feathers:

    mkdir my-server
    cd my-server
    feathers generate app

将所有问题的答案都设置为默认值。当进程完成时,键入以下命令以创建新资源:

 feathers generate service

其中一个问题是资源的名称;将其命名为messages,但除此之外,其他问题都使用默认值。

使用exit命令退出 feathers-cli,并使用以下命令启动新服务器:

    npm start

几秒钟后,您的 REST 服务器应该已启动,并且应该正在端口3030上进行监听。你能诚实地说这很困难吗?

上述命令序列适用于 Feathers 版本 2.0.0

您可能正在使用另一个版本,但是使用后续版本仍然很容易获得相同的结果;请查看feathersjs.com/上的在线安装指南。

接下来,您将构建一个与服务器无缝通信的 Vue 应用程序。现在,由于服务器通过 HTTP 在本地环境中运行,您将无法使用 JSFiddle,因为它在 HTTPS 上运行,并认为 HTTP 是不安全的。您可以使用之前描述的其他方法,或者使用 HTTP 上的服务,例如codepen.io或其他服务。

您将编写一个管理便签消息的应用程序。我们希望能够查看、添加、编辑和删除它们。

在 HTML 中键入以下内容:

<div id="app"> 
  <h3>Sticky messages</h3> 
  <ol> 
    <li v-for="message in messages"> 
      <button @click="deleteItem(message._id)">Delete</button> 
      <button @click="edit(message._id, message.text)"> 
        edit 
      </button> 
      <input v-model="message.text"> 
    </li> 
  </ol> 
  <input v-model="toAdd"> 
  <button @click="add">add</button> 
</div>

我们的 Vue 实例状态将包括一系列记录的消息,以及要添加到列表中的临时消息:

new Vue({ 
  el: '#app', 
  data: { 
    messages: [], 
    toAdd: '' 
  }, 
})

我们要做的第一件事是向服务器请求消息列表。为此编写创建的钩子:

created () { 
  axios.get('http://localhost:3030/messages/') 
    .then(response => { 
      this.messages = response.data.data 
    }) 
},

要创建新消息,请编写一个绑定到添加按钮的点击事件的方法,并将输入框中的内容发送到服务器:

methods: { 
  add () { 
    axios.post('http://localhost:3030/messages/', { 
      text: this.toAdd 
    }) 
      .then(response => { 
        if (response.status === 201) { 
          this.messages.push(response.data) 
          this.toAdd = '' 
        } 
      }) 
  } 
}

同样,编写一个用于删除消息和编辑消息的方法:

deleteItem (id) { 
  console.log('delete') 
  axios.delete('http://localhost:3030/messages/' + id) 
    .then(response => { 
      if (response.status < 400) { 
        this.messages.splice( 
          this.messages.findIndex(e => e.id === id), 1) 
      } 
    }) 
}, 
edit (id, text) { 
  axios.put('http://localhost:3030/messages/' + id, { 
    text 
  }) 
    .then(response => { 
      if (response.status < 400) { 
        console.info(response.status) 
      } 
    }) 
}

启动你的应用程序,你将能够管理你的便利贴消息板:

为了证明你确实在与服务器通信,你可以刷新页面,或者关闭并重新打开浏览器,你的笔记仍然会在那里。

它是如何工作的...

REST意味着REpresentational State Transfer,也就是说你将传输某个资源状态的表示。在实践中,我们使用一组动词来传输我们消息状态的表示。

使用 HTTP 协议,我们可以使用以下动词:

动词 属性 描述
GET 幂等,安全 用于检索资源的表示
POST 用于上传新资源
PUT 幂等 用于上传现有资源(修改它)
DELETE 幂等 用于删除资源

幂等意味着如果我们两次使用相同的动词,资源不会发生任何变化,而安全意味着根本不会发生任何变化。

在我们的应用程序中,我们只在创建时使用 GET 动词。当我们看到列表因其他操作而改变时,那只是因为我们在前端上反映了服务器上的操作。

POST 动词用于向列表中添加新消息。请注意,它不是幂等的,因为即使在便利贴消息中使用相同的文本,我们仍然会在按下添加按钮时创建一个 ID 不同的新消息。

按下编辑按钮会触发 PUT,而删除按钮,嗯,你可以想象它使用了 DELETE 动词。

Axios 通过使用动词本身来命名其 API 的方法,使其非常清晰。

实现无限滚动

无限滚动是使用 Vue 和 AJAX 的一个很好的例子。它也非常受欢迎,可以改善某些类型内容的交互。你将构建一个可以使用无限滚动的随机单词生成器。

准备工作

我们将使用 Axios。查看使用 Axios 发送基本 AJAX 请求的示例,了解如何安装它及其基本功能。除此之外,你不需要了解太多就可以跟着做。

如何做...

为了使我们的应用程序工作,我们将从www.setgetgo.com/randomword/get.php端点请求随机单词。每次你将浏览器指向这个地址,你都会得到一个随机单词。

整个页面将仅由无限单词列表组成。编写以下 HTML:

<div id="app"> 
  <p v-for="word in words">{{word}}</p> 
</div>

随着页面向下滚动,单词列表需要增长。所以我们需要两件事:了解用户何时到达页面底部,以及获取新单词。

要知道用户何时到达页面底部,我们在 Vue 实例中添加一个方法:

new Vue({ 
  el: '#app', 
  methods: { 
    bottomVisible () { 
      const visibleHeight = document.documentElement.clientHeight 
      const pageHeight = document.documentElement.scrollHeight 
      const scrolled = window.scrollY 
      const reachedBottom = visibleHeight + scrolled >= pageHeight 
      return reachedBottom || pageHeight < visibleHeight 
    } 
  } 
})

如果页面滚动到底部,或者页面本身比浏览器小,这将返回true

接下来,我们需要添加一个机制,将这个函数的结果绑定到一个状态变量bottom,并在用户滚动页面时更新它。我们可以在created钩子中做到这一点:

created () { 
  window.addEventListener('scroll', () => { 
    this.bottom = this.bottomVisible() 
  }) 
}

状态将由bottom变量和随机单词列表组成:

data: { 
  bottom: false, 
  words: [] 
}

现在我们需要一个方法来向数组中添加单词。将以下方法添加到现有方法中:

addWord () { 
  axios.get('http://www.setgetgo.com/randomword/get.php') 
    .then(response => { 
      this.words.push(response.data) 
      if (this.bottomVisible()) { 
        this.addWord() 
      } 
    }) 
}

该方法将递归调用自身,直到页面有足够的单词填满整个浏览器视图。

由于这个方法需要在每次到达底部时被调用,我们将监视底部变量,并在其为true时触发该方法。在data之后的 Vue 实例中添加以下选项:

watch: { 
  bottom (bottom) { 
    if (bottom) { 
      this.addWord() 
    } 
  } 
}

我们还需要在created钩子中调用addWord方法来启动页面:

created () { 
  window.addEventListener('scroll', () => { 
    this.bottom = this.bottomVisible() 
  }) 
 this.addWord() 
}

如果现在启动页面,你将得到一个无限流的随机单词,这在你需要创建新密码时很有用!

工作原理…

在这个教程中,我们使用了一个叫做watch的选项,它使用以下语法:

watch: { 
 'name of sate variable' (newValue, oldValue) { 
   ... 
  } 
}

这是计算属性的对应物,当我们对一些响应式变量的变化后不感兴趣。事实上,我们只是用它来触发另一个方法。如果我们对一些计算结果感兴趣,我们会使用计算属性。

在发送请求之前处理请求

这个教程教你如何使用拦截器在请求发送到互联网之前编辑请求。在某些情况下,这可能很有用,比如当你需要在所有请求到服务器时提供授权令牌,或者当你需要一个单一点来编辑 API 调用的执行方式时。

准备工作

这个教程使用了 Axios(使用 Axios 发送基本 AJAX 请求教程);除此之外,最好已经完成了在发送数据之前如何验证用户数据教程,因为我们将构建一个小型表单进行演示。

如何做...

在这个教程中,您将为一个假设的评论系统构建一个脏话过滤器。假设我们网站上有一篇文章可能会引发争论:

<div id="app"> 
  <h3>Who's better: Socrates or Plato?</h3> 
  <p>Technically, without Plato we wouldn't have<br> 
  much to go on when it comes to information about<br> 
  Socrates. Plato ftw!</p>

在那篇文章之后,我们放置了一个评论框:

  <form> 
    <label>Write your comment:</label> 
    <textarea v-model="message"></textarea> 
    <button @click.prevent="submit">Send!</button> 
  </form> 
  <p>Server got: {{response}}</p> 
</div>

我们还在表单后面添加了一行来调试我们将从服务器获取的响应。

在我们的 Vue 实例中,我们编写所有支持代码将评论发送到我们的服务器,这种情况下,将是jsonplaceholder.typicode.com/comments,一个假的 REST 接口,将表现得像一个真正的服务器。

这是由按下提交按钮触发的 submit 方法:

methods: { 
  submit () { 
    axios.post('http://jsonplaceholder.typicode.com/comments', 
    { 
      body: this.message 
    }).then(response => { 
      this.response = response.data 
    }) 
  } 
}

Vue 实例的状态将只有两个变量:

data: { 
  message: '', 
  response: '...' 
}

像往常一样,我们希望将其挂载到<div>应用程序中:

new Vue({ 
  el: '#app', 
...

一旦实例被挂载,我们希望在 Axios 中安装单词过滤器;为此,我们利用 Vue 的mounted钩子:

mounted () { 
  axios.interceptors.request.use(config => { 
    const body = config.data.body.replace(/punk/i, '***') 
    config.data.body = body 
    return config 
  }) 
}

现在我们可以启动我们的应用程序并尝试写我们的脏话评论:

它是如何工作的...

mounted钩子中,我们正在安装所谓的拦截器。特别是一个请求拦截器,这意味着它将获取我们的请求并在发送到互联网之前对其进行操作:

axios.interceptors.request.use(config => { 
  const body = config.data.body.replace(/punk/i, '***') 
  config.data.body = body 
  return config 
})

config对象包含许多我们可以编辑的内容。它包含头部和 URL 参数。它还包含 Axios 配置变量。您可以查看 Axios 文档以获取最新列表。

我们正在获取随 POST 请求发送的数据部分,并检查是否存在punk这个词。如果是这样,它将被替换为星号。返回的对象将成为当前请求的新配置。

防止 XSS 攻击到您的应用程序

在没有考虑安全性的情况下编写应用程序将不可避免地导致漏洞,特别是如果它必须在 Web 服务器上运行。跨站脚本XSS)是当今最流行的安全问题之一;即使您不是安全专家,您也应该了解它的工作原理以及如何在 Vue 应用程序中防止它。

准备工作

这个步骤不需要任何先前的知识,只需要了解 Axios。您可以在使用 Axios 发送基本的 AJAX 请求中找到更多关于 Axios 以及如何安装它的信息。

如何做...

您应该首先发现后端是如何给您 CSRF 令牌的(在下一段中会详细介绍)。我们假设服务器会在您的浏览器中放置一个名为 XSRF-TOKEN 的 cookie。

您可以模拟您的服务器,在浏览器控制台中使用document.cookie = 'XSRF-TOKEN=abc123'命令设置一个 cookie(在开发者工具中)。

Axios 会自动读取这样的 cookie,并在下一次请求中传输它。

考虑到我们在代码中调用了一个 Axios 的get请求,如下所示:

methods: { 
  sendAllMoney () { 
    axios.get('/sendTo/'+this.accountNo) 
  } 
}

Axios 会获取该 cookie,并在请求中添加一个名为 X-XSRF-TOKEN 的新标头。您可以通过在 Chrome 的开发者工具的网络选项卡中点击请求的名称来查看这样的标头:

它是如何工作的...

为了防止 XSS 攻击,您必须确保没有用户输入会出现在您的应用程序中。这意味着您必须非常小心地使用v-html属性(输出原始 HTML的方法)。

不幸的是,您无法控制页面外发生的事情。如果您的用户之一收到了包含与您的应用程序中的操作相对应的链接的虚假电子邮件,那么点击邮件中的链接将触发该操作。

让我们举个具体的例子;您开发了一个银行应用VueBank,您的应用用户收到了以下虚假电子邮件:

Hello user!
Click here to read the latest news.

正如您所看到的,这封邮件甚至与我们的应用无关,here超链接被隐藏在邮件本身的 HTML 中。实际上,它指向http://vuebank.com?give_all_my_money_to_account=754839534地址。

如果我们已经登录了 VueBank,那么链接可能会立即生效。这对我们的财务状况不利。

为了防止这类攻击,我们应该让后端为我们生成一个CSRF跨站点请求伪造)令牌。我们将获取该令牌并将其发送到请求中,以证明该请求是由用户发起的。前面的链接将变成

http://vuebank.com?give_all_my_money_to_account=754839534&csrf=s83Rnj

由于令牌每次都是随机生成的,所以邮件中的链接无法被正确伪造,因为攻击者不知道服务器给网页的令牌。

在 Vue 中,我们使用 Axios 来发送令牌。通常,我们不会将其作为链接的一部分发送,而是作为请求的标头;实际上,Axios 会为我们执行此操作,并在下一个请求中自动放入令牌。

您可以通过设置axios.defaults.xsrfCookieName变量来更改 Axios 将拾取的 cookie 的名称,并且您可以通过编辑axios.defaults.xsrfHeaderName变量来更改将返回令牌的标头的名称。

第十五章:单页面应用程序

在本章中,将涵盖以下内容:

  • 使用 vue-router 创建 SPA

  • 在切换路由之前获取数据

  • 使用命名动态路由

  • 在页面中有多个 router-view

  • 按层次结构组合您的路由

  • 使用路由别名

  • 在您的路由之间添加过渡

  • 管理路由的错误

  • 为加载页面添加进度条

  • 如何重定向到另一个路由

  • 在点击返回时保存滚动位置

介绍

许多现代应用程序都基于 SPA 或单页面应用程序模型。从用户的角度来看,这意味着整个网站看起来类似于单个页面中的应用程序。

这很好,因为如果做得正确,它会增强用户体验,主要是减少等待时间,因为没有新页面需要加载-整个网站都在一个页面上。这就是 Facebook、Medium、Google 和许多其他网站的工作方式。

URL 不再指向 HTML 页面,而是指向应用程序的特定状态(通常看起来像不同的页面)。在实践中,在服务器上,假设您的应用程序位于index.html页面内,这是通过将请求“关于我”的用户重定向到index.html来实现的。

后一页将采用 URL 的后缀,并将其解释为路由,从而创建一个类似页面的具有传记信息的组件。

使用 vue-router 创建 SPA

Vue.js 通过其核心插件 vue-router 实现了 SPA 模式。对于 vue-router,每个路由 URL 对应一个组件。这意味着我们将告诉 vue-router 当用户转到特定 URL 时如何行为,以其组件为基础。换句话说,在这个新系统中,每个组件都是旧系统中的一个页面。

准备工作

对于这个配方,您只需要安装 vue-router 并对 Vue 组件有一些了解。

要安装 vue-router,请按照router.vuejs.org/en/installation.html上的说明进行操作。

如果您正在使用 JSFiddle 进行跟踪,您可以添加类似于unpkg.com/vue-router/dist/vue-router.js的链接。

如何做…

我们正在为一家餐厅准备一个现代网站,并且将使用 SPA 模式。

网站将包括三个页面:主页、餐厅菜单和酒吧菜单。

整个 HTML 代码将如下所示:

<div id="app">
  <h1>Choppy's Restaurant</h1>
  <ul>
    <li>Home</li>
    <li>Menu</li>
    <li>Bar</li>
  </ul>
  <router-view></router-view>
</div>

<router-view>组件是 vue-router 的入口点。它是组件显示为页面的地方。

列表元素将成为链接。目前,它们只是列表元素;要将它们转换为链接,我们可以使用两种不同的语法。将第一个链接包装如下行:

<li><router-link to="/">Home</router-link></li>

另一个例子如下:

<li><router-link to="/menu">Menu</router-link></li>

我们还可以使用另一种语法(用于“Bar”链接):

<li>
  <router-link
    tag="li" to="/bar"
      :event="['mousedown', 'touchstart']"
    >
    <a>Bar</a>
  </router-link>
</li>

这种更冗长但更明确的语法可以用来将自定义事件绑定到特定路由。

为了告诉 Vue 我们要使用 vue-router 插件,在 JavaScript 中写入以下内容:

Vue.use(VueRouter)

我们在开头列出的三个页面部分将由这三个虚拟组件扮演(将它们添加到 JavaScript 中):

const Home = { template: '<div>Welcome to Choppy's</div>' }
const Menu = { template: '<div>Today we have cookies</div>' }
const Bar = { template: '<div>We serve cocktails</div>' }

现在,您可以创建路由器了。代码如下:

const router = new VueRouter({})

这个路由器并没有做太多事情;我们需要添加路由(对应 URL)和它们关联的组件:

const router = new VueRouter({
 routes: [ { path: '/', component: Home }, { path: '/menu', component: Menu }, { path: '/bar', component: Bar } ] })

现在我们的应用几乎完成了;我们只需要声明一个简单的Vue实例:

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

我们的应用现在可以工作了;在启动之前,添加这个 CSS 规则以获得稍微更好的反馈:

a.router-link-active, li.router-link-active>a {
  background-color: gainsboro;
}

打开应用并点击“Bar”链接时,您应该看到类似以下截图的内容:

工作原理…

您的程序的第一步是将 vue-router 注册为插件。而 vue-router 则注册路由(URL 的一部分)并将组件连接到每个路由。

当我们第一次访问应用程序时,浏览器的 URL(您无法在 JSFiddle 内部看到它在变化,因为它在一个 iframe 内部)将以index.html/#/结尾。井号后面的所有内容都是 vue-router 的路由。在这种情况下,它只是一个斜杠(/),因此匹配第一个主页路由。

当我们点击链接时,<router-view>的内容会根据我们与该路由关联的组件而改变。

还有更多…

敏锐的读者肯定会发现可以解释为错误的地方——在运行应用程序之前,我们添加了一些 CSS 样式。每当页面对应于实际指向的链接时,.router-link-active类会自动注入到<router-link>组件中。

当我们点击“菜单和栏”时,背景颜色会改变,但似乎仍然被选中为主页链接。这是因为<router-link>组件执行的匹配不是精确的。换句话说,/bar/menu包含/字符串,因此/总是匹配。

一个快速的解决方法是添加属性,与第一个<router-link>完全相同:

<li><router-link to="/" exact>Home</router-link></li>

现在,只有当路由完全匹配主页链接时,“主页”链接才会被突出显示。

另一个需要注意的事情是规则本身:

a.router-link-active, li.router-link-active>a {
  background-color: gainsboro;
}

为什么我们匹配两个不同的东西?这取决于你如何编写路由链接。

<li><router-link to="/" exact>Home</router-link></li>

上述代码将被翻译成以下 DOM 部分:

<li><a href="#/" class="router-link-active">Home</a></li>

而:

<router-link tag="li" to="/" exact>Home</router-link>

变成:

<li class="router-link-active">Home</li>

请注意,在第一种情况下,类被应用到子锚点元素;在第二种情况下,它被应用到父元素。

在切换路由之前获取数据

在 Vue 的上一个版本中,我们有一个专门的方法从互联网获取数据,然后再改变路由。在 Vue 2 中,我们有一个更通用的方法,它将在切换路由之前处理这个问题,可能还有其他事情。

准备工作

要完成这个教程,你应该已经了解了 vue-router 的基础知识以及如何进行 AJAX 请求(更多内容请参见最后一章)。

操作步骤…

我们将编写一个简单的网页作品集,由两个页面组成:主页和关于我页面。

对于这个教程,我们需要将 Axios 作为一个依赖项添加进去。

基本布局从以下 HTML 代码中清晰可见:

<div id="app">
  <h1>My Portfolio</h1>
  <ul>
    <li><router-link to="/" exact>Home</router-link></li>
    <li><router-link to="/aboutme">About Me</router-link></li>
  </ul>
  <router-view></router-view>
</div>

在 JavaScript 中,你可以开始构建你的AboutMe组件:

const AboutMe = {
  template: `<div>Name:{{name}}<br>Phone:{{phone}}</div>`
}

它将只显示一个姓名和一个电话号码。让我们在组件的data选项中声明这两个变量,如下所示:

data () {
  return {
    name: undefined,
    phone: undefined  
  } 
}

在实际加载组件到场景之前,vue-router 将在我们的对象中查找一个名为beforeRouteEnter的选项;我们将使用这个选项从服务器加载姓名和电话。我们使用的服务器将提供一些虚假数据,仅用于显示一些内容,如下所示:

beforeRouteEnter (to, from, next) {
  axios.post('https://schematic-ipsum.herokuapp.com/', {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "ipsum": "name"
      },
      "phone": {
        type": "string",
        "format": "phone"
      }
    }
  }).then(response => {
    next(vm => {
      vm.name = response.data.name
      vm.phone = response.data.phone 
    })
  })
}

对于另一个组件,主页,我们将只写一个小组件作为占位符:

const Home = { template: '<div>This is my home page</div>' }

接下来你需要注册router和它的paths

Vue.use(VueRouter)
const router = new VueRouter({
  routes: [
    { path: '/', component: Home },
    { path: '/aboutme', component: AboutMe },  
  ] 
})

当然,你还需要注册一个Vue根实例,如下所示:

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

当你启动应用程序并点击“关于我”链接时,你应该看到类似于这样的东西:

您会注意到当您点击链接时页面不会重新加载,但显示生物信息仍然需要一些时间。这是因为它正在从互联网获取数据。

它是如何工作的…

beforeRouteEnter钩子接受三个参数:

  • to:这是一个代表用户请求的路由的Route对象。

  • from:这也是一个代表当前路由的Route对象。这是用户在出现错误时将保留在的路由。

  • next:这是一个我们可以在准备好切换路由时使用的函数。调用这个函数并传入 false 将阻止路由的改变,并且在出现错误时非常有用。

在调用前面的函数时,我们使用 Axios 调用了一个 web 服务,该服务提供了一个名称和一个电话号码的字符串。

当我们在这个钩子里时,重要的是要记住我们没有访问这个。这是因为这个钩子在组件实际实例化之前运行,所以没有this可以引用。

当服务器响应时,我们在then函数中,并且想要分配从服务器返回的名称和电话,但是,正如所说,我们无法访问这个。下一个函数接收到我们组件的引用作为参数。我们使用这个来将变量设置为接收到的值:

...
}).then(response => {
  next(vm => {
    vm.name = response.data.name
    vm.phone = response.data.phone
  })
})

使用命名动态路由

手动注册所有路由可能会耗费时间,而且当路由事先不知道时,这是不可能的。vue-router 允许您使用参数注册路由,这样您就可以为数据库中的所有对象创建链接,并覆盖其他用户选择路由的用例,遵循某种模式,这将导致手动注册太多的路由。

准备工作

除了 vue-router 的基础知识(参考使用 vue-router 创建 SPA配方),你不需要任何额外的信息来完成这个配方。

如何做…

我们将开设一个有十种不同菜品的在线餐厅。我们将为每道菜创建一个路由。

我们网站的 HTML 布局如下:

<div id="app">
  <h1>Online Restaurant</h1>
  <ul>
    <li>
      <router-link :to="{ name: 'home' }" exact>
        Home
      </router-link>
    </li>
    <li v-for="i in 10">
      <router-link :to="{ name: 'menu', params: { id: i } }">
        Menu {{i}}
      </router-link>
    </li>
    </ul>
  <router-view class="view"></router-view>
</div>

这将创建 11 个链接,一个用于主页,十个用于菜品。

在 JavaScript 部分注册VueRouter后,代码如下:

Vue.use(VueRouter)

创建两个组件;一个将是主页的占位符:

const Home = { template: `
  <div>
    Welcome to Online Restaurant
  </div>
` }

其他路由将连接到一个Menu组件:

const Menu = { template: `
  <div>
    You just ordered
    <img :src="'http://lorempixel.com/200/200/food/' + $route.params.id">
  </div>
` }

在前面的组件中,我们使用$route引用全局路由对象,并从 URL 中获取id参数。Lorempixel.com是一个提供示例图片的网站。我们为每个id连接不同的图片。

最后,使用以下代码创建路由本身:

const router = new VueRouter({
  routes: [
    { path: '/', name:'home', component: Home }, 
    { path: '/menu/:id', name: 'menu', component: Menu },
  ]
})

你可以看到菜单的路径包含/:id,这是id参数在 URL 中出现的占位符。

最后,写一个根Vue实例:

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

现在可以启动应用程序,应该能够看到所有的菜单项。点击其中任何一个应该点菜:

它是如何工作的…

代码的两个主要部分有助于创建不同菜品的路由。

首先,我们使用冒号语法注册了一个通用路由,并为其分配了一个名称,代码如下:

{ path: '/menu/:id', name: 'menu', component: Menu }

这意味着我们可以有一个以/menu/82结尾的 URL,而Menu组件将显示,并且$route.params.id变量设置为82。因此,下一行应该根据以下进行更改:

<img :src="'http://lorempixel.com/200/200/food/' + $route.params.id">

在渲染的 DOM 中,前一行将被以下行替换:

<img src="'http://lorempixel.com/200/200/food/82">

不要在现实生活中没有这样的图片这个事实上介意。

注意,我们还为这个路由指定了一个名称。这并不是严格必要的,但它使我们能够编写代码的第二个主要部分,如下所示:

<router-link :to="{ name: 'menu', params: { id: i } }">
  Menu {{i}}
</router-link>

我们可以传递一个对象给to属性,而不是写一个字符串,并指定params。在我们的例子中,paramv-for包装给出。这意味着,例如,在v-for的第四个循环中:

<router-link :to="{ name: 'menu', params: { id: 4} }">
  Menu 4
</router-link>

这将导致 DOM 如下:

<a href="#/menu/4" class="">Menu 4</a>

在你的页面中有多个 router-view

拥有多个<router-view>可以让你拥有可以用更复杂布局组织的页面。例如,你可以有一个侧边栏和主视图。这个食谱就是关于这个的。

准备工作

这个食谱没有使用任何高级概念。建议你熟悉 vue-router 并学习如何安装它。不过,可以去本章的第一个食谱了解更多信息。

如何做…

这个食谱将使用大量代码来阐明观点。不过,不要灰心,机制真的很简单。

我们将建立一个二手硬件商店。我们将有一个主视图和一个侧边栏;这些将是我们的路由视图。侧边栏将包含我们的购物清单,这样我们总是知道我们在购物什么,不会有任何干扰。

整个 HTML 代码非常简短,因为它只包含一个标题和两个router-view组件:

<div id="app">
  <h1>Second-Hand Hardware</h1>
    <router-view name="list"></router-view>
    <router-view></router-view>
</div>

在这种情况下,列表被命名为router-view。第二个没有名称;因此,默认情况下被命名为Vue

在 JavaScript 中注册vue-router

Vue.use(VueRouter)

之后,注册路由:

const router = new VueRouter({
  routes: [
    { path: '/',
      components: {
        default: Parts,
        list: List
      }
    },
    { path: '/computer',
      components: {
        default: ComputerDetail,
        list: List
      }
    }
  ]
})

组件不再是单个对象;它已经成为一个包含两个组件的对象:一个用于list,另一个用于默认的router-view

在路由代码之前编写list组件,如图所示:

const List = { template: `
  <div>
    <h2>Shopping List</h2>
      <ul>
        <li>Computer</li>
      </ul>
  </div>
` }

这将只显示计算机作为我们应该记得购买的物品。

部分组件如下;在router代码之前编写它:

const Parts = { template: `
  <div>
    <h2>Computer Parts</h2>
    <ul>
      <li><router-link to="/computer">Computer</router-link></li>
      <li>CD-ROM</li>
    </ul>
  </div>
` }

这包含一个链接,可以查看有关出售计算机的更多信息;下一个组件绑定到该页面,因此在router代码之前编写它:

const ComputerDetail = { template: `
  <div>
    <h2>Computer Detail</h2>
    <p>Pentium 120Mhz, CDs sold separately</p>
  </div>
` }

当然,不要忘记添加Vue实例:

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

当你启动应用程序时,你应该看到两个路由视图一个在另一个上面。如果你想让它们并排,你可以添加一些 CSS 样式:

工作原理…

在页面中添加<router-view>组件时,你只需要记住在路由注册期间添加一个名称来引用它:

<router-view name="view1"></router-view>
<router-view name="view2"></router-view>
<router-view></router-view>

如果你没有指定名称,路由将被称为默认路由:

routes: [
  { path: '/',
    components: {  
      default: DefaultComponent,
      view1: Component1,
      view2: Component2
    }
  }
]

这样,组件将显示在它们各自的router-view元素中。

如果你没有为命名视图指定一个或多个组件,与该名称关联的router-view将为空。

按层次组织您的路由

在许多情况下,你的网站的组织树可能会很复杂。在某些情况下,有一个明确的分层组织,你可以遵循并使用嵌套路由,vue-routes 可以帮助你保持一切井然有序。最好的情况是 URL 的组织方式与组件的嵌套方式完全对应。

准备就绪

在这个教程中,你将使用 Vue 的组件和其他基本功能。你还将使用动态路由。去查看使用命名动态路由教程,了解更多信息。

如何做…

在这个教程中,你将为一个虚构的世界建立一个在线会计网站。我们将有两个用户--StarkLannister--我们将能够看到这两个用户拥有多少黄金和士兵。

我们网站的 HTML 布局如下:

<div id="app">
  <h1>Kindoms Encyclopedia</h1>
  <router-link to="/user/Stark/">Stark</router-link>
  <router-link to="/user/Lannister/">Lannister</router-link>
  <router-view></router-view>
</div>

我们有一个标题和两个链接--一个是Stark,一个是Lannister--最后是router-view元素。

我们将VueRouter添加到插件中:

Vue.use(VueRouter)

然后,我们注册routes

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

我们所说的是注册一个动态路由/user/:id,并且在User组件内部将有另一个 router-view,其中将包含 gold 和 soldiers 的嵌套路径。

刚才提到的三个组件是按照所示编写的;在路由代码之前添加它们:

const User = { template: `
  <div class="user">
    <h1>Kindoms Encyclopedia</h1>
    User {{$route.params.id}}
    <router-link to="gold">Gold</router-link>
    <router-link to="soldiers">Soldiers</router-link>
    <router-view></router-view>
  </div>
`}

正如预期的那样,在User组件内部有另一个 router-view 入口,将包含嵌套的routes组件。

然后,在路由代码之前编写SoldiersGold组件:

const Soldiers = { template: `
  <div class="soldiers">
    <span v-for="soldier in $root[$route.params.id].soldiers"> 

    </span>
  </div>
`}
const Gold = { template: `
   div class="gold">
    <span v-for="coin in $root[$route.params.id].gold">

    </span>
  </div>
`}

这些组件将显示与 Vue 根实例数据选项内的 gold 或 soldiers 变量一样多的表情符号。

这是Vue根实例的样子:

new Vue({
  router,
  el: '#app',
  data: {
    Stark: {
      soldiers: 100,
      gold: 50  
    },
    Lannister: {
      soldiers: 50,
      gold: 100
    }
  }
})

启动应用程序将使您能够直观地表示两个用户的金币和士兵数量:

工作原理…

为了更好地理解嵌套路由的工作原理,有必要看一下以下图表:

我们的路由中只有两个级别。第一个级别,即顶级,由大包装矩形表示,对应于/user/:id路由,这意味着每个潜在匹配的 ID 都在同一级别。

相反,内部矩形是一个嵌套路由和嵌套组件。它对应于路由 gold 和 Gold 组件。

当嵌套routes对应于嵌套组件时,这是正确的选择。还有另外两种情况需要考虑。

当我们有嵌套组件但没有嵌套路由时,我们可以在嵌套路由前加上斜杠/。这将使其表现得像顶级路由。

例如,考虑将我们的代码更改为以下内容:

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

/gold路由前加上前缀将使Gold组件在我们将浏览器指向/goldURL 时出现,而不是/user/Lannister/gold(在这种情况下将导致错误和空白页面,因为用户未指定)。

另一种情况是有嵌套的routes但同一级别没有组件。在这种情况下,只需使用常规语法注册路由。

使用路由别名

有时需要有多个指向同一页面的 URL。这可能是因为页面已更改名称,或者因为页面在站点的不同部分中被称为不同的名称。

特别是当页面更改名称时,同样重要的是在许多设置中保留以前的名称。链接可能会断裂,页面可能会从网站的某些部分变得无法访问。在这个食谱中,你将防止这种情况发生。

准备工作

对于这个食谱,你只需要对 vue-router 组件有一些了解(如何安装和基本操作)。有关 vue-router 的更多信息将从使用 vue-router 创建单页应用食谱开始。

如何做…

假设我们有一个时尚网站,负责给服装命名的员工 Lisa 为两件衣服创建了两个新链接:

<router-link to="/green-dress-01/">Valentino</router-link>
<router-link to="/green-purse-A2/">Prada</router-link>

开发人员在 vue-router 中创建相应的路由:

const router = new VueRouter({
  routes: [
    {
      path: '/green-dress-01',
      component: Valentino01
    },
    {
      path: '/green-purse-A2',
      component: PradaA2
    }
  ]
})

后来发现这两件物品不是绿色的,而是红色的。Lisa 并不怪罪,因为她是色盲。

现在你负责更改所有链接以反映列表的真实颜色。你要做的第一件事是改变链接本身。在你编辑后,HTML 布局看起来是这样的:

<div id="app">
  <h1>Clothes Shop</h1>
  <router-link to="/red-dress-01/">Valentino</router-link>
  <router-link to="/red-purse-A2/">Prada</router-link>
  <router-view></router-view>
</div>

你向Vue添加VueRouter插件:

Vue.use(VueRouter)

然后,注册新的routes以及旧的aliases

const router = new VueRouter({
  routes: [
    {
      path: '/red-dress-01',
      component: Valentino01,
      alias: '/green-dress-01'
    },
    {
      path: '/red-purse-A2',
      component: PradaA2,
      alias: '/green-purse-A2'
    }
  ]
})

这些组件看起来是这样的:

const Valentino01 = { template: '<div class="emoji"></div>' }
const PradaA2 = { template: '<div class="emoji"></div>' }

在启动应用程序之前,请记住实例化一个Vue实例:

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

你可以添加一个 CSS 规则,使表情符号看起来像图片,就像下面的截图所示:

.emoji {
  font-size: 3em;
}

工作原理…

即使我们改变了所有的链接,我们也无法控制其他实体如何链接到我们的页面。对于搜索引擎,比如 Google,没有办法告诉它们删除对旧页面的链接并使用新页面。这意味着如果我们不使用别名,我们可能会在形式上遭受大量的坏名声,包括损坏的链接和 404 页面;在某些情况下,甚至是我们支付给链接到一个不存在页面的广告商。

在你的路由之间添加过渡效果

我们在过渡和动画中详细探讨了过渡效果。在这里,我们将在更改路由时使用它们,而不是更改元素或组件。同样的观察结果在这里也适用。

准备工作

在尝试这个食谱之前,我强烈建议你完成过渡和动画中的一些食谱,以及这个食谱。这个食谱是到目前为止学到的概念的混合体。

如何做…

在这个教程中,我们将为一个鬼魂餐厅建立一个网站。它与普通餐厅的网站并没有太大的不同,除了页面必须淡出而不是立即出现的要求。

让我们先写一些 HTML 布局:

<div id="app">
  <h1>Ghost's Restaurant</h1>
  <ul>
    <li><router-link to="/">Home</router-link></li>
    <li><router-link to="/menu">Menu</router-link></li>  
  </ul>
  <transition mode="out-in">
  <router-view></router-view>
  </transition>
</div>

请注意,我们用一个transition标签包裹了主路由显示端口。设置了out-in模式,因为我们希望消失的组件的动画在另一个组件出现之前完成。如果我们没有设置这个,两个淡出的组件会在短暂的时间内叠加在一起。有关更详细的讨论,您可以参考在过渡中让元素离开之前进入阶段的教程。

让我们创建两个页面/组件:

const Home = { template: '<div>Welcome to Ghost's</div>' }
const Menu = { template: '<div>Today: invisible cookies</div>' }

现在,让我们注册routes

Vue.use(VueRouter)
const router = new VueRouter({
  routes: [
    { path: '/', component: Home },
    { path: '/menu', component: Menu }
  ]
})

在启动应用程序之前,实例化一个Vue对象:

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

为了使过渡效果生效,您需要添加一些 CSS 规则:

.v-enter-active, .v-leave-active {
  transition: opacity .5s;
}
.v-enter, .v-leave-active {
  opacity: 0
}

现在启动您的应用程序。您成功地在页面切换之间添加了一个淡出过渡。

它是如何工作的…

把整个<router-view>包裹在一个过渡标签中将为所有组件执行相同的过渡。

如果我们想为每个组件设置不同的过渡,我们有另一种选择:我们必须将单独的组件包裹在过渡中。

比如说,我们有两个过渡效果:诡异和美味。我们希望在Home组件出现时应用第一个过渡效果,在Menu组件出现时应用第二个过渡效果。

我们需要修改我们的组件,如下所示:

const Home = { template: `
  <transition name="spooky">
    <div>Welcome to Ghost's</div>
  </transition>
` }
const Menu = { template: `
  <transition name="delicious">
    <div>Today: insisible cookies!</div>
  </transition>
` }

为您的路由管理错误

如果我们去的页面找不到或者不起作用,去链接就没有太多意义。传统上,当发生这种情况时,我们会看到一个错误页面。在 SPA 中,我们更加强大,可以阻止用户完全进入那里,并显示一个礼貌的消息,说明页面不可用。这极大地增强了用户体验,因为用户可以立即采取其他行动,而无需返回。

准备工作

为了跟上进度,您应该完成在切换路由之前获取数据的教程。

这个教程将在此基础上进行,并假设您已经将所有相关的代码放在了适当的位置。

如何做…

正如所说,我们将编辑在切换路由之前获取数据教程的结果代码来处理错误。只是为了让您记得,当我们去到/aboutme页面时,我们正在从互联网上加载信息。如果信息不可用,我们希望避免进入该页面。

对于这个配方,添加 Axios 作为依赖项,就像在以前的配方中一样。

首先,使用突出显示的代码丰富 HTML 布局:

<div id="app">
  <h1>My Portfolio</h1>
  <ul>
    <li><router-link to="/" exact>Home</router-link></li>
    <li><router-link to="/aboutme">About Me</router-link></li>
  </ul>
  <router-view></router-view>
 <div class="toast" v-show="showError"> There was an error </div> </div>

这是一个吐司消息,每当出现错误时都会出现在屏幕上。使用这个 CSS 规则为它添加一些样式:

div.toast {
  width: 15em;
  height: 1em;
  position: fixed;
  bottom: 1em;
  background-color: red;
  color: white;
  padding: 1em;
  text-align: center;
}

接下来你想做的事情是有一个全局机制来将showError设置为true。在 JavaScript 代码的顶部,声明vm变量:

let vm

然后,将我们的Vue根实例分配给它:

vm = new Vue({
  router,
  el: '#app',
 data: { showError: false } })

我们还将showError变量添加到数据选项中。

最后要做的事情实际上是在显示生物信息之前管理我们的数据检索错误。

将突出显示的代码添加到beforeRouteEnter钩子中:

beforeRouteEnter (to, from, next) {
  axios.post('http://example.com/', {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "ipsum": "name"
      },
      "phone": {
        "type": "string",
        "format": "phone"
      }
    }
  }).then(response => {
  next(vm => {
    vm.name = response.data.name
    vm.phone = response.data.phone
  })
}).catch(error => {
 vm.showError = true next(false) }) }

接下来的(false)命令将使用户停留在原地,我们还编辑了端点到example.com,它将在POST请求上返回一个错误代码:

它是如何工作的...

Axios 将从example.com接收到一个错误,这将触发对我们调用 post 时创建的 promise 的拒绝。promise 的拒绝将反过来触发 catch 中传递的函数。

值得注意的是,在代码的这一点上,vm指的是根Vue实例;这是因为该代码总是在Vue实例初始化并分配给vm之后执行的。

添加进度条以加载页面

虽然 SPA 用户不必等待新页面加载,但他仍然必须等待数据加载。在在切换路由之前获取数据配方中,我们在点击按钮到/aboutme页面后还必须等待一段时间。没有任何提示数据正在加载,然后突然页面出现了。如果用户至少有一些反馈页面正在加载,那不是很好吗?

准备就绪

为了跟上,您应该完成在切换路由之前获取数据配方。

这个配方将在此基础上构建,我假设您已经在适当的位置有了所有相关的代码。

如何做...

正如前面所述,我将假设您已经有了在切换路由之前获取数据配方中的所有代码,并且已经在适当的位置工作。

对于这个配方,我们将使用一个额外的依赖项--NProgress,一个小型实用程序,用于在屏幕顶部显示加载条。

在页面的头部或 JSFiddle 的依赖项列表中添加以下两行(npm 也有一个包):

<link rel="stylesheet" href="https://cdn.bootcss.com/nprogress/X/nprogress.css">
<script src="https://cdn.bootcss.com/nprogress/X/nprogress.js"></script>

在这里,XNProgress的版本。在写作时,它是 0.2.0,但您可以在网上查找。

完成这些步骤后,下一步是定义我们希望进度条的行为。

首先,我们希望点击链接后立即出现进度条。为此,我们可以在点击事件上添加一个事件监听器,但如果有一百个链接,这将是一个很差的设计。一个更可持续和干净的方法是通过为路由创建一个新的钩子,并将进度条的出现与路由的切换连接起来。这样还可以提供一致的应用体验:

router.beforeEach((to, from, next) => {
  NProgress.start()
  next()
})

同样地,我们希望在成功加载完成后进度条消失。这意味着我们希望在回调函数内部执行它:

beforeRouteEnter (to, from, next) {
  axios.post('http://schematic-ipsum.herokuapp.com/', {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "ipsum": "name"
      },
      "phone": {
        "type": "string",
        "format": "phone"
      }
    }
  }).then(response => {
 NProgress.done()    next(vm => {
      vm.name = response.data.name
      vm.phone = response.data.phone
    })
  })
}

现在您可以启动应用程序,您的进度条应该已经工作了:

工作原理…

这个示例还表明,利用外部库并不难,只要它们易于安装。

由于NProgress组件如此简单且有用,我在这里报告它的 API 作为参考:

  • NProgress.start(): 显示进度条

  • NProgress.set(0.4): 设置进度条的百分比

  • NProgress.inc(): 将进度条递增一点

  • NProgress.done(): 完成进度

我们使用了前面两个函数。

作为预防措施,我还建议不要依赖于个别组件调用done()函数。我们在then函数中调用它,但如果下一个开发人员忘记了呢?毕竟,我们在任何路由切换之前都会启动进度条。

最好是在router中添加一个新的钩子:

router.afterEach((to, from) => {
  NProgress.done()
})

由于done函数是幂等的,我们可以随意调用它。因此,这不会修改我们应用的行为,并且可以确保即使将来的开发人员忘记关闭进度条,一旦路由已经改变,它也会自动消失。

如何重定向到另一个路由

您可能有无数个原因希望重定向用户。您可能希望用户在访问页面之前登录,或者页面已经移动,您希望用户注意新链接。在这个示例中,您将重定向用户到一个新的主页,以便快速修改网站。

准备就绪

这个教程只会使用关于 vue-router 的基本知识。如果您已经完成了使用 vue-router 创建 SPA的教程,那么您就可以开始了。

如何做…

假设我们有一个在线服装店。

这将是网站的 HTML 布局:

<div id="app">
  <h1>Clothes for Humans</h1>
  <ul>
    <li><router-link to="/">Home</router-link></li>
    <li><router-link to="/clothes">Clothes</router-link></li>
  </ul>
  <router-view></router-view>
</div>

这只是一个链接到服装列表的页面。

让我们注册VueRouter

Vue.use(VueRouter)

我们的网站有三个页面,分别由以下组件表示:

const Home = { template: '<div>Welcome to Clothes for Humans</div>' }
const Clothes = { template: '<div>Today we have shoes</div>' }
const Sales = { template: '<div>Up to 50% discounts! Buy!</div>' }

它们代表着主页、服装列表和去年我们使用过的一些打折的页面。

让我们注册一些routes

const router = new VueRouter({
  routes: [
    { path: '/', component: Home }
    { path: '/clothes', component: Clothes },
    { path: '/last-year-sales', component: Sales }
  ]
})

最后,我们添加一个根Vue实例:

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

您可以启动应用程序,它应该可以正常工作,没有任何问题。

黑色星期五就要到了,我们忘了这是全球时尚界最大的活动。我们没有时间重写主页,但去年的销售页面可以解决问题。我们要做的是将访问我们主页的用户重定向到那个页面。

为了实现这一点,我们需要修改我们注册的routes的方式:

const router = new VueRouter({
  routes: [
    { path: '/', component: Home, redirect: '/last-year-sales' },
    { path: '/clothes', component: Clothes },
    { path: '/last-year-sales', component: Sales }
  ]
})

只需添加那个重定向,我们就挽救了这一天。现在,每当您访问主页时,都会呈现销售页面。

它是如何工作的…

当匹配根路由时,Home组件不会被加载。而是匹配/last-year-sales的路径。我们也可以完全省略组件,因为它永远不会被加载:

{ path: '/', redirect: '/last-year-sales' }

还有更多…

在 vue-router 中进行重定向比我们刚才看到的更强大。在这里,我将尝试为我们刚刚创建的应用程序增加更多重定向功能。

重定向到 404 页面

重定向未找到的页面是通过在最后一个路由中添加一个捕获所有来完成的。它将匹配所有其他路由未匹配的内容:

...
{ path: '/404', component: NotFound },
{ path: '*', redirect: '/404' }

命名重定向

重定向可以与命名路由结合使用(参考使用命名动态路由教程)。我们可以通过名称指定目的地:

...
{ path: '/clothes', name: 'listing', component: Clothes },
{ path: '/shoes', redirect: { name: 'listing' }}

带参数重定向

您还可以在重定向时保留参数:

...
{ path: '/de/Schuh/:size', redirect: '/en/shoe/:size' },
{ path: '/en/shoe/:size', component: Shoe }

动态重定向

这是最终的重定向。您可以访问用户试图访问的路由,并决定要将其重定向到哪里(尽管您无法取消重定向):

...
{ path: '/air', component: Air },
{ path: '/bags', name: 'bags', component: Bags },
{ path: '/super-shirt/:size', component: SuperShirt },
{ path: '/shirt/:size?', component: Shirt},
{ path: '/shirts/:size?',
  redirect: to => {
    const { hash, params, query } = to
    if (query.colour === 'transparent') {
      return { path: '/air', query: null }
    }
    if (hash === '#prada') {
      return { name: 'bags', hash: '' }
    }
    if (params.size > 10) {
      return '/super-shirt/:size'
    } else {
      return '/shirt/:size?'
    }
  }
}

在返回时保存滚动位置

在 vue-router 中,有两种导航模式:hashhistory。默认模式和前面的示例中使用的模式是previouslye。传统上,当您访问网站,向下滚动一点并单击链接到另一个页面时,新页面将从顶部显示。当您单击浏览器的返回按钮时,页面将从先前滚动的高度显示,并且您刚刚单击的链接可见。

当您在 SPA 中时,这是不正确的,或者至少不是自动的。vue-router 历史模式让您模拟这一点,甚至更好地控制发生在您滚动时发生的事情。

准备工作

要完成此示例,我们需要切换到历史模式。历史模式仅在应用程序在正确配置的服务器上运行时才起作用。如何为 SPA 配置服务器超出了本书的范围(但原则是每个路由都从服务器端重定向到index.html)。

我们将使用一个 npm 程序来启动一个小型服务器;您应该已经安装了 npm。

如何做…

首先,您将安装一个用于 SPA 的紧凑服务器,以便历史模式可以工作。

在您喜欢的命令行中,进入将包含您的应用程序的目录。然后,键入以下命令:

    npm install -g history-server
    history-server .

服务器运行后,您将不得不将浏览器指向http://localhost:8080,如果您的目录中有一个名为index.html的文件,它将显示出来;否则,您将看不到太多。

创建一个名为index.html的文件,并填写一些样板,就像在选择开发环境示例中一样。我们希望有一个只有Vuevue-router作为依赖项的空白页面。我们的空白画布应该如下所示:

<!DOCTYPE html>
<html>
<head>
  <script src="https://unpkg.com/vue/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
</head>
<body>
  <div id="app">
  </div>
  <script>
    new Vue({
      router,
      el: '#app'
    })
  </script>
</body>
</html>

作为 HTML 布局,将其放在 body 中:

<div id="app">
  <h1>News Portal</h1>
    <ul>
      <li><router-link to="/">Home</router-link></li>
      <li><router-link to="/sports">Sports</router-link></li>
      <li><router-link to="/fashion">Fashion</router-link></li>
    </ul>
  <router-view></router-view>
</div>

我们有一个带有三个链接和一个 router-view 入口的标题。我们将为体育和时尚页面创建两个长页面:

const Sports = { template: `
  <div>
    <p v-for="i in 30">
      Sample text about sports {{i}}.
    </p>
    <router-link to="/fashion">Go to Fashion</router-link>
    <p v-for="i in 30">
      Sample text about sports {{i + 30}}.
    </p>
  </div>
` }
const Fashion = { template: `
  <div>
    <p v-for="i in 30">
      Sample text about fashion {{i}}.
    </p>
    <router-link to="/sports">Go to Sports</router-link>
    <p v-for="i in 30">
      Sample text about fashion {{i + 30}}.
    </p>
  </div>
` }

我们只需要一个主页组件的存根:

const Home = { template: '<div>Welcome to BBCCN</div>' }

为这个新闻网站编写一个合理的路由器:

Vue.use(VueRouter)
const router = new VueRouter({
  routes: [
    { path: '/', component: Home },
    { path: '/sports', component: Sports },
    { path: '/fashion', component: Fashion } 
  ]
})

如果您现在使用浏览器转到先前指定的地址,您应该可以看到网站正在运行。

转到体育页面,滚动到看到链接为止,然后单击它。

注意您正在访问的页面不是从头开始显示的。这在传统网站中不会发生,也不是理想的。

单击返回按钮,注意我们在上次离开页面的地方;我们希望保留这种行为。

最后,请注意页面的 URL 看起来并不自然,但内部有哈希符号;我们希望 URL 看起来更好:

为了实现这一点,让我们将路由器代码修改为以下内容:

const router = new VueRouter({
 mode: 'history',  routes: [
    { path: '/', component: Home },
    { path: '/sports', component: Sports },
    { path: '/fashion', component: Fashion }
  ],
 scrollBehavior (to, from, savedPosition) { if (savedPosition) { return savedPosition } else { return { x: 0, y: 0 } } } })

我们添加了一行,指定新模式为历史(链接中没有哈希),并定义了scrollBehavior函数,以便在有位置时返回到最后位置;如果是新页面,它应该滚动到左上角。

您可以通过刷新浏览器并返回主页来尝试这一点。

打开体育页面并单击页面中间的链接。新页面现在从头开始显示。

单击“返回”,savedPosition将被恢复。

请注意现在 URL 看起来更好了:

它是如何工作的...

当您在浏览器中使用包含哈希符号的 URL 时,浏览器将发送一个不带哈希后缀的 URL 的请求,也就是说,当您在页面内有一个事件,该事件转到相同页面但带有不同的哈希后缀时:

http://example.com#/page1 on  http://example.com#/page2

浏览器不会重新加载页面;这就是为什么 vue-router 可以在用户单击仅修改哈希的链接时修改页面的内容,而无需重新加载页面。

当您将模式从hash更改为history时,vue-router 将放弃哈希标记,并利用“history.pushState()”函数。

此函数将添加另一个虚拟页面并将 URL 更改为其他内容:

http://example.com/page1 =pushState=> http://example.com/page2

浏览器不会发送 GET 请求来查找page2; 实际上,它什么也不会做。

当您按下返回按钮时,浏览器会恢复 URL,并且 vue-router 会接收一个事件。然后它将读取 URL(现在是page1)并匹配相关的路由。

我们紧凑的历史服务器的作用是将每个 GET 请求重定向到index.html页面。这就是为什么当我们尝试直接转到http://localhost:8080/fashion时,我们不会收到 404 错误。

第十六章:组织+自动化+部署=Webpack

在这一章中,我们将讨论以下主题:

  • 从组件中提取逻辑以保持代码整洁

  • 使用 Webpack 捆绑您的组件

  • 使用 Webpack 组织您的依赖项

  • 在您的 Webpack 项目中使用外部组件

  • 使用热重载进行持续反馈的开发

  • 使用 Babel 编译 ES6

  • 在开发过程中运行代码检查器

  • 只使用一个命令来构建一个压缩和一个开发.js 文件

  • 将您的组件发布到公共场所

介绍

Webpack 与 npm 结合是一个非常强大的工具。本质上,它只是一个捆绑器,将一些文件及其依赖项捆绑到一个或多个可消耗的文件中。它现在已经进入第二个版本,并且比以前更加强大,特别是对于 Vue 开发人员。

Webpack 将使您能够方便地编写单个文件中方便隔离的组件,并可通过命令进行发布。它将使您能够使用不同的 JavaScript 标准,如 ES6,但也可以使用其他语言,这都归功于加载器,这个概念将在以下配方中反复出现。

从组件中提取逻辑以保持代码整洁

Vue 组件有时可能变得非常复杂。在这些情况下,最好将它们拆分开来,并尝试用抽象隐藏一些复杂性。放置这种复杂性的最佳位置是外部 JavaScript 文件。这样做的好处是,如果有必要,更容易与其他组件共享提取的逻辑。

准备工作

这个配方是中级水平的。在来这里之前,你知道如何用 npm 建立一个项目。

还要确保您已经全局安装了vue-cli包,使用以下命令:

npm install -g vue-cli

如何做...

我们将为复利利息构建一个计算器;您将发现在初始投资后您将拥有多少钱。

创建一个干净的 Webpack 项目

创建一个新目录,并使用以下命令在其中创建一个新的Vue项目:

vue init webpack

您可以选择问题的默认值。

运行npm install来安装所有必需的依赖项。

然后,导航到目录结构中的src/App.vue并删除文件中的几乎所有内容。

最终结果应该如下:

<template>
  <div id="app">
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style>
</style>

我已经为您做了这个,您可以使用以下命令来使用另一个模板:

vue init gurghet/webpack

构建复利计算器

要构建复利计算器,您需要三个字段:初始资本或本金,年利率和投资期限。然后添加一个输出字段来显示最终结果。以下是相应的 HTML 代码:

<div id="app">
  <div>
    <label>principal capital</label>
    <input v-model.number="principal">
  </div>
  <div>
    <label>Yearly interestRate</label>
    <input v-model.number="interestRate">
  </div>
  <div>
    <label>Investment length (timeYears)</label>
    <input v-model.number="timeYears">
  </div>
  <div>
    You will gain:
    <output>{{final}}</output>
  </div>
</div>

我们加上.number修饰符,否则我们放入的数字将被 JavaScript 转换为字符串。

在 JavaScript 部分,通过编写以下代码声明三个模型变量:

export default {
  name: 'app',
  data () {
    return {
      principal: 0,
      interestRate: 0,
      timeYears: 0
    }
  }
}

为了计算复利,我们采用数学公式:

在 JavaScript 中,可以写成如下形式:

P * Math.pow((1 + r), t)

您必须将其添加到Vue组件作为计算属性,如下所示:

computed: {
  final () {
    const P = this.principal
    const r = this.interestRate
    const t = this.timeYears
    return P * Math.pow((1 + r), t)
  }
}

您可以使用以下命令运行您的应用程序(从您的目录启动):

npm run dev

现在我们的应用程序可以运行了,您可以看到我们将在银行账户中存入 0.93 美元,利率为 2.25%,然后休眠 1000 年会获得多少收益(43 亿美元!):

代码内部的公式现在并不是什么大问题。但是,如果我们有另一个组件也执行相同的计算怎么办?我们也希望更明确地表明我们正在计算复利,而在这个范围内我们实际上并不关心公式是怎么做的。

src文件夹中创建一个名为compoundInterest.js的新文件;在其中编写以下代码:

export default function (Principal, yearlyRate, years) {
  const P = Principal
  const r = yearlyRate
  const t = years
  return P * Math.pow((1 + r), t)
}

然后我们相应地修改App.vue中的代码:

computed: {
  final () {
    return compoundInterest(
 this.principal,
 this.interestRate,
 this.timeYears
 )
  }
}

还要记得在 JavaScript 部分的顶部导入我们刚刚创建的文件:

<script>
 import compoundInterest from './compoundInterest'
  export default {
  ...

工作原理...

在组件中工作或者一般编程时,最好将代码范围减少到只有一层抽象。当我们编写一个计算函数返回最终资本值时,我们只需要担心调用正确的函数--为我们的目的进行正确的计算的函数。公式的内部处于更低的抽象层次,我们不想处理那些。

我们所做的是将所有计算的细节放在一个单独的文件中。然后我们用以下一行从文件中导出函数:

export default function (Principal, yearlyRate, years) {
...

这样在从我们的Vue组件中导入文件时,默认情况下就可以使用该函数:

import compoundInterest from './compoundInterest'
...

所以,现在compoundInterest是我们在另一个文件中定义的函数。此外,这种关注点的分离使我们可以在代码的任何地方使用这个函数来计算复利,甚至在其他文件中(可能也是其他项目)。

使用 Webpack 打包您的组件

Webpack 允许您将项目打包成缩小的 JavaScript 文件。然后,您可以分发这些文件或自己使用它们。当您使用vue-cli提供的内置模板时,Webpack 被配置为构建一个完整的工作应用程序。有时我们想要构建一个库来发布或在另一个项目中使用。在这个食谱中,您将调整 Webpack 模板的默认配置,以发布一个组件。

准备工作

只有在您安装了 npm 并熟悉了vue-cli和 Webpack 模板之后,这个食谱才对您有意义。

如何做...

对于这个食谱,您将构建一个可重复使用的组件,它可以摇动您放入其中的任何东西;为此,我们将使用优秀的 CSShake 库。

基于 Webpack 模板创建一个新的干净项目。您可以查看之前的食谱,看看如何做,或者您可以使用我制作的预构建模板。您可以通过创建一个新目录并运行此命令来使用我的模板:

vue init gurghet/webpack

如果您不知道它们的含义,请选择默认答案。记得运行npm install来引入依赖项。

让我们首先重命名一些东西:将App.vue文件重命名为Shaker.vue

在其中,将以下内容写入 HTML 模板:

<template>
  <span id="shaker" class="shake">
    <link rel="stylesheet" type="text/css" href="https://csshake.surge.sh/csshake.min.css">
    <slot></slot>
  </span>
</template>

请注意,我们将<div>更改为<span>,与原始模板相比。这是因为我们希望我们的摇动器成为一个内联组件。

组件已经完整了;我们只需要在 JavaScript 部分进行一些微小的美学编辑:

<script>
  export default {
    name: 'shaker'
  }
</script>

要手动测试我们的应用程序,我们可以以以下方式修改main.js文件(突出显示的文本是修改后的代码):

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import Shaker from './Shaker'

/* eslint-disable no-new */
new Vue({
  el: '#app',
 template: `
    <div>
      This is a <Shaker>test</Shaker>
    </div>
  `,  components: { Shaker }
})

这将创建一个示例页面,如下截图所示,在其中我们可以使用热重新加载原型化我们的组件。通过运行以下命令来启动它:

npm run dev

将光标放在单词test上应该会使其摇动。

现在,我们想将这个组件打包成一个单独的 JavaScript 文件,以便将来可以重复使用。

默认模板中没有此配置,但很容易添加一个。

首先,您需要在build文件夹内的webpack.prod.js文件中进行一些修改。

让我们摆脱一些我们在发布库时不需要的插件;找到文件中的plugins数组。它是一个包含以下代码形式的插件数组:

plugins: [
  new Plugin1(...),
  new Plugin2(...),
  ...
  new PluginN(...)
]

我们只需要以下插件:

  • webpack.DefinePlugin

  • webpack.optimize.UglifyJsPlugin

  • webpack.optimize.OccurrenceOrderPlugin

摆脱所有其他插件,因为我们不需要它们;最终数组应该看起来像这样:

plugins: [
  new webpack.DefinePlugin({
    'process.env': env
  }),
  new webpack.optimize.UglifyJsPlugin({
    compress: {
      warnings: false
    }
  }),
  new webpack.optimize.OccurrenceOrderPlugin()
]

第一个允许您添加一些更多的配置,第二个插件会使文件变小,第三个将优化生成文件的大小。

我们需要编辑的另一个属性是output,因为我们希望简化输出路径。

原始属性看起来像这样:

output: {
  path: config.build.assetsRoot,
  filename: utils.assetsPath('js/[name].[chunkhash].js'),
  chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
}

它最初的作用是在js目录内创建一系列输出文件。方括号中有变量;我们不需要它们,因为我们的应用程序只有一个独立模块,我们将其称为shaker。我们需要获取以下代码:

output: {
  path: config.build.assetsRoot,
 filename: utils.assetsPath('shaker.js') }

由于刚才说过,您希望组件是独立的,我们需要进行一些更多的修改,这也将取决于您的需求。

如果您希望组件内置任何 CSS 样式(在我们的情况下我们没有,因为我们正在使用外部 CSS 库),您应该禁用ExtractTextPlugin;我们已经从列表中删除了插件,但是一些其他文件仍在使用它。找到vue-loader.conf.js文件中的extract选项(在某些版本中是相同文件的vue部分)并用以下代码替换它:

... {
  loaders: utils.cssLoaders({
    ...
    extract: false
  })
}

我们的组件通常会包含 Vue 库;如果您想在 Vue 项目中使用组件,则不需要这个,因为这将是重复的代码。您可以告诉 Webpack 只搜索外部依赖项,而不包括它们。在您刚刚修改的webpack.prod.js文件中,在plugins之前添加以下属性:

externals: {
  'vue': 'Vue'
}

这将告诉 Webpack 不将 Vue 库写入捆绑包中,而只是获取一个全局命名为Vue,并在我们的代码中导入vue依赖项时使用它。Webpack 配置几乎完成了;我们只需要在module属性之前添加另一个属性:

var webpackConfig = merge(baseWebpackConfig, {
  entry: {
 app: './src/dist.js'
 },
  module: {
  ...

这将启动编译,从dist.js文件中读取代码。等一下,这个文件还不存在。让我们创建它,并在其中添加以下代码:

import Vue from 'vue'
import Shaker from './Shaker'
Vue.component('shaker', Shaker)

在最终的 JavaScript 压缩文件中,Vue 依赖项将被外部引用,然后我们将全局注册组件。

作为最后的更改,我建议修改保存压缩文件的文件夹。在config/index.js文件中,编辑以下行:

assetsSubDirectory: 'static',

用以下代码替换前一行:

assetsSubDirectory: '.',

现在用 npm 运行命令构建压缩文件:

npm run build

您将看到如下输出:

为了测试我们的文件,我们可以使用 JSFiddle

复制您创建的文件内容到dist/shaker.js中,然后转到gist.github.com/(您可能需要注册),并将文件内容粘贴到文本区域中。将其命名为shaker.js

由于文本是单行的,使用 No wrap 选项您将看不到太多。点击创建公共 gist,当您看到下一页时,点击 Raw,如下面的截图所示:

复制地址栏中的 URL,转到rawgit.com/,在那里您可以粘贴链接:

点击并复制右侧得到的链接。恭喜,您刚刚在网络上发布了您的组件!

现在转到 JSFiddle 并选择 Vue 作为库。您现在可以在左侧添加您复制的链接,然后您的组件就可以使用了:

它是如何工作的...

官方模板中的 Webpack 配置非常复杂。另一方面,不要试图立刻理解一切,否则你会陷入困境,也学不到太多。

我们创建了一个UMD(通用模块定义)模块,它将尝试查看是否有 Vue 依赖可用,并将自己安装为组件。

您甚至可以为您的组件添加 CSS 和样式,以我们配置 Webpack 的方式,样式仍将与您的组件一起发布。

还有更多...

在本章的将您的组件发布到公共中,您将学习如何在 npm 发布注册表中发布您的组件。我们将使用不同的方法,但您将在那里找到发布到注册表的缺失步骤。

使用 Webpack 组织您的依赖项

Webpack 是一个组织代码和依赖项的工具。此外,它为您提供了一种使用 JavaScript 文件开发和构建的方式,这些文件嵌入了我们传递给它们的所有依赖项和模块。我们将在这个示例中使用它来构建一个小型的 Vue 应用程序,并将所有内容捆绑在一个单一文件中。

准备工作

这个示例除了使用 npm 和一些命令行知识外,不需要任何特殊技能。您可以在本章的使用 Webpack 组织您的依赖项示例中找到更多信息。

如何做...

为您的示例创建一个新文件夹,并在其中创建一个package.json文件,其中包含以下内容:

{
 "name": "recipe",
 "version": "1.0.0"
}

这在我们的文件夹中定义了一个 npm 项目。当然,如果你知道自己在做什么,你可以使用npm inityarn init

我们将为这个示例安装 Webpack 2。要将其添加到项目依赖项中,请运行以下命令:

npm install --save-dev webpack@2

--save-dev选项意味着我们不会在最终产品中发布 Webpack 的代码,而只会在开发过程中使用它。

创建一个新的app目录,并在其中创建一个App.vue文件。

这个文件将是一个简单的Vue组件;它可以像下面这样简单:

<template>
  <div>
    {{msg}}
  </div>
</template>
<script>
export default {
  name: 'app',
  data () {
    return {
      msg: 'Hello world'
    }
  }
}
</script>
<style>
</style>

我们需要告诉 Webpack 如何将.vue文件转换为.js文件。为此,在根文件夹中创建一个名为webpack.config.js的配置文件;这个文件将被 Webpack 自动捡起。在这个文件中,写入如下内容:

module.exports = {
  module: {
    rules: [
      {test: /.vue$/, use: 'vue-loader'}
    ]
  }
}

rules 中的这一行表示:

嘿 Webpack,当你看到以.vue结尾的文件时,使用vue-loader将其转换为 JavaScript 文件。

我们需要使用以下命令通过 npm 安装这样的加载器:

npm install --save-dev vue-loader

这个加载器内部使用其他依赖项,这些依赖项不会自动安装;我们需要通过运行以下命令手动安装它们:

npm install --save-dev vue-template-compiler css-loader

让我们趁机安装 Vue 本身:

npm install --save vue

现在我们的Vue组件已经准备好了。我们需要编写一个页面来放置它并尝试它。在app文件夹中创建一个名为index.js的文件。我们将在 Vue 实例中实例化组件。在index.js中,写入以下内容:

import Vue from 'vue'
import App from './App.vue'
new Vue({
  el: '#app',
  render: h => h(App)
})

这将在具有id="app"的元素内挂载 Vue 实例,并且它将包含一个单一组件--我们的App.vue

我们还需要另一个文件--一个 HTML 文件。在根目录中,创建index.html并输入以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Webpack 2 demo</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="dist/bundle.js"></script>
  </body>
</html>

我们不想直接引用app/index.js;这是因为index.js本身并不包含太多内容。它有一个import语句,浏览器不会识别。Webpack 可以轻松地创建dist/bundle.js,其中包含index.js及其所有依赖项。要做到这一点,请运行以下命令:

./node_modules/webpack/bin/webpack.js app/index.js dist/bundle.js

这应该会生成类似于这样的输出:

现在你可以打开index.html,你会看到组件在工作。

然而,每次启动这个长命令并不是很有趣。Webpack 和 npm 可以做得更好。

webpack.config.js中,添加以下属性:

module.exports = {
  entry: './app/index.js',
 output: {
 filename: 'bundle.js',
 path: __dirname + '/dist'
 },
  module: {
  ...

这将指定 Webpack 的入口点以及结果文件应该保存在哪里。

我们还可以在package.json中添加一个脚本:

"scripts": {
  "build": "webpack"
}

现在,启动npm run build将产生与我们使用的长命令相同的效果。

它是如何工作的...

在这个教程中,我们基本上创建了一个 JavaScript 文件(bundle.js),它同时包含了 Vue 和我们编写的组件。在index.html中,没有 Vue 的痕迹,因为它嵌入在bundle.js中。

当我们有很多依赖时,这种工作方式要好得多。我们不再需要在页面的头部或主体中添加很多标签。此外,我们也不必担心加载我们不需要的依赖。

作为额外的奖励,Webpack 具有压缩我们最终文件和其他高级优化的能力和灵活性,这是通过手动加载依赖项根本不可能实现的。

在你的 Webpack 项目中使用外部组件

在自己的项目中使用外部 Vue 组件通常很简单。但有时候,事情并不那么简单。特别是,在官方模板中有一些配置与 Webpack 实际上阻止你使用一些外部组件。在这个教程中,我们将安装 Bulma 项目中的一个模态对话框组件。

准备工作

在这个教程中,我们将调整 Webpack 配置。建议在接手这项任务之前,先完成使用 Webpack 组织你的依赖项教程。

如何做...

我们将从一个新的 Webpack 项目开始。你可以使用vue-cli和官方的 Webpack 模板创建一个新的项目。不过,我的建议是,从我的 Webpack 模板开始,这是一个干净的起点。要做到这一点,请在一个新目录中运行以下命令:

vue init gurghet/webpack

我们将安装vue-bulma-modal,这是一个使用 Bulma CSS 框架编写的 Vue 组件:

npm install --save vue-bulma-modal bulma

在上述命令中,我们也安装了bulma,其中包含实际的 CSS 样式。

要使样式生效,我们需要将它们转换为 Webpack 的 JavaScript;这意味着我们需要安装一些加载器:

npm install --save-dev node-sass sass-loader

SASS 加载器已经配置好了,所以不需要做任何修改。不过,我们将修改与 Babel 加载器相关的 Webpack 配置(在使用热重新加载进行持续反馈开发食谱中了解更多)。

在官方模板中(但这可能会改变,请注意),有一行阻止 Webpack 编译依赖项。转到build/webpack.base.conf.js并找到以下代码块:

{
  test: /.js$/,
  loader: 'babel-loader',
  include: [
    path.join(projectRoot, 'src')
  ],
 exclude: /node_modules/
},

根据您使用的 Webpack 版本,您可能需要稍微调整加载器语法。例如,在较旧的 Webpack 版本中,您将写babel而不是babel-loader

您必须删除突出显示的行,而是写入以下内容:

{
  test: /.js$/,
  loader: 'babel-loader',
  include: [
    path.join(projectRoot, 'src'),
 path.join(projectRoot, 'node_modules/vue-bulma-modal')
  ]
},

这告诉 Webpack 使用babel-loader编译我们刚刚安装的组件。

现在,在App.vue中编写以下 HTML 布局:

<template>
  <div id="app">
    <card-modal
      @ok="accept"
      ok-text="Accept"
      :visible="popup"
      @cancel="cancel"
    >
      <div class="content">
        <h1>Contract</h1>
          <p>
            I hereby declare I have learned how to
            install third party components in my
            own Vue project.
          </p>
        </div>
      </card-modal>
    <p v-if="signed">It appears you signed!</p>
  </div>
</template>

然后,您可以按照 JavaScript 中所示的逻辑编写代码:

<script>
import { CardModal } from 'vue-bulma-modal'
export default {
  name: 'app',
  components: { CardModal },
  data () {
    return {
      signed: false,
      popup: true
    }
  },
  methods: {
    accept () {
      this.popup = false
      this.signed = true
    },
    cancel () {
      this.popup = false
    }
  }
}
</script>

使用 Bulma 样式,我们需要启用 SASS 加载器并导入bulma文件。添加以下行:

<style lang="sass">
@import '~bulma';
</style>

请注意,我们在第一行中指定了样式的语言(我们写的是 SCSS,但在这种情况下我们原样写)。

如果您现在尝试使用npm run dev命令运行应用程序,您将看到 Bulma 模态对话框以其全部辉煌:

它是如何工作的...

官方的 Webpack 模板包含一个配置规则,永远不要编译node_modules目录中的文件。这意味着 Web 组件的作者被鼓励分发已经编译好的文件,否则用户将在其项目中导入原始 JavaScript 文件(因为 Webpack 不会编译它们),从而在浏览器中引起各种错误。就个人而言,我认为这不是一个好的工程实践。这种设置的一个问题是,由于您在项目中导入的文件是针对 Vue 的一个版本编译的,如果您使用更新的 Vue 版本,组件可能无法正常工作(这实际上在过去发生过)。

更好的方法是导入原始文件和组件,让 Webpack 将它们编译成一个单一文件。不幸的是,大多数在外部可用的组件已经编译好了,所以虽然按照官方模板导入它们非常快速,但你更有可能遇到兼容性问题。

在导入外部组件时,首先要做的是检查它们的package.json文件。让我们看看vue-bulma-modal包在这个文件中包含了什么:

{
  "name": "vue-bulma-modal",
  "version": "1.0.1",
  "description": "Modal component for Vue Bulma",
 "main": "src/index.js",  "peerDependencies": {
    "bulma": ">=0.2",
    "vue": ">=2"
  },
  ...
  "author": "Fangdun Cai <cfddream@gmail.com>",
  "license": "MIT"
}

main属性引用的文件是我们在 JavaScript 中写下以下行时导入的文件:

import { CardModal } from 'vue-bulma-modal'

src/index.js文件则包含以下代码:

import Modal from './Modal'
import BaseModal from './BaseModal'
import CardModal from './CardModal'
import ImageModal from './ImageModal'

export {
  Modal,
  BaseModal,
  CardModal,
  ImageModal
}

这不是一个编译后的文件;它是原始的 ES6,我们知道这一点是因为在常规 JavaScript 中没有定义import。这就是为什么我们需要 Webpack 来为我们编译它。

另一方面,考虑到我们写下以下内容:

<style lang="sass">
@import '~bulma';
</style>

使用波浪号(~),我们告诉 Webpack 像模块一样解析样式,所以我们真正导入的是bulma包的package.jsonmain引用的文件,如果我们检查一下,它看起来是这样的:

{
  "name": "bulma",
  "version": "0.3.1",
  ...
 "main": "bulma.sass",
  ...
}

由于我们正在导入具有 SASS 语法的 SASS,我们需要在 Vue 组件中指定lang="sass"

使用热重载进行持续反馈开发

热重载是一种非常有用的技术,它让您在浏览器中查看结果的同时进行开发,甚至无需刷新页面。这是一个非常紧密的循环,可以真正加快您的开发过程。在官方 Webpack 模板中,默认安装了热重载。在这个示例中,您将学习如何自己安装它。

准备工作

在尝试这个示例之前,您应该至少对 Webpack 的工作原理有一个模糊的概念;本章中的使用 Webpack 组织您的依赖项示例将为您提供帮助。

如何做到这一点...

在新目录中创建一个新的 npm 项目,可以使用npm init -yyarn init -y。我个人更喜欢第二种方法,因为生成的package.json更加简洁。

要安装 Yarn,可以使用npm install -g yarn命令。Yarn 的主要好处是您将能够将您的依赖项锁定到已知版本。这可以防止在团队合作时出现错误,当应用程序从 Git 克隆时,会有略有不同的版本引入不兼容性。

您将创建一个数字骂人罐。每次您说脏话,您都要向骂人罐捐赠一定金额的钱,用于长期目标。

创建一个名为SwearJar.vue的新文件,并在其中添加以下代码:

<template>
  <div>
    Swears: {{counter}} $$
    <button @click="addSwear">+</button>
  </div>
</template>
<script>
export default {
  name: 'swear-jar',
  data () {
    return {
      counter: 0
    }
  },
  methods: {
    addSwear () {
      this.counter++
    }
  }
}
</script>

您将在网页上插入此组件。

在同一目录下创建一个名为index.html的文件,并写入以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Swear Jar Page</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="bundle.js"></script>
  </body>
</html>

bundle.js文件将由 Webpack(在内存中)为我们创建。

您需要的最后一个应用文件是一个包含我们 Vue 根实例的 JavaScript 文件。在同一目录中创建一个名为index.js的文件;将以下内容放入其中:

import Vue from 'vue'
import SwearJar from './SwearJar.vue'
new Vue({
  el: '#app',
  render: h => h(SwearJar)
})

现在,您需要创建一个名为webpack.config.js的文件,告诉 Webpack 一些事情。首先是我们应用的入口点(index.js)以及我们想要放置编译文件的位置:

module.exports = {
  entry: './index.js',
  output: {
    path: 'dist',
    filename: 'bundle.js'
  }
}

接下来,我们将告诉 Webpack 使用vue-loader.vue文件转换为 JavaScript:

module.exports = {
  entry: './index.js',
  output: {
    path: 'dist',
    filename: 'bundle.js'
  },
  module: {
 rules: [
 {
 test: /.vue$/,
 use: 'vue-loader'
 }
 ]
 }
}

为了使一切正常运行,我们仍然需要安装我们代码中暗示的依赖项。我们可以用以下两个命令来安装它们:

npm install --save vue
npm install --save-dev vue-loader vue-template-compiler webpack webpack-dev-server

特别是最后一个--webpack-dev-server--是一个开发服务器,将帮助我们进行热重载开发。

运行以下命令启动服务器:

./node_modules/webpack-dev-server/bin/webpack-dev-server.js --output-path / --inline --hot --open

实际上,让我们将这个命令放在一个 npm 脚本中。

打开package.json并添加以下行:

"scripts": {
  "dev": "webpack-dev-server --output-path / --inline --hot --open"
}

现在我们可以运行npm run dev,我们将得到相同的结果--浏览器将弹出--如下面的截图所示:

单击加号按钮将使计数增加,但是这个应用的样式呢?让我们让它更有吸引力。

打开您的代码编辑器和窗口并排放置,并对SwearJar.vue进行以下修改:

<template>
  <div>
    <p>Swears: {{counter}} $$</p>
    <button @click="addSwear">Add Swear</button>
  </div>
</template>

保存文件,您将看到页面自动更新。更好的是,如果计数已经设置为非零,状态将被保留,这意味着如果您有一个复杂的组件,在每次修改后您不必手动将其带入相同的状态。尝试将脏话计数设置为某个数字并编辑模板。大多数情况下,计数不会被重置为零。

它是如何工作的...

Webpack 开发服务器是非常有帮助的软件,让您可以以非常紧密的反馈循环进行开发。我们使用了大量参数来使其运行:

webpack-dev-server --output-path / --inline --hot --open

所有这些参数在webpack.config.js中都是相同的。相反,我们将这些参数放在命令行中以方便使用。--output-path是 Webpack 服务器将提供bundle.js的位置;在我们的情况下,我们说我们希望它在根路径提供服务,因此它将有效地将/bundle.js路径绑定到实际的bundle.js文件。

第二个参数--inline将在我们的浏览器中注入一些 JavaScript 代码,以便我们的应用程序可以与 Webpack 开发服务器进行通信。

--hot参数将激活热模块替换插件,它将与vue-loader(实际上是其中的vue-hot-reload-api)通信,并且将重新启动或重新渲染(保留状态)页面内的每个 Vue 模型。

最后,--open只是为我们打开默认浏览器。

使用 Babel 编译 ES6

ES6 有很多有用的功能,在这个教程中,您将学习如何在您的项目中使用它。值得注意的是,ES6 目前在浏览器中有很好的支持。您不会在野外的 80%的浏览器中遇到兼容性问题,但根据您的受众,您可能需要甚至接触仍在使用 Internet Explorer 11 的人,或者您可能只是想最大化您的受众。此外,一些开发工具和 Node.js 仍然不完全支持 ES6,因此即使是开发,也认为 Babel 是必需的。

准备工作

在这个教程中,我们将使用 npm 和命令行。

如何做...

创建一个带有空 npm 项目的新目录。您可以在目录中使用npm init -y命令,或者如果您已经安装了 Yarn,您可以在目录中使用yarn init -y。这个命令将在目录中创建一个新的package.json。(请参阅使用 Yarn 进行持续反馈开发和热重载教程中关于 Yarn 的注意事项。)

对于这个 npm 项目,除了 Vue 之外,我们还需要一些依赖项:Webpack 和 Babel 作为 Webpack 的加载器。哦是的,我们还需要vue-loader来为 Webpack 安装它们,启动以下两个命令:

npm install --save vue
npm install --save-dev webpack babel-core babel-loader babel-preset-es2015 vue-loader vue-template-compiler

在同一个目录中,让我们编写一个使用 ES6 语法的组件;让我们称之为myComp.vue

<template>
  <div>Hello</div>
</template>
<script>
var double = n => n * 2
export default {
  beforeCreate () {
    console.log([1,2,3].map(double))
  }
}
</script>

这个组件除了在控制台打印[2,4,6]数组之外并没有做太多事情,但它在以下行中使用箭头语法:

var double = n => n * 2

一些浏览器和工具无法理解这一点;我们需要使用 Babel 加载器对这个组件进行 Webpack 编译。

创建一个新的webpack.config.js文件,并在其中写入以下内容:

module.exports = {
  entry: 'babel-loader!vue-loader!./myComp.vue',
  output: {
    filename: 'bundle.js',
    path: 'dist'
  }
}

这将告诉 Webpack 从我们的 myComp.vue 文件开始编译,但在此之前,它将被 vue-loader 处理为一个 js 文件,然后由 babel-loader 处理为更简单和更兼容的箭头函数。

我们可以通过不同和更标准的配置来实现相同的效果:

module.exports = {
 entry: './myComp.vue',  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /.vue$/,
        use: 'vue-loader'
      },
      {
        test: /.js$/,
        use: 'babel-loader'
      }
    ]
  }
}

这是一个更一般的配置,它表示每当我们遇到以 .vue 结尾的文件时,应该使用 vue-loader 进行解析和处理,而对于 .js 文件则使用 babel-loader

要配置 Babel 加载器,有几个选项;我们将遵循推荐的方式。在项目文件夹内创建一个名为 .babelrc 的文件(注意初始点),并指定我们要应用 es2015 预设,写入以下代码:

{
  "presets": ["es2015"]
}

最后,我总是喜欢在 package.json 文件中添加一个新的脚本,以便更轻松地启动命令。在文件末尾(但在最后一个右花括号之前)添加以下行:

"scripts": {
  "build": "webpack"
}

然后运行 npm run build。这将在 dist 目录内创建一个名为 bundle.js 的文件;打开它并搜索包含例如 double 的行。你应该会找到类似这样的内容:

...
var double = function double(n) {
  return n * 2;
};
...

这是我们的 var double = n => n * 2,从 ES6 转换为普通JavaScript。

它是如何工作的...

es2015 Babel 预设是一组 Babel 插件,旨在将 ECMAScript2015(ES6)语法转换为更简单的 JavaScript。例如,它包含了 babel-plugin-transform-es2015-arrow-functions 插件,你可能已经猜到,它转换箭头函数:

var addOne = n => n + 1

将箭头函数转换为更简单的 JavaScript,如下所示:

var addOne = function addOne(n) {
  return n + 1
}

为了选择文件及其相应的加载器,我们填写了 webpack.config.js 内的测试字段,并匹配 .vue 文件,我们写入了以下内容:

test: /\.vue$/

这个语法是一个正则表达式,它总是以一个斜杠开始,以另一个斜杠结束。它匹配的第一个字符是点,表示为 \.,因为 . 字符已经被用于其他目的。点后面必须跟着 vue 字符,字符串的结束字符表示为一个美元符号。如果把它们都放在一起,它将匹配所有以 .vue 结尾的字符串。对于 .js 文件也是类似的。

在开发时运行代码检查器

对代码进行 linting 可以大大减少开发过程中累积的小错误和低效率,它可以保证团队或组织的编码风格一致,并且使您的代码更易读。与偶尔运行 linter 不同,让它持续运行是很有用的。本教程将教您如何在 Webpack 中实现这一点。

准备工作

在本教程中,我们将再次使用 Webpack。您将使用webpack-dev-server构建一个紧密的循环,这在使用热重载进行持续反馈开发教程中有介绍。

如何做...

在一个新的文件夹中,创建一个新的 npm 项目(可以使用npm init -yyarn init -y)。

在文件夹内,创建一个名为src的新目录,并在其中放入一个名为MyComp.vue的文件。让文件包含以下代码:

<template>
  <div>
    Hello {{name}}!
  </div>
</template>
<script>
export default {
  data () {
    return {
      name: 'John',
      name: 'Jane'
    }
  }
}
</script>

我们已经发现了一个问题——Johnname属性将被后面的Jane属性用相同的键覆盖。假装我们没有注意到这一点,并将组件放在一个网页中。为此,我们需要另一个文件,名为index.js,放在src目录中。在其中写入以下代码:

import Vue from 'vue'
import MyComp from './MyComp.vue'
new Vue({
  el: '#app',
  render: h => h(MyComp)
})

在根目录中,放置一个名为index.html的文件,其中包含以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Hello</title>
  </head>
  <body>
    <div id="app"></div>
      <script src="bundle.js"></script>
    </body>
</html>

我们现在需要一个webpack.config.js文件来告诉 Webpack 如何编译我们的文件;在其中写入以下内容:

module.exports = {
  entry: './src/index.js',
  module: {
    rules: [
      { 
        test: /.vue$/,
        use: 'vue-loader'
      }
    ]
  }
}

这只是告诉 Webpack 从index.js文件开始编译,并且每当它找到一个.vue文件时,将其转换为 JavaScript 代码使用vue-loader。除此之外,我们希望用 linter 扫描所有文件,以确保我们的代码没有愚蠢的错误。

将以下加载器添加到rules数组中:

{
  test: /.(vue|js)$/,
  use: 'eslint-loader',
  enforce: 'pre'
}

enforce: 'pre'属性将在其他加载器之前运行此加载器,因此它将应用于您编写的代码而不是其转换。

我们需要做的最后一件事是配置 ESLint。在根目录中创建一个名为.eslintrc.js的新文件,并在其中添加以下内容:

module.exports = {
  "extends": "eslint:recommended",
  "parser": "babel-eslint",
  plugins: [
    'html'
  ]
}

我们在这里说了几件事情。首先是我们想要应用到我们的代码的一组规则;换句话说,我们的规则集(现在为空)正在扩展推荐的规则集。其次,我们使用babel-eslint解析器而不是默认的解析器。最后,我们使用 HTML ESLint 插件,它将帮助我们处理.vue文件,并从中提取 JavaScript 代码。

现在我们已经准备好启动开发工具,但首先,我们需要使用以下命令安装依赖项:

npm install --save vue
npm install --save-dev babel-eslint eslint eslint-loader eslint-plugin-html vue-loader vue-template-compiler webpack webpack-dev-server

我们可以直接启动 Webpack 开发服务器,但我强烈建议将以下代码添加到package.json文件中:

"scripts": {
  "dev": "webpack-dev-server --entry ./src/index.js --inline --hot --open"
}

现在,如果我们启动npm run dev,浏览器应该打开,并显示组件不正确:

<q>Hello Jane!</q>

你也应该能在控制台中看到问题:

11:7  error  Duplicate key 'name'  no-dupe-keys

这意味着我们有两个相同的name键。通过删除属性来纠正错误:

data () {
  return {
    name: 'John'
  }
}

在控制台中,保存 Vue 组件后,你应该注意到 Webpack 已经再次执行了编译,这次没有错误。

它是如何工作的…

基本上,这里发生的是 linter loader 在其他编译步骤之前处理文件,并将错误写入控制台。这样,你就能在开发过程中看到代码中的不完美之处。

ESLint 和 Webpack 在 Vue 官方模板中可用。现在你知道,如果出于某种原因,你想修改 ESLint 规则,你可以从.eslintrc.js文件中进行,如果你想使用另一个 linter,你可以在 Webpack 配置文件中使用另一个 loader。

只使用一个命令构建一个压缩和一个开发.js 文件

在发布组件时,你可能需要一个可靠的流程来发布你构建的文件。一个常见的操作是发布库/组件的两个版本:一个用于开发目的,一个用于在生产代码中使用,通常是压缩的。在这个方法中,你将调整官方模板,同时发布一个压缩和一个开发 JavaScript 文件。

准备工作

如果你已经在构建和分发自己的组件,这个方法就有意义。如果你想了解更多,我建议你参考Bundling your component with Webpack这个方法。

如何做…

我们将从官方 Webpack 模板开始一个项目。你可以使用你自己的,或者你可以用vue init webpack创建一个新项目,并用npm install安装依赖。

进入build目录。当你启动npm run build命令时,实际上是在这个目录中启动了build.js文件。

如果你检查文件,你会在结尾附近找到类似这样的内容:

webpack(webpackConfig, function (err, stats) {
...
})

这相当于从命令行启动 Webpack,使用第一个参数中指定的相同配置webpackConfig。为了得到一个经过压缩和一个未经压缩的文件,我们必须将webpackConfig带到一个共同的基准,然后我们将只指定开发和生产版本文件之间的差异。

为了做到这一点,进入同一目录中的webpack.prod.conf.js。在这里,你可以看到我们正在传递的配置;特别是,你会发现UglifyJsPlugin,它负责压缩文件,如果你查看插件数组。删除该插件,因为它代表了两个分发版本之间的主要差异。

现在,在 Webpack 命令之前在build.js中写入以下内容:

const configs = [
  {
    plugins: [
      new webpack.optimize.UglifyJsPlugin({
        compress: {
          warnings: false
        },
        sourceMap: true
      })
    ]
  },
  {
    plugins: []
  }
]

现在你有了一个包含两个不同配置的数组,一个带有用于压缩文件的插件,一个没有。如果你将它们中的每一个与webpack.prod.conf.js中的配置合并,你将得到不同的结果。

为了合并这两个配置,我们将使用webpack-merge包。在文件顶部添加以下行:

var merge = require('webpack-merge')

然后,修改 Webpack 命令的第一行为以下内容:

configs.map(c => webpack(merge(webpackConfig, c), function (err, stats) {
...

这将启动与我们在配置数组中指定的一样多的合并配置。

你现在可以运行npm run build命令,但问题是文件将会有相同的名称。从webpack.prod.conf.js中剪切输出属性,并粘贴到config数组中,现在它应该是这样的:

const configs = [
  {
    output: {
 path: <whatever is your path>,
 filename: 'myFilename.min.js'),
 <other options you may have>
 },
    plugins: [
      new webpack.optimize.UglifyJsPlugin({
        compress: {
          warnings: false
        }
      })
    ]
  },
  {
    output: {
 path: <whatever is your path>,
 filename: 'myFilename.js'),
 <other options you may have>
 },
    plugins: []
  }
]

如果现在构建项目,你将会得到一个经过压缩和一个开发文件。当然,你可以根据需要个性化你的配置,使它们变得截然不同。例如,你可以在一个配置中添加源映射,而在另一个配置中保持原样。

工作原理...

我们首先创建了一个对象数组,代表了 Webpack 配置中的差异。然后我们使用webpack-merge将每个配置映射到一个更大的共同配置中。当我们现在调用npm run build命令时,这两个配置将依次运行。

通常的约定是在文件名后加上min来表示该文件已经被压缩并准备用于生产环境。

发布你的组件到公共环境

在某个时候,你会有一个想要回馈社区的时刻。也许你建了一个“放屁按钮”,或者你建了一个自动股票期权交易者;无论你建了什么,JavaScript 和 Vue 社区都会乐意欢迎你。在市场营销和许可方面有很多事情要做,但在这个教程中,你将集中在更技术方面的内容。

准备工作

这个教程是针对那些想要与 Vue 社区分享他们的工作的人。在 使用 Webpack 打包你的组件 这个教程中,你将找到如何调整官方 Webpack 模板以正确打包你的组件;这个教程可以被看作是第二部分。我们不会使用官方模板。

如何做...

我在这个教程中采用的方法是使用 Guillaume Chau 的优秀的 vue-share-components 模板。我们将从这个起点构建一个笑话按钮。

在你的命令行中,创建一个新的目录,并在其中输入以下命令:

vue init Akryum/vue-share-components

它会问你一些问题;你可以从下面的图片中复制答案。唯一需要注意的是,你(遗憾地)不能在你的项目中使用 joke-button 这个名字,因为在写这个教程时我已经注册了它。然而,你可以想出一个听起来类似的名字(在继续之前,你可能想检查一下这个名字在 npm 注册表中是否可用):

项目创建完成后,你可以像在控制台输出中一样使用 npm install 安装依赖。

在项目内,让我们创建笑话按钮组件。在 component 文件夹内,你会找到一个 Test.vue 组件;将其重命名为 JokeButton.vue 并使其看起来像以下代码:

<template>
  <div class="test">
    <button @click="newJoke">New Joke</button>
    <p>{{joke}}</p>
  </div>
</template>
<script>
const jokes = [
 'Chuck Norris/'s keyboard has the Any key.',
 'Chuck Norris can win at solitaire with only 18 cards.',
 'Chuck Norris/' first job was as a paperboy. There were no survivors.',
 'When Chuck Norris break the build, you can/'t fix it.',
]
export default {
  name: 'joke-button',
  data () {
    return {
      joke: '...',
    }
  },
  methods: {
    newJoke () {
      this.joke = jokes[Math.floor(Math.random() * jokes.length)]
    },
  },
}
</script>

显然,你可以创建你喜欢的组件;这只是一个例子。

index.js 文件中,你会看到 Test 组件被导入和安装;你需要安装 JokeButton。你需要改变的行已经被标出:

import JokeButton from './components/JokeButton.vue'
// Install the components
export function install (Vue) {
  Vue.component('jokeButton', JokeButton)
  /* -- Add more components here -- */
}
// Expose the components
export {
  JokeButton,
  /* -- Add more components here -- */
}
...

我们的组件准备好了!

现在你需要去 npm 网站注册一个账号(如果你还没有的话)。

前往npmjs.com

点击注册并输入你的详细信息,就像我在这里做的一样:

当然,如果你愿意,你可以订阅 npm 每周的新闻通讯。

注册后,您就完成了,可以回到命令行。您必须使用以下命令从终端登录到 npm 注册表:

npm adduser

您会看到类似于这样的东西:

您将不得不输入您刚刚为 npm 网站输入的密码。

下一个命令将在公共存储库中发布您的库:

npm publish

现在,您甚至可以查找您的软件包,确实,您将会发现它如下截图所示:

要尝试它,您可以在自己的README中找到说明,这是多么酷啊?

它是如何工作的...

vue-share-components比官方模板更简单,所以通过检查它来学习是一个很好的方法。

我们可以首先查看package.json文件。以下行是相关的:

...
"main": "dist/joke-button.common.js",
"unpkg": "dist/joke-button.browser.js",
"module": "index.js",
"scripts": {
  "dev": "cross-env NODE_ENV=development webpack --config config/webpack.config.dev.js --progress --watch",
  "build": "npm run build:browser && npm run build:common",
  "build:browser": "cross-env NODE_ENV=production webpack --config config/webpack.config.browser.js --progress --hide-modules",
  "build:common": "cross-env NODE_ENV=production webpack --config config/webpack.config.common.js --progress --hide-modules",
  "prepublish": "npm run build"
},
...

main属性是我们在程序中输入以下命令时实际得到的内容:

import JokeButton from 'JokeButton'

或者,我们在添加以下代码时得到它:

var JokeButton = require("JokeButton")

因此,JokeButton变量实际上将包含我们的joke-button.common.js中导出的内容。

您可以编辑package.json的主属性,直接指向.vue组件。这样,您可以让用户负责编译组件。虽然这对用户来说更费力,但当用户想要自由地针对最新版本的 Vue 进行编译时,这也是有帮助的。

在后一种情况下,如果您的组件中有一些逻辑导出到external.js文件中(就像本章的第一个示例中那样),请始终记得在 Webpack 规则中添加目录,如下所示:

{

  test: /.js$/,

  loader: 'babel-loader',

  include: [resolve('src'), resolve('test'), resolve('node_modules/myComponent')]

},

unpkg 是unpkg.com的特定部分,它是一个 CDN。这非常好,因为一旦我们发布我们的项目,我们的脚本将被发布在unpkg.com/joke-button,它将指向适用于浏览器的joke-button.browser.js文件。

prepublish脚本是一个特殊的脚本,在使用npm publish命令将项目发布到 npm 之前将被调用。这可以消除在发布组件之前忘记构建文件的可能性(这种情况经常发生,所以我被迫人为增加软件版本,手动构建文件,然后再次发布)。

另一个有趣的事实要注意的是webpack.config.common.jswebpack.config.browser.js之间的区别,前者输出joke-button.common.js文件,而后者输出joke-button.browser.js文件。

第一个文件的输出设置如下:

output: {
  path: './dist',
  filename: outputFile + '.common.js',
  libraryTarget: 'commonjs2',
},
target:  'node',

因此,它将输出一个将暴露出一个 commonJS 接口的库;这适用于非浏览器环境,您将需要要求或导入此库才能使用它。另一方面,用于浏览器的第二个文件具有以下输出:

output: {
  path: './dist',
  filename: outputFile + '.browser.js',
  library: globalName,
  libraryTarget: 'umd',
},

UMD 将在全局范围内暴露自己,无需导入任何内容,因此非常适合浏览器,因为我们可以在 Vue 网页中包含该文件并自由使用组件。这也是可能的,多亏了index.js的自动安装功能:

/* -- Plugin definition & Auto-install -- */
/* You shouldn't have to modify the code below */
// Plugin
const plugin = {
 /* eslint-disable no-undef */
 version: VERSION,
 install,
}
export default plugin
// Auto-install
let GlobalVue = null
if (typeof window !== 'undefined') {
 GlobalVue = window.Vue
} else if (typeof global !== 'undefined') {
 GlobalVue = global.Vue
}
if (GlobalVue) {
 GlobalVue.use(plugin)
}

这段代码的作用是将安装函数(用于在 Vue 中注册组件)打包到plugin常量中,并同时导出。然后,它会检查是否定义了windowglobal,如果是这样,它会获取代表 Vue 库的Vue变量,并使用插件 API 来安装组件。

第十七章:高级 Vue.js - 指令、插件和渲染函数

在本章中,我们将讨论以下主题:

  • 创建一个新指令

  • 在 Vue 中使用 WebSockets

  • 为 Vue 编写一个插件

  • 手动渲染一个简单的组件

  • 渲染带有子元素的组件

  • 使用 JSX 来渲染一个组件

  • 创建一个功能性组件

  • 使用高阶组件构建响应式表格

介绍

指令和插件是以可重用的方式打包功能,并且使其在应用程序和团队之间易于共享的方式;在本章中,您将构建其中的一些。渲染函数是 Vue 在幕后真正工作的方式,它将模板转换为 Vue 语言,然后再次转换为 HTML 和 JavaScript;如果您需要优化应用程序的性能并处理一些边缘情况,它们将变得有用。

一般来说,尽量避免使用这些高级功能,因为它们在过去有点被滥用。通常,许多问题可以通过简单地编写一个良好的组件并分发组件本身来解决;只有在这种情况不成立时,才应该考虑使用高级功能。

本章面向略有经验的人,您可能不会在其他教程中找到的逐步详细级别,但我仍然努力使它们完整。

创建一个新指令

指令就像迷你函数,您可以快速地将其放入您的代码中,主要是为了改善用户体验,并向图形界面添加新的低级功能。

准备工作

尽管这个教程在高级章节中,但实际上非常容易完成。指令被认为是高级的主要原因是,通常情况下,您应该更喜欢组合来添加功能和样式到您的应用程序中。当组件无法满足需求时,使用指令。

如何做...

我们将构建一个v-pony指令,它将把任何元素转换为一个小马元素。小马元素创建时具有粉色背景,并在点击时更改颜色。

小马元素的 HTML 代码如下:

<div id="app">
  <p v-pony>I'm a pony paragraph!</p>
  <code v-pony>Pony code</code>
  <blockquote>Normal quote</blockquote>
  <blockquote v-pony>I'm a pony quote</blockquote>
</div>

为了展示区别,我包含了一个普通的blockquote元素。在我们的 JavaScript 部分,写入以下内容:

Vue.directive('pony', {
  bind (el) {
    el.style.backgroundColor = 'hotpink'
  }
})

这是如何声明一个新指令的方式。bind钩子在指令绑定到元素时被调用。我们现在唯一要做的事情就是设置背景颜色。我们还希望它在每次点击后改变颜色。要做到这一点,您必须添加以下代码:

Vue.directive('pony', {
  bind (el) {
    el.style.backgroundColor = 'hotpink'
    el.onclick = () => {
 const colGen = () => 
 Math.round(Math.random()*255 + 25)
 const cols =
 [colGen() + 100, colGen(), colGen()]
 const randRGB =
 `rgb(${cols[0]}, ${cols[1]}, ${cols[2]})`
 el.style.backgroundColor = randRGB
 }
  }
})

在这里,我们创建了一个onclick监听器,它将生成一个偏向红色的随机颜色,并将其分配为新的背景颜色。

在我们的 JavaScript 结尾处,记得创建一个Vue实例:

new Vue({
  el: '#app'
})

您可以启动应用程序以查看指令的运行情况:

不要忘记点击文本以更改背景颜色!

工作原理...

声明新指令的语法如下所示:

Vue.directive(<name: String>, {
  // hooks
})

这将注册一个新的全局指令。在 hooks 对象内,您可以定义两个重要的函数:bind,您在本教程中使用了它,以及update,每当包含在其中的组件更新时触发。

每个 hook 函数至少带有三个参数:

  • el: HTML 元素

  • binding:指令可以接收一个参数;binding 是一个包含参数值的对象

  • vnode:这个元素的 Vue 内部表示

我们使用el参数直接编辑元素的外观。

在 Vue 中使用 WebSockets

WebSockets 是一种新技术,它使用户和托管应用程序的服务器之间实现双向通信。在这项技术出现之前,只有浏览器可以发起请求和建立连接。如果页面上有更新,浏览器必须不断地轮询服务器。有了 WebSockets,这就不再需要了;连接建立后,服务器只有在需要时才能发送更新。

准备工作

对于这个教程,您不需要做任何准备,只需要了解 Vue 的基础知识。如果您不知道什么是 WebSockets,您也不需要知道,只需将它们视为服务器和浏览器之间持续双向通信的通道。

如何做...

对于这个教程,我们需要一个服务器和一个将充当客户端的浏览器。我们不会构建服务器;相反,我们将使用一个已经存在的服务器,它只会通过 WebSockets 回显您发送的任何内容。因此,如果我们发送Hello消息,服务器将回复Hello

您将构建一个聊天应用程序,它将与此服务器通信。编写以下 HTML 代码:

<div id="app">
  <h1>Welcome</h1>
  <pre>{{chat}}</pre>
  <input v-model="message" @keyup.enter="send">
</div>

<pre>标签将帮助我们呈现聊天。由于我们不需要<br/>元素来换行,我们可以使用n特殊字符,表示换行。

为了使我们的聊天工作,我们首先必须在 JavaScript 中声明我们的 WebSocket:

 const ws = new WebSocket('ws://echo.websocket.org')

之后,我们声明将包含chat字符串(目前为止包含聊天内容)和message字符串(包含我们当前正在编写的消息)的Vue实例:

new Vue({
  el: '#app',
  data: {
    chat: '',
    message: ''
  }
})

我们仍然需要定义send方法,在文本框中按下Enter时调用:

new Vue({
  el: '#app',
  data: {
    chat: '',
    message: ''
  },
  methods: {
 send () {
 this.appendToChat(this.message)
 ws.send(this.message)
 this.message = ''
 },
 appendToChat (text) {
 this.chat += text + 'n'
 }
 }
}

我们将appendToChat方法分解出来,因为我们将用它来附加我们将收到的所有消息。为此,我们必须等待组件被实例化。created挂钩是一个安全的地方:

...
created () {
  ws.onmessage = event => {
    this.appendToChat(event.data)
  }
}
...

现在启动应用程序,与您的个人回声室聊天:

它是如何工作的...

要查看您构建的内容的内部,请打开 Chrome 开发者工具( | 更多工具 | 开发者工具或Opt + Cmd + I):

转到网络选项卡并重新加载页面;您应该会看到echo.websocket.orl WebSocket,就像屏幕截图中所示。写点东西,消息将出现在帧选项卡中,就像这样:

绿色消息是您发送的,而白色消息是您收到的。您还可以检查消息长度(以字节为单位)和发送或接收的确切时间。

为 Vue 编写插件

插件是我们想要在应用程序中使用的一组实用程序或全局新行为。Vuex 和 vue-router 是 Vue 插件的两个著名示例。插件实际上可以是任何东西,因为编写插件意味着在非常低的级别上进行操作。您可以编写不同类型的插件。对于这个示例,我们将集中于构建具有全局属性的指令。

准备工作

这个示例将基于创建一个新的指令,只是我们将为全局协调添加一些功能。

如何做...

对于这个示例,我们将为袋鼠欣赏俱乐部构建一个网站。主页 HTML 的布局如下:

<div id="app">
  <h1>Welcome to the Kangaroo club</h1>
  <img src="https://goo.gl/FVDU1I" width="300px" height="200px">
  <img src="https://goo.gl/U1Hvez" width="300px" height="200px">
  <img src="https://goo.gl/YxggEB" width="300px" height="200px">
  <p>We love kangaroos</p>
</div>

你可以将袋鼠图片的链接更改为你喜欢的链接。

在 JavaScript 部分,我们目前实例化了一个空的Vue实例:

new Vue({
  el: '#app'
})

如果我们现在打开页面,会得到这个:

现在我们想在我们的网站上添加一个有趣的注释。我们希望页面的元素(除标题外)在随机时间间隔跳动。

为了做到这一点,您将实现的策略是在一个数组中注册所有需要跳跃的元素,然后定期选择一个随机元素并使其跳跃。

我们需要定义的第一件事是 CSS 中的跳跃动画:

@keyframes generateJump {
  20%{transform: translateY(0);}
  40%{transform: translateY(-30px);}
  50%{transform: translateY(0);}
  60%{transform: translateY(-15px);}
  80%{transform: translateY(0);}
}
.kangaroo {
  animation: generateJump 1.5s ease 0s 2 normal;
}

它的作用是创建一个名为kangaroo的类,当应用于一个元素时,使其沿 y 轴跳动两次。

接下来,在 JavaScript 中编写一个将此类添加到指定元素的函数:

const jump = el => {
  el.classList.add('kangaroo')
  el.addEventListener('animationend', () => {
    el.classList.remove('kangaroo')
  })
}

jump函数添加kangaroo类,然后在动画完成时将其移除。

我们希望对从注册的元素中随机选择的一个元素执行此操作:

const doOnRandomElement = (action, collection) => {
  if (collection.length === 0) {
    return
  }
  const el = 
    collection[Math.floor(Math.random()*collection.length)]
  action(el)
}

doOnRandomElement函数接受一个动作和一个集合,并将该动作应用于一个绘制的元素。然后我们需要在随机间隔内安排它:

const atRandomIntervals = action => {
  setTimeout(() => {
    action()
    atRandomIntervals(action)
  }, Math.round(Math.random() * 6000))
}

atRandomIntervals函数接受指定的函数,并在小于 6 秒的随机间隔内调用它。

我们现在拥有了实际构建一个使我们的元素跳跃的插件所需的所有功能:

const Kangaroo = {
  install (vueInstance) {
    vueInstance.kangaroos = []
    vueInstance.directive('kangaroo', {
      bind (el) {
       vueInstance.kangaroos.push(el)
      }
    })
    atRandomIntervals(() => 
      doOnRandomElement(jump, vueInstance.kangaroos))
  }
}

Kangaroo 插件在安装时创建一个空数组;它声明了一个新的指令kangaroo,它将在数组中注册所有的元素。

然后在随机间隔内,从数组中随机选择一个元素,并对其调用跳跃函数。

激活插件之前,我们需要在声明Vue实例之前(但在声明Kangaroo之后)加一行:

Vue.use(Kangaroo)
new Vue({
  el: '#app'
})

我们必须选择跳跃的元素,也就是除了标题之外的所有东西:

 <div id="app">
   <h1>Welcome to the Kangaroo club</h1>
   <img v-kangaroo src="https://goo.gl/FVDU1I" width="300px" height="200px">
   <img v-kangaroo src="https://goo.gl/U1Hvez" width="300px" height="200px">
   <img v-kangaroo src="https://goo.gl/YxggEB" width="300px" height="200px">
   <p v-kangaroo>We love kangaroos</p>
 </div>

如果现在运行您的应用程序,您会看到一张图片或文本每隔几秒钟像袋鼠一样跳跃。

它是如何工作的...

在本质上,Vue 插件只是一种将一些功能组合在一起的方法。没有太多的限制,创建插件的全部工作就是声明一个安装函数。要做到这一点的一般语法如下:

MyPlugin.install = (vueInstance, option) => {
  // ...
}

要使用您刚刚制作的插件,请编写以下内容:

Vue.use(MyPlugin, { /* any option you need */ }) 

这里,第二个参数是传递给install函数的可选对象。

由于插件是全局实体,您应该节俭使用它们,只用于您预见会影响整个应用程序的功能。

手动渲染一个简单的组件

Vue 将您的 HTML 模板转换为渲染函数。通常,您应该坚持使用模板,因为它们更简单。有一些情况下,渲染函数变得很有用。在这里,我们展示了一个简单的例子,其中渲染函数很有用。

准备就绪

这是关于渲染函数的第一个教程。如果您已经了解了 Vue 的基础知识,您将会理解一切。

如何做...

渲染函数的第一个用例是每当您只想要一个显示另一个组件的Vue实例时。

编写一个空的 HTML 布局,如下所示:

 <div id="app"></div>

我们有一个名为 Greeter 的组件,我们希望将其显示为主Vue实例。在 JavaScript 部分,添加以下代码:

const Greeter = {
  template: '<p>Hello World</p>'
}

在这里,我们必须想象我们从其他地方获取了Greeter组件,并且由于组件已经很好地打包,我们不想修改它。相反,我们将它传递给Vue主实例:

const Greeter = {
  template: '<p>Hello World</p>'
}
new Vue({
 el: '#app',
 render: h => h(Greeter)
})

如果我们现在启动应用程序,我们只会看到Greeter组件。主Vue实例只会充当包装器。

它是如何工作的...

渲染函数替换了Vue实例中的模板。当调用渲染函数时,传递的参数是所谓的createElement函数。我们为了简洁起见将其命名为h。这个函数接受三个参数,但现在只需要注意我们传递的第一个参数(我们唯一传递的参数)是Greeter组件。

理论上,您可以在h函数内联编写组件。在实际项目中,这并不总是可能的,这取决于运行时是否存在 Vue 模板编译器。当您使用官方的 Webpack 模板时,您会被问到是否要在分发软件时包含 Vue 模板编译器。

createElement函数的参数在这里列出:

  1. 作为第一个参数,唯一必需的参数,您可以选择传递三种不同的东西:
  • Vue 组件的选项,就像我们的教程中

  • 表示 HTML 标签的字符串(例如divh1p

  • 一个为 Vue 组件返回选项对象的函数或表示 HTML 标签的字符串

  1. 第二个参数必须是一个名为Data Object的对象。这个对象在下一个教程中会解释。

  2. 第三个参数是一个数组或一个字符串:

  • 数组表示要放在组件内的元素、文本或组件的列表

  • 您可以编写一个字符串,它将被渲染为文本

渲染具有子元素的组件

在这个教程中,您将完全使用渲染函数构建一个简单的网页,其中包含一些元素和组件。这将让您近距离了解 Vue 如何编译您的模板和组件。如果您想要构建一个高级组件,并且希望有一个完整的示例来启动,这可能会很有用。

准备工作

这是一个完整的关于如何通过渲染函数构建组件的示例。通常情况下,您不需要在实践中这样做;这仅建议给高级读者。

如何做...

你将为一个水管工俱乐部建立一个页面。页面将如下所示:

每当我们在名称文本框中写入一个名称,它将会被写入问候语中,就像v-model指令一样。

对于这个示例,我们从末尾开始而不是从开头开始,因为通常当你不得不求助于render函数时,你对你想要得到的东西有一个很清楚的想法。

在我们应用程序的 HTML 部分中,让我们从一个空标签开始:

<div id="app"></div>

在 JavaScript 中,在render函数中写入一个空的<div>元素:

new Vue({
  el: '#app',
  render: h => h('div')
})

我们将放入的第一件事是标题,就像这样:

new Vue({
  el: '#app',
  render: h => h(
    'div',
    [
 h('h1', 'The plumber club page')
 ]
  )
})

所有其他元素和组件都将适应我们刚刚为标题创建的数组。

我们需要一个<input>元素,它将获取值并显示问候语。为此,我们可以构建一个Vue组件。

在下面的代码中,我们使用了一个常规的 JavaScript 函数,而不是箭头函数;这是因为我们想要一个对组件本身的引用。箭头函数不允许您修改this的作用域,而this取决于函数的调用方式,并且可以选择地绑定到常规函数中的任何变量。在我们的情况下,它将绑定到实例组件。

在页面的标题之后,我们在同一个数组中添加以下组件:

h(
  {
    render: function (h) {
      const self = this
      return h('div', [
        'Your name is ',
        h('input', {
          domProps: {
            value: self.name
          },
          on: {
            input (event) {
              self.name = event.target.value
            }
          }
        }),
        h(
          'p',
          'Hello ' + self.name + 
            (self.exclamation ? '!' : ''))
      ])
    },
    data () { return { name: '' } },
    props: ['exclamation']
  },
  {
    props: {
      exclamation: true
    }
  }
)

该组件有三个选项:renderdataprops函数。

createElement函数的第二个参数是实际为我们的 props 分配值:

{
  props: {
    exclamation: true
  }
}

这将相当于在声明组件时写入:exclamation="true"

您可以轻松理解组件的dataprops选项。让我们来看看我们在render函数中写了什么。

在函数的第一行,我们将self = this设置为一个方便的方式来引用组件,如果我们要添加任何嵌套函数。然后,我们返回createElement函数(h)的结果,它在一个 div 标签内将三个东西放在 DOM 中。第一个是原始文本Your name is,然后是两个元素:一个输入和一个段落。

在使用render函数时,我们没有v-model指令的直接等价物。相反,我们手动实现它。我们将值绑定到名称,然后添加一个监听器来监听输入事件,该事件将把状态变量name的值设置为文本框中的内容。

然后,我们插入一个段落元素,用于组成问候语,根据exclamation属性的值添加感叹号。

在组件之后,我们可以添加如下内容,如同在同一个数组中所示:

 'Here you will find ', h('i', 'a flood '), 'of plumbers.'

如果你做得对,你应该能够运行应用程序并看到整个页面。

工作原理...

在这个例子中,我们已经看到了 Vue 编译我们模板时发生的事情的一瞥;同样,不建议在常规组件中这样做。大多数情况下,结果只会更冗长,几乎没有收益。另一方面,在一些情况下,编写render函数实际上可能会导致更好或更健壮的代码,并涵盖一些难以用模板表达的功能。

使用 JSX 渲染组件

JSX 在 React 社区中非常受欢迎。在 Vue 中,你不必使用 JSX 来为你的组件构建模板;你可以使用更熟悉的 HTML。然而,如果你被迫编写大量的render函数,JSX 是你可以做的下一个最好的事情。

准备工作

在尝试本教程之前,最好先玩一下render函数。之前的教程提供了一些练习。

如何做...

JSX 需要一个 Babel 插件才能工作。在本教程中,我将假设你是在 webpack 模板中工作。

要安装 babel 插件,你可以运行以下命令:

npm install
 babel-plugin-syntax-jsx
 babel-plugin-transform-vue-jsx
 babel-helper-vue-jsx-merge-props
 --save-dev

.babelrc文件中,在plugins数组中添加以下内容:

"plugins": [
 ...
  "transform-vue-jsx" ]

像往常一样运行npm install来安装所有依赖项。

现在,打开main.js并删除其中的所有内容。用以下代码替换它:

import Vue from 'vue'

/* eslint-disable no-new */
new Vue({
  el: '#app',
  render (h) {
    return <div>{this.msg}</div>
  },
  data: {
    msg: 'Hello World'
  }
})

如果你从未见过 JSX,那么高亮显示的那一行可能有点奇怪。请注意,在前面的代码中,我们没有在render选项中使用箭头函数。这是因为我们在内部使用了this,并且我们希望它绑定到组件上。

你已经可以使用npm run dev命令看到你的页面在工作了。

工作原理...

Babel 插件将把 JSX 代码转换成 JavaScript 的render函数。

我不建议在 Vue 中使用 JSX。我能想到它有用的唯一时机是当您需要将render函数与 JavaScript 混合使用,并且需要一种快速且可读的方式来定义模板。除此之外,使用 JSX 并没有太多优势。

还有更多...

让我们稍微复杂化代码,至少让它具有 JSX 与 props 交互的味道。

在主Vue实例之前定义一个新的组件:

const myComp = {
  render (h) {
    return <p>{this.myProp}</p>
  },
  props: ['myProp']
}

让我们在我们的Vue实例中使用这个组件,并通过 props 传递msg变量:

new Vue({
  el: '#app',
  render (h) {
    return <div>
      <myComp myProp={this.msg}/>
    </div>
  },
  data: {
    msg: 'Hello World'
  },
  components: {
    myComp
  }
})

语法与 HTML 模板略有不同。特别要注意如何传递 props 以及如何使用驼峰命名和自闭合标签。

创建一个功能性组件

一个更轻量级的组件版本是功能性组件。功能性组件没有实例变量(所以没有this)并且没有状态。在这个示例中,我们将编写一个简单的功能性组件,通过 HTML 接收一些指令并将其转换为绘图。

准备工作

在尝试这个示例之前,您至少应该熟悉 Vue 中的渲染函数。您可以使用之前的示例来做到这一点。

如何做...

当您编写一个<svg>元素时,通常需要将数据放入其中的元素的属性中以实际绘制形状。例如,如果您想要绘制一个三角形,您必须编写以下内容:

<svg>
  <path d="M 100 30 L 200 30 L 150 120 z"/>
</svg>

d属性中的文本是一系列指令,用于使虚拟光标移动以绘制:M将光标移动到<svg>内的(100, 30)坐标,然后L绘制一条线直到(200, 30),然后再次到(150, 120)坐标。最后,z关闭我们正在绘制的路径,结果总是一个三角形。

我们想用一个组件来绘制一个三角形,但我们不喜欢属性,我们想用自己的语言来编写,所以我们将编写以下内容以获得相同的结果:

<orange-line>
  moveTo 100 30 traceLine 200 30 traceLine 150 120 closePath
</orange-line>

这是功能性组件的完美工作,因为没有状态需要管理,只需要将一个组件转换为一个元素。

您的 HTML 布局将简单地如下所示:

<div id="app">
  <orange-line>
    moveTo 100 30 traceLine 200 30 traceLine 150 120 closePath
  </orange-line>
</div>

然后,在您的 JavaScript 中布置您的功能性组件:

const OrangeLine = {
  functional: true,
  render (h, context) {
    return h('svg',
      []
    )
  }
}

您必须使用functional: true来指定组件将是功能性的;然后渲染函数与通常略有不同。第一个参数仍然是createElement函数,但第二个传递的是我们组件的上下文。

我们可以通过context.children访问组件内部 HTML 中写的文本(绘制命令)。

你可以看到我已经添加了一个空的<svg>元素。在其中,有一个空的子节点数组;我们将只在那里放置<path>元素,如下所示:

render (h, context) {
  return h('svg',
    [
      h('path', {
 attrs: {
 d: context.children.map(c => {
 return c.text
 .replace(/moveTo/g, 'M')
 .replace(/traceLine/g, 'L')
 .replace(/closePath/g, 'z')
 }).join(' ').trim(),
 fill: 'black',
 stroke: 'orange',
 'stroke-width': '4'
 }
 })
    ]
  )
}

高亮显示的代码创建了一个路径元素,然后设置了一些属性,比如fillstroked属性获取了组件内部的文本,进行了一些替换,然后返回它。

我们只需要在 JavaScript 中创建Vue实例:

new Vue({
  el: '#app',
  components: {
    OrangeLine
  }
})

现在,加载应用程序,我们应该看到一个三角形,如下截图所示:

它是如何工作的...

Vue 允许您创建非常轻量级的组件,因为它们没有任何内部状态。随之而来的是一些限制,例如,我们可以将一些逻辑放在渲染函数中来处理用户输入(以元素的子节点或 props 的形式)。

我们传递的上下文包含以下属性:

  • props: 这是由用户传递的。

  • children: 这实际上是一个虚拟节点数组,在模板中是我们组件的子节点。这里我们没有实际的 HTML 元素,而只是 Vue 的表示。

  • slots: 这是一个返回插槽的函数(在某些情况下可以代替 children 使用)。

  • data: 这是传递给组件的整个数据对象。

  • parent: 这是对父组件的引用。

在我们的代码中,我们通过以下方式提取了组件内部的文本:

context.children.map(c => {
  return c.text
    .replace(/moveTo/g, 'M')
    .replace(/traceLine/g, 'L')
    .replace(/closePath/g, 'z')
}).join(' ').trim()

我们正在取出 children 中包含的虚拟节点数组,并将每个节点映射到其文本。由于我们在 HTML 中只放了一些文本,节点数组将是一个单例,只有一个节点:我们输入的文本。因此,在这种特殊情况下,执行var a = children.map(c => someFunction(c))等同于执行var a = [someFunction(children[0])]

我们不仅提取了文本,还替换了一些我发明的术语来描述svg命令,用真实的命令。join函数将把数组中的所有字符串(在我们的情况下只有一个)拼接在一起,trim将删除所有空格和换行符。

使用高阶组件构建响应式表格

功能组件在我们必须决定实际包装哪个组件时是非常好的包装器。在这个示例中,您将编写一个响应式表格,根据浏览器宽度显示不同的列。

准备工作

这个示例是关于功能组件的。如果你想热身,可以尝试并完成上一个示例。

如何做...

对于这个示例,我们将使用优秀的语义化 UI CSS 框架。要使用它,您必须将 CSS 库作为依赖项或<link>标签包含在内。例如,您可以将以下代码放在 HTML 的<head>中:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.7/semantic.css" />

如果您正在使用 JSFiddle,内部的链接就足够了。

您必须添加另一个标签到您的页面上,以便在移动设备上看起来更好:

<meta name="viewport" content="width=device-width">

这告诉移动浏览器页面的宽度等于设备的宽度。如果您不这样做,移动设备可能会认为页面比手机大得多,并试图显示所有内容,显示您应用的缩小版本。

我们将设计一张猫品种的表格。您可以在 Vue 实例状态中看到所有数据。在您的 JavaScript 中编写它:

new Vue({
  el: '#app',
  data: {
    width: document.body.clientWidth,
  breeds: [
    { name: 'Persian', colour: 'orange', affection: 3, shedding: 5 },
    { name: 'Siberian', colour: 'blue', affection: 5, shedding: 4 },
    { name: 'Bombay', colour: 'black', affection: 4, shedding: 2 }
  ]
  },
  created() {
    window.onresize = event => {
      this.width = document.body.clientWidth
    }
  },
  components: {
    BreedTable
  }
})

我们声明width变量来改变页面的布局,由于页面的宽度本质上不是响应式的,我们还在window.onresize上安装了一个监听器。对于一个真正的项目,您可能需要更复杂一些的东西,但对于这个示例,这就足够了。

还要注意我们如何使用BreedTable组件,我们的写法是这样的:

const BreedTable = {
  functional: true,
  render(h, context) {
    if (context.parent.width > 400) {
      return h(DesktopTable, context.data, context.children)
    } else {
      return h(MobileTable, context.data, context.children)
    }
  }
}

我们的组件所做的就是将所有的context.datacontext.children传递给另一个组件,这将是DesktopTableMobileTable,取决于分辨率。

我们的 HTML 布局如下:

<div id="app">
  <h1>Breeds</h1>
  <breed-table :breeds="breeds"></breed-table>
</div>

breeds属性将传递给context.data中选定的组件。

我们的桌面表格看起来会很普通:

const DesktopTable = {
  template: `
    <table class="ui celled table unstackable">
      <thead>
        <tr>
          <th>Breed</th>
          <th>Coat Colour</th>
          <th>Level of Affection</th>
          <th>Level of Shedding</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="breed in breeds">
          <td>{{breed.name}}</td>
          <td>{{breed.colour}}</td>
          <td>{{breed.affection}}</td>
          <td>{{breed.shedding}}</td>
        </tr>
      </tbody>
    </table>
  `,
  props: ['breeds']
}

顶部的类是语义 UI 的一部分,它们将使我们的表格看起来更好。特别是unstackable类,它禁用了 CSS 执行的自动堆叠。我们将在下一节中更多地介绍这个。

对于移动表格,我们不仅想编辑样式,还想对列进行分组。品种将与颜色一起显示,情感与脱毛一起显示。此外,我们希望以紧凑的样式表达它们。表头将如下所示:

const MobileTable = {
  template: `
    <table class="ui celled table unstackable">
      <thead>
       <tr>
         <th>Breed</th>
         <th>Affection & Shedding</th>
       </tr>
     </thead>
   ...

我们不仅仅是拼写外套颜色,我们还画了一个小圆圈表示颜色:

...
<tbody>
  <tr v-for="breed in breeds">
    <td>{{breed.name}}
      <div 
        class="ui mini circular image"
        :style="'height:35px;background-color:'+breed.colour"
      ></div>
    </td>
  ...

此外,与桌面表格中使用数字不同,我们为喜爱程度和脱毛程度使用了心形和星级评分:

      ...
      <td>
        <div class="ui heart rating">
          <i 
            v-for="n in 5"
            class="icon"
            :class="{ active: n <= breed.affection }"
          ></i>
        </div>
        <div class="ui star rating">
          <i 
            v-for="n in 5"
            class="icon"
            :class="{ active: n <= breed.shedding }"
          ></i>
        </div>
      </td>
    </tr>
  </tbody>
</table>

此外,不要忘记像DesktopTable组件中那样声明breeds属性。

现在在浏览器中启动你的应用程序。当压缩到足够小的时候,你可以看到表格是如何将列分组的:

以下截图显示数字被心形和星级评分替换:

它是如何工作的...

响应式页面会根据浏览器的宽度更改其布局,当用户使用平板电脑或智能手机浏览网站时,这一点非常重要。

大多数组件只需开发一次,就可以为响应式页面开发,只需根据不同的尺寸多次进行样式设置。与为移动设备优化的单独网站相比,这可以节省大量开发时间。

通常,在响应式页面表格中,会从列式布局变为堆叠布局,如下图所示:

我从来不喜欢这种方法,但客观的缺点是,如果你让你的表在一侧看起来很好,它在另一侧看起来就不那么好。这是因为你必须以相同的方式设置单元格,而响应式的作用是将它们堆叠起来。

我们的BreedTable组件所做的是在两个组件之间动态切换,而不仅仅依赖于 CSS。由于它是一个功能性组件,与完整的组件相比,它具有非常轻量级的优势。

在真实应用中,使用onresize事件是值得商榷的,主要是因为性能问题。在生产系统中,通过 JavaScript 实现响应式的解决方案需要更加结构化。例如,考虑使用定时器或使用matchMedia

最后一点,注意 Vue 实例从未注册这两个子组件;这是因为它们从未出现在模板中,而是直接在代码中作为对象引用。

第十八章:使用 Vuex 的大型应用程序模式

在本章中,我们将涵盖以下配方:

  • 在 vue-router 中动态加载页面

  • 为应用程序状态构建一个简单的存储

  • 理解 Vuex 的 mutations

  • 在 Vuex 中列出你的操作

  • 使用模块分离关注点

  • 构建 getter 来帮助检索数据

  • 测试你的存储

介绍

在本章中,你将学习 Vuex 的工作原理以及如何使用它来支持可扩展的应用程序。Vuex 实现了一种在前端框架中流行的模式,它包括将不同的关注点分开来管理一个大型全局应用程序状态。mutations 是唯一可以改变状态的东西,所以你只需要在一个地方查找它。大部分逻辑,以及所有的异步逻辑,都包含在 actions 中;最后,getters 和 modules 进一步帮助分散认知负荷,当涉及计算派生状态和将代码拆分成不同的文件时。

除了配方,你还会发现我在开发真正大型应用程序时发现有用的智慧之言;有些与命名约定有关,有些与避免错误的小技巧有关。

如果你完成了所有的配方,你将准备好开发大型前端应用程序,减少错误并实现无缝协作。

在 vue-router 中动态加载页面

很快,你将建立大量组件的大型 Vue 网站。加载大量 JavaScript 可能会产生浪费和无用的前期延迟。

准备工作

这个配方需要了解 vue-router。

如何做...

通过创建新目录并运行以下命令来使用vue-cli创建一个新项目:

vue init webpack

你可以根据自己的喜好回答问题,只要在要求时将vue-router添加到模板中即可。

我们将创建两个组件:一个将是我们的主页,它将是小而轻的,另一个组件将非常大且加载速度很慢。我们想要实现的是立即加载主页,而不必等待浏览器下载巨大的组件。

打开components文件夹中的Hello.vue文件。删除所有内容,只留下以下内容:

<template>
  <div>
    Lightweight hello
  </div>
</template>

在同一个文件夹中,创建另一个名为Massive.vue的文件,并在其中写入以下内容:

<template>
  <div>
   Massive hello
  </div>
</template>

<script>
/* eslint-disable no-unused-vars */
const a = `

在最后一行留一个开放的反引号,因为我们必须用大量无用的数据膨胀文件。保存并关闭Massive.vue

在控制台中,转到存储文件的相同目录,并使用以下文件将大量垃圾放入其中:

yes "XXX" | head -n $((10**6)) >> Massive.vue

这个命令的作用是将XXX行重复附加到文件中 10⁶次;这将向文件添加 400 万字节,使其对于快速浏览体验来说太大了。

现在我们需要关闭我们打开的反引号。不要尝试现在打开文件,因为你的文本编辑器可能无法打开这样一个庞大的文件;相反,使用以下命令:

echo '`</script>' >> Massive.vue

我们的Massive组件现在已经完成。

打开router文件夹中的index.js并添加组件及其路由:

import Massive from '@/components/Massive'
...
export default new Router({
  routes: [
    {
      path: '/',
      name: 'Hello',
      component: Hello
    },
 {
 path: '/massive',
 name: 'Massive',
 component: Massive
 }
  ]
})

使用npm install安装所有依赖项后,我们现在可以使用npm run dev命令启动我们非常庞大的应用程序了。

该应用程序将加载得非常快,但这是因为它直接从您的本地存储加载;为了模拟更真实的情况,打开开发者工具中的网络选项卡,并选择网络限制。选择一些慢的东西,比如 GPRS 或者好的 3G,这是我们大多数人可能拥有的:

现在右键单击刷新按钮,选择硬刷新以绕过缓存(或按Shift + Cmd + R):

你会注意到页面加载需要几分钟的时间。当它变成 X 时,你可以通过再次单击刷新按钮来停止页面的加载。

为了解决这个问题,回到router文件夹中的index.js文件。删除以下行,其中你导入Massive组件:

import Massive from '@/components/Massive'

前一行告诉 Webpack 将Massive组件中包含的所有代码都包含在一个单独的 js 捆绑包中。相反,我们希望告诉 Webpack 将Massive组件保持为一个单独的捆绑包,并且只在必要时加载它。

不要直接导入组件,使用以下代码声明Massive

const Massive = resolve =>
 require(['../components/Massive.vue'], resolve)

Webpack 将把这种特殊语法转换为一个单独的文件,它将被懒加载。保存并在仍然设置为慢速的限制下进行另一次硬刷新(比如 GPRS 到好的 3G)。几秒钟后,你应该能够看到 hello 页面。如果你想加载Massive组件,只需将massive添加到 URL 中,但你将需要等待一段时间。

它是如何工作的...

显然,在真实的应用程序中,你不会有这样一个庞大的组件,但你可以很容易地看到,如果Massive组件代表了你的应用程序的所有其他组件,它们很快就会累积成这样一个庞大的大小。

这里的诀窍是异步加载它们;Webpack 将帮助您将它们分成更小的包,这样它们只会在需要时加载。

还有更多...

有一种替代语法可以懒惰地导入组件。这可能会成为未来的 ECMA 标准,所以你应该意识到这一点。打开router目录内的index.js文件,并完全删除Massive组件的导入,或者我们在这个示例中添加的Massive常量行。

在路由内,当指定/massive路由的组件时,尝试以下操作:

routes: [ {  path:  '/',
  name:  'Hello',
  component:  Hello
 }, {     path:  '/massive',
  name:  'Massive',
 component: import('@/components/Massive') } ] 

这将等同于我们之前所做的,因为 Webpack 将获取这行,并且不会直接导入 Massive 组件的代码,而是创建一个不同的 js 文件,懒加载加载。

为应用程序状态构建一个简单的存储

在这个示例中,您将了解在构建大型应用程序时 Vuex 的基本原理。这个示例有点不正统,因为为了理解 Vuex 存储的工作原理,我们将直接操作它;在真实应用程序中,你永远不应该这样做。

准备工作

在尝试这个示例之前,您应该知道如何让组件与 Vuex 通信。

如何做...

在一个新的目录中运行以下命令,基于 Webpack 模板创建一个新项目:

vue init webpack

你如何回答这个问题并不重要。运行npm intall并使用npm install vuex --save或者如果你使用 yarn,使用yarn add vuex来安装 Vuex。

打开src文件夹内的main.js文件,并添加以下突出显示的行以完成安装 Vuex:

import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'

/* eslint-disable no-new */
new Vue({
 el: '#app',
 router,
 store,
 template: '<App/>',
 components: { App }
})

当然,现在没有store模块,所以你需要创建一个。为此,在src文件夹下创建一个名为store的文件夹。在其中,创建一个名为index.js的文件。在main.js文件中,我们没有指定使用index.js文件,但当没有指定文件而只有文件夹时,这是默认行为。

我们将实现的是一个简化的股票市场。我们有三种资产:星星(STAR)、灯(LAMP)和钻石(DIAM)。我们将定义两条路线:一条用于 STAR/LAMP 市场,另一条用于 LAMP/DIAM 市场。

在存储文件夹中的index.js文件中,写入以下内容:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const store = new Vuex.Store({
  state: {
    STAR: 100,
    LAMP: 100,
    DIAM: 100,
    rate: {
      STAR: {
        LAMP: 2
      },
      LAMP: {
        DIAM: 0.5
      }
    }
  }
})
export default store

我们正在创建一个新的Vuex存储,用于保存我们的余额。最初,我们每种资产有 100 个;在存储中,星星和灯之间的汇率以及灯和钻石之间的汇率也是固定的。

components目录下创建一个名为Market.vue的新组件。它将具有以下模板:

<template>
  <div class="market">
    <h2>{{symbol1}}/{{symbol2}} Stock Exchange</h2>
    <div class="buy-sell">
      <input v-model.number="amount">{{symbol1}}
      <button @click="buy">
        Buy for {{rate*amount}} {{symbol2}}
      </button>
      <button @click="sell">
        Sell for {{rate*amount}} {{symbol2}}
      </button>
    </div>
  </div>
</template>

symbol1symbol2代表两种交易的资产。在这个组件的 JavaScript 中,我们定义了sellbuy方法,直接在全局Vuex存储上操作:

<script>
export default {
  name: 'market',
  data () {
    return {
      amount: 0
    }
  },
  computed: {
    rate () {
      return this.$store.state.rate[this.symbol1][this.symbol2]
    }
  },
  props: ['symbol1', 'symbol2'],
  methods: {
    buy () {
      this.$store.state[this.symbol1] += this.amount
      this.$store.state[this.symbol2] -= this.amount * this.rate
    },
    sell () {
      this.$store.state[this.symbol1] -= this.amount
      this.$store.state[this.symbol2] += this.amount * this.rate
    }
  }
}
</script>

您不应该像我在这里所做的那样直接触摸状态。您应该始终使用 mutations。在这里,我们跳过中间人以保持食谱的最小化。关于 mutations 的更多内容将在下一个食谱中介绍。

您必须在router文件夹中的index.js中以以下方式使用此组件:

import Vue from 'vue'
import Router from 'vue-router'
import Market from '@/components/Market'
Vue.use(Router)
export default new Router({
  routes: [
    {
      path: '/',
      redirect: '/STAR/LAMP'
    },
    {
      path: '/:symbol1/:symbol2',
      component: Market,
      props: true
    }
  ]
})

在上述代码中,我们对包含一对交易符号的任何路由使用Market组件。作为主页,我们使用 STAR/LAMP 市场。

为了显示一些导航链接到不同的市场和我们当前的余额,我们可以编辑App.vue组件,使用以下模板:

<template>
  <div id="app">
    <nav>
      <ul>
        <li>
          <router-link to="/STAR/LAMP">STAR/LAMP Market</router-link>
        </li><li>
          <router-link to="/LAMP/DIAM">LAMP/DIAM Market</router-link>
        </li>
      </ul>
    </nav>
    <router-view></router-view>
    <div class="balance">
      Your balance is:
      <ul>
        <li>{{$store.state.STAR}} stars</li>
        <li>{{$store.state.LAMP}} lamps</li>
        <li>{{$store.state.DIAM}} diamonds</li>
      </ul>
    </div>
  </div>
</template>

对于这个组件,我们不需要任何 JavaScript,所以可以删除<script>标签。

我们的应用现在已经准备就绪;启动它并开始交易。以下图片是我们完成的应用程序,不包括App.vue中包含的样式:

它是如何工作的...

底部的余额就像全局状态的摘要。通过 Vuex,我们能够通过访问每个组件都被 Vuex 插件注入的$store变量来影响其他组件。当您想要基本上扩展变量的范围超出组件本身时,您可以很容易地想象如何在大型应用程序中使用这种策略。

一些状态可能是局部的,例如,如果您需要一些动画或者需要一些变量来显示组件的模态对话框;不将这些值放入存储中是完全可以的。否则,在一个地方拥有结构化的集中状态会帮助很多。在随后的食谱中,您将使用更高级的技术来更好地利用 Vuex 的力量。

理解 Vuex 的变异

在 Vuex 应用程序中,变异状态的正确方法是使用 mutations 的帮助。变异是将状态更改分解为原子单位的非常有用的抽象。在这个食谱中,我们将探讨这一点。

准备就绪

完成上一个食谱后,可以完成此食谱,而无需太多了解 Vuex。

如何做...

将 Vuex 作为项目的依赖项添加(CDN 地址为https://unpkg.com/vuex)。我假设您正在使用 JSFiddle 进行跟进;否则,请记住在存储代码之前放置Vue.use(Vuex)

我们将构建的示例应用程序是向网站用户广播通知。

HTML 布局如下所示:

<div id="app">
  <div v-for="(message, index) in messages"> 
    <p style="cursor:pointer">{{message}}
      <span @click="close(index)">[x]</span>
    </p>
  </div>
  <input v-model="newMessage" @keyUp.enter="broadcast">
  <button @click="broadcast">Broadcast</button>
</div>

这个想法是有一个文本框来写消息,广播的消息将显示在顶部,最近的消息将首先显示。可以通过点击小 x 来关闭消息。

首先,让我们构建一个存储库,用于保存广播消息列表并列举我们可以对该列表进行的可能变异:

const store = new Vuex.Store({
  state: {
    messages: []
  },
  mutations: {
    pushMessage (state, message) {
      state.messages.push(message)
    },
    removeMessage (state, index) {
      state.messages.splice(index, 1)
    }
  }
})

因此,我们有一系列消息;我们可以将其中一个推送到列表的顶部,或者通过知道其索引来删除消息。

接下来,我们需要编写应用程序本身的逻辑:

new Vue({
  store,
  el: '#app',
  data: {
    newMessage: ''
  },
  computed: Vuex.mapState(['messages']),
  methods: {
    broadcast () {
      store.commit('pushMessage', this.newMessage)
      this.newMessage = ''
    },
    close (index) {
      store.commit('removeMessage', index)
    }
  }
})

现在,您可以启动应用程序并开始向我们虚构的用户广播消息:

它是如何工作的...

我认为重要的是要注意变异的名称;它们被称为pushMessageremoveMessage,但在这个应用程序中它们真正做的是在屏幕上显示消息,并(虚构地)向用户广播消息。将它们称为showMessagebroadcastMessagehideMessage会更好吗?不会,因为变异本身和该变异的特定效果之间必须有明确的意图分离。当我们决定让用户有能力忽略这些通知或者在实际广播通知之前引入延迟时,问题就变得清晰了。然后我们将有一个showMessage变异,它实际上并不显示消息。

我们使用的计算语法如下所示:

computed: Vuex.mapState(['messages'])

当您将 Vuex 作为 ES6 模块导入时,您不必在表达式中显式使用 Vuex。您只需要写

import { mapState } from 'Vuex'

然后,mapState函数将可用。

mapState方法以字符串数组作为参数,查找存储中具有相同名称的state变量,并创建具有相同名称的计算属性。您可以使用任意数量的变量来做到这一点。

还有更多...

如果您在本地 npm 项目上跟随操作,请打开 Vue 开发者工具(不幸的是,在使用 JSFiddle 时,Vue 开发者工具不可用),您将看到每条消息都会发出一个新的变异。考虑一下,您点击了小时钟:

实际上,您可以使用它来撤消变异,如下图所示:

请注意,当点击时间旅行选项时,状态并未发生变化;这是因为紫色丝带仍然停留在最后的状态。要查看不同的状态,只需点击变异名称本身。

这种调试机制是可能的,因为变异始终是同步的;这意味着可以在变异之前和之后对状态进行快照,并在时间轴上导航。在下一个食谱中,您将学习如何使用 Vuex 执行异步操作。

在 Vuex 中列出您的操作

您的所有变异必须是同步的,那么如何做一些等待超时或使用 Axios 进行 AJAX 请求的事情呢?操作是下一个抽象级别,将帮助您处理这些问题。在操作中,您可以提交多个变异并执行异步操作。

准备就绪

变异是操作的构建块,因此强烈建议您在尝试此操作之前完成前面的食谱。

我们将使用“为应用程序状态构建简单存储”食谱中的设置;您也可以使用自己的设置,但无论如何,此食谱都是基于官方 Webpack 模板的轻微修改。

如何做...

您将构建一个流行的 Xkcd 网站的克隆。实际上,它将更像是一个包装器,而不是一个真正的克隆,因为我们将重用网站上的面板。

基于 Webpack 模板创建一个 Vue 项目,使用vue init webpack命令。我们首先要做的是在config文件夹中的index.js中将 API 连接到 Xkcd 网站。将以下行放入proxyTable对象中:

module.exports = {
  ...
  dev: {
    proxyTable: {
      '/comic': {
        target: 'https://xkcd.com',
        changeOrigin: true,
        pathRewrite: (path, req) => {
          const num = path.split('/')[2]
          return `/${num}/info.0.json`
        }
      }
    },
  ...

这将把我们发出的所有请求重定向到/comic到 Xkcd 网站。

src目录下,创建一个新的store目录,并在其中创建一个index.js文件;在这里,开始构建应用程序存储:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    currentPanel: undefined,
    currentImg: undefined,
    errorStack: []
  },
  actions: {},
  mutations: {}
}

export default store

您应该像以前的食谱一样在main.js中导入这个。我们想要跟踪当前面板编号,面板图像的链接以及可能的错误。修改状态的唯一方法是通过变异,而操作可以执行异步工作。

当应用程序加载时,我们计划显示最新的漫画。为此,我们创建一个动作:

actions: {
  goToLastPanel ({ commit }) {
    axios.get(endpoint)
      .then(({ data }) => {
        commit('setPanel', data.num)
        commit('setImg', data.img)
      }).catch(error => {
        commit('pushError', error)
      })
  }
 ...

为了使此代码工作,我们需要声明端点并安装 Axios:

...
import axios from 'axios'
...
const endpoint = '/comic/'

对于您来说,编写相应的突变应该很容易:

mutations: {
  setPanel (state, num) {
    state.currentPanel = num
  },
  setImg (state, img) {
    state.currentImg = img
  },
  pushError (state, error) {
    state.errorStack.push(error)
  }
}

我们将重用Hello.vue组件,并在其中放入以下模板:

<template>
  <div class="hello">
    <h1>XKCD</h1>
    <img :src="currentImg">
  </div>
</template>

为了在加载时显示最后一个面板,您可以在组件中使用以下 JavaScript:

<script>
import { mapState } from 'vuex'
export default {
  name: 'hello',
  computed: mapState(['currentImg']),
  created () {
    this.$store.dispatch('goToLastPanel')
  }
}
</script>

此外,您可以删除大部分App.vue模板,只留下以下内容:

<template>
  <div id="app">
    <router-view></router-view>
  </div>
</template>

它是如何工作的...

proxyTable对象将配置http-proxy-middleware。每当我们开发大型 Web 应用程序的 UI 并在localhost上启动开发服务器,但我们的 API 响应到另一个 Web 服务器时,这将非常有用。当我们想要使用 CORS 并且不允许其他网站使用我们的 API 时,这一点尤为重要。Xkcd API 不允许localhost消耗 Web 服务。这就是为什么,即使我们尝试直接使用 Xkcd API,我们的浏览器也不会让我们这样做。changeOrigin选项将以 Xkcd 为主机发送请求,从而使 CORS 变得不必要。

要从组件中调用一个动作,我们使用了dispatch函数。还可以传递第二个参数,第一个是动作本身的名称。然后在定义动作时将第二个参数传递。

关于命名的最后说明——在我的看法中,由于动作是异步的,而突变是同步的,因此无需在动作的名称中显式地表明异步性。

使用模块分离关注点

构建大型应用程序时,Vuex 存储可能会变得拥挤。幸运的是,可以使用模块将应用程序的不同关注点分成单独的区块。

准备工作

如果您想使用模块,这个示例可以作为参考。您应该已经对 Vuex 有足够的了解。

对于这个示例,您需要对 Webpack 有一定的了解。

如何做...

在这个示例中,我们将以稍微简化的方式对一个完全功能的人体进行建模。每个器官都将有一个单独的模块。

使用vue init webpacknpm install vuex创建一个新的 Webpack 模板。在其中创建一个包含src/store/index.js文件的新目录。在其中,写入以下内容:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    brain,
    heart
  }
})

export default store

heart模块是这样的;将其放在存储声明之前:

const heart = {
  state: { loves: undefined },
  mutations: {
    love (state, target) {
      state.loves = target
    },
    unlove (state) {
      state.loves = undefined
    }
  }
}

请注意,传递给突变的状态不是根状态,而是模块的本地状态。

然后是大脑,它分为左叶叶和右叶叶;在存储之前写入以下内容:

const brain = {
  modules: {
    left: leftLobe,
    right: rightLobe
  }
}

你可以将它们实现为简单的布尔状态(在它们所依赖的大脑之前写入):

const leftLobe = {
  namespaced: true,
  state: { reason: true },
  mutations: {
    toggle (state) { state.reason = !state.reason }
  }
}
const rightLobe = {
  namespaced: true,
  state: { fantasy: true },
  mutations: {
   toggle (state) { state.fantasy = !state.fantasy }
  }
}

namespaced设置为 true 会修改你调用 mutator 的方式。因为它们都被称为toggle,现在你可以指定哪个叶叶,例如,对于左叶叶,变异字符串变成了left/toggle,其中left表示它是大脑中用来指代左叶叶的键。

要查看你的存储在运行中的情况,你可以创建一个使用所有变异的组件。对于大脑,我们可以有两张叶叶的图片,如下所示:

<img 
 :class="{ off: !$store.state.brain.left.reason }"
 src="http://i.imgur.com/n8B6wuY.png"
 @click="left"><img
 :class="{ off: !$store.state.brain.right.fantasy }"
 src="http://i.imgur.com/4BbfVur.png"
 @click="right">

这将用红色铅笔创建两个大脑叶叶的图画;注意嵌套方式中模块名称的使用。以下的off CSS 规则会使叶叶变灰:

.off {
  filter: grayscale(100%)
}

要调用变异,我们在正确的方法中使用上述字符串:

methods: {
  left () {
    this.$store.commit('left/toggle')
  },
  right () {
    this.$store.commit('right/toggle')
  }
}

你也可以创建一个输入文本框,并调用其他两个变异,如下所示:

...
love () {
  this.$store.commit('love', this.partner)
},
clear () {
  this.$store.commit('unlove')
  this.partner = undefined
}
...

这很容易,但是如何检索叶叶的名称呢?你可以在你的模板中放上这些大括号:

<p> loves: {{$store.state.heart.loves}}</p>
<input v-model="partner" @input="love">
<button @click="clear">Clear</button>

显然,你必须在你的 Vue 实例上声明partner变量:

它是如何工作的...

我们已经看到了如何使用模块将你的应用程序关注点分成不同的单元。随着项目规模的增长,这种能力可能变得很重要。

常见模式是,在变异中,你可以直接访问本地状态:

const leftLobe = {
  namespaced: true,
  state: { reason: true },
  mutations: {
    toggle (state) {
      // here state is the left lobe state
      state.reason = !state.reason
    }
  }
}

在变异中,只有访问本地状态是有意义的。例如,大脑不能改变心脏,反之亦然,但动作呢?如果我们在模块内声明一个动作,我们会得到一个名为上下文的对象,看起来像这样:

{
  "getters":{},
  "state":{
    "reason":true
  },
  "rootGetters":{},
  "rootState":{
    "brain":{
      "left":{
        "reason":true
      },
      "right":{
        "fantasy":false
      }
    },
    "heart":{
      "loves": "Johnny Toast"
    }
  }
}

因此,如果我们想在左叶叶中声明一个动作,并且想要影响心脏,我们必须做类似以下的事情:

actions: {
  beNerd ({ rootState }) {
    rootState.heart.loves = 'Math & Physics'
  }
}

构建 getter 来帮助检索你的数据

你不想在你的状态中保存太多数据。保留重复或派生数据可能特别危险,因为它很容易失去同步。Getter 可以帮助你做到这一点,而不会将负担转移到组件上,因为它将所有逻辑都保存在一个地方。

准备工作

如果你已经具有一些 Vuex 知识并且想要拓展你的视野,那么这个教程适合你。

如何做...

想象一下,你正在构建一个比特币钱包。你想给你的用户一个余额概览,并且你希望他们看到它对应多少欧元。

使用vue init webpacknpm install vuex创建一个新的 Webpack 模板。创建一个新的src/store/index.js文件,并在其中写入以下内容:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    bitcoin: 600,
    rate: 1000,
    euro: 600000
  }
})

export default store

这段代码容易出错。如果我们没有正确进行乘法运算,第一个错误可能是欧元金额的错误计算。第二种错误可能是在交易过程中告诉用户“比特币”和“欧元”余额,导致其中一种金额过时和错误。

为了解决这些问题,我们使用getters

const store = new Vuex.Store({
  state: {
    bitcoin: 600,
    rate: 1000
  },
  getters: {
    euro: state => state.bitcoin * state.rate
  }
})

这样,“欧元”金额永远不会在状态中,而是始终计算。此外,它是集中在存储中,因此我们不需要向我们的组件添加任何内容。

现在,从模板中轻松检索两个金额:

<template>
  <div>
    <h1>Balance</h1>
    <ul>
      <li>{{$store.state.bitcoin}}฿</li>
      <li>{{$store.getters.euro}}&euro;</li>
    </ul>
  </div>
</template>

在这里,&#3647 ;是比特币符号的 HTML 实体。

它是如何工作的...

如果我们不谈论输入数据,为派生数据设置一个 getter 总是一个好主意。我们尚未讨论的 getter 的一个显着特点是它们能够与其他 getter 进行交互并接受参数。

访问其他 getter

调用 getter 时传递的第二个参数是包含其他getters的对象:

getters:  {
 ...
  getCatPictures: state => state.pictures.filter(pic => isCat(pic)) getKittens:  (state, getters)  =>  {
 return getters.getCatPictures().filter(cat  => !isAdult(cat))
 } } 

在我们的示例中,我们可以调用eurogetter 来获得一些更多的派生数据,比如我们可以用 150,000 欧元的平均价格大致计算出我们可以用比特币购买多少房屋:

const store = new Vuex.Store({
  state: {
    bitcoin: 600,
    rate: 1000
  },
  getters: {
    euro: state => state.bitcoin * state.rate,
    houses: (state, getters) => getters.euro() / 150000
})

传递参数

如果 getter 返回一个带有参数的函数,那么该参数将成为 getter 的参数:

getters: {
  ...
  getWorldWonder: state => nth => state.worldWonders[nth]
}

在我们的示例中,一个实际的例子可以指定前一段中的 getter 中房屋的平均成本:

const store = new Vuex.Store({
  state: {
    bitcoin: 600,
    rate: 1000
  },
  getters: {
    euro: state => state.bitcoin * state.rate,
    houses: (state, getters) => averageHousePrice => {
 return getters.euro() / averageHousePrice
 }
})

测试您的 store

在这个示例中,您将为 Vuex 存储编写测试。

准备就绪

这个示例需要对单元测试和端到端测试有所了解,并且对 Vuex 有一些了解。

如何做...

首先,我将定义我们的存储必须实现的一些功能;然后,您将编写证明这些功能存在且正常工作的测试。

软件要求

我们的存储包括待办事项列表中的项目,如下所示:

state: {
  todo: [
    { id: 43, text: 'Buy iPhone', done: false },
    ...
  ],
  archived: [
    { id: 2, text: 'Buy gramophone', done: true },
    ...
  ]
}

我们有两个要求:

  • 我们必须有一个MARK_ITEM_AS_DONEmutation,将done字段从 false 更改为 true。

  • 我们必须有一个downloadNew操作,从服务器下载最新项目并将其添加到列表中。

测试 mutations

为了能够测试您的 mutations,您必须使它们可用于您的测试文件。为此,您必须从存储中提取 mutation 对象。考虑类似于这样的东西:

import Vuex from 'vuex'
import Vue from 'vue'

Vue.use(Vuex)

const store = new Vuex.Store({
  ...
  mutations: {
    ...
    MARK_ITEM_AS_DONE (state, itemId) {
      state.todo.filter(item => {
        return item.id === itemId
      }).forEach(item => {
        item.done = true
      })
      state.archived.filter(item => {
        return item.id === itemId
      }).forEach(item => {
        item.done = true
      })
    }
  }
}) 

export default store

您必须将其提取为类似于这样的东西:

export const mutations = { ... }

const store = new Vuex.Store({ ... })

export default store

这样,您可以在测试文件中使用以下行导入突变:

import { mutations } from '@/store'

对第 1 个要求的测试可以编写如下:

describe('mutations', () => {
  it(`MARK_ITEM_AS_DONE mutation must change the
        done field from false to true for a todo`, () => {
    const state = {
      todo: [
        { id: 43, text: 'Buy iPhone', done: false }
      ],
      archived: [
        { id: 40, text: 'Buy cat', done: false }
      ]
    }
    mutations.MARK_ITEM_AS_DONE(state, 43)
    expect(state.todo[0].done).to.be.true
  })
})

如果您使用官方的 Webpack 模板,可以使用npm run unit运行测试。这默认使用 PhantomJS,它不实现一些功能。您可以使用 Babel polyfills,或者简单地进入karma.conf.js并在browsers数组中写入Chrome而不是PhantomJS。记得使用npm install karma-chrome-launcher --save-dev安装 Chrome 启动器。

测试行动

测试行动意味着测试行动是否提交了预期的突变。我们对突变本身不感兴趣(至少在单元测试中不感兴趣),因为它们已经单独测试过了。不过,我们可能需要模拟一些依赖关系。

为了避免依赖于 Vue 或 Vuex(因为我们不需要它们,它们可能会污染测试),我们在store目录中创建了一个新的actions.js文件。使用npm install axios安装 Axios。actions.js文件可以如下所示:

import axios from 'axios'

export const actions = {
  downloadNew ({ commit }) {
    axios.get('/myNewPosts')
      .then(({ data }) => {
        commit('ADD_ITEMS', data)
      })
  }
}

为了测试第 2 个要求,我们首先模拟应该下载新待办事项的服务器调用:

describe('actions', () => {
const actionsInjector = 
  require('inject-loader!@/store/actions')
const buyHouseTodo = {
  id: 84,
  text: 'Buy house',
  done: true
}
const actions = actionsInjector({
  'axios': {
    get () {
      return new Promise(resolve => {
        resolve({
          data: [buyHouseTodo]
        })
      })
    }
  }
}).default
}

这将确保对axiosget方法的任何调用都将始终返回一个新的待办事项。

然后,我们希望确保在调度时调用ADD_ITEMS突变:

describe('actions', () => {
  const actionsInjector = 
    require('inject-loader!@/store/actions')
    const buyHouseTodo = {
      id: 84,
      text: 'Buy house',
      done: true
    }
    const actions = actionsInjector({
      'axios': {
        get () {
          return new Promise(resolve => {
            resolve({ data: [buyHouseTodo] })
          })
        }
      }
    }).default
    it(`downloadNew should commit ADD_ITEMS
    with the 'Buy house' todo when successful`, done => {
    const commit = (type, payload) => {
      try {
        expect(type).to.equal('ADD_ITEMS')
        expect(payload).to.deep.equal([buyHouseTodo])
        done()
      } catch (error) {
        done(error)
      }
    }
  actions.downloadNew({ commit })
  })
})

它是如何工作的...

对突变的测试非常简单,但我认为对行动的测试需要更多的解释。

由于我们不想依赖外部服务来执行操作,我们不得不模拟axios服务。我们使用inject-loader,它接受原始库并用任意代码模拟我们指定的部分(@符号是src的简写);在我们的情况下,我们模拟了axios库,特别是get方法。我们必须使用 CommonJS 语法(带有require)因为这是告诉 Webpack 在导入时使用加载器的唯一方法。

在测试中,我们还模拟了commit函数。通常,这个函数调用一个修改状态的突变。我们只想知道是否调用了正确的突变,并且带有正确的参数。此外,我们必须将所有内容包装在try块中;如果没有它,测试将因超时而失败,我们将丢失错误。相反,现在我们立即失败,我们可以从控制台中读取导致测试失败的错误。

第十九章:与其他框架集成

在本章中,我们将探讨以下主题:

  • 使用 Electron 构建通用应用程序

  • 使用 Vue 与 Firebase

  • 使用 Feathers 创建实时应用程序

  • 使用 Horizon 创建一个响应式应用程序

介绍

Vue 很强大,但如果您需要后端,它单独做不了太多;至少您需要一个服务器来部署您的软件。在本节中,您将使用流行的框架实际构建小型但完整且可工作的应用程序。Electron 用于将 Vue 应用程序带到桌面。Firebase 是一个现代的云后端,最后,FeatherJS 是一个简约但功能齐全的 JavaScript 后端。完成这些后,您将拥有与它们交互并快速构建专业应用程序所需的所有工具。

使用 Electron 构建通用应用程序

Electron 是一个用于在 Mac、Linux 和 Windows 上运行通用应用程序的框架。它的核心是一个精简版的 Web 浏览器。它已被用于创建广泛使用的应用程序,如 Slack 和 Visual Studio Code 等。在这个示例中,您将使用 Electron 构建一个简单的应用程序。

准备工作

为了构建这个应用程序,我们将只使用基本的 Vue 功能。Electron 超出了本书的范围,但对于这个示例,不需要了解 Electron;事实上,这是学习更多关于 Electron 的好起点。

如何做...

在这个示例中,我们将构建一个小型但完整的应用程序--一个番茄钟应用程序。番茄钟是大约 25 个时间单位的间隔,您应该集中精力工作。之所以这样称呼它,是因为通常使用番茄形状的厨房计时器来测量时间。这个应用程序将跟踪时间,这样您就不必购买昂贵的厨房计时器了。

使用 Electron-Vue 模板是使用 Electron 快速启动 Vue 项目的最佳方法(你不说!)。可以通过以下命令轻松实现:

vue init simulatedgreg/electron-vue pomodoro

您可以使用默认值进行回答,但当被问及要安装哪个插件时,只需选择vue-electron。使用npm intall安装所有依赖项,如果愿意,您可以在进行必要修改时保持应用程序处于热重新加载状态,方法是使用npm run dev。您可以通过单击角落的x来隐藏开发工具:

首先,我们希望我们的应用程序尽可能小。让我们转到app/src/main/index.js文件;这个文件控制我们应用程序的生命周期。将窗口大小更改为以下内容:

mainWindow = new BrowserWindow({
  height: 200,
  width: 300
})

然后,我们并不真的想要app/src/render/components文件夹中的样板组件,所以您可以删除所有内容。相反,创建一个Pomodoro.vue文件,并将此模板放入其中:

<template>
  <div class="pomodoro">
    <p>Time remaining: {{formattedTime}}</p>
    <button v-if="remainingTime === 1500" @click="start">Start</button>
    <button v-else @click="stop">Stop</button>
  </div>
</template>

为了使其工作,我们还必须编写 JavaScript 部分,如下所示:

<script>
export default {
  data () {
    return {
      remainingTime: 1500,
      timer: undefined
    }
  },
  methods: {
    start () {
      this.remainingTime -= 1
      this.timer = setInterval(() => {
        this.remainingTime -= 1
        if (this.remainingTime === 0) {
          clearInterval(this.timer)
        }
      }, 1000)
    },
    stop () {
      clearInterval(this.timer)
      this.remainingTime = 1500
    }
  }
}
</script>

这样,单击程序中的开始按钮将每秒减少 1 秒。单击停止按钮将清除计时器并将剩余时间重置为 1500 秒(25 分钟)。计时器对象基本上是setInterval操作的结果,clearInterval只是停止计时器正在执行的任何操作。

在我们的模板中,我们希望有一个formattedTime方法,以便以mm:ss格式查看时间,这比仅剩秒数更易读(即使更极客),因此我们需要添加计算函数:

computed: {
  formattedTime () {
    const pad = num => ('0' + num).substr(-2)
    const minutes = Math.floor(this.remainingTime / 60)
    const seconds = this.remainingTime - minutes * 60
    return `${minutes}:${pad(seconds)}`
  }
}

要将此组件添加到应用程序中,请转到App.vue文件并编辑以下行,替换landingPage占位符元素:

<template>
  <div id="#app">
 <pomodoro></pomodoro>
  </div>
</template>

<script>
 import Pomodoro from 'components/Pomodoro'
  export default {
    components: {
 Pomodoro
    }
  }
</script>

使用npm run dev启动应用程序,现在您应该能够在工作或学习时跟踪时间了:

甚至可以使用npm run build命令构建应用程序的可分发版本。

工作原理...

我们实现的计时器方式对于时间跟踪来说并不特别准确。让我们来审查一下代码:

this.timer = setInterval(() => {
  this.remainingTime -= 1
  if (this.remainingTime === 0) {
    clearInterval(this.timer)
  }
}, 1000)

这意味着我们每秒减少剩余时间。问题在于setInterval函数本身并不是 100%准确的,可能会在 1000 毫秒之前或之后触发函数,这取决于机器的计算负载;这样,误差会积累并变成相当大的数量。更好的方法是在每次循环时检查时钟并调整误差,尽管我们不会在这里涵盖这个问题。

使用 Vue 和 Firebase

使用 Vue 和 Firebase 作为后端非常容易,这要归功于 VueFire--一个包含 Firebase 绑定的插件。在这个示例中,您将开发一个完全功能的气味数据库。

准备工作

Firebase 超出了本书的范围,但是我将假设,对于这个示例,您对基本概念有所了解。除此之外,您真的没有太多需要了解的,因为我们将在此基础上构建一个非常基本的 Vue 应用程序。

如何做到这一点...

在开始编写代码之前,我们需要创建一个新的 Firebase 应用程序。要做到这一点,您必须登录firebase.google.com/并创建一个新的应用程序。在我们的情况下,它将被称为smell-diary。您还需要记下您的 API 密钥,该密钥可以在项目设置中找到:

此外,您需要禁用身份验证;转到数据库部分,在规则选项卡中,将读取和写入都设置为 true:

{
  "rules": {
    ".read": true,
    ".write": true
  }
}

我们已经完成了 Firebase 配置。

打开一个干净的 HTML5 样板或 JSFiddle,使用Vue作为库。我们需要将以下依赖项表示为文件头中的脚本标记:

 <script src="https://unpkg.com/vue/dist/vue.js"></script>
 <script src="https://www.gstatic.com/firebasejs/3.6.9/firebase.js"></script>
 <script src="https://unpkg.com/vuefire/dist/vuefire.js"></script>

VueFire 将自动检测 Vue(因此顺序很重要)并将自身安装为插件。我们将构建一个非常简单的数据库来跟踪我们周围事物的气味。以下是我们应用程序的 HTML 布局:

<div id="app">
  <ul>
    <li v-for="item in items">
      {{item.name}}: {{item.smell}}
    <button @click="removeItem(item['.key'])">X</button>
    </li>
  </ul>
  <form @submit.prevent="addItem">
    <input v-model="newItem" />
    smells like
    <input v-model="newSmell" />
    <button>Add #{{items.length}}</button>
  </form>
</div>

在我们应用程序的 JavaScript 部分,我们需要指定 API 密钥以用于与 Firebase 进行身份验证,写入以下内容:

const config = {
  databaseURL: 'https://smell-diary.firebaseio.com/'
}

然后,我们将配置提供给 Firebase 并获取数据库的控制权:

const firebaseApp = firebase.initializeApp(config)
 const db = firebaseApp.database()

这可以在Vue实例之外完成。VueFire 插件在Vue实例中安装了一个名为firebase的新选项;我们必须指定我们要使用item变量在 Firebase 应用程序中访问/items

new Vue({
  el: '#app',
  firebase: {
    items: db.ref('/items')
  }
})

newItemnewSmell变量将临时保存我们在输入框中输入的值;然后,addItemremoveItem方法将发布和从我们的数据库中删除数据:

data: {
  newItem: '',
  newSmell: ''
},
methods: {
  addItem () {
    this.$firebaseRefs.items
      .push({
        name: this.newItem,
        smell: this.newSmell
      })
    this.newItem = ''
    this.newSmell = ''
  },
  removeItem (key) {
    this.$firebaseRefs.items
      .child(key).remove()
  }
}

如果您现在启动应用程序,您已经可以添加您最喜欢的香味以及嗅探它们的方式:

它是如何工作的...

Firebase 作为一个简单的键值存储。在我们的情况下,我们从不存储值,而是始终添加子项;您可以在 Firebase 控制台中查看您创建的内容:

密钥是自动生成的,它们包含空值和 32 级嵌套数据。我们使用一级嵌套来为每个对象插入名称和气味。

使用 Feathers 创建实时应用程序

大多数现代应用程序都是实时的,不是传统意义上的实时,而是它们不需要重新加载页面就可以更新。实现这一点最常见的方式是通过 WebSockets。在这个配方中,我们将利用 Feathers 和 Socket.io 来构建一个猫数据库。

准备工作

这个配方没有先决条件,但如果您想要更多上下文,可以在开始这个配方之前完成创建 REST 客户端(和服务器!)配方。

如何操作...

要完成这个配方,您需要 Feathers 的命令行;使用以下命令安装它:

npm install -g feathers-cli

现在运行feathers generate,它将为您创建所有样板。在询问 API 时,选择 Socket.io:

其他所有问题都可以保持默认值。在 Feather 控制台中,输入generate service来创建一个新的服务。您可以将其命名为 cats,并将其他问题保持默认值。

public文件夹中,打开index.html并删除除了 HTML5 样板之外的所有内容。您需要在头部引入三个依赖项:

 <script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.1.10/vue.js"></script>
 <script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.7.3/socket.io.js"></script>
 <script src="//unpkg.com/feathers-client@¹.0.0/dist/feathers.js"></script>

body标签中编写 HTML 布局如下:

<div id="app">
  <div v-for="cat in cats" style="display:inline-block">
    <img width="100" height="100" :src="cat.url" />
    <p>{{cat.name}}</p>
  </div>
  <form @submit.prevent="addCat">
    <div>
      <label>Cat Name</label>
      <input v-model="newName" />
    </div>
    <div>
      <label>Cat Url</label>
      <input v-model="newUrl" />
    </div>
    <button>Add cat</button>
    <img width="30" height="30" :src="newUrl" />
  </form>
</div>

第一个<div>标签是猫的画廊。然后,构建一个表单来添加您收集的猫的新图像。

body标签中,您可以始终使用以下行配置 Feathers 服务:

<script>
  const socket = io('http://localhost:3030')
  const app = feathers()
    .configure(feathers.socketio(socket))
  const catService = app.service('cats')

这是为了配置将连接到 WebSockets 的浏览器的客户端。catService方法是对猫数据库的处理。接下来,我们编写Vue实例:

  new Vue({
    el: '#app',
    data: {
      cats: [],
      newName: '',
      newUrl: ''
    },
    methods: {
      addCat () {
        catService.create({
          name: this.newName,
          url: this.newUrl
        })
        this.newName = ''
        this.newUrl = ''
      }
    },

最后,我们需要在启动时请求数据库中的所有猫,并安装一个监听器以防其他用户创建新的猫:

    mounted () {
      catService.find()
        .then(page => {
          this.cats = page.data
        })
      catService.on('created', cat => {
        this.cats.push(cat)
      })
    }
 })
 </script>

如果您使用npm start运行应用程序,可以导航到控制台中写的 URL 以查看您的新应用程序。打开另一个浏览器窗口,看看它如何实时变化:

工作原理...

实时查看添加的猫显然是现代应用程序的选择。Feathers 让您可以轻松创建它们,并且只需少量代码,这要归功于底层的 Socket.io,后者又使用了 WebSockets。

WebSockets 实际上并不那么复杂,Feathers 在这种情况下所做的就是监听通道中的消息,并将它们与像向数据库添加内容这样的操作关联起来。

当你可以轻松切换数据库和 WebSocket 提供程序,或者切换到 REST 而不用触碰你的 Vue 代码时,Feathers 的强大之处就显而易见了。

使用 Horizon 创建一个反应式应用程序

Horizon 是一个构建反应式、实时可扩展应用程序的平台。它在内部使用 RethinkDB,并且与 Vue 立即兼容。在这个教程中,你将建立一个自动个人日记。

准备工作

这个教程只需要一点 Vue 基础知识,但真的没有其他什么。

不过,在开始之前,请确保你安装了 RethinkDB。你可以在他们的网站上找到更多信息(www.rethinkdb.com/docs/install/)。如果你有 Homebrew,你可以用brew install rethinkdb来安装它。

此外,你还需要一个 Clarifai 令牌。要免费获取一个,请转到developer.clarifai.com/并注册。你将看到你应该在你的应用程序中写的代码,就像下面的图片中一样:

特别是,你将需要clientIdclientSecret,它们以这种方式显示:

var app = new Clarifai.App( 'your client id would be printed here',
 'your client secret would be here' );

记下这段代码,或者准备好将其复制粘贴到你的应用程序中。

如何做...

写日记是一项艰巨的任务,每天都要写很多。在这个教程中,我们将建立一个基于我们白天拍摄的照片为我们写作的自动日记。

Horizon 将帮助我们记住一切,并在我们的设备之间同步日记。安装 RethinkDB 后,使用以下命令安装 Horizon:

npm install -g horizon

现在,你将有一个新的命令hz可用。输入hz -h来检查它;你应该会看到类似以下的内容:

要创建一个将托管我们的新应用程序的目录,请输入以下内容:

hz init vue_app

然后,进入新创建的vue_app目录,查看dist文件夹中的index.html。这个文件将是我们服务器的入口点,用编辑器打开它。

你可以清除一切,只留下一个空的 HTML5 样板,其中包含一个空的<head><body>。在头部部分,我们需要声明对 Vue、Horizon 和 Clarifai 的依赖,如下所示:

 <script src="https://unpkg.com/vue"></script>
 <script src="/horizon/horizon.js"></script>
 <script src="https://sdk.clarifai.com/js/clarifai-latest.js"></script>

请注意,Horizon 并不是来自 CDN,而是来自本地依赖。

我们首先为我们的日记制定一个模板。我们有两部分。在第一部分中,我们将列出过去做过的事情。在 HTML 的主体中写入以下内容:

<div id="app">
  <div>
    <h3>Dear diary...</h3>
    <ul>
      <li v-for="entry in entries">
        {{ entry.datetime.toLocaleDateString() }}:
        {{ entry.text }}
      </li>
    </ul>
  </div>
...

在第二部分中,我们将输入新条目:

  ...
  <h3>New Entry</h3>
  <img
    style="max-width:200px;max-height:200px"
    :src="data_uri"
  />
  <input type="file" @change="selectFile" ref="file">
  <p v-if="tentativeEntries.length">Choose an entry</p>
  <button v-for="tentativeEntry in tentativeEntries" @click="send(tentativeEntry)">
    {{tentativeEntry}}
  </button>
</div>

在此之后,打开一个<script>标签,我们将在其中编写以下所有 JavaScript。

首先,我们需要登录到 Clarifai:

var app = new Clarifai.App(
 '7CDIjv_VqEYfmFi_ygwKsKAaDe-LwEzc78CcW1sA',
 'XC0S9GHxS0iONFsAdiA2xOUuBsOhAT0jZWQTx4hl'
 )

显然,您希望输入 Clarifai 的clientIdclientSecret

然后,我们需要启动 Horizon 并获得我们将创建的entries集合的句柄:

const horizon = new Horizon()
const entries = horizon('entries')

现在,我们最终编写我们的Vue实例,其中包含三个状态变量:

new Vue({
  el: '#app',
  data: {
    tentativeEntries: [],
    data_uri: undefined,
    entries: []
  },
  ...

tentativeEntries数组将包含我们可以选择的日记的可能条目列表;data_uri将包含我们想要用作今天所做事情的参考的图像(base64代码);entries是所有过去的条目。

当我们加载图像时,我们要求 Clarifai 提出可能的条目:

...
methods: {
  selectFile(e) {
  const file = e.target.files[0]
  const reader = new FileReader()
  if (file) {
    reader.addEventListener('load', () => {
      const data_uri = reader.result
      this.data_uri = data_uri
      const base64 = data_uri.split(',')[1]
      app.models.predict(Clarifai.GENERAL_MODEL, base64)
        .then(response => {
          this.tentativeEntries =
            response.outputs[0].data.concepts
            .map(c => c.name)
        })
      })
    reader.readAsDataURL(file)
  }
},
...

然后,当我们按下发送按钮时,我们告诉 Horizon 条目集存储这个新条目:

    ...
    send(concept) {
      entries.store({
        text: concept,
         datetime: new Date()
      }).subscribe(
        result => console.log(result),
        error => console.log(error)
      )
      this.tentativeEntries = []
      this.$refs.file.value = ''
      this.data_uri = undefined
    }
  }
})

最后,我们希望在页面加载时确保屏幕上有最后十个条目,并且每次添加新条目时,它都会实时弹出。在 Vue 实例中的方法之后添加以下钩子:

created() {
  entries.order('datetime', 'descending').limit(10).watch()
    .subscribe(allEntries => {
      this.entries = [...allEntries].reverse()
  })
}

要运行 Horizon 服务器,请使用以下命令:

hz serve --dev

上述代码的输出如下:

转到指定的地址(第一行,而不是管理界面),您将看到以下内容:

您会注意到,如果您打开其他浏览器窗口,它们将实时更新。现在您终于可以每天写日记而不用打字了!

工作原理...

我们的应用程序使用一种称为响应式的模式。它的核心可以清楚地在创建的句柄中看到:

entries.order('datetime', 'descending').limit(10).watch()
  .subscribe(allEntries => {
    this.entries = [...allEntries].reverse()
  })

第一行返回了所谓的响应式可观察对象。可观察对象可以被视为事件源。每次触发事件时,订阅者将对该事件进行处理。在我们的情况下,我们正在获取整个条目集合,并且抛出的事件是对该集合的修改。每当我们收到这种类型的事件时,我们就会更新entries数组。

我不会在这里提供有关响应式编程的深入解释,但我想强调这种模式非常有助于可扩展性,因为您可以轻松实现数据流的控制;limit(10)就是一个例子。

第二十章:Vue 路由模式

路由是任何单页面应用SPA)的重要组成部分。本章重点介绍了如何最大化使用 Vue 路由器,并从用户页面之间的路由、参数到最佳配置进行了讨论。

到本章结束时,我们将涵盖以下内容:

  • 在 Vue.js 应用程序中实现路由

  • 使用动态路由匹配创建路由参数

  • 将路由参数作为组件属性传递

单页面应用程序

现代 JavaScript 应用程序实现了一种称为 SPA 的模式。在其最简单的形式中,它可以被认为是根据 URL 显示组件的应用程序。由于模板被映射到路由,因此无需重新加载页面,因为它们可以根据用户导航的位置进行注入。

路由器的工作。

通过这种方式创建我们的应用程序,我们能够提高感知和实际速度,因为我们的应用程序更加动态。

使用路由器

让我们启动一个游乐项目并安装vue-router库。这使我们能够在应用程序内利用路由,并为我们提供现代 SPA 的功能。

在终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vue-router-basics

# Navigate to directory
$ cd vue-router-basics

# Install dependencies
$ npm install

# Install Vue Router
$ npm install vue-router

# Run application
$ npm run dev

由于我们在构建系统中使用 webpack,我们已经使用npm安装了路由器。然后我们可以在src/main.js中初始化路由器:

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

import App from './App.vue';

Vue.use(VueRouter);

new Vue({
  el: '#app',
  render: h => h(App)
});

这实际上将VueRouter注册为全局插件。插件只是一个接收Vueoptions作为参数的函数,并允许诸如VueRouter之类的库向我们的 Vue 应用程序添加功能。

创建路由

然后,我们可以在main.js文件中定义两个简单的组件,它们只是有一个模板,显示带有一些文本的h1

const Hello = { template: `<h1>Hello</h1>` };
const World = { template: `<h1>World</h1>`};

然后,为了在特定的 URL(如/hello/world)上在屏幕上显示这些组件,我们可以在应用程序内定义路由:

const routes = [
  { path: '/hello', component: Hello },
  { path: '/world', component: World }
];

现在我们已经定义了我们想要使用的组件以及应用程序内的路由,我们需要创建一个新的VueRouter实例并传递路由。

尽管我们使用了Vue.use(VueRouter),但我们仍需要创建一个新的VueRouter实例并初始化我们的路由。这是因为仅仅将VueRouter注册为插件,就可以让我们在 Vue 实例中访问路由选项:

const router = new VueRouter({
  routes
});

然后,我们需要将router传递给我们的根 Vue 实例:

new Vue({
  el: '#app',
  router,
  render: h => h(App)
});

最后,要在我们的App.vue组件中显示路由的组件,我们需要在template中添加router-view组件:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

如果我们导航到/#/hello//#/world,将显示相应的组件:

动态路由

我们还可以根据特定参数动态匹配路由。这可以通过在参数名之前指定一个冒号的路由来实现。以下是使用类似问候组件的示例:

// Components
const Hello = { template: `<h1>Hello</h1>` };
const HelloName = { template: `<h1>Hello {{ $route.params.name}}` }

// Routes
const routes = [
 { path: '/hello', component: Hello },
 { path: '/hello/:name', component: HelloName },
]

如果我们的用户导航到/hello,他们将看到带有文本Helloh1。否则,如果他们导航到/hello/{name}(即 Paul),他们将看到带有文本Hello Paulh1

我们取得了很大的进展,但重要的是要知道,当我们导航到参数化的 URL 时,如果参数发生变化(即从/hello/paul/hello/katie),组件的生命周期钩子不会再次触发。我们很快会看到这一点!

路由 props

让我们将我们的/hello/name路由更改为将name参数作为component属性传递,这可以通过在路由中添加props: true标志来完成:

const routes = [
  { path: '/hello', component: Hello },
  { path: '/hello/:name', component: HelloName, props: true},
]

然后,我们可以更新我们的组件以接受一个带有nameid属性,并在生命周期钩子中将其记录到控制台中:

const HelloName = {
  props: ['name'],
  template: `<h1>Hello {{ name }}</h1>`,
  created() {
    console.log(`Hello ${this.name}`)
  }
}

如果我们尝试导航到不同的动态路由,我们会发现created钩子只会触发一次(除非我们刷新页面),即使我们的页面显示了正确的名称:

组件导航守卫

我们如何解决生命周期钩子问题?在这种情况下,我们可以使用所谓的导航守卫。这允许我们钩入到路由器的不同生命周期中,比如beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave方法。

beforeRouteUpdate

让我们使用beforeRouteUpdate方法来访问有关路由更改的信息:

const HelloName = {
  props: ['name'],
  template: `<h1>Hello {{ name }}</h1>`,
  beforeRouteUpdate(to, from, next) {
    console.log(to);
    console.log(from);
    console.log(`Hello ${to.params.name}`)
  },
}

如果我们在导航到/hello/{name}下的不同路由后检查 JavaScript 控制台,我们将能够看到用户要去的路由以及他们来自哪里。tofrom对象还为我们提供了对params、查询、完整路径等的访问权限。

虽然我们正确地得到了日志记录,但如果我们尝试在路由之间导航,你会注意到我们的应用程序不会使用参数name属性进行更新。这是因为我们在守卫内完成任何计算后没有使用next函数。让我们添加进去:

  beforeRouteUpdate(to, from, next) {
    console.log(to);
    console.log(from);
    console.log(`Hello ${to.params.name}`)
    next();
  },

beforeRouteEnter

我们还可以利用beforeRouteEnter来在进入组件路由之前执行操作。这里有一个例子:

 beforeRouteEnter(to, from, next) {
  console.log(`I'm called before entering the route!`)
  next();
 }

我们仍然必须调用next来将堆栈传递给下一个路由处理程序。

beforeRouteLeave

我们还可以钩入beforeRouteLeave来在离开路由时执行操作。由于我们已经在这个钩子的上下文中在这个路由上,我们可以访问组件实例。让我们看一个例子:

 beforeRouteLeave(to, from, next) {
 console.log(`I'm called before leaving the route!`)
 console.log(`I have access to the component instance, here's proof! 
 Name: ${this.name}`);
 next();
 }

再次,我们必须在这种情况下调用next

全局路由钩子

我们已经研究了组件导航守卫,虽然这些守卫是基于组件的,但你可能想要建立全局钩子来监听导航事件。

beforeEach

我们可以使用router.beforeEach来全局监听应用程序中的路由事件。如果你有认证检查或其他应该在每个路由中使用的功能,这是值得使用的。

这里有一个例子,简单地记录用户要去的路由和来自的路由。以下每个示例都假定路由器存在于类似以下范围的上下文中:

const router = new VueRouter({
  routes
})

router.beforeEach((to, from, next) => {
 console.log(`Route to`, to)
 console.log(`Route from`, from)
 next();
});

再次,我们必须调用next()来触发下一个路由守卫。

beforeResolve

beforeResolve全局路由守卫在确认导航之前触发,但重要的是要知道,这只是在所有特定于组件的守卫和异步组件已经解析之后。

这里有一个例子:

router.beforeResolve((to, from, next) => {
 console.log(`Before resolve:`)
 console.log(`Route to`, to)
 console.log(`Route from`, from)
 next();
});

afterEach

我们还可以钩入全局的afterEach函数,允许我们执行操作,但我们无法影响导航,因此只能访问tofrom参数:

router.afterEach((to, from) => {
 console.log(`After each:`)
 console.log(`Route to`, to)
 console.log(`Route from`, from)
});

解析堆栈

现在我们已经熟悉了提供的各种不同的路由生命周期钩子,值得调查的是,每当我们尝试导航到另一个路由时,整个解析堆栈。

  1. 触发路由更改:这是任何路由生命周期的第一阶段,当我们尝试导航到新路由时触发。一个例子是从/hello/Paul/hello/Katie。在这一点上还没有触发任何导航守卫。

  2. 触发组件离开守卫:接下来,任何离开守卫都会被触发,比如在加载的组件上的beforeRouteLeave

  3. 触发全局 beforeEach 守卫:由于全局路由中间件可以通过beforeEach创建,这些函数将在任何路由更新之前被调用。

  4. 在重用组件中触发本地 beforeRouteUpdate 守卫:正如我们之前看到的,每当我们使用不同的参数导航到相同的路由时,生命周期钩子不会被触发两次。相反,我们使用beforeRouteUpdate来触发生命周期更改。

  5. 在组件中触发 beforeRouteEnter:这在导航到任何路由之前每次都会被调用。在这个阶段,组件还没有被渲染,因此无法访问this组件实例。

  6. 解析异步路由组件:然后尝试解析项目中的任何异步组件。这里有一个例子:

const MyAsyncComponent = () => ({
component: import ('./LazyComponent.vue'),
loading: LoadingComponent,
error: ErrorComponent,
delay: 150,
timeout: 3000
})
  1. 在成功激活的组件中触发 beforeRouteEnter

现在我们可以访问beforeRouteEnter钩子,并在解析路由之前执行任何操作。

  1. 触发全局 beforeResolve 钩子:提供了组件内的守卫和异步路由组件已经解析,我们现在可以钩入全局的router.beforeResolve方法,允许我们在这个阶段执行操作。

  2. 导航:所有先前的导航守卫都已触发,用户现在成功导航到了一个路由。

  3. 触发 afterEach 钩子:虽然用户已经导航到了路由,但事情并没有到此为止。接下来,路由器会触发一个全局的afterEach钩子,该钩子可以访问tofrom参数。由于路由在这个阶段已经解析,它没有下一个参数,因此不能影响导航。

  4. 触发 DOM 更新:路由已经解析,Vue 可以适当地触发 DOM 更新。

  5. 在 beforeRouteEnter 中的 next 中触发回调:由于beforeRouteEnter无法访问组件的this上下文,next参数接受一个回调函数,在导航时解析为组件实例。一个例子可以在这里看到:

beforeRouteEnter (to, from, next) {   
 next(comp => {
  // 'comp' inside this closure is equal to the component instance
 }) 

程序化导航

我们不仅限于使用router-link进行模板导航;我们还可以在 JavaScript 中以编程方式将用户导航到不同的路由。在我们的App.vue中,让我们暴露<router-view>并让用户能够选择一个按钮,将他们导航到/hello/hello/:name路由:

<template>
  <div id="app">
    <nav>
      <button @click="navigateToRoute('/hello')">/Hello</button>
      <button 
       @click="navigateToRoute('/hello/Paul')">/Hello/Name</button>
    </nav>
    <router-view></router-view>
  </div>
</template>

然后我们可以添加一个方法,将新的路由推送到路由堆栈中

<script>
export default {
  methods: {
    navigateToRoute(routeName) {
      this.$router.push({ path: routeName });
    },
  },
};
</script>

此时,每当我们选择一个按钮,它应该随后将用户导航到适当的路由。$router.push()函数可以接受各种不同的参数,取决于你如何设置你的路由。这里有一些例子:

// Navigate with string literal
this.$router.push('hello')

// Navigate with object options
this.$router.push({ path: 'hello' })

// Add parameters
this.$router.push({ name: 'hello', params: { name: 'Paul' }})

// Using query parameters /hello?name=paul
this.$router.push({ path: 'hello', query: { name: 'Paul' }})

router.replace

不要推送导航项到堆栈上,我们也可以用 router.replace 替换当前的历史堆栈。以下是一个例子:

this.$router.replace({ path: routeName });

router.go

如果我们想要向后或向前导航用户,我们可以使用 router.go;这本质上是对 window.history API 的抽象。让我们看一些例子:

// Navigate forward one record
this.$router.go(1);

// Navigate backward one record
this.$router.go(-1);

// Navigate forward three records
this.$router.go(3);

// Navigate backward three records
this.$router.go(-3);

延迟加载路由

我们还可以延迟加载我们的路由,以利用 webpack 的代码拆分。这使我们比急切加载路由时拥有更好的性能。为此,我们可以创建一个小型的试验项目。在终端中运行以下命令来执行:

# Create a new Vue project
$ vue init webpack-simple vue-lazy-loading

# Navigate to directory
$ cd vue-lazy-loading

# Install dependencies
$ npm install

# Install Vue Router
$ npm install vue-router

# Run application
$ npm run dev

让我们开始创建两个组件,名为 Hello.vueWorld.vue,在 src/components 中:

// Hello.vue
<template>
  <div>
    <h1>Hello</h1>
    <router-link to="/world">Next</router-link>
  </div>
</template>

<script>
export default {};
</script>

现在我们已经创建了我们的 Hello.vue 组件,让我们创建第二个 World.vue

// World.vue
<template>
  <div>
    <h1>World</h1>
    <router-link to="/hello">Back</router-link>
  </div>
</template>

<script>
export default {};
</script>

然后我们可以像通常一样初始化我们的路由器,在 main.js 中:

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

Vue.use(VueRouter);

主要区别在于导入组件的方式。这需要使用 syntax-dynamic-import Babel 插件。通过在终端中运行以下命令将其安装到项目中:

$ npm install --save-dev babel-plugin-syntax-dynamic-import

然后我们可以更新 .babelrc 来使用新的插件:

{
 "presets": [["env", { "modules": false }], "stage-3"],
 "plugins": ["syntax-dynamic-import"]
}

最后,这使我们能够异步导入我们的组件,就像这样:

const Hello = () => import('./components/Hello');
const World = () => import('./components/World');

然后我们可以定义我们的路由并初始化路由器,这次引用异步导入:

const routes = [
 { path: '/', redirect: '/hello' },
 { path: '/hello', component: Hello },
 { path: '/World', component: World },
];

const router = new VueRouter({
 routes,
});

new Vue({
 el: '#app',
 router,
 render: h => h(App),
});

然后我们可以通过在 Chrome 中查看开发者工具 | 网络选项卡来查看其结果,同时浏览我们的应用程序:

每个路由都添加到自己的捆绑文件中,随后使我们的性能得到改善,因为初始捆绑文件要小得多:

一个单页应用项目

让我们创建一个使用 RESTful API 和我们刚学到的路由概念的项目。在终端中运行以下命令来创建一个新项目:

# Create a new Vue project
$ vue init webpack-simple vue-spa

# Navigate to directory
$ cd vue-spa

# Install dependencies
$ npm install

# Install Vue Router and Axios
$ npm install vue-router axios

# Run application
$ npm run dev

启用路由

我们可以通过在应用程序中启用 VueRouter 插件来开始。为此,我们可以在 src/router 中创建一个名为 index.js 的新文件。我们将使用这个文件来包含所有特定于路由的配置,但根据底层功能将每个路由分离到不同的文件中。

让我们导入并添加路由插件:

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

Vue.use(VueRouter)

定义路由

为了将路由分离到应用程序中的不同文件中,我们首先可以在 src/components/user 下创建一个名为 user.routes.js 的文件。每当我们有一个需要路由的不同功能集时,我们可以创建自己的 *.routes.js 文件,然后将其导入到路由的 index.js 中。

目前,我们只需导出一个新的空数组:

export const userRoutes = [];

然后我们可以将路由添加到我们的 index.js 中(即使我们还没有定义任何路由):

import { userRoutes } from '../components/user/user.routes';

const routes = [...userRoutes];

我们正在使用 ES2015+ 的展开运算符,它允许我们使用数组中的每个对象而不是数组本身。

然后,我们可以初始化路由,创建一个新的 VueRouter 并传递路由,如下所示:

const router = new VueRouter({
  // This is ES2015+ shorthand for routes: routes
  routes,
});

最后,让我们导出路由,以便它可以在我们的主 Vue 实例中使用:

export default router;

main.js 中,让我们导入路由并将其添加到实例中,如下所示:

import Vue from 'vue';
import App from './App.vue';
import router from './router';

new Vue({
 el: '#app',
 router,
 render: h => h(App),
});

创建 UserList 路由

我们应用程序的第一部分将是一个主页,显示来自 API 的用户列表。我们过去曾使用过这个例子,所以你应该对涉及的步骤很熟悉。让我们在 src/components/user 下创建一个名为 UserList.vue 的新组件。

组件将看起来像这样:

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{user.name}}
    </li>
  </ul> 
</template>

<script>
export default {
  data() {
    return {
      users: [
        {
          id: 1,
          name: 'Leanne Graham',
        }
      ],
    };
  },
};
</script>

此时可以随意添加自己的测试数据。我们将很快从 API 请求这些数据。

由于我们已经创建了组件,我们可以在 user.routes.js 中添加一个路由,当激活 '/'(或您选择的路径)时显示此组件:

import UserList from './UserList';

export const userRoutes = [{ path: '/', component: UserList }];

为了显示这个路由,我们需要更新 App.vue,随后将内容注入到 router-view 节点中。让我们更新 App.vue 来处理这个问题:

<template>
 <div>
  <router-view></router-view>
 </div>
</template>

<script>
export default {};
</script>

<style>

</style>

我们的应用程序应该显示单个用户。让我们创建一个 HTTP 实用程序来从 API 获取数据。

从 API 获取数据

src/utils 下创建一个名为 api.js 的新文件。这将用于创建 Axios 的基本实例,然后我们可以在其上执行 HTTP 请求:

import axios from 'axios';

export const API = axios.create({
 baseURL: `https://jsonplaceholder.typicode.com/`
})

然后我们可以使用 beforeRouteEnter 导航守卫,在某人导航到 '/' 路由时获取用户数据:

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{user.name}}
    </li>
  </ul> 
</template>

<script>
import { API } from '../../utils/api';
export default {
  data() {
    return {
      users: [],
    };
  },
  beforeRouteEnter(to, from, next) {
    API.get(`users`)
      .then(response => next(vm => (vm.users = response.data)))
      .catch(error => next(error));
  },
};
</script>

然后我们发现屏幕上显示了用户列表,如下截图所示,每个用户都表示为不同的列表项。下一步是创建一个 detail 组件,注册详细路由,并找到链接到该路由的方法:

创建详细页面

为了创建详细页面,我们可以创建 UserDetail.vue 并按照与上一个组件类似的步骤进行操作:

<template>
  <div class="container">
    <div class="user">
      <div class="user__name">
        <h1>{{userInfo.name}}</h1>
        <p>Person ID {{$route.params.userId}}</p>
        <p>Username: {{userInfo.username}}</p>
        <p>Email: {{userInfo.email}}</p>
      </div>
      <div class="user__address" v-if="userInfo && userInfo.address">
        <h1>Address</h1>
        <p>Street: {{userInfo.address.street}}</p>
        <p>Suite: {{userInfo.address.suite}}</p>
        <p>City: {{userInfo.address.city}}</p>
        <p>Zipcode: {{userInfo.address.zipcode}}</p>
        <p>Lat: {{userInfo.address.geo.lat}} Lng: 
        {{userInfo.address.geo.lng}} </p>
      </div>

      <div class="user__other" >
        <h1>Other</h1>
        <p>Phone: {{userInfo.phone}}</p>
        <p>Website: {{userInfo.website}}</p>
        <p v-if="userInfo && userInfo.company">Company: 
        {{userInfo.company.name}}</p>
      </div>
    </div>
  </div>
</template>

<script>
import { API } from '../../utils/api';

export default {
  data() {
    return {
      userInfo: {},
    };
  },
  beforeRouteEnter(to, from, next) {
    next(vm => 
      API.get(`users/${to.params.userId}`)
        .then(response => (vm.userInfo = response.data))
        .catch(err => console.error(err))
    )
  },
};
</script>

<style>
.container {
 line-height: 2.5em;
 text-align: center;
}
</style>

由于在我们的详细页面中永远不应该有多个用户,因此userInfo变量被创建为 JavaScript 对象而不是数组。

然后我们可以将新组件添加到我们的user.routes.js中:

import UserList from './UserList';
import UserDetail from './UserDetail';

export const userRoutes = [
 { path: '/', component: UserList },
 { path: '/:userId', component: UserDetail },
];

为了链接到这个组件,我们可以在我们的UserList组件中添加router-link

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <router-link :to="{ path: `/${user.id}` }">
      {{user.name}}
      </router-link>
    </li>
  </ul> 
</template>

如果我们然后在浏览器中查看,我们可以看到只有一个用户列出,下面的信息来自于与该用户关联的用户详细信息:

子路由

我们还可以从我们的 API 中访问帖子,因此我们可以同时显示帖子信息和用户信息。让我们创建一个名为UserPosts.vue的新组件:

<template>
  <div>
    <ul>
      <li v-for="post in posts" :key="post.id">{{post.title}}</li>
    </ul>
  </div>
</template>

<script>
import { API } from '../../utils/api';
export default {
  data() {
    return {
      posts: [],
    };
  },
  beforeRouteEnter(to, from, next) {
       next(vm =>
          API.get(`posts?userId=${to.params.userId}`)
          .then(response => (vm.posts = response.data))
          .catch(err => console.error(err))
     )
  },
};
</script>

这允许我们根据我们的userId路由参数获取帖子。为了将此组件显示为子视图,我们需要在user.routes.js中注册它:

import UserList from './UserList';
import UserDetail from './UserDetail';
import UserPosts from './UserPosts';

export const userRoutes = [
  { path: '/', component: UserList },
  {
    path: '/:userId',
    component: UserDetail,
    children: [{ path: '/:userId', component: UserPosts }],
  },
];

然后我们可以在UserDetail.vue组件内部添加另一个<router-view>标签来显示子路由。模板现在看起来像这样:

<template>
  <div class="container">
    <div class="user">
        // Omitted
    </div>
    <div class="posts">
      <h1>Posts</h1>
      <router-view></router-view>
    </div>
  </div>
</template>

最后,我们还添加了一些样式,将用户信息显示在左侧,帖子显示在右侧:

<style>
.container {
  line-height: 2.5em;
  text-align: center;
}
.user {
  display: inline-block;
  width: 49%;
}
.posts {
  vertical-align: top;
  display: inline-block;
  width: 49%;
}
ul {
  list-style-type: none;
}
</style>

如果我们然后转到我们的浏览器,我们可以看到数据的显示方式正如我们计划的那样,用户信息显示在左侧,帖子显示在右侧:

哒哒!我们现在创建了一个具有多个路由、子路由、参数等的 Vue 应用程序!

总结

在这一部分,我们学习了关于 Vue Router 以及如何使用它来创建单页面应用程序。因此,我们涵盖了从初始化路由插件到定义路由、组件、导航守卫等方面的所有内容。我们现在有了必要的知识来创建超越单一组件的 Vue 应用程序。

既然我们扩展了我们的知识并了解了如何使用 Vue Router,我们可以继续在下一章节中处理Vuex中的状态管理。

第二十一章:使用 Vuex 进行状态管理

在本章中,我们将研究使用Vuex进行状态管理模式。Vuex可能并非每个应用程序都需要,但当适合使用它时,了解它的重要性以及如何实现它非常重要。

到本章结束时,您将完成以下工作:

  • 了解了Vuex是什么以及为什么应该使用它

  • 创建了您的第一个 Vuex 存储

  • 调查了 actions、mutations、getters 和 modules

  • 使用 Vue 开发工具逐步执行Vuex的 mutations。

什么是 Vuex?

状态管理是现代 Web 应用程序的重要部分,随着应用程序的增长,管理这种状态是每个项目都面临的问题。Vuex旨在通过强制使用集中式存储来帮助我们实现更好的状态管理,本质上是应用程序中的单一真相来源。它遵循类似于 Flux 和 Redux 的设计原则,并与官方 Vue 开发工具集成,为开发体验提供了很好的支持。

让我们更深入地定义这些术语。

状态管理模式(SMP)

我们可以将状态定义为组件或应用程序中变量/对象的当前值。如果我们将我们的函数视为简单的输入->输出机器,那么这些函数之外存储的值构成了我们应用程序的当前状态。

请注意,我在组件级别应用程序级别状态之间做出了区分。组件级状态可以定义为限定在一个组件内的状态(即,组件内的数据函数)。应用程序级状态类似,但通常在多个组件或服务之间使用。

随着我们的应用程序不断增长,跨多个组件传递状态变得更加困难。我们在本书前面看到,我们可以使用事件总线(即全局 Vue 实例)来传递数据,虽然这样做可以工作,但最好将我们的状态定义为一个集中式存储的一部分。这使我们能够更容易地推理应用程序中的数据,因为我们可以开始定义总是生成状态的新版本的actionsmutations,并且管理状态变得更加系统化。

事件总线是一种简单的状态管理方法,依赖于一个单一的视图实例,并且在小型 Vuex 项目中可能是有益的,但在大多数情况下,应该使用 Vuex。随着我们的应用程序变得更大,使用 Vuex 清晰地定义我们的操作和预期的副作用,使我们能够更好地管理和扩展项目。

如何将所有这些组合在一起的一个很好的例子可以在以下截图中看到(vuex.vuejs.org/en/intro.html):

Vuex 状态流

让我们将这个例子分解成一个逐步过程:

  1. 初始State呈现在 Vue 组件中。

  2. 一个 Vue 组件发送一个Action来从后端 API获取一些数据。

  3. 然后触发一个Commit事件,由一个Mutation处理。这个Mutation返回一个包含来自后端 API的数据的新版本的状态。

  4. 然后可以在 Vue Devtools中看到该过程,并且您可以在应用程序中发生的先前状态的不同版本之间“时间旅行”。

  5. 新的State然后呈现在Vue 组件中。

因此,我们的 Vuex 应用程序的主要组件是存储,它是我们所有组件的单一真相来源。存储可以被读取,但不能直接改变;它必须有变化函数来进行任何更改。虽然这种模式一开始可能看起来很奇怪,如果您以前从未使用过状态容器,但这种设计允许我们以一致的方式向我们的应用程序添加新功能。

由于 Vuex 是原生设计用于与 Vue 一起工作的,存储默认是响应式的。这意味着在存储内部发生的任何更改都可以实时看到,而无需任何黑客技巧。

思考状态

作为一个思考练习,让我们首先定义我们应用程序的目标以及任何状态、操作和潜在的变化。您现在不必将以下代码添加到您的应用程序中,所以请随意阅读,我们将在最后将所有内容整合在一起。

让我们首先将状态视为键/值对的集合:

const state = {
 count: 0 // number
}

对于我们的计数器应用程序,我们只需要一个状态元素 - 当前计数。这可能有一个默认值为0,并且是一个数字类型。由于这可能是应用程序内唯一的状态,您可以考虑此状态在这一点上是应用程序级别的。

接下来,让我们考虑用户可能想要执行的任何动作类型。

然后,这三种动作类型可以被分派到存储中,因此我们可以执行以下变异,每次返回一个新版本的状态:

  • 增量:将当前计数器加一(0 -> 1)

  • 减量:从当前计数器中减去一个(1 -> 0)

  • 重置:将当前计数器重置为零(n -> 0)

我们可以想象,在这一点上,我们的用户界面将被更新为我们计数的正确绑定版本。让我们实现这一点,使其成为现实。

使用 Vuex

现在我们已经详细了解了由Vuex驱动的应用程序的组成部分,让我们创建一个游乐项目来充分利用这些功能!

在您的终端中运行以下命令:

# Create a new Vue project
$ vue init webpack-simple vuex-counter

# Navigate to directory
$ cd vuex-counter

# Install dependencies
$ npm install

# Install Vuex
$ npm install vuex

# Run application
$ npm run dev

创建一个新存储

让我们首先创建一个名为index.js的文件,在src/store内。这是我们将用来创建新存储并汇集各种组件的文件。

我们可以首先导入VueVuex,并告诉 Vue 我们想要使用Vuex插件:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

然后,我们可以导出一个包含所有应用程序状态的状态对象的新Vuex.Store。我们导出这个,以便在必要时在其他组件中导入状态:

export default new Vuex.Store({
  state: {
    count: 0,
  },
}); 

定义动作类型

然后,我们可以在src/store内创建一个名为mutation-types.js的文件,其中包含用户可能在我们的应用程序中执行的各种动作:

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

虽然我们不必明确地定义我们的动作,但尽可能使用常量是一个好主意。这使我们能够更好地利用工具和 linting 技术,并且使我们能够一目了然地推断整个应用程序中的动作。

动作

我们可以使用这些动作类型来提交一个新的动作,随后由我们的 mutations 处理。在src/store内创建一个名为actions.js的文件:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    commit(types.INCREMENT);
  },
  types.DECREMENT {
    commit(types.DECREMENT);
  },
  types.RESET {
    commit(types.RESET);
  },
};

在每个方法内部,我们正在解构返回的store对象,只取commit函数。如果我们不这样做,我们将不得不像这样调用commit函数:

export default {
 types.INCREMENT {
  store.commit(types.INCREMENT);
 }
}

如果我们重新查看我们的状态图,我们可以看到在提交一个动作后,该动作被变异器接收。

变异

变异是存储状态可以改变的唯一方法;这是通过提交/分派一个动作来完成的,如前所述。让我们在src/store内创建一个名为mutations.js的新文件,并添加以下内容:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    state.count++;
  },
  types.DECREMENT {
    state.count--;
  },
  types.RESET {
    state.count = 0;
  },
};

您会注意到,我们再次使用我们的操作类型来定义方法名;这是可能的,因为 ES2015+中的一个新功能名为计算属性名。现在,每当提交/分发一个操作时,变更器将知道如何处理这个操作并返回一个新的状态。

Getter

现在我们可以提交操作,并使这些操作返回状态的新版本。下一步是创建 getter,以便我们可以在整个应用程序中返回状态的切片部分。让我们在src/store内创建一个名为getters.js的新文件,并添加以下内容:

export default {
  count(state) {
    return state.count;
  },
};

由于我们有一个微小的示例,因此并不完全需要为此属性使用 getter,但是随着应用程序的扩展,我们将需要使用 getter 来过滤状态。将这些视为状态中值的计算属性,因此,如果我们想要返回此属性的修改版本以供视图层使用,我们可以这样做:

export default {
  count(state) {
    return state.count > 3 ? 'Above three!' : state.count;
  },
};

组合元素

为了将所有这些内容整合在一起,我们必须重新访问我们的store/index.js文件,并添加适当的stateactionsgettersmutations

import Vue from 'vue';
import Vuex from 'vuex';

import actions from './actions';
import getters from './getters';
import mutations from './mutations';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    count: 0,
  },
  actions,
  getters,
  mutations,
});

在我们的App.vue中,我们可以创建一个template,它将给我们当前的计数以及一些按钮来增加减少重置状态:

<template>
  <div>
    <h1>{{count}}</h1>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">R</button>
  </div>
</template>

每当用户点击按钮时,都会从以下方法之一内部分发一个操作。

import * as types from './store/mutation-types';

export default {
  methods: {
    increment() {
      this.$store.dispatch(types.INCREMENT);
    },
    decrement() {
      this.$store.dispatch(types.DECREMENT);
    },
    reset() {
      this.$store.dispatch(types.RESET);
    },
  },
}

再次使用常量可以提供更好的开发体验。接下来,为了利用我们之前创建的 getter,让我们定义一个computed属性:

export default {
  // Omitted
  computed: {
    count() {
      return this.$store.getters.count;
    },
  },
}

然后,我们有一个应用程序,显示当前计数并可以增加、减少或重置:

负载

如果我们想让用户决定要增加计数的数量怎么办?假设我们有一个文本框,我们可以在其中添加一个数字并按照该数字增加计数。如果文本框设置为0或为空,我们将增加计数1

因此,我们的模板将如下所示:

<template>
  <div>
    <h1>{{count}}</h1>

    <input type="text" v-model="amount">

    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">R</button>
  </div>
</template>

我们将在本地组件状态上放置金额值,因为这并不一定需要成为主要的 Vuex 存储的一部分。这是一个重要的认识,因为这意味着如果有必要,我们仍然可以拥有本地数据/计算值。我们还可以更新我们的方法,将金额传递给我们的操作/变更:

export default {
  data() {
    return {
      amount: 0,
    };
  },
  methods: {
    increment() {
      this.$store.dispatch(types.INCREMENT, this.getAmount);
    },
    decrement() {
      this.$store.dispatch(types.DECREMENT, this.getAmount);
    },
    reset() {
      this.$store.dispatch(types.RESET);
    },
  },
  computed: {
    count() {
      return this.$store.getters.count;
    },
    getAmount() {
      return Number(this.amount) || 1;
    },
  },
};

然后我们必须更新actions.js,因为现在它接收state对象和我们的amount作为参数。当我们使用commit时,让我们也将amount传递给 mutation:

import * as types from './mutation-types';

export default {
  types.INCREMENT {
    commit(types.INCREMENT, amount);
  },
  types.DECREMENT {
    commit(types.DECREMENT, amount);
  },
  types.RESET {
    commit(types.RESET);
  },
};

因此,我们的突变看起来与以前类似,但这次我们根据数量递增/递减:

export default {
  types.INCREMENT {
    state.count += amount;
  },
  types.DECREMENT {
    state.count -= amount;
  },
  types.RESET {
    state.count = 0;
  },
};

哒哒!现在我们可以根据文本值递增计数:

Vuex 和 Vue 开发工具

现在,我们有了一种一致的通过操作与存储进行交互的方式,我们可以利用 Vue 开发工具来查看我们随时间推移的状态。

我们将使用计数器应用程序作为示例,以确保您已经运行了这个项目,并在 Chrome(或您的浏览器的等效部分)中右键单击检查元素。如果我们转到 Vue 选项卡并选择 Vuex,我们可以看到计数器已加载初始应用程序状态:

从前面的屏幕截图中,您可以看到计数状态成员以及任何 getter 的值。让我们点击递增按钮几次,看看会发生什么:

太棒了!我们可以看到 INCREMENT 操作以及对状态和 getter 的后续更改,以及有关突变本身的更多信息。让我们看看如何在我们的状态中进行时间旅行:

在前面的屏幕截图中,我选择了第一个操作上的时间旅行按钮。然后您可以看到我们的状态恢复为计数:1,并且这反映在其余的元数据中。然后应用程序将更新以反映状态的更改,因此我们可以逐个步骤地查看每个操作并在屏幕上查看结果。这不仅有助于调试,而且我们向应用程序添加的任何新状态都将遵循相同的过程,并以这种方式可见。

让我们点击一个操作上的提交按钮:

正如您所看到的,这将合并我们的所有操作,直到我们点击提交,然后成为我们的基本状态的一部分。因此,计数属性等于您提交给基本状态的操作。

模块和可扩展性

目前,我们的所有东西都在根状态中。随着我们的应用程序变得越来越大,利用模块是个好主意,这样我们可以适当地将我们的容器分成不同的块。让我们通过在store文件夹中创建一个名为modules/count的新文件夹,将我们的计数状态转换为自己的模块。

然后,我们可以将actions.jsgetters.jsmutations.jsmutation-types.js文件移动到计数模块文件夹中。这样做后,我们可以在文件夹内创建一个index.js文件,仅导出这个模块的stateactionsgettersmutations

import actions from './actions';
import getters from './getters';
import mutations from './mutations';

export const countStore = {
  state: {
    count: 0,
  },
  actions,
  getters,
  mutations,
};

export * from './mutation-types';

我还选择从index.js文件中导出突变类型,这样我们可以通过仅从store/modules/count导入,按模块的方式在我们的组件中使用这些类型。由于在这个文件中导入了多个东西,我给存储起了名字countStore。让我们在store/index.js中定义新模块:

import Vue from 'vue';
import Vuex from 'vuex';
import { countStore } from './modules/count';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    countStore,
  },
});

我们的App.vue稍作修改;我们不再引用 types 对象,而是直接从这个模块中引用 types:

import * as fromCount from './store/modules/count';

export default {
  data() {
    return {
      amount: 0,
    };
  },
  methods: {
    increment() {
      this.$store.dispatch(fromCount.INCREMENT, this.getAmount);
    },
    decrement() {
      this.$store.dispatch(fromCount.DECREMENT, this.getAmount);
    },
    reset() {
      this.$store.dispatch(fromCount.RESET);
    },
  },
  computed: {
    count() {
      return this.$store.getters.count;
    },
    getAmount() {
      return Number(this.amount) || 1;
    },
  },
};

然后,我们可以通过与我们的计数示例相同的文件/结构来向我们的应用程序添加更多的模块。这使我们能够随着应用程序的持续增长而扩展。

总结

在这一章中,我们利用了Vuex库来实现 Vue 中的一致状态管理。我们定义了什么是状态,以及组件状态和应用程序级状态。我们学会了如何适当地将我们的操作、获取器、突变和存储在不同的文件中进行分割,以便扩展,以及如何在组件中调用这些项目。

我们还学习了如何使用Vuex的 Vue devtools 来逐步查看应用程序中发生的突变。这使我们能够更好地调试/推理我们在开发应用程序时所做的决定。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报