Tagbangers Blog

うわさのRiot.jsとSpring Bootでサーバーサイドレンダリング

前置き

ウェブアプリケーションをもっともっときもちのいい 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