fileMinifier.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. var debug = require('debug')('ylt:fileMinifier');
  2. var Q = require('q');
  3. var UglifyJS = require('uglify-js');
  4. var CleanCSS = require('clean-css');
  5. var Minimize = require('minimize');
  6. var FileMinifier = function() {
  7. function minifyFile(entry) {
  8. var deferred = Q.defer();
  9. if (!entry.weightCheck || !entry.weightCheck.body) {
  10. // No valid file available
  11. deferred.resolve(entry);
  12. return deferred.promise;
  13. }
  14. var fileSize = entry.weightCheck.uncompressedSize;
  15. debug('Let\'s try to optimize %s', entry.url);
  16. debug('Current file size is %d', fileSize);
  17. var startTime = Date.now();
  18. if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(entry.weightCheck.body)) {
  19. debug('File is a JS');
  20. return minifyJs(entry.weightCheck.body)
  21. .then(function(newFile) {
  22. if (!newFile) {
  23. debug('Optimization didn\'t work');
  24. return entry;
  25. }
  26. var endTime = Date.now();
  27. var newFileSize = newFile.length;
  28. debug('JS minification complete for %s', entry.url);
  29. if (gainIsEnough(fileSize, newFileSize)) {
  30. entry.weightCheck.bodyAfterOptimization = newFile;
  31. entry.weightCheck.optimized = newFileSize;
  32. entry.weightCheck.isOptimized = false;
  33. debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
  34. }
  35. return entry;
  36. })
  37. .fail(function(err) {
  38. return entry;
  39. });
  40. } else if (entry.isCSS) {
  41. debug('File is a CSS');
  42. return minifyCss(entry.weightCheck.body)
  43. .then(function(newFile) {
  44. if (!newFile) {
  45. debug('Optimization didn\'t work');
  46. return entry;
  47. }
  48. var endTime = Date.now();
  49. debug('CSS minification took %dms', endTime - startTime);
  50. var newFileSize = newFile.length;
  51. debug('CSS minification complete for %s', entry.url);
  52. if (gainIsEnough(fileSize, newFileSize)) {
  53. entry.weightCheck.bodyAfterOptimization = newFile;
  54. entry.weightCheck.optimized = newFileSize;
  55. entry.weightCheck.isOptimized = false;
  56. debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
  57. }
  58. return entry;
  59. })
  60. .fail(function(err) {
  61. return entry;
  62. });
  63. } else if (entry.isHTML) {
  64. debug('File is an HTML');
  65. return minifyHtml(entry.weightCheck.body)
  66. .then(function(newFile) {
  67. if (!newFile) {
  68. debug('Optimization didn\'t work');
  69. return entry;
  70. }
  71. var endTime = Date.now();
  72. debug('HTML minification took %dms', endTime - startTime);
  73. var newFileSize = newFile.length;
  74. debug('HTML minification complete for %s', entry.url);
  75. if (gainIsEnough(fileSize, newFileSize)) {
  76. entry.weightCheck.bodyAfterOptimization = newFile;
  77. entry.weightCheck.optimized = newFileSize;
  78. entry.weightCheck.isOptimized = false;
  79. debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
  80. }
  81. return entry;
  82. })
  83. .fail(function(err) {
  84. return entry;
  85. });
  86. } else {
  87. debug('Not minifiable type or already minified');
  88. deferred.resolve(entry);
  89. }
  90. return deferred.promise;
  91. }
  92. // The gain is estimated of enough value if it's over 2KB or over 20%,
  93. // but it's ignored if is below 400 bytes
  94. function gainIsEnough(oldWeight, newWeight) {
  95. var gain = oldWeight - newWeight;
  96. var ratio = gain / oldWeight;
  97. return (gain > 2096 || (ratio > 0.2 && gain > 400));
  98. }
  99. // Uglify
  100. function minifyJs(body) {
  101. // Splitting the Uglify function because it sometime takes too long (more than 10 seconds)
  102. // I hope that, by splitting, it can be a little more asynchronous, so the application doesn't freeze.
  103. return splittedUglifyStep1(body)
  104. .delay(1)
  105. .then(splittedUglifyStep2)
  106. .delay(1)
  107. .then(function(ast) {
  108. // Only do the compression step for smaller files
  109. // otherwise it can take a very long time compared to the gain
  110. if (body.length < 200*1024) {
  111. return splittedUglifyStep3(ast);
  112. } else {
  113. debug('Skipping step 3 because the file is too big (%d bytes)!', body.length);
  114. return ast;
  115. }
  116. })
  117. .delay(1)
  118. .then(splittedUglifyStep4)
  119. .delay(1)
  120. .then(splittedUglifyStep5)
  121. .delay(1)
  122. .then(splittedUglifyStep6)
  123. .delay(1)
  124. .then(splittedUglifyStep7);
  125. }
  126. function splittedUglifyStep1(code) {
  127. var deferred = Q.defer();
  128. var startTime = Date.now();
  129. try {
  130. var toplevel_ast = UglifyJS.parse(code);
  131. var endTime = Date.now();
  132. debug('Uglify step 1 took %dms', endTime - startTime);
  133. deferred.resolve(toplevel_ast);
  134. } catch(err) {
  135. debug('JS syntax error, Uglify\'s parser failed (step 1)');
  136. deferred.reject(err);
  137. }
  138. return deferred.promise;
  139. }
  140. function splittedUglifyStep2(toplevel) {
  141. var deferred = Q.defer();
  142. var startTime = Date.now();
  143. toplevel.figure_out_scope();
  144. var endTime = Date.now();
  145. debug('Uglify step 2 took %dms', endTime - startTime);
  146. deferred.resolve(toplevel);
  147. return deferred.promise;
  148. }
  149. function splittedUglifyStep3(toplevel) {
  150. var deferred = Q.defer();
  151. var startTime = Date.now();
  152. var compressor = UglifyJS.Compressor({warnings: false});
  153. var compressed_ast = toplevel.transform(compressor);
  154. var endTime = Date.now();
  155. debug('Uglify step 3 took %dms', endTime - startTime);
  156. deferred.resolve(compressed_ast);
  157. return deferred.promise;
  158. }
  159. function splittedUglifyStep4(compressed_ast) {
  160. var deferred = Q.defer();
  161. var startTime = Date.now();
  162. compressed_ast.figure_out_scope();
  163. var endTime = Date.now();
  164. debug('Uglify step 4 took %dms', endTime - startTime);
  165. deferred.resolve(compressed_ast);
  166. return deferred.promise;
  167. }
  168. function splittedUglifyStep5(compressed_ast) {
  169. var deferred = Q.defer();
  170. var startTime = Date.now();
  171. compressed_ast.compute_char_frequency();
  172. var endTime = Date.now();
  173. debug('Uglify step 5 took %dms', endTime - startTime);
  174. deferred.resolve(compressed_ast);
  175. return deferred.promise;
  176. }
  177. function splittedUglifyStep6(compressed_ast) {
  178. var deferred = Q.defer();
  179. var startTime = Date.now();
  180. compressed_ast.mangle_names();
  181. var endTime = Date.now();
  182. debug('Uglify step 6 took %dms', endTime - startTime);
  183. deferred.resolve(compressed_ast);
  184. return deferred.promise;
  185. }
  186. function splittedUglifyStep7(compressed_ast) {
  187. var deferred = Q.defer();
  188. var startTime = Date.now();
  189. var code = compressed_ast.print_to_string();
  190. var endTime = Date.now();
  191. debug('Uglify step 7 took %dms', endTime - startTime);
  192. deferred.resolve(code);
  193. return deferred.promise;
  194. }
  195. // Clear-css
  196. function minifyCss(body) {
  197. var deferred = Q.defer();
  198. try {
  199. var result = new CleanCSS({compatibility: 'ie8'}).minify(body);
  200. deferred.resolve(result.styles);
  201. } catch(err) {
  202. deferred.reject(err);
  203. }
  204. return deferred.promise;
  205. }
  206. // HTMLMinifier
  207. function minifyHtml(body) {
  208. var deferred = Q.defer();
  209. var minimize = new Minimize({
  210. empty: true, // KEEP empty attributes
  211. conditionals: true, // KEEP conditional internet explorer comments
  212. spare: true // KEEP redundant attributes
  213. });
  214. minimize.parse(body, function (error, data) {
  215. if (error) {
  216. deferred.reject(error);
  217. } else {
  218. deferred.resolve(data);
  219. }
  220. });
  221. return deferred.promise;
  222. }
  223. // Avoid loosing time trying to compress some JS libraries known as already compressed
  224. function isKnownAsMinified(url) {
  225. var result = false;
  226. // Twitter
  227. result = result || /^https?:\/\/platform\.twitter\.com\/widgets\.js/.test(url);
  228. // Facebook
  229. result = result || /^https:\/\/connect\.facebook\.net\/[^\/]*\/(sdk|all)\.js/.test(url);
  230. // Google +1
  231. result = result || /^https:\/\/apis\.google\.com\/js\/plusone\.js/.test(url);
  232. // jQuery CDN
  233. result = result || /^https?:\/\/code\.jquery\.com\/.*\.min.js/.test(url);
  234. // Google Analytics
  235. result = result || /^https?:\/\/(www|ssl)\.google-analytics\.com\/(.*)\.js/.test(url);
  236. if (result === true) {
  237. debug('This file is known as already minified. Skipping minification: %s', url);
  238. }
  239. return result;
  240. }
  241. // Avoid loosing some trying to compress JS files if they alreay look minified
  242. // by counting the number of lines compared to the total size.
  243. // Less than 1KB per line is suspicious
  244. function looksAlreadyMinified(code) {
  245. var linesCount = code.split(/\r\n|\r|\n/).length;
  246. var linesRatio = code.length / linesCount;
  247. var looksMinified = linesRatio > 1024;
  248. debug('Lines ratio is %d bytes per line', Math.round(linesRatio));
  249. debug(looksMinified ? 'It looks already minified' : 'It doesn\'t look minified');
  250. return looksMinified;
  251. }
  252. function entryTypeCanBeMinified(entry) {
  253. return entry.isJS || entry.isCSS || entry.isHTML;
  254. }
  255. return {
  256. minifyFile: minifyFile,
  257. minifyJs: minifyJs,
  258. minifyCss: minifyCss,
  259. minifyHtml: minifyHtml,
  260. gainIsEnough: gainIsEnough,
  261. entryTypeCanBeMinified: entryTypeCanBeMinified,
  262. isKnownAsMinified: isKnownAsMinified
  263. };
  264. };
  265. module.exports = new FileMinifier();