GithubHelp home page GithubHelp logo

blog's People

Contributors

bingoran avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar

blog's Issues

6、构建准备

构建准备

webpack-cli 的最后,webpack 函数执行完成之后,返回了一个 compiler 实例;

这一篇文章,我们主要看看这个 webpack 函数,到底在编译的过程中起了什么作用;

其实,通过标题,我们大概能猜到,这个 webpack 函数是在为编译阶段做准备;

函数位置

webpack 函数所在的位置,是在

node_modules -> webpack -> lib -> webpack.js

注意,webpack 库下面有两个 webpack.js 文件,一个是在

 bin -> webpack.js

一个在

lib -> webpack.js

bin -> webpack.js 这个文件的作用我们在 webpack-cil 的职责 中已经分析过,这里不再赘述;

这里,我们的主角是 lib -> webpack.js;

webpack.js 构建准备流程图

image-20210326163759012

webpack.js 源码分析

webpack.js

...
// 配置文件是数组的时候,创建多个实例,不常用
const createMultiCompiler = (childOptions, options) => {...};

/**
 * 创建真正的 Compiler 实例
 */
const createCompiler = rawOptions => {
	// 整理成为完整的 webpack 配置文件
	const options = getNormalizedWebpackOptions(rawOptions);
	// 设置 context,当前环境就是 node 当前的执行目录 process.cwd()
	applyWebpackOptionsBaseDefaults(options);
	// 新建编译器实例
	const compiler = new Compiler(options.context);
	compiler.options = options; // 配置文件挂载
	// 注册环境
	// 1、监听了 beforeRun 这个钩子,用于构建之前清理缓存
	// 2、定义 inputFileSystem、outputFileSystem、watchFileSystem 等输入输出流
	new NodeEnvironmentPlugin({
		infrastructureLogging: options.infrastructureLogging
	}).apply(compiler);

	// 注册 webpack 配置文件插件
	if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	}
	// 做一些初始的操作,给配置文件做一些设置默认的参数
	applyWebpackOptionsDefaults(options);
	// 初始化环境信息
	compiler.hooks.environment.call(); // 没找到相应的监听,webpack5 留给了开发者自己使用
	compiler.hooks.afterEnvironment.call();
	// 根据所有配置 option 参数转换成对应的 webpack 内部插件
	new WebpackOptionsApply().process(options, compiler);
	compiler.hooks.initialize.call();
	return compiler;
};

const webpack = ((options, callback) => {

		const create = () => {
			validateSchema(webpackOptionsSchema, options);
			let compiler;
			let watch = false;
			let watchOptions;
			// 如果配置文件是数组形式存在多分配置文件,则会创建多个编译器分别处理
			if (Array.isArray(options)) {
				compiler = createMultiCompiler(options, options);
				watch = options.some(options => options.watch);
				watchOptions = options.map(options => options.watchOptions || {});
			} else {
				// 我们的常规处理方式,就是配置文件是一个普通的对象形式
				compiler = createCompiler(options);
        // 监听模式:只有 watch 为 true 的时候 watchOptions 才有意义
				watch = options.watch; 
        // 监听模式的配置参数(不监听的文件夹,刷新频率等)
				watchOptions = options.watchOptions || {}; 
			}
			return { compiler, watch, watchOptions };
		};

		if (callback) {
			try {
				const { compiler, watch, watchOptions } = create();
				if (watch) {
					// 如果是监听模式:走这里
					compiler.watch(watchOptions, callback);
				} else {
					// 打包编译模式走这里
					// run 是编译的入口方法
					compiler.run((err, stats) => {
						compiler.close(err2 => {
							callback(err || err2, stats);
						});
					});
				}
				return compiler;
			} catch (err) {...}
		} else {
			const { compiler, watch } = create();
			...
			return compiler;
		}
	}
);

module.exports = webpack;

可以看到,webpack.js 文件里边主要就这 3 个函数

1、webpack

2、createCompiler

3、createMultiCompiler

下面,我们主要分析这 3 个函数

webpack 函数

1、create 函数的作用是,根据配置文件,实例化不同的 compiler,并且返回监听模式的相关配置。注意:这里配置文件一般只返回一个对象;对于返回数组的情况极少,所以暂时不看多编译器打包处理。

2、判断 callback 是否为空;如果为空,则只返回 compiler 对象;如果不为空,则根据配置文件按情况实例化 compiler;

3、根据是否是监听模式,再调用 compiler.watch 或者 compiler.run 方法

createMultiCompiler 函数

创建多编译器,这个函数只有在配置文件是数组的时候才会使用到,目前没接触过,暂不做分析。

createCompiler 函数

1、整理 webpack 配置文件,将部分配置转换成 webpack 需要的格式
2、创建 context 上下文,这里使用的是 process.cwd(),指当前 node 的工作目录
3、创建 compiler 实例
4、初始化 NodeEnvironmentPlugin 插件

  • 监听了 beforeRun 这个钩子,用于构建之前清理缓存

  • 定义 inputFileSystem、outputFileSystem、watchFileSystem 等输入输出流

5、初始化用户配置的插件,注册插件钩子

if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	}

这里可以看到,配置文件里的 plugins 必须是数组才可以正确解析,而且数组里边要么是函数,要么是实例化的插件类。

5、进一步优化 options,给一些配置赋上默认值
6、初始化 webpack 内部插件,例如 js 解析器、缓存插件、添加入口的插件等

// 根据所有配置 options 参数转换成对应的 webpack 内部插件
new WebpackOptionsApply().process(options, compiler);

WebpackOptionsApply().process 这个函数比较重要,下面会专门介绍 WebpackOptionsApply

14、构建-make 阶段

构建-make 阶段

构建流程

现在,我们就以 addEntry 为起点,看看 Compiltion 是怎么处理模块构建的;

为了方便理解,这里通过流程图先梳理一下 Compiltion 模块构建流程;

yuque_diagram

流程看着比较复杂,但是好在整体的构建过程比较清晰,下面我们从 addEntry 开始一步一步分析Compiltion 对模块构建流程。

为了便于分析,我们将构建的整个流程进行拆解:

1、addEntry -> addModuleTree 为第一部分

2、handleModuleCreation -> factorizeModule 为第二部分

3、addModule 为第三部分

4、buildModule 为第四部分

5、processModuleDependencies 为第五部分

第一部分 addEntry -> addModuleTree

源码

...
// addEntry、_addEntryItem 的作用是将模块的入口信息传递给模块树中,即 addModuleTree
addEntry(context, entry, optionsOrName, callback) {
		...
		this._addEntryItem(context, entry, "dependencies", options, callback);
}

_addEntryItem(context, entry, target, options, callback) {
  const { name } = options;
  let entryData =
    name !== undefined ? this.entries.get(name) : this.globalEntry;
  if (entryData === undefined) {
    // 入口数据,包括入口依赖,包含依赖和一些配置信息
    // 配置信息包括了模块路径和模块名
    entryData = {
      dependencies: [],
      includeDependencies: [],
      options: {
        name: undefined,
        ...options
      }
    };
    // target值为:dependencies
    // name值为:main
    entryData[target].push(entry);
    this.entries.set(name, entryData);
  } else {...}

  // 添加模块树
  this.addModuleTree({
    ...
		dependency: entry
    ...
  }...);
}
  
// 这些调用最后会将 entry 的入口信息”翻译“成一个模块(严格上说,模块是NormalModule实例化后的对象)
addModuleTree({ context, dependency, contextInfo }, callback) {
  ...
  const Dep = (dependency.constructor);
  // 返回对应的模块工厂,这样才能正确的处理模块
  // dependencyFactories 的初始化是在 EntryPlugin 中
  const moduleFactory = this.dependencyFactories.get(Dep);
  ...
  // 处理模块的创建,开始创建模块实例
  this.handleModuleCreation({
    ...
    factory: moduleFactory,
		dependencies: [dependency],
  },...);
}

addEntry -> addModuleTree 的代码比较简单,也比较好理解:

  • 首先,_addEntryItem 将入口信息处理后,保存在 this.entries 里边
  • addModuleTree 通过 dependency 的构造函数,为入口模块找到对应的模块工厂;这里 dependency 是还未被解析成模块的依赖对象,这里是 webpack 的入口模块;当然 dependency 还可以是模块依赖的其他模块,在创建模块的时候,都会先生成一个 dependency 对象,然后包装成数组,传递给 handleModuleCreation 处理模块的创建

可以看到,第一部分的作用就是为 webpack 的入口模块找到相应的模块工厂,然后将模块的依赖信息和工厂交个 handleModuleCreation 进一步处理。

第二部分 handleModuleCreation -> factorizeModule

源码

handleModuleCreation(
  {
    factory,
    dependencies,
    originModule,
    contextInfo,
    context,
    recursive = true
  },
  callback
) {
  const moduleGraph = this.moduleGraph; // 模块图
  // 因式分解模块
  this.factorizeModule(
    {
      currentProfile,
      factory,
      dependencies,
      originModule,
      contextInfo,
      context
    },
    (err, newModule) => {
      // 分解模块完成
      // 添加模块,执行addModule,存储 module
		  this.addModule(newModule, (err, module) => {...})
    }
  );
}

// 添加到因式分解队列,最终会通过绑定的 _factorizeModule 方法对 因式分解队列 里边的模块进行处理
factorizeModule(options, callback) {
		this.factorizeQueue.add(options, callback);
}
_factorizeModule(
  {
    currentProfile,
    factory,
    dependencies,
    originModule,
    contextInfo,
    context
  },
  callback
) {
  if (currentProfile !== undefined) {
    currentProfile.markFactoryStart();
  }
  // 执行 moduleFactory.create 创建模块,这里主要做了三件事
  // 1、执行 factory.hooks.factorize.call 钩子,
  // 然后会调用ExternalModuleFactoryPlugin中注册的钩子,用于配置外部文件的模块加载方式, 例如fs, http, events等node原生方法
  // 2、使用 enhanced-resolve 解析模块和 loader 真实绝对路径
  // 3、new NormalModule() 创建 module 实例
  factory.create(
    ...,
    (err, result) => {
      if (result) {
        const {
          fileDependencies,
          contextDependencies,
          missingDependencies
        } = result;
        if (fileDependencies) {
          this.fileDependencies.addAll(fileDependencies);
        }
        if (contextDependencies) {
          this.contextDependencies.addAll(contextDependencies);
        }
        if (missingDependencies) {
          this.missingDependencies.addAll(missingDependencies);
        }
      }
      ...
      const newModule = result.module;
      ...
      callback(null, newModule);
    }
  );
}

下面,我们分析一下这一阶段:

handleModuleCreation 的作用是,传递模块工厂以及 dependencies,然后调用 factorizeModule 对模块进行分解。

factory.create 是这一阶段的核心,主要做了三件事

  • 执行 factory.hooks.factorize.call 钩子, 然后会调用ExternalModuleFactoryPlugin中注册的钩子,用于配置外部文件的模块加载方式, 例如fs, http, events等node原生方法

  • 使用 enhanced-resolve 解析模块和 loader 真实绝对路径

  • new NormalModule() 创建 module 实例

factory.create 执行完成后,会输出一个 newModule;

这个 newModule 包含了模块的路径,相关的依赖,以及转换解析 newModule 的 loader 路径等等;

newModule 就相当于 moduleFactory 创造的模块说明书,后面对于模块的构建解析都是按照这个说明书来的。

第三部分 addModule

源码

// 添加模块队列,最后里边的模块会通过绑定的 _addModule 进行处理
addModule(module, callback) {
	this.addModuleQueue.add(module, callback);
}
_addModule(module, callback) {
  const identifier = module.identifier();
  const alreadyAddedModule = this._modules.get(identifier);
  if (alreadyAddedModule) {
    return callback(null, alreadyAddedModule);
  }

  const currentProfile = this.profile
    ? this.moduleGraph.getProfile(module)
    : undefined;
  if (currentProfile !== undefined) {
    currentProfile.markRestoringStart();
  }

  this._modulesCache.get(identifier, null, (err, cacheModule) => {
    if (err) return callback(new ModuleRestoreError(module, err));

    if (currentProfile !== undefined) {
      currentProfile.markRestoringEnd();
      currentProfile.markIntegrationStart();
    }

    if (cacheModule) {
      cacheModule.updateCacheModule(module);
      module = cacheModule;
    }
    this._modules.set(identifier, module);
    this.modules.add(module);
    // 将模块添加到 moduleGraph 模块图中
    ModuleGraph.setModuleGraphForModule(module, this.moduleGraph);
    if (currentProfile !== undefined) {
      currentProfile.markIntegrationEnd();
    }
    callback(null, module);
  });
}

addModule 的功能比较简单:

  • 如果 this._modules 已经存在构建过的模块,则直接 callback
  • this._modulesCache 通过 identifier 获取 cacheModule,如果存在 cacheModule,则更新缓存,并把 module 赋值为 cacheModule
  • 将 module 添加到 this._modules 以及 this.modules 中
  • 通过 ModuleGraph.setModuleGraphForModule 将 module 添加到 moduleGraph 中

第四部分 buildModule

源码

buildModule(module, callback) {
  this.buildQueue.add(module, callback);
}
_buildModule(module, callback) {
  ...
  // 判断是否需要构建当前模块:如果模块有错误或者模块构建的模块可以从缓存中取
  module.needBuild(
    {
      fileSystemInfo: this.fileSystemInfo
    },
    (err, needBuild) => {
      ...
      // 然后调用 module 的 build 方法
      // 然后进入 doBuild 方法, 
      module.build(
        this.options,
        this,
        this.resolverFactory.get("normal", module.resolveOptions),
        this.inputFileSystem,
        err => {
          ...
          // 缓存解析完的 module 至 _modulesCache,此时已经有 _source(解析后的源码)
          this._modulesCache.store(module.identifier(), null, module, err => {
            ...
            // 成功-代表模块构建就构建好了
            this.hooks.succeedModule.call(module);
            return callback();
          });
        }
      );
    }
  );
}

执行完 addModule 后,就进入 buildModule (模块构建)的流程;

buildModule 的核心是 module.build 方法;

以 NormalModule 为例,module.build 主要干了下面几件事情:

  • 1、创建 loader上下文
  • 2、通过 runLoaders,执行loader
  • 3、调用 JavascriptParser.js 的 parse 方法将 loader 执行完的源码解析成 ast (使用了acorn 工具),然后手机模块依赖
  • 4、生成模块的 hash
  • 5、缓存解析完的 module 至 _modulesCache,此时已经有_source(解析后的源码)

第五部分 processModuleDependencies

源码

this.buildModule(module, err => {
  ...
  // 这避免了循环依赖的死锁
  if (this.processDependenciesQueue.isProcessing(module)) {
    return callback();
  }
  // 处理模块依赖
  // 执行processModuleDependencies,获得模块依赖
  this.processModuleDependencies(module, err => {...});
});
  
processModuleDependencies(module, callback) {
  this.processDependenciesQueue.add(module, callback);
}

// 流程模块依赖关系
_processModuleDependencies(module, callback) {
  // 存放模块相关依赖
  const dependencies = new Map();
  const sortedDependencies = [];
  ...
  
  // 处理模块依赖流程,将
  const processDependency = dep => {
    this.moduleGraph.setParents(dep, currentBlock, module);
    const resourceIdent = dep.getResourceIdentifier();
    if (resourceIdent) {
      ...
      const constructor = dep.constructor;
      let innerMap;
      let factory;
      if (factoryCacheKey === constructor) {...} else {
        /// 根据依赖类型返回对应工厂
        factory = this.dependencyFactories.get(dep.constructor);
        ...
        innerMap = dependencies.get(factory);
        ...
      }
      // 模块依赖
      let list = innerMap.get(cacheKey);
      if (list === undefined) {
        innerMap.set(cacheKey, (list = []));
        sortedDependencies.push({
          factory: factoryCacheValue2,
          dependencies: list,
          originModule: module
        });
      }
      list.push(dep);
      listCacheKey = cacheKey;
      listCacheValue = list;
    }
  };

  const processDependenciesBlock = block => {
    if (block.dependencies) {
      currentBlock = block;
      // 循环处理模块依赖,将依赖的模块,转化为待处理的模块
      for (const dep of block.dependencies) processDependency(dep);
    }
    if (block.blocks) {
      for (const b of block.blocks) processDependenciesBlock(b);
    }
  };

  try {
    processDependenciesBlock(module);
  } catch (e) {
    return callback(e);
  }
    
  // 如果 sortedDependencies.length === 0 
  // 到此 make 阶段结束 进入 seal 阶段
  if (sortedDependencies.length === 0) {
    callback();
    return;
  }

  asyncLib.forEach(
    sortedDependencies,
    (item, callback) => {
      // 回到 handleModuleCreation,循环构建模块
      this.handleModuleCreation(item, err => {
        ....
        callback();
      });
    },
    err => {...}
  );
}

可以看到,processModuleDependencies 就是处理模块依赖关系的;

processModuleDependencies 里边定义了一个 sortedDependencies,用来保存与 module 相关还未被解析成模块的依赖对象;processDependency 方法的作用类似于第一部分的 addModuleTree;

解析完 module 依赖的模块,将结果保存在 sortedDependencies 中;

  • 如果所有模块 sortedDependencies.length 等于 0,则说明与模块相关的依赖解析完成了,到此 make 阶段结束进入 seal 阶段
  • 如果 sortedDependencies.length 大于 0,则调用 this.handleModuleCreation 继续递归的构建模块

总结

以上,就是 make 阶段的全部内容;

可以看到,make 阶段的作用就是以入口为起点,然后收集所有依赖,形成一颗依赖树;然后对这个依赖树进行模块构建处理;make 阶段过后,与项目相关的所有的资源都已经转化为 js 资源;

但是经过这个阶段,只能说 webpack 对模块进行了初加工,还达不到输出使用标准;还需要后续流程进行优化精加工处理,现在我们继续往下。

10、构建前篇-认识 Compiltion

构建前篇-认识 Compiltion

在构建入口一篇,在初始化 Compiltion 的时候,我们提到过 Compiltion,这里回顾一下;

Compiler 负责把控整个打包过程; Compilation 负责把控详细的编译步骤。

如果把 Compiler 比做生产线,那么 Compilation 就是这条生产线上能把原料变为商品的机器;

compilation 记录本次编译作业的环境信息,该对象负责组织整个编译过程,包含了每个构建环节对应的方法:

1、添加入口 entry,通过 entry 分析模块

2、从入口 entry 开始解析模块之间的依赖,形成一颗模块依赖树

3、构建完成后,进入 seal 阶段,对模块进行一系列的优化

4、优化完成后,将模块转化为标准的 webpack 模块,做输出准备

compilation 存放所有的 modules,chunks,生成的 assets 以及最后用来生成 js 的 template;

从文字角度,我先总结了 Compiltion 提供的功能;

下面,我们就从源码角度认识下 Compiltion;这里只是先了解下 Compiltion 的大体框架,构建细节,将从下一篇开始讲解。

Compiltion 源码

Compiltion.js

...
class Compilation {
	constructor(compiler) {
		...
		// compilation 负责模块 打包 编译 优化 的过程
		this.hooks = {}
		...
		// compiler 实例
		this.compiler = compiler;
		// 输出相关
		...
    // 配置信息
		const options = compiler.options;
		// 相关模版
		...
    // 模块、chunk 图
		this.moduleGraph = new ModuleGraph();
		this.chunkGraph = undefined;
		// 代码生成结果
		this.codeGenerationResults = undefined;

		// 模块队列
		this.processDependenciesQueue = new AsyncQueue({...});
		this.addModuleQueue = new AsyncQueue({...});
		this.factorizeQueue = new AsyncQueue({...});
		this.buildQueue = new AsyncQueue({...});
		this.rebuildQueue = new AsyncQueue({...});

		// 入口相关
		...
		// 存放相关 chunk
		this.chunks = new Set(); 
		...
		// 存放相关 module
		this.modules = new Set(); // 存放 modules 模块
		...
		// 存放 asset
		this.assets = {}; 
		...
		// 存放输出资源
		this.emittedAssets = new Set(); 
		...
		// 文件依赖
		this.fileDependencies = new LazySet(); 
		// 上下文依赖
		this.contextDependencies = new LazySet(); 
		// 错误依赖
		this.missingDependencies = new LazySet(); 
		// 构建依赖
		this.buildDependencies = new LazySet(); 
    ...
    // 缓存相关
		this._modulesCache = this.getCache("Compilation/modules");
		this._assetsCache = this.getCache("Compilation/assets");
		this._codeGenerationCache = this.getCache("Compilation/codeGeneration");
	}

	// 返回缓存
	getCache(name) {
		return this.compiler.getCache(name);
	}
	...

	// 添加模块队列,最后里边的模块会通过绑定的 _addModule 进行处理
	addModule(module, callback) {
		this.addModuleQueue.add(module, callback);
	}
	_addModule(module, callback) {...}
	...

	// 模块对象的构建
	buildModule(module, callback) {
		this.buildQueue.add(module, callback);
	}
	_buildModule(module, callback) {...}

	// 处理模块依赖
	processModuleDependencies(module, callback) {
		this.processDependenciesQueue.add(module, callback);
	}
	...

	// 流程模块依赖关系
	_processModuleDependencies(module, callback) {}

	// 处理模块创建
	handleModuleCreation(...) {...}
	// 分解模块
	factorizeModule(options, callback) {
		// 添加到因式分解队列,最终会通过绑定的 _factorizeModule 方法对 因式分解队列 里边的模块进行处理
		this.factorizeQueue.add(options, callback);
	}
	_factorizeModule(...) {...}

	...
	// 将entry的入口信息”翻译“成一个模块(严格上说,模块是NormalModule实例化后的对象)
	addModuleTree({ context, dependency, contextInfo }, callback) {...}

	addEntry(context, entry, optionsOrName, callback) {...}
	// addEntry的作用是将模块的入口信息传递给模块树中,即 addModuleTree
	_addEntryItem(context, entry, target, options, callback) {...}

	// 将模块添加到 重建模块 队列,之后会由 _rebuildModule 进行处理
	rebuildModule(module, callback) {	
		this.rebuildQueue.add(module, callback);
	}
	_rebuildModule(module, callback) {...}
   // 模块构建好之后会触发 finish
	 // 可以得到 处理后的源码
	finish(callback) {...}

	unseal() {...}

	// 输出
	seal(callback) {...}

	...
  // 代码生成
	codeGeneration(callback) {...}
  ...

	// 添加 chunk
	addChunk(name) {
		if (name) {
			const chunk = this.namedChunks.get(name);
			if (chunk !== undefined) {
				return chunk;
			}
		}
		const chunk = new Chunk(name);
		this.chunks.add(chunk);
		ChunkGraph.setChunkGraphForChunk(chunk, this.chunkGraph);
		if (name) {
			this.namedChunks.set(name, chunk);
		}
		return chunk;
	}
  ...
  // 创建模块 hash
	createModuleHashes() {...}
  // 输出资源
	emitAsset(file, source, assetInfo = {}) {}

  // 更新、重命名、返回、删除、清空 asset
	...

  // 生成资源
	createModuleAssets() {...}

	// 返回渲染清单
	getRenderManifest(options) {
		return this.hooks.renderManifest.call([], options);
	}

  // 值得一提的是,createChunkAssets执行过程中,会优先读取cache中是否已经有了相同hash的资源,
	// 如果有,则直接返回内容,否则才会继续执行模块生成的逻辑,并存入cache中。
	createChunkAssets(callback) {...}
  ...
}
...
module.exports = Compilation;

Compiltion 的源码比较复杂,接近 4000 行的代码量,硬读比较困难;

但是当我们抛开代码实现,只看 Compiltion 代码结构的时候,Compiltion 的功能还是比较清晰的。

可以看到,Compiltion 实际就是在做两件事,处理模块然后根据处理后的模块生成资源

相信通过文字总结和源码窥探,我们对 Compiltion 已经有初步印象了;有了总体的认识,针对后面的分析,相信也比较好理解一些。

1、认识 webpack

认识 webpack

这些年,随着前端不断的发展,以 ES6 为首 JavaScript 新一代标准逐渐流行起来,并且几乎以年为单位的速度不断更新。

同时涌现出了很多拓展语言,比如 Sass、Less、TypeScript 等等;这些语言的诞生,为前端开发带来了很大的便利。

但是现代浏览器的发展速度远跟不上上述技术的更新速度,大多数的 JavaScript 新特性,拓展语言都不是现代浏览器能直接支持的;所以要使用上述技术,我们必须对开发完后的代码进行转换,转换为浏览器能直接支持的语法供浏览器使用;然而,这个转换过程是很繁琐且极为低效的。

基于上述痛点,我们急需一个打包工具,去整合屏蔽掉新技术所带来差异,提高开发效率。

基于这样的历史背景,webpack 诞生了。

官网描述 webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具

有过使用经验的人都知道,它做的事情就是根据入口,分析项目的依赖关系,然后再根据依赖关系找到项目所依赖的模块,最后整理这些模块并输出一个或多个 bundle,供浏览器使用。

webpack 的工作原理

webpack 是一个基于事件流的插件的架构,他的很多功能都是通过诸多的内置插件实现的;

webpack 的插件系统通过 Tapable 提供支持,它类似于 node 的 EventEmit,是一个订阅发布系统;

webpack 通过 Tapable 提供 注册调用 插件的功能。

13、gitlab 开启邮件通知

docker-compose.yml

添加邮件相关的配置,这里以腾讯企业邮箱为例:

version: '3.1'
services:
  # gitlab 相关
  gitlab: 
    ...
    environment: 
      TZ: 'Asia/Shanghai' 
      GITLAB_OMNIBUS_CONFIG: | 
        ...
        gitlab_rails['smtp_enable'] = true 
        gitlab_rails['gitlab_email_enabled'] = true 
        gitlab_rails['gitlab_email_from'] = '[email protected]' 
        gitlab_rails['gitlab_email_display_name'] = 'Gitlab CE' 
        gitlab_rails['smtp_address'] = "smtp.exmail.qq.com" 
        gitlab_rails['smtp_port'] = 465 
        gitlab_rails['smtp_tls'] = true
        gitlab_rails['smtp_user_name'] = "[email protected]" 
        gitlab_rails['smtp_password'] = "Qq751384171" 
        gitlab_rails['smtp_domain'] = "izhiqun.com" 
        gitlab_rails['smtp_authentication'] = "login" 
        gitlab_rails['smtp_enable_starttls_auto'] = false 
        user['git_user_email'] = "[email protected]"
    ...
  # gitlab-runner 相关
  gitlab-runner: 
    ...

注意,如果使用的是阿里云服务器,必须使用 465 的端口,并在安全组放行 465 端口;

修改完 docker-compose.yml 需要重建镜像和启动

运行

docker-compose up -d --build

测试邮件发送

第一步:进入 gitlab bash

docker exec -it gitlab bash

第二步:输入

gitlab-rails console

第三步:编辑要发送的邮箱和内容

Notify.test_email('[email protected]', '邮件标题', '邮件正文').deliver_now

如果显示下面内容,说明邮件发送成功了

image-20210413194913845

邮件配置

第一步:

image-20210416105153003

第二步:

image-20210416105249834

可以看到,这里有两处配置邮件的地方

Emails on push

这个是在代码 push 的时候推送邮件

配置如下

image-20210416105614471

选择激活,然后触发选择 push 和 tag push;

在推送代码和 tag 的时候,Recipients 里配置的邮箱就会收到相应的代码邮件;

Recipients 多个邮箱用逗号隔开。

Pipelines emails

这个是流水线构建邮件

image-20210416105951610

选择激活,然后在 Recipients 里输入要发生邮件的邮箱;

当 gitlab 完成 CI/CD 流程后,就会发送构建状态到相应的邮箱;

这里还有一个附加选项 Notify only broken pipelines,意思是只在构建失败的时候才发送邮件;

5、使用 docker-compose.yml 安装 gitlab 服务

这里 gitlab 我们不直接安装,而是在 docker-compose 里通过镜像的形式进行使用

首先,创建 docker-compose.yml 文件

cd /opt/
mkdir docker_gitlab
vim docker-compose.yml

docker-compose.yml

填入 gitlab 相关的内容

version: '3.1'
services:
  # gitlab 相关
  gitlab: 
    image: 'twang2218/gitlab-ce-zh:11.1.4' # 中文版
    container_name: 'gitlab' 
    restart: always 
    privileged: true 
    hostname: 'gitz.izhiqun.com' 
    ports: 
      - '80:80' # gitlab 网络访问映射端口
      - '443:443' 
      - '22:22' # ssh 映射端口
    environment: 
      TZ: 'Asia/Shanghai' 
      GITLAB_OMNIBUS_CONFIG: | 
        external_url 'http://gitz.izhiqun.com' 
        gitlab_rails['time_zone'] = 'Asia/Shanghai' 
        gitlab_rails['gitlab_shell_ssh_port'] = 22
     volumes: 
       - /opt/docker_gitlab/config:/etc/gitlab 
       - /opt/docker_gitlab/data:/var/opt/gitlab 
       - /opt/docker_gitlab/logs:/var/log/gitlab

注意,这里的 ssh 映射端口可能与 centos 里边自带安装的 ssh 默认端口冲突;

所以这里改为 22 的前提是,需要修改 centos 自带 ssh 的默认端口号为其他端口号(参考上一篇)。

如果这里使用其他端口号,则将 22 修改为其他端口号就行,另外在阿里云服务器安全组放行该端口。

另外:这里 gitlab_rails['gitlab_shell_ssh_port'] 的端口需要和 ssh 端口保持一致。

修改了 docker-compose.yml 需要重建镜像

docker-compose build
docker-compose up -d

# 也可以使用复合指令
docker-compose up -d --build

gitlab 访问

重建镜像并启动完毕,就可以访问 gitlab 了

#浏览器访问
http://112.124.0.72/

#设置密码
1234567

#用户名密码登录
root
1234567

如果 80 端口访问不了,需要在阿里云后台设置网络入方向安全组,添加80端口

image-20210419145542037

这里补充一下 docker 中使用 gitlab 相关的命令

了解即可,后面才能用上

# step 1
docker exec -it gitlab bash
# step 2 gitlab 重载配置
gitlab-ctl reconfigure
# step 3 gitlab 重启
gitlab-ctl restart

4、centos7 重新设置 ssh 默认端口

注意,这一步不是必须的,如果不想修改 centos7 默认的 ssh 端口号,这可以跳过这篇文章。

为什么我们需要重新设置 ssh 的默认端口号

因为我们的代码是托管到我们搭建的 gitlab 上面的,代码的克隆会使用 gitlab 的 ssh 服务;

如果 gitlab 不使用默认的端口号,就需要其他的端口来代理 gitlab 的 ssh 服务,我们的克隆地址会类似于这样

git@XXX:端口号/demo/cicd-demo.git // XXX 代表域名 

如果 gitlab 使用默认的端口号,则我们的克隆地址是这样的,比较符合我们的直觉

git@XXX:demo/cicd-demo.git

这个时候克隆地址就不会显示的显示端口号了

其实 ssh 的 22 端口类似于浏览器的 80 端口

阿里云防火墙设置

#安装
yum install -y curl policycoreutils-python openssh-server
#启动
systemctl enable sshd && systemctl start sshd
#开启防火墙
systemctl enable firewalld && systemctl start firewalld
#运行ssh服务
firewall-cmd --add-service=ssh --permanent
#运行http服务
firewall-cmd --add-service=http --permanent
#重新加载
firewall-cmd --reload

centos7 ssh 默认端口重新设置

#查看 ssh 开放的端口
semanage port -l | grep ssh
# 显示
-> ssh_port_t                     tcp      22

可以看到现在 centos7 系统的默认 ssh 端口是 22

1、为 ssh 增加端口 2222

#为ssh增加端口2222
semanage port -a -t ssh_port_t -p tcp 2222

#--防火墙开放2222/tcp端口
firewall-cmd --add-port=2222/tcp --permanent

#查看
firewall-cmd --list-ports

2、修改ssh服务端口协议

#修改ssh服务端口协议
vim /etc/services

# 文件里边修改这两项
ssh             2222/tcp                          # The Secure Shell (SSH) Protocol
ssh             2222/udp                          # The Secure Shell (SSH) Protocol

3、重启防火墙

#重启防火墙
firewall-cmd --reload
#重启ssh服务
systemctl restart sshd

4、阿里云服务器安全组放行 2222 端口

image-20210419152629351

5、重新登录ssh

#重新登录ssh
ssh [email protected] -p 2222
#然后输入密码
password:

可以看到,现在我们通过命令行登录系统,需要加上端口 2222;说明我们修改成功了。

命令使用延伸

#ssh删除端口2222
semanage port -d -t ssh_port_t -p tcp 2222

#--防火墙删除2222/tcp端口
firewall-cmd --remove-port=2222/tcp --permanent

17、构建产物-seal模块封装

构建产物-seal模块封装

seal 阶段是 webpack 打包比较重要的一个大阶段;

到这一步,说明所有的模块已经构建完毕,在这里进行模块封装;

seal 这一步,对每个 module 和 chunk 进行整理,对生成后的源码进行优化,分割,再组装;

每个 entry(入口) 对应一个 chunk,最后将准备好的所有打包内容,准备输出。

seal 阶段比较复杂,为了方便理解,这里通过流程图先梳理一下 seal 的工作流程;

img

源码中,seal 方法中触发了大量的 hooks,为了便于理解,将很多不是主流程的钩子都省略了。

可以看到,seal 对模块封装的流程还是比较复杂的,流程步骤非常的多;但是单看主流程,似乎还是很容易理解。

我们先梳理一下 seal 的各个阶段所做的工作

1、遍历 entrys,生成 chunk,一个 entry 对应一个 chunk

2、执行 buildChunkGraph,建立起 chunk 与其他依赖之间的关系

3、循环调用 hooks.optimizeChunks,通过相关插件优化 chunk

4、执行 hooks.optimizeChunkModules的钩子,开始进行代码生成和封装

5、执行 createModuleHashes,更新(生成)模块hash

6、执行 codeGeneration,生成代码

7、 执行 processRuntimeRequirements 

8、执行 createHash ,生成 hash,返回 codeGenerationJobs

9、执行 clearAssets,清除 chunk 旧的缓存文件名,防止热更新模式访问到之前的过期内容

10、执行 createModuleAssets,将 module.buildInfo.assets 处理后挂载到 assets 上

11、执行 createChunkAssets,处理 this.chunks,处理完成后挂载到 assets 上

下面我们就从源码的角度,分析下 seal 模块封装流程。

1、遍历 entrys,生成 chunk,一个 entry 对应一个 chunk

首先,创建 ChunkGraph

// 创建 ChunkGraph 
const chunkGraph = new ChunkGraph(this.moduleGraph);
this.chunkGraph = chunkGraph;
// 遍历 modules,将 module 添加到 chunkGraph 上
for (const module of this.modules) {
  ChunkGraph.setChunkGraphForModule(module, chunkGraph);
}

这里,会遍历 modules,将 module 添加到 chunkGraph 上

for (const [name, { dependencies, includeDependencies, options }] of this
  .entries) {
  const chunk = this.addChunk(name);
  const entrypoint = new Entrypoint(options);
  ...
	connectChunkGroupAndChunk(entrypoint, chunk);
  ...
  for (const dep of [...this.globalEntry.dependencies, ...dependencies]) {
    entrypoint.addOrigin(null, { name }, /** @type {any} */ (dep).request);

    const module = this.moduleGraph.getModule(dep);
    if (module) {
      // 在 chunkGraph 上连接 chunk 和入口模块
      chunkGraph.connectChunkAndEntryModule(chunk, module, entrypoint);
      ...
    }
  }
}

环遍历之前添加的入口 entrys(在构建第一步添加的 this.entries);

  • 继续往上看看 SharedPlugin 又是在什么地方注册的

  • 之后实例化一个 entryPoint ,这个 entryPoint 是一个包含 runtimeChunk 的 chunkGroup ,每个 chunkGroup 可以包含多的 chunk ;

  • 执行 connectChunkGroupAndChunk 建立起 module、chunk、entrypoint 的关系。

2、执行 buildChunkGraph,建立起 chunk 与其他依赖之间的关系

buildChunkGraph.js

const buildChunkGraph = (compilation, inputEntrypointsAndModules) => {
	...
  // PART ONE
	visitModules(...);
	...
  // PART TWO
	connectChunkGroups(...);
  ...
	cleanupUnconnectedGroups(compilation, allCreatedChunkGroups);
};

const visitModules = (...) => {
	const { moduleGraph, chunkGraph } = compilation;

	// 循环处理每个模块
	// 1、 对于同步模块,缓存进 blockModulesMap Map
	// 2、 对于异步模块,缓存进 blockQueue Set
	const blockModulesMap = extractBlockModulesMap(compilation);
  ...
}
  
const extractBlockModulesMap = compilation => {
	const { moduleGraph } = compilation;
	const blockModulesMap = new Map();
	const blockQueue = new Set();
  // 循环处理每个模块
	for (const module of compilation.modules) {
		...
		blockQueue.add(module);
		for (const block of blockQueue) {
			let modules;
      // 对于同步模块,缓存进 blockModulesMap Map
			if (moduleMap !== undefined && block.dependencies) {
				 ...
         blockModulesMap.set(block, modules);
         ...
				}
			}
      // 对于异步模块,缓存进 blockQueue Set
			if (block.blocks) {
				for (const b of block.blocks) {
					blockQueue.add(b);
				}
			}
		}
	}
	return blockModulesMap;
};

buildChunkGraph 中:

  • 第一步执行 visitModules 建立模块之间的依赖关系

在这个方法中,对所有 module 进行一次遍历,在遍历 module 的过程中,会对这个 module 的 dependencies 依赖进行处理,获取这个 module 的依赖模块,同时还会处理这个 module 的 blocks (异步加载的模块)。遍历的过程结束后会建立起基本的 module graph,包含普通的 module 及异步 module(block),最终存储到一个 map 表 (blockModulesMap)中,代表着模块间的依赖关系。

  • 第二步执行 connectChunkGroups 建立 chunkGroup 之间的父子关系

  • 第三步执行 cleanupUnconnectedGroups 删除的没有父子关系的 chunkGroup

3、循环调用,hooks.optimizeChunks,通过相关插件优化 chunk

执行 grep 搜索哪些插件监听了 hooks.optimizeChunks 钩子

grep -w "hooks.optimizeChunks" -rn ./node_modules/webpack

image-20210426152938361

可以了解到,这一步会调用与之相关的优化相关的插件

1、EnsureChunkConditionsPlugin

2、SplitChunksPlugin

3、RemoveEmptyChunksPlugin

4、MergeDuplicateChunksPlugin

5、AggressiveMergingPlugin

4、执行 hooks.optimizeChunkModules的钩子,开始进行代码生成和封装

这里开始进行代码生成和封装

5、执行 createModuleHashes,更新(生成)模块hash

这个方法主要做一件事情,就是为 model 生成 hash

createModuleHashes() {
  ...
  for (const module of this.modules) {
    for (const runtime of chunkGraph.getModuleRuntimes(module)) {
      statModulesHashed++;
      this._createModuleHash(...);
    }
  }
}
_createModuleHash( module, ...) {
  const moduleHash = createHash(hashFunction);
  module.updateHash(moduleHash, {
    chunkGraph,
    runtime,
    runtimeTemplate
  });
  ...
  return moduleHashDigest;
}

其中关键的 updateHash 方法,封装在 module 类的实现中;这里的 module 是 normalModule。

NormalModule.js

updateHash(hash, context) {
  hash.update(this.buildInfo.hash);
  this.generator.updateHash(hash, {
    module: this,
    ...context
  });
  super.updateHash(hash, context);
}

this.buildInfo.hash 指模块编译完成时生成的 module hash

6、执行 codeGeneration,代码生成

codeGeneration(callback) {
  const { chunkGraph } = this;
  this.codeGenerationResults = new CodeGenerationResults();
  const jobs = [];
  for (const module of this.modules) {
    const runtimes = chunkGraph.getModuleRuntimes(module);
    if (runtimes.size === 1) {
      for (const runtime of runtimes) {
        ...
        jobs.push({ module, hash, runtime, runtimes: [runtime] });
      }
    } else if (runtimes.size > 1) {
      for (const runtime of runtimes) {
          ...
          const newJob = { module, hash, runtime, runtimes: [runtime] };
          ...
      }
    }
  }
  this._runCodeGenerationJobs(jobs, callback);
}

_runCodeGenerationJobs(jobs, callback) {
  let statModulesFromCache = 0;
  let statModulesGenerated = 0;
  const {
    chunkGraph,
    moduleGraph,
    dependencyTemplates,
    runtimeTemplate
  } = this;
  const results = this.codeGenerationResults;
  const errors = [];
  asyncLib.eachLimit(
    jobs,
    this.options.parallelism,
    ({ module, hash, runtime, runtimes }, callback) => {
      this._codeGenerationModule(
        module,
        runtime,
        runtimes,
        hash,
        dependencyTemplates,
        chunkGraph,
        moduleGraph,
        runtimeTemplate,
        errors,
        results,
        (err, codeGenerated) => {
          if (codeGenerated) statModulesGenerated++;
          else statModulesFromCache++;
          callback(err);
        }
      );
    },
    err => {...}
  );
}
_codeGenerationModule(
  module,
  runtime,
  runtimes,
  hash,
  dependencyTemplates,
  chunkGraph,
  moduleGraph,
  runtimeTemplate,
  errors,
  results,
  callback
) {
  let codeGenerated = false;
  const cache = new MultiItemCache(
    runtimes.map(runtime =>
      this._codeGenerationCache.getItemCache(
        `${module.identifier()}|${getRuntimeKey(runtime)}`,
        `${hash}|${dependencyTemplates.getHash()}`
      )
    )
  );
  cache.get((err, cachedResult) => {
    if (err) return callback(err);
    let result;
    if (!cachedResult) {
      try {
        codeGenerated = true;
        this.codeGeneratedModules.add(module);
        result = module.codeGeneration({
          chunkGraph,
          moduleGraph,
          dependencyTemplates,
          runtimeTemplate,
          runtime
        });
      } catch (err) {...}
    } else {
      result = cachedResult;
    }
    ...
    if (!cachedResult) {
      cache.store(result, err => callback(err, codeGenerated));
    } else {
      callback(null, codeGenerated);
    }
  });
}

codeGeneration 相关的代码比较多,其中关键的 module.codeGeneration 方法,封装在 module 类的实现中。

  • 首先,将 this.modules 转化为一个个构建任务,然后将构建任务放入到 jobs 数组中;

  • 然后将 jobs 传递给 _runCodeGenerationJobs 方法,针对每一个构建任务,执行 _codeGenerationModule方法

  • 然后,在 _codeGenerationModule 中,执行 module.codeGeneration,进行代码生成

  • 最后,将结果 result 放入缓存 cache 中

7、 执行 processRuntimeRequirements

processRuntimeRequirements() {
		const { chunkGraph } = this;

		const additionalModuleRuntimeRequirements = this.hooks
			.additionalModuleRuntimeRequirements;
		const runtimeRequirementInModule = this.hooks.runtimeRequirementInModule;
		for (const module of this.modules) {
			if (chunkGraph.getNumberOfModuleChunks(module) > 0) {
				for (const runtime of chunkGraph.getModuleRuntimes(module)) {
					let set;
					....
					chunkGraph.addModuleRuntimeRequirements(module, runtime, set);
				}
			}
		}
  
		for (const chunk of this.chunks) {
			const set = new Set();
			...
			chunkGraph.addChunkRuntimeRequirements(chunk, set);
		}

		const treeEntries = new Set();
		for (const ep of this.entrypoints.values()) {
			const chunk = ep.getRuntimeChunk();
			if (chunk) treeEntries.add(chunk);
		}
		for (const ep of this.asyncEntrypoints) {
			const chunk = ep.getRuntimeChunk();
			if (chunk) treeEntries.add(chunk);
		}

		for (const treeEntry of treeEntries) {
			const set = new Set();
			...
			chunkGraph.addTreeRuntimeRequirements(treeEntry, set);
		}
	}

processRuntimeRequirements 主要处理 module 和 chunk 运行时引入相关的需求,然后在针对不同的类型,添加到 chunkGraph 上。

  • module -> chunkGraph.addModuleRuntimeRequirements(module, runtime, set)
  • chunk -> chunkGraph.addModuleRuntimeRequirements(module, runtime, set)
  • treeEntry -> chunkGraph.addTreeRuntimeRequirements(treeEntry, set)

8、执行 createHash ,生成 hash,返回 codeGenerationJobs

  • 会根据模块配置的 hash 规则更新(生成)模块hash

比如:模块使用 chunkhash、css 使用 contenthash、图片使用 hash

  • codeGenerationJobs 交给 _runCodeGenerationJobs(jobs, callback) 处理,流程和之前 this.codeGeneration 生成 jobs 后处理流程一样

9、执行 clearAssets,清除 chunk 旧的缓存文件名

clearAssets() {
  for (const chunk of this.chunks) {
    chunk.files.clear();
    chunk.auxiliaryFiles.clear();
  }
}

执行 clearAssets 清除 chunk 的 files 和 auxiliary,

这里缓存的是生成的 chunk 的文件名,防止热更新模式残留上次构建的遗弃内容

10、执行 createModuleAssets,将 module.buildInfo.assets 处理后挂载到 assets 上

createModuleAssets() {
  const { chunkGraph } = this;
  for (const module of this.modules) {
    if (module.buildInfo.assets) {
      const assetsInfo = module.buildInfo.assetsInfo;
      for (const assetName of Object.keys(module.buildInfo.assets)) {
        const fileName = this.getPath(assetName, {
          chunkGraph: this.chunkGraph,
          module
        });
        // 将文件名添加到 chunk 的 auxiliaryFiles 上面
        for (const chunk of chunkGraph.getModuleChunksIterable(module)) {
          chunk.auxiliaryFiles.add(fileName);
        }
        this.emitAsset(
          fileName,
          module.buildInfo.assets[assetName],
          assetsInfo ? assetsInfo.get(assetName) : undefined
        );
        this.hooks.moduleAsset.call(module, fileName);
      }
    }
  }
}

emitAsset(file, source, assetInfo = {}) {
  if (this.assets[file]) {
    ...
  }
  // 将资源挂载到 compiltion 对象的 assets 上
  this.assets[file] = source;
  this._setAssetInfo(file, assetInfo, undefined);
}

可以看到 createModuleAssets 是比较简单的

  • 遍历 this.modules, 并检查 module 上是否存在 module.buildInfo.assets
  • 然后遍历 module.buildInfo.assets,得到 fileName, 并将文件名添加到 chunk 的 auxiliaryFiles 上面
  • 调用 this.emitAsset,将资源挂载到 compiltion 对象的 assets 上

11、执行 createChunkAssets,处理 this.chunks,处理完成后挂载到 assets 上

执行 createChunkAssets 的作用时,决定最终输出到每个 chunk 当中对应的文本内容是什么;

首先,会调用 getRenderManifest 触发 hooks.renderManifest 钩子

getRenderManifest(options) {
		return this.hooks.renderManifest.call([], options);
}

asyncLib.forEach(
this.chunks,
(chunk, callback) => {
  let manifest;
  try {
    manifest = this.getRenderManifest({...});
  } catch (err) {...}

通过 grep 命令看看哪些地方监听了 hooks.renderManifest 钩子

grep -w "hooks.renderManifest" -rn ./node_modules/webpack

image-20210426165940486

我们发现 MainTemplate.js 和 ChunkTemplate.js 在 webpack5 中,对 hooks.renderManifest 已经废弃;在 webpack5 中主要是用 JavascriptModulesPlugin 处理;

JavascriptModulesPlugin.js

compilation.hooks.renderManifest.tap(
	"JavascriptModulesPlugin",
  (result, options) => {
    const {
      hash,
      chunk,
      chunkGraph,
      moduleGraph,
      runtimeTemplate,
      dependencyTemplates,
      outputOptions,
      codeGenerationResults
    } = options;
    const hotUpdateChunk = chunk instanceof HotUpdateChunk ? chunk : null;
    let render;
    const filenameTemplate = JavascriptModulesPlugin.getChunkFilenameTemplate(
      chunk,
      outputOptions
    );
    if (hotUpdateChunk) {
      render = () => this.renderChunk(...);
    } else if (chunk.hasRuntime()) {
      render = () => this.renderMain(...);
    } else {
      ...
      render = () => this.renderChunk(...);
    }

    result.push({
      render,
      filenameTemplate,
      pathOptions: {
        hash,
        runtime: chunk.runtime,
        chunk,
        contentHashType: "javascript"
      },
      info: {
        javascriptModule: compilation.runtimeTemplate.isModule()
      },
      identifier: hotUpdateChunk
        ? `hotupdatechunk${chunk.id}`
        : `chunk${chunk.id}`,
      hash: chunk.contentHash.javascript
    });

    return result;
  }
);

首先根据 chunk 是否是 HotUpdateChunk 实例来决定渲染方式;

如果 hotUpdateChunk 不为空,render 使用 renderChunk,用于热更新 chunk 的生成;

如果 chunk 是运行时,则 render 使用 renderMain 用于运行时代码的生成;

否则 render 默认使用 renderChunk 用于普通 chunk 代码的生成;

最后把处理好的 render 和 filenameTemplate、pathOptions 以及一些其他信息拼装成一个对象添加到 result 数组中返回,然后赋值给 manifest。

manifest = this.getRenderManifest({...});

然后执行

asyncLib.forEach(
  manifest,
  (fileManifest, callback) => {
    const ident = fileManifest.identifier;
    const usedHash = fileManifest.hash;
    const assetCacheItem = this._assetsCache.getItemCache(ident,usedHash);
    assetCacheItem.get((err, sourceFromCache) => {
      ...
        let source = sourceFromCache;
        const alreadyWritten = alreadyWrittenFiles.get(file);
        if (alreadyWritten !== undefined) {
           ...
            source = alreadyWritten.source;
        } else if (!source) {
          // render the asset
          source = fileManifest.render();

          // Ensure that source is a cached source to avoid additional cost because of repeated access
          if (!(source instanceof CachedSource)) {
            const cacheEntry = cachedSourceMap.get(source);
            if (cacheEntry) {
              source = cacheEntry;
            } else {
              const cachedSource = new CachedSource(source);
              cachedSourceMap.set(source, cachedSource);
              source = cachedSource;
            }
          }
        }
        // 可以在 createChunkAssets 方法体中的 this.emitAsset(file, source, assetInfo) 代码行打上断点,
        // 观察此时 source 中的数据结构。在 source._source 字段已经初见打包后源码雏形
        this.emitAsset(file, source, assetInfo);
        ....
  },
  callback
);

这里的关键代码是 fileManifest.render,用于生成代码,最终返回 ConcatSource 类型实例。

回到,JavascriptModulesPlugin.js 看看 renderChunk 和 renderMain 的源码

	renderChunk(renderContext, hooks) {
		const { chunk, chunkGraph } = renderContext;
		const modules = chunkGraph.getOrderedChunkModulesIterableBySourceType(
			chunk,
			"javascript",
			compareModulesByIdentifier
		);
		const moduleSources =
			Template.renderChunkModules(
				renderContext,
				modules ? Array.from(modules) : [],
				module => this.renderModule(module, renderContext, hooks, true)
			) || new RawSource("{}");
		...
		return new ConcatSource(source, ";");
	}

	renderMain(renderContext, hooks, compilation) {
		const { chunk, chunkGraph, runtimeTemplate } = renderContext;
		....
		const chunkModules = Template.renderChunkModules(
			renderContext,
			inlinedModules
				? allModules.filter(m => !inlinedModules.has(m))
				: allModules,
			module =>
				this.renderModule(
					module,
					renderContext,
					hooks,
					allStrict ? "strict" : true
				),
			prefix
		);
		...
		return iife ? new ConcatSource(finalSource, ";") : finalSource;
	}

这里的关键代码是 Template.renderChunkModules,可以看到 renderChunk 和 renderMain 最终都会执行 Template.renderChunkModules;

Template.renderChunkModules 的工作其实就是根据生成的内容所使用到的 webpack_require 的函数,增加添加对应的代码,例如__webpack_require__、__webpack_require__.n、__webpack_require__.r等

执行完毕之后,最终返回 ConcatSource 类型实例。

之后,返回 createChunkAssets 函数体 执行 emitAsset

source = fileManifest.render();
this.emitAsset(file, source, assetInfo);

此时可以在 this.emitAsset(file, source, assetInfo) 代码行打上断点,
观察此时 source 中的数据结构。在 source._source 字段已经初见打包后源码雏形了。

执行 emitAsset 将处理好的 source 挂载到 this.assets 对象上。

至此 seal 阶段全部完成。

8、gitlab 项目拉取

image-20210419153810745命令行指令

根据不同情况,拉取项目

Git 全局设置
git config --global user.name "XXX"
git config --global user.email "[email protected]"
创建新版本库
git clone git@XXX:demo/cicd-demo.git # XXX 代表域名
cd cicd-demo
touch README.md
git add README.md
git commit -m "add README"
git push -u origin master
已存在的文件夹
cd existing_folder
git init
git remote add origin git@XXX:demo/cicd-demo.git
git add .
git commit -m "Initial commit"
git push -u origin master
已存在的 Git 版本库
cd existing_repo
git remote rename origin old-origin
git remote add origin git@XXX:demo/cicd-demo.git
git push -u origin --all
git push -u origin --tags

由于是新建的项目,这里使用创建新版本库的做法,如果满足其他需求,自选即可

拉取项目

注意⚠️拉取项目的时候如果确定配置好了 ssh 还是需要密码,可能是服务器上文件的权限问题

#第一步:命令行转到 gitlab bash
docker exec -it gitlab bash
#第二步:设置权限
chmod 400 /etc/gitlab/ssh*

回到本地电脑命令行

mkdir GitlabProject
cd GitlabProject 
git clone git@XXX:demo/cicd-demo.git
cd cicd-demo

image-20210419154137025

现在 gitlab 已经可以为我们托管项目了

到这一步,gitlab 托管我们自己的项目就实现了;下一步,进行 CI/CD 相关的配置

1、docker 安装

docker 安装

# 安装必要依赖
yum install -y yum-utils device-mapper-persistent-data lvm2

# 配置docker下载源
yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

# 安装docker
sudo yum list docker-ce --showduplicates | sort -r # 列出可以安装的docker版本
sudo yum install -y docker-ce # 下载最新版本

使用docker

# 启动docker 
sudo systemctl start docker
# 停止启动docker
sudo systemctl stop docker
# 重启docker  
sudo systemctl restart docker
# 设置为开机自动启动
sudo systemctl enable docker

检查docker是否安装成功

docker --version # 查看安装的docker版本

卸载 docker

sudo yum remove docker-ce
# delete all Images, containers, volumes, or customized configuration files 
sudo rm -rf /var/lib/docker

11、在 gitlab 为 CICD 配置 ssh 密钥登录

原理

A 机器免密登录 B 机器,A机器IP(192.168.1.210),B机器IP(192.168.1.211),正确姿势:

1、在A机器上生成密码:

$ cd ~/.ssh
$ ssh-keygen -t rsa -C '[email protected]'

密钥名字可以自己随便取,默认是 id_rsa,如果使用默认名字,一路回车即可。

2、把A机器生成的公钥 id_rsa.pub 文件发送到 B 机器上:

$ ssh-copy-id -i /root/.ssh/id_rsa.pub [email protected]

系统自动在B机器 root/.ssh 目录中生成 authorized_keys 文件

3、在 A 机器上测试SSH登录 B 机器免密:

$ ssh 192.168.1.211

配置

1、在阿里云服务器根目录生成公钥私钥

ssh-keygen -t rsa -C '[email protected]'

然后一路回车即可

image-20210412190521366

红框是公钥私钥的位置

2、把公钥匙链接到目标服务器的 authorized_keys (所在位置 ~/.ssh/authorized_keys)

ssh-copy-id -i /root/.ssh/id_rsa.pub [email protected] -p 22

image-20210419161249115

期间会输入一次目标服务器的登录密码

3、在 .gitlab-ci.yam 中添加如下配置

# 前置脚本
before_script:
  # 加载私钥
  - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
  - eval $(ssh-agent -s)
  - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
  - mkdir -p ~/.ssh
  - chmod 700 ~/.ssh
  - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'

然后再添加一个上传命令,就可以把打包后的代码上传到我们指定的服务器了

deploy-job: # 部署结果到目标服务器
  tags:
    - test
  stage: deploy
  <<: *commonConfig
  script:
    - pwd #  输出下路径
    - scp -r ./dist root@域名(或者目标服务器的ip):/opt/www  # 将打包好的文件上到发布项目的服务器中的。放到nginx能访问到的文件夹下

9、构建入口

构建入口

在构建入口前篇我们说过,构建准备 完成之后,会调用 compiler.run() 方法,这一步之后,就进入了 webpack 最核心的类 Compiler 了;

这篇我们将分析从 compiler.run() 开始到正式开始构建这期间 webpack 所做的工作。

先看下流程图

img

现在,我们去看看源码实现

compiler.run 方法源码

compiler.run

run(callback) {
		...
     // 最终的回调
		const finalCallback = (err, stats) => {...};

		....
    // 构建完成后的回调
		const onCompiled = (err, compilation) => {...};

		const run = () => {
			// 1 清理缓存
			this.hooks.beforeRun.callAsync(this, err => {
				if (err) return finalCallback(err);
        // webpack 5 中,这个钩子内部没有做处理
				this.hooks.run.callAsync(this, err => {
					if (err) return finalCallback(err);

					this.readRecords(err => {
						if (err) return finalCallback(err);
						//  最终回到 onCompiled 回调
						this.compile(onCompiled);
					});
				});
			});
		};

		if (this.idle) {
			this.cache.endIdle(err => {
				if (err) return finalCallback(err);
				this.idle = false;
				run();
			});
		} else {
			run();
		}
	}

run 方法其实很简单,里边主要就这 3 个方法

  • run 是构建入口方法
  • onCompiled 是构建完成后的回调
  • finalCallback 是最后的回调

这里,先通过 this.idle 看看 Compiler 是否空闲状态,如果不是空闲状态,则设置为空闲状态,在调用 run 方法;由于 onCompiled,finalCallback 是构建完成后调用的方法,因此,我们后面再分析。

调用 run 方法,首先会触发两个钩子 ,然后再调用 this.compiler 方法

1、this.hooks.beforeRun.callAsync()

2、this.hooks.run.callAsync()

3、this.compiler

下面我们查看一下 beforeRun 和 run 做了哪些事情。

hooks.beforeRun

首先通过 grep 命令查看一下,哪些插件注册监听了 beforeRun 钩子

grep -w "hooks.beforeRun" -rn ./node_modules/webpack

image-20210329201952852

通过搜索结果可以看到 NodeEnvironmentPlugin 插件监听了 hooks.beforeRun 这个钩子,是不是在那里见过 NodeEnvironmentPlugin;对,就是在构建准备那一节注册了这个钩子;现在我们点击查看一下 NodeEnvironmentPlugin 到底做了什么功能。

NodeEnvironmentPlugin.js

class NodeEnvironmentPlugin {
	...
	apply(compiler) {
		compiler.infrastructureLogger = createConsoleLogger(....);
    // 定义了输入输出流
		compiler.inputFileSystem = new CachedInputFileSystem(fs, 60000);
		const inputFileSystem = compiler.inputFileSystem;
		compiler.outputFileSystem = fs;
		compiler.intermediateFileSystem = fs;
		compiler.watchFileSystem = new NodeWatchFileSystem(
			compiler.inputFileSystem
		);
		// 监听了 beforeRun 这个钩子
		compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
			if (compiler.inputFileSystem === inputFileSystem) {
				// 作用是清理构建缓存。 purge(清洗)
				inputFileSystem.purge();
			}
		});
	}
}
module.exports = NodeEnvironmentPlugin;

可以看到,这个插件主要做了两件事情

1、定义 inputFileSystem、outputFileSystem、watchFileSystem 等输入输出流

2、监听了 beforeRun 这个钩子,用于构建之前清理缓存

hooks.run

和上面一样,通过 grep 命令查看一下,哪些插件注册监听了 run 钩子

grep -w "hooks.run" -rn ./node_modules/webpack

image-20210329203522223

可以看到,主要是 ProgressPlugin 这个插件里边看到了 hooks.run 的身影;

我们都知道 ProgressPlugin 这个插件是和进度相关的插件,主要的功能就是记录 webpack 的构建进度,因此在 webpack 5 中,run 这个钩子已经没有实际意义。顺便提一句,在 webpack 4 中,存在一个 CachePlugin 在监听 run 钩子,主要在这个钩子中主要是处理缓存的模块,减少编译的模块,加速编译速度,这里了解一下即可。

this.compile

下面是 compile 函数的源代码

compile(callback) {
		// 初始化模块工厂对象:这一步后才能识别不同的模块
		// 初始化 normalModuleFactory contextModuleFactory
		const params = this.newCompilationParams();
		this.hooks.beforeCompile.callAsync(params, err => {
			if (err) return callback(err);

			this.hooks.compile.call(params);
			 // compilation 记录本次编译作业的环境信息 
			 // 1、该对象负责组织整个编译过程,包含了每个构建环节对应的方法
			 // 2、对象内部保留了对 compiler 对象的引用 
			 // 3、并且存放所有的 modules, chunks,生成的 assets 以及最后用来生成 js 的 template
			const compilation = this.newCompilation(params);
      ...
			// 编译过程,编译正式开始
			// 1、其中 EntryPlugin 监听了 make 钩子,这里会调用 addEntry
			// 2、找到入口 js 模块,进行下一步的模块绑定
			this.hooks.make.callAsync(compilation, err => {
        ...
				//模块构建完成
				this.hooks.finishMake.callAsync(compilation, err => {
          ...
          // 可以理解为 node 自动支持的一种异步函数,
					process.nextTick(() => {
						...
						// 做一些模块的错误和警告的处理
						compilation.finish(err => {
							...
							// 封装模块开始
							compilation.seal(err => {
								...
								// 编译完成
								this.hooks.afterCompile.callAsync(compilation, err => {
									...
									return callback(null, compilation);
								});
							});
						});
					});
				});
			});
		});
	}
1、首先,调用了 this.newCompilationParams 方法

this.newCompilationParams 会初始化模块工厂对象;

这个方法很重要,因为后续的 JS 模块处理就是由模块工厂负责的

至于模块工厂干的细活,后面我们会详细分析

// 初始化模块工厂对象:这一步后才能识别不同的模块
// 初始化 normalModuleFactory contextModuleFactory
const params = this.newCompilationParams();

// newCompilationParams 方法
newCompilationParams() {
  const params = {
    normalModuleFactory: this.createNormalModuleFactory(),
    contextModuleFactory: this.createContextModuleFactory()
  };
  return params;
}
2、第二步,调用 hooks.beforeCompile 钩子

可以看一下哪些插件监听了这个钩子

grep -w "hooks.beforeCompile" -rn ./node_modules/webpack 

image-20210330171725069

可以看到,主要有 LazyCompilationPlugin,DllReferencePlugin 这两个插件;

LazyCompilationPlugin 是和 hmr 相关的插件;

DllReferencePlugin 监听的 beforeCompile 钩子,也只是查看 webpack 配置文件是否配置了 manifest 字段,如果是一个路径,则在插件的 _compilationData 里边以 weakMap 的方式记录了 manifest 路径对应的内容;

因此 hooks.beforeCompile 更多的是提供了一个编译前的钩子,在这个阶段,用户可以定制自己对于构建过程有用的功能。

3、第三步,调用 hooks.compile 钩子

可以看一下哪些插件监听了这个钩子

grep -w "hooks.compile" -rn ./node_modules/webpack

image-20210330173335224

可以看到,主要有 DllReferencePlugin, ExternalsPlugin,DelegatedPlugin 这几个插件;

简要的贴下代码:

// DllReferencePlugin.js
compiler.hooks.compile.tap("DllReferencePlugin", params => {
			.....
			const normalModuleFactory = params.normalModuleFactory;
			new ExternalModuleFactoryPlugin(sourceType || "var", externals).apply(
				normalModuleFactory
			);
			new DelegatedModuleFactoryPlugin({
				source: source,
				type: this.options.type,
				scope: this.options.scope,
				context: this.options.context || compiler.options.context,
				content,
				extensions: this.options.extensions,
				associatedObjectForCache: compiler.root
			}).apply(normalModuleFactory);
		});

// ExternalsPlugin
compiler.hooks.compile.tap("ExternalsPlugin", ({ normalModuleFactory }) => {
			new ExternalModuleFactoryPlugin(this.type, this.externals).apply(
				normalModuleFactory
			);
		});

// DelegatedPlugin
compiler.hooks.compile.tap("DelegatedPlugin", ({ normalModuleFactory }) => {
			new DelegatedModuleFactoryPlugin({
				associatedObjectForCache: compiler.root,
				...this.options
			}).apply(normalModuleFactory);
		});

通过源码我们可以知道,hooks.compile 这个钩子没干别的事情,还是在为编译过程做准备;

主要是在 normalModuleFactory 这个模块工厂上面注册插件,在 webpack 处理模块相关的功能的时候,会触发这些工厂插件。

4、第四步,实例化 compilation

我们知道 Compiler 是 webpack 最重要的类;那么这里的 Compilation 就是 webpack 第二重要的类;

Compiler 负责把控整个打包过程; Compilation 负责把控详细的编译步骤。

如果把 Compiler 比做生产线,那么 Compilation 就是这条生产线上能把原料变为商品的机器;

还不了解 Compilation 没关系,我们先对它做个初步了解。

compilation 记录本次编译作业的环境信息,该对象负责组织整个编译过程,包含了每个构建环节对应的方法:

1、添加入口 entry,通过 entry 分析模块

2、从入口 entry 开始解析模块之间的依赖,形成一颗模块依赖树

3、构建完成后,进入 seal 阶段,对模块进行一系列的优化

4、优化完成后,将模块转化为标准的 webpack 模块,做输出准备

compilation 存放所有的 modules,chunks,生成的 assets 以及最后用来生成 js 的 template

5、第五步,调用 hooks.make 钩子

可以看一下哪些插件监听了这个钩子

grep -w "hooks.make" -rn ./node_modules/webpack

image-20210330175623065

可见,监听 hooks.make 钩子的插件比较多,这里挑最重要的 EntryPlugin 插件介绍一下;

通过前面的学习,我们之前 EntryPlugin 这个插件是在构建准备阶段就注册好了的钩子;

// WebpackOptionsApply.js
process(options, compiler) {
 ...
    // 用于入口解析(包括确定是单入口还是多入口)
		new EntryOptionPlugin().apply(compiler);
		compiler.hooks.entryOption.call(options.context, options.entry);
 ...
}
 
// EntryOptionPlugin.js
 static applyEntryOption(compiler, context, entry) {
		...
			const EntryPlugin = require("./EntryPlugin");
			...
				for (const entry of desc.import) {
					new EntryPlugin(context, entry, options).apply(compiler);
				}
		...
	}

EntryPlugin.js

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
			const { entry, options, context } = this;

			const dep = EntryPlugin.createDependency(entry, options);
			// 建编译入口, 编译正式开始
			compilation.addEntry(context, dep, options, err => {
				callback(err);
			});
		});

可以看到,执行这个钩子,调用了 compilation 对象上的 addEntry 方法;

compilation 就正式的接管编译过程了;

也就是说,从这一刻起,编译过程正式开始。

12、构建模块工厂-NormalModuleFactory

构建模块工厂-NormalModuleFactory

image-20210408171802007

NormalModuleFactory 主要干4件事情:

1、创建 NormalModule 实例;

2、为即将构建的模块找到相应的 loader,用于处理不同的资源;

3、为相应的模块创建相应的 parser ,parser 的作用是将源码解析为 AST,即 source -> AST;

4、为相应的模块创建相应的 generator 用于生成代码;

总之,NormalModuleFactory 的工作逻辑就是;创建模块,在构建模块的时候映射相应的 loader,同时为构建模块添加相应的依赖,在解析的时候提供相应的 parser 解析器,在代码生成的时候提供 generator 生成器。

NormalModuleFactory 流程图

img

NormalModuleFactory 源码解析

由于 NormalModuleFactory 的代码比较多,为了更加方便查看 NormalModuleFactory,下面只保留了主要逻辑代码。

class NormalModuleFactory extends ModuleFactory {
  
	constructor({
		context,
		fs,
		resolverFactory,
		options, // 对应 webpack 配置里边的 module 配置项
		associatedObjectForCache,
		layers = false
	}) {
		super();
		this.hooks = Object.freeze({});
		this.resolverFactory = resolverFactory;
		// ruleSet 存放的是 loader,包括自带的 loader 和自定义的 loader
		this.ruleSet = ruleSetCompiler.compile([
			{
				rules: options.defaultRules
			},
			{
				rules: options.rules
			}
		]);
		...
		/*****
		 * hooks.factorize 钩子主要干了几件事情
		 * 1、调用 hooks.resolve 钩子函数,将与解析模块相关的数据挂载到 resolveData.createData 对象上
		 * 2、创建 NormalModule 实例
		 * 3、将 createdModule createData resolveData 整合成一个对象 createdModule 返回
		 * 至此,createdModule 包含了模块路径,处理模块的 loader 路径,以及解析和生成相关模块的方法;
		 * createdModule 就相当于模块的使用说明书
		 */
		this.hooks.factorize.tapAsync(
			{
				name: "NormalModuleFactory",
				stage: 100
			},
			(resolveData, callback) => {
				// 内部继续调用  hooks.resolve 钩子
				this.hooks.resolve.callAsync(resolveData, (err, result) => {
					...
					this.hooks.afterResolve.callAsync(resolveData, (err, result) => {
						...
						const createData = resolveData.createData;

						this.hooks.createModule.callAsync(
							createData,
							resolveData,
							(err, createdModule) => {
								if (!createdModule) {
									...
									// 创建 NormalModule 实例
									createdModule = new NormalModule(createData);
								}
								createdModule = this.hooks.module.call(
									createdModule,
									createData,
									resolveData
								);
								return callback(null, createdModule);
							}
						);
					});
				});
			}
		);
    /***
		 * hooks.resolve 主要干了几件事情
		 * 1、根据资源依赖类型,挂载相应的 loader
		 * 2、将与处理模块相关的 loader 数组,以及解析器 parser,生成器 generator 挂载 resolveData 的 createData 对象上
		 */
		this.hooks.resolve.tapAsync(
			{...},
			(data, callback) => {
				....
				const continueCallback = needCalls(2, err => {
					...
					const result = this.ruleSet.exec({...});
					const settings = {};
					const useLoadersPost = [];
					const useLoaders = [];
					const useLoadersPre = [];
					for (const r of result) {...}

					let postLoaders, normalLoaders, preLoaders;

					const continueCallback = needCalls(3, err => {
						...
						const allLoaders = postLoaders;
						....
						Object.assign(data.createData, {
							...
							request: stringifyLoadersAndResource(
								allLoaders,
								resourceData.resource
							),
							...
							loaders: allLoaders,
							...
							// 配备解析器
							parser: this.getParser(type, settings.parser),
							// 配备生成器
							generator: this.getGenerator(type, settings.generator),
							resolveOptions
						});
						callback();
					});
					...
				});
        // 获取 loader 地址
				this.resolveRequestArray(...);
				...
			}
		);
	}

	// 这里注意做了 3 件事情
	//  1、执行 factory.hooks.factorize.call 钩子,然后会调用 ExternalModuleFactoryPlugin 中注册的钩子,用于配置外部文件的模块加载方式, 例如fs, http, events等node原生方法
	//  2、使用enhanced-resolve解析模块和loader真实绝对路径
	//  3、new NormalModule() 创建 module 实例
	create(data, callback) {
		// 模块依赖
		const dependencies = /** @type {ModuleDependency[]} */ (data.dependencies);
		if (this.unsafeCache) {
			// 如果有缓存,则直接返回
			const cacheEntry = dependencyCache.get(dependencies[0]);
			if (cacheEntry) return callback(null, cacheEntry);
		}
		const context = data.context || this.context;
		const resolveOptions = data.resolveOptions || EMPTY_RESOLVE_OPTIONS;
		const dependency = dependencies[0];
		const request = dependency.request;
		const contextInfo = data.contextInfo;
		const fileDependencies = new LazySet();
		const missingDependencies = new LazySet();
		const contextDependencies = new LazySet();
		/** @type {ResolveData} */
		// 需要解决的数据
		const resolveData = {
			contextInfo,
			resolveOptions,
			context,
			request,
			dependencies,
			fileDependencies,
			missingDependencies,
			contextDependencies,
			createData: {},
			cacheable: true
		};
		// 解析前对 resolveData 进行检查
		this.hooks.beforeResolve.callAsync(resolveData, (err, result) => {
			if (err) {
				return callback(err, {
					fileDependencies,
					missingDependencies,
					contextDependencies
				});
			}

			// Ignored: 忽略
			if (result === false) {
				return callback(null, {
					fileDependencies,
					missingDependencies,
					contextDependencies
				});
			}

			if (typeof result === "object")
				throw new Error(
					deprecationChangedHookMessage(
						"beforeResolve",
						this.hooks.beforeResolve
					)
				);
      // 执行factory.hooks.factorize.call钩子,
			// 然后会调用ExternalModuleFactoryPlugin中注册的钩子,
			// 用于配置外部文件的模块加载方式, 例如fs, http, events等node原生方法
			this.hooks.factorize.callAsync(resolveData, (err, module) => {
				if (err) {
					return callback(err, {
						fileDependencies,
						missingDependencies,
						contextDependencies
					});
				}

				const factoryResult = {
					module,
					fileDependencies,
					missingDependencies,
					contextDependencies
				};

				if (
					this.unsafeCache &&
					resolveData.cacheable &&
					module &&
					this.cachePredicate(module)
				) {
					for (const d of dependencies) {
						dependencyCache.set(d, factoryResult);
					}
				}

				callback(null, factoryResult);
			});
		});
	}
	...
  // 返回解析器:解析器是用来解析 JS 模块的
	getParser(type, parserOptions = EMPTY_PARSER_OPTIONS) {
		...
		parser = this.createParser(type, parserOptions);
		...
		return parser;
	}

	/**
	 * 创建解析器
	 * @param {*} type 解析器类型
	 * @param {*} parserOptions 解析器配置
	 * JS 类型的parser 分为3种:“auto”, "script", "module"
	 * 根据模块的需求,factory 帮我们匹配不同的解析器(parser)
	 */
	createParser(type, parserOptions = {}) {...}
  
	// 返回生成器:
	getGenerator(type, generatorOptions = EMPTY_GENERATOR_OPTIONS) {
		...
		generator = this.createGenerator(type, generatorOptions);
		...
		return generator;
	}
  
	/**
	 * 创建生成器
	 * @param {*} type 生成器类型
	 * @param {*} generatorOptions 生成器配置
	 * JS 类型的 generator 分为 3 种:“auto”, "script", "module"
	 * 根据模块的需求,factory 帮我们匹配不同的生成器(generator)
	 */
	createGenerator(type, generatorOptions = {}) {...}

	getResolver(type, resolveOptions) {
		// 通过 enhanced-resolve 得到真实的路径
		/**
		 * webpack 使用 enhanced-resolve 来进行模块解析
		 * 它的作用类似于一个异步的 require.resolve 方法,
		 * 将一个 require/import 的语句中的引入字符串,解析为引入文件的绝对路径
		 * 
		 * 这里:ResolverFactory.createResolver 方法,生成了一个 Resolver 对象,并将它的 resolve 方法暴露给外部
		 */ 
		return this.resolverFactory.get(type, resolveOptions);
	}
}

module.exports = NormalModuleFactory;

去除了 NormalModuleFactory 类里边的辅助性代码,NormalModuleFactory 的功能就比较清晰的展现出来了;

NormalModuleFactory 继承自 ModuleFactory;通过前面的介绍可以知道,webpack 除了 NormalModuleFactory 还有一种上下文模块工厂 ContextModuleFactory;不同的模块工厂处理不同类型的模块,没有本质上的差别;这里只对 NormalModuleFactory 进行分析;

NormalModuleFactory 主要包括下面几个部分:

  • 构造函数 constructor
  • create 创建方法
  • getParser 返回解析器方法
  • getGenerator 返回生成器方法

上面 4 个方法就是 NormalModuleFactory 工厂的骨架了;

不难发现,NormalModuleFactory 主要负责的就是创建模块、解析模块以及生成目标代码相关的工作;webpack 成为模块打包器,那么处理模块的核心就在这里了。

下面我们就上述 NormalModuleFactory 的 4 个部分进行分析

构造函数 constructor

NormalModuleFactory 的构造函数主要做了下面几件事情

1、挂载自身相关的钩子函数

2、挂载 resolverFactory

resolverFactory 主要的功能就是提供路径解析
它的内部使用 enhanced-resolve 来进行模块路径解析;
它的作用类似于一个异步的 require.resolve 方法;
将一个 require/import 的语句中的引入字符串,解析为引入文件的绝对路径。

3、挂载 ruleSet

ruleSet 存放的是 loader,包括自带的 loader 和自定义的 loader

4、监听了 hooks.factorize 钩子

hooks.factorize 钩子主要干了几件事情
(1) 调用 hooks.resolve 钩子函数,将与解析模块相关的数据挂载到 resolveData.createData 对象上
(2) 创建 NormalModule 实例
(3) 将 createdModule createData resolveData 整合成一个对象 createdModule 返回
至此,createdModule 包含了模块路径,处理模块的 loader 路径,以及解析和生成相关模块的方法;createdModule 就相当于模块的使用说明书,他告诉使用者用什么模块的路径,处理资源的 loader 以及解析和生成代码的方法。

5、监听了 hooks.resolve 钩子

hooks.resolve 主要干了几件事情
(1) 根据资源依赖类型,挂载相应的 loader
(2) 将与处理模块相关的 loader 数组,以及解析器 parser,生成器 generator 挂载到 resolveData 的 createData 对象上

create 创建方法

下面是 create 的主要原代码

create(data, callback) {
		// 模块依赖
		const dependencies = /** @type {ModuleDependency[]} */ (data.dependencies);
		if (this.unsafeCache) {
			// 如果有缓存,则直接返回
			const cacheEntry = dependencyCache.get(dependencies[0]);
			if (cacheEntry) return callback(null, cacheEntry);
		}
		...
		const resolveData = {
			contextInfo,
			resolveOptions,
			context,
			request,
			dependencies,
			fileDependencies,
			missingDependencies,
			contextDependencies,
			createData: {},
			cacheable: true
		};
		// 解析前对 resolveData 进行检查
		this.hooks.beforeResolve.callAsync(resolveData, (err, result) => {
			...
      // 执行factory.hooks.factorize.call钩子,
			// 然后会调用 ExternalModuleFactoryPlugin 中注册的钩子,
			// 用于配置外部文件的模块加载方式, 例如fs, http, events等node原生方法
			this.hooks.factorize.callAsync(resolveData, (err, module) => {
				...
				const factoryResult = {
					module,
					fileDependencies,
					missingDependencies,
					contextDependencies
				};
				...
				callback(null, factoryResult);
			});
		});
	}

create 是 NormalModuleFactory 的工厂方法,顾名思义它的作用是创建模块的,可以看到 create 的工作步骤还是比较简单的:

1、检查是否有模块缓存,如果有,则直接返回

2、创建了一个 resolveData 对象,此时 createData 是空对象

3、调用 hooks.beforeResolve 钩子,对 resolveData 进行检查

4、调用 hooks.factorize 钩子,产生 module,然后组装相应的依赖数据,形成 factoryResult 返回给 Compilation

getParser 返回解析器方法

// 返回解析器:解析器是用来解析 JS 模块的
getParser(type, parserOptions = EMPTY_PARSER_OPTIONS) {
  ...
  parser = this.createParser(type, parserOptions);
  ...
  return parser;
}

/**
 * 创建解析器
 * @param {*} type 解析器类型
 * @param {*} parserOptions 解析器配置
 * JS 类型的 parser 分为3种:“auto”, "script", "module"
 * 根据模块的需求,factory 帮我们匹配不同的解析器(parser)
 */
createParser(type, parserOptions = {}) {
  ...
  const parser = this.hooks.createParser.for(type).call(parserOptions);
  ...
}

getParser 的作用是返回一个对应模块类型的解析器;

JS 类型的 parser 分为3种:“auto”, "script", "module";

根据模块的需求,factory 帮我们匹配不同的解析器(parser);

下面,我们通过 grep 查看哪些类监听了 hooks.createParser 钩子:

grep -w "hooks.createParser" -rn ./node_modules/webpack 

image-20210408181624327

这里我们查看 JavascriptModulesPlugin

// JavascriptModulesPlugin.js
apply(compiler) {
		compiler.hooks.compilation.tap(
			"JavascriptModulesPlugin",
			(compilation, { normalModuleFactory }) => {
				....
				// createParser 注册了 3 个类型;auto、script、module
				// 根据不同的需求,normalModuleFactory 使用不同的 parser
				normalModuleFactory.hooks.createParser
					.for("javascript/auto")
					.tap("JavascriptModulesPlugin", options => {
						return new JavascriptParser("auto");
					});
				normalModuleFactory.hooks.createParser
					.for("javascript/dynamic")
					.tap("JavascriptModulesPlugin", options => {
						return new JavascriptParser("script");
					});
				normalModuleFactory.hooks.createParser
					.for("javascript/esm")
					.tap("JavascriptModulesPlugin", options => {
						return new JavascriptParser("module");
					});
					}
       ...
		);
  }

可以看到,normalModuleFactory.hooks.createParser 会根据不同的 JS 类型,返回不同的 JavascriptParser 实例;

getGenerator 返回生成器方法

// 返回生成器:
getGenerator(type, generatorOptions = EMPTY_GENERATOR_OPTIONS) {
  ...
  generator = this.createGenerator(type, generatorOptions);
  ...
  return generator;
}

/**
 * 创建生成器
 * @param {*} type 生成器类型
 * @param {*} generatorOptions 生成器配置
 * JS 类型的 generator 分为 3 种:“auto”, "script", "module"
 * 根据模块的需求,factory 帮我们匹配不同的生成器(generator)
 */
createGenerator(type, generatorOptions = {}) {
  ...
  	const generator = this.hooks.createGenerator
			.for(type)
			.call(generatorOptions);
  ...
}

getGenerator 的作用是返回一个对应模块类型的生成器;

JS 类型的 generator 分为 3 种:“auto”, "script", "module";

根据模块的需求,factory 帮我们匹配不同的生成器(generator);

下面,我们通过 grep 查看哪些类监听了 hooks.createGenerator 钩子:

grep -w "hooks.createGenerator" -rn ./node_modules/webpack 

image-20210408193827236

可以看到,parser 和 generator 在同一个插件里边处理

同上这里我们查看 JavascriptModulesPlugin

// JavascriptModulesPlugin.js
apply(compiler) {
		compiler.hooks.compilation.tap(
			"JavascriptModulesPlugin",
			(compilation, { normalModuleFactory }) => {
				....
				// createGenerator 注册了 3 个类型;“auto”, "script", "module"
				// 之后通过 JavascriptGenerator 统一处理
				normalModuleFactory.hooks.createGenerator
					.for("javascript/auto")
					.tap("JavascriptModulesPlugin", () => {
						return new JavascriptGenerator();
					});
				normalModuleFactory.hooks.createGenerator
					.for("javascript/dynamic")
					.tap("JavascriptModulesPlugin", () => {
						return new JavascriptGenerator();
					});
				normalModuleFactory.hooks.createGenerator
					.for("javascript/esm")
					.tap("JavascriptModulesPlugin", () => {
						return new JavascriptGenerator();
					});
       ...
		);
  }

可以看到,normalModuleFactory.hooks.createGenerator 虽然会根据不同的 JS 类型,但是返回的 JavascriptGenerator 实例却是相同的;这是因为,webpack 天生只支持 JS 资源,不同的资源在 parse 阶段通过 loader 都解析成了 JS 模块,所以生成阶段只需要按照统一的标准输出就行。

getResolver(type, resolveOptions)

最后 getResolver 也有必要说明一下:

getResolver(type, resolveOptions) {
  // 通过 enhanced-resolve 得到真实的路径
  /**
   * webpack 使用 enhanced-resolve 来进行模块路径解析
   * 它的作用类似于一个异步的 require.resolve 方法,
   * 将一个 require/import 的语句中的引入字符串,解析为引入文件的绝对路径
   * 
   * 这里:ResolverFactory.createResolver 方法,生成了一个 Resolver 对象,并将它的 resolve 方法暴露给外部
   */ 
  return this.resolverFactory.get(type, resolveOptions);
}

// ResolverFactory.js
const Factory = require("enhanced-resolve").ResolverFactory;
...
get(type, resolveOptions = EMPTY_RESOLVE_OPTIONS) {
  ...
  const newResolver = this._create(type, resolveOptions);
  ...
  return newResolver;
}
_create(type, resolveOptionsWithDepType) {
  ...
  const resolver = Factory.createResolver(resolveOptions);
  ...
  return resolver;
}

getResolver 返回的是 resolverFactory.get 的执行结果;

通过 ResolverFactory.get 源码可以知道:

1、ResolverFactory 内部是使用 enhanced-resolve 来进行路径解析的;

2、Factory.createResolver 方法,生成了一个 Resolver 对象,并将它的 resolve 方法暴露给外部使用;它的作用类似于一个异步的 require.resolve 方法,将一个 require/import 的语句中的引入字符串,解析为引入文件的绝对路径;

总结

NormalModuleFactory 的作用就是创建模块,并且给模块提供相应的使用说明书;有了这个说明书,后续的流程就知道怎么处理这个模块了。

这里再强调一遍开头的总结,NormalModuleFactory 主要干4件事情:

NormalModuleFactory 主要干3件事情:

1、创建 NormalModule 实例;

2、为即将构建的模块找到相应的 loader,用于处理不同的资源;

3、为相应的模块创建相应的 parser ,parser 的作用是将源码解析为 AST,即 source -> AST;

4、为相应的模块创建相应的 generator 用于生成代码;

总之,NormalModuleFactory 的工作逻辑就是;创建模块,在构建模块的时候映射相应的 loader,同时为构建模块添加相应的依赖,在解析的时候提供相应的 parser 解析器,在代码生成的时候提供 generator 生成器。

15、构建-finishMake 阶段

构建-finishMake 阶段

我们知道,make 阶段是以 entry 为起点,然后通过不断的依赖分析形成一个依赖图,按照依赖图构建模块;按道理说,make 阶段已经处理好了与项目相关的所有模块;那么 finishMake 又是干什么的呢。

老套路,首先我们新看看哪些地方监听了 hooks.finishMake 钩子

grep -w "hooks.finishMake" -rn ./node_modules/webpack

image-20210423114257577

可以看到 ProvideSharedPlugin 监听了这个钩子,点击查看

compiler.hooks.finishMake.tapPromise("ProvideSharedPlugin", compilation => {
			const resolvedProvideMap = compilationData.get(compilation);
			if (!resolvedProvideMap) return Promise.resolve();
			return Promise.all(
				Array.from(
					resolvedProvideMap,
					([resource, { config, version }]) =>
						new Promise((resolve, reject) => {
							compilation.addInclude(
								compiler.context,
								new ProvideSharedDependency(...),
								{
									name: undefined
								},
								err => {...}
							);
						})
				)
			).then(() => {});
		});

代码不多,也很好理解,核心代码其实就是 compilation.addInclude;

这里,通过调用 compilation 的 addInclude;

查看 addInclude 源码:

addEntry(context, entry, optionsOrName, callback) {
	...
	this._addEntryItem(context, entry, "dependencies", options, callback);
}

addInclude(context, dependency, options, callback) {
  this._addEntryItem(
    context,
    dependency,
    "includeDependencies",
    options,
    callback
  );
}

又回到了熟悉的地方;

这里 addEntry 和 addInclude 都调用 _addEntryItem 开始进行模块的构建,不同的是,依赖类型 dependency 不同,addInclude 使用的是 ProvideSharedDependency 类型的依赖;而 target addEntry 传递的是 “dependencies”,而 addInclude 传递的是 “includeDependencies”。

然而,看似 addEntry 和 addInclude 的功能熟悉且相似,但是 addInclude 是使用场景和 addEntry 是完全不同的;

这里涉及到到了 webpack5 提供的新功能,模块联邦 ModuleFederation(关于模块联邦笔者也是出于概念了解,没有实践过;这里也是在读 webpack 源码的过程中恰巧遇到,以后实践了这部分内容再来进行补充。)

那么 ProvideSharedPlugin 是在哪里注册的呢

grep -w "new ProvideSharedPlugin" -rn ./node_modules/webpack

image-20210423120607893

可以看到,ProvideSharedPlugin 是在 SharedPlugin 里边注册的

apply(compiler) {
		...
		new ProvideSharedPlugin({
			shareScope: this._shareScope,
			provides: this._provides
		}).apply(compiler);
	}

继续往上看看 SharedPlugin 又是在什么地方注册的

grep -w "new SharePlugin" -rn ./node_modules/webpack

image-20210423120746514

可以看到,SharedPlugin 是在 ModuleFederationPlugin 里边注册的

终于破案了,通过层层寻找,终于找到了 ModuleFederationPlugin;

虽然对 webpack5 模块联邦的只是处于概率了解阶段,但是 webpack5 是通过插件的形式来进行处理的,也就是在 webpack.config.js 的 plugins 数组进行配置;

模块联邦的核心就是 ModuleFederationPlugin;

因此 hooks.finishMake 钩子对于我们不使用“模块联邦”相关的功能的时候,似乎可以忽略;

这里引出了 webpack5 的模块联邦的概率,感兴趣的小伙伴可以去研究一下。

9、gitlab-runner 安装

Runner 可以理解为:在特定机器上根据项目的 .gitlab-ci.yml 文件,对项目执行 pipeline 的程序。Runner 可以分为两种: Specific RunnerShared Runner

  • **Shared Runner **是 Gitlab 平台提供的免费使用的 runner 程序,它由 Google 云平台提供支持,每个开发团队有十几个。对于公共开源项目是免费使用的,如果是私人项目则有每月 2000 分钟的CI 时间上限。
  • **Specific Runner **是我们自定义的,在自己选择的机器上运行的runner程序,gitlab给我们提供了一个叫 gitlab-runner 的命令行软件,只要在对应机器上下载安装这个软件,并且运行 gitlab-runner register 命令,然后输入从 gitlab-ci 交互界面获取的 token 进行注册, 就可以在自己的机器上远程运行pipeline程序了。

修改 docker-compose.yml 文件,添加 gitlab-runner 相关的内容

version: '3.1'
services:
  # gitlab 相关
  ...
  # gitlab-runner 相关
  gitlab-runner: 
    image: gitlab/gitlab-runner 
    restart: always 
    container_name: 'gitlab-runner' 
    privileged: true 
    volumes: 
       - ./config:/etc/gitlab-runner
       - /var/run/docker.sock:/var/run/docker.sock #宿主机机的docker.sock映射到镜像里面
       - /usr/bin/docker:/bin/docker #宿主机的docker可执行映射到镜像里面 后面build的时候会用到,目的是在容器里边可以使用 docker 和 docker-compose 相关命令
      #  - /cache # 缓存目录

然后命令行执行

# 分配宿主机的权限给容器使用 - /var/run/docker.sock:/var/run/docker.sock
# 在宿主机启动 docker 程序后执行(这个是给 docker 权限给容器的)
sudo chown root:root /var/run/docker.sock 
# 在 /opt 目录中重新启动容器
docker-compose up -d --build 
# ⚠️注意:添加容器权限,保证容器可以使用宿主机的 docker(这个一定要在容器跑起来以后执行, 要不然后面自动构建会出现权限问题)
docker exec -it gitlab-runner usermod -aG root gitlab-runner

image-20210412110445707

注册 gitlab-runner 到 gitlab

首先,获取在 Runner 设置时指定以下 URL,以及在安装过程中使用以下注册令牌;

位置是在,项目 -> 设置 -> CI/CD -> Runner 中

image-20210419154714702

# 回到命令行执行
docker exec -it gitlab-runner gitlab-runner register

⚠️注意:如果是前端项目建议 Enter an executor 这一步选择 docker

然后选择一个镜像

[root@iZbp19ntc41vxf23iy8fi7Z ~]# docker exec -it gitlab-runner gitlab-runner register
Runtime platform                                    arch=amd64 os=linux pid=355 revision=54944146 version=13.10.0
Running in system-mode.                            
# 输入 Gitlab 实例的地址
Enter the GitLab instance URL (for example, https://gitlab.com/):
http://XXX.XXX.XXX/ # 上面的 URL
# 输入token
Enter the registration token:
XXXX # 上面的 token
# 输入 Runner 的描述
Enter a description for the runner:
[0c6a23ee0fea]: zq-runner-test
# 输入与 Runner 关联的标签
Enter tags for the runner (comma-separated):
test
Registering runner... succeeded                     runner=Fx95vBdE
# 输入 Ruuner 的执行者
Enter an executor: custom, docker-ssh, shell, docker-ssh+machine, kubernetes, docker, parallels, ssh, virtualbox, docker+machine:
docker
# 指定 docker 镜像
Enter the default Docker image (for example, ruby:2.6):
node:14.15.4
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded! 

当然 runner 执行者也可以选择其他

[root@iZbp19ntc41vxf23iy8fi7Z opt]# docker exec -it gitlab-runner gitlab-runner register
Runtime platform                                    arch=amd64 os=linux pid=29 revision=54944146 version=13.10.0
Running in system-mode.                            
# 输入 Gitlab 实例的地址
Enter the GitLab instance URL (for example, https://gitlab.com/):
http://XXX.XXX.XXX/ # 上面的 URL
# 输入token
Enter the registration token:
XXXX # 上面的 token
# 输入Runner的描述
Enter a description for the runner:
[0c6a23ee0fea]: zq-runner-test
# 输入与Runner关联的标签
Enter tags for the runner (comma-separated):
deploy
Registering runner... succeeded                     runner=Fx95vBdE
# 输入Ruuner的执行者:这里有坑,如果选择 shell 那么 runner 访问不到宿主环境的信息
# 前端项目建议选择 docker,具体看下面的一份配置
Enter an executor: custom, docker-ssh, shell, ssh, virtualbox, docker-ssh+machine, docker, parallels, docker+machine, kubernetes:
shell
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded! 
[root@iZbp19ntc41vxf23iy8fi7Z opt]# 

执行完上面步骤后,runner 就运行起来了,类似于下面这样

image-20210419155353783

3、如何调试 webpack

如何调试 webpack

我们可以用 node-inspector 在 chrome 中调试 node 代码,这比命令行中调试方便很多。

node 从 v6.x 开始已经内置了一个 inspector,当我们启动的时候可以加上 --inspect 参数即可:

对普通 test 文件进行调试可以这样

node --inspect test.js

那么如何 debug 我们的 webpack 呢,我是这么做的:

1、命令行输入

node --inspect-brk ./node_modules/webpack/bin/webpack.js --config webpack.config.js

当然也可以在 package.json 文件里的 scripts 字段下新增一条脚本命令

"scripts": {
    ....
    "debug": "node --inspect-brk ./node_modules/webpack/bin/webpack.js --config webpack.config.js",
  },

这样就可以直接运行

npm run debug
# 或者
yarn debug

输入命令后,如果成功执行会输出下面信息

image-20210326112809910

2、打开 chrome 浏览器

在地址栏输入并回车

chrome://inspect/#devices

就可以看到如下界面

image-20210326113039259

点击 inspect

3、调试

上一步点击 inspect 后会弹出浏览器调试栏,并且第一行代码就暂停了,会发现在文件列表中可能找不到其他文件,这时候可以点击下面红框添加我们想调试的目录。

Chrome中调试webpack 截图如下:

image-20210326113322204

现在我们就可以一步步对我们的代码进行调试了

19、webpack 工作流程图

webpack 工作流程图

分析完 webpack 的打包流程,现在我们来进行汇总一下,形成一张完整的包含重要节点的打包流程图

img

14、阿里云下打包错误解决

项目环境:打包环境是基于 centos 7,项目是 React 写的,打包用的 webpack

Linux 和 macOS 以及 Window 的区别是它的文件名是分大小写的

第一步:webpack 添加 case-sensitive-paths-webpack-plugin 插件检查文件路径大小写问题

webpack.config.js

// 引入
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')

// 插件数组里边添加
plugins:[
    // 检查 windows macos linux 不同系统下的路径问题
    new CaseSensitivePathsPlugin({
      debug: false
    })
]

第二步:进入阿里云 centos 系统,运行项目

前提是阿里云里边安装好了 node 环境,推荐使用 nvm 的方式,方便管理 node 版本

npm run start

看看有没有引入库的大小写问题,比如以安装 clipboard 库为例子:

package.js

"clipboard": "^2.0.4",

项目引入

import clipboard from "Clipboard" // linux 下会报错

正确写法应该是

import clipboard from "clipboard" // 正确

第三步:如果还有文件找不到的问题,比如

ERROR in XXX/a/index.js 13:36-71
Module not found: Error: Can't resolve 'XXX/b' in 'XXX/a'

这个时候就要去检查 git 项目里边的 b 文件是否存在,虽然这种机率不大,但是有可能就是这种问题导致的,本地项目里边的文件可能以为种种问题,没能同步到 git 库。特别是从其他 git 库 remote 的项目需要注意这一点。

7、gitlab ssh 密钥设置

1、打开本地终端,使用如下命令生成ssh公钥和私钥对

$ cd ~/.ssh
$ ssh-keygen -t rsa -b 4096 -C '[email protected]'

2、回车然后会出现:Enter file in which to save the key (/Users/idid/.ssh/id_rsa):

输入密钥名字,⚠️注意不能和之前创建的密钥名相同

image-20210410141405730

3、设置你的密码

这一步一般直接回车

4、查看本机ssh公钥,并获取

$ vim id_rsa_gitlab.pub

image-20210419153532970

5、上传密钥

image-20210410141800209

下一步,就可以进行代码拉取了

2、项目准备

项目准备

环境准备

如果自己电脑里边已经搭建好了 node 环境,可以跳过这一步

1、nvm 安装: nvm 是 node 版本管理库,可以随意安装和切换不同的node版本
2、node 安装

webpack安装 和 webpack-cli安装

1、 创建空目录和 package.json

mkdir webpack-origin-code
cd webpack-origin-code
npm init -y (y 是全部默认选项都选 yes 的简称)

2、 安装 webpack 和 webpack-cli

npm install webpack webpack-cli --save-dev

也可

yarn add webpack webpack-cli --dev

检查是否安装成功:

./node_modules/.bin/webpack -v

image-20210326104028940

可以看到我安装的版本是

  • webpack 5.25.1
  • webpack-cli 4.5.0

因此我们的源码分析也是基于 webpack 5.25.1 和 webpack-cli 4.5.0 的版本

项目文件准备

1、在根目录下创建 src 文件夹,用于存放项目文件

项目文件如下

src
  ├── main.js           
  ├── a.js            

准备点简单的内容

2、在根目录下创建 dist 文件夹,用于存放项目打包后的文件

src
  ├── bundle.js               

创建 webpack 配置文件

在项目根目录下创建 webpack.config.js 并且做简单的配置

const path = require('path');
module.exports = {
  mode: "development",
  entry: path.join(__dirname, './src/main.js'),
  output: {
    path: path.join(__dirname, 'dist'),
    filename: "bundle.js",
  },
  devtool: "source-map"
}

然后在 package.json 文件配置的 scripts 下配置好打包命令就可以开始打包了

package.json

{
  ...
  "scripts": {
    "build": "webpack --config webpack.config.js",
  },
  ...
}

运行

npm run build 

也可

yarn build 

打包结果

image-20210326111826585

image-20210326112007102

可以看到这个时候 webpack 已经可以正常工作了,好了,现在我们可以开始源码之旅了。

16、构建结束 - compilation.finish

构建结束 - compilation.finish

finishMake 钩子执行完成之后,下一步又回到 compilation 执行 finish;

下面,我们看看 finish 到底做了什么

源码

finish(callback) {
		// webpack5 新加的
		if (this.profile) {
			...
			const logNormalSummary = (category, getDuration, getParallelism) => {...};
			const logByLoadersSummary = (category, getDuration, getParallelism) => {...};
			logNormalSummary(
				"resolve to new modules",
				p => p.factory,
				p => p.factoryParallelismFactor
			);
			...
			this.logger.timeEnd("finish module profiles");
		}
		this.logger.time("finish modules");
		const { modules } = this;
		// 
		this.hooks.finishModules.callAsync(modules, err => {
			this.logger.timeEnd("finish modules");
			if (err) return callback(err);

			// extract warnings and errors from modules
			this.logger.time("report dependency errors and warnings");
			for (const module of modules) {
				// console.log(module._source.source());// 处理后的源码
				// 报告依赖错误和警告
				this.reportDependencyErrorsAndWarnings(module, [module]);
				const errors = module.getErrors();
				if (errors !== undefined) {
					for (const error of errors) {
						if (!error.module) {
							error.module = module;
						}
						this.errors.push(error);
					}
				}
				const warnings = module.getWarnings();
				if (warnings !== undefined) {
					for (const warning of warnings) {
						if (!warning.module) {
							warning.module = module;
						}
						this.warnings.push(warning);
					}
				}
			}
			this.logger.timeEnd("report dependency errors and warnings");
			callback();
		});
	}

可以看到,compilation.finish 的功能很简单,就是在做收集工作,收集模块构建过程中产生的日志、警告以及错误:

  • 通过 logNormalSummary 和 logByLoadersSummary 收集日志信息

  • 调用 hooks.finishModules 结束模块构建的钩子

  • 收集每个模块在构建过程中产生的错误和警告

5、webpack-cil 的职责

webpack-cil 的职责

webpack-cli 和 webpack 本来是一家人,是从 webpack 4 开始才分的家。

他们虽然分家了,但是要承担起 webpack 家族打包的重任,还得他们密切配合才能完成。

因此,我们在使用 webpack 的时候,需要同时安装 webpack 和 webpack-cli。

见名知意

顾名思义 webpack-cli 主要负责 webpack 相关命令处理。

他就像是 webpack 的管家,当命令来的时候,他先负责接待;然后再看命令的具体需求,响应不同的操作;

比如:

webpack --help

看到这样的命令,就输出帮助信息,不需要劳烦 webpack 大驾;

webpack --config webpack.config.js

看到这样的命令,就明白,这是要打包了,再将需求交给 webpack 处理。

追根溯源

明白了 webpack-cli 大概的职责,接下来我们可以看看 webpack-cli 代码是怎么实现的。

这里顺便说一句,对于命令的配置以及命令行的解析,webpack 5 重写了这部分;webpack 5 不再使用 yargs 库,而是使用 commander 库对命令进行配置及解析。

webpack-cli 目录结构

image-20210326171543183

首先

当在命令行输入 webpack 命令的时候,会进入 webpack -> bin 目录下的 webpack.js 文件

webpack.js

#!/usr/bin/env node

/**
 * 执行某个 shell 脚本。例如:npm install webpack-cli -D
 */
const runCommand = (command, args) => {...};

/**
 * 检测某个包是否安装,即 require.resolve 这个包是否会抛出异常
 */
const isInstalled = packageName => {...};

/**
 * @param {CliOption} cli options
 * @returns {void}
 */
const runCli = cli => {
	const path = require("path");
  // webpack-cli/package.json
	const pkgPath = require.resolve(`${cli.package}/package.json`);
  // 得到 package.json 对象
	const pkg = require(pkgPath);
	// .../webpack-cli ./bin/cli.js
	require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
};

/** @type {CliOption} */
const cli = {
	name: "webpack-cli",
	package: "webpack-cli",
	binName: "webpack-cli",
	installed: isInstalled("webpack-cli"),
	url: "https://github.com/webpack/webpack-cli"
};

if (!cli.installed) {
	  ....
    // 运行安装命令,开始安装
		runCommand(packageManager, installOptions.concat(cli.package))
			.then(() => {
				// 安装完成,运行命令
				runCli(cli);
			})
			.catch(error => {...});
	});
} else {
	runCli(cli);
}

webpack.js 的功能其实比较简单,就是验证 webpack-cli 库是否安装

1、如果没有安装,可以按照提示进行安装

2、如果安装了 webpack-cli 库,就运行 runCli(cli)

经过上面步骤之后,最关键的一步其实就是

// .../webpack-cli/bin/cli.js
require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));

此时程序就进入了 webpack-cli 执行 bin 目录下的 cli.js

cli.js

#!/usr/bin/env node

'use strict';
....
const runCLI = require('../lib/bootstrap');// 引导
....

process.title = 'webpack';
// webpack 包是否存在
if (utils.packageExists('webpack')) {
    // argv是对 webpack --config webpack.config.js 这行命令的解析
    runCLI(process.argv, originalModuleCompile);
} else {
    // 如果不存在,则安装 webpack
    // 再执行 runCLI(process.argv, originalModuleCompile);
    .....
}

可以看到,这个文件的功能,主要就是检测 webpack 是否安装,然后进入 bootstrap.js 文件

bootstrap.js

const WebpackCLI = require('./webpack-cli');
const utils = require('./utils');

const runCLI = async (args, originalModuleCompile) => {
    try {
        // 创建 webpack-cli 实例
        const cli = new WebpackCLI();
        cli._originalModuleCompile = originalModuleCompile;
        await cli.run(args);
    } catch (error) {...}
};

module.exports = runCLI;

这个类的主要功能就是实例化 webpack-cli 类,然后运行 run 方法

webpack-cli.js

const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const Module = require('module');

const { program } = require('commander'); // commander,命令行参数处理框架
const utils = require('./utils');

class WebpackCLI {
    constructor() {
        // Global
        // this.webpack = require('webpack');
        this.webpack = require('webpack');
        
        this.logger = utils.logger;
        this.utils = utils;

        // Initialize program
        this.program = program;
        this.program.name('webpack');
        ...
    }
    ...
    // 命令行分析开始
    async run(args, parseOptions) {
        // Built-in internal commands
        // 构建命令
        const buildCommandOptions = {};
        // 监视命令
        const watchCommandOptions = {};
        // 版本相关的命令
        const versionCommandOptions = {};
        // 帮助相关的命令
        const helpCommandOptions = {};
        // Built-in external commands
        // 外部命令 no build 命令
        const externalBuiltInCommandsInfo = [];

        // 已知命令
        const knownCommands = [
            buildCommandOptions,
            watchCommandOptions,
            versionCommandOptions,
            helpCommandOptions,
            ...externalBuiltInCommandsInfo,
        ];
        ...
        // 加载命令,通过名字
        const loadCommandByName = async (commandName, allowToInstall = false) => {
            // 是否是 build 类型的命令
            const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
            // 是否是监控类型的命令
            const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);

            if (isBuildCommandUsed || isWatchCommandUsed) {
                // 返回构建配置项
                const options = this.getBuiltInOptions();
                // console.log("options:",options);

                await this.makeCommand(
                    isBuildCommandUsed ? buildCommandOptions : watchCommandOptions,
                    isWatchCommandUsed ? options.filter((option) => option.name !== 'watch') : options,
                    async (entries, options) => {
                        if (entries.length > 0) {
                            // 入口处理
                            options.entry = [...entries, ...(options.entry || [])];
                        }
                        // 重要
                        await this.buildCommand(options, isWatchCommandUsed);
                    },
                );
            } else if (isCommand(commandName, helpCommandOptions)) {
                // 帮助命令
               this.makeCommand(helpCommandOptions, [], () => {});
            } else if (isCommand(commandName, versionCommandOptions)) {
                // 版本命令
                this.makeCommand(versionCommandOptions, [], () => {});
            } else {
                // no build 命令
                ...
            }
        };

        // Register own exit
        // 重写退出和输出
        this.program.exitOverride(async (error) => {...};

        // Default `--color` and `--no-color` options
        const cli = this;
        ...

        // Make `-v, --version` options
        // Make `version|v [commands...]` command
        // 输出版本命令
        const outputVersion = async (options) => {...};
        // 输出帮助命令
        const outputHelp = async (options, isVerbose, isHelpCommandSyntax, program) => {...};
        // 命令执行回调
        this.program.action(async (options, program) => {
            ...
            // 是否是帮助命令语法
            const isHelpCommandSyntax = isCommand(operand, helpCommandOptions);
            // 帮助命令处理
            if (options.help || isHelpCommandSyntax) {...}
            // 版本命令处理
            if (options.version || isCommand(operand, versionCommandOptions)) {...}

            let commandToRun = operand;
            let commandOperands = operands.slice(1);
            // 已知命令
            if (isKnownCommand(commandToRun)) {
                await loadCommandByName(commandToRun, true);
            } else {
                // 未知命令处理
                ...
            }
            await this.program.parseAsync([commandToRun, ...commandOperands, ...unknown], { from: 'user' });
        });
        // 解析命令
        await this.program.parseAsync(args, parseOptions);
    }
    ...
    //MAKE: 创建Compiler
    async createCompiler(options, callback) {
        // 设置 node 运行环境,dev 或者 pro
        this.applyNodeEnv(options); 
        // 配置文件处理
        let config = await this.resolveConfig(options);
        config = await this.applyOptions(config, options);
        // 给 webpack 的配置文件的 plugins 数组的头部加上 CLIPlugin 插件
        config = await this.applyCLIPlugin(config, options);

        let compiler;
        try {
            // 新建 compiler 实例
            compiler = this.webpack(
                config.options,
                callback
                    ? (error, stats) => {
                          if (error && this.isValidationError(error)) {
                              this.logger.error(error.message);
                              process.exit(2);
                          }
                          callback(error, stats);
                      }
                    : callback,
            );
        } catch (error) {...}

        // TODO webpack@4 return Watching and MultiWatching instead Compiler and MultiCompiler, remove this after drop webpack@4
        if (compiler && compiler.compiler) {
            compiler = compiler.compiler;
        }

        return compiler;
    }
    // 构建命令
    async buildCommand(options, isWatchCommand) {
        let compiler;
        const callback = (error, stats) => {...};
        ...
        // 初始化 compiler 
        compiler = await this.createCompiler(options, callback);
        ...
    }
}

module.exports = WebpackCLI;

从代码量可以看出,这是 webpack-cli 的核心类,事实也是如此。

这个类代码量比较大,但是核心其实就 3 个方法

1、run 方法

  • webpack 5 通过 commander 这个命令行参数处理框架来定制命令;

  • run 方法,根据命令行 shell 和 webpack 配置定制 webpack 命令,然后解析执行;

  • 如果识别到 build 或者 watch 命令,就会调用 buildCommand 方法,执行打包流程;

2、buildCommand 方法

buildCommand 比较简单

  • 这个方法主要就是定义了一个 callback 回调函数,这个回调函数会在打包结束之后执行;
  • 调用 createCompiler 方法;

3、createCompiler 方法

createCompiler 这个方法是 webpack-cli 最核心的方法;

执行完这一步后,webpack-cli 也就完成了他的主要任务,把执行权交回给 webpack;

可以看到,这个方法的主要功能是:

  • 设置了 node 的运行环境,development 或者 production
  • 对 webpack 配置文件进行了处理
  • 对配置文件的插件数组的头部插入了 applyCLIPlugin 插件
  • 然后执行 webpack 方法,这个方法会返回一个 compiler 实例

8、构建入口前篇-认识 Compiler

构建入口前篇-认识 Compiler

构建准备 完成之后,会调用 compiler.run() 方法,这一步之后,就进入了 webpack 最核心的类 Compiler 了;

因为 Compiler 是 webpack 的核心类,在构建打包的过程中扮演了非常重要的角色,因此在分析 Compiler 的 run 方法之前,我们先大概了解下 Compiler 类的基本结构,以及 Compiler 的工作流程,对 Compiler 的职责有个大概的认识;

Compiler.js

下面是简化折叠后的 Compiler.js

...
const {
	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook
} = require("tapable");
...

class Compiler {
	constructor(context) {
		// 所有钩子都是由 Tapable 提供的,不同钩子类型在触发时,调用时序也不同
		// 定义不同的钩子
		this.hooks = Object.freeze({...});
    // 初始化 compiler 对象上的属性
		this.webpack = webpack;
		this.name = undefined;  // 名字
		this.outputPath = ""; // 输出路径
    ...
	  // webpack 配置
		this.options = /** @type {WebpackOptions} */ ({});
    // 上下文
		this.context = context;
		// 缓存
		this.cache = new Cache();
    ...
		// 资源输出缓存
		this._assetEmittingSourceCache = new WeakMap();
		this._assetEmittingWrittenFiles = new Map();
	}

	// 获取缓存
	getCache(name) {
		return new CacheFacade(this.cache, `${this.compilerPath}${name}`);
	}

	
	getInfrastructureLogger(name) {}

	// 监听
	watch(watchOptions, handler) {}

	// 启动
	run(callback) {}

	
	runAsChild(callback) {}

	purgeInputFileSystem() {}

	// 输出资源
	emitAssets(compilation, callback) {}

	// 输出记录
	emitRecords(callback) {}

	// 读取记录
	readRecords(callback) {}

	// 创建子编译器
	createChildCompiler(}

	isChild() {}
  // 创建编译器
	createCompilation() {}

	// 新建一个编译实例
	newCompilation(params) {}
  // 创建 normalModule 工厂
	createNormalModuleFactory() {}
  // 创建  contextModule 工厂
	createContextModuleFactory() {}
	newCompilationParams() {}
	
	// 开始编译
	compile(callback) {}

	// 关闭,退出
	close(callback) {}
}

module.exports = Compiler;

可以看到,webpack 5 的 Compiler 类不再继承 Tabable,而是将 Tabable 类单独作为工具库使用;实现了 Compiler 与 Tapable 的解耦。

通过前面的介绍,我们知道 Compiler 类的入口方法是 compiler.run;

下面我们通过流程图直观的了解一下 Compiler 类的工作流程,也就是 Compiler 类的生命周期。

Compiler 类的生命周期

img

通过上面的简化的 Compiler 类和流程图,虽然我们忽略了细节,但似乎对 Compiler 有了总体的认识;

  • 首先,构造函数里边定义了,hooks(钩子)、配置文件、上下文,缓存等信息
  • 其次,Compiler 的类方法,涵盖了启动、监听、创建编译器、控制编译及封装过程、关闭等功能

因此,Compiler 类覆盖了编译过程的整个生命周期,可以看做是编译过程的主引擎 ,现在关于整个编译过程只有一个 Compiler 实例,相信也有了直观的认识。

Compiler 都定义了哪些 hooks

都说 webpack 是一个基于 Tapable 事件流的插件的架构。整个构建的生命周期,都是由 Tapable 进行串联的,那么我们就来看一下,在 Compiler 里边都定义了哪些钩子。

this.hooks 源码如下

this.hooks = Object.freeze({
			// 初始化
			initialize: new SyncHook([]),
      // 判断构建是否成功,是否需要输出
			shouldEmit: new SyncBailHook(["compilation"]),
			// 整个过程构建完成之后,会调用的钩子
			done: new AsyncSeriesHook(["stats"]),
			afterDone: new SyncHook(["stats"]),
			additionalPass: new AsyncSeriesHook([]),
			// 开始编译的一些钩子
			beforeRun: new AsyncSeriesHook(["compiler"]),
			run: new AsyncSeriesHook(["compiler"]),
			// 文件生成的钩子
			emit: new AsyncSeriesHook(["compilation"]),
			assetEmitted: new AsyncSeriesHook(["file", "info"]),
			afterEmit: new AsyncSeriesHook(["compilation"]),

			// 像 webpack 有一些插件:
			// 例如 html-webpack-plugin 有自己独立的构建流程
			// 这个时候使用的是 thisCompilation 来进行构建的
			thisCompilation: new SyncHook(["compilation", "params"]),
			compilation: new SyncHook(["compilation", "params"]),
			// 普通的模块工厂钩子:处理普通的模块,类似于 module.exports 导出的模块
			normalModuleFactory: new SyncHook(["normalModuleFactory"]),
			// 上下文模块工厂:模块名前面有路径的模块。类似于  ./src/a ./src/b 这样的模块
			contextModuleFactory: new SyncHook(["contextModuleFactory"]),
      // 编译相关的
			beforeCompile: new AsyncSeriesHook(["params"]),
			compile: new SyncHook(["params"]),
			make: new AsyncParallelHook(["compilation"]),
			finishMake: new AsyncSeriesHook(["compilation"]),
			afterCompile: new AsyncSeriesHook(["compilation"]),
      // watch 相关的
			watchRun: new AsyncSeriesHook(["compiler"]),
			failed: new SyncHook(["error"]),
			invalid: new SyncHook(["filename", "changeTime"]),
			watchClose: new SyncHook([]),
			shutdown: new AsyncSeriesHook([]),

			infrastructureLog: new SyncBailHook(["origin", "type", "args"]),

			// TODO the following hooks are weirdly located here
			// TODO move them for webpack 5
			// 环境相关的
			environment: new SyncHook([]),
			afterEnvironment: new SyncHook([]),
			afterPlugins: new SyncHook(["compiler"]),
			afterResolvers: new SyncHook(["compiler"]),
			// 初始化的钩子
			entryOption: new SyncBailHook(["context", "entry"])
		});

上面的注释已经写得很详细了,这里简单的总结一下;

通过源码可以看出,Compiler 里边定义了几十个钩子,钩子的设定也是和生命周期相关的;

类型也涵盖了初始化 、入口 、监听 、编译、文件输出等等。

相信到现在,我们也对 webpack 是一个基于 Tapable 事件流的插件的架构 有了更深的印象。

下一步,是时候从细节开始去了解 Compiler 了。

10、创建 .gitlab-ci.yml 文件

.gitlab-ci.yml 是 CI/CD 的核心文件,gitlab 根据配置进行项目的 CI/CD 流程,这个文件是放在项目根目录的

这里使用的是 yaml 配置语言,不熟悉语法的自己谷歌

变量定义

变量的作用是将不希望暴露的敏感信息配置在 gitlab 里边;

这样,虽然我们能看到配置文件,像私钥,IP 这样的访问这是不能直接读取到的;

定义完的变量在 .gitlab-ci.yml 文件里边可以通过下面的形式访问

${SERVER_ADDR}

image-20210419160339617

.gitlab-ci.yml

注意:这里的 ssh 文件传输需要先在指定服务器配置免密传输,配置方式我写在了 “在 gitlab CICD 上使用SSH密钥” 这篇里边

#====================常量声明==============================
# gitlab常量说明(从 gitlab 变量读取)
# SSH_PRIVATE_KEY 私钥
# SERVER_ADDR 目标服务器地址
# 本地常量声明
variables:
  # 远程服务器部署目录
  DEPLOY_SERVER_PATH_PRO: /home/app/web/demo #正式环境
  DEPLOY_SERVER_PATH_DEV: /home/app/web/demo #测试环境

#====================缓存==============================
cache: # 缓存目录
  paths:
    - node_modules # 会监听 package.json 文件的变化更新
    - dist # 打包完成后代码存放的地方

#====================公共配置==============================
.common-config: &commonConfig
  image: node:14.15.4 # 指定 node 版本
  only: # 表示仅在仓库的哪些分支上执行构建
    refs:
      # 正式环境
      - master
      - release
      - production
      - pro
      # 预发布环境 | 暂时没有配置需要手动操作
      # - pre
      # 测试环境
      - develop
      - dev
      - test   

.common-npm: &commonNpm
  before_script:
    # 定义变量 如NODE环境变量
    - echo "node 环境判断"; 
    - NODE_ENV=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
               then 
                 echo "dev"; 
               else 
                 echo "pro"; 
               fi`;
    - echo "当前 node 环境 ${NODE_ENV}";
    # 安装 cnpm 加速打包过程
    - npm install cnpm -g --registry=https://registry.npm.taobao.org

.common-deploy: &commonDeploy
   before_script:
    # 定义变量 DEPLOY_SERVER_PATH
    - DEPLOY_SERVER_PATH=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
                            then 
                              echo "${DEPLOY_SERVER_PATH_DEV}"; 
                            else 
                              echo "${DEPLOY_SERVER_PATH_PRO}";
                            fi`;
    - echo "服务器工作目录: ${DEPLOY_SERVER_PATH}";
    # 加载私钥
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
 
#====================部署阶段==============================
stages: # 部署阶段
  - install # 安装依赖
  - build # 打包编译
  - deploy # 部署到目标服务器

# 阶段1:安装依赖
install-job:
  tags:
    - test
  stage: install
  <<: *commonConfig
  <<: *commonNpm
  script:
    - cnpm install

# 阶段2: 构建项目
build-job:
  tags:
    - test
  stage: build
  <<: *commonConfig
  <<: *commonNpm
  script:
    - echo "开始构建";
    - if [ $NODE_ENV = "dev" ];
      then
        echo "测试环境开始构建:命令 cnpm run beta";
        cnpm run beta;
      else
        echo "正式环境开始构建:命令 cnpm run build2";
        cnpm run build2;
      fi;

# 阶段3:部署结果到目标服务器
deploy-job: 
  tags:
    - test
  stage: deploy
  <<: *commonConfig
  <<: *commonDeploy
  script:
    # 远程部署目录备份并部署新文件
    - pwd #  输出下当前 gitlab 工作目录路径
    - echo "服务器地址: ${SERVER_ADDR}";
    - echo "服务器工作目录: ${DEPLOY_SERVER_PATH}";
    - ssh root@${SERVER_ADDR} 'bash -s' < `pwd`/backup.sh ${DEPLOY_SERVER_PATH}
    - scp -r ./dist/* root@${SERVER_ADDR}:${DEPLOY_SERVER_PATH}  # 将打包好的文件上到发布项目的服务器中的,放到nginx能访问到的文件夹下

backup.sh

#!/bin/bash
echo "远程服务器登录成功"
cd $1 # $1 是部署服务器的名称
FILE_NAME=`mktemp -u pm.dev.XXXXX` # 创建临时文件
DATE=`date +"%Y-%m-%d_%H%M"`
BACKUP="backup"
tar -zcvf ${FILE_NAME}.${DATE}.tar.gz ./* --exclude="$BACKUP"
# 移动备份文件到备份文件夹
if [ ! -d "$BACKUP" ]; then
mkdir "$BACKUP"
fi
mv ${FILE_NAME}.${DATE}.tar.gz "$BACKUP"
# 删除远程服务器部署目录中除backup外所有文件
ls|egrep -v "$BACKUP"|xargs rm -rf
echo "远程脚本执行完成,执行目录:`pwd` 备份文件夹:`ls`"

运行命令,重启动

12、.gitlab-ci.yml 优化并备份上次构建内容

.gitlab-ci.yml

#====================常量声明==============================
# gitlab常量说明(从 gitlab 变量读取)
# SSH_PRIVATE_KEY 私钥
# SERVER_ADDR 目标服务器地址
# 本地常量声明
variables:
  # 远程服务器部署目录
  DEPLOY_SERVER_PATH_PRO: /home/app/web/demo #正式环境
  DEPLOY_SERVER_PATH_DEV: /home/app/web/demo #测试环境

#  echo "${SSH_PRIVATE_KEY_DEV}"; 
#====================缓存==============================
cache: # 缓存目录
  paths:
    - node_modules # 会监听 package.json 文件的变化更新
    - dist # 打包完成后代码存放的地方
    - sourcemap # source-map

#====================公共配置==============================
image: node:14.15.4 # 指定 node 版本
.common-config: &commonConfig
  only: # 表示仅在仓库的哪些分支上执行构建
    refs:
      # 正式环境
      - master
      - release
      - production
      - pro
      # 预发布环境 | 暂时没有配置需要手动操作
      # - pre
      # 测试环境
      - develop
      - dev
      - test   

.common-npm: &commonNpm
  before_script:
    # 定义变量 如NODE环境变量
    - echo "node 环境判断"; 
    - NODE_ENV=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
               then 
                 echo "dev"; 
               else 
                 echo "pro"; 
               fi`;
    - echo "当前 node 环境 ${NODE_ENV}";
    # 安装 cnpm 加速打包过程
    - npm install cnpm -g --registry=https://registry.npm.taobao.org

.common-deploy: &commonDeploy
   before_script:
    # 定义变量 DEPLOY_SERVER_PATH
    - DEPLOY_SERVER_PATH=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
                            then 
                              echo "${DEPLOY_SERVER_PATH_DEV}"; 
                            else 
                              echo "${DEPLOY_SERVER_PATH_PRO}";
                            fi`;
    - echo "服务器工作目录: ${DEPLOY_SERVER_PATH}";
    - SSH_PRIVATE_KEY=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
               then 
                echo "${SSH_PRIVATE_KEY_DEV_ZUIAPP}"; 
               else 
                 echo "${SSH_PRIVATE_KEY_PRO_ZUIAPP}"; 
               fi`;
    - SERVER_ADDR=`if [[ ${CI_COMMIT_REF_NAME:0:3} = "dev" || ${CI_COMMIT_REF_NAME:0:4} = "test" ]]; 
               then 
                 echo "${SERVER_ADDR_DEV}"; 
               else 
                 echo "${SERVER_ADDR_PRO}"; 
               fi`;
    # 加载私钥
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
 
#====================部署阶段==============================
stages: # 部署阶段
  - install-and-build # 安装依赖
  # - build # 打包编译
  - deploy # 部署到目标服务器

# 阶段1:安装依赖
install-and-build-job:
  tags:
    - test
  stage: install-and-build
  <<: *commonConfig
  <<: *commonNpm
  script:
    - cnpm install
    - echo "开始构建"
    - pwd
    - if [ $NODE_ENV = "dev" ];
      then
        echo "测试环境开始构建:命令 npm run beta";
        CI= cnpm run beta;
      else
        echo "正式环境开始构建:命令 npm run build";
        CI= cnpm run build2;
      fi;
  artifacts: # 提供给 gitlab 交互界面上提供下载,构建完成后可以下载构建内容
    name: 'bundle'
    expire_in: 1 week # artifacets 的过期时间,因为这些数据都是直接保存在 Gitlab 机器上的,过于久远的资源就可以删除掉了
    paths: 
      - dist/

# 阶段2: 构建项目
# build-job:
#   tags:
#     - test
#   stage: build
#   <<: *commonConfig
#   <<: *commonNpm
#   script:
#     - echo "开始构建";
#     - if [ $NODE_ENV = "dev" ];
#       then
#         echo "测试环境开始构建:命令 npm run beta";
#         CI= cnpm run beta;
#       else
#         echo "正式环境开始构建:命令 npm run build";
#         CI= cnpm run build;
#       fi;
#   artifacts: # 提供给 gitlab 交互界面上提供下载,构建完成后可以下载构建内容
#     name: 'bundle'
#     paths: 
#       - dist/

# 阶段3:部署结果到目标服务器
deploy-job: 
  # before_script:
  #   - apt-get update -qq && apt-get install -y -qq sshpass # sshpass 安装
  tags:
    - test
  stage: deploy
  <<: *commonConfig
  <<: *commonDeploy
  script:
    # 远程部署目录备份并部署新文件
    - pwd #  输出下当前 gitlab 工作目录路径
    - echo "服务器地址: ${SERVER_ADDR}";
    - echo "服务器工作目录: ${DEPLOY_SERVER_PATH}";
    - ssh root@${SERVER_ADDR} 'bash -s' < `pwd`/backup.sh ${DEPLOY_SERVER_PATH}
    - scp -r ./dist/* root@${SERVER_ADDR}:${DEPLOY_SERVER_PATH}  # 将打包好的文件上到发布项目的服务器中的,放到nginx能访问到的文件夹下

backup.sh

#!/bin/bash
echo "远程服务器登录成功"
cd $1 # $1 是部署服务器的名称
FILE_NAME=`mktemp -u pm.dev.XXXXX` # 创建临时文件
DATE=`date +"%Y-%m-%dT%H_%M_%S"`
BACKUP="backup"
tar -zcvf ${FILE_NAME}.${DATE}.tar.gz ./* --exclude="$BACKUP"
# 移动备份文件到备份文件夹
if [ ! -d "$BACKUP" ]; then
mkdir "$BACKUP"
fi
mv ${FILE_NAME}.${DATE}.tar.gz "$BACKUP"
# 删除远程服务器部署目录中除backup外所有文件
# ls|egrep -v "$BACKUP"|xargs rm -rf
# 删除指定目录和文件
rm -rf dll
rm -rf static
rm -rf assets
rm -f index.html
rm -f favic.ico
echo "远程脚本执行完成,执行目录:`pwd` 备份文件夹:`ls`"

代码提交到指定分支,查看构建情况

像这样,说明 CI/CD 的流程就已经跑通了

image-20210419163541509

11、构建-模块工厂和模块

构建-模块工厂和模块

通过前面的分析我们知道,compiler 在触发 compiler.hooks.make 钩子后,在 EntryPlugin 插件通过触发 compiltion 的 addEntry 方法;此时就找到了模块构建编译入口, 编译正式开始。

这里我们回顾一下

EntryPlugin.js

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
    const { entry, options, context } = this;	
		const dep = EntryPlugin.createDependency(entry, options);
		// 构建编译入口, 编译正式开始
		compilation.addEntry(context, dep, options, err => {
			callback(err);
		});
	});

模块工厂和模块

在正式开始构建阶段源码分析之前,有两个东西是需要我们提前了解的,那就是模块工厂和模块;

下面,我们将用两篇单独的文章来分别介绍模块工厂和模块。

20、总结

总结

compiler 和 compilation

compiler:

  • 涵盖了启动、监听、创建编译器、控制编译及封装过程、关闭等功能;是整个编译过程的推手;
  • 在整个编译过程只有一个 compiler 实例;

compilation:

  • 主要负责的是构建工作,包括模块的解析、代码生成和封装;

  • 每个编译过程都会生成一个 compilation 实例。这里的每个编译过程可以看作是 watch 模式下的文件修改引发的重新编译。因为每次 watch 都会执行 compiler.run 方法,而初始化 compilation 是在 compiler.run 之后的;

module、chunk、bundle 的关系

**module:**import 一个模块就是一个 module。

chunk: 一个入口文件会生成一个 chunk,代码分割也会生成 chunk。

bundle: 最终的产物,一个产物就是一个 bundle。

它们之间的关系是

多个 module 对应一个 chunk,一个 chunk 对应多个 bandle

之前在一个公众号上看到一张图片,很好的描述了这三者的关系

image-20210428153733785

针对上面的图,对应分析如下

webpack.js

 entry: {
    index: "../index.js",
    utils: '../utils.js',
 }

这里定义了 utils.js 和 index.js 两个入口,所以会对应生成两个 chunk。

打包完成后将 css 分离出来,所以生成了3个 bundle:

  • index.bundle.css
  • index.bundle.js
  • utils.bundle.js

前两个属于chunk 0 ,最后一个属于chunk 1;

因此,这也佐证了一个 chunk 对应多个 bandle。

4、从 webpack 命令说起

从 webpack 命令说起

不知道大家平时在使用 webpack 的时候有没有这样的疑问,就是当我们输入如下命令的时候

$ webpack --config webpack.config.js

系统是怎么找到 webpack 并且进行打包的呢?

带着上面的疑问,我们去项目中一步一步寻找答案。

第一步 展开 node_modules 下的 .bin 目录

image-20210326141927535

我们在这里发现了 webpack 和 webpack-cli 的身影;

那是不是 webpack 命令的入口就在这里呢,答案是,是的。

细心的同学可能已经注意到了,.bin 目录下文件右边都有一个转向箭头,那这是为什么呢;

其实这是代表这里的文件是“软链接”,是指向真实文件的快捷方式;通过这个链接,可以快速定位到真实文件。

那为什么会在这个 .bin 目录下创建软连接呢?

第二步 找到 node_modules 下 webpack 安装包

1、打开node_modules -> webpack -> package.json 文件

{
  "name": "webpack",
  "version": "5.25.1",
  ....
  "bin": {
    "webpack": "bin/webpack.js"
  },
  ....
}

原来,这里有一个 bin 字段,里边配置了 webpack 的真实入口。

在我们安装 webpack 的时候

1、首先会去解析 package.json 文件,发现里边配置了 bin 字段

2、根据 bin 字段里边的键名(这里是 webpack),在 node_modules 的 .bin 目录下创建同名软连接

3、软连接指向 package.json 里 bin 对应的实际路径(这里是 bin/webpack.js)

image-20210326163431884

webpack-cli 同上

image-20210326163759012

这样, webpack 命令的执行之谜也就解开了。

15、 gitlab 内存占用过大的一些解决思路

首先通过 gitlab 面板看一下内存使用率

![image-20210419104551950](/Users/luobing/Library/Application Support/typora-user-images/image-20210419104551950.png)

再通过 free 命令查看

![image-20210419105827093](/Users/luobing/Library/Application Support/typora-user-images/image-20210419105827093.png)

可以看到,现在的空闲空间是 749 M,使用了 3.4 G的内存

gitlab 配置优化

gitlab 的配置项在 gitlab.rb 文件里

打开 gitlab.rb 文件

unicorn['worker_timeout'] = 60
# 减少进程数,默认是 CPU 核数 + 1,由于服务器配置是 2 核 + 8 G 的配置,所以改为 2 核
unicorn['worker_processes'] = 2

# 最小的由 400 改为 200
unicorn['worker_memory_limit_min'] = "200 * 1 << 20"
# 最大的由 650 改为 300
unicorn['worker_memory_limit_max'] = "300 * 1 << 20"

# sidekiq 并发数由 25 改为 16
sidekiq['concurrency'] = 16

# 数据库缓存大小改为 256 M
# 这个需要按照配置来,如果内存占用还是过高,可以考虑再降一半设置为 128M
postgresql['shared_buffers'] = "256MB"

# 数据库并发数改为 8 
# 这个需要按照配置来,如果内存占用还是过高,可以考虑再降一半设置为 4
postgresql['max_worker_processes'] = 8

docker 重启 gitlab 服务

// step 1
docker exec -it gitlab bash
// step 2
gitlab-ctl reconfigure

image-20210419110554229

可以看到配置文件的加载和之前的不同之处

image-20210419111018788

image-20210419111115108

image-20210419111139951

// step 3
gitlab-ctl restart

image-20210419110616170

再次查看面板信息

image-20210419113433922

image-20210419113506846

可以看到,free 由 749 M 变为了 1.6 G,used 由 3.4 G 变为了 2.2 G

在服务器配置有限的情况下,增加了近 1 个 G 的使用空间

13、模块-NormalModule

模块-NormalModule

在模块工厂一节我们说过,NormalModuleFactory 的作用就是创建模块,并且给模块提供相应的使用说明书;NormalModule 有了这个说明书,就能配合做模块的打包操作了。

NormalModule 主要的工作有两个部分

  • 模块资源解析转换,即 parser 阶段,对应的过程是 source -> ast,然后分析出相关依赖
  • 最终代码生成,即 generator 阶段

下面我们通过源码分析 NormalModule

NormalModule 流程图

img

NormalModule 源码解析

由于 NormalModule 里的代码比较多,这里对 NormalModule 进行了精简,只保留了主要的逻辑部分

class NormalModule extends Module {
	constructor({
		layer,
		type,
		request,
		userRequest,
		rawRequest,
		loaders,
		resource,
		matchResource,
		parser,
		generator,
		resolveOptions
	}) {
		super(type, getContext(resource), layer);
		// 工厂在初始化的时候传递进来的
		...
		// 是否是二进制资源
		this.binary = /^(asset|webassembly)\b/.test(type);
		// 模块解析器
		this.parser = parser;
		// 模块生成器
		this.generator = generator;
		// 资源路径
		this.resource = resource;
		// loaders
		this.loaders = loaders;
		...
		// 构建信息
		...
		// 缓存信息
		...
	}

	// 其实 doBuild 就是选用合适的 loader 去加载 resource,
	// 目的是为了将这份 resource 转换为 JS 模块(原因是 webpack 只识别 JS 模块)。
	// 最后返回加载后的源文件 source,以便接下来继续处理, 经过了 doBuild 后,任何的模块都转换成标准JS模块。
	doBuild(options, compilation, resolver, fs, callback) {
		// 创建 loader 上下文
		const loaderContext = this.createLoaderContext();
    // 过程结果
		const processResult = (err, result) => {
			...
      // 这里就是 babel-loader 编译后的代码
			// result 是一个数组,第一项就是我们的 source
			const source = result[0]; 
			const sourceMap = result.length >= 1 ? result[1] : null;
			const extraInfo = result.length >= 2 ? result[2] : null;
			...
      // this._source 是一个 对象,有name和value两个字段
			// name就是我们的文件路径,value就是 编译后的JS代码
			this._source = this.createSource(
				options.context,
				this.binary ? asBuffer(source) : asString(source),
				sourceMap,
				compilation.compiler.root
			);
			if (this._sourceSizes !== undefined) this._sourceSizes.clear();
			// 赋值 _ast 
			this._ast =
				typeof extraInfo === "object" &&
				extraInfo !== null &&
				extraInfo.webpackAST !== undefined
					? extraInfo.webpackAST
					: null;
			return callback();
		};

		const hooks = NormalModule.getCompilationHooks(compilation);
    // 触发 beforeLoaders 钩子
		hooks.beforeLoaders.call(this.loaders, this, loaderContext);
		// 使用 runLoaders 执行loader
		runLoaders(
			{
				resource: this.resource, // 传入资源路径: 这里的resource可能是js文件,可能是css文件,可能是img文件
				loaders: this.loaders, // 需要使用的loader
				context: loaderContext,
				processResource: (loaderContext, resource, callback) => {
					const scheme = getScheme(resource);
					if (scheme) {
						hooks.readResourceForScheme
							.for(scheme)
							.callAsync(resource, this, (err, result) => {
								if (err) return callback(err);
								if (typeof result !== "string" && !result) {
									return callback(new UnhandledSchemeError(scheme, resource));
								}
								return callback(null, result);
							});
					} else {
						loaderContext.addDependency(resource);
						fs.readFile(resource, callback);
					}
				}
			},
			(err, result) => {
				...
				this.buildInfo.fileDependencies = new LazySet();
				this.buildInfo.fileDependencies.addAll(result.fileDependencies);
				this.buildInfo.contextDependencies = new LazySet();
				this.buildInfo.contextDependencies.addAll(result.contextDependencies);
				this.buildInfo.missingDependencies = new LazySet();
				this.buildInfo.missingDependencies.addAll(result.missingDependencies);
				if (
					this.loaders.length > 0 &&
					this.buildInfo.buildDependencies === undefined
				) {
					this.buildInfo.buildDependencies = new LazySet();
				}
				for (const loader of this.loaders) {
					this.buildInfo.buildDependencies.add(loader.loader);
				}
				this.buildInfo.cacheable = result.cacheable;
				processResult(err, result.result);
			}
		);
	}

	// normalModule 中的 build 开启构建。主要过程为:
	// 1、创建 loader 上下文
	// 2、通过 runLoaders,执行 loader,将所有资源统一处理成 JS 模块
	// 3、调用 JavascriptParser.js 将 loader 执行完的源码解析成 ast(使用了acorn工具),这步会生成当前模块的依赖集合
	// 4、生成模块的 hash
	// 5、缓存解析完的 module 至 _modulesCache,此时已经有_source(解析后的源码)
	build(options, compilation, resolver, fs, callback) {
		// 重置构建信息
		this._forceBuild = false;
		this._source = null;
		if (this._sourceSizes !== undefined) this._sourceSizes.clear();
		this._ast = null;
		this.error = null;
		this.clearWarningsAndErrors();
		this.clearDependenciesAndBlocks();
		this.buildMeta = {};
		// 存放构建信息
		this.buildInfo = {
			cacheable: false,
			parsed: true,
			fileDependencies: undefined,
			contextDependencies: undefined,
			missingDependencies: undefined,
			buildDependencies: undefined,
			hash: undefined,
			assets: undefined,
			assetsInfo: undefined
		};

		return this.doBuild(options, compilation, resolver, fs, err => {
			//doBuild 之后,我们的任何模块都被转成了标准的JS模块,那么下面我们就可以编译JS了。

			const handleParseResult = result => {
				this.dependencies.sort(
					concatComparators(
						compareSelect(a => a.loc, compareLocations),
						keepOriginalOrder(this.dependencies)
					)
				);
				// 生成 hash
				this._initBuildHash(compilation);
				this._lastSuccessfulBuildMeta = this.buildMeta;
				return handleBuildDone();
			};

			const handleBuildDone = () => {
				const snapshotOptions = compilation.options.snapshot.module;
				if (!this.buildInfo.cacheable || !snapshotOptions) {
					return callback();
				}
				// convert file/context/missingDependencies into filesystem snapshot
				// 文件系统信息创建快照
				compilation.fileSystemInfo.createSnapshot(
					startTime,
					this.buildInfo.fileDependencies,
					this.buildInfo.contextDependencies,
					this.buildInfo.missingDependencies,
					snapshotOptions,
					(err, snapshot) => {
						if (err) {
							this.markModuleAsErrored(err);
							return;
						}
						this.buildInfo.fileDependencies = undefined;
						this.buildInfo.contextDependencies = undefined;
						this.buildInfo.missingDependencies = undefined;
						this.buildInfo.snapshot = snapshot;
						return callback();
					}
				);
			};
			...
      // doBuild 构建完成后
			let result;
			try {
				// this.parser.parse 使用的库是 acorn
				// 将代码里边的依赖添加到模块依赖列表中去,这样就可以不断的分析构建依赖
				// 最终依赖分析完成之后,将最终的代码存放到 compiltion 对象上的 modules 数组里边去
				// 然后触发 compiltion 对象上 succeedModule 钩子

				//而这里的 this.parser 其实就是 JavascriptParser 的实例对象,
				//最终 JavascriptParser 会调用第三方包 acorn 提供的 parse 方法对JS 源代码进行语法解析。
				result = this.parser.parse(this._ast || this._source.source(), {
					current: this,
					module: this,
					compilation: compilation,
					options: options
				});
			} catch (e) {
				handleParseError(e);
				return;
			}
			// 处理解析结果
			handleParseResult(result);
		});
	}

	/**
	 * @param {CodeGenerationContext} context context for code generation
	 * @returns {CodeGenerationResult} result
	 */
	codeGeneration({
		dependencyTemplates,
		runtimeTemplate,
		moduleGraph,
		chunkGraph,
		runtime,
		concatenationScope
	}) {
		...
		const sources = new Map();
		for (const type of this.generator.getTypes(this)) {
			const source = this.error
				? new RawSource(
						"throw new Error(" + JSON.stringify(this.error.message) + ");"
				  )
				: this.generator.generate(this, {
						dependencyTemplates,
						runtimeTemplate,
						moduleGraph,
						chunkGraph,
						runtimeRequirements,
						runtime,
						concatenationScope,
						getData,
						type
				  });

			if (source) {
				sources.set(type, new CachedSource(source));
			}
		}

		/** @type {CodeGenerationResult} */
		const resultEntry = {
			sources,
			runtimeRequirements,
			data
		};
		return resultEntry;
	}
}
module.exports = NormalModule;

NormalModule 代码量比较大,但是我们剥离出主意的部分后,还是比较好理解;

主要分为 3 个部分:

1、构造函数准备好构建原料

2、build 方法驱动 parser,也就是进行 source -> ast

3、codeGeneration 方法驱动 generator

下面,我们分别对这个三个部分进行解析

构造函数

从它的构造函数部分,基本可以窥探 NormalModule 的功能,先看这部分代码

// 工厂在初始化的时候传递进来的
...
// 是否是二进制资源
this.binary = /^(asset|webassembly)\b/.test(type);
// 模块解析器
this.parser = parser;
// 模块生成器
this.generator = generator;
// 资源路径
this.resource = resource;
// loaders
this.loaders = loaders;
...
// 构建信息
...
// 缓存信息
...

这里对部分代码进行了省略,用注释的方式标注了省略部分的功能;

可以看到,NormalModule 在初始化的时候定义了自己的能力边界,主要分为两部分:

1、模块工厂 NormalModuleFactory 在创建 NormalModule 的时候,给它传递了模块路径、转换模块需要使用的 loader、模块解析器、生成器、资源类型等信息;

2、NormalModule 自身定义了一些属性来保存构建信息和缓存相关的信息;

因此,通过这个构造方法,我们基本可以了解 NormalModule 的功能,那就是在这里进行模块的转换以及代码生成的工作。

模块构建 build

build(options, compilation, resolver, fs, callback) {
		// 重置构建信息
		this._forceBuild = false;
		this._source = null;
		if (this._sourceSizes !== undefined) this._sourceSizes.clear();
		this._ast = null;
		this.error = null;
		this.clearWarningsAndErrors();
		this.clearDependenciesAndBlocks();
		this.buildMeta = {};
		// 存放构建信息
		this.buildInfo = {
			cacheable: false,
			parsed: true,
			fileDependencies: undefined,
			contextDependencies: undefined,
			missingDependencies: undefined,
			buildDependencies: undefined,
			hash: undefined,
			assets: undefined,
			assetsInfo: undefined
		};

		return this.doBuild(options, compilation, resolver, fs, err => {})
}

可以看到, build 是模块构建的开始,这里做了两件事情

1、重置构建信息

2、调用 doBuild 方法

重置构建信息

这里对 _source、_ast 等内部属性进行了清空,这里比较重要的是 buildInfo,buildInfo 是存放构建信息的,在 NormalModule 的各个生命周期里,构建完的模块信息都会挂到 buildInfo 上。

调用 doBuild 方法

doBuild 才是构建的真正地方,这里对 doBuild 所做的工作如下:

1、创建 loader 上下文

const loaderContext = this.createLoaderContext();

2、通过 runLoaders,执行 loader,将所有资源统一处理成 JS 模块

// 使用 runLoaders 执行loader
runLoaders(
  {
    resource: this.resource, // 传入资源路径: 这里的resource可能是js文件,可能是css文件,可能是img文件
    loaders: this.loaders, // 需要使用的loader
    context: loaderContext,
    processResource: (loaderContext, resource, callback) => {},
  (err, result) => {
    ...
    processResult(err, result.result);
  }
);

3、将转换结果挂载到 _source 和 _ast 上

const processResult = (err, result) => {
  ...
  // 这里就是 babel-loader 编译后的代码
  // result 是一个数组,第一项就是我们的 source
  const source = result[0]; 
  const sourceMap = result.length >= 1 ? result[1] : null;
  const extraInfo = result.length >= 2 ? result[2] : null;
  ...
  // this._source 是一个 对象,有name和value两个字段
  // name就是我们的文件路径,value就是 编译后的JS代码
  this._source = this.createSource(
    options.context,
    this.binary ? asBuffer(source) : asString(source),
    sourceMap,
    compilation.compiler.root
  );
  if (this._sourceSizes !== undefined) this._sourceSizes.clear();
  // 赋值 _ast 
  this._ast =
    typeof extraInfo === "object" &&
    extraInfo !== null &&
    extraInfo.webpackAST !== undefined
      ? extraInfo.webpackAST
      : null;
  return callback();
};

4、doBuild 执行完成之后回到回调方法,执行 JavascriptParser.js 的实例方法 parse 将 loader 执行完的源码解析成 ast(使用了acorn工具),然后对 ast 进行语法解析,这步会生成当前模块的依赖集合

...
// doBuild 构建完成后
			let result;
			try {
				// this.parser.parse 使用的库是 acorn
				// 将代码里边的依赖添加到模块依赖列表中去,这样就可以不断的分析构建依赖
				// 最终依赖分析完成之后,将最终的代码存放到 compiltion 对象上的 modules 数组里边去
				// 然后触发 compiltion 对象上 succeedModule 钩子

				//而这里的 this.parser 其实就是 JavascriptParser 的实例对象,
				//最终 JavascriptParser 会调用第三方包 acorn 提供的 parse 方法对 JS 源代码进行语法解析。
				result = this.parser.parse(this._ast || this._source.source(), {
					current: this,
					module: this,
					compilation: compilation,
					options: options
				});
			} catch (e) {
				handleParseError(e);
				return;
			}
			// 处理解析结果
			handleParseResult(result);
...

顺便我们看下 JavascriptParser.js 与 parse 的源码

const { Parser: AcornParser } = require("acorn");
...
const parser = AcornParser;

class JavascriptParser extends Parser {
  parse(source, state) {
		...
		if (typeof source === "object") {
			ast = (source);
			comments = source.comments;
		} else {
			comments = [];
			ast = JavascriptParser._parse(source, {
				sourceType: this.sourceType,
				onComment: comments,
				onInsertedSemicolon: pos => semicolons.add(pos)
			});
		}

		...
		if (this.hooks.program.call(ast, comments) === undefined) {
			...
			// 这里webpack会遍历一次ast.body,其中会收集这个模块的所有依赖项
			// 最后写入到`module.dependencies`中
      // 举个例子
			// 显然如果我们有 import a from 'a.js' 这样的语句
			// 那么经过 babel-loader 之后会变成 var a = require('./a.js') 
			// 而对这一句的处理就在 walkStatements 中
			this.walkStatements(ast.body);
		}
		...
		return state;
	}
  
  static _parse(code, options) {
		const type = options ? options.sourceType : "module";
		const parserOptions = {
			...defaultParserOptions,
			allowReturnOutsideFunction: type === "script",
			...options,
			sourceType: type === "auto" ? "module" : type
		};

		/** @type {AnyNode} */
		let ast;
		...
		try {
			// 使用 acorn 的 parse 得到 ast 抽象语法树
			ast = /** @type {AnyNode} */ (parser.parse(code, parserOptions));
		} catch (e) {...}
    ...
		return (ast);
	 }
  }
}

5、生成模块的 hash

...
const handleParseResult = result => {
    // 依赖排序
    this.dependencies.sort(
      concatComparators(
        compareSelect(a => a.loc, compareLocations),
        keepOriginalOrder(this.dependencies)
      )
    );
    // 生成 hash
    this._initBuildHash(compilation);
    this._lastSuccessfulBuildMeta = this.buildMeta;
    return handleBuildDone();
  };
...

代码生成

可以看到代码生成阶段没有代码解析阶段那么复杂

贴下源码

codeGeneration({
		dependencyTemplates,
		runtimeTemplate,
		moduleGraph,
		chunkGraph,
		runtime,
		concatenationScope
	}) {
		...
		const sources = new Map();
		for (const type of this.generator.getTypes(this)) {
			const source = this.error
				? new RawSource(
						"throw new Error(" + JSON.stringify(this.error.message) + ");"
				  )
				: this.generator.generate(this, {
						dependencyTemplates,
						runtimeTemplate,
						moduleGraph,
						chunkGraph,
						runtimeRequirements,
						runtime,
						concatenationScope,
						getData,
						type
				  });

			if (source) {
				sources.set(type, new CachedSource(source));
			}
		}

		/** @type {CodeGenerationResult} */
		const resultEntry = {
			sources,
			runtimeRequirements,
			data
		};
		return resultEntry;
	}

this.generator 就是 Factory 给模块配备的 generator;

然后把代码生成的结果通过 resultEntry 返回,resultEntry 会存储在 CacheSourse 对象里边。

3、阿里云通过 NVM 管理 Node 环境

因为我们是部署前端项目,所以还需要准备好 Node.js 环境

NVM(Node version manager)是 Node.js 的版本管理软件,使用户可以轻松在 Node.js 各个版本间进行切换。适用于长期做 node 开发的人员或有快速更新 node 版本、快速切换 node 版本这一需求的用户。

安装步骤:

1、直接使用git将源码克隆到本地的~/.nvm目录下,并检查最新版本。

yum install git
git clone https://github.com/cnpm/nvm.git ~/.nvm && cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`

2、激活NVM。

echo ". ~/.nvm/nvm.sh" >> /etc/profile
source /etc/profile

3、、列出Node.js的所有版本。

nvm list-remote

4、安装多个Node.js版本。

nvm install v10.15.0
nvm install v14.15.4

5、查看已安装Node.js版本,当前使用的版本为v14.15.4。

[root@iZuf62didsxigy36d6kjtrZ .nvm]# nvm ls

6、切换 Node.js 版本至 v14.15.4

[root@iZuf62didsxigy36d6kjtrZ .nvm]# nvm use v14.15.4
Now using node v14.15.4

7、NVM的更多操作请参考帮助文档:

nvm help

2、docker-compose 安装

docker-compose 安装

# 下载 docker-compose 命令
sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# docker-compose 执行权限
sudo chmod +x /usr/local/bin/docker-compose

# 查看当前docker-compose版本号
docker-compose -vefsion

docker-compose 使用

#重建镜像
docker-compose build
#启动
docker-compose up -d
#重建镜像和启动可以合并成一个命令
docker-compose up -d --build
#查看日志
docker-compose logs -f
#查看目前的 docker 服务
docker-compose ps

7、构建准备-WebpackOptionsApply

构建准备-WebpackOptionsApply

webpack 里边的 apply 方法和我们平时接触到的 apply 方法是有区别的;

webpack 里边的 apply 方法的含义是注册,这里的注册是注册插件;

因此 WebpackOptionsApply 这个类的功能,我们也能猜出大概了,即根据 webpack 的配置文件注册各种插件

下面,我们就通过 WebpackOptionsApply 源码看看他都注册了哪些插件。

WebpackOptionsApply 源码解析

由于 WebpackOptionsApply 里边的代码比较多,这里挑几个比较重要的地方解析一下

class WebpackOptionsApply extends OptionsApply {
	constructor() {
		super();
	}

	// process 的作用是,根据配置文件,注册本次构建所需要的插件,等待后续过程的触发
	process(options, compiler) {
		// 设置输出路径,记录输入输出路径,设置名字
		compiler.outputPath = options.output.path;
		compiler.recordsInputPath = options.recordsInputPath || null;
		compiler.recordsOutputPath = options.recordsOutputPath || null;
		compiler.name = options.name;
    // 所有的插件都会变成 compiler 对象上的实例
		// 根据配置文件,注册相应的插件

		// 外部资源相关的插件
		if (options.externals) {...}
		if (options.externalsPresets.node) {...}
		...

    // 输出相关的插件
		if (typeof options.output.chunkFormat === "string") {}
    ...

    // sourceMap 相关的插件
		if (options.devtool) {...}

    // JS 解析器,用于 js 文件的处理(包括生成 acorn 生成 ast)
		new JavascriptModulesPlugin().apply(compiler);
		new JsonModulesPlugin().apply(compiler);
		new AssetModulesPlugin().apply(compiler);
	  ...

    // 用于入口解析(包括确定是单入口还是多入口),监听了 hooks.entryOption 钩子
		new EntryOptionPlugin().apply(compiler);
		// 对入口 entry 进行解析,根据 entry 的类型注册 EntryPlugin
		// 其中 EntryPlugin 监听了 compiler.hooks.make 钩子
		// compiler 到 compiltion 的入口就在这里
    // 这个方法相当于入口程序已经就绪,就等后续的一声令下(hooks.make 触发)就可以运行了
		compiler.hooks.entryOption.call(options.context, options.entry);
		...

		// 注册优化相关的插件
		if (options.optimization.removeAvailableModules) {}

		...
		
    // 缓存相关的钩子
		// 根据 cache 配置初始化缓存插件,可以看到缓存方式分为 2 种,memory 代表内存缓存,filesystem 代表文件缓存(持久化缓存)
		// 1、memory 缓存只注册了 MemoryCachePlugin 钩子
		// 2、filesystem 缓存先注册 MemoryCachePlugin 然后再注册 IdleFileCachePlugin 持久化缓存插件
		// IdleFileCachePlugin 会将缓存操作放入队列中,在 shutdown 钩子函数中利用空闲进程执行缓存,防止影响编译速度
		if (options.cache && typeof options.cache === "object") {
			const cacheOptions = options.cache;
			switch (cacheOptions.type) {
				case "memory": {
					//@ts-expect-error https://github.com/microsoft/TypeScript/issues/41697
					const MemoryCachePlugin = require("./cache/MemoryCachePlugin");
					new MemoryCachePlugin().apply(compiler);
					break;
				}
				case "filesystem": {
					...
					//@ts-expect-error https://github.com/microsoft/TypeScript/issues/41697
					const MemoryCachePlugin = require("./cache/MemoryCachePlugin");
					new MemoryCachePlugin().apply(compiler);
					switch (cacheOptions.store) {
						case "pack": {
							const IdleFileCachePlugin = require("./cache/IdleFileCachePlugin");
							...
							break;
						}
						default:
							...
					}
					break;
				}
				default:
					...
			}
		}
		...
}

module.exports = WebpackOptionsApply;

WebpackOptionsApply 非常简单,就只有一个 process 方法;

process 的作用是,根据配置文件,注册本次构建所需要的插件,等待后续过程的触发;

由于 process 里边处理的插件数量比较多,这里我们挑几个主要的介绍一下

process 功能解析

上面代码相关的位置,已经做了相应的功能描述,这里来梳理一下

1、首先在 compiler 上设置输出路径,记录输入输出路径,设置名字

2、注册处理外部资源相关的插件,比如 fs、http 等外部资源

3、注册输出相关的插件

4、注册 sourceMap 相关的插件

5、注册 JavascriptModulesPlugin 插件

这个插件比较重要

1、通过 createParser 初始化不同的 parse 处理不同的资源,根据不同的需求,normalModuleFactory 使用不同的 parser(主要有 auto、script、module 三个类型)

2、renderManifest 创建拼装打包内容

6、注册 EntryOptionPlugin 插件

1、用于入口解析(包括确定是单入口还是多入口),监听了 hooks.entryOption 钩子

2、根据 entry 的类型注册 EntryPlugin,其中 EntryPlugin 监听了 compiler.hooks.make 钩子, compiler 到 compiltion 的入口就在这里,这个方法相当于入口程序已经就绪,就等后续的一声令下(hooks.make 触发)就可以运行了

7、注册优化相关的插件

8、注册缓存相关的插件

根据 cache 配置初始化缓存插件,可以看到缓存方式分为 2 种

  • memory 代表内存缓存
  • filesystem 代表文件缓存(持久化缓存)

1、memory 缓存只注册了 MemoryCachePlugin 钩子;memory 存储在内存中,用于热更新,对重新编译不起作用,存储方式为Map对象;

2、filesystem 缓存先注册 MemoryCachePlugin 然后再注册 IdleFileCachePlugin 持久化缓存插件;

  • filesystem 缓存会生成本地文件
  • IdleFileCachePlugin 会将缓存操作放入延时队列中,在编译完之后才会将队列内容写入文件
  • 缓存文件默认保存在 node_modules/.cache 中,一个 chunk 生成一个缓存文件
  • filesystem 做永久化储存,之所以会用到 MemoryCachePlugin,是用于 watch 模式使用

webpack 执行完这一步后,打包的前期准备也就完成了,之后会在不同的打包流程触发这里注册的插件,来完成打包过程。

18、构建输出

构建输出

seal 完成后,执行 callback,此时,执行流程又回到了 Compiler;

构建输出的流程相对于 make 和 seal 比较简单,这里通过流程图演示一下

img

首先触发 hooks.afterCompile 后,再次 callback,执行onCompiled回调;

compile(callback) {
  compilation.seal(err => {
    ...
    // 编译完成
    this.hooks.afterCompile.callAsync(compilation, err => {
      ...
      if (err) return callback(err);
      return callback(null, compilation);
    });
  });
}

run(callback) {
   ...
   this.compile(onCompiled);
   ...
}

onCompiled 方法

const onCompiled = (err, compilation) => {
			if (err) return finalCallback(err);
      // 资源构建完成后,检查编译构建状态
			if (this.hooks.shouldEmit.call(compilation) === false) {
				...
				const stats = new Stats(compilation);
				// 构建完成
				this.hooks.done.callAsync(stats, err => {
					...
					return finalCallback(null, stats);
				});
				return;
			}

			process.nextTick(() => {
				...
				// 输出资源
				this.emitAssets(compilation, err => {
					...
					if (compilation.hooks.needAdditionalPass.call()) {...}

					...
					this.emitRecords(err => {
						...
						const stats = new Stats(compilation);
						// 资源输出完毕,整个构建过程正式结束
						// 执行done钩子函数,这里会执行compiler.run的回调当中,再执行compiler.close,然后执行永久化存储(前提是使用的filesystem缓存模式)
						this.hooks.done.callAsync(stats, err => {
							...
							this.cache.storeBuildDependencies(
								compilation.buildDependencies,
								err => {
									if (err) return finalCallback(err);
									return finalCallback(null, stats);
								}
							);
						});
					});
				});
			});
		};
  • 首先触发 hooks.shouldEmit 钩子,检查构建过程是否有错误

    grep -w "hooks.shouldEmit" -rn ./node_modules/webpack

    image-20210426201845648

    NoEmitOnErrorsPlugin.js

    compiler.hooks.shouldEmit.tap("NoEmitOnErrorsPlugin", compilation => {
    		if (compilation.getStats().hasErrors()) return false;
    });
  • 调用 emitAssets,将打包构建好的内容输出到磁盘中

    emitAssets(compilation, callback) {
    		let outputPath;
    		const emitFiles = err => {
    			if (err) return callback(err);
    			const assets = compilation.getAssets();
    			compilation.assets = { ...compilation.assets };
    			....
    		};
        // emit 阶段
    		this.hooks.emit.callAsync(compilation, err => {
    			if (err) return callback(err);
    			outputPath = compilation.getPath(this.outputPath, {});
    			// 将内容输出到磁盘里边去
    			mkdirp(this.outputFileSystem, outputPath, emitFiles);
    		});
    	}
  • 调用 emitRecords,输出构建记录

    emitRecords(callback) {
    		if (!this.recordsOutputPath) return callback();
    		const writeFile = () => {
    			this.outputFileSystem.writeFile(
    				this.recordsOutputPath,
    				JSON.stringify(
    					this.records,
    					(n, value) => {...},
    					2
    				),
    				callback
    			);
    		};
    		const recordsOutputPathDirectory = dirname(
    			this.outputFileSystem,
    			this.recordsOutputPath
    		);
    	  ...
    		mkdirp(this.outputFileSystem, recordsOutputPathDirectory, err => {
    			if (err) return callback(err);
    			writeFile();
    		});
    	}
  • 执行 hooks.done 钩子,然后将构建依赖写入 this.cache,然后执行 finalCallback 回到 webpack.js

webpack.js

onCompiled 方法执行完成之后,执行 finalCallback,此时回到 webpack.js 的 run 方法

compiler.run((err, stats) => {
  compiler.close(err2 => {
    callback(err || err2, stats);
  });
});

这里继续执行 compiler.close

Compiler.js

close(callback) {
		this.hooks.shutdown.callAsync(err => {
			if (err) return callback(err);
			this.cache.shutdown(callback);
		});
}

在close 方法中,会触发 hooks.shutdown 钩子;如果使用的是永久化缓存(前提是使用的 filesystem 缓存模式),这里会执行本地存储,目录默认在 node_modules/.cache 目录下。

webpack5 缓存策略:

根据 cache 配置初始化缓存插件,缓存方式分为 2 种,memory 代表内存缓存,filesystem 代表文件缓存(持久化缓存)

  • 1、memory 缓存只注册了 MemoryCachePlugin 钩子
  • 2、filesystem 缓存先注册 MemoryCachePlugin 然后再注册 IdleFileCachePlugin 持久化缓存插件

IdleFileCachePlugin 会将缓存操作放入队列中,在 shutdown 钩子函数中利用空闲进程执行缓存,防止影响编译速度。

至此,webpack 构建过程结束。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.