- PVSM.RU - https://www.pvsm.ru -
В этой статье будет использоваться ГРЯЗНЫЙ, небезопасный, "костыльный", страшный и т. д. метод eval
. Слабонервным не читать!
Сразу скажу, что некоторые проблемы удобства использования решить не удалось: в коде, который будет передан в worker, нельзя использовать замыкание.
Всем нам нравятся новые технологии, и нравится, когда этими технологиями удобно пользоваться. Но в случае с worker это не совсем так. Worker работает с файлом или ссылкой на файл, но это неудобно. Хочется иметь возможность засунуть в worker любую задачу, а не только специально запланированный код.
Что нужно, чтобы сделать работу с worker удобнее? На мой взгляд, следующее:
Для начала нам понадобится протокол общения между worker и основным окном. В целом протокол — это просто структура и типы данных, с помощью которых будут общаться окно браузера и worker. Тут нет ничего сложного. Можно использовать что-то типа этого [1] или написать свою версию. В каждом сообщении у нас будет ID, и данные, характерные для конкретного типа сообщения. Для начала у нас будет два типа сообщений для worker:
Перед тем как приступить к созданию worker, нужно описать файл, который будет работать в worker и поддерживать описанный нами протокол. Я люблю ООП [2], поэтому это будет класс с названием WorkerBody. Этот класс должен подписаться на событие от родительского окна.
self.onmessage = (message) => {
this.onMessage(message.data);
};
Теперь мы можем слушать события от родительского окна. События у нас есть двух видов: те, на которые подразумевается ответ, и все остальные. Обработаем события.
Добавление библиотек и файлов в worker делается при помощи API importScripts [3].
И самое страшное: для запуска произвольной функции мы будем использовать eval.
...
onMessage(message) {
switch (message.type) {
case MESSAGE_TYPE.ADD_LIBS:
this.addLibs(message.libs);
break;
case MESSAGE_TYPE.WORK:
this.doWork(message);
break;
}
}
doWork(message) {
try {
const processor = eval(message.job);
const params = this._parser.parse(message.params);
const result = processor(params);
if (result && result.then && typeof result.then === 'function') {
result.then((data) => {
this.send({ id: message.id, state: true, body: data });
}, (error) => {
if (error instanceof Error) {
error = String(error);
}
this.send({ id: message.id, state: false, body: error });
});
} else {
this.send({ id: message.id, state: true, body: result });
}
} catch (e) {
this.send({ id: message.id, state: false, body: String(e) });
}
}
send(data) {
data.body = this._serializer.serialize(data.body);
try {
self.postMessage(data);
} catch (e) {
const toSet = {
id: data.id,
state: false,
body: String(e)
};
self.postMessage(toSet);
}
}
Метод onMessage
отвечает за получение сообщения и выбор обработчика, doWork
— запускает переданную функцию, а send
отправляет ответ в родительское окно.
Теперь, когда у нас есть содержимое worker, надо научиться сериализовать и парсить любые данные, чтобы передавать их в worker. Начнем с сериализатора. Мы хотим иметь возможность передавать в worker любые данные, в том числе — экземпляры классов, классы и функции. Но с помощью нативных возможностей worker мы можем передать только JSON-like данные. Чтобы обойти этот запрет, нам понадобится eval [4]. Все, что не может принять JSON, мы обернем в соответствующие строковые конструкции и запустим на другой стороне. Чтобы сохранить иммутабельность, полученные данные клонируются на лету, и то, что не может быть сериализовано обычными способами, заменяется служебными объектами, а они в свою очередь заменяются обратно парсером на другой стороне. На первый взгляд может показаться, что эта задача несложная, но существует множество подводных камней. Самое страшное ограничение такого подхода — невозможность использовать замыкание, что несет в себе несколько иной стиль написания кода. Начнем с самого простого, с функции. Для начала надо научиться отличать функцию от конструктора класса.
Попробуем отличить:
static isFunction(Factory){
if (!Factory.prototype) {
// Arrow function has no prototype
return true;
}
const prototypePropsLength = Object.getOwnPropertyNames(Factory.prototype)
.filter(item => item !== 'constructor')
.length;
return prototypePropsLength === 0 && Serializer.getClassParents(Factory).length === 1;
}
static getClassParents(Factory) {
const result = [Factory];
let tmp = Factory;
let item = Object.getPrototypeOf(tmp);
while (item.prototype) {
result.push(item);
tmp = item;
item = Object.getPrototypeOf(tmp);
}
return result.reverse();
}
Первым делом мы выясним, есть ли у функции прототип. Если его нет — это точно функция. Затем мы смотрим на количество свойств в прототипе, и, если в прототипе только конструктор и функция не является наследником другого класса, мы считаем, что это — функция.
Обнаружив функцию, мы просто заменяем ее служебным объектом с полями __type = "serialized-function"
и template
, который равен шаблону данной функции (func.toString()
).
Пока что пропустим класс и разберем экземпляр класса. Далее в данных нам необходимо отличать обычные объекты от экземпляров классов.
static isInstance(some) {
const constructor = some.constructor;
if (!constructor) {
return false;
}
return !Serializer.isNative(constructor);
}
static isNative(data) {
return /function .*?() { [native code] }/.test(data.toString());
}
Мы считаем что объект является обычным, если у него нет конструктора или его конструктор — нативная функция. Опознав экземпляр класса, мы заменяем его служебным объектом с полями:
__type
— 'serialized-instance'data
— данные, которые были в экземпляреindex
— индекс класса этого экземпляра в служебном списке классов.Чтобы передать данные, нам необходимо сделать дополнительное поле: в нем мы будем хранить список всех уникальных классов, которые мы передаем. Самое сложное заключается в том, чтобы при обнаружении класса брать не только его шаблон, но и шаблон всех родительских классов и сохранять их как самостоятельные классы — чтобы каждый "родитель" был передан не более одного раза, — и сохранить проверку на instanceof. Определить класс несложно: это — функция, которая не прошла нашу проверку Serializer.isFunction. При добавлении класса мы проверяем наличие такого класса в списке сериализованных данных и добавляем только уникальные. Код, который собирает класс в шаблон, — довольно большой и лежит тут [5].
В парсере мы сначала обходим все переданные нам классы и компилируем их, если ранее они не были переданы. Затем мы рекурсивно обходим каждое поле данных и заменяем служебные объекты на скомпилированные данные. Самое интересное — в экземпляре класса. У нас есть класс и есть данные, которые были в его экземпляре, но мы не можем просто так создать экземпляр, ведь вызов конструктора может иметь параметры, которых у нас нет. На помощь нам приходит почти забытый метод Object.create [6], который возвращает объект с заданным прототипом. Так мы избегаем вызова конструктора и получаем экземпляр класса, а затем просто переписываем в экземпляр свойства.
Для успешной работы worker нам необходимо иметь парсер и сериализатор внутри worker и снаружи, поэтому мы берем сериализатор, и превращаем в шаблон сериализатор, парсер и тело worker. Из шаблона делаем блоб и создаем ссылку на скачивание через URL.createObjectURL [7] (данный способ может не работать при некоторых "Content-Security-Policy"). Данный способ также подходит для запуска произвольного кода из строки.
_createWorker(customWorker) {
const template = `var MyWorker = ${this._createTemplate(customWorker)};`;
const blob = new Blob([template], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
_createTemplate(WorkerBody) {
const Name = Serializer.getFnName(WorkerBody);
if (!Name) {
throw new Error('Unnamed Worker Body class! Please add name to Worker Body class!');
}
return [
'(function () {',
this._getFullClassTemplate(Serializer, 'Serializer'),
this._getFullClassTemplate(Parser, 'Parser'),
this._getFullClassTemplate(WorkerBody, 'WorkerBody'),
`return new WorkerBody(Serializer, Parser)})();`
].join('n');
}
Таким образом, у нас получилась простая в использовании библиотека, которая может запустить произвольный код в worker. Она поддерживает классы из TypeScript. Например:
const wrapper = workerWrapper.create();
wrapper.process((params) => {
// This code in worker. Cannot use closure!
// do some hard work
return 100; // or return Promise.resolve(100)
}, params).then((result) => {
// result = 100;
});
wrapper.terminate() // terminate for kill worker process
Данная библиотека, к сожалению, далека от идеала. Необходимо добавить поддержку сеттеров и геттеров на классах и объектах, экземпляры классов и ссылки на конструкторы внутри классов, прототипов, статичных свойств. Мы также хотели бы добавить кэширование, сделать альтернативный запуск скриптов без eval
через URL.createObjectURL и добавить в сборку файл с содержимым worker (если недоступно создание "на лету"). Приходите в репозиторий [8]!
Автор: TsDaniil
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/325765
Ссылки в тексте:
[1] этого: https://habr.com/ru/company/waves/blog/455942/
[2] ООП: https://ru.wikipedia.org/wiki/%D0%9E%D0%B1%D1%8A%D0%B5%D0%BA%D1%82%D0%BD%D0%BE-%D0%BE%D1%80%D0%B8%D0%B5%D0%BD%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5_%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5
[3] importScripts: https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/importScripts
[4] eval: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval
[5] тут: https://github.com/tsigel/worker-wrapper/blob/9c79421568533a3995d158f1d392a75f56a67137/src/Serializer.ts#L137
[6] Object.create: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create
[7] URL.createObjectURL: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
[8] репозиторий: https://github.com/tsigel/worker-wrapper
[9] Источник: https://habr.com/ru/post/462155/?utm_campaign=462155&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.