如何用typescript寫一個處理console的babel插件

技術點介紹

經過這篇文章你能夠學到:javascript

  • ts-mochachai來寫測試用例,
  • 如何寫一個babel插件,
  • 如何用schame-utils來作options校驗,
  • typescript雙重斷言的一個應用場景
  • 如何組織測試代碼

前言

console對象對前端工程師來講是必不可少的api,開發時咱們常常經過它來打印一些信息來調試。但生產環境下console有時會引發一些問題。前端

最近公司內報了一個bug,console對象被重寫了可是沒有把全部的方法都重寫,致使了報錯,另外考慮到console會影響性能,因此最後定的解決方案是把源碼中全部的console都刪掉。java

生產環境下刪除console是沒問題的,可是這件事不須要手動去作。在打包過程當中,咱們會對代碼進行壓縮,而壓縮的工具都提供了刪除一些函數的功能,好比terser支持drop_console來刪除console.*,也能夠經過pure_funcs來刪除某幾種console的方法。 node

可是這種方案對咱們是不適用的,由於咱們既有react的項目又有react-native的項目,react-native並不會用webpack打包,也就不會用terser來壓縮。react

其實源碼到最終代碼過程當中會經歷不少次ast的解析,好比eslintbabelterser等,除了eslint主要是用來檢查ast,並不會作過多修改,其他的工具均可以來完成修改ast,刪除console這件事情。terser不能夠用,那麼咱們能夠考慮用babel來作。webpack

並且,咱們只是但願在生產環境下刪除console,在開發環境下console仍是頗有用的,若是能擴展一下console,讓它功能更強大,好比支持顏色的打印,支持文件和代碼行數的提示就行了。git

因而咱們就開發了本文介紹的這個插件: babel-plugin-console-transformgithub

演示

先看下效果再講實現。web

好比源碼是這樣的:typescript

生產環境下轉換後的代碼:

開發環境下轉換後的代碼:

運行效果:

生產環境刪除了console,開發環境擴展了一些方法,而且添加了代碼行數和顏色等。

接下來是功能的細節還有實現思路。

功能

按照需求,這個插件須要在不一樣的環境作不一樣的處理,生產環境能夠刪除console,開發環境擴展console。

生產環境刪除console並非所有刪除,還須要支持刪除指定name的方法,好比log、warn等,由於有時console.error是有用的。並且有的時候根據方法名還不能肯定能不能刪除,要根據打印的內容來肯定是否是要刪。

開發環境擴展console要求不改變原生的api,擴展一些方法,這些方法會被轉換成原生api,可是會額外添加一些信息,好比添加代碼文件和行數的信息,添加一些顏色的樣式信息等。

因而console-transform這個插件就有了這樣的參數

{
    env: 'production',
    removeMethods: ["log", "*g*", (args) => args.includes('xxxx')],
    additionalStyleMethods: {
        'success': 'padding:10px; color:#fff;background:green;',
        'danger': 'padding:20px; background:red;font-size:30px; color:#fff;'
    }
}
複製代碼

其中env是指定環境的,能夠經過process.env.NODE_ENV來設置。

removeMethods是在生產環境下要刪除的方法,能夠傳一個name,支持glob,也就是 \*g*是刪除全部名字包含g的方法;並且能夠傳一個函數,函數的參數是console.xxx的全部參數,插件會根據這個函數的返回值來決定是否是刪除改console.xxx。多個條件的時候,只要有一個生效,就會刪。

additionalStyleMethods裏面能夠寫一些擴展的方法,好比succes、danger,分別定義了他們的樣式。其實插件自己提供了 red、green、orange、blue、bgRed、bgOrange、bgGreen、bgBlue方法,經過這個參數能夠自定義,開發環境console怎麼用都行。

實現

接下來是重頭戲,實現思路了。

首先介紹下用到的技術,代碼是用typescript寫的,實現功能是基於 @babel/core@babel/types,測試代碼使用ts-mochachai寫的,代碼的lint用的eslintprettier

主要邏輯

babel會把代碼轉成ast,插件裏能夠對對ast作修改,而後輸出的代碼就是轉換後的。babel的插件須要是一個返回插件信息的函數。

以下, 參數是babelCore的api,裏面有不少工具,咱們這裏只用到了 types來生成一些ast的節點。返回值是一個PluginObj類型的對象。

import BabelCore, { PluginObj } from '@babel/core';

export default function({ types, }: typeof BabelCore): PluginObj<ConsoleTransformState> {
  return {
      name: 'console-transform',
      visitor: {...}
  }
}
複製代碼

其中ConsoleTransformState裏面是咱們要指定的類型,這是在後面對ast處理時須要拿到參數和文件信息時用的。

export interface PluginOptions {
  env: string;
  removeMethods?: Array<string | Function>;
  additionalStyleMethods?: { [key: string]: string };
}

export interface ConsoleTransformState {
  opts: PluginOptions;
  file: any;
}
複製代碼

PluginOptions是options的類型,env是必須,其他兩個可選,removeMethods是一個值爲string或Function的數組,additionalStyleMethods是一個值爲string的對象。 這都是咱們討論需求時肯定的。(其中file是獲取代碼行列數用的,咱們找到它的類型,就用了any。)

返回的插件信息對象有一個visitor屬性,能夠聲明對一些節點的處理方式,咱們須要處理的是CallExpression節點。(關於代碼對應的ast是什麼樣的,能夠用astexplorer這個工具看)。

{
    CallExpression(path, { opts, file }) {
        validateSchema(schema, opts);
        const { env, removeMethods, additionalStyleMethods } = opts;
        const callee = path.get('callee');
        if (
            callee.node.type === 'MemberExpression' &&
            (callee.node.object as any).name === 'console'
        ) {
           ...
        }
    },
}
複製代碼

這個方法就會在處理到CallExpression類型的節點時被調用,參數path 能夠拿到一些節點的信息,經過path.get('callee')拿到調用信息,而後經過node.type過濾出console.xxx() 而不是xxx()類型的函數調用,也就是MemberExpression類型,再經過callee.node.object過濾出console的方法。

實現production下刪除console

接下來就是實現主要功能的時候了

const methodName = callee.node.property.name as string;
if (env === 'production') {
    ...
    return path.remove();
} else {
    const lineNum = path.node.loc.start.line;
    const columnNum = path.node.loc.start.column;
      ...
    path.node.arguments.unshift(...);
    callee.node.property.name = 'log';
}
複製代碼

先看主要邏輯,production環境下,調用path.remove(),這樣console就沒了,其餘環境對console的參數(path.node.arguments.)作一些修改,在前面多加一些參數,而後把方法名(callee.node.property.name)改成log。大致框架就是這樣的。

而後細化一下:

production的時候,當有removeMethods參數時,要根據其中的name和funciton來決定是否刪除:

if (removeMethods) {
    const args = path.node.arguments.map(
        item => (item as any).value,
    );
    if (isMatch(removeMethods, methodName, args)) {
        return path.remove();
    }
    return;
}
return path.remove();
複製代碼

經過把path.node.arguments把全部的args放到一個數組裏,而後來匹配條件。以下,匹配時根據類型是string仍是function決定如何調用。

const isMatch = (
  removeMethods: Array<string | Function>,
  methodName: string,
  args: any[],
): boolean => {
  let isRemove = false;
  for (let i = 0; i < removeMethods.length; i++) {
    if (typeof removeMethods[i] === 'function') {
      isRemove = (removeMethods[i] as Function)(args) ? true : isRemove;
    } else if (mm([methodName], removeMethods[i] as string).length > 0) {
      isRemove = true;
    }
  }
  return isRemove;
};
複製代碼

若是是function就把參數做爲參數傳入,根據返回值肯定是否刪除,若是是字符串,會用mimimatch作glob的解析,支持**、 {a,b}等語法。

實現非production下擴展console

當在非production環境下,插件會提供一些內置方法

const styles: { [key: string]: string } = {
  red: 'color:red;',
  blue: 'color:blue;',
  green: 'color:green',
  orange: 'color:orange',
  bgRed: 'padding: 4px; background:red;',
  bgBlue: 'padding: 4px; background:blue;',
  bgGreen: 'padding: 4px; background: green',
  bgOrange: 'padding: 4px; background: orange',
};
複製代碼

結合用戶經過addtionalStyleMethods擴展的方法,來對代碼作轉換:

const methodName = callee.node.property.name as string;

const lineNum = path.node.loc.start.line;
const columnNum = path.node.loc.start.column;

const allStyleMethods = {
  ...styles,
  ...additionalStyleMethods,
};

if (Object.keys(allStyleMethods).includes(methodName)) {
  const ss = path.node.arguments.map(() => '%s').join('');
  path.node.arguments.unshift(
    types.stringLiteral(`%c${ss}%s`),
    types.stringLiteral(allStyleMethods[methodName]),
    types.stringLiteral(
      `${file.opts.filename.slice( process.cwd().length, )} (${lineNum}:${columnNum}):`,
    ),
  );
  callee.node.property.name = 'log';
}
複製代碼

經過methodName判斷出要擴展的方法,而後在參數(path.node.arguments)中填入一些額外的信息 ,這裏就用到了@babel/core提供的types(實際上是封裝了@babel/types的api)來生成文本節點了,最後把擴展的方法名都改爲log。

實現options的校驗

咱們邏輯寫完了,可是options尚未校驗,這裏能夠用schema-utils這個工具來校驗,經過一個json對象來描述解構,而後調用validate的api來校驗。webpack那麼複雜的options就是經過這個工具校驗的。

schema以下,對envremoveMethodsadditionalStyleMethods都是什麼格式作了聲明。

export default {
  type: 'object',
  additionalProperties: false,
  properties: {
    env: {
      description:
        'set the environment to decide how to handle `console.xxx()` code',
      type: 'string',
    },
    removeMethods: {
      description:
        'set what method to remove in production environment, default to all',
      type: 'array',
      items: {
        description:
          'method name or function to decide whether remove the code',
        oneOf: [
          {
            type: 'string',
          },
          {
            instanceof: 'Function',
          },
        ],
      },
    },
    additionalStyleMethods: {
      description:
        'some method to extend the console object which can be invoked by console.xxx() in non-production environment',
      type: 'object',
      additionalProperties: true,
    },
  },
  required: ['env'],
};
複製代碼

測試

代碼寫完了,就到了測試環節,測試的完善度直接決定了你這個工具可不可用。

options的測試就是傳入各類狀況的options參數,看報錯信息是否正確。這裏有個知識點,由於options須要傳錯,因此確定類型不符合,使用as any as PluginOptions的雙重斷言能夠繞過類型校驗。

describe('options格式測試', () => {
  const inputFilePath = path.resolve(
    __dirname,
    './fixtures/production/drop-all-console/actual.js',
  );

  it('env缺失會報錯', () => {
    const pluginOptions = {};
    assertFileTransformThrows(
      inputFilePath,
      pluginOptions as PluginOptions,
      new RegExp(".*configuration misses the property 'env'*"),
    );
  });

  it('env只能傳字符串', () => {
    const pluginOptions = {
      env: 1,
    };
    assertFileTransformThrows(
      inputFilePath,
      (pluginOptions as any) as PluginOptions,
      new RegExp('.*configuration.env should be a string.*'),
    );
  });

  it('removeMethods的元素只能是string或者function', () => {
    const pluginOptions = {
      env: 'production',
      removeMethods: [1],
    };
    assertFileTransformThrows(
      inputFilePath,
      (pluginOptions as any) as PluginOptions,
      new RegExp(
        '.*configuration.removeMethods[.*] should be one of these:s[ ]{3}string | function.*',
      ),
    );
  });

  it('additionalStyleMethods只能是對象', () => {
    const pluginOptions: any = {
      env: 'production',
      additionalStyleMethods: [],
    };
    assertFileTransformThrows(
      inputFilePath,
      pluginOptions as PluginOptions,
      new RegExp(
        '.*configuration.additionalStyleMethods should be an object.*',
      ),
    );
  });
});
複製代碼

主要的仍是plugin邏輯的測試。

@babel/core 提供了transformFileSync的api,能夠對文件作處理,我封裝了一個工具函數,對輸入文件作處理,把結果的內容和另外一個輸出文件作對比。

const assertFileTransformResultEqual = (
  inputFilePathRelativeToFixturesDir: string,
  outputFilePath: string,
  pluginOptions: PluginOptions,
): void => {
  const actualFilePath = path.resolve(__dirname, './fixtures/', inputFilePathRelativeToFixturesDir,);
  const expectedFilePath = path.resolve(__dirname,'./fixtures/',outputFilePath);

  const res = transformFileSync(inputFilePath, {
    babelrc: false,
    configFile: false,
    plugins: [[consoleTransformPlugin, pluginOptions]]
  });
  assert.equal(
    res.code,
    fs.readFileSync(expectedFilePath, {
      encoding: 'utf-8',
    }),
  );
};
複製代碼

fixtures下按照production和其餘環境的不一樣場景分別寫了輸入文件actual和輸出文件expected。好比production下測試drop-all-console、drop-console-by-function等case,和下面的測試代碼一一對應。

代碼裏面是對各類狀況的測試

describe('plugin邏輯測試', () => {
  describe('production環境', () => {
    it('默認會刪除全部的console', () => {
      const pluginOptions: PluginOptions = {
        env: 'production',
      };
      assertFileTransformResultEqual(
        'production/drop-all-console/actual.js',
        'production/drop-all-console/expected.js',
        pluginOptions,
      );
    });
    it('能夠經過name刪除指定console,支持glob', () => {...});
    it('能夠經過function刪除指定參數的console', () => {...}
});

  describe('其餘環境', () => {
    it('非擴展方法不作處理', () => {...});
    it('默認擴展了red 、green、blue、orange、 bgRed、bgGreen等方法,而且添加了行列數', () => {...});
    it('能夠經過additionalStyleMethods擴展方法,而且也會添加行列數', () => {...});
    it('能夠覆蓋原生的log等方法', () => {...});
  });
});
複製代碼

總結

這個插件雖然功能只是處理console,但細節仍是蠻多的,好比刪除的時候要根據name和function肯定是否刪除,name支持glob,非production環境要支持用戶自定義擴展等等。

技術方面,用了schema-utils作options校驗,用ts-mocha結合斷言庫chai作測試,同時設計了一個比較清晰的目錄結構來組織測試代碼。

麻雀雖小,五臟俱全,但願你們能有所收穫。這個插件在咱們組已經開始使用,你們也可使用,有bug或者建議能夠提isssue和pr。

剛開始作公衆號(前端源碼深潛),之後會專一源碼和一些工具實現的分享。對這方面感興趣的能夠關注。

相關文章
相關標籤/搜索