趁着手头业务不忙,简单记一次封装 console.log 的奇葩经历😶

1,060 阅读8分钟

扯皮

到年底了,没想到我手里的业务少之又少,现在每天给后端当测试,给 PD 当传话筒。想着趁着这段时间学点东西结果还没看一会儿就又被拉着去帮后端排查问题,无聊至极

结果就是在这种无聊的环境下,我自己干了一件更无聊的事😅,所以简单记录一下

正文

问题背景

为什么想到要封装 console.log 是因为最近在调试项目的时候发现一个很鸡肋的地方:

image.png

简单描述一下就是经常会在 for 循环里 console.log 来看控制台的效果,但是我可能只是想看某一项,所以就需要在外面加一个条件判断🤔,当然上面写的例子比较简单,放到真实项目中就不太一样了

一是项目里的数据结构比较复杂,二是混杂着各种业务代码。本来看着就不爽,为了调试还需要单独写一个 if 判断语句,判断语句写的挺长结果一看里面就只为了一个 console,就挺无语的😑,所以我在想这里的条件判断是不是应该封装到 console.log 中而不是直接侵入业务代码🤨

还有一个场景,回想到上家实习公司的项目,那时候我也习惯直接 console 进行调试,但是打开控制台后它的画风是这样的:

image.png

这里也是为了方便省略了很多,真实情况是日志撑满了我 15.6 寸笔记本的高度🤐,只能说接手的同学太多,而且还都不愿意删

所以后来我在开发调试的时候都习惯这样写:console.log("check:", xxx),也就是前面加一个 "check:" 来与当前已有的日志进行区分

直到后来我发现有一位同学也是这样搞的都是用 "check",导致我为了区分它的还要换另外一种姿势😑

我决定简单封装一下 console.log,主要有两个点:

  1. 更方便的条件判断 log
  2. 允许配置 log 前缀与其他 log 进行区分

API 设计

首先关于条件判断 if 肯定是省不了的,我们可以把它封装到内部,那怎么让用户使用起来更方便呢?我想参照之前写单元测试的 API 类似链式调用的形式,比如这样:

image.png

复杂的判断肯定还是需要用户手写的,所以只能做到将判断逻辑收敛到 logger 中,以此来减少对业务代码的侵入

至于前缀的玩法就有很多了,比如以 Logger 类封装允许用户进行公共配置,又或者在调用时提供相应的 API 接口,也可以在调用时以 option 对象配置

而且我们知道 console.log 是可以编写一些简单样式的:

image.png

关于样式这些玩法在掘金上有不少相关文章,感兴趣的可以看一下👇:

领导被我的花式console.log吸引了!直接写入公司公共库!- 掘金

5分钟教你使用 console.log 输出五彩斑斓的黑console.log - 掘金

不过我这里不需要这么多花里胡哨的,毕竟如果控制台全是这种五颜六色的说实话还不如之前的了😅,我们可以内置几个类似组件库标签的样式供用户使用,起到与其他日志区分的作用即可:

image.png

不过 CSS 编写方式确实有点鸡肋,因为我们的项目都是在 React 下使用,所以让用户按正常样式编写,我们内部处理成字符串即可,至于 API 我们有以下三种方式:

image.png

前缀函数封装

因为前缀这部分配置都在 log 前,所以我们先来解决这部分

React.CSSProperties 里的样式编写都是小驼峰,所以我们需要写一个工具函数将其进行转换,交给 AI 就行:

image.png

养成工具函数写 jsDoc 注释的好习惯,哪怕不标注参数只写一行描述都比不写强🙃

现在写起来就方便多了:

image.png

下面我们先来封装一个简单的 FsLogger 类,先简单来实现一下 prefix 和 prefixStyle:

image.png

用户可以在实例化时进行前缀配置,也可以实例调用对应的方法进行配置,当然优先级肯定是实例调用,所以内部我们设置了相关私有属性,这在最终 log 时会进行合并

需要注意的是这里严格意义上来讲并不完全是链式调用,是有一定调用顺序要求的,不过这里的 prefix 和 prefixStyle 倒是不用考虑顺序

所以我们不能无脑返回 this,要考虑到 this 指向问题,具体细节看上面标注的 bind 使用

这两个方法实现后我们来实现 log 函数,它会收敛我们一开始 API 设计的三种调用方式,并保存用户要打印的内容:

image.png

前缀配置优先级:log 配置 > 函数调用配置 > 实例化配置,最终我们收敛在了 opt 中,真正执行打印操作的逻辑我们使用私有方法 __log:

image.png

这里面只需要根据用户前缀配置 options 的情况进行逻辑处理打印即可,别忘了打印结束后把保存的私有变量全部置空

现在我们的 console 就可以这样用了,符合预期:

image.png

image.png

实现 expect、toBe

下面来到我们最开始提的需求,其实很简单无非是把 if 语句给放到里面,只不过我们在 API 设计中考虑到要链式调用,我们依旧以 log 作为最终收口,添加额外的 __isLog 变量来判断是否需要打印:

image.png

关于 toBe 我们提取了 typeof 返回的基本数据类型,而回调的参数类型设置为 unknown,因为毕竟是用户传进来的,也无法在一开始借助泛型推导了,所以直接把决定权给用户,想确定就直接 as 即可

突然想到之前实现 log 里的泛型好像也没什么用🤔,还是给删了吧...

现在我们可以这样用了:

image.png

image.png

主题色拓展

让用户自己手搓一些样式还是不太合适的,我们考虑内置类似标签主题色方便使用,关键在于这里的 API 又该怎么设计呢?🤔

一开始我们想到无脑 logger.primarylogger.wran 但是这又与 console 上携带的那几个方法有些冲突,我们这里的的样式主要作用于前缀上,所以命名还是更具体一些比较好

先来写几个内置的主题样式:

image.png

我们不再针对于这几个主题都写一个独立的方法了,可以统一使用 usePrefixStyle 来设置主题,它的作用其实很 prefixStyle 一样,只不过会使用我们内置的样式:

当然上小节忘记把 expect 与 prefix、prefixStyle 联动了,在这里顺手补上🤪

image.png

由于 usePrefixStyleprefixStyle 功能上基本是一致的,所以就不互相引用了,下面直接看效果:

image.png

image.png

既然已经内置主题了,假如用户还想自定义主题呢?当然可以,无非就是再加一个配置项的事😋:

image.png

image.png

image.png

部分细节问题

以上就是我们实现的全部内容了,其实还真不少,但缺陷也不少,我们简单来盘一下🧐

第一个问题:toBe 的调用顺序问题

我们在实现 prefix 的时候就有提到实际上我们的链式调用是有强调顺序的,像 toBe 本身就需要与 expect 强绑定,也就是说它必须紧跟在 expect 后面

而我们目前实现只保证了 expect 后面是 toBe,没有保证 toBe 的前面一定是 expect,所以你可以这样用:

logger.toBe('string').log();

很显然这是一种错误用法,怎么避免这种调用呢?很简单,应该把它作为私有属性:

image.png

但实际上这种私有属性只是 TS 编译期间的限制,想直接无视肯定也可以:

image.png

image.png

我们这里就不再延申了,对 JS 私有属性感兴趣的可以看光哥的这篇文章👇:

JS 私有属性的 6 种实现方式,你用过几种?- 掘金

第二个问题:上下文参数混淆

链式调用有一个很大的问题在于我们无法知道用户到底停在哪里,我们的实现如果要想有打印功能是必须要以 log 方法调用结尾的,但耐不住用户这样错误的使用:

const logger = new FsLogger({ prefix: "check", prefixStyle: { color: "red" } });
logger.expect(1).toBe("number");
logger.prefix("hello").prefixStyle({ color: "blue" });
logger.log();

image.png

这样我们最底部的打印结果就会受上面 toBe、prefix、prefixStyle 的污染

这个问题要解决还是比较困难的,因为我们的实现不像 JQuery 那样直接 $(selector) 0 帧起手获取 DOM,而是混到了中间调用且位置也不固定

所以这块只能要求用户正确的以 log 结尾使用,其次我们可以暴露出一个 clearContext 的方法,万一真有这个场景出现上述类似问题,允许用户先手动进行清除:

image.png

这样至少能够解决污染问题:

const logger = new FsLogger({ prefix: "check", prefixStyle: { color: "red" } });
logger.expect(1).toBe("number");
logger.prefix("hello").prefixStyle({ color: "blue" });
logger.clearContext().log("hello world");

image.png

第三个问题:打印结果右侧定位链接定位不准确问题

这个问题几乎无解,因为 console 结果位置就是按照调用位置来的,我们做了一层封装后肯定就在自己的工具库里打印,所以肯定无法直接定位到原来业务代码位置的

虽然也可以另辟蹊径,比如在非严格模式下的 caller 能够拿到函数的调用者,但是光一个非严格模式就直接卡死了🙃

其次也可以借助 error 对象的 stack 属性来获取路径,比如我们可以让用户这样传入,然后内部提取:

image.png

image.png

但是这里 stack 路径在项目开发时也不一定会准确,毕竟我们现在的前端资源都会进行打包😇,其次在我们公司的业务中经常会有代理前端资源映射的操作,这里的 stack 是肯定无法回显到业务代码中的

不过话又说回来,我之所以去封装 console.log 的业务场景也并不是去拿右侧定位信息,恰恰相反,实际上我是知道在业务代码中哪个位置进行打印,只是想第一时间找到打印的结果,所以这里不实现倒也没什么🤣

End

最后源码奉上,大部分时间都是白天抽空敲的,部分实现细节也没有细究,就是无聊时图一乐罢了😇,现在看看还挺长的:

/** 驼峰转为中横线 */
function camelToKebab(str: string) {
  return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}

/** 将样式对象转为字符串 */
function transformStyleToStr(style: React.CSSProperties) {
  let str = "";
  for (const key in style) {
    str += `${camelToKebab(key)}:${style[key as keyof React.CSSProperties]};`;
  }
  return str;
}

type Theme = "primary" | "success" | "info" | "error";

const themePublicStyle = { padding: "2px 4px", borderRadius: "4px" };

const themesPrefixStyle: Record<Theme, React.CSSProperties> = {
  primary: {
    color: "#548bfb",
    background: "#e9f3ff",
    ...themePublicStyle,
  },
  success: {
    color: "#d4380d",
    background: "#fff2e8",
    ...themePublicStyle,
  },
  info: {
    color: "#c41d7f",
    background: "#fff0f6",
    ...themePublicStyle,
  },
  error: {
    color: "#ff4d4f",
    background: "#fff2f0",
    ...themePublicStyle,
  },
};

interface Options {
  prefix?: string;
  prefixStyle?: React.CSSProperties;
  extendPrefixThemes?: Record<string, React.CSSProperties>;
}

type DataType = "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function";

type ToBeParams = DataType | ((value: unknown) => boolean);

class FsLogger {
  __value: any;
  private __isLog: boolean;
  private __prefix: Options["prefix"];
  private __prefixStyle: Options["prefixStyle"];
  private __prefixOptions: Options;
  private __prefixThemes = themesPrefixStyle;
  constructor(options: Options = {}) {
    this.__prefixOptions = options;
    this.__value = undefined;
    this.__isLog = true;
  }

  private __composeOptions(options?: Options) {
    let opt: Options = {};

    opt = {
      prefix: this.__prefix ? this.__prefix : this.__prefixOptions.prefix,
      prefixStyle: this.__prefixStyle ? this.__prefixStyle : this.__prefixOptions.prefixStyle,
    };

    if (options && Object.keys(options).length) {
      opt = Object.assign(opt, options);
    }
    return opt;
  }

  private __clear() {
    this.__prefix = undefined;
    this.__prefixStyle = undefined;
    this.__value = undefined;
    this.__isLog = true;
  }

  private __log(value: any, options: Options) {
    const { prefix, prefixStyle } = options;
    if (this.__isLog) {
      if (prefix && prefixStyle) {
        console.log(`%c${prefix}`, transformStyleToStr(prefixStyle), value);
      } else if (prefix) {
        console.log(prefix, value);
      } else {
        console.log(value);
      }
    }
    this.__clear();
  }

  private __toBe(params: ToBeParams) {
    if (typeof params === "string") {
      this.__isLog = typeof this.__value === params;
    } else {
      this.__isLog = params(this.__value);
    }
    return {
      log: this.log.bind(this),
    };
  }

  clearContext() {
    this.__clear();
    return this;
  }

  expect(value: any) {
    this.__value = value;
    return {
      toBe: this.__toBe.bind(this),
    };
  }

  prefix(text: string) {
    this.__prefix = text;
    return {
      prefixStyle: this.prefixStyle.bind(this),
      usePrefixStyle: this.usePrefixStyle.bind(this),
      useDefinePrefixStyle: this.useDefinePrefixStyle.bind(this),
      expect: this.expect.bind(this),
      log: this.log.bind(this),
    };
  }

  prefixStyle(style: React.CSSProperties) {
    this.__prefixStyle = style;
    return {
      prefix: this.prefix.bind(this),
      expect: this.expect.bind(this),
      log: this.log.bind(this),
    };
  }

  usePrefixStyle(name: Theme) {
    this.__prefixStyle = this.__prefixThemes[name];
    return {
      prefix: this.prefix.bind(this),
      expect: this.expect.bind(this),
      log: this.log.bind(this),
    };
  }

  useDefinePrefixStyle(name: string) {
    const theme = this.__prefixOptions.extendPrefixThemes?.[name];
    if (theme) this.__prefixStyle = theme;
    return {
      prefix: this.prefix.bind(this),
      expect: this.expect.bind(this),
      log: this.log.bind(this),
    };
  }

  log(value?: any, options: Options = {}) {
    if (value) {
      this.__value = value;
    }
    this.__log(this.__value, this.__composeOptions(options));
  }
}