JHU-Angular-单页应用笔记-全-
JHU Angular 单页应用笔记(全)
001:0_课程介绍 🎬




在本节课中,我们将要学习这门课程的背景、目标以及它如何建立在先前知识的基础上。我们将了解 AngularJS 框架的重要性,并预览本课程将如何通过实践项目帮助你掌握它。
课程背景与基础
上一门课程《面向 Web 开发者的 HTML、CSS 和 JavaScript》教会了我们如何有效使用 CSS 来创建响应式网站。这些网站能自动适配任何屏幕尺寸,让用户可以在从台式机到移动电话的任何设备上与网站交互。
我们还深入探讨了 JavaScript 和 Ajax 技术,这些技术通过功能和外部数据使我们的页面变得生动。
然而,正如我在上一门课程某个模块的介绍中提到的那样,我们超越了简单的基础知识,真正深入挖掘,以理解这种世界上最流行、或许也是最容易被误解的编程语言——JavaScript。
但我们并未止步于此。作为课程的一部分,我们运用所有新获得的技能,为一个真实的客户构建了一个真实的网站。
这门课程取得了巨大成功,成为了 Coursera 上评分最高的课程,并连续三周保持第一名的位置。
本课程目标与 AngularJS 介绍
在本课程中,我们将以这些基本技能为基础,将学生带到 Web 开发精通的下一阶段。
AngularJS 是构建单页应用程序最流行的框架。它是开源的,并得到谷歌的支持。总的来说,掌握这个框架并非易事,它以陡峭的学习曲线而闻名。然而,在本课程中,我相信学生们会发现学习这个框架的体验会轻松得多。
这是因为我们会循序渐进,不仅解释如何做某事,还会解释其背后的原因。
我们还会解释那些不仅适用于 AngularJS 的概念,并讨论这些概念如何应用于软件开发的一般实践。
这种方法不仅将赋予学生快速开发出色 Web 应用程序的技能,还将用标准开发实践和方法的知识来丰富他们。
课程特色:真实世界视角
我之前提到过这一点,但值得重复的是,这些课程的特别之处在于,授课者不仅擅长教学,而且在实际工作中每天都在使用这些技术。
因此,学生们获得的是如何应对常见软件开发问题(尤其是在 Web 开发领域)的真实世界视角。
正如我提到的,上一门课程最有趣的部分之一是我们拜访了一位真实客户,然后为该客户开发了一个真实的网站。在本课程中,我们不会让人失望。我们将使用 AngularJS 从头开始重建我们在上一门课程中构建的网站,并添加更多功能。
实践项目与学习成果
通过用 AngularJS 构建一个真实用户将实际使用的真实网站,学生们将能够看到我们在本课程中学到的知识的真实世界实际应用。
任何具备 HTML、CSS 和 JavaScript 编程语言基础知识的人都可以学习这门课程,并将他们的技能提升到一个全新的水平。此外,像往常一样,我们会在学习过程中获得一些乐趣。
所以,剩下要做的就是注册课程了。


本节课中我们一起学习了这门 AngularJS 课程的介绍。我们回顾了先修课程的基础,了解了 AngularJS 作为流行框架的地位和本课程的教学方法,并预览了通过真实项目进行实践学习的特色。现在,我们已经为开启 AngularJS 单页应用开发之旅做好了准备。
002:课程介绍与环境搭建 🚀
在本模块中,我们将首先介绍本课程的评分方式。接着,我们会推荐一些参考书籍,并提供获取课程中所有源代码的方法。然后,我们将分别讲解在 Mac 和 Windows 系统上如何搭建开发环境。本模块的核心内容不仅是 AngularJS 的基础知识,更重要的是理解支撑 AngularJS 成为前端 Web 应用优秀解决方案背后的核心概念。要成为一名优秀的软件开发人员(不仅仅是 AngularJS 开发者),你需要理解这些概念。而对于成为一名优秀的 AngularJS 开发者而言,这些概念至关重要,因为它们能帮助你理解该框架所要解决的问题,从而更好地掌握 AngularJS 所提供的解决方案。

课程评分与资源 📚
上一段我们概述了本模块的主要内容,现在让我们首先了解课程的具体安排。
本课程的评分方式如下。
以下是推荐的参考书籍。
此外,课程中出现的所有源代码均可通过指定方式获取。
开发环境搭建 💻
了解了课程的基本信息后,接下来我们需要准备开发工具。本节将指导你完成开发环境的配置。
我们将分别介绍在 Mac 和 Windows 操作系统上的环境搭建步骤。
AngularJS 核心概念介绍 ⚙️
环境准备就绪后,我们将进入本模块的核心部分。本节不仅介绍 AngularJS 的基础,更着重阐述其背后的设计理念。
要理解 AngularJS 为何是一个有效的解决方案,需要先掌握一些关键概念。这些概念是理解框架设计意图的基础。
例如,数据绑定 的核心思想可以用一个简单的公式表示:View ≈ Model,即视图与模型状态保持同步。
另一个重要概念是 依赖注入,它在代码中通常体现为:
app.controller('MyController', ['$scope', '$http', function($scope, $http) {
// $scope 和 $http 服务被注入到此控制器中
}]);
理解这些概念,能让你看清 AngularJS 旨在解决的前端开发中的常见问题,从而更有效地运用其提供的解决方案。
总结 🎯
本节课中,我们一起学习了本课程的评分方式与资源获取途径,完成了 Mac 和 Windows 系统下的开发环境搭建,并初步探讨了支撑 AngularJS 框架的核心概念。理解这些基础概念是后续深入学习 AngularJS 开发的关键第一步。
003:我们需要什么 🛠️


在本节课程中,我们将介绍如何为后续学习设置开发环境。我们将逐一讲解所需的工具和账户,并说明它们的作用。
概述
在本节中,我们将学习搭建一个完整的 AngularJS 开发环境所需的核心组件。这包括代码托管、浏览器、代码编辑器、版本控制工具以及一个能提升开发效率的本地服务器工具。
开发环境组件
以下是构建开发环境所需的各项工具和账户。
1. GitHub 账户
首先,你需要注册一个 GitHub.com 账户。这个过程非常简单,只需提供用户名、密码和电子邮件地址即可完成。
2. 网页浏览器
在后续课程中,你需要使用一个网页浏览器。我将使用 Google Chrome,这是个人偏好的选择,因为它内置了非常先进的、对网页开发很有帮助的 Chrome 开发者工具。但这并非强制要求,你可以使用任何你喜欢的浏览器。
3. 代码编辑器
你需要一个在本课程中使用的代码编辑器。我将使用 Atom 代码编辑器。它是免费的,功能强大,并且拥有大量可以帮助你编码的插件。它实际上也是由 GitHub 开发的。当然,你不必使用它,也可以选择同样是免费的 Visual Studio Code 或 Brackets。Sublime Text 3 也是免费的,但最终会要求你付费。基本上,请选择一个你喜欢并能高效使用的编辑器。
4. Git 版本控制工具
我们需要 Git 工具,原因有几个。第一,如果你想成为一名更专业的网页开发者,至少需要对 Git 有所了解。在本课程中,我们将使用最基础的 Git 命令,不会深入探讨,但你应该至少熟悉并初步接触这些最基础的命令。
5. Browser Sync 工具
我们还将使用一个名为 Browser Sync 的工具。我会展示这个工具的实际功能。它将帮助我们在本地机器上开发时,能够实时地在浏览器中看到我们在编辑器中编写的所有更新,而无需手动点击刷新按钮。Browser Sync 工具实际上是一个 Node.js 组件或包,我们需要安装 Node.js 才能让它工作。
操作系统说明
接下来的几个视频,我将分为 Mac OS 和 Windows 两部分进行讲解。我会在 Mac OS 上解释所有步骤,同样也会在 Windows 上解释所有步骤。因此,请根据你在后续课程中使用的操作系统进行选择。
我本人将使用我的 Mac 电脑进行演示。但如果你使用的是 Windows,请不要担心界面按钮看起来略有不同。除了 Windows 和 Mac OS 在输入两三个命令时存在差异,以及外观略有变化之外,整个课程的其余部分几乎没有区别。在 Windows 或 Mac 上进行网页开发本质上是一样的。




总结
本节课我们一起学习了搭建 AngularJS 开发环境所需的五个核心部分:GitHub 账户、网页浏览器、代码编辑器、Git 工具以及依赖于 Node.js 的 Browser Sync 工具。下一节,我们将开始动手,根据你的操作系统(Mac 或 Windows)来逐步安装和配置这些工具。
004:Mac开发环境设置(第1部分)💻


概述
在本节课中,我们将学习如何在 Mac 电脑上设置开发环境。我们将安装代码编辑器、版本控制工具、JavaScript 运行环境以及一个实用的开发服务器工具。这是开始使用 AngularJS 进行开发的第一步。
安装 Atom 编辑器
首先,我们需要安装一个代码编辑器。我们将使用 Atom 编辑器。
以下是安装步骤:

- 打开 Chrome 浏览器,访问
atom.io网站。 - 点击下载按钮,下载 ZIP 文件。
- 下载完成后,在 Finder 中双击该 ZIP 文件进行解压。
- 将解压出的 Atom 应用文件拖拽到“应用程序”文件夹中。
- 可以删除原始的 ZIP 文件。
现在,让我们验证 Atom 是否安装成功。从启动台(Launchpad)中找到并点击 Atom 图标启动它。首次启动时,系统可能会提示该应用来自互联网,确认打开即可。启动后,关闭欢迎界面,Atom 编辑器就准备就绪了。你可以将其拖入 Dock 栏以便快速访问。

安装 Git
上一节我们安装了代码编辑器,本节中我们来看看如何安装版本控制工具 Git。
在 Mac 上,我们可以通过安装“命令行开发者工具”来获取 Git。
以下是安装步骤:
- 打开“终端”(Terminal)应用程序。
- 在终端中输入以下命令并按回车键:
git --version - 如果系统提示需要安装命令行工具,请点击“安装”按钮并同意许可协议。
- 等待下载和安装完成。
安装完成后,再次在终端中输入 git --version,你将看到类似 git version 2.7.4 的输出,这表明 Git 已成功安装。这个版本对于我们的学习已经足够。如果你想安装最新版本,可以访问 Git 官网下载。
安装 Node.js 和 npm
接下来,我们需要安装 Node.js 运行环境及其包管理工具 npm。它们对于管理项目依赖至关重要。
以下是安装步骤:
- 在浏览器中访问 Node.js 官网的下载页面。
- 选择适用于 Mac 的 64 位安装包(.pkg 文件)进行下载。
- 下载完成后,在 Finder 中双击安装包文件。
- 按照安装向导的提示,逐步点击“继续”,同意许可协议,并最终点击“安装”。
- 系统可能会要求输入管理员密码以完成安装。
安装完成后,我们需要验证安装是否成功,并确保环境变量配置正确。打开终端,输入以下命令:
echo $PATH
检查输出中是否包含 /usr/local/bin 路径,它通常已自动配置好。
现在,让我们验证 Node.js 和 npm 的安装。在终端中分别输入以下两个命令:
node --version
npm --version
你将看到类似 v4.5.0 和 2.15.9 的版本号输出,这表明它们已安装成功。
安装 BrowserSync
最后,我们将安装一个名为 BrowserSync 的开发工具。它可以在你修改代码时自动刷新浏览器,极大提升开发效率。
以下是安装步骤:
- 在浏览器中搜索 “browsersync”,进入其官方网站。
- 官网会给出安装命令:
npm install -g browser-sync。这里的-g参数表示全局安装。 - 复制该命令。
- 回到终端,粘贴命令并执行。如果你不是管理员,可能会遇到权限错误。
- 如果遇到权限问题,可以在命令前加上
sudo以管理员权限运行:sudo npm install -g browser-sync - 输入你的用户密码,等待安装完成。安装过程中可能会出现一些警告信息,但只要最终成功完成即可。
安装完成后,在终端中输入以下命令来验证:
browser-sync --version
如果看到版本号(例如 2.14.0),说明 BrowserSync 已成功安装。


总结
本节课中,我们一起学习了在 Mac 上设置前端开发环境的核心步骤。我们成功安装了 Atom 编辑器、Git 版本控制工具、Node.js 运行环境(包含 npm 包管理器)以及 BrowserSync 开发服务器。现在,你的电脑已经具备了开始进行 AngularJS 应用开发的基本工具。在接下来的课程中,我们将利用这些工具创建我们的第一个项目。
005:Mac开发环境设置(第2部分) 🛠️




在本节课中,我们将继续完成 Mac 开发环境的设置。我们将学习如何配置 GitHub 账户、创建代码仓库、使用 Git 进行版本控制,以及如何利用 GitHub Pages 免费托管我们的网页应用。通过本教程,你将能够将本地代码同步到云端,并实时预览你的网页。
上一节我们安装了必要的开发工具,本节中我们来看看如何配置 GitHub 并建立本地与远程仓库的同步工作流。
创建 GitHub 账户与仓库 🚀
首先,你需要访问 GitHub.com 并创建一个账户。如果你已有账户,请直接登录。
登录后,你需要为整个课程创建一个代码仓库。以下是具体步骤:
- 点击页面右上角你的头像旁边的加号(+)图标。
- 在下拉菜单中选择 “New repository”。
- 为仓库命名,例如
Coursera-test。 - 添加一个简短的描述(可选)。
- 保持仓库为 “Public”(公开),因为私有仓库需要付费。
- 勾选 “Initialize this repository with a README” 选项。
- 点击 “Create repository” 按钮完成创建。
启用 GitHub Pages 🌐
为了让你的仓库能够作为一个网站被访问,我们需要启用 GitHub Pages 功能。
- 在新建的仓库页面,点击右侧的 “Settings” 选项卡。
- 向下滚动到 “GitHub Pages” 部分。
- 在 “Source” 下拉菜单中,选择 “master branch”。
- 点击 “Save” 按钮保存设置。
保存后,页面会显示你的网站发布就绪,并提供一个 URL,格式通常为 https://{你的用户名}.github.io/{仓库名}。你可以点击此链接查看,目前页面是空的,因为我们还没有上传任何网页文件。

克隆仓库到本地 💻
接下来,我们需要将远程仓库复制(克隆)到你的本地电脑上。

- 在仓库页面,点击绿色的 “Code” 按钮。
- 点击链接旁边的剪贴板图标,复制仓库的 HTTPS 地址。
- 打开终端(Terminal)应用程序。
- 使用
git clone命令,后面粘贴你复制的仓库地址。
git clone https://github.com/你的用户名/仓库名.git
命令执行后,你的仓库就会被下载到当前目录。
配置 Atom 编辑器 ⚙️
为了更方便地在本地仓库目录中编辑文件,我们需要配置 Atom 编辑器。
- 打开 Atom 编辑器。
- 点击顶部菜单栏的 “Atom”,选择 “Install Shell Commands”。
- 根据提示输入你的系统密码,完成安装。
安装后,你可以在终端中直接使用 atom . 命令,在当前目录打开 Atom 并将此目录作为项目根目录。
使用 Browser-Sync 实时预览 🔄
在开发过程中,实时看到代码更改的效果非常重要。我们将使用 browser-sync 工具来实现这一点。
首先,确保你位于本地仓库的目录中。然后运行以下命令:
browser-sync start --server --directory --files "**/*"
这个命令会启动一个本地服务器,并监视目录中所有文件的更改。一旦你保存了文件,浏览器中的页面会自动刷新。
使用 Git 管理代码变更 📝
当你对文件进行修改后,需要将这些更改提交到本地仓库,并推送到远程的 GitHub 仓库。
以下是基本的工作流程:
- 检查状态:使用
git status查看哪些文件被修改。 - 添加更改:使用
git add .命令将所有更改标记为待提交。 - 提交更改:使用
git commit -m “提交信息”命令将更改提交到本地仓库。 - 推送到远程:使用
git push命令将本地提交上传到 GitHub。
如果是第一次推送,Git 可能会要求你配置全局用户名和邮箱:
git config --global user.name “你的名字”
git config --global user.email “你的邮箱”
配置完成后,再次执行 git push 即可。
查看在线网站 ✅
完成推送后,你可以再次访问之前 GitHub Pages 提供的 URL。现在,你应该能看到你刚刚创建或修改的网页内容了。对于课程作业,你可以在仓库中创建子文件夹(例如 module1-solution),将作业文件放入其中,并重复上述的 add、commit、push 流程来提交作业。


本节课中我们一起学习了如何设置 GitHub 仓库、启用免费的网页托管服务、将代码克隆到本地、使用编辑器进行开发、通过工具实现实时预览,以及使用 Git 进行版本控制和代码同步。掌握这些步骤后,你就拥有了一个完整的开发、测试和部署环境,可以高效地完成本课程的所有实践项目。
006:Windows开发环境设置(第1部分)🛠️


在本节课中,我们将学习如何在 Windows 操作系统上设置开发环境,为后续的 AngularJS 学习做好准备。我们将安装浏览器、代码编辑器、Git、Node.js 以及一个名为 Browser Sync 的开发工具。

安装浏览器 🌐

首先,你需要一个浏览器来运行和测试你的 Web 应用。虽然你可以使用 Internet Explorer,但本课程强烈推荐使用 Google Chrome。我们将假设你已经知道如何下载和安装 Chrome 浏览器。
安装代码编辑器 📝
接下来,我们需要一个代码编辑器。本课程将演示如何安装 Atom 编辑器。当然,你也可以选择其他你喜欢的编辑器。
以下是安装 Atom 的步骤:
- 访问 Atom 官方网站下载安装程序。
- 运行下载好的安装文件。
- 按照安装向导的提示完成安装。
- 安装完成后,你可以关闭欢迎界面。
现在,Atom 编辑器已经成功安装并可以运行。

安装 Git 🗂️
Git 是一个版本控制系统,对于管理代码非常重要。

以下是安装 Git 的步骤:
- 在浏览器中搜索“download Git”并访问官网。
- 下载适用于 Windows 的最新版本安装程序。
- 运行安装程序,并按照向导进行安装。在安装过程中,建议选择以下选项:
- 在桌面上创建快捷方式图标。
- 选择“Use Git from the Windows Command Prompt”选项,以便在命令行中使用 Git。
- 安装完成后,打开“命令提示符”(CMD),输入以下命令来验证安装是否成功:
如果成功显示版本号(例如git --versiongit version 2.9.3),则说明 Git 已正确安装。
安装 Node.js 和 npm ⚙️
Node.js 是一个 JavaScript 运行时环境,而 npm(Node Package Manager)是它的包管理器。我们需要它们来安装其他工具。



以下是安装 Node.js 的步骤:
- 在浏览器中搜索“download node JS”并访问官网。
- 下载适用于 Windows 64 位的安装程序(.msi 文件)。
- 运行安装程序,接受许可协议,并按照默认设置完成安装。
- 安装完成后,重新打开命令提示符,然后输入以下命令验证 Node.js 安装:
node --version - 接着,验证 npm 是否也已正确安装:
如果两个命令都返回了版本号,说明安装成功。npm --version

安装 Browser Sync 🔄
Browser Sync 是一个开发工具,可以在你修改代码时自动刷新浏览器,提升开发效率。

以下是安装 Browser Sync 的步骤:
- 在浏览器中搜索“browser sync”并访问其官网。
- 官网会显示安装命令。我们需要使用 npm 进行全局安装(
-g参数代表全局安装):npm install -g browser-sync - 在命令提示符中粘贴并运行上述命令。npm 将自动下载并安装 Browser Sync。
- 安装过程可能会有一些警告信息,只要最终完成即可。安装完成后,输入以下命令验证:
如果显示了版本号,则说明 Browser Sync 已成功安装。browser-sync --version

本节课中,我们一起学习了在 Windows 系统上搭建开发环境。我们成功安装了 Google Chrome 浏览器、Atom 代码编辑器、Git 版本控制系统、Node.js 运行时(包含 npm 包管理器),以及 Browser Sync 开发工具。现在,你的开发环境已经准备就绪,可以开始进行 AngularJS 应用开发了。
007:Windows开发环境设置(第2部分)🚀

在本节课中,我们将继续完成 Windows 开发环境的设置。我们将学习如何创建 GitHub 账户、建立代码仓库、启用 GitHub Pages 来托管网站,以及如何使用 Git 命令和 Atom 编辑器进行本地开发与代码同步。
上一节我们安装了必要的开发工具。本节中,我们来看看如何将本地开发环境与 GitHub 连接起来,并创建一个可以实时预览的 Web 项目。
首先,我们需要在 GitHub.com 上创建一个账户。
以下是创建账户的步骤:
- 访问 GitHub.com。
- 选择一个可用的用户名。
- 提供你的电子邮件地址和密码。
- 完成注册。
如果你已有账户,可以直接登录。
登录后,你将看到 GitHub 的主页。新账户的页面可能看起来比较空。

接下来,我们需要为课程创建一个代码仓库,用于存放所有作业。
以下是创建新仓库的步骤:
- 点击页面右上角加号旁边的下拉箭头。
- 选择 “New repository”。
- 将仓库命名为
coursera-test(你可以使用任何名称)。 - 添加描述 “test repo for Coursera”。
- 保持仓库为 “Public”(私有仓库需要付费)。
- 勾选 “Initialize this repository with a README”。
- 点击 “Create repository” 完成创建。
在返回命令行之前,我们需要将此仓库配置为一个可通过网络访问的网站。GitHub 提供了名为 “GitHub Pages” 的功能来实现这一点。

我们需要先启用此功能。请注意,我们当前位于 master 分支。要启用 GitHub Pages,请按以下步骤操作:
- 点击仓库页面右上角的 “Settings”。
- 向下滚动到 “GitHub Pages” 部分。
- 点击 “Source” 下的下拉菜单。
- 选择 “master branch” 作为网站源。
- 点击 “Save”。
保存后,页面会显示你的网站已准备就绪,并提供一个链接。点击此链接可能会看到 404 错误,这是因为仓库中还没有任何网页文件,我们稍后会解决这个问题。

接下来,我们需要将仓库克隆到本地机器。
以下是克隆仓库的步骤:
- 返回仓库主页面。
- 点击绿色的 “Clone or download” 按钮。
- 复制显示的 URL。
- 打开命令行,创建一个项目目录,例如
CourseraProject。 - 进入该目录,执行
git clone <你复制的URL>命令。

克隆完成后,进入新创建的仓库目录(例如 coursera-test)。现在,我们可以从这个目录启动 Atom 编辑器,将其作为项目根目录。


在命令行中,执行 atom . 命令。这将在 Atom 中打开当前目录作为项目文件夹。

现在,我们准备创建第一个 HTML 页面。
在 Atom 编辑器中:
- 右键点击项目文件夹。
- 选择 “New File”。
- 将文件命名为
index.html。 - 在文件中输入基本的 HTML 结构,例如包含标题和 “Hello Coursera” 的 H1 标签。
- 保存文件。
为了能实时看到代码更改在浏览器中的效果,我们将使用 Browser Sync 工具。
返回命令行,在项目目录中执行以下命令来启动 Browser Sync 服务器:
browser-sync start --server --directory --files "**/*"
此命令会启动一个本地服务器,并监控项目目录中所有文件的更改。命令执行后,默认浏览器会自动打开并显示你的 index.html 页面。
现在,你可以将浏览器窗口和 Atom 编辑器并排摆放。在 Atom 中修改 index.html 文件并保存后,Browser Sync 会自动刷新浏览器页面,让你立即看到更改效果。
完成预览后,可以在命令行中按 Ctrl + C 停止 Browser Sync 服务。
接下来,我们需要将本地修改提交到 Git 仓库,并推送到 GitHub。

以下是提交和推送代码的步骤:
- 在命令行中,使用
git status查看文件状态。 - 使用
git add .命令将所有更改标记为待提交。 - 再次使用
git status确认文件已变为绿色(待提交状态)。 - 使用
git commit -m "提交信息"命令提交更改到本地仓库。如果是首次提交,Git 可能会提示你配置用户名和邮箱,按提示执行git config --global user.email "你的邮箱"和git config --global user.name "你的用户名"即可。 - 使用
git push命令将本地提交推送到远程的 GitHub 仓库。系统会提示你输入 GitHub 的用户名和密码。
推送完成后,返回浏览器中的 GitHub 仓库页面并刷新,你应该能看到新提交的 index.html 文件。
最后,再次访问你的 GitHub Pages 网站链接(在仓库设置的 GitHub Pages 部分可以找到)。现在,你应该能看到 index.html 中 “Hello Coursera” 的内容已经成功发布到网上。

今后提交作业时,你只需为每个模块的作业在项目中创建单独的文件夹,然后重复 修改 -> git add -> git commit -> git push 这个流程即可。每次推送后,你的作业网站都会自动更新。



本节课中我们一起学习了如何设置 GitHub 仓库、启用 GitHub Pages、使用 Atom 和 Browser Sync 进行本地开发与实时预览,以及使用 Git 进行版本控制并将代码同步到线上。这套工作流将贯穿整个课程,用于开发和提交你的作业。
008:为什么不保持简单 🧐




在本节课中,我们将要学习为什么在开发 Web 应用时,我们需要引入像 AngularJS 这样的框架,而不是仅仅使用简单的 HTML、CSS 和 JavaScript。理解这个“为什么”能帮助我们更好地掌握后续的复杂概念,并认识到这些框架的价值所在。
在深入探讨 AngularJS 的具体主题,如解释作用域(scope)、服务(services)、指令(directives),以及 Angular 使用的通用编程概念,如模型-视图-视图模型(MVVM)架构之前,退一步思考并理解这种整体方法背后的原因会非常有帮助。当你学习新事物时,越能理解事物为何以特定方式组合在一起,不仅有助于你理解和记住信息,还能让你的努力变得有意义。没有人喜欢做毫无意义的事情,尤其是当事情变得更复杂时,而 Angular 确实可能变得复杂。
但是,如果你知道并理解你所采用方法的价值,你就会对事物有完全不同的看法。你可以欣赏你追求的目标,并判断你离那个目标还有多远。在本课程中,我会逐步解释所有内容,所以你无需过于担心复杂性。然而,了解让事情变得不那么简单的动机仍然很重要。
那么,Angular 和其他此类框架试图实现的目标是什么?为什么不保持简单,为什么要让事情复杂化?
我所说的“保持简单”是什么意思?从之前的课程中,你已经了解了驱动 Web 的核心技术。你知道如何使用 HTML 来构建网页结构,可以使用 CSS 来布局和样式化页面,还可以添加功能使页面变得生动,例如从服务器或 Web 上的其他服务请求数据。那么,问题来了:为什么还要添加其他东西?
让我们看看随着项目不断添加更多功能,你的项目代码库会发生什么变化。显然,随着功能的增加,代码库也会随之增长。最重要的是,承载功能的 JavaScript 代码在规模和复杂性上都会增长。即使规模不变,复杂性也肯定会增加。
因此,对于最初问题的直接答案是:我们开发者并不是在引入所有这些复杂的框架和编码方法,从而使事情变得复杂。恰恰相反,如果没有这些方法,我们的代码本身就会变得复杂。这些框架和方法的存在是为了帮助我们处理这种复杂性,并将其控制在可控范围内。
问题在于,我们如何在一定程度上控制规模,但更重要的是,如何控制代码库的复杂性?控制复杂性意味着什么?我们试图实现什么?换句话说,我们希望代码具有哪些特性,才能认为它不那么复杂且相对容易处理?让我们回顾一些基本特性。
以下是构建可维护代码时需要考虑的几个关键特性。
- 良好的代码组织:我们需要某种方式来组织代码的不同部分,以便快速找到负责特定功能的代码。请记住,这不仅是为了你自己,还需要以尽可能清晰的方式组织代码,以便你的团队成员理解。这里有一个很好的经验法则:考虑让一个全新的开发者加入团队需要什么。向新开发者解释功能代码在代码库中的位置是否相当直接明了?
- 可维护性与低耦合性:应用程序的需求总是在变化。变化可能源于客户需要新功能、业务规则改变,或者因为某些需求在开发后期才被正确理解。因此,我们需要提前为此做好准备。如果某个业务规则发生变化,我们不希望重写整个应用程序。事实上,我们希望尽可能少地更改代码,以集成新的变更,并让其余代码甚至察觉不到任何变化。
- 代码可重用性:多次编写相同的功能会极大地浪费时间,尤其是在同一个应用程序中。然而,比最初重复花费时间更糟糕的是,想想当我们需要更新该功能时会发生什么?我们不得不在多个地方进行更新。当然,这种情况很容易导致简单的错误,并在代码中引入难以追踪的缺陷。因此,当我们开始思考如何编写某个功能时,至少应该花一点时间思考是否需要让代码更通用,并以一种可以在应用程序中多个地方使用的方式来编写它。
- 代码可测试性:最后,你如何确信你的代码确实在工作?答案是测试。但如何测试?你可以启动整个系统,看看代码是否“一切正常”。但如果你只是编辑了几个小函数,甚至还没有完成整个功能的编码,这是否是高效和合理利用时间的方式?可能不是。相反,你需要以这样一种方式编写解决方案:可以独立测试其中的小部分,而无需部署整个 Web 应用程序。这意味着在编码时,你必须牢记这个想法,并将功能分离成更小、可测试的组件。
上一节我们探讨了为什么代码会自然变得复杂,以及我们希望代码具备哪些特性来对抗这种复杂性。本节中,我们来总结一下引入额外技术和方法的真正目的。
总而言之,额外技术和方法的目的不是让事情变得更复杂,而是为了处理随着功能增长,代码本身带来的固有复杂性。
我们还讨论了拥有易于处理的代码意味着什么。首先,我们需要良好的代码组织,以便能够快速找到需要处理的相关代码。其次,更新部分代码不应同时影响其他部分。这将允许我们编写更小的代码块,而无需在每次更新小东西时处理整个系统。我们还希望编写可重用的代码,不希望两次编写相同的代码,当然也不希望在多个地方调试它。
最后,我们需要能够编写可测试的代码,即编写足够小的代码块,使其易于测试,而无需一次性处理整个系统。




009:为什么代码会变得复杂 🎼


在本节课中,我们将要学习为什么代码会变得复杂,特别是探讨导致代码失去我们之前讨论过的良好特性的原因。我们将重点关注两个核心概念:高内聚和低耦合。
代码复杂化的原因
代码变得复杂的原因有很多。其中一个原因是人们编写了难以阅读的代码。
以下是导致代码难以阅读的几个具体表现:
- 糟糕或不一致的编码风格:例如,在文件的一部分使用一种编码方式,在另一部分使用另一种方式,空格使用混乱。
- 难以理解的函数或变量名:使用含义模糊或过于复杂的命名。
- 缺乏注释或API文档:代码没有足够的说明来解释其功能。
难以阅读的代码问题相对容易解决,只需根据某种风格指南重写或格式化代码即可。
然而,有一个更棘手的问题,这也是软件工程领域多年来一直试图解决的难题,那就是缺乏高内聚和低耦合。
理解高内聚与低耦合
上一节我们介绍了代码复杂化的表面原因,本节中我们来看看更深层次的设计原则问题。
什么是高内聚?
高内聚是指在某个代码边界内,小的功能片段彼此之间紧密相关。
这里提到“代码边界”有几个原因:
- 在不同编程语言中,这个原则所指的边界可能不同,可能是类、模块或函数。
- 更重要的是,这取决于你的设计目标。你可以为整个模块设计高内聚,也可以为一个简单的函数设计高内聚,这取决于你关注代码的哪个层面。
简单来说,高内聚衡量的是“一个事物是否专注于做好一件事”。
一个高内聚的功能块可以调用其他函数来实现目标。关键在于,所有这些被调用的函数都与主功能的目标高度相关,它们共同完成同一件事,只是分工不同。
让我们看一个有趣的例子。比较我的每日日程和我的电脑(Mac)的“日程”:
- 我的日程:包含起床、刷牙、喝咖啡、编码、工作、吃晚餐、制作Coursera课程、吃夜宵饼干、减肥等。这些事项彼此之间存在冲突(比如吃饼干和减肥),并非100%相关,因此内聚性低,处理起来更复杂。
- 电脑的日程:非常简单:闪灯、运行、睡眠、关机。它只做几件高度相关的事,因此内聚性高。
低内聚正是使人类日程(以及类似结构的代码)变得复杂的原因。
什么是低耦合?
低耦合意味着一个组件对另一个组件的依赖尽可能少。
在软件中实现低耦合是好事,它能显著降低开发的复杂性。因为当你处理一个松散耦合的小部件时,你无需担心系统的其余部分。
最简洁的说法是:在软件组件中,如果更改一个组件时,不需要更改另一个组件,那么它们就是低耦合的。
例如,假设有一个运行良好的购物车组件,然后你添加了一个处理信用卡支付的组件。如果这两个组件是松散耦合的,那么当我们改变信用卡处理方式时,购物车代码完全不需要改变。它不关心信用卡处理组件内部如何工作,只需要能够调用它即可。
现实生活中的例子:更换iPhone电池是一个相当复杂的过程(紧密耦合),而更换LG G5电池则很简单,只需按一下按钮,电池弹出,换上新电池即可(松散耦合)。
在软件中的体现
那么,这些概念如何在软件中体现?具体到我们的目的,它们如何在Web应用软件中体现?
在本次讲座的第二部分,我们将通过一个简单的示例来具体观察这些概念。
总结


本节课中我们一起学习了代码复杂化的原因。我们了解到,除了难以阅读的代码外,更深层次的问题是缺乏良好的设计原则,即高内聚和低耦合。高内聚要求一个代码单元专注于做好一件事,而低耦合要求不同单元之间的依赖最小化。遵循这些原则是编写易于维护和扩展的软件的关键。
010:为什么代码会变得复杂


在本节课中,我们将要学习为什么软件会随着增长而变得复杂。我们将通过一个具体的网页应用示例,分析低内聚和紧耦合这两个核心问题,并理解它们如何影响代码的可维护性和可扩展性。
一个设计糟糕的示例
为了展示一个低内聚且紧耦合的糟糕设计示例,我们来看一个简单的应用。当你在输入框中输入名字时,应用会根据每个字符的 ASCII 码值,实时计算并显示你名字的总数值。随着你输入或删除字符,这个数值会动态更新。

让我们来看看这个功能是如何实现的。我们的应用包含两个文件:index.html 和 app.js,它们位于 Git 仓库示例文件夹的 lecture 02 目录中。
在我们的 HTML 文件中,需要关注的主要是两个标签:一个是 ID 为 name 的输入框,我们将为其附加一些行为;另一个是 ID 为 output 的占位符标签,用于显示结果。
分析 JavaScript 代码
现在,我们跳转到 app.js 文件。首先,我们定义了一个名为 student 的对象字面量,其 name 属性初始为空字符串。
接下来的代码主要设置了几个事件监听器。第一个监听器监听 DOMContentLoaded 事件,当整个页面加载完毕并实例化在浏览器内存中时,会触发其回调函数。这个函数会找到 ID 为 name 的输入框元素,并为其设置一个 keyup 事件监听器。
keyup 事件触发时,会调用 calculateNumericOutput 函数来计算输入框中文本的数值。
深入 calculateNumericOutput 函数
这个函数首先从 HTML 输入框中获取值,并将其存入 student.name 属性。接着,它遍历这个名字字符串,累加每个字符的字符编码值。然后,它生成一个输出字符串,例如“total numeric value of a person's name is: 123”。最后,它找到 ID 为 output 的元素,并将其 innerText 属性设置为这个输出字符串。
那么,这段代码有什么问题呢?实际上,问题不少。
紧耦合的问题

首先,index.html 文件负责展示内容,属于表现层。而 HTML 和 CSS 的核心职责是呈现,不应包含功能逻辑。如果我们将功能逻辑与表现层紧密绑定,就会产生紧耦合。

例如,如果设计师决定将输入框的 ID 从 name 改为 fullName,我们的应用就会崩溃。因为 app.js 中的代码正试图将 keyup 行为附加到 ID 为 name 的元素上。这个改变会导致事件监听器失效,用户输入时不再有任何反应。
这就是紧耦合的典型表现:我们的应用功能代码(JavaScript)过度依赖于表现层代码(HTML)的具体实现细节。这不仅限于 name 这个 ID,output 这个 ID 也存在同样的问题。
低内聚的问题
接下来,我们看看 calculateNumericOutput 函数。一个函数应该只做一件事,或者处理紧密相关的事情。但仔细观察这个函数,它做了许多看似完全不相关的事情:
- 从 HTML 中获取输入值(与表现层紧耦合)。
- 计算字符串的数值总和(核心计算逻辑)。
- 将结果格式化并插入到 HTML 文档中(再次与表现层交互)。
这个函数承担了过多不相关的职责。更好的设计应该是:获取输入字符串的功能独立出来,计算数值的功能独立出来,将结果插入页面的功能也独立出来。calculateNumericOutput 应该只负责接收一个字符串并返回其计算后的数值。
虽然在这个小应用中问题不大,但这在微观上是一个低内聚的典型例子:同一个函数内包含了彼此关联度不高的功能。
课程总结

本节课中我们一起学习了软件复杂度增长的一个主要原因。
我们讨论了高内聚的重要性,即一个模块或函数应专注于完成单一、紧密相关的任务。我们也探讨了松耦合,即一个组件的改变不应直接影响另一个组件。

通过一个真实的网页应用示例,我们看到了紧耦合的危害:当表现层(HTML)的改动直接迫使功能层(JavaScript)也必须改动时,代码就变得脆弱且难以维护。同时,我们也看到了低内聚的例子:一个函数混杂了多个不相关的职责。
优秀的软件设计追求高内聚和松耦合。在后续课程中,你将看到 AngularJS 如何帮助我们实现这两个目标,从而构建更清晰、更易维护的应用程序。
011:模型-视图-视图模型(MVVM) 🧩


在本节课中,我们将要学习一种名为“模型-视图-视图模型”(MVVM)的软件设计模式。这种模式是构建用户界面(UI)及其相关功能的常用方法,其核心目标是实现代码的高内聚和低耦合。
如何实现高内聚与低耦合? 🤔
上一节我们介绍了高内聚和低耦合的概念。那么,我们如何神奇地实现这两个目标呢?
希望你已经开始明白,我们不仅需要考虑代码要实现什么功能,还需要思考如何组织代码结构。我们需要将代码分解成几个独立的功能模块,让它们协同工作,以实现所需的业务逻辑。我们需要一个一致的方法,但具体采用哪种方法呢?
我们可以从头开始设计架构,并尝试实现它,以验证其能否适应代码库的增长。如果你从事研究工作,可能会这样做。然而,如果你希望尽快实现功能,你会向更有经验的人寻求针对你特定情况的建议。
幸运的是,由于类似的情况反复出现,开发者们多年来已经为这些情况总结出了一套“现成”的解决方案。这些方案的具体实现在不同框架之间,甚至不同开发者之间可能有所不同,但如何构建此类解决方案的总体思路是相当标准的。
这些开发者们为符合特定模式的问题所提出的设计和架构解决方案,被称为设计模式。
什么是设计模式? 🏗️
在非标准情况下,你通常需要一些经验来识别你试图解决的问题是否符合某个我们已有现成解决方案的模式,即一个设计模式。
然而,在相当常见的情况下,比如设计一个带有某些功能的用户界面,开发者们会使用标准的设计模式。在我们的案例中,就有这样一个模式,它被称为模型-视图-视图模型(MVVM)。
MVVM 架构的关键组件 🔑
接下来,我们来看看模型-视图-视图模型(MVVM)架构的一些关键组件。
模型(Model)
第一个组件是模型。它代表并持有原始数据。这些数据可能直接来自数据库,或来自服务器的 REST API 调用。
- 这些数据中的一部分可能会以某种形式在视图中显示。
- 但这并不意味着所有数据都会被显示,甚至可能没有任何数据被直接显示。
- 很可能,你会从服务器获取一些值,对它们进行相加或其他操作,然后将操作结果显示给用户。
以下是模型组件的一些关键点:
- 此组件还可以包含从某个源(例如,通过向服务器发起 AJAX 调用)检索数据的逻辑。
- 关于此组件,需要记住的最重要一点是:它不包含任何与显示实际模型相关的逻辑。它不知道数据将如何被显示,也不知道谁负责显示它,它只包含数据。
视图(View)
另一个组件是视图。视图相当简单,它就是用户界面。在 Web 应用中,视图就是 HTML 和 CSS。
- 它只显示提供给它的数据,从不更改这些数据。
- 此外,它以声明式的方式广播事件,但从不自己处理这些事件。
这里“声明式”意味着你无需编写任何 JavaScript 代码来触发或引发视图中的事件。视图只是声明性地指出:如果发生此类事件,我们应该去哪里处理它,并指向我们即将讨论的视图模型。
视图模型(ViewModel)
下一个组件是我们的视图模型。视图模型是视图状态的表示。它是代表视图的数据模型,可以看作是视图的“背面”。
- 它持有视图中显示的数据。
- 它响应事件,换句话说,它执行表示逻辑。
- 如果存在某些业务逻辑(如数据处理),它通常会调用其他功能来完成。
关于此组件,需要记住的最重要一点是:它从不要求视图显示任何内容。这意味着它从不直接操作 DOM。它从不调用 getElementById,甚至一开始就不知道 HTML 中存在哪些 ID,它也不关心这些。这实现了我们之前提到的视图(HTML 和 CSS)与视图模型(支持该视图表示逻辑的 JavaScript)之间的低耦合。
声明式绑定器(Declarative Binder)
这个设计模式还有一个至关重要的组件,那就是声明式绑定器。
- 声明式绑定器以声明方式将视图模型的模型绑定到视图。
- 它是我们刚刚讨论的视图模型与包含 HTML 和 CSS 的视图之间的“粘合剂”。
这里的“声明式”意味着你无需为此编写任何代码。这就是框架发挥作用的地方,框架为你完成了这个“魔法”。
- 这个组件,即声明式绑定器,是整个 MVVM 模式的关键推动者。没有它,你将不得不手动编写所有这些绑定,整个模式也就无从谈起。
MVVM 架构示意图 📊
让我们来看一个示意图。
我们知道有我们讨论过的这三个组件:视图、视图模型和模型。
- 视图是我们的 UI 表示。
- 视图模型是我们的表示逻辑。
- 在 Web 应用中,视图使用 HTML 和 CSS 实现,而视图模型和模型则使用 JavaScript 实现。
通常的过程是:表示逻辑(视图模型)向模型请求一些业务数据或某种数据。模型将该数据返回给视图模型。此时,视图模型将该数据绑定到视图内部的某些组件或元素上。这种数据绑定以声明方式发生,这意味着你实际上并不操作视图内部的任何东西,你不需要为了在视图中显示视图模型的数据而去操作 DOM。
AngularJS 的灵活性 💪
AngularJS 足够灵活,能够认识到模型-视图-视图模型(MVVM)并非放之四海而皆准的解决方案。由于框架不希望将开发者限制在单一方法内,AngularJS 的某些特性允许你脱离 MVVM 模式,采用你认为最适合当前情况的任何其他模式。这甚至到了允许你在 JavaScript 代码中直接操作 DOM 的程度。
这使得该框架极具适应性,因为几乎总有一种简单的方法可以将其他 JavaScript 库集成到其中。例如,如果你想将 AngularJS 与 D3.js(一个非常流行且功能强大的图形库)一起使用,你可以轻松做到。

正因为如此,Angular 有时也被称为 模型-视图-任意 或 Mv*,因为它不限制你使用 MVVM 模式,而是让你可以选择使用它,也可以选择不使用。
总结 📝
本节课中我们一起学习了以下内容:

- 设计模式是针对常见软件开发问题的现成解决方案。
- 我们将要使用的一种设计模式叫做 MVVM,它代表模型-视图-视图模型。这是一种用于将 UI 与某些功能结合在一起的常见设计模式。
- 该模式中的模型代表并持有原始数据。
- 该模式中的视图是用户界面,它从不更改数据,并声明事件但不实际处理它们。
- 视图模型是用户界面状态的数据表示。
- 该设计模式的最后一部分是声明式绑定器,它以声明方式将视图模型绑定到视图。
- AngularJS 足够灵活,不局限于 MVVM,而是允许根据具体情况和开发者的选择使用其他设计模式。
- 总的来说,包括 MVVM 在内的设计模式的目标,是在接下来的几讲中实现高内聚和低耦合。我们将使用 AngularJS 重新实现同一个名称计算器示例,同时更好地实现高内聚和低耦合的目标。
012:AngularJS 安装和第一个简单应用 🚀


在本节课中,我们将学习如何将 AngularJS 库引入到 HTML 页面中,并编写我们的第一个 AngularJS 应用程序。我们将从下载 AngularJS 开始,逐步创建一个简单的应用,并理解其核心组件(如模块和控制器)是如何与 HTML 视图绑定的。
下载 AngularJS 库
首先,我们需要获取 AngularJS 库文件。请访问 AngularJS 官方网站(angularjs.org),进入下载页面。
以下是具体步骤:

- 确保选择 AngularJS 1.x 版本,而不是 2.x 或更高版本。
- 在提供的文件列表中,找到并下载
angular.min.js文件。这是经过压缩的版本,有助于节省带宽。 - 将下载的
angular.min.js文件保存到你的项目文件夹中(例如lecture04文件夹)。
创建项目文件结构
在项目文件夹中,我们至少需要两个文件:
angular.min.js: 我们刚刚下载的 AngularJS 库文件。app.js: 一个空的 JavaScript 文件,用于编写我们自己的应用逻辑。

在 HTML 中引入脚本
接下来,我们需要在 HTML 文件中引用这两个 JavaScript 文件。顺序很重要:必须先引入 AngularJS 库,再引入我们自己的应用逻辑文件。
<script src="angular.min.js"></script>
<script src="app.js"></script>
这样,app.js 中的代码就可以使用 AngularJS 提供的功能了。
编写第一个 AngularJS 应用
现在,让我们开始编写 app.js 文件中的代码。
1. 使用 IIFE 和 use strict
首先,我们使用一个立即调用函数表达式(IIFE) 来包裹所有代码,并启用 "use strict" 模式。这可以防止变量意外泄露到全局作用域,并帮助我们避免一些常见的编码错误。
(function () {
'use strict';
// 我们的 AngularJS 代码将写在这里
})();
2. 定义 AngularJS 模块
AngularJS 在全局作用域中暴露了一个名为 angular 的对象。我们通过调用 angular.module 方法来定义一个模块。模块是 AngularJS 应用的容器。
angular.module('MyFirstApp', []);
'MyFirstApp': 我们给这个应用模块起的名字。[]: 一个空数组,表示这个模块当前不依赖任何其他模块。
3. 将模块绑定到 HTML 视图
为了让 AngularJS 管理我们的 HTML,需要在 HTML 标签上使用 ng-app 指令,并将其值设置为我们的模块名。
<html ng-app="MyFirstApp">
这行代码告诉 AngularJS,从 <html> 标签开始,整个页面都由名为 "MyFirstApp" 的模块来管理。
4. 创建控制器
控制器是视图模型(ViewModel) 的具体实现,负责管理与之关联的那部分视图的数据和逻辑。我们通过链式调用在模块上定义控制器。
angular.module('MyFirstApp', [])
.controller('MyFirstController', function () {
// 控制器的逻辑将在这里定义
});
'MyFirstController': 控制器的名称。function () { ... }: 一个函数,用于定义该控制器的行为。
5. 将控制器绑定到 HTML 视图
在 HTML 中,我们使用 ng-controller 指令将控制器绑定到某个具体的 DOM 元素上。
<div ng-controller="MyFirstController">
<!-- 这个 div 区域由 ‘MyFirstController’ 控制 -->
</div>
现在,这个 <div> 元素及其内部内容就与 MyFirstController 控制器关联起来了。AngularJS 会自动建立视图(HTML)和视图模型(控制器函数)之间的绑定。
核心概念回顾与总结
本节课我们一起学习了搭建第一个 AngularJS 应用的基础步骤:
- 下载与引入: 获取
angular.min.js并在 HTML 中正确引入。 - 模块化: 使用
angular.module('模块名', [])创建应用的核心容器。 - 视图绑定: 通过
ng-app="模块名"指令将模块作用域绑定到 HTML。 - 控制器: 使用
.controller('控制器名', function(){...})定义视图模型,并通过ng-controller="控制器名"将其与页面的一部分视图关联。
我们使用了 IIFE 和 "use strict" 来保证代码的严谨性,防止污染全局命名空间。此时,虽然控制器函数内部还是空的,但 AngularJS 的数据绑定框架已经就绪。


在下一讲中,我们将深入探索控制器如何具体地影响和更新视图,让我们的应用真正“动”起来。
013:通过作用域与视图共享数据 🎯


在本节课中,我们将学习 AngularJS 中一个核心概念:作用域(Scope)。我们将探讨如何利用 $scope 对象在控制器(视图模型)和视图(HTML 页面)之间共享数据,实现双向数据绑定。
显然,如果我们的 Angular 应用只能神奇地将 JavaScript 连接到 HTML 页面,而无法进行其他操作,那将非常乏味。因此,我们确实需要一种机制,让我们的视图(即 index.html)和包含我们视图模型的 AngularJS 能够共享数据。
我现在位于 lecture05 文件夹中,该文件夹在 fullstack-course5/examples 目录下。我已经设置好了 Browser Sync,所以它正在显示我们正在查看的页面,即 index.html。
首先,让我们给页面一个更好的标题。我们使用一个 <h1> 标签,并写上“我的第一个 AngularJS 应用”,这样我们就能在页面上看到一些实际内容了。
接下来,我们将使用 AngularJS 提供的一个特殊对象,在视图模型和视图之间共享数据。这个特殊对象叫做 $scope。在 AngularJS 中,当你看到变量名前面带有美元符号($)时,这意味着这是 Angular 保留或提供的东西。虽然没有什么能阻止你用自己的变量名使用 $,但约定俗成的是不要这样做,因为 Angular 将 $ 前缀保留给其内置的功能。
这个 $scope 对象(我们稍后会讨论它如何出现)可以定义一些属性,这些属性将暴露给视图。
例如,在控制器中,我可以这样写:
$scope.name = ‘Yaakov’;
现在,name 属性就存在于 $scope 上了。而这个 $scope 在我的 ng-controller 指令内部或由其控制的任何元素中都是可用的。
因此,我可以在 HTML 中使用一个表达式(用双花括号 {{ }} 表示)来输出这个值。如果我在这里写下 {{ name }},由于 name 存在于 $scope 上,并且 $scope 在此处定义,Angular 框架会假定它应该在 $scope 上查找这个值。保存后,你会看到“Yaakov”突然显示在我的页面上,因为它查找了绑定到这个特定控制器(视图模型)的 $scope,并将其显示在这两个 div 之间。
name 的值并不是我们唯一能引用的东西。我们可以引用 $scope 上的任何内容,并将其暴露给我们的 index.html。
例如,我可以在控制器中创建一个函数:
$scope.sayHello = function() {
return ‘Hello, Coursera!’;
};
然后,我可以回到 index.html,在表达式中调用这个函数:{{ sayHello() }}。同样,Angular 会假定这是 $scope 上的内容。调用后,页面上就会显示“Hello, Coursera!”。
一个更有趣的例子是,我可以声明式地将一段 HTML 绑定到我的 $scope 上。
让我们删除之前的表达式,在这里放一个输入框。我将使用一个特殊的 Angular 属性 ng-model 来告诉 Angular,我希望这个输入框的值始终等于 $scope 上的某个属性,比如 name。现在,它会假定 $scope 上有一个叫 name 的属性。回到控制器查看,确实存在 $scope.name = ‘Yaakov’。保存 HTML 后,你会看到输入框中显示了“Yaakov”,并且这个值被绑定到了输入框的 value 上。
实际上,如果我们更改输入框中的内容,这实际上是在改变附加在我们 $scope 的 name 属性上的值。一个快速验证的方法是,不仅将 name 属性绑定到这个输入元素,还在它旁边输出它。
例如,在输入框后面写上“我的输入是:{{ name }}”。保存后,你会看到“我的输入是:Yaakov”。当我们改变输入框的内容时,由于这个变量完全绑定到了我们 $scope 的 name 属性上,一旦我们改变它(输入框正在做这件事),Angular 会查找 $scope 上 name 属性的任何变化,并自动更新这个表达式的值。所以,当我输入“Coursera”时,你可以看到通过表达式输出的值也在实时更新。


在本节课中,我们讨论了如何通过 $scope 对象 在控制器(视图模型)和视图(index.html)之间绑定和共享数据。我们学习了如何将数据属性(如 $scope.name)和函数(如 $scope.sayHello)暴露给视图,并使用 {{ 表达式 }} 在 HTML 中显示它们。更重要的是,我们利用 ng-model 指令 实现了输入元素与 $scope 属性的双向数据绑定,使得视图的更改能自动同步到模型,反之亦然。这是构建动态、响应式 AngularJS 应用的基础。
014:在AngularJS中实现名称计算器示例 🧮


概述
在本节课中,我们将回顾最初编写的应用程序,并使用 AngularJS 重写整个程序。我们将看到模型-视图-视图模型(MVVM)架构的概念在我们的代码中生动地体现出来。
项目初始化与设置
上一节我们介绍了 AngularJS 的基本概念,本节中我们来看看如何搭建一个具体的应用。
目前,我位于编辑器中的 lecture 06 文件夹,该文件夹位于 fullstack-course5-examples 目录下。可以看到,我已经在 HTML 文件中进行了一些设置,并且浏览器已经可以显示我的 HTML 页面。
现在,让我关闭文件浏览器,专注于编辑器。可以看到,我们已经在此处设置了 AngularJS,并且在 app.js 中也进行了配置。其余的 HTML 内容目前相当简单,只有一个 <h1> 标题“Name Calculator”和一个作为输入字段的文本框。
创建 AngularJS 模块和控制器
接下来,我们转到 app.js 文件并启动我们的应用程序。
我已经为自己预设了立即调用函数表达式(IIFE)并使用了严格模式,以避免犯错。首先,我要创建一个 Angular 模块。创建模块的方法是调用 angular.module,我们将其命名为 NameCalculator。
angular.module('NameCalculator', []);
至于依赖项,它没有任何依赖,所以我们将其留空。
然后,我们将创建控制器,并将其命名为 NameCalculatorController。负责该控制器的函数将在此处定义。
angular.module('NameCalculator', [])
.controller('NameCalculatorController', function($scope) {
// 控制器逻辑将在这里编写
});
我们在此处指定了 $scope,这个对象将用于与我们的视图(即 index.html)进行通信。到目前为止,我们已经声明了模块和控制器。
将模块和控制器绑定到视图
现在我们需要做的是将它们连接到我们的视图 index.html 中。
我们指定 ng-app 的地方是整个应用程序的入口点。只要它在我们将要绑定的控制器之上,并且包含我们将要处理的一大块 HTML,具体放在哪里并不重要。通常我们可以将其放在 <html> 标签上,但这次让我们稍微改变一下,将其放在 <body> 标签本身。
<body ng-app="NameCalculator">
现在,NameCalculator 这个模块已经绑定到 <body> 标签,因此它控制从 <body> 开始到结束的所有内容。
在这里,我们有几个 <div> 标签。我们将 ng-controller 绑定到其中一个上。
<div ng-controller="NameCalculatorController">
编辑器在这里给了我一些提示。保存后,应该没有错误。现在我们已经将两者绑定在一起了。
在控制器和视图之间共享数据
我们希望在我们的控制器和视图之间共享一些数据。一是要输入姓名的人的名字,二是要显示的初始值。
因此,我们在控制器中定义 $scope.name 并初始化为空字符串,同时定义 $scope.totalValue 并初始化为 0。
$scope.name = "";
$scope.totalValue = 0;
保存后,我们现在可以直接在 HTML 页面上输出这些值。转到 index.html,我们可以这样写:
<p>Total numeric value of person's name is: {{totalValue}}</p>
一旦我们这样做,totalValue 现在显示为 0,因为它从我们的 $scope 中获取了这个 0。
我们还没有真正绑定 name,所以现在让我们来做这件事。在 HTML 中,我们希望将 name 绑定到输入元素。我们通过 ng-model 指令来实现这种绑定。
<input type="text" ng-model="name">
随着输入框中的名字变化,我们 $scope 对象中的 name 也会随之改变。如果我们想验证这一点,可以快速在这里再放一个值 {{name}},然后当我们在这里输入时,该值将显示在总数值下方。验证完毕后,我们可以移除它。
实现动态计算功能
我们需要某种方式让控制器知道,每次用户按下按键时,我们都应该查看模型(即我们的 name),重新计算总值,并通过更新 $scope 来输出这个总值。
我们可以通过 ng-keyup 指令来实现。我们将绑定一个作用域上的函数,我们称之为 displayNumeric。
<input type="text" ng-model="name" ng-keyup="displayNumeric();">
保存后,由于我们尚未定义此函数,因此不会发生任何事情。现在让我们转到 app.js,在 $scope 上定义 displayNumeric 函数,使其对视图可见。
$scope.displayNumeric = function() {
var totalNameValue = 0; // 获取总值
// 计算逻辑将放在这里
$scope.totalValue = totalNameValue;
};
剩下的就是实际计算它。让我们编写一个函数来完成这个任务。我们将编写一个常规函数,称之为 calculateNumericForString,并传递一个字符串给它。
var calculateNumericForString = function(string) {
var totalStringValue = 0;
for (var i = 0; i < string.length; i++) {
totalStringValue += string.charCodeAt(i);
}
return totalStringValue;
};
这段代码所做的是:从 0 开始,遍历字符串,对于字符串中的每个位置,找出其字符代码,将其加到总值中,然后返回该总值。
现在,我们只需要在 displayNumeric 函数中调用它,而不是使用 0。我们将调用该函数,并将 $scope.name 传递给它,因为这将随着我在这里输入内容而更新。

$scope.displayNumeric = function() {
var totalNameValue = calculateNumericForString($scope.name);
$scope.totalValue = totalNameValue;
};
一旦我们计算出该数值,它将保存在 totalNameValue 变量中,然后我们将其复制到 $scope 中,这将自动更新视图中的 totalValue。
保存后,现在如果我们开始在这里输入,例如输入我的名字,你会看到随着我输入或删除内容,数值会自动变化。
设计视角的代码回顾
让我们从设计角度再看一下我们的代码。在我们的 app.js 中,我们从未真正触及视图,也没有尝试弄清楚视图是如何实现的。视图中甚至没有任何 ID。
我们所做的是声明性地说明这个输入字段应该绑定到视图模型(即我们的控制器)中 $scope 上的 name。每当有事件发生(即有人按下或释放按键)时,它应该调用这个同样绑定到我们视图模型(即控制器,特别是 $scope)的函数。
这为我们的视图或 HTML 提供了很大的灵活性。我们可以随意改变整个结构,而功能完全不受影响。换句话说,无论你最终如何显示该名称的数值,或者如何从用户那里获取名称,app.js 中的代码都将保持不变。

总结
本节课中,我们一起学习了如何使用 AngularJS 重构一个简单的名称计算器应用。我们实践了创建模块和控制器、进行数据绑定、以及通过指令响应用户事件。核心在于理解了 MVVM 模式如何通过 $scope 实现模型与视图的分离,从而提高了代码的灵活性和可维护性。
015:自定义HTML属性 🧙♂️


在本节课中,我们将揭开 AngularJS 框架中“魔法”的面纱,探究像 ng-app 这样的自定义属性是如何被识别和处理的。我们将通过简单的 JavaScript 示例,理解 AngularJS 在幕后是如何找到并绑定这些属性的。
概述
上一讲我们介绍了 ng-app 和 ng-controller 如何与 JavaScript 模块和控制器进行链接绑定。对于初学者来说,这种自动绑定可能显得有些神秘。本节我们将退后一步,通过基础的 JavaScript 操作,来理解 AngularJS 实现这种“魔法”的基本原理。
自定义HTML属性初探
首先,我们来看一个非常简单的 HTML 页面。它包含一个 div 元素,其 id 为 “target”。
<body>
<h1>示例页面</h1>
<div id="target">
<section>...</section>
<article>...</article>
</div>
</body>
我们可以使用 JavaScript 轻松地选中这个元素。
var element = document.getElementById('target');
console.log(element);
执行上述代码,我们可以在控制台看到选中的 div 元素及其所有子结构。
非标准属性的行为
现在,让我们尝试给这个 div 添加一个非标准的 HTML 属性,例如 greeting="hello"。
<div id="target" greeting="hello">
...
</div>
保存后,页面看起来没有任何变化。这是因为浏览器无法识别 greeting 这个非标准属性,所以选择忽略它。然而,JavaScript 提供了方法来获取这些自定义属性及其值。
var attrValue = element.getAttribute('greeting');
console.log(attrValue); // 输出:hello
通过 getAttribute 方法,我们成功获取了 greeting 属性的值 “hello”。这说明,任何设置在元素上的属性,我们都可以通过 JavaScript 访问到。
模拟 AngularJS 的查找机制
接下来,我们将 greeting 属性替换为 AngularJS 风格的 ng-app 属性。
<div id="target" ng-app="myTestApp">
...
</div>
此时,ng-app 属性本身在这个没有加载 AngularJS 的页面中并不起作用,但我们依然可以获取它的值。
var ngAppValue = element.getAttribute('ng-app');
console.log(ngAppValue); // 输出:myTestApp
现在,假设我们是 AngularJS 框架的作者,我们需要在页面中找到所有带有 ng-app 属性的元素,以便将模块与它们绑定。我们可以使用 document.querySelector 方法来实现。
var element2 = document.querySelector('[ng-app]');
console.log(element2);
console.log(element2.getAttribute('ng-app')); // 输出:myTestApp
代码 [ng-app] 是一个 CSS 属性选择器,它会返回页面上第一个拥有 ng-app 属性的元素。一旦我们获得了这个元素,我们就获得了操作它的入口。在 AngularJS 的实际运行中,框架会找到这个元素,读取 ng-app 的值(例如 “myTestApp”),然后在已定义的 JavaScript 模块中寻找同名的模块,最终将两者绑定起来。ng-controller 等指令的工作原理也与此类似。
关于 HTML5 自定义数据属性
最后需要说明的一点是,HTML5 引入了一套标准的自定义属性格式,即 data-* 属性(例如 data-app="myTestApp")。使用 data-* 前缀可以确保代码完全符合 HTML5 规范。
然而,AngularJS 为了保持简洁,并没有强制要求使用 data-* 前缀。ng-app、ng-controller 这样的写法在 AngularJS 中是完全有效的。你可以查阅资料了解 data-* 属性的详细规范,但对于学习和使用 AngularJS 而言,直接使用 ng- 前缀就足够了。
总结


本节课我们一起学习了 AngularJS “魔法”背后的基本原理。我们通过 JavaScript 演示了如何获取元素的自定义属性,以及如何使用选择器查找带有特定属性的元素。这揭示了 AngularJS 框架在启动时,是如何通过查找 ng-app 等指令属性来初始化应用并建立绑定的。理解这个底层机制,有助于我们消除对框架的神秘感,并为其更高级的功能学习打下坚实的基础。
016:依赖注入




在本节课中,我们将学习另一个在 AngularJS 中广泛使用的重要概念——依赖注入。
概述
在本节中,我们将探讨依赖注入的概念。我们将了解它是什么,为什么它很重要,以及 AngularJS 如何利用它来帮助我们构建更灵活、更易测试的应用程序。
什么是依赖注入
我们已经见过在 AngularJS 中创建模块和控制器的模式。控制器函数接收一个 $scope 对象。这个 $scope 对象是一个复杂的对象,它是由 AngularJS 为我们实例化并提供的。我们获取这个对象的方式,就是通过依赖注入。
所以,依赖注入是另一个设计模式。我们已经见过模型-视图-视图模型模式,现在又有了依赖注入。它是一种实现“控制反转”以解决依赖关系的设计模式。
理解控制反转
为了理解控制反转的含义,让我们看一个购物车的例子,看看通常是如何构建组件的。
常规的控制方式是:如果我们有一个购物车模块,我们可以有一个单独的模块叫做 CardProcessingBank1,用于处理特定银行的信用卡交易。它有一个 charge 方法,接收信用卡号和金额。我们为特定银行创建这个模块,是因为每个银行可能有特定的 URL 和 API。
我们不希望将所有代码直接塞进购物车模块,因为那样购物车模块会与特定银行的 API 紧密耦合。我们不希望这样。
通常,我们会在购物车模块内部实例化信用卡处理模块,然后调用它的 charge 方法。从图中可以清楚地看到,购物车模块依赖于 CardProcessingBank1 模块。
但是,如果明天我们决定换一家银行,或者因为手续费更低而想使用另一家银行,会发生什么?我们就必须编写另一个 CardProcessingBank2 模块,它也有相同类型的 charge 方法。这意味着我们的购物车代码必须稍作修改,在实例化信用卡处理模块时,需要创建 CardProcessingBank2 的实例。
这里存在一个大问题:我们必须更改购物车内部的代码。仅仅因为我们更换了处理信用卡的银行,就需要修改购物车代码,这再次形成了紧耦合。
测试带来的问题
如何测试购物车?假设购物车只有几个简单的方法,如 addToCart 和 removeFromCart。在这种场景下,为了测试购物车是否能正确地向数组添加或移除商品、计算总价和税费等,你不得不实例化一整套信用卡处理系统。这很不合理。
当然,我们可以创建一个假的信用卡处理模块,并在购物车中实例化它。但这又要求我们更改购物车内部的代码。为了测试而临时更改我们正在测试的函数内部的代码,似乎是一个倒退且糟糕的主意。谁能保证我们在为测试更改代码,然后再改回去的过程中不会出错?
解决方案:控制反转
解决方案就是控制反转。在这种方法中,我们的购物车函数或模块接受一个信用卡处理模块作为参数。我们仍然会调用该信用卡处理模块的 charge 方法,但现在使用的是从外部传入的实例。
我们仍然可以有那些独立的模块,但在这个案例中,某个更上层的系统将负责实例化信用卡处理模块。例如,系统会实例化 CardProcessingBank1,然后调用购物车模块,并将这个新实例传递给它。
在这种场景下,如果我们需要换一家银行进行信用卡处理,购物车代码完全不需要改动。这就是为什么控制反转有时被戏称为“别打电话给我们,我们会打给你”。因为正如你所见,购物车不再主动调用并实例化信用卡处理模块;相反,系统会调用购物车模块,并向其提供要在内部使用的信用卡处理实例。
总结
本节课中我们一起学习了另一个名为依赖注入的模式,简称 DI。它实现了一种称为控制反转的方法,通常缩写为 IoC。
在这种方法中,客户端(如我们的控制器或服务)的依赖项由其他系统(在 AngularJS 中就是框架本身)调用时提供。其核心要点在于,客户端不负责实例化它所依赖的其他代码。
通过依赖注入,我们能够编写出更松耦合、更易维护和测试的代码,这是构建健壮 AngularJS 应用的关键。




017:依赖注入在 JavaScript 中的工作原理 🔧


在本节课中,我们将要学习依赖注入在 JavaScript 中的具体实现原理,特别是 AngularJS 是如何实现这一机制的。我们将通过构建一个简单的应用,并探索 AngularJS 的一些特殊服务来揭示其背后的“魔法”。
理解依赖注入的实现
上一节我们介绍了依赖注入的基本概念,本节中我们来看看如何通过代码实现它。我们将构建一个简单的 AngularJS 应用,并探索几个关键的 AngularJS 服务。
首先,我们位于 lecture 09 文件夹中,这里有一个非常简单的应用。目前,我们只是实例化了一个名为 Dapp 的应用,并将其绑定到 HTML 元素上。同时,我们定义了一个名为 DIController 的控制器。
在控制器中,我们将 scope 服务的 name 属性绑定到视图模型上。scope 是连接视图模型(ViewModel)和视图(HTML)的“胶水”。
angular.module('Dapp', [])
.controller('DIController', DIController);
function DIController($scope) {
$scope.name = "Yaakov";
}
在 HTML 中,我们通过 ng-model 指令将输入框与 $scope.name 进行双向绑定。
引入过滤器服务
接下来,我们希望实现一个功能:当输入框失去焦点时,其中的文本自动转换为大写。为此,我们将引入另一个 AngularJS 服务:$filter。
我们可以像注入 $scope 服务一样,将 $filter 服务注入到控制器中。$filter 服务用于创建格式化数据的函数。
以下是实现步骤:
- 在控制器函数中添加
$filter参数。 - 在
$scope上创建一个名为upper的函数。 - 在这个函数内部,使用
$filter服务获取一个名为uppercase的过滤器函数。 - 使用这个过滤器函数来更新
$scope.name的值。
function DIController($scope, $filter) {
$scope.name = "Yaakov";
$scope.upper = function() {
var upCase = $filter('uppercase');
$scope.name = upCase($scope.name);
};
}
在 HTML 中,我们使用 ng-blur 指令来绑定 upper 函数。
<input type="text" ng-model="name" ng-blur="upper()">
现在,当你在输入框中输入文本并移开焦点时,文本会自动转换为大写。
揭秘依赖注入的“魔法”
现在,核心问题依然存在:AngularJS 是如何知道应该在何处注入 $scope 和 $filter 服务的呢?
为了理解这一点,我们先来看一个普通的 JavaScript 函数。
function annotateMe(name, job, blah) {
return "blah";
}
console.log(annotateMe.toString());
如果你在控制台打印这个函数的字符串形式,你会得到包含整个函数定义的字符串。这个字符串包含了参数名 name、job、blah。
这意味着,如果我们解析这个字符串,就可以提取出参数的名称。AngularJS 正是这样做的:它解析控制器函数的字符串表示,提取参数名,然后在其内部的服务注册表中查找匹配的服务,最后在调用函数时将这些服务作为参数传入。
使用 $injector 服务

AngularJS 中负责这项工作的服务是 $injector。我们甚至可以将其注入到控制器中,并查看它的功能。
$injector 服务有一个 annotate 方法,它可以返回一个函数的所有参数名称组成的数组。
function DIController($scope, $filter, $injector) {
// ... 控制器逻辑 ...
console.log($injector.annotate(DIController));
}
运行上述代码,控制台将输出 ['$scope', '$filter', '$injector']。这就是 AngularJS 内部用来确定依赖关系并执行注入的机制。依赖注入的“魔法”就此揭晓。
总结

本节课中我们一起学习了依赖注入在 JavaScript 和 AngularJS 中的工作原理。我们通过一个简单的应用,实践了如何注入 $scope 和 $filter 服务。更重要的是,我们揭示了 AngularJS 实现依赖注入的核心机制:通过 $injector 服务解析函数参数,并动态注入相应的服务实例。理解这一原理,有助于我们更好地使用和调试 AngularJS 的依赖注入功能。
018:保护依赖注入不受代码压缩影响 🔧


在本节课中,我们将要学习如何保护 AngularJS 应用中的依赖注入机制,使其在代码压缩后依然能够正常工作。我们将首先理解什么是代码压缩,然后探讨它如何破坏依赖注入,最后学习两种有效的解决方案。
什么是代码压缩? 📦

上一节我们介绍了 AngularJS 中依赖注入的工作原理。本节中我们来看看代码压缩是什么,以及它为何会对依赖注入造成影响。
代码压缩是一个从源代码中移除所有不必要字符的过程,关键点在于它不会改变源代码的功能。因此,压缩过程会使我们的代码变得完全不可读,但同时保持 100% 的功能性。许多压缩工具甚至自称为“丑化”工具。
以下是代码压缩通常会移除的内容:
- 空白字符
- 换行符
- 注释
- 某些用于提高代码可读性的花括号

那么,代码压缩的目的是什么呢?其实很简单:减少从服务器传输的数据量。当所有这些字符从 JavaScript 文件中移除后,文件本身会变得小得多,从而加快网页加载速度。

代码压缩如何破坏依赖注入? ⚠️
现在我们已经了解了代码压缩,接下来我们通过一个实例来看看它是如何破坏依赖注入并导致应用失效的。

我们有一个简单的应用,其功能是在文本框失去焦点时将其内容转换为大写。其核心控制器代码如下所示:
app.controller('DIController', function($scope, $filter) {
// 控制器逻辑
});
当我们使用在线工具压缩这段代码后,变量名 $scope 和 $filter 会被替换成简短的字符(例如 a, b)。AngularJS 依赖注入机制原本是通过查找这些参数名称来确定要注入哪些服务的。压缩后,这些名称消失了,AngularJS 便无法正确注入依赖,导致应用出错。


如何保护依赖注入? 🛡️
既然我们看到了问题所在,本节中我们将探讨两种保护依赖注入免受代码压缩影响的方法。
方法一:使用内联数组注解
第一种解决方案是在定义控制器时,不使用函数作为第二个参数,而是使用一个数组。数组的最后一个元素是控制器函数本身,而前面的元素是字符串,用于指定控制器函数期望接收的参数。


app.controller('DIController', ['$scope', '$filter', function($scope, $filter) {
// 控制器逻辑
}]);
在这个例子中,我们告诉 AngularJS:$scope 和 $filter 将按此顺序作为参数注入到控制器函数中。由于字符串字面量(如 ‘$scope’)在压缩过程中不会被修改,因此依赖注入得以保护。
然而,这种方法将字符串、函数混合在一个数组中,降低了代码的可读性。


方法二:使用 $inject 属性注解
更优雅的解决方案是保持控制器函数定义不变,然后为这个函数对象附加一个 $inject 属性。
function DIController($scope, $filter) {
// 控制器逻辑
}
DIController.$inject = ['$scope', '$filter'];
app.controller('DIController', DIController);
AngularJS 在查找要注入的函数时,会同时检查该函数是否拥有 $inject 属性。如果找到,它将使用该属性数组(即依赖服务的名称列表)作为指南,来确定将哪个服务注入到函数的哪个参数中。

这种方法将依赖声明与函数定义分离,显著提高了代码的可读性和可维护性。



总结 📝

本节课中我们一起学习了保护 AngularJS 依赖注入免受代码压缩影响的重要性与方法。
我们首先了解了代码压缩,它是一个通过移除不必要的字符来减小文件大小、提升加载速度的过程。然而,压缩会破坏 AngularJS 依赖注入所依赖的参数名。
接着,我们学习了两种解决方案:
- 内联数组注解:在控制器注册时使用数组,将依赖名作为字符串字面量提供。
$inject属性注解:为控制器函数定义附加一个$inject属性数组来声明依赖。

其中,使用 $inject 属性的方法是更受推荐的做法,因为它能更好地保持代码的清晰度和组织性。确保在生产环境中部署应用前采用这些技术,你的 AngularJS 应用就能在压缩后依然稳健运行。
019:表达式和插值




在本节课中,我们将要学习表达式和插值。
什么是表达式?🤔
表达式是能够被求值为某个值的代码片段。在 Angular 中,我们已经见过表达式,它们就是位于双大括号 {{ }} 中的内容。这些双大括号有时也被称为“胡须”或“把手”。
Angular 会处理这些表达式。你可以将它们粗略地理解为类似于在 JavaScript 中调用 eval() 的结果,但在几个关键方面有所不同。
首先,表达式在作用域的上下文中执行。这个作用域就是我们一直在说的 $scope。表达式可以访问放置在 $scope 上的属性。
其次,如果表达式求值过程中发生类型错误或引用错误,Angular 不会抛出这些错误,而是简单地显示一个空字符串或空值。
此外,与 eval 语句不同,Angular 表达式中不允许使用控制流函数,例如 if 语句等。
表达式还可以接受一个过滤器或过滤器链,以特定方式格式化字符串输出。我们将在后续课程中详细学习过滤器。
什么是插值?🔗

“插值”这个术语是计算机科学中的一个专业说法,指的是处理包含一个或多个占位符的字符串字面量的过程,这些占位符随后会被实际值替换。
简单来说,你有一个包含某种占位符的字符串,解析这个字符串,找到这些占位符,并用与占位符名称对应的值替换它们。
例如,在 Angular 中,字符串 "Message: {{ message }}",假设 message 属性等于 "hello",就会被插值为 "Message: hello"。
这里有一个重要的注意事项:message 变量(或属性)仍然连接到作用域上的原始 message 属性。如果作用域(即 $scope)中的 message 以任何方式被其他过程更改,插值的结果也会随之改变。
代码示例演示 💻
现在,让我们跳转到代码编辑器,看看一些具体的例子。

我回到了编辑器,位于 lecture11 文件夹的 fullstack-course5-examples 目录中。这里的设置与我们之前看到的非常相似。
我们有一个 index.html 文件,一个驱动应用的 app.js 文件,以及 angular.min.js。
让我们看看 index.html 中的内容。我们使用 ng-app 指令设置了名为 MsgApp 的应用,并使用 ng-controller 指令设置了一个名为 MsgController 的控制器,它控制着这一块 HTML。
在 app.js 中,我们创建了模块 MsgApp,并注册了一个控制器,其驱动函数是 MsgController。为了防止代码压缩时出现问题,我们为控制器函数对象附加了一个 $inject 属性。
目前,我们只是在 $scope 服务上放置了一个 name 属性,并将其设置为字符串 "Yaakov"。
我们已经见过这种插值和表达式求值。在 HTML 中,我们可以直接写 {{ name }}。保存后,由于浏览器同步工具在工作,页面会更新为显示 "Yaakov"。
但我们能做的远不止这些。我们还可以在 $scope 服务上定义一个方法或函数,然后在表达式中使用它。
让我们在控制器中定义一个函数:
$scope.sayMessage = function() {
return "Yaakov likes to eat healthy snacks at night!";
};
定义完成后,我们可以回到 index.html,并写下 {{ sayMessage() }}。注意,因为这是一个函数,我们需要加上括号来调用它。保存后,页面会显示 "Yaakov likes to eat healthy snacks at night!"。
实际上,Angular 正在评估位于 ng-controller 开始和结束标签之间的字符串,并对它们进行插值。换句话说,它寻找这些占位符并对其进行求值。在这个例子中,它求值的是一个函数调用。
如果你查看页面源代码(不是开发者工具中的“元素”面板,而是原始的 HTML 源代码),你会看到 ng-controller 内的原始状态仍然是 {{ sayMessage() }}。但如果你查看“元素”面板,你会看到它已经被替换为实际的字符串值,并与周围的文本连接在一起。
我们可以连接多个占位符。例如,我们可以写:
{{ name }} has a message for you: {{ sayMessage() }}
保存后,页面会显示 "Yaakov has a message for you: Yaakov likes to eat healthy snacks at night!"。你可以看到,所有这些字符串被连接在一起,形成了这个 div 元素的内容。
之前提到过,如果表达式求值导致引用错误或类型错误,Angular 不会抛出错误,而是输出空值。让我们来测试一下,将表达式改为一个不存在的属性,例如 {{ someNonsense }}。
保存后,你会发现页面上没有显示任何错误信息,完全是空白的。查看“元素”面板,你会发现那里什么都没有。因为 someNonsense 在作用域上不存在,求值出错,Angular 就将其渲染为空。
当我们改回正确的表达式时,页面内容又会正常显示。
总结 📝


本节课中我们一起学习了 Angular 中的表达式和插值。
我们首先介绍了表达式。表达式是能求值为某个值的代码片段,在 Angular 中用双大括号 {{ }} 表示。Angular 表达式与它们所在的作用域紧密绑定,可以访问该作用域上的属性。一个重要的特性是,Angular 表达式不会向用户显示类型错误或引用错误,这非常有用,因为你肯定不希望错误信息直接出现在 HTML 页面上。
接着,我们探讨了插值。插值是一个处理字符串字面量中占位符的过程,会用实际值替换这些占位符。在 Angular 中,这些占位符通常是表达式。当占位符的值(即作用域上的属性)发生变化时,插值的结果会自动更新,这是 Angular 数据绑定的核心优势之一。
通过实际的代码示例,我们演示了如何在 HTML 中使用简单的属性插值、调用作用域上的函数,以及 Angular 对错误表达式的静默处理。




020:表达式和插值


在本节课中,我们将学习如何在 AngularJS 中使用表达式和插值,实现动态更新网页内容的功能。我们将通过一个有趣的示例——一个可以“喂食”并改变状态的图片应用,来演示表达式不仅可以在标签内容中使用,还可以在标签属性中使用。
在上一讲的第一部分,我们创建了一个消息应用,它能够使用插值和表达式来输出一些我们在控制器内的 $scope 上设置的消息。现在,我们将展示一个更有趣的示例,同时演示表达式不一定只能放在标签体内,它们也可以成为标签属性的一部分。使用这种技术,可以实现非常巧妙的效果。
我仍然位于课程 lecture11 文件夹的 fullstack-course5/examples 目录中。我添加了两张图片:yaakov_hungry.png 和 yaakov_fed.png。我们将创建一个 div,请注意这个 div 位于带有 ng-controller 属性的 div 内部,这是为了与特定控制器的 $scope 进行通信。
我们在这里创建一个新的 div,只是为了在视觉上将之前的信息与我们即将实现的功能分开。
我们的目标是:在网页上显示一张图片,图片内容是“饥饿的 Yaakov”。然后,我们将添加一个写着“喂食 Yaakov”的按钮。点击该按钮后,图片应切换为另一张图片,即“吃饱的 Yaakov”。换句话说,Yaakov 已经被喂饱了。
我们将从创建一个按钮开始。按钮的文字是“喂食 Yaakov”。对于这个按钮的点击事件,我们希望由 Angular 和控制器来处理,因此我们将使用 ng-click 指令。在 ng-click 中,我们指定要触发的功能,即切换或更改图片。我们将调用一个名为 feedYaakov 的函数,这个函数稍后需要实现。

现在,我们可能需要一个换行,通常我会进行样式设计,但目前我们专注于功能,所以简单的换行就足够了。
接下来,我们需要为图片创建一个占位符。我们将使用 img 标签,暂时不写 alt 属性。src 属性将指向 images/ 目录,后面跟着 yaakov_。这里的关键是我们不知道要显示哪张图片,我们想显示反映 Yaakov 状态的图片。Yaakov 最初可能是饥饿的,然后被喂饱。因此,我们需要在这里使用表达式。我们将使用 stateOfBeing 这个变量来表示 Yaakov 的状态。这个表达式将被插值到字符串中,最终形成完整的图片路径,例如 yaakov_hungry.png 或 yaakov_fed.png。

让我们看看网页。可以看到“喂食 Yaakov”按钮,但出现了一个错误。这是合理的,因为我们还没有图片,这只是一个表达式,我们还没有在 $scope 上设置这些变量。
我们需要回到代码编辑器,定义 stateOfBeing 属性和 feedYaakov 函数。让我们打开 app.js,在 $scope 上添加一个名为 stateOfBeing 的属性,并将其初始值设为 "hungry"。
保存后,快速查看网页,现在图片应该显示出来了。我们看到的是 yaakov_hungry.png 图片。但我们仍然看到一个 404 错误,因为它找不到图片,这有点奇怪,我们稍后会检查。




回到编辑器,我们仍然需要定义 feedYaakov 函数。我们需要将其放在 $scope 上。这个函数的作用是在点击后,将 stateOfBeing 变量的值切换为 "fed"。

保存后,回到浏览器。当我们点击“喂食 Yaakov”按钮时,不同的图片显示出来,Yaakov 看起来很开心,饼干几乎被吃完了。
但奇怪的是,查看控制台,我们仍然看到 404 错误,提示图片未找到。为什么这张图片找不到呢?这完全合理,因为 HTML 是按顺序处理的。当浏览器逐行处理这个 HTML 页面时,在处理到 img 标签这一行时,我们的 stateOfBeing 还没有被定义。实际上,AngularJS 还没有处理这段 HTML,还没有查找这些占位符并用值替换这些表达式。因此,浏览器尝试从这个 URL 获取图片,而这个 URL 仍然包含 {{ 和 }},所以是无效的,导致了 404 错误。一旦 Angular 处理了这部分,它就会用正确的 URL 替换这个占位符,我们就能看到图片,但控制台中的 404 错误信息仍然存在。
我们如何解决这个问题呢?使用常规的 HTML 技巧无法解决,但可以使用 Angular 来解决。我们需要以某种方式告诉浏览器不要立即尝试获取这个 URL,而是只在 Angular 准备好解释这些占位符时才获取。一种方法是在这里使用 Angular 的属性,而不是标准的 HTML 属性。这个 Angular 属性就是 ng-src。

保存后,回到网页,现在没有错误了。因为图片只在 Angular 获取到这个 HTML 并将所有表达式转换为它们的值之后才被获取。现在,刷新页面以确保,即使浏览器同步正在工作,你也能看到拿着饼干的 Yaakov。点击“喂食 Yaakov”按钮,保存在 $scope 上的 stateOfBeing 变量将其值更改为 _fed,现在 Yaakov 很开心,因为他几乎吃掉了整个饼干。
在本讲的这一部分,我们并没有看到什么全新的内容,但你看到了表达式的另一种用法或思路。由于你甚至可以在属性值中使用表达式,因此可以实现一些非常有趣的行为,而这些行为在其他情况下可能很难实现。


本节课中,我们一起学习了:
- 如何在 HTML 标签属性(如
src)中使用 AngularJS 表达式。 - 如何使用
ng-click指令绑定点击事件到控制器中定义的函数。 - 如何通过改变
$scope上的数据,动态更新视图(如图片)。 - 使用
ng-src替代src属性,以避免因表达式未及时解析而导致的 404 错误。


通过这个简单的“喂食 Yaakov”示例,我们巩固了表达式和插值的概念,并展示了它们在构建交互式 Web 应用中的强大能力。
021:模块1总结


概述
在本节课中,我们将对模块一的学习内容进行总结,并回顾你已经掌握的核心技能。
模块一学习成果总结
恭喜你完成了模块一的学习。你可能已经意识到,你现在已经能够构建简单的 AngularJS 应用程序了。不仅如此,你现在还理解了所学 Angular 特性背后的原理。这不仅会提升你作为软件开发者的能力,也将帮助你更好地理解和记住我们正在学习的信息。
课程相关资源与互动
以下是关于课程后续互动与支持的信息。
- 你可以访问本课程的 Facebook 页面,地址是
facebook.com/courserawebdev。这是一个与我联系的好方法,我也会在该页面上发布一些可选内容。我将在视频下方提供该链接。 - 如果你非常喜欢这门课程并想给它一个五星评价,请尽管去做。我保证我不会抱怨。
总结与展望
本节课中,我们一起回顾了模块一的完成情况以及你已获得的能力。然而,学习的乐趣远未结束,我们下一个模块再见。
022:欢迎进入模块2

在本模块中,我们将学习如何使用 Angular 过滤器来格式化数据,深入理解 Angular 的“消化周期”,掌握 JavaScript 的原型继承概念,并学习创建自定义服务与配置。这些技能将帮助你构建更复杂、架构更先进的 Web 应用。
🎼 模块2内容概述
上一节我们完成了模块1的学习,本节中我们来看看模块2的核心内容。
模块2将涵盖以下关键主题:
- 使用 Angular 过滤器操作数据。
- 深入理解 Angular JS 的消化周期。
- 学习 JavaScript 的原型继承。
- 创建自定义的 Angular 服务。
- 在 HTML 中使用循环和条件逻辑指令。
🎼 详细学习路径
以下是本模块各章节的详细学习路径。
-
Angular 过滤器
我们将首先学习如何使用 Angular 内置过滤器来将数据转换为我们想要的格式,并学习如何创建自定义过滤器。 -
消化周期
然后,我们将深入探讨消化周期。这是 Angular JS 用来神奇地使用来自视图模型或控制器的绑定数据更新网页的过程。理解这个过程对于熟练掌握 Angular JS 至关重要。 -
原型继承
之后,我们将学习 JavaScript 编程语言中最基本的概念之一,即原型继承。在讨论应用程序中 Angular JS 控制器之间的继承之前,必须清楚地理解这个主题。 -
自定义服务
我们将通过学习如何创建自己的自定义 Angular 服务以及如何配置它们来结束本模块。通过自定义 Angular 服务,我们将能够在应用程序的不同控制器或其他组件之间共享数据。 -
实用指令
我们还将学习一些有用的 Angular 指令,这些指令允许我们将循环和条件逻辑直接放入 HTML 中。
🎼 模块目标总结

本节课中我们一起学习了模块2的完整路线图。到本模块结束时,你将掌握创建相当复杂的 Web 应用程序所需的技能,这些应用程序将开始使用一些更高级的软件架构技术。
023:过滤器 🔧


在本节课中,我们将要学习 AngularJS 中一个非常实用的功能——过滤器。过滤器可以改变表达式在视图中的输出格式,例如将文本转换为大写、格式化货币等。我们将学习如何在 JavaScript 代码和 HTML 模板中使用它们。
什么是过滤器? 🧐
在 AngularJS 中,过滤器是一种特殊的构造,用于改变表达式的输出。你可以在 HTML 模板或 JavaScript 代码中使用过滤器。
例如,这里有一个应用了 uppercase 过滤器的例子。无论原始值是什么,这个过滤器都会将字符串转换为大写。
{{ expression | uppercase }}
AngularJS 提供了一些内置过滤器,例如 uppercase、lowercase、currency、number 等。要在 JavaScript 代码中使用过滤器,我们需要先在控制器中注入 $filter 服务。
在 JavaScript 中使用过滤器 💻
$filter 服务是一个用于创建过滤函数的服务。以下代码演示了如何创建并使用一个过滤函数:
// 注入 $filter 服务
app.controller(‘MyController‘, [‘$scope‘, ‘$filter‘, function($scope, $filter) {
// 创建过滤函数并立即执行
var output = $filter(‘uppercase‘)(‘hello‘);
// output 的值现在是 ‘HELLO‘
}]);
这段代码首先通过 $filter(‘uppercase‘) 创建了一个大写过滤函数,然后立即用参数 ‘hello‘ 执行它。你也可以将创建函数和调用函数分开写成两行。
有些过滤器接受自定义参数来改变其行为。例如,currency 过滤器可以接受货币符号和小数位数作为参数。


在 HTML 模板中使用过滤器 📄
AngularJS 一个非常方便的特性是,你可以直接在 HTML 文件中使用过滤器。只需在表达式和过滤器名称之间添加一个竖线(|)即可。
<!-- 使用内置的 uppercase 过滤器 -->
{{ ‘hello‘ | uppercase }}
<!-- 输出: HELLO -->
表达式不一定非得是字符串字面量,它可以是任何绑定在 $scope 上的值。
<!-- 假设 $scope.greeting = ‘hello‘ -->
{{ greeting | uppercase }}
<!-- 输出: HELLO -->
同样,你也可以在 HTML 中为过滤器传递自定义参数,参数之间用冒号(:)分隔。


<!-- 使用 currency 过滤器并自定义参数 -->
{{ cost | currency : ‘¥‘ : 4 }}
<!-- 假设 $scope.cost = 0.45, 输出: ¥0.4500 -->
实战演练:代码示例 🚀
现在,让我们进入代码编辑器,通过实际例子来巩固对过滤器的理解。

我们位于 lecture 12 文件夹中。我们的应用目前显示一条信息:“Yaakov likes to eat healthy snacks at night”。我们将使用过滤器将其转换为大写。
首先,我们需要修改控制器,注入 $filter 服务。


app.controller(‘MsgController‘, [‘$scope‘, ‘$filter‘, function($scope, $filter) {
// ... 控制器逻辑
}]);
接着,我们使用 $filter 服务来转换信息。
// 原始信息
var msg = “Yaakov likes to eat healthy snacks at night“;
// 使用 uppercase 过滤器
var output = $filter(‘uppercase‘)(msg);
// 将转换后的结果赋值给 $scope
$scope.message = output;
保存代码并刷新页面,可以看到信息已全部变为大写。
你可能会问,如何知道有哪些内置过滤器?可以访问 AngularJS 官方文档的“开发指南”部分,查看“过滤器”章节,其中列出了所有内置过滤器及其用法。
使用 Currency 过滤器 💰
让我们尝试另一个内置过滤器:currency。我们将展示 Yaakov 吃的饼干的价格。

首先,在控制器的 $scope 上定义一个价格。
$scope.cookieCost = 0.45; // 这是一个数字,不是字符串
然后,在 HTML 模板中使用 currency 过滤器来格式化显示。
<p>Cost: {{ cookieCost | currency }}</p>
<!-- 输出: $0.45 -->
现在,让我们自定义这个货币格式。假设我们使用一种虚构的货币,符号是“#bh”,并且要求显示4位小数。

<p>Cost: {{ cookieCost | currency : ‘#bh‘ : 4 }}</p>
<!-- 输出: #bh0.4500 -->
保存并查看页面,可以看到价格已按照我们的自定义格式显示。同时,之前的大写转换功能依然正常工作。
总结 📝
本节课中我们一起学习了 AngularJS 过滤器的核心概念和用法。

- 过滤器的作用:改变表达式在视图中的输出格式。
- 两种使用方式:既可以在 JavaScript 控制器中通过
$filter服务使用,也可以直接在 HTML 模板中使用管道符号(|)调用。 - 内置过滤器:AngularJS 提供了诸如
uppercase、lowercase、currency、number等实用的内置过滤器。 - 自定义参数:许多过滤器接受参数来定制输出,在 HTML 中使用冒号(
:)分隔多个参数。



通过本讲的学习,你已经掌握了使用过滤器来格式化数据的基本技能,这能让你的应用界面更加专业和用户友好。
024:创建自定义过滤器 🎼





在本节课中,我们将要学习如何创建自定义过滤器。之前我们已经了解了如何使用 Angular 内置的过滤器,但这并不总是能满足需求。因此,本节将详细介绍创建自定义过滤器的步骤和原理。
概述
之前我们学习了如何使用 Angular 提供的开箱即用的过滤器,但这并不总是足够的。因此,在本讲中,我们将学习如何创建我们自己的自定义过滤器。这个过程包含几个关键步骤。
创建自定义过滤器的步骤
以下是创建自定义过滤器需要遵循的几个核心步骤。
第一步:定义过滤器工厂函数
首先,我们需要定义过滤器工厂函数。顾名思义,这实际上是工厂设计模式的一个例子。工厂是代码中的一个中心位置,用于生产新的对象或函数。在我们的例子中,这个名为 customFilterFactory 的工厂函数,会为整个过滤机制生产出我们的过滤函数。
为了让 AngularJS 正常工作,我们需要创建一个能生产过滤函数的工厂。至少,过滤函数本身需要接收一些输入作为参数,然后通常返回该输入的某个处理后的版本,也就是过滤后的输出。
第二步:向模块注册过滤器工厂
第二步是向我们的模块注册这个过滤器工厂。我们注册控制器的方式非常相似。对于控制器,我们在模块上调用 .controller 方法,并给它一个名称和一个负责控制器的函数。对于过滤器,我们在模块实例上调用 .filter 方法,指定过滤器的名称,并引用负责创建过滤函数的过滤器工厂函数。
过滤器名称必须是有效的 AngularJS 表达式标识符。一个安全的命名方式是使用任何在 JavaScript 中有效的变量名。而工厂函数本身的名称(例如 customFilterFactory)可以任意选择,它与我们在 AngularJS 中注册工厂时使用的名称没有关联。然而,将它们命名为相似的名称是个好习惯,以免日后混淆。
第三步:注入并使用过滤器
现在,我们需要将过滤器注入到我们计划使用它的任何组件中。在这个例子中,我们计划在控制器内部(也就是直接在 JavaScript 中,而不是在 HTML 中)使用它,因此我们需要将它注入到控制器函数中。
我们按照之前常用的方式来完成注入:在控制器函数的 $inject 属性数组中添加一个值,然后在定义控制器函数时,在相同位置将其指定为一个参数。
显然,注入过滤器的最终目的是使用它。在最后一行代码中,你可以看到我们使用了自定义过滤器,并将我们的消息作为输入传递给它。
请注意:我们注入的过滤器名称,并不是我们注册工厂时使用的名称。实际上,我们使用的名称是原始注册的名称加上单词 filter,如红色部分所示。
同时请注意:与我们之前见过的 $filter 服务不同,调用 customFilter 实际上是调用了过滤函数。如果你还记得,调用 $filter 服务并不会直接调用过滤函数,而只是为我们创建过滤函数。这两点并非巧合。
深入理解:工厂与函数
让我们回顾一下。当我们注册过滤器时,我们真正注册的是一个创建过滤函数的工厂。
另一方面,我们之前看到,当你调用 $filter 服务并指定你想要实例化的过滤函数(例如 uppercase)时,$filter 服务会调用 Angular 已经提供的 uppercase 过滤器工厂,来创建大写转换过滤函数。
得到过滤函数后,你可以用提供的值来执行它。
然而,当我们将自定义过滤器直接注入到控制器时,Angular 会为我们调用我们注册的过滤器工厂(例如 customFilterFactory),并允许我们注入该工厂的产物,也就是工厂返回的任何东西,即我们实际的过滤函数。
在这个过程中,Angular 为我们注入的实际过滤函数命名,使用的名称是我们注册工厂函数的名称加上附加在末尾的单词 filter。
所以,如果我们注册了一个名为 Custom 的工厂函数,AngularJS 将执行我们的工厂来创建实际的过滤函数,并将其命名为 CustomFilter,即在注册的工厂名称末尾附加 Filter。
顺便一提:因此,在自定义过滤器工厂名称的末尾使用 filter 可能是个坏主意。例如,如果你将自定义过滤器工厂函数命名为 customFilter,Angular 会在其后附加单词 filter,你将被迫使用 customFilterFilter 这样的名称来注入它,这有点尴尬,所以请避免这样做。
核心步骤回顾
以上是很多背景细节,但它确实有助于你理解正在发生的事情,而不是死记硬背奇怪的规则。内容很多,如果你第一次没有完全理解,建议你回看这部分内容。现在,让我们再次回顾一下基本步骤。
第一步:定义我们的过滤器工厂函数。这是一个创建并返回过滤函数实例的函数。
第二步:向模块注册我们的过滤器工厂。我们通过调用 .filter 方法来完成,给它我们的过滤器名称,并指定负责创建过滤函数的过滤器工厂函数。
第三步:至少如果你试图直接在 JavaScript 内部(如在控制器或类似的地方)使用你的过滤器,那么你需要注入该过滤器,注入的名称是你注册的过滤器名称加上附加的单词 filter,如本例中的 customFilter。
总结
本节课中,我们一起学习了创建 AngularJS 自定义过滤器的完整流程。我们首先定义了过滤器工厂函数,然后将其注册到模块中,最后在控制器中注入并使用它。我们还深入探讨了过滤器工厂与 $filter 服务之间的区别,以及 AngularJS 内部如何命名和注入这些函数。


在下一部分,我们将进入代码编辑器,亲眼看看这些概念的实际应用。
025:创建自定义过滤器 🎼




在本节课中,我们将学习如何在 AngularJS 应用中实现自定义过滤器。上一节我们介绍了创建自定义过滤器的基本流程,本节中我们来看看如何将自定义过滤器应用到实际项目中。

我们的控制器返回了一条消息:“Yaakov likes to eat healthy snacks at night”。现在,我们想通过一个自定义过滤器,将句子中的“likes”替换为“loves”。

首先,我们需要在代码编辑器中创建过滤器工厂函数。找到控制器定义结束的位置,在其下方添加我们的函数。
我们将其命名为 lovesFilter。由于它是一个工厂函数,它需要返回另一个函数。这个返回的函数将接收一个输入参数。
function lovesFilter() {
return function(input) {
// 确保输入存在
input = input || '';
// 替换单词
input = input.replace('likes', 'loves');
// 返回处理后的结果
return input;
};
}
接下来,我们需要将这个工厂函数注册到 AngularJS 应用中。回到控制器定义的上方,在结束控制器定义之前,添加一个 filter 语句。
app.filter('loves', lovesFilter);
现在,我们需要在控制器中使用这个 loves 过滤器。首先,我们必须将其注入到控制器中。
以下是控制器中注入依赖的数组,我们需要在其中添加 lovesFilter。
app.controller('MyController', ['$scope', 'lovesFilter', function($scope, lovesFilter) {
// 控制器逻辑
}]);
然后,我们可以在控制器中创建一个新的函数来使用这个过滤器。假设我们有一个 sayMessage 函数,我们将复制它并创建一个 sayLovesMessage 函数。
$scope.sayLovesMessage = function() {
var message = "Yaakov likes to eat healthy snacks at night";
// 应用自定义过滤器
return lovesFilter(message);
};
最后,我们需要在 HTML 模板中调用这个新函数。在 index.html 文件中,我们可以添加以下代码来显示原始消息和处理后的消息。
<p>原始消息: {{ sayMessage() }}</p>
<br>
<p>处理后消息: {{ sayLovesMessage() }}</p>
保存文件后,由于浏览器同步工具已启用,我们可以立即在浏览器中查看结果。你将看到处理后的消息显示为:“Yaakov loves to eat healthy snacks at night”。


至此,我们成功地在 JavaScript 代码内部使用了一个自定义过滤器。



在本讲的第三部分,我们将探讨如何创建带有额外参数的自定义过滤器,以及如何在 HTML 模板中直接使用自定义过滤器。



本节课中我们一起学习了如何创建并注册一个自定义 AngularJS 过滤器,将其注入到控制器中,并在控制器函数内调用它来修改数据。这是构建更动态、更个性化应用的重要一步。
026:创建自定义过滤器(第二部分)


在本节课中,我们将学习如何创建能够接受额外参数的自定义过滤器,以及如何在 HTML 模板中直接使用和链式调用过滤器。
概述
上一节我们介绍了如何创建和使用一个仅接收输入表达式作为参数的自定义过滤器。本节中,我们来看看如何创建一个能接受额外自定义参数的自定义过滤器。
创建带参数的自定义过滤器

创建带额外参数的自定义过滤器,其过程与之前基本相同。
以下是创建此类过滤器的步骤:
- 定义过滤器工厂函数:与之前不同,这次工厂函数返回的过滤器函数除了第一个输入参数外,还可以接收额外的自定义参数。
- 注册过滤器工厂函数:此步骤与之前完全相同,将工厂函数注册到模块中。
- 使用过滤器:在控制器中注入过滤器,或在 HTML 模板中直接使用。使用时,除了输入值,还需提供额外的参数值。
在 HTML 模板中使用自定义过滤器
在 HTML 模板中使用自定义过滤器更为便捷,无需在控制器中注入。

以下是具体方法:
- 在 HTML 模板中,通过管道符
|后跟过滤器注册名来使用过滤器。 - 无需在注册名后附加
filter字样,Angular 会自动处理引用。 - 如需传递额外参数,只需在过滤器名后使用冒号
:分隔并依次提供参数值。 - 过滤器可以链式调用,只需用管道符
|将它们连接起来。前一个过滤器的输出会成为后一个过滤器的输入。
代码示例与实践
现在,让我们回到代码编辑器,通过实践来理解这些概念。
我们将创建一个名为 truth 的新过滤器,它允许用户指定要查找和替换的字符串。
首先,定义过滤器工厂函数:
function TruthFilter() {
return function(input, target, replace) {
return input.replace(target, replace);
};
}

接着,将这个工厂函数注册到模块中:

angular.module('myApp', [])
.filter('truth', TruthFilter);
现在,我们可以在 HTML 模板中直接使用这个过滤器。假设我们有一个变量 message 的值为 "Yaakov loves to eat healthy snacks at night"。
<p>{{ message | truth:'healthy':'cookie' }}</p>
渲染结果将是:Yaakov loves to eat cookie snacks at night。
更进一步,我们可以链式调用过滤器。例如,先将 message 通过 truth 过滤器处理,再将结果转换为大写:

<p>{{ message | truth:'healthy':'cookie' | uppercase }}</p>
最终输出将是:YAAKOV LOVES TO EAT COOKIE SNACKS AT NIGHT。
总结
本节课中我们一起学习了创建和使用 AngularJS 自定义过滤器的进阶知识。
我们回顾了创建自定义过滤器的步骤:首先定义过滤器工厂函数,然后将其注册到模块。要在 JavaScript 中使用自定义过滤器,需要将其注入到控制器中,注入名称为注册名后附加 filter。要在 HTML 中使用,则无需注入,直接通过管道符和注册名调用即可。
我们重点掌握了如何创建能接受额外参数的自定义过滤器,只需在工厂函数返回的过滤器函数中定义这些参数即可。在 HTML 中使用带参数的过滤器时,通过冒号分隔参数。
最后,我们了解了 AngularJS 一个非常强大的功能——过滤器链式调用。通过管道符连接多个过滤器,可以实现数据的连续处理。


通过本课的学习,你应能创建更灵活、功能更强大的自定义过滤器,并熟练地在模板中应用它们。
027:脏检查周期 🔄


在本节课中,我们将要学习 AngularJS 的核心机制之一——脏检查周期。我们将揭开 AngularJS 如何自动更新页面的神秘面纱,理解其背后的工作原理。
AngularJS 起初可能显得有些神奇:当我在文本框中输入内容时,它如何知道更新网页的某个部分?让我们拉开帷幕,看看这个魔法是如何运作的。
理解事物背后的工作原理,通常能帮助你更好地使用它。
从现象到原理
我们之前见过这段代码:你在一个输入元素上使用 ng-model 属性声明了一个属性。当用户输入内容时,下方 div 中对 name 属性的插值就会神奇地同步更新。这是如何实现的呢?让我们通过一张图来理解这个过程,然后我们将深入代码进行实践。
事件队列与 Angular 上下文
我们熟悉事件队列。当用户点击或输入时,这些点击和按键事件会进入事件队列。如果在 Angular 应用内部,我们使用常规的 JavaScript 方法来捕获和处理这些事件,Angular 应用将不会知道页面上发生了任何变化。
然而,如果我们使用特殊的指令,过程就有所不同。这些指令被称为 ng-keyup 或 ng-click,它们与常规的 keyup 和 click 事件同义。
当我们使用 ng-keyup 或 ng-click 时,这些 keyup 和 click 事件将由我们在 Angular 上下文中绑定到这些事件的处理器来处理。换句话说,这些处理器是在 $scope 服务上声明的。
脏检查与 $digest 循环
但事情并未就此停止。Angular 上下文或 $scope 有一个特殊的观察者数组。它会遍历所有设置了观察的属性,检查是否有任何属性因刚刚发生的事件而改变。
Angular 通过一个名为 $digest 的特殊函数来启动这个过程。
有多种方式可以触发 Angular 为我们定义在 $scope 上的属性自动设置这些观察者。
以下是设置观察者的两种常见方式:
- 插值表达式:当你在 HTML 中用双花括号
{{ }}包裹一个$scope属性时。 ng-model指令:当你指定ng-model="someScopeProperty"时。
在这两种情况下,Angular 都会设置一个函数来监视这些属性的变化,这个函数被称为 观察者。
$digest 循环会遍历所有观察者,检查是否一切未变。如果确实没有变化,循环结束。如果某些内容发生了变化,Angular 会再次遍历整个观察者列表。这个过程会不断重复,直到所有观察者都报告没有发生变化。
因此,大多数情况下,$digest 循环会运行两次:一次检测到变化,另一次确保没有其他变化。遍历观察者的过程被称为 $digest 循环,整个流程被称为 脏检查周期。
为什么需要多次循环?
那么,为什么循环需要再次运行以验证一切未变呢?这是因为一个变化可能触发其他变化,而脏检查周期在第一次尝试时可能无法检测到。
例如,对属性三的更改可能触发属性二的值发生变化。如果此时 Angular 已经检查过属性二并且没有发现任何变化,那么它只有在下一次循环中才会知道属性二实际上已经改变了。
这个过程在游戏开发中实际上非常常见,被称为 脏检查。一旦最终的 $digest 循环运行并验证所有观察者中没有任何变化,Angular 就会更新 DOM 中已改变的值,重新绘制页面的相关部分,你就能在眼前看到更新后的页面了。
理论已经足够,在本讲的第二部分,我们将进入代码编辑器,亲眼看看这些概念的实际运作。


本节课中,我们一起学习了 AngularJS 脏检查周期的核心概念。我们了解到,通过 $digest 循环,Angular 能够自动追踪数据变化并更新视图。关键在于观察者列表和循环检查机制,这确保了即使一个变化引发连锁反应,视图最终也能正确同步。理解这个机制,将帮助你更好地编写和调试 AngularJS 应用。
028:脏检查周期


在本节课中,我们将深入代码,学习 AngularJS 中脏检查周期的实际实现方式。我们将通过一个简单的示例应用,手动设置观察者,并观察它们如何工作。
上一节我们介绍了脏检查周期的理论基础。本节中,我们来看看如何在代码中实际设置和使用观察者。
我位于第14讲的示例文件夹中,这里有一个名为 Count app 的简单 AngularJS 应用。目前,页面中只有一个按钮,它通过 ng-click 绑定了一个名为 showNumberOfWatchers 的函数。



在浏览器中,点击按钮暂时不会有任何反应,因为我们还没有实现这个函数。让我们转到 app.js 文件,可以看到这个函数的框架已经存在。目前,我们只是简单地记录 $scope 服务本身。
$scope.showNumberOfWatchers = function() {
console.log($scope);
};
回到页面并点击按钮,你会在控制台中看到输出的 $scope 对象。让我们仔细查看一下这个对象。

在 $scope 对象中,我们可以看到 showNumberOfWatchers 函数。此外,还有两个以双美元符号 $$ 开头的属性:$$watchers 和 $$watchersCount。$$watchers 是一个数组,目前是 null。$$watchersCount 则记录了观察者数组中的对象数量,目前是 0。
双美元符号 $$ 表示这是 AngularJS 内部的属性,意味着你不应该直接与它们交互。然而,在本讲座中,我们将直接与它们交互,以便了解幕后的工作原理。

由于我们不需要整个对象,只需要观察者的数量,因此我们将修改代码,只记录 $$watchersCount。

$scope.showNumberOfWatchers = function() {
console.log("Number of watchers: " + $scope.$$watchersCount);
};
保存后,点击按钮,现在会显示“Number of watchers: 0”。

接下来,让我们在 $scope 上声明一个属性。回到 app.js,添加一个名为 onceCounter 的属性,并将其初始值设为 0。
$scope.onceCounter = 0;


在 index.html 中,我们创建一个按钮,其 ng-click 绑定到一个名为 countOnce 的函数。

<button ng-click="countOnce()">Up Once Counter</button>
显然,这个函数需要定义在 $scope 上。让我们回到 app.js 创建它。
$scope.countOnce = function() {
$scope.onceCounter = 1;
};


保存后,回到浏览器。点击“Number of watchers”按钮,显示为0。然后点击“Up Once Counter”按钮,onceCounter 的值变为1。再次点击“Number of watchers”,观察者数量仍然是0。这说明仅仅设置属性并不会自动创建观察者。
要设置观察者,有几种方法。其中一种方法是手动设置。$scope 服务上有一个特殊的函数 $watch 用于此目的。

$watch 函数接受两个参数:
- 第一个参数是要监视的属性名(字符串形式)。
- 第二个参数是监视函数,AngularJS 会将属性的新值和旧值传递给它。
让我们为 onceCounter 设置一个观察者。
$scope.$watch('onceCounter', function(newValue, oldValue) {
console.log('Old value: ' + oldValue);
console.log('New value: ' + newValue);
});
保存代码。现在,我们为 onceCounter 设置了一个观察者。

回到应用并刷新页面,你会立即在控制台看到输出:“Old value: 0”和“New value: 0”。这意味着我们的观察者函数在初始化时已经执行了一次。
现在点击“Number of watchers”按钮,你会看到观察者数量变成了1。
点击“Up Once Counter”按钮,观察者被触发,控制台输出:“Old value: 0”和“New value: 1”。
再次点击“Number of watchers”,数量仍然是1。
如果你继续点击“Up Once Counter”按钮,控制台不会再输出新的值。这是因为 onceCounter 的值被设置为1后就不再改变,观察者检测不到变化,因此函数不再执行。

现在,让我们声明另一个属性,并创建一个每次点击都会递增的处理器。

在 app.js 中,添加 counter 属性。

$scope.counter = 0;
然后创建递增函数。
$scope.incrementCounter = function() {
$scope.counter++;
};
在 index.html 中添加对应的按钮。
<button ng-click="incrementCounter()">Increment Counter</button>
保存后回到页面。点击“Increment Counter”按钮,页面上没有任何反应。点击“Number of watchers”,数量仍然是1。这是因为我们还没有为 counter 属性设置观察者。
让我们回到 app.js,为 counter 属性也设置一个观察者。
$scope.$watch('counter', function(newValue, oldValue) {
console.log('Counter old value: ' + oldValue);
console.log('Counter new value: ' + newValue);
});

为了更好地区分,我们也修改一下 onceCounter 观察者的日志信息。

$scope.$watch('onceCounter', function(newValue, oldValue) {
console.log('Once counter old value: ' + oldValue);
console.log('Once counter new value: ' + newValue);
});



保存代码。刷新页面后,你会看到两组初始化日志。
现在点击“Number of watchers”按钮,观察者数量显示为2。

点击“Increment Counter”按钮,你会看到控制台输出 counter 的旧值和新值(例如:0 -> 1)。每次点击,值都会更新并触发观察者函数。

本节课中我们一起学习了如何使用 $watch 函数手动设置观察者,并观察了属性变化如何触发脏检查。在下一部分,我们将探讨更多由 AngularJS 自动为我们设置观察者的机制。




029:脏检查周期 🔄


在本节课中,我们将要学习 AngularJS 的核心机制之一——脏检查周期(Digest Cycle)。我们将探讨如何设置监视器(watchers),以及 AngularJS 如何自动管理这些监视器来更新视图。
自动设置监视器
上一节我们介绍了如何使用 $watch 函数手动设置监视器,但这不是在控制器中设置监视器的推荐方式。AngularJS 提供了更自动化的方法,本节中我们来看看这些方法。

在控制器内部使用 $watch 函数并不是推荐的做法。控制器和模板(或 HTML)本身已有自动设置这些监视器的机制。

以下是实现自动监视的一种方法。首先,我们注释掉控制器中手动设置的 $watch 代码。
// 注释掉手动 $watch
// $scope.$watch(...)
然后,在 index.html 文件中,我们添加一个新的 div 元素,并使用双花括号 {{ }} 进行插值绑定。

<div>
Once Counter: {{onceCounter}} <br>
Regular Counter: {{counter}}
</div>
保存更改后,即使我们没有手动设置任何监视器,返回页面并点击“显示监视器数量”按钮,你仍然会看到监视器的数量为2。onceCounter 在首次点击后不再变化,而 counter 会随着每次点击递增按钮而更新。
追踪脏检查周期


有一个技巧可以帮助我们观察脏检查周期(Digest Loop)何时执行。我们可以通过一个特殊的 $watch 函数来记录循环的触发。
我们设置一个监视器,它不监视具体的属性,而是提供一个函数。这个函数会在每次脏检查周期中被调用,从而让我们知道循环何时运行。
$scope.$watch(function() {
console.log("Digest Loop Fired");
return 'someProperty';
});
保存并返回页面,你会看到控制台立即打印了两次“Digest Loop Fired”。这是因为 AngularJS 在初始化时,由于插值绑定导致属性变化,触发了脏检查周期。第一次循环检测到变化,第二次循环确认没有更多变化(即“干净”状态)。

现在,如果我们点击“递增 Once Counter”按钮,控制台会再次打印两次信息:一次是因为计数器实际变化,另一次是为了确认没有其他值变脏。然而,由于 onceCounter 的特性,后续点击可能只触发一次循环,因为值不再变化。
点击“递增 Regular Counter”按钮时,每次点击都会触发两次循环:一次检测 counter 的变化,另一次确保所有监视器都报告“干净”。

设置监视器的三种方式
至此,我们已经看到了两种设置监视器的方法。第一种是手动使用 $watch 函数(不推荐在控制器中使用,但可在服务、指令或组件中使用)。第二种是通过双花括号 {{ }} 插值绑定属性,AngularJS 会自动为此设置一个监视器,因为它知道当属性变化时需要更新 DOM。
还有第三种设置监视器的方式,即使用带有 ng-model 指令的输入元素。ng-model 实现了双向数据绑定。


我们在 HTML 中添加一个输入框:
<input type="text" ng-model="name">
在控制器中初始化 name 属性:
$scope.name = "Yaakov";
保存后,返回页面。现在点击“显示监视器数量”,你会看到监视器数量变为4。这四个监视器分别是:
- 输入框的
ng-model="name"。 {{onceCounter}}插值绑定。{{counter}}插值绑定。- 我们为追踪脏检查周期而设置的特殊
$watch函数。
当我们在输入框中键入内容时,每次按键都会触发两次脏检查循环,原理与之前相同:一次响应变化,一次验证稳定性。
总结与核心概念
本节课中我们一起学习了脏检查周期。其核心是循环运行 Digest Loop,直到所有监视器都报告没有变化为止。这个过程通常被称为 脏检查(Dirty Checking),目的是确保每个被监视的值都不再“脏”(即未变化)。
我们学习了三种设置监视器的方法:
- 手动调用
$scope.$watch函数:不推荐在控制器中使用。 - 使用双花括号
{{ }}插值绑定:AngularJS 会自动设置监视器。 - 使用
ng-model指令:在输入元素上声明,AngularJS 会自动为其关联的属性设置监视器。


最后,请记住,我们讨论的所有机制都只在 Angular 上下文(Angular Context) 中生效。如果一个事件(如更新 $scope 上的属性)不是由 Angular 基础设施触发的(例如在原生 setTimeout 回调中),那么脏检查周期就不会自动运行,视图也不会立即更新。直到下一个由 Angular 上下文触发的事件启动脏检查周期时,这些已改变的值才会被检测到并更新到视图中。
030:$digest 和 $apply 🎯


在本节课中,我们将要学习当代码运行在 AngularJS 上下文之外时,如何手动触发数据绑定更新。我们将探讨 $digest 和 $apply 这两个核心函数的作用、区别以及最佳实践。
概述
上一节我们介绍了,只有当事件队列中的原始事件是 AngularJS 所“感知”的(例如 ng-click)时,整个 $digest 循环才会被自动触发。本节中我们来看看,当代码不在 AngularJS 应用内部,但又需要影响应用内部的数据时,我们该如何处理。这正是 $digest 和 $apply 函数发挥作用的地方。
问题引入:非 AngularJS 上下文中的事件
让我们回顾一下事件队列的示意图。当事件队列中存在一个 ng-click 事件时,AngularJS 能够感知到它,并最终调用 $digest 函数来启动脏检查循环。
但是,如果事件队列中存在其他非 AngularJS 感知的事件会怎样呢?例如,原生的 onclick 事件,或者一个被放入事件队列、在一定延迟后执行某些功能的 setTimeout 函数。
在这些情况下,我们需要采取特殊措施来通知 AngularJS 它需要运行其 $digest 循环。在实践中,我们可能遇到的是像 jQuery 这样的自定义库,它需要处理一些特定功能,而我们希望介入其中并影响 Angular 应用内部的一些值和 UI 状态。为了简化示例,我们将使用 setTimeout,因为它比搭建一整套 jQuery 基础设施更容易处理。

代码示例:一个简单的计数器应用
让我们进入代码编辑器,看看如何在 AngularJS 应用中处理这种情况。
我们有一个非常简单的计数器应用。它包含一个控制器 CounterController 和一个“增加计数器”按钮。这个按钮连接到作用域(scope)上的 upCounter 函数,该函数会递增计数器。每次递增时,它会触发 $digest 循环,并更新页面上的计数器插值表达式({{ counter }})。
以下是 app.js 的初始代码:


app.controller('CounterController', ['$scope', function($scope) {
$scope.counter = 0;
$scope.upCounter = function() {
$scope.counter++;
console.log('Counter incremented.');
};
}]);
在浏览器中,点击按钮,计数器会正常递增。
引入问题:使用 setTimeout
现在,我们修改代码,不让人一点击按钮就立即增加计数器,而是使用 setTimeout 在两秒后执行递增操作。

$scope.upCounter = function() {
setTimeout(function() {
$scope.counter++;
console.log('Counter incremented.');
}, 2000);
};
回到网页,点击“增加计数器”按钮,等待两秒后,控制台打印了日志,但页面上的计数器显示仍然是 0。

为什么会这样?原因是这个 setTimeout 回调函数被完全置于 AngularJS 上下文之外执行。因为它不是在 AngularJS 上下文中被调用的,$digest 循环根本不知道需要启动。因此,数据的变化没有被检测到,视图也就没有更新。
解决方案一:手动调用 $digest
为了解决这个问题,我们可以在自定义代码执行完毕后,手动触发 $digest 循环。


$scope.upCounter = function() {
setTimeout(function() {
$scope.counter++;
console.log('Counter incremented.');
$scope.$digest(); // 手动触发脏检查
}, 2000);
};
现在,再次测试。点击按钮,等待两秒后,计数器成功递增,并且页面显示更新为 1。
手动调用 $scope.$digest() 会通知 AngularJS 启动脏检查循环,从而检查所有监视器(watcher),更新 DOM。
解决方案二:使用 $apply 包装代码
虽然直接调用 $digest 可以解决问题,但存在一个更好的方法:使用 $apply。直接调用 $digest 的问题是,如果在我们执行的代码中发生任何错误或异常,这些异常将不会被 AngularJS 捕获到。
$apply 函数可以解决这个问题。它接受一个函数作为参数。

$scope.upCounter = function() {
setTimeout(function() {
$scope.$apply(function() {
$scope.counter++;
console.log('Counter incremented.');
});
}, 2000);
};
在这个例子中,当 setTimeout 在两秒后执行时,它会调用 $apply,并将包含我们实际代码的函数传递给它。$apply 会执行这个函数,并且在这个过程结束时,自动调用 $digest。更重要的是,在此过程中抛出的任何异常都会被 AngularJS 上下文捕获。
测试结果与之前相同,计数器能够正常更新。
最佳实践:使用 AngularJS 原生服务

然而,在实践中,你应该始终优先寻找是否存在 AngularJS 提供的、原生的替代方案。在我们的案例中,有一个名为 $timeout 的服务,它的功能与 setTimeout 完全相同,但它本身就在 AngularJS 上下文中运行。
使用 $timeout 服务,我们无需进行任何额外操作。
首先,我们需要将 $timeout 服务注入到控制器中。

app.controller('CounterController', ['$scope', '$timeout', function($scope, $timeout) {
$scope.counter = 0;
$scope.upCounter = function() {
$timeout(function() {
$scope.counter++;
console.log('Counter incremented.');
}, 2000);
};
}]);
现在,我们直接使用 $timeout 替代原生的 setTimeout。保存代码并测试,计数器会在两秒后成功递增并更新视图。这是最简洁、最推荐的方式。
总结
本节课中我们一起学习了当事件不被 AngularJS 感知时,如何手动触发数据绑定更新。我们探讨了三种解决方案:
以下是三种解决方案的对比:
- 手动调用
$digest:在你的自定义代码之后直接调用$scope.$digest()。这会告诉 AngularJS 启动整个脏检查循环。但此方法无法让 AngularJS 捕获你代码中可能抛出的异常。 - 使用
$apply包装代码:将你的自定义代码包装在$scope.$apply(function() { ... })中。这是一个更好的方法,因为它允许 AngularJS 上下文捕获代码执行中抛出的任何异常,并在最后自动调用$digest。 - 使用 AngularJS 原生服务(最佳实践):尽可能寻找并使用 AngularJS 提供的等效服务(例如用
$timeout代替setTimeout,用$interval代替setInterval,用$http代替$.ajax等)。这些服务天生就是 AngularJS 感知的,你无需进行任何额外操作。


总而言之,当需要在 AngularJS 上下文之外修改作用域数据时,请优先考虑使用 AngularJS 原生服务。如果无法避免,则使用 $apply 来包装你的代码。将直接调用 $digest 作为最后的选择。
031:双向绑定、单向绑定和一次性绑定


概述
在本节课中,我们将要学习 AngularJS 中的三种数据绑定策略:双向绑定、单向绑定和一次性绑定。我们将探讨它们的工作原理、区别以及各自的适用场景。

双向绑定与单向绑定的概念
上一节我们介绍了数据绑定的基础。本节中,我们来看看双向绑定和单向绑定的具体含义。
双向绑定和单向绑定的概念我们已经有所了解。一次性绑定的概念我们将在本讲中更详细地探讨。之后,我们将比较和对比这些不同的绑定策略。
双向绑定详解
双向绑定的含义是,AngularJS 不仅会为作用域上的属性设置一个监视器,还会在输入元素上设置某种监听器。这样,如果输入元素的值发生变化,该变化会反映在作用域上的属性中。
以下是一个双向绑定的代码示例:
<input ng-model="name">
在这个例子中,用户可以通过在文本框中输入来影响 name 属性,作用域上的 name 属性会随之更新。同样,如果在控制器中更新了 name 属性,该值也会自动更新并反映在用户界面上。
单向绑定详解
单向绑定通常通过双花括号插值实现。以下是单向绑定的代码示例:
<p>{{lastName}}</p>
这个值在浏览器中发生变化的唯一方式是控制器内部更新了作用域上的 lastName 属性。我们无法直接通过双花括号从浏览器或用户端影响 lastName 的值。因此,这是一个单向绑定的例子。
监视器与性能考量
如果我们设置大量绑定,AngularJS 会为我们创建大量监视器。摘要循环会遍历我们拥有的每个监视器列表,并检查在上一个循环中是否发生了任何变化。
这些循环可能会运行几次。如果拥有成千上万个监视器,显然会花费一些时间,从而导致应用程序性能下降。经验法则是每个页面的监视器不应超过 2000 个。不过,实际数量取决于用户机器的类型、速度和承载能力。

然而,有一点是明确的:在摘要循环期间,最小化监视器列表中活动监视器的数量是可取的。
一次性绑定介绍
实现这一目标的一种方法是设置一种特殊的一次性绑定。一次性绑定看起来与单向绑定非常相似,只是在属性名称前加上双冒号。
以下是单向绑定和一次性绑定的对比:
- 单向绑定:
{{propertyName}} - 一次性绑定:
{{::propertyName}}
双冒号告诉 AngularJS,它应该为该属性设置一个监视器,但一旦该属性被初始化,AngularJS 的摘要循环就会启动,更新用户界面中的该属性,重新绘制浏览器,属性值将呈现给用户。此时,AngularJS 会自动移除该特定属性的监视器。
当然,这样做的缺点是这只发生一次。因此,如果在应用程序运行过程中该属性被更新,该更新将不会反映在用户界面中。

然而,对于某些属性,例如用户的完整姓名,在应用程序的整个生命周期中预计不会改变,我们仍然需要能够将其插入到 HTML 模板中,但不需要在应用程序的剩余生命周期内每次都监视和检查该属性。
代码演示与分析
让我们进入代码编辑器,看看这个概念的实际应用。
我们有一个简单的绑定应用程序。控制器中有几个属性:firstName 初始化为 “Yaakov”,fullName 初始化为空字符串。
我们还有一个之前见过的函数,用于显示摘要循环中的监视器数量。我们将把它绑定到一个按钮上,以便随时点击查看有多少个监视器。
此外,我们有一个 setFullName 函数,它将 fullName 属性设置为 firstName 加上空格和我的姓氏 “Hikinghen”。这将更新 fullName 属性。
在 HTML 模板中,我们有一个使用 ng-model 在 firstName 属性上设置双向绑定的输入元素。这意味着如果在这里输入内容,firstName 属性会随着我们的输入不断更新。

我们还有一个对 fullName 的一次性绑定,以及几个用于记录 firstName 和 fullName 的按钮。
关键问题与解决方案
如果我们现在点击“显示监视器数量”,会看到监视器数量是 1。这应该让我们稍作停顿。为什么是这样?我们希望这里的 fullName 属性能够显示,但只有一个监视器。
如果我们查看 HTML,会发现 {{::fullName}} 确实应该创建一个监视器。但结果显示只有一个,这意味着要么这个没有创建监视器,要么发生了其他情况。

让我们记录一下 firstName,结果是 “Yaakov”,这符合预期。再记录 fullName,目前是空字符串。我们可以设置 fullName。点击“设置全名”按钮后,再记录 fullName,可以看到它是 “yaakov hikinghen”。但问题是,它仍然没有显示在页面上。为什么?
部分原因我们已经讨论过。我们没有额外的监视器来监视 fullName 属性,以便在其更改时输出。让我们回到代码中,看看能否找到这个问题的根源。
如果你看前面,会发现我们已经将 fullName 初始化为空字符串。这就是问题的根源。如前所述,一旦一次性绑定的属性被初始化,AngularJS 的摘要循环就会遍历所有监视器,将此值输出到浏览器,然后移除其监视器。这意味着,具有一次性绑定属性的 HTML 模板将永远不会再次更新,因为监视器已不存在。
解决方法是简单地注释掉初始化 fullName 的那行代码。这意味着该属性第一次被初始化将是在我们的 setFullName 函数内部,该函数由我们按下按钮触发。
保存更改后,回到浏览器,点击“显示监视器数量”,现在情况好多了。现在有两个监视器。记录 firstName 是 “Yaakov”,记录 fullName 是 undefined,这正符合我们此时的期望。因为如果它已经被定义,那就为时已晚,监视器已经被移除了。


现在,如果我们点击“设置全名”,你会看到它立即出现在这里。但如果我们再次点击“显示监视器数量”,它不再是两个,而是一个。因为一旦我们一次性绑定到 HTML 模板,就不再需要那个监视器了。我们可以节省性能,将该监视器从监视器列表中移除。因此,这就是为什么这里只有一个监视器。
与此同时,双向绑定将继续工作。例如,如果我现在记录 fullName,它是 “yaakov Hihen”。但是,如果我更新名字,比如在这里输入 “AAA”,这意味着作用域上的 firstName 属性会在末尾额外添加三个字母 “A”。如果我此时执行“设置全名”,它将使 fullName 属性变为 “yaakov AAA hikinghen”。但这不会改变 HTML 模板中显示的内容,因为那是一次性绑定。然而,fullName 属性实际上会包含更新后的 firstName,因为这是我们在 setFullName 函数中手动更新的。
总结
本节课中我们一起学习了 AngularJS 的三种数据绑定策略。
- 双向绑定:通过
ng-model属性在输入元素上设置。这意味着 AngularJS 会为输入元素的值变化设置监听器,任何变化都会自动更新到作用域上的属性。同时,任何对属性的直接更新也会自动反映在用户界面上。这显然是使用监视器完成的。 - 单向绑定:通过属性周围的双花括号自动设置。这只意味着一件事:控制器内部或类似情况下对属性值的直接更新会自动更新到用户界面,并且监视器仍然保留。
- 一次性绑定:看起来与单向绑定非常相似,通过属性名称前的双花括号和双冒号设置。这意味着属性的初始值会自动更新到用户界面,然后该属性的监视器会被 AngularJS 自动移除,因此用户界面永远不会再因该属性的任何更新而更新。


通过理解这些绑定策略的区别和适用场景,你可以更有效地构建高性能的 AngularJS 应用程序。
032:ng-repeat


概述
在本节课中,我们将要学习 AngularJS 中的一个核心指令:ng-repeat。这个指令允许我们循环遍历数据集合,并将结果动态输出到网页上。我们将通过构建一个购物清单应用来理解其基本用法、如何遍历对象数组,以及如何利用它实现数据的动态更新。
什么是 ng-repeat?
任何像 AngularJS 这样级别的框架,都必须提供某种结构来循环遍历数据并将结果输出到网页。AngularJS 确实拥有这样的结构,它被称为 ng-repeat。这就是我们本节课要学习的内容。
我们已经见过一些指令,例如 ng-app 和 ng-controller。ng-repeat 是另一个我们将要学习的指令,它是 AngularJS 开箱即用的一部分。
示例应用:购物清单
现在,我们进入编辑器,位于课程示例文件夹中。让我们来看这个简单的购物清单应用。
我们有一个名为 ShoppingListApp 的 ng-app,以及一个名为 ShoppingListController 的控制器,所有逻辑都将在这里发生。
数据模型
让我们切换到 app.js 文件,看看这里有什么。
我们有两个数组:
shoppingList1:包含 8 个字符串元素。shoppingList2:包含 4 个对象元素,每个对象都有name和quantity属性。
在控制器中,我们将这两个数组作为属性附加到 $scope 服务上,这样它们就可以在 HTML 模板中被引用。


基础循环:遍历字符串数组
回到 index.html,我们看到第一个购物清单版本。这里有一个无序列表 (<ul>),其列表项 (<li>) 上有一个名为 ng-repeat 的属性。
ng-repeat 的语法类似于 for-each 循环。item in shoppingList1 表示我们将遍历 shoppingList1 数组,每次迭代时,当前遍历到的集合元素将被赋值给 item 变量。
在列表项标签体内,我们使用双花括号插值来显示 item 的值。每次迭代都会输出一个新的列表项,其内容对应数组中的一个元素。
代码示例:
<ul>
<li ng-repeat="item in shoppingList1">{{ item }}</li>
</ul>
保存并查看浏览器,你会看到购物清单中的八个项目都被输出到了页面上。检查 HTML 代码,你会发现 ng-repeat 指令将 <li> 标签重复了八次,每次都将数组中的一个元素作为内容插入。
进阶循环:遍历对象数组


现在,让我们注释掉第一个列表,查看第二个购物清单。这次我们遍历的是 shoppingList2 数组,它包含的是对象。
在循环中,每个 item 变量代表一个对象。因此,在插值时,我们可以使用点号表示法来访问对象的属性,例如 {{ item.quantity }} 和 {{ item.name }}。
代码示例:
<ul>
<li ng-repeat="item in shoppingList2">Buy {{ item.quantity }} of {{ item.name }}</li>
</ul>
保存后,浏览器会显示如“Buy 2 of Milk”这样的条目。这表明我们可以轻松地遍历和显示复杂的数据结构。
获取循环索引
有时,我们可能希望获取当前项的索引(位置)。ng-repeat 指令通过 $index 属性向宿主标签的体内暴露了这个索引值。


我们可以像下面这样使用它,为列表项添加编号(注意索引从 0 开始,所以我们加 1 使其从 1 开始显示)。
代码示例:
<ul>
<li ng-repeat="item in shoppingList2">
{{ $index + 1 }}. Buy {{ item.quantity }} of {{ item.name }}
</li>
</ul>
保存后,每个列表项前面都会出现 1、2、3、4 这样的编号。
数据绑定与动态更新
ng-repeat 的强大之处在于它与 AngularJS 的数据绑定深度集成。双花括号插值会设置监视器(watchers)。这意味着,如果数组中的数据发生变化,这些变化会自动反映在页面上。
监视深层属性

这种监视甚至适用于对象内部的深层属性。让我们添加一个输入框,将其与 shoppingList2 中第一个商品的 quantity 属性进行双向绑定。

代码示例:
<input type="text" ng-model="shoppingList2[0].quantity">
保存后,输入框中会显示数字 2(即牛奶的数量)。如果你在输入框中修改这个值,列表中的对应数量会立即自动更新,无需任何额外操作。
动态添加新项
ng-repeat 不仅监视集合内元素的变化,还监视整个集合本身。当集合增加或减少元素时,视图会自动重新渲染以反映更新后的数据。
让我们实现一个功能,允许用户向购物清单中添加新商品。
首先,在 HTML 中添加两个输入框和一个按钮,用于输入新商品的名称和数量,并绑定点击事件。
HTML 代码示例:
<input type="text" ng-model="newItemName" placeholder="Item Name">
<input type="text" ng-model="newItemQuantity" placeholder="Item Quantity">
<button ng-click="addToList()">Add To List</button>
然后,在控制器中实现 addToList 函数。这个函数会创建一个新的商品对象,并将其推入 shoppingList2 数组。

JavaScript 代码示例:
$scope.addToList = function() {
var newItem = {
name: $scope.newItemName,
quantity: $scope.newItemQuantity
};
$scope.shoppingList2.push(newItem);
};
保存后,在浏览器中测试。输入商品名(如“Rich Cookie”)和数量(如 500),点击“Add To List”按钮,新的商品项就会立即出现在列表中。同时,之前绑定的第一个商品的输入框依然可以正常工作,修改其值会实时更新列表。
总结
本节课中,我们一起学习了 ng-repeat 指令。
ng-repeat 是一个用于扩展 HTML 元素功能的指令。它允许我们赋予常规 HTML 动态行为的能力,这是 AngularJS 的一个强大理念。
其行为类似于其他编程语言中的 for-each 循环。它的工作方式是在某个元素上添加 ng-repeat 属性,然后指定一个变量(如 item)和一个集合(通常是 $scope 上的一个属性)。在循环体内,可以使用该变量来访问集合中的当前元素。
此外,ng-repeat 还向宿主标签体内暴露了一个特殊的 $index 属性,它保存着当前项在循环中的数字索引,可以在插值或模板内的计算中使用。

最重要的是,ng-repeat 与 AngularJS 的数据绑定系统紧密结合。它会为遍历的项设置监视器,当数据模型中的数组或对象属性发生变化时,视图会自动、高效地更新,从而极大地简化了动态数据渲染的复杂性。
033:带过滤器的 ng-repeat 🎼




在本节课中,我们将要学习 AngularJS 中 ng-repeat 指令的一个强大功能:过滤器。我们将从 JavaScript 数组的 filter 函数讲起,然后学习如何在 AngularJS 的 ng-repeat 中应用内置的 filter 过滤器,最终实现一个用户可实时搜索的列表。
在深入讲解 ng-repeat 的过滤功能之前,我们先来了解一下 JavaScript 中过滤器的一般概念及其工作原理。
我现在位于 lecture 18 文件夹中,该文件夹在 fullstack-course5 的 examples 目录下。这里有两个额外的文件:filter.html 和 filter.js。filter.html 是一个简单的 HTML 页面,它只需要引入我们的 filter.js 脚本。
让我们打开 filter.js 文件。目前,它只有一个包含数字 1 到 10 的简单数组,我们只是将这个数组打印到控制台。

var numberArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(numberArray);

如果我们打开浏览器查看控制台,会看到它确实打印了 1 到 10。
过滤数组的概念是:对数组应用某种函数,使得只有部分元素能进入新数组,而不是全部。
让我们访问 Mozilla 开发者网络,查看数组原型上的 filter 函数。filter 方法会创建一个新数组,其包含通过所提供函数实现的测试的所有元素。


基本用法是:在原始数组上调用 filter 函数,传入一个回调函数和一些可能的额外参数。返回值是一个包含通过测试的元素的新数组。传入回调函数的参数是数组中的每一项。
让我们回到代码编辑器,编写第一个过滤器。

var filteredNumberArray = numberArray.filter(function(value) {
return value > 5;
});
console.log(filteredNumberArray);
我们创建了一个新数组 filteredNumberArray。我们在原始 numberArray 上调用 filter 函数,并传入一个匿名函数。这个函数的参数 value 是数组中的每一项。函数需要返回一个布尔值,表示该项是否“通过测试”。这里我们返回 value > 5,意味着只有大于 5 的数字才会进入新数组。

保存代码并刷新浏览器,可以看到 filteredNumberArray 现在只包含 6 到 10 的数字。

我们可以让代码更优雅一些。由于我们有一个函数作为另一个函数的参数,我们可以将其提取出来,定义一个命名函数。
function aboveFiveFilter(value) {
return value > 5;
}
var filteredNumberArray = numberArray.filter(aboveFiveFilter);
console.log(filteredNumberArray);


现在代码看起来更清晰了。保存后,我们依然会看到过滤后的数组 [6, 7, 8, 9, 10]。







以上就是关于数字数组的过滤。那么,如果我们有一组字符串呢?让我们粘贴一个准备好的数组,它来自上一讲的购物清单。
var shoppingList = [
"Milk", "Donuts", "Cookies", "Chocolate", "Peanut Butter",
"Pepto Bismol", "Pepto Bismol (Chocolate flavor)", "Pepto Bismol (Cookie flavor)"
];
console.log(shoppingList);
保存并刷新浏览器,可以看到控制台打印出了这 8 个购物项。


现在,让我们回到代码编辑器,尝试为这个购物清单创建一个过滤器。我们将搜索包含特定字符串的项。

var searchValue = "Bismol";
function containsFilter(value) {
return value.indexOf(searchValue) !== -1;
}
var searchedShoppingList = shoppingList.filter(containsFilter);
console.log(searchedShoppingList);
我们定义了一个搜索字符串 searchValue 和一个 containsFilter 函数。该函数检查数组中的每一项 (value) 是否包含 searchValue 字符串(使用 indexOf 方法,如果找不到则返回 -1)。然后我们调用 filter 函数并传入这个过滤器。
保存并刷新浏览器,可以看到 searchedShoppingList 只包含带有 “Bismol” 字样的三项。


现在,让我们将这些知识应用到 AngularJS 和 ng-repeat 中。我们希望能够在 ng-repeat 输出数组时动态地过滤它。
首先,访问 AngularJS 官网的开发者指南,查看内置过滤器。其中有一个名为 filter 的过滤器(名称有些重复),它的作用是从数组中选择一个子集并作为新数组返回,这和我们刚才使用的 filter 函数功能一致。
在 HTML 模板绑定中,它的用法是:一个过滤表达式,后跟一个管道符 |,然后是单词 filter,再跟一个表达式。如果这个表达式是一个字符串,那么它将用于匹配数组的内容(数组中的字符串或具有字符串属性的对象),所有匹配的项都会被返回。

让我们回到代码编辑器,介绍我们将要实际使用的应用程序。关闭之前的文件,打开 index.html 和 app.js。
在 index.html 中,有一个简单的 ng-repeat 循环遍历 shoppingList。在 app.js 中,shoppingList 就是我们之前使用的同一个数组。

<!-- index.html 片段 -->
<ul>
<li ng-repeat="item in shoppingList">{{ item }}</li>
</ul>
// app.js 片段
angular.module('ShoppingListApp', [])
.controller('ShoppingListController', function($scope) {
$scope.shoppingList = [
"Milk", "Donuts", "Cookies", "Chocolate", "Peanut Butter",
"Pepto Bismol", "Pepto Bismol (Chocolate flavor)", "Pepto Bismol (Cookie flavor)"
];
});
保存并在浏览器中打开 index.html,可以看到我们的购物清单。
现在,让我们在 index.html 的列表上方创建一个输入框,用于绑定搜索关键词。
<input type="text" ng-model="search" placeholder="Search...">
<ul>
<li ng-repeat="item in shoppingList | filter:search">{{ item }}</li>
</ul>
我们创建了一个 input 元素,并使用 ng-model 将其双向绑定到一个名为 search 的属性上。然后,在 ng-repeat 中,我们对 shoppingList 数组应用了 filter 过滤器,过滤的条件就是 search 这个字符串。


现在会发生什么呢?当我们在输入框中打字时,search 属性会自动更新。由于所有这些都是通过 AngularJS 的 $digest 循环中的监视器注册的,过滤器会自动生效,我们的购物清单会根据输入框中提供的搜索字符串自动被过滤。
让我们在浏览器中查看效果。页面加载后显示完整的购物清单。当我们在输入框中输入 “Bismol” 时,列表立即只显示包含 “Bismol” 的三项。当我们删除文字时,列表又逐渐变回完整。输入 “Milk” 则只显示 “Milk”。




回到代码编辑器,可以看到使用 filter 过滤器基本上实现了和我们在 filter.js 中相同的功能:它使用搜索字符串来判断购物清单数组中的每一项是否匹配。由于我们通过双向绑定更新了 search 属性,ng-repeat 中的列表得以实时过滤。

这样,我们就实现了一个用户可搜索的列表,用户只需输入搜索词,列表就会根据该搜索词自动过滤。



总结 📝
本节课中我们一起学习了以下内容:
-
JavaScript 数组的
filter函数:这是一个存在于数组对象上的特殊函数。它的作用是创建一个新数组,新数组中的每个元素都满足传入filter函数的比较函数所设定的条件。其基本语法是:var newArray = originalArray.filter(function(item) { // 返回 true 或 false return condition; }); -
AngularJS 的内置
filter过滤器:AngularJS 提供了一个名为filter的过滤器(名称有些重复),它封装了我们上面讨论的数组filter函数的功能。

-
过滤器的应用:当以字符串作为参数传递给 AngularJS 的
filter过滤器时,它会过滤原始集合(或数组),将数组中的所有字符串项与提供的搜索字符串进行匹配。 -
在
ng-repeat中实现实时搜索:我们在代码中通过以下方式应用它:<input type="text" ng-model="searchString"> <div ng-repeat="item in collection | filter:searchString"> {{ item }} </div>我们将搜索字符串
searchString绑定到一个文本框输入元素上。这使得用户可以随时在文本框中输入任何字符串,该字符串将作为过滤ng-repeat所遍历的集合(collection)的搜索条件。因此,ng-repeat会根据用户在该搜索框中输入的内容来更新用户界面。



通过本讲的学习,你已经掌握了如何使用 AngularJS 的过滤器来增强 ng-repeat 指令,从而创建出交互性更强、用户体验更好的动态列表。
034:原型继承(第1部分)


在本节课中,我们将要学习 JavaScript 中一个非常基础且重要的概念:原型继承。理解这个概念对于后续掌握 AngularJS 中更优的控制器语法至关重要。如果不理解原型继承,我们可能只是在死记硬背一些奇怪的规则,而不是真正理解其工作原理。
什么是继承?
首先,我们来探讨一下什么是继承。继承是指一个对象或类基于另一个对象或类,使用相同的实现或相同的值。
例如,如果你有一个“动物”对象或类,它可能拥有一些属性,比如“腿的数量”,以及一个名为“行走”的方法。然后,你可以有另一个名为“狗”的类或对象,它以某种方式从“动物”类或对象继承。这样一来,“狗”对象或类就可以访问“腿的数量”属性和“行走”方法。
不同的编程语言实现了不同类型的继承。总的来说,这种技术用于代码复用以及构建逻辑实体结构。请注意,为了便于理解,我们在此不严格区分子类型和继承等术语,这些定义足以满足我们的学习目的。
原型继承
与基于类、规则更复杂、需要记忆大量细节的面向对象继承不同,原型继承基于对象实例,非常简单直接。原始的对象实例会成为所有后续创建对象的原型。
在这个例子中,我们有一个名为 parent 的对象,它有一个 type 属性,其值为字符串 "parent",还有一个名为 method 的方法。
let parent = {
type: 'parent',
method: function() {
console.log('This is a method from parent.');
}
};
然后,如果我们创建一个基于 parent 对象的 child 对象,这个 child 对象最初只是一个空对象。
let child = Object.create(parent);
然而,如果我们尝试计算表达式 child.type,JavaScript 引擎会首先在 child 对象中查找 type 属性。在这个例子中,child 对象本身没有这个属性。
因此,JavaScript 引擎会沿着原型链向上查找,看哪个对象是 child 对象的父对象。在这个例子中,父对象就是 parent 对象本身。引擎会询问 parent 对象是否拥有 type 属性。在这个例子中,parent 对象确实拥有这个属性,其值为字符串 "parent"。
所以,type 属性会被解析为 parent 对象的 type 属性,这意味着表达式 child.type 最终会被解析为字符串 "parent"。
原型链与属性遮蔽
之所以称之为原型链,是因为它不局限于一个对象及其子对象,它可以有孙对象、曾孙对象等等。换句话说,你可以基于 child 对象创建另一个对象,那么当前的 parent 对象就会成为这个新创建对象的“祖父”对象。
然而,如果我们不是计算 child.type 表达式,而是在 child 对象上设置一个 type 属性,会发生什么呢?这时,child 对象将不再为空,它会拥有一个名为 type 的属性,其值为字符串 "child"。
child.type = 'child';
那么,如果你现在尝试计算 child.type,会发生什么?由于 child.type 属性实际上“遮蔽”了从原型对象(即 parent 对象)继承来的 type 属性,引擎会立即计算并解析 child 对象自身的 type 属性。
这意味着它不会沿着原型链向上查找 parent 对象。因此,在这种情况下,表达式 child.type 会被计算为字符串 "child"。
过渡到实践
上一节我们介绍了原型继承的基本概念和原理。在本节中,我们来看看这些概念在实际代码中是如何运作的。
以下是理解原型继承的几个关键点:
- 继承关系:子对象可以访问父对象(原型)的属性和方法。
- 查找机制:当访问一个属性时,引擎会先在对象自身查找,如果没有则沿着原型链向上查找。
- 属性遮蔽:如果子对象定义了与原型同名的属性,则会使用子对象自身的属性值,这被称为“遮蔽”。
总结


本节课中,我们一起学习了 JavaScript 中的原型继承。我们了解到,原型继承是一种基于对象实例的简单继承机制,它通过原型链来实现属性和方法的共享与查找。理解属性遮蔽现象对于掌握对象属性的访问优先级至关重要。在下一部分,我们将进入代码编辑器,亲眼看看这些概念是如何在实践中应用的。
035:原型继承(第二部分)🧬


在本节课中,我们将深入学习 JavaScript 中的原型继承机制。我们将通过编写代码示例,逐步理解对象如何通过原型链共享属性和方法,以及如何创建基于原型的对象关系。
概述
我们将从创建一个简单的父对象开始,然后演示如何基于它创建子对象,从而建立原型继承关系。接着,我们会探索属性查找、属性遮蔽(masking)以及原型链的运作原理。最后,我们会简要介绍函数构造器,为后续理解 AngularJS 的控制器语法打下基础。
创建父对象与子对象
首先,我们声明一个父对象。这是一个简单的对象字面量,包含一个基本值属性、一个对象属性和一个方法。
var parent = {
value: "parent value",
obj: {
objectValue: "parent object value"
},
walk: function () {
console.log("walking!");
}
};

接下来,我们基于这个父对象创建一个子对象。我们使用 Object.create() 方法来实现原型继承。这个方法在现代浏览器中都应得到支持。
var child = Object.create(parent);
执行完这行代码后,parent 对象就成了 child 对象的原型。
探索原型链查找
现在,让我们通过控制台输出,观察子对象和父对象中的属性。
以下是我们要输出的内容:
console.log("CHILD - child.value: ", child.value);
console.log("CHILD - child.obj.objectValue: ", child.obj.objectValue);
console.log("PARENT - parent.value: ", parent.value);
console.log("PARENT - parent.obj.objectValue: ", parent.obj.objectValue);
console.log("parent: ", parent);
console.log("child: ", child);
当 JavaScript 引擎在 child 对象上查找 value 属性时,由于 child 自身没有这个属性,它会沿着原型链向上查找,最终在 parent 对象上找到并返回 "parent value"。对于 child.obj.objectValue 的查找过程也是如此。
在浏览器控制台中,我们可以看到 child.value 和 child.obj.objectValue 的输出结果确实与父对象的对应属性值相同。同时,打印出的 child 对象本身看起来是空的,但它有一个特殊的 __proto__ 属性,指向其原型——也就是 parent 对象。
属性遮蔽与引用类型的影响
上一节我们看到了原型链的基本查找机制。本节中,我们来看看当子对象定义自己的属性时会发生什么。
我们将在子对象上定义自己的 value 属性,并修改从父对象继承来的 obj.objectValue。

child.value = "child value";
child.obj.objectValue = "child object value";
修改后,我们再次输出相关值进行对比:
console.log("*** CHANGED: child.value = 'child value'");
console.log("*** CHANGED: child.obj.objectValue = 'child object value'");
console.log("CHILD - child.value: ", child.value);
console.log("CHILD - child.obj.objectValue: ", child.obj.objectValue);
console.log("PARENT - parent.value: ", parent.value);
console.log("PARENT - parent.obj.objectValue: ", parent.obj.objectValue);
console.log("parent: ", parent);
console.log("child: ", child);
现在,控制台会显示关键区别:
child.value现在输出"child value"。因为子对象自身有了value属性,它遮蔽了原型链上的同名属性。父对象的parent.value保持不变。child.obj.objectValue和parent.obj.objectValue现在都输出"child object value"。这是因为obj是一个引用类型的属性,子对象并没有自己的obj属性,访问child.obj时依然会查找到原型链上的parent.obj,修改的是同一个对象。这证实了child.obj和parent.obj是严格相等的。
console.log(child.obj === parent.obj); // 输出: true

多级继承与函数构造器简介
原型链可以不止一层。我们可以基于 child 对象再创建一个 grandchild 对象。
var grandchild = Object.create(child);
console.log("Grandchild: ", grandchild);
grandchild.walk(); // 输出: walking!

grandchild 对象可以成功调用 walk 方法,即使这个方法定义在原型链顶端的 parent 对象中。这展示了原型继承的强大之处。
在进入 AngularJS 控制器语法之前,我们还需要理解一个相关概念:函数构造器。
函数在 JavaScript 中也是对象,可以用作构造新对象的“类”。函数名首字母大写是一种约定,表示它将被用作构造器。
// 函数构造器
function Dog(name) {
this.name = name;
console.log("'this' is: ", this);
}
// 正确用法:使用 new 关键字
var myDog = new Dog("Max");
console.log("myDog: ", myDog);
// 错误用法:省略 new 关键字
var myDog2 = Dog("Max2"); // this 将指向全局对象(如 window)
console.log("myDog2: ", myDog2);
使用 new 关键字调用函数时,函数内部的 this 会指向新创建的对象。如果忘记使用 new,this 将指向全局作用域,这通常会导致错误。

总结



本节课我们一起学习了 JavaScript 原型继承的核心机制。
- 我们使用
Object.create()建立了对象间的原型继承关系。 - 我们理解了属性查找会沿原型链向上进行。
- 我们看到了子对象定义同名属性会遮蔽原型属性,而修改继承来的引用类型属性会影响原型。
- 我们了解了原型链可以有多级,并简要介绍了函数构造器的用法。


这些关于对象、原型和继承的概念,是理解接下来 AngularJS 中控制器和作用域工作原理的重要基础。在下一部分,我们将把这些知识应用到 AngularJS 的具体语境中。
036:作用域继承



概述
在本节课中,我们将要学习 AngularJS 中的一个核心概念:作用域继承。我们将探讨控制器嵌套时作用域如何工作,以及如何利用 controller as 语法来避免常见的作用域属性遮蔽问题,从而编写出更清晰、更易维护的代码。
原型继承回顾
上一节我们介绍了 JavaScript 的原型继承机制。理解这个概念是掌握 AngularJS 作用域继承的基础。
作用域继承的概念
现在,让我们来讨论另一个称为“作用域继承”的概念。
在 AngularJS 应用中,让一个控制器处理页面中的所有功能既不常见,也不是好的实践。更好的做法是编写负责页面较小部分功能的、更小的代码模块。
众所周知,文档对象模型(DOM)是一个由嵌套的 HTML 元素和节点组成的树状结构。当你声明一个控制器负责页面的一部分时,控制器的自然嵌套就会发生。
因此,外部控制器的 $scope 服务(或简称为作用域)对于内部控制器是可用的。然而,AngularJS 使其变得更加优雅:内部控制器的作用域通过原型继承的方式从外部控制器的作用域继承而来。
既然你现在明白了原型继承的含义,你就能理解,声明在外部控制器作用域(在本例中是 controller1)上的属性,无需任何额外努力,就可以被内部控制器作用域(controller2 和 controller3)访问。当然,前提是内部控制器没有在其自身的作用域上声明相同的属性,从而遮蔽了外部父控制器作用域的属性。
属性遮蔽问题
如果我们声明一个名为 prop 的属性在第一个控制器的作用域上,并使其等于字符串 CTRL1 以表示它来自第一个控制器,然后尝试在继承自 controller1 的第二个和第三个控制器的作用域上访问该属性,JavaScript 引擎将沿着原型链向上查找,并在第一个控制器中找到该属性的值,即 CTRL1。
但是,如果我们在 controller2 内部,首先获取从 $scope1 继承的 prop 值,以某种方式对其进行求值,然后想要更改它,使得 $scope1 的 prop 值和 $scope2 的 prop 值相同,甚至希望它们指向相同的内存位置,会发生什么?
然而,如果不手动沿着原型链向上查找并获取 $scope1 对象的实例,我们无法通过原始类型实现这一点。每次我们在 $scope2 上设置 prop 属性,最终都会从 scope2 的视角遮蔽 $scope1 的 prop 属性。因此,在这种场景下,$scope1.prop 将保持不变,仍然等于 CTRL1。
但是,正如你可能从上一个视频中记得的,当我们处理通过原型继承的对象时,情况会非常不同。
原因在于,仅仅为了获取继承对象的属性,我们就必须沿着原型链向上查找。因此,当我们更改此类对象的属性时,我们是在其原始源处进行更改,所以所有控制器都会反映这一更改,并且不会发生遮蔽。
正如你在此案例中看到的,即使我们通过 $scope2(一个内部控制器作用域)的引用更改了对象属性 prop,拥有相同对象属性 prop 的 $scope1 也改变了它的值。所以你可以看到,当我们拥有作用域继承时,使用作为作用域属性的对象,而不是直接位于作用域本身的原始属性,是非常有利的。
controller as 语法
这正是 controller as 语法能给我们带来很大帮助的地方。controller as 语法为我们提供了一个非常方便的对象,我们可以将所有属性附加到该对象上,从而避免属性遮蔽问题。
它允许我们在 HTML 模板中指定一个标签。这个标签的名称作为属性附加到相应的 $scope 服务上。因此,在本例中,对于第一个控制器是 $scope.ctrl1,对于第二个控制器是 $scope.ctrl2。这些属性(在本例中是 ctrl1 和 ctrl2)指向控制器本身的实例对象。
换句话说,控制器实例作为属性附加在 $scope 上。
这意味着,当涉及到实现控制器函数(即负责实现控制器功能的函数)时,我们可以直接将属性附加到控制器函数内部的 this 关键字上,甚至无需将 $scope 注入到控制器中。因此,this 关键字就等同于我们在 HTML 模板中通过 controller as 语法给定的标签名称。
我们能够用不多的代码完成所有这些,是因为 AngularJS 在幕后为我们做了很多工作。
AngularJS 的幕后工作
以下是它大致所做的事情。显然,AngularJS 实际所做的要复杂得多,所以这只是幕后发生事情的一个粗略概念。
在我们的案例中,我们有两个嵌套的控制器:外部控制器 controller1 声明为 ctrl1,内部控制器 controller2 声明为 ctrl2。
AngularJS 为我们的 controller1 创建作用域(我们暂时称之为 $scope1)后,会附加一个名为 ctrl1 的属性(与我们的标签相同),然后使用我们的控制器函数作为函数构造器来实例化它。这意味着 ctrl1 现在等同于我们指定为控制器函数的那个函数构造器内部的 this 关键字。
由于内部作用域 $scope2 将通过原型继承基于外部作用域 $scope1 创建,然后我们将经历一个类似的过程:我们将为 $scope2 创建一个属性,其名称是我们分配给 controller2 的标签(即 ctrl2),并使用 new 关键字创建的 controller2 函数实例来初始化它(因此将 controller2 函数用作函数构造器)。所以,ctrl2 将再次指向与 controller2 函数构造器内部的 this 关键字所指向的相同的东西。
代码可读性的提升
但事情不仅在 JavaScript 内部变得更好、更简单。controller as 语法也使 HTML 模板变得更简单。它不仅允许我访问来自父作用域的同名属性而不会遮蔽任何属性,还使代码更具可读性。
所以现在,当我看到插值表达式 {{ ctrl2.myProp }} 时,我知道这个属性是在 controller2 上实例化和归属的;而 {{ ctrl1.myProp }} 则是在 controller1 上实例化和归属的属性。
总结
本节课中,我们一起学习了 AngularJS 中的作用域继承机制。我们了解到,嵌套控制器的作用域通过原型链连接,这可能导致内部作用域遮蔽外部作用域的原始类型属性。为了解决这个问题,我们引入了 controller as 语法,它允许我们将控制器实例直接绑定到作用域的一个属性上,从而通过对象引用来共享数据,避免了属性遮蔽,并显著提升了代码的清晰度和可维护性。


好的,理论部分暂时介绍到这里。在本讲座的第四部分,我们将跳转到代码编辑器中,亲眼看看这些 AngularJS 概念的实际应用。
037:作用域继承(第4部分)


在本节课中,我们将学习 AngularJS 中嵌套控制器之间的作用域继承机制。我们将通过一个具体的例子,演示原型链如何影响属性的查找和修改,并初步了解 controller as 语法的原理。
示例应用结构
首先,我们来看一个简单的应用。它包含两个控制器:一个父控制器 ParentController1 和一个嵌套在其中的子控制器 ChildController1。
以下是 app.js 文件中的代码:
angular.module('ControllerAsApp', [])
.controller('ParentController1', ParentController1)
.controller('ChildController1', ChildController1)
.controller('ParentController2', ParentController2)
.controller('ChildController2', ChildController2);
目前,我们只关注 ParentController1 和 ChildController1。
父控制器设置

在 ParentController1 中,我们设置了两个属性:
function ParentController1($scope) {
$scope.parentValue = 1;
$scope.pc = this;
this.parentValue = 1;
}
$scope.parentValue = 1;:在作用域对象上设置了一个原始类型的属性parentValue,值为1。$scope.pc = this;和this.parentValue = 1;:this关键字指向ParentController1的实例。我们将其赋值给作用域的pc属性,同时也在控制器实例本身上设置了一个parentValue属性。
子控制器与原型链查找
ChildController1 的代码如下:
function ChildController1($scope) {
console.log("$scope.parentValue: ", $scope.parentValue);
console.log($scope);
}
子控制器自身没有定义 parentValue 属性。当它尝试访问 $scope.parentValue 时,JavaScript 引擎会沿着原型链向上查找。由于子控制器的 $scope 的原型是其父控制器的 $scope,因此它找到了值为 1 的 parentValue。
在浏览器控制台中,我们可以看到子控制器的 $scope 对象本身没有 parentValue,但其 __proto__(原型)对象上存在该属性。
属性屏蔽
接下来,我们在子控制器中添加代码,修改 parentValue:
$scope.parentValue = 5;
console.log("CHANGED: $scope.parentValue = ", $scope.parentValue);
console.log($scope);
现在,子控制器的 $scope 上直接拥有了自己的 parentValue 属性(值为 5)。这屏蔽了原型链上的同名属性。此时,访问 $scope.parentValue 将直接返回子作用域上的值,而不会继续向上查找。原型链上父控制器的 parentValue 仍然是 1。
通过引用修改父级属性
上一节我们看到了属性屏蔽。本节中,我们来看看如何通过引用修改父控制器实例上的属性。
我们通过 $scope.pc 来访问父控制器实例,并修改其上的 parentValue:
console.log("$scope.pc.parentValue: ", $scope.pc.parentValue);
$scope.pc.parentValue = 5;
console.log("CHANGED: $scope.pc.parentValue = ", $scope.pc.parentValue);
console.log($scope);
因为 pc 是一个指向父控制器实例的引用,所以修改 $scope.pc.parentValue 会直接改变父控制器实例上的属性值。在控制台中检查原型链,可以看到父控制器实例(即 pc 对象)的 parentValue 已被更新为 5。
使用 $scope.$parent
AngularJS 提供了 $scope.$parent 属性来安全地访问父作用域,这比直接使用 __proto__ 更推荐。
例如,我们可以这样访问父作用域的属性:
console.log("$scope.$parent.parentValue: ", $scope.$parent.parentValue);
这将输出 1,因为它从父控制器的 $scope 对象上获取 parentValue。

总结
本节课中我们一起学习了:
- 原型链继承:子控制器的
$scope原型指向父控制器的$scope,从而实现属性继承。 - 属性屏蔽:当子作用域定义了与父作用域同名的属性时,会屏蔽父级的属性。
- 通过引用修改:通过附着在作用域上的对象引用(如
pc),可以修改父级控制器实例的属性。 $parent属性:使用$scope.$parent可以安全地访问父作用域。

在下一部分,我们将继续本例,并正式探讨 controller as 语法。
038:Controller As 语法 🎯


在本节课中,我们将要学习 AngularJS 中的 Controller As 语法。这是一种替代传统 $scope 使用方式的方法,它能让代码更清晰、更易于理解,并避免原型继承可能带来的属性遮蔽问题。我们将通过一个具体的代码示例来演示其用法和优势。
上一节我们介绍了 $scope 的原型继承机制。本节中,我们来看看如何使用 Controller As 语法来简化代码并避免继承带来的复杂性。
首先,我们回到代码编辑器。为了避免日志输出造成混淆,我们将注释掉之前的代码。这次,我们将在 index.html 文件中启用两个使用 Controller As 语法的控制器。
以下是 index.html 中的关键部分:
<div ng-controller="ParentController2 as parent">
<!-- 父控制器区域 -->
<div ng-controller="ChildController2 as child">
<!-- 子控制器区域 -->
</div>
</div>
我们定义了两个控制器:ParentController2 和 ChildController2。通过 as parent 和 as child 的语法,我们为每个控制器实例指定了一个标签。这意味着,任何要添加到作用域上的属性,现在不是直接添加到 $scope 上,而是添加到 $scope 上以该标签命名的对象属性上。
因此,在 HTML 模板中,我们可以直接引用这些标签来访问属性。例如,我们可以使用 parent.value 来访问父控制器上的 value 属性,使用 child.value 来访问子控制器上的 value 属性。
接下来,让我们查看 app.js 文件,看看控制器是如何定义的。
首先是 ParentController2:
app.controller('ParentController2', function() {
var parent = this; // 最佳实践:变量名与HTML中的标签名一致
parent.value = 1;
});
这里有一个最佳实践:将控制器函数内部的 this 赋值给一个局部变量(例如 parent),并且这个变量名最好与 HTML 模板中使用的标签名一致。这样做并非强制,有些人喜欢用 vm(代表 ViewModel),但保持名称一致能让代码更清晰。注意,我们甚至不需要注入 $scope 服务,因为属性是直接附加在控制器实例(即 this)上的。AngularJS 会在幕后将这个控制器实例赋值给 $scope 上对应的标签属性。
现在来看 ChildController2:
app.controller('ChildController2', function($scope) {
var child = this; // 变量名与HTML中的标签名一致
child.value = 5;
// 仅用于调试,查看作用域对象
console.log($scope);
});
在子控制器中,我们同样将 this 赋值给 child 变量,并设置了一个同名的 value 属性,其值为 5。由于我们使用了不同的控制器实例(parent 和 child),子控制器的 value 属性不会遮蔽父控制器的 value 属性。我们注入了 $scope 主要是为了将其输出到控制台进行观察,在实际业务代码中,如果不需要直接操作 $scope,则不必注入它。

保存代码并刷新浏览器页面,我们可以看到显示结果:父控制器的值为 1,子控制器的值为 5。
如果打开浏览器的开发者工具,查看控制台输出的 $scope 对象,我们会发现:
$scope对象上有一个child属性,它就是ChildController2的实例,其value属性为 5。- 沿着原型链向上查找,在原型对象上可以找到一个
parent属性,它就是ParentController2的实例,其value属性为 1。

这种结构使得属性来源非常清晰。
Controller As 语法还有一个巨大优势:在子控制器的模板或代码中,可以非常方便且明确地引用父控制器的属性。
例如,我们可以在子控制器对应的 HTML 模板中这样写:


<p>Parent Value (accessed from child): {{ parent.value }}</p>

或者在子控制器的 JavaScript 代码中这样引用:
// 在ChildController2内部
var parentValue = parent.value; // 前提是‘parent’变量在作用域链上可访问
因为 parent 对象通过原型链被继承了下来,所以我们可以直接通过 parent.value 来访问它。这样,代码的可读性和可调试性都大大增强,你能一目了然地知道每个属性属于哪个控制器。
现在,让我们对本节课的内容进行总结。
首先,需要明确,我们在本讲中学到的关于原型继承等概念,不仅适用于理解 Controller As 语法,它们贯穿于整个 AngularJS 框架乃至 JavaScript 语言本身。
我们来回顾几个核心点:
- 继承的目的:继承通常用于代码复用,有时也用于表达实体之间的关系。
- JavaScript 的原型继承:它是基于对象实例而非类的。通常,原型继承比基于类的继承更简单,只需记住几条规则:
- 子对象的属性继承自父对象,并通过原型链进行查找。这些属性并不直接存在于子对象自身上。
- 如果子对象自身声明了与父对象同名的属性,则该属性会成为子对象的本地属性,并会遮蔽父对象的同名属性。
- AngularJS 中的
$scope:$scope基于原型继承。这意味着,嵌套控制器中,子控制器的$scope会从父控制器的$scope原型继承。 - Controller As 语法:其用法非常简单,格式为
ng-controller="ControllerName as labelName"。AngularJS 会自动在对应控制器的$scope上创建一个以labelName命名的属性。这个标签名(labelName)就成为对控制器实例(即函数内部的this关键字)的引用。- 其原理是,AngularJS 的
.controller方法将我们的控制器函数当作构造函数来调用。 - 当你想在视图模型(控制器)和 HTML 模板之间共享属性时,你直接将属性附加到控制器实例(
this)上,而不是$scope上。 - 使用 Controller As 语法后,无论是 HTML 模板还是 JavaScript 代码,语法都变得更加简单直观。我们不再需要处理原型继承可能导致的属性遮蔽问题,使得代码更易于编写和维护。
- 其原理是,AngularJS 的


本节课中,我们一起学习了 Controller As 语法的原理、优势和实践方式。通过将属性直接绑定到控制器实例,并通过明确的标签进行引用,我们能够写出更清晰、更模块化且不易出错的 AngularJS 代码。这是构建复杂单页应用时一个非常推荐的最佳实践。
039:自定义服务 🛠️


概述
在本节课中,我们将要学习为什么以及如何在 AngularJS 中构建自定义服务。我们将探讨控制器在 MVVM 模式中的角色,理解为何业务逻辑不应放在控制器中,并学习如何通过创建单例服务来共享代码与状态。
控制器在 MVVM 中的角色
上一节我们介绍了 MVVM 设计模式。在本节中,我们来看看控制器在其中扮演的具体角色。
控制器是我们之前讨论过的模型-视图-视图模型(MVVM)设计模式中的视图模型。视图模型的工作是表示视图的状态。显然,我们表示该状态的方式是通过控制器实例中处理的数据。正如我们所知,这是通过 $scope 对象实现的。
我们也讨论过,控制器本身不应包含太多(如果有的话)业务逻辑功能,而应将处理业务逻辑的责任传递给其他组件。
控制器的职责与限制
当谈论控制器的职责时,控制器应该负责设置 $scope 上的初始数据状态,同时也应该为 $scope 添加行为。这里所说的“添加行为”,并不意味着控制器应该负责业务逻辑,而是指控制器负责处理任何事件并更新视图状态。换句话说,就是影响 $scope 对象上的值,这些变化是由绑定到 $scope 上某个方法的事件触发的。
另一方面,我们不希望使用控制器(以及其他组件)来直接处理业务逻辑。处理业务逻辑的代码应该被分解到它自己的组件中,这正是我们接下来要讨论的。
此外,控制器也不应用于跨其他控制器共享代码或状态。我们之前提到过,一个 Web 应用程序很少只有一个控制器。在同一个应用中有多个控制器时,不可避免地需要跨这些控制器共享一些数据。控制器不应用于这种类型的共享。所有这些问题的答案就是使用自定义服务。
如何注册自定义服务
那么,如何注册自定义服务呢?实际上,这与注册控制器的方式非常相似。
Angular 中的模块实例有另一个名为 service 的方法。就像控制器一样,它接收服务的名称和一个函数值,该函数值将作为创建该服务的函数构造函数。请注意我的术语:我确实是指提供给 .service 方法的函数值被视为一个函数构造函数,我们在之前的课程中已经讨论过这意味着什么。
同时请注意,你给服务的字符串名称是你用来注入到其他服务或控制器中的名称,而不是用作创建服务的函数构造函数的函数名称。
服务的核心特性:单例与惰性实例化
使用 .service 方法创建服务还有几个非常重要的特性。AngularJS 将使用此方法为我们创建的服务保证是一个单例。
那么什么是单例呢?这是另一个设计模式。单例设计模式的宽松定义是:它限制一个对象始终只有一个实例。这意味着每个依赖组件都将获得对同一个实例的引用。这也意味着,注入了该特定服务的多个控制器都将能够访问同一个服务实例。这使我们能够在应用程序的不同控制器或其他组件之间共享数据。
因此,如果你在一个控制器中将一些数据放入服务中,你可以立即在另一个注入了同一服务的控制器中检索到它。这就是服务在跨应用程序共享数据时非常方便的原因。
我们需要了解的服务的另一个属性是惰性实例化。惰性实例化意味着,只有在应用程序组件声明依赖该服务时,该服务才会被创建。如果我们应用程序中没有组件依赖于该服务,那么它甚至永远不会被创建。


总结
本节课中我们一起学习了在 AngularJS 中创建自定义服务的核心理念。我们明确了控制器应专注于视图状态管理,而将业务逻辑和跨组件数据共享的职责委托给服务。通过 .service 方法注册的服务是单例的,这确保了数据的一致性共享,并且它们是惰性实例化的,有助于优化应用性能。在下一部分,我们将进入代码编辑器,实际查看这些概念的应用。
040:自定义服务 🛠️


在本节课中,我们将学习如何在 AngularJS 应用中创建和使用自定义服务。服务是一种强大的工具,用于在控制器之间共享数据和业务逻辑,使代码更加模块化和可维护。
概述
我们将构建一个简单的购物清单应用。该应用包含两个主要部分:一个用于添加新物品,另一个用于显示清单。这两个部分由不同的控制器管理。为了在它们之间共享数据(即购物清单),我们将创建一个自定义服务。通过本教程,你将理解服务如何作为单例(Singleton)工作,以及如何利用它来协调不同组件间的状态。
应用结构分析
首先,我们来看一下应用的基本结构。应用包含两个控制器:
shoppingListAddController:负责处理用户输入,将新物品添加到清单。shoppingListShowController:负责获取并显示当前的购物清单。
这两个控制器是独立的,无法直接共享数据。为了解决这个问题,我们需要一个中介——自定义服务。
创建自定义服务
在 AngularJS 中,我们使用 .service 方法来创建自定义服务。这个服务将作为单例,意味着整个应用中只会存在它的一个实例。所有依赖它的组件都将获得这同一个实例,从而实现数据共享。
以下是购物清单服务的定义:
// 定义名为 `ShoppingListService` 的服务
app.service('ShoppingListService', function () {
// 内部私有变量,存储物品列表
var items = [];
// 公开给外部使用的方法:添加物品
this.addItem = function (itemName, itemQuantity) {
var item = {
name: itemName,
quantity: itemQuantity
};
items.push(item);
};
// 公开给外部使用的方法:获取所有物品
this.getItems = function () {
return items;
};
// 公开给外部使用的方法:根据索引移除物品
this.removeItem = function (itemIndex) {
items.splice(itemIndex, 1);
};
});
核心概念解析:
- 单例模式:通过
.service()创建的服务是单例。ShoppingListService在整个应用生命周期内只被实例化一次。 - 数据封装:服务内部的
items数组是私有的,外部只能通过公开的方法(如getItems)来访问,这保护了数据结构的内部实现。 - 方法暴露:通过将函数赋值给
this(如this.addItem),我们将其作为服务实例的方法公开出去。
在控制器中注入并使用服务
定义了服务之后,我们需要在控制器中“注入”它才能使用。AngularJS 的依赖注入机制会自动为我们提供该服务的单例实例。
添加物品控制器
shoppingListAddController 依赖 ShoppingListService 来保存用户输入的新物品。
app.controller('ShoppingListAddController', ['$scope', 'ShoppingListService',
function ($scope, ShoppingListService) {
var adder = this;
// 初始化绑定到输入框的模型数据
adder.itemName = '';
adder.itemQuantity = '';
// 添加物品的方法
adder.addItem = function () {
// 调用服务的 addItem 方法
ShoppingListService.addItem(adder.itemName, adder.itemQuantity);
// 清空输入框
adder.itemName = '';
adder.itemQuantity = '';
};
}
]);
显示清单控制器
shoppingListShowController 同样依赖 ShoppingListService,用于获取要显示的物品列表,并处理移除操作。

app.controller('ShoppingListShowController', ['$scope', 'ShoppingListService',
function ($scope, ShoppingListService) {
var showList = this;
// 从服务获取物品列表
showList.items = ShoppingListService.getItems();
// 移除物品的方法
showList.removeItem = function (itemIndex) {
// 调用服务的 removeItem 方法
ShoppingListService.removeItem(itemIndex);
};
}
]);
关键点:两个控制器注入的是同一个 ShoppingListService 实例。因此,在 AddController 中添加的物品,会立即反映在 ShowController 获取的 items 列表中。

构建用户界面
有了控制器和服务,我们需要构建对应的 HTML 模板来绑定数据和操作。
添加物品区域
此部分绑定到 ShoppingListAddController(别名为 adder)。
<div ng-controller="ShoppingListAddController as adder">
<h3>添加新物品</h3>
<input type="text" ng-model="adder.itemName" placeholder="物品名称">
<input type="text" ng-model="adder.itemQuantity" placeholder="数量">
<button ng-click="adder.addItem()">添加到购物清单</button>
</div>
显示清单区域
此部分绑定到 ShoppingListShowController(别名为 showList),并使用 ng-repeat 循环显示物品。
<div ng-controller="ShoppingListShowController as showList">
<h3>购物清单</h3>
<ol>
<!-- 循环遍历 items 数组,$index 是当前项的索引 -->
<li ng-repeat="item in showList.items">
{{ item.quantity }} 份 {{ item.name }}
<!-- 点击按钮调用移除方法,并传入当前项的索引 -->
<button ng-click="showList.removeItem($index)">移除</button>
</li>
</ol>
</div>
模板说明:
ng-repeat指令会为数组中的每个元素创建一个新的作用域。$index是ng-repeat提供的一个特殊属性,代表当前迭代项在数组中的索引位置,我们将其传递给removeItem方法。

功能演示与交互
当用户在输入框中填写信息(例如“饼干,3袋”)并点击“添加”按钮后:
adder.addItem()方法被调用。- 该方法调用
ShoppingListService.addItem(),将新物品对象存入服务内部的数组。 - 由于
showList.items是通过ShoppingListService.getItems()获取的,它直接引用了服务内部的同一个数组。因此,视图会自动更新,新物品立即出现在下方的清单中。 - 每个物品旁边都有一个“移除”按钮。点击时,会调用
showList.removeItem($index),并传入该物品的索引。服务中的removeItem方法会从数组中删除对应项,清单显示也随之更新。
核心概念总结
本节课中我们一起学习了 AngularJS 自定义服务的创建与应用。让我们回顾一下关键要点:
- 控制器的职责:控制器应专注于管理视图逻辑,不应直接处理复杂的业务逻辑或在不同组件间共享代码与数据。
- 服务的作用:自定义服务是存放可复用业务逻辑和共享数据的理想场所。它促进了代码的模块化和关注点分离。
- 服务的特性:
- 单例:通过
.service()方法创建的服务是单例,整个应用只有一份实例。 - 延迟实例化:服务只有在被某个组件依赖注入时才会被创建。
- 构造函数调用:传递给
.service()的函数会被 AngularJS 使用new关键字调用,因此函数内部的this指向新创建的服务实例。
- 单例:通过
- 数据流:通过向多个控制器注入同一个服务实例,可以轻松实现跨控制器的数据同步与状态管理。


通过将购物清单的管理逻辑抽取到 ShoppingListService 中,我们的控制器变得简洁且职责清晰,应用结构也更加健壮和易于维护。
041:使用工厂创建自定义服务 🏭


在本节课中,我们将要学习如何使用 AngularJS 的 factory 方法来创建自定义服务。我们将探讨工厂设计模式的基本概念,比较 factory 方法与 service 方法的区别,并学习两种不同的工厂函数实现方式。
概述
工厂设计模式的核心是一个中心化的地方,用于生产新的对象或函数。在 AngularJS 中,factory 方法就是一个强大的工具,它不仅可以创建单例服务,还能生产任何类型的对象,并支持动态配置。这与 service 方法有显著区别,后者是一种功能更受限的工厂。
工厂设计模式回顾
首先,回顾一下工厂设计模式的特征是有益的。该模式的核心是一个中心化的地方,用于生产新的对象或函数。这不是工厂设计模式的官方定义,但它能清晰地传达其核心思想,而无需过多的计算机科学术语。
对于我们的目的而言,关键在于工厂可以生产任何类型的对象,而不仅仅是单例。它还可以用于生产可动态定制的服务,这正是我们本节课最终要实现的目标。
Factory 与 Service 的对比
AngularJS 社区中关于 factory 与 service 的对比存在一些混淆。factory 方法有时被称为服务工厂,这是合理的,因为工厂就是用来生产服务的。
然而,factory 方法与 service 方法之间存在非常重要的区别。factory 方法不仅仅是创建与 service 方法相同服务的另一种方式。service 方法本身也是一种工厂,但与 factory 方法相比,它的功能要有限得多。
service 方法是一种总是生产同类型服务(单例)的工厂,并且没有简单的方法来配置其行为。service 方法通常用作不需要任何配置的快捷方式。
注册服务工厂函数
注册服务工厂函数的方式与我们注册服务和控制器的方式非常相似。你在模块上调用 factory 方法,指定工厂的名称(例如 customService),然后指定工厂函数。
工厂函数的作用是在执行时生产一个服务。请注意一个非常重要的区别:如果我们用 service 方法进行完全相同的调用,那么 customService 本身就会被期望是服务,而不是通过执行其方法之一来生产服务的结果。
另外请注意,在这个例子中,我们将工厂函数命名为 customService,并且我们注册到 factory 方法下的名称也是相同的 customService。但事实上,我们给函数起什么名字并不重要。重要的是我们注册该函数时使用的名称(即引号内的部分),这个名称才是我们注入到其他服务、控制器等中时使用的名称。所以,关键不是函数的名字,而是你通过 factory 方法注册该函数时使用的名字。
编写工厂函数
当涉及到编写工厂函数本身时(在本例中是 customService 函数),有几种不同的实现方式可以选择。
第一种实现方式如下所示,它返回一个函数作为我们工厂函数执行的结果。你可以看到,返回的局部变量 factory 实际上是一个函数值。我们最终的目标是创建一个名为 someService 的服务。
请注意,是我们自己调用了 new 关键字。换句话说,我们控制着如何创建以及创建什么。一旦我们创建了我们想要的东西,我们就可以将其作为 customService 函数执行的结果返回。换句话说,我们的工厂函数本身返回一个函数,因为返回的局部变量 factory 引用了一个函数。这是实现工厂函数的一种方式。
实现工厂函数的另一种方式是返回一个对象字面量。这里我们创建了一个带有属性 getSomeService 的对象字面量。属性 getSomeService 的值是一个函数。因此,customService 工厂函数执行后的返回值是一个对象字面量。然后,我们可以获取该对象字面量的 getSomeService 属性(其值是一个函数)并执行它,从而获得一个我们可以使用的 someService 实例。
即使使用这种实现方式,我们仍然负责实例化名为 someService 的服务。我们控制着它的创建方式,而这正是使用 factory 方法的最大意义所在。
两种实现方式的对比
为了突出两种同样有效的方法之间的区别,我们提取出了工厂函数的主体部分。
在左侧,你看到一种方法,它是一个带有属性方法的对象字面量,该方法会调用 new SomeService()。这个对象字面量是工厂函数执行后返回的一部分。
在右侧,我们返回一个函数,该函数返回对新 SomeService 的引用。该函数的值 factory 就是当你执行工厂函数本身时返回的东西。
根据你选择的实现方式,使用工厂的方式显然会有所不同。
如果你选择对象字面量方法,那么使用它的方式是通过引用 AngularJS 为你创建的工厂函数作为一个对象,然后访问其方法属性,即 .getSomeService。由于 customService 引用的是一个对象字面量,你将通过点符号访问其名为 getSomeService 的属性,就像访问任何对象字面量的属性一样。然而,getSomeService 属性是一个函数,因此为了执行该函数,你只需在其后加上括号 ()。完成后,你就可以在由该属性引用的函数内部使用生产出来的服务,例如 someService.someMethod() 等等。
在使用函数作为工厂函数返回值的实现方式中,与对象字面量方法(其中 customService 工厂函数实际上引用一个对象字面量)不同,在这种情况下,customService 将引用一个函数。因此,我们需要做的就是在其后加上括号 () 并执行它,以获得我们创建的服务,然后像之前一样使用,例如 someService.someMethod() 等等。
下一步:查看代码示例
好了,现在我们理解了创建这些工厂的过程,让我们进入代码编辑器,看一个如何创建和使用这些工厂的示例。
如果你从未听说过工厂模式,这个概述可能第一次尝试时有点难以消化。我的建议是你继续学习第二部分,查看这些概念的编码示例,然后再回来看这个视频,以便在更深层次上掌握这些概念。
总结


在本节课中,我们一起学习了如何使用 AngularJS 的 factory 方法创建自定义服务。我们了解了工厂设计模式的核心思想,明确了 factory 与 service 方法的区别,并探讨了两种主要的工厂函数实现方式:返回对象字面量和返回函数。理解这些概念是构建灵活、可配置的 AngularJS 服务的关键。在接下来的部分,我们将通过实际代码来巩固这些知识。
042:使用工厂创建自定义服务 🏭


在本节课中,我们将学习如何使用 AngularJS 的工厂(Factory)来创建可配置、非单例的自定义服务。我们将通过构建一个包含两个独立购物列表的应用来演示这一概念。
概述
我们将创建一个应用,其中包含两个并排显示但完全独立的购物列表。为了实现这一点,我们需要一个购物列表服务,但这次我们不希望它是单例模式,因为两个列表的数据应该互不影响。我们将使用工厂模式来创建这个服务,并为其添加一个可选的“最大物品数量”限制功能。
修改购物列表服务
首先,我们需要修改基础的购物列表服务,使其能够支持最大物品数量的限制。
以下是修改后的服务核心代码。主要变化在于 addItem 方法,它在添加新物品前会检查是否已达到最大数量限制。
// 购物列表服务
function ShoppingListService(maxItems) {
var service = this;
var items = [];
service.addItem = function (itemName, quantity) {
// 检查最大物品数量限制
if ((maxItems === undefined) ||
(maxItems !== undefined) && (items.length < maxItems)) {
var item = {
name: itemName,
quantity: quantity
};
items.push(item);
} else {
// 如果达到限制,则抛出错误
throw new Error("Max items (" + maxItems + ") reached.");
}
};
service.getItems = function () {
return items;
};
}
代码解释:
maxItems参数是可选的。如果未定义,则表示没有数量限制。- 在
addItem方法中,我们首先检查maxItems是否为undefined,或者当前物品数量是否小于maxItems。 - 如果条件满足,则添加物品。
- 如果条件不满足(即已达到最大数量),则使用
throw new Error()抛出一个错误。
创建工厂函数
上一节我们介绍了如何修改基础服务以支持配置。本节中,我们将看看如何创建一个工厂函数来动态生成这个服务的实例。
我们不使用 .service() 方法让 AngularJS 自动创建单例服务,而是创建一个工厂。工厂是一个函数,它负责创建并返回我们需要的对象(在这里是服务实例)。
以下是工厂函数的定义:
// 工厂函数
app.factory('ShoppingListFactory', function () {
var factory = function (maxItems) {
return new ShoppingListService(maxItems);
};
return factory;
});
核心概念:
.factory()方法接收两个参数:工厂名称和一个工厂函数。- 工厂函数本身返回另一个函数(这里命名为
factory)。 - 当其他组件(如控制器)注入
ShoppingListFactory并调用它时(例如ShoppingListFactory(3)),实际执行的是这个内部返回的函数。 - 这个内部函数接收
maxItems参数,并利用它创建一个新的ShoppingListService实例返回。
在控制器中使用工厂
现在,我们可以在不同的控制器中使用这个工厂来获取独立的服务实例。
控制器 1:无限制列表
第一个控制器创建一个没有物品数量限制的购物列表。
app.controller('ShoppingListController1', ['ShoppingListFactory', function (ShoppingListFactory) {
var list1 = this;
// 使用工厂创建新的服务实例,不传入参数,表示无限制
var shoppingList = ShoppingListFactory();
list1.items = shoppingList.getItems();
list1.addItem = function () {
try {
shoppingList.addItem(list1.itemName, list1.itemQuantity);
} catch (error) {
list1.errorMessage = error.message;
}
};
}]);
控制器 2:有限制列表(最多3个)
第二个控制器创建一个最多只能有3个物品的购物列表,并演示错误处理。
app.controller('ShoppingListController2', ['ShoppingListFactory', function (ShoppingListFactory) {
var list2 = this;
// 使用工厂创建新的服务实例,并传入最大数量限制:3
var shoppingList = ShoppingListFactory(3);
list2.items = shoppingList.getItems();
list2.addItem = function () {
try {
shoppingList.addItem(list2.itemName, list2.itemQuantity);
list2.errorMessage = ""; // 添加成功则清空错误信息
} catch (error) {
list2.errorMessage = error.message; // 捕获并显示错误
}
};
}]);
关键点:
- 两个控制器注入了同一个
ShoppingListFactory。 - 通过向工厂函数传递不同的参数(
undefined或3),我们得到了两个配置不同的、完全独立的ShoppingListService实例。 - 在第二个控制器中,我们使用
try...catch块来捕获服务抛出的错误,并将错误信息显示在界面上。
应用演示

在浏览器中运行应用,我们可以看到:
- 两个购物列表可以独立操作,互不影响。
- 第一个列表可以添加任意数量的物品。
- 当尝试向第二个列表添加第4个物品时,会触发错误,并显示“Max items (3) reached.”的提示信息。
- 这证明了工厂成功创建了两个独立的服务实例。
.factory 与 .service 的对比
让我们总结一下工厂模式与服务模式的区别。
以下是两者之间的核心区别:
.factory()方法:功能强大且灵活。它可以返回任何东西——一个对象、一个函数、一个服务实例,甚至是单例。它非常适合需要定制或配置的场景。.service()方法:是.factory()的一种特例,限制更多。它只能用来创建一个单例服务实例(通过new关键字调用构造函数),并且难以在创建时进行自定义配置。
调用方式对比:
// 工厂模式:工厂函数返回我们定制的创建逻辑
app.factory(‘FactoryName‘, function() {
return function(config) {
// 根据 config 创建并返回某个对象
return new SomeService(config);
};
});
// 服务模式:直接注册一个构造函数,Angular 会帮我们 new 一个单例
app.service(‘ServiceName‘, SomeServiceConstructor);
当注入到控制器时:
- 注入
FactoryName得到的是工厂函数返回的东西(例如一个创建函数)。 - 注入
ServiceName得到的是 Angular 已经创建好的单例服务实例。
总结


本节课中我们一起学习了如何使用 AngularJS 的工厂(.factory())来创建可配置的自定义服务。关键点在于:
- 工厂模式提供了比服务模式更大的灵活性,允许我们创建非单例的、可定制的对象。
- 我们通过工厂函数返回一个创建函数,从而让控制器能够按需生成具有不同配置的服务实例。
- 通过一个双列表购物车应用的实践,我们看到了两个控制器如何使用同一个工厂获得完全独立、配置各异的服务实例,实现了数据的隔离。
- 最后,我们明确了
.factory()与.service()方法在用途和灵活性上的主要区别。
043:使用提供者创建自定义服务


概述
在本节课中,我们将要学习 AngularJS 中最灵活的服务创建方法——.provider 方法。我们将了解如何通过提供者创建可配置的服务,以及如何在应用启动前对其进行自定义配置。
什么是提供者方法
.provider 方法是 Angular 中创建服务最详细、最灵活的方法。使用提供者方法,你不仅可以创建一个工厂,还可以在整个应用启动时仅配置一次该工厂,然后在应用中使用带有自定义设置的工厂。换句话说,你可以在应用启动前配置这个工厂。
事实上,根据 Angular 官方文档,当我们使用 .service 或 .factory 方法配置服务时,真正在幕后执行的就是提供者方法。
上一节我们介绍了服务的基本概念,本节中我们来看看如何使用 .provider 方法。

使用提供者方法的步骤
以下是使用 .provider 方法创建和配置服务的详细步骤。
第一步:定义提供者函数
第一步是定义提供者函数。这是一个特殊的函数,在其实际实例上有一个特定的属性。你会看到 var provider = this;,因此 provider.$get 属性是一个直接附加到提供者实例的函数。
这个 $get 函数是一个工厂函数。换句话说,它就像我们提供给 .factory 方法的函数一样,你在这个函数中创建你的服务。在我们的例子中,我们创建了一个名为 service 的新服务。
正是这个作为函数的 $get 属性,使得该函数成为一个提供者。AngularJS 期望提供者有一个 $get 属性,其值是一个函数,Angular 会将其视为工厂函数。
但使整个设置非常特别的是,你可以在服务提供者内部提供一些配置对象,这些对象通常带有默认值,你可以在稍后配置整个应用的步骤中覆盖它们。
例如,我们可以这样定义:
function ServiceProvider() {
var provider = this;
provider.config = {
prop: 'default value'
};
provider.$get = function() {
var service = {};
// 使用 provider.config 中的配置
service.someProperty = provider.config.prop;
return service;
};
}
第二步:向模块注册提供者函数
向模块注册提供者的方式与注册控制器、工厂和服务非常相似。你只需调用模块实例上的 .provider 方法,并提供提供者将产生的服务名称,以及拥有 $get 属性的服务提供者函数。
请注意,你在提供者函数中提供的名称是服务名称,它将被注入到其他服务、控制器等中。服务提供者函数本身的名称完全无关紧要。
angular.module('myApp').provider('service', ServiceProvider);
第三步:像往常一样注入服务
第三步是像往常一样注入我们的服务。你会注意到,我们注入的是 .provider 定义的服务名称,而不是服务提供者函数。我们的 .provider 声明我们将拥有一个名为 service 的服务,因此我们注入的也叫 service。
angular.module('myApp').controller('MyCtrl', function(service) {
// 使用 service
});
到目前为止,我们还没有真正配置任何东西,所以接下来是可选的第四步。
第四步(可选):配置服务
第四步是可选的,它分为两个子步骤:注册配置函数和定义配置函数。
4A:注册配置函数
配置函数是可以在模块实例上调用的特殊函数,它保证在任何服务、工厂或控制器创建之前运行。这意味着这是我们能够在这些服务被创建之前对其进行配置的步骤。
它是可选的,因为你不必配置它,你可以只让默认值生效。然而,对于某些服务来说,拥有任何类型的默认值都没有意义,因此有时业务逻辑或特定服务要求必须进行配置。
4B:定义配置函数
在定义配置函数时,我们将 serviceProvider 注入到配置函数中。我们不能将任何常规服务注入到配置函数中,因为它是在任何服务、工厂或控制器创建之前执行的。
因此,我们需要做的是获取特定服务的提供者函数。我们注入到配置函数中的是那个字符串加上 Provider 后缀。在我们的例子中,提供者是用 service 声明的,因此我们注入到配置函数中并在其中使用的是 serviceProvider。
同样,实际提供者函数的名称完全无关紧要。最后,我们可以使用 serviceProvider 实例来访问其属性(如 config),并继续为特定应用配置它。
angular.module('myApp').config(function(serviceProvider) {
// 配置提供者
serviceProvider.config.prop = 'custom configured value';
});
代码实践
好的,现在我们已经在理论上了解了整个过程,在本讲的第二部分,让我们进入代码编辑器,看看它是如何实际运作的。
(此处通常会展示实际的代码编辑器截图和代码示例,演示上述步骤的具体实现。)
总结

本节课中我们一起学习了 AngularJS 中使用 .provider 方法创建自定义服务。我们了解了提供者是最灵活的服务创建方式,允许在应用启动前进行配置。我们逐步讲解了定义提供者函数、注册提供者、注入服务以及可选地通过配置函数自定义服务设置的完整流程。掌握提供者方法对于创建高度可配置和可重用的 AngularJS 服务至关重要。
044:使用提供者创建自定义服务(第2部分)


在本节课中,我们将学习如何使用 AngularJS 中的 provider 来创建自定义服务。provider 是创建服务最灵活的方式,它允许我们在应用启动前进行配置。
项目概览
我们回到代码编辑器中,位于 lecture 22 文件夹内。这里有一个购物清单应用,其界面与之前使用 factory 创建服务的应用看起来几乎相同。
应用包含两个输入框,用于输入商品名称和数量。点击“添加商品”按钮后,商品会通过 ng-repeat 指令立即显示在列表中。但这次,我们的购物清单服务将使用 provider 来创建。
查看服务提供者
让我们查看 app.js 文件。可以看到,这里使用了 .provider 方法,而不是 .service 或 .factory。
.provider('ShoppingListService', function ShoppingListServiceProvider() {
// 提供者逻辑
})
服务名称是 ShoppingListService,提供者函数名是 ShoppingListServiceProvider。函数名本身并不重要,但加上 Provider 后缀可以使其更清晰。
向下滚动,查看 ShoppingListServiceProvider 函数的具体实现。
var provider = this;
provider.defaults = {
maxItems: 10
};
provider.$get = function () {
// 创建并返回服务实例的逻辑
};
这里的关键是,我们将 this 关键字赋值给局部变量 provider,然后为这个提供者实例附加一个 $get 属性。$get 是一个函数,它负责创建并返回 ShoppingListService 的实例给任何请求该服务的地方。
我们还创建了一个默认对象,其 maxItems 属性值为 10。这意味着,当创建购物清单服务时,它会默认限制清单中最多只能有 10 件商品。
控制器中的服务注入

接下来,我们看看控制器。控制器 ShoppingListController 注入了 ShoppingListService(注意,注入的是服务名,而不是提供者名)。
.controller('ShoppingListController', ['ShoppingListService', function(ShoppingListService) {
var ctrl = this;
ctrl.items = ShoppingListService.getItems();
// ... 其他控制器逻辑
}])
控制器从 ShoppingListService 实例中获取商品列表。这是因为我们在生成服务时返回了该实例。控制器的其余代码,如添加商品(确保不超过最大数量)和错误处理逻辑,与之前完全相同。


在浏览器中测试应用
保存代码后,我们可以在浏览器中看到购物清单应用。我们可以尝试添加商品,例如“饼干,5袋”。当添加的商品数量接近默认的最大值 10 时,如果我们尝试添加第 11 件商品,服务内部会抛出一个错误。控制器会捕获这个错误,并将错误信息显示在页面上。
配置提供者

目前,我们依赖的是提供者的默认配置(maxItems: 10)。但我们可以通过 config 函数在应用启动前对其进行配置。
让我们修改模块定义,添加一个 config 函数。

.config(function (ShoppingListServiceProvider) {
ShoppingListServiceProvider.defaults.maxItems = 2;
})
以下是配置步骤:
- 定义
config函数。 - 在注入数组中声明
ShoppingListServiceProvider(服务名 +Provider后缀)。 - 在
config函数中,我们可以访问提供者的属性。在这里,我们将maxItems的值修改为 2。
现在,在整个应用的生命周期内,购物清单服务最多只允许包含 2 件商品。


回到浏览器测试,添加两件商品后,再尝试添加第三件,就会看到错误提示。当然,我们可以通过移除已有商品来继续添加新商品,所以这个配置并不能完全“阻止”购物,但展示了配置的能力。
总结
本节课我们一起学习了如何使用 AngularJS 的 provider 来创建和配置自定义服务。
在 service、factory 和 provider 这三种创建服务的方式中,provider 的写法最繁琐,但也是最灵活的。它的灵活性在于,不仅可以在使用时配置,还可以在应用启动引导阶段、任何其他组件实例化之前进行配置。
provider 的工作原理是:
- 你提供一个服务名称和一个提供者函数。
- 提供者函数必须在其实例上拥有一个
$get属性,该属性是一个函数,用于创建并返回最终的服务实例。 - 其他组件注入的是服务名称对应的实例,而不是提供者本身。
- 可选的
.config函数在服务、工厂或控制器实例化之前被调用。因此,我们不能在config函数中注入常规的服务或工厂实例,但可以通过在服务名后附加Provider字符串来注入该服务的提供者。这样,我们就能在应用开始时一次性配置好生产服务的工厂。


通过 provider,我们能够实现应用级别的、一次性的服务配置,这是构建复杂、可配置 AngularJS 应用的重要工具。
045:ng-if、ng-show和ng-hide




在本节课中,我们将要学习 AngularJS 中的三个重要指令:ng-if、ng-show 和 ng-hide。这些指令用于根据条件控制 HTML 元素的显示与隐藏。我们将通过一个购物清单应用的例子,详细讲解它们的工作原理、区别以及适用场景。
在之前的几讲中,我们实现了一个功能:当购物清单中的商品数量超过某个上限时,会显示一条错误信息。
这条错误信息是控制器实例的一个属性,而控制器实例本身又附加在作用域服务上。然而,我们之前的方法是无条件地显示错误标签。如果没有错误,我们只是在标签旁边显示一个空字符串。
我们真正希望实现的是:如果没有错误信息,就隐藏整个包含错误信息的 div 元素;如果有错误信息,则显示它。我们已经学习过像 ng-controller、ng-app、ng-repeat 这样的 Angular 指令。在本讲中,我们将学习另外几个指令:ng-if、ng-show 和 ng-hide。
这些指令背后的理论并不复杂,所以让我们直接看代码,了解它们是如何工作的。
代码示例:购物清单应用
我们回到代码编辑器,进入 lecture23 文件夹,它位于 full-course-5-examples 目录中。这是我们之前见过的购物清单应用。

这里有一个名为 ShoppingListController 的控制器。我们使用 controller as 语法,在控制器内部将其命名为 list。
正如你所记得的,当用户点击“添加商品”按钮时,如果购物清单服务内部的 items 数组超过了设定的最大商品数量,服务会抛出一个异常。让我们在 app.js 中查看一下相关代码。
向下滚动到 addItem 方法,你会看到,如果特定语句抛出异常,我们会捕获这个错误,并将错误对象中的信息赋值给 list.errorMessage。
回到 index.html,你会看到我们在这里显示了“Error”标签,然后显示从控制器返回的错误信息。

如前所述,我们真正想做的是:如果没有错误信息,就完全不显示这个 div。
使用 ng-if 指令
实现这个目标的一种方法是使用 ng-if 指令。ng-if 指令将一个条件作为其值。如果该条件为真(true),则显示整个 div;如果条件为假(false),AngularJS 会将整个 div 从 DOM 树中移除。换句话说,就好像这个 div 在我们的 HTML 中不存在一样。
让我们保存修改,然后打开浏览器查看效果。打开 Chrome 开发者工具,切换到“元素”面板。
查看我们的 HTML,可以看到在有序列表之后,我们没有看到 div,而是看到一个名为 <!-- ngIf: list.errorMessage --> 的 HTML 注释。这个 div 不存在,因为 list.errorMessage 的求值结果为假(false)。
然而,如果我们开始添加商品(这里先随意添加一些内容),当添加的商品超过五个时,你会看到那个 div 突然出现了,并成为了文档对象模型的一部分。
你可以在这里看到 div,看到 ng-if 和它的 class="error"。这个 error 类是我们赋予它的,它来自 styles.css,这就是为什么我们的文本颜色是红色的。现在 div 显示出来了。
使用 ng-show 指令
但是,还有另一种隐藏这条信息的方法。让我们回到代码编辑器,在 HTML 模板中,注释掉使用 ng-if 的 div,并使用一些预先写好的代码。我们现在使用的是 ng-show。
ng-show 做的事情和 ng-if 几乎一样,区别在于它不会将 div 从文档对象模型中移除,而是简单地为元素附加特殊的 CSS 类,Angular 会根据这些类来隐藏 div。
保存修改,回到浏览器。在添加五个商品之前,我们可以看到 div 已经存在,但它有一个 ng-hide 类作为其类之一,这就是它被隐藏的原因。实际上,如果你查看 ng-hide,会看到它设置了 display: none,所以它被隐藏了。
随着我们继续添加商品,当添加第六个商品时,ng-hide 类会从这个 div 上移除,因此它现在就在浏览器中显示出来了。
使用 ng-hide 指令
另一个指令叫做 ng-hide。你可能已经猜到,它和 ng-show 的作用完全相反。
ng-show 和 ng-hide 的唯一区别在于,它们根据条件的真假来决定是隐藏还是显示元素。ng-show 会在其赋值为假(false)时隐藏元素;ng-hide 则会在其赋值为假(false)时显示元素,反之亦然。
让我们保存修改。可以看到,我们在这里设置的条件是 !list.errorMessage。换句话说,当 list.errorMessage 为假(false)时,我们将隐藏这个 div。由于空字符串的求值结果为假(false),所以这时我们会隐藏整个 div。
回到浏览器,向下滚动,可以看到 div 仍然存在,并且 ng-hide 类已经应用到了这个 div 上。这是因为表达式求值为假(false),加上感叹号取反后变为真(true),所以 ng-hide 的条件为真(true),它正在隐藏 div。

同样,如果我们添加一些商品,在添加第六个商品时,ng-hide 类将被移除,因为 list.errorMessage 变成了真(true),其相反值假(false)导致它不再隐藏我们的 div。
总结
本节课中我们一起学习了 AngularJS 中控制元素显示与隐藏的三个指令。
ng-if:这是一个通用的条件判断属性指令。如果其值为假(false),Angular 会将包含它的元素从文档对象模型中完全移除。ng-show和ng-hide:这些属性指令会自动将 CSS 类附加到包含元素上,根据其值的真假来决定是显示还是隐藏元素。然而,包含元素并不会从文档对象模型中被移除。
因此,如果你出于某些原因需要在元素隐藏时仍然能够操作它,可以使用 ng-show 或 ng-hide。但使用 ng-if 则无法做到这一点,因为当其值为假时,整个元素会从文档对象模型中被移除。




046:模块2总结

概述
在本节课中,我们将对模块2的学习内容进行回顾与总结,并展望后续的学习方向。
恭喜你完成了模块2的学习,现在可以进行一个简短的回顾。
如果你已经跟随我理解了本模块所教授功能背后的概念,那么你已经领先于许多仅仅是在使用 Angular 功能、却不理解其工作原理的 AngularJS 开发者。这并非微不足道的成就,你应该为此感到自豪。
快速回顾一下,现在仅仅是模块2,但你已经掌握了相当多的知识。所以请不要在此止步,继续学习模块3,我保证前方还有更多有趣的内容。
如果你还没有访问我们的 Facebook 页面(Facebook.com/courserawebdev),请点击视频下方的链接并为我们的页面点赞。
如果你听从了我关于饼干的建议,并且吃掉了整袋饼干,那么恭喜你,你在这个模块中获得的不仅仅是 AngularJS 的知识。
如果你在本模块开始时听从了我的建议,拿了一袋饼干,那么你获得的将不仅仅是 AngularJS 的知识,你还增加了一些体重。
我正在忙着录制。哦,你能把饼干加到购物清单上吗?我想我们快吃完了。哎,那个东西还在录吗?
总结

本节课中我们一起学习了模块2的总结。我们回顾了理解功能背后概念的重要性,并鼓励大家继续学习模块3的进阶内容。同时,我们也以一种轻松的方式提醒大家,学习与生活需要平衡。
047:欢迎进入模块3

在本模块中,我们将学习 AngularJS 的许多核心功能。我们将从 Promise API 开始,然后学习如何使用 Angular 内置的 HTTP 服务与服务器通信,最后将花大量时间深入探讨 AngularJS 的皇冠特性——指令。
模块内容概述
上一节我们完成了模块2的学习,本节中我们来看看模块3的主要内容。
模块3将涵盖以下三个核心主题:
- Promise API:这是现代 JavaScript 开发中至关重要的概念,虽然对 Angular 至关重要,但其应用范围远超 Angular 本身。
- HTTP 服务:我们将学习如何使用 Angular 内置的
$http服务向服务器发起调用。 - 指令:我们将投入大量时间学习 AngularJS 框架的核心特性。指令不仅允许我们扩展现有 HTML 元素的功能,还允许我们创建具有自定义视图和行为的新元素。
以下是本模块学习路径的简要说明:
- 首先,我们将深入理解 Promise,这是处理异步操作的基础。
- 接着,我们将应用 Promise 知识,学习如何通过
$http服务与后端服务器进行数据交互。 - 最后,我们将重点学习 指令,这是构建可复用组件和扩展 HTML 能力的关键。
总结

本节课中我们一起学习了模块3的总体安排。我们了解到,本模块将引导我们掌握 Promise API、HTTP 服务以及强大的指令系统。这些是深入理解和有效使用 AngularJS 进行单页应用开发的基石。接下来,让我们开始第一个主题——Promise 的学习。
048:使用 Promise 和 $q 处理异步行为




在本节课中,我们将学习如何使用 Promise 和 $q 服务来编写和处理异步行为。
概述
在 Promise 出现之前,我们主要使用回调函数来处理异步操作,并且现在仍然可以使用。其基本模式是:调用一个异步函数,并传入一个函数作为参数,该函数会在异步行为完成后执行。这种模型的主要问题在于,没有一种简单直接的方式将异步函数的结果传递回调用者。特别是当结果的最终接收者距离异步函数调用处相隔多层时,例如一个服务调用另一个服务,而后者再调用异步函数,结果需要一直传递到最顶层的函数。使用回调方法很难实现这一点。
此外,如果需要将多个异步操作串联起来执行,例如当异步函数1完成后,不直接返回值,而是需要调用异步函数2,等它完成后再调用异步函数3,最后才处理所有结果的组合,代码会变得非常难以阅读。这还没有考虑错误处理,如果加上错误处理,代码会变得一团糟。
同样,如果我们希望所有异步函数并行执行,然后仅在全部成功时执行后续逻辑,但只要有一个失败就整体失败并执行错误处理,使用回调方法实现起来也非常复杂。然而,使用 Promise API,我们可以相当轻松地实现所有这些功能。
什么是 Promise?🤔
Promise API 实际上是新 ES6 标准的一部分。但是,并非所有浏览器都实现了 ES6 API,因此 Promise 对象可能不可用。不过,AngularJS 实现了自己的 Promise API,它与即将到来的 ES6 中的 Promise 非常相似。
那么,什么是 Promise?Promise 是一个可以传递或返回的对象,它持有对异步行为结果的引用。在 Angular 中,Promise 是通过一个名为 $q 的特殊服务创建的。
如何使用 $q 服务创建 Promise
让我们看一些关于如何创建 Promise 的模板代码。
首先,我们有一个异步函数。我们要做的第一件事是调用 $q.defer() 方法。
var deferred = $q.defer();
该服务会创建一个代表异步环境及其所有钩子的对象,其中包括 Promise 对象。
然后,在我们实现了一些行为之后,该对象有一个特殊的方法叫做 resolve。这个方法标志着我们的执行成功完成,并将数据包装起来,供 Promise 稍后获取。
deferred.resolve(someResultObject);
在这个例子中,我们将某个结果对象作为 resolve 方法的一部分进行包装。
如果出现问题,我们可以调用 deferred.reject(),这将标记为未成功完成,并且也会为 Promise 包装一些数据,通常可能是一个错误对象或错误消息。
deferred.reject(someErrorObject);
最后但同样重要的是,我们必须将我们的 Promise 返回给这个函数的调用者。因此,我们会写:
return deferred.promise;
这将 Promise 对象发送回调用者,它实际上是连接整个流程生命周期的钩子。
需要记住的是,尽管这段示例代码看起来是同步的(目前看可能是这样),但最终调用 deferred.resolve 和 deferred.reject 的部分,可以是真正异步运行代码的一部分。例如,如果我们想在这个例子中模拟异步,可以将高亮显示的代码行包装在一个 setTimeout 函数中。
调用者如何使用 Promise
下一步是调用者调用我们的异步函数,并获取对 Promise 对象的引用。
var promise = someAsyncFunction();
你可以清楚地看到,Promise 是一个可以传递到应用程序中其他 API 的对象。
然后,在应用程序中任何合适的地方,我都可以在 Promise 上调用 then 函数来提取结果或处理错误。
promise.then(
function(result) {
// 处理成功结果
},
function(error) {
// 处理错误
}
);
请注意,then 函数接受两个参数,这两个参数本身都是函数。Promise API 将发送给这些函数的参数,正是我们之前用 resolve 或 reject 包装 deferred 对象时传入的数据。显然,这里我指的是 result 对象和 error 对象,它们可以只是一个简单的字符串。
then 函数本身也是可链式调用的,因为它自身也返回一个 Promise。我们将在本讲座的第二部分通过示例演示这种链式调用的强大之处。
并行处理多个 Promise
$q 服务还具有异步解析多个 Promise 的能力,即多个 Promise 无需等待另一个完成就可以开始运行。
$q.all() 方法实现了这一点,同时还提供了在一个中心位置处理所有结果或处理来自任何 Promise 的错误的能力。
$q.all([promise1, promise2, promise3])
.then(function(results) {
// 所有 Promise 都成功完成,results 是一个包含所有结果的数组
})
.catch(function(error) {
// 任何一个 Promise 失败
});
总结
在本节课中,我们一起学习了 Promise 的概念及其在 AngularJS 中通过 $q 服务的实现。我们了解了为什么 Promise 比传统的回调函数更强大,特别是在处理异步操作链、错误处理和并行执行方面。我们学习了如何创建 Promise(使用 $q.defer()),如何标记其完成状态(resolve 和 reject),以及调用者如何通过 then 方法处理成功和失败的结果。最后,我们还简要介绍了 $q.all() 用于并行处理多个 Promise。在下一部分,我们将回到代码编辑器,查看一些使用此 API 的具体示例。




049:使用Promise和$q的异步行为 🚀


在本节课中,我们将学习如何在AngularJS应用中使用Promise和$q服务来处理异步行为。我们将通过一个购物清单应用的例子,演示如何优雅地管理异步操作,例如验证用户输入,并理解如何通过链式调用和并行执行来优化代码。
概述

现在,你应该对我们正在构建的购物清单应用相当熟悉了。你可以输入商品名称和数量,点击“添加商品”按钮,商品就会出现在表单下方的列表中。
在这个版本的购物清单应用中,我们有两个关键点:第一,我们假设数量只是一个数字,因此我在这里用“盒”来表示数量。第二,为了防止我增重,我们将使用一个特殊的服务来检查商品名称,确保其中不包含“cookie”这个词。如果包含,该服务将阻止我将该商品添加到购物清单中。同样,我们也会检查数量,如果数量大于5,服务同样会阻止我将该商品添加到列表中。
由于分析商品名称可能是一个非常复杂的过程,因此可能需要很长时间,我们将以异步方式实现它。分析数量也将是异步的。这意味着当我们点击“添加商品”时,商品不会立即出现在表单下方,而是会先被分析,然后决定是否应该出现。如果允许出现,它将在几秒钟后显示。
让我们看看我是如何实现这一点的。
代码实现分析
我位于lecture24文件夹中,这是我们的shoppingListPromiseApp。和之前一样,它包含一个控制器,两个用于绑定商品名称和数量的文本框,一个绑定到addItem方法的按钮,以及一个有序列表,用于循环遍历商品数组并显示每个商品的数量、名称和一个用于从列表中移除该商品的按钮。
让我们看看app.js文件。
在我们的app.js中,除了声明控制器和购物清单服务,我们还声明了一个名为WeightLossFilterService的服务。我们先看看购物清单服务,因为这是控制器将直接调用的服务。
这是我们的购物清单服务,你可以看到我们注入了这个名为WeightLossFilterService的服务。暂时不用担心$q服务,我们稍后会用到它。
你可以看到,购物清单服务中的addItem方法通过调用WeightLossFilterService.checkName来使用异步行为。我们正在检查名称,以确保我们被允许将该商品添加到购物清单中。我们使用then方法,获取响应,并在使用响应之前,我们想确保数量也是可接受的。因此,我们将在then函数(成功处理函数)内部再次调用WeightLossFilterService,获取下一个Promise,并再次调用then函数。只有当所有检查都通过时,我们才会将新创建的商品推送到items数组中。如果下一个Promise因任何原因失败,我们将把错误消息输出到控制台。同样,如果外层的Promise失败,我们也会做同样的事情,将错误消息输出到控制台。
到目前为止一切顺利。让我们看看WeightLossFilterService。
这里有很多被注释掉的代码,但这是WeightLossFilterService,它依赖于$q服务和$timeout服务。$timeout服务与JavaScript的setTimeout相同,只是它是Angular化的,因此你不需要手动调用$apply或$digest。
这是我们的checkName方法。checkName方法首先获取包含整个异步行为环境的延迟对象,并设置一个带有简单消息的结果(这里只是一个空消息)。然后,我们将执行$timeout函数来模拟异步行为。我们将执行一个非常复杂的算法,以确定Yaakov是否试图将任何与cookie相关的东西放入他的购物清单中。实际上,我们并没有做任何复杂的事情,我只是在开玩笑。我们要做的是将商品名称转换为小写,然后检查“cookie”这个词是否出现在该名称中。如果没有出现,我们将通过resolve方法成功完成Promise,并传递结果(这里只是一个空对象,只有一个空消息属性)。然而,如果我们在商品名称中找到了“cookie”,我们将设置结果消息为“stay away from cookies, Yaakov”,然后调用reject方法,这将使我们的Promise不成功地完成,并传递结果,以便我们可以从中检索错误消息。
请注意,你为这些参数命名的方式完全没有影响,它们只是会在then函数内部从Promise中检索出来。$timeout将等待三秒钟,然后执行整个功能。

最后但同样重要的是,我们需要将Promise返回给此函数的调用者,以便他们可以有一个钩子来接入异步行为是被resolve方法解决还是被reject方法拒绝。
请注意,checkQuantity方法基本相同。唯一的区别是,我们检查数量是否小于6,而不是检查“cookie”这个词是否作为商品名称的一部分出现。另一个区别是,我们将只等待一秒钟而不是三秒钟来执行此行为,错误消息将是“that's too much, Yaakov”,而不是“stay away from the cookies”。让我们保存并返回浏览器,尝试输入一些东西,比如“chips”,并说我们需要三盒薯片,然后点击“添加”。我们将数大约三秒钟,然后再过一秒钟,四秒钟后,我们看到我们有了三盒薯片。现在,让我们尝试绕过系统,说我们将有“cookie flavored chips”,并得到三盒。我们点击“添加商品”,大约三秒钟后,我们看到控制台中显示“stay away from cookies, Yaakov”,并且没有商品被添加到我们的列表中。

现在,如果我说“chips”但这次数量是6(比5多一个)并点击“添加商品”,会发生什么?它将检查商品列表中是否不包含cookie,然后检查并发现6确实大于5,因此它将显示“that's too much, Yaakov”,它将拒绝Promise,因此我们不会在列表中看到另一个商品。
改进Promise处理
到目前为止一切顺利,但让我们回到用于处理Promise的代码,看看我们是如何处理Promise的。它有点混乱,即使这不是回调函数,但它看起来仍然像回调函数,有很多缩进和很多代码,很难理解什么在调用什么。让我们重写这个函数,使我们的代码更清晰。
我们将注释掉这个版本,我已经在这里准备了第二个版本。让我们取消注释并仔细看看这段代码。这是购物清单服务上相同的addItem方法,我仍然调用WeightLossFilterService.checkName,但我获取了这个Promise。当我在这个Promise上调用then方法时,我甚至没有提供第二个本应处理错误的函数。我不这样做的原因是,如果出现错误,它会向上冒泡和传播,我可以再次调用then方法,这将再次返回一个Promise,然后我可以调用名为catch的特殊方法,当延迟对象的reject被调用时,该方法将被执行。
在原始的Promise中,then方法中我所要做的就是执行WeightLossFilterService来检查购物清单中商品的数量。由于它返回一个Promise,我可以直接返回它,然后它将被下一个then方法获取。我在这里真的不需要做任何事情,因为我知道我的响应对象只是一个带有空消息的空对象。换句话说,如果我们在这个代码块中,那意味着我们成功了,除了检查数量的有效性之外,我们不需要做任何其他事情。下一个then方法以相同的方式处理响应,因为如果我们到达这个代码块,意味着数量检查通过,我们被允许将商品推送到购物清单数组中。
请注意我们在这里做了什么:我们不再需要在每个单独的情况下处理错误条件或Promise的拒绝,我们只需要做一次,并且可以集中处理。这当然使代码更容易阅读,也更清晰。让我们保存并看看这是否有效。让我们回到浏览器,输入“cookies”,给它四盒饼干,点击“添加”,这将是一个错误条件,因此显示“stay away from cookies, Yaakov”。如果我们输入“chips”并点击“添加”,大约三到四秒钟后,它应该会添加那四盒薯片。如果我们保持“chips”不变,但输入7盒并点击“添加”,那也将被解决,但是以拒绝的方式解决,因此我们将显示“that's too much, Yaakov”,因为我们这里的catch方法正在捕获上面链式调用中任何Promise的任何错误。

并行执行Promise以提升性能

到目前为止一切顺利,但我们可以进一步改进。让我们看看当我们尝试添加一个有效商品但数量过多时会发生什么。让我们清除并再次点击“添加”。看看这需要多长时间,大约需要四秒钟。为什么需要四秒钟?检查这个商品需要一秒钟,检查那个商品需要三秒钟。我们知道,如果这些商品中的任何一个无效,我想输出错误消息并完全忘记将其添加到我的列表中。那么,为什么我要在验证商品名称之后才执行我的盒子数量的验证呢?它们实际上应该并行执行。让我们开始编写代码。
让我们回去,再次注释掉这个版本的addItem方法,并取消注释另一个版本的addItem方法。这个版本并行执行两件事。我们这样做的方式是捕获名称Promise(即WeightLossFilterService.checkName方法的返回结果,它返回一个Promise),然后我们将捕获数量Promise(即执行WeightLossFilterService.checkQuantity方法的结果)。在这里,我们将使用我们的$q服务all方法。它接受一个Promise数组。然后我们可以在其上调用then方法,该方法只会在该数组中的每个Promise都得到结果时执行。然而,如果该数组中的任何Promise导致拒绝(即延迟对象的reject),所有Promise将立即被取消,执行将跳入catch方法,输出此错误响应消息。这意味着,如果这个Promise执行得更快,因此失败得更快,我不必等待这个Promise完成,就可以取消整个执行并输出消息。数量Promise大约需要一秒钟执行,而名称Promise大约需要三秒钟执行。


让我们保存并返回浏览器。如果我现在在这里输入“chips”和数量3(一个有效商品和有效数量),我点击“添加”,这将需要大约三秒钟执行,而不是四秒,因为我们正在异步执行两个验证。时间较长的那个将运行三秒钟。但当它完成时,耗时较短的那个应该已经完成了。现在,如果我在这里输入“cookies”,那也将需要三秒钟,并且三秒钟后实际失败,你可以看到它失败了,拒绝了,并显示“stay away from cookies, Yaakov”。这也是因为它是时间较长的那个,因此失败所需的三秒钟是总时间。但看看如果我说“chips”然后输入无效的数量如7会发生什么。当我点击“添加商品”时,看看它失败得多快。它几乎立即失败,大约一秒钟后显示“that's too much, Yaakov”。我们实际上可以再做一次,点击“添加”,你可以看到它立即失败。
因此,通过$q.all构造,我们能够放置多个Promise(实际上任意多个),它们都将尝试异步执行,从而使我们的整个应用可能更快。
总结
在本节课中,我们学习了以下内容:

首先,请记住,Promise在处理异步行为时为我们提供了很大的灵活性。
$q服务是Promise API的Angular实现。JavaScript ES6的新实现有自己的Promise API,一旦所有主要浏览器都支持,我们可能会使用它。

Promise要么被解决(resolve),要么被拒绝(reject)。解决被认为是成功,拒绝被认为是失败。

你可以在Promise上调用的then方法接受两个参数,两者都是函数值。第一个是处理成功或解决结果的函数,第二个是处理错误或拒绝结果的函数。此外,该方法本身返回一个Promise,因此你可以将其与其他Promise链式调用,使代码也更易读。
最后,我们讨论了$q服务的all方法。该方法允许我们并行执行多个Promise,使事情更快,同时,我们能够在代码中的一个中心位置处理所有这些Promise的成功和失败。


通过本节课的学习,你应该能够理解并应用Promise和$q服务来管理和优化AngularJS应用中的异步操作。
050:使用 $http 服务进行 Ajax 🚀


在本节课中,我们将要学习 AngularJS 中一个核心的工具服务——$http 服务。我们将了解它的基本用法、配置选项以及如何正确处理其异步特性。
概述
上一节我们介绍了 AngularJS 中异步行为的处理方式。本节中,我们来看看 AngularJS 自带的一个核心工具服务:$http 服务。该服务的目的是让前端应用与服务器之间的通信变得非常简单直接。
$http 服务简介
$http 服务本质上是异步的,它基于我们之前讨论过的 $q 服务所暴露的延迟(deferred)和承诺(promise)API。当调用该服务的主要方法时,它会返回一个承诺(promise),然后由我们来处理这个承诺。
调用 $http 服务非常简单。$http 服务本身是一个函数,因此可以直接调用它。
配置对象
它只接受一个参数:一个配置对象。Angular 期望这个对象具有一些预定义的属性,例如 method 和 url 等。正如前面提到的,它返回一个承诺,这就是为什么你可以调用熟悉的 .then() 方法。
配置对象唯一必需的属性是 url。如果未指定 method 属性,则默认使用 GET 方法。
以下是配置对象中一些重要的属性:
url:请求的目标地址。method:HTTP 方法,如 GET、POST 等。params:一个对象,其属性名将成为参数名,对应的值将成为这些参数的值。
参数值会自动进行 URL 编码。URL 不允许包含空格和其他特殊字符。如果你试图传递给服务器的参数值包含这些特殊字符,URL 编码过程会用百分号后跟两个十六进制数字来替换它们,空格会变成加号。
幻灯片上展示的 method、url 和 params 这三个属性当然不是配置对象可以拥有的全部属性。对于更具体的用例,还有更多属性,你可以在 Angular 关于 $http 服务的文档中查阅它们。
处理响应
传递给 .then() 函数的参数与处理承诺时通常传递的参数相同。
第一个参数是处理成功响应(即承诺被解决)的函数值,第二个参数是处理错误响应(即承诺被拒绝)的函数值。在幻灯片中,我将这些函数命名为 success 和 error,但在实践中,当你像这样内联使用它们时,可以让它们保持匿名。
响应对象中最常用的属性可能是 response.data。这个属性保存了响应体。
如果 AngularJS 检测到响应体包含 JSON,它会自动使用 JSON 解析器将响应体转换为 JavaScript 对象。
同样,如果发生错误,也会返回相同的响应对象。然而,它的 data 属性可能包含一些服务器生成的 HTML 页面来解释错误信息,因此对于我们的编程需求来说通常不那么有用。
避免常见错误
在我们继续看一些编码示例之前,让我提醒你避免一个相当常见的错误。
这个错误通常是这样发生的:首先声明一个名为 message 的局部变量并初始化为空字符串。然后我们调用 $http 方法,提供某个 URL,并在成功解析后,将我们的局部 message 变量设置为 response.data。接着,我们获取 $scope 对象并在其上创建一个 message 属性,将其初始化为局部变量 message 当前持有的值。
那么,你认为 $scope.message 的值是多少?
如果你认为是空字符串,那么你是正确的。为什么会这样?请记住,我们正在处理一个异步调用。局部变量 message 只有在幻灯片上最后一行代码执行之后才会被设置为 response.data。而最后一行代码会将 $scope.message 设置为局部变量 message 当前 的值,也就是空字符串。
那么如何修复呢?其实很简单。正确的实现方式是直接在代表承诺成功解决的函数中,将 $scope.message 设置为 response.data。
这样,只有在数据返回且承诺被解决时,才会在作用域服务上设置 message。
总结


本节课中,我们一起学习了 AngularJS 中 $http 服务的基本概念和使用方法。我们了解了如何配置请求、处理响应,并重点讨论了如何避免在处理异步调用时常见的错误。接下来,我们将在本讲座的第二部分查看一些实际的代码示例。
051:使用 $http 服务进行 Ajax


概述
在本节课中,我们将学习如何使用 AngularJS 中的 $http 服务来执行 Ajax 调用,从远程服务器获取数据。我们将通过重构一个餐厅菜单应用来实践这一过程。
准备远程服务器
在开始进行远程调用之前,我们需要一个已设置好的远程服务器。之前的课程构建了一个餐厅应用,我们将使用 AngularJS 重新实现它,而该服务器仍然可用并已部署在互联网上。
我们将使用该服务器作为数据源。关于服务器的部署地址及其支持的 API 的说明和 URL,可以在 GitHub 仓库 jhuueppcosera/resrantmanu server 中找到。
以下是该服务器支持的主要 URL 模式:
/categories.json:显示我们中餐厅菜单的所有类别。/menu_items.json:显示所有菜单项。/menu_items.json?category={short_name}:显示特定类别的所有菜单项及该类别信息。

对于本教程,我们将主要使用 /categories.json 和 /menu_items.json?category={short_name} 这两个端点。
构建应用:列出菜单类别
上一节我们介绍了服务器 API,本节中我们来看看如何构建一个列出所有菜单类别的应用。我们将使用 $http 服务来实现。
我们的应用名为 Menu Categories App,其 HTML 模板非常简单,主要包含一个控制器和一个无序列表,用于通过 ng-repeat 循环显示类别。
以下是控制器 MenuCategoriesController 的核心代码片段:
angular.module(‘MenuCategoriesApp‘, [])
.controller(‘MenuCategoriesController‘, MenuCategoriesController);
MenuCategoriesController.$inject = [‘$scope‘, ‘MenuCategoriesService‘];
function MenuCategoriesController($scope, MenuCategoriesService) {
var menu = this;
menu.categories = [];
MenuCategoriesService.getMenuCategories().then(function(response) {
menu.categories = response.data;
}).catch(function(error) {
console.log(‘Something went wrong!‘, error);
});
}
我们创建了一个服务 MenuCategoriesService 来处理数据获取。该服务注入了 $http 服务,并定义了一个 getMenuCategories 方法。
以下是服务中发起 GET 请求的核心代码:
angular.module(‘MenuCategoriesApp‘)
.service(‘MenuCategoriesService‘, MenuCategoriesService);
MenuCategoriesService.$inject = [‘$http‘];
function MenuCategoriesService($http) {
var service = this;
service.getMenuCategories = function() {
var response = $http({
method: "GET",
url: "https://coursera-jhu-default-rtdb.firebaseio.com/categories.json"
});
return response;
};
}
在这个方法中,我们使用 $http 函数并传入一个配置对象,指定请求方法为 GET 以及目标 URL。$http 调用返回一个 Promise。

在控制器中,我们调用该服务方法,并在 Promise 成功解析后,将返回数据(response.data)赋值给控制器的 categories 属性,从而更新视图。
运行应用后,页面将成功列出所有菜单类别及其短名称。

增强功能:获取特定类别菜单项
现在我们已经能够列出所有类别,接下来我们增强功能,使每个类别的短名称成为一个可点击的链接,点击后获取并显示该特定类别的菜单项。
首先,我们需要修改 HTML 模板,将短名称包装在链接中,并添加 ng-click 指令来触发一个控制器方法。
修改后的列表项代码如下:
<li ng-repeat="category in menu.categories">
(<a href="" ng-click="menu.logMenuItems(category.short_name)">{{ category.short_name }}</a>)
{{ category.name }}
</li>
当链接被点击时,会调用控制器上的 logMenuItems 方法,并传入该类别的 short_name。
在控制器中,我们定义 logMenuItems 方法。该方法将调用服务中的另一个方法 getMenuForCategory 来获取特定类别的数据。
控制器方法代码如下:
menu.logMenuItems = function(shortName) {
MenuCategoriesService.getMenuForCategory(shortName)
.then(function(response) {
console.log(response.data);
})
.catch(function(error) {
console.log(‘Error:‘, error);
});
};
在服务中,我们实现 getMenuForCategory 方法。这个方法与 getMenuCategories 类似,但 URL 需要包含查询参数。

以下是带参数请求的核心代码:
service.getMenuForCategory = function(shortName) {
var response = $http({
method: "GET",
url: "https://coursera-jhu-default-rtdb.firebaseio.com/menu_items.json",
params: {
category: shortName
}
});
return response;
};
注意配置对象中的 params 属性。它是一个对象,其属性名(category)将成为 URL 的查询参数名,属性值(shortName)将成为参数值。AngularJS 会自动将其格式化为 ?category=C 这样的形式并附加到请求 URL 上。
完成这些更改后,点击页面上的类别链接,浏览器控制台将成功输出对应类别的菜单项数据。

优化:使用常量定义基础 URL
观察之前的代码,我们发现每个 $http 调用中都硬编码了完整的基础 URL。如果服务器地址发生变化,我们需要修改多处代码,这既不高效也不便于维护。
为了解决这个问题,我们可以使用 AngularJS 模块的 .constant 方法来定义一个可注入的常量,用于存储基础 URL。
我们可以在模块定义后添加一个常量:
angular.module(‘MenuCategoriesApp‘)
.constant(‘ApiBasePath‘, ‘https://coursera-jhu-default-rtdb.firebaseio.com‘);
然后,我们将这个常量注入到 MenuCategoriesService 中,并在构造 URL 时使用它。
更新后的服务方法示例如下:
MenuCategoriesService.$inject = [‘$http‘, ‘ApiBasePath‘];
function MenuCategoriesService($http, ApiBasePath) {
var service = this;
service.getMenuCategories = function() {
var response = $http({
method: "GET",
url: (ApiBasePath + "/categories.json")
});
return response;
};
service.getMenuForCategory = function(shortName) {
var response = $http({
method: "GET",
url: (ApiBasePath + "/menu_items.json"),
params: {
category: shortName
}
});
return response;
};
}
现在,基础 URL 只在一个地方定义。如果未来需要更改,只需更新这个常量的值即可,所有依赖它的服务都会自动使用新的地址。



总结
本节课中我们一起学习了 AngularJS $http 服务的核心用法。
$http服务:是基于 Promise API 的核心服务,用于发起 HTTP 请求。其基本形式是一个函数,接收一个配置对象作为参数,其中url属性是必需的,并返回一个 Promise。- 公式/概念:
$http(configObject) -> Promise
- 公式/概念:
- 处理响应:通过 Promise 的
.then()方法处理成功响应。服务器返回的数据(响应体)位于response.data属性中。如果原始数据是 JSON 字符串,AngularJS 会自动将其转换为 JavaScript 对象。 - 传递参数:可以通过配置对象的
params属性来设置 URL 查询字符串参数,AngularJS 会自动处理编码和拼接。 - 使用常量:通过
module.constant()定义的常量,可以在整个应用中被注入和使用,是管理配置值(如 API 基础路径)的最佳实践。

通过本课的学习,你已经掌握了使用 AngularJS 与后端服务器进行数据交互的基本技能。
052:动态HTML


概述
在本节课中,我们将要学习 AngularJS 最核心的特性之一:指令。指令允许我们扩展标准的 HTML,创建自定义的、动态的视图组件。我们将了解指令是什么、AngularJS 如何编译和链接它们,并学习创建自定义指令的基本步骤。
什么是指令?
AngularJS 最显著的特性之一就是指令的概念。AngularJS 官网在回答“为什么选择 Angular”时,也暗示了指令的重要性。
官网的说明如下:HTML 非常适合声明静态文档,但在用于声明 Web 应用中的动态视图时,它就力不从心了。AngularJS 允许你为你的应用程序扩展 HTML 词汇表。由此产生的环境表达能力非凡、可读性强且开发迅速。
这段描述的关键词是“扩展”。Angular 允许你将标准 HTML 扩展为适合你特定应用程序的任何形式。这非常强大。
因此,在 Angular 的 HTML 和模板中,看到类似下面的代码并不奇怪:
<div>
<list-item></list-item>
</div>
你可以看到,一个名为 list-item 的标签出现在常规 HTML 代码中间。这就是一个 Angular 指令。它当然不是一个标准的 HTML 标签。
Angular 如何处理指令?
那么,这段代码如何变成“常规”的 HTML 代码呢?回忆一下之前的课程,我们曾试图理解自定义 HTML 属性如何工作,从而揭开 Angular 如何在 HTML 中找到占位符的奥秘。
一旦 Angular 在某个元素上找到 ng-app 属性,它就获得了附加到该元素的整个文档对象模型的访问权限。然后,Angular 函数可以自由地操作那棵 HTML 节点树。它可以:
- 将双花括号
{{ }}包围的变量替换为从$scope中查找到的值。 - 检查任何节点(无论是内容、标签还是属性),并决定是否应该用其他东西替换它,或者为其附加某些功能或行为。
在 Angular 中,这个过程被称为编译。
如果你以前做过编程,当你听到“编译”这个词时,它通常意味着将你的源代码翻译成机器可以理解的其他代码。通常,这发生在你实际执行代码之前。在 Angular 中,大部分编译发生在你加载页面或特定 HTML 模板的开始时,有时甚至更晚,但过程是相同的:Angular 编译并链接你的自定义 HTML 代码。
你还可以将指令组合在一起使用。在这里,你可以看到我们在这个自定义的 list-item 指令上使用了熟悉的 ng-repeat。
指令的定义
现在,让我们来定义什么是指令。指令实际上只是 DOM 元素上的一个标记,它告诉 Angular 的 HTML 编译器为该 DOM 元素附加指定的行为。编译器还可以转换或更改 DOM 元素及其子元素。
我们所说的这个“标记”本身可以是以下四种之一:
- 一个属性。
- 一个元素名。
- 一个注释。
- 一个 CSS 类。
然而,使用注释或 CSS 类来定义指令并不是最佳实践。因此,在大多数情况下,当你定义自定义指令时,你会坚持使用元素和属性。
创建自定义指令的步骤
上一节我们介绍了指令的基本概念,本节中我们来看看如何创建自己的自定义指令。以下是创建自定义指令的步骤。
第一步:在 Angular 模块中注册指令
第一步是在 Angular 模块中注册指令。如下所示,这种方式与注册控制器、服务、工厂等几乎完全相同。
angular.module(‘myApp’, [])
.directive(‘myTag’, function() {
// 工厂函数
});
这里我们使用了一个特殊的模块方法 directive。我们定义的指令名称(例如 ‘myTag’)是一个将在 HTML 中出现的规范化名称。我稍后会解释“规范化”的含义。
第二个参数是一个工厂函数,它返回一个被称为 DDO 的对象,即指令定义对象。DDO 基本上是一个配置对象,它告诉 Angular 编译器当在 HTML 中找到这个标签或元素或属性时,这个指令应该如何行为。
在我们的示例中,这个工厂函数只会执行一次,而不是每次在 HTML 中找到我们的指令时都执行。由于它只会被执行一次,你可以使用该函数执行任何你需要的初始化操作,当然,最重要的是返回这个特殊的 DDO 对象。
第二步:定义工厂函数
第二步是定义我们的工厂函数。请注意,就像控制器和服务一样,我们可以将其他依赖项注入到这个指令中。我们可以注入其他服务、控制器等。
和往常一样,这个函数最重要的事情是返回我们的 DDO。指令定义对象由 AngularJS 文档中定义的多个属性组成。这里定义的 template 属性只是一个示例,它不是一个必需的属性。然而,在这个例子中,每次我们在 HTML 中使用 my-tag 时,都会向用户显示“Hello World”。
angular.module(‘myApp’, [])
.directive(‘myTag’, function() {
return {
template: ‘<h1>Hello World</h1>’
};
});
第三步:在 HTML 中使用指令
第三步,显然是在我们的 HTML 中使用这个标签。请注意,元素的名称不是 myTag,而是 my-tag。
<my-tag></my-tag>
为了让 Angular 将你在 HTML 中找到的元素或属性与指令的名称匹配,它首先需要规范化在 HTML 中找到的名称。
它规范化名称的方式是:移除连字符 -,然后将下一个字母大写。换句话说,它将连字符分隔的表示法转换为驼峰式表示法。
如果你不熟悉驼峰式表示法,它其实很简单。它基本上是一种编程中的命名方式,你将多个单词连接在一起,第一个单词全部小写,随后的每个单词首字母大写。
所以,当 AngularJS 在我们的 HTML 中看到 my-tag 时,它会寻找一个以名称 myTag(t 大写)注册的指令。显然,它会找到我们声明的名为 myTag 的指令,从而将这两部分连接起来,并提供在 myTag 工厂函数中定义的功能。


总结
本节课中,我们一起学习了 AngularJS 的核心——指令。我们了解到指令是扩展 HTML 的强大工具,它允许我们创建自定义的动态组件。我们探讨了 Angular 编译和链接 HTML 的过程,并详细学习了创建自定义指令的三个步骤:在模块中注册、定义返回 DDO 的工厂函数,以及在 HTML 中使用规范化名称调用指令。在下一部分,我们将回到代码编辑器,实际创建我们的第一个自定义指令。
053:动态HTML(第二部分)


在本节课中,我们将编写第一个自定义指令。我们将复用之前编写的购物清单应用,并探索如何通过指令来重用 HTML 代码片段,避免重复编写。
概述

我们将从一个包含两个独立购物清单的应用开始。每个清单都有自己的服务实例,因此互不干扰。本节的目标是识别并提取可重用的 HTML 代码,将其封装成自定义指令,从而实现代码复用,并使 HTML 更具语义化。
应用回顾
首先,回顾一下现有的应用。它包含两个购物清单控制器,每个控制器管理自己的清单项。在 HTML 中,每个清单项都通过类似以下的结构渲染:
<li>
{{ item.quantity }} of {{ item.name }}
</li>
这段代码在两个清单中重复出现。我们的目标是将其封装成一个指令。
创建第一个指令:列表项描述
我们将创建一个名为 listItemDescription 的指令,用于渲染清单项的描述信息。
以下是创建该指令的步骤:
- 在 AngularJS 模块中注册指令。
- 定义指令的工厂函数,该函数返回一个指令定义对象。
- 在 DDO 中指定
template属性,即要插入的 HTML 字符串。
具体代码如下:


app.directive('listItemDescription', function () {
return {
template: '{{ item.quantity }} of {{ item.name }}'
};
});
指令注册后,我们可以在 HTML 中使用它。AngularJS 会将驼峰式命名的指令名(listItemDescription)转换为使用连字符的标签名(list-item-description)。
因此,原来的 HTML 可以替换为:
<list-item-description></list-item-description>
指令的作用域
一个关键问题是:指令模板中的 item.quantity 和 item.name 是从哪里来的?
答案是,默认情况下,指令会继承其所在控制器的作用域。因为 <list-item-description> 标签位于 ShoppingListController1 的 ng-controller 指令内部,所以它可以访问该控制器作用域中的所有属性,包括 ng-repeat 循环中的当前 item 对象。
这使得指令能够无缝地使用外部数据。
创建第二个指令:完整的列表项
接下来,我们创建一个更复杂的指令来封装整个 <li> 元素,包括内部的描述指令。
由于模板内容较长且包含引号,使用 template 属性会显得笨拙。因此,我们使用 templateUrl 属性,将模板内容移到一个单独的 HTML 文件中。
首先,注册名为 listItem 的指令:
app.directive('listItem', function () {
return {
templateUrl: 'listItem.html'
};
});
然后,创建 listItem.html 文件,内容如下:

<list-item-description></list-item-description>

最后,在主要的 index.html 中使用这个新指令。为了更清晰,我们将 ng-repeat 指令从模板内部移到自定义标签上:
<list-item ng-repeat="item in list.items"></list-item>
这样做的好处是,HTML 结构一目了然:我们正在为列表中的每一项重复一个“列表项”组件。

指令的优势
通过以上步骤,我们实现了 HTML 代码的复用。现在,清单项的结构和样式只需在一个地方(指令模板)定义和维护。如果在 listItem.html 中修改了结构,所有使用该指令的地方都会自动更新。
此外,我们的 HTML 代码变得更加语义化。像 <list-item> 和 <list-item-description> 这样的标签,清晰地表达了它们在应用中的具体用途,而不仅仅是通用的 <div> 或 <span> 标签。这大大提高了代码的可读性和可维护性。
总结
本节课我们一起学习了 AngularJS 自定义指令的核心概念和实践:
- 指令的本质:指令是 HTML 中的标记,AngularJS 编译器会将其编译为特定的行为或 DOM 结构。
- 创建指令:通过
module.directive(‘name‘, factoryFn)注册指令。工厂函数返回一个指令定义对象。 - DDO 的模板:可以使用
template(内联字符串)或templateUrl(外部文件)来定义指令的 HTML 模板。 - 指令作用域:默认情况下,指令继承其父控制器的作用域,从而可以访问相关数据。
- 命名规范:注册时使用驼峰式名称(如
listItem),在 HTML 中使用连字符形式(如list-item)。 - 核心价值:自定义指令实现了 HTML 和行为的代码复用,并且让 HTML 结构具有了与应用逻辑相关的语义化含义,这是构建复杂、可维护单页应用的重要基石。


通过创建和使用自定义指令,我们能够构建出模块化、清晰且高效的前端代码结构。
054:restrict属性


在本节课中,我们将学习 AngularJS 指令定义对象(DDO)的另一个重要属性:restrict。我们将了解这个属性的作用、如何配置它,以及在不同场景下的最佳实践。
概述
上一节我们介绍了指令定义对象(DDO)的 template 和 templateUrl 属性。本节中,我们来看看另一个关键的 DDO 属性:restrict。这个属性决定了 AngularJS 编译器在何处查找和识别我们自定义的指令。
restrict 属性的作用
restrict 属性用于告知 AngularJS 编译器如何检测你的自定义指令。它指定了指令在 HTML 中应该以何种形式出现。

如果你不在 DDO 中指定 restrict 属性,AngularJS 会将其默认值设为 ‘AE’。这里的 ‘A’ 代表属性(Attribute),‘E’ 代表元素(Element)。这意味着,默认情况下,AngularJS 会同时尝试将你的指令识别为一个属性或一个元素。
如果你想将指令限制为仅能作为其他元素的属性使用,你需要在 restrict 属性中指定值 ‘A’。
将指令限制为属性是一种最佳实践,特别是当该指令旨在扩展其他元素的行为时。例如,ng-repeat 指令本身没有内容,它只是扩展了你放置该属性的任何元素的行为。

如果你想将指令限制为仅能作为一个独立的元素使用,你需要在 restrict 属性中指定值 ‘E’。
将指令限制为元素是一种最佳实践,特别是当该指令定义了一个带有相关模板的组件时。例如,我们之前例子中的列表项指令,它有自己的内容和模板,因此将其定义为元素是合理的。
最佳实践是避免使用基于类(Class)和注释(Comment)的指令。因此,也不应将你的 DDO 限制为这些类型。

如果你将指令限制为一种类型,却在 HTML 中以另一种方式使用它,AngularJS 编译器将无法匹配该指令,并会像处理其他它不应处理的 HTML 内容一样,完全忽略它。例如,如果你指定指令为元素,却将其用作属性,AngularJS 会完全忽略它。
代码示例与实践

现在,让我们进入代码编辑器,通过实际操作来理解这些概念。
我们回到代码编辑器,位于 lecture27 文件夹中。这里有一个与上一讲完全相同的应用:购物清单指令应用。我们创建了几个自定义指令,其中之一是 listItem 指令。这个指令对应一个模板文件 listItem.html,其内容是一个包含按钮的列表项(<li>)。


查看我们的 HTML,可以看到 listItem 被用作一个元素。查看 app.js,我们发现 listItem 指令没有指定 restrict 属性。这意味着我们可以将其用作属性或元素。现在,让我们为其添加 restrict 属性,并设置为 ‘AE’,这样它既可以作为属性也可以作为元素使用。

保存更改后,在浏览器中测试,添加项目功能仍然正常工作。
回到代码编辑器,让我们将第一个控制器中的 listItem 用法从元素改为属性,因为这是允许的。我们将 <list-item> 标签改为一个标准的 <li> 标签,并在其上添加 list-item 属性。
保存后,回到浏览器测试。添加一些项目后,你可能会发现列表的编号显示异常。打开开发者工具检查 DOM 元素,会发现出现了嵌套的 <li> 标签。

这是因为当我们将 listItem 指令作为属性应用在 <li> 元素上时,listItem 指令的模板(listItem.html)本身也包含一个 <li> 标签,这就导致了嵌套。为了让指令作为属性正常工作,我们需要从模板中移除外层的 <li> 标签。
修改模板后保存,刷新浏览器再次测试。现在,第一个列表(使用属性形式)可以正常工作了。我们检查 DOM 结构,可以看到 <li> 元素内部正确包含了指令模板生成的内容。

然而,第二个列表(仍然使用元素形式 <list-item>)现在却无法正常显示内容了。检查 DOM 会发现,<list-item> 元素内部是空的。这是因为我们从其模板中移除了 <li> 标签,导致作为元素使用时,它没有生成任何有效的包裹内容。
这引出了第一个要点:无论你将指令限制为属性还是元素,与该指令关联的模板必须在你的 HTML 上下文中具有意义。
目前,我们在 app.js 中将 listItem 限制为 ‘AE’。现在,让我们尝试将其限制为仅能作为属性(‘A’)。保存后,第一个列表(属性形式)仍然正常工作。但第二个列表(元素形式)虽然点击按钮有功能响应(如项目数量限制),但页面上不显示任何内容。检查 DOM 会发现,AngularJS 编译器完全忽略了 <list-item> 这个元素,因为它被限制为仅能作为属性使用。
正如课程前面所说,带有模板的指令通常更适合作为元素。因此,让我们将其改回 ‘E’(仅元素)。同时,为了让指令作为元素能正常工作,我们需要将 <li> 标签添加回 listItem.html 模板中。

修改并保存后,第二个列表(元素形式)恢复正常。但第一个列表(属性形式)又无法显示内容了。检查 DOM 会发现,<li> 元素内部是空的。这是因为 AngularJS 编译器现在只将 list-item 识别为元素指令,而完全忽略了作为属性使用的 list-item。
总结
本节课我们一起学习了 restrict 属性的核心概念与应用。


- 作用:DDO 的
restrict属性决定了 AngularJS 编译器应查找何种形式的自定义指令。 - 错误使用后果:使用与定义不同的限制类型来调用指令,将导致编译器直接忽略它,从而使带有自定义行为的标签完全失效。
- 最佳实践:
- 当指令拥有内容(模板)和可能的行为时,使用
‘E’(元素)来限制它。 - 当指令没有自身内容,仅用于扩展宿主元素的行为时,使用
‘A’(属性)来限制它。 - 虽然技术上可以使用类(
‘C’)和注释(‘M’)指令,但这主要是为了旧浏览器兼容性,现已不再常用,因此应避免定义这两种类型的自定义指令。
- 当指令拥有内容(模板)和可能的行为时,使用
055:隔离作用域和&


在本节课中,我们将要学习 AngularJS 指令中一个非常重要的概念:隔离作用域。我们将探讨为什么需要隔离作用域,以及如何使用不同的绑定方式(=、@、&)将外部数据传递到指令内部。通过本课的学习,你将能够创建出更独立、可复用性更高的自定义指令。
概述
在之前的示例中,我们创建的指令默认使用其父控制器的作用域。这意味着指令内部的 $scope 对象与其父控制器的 $scope 是同一个对象。虽然这种设置在某些情况下看起来可行,但它带来了一个架构上的问题:指令过度依赖于特定的父控制器,导致控制器和指令之间存在过高的耦合度。
发现问题:指令与控制器过度耦合
让我们在代码编辑器中具体审视这个问题。我们有一个购物清单应用,其中包含两个购物清单,它们都使用了同一个自定义指令 listItem。
<!-- 清单1 -->
<shopping-list list="list1">
<list-item></list-item>
</shopping-list>
<!-- 清单2 -->
<shopping-list list="list2">
<list-item></list-item>
</shopping-list>
问题在于,指令的模板中硬编码了对一个名为 list 的变量的引用(例如,在 ng-click 中调用 list.removeItem(...))。当我们把第一个控制器的列表变量名从 list 改为 list1 后,指令就无法在第二个控制器中正常工作了,因为第二个控制器中并没有 list1 这个变量。
// 指令模板中的问题代码
template: ‘<button ng-click="list.removeItem($index)">Remove Item</button>‘
这清楚地表明,我们的指令与其运行环境(父控制器)紧密耦合。为了使其更通用,我们需要一种方法,让父环境将所需的数据“传递”给指令,就像给函数传递参数一样。这就是隔离作用域要解决的问题。

引入解决方案:隔离作用域

正如前面提到的,在没有额外配置的情况下,指令会继承父作用域。为了打破这种继承关系,并为指令创建一个独立的作用域,我们需要在指令定义对象(DDO)中指定一个 scope 属性。
app.directive(‘myDirective‘, function() {
return {
scope: {}, // 创建一个空的隔离作用域
// ... 其他配置
};
});
仅仅设置 scope: {} 就足以将指令的作用域与父作用域隔离开来。但是,如果作用域是隔离的,我们如何将外部数据传递到指令内部呢?答案是:通过在 scope 对象中定义属性,并将它们与指令元素上的特定属性进行绑定。
AngularJS 提供了几种绑定策略,本讲我们先介绍前两种:双向绑定 (=) 和 DOM 属性字符串绑定 (@)。

绑定策略一:双向绑定 (=)

使用 = 符号可以实现父作用域与指令隔离作用域之间的双向绑定。
scope: {
myProp: ‘=someAttr‘ // 双向绑定到名为 ‘some-attr‘ 的HTML属性
}
以下是关于双向绑定的关键点:
- 双向通信:如果父作用域中的值发生变化,指令内部的值会自动更新;反之,如果在指令内部修改了这个值,父作用域中的值也会同步更新。
- 属性名推断:如果省略属性名,AngularJS 会假设HTML属性名与
scope中的属性名相同(遵循规范化规则,即驼峰命名myProp对应短横线命名my-prop)。 - 可选绑定:在
=后添加?(如myProp: ‘=?‘)表示这个绑定是可选的。指令内部的代码需要处理该值可能为undefined的情况。
在HTML模板中,我们这样使用:
<my-directive some-attr="parentObject"></my-directive>
这里,parentObject 是父控制器 $scope 上的一个对象,它会被求值,然后其引用被传递给指令内部的 myProp 属性。
绑定策略二:DOM 属性字符串绑定 (@)
使用 @ 符号可以将指令元素上某个 DOM 属性的值(始终是字符串)绑定到指令的隔离作用域。
scope: {
myProp: ‘@myAttribute‘ // 绑定到名为 ‘my-attribute‘ 的DOM属性值
}
以下是关于字符串绑定的关键点:
- 单向绑定(外到内):这是一种单向绑定。当外部DOM属性的值改变时,指令内部的值会更新。但是,如果在指令内部修改
myProp,DOM 属性的值不会被更新。 - 值是字符串:通过
@绑定获取的值总是一个字符串。 - 支持插值:你可以在属性值中使用
{{ }}插值语法,AngularJS 会先对其进行求值。
在HTML模板中,我们这样使用:
<my-directive my-attribute="{{ ‘Hello ‘ + userName + ‘!‘ }}"></my-directive>
在这个例子中,指令内部的 myProp 将被赋值为一个拼接后的字符串,例如 “Hello Alice!”。
总结与预告
本节课中我们一起学习了:
- 问题:指令默认共享父作用域会导致与特定控制器过度耦合,降低可复用性。
- 解决方案:通过设置
scope: {}为指令创建隔离作用域。 - 数据传递:为了在隔离后仍能获取外部数据,需要在
scope对象中定义属性并进行绑定。 - 两种绑定策略:
=(双向绑定):用于在父作用域和指令间建立双向数据同步,适合传递对象或变量。@(字符串绑定):用于将DOM属性的字符串值(支持插值)单向传递到指令内部。


在下一部分(第28讲第2部分),我们将回到代码编辑器,运用隔离作用域和 = 绑定来重构我们的购物清单指令,解决其与控制器耦合的问题,并使其变得更加通用和健壮。我们还将介绍第三种绑定策略:&,它用于绑定父作用域中的表达式或函数。
056:隔离作用域与&符号


概述
在本节课程中,我们将学习如何创建具有隔离作用域的 AngularJS 指令。我们将构建一个可复用的购物清单组件,并探讨如何使用不同的绑定方式将数据从父控制器传递到指令中。
创建购物清单指令
上一节我们介绍了指令的基础概念,本节中我们来看看如何构建一个实际的、可复用的指令。
首先,我们需要在代码编辑器中定位到 lecture 28 文件夹。这个应用与第一部分讨论的相同,但我们已经移除了所有自定义指令,以便从头开始。
接下来,我们将创建一个名为 shoppingList 的指令,其模板将包含整个购物清单的 HTML 结构。
以下是创建指令的步骤:
- 在
app.js文件中,使用directive方法注册一个名为shoppingList的指令。 - 该指令的工厂函数将返回一个指令定义对象。
- 在 DDO 中,我们首先需要指定模板的 URL。
app.directive('shoppingList', function() {
var ddo = {
templateUrl: './shoppingList.tpl.html'
};
return ddo;
});
然后,我们创建 shoppingList.tpl.html 模板文件,并将之前位于控制器视图中的清单 HTML 结构剪切并粘贴到这个新文件中。在模板中,我们将对控制器的引用替换为更通用的名称,例如 list。

建立隔离作用域并传递数据

为了使指令独立于父控制器,同时又能接收来自控制器的数据,我们需要为指令创建隔离作用域。
我们将在指令的 DDO 中定义一个 scope 属性,它是一个对象,用于建立指令的隔离作用域并声明需要绑定的属性。
scope: {
list: '=myList'
}
这段代码意味着:在指令的隔离作用域内创建一个名为 list 的属性。这个属性将通过一个名为 my-list 的 HTML 属性,与父作用域中的某个表达式进行双向绑定。

现在,我们需要在 HTML 中使用这个指令。在 index.html 中,我们使用 <shopping-list> 标签,并通过 my-list 属性将控制器的数据传递进去。
<!-- 对于第一个控制器 -->
<shopping-list my-list="list1.items"></shopping-list>
<!-- 对于第二个控制器 -->
<shopping-list my-list="list2.items"></shopping-list>
保存后,在浏览器中测试,第一个清单应该可以正常工作:可以添加项目,点击“移除”按钮也能成功删除项目。这是因为模板中 ng-click="list.removeItem($index)" 所引用的 list,正是通过 my-list 属性传入的 list1.items 对象。

我们为第二个控制器复制相同的指令标签,并传入 list2.items,第二个清单也能独立工作。
使用单向绑定传递标题
现在,我们希望将清单的标题也整合到指令中。标题内容应由父控制器决定,但由指令负责显示。这是一个使用单向绑定的典型场景。
我们回到指令的 DDO,在 scope 对象中添加一个新的绑定属性。

scope: {
list: '=myList',
title: '@' // 使用 @ 符号进行单向绑定
}
这里,title: '@' 表示指令的 title 属性将与一个 DOM 属性的字符串值进行绑定。这是一种单向绑定:当父作用域中对应表达式的值发生变化时,指令中的 title 会更新,但指令内部对 title 的修改不会影响父作用域。
接下来,我们需要在父控制器中设置标题。我们在第一个控制器的逻辑中动态计算标题,使其包含项目数量。
$scope.title = $scope.originalTitle + " (" + $scope.items.length + " items )";
我们需要在添加、编辑和移除项目时都更新这个 title 属性。
然后,在指令模板 shoppingList.tpl.html 的顶部,使用插值表达式显示标题。
<h3>{{ title }}</h3>
最后,在 index.html 中使用指令时,通过 title 属性传递标题值。
<shopping-list my-list="list1.items" title="{{ list1.title }}"></shopping-list>
<shopping-list my-list="list2.items" title="Shopping List #2 (Max 3 items)"></shopping-list>
现在,第一个清单的标题会随着项目数量的增减而动态更新。第二个清单的标题则是一个固定的字符串。这样,我们就创建了一个高度可复用、与父控制器解耦的指令组件。
总结
本节课中我们一起学习了 AngularJS 指令中隔离作用域的核心用法。

- 隔离作用域的作用:它打破了指令作用域从父作用域的原型继承链,使指令更加独立,降低了与父组件(如控制器)的耦合度。
- 传递数据的绑定方式:我们学习了两种主要的绑定方式。
- 双向绑定 (
=): 使用等号=声明。在这种方式下,指令内部属性与父作用域属性是双向联动的。任何一方的修改都会影响另一方。 - 单向属性绑定 (
@): 使用@符号声明。这种方式将 DOM 属性的值(始终是字符串)传递给指令。它是单向的:父作用域属性的变化会传递给指令,但指令内部对属性的修改不会传回父作用域。
- 双向绑定 (

通过结合使用这些技术,我们可以构建出模块化、可测试且易于维护的 AngularJS 组件。
057:在指令内使用控制器


概述
在本节课中,我们将学习如何在 AngularJS 指令中定义和使用控制器。这将使我们的指令不仅能作为模板容器,还能封装特定的行为逻辑,从而提升代码的可读性、语义化和可复用性。
在指令中定义控制器
上一节我们介绍了指令的基本概念和隔离作用域。本节中我们来看看如何为指令添加控制器,以赋予其更复杂的行为。
我们可以在指令定义对象中,通过添加一个名为 controller 的属性来实现。该属性的值应为一个函数,此函数将作为控制器的实现。
angular.module('myApp').directive('myDirective', function() {
return {
scope: {},
controller: function() {
// 控制器逻辑
},
// ... 其他属性
};
});
此控制器函数的工作方式与我们之前编写的任何其他控制器完全相同。
控制器与作用域属性的绑定
如果仅仅指定控制器函数,那么声明在隔离作用域上的属性,现在可以通过控制器内的 $scope 对象访问。当然,我们需要像其他控制器一样注入 $scope。
然而,我们之前介绍控制器时曾解释过,最佳实践不是将属性直接放在 $scope 上,而是放在控制器实例本身,并让 Angular 将该控制器实例作为 $scope 的一个属性附加。这就是“controller as”语法。
为了实现这一点,我们需要在指令定义对象中使用另外两个属性。
以下是实现“controller as”语法和属性绑定的关键步骤:
bindToController属性:此属性告诉 Angular 将隔离作用域属性绑定到控制器实例上,而不是直接绑定到$scope上。controllerAs属性:此属性指定在“controller as”语法中使用的标签。在 HTML 模板中,我们可以使用这个标签来引用作用域属性和方法。
angular.module('myApp').directive('myDirective', function() {
return {
scope: {
myData: '='
},
bindToController: true,
controllerAs: 'myCtrl',
controller: function() {
// 控制器逻辑
// 现在可以通过 `this.myData` 访问隔离作用域属性
}
};
});
实现控制器函数
实现控制器函数应该很熟悉,因为我们使用了“controller as”语法。我们应该遵循最佳实践,将任何额外的属性和方法附加到 this 变量上。
这里我使用了另一个最佳实践:首先将 this 变量的引用存储在局部变量中,然后使用该局部变量将属性和方法附加到控制器实例上。
controller: function() {
var ctrl = this;
ctrl.someProperty = 'Hello';
ctrl.someMethod = function() {
// 方法逻辑
};
// 可以访问和操作声明的隔离作用域属性 `ctrl.myData`
}
需要注意一点,由于我们声明指令的作用域为隔离作用域,因此我们附加到该作用域的任何内容都仍在隔离作用域内,而不仅仅是通过 DDO 中的 scope 声明映射的内容。
在指令模板中使用控制器
最后一步是在指令模板中使用我们的属性和方法。请注意,我使用的是在 DDO 的 controllerAs 属性中声明的标签 myCtrl。
<template>
<div>{{ myCtrl.someProperty }}</div>
<button ng-click="myCtrl.someMethod()">Click Me</button>
</template>
关于单向绑定的说明
在深入代码编辑器之前,还有一点值得提及。我们一直使用等号 = 来声明双向绑定。在双向绑定的情况下,指令中的属性和父级中的属性都会被监视变化,然后同步这些变化。
然而,最佳实践是尽量避免在指令内部更改传入的值。无论如何,如果我们没有计划在指令内部更改绑定的值,那么我们就是在浪费资源,因为 Angular 会在我们的指令内部设置额外的监视器,这些监视器会一直被检查但从未被使用。
因此,我们将尽可能使用单向绑定符号 <。单向绑定只监视父级属性的标识,而不监视指令内部的属性。
公式:scope: { myValue: '<' }
显然,单向绑定不会改变 JavaScript 的基本工作原理。因此,如果你使用单向绑定传入一个原始值(如数字),单向绑定可以保证指令外部的值不会受到你在指令内部对传入值所做的任何操作的影响。
然而,如果你传入一个对象,并且你的指令更改了该对象的某个属性值,虽然不会因此设置或触发监视器,但指令外部的上下文肯定会受到影响,因为这种更改在指令外部是可见的。
请记住,在 JavaScript 中,对象是通过引用传递的,你的指令和指令外部的上下文都指向同一个对象。
因此,虽然设置单向绑定有助于提高性能(因为 Angular 不会设置额外的监视器),但它根本无法保证对象的属性不会被指令更改。
总结

本节课中我们一起学习了如何在 AngularJS 指令中集成控制器。我们了解了在指令定义对象中定义 controller 函数,以及如何使用 bindToController 和 controllerAs 属性将隔离作用域属性优雅地绑定到控制器实例上。我们还探讨了在指令模板中通过控制器别名来访问属性和方法的最佳实践。最后,我们讨论了双向绑定与单向绑定的区别,以及何时使用单向绑定 < 来优化性能。通过这些步骤,我们可以创建出功能更强大、结构更清晰的指令。
058:在指令内使用控制器 🎯


概述
在本节课中,我们将学习如何在 AngularJS 指令内部使用控制器。我们将通过修改一个购物清单应用,将错误提示功能从主页面移动到指令内部来实现。这有助于我们更好地理解指令的封装性和组件化设计。
代码环境与目标
我们回到代码编辑器,位于第29讲的示例文件夹中。这是我们一直在开发的购物清单应用。本次的目标是将错误提示功能整合到 shoppingList 指令内部。
之前,错误提示信息显示在指令外部的另一个 div 中。从组件化设计的角度思考,错误提示应该属于购物清单指令的一部分,因为指令本身了解清单中的项目,并能决定是否显示错误信息。
修改指令定义对象
首先,我们修改 app.js 文件中的指令定义。我们将 items 的绑定方式从双向绑定(=)改为单向绑定(<),因为我们不打算在指令内部修改传入的数组。
.directive('shoppingList', function() {
return {
templateUrl: 'shoppingList.html',
scope: {
items: '<', // 改为单向绑定
title: '@'
},
// 在DDO上声明控制器
controller: 'ShoppingListDirectiveController',
controllerAs: 'list',
bindToController: true // 将scope属性绑定到控制器实例
};
})
定义指令控制器
接下来,我们需要定义这个控制器。我们可以直接在指令函数下方定义,也可以将其注册到模块上以便复用。这里我们先展示直接定义的方式。
// 方式一:作为局部函数定义
function ShoppingListDirectiveController() {
var list = this;
// 定义功能:检查列表中是否包含“饼干”
list.cookiesInList = function () {
for (var i = 0; i < list.items.length; i++) {
var name = list.items[i].name;
if (name.toLowerCase().indexOf("cookie") !== -1) {
return true;
}
}
return false;
};
}
控制器的核心功能是 cookiesInList 方法。它遍历绑定到控制器的 items 数组,检查是否有项目名称包含“cookie”字符串,并返回布尔值。
修改指令模板
现在,我们需要在指令的模板文件 shoppingList.html 中使用这个控制器功能。


- 首先,更新列表渲染部分,通过
list别名访问控制器实例。 - 然后,添加一个警告信息
div,并使用ng-if指令根据list.cookiesInList()方法的返回值来控制其显示。
<!-- 使用 controllerAs 语法访问 items -->
<div ng-repeat="item in list.items">
{{ item.quantity }} of {{ item.name }}
<button ng-click="list.removeItem($index);">Remove Item</button>
</div>
<!-- 根据控制器逻辑显示警告 -->
<div class="error" ng-if="list.cookiesInList()">
WARNING! WARNING! COOKIES DETECTED!
</div>

在模块上注册控制器(可选)

为了使控制器可在模块的其他地方复用,我们可以将其注册到 AngularJS 模块上,而不是定义为局部函数。
// 方式二:在模块上注册控制器
.controller('ShoppingListDirectiveController', ShoppingListDirectiveController);
// 然后在指令的DDO中通过字符串名引用
controller: 'ShoppingListDirectiveController',
功能验证
保存所有修改后,我们回到浏览器进行测试。
- 添加“薯片”等普通商品,警告信息不会出现。
- 添加名称包含“cookie”的商品(如“饼干”)时,红色的警告信息会立即显示在购物清单下方。

这证明了我们成功将业务逻辑封装在了指令的控制器内部。


总结
本节课我们一起学习了如何在 AngularJS 指令内部使用控制器。

- 核心目的:为指令添加专属的业务逻辑,实现更好的封装性和组件化。
- 实现步骤:
- 在指令定义对象中,通过
controller属性声明控制器。 - 配合使用
controllerAs和bindToController: true,将作用域属性绑定到控制器实例。 - 在控制器函数中,通过
this关键字(或controllerAs指定的别名)定义属性和方法。 - 在指令模板中,通过控制器别名调用其方法或访问数据。
- 在指令定义对象中,通过
- 绑定方式选择:尽可能使用单向绑定(
<)替代双向绑定(=),这不仅能提升性能,也符合“避免在指令内修改传入对象”的最佳架构实践。 - 控制器注册:控制器可以定义为指令的局部函数,也可以注册到模块上成为可复用的组件。

通过将错误检查逻辑内聚到 shoppingList 指令中,我们的代码结构变得更加清晰和可维护。
059:指令API与&绑定


概述
在本节课中,我们将学习AngularJS指令API中的一个重要概念:使用&符号进行引用绑定。这种绑定方式允许指令调用父控制器中的方法,并将指令内部的数据传递给该方法。
指令与父控制器的通信场景
有时,指令需要调用其父控制器中的方法,并传递一些只有指令自身才知道的数据。
例如,基于最初提供给指令的数据,传递列表中哪个项目被点击了。
假设你有一个类似于我们一直在使用的购物清单指令,它负责显示购物清单。
它可能还负责提供一个按钮,用于从列表中移除项目。
然而,负责管理这些数据的是父控制器。购物清单指令只负责显示列表。它不知道,也不应该知道如果有人想从列表中移除项目时应该发生什么。
也许需要发生一些与指令职责无关的副作用。
实际的数据操作,例如移除项目,需要在父控制器中进行。换句话说,我们需要在指令和父控制器的某个方法之间建立链接,然后从指令向父控制器的方法提供一些数据以供执行。
然而我们必须小心,父控制器必须在自己的上下文中执行该方法,而不是在指令的上下文中。
这意味着当我们调用父控制器的方法,并且该方法尝试使用$scope时,它应该引用父控制器的作用域,而不是指令的独立作用域。
这是通过引用绑定实现的,用&符号表示,我们将在后续步骤中看到一些额外的语法。
实现步骤详解
显然,我们需要从调用父控制器中某个方法的需求开始。
步骤1:在父控制器中定义方法
在这个例子中,我们调用method方法,这是一个接收一个参数的函数。
请注意,我们使用this关键字将此方法定义为控制器实例的一个属性。this关键字保持指向此控制器的实例显然非常重要。
这里声明的参数arg1需要来自子指令。
步骤2:在指令定义对象中声明绑定
在DDO中,我们声明一个myMethod独立作用域属性,它将作为属性名,用于引用父控制器作用域上存在的传入方法。
&符号后的引用将跟随名称method,这是在父HTML模板中需要使用的名称,用于传递对父方法的引用。
这里没有太多新内容,属性名由指令使用,而该属性的值由父控制器使用,父控制器在其HTML内部引用该值。
步骤3:在父控制器模板中使用指令
在父控制器模板中使用我们的指令,指定在DDO中声明的method属性,并提供对控制器方法的引用作为其值。
请注意,这里传入的myArg参数只是一个键,稍后将用于分配来自指令独立作用域的内容。我们实际上并没有像这里显示的那样将myArg参数从控制器传递给指令。
我知道这语法有点奇怪,因为它看起来就是这样。但请记住,使用&符号引用绑定时,调用中的参数名只是一个键,稍后我们将使用该键将指令中的值映射到该键,Angular将用此键调用我们控制器的方法。
这可能会让人有点困惑,所以不要过早感到沮丧,等待编码示例将这一切在你的脑海中整合起来。
我们还有一个步骤。
步骤4:在指令模板中触发方法调用
最后,在指令模板中,我们通过使用指令的控制器作为标签dirCtrl,并调用在独立作用域绑定上声明的myMethod方法,来触发映射的控制器方法调用。
但请注意我们传递给方法的内容:不是一个常规参数。
相反,我们使用父控制器调用时使用的参数名,并将指令中的某个值映射给它。
所以我们传递给方法的是一个映射对象,而不是单个参数myArg。
该映射对象中的键必须与父控制器声明调用时使用的参数名对应。
为了更清楚,我们在控制器模板HTML顶部使用的参数名,就是我们通过传入一个以该参数名为键的对象,将指令内部值映射到的键。
概念梳理与总结
好了,在我们跳转到代码编辑器之前,让我们尝试直观地将所有内容整合起来,使其尽可能清晰。
你的控制器中有一些功能,你希望当指令内部发生某些事情时执行这些功能,而该指令位于控制器的模板中。
毕竟,控制器掌握着数据,你希望指令只显示这些数据,而不是操作它。
控制器有一个名为method的方法,它接收一个参数。
我们需要将我们的方法作为引用传递给指令,以便当指令内部发生某些只有指令自己知道的事情时,指令可以将调用委托给这个方法。
例如,每当有人在指令模板内点击一个按钮时。我们传递对控制器方法的引用的方式,是使用一个与我们已在DDO中声明的属性相匹配的属性。在这个例子中,属性名是method。
这意味着我们的指令内部引用该方法的方式现在是myMethod。如果我们想调用传入指令的控制器函数,我们必须使用的名称是myMethod。由于我们在指令内部使用控制器即语法,myMethod将绑定到指令的控制器实例,而不是直接绑定到指令的作用域。
所以当我们在指令模板内调用myMethod时,它将加上我们声明指令控制器时使用的标签作为前缀。
现在,在我们的指令内部,当用户点击按钮时,我们可以调用myMethod,它只是传递给指令的外部控制器方法的一个别名。
最后一项任务是确保将数据从指令正确传递给外部控制器的方法。我们通过传入一个对象来实现这一点,该对象的属性名必须与控制器模板中方法调用的参数名匹配。
结束语
本节课中我们一起学习了AngularJS指令API中的&引用绑定。这是一种强大的机制,允许指令安全地调用父控制器中的函数,并传递指令内部的数据,同时保持数据操作逻辑在父控制器中,符合关注点分离的原则。

内容很多,我的建议是,在你看到第二部分中的示例后,再回顾一遍本讲内容,那么不同部分之间的所有这些联系应该会感觉清晰得多。

好了,让我们跳转到代码编辑器,看看这些概念的实际应用。
060:指令 API 与 & 绑定


概述
在本节课中,我们将学习 AngularJS 指令 API 中的一个重要概念:& 绑定。我们将通过一个购物清单应用的例子,演示如何从指令的独立作用域中调用父作用域中的函数,并传递数据。我们将首先展示一种“错误”的实现方式,然后修正它,以理解 & 绑定的工作原理和必要性。
初始场景与问题
我们回到代码编辑器,位于 lecture30 文件夹中。这是一个购物清单指令应用。当前的任务是:在每个清单项旁边提供一个按钮,用户点击后可以删除该项。
我们首先尝试一种不太正确的方式来实现这个功能。
在 index.html 中,我们在 shopping-list 指令上添加一个 bad-remove 属性,并直接将父控制器中的函数引用传递给它。
<!-- index.html -->
<shopping-list ... bad-remove="list.removeItem">
在指令的控制器文件 abgs.js 中,我们为指令的独立作用域定义一个 badRemove 属性,使用 = 进行双向绑定。
// abgs.js
scope: {
badRemove: '='
}


在父控制器 ShoppingListController 中,我们有一个 removeItem 方法。为了观察 this 的指向,我们添加了一个副作用:将最后删除的项名保存在 this.lastRemoved 属性中,并在视图中显示它。
// ShoppingListController
this.removeItem = function (itemIndex) {
console.log("‘this’ is: ", this);
this.lastRemoved = this.items[itemIndex].name;
this.items.splice(itemIndex, 1);
};

<!-- index.html 中显示最后删除的项 -->
<span>Last removed: {{list.lastRemoved}}</span>
在指令的模板 shopping-list.html 中,我们为每个项添加一个按钮,点击时调用 badRemove 函数并传入当前项的索引。
<!-- shopping-list.html -->
<button ng-click="badRemove($index)">Bad Remove</button>
预期:点击按钮,项被删除,lastRemoved 被更新并显示。
实际:点击按钮后,项被删除了,但 lastRemoved 没有显示,标题也未更新。
打开浏览器控制台,查看 console.log 的输出,我们发现 this 指向的是 ShoppingListDirectiveController(指令的控制器),而不是我们期望的父控制器 ShoppingListController。因此,this.lastRemoved 和 this.title 的赋值都发生在指令的独立作用域中,父控制器对此一无所知。

理解问题根源:this 的上下文丢失
上一节我们遇到了函数在错误上下文中执行的问题。本节中,我们通过一个简单的 JavaScript 例子来理解其根本原因。


考虑以下对象:
function Person() {
this.fullName = ‘Yaakov’;
this.fave = ‘cookies’;
this.describe = function() {
console.log(this.fullName + ‘ likes ’ + this.fave);
};
}
var yaakov = new Person();
yaakov.describe(); // 输出:”Yaakov likes cookies”
当我们直接调用 yaakov.describe() 时,函数内部的 this 正确指向 yaakov 对象。
但是,如果我们把方法“抽离”出对象,再调用:
var describeFunction = yaakov.describe;
describeFunction(); // 输出:”undefined likes undefined”

此时,describeFunction 作为一个独立函数被调用,其 this 指向了全局对象(在浏览器中是 window),而 window 上没有 fullName 和 fave 属性,所以输出是 undefined。



JavaScript 的解决方案:使用 .call() 方法显式指定 this 的上下文。
describeFunction.call(yaakov); // 输出:”Yaakov likes cookies”
回到我们的 AngularJS 问题:在指令模板中,ng-click=“badRemove($index)” 的调用方式,类似于将父控制器的 removeItem 方法“抽离”出来,然后在指令控制器的上下文中执行,导致 this 指向错误。

解决方案:使用 & 绑定
理解了问题所在后,我们来看看 AngularJS 提供的解决方案:& 绑定(引用绑定)。它允许我们在父作用域的上下文中执行一个表达式。
以下是实现步骤:

-
在指令定义中声明
&绑定属性
在abgs.js中,我们为独立作用域添加一个新的属性onRemove,使用&符号。// abgs.js scope: { // ... 其他绑定 onRemove: ‘&’ } -
在父模板中传递函数引用和参数映射
在index.html中,我们不再使用bad-remove,而是使用新的on-remove属性。其值是一个函数调用,并指定一个参数名(例如index)来接收指令传递过来的值。<!-- index.html --> <shopping-list ... on-remove=“list.removeItem(index)”>这里的
index是一个键名,它告诉指令:“当你调用这个函数时,请把一个值绑定到名为index的参数上”。 -
在指令模板中调用并传递数据
在shopping-list.html模板中,我们调用onRemove函数。但这次,我们传递一个对象映射,将父模板中定义的参数键(index)映射到指令内部的值($index)。<!-- shopping-list.html --> <button ng-click=“onRemove({index: $index})”>Remove Item</button>
工作原理:当按钮被点击时,AngularJS 会执行 onRemove 所引用的表达式(即 list.removeItem(index)),并且将指令提供的对象 {index: $index} 中的值,赋给表达式中的对应参数 index。关键在于,这个表达式是在定义它的地方——即父控制器的上下文中——被求值和执行的,因此函数内的 this 正确指向了父控制器。
验证结果

保存所有更改并刷新浏览器。
- 添加几个测试项(如 test1, test2, test3)。
- 点击新的 “Remove Item” 按钮删除
test2。 - 观察结果:“Last removed: test2” 成功显示,页面标题也更新为正确的项目数量。
- 查看控制台,
console.log(“‘this’ is: “, this)的输出现在显示this指向ShoppingListController,这正是我们期望的。
至此,功能正确实现。& 绑定确保了函数在正确的上下文中执行,并且建立了一个清晰的、从指令独立作用域到父作用域的数据传递通道。

总结

本节课我们一起学习了 AngularJS 指令 API 中的 & 绑定。
- 问题:当指令需要调用父作用域的函数时,简单的函数引用传递可能导致函数在错误的上下文(指令的独立作用域)中执行,引发
this指向错误。 - 解决方案:使用
&绑定(引用绑定)。 - 实现模式:
- 父模板:通过属性提供一个函数调用表达式,并定义参数键,例如
on-remove=“parentCtrl.removeItem(index)“。 - 指令模板:调用该引用时,传入一个对象映射,将值绑定到父模板定义的参数键上,例如
ng-click=“onRemove({index: $index})”。
- 父模板:通过属性提供一个函数调用表达式,并定义参数键,例如
- 核心优势:
&绑定保证了表达式在父作用域的上下文中执行,同时提供了一种结构化的方式,让指令能将数据从独立作用域传递回父作用域。


通过先展示错误再修正的过程,我们深刻理解了 & 绑定的必要性和工作原理,这是构建交互式 AngularJS 指令的关键技能之一。
061:使用link操作DOM


概述
在本节课中,我们将要学习 AngularJS 中一个重要的概念:如何在指令中通过 link 函数来操作 DOM。我们将探讨为什么有时需要直接操作 DOM,以及 AngularJS 如何通过 link 函数和 JQLite 来帮助我们安全、高效地完成这项工作。
从开始讨论 AngularJS 起,我们就介绍了模型-视图-模型架构,即 MVVM 设计模式。这个架构的一部分明确指出,我们不希望自己直接操作 DOM,而是希望 AngularJS 为我们完成,而我们只需使用属性绑定等声明。
我也曾告诉过你,AngularJS 被称为 MV* 框架,因为有时坚持最纯粹的方式、永远不在代码中直接操作 DOM 是行不通的。AngularJS 允许应对这种情况,这也是它能更容易地与其他优秀库(例如用于高级 JavaScript 可视化和图形的 D3.js 库)集成的原因之一。
但 AngularJS 不仅仅是允许你操作 DOM,它还通过提供一个精简版的 jQuery(称为 JQLite)来帮助你,并且允许你通过简单地引入完整版 jQuery 库来使用它,而无需进行任何其他配置。
此外,AngularJS 还通过指令的 link 属性提供了一个操作 DOM 的特殊入口。这个属性持有一个函数,通常你在这个函数中直接更新 DOM 并注册原生事件监听器。
之前我们在学习设置自定义监视器时,我曾警告过在控制器内部设置它们是一种不好的做法,并承诺会向你展示一个更合适的地方来设置监视器。link 函数就是这些地方之一。让我们来看一些示例代码。
理解 link 函数的结构

在指令定义对象中,我们通过 link 属性声明 link 函数。link 函数可以与指令的控制器一起工作,或者,由于它可以访问与控制器相同的范围和属性,它也可以接管控制器的所有职责。
link 函数总是被传入特定的参数,所以我们不直接向 link 函数注入任何东西。然而,如果我们想使用某些服务,我们可以将其注入到指令工厂函数中,然后在 link 函数内部使用它。
一旦我们在指令定义对象中声明了 link 函数,我们就必须定义实际的 link 函数。请注意这里的参数:
-
scope:注意它不是$scope,而只是scope。你当然也可以在这里命名为$scope,但我们通常省略美元符号的原因是为了提醒自己,作用域不是注入到link函数中的。请记住,$scope或任何带$的变量在 JavaScript 中只是一个有效的变量名。link函数只是将指令的作用域作为第一个参数传递给你定义的函数,你可以像在控制器内部一样读取和附加属性到该作用域上。 -
element:element参数代表指令的顶层元素。换句话说,要么是这个指令定义的自定义元素,要么是这个自定义指令作为属性声明所在的元素。此外,这个元素是原生 DOM 元素对象的包装器,带有来自 JQLite 的特殊函数。JQLite 是 jQuery 的精简版。如果在主 HTML 页面中,jQuery 库在 AngularJS 主文件之前被引入,那么这个element就是 jQuery 对象本身。在这种情况下,它支持 jQuery 的许多选择器函数,以及一些动画方法,如fadeIn、fadeOut、slideDown、slideUp等。我们稍后在查看实际代码示例时会看到这些。 -
attrs:attrs参数是一个对象,包含对元素上声明的属性的引用。 -
controller:controller是对该指令声明的控制器的引用(如果存在的话)。
末尾还有一个与嵌入(transclusion)相关的参数,这里没有展示,因为我们还没有涉及嵌入,并且因为它是 JavaScript,所以我可以省略那些我无论如何都不会处理的参数。
核心概念与代码示例
以下是 link 函数在指令定义中的基本结构:
angular.module('myApp')
.directive('myDirective', function() {
return {
restrict: 'E',
scope: {},
link: function(scope, element, attrs, controller) {
// 在这里操作 DOM 和注册事件监听器
// 例如,改变元素的文本内容
element.text('Hello from link function!');
// 例如,添加一个点击事件监听器
element.on('click', function() {
alert('Element was clicked!');
});
}
};
});
在上面的代码中:
scope是当前指令的作用域。element是指令关联的 DOM 元素(已由 JQLite/jQuery 包装)。attrs包含了该元素上所有属性的键值对。controller可用于访问指令控制器的实例方法。
总结


本节课中,我们一起学习了 AngularJS 指令中 link 函数的作用和用法。我们了解到,虽然 AngularJS 推崇声明式的 MVVM 模式,但在需要与 DOM 进行直接交互或集成第三方库时,link 函数提供了一个官方且强大的途径。它接收四个主要参数:scope、element、attrs 和 controller,使我们能够在正确的生命周期里安全地操作 DOM 元素、绑定事件以及访问属性。下一部分,我们将通过实际的代码示例来进一步巩固这些概念。
062:使用 link 操作 DOM


概述
在本节课程中,我们将学习如何在 AngularJS 指令的 link 函数中直接操作 DOM。我们将创建一个购物清单应用,当清单中包含“饼干”时,会显示一个警告信息。我们将首先使用 AngularJS 内置的 jqLite 实现,然后升级到使用完整的 jQuery 库来添加动画效果。
准备工作
我们位于 lecture 31 文件夹中,这是购物清单指令应用的一个版本。在之前的版本中,如果检测到清单中有“饼干”,我们使用 ng-if 在购物清单下方显示错误信息。在本版本中,我们将使用 DOM 操作来实现类似功能。

声明 Link 函数
首先,我们需要在指令定义对象中声明 link 函数,以便调用 DOM 操作方法。

link: ShoppingListDirectiveLink
接着,我们定义这个 ShoppingListDirectiveLink 函数。该函数接收 scope、element、attributes 和 controller 作为参数。
function ShoppingListDirectiveLink(scope, element, attrs, controller) {
console.log(scope);
console.log(controller);
console.log(element);
}
在浏览器控制台中,我们可以看到:
link函数的scope与注入到指令控制器中的$scope完全相同。controller实例就是我们定义的ShoppingListDirectiveController。element参数代表指令的顶级元素,即<shopping-list>。
设置监视器
我们的目标是监视控制器中的 cookiesInList 函数。当该函数返回值发生变化时,我们需要显示或隐藏警告信息。
以下是设置监视器的代码:


scope.$watch(controller.cookiesInList, function(newValue, oldValue) {
if (newValue === true) {
displayCookieWarning();
} else {
removeCookieWarning();
}
});
现在,我们需要定义 displayCookieWarning 和 removeCookieWarning 这两个函数。

准备模板和样式
在显示警告之前,我们需要在指令模板中为其创建一个占位符。
<div class="error">😊 Cookies detected.</div>

同时,在 CSS 中设置初始状态为不可见:
.error {
display: none;
}
使用 jqLite 操作 DOM
AngularJS 内置的 jqLite 提供了一组有限的 jQuery 方法。我们可以使用 element.find() 来查找元素,但注意它仅限于通过标签名进行查找。


以下是使用 jqLite 显示和隐藏警告的函数:
function displayCookieWarning() {
var warningElem = element.find('div');
warningElem.css('display', 'block');
}

function removeCookieWarning() {
var warningElem = element.find('div');
warningElem.css('display', 'none');
}


重要提示:element.find() 只会在指令元素内部的 DOM 中查找,这使得它在大型 HTML 页面中具有很高的性能。
现在,当我们在购物清单中添加“饼干”时,警告信息会显示;移除“饼干”时,警告信息会消失。
升级到使用 jQuery
为了获得更丰富的动画效果,我们可以引入完整的 jQuery 库。确保在引入 AngularJS 之前引入 jQuery。

引入 jQuery 后,element 对象将拥有完整的 jQuery 方法集,包括 slideDown 和 slideUp 等动画方法。
我们可以更新函数,使用更精确的选择器和动画效果:
function displayCookieWarning() {
var warningElem = element.find('div.error');
warningElem.slideDown(900);
}

function removeCookieWarning() {
var warningElem = element.find('div.error');
warningElem.slideUp(900);
}
现在,警告信息会以滑动动画的形式显示和隐藏,用户体验更加流畅。
总结
本节课我们一起学习了如何在 AngularJS 指令中使用 link 函数操作 DOM。


- DOM 操作通常在指令定义对象的
link函数中完成。 link函数本身不支持依赖注入,但可以通过指令声明注入所需的组件、服务或控制器。link函数的第一个参数scope与指令控制器中的$scope是同一个对象。- 第二个参数
element代表指令的顶级元素。如果 jQuery 在 AngularJS 之前引入,element是一个完整的 jQuery 对象;否则,它是一个 AngularJS 提供的简化版 jqLite 对象。 - 使用
element.find()可以在指令内部高效地查找 DOM 元素。 - 通过集成像 jQuery 这样的第三方库,我们可以在指令内部直接使用丰富的 DOM 操作和动画方法,使代码模块化且易于维护。
AngularJS 单页应用开发:P63:使用指令的 transclude 包装其他元素


在本节课中,我们将学习 AngularJS 指令中一个名为 transclude 的强大功能。它允许指令包装任意的 HTML 内容,包括表达式和函数调用,并且这些内容将在父作用域而非指令的独立作用域中进行求值。
概述
上一节我们介绍了如何向指令的独立作用域传递字符串、插值字符串、对象和函数引用。然而,有时我们需要传递整个模板,而不仅仅是简单的数据。本节中,我们来看看如何使用 transclude 属性来实现这一需求。
一个常见的用例是,当你将对话框编码为一个指令时。这个对话框需要具备对话框的行为(例如能够关闭、提供确定和取消按钮),但同时它必须足够通用,以允许使用者提供对话框实际要显示的内容。这正是指令定义对象(DDO)中的 transclude 属性所能实现的功能。
核心概念与步骤
使用 transclude 允许你用指令包装任意内容,包括 HTML 模板中的表达式和函数调用。最关键的一点是,被包装的表达式是在父作用域中求值的,而不是在指令的独立作用域中。
以下是实现此功能的三个步骤:
步骤一:在指令定义中启用 transclude
在指令的 DDO 中,将 transclude 属性设置为 true。
transclude: true
步骤二:在模板中使用指令并包装内容
在 HTML 模板中使用你的指令,并在其开始和结束标签之间放入需要包装的内容。请注意,此处的任何插值表达式等,都将在父控制器的作用域中进行求值。
步骤三:在指令模板中指定插入位置
在指令自身的模板中,使用 ng-transclude 属性来标记一个位置。AngularJS 会将步骤二中包装的、已经过求值的内容,插入到这个指定位置。
总结


本节课中,我们一起学习了 AngularJS 指令的 transclude 功能。我们了解到,通过三个简单的步骤——在 DDO 中启用 transclude、在父模板中包装内容、在指令模板中使用 ng-transclude 指定插入点——即可创建能够灵活包装和显示外部内容的指令。这个功能既简单又强大,是构建可复用组件(如对话框、卡片等)的关键技术。
064:使用指令的 transclude 包装其他元素


概述
在本节课中,我们将学习 AngularJS 指令中一个强大的功能:transclude。我们将了解如何使用 transclude 来包装和插入父作用域中的内容到指令模板中,同时保持内容在原始父作用域上下文中的绑定和解析。
项目背景与目标
上一节我们介绍了指令的隔离作用域。本节中,我们来看看如何让指令能够灵活地包装来自父模板的任意内容,而不仅仅是处理简单的字符串或对象。
我们的目标是修改现有的购物清单应用,创建两个并排显示的购物清单。关键区别在于,每个清单显示的警告信息将由使用指令的父模板自定义提供,而不是由指令内部硬编码决定。
步骤一:准备 HTML 结构
首先,我们需要在 index.html 中设置两个并排的购物清单指令实例。
以下是具体的 HTML 代码修改:
<!-- 第一个购物清单 -->
<shopping-list list-id="list1">
<span class="warning">Warning: {{listThatWarning}}</span>
</shopping-list>
<!-- 第二个购物清单 -->
<shopping-list list-id="list2">
<div class="title">OH NO!</div>
<div>{{listThatWarning}}</div>
</shopping-list>
list-id属性:我们为每个指令实例指定了唯一的ID(list1和list2),以便区分。- 自定义警告内容:每个
<shopping-list>标签内部的内容就是我们希望“透传”到指令内部的内容。第一个使用简单的<span>,第二个使用了更复杂的结构,包含标题和内容。 {{listThatWarning}}:这是一个绑定到父控制器ShoppingListController中listThatWarning属性的表达式。
为了并排显示,我们添加了简单的 CSS 样式,将两个元素浮动到左侧,并为第二个元素添加左边距以分隔开。
步骤二:理解父控制器数据
让我们回到 index.html,查看数据来源。{{listThatWarning}} 绑定的是定义在主父控制器 ShoppingListController 上的一个属性。
在 app.js 中找到 ShoppingListController,可以看到我们初始化了一个名为 listThatWarning 的属性。这样,指令本身并不知道要显示什么警告信息,警告内容将根据我们使用指令的方式(即上面HTML中定义的内容)进行自定义。
步骤三:启用并配置指令的 Transclude 功能
现在,我们需要修改指令定义,使其能够接收并放置这些自定义内容。
-
在指令定义对象中启用
transclude:
在app.js中找到shoppingList指令的定义。我们需要设置transclude: true。.directive('shoppingList', function () { return { templateUrl: 'shoppingList.html', scope: { list: '=', title: '@title', listId: '@' }, controller: 'ShoppingListDirectiveController', controllerAs: 'list', bindToController: true, transclude: true // 启用内容透传功能 }; })

-
在指令模板中指定插入点:
接下来,我们需要告诉 Angular,透传过来的内容应该放在指令模板shoppingList.html的哪个位置。我们使用ng-transclude指令来标记这个插入点。找到模板中用于显示错误信息的部分,将
ng-transclude属性添加进去。<!-- shoppingList.html 片段 --> <div class="error" ng-show="list.cookiesInList()" ng-transclude> <!-- 透传的内容将出现在这里 --> </div>这意味着,父模板中
<shopping-list>标签内的所有内容(即我们自定义的警告信息),在求值后将被插入到这个div的内部。
步骤四:查看运行结果
保存所有修改并刷新浏览器页面。现在,当我们尝试添加“cookies”时,两个购物清单会并排显示,并且警告信息各不相同:
- 第一个清单显示简单的斜体警告:“Warning: cookies detected”。
- 第二个清单显示一个带有“OH NO!”标题和“cookies detected”正文的警告框。
如果我们移除“cookies”,警告会从两边同时消失。同时,之前课程中实现的 jQuery 动画效果仍然正常工作。
核心概念与总结
本节课中我们一起学习了 transclude 机制的核心要点。
transclude 的核心行为是:被包装的内容在其原始的父作用域上下文中求值,而不是在指令的隔离作用域中。
以下是使用 transclude 的关键步骤总结:
-
在指令定义对象中声明:包含一个
transclude属性并将其设置为true。transclude: true -
在指令模板中标记插入点:使用
ng-transclude属性来指定透传内容在模板中的放置位置。<div ng-transclude></div> -
在父模板中提供内容:在自定义指令的标签内部编写需要透传的 HTML 和表达式。
transclude 的强大之处在于:即使指令使用了隔离作用域,通过 transclude 插入的内容中的表达式(如 {{listThatWarning}}),Angular 在解析时也会忽略指令的隔离作用域,转而向上查找其原始的父作用域(本例中是 ShoppingListController)。这使得指令能够封装结构,同时允许父模板灵活控制部分内容。


通过这种方式,我们创建了高度可重用且结构清晰的指令,它既能管理复杂的内部逻辑和视图,又能为外部调用者提供定制特定区域内容的能力。
065:模块3总结

概述
在本节课中,我们将对模块3的学习内容进行总结,并展望后续模块的学习重点。
模块3学习回顾
恭喜你完成了模块3的学习。通过本模块,你已经掌握了指令(Directives)、承诺(Promises)等核心概念。你现在已经具备了掌握AngularJS的良好基础。
后续学习展望
然而,学习不应在此停止。下一个模块的主题将为你完整地勾勒出AngularJS的全貌。此外,你只需要再完成一个模块的学习,就能获得足够的技能,从而能够理解我将为一个真实客户编写整个真实Web应用程序的过程。这将会非常有趣。
掌握后续技能的必要性
但是,你确实需要学习下一个模块所教授的技能,才能真正理解我们将要完成的工作。你现在已经完成了本课程大半的内容,再坚持几周即可完成全部学习。
保持联系与获取额外资源
为了保持联系并获取一些额外的可选学习材料,请务必访问 Facebook.com/courserawebdev 并为我们的页面点赞。同时,请在Twitter和LinkedIn上关注我。我在这些社交网络上的个人资料链接位于视频下方。

总结
本节课中,我们一起回顾了模块3的核心成就,并明确了继续学习模块4对于深入理解和实践AngularJS开发的重要性。我们下个模块再见。
066:组件、模块与路由 🚀
概述

在本模块中,我们将学习如何构建更结构化、更易维护的单页 Web 应用。我们将从组件化架构思想入手,然后深入探讨 AngularJS 的组件 API、事件系统、模块化组织,并最终重点学习如何在应用的不同视图之间进行路由。
组件化架构介绍 🧱
上一节我们完成了模块3的学习,本节中我们将进入模块4,首先介绍组件化架构的基本思想。
组件化架构是一种将用户界面划分为独立、可复用部分的设计方法。每个组件管理自己的视图和数据逻辑,这有助于构建清晰、可测试且易于协作的大型应用。
AngularJS 组件 API 🔧
了解了组件化架构的优势后,我们来看看 AngularJS 如何实现它。AngularJS 在后期版本中引入了组件 API。
这个 API 不仅旨在通过使用组件化架构来改进你的应用程序,还能为你升级到几乎完全使用组件的 Angular 2+ 版本做好准备。一个基本的组件使用 component 方法进行注册,其结构包含控制器和模板。
angular.module('myApp').component('myComponent', {
templateUrl: 'my-component.html',
controller: MyComponentController
});
AngularJS 事件系统与模块化 📦
掌握了组件创建后,我们需要让它们能够通信。本节我们将学习 AngularJS 的事件系统以及如何将应用拆分为更小的模块。
AngularJS 提供了 $emit(向上派发)和 $broadcast(向下广播)等方法来进行跨作用域的事件通信。同时,将应用功能拆分到不同的 Angular 模块中,有助于保持代码的组织性。这些独立的模块可以像拼图一样组合起来,最终形成完整的应用程序。
以下是创建和使用模块的步骤:
- 创建模块:使用
angular.module(‘moduleName‘, [])定义新模块。 - 注册组件/服务:在新模块上注册其专属的组件、服务或指令。
- 注入依赖:在主应用模块的依赖数组中引入这些功能模块。
深入应用路由 🧭
当应用由多个组件和视图构成时,如何在它们之间导航就变得至关重要。本节我们将深入探讨单页应用中的路由,特别是 UI-Router 模块的使用。
没有路由,你的单页应用将只能显示一个视图,无法优雅地切换到其他内容。UI-Router 是 AngularJS 生态中最受欢迎的开源路由解决方案之一,它甚至被谷歌官方的路由文档所引用。它通过状态机模型来管理应用视图和状态,比核心的 ngRoute 服务更加强大和灵活。
总结
在本模块中,我们一起学习了构建现代 AngularJS 单页应用的核心进阶概念。我们从组件化架构思想出发,实践了如何使用 AngularJS 的组件 API 来创建可复用的 UI 单元。接着,我们探索了组件间通信的事件系统以及将应用拆分为功能模块的最佳实践。最后,我们深入研究了使用 UI-Router 进行客户端路由,这是构建多视图单页应用的关键。掌握这些知识,你将能够构建出结构更清晰、更易维护的复杂 Web 应用。

(附注:关于课程中提到的蔬菜,我会在本模块结束时吃完这碗健康食物的。)🥗
067:基于组件的架构(第1部分)🏗️


在本节课中,我们将要学习 Angular 1.5 引入的“组件”概念。这是一种实现指令的新方式,它采用简化的配置,并预设了一些最佳实践。我们将探讨基于组件的架构原则,以及如何在 Angular 中创建和使用组件。
概述
Angular 1.5 引入了一种实现指令的新方法:组件。组件是一种特殊的指令,它使用简化的配置,并预设了一些默认值,这些默认值本身就是最佳实践。虽然组件并不强制你使用基于组件的架构,但使用组件可以更轻松、更自然地以这种方式组织你的应用程序代码。使用组件编写应用程序也能让你更接近 Angular 2 的编程风格。
基于组件的架构原则
首先,我们来了解基于组件的架构原则,以及它如何通过 Angular 组件体现出来。
原则一:组件仅控制自己的视图和数据
组件只控制自己的视图和数据。它们从不修改自身范围之外的数据或 DOM。由于原型继承,你的代码有可能修改应用程序中几乎任何地方的数据。虽然这有时看起来非常方便,但它会迅速导致混乱,因为应用程序中的任何代码都无法确保其数据免受副作用的影响,这些副作用它既不了解,也无法控制。这就是为什么 Angular 组件总是使用隔离作用域。
原则二:组件具有明确定义的公共 API
公共 API 指的是输入到组件的数据和从组件输出的数据。为了贯彻这一原则,忠于基于组件的架构,组件代码应遵循一些简单的约定。这些约定旨在进一步阻止操作不属于组件直接所有的数据,即使在隔离作用域下也可能发生这种情况。
例如,如果你使用等号(=)通过双向绑定传递一个值,那么你在组件中对数据所做的任何更改也会反映在父级中。因此,组件的“输入”应仅使用单向绑定(<)和属性值绑定(@)来定义。
即使使用单向绑定,如果绑定的值是一个对象,更改该对象的属性值也会影响到组件外部的该对象。这仅仅是因为 JavaScript 中的对象是通过引用传递的。虽然你没有修改对象的实际引用,但你正在修改附加到该引用的值。因此,另一个约定是:永远不要更改传入的对象或数组的属性值。
这就是为什么我们强调“明确定义的 API”。API 是一种契约:你期望提供特定的输入,并期望接收特定的输出。在基于组件的架构中,如果我是一个带有 API 的组件的使用者,我必须向 API 提供某些数据,并确保我的数据不会被修改,然后期望 API 产生一些定义好的输出。
组件的输出通过 & 符号定义为回调函数。数据通过一个键值映射对象传递回调用者,该对象将调用者的参数名与映射中的键对应起来。我们在指令中已经见过这种形式。
原则三:组件具有明确定义的生命周期
这意味着组件有许多预定义的方法,我们可以在组件生命周期的不同阶段介入。这些方法包括:
$onInit:该方法用于初始化控制器的功能。$onChanges:每当单向绑定更新时调用此方法,它会接收一个包含currentValue和previousValue等属性的changeObj对象,你可以据此查看值如何变化并做出响应。$postLink:该方法与指令中的link方法非常相似。$onDestroy:当作用域即将被销毁(即从内存中卸载)时调用此方法。你可以使用这个钩子来释放在其他方法中设置的外部资源、监视器和事件处理程序。
此列表中未展示所有方法,你可以随时在线查阅组件 API 生命周期方法的文档。
原则四:应用程序应被视为组件树
这意味着整个应用程序应该由组件构成。正如我们所说,每个组件都有明确定义的输入和输出。通过最小化双向数据绑定,可以更容易地预测数据何时何地发生变化,从而对组件的状态更有信心。
在 Angular 中创建组件
现在,让我们来看看在 Angular 中创建组件的步骤。正如所承诺的,它比创建指令更简单。
第一步:在模块中注册组件
你会注意到格式非常相似。首先,你给出组件的名称(使用驼峰命名法),在 HTML 中,它将被视为连字符形式(例如,myComponent 在 HTML 中为 <my-component>)。

然而,与提供函数或工厂函数作为指令的实现不同,你为组件提供一个简单的配置对象。
angular.module(‘myApp‘, [])
.component(‘myComponent‘, {
// 配置属性将放在这里
});
第二步:配置组件
以下是组件最常见的配置属性:
template或templateUrl:大多数组件都有一个与之关联的模板。controller:你并非必须为组件提供控制器。只有当你确实需要添加一些功能时才在此指定。否则,Angular 会自动提供一个空对象,并自动将其实例放置在隔离作用域上,标签为$ctrl。bindings:在组件中,作用域始终是隔离的,我们无法更改它。我们使用一个名为bindings的属性来定义隔离作用域的参数映射。它实际上与我们之前使用的scope属性完全相同,但现在属性名是bindings,因为我们始终假设是隔离作用域。
angular.module(‘myApp‘, [])
.component(‘myComponent‘, {
templateUrl: ‘my-component.html‘,
controller: ‘MyComponentController‘,
bindings: {
inputData: ‘<‘, // 单向绑定输入
onAction: ‘&‘ // 回调函数输出
}
});
在组件模板中,我们将使用传入组件的属性。注意,我们使用的是 Angular 自动生成的标签 $ctrl,它指向为组件配置的控制器。
<!-- my-component.html -->
<div>
<p>接收到的数据:{{ $ctrl.inputData }}</p>
<button ng-click=“$ctrl.onAction({value: ‘clicked!‘})“>触发动作</button>
</div>
在使用该组件的 HTML 中,其用法与你之前编码的自定义指令完全相同。再次注意,在 bindings 中声明的驼峰命名法标签名和属性名,现在应转换为全部小写并用连字符连接。
<my-component input-data=“someParentData“ on-action=“parentCallback(value)“></my-component>
总结


本节课中,我们一起学习了 Angular 1.5 中引入的组件概念。我们探讨了基于组件架构的四个核心原则:组件仅控制自身数据与视图、拥有明确定义的公共 API、具有明确定义的生命周期,以及应用程序应被视为组件树。我们还详细介绍了在 Angular 中创建组件的两个步骤:在模块中注册组件和配置组件的关键属性(如 templateUrl、controller 和 bindings)。组件通过其简化的配置和预设的最佳实践,使得构建可预测、可维护的单页应用变得更加容易和自然。在下一部分,我们将进入代码编辑器,亲眼看看这些概念的实际应用。
068:基于组件的架构 🧩


在本节课中,我们将学习如何将一个 AngularJS 指令(Directive)重构为一个组件(Component)。我们将通过一个购物清单应用的例子,详细讲解组件的配置、控制器、模板以及输入/输出绑定。
概述
上一节我们介绍了组件化架构的基本概念。本节中,我们将动手实践,把之前用指令实现的购物清单功能,转换成一个更现代、更易维护的 AngularJS 组件。我们将重点关注如何定义组件、设置其绑定属性,并重构控制器逻辑。
初始代码结构
我们从一个名为 shopping list component app 的应用开始。它基本上是第30讲代码的清理版本。
以下是应用的主视图(index.html)结构:
<div ng-controller="ShoppingListController as listCtrl">
<h3>{{ listCtrl.title }}</h3>
<shopping-list items="listCtrl.items"
title="listCtrl.title"
on-remove="listCtrl.removeItem(index)">
</shopping-list>
<div ng-if="listCtrl.lastRemoved">
Last removed item: {{ listCtrl.lastRemoved }}
</div>
</div>
可以看到,我们使用了一个名为 shopping-list 的指令(目前还是指令),并向其传递了 items、title 和 on-remove 属性。
将指令转换为组件
现在,让我们进入 app.js 文件,开始将 shopping-list 指令转换为组件。
以下是转换步骤:
1. 更改注册方法
首先,我们需要将 .directive 注册方法改为 .component。
// 之前是指令
// app.directive('shoppingList', ShoppingListDirective);
// 现在是组件
app.component('shoppingList', {
// 组件配置对象将放在这里
});
2. 定义组件配置对象
组件使用一个简单的配置对象来定义,这比指令的函数形式更清晰。我们需要配置模板、控制器和绑定属性。
app.component('shoppingList', {
templateUrl: 'shoppingList.template.html',
controller: 'ShoppingListComponentController',
bindings: {
items: '<', // 单向绑定输入
title: '@', // DOM 属性字符串绑定
onRemove: '&' // 回调函数输出
}
});

templateUrl: 指定组件模板的路径。controller: 指定组件控制器的名称。bindings: 定义组件的输入和输出,替代了指令中的scope配置。items: ‘<‘表示单向数据绑定。title: ‘@’表示绑定 DOM 属性的字符串值。onRemove: ‘&’表示绑定一个父作用域中的函数,用于回调。
3. 重构组件控制器
接下来,我们需要定义 ShoppingListComponentController。我们将复用之前指令控制器的逻辑,但需要进行一些调整。



app.controller('ShoppingListComponentController', ShoppingListComponentController);
function ShoppingListComponentController() {
var $ctrl = this;
// 检查清单中是否有包含“cookie”的项
$ctrl.cookiesInList = function () {
for (var i = 0; i < $ctrl.items.length; i++) {
var name = $ctrl.items[i].name;
if (name.toLowerCase().indexOf("cookie") !== -1) {
return true;
}
}
return false;
};
// 新的移除方法,用于调用父组件传递的回调
$ctrl.remove = function (myIndex) {
// 调用通过绑定传入的 onRemove 函数,并传递索引参数
$ctrl.onRemove({ index: myIndex });
};
}
关键变化:
- 控制器函数内部,我们使用
var $ctrl = this;来引用组件控制器实例。在模板中也将使用$ctrl。 - 我们添加了一个新的
$ctrl.remove方法。它接收一个索引,然后调用绑定进来的$ctrl.onRemove回调函数,并将索引包装在对象{ index: myIndex }中传递出去。这里的index键名必须与bindings中定义的回调参数名匹配。
4. 更新组件模板
最后,我们需要更新组件模板 shoppingList.template.html,将之前引用控制器实例变量 list 的地方改为 $ctrl,并使用新的 remove 方法。
<h3>{{ $ctrl.title }}</h3>
<ul>
<li ng-repeat="item in $ctrl.items">
{{ item.quantity }} of {{ item.name }}
<button ng-click="$ctrl.remove($index);">Remove Item</button>
</li>
</ul>
<div ng-if="$ctrl.cookiesInList()">
<h4>WARNING! WARNING! COOKIES DETECTED!</h4>
</div>
- 将
list.title改为$ctrl.title。 - 将
list.items改为$ctrl.items。 - 将
list.cookiesInList()改为$ctrl.cookiesInList()。 - 按钮的
ng-click事件从复杂的表达式on-remove({index: $index})简化为直接调用控制器方法$ctrl.remove($index),这样模板更清晰。
测试组件功能
完成以上步骤后,保存所有文件并刷新浏览器。
- 添加项目:在输入框中输入“chips”并添加,它会正常显示在列表中。
- 触发警告:输入“cookies”并添加。由于控制器中的
cookiesInList方法检测到列表中存在“cookie”,警告信息会立即显示。 - 移除项目:点击“cookies”旁边的“Remove Item”按钮。该项目会从列表中移除,警告信息随之消失。同时,被移除的“cookies”项会通过
onRemove回调传递到父控制器,并显示在页面下方的“Last removed item”区域。

总结
本节课中我们一起学习了如何将 AngularJS 指令转换为组件。我们完成了以下关键操作:
- 使用
.component()方法注册组件,并配置templateUrl、controller和bindings。 - 重构控制器,使用
$ctrl别名,并添加方法来处理内部逻辑和调用父级回调。 - 更新模板,使其引用
$ctrl并简化事件处理。

通过这次转换,我们的代码结构变得更加清晰和模块化。组件的 bindings 属性明确区分了输入和输出,使得数据流更容易理解。在下一部分,我们将探讨组件生命周期方法,并利用它们来进一步增强这个购物清单应用的功能。
069:基于组件的架构(第3部分)



在本节课中,我们将继续学习 AngularJS 组件,重点探讨组件控制器中的生命周期方法。我们将学习如何使用这些方法来响应组件状态的变化,并执行 DOM 操作。


在上一讲中,我们将之前编写的指令转换为了组件,并看到了组件配置的简洁性。本节中,我们将深入组件的控制器,了解并应用其生命周期方法。
生命周期方法简介
组件的控制器可以定义一些特定的方法,AngularJS 框架会在特定时机自动调用它们。这些方法被称为生命周期方法。



以下是几个关键的生命周期方法:
$onInit: 在控制器初始化后执行一次。$onChanges: 当组件绑定的输入属性发生变化时执行。$postLink: 在组件模板链接到 DOM 后执行,类似于指令中的link函数。
使用 $onInit 和 $onChanges

我们首先在购物清单组件的控制器中添加 $onInit 和 $onChanges 方法。
controller: function ($scope, $element) {
var ctrl = this;
// 生命周期方法:初始化
ctrl.$onInit = function () {
console.log("$onInit executed.");
};
// 生命周期方法:响应绑定变化
ctrl.$onChanges = function (changesObj) {
console.log("$onChanges fired.", changesObj);
};
// ... 其他控制器逻辑
}
保存代码并在浏览器中打开控制台,可以看到 $onInit 在页面加载时执行了一次。当我们修改组件绑定的属性(例如标题)时,$onChanges 方法会被触发,并传入一个包含变化信息的对象。
注意:$onChanges 默认只监视绑定属性的引用是否改变。对于数组,如果只是向其中添加或删除元素(引用未变),则不会触发 $onChanges。只有为数组赋予一个新数组(新引用)时,才会触发。
使用 $postLink 进行 DOM 操作
接下来,我们使用 $postLink 方法来实现一个动画效果:当购物清单中包含“cookies”时,显示一个警告信息并滑入视图。
首先,我们需要调整 HTML 模板和 CSS,让警告信息默认隐藏。
<!-- 修改前,使用 ng-if -->
<div class="error" ng-if="ctrl.cookiesInList()">WARNING! Cookies detected.</div>
<!-- 修改后,移除 ng-if -->
<div class="error">WARNING! Cookies detected.</div>
.error {
/* ... 其他样式 ... */
display: none; /* 默认隐藏 */
}
然后,在控制器中实现 $postLink 方法。我们需要注入 $scope 和 $element 服务。$element 是一个 jQuery(或 jqLite)对象,代表组件的最外层元素。


controller: ['$scope', '$element', function ($scope, $element) {
var ctrl = this;
ctrl.$postLink = function () {
// 监视 cookiesInList 函数的结果
$scope.$watch(
function () {
return ctrl.cookiesInList();
},
function (newValue, oldValue) {
// 找到组件模板内的 .error 元素
var warningEl = $element.find('div.error');
if (newValue === true) {
// 如果检测到 cookies,滑下显示警告
warningEl.slideDown(900);
} else {
// 否则,滑上隐藏警告
warningEl.slideUp(900);
}
}
);
};
// ... 其他控制器逻辑
}]
重要提示:为了使用 jQuery 的 slideDown 和 slideUp 动画,需要在 AngularJS 之前引入 jQuery 库。
<script src="jquery-3.1.0.min.js"></script>
<script src="angular.min.js"></script>
完成以上步骤后,当在购物清单中添加包含“cookies”的商品时,警告信息会平滑地滑出显示;移除后,警告信息会滑上隐藏。



本节课中,我们一起学习了 AngularJS 组件的三个核心生命周期方法:$onInit、$onChanges 和 $postLink。我们了解了它们各自的调用时机和用途,并通过实例演示了如何使用 $postLink 方法在组件链接到 DOM 后执行 jQuery 动画来操作组件模板内的元素。掌握这些生命周期方法能帮助你更精准地控制组件的行为和交互。
070:基于组件的架构


概述
在本节课中,我们将学习 AngularJS 组件的一个新生命周期方法 $doCheck,并了解如何使用它来替代手动设置 $watch 以监听数据变化。我们还将回顾基于组件的架构的核心原则。
在上一讲中,我们讨论了 $postLink,并看到了如何为购物清单设置一个监视器,然后使用 jQuery 在屏幕上输出错误信息。

我们必须在 $postLink 中设置监视器的原因是,我们的 $onChanges 对象不包含对数组的任何更改。即使我们向数组中添加了更多元素,因为 $onChanges 不会监视数组元素的添加,它只监视数组本身的引用。
当我编写那个示例时,我自己没有意识到我使用的是 Angular 1 的一个稍旧版本(1.5.7)。在 1.5.8 版本中,他们引入了另一个生命周期方法作为组件生命周期方法的一部分。
现在,我们位于 Angular 1 的文档中(docs.angularjs.org/guide/component)。为了更有趣,如果我们切换到 1.5.8 版本并向下滚动到那些生命周期方法,你会看到有 $onInit、$onChanges、$onDestroy 和 $postLink,但我们缺少了另一个在 1.5.8 中实际有效但未在文档中列出的方法。让我们回到默认的快照文档,向下滚动到生命周期方法,因为还有一个他们发布的方法叫做 $doCheck。
这是一个在每次摘要循环(digest cycle)运行时被调用的方法。随着摘要循环的执行,它会无参数地调用此方法。文档说明,此钩子被调用时不带参数。如果你要检测变化,必须存储先前的值以便与当前值进行比较。基本上,你必须自己进行检查。
我们将放弃尝试设置自己的监视器(它只是挂钩到摘要循环中),转而使用标准的生命周期方法 $doCheck。
让我们回到代码编辑器。这里我创建了一个新讲座(lecture 33 1.5.8)。尽管文档说此方法不存在,但我测试过它确实有效。所以它肯定存在。这是与之前完全相同的应用程序,唯一的区别是我编辑并移除了一些东西。
首先,我编辑了购物清单组件控制器中的一个局部变量 totalItems。这个局部变量 totalItems 将在 $onInit 中初始化。我所说的 totalItems 是指我们购物清单中的总项目数。它将在 $onInit 中初始化为 0。
我们保留 $onChanges,即使它对我们来说不是真的有用,只是为了查看它的控制台日志输出。我们在这里所做的是移除了 $postLink,实际上也移除了 $onDestroy,因为我们现在不需要。我们不再设置监视器,而是知道 $doCheck 在每次摘要循环运行时都会被调用。
这意味着我们可以检查控制器中 items 数组的长度。因为我们的绑定监视器只关注 items 引用本身,而不关注实际数组的元素。我们可以查看数组的长度是否与总项目数相同。如果不同,我们需要记住数组中当前包含的总项目数。既然它改变了(显然,因为不相同),我们将继续调用 this.isCookiesInList() 方法。
如果 isCookiesInList() 返回 true,我们将调用相同的 jQuery 方法来向下滑动显示错误信息。如果总项目数等于数组的长度(意味着列表中没有变化),我们将确保错误信息向上滑动并消失。
为了清晰起见,让我们在这里添加几个控制台日志语句。第一个日志将在 if 语句内部,这意味着总项目数与数组长度不相等,表示我们购物清单中的项目数发生了变化(增加或减少)。让我们记录:“Number of items changed, checking for cookies.”

另一个语句是,如果确实在列表中找到饼干,除了显示错误信息,我们还将记录:“Oh no! Cookies!” 如果没有饼干,我们将在这里记录:“No cookies here. Move right along.”
现在,我们在这里有了三个控制台日志语句。还有一件事,我们不再使用 $scope,这很好,因为我们真的希望摆脱使用 $scope。根据组件架构(这也是 Angular 2 的核心),组件是 Angular 2 愿景的一部分,$scope 不是我们想要依赖的东西,所以我们将从这里移除它。

通过使用 $doCheck 方法,我们能够消除对 $scope 的注入。这并非世界末日,我们当然不是在用 Angular 1 做 Angular 2 的事情,但如果你注入 $scope 并开始向其附加属性(这是一种不好的做法),这可能会为错误打开窗口。如果它甚至不存在,那么你就会远离它。
让我们回到浏览器。我已经让浏览器链接正常工作了。在这里,你会看到它首先打印出我们的更改。但如果我们放入“Chips”(比如三袋薯片)并添加它,它会说:“Number of items changed, checking for cookies.” 然后说:“No cookies here. Move right along.” 所以没问题。

如果我们在这里放入“Cookies”(保持三袋并放入饼干),它会说:“Oh no! Cookies!” 我们需要稍微向下滚动一点才能看到警告在那里。如果我移除饼干(让我清除它),如果我再次移除饼干,警告就会消失:“Number of items changed, no cookies here move right along.” 饼干不再是我们购物清单的一部分。
总结
这只是挂钩到组件周围发生的某些生命周期事件的另一种方式,其中之一就是 $doCheck,我们在这里使用了它。它在每次摘要循环时触发,让你有机会更深入地检查绑定的项目或对象,从而可以对事物进行更深入的检查,而无需设置自己的监视器(这可能会在性能上稍微减慢速度)。但无论如何,这无疑是更好的实践,因为我们可以避免注入容易出错的 $scope(因为一旦它可用并被注入,其他开发人员可能会开始向 $scope 附加属性)。
AngularJS 中的组件鼓励基于组件的架构,但它们并不 100% 强制执行。因此,我们必须遵循约定,以使我们的架构保持基于组件的架构。基于组件架构的一个主要点是,组件永远不应修改不属于它们的数据或模型。这就是为什么它总是具有隔离作用域和明确定义的 API,为我们提供组件的输入和允许组件将数据返回给外部世界的输出。


注册组件的方式与注册控制器、服务等非常相似,你提供名称,但区别在于 component 函数的第二个参数不是一个工厂函数,而只是一个配置对象。通常,我们只提供一个包含 controller、template、bindings 等属性的对象字面量。其中一个名为 controller 的属性用于指定你希望用作组件控制器的控制器。只有在你确实要添加额外功能时才提供它,否则 Angular 已经为我们提供了一个空函数,用作一种空控制器。
071:AngularJS事件系统 🎯




在本节课中,我们将要学习AngularJS中的事件系统。这是一种优雅的组件间通信方式,可以解决在复杂应用结构中数据传递的难题。我们将了解发布-订阅模式在Angular中的实现,以及如何使用$emit和$broadcast方法来发送事件。
组件通信的挑战
我们已经见过几种应用程序内部组件之间通信的方式。
例如,通过原型继承,子组件可以与父组件通信。
请注意,这里我宽泛地使用“组件”一词,这显然不仅适用于组件,也适用于指令、控制器等。
另一种组件通信方式是通过共享的单例服务。
使用.service方法创建的共享服务是单例的,因此不同的组件可以使用该服务的单一实例在彼此之间共享数据。
然而,在某些情况下,这种通信方式不够优雅,并且会在应用程序的不同部分之间创建过多的依赖关系。
让我们看一些图示。
请看这张图,组件2希望向其父组件1传递一些数据。
我们有两种方法可以实现这一点。首先,我们可以通过简单地向上遍历作用域链并访问父控制器来从组件2访问组件1。Angular通过$scope对象上的$parent属性使这变得容易。但这显然不是终点,因为我们仍然需要确保在组件1中设置机制来响应新数据,或者从组件2调用某些东西。换句话说,我们仍然需要确保组件1知道我们的通信。我们也可以使用回调方法绑定并调用父组件的方法,向其传递一些数据。这就是与父组件的通信。
在一个翻转的场景中,我们希望与子组件通信。一种快速的方法是让组件2提供某种方式让我们将数据发送给它。同样,组件2需要某种方式来响应新数据。
如果你想让一个组件与其祖父组件通信,事情会变得更有趣一些。一个优雅的解决方案不那么明显。
你应该使用两次$parent属性来向上遍历作用域链到达目的地吗?我不确定,看看这个操作所创建的依赖关系。
另一个可能更优雅的解决方案是使用单例服务并将其注入到两个组件中。
你仍然需要在组件1中为存储在共享服务中的值设置一些监视,以便你可以响应共享服务值中的任何更改。
当目标反转时,我们面临类似的困境。我们应该将数据发送到组件2,然后让组件2将数据发送到组件1吗?或者我们应该再次使用我们的共享服务?不确定,但这感觉不再那么优雅了。
那么,同时与多个组件通信呢?在这里,我们想要通信的组件似乎分散在各处。例如,组件4既不是我们发送消息的组件2的后代,也不是其祖先。
是的,我们仍然可以使用共享的单例服务方法,但必须在各处使用它,事情变得有点混乱。
一个类似的场景是,多个组件希望与一个中心组件通信,但这些组件不知道该组件在结构中的位置。同样,我们可以使用共享服务方法,但这将是一种混乱的方式来响应我们需要响应的某些数据更改或UI更改。
幸运的是,实际上有一个软件设计模式可以优雅地解决所有这些问题,Angular通过其事件系统实现了这一模式。
发布-订阅模式
该设计模式称为发布-订阅设计模式,基本上该设计模式有两个组成部分:发布者和订阅者。
发布者通过某个公共通道向订阅者发送消息。当我们谈论发布者时,发布者用某种分类标记消息。
发布者也不知道其消息的订阅者,也不知道是否存在任何订阅者。
另一方面,订阅者注册监听具有特定分类的消息。他们也不知道发布者,或者是否存在这些特定消息的任何发布者。
在Angular中,这些消息进行通信的公共通道是作用域。在这个设计模式中,我们谈论的消息被称为可以保存数据的事件。
Angular中的事件处理
Angular事件处理中有两种类型的发布者。
第一种称为$emit。$emit沿着作用域链向上传播。
因此,如果组件3想要发出一个名为greet、数据为{message: ‘hi’}的事件,该事件将一直向上传播,直到到达应用程序中最顶层的作用域。
在Angular中发布消息的另一种方式是广播它们,这是通过$broadcast方法完成的。使用该方法,事件沿着作用域链向下传播。
例如,如果最顶层的作用域广播一个名为greet、数据为{message: ‘hi’}的事件,该事件将一直向下传播到作用域链中的最后一个作用域。
在Angular中订阅事件的方式是调用作用域服务上的一个特殊方法,称为$on。你需要提供你订阅的事件名称和一些处理函数,该函数定义在捕获到该事件后要做什么。
你可能会有以下疑问:当你的广播目标不在你广播调用向下传播的直接路径中时会发生什么?或者反过来,如果你正在发出一个事件,而目标不在你事件向上传播作用域链的直接作用域链中?
答案是,你可以从应用程序中所有内容的父作用域(也称为根作用域)广播你的事件。你可能直到现在才意识到,但我们在HTML页面中某个顶级元素(如<html>)上声明的ng-app,实际上是其下所有控制器、指令和组件的父控制器。
Angular有一个特殊的服务叫做$rootScope,你可以将其注入到应用程序中的任何地方,以引用作用域链中可能最高的作用域,即根作用域。
因此,如果你的组件3试图联系组件4,而组件4不在向上作用域树的直接作用域链中,你可以在组件3内部使用$rootScope.$broadcast。这样做将到达你作用域链的最顶端,并向下传播,到达组件4。
请注意,当你从根作用域广播时,应用程序中的每个节点都会收到该事件,并有机会响应它。
语法详解
让我们看一下具体的语法。
调用$emit和$broadcast可以从控制器、服务、组件或任何地方完成。不要忘记美元符号是方法名的一部分。
正如我们所说,$emit沿着作用域链向上发送事件。
另一方面,$broadcast沿着作用域链向下发送事件。请注意,最佳实践是对事件名称字符串进行命名空间划分。这有助于在以后理解事件针对系统的哪一部分时提高代码的可读性。
在这两种情况下,你都可以随事件发送一个包含一些数据的对象,这些数据可以在事件到达目的地时被解包。
最后一步是为你的事件注册一个监听器。这是通过在某个$scope或$rootScope(实际上就是应用程序中的顶级作用域)上执行$on函数来完成的。显然,我们需要使用与广播或发出事件时相同的事件名称字符串。
处理函数会自动接收两个参数:event(包含事件信息,包括事件名称字符串)和最重要的data对象。这个data对象包含了我们在发出或广播事件时与事件一起包装的数据。
事件发射语法示例:
// 在某个组件/控制器中
$scope.$emit('namespace:eventName', { key: 'value' });
事件广播语法示例:
// 在某个组件/控制器中,或从$rootScope
$scope.$broadcast('namespace:eventName', { key: 'value' });
// 或从根作用域广播到所有组件
$rootScope.$broadcast('namespace:globalEvent', { key: 'value' });
事件监听语法示例:
// 在需要响应该事件的组件/控制器中
$scope.$on('namespace:eventName', function(event, data) {
// 处理接收到的数据
console.log(data.key); // 输出: 'value'
});
总结
本节课中我们一起学习了AngularJS的事件系统。我们探讨了传统组件通信方式(如作用域继承和共享服务)的局限性,并引入了发布-订阅模式作为更优雅的解决方案。
我们详细介绍了Angular中实现该模式的两个核心方法:$emit(向上传播事件)和$broadcast(向下传播事件),以及如何使用$on方法来监听事件。我们还了解了$rootScope的作用,它作为事件的全局通道,可以实现任意组件间的通信。
通过掌握事件系统,你可以构建出耦合度更低、更易于维护的AngularJS应用程序。




072:AngularJS 事件系统


概述
在本节课中,我们将学习 AngularJS 的事件系统。我们将通过一个购物清单应用的例子,了解如何在应用的不同部分之间进行异步通信,并使用事件来显示和隐藏一个加载指示器(旋转图标)。你将学习到如何发布事件、监听事件,以及如何正确地管理事件监听器以避免内存泄漏。
代码结构与目标
我们回到代码编辑器,位于第 34 讲的示例文件夹中。这是一个名为 shopping list events app 的购物清单应用。
在许多应用中,当发生异步通信时,由于通信通常需要一些时间,我们需要向用户显示某种指示器(如旋转图标),告知用户正在处理中,请等待。
我们的目标是:当向购物清单中添加一个物品时,我们将触发一系列异步操作。每个操作都会检查清单中的某个物品是否包含单词“cookie”。由于清单中可能有多个物品,因此会触发多个异步操作。在这些异步操作进行期间,我们希望向用户显示一个指示器。我们将显示一个加载旋转图标(这里使用 Flickr 风格的按钮图标)。
由于不确定这个行为应该放在哪里,我们将把加载旋转图标组件放在控制器之外。它不在购物清单控制器内部(你可以看到 div 在这里结束),而是完全位于其外部,但它仍然位于 ng-app 指令内部,这意味着它在 Angular 应用范围内。
加载旋转图标组件
让我们查看 app.js 中的代码。我们有一个名为 loadingSpinner 的组件。该组件有一个 spinner.html 模板 URL 和一个 SpinnerController。
spinner.html 模板非常简单,只是一个 img 标签,其中包含一个 ng-if 指令。这意味着只有当 $ctrl.showSpinner 为 true 时,这个 img 标签才会成为 DOM 的一部分并显示;否则,整个 img 标签将从 DOM 中移除。
回到 app.js,我们定义了 SpinnerController,并注入了 $rootScope(稍后会解释原因)。现在,让我们看看代码中一个非常重要的部分,它位于购物清单组件的控制器中。
购物清单组件控制器
购物清单组件控制器注入了 $q 服务(关于 Promise 的部分提到过)和 WeightLossFilterService。这是一个异步服务,用于检查购物清单中的物品名称,确保没有包含“cookie”。
我们将在 $doCheck 函数中调用该服务。$doCheck 在每次摘要循环时被调用,因此我们可以检查物品数组中的项目数量是否发生变化。如果发生变化,就意味着是时候检查购物清单中是否包含“cookie”了。
然而,在调用 WeightLossFilterService 进行异步检查之前,我们将广播一个事件。注意,我们使用 $rootScope 来广播事件。原因是需要捕获此事件的组件是 loadingSpinner,而它完全不在我们当前作用域链的路径上。因此,我们使用 $rootScope 从最顶层的 ng-app 开始广播事件。
我们广播的事件名为 shoppingList.processing,传递的数据对象中有一个属性 on,其值为 true。这个事件应该在应用的某个地方被触发,我们不知道具体在哪里,也不关心。它应该向用户显示某种旋转图标,表明“正在处理”。在我们的例子中,我们只需要显示这个旋转图标,让用户知道有事情正在发生。
我们使用 $q.all 方法。我们将遍历 items 数组中的每个物品,对每个物品调用 WeightLossFilterService 的 checkName 方法(该方法返回一个 Promise),并将每个 Promise 推入 promises 数组。$q.all 方法接收一个 Promise 数组,然后可以同时处理它们。这正是我们想要的:我们希望它们是异步的,并且全部并行执行。
这样做的原因是,即使其中一个 Promise 失败(意味着检测到“cookie”),我们也会进入 catch 块,其余的 Promise 会自动被取消。一旦检测到“cookie”,其余的检查将被取消。
接下来,我们会执行之前见过的 jQuery stop 和 slideDown 操作,找到错误消息元素并将其滑下显示。如果所有 Promise 都返回成功结果(意味着购物清单中没有物品包含“cookie”),那么我们将简单地移除错误消息(如果存在的话)。
无论结果如何(成功或失败),在 finally 块中,我们将使用 $rootScope 广播另一个消息。事件名同样是 shoppingList.processing,但这次 on 属性的值为 false。这意味着我们已经完成了所有异步通信,没有其他处理正在进行,因此应该关闭旋转图标。
这样,我们就设置了在应该打开旋转图标时广播“开启”事件,并在所有异步处理结束时广播“关闭”事件。

实现事件监听器
现在,让我们转到实际的 SpinnerController 并实现事件监听器来捕获这些事件。
首先,我们使用注入到控制器中的 $rootScope,调用 $on 方法。$on 方法将监听名为 shoppingList.processing 的事件。我们需要一个处理函数,它会接收 event 和 data 参数。

我们可以先记录一下事件和数据,以便观察。现在,让我们打开浏览器,启动 BrowserSync,并打开控制台。
当我们添加一个物品(例如“chips, two bags of chips”)并点击“Add”时,可以看到几件事情发生:第一个事件被触发,它是一个对象,事件名是 shoppingList.processing,广播的数据是 {on: true}。随后,另一个事件被广播,同样是 shoppingList.processing,但数据是 {on: false}。

第一个事件在开始处理时广播,第二个事件在处理完成时广播。这两个事件都在我们的 SpinnerController 中被捕获并打印出来。

控制旋转图标的显示
下一步很简单:我们只需要检查 data.on 的值。如果为 true,意味着我们应该打开旋转图标,只需设置 $ctrl.showSpinner = true。否则,设置 $ctrl.showSpinner = false。
保存更改,回到浏览器尝试一下。输入“chips, three bags of chips”,点击“Add”,可以看到旋转图标出现并显示“正在处理”。当第二个事件到来时,图标消失。
如果我们输入“cookies, three bags of cookies”并点击“Add”,旋转图标会再次出现,处理两个物品,然后返回“cookies detected”的错误。当我们移除该物品时,整个广播过程会再次触发,检查购物清单中没有“cookie”后,旋转图标停止,警告信息也消失。
防止内存泄漏
还有一件事需要注意。仔细观察,这个 SpinnerController 和 loadingSpinner 组件在当前视图中工作得很好。但是,如果我切换视图再返回,每次返回时,$rootScope.$on 都会注册这个事件监听器,并且没有代码来销毁它。
如果我们在控制器中使用 $scope(而不是 $rootScope),情况会不同。因为每次视图被销毁时,其关联的 $scope 也会被销毁,附加在其上的任何东西也会被清理。然而,$rootScope 在整个应用销毁之前永远不会被销毁。这里存在潜在的内存泄漏风险,因为每次进入这个视图,$on 都会执行,但没有任何东西注销它。
幸运的是,在组件控制器中有一个方便的方法叫 $onDestroy。当控制器的 $scope 被销毁时,我们希望触发这个监听器的销毁。我们可以通过保存 $on 方法返回的注销函数来实现。

$on 方法实际上返回一个注销函数。我们只需将其赋值给一个变量(例如 cancelListener),然后在 $onDestroy 方法中调用这个函数。这样,每次视图被销毁,该控制器所属的 $scope 被销毁时,我们也会同时注销在 $rootScope 上注册的监听器,防止它一直驻留在内存中。
让我们检查一下是否一切正常。添加“chips, four bags of chips”,旋转图标正常工作并关闭。再添加“cookies, five bags of cookies”,旋转图标出现,最后提示检测到“cookie”。移除后,旋转图标再次启动检查,确认没有“cookie”后停止,警告也消失。
总结

本节课中,我们一起学习了 AngularJS 的事件系统。
在 Angular 中,发布-订阅设计模式是通过 Angular 事件系统实现的。你可以在系统中的任何地方发布事件,同样,也可以在系统中的任何地方监听并捕获这些事件。
发布事件有两种主要方式:
- 使用
$scope.$emit方法,它沿着作用域链向上发送事件。 - 使用
$scope.$broadcast方法,它沿着作用域链向下发送事件。
显然,你的消息或事件能否到达特定节点,取决于该节点在作用域链结构中的位置,以及它是否在 $broadcast 或 $emit 方法的传播路径上。如果不在路径上,或者为了真正到达所有节点,你可以使用 $rootScope.$broadcast。这会从声明 ng-app 的最顶层(即我们整个应用的根控制器)开始广播消息,因此应用中的所有东西都继承自它的作用域,所有东西都在其作用域链的路径上。
监听事件可以使用 $scope.$on 或 $rootScope.$on。无论哪种情况,你都需要传入要监听的事件名称,并注册一个在该事件触发时应执行的函数。
需要注意的是,如果你使用 $scope.$on 监听事件,当特定视图被销毁时,其关联的 $scope 也会被销毁,监听器会自动清理,通常无需额外操作。但使用 $rootScope.$on 则不同,因为 $rootScope 在整个应用生命周期中都存在。因此,你必须在使用 $rootScope.$on 时手动注销监听器。

注销监听器的方法是:捕获 $on 方法的返回值(它恰好就是注销函数),然后将其挂钩到组件的 $onDestroy 方法中。在这个注销函数内部,如果你跳过这一步,监听器函数将在内存中保持注册状态,基本上持续整个应用的生命周期。更糟糕的是,每次返回到这个视图,新的监听器函数都会被注册,越来越多的函数会驻留在内存中,无法访问也无法从内存中卸载。通过注销对事件的监听,我们可以避免这种内存泄漏问题。
073:模块 🧩


在本节课中,我们将要学习 AngularJS 中模块的概念。我们将了解什么是模块、为什么使用模块、如何声明和创建模块,以及如何将应用程序拆分为多个模块和文件。通过模块化,我们可以更好地组织代码,提高项目的可维护性和可复用性。
概述
无论你是否意识到,实际上我们从课程一开始就在使用模块。然而,我们并未深入讨论它们,因此也尚未充分利用其能力。使用模块最大的优势在于,它允许我们将应用程序模块化。换句话说,模块让我们能够将应用拆分成更小的部分,然后再将它们组合起来。这是一个巨大的优势,因为它允许我们独立地处理应用程序的不同部分。
此外,它促使我们将软件项目视为一个整体的各个小部分来思考。编写小而简单的代码总是比一次性处理整个开发工作要容易得多。
使用模块,我们还可以封装我们的控制器、组件、指令等,并将其提供给其他开发者在他们的应用中使用。
模块的声明与语法
与拥有一个主方法作为一切起点的常规应用程序不同,Angular 中没有主方法。相反,我们使用模块 API 来声明我们应用程序的构件(如组件、控制器等)。让我们来看看声明模块的语法。
第一步:声明或创建模块
你通过调用 angular 对象本身的 module 方法来声明一个模块。你赋予模块的字符串名称在整个应用程序中应该是唯一的。另一个重要的注意事项是,如果你在 module 方法中指定了第二个参数,你实际上是在创建模块,而不仅仅是检索一个已存在的模块。
以下是创建模块的代码示例:
// 创建不依赖其他模块的模块
var module1 = angular.module('module1', []);
var module2 = angular.module('module2', []);
// 创建依赖其他模块的模块
var module3 = angular.module('module3', ['module1', 'module2']);
请注意,你可以创建一个不依赖任何其他模块的模块(如 module1 和 module2 的情况),也可以声明一个模块依赖于一个或多个其他模块(如 module3 依赖于 module1 和 module2)。
第二步:声明模块构件
这一步我们在几乎每一讲中都做过,所以你应该非常熟悉。然而,请注意创建模块和检索模块实例以附加控制器或其他构件之间的关键区别:检索时省略了 module 方法的第二个参数。
以下是检索模块并声明控制器的代码示例:
// 检索已创建的 module1,并为其附加一个控制器
angular.module('module1')
.controller('MyController', function() {
// 控制器逻辑
});
这假设我们之前已经创建了 module1。如果没有,检索 module1 的调用将导致错误。
第三步:在 HTML 中关联主模块
最后一步是使用 ng-app 指令将主模块的名称关联到我们的 HTML 中。
<html ng-app="module3">
以上就是模块的基本用法。
文件组织与最佳实践
就像在实际开发中处理包含相关组件的独立模块更容易一样,将所有 JavaScript 代码放在一个文件中也不是最佳实践。最佳实践是将一个构件(如一个控制器)放在一个单独的 JavaScript 文件中。
有时,当代码量很小,并且将几个构件放在同一个文件中更容易理解时,可以打破这个最佳实践规则。通常,我们会在自己的 JavaScript 文件中声明或创建一个模块。
以下是文件组织的示例:
- module1.js:在此文件中创建和声明
module1。这通过使用带有第二个参数(空数组或模块依赖数组)的angular.module方法来完成。 - controller.js:在此文件中,通过使用不带第二个参数的相同
angular.module方法来检索你创建的module1,并调用其上的controller方法来声明一个控制器。
如果你不习惯看到 JavaScript 代码这样拆分,只需想想它在浏览器中是如何工作的,你就会更适应。因为浏览器按顺序处理 HTML,这意味着从概念上讲,浏览器会将你所有的 JS 文件合并成一个 JS 文件。该文件的内容将是你 HTML 中声明的文件内容一个接一个地组合。
在这种情况下,你可以想象创建一个新的 JavaScript 文件,然后将 module1.js 的内容粘贴进去,接着将 controller.js 的内容粘贴到该文件之后,依此类推。
请注意,在 HTML 或 JavaScript 文件中声明模块的顺序并不重要。如果你将整个设置视为由较小文件组成的一个巨大 JavaScript 文件,Angular 在读取所有模块声明后,会找出哪个模块依赖于哪个其他模块,并以正确的顺序实例化它们。
然而,你绝对不能做的是在创建模块之前为该模块声明构件。这样做的原因是,按那种顺序粘贴这两个文件的内容大致会产生如下所示的代码,这显然会导致错误,因为我们在第一行代码中尝试检索 module2 的实例并声明组件时,模块本身甚至还没有在第二行代码中被创建。
// 错误示例:先声明构件,后创建模块
angular.module('module2').component('myComponent', {...}); // 错误:module2 尚未创建
angular.module('module2', []); // 创建 module2 的代码在后面
模块的特殊方法
模块有几个特殊的方法,我们已经见过其中一个:config 方法。config 方法在模块的任何其他方法之前运行。传入 config 方法的函数值可以注入 provider 或 constant。由于 provider 用于配置服务,因此不能将服务注入 config 方法的限制是有道理的,因为我们不希望在我们有机会配置服务如何实例化之前就实例化它。
另一个方法是 run 方法,它在模块上执行。run 方法在 config 方法之后立即执行。你只能将实例和常量注入 run 方法,而不能注入 provider,因为我们希望防止系统在运行时被重新配置。
对于一个依赖于其他模块的模块,所有依赖项的 config 方法会首先执行。然后,这些模块的 run 方法会以相同的模式重复执行:先执行依赖模块的 run 方法,再执行本模块的 run 方法。
总结


本节课中,我们一起学习了 AngularJS 模块的核心概念。我们了解了模块如何帮助我们将应用拆分为更小、更易管理的部分,并支持独立开发和代码复用。我们掌握了使用 angular.module(‘name‘, [dependencies]) 创建模块,以及使用 angular.module(‘name‘) 检索模块并声明控制器等构件的方法。我们还探讨了将代码拆分到多个文件的最佳实践,以及模块生命周期中的 config 和 run 方法。最后,我们理解了模块依赖的加载顺序规则。掌握模块是构建结构清晰、可维护的 AngularJS 应用的基础。
074:模块(第2部分)


概述
在本节课中,我们将学习 AngularJS 模块的组织方式、模块间的依赖关系,以及 config 和 run 方法的执行顺序。我们将通过一个重构后的购物清单应用示例来演示这些概念。
项目结构与模块化组织
我们回到了代码编辑器,位于 lecture35 文件夹中。这是我们的购物清单应用,但这次我们对其进行了重构,使其结构更加清晰。
index.html 文件与之前一样简单。ng-app 指令指向名为 shoppingList 的模块。这意味着我们有一个名为 shoppingList 的模块。
<!DOCTYPE html>
<html ng-app="shoppingList">
<!-- ... 其他内容 ... -->
</html>
在文件末尾,我们有一个加载指示器(spinner),这是我们本节课将重点关注的部分。
我们通过单独的 <script> 标签引入项目文件。我们将 jQuery 和 AngularJS 库文件放在 lib 文件夹中,并首先加载它们。然后,我们加载各个模块。
<script src="lib/jquery.min.js"></script>
<script src="lib/angular.min.js"></script>
<script src="src/spinner/spinner.module.js"></script>
<script src="src/shoppingList/shoppingList.module.js"></script>
<!-- ... 加载其他组件和服务的脚本 ... -->
查看 src 目录,可以看到我们有一个 shoppingList 文件夹和一个 spinner 文件夹。shoppingList 文件夹包含了购物清单应用的所有组件、控制器、工厂和服务。这是一种最佳实践,即按功能模块组织文件。
模块的声明与获取
上一节我们看到了项目结构,本节中我们来看看模块是如何定义和使用的。
shoppingList.module.js 文件负责创建和声明我们的模块。
// shoppingList.module.js
angular.module('shoppingList', []);
这段代码创建了一个名为 'shoppingList' 的模块,并暂时没有指定任何依赖项。
在其他文件中,我们通过只传递一个参数(模块名)来获取这个已创建的模块,而不是重新声明它。
// shoppingList.component.js
angular.module('shoppingList')
.component('shoppingList', {
templateUrl: 'src/shoppingList/shoppingList.template.html',
controller: ShoppingListComponentController
});
请注意,这里没有第二个参数(依赖数组)。如果提供了第二个参数,将会重新声明(即覆盖)该模块,导致之前添加到该模块的所有内容丢失。因此,我们在这里是获取模块,然后在其上调用 .component 方法来添加组件。
spinner 模块也是以同样的方式组织的。它有自己的模块文件、组件文件以及 HTML 模板。
模块间的依赖关系
现在,我们的购物清单应用还没有显示加载指示器。这是因为 shoppingList 模块目前并不依赖于 spinner 模块。


为了让 AngularJS 能够识别并使用 <loading-spinner> 这个自定义元素,我们需要在 shoppingList 模块的依赖列表中声明 spinner 模块。
修改 shoppingList.module.js 文件:
// 修改前
angular.module('shhoppingList', []);
// 修改后
angular.module('shoppingList', ['spinner']);

一旦建立了依赖关系,AngularJS 就会知道如何处理 <loading-spinner> 元素,因为它在 spinner 模块中被定义为一个组件。现在,加载指示器就能正常工作了。
config 与 run 方法块
接下来,我们探讨模块生命周期的两个重要方法:config 和 run。
我们可以在模块上定义 config 和 run 块。config 块在模块启动时、任何服务被实例化之前执行,通常用于提供者(Provider)的配置。run 块在模块启动后、所有服务实例化之后执行,通常用于初始化代码。
以下是向 spinner 和 shoppingList 模块添加 config 和 run 块的示例:

// spinner.module.js
angular.module('spinner', [])
.config(function() {
console.log('Spinner config fired.');
})
.run(function() {
console.log('Spinner run fired.');
});
// shoppingList.module.js
angular.module('shoppingList', ['spinner'])
.config(function() {
console.log('Shopping List config fired.');
})
.run(function() {
console.log('Shopping List run fired.');
});
当应用加载时,控制台会按特定顺序打印这些信息。依赖模块的 config 和 run 块会先于依赖它们的模块执行。无论模块在 HTML 中被引用的顺序如何,这个顺序都不会改变。
模块加载顺序的重要性

最后,我们来理解模块声明的顺序规则。
模块的创建(即调用 angular.module(‘name‘, []))必须在该模块的任何使用(如添加组件、控制器)之前。换句话说,模块必须先存在,才能向它添加东西。
例如,以下顺序是错误的,会导致 “module spinner is not available” 错误:
<!-- 错误顺序:先尝试使用模块,后创建模块 -->
<script src="src/spinner/spinner.component.js"></script> <!-- 这里尝试获取‘spinner‘模块 -->
<script src="src/spinner/spinner.module.js"></script> <!-- 这里才创建‘spinner‘模块 -->
正确的顺序应该是先创建模块,再使用模块:
<!-- 正确顺序:先创建模块,后使用模块 -->
<script src="src/spinner/spinner.module.js"></script> <!-- 创建‘spinner‘模块 -->
<script src="src/spinner/spinner.component.js"></script> <!-- 在已存在的模块上添加组件 -->
只要遵循“先声明,后使用”的原则,各个模块在 HTML 中被列出的先后顺序本身并不重要。
总结
本节课中我们一起学习了:
- 模块化组织:将应用按功能拆分为不同模块,使代码结构更清晰。
- 模块声明与获取:
- 使用
angular.module(‘name‘, [dependencies])创建模块。 - 使用
angular.module(‘name‘)获取已创建的模块以添加组件、服务等。
- 使用
- 模块依赖:通过在依赖数组中声明其他模块的名称,使一个模块能够使用另一个模块的功能。
config与run块:config块用于模块配置,优先执行。run块用于模块初始化,在config之后执行。- 依赖模块的
config和run块总在父模块之前执行。
- 加载顺序规则:模块的声明文件必须在其使用文件之前加载,以确保模块在使用前已被创建。


掌握这些概念对于构建大型、可维护的 AngularJS 单页应用至关重要。
075:路由 🧭


在本节课中,我们将要学习单页应用(SPA)的核心概念——路由。我们将探讨传统Web应用与单页应用在处理页面视图切换时的不同方式,并重点介绍如何在AngularJS应用中使用UI Router来实现路由功能。
概述:从传统应用到单页应用
在传统Web应用中,每次视图切换都需要向服务器请求完整的HTML页面。这种方式被称为粗粒度更新,因为即使是很小的内容变化,也需要传输整个页面的数据。其流程可以概括为:
传统请求流程:
浏览器请求 `/page1.html` -> 服务器处理并返回 `page1.html` 内容 -> 浏览器渲染
用户点击链接 -> 浏览器请求 `/page2.html` -> 服务器处理并返回 `page2.html` 内容 -> 浏览器重新渲染

随着Ajax技术的引入,情况得到了改善。浏览器可以在不刷新页面的情况下,通过后台请求向服务器获取少量数据,然后动态更新页面内容。这被称为细粒度更新。然而,这种方法存在一个主要缺点:浏览器的后退和前进按钮会失效,因为视图切换的逻辑由JavaScript控制,并未反映在浏览器的历史记录中。
上一节我们介绍了传统Web应用与Ajax应用的导航方式,本节中我们来看看单页应用是如何解决这些问题的。
单页应用(SPA)的路由原理
在单页应用模型中,初始交互与传统方式相同:浏览器请求 index.html,服务器返回包含CSS和JavaScript的页面内容。之后的关键在于,视图切换由客户端(浏览器中的JavaScript)处理,而不是服务器。
其核心机制是利用URL中的哈希片段。当用户点击一个指向新视图的链接时,URL会变为类似 index.html#/view1 的形式。# 符号(哈希)之后的部分不会导致浏览器向服务器发起新请求,但可以被JavaScript检测并处理。
SPA路由示例:
// 哈希 URL 示例
http://example.com/index.html#/products
http://example.com/index.html#/products/3
JavaScript(具体来说是路由库)会监听URL中哈希部分的变化。当哈希变化时,路由库会执行相应的操作,例如:
- 可能发起一个Ajax请求来获取对应视图的HTML模板。
- 将获取到的模板内容插入到页面中指定的容器内。
由于哈希变化会更新浏览器地址栏的URL,因此它会自动被添加到浏览器的历史记录中。这使得浏览器的后退和前进按钮可以正常工作。
此外,通过配置服务器(HTML5模式),现代SPA甚至可以去掉URL中的 # 符号,使用更简洁的URL,但本课程中我们将继续使用哈希模式。
视图状态与UI Router
除了基于URL的路由,还有一种更通用的导航方式:基于状态。在这种方式中,每个视图被表示为一个视图状态,即一个JavaScript对象,它包含了重现该视图所需的所有数据。
以下是两种路由方式的对比:
基于URL的路由:
- 状态通过唯一的URL字符串编码。
- 导航通过改变URL触发。
基于状态的路由(如UI Router):
- 状态通过JavaScript对象编码。
- 导航通过改变程序内部的状态对象触发。
- URL可以不更新,或者作为状态的一个可选属性。
对于AngularJS应用,有两个常用的路由包:ngRoute 和 UI Router。
ngRoute:由Google和社区开发,是AngularJS的一个独立模块。它必须为每个路由配置一个URL,且不支持嵌套视图。适合用于原型或简单项目。UI Router:由社区开发的开源项目。它以UI状态为核心概念,URL路由是其支持的功能之一。它支持嵌套视图和更灵活的状态管理,是构建复杂单页应用的更好选择。
鉴于 UI Router 功能更强大,本课程将重点讲解它。一旦掌握了 UI Router,使用 ngRoute 也会非常容易。
使用UI Router的基本步骤
现在,让我们来看看在AngularJS应用中使用 UI Router 的基本步骤。
步骤1:引入脚本文件
首先,需要在HTML文件中引用 UI Router 的JavaScript库。由于它依赖于AngularJS核心库,所以必须在AngularJS核心库之后引入。
<script src="angular.js"></script>
<script src="angular-ui-router.js"></script>
步骤2:设置视图容器
接下来,在HTML页面中指定一个位置,作为动态加载视图的容器。这是通过 UI Router 提供的自定义指令 ui-view 实现的。
<div ui-view></div>
<!-- 当某个状态被激活时,其对应的模板内容将插入到这个 div 中 -->
步骤3:声明模块依赖
在创建AngularJS应用模块时,需要将 ui.router 声明为依赖项。注意模块名是 ui.router(使用点号),而项目名是 ui-router(使用连字符)。
angular.module('myApp', ['ui.router']);
步骤4:配置状态与路由
UI Router 通过 $stateProvider 和 $urlRouterProvider 这两个服务来管理状态和URL路由。在使用它们之前,需要在 config 函数中进行配置。
以下是配置一个简单状态的方法:
angular.module('myApp').config(function($stateProvider, $urlRouterProvider) {
// 配置状态
$stateProvider
.state('view1', { // 状态名称
url: '/view1', // 关联的URL(可选)
template: '<h1>这是视图1的内容</h1>' // 状态的HTML模板
})
.state('view2', {
url: '/view2',
templateUrl: 'templates/view2.html' // 通过URL指定外部模板文件
});
// 配置默认路由(当没有匹配的URL时)
$urlRouterProvider.otherwise('/view1');
});
.state()方法用于定义应用中的各个状态。每个状态有一个唯一的名字,并可选择性地关联一个URL和一个模板。.otherwise()方法用于定义默认路由。当用户输入的URL无法匹配任何已定义的状态时,应用将重定向到这个默认URL。
总结
本节课中我们一起学习了单页应用路由的核心知识。我们首先回顾了传统多页应用和Ajax应用在导航上的局限性,然后深入探讨了单页应用如何利用哈希URL来实现无刷新视图切换,并保持浏览器历史记录的正常工作。
我们比较了 ngRoute 和 UI Router 两个路由库,并解释了为什么 UI Router 因其以状态为中心的设计和对嵌套视图的支持而成为更强大、更灵活的选择。
最后,我们一步步讲解了在AngularJS项目中集成和使用 UI Router 的四个基本步骤:
- 引入脚本文件。
- 在HTML中使用
ui-view指令设置视图容器。 - 将
ui.router声明为应用模块的依赖。 - 使用
$stateProvider配置应用状态,并使用$urlRouterProvider配置默认路由。


掌握这些基础知识后,你就可以开始构建具有多个视图并能流畅切换的单页Web应用了。在接下来的课程中,我们将通过实际编码来巩固这些概念。
076:路由 🧭


在本节课中,我们将学习如何使用 AngularJS 的 UI-Router 库来配置路由和状态管理,从而构建一个具有多个视图的单页应用。

下载 UI-Router 库
首先,我们需要下载 UI-Router 库文件。正确的稳定版本是 0.3.1,而不是搜索结果中常见的 0.2.14。
以下是下载步骤:
- 在浏览器中搜索
UI-Router CDN。 - 找到一个提供
0.3.1版本文件的 CDN 链接。 - 复制该文件的 URL,在浏览器中打开。
- 右键点击页面,选择“另存为”,将文件保存到项目的
lib目录中。
在我们的示例中,文件已保存在 lecture36/lib 目录下。
配置基础应用
上一节我们准备好了库文件,本节中我们来看看如何配置一个基础的单页应用。

首先,在 HTML 文件中引用 AngularJS 和 UI-Router 库,并设置一个视图占位符。
<!-- 引用必要的库 -->
<script src="lib/angular.min.js"></script>
<script src="lib/angular-ui-router.min.js"></script>
<!-- 视图占位符 -->
<div ui-view></div>

接着,在 JavaScript 文件中定义 AngularJS 模块,并注入 ui.router 依赖。
// app.js
angular.module('routingApp', ['ui.router'])



配置路由规则
现在,我们需要在应用的配置阶段设置路由规则。这需要使用 $stateProvider 和 $urlRouterProvider 服务。


.config(function($stateProvider, $urlRouterProvider) {
// 配置默认路由
$urlRouterProvider.otherwise('/tab1');
})
$urlRouterProvider.otherwise() 方法用于定义当没有匹配到任何已定义状态时的默认跳转路径。


定义应用状态
接下来,我们使用 $stateProvider 来定义应用的不同状态。每个状态可以关联一个 URL 和一个模板。




以下是定义两个标签页状态的示例:
$stateProvider
.state('tab1', {
url: '/tab1',
template: '<div>这是标签1的内容</div>'
})
.state('tab2', {
url: '/tab2',
templateUrl: 'src/tab2.html'
});
state(name, config):定义一个状态。name是状态名,config是配置对象。url:该状态对应的浏览器地址栏 URL。template或templateUrl:该状态激活时要插入到ui-view中的 HTML 内容。template直接写字符串,templateUrl指向一个外部 HTML 文件。

创建状态导航链接
为了在状态间导航,UI-Router 提供了 ui-sref 指令。它可以替代传统的 <a href="..."> 链接。


在 HTML 中,我们可以这样创建导航:
<!-- 使用 ui-sref 指令链接到状态 -->
<a ui-sref="tab1">标签 1</a>
<a ui-sref="tab2">标签 2</a>
ui-sref 的值是目标状态的名称。当状态定义了 URL 时,该指令会自动为元素生成正确的 href 属性。


高级功能:无 URL 状态与样式激活

UI-Router 的强大之处在于,状态可以独立于 URL 存在。
例如,我们可以定义一个没有 url 属性的状态:
.state('tab2', {
// 没有 url 属性
templateUrl: 'src/tab2.html'
})
即使没有 URL,通过 ui-sref="tab2" 点击按钮或链接,应用依然可以切换到该状态,只是浏览器地址栏不会变化。
此外,我们可以使用 ui-sref-active 指令,为当前激活状态对应的导航元素自动添加 CSS 类,实现高亮效果。

<a ui-sref="tab1" ui-sref-active="active-tab">标签 1</a>
<a ui-sref="tab2" ui-sref-active="active-tab">标签 2</a>
当状态 tab1 激活时,第一个 <a> 标签会获得 active-tab 类,方便我们设置样式(如加粗文字)。
核心概念总结

本节课中我们一起学习了 UI-Router 的核心用法:
- 两个子系统:UI-Router 将 URL 映射 和 UI 状态管理 作为两个独立的子系统。
- 配置默认路由:使用
$urlRouterProvider.otherwise(‘/path’)处理未匹配的 URL。 - 定义状态:使用
$stateProvider.state()方法定义应用状态,可配置url、template或templateUrl。 - 视图占位符:在 HTML 中使用
<div ui-view></div>作为状态模板的插入点。 - 状态导航:使用
ui-sref="stateName"指令创建指向特定状态的链接或按钮。 - 激活状态样式:使用
ui-sref-active="className"指令为激活状态的导航元素自动添加样式类。
通过以上步骤,我们就能构建一个通过状态驱动视图切换、功能清晰的单页 Web 应用。
077:带控制器的路由状态

在本节课中,我们将学习如何在 AngularJS 的 UI-Router 中,将控制器直接定义在路由状态上,而不是在 HTML 模板中声明。这种方法可以使代码结构更清晰、更优雅,并提高模板的可重用性。

模板中的控制器声明
与任何模板一样,我们可以通过状态定义中 templateUrl 属性指向的 HTML 文件来提供视图。在这个 HTML 模板中,我们可以声明一个控制器,负责管理该视图的视图模型或行为。
假设我们定义了一个状态,其 templateUrl 指向一个名为 home.html 的 HTML 模板。这个 home.html 模板通常会使用 controller as 语法来声明控制器,例如 ng-controller="HomeCtrl as home"。
以下是一个典型的模板内控制器声明示例:
<div ng-controller="HomeCtrl as home">
<!-- 模板内容 -->
</div>
状态定义中的控制器声明
上一节我们介绍了在模板中声明控制器的方法,但这种方法效率不高,也不够优雅。你可以看到,包装 div 标签的唯一目的就是声明控制器,它似乎没有其他价值。
UI-Router 允许我们将控制器声明从模板中提取出来,直接在状态定义中进行定义。这是一种更优的解决方案。
以下是如何在状态定义中直接声明控制器:
$stateProvider.state('home', {
templateUrl: 'home.html',
controller: 'HomeCtrl',
controllerAs: 'home'
});
现在,我们使用 controller as 语法,将控制器的声明直接作为状态定义的一部分。由于本例中没有为控制器使用函数值,你可以假设我们已经使用 Angular 的 .controller 方法声明了名为 HomeCtrl 的控制器。
如果我们在此处能访问到控制器的函数值,也可以选择指定该函数值,而不是控制器的字符串名称。同时,仍然可以通过状态定义中的 controllerAs 属性来实现 controller as 语法。
代码示例与优势
请注意,我们的 home.html 模板因此变得更简洁。虽然这种情况不常见,但我们甚至有可能在其他地方,为这个 home.html 模板搭配一个不同的控制器来重复使用它。这正是因为我们将控制器声明从实际模板中提取了出来。
所以,这无疑是一个更加优雅的解决方案。让我们通过一个简单的代码示例来看看这是如何工作的。
以下是状态定义的代码:
angular.module('myApp')
.config(function($stateProvider) {
$stateProvider.state('home', {
url: '/',
templateUrl: 'views/home.html',
controller: 'HomeController',
controllerAs: 'vm'
});
})
.controller('HomeController', function() {
var vm = this;
vm.message = '欢迎来到首页!';
});
对应的 home.html 模板现在非常简洁:
<h1>{{vm.message}}</h1>
<p>这是主页内容。</p>

总结

本节课中,我们一起学习了在 AngularJS 的 UI-Router 中管理控制器的两种方式。我们首先回顾了在 HTML 模板内部声明控制器的传统方法,然后重点介绍了将控制器直接定义在路由状态上的更优方案。这种方法通过 controller 和 controllerAs 属性实现,使得模板更简洁、控制器逻辑与视图分离更彻底,从而提升了代码的可维护性和模板的可重用性。
078:带控制器的路由状态


概述
在本节课中,我们将学习如何在 AngularJS 的 UI Router 中,为路由状态(state)直接关联一个控制器。我们将通过一个购物清单应用的例子,演示如何将控制器从模板中移动到状态配置中,并理解其工作原理。
项目结构与初始状态
我们位于课程第37讲的示例文件夹中。这是一个购物清单应用的不同版本,旨在利用我们已学过的 UI Router 功能。
当前 index.html 文件内容非常简单。它包含欢迎信息“Welcome to prem no cookie shopping list”以及一系列脚本引用。这些脚本包括:
- AngularJS
- Angular UI Router
- 主应用模块
shoppinglist.module - 路由配置文件
routes.js - 购物清单组件
shoppinglist.component - 主控制器
main-shoppinglist.controller - 服务
shoppinglist.service
这个应用目前只有一个主屏幕(home screen),其中包含一个指向“预置无饼干购物清单”的链接。
分析路由配置
让我们查看 routes.js 文件。它使用 module.config 来设置应用状态。
首先,它将任何未匹配的 URL 重定向到根路径 /,该路径对应 home 状态。
以下是定义的两个状态:
1. Home 状态
- URL:
/ - 模板:
src/shoppinglist/templates/home.tmpl.html
该模板仅包含一个使用ui-sref指令指向mainList状态的链接。
2. MainList 状态
- 名称:
mainList - URL:
/mainList - 模板:
src/shoppinglist/templates/main-shoppinglist.tmpl.html
初始的 main-shoppinglist.tmpl.html 模板内容如下:
<div ng-controller="MainShoppingListController as mainList">
<!-- 返回首页的链接 -->
<a ui-sref="home">Home</a>
<!-- 使用购物清单组件 -->
<shopping-list items="mainList.items"></shopping-list>
</div>
该模板使用 ng-controller 指令关联了 MainShoppingListController 控制器,并为其指定了别名 mainList。然后,它将控制器中的 mainList.items 数据传递给 shopping-list 组件进行渲染。
理解数据流与异步操作
MainShoppingListController 控制器注入了 ShoppingListService 服务。它调用服务的 getItems() 方法来获取购物清单数据。
关键点在于,getItems() 方法返回一个 Promise 对象,用于模拟从服务器异步获取数据的过程。在服务中,我们故意设置了800毫秒的延迟来模拟网络请求。
// 在 MainShoppingListController 中
ShoppingListService.getItems().then(function (result) {
mainList.items = result;
});
只有当 Promise 被成功解析(resolve)后,返回的数据才会被赋值给 mainList.items。随后,数据通过组件传递并最终渲染到页面上。
在浏览器中测试应用,可以看到从首页点击链接进入 /mainList 页面时,清单数据会稍有延迟才显示出来,这模拟了真实的异步数据加载场景。
重构:将控制器移至状态配置
之前,控制器是在模板中通过 ng-controller 声明的。UI Router 允许我们将控制器定义在状态配置中,使关注点分离更清晰。
以下是重构步骤:
-
修改模板:从
main-shoppinglist.tmpl.html模板中移除ng-controller指令及其外层的<div>标签。模板变得非常简洁,只包含返回链接和组件标签。<!-- 修改后的 main-shoppinglist.tmpl.html --> <a ui-sref="home">Home</a> <shopping-list items="mainList.items"></shopping-list>注意,组件仍然期望
mainList.items数据,但mainList这个控制器实例现在将由状态配置提供。 -
修改状态配置:在
routes.js文件的mainList状态定义中,添加controller和controllerAs属性。.state('mainList', { url: '/mainList', templateUrl: 'src/shoppinglist/templates/main-shoppinglist.tmpl.html', controller: 'MainShoppingListController', // 指定控制器函数 controllerAs: 'mainList' // 为控制器实例指定别名 })
经过以上修改,应用功能保持不变。控制器的职责从模板转移到了路由状态配置中,mainList 这个别名在模板中依然可用。


核心语法总结
本节课我们一起学习了在 UI Router 状态中声明控制器的两种主要方式。
方式一:使用字符串语法
在状态配置中,可以直接使用字符串形式的 controller as 语法。
controller: 'MainShoppingListController as mainList'

方式二:使用属性分离语法
更清晰的方式是分别指定 controller 和 controllerAs 属性。
controller: 'MainShoppingListController', // 控制器函数名
controllerAs: 'mainList' // 模板中使用的实例别名
在模板中,你可以通过 controllerAs 指定的别名(例如 mainList)来访问控制器中的数据和方法,例如 mainList.items。


总结
本节课中,我们一起学习了如何将控制器与 UI Router 的状态进行绑定。关键要点包括:
- 控制器可以从 HTML 模板中移出,直接定义在路由状态配置里。
- 使用
controller和controllerAs属性可以清晰地建立这种关联。 - 在模板中,通过
controllerAs定义的别名来访问控制器作用域。 - 这种方式使得状态的管理更加集中和清晰,符合单页应用的路由设计理念。
079:带resolve的路由状态


概述
在本节课中,我们将学习 AngularJS 路由中一个名为 resolve 的强大属性。通过 resolve,我们可以在路由切换到新状态(state)并加载控制器(controller)之前,预先获取该控制器所需的数据。这能确保控制器在初始化时,其依赖的数据已经准备就绪。
回顾上一节内容
在上一节的例子中,我们配置了一个路由状态,该状态包含一个控制器。在该控制器的 $onInit 函数中,我们调用了异步服务来获取控制器运行所需的数据。
社区对这种执行顺序存在一些争议。有人认为,在切换到下一个视图或状态时,如果能先获取所需数据,再将数据传递给控制器,会是更好的做法。通过状态的 resolve 属性,我们不仅可以实现这一点,还能做得更多。现在,让我们来看看它的语法。

resolve 属性的语法
以下是 resolve 属性的基本语法示例:
.state('view1', {
controller: 'View1Controller as view1',
resolve: {
myData: ['Service', function(Service) {
return Service.getData();
}]
}
})
你可以看到,这里我们声明了一个名为 view1 的状态。我们为该状态声明了一个控制器 View1Controller as view1,但现在我们有了一个新属性 resolve。
resolve 是一个配置对象,包含一些键值对。键 myData 对应的值,我们使用了自课程开始以来未曾详细讲解的语法。这是因为我们之前一直在使用更优雅的依赖注入方式。但在这里,我们使用数组语法来保护函数,防止代码压缩(minification)破坏依赖注入。
如果你还记得最初的课程内容,当你提供一个包含多个字符串的数组,并将最后一个值设为函数时,数组中声明的字符串(按顺序)将被视为你想要注入到该函数中的依赖项。函数的参数将与数组中声明的依赖项一一对应。
因此,这里我们有一个数组,第一个值是字符串 'Service',第二个值(即最后一个值)是一个函数,该函数以 Service 作为参数。这意味着 AngularJS 会将 Service 注入到这个函数中。
请注意,Service.getData() 返回一个 Promise。最终,myData 的值将被注入到 View1Controller 中,其键名就是 myData。
这意味着,就像我们向控制器注入一个名为 myService 的服务一样,我们将能够向 View1Controller 注入另一个名为 myData 的依赖项,该名称与 resolve 对象内的键名完全匹配。
现在,如果 resolve 属性(在本例中是 myData)被赋予一个 Promise(本例就是这种情况),路由器将不会推进到新状态(本例中是 view1),直到该 Promise 被成功解析(resolved)。如果 Promise 被完全拒绝(rejected),路由器则根本不会切换到新状态。
在控制器中使用 resolve 数据
在我们的控制器 View1Controller 中,我们像注入其他任何依赖项一样注入 myData。我们通过将其声明在 $inject 数组中来防止代码压缩问题,然后将其作为 View1Controller 函数的一个参数按相同顺序列出。
View1Controller.$inject = ['myData'];
function View1Controller(myData) {
// 控制器逻辑,此时 myData 已可用
}
resolve 属性返回的不一定是 Promise,它实际上可以是任何我们想要注入到对应控制器中的东西,甚至可以是字符串,如下例所示。
动手实践:重写示例
现在,让我们回到代码编辑器,使用 resolve 属性重写之前的示例。
通过使用 resolve,我们确保了在 View1Controller 初始化之前,其所需的数据 myData 已经成功获取。这消除了控制器内部处理数据加载和等待的逻辑,使控制器代码更简洁、更专注于视图逻辑。
如果数据获取失败(Promise 被拒绝),路由将不会跳转,这提供了内置的错误处理机制,防止用户进入一个数据不完整的视图。
总结
本节课中,我们一起学习了 AngularJS 路由的 resolve 属性。我们了解到:
resolve的作用:在路由状态切换前预先获取数据,并注入到目标控制器。- 基本语法:
resolve是一个对象,其键值对定义了要注入的依赖项及其获取方式。 - 与 Promise 的关系:当
resolve返回 Promise 时,路由会等待其解析后才进行切换。 - 在控制器中的使用:通过依赖注入,像使用服务一样使用
resolve提供的数据。 - 优势:使控制器逻辑更清晰,提供了数据预加载和路由守卫的能力。


通过将数据获取逻辑从控制器移至路由配置,我们的代码结构变得更加清晰和可维护。
080:带resolve的路由状态


在本节课中,我们将学习如何在 AngularJS 的 UI-Router 中使用 resolve 属性。resolve 允许我们在路由状态切换之前预先获取数据,确保控制器在初始化时就已经拥有所需的数据,从而避免视图加载后数据才延迟到达的尴尬情况。
上一节我们介绍了异步服务在控制器中的基本用法,本节中我们来看看如何使用 resolve 来优化数据加载流程。
初始状态分析
我们当前的应用与第37讲结束时完全相同。在 mainList 状态中,我们有一个名为 MainShoppingListController 的控制器。如果现在查看这个控制器,可以看到在 $onInit 生命周期钩子中,我们调用了 ShoppingListService 这个异步服务。当 Promise 被解析后,我们将返回的数组结果赋值给 mainList.items。

在浏览器中测试此行为时,点击链接跳转到 mainList 状态,你会看到视图立即加载,但列表数据大约在800毫秒后才出现。这是因为我们先切换了视图,然后再去获取数据。
这种方式不仅视觉效果不佳,而且在视图加载时我们并不清楚其中是否包含任何数据。

配置 resolve 属性
让我们回到代码编辑器,在路由状态上创建一个 resolve 属性。
我们回到 routes.js 文件。除了声明 MainShoppingListController 作为 mainList 状态的控制器,我们还需要添加一个 resolve 属性。
resolve 属性将包含一个名为 items 的项。items 属性的值是一个函数,该函数返回由 ShoppingListService.getItems() 方法提供的 Promise。
以下是配置 resolve 的步骤:
- 首先,我们需要注入
ShoppingListService以保护其不被篡改。 - 然后,定义一个函数,在该函数中调用服务并返回 Promise。
配置完成后,UI-Router 会等待 items 属性对应的 Promise 被解析,然后才会将我们切换到 mainList UI 状态。

修改控制器
现在数据已经在状态转换时预先获取,我们需要修改控制器。
我们不再需要在控制器的 $onInit 中调用服务。相反,我们需要注入由 resolve 解析得到的 items 数据。
修改后的控制器只需将传入的 items 直接赋值给 mainList.items 即可。
测试与验证
保存所有更改后,返回浏览器进行测试。
现在,当你点击链接时,页面不会立即跳转。UI-Router 会等待数据获取完成(大约800毫秒),然后整个视图和数据会同时立即显示出来。这意味着异步通信发生在你点击链接的瞬间,而不是点击之后。当控制器被调用时,它已经拥有了全部数据,因此可以立即渲染。
我们还顺便修正了一个变量名大小写不一致的问题,以确保代码风格统一。
resolve 属性总结
本节课中我们一起学习了 resolve 属性的核心用法,现在让我们来总结一下要点:

resolve属性用于将值直接注入到负责该状态的控制器中,它在状态配置中声明。- 如果
resolve属性是一个 Promise,路由器会等待该 Promise 解析成功后才切换到新状态。 - 如果 Promise 被拒绝,路由器将完全不会切换到新状态。
resolve属性对象中的键名,就是将要注入到对应控制器函数中的参数名。- 虽然
resolve常用于在状态切换前解析 Promise,但它实际上可以包含任何类型的值,例如对象、字符串等。

通过使用 resolve,我们确保了视图和数据同步加载,提供了更流畅的用户体验。
081:带URL参数的路由状态


在本节课中,我们将学习如何在 AngularJS 的 UI-Router 中定义和使用带参数的路由状态。URL 参数允许我们根据动态数据(如用户ID或邮政编码)来构建和显示不同的视图内容。
概述
URL 通常不仅编码视图的名称,还可能包含决定视图如何构建的实际数据。例如,一个天气预报应用需要根据邮政编码来显示不同的天气信息。UI-Router 使得映射带参数的 URL 并从中提取这些参数变得非常简单直接。
URL参数语法
上一节我们介绍了基本的路由状态定义,本节中我们来看看如何为状态定义带参数的URL。
首先,我们在状态的 url 属性中声明参数。参数名被包裹在花括号 {} 中。
$stateProvider
.state('view1', {
url: '/view1/{parameter1}',
templateUrl: 'view1.html',
controller: 'View1Controller'
});
接下来,我们需要在控制器中获取这个参数值。UI-Router 提供了一个特殊的 $stateParams 服务。
app.controller('View1Controller', ['$scope', '$stateParams', function($scope, $stateParams) {
// 使用点符号和参数名来获取值
$scope.myParameter = $stateParams.parameter1;
}]);

URL映射规则
关于URL映射,规则相当直接。
- 尾部斜杠没有特殊处理。例如,模式
/view1/会匹配/view1/(此时parameter1为空字符串)。 - 该模式也会匹配
/view1/hello或/view1/123。
此外,UI-Router 还支持更具体的参数匹配语法,甚至可以使用正则表达式来限定参数的格式。
在链接中使用参数
如果你想使用 ui-sref 指令来创建一个链接,以跳转到需要参数的状态,可以像调用函数一样使用状态名。
ui-sref 的值是一个函数调用,其参数是一个对象,该对象包含与预期参数名完全匹配的键值对。
<!-- 假设当前状态需要 itemId 参数 -->
<a ui-sref="itemDetail({itemId: 123})">查看项目 123 的详情</a>
回到代码示例
现在,让我们回到代码编辑器的上下文,看看如何利用URL参数来显示购物清单中每个项目的详细信息。
我们将创建一个新的状态来显示项目详情,其URL包含项目ID作为参数。当用户点击列表中的某个项目时,应用将导航到这个详情页,并通过 $stateParams 获取对应的ID来加载和显示该项目的具体信息。
总结


本节课中我们一起学习了在 AngularJS 的 UI-Router 中使用URL参数。
- 我们学会了如何在状态定义中声明URL参数(
/path/{parameter})。 - 我们掌握了通过
$stateParams服务在控制器中获取参数值的方法。 - 我们了解了如何使用
ui-sref指令生成带参数的导航链接。
通过URL参数,我们可以构建出能反映应用深层状态、且可被直接分享或书签的单页Web应用。
082:带URL参数的路由状态 🧭


在本节课中,我们将学习如何在 AngularJS 应用中配置和使用带参数的路由状态。我们将通过一个购物清单应用的例子,实现点击列表项查看其详细内容的功能。

项目初始状态
上一节我们介绍了基本的应用结构。本节中我们来看看如何为列表项添加详情页面。
当前应用是第38讲内容的副本。在浏览器中,我们有一个“预制的无饼干购物清单”。点击它,列表会显示出来。我们的目标是让列表中的每一项都可以点击,点击后显示该购物清单项的详细信息。
在 shoppingListService.js 服务中,除了名称和数量,每个项还有一个描述字段。我们需要能够逐一访问这些描述。
配置详情页路由状态
首先,我们需要在 routes.js 文件中添加一个显示项详情的新状态。
以下是添加新状态的步骤:
- 在文件底部,添加一个名为
itemDetail的新状态。 - 配置状态的
url属性为/item-detail/{itemId}。这里的{itemId}是一个参数,用于标识我们要查看的项在数组中的索引(0、1 或 2)。 - 设置
templateUrl指向一个已存在的模板文件:src/shoppinglist/templates/itemdetail.template.html。这个模板会显示项的名称、数量和描述。 - 指定控制器为
ItemDetailController。 - 添加一个
resolve属性。这个属性用于在状态激活前解析数据。我们将注入$stateParams服务来获取 URL 参数,并注入ShoppingListService来获取数据。
以下是 resolve 属性的配置代码:
resolve: {
item: ['$stateParams', 'ShoppingListService',
function ($stateParams, ShoppingListService) {
return ShoppingListService.getItems().then(function (items) {
return items[$stateParams.itemId];
});
}]
}
这段代码的作用是:首先调用服务获取所有项,然后根据 $stateParams.itemId 参数的值,从返回的项数组中取出对应的单个项。这个解析出的 item 对象随后会被注入到控制器中。
设置详情页控制器
接下来,我们需要设置 ItemDetailController 来接收并使用解析出的数据。
以下是控制器的设置步骤:
- 使用
$inject属性数组来保护控制器代码,防止压缩后出错。 - 将解析出的
item对象注入到控制器中。 - 将
item对象的属性(name,quantity,description)赋值给控制器实例($scope或this的别名),以便模板可以访问它们。
以下是控制器代码示例:
(function () {
'use strict';
angular.module('ShoppingList')
.controller('ItemDetailController', ItemDetailController);
ItemDetailController.$inject = ['item'];
function ItemDetailController(item) {
var itemDetail = this;
itemDetail.name = item.name;
itemDetail.quantity = item.quantity;
itemDetail.description = item.description;
}
})();
使列表项可点击
现在,我们需要修改主列表的模板,让每个列表项都能链接到其详情页。
在主列表模板(例如 shoppinglist.template.html)中,我们使用 ng-repeat 循环显示项。我们需要为每个列表项添加一个可点击的链接。
使用 ui-sref 指令来创建指向 itemDetail 状态的链接,并传递当前项的索引作为 itemId 参数。


以下是列表项链接的代码示例:
<li ng-repeat="item in $ctrl.items">
<a ui-sref="itemDetail({itemId: $index})">
{{ item.quantity }} of {{ item.name }}
</a>
</li>
这里,$index 是 ng-repeat 提供的当前项的索引。
此外,我们还通过 CSS 为这些链接添加了悬停效果(例如,光标变为手形、添加边框等),以提供更好的用户体验。
测试功能
完成以上步骤后,保存所有文件并刷新浏览器。

现在,当你将鼠标悬停在购物清单的某个项上时,光标会变成指针,表明它是可点击的。点击任意一项,浏览器地址栏的 URL 会变为类似 #/item-detail/1 的格式,并跳转到该商品的详情页面,显示其名称、数量和描述。
你可以通过页面上的导航链接(如“返回列表”)或浏览器的后退按钮,返回到主列表页面。


总结
本节课中我们一起学习了如何配置和使用带 URL 参数的路由状态。
- 定义带参数的路由:在状态的
url属性中,使用花括号{}包裹参数名,例如/item-detail/{itemId}。 - 获取路由参数:在
resolve函数或控制器中,通过注入$stateParams服务来获取 URL 中的参数值,例如$stateParams.itemId。 - 生成带参数的链接:使用
ui-sref指令,将状态名作为函数调用,并传入一个对象来指定参数值,例如ui-sref="itemDetail({itemId: $index})"。 - 数据预解析:利用状态的
resolve属性,在进入路由前异步获取所需数据,确保控制器能立即使用。


通过结合这些技术,我们成功构建了一个交互式的单页应用,用户可以从列表导航到任何项目的详细视图。
083:带嵌套视图的路由状态 🧭

在本节课中,我们将要学习如何使用 AngularJS UI-Router 创建嵌套的视图状态,特别是掌握“主从视图”模式。这种模式允许我们在不离开主视图的情况下,显示某个项目的详细信息,从而提升用户体验并优化数据加载。

有时,将一个视图声明并作为独立的 UI 状态来处理并不合理。
例如,我们经常有一个项目列表。当用户点击列表中的某个项目时,用户希望在不离开显示整个列表的视图的情况下,看到该项目的详细信息。
这两个视图——一个是完整的项目列表,另一个是特定项目的详细信息——通常被称为“主从视图对”。
并非所有的主从视图对都适合同时显示在用户屏幕上,但有些情况确实如此。UI-Router 一个非常有用的特性是:子状态会继承父状态的 resolves 和自定义数据属性。
这意味着,如果父状态已经向服务器发起调用并获取了数据,子状态就无需重复这个过程。它可以直接使用父状态已有的数据,而无需再次调用服务器。
上一节我们介绍了嵌套状态的概念,本节中我们来看看如何声明一个子状态。
子状态通常使用点符号来声明,以指定父状态名称和子状态名称。
.state('parentState', {
// 父状态配置
})
.state('parentState.childState', {
// 子状态配置
})
当以这种方式设置,并且子状态分配了自己的 URL 时,其 URL 会拼接在父状态声明的 URL 之后。
例如,如果父状态的 URL 是 /view1,那么这个子状态的 URL 将是 /view1/detail/:param1。
拥有子状态还意味着,在父状态的模板中某个位置,需要声明一个 ui-view 指令,子状态的模板可以插入到这个视图中。
这种设置与直接放在主 HTML 页面中的 ui-view 指令没有本质区别。
关于继承的 resolve 属性,其工作方式如下。
假设父状态声明了一个名为 myData 的 resolve 属性。如果声明了一个控制器名为 childCtrl 的子状态,这意味着我们可以直接将 myData 属性注入到子控制器的函数中。
.state('parentState', {
resolve: {
myData: function(SomeService) {
return SomeService.getData();
}
}
})
.state('parentState.childState', {
controller: 'childCtrl'
})
// 在子控制器中
app.controller('childCtrl', function(myData) {
// 可以直接使用父状态获取的 myData
});
在许多场景中,这种设置允许我们避免额外的服务器端调用,因为我们可以重用父状态激活时预取的数据。
让我们通过一个示例,来看看如何使用子状态,在主要的购物清单模板内部显示被点击的购物清单项目的详细信息。
以下是实现此功能的关键步骤:
- 定义父状态:首先,定义一个显示购物清单列表的父状态。
- 定义子状态:然后,定义一个子状态来处理显示单个项目的详细信息。其 URL 可能包含项目 ID 作为参数。
- 在父模板中添加
ui-view:在父状态(列表)的模板中,添加一个ui-view指令容器,用于承载子状态(详情)的模板。 - 利用数据继承:确保父状态的
resolve获取了列表数据。子状态可以直接使用这些数据来查找和显示特定项目的详细信息,无需再次请求整个列表。
通过这种方式,当用户点击列表中的项目时,URL 会更新,子状态被激活,其模板被渲染到父模板预留的 ui-view 区域中,从而实现流畅的主从视图体验。


本节课中我们一起学习了 UI-Router 的嵌套状态功能。我们了解到,通过创建子状态并利用其继承父状态数据和 resolve 的特性,可以高效地构建“主从视图”界面。这种方法不仅提升了应用的交互流畅度,还通过避免重复的数据请求优化了性能。在接下来的课程中,我们将继续探索更多高级的路由技巧。
084:带嵌套视图的路由状态




在本节课中,我们将学习如何在 AngularJS 应用中创建嵌套的路由状态。我们将从一个现有的购物清单应用开始,它目前有两个独立的状态:一个用于显示主列表,另一个用于显示单个项目的详情。我们的目标是让项目详情状态成为主列表状态的子状态,从而实现嵌套视图,让详情页面在主列表的上下文中显示。
我们回到代码编辑器,位于第40讲的文件夹中。这里的代码基本上是第39讲结束时的副本,只是简化了一些小细节。我们向下滚动查看状态定义,可以看到我们正在使用的主列表状态,它列出了所有的购物项目。此外,还有一个项目详情状态,它根据状态参数中的项目ID来显示特定项目的所有详细信息。
问题是,它在一个完全独立的屏幕上显示这些详情。你看不到完整的项目列表,只能看到这个特定项目的详情。同时注意到,主列表状态已经有一个名为 items 的 resolve 属性,它调用了购物清单服务的 getItems 方法。如果你查看项目详情状态,我们基本上在做同样的事情,只是现在使用状态参数 itemId 将列表过滤到一个项目。

让我们跳转到浏览器,再次看看它是如何工作的。我们点击这个链接,看到一个项目列表。如果我们点击其中一个,可以看到 URL 中出现了 /item-detail/0,并且我们看到了项目的描述,但没有看到整个列表。这是因为我们进入了一个完全不同的、实际上无关的UI状态。现在,我们通过让项目详情状态成为主列表状态的子状态来修复这个问题。

我们要做的第一件事是复制主列表状态的名称 mainList,并将其放在 itemDetail 前面,中间用一个点连接。所以它变成了 mainList.itemDetail。现在,这个状态成为了 mainList 的子状态。
我们现在可以从子状态中移除 resolve 属性。这样做的原因是,我们已经从父状态获取了所有项目,所以不需要再次请求并过滤它们。我们可以直接将父状态解析出的 items 属性注入到我们的项目详情控制器中。
让我们向下滚动,完全移除这个 resolve 属性。也把逗号去掉,然后保存。现在,我们可以转到项目详情控制器,注入 items 属性,然后使用状态参数找到我们实际需要的项目。
让我们找到项目详情控制器。在这里,我们不再注入 item,而是注入 items,因为正如我们讨论过的,items 来自父状态。我们还需要再注入一个东西,那就是 $stateParams 服务,用于状态参数。我们需要它,因为我们需要查找URL上的项目ID。我们把它也加在这里。
现在我们需要做的是,根据URL上的项目ID找到对应的项目。我们将设置一个变量 item,使用 $stateParams.itemId 作为索引来访问 items 数组。所以,我们的 item 就是通过URL传递的索引所对应的对象。现在,我们可以设置项目详情的名称、数量和描述了。
让我们点击保存。然而,在继续之前,还有一件事需要做。当用户点击列表项时,URL需要被正确构建。这部分代码位于购物清单模板HTML中,也就是我们组件的模板。目前,我们在这里使用的是 itemDetail。但我们的状态名称不再是 itemDetail,而是 mainList.itemDetail。所以,我们复制这个名称,回到 ui-sref 指令中,将其替换为 mainList.itemDetail。
保存后,在继续之前,我们还需要去主购物列表组件中,放置一个 ui-view 占位符。这样,主购物列表将由这个购物清单组件处理,而 ui-view 则是当子状态被选中时,其模板插入的地方。
现在,让我们回到浏览器,点击链接查看我们的列表。现在我们应该能够点击这里的链接了。但我们似乎搞错了什么,因为插值没有正常工作。让我们回到代码编辑器,看看发生了什么问题。控制器看起来没问题,但项目详情控制器似乎没有正确设置这些名称。



让我们看看原因。因为我们注入了 items,但在控制器里我们却引用的是 item。我们需要实际引用注入到项目详情控制器中的 items。保存后,回到浏览器,现在可以看到一切正常了。

让我们回到主页,点击链接。你可以看到它就在那里。当我们再次点击时,可以看到在这个模板内部,又显示了一个模板,那就是我们点击的项目的详情。当我们点击不同的项目时,可以看到它是即时发生的。之所以能即时发生,是因为我们不再向购物清单服务请求任何项目。这些项目都已经在父状态(即主列表)中加载好了。

同时注意我们的URL:/main-list/item-detail/0。我实际上可以手动更改它,你可以看到它正在手动更改。我们的项目详情URL变成了父URL /main-list 的一个子URL。
我之前说过,URL是可选的。所以从技术上讲,我们可以回到路由配置中,注释掉子状态的URL。然而,这里有一个注意事项:如果我们注释掉子状态的URL,而它恰好有一个参数,UI Router 将不知道需要这样一个参数。因此,它要求我们提供一个名为 params 的属性。在这个属性中,我们需要给UI Router提供参数名及其值。在这个例子中,我们给它一个值,比如 null 就足够了。
一旦我们这样做并保存,注意我们的子状态不再有一个我们可以识别的URL。但我们仍然能够通过我们在主购物列表控制器中创建的链接进入子状态。即使这个链接不一定是一个URL,但它会是一个动作类型的项目,当有人点击它时,会触发状态转换到我们的子状态。
让我们回到浏览器检查一下。首先点击这个,你可以看到URL显示为 /main-list,这是正确的。如果我们点击“两袋糖”,你会看到两袋糖的详情显示出来,我们处于那个UI状态。但如果你看URL,URL仍然停留在 /main-list。这是因为我们调用了UI状态,但没有为该状态关联一个URL。我们只是注释了它,并提供了一个提示,表明我们期望一个 itemId 作为该UI状态的参数。




好的,让我们来总结一下。
嵌套状态允许我们在逻辑上表示嵌套的视图。父状态的模板中有一个 ui-view 占位符,用于子状态的模板插入其HTML。


子状态的名称通常使用 parent.child 的语法声明。换句话说,就是父状态名,然后一个点,然后是子状态名。

子状态可选声明的URL会连接到父状态声明的URL之后。所以,如果父URL是 /view1,子URL声明为 /detail,那么子状态的完整URL将是 /view1/detail。


此外,UI Router 实现的一个非常重要的特性是,父状态的 resolve 属性会被子状态继承,并且可以直接注入到子状态的控制器中。这使我们能够避免多次重复获取相同数据的服务器调用。相反,子状态可以直接将父状态已经预取的数据注入到其控制器中。
本节课中,我们一起学习了如何创建嵌套的UI状态,如何通过点号语法定义父子关系,以及如何利用 resolve 属性的继承来优化数据获取。我们还探讨了子状态URL的可选性及其对参数处理的影响。
085:路由器状态转换事件


概述
在本节课中,我们将学习 AngularJS UI-Router 提供的路由器状态转换事件。路由器在后台执行许多操作,包括可能向服务器请求数据。因此,能够监听路由器执行功能时触发的不同事件非常有用。我们将了解如何利用这些事件来增强应用的用户体验,例如显示加载指示器或处理错误。
事件广播机制
所有路由器事件都在 $rootScope 级别触发。这意味着所有事件都会沿着文档对象模型树向下广播,任何节点都可以响应这些事件。
虽然 UI-Router 提供了许多事件,但我们不会逐一介绍。接下来,我们将重点看几个在示例中会用到的关键事件。如需完整的事件列表及其触发时机,请参阅 UI-Router 官方文档。
核心状态转换事件
以下是几个重要的状态转换事件及其用途。
状态转换开始事件
第一个事件是 $stateChangeStart。该事件在状态转换过渡开始时触发。如果你知道某个操作可能需要较长时间,或者需要等待服务器响应,此时通常是启动某种加载指示器(如旋转图标)的好时机,以告知用户系统正在处理。
状态转换成功与错误事件
当状态转换成功完成时,会触发 $stateChangeSuccess 事件。如果你在 $stateChangeStart 事件中启动了加载指示器,现在就是关闭它的时候,因为转换已经完成。
同样,如果在转换过程中发生错误,则会触发 $stateChangeError 事件。UI-Router 文档指出了一个重要点:如果在你的 resolve 函数中发生任何错误,它们不会以传统方式抛出。换句话说,你在控制台中将看不到任何错误,一切看起来都正常,但实际上存在错误。你必须监听 $stateChangeError 事件,才能捕获状态更改期间发生的所有错误。
阻止状态转换
如果你想在转换开始时根据某些数据评估来捕获并阻止转换发生,可以在事件对象上调用 preventDefault() 方法。这将阻止转换继续进行,本质上取消了它。
过渡到实践
上一节我们介绍了路由器状态转换事件的核心概念,本节中我们来看看如何将它们应用到代码中。


现在,让我们回到代码编辑器,使用一些路由器事件来增强我们的购物清单应用程序。
086:路由器状态转换事件 🚦


在本节课中,我们将学习如何利用 AngularJS UI Router 内置的状态转换事件来控制页面加载指示器(如旋转器)的显示与隐藏。我们将通过监听路由器的特定事件,来替代之前自定义事件的方法,实现更优雅的交互反馈。
代码结构与准备
上一节我们介绍了自定义事件,本节中我们来看看如何利用 UI Router 的内置事件。我们回到代码编辑器,位于 lecture41 目录下。此示例代码与第40讲基本相同,但我们重新引入了旋转器组件。关键区别在于,现在的旋转器将响应 UI Router 抛出的事件,而非我们自定义的事件。
首先,我们需要在购物列表模块 shopping-list.module.js 中导入旋转器模块。我们看到这里已经导入了 ui.router 和 spinner 模块。
接下来,旋转器模块的代码将有所不同。
旋转器组件逻辑
让我们查看组件的主要逻辑文件 component.js。我们声明了一个名为 loadingSpinner 的组件,其控制器为 SpinnerController。
在控制器的 $onInit 生命周期函数中,我们将设置事件监听器。
以下是控制器中需要实现的核心步骤:
-
监听
$stateChangeStart事件:当 UI Router 开始状态转换时触发此事件。我们的响应是将控制器的showSpinner属性设置为true。$scope.$on('$stateChangeStart', function(event) { vm.showSpinner = true; });如果查看组件的模板,会发现有一个
ng-if指令绑定到controller.showSpinner。当该属性为真时,旋转器图片就会显示。 -
存储取消监听函数:
$on方法会返回一个函数,调用该函数可以取消对应事件的监听。我们将在控制器作用域外定义一个cancellers数组,并将每个监听器的取消函数存入该数组。let cancels = []; cancels.push($scope.$on('$stateChangeStart', function(event) { vm.showSpinner = true; })); -
监听
$stateChangeSuccess事件:当状态转换成功完成时触发此事件。此时,我们可以通过将showSpinner设置为false来关闭旋转器。cancels.push($scope.$on('$stateChangeSuccess', function(event) { vm.showSpinner = false; })); -
监听
$stateChangeError事件:如果在转换过程中发生任何错误(例如resolve中的错误),也会触发此事件。为防止旋转器无限旋转,我们需要在此事件中同样将showSpinner设置为false。cancels.push($scope.$on('$stateChangeError', function(event) { vm.showSpinner = false; })); -
清理监听器:最后,我们需要在控制器作用域被销毁时,清理所有的事件监听器,以防止内存泄漏。这可以在
$onDestroy生命周期函数中完成。this.$onDestroy = function() { cancels.forEach(cancelFn => cancelFn()); };

运行与效果
完成上述代码后,保存并刷新浏览器。现在,当点击链接进行页面导航时,旋转器会短暂出现,提示用户页面正在加载。由于我们的示例没有调用异步服务,转换速度极快,旋转器可能一闪而过。但反复在“首页”和“列表页”之间切换,就能观察到旋转效果,这向用户清晰地表明了应用正在后台工作。
UI Router 文档与未来
在结束 UI Router 这个话题之前,有必要了解一下如何获取其官方文档。
你可以搜索“ui-router”或“Angular ui-router”来找到其 GitHub 页面。需要注意的是,由于版本较多,文档链接可能有些混乱。最可靠的方法是滚动到页面底部,找到并点击“In-Depth Guide”链接。这份指南文档通常比较准确和实用。
另外需要指出的是,UI Router 即将发布 1.0 版本。该版本将完全以组件为中心进行设计,这与 Angular 2 以及 Angular 1.x 向组件化架构发展的趋势是一致的。届时,在定义状态时,你将可以直接指定负责处理该状态的组件,而无需再单独定义控制器。
核心概念总结

本节课中我们一起学习了 UI Router 状态转换事件的应用。让我们总结一下要点:
- 事件监听:UI Router 暴露了多个状态转换事件,供我们的代码监听。
- 事件作用域:所有 UI Router 事件都在
$rootScope上触发。 - 关键事件:有几个事件在开发中尤其常用:
$stateChangeStart:状态转换开始时触发。可以调用event.preventDefault()来阻止转换完成。$stateChangeSuccess:状态转换成功结束时触发。$stateChangeError:状态转换失败时触发(包括resolve中出现的错误)。
- 错误处理:必须监听
$stateChangeError事件才能捕获状态转换期间的所有错误。例如,从resolve功能中产生的错误不会显示在控制台中,只会被“吞掉”,监听此事件是查看它们的唯一方式。

通过合理利用这些事件,我们可以构建出响应更灵敏、用户体验更佳的单页应用程序。
AngularJS 单页应用开发:模块4:模块四总结

在本节中,我们将对模块四的学习内容进行回顾与总结,并展望接下来的学习旅程。
恭喜你完成了模块四的学习。你现在掌握的技能,已经足以开发出设计精良、优雅的单页Web应用。剩下的就是多加练习。
在下一个模块中,我们将暂时离开课堂,进入真实世界。你将和我一起进行一次有趣的实地考察,拜访真实的客户并了解他们的业务。
然后,你将坐在我旁边,亲眼目睹我编写一个真实的、将被真实用户使用的AngularJS应用程序。
虽然代码仍将由我来编写,但你将能够近距离、一步步地观察一个真实AngularJS Web应用的完整创建过程。
我们下一个模块再见。

本节课中,我们一起回顾了模块四的成就,并预告了下一模块将进入实战环节,通过观察真实项目的开发过程来深化对AngularJS的理解。
088:欢迎进入模块5

在本模块中,我们将学习如何使用 AngularJS 进行表单验证、单元测试,并最终将一个完整的网站项目重写为 AngularJS 应用。这是本课程的最后一个模块,内容非常丰富。
概述
模块5是本课程的最后一个部分。我们将从学习 AngularJS 简便的表单验证开始,然后深入探讨如何为 AngularJS 代码编写单元测试。最后,也是最有趣的部分,我们将使用 AngularJS 重写一个为真实客户开发的完整网站。完成必修部分后,你还可以进入可选的附加部分,学习如何为应用添加更多管理功能。
表单验证与单元测试
上一段我们概述了本模块的整体内容。本节中,我们来看看模块的具体起点:表单验证。
我们将首先学习利用 AngularJS 进行表单验证是多么容易。AngularJS 提供了内置的指令和属性来简化验证流程,例如 ng-required、ng-minlength 等。
完成表单验证的学习后,我们将深入探讨 AngularJS 代码的单元测试。
我们将介绍如何为所有主要的 AngularJS 构件设置测试。以下是主要的构件类型:
- 控制器
- 服务
- 指令
- 组件
此外,我们还会学习如何测试那些通过 $http 服务访问网络的服务。
项目重写与实践
掌握了单元测试的方法,我们就具备了重构大型项目的坚实基础。接下来,进入本模块最有趣的部分。
我们将选取我在之前课程中为真实客户开发的网站,并使用 AngularJS 将其完全重写。这个过程将综合运用我们在此课程中学到的所有知识。
然而,编程的乐趣不止于此。完成课程的必修部分后,你可以继续学习可选的附加部分。
在附加部分,我们将进一步优化新开发的 AngularJS 应用,为其添加更多功能,让餐厅管理员能够自行管理数据。以下是我们将要涵盖的一些增强功能:
- 设置用户身份验证
- 编辑餐厅菜单项
- 上传菜单项图片
总结
本节课中我们一起学习了模块5的完整路线图。我们从AngularJS的表单验证开始,过渡到如何为控制器、服务、指令和组件编写单元测试。最后,我们介绍了本模块的核心实践项目——使用AngularJS重写一个完整网站,并预览了后续可选的扩展功能,如身份验证和数据管理。
089:表单验证(第1部分)


概述
在本节课中,我们将学习如何在 AngularJS 中实现表单验证。表单验证是 Web 应用开发中的核心功能,用于确保用户输入的数据符合特定规则。AngularJS 提供了强大且简单的方式来处理表单验证,甚至有些小型项目会专门引入 AngularJS 来管理其表单验证。
表单与控件简介
在深入了解验证之前,我们先简要回顾一下表单和控件。表单是控件的集合,这些控件通常是用户可以与之交互的原生 HTML 控件,例如通过选择或输入数据。
我们目前已经接触过的一个控件是文本框。每个输入元素都有一个 type 属性,其值告诉浏览器如何显示该输入。例如,input type="text" 会显示为单行文本框,而 input type="textarea" 则显示为多行文本框。
其他常见的输入类型包括 checkbox(复选框)和 submit(提交按钮)。submit 类型在历史上用于由浏览器自动收集表单所有值,将其排列为键值对,并向表单元素的 action 属性构造一个 URL 请求。但在单页应用中,我们通常使用前端逻辑(如 AngularJS)来处理数据,因此不常使用 action 属性。不过,input type="submit" 会原生显示为一个按钮,我们仍然可以使用它,或者也可以使用 HTML button 标签。
将所有输入元素放在一个 form 标签下并不是 HTML 文档有效性的强制要求,但它能让我们自然地分组这些输入元素,并且是使用 AngularJS 表单验证所必需的。
设置 AngularJS 表单验证的步骤
上一节我们介绍了表单的基本概念,本节中我们来看看在 AngularJS 中设置表单验证的具体步骤。以下是实现验证需要遵循的流程。
第一步:创建表单并命名
首先,必须创建一个包含输入元素的表单,并为表单以及输入元素设置 name 属性的值。
<form name="myForm">
<input type="text" name="userName" />
</form>
第二步:引入 ng-model 指令
表单验证功能通过我们之前多次使用的 ng-model 指令引入到 HTML 中。假设你有一个控制器包裹着这个表单,并且其实例标签(或 controller as 语法)已设置为 ctrl。
<input type="text" name="userName" ng-model="ctrl.name" />
这会将控制器实例的 name 属性绑定到这个文本输入框。我们还可以为按钮添加 ng-click 指令,以便在验证通过后,在控制器中处理绑定的数据。
第三步:声明验证属性
目前,我们还没有要求表单进行任何验证。接下来,我们需要声明 HTML5 验证属性。例如,可以为一个文本框设置 required 和 minlength 属性。
<input type="text" name="userName" ng-model="ctrl.name" required minlength="4" />
这意味着该文本框不能为空(required),并且输入的内容长度至少为 4 个字符(minlength="4")。我们也可以选择使用 AngularJS 提供的等效验证属性,例如用 ng-minlength 代替 minlength。两者的主要区别在于,ng-minlength 属性的值可以是一个常规的 Angular 表达式,而无需使用双花括号进行插值。如果你不希望硬编码最小长度值,而是希望从控制器实例或其他 Angular 容器中获取该值,这将非常有用。
由于我们希望 AngularJS 负责验证,需要禁用功能有限的原生浏览器表单验证,以免造成干扰。我们可以在 form 元素上指定 novalidate 属性来实现。
<form name="myForm" novalidate>
<!-- 输入字段 -->
</form>
第四步:使用表单绑定对象提供用户反馈
现在,我们可以使用 AngularJS 创建的表单绑定对象来影响用户界面并向用户提供反馈。例如,我们可以使用 ng-if 指令来条件性地显示错误信息。
<span ng-if="myForm.userName.$error.required && myForm.userName.$touched">
用户名是必填项。
</span>
在这个例子中,myForm 是表单的 name,userName 是输入字段的 name。$error.required 和 $touched 是 AngularJS 提供的对象属性。$touched 属性是一个输入状态指示器,如果用户曾点击进入该输入字段(即“触摸”过它),其值就为 true。我们添加这个条件是为了防止在用户甚至还没有机会尝试输入值之前就显示错误信息。
AngularJS 还会在表单对象本身上创建许多绑定属性。例如,我们可以使用 $invalid 属性来根据表单是否有效来条件性地启用或禁用提交按钮。
<button ng-disabled="myForm.$invalid">提交</button>
第五步:应用 AngularJS 验证样式
在幕后,AngularJS 通过向我们的输入字段动态添加和移除与验证相关的 CSS 类,为我们提供了更多帮助。我们可以在样式表中为这些类提供样式来利用这一点。
例如,如果一个输入元素被用户触摸过并且同时有效,我们可以将其边框变为绿色;如果被触摸过但验证失败,则变为红色。
.ng-valid.ng-touched {
border: 2px solid green;
}
.ng-invalid.ng-touched {
border: 2px solid red;
}
关于可基于 AngularJS 验证进行样式化的完整 CSS 类列表,以及完整的验证绑定对象列表,请查阅 ng-model、ngModelController 和 formController 的官方文档。


总结
本节课中,我们一起学习了在 AngularJS 中实现表单验证的完整流程。我们从创建和命名表单开始,然后使用 ng-model 指令绑定数据,接着声明了 HTML5 或 AngularJS 特有的验证属性。之后,我们探讨了如何利用 AngularJS 提供的表单绑定对象(如 $error、$touched、$invalid)来向用户提供实时反馈。最后,我们还了解了如何通过 AngularJS 动态添加的 CSS 类来美化验证状态的视觉呈现。掌握这些步骤,你就能为你的单页 Web 应用构建出健壮且用户友好的表单验证功能。
090:表单验证


在本节课中,我们将学习如何在 AngularJS 应用中进行表单验证。我们将通过一个简单的注册表单示例,了解如何为输入字段添加验证规则,如何根据验证状态显示错误信息,以及如何控制表单的整体提交状态。
项目结构与表单概览
我们位于课程第42讲的示例文件夹中,项目名为 simple forms app。该应用包含一个名为 registration controller 的控制器,以及一个用于用户注册的简单表单。表单包含三个输入字段:用户名、邮箱地址和电话号码。
绑定输入字段与模型
首先,我们需要将表单中的输入字段绑定到控制器中的数据模型上。这通过 ng-model 指令实现。
以下是用户名输入字段的代码示例:
<input type="text" name="username" ng-model="reg.user.username" required minlength="4" ng-maxlength="10" />
我们使用 reg.user.username 作为模型路径,目的是将所有注册信息都组织在控制器实例的 user 对象下。这样,邮箱和电话号码字段可以分别绑定到 reg.user.email 和 reg.user.phone。
添加验证规则
在输入字段上,我们通过 HTML5 属性或 AngularJS 指令声明验证规则。
required: 表示该字段不能为空。minlength="4": 表示输入内容至少需要4个字符。ng-maxlength="10": 这是 AngularJS 的指令,表示输入内容最多不能超过10个字符。
为了禁用浏览器的原生表单验证,我们在 <form> 标签上添加了 novalidate 属性。
显示验证错误信息
验证状态发生变化时,我们需要向用户提供清晰的反馈。AngularJS 的表单控制器会为每个字段维护一个 $error 对象。
以下是如何为用户名字段显示错误信息的示例:
<span ng-if="regForm.username.$error.minlength || regForm.username.$error.required">
用户名必须至少包含4个字符。
</span>
<span ng-if="regForm.username.$error.maxlength">
用户名不能超过10个字符。
</span>
regForm.username.$error.minlength在输入字符数少于minlength时变为true。regForm.username.$error.required在字段为空时变为true。regForm.username.$error.maxlength在输入字符数超过ng-maxlength时变为true。
我们使用 ng-if 指令来控制错误信息的显示。注意,minlength 验证仅在字段有输入时触发,而 required 验证检查字段是否为空。因此,我们使用逻辑或 (||) 来确保在用户未输入任何内容时也能显示“至少4个字符”的提示。
此外,错误信息只在用户与字段交互(“触碰”过)后才显示。这是通过检查 regForm.username.$touched 属性实现的。完整的条件如下:
<span ng-if="(regForm.username.$error.minlength || regForm.username.$error.required) && regForm.username.$touched">
理解模型更新与验证状态
一个关键行为是:只有当字段通过所有验证后,输入框中的值才会更新到 ng-model 绑定的模型属性中。
例如,在用户名输入框中键入“CO”(仅2个字符),由于未满足 minlength=4 的规则,字段处于无效状态。此时,reg.user.username 的值不会被更新,因此页面上用于实时显示该值的插值表达式 {{reg.user.username}} 也不会显示内容。只有当输入满足所有规则(如“Cookie”)后,模型才会更新,插值表达式也随之显示。
通过样式提供视觉反馈
我们可以通过 CSS 为不同验证状态的输入框添加样式,提供更直观的视觉反馈。
以下是一个样式规则示例:
input.ng-touched.ng-invalid {
border: 2px solid red;
}
input.ng-valid {
border: 2px solid green;
}
ng-touched: 当用户点击过该输入框并离开后,AngularJS 会自动添加此类名。ng-invalid/ng-valid: 分别表示字段的验证状态为无效或有效。


当用户与一个无效的字段交互后,该输入框会获得红色边框。当字段内容有效时,边框会变为绿色。
验证邮箱与电话号码字段
上一节我们介绍了用户名字段的验证,本节我们来看看邮箱和电话号码字段的验证有何不同。
邮箱字段验证:
邮箱字段的输入类型被设置为 type="email"。浏览器和 AngularJS 会基于此类型提供一个内置的邮箱格式验证。我们只需添加 required 属性即可。
错误信息通过检查 regForm.email.$invalid 状态来显示。
电话号码字段验证:
电话号码字段使用了 ng-pattern 指令进行基于正则表达式的验证。
<input type="text" ... ng-pattern="/^\d{3}-\d{3}-\d{4}$/" />
这里的正则表达式 /^\d{3}-\d{3}-\d{4}$/ 要求输入格式必须为“三位数字-三位数字-四位数字”(例如 123-456-7890)。如果输入不符合该模式,regForm.phone.$error.pattern 将变为 true。
控制表单提交按钮
最后,我们需要根据整个表单的验证状态来控制提交按钮。

以下是提交按钮的代码:
<button ng-disabled="regForm.$invalid" ng-click="reg.submit()">提交</button>
ng-disabled="regForm.$invalid": 当表单中存在任何无效字段时(即regForm.$invalid为true),按钮将被禁用。ng-click="reg.submit()": 当按钮可点击时,点击会触发控制器中定义的submit方法。
在控制器中,submit 方法可以设置一个标志(例如 reg.completed = true),用于在页面上显示注册成功的摘要信息。
实时监控表单整体状态
在开发过程中,我们可以通过插值表达式 {{regForm.$valid}} 在页面上实时显示表单的整体有效状态(true 或 false),这有助于调试和理解验证逻辑。

例如,当所有字段都有效时,{{regForm.$valid}} 会显示 true,提交按钮随之启用。如果任一字段无效,则显示 false,按钮保持禁用状态。


本节课中我们一起学习了 AngularJS 表单验证的核心机制。我们掌握了如何将输入字段绑定到数据模型,如何为字段添加各种验证规则(必填、长度、格式等),以及如何利用 AngularJS 提供的 $error、$touched、$invalid 等状态属性来显示错误信息、添加视觉反馈并控制表单的提交流程。通过本讲的学习,你将能够为你的 AngularJS 应用构建出交互友好、健壮可靠的表单。
091:使用Jasmine测试JavaScript 🧪


概述
在本节课中,我们将要学习JavaScript单元测试的重要性,并介绍如何使用Jasmine测试框架来编写和运行测试。我们将从单元测试的基本概念开始,逐步深入到Jasmine的具体使用方法。
单元测试的重要性
由于JavaScript的动态类型特性,只要你知道JavaScript如何工作,编写代码可以是一种不受约束的体验。
但这种自由是以牺牲强类型语言(如Java)中存在的即时错误报告特性为代价的。这就是为什么在将JavaScript代码交付给用户之前进行测试非常重要。你不希望等到用户打电话告诉你某些功能损坏了,才发现仅仅是因为某个地方变量名拼写错误。如果在完成前就执行了代码,这类错误本可以避免。
现代软件开发很少在不编写单元测试的情况下完成。这已经是流程的一部分。
什么是单元测试?
单元测试是对应用程序中最小可测试部分的独立检查,以确保其正常运行。
单元测试是一个非常强大的概念。这个定义包含了很多内容。让我们逐步分解它。
首先,它是独立的。尽可能将你正在测试的小功能片段与应用程序的其余部分隔离开来非常重要。你需要确保只测试那一小部分,而不是整个系统。整个系统测试有其位置,但那不是单元测试。在单元测试中,我们只想验证这一小部分是否按预期运行。系统的其余部分无关紧要。
试想测试汽车轮胎在高温环境下保持胎压的能力。你不会为了测试这个而组装整辆汽车。这不仅非常耗时,还会将其他与胎压无关的问题引入测试。
定义中“最小可测试部分”也暗示你,将单元测试纳入开发过程会彻底改变你处理软件开发其余部分的方式。换句话说,你不能编写一个做20件不同事情的函数,然后期望它小而可测。
你测试的每个函数或组件都必须专注于单一功能或小结果。将整个应用程序包装成一个做所有事情的函数会使单元测试变得不可能,因为“单元”意味着小而专注。无论你是否接受测试驱动开发的最纯粹方法,这一点都成立。
单元测试的特性
单元测试还应编写成可重复的。这意味着,当你向代码库添加更多功能时,重新运行现有的单元测试并看到它们全部通过,应该能让你确信新添加的代码没有破坏某些现有功能。
但是,如果你的小单元代码有一个相当大且复杂的依赖项怎么办?你仍然需要在不依赖该依赖项的情况下测试那个小单元代码。这个问题的答案是模拟。
模拟是一种模仿依赖项及其行为,或者只是使用已知假对象的技术。模拟可以直接由开发人员完成,也可以使用模拟库。模拟库通常会提供更复杂或更通用的方式来与你试图模拟的某些功能进行交互。
例如,如果你的组件依赖于进行AJAX调用的$http服务,你将需要模拟$http服务,以便在单元测试中伪造服务器响应,来验证你的组件是否确实发生了预期的行为。
引入Jasmine测试框架
当然,你可以在没有任何其他框架帮助的情况下编写自己的单元测试。然而,使用一个附带大量工具来帮助你设置测试、提供方法来帮助你验证得到的结果是否符合预期的测试框架会更容易。
Jasmine框架是一个非常流行的JavaScript测试框架,并且在测试Angular代码时效果很好。这就是我们将用来构建测试的工具。
你可以将Jasmine用作基于浏览器的独立测试框架或命令行框架。在本课程中,我将介绍基于浏览器的版本,但要意识到使用基于命令行的版本可以让你创建自动化测试的脚本,这是一个不容轻易忽视的巨大好处。
使用Jasmine的基本步骤
以下是使用Jasmine的基本步骤。

第一步:下载Jasmine独立版并解压到某个目录。你可以从这个URL下载,这是Jasmine测试框架发布版本的URL。下载并解压该发布版本后,删除src和spec目录中的所有内容。然后将你自己的应用程序代码放入src目录,并将你的测试代码或规范代码放入spec目录。顺便说一下,我们将测试称为“spec”的原因有点超出本课程范围,它与一种称为行为驱动开发(BDD)的方法有关。基本上,正如你将在我们的示例中看到的,测试代码读起来就像我们试图测试的功能的规范。
第二步:更新提供的HTML页面,该页面名为SpecRunner.html。你需要做的是将所有对已删除的源文件和规范文件的引用替换为你自己编辑到这些目录中的文件。换句话说,就是你的应用程序代码,然后是你的测试代码或规范代码。
第三步:启动类似BrowserSync的工具或你用于本地服务器的任何工具,并访问http://localhost:3000/SpecRunner.html(可能是类似localhost:3000的地址)。该网页将启动运行Jasmine独立版(即基于浏览器的版本,而非命令行版本)的所有测试。你可以让BrowserSync保持运行,并在浏览器中保持打开SpecRunner.html。这样,你就可以在编码时实时查看测试是否在你认为应该通过的时候失败了。
如何编写规范(测试)
那么,我们如何编写之前提到的规范呢?
编写规范或测试相当简单。你通过调用describe函数来开始所有规范。这个函数是我们将要运行的所有测试的容器。
接下来,你可以选择指定一个名为beforeEach的函数。作为参数传递给beforeEach函数的函数值将在每个规范运行之前被调用一次。这非常有用,因为在任何测试运行之前,我们必须确保测试运行的条件与我们需要的条件相同。如果你的代码由于其他测试执行而更改了某些数据,然后我们在认为应用程序处于某种状态(而实际并非如此)的情况下运行另一个测试,我们的结果可能是错误的,或者无论如何肯定不可靠。因此,在beforeEach函数内部,你可以初始化每个测试可能需要的任何数据、实例化任何服务等等。
it函数是实际的测试。请注意整个结构——describe字符串和it字符串一起读起来就像一个规范:“我的函数不应返回true”。
Jasmine提供了一套相当广泛的函数,你可以用来测试应用程序代码执行的结果。请注意,函数名称的设计也使其读起来像普通的英语。在Jasmine中,要否定某个期望语句,你只需将.not属性附加到它上面,然后继续调用验证语句。例如,这里我们期望结果不为true。


总结
本节课中,我们一起学习了JavaScript单元测试的重要性及其核心概念,包括独立性、可重复性和模拟。我们介绍了Jasmine测试框架,并详细说明了使用它的基本步骤:下载配置、编写规范以及运行测试。通过describe、beforeEach和it等函数,我们可以清晰地构建和组织测试用例,确保代码质量。
092:使用Jasmine测试JavaScript 🧪




在本节课中,我们将学习如何使用Jasmine框架进行JavaScript单元测试。我们将通过两个具体的例子来理解如何编写测试、使用模拟对象以及如何组织测试代码。单元测试是软件开发中确保代码质量的关键环节。
项目结构与测试运行器
我们回到代码编辑器,位于lecture 43文件夹中。该文件夹包含了从Jasmine压缩包解压出的内容。我们移除了原有的示例文件,并添加了自己的源代码和测试文件。
以下是specrunner.html文件的内容,它作为测试运行器,导入了Jasmine库以及我们的源代码和测试文件。
<script src="cookieDetector.js"></script>
<script src="oddEven.js"></script>
<script src="cookieDetectorSpec.js"></script>
<script src="oddEvenSpec.js"></script>
第一个示例:Cookie检测器 🍪
上一节我们介绍了测试运行器,本节中我们来看看第一个测试示例。我们将编写一个函数,用于检测一个数组中是否包含“cookie”字符串。
源代码分析
以下是cookieDetector.js中的函数。它接收一个数组,遍历每个元素,检查是否包含“cookie”字符串(不区分大小写)。
function detectCookie(items) {
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.toLowerCase().indexOf("cookie") !== -1) {
return true;
}
}
return false;
}
编写测试规范
接下来,我们为这个函数编写测试。测试文件是cookieDetectorSpec.js。
我们使用describe函数来组织一组相关的测试,这里我们描述为“Cookie Detector”。在运行每个具体测试之前,我们使用beforeEach函数来初始化测试数据。
以下是测试的初始化部分:
describe("Cookie Detector", function() {
var itemsWithoutCookie;
var itemsWithCookie;
beforeEach(function() {
itemsWithoutCookie = ['apple', 'banana', 'orange'];
itemsWithCookie = ['apple', 'cookie', 'banana'];
});
});
初始化完成后,我们开始编写具体的测试用例。每个测试用例使用it函数定义,包含一个描述和一个执行测试的函数。

以下是具体的测试用例:
- 测试无Cookie的情况:我们期望当传入不包含“cookie”的数组时,函数返回
false。 - 测试有Cookie的情况:我们期望当传入包含“cookie”的数组时,函数返回
true。

it("should be able to detect no cookies", function() {
var result = detectCookie(itemsWithoutCookie);
expect(result).not.toBe(true);
});
it("should be able to detect cookies", function() {
var result = detectCookie(itemsWithCookie);
expect(result).toBe(true);
});
运行测试与调试
保存文件后,在浏览器中打开specrunner.html运行测试。最初,我们发现“检测有Cookie”的测试失败了。

检查代码发现,最初的函数版本有一个逻辑错误:return false语句错误地放在了for循环内部。这导致函数在检查第一个元素后,无论结果如何都会立即返回false。
// 错误版本
for (var i = 0; i < items.length; i++) {
// ... 检查逻辑 ...
return false; // 错误位置
}


我们修正了这个问题,将return false移到循环外部。但测试仍然失败,因为测试数据中的“Cookie”是大写字母开头。




我们修改了函数,在比较前先将字符串转换为小写,最终两个测试都通过了。


这个例子完美展示了单元测试的价值:即使是有经验的开发者,也可能写出有细微错误的代码。单元测试能及早发现这些错误,避免它们在后期引发更大的问题。
第二个示例:奇偶数生成器与模拟对象 🔢

现在,我们来看一个更复杂的例子,它涉及依赖项和模拟对象的使用。我们将测试一个函数,它根据类型(奇数或偶数)返回一个1到10之间的随机数。

源代码与依赖分离
oddEven.js中的getRandomOddEven1To10函数接受一个类型参数(‘odd’或‘even’)和一个随机数生成器函数作为依赖。
function getRandomOddEven1To10(type, randomNumberGenerator) {
var randomNumber = randomNumberGenerator(1, 10);
var isOdd = randomNumber % 2 !== 0;
if ((type === 'odd' && isOdd) || (type === 'even' && !isOdd)) {
return randomNumber;
} else {
// 逻辑:如果生成的数字不符合类型要求,这里可能需要处理,本例中我们假设模拟对象总是返回符合要求的数字。
return -1; // 或者进行递归调用等
}
}
我们不希望测试受到真正随机数的影响,因此需要“模拟”这个依赖项。
使用模拟对象进行测试
在oddEvenSpec.js中,我们使用beforeEach来创建模拟的随机数生成器。
以下是模拟对象的设置:
describe("Odd Even Generator", function() {
var randomNumberGenerator8;
var randomNumberGenerator3;
beforeEach(function() {
randomNumberGenerator8 = jasmine.createSpy('Random Generator 8').and.returnValue(8);
randomNumberGenerator3 = jasmine.createSpy('Random Generator 3').and.returnValue(3);
});
});
jasmine.createSpy创建了一个模拟函数。and.returnValue(8)确保无论传入什么参数,这个函数总是返回8(一个偶数)。同理,另一个模拟函数总是返回3(一个奇数)。
现在我们可以编写不依赖于随机性的确定性测试了。
以下是具体的测试用例:
- 测试生成奇数:我们传入类型‘odd’和总是返回奇数3的模拟生成器,期望函数返回3。
- 测试生成偶数:我们传入类型‘even’和总是返回偶数8的模拟生成器,期望函数返回8。
it("should produce an odd number", function() {
var result = getRandomOddEven1To10('odd', randomNumberGenerator3);
expect(result).toEqual(3);
});
it("should produce an even number", function() {
var result = getRandomOddEven1To10('even', randomNumberGenerator8);
expect(result).toEqual(8);
});
运行测试,两者都顺利通过。模拟对象让我们能够独立地测试核心逻辑,而无需关心其依赖项的复杂性和不确定性。
临时禁用测试
有时,某个测试可能尚未完成或需要暂时跳过。Jasmine允许在describe或it前添加x来禁用它们。
// 禁用单个测试
xit("should produce an even number", function() { ... });
// 禁用整个测试套件
xdescribe("Odd Even Generator", function() { ... });



被禁用的测试在报告中会显示为“待定”(pending)状态。







总结 📝
本节课中我们一起学习了使用Jasmine进行JavaScript单元测试的核心概念。


我们了解到,单元测试是软件开发中必不可少的过程。为单元测试做规划,会促使你编写更具模块化、功能更单一的代码,这本身就是优秀的开发实践。
我们学习了模拟对象(Mocks)的用途,即伪造目标代码的依赖项,从而可以独立测试特定单元,而无需构建整个系统。
在Jasmine中,我们通过以下方式组织测试:
describe(string, function):用于将一组相关的测试用例组织成一个测试套件。beforeEach(function):在每个it测试用例运行前执行,用于初始化测试状态,确保测试起点一致。it(string, function):定义一个具体的测试用例,其中包含执行代码和断言验证。


通过实际编写和运行测试,我们看到了即使简单的代码也可能隐藏错误,而单元测试是快速发现并修复这些错误的最有效手段。
093:测试 AngularJS 控制器(第1部分)


在本节课中,我们将要学习如何测试 AngularJS 中的控制器。控制器是 MVVM 模式中的视图模型,其职责是管理视图的数据和状态,这使得它易于进行单元测试。
概述:为何控制器易于测试
在课程开始时,我们讨论了 MVVM 设计模式。在 Angular 中,我们使用控制器来实现视图模型。控制器不允许包含直接要求视图显示内容的代码,它只负责表示视图的数据或状态。这一特性使得控制器更容易测试,而无需依赖浏览器的文档对象模型(DOM)。
AngularJS 不仅提供了一个开发 Web 应用的框架,还提供了一个名为 ngMock 的辅助模块来帮助我们测试 Angular 应用。ngMock 是一个独立的 JavaScript 文件。
测试规范的结构
让我们看一下测试规范(spec)的结构,了解如何测试应用程序中的控制器。
第一步:加载模块
第一步是使用 angular.mock.module 函数(其简写 module 是 angular.mock.module 的别名)。你需要将要测试的模块名称作为参数传递给 module 函数。例如,我们想要测试的控制器应该声明在该模块中。
你可以直接将模块传递给 beforeEach 函数,或者如本幻灯片所示,先将其包装在一个匿名函数中。当你在 beforeEach 函数中有多个操作时,这很有用。例如,除了加载模块,你可能还想配置一些模拟对象以供后续测试使用。你也可以有多个 beforeEach 调用,每个都会在任何声明的测试之前执行。
由于内容无法全部放在一张幻灯片上,让我们在下一部分看看具体内容。
第二步:注入依赖
通常,你会有另一个 beforeEach 块,它使用 Angular Mocks 库的 inject 方法来注入测试所需的对象。inject 方法是 angular.mock.inject 的别名。注入后,我们可以将它们保存下来,以便在测试中后续使用。
请注意我们注入的参数名称有些特殊。通常,Angular 服务以美元符号 $ 开头。例如,这里我们注入了 $controller 服务。这是一个 Angular 内部服务,用于实例化控制器。我们需要手动使用它来实例化我们自己的控制器。
虽然这些参数引用的是同一个对象,但为了获得正确的对象引用,我们可以将控制器服务实例保存在任意名称的变量中。我们希望使用其可识别的名称 $controller 来引用该服务。但是,如果参数名和我们的变量名完全相同,我们该如何保存呢?如果我们直接写 $controller = $controller,那只是将参数引用赋值给它自己。
Angular 为我们提供了一个基于约定的解决方案:用下划线包围服务名,angular.mock.inject 方法会自动去除下划线以查找正确的服务并注入。同时,我们可以使用带下划线的变量将引用保存到一个看起来正常的 $controller 变量中。
第三步:设置模拟服务
在这个 beforeEach 方法中,我们接下来要设置一个名为 mockService 的模拟服务。我们的控制器依赖于一个拥有 aMethod 方法的服务,该方法会返回某个值。在这里,我们设置这个模拟服务。我们的控制器将无法区分,并会愉快地调用我们的模拟服务,将其视为真实的服务。
然后,我们使用控制器服务来实例化我们的控制器,方法是传递控制器在真实应用模块中注册时使用的字符串名称。我们还需要传入控制器通常会被注入的依赖项,通过传递一个对象来实现,该对象的每个属性都是控制器将被注入的依赖项的名称。
这里,我们的真实控制器明确声明要注入一个名为 myService 的服务。然而,我们不是尝试创建 myService 的真实实例,而是传递模拟实例。这样,我们就能控制这个模拟服务为控制器提供什么,从而能够隔离并仅测试控制器的行为,而不会受到 myService 逻辑的干扰。
最后,我们将控制器实例保存在一个变量中,该变量可以被下面 it 测试函数访问。
编写实际的测试方法
让我们看一下实际的测试方法,它应该位于 beforeEach 方法之后。
我们代码的最后一步是执行附加在控制器实例上的某个函数。你可能已经猜到,这意味着我们的控制器是使用 controller as 语法声明的。如果不是,我们将需要像传入模拟服务一样,将 $scope 传入控制器,然后在 $scope 上调用 addItem 方法,并检查其值是否被设置为我们认为应该设置的值(在本例中是字符串 ‘fake-value’)。
最后一步是,你需要在 specrunner.html 中包含核心 Angular 库、Angular Mocks 库以及你的应用程序 JavaScript 代码。然后包含你的测试规范文件,你就可以启动 specrunner.html 并查看测试是否通过了。
让我们选取之前在讲座中开发的一个项目,为应用中的一个控制器编写测试。
总结


本节课中,我们一起学习了如何为 AngularJS 控制器编写单元测试。我们了解了 ngMock 模块的作用,掌握了测试规范的基本结构,包括加载模块、注入依赖、设置模拟服务以及编写断言测试。通过隔离控制器的逻辑并使用模拟依赖,我们可以有效地验证其行为是否符合预期。
094:测试 AngularJS 控制器(第二部分)


在本节课中,我们将学习如何为 AngularJS 控制器编写单元测试,特别是如何模拟(Mock)依赖的服务,以测试控制器在特定情况下的行为。
概述
我们将测试一个名为 ShoppingListController 的控制器。该控制器依赖于一个 ShoppingListService 服务。此服务有一个特性:如果尝试添加超过允许数量的商品,它会抛出一个错误。我们的测试目标是验证当服务抛出错误时,控制器是否能正确更新其内部的错误信息属性。
为了隔离测试控制器,我们将模拟(Mock)掉 ShoppingListService,使其无论何时被调用都抛出错误,然后检查控制器的错误信息是否被正确设置。
测试环境搭建
首先,我们来看测试文件的结构。测试文件位于 shoppingListController.spec.js 中。我们使用 Jasmine 测试框架和 Angular Mocks 库。
以下是测试套件的基本设置:
describe(‘Shopping List Controller‘, function() {
var $controller;
var shoppingListController;
beforeEach(module(‘ShoppingListApp‘));
beforeEach(inject(function(_$controller_) {
$controller = _$controller_;
}));
});
在上面的代码中:
describe函数定义了一个测试套件。beforeEach(module(…))确保在每个测试用例运行前,加载我们的ShoppingListApp模块。- 另一个
beforeEach块使用inject函数来注入 AngularJS 的$controller服务。我们使用下划线包裹(_$controller_)的技巧,以便将注入的服务赋值给一个名为$controller的局部变量,方便后续使用。
创建模拟服务
上一节我们介绍了测试的基本设置,本节中我们来看看如何创建模拟的 ShoppingListService。我们不希望测试依赖于真实的、可能行为复杂的服务,因此需要创建一个模拟对象来替代它。
以下是创建模拟服务对象的代码:
var shoppingListServiceErrorMock = {};
shoppingListServiceErrorMock.addItem = function(name, quantity) {
throw new Error(‘Test message‘);
};
shoppingListServiceErrorMock.getItems = function() {
return null;
};
这个模拟对象很简单:
addItem方法被重写,使其总是抛出一个带有特定信息“Test message”的Error。getItems方法被重写,返回null。这是因为控制器在初始化时会调用此方法,我们只需确保它不会导致测试失败即可。
实例化控制器并注入模拟服务
创建好模拟服务后,下一步是使用它来实例化我们要测试的控制器。
以下是实例化控制器并注入依赖的代码:
beforeEach(function() {
shoppingListController = $controller(‘ShoppingListController‘,
{$scope: {},
ShoppingListService: shoppingListServiceErrorMock
});
});
在这段代码中:
- 我们调用
$controller服务来创建ShoppingListController的实例。 - 第二个参数是一个对象,用于指定控制器的依赖注入。控制器期望一个名为
ShoppingListService的依赖项,我们在这里提供了之前创建的模拟对象shoppingListServiceErrorMock。AngularJS 的依赖注入系统会根据属性名进行匹配。
编写并执行测试用例
现在,控制器已经准备好,并且注入了会抛出错误的模拟服务。我们可以编写具体的测试用例了。
以下是测试用例的代码:
it(‘should change error message in controller‘, function() {
shoppingListController.addItem();
expect(shoppingListController.errorMessage).toBe(‘Test message‘);
});
这个测试用例的逻辑是:
- 调用控制器的
addItem方法。 - 由于模拟服务的
addItem会抛出错误,我们期望控制器的errorMessage属性会被设置为错误信息“Test message”。 expect(...).toBe(...)是 Jasmine 的断言语句,用于验证实际结果是否符合预期。
运行这个测试,它应该显示为通过(绿色),证明我们的控制器在服务出错时能正确更新错误信息。
使用 Angular Mocks 创建模拟对象
之前我们手动创建了一个简单的对象字面量作为模拟服务。Angular Mocks 库提供了更强大的工具来创建模拟对象。
让我们回到代码,学习另一种方法。我们可以使用 angular.mock 提供的方法来创建更逼真、功能更全的模拟对象,例如模拟一个完整的服务,包括其所有方法。这在进行复杂集成测试时非常有用。不过对于当前这个简单的错误测试,我们手动创建的对象已经足够。

总结

本节课中我们一起学习了如何为 AngularJS 控制器编写单元测试。核心步骤包括:
- 设置测试模块:加载包含待测试控制器的应用模块。
- 注入依赖:获取创建控制器所需的
$controller等服务。 - 模拟依赖项:创建模拟对象来替代控制器所依赖的真实服务,以控制测试环境并隔离被测单元。
- 实例化控制器:使用模拟的依赖项来创建控制器实例。
- 编写测试断言:调用控制器方法,并验证其状态或行为是否符合预期。


通过模拟依赖项,我们可以专注于测试控制器本身的逻辑,确保其在不同场景下(如服务成功、服务出错等)都能正确工作。这是编写可靠、可维护前端代码的重要实践。
095:测试AngularJS控制器 🧪


概述
在本节课中,我们将学习如何使用 AngularJS 的 $provide 服务来创建模拟对象,并以此为基础测试控制器。我们将对比之前手动创建模拟对象的方法,介绍一种更集成、更符合 AngularJS 依赖注入机制的方式。
从手动模拟到使用 $provide 服务
上一节我们介绍了如何手动创建模拟对象来测试控制器。然而,AngularJS 提供了另一种创建服务、工厂等组件的方式,即使用 $provide 服务。
我们通常使用在 Angular 模块实例上直接调用的 .service()、.factory()、.directive() 等方法来创建组件。实际上,这些方法只是底层创建这些组件的真实 API 的快捷方式。这个真实的 API 就是 $provide 服务。
以下代码展示了如何注入 $provide 服务:
angular.module('myModule', [])
.config(function($provide) {
// 使用 $provide 服务
});
可以将 angular.module 函数视为我们之前注入提供者的 .config 函数。与 .config 方法类似,你不能在 module 方法中注入非提供者组件(如一个真实的服务)。为此,你必须使用一个单独的 Angular 模块注入器。
使用 $provide 定义模拟服务
接下来,我们使用 $provide 服务为我们的模块定义一个名为 MenuService 的模拟服务。
angular.module('myModule', [])
.config(function($provide) {
$provide.service('MenuService', function() {
// 模拟服务的实现
});
});
如你所见,我们在一行内就定义了它。一旦服务被定义,我们就可以使用其声明的字符串名称 'MenuService' 作为可以注入到其他调用中的依赖项。
在这种设置中,我们不需要使用局部变量来存储新创建的服务引用,以便与创建控制器的代码共享。我们根本不需要这样做,因为我们已经将这个模拟服务作为模块的一部分。现在,我们可以让 Angular 在需要时查找并注入它。
注入模拟服务并实例化控制器
下一步是使用 $injector 的 get 方法(或类似方式)来注入我们新创建的 MenuService,然后在实例化控制器时使用它。
beforeEach(inject(function($controller, MenuService) {
$scope = {};
ctrl = $controller('MyController', { $scope: $scope, MenuService: MenuService });
}));
需要注意的是,根据你试图测试的内容,你可能不希望像在 beforeEach 中那样在中心位置创建控制器。直接在测试本身中创建控制器没有任何问题。这完全取决于你运行的测试之间共同的起始状态是什么。将测试之间共有的任何内容提取出来是一个好主意。这样,你就不会在多个地方编写相同的代码。
编写测试用例
现在,剩下的就是创建测试了,其代码与之前完全相同。
以下是编写测试的步骤:
- 设置模块和模拟服务:使用
angular.mock.module加载包含$provide配置的模块。 - 注入依赖并创建控制器:在
beforeEach块中,注入$controller和模拟的MenuService,然后创建控制器实例。 - 执行测试断言:在
it块中,访问控制器的$scope或属性,验证其行为是否符合预期。
让我们回到代码编辑器,回顾一下我们之前完成的单元测试的第二个版本。


总结
本节课中,我们一起学习了如何使用 AngularJS 的 $provide 服务来定义模拟依赖项,从而更优雅地测试控制器。我们了解了 $provide 是创建服务、工厂等组件的底层 API,并通过它直接在模块配置阶段注册模拟服务。这种方法避免了手动管理模拟对象的引用,更好地利用了 AngularJS 的依赖注入系统,使得测试代码更加清晰和可维护。记住,将测试的公共逻辑(如模块加载和控制器实例化)提取到 beforeEach 块中,是保持测试简洁和一致性的关键。
096:测试 AngularJS 控制器(第4部分)


在本节课中,我们将学习如何使用 AngularJS 提供的工具来对控制器进行单元测试。我们将重点介绍如何创建和使用模拟服务,以及如何通过 beforeEach 函数来设置测试环境。
上一节我们介绍了测试的基本概念,本节中我们来看看如何具体实施一个测试,特别是如何处理依赖注入。


我回到了代码编辑器,并仍在第44讲的文件夹中。我正在查看 shoppingListControllerSpec-v2.js 文件,这是相同测试设置的第二个版本。我之前不希望这个测试运行,所以在 describe 前加了一个 X 来临时禁用它。现在我将移除那个 X。
我们首先看到的是 beforeEach 函数。可以看到我们在这里做了几件事。首先,我们创建了一个名为 ShoppingListServiceErrorMock 的模拟服务。其次,我们加载了应用程序模块,以便能够连接并测试我们的控制器。
以下是创建模拟服务的方法:
我们通过让 Angular 注入 $provide 服务来创建它。$provide 服务有一个 service 方法。我们调用这个方法,传入我们服务的名称 ShoppingListServiceErrorMock,并内联地为我们之后的使用创建服务。你可以看到,这与创建常规服务时使用的类型相同:我创建了一个服务局部变量,附加了一个名为 addItem 的方法,并创建了另一个名为 getItems 的方法,该方法返回一个错误消息。
$provide.service('ShoppingListServiceErrorMock', function () {
var service = this;
service.addItem = function () {
throw new Error("Test Message.");
};
service.getItems = function () {
return [];
};
});
现在 Angular 知道存在一个名为 ShoppingListServiceErrorMock 的服务。
接下来,我们看看注入的内容。当我们注入时,我们不再仅仅注入需要实例化的控制器,我们还注入了 ShoppingListServiceErrorMock 模拟服务,就像注入任何其他可用的服务或控制器一样。这个服务不是在 beforeEach 内部手动创建的,而是使用 Angular 的 $provide.service 方法创建的。
一旦我们注入了它,我们就可以将其作为值提供给 $controller 调用,该调用会创建我们的购物清单控制器,并将注入的服务实例传递给它。
$controller('ShoppingListController', {
$scope: $rootScope.$new(),
ShoppingListService: ShoppingListServiceErrorMock
});
这与之前的方法完全相同,只是现在我们让 Angular 在 beforeEach 函数中注入它,而不是手动创建。
最后一部分是实际的测试方法。测试函数完全相同:我们调用 addItem,这会触发我们的 ShoppingListServiceErrorMock 抛出一个错误。该错误被转换,控制器应将错误消息设置为 "Test Message."。我们通过调用 expect 函数来验证情况确实如此。
保存后,我们可以看到两个测试规范都运行了,shoppingListController 的第二个版本测试也通过了,显示为绿色,零失败。






🧪 总结与要点
Angular 提供了 angular-mocks 模块来帮助我们进行 Angular 应用程序的单元测试。
要测试控制器,你需要执行以下步骤:
以下是测试控制器的核心步骤列表:

- 使用
angular.mock.module加载控制器所在的模块,传入你为应用程序开发的模块名称。 - 使用
$controller服务来实例化你要测试的控制器。 - 使用控制器实例来调用方法、访问属性等。
大多数测试设置都在 beforeEach 函数中完成。你至少应该调用 module 并传入模块名称,但可能还需要为你的测试或测试套件中所有测试共享的准备工作做更多设置。
创建这些模拟对象有几种方法,我们需要它们来控制测试运行的环境:
以下是创建模拟对象的两种主要方法:
- 一种方法是手动创建这些对象。
- 另一种方法是使用可以注入的
$provide服务,但你必须通过之前提到的module方法来注入它。
对于其他服务,你可以使用常规的 angular.mock.inject 方法,它们工作得很好,因为它们类似于常规服务。而 $provide 真正涉及的是我们之前在使用 .config 方法时讨论过的提供者服务,只有提供者服务才能被注入到那里。


本节课中我们一起学习了如何为 AngularJS 控制器编写单元测试,重点掌握了使用 $provide 服务创建模拟依赖以及在 beforeEach 中设置测试环境的方法。理解这些技术对于构建可靠且可测试的 AngularJS 应用至关重要。
097:测试 AngularJS 服务和 $http


在本节课中,我们将要学习如何测试 AngularJS 中的服务,特别是那些使用 $http 服务与服务器通信的服务。我们将了解如何利用 Angular 提供的 $httpBackend 模拟服务来避免真实的网络请求,从而编写可靠且独立的单元测试。
概述
测试 Angular 服务非常简单。然而,对于那些需要通过网络与服务器端数据交互的服务,测试会变得复杂。幸运的是,Angular 提供了一个特殊的 $httpBackend 服务来解决这个问题。这个服务能够拦截对服务器的调用,并允许你模拟服务器的响应,整个过程无需发起任何真实的网络请求。
显然,让测试代码去调用真实的后端服务并进行网络通信是不可取的。因为测试的成功与否将取决于另一个服务是否正常工作、网络性能如何,甚至测试机器是否连接到了网络。
测试服务的基本设置
上一节我们介绍了测试服务的基本概念,本节中我们来看看具体的设置步骤。我不会重复整个测试框架的搭建过程,因为你已经知道如何操作。在本讲中,我们将重点关注测试一个 Angular 服务(尤其是使用 $http 服务的服务)所需的关键部分。
首先,我们需要获取 Angular 的注入器服务。这是 Angular 在幕后为我们执行依赖注入的机制,我们也可以用它来从模块中获取已注册的服务实例。
我们通过注入器服务的 get 方法,并传入我们向模块注册服务时使用的字符串名称来获取服务实例。
var myService = $injector.get('myServiceName');
如果我们的服务只提供简单的工具方法或仅用于数据共享,并且不尝试通过网络与服务器通信,那么测试到这里就可以结束了。在测试方法中,我们可以直接调用服务的方法并验证结果。
处理使用 $http 的服务
然而,如果服务使用了 $http 服务,我们就需要获取 Angular 提供的该服务的模拟版本。以下是获取 $httpBackend 服务的示例:
var $httpBackend;
beforeEach(inject(function(_$httpBackend_) {
$httpBackend = _$httpBackend_;
}));
现在,我们就可以在测试中使用我们的服务和 $httpBackend 了。
配置模拟响应与执行测试
以下是测试中使用 $httpBackend 的核心步骤:
在测试方法中,首先要做的是告诉 $httpBackend 服务期望什么样的 HTTP 调用:访问哪个 URL,以及如何响应这个调用。我们使用 when 系列方法(如 whenGET)来完成这个配置。
$httpBackend.whenGET('/api/someData').respond(200, { data: 'mockData' });
$httpBackend 服务有许多方法,对应不同类型的 HTTP 请求(GET, POST, PUT, DELETE 等),具体可以参考官方文档。
配置好模拟响应后,我们就可以像平常一样调用服务的方法,并测试预期结果。
myService.getData().then(function(response) {
expect(response.data).toEqual('mockData');
});
刷新待处理请求
在涉及 $httpBackend 服务的测试中,我们必须做的最后一件事是调用它的 flush 方法。
在实际应用中,$http 服务总是异步响应的。如果我们 100% 地保留这种行为,就需要编写异步测试,但这并非易事。另一方面,我们也不希望测试完全同步执行,因为我们测试的本来就是异步行为,改变这一点会改变真实代码运行的条件,可能影响测试结果的完整性和准确性。
折中的办法是:我们保持调用是异步的,但在设置好模拟响应后,立即告诉 $httpBackend 服务刷新所有待处理的请求。这样既保留了代码异步执行的能力,又允许测试同步执行。
$httpBackend.flush();
总结


本节课中我们一起学习了如何测试 AngularJS 中的服务。我们了解到,对于使用 $http 的服务,可以通过 Angular 内置的 $httpBackend 服务来拦截和模拟 HTTP 请求,从而避免对真实网络和后台服务的依赖。关键步骤包括:获取服务和 $httpBackend 实例、使用 when 方法配置期望的请求和模拟响应、执行服务方法并断言结果,最后不要忘记调用 flush() 方法来立即处理异步请求。这种方法确保了我们的单元测试是独立、快速且可靠的。
098:测试 AngularJS 服务和 $http


在本节课中,我们将学习如何测试一个依赖 $http 服务的 AngularJS 服务。我们将通过一个具体的例子,测试一个名为 MenuCategoriesService 的服务,该服务负责从服务器获取菜单分类数据。
概述
我们将要测试的应用程序代码来自课程的第 25 讲。核心是一个控制器,它使用 MenuCategoriesService 服务。该服务通过注入的 $http 服务和一个名为 ApiBasePath 的常量,向一个特定的 API 端点发起请求以获取菜单分类数据。本节课的目标是编写测试,验证 MenuCategoriesService 中的 getMenuCategories 方法能够正确设置并返回服务器的响应数据。
测试环境与代码结构
我们位于 lecture45 文件夹中,该文件夹包含从第 25 讲直接复制过来的完整应用程序代码。我们将在 menu-categories.service.spec.js 文件中编写测试。
首先,让我们了解一下待测试的服务。MenuCategoriesService 依赖于 $http 服务和 ApiBasePath 常量。它的 getMenuCategories 方法会向 ApiBasePath + ‘/categories.json’ 这个 URL 发起 GET 请求。
测试设置
以下是测试文件 menu-categories.service.spec.js 中的初始设置步骤。
我们使用 beforeEach 块来初始化测试环境。首先,加载待测试的 AngularJS 模块。
beforeEach(module(‘menuCategoriesApp’));
接着,我们注入 $injector 服务。通过它,我们可以获取 AngularJS 上下文中的各种服务,包括我们要测试的 MenuCategoriesService 本身、用于模拟网络请求的 $httpBackend 以及 ApiBasePath 常量。
var $injector;
beforeEach(inject(function(_$injector_) {
$injector = _$injector_;
}));
var MenuCategoriesService, $httpBackend, ApiBasePath;
beforeEach(function() {
MenuCategoriesService = $injector.get(‘MenuCategoriesService’);
$httpBackend = $injector.get(‘$httpBackend’);
ApiBasePath = $injector.get(‘ApiBasePath’);
});
注意,我们不需要手动为 MenuCategoriesService 提供其依赖项($http 和 ApiBasePath),AngularJS 的依赖注入系统会自动处理。
编写测试用例
上一节我们完成了测试环境的设置,本节中我们来看看如何编写具体的测试用例。我们将测试 getMenuCategories 方法。
我们计划测试一个场景:当服务调用 getMenuCategories 方法时,它应该向正确的 URL 发起请求,并成功返回我们预设的模拟数据。
以下是具体的测试步骤:
- 使用
$httpBackend的whenGET方法,拦截对特定 URL 的 GET 请求,并定义其返回的模拟响应数据。 - 调用待测试的
MenuCategoriesService.getMenuCategories()方法。 - 由于
$http返回的是 Promise,我们在其then回调中验证返回的响应数据是否与模拟数据一致。 - 最后,调用
$httpBackend.flush()立即执行所有挂起的模拟请求,使异步调用表现得像同步一样,便于断言。
it(‘should return list of categories’, function() {
// 1. 设置模拟响应
var mockResponse = [‘Lunch’, ‘Dessert’];
$httpBackend.whenGET(ApiBasePath + ‘/categories.json’).respond(mockResponse);
// 2. 调用待测试方法
MenuCategoriesService.getMenuCategories().then(function(response) {
// 3. 验证响应数据
// 注意:此服务返回的是整个响应对象,数据在 response.data 属性中
expect(response.data).toEqual(mockResponse);
});
// 4. 触发模拟请求的响应
$httpBackend.flush();
});
保存测试文件后,在浏览器中运行测试运行器(例如 Karma),可以看到测试 “should return list of categories” 执行成功,没有失败。
总结
本节课中我们一起学习了如何测试一个 AngularJS 服务,特别是当它依赖 $http 服务进行网络请求时。我们掌握了以下关键点:
- 使用
beforeEach和module、inject函数来设置测试模块并注入所需服务。 - 利用
$httpBackend服务来模拟(Mock)HTTP 请求和响应,避免在测试中发起真实的网络调用。 - 编写测试用例来验证服务方法是否向正确的 URL 发起请求,并能正确处理返回的 Promise 和响应数据。
- 使用
$httpBackend.flush()来立即处理模拟的异步请求。



通过这种方式,我们可以独立、快速且可靠地测试 AngularJS 服务的业务逻辑。
099:测试 AngularJS 指令(第1部分)


在本节课中,我们将要学习如何测试 AngularJS 中的指令。指令是可复用的组件,在应用程序中被广泛使用,因此测试其功能至关重要。为了测试指令,我们的测试需要创建一个模拟环境,这个环境与指令通常运行的环境相似。让我们来看看具体涉及哪些步骤。
概述测试流程
测试指令的第一步是创建一个变量,用于存放我们期望的指令输出字符串。
接下来,我们将使用 mock.inject 方法注入两个服务。第一个服务我们非常熟悉,那就是 $rootScope 服务。我们的指令通常存在于某个作用域内,但和应用程序中的其他部分一样,它肯定位于顶级作用域(即 $rootScope)之内。在这个例子中,我们将模拟将指令作为带有 ng-app 属性的元素的直接子元素。
第二个服务我们之前没有见过,但讨论过它。还记得我们讨论过 Angular 如何转换我们的自定义标签和属性,并将行为附加到它们上面,将它们扩展成不同的 HTML 模板,并将其他模板插入到某些占位符中吗?所有这些工作都是通过编译过程完成的。
理解编译服务
Angular 实际上会获取我们的源 HTML,并使用 JavaScript 进行编译。换句话说,它将所有特殊语法(如指令、组件、插值)转换为任何浏览器都能理解的 DOM(文档对象模型),并将功能与这些组件关联起来。
Angular 使用 $compile 服务来完成所有这些工作。我们需要这个服务来自行完成编译,从而生成指令的最终结果,并检查它是否符合我们的预期。请注意,我们再次使用了下划线技巧来保存对服务的引用,因为服务通常以此命名。
处理指令模板
如果你的指令没有模板,或者其模板直接嵌入在指令定义对象中,可以跳过这一步。然而,如果指令使用 templateUrl 属性引用了一个模板文件,那么这一步是必需的。
当指令使用 templateUrl 时,Angular 会使用 Ajax 异步请求模板文件。这对我们来说是个问题,因为我们不想在测试中处理异步行为。我们可以使用 $httpBackend 服务来模拟响应,但那样我们最终会将模板的 HTML 硬编码到测试中。硬编码会使我们的测试固守于当前版本的模板。如果明天我们决定更新模板,即使只是很小的改动,我们的测试也会与需要测试的内容不同步。
另一个选择是使用 $templateCache 服务。当 Angular 加载指令或组件的模板时,它首先会检查该模板是否已存在于 $templateCache 服务中。如果存在,Angular 会直接使用缓存的版本,而不会尝试再次发出异步请求。如果不在缓存中,它才会发出请求。这意味着,如果我们手动将模板放入 $templateCache,我们就不必处理任何异步请求。
现在,我要提前说明,实现这一目标的最佳方式是使用 Karma。Karma 是一个安装在您机器上的程序,允许您在命令行中运行这些测试。然而,本课程不涵盖 Karma,因此我们将使用一个阻塞的 HTTP 请求将指令模板加载到缓存中。这有点取巧,但目前是可行的。
我们通过向 open 函数的最后一个参数传递 false 来禁用异步请求。
最后一步是使用指令的 templateUrl 属性值作为键,将检索到的模板内容放入 $templateCache。Angular 将查找这个键,以查看该模板或其内容是否已在缓存中。
执行测试步骤
在测试方法中,我们首先要做的是将一个带有名为 item 的属性的 item 对象放到 $rootScope 上。这是我们的指令期望存在的数据,以便它可以作为属性传递给我们的指令。
然后,我们像在 index.html 或其他 HTML 模板中直接放置该指令一样,创建指令的 HTML。
下一步是编译我们的指令 HTML。请注意,$compile 服务本身返回一个函数。这个函数需要一些作用域作为参数,以便将数据与新编译的 HTML 绑定。因此,我们给它提供包含指令工作所需的 item 属性的 $rootScope。
正如您可能从关于 $digest 和 $watch 服务的讨论中记得的那样,Angular 应用程序初始化时发生的第一件事就是运行 $digest 循环。这是为了启动所有已设置的监视器,并用数据更新视图所必需的。
由于我们不是启动整个应用程序,而只是进行测试,因此需要手动启动这个过程。这就是为什么我们在 $rootScope 上调用 $digest 函数。在此调用之后,生成的 HTML 会使用数据进行更新,并准备好进行期望测试。
现在,我们可以通过引用我们编译并数据绑定的 elem 变量,然后调用其上的 html() 方法来提取 HTML。此时,我们可以测试 HTML 字符串,并验证它是否符合我们的预期。
总结


本节课中,我们一起学习了测试 AngularJS 指令的基本流程。我们了解了需要模拟 $rootScope 和 $compile 服务来创建测试环境,并探讨了如何处理使用 templateUrl 的指令模板,以避免异步请求。最后,我们回顾了编译 HTML、绑定数据、手动触发 $digest 循环以及验证最终 HTML 输出的完整测试步骤。掌握这些知识对于确保指令在整个应用中的正确功能至关重要。
100:测试 AngularJS 指令


在本节课中,我们将学习如何为 AngularJS 指令编写单元测试。我们将通过一个具体的例子,演示如何模拟指令的依赖环境、编译指令模板,并验证其最终生成的 HTML 是否符合预期。
概述
我们将测试一个名为 shoppingList 的指令。该指令使用一个外部模板文件,并依赖于父作用域中的 title 和 list.items 数据。我们的目标是编写一个测试,确保指令能正确地将这些数据渲染到最终的 HTML 中。
测试环境搭建
首先,我们需要在测试文件中设置必要的 AngularJS 服务和模块。
以下是测试的基本结构,我们在 beforeEach 块中注入了 $compile 和 $rootScope 服务,并加载了应用模块。
describe('shoppingList Directive', function() {
var $compile, $rootScope;
beforeEach(module('shoppingListDirectiveApp'));
beforeEach(inject(function(_$compile_, _$rootScope_) {
$compile = _$compile_;
$rootScope = _$rootScope_;
}));
});
上一节我们介绍了测试的基本结构,本节中我们来看看如何处理指令使用的外部模板。
处理外部模板
由于我们的指令使用了 templateUrl 属性,AngularJS 在测试时会尝试通过 Ajax 请求获取模板文件。为了避免这种真实的 HTTP 请求,我们需要将模板内容预先存入 $templateCache 服务中。
以下是使用一种方法(同步 Ajax)将模板内容存入缓存:
beforeEach(inject(function($templateCache) {
var directiveTemplate = null;
// 这是一个同步请求,用于获取模板文件内容
$.ajax({
url: 'shopping-list.html',
success: function(data) {
directiveTemplate = data;
},
async: false // 使其同步
});
// 将模板内容存入缓存,键名必须与 templateUrl 匹配
$templateCache.put('shopping-list.html', directiveTemplate);
}));
注意:在生产测试中,更推荐使用命令行测试运行器(如 Karma)来预加载模板文件,而不是使用这种同步 Ajax 的“技巧”。
编写测试用例
现在,我们可以编写具体的测试逻辑。测试的核心步骤是:创建作用域数据、编译指令、触发数据绑定,最后验证生成的 HTML。

以下是测试用例的具体实现:
it('应使用正确的内容替换元素', function() {
// 1. 设置指令所需的作用域数据
var list = {
items: [
{ name: 'item1', quantity: 'quantity1' },
{ name: 'item2', quantity: 'quantity2' }
]
};
$rootScope.list = list;
$rootScope.title = '测试标题';
// 2. 编译指令。指令字符串应与在 HTML 中使用的方式一致。
var element = $compile('<shopping-list title="title" list="list"></shopping-list>')($rootScope);
// 3. 触发所有监听器,将作用域数据应用到编译后的元素上
$rootScope.$digest();
// 4. 获取最终生成的 HTML 并进行断言
var generatedHtml = element.html().replace(/\s+/g, ' ');
var expectedHtml = '<h3>测试标题</h3>...'; // 此处应为预期的完整HTML字符串
expect(generatedHtml).toContain(expectedHtml);
});
测试步骤总结

本节课中我们一起学习了测试 AngularJS 指令的完整流程。为了清晰地回顾,以下是测试指令通常需要遵循的步骤:
- 注入服务:在
beforeEach中使用inject方法注入$rootScope和$compile服务。 - 缓存模板:如果指令使用
templateUrl,需将模板内容预加载到$templateCache中。 - 设置作用域:在测试方法内,将指令运行所需的数据属性附加到
$rootScope上。 - 编译与绑定:使用
$compile服务编译指令字符串(即 HTML 标签),然后调用返回的函数,并传入$rootScope以绑定数据。 - 触发更新:调用
$rootScope.$digest()来触发数据绑定,更新编译后的 HTML 内容。 - 进行断言:从编译后的元素中获取
.html(),与预期的“基准”HTML 字符串进行比较,以验证指令渲染是否正确。
通过遵循这些步骤,你可以为任何 AngularJS 指令创建可靠且可维护的单元测试。
101:测试 AngularJS 组件(第1部分)


在本节课中,我们将要学习如何为 AngularJS 组件编写单元测试。与指令类似,组件在应用程序中也会被重复使用。鉴于基于组件的架构正变得越来越流行,我们需要习惯测试 Angular 组件。幸运的是,ngMock 模块提供了一个名为 $componentController 的辅助服务,它允许我们以一种易于进行单元测试的方式来创建组件控制器。接下来,让我们看看具体的语法。
测试设置与语法
上一节我们介绍了测试组件的重要性,本节中我们来看看如何使用 $componentController 服务来设置测试。
首先,在使用 module 方法加载我们的应用模块(例如 myApp)之后,我们必须使用 inject 方法来注入 $componentController 服务。
beforeEach(module('myApp'));
beforeEach(inject(function(_$componentController_) {
$componentController = _$componentController_;
}));
一旦我们获得了 $componentController 服务的引用,我们会将其保存在一个变量中,以便所有测试方法都能访问它。现在,我们就可以编写测试方法了。
创建组件控制器实例
以下是创建组件控制器实例并进行测试的步骤。
首先,如果我们的组件控制器期望任何绑定属性,我们需要设置这些绑定。在本例中,我们假设组件控制器期望一个通过名为 prop1 的绑定属性引用的对象。
var bindings = {prop1: {}};
接下来,使用 $componentController 服务创建负责我们组件的控制器。第一个参数是我们在应用模块中声明的组件名称。第二个参数是我们的组件期望的依赖注入项。在本示例中,我们的组件不期望任何注入项。
var ctrl = $componentController('myComponent', null, bindings);
最后,我们传递绑定对象,即我们专门为此测试创建的模拟绑定对象。
执行测试与验证
最后一步很简单。我们可以调用控制器的逻辑,或者简单地检查其某个属性是否正确初始化,这正是我们在这个案例中所做的。
expect(ctrl.someProperty).toBeDefined();
现在,我们只需要验证关于该属性的期望,测试就完成了。让我们回到代码编辑器,为我们的一个组件编写一个简单的测试。
课程总结


本节课中我们一起学习了如何为 AngularJS 组件编写单元测试。我们介绍了使用 ngMock 模块中的 $componentController 服务来实例化组件控制器、设置模拟绑定属性以及执行基本断言的方法。掌握这些技巧对于确保基于组件的应用程序的可靠性至关重要。
102:测试 AngularJS 组件


在本节课中,我们将学习如何为 AngularJS 组件编写单元测试。我们将通过一个具体的例子,测试一个购物清单组件中的方法,以确保它能准确检测列表中的特定项目。
我们回到代码编辑器,位于第 47 讲的 fullstack-course5/examples 文件夹中。从 readme.txt 文件可知,此处的代码源自第 33 讲,当时我们将 AngularJS 版本从 1.5.7 升级到 1.5.8,以便使用 Angular 在控制器和组件控制器中引入的生命周期方法。现在可以关闭这个文件。
让我们查看 app.js 文件。这里有一个名为 shoppingList 的组件,其控制器是 ShoppingListComponentController。如果你查看控制器,会发现一个名为 cookiesInList 的方法。这个方法的作用是检查所有项目,并判断是否有任何项目的名称中包含单词 “cookie”。如果存在,则返回 true,否则返回 false。我们需要测试这个方法,以确保它能精确地检测到 “cookie”。
接下来,我们查看 shopping-list.component.spec.js 文件。
以下是测试的准备工作。首先,我们将组件控制器服务保存到变量 componentController 中。然后,加载包含我们组件的模块 ShoppingListComponentApp。最后,注入 $componentController 服务,这让我们能方便地获取组件控制器服务。
var componentController;
beforeEach(module(‘ShoppingListComponentApp‘));
beforeEach(inject(function (_$componentController_) {
componentController = _$componentController_;
}));
现在,我们准备开始测试。测试分为两部分:一部分测试不含 “cookie” 的情况,另一部分测试包含 “cookie” 的情况。
以下是测试不含 “cookie” 情况的代码。我们首先创建一个 bindings 对象,因为我们的组件期望一个名为 items 的单向绑定属性。我们提供一个项目,其名称为 “item 1”,数量为 1。接着,我们通过 componentController 服务创建控制器实例,传入组件名称 ‘shoppingList’、它期望注入的服务(这里我们传入一个空对象 {} 以避免错误)以及绑定对象。最后,我们调用 cookiesInList 方法,并期望它返回 false。
it(‘should detect no cookies in list‘, function () {
// 设置绑定对象
var bindings = {
items: [{ name: ‘item 1‘, quantity: 1 }]
};
// 创建控制器实例
var ctrl = componentController(‘shoppingList‘, {}, bindings);
// 执行方法并断言
expect(ctrl.cookiesInList()).toBe(false);
});
第二个测试与第一个类似,但不同之处在于我们提供的项目名称是 “2 cookies”。因此,当我们调用 cookiesInList 方法时,期望它返回 true。
it(‘should detect cookies in list‘, function () {
// 设置绑定对象,包含“cookie”
var bindings = {
items: [{ name: ‘2 cookies‘, quantity: 1 }]
};
// 创建控制器实例
var ctrl = componentController(‘shoppingList‘, {}, bindings);
// 执行方法并断言
expect(ctrl.cookiesInList()).toBe(true);
});
回到浏览器并运行这些测试,可以看到两个测试都通过了:一个检测到不含 “cookie”,另一个检测到包含 “cookie”。这表明我们的测试有效,组件控制器也工作正常。

上一节我们完成了具体的测试案例,本节我们来总结测试 AngularJS 组件的一般步骤。


以下是测试组件的基本流程:
- 在
beforeEach方法中,使用module函数加载包含待测组件的模块。 - 在另一个
beforeEach方法中,使用inject函数注入$componentController服务。 - 在具体的
it测试块中,首先设置组件期望的绑定对象(如果有的话)。 - 然后,设置组件控制器期望被注入的服务对象(如果有的话)。
- 接着,使用
$componentController服务创建控制器实例,传入组件名称、注入对象和绑定对象。 - 最后,获取控制器实例,执行其方法或检查其属性,并根据你的预期进行断言。


本节课中,我们一起学习了如何为 AngularJS 组件编写单元测试。我们通过一个购物清单组件的例子,演示了如何设置测试环境、创建控制器实例以及测试组件方法。掌握这些步骤,你就能为自己的 AngularJS 组件编写有效的单元测试了。
103:客户拜访 📝


在本节课中,我们将学习如何为真实客户进行需求访谈。这是构建网站项目的第一步,我们将探讨与客户沟通的核心原则和实用技巧,以确保项目顺利启动。
接下来的讲座(第一部分和第二部分)摘自我之前的课程《面向 Web 开发者的 HTML、CSS 和 JavaScript》。如果你已经上过那门课并看过这些视频,或者只想继续编写代码,可以跳过它们。这些内容对本课程并非必需,但它们具有相关性,因为你会见到我们将为之制作网站应用的餐厅老板,学习一些自由职业者进行客户访谈的技巧,但主要还是观看过程很有趣。
在第一节中,我将介绍进行客户访谈的一些原则。在第二节中,我们将实地探访餐厅。希望你喜欢。
现在,我们准备开始为真实客户创建真实网站的冒险。在我的案例中,我联系了一家名为“David Chu‘s China Bistro”的本地餐厅,我们将为他们建立一个餐厅网站。在去拜访客户之前,我们先来了解一些规则和基本思路,这样你就不会完全摸不着头脑。显然,这不会是全面的商业策略或全面的客户应对指南,而是一些基本规则,帮助你理解我们的出发点。
你需要记住的第一个概念是:大多数客户并不知道他们想要什么。这不是因为他们无知,事实上,他们对自己的业务非常了解,但他们通常确实不知道网站上应该有什么,以及整个事情如何运作。因此,你的工作实际上是提出问题,最重要的是提出正确的问题,以帮助客户弄清楚他们想要什么,并将其传达给你。
一个很好的方法是,你带来一些已有网站的类似企业的网站示例,让客户对这些网站做出反应。你可以向他们展示几个网站,看看他们是否喜欢这个,是否喜欢那个,他们喜欢这个网站的哪些方面,不喜欢那个网站的哪些方面。当你把足够多的例子摆在他们面前时,你就会开始感觉到他们会满意什么,以及他们实际上为自己的产品网站寻找什么。
作为一般规则,每个客户都希望在页面上放置大量关于其业务的信息。这是有道理的,因为作为客户,你应该了解他们业务的许多方面,其中很多都非常重要。他们会告诉你这个也很重要,他们希望这个非常醒目、放在前面;那个也很重要,但他们希望放在侧边;还有更多事情很重要。这是一个非常重要的提醒:一切都会变得非常重要。这是客户相当常见的做法。
这完全是人之常情。唯一的问题是,当一切都重要时,就没有什么是真正重要的了。所以,这又是你的工作,你要鼓励客户遵循一个非常简单的规则:少即是多。你放上的信息越少,这些信息的影响力就越大。关键是,你应该鼓励你的客户不要在网站上塞满信息。事实上,你应该努力帮助客户做的是,识别出最关键、最重要、最有冲击力的信息,而不是把所有可能知道的关于业务的信息都塞进去。
我想与你分享的另一个快速技巧是,你应该始终尝试找到一种方法,让客户对项目进行投资。如果你只是为了建立作品集而免费做这件事,这一点尤其正确。客户需要感觉到他们自己也投入了一些东西。如果他们不投入,最终会发生的情况是,你将成为他们并不太关注的某个次要项目,你将花费和投入自己的所有时间,却不一定能从他们那里得到多少合作。所以,即使你是免费做这件事,也要让他们做点什么,至少承诺支付产品摄影费用(如果适用的话),或者承诺提供你需要用于演示的产品。或者,如果你找到一位愿意免费做这件事的摄影师(我稍后会谈到),至少他们会提供产品,并且他们将在项目中投入一些实际的、真实的资金,即使金额不是特别巨大。
只要他们实际上在花费一些东西,他们至少会有一些投资,他们会更认真地对待整个项目。
另一个重要规则是:确保客户指定一个人负责决策。如果你不这样做,会发生的情况是,这个人会告诉你他们真的想要这样,而企业里的另一个人会告诉你他们想要不同的东西。如果你有一个指定的人,你总是可以告诉其他提建议的人,这是一个很好的建议,但我对这个特定的人负责,我会把这个建议带给那个人,如果那个人批准了,我将很乐意采纳它。这引出了另一点:预先限制修改次数。
确保你提前与客户沟通,甚至可以签订合同,让他们签字同意你愿意进行的修改次数。因为显然,客户可以无休止地要求修改,而你的时间是有限的。如果是有报酬的工作,不要直接限制修改,但要限制免费修改的次数。例如,你可以说,我将在合理范围内(不是完全推翻重做)免费修改网站最多三次,之后将按小时收费。你可以根据自己的情况来安排结构。
关于具体要问客户什么,我强烈建议你谷歌搜索“Web development client questionnaire”(网站开发客户问卷),你会看到大量资源,可以从中学习如何提出这些问题以及寻找什么样的答案,只是为了了解一下这种互动是什么样的,如果你以前从未自己做过这件事的话。
我想与你分享的另一个快速技巧是,如果需要,你应该尝试让其他人参与进来。例如,如果你不是一个超级厉害的平面设计师,而我们真正专注于编码,而不是设计本身,你可以联系当地大学,看看是否有平面设计专业的学生愿意加入你,提供一些免费的设计服务,并把这段经历放在他们的简历上。你将负责编码,他们将负责设计,等等。如果你正在尝试创建的网站需要一些摄影,比如产品摄影,你也可以做同样的事情。你可以找一位愿意免费工作的年轻、崭露头角的摄影师,只要他/她被提及和署名,并且能够把这段经历放在他/她的简历上。这样,你将能够收集一些专业资源,而不会觉得你必须自己完成所有事情才能制作出看起来相当专业的东西。
在我的案例中,对于 David Chu‘s China Bistro,我相当幸运。我的妻子恰好是一名平面设计师,所以她将帮助我进行网站布局的初步设计。我的儿子恰好是一名摄影师,至少是初出茅庐的摄影师,他非常愿意提供食物摄影服务。现在,我将遵循我自己的建议。我仍然会让餐厅老板对这个项目进行投资,他们投资的方式是同意提供菜单上的每一道菜,以便我们能为这些菜品拍摄合适的照片。这是一项相当可观的投资,因为他们必须在营业时间后让员工留下来,基本上烹饪菜单上的每一道菜并提供给我们。所以这是他们数小时的工作,但这表明客户参与其中并且客户是投入的,这非常重要。
现在,另一件事是,在与客户会面之前,一定要先了解客户目前拥有什么,他们有什么样的网站,他们展示了什么信息,只是为了稍微了解一下这个业务。在我的案例中,David Chu‘s China Bistro 有以下网站,让我们来看一下。
好的,这就是他们的网站。它显然是放在某个免费服务上的,也许不是免费的,但直接看他们的电话号码,那就是他们的域名。你可以看到,它实际上就是一张图片。如果我们看一下,看起来是的,就是一张图片,是他们菜单的图片。我一眼就能看出他们的菜单有不同的类别,我想这很标准。

但这个网站在用户体验方面肯定相当糟糕,当然也没有展现出这个业务及其全部魅力。所以,我们将继续努力解决这个问题。
那么,让我们总结一下。首先,不要忘记带一些其他网站的示例,以帮助你的客户弄清楚他们想要什么。客户通常真的不明白他们自己想要什么,给他们一些例子将有助于引发讨论,并确定他们真正想要的方向。


同时,鼓励你的客户使用更少的信息。对于几乎所有非业内人士来说,试图塞入尽可能多的信息是一种本能反应,但这几乎总是错误的方式。拥有更少但更切中要害的信息,比拥有大量信息但所有信息都迷失在其中要好。
想办法让你的客户对项目进行投资,无论是支付你一点费用,还是预先承诺支付某些费用,比如摄影费或提供他们的产品。但必须是他们所谓的“skin in the game”(利益共享),这样他们也会认真对待这个项目。如果你只是为了建立作品集而免费做这件事,这一点尤其正确。

非常重要的一点是,客户应该指定一个人作为网站内容(或非内容)以及项目中一切事务的决策者。如果你有不止一个人,混乱就会开始,你将花费太多时间处理与编码和网站无关的事情,而只是管理这些本不该存在的关系。就让一个人,让客户指定一个人作为最终决策者。
同时,限制修改次数。不要让客户不断地修改再修改。如果你是免费做这件事,当然要限制在一个合理的次数,比如三次。但如果你是有偿服务,那么限制免费修改三次,之后任何额外的修改,客户实际上必须付费。
另一件事是,让他人参与进来,以帮助制作出优秀的产品。很少有人能一个人包揽所有事情,既懂产品摄影,又懂布局设计。让其他人参与进来,让他们帮助你,这样你就可以专注于编码。
接下来,让我们去拜访实际的餐厅,见见老板,弄清楚他们想要什么样的网站。这意味着实地考察。


本节课中,我们一起学习了与客户进行初次访谈的关键原则。我们了解到客户往往不清楚自己的具体需求,因此需要开发者通过展示示例、提出引导性问题来帮助他们明确目标。核心要点包括:遵循“少即是多”的原则,避免信息过载;确保客户对项目有所投入以增加其重视程度;指定唯一的决策联系人以提高效率;以及预先设定修改次数以避免无休止的改动。我们还讨论了在必要时引入其他专业人士(如设计师、摄影师)合作的可能性。这些步骤为项目的顺利启动和高效沟通奠定了基础。下一节,我们将实地探访餐厅,应用这些原则进行真正的客户访谈。
104:客户拜访

在本节课中,我们将跟随课程团队进行一次实地客户拜访。我们将见到餐厅老板 Amy,了解她对网站的需求,并尝试从顾客那里获取对餐厅的独特视角。本节的核心在于学习如何与客户沟通并明确项目预期。
拜访准备与目标
我们已准备好进行实地拜访。我们将与餐厅老板 Amy 会面,了解她希望网站具备哪些功能,并尝试采访一些顾客,从顾客视角了解这家餐厅。
当我们深入探讨时,这次会议的核心在于设定预期。无论是客户对我们的预期,还是我们对客户将提供支持的预期,如果能清晰地沟通这些预期,我们的任务就完成了。
采访顾客
以下是部分顾客的反馈:
- 顾客A:我经常来这家餐厅,大约每月一两次。我喜欢这里的氛围和服务,食物是符合犹太洁食标准的,而且品质始终如一,非常美味。我常点左宗棠鸡,分量很足,吃不完可以打包。
- 顾客B:食物非常出色。我知道他们评分很高,所以我总是相信食物会很新鲜、美味。
- 顾客C:我是约翰霍普金斯大学1964届的学生。我爱这里。
与客户(Amy)的沟通
你好,准备好开会了吗?如果让你做一点自我批评,你觉得餐厅有什么需要改进的地方吗?
是的,需要一个更好的网站。这是个非常好的回答。
我为你准备了一些其他中餐厅的网站作为参考。请看看这些网站,告诉我你是否喜欢它们,或者你希望自己的网站与它们有相似之处。你可以来回滚动查看。如果你喜欢某个网站,具体喜欢它的哪些方面?什么吸引了你?或者你不希望自己的网站出现哪些元素?
- 关于设计偏好:我喜欢这个网站的配色,非常清晰。它看起来非常简洁、干净。菜单项排列得……非常用户友好,看起来非常实用。所以你喜欢这种类型的布局。显然,我们不会为你的网站使用完全相同的颜色,因为你的网站颜色会与餐厅主题更匹配,但你喜欢这种布局风格。
- 关于决策流程:在网站开发过程中,我们需要做出一些决策,比如内容摆放位置。是否会有一个专人负责本项目相关的所有决策?这个人会是你吗?你会是主要的决策者并且保持联系畅通吗?这样如果我们需要就某些问题做决定,可以联系你并获得答复,而不是等待一两周。
- 关于开发流程:我向你解释一下大致的流程。首先,我们会创建一个网站的模型图,不是真正的网站,只是一个设计图稿。我们会展示给你看,基本上需要你认可这个设计方向。我们会提供大约三次修改机会,你可以提出调整意见。之后,我们会尝试定稿。你对这个流程安排可以接受吗?
- 关于内容素材:你看到参考网站上的图片,并且很喜欢有图片这一点。对于这类网站,图片通常非常重要。你能否承诺提供完整的餐厅菜单?这样我们可以带摄影师来,为菜单上的每一道菜拍摄照片。你同意吗?好的,我们会带摄影师来,你需要准备好菜单上的每一道菜品,我们会端出来让摄影师拍照。
- 关于餐厅特色:这是一家犹太洁食餐厅,对吗?这对你来说,在烹饪和处理食材方面意味着什么?你们这里有很多选择吗?我和不少顾客聊过,不是每个人都愿意上镜,但每个人都喜欢你们的食物。
- 关于个人喜好:你自己喜欢中餐吗?当然。你经常吃中餐吗?当然。即使你每天都在餐厅看到中餐,也不会吃腻吗?从不厌倦中餐。
- 关于餐厅氛围:这家餐厅实际上很舒适,虽然空间不大,但感觉温馨、放松。这是你有意营造的氛围吗?
拜访结束与后续
现在,我们准备离开餐厅,开始设计和编写网站代码。但在开始之前,最重要的一条规则是:永远不要在饿着肚子的时候写代码。
我知道你非常渴望开始编写网站代码,但我有点忙,马上回来。我们之后再做网站。
总结

本节课中,我们一起完成了对客户的实地拜访。我们通过采访顾客了解了餐厅的受欢迎之处,并与客户 Amy 就网站的设计偏好、决策流程、开发阶段以及内容素材准备等关键期望进行了明确沟通。清晰的沟通是项目成功的重要基石,为后续的设计与开发工作奠定了良好基础。
105:非AngularJS网站概览 🎯


在本节课中,我们将回顾一个在之前的课程中构建的、未使用AngularJS的网站。我们将分析其功能实现,特别是如何通过原生JavaScript和Ajax来动态加载内容,为后续使用AngularJS重构打下基础。
网站概览与回顾
在开始使用AngularJS构建整个网站之前,我们先来看看在上一门课程(《面向Web开发者的HTML、CSS和JavaScript》)中完成的网站版本。该课程的重点是HTML、CSS、响应式设计以及JavaScript和Ajax的基础知识。虽然我们构建的网站并不复杂,但其效果良好,客户也很满意。
我已将上一门课程中该网站的最新版本复制到了本讲座(第49讲)的文件夹中,并启动了浏览器同步服务。现在,让我们在浏览器中查看这个网站。
网站功能演示
网站是完全响应式的。当我们缩小浏览器窗口时,页面元素会重新排列,图标和图片会变小,某些元素会隐藏。最终,在移动端视图中,会出现一个“汉堡包”菜单按钮。
点击该按钮可以展开完整菜单。我们可以点击任意菜单项,查看特定菜品的价格、图片以及餐厅内部使用的ID(例如,可以点“2个A1”表示两份汤)。
观察URL地址栏,你会发现网站几乎没有路由功能。点击浏览器的后退按钮,URL中的#号会消失,但页面实际上仍停留在汤类菜单的界面。要返回主页,必须点击页面上的主页按钮或Logo链接。这表明,当前的路由功能是缺失的。
页面结构分析

接下来,我们查看页面的HTML源代码。除了<head>和页眉部分,在页眉之后有一个用于移动端视图的大电话按钮,接着是一个ID为main-content的<div>占位符,最后是页脚。
页眉和页脚之间的所有内容都动态插入到这个main-content容器中。这是通过Ajax技术实现的:先向服务器请求数据,然后找到页面中的这个特定元素并插入内容。
核心功能代码解析
现在,让我们将注意力从布局和CSS转移到功能实现上,即我们使用原生JavaScript编写的代码。
在代码编辑器中,可以看到两个关键文件:ajax-utils.js和script.js。这两个文件是我们为实现网站功能而编写的。
Ajax工具函数
ajax-utils.js文件的核心是一个名为sendGetRequest的方法,其余代码主要是为了支持这个方法。
以下是该函数的核心逻辑(用伪代码表示其结构):
function sendGetRequest(requestUrl, responseHandler, isJsonResponse) {
// 1. 创建XMLHttpRequest对象
// 2. 设置onreadystatechange事件处理程序
// 3. 发起异步请求 (open(..., true))
// 4. 在回调中检查状态 (readyState == 4 && status == 200)
// 5. 根据isJsonResponse标志决定是否将响应文本解析为JSON对象
// 6. 调用传入的responseHandler回调函数,并传入处理后的数据
}
该函数接收三个参数:
requestUrl:要发起网络请求的URL。responseHandler:当服务器响应返回时需要执行的处理函数。isJsonResponse:一个标志,指示响应是否为JSON格式。有时我们请求的是数据(JSON),有时请求的是HTML模板。对于后者,我们需要将此标志设为false。
在函数内部,我们创建了一个闭包来包装responseHandler,以确保在异步请求完成时能正确调用它。如果isJsonResponse参数未定义,则默认设为true,这使得调用代码更简洁。
处理响应的逻辑是:
- 如果
isJsonResponse为true,则使用JSON.parse()将响应文本转换为JavaScript对象,然后传递给responseHandler。 - 如果为
false,则直接将响应文本(HTML内容)传递给responseHandler。
我们将利用这个机制来为应用程序中的特定视图检索HTML模板。
本节总结
在本节中,我们一起回顾了之前构建的非AngularJS网站。我们查看了其响应式界面,分析了其通过一个<div>容器和Ajax调用来动态加载内容的基本架构,并深入了解了实现该功能的sendGetRequest工具函数的核心逻辑。这个函数负责处理异步请求,并根据需要解析JSON或直接返回HTML。


在下一部分,我们将继续审查script.js文件,看看如何利用这个工具函数来具体实现网站的各项功能,从而更清晰地理解在AngularJS中这些功能将被如何替代和优化。
106:非AngularJS网站概览(第2部分)


在本节课中,我们将继续分析一个非AngularJS实现的单页应用网站,了解其手动处理数据、模板和导航的复杂过程。我们将重点关注其JavaScript代码如何模拟现代框架的功能。
上一节我们查看了网站的基本HTML结构,本节中我们来看看其核心的JavaScript逻辑是如何工作的。
代码结构与初始化
首先,我们打开 script.js 文件。代码被包裹在一个立即执行函数表达式(IIFE)中,并将 window 对象作为参数 global 传入。这样做的目的是将内部定义的函数暴露给全局作用域。
(function (global) {
// ... 所有代码
}(window));
在函数内部,我们定义了一个名为 $dc 的对象(代表餐厅名 David Chu‘s),所有功能都将作为该对象的方法。
var dc = {};
接下来,代码初始化了一些关键的URL变量。这里有一个重要的命名模式:
- 以
HTML结尾的变量指向本地HTML模板片段(snippets)。 - 以
URL结尾的变量指向远程服务器API,用于获取JSON数据。
var homeHtmlUrl = "snippets/home-snippet.html";
var allCategoriesUrl = "https://.../categories.json";
工具函数
以下是几个为了简化操作而创建的工具函数:
1. insertHtml 函数
此函数用于将给定的HTML字符串插入到指定选择器匹配的元素中。
function insertHtml(selector, html) {
var targetElem = document.querySelector(selector);
targetElem.innerHTML = html;
}
2. showLoading 函数
此函数在页面内容切换时,在主要内容区域显示一个加载动画,以提升用户体验。
function showLoading(selector) {
var html = "<div class='text-center'>";
html += "<img src='images/ajax-loader.gif'></div>";
insertHtml(selector, html);
}
3. insertProperty 函数
此函数实现了一个简单的“插值”功能,用于将模板字符串中的占位符(如 {{propertyName}})替换为实际的值。这模拟了AngularJS中数据绑定的一个基础功能。
function insertProperty(string, propName, propValue) {
var propToReplace = "{{" + propName + "}}";
var newHtml = string.replace(new RegExp(propToReplace, "g"), propValue);
return newHtml;
}
4. switchMenuToActive 函数
此函数管理导航菜单的激活状态,通过添加或移除 active CSS类来高亮当前选中的菜单项。
核心流程:页面加载与导航
当页面(DOM)完全加载后,会触发主要的事件处理逻辑。
加载首页
首先,代码调用 showLoading 显示加载图标,然后通过Ajax请求获取 home-snippet.html 模板文件的内容,并将其插入到ID为 #main-content 的主容器中。
document.addEventListener("DOMContentLoaded", function () {
showLoading("#main-content");
$ajaxUtils.sendGetRequest(
homeHtmlUrl,
function (homeHtml) {
document.querySelector("#main-content").innerHTML = homeHtml;
},
false // 表示响应是HTML文本,不是JSON
);
// ... 其他事件绑定
});
加载菜单分类
当用户点击“Menu”链接时,会调用 loadMenuCategories 函数。这个函数的流程更复杂:
- 显示加载图标。
- 发送Ajax请求到
allCategoriesUrl获取所有菜单分类的JSON数据。 - 在成功回调中,再发起两个并行的Ajax请求,分别获取分类列表的标题模板(
categories-title-snippet.html)和每个分类项的模板(category-snippet.html)。 - 所有数据(分类JSON和两个HTML模板)都获取成功后,调用
buildCategoriesViewHtml函数来构建完整的视图HTML。 - 最后,使用
insertHtml函数将构建好的HTML插入到主容器中,并调用switchMenuToActive更新菜单状态。
构建视图:手动模板渲染
buildCategoriesViewHtml 函数是手动渲染逻辑的核心。它接收分类数据、标题模板和项目模板,然后通过循环遍历每个分类,使用 insertProperty 函数将数据“注入”到项目模板字符串中,最终拼接成一个完整的HTML字符串。

function buildCategoriesViewHtml(categories, titleHtml, itemHtml) {
var finalHtml = titleHtml;
finalHtml += "<section class='row'>";
for (var i = 0; i < categories.length; i++) {
var html = itemHtml;
var name = "" + categories[i].name;
var shortName = categories[i].short_name;
html = insertProperty(html, "name", name);
html = insertProperty(html, "short_name", shortName);
finalHtml += html;
}
finalHtml += "</section>";
return finalHtml;
}
总结
本节课中我们一起学习了这个非AngularJS网站的核心工作机制。我们看到,开发者需要手动处理许多繁琐的任务:通过多个Ajax调用分别获取数据和模板、手动实现字符串插值来渲染视图、自行管理页面状态和导航高亮。整个过程虽然可行,但代码结构复杂,且不易维护和扩展。

相比之下,AngularJS等现代框架通过数据绑定、依赖注入和路由等机制,将这些底层操作抽象化,让开发者能更专注于业务逻辑。在接下来的课程中,我们将开始使用AngularJS重建这个网站,体验框架带来的高效与便捷。
107:餐厅服务器设置(Mac版)🍎

在本节课中,我们将学习如何为餐厅网站项目设置一个独立的服务器。由于课程新增了允许餐厅所有者更新菜单的功能,每个学生都需要部署自己的服务器实例来操作数据,而不能共享一个中央服务器。
概述
上一节我们介绍了为何需要独立的服务器。本节中,我们将一步步指导您在 Mac 系统上,使用 Heroku 平台部署餐厅服务器代码。请注意,自2022年11月起,Heroku 已取消免费套餐,因此课程已改用 Google Firebase 作为数据源。本教程作为历史参考,您可以直接使用提供的 Firebase URL 继续学习,无需自行部署服务器。
准备工作
以下是设置服务器前需要完成的步骤。
第一步:获取服务器代码
首先,在浏览器中访问餐厅服务器代码的仓库:https://github.com/jhu-ep-coursera/restaurant-server。这个仓库包含了服务器功能以及部署指南。
第二步:创建 Heroku 账户
访问 Heroku 官网 (heroku.com) 并注册一个免费账户。您需要提供邮箱和设置密码。
第三步:安装 Heroku 命令行工具 (CLI)
访问 Heroku CLI 安装页面,根据 MacOS 的指示下载并安装。安装完成后,在终端运行 heroku --version 来验证安装是否成功。
第四步:登录 Heroku
在终端中运行 heroku login 命令,然后输入您注册时使用的邮箱和密码(输入密码时不会显示,这属于正常现象)。成功登录后,终端会显示确认信息。
部署服务器
完成准备工作后,现在可以开始部署服务器代码。
第五步:创建 Heroku 应用
在终端中,首先使用 cd 命令进入您克隆的 restaurant-server 项目目录。然后运行 heroku create [您的自定义子域名] 命令来创建一个新的 Heroku 应用实例,并为其指定一个唯一的子域名。
第六步:推送代码到 Heroku
Heroku 会自动为您的本地仓库添加一个名为 heroku 的远程地址。运行 git push heroku master 命令,将本地代码推送到 Heroku 进行部署。系统会自动识别这是一个 Ruby on Rails 应用并安装所需依赖。
第七步:设置数据库
代码部署后,应用还无法正常工作,因为数据库尚未初始化。依次运行以下两个命令:
heroku run rake db:migrate:在 Heroku 上创建数据库结构。heroku run rake db:seed:向数据库填充初始的菜单数据。
第八步:设置管理员凭证
为了保护管理功能,需要设置一个管理员用户名和密码。运行 heroku run rails console 进入远程控制台,然后执行以下代码(请替换 your_username 和 your_password 为您自己的凭证):
User.create(username:'your_username', password:'your_password')
验证部署
完成所有步骤后,您可以在浏览器中访问您的 Heroku 应用 URL(格式为 https://[您的子域名].herokuapp.com)。如果看到返回的 JSON 格式菜单数据,而不是错误信息,则表明餐厅服务器已成功部署并运行。
总结


本节课中我们一起学习了如何在 Heroku 上部署餐厅服务器。核心流程包括:获取代码、安装工具、创建应用、推送代码、初始化数据库以及设置安全凭证。虽然课程现已转向使用 Firebase,但通过此流程您能了解将应用部署到云平台的基本方法。现在,您的后端服务器已准备就绪,可以开始编写前端代码与之交互了。
108:餐厅服务器设置(Windows版)🖥️


在本节课中,我们将学习如何为餐厅网站设置一个独立的服务器。由于课程项目需要实现更新菜单的功能,每个学生都需要拥有自己的服务器和数据副本,而不是共享一个中央服务器。我们将按照指南,在 Windows 系统上完成服务器的部署。
课程背景与更新说明
在之前的课程中,由于网站不需要更新餐厅菜单的功能,我们可以使用一个中央服务器为所有学生提供菜单数据。然而,在本课程中,我们将添加允许餐厅所有者更新菜单的功能。由于每个部署的应用都能更新服务器上的数据,因此不能再使用单一的中央服务器。每位学生都需要设置自己的服务器,并拥有可以操作的数据副本,以便完成作业和前端应用开发。
不过,这个设置过程实际上非常直接。
我需要暂时中断本次讲座,进行一个更新说明。2022年11月28日,Heroku 将停止其免费套餐服务。这意味着我们将无法再完全免费地部署我们的应用。因此,我们需要为这门课程寻找新的解决方案。虽然我之前提到的需要自定义应用的理由对于将要为真实客户部署的完整应用仍然成立,但那些需要该功能的部分已超出本课程范围。
因此,从2022年11月开始,我们将不再通过 Ruby on Rails 自定义应用提供的 REST API 来托管数据,而是将应用托管在 Google Firebase 上,Firebase 的数据库已经提供了 REST API 功能。
在课程的其余部分,每当你看到我使用 Heroku 应用的 URL 时,你应该改用屏幕上现在显示的 Firebase URL。观看课程讲座时,请务必参考 Github 仓库中的实际代码,因为我们使用新的 Firebase URL 的方式与之前使用 Heroku URL 的方式有细微差别。
我也会更新本课程的常见问题解答,以包含此信息,这样你就可以随时在那里查看更新后的 URL,而无需回看本视频。
另外请注意,当前代码已更新,每个菜单项的所有图片现在都是 Github 仓库和 Github 部署的一部分。这些图片将不再从 Heroku 应用获取。
最后一点,虽然所有示例代码都已更新为使用 Google Firebase URL,但第60讲及以后的讲座仍在使用 Heroku URL。这是因为第60讲及以后的内容不是本课程的一部分,它们的示例代码只是展示了已完成的餐厅网站,该网站已完全部署到 Heroku,但并未使用付费套餐。你无需这样做即可完成本课程,但如果你选择研究完整的应用,它可以作为参考。
因此,对你来说,目前的要点是:如果你想,可以跳过这节关于如何用 Heroku 设置服务器的讲座视频,直接进入第51讲。如果你不想设置,你根本不需要设置任何东西。你只需要 Firebase URL,它通过我们讨论的 REST API 为你提供所有数据。
祝你在接下来的课程中好运。
开始设置
我们将按照为你准备的指南开始设置。我已经在 Chrome 浏览器中加载了该指南。要访问它,你需要前往 github.com/jhu-ep-coursera/restaurant-server。这个仓库包含了将作为我们中餐厅(David Chu’s China Bistro)后端服务器的功能。

在首页的 README.md 文件中,我们为你提供了如何逐步设置自己服务器的指南,而无需在本地机器上安装任何东西。让我们开始浏览这个指南。我将在 Windows 上演示,并会有另一个视频演示在 Mac 上的操作。
步骤详解
步骤 1:克隆仓库
第一步是克隆我们正在使用的这个仓库。以下是给出的命令:
git clone https://github.com/jhu-ep-coursera/restaurant-server.git
我们复制该命令,打开命令提示符(CMD)。进入合适的目录后,粘贴命令并执行,将餐厅服务器仓库克隆到本地机器。

步骤 2:注册 Heroku 账户
下一步是注册 Heroku 账户。指南中提供了注册 URL。打开该链接,点击“免费注册”按钮,按照基本流程(邮箱、密码等)完成注册。
步骤 3:下载并安装 Heroku CLI
接下来,我们需要下载并安装 Heroku 命令行界面(CLI)。这个工具将用于远程控制我们的 Heroku 部署。这个工具过去也叫 Heroku Toolbelt,它们是同一个东西。
访问提供的 URL,页面会自动识别我们运行的是 Windows 系统。点击下载 Heroku-toolbelt.exe 文件并运行安装程序。按照提示点击“下一步”完成安装。

安装完成后,为了验证 CLI 是否安装成功,我们需要在命令提示符中输入命令 heroku --version。如果在 Windows 中遇到“heroku 不是内部或外部命令”的错误,通常是因为我们在命令提示符打开时安装了软件。解决方法是关闭并重新打开命令提示符,然后再次尝试该命令。成功后会显示 Heroku CLI 的版本号。
步骤 4:登录 Heroku
现在,我们需要使用 Heroku CLI 登录到我们的账户。使用以下命令:
heroku login


执行命令后,会提示输入注册时使用的邮箱和密码。输入密码时,屏幕上不会显示字符,这是正常现象。登录成功后,会显示确认信息。
步骤 5:选择子域名

第五步是为我们的网站选择一个子域名。部署完成后,我们的网站 URL 将类似于 http://你的子域名.herokuapp.com。
我们可以让 Heroku 随机分配一个名字(使用 heroku create),但通常我们更希望自定义一个。我们可以这样操作:
heroku create 你的子域名
子域名可以是你的 Github 用户名或其他容易记住的字符串,只要它是有效的 URL 字符串即可。非常重要的一点是,从这一步开始,我们必须确保位于 restaurant-server 目录(即我们克隆的本地 Git 仓库)内。如果不在,请使用 cd restaurant-server 命令导航到该目录。
执行创建命令后,可以使用 git remote -v 命令验证。如果成功,你会看到列表中新增了指向 Heroku 的远程仓库地址。
步骤 6:部署应用
现在,我们需要部署应用。使用 Git 将代码推送到 Heroku:

git push heroku master
这个命令会将本地的 master 分支代码上传到 Heroku。上传过程中,Heroku 会检测到这是一个 Ruby on Rails 应用,并自动安装所需的依赖(gems),然后部署应用。
部署完成后,我们可以通过访问 http://你的子域名.herokuapp.com 来验证应用是否已上线。此时,你可能会看到一个“内部服务器错误(500)”,这是正常的,因为我们还没有设置数据库。
步骤 7:设置数据库
接下来,我们需要在 Heroku 上为我们的应用创建数据库和表结构。运行数据库迁移命令:
heroku run rake db:migrate

这个命令会在 Heroku 上为你的应用远程创建数据库架构。
然后,我们需要为数据库填充一些初始数据(例如菜单项):
heroku run rake db:seed


这个命令会运行一段时间,并将数据(包括经过 Base64 编码的菜单图片)插入数据库。完成后,再次访问你的应用 URL,现在应该能看到返回的 JSON 数据,这表明服务器已正常工作。
步骤 8:设置管理员账户
最后,我们需要为餐厅网站的管理员部分设置用户名和密码,以防止未经授权的访问。


首先,打开 Rails 控制台:
heroku run rails console
进入控制台后,执行以下命令来创建管理员用户(请将 some_username 和 some_password 替换为你自己的用户名和密码):

User.create(username: ‘你的用户名‘, password: ‘你的密码‘, password_confirmation: ‘你的密码‘)
例如:User.create(username: ‘yaakov‘, password: ‘hello123‘, password_confirmation: ‘hello123‘)。执行后,如果看到提交成功的提示,则表示用户已创建。
完成后,输入 exit 退出 Rails 控制台。

总结

在本节课中,我们一起学习了如何在 Windows 系统上为餐厅网站设置独立的 Heroku 服务器。我们完成了从克隆代码仓库、注册 Heroku、安装 CLI、登录、选择子域名、部署应用、初始化数据库到创建管理员账户的全过程。现在,你已经拥有了一个可以用于前端开发的后端服务器。请注意,由于 Heroku 政策变更,课程后续将使用 Firebase 作为数据源,但本节的设置原理仍然具有学习价值。
109:餐厅应用的基本结构 🏗️


在本节课中,我们将开始着手编码,构建一个名为“David Chu's China Bistro”的餐厅网站。我们将首先了解课程的基本设置和学习方法,为后续的实际编码工作做好准备。
课程设置与学习方法概述
在开始编写代码之前,我们需要明确一些基本规则和课程框架,以便你更好地理解我将如何呈现代码。
视频学习指南
本课程以视频形式呈现,你可以灵活地控制学习节奏。如果你觉得我讲得太快,可以暂停视频;如果想重温某个部分,可以回放。在 Coursera 视频播放器中,你还可以调整播放速度。如果觉得太慢,可以加速播放;如果觉得太快,可以减速播放。核心是你可以按照自己的节奏来学习。
源代码的获取与使用
我将展示的源代码会像常规的课程文件夹一样提供给你。这意味着你不必完全依赖视频来查看代码,可以随时查阅源代码。
项目的渐进式构建
我们将一步步地构建“David Chu's China Bistro”网站。每一讲的终点将是下一讲的起点。我会将每一讲完成后的完整代码复制到下一讲,作为新的起点。这种安排能让你清晰地看到项目是如何从一讲发展到下一讲的。
编码演示方式说明
为了提升学习效率,避免因大量的打字和纠错过程导致课程冗长乏味,许多代码我会在录制前预先编写好,然后在视频中进行讲解。当然,部分关键代码我仍会现场编写。这样能确保我们专注于核心概念,而不是打字过程。
如何从本课程中高效学习
在开始之前,我们还需要探讨如何从构建网站的过程中获得最大收益。
以下是关于学习方法的一些建议:
- 无需逐字抄写代码:源代码已经提供,你不必暂停视频来逐行输入代码。
- 主动探索与修改:我的建议是,在每节课后,你可以获取该讲的代码文件,然后对其进行一些修改和尝试。例如,改变某些内容,使用 Browser Sync 部署,或发布到你自己的 GitHub Pages 上进行实验。
- 熟悉代码结构:与代码“亲密接触”,尝试修改并观察结果,是深入理解这些课程内容的最佳方式。你可以随时撤销更改,或者重新下载代码仓库,原始代码始终可用。
通过这种方式,你不仅能理解代码是如何工作的,还能通过实践巩固知识。


本节课中,我们一起学习了本实践课程的设置框架与高效学习方法。我们明确了视频学习的灵活性、源代码的可用性、项目的渐进构建方式以及通过主动修改代码来深化理解的学习策略。下一节,我们将正式打开代码编辑器,开始构建餐厅应用的基础结构。
110:餐厅应用的基本结构 🏗️


在本节课中,我们将学习如何将一个非 AngularJS 构建的网站,重构并拆分成适合 AngularJS 单页应用(SPA)架构的组件。我们将重点关注项目的基本结构设置,包括模块划分、路由配置以及模板的组织方式。
项目入口:index.html
首先,我们来看项目的入口文件 index.html。与之前的版本相比,主要变化在于 <body> 标签上添加了 ng-app 属性,用于声明应用的起始模块。
<body ng-app="restaurant" ng-strict-di>
这里出现了一个新属性 ng-strict-di。它启用了 AngularJS 的严格依赖注入模式。这意味着,如果我们尝试注入一个未经保护(防止代码压缩混淆)的依赖项,AngularJS 将会抛出错误。保护依赖的方式,正如我们在所有课程中一直使用的,是通过 $inject 属性或一个包含依赖名称字符串的数组。
文件其余部分基本保持不变:包含响应式页头、一个巨大的“呼叫”按钮(在移动端视图下显示)、页脚,以及最重要的——ui-view 指令。
ui-view 指令是 ui-router 插入模板的占位符。在 ui-router 将具体模板插入之前,<ui-view> 标签内的内容(例如“加载中…”)会显示给用户。这提供了良好的用户体验,尤其是在网站加载较慢时。
以下是页面中引入的脚本文件:
- jQuery 2.1.4:为了与使用的 Bootstrap 3.3.6 版本兼容。
- AngularJS
- Angular UI Router:用于处理应用状态和路由。
restaurant.module.js:应用的主模块。public.module.js:公共模块的声明和路由。
核心模块:restaurant.module.js
现在,让我们深入查看主模块 restaurant.module.js。
angular.module('restaurant', ['public'])
这里声明了名为 restaurant 的主模块,并注入了 public 模块作为依赖。模块的配置函数中,我们使用 $urlRouterProvider 设置了一条规则:将所有未匹配的 URL 重定向到应用的根路径 /。
.config(['$urlRouterProvider', function($urlRouterProvider) {
$urlRouterProvider.otherwise('/');
}])
我们将路由直接写在模块文件中,这是因为目前配置非常简单。在实际项目中,根据组织习惯,你也可以选择将路由分离到独立的文件中。
公共模块:public.module.js 与 public.routes.js
接下来,我们看看 public 模块。将功能拆分为 public(公开)和未来的 admin(管理)模块是一个重要的架构决策。这样做是因为网站的公开部分(顾客浏览菜单)和管理部分(店主更新菜单)在功能、界面和权限上截然不同。它们共享公共的页头和页脚,但内部结构彼此独立。
public.module.js 文件非常简单,仅声明模块并注入 ui.router 依赖。
真正的路由逻辑定义在 public.routes.js 中。我们使用 $stateProvider 来定义应用的状态(states)。
以下是定义的状态:
-
public状态:这是所有公开状态的父状态。它有两个关键属性:abstract: true:表示这是一个抽象状态,用户不能直接导航到此状态。它充当容器,为其子状态提供共享的模板和属性。templateUrl: ‘src/public/public.html’:指定了该状态的模板文件。这个模板将被注入到主index.html的<ui-view>中。
-
public.home状态:这是网站的主页状态。url: ‘/’:对应的URL是根路径。templateUrl: ‘src/public/home/home.html’:其模板将被注入到父状态public模板中的<ui-view>里。
这种嵌套结构形成了“洋葱式”的模板组织:index.html 包含一个 <ui-view>,public.html 模板被注入其中;而 public.html 内部又包含一个 <ui-view>,home.html 模板再被注入其中。这允许我们对界面的每一层进行独立控制和组合。
模板与样式文件
现在,我们来查看相关的模板和样式文件。
public.html 模板的核心作用是提供一个嵌套的 <ui-view> 占位符,并引入公共部分的样式表。
样式文件分为两类:
restaurant.css:应用于整个网站(包括未来的管理部分)的全局样式。public.css:仅针对网站公开部分的样式,例如主页、分类页的特定样式。

home.html 模板是从之前非 AngularJS 版本的网站中直接迁移过来的“主页片段”,包含了展示给用户的各个功能区块。
应用运行与状态验证
当我们运行应用并访问 index.html 时,浏览器会被重定向到 /#/,即 public.home 状态。页面正常显示,并且保持了响应式布局。

如果尝试点击主页上尚未配置完整路由的链接(例如“查看菜单”),控制台会显示明确的路由解析错误,例如“无法从状态 ‘public.home’ 解析 ‘public.menu’”。这证明我们的路由机制正在正常工作,只是对应的子状态尚未实现,这为后续开发提供了清晰的指引。
总结

本节课中,我们一起学习了餐厅单页应用的基本结构搭建。我们了解了如何设置主入口文件、配置严格依赖注入、划分主模块与功能模块(如 public 模块),并使用 ui-router 定义嵌套的抽象状态和子状态来组织模板。我们还看到了如何将旧网站的代码片段整合到新的 AngularJS 组件结构中。这个结构为后续开发分类页面和详情页面奠定了坚实的基础。
111:编写加载旋转器 🌀


在本节课中,我们将学习如何编写一个加载旋转器(Loading Spinner)。这个旋转器用于在系统处理异步操作时告知用户需要等待。我们将创建一个可被网站公共部分和未来管理部分共同复用的模块。
概述
我们将创建一个名为 common 的独立模块,用于存放公共组件。本节重点是在该模块中创建加载旋转器组件,并通过事件机制控制其显示与隐藏。
创建公共模块
上一节我们完成了应用的基本结构。本节中,我们来看看如何构建一个可复用的公共模块。
我们首先在 src 目录下创建了一个 common 文件夹,并在其中定义了一个 AngularJS 模块。
// common.module.js
angular.module('common', []);
这个 common 模块将被公共模块和未来的管理模块共同依赖。
创建加载器组件
接下来,我们在 common 模块下创建加载器组件。以下是该组件的核心代码:
// loading.component.js
angular.module('common')
.component('loading', {
template: '<img src="spinner.svg" ng-if="$ctrl.show">',
controller: LoadingController
});
function LoadingController($rootScope) {
var $ctrl = this;
var listener;
$ctrl.$onInit = function() {
$ctrl.show = false;
listener = $rootScope.$on('spinner:activate', onSpinnerActivate);
};
$ctrl.$onDestroy = function() {
listener();
};
function onSpinnerActivate(event, data) {
$ctrl.show = data.on;
}
}
让我们分析一下这个组件:
- 模板:包含一个图片,仅当控制器的
show属性为true时通过ng-if指令显示。 - 控制器:
LoadingController负责控制旋转器的状态。 - 事件监听:控制器在初始化时 (
$onInit) 监听名为spinner:activate的根作用域 ($rootScope) 事件。事件数据中的on属性决定显示 (true) 或隐藏 (false) 旋转器。 - 销毁监听器:在组件销毁时 (
$onDestroy),移除事件监听器以防止内存泄漏。
集成到主应用
创建好组件后,我们需要将其集成到主应用中。
首先,更新公共模块的依赖,声明依赖我们新建的 common 模块。
// public.module.js
angular.module('public', ['ui.router', 'common']);
然后,在 index.html 主页面中,添加加载器组件的标签。
<!-- index.html -->
<body>
<div ui-view></div>
<loading class="loading-indicator"></loading>
</body>
最后,引入相关的 JavaScript 文件和 CSS 样式,确保组件能正常工作和显示。
<script src="src/common/common.module.js"></script>
<script src="src/common/loading/loading.component.js"></script>
<link rel="stylesheet" href="src/common/common.css">


测试与验证
完成以上步骤后,我们回到浏览器刷新应用页面。如果一切配置正确,页面底部会出现加载旋转器的占位位置(初始状态为隐藏)。目前旋转器还不会显示,因为我们尚未触发 spinner:activate 事件。在下一讲中,我们将创建触发此事件的机制。
总结
本节课中我们一起学习了:
- 创建了一个独立的
common公共模块。 - 在该模块中编写了
loading组件,它通过监听spinner:activate事件来控制一个旋转图片的显示与隐藏。 - 将公共模块注入主应用,并在主页面上添加了加载器标签。

现在,加载旋转器的静态部分已经就绪。下一讲我们将实现动态控制它显示和隐藏的逻辑。
112:编写 HTTP 拦截器 🛠️


概述
在本节课中,我们将学习如何编写一个 HTTP 拦截器。HTTP 拦截器允许我们在 HTTP 请求发出前和响应返回后,插入自定义的逻辑。我们将利用这个功能,实现一个全局的加载指示器,在应用进行异步请求时自动显示和隐藏。
加载指示器与事件广播
上一节我们介绍了加载指示器组件,它通过监听 spinner.activate 事件来控制自身的显示与隐藏。现在的问题是,谁应该来触发这个事件?
我们可以在代码中任何发起异步 HTTP 请求的地方手动触发这个事件。例如,在向餐厅服务器请求数据之前,广播 spinner.activate 事件并将 on 属性设为 true;在请求返回后,再广播一次事件并将 on 属性设为 false。
然而,这种方法存在两个问题:
- 我们需要在所有使用
$http服务的地方都记住添加这段代码。 - 有些 HTTP 请求是框架自动发起的,我们无法直接控制。例如,在路由配置中加载 HTML 模板文件时,AngularJS 会使用
$http服务异步获取这些文件,我们无法在这些地方手动插入事件广播代码。
幸运的是,$http 服务提供了一种配置方式,允许我们“拦截”请求和响应的整个生命周期。这就是拦截器。
什么是拦截器?
拦截器本质上是一个工厂函数,它返回一个包含特定方法的对象。这个对象可以“钩入” $http 服务的请求/响应流程。
以下是拦截器对象的核心方法:
request: 在请求发出前被调用。我们可以在这里执行操作,例如广播“开始加载”事件。response: 在请求成功返回后被调用。我们可以在这里执行操作,例如广播“结束加载”事件。responseError: 在请求失败(或之前的拦截器抛出错误)后被调用。我们同样需要在这里处理错误并广播“结束加载”事件。
创建拦截器工厂
现在,让我们开始编写拦截器。我们在 common/loading 文件夹下创建一个新文件 loading.interceptor.js。
这个文件将定义一个工厂,用于创建我们的拦截器。以下是该拦截器的实现代码:
(function () {
'use strict';
angular.module('common')
.factory('loadingHttpInterceptor', LoadingHttpInterceptor);
LoadingHttpInterceptor.$inject = ['$rootScope', '$q'];
function LoadingHttpInterceptor($rootScope, $q) {
var loadingCount = 0;
return {
request: function (config) {
// console.log("Inside interceptor, config: ", config);
if (++loadingCount === 1) {
$rootScope.$broadcast('spinner:activate', {on: true});
}
return config;
},
response: function (response) {
if (--loadingCount === 0) {
$rootScope.$broadcast('spinner:activate', {on: false});
}
return response;
},
responseError: function (response) {
if (--loadingCount === 0) {
$rootScope.$broadcast('spinner:activate', {on: false});
}
return $q.reject(response);
}
};
}
})();
代码解析
- 依赖注入:我们注入了
$rootScope用于广播事件,以及$q服务用于在发生错误时拒绝 Promise。 loadingCount计数器:这是一个关键变量。因为多个 HTTP 请求可能同时发生,我们需要一个计数器来跟踪正在进行的请求数量。只有当第一个请求开始时(loadingCount从 0 变为 1),我们才广播“开始加载”事件;只有当最后一个请求完成时(loadingCount减到 0),我们才广播“结束加载”事件。request方法:每个请求发出前,计数器加 1。如果加 1 后等于 1,说明这是第一个待处理的请求,于是广播spinner:activate事件并设置on: true。最后,必须返回config对象,以便请求能继续执行。response方法:每个成功响应返回后,计数器减 1。如果减 1 后等于 0,说明所有请求都已处理完毕,于是广播spinner:activate事件并设置on: false。最后,返回response对象。responseError方法:处理请求失败的情况。逻辑与response方法类似:计数器减 1,检查是否为 0,并广播“结束加载”事件。关键区别在于,我们必须调用$q.reject(response)来显式地拒绝这个 Promise,这样调用$http服务的代码才能正确地进入错误处理流程。
配置 HTTP 提供者
仅仅创建拦截器工厂是不够的,我们还需要告诉 AngularJS 的 $http 服务使用这个拦截器。这需要在模块的配置阶段完成。
我们打开 common.module.js 文件,添加配置代码:
(function () {
'use strict';
angular.module('common', [])
.config(config);
config.$inject = ['$httpProvider'];
function config($httpProvider) {
$httpProvider.interceptors.push('loadingHttpInterceptor');
}
})();
配置解析
- 在
config函数中,我们注入了$httpProvider。 $httpProvider有一个interceptors数组,用于注册所有全局的 HTTP 拦截器。- 我们使用
.push()方法,将之前创建的拦截器工厂名称'loadingHttpInterceptor'添加到这个数组中。
完成此配置后,应用中的所有 $http 请求在发出和返回时,都会经过我们定义的拦截器逻辑。
测试拦截器
最后,我们需要确保在 index.html 中引入了新创建的拦截器文件。
<script src="src/common/loading/loading.interceptor.js"></script>
现在,当我们运行应用并刷新页面时,应该能看到加载指示器短暂地出现。这是因为 AngularJS 在启动时,会通过 $http 服务异步加载路由中定义的 HTML 模板文件,而我们的拦截器成功地捕获了这些请求并触发了加载事件。
我们可以在浏览器的开发者工具控制台中,查看拦截器内 console.log 输出的 config 对象,里面包含了请求的 URL、请求头等信息,这验证了拦截器正在工作。
总结
本节课中我们一起学习了 HTTP 拦截器的强大功能。我们创建了一个名为 loadingHttpInterceptor 的拦截器,它能够:
- 自动跟踪所有并发的 HTTP 请求。
- 在第一个请求开始时,自动显示全局加载指示器。
- 在最后一个请求完成(无论成功或失败)时,自动隐藏加载指示器。
- 通过配置
$httpProvider.interceptors数组,将其注册为全局拦截器。

通过使用拦截器,我们实现了关注点分离:加载状态的管理逻辑被集中到了拦截器中,业务代码无需再关心何时显示或隐藏加载图标,代码变得更加简洁和可维护。这是构建健壮单页应用的一个重要模式。
113:编写菜单分类视图(第1部分)


在本节课中,我们将学习如何为餐厅网站创建菜单分类视图。我们将从主页的菜单按钮开始,设置路由,并构建一个显示所有菜单类别的页面。
概述
上一节我们完成了主页的构建。本节中,我们来看看如何实现点击菜单按钮后跳转到菜单页面的功能。菜单页面将首先展示所有菜品的分类,例如汤类、午餐、晚餐和寿司等。
设置菜单状态路由
首先,我们需要在 public.routes.js 文件中为菜单页面创建一个新的路由状态。
.state('public.menu', {
url: '/menu',
templateUrl: 'src/public/menu/menu.html'
})
这段代码定义了一个名为 public.menu 的状态。当用户访问 /menu 路径时,AngularJS 会加载 src/public/menu/menu.html 文件中的模板内容,并将其插入到父视图的 ui-view 标签中。
创建菜单页面模板
接下来,我们在 src/public/ 目录下创建一个名为 menu 的文件夹,并在其中新建 menu.html 文件。
最初,我们可以先添加一个简单的标题来测试路由是否正常工作。
<h1>Are you hungry? Let's eat!</h1>

保存后,在浏览器中点击菜单按钮,页面应能成功跳转并显示这个标题。通过开发者工具检查,可以看到这个 h1 标签被正确地插入到了嵌套的 ui-view 中。

构建分类视图
为了构建完整的菜单分类页面,我们需要参考之前(第49讲)没有使用 AngularJS 时实现的版本。该版本包含两个主要部分:页面标题和分类项目网格。
以下是页面标题的代码片段,我们可以直接复制到 menu.html 中。


<div class="container">
<div class="text-center">
<h2>Our Menu</h2>
<p>Choose from our delicious menu items below. Click on any category to see the items within.</p>
</div>
</div>
接着,我们需要处理每个分类项目的显示。以下是单个分类项目的 HTML 结构。
<div class="col-md-3 col-sm-4 col-xs-6 col-xxs-12">
<a href="#">
<div class="category-tile">
<img width="200" height="200" src="images/menu/a/a.jpg" alt="Soup">
<span>Soup</span>
</div>
</a>
</div>

我们将这个结构复制几份到 menu.html 中,并暂时硬编码一些数据(如图片路径和分类名称)以进行布局测试。保存后,页面应能显示多个分类区块。
修复 HTML 验证与布局错误
在测试过程中,控制台可能会出现“尝试多次加载 Angular”的警告。这通常是由于 HTML 结构无效导致的。检查 index.html 或主要模板文件,确保所有标签(特别是 div 标签)都正确闭合。
此外,如果页面布局混乱,可能是因为缺少 Bootstrap 的容器类。我们可以将分类网格代码包裹在一个 div 容器中来解决布局问题。

<div class="container">
<!-- 分类网格代码放在这里 -->
</div>
修复后,页面布局将恢复正常。
总结




本节课中我们一起学习了创建菜单分类视图的第一步。我们设置了 public.menu 状态路由,创建了基本的菜单页面模板,并从旧版本中复制了标题和分类项目的 HTML 结构进行初步渲染。我们还解决了因 HTML 标签未闭合和缺少容器导致的页面错误。在下一讲中,我们将使用 AngularJS 的数据绑定和循环指令来动态生成这些分类项目,替换当前的硬编码方式。
114:编写菜单分类视图(第2部分)


在本节课中,我们将继续构建菜单分类视图。我们将学习如何通过定义控制器和服务,从服务器获取数据,并将其动态地显示在页面上。
上一节我们介绍了菜单页面的基本结构。本节中,我们来看看如何为这个页面注入动态数据。
我回到了位于第55讲的代码编辑器中,该目录位于 fulltag-coursese5-exles 文件夹。这是我们的 menu.html 文件,它应该显示一个菜单分类列表,以及整个菜单的标题和一些描述。

无论如何,重点是这是我们的菜单分类页面。如果我们在浏览器中查看,并点击菜单,它会显示所有这些分类卡片。显然,我们需要的不仅仅是“汤”类,这意味着我们需要以某种方式获取数据。我们需要将数据传递到这个 menu.html 文件中。一旦获得数据,我们就可以弄清楚如何将数据填充到每个分类卡片中。
这听起来我们需要一个控制器,不是一个隐式控制器,而是一个显式控制器来控制这个 menu.html。让我们回到 public.routes.js,在我们的 public.menu 状态中添加控制器。
以下是添加控制器的步骤:
- 在
public.menu状态中添加一个逗号。 - 定义
controller属性。 - 使用
controllerAs语法,这是一种更好的实践。 - 将控制器命名为
MenuController。
现在,控制器本身不会为我们做任何事情。我们实际上需要这个控制器从某个地方获取数据。通常,如果控制器需要数据才能正常运行(换句话说,如果数据未出现,该控制器甚至没有存在的意义),我们通常希望在状态定义中直接通过 resolve 来获取数据。
让我们继续定义一个 resolve。
以下是定义 resolve 的步骤:
- 定义键为
menuCategories,因为我们需要菜单分类数据。 - 问题是如何获取这些数据。显然,数据存在于服务器上,不会在客户端。
- 这意味着我们需要发起一个 HTTP 请求,因此我们需要
$http服务。 - 更好的做法不是将 HTTP 调用放在控制器里,而是放在一个单独的服务中。
因此,我将创建一个尚未存在的服务。首先,我们称之为 MenuService,它将提供所有与菜单相关的数据。我使用内联注释来防止代码压缩时出现问题。在这里,我们将注入这个 MenuService。
我们需要从这里返回的是 MenuService 中的某个方法。此时,它应该是 getCategories 方法。我们需要返回所有的分类。
如果我现在就这样写,除了可能产生一些错误外,不会发生太多事情,因为我们实际上还没有定义 MenuService。让我们来定义它。
问题是我们应该在哪里定义它?是定义在 public 目录下还是 common 目录下?我认为需要定义在 common 目录下,因为当我们进入网站的管理部分时,我们仍然需要分类数据,我们仍然需要所有数据,所以最好将其集中管理。
让我们进入 common 目录,在里面创建一个新文件,命名为 menu.service.js。
让我们快速编写至少我们需要的第一个方法,即 getCategories 方法。
以下是创建 MenuService 的步骤:
- 创建一个立即执行函数表达式(IIFE)并使用
‘use strict’。 - 在
common模块上定义我们的服务:angular.module(‘common’)。 - 调用
.service方法,服务名称为MenuService。 - 在函数中注入
$http服务。 - 定义
getCategories方法,该方法返回一个 HTTP GET 请求的 Promise。 - 请求的 URL 是服务器提供的 REST 端点,例如
/categories.json。
不过,我们需要完整的 URL 前缀(如 http://...)。我可以直接在这里硬编码这个 URL,但我觉得这不是一个好主意,因为其他方法也会使用相同的前缀。更好的做法是在我们的 common 模块中定义一个常量,然后在各处重复使用这个常量。
让我们回到 common.module.js,定义一个常量。
以下是定义 API 路径常量的步骤:
- 使用
.constant方法定义一个名为ApiPath的常量。 - 常量的值是我们之前部署的餐厅服务器的 URL(例如
https://username-course5.herokuapp.com)。我们需要使用 HTTPS,因为 GitHub 部署会使用 HTTPS,如果加密页面调用非加密资源,浏览器可能会因安全原因报错。
现在,回到 menu.service.js,我们可以使用这个常量。首先需要将其注入到服务函数中。
现在,我们可以这样构造 URL:ApiPath + ‘/categories.json’。
完成后,保存文件。我们需要在 index.html 中引入这个新的服务脚本文件。它将是 common 模块的一部分。复制一个现有的 common 脚本标签,将其路径改为 menu.service.js 并保存。


现在,我们回顾一下:我们有一个包含 ApiPath 的常量,一个定义了 MenuService 的服务文件,该服务有一个 getCategories 方法,该方法通过请求 /categories.json 端点返回数据。
为了确认 URL 正确,我可以在浏览器新标签页中打开这个 URL,应该能看到返回的 JSON 数据。测试成功,URL 是正确的。
我们有了 MenuService,剩下的就是查看我们的 resolve。我们设置的 resolve 看起来已经完成了。
接下来,我们需要实际定义我们的 MenuController。让我们在 menu 文件夹内定义它,创建文件 menu.controller.js,同样使用 IIFE 和 ‘use strict’。
我们需要在 public 模块上定义这个控制器:angular.module(‘public’)。然后调用 .controller 方法,控制器名称为 MenuController,函数名相同。
在定义控制器函数之前,我们需要注入从 resolve 传来的 menuCategories。使用 $inject 属性进行注入。
在控制器函数内部,我们将 menuCategories 赋值给控制器实例的一个属性(例如 ctrl.menuCategories = menuCategories),这样它就可以在关联的模板中使用了。
保存文件。现在,这个 MenuController 绑定到了我们的 menu.html。因此,我们应该能够在 menu.html 中输出 menuCategories。

为了快速验证,我们可以在 menu.html 中使用插值表达式以一种简单(可能不太美观)的方式输出数据。例如:{{ ctrl.menuCategories | json }}。保存后,回到浏览器查看页面。
如果出现错误,比如“MenuController is not a function”,通常是因为没有在 index.html 中正确引入控制器脚本文件。确保在 index.html 中添加了 menu.controller.js 的 <script> 标签。
修复引入问题后,刷新页面,现在我们应该能看到 JSON 数据成功显示在菜单标题之前。

至此,我们已经能够将数据从服务器通过 resolve 传递到 menu.html 中。MenuService 访问餐厅服务器,将数据作为 menuCategories 传递给控制器,控制器再将其作为自身的一个属性暴露出来。这样,我们就获得了数据。
现在,我们准备开始插值这些数据,并实际创建列出所有分类的菜单页面。


本节课中,我们一起学习了如何为 AngularJS 单页应用中的视图注入动态数据。我们通过以下步骤实现了从服务器获取数据并传递给视图控制器:
- 在路由状态中定义了
resolve属性,用于预先获取数据。 - 创建了
MenuService服务来封装 HTTP 请求,并定义了getCategories方法。 - 在
common模块中定义了ApiPath常量,以便集中管理 API 基础 URL。 - 创建了
MenuController控制器,并通过依赖注入接收resolve提供的数据。 - 将数据绑定到控制器实例,从而使其在关联的 HTML 模板中可用。
通过这种方式,我们成功地将后端数据与前端视图连接起来,为下一步动态渲染菜单分类列表奠定了基础。
115:编写菜单分类视图(第3部分)


概述
在本节课中,我们将继续构建菜单分类视图。我们将创建一个可复用的组件来展示每个菜单分类,并使用 ng-repeat 指令循环显示所有分类。同时,我们还将修复导航菜单的激活状态问题。
验证数据并移除调试信息
上一节我们成功地将分类数据传递到了 menu.html 模板中,并通过直接插值的方式在网页上以 JSON 格式显示了数据。
现在我们已经验证数据存在,可以移除这个调试信息了。保存文件并刷新页面,确认 JSON 数据已不再显示。
创建可复用的分类组件
显然,我们不想手动逐个列出每个分类。我们需要能够循环遍历这个标签集合。技术上,我们可以直接在 HTML 中放置一个 ng-repeat 指令。
但是,如果我们为这一小块 HTML 创建一个独立的组件,页面会更加清晰。我们可以将其命名为 menu-category,这样在语义上也更明确。
让我们开始创建这个组件。
- 进入
public文件夹,创建一个新文件夹,命名为menu-category。 - 在
menu-category文件夹内,创建一个文件,命名为menu-category.component.js。
以下是组件的基本结构:
(function () {
'use strict';
angular.module('public')
.component('menuCategory', {
templateUrl: 'public/menu-category/menu-category.html',
bindings: {
category: '<'
}
});
})();
templateUrl指定了组件模板的位置。bindings定义了组件的输入属性。这里我们有一个单向绑定的category属性,用于接收单个分类的数据。
创建组件模板
接下来,我们需要创建组件的模板文件 menu-category.html。

- 从
menu.html中复制用于显示单个分类的 HTML 代码块。 - 将其粘贴到新创建的
menu-category.html文件中。
现在,我们可以用 <menu-category> 标签替换 menu.html 中那一大段 HTML 代码。

导入组件并初步测试
在 index.html 中,我们需要导入新创建的组件文件。在 public 模块的脚本引入部分添加:
<script src="public/menu-category/menu-category.component.js"></script>
保存所有文件并在浏览器中查看。此时页面上应该只显示一个分类卡片,因为我们还没有使用循环。
使用 ng-repeat 循环显示分类
为了显示所有分类,我们需要在 menu.html 中使用 ng-repeat 指令来遍历数据。
首先,查看 menu.controller.js 控制器,确认数据属性名称为 menuCategories。
然后,在 menu.html 中,为 <menu-category> 标签添加 ng-repeat 指令和属性绑定:
<menu-category
ng-repeat="menuCategory in $ctrl.menuCategories"
category="menuCategory">
</menu-category>
ng-repeat="menuCategory in $ctrl.menuCategories"会遍历控制器中的menuCategories数组。category="menuCategory"将每次迭代中的menuCategory对象传递给子组件的category输入属性。
完善组件模板的数据绑定
现在,我们需要更新 menu-category.html 模板,使其能够动态显示每个分类的信息。
我们需要根据传入的 category 对象来设置图片的 src、alt 属性以及分类名称。
以下是更新后的模板关键部分示例:
<img ng-src="images/menu/{{$ctrl.category.short_name}}.jpg" alt="{{$ctrl.category.name}}">
<span class="menu-item-title">{{$ctrl.category.name}}</span>
- 使用
ng-src指令来安全地绑定图片路径。 - 使用
{{ ... }}插值表达式来显示分类的名称。
保存后刷新浏览器,现在应该能看到所有分类都正确地以其各自的图片和名称显示出来了。
修复导航菜单激活状态
我们注意到,当点击进入“菜单”页面时,顶部的“菜单”导航按钮并没有高亮显示激活状态。
这可以通过为对应的 li 元素添加 ui-sref-active 指令来修复。该指令会在状态激活时自动添加指定的 CSS 类。
在 index.html 中找到“菜单”对应的列表项,修改如下:
<li ui-sref-active="active">
<a ui-sref="public.menu" ...>菜单</a>
</li>
现在,当用户处于 public.menu 状态(即菜单页面)时,active 类会被添加到 li 元素上,从而实现高亮效果。

保存并测试,导航菜单的激活状态现在应该能正常工作了。
总结
本节课中我们一起学习了:
- 创建 AngularJS 组件:我们将显示单个分类的代码封装成了一个独立的、可复用的
menu-category组件。 - 使用数据绑定:通过组件的
bindings属性,实现了从父控制器向子组件的数据传递。 - 应用
ng-repeat指令:在父模板中使用ng-repeat循环遍历分类数组,并为每个分类动态生成一个组件实例。 - 完善模板插值:在组件模板中,使用
ng-src和插值表达式{{}}来动态绑定分类的图片和文字信息。 - 管理UI状态:使用
ui-sref-active指令自动管理导航菜单的激活状态,提升了用户体验。

至此,我们的菜单分类视图已经基本完成,用户可以浏览餐厅菜单中的所有分类。
116:单个分类视图(第1部分)


在本节课中,我们将学习如何为单个分类创建视图,该视图将展示属于该分类的所有菜单项。我们将从修改菜单服务开始,添加一个用于获取特定分类菜单项的新方法。
我们回到代码编辑器,位于 lecture57 文件夹中,该文件夹在 fullstack-course5 的 examples 目录下。现在,让我们查看菜单分类的 HTML 模板。如果回到浏览器(因为浏览器同步已启动),可以看到每个分类磁贴都是由这个 HTML 模板生成的。
目前,我们已经列出了所有分类,但还缺少一个关键页面:单个分类页面,用于展示该分类下的所有菜单项。用户通过点击整个磁贴(或其中的锚点标签)来访问这个页面。目前,锚点链接只是一个 #,因为我们尚未对其进行编程。

为了实现这个功能,我们需要将其编程为跳转到一个不同的状态(state),这个状态将列出单个分类的所有菜单项。在此之前,我们必须先获取单个分类的数据。
目前,我们拥有的数据是包含名称和描述的分类列表,但还没有单个分类的菜单项数据。这意味着,在配置下一个视图状态之前,我们需要先修改菜单服务,添加一个能从服务器获取特定分类菜单项的新方法。

为了确认我们要查询的数据,让我们打开一个新标签页,查看服务器上的菜单项接口。访问 /menu_items.json 会返回所有菜单项。但我们真正需要的是通过查询参数来筛选,例如 ?category=L。这样会返回短名(short name)为 L 的分类下的所有菜单项。我们需要能够动态地获取这类数据,以便在用户访问特定分类时展示其所有项目。
让我们回到代码编辑器,进入 common 文件夹下的 menu.service.js 文件。我们将编写一个新方法,命名为 getMenuItemsForCategory。这个方法将接收一个分类短名作为参数。


以下是该方法的代码实现:
service.getMenuItemsForCategory = function(category) {
var config = {};
if (category) {
config.params = {'category': category};
}
return $http.get(ApiPath + '/menu_items.json', config)
.then(function(response) {
return response.data;
});
};
在这段代码中:
- 我们创建了一个
config对象来存储请求配置。 - 如果传入了
category参数,我们将其设置为config.params对象的一个属性,这将被$http服务自动转换为 URL 查询参数(例如?category=L)。 - 我们使用
$http.get方法向/menu_items.json接口发起请求,并传入config对象。 - 请求返回一个 Promise,我们使用
.then方法处理响应,并返回response.data,即我们需要的菜单项数据。
保存文件后,我们就为配置下一个视图状态做好了准备,该状态将用于显示单个分类及其所有菜单项。


本节课中,我们一起学习了如何扩展菜单服务,使其能够根据分类短名获取特定的菜单项列表。我们创建了 getMenuItemsForCategory 方法,该方法能构建带有查询参数的 HTTP 请求,并返回处理后的数据。这是构建单个分类视图功能的第一步。在接下来的课程中,我们将利用这个新方法来配置路由和视图,完成单个分类页面的展示。
117:单个分类视图(第2部分)


在本节课中,我们将继续构建单个分类视图。我们将创建一个新的路由状态,用于显示特定分类下的所有菜单项,并为其配置控制器和模板,最终实现数据的动态展示。
回到代码编辑器,我位于第58讲的文件夹中,具体路径是 fullstack/course5/examples。
在继续之前,需要说明一点。在常规开发中,我通常会在添加新功能前停下来进行单元测试。例如,getMenuItems 方法就需要测试,以确保没有拼写错误或逻辑问题。然而,本课程内容较多,如果详细演示每个测试,课程时长会大大增加,也会打断我们构建功能的连贯性。因此,我必须提醒你:在你自己的项目中,编写完代码后,务必先进行测试,再继续开发。虽然我在这里没有演示测试,但我会在课程最后提供包含管理部分在内的所有单元测试代码,供你参考。
现在,让我们回到代码。接下来需要为单个分类的菜单项创建另一个路由状态。
我们打开 public.routes.js 文件,向下滚动,在现有状态下方添加一个新状态。
我们调用 state 方法,将这个新状态命名为 public.menuItems,因为它将是 public 状态的子状态,专门用于显示特定分类的菜单项。
我们这样配置这个状态:其 URL 为 /menu/:category。这里需要一个参数 :category,即分类的短名称,以便确定要显示哪个分类的菜单项。
接下来,我们需要为这个状态指定一个模板。设置 templateUrl 为 src/public/menu-items/menu-items.html。这个文件我们尚未创建,但马上就会创建。先保存这个路由文件。
现在,我们来创建模板文件。在 src/public/ 目录下创建 menu-items 文件夹,并在其中创建 menu-items.html 文件。暂时,我们只在文件中写一个简单的标题:
<h1>Yum!</h1>
保存文件。接下来,我们需要配置一个链接来导航到这个新状态。这个链接位于显示所有分类的页面上,即 menu-categories 组件的模板中。
我们找到 menu-category.html 模板,将原来的 href 链接替换为 ui-sref 指令,指向我们刚创建的状态 public.menuItems。同时,我们需要传递 category 参数。我们通过传递一个对象来实现:{ category: category.short_name }。这里的 category.short_name 就是我们要传递的分类短名称。
保存更改,回到浏览器,点击“Menu”链接。现在,点击“Lunch”分类,页面应该会显示“Yum!”,并且URL会变为 /menu/l。返回后,再点击“Appetizers”分类,URL会变为 /menu/b,但页面仍然显示“Yum!”,因为我们还没有为特定分类编程显示实际内容。
让我们回到代码编辑器。现在,我们不再需要 menu-category.html 模板中的占位符内容。既然路由工作正常,我们需要将之前用于显示单个分类菜单项的HTML片段移植过来。
参考第49讲中的旧版网站,我们需要移植两个部分:
- 菜单项的标题部分。
- 实际的菜单项列表片段。
首先,复制标题部分的HTML,粘贴到我们的 menu-items.html 文件中。注意:这次需要将它包裹在一个 container div 中,因为每个子视图需要自己的容器,而之前没有是因为它被插入到了一个已有容器的上下文中。
然后,复制菜单项列表的HTML片段,也粘贴到 menu-items.html 文件中。显然,现在其中的插值表达式(如 {{item.name}})还无法工作。
我们可以在浏览器中尝试一下,可以看到页面结构加载了,但图片和内容没有正确显示,因为我们还没有将控制器与这个状态关联起来。
在关联控制器之前,我们先创建这个控制器。新建一个文件 menu-items.controller.js。
我们使用 IIFE 和严格模式,并在 public 模块中定义控制器:
(function () {
'use strict';
angular.module('public')
.controller('MenuItemsController', MenuItemsController);
MenuItemsController.$inject = ['menuItems'];
function MenuItemsController(menuItems) {
var $ctrl = this;
$ctrl.menuItems = menuItems;
}
})();
这个控制器需要注入 menuItems 数据,这些数据将来自我们稍后在路由状态中配置的 resolve 属性。控制器的作用很简单:将注入的 menuItems 数组暴露给视图。
接下来,我们需要将这个控制器关联到路由状态。回到 public.routes.js 文件,在刚才定义的状态配置对象中,添加 controller 和 controllerAs 属性。同时,添加 resolve 属性来异步获取菜单项数据。
在 resolve 中,我们定义一个返回 Promise 的函数。这个函数需要 MenuService 来调用 getMenuItems 方法,并且需要 $stateParams 来获取URL中的 category 参数。
resolve: {
menuItems: ['$stateParams', 'MenuService', function ($stateParams, MenuService) {
return MenuService.getMenuItems($stateParams.category);
}]
}
保存路由文件和控制器文件。现在,回到 menu-items.html 模板,我们可以尝试通过插值表达式 {{ $ctrl.menuItems | json }} 来输出获取到的数据,以便调试。
保存后刷新浏览器,但发现页面没有按预期显示JSON数据。检查控制台,可能会看到“MenuItemsController is not defined”的错误。这是因为我们忘记在HTML中引入新创建的控制器文件。
我们需要在 index.html 中引入这个控制器。找到引入其他脚本的地方,添加一行:
<script src="src/public/menu-items/menu-items.controller.js"></script>
再次刷新浏览器,现在页面应该能显示出一大串JSON数据了,这证明数据获取和绑定成功了。

我们可以回到控制器,暂时添加一个 console.log 语句来确认数据格式,确认无误后将其移除。

最后一步,我们需要修改 menu-items.html 模板,使用 ng-repeat 等指令来遍历 $ctrl.menuItems,并正确绑定每个菜单项的属性(如名称、描述、价格、图片等),替换掉之前硬编码的HTML,从而实现动态渲染。
至此,单个分类视图的核心数据流和展示框架就搭建完成了。在下一讲中,我们将完善这个页面的样式和细节。


本节课中,我们一起学习了如何为特定分类创建子路由状态,如何通过 resolve 属性在路由激活前异步获取数据,以及如何创建控制器将数据暴露给视图。我们还修复了因未引入脚本文件导致的控制器未定义错误,并初步验证了数据绑定的正确性。
118:单个分类视图(第3部分)📝


在本节课中,我们将继续构建单个分类菜单项的视图。我们将创建一个可复用的组件来展示每个菜单项,并学习如何将数据从父控制器传递到子组件中,最终完成菜单页面的动态渲染。
更新菜单项模板标题
上一节我们设置了路由和控制器来获取特定分类的菜单数据。本节中,我们首先更新模板,以显示从服务器返回的分类名称和特殊说明。
我们打开 menu-items.html 模板文件。需要移除之前的占位符文本,并插入从控制器获取的动态数据。控制器 MenuItemsController 暴露了一个名为 menuItems 的属性,该属性包含 category 和 menu_items 两个部分。
以下是更新标题部分的步骤:
- 使用
{{ menuItems.category.name }}显示分类名称。 - 使用
{{ menuItems.category.special_instructions }}显示特殊说明。
确保这些内容被正确地放置在页面的容器(container)内,以保证页面布局正确。
创建可复用的菜单项组件
接下来,我们需要遍历并显示所有菜单项。与其在模板中使用复杂的 ng-repeat,不如创建一个独立的组件。这样可以使代码更清晰、更易于维护。
我们将在 public 目录下创建一个名为 menu-item 的新文件夹,并在其中创建组件文件。
定义组件
首先,创建组件定义文件 menu-item.component.js:
(function () {
'use strict';
angular.module('public')
.component('menuItem', {
templateUrl: 'public/menu-item/menu-item.html',
bindings: {
menuItem: '<' // 单向数据绑定
},
controller: 'MenuItemController'
});
})();
创建组件模板
然后,创建对应的模板文件 menu-item.html。我们将从主模板中剪切出单个菜单项的HTML结构,并粘贴到此文件中。在模板中,我们需要使用 $ctrl 来访问绑定到组件的数据。
<!-- public/menu-item/menu-item.html -->
<div class="menu-item-tile">
<div class="row">
<div class="col-sm-5">
<div class="menu-item-photo">
<!-- 动态图片URL将在控制器中构建 -->
<img class="img-responsive" width="250" height="150" ng-src="{{$ctrl.basePath}}/images/{{$ctrl.menuItem.short_name}}.jpg" alt="{{$ctrl.menuItem.name}}">
</div>
</div>
<div class="menu-item-description col-sm-7">
<h3 class="menu-item-title">{{$ctrl.menuItem.name}}</h3>
<p class="menu-item-details">{{$ctrl.menuItem.description}}</p>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="menu-item-price">
{{$ctrl.menuItem.price_small | currency}}<span> {{$ctrl.menuItem.small_portion_name}}</span>
{{$ctrl.menuItem.price_large | currency}}<span> {{$ctrl.menuItem.large_portion_name}}</span>
</div>
<hr class="visible-xs">
</div>
</div>
</div>
注意,我们使用了AngularJS的内置 currency 过滤器来格式化价格。图片的 ng-src 属性需要动态构建,这将在控制器中完成。
实现组件控制器
由于需要构建图片的完整URL,我们必须注入之前定义的 ApiPath 常量。因此,我们需要为组件创建一个控制器。
创建 menu-item.controller.js 文件:
(function () {
'use strict';
angular.module('public')
.controller('MenuItemController', MenuItemController);
MenuItemController.$inject = ['ApiPath'];
function MenuItemController(ApiPath) {
var $ctrl = this;
$ctrl.basePath = ApiPath; // 将API基础路径暴露给模板
}
})();
控制器注入了 ApiPath 服务,并将其赋值给 $ctrl.basePath,这样模板就可以使用它来拼接完整的图片URL。
在主模板中使用组件
现在,组件已经准备就绪。我们需要回到 menu-items.html 主模板,使用 ng-repeat 指令遍历菜单项列表,并为每一项渲染我们刚创建的 <menu-item> 组件。
更新 menu-items.html:
<!-- 在显示标题和说明之后 -->
<div class="container">
<!-- ... 标题和特殊说明 ... -->
<menu-item
ng-repeat="menuItem in menuItems.menu_items"
menu-item="menuItem">
</menu-item>
</div>
这里,ng-repeat="menuItem in menuItems.menu_items" 会遍历控制器返回的 menu_items 数组。对于每个 menuItem 对象,我们将其通过 menu-item 属性传递给 <menu-item> 组件。


最终检查与总结

最后,确保在 index.html 中正确引入了新创建的组件和控制器的脚本文件。


<script src="public/menu-item/menu-item.component.js"></script>
<script src="public/menu-item/menu-item.controller.js"></script>
保存所有文件并刷新浏览器。现在,点击不同的菜单分类(如汤类、开胃菜),页面应该能正确显示该分类下的所有菜单项,包括图片、名称、描述和价格。
本节课中我们一起学习了:
- 更新动态标题:在模板中绑定控制器数据,显示分类信息。
- 创建AngularJS组件:将可复用的UI部分封装成独立的组件,提高代码模块化。
- 实现组件控制器:通过依赖注入获取服务(如
ApiPath),为组件模板提供数据。 - 使用组件与数据绑定:在父模板中使用
ng-repeat和自定义组件,并通过属性绑定传递数据。

通过将页面拆分为独立的组件,我们使得每个部分都可以独立开发和测试,这是构建复杂单页应用(SPA)非常有效的方法。至此,我们已成功使用AngularJS重新实现了餐厅菜单网站的核心功能。
119:课程总结


概述
在本节课中,我们将对整个课程进行总结,回顾学习成果,并为你的后续学习与发展提供建议。
课程完成祝贺
恭喜你完成了整个课程。希望你享受学习过程并收获了大量知识。
后续学习建议
那么,接下来该做什么呢?如果你尚未学习过我之前的课程《HTML、CSS 和 JavaScript 网页开发》,我建议你去了解一下。如果你发现自己需要钻研 CSS 并希望 100% 理解 JavaScript 的语言特性,那门课程可能非常适合你。此外,那门课程同样是顶级评分的课程,值得一看。
课程制作与反馈
制作这门课程并将其以最佳方式呈现给你,投入了巨大的工作量。因此,如果你认为它值得,我想请你花一分钟时间,给这门课程一个五星评分。这对我意义重大。
保持联系
最后,请保持联系。你可以在 Twitter 上关注我,在 LinkedIn 上与我建立联系,并加入我为所授课程创建的专属 Facebook 页面。所有这些链接都在本视频下方。
感谢你的观看,祝你一切顺利。

浙公网安备 33010602011771号