visit
In my previous post, I laid the ground to build upon; now is the time to start "for real".
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <!--1-->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId> <!--2-->
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator</artifactId> <!--3-->
<version>0.52</version>
</dependency>
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>vue</artifactId> <!--4-->
<version>3.4.34</version>
</dependency>
</dependencies>
fun vue(todos: List<Todo>) = router { //1
GET("/vue") {
ok().render("vue", mapOf("title" to "Vue.js", "todos" to todos)) //2-3
}
}
Todo
objects
If you're used to developing APIs, you're familiar with the body()
function; it returns the payload directly, probably in JSON format. The render()
passes the flow to the view technology, in this case, Thymeleaf. It accepts two parameters:
/templates
and the prefix is .html
; in this case, Thymeleaf expects a view at /templates/vue.html
<script th:src="@{/webjars/axios/dist/axios.js}" src="//cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"></script> <!--1-->
<script th:src="@{/webjars/vue/dist/vue.global.js}" src="//cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script> <!--2-->
<script th:src="@{/vue.js}" src="../static/vue.js"></script> <!--3-->
<script th:inline="javascript">
/*<![CDATA[*/
window.vueData = { <!--4-->
title: /*[[${ title }]]*/ 'A Title',
todos: /*[[${ todos }]]*/ [{ 'id': 1, 'label': 'Take out the trash', 'completed': false }]
};
/*]]>*/
</script>
As explained in last week's article, one of Thymeleaf's benefits is that it allows both static file rendering and server-side rendering. To make the magic work, I specify a client-side path, i.e., src
, and a server-side path, i.e., th:src
.
Todo
itemsTodo
completed checkbox, it should set/unset the completed
attributeTodo
Todo
to the list of Todo
with the following values:
id
: Server-side computed ID as the max of all other IDs plus 1label
: value of the Label field for label
completed
: set to false
The first step is to bootstrap the framework. We have already set up the reference for our custom vue.js
file above.
document.addEventListener('DOMContentLoaded', () => { //1
// The next JavaScript code snippets will be inside the block
}
The next step is to let Vue manage part of the page. On the HTML side, we must decide which top-level part Vue manages. We can choose an arbitrary <div>
and change it later if need be.
<div id="app">
</div>
On the JavaScript side, we create an app, passing the CSS selector of the previous HTML <div>
.
Vue.createApp({}).mount('#app');
The next step is to create a Vue template. A Vue template is a regular HTML <template>
managed by Vue. You can define Vue in Javascript, but I prefer to do it on the HTML page.
<template id="todos-app"> <!--1-->
<h1>{{ title }}</h1> <!--2-->
</template>
title
property; it remains to be set up
const TodosApp = {
props: ['title'], //1
template: document.getElementById('todos-app').innerHTML,
}
title
property, the one used in the HTML template
Vue.createApp({
components: { TodosApp }, //1
render() { //2
return Vue.h(TodosApp, { //3
title: window.vueData.title, //4
})
}
}).mount('#app');
render()
functionh()
for hyperscript creates a virtual node out of the object and its propertiestitle
property with the value generated server-side
First, I added a new nested Vue template for the table that displays the Todo
. To avoid lengthening the post, I'll avoid describing it in detail. If you're interested, have a look at the .
const TodoLine = {
props: ['todo'],
template: document.getElementById('todo-line').innerHTML
}
<template id="todo-line">
<tr>
<td>{{ todo.id }}</td> <!--1-->
<td>{{ todo.label }}</td> <!--2-->
<td>
<label>
<input type="checkbox" :checked="todo.completed" />
</label>
</td>
</tr>
</template>
Todo
idTodo
labelcompleted
attribute is true
Vue allows event handling via the @
syntax.
<input type="checkbox" :checked="todo.completed" @click="check" />
Vue calls the template's check()
function when the user clicks on the line. We define this function in a setup()
parameter:
const TodoLine = {
props: ['todo'],
template: document.getElementById('todo-line').innerHTML,
setup(props) { //1
const check = function (event) { //2
const { todo } = props
axios.patch( //3
`/api/todo/${todo.id}`, //4
{ checked: event.target.checked } //5
)
}
return { check } //6
}
}
props
array, so we can later access itevent
that triggered the call
<button class="btn btn-warning" @click="cleanup">Cleanup</button>
On the TodosApp
object, we add a function of the same name:
const TodosApp = {
props: ['title', 'todos'],
components: { TodoLine },
template: document.getElementById('todos-app').innerHTML,
setup() {
const cleanup = function() { //1
axios.delete('/api/todo:cleanup').then(response => { //1
state.value.todos = response.data //2-3
})
}
return { cleanup } //1
}
}
state
is where we store the model
In Vue's semantics, the Vue model is a wrapper around data that we want to be reactive. Reactive means two-way binding between the view and the model. We can make an existing value reactive by passing it to the ref()
method:
In Composition API, the recommended way to declare reactive state is using the
ref()
function.
ref()
takes the argument and returns it wrapped within a ref object with a .value property.
To access refs in a component's template, declare and return them from a component's
setup()
function.--
const state = ref({
title: window.vueData.title, //1-2
todos: window.vueData.todos, //1
})
createApp({
components: { TodosApp },
setup() {
return { ...state.value } //3-4
},
render() {
return h(TodosApp, {
todos: state.value.todos, //5
title: state.value.title, //5
})
}
}).mount('#app');
title
. It's not necessary since there's no two-way binding - we don't update the title client-side, but I prefer to keep the handling coherent across all valuesstate
At this point, we have a reactive client-side model.
<tbody>
<tr is="vue:todo-line" v-for="todo in todos" :key="todo.id" :todo="todo"></tr> <!--1-2-->
</tbody>
Todo
objectsis
attribute is crucial to cope with the way the browser parses HTML. See for more detailsWe can now implement a new feature: add a new Todo
from the client. When clicking on the Add button, we read the Label field value, send the data to the API, and refresh the model with the response.
const TodosApp = {
props: ['title', 'todos'],
components: { TodoLine },
template: document.getElementById('todos-app').innerHTML,
setup() {
const label = ref('') //1
const create = function() { //2
axios.post('/api/todo', { label: label.value }).then(response => {
state.value.todos.push(response.data) //3
}).then(() => {
label.value = '' //4
})
}
const cleanup = function() {
axios.delete('/api/todo:cleanup').then(response => {
state.value.todos = response.data //5
})
}
return { label, create, cleanup }
}
}
create()
function properTodo
On the HTML side, we add a button and bind to the create()
function. Likewise, we add the Label field and bind it to the model.
<form>
<div class="form-group row">
<label for="new-todo-label" class="col-auto col-form-label">New task</label>
<div class="col-10">
<input type="text" id="new-todo-label" placeholder="Label" class="form-control" v-model="label" />
</div>
<div class="col-auto">
<button type="button" class="btn btn-success" @click="create">Add</button>
</div>
</div>
</form>
Vue binds the create()
function to the HTML button. It does call it asynchronously and refreshes the reactive Todo
list with the new item returned by the call. We do the same for the Cleanup button, to remove checked Todo
objects.
In this post, I took my first steps in augmenting an SSR app with Vue. It was pretty straightforward. The biggest issue I encountered was for Vue to replace the line template: I didn't read the documentation extensively and missed the is
attribute.
Go further: