前置き
ウェブアプリケーションをもっともっときもちのいい UI に。
ちらつきは悪だ
そのための SPA (シングルページアプリケーション)。
でもいつまでも最初のページで Ajax による遅延描画で画面をガタガタちらつかせている場合じゃない。
だから、最初のページはサーバサイドでコンテンツをレンダリングしておきたい。
これがサーバーサイドレンダリング。
でもどうせだったらブラウザ側で実行しているコード (Javascript) をサーバサイドでもそのままつかってコンテンツをレンダリングできれば一石二鳥では?
これが Isomorphic なアプローチ。
今回の構成
Riot.js
Riot.js は React や Angular など Javascript フレームワーク戦国時代にあらたに出現した UI ライブラリです。まだマイナーですがその文法のセンスがかなり Good。
ここをみてもらえれば共感いただけるかも。
http://riotjs.com/ja/compare/
ThymeleafView vs ScriptTemplateView
Spring 4.2 から ScriptTemplateView が追加されました。ScriptTemplateView は Javascript 製のテンプレートエンジンを使ってビューをレンダリングできるクラスです。
え?もう Thymeleaf はいらないの??
確かに Isomorphic を突き詰めるのであればテンプレートエンジンも Javascript 製であった方がいいのかもしれない。しかしどうだろう、Javascript 製のテンプレートエンジン、たとえば EJS、Handlebars などがテンプレートエンジンとして Tymeleaf に勝ってるだろうか。
否、やっぱり Thymeleaf が好き。
実践
今回は、Riot 公式サイトにある TODO アプリをサーバーサイドレンタリングに対応してみることにする。
http://riotjs.com/play/todo/
Riot は Riot Tag を HTML に変換するメソッドとして riot.render(tagName, [opts]) メソッドを提供している。ようはこのメソッドをサーバサイドで実行して HTML を取得し、Thymeleaf でレンダリングしてやればいい。
http://riotjs.com/api/#rendering
riot.render メソッドはサーバーサイド用なので Riot 本体とは別のファイルで管理されている。
▼riot.render メソッドの実装
https://github.com/riot/riot/blob/master/lib/server/index.js
▼simple-dom ヘルパー
https://github.com/riot/riot/blob/master/lib/server/sdom.js
Polyfill する
window オブジェクトはサーバーサイドには存在しない
Nashorn はブラウザではない、だから window や document オブジェクトなどブラウザが生成する変数は定義されていない。そのため以下のコードを最初に読込んでおく。
var window = this; var history = {}; history.location = {} history.location.href = null; var console = {}; console.debug = print; console.warn = print; console.log = print; var setTimeout = function () {}
require が使えない
Nashorn は require を実装してないため、jvm-npm を読み込んでおく。
https://github.com/nodyn/jvm-npm
HTML テンプレート (Thymeleaf)
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Riot todo</title> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <link rel="stylesheet" th:href="@{/css/todo.css}" href="../static/css/todo.css" /> <!--[if lt IE 9]> <script src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/4.0.5/es5-shim.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.2/html5shiv.min.js"></script> <script>html5.addElements('todo')</script> <![endif]--> </head> <body> <th:block th:utext="${todoTag}"></th:block> <script th:src="@{/js/riot.js}" src="../static/js/riot.js"></script> <script th:src="@{/js/todo.js}" src="../static/js/todo.js"></script> <script th:inline="javascript"> riot.mount('todo', { title: /*[[${title}]]*/ 'I want to behave!', items: /*[[${todos}]]*/ [] }) </script> </body> </html>
Riot タグ
<todo> <h3>{ opts.title }</h3> <ul> <li each={ items.filter(whatShow) }> <label class={ completed: done }> <input type="checkbox" checked={ done } onclick={ parent.toggle }> { title } </label> </li> </ul> <form onsubmit={ add }> <input name="input" onkeyup={ edit }> <button disabled={ !text }>Add #{ items.filter(whatShow).length + 1 }</button> <button disabled={ items.filter(onlyDone).length == 0 } onclick={ removeAllDone }>X{ items.filter(onlyDone).length } </button> </form> <script> var todo = this; todo.items = opts.items edit(e) { todo.text = e.target.value } add(e) { if (todo.text) { var data = new FormData() data.append('title', todo.text) fetch('/todo.json', {method: 'post', body: data}).then(function(response) { return response.json(); }).then(function(json) { todo.items.push({ id: json.id, title: json.title }) todo.text = todo.input.value = '' todo.update(); }) } } removeAllDone(e) { fetch('/todo.json', {method: 'delete'}).then(function(response) { todo.items = todo.items.filter(function(item) { return !item.done }) todo.update(); }) } // an two example how to filter items on the list whatShow(item) { return !item.hidden } onlyDone(item) { return item.done } toggle(e) { fetch('/todo/' + e.item.id + '.json', {method: 'put'}).then(function(response) { return response.json(); }).then(function(json) { var item = e.item item.done = json.done todo.update(); return true }) } </script> </todo>
最初のページのためのコントローラー
@Controller public class IndexController { @Autowired private ResourceLoader resourceLoader; @Autowired private TodoRepository todoRepository; @Autowired private ObjectMapper objectMapper; @RequestMapping("/") public String index(Model model) throws ScriptException, IOException { NashornScriptEngine nashorn = (NashornScriptEngine) new ScriptEngineManager().getEngineByName("nashorn"); nashorn.eval(new InputStreamReader(resourceLoader.getResource("classpath:static/js/jvm-npm.js").getInputStream(), "UTF-8")); nashorn.eval(new InputStreamReader(resourceLoader.getResource("classpath:static/js/nashorn-riot.js").getInputStream(), "UTF-8")); nashorn.eval(new InputStreamReader(resourceLoader.getResource("classpath:static/js/riot.js").getInputStream(), "UTF-8")); nashorn.eval(new InputStreamReader(resourceLoader.getResource("classpath:static/js/riot-render.js").getInputStream(), "UTF-8")); nashorn.eval(new InputStreamReader(resourceLoader.getResource("classpath:static/js/todo.js").getInputStream(), "UTF-8")); String title = "I want to behave!"; List<Todo> todos = todoRepository.findAll(); String todoTag = (String) nashorn.eval(String.format("riot.render('todo', {title: '%s', items: %s})", title, objectMapper.writeValueAsString(todos))); model.addAttribute("title", title); model.addAttribute("todos", todos); model.addAttribute("todoTag", todoTag); return "index"; } }
TODO の CRUD を行う API コントローラー
@RestController @RequestMapping("/todo") public class TodoRestController { @Autowired private TodoRepository todoRepository; @PostMapping public Todo add(@RequestParam String title) { Todo todo = new Todo(); todo.setTitle(title); return todoRepository.save(todo); } @PutMapping("/{id}") public Todo toggle(@PathVariable long id) { Todo todo = todoRepository.findOne(id); todo.setDone(!todo.isDone()); todo = todoRepository.save(todo); return todo; } @DeleteMapping public void removeAllDone() { List<Todo> todos = todoRepository.findAllByDoneIsTrue(); todoRepository.deleteInBatch(todos); } }
以上、今回のソースコードはここにあります。
https://github.com/tagbangers/spring-best-practices/tree/master/spring-best-practice-riot-ssr