前端工程化配置,工程能力学习

工程治理

工程管理

GIT

ssh key config

ssh(secure shell)是一种加密网络传输协议,用于在不安全的网络环境中提供安全的方式来访问网络服务,SSH 协议主要是用来远程登录、管理系统、安全传输文件等,在数据传输的过程中采用加密保证数据的安全性和完整性

ssh 协议通信的基本流程:

  • SSH 版本协商阶段:服务器监听 22 端口信息,客户端发送 TCP 连接,建立请求,服务端返回一个 SSH 信息给客户端判断
  • 密钥和算法协商阶段
  • 客户端认证
  • 会话请求
  • 交互会话

ssh:SSH 客户端实现 & scp, sftp:rcp 的替代方案,传输文件 & sshd:SSH 服务端的视线 & ssh-keygen:产生 RSA 或 ECDSA 密钥,用来认证 & ssh-agent, ssh-add:帮助用户不需要每次输入密钥或密码的工具 & ssh-keysacn:收集大量主机的 ssh 主机公钥

Ssh-keygen 参数:-C (comment) - 提供一个注释 -t (type) - 提供生成的密钥类型 -b (bits) - 指定要生成的密钥长度 -f (filename) - 生成密钥文件名

git 中配置 ssh 的步骤

ssh-keygen -t rsa -b 4096 -C "your_email@example.com" 生成密钥对在~/ssh目录下面,在 github 或者 gitlab 上面去增加 ssh key 去配置公钥即可完成配对

测试 SSH 连接:ssh -T git@github.com

GIT 版本管理中的指令操作

详细的查看我的另一篇博客

git 使用操作

依赖管理

npm

嵌套的 node_modules 结构

npm 早期是采用嵌套的 node_modules 结构,会将依赖直接平铺在 node_modules 文件夹下,子依赖会直接嵌套在 node_modules 下面如下

1
2
3
4
5
6
7
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0

如果此时 D 模块也依赖与 B 则会在添加一次,在真实的开发下会导致 node_modules 臃肿不堪,依赖地狱

扁平化 node_modules 结构:

为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余,npm v3 将子依赖提升(hoist),采用扁平化的 node_modules 结构,子依赖会尽量平铺安装在主依赖所在的目录中

1
2
3
4
5
6
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0

将 B 依赖提升至相同的层级,不同版本则单独安装,这样既不会导致大量包的安装,也可以解决依赖地狱的问题,但是其中也存在很多问题

幽灵依赖 Phantom dependencies

幽灵依赖是指在 package.json 中未定义的依赖,但是依然可以正常的被引入使用

就比如我们只依赖 A、C 但是 C 中依赖了 B,B 被提升到了相同的层级,依然可以使用,如果某天 C 中改版不依赖 B 了,那么使用到 B 的地方可能导致报错

1
2
3
4
5
6
7
{
"dependencies": {
"A": "^1.0.0",
“B”: "被C依赖",
"C": "^1.0.0"
}
}

不确定性 Non-Determinism

不确定性指的是:同样的 pkg.json 文件,install 依赖后可能会得到不同样的 node_modules 目录结构

比如 A 依赖B@1.0.0,但是 C 依赖B@2.0.0 ,那么 install 后提升目录的是B@1.0.0还是@B2.0.0? 取决于用户安装的顺序,很可能导致生产环境和开发环境不一样

依赖分身 doppelgangers

假设此时的情况是:A 和 D 依赖B@1.0 ,C 和 E 依赖B@2.0

下面是提升B@1.0的情况

1
2
3
4
5
6
7
8
9
10
node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── E@1.0.0
└── node_modules
└── B@2.0.0

这样 B 会安装两次,无论提升那个都会导致被安装两此,且引入的 B 不是相同的 B,这样可能对 B 做一些缓存和副作用的时候,会导致出错

yarn

提升安装速度

npm 中安装依赖时,安装任务是串行的,会按顺序包逐个下载,为了解决安装速度问题,yarn 采用了并行安装,且存在缓存机制,存在磁盘中

lockfile 引入

引入 yarn.lock 文件,记录依赖,子依赖,获取地址等一些关键的元信息,即使不同的安装顺序,相同的依赖关系在任何容器和环境中,都能得到稳定的 node_modules 目录结构,保证安装依赖的确定性

依然没能解决幽灵依赖依赖分身的问题

pnpm

定义为快速的、节省磁盘空间的包管理工具,建立了一套新的依赖管理机制

内容寻址存储 CAS

与依赖提升和扁平化的 node_modules 不同,pnpm 引入了另一套依赖管理策略:内容寻址存储

该策略会将包安装在全局的 store 中,依赖的每个版本只会在系统中安装一次

在引用项目 node_modules 的依赖时,会通过硬链接与符号链接在全局的 store 中找到这个文件,为了实现此过程,node_modules 下会多出 .pnpm 目录,而且是非扁平化结构。

  • 硬链接 Hard Link: 硬链接可以理解为源文件的副本,项目里安装的其实是副本,它使得用户可以通过路径引用查找到源文件而且这个副本根本不占任何空间。同时,pnpm 会在全局 store 里存储硬链接,不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间
  • 符号链接 Symbolic link :也叫软连接,可以理解为快捷方式,pnpm 可以通过它找到对应磁盘目录下的依赖地址。

这样的机制十分巧妙可以解决大部分问题了

缺点:在不支持软链的环境中无法使用,比如 Electron,因为依赖源是在 store 中,调试依赖,修整依赖会有比较大的影响

monorepo

代码规范

TypeScript 代码规范

命名

  • 标识符必须只能使用 ASCII 字母、数字、下划线组成
  • 类、枚举、类型命名使用大写驼峰(UpperCamelCase)
  • 变量和函数命令使用小写驼峰(lowerCamelCase)
  • 全局常量使用CONSTANT_CASE
  • 枚举值命令使用大驼峰或CONSTANT_CASE
  • 名称中不要出现对应的类型标识和 my 之类的(比如 nameString myType … 等)
  • 使用描述性名称,名称应该是描述性和清晰的,不要使用缩写或者删除单词中某些字母等的方式

语言特性

字符串
  • 使用String#startWithString#endWithincludesslice等现代方法

数组

  • 禁止使用 Array()构造函数,直接使用字面量或者Array.from方法
  • 建议使用for....of方法来遍历数组,更快,更好的可读性,breakreturn可以提前退出
枚举
  • 所有的枚举成员必须是字面量,不能是表达式之类的
  • 禁止枚举成员的值重复
函数
  • 函数重载签名必须是连续的
  • 通过联合类型或可选/剩余参数解决的函数不能使用函数重载
  • 禁止使用 this 别名,可以通过箭头函数去解决问题
接口和类
  • 类成员:禁止使用#声明私有字段,使用private字段, 建议使用readonly,限制属性,方法,整个类型的可见性,方便更清晰的代码结构,可维护的设计,默认情况是public不用添加,对私有的添加privateprotected
  • 接口和类声明使用 ; 来分隔,单个成员声明,内联对象等都使用 **;**来声明
  • 接口和对象中的方法必须使用属性签名而不是方法签名
  • 禁止在类中定义 new 方法,接口中声明 constructor 方法
类型断言和非空断言

类型断言和非空断言是不安全的,使用之后会让 TypeScript 编译器静默,不会插入任何运行时检查来匹配这些断言,可能导致程序运行时崩溃,因此没有必要不要使用,使用判断和 instanceof 去完成替代上面的逻辑

  • 类型断言使用as语法

  • 必须使用类型注释而不是类型断言来指定对象字面量的类型

  • 不要使用非空断言

    可以在tsconfig中去启用strictNullChecks选项

  • 禁止使用多余的非空断言

  • 禁止在可选链之后写非空断言

  • 禁止在空值运算合并的左侧操作数中使用非空断言

注释指令
  • 禁止使用 _@ts-ignore@ts-nocheck@ts-check_,可以使用 @ts-expect-error 代替

  • 特殊情况下可以使用 @ts-expect-error,用注释指令抑制编译错误,会掩盖背后更大的问题。最佳实践是使用正确的代码类型。

    但有些情况下,也有无法解决的类型问题,比如第三方依赖没有完备的类型声明或者错误的类型。

禁止类型始终为真或者始终为假的变量作为条件

使用可选链式表达,不要使用&& 或者 || 去替代

类型系统

  • 禁止使用大写的内置对象作为类型:某些内置对象用作类型时被认为是危险的或有害的,通常的做法是禁止这些类型的使用以确保一致性和安全性
  • 能简单推断类型的代码,禁止声明类型,其余必须声明类型:能推断出的类型是声明的代码初始化的stringnumberbooleanRegExp字面量
  • 使用返回类型
  • 在导出函数和类的公共方法上必须定义返回和参数类型
  • 禁止使用字面量做为用户的类型
  • 为对象声明类型的时候必须使用interface而不是type
  • 禁止使用 any,可以采用更具体的类型,unknown,抑制 linter 警告并记录原因
  • 禁止在泛型上使用无效的类型约束
  • 禁用声明冗余的类型
  • 禁止在泛型和返回类型之外使用 void 类型

文件结构

  • 禁止使用命名空间namespaces在 ts 文件中
  • 禁止在导入模块的时使用require语句

React 代码规范

命名

  • 单文件的组件名应该使用UpperCamelCasekebab-case
  • 使用 JSX 语法的文件扩展名应该是.jsx或者.tsx
  • JSX 组件命名的方式应该采用UpperCamelCase
  • JSX 组件名由多个单词组成

代码格式化

  • 非函数式组件的创建采用 ES6 class
  • 禁止不必要的 JSX 表达式
  • 禁止给没有子组件的组件添加额外的结束标签
  • JSX 组件中的函数不应超过 300 行:遵循单一职责,分离业务状态和 UI 显示

特性

  • 禁止将注释作为文本节点插入
  • 禁止在 JSX 中直接使用未转义的 HTML 实体
  • 禁止出现定义了但未使用的状态
  • 禁止在组件内创建不稳定的组件,单独开成一个组件引入,不然每次渲染都会导致引用发生变化,不必要的性能消耗
  • 在使用 useEffectuseMemouseCallback 等 Hook 函数时应该提供正确的依赖项数组

组件

  • 迭代使用组件的时候必须执行 key 属性
  • 不要使用数组索引作为组件的 key
  • 禁止将子节点通过组件 children 属性传递
  • 组件的 style 属性值必须是一个对象

Eslint

Prettier

Husky

编译构建

编译构建工具

Webpack

基本概念

Webpack 是一个现代的 JavaScript 应用程序的静态模块打包器(module bundler),通过分析目标结构,找到 JavaScript 模块以及其项目中一些高级的扩展语言(SCSS,TypeScript),将其转换和打包为合适的格式提供给浏览器使用

即整个过程完成了内容转换 + 资源合并 两种功能

基本术语介绍

  • Entry: 构建的起点指定从那个路径开始构建其内部依赖关系图,可以存在多个入口
  • Output:指定 bundles 命名和输入路径
  • Loaders:用于 Webpack 只能理解 JavaScript 和 JSON 文件,帮组 webpack 处理其他类型的文件并转换
  • Plugin:用于 webpack 的拓展,在 webpack 流程中存在很多的 hooks,能够执行更广泛的任务,例如 bundle 优化,资源管理,环境变量注入等
  • Mode:环境,用于不同的优化策略
  • Module:构建应用单元,在 webpack 中,一切文件皆被视为一个模块,webpack 从 Entry 出发,去递归构建一个包含所有依赖文件的模块网络
  • Chunk:代码的集合体,Chunk 由模块合并而成,被用来优化输出文件的结构,Chunk 使得 Webpack 能够灵活的组织和分割代码
    • initial 是入口点的主要 chunk,包含为入口指定的所有 module 及其依赖
    • non-intial 是一个可能被延迟加载的 chunk,使用动态导入或 SplitChunksPlugin 时可能出现

编译过程

  • 初始化阶段
    • 初始化参数:从配置文件、配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
    • 创建编译器对象:用上一步得到的参数创建 Compiler 对象
    • 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
    • 开始编译:执行 compiler 对象的 run 方法,创建 Complition 对象
    • 确定入口:根据配置中的 entry 找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象
  • 构建阶段
    • 编译模块(make):从Entry文件开始,调用loader将模块转译为标准 JS 内容,调用 JS 解析器将内容转换为 AST 对象,从中找出该模块依赖的模块,在递归本步骤直到所有入口依赖的文件都经过本步骤的处理
    • 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的依赖关系图
  • 生成阶段
    • 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,在把每个Chunk转换成一个单独的文件加入到输出列表
    • 写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

编译的开始

Webpack 的执行入口在/lib/webpack.js 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const webpack = (options, callback) => {
const create = () => {
let compiler // 定义编译器实例
let watch = false // 是否开启观察模式的标志
let watchOptions // 观察模式的配置
if (Array.isArray(options)) {
// 处理多重编译的逻辑
} else {
}
return { compiler, watch, watchOptions }
}
// 核心创建和运行逻辑
const { compiler, watch, watchOptions } = create()
if (watch) {
// 如果开启观察模式,调用 compiler.watch
compiler.watch(watchOptions, callback)
} else if (callback) {
// 如果有回调函数,但没有开启观察模式,调用 compiler.run
compiler.run(callback)
}
return compiler // 返回创建的编译器实例
}

webpack.create() 核心逻辑:

  • validateSchema 校验 webpack.config.js 的参数
  • 调用 createCompiler(options)方法创建 compiler 对象
  • 返回 compiler、watch、watchOptions

其中,compiler.run(callback) 的执行即开启了 webpack 的编译过程

compiler 的创建过程

createCompiler函数实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const createCompiler = (rawOptions, compilerIndex) => {
// 标准化Webpack配置,参数归一化
const options = getNormalizedWebpackOptions(rawOptions)
// 应用基本的webpack配置默认
applyWebpackOptionsBaseDefaults(options)
// 创建compiler实例
const compiler = new Compiler(
/** @type {string} */ (options.context),
options
)
// 把node环境变量绑定到compiler实例
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler)
// 注册自定义插件(并不会立马执行,而是订阅相关 hooks 的触发)
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
/** @type {WebpackPluginFunction} */
;(plugin).call(compiler, compiler)
} else if (plugin) {
plugin.apply(compiler)
}
}
}
// 应用剩余的 webpack 配置默认值
const resolvedDefaultOptions = applyWebpackOptionsDefaults(
options,
compilerIndex
)
if (resolvedDefaultOptions.platform) {
compiler.platform = resolvedDefaultOptions.platform
}
// 触发环境设置相关的钩子
compiler.hooks.environment.call()
compiler.hooks.afterEnvironment.call()
// 核心方法,把webpack配置使用内置插件plugin进行初始化处理
new WebpackOptionsApply().process(options, compiler)
compiler.hooks.initialize.call()
return compiler
}

crateCompiler 核心流程:

  • 调用 getNormalizedWepackOptions + applyWebpackOptionsBaseDefaults 对 webpack.config.js 对 options 进行规范化和默认值的操作
  • new compiler() 初始化 compiler 实例
  • new NodeEnviromentPlugin 调用内置插件,把 node 环境变量绑到 compiler 实例上
  • 遍历 options.plugins,注册所有自定义的 plugin
  • 调用 enviroment,afterEnvironment 生命周期回调
  • new WebpackOptionsApply().process(options, compiler),进行各种 options 解析
  • 调用 initialize 生命周期回调方法,说明初始化已经执行完成
  • 返回 compiler 实例

WebpackOptionsApply()

现在了解一下WebpackOptionsApply().process(options, compiler)方法,其主要作用是解析 Webpack.config.js 配置,数十个插件在该方法中完成注册,在合适的时机运行 plugin 插件,注册,运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class WebpackOptionsApply extends OptionsApply {
......
process(options, compiler) {
......
if (options.externals) {
const ExternalsPlugin = require("./ExternalsPlugin");
// 解析options.xxx配置,注册插件进行处理
new ExternalsPlugin(options.externalsType, options.externals).apply(
compiler
);
}
// 注册插件进行初始化处理
new EntryOptionPlugin().apply(compiler);
// hooks.<hook name>.call调用,plugin插件响应
compiler.hooks.entryOption.call(options.context, options.entry);
new RuntimePlugin().apply(compiler);
......
}
}

WebpackOptionsApply() 核心流程:

  • new xxxPlugin().apply(compiler)的写法注册内置插件,用以解析 webpack.config.js 配置,同时传入 compiler 实例
  • 对入口、运行时等进行处理,例如new EntryOptionPlugin().apply(compiler)

在执行compiler.run()之前,做了充足的准备,接下来才会进入编译阶段

Compiler.run

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
run(callback) {
......
// 失败回调
const finalCallback = (err, stats) => {};
// 编译完成的回调
const onCompiled = (err, _compilation) => {
const compilation = _compilation;
// 检查是否应该输出结果
if (this.hooks.shouldEmit.call(compilation) === false) {
// 处理完成后的逻辑...
return;
}

process.nextTick(() => {
// 处理资源输出
this.emitAssets(compilation, (err) => {
// 其他处理逻辑...
});
});
};

// 真正开始编译的逻辑
const run = () => {
// 调用 beforeRun 和 run 钩子
this.hooks.beforeRun.callAsync(this, (err) => {
this.hooks.run.callAsync(this, (err) => {
// 读取记录后开始编译
this.readRecords((err) => {
this.compile(onCompiled);
});
});
});
};

if (this.idle) {
......
} else {
run();
}
}

整个函数实现,触发了很多的 hooks 比如 beforeRun、run、afterDone 等,其中比较重要的就是 this.compile(onCompiled)函数

调用 compiler.compile()

对核心方法Compiler.compile(onCompiled)方法继续查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 启动编译流程
compile(callback) {
const params = this.newCompilationParams();

// 在编译之前调用的钩子
this.hooks.beforeCompile.callAsync(params, (err) => {
// 触发编译开始的钩子
this.hooks.compile.call(params);

// 创建一个新的编译实例
const compilation = this.newCompilation(params);

this.hooks.make.callAsync(compilation, (err) => {
// 完成模块构建
this.hooks.finishMake.callAsync(compilation, (err) => {
process.nextTick(() => {
// 完成编译过程的准备工作
compilation.finish((err) => {
// 封闭编译记录,准备输出文件
compilation.seal((err) => {
// 编译完成后的钩子
this.hooks.afterCompile.callAsync(compilation, (err) => {
// 返回编译成功的回调
return callback(null, compilation);
});
});
});
});
});
});
});
}

钩子的执行顺序beforeCompile - compile - make - finishMake - afterCompile,整个编译是编译前 - 编译后

其中比较最核心的就是 compile 和 make 阶段,其中compile在函数中首次实现了compilation实例的创建

compile 和 compilation 的区别:

  • Compiler: webpack 的核心,贯穿于整个构建周期,Compiler 封装了 Webpack 环境的全局配置,包括但不限于配置信息、输出路径等

  • Compilation: 表示单次的构建过程及其产出,与 compiler 不同的是,compilation 对象在每次构建中都是新创建的,描述了构建的具体过程,包括模块资源,编译后的产出资源,文件变化,依赖关系状态,每当文件变化触发重写构建时,都会生成新的Complation实例

Compiler 是一个长期存在的环境描述,贯穿整个 Webpack 运行周期;而 Compilation 是对单次构建的具体描述,每一次构建过程都可能有所不同

重点关注 make 阶段,但是我们并没有在 make 中看到关键的函数执行,即相关的逻辑应该是通过 hooks 去触发的,在全局中搜索compiler.hooks.make.tapAsync去查看 make 编译的逻辑

1
2
3
4
5
compiler.hooks.make.tapAsync('EntryPlugin', (compilation, callback) => {
compilation.addEntry(context, dep, options, (err) => {
callback(err)
})
})

通过查找我们发现它在 EntryOptionPlugin 中被实例化,再搜索 EntryOptionPlugin,发现在 WebpackOptionsApply 中被引入,很显然,这个插件在 compiler.run() 之前就被注册好了

添加 Entry

看看 EntryPlugin 中的compilation.addEntry完成了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function addEntry(context, entry, optionsOrName, callback) {
// TODO webpack 6 remove
const options =
typeof optionsOrName === 'object' ? optionsOrName : { name: optionsOrName }

this._addEntryItem(context, entry, 'dependencies', options, callback)
}

function _addEntryItem(context, entry, target, options, callback) {
const { name } = options
// 尝试获取或初始化入口数据
let entryData = this.entries.get(name) || this.globalEntry

// 添加入口依赖
entryData[target].push(entry)

// 检查和合并选项,这里简化为直接使用传入选项
entryData.options = { ...entryData.options, ...options }

// 触发添加入口的钩子
this.hooks.addEntry.call(entry, options)

// 处理入口依赖的模块树,这里简化异步处理逻辑
this.addModuleTree({ context, dependency: entry }, (err, module) => {
// 入口添加成功
this.hooks.succeedEntry.call(entry, options, module)
callback(null, module)
})
}

可以了解到这里的工作就是在处理 Entry,Entry 的添加过程中会调用addModuleTree(),根据代码依赖递归构建模块树(Module Tree)

添加到 Module Tree

Rollup

Vite

babel

其它工具

esbuild swc bun

编译规范

JS 模块化

CSS 模块化

CI/CD 流水