js模块化

85 阅读16分钟

块内部的变量和方法是私有的,只会向外暴露一些接口,通过接口与外部进行通信

背景

CommonJS的工作原理

当使用require(模块路径)导入一个模块时,node会做以下两件事情(不考虑模块缓存):

  1. 通过模块路径找到本机文件,并读取文件内容
  1. 将文件中的代码放入到一个函数环境中执行,并将执行后module.exports的值作为require函数的返回结果

正是这两个步骤,使得CommonJS在node端可以良好的被支持

但是,CommonJS是同步的,必须要等到加载完文件并执行完代码后才能继续向后执行,当想要把CommonJS放到浏览器端时,就遇到了一些挑战:

  1. 浏览器要加载JS文件,需要远程从服务器读取,而网络传输的效率远远低于node环境中读取本地文件的效率。由于CommonJS是同步的,这会极大的降低运行性能
  1. 如果需要读取JS文件内容并把它放入到一个环境中执行,需要浏览器厂商的支持,可是浏览器厂商不愿意提供支持,最大的原因是CommonJS属于社区标准,并非官方标准

基于以上两点原因,浏览器无法支持模块化

可这并不代表模块化不能在浏览器中实现

要在浏览器中实现模块化,只要能解决上面的两个问题就行了

解决办法其实很简单:

  1. 远程加载JS浪费了时间?做成异步即可,加载完成后调用一个回调就行了
  1. 模块中的代码需要放置到函数中执行?编写模块时,直接放函数中就行了

基于这种简单有效的思路,出现了AMD和CMD规范,有效的解决了浏览器模块化的问题,之后又提出了esm的模块标准

历史

  1. 一个一个引入 加载页面后去请求大量的script,浏览器有请求限制,几千个modules,HTTP2也无济于事
  2. 全写在一个文件里 作用域的问题,变量污染,全局变量

函数作用域/对象封装

  1. IIFE

IIFE不会污染全局变量,因为不用为函数命名

模块需要暴露的方法和变量,都可以挂载都window上 => 权衡全局变量污染问题,可以使用特殊符号避免

immediately invoked function expression 立即执行函数 IIFE可以看做是两部分构成,前半部分定义了一个函数表达式,后半部分的括号这是表示运行这个函数。

(function a(){

    console.log('run a');

})();//最好加分号

var outerScope = 1;

const whatever = (function(dataWillUsedInside){

var outerScope = 0;

return {

someAttribute: 'youWantThis'

}

})(0);

// 1

console.log(outerScope);

问题:

1.不能修改某个文件,只能编译所有文件,包括没用过的

2.不能按需应用 只能引用全部的一坨

需求:

动态绑定

静态引入

模块的概念

  • 将一个复杂程序的各个部分,按照一定的规则(规范)封装不同的块(不同的文件),并组合在一起
  • 块 内部的变量和方法是私有的,只会向外暴露一些接口,通过接口与外部进行通信

非模块化存在的问题

  • 对全局变量的污染
  • 各个js文件内部变量互相修改,即只存在全局作用域,没有函数作用域
  • 各个模块如果存在依赖关系,依赖关系模糊,很难分清谁依赖谁,而依赖又必须前置
  • 难以维护

1. CommonJS

一个单独的文件就是一个模块。Node.js为主要实践者,它有四个重要的环境变量为模块化的实现提供支持:module、exports、require、global。

require 命令用于输入其他模块提供的功能,module.exports命令用于规范模块的对外接口,输出的是一个值的拷贝,输出之后就不能改变了,会缓存起来。

模块以文件维度存在,并且在编译后缓存于内存中,通过require.cache可以查看模块缓存情况

  1. 路径分析
  2. 文件定位
  3. 编译执行

原来叫ServerJS

require

module.exports >exports

值拷贝,输出值之后模块内部变化不影响这个值

  • 每个模块可以多次加载但是只会在第一次加载时运行,然后会被缓存供后续加载时使用,优先从缓存里加载
  • 按照代码出现顺序同步加载
  1. CommonJS 模块中 require 引入模块的位置不同会对输出结果产生影响,并且会生成值的拷贝
  2. CommonJS 模块重复引入的模块并不会重复执行,再次获取模块只会获得之前获取到的模块的缓存

运行时加载:输入时先加载整个模块生成一个对象,然后在这个对象上读取方法

// CommonJS模块

let { stat, exists, readfile } = require('fs');



// 等同于

let _fs = require('fs');

let stat = _fs.stat;

let exists = _fs.exists;

let readfile = _fs.readfile;

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

2.CMD/AMD

AMD推崇依赖前置,依赖也是提前就写好了,所以提前执行,提前加载, 因为是使用时用的require

CMD推崇依赖就近,依赖用的时候再require,延迟执行,用的时候在加载, 因为是定义时用的require

AMD

它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

语法

  1. 定义暴露模块
define([依赖模块名], function(依赖模块名){return 模块对象})
define(['dataService', 'jquery'], function (dataService, $) {

     var name = 'xfzhang'

     function showMsg() {

         $('body').css({background : 'red'})

         alert(name + ' '+dataService.getMsg())

     }

     return {showMsg}

 })
  1. 引入模块

AMD也采用require()语句加载模块,但是不同于CommonJS,它要求两个参数:

require([module], callback);
  • 第一个参数[module],是一个数组,里面的成员就是要加载的模块;
  • 第二个参数callback,则是加载成功之后的回调函数。
/** AMD写法 **/

RequireJS



//b.js

define(function () { // ----------------- define(function(){...}) 定义一个模块

  return 'string b'

})



//c.js

define(['./b.js'], function(res) { // --- define(['a'], function(res){...}) 定义一个有依赖的模块,c 依赖模块 b

  return res + 'c'

});



//index.html

<!DOCTYPE html>

<html lang="en">

<head>

  <script src="https://cdn.bootcss.com/require.js/2.3.6/require.min.js"></script>

</head>

<body>

  <script>

    require(['./requirejs/c.js'], function(res) { // 引入模块,并使用模块暴露的值,res 就是模块 c 暴露的值

      console.log(res, 'res')

    })

  </script>

</body>

</html>

CMD

语法

这里以sea.js为例

  1. 定义
// 所有模块都通过 define 来定义

define(function(require, exports, module) {

  // 通过 require 引入依赖

  var $ = require('jquery');

  var Spinning = require('./spinning');



  // 通过 exports 对外提供接口

  exports.doSomething = ...



  // 或者通过 module.exports 提供整个接口

  module.exports = ...

});
  1. 使用
seajs.use(['./a', './b'], function(a, b) {

  a.init();

  b.init();

});
/** CMD写法 **/

//定义没有依赖的模块

define(function(require, exports, module){

  var value = 1

  exports.xxx = value

  module.exports = value

})



//定义有依赖的模块

define(function(require, exports, module){

  var module2 = require('./module1') //引入依赖模块(同步)

  require.async('./module2', function (m3) { //引入依赖模块(异步)

  })

  exports.xxx = value // 暴露模块,也可以用module.exports

})



// 引入使用模块

//定义没有依赖的模块

define(function(require, exports, module){

  var value = 1

  exports.xxx = value

  module.exports = value

})



//定义有依赖的模块

define(function(require, exports, module){

  var module2 = require('./module1') //引入依赖模块(同步)

  require.async('./module2', function (m3) { //引入依赖模块(异步)

  })

  exports.xxx = value // 暴露模块,也可以用module.exports

})



// 引入使用模块

define(function (require) {

  var a = require('./module1')

  var b = require('./module2')

})

3.ESM

区别与以上三者需要在 进行时加载,ES6尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

官方比社区的优越之处,社区没办法自己加关键字,静态化设计思想,编译时确定依赖关系,不需要运行,前面都是运行

浏览器标准,兼容node

  1. CommonJS是运行时加载,因为只有运行时才能生成对象,从而得到对象,才可以访问对象的值
  2. ES6模块不是对象,而是通过exports显示输出的代码,通过import输入

CommonJS其实加载的是一个对象,这个对象只有在脚本运行时才会生成,而且只会生成一次,这个后面我们会具体解释。但是ES6模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成,这样我们就可以使用各种工具对JS模块进行依赖分析,优化代码。

与commonjs对比

  1. 磁盘里当作文本来扫描下
  2. 装载到内存中运行

因为JavaScript是没有编译动作的,这里的"编译时",个人倾向于称为"解析时",相对于"运行时"而言的。

下面的代码要求foo.js必须存在,因为在”解析时“要立刻读进来接续看看foo.js里面代码格式正确不正确,import不能出现在if/else里。

import foo from './foo.js'

如果是运行时,就可以像下面这样,编译时才不管foo.js存在不存在,只要这段代码格式没问题就好,在运行时才去读foo.js,没有就出错了。

if (someCondition) {

  foo = require('./foo.js');

}

commonjs 模块在引入时就已经运行了,它是“运行时”加载的;但 es6 模块在引入时并不会立即执行,内核只是对其进行了引用(生成了一个只读引用),只有在真正用到时才会被执行,这就是“编译时”加载(引擎在编译代码时建立引用)。

ES6 模块与 CommonJS 模块完全不同

它们有三个重大差异。

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。

CommonJS 模块就是对象,输入时必须查找对象属性

// CommonJS模块

let { stat, exists, readfile } = require('fs');



// 等同于

let _fs = require('fs');

let stat = _fs.stat;

let exists = _fs.exists;

let readfile = _fs.readfile;

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。

// ES6模块

import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。

模块的整体加载,用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

import * as circle from './circle'

引入default时候可以不加括号而且任意取名

本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。

import foo from './foo'

等同于

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

ES6模块的好处

(1) 静态加载,编译时加载 ----- 效率较高,可以实现( 类型检查 )等只能靠( 静态分析 )实现的功能

(2) 不再需要( 对象 )作为( 命名空间 ),未来这些功能可以通过模块提供

区别

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
  1. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”

注意,esm因为提升变量原因所以会在整个代码区域先执行,而commonjs在引入时执行

注意import语句与import()函数,import()函数类似于异步的require,支持动态加载模块 ,即运行时执行,什么时候运行到这一句,就会加载指定的模块,可以完全当成一个执行函数理解。import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。

const utils = require('../utils/utils');替换写法

import * as utils from '../utils/utils';

4.webpack

只用import不用webpack有一点性能问题,因为在浏览器从上到下读取这个JS文件的时候,首先遇到import,然后去找这个包在哪,找到相应的路径,然后验证一下这些东西还能不能用,最后把这个文件读进来,然后继续在这个文件里重复这个步骤 — 一直到所有的依赖都读完了。需要注意的是,这一切都是在runtime完成的,也就是在加载你的网页的时候。

在实际开发中,浏览器模块很少被以“原始”形式进行使用。通常,我们会使用一些特殊工具,例如 Webpack,将它们打包在一起,然后部署到生产环境的服务器。

使用打包工具的一个好处是 —— 它们可以更好地控制模块的解析方式,允许我们使用裸模块和更多的功能,例如 CSS/HTML 模块等。

构建工具做以下这些事儿:

  1. 从一个打算放在 HTML 中的 <script type="module"> “主”模块开始。
  2. 分析它的依赖:它的导入,以及它的导入的导入等。
  3. 使用所有模块构建一个文件(或者多个文件,这是可调的),并用打包函数(bundler function)替代原生的 import 调用,以使其正常工作。还支持像 HTML/CSS 模块等“特殊”的模块类型。
  4. 在处理过程中,可能会应用其他转换和优化:
  • 删除无法访问的代码。
  • 删除未使用的导出(“tree-shaking”)。
  • 删除特定于开发的像 consoledebugger 这样的语句。
  • 可以使用 Babel 将前沿的现代的 JavaScript 语法转换为具有类似功能的旧的 JavaScript 语法。
  • 压缩生成的文件(删除空格,用短的名字替换变量等)。

如果我们使用打包工具,那么脚本会被打包进一个单一文件(或者几个文件),在这些脚本中的 import/export 语句会被替换成特殊的打包函数(bundler function)。因此,最终打包好的脚本中不包含任何 import/export,它也不需要 type="module",我们可以将其放入常规的 <script>

参考

juejin.cn/post/684490…

juejin.cn/post/684490…

Babel + ts + rollup 编写一个适合2种规范格式的库

2021.4.12

es和cjs混用

一般应用npm包用2种方式,import和require。import适用于es6和es6以上语法,require使用于es6以下语法,像我们的项目中一般都是用import引入模块,因为项目已经支持了es6语法,不然就显得项目技术落后,需要升级了。

另外一种umd会被es取代,所以不再考虑

然而,import和require两种语法是不能混用的。import可以引用需要require引用的,反之则不能。

首先验证一下,的确es可以引用cjs打包的文件。但是有一点,如果要运行es6文件,需要babel.config.json + babel-node支持。其次,es可以加载cjs,只能加载cjs的default,不能加载其它的导出内容,如module.exports.a。

那么,如果我们要写一个适合2种规范格式的库,就可以写一个cjs,指定cjs默认导出default,同时供import和require两种方式加载。所以说,如果我们写的库只导出一个default,那是不必写es的。

ts项目里引用ts编译的es格式的包

诊断miauth库的导出,package.json是没有问题的,打包成cjs也是没有问题的。问题是用了ts + class + babel导致产出的class的写法有问题?提示default未定义。

  1. 通过一个新的ts项目,验证出原因,是 ts 的问题。

加入以下语法,以适配在TypeScript中适配以default引入cjs库

const midunAuth = require('./lib/midun-auth');

module.exports = midunAuth;

// Allow use of default import syntax in TypeScript

module.exports.default = midunAuth;

  1. 卡在 ts + babel + es modules,一直提示“can not use import statement outside a module”

2.1) @babel/preset-typescript是干什么的?

es6语法需要polyfill该怎么处理?

1)false,由引入者自行解决

2)entry,在入口模块导入polyfills,如import core-js、import regerator-runtime/runtime

3)usage,按需引入。

但是对于一个lib,应该按什么方式引入呢?

2.2) babel-node + @babel/preset-env + es module,是可以的。但是 + @babel/preset-typescript + ts + es module 却不行

babel-node + ts 运行时要再加上--extendsions ".ts,.tsx"才可以

zhuanlan.zhihu.com/p/102250469

zhuanlan.zhihu.com/p/65261078

  1. midun-auth-sdk最后收尾

2.1 去 console 和 注释代码,实现起来太难

2021.4.28

  1. 先发一个小米内部的包吧,

  2. 报错Cannot find module 'regenerator-runtime/runtime.js'

  3. 但是用相对路径,则没问题。把core-js改成depen也不行