Padrões de Extensão de API em JavaScript e Design de Validação de Formulários Extensível

Extensão via Prototype

Considere um cenário onde precisamos repetir uma string um número determinado de vezes:

'abc'.replicate(3) // abcabcabc

Uma abordagem comum é vincular o método diretamente ao protótipo de String:

String.prototype.replicate = String.prototype.replicate || function(count) {
    let output = '';
    for (let idx = 0; idx < count; idx++) {
        output += this;
    }
    return output;
};

Plugins jQuery

Existem três abordagens principais para criar plugins jQuery:

  • $.extend() — adiciona métodos estáticos ao namespace jQuery
  • $.fn — adiciona novos métodos encadeáveis a elementos DOM
  • $.widget() — utiliza a fábrica de widgets do jQuery UI para componentes mais robustos

A primiera abordagem é simples e cria funções utilitárias acessíveis diretamente via $:

$.extend({
    greet: function(name) {
        console.log('Olá, ' + (name || 'Visitante') + '!');
    }
});

$.greet();
$.greet('Maria');

Para um plugin orientado a objetos mais estruturado:

const Styler = function(target, config) {
    this.$target = target;
    this.defaults = {
        color: 'red',
        fontSize: '12px',
        textDecoration: 'none'
    };
    this.settings = $.extend({}, this.defaults, config);
};

Styler.prototype = {
    apply: function() {
        return this.$target.css({
            color: this.settings.color,
            fontSize: this.settings.fontSize,
            textDecoration: this.settings.textDecoration
        });
    }
};

$.fn.styleEnhancer = function(options) {
    const instance = new Styler(this, options);
    return instance.apply();
};

Uso:

$(function() {
    $('a').styleEnhancer({
        color: '#2C9929',
        fontSize: '20px'
    });
});

Plugins Vue.js

Plugins Vue podem adicionar funcionalidades globais de diversas formas:

  • Métodos ou propriedades globais
  • Recursos globais como diretivas, filtros e transições
  • Opções de componentes via mixin
  • Métodos de instância em Vue.prototype
  • Bibliotecas com API própria combinando os recursos acima

Todo plugin Vue deve expor um método install:

const CustomPlugin = {
    install(Vue, options) {
        // Método global
        Vue.globalHelper = function() {
            // lógica...
        };

        // Diretiva global
        Vue.directive('my-directive', {
            bind(el, binding) {
                // lógica...
            }
        });

        // Mixin global
        Vue.mixin({
            created() {
                // lógica...
            }
        });

        // Método de instância
        Vue.prototype.$utility = function(config) {
            // lógica...
        };
    }
};

export default CustomPlugin;

Ativação do plugin:

Vue.use(CustomPlugin);
Vue.use(CustomPlugin, { someOption: true });

Construindo um Sistema de Validação de Formulários Extensível

O princípio central é seguir o padrão Strategy, mantendo o sistema aberto para extensão e fechado para modificação.

Estrutura HTML Base

<form id="registerForm" method="post" onsubmit="return handleSubmit()">
    <p>
        <label>Nome de usuário:</label>
        <input type="text" name="userName" />
    </p>
    <p>
        <label>Senha:</label>
        <input type="text" name="password" />
    </p>
    <p>
        <label>Telefone:</label>
        <input type="text" name="phoneNumber" />
    </p>
    <div>
        <button type="submit">Enviar</button>
    </div>
</form>

Implementação Inicial

function handleSubmit() {
    const form = document.getElementById("registerForm");
    const fieldConfigs = [
        {
            value: form.userName.value,
            constraints: [
                { type: 'required', msg: 'Nome de usuário é obrigatório' },
                { type: 'minLength:3', msg: 'Nome deve ter no mínimo 3 caracteres' }
            ]
        },
        {
            value: form.password.value,
            constraints: [
                { type: 'required', msg: 'Senha é obrigatória' },
                { type: 'minLength:6', msg: 'Senha deve ter no mínimo 6 caracteres' }
            ]
        },
        {
            value: form.phoneNumber.value,
            constraints: [
                { type: 'required', msg: 'Telefone é obrigatório' },
                { type: 'isMobile', msg: 'Formato de telefone inválido' }
            ]
        }
    ];

    const error = validator.run(fieldConfigs);
    if (error) {
        alert(error);
        return false;
    }
    return true;
}

const validator = (function() {
    const strategies = {
        required: function(input, msg) {
            if (!input) return msg;
        },
        minLength: function(input, min, msg) {
            if (String(input).length < min) return msg;
        },
        maxLength: function(input, max, msg) {
            if (String(input).length > max) return msg;
        },
        isMobile: function(input, msg) {
            if (!/^1\d{10}$/.test(input)) return msg;
        },
        isTel: function(input, msg) {
            if (!/^\d{3}-\d{8}|\d{4}-\d{7}|\d{11}$/.test(input)) return msg;
        }
    };

    return {
        run(configs) {
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) {
                    return 'Campo não encontrado';
                }
                for (let j = 0; j < configs[i].constraints.length; j++) {
                    const c = configs[i].constraints[j];
                    const parts = c.type.split(':');
                    const name = parts.shift();
                    parts.unshift(configs[i].value);
                    parts.push(c.msg);
                    const result = strategies[name].apply(null, parts);
                    if (result) return result;
                }
            }
        }
    };
})();

Tornando o Sistema Extensível

O problema da abordagem acima é que regras específicas não podem ser adicionadas sem modificar o objeto strategies. A solução é expor um método de registro:

const validator = (function() {
    const strategies = {
        required: function(input, msg) {
            if (!input) return msg;
        },
        minLength: function(input, min, msg) {
            if (String(input).length < min) return msg;
        },
        maxLength: function(input, max, msg) {
            if (String(input).length > max) return msg;
        },
        isMobile: function(input, msg) {
            if (!/^1\d{10}$/.test(input)) return msg;
        },
        isTel: function(input, msg) {
            if (!/^\d{3}-\d{8}|\d{4}-\d{7}|\d{11}$/.test(input)) return msg;
        }
    };

    return {
        run(configs) {
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) {
                    return 'Campo não encontrado';
                }
                for (let j = 0; j < configs[i].constraints.length; j++) {
                    const c = configs[i].constraints[j];
                    const parts = c.type.split(':');
                    const name = parts.shift();
                    parts.unshift(configs[i].value);
                    parts.push(c.msg);
                    const result = strategies[name].apply(null, parts);
                    if (result) return result;
                }
            }
        },

        register(name, fn) {
            strategies[name] = fn;
        }
    };
})();

Registrando uma regra personalizada para validar apenas caracteres alfanuméricos:

validator.register('isAlphaNum', function(input, msg) {
    if (/[^a-zA-Z0-9]/.test(input)) return msg;
});

function handleSubmit() {
    const form = document.getElementById("registerForm");
    validator.register('isAlphaNum', function(input, msg) {
        if (/[^a-zA-Z0-9]/.test(input)) return msg;
    });

    const fieldConfigs = [
        {
            value: form.userName.value,
            constraints: [
                { type: 'required', msg: 'Nome de usuário é obrigatório' },
                { type: 'minLength:3', msg: 'Nome deve ter no mínimo 3 caracteres' },
                { type: 'isAlphaNum', msg: 'Nome deve conter apenas letras e números' }
            ]
        },
        {
            value: form.password.value,
            constraints: [
                { type: 'required', msg: 'Senha é obrigatória' },
                { type: 'minLength:6', msg: 'Senha deve ter no mínimo 6 caracteres' }
            ]
        },
        {
            value: form.phoneNumber.value,
            constraints: [
                { type: 'required', msg: 'Telefone é obrigatório' },
                { type: 'isMobile', msg: 'Formato de telefone inválido' }
            ]
        }
    ];

    const error = validator.run(fieldConfigs);
    if (error) {
        alert(error);
        return false;
    }
    return true;
}

Coletando Todos os Erros Simultaneamente

Para exibir múltiplas mensagens de erro ao mesmo tempo, adicionamos um método runAll:

const validator = (function() {
    const strategies = {
        required: function(input, msg) {
            if (!input) return msg;
        },
        minLength: function(input, min, msg) {
            if (String(input).length < min) return msg;
        },
        maxLength: function(input, max, msg) {
            if (String(input).length > max) return msg;
        },
        isMobile: function(input, msg) {
            if (!/^1\d{10}$/.test(input)) return msg;
        },
        isTel: function(input, msg) {
            if (!/^\d{3}-\d{8}|\d{4}-\d{7}|\d{11}$/.test(input)) return msg;
        }
    };

    return {
        run(configs) {
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) return 'Campo não encontrado';
                for (let j = 0; j < configs[i].constraints.length; j++) {
                    const c = configs[i].constraints[j];
                    const parts = c.type.split(':');
                    const name = parts.shift();
                    parts.unshift(configs[i].value);
                    parts.push(c.msg);
                    const result = strategies[name].apply(null, parts);
                    if (result) return result;
                }
            }
        },

        runAll(configs) {
            const errors = [];
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) return 'Campo não encontrado';
                for (let j = 0; j < configs[i].constraints.length; j++) {
                    const c = configs[i].constraints[j];
                    const parts = c.type.split(':');
                    const name = parts.shift();
                    parts.unshift(configs[i].value);
                    parts.push(c.msg);
                    const result = strategies[name].apply(null, parts);
                    if (result) {
                        errors.push({
                            field: configs[i].alias,
                            rule: name,
                            message: result
                        });
                        break;
                    }
                }
            }
            return errors.length > 0 ? errors : false;
        },

        register(name, fn) {
            strategies[name] = fn;
        }
    };
})();

HTML ajustado com elementos para exibição de erro:

<form id="registerForm" method="post" onsubmit="return handleSubmit()">
    <p>
        <label>Nome de usuário:</label>
        <input type="text" name="userName" />
        <span class="error-msg"></span>
    </p>
    <p>
        <label>Senha:</label>
        <input type="text" name="password" />
        <span class="error-msg"></span>
    </p>
    <p>
        <label>Telefone:</label>
        <input type="text" name="phoneNumber" />
        <span class="error-msg"></span>
    </p>
    <div>
        <button type="submit">Enviar</button>
    </div>
</form>

<style>
    .error-msg { color: red; }
</style>

function handleSubmit() {
    const form = document.getElementById("registerForm");
    validator.register('isAlphaNum', function(input, msg) {
        if (/[^a-zA-Z0-9]/.test(input)) return msg;
    });

    const fieldConfigs = [
        {
            value: form.userName.value,
            alias: 'userName',
            constraints: [
                { type: 'required', msg: 'Nome de usuário é obrigatório' },
                { type: 'minLength:3', msg: 'Nome deve ter no mínimo 3 caracteres' },
                { type: 'isAlphaNum', msg: 'Nome deve conter apenas letras e números' }
            ]
        },
        {
            value: form.password.value,
            alias: 'password',
            constraints: [
                { type: 'required', msg: 'Senha é obrigatória' },
                { type: 'minLength:6', msg: 'Senha deve ter no mínimo 6 caracteres' }
            ]
        },
        {
            value: form.phoneNumber.value,
            alias: 'phoneNumber',
            constraints: [
                { type: 'required', msg: 'Telefone é obrigatório' },
                { type: 'isMobile', msg: 'Formato de telefone inválido' }
            ]
        }
    ];

    const errors = validator.runAll(fieldConfigs);
    if (errors) {
        errors.forEach(function(err) {
            const inputEl = document.querySelector('#registerForm [name="' + err.field + '"]');
            inputEl.nextElementSibling.innerHTML = err.message;
        });
        return false;
    }
    return true;
}

Validação em Tempo Real com Eventos de Blur

Para suportar validação ao perder o foco, adicionamos um método bindEvents:

const validator = (function() {
    const strategies = {
        required: function(input, msg) {
            if (!input) return msg;
        },
        minLength: function(input, min, msg) {
            if (String(input).length < min) return msg;
        },
        maxLength: function(input, max, msg) {
            if (String(input).length > max) return msg;
        },
        isMobile: function(input, msg) {
            if (!/^1\d{10}$/.test(input)) return msg;
        },
        isTel: function(input, msg) {
            if (!/^\d{3}-\d{8}|\d{4}-\d{7}|\d{11}$/.test(input)) return msg;
        }
    };

    function executeRule(value, constraint) {
        const parts = constraint.type.split(':');
        const name = parts.shift();
        parts.unshift(value);
        parts.push(constraint.msg);
        return strategies[name].apply(null, parts);
    }

    return {
        run(ruleMap, configs) {
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) return 'Campo não encontrado';
                const rules = ruleMap[configs[i].alias];
                for (let j = 0; j < rules.length; j++) {
                    const result = executeRule(configs[i].value, rules[j]);
                    if (result) return result;
                }
            }
        },

        runAll(ruleMap, configs) {
            const errors = [];
            for (let i = 0; i < configs.length; i++) {
                if (configs[i].value === undefined) return 'Campo não encontrado';
                const rules = ruleMap[configs[i].alias];
                for (let j = 0; j < rules.length; j++) {
                    const result = executeRule(configs[i].value, rules[j]);
                    if (result) {
                        errors.push({
                            field: configs[i].alias,
                            message: result
                        });
                        break;
                    }
                }
            }
            return errors.length > 0 ? errors : false;
        },

        bindEvents(config) {
            const ruleEntries = config.rules;
            for (const fieldName in ruleEntries) {
                if (!ruleEntries.hasOwnProperty(fieldName)) continue;

                const inputEl = document.querySelector(config.container + ' [name="' + fieldName + '"]');
                const blurRules = ruleEntries[fieldName].filter(function(r) {
                    return r.trigger === 'blur';
                });

                (function(dom, domRules) {
                    dom.addEventListener('blur', function() {
                        const val = dom.value;
                        let foundError = '';
                        for (let k = 0; k < domRules.length; k++) {
                            const result = executeRule(val, domRules[k]);
                            if (result) {
                                foundError = domRules[k].msg;
                                break;
                            }
                        }
                        dom.nextElementSibling.innerHTML = foundError;
                    }, false);
                })(inputEl, blurRules);
            }
        },

        register(name, fn) {
            strategies[name] = fn;
        }
    };
})();

validator.register('isAlphaNum', function(input, msg) {
    if (/[^a-zA-Z0-9]/.test(input)) return msg;
});

const ruleMap = {
    userName: [
        { type: 'required', msg: 'Nome de usuário é obrigatório', trigger: 'blur' },
        { type: 'minLength:3', msg: 'Nome deve ter no mínimo 3 caracteres', trigger: 'blur' },
        { type: 'isAlphaNum', msg: 'Nome deve conter apenas letras e números', trigger: 'blur' }
    ],
    password: [
        { type: 'required', msg: 'Senha é obrigatória', trigger: 'blur' },
        { type: 'minLength:6', msg: 'Senha deve ter no mínimo 6 caracteres', trigger: 'blur' }
    ],
    phoneNumber: [
        { type: 'required', msg: 'Telefone é obrigatório', trigger: 'blur' },
        { type: 'isMobile', msg: 'Formato de telefone inválido', trigger: 'blur' }
    ]
};

validator.bindEvents({
    container: '#registerForm',
    rules: ruleMap
});

function handleSubmit() {
    const form = document.getElementById("registerForm");
    const fieldConfigs = [
        { value: form.userName.value, alias: 'userName' },
        { value: form.password.value, alias: 'password' },
        { value: form.phoneNumber.value, alias: 'phoneNumber' }
    ];

    const errors = validator.runAll(ruleMap, fieldConfigs);
    if (errors) {
        errors.forEach(function(err) {
            const inputEl = document.querySelector('#registerForm [name="' + err.field + '"]');
            inputEl.nextElementSibling.innerHTML = err.message;
        });
        return false;
    }
    return true;
}

HTML e CSS permanecem os mesmos da seção anterior, com os elementos <span class="error-msg"> ao lado de cada input.

Publicado em 10-15 17:40