Vue2-示例-全-

Vue2 示例(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将介绍 Vue.js 2 的使用。Vue 可以作为前端框架通过包含一个 JS 文件来使用,也可以作为后端框架与 Node.js 一起使用。本书是使用前端版本的框架编写的,尽管会指出,如果需要的话,它可以很容易地转换为使用 Node 版本,因为这两个版本的框架的基本原理是相同的。

Vue 是一个可以用于简单数据显示和创建完整 Web 应用程序的框架。本书将尝试涵盖这两个方面,并介绍插件和附加组件,以帮助创建更大的应用程序。

本书还将介绍使用 Vue 组件的方法,包括使用它们而不是将所有数据和方法都包含在主 Vue 实例中的好处。本书还将介绍使用 Vue 的两个最流行的插件:Vuex 和 Vue-Router。本书不涵盖应用程序的样式处理过程。

Vuex 是 Vue 的集中式状态管理模式和库。它使存储、操作和访问数据变得更加可管理,并且非常适用于需要显示大量数据的应用程序。Vue-Router 用于处理应用程序的导航,根据 URL 加载不同的组件。

从一个 JSON 数据集开始,本书的第一部分将涵盖 Vue 对象及其如何利用每个对象。通过探索从 JSON 数据集中显示数据的不同方式来进行讲解。然后我们将继续使用过滤器和搜索来操作数据,并创建动态值。

完成后,我们将学习如何通过 API 动态加载数据,以 Dropbox API 为例。数据加载完成后,本书将介绍如何在文件夹之间导航,同时更新 URL 并创建文件的下载链接。然后,我们将加载 Vuex,并学习如何存储每个文件夹的数据,然后进行预缓存文件夹,使应用程序的导航速度更快。

最后,我们将学习如何使用之前项目中学到的技能以及引入新技能来创建一个电子商务前端。首先,产品将以列表的形式显示;使用过滤器和搜索,您将能够点击产品以获取更多信息并将其添加到购物篮中。准备好后,"客户"将能够查看他们的购物篮,更新商品和数量,并最终结账。

本书内容

第一章,“开始使用 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 Store 中存储和检索数据。然后,它介绍了如何将 Vuex 与我们的 Dropbox 应用程序集成,如何缓存当前 Dropbox 文件夹的内容,并在需要时从存储中加载数据。

第七章,“预缓存其他文件夹和文件以实现更快的导航”,描述了预缓存文件夹的过程,存储父文件夹的内容,以及如何缓存文件的下载链接。

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

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

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

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

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

您需要为本书准备的内容

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

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

  • 一个网络浏览器。

  • 一个带有文件和文件夹的 Dropbox 用户帐户。

本书适用对象

本书适用于熟悉 JavaScript 但希望探索 JavaScript MVVM 框架用于单页应用程序SPA)的开发人员。他们应该熟悉 HTML 并熟悉 CSS,以便能够构建和样式化 SPA 的界面。本书将引导读者从初始化 Vue 及其基本功能一直到使用高级 Vue 插件和技术。读者应该熟悉 JavaScript 函数和变量以及使用 ES6/ES2015 箭头函数的用法。

本书的约定

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

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

代码块设置如下:

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

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

 app.salesTax = 20

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

警告或重要提示以如下方式出现在一个框中。技巧和窍门以如下方式出现。

第一章:开始使用 Vue.js

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

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

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

在本书的第一部分中,我们将构建一个使用 JSON 字符串来显示数据的应用程序。然后,我们将研究数据过滤和操作,然后转向构建可重用组件以减少代码重复。

在本章中,我们将讨论以下内容:

  • 如何通过包含 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 模板语言来输出数据或变量。通过将变量名放在双花括号{{ 变量 }}之间来实现。逻辑语句,如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对象来实现这一点。使用这种技术,我们能够开始遵循 MVVM(模型-视图-视图模型)方法论。

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;
           }
        }
     });

虽然乍一看可能很高级,但该代码是将固定价格与添加了销售税后的价格进行计算。pricesalesTaxcurrency符号都存储为数据对象上的值,并在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 中的datacomputedmethod对象以及如何显示静态数据值。在本章中,我们将介绍以下内容:

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

  • 使用表单元素过滤列表

  • 根据数据应用条件性的 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>'
        }
      });

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

声明式渲染

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

bind选项通过在要填充的属性前面添加属性来使用。例如,如果你想使用名为imageSource的数据键的值填充图像源,你可以这样做:

视图

在视图应用空间中创建一个带有动态src属性的 img 标签,使用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指令-根据条件确定是否显示块的值或函数。

在视图中创建一个带有单个div的 Vue 实例,并设置一个名为isVisible的数据键,值为false

视图

从以下视图代码开始:

      <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 显示属性而不是操作 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,则可能会看到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-elsev-if选项,允许我们使用所需的 HTML,它将如下所示:

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

这个活动元素是 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 应用程序现在应该是这样的:

      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 知道将字符串解释为日期。一旦存储在注册变量中,我们使用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(活动用户)选项时,将显示单选按钮,并隐藏输入框。我们已经确保默认选择了第一个单选按钮以帮助用户。

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

绑定输入

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

      data: {
        people: [...],

        currency: '$',

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

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

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

以下是一个例子:

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

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

尝试输出filterFieldfilterQuery并更改字段。

      {{ filterField }}

如果你输出filterUserState变量,你可能会注意到它似乎在工作,但实际上它没有得到期望的结果。变量的输出将是根据 value 属性设置的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 中传递进来的人。在你的视图中,给<tr>元素添加v-show属性,值为filterRow,同时传入人物对象:

      <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 种方式中了解更多关于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>

请注意输入字段上方法调用之前的感叹号。这表示否定,并有效地颠倒了函数的结果,例如not true等同于false,反之亦然。

为了改进用户体验,我们还可以在显示任何输入之前检查过滤是否处于活动状态。这可以通过在我们的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>

类 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类。例如,$2,600 的余额将同时获得warningincreasing类,而$2,400 只会获得warning类。

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

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

在该方法中,我们需要访问传入的 person 的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。Vue 不会为传入数组的false值输出任何内容。

为了确定我们是否需要增加的类,我们需要对余额进行一些计算。因为我们希望如果余额超过 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;
      }

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

组合格式函数

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

自动检测格式化

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

      {{ format(person.balance) }}

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

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

      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语句中添加两个 case,一个是balance,另一个是registered。由于我们不希望在输入不匹配 case 时发生任何操作,所以我们不需要有一个default语句:

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

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

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

      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,或者我们可以在现有代码中添加新的 case。例如,如果我们的数据包含一个字段,详细说明用户停用帐户的日期,我们可以轻松地以与注册日期相同的格式显示它:

      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 实例,因为每个组件都有自己的数据、方法和计算属性对象,还有一些特定于组件的选项,我们很快就会介绍。当涉及到创建具有不同页面和部分的应用程序时,组件也非常有用,这将在第八章《介绍 Vue-Router 和加载基于 URL 的组件》中介绍。

当注册一个组件时,您需要创建一个自定义的 HTML 元素来在视图中使用,例如:

      <my-component></my-component>

在命名组件时,可以使用短横线命名法(连字符)、帕斯卡命名法(没有标点符号,但每个单词首字母大写)或驼峰命名法(类似于帕斯卡命名法,但第一个单词首字母小写)。Vue 组件不受 W3C Web 组件/自定义元素规则的限制或关联,但按照使用短横线命名法的惯例是一个好的做法。

创建和初始化您的组件

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对象,并暂时将一个空的<div>添加到template属性中:

      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对象,将货币符号附加到整数上,并确保有两位小数。不要忘记将货币符号添加到您的 data 函数中。

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

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

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

向组件传递数据 - props

将余额作为一个组件是很好的,但如果余额是固定的,那就不太好了。当您通过 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,以便我们可以将成本作为 HTML 属性传递。您的视图现在应该是这样的:

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

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

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

然而,在浏览器中运行这个模板会在 JavaScript 控制台中抛出一个错误。这是因为,原生地,传入的 props 被解释为字符串。我们可以通过两种方式解决这个问题;要么在formatCost()函数中将我们的 prop 转换为数字,要么使用v-bind: HTML 属性告诉 Vue 接受输入的内容。

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

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

我们可以采取额外的步骤来确保 Vue 知道要期望什么样的输入,并通知其他用户您的代码应该传递什么。这可以在组件本身中完成,并且除了格式之外,还允许您指定默认值以及属性是否为必需的。

将您的props数组转换为一个对象,其中cost作为键。如果您只是定义字段类型,可以使用 Vue 的简写方式来声明,将值设置为字段类型。这些可以是字符串、数字、布尔值、函数、对象、数组或符号。由于我们的成本属性应该是一个数字,所以将其添加为键:

      props: {
 cost: Number
 },

如果我们的组件在未定义任何内容时不抛出错误,而是渲染$0.00,那将很好。我们可以通过将默认值设置为0来实现这一点。要定义默认值,我们需要将我们的 prop 转换为一个对象本身-包含一个type键,其值为Number。然后,我们可以定义另一个default键,并将值设置为0

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

在浏览器中渲染组件应该显示传递到成本属性的任何值,但是如果删除此属性,将显示$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,允许传入人员对象。为了验证目的,只指定它可以是一个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作为每个人的变量。它们可以相同,但如果变量、组件和 props 具有不同的名称,代码会更容易阅读。将v-for更新为v-for="individual in people"

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

在视图的template标签中,添加一个新的team-member组件实例,将individual变量传递给person prop。不要忘记在 person prop 前添加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;
          }
        }
      });

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

使过滤器与 props 再次正常工作

在模板中的包含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的单个属性。在你的组件上创建一个新的 prop,命名为filter,并确保只允许传递一个Object

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

然后我们可以将这个 prop 添加到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 通过自定义事件来解决这个问题。这些事件允许你将数据传递(或"emit")给父组件或其他组件。

我们将创建一个过滤组件,当过滤器发生变化时,将数据传递回父 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 实例上的过滤器对象。从组件中删除data对象,并通过 prop 传递父对象 - 就像我们在team-member组件中所做的那样:

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

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

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

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

为了将数据传递给父 Vue 实例上的 changeFilter 方法,我们需要在我们的<filtering>元素中添加一个新的 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() {
        }
      }

关键是你想要监视的变量。由于我们的变量包含一个点,所以需要用引号括起来。在这个函数中,创建一个名为change-filter-query的新的$emit事件,输出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,并将team-member组件中的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 promises,这是一种在不需要回调函数的情况下向代码添加操作的方法。如果您对 promises 不熟悉,请查看 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 class="calibre31">1kb</q>而不是<q class="calibre31">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 文件夹的文件和文件夹内容。现在我们需要使我们的应用程序易于导航。这意味着用户将能够点击文件夹名称以导航到并列出其内容,并且还能够下载文件。

在继续之前,请确保在 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属性:

      <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 文档。

当点击时,将触发导航方法,该方法使用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);
      }

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

从当前路径创建面包屑

在导航文件夹或任何嵌套结构时,始终有一个可用的面包屑对用户来说是很好的,这样他们就知道自己在哪里,走了多远,还可以轻松返回到以前的文件夹。我们将为面包屑制作一个组件,因为它将具有各种属性、计算函数和方法。

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

例如,如果我们有文件夹结构img/holiday/summer/iphone,我们希望能够点击Holiday并使应用程序导航到img/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属性现在包含我们所在位置的完整路径(例如 img/holiday/summer)。我们想要将这个字符串分解,以便我们可以识别文件夹名称并构建面包屑供组件渲染。

在组件上创建一个computed对象,并创建一个名为folders()的新函数。这将为我们创建面包屑数组,供我们在模板中循环使用:

      computed: {
       folders() {   
        }
      }

现在我们需要设置一些变量供我们使用。创建一个新的空数组output,这是我们要构建面包屑的地方。我们还需要一个空的字符串变量titled slugslug变量是 URL 的一部分,它的使用在 WordPress 中很流行。最后一个变量是作为数组创建的路径。我们知道,每个文件夹都是由/分隔的,我们可以使用这个来将字符串分解成各个部分:

      computed: {
        folders() {
 let output = [],
 slug = '',
 parts = this.p.split('/');
        }
      }

如果我们查看Summer文件夹的parts变量,它将如下所示:

      ['images', 'holiday', 'summer']

现在我们可以循环遍历数组来创建面包屑。每个面包屑项将是一个对象,包含个别文件夹的name,例如holidaysummer,以及slug,前者为 img/holiday,后者为 img/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;
      }

这个循环通过以下步骤创建我们的面包屑。以 img/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':img/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变量更新为包含fi的一对括号,用逗号分隔。变量f是循环中的当前文件夹,而已创建的变量i是项目的索引。请记住,数组索引从 0 开始,而不是从 1 开始。

我们添加的分隔符包含在一个带有v-if属性的 span 标签中,其内容可能看起来很困惑。这是将当前索引与folders数组的长度(它有多少项)减 1 混淆在一起。减 1 是因为索引从 0 开始,而不是从 1 开始,这是您所期望的。如果数字不匹配,则显示span元素。

我们需要做的最后一件事是使面包屑导航到选定的文件夹。我们可以通过调整我们为“文件夹”组件编写的导航函数来实现这一点。然而,由于我们的整个组件是面包屑,而不是每个单独的链接,我们需要修改它以接受一个参数。

首先,为链接添加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.comimg/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方法。

由于我们在显示之前不会循环遍历或编辑文件夹对象,所以我们可以在模板中添加#。由于我们告诉 Vuehref绑定到一个 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 缓存当前文件夹结构,并在我们的应用程序中引入文件夹缓存,我们可以对 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实例在页面加载和哈希变化时具有正确的路径。为了避免重复,我们将使用一个方法和一个created函数来扩展我们的Vue实例。

将路径变量设置为空字符串。创建一个名为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 Dev Tools 和测试您的 SPA》中介绍的内容。通过集成该库,可以轻松调试和查看存储的当前和过去状态。开发工具反映状态变化或数据更新,并允许您检查存储的每个部分。

如前所述,Vuex 是一种状态管理模式,是您的 Vue 应用程序的真相来源。例如,跟踪购物篮或已登录用户对于某些应用程序至关重要,如果这些数据在组件之间不同步,可能会造成严重问题。而且,如果没有使用父组件来处理数据交换,就无法在子组件之间传递数据。Vuex 通过处理数据的存储、变化和操作来消除这种复杂性。

刚开始使用 Vuex 时,可能会觉得非常冗长,似乎超出了所需的范围;然而,这是一个很好的例子,可以帮助我们熟悉这个库。有关 Vuex 的更多信息可以在它们的文档中找到。

对于我们的 Dropbox 应用程序,可以利用 Vuex 存储文件夹结构、文件列表和下载链接。这意味着如果用户多次访问同一个文件夹,API 将不需要查询,因为所有信息已经存储。这将加快文件夹的导航速度。

包括和初始化 Vuex

Vuex 库的包含方式与 Vue 本身相同。您可以使用之前提到的 unpkg 服务(unpkg.com/vuex)使用托管版本,或者您可以从他们的github.com/vuejs/vuex下载 JavaScript 库。

在 HTML 文件底部添加一个新的<script>块。确保在应用程序 JavaScript 之前,但在vue.js库之后包含 Vuex 库:

<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的新变量,并初始化Vuex.Store类,将其分配给该变量:

const store = new Vuex.Store({

});

初始化 Vuex 存储后,我们现在可以使用store变量来利用其功能。使用store,我们可以访问其中的数据,并通过 mutations 修改该数据。使用独立的store,许多 Vue 实例可以更新相同的store;这在某些情况下可能是需要的,但在其他情况下可能是一个不希望的副作用。

为了避免这种情况,我们可以将存储与特定的 Vue 实例关联起来。这是通过将store变量传递给我们的 Vue 类来完成的。这样做还将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

利用存储

为了帮助我们掌握如何使用存储,让我们将当前存储在父 Vue 实例上的path变量移动起来。在开始编写和移动代码之前,有一些使用 Vuex 存储时不同的短语和词汇,我们应该熟悉一下:

  • state:这是存储等效数据对象;原始数据存储在此对象中。

  • getters:这些是 Vuex 中与计算值相当的对象;store的函数可以在返回给组件使用之前处理原始状态值。

  • mutations:Vuex 不允许直接在store之外修改 state 对象,必须通过变异处理程序来完成;这些是store上的函数,允许更新状态。它们总是以state作为第一个参数。

这些对象直接属于store。然而,更新store并不像调用store.mutationName()那样简单。相反,我们必须使用一个新的commit()函数来调用该方法。该函数接受两个参数:变异的名称和传递给它的数据。

虽然最初很难理解,但是 Vuex store 的冗长性允许强大的功能。下面是一个使用 store 的示例,将第一章《开始使用 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 的 state 作为第一个参数。

要使用这个store,你可以这样做:

new Vue({
  el: '#app',

  store,
  computed: {
    message() {
      return this.$store.state.message
    },
    formatted() {
      return this.$store.getters.message
    }
  }
});

检索消息

{{ message }}计算函数中,我们从 state 对象中检索了原始的、未经处理的消息,并使用了以下路径:

this.$store.state.message

这实际上是访问store,然后是 state 对象,然后是 message 对象键。

类似地,{{ formatted }}计算值使用store的 getter,将字符串转换为小写。这是通过访问getters对象来检索的:

this.$store.getters.message

更新消息

要更新消息,您需要调用commit函数。这个函数接受方法名称作为第一个参数,载荷或数据作为第二个参数。如果需要传递多个变量,载荷可以是一个简单的变量、数组或对象。

store中的updateMessage变异接受一个参数,并将消息设置为相等,所以要更新我们的消息,代码应该是:

store.commit('updateMessage', 'VUEX Store');

这可以在应用程序的任何地方运行,并且会自动更新之前使用的值,因为它们都依赖于同一个store

现在返回我们的 message getter 将返回 VUEX Store,因为我们已经更新了 state。考虑到这一点,让我们更新我们的应用程序,使用 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。

更新 breadcrumb 组件

从 HTML 中删除:p prop,只留下一个简单的 breadcrumb 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()的新方法,并将代码从then()函数内部移动到 Dropbox 的filesListFolder方法之后。在此函数内部调用新方法。

现在,您的两个方法应该如下所示,并且您的应用程序应该仍然正常工作:

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);
  });
}

使用 Promise,我们可以将createFolderStructure作为 API 调用的操作。

下一步是存储正在处理的数据。为此,我们将利用将对象传递给存储的commit函数的能力,并将路径用作存储对象中的键。我们将不会嵌套文件结构,而是将信息存储在一个扁平的结构中。例如,在浏览了几个文件夹后,我们的存储将如下所示:

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 函数执行以下操作。例如路径 img/iPhone/mom's Birthday - 40th`将受到以下影响:

  • 将字符串转换为小写:img/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
}

这使我们能够根据数据是否存在于存储中来加载数据,使用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。它应该接受响应变量作为唯一参数:

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 来存储当前文件夹路径和我们访问过的文件夹的内容。

本章将讨论以下内容:

  • 预缓存不仅用户当前所在的文件夹,还包括子文件夹。这将通过循环遍历当前显示的文件夹并检查它们是否已经被缓存来完成。如果没有,我们可以从 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的新方法。它将接受一个参数,即路径,并返回使用 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 中返回,将结果存储在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方法显示数据。

现在我们的数据已经准备好被缓存并从存储中提供,我们可以继续使用我们的新方法display数据。在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 属性上启用更多选项,您需要将其传递给一个对象,而不是直接的函数。

为结构创建一个新的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);
 }
  }
}

通过这段简单的代码,我们的应用程序似乎加快了十倍。您导航到的每个子文件夹都会立即加载(除非您有一个特别长的文件夹列表,并且您快速导航到最后一个文件夹)。为了让您了解缓存的速度和时间,将console.log()添加到您的getFolderStructure方法中,并打开浏览器开发者工具:

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。我们将称之为 prop cache,并将函数作为值传递。由于属性是动态的,请不要忘记添加一个前导冒号:

<folder :f="entry" :cache="getFolderStructure"></folder>

在 JavaScript 的folder组件中,在 props 键中添加cache关键字。告诉 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 中的哈希进入子文件夹。

缓存父文件夹

缓存父级结构是我们可以采取的下一个预防措施,以帮助加快应用程序的速度。假设我们已经导航到了我们的图像目录img/holiday/summer,并希望与朋友或同事共享。我们会将带有此 URL 的 URL 哈希发送给他们,在页面加载时,他们将看到内容。如果他们然后使用面包屑导航到img/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()会发现,每次导航到一个文件夹时,面包屑都会重新挂载。这是因为视图中的v-if语句会每次删除和添加 HTML。

由于我们只需要在初始加载应用程序时缓存父文件夹一次,让我们考虑更改触发缓存的位置。我们只需要在第一次运行这个函数;一旦用户开始在树上向上和向下导航,沿途访问的所有文件夹都将被缓存。

缓存父文件夹一次

为了确保我们使用的资源最少,我们可以将面包屑使用的文件夹数组保存在存储中。这意味着breadcrumb组件和我们的父级缓存函数可以访问同一个数组。

在存储状态中添加一个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 实例生命周期,我们可以看到 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,它也等于data.link,即来自 API 的下载链接。通过在导航到文件夹时存储和显示此信息,我们可以添加一个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 用于构建单页应用程序(SPAs),允许您将组件映射到 URL,或者在VueRouter术语中,路由和路径。这是一个非常强大的插件,处理了处理 URL 所需的许多复杂细节。

本章将涵盖以下内容:

  • 初始化 Vue-router 及其选项

  • 使用 Vue-router 创建链接

  • 创建动态路由以根据 URL 更新视图

  • 使用 URL 的 props

  • 嵌套和命名路由

  • 如何使用 Vue-router 进行编程导航

安装和初始化 Vue-router

与我们向应用程序添加 Vue 和 Vuex 的方式类似,您可以直接从 unpkg 中包含该库,或者转到以下 URL 并下载一个本地副本:unpkg.com/Vue-router。将 JavaScript 与 Vue 和应用程序的 JavaScript 一起添加到新的 HTML 文档中。还要创建一个应用程序容器元素作为您的视图。在下面的示例中,我将 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>

不要忘记将所有内容封装在一个"root"元素中(在这里用包装的<div>标签表示)。你还需要确保在加载应用程序 JavaScript 之前声明模板。

我们创建了一个 Home 页面模板,idhomepage,和一个 About 页面,包含一些来自lorem ipsum的占位文本,idabout。在你的 JavaScript 中创建两个引用这两个模板的组件:

const Home = {
  template: '#homepage'
};

const About = {
  template: '#about'
};

下一步是给路由器一个占位符来渲染视图中的组件。这可以通过使用自定义的<router-view>HTML 元素来实现。使用这个元素可以控制内容的渲染位置。它允许我们在应用视图中直接拥有一个 header 和 footer,而不需要处理混乱的模板或包含组件本身。

在你的应用程序中添加一个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 变量。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应该显示与之前相同的句子。在某些情况下,通过“幕后”(不使用 URL)传递props是理想的,这样您可能不希望用户共享特定的 URL 或根据易于更改的参数更改应用程序的状态。

嵌套路由

嵌套路由与子路由不同,因为它们存在于已经匹配路由开始部分的组件中。这允许您在现有视图中显示不同的内容。

一个很好的例子是 Twitter。如果您访问 Twitter 用户的个人资料页面,您可以查看他们关注的人,关注他们的人以及他们创建的列表。如果您在浏览页面时观察 URL,您会注意到一个重复的模式:用户名后跟不同的页面。嵌套路由和子路由之间的区别在于,嵌套路由允许您在不同的子页面中保持组件相同(例如,标题和侧边栏)。

这样做的好处是用户可以收藏和分享链接,使页面更易访问,并且有利于 SEO。使用简单的切换或选项卡框来显示视图中的不同内容很难实现这些优势。

要将 Twitter 模式复制到 Vue 路由中,它将如下所示:

https://twitter.com/:user/:page

如果我们使用之前的路由方法创建这个,我们将不得不为每个页面构建组件,这些组件在其模板中包含侧边栏中的标题和用户信息-如果您需要更新代码,那将是一种痛苦!

让我们为我们的关于页面创建一些嵌套路由。在我们的商店应用程序中,我们不会使用嵌套路由,但了解 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>

在浏览器中查看关于页面不会渲染任何内容,也不会破坏应用程序。下一步是为这些组件添加嵌套路由到路由器中。我们不是将它们添加到顶级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数组。转到浏览器并导航到关于页面。在 URL 的末尾添加/contact/food,注意新内容出现在您之前添加到模板中的<router-link>元素的位置。

可以从任何地方创建到这些组件的链接,方式与您链接主页和关于页面的方式相同。您可以将它们添加到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列为组件。

例如,如果您尝试在 food 组件中输出一个不可用的变量{{ 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 路由器还允许您为视图命名,从而可以将不同的组件传递给应用程序的不同部分。例如,商店可能会有侧边栏和主要内容区域。不同的页面可以以不同的方式利用这些区域。

关于页面可以使用主要内容显示关于内容,同时使用侧边栏显示联系方式。然而,商店页面将使用主要内容列出产品,并使用侧边栏显示过滤器。

为此,创建第二个router-view元素作为原始元素的兄弟元素。保留原始元素的位置,但在第二个元素上添加一个name属性,以适当的标题命名:

<main>
  <router-view></router-view>
</main>

<aside>
    <router-view name="sidebar"></router-view>
</aside>

在路由器实例中声明路由时,我们现在将使用一个新的键components,并删除之前的单数component键。这个键接受一个对象,其中包含视图的名称和组件的名称的键值对。

建议将主路由保留为未命名状态,这样您就不需要更新每个路由。如果决定为主路由命名,那么您需要为应用程序中的每个路由执行此步骤。

更新About路由以使用这个新的键,并将其转换为一个对象。下一步是告诉代码每个组件将放在哪里。

使用默认值作为键,将About组件设置为值。这将把 About 组件的内容放在未命名的router-view中,即主要的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 及其功能和功能。有了这些知识,我们现在可以继续使用 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: 在列表视图中显示分页的产品列表,例如在首页分类页组件中。

  • 分类列表ListCategories: 这将创建一个用于导航的分类列表。

  • 购买列表ListPurchases: 此组件将出现在购物篮、结账和订单确认页面中;它将以表格形式列出产品的变体、价格和数量。它还将显示购物篮中所有产品的总价。

  • 过滤ProductFiltering: 在分类页面的侧边使用的组件,将为用户提供过滤的能力,并更新 URL,使用我们在第八章中介绍的 GET 参数,介绍 Vue-Router 和加载基于 URL 的组件

路径

在我们概述了组件之后,我们可以规划商店的路径和 URL,以及它们将采取的组件或操作。我们还需要考虑错误的 URL,以及是否应该将用户重定向到适当的位置或显示错误消息:

  • /首页

  • /category/:slug: CategoryPage,使用 :slug 唯一标识符来确定要显示的产品

  • /category: 这将重定向到 /

  • /product/:slug: ProductPage - 再次使用 :slug 来标识产品

  • /product: 这将重定向到 /

  • /basket: OrderBasket

  • /checkout: OrderCheckout - 如果没有产品,它将重定向用户到 /basket

  • /complete: OrderConfirmation - 如果用户没有从 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 解析器只会给你的应用程序增加 3 KB 的大小,而 d3 则大约为 60 KB。除非你预计以后会添加可视化效果,否则建议你选择更小的库-尤其是因为它们执行相同的功能。然而,我们将为两个库运行示例。

我们希望在应用程序加载时加载我们的产品数据,所以我们的 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 个带有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 存储中。这将通过一个简单的 mutation 来完成,该 mutation 接受一个 payload 并将其存储在不进行任何修改的情况下。

在你的 store 中创建一个statemutations对象。在state中添加一个products键作为一个对象,并在mutations对象中创建一个名为products的函数。该 mutation 应该接受两个参数-状态和一个 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;
 }
  }
};

由于我们有一个格式良好的产品存储和slug变量在Product组件中可用,所以选择产品现在是一个相当简单的过程。存储中的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错误页面。

显示产品信息

有了我们的产品加载、过滤和错误捕捉,我们可以继续显示我们产品所需的信息。每个产品可能包含一个或多个图片,以及一个或多个变体和任何组合 - 所以我们需要确保我们为每种情况都提供支持。

为了查看可用的数据,将console.log(product)添加到return之前:

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。在vendor.titletype之前加上它们是什么的描述,但是确保只在产品详细信息中存在时才渲染该文本:

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 标签的 body 输出为文本 - 这意味着我们可以在页面上看到它们。如果回想起本书开头我们讨论输出类型的地方,我们需要使用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 个图像,因此要显示它们,我们始终需要进行v-for循环。添加一个新的容器并循环遍历图像。为每个图像添加一个宽度,以免占用整个页面。

图像数组包含每个图像的对象。这个对象有一个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>

key属性应该是该项本身的唯一属性,而不是数组中的索引,以帮助 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,在选择框中显示缺货信息,并使用disabledHTML 属性禁用“添加到购物篮”按钮。我们还可以更新按钮的值:

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 毫米组合扳手(#/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 中的动态变量加载特定产品。我们创建了一个产品详细视图,允许用户浏览图库并从下拉列表中选择变体。如果变体有关联的图像,主图像将更新。

在第十章中,构建电子商务商店-浏览产品

我们将创建一个分类页面,创建过滤和排序功能,帮助用户找到他们想要的产品。

第十章:构建电子商务商店-浏览产品

在第九章中,使用 Vue-Router 动态路由加载数据,我们将产品数据加载到 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()函数来完成 - 这是一个简单的for循环的 ES2015 替代品。此函数现在返回一个包含产品对象的数组:

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

为了确定从哪里进行切片,我们可以使用以下计算:

(当前页码 * 每页显示的项数) - 每页显示的项数

最后的减法可能看起来奇怪,但它意味着在第1页,结果是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 将显示不同的产品列表,具体取决于数量。

为了继续我们对产品列表的语义化方法,当不在第一页时,我们应该更新有序列表的起始位置。这可以使用 HTML 属性start来完成 - 这允许您指定有序列表应该从哪个数字开始。

使用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属性,并检查当前页面是否为 1。在下一页按钮上,将其与我们的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 个产品,则显示“每页产品”组件,并且只在比前一步骤中有更多产品时显示每个步骤

  • 只有在pageLinksCount变量大于我们的pageLinks组件时才显示

所有这些添加都已添加到以下组件代码中。我们还删除了不必要的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数组中,如果价格在数组中不存在的话。使用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

操作允许您在存储本身中创建函数。操作无法直接改变状态 - 这仍然是突变的工作,但它允许您将多个突变组合在一起,这在这种情况下非常适合我们。如果您想在改变状态之前运行异步操作,操作也非常适合 - 例如使用setTimeout JavaScript 函数。

转到您的Vuex.Store实例,并在突变之后添加一个新的actions对象。在内部,创建一个名为initializeShop的新函数:

const store = new Vuex.Store({
  state: {
    products: {}
  },

  mutations: {
    products(state, payload) {
      state.products = payload;
    }
  },

 actions: {
 initializeShop() {

 }
 }
});

在 action 参数中,第一个参数是 store 本身,我们需要使用它来使用 mutations。有两种方法可以做到这一点,第一种是使用一个变量并在函数内部访问其属性。例如:

actions: {
  initializeShop(store) {
    store.commit('products');
  }
}

然而,使用 ES2015,我们可以使用参数解构并利用我们需要的属性。对于这个 action,我们只需要commit函数,像这样:

actions: {
  initializeShop({commit}) {
    commit('products');
  }
}

如果我们还想要来自 store 的 state,我们可以将其添加到花括号中:

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));
  });
}

下一步是为我们的 categories 创建一个 mutation。由于我们可能希望独立于产品更新我们的 categories - 我们应该在mutations中创建一个第二个函数。它也应该是这个函数循环遍历产品并创建类别列表。

首先,在 state 对象中创建一个名为categories的新属性。默认情况下,它应该是一个对象:

state: {
  products: {},
  categories: {}
}

接下来,创建一个名为categories的新 mutation。除了 state 之外,它还应该接受第二个参数。为了保持一致,将其命名为payload - 因为这是 Vuex 所指的:

mutations: {
  products(state, payload) {
    state.products = payload;
  },

 categories(state, payload) {

 }
},

现在是功能的时间了。这个 mutation 需要循环遍历产品。对于每个产品,它需要隔离type。一旦它有了标题和 slug,它需要检查是否存在具有该 slug 的条目;如果存在,将产品 handle 附加到products数组中,如果不存在 - 它需要创建一个新的数组和详细信息。

创建一个空的categories对象,并循环遍历payload,为产品和类型设置一个变量:

categories(state, payload) {
 let categories = {}; 
 Object.keys(payload).forEach(key => {
 let product = payload[key],
 type = product.type;
 });
}

现在,我们需要检查是否存在一个具有当前type.handle键的条目。如果不存在,我们需要创建一个新的条目。该条目需要具有标题、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: []
 }
 }
  });
}

最后,我们需要将当前产品 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);
}

将 mutation 添加到initializeShop action 中:

initializeShop({commit}, products) {
  commit('products', products);
  commit('categories', products);
}

在浏览器中查看应用程序,您将面临一个 JavaScript 错误。这是因为一些产品不包含我们用于对其进行分类的“type”。即使解决了 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 控制台中显示所有类别 - 让您可以看到有多少类别。

让我们将任何只有两个或更少产品的类别合并到“其他”类别中 - 不要忘记在之后删除该类别。在产品循环之后,循环遍历类别,检查可用产品的数量。如果少于三个,将它们添加到“其他”类别中:

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 - 添加这个带有name和启用 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 getters

使我们的类别页面与任何其他商店一样的最后一步是引入过滤。过滤允许您查找具有特定尺寸、颜色、标签或制造商的产品。我们的过滤选项将从页面上的产品构建。例如,如果没有产品具有 XL 尺寸或蓝色,那么将其显示为过滤器就没有意义。

为了实现这一点,我们还需要将当前类别的产品传递给过滤组件。但是,产品在CategoryPage组件上进行处理。我们可以将功能移动到 Vuex 存储的获取器中,而不是重复此处理。获取器允许您从存储中检索数据并像在组件的函数中一样操作它。但是,由于它是一个中心位置,这意味着多个组件可以从处理中受益。

获取器是 Vuex 中计算函数的等效物。它们被声明为函数,但作为变量进行调用。但是,可以通过在其中返回一个函数来操作它们以接受参数。

我们将把CategoryPage组件中的categoryproducts函数都移到获取器中。然后,getter函数将返回一个包含类别和产品的对象。

在存储中创建一个名为getters的新对象。在其中,创建一个名为categoryProducts的新函数:

getters: {
  categoryProducts: () => {

  }
}

获取器本身接收两个参数,第一个是状态,第二个是任何其他获取器。要将参数传递给获取器,必须在获取器内部返回一个接收参数的函数。幸运的是,在 ES2015 中,可以使用双箭头(=>)语法实现这一点。由于在此函数中不会使用任何其他获取器,因此不需要调用第二个参数。

由于我们正在将所有逻辑抽象出来,因此将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计算函数中的逻辑移动到获取器中。更新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;
},

更新categoryProducts getter 以接受第一个函数的 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。更新 category 函数以使用这些新函数:

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
  }
}

更新类别路由,将侧边栏中的组件从ListCategories更改为ProductFiltering

{
  path: '/category/:slug',
  name: 'Category',
  components: {
    default: CategoryPage,
    sidebar: ProductFiltering
  },
  props: {
    default: true, 
    sidebar: true
  }
}

现在,您应该有包含CategoryPageListCategories组件的Home路由,以及包含ProductFiltering组件的Category路由。

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方法中循环遍历类别中的products,并首先找到每个产品的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的 if 声明中提取逻辑。将这两个参数命名为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 函数来重置 topics 键:

watch: {
  slug() {
 this.topics = this.defaultTopics();
 }
}

在复选框过滤器更改时更新 URL

当与我们的过滤组件交互时,它将更新 URL 查询参数。这允许用户查看过滤器的效果,将其加为书签,并在需要时共享 URL。我们已经在分页中使用了查询参数,将用户放回第一页进行过滤是有意义的,因为可能只有一页。

为了构建我们的过滤器的查询参数,我们需要循环遍历每个过滤器类型,并为每个在checked数组中有项目的过滤器添加一个新参数。然后,我们可以调用router.push()来更新 URL,并相应地更改显示的产品。

updateFilters方法中创建一个空对象。循环遍历主题并使用选中的项目填充filters对象。将filters对象设置为路由器中的query参数:

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可以是数组或字符串,我们需要确保checked属性无论如何都是一个数组。我们还需要确保查询键确实是一个过滤器,而不是一个页面参数:

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。然后我们对每个查询参数重复这个过程。完成后,我们只显示具有所有条件的产品。

我们构建的方式允许产品在类别内为OR,但在不同的部分为AND。例如,如果用户选择了多种颜色(红色和绿色)和一个标签(配件),它将显示所有红色或绿色的配件产品。

我们的过滤器是通过标签、供应商和动态过滤器创建的。由于其中两个属性是固定的,我们需要首先检查它们。动态过滤器将通过重建它们构建的方式进行验证。

创建一个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对象。我们可以使用标签来复制该功能 - 记住产品上的标签是我们需要过滤的对象。

还需要检查过滤器构建的动态属性。这是通过检查每个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 store 中。这将以对象数组的形式存在。每个对象将包含几个关键信息,这些信息将允许我们在不必每次查询 Vuex store 时都能显示购物篮中的产品。它还允许我们存储有关产品页面当前状态的详细信息 - 当选择变体时,记住图片更新。

我们要为每个添加到购物篮中的产品存储以下详细信息:

  • 产品标题

  • 产品句柄,以便我们可以链接回产品页面

  • 选择的变体标题(在选择框中显示)

  • 当前选择的图片,以便我们可以在结账时显示适当的图片

  • 变体详情,包括价格、重量和其他细节

  • 变体 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的新变异。这将接受状态作为第一个参数,组件实例作为第二个参数。通过传递实例,我们可以访问组件上的变量、方法和计算值:

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是唯一的。或者,我们可以使用条形码属性。

使用原生的 JavaScriptfind函数,我们可以识别出具有与传入的“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
    });
 }
}

现在,当商品添加到购物篮时,购物篮会被填充,并且在已存在时会递增。

为了提高应用程序的可用性,当用户将商品添加到购物篮时,我们应该给予用户一些反馈。这可以通过简要更新“添加到购物篮”按钮并在网站标题中显示产品计数和指向购物篮的链接来实现。

更新添加商品时的“加入购物篮”按钮

作为对我们商店的可用性改进,当用户点击“Add to basket”按钮时,我们将更新它。这将变为“Added to your basket”,并在一定时间内应用一个类,例如两秒钟,然后返回到之前的状态。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文件,将<header>元素添加到应用的 HTML 中,并插入一个占位符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。作为占位符,返回1。计算数量需要使用state,因此现在将其传递给函数:

getters: {
  ...

 cartQuantity: (state) => { 
 return 1;
 }
}

返回 getter 的结果到您的 Vue 实例。理想情况下,我们希望在括号中显示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>`
};

OrderConfirmation组件中添加components对象,并包含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);
  }
},

我们的价格现在格式化得很好,并且显示正确。

计算总价格

我们购买清单的下一个添加是购物篮的总价值。这需要以与我们之前计算购物篮数量类似的方式进行计算。

创建一个名为totalPrice的新的computed函数。该函数应该循环遍历产品并累加价格,考虑到任何多个数量:

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: ''
    }
  }
},

下一步是如果复选框被勾选,则禁用交付字段。这可以通过根据复选框的结果激活disabledHTML 属性来实现。类似于我们在产品页面上禁用“添加到购物车”按钮的方式,将交付字段上的禁用属性绑定到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对象相同。

sameAddress变量创建一个新的watch对象和函数。如果它为true,则将交付对象设置为与账单对象相同:

watch: {
  sameAddress() {
    if(this.sameAddress) {
      this.delivery = this.billing;
    }
  }
}

添加了watch函数后,我们可以输入数据到账单地址,勾选复选框,然后交付地址会自动填充。最好的是它们现在保持同步,所以如果你更新了账单地址,交付地址会实时更新。问题出现在当你取消勾选复选框并编辑账单地址时,交付地址仍然会更新。这是因为我们将这两个对象绑定在一起。

添加一个else语句,在取消勾选复选框时复制账单地址:

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。在购物篮中的组件中添加prop

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 的产品并将数量更新为整数。我们还需要使用结果更新存储,以便在页面顶部更新数量。我们将创建一个通用的突变,接受带有新值的完整basket数组,以允许在产品删除时使用相同的突变。

创建更新数量并提交名为updatePurchases的突变。

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);
  }
}

在商店中,创建将state.basket设置为有效载荷的突变:

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()方法中使用的相同突变:

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开发了几个单页应用程序(SPA)。尽管开发是创建 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 的开发版本,因此开发工具应该适用于我们开发的所有三个 SPA。打开 Dropbox 示例并打开 Vue 开发者工具。

检查 Vue 组件的数据和计算值

Vue 开发者工具提供了页面上正在使用的组件的很好概述。您还可以深入了解组件并预览在该特定实例上使用的数据。这非常适合在任何给定时间检查页面上每个组件的属性。

例如,如果我们检查 Dropbox 应用程序并导航到组件选项卡,我们可以看到 Vue 实例和组件。点击这个将显示组件的所有数据属性 - 以及任何计算属性。这样我们就可以验证结构是否正确构建,以及计算路径属性:

深入研究每个组件,我们可以访问单个数据对象和计算属性。

使用 Vue 开发者工具来检查应用程序是一种更高效的验证数据的方式,因为它避免了使用多个console.log()语句。

查看 Vuex 的 mutations 和时间旅行

导航到下一个选项卡 Vuex,可以实时观察 store mutations 的发生。每次发生 mutation 时,左侧面板中都会创建一行新的。这个元素允许我们查看发送的数据以及数据提交之前和之后的 Vuex store 的样子。

它还提供了几个选项来还原、提交和时间旅行到任何点。加载 Dropbox 应用程序后,左侧面板中立即出现了几个结构 mutations,列出了 mutation 的名称和发生的时间。这是代码预缓存文件夹的操作。点击每个选项将显示 Vuex store 的状态 - 以及包含的 payload 的 mutation。状态显示是在 payload 发送和 mutation 提交之后。要预览该 mutation 之前的状态,请选择前面的选项:

在每个条目旁边,您会注意到三个符号,允许您执行多个操作并直接在浏览器中改变 store:

  • 提交此 mutation:这允许您提交到该点的所有数据。这将从开发工具中删除所有的 mutations,并将基本状态更新到此点。如果有多个 mutations 发生,您希望跟踪它们,这将非常方便。

  • 还原此变化:这将撤消该变化和此点之后的所有变化。这使您可以一遍又一遍地执行相同的操作,而无需刷新或丢失当前位置。例如,在我们的购物应用程序中将产品添加到购物篮时,会发生变化。使用此功能,您可以从购物篮中删除产品并撤消任何后续变化,而无需离开产品页面。

  • 时间旅行到此状态:这允许您预览应用程序和该特定变化状态,而不会还原所选点之后发生的任何变化。

变化选项卡还允许您在左侧面板顶部提交或还原所有变化。在右侧面板中,您还可以导入和导出存储状态的 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的 Google 搜索结果的第一个结果是维基百科的条目。

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 扩展,可以让您可视化地创建测试用例和命令。

测试,特别是对于大型应用程序来说,是至关重要的,尤其是在将应用程序部署到开发环境时。无论您选择单元测试还是浏览器自动化,都有大量的文章和书籍可供参考。

总结

在本书中,我们介绍了几种技术,并学习了如何使用 Vue 和官方 Vue 插件。我们构建了三个单页面应用程序,涵盖了不同的方法和方式。

在本书的第一部分中,我们介绍了如何初始化 Vue 实例。我们探讨了如何循环遍历数据以及如何创建用户界面来过滤显示的数据。我们还介绍了如何在每一行上有条件地渲染 CSS 类。

然后,我们开始将 Vuex 集成到我们的应用程序中,并与 API 进行通信,以 Dropbox 为例。我们研究了如何访问数据并将其存储在本地。这有助于提高应用程序的性能和速度,改善用户体验。

最后,我们创建了一个模拟商店。使用来自 Shopify CSV 文件的真实数据,我们创建了一个允许单独查看产品的应用程序。我们还创建了一个可以进行过滤和排序的类别列表页面,使用户能够找到他们想要的特定产品。为了完善体验,我们构建了一个可编辑的购物篮、结账和订单确认界面。

在本章中,我们介绍了使用 Vue 开发工具,以及如何构建测试用例。这完成了使用Vue.js构建单页面应用程序的过程。

posted @ 2024-05-16 12:09  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报