Как минимизировать/сжать тысячи JS-файлов, в том числе несколько больших, одновременно или последовательно без сбоя консоли?

Контекст

С помощью демонстрации я сейчас рефакторинг, у меня есть папка src, которая содержит 196 МБ. Около 142 МБ состоят из двух бинарных файлов.

Около 2000 из оставшихся 2137 файлов (что составляет около 46 МБ) состоит из файлов JavaScript, большинство из которых принадлежат официальным и полным дистрибутивам двух крупных фреймворков. Самый большой файл JavaScript весит около 23 МБ. Это неминифицированный код, изначально написанный на C++ и скомпилированный с помощью emscripten в asm.

Я хотел написать скрипт Node.js, который копирует все мои файлы из пути src в путь dist и минимизирует каждый файл JS или CSS, который встречается на этом пути. К сожалению, количество и/или размер задействованных файлов JS, кажется, ломают мой сценарий.


Давайте пройдемся по шагам, которые я предпринял...

Шаг 1

Я начал с написания небольшого скрипта сборки, который копировал все данные из моей папки src в мою папку dist. Я был удивлен, узнав, что этот процесс завершается за считанные секунды.

Ниже приведен мой код для этого скрипта. Обратите внимание, что вам понадобится Node 8 для запуска этого кода.

const util = require('util');
const fs = require('fs');
const path = require('path');

const mkdir = util.promisify(require('mkdirp'));
const rmdir = util.promisify(require('rimraf'));
const ncp = util.promisify(require('ncp').ncp);
const readdir = util.promisify(fs.readdir);
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);

const moveFrom = path.join(__dirname,"../scr");
const moveTo = path.join(__dirname,"../dist");

var copyFile = function(source, target) {
    return new Promise(function(resolve,reject){
        const rd = fs.createReadStream(source);
        rd.on('error', function(error){
            reject(error);
        });
        const wr = fs.createWriteStream(target);
        wr.on('error', function(error){
            reject(error);
        });
        wr.on('close', function(){
            resolve();
        });
        rd.pipe(wr);
    });
};

var copy = function(source, target) {
    stat(source)
    .then(function(stat){
        if(stat.isFile()) {
            console.log("Copying file %s", source);
            switch (path.extname(target)) {
                default:
                    return copyFile(source, target);
            }
        } else if( stat.isDirectory() ) {
            return build(source, target);
        }
    }).catch(function(error){
        console.error(error);
    });
};

var build = function(source, target) {
    readdir(source)
    .then(function(list) {
        return rmdir(target).then(function(){
            return list;
        });
    })
    .then(function(list) {
        return mkdir(target).then(function(){
            return list;
        });
    }).then(function(list) {
        list.forEach(function(item, index) {
            copy(path.join(source, item), path.join(target, item));
        });
    }).catch(function(error){
        console.error(error);
    })
};

build(moveFrom, moveTo);

Шаг 2

Чтобы минимизировать мои CSS-файлы всякий раз, когда я с ними сталкивался, я добавил минимизацию CSS.

Для этого я внес следующие изменения в свой код.

Сначала я добавил эту функцию:

var uglifyCSS = function(source, target) {
    readFile(source, "utf8")
    .then(function(content){
        return writeFile(target, require('ycssmin').cssmin(content), "utf8");
    }).catch(function(error){
        console.error(error);
    });
}

Затем я изменил свою функцию копирования, например:

var copy = function(source, target) {
    stat(source)
    .then(function(stat){
        if(stat.isFile()) {
            console.log("Copying file %s", source);
            switch (path.extname(target)) {
            case ".css":
                return uglifyCSS(source, target);
            default:
                return copyFile(source, target);
            }
        } else if( stat.isDirectory() ) {
            return build(source, target);
        }
    }).catch(function(error){
        console.error(error);
    });
};

Все идет нормально. На этом этапе все еще идет гладко.

Шаг 3

Затем я сделал то же самое, чтобы минимизировать свой JS.

Итак, я снова добавил новую функцию:

var uglifyJS = function(source, target) {
    readFile(source, "utf8")
    .then(function(content){
        return writeFile(target, require('uglify-js').minify(content).code, "utf8");
    }).catch(function(error){
        console.error(error);
    });
}

Затем я снова изменил свою функцию копирования:

var copy = function(source, target) {
    stat(source)
    .then(function(stat){
        if(stat.isFile()) {
            console.log("Copying file %s", source);
            switch (path.extname(target)) {
            case ".css":
                return uglifyCSS(source, target);
            case ".js":
                return uglifyJS(source, target);
            default:
                return copyFile(source, target);
            }
        } else if( stat.isDirectory() ) {
            return build(source, target);
        }
    }).catch(function(error){
        console.error(error);
    });
};

Проблема

Здесь все идет не так. По мере того, как процесс сталкивается со все большим количеством JS-файлов, он продолжает замедляться, пока процесс не остановится полностью.

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

Любые идеи, как решить эту проблему?

Это полный код:

const util = require('util');
const fs = require('fs');
const path = require('path');

const mkdir = util.promisify(require('mkdirp'));
const rmdir = util.promisify(require('rimraf'));
const ncp = util.promisify(require('ncp').ncp);
const readdir = util.promisify(fs.readdir);
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const stat = util.promisify(fs.stat);

const moveFrom = path.join(__dirname,"../scr");
const moveTo = path.join(__dirname,"../dist");

var copyFile = function(source, target) {
    return new Promise(function(resolve,reject){
        const rd = fs.createReadStream(source);
        rd.on('error', function(error){
            reject(error);
        });
        const wr = fs.createWriteStream(target);
        wr.on('error', function(error){
            reject(error);
        });
        wr.on('close', function(){
            resolve();
        });
        rd.pipe(wr);
    });
};

var uglifyCSS = function(source, target) {
    readFile(source, "utf8")
    .then(function(content){
        return writeFile(target, require('ycssmin').cssmin(content), "utf8");
    }).catch(function(error){
        console.error(error);
    });
}

var uglifyJS = function(source, target) {
    readFile(source, "utf8")
    .then(function(content){
        return writeFile(target, require('uglify-js').minify(content).code, "utf8");
    }).catch(function(error){
        console.error(error);
    });
}

var copy = function(source, target) {
    stat(source)
    .then(function(stat){
        if(stat.isFile()) {
            console.log("Copying file %s", source);
            switch (path.extname(target)) {
                    case ".css":
                        return uglifyCSS(source, target);
                            case ".js":
                                return uglifyJS(source, target);
                default:
                    return copyFile(source, target);
            }
        } else if( stat.isDirectory() ) {
            return build(source, target);
        }
    }).catch(function(error){
        console.error(error);
    });
};

var build = function(source, target) {
    readdir(source)
    .then(function(list) {
        return rmdir(target).then(function(){
            return list;
        });
    })
    .then(function(list) {
        return mkdir(target).then(function(){
            return list;
        });
    }).then(function(list) {
        list.forEach(function(item, index) {
            copy(path.join(source, item), path.join(target, item));
        });
    }).catch(function(error){
        console.error(error);
    })
};

build(moveFrom, moveTo);

person John Slegers    schedule 16.06.2017    source источник
comment
Святое дерьмо. Какое приложение требует почти 200 МБ JavaScript?   -  person Mike Cluck    schedule 16.06.2017
comment
@MikeC: я занимаюсь рефакторингом этой демонстрации. Большинство из них не JavaScript. Около 142 МБ из этих 196 МБ занимают два двоичных файла (один для мобильных устройств и один для настольных компьютеров), которые используются приложением. Однако большая часть оставшихся 50 МБ состоит из файлов JavaScript: он содержит полный LuciadRIA, а также набор инструментов Dojo. Я обновил свой ответ, чтобы лучше отразить эту ситуацию.   -  person John Slegers    schedule 16.06.2017
comment
@JohnSlegers У меня похожая проблема, я использую gulp-uglify. Но это происходит не всегда. Это происходит с перерывами (скорее всего, когда системные ресурсы исчерпаны). Поэтому я удалил задачу минимизации из сборки основной задачи и запустил ее отдельно. С нетерпением жду ответа.   -  person karthick    schedule 16.06.2017
comment
Думаю, теперь вы получили ответ. Все, что вам нужно, это пропустить одну асинхронную операцию, и вы выбыли из бизнеса. Не очень прощающий :-)   -  person oligofren    schedule 16.06.2017
comment
На самом деле проблема заключается в файле размером 23 МБ, а не в большом количестве файлов. Удаление файла — это все, что потребовалось, чтобы решить проблему, что, я думаю, придется сделать сейчас. И хотя я ценю ваш вклад, синхронизация не особо помогла. У меня все еще возникла та же проблема с вашим предложением, и без файла размером 23 МБ ваш код на самом деле работает медленнее.   -  person John Slegers    schedule 16.06.2017


Ответы (2)


Простое исправление: вся ваша проблема в том, что у вас нет границ для пареллизации:

list.forEach(function(item, index) {
        copy(path.join(source, item), path.join(target, item));
});

Вы синхронно отправляете асинхронные операции. Это означает, что они возвращаются немедленно, без вашего ожидания. Вам нужно либо сделать операции последовательными, либо установить привязку к выполняемым операциям. Это сделает список функций:

const copyOperations = list.map((item) => {
        return copy(path.join(source, item), path.join(target, item));
});

Затем заставьте их запускаться последовательно:

const initialValue = Promise.resolve();
copyOperations.reduce((accumulatedPromise, nextFn) => {
    return accumulatedPromise.then(nextFn);
}, initialValue);

Теперь, если вы хотите дождаться завершения всех из них, вам нужно вернуть обещание, поэтому раздел копирования вашего кода будет выглядеть так:

.then(function(list) {
    const copyOperations = list.map((item) => {
            return copy(path.join(source, item), path.join(target, item));
    });

    const allOperations = copyOperations.reduce((accumulatedPromise, nextFn) => {
        return accumulatedPromise.then(nextFn);
    }, Promise.resolve());

    return allOperations; 
})

Это, конечно, просто копирует один файл за раз, и если вам требуется одновременное выполнение большего количества операций, вам нужен более причудливый механизм. Попробуйте этот механизм объединения промисов, где вы можете установить порог, например require('os').cpus().length;

Пример ограниченного распараллеливания с использованием генератора ES6

просто замените тело функции then выше этим

const PromisePool = require('es6-promise-pool')
const maxProcesses = require('os').cpus().length;

const copyOperations = list.map((item) => {
        return copy(path.join(source, item), path.join(target, item));
});

const promiseGenerator = function *(){
    copyOperations.forEach( operation => yield operation );
}

var pool = new PromisePool(promiseGenerator(), maxProcesses)

return pool.start()
  .then(function () {
    console.log('Complete')
  });
person oligofren    schedule 16.06.2017
comment
Оказывается, проблема была не в количестве файлов, а в размере самого большого файла. Когда я попробовал ваш подход, процесс все еще продолжал задыхаться. Затем я попытался удалить эти 23 МБ, и ваш подход сработал. Затем я снова попробовал свой подход, и тот оказался на самом деле быстрее. Думаю, мне придется попробовать и посмотреть, смогу ли я запустить UglifyJS отдельно для этого файла размером 23 МБ, или я просто пропущу его при сборке в будущем. В любом случае, это всего лишь проверка концепции, так что на данный момент я думаю, что skipp в этом пока что должен делать. Тем не менее, спасибо за ваш вклад! - person John Slegers; 16.06.2017
comment
Рад слышать, что вы нашли виновного. Когда вы говорите «мой подход», я думаю, вы имеете в виду первую (последовательную) версию, верно? Этого следовало ожидать. Но я предполагаю, что параллельная версия будет работать довольно хорошо, и, возможно, у нее может быть немного большее количество maxProcesses для ускорения. - person oligofren; 17.06.2017
comment
На следующей неделе посмотрю :-) - person John Slegers; 18.06.2017

Предложение Oligofren не помогло. Однако удаление JS-файла размером 23 МБ решило проблему. Таким образом, похоже, проблема была не в большом количестве файлов (как я подозревал), а в том, что файл слишком велик для обработки NodeJ. Я полагаю, что игра с настройками памяти NodeJs (например, node --stack-size) могла бы это исправить.

В любом случае, хотя мне все еще нужно решение, чтобы заставить все работать без удаления файла размером 23 МБ, я полагаю, что на данный момент придется удалить этот файл из обрабатываемых файлов. Это в значительной степени просто доказательство концепции, над которой я работал в любом случае.

person John Slegers    schedule 16.06.2017