• [ JS 基础 ] JS 模块化


    JS 模块化

    JS 最初的设计是没有模块化的,仅仅是单文件裸奔。。当然之前有很多模块化的实现方法,不过目前比较流行的模块化规范是 Nodejs 中 CommonJS 的模块化( 2019 年)以及 ES6 的模块化( 2015 年)

    CommonJs

    模块导出

    关键字:module.exports exports

    moduleexports都是对象类型

    最终暴露给其他模块的对象是module.exportsmodule.exports exports两者指向的内存地址是一样的(同一个引用),其实在模块加载的最开始会执行:let exports = module.exports

    导出例子:

    // 导出内容作为属性
    module.exports.name = 'js';
    module.exports.foo = () => console.log('foo');
    exports.abc = 123;
    
    // 整体导出
    module.exports = {name: 'js', foo: () => {}, abc: 123};
    // exports = {name: 'js', foo: () => {}, abc: 123};  // 不可以
    

    注意:整体导出只能使用module.exports,因为一旦是整体导出,是用一个对象去替换module.exports 的指向。如果用exports来整体导出,并没有改变module.exports的指向,细品上面的粗体。

    模块导入

    关键字:require

    导入例子:require('./moduleA')

    导入规则

    相对路径的情况下,在导入的目录下寻找moduleA

    • 无后缀名的情况按照 JavaScript 解析
    • moduleA.json按照 json 解析
    • moduleA.node按照加载的编译插件模块dlopen

    如果同一级目录下没有moduleA文件,则会去找同级的moduleA目录

    • 如果有moduleA/package.json会找其中main指向的入口文件,如果寻找失败, fallback 到寻找下面的文件
    • moduleA/index.js
    • moduleA/index.json
    • moduleA/index.node

    绝对路径的情况下,同上

    没有开头路径的模块,比如vue

    • 首先判断是否是核心内置模块(比如:fs path),是就直接导入
    • 如果不是,会从当前项目中最近的node_module下找按照上面有路径的情况找文件。
    • 如果没找到 继续向父目录的node_modules中找

    模块实现

    Node的模块实际上可以理解为代码被包裹在一个函数包装器里面

    **require wrapper: **(从知乎上抄的)

    function wrapper (script) {
        return '(function (exports, require, module, __filename, __dirname) {' + 
            script +
         '
    })'
    }
    
    function require(id) {
     var cachedModule = Module._cache[id];
      if(cachedModule){
        return cachedModule.exports;
      }
    
      const module = { exports: {} }
    
      // 这里先将引用加入缓存
      Module._cache[id] = module
    
      //当然不是eval这么简单
      eval(wrapper('module.exports = "123"'))(module.exports, require, module, 'filename', 'dirname')
    
    
      return module.exports
    }
    

    类似源码( TODO )的简单实现吧,主要关键是会缓存模块

    • 模块只执行一次之后调用获取的 module.exports 都是缓存,哪怕这个 js 还没执行完毕(因为先加入缓存后执行模块)
    • 模块导出就是return这个变量的其实跟a = b赋值一样(原因我认为是在导出的时候基本类型也是直接赋值拷贝的,exports.aaa = aaa), 基本类型导出的是引用类型导出的是引用地址
    • exportsmodule.exports 持有相同引用,因为最后导出的是 module.exports, 所以对exports进行赋值会导致exports操作的不再是module.exports的引用

    注意这个缓存,在多次导入同一个模块的时候

    1. 会先判断是否已经缓存过这个模块(对象)
    2. 如果是没有缓存,将 module 先加入缓存(拷贝的方式:注意引用类型),执行这个 module (初始化),返回module.exports对象。
    3. 总之要注意多次导入的其实是同一个缓存中的对象。

    可以写一下试一试

    // moduleA.js
    let abc = {x: 'abc'}
    console.log('before: ', abc)
    setTimeout(() => {
      abc.x = 'bbbbbbbbxxxxx'
      console.log('after 3s: ', abc)
    }, 3000);
    module.exports = abc
    
    // index.js
    const abc = require('./moduleA')
    console.log('from index: abc', abc);
    setTimeout(() => {
      console.log('after 3.5s : abc', abc);
    }, 3500);
    setTimeout(() => {
      const abcc = require('./moduleA')
      console.log('next require: abc', abcc);
    }, 5000);
    

    P.S. CommonJS 的模块化是动态的,运行时导入模块的对象(那一套缓存)

    以及,在 CommonJS 中的this指向的就是modules.exports,情理之中,意料之外,想了想是这么回事。。

    ES6 Module

    参考阮一峰老师:https://es6.ruanyifeng.com/#docs/module

    ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

    核心:静态

    使用 CommonJS 模块是运行时加载

    const { resolve } = require('path');
    
    // 等价于
    const _path = require('path');
    const resolve = _path.resolve;
    

    ES6 编译时加载(或者静态加载)

    import { resolve } from 'path';
    

    这样以来不仅效率高了,而且仅加载一个方法。 不过这也导致了没法引用 ES6 模块本身,因为它不是对象。

    严格模式( ES5 )

    ES6 的模块自动采用严格模式

    严格模式主要有以下限制。

    • 变量必须声明后再使用
    • 函数的参数不能有同名属性,否则报错
    • 不能使用with语句
    • 不能对只读属性赋值,否则报错
    • 不能使用前缀 0 表示八进制数,否则报错
    • 不能删除不可删除的属性,否则报错
    • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
    • eval不会在它的外层作用域引入变量
    • evalarguments不能被重新赋值
    • arguments不会自动反映函数参数的变化
    • 不能使用arguments.callee
    • 不能使用arguments.caller
    • 禁止this指向全局对象
    • 不能使用fn.callerfn.arguments获取函数调用的堆栈
    • 增加了保留字(比如protectedstaticinterface

    export

    将某个变量给外部开放使用接口

    几种写法

    // 
    export var a = 1;
    export function foo() {};
    
    // 打包导出
    const c = '123';
    function b() {};
    export {
    	c as cc,
      b,
    }
    // 只能这样写 不然语法会报错
    

    export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

    export var foo = 'bar';
    setTimeout(() => foo = 'baz', 500);
    

    最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

    import

    import { resolve } from 'path';
    

    接收大括号,对应模块中导出的变量名

    当然也可以用 as 重新取个名字

    所有import进来的都是 read-only 的,也就是 const 的,但是如果是引用类型,你知道可以怎么做吧。

    注意:

    • import是会提升的(因为在编译阶段执行)

    • 不要在import的时候使用动态的构造,比如import {'f' + 'oo'} from 'foo',这些都是runtime的东西。。

    • 只写import xxx,只会执行这个模块,多次写也只会执行一次

    • import { foo } from 'my_module';
      import { bar } from 'my_module';
      
      // 等同于
      import { foo, bar } from 'my_module';
      // 对应的是同一个 my_module
      
    • import是单例模式

    export default

    为模块指定默认输出,用户不需要知道这个模块有什么东西,一股脑的导入即可,可以任意命名。

    // import-default.js
    import customName from './export-default';
    customName(); // 'foo'
    

    export default之后的有名字的函数都“名亡实存”了

    注意:

    • 只能出现一次
    • 可以和普通export并存

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

    export const e = '123123'
    
    export {
        e as default
    }
    // 等价于
    // export default e
    
    import $ from 'lodash';  // 鲁大师也能是 dollar
    

    其实通过转码之后的export default会编译为exports.default = xxxx

    export 与 import 的复合写法

    如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。

    export { foo, bar } from 'my_module';
    // 向外转发了这两个接口 实际并没有导入到当前模块
    
    // 可以简单理解为
    import { foo, bar } from 'my_module';
    export { foo, bar };
    

    用到这样的场景?

    // 接口改名
    export { foo as myFoo } from 'my_module';
    
    // 整体输出
    export * from 'my_module';
    

    其他的情况见阮一峰

    import()

    import是在编译时静态处理(提升到最前处理),那么我们想动态的导入模块怎么办呢?

    import命令叫做“连接” binding 其实更合适

    为了提升动态性,ES2020 提案引入import()函数

    import(specifier).then(module => {
      // ...
    }).catch(err => {
      // ...
    })
    

    返回一个 Promise 。

    import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Node 的require方法,区别主要是前者是异步加载,后者是同步加载。

    适用场景

    • 按需加载(懒加载): vue-router 的懒加载就传入一个 import 函数即可
    • 条件加载
    • 动态路径加载

    注意

    • 加载后这个模块会作为一个对象
    • 可以用结构的方式获取属性
    • default属性获取默认导出的变量:
      • .then(({default: ddd}) => {...}) ddd 就是这个模块
      • asyncawait 的时候同样 const { default: _ } = await import('lodash');
      • 其实就是整个模块对象有个 default 的属性。
    • 也可以在async函数里面用!
    • 多个模块导入可以用Promise.all()

    浏览器支持 ES Module

    <script type="module" src="./foo.js"></script>
    

    浏览器加载模块(type="module")的时候是异步处理的

    <script type="module" src="./foo.js"></script>
    <!-- 等同于 -->
    <script type="module" src="./foo.js" defer></script>
    

    也可以给async属性让他异步加载

    其实看到这样调用模块有点迷惑,其实在foo.js这个文件中就是一个有import语句的 ES Module

    也可以直接让 js 标签作为模块语法

    <script type="module">
      import $ from "./jquery/src/jquery.js";
      $('#message').text('Hi from jQuery!');
    </script>
    

    注意:代码是有 module 作用域的,自动use strict

    Node.js 中使用 ES Module

    由于 CommonJS 和 ES 不兼容,所以比较麻烦,但是在 v13.2 版本开始支持了

    Node.js 要求 ES6 模块采用.mjs后缀文件名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"

    如果不希望改成.mjs的话,可以在package.json中声明

    {
       "type": "module" // 默认是 commonjs
    }
    

    此时还想用 CommonJS 的话,文件后缀名改为.cjs

    main

    package.json 中指定入口文件,没有"type": "module"的时候会解析为 CommonJS 模块

    {
      "main": "./src/index.js"
    }
    

    exports

    优先级比main,有多种用法

    • 子目录别名:

      {
        "exports": {
          "./submodule": "./src/submodule.js"
        }
      }
      

      ./src/submodule.js指定别名为./submodule,别人可以直接导入了import xx from 'myModule/submodule'

    • main 的别名:

      如果别名是.

      {
        "exports": {
          ".": "./main.js"
        }
      }
      
      // 等同于
      {
        "exports": "./main.js"
      }
      

      由于exports字段只有支持 ES6 的 Node.js 才认识,所以可以用来兼容旧版本的 Node.js。

      {
        "main": "./main-legacy.cjs",
        "exports": {
          ".": "./main-modern.cjs"
        }
      }
      

    循环引入

    CommonJS

    由于动态导入的机制,如果 a 模块要引入 b,b 中也引入 a,我们在 main 函数中先导入 a,执行 a 模块代码的过程中发现引入了 b,会去执行 b,在 b 中发现引入了 a,但此时 a 没有执行完毕,所以 b 中拿到 a 的数据只是一部分的缓存,回到 a 后可能会出错。

    所以我们要避免写

    const foo = require('a').foo		//	此时可能 a 还没有执行完毕
    

    ES Module

    直接看例子

    // a.mjs
    import {bar} from './b';
    console.log('a.mjs');
    console.log(bar);
    export let foo = 'foo';
    
    // b.mjs
    import {foo} from './a';
    console.log('b.mjs');
    console.log(foo);
    export let bar = 'bar';
    

    执行 a.mjs 会发生什么:发现 import 了 b,所以先去执行 b 了,发现 import 了 a,此时 a 以及是判断执行过了,所以认为 foo 是获取到的接口,然后继续执行到第 10 行发现 foo 实际上没有定义,就报错了。

    解决方法:由于都用的是引用,用函数返回就行了,用的时候再取值

    再看一个

    // even.js
    import { odd } from './odd'
    export var counter = 0;
    export function even(n) {
      counter++;
      return n === 0 || odd(n - 1);
    }
    
    // odd.js
    import { even } from './even';
    export function odd(n) {
      return n !== 0 && even(n - 1);
    }
    

    执行一下

    $ babel-node
    > import * as m from './even.js';
    > m.even(10);
    true
    > m.counter
    6
    > m.even(20)
    true
    > m.counter
    17
    

    改成 CommonJS 就不能了

    // even.js
    var odd = require('./odd');
    var counter = 0;
    exports.counter = counter;
    exports.even = function (n) {
      counter++;
      return n == 0 || odd(n - 1);
    }
    
    // odd.js
    var even = require('./even').even;		// 这里上面此时还没执行完,是undef
    module.exports = function (n) {
      return n != 0 && even(n - 1);
    }
    // var m = require('./even');
    // m.even(10)
    // TypeError: even is not a function
    

    上面例子来自阮一峰老师

    对比

    CommonJS 的模块化是动态的,运行时导入(那一套缓存)

    ES module 是静态的连接,在编译成 AST 的时候导入/输出接口

    CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的只读引用注意基本非引用类型,引用类型无所谓的都是指针,想改就改。。

    所以在不同 ES 模块中导入同一个导出的实例都是同一个,比如 Vue,这很关键

    CommonJS 中的 thismodule.exports,ES 模块是undefined

    其次,以下这些顶层变量在 ES6 模块之中都是不存在的。

    • arguments
    • require
    • module
    • exports
    • __filename
    • __dirname

    小结

    感觉有些方面模块化这种东西大同小异,比如类比 Python , __init__.py 文件就相当于是 JS 中的 index.js 所以一个目录都可以是一个 module 或者叫 package ,但是我还是觉得 Python 中用 . 作为子模块的连接比较舒服(前提是所有路径都是模块)。

    扩展

    rollup 和 webpack 的区别 使用场景:https://zhuanlan.zhihu.com/p/75717476

    思考我们打包的场景,适合用什么打包工具去打包:

    • Web App: webpack
    • js 类库: rollup
  • 相关阅读:
    MVC模式下 provider: SQL Network Interfaces, error: 50
    How to expose a JSON endpoint from a WCF-service
    net 后台任意设置 控件显示和隐藏就OK
    VS编程,快速折叠或者展开代码到 #region 级别的设置方法。
    java进阶(18)--Enum枚举
    java进阶(17)--Random
    java进阶(16)--System常用方法总结
    java进阶(15)--DecimalFormat、BigDecimal
    java进阶(14)--日期时间处理
    java进阶(13)--int、String、Integer互相转换
  • 原文地址:https://www.cnblogs.com/coyote-waltz/p/13189256.html
Copyright © 2020-2023  润新知