How to Write Clean Vue Components

Alexander Opalic - Jan 28 - - Dev Community

Introduction

Writing code that's both easy to test and easy to read can be a challenge, especially with Vue components. In this blog post, I'm going to share a design idea that will make your Vue components better.

This method won't speed up your code, but it will make it simpler to test and understand. Think of it as a big-picture way to improve your Vue coding style. It's going to make your life easier when you need to fix or update your components.

Whether you're new to Vue or have been using it for some time, this tip will help you make your Vue components cleaner and more straightforward.


Understanding Vue Components

A Vue component is like a reusable puzzle piece in your app. Usually, it has three main parts:

  1. View: This is the template section where you design the user interface.
  2. Reactivity: Here, Vue's features like ref make the interface interactive.
  3. Business Logic: This is where you process data or manage user actions.

Image description


Case Study: snakeGame.vue

Let's look at a common Vue component, snakeGame.vue. It mixes the view, reactivity, and business logic, which can make it complex and hard to work with.

Code Sample: Traditional Approach

<template>
  <div class="game-container">
    <canvas ref="canvas" width="400" height="400"></canvas>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';

const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
let snake = [{ x: 200, y: 200 }];
let direction = { x: 0, y: 0 };
let lastDirection = { x: 0, y: 0 };
let food = { x: 0, y: 0 };
const gridSize = 20;
let gameInterval: number | null = null;

onMounted(() => {
  if (canvas.value) {
    ctx.value = canvas.value.getContext('2d');
    resetFoodPosition();
    gameInterval = setInterval(gameLoop, 100);
  }
  window.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown);
});

function handleKeydown(e: KeyboardEvent) {
  e.preventDefault();
  switch (e.key) {
    case 'ArrowUp': if (lastDirection.y !== 0) break; direction = { x: 0, y: -gridSize }; break;
    case 'ArrowDown': if (lastDirection.y !== 0) break; direction = { x: 0, y: gridSize }; break;
    case 'ArrowLeft': if (lastDirection.x !== 0) break; direction = { x: -gridSize, y: 0 }; break;
    case 'ArrowRight': if (lastDirection.x !== 0) break; direction = { x: gridSize, y: 0 }; break;
  }
}

function gameLoop() {
  updateSnakePosition();
  if (checkCollision()) {
    endGame();
    return;
  }
  checkFoodCollision();
  draw();
  lastDirection = { ...direction };
}

function updateSnakePosition() {
  for (let i = snake.length - 2; i >= 0; i--) {
    snake[i + 1] = { ...snake[i] };
  }
  snake[0].x += direction.x;
  snake[0].y += direction.y;
}

function checkCollision() {
  return snake[0].x < 0 || snake[0].x >= 400 || snake[0].y < 0 || snake[0].y >= 400 ||
         snake.slice(1).some(segment => segment.x === snake[0].x && segment.y === snake[0].y);
}

function checkFoodCollision() {
  if (snake[0].x === food.x && snake[0].y === food.y) {
    snake.push({ ...snake[snake.length - 1] });
    resetFoodPosition();
  }
}

function resetFoodPosition() {
  food = {
    x: Math.floor(Math.random() * 20) * gridSize,
    y: Math.floor(Math.random() * 20) * gridSize,
  };
}

function draw() {
  if (!ctx.value) return;
  ctx.value.clearRect(0, 0, 400, 400);
  drawGrid();
  drawSnake();
  drawFood();
}

function drawGrid() {
  if (!ctx.value) return;
  ctx.value.strokeStyle = '#ddd';
  for (let i = 0; i <= 400; i += gridSize) {
    ctx.value.beginPath();
    ctx.value.moveTo(i, 0);
    ctx.value.lineTo(i, 400);
    ctx.value.stroke();
    ctx.value.moveTo(0, i);
    ctx.value.lineTo(400, i);
    ctx.value.stroke();
  }
}

function drawSnake() {
  ctx.value.fillStyle = 'green';
  snake.forEach(segment => {
    ctx.value.fillRect(segment.x, segment.y, gridSize, gridSize);
  });
}

function drawFood() {
  ctx.value.fillStyle = 'red';
  ctx.value.fillRect(food.x, food.y, gridSize, gridSize);
}

function endGame() {
  clearInterval(gameInterval as number);
  alert('Game Over');
}

</script>

<style>
.game-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Screenshot from the game

Image description

Challenges with the Traditional Approach

When you mix the view, reactivity, and business logic all in one file, the component often becomes bulky and hard to maintain. It also becomes difficult to test with unit tests, as you mostly end up doing more complex integration tests.


Introducing the Functional Core, Imperative Shell Pattern

To solve these problems in Vue, we use the "Functional Core, Imperative Shell" pattern. This pattern is key in software architecture and helps you structure your code better:

Functional Core, Imperative Shell Pattern: In this design, the main logic of your app (the 'Functional Core') stays pure and without side-effects, making it easy to test. The 'Imperative Shell' then deals with the outside world, like the UI or databases, and talks to the pure core.

Image description

What Are Pure Functions?

In this pattern, pure functions are at the heart of the 'Functional Core'. A pure function is a concept from functional programming and it's special for two reasons:

  1. Predictability: If you give a pure function the same inputs, it always gives back the same output.
  2. No Side Effects: Pure functions don't change anything outside of them. They don't alter external variables, call APIs, or do any input/output.

Pure functions are simpler to test, debug, and understand. They are the foundation of the Functional Core, keeping your app's business logic clean and manageable.


Applying the Pattern in Vue

In Vue, this pattern has two parts:

  • Imperative Shell (useGameSnake.ts): This part handles the Vue-specific reactive bits. It's where your components interact with Vue, managing things like state changes and events.
  • Functional Core (pureGameSnake.ts): This is where your pure business logic lives. It's separate from Vue, which makes it easier to test and think about your app's main functions, independent of the UI.

Implementing pureGameSnake.ts

The pureGameSnake.ts file encapsulates the game's business logic without any Vue-specific reactivity. This separation means easier testing and clearer logic.

export const gridSize = 20;

export function initializeSnake() {
  return [{ x: 200, y: 200 }];
}

export function moveSnake(snake, direction) {
  let newSnake = snake.map((segment, index) => {
    if (index === 0) {
      return { x: segment.x + direction.x, y: segment.y + direction.y };
    }
    return { ...snake[index - 1] };
  });
  return newSnake;
}

export function isCollision(snake) {
  let head = snake[0];
  let hasCollided = head.x < 0 || head.x >= 400 || head.y < 0 || head.y >= 400 ||
                    snake.slice(1).some(segment => segment.x === head.x && segment.y === head.y);
  return hasCollided;
}

export function randomFoodPosition() {
  return {
    x: Math.floor(Math.random() * 20) * gridSize,
    y: Math.floor(Math.random() * 20) * gridSize,
  };
}

export function isFoodEaten(snake, food) {
  let head = snake[0];
  return head.x === food.x && head.y === food.y;
}

Enter fullscreen mode Exit fullscreen mode

Implementing useGameSnake.ts

In useGameSnake.ts, we manage the Vue-specific state and reactivity, leveraging the pure functions from pureGameSnake.ts.

import { onMounted, onUnmounted, ref } from 'vue';
import * as GameLogic from './pureGameSnake.ts';

export function useGameSnake() {
  const snake = ref(GameLogic.initializeSnake());
  const direction = ref({ x: 0, y: 0 });
  const food = ref(GameLogic.randomFoodPosition());
  const gameState = ref<'over' | 'playing'>('playing');
  let gameInterval = null;

  const startGame = () => {
    gameInterval = setInterval(() => {
      snake.value = GameLogic.moveSnake(snake.value, direction.value);

      if (GameLogic.isCollision(snake.value)) {
        gameState.value = 'over';
        clearInterval(gameInterval);
      } else if (GameLogic.isFoodEaten(snake.value, food.value)) {
        snake.value.push({ ...snake.value[snake.value.length - 1] });
        food.value = GameLogic.randomFoodPosition();
      }
    }, 100);
  };

  onMounted(startGame);

  onUnmounted(() => {
    clearInterval(gameInterval);
  });

  return { snake, direction, food, gameState };
}
Enter fullscreen mode Exit fullscreen mode

Refactoring gameSnake.vue

Now, our gameSnake.vue is more focused, using useGameSnake.ts for managing state and reactivity, while the view remains within the template.

<template>
  <div class="game-container">
    <canvas ref="canvas" width="400" height="400"></canvas>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue';
import { useGameSnake } from './useGameSnake.ts';
import { gridSize } from './pureGameSnake';

const { snake, direction, food, gameState } = useGameSnake();
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
let lastDirection = { x: 0, y: 0 };

onMounted(() => {
  if (canvas.value) {
    ctx.value = canvas.value.getContext('2d');
    draw();
  }
  window.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown);
});

watch(gameState, (state) => {
  if (state === 'over') {
    alert('Game Over');
  }
});

function handleKeydown(e: KeyboardEvent) {
  e.preventDefault();
  switch (e.key) {
    case 'ArrowUp': if (lastDirection.y !== 0) break; direction.value = { x: 0, y: -gridSize }; break;
    case 'ArrowDown': if (lastDirection.y !== 0) break; direction.value = { x: 0, y: gridSize }; break;
    case 'ArrowLeft': if (lastDirection.x !== 0) break; direction.value = { x: -gridSize, y: 0 }; break;
    case 'ArrowRight': if (lastDirection.x !== 0) break; direction.value = { x: gridSize, y: 0 }; break;
  }
  lastDirection = { ...direction.value };
}

watch([snake, food], () => {
  draw();
}, { deep: true });

function draw() {
  if (!ctx.value) return;
  ctx.value.clearRect(0, 0, 400, 400);
  drawGrid();
  drawSnake();
  drawFood();
}

function drawGrid() {
  if (!ctx.value) return;
  ctx.value.strokeStyle = '#ddd';
  for (let i = 0; i <= 400; i += gridSize) {
    ctx.value.beginPath();
    ctx.value.moveTo(i, 0);
    ctx.value.lineTo(i, 400);
    ctx.value.stroke();
    ctx.value.moveTo(0, i);
    ctx.value.lineTo(400, i);
    ctx.value.stroke();
  }
}

function drawSnake() {
  ctx.value.fillStyle = 'green';
  snake.value.forEach(segment => {
    ctx.value.fillRect(segment.x, segment.y, gridSize, gridSize);
  });
}

function drawFood() {
  ctx.value.fillStyle = 'red';
  ctx.value.fillRect(food.value.x, food.value.y, gridSize, gridSize);
}
</script>

<style>
.game-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Advantages of the Functional Core, Imperative Shell Pattern

The Functional Core, Imperative Shell pattern greatly enhances the testability and maintainability of Vue components. By decoupling the business logic from the framework-specific code, this pattern offers several key advantages:

Simplified Testing

When business logic is intertwined with Vue's reactivity and component structure, testing can be cumbersome. Traditional unit testing becomes challenging, often leading to reliance on integration tests which are less granular and more complex. By extracting the core logic into pure functions (as in pureGameSnake.ts), we can easily write unit tests for each function. This isolation simplifies testing dramatically, as each piece of logic can be tested independently of Vue's reactivity system.

Enhanced Maintainability

The Functional Core, Imperative Shell pattern results in a clearer separation of concerns. Vue components become leaner, focusing mainly on the user interface and reactivity, while the pure business logic resides in separate, framework-agnostic files. This separation makes the code easier to read, understand, and modify. Maintenance becomes more manageable, especially as the application scales.

Framework Agnosticism

A significant advantage of this pattern is the portability of your business logic. The pure functions in the Functional Core are not tied to any specific UI framework. Should you ever need to switch from Vue to another framework, or if Vue undergoes major changes, your core logic remains intact and reusable. This flexibility safeguards your code against technological shifts and changes in project requirements.

Testing Complexities in Traditional Vue Components vs. Functional Core, Imperative Shell Pattern

Challenges in Testing Traditional Components

Testing traditional Vue components, where view, reactivity, and business logic are all intertwined, can be quite challenging. In such components, unit tests become difficult to implement effectively because:

  • The tests often end up being more like integration tests, which are broader and less precise.
  • Mocking dependencies and Vue's reactivity system can be complicated and time-consuming.
  • Ensuring that tests cover all aspects of the component’s functionality, including its reactive behavior and side effects, adds complexity.

This complexity in testing can lead to less confidence in the tests and, by extension, the stability of the component itself.

Simplified Testing with Functional Core, Imperative Shell Pattern

By refactoring components to use the Functional Core, Imperative Shell pattern, testing becomes much more straightforward:

  • Isolated Business Logic: With pure functions in the Functional Core, you can write simple unit tests for your business logic without worrying about Vue's reactivity or component states.
  • Predictable Outcomes: Pure functions produce predictable outputs for given inputs, making them easy to test.
  • Reduced Complexity: Since the reactive and side-effect-laden parts of your code are isolated in the Imperative Shell, you can focus on testing the interaction with Vue’s reactivity separately. This separation simplifies the testing of each part.

The end result is a more modular, testable, and maintainable codebase, where each piece can be tested in isolation, leading to higher quality and more reliable Vue components.


Conclusion

Implementing the Functional Core, Imperative Shell pattern in Vue applications leads to a more robust, testable, and maintainable codebase. It not only aids in the current development process but also prepares your code for future changes and scalability. This approach, while requiring an upfront effort in restructuring, pays off significantly in the long run, making it a wise choice for any Vue developer looking to improve their application's architecture and quality.

Image description


Enjoyed this post? Follow me on X for more Vue and TypeScript content:

@AlexanderOpalic

. . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player