到目前为止,我们的Web界面使用传统的HTML内容服务器端呈现。某些类型的应用程序可以利用客户端呈现来避免整页重新加载以及接近本机应用程序的体验,从而改善用户体验。
为此目的存在许多流行的框架。我们为本指南选择了流行的AngularJS框架,但可以同时选择React,Vue.js,Riot或其他框架/库,而不会损失一般性。
单页面应用程序
我们正在构建的维基编辑应用程序允许选择一个页面,并将其前半部分作为HTML预览进行编辑,另一半为Markdown编辑器:
HTML预览是通过在后端调用一个新端点来呈现的。渲染是在Markdown编辑器文本更改时触发的。为了避免在用户忙于输入Markdown时用不必要的请求重载后端,引入了一个延迟,以便只在该延迟期间没有改变时才触发渲染。
应用程序界面也是动态的,因为新页面使删除按钮消失:
Vert.x后端
简化HTTP垂直代码
客户端应用程序需要一个后台来公开:
静态HTML,CSS和JavaScript内容引导Web浏览器中的应用程序,以及
一个Web API,通常是一个HTTP / JSON服务。
我们简化了HTTP verticle实施只覆盖所需要的。从步骤#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(
() -> apiResponse(context, 200, null, null),
t -> apiFailure(context, t));
}
暴露的路线
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)); (1) (2)
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);
});
AngularJS前端
应用程序视图
该界面适合位于的单个HTML文件src/main/resources/webroot/index.html
。该head
部分是:
<html lang="en" ng-app="wikiApp"> (1)
<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> (2)
<style>
body {
padding-top: 2rem;
padding-bottom: 2rem;
}
</style>
</head>
<body>
AngularJS模块被命名
wikiApp
。wiki.js
拥有我们的AngularJS模块和控制器的代码。
正如您在AngularJS之外可以看到的,我们正在使用来自外部CDN的以下依赖项:
由于性能原因,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>
</html>
我们的AngularJS控制器被调用WikiController
并且绑定到一个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> (1)
</div>
</span>
<span>
<button type="button" class="btn btn-secondary" ng-click="reload()"><i class="fa fa-refresh"
aria-hidden="true"></i> Reload</button> (2)
</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> (3)
</span>
</div>
<div class="col-md-12"> (4)
<div class="invisible alert" role="alert" id="alertMessage">
{{alertMessage}}
</div>
</div>
</div>
对于每个wiki页面名称,我们生成一个使用元素,
ng-repeat
并ng-click
在load
被点击时定义控制器action()。刷新按钮绑定到
reload
控制器操作。所有其他按钮的工作方式相同。该
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> (1)
</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
控制器的属性。
应用控制器
在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
为控制器提供DOM范围,以及$http
对后端执行异步HTTP请求,以及$timeout
在给定延迟后触发动作,同时保持与AngularJS生命周期相关联(例如,以确保任何状态修改触发视图更改,而使用经典的setTimeout函数时情况并非如此)。
控制器方法被绑定到$scope
对象。让我们从3个简单的方法开始:
$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
请求方法返回promise)。该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) { (1)
if (markdownRenderingPromise !== null) {
$timeout.cancel(markdownRenderingPromise); (3)
}
markdownRenderingPromise = $timeout(function() {
markdownRenderingPromise = null;
$http.post("/app/markdown", text).then(function(response) { (4)
$scope.updateRendering(response.data);
});
}, 300); (2)
});
$scope.$watch
可以通知状态变化。在这里,我们监视pageMarkdown
绑定到编辑器的属性的更改textarea
。300毫秒是一个精细的延迟,如果没有在编辑器中改变触发渲染。
超时是承诺,所以如果状态发生了变化,我们会取消前一个并创建一个新状态。这是我们如何延迟渲染,而不是在每次击键时进行渲染。
我们要求后端将编辑器文本呈现为HTML,然后刷新预览。