重构别名为@的导入路径为相对路径

25
在使用Webpack、TypeScript或其他转换ES模块导入的工具的模块化环境中,路径别名是常用的约定之一,其中一个常见的约定是使用@代替src

对于我来说,将带有别名的绝对路径项目进行转换是一个经常遇到的问题:

src/foo/bar/index.js

import baz from '@/baz';

转换为相对路径:

src/foo/bar/index.js

import baz from '../../baz';

例如,一个使用别名的项目需要与另一个不使用别名的项目合并,由于样式指南或其他原因,配置后者使用别名不是一个选项。这不能通过简单的搜索和替换来解决,手动修复导入路径繁琐且容易出错。我希望原始的JavaScript / TypeScript代码库在其他方面保持不变,因此使用转译器进行转换可能不是一个选项。我想使用我选择的IDE(Jetbrains IDEA / Webstorm / Phpstorm)实现这种重构,但也接受任何其他IDE(VS Code)或纯Node.js的解决方案。如何实现这一目标?
4个回答

25

三种将别名导入重写为相对路径的可能解决方案:

1. babel-plugin-module-resolver

使用babel-plugin-module-resolver,同时省略其他babel插件/预设。

.babelrc
"plugins": [
  [
    "module-resolver",
    {
      "alias": {
        "^@/(.+)": "./src/\\1"
      }
    }
  ]
]

构建步骤: babel src --out-dir dist (输出到dist文件夹,不会修改原文件)

// input                                // output
import { helloWorld } from "@/sub/b"    // import { helloWorld } from "./sub/b";
import "@/sub/b"                        // import "./sub/b";
export { helloWorld } from "@/sub/b"    // export { helloWorld } from "./sub/b";
export * from "@/sub/b"                 // export * from "./sub/b";

对于TS,您还需要@babel/preset-typescript,并通过 babel src --out-dir dist --extensions ".ts" 激活.ts扩展名。

2. 使用正则表达式的jscodeshift Codemod

应支持来自MDN文档和相关的所有导入/导出变体。该算法的实现如下:
1. 输入:路径别名映射,形式为alias -> resolved path,类似于TypeScript的tsconfig.json paths或Webpack的resolve.alias
const pathMapping = {
  "@": "./custom/app/path",
  ...
};

2. 迭代所有源文件,例如遍历 src 目录:

jscodeshift -t scripts/jscodeshift.js src # use -d -p options for dry-run + stdout
# or for TS
jscodeshift --extensions=ts --parser=ts -t scripts/jscodeshift.js src

3. 对于每个源文件,查找所有的导入和导出声明。

function transform(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root.find(j.ImportDeclaration).forEach(replaceNodepathAliases);
  root.find(j.ExportAllDeclaration).forEach(replaceNodepathAliases);
  root
    .find(j.ExportNamedDeclaration, node => node.source !== null)
    .forEach(replaceNodepathAliases);
  return root.toSource();
 ...
};

jscodeshift.js:

/**
 * Corresponds to tsconfig.json paths or webpack aliases
 * E.g. "@/app/store/AppStore" -> "./src/app/store/AppStore"
 */
const pathMapping = {
  "@": "./src",
  foo: "bar",
};

const replacePathAlias = require("./replace-path-alias");

module.exports = function transform(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root.find(j.ImportDeclaration).forEach(replaceNodepathAliases);
  root.find(j.ExportAllDeclaration).forEach(replaceNodepathAliases);

  /**
   * Filter out normal module exports, like export function foo(){ ...}
   * Include export {a} from "mymodule" etc.
   */
  root
.find(j.ExportNamedDeclaration, (node) => node.source !== null)
.forEach(replaceNodepathAliases);

  return root.toSource();

  function replaceNodepathAliases(impExpDeclNodePath) {
impExpDeclNodePath.value.source.value = replacePathAlias(
  file.path,
  impExpDeclNodePath.value.source.value,
  pathMapping
);
  }
};

进一步说明:
import { AppStore } from "@/app/store/appStore-types"

创建以下AST,其ImportDeclaration节点的source.value可以被修改:

AST explorer

4. 对于每个路径声明,测试包含路径别名之一的正则表达式模式。

5. 获取别名的解析路径,并将其转换为相对于当前文件位置的路径(感谢@Reijo)。

replace-path-alias.js (4. + 5.):

const path = require("path");

function replacePathAlias(currentFilePath, importPath, pathMap) {
  // if windows env, convert backslashes to "/" first
  currentFilePath = path.posix.join(...currentFilePath.split(path.sep));

  const regex = createRegex(pathMap);
  return importPath.replace(regex, replacer);

  function replacer(_, alias, rest) {
const mappedImportPath = pathMap[alias] + rest;

// use path.posix to also create foward slashes on windows environment
let mappedImportPathRelative = path.posix.relative(
  path.dirname(currentFilePath),
  mappedImportPath
);
// append "./" to make it a relative import path
if (!mappedImportPathRelative.startsWith("../")) {
  mappedImportPathRelative = `./${mappedImportPathRelative}`;
}

logReplace(currentFilePath, mappedImportPathRelative);

return mappedImportPathRelative;
  }
}

function createRegex(pathMap) {
  const mapKeysStr = Object.keys(pathMap).reduce((acc, cur) => `${acc}|${cur}`);
  const regexStr = `^(${mapKeysStr})(.*)$`;
  return new RegExp(regexStr, "g");
}

const log = true;
function logReplace(currentFilePath, mappedImportPathRelative) {
  if (log)
console.log(
  "current processed file:",
  currentFilePath,
  "; Mapped import path relative to current file:",
  mappedImportPathRelative
);
}

module.exports = replacePathAlias;

3. 仅正则表达式搜索和替换

遍历所有源并应用正则表达式(未经充分测试):

^(import.*from\\s+["|'])(${aliasesKeys})(.*)(["|'])$

其中${aliasesKeys}包含路径别名"@"。可以通过修改第二个和第三个捕获组(路径映射+解析为相对路径)来处理新的导入路径。

该变体不能处理AST,因此可能不如jscodeshift稳定。

目前,正则表达式仅支持导入。以import "module-name"形式的副作用导入被排除在外,这样可以更安全地进行搜索/替换。

Sample:

const path = require("path");

// here sample file content of one file as hardcoded string for simplicity.
// For your project, read all files (e.g. "fs.readFile" in node.js)
// and foreach file replace content by the return string of replaceImportPathAliases function.
const fileContentSample = `
import { AppStore } from "@/app/store/appStore-types"
import { WidgetService } from "@/app/WidgetService"
import { AppStoreImpl } from "@/app/store/AppStoreImpl"
import { rootReducer } from "@/app/store/root-reducer"
export { appStoreFactory }
`;

// corresponds to tsconfig.json paths or webpack aliases
// e.g. "@/app/store/AppStoreImpl" -> "./custom/app/path/app/store/AppStoreImpl"
const pathMappingSample = {
  "@": "./src",
  foo: "bar"
};

const currentFilePathSample = "./src/sub/a.js";

function replaceImportPathAliases(currentFilePath, fileContent, pathMap) {
  const regex = createRegex(pathMap);
  return fileContent.replace(regex, replacer);

  function replacer(_, g1, aliasGrp, restPathGrp, g4) {
    const mappedImportPath = pathMap[aliasGrp] + restPathGrp;

    let mappedImportPathRelative = path.posix.relative(
      path.dirname(currentFilePath),
      mappedImportPath
    );
    // append "./" to make it a relative import path
    if (!mappedImportPathRelative.startsWith("../")) {
      mappedImportPathRelative = `./${mappedImportPathRelative}`;
    }
    return g1 + mappedImportPathRelative + g4;
  }
}

function createRegex(pathMap) {
  const mapKeysStr = Object.keys(pathMap).reduce((acc, cur) => `${acc}|${cur}`);
  const regexStr = `^(import.*from\\s+["|'])(${mapKeysStr})(.*)(["|'])$`;
  return new RegExp(regexStr, "gm");
}

console.log(
  replaceImportPathAliases(
    currentFilePathSample,
    fileContentSample,
    pathMappingSample
  )
);


谢谢。我的意图是摆脱@,使得代码库中不再出现@/。将一个项目与另一个项目合并意味着之前作为单独项目的源文件被复制到不假定使用别名的项目中。 - Estus Flask
我并不完全理解babel选项。它似乎要好100%,它会覆盖您当前磁盘上的文件吗?如果是这样,我们应该添加一个注释以确保git status干净(或者只是提到源代码将在磁盘上被修改)。我可能误解了那个答案... - Devin Rhode
所以,这实际上是修改具有@foo命名空间的npm包(例如@testing-library)https://cln.sh/hBuImC - Devin Rhode
我通过以下方式得到了一些结果:1. 搜索替换所有自定义别名为@root/foo/bar的某种形式。2. 运行jscodeshift脚本。然而,我认为它正在忽略所有.jsx、.ts和.tsx文件。我不确定如何适应这三个其他文件扩展名。 - Devin Rhode

15

我创建了一个脚本来完成这个任务。

它基本上遍历项目树,搜索所有文件,找到类似于"@/my/import"的导入,使用正则表达式/"@(\/\w+[\w\/.]+)"/gi,然后使用nodejs的path模块创建相对路径。

希望您没有任何我在这个简单脚本中未涵盖的边缘情况,所以最好备份您的文件。我只在简单的情况下进行过测试。

这里是代码

const path = require("path");
const args = process.argv;

const rootName = args[2];
const rootPath = path.resolve(process.cwd(), rootName);
const alias = "@";

if (!rootPath || !alias) return;

const { promisify } = require("util");
const fs = require("fs");

const readFileAsync = promisify(fs.readFile);
const readDirAsync = promisify(fs.readdir);
const writeFileAsync = promisify(fs.writeFile);
const statsAsync = promisify(fs.stat);

function testForAliasImport(file) {
  if (!file.content) return file;

  const regex = /"@(\/\w+[\w\/.]+)"/gi;

  let match,
    search = file.content;

  while ((match = regex.exec(search))) {
    const matchString = match[0];
    console.log(`found alias import ${matchString} in ${file.filepath}`);
    file.content = file.content.replace(
      matchString,
      aliasToRelative(file, matchString)
    );
    search = search.substring(match.index + matchString.length);
  }

  return file;
}

function aliasToRelative(file, importString) {
  let importPath = importString
    .replace(alias, "")
    .split('"')
    .join("");
  const hasExtension = !!path.parse(importString).ext;

  if (!hasExtension) {
    importPath += ".ext";
  }

  const filepath = file.filepath
    .replace(rootPath, "")
    .split("\\")
    .join("/");

  let relativeImport = path.posix.relative(path.dirname(filepath), importPath);

  if (!hasExtension) {
    relativeImport = relativeImport.replace(".ext", "");
  }

  if (!relativeImport.startsWith("../")) {
    relativeImport = "./" + relativeImport;
  }

  relativeImport = `"${relativeImport}"`;

  console.log(`replaced alias import ${importString} with ${relativeImport}`);
  return relativeImport;
}

async function writeFile(file) {
  if (!file || !file.content || !file.filepath) return file;
  try {
    console.log(`writing new contents to file ${file.filepath}...`);
    await writeFileAsync(file.filepath, file.content);
  } catch (e) {
    console.error(e);
  }
}

async function prepareFile(filepath) {
  const stat = await statsAsync(filepath);
  return { stat, filepath };
}

async function processFile(file) {
  if (file.stat.isFile()) {
    console.log(`reading file ${file.filepath}...`);
    file.content = await readFileAsync(file.filepath);
    file.content = file.content.toString();
  } else if (file.stat.isDirectory()) {
    console.log(`traversing dir ${file.filepath}...`);
    await traverseDir(file.filepath);
  }
  return file;
}

async function traverseDir(dirPath) {
  try {
    const filenames = await readDirAsync(dirPath);
    const filepaths = filenames.map(name => path.join(dirPath, name));
    const fileStats = await Promise.all(filepaths.map(prepareFile));
    const files = await Promise.all(fileStats.map(processFile));
    await Promise.all(files.map(testForAliasImport).map(writeFile));
  } catch (e) {
    console.error(e);
  }
}


traverseDir(rootPath)
  .then(() => console.log("done"))
  .catch(console.error);

确保将目录名称作为参数提供。比如 src

对于IDE部分,我知道Jetbrains Webstorm可以定义npm任务。
创建一个scripts目录来保存脚本。
package.json中定义一个脚本。

"scripts": {
    ...
    "replaceimports": "node scripts/script.js \"src\""
}
在npm工具窗口中注册npm任务以供使用。

如果您觉得这很有用,请告诉我,这样我就知道值得再投入更多时间。 - Armin Bu
谢谢,是的,这很有用。请在答案中列出来自gist的代码,即使外部链接离线也应该可用。我会尝试奖励第二个赏金给这篇文章。 - Estus Flask
我使用了这个脚本和上面的另一个jscodeshift脚本。然后我对两者的结果进行了差异化和合并。实际上,我使用了一个稍微更新的脚本,有人在链接的github要点中进行了评论,该脚本处理了在mac上运行的一些具体细节。这个脚本的一个很大的优点是,它还更新了jest.mock导入路径,甚至是一些被注释掉的代码中的路径。 - Devin Rhode

8
简单地减少任务所需的时间的方法是使用正则表达式模式匹配,仅针对位于特定深度级别的文件。 假设您有一个指向您的components文件夹的神奇路径,并且具有以下项目结构:
...
├── package.json
└── src
    └── components

你可以通过简单的查找和替换来重构它:
find: from "components
replace: from "../components
files to include: ./src/*/**.ts

接下来,您只需进行递归:

find: from "components
replace: from "../../components
files to include: ./src/*/*/**.ts

我写了一篇小博客讨论这个问题:https://dev.to/fes300/refactoring-absolute-paths-to-relative-ones-in-vscode-3iaj


2
从你提供的链接中可以看到:
引用块中写道:“模块标识符的含义和结构取决于模块加载器或模块打包工具”。
也就是说,将“@”转换为相对导入并没有一种适用于所有情况的解决方案。但你可以创建一个程序来让用户指定在给定项目中“@”代表的含义,这应该是一个相当不错的解决方案!
我认为我会尝试创建一个 codemod,查找包含import语句中的“@”符号的文件,并确定需要向上遍历多少个父目录才能到达用户指定的根目录,然后将“@”符号替换为适当数量的“../”。
一旦你创建了这样一个codemod/程序/脚本,你就可以从你选择的编辑器中触发它!
至少这是我解决这个问题的方法(除了在开始之前寻找可应用的预制解决方案之外)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接