盘点webpack源码中的那些通用优化手段

1,319 阅读5分钟

前言

大家好这里是阳九,一个文科转码的野路子全栈码农,热衷于研究和手写前端工具.

啃webpack源码也有一阵子了 今天来盘点一些webpack源码中的一些通用优化策略

看看这些库的底层优化到底是怎么做的

lazySet [懒]

lazySet是在webpack中大量运用到的一种数据结构 我们在webpack的主类里就可以看到:

class compilation{
     constructor(){
            ...
        /** @type {LazySet<string>} */
	this.fileDependencies = new LazySet();
	/** @type {LazySet<string>} */
	this.contextDependencies = new LazySet();
	/** @type {LazySet<string>} */
	this.missingDependencies = new LazySet();
	/** @type {LazySet<string>} */
	this.buildDependencies = new LazySet();
        }
    
    }

它是由集合Set封装而来, 与普通的集合不同之处在于,它支持惰性添加元素。 而惰性一词就是优化的关键思想之一, 也就是需要的时候才使用

当我们需要往Set中插入数据时,此时并不会直接将其插入Set,而是给这个元素一个key值,当我们在后面的操作中需要使用这个元素时,才会将其插入Set并返回。

原因: 我们知道,set是有去重功能的。set底层使用哈希表来存储元素,在加入新元素时需要重新计算哈希值,并遍历相应桶进行比较和查找。

如果集合中保存大量数据,那么单次插入并去重会很慢。

deopt

deopt即De-Optimization,是指在Javascript的JIT(Just-In-Time)编译过程中,程序执行时不再满足之前优化假设而导致代码需要重新解析和重建。

比如当一个函数经过多次被调用后,优化编译器会根据该函数已执行的情况来进行特定场景下的加速。

例子

  1. 当_needMerge属性为true时才会真正去将这些数据merge到set中
  2. 在执行耗时操作比如keys时会将_deopt设置为false,禁止merge操作
class LazySet {
	constructor(iterable) {
		this._set = new Set(iterable);
		this._toMerge = new Set();
		this._needMerge = false;
                this._deopt = false;
                ...
	}
        
        // 在其每个方法前面都会检查是否需要merge元素到set中
        has(item) {
            if (this._needMerge) this._merge();
            return this._set.has(item);
	}
        
        // 执行keys方法时,会将_deopt关闭,不进行merge操作
        keys() {
            this._deopt = true;
            if (this._needMerge) this._merge();
            return this._set.keys();
	}

        addAll(iterable) {
        // 只有在非_deopt的时候才会执行merge操作
		if (this._deopt) {
			const _set = this._set;
			for (const item of iterable) {
				_set.add(item);
			}
		} else {
			if (iterable instanceof LazySet) {
				if (iterable._isEmpty()) return this;
				this._toDeepMerge.push(iterable);
				this._needMerge = true;
				if (this._toDeepMerge.length > 100000) {
					this._flatten();
				}
			} else {
				this._toMerge.add(iterable);
				this._needMerge = true;
			}
			if (this._toMerge.size > 100000) this._merge();
		}
		return this;
	}
}

上述的四个属性: fileDependencies,contextDependencies等, 也就是文件依赖项,其元素数量可能非常多,并且在webpack构建的时候会大量进行增查操作。

如果频繁地创建和销毁临时对象,会导致严重的性能问题。而使用lazySet就可以避免这个情况出现。只有当真正需要新增元素时才会去进行相应操作,从而减少了无谓的内存分配和回收次数。

CachedInputFileSystem [缓存]

node中我们一般使用原生fs模块去执行文件操作 而webpack中,将文件系统分为了四份,各自有不同的功能。

而webpack使用自己封装的CachedInputFileSystem作为InputFileSystem

简单的讲,通过这个文件系统的文件内容,都会被直接缓存到内存,使得后续读写加速

compiler.inputFileSystem = new CachedInputFileSystem(fs, 60000);

封装了一些fs模块的常见API, lstat,stat,readdir,readFile,readJson,readlink 以及他们的Sync方法

这个模块一共有两个功能

  • 允许Webpack从不同的输入源读取数据(如内存、磁盘等)
  • 将通过此系统的文件缓存,下次快速读取

这里以readFile举例,创建了一个Backend(后端缓存), readFile实际执行的是CacheBackend.provide

module.exports = class CachedInputFileSystem {
    ...
    	this._readFileBackend = createBackend(
		duration,
		this.fileSystem.readFile,
		this.fileSystem.readFileSync,
		this.fileSystem
	);
        // readFile实际执行CacheBackend.provide
	const readFile = this._readFileBackend.provide;
}

由CacheBackend提供的provide方法一共做了两件事情

  • 类型检查
  • 检查是否缓存过结果,有则直接返回缓存结果
// 创建CacheBackend实例
const createBackend = (duration, provider, syncProvider, providerContext) => {
	if (duration > 0) {
		return new CacheBackend(duration, provider, syncProvider, providerContext);
	}
	return new OperationMergerBackend(provider, syncProvider, providerContext);
};

class CacheBackend {
    constructor(){
        this._data = new Map(); // 保存结果的Map
    }
    ...
    provide(path, options, callback) {
       // Check in cache
       
       // Run the operation
       
    }
}

memoize [缓存]

memoize是一种函数缓存策略, 术语叫"记忆化函数",webpack中使用的非常简单

通过memoize创建了匿名函数,(闭包),然后将函数结果缓存到闭包内. (如果对闭包概念不清楚的同学建议不往下看了)

在后续每次执行memoized 函数,则会直接获得上一次的结果(cache=true状态)

而不必再重新进行某个高开销的计算。

const memoize = fn => {
	let cache = false;
	/** @type {T} */
	let result = undefined;
	return () => {
		if (cache) {
			return result;
		} else {
                        // 直接将函数结果保存到闭包内
			result = fn();
			cache = true;
			// Allow to clean up memory for fn
			// and all dependent resources
			fn = undefined;
			return result;
		}
	};
};

这种策略在很多库中都有使用,比如我们常用的国际化组件,format-JS中,(也就是forrmatMessage), 内部使用了fast-memorize 库,将普通函数转化成记忆化函数,这个库的功能更加强大,算法也经过优化。

import {memoize} from 'fast-memoize';

function expensiveOperation(a, b) {
console.log('Running expensiveOperation!');
return a + b;
}

const memoizedOperation = memoize(expensiveOperation);

console.log(memoizedOperation(1, 2)); // 执行计算并输出 3
console.log(memoizedOperation(1, 2)); // 直接返回缓存的结果,不会执行计算

流处理构建资源 [IO提速]

webpack中有这么几个步骤

  • module构建和加载
  • chunking 将多个module分割成多个chunk
  • optimization 代码优化
  • output 输出成文件

而在整个流转的过程中,模块资源都是以流的形式进行计算和传递的。

我们可以看到,在一个构建完毕的module对象中有两个个属性 "_valueAsBuffer" "_valueAsString"

image.png

  • 在webpack中,模块可以分为很多种,js,css,图片等. 他们无法被统一用字符串表达,

  • 像图片、音频等二进制文件不能直接通过js语法表示而必须用字节数组或者Buffer对象进行表示

  • webpack中读写AST 或/及分析抽象句法树(AST)的消耗是很大的, 这些操作对源byte code较友好。

  • Node对IO,流处理的支持更好,整个Webpack构建过程都是基于流处理的

  • 对跨平台更友好

总结

一些通用优化策略 比如

  • 缓存思想
  • 高级数据结构封装
  • lazy思想
  • 池化思想,并发思想
  • 分时思想(分片)
  • 流处理 ......

等等 其实都有在底层库里实现, 也是我们平时写码时所需要思考并掌握的技能。

厉害的工程师为了压榨性能, 依然会不断挖掘更多的方案,进行更多算法,数据上的优化

代码优化博大精深