前端模块化

302 阅读6分钟

前端模块化

模块化开发:通过将复杂代码按照功能的不同划分为不同模块单独维护的方式去提高开发效率,降低维护成本。

前端模块化的演变

按文件划分

将每个功能及相关的状态数据,单独存放在不同的文件当中,这样每一个文件就是一个模块。在HTML文件中引入这些文件,每一个script标签就对应一个模块,再代码中调用模块中的全局成员。

这种方式的缺点就是这些模块在全局范围内工作,没有独立的私有空间,可以在模块外部被任意的使用和修改。而且当全局成员多了以后就会产生命名冲突,还不能管理模块与模块之间的依赖关系。总结下就是:

  • 污染全局作用域
  • 命名冲突问题
  • 无法管理模块之间的依赖关系

命名空间方式

每个模块只暴露一个全局的对象,所有的成员都挂载到这个对象下面。

var moduleA = {
  name: 'module-a',

  method1: function () {
    console.log(this.name + '#method1')
  },

  method2: function () {
    console.log(this.name + '#method2')
  }
}

这种方式和按文件划分一样,没有私有空间,模块成员还是会被外部修改。模块之间的依赖关系依然没有解决。

IIFE(立即执行函数)

使用立即执行函数为模块提供私有空间。将成员都放在一个函数提供的私有作用域中,对于需要暴露的成员使用挂载到全局对象上的这种方式去实现。

;(function () {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})()

这种方式确保了私有变量的安全,还可以用立即执行函数的参数去当作依赖声明来使用,这样就可以让各模块之间的关系变得更加明显。

CommonJS规范和AMD规范

CommonJS规范是nodeJS中提出的一套标准,这套规范约定了以下几点:

  • 一个文件就是一个模块
  • 每个模块都有一个单独的作用域
  • 通过module.exports导出成员
  • 通过require函数载入模块

CommonJS约定了以同步模式加载模块,在浏览器端使用时每次页面加载都导致大量的同步模式请求出现,导致项目运行效率低下。所以针对浏览器端出现了AMD(Asynchronous Module Mefinition)规范,其中require.js这个库就实现了这个规范。

require.js规定每个模块都使用define函数去定义:

define([module-name?], [array-of-dependencies?], [module-factory-or-object])

该函数默认可以接收两个参数,也可以传入第三个参数。其中第一个参数就是该模块的名称;第二个参数是一个数组,里面是该模块的一些依赖项;第三个参数是一个函数,函数的参数与之前的依赖项一一对应,函数的作用是为了给当前模块提供私有空间。如果想要让该模块导出成员,可以使用return的方式返回一个对象。

required.js还提供了一个require函数来加载模块。

对于AMD,它的缺点是使用起来也有些复杂,并且模块JS文件请求频繁。

如今随着ESModules的出现,在浏览器已经可以使用ESModules来解决模块化的问题,而对于node端,还是使用commonjs规范。

ES Modules

基本特性

html文件的script标签上通过添加type = module的属性就可以以ES Module的标准来执行其中的代码。

<script type="module">
	// 标签的内部就相当于一个模块
</script>

在该类标签内部的代码中,自动采用严格模式并忽略use strict文件头

每个ESModule都是运行在单独的私有作用域中。

ESModule是通过CORS的方式请求外部JS模块,当src属性中js文件的地址不支持CORS时,会报跨域的错误。

ESMscript标签会延迟执行脚本,相当于添加了defer属性。

ES Modules导入导出

ESM的导入和导出主要是由importexport这两个关键字构成的。

// foo.js
export const foo = 'esm'  // 导出

// main.js
import { foo } from './foo.js'  // 导入

导出还可以这样:

const foo = 'esm'
function fn() {}
class Person {}

export {
	foo,
    fn as fnn,  // 重命名
    Person
}

这种方式更能直观的展示该模块导出了哪些成员

当导出的成员包含default时,其导入的时候也需要重命名:

// foo.js
const foo = 'esm'
export {
	foo as default
}

// main.js
import { default as foo } from './foo.js'

或者这种:

// foo.js
const foo = 'esm'
export default foo

// main.js
import foo from './foo.js'  // 变量名根据需要取

注意事项🚨

  1. export后面的{}是固定用法而不是对象的字面量

    const foo = 'esm'
    function fn() {}
    class Person {}
    
    export {
    	foo,
        fn as fnn,  // 重命名
        Person
    }
    

    在这个例子中export后面的{}是固定用法,就像定义函数时函数的大括号一样。import后的{}也不是对导出的成员变量的解构:

    // 1
    // 如果export后面的{}是对象的字面量(值),那么这种写法也应该会正常执行,但是却有报错
    export 'foo'
    //  所以这是错误的👆
    
    // 2
    // 如果imoprt后面的{}是解构,那么应该能够解构出这个默认导出的name属性,但是却会报错
    // foo.js
    const name = 'foo'
    export default { name }
    // another file
    import { name } from './foo.js'
    //  所以这也是错误的👆
    
  2. export导出的是变量的引用

  3. import只能出现在最顶层

    // 这种写法是错误的
    if(true) {
        import xxx from './foo.js'
    }
    

    如果想要动态导入,需要使用import函数:

    // import函数返回的是一个promise对象
    import('./foo.js').then(module => {
        // some actions
    })
    
  4. 可以直接导出导入的成员

    export { foo } from './foo.js'
    // 这样就不能在当前模块访问该成员
    
  5. 如果需要在ie下使用ESM,需要使用几个插件

    <script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
    
    <script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
    
    <script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
    
    <script type="module">
      import { foo } from './module.js'
      console.log(foo)
    </script>
    

    script标签上的nomodule属性是为了防止那些支持polyfill的浏览器重复加载。

ES Modules与CommonJS的交互(nodejs环境)

  1. commonjs导出与**esm导入**

    // commonjs.js-导出
    module.exports = {
        foo: 'commonjs export'
    }
    
    // esm.js-导入
    import foo from './foo.js'
    console.log(foo)  // 打印结果为{ foo: 'commonjs export' }
    

    使用module.exports导出的成员是可以被import导入的,当使用exports.xxx这种方法导出时:

    // commonjs.js-导出
    exports.foo: 'commonjs export'
    
    // esm.js-导入
    import foo from './commonjs.js'
    console.log(foo)  // 打印结果为{ foo: 'commonjs export' }
    

    这样也是可以的。

    commonjs只会导出一个默认成员,所以使用import导入时只能按载入默认成员的方式导入模块

  2. esm导出与**commonjs导入**

    // esm.js-导出
    export const foo = 1
    
    // commonjs.js-导入
    const foo = require('./esm.js')
    console.log(foo)  // 这里会报错
    

    这说明不能通过commonjsrequire方法来导入esm的模块

总的来说就是以下几点:

  • esm中可以导入commonjs模块
  • commonjs中不可以导入esm模块
  • commonjs始终只会导出一个默认成员

ES Modules与CommonJS的差异

esm中,若想使用__filename__dirname等这样的全局变量,需要下面这样:

import { fileURLToPath } from 'url'
import { dirname } from 'path'

const __filename = fileURLToPath(import.meta.url)  // __filename
const __dirname = dirname(__filename)  // __dirname