1、什么叫“多个模块且不包含共享模块代码的JS库”?

假设你现在要在npm上发布一个js库,你的库里有module1.jsmodule2.js2个模块,这2个模块都依赖了hex.js工具模块,如果使用普通的
打包模式打包module1.jsmodule2.js2个模块,那么module1.jsmodule2.js2个模块中都会包含hex.js工具模块,这会导致
在项目导入这2个模块后打包的体积变大,且有重复内容。

假设项目目录结构如下:

普通打包模式打包后的产物内容如图:

从上面2张图中可以看出打包后的module1.jsmodule2.js2个模块都包含了hex.js工具模块的代码。

那应该怎么打包才能实现不包含hex.js工具模块的代码?

2、实现打包多个模块不包含共享模块的代码?

原理很简单:把共享模块的代码当成当成外部扩展(Externals)

webpack配置文件中可以通过externals来配置外部扩展,它支持字符串、对象、函数、正则类型的值。符合externals的模块不会打包进最终的产物中。

实现思路描述:

1. 在webpack配置文件中配置`externals`,值为函数
2. 在`externals`函数中将"被请求引入的路径"(即`import xx from './utils.js'`中的路径)为相对路径的模块指定为外部模块即可

3、代码实现

安装依赖:npm install webpack webpack-cli ts-loader terser-webpack-plugin globby -D

其中globby用来快速获取指定文件夹下的指定多个文件的路径

3.1、模块代码

/src/module1.ts

import Hex from './utils/hex';
import { isEmptyObject, getTimeStamp } from './utils/util';
import { isValidPort, isInt } from './module2';

class ATestModule {
  static isValidPort = isValidPort;
  static getTimeStamp = getTimeStamp;
  static Hex = Hex;
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  sayName () {
    console.log(`我的名字是:${this.name}(输出时间:${getTimeStamp()})`);
  }

  checkIsEmptyObject (obj: Record<string, any>) {
    if (typeof obj !== 'object' || Array.isArray(obj)) {
      return true;
    }
    return isEmptyObject(obj);
  }
}

export { ATestModule };
export default ATestModule;

/src/module2.ts

import Hex from './utils/hex';

const intReg = /^\d+$/
/**
 * 是否为整型
 * @param val 变量s
 * @returns {boolean}
 */
export function isInt (val: any) {
  if (typeof val != 'string' && typeof val != 'number') {
    return false
  }
  return intReg.test(val + '')
}

/**
 * 校验端口是否合法
 * @param {number} value 端口号
 * @param {number} startPort 开始端口
 * @param {number} endPort 结束端口
 * @returns {{valid: boolean, msg: string}}
 */
export function isValidPort (value: number, startPort = 0, endPort = 65535) {
  let result = {
    valid: false,
    msg: ''
  }
  if ((value + '').length == 0) {
    result.msg = '请输入端口号'
    return result
  }
  if (!isInt(value)) {
    result.msg = '端口必须为正整数'
    return result
  }

  if (value < startPort || value > endPort) {
    result.msg = `端口取值区间为${startPort}-${endPort}`
    return result
  }

  result.valid = true
  return result
}

/**
 * 字节数组转成16进制字符串
 * @param {number[]} bytes
 * @returns {string}
 */
export function bytesToHex (bytes: number[]) {
  return Hex.encode(bytes, 0, bytes.length);
}

export default {
  isValidPort,
  isInt
};

/src/index.ts

import ATestModule from './module1';
import module2 from './module2';

export { ATestModule };
export default {
  ATestModule,
  module2
};

/src/utils/hex.ts

function Hex(){}

/**
 * 字节数组转16进制字符串
 * @param b 字节数组
 * @param pos 开始位置,一般为0
 * @param len 结束位置,一般为字节数组的长度
 * @returns {string}
 */
Hex.encode = function(b: number[], pos: number, len: number): string {
  var hexCh = new Array(len*2);
  var hexCode = new Array('0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F');

  for(var i = pos,j = 0;i<len+pos;i++,j++) {
    hexCh[j] = hexCode[(b[i]&0xFF)>>4];
    hexCh[++j] = hexCode[(b[i]&0x0F)];
  }

  return hexCh.join('');
}

export {
  Hex
};
export default Hex

/src/utils/util.ts

/**
 * 判断对象是否为空
 * @param obj
 * @returns {boolean}
 */
export function isEmptyObject (obj: Record<string, any>): boolean {
  let keys = Object.keys(obj);
  return keys.length == 0;
};

/**
 * 获取格式化后的时间戳
 * @returns {string}
 */
export function getTimeStamp (): string {
  let date = new Date();
  let year = date.getFullYear();
  let month = date.getMonth() + 1;
  let day = date.getDate();
  let hour = date.getHours();
  let minute = date.getMinutes();
  let second = date.getSeconds();
  let milliseconds = date.getMilliseconds();
  let result = '';
  // month = month < 10 ? ('0' + month) : (month + '');
  let dayStr = day < 10 ? ('0' + day) : (day + '');
  let hourStr = hour < 10 ? ('0' + hour) : (hour + '');
  let monthStr = month < 10 ? ('0' + month) : (month + '');
  let minuteStr = minute < 10 ? ('0' + minute) : (minute + '');
  let secondStr = second < 10 ? ('0' + second) : (second + '');
  let millisecondsStr = milliseconds < 10 ? ('0' + milliseconds) : (milliseconds + '');

  result = `${year}-${monthStr}-${dayStr} ${hourStr}:${minuteStr}:${secondStr}:${millisecondsStr}`;

  return result;
};

tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2015",
    "outDir": "./typings",
    "lib": [
      "ES2015",
      "DOM"
    ],
    "esModuleInterop": true,
    "removeComments": false,
    "noEmitOnError": true,
    "isolatedModules": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strictNullChecks": true,
    "strict": true,
    "declaration": true,
    "baseUrl": "./",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
  "include": [
    "src/**/*"
  ]
}

3.2、webpack打包配置

在项目根目录创建一个webpack.build-lib.js,代码如下:

const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');

/**
 * 获取打包配置
 * @param entry 入口文件
 * @param output webpack的output输出配置
 * @param otherConfig webpack其他配置
 */
function getWebpackConfig (entry, output, otherConfig = {}) {
  return {
    mode: "production",
    entry: entry,
    output,
    resolve: {
      extensions: ['.ts', '.tsx', '.js']
    },
    module: {
      rules: [
        {
          test: /\.ts$/, // 匹配.ts文件
          use: 'ts-loader', // 使用ts-loader处理这些文件
          exclude: /node_modules/, // 排除node_modules目录
        }
      ]
    },
    externals ({ context, request, contextInfo, getResolve }, callback) {
      // 将import时相对路径的模块视为外部依赖
      if ((request.startsWith('./') || request.startsWith('../')) && (request !== entry)) {
        // 给在导入模块时没有后缀的语句添加后缀
        if (!request.endsWith('.ts') && !request.endsWith('.js')) {
          request += '.js';
        }
        // 使用 request 路径,将一个 commonjs 模块外部化
        return callback(null, 'commonjs2 ' + request);
      }
      // 继续下一步且不外部化引用
      callback();
    },
    optimization: {
      minimize: false, // 禁用最小化(压缩)
      /* minimizer: [
        new TerserPlugin({
          terserOptions: {
            // ecma: 2015, // 配置支持 ES Module
            compress: {
              // arrows: false, // 去除箭头函数
              warnings: false,
              drop_console: true, // 去除console
              drop_debugger: true, // 去除debugger
            },
            mangle: {
              keep_classnames: true, // 保持类名不被压缩
              keep_fnames: true,     // 保持函数名不被压缩
            },
          },
          // sourceMap: false,
        }),
      ] */
    },
    ...otherConfig
  };
}

/**
 * d打包单个文件
 * @param sourceFilePath 需要打包的文件路径
 * @param config webpack配置信息
 * @param totalCount 需要打包的文件总数量,可选
 * @param currentIndex 当前打包的文件的索引,可选
 */
function buildSingleFile (sourceFilePath, config, totalCount, currentIndex) {
  const webpackConfig = getWebpackConfig(sourceFilePath, config);
  // console.log('webpackConfig', webpackConfig);
  const compiler = webpack(webpackConfig);

  return new Promise(function (resolve, reject) {
    compiler.run((err, stats) => {
      let percent = -1;
      if (!isNaN(totalCount) && !isNaN(currentIndex)) {
        percent = (currentIndex / totalCount).toFixed(2);
      }

      if (err) {
        console.error(`编译[${sourceFilePath}]失败}` + (percent > -1 ? `,进度:${percent * 100}%` : ''));
        console.error(err);
        resolve(false);
      } else {
        console.log(`编译[${sourceFilePath}]成功` + (percent > -1 ? `,进度:${percent * 100}%` : ''));
      }
      compiler.close((closeErr) => { // 要加这个回调,否则会报错
        // ...
        // console.log('关闭编译器', closeErr);
      });
      resolve(true);
    });
  });
};

const outputDir = 'lib';
/**
 * 执行打包
 * @returns {Promise<void>}
 */
async function doBuild (outputDir) {
  console.log('【执行打包lib操作】');
  // 用于模式匹配目录文件
  const globby = await import('globby');
  // 需要忽略的文件
  const needIgnoreFiles = ['!src/**/*.d.ts'];
  const configJsAndMarkdownPaths = globby.globbySync(['src/*.js', 'src/*.ts', 'src/**/*.js', 'src/**/*.ts', ...needIgnoreFiles]);
  const filesLen = configJsAndMarkdownPaths.length;
  console.log('需要打包的文件数量:', filesLen);
  const outputFileBasePath = path.resolve(__dirname, outputDir);

  const startTime = new Date().getTime();
  for ( let index = 0; index < filesLen; index++) {
    let filePath = configJsAndMarkdownPaths[index];
    const parsedFilePath = path.parse(filePath);
    const outputFilePath = outputFileBasePath + parsedFilePath.dir.replace('src', '').replaceAll('/', path.sep);
    if (!fs.existsSync(outputFilePath)) {
      fs.mkdirSync(outputFilePath, { recursive: true });
    }
    const config = {
      path: outputFilePath,
      filename: parsedFilePath.name + '.js', // 打包后的js的文件名称
      library: {
        // name: 'xxx', // 不需要加name,默认即可
        type: 'commonjs2',
        // type: 'module',
        // export: 'default', // export值不能未default,否则js导出时只能使用export default xxx形式才能生效
      }
    };
    await buildSingleFile('./' + filePath, config, filesLen, index + 1);
  }
  console.log(`编译耗时:${(new Date().getTime() - startTime)/ 1000}s`);

  const typingsDirPath = path.resolve(__dirname, './typings');
  // 删除旧的类型描述文件
  if (fs.existsSync(typingsDirPath)) {
    fs.rmSync(path.resolve(__dirname, './typings'), { recursive: true });
  }
  try {
    // 执行生成d.ts文件
    execSync('tsc --declaration --emitDeclarationOnly'/* , {
    cwd: join(__dirname, '../')
  } */)
  } catch (err) {
    console.error('生成类型描述文件报错');
    console.error(err);
  }

  // 复制描述文件
  copyDir(path.resolve(__dirname, './typings'), path.resolve(__dirname, outputDir));

  console.log(`打包完成,耗时:${(new Date().getTime() - startTime)/ 1000}s`);
};

/*
 * 复制目录、子目录,及其中的文件
 * @param src {String} 要复制的目录
 * @param dist {String} 复制到目标目录
 */
function copyDir(src, dist, ignoreDirs){
  if(!fs.existsSync(src)){
    return;
  }
  var b = fs.existsSync(dist)
  if(!b){
    fs.mkdirSync(dist, {recursive: true});//创建目录
  }
  _copy(src, dist, ignoreDirs);
}

function _copy(src, dist, ignoreDirs = []) {
  var paths = fs.readdirSync(src);
  paths.forEach(function(p) {
    if (ignoreDirs.includes(p)) {
      return;
    }
    var _src = src + '/' +p;
    var _dist = dist + '/' +p;
    var stat = fs.statSync(_src)
    if(stat.isFile()) {// 判断是文件还是目录
      fs.writeFileSync(_dist, fs.readFileSync(_src));
    } else if(stat.isDirectory()) {
      copyDir(_src, _dist, ignoreDirs);// 当是目录是,递归复制
    }
  });
}

doBuild(outputDir);

3.3、普通模式打包webpack配置

在项目根目录下创建一个webpack.build-sdk.js,代码如下:

const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: "production",
  entry: './src/module1.ts',
  output: {
    path: path.resolve(__dirname, './sdk'),
    filename: 'module1.js', // 打包后的js的文件名称
    library: {
      // name: 'module1',
      type: 'commonjs2',
    }
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  module: {
    rules: [
      {
        test: /\.ts$/, // 匹配.ts文件
        use: 'ts-loader', // 使用ts-loader处理这些文件
        exclude: /node_modules/, // 排除node_modules目录
      }
    ]
  },
  optimization: {
    minimize: false, // 禁用最小化(压缩)
    /* minimizer: [
      new TerserPlugin({
        terserOptions: {
          // ecma: 2015, // 配置支持 ES Module
          compress: {
            // arrows: false, // 去除箭头函数
            warnings: false,
            drop_console: true, // 去除console
            drop_debugger: true, // 去除debugger
          },
          mangle: {
            keep_classnames: true, // 保持类名不被压缩
            keep_fnames: true,     // 保持函数名不被压缩
          },
        },
        // sourceMap: false,
      }),
    ] */
  }
};

3.3、在package.json中添加2条打包命令

  • 普通模式打包:"build:sdk": "webpack --config webpack.build-sdk.js"
  • 多个模块共享公共模块打包:"build:lib": "webpack --config webpack.build-lib.js"

4、使用

多个模块共享公共模块打包:执行npm run build:lib命令,得到的结果如图:

普通模式打包:执行npm run build:sdk命令,得到的结果如图:


heath_learning
1.4k 声望31 粉丝