前端知识体系-工程化

370 阅读34分钟

Webpack

Webpack执行流程

  • 通过yargs解析config和shell的配置项
  • webpack 初始化过程,首先会根据第一步的 options 生成 compiler 对象,然后初始化 webpack 的内置插件及 options 配置
  • run 代表编译的开始,会构建 compilation 对象,用于存储这一次编译过程的所有数据
  • make 执行真正的编译构建过程,从入口文件开始,构建模块,直到所有模块创建结束
  • seal 生成 chunks,对 chunks 进行一系列的优化操作,并生成要输出的代码
  • seal 结束后,Compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束

webpack依赖图谱

const fs=require("fs");
const path=require("path");
const babel=require("@babel/core");
const parser=require("@babel/parser");
const traverse=require("@babel/traverse").default;// 默认es module导出

const moduleAnalyser=(filename)=>{
    const content=fs.readFileSync(filename,"utf-8");// 读取文件内容
    const ast=parser.parse(content,{
        sourceType:"module"
    })
    const dependencies={};
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname=path.dirname(filename);//filename对应的文件夹路径
            const newFile="./"+path.join(dirname,node.source.value);
            dependencies[node.source.value]=newFile;
        }
    })
    const { code } = babel.transformFromAst(ast,null,{
        presets:["@babel/preset-env"]
    })//转换ast
    return {
        filename,
        dependencies,
        code
    }
}

const makeDependenciesGraph=(entry)=>{
    const entryModule=moduleAnalyser(entry);
    const graphArray=[entryModule];
    for(let i=0;i<graphArray.length;i++){
        const item=graphArray[i];
        const { dependencies } = item; // 解构出依赖
        if(dependencies){
            for(let j in dependencies){
                // 递归分析依赖,放入依赖图谱数组
                graphArray.push(moduleAnalyser(dependencies[j]))
            }
        }
    }
    console.log(graphArray);
}

const moduleInfo=makeDependenciesGraph("./src/index.js");// 入口函数
console.log(moduleInfo);

参考:

juejin.cn/post/684490…

juejin.cn/post/684490…

webpack5新特性

  1. 通过持久化硬盘缓存能力来提升构建性能

    • 内置 FileSystem Cache 能力加速二次构建;通过cache选项配置使用
  2. 通过更好的算法来改进长期缓存(降低产物资源的缓存失效率)

    • Webpack5 之前,文件打包后的名称是通过 ID 顺序排列的,产生一个新的 chunk 将会导致排在其后所有 js 文件文件名称发生变化
    • Webpack5新增了长期缓存的算法,该算法以确定性的方式为模块和分块分配短的(3 或 5 位)数字 ID, 有利于长期缓存。
    • Webpack5 还使用了[真实的 contenthash]来支持更友好的长期缓存,修改变量名称或者注释不会改变contenthash
  3. 构建优化-通过更好的 Tree Shaking 能力和代码的生成逻辑来优化产物的大小

    • 嵌套的 tree-shaking
    • CommonJs Tree Shaking
    • export * 已经得到改进,并且不再将默认导出标记为使用。
    • import() 通过魔法注释(webpackExports)手动 tree shake 模块。
    • Webpack5 内置了 Prepack 的部分能力,“预计算”能力,既能减小我们包的体积,又能加快运行时的速度。
  4. Webpack4功能清理

    • 所有在 v4 中被废弃的能力都被移除
    • 移除了 Node.js Polyfills,Polyfill 交由开发者自由控制
  5. 通过引入一些重大的变更为未来的一些特性做准备,使得能够长期的稳定在 Webpack5 版本上

    • 支持 Top Level Await,从此告别 async
  6. 增加内置功能

    • 内置静态资源构建能力 —— Asset Modules Webpack5 提供了内置的静态资源构建能力,我们不需要安装额外的 loader,仅需要简单的配置就能实现静态资源的打包和分目录存放。

    • 内置 WebAssembly 编译及异步加载能力(sync/async)

    • 内置 Web Worker 构建能力

  7. 支持崭新的 Web 平台特性

    • import.meta
    • 原生 Worker 支持
  8. 模块联邦 Module Federation

    webpack5 的新特性,分模块共同开发。多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。这通常被称作微前端,但并不仅限于此。
    我们分为本地模块、远程模块。 其中本地模块即为普通模块,是当前构建的一部分;而远程模块不属于当前构建,并在运行时从所谓的容器加载。 加载远程模块被认为是异步操作。当使用远程模块时,这些异步操作将被放置在远程模块和入口之间的下一个chunk的加载操作中。如果没有chunk加载操作,就不能使用远程模块。
    chunk的加载操作通常是通过调用import()实现的,但也支持像 require.ensure或require([…])之类的旧语法。 容器是由容器入口创建的,该入口暴露了对特定模块的异步访问。暴露的访问分为两个步骤:
    步骤1:加载模块(异步的)
    步骤2:执行模块(同步的)
    步骤1将在chunk加载期间完成。步骤2将在与其他(本地和远程)的模块交错执行期间完成。这样一来,执行顺序不受模块从本地转换为远程或从远程转为本地的影响。
    容器可以嵌套使用,容器可以使用来自其他容器的模块。容器之间也可以循环依赖。

参考:

juejin.cn/post/696167…

webpack.docschina.org/blog/2020-1…

webpack 概念

Hash

  • hash :任何一个文件改动,整个项目的构建 hash 值都会改变;
  • chunkhash:文件的改动只会影响其所在 chunk 的 hash 值;
  • contenthash:每个文件都有单独的 hash 值,文件的改动只会影响自身的 hash 值;

source map

sourceMap其实就是就是一段维护了前后代码映射关系的json描述文件,包含了以下一些信息:

  • version:sourcemap版本
  • file:转换后的文件名。
  • sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。
  • sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
  • names:转换前的所有变量名和属性名。
  • mappings:记录位置信息的字符串。
    • mappings 信息是关键,它使用Base64 VLQ 编码,包含了源代码与生成代码的位置映射信息。mappings的编码原理详解可见:www.ruanyifeng.com/blog/2013/0…

source map 选项:

  • inline 代码内通过 dataUrl 形式引入 SourceMap
  • eval eval(...) 形式执行代码,通过 dataUrl 形式引入 SourceMap
    • 只是它映射的是转换后的代码,而不是映射到原始代码,构建速度快但不包含行列信息。
  • module 展示源代码中的错误位置
    • Webpack会利用loader将所有非js模块转化为webpack可处理的js模块,而增加上面的cheap配置后也不会有loader模块之间对应的sourceMap。比如jsx文件会经历loader处理成js文件再混淆压缩, 如果没有loader之间的sourceMap,那么在debug的时候定义到上图中的压缩前的js处,而不能追踪到jsx中。所以为了映射到loader处理前的代码,我们一般也会加上module配置
  • cheap 只需要定位到行信息,不需要列信息
  • inline 代码内通过 dataUrl 形式引入 SourceMap,不单独生成.map文件。
  • hidden 生成 SourceMap 文件,但不使用
  • nosources 不生成源代码 Source Code

moduel chunk bundle

module

Module是离散功能块。 其实简单来说,module模块就是我们编写的代码文件,比如JavaScript文件、CSS文件、Image文件、Font文件等等,它们都是属于module模块。而module模块的一个特点,就是可以被引入使用。

chunk

此 webpack 特定术语在内部用于管理bundle过程。

chunk是webpack打包过程的中间产物,webpack会根据文件的引入关系生成chunk,也就是说一个chunk是由一个module或多个module组成的,这取决于有没有引入其他的module。

Bundle bundle 由许多不同的模块生成,包含已经经过加载和编译过程的源文件的最终版本。 bundle其实是webpack的最终产物。

简而言之,module 就是没有被编译之前的代码,通过 webpack 的根据文件引用关系生成 chunk 文件,webpack 处理好 chunk 文件后,生成运行在浏览器中的代码 bundle

Tree Shaking

Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其它模块使用,并将其删除,以此实现打包产物的优化。

  • Tree Shaking 较早前由 Rich Harris 在 Rollup 中率先实现;
  • Webpack 自 2.0 版本开始接入;
  • Webpack4扩展了package.json 文件中的sideEffects支持;
  • webpack5深化了Tree Shaking,提供了部分commonjs的Tree Shaking支持。

启用 Tree Shaking

  1. 使用 ESM 规范编写模块代码,babel-preset module:false
  2. 配置 mode = production
  3. 配置 optimization.usedExportstrue,启动标记功能;或者提供sideEffects
  4. 启动代码优化功能,可以通过如下方式实现:
    • 配置 optimization.minimize = true
    • 提供 optimization.minimizer 数组

实现Tree shaking有两种不同的方式

  1. optimization.usedExports usedExports通过标记某些函数是否被使用,之后通过代码压缩插件来进行优化 未导出的变量会被标记为:unused harmony exports

  2. package.json sideEffects 通过 package.json 的 "sideEffects" 属性作为标记,向 compiler 提供提示哪些文件是有副作用的。无副作用的文件才可以安全的删除。

"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。

sideEffects 和 usedExports(更多被认为是 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects 一样简单直接。

参考:

segmentfault.com/a/119000002…

juejin.cn/post/700241…

www.webpackjs.com/guides/tree…

webpack.docschina.org/guides/tree…

Tree shaking原理

Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:

  • Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
    • 被使用的export被标记为/* harmony export (binding) */
    • 未被使用的export会被标记为/* unused harmony export name */,不会使用__webpack_require__.d进行exports绑定
  • Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
  • 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
// o:hasOwnProperty
  if(!__webpack_require__.o(exports, name)) {
    Object.defineProperty(exports, name, { enumerable: true, get: getter });
  }
};

参考:

segmentfault.com/a/119000002…

juejin.cn/post/700241…

Scope hoisting

被 webpack 转换后的模块会带上一层匿名闭包,import 会被转换成 __webpack_require。 构建后的代码存在大量闭包代码,导致一些问题:

  • ⼤量作用域包裹代码,导致体积增大(模块越多越明显)
  • 运行代码时创建的函数作⽤域变多,内存开销变大

Scope hoisting原理 是将所有模块的代码按照引用顺序放在⼀个函数作用域里,然后适当的重命名⼀些变量以防止变量名冲突。

生产环境内置了ModuleConcatenationPlugin插件,让webpack根据模块间的关系依赖图中,将所有的模块连接成一个模块,称为"作用域提升"。对于代码缩小体积有很大的提升,也能侧面解决副作用的问题;每个模块会被标记//CONCATENATED MODULE

//被打包到一个作用域内
(function(module, __webpack_exports__, __webpack_require__) {
  //CONCATENATED MODULE: ./src/my-module.js
  // ...
  //CONCATENATED MODULE: ./src/index.js
   // ...
}

webpack mode 为 production 默认开启,必须是 ES6 语法(静态分析),CJS 不⽀持(动态)

热更新原理

HMR 的核心就是客户端从服务端拉取更新后的文件,准确的说是 chunk diff(chunk需要更新的部分),实际上 webpack-dev-server 与浏览器之间维护了一个 WebSocket,当本地资源发生变化时,webpack-dev-server会向浏览器推送更新,并带上构建时的 hash,让客户端与上一次资源进行对比。客户端对比出差异后会向 webpack-dev-server 发起 ajax 请求来获取更改内容(文件列表、hash),这样客户端就可以再借助这些信息继续向 webpack-dev-server 发起 jsonp 请求获取该 chunk 的增量更新。后续的部分由 HotModulePlugin 来完成,提供了相关的 API 以供开发真针对自身场景进行处理,像 react-hot-loadervue-loader 都是借助这些 API 实现 HMR。

热更新步骤:

  1. webpack-dev-server启动本地服务
    • 启动webpack,生成compiler实例。可以启动 webpack 编译工作,以及监听本地文件的变化。
  • 使用express框架启动本地server,让浏览器可以请求本地的静态资源

  • 本地server启动之后,再去启动websocket服务

  1. 修改webpack入口文件,向浏览器注入websocket运行时代码

  2. 监听文件变化进行,webpack编译结束,通过websocket给浏览器发送通知,okhash事件,向浏览器发送最新的hash值,用于检查更新逻辑。

    • hash事件,更新最新一次打包后的hash值。
    • ok事件,进行热更新检查。
  3. 浏览器收到热更新通知进行更新。客户端向 webpack-dev-server 发起 jsonp 请求获取该 chunk 的增量更新。

    • 热更新检查事件是调用reloadApp方法。
    • moudle.hot.check 开始热更新
    • 删除过期的模块,就是需要替换的模块
    • 将新的模块添加到 modules 中
    • 通过__webpack_require__执行相关模块的代码

参考:

juejin.cn/post/684490…

Loader

Webpack 支持使用 loader 对文件进行预处理。 loader是node中的一个模块,对外export的是一个函数。当一个文件资源需要被转化的时候,就会调用该loader。 loader 支持链式传递。能够对资源使用流水线(pipeline)。一组链式的 loader 将按照相反的顺序执行。

loader 链中的第一个 loader 返回值给下一个 loader。在最后一个 loader,返回 webpack 所预期的 JavaScript。

loader 执行顺序

webpack 按照 [post,inline,normal,pre] 顺序组装loaders给loader-runner执行。 所有一个接一个地进入loader-runner的 loader,都有两个阶段:

  1. Pitching 阶段: loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。更多详细信息,请查看 Pitching Loader
  2. Normal 阶段: loader 方法本身,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段。

pitch方法主要是为执行 loader 之前做一些预处理或者拦截的工作。如果pitch方法没有返回值则继续执行下一个 pitch,但如果有返回值则跳过剩下的loader。

参考 juejin.cn/post/699841…

test include exclude 匹配原理

按顺序依次匹配test include exclude,

  • 若不匹配test,返回
  • 若不匹配include,返回
  • 若匹配到exclude,返回

最终取三者交集。

ModuleFilenameHelpers.matchObject = (obj, str) => {
    if (obj.test) {
        if (!ModuleFilenameHelpers.matchPart(str, obj.test)) {
            return false;
        }
    }
    if (obj.include) {
        if (!ModuleFilenameHelpers.matchPart(str, obj.include)) {
            return false;
        }
    }
    if (obj.exclude) {
        if (ModuleFilenameHelpers.matchPart(str, obj.exclude)) {
            return false;
        }
    }
    return true;
};
ModuleFilenameHelpers.matchPart = (str, test) => {
    if (!test) return true;
    if (Array.isArray(test)) {
        return test.map(asRegExp).some(regExp => regExp.test(str));
    } else {
        return asRegExp(test).test(str);
    }
};

css相关loader

  • style-loader 将模块导出的内容作为样式并添加到 DOM 中
  • css-loader css-loader的作用主要是解析css文件中的@import和url语句,处理css-modules,并将结果作为一个js模块返回
  • less-loader 加载并编译 LESS 文件
  • sass-loader 加载并编译 SASS/SCSS 文件
  • postcss-loader 使用 PostCSS 加载并转换 CSS/SSS 文件

loader的实现:www.webpackjs.com/contribute/…

Plugin

在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。plugin是一个扩展器,在webpack打包的过程中,基于事件驱动的机制,监听webpack打包过程中的某些节点,从而执行广泛的任务。

plugin本质上是一个对外导出的class,类中包含一个固定方法名apply.

apply函数的第一个参数就是compiler,我们编写的插件逻辑就是在apply函数下面进行编写.

既然程序中已经获取了compiler参数,理论上我们就可以在compiler的各个钩子函数中绑定监听事件.比如在emit阶段绑定一个监听事件. 主程序一旦执行到emit阶段,绑定的回调函数就会触发.通过上面的介绍可知,主程序处于emit阶段时,compilation已经将代码编译构建完了,下一步会将内容输出到文件系统.

此时compilation.assets存放着即将输出到文件系统的内容,如果这时候我们操作compilation.assets数据,势必会影响最终打包的结果.

原理

  • 一个具名 JavaScript 函数。
  • 在它的原型上定义 apply 方法。
  • 指定一个触及到 webpack 本身的 事件钩子。
  • 操作 webpack 内部的实例特定数据。
  • 在实现功能后调用 webpack 提供的 callback。

插件实例:

// 在webpack打包结束后把一个readme.txt放到dist目录
class ReadmeWebpackPlugin {
    apply(compiler){
        compiler.hooks.emit.tapAsync('ReadmeWebpackPlugin',( compilation,callback ) => {
            compilation.assets['readme.txt'] = {
                source:function(){
                    return 'readme'
                },
                size:function(){
                    return 6
                }
            }
            callback()
        })
    }
}
module.exports = ReadmeWebpackPlugin;
  • compiler可以理解为一个webpack的实例,该实例存储了webpack配置、打包过程等一系列的内容。compiler提供了compiler.hooks,在为 webpack 开发插件时,你可能需要知道每个钩子函数是在哪里调用的,具体就可以查阅官方文档。
  • compilation 模块会被 compiler 用来创建新的编译(或新的构建)。该实例存放的是本次打包编译的内容。
  • 事件钩子
    • 当为AsyncSeriesHook时,我们使用tapAsync来tap插件,需要注意,我们需要调用 callback
    • 当为SyncHook时,使用tap,并且不再需要callback。

参考: webpack.js.org/api/compile…

SplitChunks

splitChunks: { 
    chunks: "async", 
    minSize: 30000, 
    minChunks: 1, 
    maxAsyncRequests: 5, 
    maxInitialRequests: 3, 
    automaticNameDelimiter: '~', 
    name: true, 
    cacheGroups: { 
        vendors: { 
            test: /[\\/]node_modules[\\/]/, 
            priority: -10 
        }, 
            default: { 
            minChunks: 2, 
            priority: -20, 
            reuseExistingChunk: true 
        } 
    } 
} 

主要字段含义:

  • chunks: 指的的那些chunks需要进行优化,是一个字符串类型,有效值是:all,async和initial。

    • async这个值表示按需引入的模块将会被用于优化。
    • initial表示项目中被直接引入的模块将会被用于优化。
    • all顾名思义,表明直接引入和按需引入的模块都会被用于优化。
  • minSize: 打包优化完生成的新chunk大小要> 30000字节,否则不生成新chunk。

  • minChunks: 共享该module的最小chunk数

  • maxAsyncRequests:最多有N个异步加载请求该module

  • maxInitialRequests: 一个入口文件可以并行加载的最大文件数量

  • cacheGroups: 这个就是重点了,我们要切割成的每一个新chunk就是一个cache group。

    • test:用来决定提取哪些module,可以接受字符串,正则表达式,或者函数
    • priority:一个模块可以属于多个缓存组。优化将优先考虑具有更高 priority(优先级)的缓存

splitchunks流程:

  1. 进行优化的预处理,定义优化过程中一些必要的方法和数据结构
  2. 准备完成后,遍历所有 module,将符合条件的 module 通过 addModuleToChunksInfoMap 方法存到 chunksInfoMap 中,进行分组,其实就是创建缓存组的过程
  3. 按照用户的 cacheGroup 配置,一项一项检查 chunksInfoMap 中各个缓存组是否符合规则,去除不符合的,留下符合的加入 compilation 的 chunkGraph 中,最后将符合条件的缓存组中的模块打包成新的 chunk。

参考:

juejin.cn/post/684490…

Tapable

对于Webpack有一句话Everything is a plugin,Webpack本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是Tapable。Tapable有点类似nodejs的events库,核心原理也是依赖与发布订阅模式。webpack中最核心的负责编译的Compiler和负责创建bundles的Compilation都是Tapable的实例。

Tapable提供了很多类型的hook,分为同步和异步两大类(异步中又区分异步并行和异步串行),而根据事件执行的终止条件的不同,由衍生出 Bail/Waterfall/Loop 类型。

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");

按照同步/异步分类

在 Tapable 中所有注册的事件可以分为同步、异步两种执行方式,正如名称表述的那样:

  • 同步表示注册的事件函数会同步进行执行,SyncHook。
  • 异步表示注册的事件函数会异步进行执行,AsyncSeriesHook。
  • 异步串行钩子( AsyncSeries ):可以被串联(连续按照顺序调用)执行的异步钩子函数。
  • 异步并行钩子( AsyncParallel ):可以被并联(并发调用)执行的异步钩子函数。

针对同步钩子来 tap 方法是唯一的注册事件的方法,通过 call 方法触发同步钩子的执行。
异步钩子可以通过 tap、tapAsync、tapPromise三种方式来注册,同时可以通过对应的 call、callAsync、promise 三种方式来触发注册的函数。

按照执行机制分类

Tapable 可以按照异步/同步执行分类的同时也可以按照执行机制进行分类,比如:

  • BasicHook: 执行每一个,不关心函数的返回值 AsyncSeriesHook。
  • BailHook: 顺序执行 Hook,遇到第一个结果 result !== undefined 则返回,不再继续执行之后的事件函数
  • WaterfallHook: 类似于 reduce,如果前一个 Hook 函数的结果 result !== undefined,则 result 会作为后一个 Hook 函数的第一个参数。
  • LoopHook: 不停的循环执行 Hook,直到所有函数结果 result === undefined。

Hook的使用

以最简单的 SyncHook 为例

// 初始化同步钩子 
const hook = new SyncHook(["arg1", "arg2", "arg3"]); 
// 注册事件 
hook.tap('flag1', (arg1,arg2,arg3) => { console.log('flag1:',arg1,arg2,arg3) }) 
hook.tap('flag2', (arg1,arg2,arg3) => { console.log('flag2:',arg1,arg2,arg3) }) 
// 调用事件并传递执行参数 
hook.call('SyncHook','001','002') 
// 打印结果 
// flag1: SyncHook 001 002 
// flag2: SyncHook 001 002
  • tap 函数监听对应的事件,注册事件时接受两个参数:

    • 第一个参数是一个字符串,它没有任何实际意义仅仅是一个标识位而已。
    • 第二个参数表示本次注册的函数,在调用时会执行这个函数。
  • 通过 call 方法传入对应的参数,调用注册在 hook 内部的事件函数进行执行。

    • 同时在 call 方法执行时,会将 call 方法传入的参数传递给每一个注册的事件函数作为实参进行调用。

最佳实践

所有的钩子构造函数,都接受一个可选的参数,包含参数的名称列表

const hook = new SyncHook(["arg1", "arg2", "arg3"]);

注册钩子,最好的实践就是把所有的钩子暴露在一个类的hooks属性里面:

class Car {
    constructor() {
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
    }

    /* ... */
}

使用钩子,其他开发者现在可以这样用这些钩子

const myCar = new Car();
myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

在同步钩子中, tap 是唯一的绑定方法,异步钩子通常支持tapPromise,tapAsync,tap:

// promise: 绑定promise钩子的API
myCar.hooks.calculateRoutes.tapPromise("GoogleMapsPlugin", (source, target, routesList) => {
    // return a promise
    return google.maps.findRoute(source, target).then(route => {
        routesList.add(route);
    });
});
// tapAsync:绑定异步钩子的API
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
    bing.findRoute(source, target, (err, route) => {
        routesList.add(route);
        callback();
    });
});
// tap: 绑定同步钩子的API
myCar.hooks.calculateRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {
    const cachedRoute = cache.get(source, target);
    if(cachedRoute)
        routesList.add(cachedRoute);
})

触发钩子:类需要调用被声明的那些钩子

class Car {
    /* ... */

    setSpeed(newSpeed) {    
        // call(xx) 传参调用同步钩子的API
        this.hooks.accelerate.call(newSpeed);
    }

    useNavigationSystemPromise(source, target) {
        const routesList = new List();
        // 调用promise钩子(钩子返回一个promise)的API
        return this.hooks.calculateRoutes.promise(source, target, routesList).then(() => {
            return routesList.getRoutes();
        });
    }

    useNavigationSystemAsync(source, target, callback) {
        const routesList = new List();
        // 调用异步钩子API
        this.hooks.calculateRoutes.callAsync(source, target, routesList, err => {
            if(err) return callback(err);
            callback(null, routesList.getRoutes());
        });
    }
}

参考:

www.npmjs.com/package/tap…

juejin.cn/post/684490…

juejin.cn/post/704098…

Webpack优化构建速

  1. 构建费时分析

  2. 优化 resolve 配置

    • alias,import 或 require 的别名
    • extensions webpack 就会按照 extensions 配置的数组从左到右的顺序去尝试解析模块 - 高频文件后缀名放前面
    • 手动配置后,默认配置会被覆盖
    • modules 告诉 webpack 解析模块时应该搜索的目录
    • resolveLoader 解析 webpack 的 loader 包
    • externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。
    • include 符合条件的模块进行解析
    • exclude 排除符合条件的模块,不解析,优先级更高
    • noParse 不解析指定的某些大型依赖库。不解析的文件中不应该含有 importrequiredefine 的调用,因为这些导入都不会被解析。忽略大型的 library 可以提高构建性能。参考 zhuanlan.zhihu.com/p/55682789
    • IgnorePlugin 防止在 import 或 require 调用
  3. 多线程配置 thread-loader

    • 配置在 thread-loader 之后的 loader 都会在一个单独的 worker 池(worker pool)中运行
  4. 利用缓存

    • babel-loader cacheDirectory 启用缓存
    • cache-loader 缓存一些性能开销比较大的 loader 的处理结果
    • hard-source-webpack-plugin
  5. tree-shaking 和 Scope hoisting 来剔除多余代码。

  6. 压缩资源

    • optimize-css-assets-webpack-plugin
    • terser-webpack-plugin
  7. splitChunks 分包配置 webpack 将根据以下条件自动拆分 chunks:

    • 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
    • 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
    • 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
    • 当加载初始化页面时,并发请求的最大数量小于或等于 30
  8. import() 代码懒加载

  9. 入口点分割 置多个打包入口,多页打包

webpack 中的模块解析规则

使用 enhanced-resolve,webpack 能够解析三种文件路径:

绝对路径

import "/home/me/file";

import "C:\Users\me\file";

由于我们已经取得文件的绝对路径,因此不需要进一步再做解析。

相对路径

import "../src/file1";
import "./file2";

在这种情况下,使用 import 或 require 的资源文件(resource file)所在的目录被认为是上下文目录(context directory)。在 import/require 中给定的相对路径,会添加此上下文路径(context path),以产生模块的绝对路径(absolute path)。

模块路径

模块检索相关的配置参数有:resolve.modules、resolve.alias、resolve.extensions、package.json和resolve.mainFields

import "module";
import "module/lib/file";

模块将在 resolve.modules 中指定的所有目录内搜索。 你可以替换初始模块路径,此替换路径通过使用 resolve.alias 配置选项来创建一个别名。

一旦根据上述规则解析路径后,解析器(resolver)将检查路径是否指向文件或目录。如果路径指向一个文件:

  • 如果路径具有文件扩展名,则被直接将文件打包。
  • 否则,将使用 [resolve.extensions] 选项作为文件扩展名来解析,此选项告诉解析器在解析中能够接受哪些扩展名(例如 .js.jsx)。

如果路径指向一个文件夹,则采取以下步骤找到具有正确扩展名的正确文件:

  • 如果文件夹中包含 package.json 文件,则按照顺序查找 resolve.mainFields 配置选项中指定的字段。并且 package.json 中的第一个这样的字段确定文件路径。
  • 如果 package.json 文件不存在或者 package.json 文件中的 main 字段没有返回一个有效路径,则按照顺序查找 resolve.mainFiles 配置选项中指定的文件名,看是否能在 import/require 目录下匹配到一个存在的文件名。
  • 文件扩展名通过 resolve.extensions 选项采用类似的方法进行解析。

webpack 根据构建目标(build target)为这些选项提供了合理的默认配置。

TS 模块解析策略

共有两种可用的模块解析策略:NodeClassic。 你可以使用 --moduleResolution标记为指定使用哪个。 默认值为 Node

Classic

这种策略以前是TypeScript默认的解析策略。 现在,它存在的理由主要是为了向后兼容。

相对导入的模块是相对于导入它的文件进行解析的。 因此 /root/src/folder/A.ts文件里的import { b } from "./moduleB"会使用下面的查找流程:

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts

对于非相对模块的导入,编译器则会从包含导入文件的目录开始依次向上级目录遍历,尝试定位匹配的声明文件。

比如:

有一个对moduleB的非相对导入import { b } from "moduleB",它是在/root/src/folder/A.ts文件里,会以如下的方式来定位"moduleB"

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts
  3. /root/src/moduleB.ts
  4. /root/src/moduleB.d.ts
  5. /root/moduleB.ts
  6. /root/moduleB.d.ts
  7. /moduleB.ts
  8. /moduleB.d.ts

Node

这个解析策略试图在运行时模仿Node.js模块解析机制。 完整的Node.js解析算法可以在 Node.js module documentation找到。

Node.js如何解析模块

为了理解TypeScript编译依照的解析步骤,先弄明白Node.js模块是非常重要的。 通常,在Node.js里导入是通过require函数调用进行的。 Node.js会根据 require的是相对路径还是非相对路径做出不同的行为。

相对路径很简单。 例如,假设有一个文件路径为 /root/src/moduleA.js,包含了一个导入var x = require("./moduleB"); Node.js以下面的顺序解析这个导入:

  1. /root/src/moduleB.js视为文件,检查是否存在。
  2. /root/src/moduleB视为目录,检查是否它包含package.json文件并且其指定了一个"main"模块。 在我们的例子里,如果Node.js发现文件 /root/src/moduleB/package.json包含了{ "main": "lib/mainModule.js" },那么Node.js会引用/root/src/moduleB/lib/mainModule.js
  3. /root/src/moduleB视为目录,检查它是否包含index.js文件。 这个文件会被隐式地当作那个文件夹下的"main"模块。

你可以阅读Node.js文档了解更多详细信息:file modules 和 folder modules

但是,非相对模块名的解析是个完全不同的过程。 Node会在一个特殊的文件夹 node_modules里查找你的模块。node_modules可能与当前文件在同一级目录下,或者在上层目录里。 Node会向上级目录遍历,查找每个node_modules直到它找到要加载的模块。

还是用上面例子,但假设/root/src/moduleA.js里使用的是非相对路径导入var x = require("moduleB");。 Node则会以下面的顺序去解析 moduleB,直到有一个匹配上。

  1. /root/src/node_modules/moduleB.js
  2. /root/src/node_modules/moduleB/package.json (如果指定了"main"属性)
  3. /root/src/node_modules/moduleB/index.js 
  4. /root/node_modules/moduleB.js
  5. /root/node_modules/moduleB/package.json (如果指定了"main"属性)
  6. /root/node_modules/moduleB/index.js 
  7. /node_modules/moduleB.js
  8. /node_modules/moduleB/package.json (如果指定了"main"属性)
  9. /node_modules/moduleB/index.js

注意Node.js在步骤(4)和(7)会向上跳一级目录。

你可以阅读Node.js文档了解更多详细信息:loading modules from node_modules

TypeScript如何解析模块

TypeScript是模仿Node.js运行时的解析策略来在编译阶段定位模块定义文件。 因此,TypeScript在Node解析逻辑基础上增加了TypeScript源文件的扩展名( .ts.tsx.d.ts)。 同时,TypeScript在 package.json里使用字段"typings"来表示类似"main"的意义 - 编译器会使用它来找到要使用的"main"定义文件。

比如,有一个导入语句import { b } from "./moduleB"/root/src/moduleA.ts里,会以下面的流程来定位"./moduleB"

  1. /root/src/moduleB.ts
  2. /root/src/moduleB.tsx
  3. /root/src/moduleB.d.ts
  4. /root/src/moduleB/package.json (如果指定了"typings"属性)
  5. /root/src/moduleB/index.ts
  6. /root/src/moduleB/index.tsx
  7. /root/src/moduleB/index.d.ts

回想一下Node.js先查找moduleB.js文件,然后是合适的package.json,再之后是index.js

类似地,非相对的导入会遵循Node.js的解析逻辑,首先查找文件,然后是合适的文件夹。 因此/src/moduleA.ts文件里的import { b } from "moduleB"会以下面的查找顺序解析:

  1. /root/src/node_modules/moduleB.ts

  2. /root/src/node_modules/moduleB.tsx

  3. /root/src/node_modules/moduleB.d.ts

  4. /root/src/node_modules/moduleB/package.json (如果指定了"typings"属性)

  5. /root/src/node_modules/moduleB/index.ts

  6. /root/src/node_modules/moduleB/index.tsx

  7. /root/src/node_modules/moduleB/index.d.ts 

  8. /root/node_modules/moduleB.ts

  9. /root/node_modules/moduleB.tsx

  10. /root/node_modules/moduleB.d.ts

  11. /root/node_modules/moduleB/package.json (如果指定了"typings"属性)

  12. /root/node_modules/moduleB/index.ts

  13. /root/node_modules/moduleB/index.tsx

  14. /root/node_modules/moduleB/index.d.ts 

  15. /node_modules/moduleB.ts

  16. /node_modules/moduleB.tsx

  17. /node_modules/moduleB.d.ts

  18. /node_modules/moduleB/package.json (如果指定了"typings"属性)

  19. /node_modules/moduleB/index.ts

  20. /node_modules/moduleB/index.tsx

  21. /node_modules/moduleB/index.d.ts

不要被这里步骤的数量吓到 - TypeScript只是在步骤(8)和(15)向上跳了两次目录。 这并不比Node.js里的流程复杂。

ESbuild

ESbuild 是一个类似webpack构建工具。它的构建速度是 webpack 的几十倍。

主要特性:

  • 极快的速度,无需缓存
  • 支持 ES6 和 CommonJS 模块
  • 支持对 ES6 模块进行 tree shaking
  • API 可同时用于 JavaScript 和 Go
  • 兼容 TypeScript 和 JSX 语法
  • 支持 Source maps
  • 支持 Minification
  • 支持 plugins

为什么这么快 ?

  1. js是单线程串行,esbuild是新开一个进程,然后多线程并行,充分发挥多核优势
  2. go是纯机器码,肯定要比JIT快
  3. 不使用 AST,优化了构建流程。

esbuild 的缺点

  1. 为了保证 esbuild 的编译效率,esbuild 没有提供 AST 的操作能力。所以一些通过 AST 处理代码的 babel-plugin 没有很好的方法过渡到 esbuild 中。
  2. esbuild 只能将代码转成 es6,不兼容低版本浏览器。

ESbuild 的应用

利用 esbuild 编译代码

esbuild 提供了 writeFileSync/writeFile 对 code 进行编译, 兼容 TypeScript 和 JSX语法

require('esbuild')
  .build({
    entryPoints: ['./src/App.jsx'],
    bundle: true,
    outfile: 'out.js',
    loader: {
      '.png': 'dataurl',
      '.svg': 'text'
    }
  })
  .catch(() => process.exit(1));

利用 esbuild 压缩代码体积

esbuild 提供了一个 minify 配置允许用户去压缩代码体积,实际 demo 如下

var js = 'fn = obj => { return obj.x }'
require('esbuild').transformSync(js, {
  minify: true,
})

// minify 后
{
  code: 'fn=n=>n.x;\n',
  map: '',
  warnings: []
}

处理其他资源

与webpack不同的是,esbuild内置了一些文件处理的loader。 当esbuild解析到某后缀时,会自动使用该loader进行处理。 当然,你也可以手动指定对应的loader处理器,如你想使用jsx loader去处理js文件。可以按下面的实例进行配置。

目前Esbuild 内置了 js,jsx,ts,tsx,css,text,binary,dataurl,file类型的loader

require('esbuild').buildSync({
  entryPoints: ['app.js'],
  bundle: true,
  loader: { '.js': 'jsx' }, // 默认使用 js loader ,手动改为 jsx-loader 
  outfile: 'out.js',
})
复制代码

用esbuild启动一个web server用于调试(支持热更新)

require('esbuild').serve({}, {
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
}).then(server => {
  // Call "stop" on the web server when you're done
  server.stop()
})

在Webpack中使用esbuild

在当前前端环境中,直接使用 esbuild 代理 webpack 是不现实的。在目前的主流方案是在 webpack 中使用 esbuild 去做一些代码的 transform (代替 babel-loader),即将 webpack 中做代码转化的步骤(babel-loader, ts-loader) esbuild-loader 代替。

例如,借助 esbuild-loader,把 Vue CLI 中的 Babel 替换为 esbuild。

在 vue.config.js 下的 chainWebpack 中加入以下内容:

// 清空已有的使用 `babel-loader` 的规则
config.module.rule("js").uses.clear();
config.module.rule("ts").uses.clear();
config.module.rule("tsx").uses.clear();

// 注入使用 `esbuild-loader` 的新规则
config.module.rule("js")
    .test(/.m?jsx?$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "jsx",
        target: "es2015"
    })
    .end();
config.module.rule("ts")
    .test(/.ts$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "ts",
        target: "es2015"
    })
    .end();
config.module.rule("tsx")
    .test(/.tsx$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "tsx",
        target: "es2015"
    })
    .end();

参考: juejin.cn/post/691892…

技术选型

技术选型需要考虑的因素

  1. 项目因素 明确现在项目的规模、重要程度。 项目的需求(特别是非功能性需求)也会限制技术的选型
  2. 团队因素 考虑团队的因素,也就是人的因素,考虑团队人员的技术组成。 考虑招聘的因素,对于特别小众的技术,可能会因为招不到人而影响到对公司的业务支持。
  3. 技术因素 技术特性考虑(易用性、可维护性、可扩展性、性能等)、技术成熟度、社区活跃度、架构匹配和演化等。 github上的star数,可以作为一个重要的参考。

技术选型步骤

  1. 首先明确选型的需求和目的,最好能列出必须要考虑的各种因素以及评判标准。

  2. 寻找候选技术和产品。这时范围可以尽量的广一些,搜集尽可能多的候选技术和产品。

  3. 初步筛选。把一些由于各种限制无法选择或者明显不可能的技术或产品排除,筛选3个左右备选方案。

  4. 做一些详细的调查和分析。可以列个技术选型分析表

分析维度: - 团队-维护 - 技术成熟度 - 社区活跃度 - 生态完善成度 - 性能 - 学习曲线 - 兼容性 - 扩展性

5.可以咨询其他公司是否用过个技术或产品,可以求教些实践经验。

前端架构设计

核心思想

【1】解决问题:前端架构的设计,应是用于解决已存在或者未来可能发生的技术问题,增加项目的可管理性、稳定性、可扩展性。

【2】人效比:对于需要额外开发工作量的事务(本文中存在一些需要一定开发量的内容),我们在决定是否去做的时候,应该考虑到两个要素:

第一个是花费的人力成本,第二个是未来可能节约的时间和金钱、避免的项目风险与资损、提高对业务的支撑能力以带来在业务上可衡量的更高的价值、以及其他价值。

【3】定性和定量:架构里设计的内容,一定要有是可衡量的意义的,最好是可以定量的——即可以衡量带来的收益或减少的成本,至少是可以定性的——即虽然无法用数字阐述收益,但我们可以明确这个是有意义的,例如增加安全性降低风险。

【4】数据敏感:专门写这一条强调数据作为依据的重要性。

基础层设计

1 自建Gitlab
2 版本管理
3 自动编译发布Jenkins
4 纯前端版本发布
5 统一脚手架
6 Node中间层
7 埋点系统
8 监控和报警系统
9 安全管理
10 Eslint
11 灰度发布
12 前后端分离
13 Mock
14 定期备份\

应用层设计

1 多页和单页
2 以应用为单位划分前端项目
3 基础组件库的建设
4 技术栈统一
5 浏览器兼容
6 内容平台建设
7 权限管理平台
8 登录系统设计(单点登录)
9 CDN
10 负载均衡
11 多端共用一套接口

参考: zhuanlan.zhihu.com/p/36619380

并发模式

www.shushangyun.com/article-437…

设计模式

观察者模式(observer)

观察者模式:定义了对象间一种一对多的依赖关系,当目标对象 Subject 的状态发生改变时,所有依赖它的对象 Observer 都会得到通知。比如我们监听 div 的 click 事件,其本质就是观察者模。

角色:

  • Subject - 被观察者,发布者;
  • Observer - 观察者,订阅者;

特征:

  1. 一个目标者对象 Subject,拥有方法:添加 / 删除 / 通知 Observer
  2. 多个观察者对象 Observer,拥有方法:接收 Subject 状态变更通知并处理;
  3. 目标对象 Subject 状态变更时,通知所有 Observer

具体实例:现有三个报社,报社一、二、三;有两个订报人,订阅者1,订阅者2。此处,报社就是被观察者、订阅人就是观察者。

优缺点:
  • 优点明显:降低耦合(松解耦),两者都专注于自身功能;
  • 缺点也很明显:所有观察者都能收到通知,无法过滤筛选;

发布订阅模式(Publisher && Subscriber)

发布订阅模式:基于一个事件(主题)通道,希望接收通知的对象 Subscriber 通过自定义事件订阅主题,被激活事件的对象 Publisher 通过发布主题事件的方式通知各个订阅该主题的 Subscriber 对象。

发布订阅模式与观察者模式的不同,“第三者” (事件中心)出现。目标对象并不直接通知观察者,而是通过事件中心来派发通知。

优缺点:

 - 优点:解耦更好(完全解耦),细粒度更容易掌控;

 - 缺点:不易阅读,额外对象创建,消耗时间和内存(很多设计模式的通病)

两种模式的关联和区别

发布订阅模式更灵活,是进阶版的观察者模式,指定对应分发。

  1. 观察者模式维护单一事件对应多个依赖该事件的对象关系;
  2. 发布订阅维护多个事件及依赖各个事件的对象之间的关系;
  3. 观察者模式是目标对象直接触发通知(全部通知),观察对象被迫接收通知。发布订阅模式多了个中间层,由其去管理通知广播(只通知订阅对应事件的对象);
  4. 观察者模式对象间依赖关系较强,发布订阅模式中对象之间实现真正的解耦。

从使用层面上讲:

  • 观察者模式,多用于单个应用内部
  • 发布订阅模式,则更多的是一种跨应用的模式(cross-application pattern),比如我们常用的消息中间件

参考:www.cnblogs.com/cc-freiheit…

命令模式

将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,对请求排队或者记录请求日志,可以提供命令的撤销和恢复功能。

优点
  • 对命令进行封装,使命令易于扩展和修改
  • 命令发出者和接受者解耦,使发出者不需要知道命令的具体执行过程即可执行
缺点
  • 使用命令模式可能会导致某些系统有过多的具体命令类。
访问者模式

表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

场景例子

  • 对象结构中对象对应的类很少改变,但经常需要在此对象结构上定义新的操作
  • 需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而需要避免让这些操作"污染"这些对象的类,也不希望在增加新操作时修改这些类。
优点
  • 符合单一职责原则
  • 优秀的扩展性
  • 灵活性
缺点
  • 具体元素对访问者公布细节,违反了迪米特原则
  • 违反了依赖倒置原则,依赖了具体类,没有依赖抽象。
  • 具体元素变更比较困难

装饰者模式

  • 动态地给某个对象添加一些额外的职责,,是一种实现继承的替代方案
  • 在不改变原对象的基础上,通过对其进行包装扩展,使原有对象可以满足用户的更复杂需求,而不会影响从这个类中派生的其他对象

Babel原理

官方的解释 Babel 是一个 JavaScript 编译器,用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前版本和旧版本的浏览器或其他环境中。简单来说 Babel 的工作就是:

  • 语法转换
  • 通过 Polyfill 的方式在目标环境中添加缺失的特性
  • JS 源码转换

Babel的原理和流程

  • Parser(@babel/parser):词法解析(Lexical Analysis),词法解析器(Tokenizer)将代码分割成(Tokens)语法片段数组

  • Parser(@babel/parser): 语法解析(Syntactic Analysis):这个阶段语法解析器(Parser)会把Tokens转换为抽象语法树(Abstract Syntax Tree,AST)。语法插件(@babel/plugin-syntax-*)参与

    • Traverser(@babel/traverse):遍历(访问者模式)AST,并应用转换器

    • Traverser(@babel/traverse):transformer,AST转换器,增删改节点,(@babel/plugin-transform-*)插件参与

  • Generator(@babel/generator):代码生成,将AST转化为字符串形式新代码,同时这个阶段还会生成Source Map

包结构

  • @babel/core核心包,内置一下包
  • 代码解析器Parser(@babel/parser)
  • AST遍历器Traverser(@babel/traverse)
  • 代码生成器Generator(@babel/generator)
  • 语法插件(@babel/plugin-syntax-*)
  • 转换插件(@babel/plugin-transform-*)
  • 预案插件(@babel/plugin-proposal-*)
  • 插件预定义集合(@babel/presets-*)presets-env
  • 模板引擎@babel/template更方便操作AST
  • @babel/types: AST 节点构造器和断言. 插件开发时使用很频繁
  • @babel/helper-*: 一些辅助器,用于辅助插件开发,例如简化AST操作
  • @babel/helper: 辅助代码,单纯的语法转换可能无法让代码运行起来,比如低版本浏览器无法识别class关键字,这时候需要添加辅助代码,对class进行模拟。

问者模式

  • 转换器操作 AST 一般都是使用访问器模式,由这个访问者(Visitor)来
  • 进行统一的遍历操作,
  • 提供节点的操作方法,
  • 响应式维护节点之间的关系;
  • 而插件(设计模式中称为‘具体访问者’)只需要定义自己感兴趣的节点类型,当访问者访问到对应节点时,就调用插件的访问(visit)方法。

插件开发

github.com/jamiebuilds…

pnpm

  • 如果你对同一依赖包需要使用不同的版本,则仅有 版本之间不同的文件会被存储起来。、
  • 所有文件都保存在硬盘上的统一的位置。当安装软件包时, 其包含的所有文件都会硬链接自此位置,而不会占用 额外的硬盘空间。这让你可以在项目之间方便地共享相同版本的 依赖包。
  • 当使用 npm 或 Yarn Classic 安装依赖包时,所有软件包都将被提升到 node_modules 的 根目录下。其结果是,源码可以访问 本不属于当前项目所设定的依赖包。默认情况下,pnpm 则是通过使用符号链接的方式仅将项目的直接依赖项添加到 node_modules 的根目录下。

Hard link

是因为计算机里面一个叫做 Hard link 的机制,hard link 使得用户可以通过不同的路径引用方式去找到某个文件。pnpm 会在全局的 store 目录里存储项目 node_modules 文件的 hard links 。

举个例子,例如项目里面有个 1MB 的依赖 a,在 pnpm 中,看上去这个 a 依赖同时占用了 1MB 的 node_modules 目录以及全局 store 目录 1MB 的空间(加起来是 2MB),但因为 hard link 的机制使得两个目录下相同的 1MB 空间能从两个不同位置进行寻址,因此实际上这个 a 依赖只用占用 1MB 的空间,而不是 2MB。

node_modules 结构

在 pnpm 官网有一篇很经典的文章,关于介绍 pnpm 项目的 node_modules 结构: Flat node_modules is not the only way | pnpm

在这篇文章中介绍了 pnpm 目前的 node_modules 的一些文件结构,例如在项目中使用 pnpm 安装了一个叫做 express 的依赖,那么最后会在 node_modules 中形成这样两个目录结构:

node_modules/express/...
node_modules/.pnpm/express@4.17.1/node_modules/xxx

其中第一个路径是 nodejs 正常寻找路径会去找的一个目录,如果去查看这个目录下的内容,会发现里面连个 node_modules 文件都没有:

▾ express
    ▸ lib
      History.md
      index.js
      LICENSE
      package.json
      Readme.md

实际上这个文件只是个软连接,它会形成一个到第二个目录的一个软连接(类似于软件的快捷方式),这样 node 在找路径的时候,最终会找到 .pnpm 这个目录下的内容。

其中这个 .pnpm 是个虚拟磁盘目录,然后 express 这个依赖的一些依赖会被平铺到 .pnpm/express@4.17.1/node_modules/ 这个目录下面,这样保证了依赖能够 require 到,同时也不会形成很深的依赖层级。

在保证了 nodejs 能找到依赖路径的基础上,同时也很大程度上保证了依赖能很好的被放在一起。

pnpm 对于不同版本的依赖有着极其严格的区分要求,如果项目中某个依赖实际上依赖的 peerDeps 出现了具体版本上的不同,对于这样的依赖会在虚拟磁盘目录 .pnpm 有一个比较严格的区分,具体可以参考: pnpm.io/how-peers-a… 这篇文章。

综合而言,本质上 pnpm 的 node_modules 结构是个网状 + 平铺的目录结构。这种依赖结构主要基于软连接(即 symlink)的方式来完成。

symlink 和 hard link 机制

在前面知道了 pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。

zhuanlan.zhihu.com/p/404784010

npm

npm的安装机制

  1. npm install执行之后, 首先会检查和获取 npm的配置,这里的优先级为: 项目级的.npmrc文件 > 用户级的 .npmrc文件 > 全局级的 .npmrc > npm内置的 .npmrc 文件

  2. 构建依赖树,检查项目中是否有 package-lock.json文件

  • 如果有, 检查 package-lock.jsonpackage.json声明的依赖是否一致:

    • 一致, 直接使用package-lock.json中的信息,从网络或者缓存中加载依赖
    • 不一致, 根据上述流程中的不同版本进行处理
  • 如果没有, 那么会根据package.json递归构建依赖树,然后就会根据构建好的依赖去下载完整的依赖资源,在下载的时候,会检查有没有相关的资源缓存:

    • 存在, 直接解压到node_modules文件中
    • 不存在, 从npm远端仓库下载包,校验包的完整性,同时添加到缓存中,解压到 node_modules
  1. 最后, 生成 package-lock.json 文件

yarn

当npm还处于v3时期的时候,一个叫yarn的包管理工具横空出世.在2016年, npm还没有package-lock.json文件,安装的时候速度很慢,稳定性很差,yarn的出现很好的解决了一下的一些问题:

  • 确定性: 通过yarn.lock等机制,即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都可以以相同的方式安装。(那么,此时的npm v5之前,并没有package-lock.json机制,只有默认并不会使用 npm-shrinkwrap.json)
  • 采用模块扁平化的安装模式: 将不同版本的依赖包,按照一定的策略,归结为单个版本;以避免创建多个版本造成工程的冗余(目前版本的npm也有相同的优化)
  • 网络性能更好: yarn采用了请求排队的理念,类似于并发池连接,能够更好的利用网络资源;同时也引入了一种安装失败的重试机制
  • 采用缓存机制,实现了离线模式 (目前的npm也有类似的实现)

安装机制

检测(checking) ---> 解析包(Resolving Packages) ---> 获取包(Fetching) ---> 链接包(Linking Packages) ---> 构建包(Building Packages)

检测包

这一步,最主要的目的就是检测我们的项目中是否存在npm相关的文件,比如package-lock.json等;如果有,就会有相关的提示用户注意:这些文件可能会存在冲突。在这一步骤中 也会检测系统OS, CPU等信息。

解析包

这一步会解析依赖树中的每一个包的信息:

首先,获取到首层依赖: 也就是我们当前所处的项目中的package.json定义的dependenciesdevDependenciesoptionalDependencies的内容。

紧接着会采用遍历首层依赖的方式来获取包的依赖信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过的包和正在进行解析包呢用Set数据结构进行存储,这样就可以保证同一版本范围内的包不会进行重复。

在Yarn中会根据 cacheFolder+slug+node_modules+pkg.name 生成一个路径;判断系统中是否存在该path,如果存在证明已经有缓存,不用重新下载。这个path也就是依赖包缓存的具体路径。

对于没有命中的缓存包,在 Yarn 中存在一个Fetch队列,按照具体的规则进行网络请求。

链接包

我们上一步已经把依赖放到了缓存目录,那么下一步,应该把项目中的依赖复制到node_modules目录下,此时需要遵循一个扁平化的原则。复制依赖之前, Yarn会先解析 peerDepdencies,如果找不到符合要求的peerDepdencies的包,会有 warning提示,并最终拷贝依赖到项目中。

构建包

如果依赖包中存在二进制包需要进行编译,那么会在这一步进行。

juejin.cn/post/706084…

前端前沿技术

  1. 移动端能力
  • 小程序生态
  1. 跨端能力
  • flutter
  • Taro
  • 鸿蒙OS
  1. PC端
  • NWjs
  • Electron
  1. 中后台/云服务体系
  • BFF(Backend For Frontend)
  • SSR
  • Serveless
  1. 微前端

  2. 低代码、智能化

  3. Web3D/AR/VR等前端图形技术

  4. web3.0

segmentfault.com/a/119000004…