Vue2-和-Laravel5-全栈开发(全)

Vue2 和 Laravel5 全栈开发(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现在是 2014 年,单页应用(SPA)解决方案的战争真正激烈。有许多竞争对手:Angular、React、Ember、Knockout 和 Backbone 等等。然而,最受关注的战斗是在谷歌的 Angular 和 Facebook 的 React 之间。

直到这一点,SPA 之王 Angular 是一个完整的框架,遵循熟悉的 MVC 范例。而 React,这个不太可能的挑战者,与其核心库只处理视图层,而且标记完全由 JavaScript 编写,相比之下似乎相当奇怪!虽然 Angular 占据着更大的市场份额,但 React 在开发人员思考 Web 应用设计的方式上引起了巨大的变革,并提高了框架的大小和性能。

与此同时,一位名叫 Evan You 的开发人员正在尝试自己的新框架 Vue.js。它将结合 Angular 和 React 的最佳特性,实现简单和强大之间的完美平衡。你的愿景与其他开发人员的共鸣如此之好,以至于 Vue 很快就成为最受欢迎的 SPA 解决方案之一。

尽管竞争激烈,但 Vue 迅速获得了关注。这在一定程度上要归功于 Laravel 的创始人 Taylor Otwell,他在 2015 年初发推特称赞 Vue 的印象深刻。这条推文引起了 Laravel 社区对 Vue 的极大兴趣。

Vue 和 Laravel 的合作将在 2016 年 9 月发布的 Laravel 5.3 版本中进一步紧密结合,当时 Vue 被包含为默认的前端库。对于具有相同理念的两个软件项目来说,这是一个完全合乎逻辑的联盟:简单和开发者体验的重点。

如今,Vue 和 Laravel 为开发 Web 应用提供了一个非常强大和灵活的全栈框架,正如你将在本书中发现的那样,它们是非常愉快的工作对象。

本书涵盖的内容

构建一个全栈应用需要广泛的知识,不仅仅是关于 Vue 和 Laravel,还包括 Vue Router、Vuex 和 Webpack,更不用说 JavaScript、PHP 和 Web 开发的一般知识了。

因此,作为作者,我面临的最大挑战之一是决定应该包括什么,不应该包括什么。我最终确定的主题是对以下两个问题的回答:

  • 读者在所有或大多数 Vue.js 应用中将使用的基本特性、工具和设计模式是什么?

  • 相对于其他架构,设计和构建全栈 Vue.js 应用的关键问题是什么?

以下是本书各章节涉及的主题分布:

第一章《你好 Vue - Vue.js 简介》介绍了 Vue.js 的概述,以及本书的案例研究项目Vuebnb

第二章《原型设计 Vuebnb,你的第一个 Vue.js 项目》提供了 Vue.js 基本特性的实际介绍,包括安装、模板语法、指令、生命周期钩子等。

第三章《设置 Laravel 开发环境》展示了如何为全栈 Vue.js 应用设置一个新的 Laravel 项目。

第四章《使用 Laravel 构建 Web 服务》是关于为我们的案例研究项目的后端奠定基础,包括设置数据库、模型和 API 端点。

第五章《使用 Webpack 集成 Laravel 和 Vue.js》解释了一个复杂的 Vue 应用将需要构建步骤,并介绍了用于捆绑项目资产的 Webpack。

第六章《使用 Vue.js 组件构建小部件》教授了组件是现代 UI 开发的一个基本概念,也是 Vue.js 最强大的功能之一。

第七章《使用 Vue Router 构建多页面应用》介绍了 Vue Router,并展示了如何在前端应用中添加虚拟页面。

第八章《使用 Vuex 管理应用状态》解释了状态管理是管理复杂 UI 数据的必备功能。我们介绍了 Flux 模式和 Vuex。

第九章,“使用 Passport 添加用户登录和 API 身份验证”,专注于全栈应用程序中最棘手的部分之一——身份验证。本章展示了如何使用 Passport 进行安全的 AJAX 调用到后端。

第十章,“将全栈应用部署到云端”,描述了如何构建和部署我们完成的项目到基于云的服务器,并使用 CDN 来提供静态资产。

你需要为这本书做好准备

在你开始案例研究项目的开发之前,你必须确保你有正确的软件和硬件。

操作系统

你可以使用基于 Windows 或 Linux 的操作系统。不过我是 Mac 用户,所以本书中使用的所有终端命令都将是 Linux 命令。

请注意我们将使用 Homestead 虚拟开发环境,其中包括 Ubuntu Linux 操作系统。如果你 SSH 进入这个虚拟机并从那里运行所有终端命令,你可以使用和我一样的命令,即使你使用的是 Windows 主机操作系统。

开发工具

下载项目代码将需要 Git。如果你还没有安装 Git,请按照这个指南中的说明进行:git-scm.com/book/en/v2/Getting-Started-Installing-Git

要开发一个 JavaScript 应用程序,你需要 Node.js 和 NPM。它们可以从同一个软件包中安装;请参阅这里的说明:nodejs.org/en/download/

我们还将使用 Laravel Homestead。第三章将提供设置 Laravel 开发环境的说明。

浏览器

Vue 需要 ECMAScript 5,这意味着你可以使用任何主流浏览器的最新版本来运行它。我建议你使用 Google Chrome,因为我将为 Chrome Dev Tools 提供调试示例,如果你也使用 Chrome,那么你跟着学会会更容易。

在选择浏览器时,你还应该考虑与 Vue Devtools 的兼容性。

Vue Devtools

Vue Devtools 浏览器扩展使得调试 Vue 变得轻而易举,在本书中我们将大量使用它。这个扩展是为 Google Chrome 设计的,但也可以在 Firefox 中使用(还有 Safari,需要稍微修改一下)。

查看以下链接以获取更多信息和安装说明:github.com/vuejs/vue-devtools

IDE

当然,你需要一个文本编辑器或 IDE 来开发案例研究项目。

硬件

你需要一台配置足够安装和运行上述软件的计算机。最消耗资源的程序将是 VirtualBox 5.2(或 VMWare 或 Parallels),我们将使用它来设置 Homestead 虚拟开发环境。

你还需要一个互联网连接来下载源代码和项目依赖。

这本书适合谁

这本书是为寻求使用 Vue.js 和 Laravel 进行全栈开发的 Laravel 开发者而写的,提供了实用和最佳实践的方法。

任何对这个主题感兴趣的网页开发者都可以成功使用这本书,只要他们满足以下条件:

主题 级别
HTML 和 CSS 中级知识
JavaScript 中级知识
PHP 中级知识
Laravel 基础知识
Git 基础知识

请注意读者不需要有 Vue.js 或其他 JavaScript 框架的经验。

约定

在本书中,你会发现一些文本样式,用来区分不同类型的信息。以下是一些这些样式的例子和它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名都显示如下:“例如,在这里我创建了一个自定义元素,grocery-item,它呈现为li。”

代码块设置如下:

<div id="app">
  <!--Vue has dominion within this node-->
</div>
<script> new Vue({ el: '#app'
  }); </script>

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

$ npm install

新术语重要词汇以粗体显示。屏幕上看到的词语,例如菜单或对话框中的词语,会以这样的方式出现在文本中:"Vue 不允许这样做,如果你尝试会出现这个错误:不要将 Vue 挂载到 或 - 而是挂载到普通元素上。"

警告或重要提示会以这样的方式出现在一个框中。提示和技巧会以这样的方式出现。

第一章:你好 Vue - Vue.js 简介

欢迎来到《全栈 Vue.js 2 和 Laravel 5》!在本章中,我们将对 Vue.js 进行高层次的概述,让您熟悉它的功能,为学习如何使用它做好准备。

我们还将熟悉本书中的主要案例研究项目 Vuebnb。

本章涵盖的主题:

  • Vue 的基本特性,包括模板、指令和组件

  • Vue 的高级特性,包括单文件组件和服务器端渲染

  • Vue 生态系统中的工具,包括 Vue Devtools、Vue Router 和 Vuex

  • 您将在本书中逐步构建的主要案例研究项目是 Vuebnb

  • 安装项目代码的说明

介绍 Vue.js

截至 2017 年底,Vue.js 的版本是 2.5。在首次发布不到四年的时间里,Vue 已经成为 GitHub 上最受欢迎的开源项目之一。这种受欢迎程度部分是由于其强大的功能,也是由于其强调开发者体验和易于采用。

Vue.js 的核心库,像 React 一样,只用于从 MVC 架构模式中操纵视图层。然而,Vue 有两个官方支持库,Vue Router 和 Vuex,分别负责路由和数据管理。

Vue 不像 React 和 Angular 那样得到科技巨头的支持,而是依赖于少数公司赞助商和专门的 Vue 用户的捐赠。更令人印象深刻的是,Evan You 目前是唯一的全职 Vue 开发人员,尽管来自世界各地的 20 多名核心团队开发人员协助开发、维护和文档编写。

Vue 的关键设计原则如下:

  • 重点:Vue 选择了一个小而集中的 API,它的唯一目的是创建 UI

  • 简单性:Vue 的语法简洁易懂

  • 紧凑:核心库脚本压缩后约为 25 KB,比 React 甚至 jQuery 都要小

  • 速度:渲染基准超过了许多主要框架,包括 React

  • 多功能性:Vue 非常适合小型任务,您可能会使用 jQuery,但也可以扩展为合法的 SPA 解决方案

基本特性

现在让我们对 Vue 的基本特性进行高层次的概述。如果您愿意,您可以在计算机上创建一个 HTML 文件,如下所示,然后在浏览器中打开它,并按照以下示例进行编码。

如果你宁愿等到下一章,当我们开始进行案例研究项目时,那也可以,因为我们这里的目标只是为了感受一下 Vue 能做什么:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>Hello Vue</title>
</head>
<body>
  <!--We'll be adding stuff here!-->
</body>
</html>

安装

尽管 Vue 可以在更复杂的设置中作为 JavaScript 模块使用,但它也可以简单地作为外部脚本包含在 HTML 文档的主体中:

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

模板

默认情况下,Vue 将使用 HTML 文件作为其模板。包含的脚本将声明 Vue 的一个实例,并在配置对象中使用el属性告诉 Vue 在模板中的哪个位置挂载应用程序:

<div id="app">
  <!--Vue has dominion within this node-->
</div>
<script> new Vue({ el: '#app'
  }); </script>

我们可以通过将其创建为data属性并使用 mustache 语法将其打印到页面中,将数据绑定到我们的模板中:

<div id="app"> {{ message }} <!--Renders as "Hello World"-->
</div>
<script> new Vue({ el: '#app', data: { message: 'Hello World'
    }
  }); </script>

指令

与 Angular 类似,我们可以使用指令向我们的模板添加功能。这些是我们添加到以v-前缀开头的 HTML 标签的特殊属性。

假设我们有一个数据数组。我们可以使用v-for指令将这些数据呈现为页面上的连续 HTML 元素:

<div id="app">
  <h3>Grocery list</h3>
  <ul>
    <li v-for="grocery in groceries">{{ grocery }}</li>
  </ul>
</div>
<script> var app = new Vue({ el: '#app', data: { groceries: [ 'Bread', 'Milk' ]
    }
  }); </script>

上述代码呈现如下:

<div id="app">
  <h3>Grocery list</h3>
  <ul>
    <li>Bread</li>
    <li>Milk</li>
  </ul>
</div>

响应性

Vue 设计的一个关键特性是其响应性系统。当您修改数据时,视图会自动更新以反映这一变化。

例如,如果我们创建一个函数,在页面已经呈现后将另一个项目推送到我们的杂货项目数组中,页面将自动重新呈现以反映这一变化:

setTimeout(function() { app.groceries.push('Apples');
}, 2000);

初始渲染后两秒,我们看到了这个:

<div id="app">
  <h3>Grocery list</h3>
  <ul>
    <li>Bread</li>
    <li>Milk</li>
    <li>Apples</li>
  </ul>
</div>

组件

组件扩展了基本的 HTML 元素,并允许您创建自己的可重用自定义元素。

例如,这里我创建了一个自定义元素grocery-item,它渲染为一个li。该节点的文本子节点来自自定义 HTML 属性title,在组件代码内部可以访问到:

<div id="app">
  <h3>Grocery list</h3>
  <ul>
    <grocery-item title="Bread"></grocery-item>
    <grocery-item title="Milk"></grocery-item>
  </ul>
</div>
<script> Vue.component( 'grocery-item', { props: [ 'title' ], template: '<li>{{ title }}</li>'
  });

  new Vue({ el: '#app'
  }); </script>

这样渲染:

<div id="app">
  <h3>Grocery list</h3>
  <ul>
    <li>Bread</li>
    <li>Milk</li>
  </ul>
</div>

但使用组件的主要原因可能是它更容易构建一个更大的应用程序。功能可以被分解为可重用的、自包含的组件。

高级功能

如果你迄今为止一直在跟着示例编码,那么请关闭你的浏览器,直到下一章,因为以下高级片段不能简单地包含在浏览器脚本中。

单文件组件

使用组件的一个缺点是,你需要在主 HTML 文件之外的 JavaScript 字符串中编写你的模板。虽然有办法在 HTML 文件中编写模板定义,但这样就会在标记和逻辑之间产生尴尬的分离。

一个方便的解决方案是单文件组件

<template>
  <li v-on:click="bought = !bought" v-bind:class="{ bought: bought }">
    <div>{{ title }}</div>
  </li>
</template>
<script> export default { props: [ 'title' ], data: function() {
      return { bought: false
      };
    }
  } </script>
<style> .bought {
    opacity: 0.5;
  } </style>

这些文件的扩展名是.vue,它们封装了组件模板、JavaScript 配置和样式,全部在一个文件中。

当然,网页浏览器无法读取这些文件,因此它们需要首先通过 Webpack 这样的构建工具进行处理。

模块构建

正如我们之前所看到的,Vue 可以作为外部脚本直接在浏览器中使用。Vue 也可以作为 NPM 模块在更复杂的项目中使用,包括像 Webpack 这样的构建工具。

如果你对 Webpack 不熟悉,它是一个模块打包工具,可以将所有项目资产捆绑在一起,形成可以提供给浏览器的东西。在捆绑过程中,你也可以转换这些资产。

使用 Vue 作为模块,并引入 Webpack,可以开启以下可能性:

  • 单文件组件

  • 当前浏览器不支持的 ES 功能提案

  • 模块化的代码

  • 预处理器,如 SASS 和 Pug

我们将在第五章中更深入地探索 Webpack,使用 Webpack 集成 Laravel 和 Vue.js

服务器端渲染

服务器端渲染是增加全栈应用程序加载速度感知度的好方法。用户在加载您的网站时会得到一个完整的页面,而不是直到 JavaScript 运行时才会填充的空页面。

假设我们有一个使用组件构建的应用。如果我们使用浏览器开发工具在页面加载后查看我们的页面 DOM,我们将看到我们完全渲染的应用:

<div id="app">
  <ul>
    <li>Component 1</li>
    <li>Component 2</li>
    <li>
      <div>Component 3</div>
    </li>
  </ul>
</div>

但是,如果我们查看文档的源代码,也就是index.html,就像服务器发送时一样,你会看到它只有我们的挂载元素:

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

为什么?因为 JavaScript 负责构建我们的页面,因此 JavaScript 必须在页面构建之前运行。但是通过服务器端渲染,我们的index文件包含了浏览器在下载和运行 JavaScript 之前构建 DOM 所需的 HTML。应用程序加载速度并没有变快,但内容会更快地显示出来。

Vue 生态系统

虽然 Vue 是一个独立的库,但与其生态系统中的一些可选工具结合使用时,它会变得更加强大。对于大多数项目,你将在前端堆栈中包含 Vue Router 和 Vuex,并使用 Vue Devtools 进行调试。

Vue 开发者工具

Vue Devtools 是一个浏览器扩展,可以帮助你开发 Vue.js 项目。除其他功能外,它允许你查看应用程序中组件的层次结构和组件的状态,这对调试很有用:

图 1.1 Vue Devtools 组件层次结构

我们将在本节的后面看到它还能做什么。

Vue 路由

Vue Router 允许你将 SPA 的不同状态映射到不同的 URL,给你虚拟页面。例如,mydomain.com/可能是博客的首页,并且有这样的组件层次结构:

<div id="app">
  <my-header></my-header>
  <blog-summaries></blog-summaries>
  <my-footer></my-footer>
</div>

mydomain.com/post/1可能是博客中的一个单独的帖子,看起来像这样:

<div id="app">
  <my-header></my-header>
  <blog-post post-id="id">
  <my-footer></my-footer>
</div>

从一个页面切换到另一个页面不需要重新加载页面,只需交换中间组件以反映 URL 的状态,这正是 Vue Router 所做的。

Vuex

Vuex 提供了一种强大的方式来管理应用程序的数据,随着 UI 的复杂性增加,它将应用程序的数据集中到一个单一的存储中。

我们可以通过检查 Vue Devtools 中的存储来获取应用程序状态的快照:

图 1.2 Vue Devtools Vuex 标签

左侧列跟踪对应用程序数据所做的更改。例如,用户保存或取消保存项目。您可以将此事件命名为toggleSaved。Vue Devtools 允许您在事件发生时查看此事件的详细信息。

我们还可以在不触及代码或重新加载页面的情况下恢复到数据的任何先前状态。这个功能称为时间旅行调试,对于调试复杂的 UI 来说,您会发现它非常有用。

案例研究项目

在快速概述了 Vue 的主要特性之后,我相信您现在渴望开始真正学习 Vue 并将其投入使用。让我们首先看一下您将在整本书中构建的案例研究项目。

Vuebnb

Vuebnb 是一个现实的全栈 Web 应用程序,它利用了 Vue、Laravel 和本书中涵盖的其他工具和设计模式的许多主要特性。

从用户的角度来看,Vuebnb 是一个在线市场,可以在世界各地的城市租用短期住宿。您可能会注意到 Vuebnb 和另一个名字类似的住宿在线市场之间的一些相似之处!

您可以在此处查看 Vuebnb 的完成版本:vuebnb.vuejsdevelopers.com

如果您现在没有互联网访问权限,这里有两个主要页面的截图。首先是主页,用户可以在这里搜索或浏览住宿选项:

图 1.3 Vuebnb 主页

其次是列表页面,用户可以在这里查看特定于可能有兴趣租用的单个住宿的信息:

图 1.4 Vuebnb 列表页面

代码库

案例研究项目贯穿整本书的整个持续时间,因此一旦您创建了代码库,您可以逐章添加内容。最终,您将从头开始构建和部署一个全栈应用程序。

代码库位于 GitHub 存储库中。您可以将其下载到计算机上通常放置项目的任何文件夹中,例如~/Projects

$ cd ~/Projects
$ git clone https://github.com/PacktPublishing/Full-Stack-Vue.js-2-and-Laravel-5
$ cd Full-Stack-Vue.js-2-and-Laravel-5

与其直接克隆此存储库,不如首先进行分叉,然后再克隆。这样可以让您随意进行任何更改,并将您的工作保存到您自己的远程存储库。这里有一个关于在 GitHub 上进行分叉存储库的指南:help.github.com/articles/fork-a-repo/

文件夹

代码库包含以下文件夹:

图 1.5 代码库目录内容

以下是每个文件夹的用途概述:

  • Chapter02Chapter10包含每章的代码的完成状态(不包括本章)

  • images目录包含 Vuebnb 中用于使用的示例图片。这将在第四章中进行解释,使用 Laravel 构建 Web 服务

  • vuebnb是您将在第三章开始工作的主要案例研究项目的项目代码,设置 Laravel 开发环境

  • vuebnb-prototype是 Vuebnb 原型的项目代码,我们将在第二章中构建,原型设计 Vuebnb,您的第一个 Vue.js 项目

总结

在本章中,我们对 Vue.js 进行了高层次的介绍,涵盖了模板、指令和组件等基本特性,以及单文件组件和服务器端渲染等高级特性。我们还看了 Vue 生态系统中的工具,包括 Vue Router 和 Vuex。

然后我们对 Vuebnb 进行了概述,这是您在阅读本书时将要构建的全栈项目,并了解了如何从 GitHub 安装代码库。

在下一章中,我们将适当地了解 Vue 的基本特性,并开始通过构建 Vuebnb 的原型来使用它们。

第二章:Vuebnb 原型,您的第一个 Vue.js 项目

在本章中,我们将学习 Vue.js 的基本特性。然后,我们将把这些知识付诸实践,通过构建 Vuebnb 的案例研究项目原型。

本章涵盖的主题:

  • Vue.js 的安装和基本配置

  • Vue.js 的基本概念,如数据绑定、指令、观察者和生命周期钩子

  • Vue 的响应系统是如何工作的

  • 案例研究项目的项目要求

  • 使用 Vue.js 添加页面内容,包括动态文本、列表和页眉图像

  • 使用 Vue 构建图像模态 UI 功能

Vuebnb 原型

在本章中,我们将构建 Vuebnb 的原型,这是本书持续运行的案例研究项目。原型将只是列表页面,到本章结束时将会是这样的:

图 2.1 Vuebnb 原型

一旦我们在第三章 设置 Laravel 开发环境和第四章 使用 Laravel 构建 Web 服务中设置好了我们的后端,我们将把这个原型迁移到主项目中。

项目代码

在开始之前,您需要通过从 GitHub 克隆代码库将其下载到您的计算机上。在第一章的代码库部分中给出了说明,你好 Vue - Vue.js 简介

vuebnb-prototype文件夹中包含了我们将要构建的原型的项目代码。切换到该文件夹并列出其中的内容:

$ cd vuebnb-prototype
$ ls -la

文件夹内容应该如下所示:

图 2.2 vuebnb-prototype 项目文件除非另有说明,本章中所有后续的终端命令都假定您在vuebnb-prototype文件夹中。

NPM 安装

您现在需要安装此项目中使用的第三方脚本,包括 Vue.js 本身。NPM install方法将读取包含的package.json文件并下载所需的模块:

$ npm install

您现在会看到您的项目文件夹中出现了一个新的node_modules目录。

主要文件

在 IDE 中打开vuebnb-prototype目录。请注意,包含了以下index.html文件。它主要由样板代码组成,但也包括一些结构标记在body标签中。

还要注意,该文件链接到style.css,我们的 CSS 规则将被添加在那里,以及app.js,我们的 JavaScript 将被添加在那里。

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Vuebnb</title>
  <link href="node_modules/open-sans-all/css/open-sans.css" rel="stylesheet">
  <link rel="stylesheet" href="style.css" type="text/css">
</head>
<body>
<div id="toolbar">
  <img class="icon" src="logo.png">
  <h1>vuebnb</h1>
</div>
<div id="app">
  <div class="container"></div>
</div>
<script src="app.js"></script>
</body>
</html>

目前app.js是一个空文件,但我已经在style.css中包含了一些 CSS 规则来帮助我们入门。

style.css

body {
  font-family: 'Open Sans', sans-serif; color: #484848; font-size: 17px;
  margin: 0;
}

.container {
  margin: 0 auto;
  padding: 0 12px;
}

@media (min-width: 744px) {
  .container {
      width: 696px;
  }
}

#toolbar {
  display: flex;
  align-items: center;
  border-bottom: 1px solid #e4e4e4;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
}

#toolbar .icon {
  height: 34px;
  padding: 16px 12px 16px 24px;
  display: inline-block;
}

#toolbar h1 {
  color: #4fc08d;
  display: inline-block;
  font-size: 28px;
  margin: 0;
}

在浏览器中打开

要查看项目,请在 Web 浏览器中找到index.html文件。在 Chrome 中,只需点击文件| 打开文件。加载完成后,您将看到一个大部分为空白的页面,除了顶部的工具栏。

安装 Vue.js

现在是时候将Vue.js库添加到我们的项目中了。Vue 已作为我们的 NPM 安装的一部分下载,所以现在我们可以简单地使用script标签链接到Vue.js的浏览器构建版本。

index.html

<body>
<div id="toolbar">...</div>
<div id="app">...</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script src="app.js"></script>
</body>

在我们自定义的app.js脚本之前,包含 Vue 库是很重要的,因为脚本是按顺序运行的。

Vue 现在将被注册为全局对象。我们可以通过转到浏览器并在 JavaScript 控制台中输入以下内容来测试:

console.log(Vue);

这是结果:

图 2.3 检查 Vue 是否注册为全局对象

页面内容

当我们的环境设置好并安装好了起始代码后,我们现在已经准备好开始构建 Vuebnb 原型的第一步了。

让我们向页面添加一些内容,包括页眉图像、标题和关于部分。我们将在我们的 HTML 文件中添加结构,并使用Vue.js在需要时插入正确的内容。

Vue 实例

查看我们的app.js文件,现在让我们通过使用Vue对象的new运算符来创建 Vue.js 的根实例。

app.js

var app = new Vue();

当你创建一个Vue实例时,通常会希望将一个配置对象作为参数传递进去。这个对象是定义项目的自定义数据和函数的地方。

app.js

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

随着我们的项目的进展,我们将在这个配置对象中添加更多内容,但现在我们只是添加了el属性,告诉 Vue 在页面中的哪里挂载自己。

你可以将其分配为一个字符串(CSS 选择器)或 HTML 节点对象。在我们的例子中,我们使用了#app字符串,它是一个 CSS 选择器,指的是具有appID 的元素。

index.html

<div id="app">
  <!--Mount element-->
</div>

图 2.5。包含模拟列表示例的页面

index.html

<body>
<div id="toolbar">...</div>
<div id="app">
  <!--Vue only has dominion here-->
  <div class="header">...</header> ... </div>
<script src="node_modules/vue/dist/vue.js"></script>
<script src="app.js"></script>
</body>

从现在开始,我们将把我们的挂载节点及其子节点称为我们的模板。

数据绑定

Vue 的一个简单任务是将一些 JavaScript 数据绑定到模板上。让我们在配置对象中创建一个data属性,并为其分配一个包含title属性和'My apartment'字符串值的对象。

app.js

var app = new Vue({ el: '#app', data: { title: 'My apartment'
  }
});

这个data对象的任何属性都将在我们的模板中可用。为了告诉 Vue 在哪里绑定这些数据,我们可以使用mustache语法,也就是双花括号,例如,{{ myProperty }}。当 Vue 实例化时,它会编译模板,用适当的文本替换 mustache 语法,并更新 DOM 以反映这一点。这个过程被称为文本插值,并在下面的代码块中进行了演示。

index.html

<div id="app">
  <div class="container">
    <div class="heading">
      <h1>{{ title }}</h1>
    </div>
  </div>
</div>

将呈现为:

<div id="app">
  <div class="container">
    <div class="heading">
      <h1>My apartment</h1>
    </div>
  </div>
</div>

现在让我们添加一些更多的数据属性,并增强我们的模板以包含更多的页面结构。

app.js

var app = new Vue({ el: '#app', data: { title: 'My apartment', address: '12 My Street, My City, My Country', about: 'This is a description of my apartment.'
  }
});

index.html

<div class="container">
  <div class="heading">
    <h1>{{ title }}</h1>
    <p>{{ address }}</p>
  </div>
  <hr>
  <div class="about">
    <h3>About this listing</h3>
    <p>{{ about }}</p>
  </div>
</div>

让我们也添加一些新的 CSS 规则。

style.css

.heading {
  margin-bottom: 2em;
}

.heading h1 {
  font-size: 32px;
  font-weight: 700;
}

.heading p {
  font-size: 15px;
  color: #767676;
}

hr {
  border: 0;
  border-top: 1px solid #dce0e0;
}

.about {
  margin-top: 2em;
}

.about h3 {
  font-size: 22px;
}

.about p {
  white-space: pre-wrap;
}

如果你现在保存并刷新你的页面,它应该看起来像这样:

图 2.4。带有基本数据绑定的列表页面

模拟列表

当我们开发时,最好使用一些模拟数据,这样我们就可以看到我们完成的页面将会是什么样子。我已经在项目中包含了sample/data.js,就是为了这个原因。让我们在我们的文档中加载它,确保它在我们的app.js文件之上。

index.html

<body>
<div id="toolbar">...</div>
<div id="app">...</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script src="sample/data.js"></script>
<script src="app.js"></script>
</body>

看一下文件,你会看到它声明了一个sample对象。我们现在将在我们的数据配置中利用它。

app.js

data: { title: sample.title, address: sample.address, about: sample.about }

一旦你保存并刷新,你会在页面上看到更真实的数据:

在这种方式中使用分布在不同脚本文件中的全局变量并不是一种理想的做法。不过,我们只会在原型中这样做,稍后我们将从服务器获取这个模拟列表示例。

页眉图像

没有一个房间列表会完整而没有一个大而光滑的图片来展示它。我们的模拟列表中有一个页眉图像,现在我们将其包含进来。将这个标记添加到页面中。

Vue 对其挂载的元素和任何子节点都具有支配权。到目前为止,对于我们的项目,Vue 可以操作具有header类的div,但无法操作具有toolbarID 的div。放置在后者div中的任何内容对 Vue 来说都是不可见的。

<div id="app">
  <div class="header">
    <div class="header-img"></div>
  </div>
  <div class="container">...</div>
</div>

并将其添加到 CSS 文件中。

style.css

.header {
  height: 320px;
}

.header .header-img {
  background-repeat: no-repeat;
  background-size: cover;
  background-position: 50% 50%;
  background-color: #f5f5f5;
  height: 100%;
}

你可能会想为什么我们使用div而不是img标签。为了帮助定位,我们将把我们的图像设置为具有header-img类的div的背景。

样式绑定

要设置背景图像,我们必须在 CSS 规则中提供 URL 作为属性,就像这样:

.header .header-img {
  background-image: url(...);
}

显然,我们的页眉图像应该针对每个单独的列表进行特定设置,所以我们不想硬编码这个 CSS 规则。相反,我们可以让 Vue 将数据中的 URL 绑定到我们的模板上。

Vue 无法访问我们的 CSS 样式表,但它可以绑定到内联style属性:

<div class="header-img" style="background-image: url(...);"></div>

你可能会认为在这里使用文本插值是解决方案,例如:

<div class="header-img" style="background-image: {{ headerUrl }}"></div>

但这不是有效的 Vue.js 语法。相反,这是另一个 Vue.js 功能称为指令的工作。让我们先探索指令,然后再来解决这个问题。

指令

Vue 的指令是带有v-前缀的特殊 HTML 属性,例如v-if,它提供了一种简单的方法来为我们的模板添加功能。您可以为元素添加的一些指令的示例包括:

  • v-if:有条件地渲染元素

  • v-for:基于数组或对象多次渲染元素

  • v-bind:将元素的属性动态绑定到 JavaScript 表达式

  • v-on:将事件监听器附加到元素

在整本书中,我们将探索更多内容。

用法

就像普通的 HTML 属性一样,指令通常是形式为name="value"的名称/值对。要使用指令,只需将其添加到 HTML 标记中,就像添加属性一样,例如:

<p v-directive="value">

表达式

如果指令需要一个值,它将是一个表达式

在 JavaScript 语言中,表达式是小的、可评估的语句,产生单个值。表达式可以在期望值的任何地方使用,例如在if语句的括号中:

if (expression) {
  ...
}

这里的表达式可以是以下任何一种:

  • 数学表达式,例如x + 7

  • 比较,例如v <= 7

  • 例如 Vue 的data属性,例如this.myval

指令和文本插值都接受表达式值:

<div v-dir="someExpression">{{ firstName + " " + lastName }}</div>

示例:v-if

v-if将根据其值是否为表达式有条件地渲染元素。在下面的情况下,v-if将根据myval的值删除/插入p元素:

<div id="app">
  <p v-if="myval">Hello Vue</p>
</div>
<script> var app = new Vue({ el: '#app', data: { myval: true
    }
  }); </script>

将呈现为:

<div id="app">
  <p>Hello Vue</p>
</div>

如果我们添加一个带有v-else指令的连续元素(一个不需要值的特殊指令),它将在myval更改时对称地删除/插入:

<p v-if="myval">Hello Vue</p>
<p v-else>Goodbye Vue</p>

参数

一些指令需要一个参数,在指令名称后面加上冒号表示。例如,v-on指令监听 DOM 事件,需要一个参数来指定应该监听哪个事件:

<a v-on:click="doSomething">

参数不一定是click,也可以是mouseenterkeypressscroll或任何其他事件(包括自定义事件)。

样式绑定(续)

回到我们的页眉图像,我们可以使用v-bind指令和style参数将值绑定到style属性。

index.html

<div class="header-img" v-bind:style="headerImageStyle"></div>

headerImageStyle是一个表达式,它评估为设置背景图像到正确 URL 的 CSS 规则。听起来很混乱,但当你看到它工作时,就会很清楚。

现在让我们创建headerImageStyle作为数据属性。当绑定到样式属性时,可以使用一个对象,其中属性和值等同于 CSS 属性和值。

app.js

data: {
  ... headerImageStyle: {
    'background-image': 'url(sample/header.jpg)'
  }
},

保存代码,刷新页面,页眉图像将显示:

图 2.6。包括页眉图像的页面

使用浏览器开发工具检查页面,并注意v-bind指令的评估方式:

<div class="header-img" style="background-image: url('sample/header.jpg');"></div>

列表部分

我们将要添加到页面的下一部分是AmenitiesPrices列表:

图 2.7。列表部分

如果您查看模拟列表示例,您会看到对象上的amenitiesprices属性都是数组。

sample/data.js

var sample = { title: '...', address: '...', about: '...', amenities: [
    { title: 'Wireless Internet', icon: 'fa-wifi'
    },
    { title: 'Pets Allowed', icon: 'fa-paw'
    },
    ...
  ], prices: [
    { title: 'Per night', value: '$89'
    },
    { title: 'Extra people', value: 'No charge'
    },
    ...
  ]
}

如果我们可以轻松地遍历这些数组并将每个项目打印到页面上,那不是很容易吗?我们可以!这就是v-for指令的作用。

首先,让我们将这些添加为根实例上的数据属性。

app.js

data: {
  ... amenities: sample.amenities, prices: sample.prices }

列表渲染

v-for指令需要一种特殊类型的表达式,形式为item in items,其中items是源数组,item是当前正在循环的数组元素的别名。

让我们首先处理amenities数组。该数组的每个成员都是一个具有titleicon属性的对象,即:

{ title: 'something', icon: 'something' }

我们将在模板中添加v-for指令,并将其分配的表达式设置为amenity in amenities。表达式的别名部分,即amenity,将在整个循环序列中引用数组中的每个对象,从第一个开始。

index.html

<div class="container">
  <div class="heading">...</div>
  <hr>
  <div class="about">...</div>
  <div class="lists">
    <div v-for="amenity in amenities">{{ amenity.title }}</div>
  </div>
</div>

它将呈现为:

<div class="container">
  <div class="heading">...</div>
  <hr>
  <div class="about">...</div>
  <div class="lists">
    <div>Wireless Internet</div>
    <div>Pets Allowed</div>
    <div>TV</div>
    <div>Kitchen</div>
    <div>Breakfast</div>
    <div>Laptop friendly workspace</div>
  </div>
</div>

图标

我们设施对象的第二个属性是icon。这实际上是与 Font Awesome 图标字体中的图标相关的类。我们已经安装了 Font Awesome 作为 NPM 模块,因此现在可以将其添加到页面的头部以使用它。

index.html

<head> ... <link rel="stylesheet" href="node_modules/open-sans-all/css/open-sans.css">
  <link rel="stylesheet" href="node_modules/font-awesome/css/font-awesome.css">
  <link rel="stylesheet" href="style.css" type="text/css">
</head>

现在,我们可以在模板中完成我们的设施部分的结构。

index.html

<div class="lists">
  <hr>
  <div class="amenities list">
    <div class="title"><strong>Amenities</strong></div>
    <div class="content">
      <div class="list-item" v-for="amenity in amenities">
        <i class="fa fa-lg" v-bind:class="amenity.icon"></i>
        <span>{{ amenity.title }}</span>
      </div>
    </div>
  </div>
</div>

style.css

.list {
  display: flex;
  flex-wrap: nowrap;
  margin: 2em 0;
}

.list .title {
  flex: 1 1 25%;
}

.list .content {
  flex: 1 1 75%;
  display: flex;
  flex-wrap: wrap;
}

.list .list-item {
  flex: 0 0 50%;
  margin-bottom: 16px;
}

.list .list-item > i {
  width: 35px;
}

@media (max-width: 743px) {
  .list .title {
    flex: 1 1 33%;
  }

  .list .content {
    flex: 1 1 67%;
  }

  .list .list-item {
    flex: 0 0 100%;
  }
}

正如你所期望的那样,由v-for="amenity in amenities"生成的 DOM 节点与amenities数组具有响应性绑定。如果amenities的内容发生变化,Vue 将自动重新渲染节点以反映更改。

在使用v-for时,建议为列表中的每个项目提供一个唯一的key属性。这使得 Vue 能够定位需要更改的确切 DOM 节点,从而使 DOM 更新更有效率。

通常,键将是一个数字 ID,例如:

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

对于设施和价格列表,内容在应用程序的生命周期内不会发生变化,因此我们不需要提供键。一些代码检查工具可能会警告您,但在这种情况下,可以安全地忽略警告。

价格

现在让我们也将价格列表添加到我们的模板中。

index.html

<div class="lists">
  <hr>
  <div class="amenities list">...</div>
  <hr>
  <div class="prices list">
    <div class="title">
      <strong>Prices</strong>
    </div>
    <div class="content">
      <div class="list-item" v-for="price in prices"> {{ price.title }}: <strong>{{ price.value }}</strong>
      </div>
    </div>
  </div>
</div>

我相信您会同意,循环模板比逐个项目编写要容易得多。但是,您可能会注意到这两个列表之间仍然存在一些常见的标记。在本书的后面,我们将利用组件使模板的这一部分更加模块化。

显示更多功能

现在我们遇到了一个问题,即“列表”部分在“关于”部分之后。关于部分的长度是任意的,在我们将要添加的一些模拟列表中,您会看到这部分非常长。

我们不希望它主导页面并迫使用户进行大量不受欢迎的滚动以查看“列表”部分,因此我们需要一种方法,如果文本太长,则隐藏一些文本,但允许用户选择查看完整文本。

让我们添加一个“显示更多”UI 功能,它将在一定长度后裁剪“关于”文本,并为用户提供一个按钮来显示隐藏的文本:

图 2.8. 显示更多功能

我们将首先向包含about文本插值的p标签添加一个contracted类。此类的 CSS 规则将限制其高度为 250 像素,并隐藏溢出元素的任何文本。

index.html

<div class="about">
  <h3>About this listing</h3>
  <p class="contracted">{{ about }}</p>
</div>

style.css

.about p.contracted {
  height: 250px;
  overflow: hidden;
}

我们还将在p标签之后放置一个按钮,用户可以单击该按钮将该部分展开到完整高度。

index.html

<div class="about">
  <h3>About this listing</h3>
  <p class="contracted">{{ about }}</p>
  <button class="more">+ More</button>
</div>

这是所需的 CSS,包括一个通用按钮规则,该规则将为项目中将要添加的所有按钮提供基本样式。

style.css

button {
  text-align: center;
  vertical-align: middle;
  user-select: none;
  white-space: nowrap;
  cursor: pointer;
  display: inline-block;
  margin-bottom: 0;
}

.about button.more {
  background: transparent;
  border: 0;
  color: #008489;
  padding: 0;
  font-size: 17px;
  font-weight: bold;
}

.about button.more:hover, 
.about button.more:focus, 
.about button.more:active {
  text-decoration: underline;
  outline: none;
}

为了使其工作,我们需要一种方法,在用户单击“更多”按钮时删除contracted类。看起来指令是一个很好的选择!

类绑定

我们将采取的方法是动态绑定contracted类。让我们创建一个contracted数据属性,并将其初始值设置为true

app.js

data: {
  ... contracted: true
}

与我们的样式绑定一样,我们可以将此类绑定到一个对象。在表达式中,contracted属性是要绑定的类的名称,contracted值是对同名数据属性的引用,它是一个布尔值。因此,如果contracted数据属性评估为true,那么该类将绑定到元素,如果评估为false,则不会绑定。

index.html

<p v-bind:class="{ contracted: contracted }">{{ about }}</p>

因此,当页面加载时,contracted类被绑定:

<p class="contracted">...</p>

事件监听器

现在,我们希望在用户单击“更多”按钮时自动删除contracted类。为了完成这项工作,我们将使用v-on指令,该指令使用click参数监听 DOM 事件。

v-on指令的值可以是一个表达式,将contracted赋值为false

index.html

<div class="about">
  <h3>About this listing</h3>
  <p v-bind:class="{ contracted: contracted }">{{ about }}</p>
  <button class="more" v-on:click="contracted = false">+ More</button>
</div>

响应性

当我们单击“更多”按钮时,contracted值会发生变化,Vue 将立即更新页面以反映此更改。

Vue 是如何知道做到这一点的?要回答这个问题,我们必须首先了解 getter 和 setter 的概念。

获取器和设置器

为 JavaScript 对象的属性分配值就像这样简单:

var myObj = { prop: 'Hello'
}

要检索它就像这样简单:

myObj.prop

这里没有什么诀窍。不过,我想要表达的是,我们可以通过使用 getter 和 setter 来替换对象的正常赋值/检索机制。这些是特殊函数,允许自定义逻辑来获取或设置属性的值。

当一个属性的值由另一个属性确定时,getter 和 setter 特别有用。这里有一个例子:

var person = { firstName: 'Abraham', lastName: 'Lincoln',
  get fullName() {
    return this.firstName + ' ' + this.lastName;
  },
  set fullName(name) {
    var words = name.toString().split(' ');
    this.firstName = words[0] || '';
    this.lastName = words[1] || '';
  }
}

当我们尝试对fullName属性进行正常赋值/检索时,fullName属性的getset函数将被调用:

console.log(person.fullName); // Abraham Lincoln person.fullName = 'George Washington'; console.log(person.firstName); // George console.log(person.lastName) // Washington

响应式数据属性

Vue 的另一个初始化步骤是遍历所有数据属性并为其分配 getter 和 setter。如果您查看以下截图,您可以看到我们当前应用程序中的每个属性都添加了getset函数:

图 2.9 获取器和设置器

Vue 添加了这些 getter 和 setter,以使其能够在访问或修改属性时执行依赖跟踪和更改通知。因此,当contracted值通过click事件更改时,将触发其set方法。set方法将设置新值,但也将执行通知 Vue 值已更改的次要任务,并且可能需要重新渲染依赖它的页面的任何部分。

如果您想了解更多关于 Vue 的响应系统的信息,请查看文章Vue.js 中的响应性(及其陷阱),网址为vuejsdevelopers.com/2017/03/05/vue-js-reactivity/

隐藏更多按钮

一旦“关于”部分被展开,我们希望隐藏“更多”按钮,因为它不再需要。我们可以使用v-if指令与contracted属性一起实现这一点。

index.html

<button v-if="contracted" class="more" v-on:click="contracted = false"> + More
</button>

图片模态窗口

为了防止我们的页眉图片占据页面,我们对其进行了裁剪并限制了其高度。但是,如果用户想要以全貌查看图片呢?允许用户专注于单个内容项的一个很好的 UI 设计模式是模态窗口

当打开时,我们的模态框将如下所示:

图 2.10 页眉图片模态

我们的模态框将提供一个适当缩放的页眉图片视图,这样用户就可以专注于住宿的外观,而不会被页面的其他部分分散注意力。

书中稍后,我们将在模态框中插入一个图片轮播,这样用户就可以浏览整个房间图片集合!

不过,现在我们的模态框需要以下功能:

  1. 通过点击页眉图片打开模态框

  2. 冻结主窗口

  3. 显示图片

  4. 使用关闭按钮或Escape键关闭模态窗口

打开

首先,让我们添加一个布尔数据属性,表示我们模态框的打开或关闭状态。我们将其初始化为false

app.js

data: {
  ... modalOpen: false
}

我们将使点击页眉图片打开模态框。我们还将在页眉图片的左下角叠加一个标有“查看照片”的按钮,以向用户发出更强烈的信号,告诉他们应该点击以显示图片。

index.html

<div 
  class="header-img" 
  v-bind:style="headerImageStyle" 
  v-on:click="modalOpen = true" >
  <button class="view-photos">View Photos</button>
</div>

请注意,通过将点击监听器放在包装div上,无论用户点击button还是div,都将捕获点击事件,这是由于 DOM 事件传播。

我们将为页眉图片添加一些 CSS,使光标成为指针,让用户知道可以点击页眉,并为页眉添加相对位置,以便在其中定位按钮。我们还将添加样式规则来设计按钮。

style.css

.header .header-img { ... cursor: pointer;
  position: relative;
}

button {
  border-radius: 4px;
  border: 1px solid #c4c4c4;
  text-align: center;
  vertical-align: middle;
  font-weight: bold;
  line-height: 1.43;
  user-select: none;
  white-space: nowrap;
  cursor: pointer;
  background: white;
  color: #484848;
  padding: 7px 18px;
  font-size: 14px;
  display: inline-block;
  margin-bottom: 0;
}

.header .header-img .view-photos {
  position: absolute;
  bottom: 20px;
  left: 20px;
}

现在让我们为模态框添加标记。我把它放在页面的其他元素之后,尽管这并不重要,因为模态框将脱离文档的常规流程。我们通过在以下 CSS 中给它一个fixed位置来将其从流程中移除。

index.html

<div id="app">
  <div class="header">...</div>
  <div class="container">...</div>
  <div id="modal" v-bind:class="{ show : modalOpen }"></div>
</div>

主模态div将充当其余模态内容的容器,同时也是将覆盖主窗口内容的背景面板。为了实现这一点,我们使用 CSS 规则将其拉伸到完全覆盖视口,给它设置toprightbottomleft值为0。我们将z-index设置为一个较高的数字,以确保模态框叠放在页面中的任何其他元素之前。

还要注意,display最初设置为none,但我们会动态地将一个名为show的类绑定到模态框,使其显示为块级元素。当然,添加/删除这个类将绑定到modalOpen的值。

style.css

#modal {
  display: none;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
}

#modal.show {
  display: block;
}

窗口

现在让我们为将覆盖在背景面板上的窗口添加标记。窗口将具有宽度约束,并将居中显示在视口中。

index.html

<div id="modal" v-bind:class="{ show : modalOpen }">
  <div class="modal-content">
    <img src="sample/header.jpg"/>
  </div>
</div>

style.css

.modal-content {
  height: 100%;
  max-width: 105vh;
  padding-top: 12vh;
  margin: 0 auto;
  position: relative;
}

.modal-content img {
  max-width: 100%;
}

禁用主窗口

当模态框打开时,我们希望防止与主窗口的任何交互,并且清楚地区分主窗口和子窗口。我们可以通过以下方式实现这一点:

  • 调暗主窗口

  • 防止 body 滚动

调暗主窗口

当模态框打开时,我们可以简单地隐藏我们的主窗口,但是最好让用户仍然能够意识到他们在应用程序流程中的位置。为了实现这一点,我们将在半透明面板下调暗主窗口。

我们可以通过给我们的模态面板添加不透明的黑色背景来实现这一点。

style.css

#modal { ... background-color: rgba(0,0,0,0.85);
}

防止 body 滚动

不过,我们有一个问题。尽管我们的模态面板是全屏的,但它仍然是body标签的子元素。这意味着我们仍然可以滚动主窗口!我们不希望用户在模态框打开时以任何方式与主窗口进行交互,因此我们必须禁用body上的滚动。

诀窍是向body标签添加 CSS overflow属性,并将其设置为hidden。这样做的效果是裁剪任何溢出(即,当前不在视图中的页面的部分),其余内容将变为不可见。

我们需要动态添加和删除这个 CSS 规则,因为当模态框关闭时,我们显然希望能够滚动页面。因此,让我们创建一个名为modal-open的类,当模态框打开时,我们可以将其应用于body标签。

style.css

body.modal-open {
  overflow: hidden;
 position: fixed;
}

我们可以使用v-bind:class来添加/删除这个类,对吗?不幸的是,不行。请记住,Vue 只对它挂载的元素有控制权:

<body>
  <div id="app"> 
    <!--This is where Vue has dominion and can modify the page freely-->
  </div>
  <!--Vue is unable to change this part of the page or any ancestors-->
</body>

如果我们向body标签添加指令,它将不会被 Vue 看到。

Vue 的挂载元素

如果我们只是在body标签上挂载 Vue,那么这样做能解决我们的问题吗?例如:

new Vue({ el: 'body' 
});

Vue 不允许这样做,如果您尝试这样做,您将收到此错误:不要将 Vue 挂载到或 - 而是挂载到普通元素。

请记住,Vue 必须编译模板并替换挂载节点。如果您的脚本标签作为挂载节点的子元素存在(通常是body),或者如果您的用户有修改文档的浏览器插件(很多都有),那么当 Vue 替换该节点时,页面可能会出现各种问题。

如果您定义了自己的具有唯一 ID 的根元素,则不应该出现这种冲突。

观察者

那么,如果body不在 Vue 的控制范围之内,我们如何向body添加/删除类?我们将不得不用浏览器的 Web API 以老式的方式来做。当模态框打开或关闭时,我们需要运行以下语句:

// Modal opens document.body.classList.add('modal-open');

// Modal closes document.body.classList.remove('modal-closed');

正如讨论的那样,Vue 为每个数据属性添加了响应式的 getter 和 setter,以便在数据更改时知道如何适当地更新 DOM。Vue 还允许您编写自定义逻辑,以通过名为watchers的功能钩入响应式数据更改。

要添加观察者,首先向 Vue 实例添加watch属性。将一个对象分配给这个属性,其中每个属性都有一个声明的数据属性的名称,每个值都是一个函数。该函数有两个参数:旧值和新值。

每当数据属性更改时,Vue 将触发任何声明的观察者方法:

var app = new Vue({ el: '#app' data: { message: 'Hello world'
  }, watch: { message: function(newVal, oldVal) { console.log(oldVal, ', ', newVal);
    }
  }
});

setTimeout(function() { app.message = 'Goodbye world';
  // Output: "Hello world, Goodbye world";
}, 2000);

Vue 不能为我们更新body标签,但它可以触发将要更新的自定义逻辑。让我们使用一个观察者来在我们的模态框打开和关闭时更新body标签。

app.js

var app = new Vue({ data: { ... }, watch: { modalOpen: function() {
      var className = 'modal-open';
      if (this.modalOpen) { document.body.classList.add(className);
      } else { document.body.classList.remove(className);
      }
    }
  }
});

现在当您尝试滚动页面时,您会发现它不会动!

关闭

用户需要一种关闭他们的模态框并返回到主窗口的方法。我们将在右上角叠加一个按钮,当点击时,会评估一个表达式来将modalOpen设置为false。我们包装div上的show类将随之被移除,这意味着display CSS 属性将返回到none,从而将模态框从页面中移除。

index.html

<div id="modal" v-bind:class="{ show : modalOpen }">
  <button v-on:click="modalOpen = false" class="modal-close"> &times; </button>
  <div class="modal-content">
    <img src="sample/header.jpg"/>
  </div>
</div>

style.css

.modal-close {
  position: absolute;
  right: 0;
  top: 0;
  padding: 0px 28px 8px;
  font-size: 4em;
  width: auto;
  height: auto;
  background: transparent;
  border: 0;
  outline: none;
  color: #ffffff;
  z-index: 1000;
  font-weight: 100;
  line-height: 1;
}

Escape 键

为我们的模态框添加一个关闭按钮很方便,但大多数人关闭窗口的本能动作是按下Escape键。

v-on是 Vue 监听事件的机制,似乎是这项工作的一个很好的选择。添加keyup参数将在此输入聚焦时按下任何键后触发处理程序回调:

<input v-on:keyup="handler">

事件修饰符

Vue 通过为v-on指令提供修饰符来轻松监听特定键。修饰符是由点(.)表示的后缀,例如:

<input v-on:keyup.enter="handler">

正如您可能猜到的那样,.enter修饰符告诉 Vue 仅在事件由Enter键触发时调用处理程序。修饰符使您无需记住特定的键码,还使您的模板逻辑更加明显。Vue 提供了各种其他键修饰符,包括:

  • tab

  • delete

  • 空间

  • esc

考虑到这一点,似乎我们可以使用这个指令关闭我们的模态框:

v-on:keyup.esc="modalOpen = false"

但是我们应该将这个指令附加到哪个标签呢?不幸的是,除非有一个输入被聚焦,否则键事件将从body元素分派,而我们知道这是 Vue 无法控制的!

为了处理这个事件,我们将再次求助于 Web API。

app.js

var app = new Vue({ 
  ... 
}); document.addEventListener(</span>'keyup', function(evt) {
  if (evt.keyCode === 27 && app.modalOpen) { app.modalOpen = false;
  }
});

这个方法可以工作,但有一个警告(在下一节中讨论)。但是 Vue 可以帮助我们使它完美。

生命周期钩子

当您的主脚本运行并设置了 Vue 的实例时,它会经历一系列的初始化步骤。正如我们之前所说的,Vue 将遍历您的数据对象并使它们具有反应性,同时编译模板并挂载到 DOM 上。在生命周期的后期,Vue 还将经历更新步骤,然后是拆卸步骤。

这是从vuejs.org获取的生命周期实例图。其中许多步骤涉及到我们尚未涵盖的概念,但您应该能够理解大意:

图 2.11. Vue.js 生命周期图

Vue 允许您通过生命周期钩子在这些不同的步骤执行自定义逻辑,这些钩子是在配置对象中定义的回调函数。

例如,这里我们利用了beforeCreatecreated钩子:

new Vue({ data: { message: 'Hello'
  }, beforeCreate: function() { console.log('beforeCreate message: ' + this.message);
    // "beforeCreate message: undefined"
  }, created: function() { console.log('created: '+ this.message);
    // "created message: Hello"
  },
});

beforeCreate钩子被调用之后但在created钩子被调用之前,Vue 将数据属性别名为上下文对象,因此在前者中this.messageundefined

我之前提到的关于Escape键监听器的警告是:虽然不太可能,但如果在按下Escape键并且我们的回调在 Vue 代理数据属性之前被调用,app.modalOpen将是undefined而不是true,因此我们的if语句将无法像我们期望的那样控制流程。

为了克服这个问题,我们可以在created生命周期钩子中设置监听器,该监听器将在 Vue 代理数据属性之后调用。这给了我们一个保证,当回调运行时,modalOpen将被定义。

app.js

function escapeKeyListener(evt) {
  if (evt.keyCode === 27 && app.modalOpen) { app.modalOpen = false;
  }
}

var app = new Vue({ data: { ... }, watch: { ... }, created: function() { document.addEventListener('keyup', escapeKeyListener);
  }
});

方法

Vue 配置对象还有一个方法部分。方法不是响应式的,因此您可以在 Vue 配置之外定义它们,而在功能上没有任何区别,但 Vue 方法的优势在于它们作为上下文传递了 Vue 实例,因此可以轻松访问您的其他属性和方法。

让我们重构我们的escapeKeyListener成为一个Vue实例方法。

app.js

var app = new Vue({ data: { ... }, methods: { escapeKeyListener: function(evt) {
      if (evt.keyCode === 27 && this.modalOpen) {
        this.modalOpen = false;
      }
    }
  }, watch: { ... },
 created: function() { document.addEventListener('keyup', this.escapeKeyListener);
  }
});

代理属性

你可能已经注意到我们的escapeKeyListener方法可以引用this.modalOpen。难道不应该是this.methods.modalOpen吗?

当 Vue 实例被构建时,它会将任何数据属性、方法和计算属性代理到实例对象。这意味着在任何方法中,你可以引用this.myDataPropertythis.myMethod等,而不是this.data.myDataPropertythis.methods.myMethod,正如你可能会假设的那样:

var app = new Vue({ data: { myDataProperty: 'Hello'
  }, methods: { myMethod: function() {
      return this.myDataProperty + ' World';
    }
  }
}); console.log(app.myMethod());
// Output: 'Hello World' 

你可以通过在浏览器控制台中打印 Vue 对象来查看这些代理属性:

图 2.12。我们应用的 Vue 实例

现在文本插值的简单性可能更有意义,它们具有 Vue 实例的上下文,并且由于代理属性的存在,可以像{{ myDataProperty }}一样被引用。

然而,虽然代理到根使语法更简洁,但一个后果是你不能用相同的名称命名你的数据属性、方法或计算属性!

移除监听器

为了避免任何内存泄漏,当 Vue 实例被销毁时,我们还应该使用removeEventListener来摆脱监听器。我们可以使用destroy钩子,并调用我们的escapeKeyListener方法来实现这个目的。

app.js

new Vue({ data: { ... }, methods: { ... }, watch: { ... }, created: function() { ... }, destroyed: function () { document.removeEventListener('keyup', this.escapeKeyListener);
  }
});

摘要

在本章中,我们熟悉了 Vue 的基本特性,包括安装和基本配置、数据绑定、文本插值、指令、方法、观察者和生命周期钩子。我们还了解了 Vue 的内部工作原理,包括响应系统。

然后,我们利用这些知识来设置一个基本的 Vue 项目,并为 Vuebnb 原型创建页面内容,包括文本、信息列表、页眉图像,以及 UI 小部件,如“显示更多”按钮和模态窗口。

在下一章中,我们将暂时离开 Vue,同时使用 Laravel 为 Vuebnb 设置后端。

第三章:设置 Laravel 开发环境

在本书的前两章中,我们介绍了 Vue.js。您现在应该对其基本功能非常熟悉。在本章中,我们将启动一个 Laravel 开发环境,准备构建 Vuebnb 的后端。

本章涵盖的主题:

  • Laravel 简介

  • 设置 Homestead 虚拟开发环境

  • 配置 Homestead 以托管 Vuebnb

Laravel

Laravel 是一个用于构建强大的 Web 应用程序的 PHP 开源 MVC 框架。Laravel 目前版本为 5.5,是最受欢迎的 PHP 框架之一,因其优雅的语法和强大的功能而备受喜爱。

Laravel 适用于创建各种基于 Web 的项目,例如以下项目:

  • 具有用户认证的网站,如客户门户或社交网络

  • Web 应用程序,如图像裁剪器或监控仪表板

  • Web 服务,如 RESTful API

在本书中,我假设您对 Laravel 有基本的了解。您应该熟悉安装和设置 Laravel,并熟悉其核心功能,如路由、视图和中间件。

如果您是 Laravel 新手或认为自己可能需要温习一下,您应该花一两个小时阅读 Laravel 的优秀文档,然后再继续阅读本书:laravel.com/docs/5.5/

Laravel 和 Vue

Laravel 可能看起来像一个庞大的框架,因为它包括了构建几乎任何类型的 Web 应用程序的功能。然而,在幕后,Laravel 实际上是许多独立模块的集合,其中一些是作为 Laravel 项目的一部分开发的,一些来自第三方作者。Laravel 之所以伟大,部分原因在于它对这些组成模块的精心策划和无缝连接。

自 Laravel 5.3 版本以来,Vue.js 一直是 Laravel 安装中包含的默认前端框架。没有官方原因说明为什么选择 Vue 而不是其他值得选择的选项,如 React,但我猜想是因为 Vue 和 Laravel 分享相同的理念:简单和对开发者体验的重视。

无论出于什么原因,Vue 和 Laravel 都提供了一个非常强大和灵活的全栈框架,用于开发 Web 应用程序。

环境

我们将使用 Laravel 5.5 作为 Vuebnb 的后端。这个版本的 Laravel 需要 PHP 7、几个 PHP 扩展和以下软件:

  • Composer

  • 一个 Web 服务器,如 Apache 或 Nginx

  • 数据库,如 MySQL 或 MariaDB

Laravel 的完整要求列表可以在安装指南中找到:laravel.com/docs/5.5#installation

我强烈建议您使用Homestead开发环境,而不是在计算机上手动安装 Laravel 的要求,因为 Homestead 已经预先安装了所有您需要的东西。

Homestead

Laravel Homestead 是一个虚拟的 Web 应用程序环境,运行在 Vagrant 和 VirtualBox 上,可以在任何 Windows、Mac 或 Linux 系统上运行。

使用 Homestead 将为您节省从头开始设置开发环境的麻烦。它还将确保您拥有与我使用的相同环境,这将使您更容易跟随本书的内容。

如果您的计算机上没有安装 Homestead,请按照 Laravel 文档中的说明进行操作:laravel.com/docs/5.5/homestead。使用默认配置选项。

安装了 Homestead 并使用vagrant up命令启动了 Vagrant 虚拟机后,您就可以继续了。

Vuebnb

在第二章中,原型设计 Vuebnb,您的第一个 Vue.js 项目,我们制作了 Vuebnb 前端的原型。原型是从一个单独的 HTML 文件创建的,我们直接从浏览器加载。

现在我们将开始处理全栈 Vuebnb 项目,原型很快将成为其中的关键部分。这个主要项目将是一个完整的 Laravel 安装,带有 Web 服务器和数据库。

项目代码

如果您还没有这样做,您需要从 GitHub 克隆代码库到您的计算机上。在第一章的代码库部分中给出了说明,Hello Vue - Vue.js 简介

代码库中的vuebnb文件夹包含我们现在想要使用的项目代码。切换到此文件夹并列出内容:

$ cd vuebnb
$ ls -la

文件夹内容应该如下所示:

图 3.1。vuebnb 项目文件

共享文件夹

Homestead.yaml文件的folders属性列出了您希望在计算机和 Homestead 环境之间共享的所有文件夹。

确保代码库与 Homestead 共享,以便我们在本章后期可以从 Homestead 的 Web 服务器上提供 Vuebnb。

~/Homestead/Homestead.yaml

folders:
  - map: /Users/anthonygore/Projects/Full-Stack-Vue.js-2-and-Laravel-5
    to: /home/vagrant/projects

终端命令

本书中的所有进一步的终端命令都将相对于项目目录给出,即vuebnb,除非另有说明。

然而,由于项目目录在主机计算机和 Homestead 之间共享,终端命令可以从这两个环境中的任何一个运行。

Homestead 可以避免您在主机计算机上安装任何软件。但如果您不这样做,许多终端命令可能无法正常工作,或者在主机环境中可能无法正常工作。例如,如果您的主机计算机上没有安装 PHP,您就无法从中运行 Artisan 命令:

$ php artisan --version
-bash: php: command not found

如果您是这种情况,您需要首先通过 SSH 连接在 Homestead 环境中运行这些命令:

$ cd ~/Homestead
$ vagrant ssh

然后,切换到操作系统中的项目目录,同样的终端命令现在将起作用:

$ cd ~/projects/vuebnb
$ php artisan --version
Laravel Framework 5.5.20

从 Homestead 运行命令的唯一缺点是由于 SSH 连接而变慢。我将让您决定您更愿意使用哪一个。

环境变量

Laravel 项目需要在.env文件中设置某些环境变量。现在通过复制环境文件示例来创建一个:

$ cp .env.example .env

通过运行此命令生成应用程序密钥:

$ php artisan key:generate

我已经预设了大多数其他相关的环境变量,所以除非您已经按照我不同的方式配置了 Homestead,否则您不需要更改任何内容。

Composer 安装

要完成安装过程,我们必须运行composer install来下载所有所需的软件包:

$ composer install

数据库

我们将使用关系数据库来在后端应用程序中持久保存数据。Homestead 默认情况下运行 MySQL;您只需在.env文件中提供配置以在 Laravel 中使用它。默认配置将在没有进一步更改的情况下工作。

.env

DB_CONNECTION=mysql
DB_HOST=192.168.10.10
DB_PORT=3306
DB_DATABASE=vuebnb
DB_USERNAME=homestead
DB_PASSWORD=secret

无论您为数据库选择什么名称(即DB_DATABASE的值),都要确保将其添加到Homestead.yaml文件中的databases数组中。

~/Homestead/Homestead.yaml

databases:
  ... - vuebnb

提供项目

主要的 Vuebnb 项目现在已安装。让我们让 Web 服务器在本地开发域vuebnb.test上提供它。

在 Homestead 配置文件中,将vuebnb.test映射到项目的public文件夹。

~/Homestead/Homestead.yaml

sites:
  ... - map: vuebnb.test
    to: /home/vagrant/vuebnb/public

本地 DNS 条目

我们还需要更新计算机的主机文件,以便它理解vuebnb.test和 Web 服务器的 IP 之间的映射。Web 服务器位于 Homestead 框中,默认情况下 IP 为192.168.10.10

要在 Mac 上配置这个,打开您的主机文件/etc/hosts,在文本编辑器中添加这个条目:

192.168.10.10 vuebnb.test

在 Windows 系统上,hosts 文件通常可以在C:\Windows\System32\Drivers\etc\hosts找到。

访问项目

配置完成后,我们现在可以从Homestead目录中运行vagrant provision来完成设置:

$ cd ~/Homestead
$ vagrant provision
# The next command will return you to the project directory
$ cd -

当配置过程完成时,我们应该能够在浏览器中输入http://vuebnb.test来看到我们的网站运行:

图 3.2. Laravel 欢迎视图

现在我们准备开始开发 Vuebnb!

总结

在这个简短的章节中,我们讨论了开发 Laravel 项目的要求。然后安装并配置了 Homestead 虚拟开发环境来托管我们的主要项目 Vuebnb。

在下一章中,我们将通过构建一个 Web 服务来为 Vuebnb 的前端提供数据,开始我们的主要项目工作。

第四章:使用 Laravel 构建网络服务

在上一章中,我们已经启动并运行了 Homestead 开发环境,并开始为主要的 Vuebnb 项目提供服务。在本章中,我们将创建一个简单的网络服务,使 Vuebnb 的房间列表数据可以在前端显示。

本章涵盖的主题:

  • 使用 Laravel 创建网络服务

  • 编写数据库迁移和种子文件

  • 创建 API 端点以使数据公开访问

  • 从 Laravel 提供图像

Vuebnb 房间列表

在第二章中,Vuebnb 原型设计,您的第一个 Vue.js 项目,我们构建了前端应用程序的列表页面原型。很快,我们将删除此页面上的硬编码数据,并将其转换为可以显示任何房间列表的模板。

在本书中,我们不会为用户创建他们自己的房间列表添加功能。相反,我们将使用包含 30 个不同列表的模拟数据包,每个列表都有自己独特的标题、描述和图像。我们将使用这些列表填充数据库,并配置 Laravel 根据需要将它们提供给前端。

网络服务

网络服务是在服务器上运行的应用程序,允许客户端(如浏览器)通过 HTTP 远程写入/检索数据到/从服务器。

网络服务的接口将是一个或多个 API 端点,有时会受到身份验证的保护,它们将以 XML 或 JSON 有效负载返回数据:

图 4.1。Vuebnb 网络服务

网络服务是 Laravel 的特长,因此为 Vuebnb 创建一个网络服务不难。我们将使用路由来表示我们的 API 端点,并使用 Laravel 无缝同步与数据库的 Eloquent 模型来表示列表:

图 4.2。网络服务架构

Laravel 还具有内置功能,可以添加 REST 等 API 架构,尽管我们不需要这个简单的用例。

模拟数据

模拟列表数据在文件database/data.json中。该文件包括一个 JSON 编码的数组,其中包含 30 个对象,每个对象代表一个不同的列表。在构建了列表页面原型之后,您无疑会认出这些对象上的许多相同属性,包括标题、地址和描述。

database/data.json

[
  {
    "id": 1,
    "title": "Central Downtown Apartment with Amenities",
    "address": "...",
    "about": "...",
    "amenity_wifi": true,
    "amenity_pets_allowed": true,
    "amenity_tv": true,
    "amenity_kitchen": true,
    "amenity_breakfast": true,
    "amenity_laptop": true,
    "price_per_night": "$89"
    "price_extra_people": "No charge",
    "price_weekly_discount": "18%",
    "price_monthly_discount": "50%",
  },
  {
    "id": 2, ... }, ... ]

每个模拟列表还包括房间的几张图片。图像并不真正属于网络服务的一部分,但它们将存储在我们应用程序的公共文件夹中,以便根据需要提供服务。

图像文件不在项目代码中,而是在我们从 GitHub 下载的代码库中。我们将在本章后期将它们复制到我们的项目文件夹中。

数据库

我们的网络服务将需要一个用于存储模拟列表数据的数据库表。为此,我们需要创建一个模式和迁移。然后,我们将创建一个 seeder,它将加载和解析我们的模拟数据文件,并将其插入数据库,以便在应用程序中使用。

迁移

迁移是一个特殊的类,其中包含针对数据库运行的一组操作,例如创建或修改数据库表。迁移确保每次创建应用程序的新实例时,例如在生产环境中安装或在团队成员的机器上安装时,您的数据库都会被相同地设置。

要创建新的迁移,请使用make:migration Artisan CLI 命令。命令的参数应该是迁移将要执行的操作的蛇形描述。

$ php artisan make:migration create_listings_table

现在您将在database/migrations目录中看到新的迁移。您会注意到文件名具有前缀时间戳,例如2017_06_20_133317_create_listings_table.php。时间戳允许 Laravel 确定迁移的正确顺序,以防需要同时运行多个迁移。

您的新迁移声明了一个扩展了Migration的类。它覆盖了两个方法:up用于向数据库添加新表、列或索引;down用于删除它们。我们很快将实现这些方法。

2017_06_20_133317_create_listings_table.php

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateListingsTable extends Migration
{
  public function up()
  {
    //
  }

  public function down()
  {
    //
  }
}

模式

模式是数据库结构的蓝图。对于诸如 MySQL 之类的关系数据库,模式将数据组织成表和列。在 Laravel 中,可以使用Schema外观的create方法声明模式。

现在我们将为一个表创建一个模式,用于保存 Vuebnb 列表。表的列将与我们的模拟列表数据的结构相匹配。请注意,我们为设施设置了默认的false值,并允许价格有一个NULL值。所有其他列都需要一个值。

模式将放在我们迁移的up方法中。我们还将填写down,调用Schema::drop

2017_06_20_133317_create_listings_table.php

public function up()
{ Schema::create('listings', function (Blueprint $table) {
    $table->primary('id');
    $table->unsignedInteger('id');
    $table->string('title');
    $table->string('address');
    $table->longText('about');

    // Amenities
    $table->boolean('amenity_wifi')->default(false);
    $table->boolean('amenity_pets_allowed')->default(false);
    $table->boolean('amenity_tv')->default(false);
    $table->boolean('amenity_kitchen')->default(false);
    $table->boolean('amenity_breakfast')->default(false);
    $table->boolean('amenity_laptop')->default(false);

    // Prices
    $table->string('price_per_night')->nullable();
    $table->string('price_extra_people')->nullable();
    $table->string('price_weekly_discount')->nullable();
    $table->string('price_monthly_discount')->nullable();
  });
}

public function down()
{ Schema::drop('listings');
}

外观是一种面向对象的设计模式,用于在服务容器中创建对底层类的静态代理。外观不是为了提供任何新功能;它的唯一目的是提供一种更易记和易读的方式来执行常见操作。将其视为面向对象的辅助函数。

执行

现在我们已经设置了新的迁移,让我们使用这个 Artisan 命令来运行它:

$ php artisan migrate

您应该在终端中看到类似以下的输出:

Migrating: 2017_06_20_133317_create_listings_table
Migrated:  2017_06_20_133317_create_listings_table

要确认迁移是否成功,让我们使用 Tinker 来显示新表的结构。如果您从未使用过 Tinker,它是一个 REPL 工具,允许您在命令行上与 Laravel 应用程序进行交互。当您在 Tinker 中输入命令时,它将被评估为您的应用程序代码中的一行。

首先,打开 Tinker shell:

$ php artisan tinker

现在输入一个 PHP 语句进行评估。让我们使用DB外观的select方法来运行一个 SQLDESCRIBE查询,以显示表结构:

>>>> DB::select('DESCRIBE listings;');

输出非常冗长,所以我不会在这里重复,但您应该看到一个包含所有表细节的对象,确认迁移已经成功。

种子模拟列表

现在我们有了列表的数据库表,让我们用模拟数据填充它。为此,我们需要做以下事情:

  1. 加载database/data.json文件

  2. 解析文件

  3. 将数据插入列表表中

创建一个 seeder

Laravel 包括一个我们可以扩展的 seeder 类,称为Seeder。使用此 Artisan 命令来实现它:

$ php artisan make:seeder ListingsTableSeeder

当我们运行 seeder 时,run方法中的任何代码都会被执行。

database/ListingsTableSeeder.php

<?php

use Illuminate\Database\Seeder;

class ListingsTableSeeder extends Seeder
{
  public function run()
  {
    //
  }
}

加载模拟数据

Laravel 提供了一个File外观,允许我们简单地从磁盘打开文件,如File::get($path)。要获取模拟数据文件的完整路径,我们可以使用base_path()辅助函数,它将应用程序目录的根路径作为字符串返回。

然后,可以使用内置的json_decode方法将此 JSON 文件转换为 PHP 数组。一旦数据是一个数组,只要表的列名与数组键相同,就可以直接将数据插入数据库。

database/ListingsTableSeeder.php

public function run()
{
  $path = base_path() . '/database/data.json';
  $file = File::get($path);
  $data = json_decode($file, true);
}

插入数据

为了插入数据,我们将再次使用DB外观。这次我们将调用table方法,它返回一个Builder的实例。Builder类是一个流畅的查询构建器,允许我们通过链接约束来查询数据库,例如DB::table(...)->where(...)->join(...)等。让我们使用构建器的insert方法,它接受一个列名和值的数组。

database/seeds/ListingsTableSeeder.php

public function run()
{
  $path = base_path() . '/database/data.json';
  $file = File::get($path);
  $data = json_decode($file, true);
  DB::table('listings')->insert($data);
}

执行 seeder

要执行 seeder,我们必须从相同目录中的DatabaseSeeder.php文件中调用它。

database/seeds/DatabaseSeeder.php

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
  public function run()
  {
    $this->call(ListingsTableSeeder::class);
  }
}

完成后,我们可以使用 Artisan CLI 来执行 seeder:

$ php artisan db:seed

您应该在终端中看到以下输出:

Seeding: ListingsTableSeeder

我们将再次使用 Tinker 来检查我们的工作。模拟数据中有 30 个列表,所以为了确认种子成功,让我们检查数据库中是否有 30 行:

$ php artisan tinker >>>> DB::table('listings')->count(); 
# Output: 30

最后,让我们检查表的第一行,以确保其内容符合我们的预期:

>>>> DB::table('listings')->get()->first();

以下是输出:

=> {#732
 +"id": 1,
 +"title": "Central Downtown Apartment with Amenities",
 +"address": "No. 11, Song-Sho Road, Taipei City, Taiwan 105",
 +"about": "...",
 +"amenity_wifi": 1,
 +"amenity_pets_allowed": 1,
 +"amenity_tv": 1,
 +"amenity_kitchen": 1,
 +"amenity_breakfast": 1,
 +"amenity_laptop": 1,
 +"price_per_night": "$89",
 +"price_extra_people": "No charge",
 +"price_weekly_discount": "18%",
 +"price_monthly_discount": "50%"
}

如果你的看起来像这样,那么你已经准备好继续了!

列表模型

我们现在已经成功为我们的列表创建了一个数据库表,并用模拟列表数据进行了种子。现在我们如何从 Laravel 应用程序中访问这些数据呢?

我们看到DB外观让我们直接在数据库上执行查询。但是 Laravel 提供了一种更强大的方式通过Eloquent ORM访问数据。

Eloquent ORM

对象关系映射ORM)是一种在面向对象编程语言中在不兼容的系统之间转换数据的技术。MySQL 等关系数据库只能存储整数和字符串等标量值,这些值组织在表中。但是我们希望在我们的应用程序中使用丰富的对象,因此我们需要一种强大的转换方式。

Eloquent 是 Laravel 中使用的 ORM 实现。它使用活动记录设计模式,其中一个模型与一个数据库表绑定,模型的一个实例与一行绑定。

要在 Laravel 中使用 Eloquent ORM 创建模型,只需使用 Artisan 扩展Illuminate\Database\Eloquent\Model类:

$ php artisan make:model Listing

这将生成一个新文件。

app/Listing.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Listing extends Model
{
  //
}

我们如何告诉 ORM 要映射到哪个表,以及要包含哪些列?默认情况下,Model类使用类名(Listing)的小写形式(listing)作为要使用的表名。并且默认情况下,它使用表中的所有字段。

现在,每当我们想要加载我们的列表时,我们可以在我们的应用程序的任何地方使用这样的代码:

<?php

// Load all listings
$listings = \App\Listing::all();

// Iterate listings, echo the address
foreach ($listings as $listing) {
  echo $listing->address . '\n' ;
}

/*
 * Output:
 *
 * No. 11, Song-Sho Road, Taipei City, Taiwan 105
 * 110, Taiwan, Taipei City, Xinyi District, Section 5, Xinyi Road, 7
 * No. 51, Hanzhong Street, Wanhua District, Taipei City, Taiwan 108
 * ... */

转换

MySQL 数据库中的数据类型与 PHP 中的数据类型并不完全匹配。例如,ORM 如何知道数据库值 0 是表示数字 0 还是布尔值false

Eloquent 模型可以使用$casts属性声明任何特定属性的数据类型。$casts是一个键/值数组,其中键是要转换的属性的名称,值是我们要转换为的数据类型。

对于列表表,我们将把设施属性转换为布尔值。

app/Listing.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Listing extends Model
{
  protected $casts = [
    'amenity_wifi' => 'boolean',
    'amenity_pets_allowed' => 'boolean',
    'amenity_tv' => 'boolean',
    'amenity_kitchen' => 'boolean',
    'amenity_breakfast' => 'boolean',
    'amenity_laptop' => 'boolean'
  ];
}

现在这些属性将具有正确的类型,使我们的模型更加健壮:

echo gettype($listing->amenity_wifi());

// boolean

公共接口

我们 Web 服务的最后一部分是公共接口,允许客户端应用程序请求列表数据。由于 Vuebnb 列表页面设计为一次显示一个列表,所以我们至少需要一个端点来检索单个列表。

现在让我们创建一个路由,将匹配任何传入的 GET 请求到 URI/api/listing/{listing},其中{listing}是一个 ID。我们将把这个路由放在routes/api.php文件中,路由会自动添加/api/前缀,并且默认情况下具有用于 Web 服务的中间件优化。

我们将使用closure函数来处理路由。该函数将有一个$listing参数,我们将其类型提示为Listing类的实例,也就是我们的模型。Laravel 的服务容器将解析此实例,其 ID 与{listing}匹配。

然后我们可以将模型编码为 JSON 并将其作为响应返回。

routes/api.php

<?php

use App\Listing; Route::get('listing/{listing}', function(Listing $listing) {
  return $listing->toJson();  
});

我们可以使用终端上的curl命令来测试这个功能是否有效:

$ curl http://vuebnb.test/api/listing/1

响应将是 ID 为 1 的列表:

图 4.3。Vuebnb Web 服务的 JSON 响应

控制器

随着项目的进展,我们将添加更多的路由来检索列表数据。最佳实践是使用controller类来实现这个功能,以保持关注点的分离。让我们使用 Artisan CLI 创建一个:

$ php artisan make:controller ListingController

然后我们将从路由中的功能移动到一个新的方法get_listing_api

app/Http/Controllers/ListingController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Listing;

class ListingController extends Controller
{
  public function get_listing_api(Listing $listing) 
  {
    return $listing->toJson();
  }
}

对于Route::get方法,我们可以将字符串作为第二个参数传递,而不是closure函数。字符串应该是[controller]@[method]的形式,例如ListingController@get_listing_web。Laravel 将在运行时正确解析这个。

routes/api.php

<?php Route::get('/listing/{listing}', 'ListingController@get_listing_api');

图像

正如在本章开头所述,每个模拟列表都附带了房间的几张图片。这些图片不在项目代码中,必须从代码库中名为images的平行目录中复制。

将此目录的内容复制到public/images文件夹中:

$ cp -a ../images/. ./public/images

一旦您复制了这些文件,public/images将有 30 个子文件夹,每个模拟列表一个。每个文件夹将包含四张主要图片和一个缩略图图片:

图 4.4。公共文件夹中的图像文件

访问图像

public目录中的文件可以通过将它们的相对路径附加到站点 URL 直接请求。例如,默认的 CSS 文件public/css/app.css可以在http://vuebnb.test/css/app.css请求。

使用public文件夹的优势,以及我们将图像放在那里的原因,是避免创建任何访问它们的逻辑。然后前端应用程序可以直接在img标签中调用图像。

您可能认为我们的网络服务器以这种方式提供图像是低效的,您是对的。在本书的后面,当处于生产模式时,我们将从 CDN 提供图像。

让我们尝试在浏览器中打开一个模拟列表图片来测试这个论点:http://vuebnb.test/images/1/Image_1.jpg

图 4.5。在浏览器中显示的模拟列表图像

图片链接

Web 服务的每个列表的负载应该包括指向这些新图像的链接,这样客户端应用程序就知道在哪里找到它们。让我们将图像路径添加到我们的列表 API 负载中,使其看起来像这样:

{
  "id": 1,
  "title": "...",
  "description": "...",
  ... "image_1": "http://vuebnb.test/app/image/1/Image_1.jpg",
  "image_2": "http://vuebnb.test/app/image/1/Image_2.jpg",
  "image_3": "http://vuebnb.test/app/image/1/Image_3.jpg",
  "image_4": "http://vuebnb.test/app/image/1/Image_4.jpg"
}

缩略图图像直到项目后期才会被使用。

为了实现这一点,我们将使用我们模型的toArray方法来创建模型的数组表示。然后我们将能够轻松地添加新字段。每个模拟列表都有四张图片,编号为 1 到 4,所以我们可以使用for循环和asset助手来生成公共文件夹中文件的完全合格的 URL。

最后,通过调用response助手创建Response类的实例。我们使用json方法并传入我们的字段数组,返回结果。

app/Http/Controllers/ListingController.php

public function get_listing_api(Listing $listing) 
{
  $model = $listing->toArray();
  for($i = 1; $i <=4; $i++) {
    $model['image_' . $i] = asset( 'images/' . $listing->id . '/Image_' . $i . '.jpg' );
  }
  return response()->json($model);
}

/api/listing/{listing}端点现在已准备好供客户端应用程序使用。

总结

在本章中,我们使用 Laravel 构建了一个 Web 服务,使 Vuebnb 列表数据可以公开访问。

这涉及使用迁移和模式设置数据库表,然后使用路由向数据库中填充模拟列表数据。然后我们创建了一个公共接口,用于返回模拟数据作为 JSON 负载,包括指向我们模拟图像的链接。

在下一章中,我们将介绍 Webpack 和 Laravel Mix 构建工具,以建立一个全栈开发环境。我们将把 Vuebnb 原型迁移到项目中,并对其进行重构以适应新的工作流程。

第五章:将 Laravel 和 Vue.js 集成到 Webpack 中

在本章中,我们将把 Vuebnb 前端原型迁移到我们的主要 Laravel 项目中,实现 Vuebnb 的第一个全栈迭代。这个完全集成的环境将包括一个 Webpack 构建步骤,允许我们在继续构建前端时整合更复杂的工具和技术。

本章涵盖的主题:

  • Laravel 开箱即用前端应用程序简介

  • Webpack 的高级概述

  • 如何配置 Laravel Mix 来编译前端资产

  • 将 Vuebnb 原型迁移到全栈 Laravel 环境中

  • 在 Vue.js 中使用 ES2015,包括为旧浏览器提供语法和 polyfills

  • 将前端应用程序中的硬编码数据切换为后端数据

Laravel 前端

我们认为 Laravel 是一个后端框架,但是一个新的 Laravel 项目也包括了前端应用程序的样板代码和配置。

前端开箱即用包括 JavaScript 和 Sass 资产文件,以及一个package.json文件,指定依赖项,如 Vue.js、jQuery 和 Bootstrap。

让我们看看这个样板代码和配置,以便我们了解 Vuebnb 前端应用程序在我们开始迁移时将如何适应我们的 Laravel 项目。

JavaScript

JavaScript 资产保存在resources/assets/js文件夹中。该目录中有几个.js文件,以及一个子目录component,其中有一个.vue文件。我们将在另一章节中解释后者,所以现在我们将忽略它。

主 JavaScript 文件是app.js。在这个文件中,你会看到熟悉的 Vue 构造函数,但也会有一些不太熟悉的语法。第一行是一个require函数,用于导入一个相邻的文件bootstrap.js,它又加载其他库,包括 jQuery 和 Lodash。

require不是标准的 JavaScript 函数,必须在代码在浏览器中使用之前进行解析。

resources/assets/js/app.js

require('./bootstrap'); window.Vue = require('vue'); Vue.component('example', require('./components/Example.vue'));

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

CSS

如果你以前没有听说过Sass,它是一种 CSS 扩展,使开发 CSS 更容易。默认的 Laravel 安装包括resources/assets/sass目录,其中包括两个样板 Sass 文件。

主 Sass 文件是app.scss。它的工作是导入其他 Sass 文件,包括 Bootstrap CSS 框架。

resources/assets/sass/app.scss

// Fonts
@import url("https://fonts.googleapis.com/css?family=Raleway:300,400,600");

// Variables
@import "variables";

// Bootstrap
@import "~bootstrap-sass/assets/stylesheets/bootstrap";

节点模块

Laravel 前端的另一个关键方面是项目目录根目录中的package.json文件。与composer.json类似,该文件用于配置和依赖管理,只不过是用于 Node 模块而不是 PHP。

package.json的属性之一是devDependencies,指定了开发环境中需要的模块,包括 jQuery、Vue 和 Lodash。

package.json

{ ... "devDependencies": {
    "axios": "⁰.17",
    "bootstrap-sass": "³.3.7",
    "cross-env": "⁵.1",
    "jquery": "³.2",
    "laravel-mix": "¹.4",
    "lodash": "⁴.17.4",
    "vue": "².5.3"
  }
}

视图

要在 Laravel 中提供前端应用程序,需要将其包含在视图中。唯一提供的开箱即用视图是位于resources/views/welcome.blade.phpwelcome视图,用作样板首页。

welcome视图实际上不包括前端应用程序,用户需要自行安装。我们将在本章后面讨论如何做到这一点。

资产编译

resources/assets中的文件包括不能直接在浏览器中使用的函数和语法。例如,在app.js中使用的require方法,用于导入 JavaScript 模块,不是原生 JavaScript 方法,也不是标准 Web API 的一部分:

图 5.1. 浏览器中未定义require

需要一个构建工具来获取这些资产文件,解析任何非标准函数和语法,并输出浏览器可以使用的代码。前端资产有许多流行的构建工具,包括 Grunt、Gulp 和 Webpack:

图 5.2. 资产编译过程

我们之所以要使用这个资产编译过程,是为了能够在不受浏览器限制的情况下编写我们的前端应用。我们可以引入各种方便的开发工具和功能,这些工具和功能将使我们更容易地编写代码和解决问题。

Webpack

Webpack 是 Laravel 5.5 默认提供的构建工具,我们将在 Vuebnb 的开发中使用它。

Webpack 与其他流行的构建工具(如 Gulp 和 Grunt)不同之处在于,它首先是一个模块打包工具。让我们通过了解模块打包过程的工作原理来开始我们对 Webpack 的概述。

依赖

在前端应用中,我们可能会有第三方 JavaScript 库或甚至自己代码库中的其他文件的依赖关系。例如,Vuebnb 原型依赖于 Vue.js 和模拟列表数据文件:

图 5.3。Vuebnb 原型依赖关系

除了确保任何共享的函数和变量具有全局范围,并且脚本按正确的顺序加载外,在浏览器中没有真正的方法来管理这些依赖关系。

例如,由于node_modules/vue/dist/vue.js定义了全局的Vue对象并且首先加载,我们可以在app.js脚本中使用Vue对象。如果不满足这两个条件中的任何一个,当app.js运行时,Vue将未被定义,导致错误:

<script src="node_modules/vue/dist/vue.js"></script>
<script src="sample/data.js"></script>
<script src="app.js"></script>

这个系统有一些缺点:

  • 全局变量引入了命名冲突和意外变异的可能性

  • 脚本加载顺序是脆弱的,随着应用程序的增长很容易被破坏

  • 我们无法利用性能优化,比如异步加载脚本

模块

解决依赖管理问题的一个方法是使用 CommonJS 或原生 ES 模块等模块系统。这些系统允许 JavaScript 代码模块化,并导入到其他文件中。

这里是一个 CommonJS 的例子:

// moduleA.js module.exports = function(value) {
  return value * 2;
}

// moduleB.js
var multiplyByTwo = require('./moduleA'); console.log(multiplyByTwo(2));

// Output: 4

这里是一个原生 ES 模块的例子:

// moduleA.js
export default function(value) {
  return value * 2;
}

// moduleB.js
import multiplyByTwo from './moduleA'; console.log(multiplyByTwo(2)); // Output: 4

问题在于 CommonJS 不能在浏览器中使用(它是为服务器端 JavaScript 设计的),而原生 ES 模块现在才开始得到浏览器支持。如果我们想在项目中使用模块系统,我们需要一个构建工具:Webpack。

打包

将模块解析为适合浏览器的代码的过程称为打包。Webpack 从入口文件开始打包过程。在 Laravel 前端应用中,resources/assets/js/app.js是入口文件。

Webpack 分析入口文件以找到任何依赖关系。在app.js的情况下,它会找到三个:bootstrapvueExample.vue

resources/assets/js/app.js

require('./bootstrap'); window.Vue = require('vue'); Vue.component('example', require('./components/Example.vue'));

...

Webpack 将解析这些依赖关系,然后分析它们以找到它们可能具有的任何依赖关系。这个过程会一直持续,直到找到项目的所有依赖关系。结果是一个依赖关系图,在一个大型项目中,可能包括数百个不同的模块。

Webpack 将这些依赖关系图作为打包所有代码到单个适合浏览器的文件的蓝图:

<script src="bundle.js"></script>

加载器

Webpack 之所以如此强大的部分原因是,在打包过程中,它可以使用一个或多个 Webpack 加载器来转换模块。

例如,Babel是一个编译器,将下一代 JavaScript 语法(如 ES2015)转换为标准的 ES5。Webpack Babel 加载器是最受欢迎的加载器之一,因为它允许开发人员使用现代特性编写他们的代码,但仍然在旧版浏览器中提供支持。

例如,在入口文件中,我们看到了 IE10 不支持的 ES2015 const声明。

resources/assets/js/app.js

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

如果使用了 Babel 加载器,const将在添加到包中之前被转换为var

public/js/app.js

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

Laravel Mix

Webpack 的一个缺点是配置它很繁琐。为了简化事情,Laravel 包含一个名为Mix的模块,它将最常用的 Webpack 选项放在一个简单的 API 后面。

Mix 配置文件可以在项目目录的根目录中找到。Mix 配置涉及将方法链接到mix对象,声明应用程序的基本构建步骤。例如,js方法接受两个参数,入口文件和输出目录,默认情况下应用 Babel 加载器。sass方法以类似的方式工作。

webpack.mix.js

let mix = require('laravel-mix'); mix.js('resources/assets/js/app.js', 'public/js')
  .sass('resources/assets/sass/app.scss', 'public/css');

运行 Webpack

现在我们对 Webpack 有了一个高层次的理解,让我们运行它并看看它是如何捆绑默认的前端资产文件的。

首先,确保您已安装所有开发依赖项:

$ npm install

CLI

通常情况下,Webpack 是从命令行运行的,例如:

$ webpack [options]

与其自己找出正确的 CLI 选项,我们可以使用package.json中预定义的 Weback 脚本之一。例如,development脚本将使用适合创建开发构建的选项运行 Webpack。

package.json

"scripts": {
  ...
  "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
  ...
}

首次构建

现在让我们运行dev脚本(development脚本的快捷方式):

$ npm run dev

运行后,您应该在终端中看到类似以下的输出:

图 5.4. Webpack 终端输出

这个输出告诉我们很多事情,但最重要的是构建成功了,以及输出了哪些文件,包括字体、JavaScript 和 CSS。请注意,输出文件路径是相对于public目录而不是项目根目录,所以js/apps.js文件将在public/js/app.js找到。

JavaScript

检查输出的 JavaScript 文件public/js/app.js,我们会看到里面有大量的代码 - 大约 42,000 行!这是因为 jQuery、Lodash、Vue 和其他 JavaScript 依赖项都被捆绑到这个文件中。这也是因为我们使用了不包括缩小或丑化的开发构建。

如果您搜索文件,您会看到我们的入口文件app.js的代码已经按预期转换为 ES5:

图 5.5. 捆绑文件 public/js/app.js

CSS

我们还有一个 CSS 捆绑文件public/css/app.css。如果您检查这个文件,您会发现导入的 Bootstrap CSS 框架已经包含在内,Sass 语法已经编译成普通的 CSS。

字体

你可能会觉得奇怪的是输出中有字体,因为 Mix 没有包含任何显式的字体配置。这些字体是 Bootstrap CSS 框架的依赖项,Mix 默认会将它们单独输出而不是打包成一个字体包。

迁移 Vuebnb

现在我们熟悉了默认的 Laravel 前端应用程序代码和配置,我们准备将 Vuebnb 原型迁移到主项目中。这个迁移将允许我们将所有源代码放在一个地方,而且我们可以利用这个更复杂的开发环境来构建 Vuebnb 的其余部分。

迁移将涉及:

  1. 移除任何不必要的模块和文件

  2. 将原型文件移动到 Laravel 项目结构中

  3. 修改原型文件以适应新环境

图 5.6. Vuebnb 原型迁移

移除不必要的依赖项和文件

让我们首先移除我们不再需要的 Node 依赖项。我们将保留axis,因为它将在后面的章节中使用,以及cross-env,因为它确保我们的 NPM 脚本可以在各种环境中运行。我们将摆脱其余的:

$ npm uninstall bootstrap-sass jquery lodash --save-dev

这个命令会让你的开发依赖项看起来像这样。

package.json

"devDependencies": {
  "axios": "⁰.17",
  "cross-env": "⁵.1",
  "laravel-mix": "¹.4",
  "vue": "².5.3"
}

接下来,我们将移除我们不需要的文件。这包括几个 JavaScript 资产,所有的 Sass 以及welcome视图:

$ rm -rf \
resources/assets/js/app.js \
resources/assets/js/bootstrap.js \
resources/assets/js/components/* \
resources/assets/sass \
resources/views/welcome.blade.php

由于我们正在移除所有 Sass 文件,我们还需要在 Mix 配置中移除sass方法。

webpack.mix.js

let mix = require('laravel-mix'); mix .js('resources/assets/js/app.js', 'public/js')
;

现在我们的前端应用程序没有杂乱的东西,我们可以将原型文件移动到它们的新家。

HTML

现在让我们将原型项目中的index.html的内容复制到一个新文件app.blade.php中。这将允许模板作为 Laravel 视图使用:

$ cp ../vuebnb-prototype/index.html ./resources/views/app.blade.php

我们还将更新主页 web 路由,指向这个新视图而不是欢迎页面。

routes/web.php:

<?php Route::get('/', function () {
  return view('app');
});

语法冲突

使用原型模板文件作为视图会导致一个小问题,因为 Vue 和 Blade 共享相同的语法。例如,查看 Vue.js 在标题部分插入标题和列表地址的地方。

resources/views/app.blade.php:

<div class="heading">
  <h1>{{ title }}</h1>
  <p>{{ address }}</p>
</div>

当 Blade 处理这个时,它会认为双大括号是它自己的语法,并且会生成一个 PHP 错误,因为titleaddress都不是定义的函数。

有一个简单的解决方案:通过在双大括号前加上@符号来让 Blade 知道忽略它们。这可以通过在前面加上@符号来实现。

resources/views/app.blade.php:

<div class="heading">
  <h1>@{{ title }}</h1>
  <p>@{{ address }}</p>
</div>

在文件中的每一组双大括号中完成这些操作后,加载浏览器中的主页路由以测试新视图。没有 JavaScript 或 CSS,它看起来不太好,但至少我们可以确认它可以工作:

图 5.7。主页路由

JavaScript

现在让我们将原型的主要脚本文件app.js移动到 Laravel 项目中:

$ cp ../vuebnb-prototype/app.js ./resources/assets/js/

根据当前的 Mix 设置,这将成为 JavaScript 捆绑包的入口文件。这意味着视图底部的 JavaScript 依赖项可以被捆绑包替换。

resources/views/app.blade.php:

<script src="node_modules/vue/dist/vue.js"></script>
<script src="sample/data.js"></script>
<script src="app.js"></script>

可以被替换为,

resources/views/app.blade.php:

<script src="{{ asset('js/app.js') }}"></script>

模拟数据依赖项

让我们也将模拟数据依赖项复制到项目中:

$ cp ../vuebnb-prototype/sample/data.js ./resources/assets/js/

目前,这个文件声明了一个全局变量sample,然后在入口文件中被引用。让我们通过用 ES2015 的export default替换变量声明来将这个文件变成一个模块。

resources/assets/js/data.js:

export default {
 ...
}

现在我们可以在我们的入口文件顶部导入这个模块。请注意,Webpack 可以在导入语句中猜测文件扩展名,因此您可以省略data.js中的.js

resources/assets/js/app.js:

import sample from './data';

var app = new Vue({
  ...
});

虽然 Laravel 选择使用 CommonJS 语法来包含模块,即require,但我们将使用原生 ES 模块语法,即import。这是因为 ES 模块正在成为 JavaScript 标准的一部分,并且它更符合 Vue 使用的语法。

使用 Webpack 显示模块

让我们运行 Webpack 构建,确保 JavaScript 迁移到目前为止是有效的:

$ npm run dev

如果一切顺利,您将看到 JavaScript 捆绑文件被输出:

图 5.8。Webpack 终端输出

很好地知道模拟数据依赖项是如何添加的,而不必手动检查捆绑包以找到代码。我们可以通过告诉 Webpack 在终端输出中打印它处理过的模块来实现这一点。

在我们的package.jsondevelopment脚本中,设置了一个--hide-modules标志,因为一些开发人员更喜欢简洁的输出消息。让我们暂时将其移除,而是添加--display-modules标志,使脚本看起来像这样:

"scripts": { ... "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --display-modules --config=node_modules/laravel-mix/setup/webpack.config.js", ... }

现在再次运行构建,我们会得到更详细的终端输出:

图 5.9。带有 display-modules 标志的 Webpack 终端输出

这可以确保我们的app.jsdata.js文件都包含在捆绑包中。

Vue.js 依赖项

现在让我们将 Vue.js 作为我们入口文件的依赖项导入。

resources/assets/js/app.js:

import Vue from 'vue';
import sample from './data';

var app = new Vue({
  ...
});

再次运行构建,我们现在会在终端输出中看到 Vue.js 在模块列表中,以及它引入的一些依赖项:

图 5.10。显示 Vue.js 的 Webpack 终端输出

你可能想知道import Vue from 'vue'是如何解析的,因为它似乎不是一个正确的文件引用。Webpack 默认会在项目的node_modules文件夹中检查任何依赖项,这样就不需要将import Vue from 'node_modules/vue';放在项目中了。

但是,它又是如何知道这个包的入口文件呢?看一下前面截图中的 Webpack 终端输出,你会看到它已经包含了node_modules/vue/dist/vue.common.js。它知道要使用这个文件,是因为当 Webpack 将节点模块添加为依赖项时,它会检查它们的package.json文件,并查找main属性,而在 Vue 的情况下是。

node_modules/vue/package.json

{ ... "main": "dist/vue.runtime.common.js", ... }

但是,Laravel Mix 会覆盖这一点,以强制使用不同的 Vue 构建。

node_modules/laravel-mix/setup/webpack.config.js

alias: {
  'vue$': 'vue/dist/vue.common.js'
}

简而言之,import Vue from 'vue'实际上与import Vue from 'node_modules/vue/dist/vue.common.js'是一样的。

我们将在第六章中解释不同的 Vue 构建,使用 Vue.js 组件组合小部件

搞定了,我们的 JavaScript 已成功迁移。再次加载主页路由,我们现在可以更好地看到 Vuebnb 的列表页面,其中包括 JavaScript:

图 5.11.带有 JavaScript 迁移的主页路由

CSS

要迁移 CSS,我们将从原型中复制style.css到 Laravel 项目中。默认的 Laravel 前端应用程序使用 Sass 而不是 CSS,因此我们需要先为 CSS 资产创建一个目录:

$ mkdir ./resources/assets/css
$ cp ../vuebnb-prototype/style.css ./resources/assets/css/

然后在我们的 Mix 配置中进行新的声明,使用styles方法获取一个 CSS 捆绑包。

webpack.mix.js

mix .js('resources/assets/js/app.js', 'public/js')
  .styles('resources/assets/css/style.css', 'public/css/style.css')
;

现在我们将在视图中链接到 CSS 捆绑包,更新链接的href

resources/views/app.blade.php

<link rel="stylesheet" href="{{ asset('css/style.css') }}" type="text/css">

字体样式

我们还有 Open Sans 和 Font Awesome 样式表要包含。首先,使用 NPM 安装字体包:

$ npm i --save-dev font-awesome open-sans-all

我们将修改我们的 Mix 配置,将我们的应用程序 CSS、Open Sans 和 Font Awesome CSS 捆绑在一起。我们可以通过将数组传递给styles方法的第一个参数来实现这一点。

webpack.mix.js

mix .js('resources/assets/js/app.js', 'public/js')
  .styles([
    'node_modules/open-sans-all/css/open-sans.css',
    'node_modules/font-awesome/css/font-awesome.css',
    'resources/assets/css/style.css'
  ], 'public/css/style.css')
;

Mix 将在终端输出中附加有关 CSS 捆绑包的统计信息:

图 5.12.带有 CSS 的 Webpack 终端输出

记得从视图中删除对字体样式表的链接,因为现在它们将在 CSS 捆绑包中。

字体

Open Sans 和 Font Awesome 都需要一个 CSS 样式表和相关的字体文件。与 CSS 一样,Webpack 可以将字体捆绑为模块,但我们目前不需要利用这一点。相反,我们将使用copy方法,告诉 Mix 将字体从它们的主目录复制到public文件夹中,这样前端应用程序就可以访问它们了。

webpack.mix.js

mix .js('resources/assets/js/app.js', 'public/js')
  .styles([
    'node_modules/open-sans-all/css/open-sans.css',
    'node_modules/font-awesome/css/font-awesome.css',
    'resources/assets/css/style.css'
  ], 'public/css/style.css')
  .copy('node_modules/open-sans-all/fonts',  'public/fonts')
  .copy('node_modules/font-awesome/fonts',  'public/fonts')
;

再次构建后,您现在将在项目结构中看到一个public/fonts文件夹。

图像

我们现在将迁移图像,包括工具栏的标志和模拟数据标题图像:

$ cp ../vuebnb-prototype/logo.png ./resources/assets/images/
$ cp ../vuebnb-prototype/sample/header.jpg ./resources/assets/images/

让我们再链上另一个copy方法,将它们包含在public/images目录中。

webpack.mix.js

mix .js('resources/assets/js/app.js', 'public/js')
  .styles([
    'node_modules/open-sans-all/css/open-sans.css',
    'node_modules/font-awesome/css/font-awesome.css',
    'resources/assets/css/style.css'
  ], 'public/css/style.css')
  .copy('node_modules/open-sans-all/fonts',  'public/fonts')
  .copy('node_modules/font-awesome/fonts',  'public/fonts')
  .copy('resources/assets/images', 'public/images')
;

我们还需要确保视图指向正确的图像文件位置。在工具栏中。

resources/views/app.blade.php

<div id="toolbar">
  <img class="icon" src="{{ asset('images/logo.png') }}">
  <h1>vuebnb</h1>
</div>

以及在模态框中。

resources/views/app.blade.php

<div class="modal-content">
  <img src="{{ asset('images/header.jpg') }}"/>
</div>

不要忘记需要更新入口文件中的headerImageStyle数据属性。

resources/assets/js/app.js

headerImageStyle: {
  'background-image': 'url(/images/header.jpg)'
},

虽然不完全是一张图片,我们也将迁移favicon。这可以直接放入public文件夹中:

$ cp ../vuebnb-prototype/favicon.ico ./public

再次构建后,我们现在将完全迁移 Vuebnb 客户端应用程序原型:

图 5.13.从 Laravel 提供的 Vuebnb 客户端应用程序原型

开发工具

我们可以利用一些方便的开发工具来改进我们的前端工作流程,包括:

  • 监视模式

  • BrowserSync

监视模式

到目前为止,我们一直在每次进行更改时手动运行应用程序的构建,使用npm run dev。Webpack 还有一个观察模式,在这种模式下,当依赖项发生更改时,它会自动运行构建。由于 Webpack 的设计,它能够通过仅重新构建已更改的模块来高效地完成这些自动构建。

要使用观察模式,请运行package.json中包含的watch脚本:

$ npm run watch

要测试它是否有效,请在resources/assets/js/app.js的底部添加以下内容:

console.log("Testing watch");

如果观察模式正在正确运行,保存此文件将触发构建,并且您将在终端中看到更新的构建统计信息。然后刷新页面,您将在控制台中看到测试观察模式的消息。

要关闭观察模式,在终端中按Ctrl + C。然后可以随时重新启动。不要忘记在满意观察模式工作后删除console.log

我假设您在本书的其余部分中都在使用watch,所以我不会再提醒您在更改后构建项目了!

BrowserSync

另一个有用的开发工具是 BrowserSync。与观察模式类似,BrowserSync 监视文件的更改,当发生更改时,将更改插入浏览器。这样可以避免在每次构建后手动刷新浏览器。

要使用 BrowserSync,您需要安装 Yarn 软件包管理器。如果您在 Vagrant Box 中运行终端命令,那么您已经准备就绪,因为 Yarn 已预先安装在 Homestead 中。否则,请按照此处的 Yarn 安装说明进行安装:yarnpkg.com/en/docs/install

BrowserSync 已与 Mix 集成,并且可以通过在 Mix 配置中调用browserSync方法来使用。传递一个带有应用程序 URL 作为proxy属性的选项对象,例如,browserSync({ proxy: http://vuebnb.test })

我们将应用程序的 URL 存储为.env文件中的环境变量,因此让我们从那里获取它,而不是硬编码到我们的 Mix 文件中。首先,安装 NPM dotenv模块,它将.env文件读入 Node 项目中:

$ npm i dotenv --save-devpm

在 Mix 配置文件的顶部要求dotenv模块,并使用config方法加载.env。然后环境变量将作为process.env对象的属性可用。

现在我们可以将一个带有process.env.APP_URL分配给proxy的选项对象传递给browserSync方法。我还喜欢使用open: false选项,这样可以防止 BrowserSync 自动打开一个标签页。

webpack.mix.js

require('dotenv').config();
let mix = require('laravel-mix'); mix ...
  .browserSync({ proxy: process.env.APP_URL, open: false
  })
;

BrowserSync 默认在自己的端口3000上运行。当您再次运行npm run watch时,在localhost:3000上打开一个新标签页。在对代码进行更改后,您会发现这些更改会自动反映在此 BrowserSync 标签页中!

请注意,如果您在 Homestead 框中运行 BrowserSync,可以在vuebnb.test:3000上访问它。

即使 BrowserSync 服务器在不同的端口上运行,我将继续在应用程序中引用 URL 而不指定端口,以避免任何混淆,例如,vuebnb.test而不是localhost:3000vuebnb.test:3000

ES2015

js Mix 方法将 Babel 插件应用于 Webpack,确保任何 ES2015 代码在添加到捆绑文件之前被转译为浏览器友好的 ES5。

我们使用 ES5 语法编写了 Vuebnb 前端应用程序原型,因为我们直接在浏览器中运行它,没有任何构建步骤。但现在我们可以利用 ES2015 语法,其中包括许多方便的功能。

例如,我们可以使用一种简写方式将函数分配给对象属性。

resources/assets/js/app.js

escapeKeyListener: function(evt) {
  ...
}

可以更改为:

escapeKeyListener(evt) {
  ...
}

app.js中有几个这样的实例,我们可以更改。尽管在我们的代码中还没有其他使用 ES2015 语法的机会,但在接下来的章节中我们会看到更多。

Polyfills

ES2015 提案包括新的语法,还包括新的 API,如Promise,以及对现有 API 的添加,如ArrayObject

Webpack Babel 插件可以转译 ES2015 语法,但新的 API 方法需要进行 polyfill。Polyfill是在浏览器中运行的脚本,用于覆盖可能缺失的 API 或 API 方法。

例如,Object.assign是一个新的 API 方法,在 Internet Explorer 11 中不受支持。如果我们想在前端应用程序中使用它,我们必须在脚本的顶部检查 API 方法是否存在,如果不存在,则使用 polyfill 手动定义它:

if (typeof Object.assign != 'function') {
  // Polyfill to define Object.assign
}

说到这一点,Object.assign是合并对象的一种方便方法,在我们的前端应用程序中会很有用。让我们在我们的代码中使用它,然后添加一个 polyfill 来确保代码在旧版浏览器中运行。

查看我们入口文件resources/assets/js/app.js中的data对象。我们手动将sample对象的每个属性分配给data对象,给它相同的属性名。为了避免重复,我们可以使用Object.assign来简单地合并这两个对象。实际上,这并没有做任何不同的事情,只是更简洁的代码。

resources/assets/js/app.js:

data: Object.assign(sample, { headerImageStyle: {
    'background-image': 'url(/images/header.jpg)'
  }, contracted: true, modalOpen: false
}),

为了 polyfillObject.assign,我们必须安装一个新的core-js依赖项,这是一个为大多数新的 JavaScript API 提供 polyfill 的库。我们稍后将在项目中使用一些其他core-js的 polyfill:

$ npm i --save-dev core-js

app.js的顶部,添加以下行以包含Object.assign的 polyfill:

import "core-js/fn/object/assign";

构建完成后,刷新页面以查看是否有效。除非您可以在旧版浏览器(如 Internet Explorer)上测试,否则您很可能不会注意到任何区别,但现在您可以确保这段代码几乎可以在任何地方运行。

模拟数据

我们现在已经完全将 Vuebnb 原型迁移到了我们的 Laravel 项目中,并且我们已经添加了一个构建步骤。前端应用程序中的一切都像第二章中的一样工作,Vuebnb 原型设计,您的第一个 Vue.js 项目

但是,我们仍然在前端应用程序中硬编码了模拟数据。在本章的最后部分,我们将删除这些硬编码的数据,并用后端数据替换它。

路由

目前,主页路由,即*/*,加载我们的前端应用程序。但是,我们迄今为止构建的前端应用程序并不是一个主页!我们将在以后的章节中构建它。

我们构建的是listing页面,应该在类似/listing/5的路由上,其中5是正在使用的模拟数据列表的 ID。

页面 路由
主页 /
列表页面 /listing/

让我们修改路由以反映这一点。

routes/web.php:

<?php

use App\Listing; Route::get('/listing/{listing}', function ($id) {
  return view('app');
});

就像在我们的api/listing/{listing}路由中一样,动态段意味着要匹配我们模拟数据列表中的一个 ID。如果您还记得上一章,我们创建了 30 个模拟数据列表,ID 范围是 1 到 30。

如果我们现在在闭包函数的配置文件中对Listing模型进行类型提示,Laravel 的服务容器将传递一个与动态路由段匹配的 ID 的模型。

routes/web.php:

Route::get('/listing/{listing}', function (Listing $listing) {
  // echo $listing->id // will equal 5 for route /listing/5
  return view('app');
});

一个很酷的内置功能是,如果动态段与模型不匹配,例如/listing/50/listing/somestring,Laravel 将中止路由并返回 404。

架构

考虑到我们可以在路由处理程序中检索到正确的列表模型,并且由于 Blade 模板系统的存在,我们可以动态地将内容插入到我们的app视图中,一个明显的架构出现了:我们可以将模型注入到页面的头部。这样,当 Vue 应用程序加载时,它将立即访问模型:

图 5.14。将内联列表模型插入页面的头部

注入数据

将模拟列表数据传递到客户端应用程序将需要几个步骤。我们将首先将模型转换为数组。然后可以使用view助手在模板中运行时使模型可用。

routes/web.php:

Route::get('/listing/{listing}', function (Listing $listing) {
  $model = $listing->toArray();
  return view('app', [ 'model' => $model ]);
});

现在,在 Blade 模板中,我们将在文档的头部创建一个脚本。通过使用双花括号,我们可以直接将模型插入脚本中。

resources/views/app.blade.php

<head> ... <script type="text/javascript"> console.log({{ $model[ 'id' ] }}); </script>
</head>

现在,如果我们转到/listing/5路由,我们将在页面源代码中看到以下内容:

<script type="text/javascript"> console.log(5); </script>

并且您将在控制台中看到以下内容:

图 5.15。注入模型 ID 后的控制台输出

JSON

现在我们将整个模型编码为 JSON 放在视图中。JSON 格式很好,因为它可以存储为字符串,并且可以被 PHP 和 JavaScript 解析。

在我们的内联脚本中,让我们将模型格式化为 JSON 字符串并分配给model变量。

resources/views/app.blade.php

<script type="text/javascript"> var model = "{!! addslashes(json_encode($model)) !!}"; console.log(model); </script>

请注意,我们还必须在另一个全局函数addslashes中包装json_encode。这个函数将在需要转义的任何字符之前添加反斜杠。这是必要的,因为 JavaScript JSON 解析器不知道字符串中的引号是 JavaScript 语法的一部分,还是 JSON 对象的一部分。

我们还必须使用不同类型的 Blade 语法进行插值。Blade 的一个特性是,双花括号{{ }}中的语句会自动通过 PHP 的htmlspecialchars函数发送,以防止 XSS 攻击。不幸的是,这将使我们的 JSON 对象无效。解决方案是使用替代的{!! !!}语法,它不会验证内容。在这种情况下这样做是安全的,因为我们确定我们没有使用任何用户提供的内容。

现在,如果我们刷新页面,我们将在控制台中看到 JSON 对象作为字符串:

图 5.16。控制台中的 JSON 字符串模型

如果我们将日志命令更改为console.log(JSON.parse(model));,我们将看到我们的模型不是一个字符串,而是一个 JavaScript 对象:

图 5.17。控制台中的对象模型

我们现在已经成功地将我们的模型从后端传递到前端应用程序!

在脚本之间共享数据

现在我们有另一个问题要克服。文档头部的内联脚本,其中包含我们的模型对象,与我们的客户端应用程序所在的脚本不同,这是需要的地方。

正如我们在前一节中讨论的,通常不建议使用多个脚本和全局变量,因为它们会使应用程序变得脆弱。但在这种情况下,它们是必需的。在两个脚本之间安全共享对象或函数的最佳方法是将其作为全局window对象的属性。这样,从您的代码中很明显,您有意使用全局变量:

// scriptA.js window.myvar = 'Hello World';

// scriptB.js console.log(window.myvar); // Hello World

如果您向项目添加其他脚本,特别是第三方脚本,它们可能也会添加到window对象,并且可能会发生命名冲突的可能性。为了尽量避免这种情况,我们将确保使用非常特定的属性名称。

resources/views/app.blade.php

<script type="text/javascript"> window.vuebnb_listing_model = "{!! addslashes(json_encode($model)) !!}" </script>

现在,在前端应用程序的入口文件中,我们可以在脚本中使用这个window属性。

resources/assets/js/app.js

let model = JSON.parse(window.vuebnb_listing_model);

var app = new Vue({
  ...
});

替换硬编码的模型

现在我们可以在入口文件中访问我们的列表模型,让我们将其与data属性分配中的硬编码模型进行交换。

resources/assets/js/app.js

let model = JSON.parse(window.vuebnb_listing_model);

var app = new Vue({ el: '#app' data: Object.assign(model, {
    ...
  })
  ...
});

完成后,我们现在可以从app.js的顶部删除import sample from './data';语句。我们还可以删除示例数据文件,因为它们在项目中将不再使用:

$ rm resources/assets/js/data.js resources/assets/images/header.jpg

设施和价格

如果您现在刷新页面,它将加载,但脚本将出现一些错误。问题在于设施和价格数据在前端应用程序中的结构与后端中的结构不同。这是因为模型最初来自我们的数据库,它存储标量值。在 JavaScript 中,我们可以使用更丰富的对象,允许我们嵌套数据,使其更容易处理和操作。

这是模型对象当前的外观。请注意,设施和价格是标量值:

图 5.18。列表模型当前的外观

这就是我们需要的样子,包括设施和价格作为数组:

图 5.19。列表模型应该的外观

为了解决这个问题,我们需要在将模型传递给 Vue 之前对其进行转换。为了让您不必过多考虑这个问题,我已经将转换函数放入了一个文件resources/assets/js/helpers.js中。这个文件是一个 JavaScript 模块,我们可以将其导入到我们的入口文件中,并通过简单地将模型对象传递给函数来使用它。

resources/assets/js/app.js

import Vue from 'vue';
import { populateAmenitiesAndPrices } from './helpers';

let model = JSON.parse(window.vuebnb_listing_model); model = populateAmenitiesAndPrices(model)</span>;

完成这些步骤并刷新页面后,我们应该在页面的文本部分看到新的模型数据(尽管图像仍然是硬编码的):

图 5.20。页面中的新模型数据与硬编码的图像

图像 URL

最后要做的事情是替换前端应用程序中的硬编码图像 URL。这些 URL 目前不是模型的一部分,因此需要在将其注入模板之前手动添加到模型中。

我们已经在第四章中做了一个非常类似的工作,使用 Laravel 构建 Web 服务,用于 API 列表路由。

app/Http/Controllers/ListingController.php

public function get_listing_api(Listing $listing) 
{ $model = $listing->toArray();
  for($i = 1; $i <=4; $i++) { $model['image_' . $i] = asset(
      'images/' . $listing->id . '/Image_' . $i . '.jpg'
    );
  }
  return response()->json($model);
}

实际上,我们的 web 路由最终将与这个 API 路由的代码相同,只是不返回 JSON,而是返回一个视图。

让我们分享共同的逻辑。首先将路由闭包函数移动到列表控制器中的一个新的get_listing_web方法。

app/Http/Controllers/ListingController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Listing;

class ListingController extends Controller
{
  public function get_listing_api(Listing $listing) 
  {
    ...
  }

  public function get_listing_web(Listing $listing) 
  {
    $model = $listing->toArray();
    return view('app', ['model' => $model]);
  }
}

然后调整路由以调用这个新的控制器方法。

routes/web.php

<?php Route::get('/listing/{listing}', 'ListingController@get_listing_web');

现在让我们更新控制器,使得web和 API 路由都将图像的 URL 添加到它们的模型中。我们首先创建一个新的add_image_urls方法,它抽象了在get_listing_api中使用的逻辑。现在路由处理方法都将调用这个新方法。

app/Http/Controllers/ListingController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Listing;

class ListingController extends Controller
{
  private function add_image_urls($model, $id) 
  {
    for($i = 1; $i <=4; $i++) {
      $model['image_' . $i] = asset(
        'images/' . $id . '/Image_' . $i . '.jpg'
      );
    }
    return $model;
  }

  public function get_listing_api(Listing $listing) 
  {
    $model = $listing->toArray();
    $model = $this->add_image_urls($model, $listing->id);
    return response()->json($model);
  }

  public function get_listing_web(Listing $listing) 
  {
    $model = $listing->toArray();
    $model = $this->add_image_urls($model, $listing->id);
    return view('app', ['model' => $model]);
  }
}

完成后,如果我们刷新应用并打开 Vue Devtools,我们应该看到我们有图像 URL 作为images数据属性:

图 5.21。如 Vue Devtools 中所示,图像现在是一个数据属性

替换硬编码的图像 URL

最后一步是使用后端的图像 URL,而不是硬编码的 URL。记住images是一个 URL 数组,我们将使用第一个图像作为默认值,即images[0]

首先,我们将更新入口文件,

resources/assets/js/app.js

headerImageStyle: {
  'background-image': `url(${model.images[0]})`
}

然后是模态图像的视图。

resources/views/app.blade.php

<div class="modal-content">
  <img v-bind:src="images[0]"/>
</div>

完成重建和页面刷新后,您将在页面中看到模拟数据列表#5的内容:

图 5.22。带有模拟数据的列表页面

为了验证并欣赏我们的工作,让我们尝试另一个路由,例如/listing/10

图 5.23。带有模拟数据的列表页面

总结

在本章中,我们熟悉了 Laravel 默认前端应用程序的文件和配置。然后我们将 Vuebnb 客户端应用程序原型迁移到我们的 Laravel 项目中,实现了 Vuebnb 的第一个全栈迭代。

我们还了解了 Webpack,看到它是如何通过将模块捆绑到浏览器友好的构建文件中来解决 JavaScript 依赖管理问题的。我们通过 Laravel Mix 在项目中设置了 Webpack,它提供了一个简单的 API 来处理常见的构建场景。

然后我们调查了一些工具,使得我们的前端开发过程更容易,包括 Webpack 的监视模式和 BrowserSync。

最后,我们看到如何通过将数据注入到文档头部,将数据从后端传递到前端应用程序。

在第六章中,使用 Vue.js 组件组合小部件,我们将介绍构建 Vue.js 用户界面的最重要和强大的工具之一:组件。我们将为 Vuebnb 构建一个图像轮播,并利用组件的知识将 Vuebnb 客户端应用程序重构为灵活的基于组件的架构。

第六章:使用 Vue.js 组件组合小部件

组件正在成为前端开发的一个重要方面,并且是大多数现代前端框架的一个特性,包括 Vue、React、Angular、Polymer 等。组件甚至通过一个称为Web Components的新标准成为 Web 的本地特性。

在本章中,我们将使用组件为 Vuebnb 创建一个图像轮播,允许用户查看房间列表的不同照片。我们还将重构 Vuebnb 以符合基于组件的架构。

本章涵盖的主题:

  • 组件是什么以及如何使用 Vue.js 创建它们

  • 通过 props 和 events 进行组件通信

  • 单文件组件- Vue 中最有用的功能之一

  • 使用插槽向组件添加自定义内容

  • 完全从组件构建应用程序的好处

  • 如何使用渲染函数跳过模板编译器

  • 使用 Vue 的仅运行时构建来减小捆绑包大小

组件

当我们构建 Web 应用程序的模板时,我们可以使用 HTML 元素,如divtablespan。这种各种元素使得我们可以轻松创建页面上所需的任何结构。

如果我们可以通过例如my-element创建自定义元素,那该多好?这将允许我们创建专门为我们的应用程序设计的可重用结构。

组件是在 Vue.js 中创建自定义元素的工具。当我们注册一个组件时,我们定义一个模板,它呈现为一个或多个标准 HTML 元素:

图 6.1。组件促进可重用的标记,并呈现为标准 HTML

注册

有许多注册组件的方法,但最简单的方法是使用component API 方法。第一个参数是您要给组件的名称,第二个是配置对象。配置对象通常会包括一个template属性,以使用字符串声明组件的标记:

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

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

注册了这样一个组件后,我们可以在项目中使用它:

<div id="app">
  <my-component></my-component>
  <!-- Renders as <div>My component!</div> -->
</div>

数据

除了可重用的标记之外,组件还允许我们重用 JavaScript 功能。配置对象不仅可以包括一个模板,还可以包括自己的状态,就像 Vue 实例一样。实际上,每个组件都可以被视为 Vue 的迷你实例,具有自己的数据、方法、生命周期钩子等。

我们对待组件数据的方式与 Vue 实例略有不同,因为组件是可重用的。例如,我们可以像这样创建一个check-box组件库:

<div id="app">
  <check-box></check-box>
  <check-box></check-box>
  <check-box></check-box>
</div>
<script> Vue.component('check-box', { template: '<div v-on:click="checked = !checked"></div>' data: { checked: false
    }
  }); </script>

现在,如果用户点击复选框div,则checked状态会同时从 true 切换到 false!这不是我们想要的,但这将会发生,因为组件的所有实例都引用相同的data对象,因此具有相同的状态。

为了使每个实例具有自己的唯一状态,data属性不应该是一个对象,而应该是一个返回对象的工厂函数。这样,每次组件被实例化时,它都链接到一个新的数据对象。实现这一点就像这样简单:

data() {
  return { checked: false 
  }
}

图像轮播

让我们使用组件为 Vuebnb 前端应用程序构建一个新功能。正如您从之前的章节中记得的那样,我们的模拟数据列表中有四个不同的图像,并且我们正在将 URL 传递给前端应用程序。

为了让用户查看这些图像,我们将创建一个图像轮播。这个轮播将取代当前在单击列表标题时弹出的模态窗口中的静态图像。

首先打开应用视图。删除静态图像,并将其替换为自定义 HTML 元素image-carousel

resources/views/app.blade.php

<div class="modal-content">
  <image-carousel></image-carousel>
</div>

组件可以在您的代码中通过 kebab-case 名称(如my-component)、PascalCase 名称(如MyComponent)或 camelCase 名称(如myComponent)来引用。Vue 将这些视为相同的组件。然而,在 DOM 或字符串模板中,组件应始终使用 kebab-case。Vue 不强制执行这一点,但页面中的标记在 Vue 开始处理之前会被浏览器解析,因此应符合 W3C 命名约定,否则解析器可能会将其删除。

现在让我们在入口文件中注册组件。这个新组件的模板将简单地是我们从视图中移除的图像标签,包裹在一个div中。我们添加这个包装元素,因为组件模板必须有一个单一的根元素,并且我们很快将在其中添加更多元素。

作为概念验证,组件数据将包括一个硬编码的图像 URL 数组。一旦我们学会如何将数据传递给组件,我们将删除这些硬编码的 URL,并用来自我们模型的动态 URL 替换它们。

resources/assets/js/app.js:

Vue.component('image-carousel', { template: `<div class="image-carousel">
              <img v-bind:src="images[0]"/>
            </div>`,
  data() {
    return { images: [
        '/images/1/Image_1.jpg',
        '/images/1/Image_2.jpg',
        '/images/1/Image_3.jpg',
        '/images/1/Image_4.jpg'
      ]
    }
  }
});

var app = new Vue({
  ...
});

在测试这个组件之前,让我们对 CSS 进行调整。我们之前有一个规则,确保模态窗口内的图像通过.modal-content img选择器拉伸到全宽。让我们改用.image-carousel选择器,因为我们正在将图像与模态窗口解耦。

resources/assets/css/style.css:

.image-carousel img {
  width: 100%;
}

在代码重建后,将浏览器导航到/listing/1,你应该看不到任何区别,因为组件应该以几乎与之前标记完全相同的方式呈现。

然而,如果我们检查 Vue Devtools,并打开到“组件”选项卡,你会看到我们现在在Root实例下嵌套了ImageCarousel组件。选择ImageCarousel,甚至可以检查它的状态:

图 6.2。Vue Devtools 显示 ImageCarousel 组件

更改图像

轮播图的目的是允许用户浏览一系列图像,而无需滚动页面。为了实现这一功能,我们需要创建一些 UI 控件。

但首先,让我们向我们的组件添加一个新的数据属性index,它将决定当前显示的图像。它将被初始化为 0,UI 控件稍后将能够增加或减少该值。

我们将把图像源绑定到位置为index的数组项。

resources/assets/js/app.js:

Vue.component('image-carousel', { template: `<div class="image-carousel">
              <img v-bind:src="images[index]"/>
            </div>`,
  data() {
    return { images: [
        '/images/1/Image_1.jpg',
        '/images/1/Image_2.jpg',
        '/images/1/Image_3.jpg',
        '/images/1/Image_4.jpg'
      ], index: 0
    }
  }
});

页面刷新后,屏幕上看到的内容应该没有变化。但是,如果你将index的值初始化为123,当你重新打开模态窗口时,你会发现显示的是不同的图像:

图 6.3。将index设置为 2 会选择不同的 URL,显示不同的图像

计算属性

直接将逻辑写入我们的模板作为一个表达式是很方便的,例如v-if="myExpression"。但是对于无法定义为表达式的更复杂的逻辑,或者对于模板来说变得太冗长的情况怎么办呢?

在这种情况下,我们使用计算属性。这些属性是我们添加到 Vue 配置中的,可以被视为响应式方法,当依赖值发生变化时会重新运行。

在下面的示例中,我们在computed配置部分下声明了一个计算属性message。请注意,该函数依赖于val,也就是说,message的返回值将随着val的变化而不同。

当这个脚本运行时,Vue 将注意到message的任何依赖关系,并建立响应式绑定,这样,与普通方法不同,函数将在依赖关系发生变化时重新运行:

<script>
  var app = new Vue({ el: '#app', data: { val: 1
    }, computed: {
      message() {
        return `The value is ${this.val}`
      }
    }   
  });

  setTimeout(function() { app.val = 2;
  }, 2000);
</script>
<div id="app">
  <!--Renders as "The value is 1"-->
  <!--After 2 seconds, re-renders as "The value is 2"-->
  {{ message }}
</div>

回到图像轮播,让我们通过将绑定到图像src的表达式抽象为计算属性,使模板更加简洁。

resources/assets/js/app.js:

Vue.component('image-carousel', { template: `<div class="image-carousel">
              <img v-bind:src="image"/>
            </div>`,
  data() { ... }, computed: {
    image() {
      return this.images[this.index];
    }
  }
});

组合组件

组件可以像标准 HTML 元素一样嵌套在其他组件中。例如,如果component A在其模板中声明component B,则component B可以是component A的子级:

<div id="app">
  <component-a></component-a>
</div>
<script> Vue.component('component-a', { template: `
      <div>
        <p>Hi I'm component A</p>
        <component-b></component-b>
      </div>`
  }); Vue.component('component-b', { template: `<p>And I'm component B</p>`
  });

  new Vue({ el: '#app'
  }); </script>

这将呈现为:

<div id="app">
  <div>
    <p>Hi I'm component A</p>
    <p>And I'm component B</p>
  </div>
</div>

注册范围

虽然一些组件设计用于在应用程序的任何地方使用,但其他组件可能具有更具体的目的。当我们使用 API 注册组件,即Vue.component时,该组件是全局注册的,并且可以在任何其他组件或实例中使用。

我们还可以通过在根实例或另一个组件的components选项中声明来本地注册组件:

Vue.component('component-a', { template: `
    <div>
      <p>Hi I'm component A</p>
      <component-b></component-b>
    </div>`, components: {
    'component-b': { template: `<p>And I'm component B</p>`
    }
  }
});

轮播控件

为了允许用户更改轮播中当前显示的图像,让我们创建一个新的组件CarouselControl。该组件将呈现为一个浮动在轮播上的箭头,并将响应用户的点击。我们将使用两个实例,因为将有一个左箭头和一个右箭头,用于减少或增加图像索引。

我们将在ImageCarousel组件中本地注册CarouselControlCarouselControl模板将呈现为一个i标签,通常用于显示图标。轮播图标的一个很好的图标是 Font Awesome 的chevron图标,它是一个优雅的箭头形状。目前,我们还没有办法区分左右,所以现在,两个实例都将有一个朝左的图标。

resources/assets/js/app.js

Vue.component('image-carousel', { template: ` <div class="image-carousel">
      <img v-bind:src="image">
      <div class="controls">
        <carousel-control></carousel-control>
        <carousel-control></carousel-control>
      </div>
    </div> `,
  data() { ... }, computed: { ... }, components: {
    'carousel-control': { template: `<i class="carousel-control fa fa-2x fa-chevron-left"></i>` }
  }
});

为了让这些控件在我们的图像轮播上漂亮地浮动,我们还会在我们的 CSS 文件中添加一些新的规则。

resources/assets/css/style.css

.image-carousel {
  height: 100%;
  margin-top: -12vh; position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.image-carousel .controls {
  position: absolute;
  width: 100%;
  display: flex;
  justify-content: space-between;
}

.carousel-control {
  padding: 1rem;
  color: #ffffff;
  opacity: 0.85 }

@media (min-width: 744px) {
  .carousel-control {
      font-size: 3rem;
  }
}

添加了该代码后,打开模态窗口查看我们迄今为止的工作成果:

图 6.4。添加到图像轮播的轮播控件

与组件通信

组件的一个关键方面是它们是可重用的,这就是为什么我们给它们自己的状态以使它们独立于应用程序的其余部分。但是,我们可能仍然希望发送数据,或者将其发送出去。组件有一个用于与应用程序的其他部分通信的接口,我们现在将进行探讨。

属性

我们可以通过自定义 HTML 属性prop向组件发送数据。我们还必须在组件的配置中的数组props中注册此自定义属性。在下面的示例中,我们创建了一个 prop,title

<div id="app">
  <my-component title="My component!"></my-component>
  <!-- Renders as <div>My component!</div> -->
</div>
<script> Vue.component('my-component', { template: '<div>{{ title }}</div>', props: ['title']
  });

  new Vue({ el: '#app'
  });
</script>

prop 可以像组件的任何数据属性一样使用:您可以在模板中插值,将其用于方法和计算属性等。但是,您不应该改变 prop 数据。将 prop 数据视为从另一个组件或实例借用的数据-只有所有者应该更改它。

属性被代理到实例中,就像数据属性一样,这意味着你可以在组件的代码中将属性称为this.myprop。一定要将您的属性名称设置为与数据属性不同,以避免冲突!

单向数据流

由于 prop 必须在使用组件的模板中声明,因此 prop 数据只能从父级传递到子级。这就是为什么您不应该改变 prop 的原因-因为数据是向下流动的,更改不会反映在父级中,因此您将拥有不同版本的应该是相同状态的内容。

如果您确实需要告诉所有者更改数据,那么有一个单独的接口用于从子级向父级传递数据,我们稍后会看到。

动态 prop

我们可以使用v-bind指令将数据动态绑定到组件。当父级数据发生变化时,它将自动流向子级。

在下面的示例中,根实例中title的值在两秒后以编程方式更新。此更改将自动流向MyComponent,后者将以响应方式重新呈现以显示新值:

<div id="app">
  <my-component :title="title"></my-component>
  <!-- Renders initially as <div>Hello World</div> -->
  <!-- Re-renders after two seconds as <div>Goodbye World</div> -->
</div>
<script> Vue.component('my-component', { template: '<div>{{ title }}</div>', props: [ 'title' ]
  });

  var app = new Vue({ el: '#app', data: { title: 'Hello World'
    }
  });

  setTimeout(() => { app.title = 'Goodbye World'
  }, 2000); </script>

由于在模板中经常使用v-bind指令,您可以省略指令名称作为简写:<div v-bind:title="title">可以缩写为<div :title="title">

图片 URL

当我们创建ImageCarousel时,我们硬编码了图像 URL。通过 props,我们现在有了一种机制,可以从根实例向组件发送动态数据。让我们将根实例数据属性images绑定到一个 prop,也叫images,在我们的ImageCarousel声明中。

resources/views/app.blade.php

<div class="modal-content">
  <image-carousel :images="images"></image-carousel>
</div>

现在,删除ImageCarousel组件中的数据属性images,并将images声明为 prop。

resources/assets/js/app.js

Vue.component('image-carousel', { props: ['images'],
  data() {
    return { index: 0
    }
  },
  ...
}

根实例现在将负责图像 URL 的状态,图像轮播组件将负责显示它们。

使用 Vue Devtools,我们可以检查图像轮播组件的状态,现在包括images作为 prop 值而不是数据值:

图 6.5。图像 URL 是发送到 ImageCarousel 组件的 props

现在图像 URL 来自模型,我们可以访问其他列表路由,比如/listing/2,并再次在模态窗口中看到正确的图像显示。

区分轮播控件

CarouselControl组件应该有两种可能的状态:要么指向左,要么指向右。当用户点击时,前者将上升到可用图像,后者将下降。

这种状态不应该由内部确定,而应该从ImageCarousel传递下来。为此,让我们向CarouselControl添加一个 propdir,它将采用一个字符串值,应该是leftright

有了dirprop,我们现在可以将正确的图标绑定到i元素。这是通过一个计算属性完成的,它将 prop 的值附加到字符串fa-chevron-,结果要么是fa-chevron-left要么是fa-chevron-right

resources/assets/js/app.js

Vue.component('image-carousel', { template: ` <div class="image-carousel">
      <img :src="image">
      <div class="controls">
        <carousel-control dir="left"></carousel-control>
        <carousel-control dir="right"></carousel-control>
      </div>
    </div> `,
  ... components: {
    'carousel-control': { template: `<i :class="classes"></i>`, props: [ 'dir' ], computed: {
        classes() {
          return 'carousel-control fa fa-2x fa-chevron-' + this.dir;
        }
      } }
  }
} 

现在我们可以看到轮播控制图标正确指向:

图 6.6。轮播控制图标现在正确指向

自定义事件

我们的轮播控件显示得很好,但它们还没有做任何事情!当它们被点击时,我们需要它们告诉ImageCarousel要么增加要么减少它的index值,这将导致图像被更改。

动态 props 对于这个任务不起作用,因为 props 只能从父组件向子组件发送数据。当子组件需要向父组件发送数据时,我们该怎么办?

自定义事件可以从子组件发出,并由其父组件监听。为了实现这一点,我们在子组件中使用$emit实例方法,它将事件名称作为第一个参数,并为任何要随事件发送的数据附加任意数量的额外参数,例如this.$emit('my-event', 'My event payload');

父组件可以在声明组件的模板中使用v-on指令来监听此事件。如果您使用方法处理事件,那么随事件发送的任何参数都将作为参数传递给此方法。

考虑这个例子,一个子组件MyComponent发出一个名为toggle的事件,告诉父组件,根实例,改变一个数据属性toggle的值:

<div id="app">
  <my-component @toggle="toggle = !toggle"></my-component> {{ message }} </div>
<script> Vue.component('my-component', { template: '<div v-on:click="clicked">Click me</div>', methods: { clicked: function() {
        this.$emit('toggle');
      }
    }
  });

  new Vue({ el: '#app', data: { toggle: false
    }, computed: { message: function() {
        return this.toggle ? 'On' : 'Off';
      }
    }
  }); </script>

更改轮播图像

回到CarouselControl,让我们通过使用v-on指令和触发一个方法clicked来响应用户的点击。这个方法将反过来发出一个自定义事件change-image,其中将包括一个-11的有效负载,具体取决于组件的状态是left还是right

就像v-bind一样,v-on也有一个简写。只需用@替换v-on:;例如,<div @click="handler"></div>相当于<div v-on:click="handler"></div>

resources/assets/js/app.js

components: {
  'carousel-control': { template: `<i :class="classes" @click="clicked"></i>`, props: [ 'dir' ], computed: {
      classes() {
        return 'carousel-control fa fa-2x fa-chevron-' + this.dir;
      }
    }, methods: {
      clicked() {
        this.$emit('change-image', this.dir === 'left' ? -1 : 1);
      }
    }
  }
}

打开 Vue Devtools 到Events选项卡,并同时点击轮播控件。自定义事件将在此处记录,因此我们可以验证change-image是否被发出:

图 6.7。屏幕截图显示自定义事件及其有效负载

ImageCarousel现在需要通过v-on指令监听change-image事件。该事件将由一个名为changeImage的方法处理,该方法将具有一个参数val,反映事件中发送的有效负载。然后,该方法将使用val来调整index的值,确保它在超出数组索引范围时循环到开始或结束。

resources/assets/js/app.js

Vue.component('image-carousel', { template: ` <div class="image-carousel">
      <img :src="image">
      <div class="controls">
        <carousel-control 
 dir="left" 
 @change-image="changeImage" ></carousel-control>
        <carousel-control 
 dir="right" 
 @change-image="changeImage" ></carousel-control>
      </div>
    </div> `,
  ... methods: {
    changeImage(val) {
      let newVal = this.index + parseInt(val);
      if (newVal < 0) {
        this.index = this.images.length -1;
      } else if (newVal === this.images.length) {
        this.index = 0;
      } else {
        this.index = newVal;
      }
    }
  },
  ...
}

完成后,图像轮播将正常工作:

图 6.8。图像轮播在更改图像后的状态

单文件组件

单文件组件SFCs)是具有.vue扩展名的文件,包含单个组件的完整定义,并可以导入到您的 Vue.js 应用程序中。SFC 使创建和使用组件变得简单,并带有各种其他好处,我们很快会探讨。

SFC 类似于 HTML 文件,但最多有三个根元素:

  • template

  • script

  • style

组件定义放在script标签内,除了以下内容,其余与任何其他组件定义完全相同:

  • 它将导出一个 ES 模块

  • 它将不需要template属性(或render函数;稍后会详细介绍)

组件的模板将在template标签内声明为 HTML 标记。这应该是一个从编写繁琐的模板字符串中解脱出来的好消息!

style标签是 SFC 独有的功能,可以包含组件所需的任何 CSS 规则。这主要有助于组织 CSS。

这是声明和使用单文件组件的示例。

MyComponent.vue

<template>
  <div id="my-component">{{ title }}</div>
</template>
<script> export default {
    data() { title: 'My Component'
    }
  }; </script>
<style> .my-component {
    color: red;
  } </style>

app.js

import 'MyComponent' from './MyComponent.vue';

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

转换

要在应用程序中使用单文件组件,只需像使用 ES 模块一样导入它。.vue文件不是有效的 JavaScript 模块文件。就像我们使用 Webpack Babel 插件将 ES2015 代码转译为 ES5 代码一样,我们必须使用Vue Loader.vue文件转换为 JavaScript 模块。

Vue Loader 已经默认配置了 Laravel Mix,因此在这个项目中我们无需做其他操作;我们导入的任何 SFC 都会正常工作!

要了解有关 Vue Loader 的更多信息,请查看vue-loader.vuejs.org/上的文档。

将组件重构为 SFC

我们的resource/assets/js/app.js文件现在几乎有 100 行。如果我们继续添加组件,它将变得难以管理,因此现在是时候考虑拆分它了。

让我们从重构现有组件为 SFC 开始。首先,我们将创建一个新目录,然后创建.vue文件:

$ mkdir resources/assets/components
$ touch resources/assets/components/ImageCarousel.vue
$ touch resources/assets/components/CarouselControl.vue

ImageCarousel.vue开始,第一步是创建三个根元素。

resources/assets/components/ImageCarousel.vue

<template></template>
<script></script>
<style></style>

现在,我们将template字符串移入template标签中,将组件定义移入script标签中。组件定义必须导出为模块。

resources/assets/components/ImageCarousel.vue

<template>
  <div class="image-carousel">
    <img :src="image">
    <div class="controls">
      <carousel-control 
        dir="left" 
        @change-image="changeImage" ></carousel-control>
      <carousel-control 
        dir="right" 
        @change-image="changeImage" ></carousel-control>
    </div>
  </div>
</template>
<script> export default { props: [ 'images' ],
    data() {
      return { index: 0
      }
    }, computed: {
      image() {
        return this.images[this.index];
      }
    }, methods: {
      changeImage(val) {
        let newVal = this.index + parseInt(val);
        if (newVal < 0) {
          this.index = this.images.length -1;
        } else if (newVal === this.images.length) {
          this.index = 0;
        } else {
          this.index = newVal;
        }
      }
    }, components: {
      'carousel-control': { template: `<i :class="classes" @click="clicked"></i>`, props: [ 'dir' ], computed: {
          classes() {
            return 'carousel-control fa fa-2x fa-chevron-' + this.dir;
          }
        }, methods: {
          clicked() {
            this.$emit('change-image', this.dir === 'left' ? -1 : 1);
          }
        }
      }
    }
  } </script>
<style></style>

现在我们可以将此文件导入到我们的应用程序中,并在根实例中本地注册它。如前所述,Vue 能够自动在 kebab-case 组件名称和 Pascal-case 组件名称之间切换。这意味着我们可以在component配置中使用对象简写语法,Vue 将正确解析它。

resources/assets/js/app.js

import ImageCarousel from '../components/ImageCarousel.vue';

var app = new Vue({
  ... components: { ImageCarousel }
});

在继续之前,请确保删除app.js中原始ImageCarousel组件定义的任何剩余代码。

CSS

SFC 允许我们向组件添加样式,有助于更好地组织我们的 CSS 代码。让我们将为图像轮播创建的 CSS 规则移入这个新 SFC 的style标签中:

<template>...</template>
<script>...</script>
<style> .image-carousel {
    height: 100%;
    margin-top: -12vh; position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .image-carousel img {
    width: 100%;
  }

  .image-carousel .controls {
    position: absolute;
    width: 100%;
    display: flex;
    justify-content: space-between;
  } </style>

项目构建完成后,你应该发现它仍然是一样的。然而,有趣的是,CSS 最终出现在了构建中的位置。如果你检查public/css/style.css,你会发现它不在那里。

它实际上包含在 JavaScript 捆绑包中作为一个字符串:

图 6.9. CSS 存储为 JavaScript 捆绑文件中的字符串

要使用它,Webpack 的引导代码将在应用程序运行时将此 CSS 字符串内联到文档的头部:

图 6.10. 文档头中的内联 CSS

内联 CSS 实际上是 Vue Loader 的默认行为。但是,我们可以覆盖这一行为,让 Webpack 将 SFC 样式写入它们自己的文件中。在 Mix 配置的底部添加以下内容。

webpack.mix.js

mix.options({ extractVueStyles: 'public/css/vue-style.css'
});

现在,一个额外的文件public/css/vue-style.css将被输出到构建中:

图 6.11. 包括单文件组件样式的 Webpack 输出

我们需要在主样式表之后在视图中加载这个新文件。

resources/views/app.blade.php

<head> ... <link rel="stylesheet" href="{{ asset('css/style.css') }}" type="text/css">
  <link rel="stylesheet" href="{{ asset('css/vue-style.css') }}" type="text/css"> ... </head>

CarouselControl

现在让我们将CarouselControl组件抽象成一个 SFC,并将resources/assets/css/style.css中的任何相关 CSS 规则移动过来。

resources/assets/components/CarouselControl.vue

<template>
  <i :class="classes" @click="clicked"></i>
</template>
<script> export default { props: [ 'dir' ], computed: {
      classes() {
        return 'carousel-control fa fa-2x fa-chevron-' + this.dir;
      }
    }, methods: {
      clicked() {
        this.$emit('change-image', this.dir === 'left' ? -1 : 1);
      }
    }
  } </script>
<style> .carousel-control {
    padding: 1rem;
    color: #ffffff;
    opacity: 0.85 }

  @media (min-width: 744px) {
    .carousel-control {
      font-size: 3rem;
    }
  } </style>

现在,这个文件可以被ImageCarousel组件导入。

resources/assets/components/ImageCarousel.vue

<template>...</style>
<script> import CarouselControl from '../components/CarouselControl.vue';

  export default {
    ... components: { CarouselControl }
  } </script>
<style>...</style>

完成后,我们现有的组件已经重构为 SFC。这并没有对我们应用程序的功能产生明显的影响(尽管稍微快一点,我稍后会解释),但随着我们的开发继续,这将使开发变得更容易。

内容分发

想象一下,你将要构建一个基于组件的 Vue.js 应用程序,它的结构类似于以下结构:

图 6.12. 基于组件的 Vue.js 应用程序

请注意,在上图的左分支中,ComponentCComponentB声明。然而,在右分支中,ComponentDComponentB的另一个实例声明。

根据你目前对组件的了解,如果ComponentB必须声明两个不同的组件,你会如何制作ComponentB的模板?也许它会包括一个v-if指令,根据从ComponentA传递下来的某个变量来使用ComponentCComponentD。这种方法可以工作,但是它会使ComponentB非常不灵活,在应用程序的其他部分限制了它的可重用性。

插槽

到目前为止,我们已经学到了组件的内容是由它自己的模板定义的,而不是由它的父级定义的,所以我们不会期望以下内容能够工作:

<div id="app">
  <my-component>
    <p>Parent content</p>
  </my-component>
</div>

但是,如果MyComponent在它的模板中有一个插槽,它将起作用。插槽是组件内的分发出口,使用特殊的slot元素定义:

Vue.component('my-component', { template: `
    <div>
      <slot></slot>
      <p>Child content</p>
    </div>`
});

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

这将呈现为:

<div id="app">
  <div>
    <p>Parent content</p>
    <p>Child content</p>
  </div>
</div>

如果ComponentB在它的模板中有一个插槽,就像这样:

Vue.component('component-b', { 
 template: '<slot></slot>'
}); 

我们可以解决刚才提到的问题,而不必使用繁琐的v-for

<component-a>
  <component-b>
    <component-c></component-c>
  </component-b>
  <component-b>
    <component-d></component-d>
  </component-b>
</component-a>

重要的是要注意,在父模板中声明的组件内的内容是在父模板的范围内编译的。尽管它在子组件内呈现,但它无法访问子组件的任何数据。以下示例应该能够区分这一点:

<div id="app">
  <my-component>
    <!--This works-->
    <p>{{ parentProperty }}</p>

    <!--This does not work. childProperty is undefined, as this content--> 
    <!--is compiled in the parent's scope-->
    <p>{{ childProperty }} </my-component>
</div>
<script> Vue.component('my-component', { template: `
      <div>
        <slot></slot>
        <p>Child content</p>
      </div>`,
    data() {
      return { childProperty: 'World'
      }
    }
  });

  new Vue({ el: '#app', data: { parentProperty: 'Hello'
    }
  }); </script>

模态窗口

我们根 Vue 实例中剩下的大部分功能都涉及模态窗口。让我们将这些抽象成一个单独的组件。首先,我们将创建新的组件文件:

$ touch resources/assets/components/ModalWindow.vue

现在,我们将把视图中的标记移到组件中。为了确保轮播图与模态窗口保持解耦,我们将在标记中的ImageCarousel声明替换为一个插槽。

resources/assets/components/ModalWindow.vue

<template>
  <div id="modal" :class="{ show : modalOpen }">
    <button @click="modalOpen = false" class="modal-close">&times;</button>
    <div class="modal-content">
      <slot></slot>
    </div>
  </div>
</template>
<script></script>
<style></style>

现在,我们可以在视图中刚刚创建的洞中声明一个ModalWindow元素,并将ImageCarousel作为插槽的内容。

resources/views/app.blade.php

<div id="app">
  <div class="header">...</div>
  <div class="container">...</div>
  <modal-window>
    <image-carousel :images="images"></image-carousel>
  </modal-window>
</div>

我们现在将从根实例中移动所需的功能,并将其放置在script标签内。

resources/assets/components/ModalWindow.vue

<template>...</template>
<script> export default {
    data() {
      return { modalOpen: false
      }
    }, methods: {
      escapeKeyListener(evt) {
        if (evt.keyCode === 27 && this.modalOpen) {
          this.modalOpen = false;
        }
      }
    }, watch: {
      modalOpen() {
        var className = 'modal-open';
        if (this.modalOpen) { document.body.classList.add(className);
        } else { document.body.classList.remove(className);
        }
      }
    },
    created() { document.addEventListener('keyup', this.escapeKeyListener);
    },
    destroyed() { document.removeEventListener('keyup', this.escapeKeyListener);
    },
  } </script>
<style></style>

接下来在入口文件中导入ModalWindow

resources/assets/js/app.js

import ModalWindow from '../components/ModalWindow.vue';

var app = new Vue({ el: '#app', data: Object.assign(model, { headerImageStyle: {
      'background-image': `url(${model.images[0]})`
    }, contracted: true
  }), components: { ImageCarousel, ModalWindow }
});

最后,让我们将任何与模态相关的 CSS 规则也移入 SFC 中:

<template>...</template>
<script>...</script>
<style> #modal {
    display: none;
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 2000;
    background-color: rgba(0,0,0,0.85);
  }

  #modal.show {
    display: block;
  }

  body.modal-open {
    overflow: hidden;
    position: fixed;
  }

  .modal-close {
    cursor: pointer;
    position: absolute;
    right: 0;
    top: 0;
    padding: 0px 28px 8px;
    font-size: 4em;
    width: auto;
    height: auto;
    background: transparent;
    border: 0;
    outline: none;
    color: #ffffff;
    z-index: 1000;
    font-weight: 100;
    line-height: 1;
  }

  .modal-content {
    height: 100%;
    max-width: 105vh;
    padding-top: 12vh;
    margin: 0 auto;
    position: relative;
  } </style>

项目构建完成后,您会注意到模态窗口不会打开。我们将在下一节中修复这个问题。

如果您检查 Vue Devtools,您会看到现在组件层次结构中有一个ModalWindow组件:

图 6.13。Vue Devtools 显示组件层次结构我们在 Vue Devtools 中的应用程序表示略有误导。它使得ImageCarousel看起来是ModalWindow的子组件。即使ImageCarousel由于插槽而在ModalWindow内部呈现,但这些组件实际上是同级的!

Refs

在初始状态下,模态窗口使用display: none CSS 规则隐藏。要打开模态窗口,用户必须点击标题图像。然后,点击事件侦听器将设置根实例数据属性modelOpen为 true,这将反过来向模态窗口添加一个类,以覆盖display: nonedisplay: block

然而,在重构之后,modalOpen已经移动到ModalWindow组件中,连同其余的模态逻辑,因此模态打开功能目前已经失效。修复这个问题的一种可能的方法是让根实例管理模态的打开/关闭状态,将逻辑移回根实例。然后我们可以使用 prop 来通知模态何时需要打开。当模态关闭时(这发生在模态组件的范围内,关闭按钮所在的地方),它会向根实例发送事件以更新状态。

这种方法可以工作,但不符合使我们的组件解耦且可重用的精神;模态组件应该管理自己的状态。那么,我们如何才能让模态保持其状态,但让根实例(父级)改变它?事件不起作用,因为事件只能向上流动,而不能向下流动。

ref是一个特殊的属性,允许您直接引用子组件的数据。要使用它,声明ref属性并为其分配一个唯一值,例如imagemodal

resources/views/app.blade.php

<modal-window ref="imagemodal"> ... </modal-window>

现在根实例可以通过$refs对象访问特定的ModalWindow组件数据。这意味着我们可以在根实例方法中更改modalOpen的值,就像我们可以从ModalWindow内部一样。

resources/assets/js/app.js

var app = new Vue({
  ... methods: {
    openModal() {
      this.$refs.imagemodal.modalOpen = true;
    },
  }
});

现在我们可以在标题图像的点击侦听器中调用openModal方法,从而恢复模态打开功能。

resources/views/app.blade.php

<div id="app">
  <div class="header">
    <div class="header-img" :style="headerImageStyle" @click="openModal">
      <button class="view-photos">View Photos</button>
    </div>
  </div> ... </div>

当使用组件的正常交互方法,即 prop 和事件,足以满足需求时,使用ref是一种反模式。ref通常只用于与页面正常流程之外的元素进行通信,就像模态窗口一样。

标题图像

现在让我们将标题图像抽象成一个组件。首先,创建一个新的.vue文件:

$ touch resources/assets/components/HeaderImage.vue

现在移动到标记、数据和 CSS。注意以下修改:

  • 必须发出事件header-clicked。这将用于打开模态窗口

  • 图像 URL 作为 prop 传递,image-url,然后通过计算属性转换为内联样式规则

resource/assets/components/HeaderImage.vue

<template>
  <div class="header">
    <div class="header-img" 
      :style="headerImageStyle" 
      @click="$emit('header-clicked')"
    >
      <button class="view-photos">View Photos</button>
    </div>
  </div>
</template>
<script> export default { computed: {
      headerImageStyle() {
        return {
          'background-image': `url(${this.imageUrl})`
        };
      }
    }, props: [ 'image-url' ]
  } </script>
<style> .header {
    height: 320px;
  }

  .header .header-img {
    background-repeat: no-repeat;
    -moz-background-size: cover;
    -o-background-size: cover;
    background-size: cover;
    background-position: 50% 50%;
    background-color: #f5f5f5;
    height: 100%;
    cursor: pointer;
    position: relative;
  }

  .header .header-img button {
    font-size: 14px;
    padding: 7px 18px;
    color: #484848;
    line-height: 1.43;
    background: #ffffff;
    font-weight: bold;
    border-radius: 4px;
    border: 1px solid #c4c4c4;
  }

  .header .header-img button.view-photos {
    position: absolute;
    bottom: 20px;
    left: 20px;
  } </style>

一旦在resources/assets/js/app.js中导入了这个组件,就在主模板中声明它。确保绑定image-urlprop 并处理点击事件。

resources/views/app.blade.php

<div id="app">
  <header-image 
    :image-url="images[0]" 
    @header-clicked="openModal" ></header-image>
  <div class="container">...</div> <modal-window>...</modal-window>
</div>

功能列表

让我们继续将 Vuebnb 重构为组件,并将设施和价格列表抽象出来。这些列表具有类似的目的和结构,因此创建一个单一的通用组件是有意义的。

让我们回顾一下当前列表的标记是什么样子的。

resources/views/app.blade.php

<div class="lists">
  <hr>
  <div class="amenities list">
    <div class="title"><strong>Amenities</strong></div>
    <div class="content">
      <div class="list-item" v-for="amenity in amenities">
        <i class="fa fa-lg" :class="amenity.icon"></i>
        <span>@{{ amenity.title }}</span>
      </div>
    </div>
  </div>
  <hr>
  <div class="prices list">
    <div class="title"><strong>Prices</strong></div>
    <div class="content">
      <div class="list-item" v-for="price in prices"> @{{ price.title }}: <strong>@{{ price.value }}</strong>
      </div>
    </div>
  </div>
</div>

两个列表之间的主要区别在于<div class="content">...</div>部分,因为在每个列表中显示的数据结构略有不同。设施有一个图标和一个标题,而价格有一个标题和一个值。我们将在这一部分使用插槽,以允许父级自定义每个内容。

但首先,让我们创建新的FeatureList组件文件:

$ touch resources/assets/components/FeatureList.vue

我们将一个列表的标记移到其中,使用插槽替换列表内容。我们还将为标题添加一个 prop,并移入任何与列表相关的 CSS。

resources/assets/components/FeatureList.vue:

<template>
  <div>
    <hr>
    <div class="list">
      <div class="title"><strong>{{ title }}</strong></div>
      <div class="content">
        <slot></slot>
      </div>
    </div>
  </div>
</template>
<script> export default { props: ['title']
  } </script>
<style> hr {
    border: 0;
    border-top: 1px solid #dce0e0;
  }
  .list {
    display: flex;
    flex-wrap: nowrap;
    margin: 2em 0;
  }

  .list .title {
    flex: 1 1 25%;
  }

  .list .content {
    flex: 1 1 75%;
    display: flex;
    flex-wrap: wrap;
  }

  .list .list-item {
    flex: 0 0 50%;
    margin-bottom: 16px;
  }

  .list .list-item > i {
    width: 35px;
  }

  @media (max-width: 743px) {
    .list .title {
      flex: 1 1 33%;
    }

    .list .content {
      flex: 1 1 67%;
    }

    .list .list-item {
      flex: 0 0 100%;
    }
  } </style>

继续将FeatureList导入resources/assets/js/app.js,并将其添加到本地注册的组件中。现在我们可以在主模板中使用FeatureList,每个列表都有一个单独的实例。

resources/views/app.blade.php:

<div id="app"> ... <div class="container"> ... <div class="lists">
      <feature-list title="Amenities">
        <div class="list-item" v-for="amenity in amenities">
          <i class="fa fa-lg" :class="amenity.icon"></i>
          <span>@{{ amenity.title }}</span>
        </div>
      </feature-list>
      <feature-list title="Prices">
        <div class="list-item" v-for="price in prices"> @{{ price.title }}: <strong>@{{ price.value }}</strong>
        </div>
      </feature-list>
    </div>
  </div>
</div>

作用域插槽

FeatureList组件可以工作,但相当薄弱。大部分内容都通过插槽传递,因此似乎父级做了太多的工作,而子级做得太少。鉴于在组件的两个声明中都有重复的代码(<div class="list-item" v-for="...">),最好将这些代码委托给子级。

为了使我们的组件模板更加灵活,我们可以使用作用域插槽而不是常规插槽。作用域插槽允许您将模板传递给插槽,而不是传递渲染的元素。当这个模板在父级中声明时,它将可以访问子级提供的任何 props。

例如,一个带有作用域插槽的组件child可能如下所示:

<div>
  <slot my-prop="Hello from child"></slot>
</div>

使用这个组件的父级将声明一个template元素,其中将有一个命名别名对象的slot-scope属性。在子级模板中添加到插槽的任何 props 都可以作为别名对象的属性使用:

<child>
  <template slot-scope="props">
    <span>Hello from parent</span>
    <span>{{ props.my-prop }}</span>
  </template>
</child>

这将呈现为:

<div>
  <span>Hello from parent</span>
  <span>Hello from child</span>
</div>

让我们通过包含一个带有FeatureList组件的作用域插槽的步骤。目标是能够将列表项数组作为 prop 传递,并让FeatureList组件对它们进行迭代。这样,FeatureList将拥有任何重复的功能。然后父级将提供一个模板来定义每个列表项的显示方式。

resources/views/app.blade.php:

<div class="lists">
  <feature-list title="Amenities" :items="amenities">
    <!--template will go here-->
  </feature-list>
  <feature-list title="Prices" :items="prices">
    <!--template will go here-->
  </feature-list>   
</div>

现在专注于FeatureList组件,按照以下步骤操作:

  1. 在配置对象的 props 数组中添加items

  2. items将是一个我们在<div class="content">部分内部迭代的数组。

  3. 在循环中,item是任何特定列表项的别名。我们可以创建一个插槽,并使用v-bind="item"将该列表项绑定到插槽。(我们以前没有使用过没有参数的v-bind,但这将整个对象的属性绑定到元素。这对于设施和价格对象具有不同属性的情况很有用,现在我们不必指定它们。)

resources/assets/components/FeatureList.vue:

<template>
  <div>
    <hr>
    <div class="list">
      <div class="title"><strong>{{ title }}</strong></div>
      <div class="content">
        <div class="list-item" v-for="item in items">
          <slot v-bind="item"></slot>
        </div>
      </div>
    </div>
  </div>
</template>
<script> export default { props: ['title', 'items']
  } </script>
<style>...</style>

现在我们将回到我们的视图。让我们先处理设施列表:

  1. FeatureList声明中声明一个template元素。

  2. 模板必须包含slot-scope属性,我们将其分配给一个别名amenity。这个别名允许我们访问作用域 props。

  3. 在模板中,我们可以使用与以前完全相同的标记来显示我们的设施列表项。

resources/views/app.blade.php:

<feature-list title="Amenities" :items="amenities">
  <template slot-scope="amenity">
    <i class="fa fa-lg" :class="amenity.icon"></i>
    <span>@{{ amenity.title }}</span>
  </template>
</feature-list>

这是包含价格的完整主模板。

resources/views/app.blade.php:

<div id="app"> ... <div class="container"> ... <div class="lists">
      <feature-list title="Amenities" :items="amenities">
        <template slot-scope="amenity">
          <i class="fa fa-lg" :class="amenity.icon"></i>
          <span>@{{ amenity.title }}</span>
        </template>
      </feature-list>
      <feature-list title="Prices" :items="prices">
        <template slot-scope="price"> @{{ price.title }}: <strong>@{{ price.value }}</strong>
        </template>
      </feature-list>
    </div>
  </div>
</div>

尽管这种方法的标记与以前一样多,但它已经将更常见的功能委托给了组件,这使得设计更加健壮。

可展开的文本

我们在第二章中创建了功能,原型 Vuebnb,你的第一个 Vue.js 项目,允许关于文本在页面加载时部分收缩,并通过点击按钮展开到完整长度。让我们也将这个功能抽象成一个组件:

$ touch resources/assets/components/ExpandableText.vue

将所有标记、配置和 CSS 移入新组件。请注意,我们在文本内容中使用了一个插槽。

resources/assets/components/ExpandableText.vue

<template>
  <div>
    <p :class="{ contracted: contracted }">
      <slot></slot>
    </p>
    <button v-if="contracted" class="more" @click="contracted = false"> + More
    </button>
  </div>
</template>
<script> export default {
    data() {
      return { contracted: true
      }
    }
  } </script>
<style> p {
    white-space: pre-wrap;
  }

  .contracted {
    height: 250px;
    overflow: hidden;
  } .about button.more {
    background: transparent;
    border: 0;
    color: #008489;
    padding: 0;
    font-size: 17px;
 font-weight: bold;
  } .about button.more:hover, 
 .about button.more:focus, 
 .about button.more:active {
    text-decoration: underline;
    outline: none;
  } </style>

一旦你在resources/assets/js/app.js中导入了这个组件,在主模板中声明它,记得在插槽中插入about数据属性。

resource/views/app.blade.php

<div id="app">
  <header-image>...</header-image>
  <div class="container">
    <div class="heading">...</div>
    <hr>
    <div class="about">
      <h3>About this listing</h3>
      <expandable-text>@{{ about }}</expandable-text>
    </div>
    ... </div>
</div>

做到这一点后,Vuebnb 客户端应用的大部分数据和功能都已经被抽象成了组件。让我们看看resources/assets/js/app.js,看看它变得多么简洁!

resources/assets/js/app.js

...

import ImageCarousel from '../components/ImageCarousel.vue';
import ModalWindow from '../components/ModalWindow.vue';
import FeatureList from '../components/FeatureList.vue';
import HeaderImage from '../components/HeaderImage.vue';
import ExpandableText from '../components/ExpandableText.vue';

var app = new Vue({ el: '#app', data: Object.assign(model, {}), components: { ImageCarousel, ModalWindow, FeatureList, HeaderImage, ExpandableText }, methods: {
    openModal() {
      this.$refs.imagemodal.modalOpen = true;
    }
  }
});

虚拟 DOM

现在让我们改变方向,讨论 Vue 如何渲染组件。看看这个例子:

Vue.component('my-component', { template: '<div id="my-component">My component</div>'
});

为了让 Vue 能够将这个组件渲染到页面上,它将首先使用内部模板编译器库将模板字符串转换为 JavaScript 对象:

图 6.14。模板编译器如何将模板字符串转换为对象

一旦模板被编译,任何状态或指令都可以很容易地应用。例如,如果模板包括一个v-for,可以使用简单的 for 循环来复制节点并插入正确的变量。

之后,Vue 可以与 DOM API 交互,将页面与组件的状态同步。

渲染函数

与为组件提供字符串模板不同,你可以提供一个render函数。即使不理解语法,你可能也能从以下例子中看出,render函数生成了一个与前面例子中的字符串模板在语义上等价的模板。两者都定义了一个带有id属性为my-componentdiv,并且内部文本为My component

Vue.component('my-component'</span>, {
  render(createElement) {
    createElement('div', {attrs:{id:'my-component'}}, 'My component');
    // Equivalent to <div id="my-component">My component</div>
  }
})

渲染函数更高效,因为它们不需要 Vue 首先编译模板字符串。不过,缺点是,编写渲染函数不像标记语法那样简单或表达性强,一旦你有了一个大模板,将会很难处理。

Vue Loader

如果我们能够在开发中创建 HTML 标记模板,然后让 Vue 的模板编译器在构建步骤中将它们转换为render函数,那将是两全其美的。

这正是当 Webpack 通过Vue Loader转换它们时发生在单文件组件中的情况。看一下下面的 JavaScript 捆绑包片段,你可以看到 Webpack 在转换和捆绑ImageCarousel组件后的情况:

图 6.15。捆绑文件中的 image-carousel 组件

将主模板重构为单文件组件

我们应用的根实例的模板是app视图中#app元素内的内容。这样的 DOM 模板需要 Vue 模板编译器,就像任何字符串模板一样。

如果我们能够将这个 DOM 模板抽象成一个 SFC,那么我们所有的前端应用模板都将被构建为render函数,并且不需要在运行时调用模板编译器。

让我们为主模板创建一个新的 SFC,并将其命名为ListingPage,因为这部分应用是我们的列表页面:

$ touch resources/assets/components/ListingPage.vue

我们将主模板、根配置和任何相关的 CSS 移到这个组件中。注意以下内容:

  • 我们需要将模板放在一个包装的div中,因为组件必须有一个单一的根元素

  • 现在我们可以删除@转义,因为这个文件不会被 Blade 处理

  • 现在组件与我们创建的其他组件相邻,所以确保更改导入的相对路径

resource/assets/components/ListingPage.vue

<template>
  <div>
    <header-image 
      :image-url="images[0]" 
      @header-clicked="openModal" ></header-image>
    <div class="container">
      <div class="heading">
        <h1>{{ title }}</h1>
        <p>{{ address }}</p>
      </div>
      <hr>
      <div class="about">
        <h3>About this listing</h3>
        <expandable-text>{{ about }}</expandable-text>
      </div>
      <div class="lists">
        <feature-list title="Amenities" :items="amenities">
          <template slot-scope="amenity">
            <i class="fa fa-lg" :class="amenity.icon"></i>
            <span>{{ amenity.title }}</span>
          </template>
        </feature-list>
        <feature-list title="Prices" :items="prices">
          <template slot-scope="price"> {{ price.title }}: <strong>{{ price.value }}</strong>
          </template>
        </feature-list>
      </div>
    </div>
    <modal-window ref="imagemodal">
      <image-carousel :images="images"></image-carousel>
    </modal-window>
  </div>
</template>
<script> import { populateAmenitiesAndPrices } from '../js/helpers';

  let model = JSON.parse(window.vuebnb_listing_model); model = populateAmenitiesAndPrices(model);

  import ImageCarousel from './ImageCarousel.vue';
  import ModalWindow from './ModalWindow.vue';
  import FeatureList from './FeatureList.vue';
  import HeaderImage from './HeaderImage.vue';
  import ExpandableText from './ExpandableText.vue';

  export default {
    data() {
      return Object.assign(model, {});
    }, components: { ImageCarousel, ModalWindow, FeatureList, HeaderImage, ExpandableText }, methods: {
      openModal() {
        this.$refs.imagemodal.modalOpen = true;
      }
    }
  } </script>
<style> .about {
    margin: 2em 0;
  }

  .about h3 {
    font-size: 22px;
  } </style>

使用渲染函数挂载根级组件

现在我们主模板中的挂载元素将是空的。我们需要声明Listing组件,但我们不想在视图中这样做。

resources/views/app.blade.php

<body>
<div id="toolbar">
  <img class="icon" src="{{ asset('images/logo.png') }}">
  <h1>vuebnb</h1>
</div>
<div id="app">
  <listing></listing>
</div>
<script src="{{ asset('js/app.js') }}"></script>
</body>

如果我们这样做,就无法完全消除应用中的所有字符串和 DOM 模板,所以我们将保持挂载元素为空。

resources/views/app.blade.php

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

我们现在可以在我们的根实例中声明Listing并使用渲染函数。

resources/assets/js/app.js

import "core-js/fn/object/assign";
import Vue from 'vue';

import ListingPage from '../components/ListingPage.vue';

var app = new Vue({ el: '#app', render: h => h(ListingPage)
});

为了避免走神,我不会在这里解释render函数的语法,因为这是我们在整本书中唯一要编写的函数。如果您想了解更多关于render函数的信息,请查看 Vue.js 文档vuejs.org/

现在 Vuebnb 不再使用字符串或 DOM 模板,我们不再需要模板编译器功能。有一个特殊的 Vue 构建可以使用,不包括它!

Vue.js 构建

运行 Vue.js 有许多不同的环境和用例。在一个项目中,您可能直接在浏览器中加载 Vue,在另一个项目中,您可能在 Node.js 服务器上加载它,以进行服务器渲染。因此,提供了不同的 Vue 构建,以便您可以选择最合适的一个。

在 Vue NPM 包的dist文件夹中,我们可以看到八个不同的 Vue.js 构建:

图 6.16。node_modules/vue/dist 文件夹中的各种构建

Vue.js 网站提供了一个表格来解释这八个不同的构建:

UMD CommonJS ES Module
完整 vue.js vue.common.js vue.esm.js
仅运行时 vue.runtime.js vue.runtime.common.js vue.runtime.esm.js
完整(生产环境) vue.min.js - -
仅运行时(生产环境) vue.runtime.min.js - -

模块系统

表格的列将构建分类为UMDCommonJSES Module。我们在第五章中讨论了 CommonJS 和 ES 模块,但我们没有提到UMD通用模块定义)。关于 UMD,您需要知道的主要是它是另一种模块模式,并且在浏览器中运行良好。如果您直接在script标签中链接到 Vue,UMD 就是最佳选择。

生产构建

表格的行分为两种类型:完整或运行时,以及带有或不带有生产环境。

生产构建用于部署的应用程序,而不是在开发中运行的应用程序。它已经被缩小,并且关闭或剥离了任何警告、注释或其他开发选项。目的是使构建尽可能小和安全,这是您在生产中想要的。

请注意,生产构建只有 UMD 版本,因为只有 UMD 可以直接在浏览器中运行。CommonJS 和 ES 模块需要与构建工具一起使用,比如 Webpack,它提供了自己的生产处理。

完整构建与仅运行时

正如我们所讨论的,Vue 包括一个模板编译器,用于在运行时将任何字符串或 DOM 模板转换为渲染函数。完整构建包括模板编译器,这是您通常会使用的。但是,如果您已经在开发中将模板转换为渲染函数,您可以使用仅运行时构建,它不包括编译器,大小约小 30%!

选择构建

对于 Vuebnb 来说,一个很好的构建是vue.runtime.esm.js,因为我们使用 Webpack,不需要模板编译器。我们也可以使用vue.runtime.common.js,但这与我们在项目的其他地方使用 ES 模块不一致。实际上,它们没有区别,因为 Webpack 会以相同的方式处理它们。

请记住,在我们的入口文件顶部包含了 Vue 的语句import Vue from 'vue'。最后的'vue'是 Webpack 运行时解析的 Vue 构建的别名。目前,这个别名在默认的 Mix 配置中定义,并设置为构建vue.common.js。我们可以通过在webpack.mix.js文件底部添加以下内容来覆盖该配置。

webpack.mix.js

...

mix.webpackConfig({ resolve: { alias: {
      'vue$': 'vue/dist/vue.runtime.esm.js'
    }
  }
});

在新的构建之后,我们应该期望看到由于模板编译器被移除而导致的较小的捆绑包大小。在下面的屏幕截图中,我展示了在单独的终端标签页中运行dev构建之前和之后的捆绑包:

图 6.17。应用运行时构建后捆绑包大小的差异

请记住,没有了模板编译器,我们不能再为我们的组件提供字符串模板。这样做将导致运行时错误。不过,这不应该是一个问题,因为我们有更强大的 SFC 选项。

摘要

在本章中,我们看到了如何使用组件来创建可重用的自定义元素。然后,我们注册了我们的第一个 Vue.js 组件,并用模板字符串来定义它们。

接下来,我们将使用 props 和自定义事件来进行组件通信。我们利用这些知识在列表页面模态窗口中构建了一个图像轮播。

在本章的下半部分,我们介绍了单文件组件,我们使用它来重构 Vuebnb 成为基于组件的架构。然后,我们学习了插槽如何帮助我们通过组合父级和子级内容来创建更多功能的组件。

最后,我们看到了如何使用仅运行时构建来使 Vue 应用程序的大小更小。

在下一章中,我们将通过构建主页并使用 Vue Router 来实现页面之间的导航而不重新加载,将 Vuebnb 打造成一个多页面应用程序。

第七章:使用 Vue Router 构建多页面应用程序

在上一章中,我们了解了 Vue.js 组件,并将 Vuebnb 转换为基于组件的架构。现在我们已经做到了这一点,我们可以使用 Vue Router 轻松地向我们的应用程序添加新页面。

在本章中,我们将为 Vuebnb 创建一个主页,其中包括一个可点击的缩略图库,展示完整的模拟列表。

本章涉及的主题:

  • 解释了路由器库是什么,以及为什么它们是单页面应用的关键部分

  • Vue Router 及其主要功能概述

  • Vue Router 的安装和基本配置

  • 使用RouterLinkRouterView特殊组件来管理页面导航

  • 设置 Vue 的 AJAX 以从 Web 服务检索数据而无需刷新页面

  • 使用路由导航守卫在加载新页面之前检索数据

单页面应用程序

大多数网站都被分成页面,以使它们包含的信息更容易消化。传统上,这是通过服务器/客户端模型完成的,其中每个页面必须使用不同的 URL 从服务器加载。要导航到新页面,浏览器必须发送到该页面的 URL 的请求。服务器将发送数据回来,浏览器可以卸载现有页面并加载新页面。对于普通的互联网连接,这个过程可能需要几秒钟,用户必须等待新页面加载。

通过使用强大的前端框架和 AJAX 实用程序,可以实现不同的模型:浏览器可以加载初始网页,但导航到新页面不需要浏览器卸载页面并加载新页面。相反,新页面所需的任何数据都可以通过 AJAX 异步加载。从用户的角度来看,这样的网站看起来就像任何其他网站一样有页面,但从技术角度来看,这个网站实际上只有一个页面。因此得名,单页面应用SPA)。

单页面应用程序架构的优势在于它可以为用户创建更无缝的体验。新页面的数据仍然必须被检索,因此会对用户的流程造成一些小的中断,但由于数据检索可以异步进行并且 JavaScript 可以继续运行,因此这种中断被最小化。此外,由于 SPA 页面通常需要较少的数据,因为一些页面元素可以重复使用,所以页面加载速度更快。

SPA 架构的缺点是,由于增加的功能,客户端应用程序变得更加臃肿,因此通过加快页面切换所获得的收益可能会被用户必须在第一次页面加载时下载一个大型应用程序所抵消。此外,处理路由会给应用程序增加复杂性,因为必须管理多个状态,处理 URL,并且必须在应用程序中重新创建许多默认的浏览器功能。

路由器

如果您选择 SPA 架构,并且您的应用设计包括多个页面,您将需要使用路由器。在这种情况下,路由器是一个库,它将通过 JavaScript 和各种本机 API 模拟浏览器导航,以便用户获得类似于传统多页面应用的体验。路由器通常包括以下功能:

  • 从页面内部处理导航操作

  • 将应用程序的部分与路由匹配

  • 管理地址栏

  • 管理浏览器历史记录

  • 管理滚动条行为

Vue 路由器

一些前端框架,如 Angular 或 Ember,包含一个即插即用的路由器库。这些框架的理念是,开发人员更适合使用完整的、集成的解决方案来构建他们的 SPA。

其他框架/库,如 React 和 Vue.js,不包括路由器。相反,您必须安装一个单独的库。

在 Vue.js 的情况下,有一个名为Vue Router的官方路由器库可用。这个库是由 Vue.js 核心团队开发的,因此它针对与 Vue.js 一起使用进行了优化,并充分利用了基本的 Vue 功能,如组件和响应性。

使用 Vue Router,应用程序的不同页面由不同的组件表示。当您设置 Vue Router 时,您将传递配置,告诉它哪个 URL 映射到哪个组件。然后,在应用程序中点击链接时,Vue Router 将交换活动组件,以匹配新的 URL,例如:

let routes = [
  { path: '/', component: HomePage },
  { path: '/about', component: AboutPage },
  { path: '/contact', component: ContactPage }
];

由于在正常情况下渲染组件是一个几乎瞬间的过程,使用 Vue Router 在页面之间的转换也是如此。但是,有一些异步钩子可以被调用,以便让您有机会从服务器加载新数据,如果您的不同页面需要它。

特殊组件

当您安装 Vue Router 时,两个组件将全局注册,供整个应用程序使用:RouterLinkRouterView

RouterLink通常用于替代a标签,并使您的链接可以访问 Vue Router 的特殊功能。

正如所解释的,Vue Router 将交换指定的页面组件,以模拟浏览器导航。RouterView是此组件交换发生的出口。就像插槽一样,您可以将其放在主页面模板的某个位置。例如:

<div id="app">
  <header></header>
  <router-view> // This is where different page components display </router-view>
  <footer></footer>
</div>

Vuebnb 路由

Vuebnb 从未被规定为单页面应用程序的目标。事实上,Vuebnb 将偏离纯 SPA 架构,我们将在本书的后面看到。

也就是说,将 Vue Router 纳入 Vuebnb 将对用户在应用程序中的导航体验非常有益,因此我们将在本章中将其添加到 Vuebnb 中。

当然,如果我们要添加一个路由,我们需要一些额外的页面!到目前为止,在项目中,我们一直在 Vuebnb 的listing页面上工作,但尚未开始在应用程序的首页上工作。因此,除了安装 Vue Router 之外,我们还将开始在 Vuebnb 主页上工作,该主页显示缩略图和链接到我们所有模拟列表的页面:

图 7.1。Vuebnb 的首页

安装 Vue Router

Vue Router 是一个 NPM 包,可以在命令行上安装:

$  npm i --save-dev vue-router

让我们将我们的路由器配置放入一个新文件router.js中:

$ touch resources/assets/js/router.js

要将 Vue Router 添加到我们的项目中,我们必须导入该库,然后使用Vue.useAPI 方法使 Vue 与 Vue Router 兼容。这将为 Vue 提供一个新的配置属性router,我们可以使用它来连接一个新的路由器。

然后,我们使用new VueRouter()创建 Vue Router 的实例。

resources/assets/js/router.js

import Vue from 'vue';
import VueRouter from 'vue-router'; Vue.use(VueRouter);

export default new VueRouter();

通过从这个新文件中导出我们的路由器实例,我们已经将其转换为一个可以在app.js中导入的模块。如果我们将导入的模块命名为router,则可以使用对象解构来简洁地将其连接到我们的主配置对象。

resources/assets/js/app.js

import "core-js/fn/object/assign";
import Vue from 'vue';

import ListingPage from '../components/ListingPage.vue';
import router from './router'

var app = new Vue({ el: '#app', render: h => h(ListingPage), router });

创建路由

Vue Router 的最基本配置是提供一个routes数组,它将 URL 映射到相应的页面组件。此数组将包含至少两个属性的对象:pathcomponent

请注意,通过页面组件,我只是指任何我们指定为在我们的应用程序中表示页面的组件。它们在其他方面都是常规组件。

目前,我们的应用程序中只会有两个路由,一个用于我们的主页,一个用于我们的列表页面。HomePage组件尚不存在,因此在创建它之前,我们将保持其路由被注释掉。

resources/assets/js/router.js

import ListingPage from '../components/ListingPage.vue';

export default new VueRouter({ mode: 'history', routes: [
    // { path: '/', component: HomePage }, // doesn't exist yet!
    { path: '/listing/:listing', component: ListingPage }
  ]
});

您会注意到我们的ListingPage组件的路径包含一个动态段:listing,因此此路由将匹配包括/listing/1、listing/2...listing/whatever在内的路径。

Vue Router 有两种模式:hash模式和history模式。哈希模式使用 URL 哈希来模拟完整的 URL,因此当哈希更改时页面不会重新加载。历史模式有真实的 URL,并利用history.pushStateAPI 来更改 URL 而不引起页面重新加载。历史模式的唯一缺点是 Vue 无法处理应用程序之外的 URL,例如/some/weird/path,必须由服务器处理。这对我们来说不是问题,所以我们将使用 Vuebnb 的历史模式。

应用程序组件

为了使我们的路由器工作,我们需要在页面模板的某个地方声明一个RouterView组件。否则,页面组件将无处可渲染。

我们稍微重构我们的应用程序来做到这一点。目前,ListingPage组件是应用程序的root组件,因为它位于组件层次结构的顶部,并加载我们使用的所有其他组件。

由于我们希望路由器根据 URL 在ListingPageHomePage之间切换,我们需要另一个组件在组件层次结构中位于ListingPage之上并处理这项工作。我们将称这个新的根组件为App

图 7.2。App、ListingPage 和 HomePage 之间的关系

让我们创建App组件文件:

$ touch resources/assets/components/App.vue

Vue 的根实例在加载时应该将这个渲染到页面上,而不是ListingPage

resources/assets/js/app.js

import App from '../components/App.vue';

...

var app = new Vue({ el: '#app', render: h => h(App), router });

以下是App组件的内容。我在模板中添加了特殊的RouterView组件,这是HomePageListingPage组件将渲染的出口。

您还会注意到我已经将工具栏从app.blade.php移动到了App的模板中。这样工具栏就在 Vue 的领域内;之前它在安装点之外,因此无法被 Vue 触及。我这样做是为了以后我们可以使用RouterLink将主标志变成一个指向主页的链接,因为这是大多数网站的惯例。我也将任何与工具栏相关的 CSS 移入了style元素中。

resources/assets/components/App.vue

<template>
  <div>
    <div id="toolbar">
      <img class="icon" src="/images/logo.png">
      <h1>vuebnb</h1>
    </div>
    <router-view></router-view>
  </div>
</template>
<style> #toolbar {
    display: flex;
    align-items: center;
    border-bottom: 1px solid #e4e4e4;
    box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
  }

  #toolbar .icon {
    height: 34px;
    padding: 16px 12px 16px 24px;
    display: inline-block;
  }

  #toolbar h1 {
    color: #4fc08d;
    display: inline-block;
    font-size: 28px;
    margin: 0;
  } </style>

完成后,如果您现在将浏览器导航到类似/listing/1的 URL,您会发现一切看起来与以前一样。但是,如果您查看 Vue Devtools,您会发现组件层次结构已经改变,反映了App组件的添加。

还有一个指示器,告诉我们ListingPage组件是 Vue Router 的活动页面组件:

图 7.3。在 Vue Devtools 打开的情况下,显示组件层次结构的/listing/1

主页

现在让我们开始在我们的主页上工作。我们首先创建一个新组件HomePage

$ touch resources/assets/components/HomePage.vue

现在,让我们在设置之前向组件添加占位符标记。

resources/assets/components/HomePage.vue

<template>
  <div>Vuebnb home page</div>
</template>

确保在router文件中导入此组件,并取消使用它的路由。

resources/assets/js/router.js

....

import HomePage from '../components/HomePage.vue';
import ListingPage from '../components/ListingPage.vue';

export default new VueRouter({ mode: 'history', routes: [
    { path: '/', component: HomePage },
    { path: '/listing/:listing', component: ListingPage }
  ]
});

您可能会尝试通过将 URLhttp://vuebnb.test/放入浏览器地址栏来测试这个新路由。但是,您会发现这导致 404 错误。请记住,我们仍然没有在服务器上为此创建路由。尽管 Vue 从内部管理路由,但任何地址栏导航请求必须由 Laravel 提供。

现在,让我们通过使用RouterLink组件在工具栏中创建一个指向我们主页的链接。这个组件就像一个增强版的a标签。例如,如果给你的路由一个name属性,你可以简单地使用to属性,而不必提供一个href。Vue 会在渲染时解析这个到正确的 URL。

resources/assets/components/App.vue

<div id="toolbar">
  <router-link :to="{ name: 'home' }">
    <img class="icon" src="/images/logo.png">
    <h1>vuebnb</h1>
  </router-link>
</div>

让我们也为我们的路由添加name属性,以使其工作。

resources/assets/js/app.js

routes: [
  { path: '/', component: HomePage, name: 'home' },
  { path: '/listing/:listing', component: ListingPage, name: 'listing' }
]

现在我们必须修改我们的 CSS,因为我们现在的标志周围有另一个标签。修改工具栏 CSS 规则以匹配后面的规则。

resources/assets/components/App.vue

<template>...</template>
<style> #toolbar { border-bottom: 1px solid #e4e4e4; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
  }

 ... #toolbar a { display: flex; align-items: center; text-decoration: none;
  }
</style>

现在让我们打开一个列表页面,比如/listing/1。如果你检查 DOM,你会看到我们的工具栏现在里面有一个新的a标签,其中包含一个正确解析的链接返回到主页:

图 7.4 工具栏是通过RouterLink元素返回到主页的链接

如果你点击那个链接,你会被带到主页!记住,页面实际上并没有改变;Vue 路由器只是在RouterView内将ListingPage替换为HomePage,并且通过history.pushStateAPI 更新了浏览器 URL:

图 7.5 主页与 Vue Devtools 显示的组件层次结构

主页路由

现在让我们为主页添加一个服务器端路由,这样我们就可以从根路径加载我们的应用程序。这个新路由将指向ListingController类中的get_home_web方法。

routes/web.php

<?php

Route::get('/', 'ListingController@get_home_web'); Route::get('/listing/{listing}', 'ListingController@get_listing_web');

现在去控制器,我们将使get_home_web方法返回app视图,就像它为列表 web 路由所做的那样。app视图包括一个模板变量model,我们使用它来传递初始应用程序状态,就像在第五章中设置的那样,使用 Webpack 集成 Laravel 和 Vue.js。现在,只需将一个空数组分配为占位符。

app/Http/Controllers/ListingController.php

public function get_home_web() 
{
  return view('app', ['model' => []]);
}

完成这些后,我们现在可以导航到http://vuebnb.test/,它会工作!当 Vue 应用程序启动时,Vue Router 将检查 URL 值,并且看到路径是*/*,将在应用程序的第一次渲染中在RouterView出口内加载HomePage组件。

查看这个页面的源代码,它与我们加载列表路由时得到的页面完全相同,因为它是相同的视图,即app.blade.php。唯一的区别是初始状态是一个空数组:

图 7.6 vuebnb.test 的页面源代码,初始状态为空

初始状态

就像我们的列表页面一样,我们的主页也需要初始状态。从最终产品来看,我们可以看到主页显示了我们所有模拟列表的摘要,包括缩略图、标题和简短描述:

图 7.7 完成的主页,关注列表

重构

在我们将初始状态注入到主页之前,让我们对代码进行一些小的重构,包括重命名一些变量和重构一些方法。这将确保代码语义反映出不断变化的需求,并保持我们的代码可读性和易理解性。

首先,让我们将我们的模板变量从$model重命名为更一般的$data

resources/views/app.blade.php

<script type="text/javascript"> window.vuebnb_server_data = "{!! addslashes(json_encode($data)) !!}" </script>

在我们的列表控制器中,我们现在要将任何通用逻辑从我们的列表路由方法中抽象出来,放到一个名为get_listing的新辅助方法中。在这个辅助方法中,我们将Listing模型嵌套在 Laravel 的Collection中,Collection是 Eloquent 模型的类似数组的包装器,提供了一堆方便的方法,我们很快就会用到。get_listing将包括来自add_image_urls辅助方法的逻辑,现在可以安全地删除它。

当我们调用view方法时,我们还需要反映对我们的模板变量的更改。

app/Http/Controllers/ListingController.php

private function get_listing($listing)
{
  $model = $listing->toArray();
  for($i = 1; $i <=4; $i++) {
    $model['image_' . $i] = asset( 'images/' . $listing->id . '/Image_' . $i . '.jpg' );
  }
  return collect(['listing' => $model]);
}

public function get_listing_api(Listing $listing)
{
  $data = $this->get_listing($listing);
  return response()->json($data);
}

public function get_listing_web(Listing $listing)
{
  $data = $this->get_listing($listing);
  return view('app', ['data' => $data]);
}

public function get_home_web() 
{
 return view('app', ['data' => []]);
} 

最后,我们需要更新我们的ListingPage组件,以反映我们正在注入的服务器数据的新名称和结构。

resources/assets/components/ListingPage.vue

<script> let serverData = JSON.parse(window.vuebnb_server_data);
  let model = populateAmenitiesAndPrices(serverData.listing);

  ... </script>

主页初始状态

使用 Eloquent ORM,使用Listing::all方法检索所有我们的列表条目是微不足道的。这个方法在一个Collection对象中返回多个Model实例。

请注意,我们不需要模型上的所有字段,例如amenitiesabout等在填充主页的列表摘要时没有用到。为了确保我们的数据尽可能精简,我们可以将一个字段数组传递给Listing::all方法,告诉数据库只包括那些明确提到的字段。

app/Http/Controllers/ListingController.php

public function get_home_web() 
{
  $collection = Listing::all([
    'id', 'address', 'title', 'price_per_night'
  ]);
  $data = collect(['listings' => $collection->toArray()]);
  return view('app', ['data' => $data]);
}

/*
  [
    "listings" => [
      0 => [
        "id" => 1,
        "address" => "...",
        "title" => "...",
        "price_per_night" => "..." ]
      1 => [ ... ]
      ... 29 => [ ... ]
    ]
  ]
*/

添加缩略图

每个模拟列表都有第一张图片的缩略版本,可以用于列表摘要。缩略图比我们用于列表页面标题的图片要小得多,非常适合在主页上显示列表摘要。缩略图的 URL 是public/images/{x}/Image_1_thumb.jpg,其中{x}是列表的 ID。

Collection对象有一个辅助方法transform,我们可以使用它来为每个列表添加缩略图图片 URL。transform接受一个回调闭包函数,每个项目调用一次,允许您修改该项目并将其返回到集合中,而不费吹灰之力。

app/Http/Controllers/ListingController.php

public function get_home_web() 
{
  $collection = Listing::all([
    'id', 'address', 'title', 'price_per_night'
  ]);
  $collection->transform(function($listing) {
    $listing->thumb = asset(
      'images/' . $listing->id . '/Image_1_thumb.jpg'
    );
    return $listing;
  });
  $data = collect(['listings' => $collection->toArray()]);
  return view('app', ['data' => $data]);
}

/*
  [
    "listings" => [
      0 => [
        "id" => 1,
        "address" => "...",
        "title" => "...",
        "price_per_night" => "...",
        "thumb" => "..." ]
      1 => [ ... ]
      ... 29 => [ ... ]
    ]
  ]
*/

在客户端接收

现在初始状态已经准备好了,让我们将其添加到我们的HomePage组件中。但在我们使用它之前,还有一个额外的方面需要考虑:列表摘要是按国家分组的。再次看一下图 7.7,看看这些组是如何显示的。

在我们解析了注入的数据之后,让我们修改对象,使得列表按国家分组。我们可以很容易地创建一个函数来做到这一点,因为每个列表对象都有一个address属性,其中国家总是明确命名的,例如,台湾台北市万华区汉中街 51 号 108

为了节省您编写这个函数的时间,我在helpers模块中提供了一个名为groupByCountry的函数,可以在组件配置的顶部导入。

resources/assets/components/HomePage.vue

...

<script>
  import { groupByCountry } from '../js/helpers';

  let serverData = JSON.parse(window.vuebnb_server_data);
  let listing_groups = groupByCountry(serverData.listings);

  export default {
    data() {
      return { listing_groups }
    }
  }
</script>

我们现在将通过 Vue Devtools 看到HomePage已经成功加载了按国家分组的列表摘要,准备显示:

图 7.8. Vue Devtools 显示了 HomePage 组件的状态

ListingSummary 组件

现在HomePage组件有了可用的数据,我们可以开始显示它。

首先,清空组件的现有内容,并将其替换为一个div。这个div将使用v-for指令来遍历我们的每一个列表组。由于listing_groups是一个具有键/值对的对象,我们将给我们的v-for两个别名:groupcountry,分别是每个对象项的值和键。

我们将在一个标题中插入countrygroup将在下一节中使用。

resources/assets/components/HomePage.vue

<template>
  <div>
    <div v-for="(group, country) in listing_groups">
      <h1>Places in {{ country }}</h1>
      <div> Each listing will go here </div>
    </div>
  </div>
</template>
<script>...</script> 

现在主页将会是这个样子:

图 7.9. 在 HomePage 组件中迭代列表摘要组

由于每个列表摘要都会有一定的复杂性,我们将创建一个单独的组件ListingSummary来显示它们:

$ touch resources/assets/components/ListingSummary.vue

让我们在HomePage模板中声明ListingSummary。我们将再次使用v-for指令来遍历group,一个数组,为每个成员创建一个ListingSummary的新实例。每个成员的数据将绑定到一个单独的 proplisting

resources/assets/components/HomePage.vue

<template>
  <div>
    <div v-for="(group, country) in listing_groups">
      <h1>Places in {{ country }}</h1>
      <div class="listing-summaries">
        <listing-summary v-for="listing in group" 
          :key="listing.id" 
          :listing="listing"
        ></listing-summary>
      </div>
    </div>
  </div>
</template>
<script> import { groupByCountry } from '../js/helpers';
 import ListingSummary from './ListingSummary.vue'; let serverData = JSON.parse(window.vuebnb_server_data);
  let listing_groups = groupByCountry(serverData.listings);

  export default {
    data() {
      return { listing_groups }
    }, components: { ListingSummary }
  } </script>

让我们为ListingSummary组件创建一些简单的内容,只是为了测试我们的方法。

resources/assets/components/ListingSummary.vue

<template>
  <div class="listing-summary"> {{ listing.address }} </div>
</template>
<script> export default { props: [ 'listing' ],
  } </script>

刷新我们的页面,现在我们将看到我们的列表摘要的原型:

图 7.10. ListingSummary 组件的原型

由于这种方法有效,现在让我们完成ListingSummary组件的结构。为了显示缩略图,我们将其绑定为固定宽度/高度的背景图片div。我们还需要一些 CSS 规则来使其显示得很好。

resources/assets/components/ListingSummary.vue

<template>
  <div class="listing-summary">
    <div class="wrapper">
      <div class="thumbnail" :style="backgroundImageStyle"></div>
      <div class="info title">
        <span>{{ listing.price_per_night }}</span>
        <span>{{ listing.title }}</span>
      </div>
      <div class="info address">{{ listing.address }}</div>
    </div>
  </div>
</template>
<script> export default { props: [ 'listing' ], computed: {
      backgroundImageStyle() {
        return {
          'background-image': `url("${this.listing.thumb}")` }
      }
    }
  } </script>
<style> .listing-summary {
    flex: 0 0 auto;
  }

  .listing-summary a {
    text-decoration: none;
  }

  .listing-summary .wrapper {
    max-width: 350px;
    display: block;
  }

  .listing-summary .thumbnail {
    width: 350px;
    height: 250px;
    background-size: cover;
    background-position: center;
  }

  .listing-summary .info {
    color: #484848;
    word-wrap: break-word;
    letter-spacing: 0.2px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .listing-summary .info.title {
    padding-top: 5px;
    font-weight: 700;
    font-size: 16px;
    line-height: 24px;
  }

  .listing-summary .info.address {
    font-size: 14px;
    line-height: 18px;
  } </style>

添加了该代码后,您的列表摘要将如下所示:

图 7.11。显示完整的列表摘要

我们给每个列表摘要一个固定的宽度/高度,以便我们可以以整齐的网格显示它们。目前,它们显示在一个高列中,所以让我们向HomePage组件添加一些 CSS flex 规则,将摘要放入行中。

我们将在包装摘要的元素中添加一个名为listing-summary-group的类。我们还将在根div中添加一个名为home-container的类,以限制页面的宽度并使内容居中。

resources/assets/components/HomePage.vue

<template>
  <div class="home-container">
    <div v-for="(group, country) in listing_groups" 
      class="listing-summary-group"
    > ... </div>
  </div>
</template>
<script>...</script>
<style> .home-container {
    margin: 0 auto;
    padding: 0 25px;
  }

  @media (min-width: 1131px) {
    .home-container {
      width: 1080px;
    }
  }

  .listing-summary-group {
    padding-bottom: 20px;
  }

  .listing-summaries {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    overflow: hidden;
  }
  .listing-summaries > .listing-summary {
    margin-right: 15px;
  } .listing-summaries > .listing-summary:last-child {
 margin-right: 0;
  } </style>

最后,我们需要添加一个规则,防止列表强制文档边缘超出视口。将这个规则添加到主 CSS 文件中。

resources/assets/css/style.css

html, body {
  overflow-x: hidden;
}

有了这个,我们得到了一个漂亮的主页:

图 7.12。列表摘要按行显示

您会注意到在整个页面宽度上,我们只能看到每个国家组的三个列表。其他七个被 CSS 的overflow: hidden规则隐藏了。很快,我们将为每个组添加图像滑块功能,以允许用户浏览所有列表。

应用内导航

如果我们使用浏览器的地址栏导航到主页,http://vuebnb.test/,它可以工作,因为 Laravel 现在在这个路由上提供页面。但是,如果我们从列表页导航到主页,就不再有页面内容了:

图 7.13。从列表页导航到空的主页

我们目前没有从主页到列表页的任何链接,但如果有的话,我们会遇到类似的问题。

原因是我们的页面组件目前从我们注入到文档头部的数据中获取它们的初始状态。如果我们使用 Vue Router 导航到不会引发页面刷新的不同页面,下一个页面组件将合并错误的初始状态。

我们需要改进我们的架构,以便在导航到页面时检查注入到头部的模型是否与当前页面匹配。为了实现这一点,我们将在模型中添加一个path属性,并检查它是否与活动 URL 匹配。如果不匹配,我们将使用 AJAX 从网络服务获取正确的数据:

图 7.14。页面如何决定需要什么数据如果您对阅读更多关于这种设计模式感兴趣,请查看文章Avoid This Common Anti-Pattern In Full-Stack Vue/Laravel Apps,网址为vuejsdevelopers.com/2017/08/06/vue-js-laravel-full-stack-ajax/

向模型添加路径

让我们转到列表控制器,并向注入到我们视图头部的数据添加一个path属性。为此,我们将添加一个名为add_meta_data的辅助函数,它将在后面的章节中添加路径,以及一些其他元属性。

请注意,当前路由的路径可以通过Request对象确定。这个对象可以被声明为任何route-handling函数的最后一个参数,并且由服务容器在每个请求中提供。

app/Http/Controllers/ListingController.php

...

private function add_meta_data($collection, $request)
{
  return $collection->merge([
    'path' => $request->getPathInfo()
  ]);
}

public function get_listing_web(Listing $listing, Request $request)
{
  $data = $this->get_listing($listing);
  $data = $this->add_meta_data($data, $request);
  return view('app', ['data' => $data]);
}

public function get_home_web(Request $request)
{
  $collection = Listing::all([
    'id', 'address', 'title', 'price_per_night'
  ]);
  $collection->transform(function($listing) {
    $listing->thumb = asset(
      'images/' . $listing->id . '/Image_1_thumb.jpg'
    );
    return $listing;
  });
  $data = collect(['listings' => $collection->toArray()]);
  $data = $this->add_meta_data($data, $request);
  return view('app', ['data' => $data]);
}

/*
  [
    "listings" => [ ... ],
    "path" => "/"
  ]
*/

路由导航守卫

类似于生命周期钩子,导航守卫允许您在其生命周期的特定时刻拦截 Vue Router 导航。这些守卫可以应用于特定组件、特定路由或所有路由。

例如,afterEach是在任何路由被导航离开后调用的导航守卫。您可以使用此钩子来存储分析信息,例如:

router.afterEach((to, from) => {
  storeAnalytics(userId, from.path);
})

我们可以使用beforeRouteEnter导航守卫从我们的网络服务中获取数据,如果头部的数据不合适。考虑以下伪代码,说明我们可能如何实现这一点:

beforeRouteEnter(to, from, next) {
  if (to !== injectedData.path) { getDataWithAjax.then(data => {
      applyData(data)
    })
  } else {
    applyData(injectedData)
  }
  next()
}

下一个

导航守卫的一个重要特性是它们会阻止导航,直到调用next函数。这允许在解析导航之前执行异步代码。

beforeRouteEnter(to, from, next) {
  new Promise(...).then(() => {
    next();  
  });
}

你可以向next函数传递false来阻止导航,或者你可以传递一个不同的路由来重定向它。如果你什么都不传,导航被认为是确认的。

beforeRouteEnter守卫是一个特殊情况。首先,在它内部,this是未定义的,因为它是在下一个页面组件被创建之前调用的。

beforeRouteEnter(to, from, next) { console.log(this); // undefined
}

然而,在beforeRouteEnter中的next函数可以接受一个回调函数作为参数,例如,next(component => { ... });其中component是页面组件实例。

这个回调函数直到路由确认并且组件实例被创建后才会被触发。由于 JavaScript 闭包的工作原理,回调函数将可以访问它被调用的周围代码的作用域。

beforeRouteEnter(to, from, next) {
  var data = { ... }
  next(component => { component.$data = data;
  });
}

HomePage 组件

让我们把beforeRouteEnter添加到HomePage组件中。首先,将从文档头部检索数据的任何逻辑移到这个钩子中。然后我们检查数据的path属性,看它是否与当前路由匹配。如果是,我们调用next并传递一个将数据应用到组件实例的回调函数。如果不是,我们需要使用 AJAX 来获取正确的数据。

resources/assets/components/HomePage.vue

export default {
  data() {
    return { listing_groups: []
    };
  }, components: { ListingSummary }, beforeRouteEnter(to, from, next) {
    let serverData = JSON.parse(window.vuebnb_server_data);
    if (to.path === serverData.path) {
      let listing_groups = groupByCountry(serverData.listings);
      next(component => component.listing_groups = listing_groups);
    } else { console.log('Need to get data with AJAX!')
      next(false);
    }
  }
}

我已经添加了listing_groups作为数据属性。之前,我们在组件实例创建时应用我们的数据。现在,我们在组件创建后应用数据。为了设置响应式数据,Vue 必须知道数据属性的名称,所以我们用空值初始化,当需要的数据可用时再更新它。

主页 API 端点

现在我们将实现 AJAX 功能。不过,在我们这样做之前,我们需要在我们的 Web 服务中添加一个主页端点。

首先添加主页 API 路由。

routes/api.php

...

Route::get('/', 'ListingController@get_home_api');

现在看看ListingController类,我们将把get_home_web中的大部分逻辑抽象成一个新函数get_listing_summaries。然后我们将在get_home_api方法中使用这个函数并返回一个 JSON 响应。

app/Http/Controllers/ListingController.php

private function get_listing_summaries()
{
  $collection = Listing::all([
    'id', 'address', 'title', 'price_per_night'
  ]);
  $collection->transform(function($listing) {
    $listing->thumb = asset(
      'images/' . $listing->id . '/Image_1_thumb.jpg'
    );
    return $listing;
  });
  return collect(['listings' => $collection->toArray()]);
}

public function get_home_web(Request $request)
{
  $data = $this->get_listing_summaries();
  $data = $this->add_meta_data($data, $request);
  return view('app', ['data' => $data]);
}

public function get_home_api()
{
  $data = $this->get_listing_summaries();
  return response()->json($data);
}

Axios

为了执行对 Web 服务的 AJAX 请求,我们将使用包含在 Laravel 默认前端代码中的 Axios HTTP 客户端。Axios 有一个非常简单的 API,允许我们向 GET URL 发出请求,如下所示:

axios.get('/my-url');

Axios 是一个基于 Promise 的库,所以为了获取响应,你可以简单地链接一个then回调:

axios.get('/my-url').then(response => { console.log(response.data); // Hello from my-url
});

由于 Axios NPM 包已经安装,我们可以导入HomePage组件。然后我们可以使用它来执行对主页 API 端点/api/的请求。在then回调中,我们将返回的数据应用到组件实例,就像我们在内联模型中所做的那样。

resources/assets/components/HomePage.vue

...
import axios from 'axios';

export default {
  data() { ... },
  components: { ... },
  beforeRouteEnter (to, from, next) {
    let serverData = JSON.parse(window.vuebnb_server_data);
    if (to.path === serverData.path) {
      let listing_groups = groupByCountry(serverData.listings);
      next(component => component.listing_groups = listing_groups);
    } else {
      axios.get(`/api/`).then(({ data }) => {
        let listing_groups = groupByCountry(data.listings);
        next(component => component.listing_groups = listing_groups);
      });
    }
  }
}

有了这个,我们现在可以以两种方式导航到主页,一种是通过地址栏,另一种是从列表页面的链接导航。无论哪种方式,我们都能得到正确的数据!

混合

如果你有任何在组件之间共同的功能,你可以把它放在一个mixin中,以避免重写相同的功能。

Vue mixin是一个与组件配置对象形式相同的对象。要在组件中使用它,声明为一个数组,并将其分配给配置属性mixin。当这个组件被实例化时,mixin的任何配置选项将与你在组件上声明的选项合并:

var mixin = { methods: {
    commonMethod() { console.log('common method');
    }
  }
}; Vue.component('a', { mixins: [ mixin ]
}); Vue.component('b', { mixins: [ mixin ] methods: {
    otherMethod() { ... }
  }
});

你可能想知道,如果组件配置中有一个与mixin冲突的方法或其他属性会发生什么。答案是mixins有一个合并策略来确定任何冲突的优先级。通常,组件指定的配置将优先。合并策略的详细信息在 Vue.js 文档中解释vuejs.org

将解决方案移动到 mixin

让我们将解决方案概括为获取首页正确数据的解决方案,以便我们也可以在列表页面上使用它。为此,我们将 Axios 和beforeRouteEnter钩子从HomePage组件移动到一个 mixin 中,然后可以将其添加到两个页面组件中:

$ touch resources/assets/js/route-mixin.js

同时,让我们通过删除next函数调用的重复来改进代码。为此,我们将创建一个新方法getData,它将负责找出页面的正确数据来源,并获取它。请注意,这个方法将是异步的,因为它可能需要等待 AJAX 解析,所以它将返回一个 Promise 而不是实际值。这个 Promise 然后在导航守卫中解析。

resources/assets/js/route-mixin.js

import axios from 'axios';

function getData(to) {
  return new Promise((resolve) => {
    let serverData = JSON.parse(window.vuebnb_server_data);
    if (!serverData.path || to.path !== serverData.path) { axios.get(`/api${to.path}`).then(({ data }) => {
        resolve(data);
      });
    } else {
      resolve(serverData);
    }
  });
}

export default { beforeRouteEnter: (to, from, next) => {
    getData(to).then((data) => {
      next(component => component.assignData(data));
    });
  }
};

我们不需要为 Promise 添加 polyfill,因为Axios库中已经提供了。

assignData

您会注意到在next回调中,我们调用了主题组件上的一个方法assignData,并将数据对象作为参数传递。我们需要在使用这个mixin的任何组件中实现assignData方法。我们这样做是为了让组件在应用到组件实例之前,如果需要的话,可以处理数据。例如,ListingPage组件必须通过populateAmenitiesAndPrices辅助函数处理数据。

resources/assets/components/ListingPage.vue

...

import routeMixin from '../js/route-mixin';

export default { mixins: [ routeMixin ],
  data() {
    return { title: null, about: null, address: null, amenities: [], prices: [], images: []
    }
  }, components: { ... }, methods: {
    assignData({ listing }) { Object.assign(this.$data, populateAmenitiesAndPrices(listing));
    },
    openModal() {
      this.$refs.imagemodal.modalOpen = true;
    }
  }
}

我们还需要将assignData添加到HomePage组件中。

resources/assets/components/HomePage.vue

<script>
  import { groupByCountry } from '../js/helpers';
  import ListingSummary from './ListingSummary.vue';

  import axios from 'axios';
  import routeMixin from '../js/route-mixin';

  export default { mixins: [ routeMixin ],
    data() { ... }, methods: {
      assignData({ listings }) {
        this.listing_groups = groupByCountry(listings);
      },
    }, components: { ... }
  }
</script>

链接到列表页面

上面的方法应该有效,但我们无法测试,因为尚未有任何应用内链接到列表页面!

我们的每个ListingSummary实例代表一个单独的列表,并且应该是指向该列表页面的可点击链接。让我们使用RouterLink组件来实现这一点。请注意,我们绑定到to属性的对象包括路由的名称以及一个params对象,其中包括路由的动态段的值,即列表 ID。

resources/assets/components/ListingSummary.vue

<div class="listing-summary">
  <router-link :to="{ name: 'listing', params: { listing: listing.id } }">
    <div class="wrapper">
      <div class="thumbnail" :style="backgroundImageStyle"></div>
      <div class="info title">
        <span>{{ listing.price_per_night }}</span>
        <span>{{ listing.title }}</span>
      </div>
      <div class="info address">{{ listing.address }}</div>
    </div>
  </router-link>
</div>

完成后,列表摘要现在将是链接。从一个链接到列表页面,我们看到这个:

图 7.15. 导航到列表页面后成功的 AJAX 调用

我们可以在图 7.15中看到,对列表 API 的 AJAX 调用成功返回了我们想要的数据。如果我们还查看 Vue Devtools 选项卡,以及 Dev Tools 控制台,我们可以看到组件实例中的正确数据。问题是,现在我们对头部图片有一个未处理的 404 错误:

图 7.16. Dev Tools 控制台显示错误

原因是组件的第一次渲染发生在next钩子中的回调之前。这意味着组件数据的初始化值在第一次渲染中被使用。

resources/assets/components/ListingPage.vue

data() {
  return { title: null, about: null, address: null, amenities: [], prices: [], images: []
  }
},

HeaderImage声明中,我们像这样绑定第一个图像::image-url="images[0]"。由于数组最初是空的,这将是一个未定义的值,并导致未处理的错误。

解释很复杂,但修复很简单:只需在header-image中添加v-if,确保在有效数据可用之前不会渲染。

resources/assets/components/ListingPage.vue

<header-image v-if="images[0]" 
  :image-url="images[0]" 
  @header-clicked="openModal"
></header-image>

滚动行为

网站导航的另一个方面是浏览器自动管理的滚动行为。例如,如果您滚动到页面底部,然后导航到新页面,滚动位置将被重置。但是,如果您返回到上一个页面,浏览器会记住滚动位置,并将您带回底部。

当我们使用 Vue Router 劫持导航时,浏览器无法做到这一点。因此,当您滚动到 Vuebnb 主页的底部并点击古巴的某个列表时,当加载列表页面组件时,滚动位置不会改变。这对用户来说感觉非常不自然,用户期望被带到新页面的顶部:

图 7.17。使用 Vue Router 导航后的滚动位置问题

Vue Router 有一个scrollbehavior方法,允许您通过简单地定义水平和垂直滚动条的xy位置来调整页面在更改路由时滚动到的位置。为了保持简单,但仍然保持用户体验自然,让我们使得在加载新页面时总是在页面顶部。

resources/assets/js/router.js

export default new VueRouter({ mode: 'history', routes: [ ... ], scrollBehavior (to, from, savedPosition) {
    return { x: 0, y: 0 }
  }
});

添加页脚

为了改进 Vuebnb 的设计,让我们在每个页面的底部添加一个页脚。我们将把它做成一个可重用的组件,所以让我们从创建它开始:

$ touch resources/assets/components/CustomFooter.vue

这是标记。目前,它只是一个无状态的组件。

resources/assets/js/CustomFooter.vue

<template>
  <div id="footer">
    <div class="hr"></div>
    <div class="container">
      <p>
        <img class="icon" src="/images/logo_grey.png"> <span>
          <strong>Vuebnb</strong>. A full-stack Vue.js and Laravel demo app
 </span> </p>
    </div>
  </div>
</template>
<style> #footer {
    margin-bottom: 3em;
  }

  #footer .icon {
    height: 23px;
    display: inline-block;
    margin-bottom: -6px;
  }

  .hr {
    border-bottom: 1px solid #dbdbdb;
    margin: 3em 0;
  }

  #footer p {
    font-size</span>: 15px;
    color: #767676 !important;
 display: flex; }
 #footer p img {
 padding-right: 6px;
 } </style>

让我们将页脚添加到App组件中,在输出页面的RouterView下方。

resources/assets/js/App.vue

<template>
  <div>
    <div id="toolbar">...</div>
    <router-view></router-view>
    <custom-footer></custom-footer>
  </div>
</template>
<script> import CustomFooter from './CustomFooter.vue';

  export default { components: { CustomFooter }
  } </script>
<style>...</style>

这是它在列表页面上的样子:

图 7.18。列表页面上的自定义页脚

现在它在主页上的样子。它看起来不太好,因为文本没有左对齐,这不是您期望的。这是因为在这个页面上使用的容器约束与我们添加到页脚的.container类不同:

图 7.19。主页上的自定义页脚

事实上,.container是专门为列表页面设计的,而.home-container是为主页设计的。为了解决这个问题,让事情变得不那么混乱,让我们首先将.container类重命名为.listing-container。您还需要更新ListingPage组件,以确保它使用这个新的类名。

其次,让我们也将.home-container移到主 CSS 文件中,因为我们将全局使用它。

resources/assets/css/style.css

.listing-container {
  margin: 0 auto;
  padding: 0 12px;
}

@media (min-width: 744px) {
  .listing-container {
    width: 696px;
  }
}

.home-container {
  margin: 0 auto;
  padding: 0 25px;
}

@media (min-width: 1131px) {
  .home-container {
    width: 1080px;
  }
}

现在我们有.home-container.listing-container作为我们custom-footer组件的两个可能的容器。让我们根据路由动态选择类,以便页脚始终正确对齐。

路由对象

路由对象代表当前活动路由的状态,并可以在根实例或组件实例中访问,如this.$route。该对象包含当前 URL 的解析信息以及 URL 匹配的路由记录:

created() { console.log(this.$route.fullPath); // /listing/1 console.log(this.$route.params); // { listing: "1" }
}

动态选择容器类

为了在custom-footer中选择正确的容器类,我们可以从路由对象中获取当前路由的名称,并在模板文字中使用它。

resources/assets/components/CustomFooter.vue

<template>
  <div id="footer">
    <div class="hr"></div>
    <div :class="containerClass">
      <p>...</p>
    </div>
  </div>
</template>
<script>
  export default { computed: {
      containerClass() {
        // this.$route.name is either 'home' or 'listing'
        return `${this.$route.name}-container`;
      }
    }
  }
</script>
<style>...</style>  

现在,当在主页上显示时,页脚将使用.home-container

图 7.20。主页上的自定义页脚与正确的容器类

列表摘要图像滑块

在我们的主页上,我们需要让用户能够看到每个国家的可能 10 个列表中不止三个。为此,我们将每个列表摘要组转换为图像滑块。

让我们创建一个新的组件来容纳每个列表摘要组。然后我们将箭头添加到该组件的两侧,让用户可以轻松地浏览其列表:

$ touch resources/assets/components/ListingSummaryGroup.vue

现在,我们将从HomePage中将显示列表摘要的标记和逻辑抽象到这个新组件中。每个组需要知道国家的名称和包含的列表,因此我们将这些数据添加为 props。

resources/assets/components/ListingSummaryGroup.vue

<template>
  <div class="listing-summary-group">
    <h1>Places in {{ country }}</h1>
    <div class="listing-summaries">
      <listing-summary v-for="listing in listings"
        :key="listing.id"
        :listing="listing"
      ></listing-summary>
    </div>
  </div>
</template>
<script> import ListingSummary from './ListingSummary.vue';

  export default { props: [ 'country', 'listings' ], components: { ListingSummary }
  } </script>
<style> .listing-summary-group {
    padding-bottom: 20px;
  }

  .listing-summaries {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    overflow: hidden;
  }
  .listing-summaries > .listing-summary {
    margin-right: 15px;
  }

  .listing-summaries > .listing-summary:last-child {
    margin-right: 0;
  } </style>

回到HomePage,我们将使用v-for声明ListingSummaryGroup,对每个国家组进行迭代。

resources/assets/components/HomePage.vue:

<template>
  <div class="home-container">
    <listing-summary-group v-for="(group, country) in listing_groups"
      :key="country"
      :listings="group"
      :country="country"
      class="listing-summary-group"
    ></listing-summary-group>
  </div>
</template>
<script> import routeMixin from '../js/route-mixin';
  import ListingSummaryGroup from './ListingSummaryGroup.vue';
  import { groupByCountry } from '../js/helpers';

  export default { mixins: [ routeMixin ],
    data() {
      return { listing_groups: []
      };
    }, methods: {
      assignData({ listings }) {
        this.listing_groups = groupByCountry(listings);
      }
    }, components: { ListingSummaryGroup }
  } </script>

大多数开发人员会将术语图像轮播图像滑块互换使用。在本书中,我做了一个细微的区分,轮播包含一个完全被另一个替换的单个图像,而滑块则是移动图像的位置,同时可见几个图像。

添加滑块

现在我们将为ListingSummaryGroup添加滑块功能。为此,我们将重用我们在第六章中制作的CarouselControl组件,使用 Vue.js 组件组合小部件。我们希望在组的两侧显示一个,所以让我们将它们放入模板中,记得声明dir属性。我们还将添加一些结构标记和 CSS 来显示控件。

resources/assets/components/ListingSummaryGroup.vue:

<template>
  <div class="listing-summary-group">
    <h1>Places in {{ country }}</h1>
    <div class="listing-carousel">
      <div class="controls">
        <carousel-control dir="left"></carousel-control>
        <carousel-control dir="right"></carousel-control>
      </div>
      <div class="listing-summaries-wrapper">
        <div class="listing-summaries">
          <listing-summary v-for="listing in listings"
            :listing="listing"
            :key="listing.id"
          ></listing-summary>
        </div>
      </div>
    </div>
  </div>
</template>
<script> import ListingSummary from './ListingSummary.vue';
  import CarouselControl from './CarouselControl.vue';

  export default { props: [ 'country', 'listings' ], components: { ListingSummary, CarouselControl }
  } </script>
<style> ... .listing-carousel {
  position: relative;
}

.listing-carousel .controls {
  display: flex;
  justify-content: space-between;
  position: absolute;
  top: calc(50% - 45px);
  left: -45px;
  width: calc(100% + 90px);
}

.listing-carousel .controls .carousel-control{
  color: #c5c5c5;
  font-size: 1.5rem;
  cursor: pointer;
}

.listing-summaries-wrapper {
  overflow: hidden;
} </style>

添加了这段代码后,您的主页将如下所示:

图 7.21. 列表摘要组的轮播控件

平移

为了在点击轮播控件时移动我们的列表摘要,我们将使用一个名为translate的 CSS 变换。这将使受影响的元素从当前位置移动指定像素的距离。

每个列表摘要的总宽度为 365px(350px 固定宽度加上 15px 边距)。这意味着如果我们将我们的组向左移动 365px,它将产生将所有图像位置向左移动一个的效果。您可以在这里看到我已经添加了平移作为内联样式以测试它是否有效。请注意,我们以方向进行平移以使组向左移动:

图 7.22. 使用平移向左移动的列表组

通过将内联样式绑定到具有listing-summary类的元素,我们可以通过 JavaScript 控制平移。让我们通过一个计算属性来做到这一点,这样我们就可以动态计算平移量。

resources/assets/components/ListingSummaryGroup.vue:

<template>
  <div class="listing-summary-group">
    <h1>Places in {{ country }}</h1>
    <div class="listing-carousel">
      <div class="controls">...</div>
      <div class="listing-summaries" :style="style">
        <listing-summary...>...</listing-summary>
      </div>
    </div>
  </div>
</template>
<script>
  export default { props: [ 'country', 'listings' ], computed: {
      style() {
        return { transform: `translateX(-365px)` }
      }
    }, components: { ... }
  }
</script>

现在所有的摘要组都将被移动:

图 7.23. 通过 JavaScript 控制的平移后的列表组

图 7.23中显而易见的问题是,我们一次只能看到三张图像,并且它们溢出到容器的其他部分。

为了解决这个问题,我们将把 CSS 规则overflow: hiddenlisting-summaries移到listing-summaries-wrapper

resources/assets/components/ListingSummaryGroup.vue:

... .listing-summaries-wrapper {
  overflow: hidden;
}

.listing-summaries {
  display: flex;
  flex-direction: row;
  justify-content: space-between;  
} ...

轮播控件

现在我们需要轮播控件来改变平移的值。为此,让我们在ListingSummaryGroup中添加一个数据属性offset。这将跟踪我们移动了多少图像,即它将从零开始,最多到七(不是 10,因为我们不希望移动得太远,以至于所有图像都超出屏幕)。

我们还将添加一个名为change的方法,它将作为自定义事件的事件处理函数,该事件由轮播控件组件发出。该方法接受一个参数val,根据触发了左侧还是右侧轮播控件,它将是-11

change将步进offset的值,然后乘以每个列表的宽度(365px)来计算平移。

resources/assets/components/ListingSummaryGroup.vue:

...

const rowSize = 3;
const listingSummaryWidth = 365;

export default { props: [ 'country', 'listings' ],
  data() {
    return { offset: 0   
    }
  }, methods: {
    change(val) {
      let newVal = this.offset + parseInt(val);
      if (newVal >= 0 && newVal <= this.listings.length - rowSize) {
        this.offset = newVal;
      }
    }
  }, computed: {
    style() {
      return { transform: `translateX(${this.offset * -listingSummaryWidth}px)` 
      }
    }
  }, components: { ... }
}

最后,我们必须在模板中使用v-on指令来注册对CarouselControl组件的change-image事件的监听器。

resources/assets/components/ListingSummaryGroup.vue:

<div class="controls">
  <carousel-control dir="left" @change-image="change"></carousel-control>
  <carousel-control dir="right" @change-image="change"></carousel-control>
</div>

完成后,每个列表组都有一个工作中的图像滑块!

最后的修饰

还有两个小功能要添加到这些图像滑块中,以给 Vuebnb 用户最佳体验。首先,让我们添加 CSS 过渡,以在半秒钟的时间内动画平移变化,并产生一个漂亮的滑动效果。

resources/assets/components/ListingSummaryGroup.vue:

.listing-summaries {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  transition: transform 0.5s;
}

遗憾的是你无法在书中看到这些效果,所以你得自己尝试一下!

最后,与我们的图像轮播不同,这些滑块不是连续的;它们有一个最小值和最大值。如果达到了最小值或最大值,让我们隐藏相应的箭头。例如,当滑块加载时,左箭头应该被隐藏,因为用户不能再减小偏移量到零以下。

为了做到这一点,我们将使用样式绑定动态添加visibility: hidden的 CSS 规则。

resources/assets/components/ListingSummaryGroup.vue

<div class="controls">
  <carousel-control dir="left" 
    @change-image="change" 
    :style="leftArrowStyle"
  ></carousel-control>
  <carousel-control dir="right" 
    @change-image="change" 
    :style="rightArrowStyle"
  ></carousel-control>
</div>

以及计算属性。

resources/assets/components/ListingSummaryGroup.vue

computed: {
  ...
  leftArrowStyle() {
    return { visibility: (this.offset > 0 ? 'visible' : 'hidden') }
  },
  rightArrowStyle() {
    return { visibility: (
        this.offset < (this.listings.length - rowSize) 
        ? 'visible' : 'hidden'
      ) 
    }
  }
}

完成这些后,我们可以看到左箭头在页面加载时隐藏,正如预期的那样:

图 7.24。页面加载时隐藏的左箭头

摘要

在本章中,我们学习了路由库的工作原理以及它们为单页应用程序的重要性。然后我们熟悉了 Vue Router 的关键特性,包括路由对象、导航守卫以及RouterLinkRouterView特殊组件。

将这些知识付诸实践,我们安装了 Vue Router 并配置了它以在我们的应用程序中使用。然后我们为 Vuebnb 建立了一个主页,其中包括一个在图像滑块内组织的列表摘要画廊。

最后,我们实现了一个架构,正确匹配页面与可用的本地数据或通过 AJAX 从 Web 服务检索到的新数据。

现在我们的应用程序中有大量的组件,其中许多组件之间进行数据通信,是时候调查另一个关键的 Vue.js 工具了:Vuex。Vuex 是一个基于 Flux 的库,提供了一种更优越的管理应用程序状态的方式。

第八章:使用 Vuex 管理应用程序状态

在上一章中,您学习了如何使用 Vue Router 将虚拟页面添加到 Vue.js 单页面应用程序中。现在,我们将在 Vuebnb 中添加跨页面共享数据的组件,因此不能依赖于瞬态本地状态。为此,我们将利用 Vuex,这是一个受 Flux 启发的 Vue.js 库,提供了一种强大的管理全局应用程序状态的方法。

本章涵盖的主题:

  • Flux 应用程序架构简介以及它在构建用户界面时的用处

  • Vuex 的概述及其关键特性,包括状态和突变

  • 如何安装 Vuex 并设置可以被 Vue.js 组件访问的全局存储

  • Vuex 如何通过突变日志和时间旅行调试实现更好的调试

  • 为 Vuebnb 列表创建保存功能和保存列表页面

  • 将页面状态移入 Vuex 以减少从服务器检索不必要数据

Flux 应用程序架构

想象一下,您开发了一个多用户聊天应用程序。界面上有用户列表、私人聊天窗口、带有聊天记录的收件箱和通知栏,用于通知用户有未读消息。

数百万用户每天通过您的应用程序进行聊天。但是,有关一个令人讨厌的问题的投诉:应用程序的通知栏偶尔会发出虚假通知;也就是说,用户将收到新的未读消息的通知,但当他们检查时,发现只是他们已经看过的消息。

我描述的是 Facebook 开发人员几年前在他们的聊天系统中遇到的一个真实场景。解决这个问题的过程激发了他们的开发人员创建了一个他们称之为Flux的应用程序架构。Flux 是 Vuex、Redux 和其他类似库的基础。

Facebook 的开发人员为这个僵尸通知问题苦苦挣扎了一段时间。他们最终意识到,它的持久性不仅仅是一个简单的错误;它指向了应用程序架构中的一个根本缺陷。

这个缺陷在抽象中最容易理解:当应用程序中有多个共享数据的组件时,它们之间的相互连接的复杂性将增加到一个程度,使得数据的状态不再可预测或可理解。当像上面描述的错误不可避免地出现时,应用程序数据的复杂性使得它们几乎不可能解决:

图 8.1。组件之间通信的复杂性随着每个额外组件的增加而增加

Flux 不是一个库。你不能去 GitHub 上下载它。Flux 是一组指导原则,描述了一种可扩展的前端架构,足以减轻这个缺陷。它不仅适用于聊天应用程序,还适用于任何具有共享状态的复杂 UI 组件,比如 Vuebnb。

现在让我们探索 Flux 的指导原则。

原则#1-真相的唯一来源

组件可能有它们自己需要知道的本地数据。例如,用户列表组件中滚动条的位置可能对其他组件没有兴趣:

Vue.component('user-list', {
  data() { scrollPos: ...
  }
});

但是,任何要在组件之间共享的数据,例如应用程序数据,都需要保存在一个单独的位置,与使用它的组件分开。这个位置被称为存储。组件必须从这个位置读取应用程序数据,而不是保留自己的副本,以防冲突或分歧:

图 8.2。集中式数据简化了应用程序状态

原则#2-数据是只读的

组件可以自由地从存储中读取数据。但是它们不能直接改变存储中的数据,至少不能直接改变。

相反,它们必须通知存储它们改变数据的意图,存储将负责通过一组定义的函数(称为mutator 方法)进行这些更改。

为什么要这样做?如果我们集中数据修改逻辑,那么如果状态存在不一致,我们就不必远程查找。我们正在最小化某些随机组件(可能在第三方模块中)以意想不到的方式改变数据的可能性:

图 8.3。状态是只读的。使用改变器方法来写入存储

原则#3 - 变异是同步的

在实现上述两个原则的应用程序中,调试状态不一致要容易得多。您可以记录提交并观察状态如何响应更改(这在 Vue Devtools 中自动发生,我们将看到)。

但是,如果我们的变异被异步应用,这种能力将受到破坏。我们会知道我们提交的顺序,但我们不会知道我们的组件提交它们的顺序和时间。同步变异确保状态不依赖于不可预测事件的顺序和时间。

Vuex

Vuex(通常发音为veweks)是 Flux 架构的官方 Vue.js 实现。通过强制执行先前描述的原则,即使在数据被共享到许多组件时,Vuex 也可以保持应用程序数据处于透明和可预测的状态。

Vuex 包括具有状态和变异方法的存储,并且将对从存储中读取数据的任何组件进行反应性更新。它还允许方便的开发功能,如热模块重新加载(更新运行中应用程序中的模块)和时间旅行调试(通过回溯变异来跟踪错误)。

在本章中,我们将为 Vuebnb 列表添加一个保存功能,以便用户可以跟踪他们最喜欢的列表。与迄今为止我们应用程序中的其他数据不同,保存的状态必须在页面之间持久存在;例如,当用户从一个页面切换到另一个页面时,应用程序必须记住用户已经保存了哪些项目。我们将使用 Vuex 来实现这一点:

图 8.4。保存状态对所有页面组件可用

安装 Vuex

Vuex 是一个可以从命令行安装的 NPM 包:

$ npm i --save-dev vuex

我们将把我们的 Vuex 配置放入一个新的模块文件store.js

$ touch resources/assets/js/store.js

我们需要在此文件中导入 Vuex,并像 Vue Router 一样使用Vue.use进行安装。这使 Vue 具有特殊属性,使其与 Vuex 兼容,例如允许组件通过this.$store访问存储。

resources/assets/js/store.js

import Vue from 'vue';
import Vuex from 'vuex'; Vue.use(Vuex);

export default new Vuex.Store();

然后我们将在我们的主应用程序文件中导入存储模块,并将其添加到我们的 Vue 实例中。

resources/assets/js/app.js

... import router from './router';
import store from './store';

var app = new Vue({ el: '#app', render: h => h(App), router, store });

保存功能

如前所述,我们将为 Vuebnb 列表添加一个保存功能。该功能的 UI 是一个小的可点击图标,叠加在列表摘要的缩略图像的右上角。它类似于复选框,允许用户切换特定列表的保存状态:

图 8.5。在列表摘要上显示的保存功能

保存功能还将添加为列表页面上的标题图像中的按钮:

图 8.6。在列表页面上显示的保存功能

ListingSave 组件

让我们开始创建新组件:

$ touch resources/assets/components/ListingSave.vue

此组件的模板将包括一个 Font Awesome heart图标。它还将包括一个点击处理程序,用于切换保存状态。由于此组件始终是列表或列表摘要的子级,因此它将很快使用列表 ID 作为 prop 来保存状态。不久将使用此 prop。

resources/assets/components/ListingSave.vue

<template>
  <div class="listing-save" @click.stop="toggleSaved()">
    <i class="fa fa-lg fa-heart-o"></i>
  </div>
</template>
<script> export default { props: [ 'id' ], methods: {
      toggleSaved() {
        // Implement this
      }
    }
  } </script>
<style> .listing-save {
    position: absolute;
    top: 20px;
    right: 20px;
    cursor: pointer;
  }

  .listing-save .fa-heart-o {
    color: #ffffff;
  } </style>

请注意,点击处理程序具有stop修饰符。此修饰符可防止点击事件冒泡到祖先元素,特别是可能触发页面更改的任何锚标签!

现在我们将ListingSave添加到ListingSummary组件中。记得将列表的 ID 作为 prop 传递。顺便说一句,让我们在.listing-summary类规则中添加position: relative,这样ListingSave可以绝对定位。

resources/assets/components/ListingSummary.vue:

<template>
  <div class="listing-summary">
    <router-link :to="{ name: 'listing', params: {listing: listing.id}}"> ... </router-link>
    <listing-save :id="listing.id"></listing-save>
  </div>
</template>
<script> import ListingSave from './ListingSave.vue';

  export default {
    ... components: { ListingSave }
  } </script>
<style> .listing-summary { ... position: relative;
  } ... @media (max-width: 400px) {
    .listing-summary .listing-save {
      left: 15px;
      right: auto;
    }
  } </style>

完成后,我们现在将在每个摘要中看到ListingSave心形图标的呈现:

图 8.7。ListingSummary组件中的ListingSave组件

已保存状态

ListingSave组件没有任何本地数据;相反,我们将保存任何已保存的列表在我们的 Vuex 存储中。为此,我们将在存储中创建一个名为saved的数组。每当用户切换列表的保存状态时,其 ID 将被添加或从此数组中移除。

首先,让我们在 Vuex 存储中添加一个state属性。这个对象将保存我们想要在应用程序组件中全局可用的任何数据。我们将在这个对象中添加saved属性,并将其分配为空数组。

resources/assets/js/store.js:

...

export default new Vuex.Store({ state: { saved: []
  }
});

变更方法

我们在ListingSave组件中创建了toggleSaved方法的存根。此方法应该在存储中的saved状态中添加或删除列表的 ID。组件可以通过this.$store访问存储。更具体地说,saved数组可以在this.$store.state.saved中访问。

resources/assets/components/ListingSave.vue:

methods: {
  toggleSaved() { console.log(this.$store.state.saved);
    /* Currently an empty array. []
    */
  }
}

请记住,在 Flux 架构中,状态是只读的。这意味着我们不能直接从组件中修改saved。相反,我们必须在存储中创建一个变更方法来为我们进行修改。

让我们在存储配置中创建一个mutations属性,并添加一个函数属性toggleSaved。Vuex 变更方法接收两个参数:存储状态和有效负载。此有效负载可以是您想要从组件传递给变更方法的任何内容。对于当前情况,我们将发送列表 ID。

toggleSaved的逻辑是检查列表 ID 是否已经在saved数组中,如果是,则将其移除,如果不是,则添加。

resources/assets/js/store.js:

export default new Vuex.Store({ state: { saved: []
  }, mutations: {
    toggleSaved(state, id) {
      let index = state.saved.findIndex(saved => saved === id);
      if (index === -1) { state.saved.push(id);
      } else { state.saved.splice(index, 1);
      }
    }
  }
});

现在我们需要从ListingSave提交这个变更。提交是 Flux 术语,与调用触发是同义词。提交看起来像一个自定义事件,第一个参数是变更方法的名称,第二个是有效负载。

resources/assets/components/ListingSave.vue:

export default { props: [ 'id' ], methods: {
    toggleSaved() {
      this.$store.commit('toggleSaved', this.id);
    }
  }
}

在存储架构中使用变更方法的主要目的是保持状态的一致性。但还有一个额外的好处:我们可以轻松地记录这些更改以进行调试。如果您在单击保存按钮后检查 Vue Devtools 中的 Vuex 选项卡,您将看到该变更的条目:

图 8.8:变更日志

日志中的每个条目都可以告诉您在提交更改后的状态,以及变化的具体情况。

如果您双击已记录的变更,Vue Devtools 将将应用程序的状态恢复到该更改后的状态。这被称为时间旅行调试,对于精细调试非常有用。

将图标更改以反映状态

我们的ListingSave组件的图标将以不同的方式显示,取决于列表是否已保存;如果列表已保存,它将是不透明的,如果没有保存,则是透明的。由于组件不会在本地存储其状态,因此我们需要从存储中检索状态以实现此功能。

Vuex 存储状态通常应通过计算属性检索。这确保了组件没有自己的副本,这违反了单一数据源原则,并且当状态被这个组件或其他组件改变时,组件会重新渲染。响应性也适用于 Vuex 状态!

让我们创建一个计算属性isListingSaved,它将返回一个布尔值,反映这个特定列表是否已保存。

resources/assets/components/ListingSave.vue:

export default { props: [ 'id' ], methods: {
    toggleSaved() {
      this.$store.commit('toggleSaved', this.id);
    }
  }, computed: {
    isListingSaved() {
      return this.$store.state.saved.find(saved => saved === this.id);
    }
  }
}

我们现在可以使用这个计算属性来改变图标。目前我们使用的是 Font Awesome 图标 fa-heart-o。这应该代表“未保存”状态。当列表被保存时,我们应该使用图标 fa-heart。我们可以通过动态类绑定来实现这一点。

resources/assets/components/ListingSave.vue:

<template>
  <div class="listing-save" @click.stop="toggleSaved()">
    <i :class="classes"></i>
  </div>
</template>
<script> export default { props: [ 'id' ], methods: { ... }, computed: {
      isListingSaved() { ...},
      classes() {
        let saved = this.isListingSaved;
        return {
          'fa': true,
          'fa-lg': true,
          'fa-heart': saved,
          'fa-heart-o': !saved }
      }
    }
  } </script>
<style> ... .listing-save .fa-heart {
    color: #ff5a5f;
  } </style>

现在用户可以直观地识别哪些列表已经被保存,哪些没有。由于响应式的 Vuex 数据,当从应用程序的任何地方对 saved 状态进行更改时,图标将立即更新:

图 8.9。ListingSave 图标将根据状态改变

添加到 ListingPage

我们还希望保存功能出现在列表页面上。它将放在 HeaderImage 组件中,与查看照片按钮一起,这样,就像列表摘要一样,按钮将覆盖在列表的主图像上。

resources/assets/components/HeaderImage.vue:

<template>
  <div class="header">
    <div 
      class="header-img" 
      :style="headerImageStyle" 
      @click="$emit('header-clicked')" >
      <listing-save :id="id"></listing-save>
      <button class="view-photos">View Photos</button>
    </div>
  </div>
</template>
<script> import ListingSave from './ListingSave.vue';

  export default {
    computed: { ... }, props: ['image-url', 'id'], components: { ListingSave }
  } </script>
<style>...</style>

请注意,HeaderImage 的范围中没有列表 ID,因此我们将不得不从 ListingPage 将其作为属性传递下来。id 当前也不是 ListingPage 的数据属性,但是,如果我们声明它,它将简单地工作。

这是因为 ID 已经是组件接收到的初始状态/AJAX 数据的属性,因此当组件被路由加载时,id 将自动由 Object.assign 填充。

resources/assets/components/ListingPage.vue:

<template>
  <div>
    <header-image v-if="images[0]"
      :image-url="images[0]"
      @header-clicked="openModal"
      :id="id"
    ></header-image> ... </div>
</template>
<script> ...
   export default {
    data() {
      ... id: null
    }, methods: {
      assignData({ listing }) { Object.assign(this.$data, populateAmenitiesAndPrices(listing));
      },
      ...
    },
    ...
   } </script>
<style>...</style>

完成后,保存功能现在将出现在列表页面上:

图 8.10。列表页面上的列表保存功能如果您通过列表页面保存一个列表,然后返回主页,相应的列表摘要将被保存。这是因为我们的 Vuex 状态是全局的,并且将在页面更改时持续存在(尽管不是页面刷新...但)。

将 ListingSave 设置为按钮

目前,ListingSave 功能在列表页面标题中显得太小,用户很容易忽略它。让我们把它做成一个合适的按钮,类似于标题左下角的查看照片按钮。

为此,我们将修改 ListingSave 以允许父组件发送一个名为 button 的 prop。这个布尔 prop 将指示组件是否应该包含一个包裹在图标周围的按钮元素。

这个按钮的文本将是一个计算属性 message,它将根据 isListingSaved 的值从 Save 变为 Saved。

resources/assets/components/ListingSave.vue:

<template>
  <div class="listing-save" @click.stop="toggleSaved()">
    <button v-if="button">
      <i :class="classes"></i> {{ message }} </button>
    <i v-else :class="classes"></i>
  </div>
</template>
<script> export default { props: [ 'id', 'button' ], methods: { ... }, computed: {
      isListingSaved() { ... },
      classes() { ... },
      message() {
        return this.isListingSaved ? 'Saved' : 'Save';
      }
    }
  } </script>
<style> ... .listing-save i {
    padding-right: 4px;
  }

  .listing-save button .fa-heart-o {
    color: #808080;
  } </style>

现在我们将在 HeaderImage 中将 button prop 设置为 true。即使值不是动态的,我们也使用 v-bind 来确保该值被解释为 JavaScript 值,而不是字符串。

resources/assets/components/HeaderImage.vue:

<listing-save :id="id" :button="true"></listing-save>

有了这个,ListingSave 将出现在我们的列表页面上:

图 8.11。列表保存功能显示为列表页面上的按钮

将页面状态移入存储

现在用户可以保存他们喜欢的任何列表,我们将需要一个“保存”页面,他们可以在那里查看这些保存的列表。我们将很快构建这个新页面,它将如下所示:

图 8.12:已保存页面

实现保存页面将需要对我们的应用架构进行增强。让我们快速回顾一下从服务器检索数据的方式,以了解为什么。

我们应用中的所有页面都需要服务器上的路由返回一个视图。这个视图包括相关页面组件的数据内联在文档头部。或者,如果我们通过应用内链接导航到该页面,一个 API 端点将提供相同的数据。我们在第七章中设置了这个机制,使用 Vue Router 构建多页面应用

保存的页面将需要与主页相同的数据(列表摘要数据),因为保存的页面实际上只是主页的轻微变体。因此,在主页和保存的页面之间共享数据是有意义的。换句话说,如果用户从主页加载 Vuebnb,然后导航到保存的页面,或者反之亦然,多次加载列表摘要数据将是一种浪费。

让我们将页面状态与页面组件解耦,并将其移入 Vuex。这样,它可以被任何需要它的页面使用,并避免不必要的重新加载:

图 8.13。存储中的页面状态

状态和变更方法

让我们向 Vuex 存储添加两个新的状态属性:listingslisting_summaries。这些将是分别存储我们的列表和列表摘要的数组。当页面首次加载时,或者当路由更改并调用 API 时,加载的数据将被放入这些数组中,而不是直接分配给页面组件。页面组件将从存储中检索这些数据。

我们还将添加一个变更方法addData,用于填充这些数组。它将接受一个带有两个属性routedata的有效负载对象。route是路由的名称,例如listinghome等。data是从文档头或 API 检索到的列表或列表摘要数据。

resources/assets/js/store.js

import Vue from 'vue';
import Vuex from 'vuex'; Vue.use(Vuex);

export default new Vuex.Store({ state: { saved: [], listing_summaries: [], listings: []
  }, mutations: {
    toggleSaved(state, id) { ... },
    addData(state, { route, data }) {
      if (route === 'listing') { state.listings.push(data.listing);
      } else { state.listing_summaries = data.listings;
      }
    }
  }
});

路由器

检索页面状态的逻辑在 mixin 文件route-mixin.js中。这个 mixin 为页面组件添加了一个beforeRouteEnter钩子,当组件实例可用时,将页面状态应用于组件实例。

现在我们将页面状态存储在 Vuex 中,我们将利用不同的方法。首先,我们不再需要 mixin;我们现在将这个逻辑放入router.js中。其次,我们将使用不同的导航守卫beforeEach。这不是一个组件钩子,而是一个可以应用于路由器本身的钩子,并且在每次导航之前触发。

您可以在以下代码块中看到我如何在router.js中实现这一点。请注意,在调用next()之前,我们将页面状态提交到存储中。

resources/assets/js/router.js

... 
import axios from 'axios';
import store from './store';

let router = new VueRouter({
  ...
}); router.beforeEach((to, from, next) => {
  let serverData = JSON.parse(window.vuebnb_server_data);
  if (!serverData.path || to.path !== serverData.path) { axios.get(`/api${to.path}`).then(({data}) => { store.commit('addData', {route: to.name, data});
      next();
    });
  }
  else { store.commit('addData', {route: to.name, data: serverData});
    next();
  }
});

export default router;

完成后,我们现在可以删除路由 mixin:

$ rm resources/assets/js/route-mixin.js

从 Vuex 中检索页面状态

现在我们已经将页面状态移入 Vuex,我们需要修改我们的页面组件来检索它。从ListingPage开始,我们必须进行的更改是:

  • 删除本地数据属性。

  • 添加一个计算属性listing。这将根据路由从存储中找到正确的列表数据。

  • 删除 mixin。

  • 更改模板变量,使它们成为listing的属性:例如{{ title }},将变成{{ listing.title }}。不幸的是,现在所有变量都是listing的属性,这使得我们的模板稍微冗长。

resources/assets/components/ListingPage.vue

<template>
  <div>
    <header-image v-if="listing.images[0]" 
      :image-url="listing.images[0]" 
      @header-clicked="openModal" 
      :id="listing.id"
    ></header-image>
    <div class="listing-container">
      <div class="heading">
        <h1>{{ listing.title }}</h1>
        <p>{{ listing.address }}</p>
      </div>
      <hr>
      <div class="about">
        <h3>About this listing</h3>
        <expandable-text>{{ listing.about }}</expandable-text>
      </div>
      <div class="lists">
        <feature-list title="Amenities" :items="listing.amenities"> ... </feature-list>
        <feature-list title="Prices" :items="listing.prices"> ... </feature-list>
      </div>
    </div>
    <modal-window ref="imagemodal">
      <image-carousel :images="listing.images"></image-carousel>
    </modal-window>
  </div>
</template>
<script> ...

  export default { components: { ... }, computed: {
      listing() {
        let listing = this.$store.state.listings.find( listing => listing.id == this.$route.params.listing );
        return populateAmenitiesAndPrices(listing);
      }
    }, methods: { ... }
  } </script>

HomePage的更改要简单得多;只需删除 mixin 和本地状态,并用计算属性listing_groups替换它,该属性将从存储中检索所有列表摘要。

resources/assets/components/HomePage.vue

export default { computed: {
    listing_groups() {
      return groupByCountry(this.$store.state.listing_summaries);
    }
  }, components: { ... }
}

进行这些更改后,重新加载应用程序,您应该看不到行为上的明显变化。但是,检查 Vue Devtools 的 Vuex 选项卡,您将看到页面数据现在在存储中:

图 8.14。页面状态现在在 Vuex 存储中

Getters

有时我们想要从商店得到的不是直接的价值,而是一个派生的价值。例如,假设我们只想获取用户保存的那些列表摘要。为此,我们可以定义一个getter,它类似于存储的计算属性:

state: { saved: [5, 10], listing_summaries: [ ... ]  
}, getters: {
  savedSummaries(state) {
    return state.listing_summaries.filter( item => state.saved.indexOf(item.id) > -1
    );
  }
}

现在,任何需要 getter 数据的组件都可以从存储中检索它:

console.log(this.$store.state.getters.savedSummaries);

/*
[
  5 => [ ... ],
  10 => [ ... ]
]
*/

通常,当几个组件需要相同的派生值时,您会定义一个 getter 来避免重复编写代码。让我们创建一个 getter 来检索特定的列表。我们已经在ListingPage中创建了这个功能,但由于我们在路由器中也需要它,我们将将其重构为 getter。

关于 getter 的一件事是,它们不像 mutations 那样接受有效负载参数。如果要将值传递给 getter,您需要返回一个函数,其中有效负载是该函数的参数。

resources/assets/js/router.js:

getters: {
  getListing(state) {
    return id => state.listings.find(listing => id == listing.id);
  }
}

现在让我们在ListingPage中使用这个 getter 来替换以前的逻辑。

resources/assets/components/ListingPage.vue:

computed: {
 listing() {
    return populateAmenitiesAndPrices(
      this.$store.getters.getListing(this.$route.params.listing)
    );
  }
} 

检查页面状态是否在存储中

我们已成功将页面状态移入存储。现在在导航守卫中,我们将检查页面需要的数据是否已经存储,以避免两次检索相同的数据:

图 8.15。获取页面数据的决策逻辑

让我们在router.jsbeforeEach钩子中实现这个逻辑。我们将在开头添加一个if块,如果数据已经存在,它将立即解析钩子。if使用一个带有以下逻辑的三元函数:

  • 如果路由名称是listing,则使用getListing getter 来查看特定列表是否可用(如果不可用,则此 getter 返回undefined

  • 如果路由名称不是 listing,请检查存储是否有列表摘要可用。列表摘要总是一次性检索的,因此如果至少有一个,您可以假定它们都在那里。

resources/assets/js/router.js:

router.beforeEach((to, from, next) => {
  let serverData = JSON.parse(window.vuebnb_server_data);
  if ( to.name === 'listing'
      ? store.getters.getListing(to.params.listing)
      : store.state.listing_summaries.length > 0
  ) {
    next();
  }
  else if (!serverData.path || to.path !== serverData.path) { axios.get(`/api${to.path}`).then(({data}) => { store.commit('addData', {route: to.name, data});
      next();
    });

  }
  else { store.commit('addData', {route: to.name, data: serverData});
    next();
  }
});

完成后,如果在应用内导航中从主页导航到列表 1,然后返回主页,然后返回列表 1,应用程序将只从 API 中检索列表 1 一次。在以前的架构下,它会做两次!

保存的页面

现在我们将保存的页面添加到 Vuebnb。让我们首先创建组件文件:

$ touch resources/assets/components/SavedPage.vue

接下来,我们将在路径/saved上创建一个新的路由,并使用这个组件。

resources/assets/js/router.js:

...

import SavedPage from '../components/SavedPage.vue';

let router = new VueRouter({
  ... routes: [
    ...
    { path: '/saved', component: SavedPage, name: 'saved' }
  ]
});

让我们还在 Laravel 项目中添加一些服务器端路由。如上所述,保存的页面使用与主页完全相同的数据。这意味着我们可以调用用于主页的相同控制器方法。

routes/web.php:

Route::get('/saved', 'ListingController@get_home_web');

routes/api.php:

Route::get('/saved', 'ListingController@get_home_api');

现在我们将定义SavedPage组件。从script标签开始,我们将导入我们在第六章中创建的ListingSummary组件,使用 Vue.js 组件组合小部件。我们还将创建一个计算属性listings,它将从存储中返回列表摘要,并根据是否保存进行过滤。

resources/assets/components/SavedPage.vue:

<template></template>
<script> import ListingSummary from './ListingSummary.vue';

  export default { computed: {
      listings() {
        return this.$store.state.listing_summaries.filter( item => this.$store.state.saved.indexOf(item.id) > -1
        );
      }
    }, components: { ListingSummary }
  } </script>
<style></style>

接下来,我们将添加到SavedPagetemplate标签。主要内容包括检查listings计算属性返回的数组长度。如果为 0,则尚未保存任何项目。在这种情况下,我们会显示一条消息通知用户。然而,如果有保存的列表,我们将遍历它们并使用ListingSummary组件显示它们。

resources/assets/components/SavedPage.vue:

<template>
  <div id="saved" class="home-container">
    <h2>Saved listings</h2>
    <div v-if="listings.length" class="listing-summaries">
      <listing-summary v-for="listing in listings"
        :listing="listing"
        :key="listing.id"
      ></listing-summary>
    </div>
    <div v-else>No saved listings.</div>
  </div>
</template>
<script>...</script>
<style>...</style>

最后,我们将添加到style标签。这里需要注意的主要是我们正在利用flex-wrap: wrap规则并向左对齐。这确保我们的列表摘要将自行组织成没有间隙的行。

resources/assets/components/SavedPage.vue:

<template>...</template>
<script>...</script>
<style> #saved .listing-summaries {
    display: flex;
    flex-wrap: wrap;
    justify-content: left;
    overflow: hidden;
  }

  #saved .listing-summaries .listing-summary {
    padding-bottom: 30px;
  }

  .listing-summaries > .listing-summary {
    margin-right: 15px;
  } </style>

让我们还在全局 CSS 文件中添加.saved-container CSS 规则。这确保我们的自定义页脚也可以访问这些规则。

resources/assets/css/style.css:

.saved-container {
  margin: 0 auto;
  padding: 0 25px;
}

@media (min-width: 1131px) {
  .saved-container {
    width: 1095px;
    padding-left: 40px;
    margin-bottom: -10px;
  }
}

最后的任务是向存储中添加一些默认的保存列表。我随机选择了 1 和 15,但您可以添加任何您想要的。在下一章中,当我们使用 Laravel 将保存的列表持久化到数据库时,我们将再次删除这些。

resources/assets/js/store.js:

state: { saved: [1, 15],
  ...
},

完成后,我们的保存页面如下所示:

图 8.16。保存页面

如果我们删除所有保存的列表,我们会看到:

图 8.17。没有列表的保存页面

工具栏链接

本章的最后一件事是在工具栏中添加一个链接到保存页面,以便从任何其他页面访问保存页面。为此,我们将添加一个内联ul,其中链接被包含在一个子li中(我们将在第九章中在工具栏中添加更多链接,添加用户登录和使用 Passport 进行 API 身份验证)。

resources/assets/components/App.vue:

<div id="toolbar">
  <router-link :to="{ name: 'home' }">
    <img class="icon" src="/images/logo.png">
    <h1>vuebnb</h1>
  </router-link>
  <ul class="links">
    <li>
      <router-link :to="{ name: 'saved' }">Saved</router-link>
    </li>
  </ul>
</div>

为了正确显示这一点,我们需要添加一些额外的 CSS。首先,我们将修改#toolbar声明,使工具栏使用 flex 进行显示。我们还将在下面添加一些新规则来显示链接。

resources/assets/components/App.vue:

<style> #toolbar {
    display: flex;
    justify-content: space-between;
    border-bottom: 1px solid #e4e4e4;
    box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
  }

  ... #toolbar ul {
    display: flex;
    align-items: center;
    list-style: none;
    padding: 0 24px 0 0;
    margin: 0;
  }

  @media (max-width: 373px) {
    #toolbar ul  {
      padding-right: 12px;
    }
  }

  #toolbar ul li {
    padding: 10px 10px 0 10px;
  }

  #toolbar ul li a {
    text-decoration: none;
    line-height: 1;
    color: inherit;
    font-size: 13px;
    padding-bottom: 8px;
    letter-spacing: 0.5px;
 cursor: pointer; }

  #toolbar ul li a:hover {
    border-bottom: 2px solid #484848;
    padding-bottom: 6px;
  } </style>

现在我们在工具栏中有一个指向保存页面的链接:

图 8.18:工具栏中的保存链接

摘要

在本章中,我们学习了 Vuex,Vue 的官方状态管理库,它基于 Flux 架构。我们在 Vuebnb 中安装了 Vuex,并设置了一个存储库,可以在其中编写和检索全局状态。

然后,我们学习了 Vuex 的主要特性,包括状态、变化方法和获取器,以及我们如何使用 Vue Devtools 调试 Vuex。我们利用这些知识实现了一个列表保存组件,然后将其添加到我们的主页面。

最后,我们将 Vuex 和 Vue Router 结合起来,以便在路由更改时更有效地存储和检索页面状态。

在下一章中,我们将涵盖全栈应用程序中最棘手的主题之一 - 认证。我们将在 Vuebnb 中添加用户配置文件,以便用户可以将其保存的项目持久保存到数据库中。我们还将继续增加对 Vuex 的了解,利用一些更高级的功能。

第九章:使用 Passport 添加用户登录和 API 身份验证

在上一章中,我们允许用户保存他们喜欢的 Vuebnb 列表。但是,这个功能只在前端应用中实现,所以如果用户重新加载页面,他们的选择将会丢失。

在本章中,我们将创建一个用户登录系统,并将保存的项目持久化到数据库中,以便在页面刷新后检索。

本章涵盖的主题:

  • 利用 Laravel 内置的身份验证功能设置用户登录系统

  • 创建带有 CSRF 保护的登录表单

  • 在存储中使用 Vuex 操作进行异步操作

  • OAuth 协议的简要介绍,用于 API 身份验证

  • 使用 Laravel Passport 允许经过身份验证的 AJAX 请求

用户模型

为了将列表项保存到数据库中,我们首先需要一个用户模型,因为我们希望每个用户都有自己独特的列表。添加用户模型意味着我们还需要一个身份验证系统,以便用户可以登录和退出。幸运的是,Laravel 提供了一个功能齐全的用户模型和身份验证系统。

现在让我们来看看用户模型样板文件,看看需要对其进行哪些修改以适应我们的目的。

迁移

首先看一下数据库迁移,用户表模式已经包括 ID、名称、电子邮件和密码列。

database/migrations/2014_10_12_000000_create_users_table.php

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateUsersTable extends Migration
{
  public function up()
  { Schema::create('users', function (Blueprint $table) {
      $table->increments('id');
      $table->string('name');
      $table->string('email')->unique();
      $table->string('password');
      $table->rememberToken();
      $table->timestamps();
    });
  }

  public function down()
  { Schema::dropIfExists('users');
  }
}

如果我们添加一个额外的列来存储保存的列表 ID,那么这个模式对我们的需求就足够了。理想情况下,我们会将它们存储在一个数组中,但是由于关系数据库没有数组列类型,我们将把它们存储为一个序列化的字符串,例如,在text列中[1, 5, 10]

database/migrations/2014_10_12_000000_create_users_table.php

Schema::create('users', function (Blueprint $table) {
  ...
  $table->text('saved');
});

模型

现在让我们来看看 Laravel 提供的User模型类。

app/User.php

<?php

namespace App;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
  use Notifiable;

  protected $fillable = [
    'name', 'email', 'password',
  ];

  protected $hidden = [
    'password', 'remember_token',
  ];
}

默认配置是可以的,但让我们通过将其添加到$fillable数组中,允许saved属性进行批量赋值。

当我们读取或写入时,我们还将使我们的模型序列化和反序列化saved文本。为此,我们可以向模型添加一个$casts属性,并将saved转换为数组。

app/User.php

class User extends Authenticatable
{
  ...

  protected $fillable = [
    'name', 'email', 'password', 'saved'
  ];

  ...

  protected $casts = [
    'saved' => 'array'
  ];
}

现在我们可以将saved属性视为数组,即使它在数据库中存储为字符串:

echo gettype($user->saved());

// array

Seeder

在一个普通的带有登录系统的 Web 应用中,您会有一个注册页面,让用户创建自己的帐户。为了确保本书不会变得太长,我们将跳过该功能,而是使用数据库 seeder 生成用户帐户:

$ php artisan make:seeder UsersTableSeeder

如果您愿意,您可以为 Vuebnb 自己实现一个注册页面。Laravel 文档在laravel.com/docs/5.5/authentication中对此进行了详细介绍。

让我们至少创建一个帐户,其中包括名称、电子邮件、密码和一个保存列表的数组。请注意,我使用了Hash外观的make方法来对密码进行哈希处理,而不是将其存储为纯文本。Laravel 的默认LoginController在登录过程中将自动对纯文本密码进行哈希处理。

database/seeds/UsersTableSeeder.php

<?php

use Illuminate\Database\Seeder;
use App\User;
use Illuminate\Support\Facades\Hash;

class UsersTableSeeder extends Seeder
{
  public function run()
  { User::create([
      'name'      => 'Jane Doe',
      'email'     => 'test@gmail.com',
      'password'  => Hash::make('test'),
      'saved'     => [1,5,7,9]
    ]);
  }
}

要运行 seeder,我们需要从主DatabaseSeeder类中调用它。

database/seeds/DatabaseSeeder.php

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
  public function run()
  {
    $this->call(ListingsTableSeeder::class);
    $this->call(UsersTableSeeder::class);
  }
}

现在让我们重新运行我们的迁移和 seeder,以安装用户表和数据,使用以下命令:

$ php artisan migrate:refresh --seed

为了确认我们的用户表和数据是否正确创建,我们将使用 Tinker 来查询表。您应该会得到类似以下的输出:

$ php artisan tinker
 >>> DB::table('users')->get(); /* {
  "id": 1, "name": "Jane Doe", "email": "test@gmail.com", "password": "...", "remember_token": null, "created_at": "2017-10-27 02:30:31", "updated_at": "2017-10-27 02:30:31", "saved": "[1,5,7,9]"
} */

登录系统

现在我们已经创建了用户模型,我们可以实现登录系统的其余部分。同样,Laravel 将其作为一个开箱即用的功能包含在内,所以我们只需要进行少量配置。

以下是登录系统的概述:

  1. 用户在登录表单中提供他们的电子邮件和密码。我们将使用 Vue 创建这个表单

  2. 表单提交到/login POST 路由

  3. LoginController然后将验证用户的凭据与数据库匹配

  4. 如果登录成功,用户将被重定向到主页。会话 cookie 附加到响应中,然后传递给所有外发请求以验证用户

以下是登录系统的图解表示,以便更清晰地理解:

图 9.1. 登录流程

LoginPage 组件

我们的应用程序需要一个登录页面,所以让我们创建一个新的页面组件:

$ touch resources/assets/components/LoginPage.vue

我们将首先定义模板标记,其中包括一个带有电子邮件和密码字段以及提交按钮的表单。表单使用 HTTP POST 方法,并发送到/login路径。我将表单元素包装在一个带有.form-controller类的div中,以帮助进行样式设置。

resources/assets/components/LoginPage.vue:

<template>
  <div id="login" class="login-container">
    <form role="form" method="POST" action="/login">
      <div class="form-control">
        <input id="email" type="email" name="email" 
          placeholder="Email Address" required autofocus>
      </div>
      <div class="form-control">
        <input id="password" type="password" name="password" 
          placeholder="Password" required>
      </div>
      <div class="form-control">
        <button type="submit">Log in</button>
      </div>
    </form>
  </div>
</template>

我们现在还不需要任何 JavaScript 功能,所以让我们现在添加我们的 CSS 规则。

resources/assets/components/LoginPage.vue:

<template>...</template>
<style> #login form {
    padding-top: 40px;
  }

  @media (min-width: 744px) {
    #login form {
      padding-top: 80px;
    }
  }

  #login .form-control {
    margin-bottom: 1em;
  }

  #login input[type=email],
  #login input[type=password],
  #login button,
  #login label {
    width: 100%;
    font-size: 19px !important;
    line-height: 24px;
    color: #484848;
    font-weight: 300;
  }

  #login input {
    background-color: transparent;
    padding: 11px;
    border: 1px solid #dbdbdb;
    border-radius: 2px;
    box-sizing:border-box }

  #login button {
    background-color: #4fc08d;
    color: #ffffff;
    cursor: pointer;
    border: #4fc08d;
    border-radius: 4px;
    padding-top: 12px;
    padding-bottom: 12px;
  } </style>

我们将在全局 CSS 文件中添加一个login-container类,以便该页面的页脚正确对齐。我们还将添加一个 CSS 规则,以确保文本输入在 iPhone 上正确显示。登录页面将是我们唯一需要文本输入的地方,但为了以防以后决定添加其他表单,让我们将其作为全局规则添加。

resources/assets/css/style.css:

...

.login-container { margin: 0 auto; padding: 0 12px;
} @media (min-width: 374px) {
  .login-container { width: 350px;
  }
} input[type=text] {
  -webkit-appearance: none;
}

最后,让我们将这个新的页面组件添加到我们的路由器中。我们首先导入组件,然后将其添加到路由器配置中的routes数组中。

请注意,登录页面不需要来自服务器的任何数据,就像 Vuebnb 的其他页面一样。这意味着我们可以通过修改导航守卫中第一个if语句的逻辑来跳过数据获取步骤。如果路由的名称是login,它现在应该立即解析。

resources/assets/js/router.js:

...

import LoginPage from '../components/LoginPage.vue';

let router = new VueRouter({
  ... routes: [
    ...
    { path: '/login', component: LoginPage, name: 'login' }
  ],
  ...
}); router.beforeEach((to, from, next) => {
  ...
  if ( to.name === 'listing'
      ? store.getters.getListing(to.params.listing)
      : store.state.listing_summaries.length > 0
    || to.name === 'login'
  ) {
    next();
  }
  ...
});

export default router;

服务器路由

现在我们在/login路由添加了一个登录页面,我们需要创建一个匹配的服务器端路由。我们还需要一个用于提交到相同/login路径的登录表单的路由。

实际上,这两个路由都是 Laravel 的默认登录系统提供的。要激活这些路由,我们只需在我们的 web 路由文件的底部添加以下行。

routes/web.php:

... Auth::routes();

要查看此代码的效果,我们可以使用 Artisan 来显示应用程序中的路由列表:

$ php artisan route:list 

输出:

图 9.2. 终端输出显示路由列表

您将看到我们手动创建的所有路由,以及一些我们没有创建的路由,例如登录注销注册。这些是 Laravel 身份验证系统使用的路由,我们刚刚激活了它们。

查看 GET/HEAD /login路由,您将看到它指向LoginController控制器。让我们来看看那个文件。

App\Http\Controllers\Auth\LoginController.php:

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;

class LoginController extends Controller
{
  use AuthenticatesUsers;

  protected $redirectTo = '/home';

  public function __construct()
  {
    $this->middleware('guest')->except('logout');
  }
}

这个类使用了一个AuthenticatesUsers特性,定义了showLoginForm方法,/login路由处理程序引用了这个方法。让我们重写该方法,使其简单地返回我们的应用视图。由于这个视图实例不需要在头部内联任何数据(登录表单没有状态),我们将向data模板变量传递一个空数组。

App\Http\Controllers\Auth\LoginController.php:

class LoginController extends Controller
{
  ...

  public function showLoginForm()
  {
    return view('app', ['data' => []]);
  }
}

完成后,通过将浏览器导航到/login,我们现在可以看到完整的登录页面:

图 9.3. 登录页面

CSRF 保护

CSRF(跨站请求伪造)是一种恶意利用,攻击者让用户在当前登录的服务器上执行一个不知情的操作。这个操作将改变服务器上对攻击者有利的东西,例如转账、更改攻击者知道的密码等。

例如,攻击者可能会在网页或电子邮件中隐藏一个脚本,并以某种方式引导用户访问它。当执行时,此脚本可以向importantwebsite.com/updateEmailAndPassword发出 POST 请求。如果用户已登录到此站点,则请求可能成功。

防止这种攻击的一种方法是在用户可能提交的任何表单中嵌入一个特殊令牌,实质上是一个随机字符串。当提交表单时,检查令牌是否与用户的会话匹配。攻击者将无法伪造此令牌,并因此受到此功能的阻碍。

在 Laravel 中,CSRF 令牌的创建和验证由默认添加到 web 路由的VerifyCsrfToken中间件管理:

图 9.4 CSRF 预防过程

要在表单中包含 CSRF 令牌,只需在form标记中添加{{ csrf_field() }}。这将生成一个包含有效 CSRF 令牌的隐藏输入字段,例如:

<input type="hidden" name="_token" value="3B08L3fj...">

然而,在我们的情况下,这不起作用,因为我们的表单不在 Blade 视图中,而是在一个不会被 Blade 处理的单文件组件中。作为替代方案,我们可以将 CSRF 令牌添加到页面的头部,并将其分配给window对象。

resources/views/app.blade.php:

<script type="text/javascript"> window.vuebnb_server_data = "{!! addslashes(json_encode($data)) !!}" window.csrf_token = "{{ csrf_token() }}" </script>

现在我们可以从 Vue.js 应用程序中检索到这个,并手动将其添加到登录表单中。让我们修改LoginPage,在表单中包含一个隐藏的input字段。我们现在将一些状态添加到这个组件中,其中令牌被包含为数据属性并绑定到隐藏字段中。

resources/assets/js/components/LoginPage.vue:

<template>
  <div id="login" class="login-container">
    <form role="form" method="POST" action="/login">
      <input type="hidden" name="_token" :value="csrf_token"> ... </form>
  </div>
</template>
<script> export default {
    data() {
      return { csrf_token: window.csrf_token }
    }
  } </script>
<style>...</style>

如果我们现在尝试使用我们在 seeder 中创建的用户的凭据登录到我们的应用程序,我们将收到此错误页面。查看地址栏,您会看到我们所在的路由是/home,这不是我们应用程序中的有效路由,因此会出现NotFoundHttpException

图 9.5 无效路由

登录后重定向

当用户登录时,Laravel 会将他们重定向到登录控制器中$redirectTo属性定义的页面。让我们将其从/home更改为/

app/Http/Auth/Controllers/LoginController.php:

class LoginController extends Controller
{
  ...

  protected $redirectTo = '/';

  ...
}

让我们也更新RedirectIfAuthenticated中间件类,以便如果已登录用户尝试查看登录页面,则将其重定向到/(而不是默认的/home值)。

app/Http/Middleware/RedirectIfAuthenticated.php:

...

if (Auth::guard($guard)->check()) {
  return redirect('/');
}

完成这些步骤后,我们的登录流程现在将正常工作。

在工具栏中添加身份验证链接

现在让我们在工具栏中添加登录和注销链接,以便 Vuebnb 用户可以轻松访问这些功能。

登录链接只是一个指向login路由的RouterLink

登出链接更有趣:我们捕获此链接的点击事件,并触发隐藏表单的提交。此表单向/logout服务器路由发送 POST 请求,将用户注销并将其重定向回主页。请注意,为使此工作,我们必须将 CSRF 令牌作为隐藏输入包含在内。

resources/assets/components/App.vue:

<template>
  ...
  <ul class="links">
    <li>
      <router-link :to="{ name: 'saved' }"> Saved </router-link>
    </li>
    <li>
      <router-link :to="{ name: 'login' }"> Log In </router-link>
    </li>
    <li>
      <a @click="logout">Log Out</a>
      <form 
 style="display: hidden" 
 action="/logout" 
 method="POST" 
 id="logout" >
        <input type="hidden" name="_token" :value="csrf_token"/>
      </form>
    </li>
  </ul>
  ...
</template>
<script>
  ...

  export default { components: { ... },
    data() {
      return { csrf_token: window.csrf_token }
    }, methods: {
      logout() { document.getElementById('logout').submit();
      }
    }
  }
</script>

保护保存的路由

我们现在可以使用我们的登录系统来保护某些路由免受未经身份验证的用户的访问。Laravel 提供了auth中间件,可以应用于任何路由,并且如果访客用户尝试访问它,将会将其重定向到登录页面。让我们将其应用于我们保存的页面路由。

routes/web.php:

Route::get('/saved', 'ListingController@get_home_web')->middleware('auth');

如果您从应用程序注销并尝试从浏览器的导航栏访问此路由,您会发现它会将您重定向回/login

将身份验证状态传递给前端

我们现在有了一个完整的登录和注销 Vuebnb 的机制。然而,前端应用程序还不知道用户的身份验证状态。让我们现在解决这个问题,这样我们就可以向前端添加基于身份验证的功能。

auth 元属性

我们将首先将身份验证状态添加到我们通过每个页面头部传递的元信息中。我们将利用Auth外观的check方法,如果用户已经验证,它将返回true,并将其分配给一个新的auth属性。

app/Http/Controllers/ListingController.php:

...
use Illuminate\Support\Facades\Auth;

class ListingController extends Controller
{
  ...

  private function add_meta_data($collection, $request)
  {
    return $collection->merge([
      'path' => $request->getPathInfo(),
      'auth' => Auth::check()
    ]);
  }
}

我们还将在我们的 Vuex 存储中添加一个auth属性。我们将从addData方法中对其进行变化,正如您从上一章中记得的那样,这是我们从文档头部或 API 中检索数据的地方。由于 API 不包括元数据,我们将有条件地改变auth属性,以避免访问可能未定义的对象属性。

resources/assets/js/store.js:

...

export default new Vuex.Store({ state: {
    ... auth: false
  }, mutations: {
    ...
    addData(state, { route, data }) {
      if (data.auth) { state.auth = data.auth;
      }
      if (route === 'listing') { state.listings.push(data.listing);
      } else { state.listing_summaries = data.listings;
      }
    }
  }, getters: { ... }
});

现在,Vuex 已经在跟踪用户的身份验证状态。一定要通过登录和注销来测试这一点,并注意 Vue Devtools 的 Vuex 选项卡中的auth的值:

图 9.6。Vue Devtools 中auth的值

响应身份验证状态

现在我们正在跟踪用户的身份验证状态,我们可以让 Vuebnb 对其做出响应。首先,让我们使用户在未登录时无法保存列表。为此,我们将修改toggleSaved变化器方法的行为,以便如果用户已登录,则可以保存项目,但如果没有,则通过 Vue Router 的push方法重定向到登录页面。

请注意,我们将不得不在文件顶部导入我们的路由模块,以便访问其功能。

resources/assets/js/store.js:

...
import router from './router';

export default new Vuex.Store({
  ... mutations: {
    toggleSaved(state, id) {
      if (state.auth) {
        let index = state.saved.findIndex(saved => saved === id);
        if (index === -1) { state.saved.push(id);
        } else { state.saved.splice(index, 1);
        }
      } else { router.push('/login');
      }
    },
    ...    
  },
  ...
});

我们还将使工具栏中显示登录链接或注销链接,而不会同时显示两者。这可以通过工具栏中依赖于$store.state.auth值的v-ifv-else指令来实现。

除非用户已登录,否则隐藏保存页面链接也是有道理的,因此我们也要这样做。

resources/assets/components/App.vue:

<ul class="links">
  <li v-if="$store.state.auth">
    <router-link :to="{ name: 'saved' }"> Saved </router-link>
  </li>
  <li v-if="$store.state.auth">
    <a @click="logout">Log Out</a>
    <form style="display: hidden" 
      action="/logout"  method="POST" 
      id="logout" >
      <input type="hidden" name="_token" :value="csrf_token"/>
    </form>
  </li>
  <li v-else>
    <router-link :to="{ name: 'login' }"> Log In </router-link>
  </li>
</ul> 

现在,工具栏的外观将取决于用户是否已登录或注销:

图 9.8。工具栏中已登录和已注销状态的比较

从数据库中检索保存的项目

现在让我们开始从数据库中检索保存的项目并在前端显示它们。首先,我们将在文档头部放置的元数据中添加一个新的saved属性。如果用户已注销,这将是一个空数组,或者如果他们已登录,则是与该用户关联的保存列表 ID 数组。

app/Http/Controllers/ListingController.php:

private function add_meta_data($collection, $request)
{
  return $collection->merge([
    'path' => $request->getPathInfo(), 
    'auth' => Auth::check(), 
    'saved' => Auth::check() ? Auth::user()->saved : []
  ]);
}

在前端,我们将把检索保存项目的逻辑放在beforeEach路由导航守卫中。我们将其放在这里而不是在addData变化中的原因是,我们不希望直接将数据分配给存储状态,而是对每个列表调用toggleSaved变化。您不能从另一个变化中提交变化,因此必须在存储之外完成此操作。

resources/assets/js/router.js:

router.beforeEach((to, from, next) => {
  let serverData = JSON.parse(window.vuebnb_server_data);
  if ( ... ) { ... }
  else if ( ... ) { ... }
  else { store.commit('addData', {route: to.name, data: serverData}); serverData.saved.forEach(id => store.commit('toggleSaved', id));
    next();
  }
});

让我们还删除我们在上一章中添加到saved中的占位符列表 ID,以便存储在初始化时为空。

resources/assets/js/store.js:

state: { saved: [], listing_summaries: [], listings: [], auth: false
}

完成这些操作后,我们应该发现,如果使用 Vue Devtools 检查,数据库中的保存列表现在与前端中的列表匹配:

$ php artisan tinker >>> DB::table('users')->select('saved')->first();
# "saved": "[1,5,7,9]"

图 9.8。Vue Devtools 的 Vuex 选项卡显示保存的列表与数据库匹配

持久保存列表

持久保存列表的机制如下:当在前端应用中切换列表时,我们触发一个 AJAX 请求,将 ID POST 到后端的一个路由。这个路由调用一个控制器,将更新模型。

图 9.9。持久保存列表

现在让我们实现这个机制。

创建 API 路由

我们将从服务器端开始,并为前端添加一个路由来 POST listing IDS。我们需要添加auth中间件,以便只有经过身份验证的用户才能访问这个路由(我们将很快讨论:api的含义)。

routes/api.php:

...

Route::post('/user/toggle_saved', 'UserController@toggle_saved') ->middleware('auth:api') ;

由于这是一个 API 路由,它的完整路径将是/api/user/toggle_saved。我们还没有创建这个路由调用的控制器UserController,所以现在让我们来做这个。

$ php artisan make:controller UserController

在这个新的控制器中,我们将添加toggled_saved处理方法。由于这是一个 HTTP POST 路由,这个方法将可以访问表单数据。我们将使前端对这个路由的 AJAX 调用包含一个id字段,这将是我们想要切换的 listing 的 ID。要访问这个字段,我们可以使用Input外观,即Input::get('id');

由于我们在这个路由上使用了auth中间件,我们可以通过使用Auth::user()方法来检索与请求相关联的用户模型。然后我们可以像在我们的 Vuex store 的toggledSaved方法中那样,要么添加要么删除用户的saved列表中的 ID。

一旦 ID 被切换,我们就可以使用模型的save方法将更新持久化到数据库中。

app/Http/Controllers/UserController.php:

<?php

...

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Input;

class UserController extends Controller
{
  public function toggle_saved()
  {
    $id = Input::get('id');
    $user = Auth::user();
    $saved = $user->saved;
    $key = array_search($id, $saved);
    if ($key === FALSE) {
        array_push($saved, $id);
    } else {
        array_splice($saved, $key, 1);
    }
    $user->saved = $saved;
    $user->save();
    return response()->json();
  }
}

Vuex actions

在第八章中,使用 Vuex 管理应用程序状态,我们讨论了 Flux 模式的关键原则,包括 mutations 必须是同步的,以避免使我们的应用程序数据不可预测的竞争条件。

如果您需要在一个 mutator 方法中包含异步代码,您应该创建一个action。Actions 类似于 mutations,但是不是直接改变状态,而是提交 mutations。例如:

var store = new Vuex.Store({ state: { val: null  
  }, mutations: {
    assignVal(state, payload) { state.val = payload;
    }  
  }, actions: {
    setTimeout(() => {
      commit('assignVal', 10);
    }, 1000)
  }
}); store.dispatch('assignVal', 10);

通过将异步代码抽象成 actions,我们仍然可以将任何改变状态的逻辑集中在 store 中,而不会通过竞争条件来污染我们的应用程序数据。

AJAX 请求

现在让我们使用 AJAX 来发起对/api/user/toggle_saved的请求当一个 listing 被保存时。我们将把这个逻辑放到一个 Vuex action 中,这样当 AJAX 调用解析时,toggleSavedmutation 就不会被提交。我们将在 store 中导入 Axios HTTP 库来实现这一点。

另外,让我们将认证检查从 mutation 移到 action 中,因为在发起 AJAX 调用之前进行这个检查是有意义的。

resources/assets/js/store.js:

import axios from 'axios';

export default new Vuex.Store({
  ... mutations: {
    toggleSaved(state, id) {
      let index = state.saved.findIndex(saved => saved === id);
      if (index === -1) { state.saved.push(id);
      } else { state.saved.splice(index, 1);
      }
    },
    ...
  },
  ... actions: {
    toggleSaved({ commit, state }, id) {
      if (state.auth) { axios.post('/api/user/toggle_saved', { id }).then(
          () => commit('toggleSaved', id)
        );
      } else { router.push('/login');
      }
    }
  }
});

现在我们需要从我们的ListingSave组件中调用toggledSavedaction,而不是 mutation。调用一个 action 的方式与 mutation 完全相同,只是术语从commit变为dispatch

resources/assets/components/ListingSave.vue:

toggleSaved() {
  this.$store.dispatch('toggleSaved', this.id);
}

前端的这个功能代码是正确的,但是如果我们测试并尝试保存一个项目,我们会从服务器得到一个401 未认证的错误:

图 9.10. AJAX 调用导致 401 未认证错误

API 认证

我们在/api/user/toggle_saved路由中添加了auth中间件,以保护它免受访客用户的攻击。我们还为这个中间件指定了api守卫,即auth:api

守卫定义了用户如何进行认证,并在以下文件中进行配置。

config/auth.php:

<?php

return [
  ...
  'guards' => [
    'web' => [
      'driver' => 'session',
      'provider' => 'users',
    ],
    'api' => [
      'driver' => 'token',
      'provider' => 'users',
    ],
  ],
  ...
];

我们的 web 路由使用session驱动程序,它使用会话 cookie 来维护认证状态。会话驱动程序随 Laravel 一起提供,并且可以直接使用。但是,默认情况下,API 路由使用token守卫。我们还没有实现这个驱动程序,因此我们的 AJAX 调用未经授权。

我们也可以在 API 路由中使用会话驱动程序,但这并不推荐,因为会话认证对于 AJAX 请求来说是不够的。相反,我们将使用passport守卫,它实现了 OAuth 协议。

您可能会看到auth用作auth:web的简写,因为 web 守卫是默认的。

OAuth

OAuth 是一种授权协议,允许第三方应用程序访问服务器上用户的数据,而不暴露其密码。对受保护数据的访问是以特殊令牌的形式给予的,一旦第三方应用程序和用户向服务器确认了身份,该令牌就会被授予。OAuth 的一个典型用例是社交登录,例如,当您为自己的网站使用 Facebook 或 Google 登录时。

进行安全的 AJAX 请求的一个挑战是,您不能将任何凭据存储在前端源代码中,因为攻击者可以轻松找到这些凭据。OAuth 的一个简单实现,其中第三方应用实际上是您自己的前端应用,是解决这个问题的一个很好的解决方案。这是我们现在将要采取的方法,用于 Vuebnb。

虽然 OAuth 是 API 身份验证的一个很好的解决方案,但它也是一个我无法在本书中完全涵盖的深入主题。我建议您阅读这篇指南以获得更好的理解:www.oauth.com/

Laravel Passport

Laravel Passport 是 OAuth 在 Laravel 应用程序中可以轻松设置的实现。让我们现在安装它以在 Vuebnb 中使用。

首先,使用 Composer 安装 Passport:

$ composer require laravel/passport

Passport 包括生成存储 OAuth 令牌所需的表的新数据库迁移。让我们运行迁移:

$ php artisan migrate

以下命令将安装生成安全令牌所需的加密密钥:

$ php artisan passport:install

运行此命令后,将Laravel\Passport\HasApiTokens特性添加到用户模型。

app/User.php

<?php

...
 use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
  use HasApiTokens, Notifiable;

  ...
}

最后,在config/auth.php配置文件中,让我们将 API 守卫的驱动程序选项设置为passport。这确保auth中间件将使用 Passport 作为 API 路由的守卫。

config/auth.php

'guards' => [
  'web' => [
    'driver' => 'session',
    'provider' => 'users',
  ],

  'api' => [
    'driver' => 'passport',
    'provider' => 'users',
  ],
],

附加令牌

OAuth 要求在用户登录时将访问令牌发送到前端应用程序。Passport 包括一个中间件,可以为您处理这个问题。将CreateFreshApiToken中间件添加到 web 中间件组,laravel_token cookie 将附加到出站响应。

app/Http/Kernel.php

protected $middlewareGroups = [
  'web' => [
    ... \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
  ],
  ...

对于出站请求,我们需要在 AJAX 调用中添加一些标头。我们可以使 Axios 默认自动附加这些。'X-Requested-With': 'XMLHttpRequest'确保 Laravel 知道请求来自 AJAX,而'X-CSRF-TOKEN': window.csrf_token附加 CSRF 令牌。

resources/assets/js/store.js

... axios.defaults.headers.common = {
  'X-Requested-With': 'XMLHttpRequest',
  'X-CSRF-TOKEN': window.csrf_token
};

export default new Vuex.Store({
  ... });

完成后,我们的 API 请求现在应该得到适当的身份验证。为了测试这一点,让我们使用 Tinker 来查看我们为我们的第一个种子用户保存了哪些项目:

$ php artisan tinker >>> DB::table('users')->select('saved')->first();

# "saved": "[1,5,7,9]"

确保您以该用户的身份登录并在浏览器中加载 Vuebnb。切换一些已保存的列表选择并重新运行上面的查询。您应该发现数据库现在正在持久保存已保存的列表 ID。

摘要

在本章中,我们学习了关于全栈 Vue/Laravel 应用程序中的身份验证,包括基于会话的 Web 路由身份验证,以及使用 Laravel Passport 的 API 路由的基于令牌的身份验证。

我们利用这些知识为 Vuebnb 设置了登录系统,并允许将保存的房间列表持久保存到数据库中。

在这个过程中,我们还学习了如何利用 CSRF 令牌来保护表单,以及关于 Vuex 操作用于向存储添加异步代码的知识。

在下一章,也是最后一章中,我们将学习如何通过将 Vuebnb 部署到免费的 Heroku PHP 服务器来将全栈 Vue 和 Laravel 应用程序部署到生产环境。我们还将开始从免费 CDN 提供图像和其他静态内容。

第十章:将全栈应用程序部署到云端

现在 Vuebnb 的功能已经完成,最后一步是将其部署到生产环境。我们将使用两个免费服务,Heroku 和 KeyCDN,与世界分享 Vuebnb。

本章涵盖的主题:

  • Heroku 云平台服务简介

  • 将 Vuebnb 作为免费应用程序部署到 Heroku

  • CDN 如何提高全栈应用程序的性能

  • 将免费 CDN 与 Laravel 集成

  • 为了提高性能和安全性,在生产模式下构建资产

Heroku

Heroku 是一个用于 web 应用程序的云平台服务。由于其提供的简单性和经济性,它在开发者中非常受欢迎。

Heroku 应用程序可以用各种语言创建,包括 PHP、JavaScript 和 Ruby。除了 web 服务器,Heroku 还提供各种附加组件,如数据库、电子邮件服务和应用程序监控。

Heroku 应用程序可以免费部署,但有一定的限制,例如,应用程序在长时间不活动后会休眠,使其响应速度变慢。如果升级到付费服务,这些限制将被解除。

现在我们将 Vuebnb 部署到 Heroku 平台。第一步是通过访问以下 URL 创建一个账户:signup.heroku.com

CLI

使用 Heroku 最方便的方式是通过命令行。访问以下 URL 并按照安装步骤进行安装:devcenter.heroku.com/articles/heroku-cli

安装了 CLI 之后,从终端登录到 Heroku。验证了你的凭据之后,你就可以使用 CLI 来创建和管理你的 Heroku 应用程序了:

$ heroku login

# Enter your Heroku credentials:
# Email: anthony@vuejsdevelopers.com
# Password: ************
# Logged in as anthony@vuejsdevelopers.com

创建一个应用程序

现在让我们创建一个新的 Heroku 应用程序。新的应用程序需要一个唯一的名称,所以在下面的命令中用你自己的选择替换vuebnbapp。这个名称将成为应用程序的 URL 的一部分,所以确保它简短且易记:

$ heroku create vuebnbapp

应用程序创建后,你将得到 URL,例如:vuebnbapp.herokuapp.com/。在浏览器中输入它,你将看到这个默认消息:

图 10.1. Heroku 默认消息新的 Heroku 应用程序被分配了一个免费的域名,例如:appname.herokuapp.com,但你也可以使用自己的自定义域名。在 Heroku Dev Center 上查看更多信息:devcenter.heroku.com

源代码

要将代码部署到你的 Heroku 应用程序,你可以使用 Heroku 的 Git 服务器。当你用 CLI 创建应用程序时,一个新的远程存储库会自动添加到你的 Git 项目中。用以下命令确认:

$ git remote -v

heroku  https://git.heroku.com/vuebnbapp.git (fetch) heroku  https://git.heroku.com/vuebnbapp.git (push) origin  git@github.com:fsvwd/vuebnb.git (fetch) origin  git@github.com:fsvwd/vuebnb.git (push)

一旦我们完成了应用程序的配置,我们将进行第一次推送。Heroku 将使用这段代码来构建应用程序。

环境变量

Heroku 应用程序具有一个短暂的文件系统,只包括最近的 Git 推送的代码。这意味着 Vuebnb 将不会有.env文件,因为这个文件没有提交到源代码中。

环境变量是由 Heroku CLI 设置的,使用heroku config命令。让我们从设置应用程序密钥开始。用你自己的应用程序密钥替换以下值:

$ heroku config:set APP_KEY=base64:mDZ5lQnC2Hq+M6G2iesFzxRxpr+vKJSl+8bbGs=

创建数据库

我们的生产应用程序需要一个数据库。Heroku 的 ClearDB 附加组件提供了一个易于设置和连接的 MySQL 云数据库。

这个附加组件每个月有限次数的交易是免费的。然而,在你添加数据库之前,你需要验证你的 Heroku 账户,这意味着你需要提供信用卡信息,即使你使用免费计划。

要验证你的 Heroku 账户,前往此 URL:heroku.com/verify

一旦你完成了这些,用这个命令创建一个新的 ClearDB 数据库:

$ heroku addons:create cleardb:ignite

默认字符串长度

在撰写本文时,ClearDB 使用的是 MySQL 版本 5.5,而我们的 Homestead 数据库是 MySQL 5.7。MySQL 5.5 中的默认字符串长度对于 Passport 授权密钥来说太短,因此我们需要在运行生产应用程序中的数据库迁移之前,在应用程序服务提供程序中手动设置默认字符串长度。

app/Providers/AppServiceProvider.php

<?php

...

use Illuminate\Support\Facades\Schema;

class AppServiceProvider extends ServiceProvider
{
  ...

  public function boot()
  { Schema::defaultStringLength(191);
  }

  ...
}

配置

当您安装 ClearDB 附加组件时,会自动设置一个新的环境变量CLEARDB_DATABASE_URL。让我们使用heroku config:get命令读取它的值:

$ heroku config:get CLEARDB_DATABASE_URL

# mysql://b221344377ce82c:398z940v@us-cdbr-iron-east-03.cleardb.net/heroku_n0b30ea856af46f?reconnect=true

在 Laravel 项目中,通过设置DB_HOSTDB_DATABASE的值来连接数据库。我们可以从CLEARDB_DATABASE_URL变量中提取这些值,其格式为:

mysql://[DB_USERNAME]:[DB_PASSWORD]@[DB_HOST]/[DB_DATABASE]?reconnect=true

一旦您提取了这些值,就在 Heroku 应用程序中设置适用的环境变量:

$ heroku config:set \
DB_HOST=us-cdbr-iron-east-03.cleardb.net \
DB_DATABASE=heroku_n0b30ea856af46f \
DB_USERNAME=b221344377ce82c \
DB_PASSWORD=398z940v

配置 Web 服务器

Heroku 的 Web 服务器配置是通过一个名为Procfile(无文件扩展名)的特殊文件完成的,它位于项目目录的根目录中。

现在让我们创建该文件:

$ touch Procfile

Procfile的每一行都是一个声明,告诉 Heroku 如何运行应用程序的各个部分。现在让我们为 Vuebnb 创建一个Procfile并添加这个单一声明。

Procfile

web: vendor/bin/heroku-php-apache2 public/

冒号左侧的部分是进程类型。web进程类型定义了应用程序中 HTTP 请求的发送位置。右侧部分是要运行或启动该进程的命令。我们将把请求路由到指向我们应用程序的public目录的 Apache 服务器。

Passport 密钥

在第九章中,使用 Passport 添加用户登录和 API 身份验证,我们使用php artisan passport:install命令为 Passport 创建了加密密钥。这些密钥存储在文本文件中,可以在storage目录中找到。

加密密钥不应该在版本控制下,因为这会使它们不安全。相反,我们需要在每次部署时重新生成这些密钥。我们可以通过向我们的 composer 文件添加一个 post-install 脚本来实现这一点。

composer.json

"scripts": {
 ...
 "post-install-cmd": [
    "Illuminate\\Foundation\\ComposerScripts::postInstall",
    "php artisan optimize",
    "php artisan passport:install"
  ],
} 

部署

我们已经完成了所有必要的设置和配置,现在我们准备部署 Vuebnb。确保将任何文件更改提交到您的 Git 存储库,并推送到 Heroku Git 服务器的主分支:

$ git add --all
$ git commit -m "Ready for deployment!" $ git push heroku master

在推送过程中,您将看到类似以下的输出:

图 10.2. 推送到 Heroku 后的 Git 输出有问题?heroku logs --tail将显示您的 Heroku 应用程序的终端输出。您还可以设置APP_DEBUG=true环境变量来调试 Laravel。不过,当您完成后记得将其设置回false

迁移和填充

部署完成后,我们将迁移我们的表并填充数据库。您可以通过在 Heroku CLI 中使用heroku run来在生产应用程序上运行 Artisan 和其他应用程序命令:

$ heroku run php artisan migrate --seed

一旦迁移和填充完成,我们可以尝试通过浏览器查看应用程序。页面应该可以访问,但您会看到这些混合内容错误:

图 10.3. 控制台错误

修复这些错误不会有太大帮助,因为所引用的文件实际上并不在服务器上。让我们首先解决这个问题。

提供静态资产

由于我们的静态资产,即 CSS、JavaScript 和图像文件,不在版本控制中,它们还没有部署到我们的 Heroku 应用服务器上。

不过,更好的选择是通过 CDN 提供它们。在本章的这一部分,我们将注册 KeyCDN 账户并从那里提供我们的静态资产。

内容分发网络

当服务器收到传入的 HTTP 请求时,通常会响应两种类型的内容:动态或静态。动态内容包括包含特定于该请求的数据的网页或 AJAX 响应,例如,通过 Blade 插入用户数据的网页。

静态内容包括图片、JavaScript 和 CSS 文件,在请求之间不会改变。使用 Web 服务器提供静态内容是低效的,因为它不必要地占用服务器资源来简单地返回一个文件。

内容传送网络CDN)是一个服务器网络,通常位于世界各地不同位置,专门用于更快、更便宜地传送静态资产。

KeyCDN

有许多不同的 CDN 服务可用,但在本书中,我们将使用 KeyCDN,因为它提供了一个易于使用的服务,并且有免费使用层。

通过访问此链接并按照说明进行注册:app.keycdn.com/signup

一旦你创建并确认了一个新的 KeyCDN 账户,通过访问以下链接添加一个新的区域。区域只是资产的集合;你可能为你用 KeyCDN 管理的每个网站创建一个不同的区域。将你的新区域命名为vuebnb,并确保它是推送区域类型,这将允许我们使用 FTP 添加文件:app.keycdn.com/zones/add

使用 FTP 上传文件

现在我们将使用 FTP 将静态资产推送到 CDN。你可以使用 FTP 实用程序(如 Filezilla)来完成这个任务,但我已经在项目中包含了一个 Node 脚本scripts/ftp.js,可以让你用一个简单的命令来完成。

脚本需要一些 NPM 包,所以首先安装这些包:

$ npm i --save-dev dotenv ftp recursive-readdir

环境变量

为了连接到你的 KeyCDN 账户,FTP 脚本需要设置一些环境变量。让我们创建一个名为.env.node的新文件,将这个配置与主要的 Laravel 项目分开:

$ touch .env.node

用于 FTP 到 KeyCDN 的 URL 是ftp.keycdn.com。用户名和密码将与你创建账户时相同,所以确保在以下代码的值中替换它们。远程目录将与你创建的区域名称相同。

.env.node

FTP_HOST=ftp.keycdn.com
FTP_USER=anthonygore
FTP_PWD=*********
FTP_REMOTE_DIR=vuebnb
FTP_SKIP_IMAGES=0

跳过图片

我们需要传输到 CDN 的文件位于public/csspublic/jspublic/fontspublic/images目录中。FTP 脚本已配置为递归复制这些文件。

然而,如果将FTP_SKIP_IMAGES环境变量设置为 true,脚本将忽略public/images中的任何文件。你应该在第一次运行脚本后这样做,因为图片不会改变,传输需要相当长的时间。

.env.node

FTP_SKIP_IMAGES=1

你可以在scripts/ftp.js中看到这是如何生效的:

let folders = [
  'css',
  'js',
  'fonts'
];

if (process.env.FTP_SKIP_IMAGES == 0) { folders.push('images');
}

NPM 脚本

为了方便使用 FTP 脚本,将以下脚本定义添加到你的package.json文件中。

package.json

"ftp-deploy-with-images": "cross-env node ./ftp.js",
"ftp-deploy": "cross-env FTP_SKIP_IMAGES=1 node ./ftp.js"

生产构建

在运行 FTP 脚本之前,确保首先使用npm run prod命令为生产构建你的应用程序。这将使用NODE_ENV=production环境变量进行 Webpack 构建。

生产构建确保你的资产被优化为生产环境。例如,当 Vue.js 在生产模式下捆绑时,它将不包括警告和提示,并且将禁用 Vue Devtools。你可以从vue.runtime.common.js模块的这一部分看到这是如何实现的。

node_modules/vue/dist/vue.runtime.common.js

/**
 * Show production mode tip message on boot? */
productionTip: process.env.NODE_ENV !== 'production',

/**
 * Whether to enable devtools
 */
devtools: process.env.NODE_ENV !== 'production',

Webpack 在生产构建过程中还会运行某些仅限于生产环境的插件,以确保你的捆绑文件尽可能小和安全。

运行 FTP 脚本

第一次运行 FTP 脚本时,你需要复制所有文件,包括图片。这将需要一些时间,可能需要 20 到 30 分钟,具体取决于你的互联网连接速度:

$ npm run prod && npm run ftp-deploy-with-images

一旦传输完成,上传的文件将在区域 URL 上可用,例如,http://vuebnb-9c0f.kxcdn.com。文件的路径将相对于public文件夹,例如,public/css/vue-style.css将在[ZONE_URL]/css/vue-style.css上可用。

测试一些文件以确保传输成功:

图 10.4 测试 CDN 文件

后续的传输可以通过使用这个命令跳过图像:

$ npm run prod && npm run ftp-deploy

从 CDN 读取

我们现在希望在生产环境中,Vuebnb 从 CDN 加载任何静态资产,而不是从 Web 服务器加载。为了做到这一点,我们将创建我们自己的 Laravel 辅助方法。

目前,我们使用 asset 辅助程序引用应用中的资产。这个辅助程序返回该资产在 Web 服务器上位置的完全合格的 URL。例如,在我们的应用视图中,我们像这样链接到 JavaScript 捆绑文件:

<script type="text/javascript" src="{{ asset('js/app.js') }}"></script>

我们的新辅助程序,我们将其称为 cdn,将返回一个指向 CDN 上资产位置的 URL:

<script type="text/javascript" src="{{ cdn('js/app.js') }}"></script>

CDN 辅助程序

让我们开始创建一个名为 helpers.php 的文件。这将声明一个新的 cdn 方法,目前不会做任何事情,只会返回 asset 辅助方法。

app/helpers.php:

<?php

if (!function_exists('cdn'))
{
  function cdn($asset)
  {
    return asset($asset);
  }
}

为了确保这个辅助程序可以在我们的应用中的任何地方使用,我们可以使用 Composer 的 autoload 功能。这使得一个类或文件可以在所有其他文件中使用,而不需要手动 includerequire 它。

composer.json:

... "autoload": {
  "classmap": [ ... ],
  "psr-4": { ... },
  "files": [
    "app/helpers.php"
  ]
},

...

每次修改 Composer 的自动加载声明时,您都需要运行 dump-autoload

$ composer dump-autoload

完成后,cdn 辅助程序将可以在我们的应用中使用。让我们用 Tinker 测试一下:

$ php artisan tinker >>>> cdn('js/app.js')
=> "http://vuebnb.test/js/app.js"

设置 CDN URL

cdn 辅助程序需要知道 CDN 的 URL。让我们设置一个 CDN_URL 环境变量,该变量将被分配给 Vuebnb 的区域 URL,减去协议前缀。

在这个过程中,让我们添加另一个变量 CDN_BYPASS,它可以用于在我们不需要 CDN 的本地开发环境中绕过 CDN。

.env:

... CDN_URL=vuebnb-9c0f.kxcdn.com
CDN_BYPASS=0

现在让我们在应用配置文件中注册这些新变量。

config/app.php:

<?php

return [
  ... // CDN

  'cdn' => [
    'url' => env('CDN_URL'),
    'bypass' => env('CDN_BYPASS', false),
  ],
];

现在我们可以完成我们的 cdn 辅助程序的逻辑。

app/helpers.php:

<?php

use Illuminate\Support\Facades\Config;

if (!function_exists('cdn'))
{
  function cdn($asset)
  {
    if (Config::get('app.cdn.bypass') || !Config::get('app.cdn.url')) {
      return asset($asset);
    } else {
      return  "//" . Config::get('app.cdn.url') . '/' . $asset;
    }
  }
}

如果您仍然打开了 Tinker,请退出并重新进入,并测试更改是否按预期工作:

>>>> exit
$ php artisan tinker >>>> cdn('js/app.js')
=> "//vuebnb-9c0f.kxcdn.com/js/app.js"

在 Laravel 中使用 CDN

现在让我们用 cdn 辅助程序替换我们的 Laravel 文件中 asset 辅助程序的用法。

app/Http/Controllers/ListingController.php:

<?php

...

class ListingController extends Controller
{
  private function get_listing($listing)
  {
    ...
    for($i = 1; $i <=4; $i++) {
      $model['image_' . $i] = cdn( 'images/' . $listing->id . '/Image_' . $i . '.jpg' );
    }
    ...
  }

  ...

  private function get_listing_summaries()
  {
    ...
    $collection->transform(function($listing) {
      $listing->thumb = cdn(
        'images/' . $listing->id . '/Image_1_thumb.jpg'
      );
      return $listing;
    });
    ...
  }

  ...
}

resources/views/app.blade.php:

<html>
  <head>
    ... <link rel="stylesheet" href="{{ cdn('css/style.css') }}" type="text/css">
    <link rel="stylesheet" href="{{ cdn('css/vue-style.css') }}" type="text/css">
    ... </head>
  <body>
    ... <script src="{{ cdn('js/app.js') }}"></script>
  </body>
</html>

在 Vue 中使用 CDN

在我们的 Vue 应用中,我们也加载一些静态资产。例如,在工具栏中我们使用 logo。

resources/assets/components/App.vue:

<img class="icon" src="/images/logo.png">

由于这是一个相对 URL,默认情况下它将指向 Web 服务器。如果我们将其改为绝对 URL,我们将不得不硬编码 CDN URL,这也不理想。

让我们让 Laravel 在文档的头部传递 CDN URL。我们只需调用空字符串的 cdn 辅助程序即可实现这一点。

resources/views/app.blade.php:

<head>
  ... <script type="text/javascript">
     ...
 window.cdn_url = "{{ cdn('') }}";
   </script>
</head>

现在我们将使用一个计算属性来构建绝对 URL,使用这个全局值。

resources/assets/components/App.vue:

<template>
  ... <router-link :to="{ name: 'home' }">
    <img class="icon" :src="logoUrl">
    <h1>vuebnb</h1>
  </router-link>
  ... </template>
<script>
  export default {
    computed: {
      logoUrl() {
        return `${window.cdn_url || ''}images/logo.png`;
      }
    },
    ... }
</script>
<style>...</style>

我们将在页脚中使用相同的概念,灰色的 logo 被使用。

resources/assets/components/CustomFooter.vue:

<template>
... <img class="icon" :src="logoUrl">
... </template>
<script>
  export default {
    computed: {
      containerClass() { ... },
      logoUrl() {
        return `${window.cdn_url || ''}images/logo_grey.png`;
      }
    },
  }
</script>

部署到 Heroku

完成后,提交任何文件更改到 Git 并再次推送到 Heroku 以触发新的部署。您还需要重建您的前端资产并将其传输到 CDN。

最后,设置 CDN 环境变量:

$ heroku config:set \
CDN_BYPASS=0 \
CDN_URL=vuebnb-9c0f.kxcdn.com

终曲

您现在已经完成了本书的案例研究项目,一个复杂的全栈 Vue.js 和 Laravel 应用。恭喜!

一定要向你的朋友和同事展示 Vuebnb,因为他们肯定会对你的新技能印象深刻。我也会很感激,如果你把你的项目链接发给我,这样我也可以欣赏你的工作。我的 Twitter 账号是 @anthonygore

回顾

在这本书中,我们走了很长的路,让我们回顾一下我们取得的一些成就:

  • 在第一章,你好 Vue - Vue.js 简介,我们介绍了 Vue.js

  • 在第二章中,原型设计 Vuebnb,您的第一个 Vue.js 项目,我们学习了 Vue.js 的基础知识,包括安装、数据绑定、指令和生命周期钩子。我们创建了 Vuebnb 列表页面的原型,包括图像模态框

  • 在第三章中,建立 Laravel 开发环境,我们安装了主要的 Vuebnb 项目,并设置了 Homestead 开发环境

  • 在第四章中,使用 Laravel 构建 Web 服务,我们创建了一个 Laravel Web 服务,为 Vuebnb 提供数据

  • 在第五章中,使用 Webpack 集成 Laravel 和 Vue.js,我们将原型迁移到主项目,并使用 Laravel Mix 将我们的资产编译成捆绑文件

  • 在第六章中,使用 Vue.js 组件组合小部件,我们学习了组件。我们利用这些知识在列表页面的模态框中添加了图像轮播,并重构了前端以整合单文件组件

  • 在第七章中,使用 Vue Router 构建多页面应用,我们向项目添加了 Vue Router,允许我们添加一个带有列表摘要滑块的主页

  • 在第八章中,使用 Vuex 管理应用程序状态,我们介绍了 Flux 架构,并将 Vuex 添加到我们的应用程序中。然后我们创建了一个保存功能,并将页面状态移到了 Vuex 中

  • 在第九章中,使用 Passport 添加用户登录和 API 认证,我们向项目添加了用户登录。我们通过经过身份验证的 AJAX 调用将用户保存的列表返回到数据库。

  • 在第十章中,将全栈应用部署到云端,我们将应用部署到 Heroku 云服务器,并将静态资产转移到 CDN

下一步

您可能已经读到了本书的结尾,但作为全栈 Vue 开发人员,您的旅程才刚刚开始!接下来应该做什么呢?

首先,您仍然可以向 Vuebnb 添加许多功能。自己设计和实现这些功能将极大地增加您的技能和知识。以下是一些开始的想法:

  • 完成用户认证流程。添加注册页面和重置密码的功能

  • 添加用户个人资料页面。在这里,用户可以上传头像,在登录时会显示在工具栏中

  • 在列表页面创建一个表单,允许预订房间。包括一个下拉式日期选择器小部件,用于选择开始和结束日期

  • 通过在服务器上运行 Vue 来对应用进行服务器渲染。这样用户在加载网站时就能看到完整的页面内容

其次,我邀请您查看Vue.js Developers,这是一个我创建的 Vue.js 爱好者的在线社区。在这里,您可以阅读有关 Vue.js 的文章,通过我们的通讯订阅了解 Vue.js 的最新消息,并与我们的 Facebook 小组中的其他开发人员分享技巧和诀窍。

在此网址查看:vuejsdevelopers.com

总结

在本章中,我们学习了如何将全栈应用部署到 Heroku 云服务器。为此,我们使用 Heroku CLI 设置了一个新的 Heroku 应用,然后使用 Heroku 的 Git 服务器进行部署。

我们还使用 KeyCDN 创建了一个 CDN,并使用 FTP 将静态资产部署到 CDN。

最后,我们了解到在部署之前以生产模式构建 JavaScript 资产对性能和安全性的重要性。

这是本书的最后一章。感谢您的阅读,祝您在网页开发之旅中好运!

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