DevGang
Авторизоваться

Как написать чистые компоненты Vue

Написание кода, который легко тестировать и который легко читается, может оказаться сложной задачей, особенно с компонентами Vue. В этом сообщении в блоге я собираюсь поделиться дизайнерской идеей, которая улучшит ваши компоненты Vue.

Этот метод не ускорит ваш код, но упростит его тестирование и понимание. Думайте об этом как о способе улучшения вашего стиля кодирования в Vue. Это облегчит вашу жизнь, когда вам понадобится исправить или обновить ваши компоненты.

Независимо от того, являетесь ли вы новичком в Vue или используете его уже некоторое время, этот совет поможет вам сделать ваши компоненты Vue более чистыми и понятными.

Понимание компонентов Vue

Компонент Vue подобен многократно используемому фрагменту головоломки в вашем приложении. Обычно он состоит из трех основных частей:

  1. Просмотр (View): Это раздел шаблона, в котором вы разрабатываете пользовательский интерфейс.
  2. Реактивность (Reactivity): Здесь функции Vue, такие как ref, делают интерфейс интерактивным.
  3. Бизнес-логика (Business Logic): Здесь вы обрабатываете данные или управляете действиями пользователя.

Тематическое исследование: snakeGame.vue

Давайте рассмотрим распространенный компонент Vue, snakeGame.vue. В нем сочетаются представление, реактивность и бизнес-логика, что может усложнить работу с ним.

Пример кода: Традиционный подход

<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>

Скриншот из игры

Проблемы, связанные с традиционным подходом

Когда вы смешиваете представление, реактивность и бизнес-логику в одном файле, компонент часто становится громоздким и сложным в обслуживании. Также становится трудно тестировать с помощью модульных тестов, поскольку в основном приходится выполнять более сложные интеграционные тесты.

Знакомство с паттерном функционального ядра и императивной оболочки

Чтобы решить эти проблемы во Vue, мы используем паттерн «Функциональное ядро, императивная оболочка». Этот шаблон является ключевым в архитектуре программного обеспечения и помогает лучше структурировать код:

Функциональное ядро, шаблон императивной оболочки. В этом дизайне основная логика вашего приложения («Функциональное ядро») остается чистой и без побочных эффектов, что упрощает тестирование. Затем «Императивная оболочка» взаимодействует с внешним миром, например с пользовательским интерфейсом или базами данных, и взаимодействует с чистым ядром.

Что такое чистые функции?

В этом шаблоне чистые функции лежат в основе «Functional Core». Чистая функция - это концепция функционального программирования, и она особенная по двум причинам:

  1. Предсказуемость: Если вы даете чистой функции одни и те же входные данные, она всегда выдает один и тот же результат.
  2. Никаких побочных эффектов: Чистые функции не изменяют ничего за пределами себя. Они не изменяют внешние переменные, не вызывают API-интерфейсы и не выполняют никаких операций ввода/вывода.

Чистые функции проще тестировать, отлаживать и понимать. Они являются основой функционального ядра, сохраняя бизнес-логику вашего приложения чистой и управляемой.

Применение паттерна в Vue

В Vue этот шаблон состоит из двух частей:

  1. Императивная оболочка (Imperative Shell) (useGameSnake.ts): эта часть обрабатывает специфичные для Vue реактивные биты. Здесь ваши компоненты взаимодействуют с Vue, управляя такими вещами, как изменения состояния и события.
  2. Функциональное ядро (Functional Core) (pureGameSnake.ts): Здесь живет ваша чистая бизнес-логика. Он отделен от Vue, что упрощает тестирование и обдумывание основных функций вашего приложения независимо от пользовательского интерфейса.

Реализация pureGameSnake.ts

Файл pureGameSnake.ts инкапсулирует бизнес-логику игры без какой-либо реакции, специфичной для Vue. Такое разделение означает более простое тестирование и более четкую логику.

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;
}

Реализация useGameSnake.ts

В useGameSnake.ts мы управляем состоянием и реактивностью, специфичными для Vue, используя чистые функции из 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 };
}

Рефакторинг gameSnake.vue

Теперь наш gameSnake.vue более сфокусирован и использует useGameSnake.ts для управления состоянием и реактивностью, в то время как представление остается внутри шаблона.

<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>

Преимущества функционального ядра и шаблона императивной оболочки

Функциональное ядро, императивный шаблон оболочки, значительно повышает тестируемость и ремонтопригодность компонентов Vue. Отделяя бизнес-логику от кода, специфичного для фреймворка, этот шаблон предлагает несколько ключевых преимуществ:

Упрощенное тестирование

Когда бизнес-логика переплетается с реактивностью Vue и структурой компонентов, тестирование может быть громоздким. Традиционное модульное тестирование становится сложной задачей, часто приводящей к зависимости от интеграционных тестов, которые являются менее детализированными и более сложными. Извлекая основную логику в чистые функции (как в pureGameSnake.ts), мы можем легко писать модульные тесты для каждой функции. Такая изоляция значительно упрощает тестирование, поскольку каждый элемент логики может быть протестирован независимо от системы реактивности Vue.

Улучшенная ремонтопригодность

Функциональное ядро, императивный шаблон оболочки приводит к более четкому разделению задач. Компоненты Vue становятся компактнее, уделяя основное внимание пользовательскому интерфейсу и реактивности, в то время как чистая бизнес-логика хранится в отдельных файлах, не зависящих от фреймворка. Такое разделение облегчает чтение, понимание и модификацию кода. Обслуживание становится более управляемым, особенно по мере масштабирования приложения.

Фреймворковый агностицизм

Существенным преимуществом этого шаблона является переносимость вашей бизнес-логики. Чистые функции в функциональном ядре не привязаны к какой-либо конкретной структуре пользовательского интерфейса. Если вам когда-нибудь понадобится перейти с Vue на другой фреймворк или если Vue претерпит серьезные изменения, ваша основная логика останется неизменной и может использоваться повторно. Такая гибкость защищает ваш код от технологических сдвигов и изменений требований проекта.

Сложности тестирования традиционных компонентов Vue по сравнению с функциональным ядром и императивным шаблоном оболочки

Проблемы тестирования традиционных компонентов

Тестирование традиционных компонентов Vue, где просмотр, реактивность и бизнес-логика тесно переплетены, может быть довольно сложной задачей. В таких компонентах модульные тесты становится трудно эффективно внедрять, потому что:

  • Тесты часто в конечном итоге больше похожи на интеграционные тесты, которые являются более широкими и менее точными.
  • Имитация зависимостей и системы реактивности Vue может быть сложной и отнимать много времени.
  • Обеспечение того, чтобы тесты охватывали все аспекты функциональности компонента, включая его реактивное поведение и побочные эффекты, добавляет сложности.

Такая сложность тестирования может привести к снижению доверия к тестам и, как следствие, к стабильности самого компонента.

Упрощенное тестирование с функциональным ядром и императивным шаблоном оболочки

Благодаря рефакторингу компонентов с использованием шаблона Functional Core, Imperative Shell тестирование становится намного проще:

  • Изолированная бизнес-логика: С чистыми функциями в функциональном ядре вы можете писать простые модульные тесты для своей бизнес-логики, не беспокоясь о реактивности Vue или состояниях компонентов.
  • Предсказуемые результаты: Чистые функции выдают предсказуемые выходные данные для заданных входных данных, что упрощает их тестирование.
  • Снижение сложности: Поскольку реактивные части вашего кода и части, связанные с побочными эффектами, изолированы в императивной оболочке, вы можете сосредоточиться на тестировании взаимодействия с реактивностью Vue по отдельности. Такое разделение упрощает тестирование каждой части.

Конечным результатом является более модульная, тестируемая и поддерживаемая база кода, где каждая часть может быть протестирована изолированно, что приводит к более высокому качеству и надежности компонентов Vue.

Вывод

Внедрение функционального ядра, шаблона императивной оболочки в приложениях Vue приводит к созданию более надежной, тестируемой и поддерживаемой базы кода. Это не только помогает в текущем процессе разработки, но и подготавливает ваш код к будущим изменениям и обеспечивает масштабируемость. Такой подход, хотя и требует первоначальных усилий по реструктуризации, значительно окупается в долгосрочной перспективе, что делает его разумным выбором для любого разработчика Vue, стремящегося улучшить архитектуру и качество своего приложения.

Источник:

#Vue.js
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу