第十章 使用AngularJS的客户端Web应用
版权声明:本文为博主自主翻译,转载请标明出处。 https://blog.csdn.net/elinespace/article/details/807115131
相应代码位于本指南仓库的step-9目录下
截止目前,我们的Web界面使用了传统的服务端渲染HTML内容。某些应用类型可以利用客户端渲染,避免全页面重新加载并且接近本地应用体验,以提升用户体验。
许多受欢迎的框架便是因为这个目的而存在。我们为本指南选择了流行的AngularJS框架,但是可以不失一般性的同等选择React、Vue.js、Riot或者其它框架/库。
10.1 单页应用
我们构建的Wiki编辑应用允许选择一个页面并编辑它,前半屏是一个HTML预览,另外半屏是Markdown编辑器:
HTML预览通过调用我们后端的一个新端口进行渲染。渲染在Markdown编辑器文本变更时触发。为了避免用户忙于键入Markdown时不必要的请求使得后端负载过重,我们引入了一个延迟,以便只有当在延迟期间没有变更时触发渲染。
应用程序界面也是动态的,新建页面时删除按钮不显示:
10.2 Vert.x后端
10.2.1 简化HTTP Verticle代码
客户端应用需要后端暴露:
- 静态HTML、CSS和JavaScript内容给浏览器中的bootstrap应用
- 一个Web API,通常是一个HTTP/JSON服务
我们简化了HTTP Verticle实现以满足需要。从step 8的RxJava版本开始,我们移除了所有服务端渲染代码以及认证和JWT令牌颁发代码,以暴露简单的开放HTTP/JSON接口。
当然,构建一个利用了JWT令牌和认证的版本对于实际部署是有意义的,但是现在我们已经涵盖了这些特征,我们更希望在本指南的这部分集中于必要的部分。
作为一个示例,apiUpdatePage方法的实现代码如下:
private void apiUpdatePage(RoutingContext context) {
int id = Integer.valueOf(context.request().getParam("id"));
JsonObject page = context.getBodyAsJson();
if(!validateJsonPageDocument(context, page, "markdown")) {
return;
}
dbService.rxSavePage(id, page.getString("markdown")).subscribe(v->apiResponse(context, 200, null, null),t->apiFailure(context, t));
}
10.2.2 公开路由
HTTP/JSON API通过与上一步中相同的路由公开:
router.get("/api/pages").handler(this::apiRoot);
router.get("/api/pages/:id").handler(this::apiGetPage);
router.post().handler(BodyHandler.create());
router.post("/api/pages").handler(this::apiCreatePage);
router.put().handler(BodyHandler.create());
router.put("/api/pages/:id").handler(this::apiUpdatePage);
router.delete("/api/pages/:id").handler(this::apiDeletePage);
前端应用静态资源来自/app,我们重定向对“/”的请求到/app/index.html静态文件:
router.get("/app/*").handler(StaticHandler.create().setCachingEnabled(false));①②
router.get("/").handler(context->context.reroute("/app/index.html"));
① 在开发环境中,禁用缓存是有用的
② 默认情况下,期望文件在类路径的webroot包下,因此在Maven或者Gradle项目中文件应被放置在src/main/resources/webroot下。
最后但并非不重要,我们预期应用程序需要后端渲染Markdown为HTML,因此我们提供了一个HTTP POST端点用于该目的:
router.post("/app/markdown").handler(context -> {
String html = Processor.process(context.getBodyAsString());
context.response().putHeader("Content-Type", "text/html")
.setStatusCode(200)
.end(html);
});
10.3 AngularJS前端
本指南不是一份AngularJS的正式介绍(可查看官方入门),我们假设读者已熟悉该框架。
10.3.1应用程序视图
适合于单个HTML文件的界面位于src/main/resources/webroot/index.html。head部分如下:
<html lang="en" ng-app="wikiApp"> ①
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Wiki Angular App</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css"
integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.4/angular.min.js"></script>
<script src="https://cdn.jsdelivr.net/lodash/4.17.4/lodash.min.js"></script>
<script src="/app/wiki.js"></script> ②
<style>
body {
padding-top: 2rem; padding-bottom: 2rem;
}
</style>
</head>
<body>
① AngularJS模块命名为wikiApp
② wiki.js持有我们的AngularJS模块和控制器代码。
如你所见,除AngularJS之外,我们通过外部CDN使用了以下依赖:
- Bootstrap用于我们界面的样式。
- Font Awesome用于提供图标。
- Lodash帮助我们在Javascript代码中采用一些实用的语法。
Bootstrap需要一些更进一步的脚本,出于性能原因可以在文档的底部加载:
<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js" integrity="sha384-A7FZj7v+d/sdmMqp/nOQwliLvUsJfDHW+k9Omg/a/EheAdgtzNs3hpfag6Ed950n" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js" integrity="sha384-vBWWzlZJ8ea9aCX4pEW3rVHjgjt7zpkNpZk+02D9phzyeVkE+jo0ieGizqPLForn" crossorigin="anonymous"></script>
</body>
我们的AngularJS控制器称为WikiController,它绑定到一个div,该div同时也是Bootstrap容器:
<div class="container" ng-controller="WikiController"> <!-- (...) -->
界面顶部的按钮包含以下元素:
<div class="row">
<div class="col-md-12">
<span class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="pageDropdownButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-file-text" aria-hidden="true"></i>Pages
</button>
<div class="dropdown-menu" aria-labelledby="pageDropdownButton">
<a ng-repeat="page in pages track by page.id" class="dropdown-item" ng-click="load(page.id)" href="#">{{page.name}}</a> ①
</div>
</span>
<span>
<button type="button" class="btn btn-secondary" ng-click="reload()">
<i class="fa fa-refresh" aria-hidden="true"></i>Reload
</button>②
</span>
<span>
<button type="button" class="btn btn-secondary" ng-click="newPage()">
<i class="fa fa-plus-square" aria-hidden="true"></i>New page
</button>
</span>
<span class="float-right">
<button type="button" class="btn btn-secondary" ng-click="delete()" ng-show="pageExists()">
<i class="fa fa-trash" aria-hidden="true"></i>Delete page
</button>③
</span>
</div>
<div class="col-md-12">④
<div class="invisible alert" role="alert" id="alertMessage">
{{alertMessage}}
</div>
</div>
</div>
① 对于每个Wiki页面名称,我们使用ng-repeat生成一个元素,ng-click用于定义它被点击时控制器的动作(load)。
② 刷新按钮被绑定到reload控制器action,所有其他按钮的工作方式相同。
③ ng-show指令允许我们显示或者隐藏元素,依赖于控制器pageExists方法的值。
④ div用于显示成功或者失败的通知。
Markdown预览和编辑元素如下:
<div class="row">
<div class="col-md-6" id="rendering"></div>
<div class="col-md-6">
<form>
<div class="form-group">
<label for="markdown">Markdown</label>
<textarea id="markdown" class="form-control" rows="25" ng-model="pageMarkdown"></textarea> ①
</div>
<div class="form-group">
<label for="pageName">Name</label>
<input class="form-control" type="text" value="" id="pageName" ng-model="pageName" ng-disabled="pageExists()">
</div>
<button type="button" class="btn btn-secondary" ng-click="save()">
<i class="fa fa-pencil" aria-hidden="true"></i> Save
</button>
</form>
</div>
</div>
① ng-model绑定textarea内容到控制器的pageMarkdown属性。
10.3.2 应用程序控制器
wiki.js JavaScript以一个AngularJS模块声明作为开始:
'use strict';
angular.module("wikiApp", []).controller("WikiController", ["$scope", "$http", "$timeout", function ($scope, $http, $timeout) {
var DEFAULT_PAGENAME = "Example page";
var DEFAULT_MARKDOWN = "# Example page\n\nSome text _here_.\n";
// (...)
wikiApp模块没有插件依赖,声明了一个单独的WikiController控制器。该控制器需要依赖注入以下对象:
- $scope 向Controller提供DOM范围。
- $http 执行到后台的HTTP异步请求。
- $timeout 当处于AngularJS生命周期时,在指定的延迟之后触发动作(举例来说,确保任何状态修改都会触发视图变更,这些不是使用经典的setTimeout功能的场景)。
Controller方法被绑定到$scope对象。让我们以三个简单的方法作为开始:
$scope.newPage = function() {
$scope.pageId = undefined;
$scope.pageName = DEFAULT_PAGENAME;
$scope.pageMarkdown = DEFAULT_MARKDOWN;
};
$scope.reload = function () {
$http.get("/api/pages").then(function (response) {
$scope.pages = response.data.pages;
});
};
$scope.pageExists = function() {
return $scope.pageId !== undefined;
};
创建一个新页面包括初始化那些需要添加到$scope对象上的控制器属性。从后台重新加载页面对象仅仅是执行一个HTTP GET请求的问题(注意$http请求方法返回promises)。pageExists方法用于显示/隐藏界面中的元素。
加载页面内容也是执行一个HTTP GET请求,并且更新预览DOM:
$scope.load = function (id) {
$http.get("/api/pages/" + id).then(function(response) {
var page = response.data.page;
$scope.pageId = page.id;
$scope.pageName = page.name;
$scope.pageMarkdown = page.markdown;
$scope.updateRendering(page.html);
});
};
$scope.updateRendering = function(html) {
document.getElementById("rendering").innerHTML = html;
};
下面的方法支持保存/更新和删除页面。对于这些操作,我们使用完全的then方法,在方法成功时第一个参数被调用,失败时第二个参数被调用。我们还引入success和error助手方法来显示通知(成功时3秒,错误时5秒):
$scope.save = function() {
var payload;
if ($scope.pageId === undefined) {
payload = {
"name": $scope.pageName,
"markdown": $scope.pageMarkdown
};
$http.post("/api/pages", payload).then(function(ok) {
$scope.reload();
$scope.success("Page created");
var guessMaxId = _.maxBy($scope.pages, function(page) { return page.id; }); $scope.load(guessMaxId.id || 0);
}, function(err) {
$scope.error(err.data.error);
});
}else{
var payload = {
"markdown": $scope.pageMarkdown
};
$http.put("/api/pages/" + $scope.pageId, payload).then(function(ok){
$scope.success("Page saved");
}, function(err) {
$scope.error(err.data.error);
});
}
};
$scope.delete = function() {
$http.delete("/api/pages/" + $scope.pageId).then(function(ok) {
$scope.reload();
$scope.newPage();
$scope.success("Page deleted");
}, function(err) {
$scope.error(err.data.error);
});
};
$scope.success = function(message) {
$scope.alertMessage = message;
var alert = document.getElementById("alertMessage");
alert.classList.add("alert-success");
alert.classList.remove("invisible");
$timeout(function() {
alert.classList.add("invisible");
alert.classList.remove("alert-success");
}, 3000);
};
$scope.error = function(message) {
$scope.alertMessage = message;
var alert = document.getElementById("alertMessage");
alert.classList.add("alert-danger");
alert.classList.remove("invisible");
$timeout(function() {
alert.classList.add("invisible");
alert.classList.remove("alert-danger");
}, 5000);
};
初始化应用程序状态和视图通过获取页面列表完成,以一个空的新页面编辑器开始:
$scope.reload();
$scope.newPage();
最后,是我们如何执行Markdown文本的实时渲染:
var markdownRenderingPromise = null;
$scope.$watch("pageMarkdown", function(text) { ①
if (markdownRenderingPromise !== null) {
$timeout.cancel(markdownRenderingPromise); ③
}
markdownRenderingPromise = $timeout(function() {
markdownRenderingPromise = null;
$http.post("/app/markdown", text).then(function(response) { ④
$scope.updateRendering(response.data);
});
}, 300); ②
});
① $scope.$watch可以通知状态改变。此处我们用于监控绑定到编辑器textarea的pageMarkdown属性的变更。
② 300毫秒是一个可接收的延迟,如果编辑器中没有任何变更,将触发渲染。
③ 超时是一个promise,因此如果状态已经变更,我们会取消上一个并且创建一个新的。这便是我们如何延迟渲染,以取代每次键盘敲击都执行它。
④ 我们请求后台渲染编辑器文本为HTML,然后刷新预览。