Writing a simple MVC app in vanilla Javascript
While working on my front-end skills I wanted to see what implementing the MVC pattern could look like in a simple vanilla Javascript app.
It’s a good way to get comfortable with a language, without the help of any framework, you end up pushing yourself to come up with conventions and a structure of your own so the result isn’t just a bunch of functions and classes glued together without much order.
What is MVC?
MVC is a software design pattern that divides an application into three separated components: A Model, which holds the business logic, a View which manages how the information is displayed and receives user interaction, and finally a Controller, which glues the other two together while keeping each of their roles separated.
The event class
For this implementation I’m going to make use of an auxiliary Event class, both the model and the view will have events that can be triggered at any point. The event class looks like this:
class Event {
constructor() {
this.listeners = [];
}addListener(listener) {
this.listeners.push(listener);
}trigger(params) {
this.listeners.forEach(listener => { listener(params); });
}
}export default Event;
Pretty straightforward: For any given event, you can add a listener to it (in the form of a callback) that will be called whenever the event is triggered.
The model
As I mentioned before, this should contain all the necessary logic, and while it wouldn’t be very user friendly, we should be able to play a game of TicTacToe by using only this class without relying on any external logic. My implementation of the model is the following:
import Event from './event';class TicTacToe {
constructor() {
this.board = Array(9).fill();
this.currentPlayer = 'X';
this.finished = false; this.updateCellEvent = new Event();
this.victoryEvent = new Event();
this.drawEvent = new Event();
} play(move) {
if (this.finished || move < 0 || move > 8 || this.board[move])
{ return false; } this.board[move] = this.currentPlayer;
this.updateCellEvent.trigger({ move, player: this.currentPlayer }); this.finished = this.victory() || this.draw(); if (!this.finished) { this.switchPlayer(); } return true;
} victory() {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]; const victory = lines.some(l => this.board[l[0]]
&& this.board[l[0]] === this.board[l[1]]
&& this.board[l[1]] === this.board[l[2]]); if (victory) {
this.victoryEvent.trigger(this.currentPlayer);
} return victory;
} draw() {
const draw = this.board.every(i => i); if (draw) {
this.drawEvent.trigger();
} return draw;
} switchPlayer() {
this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X';
}
}export default TicTacToe;
Beyond the specific implementation details, the model has a play method to make a player move and it can trigger 3 different events:
i. updateCellEvent: This event is triggered when the content of a cell in the board changes, the event sends the cell number and its new content.
ii. victoryEvent: This event is triggered whenever a player wins, the event will send the symbol of the winning player.
iii. drawEvent: This event is triggered whenever the game ends in a draw, the event sends no parameters.
The view
import Event from 'event';class View {
constructor() {
this.playEvent = new Event();
} render() {
const board = document.createElement('div');
board.className = 'board'; this.cells = Array(9).fill().map((_, i) => {
const cell = document.createElement('div');
cell.className = 'cell'; cell.addEventListener('click', () => {
this.playEvent.trigger(i);
}); board.appendChild(cell); return cell;
}); this.message = document.createElement('div');
this.message.className = 'message'; document.body.appendChild(board);
document.body.appendChild(this.message);
} updateCell(data) {
this.cells[data.move].innerHTML = data.player;
} victory(winner) {
this.message.innerHTML = `${winner} wins!`;
} draw() {
this.message.innerHTML = "It's a draw!";
}
}export default View;
In this case, the view will only have a single event (playEvent) which is triggered whenever a cell of the board is clicked, and this event will send the number of the cell clicked.
The view also has three other methods: updateCell, victory and draw.
The name of the methods are pretty self-explanatory, and each corresponds to an event triggered by the model, the communication between the two will be handled by the controller.
The Controller
We’ve arrived at the place where we connect everything, as mentioned before, the role of the controller is to glue both the view and the model together while keeping their roles separated.
We will achieve this by creating instances of the view and the model inside the controller. Then for each event of both the model and the view, we will create a handler that connects it to the corresponding method.
The code for the controller is the following:
import TicTacToe from 'model';
import View from 'view';class Controller {
constructor() {
this.model = new TicTacToe();
this.view = new View(); this.view.playEvent.addListener(move => { this.model.play(move); }); this.model.updateCellEvent.addListener(data => { this.view.updateCell(data); });
this.model.victoryEvent.addListener(winner => { this.view.victory(winner); });
this.model.drawEvent.addListener(() => { this.view.draw(); });
} run() {
this.view.render();
}
}export default Controller;
Finally, to run our game, we instantiate a controller object:
import Controller from 'controller';const app = new Controller();app.run();
And that’s it! You can check a live demo here and the full source here to see how everything fits together in the final product.
Testing
One of the advantages of this pattern is testing since the model and the view do not reference each other directly, we can easily test each class in isolation, which makes the tests a lot simpler to write.
Of course, MVC isn’t the only way you can split logic between models and views, modern javascript frameworks like React use other patterns, but for simple applications and especially to get familiar with a new language, implementing MVC it’s a great choice to get started.