深入javascript计划八:深入浅出模块化

    技术2025-02-04  7

    传统加载

    HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。

    页面内嵌脚本:

    <script type="application/javascript"> // module code </script>

    由于浏览器脚本的默认语言是 JavaScript,因此type="application/javascript"可以省略。

    外部脚本:

    <script type="application/javascript" src="path/to/myModule.js"></script>

     异步脚本:

    <script src="path/to/myModule.js" defer></script> <script src="path/to/myModule.js" async></script>

    defer与async有什么区别?

    defer:要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行(通俗来讲就是:(顺序加载,渲染完再执行)。

    多个defer:会按照它们在页面出现的顺序加载。

    async:一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染(通俗来讲就是:乱序加载,下载完就执行)。

    多个async:脚本是不能保证加载顺序的。

    浏览器是如何加载js脚本的?

    默认情况下:浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染(如果是外部脚本,还必须加入脚本下载的时间)。

    所以我们经常把<script>标签放在后面。

    如果脚本体积很大,下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。

    这显然是很不好的体验,所以浏览器允许脚本异步加载,下面就是两种异步加载的语法:

    <script src="path/to/myModule.js" defer></script> <script src="path/to/myModule.js" async></script>

    上面代码中,<script>标签打开defer或async属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。

    模块化加载

    为什么要使用模块化?

    解决命名冲突提供复用性提高代码可维护性

    外部脚本:

    type属性设为module,所以浏览器知道这是一个 ES6 模块。

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

    如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行。 

    也可以设置成async,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。

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

    页面内嵌脚本:

    <script type="module"> import utils from "./utils.js"; // other code </script>

    注意点:

    代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。模块脚本自动采用严格模式,不管有没有声明use strict。模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。同一个模块如果加载多次,将只执行一次。

    AMD 和 CMD

    鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。

    AMD:

    define(['./a', './b'], function(a, b) { // 加载模块完毕可以使用 a.do() b.do() })

    CMD:

    define(function(require, exports, module) { // 加载模块 // 可以把 require 写在函数体的任意地方实现延迟加载 var a = require('./a') a.doSomething() })

    CommonJS

    CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS 有一些区别了。

    模块导出:

    关键字:module.exports、exports

    // foo.js // 一个一个 导出 module.exports.age = 1 module.exports.foo = function() {} exports.a = 'hello' // 整体导出 module.exports = { age: 1, a: 'hello', foo:function(){} } // 整体导出不能用`exports` 用exports不能在导入的时候使用 exports = { age: 1, a: 'hello', foo:function() {} } // 这里需要注意 exports 不能被赋值,可以理解为在模块开始前exports = module.exports, 因为赋值之后exports失去了 对module.exports的引用,成为了一个模块内的局部变量。

    模块导入:

    关键字:require

    const foo = require('./foo.js') console.log(foo.age) //1

    ES6 Module

    由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

    模块导出:

    一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。

    关键字:export

    // a.js // 声明命名导出 export var name= 'an'; export var year = 1998; // 函数导出 export function multiply(x, y) { return x * y; }; // 命名导出 var name= 'an'; var year = 1958; export { name, year }; // 重命名导出 export { name as names, year as years };

    需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

    // 报错 export 1; // 报错 var m = 1; export m; // 分割线 // 写法一 export var m = 1; // 写法二 var m = 1; export {m}; // 写法三 var n = 1; export {n as m}; // 分割线 // 报错 function f() {} export f; // 正确 export function f() {}; // 正确 function f() {} export {f};

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

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

    这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。

    注意点:

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

    模块导入:

    关键字:import

    // main.js import { name, year } from './a.js'; // 重命名 import { name as n, year as y } from './a.js';

    import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

    import {a} from './xxx.js' a = {}; // Syntax Error : 'a' is read-only;

    上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。

    import {a} from './xxx.js' a.foo = 'hello'; // 合法操作

    import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。

    注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

    foo(); import { foo } from 'my_module';

    上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

    由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

    // 报错 import { 'f' + 'oo' } from 'my_module'; // 报错 let module = 'my_module'; import { foo } from module; // 报错 if (x === 1) { import { foo } from 'module1'; } else { import { foo } from 'module2'; }

    上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。

    最后,import语句会执行所加载的模块,因此可以有下面的写法。

    import 'xxx.js';

    如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

    import 'xxx.js'; import 'xxx.js';

    整体加载

    导出:

    // a.js export function area(radius) { return Math.PI * radius * radius; } export function circumference(radius) { return 2 * Math.PI * radius; }

    导入:

    import * as circle from './a.js'; console.log('圆面积:' + circle.area(4)); console.log('圆周长:' + circle.circumference(14));

    ES Module与CommonJS区别

    CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化ES Module 会编译成 require/exports 来执行的

    参考:

    https://es6.ruanyifeng.com/#docs/module

    https://segmentfault.com/a/1190000017878394?utm_source=tag-newest

    https://juejin.im/book/5bdc715fe51d454e755f75ef/section/5bdd0d83f265da615f76ba57#heading-6

    Processed: 0.008, SQL: 9