再见二丁目 | yitimo的个人日志

再见二丁目

JavaScript模块化编程整理

修改于: 2023-09-24 20:34

模块化是为了避免所有代码逻辑都集中在同一个片段下, 而是拆分到多个模块下, 最后通过某种方式编译打包并运行. 这样做能使工程更易维护, 应用整体运行更可控. JS前端项目有非常多种模块化的方式:

TODO: 继续重新修订


本文将主要围绕webpack如何处理我们的JS代码这个问题来对JavaScript模块化编程进行梳理,包括以下几点:

  1. node环境和浏览器环境
    1. 浏览器环境模块化
  2. commonjs, amd和其他
    1. webpack的output.libraryTarget配置
  3. require/module.exports和es6 import/export
    1. 导入/导出同类模块(commonjs或es6)
    2. 导入/导出不同模块(commonjs或es6)
  4. webpack chunk和模块懒加载
    1. webpack如何处理懒加载模块
    2. webpack如何拆分chunk
  5. TypeScript和代码提示
    1. TypeScript模块
    2. .d.ts文件和类型声明

node环境和浏览器环境

我们知道node环境中直接就可以使用commonjs规范进行模块化,并根据目录/文件来区分模块。

比如./lib./lib/index.jslib.js都可以作为lib模块来使用。我们通过module.exports来导出变量并通过require来导入变量。

但是在浏览器环境中我们无法直接使用commonjs,所以当我们发现自己项目的src目录下明明使用了module.exports这些语法却能在浏览器中生效时,这一定是webpack帮我们做了些什么。

浏览器环境的JS模块化

现在准备一个最简单的基于webpack的web项目,包含一个名为calendar的模块:

/// <calendar/index.js>
const now = function () {
    const _now = new Date()
    return _now.getFullYear() +
        '-' +
        addZero(_now.getMonth() + 1) +
        '-' +
        addZero(_now.getDate()) +
        ' ' +
        addZero(_now.getHours()) +
        ':' +
        addZero(_now.getMinutes()) +
        ':' +
        addZero(_now.getSeconds())
}

function addZero(src) {
    if (typeof src === 'number') {
        src = src.toString()
    }
    return src && src.length === 1 ? ('0' + src) : src
}

module.exports = { now }

然后我们在webpack.config.js中这样配置:

/// <calendar/index.js>
const path = require('path')

module.exports = {
    mode: 'development',
    devtool: 'none',
    entry: {
        calendar: './calendar/index.js'
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'wwwroot'),
    },
}

最终编译生成的calendar.js会是这样的结构:

/// <wwwroot/calendar.js>
(function(modules){
    // ...
    // 闭包方式return出module.exports
})({
    "./calendar/index.js": (function(module, exports, __webpack_require__) {
        // ...
        // 从__webpack_require__中访问utils模块
    }),
    "./calendar/utils.js": (function(module, exports) {
        // ...
    })
})

结论就是,浏览器环境下,webpack为我们把commonjs模块进行了转义,编译成为了一个立即执行函数。


模块化规范:commonjs, amd和其他

除了上文提到的commonjs之外,还有几个模块化相关的名词容易把人绕晕:commonjs、commonjs2、amd、umd。


待补充 commonjs/commonjs2/amd各自介绍


接下来我们围绕webpack配置中的output.libraryTarget这个配置来梳理这些概念。

上文示例是在编写一个实际应用,也就是说我们的项目源码只需要在一个立即执行函数中直接运行就完成了自己的任务。但是当要编写一个第三方库时,我们无法保证其他项目也一定会使用commonjs来引入这个库,此时就需要配置libraryTarget来指定模块如何导出为一个库,其中umd方式为通用方式,即能兼容不同的模块规范,现在我们配置这个属性看看:

/// <calendar/index.js>
const path = require('path')

module.exports = {
    mode: 'development',
    devtool: 'none',
    entry: {
        calendar: './calendar/index.js'
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'wwwroot'),
        library: 'libCalendar', // +
        libraryTarget: 'umd', // +
    },
}

现在编译结果会是这样的结构:

/// <wwwroot/calendar.js>
(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)
		define([], factory);
	else if(typeof exports === 'object')
		exports["libCalendar"] = factory();
	else
		root["libCalendar"] = factory();
})(window, function() {
    return 上文编译输出的立即执行函数
})

结论就是,webpack默认将我们的模块化代码编译为兼容浏览器环境的commonjs方式输出,同时我们还可以进一步配置来完成兼容amd模块甚至将模块绑定到window等操作。


commonjs和es6 module

接下来我们改用es6模块来编写calendar库:

/// <calendar/index.js>
import addZero from './utils'

const now = function () {
    const _now = new Date()
    return _now.getFullYear() +
        '-' +
        addZero(_now.getMonth() + 1) +
        '-' +
        addZero(_now.getDate()) +
        ' ' +
        addZero(_now.getHours()) +
        ':' +
        addZero(_now.getMinutes()) +
        ':' +
        addZero(_now.getSeconds())
}

export default now

移除上文webpack.config.js中添加的两行配置:

// ...
library: 'libCalendar', // -
libraryTarget: 'umd', // -
// ...

此时编译结果的结构是这样的:

/// <wwwroot/calendar.js>
(function(modules) {
    // ...
    // 闭包方式return module.exports
})({
    "./calendar/index.js": (function(module, __webpack_exports__, __webpack_require__) {
        // 从__webpack_require__访问utils模块
        // 从__webpack_exports__导出default
    }),
    "./calendar/utils.js": (function(module, __webpack_exports__, __webpack_require__) {
        // 从__webpack_exports__导出default
    })
})

结论就是,目前的es6模块我们可以看成commonjs的语法糖,webpack会将其编译成与commonjs方式类似的输出,比如将变量导出至module.exports.default

模块混用

现在从上文结论可以得知,我们完全可以在es6模块中import一个commonjs方式导出的变量,或是在commonjs模块中require一个es6模块export的变量。目前webpack最终都会编译为commonjs方式。即:

export { A }
export default A

会编译为:

module.exports = {
    default: A,
    A: A
}

import B as defaultB, { B } from '...'

会编译为:

const utils = require('...')
const defaultB = utils.default
const B = utils.B

webpack chunk和模块懒加载

接下来看看懒加载(延迟加载)的模块是如何工作的,改造calendar模块为这样:

/// <calendar/index.js>
const now = async function () {
    const utils = await import('./utils')
    const addZero = utils.addZero
    const _now = new Date()
    return _now.getFullYear() +
        '-' +
        addZero(_now.getMonth() + 1) +
        '-' +
        addZero(_now.getDate()) +
        ' ' +
        addZero(_now.getHours()) +
        ':' +
        addZero(_now.getMinutes()) +
        ':' +
        addZero(_now.getSeconds())
}

export { now }
export default now

这样会在使用到utils模块时才加载这个模块进来,牺牲了少量的加载时间,换来了初始化时更快的加载体积拆分缩减。最终编译结果结构像这样:

/// <wwwroot/calendar.js>
(function(modules) {
    // 提供一个叫requireEnsure的函数,用于根据chunk id创建一个script标签,动态插入到document.head中
})({
    "./calendar/index.js": (function(module, __webpack_exports__, __webpack_require__) {
        // ...
        // 调用__webpack_require__执行requireEnsure的函数函数以懒加载utils模块
    })
})
/// <wwwroot/0.js>
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{
    "./calendar/utils.js": (function(module, __webpack_exports__, __webpack_require__) {
        // ...
    })
}]);

结论就是,对于懒加载的模块,会单独拆分到自己的js文件中,并通过jsonp方式(动态创建script标签,设置src并插入到head)动态执行这些拆分的js文件。

webpack如何拆分chunk

在webpack配置中,我们可以手动拆分依赖,做到node_modules和src中的代码分离,甚至分离css chunk,这样可以防止出现某个js文件过大,最终做到性能优化。 接下来我们手动造一个比较大的依赖,看看webpack如何处理它。给webpack添加如下配置:

const path = require('path')

module.exports = {
    mode: 'development',
    devtool: 'none',
    entry: {
        calendar: './calendar/index.js'
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'wwwroot'),
    },
    optimization: {
        splitChunks: {
            cacheGroups: {
                vendor: { // 即utils下的依赖会被拆分到vendor模块下作为chunk被使用
                    test: /[\\/]utils[\\/]/,
                    name: 'vendor',
                    chunks: 'all'
                },
            },
        },
    },
}

亲测目前的webpack比较“智能”,即使配置了splitChunks,若目标包不够“大”,依然不会将其拆分,在实际项目中一般依赖已经都足够大了,所以不会有这个困扰。

最终输出会得到两个js,calendar.js和vendor.js。也就是,虽然没有显式使用懒加载引入utils模块,utils依然被拆分然后以懒加载形式被使用了,此实验想要成功,一定要保证utils模块足够大,条件参考webpack文档原文:

webpack will automatically split chunks based on these conditions:

When trying to fulfill the last two conditions, bigger chunks are preferred.

另外chunk的拆分仅仅是拆分了文件,并没有改为异步加载,仍然会按顺序同步执行多个chunk。


TypeScript模块

接下来轮到TypeScript了,可以将其认为是es6的高级语法糖,有自己的编译器(tsc),通过配置定制编译规则,比如编译至es5+commonjs模块。 修改webpack添加ts支持(还需要npm i --save-dev typescript ts-loader来一发):

const path = require('path')

module.exports = {
    mode: 'development',
    devtool: 'none',
    entry: {
        calendar: './calendar/index.ts'
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'wwwroot'),
    },
    resolve: {
        extensions: ['.ts', '.js', '.json']
    },
    module: {
        rules: [
            {
                test: /\.ts$/,
                loader: 'ts-loader'
            }
        ]
    }
}

现在执行webpack打包,得到的calendar.js与之前commonjs方式是一致的:

/// <wwwroot/calendar.js>
(function(modules){
    // ...
    // 闭包方式return出module.exports
})({
    "./calendar/index.js": (function(module, exports, __webpack_require__) {
        // ...
        // 从__webpack_require__中访问utils模块
    }),
    "./calendar/utils.js": (function(module, exports) {
        // ...
    })
})

而使用TypeScript的理由可以有这么几个: