说来惭愧,入职的第一个需求居然花了我两多个月?!😭

11,659 阅读15分钟

简历缺少有技术深度的项目吗?最近在做开源,实现一个脚手架,涉及广泛的工程化知识
如果你感兴趣参与贡献,或者想加入社区聊聊技术、工作、八卦,可以添加我的联系方式:Tongxx_yj。
GitHub 链接:github.com/xun082/crea…

一、需求背景

刚入职的第一个需求是工程化需求,mentor 也是希望我能从工程化的角度切入去熟悉项目,起初单纯的我以为很快就可以上手,在入职初狠狠表现一波,却没想到这个需求最后用了两个月的时间才完成!!😭

在讨论具体需求和解决方案之前,我们先聊聊从这篇文章中能得到什么:

  1. 在面对知识体系空白的需求时该如何有效面对。
  2. 相关工程化的概念(文中会有很多外链)。
  3. monorepo 中的源码引用方法。
  4. 一些构建配置的细节知识点(package.json、tsconfig.json)。

ps:文章主要是分享需求的处理过程,不会对某个大知识点做详细说明

接下来我们直接说需求:

我们的一个 monorepo 项目中,SDK 子项目通过 alias 被其他项目引入(从源码引入而不是从dist)时无法模块化(alias本身很冗长),与此同时,目前的项目打包速度也偏慢,因此考虑更换 monorepo 源码引用的方式,同时更换 SDK 子项目的构建工具。

在这里我们通过图来进一步 get 这个需求:

image.png

对于alias的模块化,我们通过下面的代码理解一下:
// 模块化前
const aliasA = {
  "@monorepo/sdk/src/modules/ui/component-a/*": "./src/module/ui/componet-a/*",
  "@monorepo/sdk/src/modules/ui/component-b/*": "./src/module/ui/componet-b/*",
};

// 模块化后
const aliasB = {
  "$componet-a/*": "./src/module/ui/componet-a/*",
  "$componet-b/*": "./src/module/ui/componet-b/*",
};

此时此刻,这种需求给我(工程化小白)带来的压力难以言喻,只能硬着头皮上了!
首先基于当下的认知分析项目现状:

  • 基本架构为 monorepo
  • SDK 的打包工具为 Rollup
  • alias 的处理涉及到 构建工具配置、tsconfig配置、package配置

与此同时,mentor 提出可以参照司内主项目的源码引用方式,开干!
在主项目项目A项目B-SDK的引用方式中 dev 和 prod 的构建方案有一定的区别,初步判断,其中运用到的知识点可能有 BabelTypeScript CompilerWebpackgulp,同时还涉及到手写构建工具插件
与此同时,组内的大哥还提了一嘴(司内的构建工具或者 tsup 也很不错啊!)—— 好好好,也调研一下!

综上,可以总结出现在需要去深入的 map

image.png

这里计划先从 BabelASTtsc 深入,理解 Plugins 的开发后再去研究各类打包工具的细节。
大佬请移步第四点~

image.png

二、编译工具与抽象语法树

这一部分记录 BabelASTtsc 的总结。因为存在较大的知识体系,有些点会写的比较简略。

2.1 Babel

参考文章一口(很长的)气了解 babel
作用:帮助开发者能够在低版本环境中(浏览器、node)使用最新的JavaScript 语言特性。通过将新版本的 JavaScript 代码转换为向后兼容的旧版本。
插件系统Babel 具有可扩展的插件系统,允许开发者根据项目需求选择和配置转换功能。插件可以用来添加、删除或修改转换规则
配置文件:Babel使用一个名为 .babelrc 的配置文件,用于指定转换规则、插件和其他选项。开发者可以根据项目的需要创建和配置自己的 .babelrc 文件。
preset:一组插件的集合,开发者可以通过使用预设来快速配置 Babel 的转换行为,而不需要一个个手动添加和配置各个插件。例如,@babel/preset-env 是一个常用的 Babel 预设,它根据目标环境的配置,自动选择需要的转换规则,以便将较新的 JavaScript 语法转换为目标环境兼容的代码。

2.2 AST

参考文章前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用🔥
作用AST 是用于表示程序源代码的抽象语法结构。其源代码的一个树状表示,其中每个节点代表源代码中的一个语法结构或表达式。它捕捉了源代码的结构和语义,并且可以用于代码分析、优化、转换和生成等任务。
构建过程

  1. 词法分析:将源代码拆分成一个个的标记(tokens),例如关键字、标识符、运算符、常量等。
  2. 语法分析:根据语法规则,将标记组织成一个抽象语法树。语法分析器会根据语法规则对标记序列进行解析,并构建出一个表示语法结构的树状结构。
  3. 构建抽象语法树:根据语法分析生成的解析树,将其转换为一个更抽象、更简化的树状结构,即抽象语法树

更多的操作可以看看参考文章,里面讲述了实现原理,以及各类实践操作。

2.3 tsc

参考文章TypeScript 想更深入一层?自定义 transformer 的 compiler api 试一下
作用TypeScript compiler(tsc)是 TypeScript 的官方编译器。它将 TypeScript 代码转换为 JavaScript 代码,以便能够在各种 JavaScript 运行时环境中执行。
其中,TypeScript transformer是一种用于自定义编译过程的工具,实现更多的定制和扩展。

整个编译过程涉及到的 api 较多,一般都是有需要再查阅文档。想要查看具体的例子请看参考文章。

在学会 BabelASTtsc 的过程中会发现三者是串联的关系,此时已经有编写一些 plugin 的能力了,那么下一步就是去了解构建工具。

三、构建工具

3.1 模块化原理

在学习 构建工具 前,需要先熟悉一下模块化的内容。
参考文章从构建产物洞悉模块化原理
模块化作用

  1. 组织和管理代码:通过将代码拆分为独立的模块,可以使代码结构更清晰、可维护性更高。
  2. 代码复用和共享:开发者可以将常用的功能封装为模块,然后在不同的项目中重复使用。
  3. 解决命名冲突:每个模块都有自己的作用域,模块内部的变量和函数不会与其他模块发生冲突。

参考文章中具体介绍了 CommonJSESM 的作用、语法,以及实现原理,同时涉及到了许多相关的常考问题,可以仔细阅读一下。

3.2 构建工具

作用:帮助开发者自动化和简化了许多繁琐的任务,提高了开发效率和代码质量。
可以直接参考以下文章,按照流程实践一遍即可。

Webpack参考文章🔥【万字】透过分析 webpack 面试题,构建 webpack5.x 知识体系
Gulp参考文章Gulp快速入门教程
Rollup参考文章一文带你快速上手Rollup
tsup参考文章使用 tsup 创建你的全新现代 TypeScript/JavaScript 库
以下是对这几类构建工具的表格总结:

工具优点缺点
Webpack- 插件生态系统丰富
- 支持热模块替换(HMR)和代码分割
- 大型项目优先考虑
- 配置复杂
- 构建速度较慢
- 需要额外的配置和插件来支持某些功能
- 生成的打包文件较大
Gulp- 简洁易用的API和链式操作
- 构建速度快,适用于小型项目
- 丰富的插件生态系统
- 支持增量构建和并行执行任务
- 任务配置和自定义选项很灵活
- 不适用于复杂的依赖关系处理和模块化打包
- 需要手动编写 gulpfile.js 配置文件来定义任务和处理文件
- 不支持代码分割热模块替换(HMR)
Rollup- 专注于 ES模块 的打包和优化,生成更小更高效的打包文件
- 构建速度快
- 支持 Tree Shaking,消除未使用的代码
- 支持代码分割和动态导入
- 适用于库和组件的打包和发布
- 对非 ES模块 的资源处理较弱
- 插件生态系统相对较小
- 配置相对较少和简单
tsup- 极简的配置和使用
- 构建速度非常快
- 适用于构建纯 TypeScript 项目
- 支持代码分割
- 功能相对较少,仅适用于纯 TypeScript 项目
- 插件生态系统相对较小

这边对一些名词做一下解释:

  • 热模块替换:在运行时替换模块的代码,而无需刷新整个应用程序。
  • 代码分割:将应用程序的代码拆分成多个较小的块(chunks),而不是将整个应用程序打包成一个单独的文件。(减少初始加载时间)
  • 增量构建:在每次构建时,只重新构建已更改的文件,而不是重新构建整个项目。
  • Gulp的链式操作:在Gulp任务中使用连续的方法调用来定义任务的处理流程,举例如下:
const gulp = require('gulp');
const sass = require('gulp-sass');
const autoprefixer = require('gulp-autoprefixer');
const cleanCSS = require('gulp-clean-css');

gulp.task('styles', function() {
  return gulp.src('src/scss/*.scss') // 指定要处理的scss文件
    .pipe(sass()) // 编译scss为css
    .pipe(autoprefixer()) // 添加浏览器前缀
    .pipe(cleanCSS()) // 压缩CSS
    .pipe(gulp.dest('dist/css')); // 输出到目标文件夹
});

四、技术方案形成

在过完一遍 map 之后,下一步则是带着目的性去阅读项目A项目B-SDK的配置方式,去形成我们的技术方案。

4.1 司内主项目的构建方式和源码引用方案

我们先来看看两个项目的构建方式

  • 项目A:司内的构建工具,内核基于 Webpack
  • 项目B-SDK:Gulp + Babel

其中 项目B-SDK 通过在 package.json 中配置 export 字段导出路径,与此同时 项目A 中的 tsconfig.json 中的 references 配置字段指向了 项目B-SDK 依赖。

听起来是不是有点绕,我们先来解释一下这些字段的作用:

解释exports

通过在 package.json 文件中的 exports 字段中指定特定的属性,可以定义模块的导出路径。

{
  "name": "my-package",
  "exports": {
    ".": {
      "import": "./src/index.js",
      "require": "./dist/index.js",
      "default": "./dist/index.js"
    },
    "./utils": {
      "import": "./src/utils.js",
      "require": "./dist/utils.js"
    }
  }
}

. 表示默认导出,而 ./utils 表示一个特定的导出路径

我们再结合 monorepo,给出案例:

项目结构
my-monorepo/
  packages/
    package-a/
      package.json
      src/
        index.js
    package-b/
      package.json
      src/
        index.js

package-a/package.json

{
  "name": "package-a",
  "exports": {
    "./count": "./src/count.js"
  }
}

package-b/src/index.js

import { someFunction } from 'package-a/count';

注意,别名的引用要通过 tsconfig 配置(compilerOptions 中的 path)

什么是 references

tsconfig.json 文件中的 references 字段来管理项目的依赖关系和构建顺序。references 字段允许在一个 Monorepo 或多包项目中指定包之间的依赖关系,以确保正确的构建顺序和类型检查。

同时它还有这些功能:

  1. 增量编译:当使用项目引用时,TypeScript 编译器可以只编译那些自上次编译以来发生变化的项目。
  2. 编辑器性能:使用项目引用可以改善编辑器的性能,因为编辑器可以仅加载需要的项目,从而减少内存占用并提高响应速度。
  3. 支持引用目标的类型检查:通过 Fork TS Checker 等方式执行 ts 类型检查时,能同时检查到引用的项目的类型问题。

配置示例:

{
  "compilerOptions": { 
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    // alias
    "paths": {
      "@/*": ["src/*"]
    },
  },
  "references": [
    { "path": "./packages/package-a" },
    { "path": "./packages/package-b" }
  ]
}

注意事项:

  1. 所有被引用的项目都应该设置 "composite": true 在它们的 compilerOptions 中,这样它们才能被其他项目引用。
  2. 引用的项目会生成 .d.ts 声明文件,这些文件会被依赖它的项目使用,以了解类型信息。

在了解两个字段的作用后,我们再来通过一张图来搞清整个源码引用的流程:

image.png

我们可以看到还多了一个角色——转换路径的 webpack 插件,在阅读插件实现原理后,得知他的作用就是解析依赖路径为绝对路径。

至此,我们的实现方案可以初步的确定下来:

  1. 更换构建工具 rollup 为司内的构建工具(简称 x)
  2. 配置相关的 package.jsontsconfig.json 配置,实现导出、依赖、路径别名配置
  3. 参照主项目的 webpack 插件,实现引用方的 alias 引入

对于更换构建工具,要考虑到功能需求、学习曲线、生态支持和性能表现。
在经过调研后,x 在构建方面更为便捷,同时速度上有很大的优势,demo 测试的打包时间如下:

  • Rollup:3m 39.7s
  • Rollup + rollup-plugin-esbuild :1s
  • Gulp:after 10 min
  • x:0.2s
  • Tsup: 0.314s

在确定方案后,下一步则是准备技术评审
在评审后,我的需求获得了巨大的“进步”,在处理 SDK 需求的基础上,还要将引用方A的构建工具迁移到最新版本,与此同时,还希望我构建一条线上发包的流水线,最后在群里通过机器人进行发包通知。

image.png

好家伙,干就干吧,这需求要没完了🫠。

评审结束后马上又进行了第二次评审准备,再把整体需求方案确定好后,接下来就要开工了,从调研到评审,就花掉了三周的时间,此时此刻生无可恋🫠 。

五、方案实施

依据整个需求,我拆分成了三个独立需求:

5.1 SDK 构建工具替换、monorepo源码引用

这个需求其实就是之前长篇大论的部分,其中对于构建工具的替换暂且忽略(没啥好说的,虽然挺坐牢),我们主要讲讲我是如何实现了 monorepo 的源码引用方案。
alias 配置
首先需要调整 alias 的配置,我们只需要在 SDKtsconfig 中配置 path 字段,即可实现别名引用(这个是给编译工具读取的),与此同时,SDK 的构建工具 x 内置了自动读取 tsconfig 配置处理为自己的 alias 配置的功能,因此无需在 x 中额外配置 alias

"@sdk/*": "./src/*",
"$componet-a/*": "./src/module/ui/componet-a/*",
"$componet-b/*": "./src/module/ui/componet-b/*",

与此同时,我们可以在 A 中的 tsconfig 配置 SDK 包的别名引用,这样可以让 lintfix 支持从 SDK 引入(说白了就是让编译器读懂,虽然 export 也做到了这个功能,但是没法让 lint 获取)

"@/*": "./src/*",
"@sdk/*": "../sdk/src/*",

export 配置
为要在 SDK 中导出路径,我们需要在 export 中配置相关的路径转发,具体的可以参照前文的字段解释。

reference 配置
A 中的 tsconfig 配置依赖 SDK,具体的效果参照前文解释

*alias处理的插件实现
参考主项目的插件,我自己手写实现了一个类似的 webpack 插件。
如何写一个 webpack 插件:干货!撸一个webpack插件(内含tapable详解+webpack流程) - 掘金 (juejin.cn)
具体的插件逻辑将通过代码结合注释说明:

class typescriptAliasPlugin {
    // 获取构建配置中传入的数据
    constructor(){  
        // 获取根目录路径
    }

    // 插件执行内容
    apply(compiler:Compiler){
        // 通过 hooks 约定执行的时机 afterResolve
            // 获取 package.json 中 devDependencies 的包内容(依赖项)
            // 遍历包名,在 node_module 中获取项目的 tsconfig (pnpm 有软连接)
            // 提取 tsconfig 中的 path 字段,注入 compiler 的 alias 配置中(注册到全局 alias)
    }
}

这个插件帮助 A 的构建工具得以读取到 SDKalias,并注册到自己的 alias 中,使得在 A 中能通过 alias 直接引用到 SDK 的源码进行开发。

至此,第一个需求的开发就接近了尾声,后续则是一系列的 build、start 的测试(痛苦地开始)。

5.2 A 项目构建工具升级

这一块其实是最折磨的,在升级之后会存在一系列的配置差异、编译报错,需要逐步修改,同时打包策略的不同还要通过相关配置去对其,来来回回花了将近一个月的时间(哭死
方便起见,我把整个流程通过代码块展示一下:

graph LR
    A[升级工具一键迁移] -->|对照配置映射表修改| B(尝试 build、start)
    B -->|修改配置| C(获得匹配的打包产物、html模板,依据报错修改业务代码 type error)
    C -->|配置 proxy,通过正则重定向请求| D(使得 start 后能通过代理访问)
    D -->|依据打包策略变更| E(修改 pipeline 以及相关的脚本文件)
    E -->|线上部署测试| F(分析 html 中 cdn 数量、包体积变化,做调整)

整个过程其实更为复杂,一坑埋好又一坑!!!

5.3 配置发包流水线 + 群机器人通知

这一块就相对轻松了,只需要依据司内提供的流水线平台搭建一个从打包到发布的模板,再通过群token,配置相关的机器人通知即可。

六、感悟

相对于技术文,这篇文章更注重分享解决需求的过程,当然对于工程化大佬而言就像一篇过家家的文章吧。

Suggestion.gif

虽然整个需求开发过程充满了坎坷(其实还有些没完成,因为一些因素还要升级 lint,做自动包体积分析的玩意),但却是让我从一个工程化小白成功入门了企业级工程化配置。

与此同时,刚入职的童鞋们还是要谨记抱住师兄们的大腿!多多沟通!!!
当然对于刚入职的实习生千万不要被需求的完成速度所限制了,不同的部门有不同的要求,很多时候对于实习生的要求并没有多么严格,按照自己的节奏完成好需求其实已经很不错了(●'◡'●)