Padrões de projeto em JS
LidoEsta aula explora os padrões de projeto mais relevantes em JavaScript, incluindo Module, Factory, Observer e Singleton, com exemplos práticos e idiomáticos. Você aprenderá como aplicar esses padrões para escrever código mais organizado, reutilizável e de fácil manutenção.
Os padrões de projeto são soluções reutilizáveis para problemas comuns no desenvolvimento de software. No JavaScript, eles ganham contornos específicos devido às características da linguagem, como funções de primeira classe, closures, protótipos e o modelo assíncrono. Nesta aula, vamos explorar quatro padrões clássicos — Module, Factory, Observer e Singleton — e ver como implementá-los de forma idiomática no JavaScript moderno.
Dominar esses padrões é essencial para escrever código mais limpo, modular e escalável. Eles são amplamente utilizados em bibliotecas e frameworks, e compreendê-los ajuda a entender melhor ferramentas como React, Vue e Express. Vamos começar com uma visão geral e depois aprofundar em cada padrão, com exemplos práticos que você pode adaptar aos seus projetos.
Module
O padrão Module permite encapsular código em unidades independentes, expondo apenas o que é necessário e protegendo o restante. Isso evita poluição do escopo global e promove a organização do código. Antes da padronização de módulos no ES6, o padrão era implementado com IIFEs (Immediately Invoked Function Expressions) e closures. Hoje, temos os módulos nativos com import e export, mas o conceito permanece o mesmo.
O padrão Module é fundamental para criar bibliotecas e aplicações de grande porte. Ele permite que você isole funcionalidades, mantenha estados privados e exponha uma API pública. Vamos ver um exemplo clássico usando uma IIFE para criar um módulo de contador:
const Counter = (function() {
let count = 0; // variável privada
function increment() {
count++;
}
function decrement() {
count--;
}
function getCount() {
return count;
}
return {
increment,
decrement,
getCount
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.getCount()); // 2
// Counter.count é undefined (privado)
Com os módulos ES6, podemos alcançar o mesmo resultado de forma mais limpa. Crie um arquivo counter.js e exporte as funções:
// counter.js
let count = 0;
export function increment() {
count++;
}
export function decrement() {
count--;
}
export function getCount() {
return count;
}
E importe em outro arquivo:
// main.js
import { increment, getCount } from './counter.js';
increment();
increment();
console.log(getCount()); // 2
O padrão Module é a base para a maioria dos projetos JavaScript modernos. Ele promove o princípio da separação de responsabilidades e facilita testes e manutenção.
Factory
O padrão Factory fornece uma maneira de criar objetos sem especificar a classe exata. Em vez de usar new diretamente, você chama uma função que retorna um novo objeto. Isso é útil quando a criação do objeto envolve lógica complexa ou quando você quer centralizar a criação em um único ponto.
No JavaScript, o padrão Factory é frequentemente implementado com uma função que retorna um objeto literal. Vamos criar uma fábrica de usuários:
function createUser(name, role) {
return {
name,
role,
permissions: role === 'admin' ? ['read', 'write', 'delete'] : ['read'],
describe() {
return `${this.name} (${this.role})`;
}
};
}
const admin = createUser('Alice', 'admin');
const guest = createUser('Bob', 'guest');
console.log(admin.describe()); // Alice (admin)
console.log(guest.permissions); // ['read']
O Factory pode ser usado para retornar diferentes tipos de objetos com base em parâmetros, como uma fábrica de veículos:
function createVehicle(type, brand) {
if (type === 'car') {
return { type: 'car', brand, wheels: 4, start() { return 'Car started'; } };
} else if (type === 'motorcycle') {
return { type: 'motorcycle', brand, wheels: 2, start() { return 'Motorcycle started'; } };
}
throw new Error('Unknown vehicle type');
}
const car = createVehicle('car', 'Toyota');
const motorcycle = createVehicle('motorcycle', 'Honda');
console.log(car.start()); // Car started
console.log(motorcycle.wheels); // 2
O Factory é muito útil em testes, pois permite criar objetos com configurações específicas sem acoplar o código a classes concretas.
Observer
O padrão Observer define uma dependência de um-para-muitos entre objetos: quando um objeto (o sujeito) muda de estado, todos os seus dependentes (observadores) são notificados e atualizados automaticamente. Esse padrão é amplamente usado em interfaces de usuário, sistemas de eventos e arquiteturas reativas.
Em JavaScript, o Observer pode ser implementado com uma classe que mantém uma lista de observadores e métodos para adicionar, remover e notificar. Vamos criar um exemplo simples de um sujeito que notifica observadores:
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(o => o !== observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received: ${data}`);
}
}
const subject = new Subject();
const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify('Hello!');
// Observer 1 received: Hello!
// Observer 2 received: Hello!
Em JavaScript moderno, o padrão Observer é frequentemente usado com eventos do DOM, como addEventListener, e também em bibliotecas como RxJS. Uma implementação mais idiomática pode usar funções como observadores:
function createObservable() {
const observers = [];
return {
subscribe(fn) {
observers.push(fn);
return () => {
const index = observers.indexOf(fn);
if (index > -1) observers.splice(index, 1);
};
},
notify(data) {
observers.forEach(fn => fn(data));
}
};
}
const observable = createObservable();
const unsubscribe = observable.subscribe(data => console.log(`Received: ${data}`));
observable.notify('Event'); // Received: Event
unsubscribe();
observable.notify('No one'); // nada acontece
Esse padrão é essencial para criar sistemas desacoplados e reativos.
Singleton
O padrão Singleton garante que uma classe tenha apenas uma instância e fornece um ponto global de acesso a ela. Em JavaScript, isso é útil para recursos compartilhados, como configurações, conexões de banco de dados ou logs.
No JavaScript, podemos implementar Singleton usando uma variável estática ou um módulo que exporta um objeto único. Vamos ver um exemplo com classe:
class Config {
constructor() {
if (Config.instance) {
return Config.instance;
}
this.settings = {};
Config.instance = this;
}
set(key, value) {
this.settings[key] = value;
}
get(key) {
return this.settings[key];
}
}
const config1 = new Config();
const config2 = new Config();
config1.set('theme', 'dark');
console.log(config2.get('theme')); // dark
console.log(config1 === config2); // true
Uma abordagem mais idiomática e simples é usar um objeto literal ou um módulo ES6, que naturalmente são singletons:
// config.js
export default {
settings: {},
set(key, value) {
this.settings[key] = value;
},
get(key) {
return this.settings[key];
}
};
E usar em qualquer lugar:
// main.js
import config from './config.js';
config.set('theme', 'dark');
O Singleton deve ser usado com moderação, pois pode dificultar testes e criar acoplamento global. Prefira injeção de dependência quando possível.
Exemplos idiomáticos
Vamos agora ver como esses padrões aparecem em código JavaScript moderno, muitas vezes combinados de maneiras elegantes. Um exemplo comum é o uso de Factory com Module para criar uma API de acesso a dados:
// api.js
const createApiClient = (baseURL) => {
let token = null;
const setToken = (newToken) => {
token = newToken;
};
const request = async (path, options = {}) => {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${baseURL}${path}`, { ...options, headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
};
return {
setToken,
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) })
};
};
export default createApiClient;
Outro exemplo é o Observer com eventos personalizados em uma aplicação de carrinho de compras:
class Cart extends EventTarget {
constructor() {
super();
this.items = [];
}
addItem(item) {
this.items.push(item);
this.dispatchEvent(new CustomEvent('cart:updated', { detail: this.items }));
}
}
const cart = new Cart();
cart.addEventListener('cart:updated', (e) => {
console.log('Cart updated:', e.detail);
});
cart.addItem({ id: 1, name: 'Product' });
Esses exemplos mostram como os padrões podem ser adaptados para o estilo moderno do JavaScript, usando classes, módulos e APIs nativas.
É importante lembrar que os padrões não são regras rígidas, mas sim diretrizes. Sempre avalie se o padrão resolve um problema real no seu contexto. Muitas vezes, soluções mais simples são suficientes.
Boas práticas
- Use o padrão Module para evitar poluir o escopo global e organizar o código.
- Prefira Factory quando a criação de objetos for complexa ou variável.
- Use Observer para desacoplar componentes e promover reatividade.
- Evite Singleton excessivo; ele pode esconder dependências e dificultar testes.
- Combine padrões quando necessário, mas mantenha a simplicidade.
Referências
- MDN: Módulos JavaScript
- MDN: Classes JavaScript
- MDN: EventTarget
- Patterns.dev: Classic Design Patterns
- Refactoring Guru: Padrões de Projeto
- Essential JS Design Patterns
Exercícios
- Implemente um módulo de utilitários usando o padrão Module (IIFE) que exponha funções para formatar datas e números. Teste as funções no console.✓ Resposta:
const Utils = (function() { function formatDate(date) { return date.toISOString().split('T')[0]; } function formatNumber(num) { return num.toFixed(2); } return { formatDate, formatNumber }; })(); console.log(Utils.formatDate(new Date())); // 2025-03-08 console.log(Utils.formatNumber(3.14159)); // 3.14 - Crie uma fábrica de animais que retorne objetos com propriedades e métodos específicos (ex.: cachorro late, gato mia). Use o padrão Factory.✓ Resposta:
function createAnimal(type, name) { const sounds = { dog: 'Au au', cat: 'Miau' }; return { type, name, speak() { console.log(`${this.name} faz ${sounds[type]}`); } }; } const dog = createAnimal('dog', 'Rex'); dog.speak(); // Rex faz Au au - Implemente um sistema de eventos com o padrão Observer: um sujeito que mantém uma lista de funções e as chama quando um evento ocorre. Teste com dois observadores.✓ Resposta:
function createEventEmitter() { const listeners = []; return { on(fn) { listeners.push(fn); }, off(fn) { const index = listeners.indexOf(fn); if (index > -1) listeners.splice(index, 1); }, emit(data) { listeners.forEach(fn => fn(data)); } }; } const emitter = createEventEmitter(); const logA = data => console.log('A:', data); const logB = data => console.log('B:', data); emitter.on(logA); emitter.on(logB); emitter.emit('hello'); // A: hello // B: hello - Implemente um Singleton para um logger que registra mensagens com timestamps. Garanta que todas as chamadas usem a mesma instância.✓ Resposta:
class Logger { constructor() { if (Logger.instance) return Logger.instance; this.logs = []; Logger.instance = this; } log(message) { const entry = `${new Date().toISOString()}: ${message}`; this.logs.push(entry); console.log(entry); } } const logger1 = new Logger(); const logger2 = new Logger(); logger1.log('First'); logger2.log('Second'); console.log(logger1 === logger2); // true - Combine Factory e Module para criar uma função que retorna um módulo de carrinho de compras com métodos para adicionar, remover e listar itens. Use o padrão Module para encapsular o estado.✓ Resposta:
function createCart() { let items = []; return { addItem(item) { items.push(item); }, removeItem(id) { items = items.filter(i => i.id !== id); }, listItems() { return items.slice(); } }; } const cart = createCart(); cart.addItem({ id: 1, name: 'Produto A' }); cart.addItem({ id: 2, name: 'Produto B' }); console.log(cart.listItems()); // [{ id: 1, name: 'Produto A' }, { id: 2, name: 'Produto B' }]