模块别名

模块别名(Module Alias)或者叫路径重映射(Remapping)是前端的模块化进程中很常见的一个需求,由来已久,无论是之于 AMD 规范的 RequireJS,还是之于 ES Module 规范的 Import Maps 提案,都在解决该问题。

RequireJS

RequireJS 的 paths 主要用于当某个依赖加载失败后进行回退。

requirejs.config({
  //To get timely, correct error triggers in IE, force a define/shim exports check.
  enforceDefine: true,
  paths: {
    jquery: [
      'http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min',
      //If the CDN location fails, load from this location
      'lib/jquery'
    ]
  }
});

//Later
require(['jquery'], function ($) {});

参见 paths config fallbacks

Sea.js

Sea.js 已经退出历史舞台,相比 RequireJS,它的配置更为丰富。

seajs.config({
  // 设置路径,方便跨目录调用
  paths: {
    arale: 'https://a.alipayobjects.com/arale',
    jquery: 'https://a.alipayobjects.com/jquery'
  },

  // 设置别名,方便调用
  alias: {
    class: 'arale/class/1.0.0/class',
    jquery: 'jquery/jquery/1.10.1/jquery'
  }
});

参见 API 快速参考配置

Node.js

除了客户端,服务端 JS 也有使用模块别名的需求,所以出现了 module-alias 之类的 npm 包,通过修改 Node 内部的 Module._resolveFilename()Module._nodeModulePaths() 方法实现模块别名。

// Node 项目入口
require('module-alias/register');
// package.json
{
  "_moduleAliases": {
    "@root": ".", // Application's root
    "@deep": "src/some/very/deep/directory/or/file",
    "@my_module": "lib/some-file.js",
    "something": "src/foo" // Or without @. Actually, it could be any string
  }
}

这样可以将:

require('../../../../some/very/deep/module');

简写成:

var module = require('@deep/module');

webpack resolve.alias

webpack 的模块解析也支持配置别名,参见 Resolve

// webpack.config.js
const path = require('path');

module.exports = {
  //...
  resolve: {
    alias: {
      Utilities: path.resolve(__dirname, 'src/utilities/'),
      Templates: path.resolve(__dirname, 'src/templates/')
    }
  }
};
import Utility from '../../utilities/utility';

可以被简化为:

import Utility from 'Utilities/utility';

Rollup @rollup/plugin-alias

Rollup 也支持通过插件 @rollup/plugin-alias 实现别名功能。

// rollup.config.js
import alias from '@rollup/plugin-alias';

module.exports = {
  input: 'src/index.js',
  output: {
    dir: 'output',
    format: 'cjs'
  },
  plugins: [
    alias({
      entries: [
        { find: 'utils', replacement: '../../../utils' },
        { find: 'batman-1.0.0', replacement: './joker-1.5.0' }
      ]
    })
  ]
};

Babel

除了在模块加载器(Loader)和打包器(Bundler)上做文章,还可以在语法转译层面实现。

比如 babel-plugin-module-alias 插件(现已更名为 babel-plugin-module-resolver)。允许编写的时候使用路径别名,编译后处理为完整路径。

// .babelrc.json
{
  "plugins": [
    [
      "module-resolver",
      {
        "root": ["./src"],
        "alias": {
          "test": "./test",
          "underscore": "lodash"
        }
      }
    ]
  ]
}

或者使用 babel-plugin-root-import

// .babelrc.json
{
  "plugins": [
    [
      "babel-plugin-root-import",
      {
        "rootPathPrefix": "@"
      }
    ]
  ]
}

TypeScript

TypeScript 支持 Path Mapping,通过配置 baseUrlpaths 实现模块别名。

以下是官方文档的一些例子:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".", // This must be specified if "paths" is.
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl"
    }
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["*", "generated/*"]
    }
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
        "app/*": ["app/*"],
        "config/*": ["app/_config/*"],
        "environment/*": ["environments/*"],
        "shared/*": ["app/_shared/*"],
        "helpers/*": ["helpers/*"],
        "tests/*": ["tests/*"]
    },
}

需要注意 TypeScript 的编译器 tsc 编译产出的 JS 文件,依然保留原始的模块路径,并不会替换为映射后的路径。

对此,社区实现了自定义 transformer 来处理 paths 配置,只不过需要依赖 ts-loader 等工具,或者使用支持 transformer 的 TypeScript 版本 ttypescript

ts-transform-paths

ts-transform-paths 是常见的 transformer,可以在 tsc 编译时替换映射路径,需要配合 webpack/rollup/ttypescript/ts-node 使用。

// webpack.config.js
const pathsTransformer = require('ts-transform-paths').default;

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: 'ts-loader',
        options: {
          getCustomTransformers: program => pathsTransformer()
        }
      }
    ]
  }
};

react-native-typescript-transformer

react-native-typescript-transformer 的使用场景是 React Native,多亏了 React Native CLI 底层的 Metro 打包器支持自定义 transformer。

// metro.config.js
module.exports = {
  transformer: {
    babelTransformerPath: require.resolve('react-native-typescript-transformer')
  }
};

其他解决方案

Gulp gulp-ts-alias

在 Gulp 中使用 gulp-typescript 时,可以通过 gulp-ts-alias 实现别名。

// gulpfile.js
const typescript = require('gulp-typescript');
const sourcemaps = require('gulp-sourcemaps');
const alias = require('gulp-ts-alias');

const project = typescript.createProject('tsconfig.json');

function build() {
  const compiled = src('./src/**/*.ts')
    .pipe(alias({ configuration: project.config }))
    .pipe(sourcemaps.init())
    .pipe(project());

  return compiled.js
    .pipe(
      sourcemaps.write({
        sourceRoot: file =>
          path.relative(path.join(file.cwd, file.path), file.base)
      })
    )
    .pipe(dest('build/'));
}

tscpaths

tscpaths 可以在 tsc 编译时替换映射路径。

tsc --project tsconfig.json && tscpaths -p tsconfig.json -s ./src -o ./out

tsc-alias

tsc-alias Fork 自 tscpaths,也是在 tsc 编译时替换映射路径。

tsc --project tsconfig.json && tsc-alias -p tsconfig.json

TypeScript + Node.js

tsconfig-paths

tsconfig-paths 可以让 Node(以及 ts-node)执行代码时,识别 tsconfig.json 中的 paths 配置。原理同 module-alias

npm i -D tsconfig-paths
ts-node -r tsconfig-paths/register src/index.ts

tsconfig-paths-webpack-plugin

tsconfig-paths-webpack-plugin 集成 tsconfig-paths 功能的 webpack 插件,可以省去配置 resolve.alias

// webpack.config.js
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  //...
  resolve: {
    plugins: [new TsconfigPathsPlugin({})]
  }
};

ESLint

ESLint 默认不识别 webpack 或者 tsconfig.json 配置的别名,需要借助社区插件:

npm i -D eslint
npm i -D eslint-plugin-import
npm i -D eslint-import-resolver-typescript

eslint --ext .js,.ts src
// .eslintrc.js
/** @type {import('eslint').Linter.Config} */
module.exports = {
  parser: '@typescript-eslint/parser',
  env: {
    browser: true,
    node: true
  },
  extends: [
    'eslint:recommended',
    'plugin:import/recommended',
    'plugin:import/typescript'
  ],
  settings: {
    'import/resolver': {
      // 配置 `eslint-import-resolver-typescript`
      typescript: {
        project: '.'
      }
    }
  }
};

Import maps

目前 ES Module 尚不支持模块别名,但已经有相关提案。

<script type="importmap">
  {
    "imports": {
      "moment": "/node_modules/moment/src/moment.js",
      "lodash": "/node_modules/lodash-es/lodash.js"
    }
  }
</script>

借助 Import maps 定义,如下代码:

import moment from 'moment';
import { partition } from 'lodash';

相当于:

import moment from '/node_modules/moment/src/moment.js';
import { partition } from '/node_modules/lodash-es/lodash.js';

补充

webpack CSS 相关的 loader 支持以 ~ 开头表示 node_modules,如 @import '~antd/dist/antd.css'。新版已经废弃这种写法,不需要加 ~,默认会尝试解析 node_modules 目录。

Bash 中,~ 表示用户 HOME 目录。

相关链接:

总结

使用哪种模块别名的实现,取决于实际使用场景,无论是单纯地使用 Babel 或者 tsc 做语法转译,还是使用 webpack、Rollup 打包,或者后期加载模块,都有对应的解决方案。

练习

思考一下以下几种路径别名的区别:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
        "@/*": ["src/*"],
        "@components/*": ["src/components/*"],
        "@components": ["src/components"],
    },
}

对应使用场景:

import Tabs from '@/components/tabs'; // 匹配 '@/*',映射为 'src/components/tabs'
import Button from '@components/button'; // 匹配 '@components/*',映射为 'src/components/button'
import { Table } from '@components'; // 匹配 '@components',映射为 'src/components'