当你有了技术深度,很可能也同时有了技术广度

9,694 阅读9分钟

很多同学不知道深入研究一些技术的意义在哪,会用不就行了?花那么大精力深入原理有什么好处呢?

这个问题就用我的两段真实学习经历来解答吧。

第一段学习经历

我刚开始写一些技术文章是研究 Babel 插件的时候,就从那里开始讲:

Babel 是一个 JS 的编译器,把高版本语法的代码,转换成低版本的代码,并且会自动引入 polyfill。

它分为三个步骤,parse、transform、generate:

parse 阶段把代码从字符串转换为 AST,transform 阶段对 AST 做各种增删改,generate 阶段再把转换后的 AST 打印成目标代码并生成 sourcemap。

所有的转换都是对 AST 的转换,也就是在 transform 阶段生效的。Babel 把这些 AST 转换逻辑组织成了一个个 plugin,plugin 比较多,用起来比较麻烦,所以又提供了 preset,也就是 plugin 的集合。

这样我们只需要用 preset-xx 就可以了,不用关心具体用到了啥 plugin。比如常用的 @babel/preset-env,只要指定 targets 运行环境,preset-env 内部会自动引入相应的 plugin 来做 AST 的转换。

本质上来说 babel 的核心功能就是对 AST 的各种转换,我们也可以自己写 plugin 来做这种转换。

当然,除了代码转换外,还可以静态分析,也就是通过分析 AST 来发现一些问题,在编译期间报错。

提到静态分析,自然会想到 ESLint 和 TypeScript Compiler,它俩不也是做 JS 的静态分析么?也是在编译期间发现一些代码中的问题并报错。只不过 ESLint 发现的是一些逻辑错误或者格式错误,而 TSC 发现的是类型错误。

都是基于 AST,那 Babel 能不能实现 ESLint 和 TSC 的功能呢?

于是当时我就尝试用 Babel 插件实现了下 Lint 的功能:

我发现 ESLint 里的逻辑错误的 rule 都很容易实现,因为都是对 AST 的分析。

比如 for 迭代方向的错误,就是看条件是 < 还是 >,对应的是 ++ 还是 --,基于 AST 很容易分析出来:

但是格式的错误就没办法了,因为 Babel 插件里拿不到这个 AST 关联的 token 信息。

我去看了下 ESLint 插件里是怎么检查出格式错误的:

发现 ESLint 提供了根据 AST 节点拿到它的 token 信息的 api,比如块语句可以拿到 { 的 token 信息,也就是所在的行列号等:

也可以拿到 { 的上一个 token 也就是 ) 的 token 信息,包含了行列号:

那这俩行列号一对比,不就知道了是不是在同一行,是不是中间有空格么?也就知道了是不是有格式问题。

ESLint 插件可以实现这种格式错误的检查,但是 Babel 插件就不可以。

是因为 AST 中不包含这部分信息么?

其实也不是,@babel/parser 也支持生成带 token 的 AST,只是没有提供对应的 api 给插件用:

如果 Babel 插件提供了查询 AST 节点的 token 的 api,那完全可以替代 ESLint 插件的功能。

然后我注意到 ESLint 插件提供了 fix 功能,可以自动 fix 一些错误,比较好奇它是怎么实现的,难道也是像 Babel 这样递归打印 AST 么?

研究了下发现并不是。

ESLint 插件可以在 report 错误的时候指定把某个 range 的文本替换成另一段文本:

原理就是字符串替换:

所以说,Babel 插件和 ESLint 插件至少有这两处不同:

  • ESLint 插件可以通过 api 拿到 AST 关联的 token 信息,检查出格式问题,而 Babel 插件不行
  • ESLint 的自动 fix 修改代码是通过字符串替换实现的,而 Babel 则是通过递归打印修改后的 AST 为字符串

ESLint 的静态分析搞明白了,我又在想: TSC 的类型检查不也是对 AST 做静态分析么?用 Babel 插件能实现么?

我还真实现了一个简易版的类型检查,还支持泛型和简单的类型编程:

但我发现有很多功能是实现不了的,比如 TypeScript 可以做跨文件的同名 namespace 合并,比如 TypeScript 可以声明跨文件的全局类型。

而 Babel 的编译是对单个文件进行的,也就是对一个文件进行 parse、transform、generate 这样的处理,下个文件再 parse、transform、generate。

TSC 会根据配置加载 lib 下的类型、加载 types 的类型,再根据 inclues、excludes、files 的配置加载项目代码里的类型,这样该放全局的放全局,该合并的合并,最后再去检查。

这种编译流程上的区别导致了 Babel 虽然可以编译 TS 代码,但并不能实现类型检查。它处理 TS 代码都是把类型语法给忽略掉的。

当然,也不只是 Babel,你用 swc、esbuild 等也是一样。

想做类型检查只能单独跑 tsc --noEmit,没有第二个选项。

搞懂了 Babel 和 ESLint、TSC 的区别,就知道为什么都是基于 AST,而 Babel 却不能取代它们两者了。

再就是代码转换,这个可是 Babel 的强项,但转换代码我们却不只用 Babel,还会用 Terser 做一次压缩(es6 之前的代码是 uglify,之后的就是 terser 了)。

为什么 Babel 明明可以在编译的过程中实现这种压缩的功能,却要用 Terser 单独来压缩呢?

其实真可以,babel 也一直在推进这事,只不过还没完成,这个 babel/minify 的项目还在 beta 阶段:

它的原理很容易想到,就是一系列 babel 插件来分析和转换代码:

前面这些编译工具都是处理 JS 的,后面我发现 postcss 就是 css 版的 babel,而且配置方式啥的都一样。

基于 postcss 同样可以实现 Lint,也就是 stylelint 工具,也同样可以实现压缩等等。

而且给 css 的 class 加 hash的 css modules 或者 scoped css 都是用它实现的。

然后我发现所有的前端编译工具都是 parse、transform、generate 这三个阶段,都是基于 AST 做分析和转换。

也很容易想明白,因为源代码和目标代码都是字符串,而中间的处理都是 AST,那自然都是这样的流程。

包括 Vue Template Compiler 也是一样,只不过它是 template 转 render function,也同样支持 transform 插件。

编译搞懂了,那自然会涉及到打包工具,因为我们一般不直接用编译工具,而是声明对什么文件用什么来编译,让打包工具去调用这些编译器,并把生成的代码打成几个 chunk。

打包工具做的事情只是根据 AST 分析出依赖图,然后对依赖图中的每个节点调用不同的编译器来编译,之后分成几个部分,包上一层 UMD 的代码生成最终的代码,当然还可以注入一些 runtime 代码。

学打包工具只需要了解 chunk 拆分、以及它的 runtime 代码做了什么事情之类的。编译和打包是两个维度的事情。

最近工具链有统一的趋势,比如 bun、rome、esbuild 等等一些工具,它们有的想把编译、Lint、压缩、打包等统一成一个工具来做,从原理上来说这是可行的,只是成本比较高,不然 babel minify 不会现在还是 beta 了。

讲了这么多,我们再回头看一下:

其实我就是研究 Babel 的时候,发现它的静态分析和 ESLint、TSC 一样都是基于 AST 做的,它的代码转换和 Terser 又有重叠部分,然后发现 postcss 也是差不多的,从而发现前端编译工具原理都类似,完全可以统一,再就是编译和打包是两个维度的事情。

我深入研究 Babel 的时候,为了搞懂它和关联的一些技术的区别,又研究了下其他技术,最终搞懂了 Babel 的同时也搞懂了很多其他工具的原理。

这就是为什么我标题说的有了技术深度的同时也会拥有技术广度。

第二段学习经历

再来举个例子,就是我最近在研究的调试:

调试我们一般用 Chrome DevTools,它可以调试网页,也可以调试 Node.js,这是为什么呢?

因为 Chrome DevTools 是基于 CDP(Chrome Devtools Protocol)和 JS 运行时通信的:

Node.js 之前的调试都是在命令行,没有 UI,所以对接了 CDP,这样就能直接用 Chrome DevTools 的 UI 来调试了。

既然只要对接 CDP 就行,那也不用非得用 Chrome DevTools 来调试,用 VSCode Debugger 也完全可以。

只不过 VSCode Debugger 多了一层适配器协议:

多这一层协议是 VSCode 为了让 Debugger UI 可以跨语言复用。

后来发现小程序调试工具、跨端引擎调试工具很多也都是用 Chrome DevTools 来调试,其实它们也是对接了 CDP,这样就可以用 Chrome DevTools 来调试它们的代码了。

调试工具分为 backend、frontend,UI 的部分是 frontend,代码运行时是 backend。

我试了下集成 Chrome DevTools frontend,自己实现 backend 的 CDP 服务,是可以的。这就是跨端引擎调试工具的原理。

也可以基于 JS 运行时的 backend,对接 CDP 来实现 frontend 部分。

这个过程中我了解到 Puppeteer 就是基于 CDP 实现的,它就是调试模式跑了一个 Chromium,然后连上 CDP 来做远程控制。

基于 CDP 实现这个并不难,所以我自己写了一个简易版 puppeteer。

不只是 puppeteer,lighthouse 的 cli 能拿到网页的运行数据并做一些记录和分析,也是基于 CDP 实现的。有网页分析需求的时候,也可以自己基于 CDP 这么搞。

这是我学习调试的经历:发现网页和 Node.js 的调试都可以用 Chrome DevTools 和 VSCode Debugger 调试,了解了下它们的原理都是基于 CDP,而且小程序调试工具、跨端引擎调试工具等可以用 Chrome DevTools 来调试也都是因为对接了 CDP。自己试了下实现 CDP backend 和 frontend,然后了解到 Puppeteer 和 LightHouse CLI 也都是基于 CDP 实现的,于是我实现了简易版的 Puppeteer。

我研究网页和 node 调试的过程中,顺便也搞懂了跨端引擎、小程序调试工具的原理和 Puppeteer 的原理。

这个案例也同样可以说明有了技术深度的同时也会拥有技术广度。

总结

很多技术从表面上看是毫不相关的,但再深入一点你会发现它们存在千丝万缕的关系。深入学习一门技术的同时,你也能顺蔓摸瓜掌握其他技术的原理,而且会比单独学习那门技术理解的更深。

当你有了技术深度的同时,很可能也同时有了技术广度,这俩并不冲突。