Добрый день.
Понравилась атрибутная регистрация компонентов в 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