likes
comments
collection
share

我是如何删除RN屎山项目中的死代码

作者站长头像
站长
· 阅读数 52

背景

我们部门在维护着一个历史悠久的react-native屎山项目,仓库体积庞大。 最近迎来了一小部分重构,在此过程中,我们发现了大量的死代码(无用文件和无用的exports)。 这些死代码非常不利于代码的迭代与维护。比如某天后端修改某个api的路径或者参数,你在项目全局搜索,发现有好多出引用了此api,但是其实那些定义或是文件根本未被引入使用,但是这将误导你继续去维护这些文件或者定义,影响迭代效率。

于是老板就交给了我一个任务,将目前项目中所有的无用文件和无用export全部删除,并且集成到CI中以防止不必要的死代码出现,影响迭代效率。

项目结构

先来看一下目录结构

.
├── assets/
│   ├── img
│   └── strings
├── bundles/
│   ├── mitra/
│   │   └── alias.config.js
│   ├── partner/
│   │   └── alias.config.js
│   ├── shopee
│   └── shopeepay
├── scripts
├── packages/
│   ├── package1
│   ├── package2
│   └── ...
├── workspaces/
│   ├── workspace1
│   ├── workspace2
│   └── ...
├── alias.config.js
├── package.json
├── typescript.json
└── yarn.lock
  • 该项目需要给多个app使用,每个app对应的不同的入口,bundles下的每个文件夹代表着各个app。
  • 由于历史原因,目前项目中flow语法和typescript语法并存(正在逐步迁移至typescript)
  • app差异化文件

如果某个模块在不同的app中存在不同的逻辑,那么我们不必在同一份代码中为不同的app写if条件判断,而是分别将逻辑写入xxx.app1.ts和xxx.app2.ts中,在打包的过程中,我们通过自己实现的一个babel插件来根据当前打包的是哪个app的bundle来动态将 import 'xxx' 替换为 import 'xxx.currentApp'

我们在写业务逻辑的时候无需关心app后缀的问题

  • 别名

项目中大量使用了别名,alias.config.js是别名配置文件。需要注意的是,mitra和partner目录下的文件使用的是自己目录下的别名配置,不在这两个目录中的文件统一使用最外层的别名配置。也就是说出现的别名使用到的配置完全是由该文件的物理位置决定的。

因为RN项目中使用metro打包,而metro内部使用babel来转换语法,我们的别名其实是通过 babel-plugin-module-resolver 这个babel插件实现的,但是我们修改了其查找配置文件的方式

看一下我们的babel 转义别名的插件是怎么写的

local-module-resolver.js

const fs = require('fs');
const path = require('path');

// 构建bundle的时候需要cd到对应的app 目录
// 针对与mitra和partner
const modules = ['./', '../../'];
// 如果是shopee和shopeepay
// const modules = ['../../']

const ALIAS_CONFIG_FILENAME = 'alias.config';
// into `alias` config and `root`
const moduleResolvers = modules
  .map(modulePath => {
    const root = fs.realpathSync(modulePath);
    let config;
    try {
      // Looking for alias.config file
      const aliasConfig = path.join(root, ALIAS_CONFIG_FILENAME);
      config = require(aliasConfig);
    } catch (e) {
      console.log(e.message);
      e;
    }

    if (!config) {
      return null;
    }

    return {
      root,
      config: Object.assign({}, config, { cwd: root }),
    };
  })
  .filter(item => !!item);

// getLocalAliasConfig determine which local config use to apply to file
function getLocalAliasConfig(filename) {
  const matchItem = moduleResolvers.find(resolver => {
    return filename.match(new RegExp(resolver.root, 'g'));
  });
  if (matchItem) {
    return matchItem.config;
  }
  return {};
}

// babel-plugin-module-resolver transformer with getLocalAliasConfig
// original file: https://github.com/tleunen/babel-plugin-module-resolver/blob/master/src/index.js
const plugin = require('babel-plugin-module-resolver').default;
module.exports = function (_ref) {
  const t = plugin(_ref);
  return Object.assign({}, t, {
    pre(file) {
      const currentFile = file.opts.filename;
      this.opts = getLocalAliasConfig(currentFile);
      // 这里调用原生的babel-plugin-module-resolver
      t.pre.call(this, file);
    },
  });
};

删除无用的文件

所谓的无用文件,自然是我们在代码中没有import的文件,所以我们的思路是通过入口文件来递归静态解析import,从而得到所有的文件依赖图(即项目中使用到的文件列表),最后再梳理出项目中所有的文件列表,两者的差集就是无用的文件。

使用metro构建

因为是RN项目,使用了metro进行打包。可以直接配置metro.config.js 中的 resolveRequest ,这是metro在打包的过程中resolve模块时调用的方法,可以在这里记录所有打包过程中使用到的文件路径。

const path = require('path');
const Resolver = require('metro-resolver');
const fs = require('fs');

const sourceFile = path.join(__dirname, './usedFiles.txt');

const dependencies = new Set();

function validAndLogPath(filePath) {
  if (
    filePath &&
    !filePath.includes('/node_modules') &&
    !dependencies.has(filePath)
  ) {
    dependencies.add(filePath);
    fs.appendFileSync(sourceFile, `${filePath}\n`);
  }
}

module.exports = {
  resolver: {
    sourceExts: ['js', 'jsx', 'ts', 'tsx'],
    resolveRequest: (context, moduleName, platform) => {
      const commonContext = {
        ...context,
        resolveRequest: undefined,
      };
      const standardResolution = Resolver.resolve(
        commonContext,
        moduleName,
        platform
      );
      const { filePaths, filePath } = standardResolution;

      // 在这里记录用到的文件
       recordPath(filePath);
       if (Array.isArray(filePaths)) {
         filePaths.forEach(recordPath);
       }
      return standardResolution;
    },
  },
};

最后打包后就会产生一个使用到的文件列表,当然这里仅仅只是一个app用到的文件列表,我们还需要分别为其他三个app同样进行一次构建,这样就能拿到全部的使用列表了

但是执行4次构建耗时较长,我们可以构造一个假的入口文件,将4个app的入口文件到导入,这样就能只执行一次构建了

import 'bundles/shopee/src/index.ts';
import 'bundles/mitra/src/index.ts';
import 'bundles/partner/src/index.ts';
import 'bundles/shopeepay/src/index.ts';

但是现在需要解决一个路径别名问题,可以看前面提到的别名配置

mitra和partner下的文件使用各自的alias配置,其它目录下的文件统一使用最外层的alias配置。同时mitra和partner也会import外部的文件,也就是说mitra和partner中将使用到两份alias配置,具体使用哪一份取决于当前文件所在位置。

例如

bundles/mitra/test.ts

import 'App/test1'
import '../../../packages/test2'

packages/test2.ts

import 'App/test2'

bundles/mitra/test.ts中使用的是bundles/mitra/alias.config.ts,而packages/test2.ts中使用的是alias.config.ts

如果我们只构建一次,babel别名转义插件应该怎么写才能满足之前的要求呢?

其实很简单,只需要将所有app路径配上就可以了

metro.config.js

- const modules = ['./', '../../'];
+ const modules = ['./bundles/mitra', './bundles/partner', './'];

这样我们就能在RN项目根目录下只执行一次build就行啦~

app差异化文件的问题

在我们的RN项目中,如果在某个模块中mitra和shopee的存在差异,可以分别将逻辑写入xxx.ts和xxx.mitra.ts两个文件中(partner和shopeepay同理),在打包mitra时,将会通过自己实现的@spc/babel-plugin-scenes(可以查看github.com/ayahua/babe… ) 插件来将import'xxx'动态替换为import 'xxx.mitra'

如果我们只build一次的话 ,这将导致一个问题,举个🌰

// xxx.ts
import 'a.ts';
import 'b.ts';

/// xxx.mitra.ts
import 'a.ts';
import 'c.ts';

build时,如果 babel-plugin-scenes中的scene不设置任何值(默认为shopee),这样只会引入xxx.ts而忽略xxx.mitra.ts,所以最终使用到的文件列表中将缺少c.ts文件,反之则会缺失b.ts文件。很显然这些文件虽然没有出现在使用到的文件列表中,也不能将他们删除。这该怎么办呢?

其实我们可以检查每个import语句,如果发现存在与之对应的app差异化文件时就将他们全部引入,这样就可以解决上面问题啦~

// 原始
import './a.ts'

// 如果发现a存在其他app差异化文件,则经过我们babel插件后变为
import './a.ts'
import './a.mitra.ts'
import './a.partner.ts'

我们可以这样改造babel-plugin-scenes插件

ImportDeclaration(path) {
  // ...
  // sceneSource 就是找到的所有差异化文件
  if (sceneSource.length > 0) {
    const declarations = sceneSource.map((s) => {
      return types.ImportDeclaration([], types.StringLiteral(s));
    });
  }

  path.insertAfter(declarations);
}

最终测试构建时间大概为 3min36s

我是如何删除RN屎山项目中的死代码

使用webpack构建

可以看到metro的构建耗时还是比较久的,花了3分多钟。在普通的web项目中我们经常使用webpack进行打包构建。webpack针对构建过程有很多优化项,且自由度更高,可配置的东西非常多,而metro的自由度较低,所以是否可以使用webpack来构建我们的RN项目呢?

这里需要再强调一下,我们仅仅只是为了借助bundler的打包过程得到所有使用到的文件列表,对于构建后的bundle是否可用并不关心,我们只需要保证代码中的import能被正确解析的就可以了。有了这个大前提我们就有了很多优化的空间,进而提升构建时间。

参照metro的打包配置,对于所有的js、jsx、ts、tsx文件,统一使用 babel-loader进行转义。给babel配置metro-react-native-babel-preset即可,其内部配置了转义ts的插件,这样就不用再配置ts-loader对ts和tsx进行解析,同时ts-loader需要依赖typescript,typescript启动服务也需要耗时,总体来说ts-loader相对babel-loader耗时较长。虽然ts-loader可以在构建的时候对代码进行类型检查,但是我们并不需要,这只会降低构建时间,毫无益处。

webpack.config.js

module.exports = {
  mode: 'development',
  entry: {
    main: './bundles/shopeepay/src/index.ts',
  },
  output: {
    path: path.resolve('dist'),
    filename: 'main.js',
  },
  module: {
   rules: [
     {
       test: /.(j|t)sx?$/,
       loader: require.resolve('babel-loader'),
     }
   ]
  }
}

当然仅仅配置babel-loader是不够的,因为在项目中还是用了图片资源,所以需要针对这些图片资源再添加一个loader进行处理。一开始我自然而然的想到了url-loader和file-loader,但是webpack5已经内置了这两个loader,于是将其配上

 module: {
   rules: [
     {
       test: /.(j|t)sx?$/,
       loader: 'babel-loader',
     },
     {
        test: /.(png|jpe?g|gif|svg)$/,
        type: 'asset'   // 这里的值还有 asset/resource、asset/inline和asset/source, 具体含义可以参考 https://webpack.js.org/guides/asset-modules/#resource-assets
      },
   ]
 }

但是回想前面提到的前提,我们其实并不关心文件是否返回正确内容,上面配置的loader其实还涉及到了更多io操作(读文件,生成文件),这也会降低构建时间,所以我们何不自己实现一个loader来跳过io操作呢?

assetsLoader.js

// pitch loader,这样可以让webpack跳过读取源文件,只需要返回一串任意字符表示此图片的内容即可
function loader(){}
loader.pitch = () => `module.exports='mock_asset_content'`;
modules.exports = loader

到这里,loader就配置完成了,然后运行webpack,果然不出所料的报了一堆错误,基本上全是找不到模块的报错,这也正常。因为在我们项目中,大量使用别名,所以这里我们需要将项目中的alias.config.js中配置的别名同样配值到webpack中。可以直接写一个函数来生成。

webpack.config.js

resolve: {
  alias: {
    App: './packages/App',
    Assets: './packages/App/Assets',
    '...'  
  }
}

同时RN项目不同于普通的web项目,有许多.ios和.android后缀的文件,但是在import的时候我们并不会声明这些后缀,所以我们要告诉webpack如何查找后缀

webpack.config.js

resolve: {
  extensions: [
    '.tsx',
    '.ts',
    '.js',
    '.json',
    '.ios.ts',
    '.ios.js',
    '.ios.tsx',
    '.android.ts',
    '.android.js',
    '.android.tsx',
  ]
}

执行webpack进行构建,这里发现了一个小坑, 有很多如下报错

Module not found: Error: Can't resolve 'react/jsx-runtime' in xxx

这是 @babel/plugin-transform-react-jsx 插件的报错,在我们安装的 metro-react-native-babel-preset 中配置了此插件来转换 jsx 语法,但是其内部配置了 runtimeautomatic , 所以在转化jsx是通过react/jsx-runtime模块来编译的,但是我们的项目中使用的是react 16,此模块是 react 17 之后才有,所以报了这个错误。

我是如何删除RN屎山项目中的死代码

知道了这个问题,我们只需要降低 metro-react-native-babel-preset 版本为 0.59.0 即可,内部 @babel/plugin-transform-react-jsx 插件的 runtime 配置为 classic

我是如何删除RN屎山项目中的死代码

具体可以查看 babeljs.io/docs/en/bab…

至此我们已经可以成功构建bundle了,但是我们怎么才能拿到所有使用到的文件列表呢?

其实在webpack每次构建过程中,都有一个compilation对象,里面有一个fileDependencies属性包含了所有当前构建依赖的文件列表,我们可写一个webpack插件在构建成功之后将compilation. fileDependencies输出即可。

大致实现思路如下

class WebpackDeadcodePlugin {
  apply(compiler) {
    if (compiler.hooks) {
      compiler.hooks.afterEmit.tapAsync(
        'WebpackDeadcodePlugin',
        handleAfterEmit
      );
    }
  }

  handleAfterEmit(compilation, callback) {
    let assets = Array.from(compilation.fileDependencies);
    const compiledFiles = convertFilesToDict(assets);
    const usedFiles = 'usedFiles.webpack.txt';
    fs.writeFileSync(usedFiles, '');
    Object.keys(compiledFiles).forEach((s) => {
      fs.appendFileSync(usedFiles, `${s}\n`);
    });
    callback();
  }

  convertFilesToDict(assets) {
    return assets
      .filter(
        (file) =>
          file &&
          file.indexOf('node_modules') === -1 &&
          !fs.statSync(s).isDirectory()
      )
  }
}

但是现在发现构建时间仍然有点长。此时可以回想我们的大前提,我们可以将所有node_modules中的包放到externals中(因为我们并不关心bundle是否可以运行)

getExternals.js

const fs = require('fs');

const randomString = () => Math.random().toString(36).slice(7);

function get(deps) {
  const keys = Object.keys(deps);

  const res = {};

  for (let i = 0; i < keys.length; i++) {
    const verion = deps[keys[i]];
    if (verion.startsWith('workspace')) continue;
    res[keys[i]] = randomString();
  }
  return res;
}

module.exports = function getExternals(pkg) {
  if (!fs.existsSync(pkg)) return {};
  const content = fs.readFileSync(pkg);
  const { dependencies = {}, devDependencies = {} } = JSON.parse(content);

  return { ...get(dependencies), ...get(devDependencies) };
};

其实到这里我们已经能分析出所有针对shopee app没有使用到的文件列表了。但是尽管shopee app没有使用到,可能mitra或者partner使用到了,所以我们现在还不能直接将它们删除。可以分别为shopee、shopeepay、mitra和partner构建一次,分别找出它们对应的未使用的文件列表,然后取它们之间的交集即可。

构建四次比较麻烦且耗时相对较长,能不能像前面metro一样只构建一次呢?因为别名的特殊情况,如果构建一次的话,webpack resolve.alias似乎无法满足我们之前所说的别名使用的要求了......

那我们可以不配置webpack alias呀,也同样使用babel来转义不就行了吗。在webpack打包之前,我们先使用babel将所有的alias转换成真实的路径,这样在wepack resolve的时候就不需要考虑别名了,因为路径已经是真实转换好的了

最终经过测试,构建耗时为 2min5s 左右, 可以看到比metro构建时间耗时减少一分多钟。

我是如何删除RN屎山项目中的死代码

其他方案

使用bundler来分析unused files会不会太重了呢(毕竟还要处理其他的事情)?我们自己是否可以通过分析项目中的所有import和require来找出所有使用的文件列表呢(同时还需要支持别名)?其实社区也有一些工具,比如:

但是它们对我们项目中的别名使用情况支持不太友好,所以暂不考虑。

特殊情况

无论是使用metro还是webpack进行构建,如果我们import的是typescript类型定义文件,会被bundler认为是无用文件。然而这些类型定义文件却是不能删的,对于这些情况可以设置白名单忽略即可。

当然以上演示是直接使用bundler提供的命令来构建的,因为我们要将脚本集成到ci中,所以最终其实是调用了他们的JavaScript api来实现的,这里就不再赘述了。

删除无用的exports

删除无用的exports,有以下几个难点

  • 如何 找出 export 出去,但是未被其他文件import 的变量

  • 如何确保上一步找出的 未被其他文件使用的变量在本文件中没有被使用?

    // 尽管a变量没有被外部文件引用,但是在其文件内部被使用了,也不能删除
    export const a = 'a';
    console.log(a)
    
  • 如何自动 删除这些变量?(尽管可以手动删除,但是如果存在成千上万个变量手动删除就显得很笨了)

  • 删除某个变量之后可能又会引入新的无用变量,如何保证它们都能被正确删除?

变量导入分析

pzavolinsky/ts-unused-exports 开源库

ts-unused-exports finds unused exported symbols in your Typescript project.

使用例子

src/index.ts

import { a, b } from './test'

console.log(a)

src/test.ts

export const a = 'a';
export const b = 'b';

使用 ts-unused-exports 需要提供一个tsconfig文件

tsconfig.json

{
  "compilerOptions": {
    "target": "ES5",
    "module": "commonjs",
    "noEmit": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "allowJs": true,
  },
  "include": ["src/**/*"]
}

然后执行

npx ts-unused-exports tsconfig.json

就可以看到我们已经能拿到未使用的 exports 了。

看起来一切似乎很美好,但是我们回到前面提到的别名问题(万恶之源!),shopee 和 shopeepay 的还比较容易分析,我们只需要将 alias 的配置也写到 tsconfig 的 paths 中就行了,但是 mitra 和 partner 是会根据文件所在的位置不同而使用不同的 alias 配置的,这个时候 tsconfig 就无法满足要求了。。。

同时经过测试,ts-unused-exports 仅仅局限于分析 import 和 export 的关系,并不会分析 export 出去的这个变量 自身文件内部是否有使用到

import/no-unused-modules eslint rule

官方介绍import/no-unused-modules可以做如下事情

我是如何删除RN屎山项目中的死代码

可以看到,import/no-unused-modules 也可以分析出未使用的exports,但是相比于 ts-unused-exports 它有哪些优势呢?

最大的优势是可以自定义resolver,从而支持项目中的别名要求,配置可以参考eslint-plugin-import/README.md at main · import-js/eslint-plugin-import · GitHub

我是如何删除RN屎山项目中的死代码

自定义resolver

那么我们如何实现resolver来实现我们的别名要求呢?其实和前面实现了babel别名插件类似,都是从当前文件位置找出对应的 alias.config.js,从而替换为真实路径。

eslintImportAliasResolver.js

const nPath = require('path');
const {
  resolve: reactNativeResolve,
} = require('eslint-import-resolver-react-native');
const resolveApp = require('../utils/resolveApp');

/**
 * consistent with https://github.com/tleunen/babel-plugin-module-resolver
 */

const ALIAS_CONFIG_FILENAME = 'alias.config';
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];

const modules = [
  resolveApp('bundles/mitra'),
  resolveApp('bundles/partner'),
  resolveApp(),
];

const moduleResolvers = modules
  .map(modulePath => {
    const root = modulePath;
    let config;
    try {
      // Looking for alias.config file
      const aliasConfig = nPath.resolve(root, ALIAS_CONFIG_FILENAME);
      // eslint-disable-next-line import/no-dynamic-require, global-require
      config = require(aliasConfig);
    } catch (e) {
      console.log(e.message);
    }

    if (!config) {
      return null;
    }

    return {
      root,
      config,
    };
  })
  .filter(Boolean);

function isRegExp(str) {
  return str.startsWith('^') || str.endsWith('$');
}

function escapeRegExp(str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function getAliasTarget(key, isKeyRegExp) {
  const regExpPattern = isKeyRegExp ? key : `^${escapeRegExp(key)}(/.*|)$`;
  return new RegExp(regExpPattern);
}

function getAliasSubstitute(value, isKeyRegExp) {
  if (typeof value === 'function') {
    return value;
  }
  if (!isKeyRegExp) {
    return ([, match]) => `${value}${match}`;
  }

  /**
   * "alias": {
   *   "^@namespace/foo-(.+)": "packages/\\1"
   * }
   *
   * Using the config from this example '@namespace/foo-bar' will become 'packages/bar'.
   * You can reference the n-th matched group with '\\n' ('\\0' refers to the whole matched path).
   * To use the backslash character (\) just escape it like so: '\\\\' (double escape is needed because of JSON already using \ for escaping).
   */
  const parts = value.split('\\\\');
  return execResult =>
    parts
      .map(part =>
        part.replace(/\\\d+/g, number => execResult[number.slice(1)] || '')
      )
      .join('\\');
}

function normalizeAlias(optsAlias) {
  if (!optsAlias) {
    return [];
  }
  const aliasArray = Array.isArray(optsAlias) ? optsAlias : [optsAlias];
  return aliasArray.reduce((aliasPairs, alias) => {
    const aliasKeys = Object.keys(alias);
    aliasKeys.forEach(key => {
      const isKeyRegExp = isRegExp(key);
      aliasPairs.push([
        getAliasTarget(key, isKeyRegExp),
        getAliasSubstitute(alias[key], isKeyRegExp),
      ]);
    });
    return aliasPairs;
  }, []);
}

function isRelativePath(nodePath) {
  return nodePath.match(/^\.?\.\//);
}

// getLocalAliasConfig determine which local config use to apply to file
function getLocalAliasConfig(filename) {
  const matchItem = moduleResolvers.find(resolver => {
    return filename.match(new RegExp(resolver.root, 'g'));
  });
  if (matchItem) {
    const { config, root } = matchItem;
    return { root, alias: normalizeAlias(config.alias) };
  }
  return null;
}

exports.interfaceVersion = 2;
exports.resolve = (source, file, config) => {
  // If it is a relative path, skip and let the subsequent resolvers to resolve it
  if (isRelativePath(source) || nPath.isAbsolute(source)) {
    return { found: false };
  }

  const aliasConfig = getLocalAliasConfig(file);
  if (!aliasConfig) return { found: false };

  const { root, alias } = aliasConfig;

  let aliasedFile = null;
  alias.some(([regExp, substitute]) => {
    const execResult = regExp.exec(source);
    if (execResult === null) {
      return false;
    }
    aliasedFile = substitute(execResult);
    return true;
  });

  if (!aliasedFile) {
    return { found: false };
  }

  const extensions = config?.extensions || EXTENSIONS;
  return reactNativeResolve(nPath.join(root, aliasedFile), file, {
    extensions,
  });
};

import/no-unused-modules内部 resolve 模块时,会依次调用我们配置的resolver来查找模块。

import/no-unused-modules 原理

import/no-unused-modules 的原理也很简单。先通过 ast 分析出所有文件的导入和导出以及文件之间的依赖关系,然后分别存入importListexportList两个map中。然后对于每个 export 语句,从 exportList 取出对应的 whereUsed,检查是否存在其他文件的引用。

importList
/**
 * For example, if we have a file named foo.js containing:
 *
 *   import { o2 } from './bar.js';
 *
 * Then we will have a structure that looks like:
 *
 *   Map { 'foo.js' => Map { 'bar.js' => Set { 'o2' } } }
 */

exportList
/**
 * For example, if we have a file named bar.js containing the following exports:
 *
 *   const o2 = 'bar';
 *   export { o2 };
 *
 * And a file named foo.js containing the following import:
 *
 *   import { o2 } from './bar.js';
 *
 * Then we will have a structure that looks like:
 *
 *   Map { 'bar.js' => Map { 'o2' => { whereUsed: Set { 'foo.js' } } } }
*/

eslint 配置

先来看一下eslint配置文件配置文件, 因为我们的项目同时存在Typescript和flow语法,所以要分别为js和ts文件配置不同的parser

const tsParserOptions = {
  ecmaFeatures: {
    jsx: true,
    generators: false,
    objectLiteralDuplicateProperties: false,
    globalReturn: false,
  },
  sourceType: 'module',
  ecmaVersion: 9,
};

const jsParserOptions = {
  requireConfigFile: false,
  ecmaVersion: 9,
  babelOptions: {
    plugins: [
      '@babel/plugin-transform-react-jsx',
      '@babel/plugin-syntax-flow', // 识别flow语法
      [
        '@babel/plugin-proposal-decorators',
        {
          decoratorsBeforeExport: true,
          legacy: false,
        },
      ],
    ],
  },
};


const eslintBaseConfig = {
  plugins: ['import'],
  settings: {
    'import/resolver': [
      path.resolve(
        __dirname,
        '../../configs/resolvers/eslintImportAliasResolver'
      ),
      require.resolve('eslint-import-resolver-typescript'),
      require.resolve('eslint-import-resolver-node'),
    ],
    'import/extensions':['.ts', '.tsx', '.js', '.jsx'],
    'import/parsers': {
      [require.resolve('@typescript-eslint/parser')]: ['.ts', '.tsx'],
      [require.resolve('@babel/eslint-parser')]: ['.js', 'jsx'],
    },
    /**
     * The original eslint-plugin-import does not support the import/parser-options option,
     * we patched it and made its internal parser and parserOption correct
     */
    'import/parser-options': {
      [require.resolve('@typescript-eslint/parser')]: tsParserOptions,
      [require.resolve('@babel/eslint-parser')]: jsParserOptions,
    },
  },
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      parser: require.resolve('@typescript-eslint/parser'),
      parserOptions: tsParserOptions,
    },
    {
      files: ['*.js', '*.jsx'],
      parser: require.resolve('@babel/eslint-parser'),
      parserOptions: jsParserOptions,
    },
  ],
};

export default eslintBaseConfig;

接着通过调用eslint api就能得出所有未使用的exports了。

import eslint from 'eslint';

const options = {
   extensions: ['.ts', '.tsx', 'js', '.jsx'],
   useEslintrc: false,
   errorOnUnmatchedPattern: false,
    baseConfig: {
      ...eslintBaseConfig,
      rules: {
        'import/no-unused-modules': [
          2,
          {
            unusedExports: true,
            src: 'files list', // 这里是项目中的文件列表(可以通过glob获取),注意此时应该排除掉单测文件
          },
        ],
      },
   },
};
const eslint = new ESLint(options);
const lintResult = await eslint.lintFiles(lintFiles)

此时你可能会发现我们配置了一个 import/parser-options setting, 然而 eslint-plugin-import 并没有支持这个配置,此配置是我们 patch 源码新增的。那么为什么需要此配置呢?

因为我们项目中存在Flow和Typescript 语法,当lint一个文件时,如果当前lint 的文件引入了一个与当前lint的文件不一样语法的文件时,就会报错,比如

lint a.ts文件,此时 a.ts 对应的parser为 @typescript-eslint/parser, 而 a.ts 内部又import了b.js, b.js对应的 parser为 @babel/eslint-parser, 在分析完 a.ts 的ast之后 no-unused-modules 会接着去分析 b.js 的ast,此时仍然会使用 @typescript-eslint/parser 去解析b.js, 那么自然会无法解析,反之亦如此。

// a.ts
import './b.js' // flow 语法
Error while xxx/b.js
Line 24, column 24: Property or signature expected.
`parseForESLint` from parser `xxx/node_modules/@typescript-eslint/parser/dist/index.js` is invalid and will just be ignored

原因就在于,在解析当前lint文件import的文件的ast时,no-unused-modules 内部并不会动态替换我们配置的parser。

eslint-plugin-import/src/ExportMap.js


// eslint-plugin-import 分析每个文件的ast时都会走到此方法,context是eslint传入的
// 所以这里的parserPath 和 parserOptions 永远是对应的parserPath 和 parserOptionlint的文件配置

function childContext(path, context) {
  const { settings, parserOptions, parserPath } = context;
  return {
    settings,
    parserOptions,
    parserPath,
    path,
  };
}

可以看到在源码中,lint一个文件的过程中parser就已经定死为此文件对应的parser了,此后再去解析此文件import的其他文件的ast时也会使用此parser。

所以我将其修改为

function childContext(path, context) {
  let {
    settings,
    parserOptions: finalParserOptions,
    parserPath: finalParserPath,
  } = context;
  const parsers = settings['import/parsers'];
  const parserOptions = settings['import/parser-options'];
  if (parsers != null) {
    const extension = _path.extname(path);
    for (const p in parsers) {
      if (parsers[p].indexOf(extension) > -1) {
        finalParserPath = p;
        if (parserOptions != null && parserOptions[p] != null) {
          finalParserOptions = parserOptions[p]
        }
        break;
      }
    }
  }
  return {
    settings,
    parserOptions: finalParserOptions,
    parserPath: finalParserPath,
    path,
  };
} 

详情可以参考 github.com/import-js/e…

至此,我们已经可以分析出项目中为使用的exports了

如何确定无用的exports在本模块没有被使用?

上一步我们已经拿到没有文件中未使用的exports,然而 eslint-plugin-import 并不会关心收集到的无用exports是否在本模块被使用到。

// 尽管a变量没有被外部文件引用,但是在其文件内部被使用了,也不能删除
export const a = 'a';
console.log(a)

在上一步中已经可以分析出所有的文件未使用的 exports 了,但是我们如何确定这些变量是否在文件内部本身有没有被使用呢(no-unused-modules 无法做到)?

比如我们已经找出 test.js 中 a 和 b 两个 export 没有被外部使用,但是 b 在 test.js 内部本身被使用了,而no-unused-modules是无法分析出来的。

// test.js

export const a = 'a';
export const b = 'b';
console.log(b);

此时 no-unused-vars 这个eslint rule就闪亮登场了。

no-unused-vars 天生可以分析出文件内部某个变量是否被使用。所以我们可以对上面分析出的文件分别调用 ESLint api,并且配置 no-unused-vars rule 是不是就解决问题了呢?

但默认情况下 no-unused-vars是不支持对 export 出去的变量进行分析的,因为既然你 export 了这个变量,其实它就认为你这个变量会被外部使用(其实也有道理,你外部不使用的话何必要 export 呢?但是我们不能确保大家写代码都是遵循这个原则的)。

其实对于这个限制,我们只需要稍加改写此规则即可。我们的分析涉及到删除,所以必须要有一个严格的限定范围,就是 export 出去 且被 import/no-unused-modules 认定为 外部未使用 的变量。所以考虑增加一个配置 fileExportVars,把 import/no-unused-modules 分析出的未使用变量名传入,限定在这个名称范围内。

主要改动逻辑是在 collectUnusedVariables 这个函数中,这个函数的作用是 收集作用域中没有使用到的变量,这里把 export且不符合变量名范围 的全部跳过不处理。

    // skip ignored export variables
    const { fileExportVars = [] } = config;
    const exportVars = fileExportVars.find(f => f.file === file)?.vars;
    if (
      isExported(variable) &&
      exportVars &&
      !exportVars.includes(def.name.name)
    ) {
      continue;
    }

这样外部就可以通过 fileExportVars 的配置来限定分析范围:

 rules: {
   'no-unused-vars': [2, {
      fileExportVars: [{file: 'a.ts', vars: ['a', 'b']}]
   }]
 }

接着删除原版中 收集未使用变量时 对 isExported 的判断,把 export出去但文件内部为使用 的变量也收集起来。由于上一步已经限定了变量名,所以这里只会收集到 import/no-unused-modules 分析出来的变量 。

if (
  !isUsedVariable(variable) &&
- !isExported(variable) &&
  !hasRestSpreadSibling(variable)
) {
   unusedVars.push(variable);
}

如何稳定的删除无用的exports?

当我们在 IDE 中编写代码时,有时会发现一些 ESLint 的飘红,将鼠标移动上去的时候,可能会发现一个自动修复的按钮,点击按钮既可以自动帮我们修复 ESLint 的 error,但有的飘红却没有这个按钮。这其实是 ESLint 的 rule fixer 的作用。参考官方文档的 Apply Fixer 部分,每个ESLint rule的开发者都可以决定自己这个规则 是否可以自动修复,以及如何修复

然而遗憾的是,官方的 no-unused-vars 并未为提供代码的自动修复方案(Add fix/suggestions to no-unused-vars rule · Issue #14585 · eslint/eslint · GitHub),所以需要我们自己对这个 rule 编写相应的 fixer 来删除未使用的变量。

ESLint 还可以解决 删除之后引入新的无用变量的问题。ESLint 会 重复执行 fix函数(最多10次),直到不再有新的可修复错误为止。

我是如何删除RN屎山项目中的死代码

核心改动(参考 eslint-plugin/no-unused-vars.js at master · aladdin-add/eslint-plugin · GitHub)

 const commaFilter = { filter: token => token.value === ',' };

function checkAndRemoveExport(fixer, removedNode) {
  let curNode = removedNode;
  while (curNode !== null) {
    if (curNode.type === 'ExportNamedDeclaration') {
      return fixer.remove(curNode);
    }
    curNode = curNode.parent;
  }
  return fixer.remove(removedNode);
}

function fix(fixer, node, sourceCode) {
  const { parent } = node;
  if (!parent) {
    return null;
  }
  const grand = parent.parent;

  switch (parent.type) {
    case 'ImportSpecifier':
    case 'ImportDefaultSpecifier':
    case 'ImportNamespaceSpecifier':
      if (!grand) {
        return null;
      }
      // If there is only one import variable, delete it directly
      if (grand.specifiers.length === 1) {
        return fixer.remove(grand);
      }
      // If there are multiple imported and the one to be deleted is not the last one, it should be deleted together with the following comma
      if (parent !== grand.specifiers[grand.specifiers.length - 1]) {
        const comma = sourceCode.getTokenAfter(parent, commaFilter);

        return [fixer.remove(parent), fixer.remove(comma)];
      }
      if (
        grand.specifiers.filter(
          specifier => specifier.type === 'ImportSpecifier'
        ).length === 1
      ) {
        const start = sourceCode.getTokenBefore(parent, commaFilter),
          end = sourceCode.getTokenAfter(parent, {
            filter: token => token.value === '}',
          });
        return fixer.removeRange([start.range[0], end.range[1]]);
      }

      // If importing more than one, delete the last one, together with the previous comma
      return fixer.removeRange([
        sourceCode.getTokenBefore(parent, commaFilter).range[0],
        parent.range[1],
      ]);

    // for no-dead-export-specifier rule
    case 'ExportSpecifier':
      if (grand.specifiers.length === 1) {
        return fixer.remove(grand);
      }
      if (parent !== grand.specifiers[grand.specifiers.length - 1]) {
        const comma = sourceCode.getTokenAfter(parent, commaFilter);
        return [fixer.remove(parent), fixer.remove(comma)];
      }
      return fixer.removeRange([
        sourceCode.getTokenBefore(parent, commaFilter).range[0],
        parent.range[1],
      ]);
    // for no-dead-export-specifier rule
    case 'ExportAllDeclaration':
      return fixer.remove(parent);

    case 'VariableDeclarator':
      if (!grand) {
        return null;
      }

      if (!parent.init) {
        return null;
      }

      if (grand.declarations.length === 1) {
        return checkAndRemoveExport(fixer, grand);
      }

      if (parent !== grand.declarations[grand.declarations.length - 1]) {
        const comma = sourceCode.getTokenAfter(parent, commaFilter);
        return [fixer.remove(parent), fixer.remove(comma)];
      }

      return [
        fixer.remove(sourceCode.getTokenBefore(parent, commaFilter)),
        fixer.remove(parent),
      ];

    /**
     * class A {}
     * ----- flow type -----
     * type A {};
     * ----- ts type -----
     * type A = {};
     * interface B {};
     * enum C {};
     */
    case 'TypeAlias':
    case 'InterfaceDeclaration':
    case 'ClassDeclaration':
    case 'TSEnumDeclaration':
    case 'TSInterfaceDeclaration':
    case 'TSTypeAliasDeclaration':
      return checkAndRemoveExport(fixer, parent);

    default:
      return null;
  }
}

module.exports = fix;

最后删除完分析出来的无用变量,可以会导致代码格式混乱,所以我们可以手动调用prettier API格式化代码即可。

整体思路

删除无用文件

使用webpack来构建项目,在构建的过程中找出所有使用到的文件列表,然后通过 fast-glob 梳理出项目中所有的文件列表,两者作差即可得到未使用的文件列表,最后调用node api删除即可。

删除无用exports

  • 通过 import/no-unused-modules eslint rule 分析出哪些文件中的哪些 exports 没有被使用
  • 改写 no-unused-vars rule,然后将上一步中分析出来的未使用的exports,依次调用ESLint API来分析 export 出去的变量在 代码内部是否有使用到
  • 实现 no-unused-vars rule的 fixer 来删除未使用到的exports
  • 调用 prettier API 格式化代码

结果

  • 最终删除了 2300+ 文件,仓库体积减小了 25%(删除无用文件并不会导致bundle体积减小,因为这部分本身就不会被打包到bundle中)
  • 删除了3000+ 无用exports,bundle体积减小 300KB 以上
  • 已经将此功能集成到 CI 中,当提交MR时,会检查此feature会不会引入无用文件和无用exports,确保代码干净,降低仓库和bundle体积快速增大的风险

因为篇幅有限,在做的过程中还遇到了很多问题,这里并没有记录,但是万恶之源都是因为屎山项目中的别名和Flow语法 😥

转载自:https://juejin.cn/post/7236286358390095930
评论
请登录