使用 babel-plugin-macros 解决自动引入的问题

关于 babel-plugin-macros 的介绍,简单可以总结为一句话:“在代码中显式声明需要在编译时需要做的事情”。
为什么需要“显式”?官方文档有介绍使用 babel plugin 方式面临的如下问题:

  1. They can lead to confusion because when looking at code in a project, you might not know that there’s a plugin transforming that code.
  2. They have to be globally configured or configured out-of-band (in a .babelrc or webpack config).
  3. They can conflict in very confusing ways due to the fact that all babel plugins run simultaneously (on a single walk of Babel’s AST).

最近刚好碰上个需求适合 macros 的场景,限制如下:

  1. 一个插件系统,插件由很多部件组成
  2. A 部件可能在系统的多个地方被使用
  3. A 部件根据不同的场景需要引入不同的代码
  4. 消费 A 部件的地方只接收同步代码
  5. 场景不感知插件细节,由插件管理器统一调度

理论上来说,插件中 A 部件的声明可以为如下形式:

import module1 from 'xxxx';
import module2 from 'yyyy';

const plugin = {
    A: {
        scence1: module1,
        scence2: module2,
    }
}

这样,在使用 A 部件时,根据场景取就可以了。

但这样引入了一个问题,场景是互斥的,这里所有场景的代码却被同时引入了,增大了包体积,影响性能。

所以,我们期望以如下形式来使用:

// plugin.ts
const plugin = {
    A: {
        scence1: 'module1 path',
        scence2: 'module2 path',
    }
}

// scence1.ts
pluginManager.usePlugin('A', 'module1 path')

当然,这代码并不能 work,因为只是声明了模块的路径,并未引入代码,我们期望代码被编译为:

import someRandomName from  'module1 path';
pluginManager.usePlugin('A', someRandomName);

借助babel-plugin-macros可以“轻松”(不熟悉 ast 不太轻松)实现该需求,定义如下接口:

/**
 * 为指定的文件路径绑定标识符
 * @param identifier
 * @param filePath
 */
declare function declare(identifier: string, filePath: string): void;

/**
 * 通过文件标识符使用自动导入的文件
 * @param identifier
 */
declare function use<T=any>(identifier: string): T;

export = {
  declare,
  use,
};

使用:

import macro from 'plugin.macro';

// 在插件中绑定
macro.declare('scence1', 'path to module1');

// 在场景中使用
macro.use('scence1');

完整实现:

ps: 由于不熟悉 babel 的 AST 操作,许多逻辑都抄抄改改自 import-all.macro

const { createMacro } = require('babel-plugin-macros');
const path = require('path');
const fs = require('fs');

module.exports = createMacro(macro);

// 全局的映射文件
const importMap = new Map();

function macro({ references, ...macroOptions }) {
  references.default.forEach((referencePath) => {
    if (
      referencePath.parentPath.type === 'MemberExpression' &&
      referencePath.parentPath.node.property.name === 'declare'
    ) {
      declare({ referencePath, ...macroOptions });
    } else if (
      referencePath.parentPath.type === 'MemberExpression' &&
      referencePath.parentPath.node.property.name === 'use'
    ) {
      use({ referencePath, ...macroOptions });
    } else if (
      referencePath.parentPath.type === 'MemberExpression' &&
      referencePath.parentPath.node.property.name === 'useNamespace'
    ) {
      useNamespace({ referencePath, ...macroOptions });
    } else {
      throw new Error(`only support 'declare' and 'use' method`);
    }
  });
}

// 收集路径声明
function declare({ referencePath, state, babel }) {
  const { types: t } = babel;
  const {
    file: {
      opts: { filename },
    },
  } = state;

  const callExpressionPath = referencePath.parentPath.parentPath;
  let fileIdentifier;
  let relativePath;
  let namespace;
  try {
    fileIdentifier = callExpressionPath.get('arguments')[0].evaluate().value;
    relativePath = callExpressionPath.get('arguments')[1].evaluate().value;
    namespace = callExpressionPath.get('arguments')[2].evaluate().value;
  } catch (error) {
    // ignore the error
  }
  if (!relativePath || !fileIdentifier) {
    throw new Error(
      `params error: ${callExpressionPath.getSource()}. expect: (identifier: string, filePath: string)`,
    );
  }
  const absolutePath = path.join(filename, '../', relativePath);
  const exits = fs.existsSync(absolutePath);
  if (!exits) {
    throw new Error(`${absolutePath} not exits.`);
  }
  const isFile = fs.lstatSync(absolutePath).isFile();
  if (!isFile) {
    throw new Error(`${absolutePath} is not a file.`);
  }
  if (
    importMap.has(fileIdentifier) &&
    importMap.get(fileIdentifier) !== absolutePath
  ) {
    console.warn(
      `${fileIdentifier} already defined with plugin ${importMap.get(
        fileIdentifier,
      )}`,
    );
  }
  importMap.set(fileIdentifier, absolutePath);
  if (namespace) {
    let files = []
    if (importMap.has(namespace)) {
      files = importMap.get(namespace);
    }
    // 热重载可能导致重复添加
    if (!files.find((id, p) => id === fileIdentifier && p === absolutePath)) {
      files.push([fileIdentifier, absolutePath]);
    }
    importMap.set(namespace, files);
  }
  callExpressionPath.replaceWith(t.expressionStatement(t.stringLiteral('')));
}

// 使用
function use({ referencePath, state, babel }) {
  const { types: t } = babel;
  const callExpressionPath = referencePath.parentPath.parentPath;
  let fileIdentifier;
  try {
    fileIdentifier = callExpressionPath.get('arguments')[0].evaluate().value;
  } catch (error) {
    // ignore the error
  }
  if (!fileIdentifier) {
    throw new Error(`params error: ${callExpressionPath.getSource()}.`);
  }
  if (!importMap.has(fileIdentifier)) {
    throw new Error(
      `can't find identifier:${fileIdentifier}`,
    );
  }
  const absolutePath = importMap.get(fileIdentifier);
  const id = referencePath.scope.generateUidIdentifier(absolutePath);
  const importNode = t.importDeclaration(
    [t.importDefaultSpecifier(id)],
    t.stringLiteral(absolutePath),
  );
  const program = state.file.path;
  program.unshiftContainer('body', importNode);
  callExpressionPath.replaceWith(id);
}

// 使用命名空间
function useNamespace({ referencePath, state, babel }) {
  const { types: t } = babel;
  const callExpressionPath = referencePath.parentPath.parentPath;
  let namespace;
  try {
    namespace = callExpressionPath.get('arguments')[0].evaluate().value;
  } catch (error) {
    // ignore the error
  }
  if (!namespace) {
    throw new Error(`params error: ${callExpressionPath.getSource()}.`);
  }
  const files = importMap.get(namespace) || [];

  const objectProperties = files.map(([id, absolutePath]) => {
    const localIdentifier = t.identifier(id);
    const importNode = t.importDeclaration(
      [t.importSpecifier(localIdentifier, t.identifier('default'))],
      t.stringLiteral(absolutePath),
    );
    state.file.path.unshiftContainer('body', importNode);
    return t.objectProperty(t.stringLiteral(id), localIdentifier);
  });
  const objectExpression = t.objectExpression(objectProperties)
  callExpressionPath.replaceWith(objectExpression);
}

PS: 后面发现该方案在开启 babel 缓存后存在重大缺陷。缓存+副作用,你品,你细品。。。