ES module

作者: anOnion | 来源:发表于2021-03-26 21:14 被阅读0次

    随着 web 应用的持续开发,我们的代码量会出现井喷式的增长;一个 web 一个 JS 成了神话。工程上自然很容易找到解决方案:就是拆分文件呗,而这一个个被拆出来的 js 文件,就是本文的话题——module。

    introduction

    在很长一段时期里,Javascript 是没有语义层面的 module 的;因为那时候 JS 文件很小、功能也不强,所以没啥必要。但是,随着技术发展,JS 功能出现了爆炸性地增长。人们不得不在工程上思考一系列的模块化方案,比较出名的有:

    • AMD:基于require.js的一个模块化方案,用于浏览器端的 JS 加载
    • CMD:稍晚于 AMD 出现的模块化方案。前者是推崇依赖前置,后者推崇就近依赖
    • CommonJS:NodeJs 使用的模块化规范
    • UMD:融合了 AMD 和 CommonJS 的一套规则方案

    当然这些都是历史话题啦,可能老派的面试官会问一下它们的区别啥的,反正现在不大可能再基于上述啥啥 MD 的方案来构件 web 应用了。主要原因是 2015 年,JS 在语言层面引入了 module 机制,我们通常称呼为 ES module;后文就主要基于 ES module 来讨论模块化方案。

    Export & Import

    ES module 笼统来说就两个关键词 exportimport

    export

    export 用于暴露当前所在某块的变量或对象(function 也是对象)。export 最常用的使用方式是把它放在数据或对象的申明前:

    // hello.js
    export function hello(name) {
      return `Hello ${name}`;
    }
    
    export const MODULES_BECAME_STANDARD_YEAR = 2021;
    

    有时候,上例 export 方式会显得较为凌乱,有些人会喜欢集中处理,如下导出方式也是合法的——顺便我们还用 as 起个了别名:

    // hello.js
    export { hello as sayHello, MODULES_BECAME_STANDARD_YEAR };
    
    function hello(name) {
      return `Hello ${name}`;
    }
    
    const MODULES_BECAME_STANDARD_YEAR = 2021;
    

    import

    import 顾名思义就是 export 的反向操作了;用于导入其他模块数据或对象。通常,我们并不需要导入依赖项的所有方法,所以最常用的是 import {...} 的方式按需导入。

    import { hello } from "./hello.js";
    console.log(hello("World")); // Hello World
    

    p.s. 按需导入的好处,主要体现在摇树(Tree shaking)上。

    上面提到,ES module 提供了一个 as 关键字起别名;上文用在了 export 阶段,import 阶段同样可以起别名:

    import { hello as sayHello } from "./hello.js";
    console.log(sayHello("World")); // Hello World
    

    如果嫌弃按需导入比较麻烦,可以一次性把依赖模块的 export 内容一次性导入——import * as

    import * as hi from "./hello.js";
    
    hi.hello("World");
    hi.MODULES_BECAME_STANDARD_YEAR;
    

    第三种常用的 import 形式叫 import "module",直接运行 "module" 里的代码,但是不会引入任何对象。

    // setupTests.js
    import "@testing-library/jest-dom";
    

    Export default

    上文提到一次性导入的话题,可是 ES module 并没有单纯的一次性导出的做法。也许你听过 export default,但它只能导出一个对象。举个例子,我们写 java 的时候一个文件只会有一个 public 的 class;虽然 javascript 并没有从语言层面限制一个文件里 class 的数量,不过从最佳实践的角度来说,通常也会写个 lint 确保这种约定俗成的规则:如 react、vue 里,我们通常只为一个文件导出一个唯一的 class:

    // Welcome.js
    export default class Welcome extends React.Component {
      render() {
        return <h1>Hello, {this.props.name}</h1>;
      }
    }
    

    注意: import * as 并不是导入依赖模块的 default 对象;导入 default 对象,我们最常用的是 import XXX from 的形式:

    import Welcome from "./Welcome";
    

    下面两种形式也合法,只是没有上面简洁:

    • import { xxx as default }

      import { Welcome as default } from "./Welcome";
      
    • import *

      import * as Welcome from "./Welcome";
      const welcome = Welcome.default;
      

    exportexport default 并没有限制在同一个 module 里合用。

    // user.js
    export default class User {
      constructor(name) {
        this.name = name;
      }
    }
    
    export function sayHi(user) {
      alert(`Hello, ${user}!`);
    }
    

    只不过我们推荐遵守以下规则:

    • export 通常用于 library,打包一系列方法;
    • export default 通常用于声明单独的实体,如一个 class。

    阶段性小结

    再简单总结一下 import 和 export 的使用方法:

    • import 有四种形式:

      • 按需导入:import { x [as y], ... } from 'module'
      • 导入所有:import * as obj from 'module'
      • 导入 default export:import x from 'module'import {default as x} from 'module'
      • 运行导入模块,但不分配对象:import 'module'
    • export 也有三种形式:

      • 声明导出: export class/function/variable
      • 别名导出: export {x [as y], ...}
      • default 导出: export default class/function/variable

    最后再提一种 import 后不作处理,立即 export 的简写形式——export ... from 'module'。import 和 export 的几种方式此时可以进行排列组合;比如,下面两种形式等价,大家也可以自行尝试其他几种组合形式。

    import { hello } from "./hello.js";
    export { hello as sayHello };
    
    export { hello as sayHello } from "./hello.js";
    

    Dynamic imports

    上文的 import 准确来说应该称为“静态导入”;它的语法特点是:只能import ... from一个字符串

    import ... from getModuleName(); // Error! only from 'string' is allowed!
    

    更罔论通过条件判断或是方法引入:

    if(...) {
      import ... // Error, not allowed!
    }
    
    function load() {
      import ...; // Error, can't pout import in any block!
    }
    

    而所谓的动态导入就是要解决静态导入的这些限制。动态导入使用了一个类似于函数的操作——import(module)——返回一个模块的 promise,从而实现在运行时加载新资源的功能。由于它“很像”函数,所以 import(module) 可以在任何作用域块中使用,如:

    const {hello} = await import('./hello.js');
    console.log(hello('World')); // Hello World
    
    if(...) {
      const User = await import('./user.js');
      const user = new User();
    }
    

    上面说一直强调 import(module) “很像”函数,这里只是说它长得像函数,也能像函数一样调用;不过 import(module) 不支持 call/apply,所以并非真正意义上的 Function。这有点类似于 arguments 只是 array like 类型,并不是 array 一个道理。我们看待 import(module) 就把它当 super()一样看好了——一个特殊的 js 语法。

    script 标签

    我曾经在《一文搞懂 script 标签》提到过 js module,我们在这里速览一遍,详细信息请移步该文

    • type = "module": 如果在浏览器里使用 ES module,需要在 script 标签里添加"module"关键字:

      <script type="module">
        import { hello } from "./hello.js";
        document.body.innerHTML = hello("Onion");
      </script>
      
    • module 的默认加载机制是 defer,不过下载过程中会顺道把 import 导入的文件也给下载了

    • nomodule: 我们通常会在 <script type="module"> 下方再写一个 <script nomodule> 标签,用于兼容老版本的浏览器

    • 外置脚本:即通过 src 属性引入外链脚本还要支持 CORS

    其他 module 特性

    import.meta

    首先想到的自然是查看当前 module 的信息喽,这些信息就放在 import.meta 里;内容不多,主要就俩:

    • import.meta.url:当前模块的 URL 路径,比如http://foo.com/bar.js
    • import.meta.scriptElement:相当于 document.currentScript,返回当前 <script> 标签的属性
    <script type="module">
      alert(import.meta.url); // script url (url of the html page for an inline script)
    </script>
    

    use strict

    module 里的 js 代码默认在文件头部添加了 use strict,非严格模式的代码会直接报错:

    <script type="module">
      a = 5; // error
    </script>
    

    顶级作用域相互独立

    每个 module 都有自己的顶级作用域;换句话说,每个模块顶级的变量和函数互不可见,只能通过 import/export 形式互相调用。

    <script type="module">
      let user = "John"; // The variable is only visible in this module script
    </script>
    <script type="module">
      alert(user); // Error: user is not defined
    </script>
    

    当然如果你实在是想共享变量,也可以强行赋值给 window,如window.user = 'Onion,提升为 global 级别的变量;不过正常情况下我们不会这么做吧。

    导入模块间共享对象

    个人觉得这个算是一个负面特性。举个例子,我们在 admin.js 里导出一个对象;该对象同时被 1.js 和 2.js 导入,我只要在其中一处修改该对象的属性,另一个文件也会受到影响。

    // admin.js
    export const admin = { name: "Onion" };
    
    // 1.js
    import { admin } from "./admin.js";
    admin.name = "Garlic";
    
    // 2.js
    import { admin } from "./admin.js";
    console.log(admin); // {name: 'Garlic'}
    

    主要原因是:ES module 虽然可被多出引入,但是只能初始化一次;结合例子就是 admin = {name: 'Onion';} 只运行一次,所有 import 的 admin 都指向 V8 引擎堆内的同一块地址了,也就导致一处改变,处处受影响了。

    解决方法也简单,写个工厂函数呗:

    export function adminFactory() {
      return { name: "Onion" };
    }
    

    this

    模块级作用域的 this === undefined;这只能算个小知识吧,大家可以自己试着跑一下:

    <script>
      alert(this); // window
    </script>
    
    <script type="module">
      alert(this); // undefined
    </script>
    

    小结

    本文介绍了现代 JS 的模块管理手段,很常规的知识;现在前端开发通常使用构件工具(webpack、esbuild 等)帮住管理这类模块,使得我们有时候会遗忘最初的浏览器设置。前几天和同事聊到 JSP => React 搬迁的事,很多人一筹莫展;我后来提了一个思路,就是不动遗产代码,只在 JSP 头部加一个 <script src="cloud.com/app.js" type="module">,然后将所有工作都移步到 React,之后只要更新 app.js 就可以逐步完成前端迁移了。大家觉得怎么样呢?

    相关文章

      网友评论

        本文标题:ES module

        本文链接:https://www.haomeiwen.com/subject/boushltx.html