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

Posted

技术标签:

【中文标题】将别名 @ 导入重构为相对路径【英文标题】:Refactor aliased @ imports to relative paths 【发布时间】:2019-12-02 21:49:06 【问题描述】:

在使用 Webpack、TypeScript 或其他转换 ES 模块导入的工具的模块化环境中,使用路径别名,常用约定是 @ for 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 代码)或普通 Node.js 的解决方案。

如何做到这一点?

【问题讨论】:

【参考方案1】:

将别名导入重新连接到相对路径的三种可能解决方案:

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。 Codemod jscodeshift 与正则表达式

应支持来自 MDN docs 的所有相关导入/导出变体。算法是这样实现的:

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

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 包含路径别名"@"。新的导入路径可以通过修改第2和第3个捕获组(路径映射+解析到相对路径)来处理。

此变体无法处理 AST,因此可能被认为不如 jscodeshift 稳定。

目前,Regex 仅支持导入。 import "module-name" 形式的副作用导入被排除在外,这样做的好处是使用搜索/替换更安全。

示例:

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
  )
);

【讨论】:

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

我为此创建了一个脚本。

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

我希望你没有我在这个简单脚本中没有涉及的任何边缘情况,所以最好备份你的文件。我只是在一个简单的场景中测试过。

这里是code:

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 任务以供使用。

【讨论】:

如果你觉得这很有用,请告诉我,所以我知道值得花更多时间。 谢谢,是的,很有用。请在答案中列出要点中的代码,即使外部链接离线,它也应该可用。我会尝试奖励这篇文章的第二次赏金。 我使用了这个脚本 + 上面的另一个 jscodeshift 脚本。然后我对两者的结果进行了比较和合并。我实际上使用了一个稍微更新的脚本,有人评论了链接的 github gist,它处理了一些在 mac 上运行的特定细节。该脚本的一大优势在于,它还更新了 jest.mock 导入路径,甚至在一些注释掉的代码中更新了一条路径。【参考方案3】:

显着减少花费在任务上的时间的一种简单方法是仅对位于特定深度级别的目标文件使用正则表达式模式匹配。假设你有一个指向你的 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

【讨论】:

【参考方案4】:

来自您链接的帖子:

模块标识符的含义和结构取决于模块加载器或模块捆绑器

意思是,对于这个 '@' -> 相对导入转换,永远不会有一个一刀切 的解决方案。你可以做的是有一些程序让你指定@对于给定项目意味着什么,这应该是一个相当不错的解决方案!

我认为我要解决这个问题的方法是创建一个codemod,它查看在其导入语句中包含@ 的文件,并确定它必须遍历多少个父目录才能到达用户确定 root 目录,然后将@ 符号替换为适当数量的../

一旦您创建了一个代码模块/程序/脚本来执行此操作,您就可以从您选择的编辑器中触发它!

这至少是我解决这个问题的方法(除了四处寻找我可以在开始之前应用的预制解决方案!)

【讨论】:

以上是关于将别名 @ 导入重构为相对路径的主要内容,如果未能解决你的问题,请参考以下文章

将打字稿路径别名编译为 NPM 发布的相对路径?

使用别名而不是相对路径创建的打字稿声明文件

无法使用相对路径导入 Spring bean 定义文件

从相对路径导入模块

compilerOptions.paths 不得设置(不支持别名导入)

在Python中以绝对路径或者相对路径导入文件(或模块)的方法