Angular2-like регистрация компонентов и зависимостей для knockoutjs

в 8:27, , рубрики: angular2, javascript, knockoutjs, TypeScript

Добрый день.

Понравилась атрибутная регистрация компонентов в angular2 и захотелось сделать подобное в проекте с knockoutjs.

@Component({
    selector: "setup-add-edit-street-name",
    template: require("text!./AddEditStreetName.tmpl.html"),
    directives: [BeatSelector]
})
export class AddEditStreetNameComponent extends AddEditModalBaseComponent<StreetNameViewModel> {
    constructor(@Inject("params") params, streetNameService: StreetNameService) {
        super(params, streetNameService);
    }
    
    location = ko.observable()
}

Компоненты в нокауте появились довольно давно. Тем не менее, отсутствие встроенной поддержки dependency injection, как и необходимость отдельной регистрации компонент несколько раздражала.

Dependency Injection

В данной статье я не хочу рассказывать про component loader'ы и как их использовать, чтобы добавить поддержку DI. Скажу лишь, что в итоге модифицировал этот пакет.

Использование:

// регистрации фабрики
kontainer.registerFactory('taskService', ['http', 'fileUploadService', (http, fileUploadService) => new TaskService(http, fileUploadService));

// регистрация самого компонента
ko.components.register('task-component', {
    viewModel: ['params', 'taskService', (service) =>  new TaskComponent(params, service) ],
    template: '<p data-bind="text: name"></p>'
});

params это те параметры, которые были переданы компоненту через разметку.

Проблема здесь в том, что регистрация компонент не слишком удобна. Легко опечататься и легко забыть зарегистрировать сервис; также хотелось сделать зависимости более явными.

Решение

Для реализации задумки нужно понять как работают декораторы в typescript. Если вкратце, то это просто некая функция или фабрика, которая будет вызвана в некоторый момент времени (в какой из можно прочитать в документации).

Декоратор регистрации компонента:

export interface ComponentParams {
    selector: string;
    template?: string;
    templateUrl?: string;
    directives?: Function[];
}

export function Component(options: ComponentParams) {
    return (target: { new (...args) }) => {
        if (!ko.components.isRegistered(options.selector)) {
            if (!options.template && !options.templateUrl) {
                throw Error(`Component ${target.name} must have template`);
            }

            const factory = getFactory(target);
            const config = {
                template: options.template || { require: options.templateUrl },
                viewModel: factory
            };

            ko.components.register(options.selector, config);
        }
    };
}

Как видно, декоратор не делает ничего особенного. Вся магия в функции getFactory:

interface InjectParam {
    index: number;
    dependency: string;
}

const injectMetadataKey = Symbol("inject");

function getFactory(target: { new (...args) }) {
    const deps = Reflect.getMetadata("design:paramtypes", target).map(type => type.name);

    const injectParameters: InjectParam[] = Reflect.getOwnMetadata(injectMetadataKey, target) || [];
    for (const param of injectParameters) {
        deps[param.index] = param.dependency;
    }

    const factory = (...args) => new target(...args);
    return [...deps, factory];
}

Здесь с помощью Reflect.getMetadata(«design:paramtypes», target) мы вытащили информацию о типах принимаемых аргументов в конструкторе компонента (для того, чтобы это заработало, нужно включить опцию в транспайлере typeScript'a — об этом ниже) и затем просто собрали фабрику для IoC из type.name.
Теперь немного подробнее об injectParamateres. Что если мы хотим инжектировать не какой-то инстанс класса, а просто Object, например, конфигурацию приложения или params переданный компоненту? В ангулар2 для этого используется декоратор Inject, применяемый к параметрам конструктора:

    constructor(@Inject("params") params, streetNameService: StreetNameService) {
        super(params, streetNameService);
    }

Вот его реализация:

interface InjectParam {
    index: number;
    dependency: string;
}

const injectMetadataKey = Symbol("inject");

export function Inject(token: string) {
    return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
        const existingInjectParameters: InjectParam[] = Reflect.getOwnMetadata(injectMetadataKey, target, propertyKey) || [];
        existingInjectParameters.push({
            index: parameterIndex,
            dependency: token
        });

        Reflect.defineMetadata(injectMetadataKey, existingInjectParameters, target, propertyKey);
    };
}

И напоследок декоратор регистрации сервиса:

export function Injectable() {
    return (target: { new (...args) }) => {
        if (!kontainer.isRegistered(target.name)) {
            const factory = getFactory(target);
            kontainer.registerFactory(target.name, factory);
        }
    };
}

// использование

@Injectable()
export class StreetNameService {
    constructor(config: AppConfig, @Inject("ApiClientFactory") apiClientFactory: ApiClientFactory) {
        this._apiClient = apiClientFactory(config.endpoints.streetName);
    }
   // ...
}

Как это всё завести?

Поскольку декораторы ещё не вошли в стандарт, для их использования в файле tsconfig.json нужно включить experimentalDecorators и emitDecoratorMetadata.
Также, так как при регистрации зависимостей мы полагаемся на имена функций-конструкторов, то важно включить опцию keep_fnames в настройках UglifyJS.

Исходный код можно найти здесь.

Автор: Veikedo

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js