前端打包(编译器)方面的思考

我正在参加「掘金·启航计划」

背景

最近在对打包、编译方面做一些思考,于是提了一些问题,自己也是做了一些总结,记录在此。形式主要是一问一答,个人理解,供你参考

Q1:打包器是什么?

打包器可以理解为是一个node脚本,把开发源代码(带框架、目录结构、npm包)打包成浏览器可以识别的规范代码(html)

例如:

前端打包(编译器)方面的思考

Q2:为什么需要打包器?

因为:

  • 开发的源代码是 带框架、目录结构、npm包等,是没有办法直接在浏览器端跑起来的

  • 当你需要发布一个npm包时,为了节省使用者的时间,并且避免打包工具的版本冲突,所以也需要提前打好包

  • 在可以打包阶段可以顺便处理很多问题,比如:浏览器兼容,性能问题,语法编译等

    • 打包器可以集成编译器,比如把react-loader(react DSL的编译器)集成到webpack中使用

      • 在打包阶段把框架语法(.vue .jsx)给解析成浏览器可以识别的规范js
  • 相当于,把给人看的代码(.vue .jsx .tsx .scss),转换成,给机器(浏览器)看的代码(.js .css .html)

盗用一张webpack官网的图:
前端打包(编译器)方面的思考

Q3:打包器原理?

大致思路是:通过一个入口,递归的去找到所有的相关依赖,最终会把js,css分别都是打在一个或多个包里,生成入口index.html,包含这些依赖(外链的形式),这些都是静态资源,可以直接部署

  • ps:也可以是多入口,最终生成的html,也会是多html,本质是一样的

原理这么讲的话,还是太浅了,需要多深入了解一些细节,于是又多提了一些问题

  1. 依赖之间的作用域怎么隔离?否则jS会相互污染

  2. 如果一个依赖被多个文件引用,怎么做拆包?

  3. 如何处理umd、cjs、esm模块?

  4. 按需加载,怎么获取精准的依赖?

以上问题以webpack5来做分析,只是一个思路、一个视角,前端的打包工具不止是有webpack(以下内容是我查看webpack打包产物 做的分析)

1. 依赖之间的作用域怎么隔离?否则jS会相互污染

  1. 用块作用域隔离(让两个模块的a变量互不影响)
(() => {
    const a = 1 
    console.log(a) 
})()
(() => {
    const a = 1 
    console.log(a) 
})()
  1. 如果会打包到同一个块作用域内的话,通过改变量名来解决变量名冲突(编译过程中改源代码)
const a = 1
console.log(a)

const util_name_b_a = 1 // 假设文件来源于:'./util/name-b.js',会取: 文件路径名_变量名,文件路径名有唯一性保证
console.log(util_name_b_a)

2. 如果一个依赖被多个文件引用,怎么做拆包?

递归分析依赖的时候,会维护一个map,如果一个依赖被多个文件引用,那么会被单独拆出来,统一放在一个公共js包里面。因为都在一个js里面,所以依赖之间也需要互相隔离,依赖会被统一放到__webpack_modules__这个对象里,格式如下:(请细看代码、注释和变量命名)

// 依赖经常是esm、cjs、umd同时存在的

// 当依赖是 cjs 或 umd 时,会被单独拆一个块作用域,放到__webpack_modules__对象内
var __webpack_modules__ = ({
    834: ((module) => { // 浏览器里面是没有 module对象的,所以module.exports会报错。这里是webpack自己传的module参数,webpack本身来兼容module对象的逻辑
        // 如果是cjs的包,直接module.exports (以'lodash.difference'为例)
        module.exports = difference;
        
        // 如果是umd的包,会有类似兼容代码
        typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
        typeof define === 'function' && define.amd ? define(['exports'], factory) :
        (global = global || self, factory(global.React = {}));
    }),
    432: (() => {
        ...
    })(),
    ...: ...
})
// 以'lodash.difference'为例
var lodash_difference = __webpack_require__(834);
var lodash_difference_default = /*#__PURE__*/__webpack_require__.n(lodash_difference); // __webpack_require__.n是做缓存用的
console.log((lodash_difference_default())[[1,2], [2,3]])


// 当依赖是 esm 时,会直接放到最外层
var __webpack_modules__ = ({
    ...
})
// esm 包,直接放最外层
const unit_name = 1 // 当变量名没有冲突时,直接用
const util_name_b_a = 1 // 当变量名冲突时,假设文件来源于:'./util/name-b.js',会取: 文件路径名_变量名,文件路径名有唯一性保证
console.log(unit_name, util_name_b_a)

3. 如何处理umd、cjs、esm模块?

  1. 问:umd、cjs、esm模块依赖是怎么打包的,可以看上面
  2. 问:如果我要用webpack来打包输出一个npm库?
    1. 如果要输出esm,那么会把cjs的语法转成esm,比如module.exports转成export
    2. 如果要输出cjs,那么会把esm语法转成cjs,比如export转成module.exports
    3. 如果要输出umd,则输出类似兼容代码
      typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
      typeof define === 'function' && define.amd ? define(['exports'], factory) :
      (global = global || self, factory(global.React = {}));
      

4. 按需加载,怎么获取精准的依赖?

可以先参考上面:2. 如果一个依赖被多个文件引用,怎么做拆包?

思路:按需加载的依赖会把 _webpack_modules_ 作为中转对象,比如 (请细看代码、注释和变量命名)

// 以'lodash.difference'为例,会以__webpack_modules__.834作为挂载点

var __webpack_modules__ = ({
    834: ((module) => {
        // 初始的时候,这里面的内容是空的
        // 等'lodash.difference'这个懒加载包真正被加载时,会被挂到 __webpack_modules__.834 这里。 还没加载时,此处会为这个懒加载包留位置,后续的代码已经关联上了此处
    }),
    432: (() => {
        ...
    })(),
    ...: ...
})

// 提前关联好 __webpack_modules__.834
var lodash_difference = __webpack_require__(834);
var lodash_difference_default = /*#__PURE__*/__webpack_require__.n(lodash_difference); // __webpack_require__.n是做缓存用的
console.log((lodash_difference_default())[[1,2], [2,3]])

Q4:前端框架利用打包器做了什么?

  1. 利用打包器,可以实现对框架语法的编译
  • 比如:浏览器不认识 .jsx .vue 这类的文件,jsx和vue的语法也没有任何工具知道如何解析,因为是框架作者自定义的,所以编译框架语法这一块,肯定需要框架作者自己来写解释器。
    • 解释器写完之后,要嵌入开发流程内去,最好是类似npm run build 一条命令 既可以编译好框架语法,又可以实现打包,让打包的产物可以开箱即用,这样无疑是最容易被大家接受的

    • 并且webpack正好提供loader这种钩子函数,那么就很自然的诞生了 jsx-loader、vue-loader

  1. 把打包器包一层,集成到脚手架(create-react-app、vue-cli)内去,更好的开箱即用
  • 因为webpack从0到1配置在webpack的版本早期,成本还是比较高的,而且很多普通开发者也不熟悉webpack,这个问题可能会阻塞vue、react这类框架的发展,提高他们的使用成本。

  • 所以,干脆就把webpack包一层,直接把一些好的实践集成进去,让开发者无感,可以开箱即用

05:在打包各阶段我们能做些什么有趣的事情?

这里肯定讲不全,只能提几个思路,看官可以自行发挥创造力去思考

  1. 可以做编译

    • 自定义loader,理论上可以把任意DSL(语法)转成合法的js或css。比如:ts,babel,sass,vue、react语法的解析。都在这个阶段
  2. 输出产物阶段

    • 可以做和性能相关的,比如:包压缩,产物名字控制、对根html加载资源作优化、资源上传到CDN 等等

webpack的生态也很繁荣,可以自行从webpack生态里的loader和plugin里去找灵感

另外提个思考题:vue和react在打包阶段能否互转?

(个人意见)在打包阶段,理论上是可以做到的,由ast作为中间态是可以实现的,react/vue -> ast -> vue/react,但是有几大缺陷:

  1. 本身实现成本比较高
  2. 维护成本非常非常高,这点应该是最致命的
    • 因为:vue、react都在频繁更新迭代,甚至有break change,比如vue3、react18,这样会造成项目很难很难维护,几乎相当于要重写。而且用户的环境非常复杂,用的版本也不一样,很难做到各种情况都兼容。并且两种框架本身也有一些api差异非常大,很难对这类api做兼容

如果要解决vue和react互相兼容的问题,不考虑打包阶段解决的话,有其他几个方向可以解决

  1. 源代码互转:左侧贴入你的vue/react代码,右侧输出对应的转换,不保证100%成功,需人工check,新语法不一定支持。

    • 这个解决思路和上面类似,只不过是抽出来了,可以可视化。缺点也和上面类似
  2. 项目内同时把vue、react实例共存,不做代码转换,直接让对应的框架实例去解析


码字不易,点赞鼓励!!

原文链接:https://juejin.cn/post/7244174365184589881 作者:bigtree

(0)
上一篇 2023年6月14日 上午10:31
下一篇 2023年6月14日 上午10:41

相关推荐

发表回复

登录后才能评论