从一个变量声明的冲突解决方案到ESM模块化规范

422 阅读25分钟

在了解模块化规范之前,需要先了解前端工程化的概念。

前端工程化

"工程化"已经不是一个新鲜词了,为什么会产生这个概念?是什么?有什么用?

为什么?

  1. 最早的前端现在不只是实现页面,最多写写js让页面可以有交互,但是随着web2.0时代的到来,前端代码日益膨胀且需求大量增加,不仅做WEB应用还要做APP、小程序等,需要考虑一种新的方式,优化前端的开发工作(解决代码冗余,项目可维护性,提升版本迭代速度等问题)。
  2. 前端最开始还是服务端工程师为主导,前端写HTML代码,后端工程师使用诸如Java的JSP的服务端渲染技术,有可能会出现一些诸如字母拼写错误、实际像素存在误差的错误,但是就这么小的错误需要前端重写HTML代码后再交给后端继续套模板,浪费团队资源。
  3. 所以前后端分离开发也就得到了实际应用,前后端分离为前端工程化提供了生存的空间。

是什么?

前端工程化并没有一个明确的概念,可以理解为模块化、组件化、规范化、自动化的集合,可以说有助于实现上述“四化”的技术都可以称之为工程化的一部分,从现在的前端实践主要包括以下几点:

  • 产出HTML模板和配套文件

    包括对jscss,静态资源和媒体流文件的处理

    • webpack
    • vite
  • 以Vuejs,Reactjs为首的渐进式 js 框架或构建用户界面的 js 库

  • eslint、editconfig、prettier等的代码约束规范

  • 中间层Web服务,比如Nodejs

  • 前端单元测试,按照模块功能点进行测试

  • 前端项目部署

    • 静态资源文件在测试服务器的部署
    • HTML模板文件在Nodejs中间层服务器的部署

其中的webpack和vite是目前最广泛的工程化的实践,可以进行弥补浏览器本身的缺陷和不足:

  • 拥抱新的es规范(最后还依赖babel的垫片)和css预处理语言的浏览器适配
  • 应用级别的性能优化,比如使用base64、文件压缩、文件加入hash指纹进行浏览器缓存在HTTP请求方面进行优化
  • 模块化开发

什么用?

解放生产力,提高开发效率,通过制定一系列规范,借助工具和框架解决前端开发和前后端协作过程中的痛点和难点,最能体现这个过程的就是npm库的第三方包,而想要开发和使用这些第三方包必须遵守模块化规范,所以模块化的实现也是实现工程化的关键。

模块化

模块化是什么?

是一种处理复杂系统分解为更好的可管理模块的方式或者说过程,通常在后端的代码逻辑中得到体现。

接口、功能、逻辑、状态是模块的基本属性,功能、状态和接口反应模块的外部特性,逻辑反应其内部特性;模块化便于依赖管理,避免命名冲突,便于维护,便于代码复用。

组件化的概念

提起组件化,想到最多的就是element、ant-design这类的组件库。

组件化是一种高效的处理复杂应用,更好的进行不同功能模块的复用,更好的明确功能模块作用(尽可能的明确模块的主逻辑,内部具体实现放在子组件)的方式。

登录模块的组件化开发.png

与组件化关系

  1. 模块化属于前端架构(后端代码)层面的概念,分属同一业务的代码封装成独立模块,模块间通过接口调用
  2. 组件化是设计层面上对UI的拆分位于框架最底层,每一个DOM元素或者页面都可以是看做一个组件
  3. 模块化重解耦,组件化重复用;前者方便多人开发,后者加速开发效率
  4. 一般是调用组件来组成模块(比如购物模块包括:注册、登录、身份验证、购物),多个模块构成业务框架

模块化和组件化的联系.jpg

模块化方案

为什么需要模块化方案? 全局变量污染

<body>
  <script src="./index.js"></script>
  <script src="./home.js"></script>
  <script src="./list.js"></script>
</body>
​
//index.js 小a开发
var name = '我是个字符串'//list.js
console.log(name) //期待打印: '我是个字符串' 实际打印:functinon(){...}//home.js 小b开发
var name = function(){
    //...
}

依赖管理

假设三个 js 中,都有一个公共方法 fun1fun2fun3。三者之间的依赖关系如下图所示。

依赖管理.png

一、普通函数

全局function模式,将不同的功能封装到不同的全局函数中

js函数是有独立作用域的,可以放任何代码,只需要在使用的地方调用即可:

function fn1(){
    //声明一些变量
}
function fn2(){
    //声明另一些变量
}
function fn3(){
    fn1()
    fn2()
    //...
}

缺点: 污染全局命名空间,容易引起命名冲突和数据不安全,模块成员间看不出直接关系,维护困难。

二、命名空间

从全局声明变量和函数的方式转为namespace模式,即采用简单对象封装变量和函数的模式:

var myModule = {
    name:'小明',
    getName:function(){
        console.log(this.name)
    }
}
//使用
myModule.getName()

缺点:对象的全部属性都会暴露出来,内部状态可以被外部修改

三、立即执行函数(IIFE)

闭包: 又称词法闭包或函数闭包,在支持头等函数的编程语言中实现词法绑定的技术。实际上是一个结构体,存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值得对应关系,环境里主要包括约束变量(函数内部绑定的符号),也要包括自由变量(函数外部定义但在函数内被引用),有些函数可能没有自由变量。在捕捉闭包时,它的自由变量会在捕捉时被确定,即使脱离了捕捉时的上下文,也能照常运行。

捕捉时对值的处理可以是值拷贝,也可以是名称引用。取决于语言设计者,也可由用户指定(c++)

实际上使用了js函数中闭包的特性实现私有数据和共享方法

var myModule = (function () {
  var name = '彭于晏'
​
  function getName() {
    console.log(name)
  }
​
  return { getName } //可以控制共享的数据和方法
})()
​
myModule.getName() // 彭于晏
console.log(myModule.name) // undefined
myModule.name = '蔡徐坤' //并不会对函数内部的name进行修改,因为没有暴露出来,访问不到
myModule.getName() // 彭于晏

如果一个模块需要引入其他模块怎么办?函数传参可以实现引入依赖:

var otherModule = (function () {
  return { age: 1 }
  // return (age = 1)
})()
​
var myModule = (function (other) {
  var name = 'xiaoming'
  
  function getName() {
    console.log(name)
    console.log(other)
  }
  
  return { getName }
})(otherModule)
​
/**
 * 情况1:返回值为对象时是值的引用
 */
// myModule.getName() //xiaoming {age: 1}
// otherModule.age = 999
// myModule.getName() //xiaoming {age: 999}/**
 * 情况2:返回值为基本类型值时是值的拷贝
 */
myModule.getName() //xiaoming 1
otherModule.age = 999
myModule.getName() //xiaoming 1
四、依赖注入

本质还是对IIFE方案的渐进增强,可以在函数中引入jquery之类的第三方库:

// module.js文件
;(function (window, $) {
  let data = 'www.baidu.com'
  //操作数据的函数
  function foo() {
    $('body').css('background', 'red')
  }
​
  //暴露行为
  window.myModule = { foo }
})(window, jQuery)
​
// html文件需要先引入jquery依赖,再引入module.js文件

IIFE方案保证了模块的独立性,还使得模块之间的依赖关系更明显,但是也有缺陷存在。

缺点

  1. 如果需要依赖多个模块,会发送多个请求,请求过多
  2. 模块多的情况下,不知道具体依赖关系如何,有可能导致加载先后顺序出错
  3. 上面两个原因会导致难以维护,尤其是项目交接给别人后

这些问题可以通过模块化规范解决:

五、CommonJS规范

由社区提出,为了弥补JS没有标准的缺陷,终极目标是提供一个类似java、python的标准库,而不是仅仅停留在脚本程序的阶段。

Nodejs采用的就是CommonJS规范,每个文件就是一个模块具有自己的作用域。模块的变量、函数、类都是私有的,除非主动暴露否则对其他文件不可见。在服务器端,模块加载是运行时(runtime)同步加载的,在浏览器端需要提前编译打包处理。

导出的模块:

  1. module.exports导出

    /** exports模块 **/
    var a = 1
    var b = 2
    var add = function () {
      return a + b
    }
    ​
    // 1.module.exports导出
    module.exports.a = a
    module.exports.b = b
    module.exports.add = add
    // 等同于module.export = {a,b,add} 这种形式
    ​
    console.log('module.exports', module.exports)//{ a: 1, b: 2, add: [Function: add] }
  2. exports导出

    /** exports模块 **/
    var a = 1
    var b = 2
    var add = function () {
      return a + b
    }
    ​
    // 1.module.exports导出
    module.exports.a = a
    module.exports.b = b
    module.exports.add = add
    ​
    // 2.exports导出
    exports.a = b //修改了module.exports.a
    exports.b = b
    exports.add = add
    ​
    console.log('exports', exports)// { a: 2, b: 2, add: [Function: add] }
    console.log('module.exports', module.exports)//{ a: 2, b: 2, add: [Function: add] }
    console.log(module.exports === exports) //true
    

    总结 :

    1. 可以看出module.exports和exports其实是一个东西,都是引用对象的一个变量名
    2. exports只能通过exports.a= xxx这种语法
    3. module.exports既可以通过语法,也可以直接赋值一个对象
    4. 可以用exports.a= xxx去修改module.exports中对应的a属性
  3. exports赋值

    /** exports模块 **/
    var a = 1
    var b = 2
    var add = function () {
      return a + b
    }
    // 1.module.exports导出
    module.exports.a = a
    module.exports.b = b
    module.exports.add = add
    ​
    //3.exports赋值
    exports = {
      a: 88,
      b: 99,
      add: 'add',
    }
    ​
    console.log('exports', exports)//exports { a: 88, b: 99, add: 'add' }
    console.log('module.exports', module.exports)//module.exports { a: 1, b: 2, add: [Function: add] }
    console.log(module.exports === exports) //false
    
  4. module.exports赋值

    /** exports模块 **/
    var a = 1
    var b = 2
    var add = function () {
      return a + b
    }
    ​
    // 1.module.exports导出
    module.exports.a = a
    module.exports.b = b
    module.exports.add = add
    ​
    //4.module.exports赋值
    module.exports = {
      a: 88,
     b: 99,
      add: () => 100,
    }
    ​
    console.log('exports', exports)//exports { a: 1, b: 2, add: [Function: add] }
    console.log('module.exports', module.exports)//module.exports { a: 88, b: 99, add: [Function: add] }
    console.log(module.exports === exports) //false
    

    总结:

    1. 对exports进行赋值(包括引用值和简单值)时,exports的指向就不一样了,在另一个模块就找不到exports变量传递的东西
    2. 对module.exports进行赋值(包括引用值和简单值),在另一个模块还可以找到module.exports变量传递的值
    3. module.exports才是模块最终导出的那个对象,可以看做模块本身已经开辟好了一个名为module.exports的导出空间,exports.xxx这种模式只是在往这个空间添加或修改属性
    4. 正常使用时,就使用module.exports这种导出方式即可,也是现在的通用规范

require的模块:

对于require加载模块时,输入的值是被输出值(module.exports对象)的浅拷贝,也就是说,一旦输出一个值,导出模块内部的变化就影响不到这个值了。

require读取并执行一个js文件的步骤: 1.先解析成绝对路径 filename,找到这个js文件模块(获取到一个指向这个文件模块的module变量),如果没有缓存会先把此文件加入到缓存空间;2.module是一个对象,它的exports属性是对外的接口,即加载的是module.exports属性,没找到的话就抛出错误 3.再校验该模块内部的cache对象是否存在以filename路径为key的值,如果有读取缓存,否则调用load加载模块,这个加载过程是同步的

验证步骤1和步骤2:

/** require模块 **/ 
var cj2 = require('./commonjs2.js')
console.log(cj2.age) //30
console.log(cj2.stars) //[ '周杰伦', '陈奕迅' ]
cj2.changeAge()
console.log(cj2.age) //30
console.log(cj2.stars) //[ '周杰伦', '陈奕迅', '邓紫棋' ]
​
/** exports模块 **/
var age = 30
var stars = ['周杰伦','陈奕迅']
function changeAge() {
  age = 99
}
function changeStars() {
  stars.push('邓紫棋')
}
module.exports = {
    age,
    stars,
    changeAge,
    changeStarts
}
​
//------------------------------------------------------------------
/**
可以看做实际导出的是:
module.exports = {
    age:shallowCopy(age),
    changeAge:shallowCopy(changeAge)
}
​
1.require模块调用的实际是shallowCopy(changeAge)而不是changeAge,但是因为函数本身就是引用类型,最终还是调用的changeAge,然后根据作用域链拿的还是外层的age进行修改
2.而最终导出的shallowCopy(age)是基本类型的拷贝,相当于在栈中开辟了一个新变量,和原来的age变量互不影响,所以第二次打印的age并没变化
**/

验证步骤3:

/** require模块 **/
var cj2 = require('./commonjs2.js')
var cj2P = require('./commonjs2.js')
console.log(cj2P === cj2) //true 因为使用的缓存值

清除缓存再引入呢?

var cj2 = require('./commonjs2.js') //copy module.exports
cj2.changeStars()
delete require.cache[require.resolve('./commonjs2.js')]
var cj2Plus = require('./commonjs2.js') //copy module.exportsconsole.log(cj2Plus === cj2) //false 清除缓存会重新载入模块,相当于引入一个新对象

怎么解决循环引用的情况?

实际是依靠步骤1的最后一句话: 如果没有缓存会先把此文件加入到缓存空间

//commonjs3模块 引用了require3文件
const getMes = require('./require3.js')
console.log('我是 a 文件') // 3
exports.say = function () {
  return getMes()
}
​
​
//require3模块 引用了commonjs3文件
const cj3 = require('./commonjs3.js')
const object = {
  name: 'React',
  author: '小R',
}
console.log('我是 b 文件') //1
console.log('打印 a 模块', cj3) //2 此时commonjs3还未导出say方法
setTimeout(() => {
  console.log('异步打印 a 模块', cj3.say()) //5 此时say不为空了
}, 0)
module.exports = function () {
  return object
}
​
​
//index.js模块
const commonjs3 = require('./commonjs3.js')
console.log('node 入口文件') //4
/**
 * 模块执行顺序: 父--子--父
 * 1.先加载commonjs3 --- 没有缓存先加入缓存,执行commonjs3文件
 * 2.发现引入了require3所以加载require3 --- 没有缓存先加入缓存,执行require3文件
 * 3.发现再一次引用了commonjs3 --- 有缓存,直接读取值,接着执行require3剩余代码
 * 4.再执行commonjs3文件
 * 5.执行index.js文件
 */
六、AMD规范

Commonjs最大的缺点就是只适用于Nodejs为主的服务端,是同步加载的,因为文件都在本地硬盘所以读取速度很快,浏览器端是没有global,module,exports,require这些全局对象的。而且浏览器请求服务器资源必须是异步的,所以有了AMD规范去解决这两个问题:

1、编写模块时,直接放在函数中

2、将js加载做成异步,加载完成后调用一个回调

语法: define(id?: String, dependencies?: String[], factory: Function|Object)

id:模块名

dependencies:依赖模块列表

factory:模块的具体实现,如果是函数,返回值就是模块的输出接口或者值

// 定义依赖 myModule,该模块依赖 JQ 模块 这里也是依赖前置的体现
define('myModule', ['jquery'], function($) {
  // $ 是 jquery 模块的输出
  $('body').text('我是myMoudle')
  return {name:'myModule'}
})
​
// 引入依赖
require(['myModule'], function(myModule) {
  // todo...
  var temp = myModule.name
})

AMD特点:

  • 完整描述了模块的定义,依赖关系,引用关系和加载机制
  • AMD的核心是预加载先对依赖的全部文件进行加载,加载完了再进行处理
  • 可以并行加载多个模块,并可以按需加载(require引入依赖进行定义即可) AMD内部运行机制: 在RequireJS内部,会使用head.appendChild()将每一个模块依赖加载为一个script标签。RequireJS等待所有的依赖加载完毕,计算出模块定义函数正确调用顺序,然后依次调用它们 理想情况下,整个网页只需要这一个script标记,利用requireJS加载依赖的其它文件
<script data-main="scripts/main" src="js/require.js"></script>

[注意]在main.js中所设置的脚本是异步加载的。所以如果在页面中配置了其它JS加载,则不能保证它们所依赖的JS已经加载成功

<script data-main="scripts/main" src="js/require.js"></script>
<script src="js/other.js"></script>

RequireJS是AMD的标准化实现,在浏览器中想使用AMD规范需要先引入require.js就可以了。

七、CMD规范

CMD规范汲取了CommonJS和AMD规范的优点,也是专门用于浏览器的异步模块加载规范

基本使用:

  • require 是一个方法,接受模块标识作为唯一参数,用来获取其他模块提供的接口
  • exports 是一个对象,用来向外提供模块接口
  • module 是一个对象,上面存储了与当前模块相关联的一些属性和方法
//定义暴露模块
define(function(require, exports, module) {
  
  // 暴露模块
  exports.aaa = 'hahaha'
})
​
//引入使用模块
define(function(require, exports, module) {
  // 同步引入
  var a = require('./a')
  a.doSomething()
  // 依赖就近原则:依赖就近书写,什么时候用到什么时候引入
  // 异步引入
  require.async('./b', function (b) {
      b.doSomething()
  })
  // 条件引入 软依赖
  if (status) {
      var c = requie('./c')
  }
})

SeaJS是CMD的标准化实现,同RequireJS一样,都是浏览器端模块加载器,两者很相似,但也有明显的不同:关于依赖模块AMD推崇依赖前置,CMD推崇依赖就近,即AMD是提前执行(在代码逻辑开始前全部执行) ,CMD是延迟执行(未执行到逻辑使用此依赖前不会执行)

社区模块化规范.jpg

八、Es Module规范

以上几种规范都是社区提出的规范,ES6发布后,js在语言层面实现了模块功能,有以下的特点:

  1. 官方语言提出的最规范的模块化解决方案,浏览器和服务器通用

  2. 编译时就能确定模块的依赖关系,输入(一个只读引用)和输出的变量,并输出对外的接口,静态的导入导出实现了tree shaking

    • CommonJS运行时才能确定(本质是因为输出的一个对象,需要脚本运行完才会生成)
    • tree shaking是尽可能的删除没有被使用过的代码,没有被使用的代码不会被打包
  3. ES6模块输出的是值的引用(所以不会缓存值),CommonJS模块输出的是值的拷贝

  4. 可以利用import()动态导入返回一个Promise实现懒加载(比如路由懒加载)方式,进行代码分割(code split代码打包优化的一种方式,避免一次性加载大量js文件时首次加载白屏时间过长的情况)

  5. 预编译阶段分析模块依赖,代码执行阶段采用深度优先遍历,执行顺序是子-->父

验证特点3:

// export1.js
export let counter = 3
export function incCounter() {
  counter++
}
export default function getDblCounter() {
  return counter*2
}
​
// App.vue
import getDblCounter, { counter, incCounter } from './modules/export1'
console.log(counter) // 3
console.log(getDblCounter()) //6
incCounter()
console.log(counter) // 4
console.log(getDblCounter()) //8

验证特点5:

//main.js
console.log('main.js开始执行') //3
import say from './a' //会提升到顶级
import say1 from './b'
console.log('main.js执行完毕') //4
​
​
//a.js
import b from './b'
console.log('a模块加载') //2
export default  function say (){
    console.log('hello , 我是小a')
}
​
​
//b.js
console.log('b模块加载') //1
export default function sayhello(){
    console.log('hello,我是小b')
}

怎么解决循环引用?

//main.js
console.log('main.js开始执行') //5
import say from './a' //会提升到顶级
import say1 from './b'
console.log('main.js执行完毕') //6
​
​
//a.js
import b from './b'
b() //3
console.log('a模块加载') //4
export default  function say (){
    console.log('hello , 我是小a')
}
​
​
//b.js
import a from './a'
a() //1
console.log('b模块加载') //2
export default function sayhello(){
    console.log('hello,我是小b')
}
​
/**
 * 循环引用的过程
 * 1.程序进入a.js
 * 2.import b from './b' 进入b.js, 此时a.js被标记为Fetching
 * 3.b.js中import a from './a' 企图再次进入a.js,但是a.js已经请求过但是没有解析完正处于Fetching状态,所以会继续往下执行
 * 4.ES Module内部有一个Module Map存储一个Module当前状态, 如果解析完就获取它的ModuleRecord(可以分析出该模块的import/export,获得依赖关系), 如果没有解析完成,则被标记为Fetching,不做处理并继续往下执行
 * 5.b.js执行完毕
 * 6.返回a.js继续执行
 * 7.返回main.js往下执行
 */

模块化规范方案总结

  1. js模块化方案都在解决以下几个问题:全局变量污染、命名冲突、繁琐的文件依赖。立即执行函数可以很好解决前两个问题,难点在于文件依赖关系梳理和加载
  2. CommonJS规范主要用于服务端编程, 解决了依赖、全局变量污染的问题 ,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
  3. AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,必须提前加载所有的依赖,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
  4. CMD规范与AMD规范很相似,都用于浏览器编程,但是可以按需加载,依赖就近,延迟执行。缺点是打包依赖SPM打包,模块的加载逻辑偏重。
  5. CommonJS在服务端使用fs模块同步读取文件,而浏览器中,不管是AMD还是CMD规范,都是使用动态创建script标签方式加载,在依赖加载完毕后再执行,不必再关注加载顺序这一烦恼
  6. ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案

模块化打包工具

为什么需要模块化打包工具?

  1. 浏览器的运行效率问题:如果按照最原始的写法,编写的js代码文件比较多,需要发送多次http请求,如果其中一个文件没有响应同时其它文件对此文件存在一些依赖,就会导致页面的部分功能无法实现。

  2. 浏览器对非js文件的支持度问题:比如使用ts文件,vue文件,scss文件等,浏览器都是不能识别的,打包工具可以使用对应的loader对其进行转化,变成浏览器可识别的文件。

  3. 浏览器对es新技术的支持度问题:打包工具的babel-loader(也可以直接使用babel)可以将es6+的代码转成向后兼容的js语法,方便运行在旧版的浏览器或其他环境,babel的工作过程如下:

    • parsing(解析原始js文件):babel内部将es6代码进行语法分析和词法分析后转成抽象语法树
    • transforming(转化):对抽象语法树进行变换操作,是依赖"入驻"到babel平台的plugins进行这一步的操作。转化时有两种情况(语法层let、const、class、箭头函数...,api方法层Promise、includes、map...),前者是在语法层面的转译,后者是在全局或者Object、Array等的原型上新增的方法需要由响应es5的方式重新定义。语法层面官方有一个presets:["@babel/env"]的预设可以直接继承,api层面必须使用polyfill将api转成浏览器支持的写法(对官方预设添加配置项即可实现按需加载)
    • printing(生成新的js文件)
webpack

一个依赖loader和插件的js应用程序的静态模块打包器。

webpack.png

这里只总体说一下webpack对于更好地实现模块化的作用,具体的工作流程及相关的配置和优化就略过了。

从官网上的描述可以看出,webpack的作用有以下几点:

  • 模块打包。可以将不同模块的文件打包整合在一起并且保证它们之间的引用正确,执行有序。利用打包我们就可以在开发的时候根据我们自己的业务自由划分文件模块,保证项目结构的清晰和可读性。

    • 包括请求合并打包。将多个script请求合并到一个请求中
  • 编译兼容。在前端的"上古时期",浏览器兼容一直是很重要也很令人头疼的一块内容,通过webpackLoader机制,不仅仅可以帮助我们对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件让我们在开发的时候可以使用新特性和新语法做开发,提高开发效率。

  • 能力扩展。通过webpackPlugin机制,我们在实现模块化打包和编译兼容的基础上,可以进一步实现诸如按需加载,代码压缩等一系列功能,帮助我们进一步提高自动化程度,工程效率以及打包输出的质量

    • HMR热更新插件,帮助实现开发过程的快速响应。webpack会对修改的代码块重新打包,将新的模块发送到浏览器端,浏览器用新模块代替旧模块

      webpack热更新.png

vite

vite是vue3生态的构建工具,用来显著提升前端开发体验(迟钝的反馈会极大影响开发者的开发效率和幸福感),基于浏览器开始原生支持ES Module模块化规范,且越来越多的js工具使用编译型语言编写

短短的一段介绍就让我出现了以下几个疑问:

1、出现背景:

冷启动项目时项目过大启动会很慢;代码更新时webpack虽然提供了HMR,在修改了某处代码后,即使其他模块没有改动,但是却仍需要进行单模块重新打包和整个项目重新构建并重载页面,这个过程还是有明显延迟的,且会随着应用的规模增长而显著下降。

2、什么是编译型语言,对于构建项目有什么用处?

运行编译型语言是相对于解释型语言存在的,编译型语言的首先将源代码编译生成机器语言,再由机器运行机器码二进制)。像C/C++等都是编译型语言。

编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。程序执行效率高,构建项目更快。

3、使用浏览器支持es模块化规范的新特性做了什么?

最新的浏览器(除了IE,目前基本所有的浏览器都支持ESM的所有语法)开始原生支持模块功能,浏览器能最优化加载模块(详见下方5) ,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

4、vite的冷启动和热更新有什么过人之处?

冷启动可以理解为项目的首次启动。webpack的冷启动必须优先抓取并构建整个应用才能服务,vite是将应用中模块区分为依赖源码,分别作不同处理:

  • 依赖基本是不会变动的纯js库,也会有ESM或者CommonJS等多种模块化格式,vite使用esbuild预构建依赖,esbuild是多线程的且具有编译型的语言优势(相当于前置了源码编译过程)所以速度比js打包器(解释型语言先解释再运行)快很多(10-100倍)

  • 源码通常是一些需要转换的非js的文件,比如jsx、vue、css、ts组件,而且不是所有源码都需要同时被加载。vite只要在浏览器请求源码时进行转换并按需以ESM方式(第一次借助浏览器) 提供源码,让浏览器接管了打包程序的部分工作

懒加载路由.png

  • 针对页面的重新加载:vite在请求到依赖的资源后会利用HTTP头加速整个页面的重新构建,源码模块会通过浏览器(第二次借助浏览器) 进行304协商缓存;依赖模块(只有node_modules包)会进行200强缓存(磁盘缓存);webpack对应的也提供了一个叫预缓存的功能。

  • 针对HMR: vite对于项目的热更新的处理和webpack是不同的。

    • 先创建一个websocket服务端:调用ws库创建ws服务器。

    • 创建一个ws客户端接收ws服务端信息:创建一个ws client文件,在处理入口文件index.html时,就会加载ws client文件并执行,创建一个客户端ws实时接收ws服务端消息

    • 服务端监听文件变化,发送ws消息通知客户端。

    • 服务端调用HMR的更新函数,会根据此次修改文件的类型,通知客户端是要刷新还是重新加载

    • vite官方描述这个更新过程是 "链失活" 式的:

    在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活[1]大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新。

    这里我有一个疑问:vite热更新浏览器重新请求的文件实际并不是单个小模块,而是该路由页面的重新请求,更像是页面的重载?

    vite热更新.png

冷启动:webpack的多文件-->bundle.js

webpack构建.png 冷启动:vite的路由懒加载+浏览器帮助加载依赖

vite构建.png

HMR:vite的客户端ws

1655778679228.png 5、vite是怎么处理模块化规范的代码的?

vite执行的所谓的"依赖预构建":将CommonJS和UMD(适配AMD/CMD/CommonJS的规范)发布的依赖项转成ESM(相比于webpack相当于让浏览器实现源码和依赖的请求,省去了根据依赖关系生成相应代码树的过程);将内部模块的ESM依赖关系转成单个模块,减少请求,提高后续页面加载性能。

6、项目的开发和打包的优化项是不同的,一般是使用不同的config配置文件。打包优化后的代码就可以直接部署在服务器,供浏览器进行请求

总结

日常工程化搭建如果考虑兼容性使用webpack打包工具,考虑拥抱es6新特性可以使用vite,而es6模块化肯定是未来的趋势,会得到所有现代浏览器的支持。