从Console对象输出到完美的日志导出探索

2,217 阅读8分钟

前言

作为一个前端开发,最常使用的浏览器莫过于 Chrome,对于 Chrome 的开发者工具更是大家最贴心的工具。但是如果你留心观察过 Console 的对象输出,就会发现一个奇怪的现象:

比如我们定义一个对象,如下:

var a = { prop1: 1 }

这时候我们打印这个对象,输出一个收起的对象,如下:

1002774349.png

这时我们修改一下对象,并输出

a.prop1 = 2

这时候输出的也是一个默认收起的对象,如下:

1002776955.png

但是这个时候,我们点击上面输出的对象展开,就会发现,这个输出也被修改了

1002672342.png

接着我们再次修改对象:

a.prop1 = 3

这个时候查看第一次的输出(前述已经展开预览过),会发现哪怕再次点击展开收起对象,值都不会再更新了,一直保持第一次被展开时获取到的对象数据:

1002645844.png

而第二次的输出,我们点击展开后,则会得到最新的对象数据:

1002781340.png

这个是为什么呢?

一、Console的日志输出模块逻辑

实际上,Chrome 的开发者工具模块交互,其实也是一个前端应用,和前端开发的其他页面一样,我们可以单独部署这个前端应用,然后分析

注,如果你想了解更多关于如何自己部署这个前端,可以去看我的这篇文章:《远程调试原理及端智能场景下远程调试实现方案》这里就不赘述了

开发者工具这个可视化的前端应用,其实是通过 CDP 和调试后端(代码真正执行的地方,比如浏览器中的 V8 引擎)交互,而这两部分建立交互的方式则是通过消息通道,这里也即 websocket

通过在开发者工具这个前端应用的入口 html 访问时,增加 ws 参数,即可建立这个连接,打开开发者工具,我们就可以看到这个接口

1002917630.png

我们所有在开发者工具中做的操作,都只是一种前端可视化交互,所有的数据及代码执行,都是在调试后端中。这些日志输出,其实是通过上述的 websocket 消息通道,由调试后端告诉开发者工具这个前端应用,做什么样的数据展示。

如上述输出的对象,实际上是通过调试后端发送了对应的消息,Runtime.consoleAPICalled

1003522796.png

其中,type用来表明使用了 console 的哪个方法,可选值有 log, debug, info, error, warning, dir, dirxml, table, trace, clear, startGroup, startGroupCollapsed, endGroup, assert, profile, profileEnd, count, timeEnd

stackTrace用来表明调用栈

而最关键的则是args参数,是最终需要输出的日志参数信息,它的定义如下:

1003648959.png

分析上述 消息,此时 args 只有一个参数,其类型是一个对象 object,但没有具体的对象属性信息,有一个 objectId 属性

而默认展示的对象信息,则是读取的 preview 属性,全部说明详见 Runtime.ObjectPreview ,使用 overflow 表明属性没有全部获取预览(展示...),使用 properties 获取预览的属性信息

至此,我们就知道了,实际上,开发者工具中的 Console模块,展示了对象的输出时,实际上并没有获取最终的对象全部属性信息,仅仅是做了一个简单的"预览"

那么当我们点击日志对象展开时,会发生什么呢?

前端会发两个消息出去,两个消息都归属于 Runtime.getProperties,用来向调试器后端查询对象的属性信息,如下:

1003565706.png

调试后端再收到这个消息后,则会根据查询的参数配置,返回对应对象的全部属性信息

1003683353.png

1003741697.png

之所以会有两个消息,是因为 ownPropertiesaccessorPropertiesOnly 查询参数不同,分别用来查询原型属性及对象自身属性

至此,我们就知道了,当第一次点击展开的时候,实际上会发接口查询对象此时的全部属性信息,并渲染到页面上,因此,此时展开渲染的是对象当时的属性信息

后面再次展开查看对象信息时,由于已经渲染完了,此时开发者工具的前端实现,缓存了这个渲染结果,并不会再次触发接口查询

二、更好的日志导出

如果你在 Console 面板下,右键就会弹出一个菜单,此时会有一个日志导出的选项,可以将控制台目前展示的日志导出

1003751068.png

我们尝试导出并看一下导出的日志信息,会发现对于对象这类信息的导出内容并不全面,如下:

log.ts:33 {prop1: "prop1", prop2: "prop2", prop3: 3, prop4: 4, prop5: Array(5), …}

这...对于依赖这个导出的日志信息排查问题的场景,显然并不能满足需求呀,我们从源码的角度再来看一下为什么会是这个样子。

开发者工具的源码,需要从 Chromium 项目中,编译 Chromium DevTools Frontend,这里我直接分享我编译好的开发者工具前端项目:全部源码详见:github.com/tongyuchan/…

我使用的是 chromium/4400 分支,console 这部分源码基本都在 console 目录下

├── ConsoleContextSelector.js
├── ConsoleFilter.js
├── ConsolePanel.js
├── ConsolePinPane.js
├── ConsolePrompt.js
├── ConsoleSidebar.js
├── ConsoleView.js
├── ConsoleViewMessage.js
├── ConsoleViewport.js
├── console-legacy.js
├── console-meta.js
├── console.js
└── console_module.js

其中,ConsoleView.js 为整个Console 面板的核心文件,此文件中定义的 ConsoleView 类,为核心主要代码

在这个类的 constructor 中,注册了各个模块,包括工具条,左侧边栏模块等,也包括了右键菜单模块

// devtools-frontend/chromium-4400/console/ConsoleView.js
export class ConsoleView extends UI.Widget.VBox {
  constructor() { 
  	// ...
    this._viewport = new ConsoleViewport(this);
    this._messagesElement = this._viewport.element;
    // 监听事件
    this._messagesElement.addEventListener('contextmenu', this._handleContextMenuEvent.bind(this), false);
  } 
  
  _handleContextMenuEvent(event) {
	const contextMenu = new UI.ContextMenu.ContextMenu(event);
    // ...
    // 添加 save as 选项,并绑定事件
    contextMenu.saveSection().appendItem(Common.UIString.UIString('Save as...'), this._saveConsole.bind(this));
  }
  
  // 保存日志方法
  async _saveConsole() {
        const url = /** @type {!SDK.SDKModel.Target} */ (SDK.SDKModel.TargetManager.instance().mainTarget()).inspectedURL();
        const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
        const filename = Platform.StringUtilities.sprintf('%s-%d.log', parsedURL ? parsedURL.host : 'console', Date.now());
        const stream = new Bindings.FileUtils.FileOutputStream();
        const progressIndicator = new UI.ProgressIndicator.ProgressIndicator();
        progressIndicator.setTitle(Common.UIString.UIString('Writing file…'));
        progressIndicator.setTotalWork(this.itemCount());
        /** @const */
        const chunkSize = 350;
        if (!await stream.open(filename)) {
            return;
        }
        this._progressToolbarItem.element.appendChild(progressIndicator.element);
        let messageIndex = 0;
        while (messageIndex < this.itemCount() && !progressIndicator.isCanceled()) {
            const messageContents = [];
            let i;
            for (i = 0; i < chunkSize && i + messageIndex < this.itemCount(); ++i) {
                const message = /** @type {!ConsoleViewMessage} */ (this.itemElement(messageIndex + i));
              	// 核心在这里,每一个message对象调用toExportString方法
                messageContents.push(message.toExportString());
            }
            messageIndex += i;
            await stream.write(messageContents.join('\n') + '\n');
            progressIndicator.setWorked(messageIndex);
        }
        stream.close();
        progressIndicator.done();
    }
}

我们再看一下 message.toExportString() 的定义

// devtools-frontend/chromium-4400/console/ConsoleViewMessage.js
toExportString() {
  const lines = [];
  const nodes = this.contentElement().childTextNodes();
  const messageContent = nodes.map(Components.Linkifier.Linkifier.untruncatedNodeText).join('');
  for (let i = 0; i < this.repeatCount(); ++i) {
    lines.push(messageContent);
  }
  return lines.join('\n');
}

这里的 nodes 实际上是 dom 元素

1003911527.png

那最终每一个 text 又经历了 Components.Linkifier.Linkifier.untruncatedNodeText 方法的处理

// devtools-frontend/chromium-4400/components/Linkifier.js
static _appendHiddenText(link, string) {
  const ellipsisNode = UI.UIUtils.createTextChild(link.createChild('span', 'devtools-link-ellipsis'), '…');
  textByAnchor.set(ellipsisNode, string);
}
static untruncatedNodeText(node) {
  return textByAnchor.get(node) || node.textContent || '';
}

最后实际上是调用了 node.textContent 方法,直接获取了dom元素内的文本值,这...怪不得并不能导出真实的数据,只是把展示元素的文本内容给导出了呀

那么,如果我们想要一个可以导出日志对象的导出功能怎么处理?

联想一下第一部分对于日志输入逻辑的分析,我们可以自己来写一个导出方法,根据输出的日志信息,如果是对象等,需要获取更多信息的场景,我们则自己发送 CDP 消息,来查询更详细的信息输出。

// devtools-frontend/chromium-4400/console/ConsoleView.js
export class ConsoleView extends UI.Widget.VBox {
  // 获取对象属性,新增方法
  async _getProperties(obj, objectId) {
    const resObj = {};
    // 发送 CDP 消息,查询对象全部属性
    const { result, internalProperties } = await obj._runtimeModel._agent.invoke_getProperties({
      accessorPropertiesOnly: false,
      generatePreview: true,
      objectId: objectId,
      ownProperties: true,
    })
    const allProperties = [...result, ...(internalProperties || [])];
    for (let i = 0; i < allProperties.length; i++) {
      const item = allProperties[i];
      resObj[item.name] = item.value.value || item.value.description;
      try {
        if (item.value.type === 'object') {
          resObj[item.name] = await this._getProperties(obj, item.value.objectId);
        }
      } catch (err) {}
    }
    return resObj;
  }
  
  // 保存日志方法
  async _saveConsole() {
        const url = /** @type {!SDK.SDKModel.Target} */ (SDK.SDKModel.TargetManager.instance().mainTarget()).inspectedURL();
        const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
        const filename = Platform.StringUtilities.sprintf('%s-%d.log', parsedURL ? parsedURL.host : 'console', Date.now());
        const stream = new Bindings.FileUtils.FileOutputStream();
        const progressIndicator = new UI.ProgressIndicator.ProgressIndicator();
        progressIndicator.setTitle(Common.UIString.UIString('Writing file…'));
        progressIndicator.setTotalWork(this.itemCount());
        /** @const */
        const chunkSize = 350;
        if (!await stream.open(filename)) {
            return;
        }
        this._progressToolbarItem.element.appendChild(progressIndicator.element);
        let messageIndex = 0;
        // while (messageIndex < this.itemCount() && !progressIndicator.isCanceled()) {
        //    const messageContents = [];
        //    let i;
        //    for (i = 0; i < chunkSize && i + messageIndex < this.itemCount(); ++i) {
        //        const message = /** @type {!ConsoleViewMessage} */ (this.itemElement(messageIndex + i));
        //      	// 核心在这里,每一个message对象调用toExportString方法
        //        messageContents.push(message.toExportString());
       //     }
       //     messageIndex += i;
       //     await stream.write(messageContents.join('\n') + '\n');
       //     progressIndicator.setWorked(messageIndex);
       // }
    
      // 获取全部日志对象
      const msgArr = this._consoleMessages;
      for (let i = 0; i < msgArr.length && !progressIndicator.isCanceled(); i++) {
        const item = msgArr[i];
        // 获取某条日志信息
        const msg = item.consoleMessage();
        // 隐藏的日志不打印,非日志命令不导出
        if (!this._shouldMessageBeVisible(item) || ["log", "error", "warning", "info", "debug"].indexOf(msg._type) === -1) {
          continue;
        }
        const params = [...msg.parameters];
        // 解析日志栈
        const path = `${msg.url.split('/').pop().split('?')[0]}: ${msg.line + 1}`;
        try {
          const textArr = [];
          while (params.length) {
            const currentParam = params.shift();
            // 函数、正则、日期对象、Symbol、Error取description展示,基础类型值取value展示
            let val = currentParam.value || currentParam.description
            if (currentParam.type === 'string') {
              // 样式不打印
              // 这里可能有更多的场景,可以做更多的精细化处理,如%o, 不展开了
              const hasStyle = currentParam.value.match(/%c/g);
              let ignoreLength = hasStyle ? hasStyle.length : 0;
              while (ignoreLength) {
                params.shift();
                ignoreLength--;
              }
              val = val.replace(/%c/g, '');
            }
            // 数组和对象,包装对象特殊处理
            if (['Array', 'Object', 'Number', 'String', 'Boolean'].indexOf(currentParam.className) > -1) {
              val = await this._getProperties(msg, currentParam.objectId);
              val = JSON.stringify(val)
            }
            textArr.push(val);
          }
          await stream.write(`${path} ${textArr.join('  ')}\n`)
        } catch (err) {
          await stream.write(`${path} ${JSON.stringify(params)}\n`)
        }
        progressIndicator.setWorked(i)
      }
    
      stream.close();
      progressIndicator.done();
    }
}

这时导出的日志信息如下:

log.ts: 33 {"prop1":"prop1","prop2":"prop2","prop3":3,"prop4":4,"prop5":{"0":5,"1":5,"2":5,"3":5,"4":5,"length":5},"prop6":true,"prop8":{"prop8_prop1":1},"prop9":{},"prop10":10}

完美导出了对象的完整信息,当然,我对于数组以及包装对象的字符串处理还是比较糙的,如果你期望有更加可读性的处理,可以根据类型做进一步的特使处理。

三、如何添加自定义功能

如何在 Console 模块,增加自定义的功能呢?比如上述的日志导出功能,如果不想修改默认行为,但又希望有一个日志导出功能,可以在工具条这一栏怎么办呢?那就自己实现一个按钮嘛~

// devtools-frontend/chromium-4400/console/ConsoleView.js
export class ConsoleView extends UI.Widget.VBox {
  constructor() { 
  	// ...
    // 工具条右侧部分,比如设置
    const rightToolbar = new UI.Toolbar.Toolbar('', this._consoleToolbarContainer);
    // 添加分割线
    rightToolbar.appendSeparator();
    // 隐藏日志信息
    rightToolbar.appendToolbarItem(this._filterStatusText);
    // 设置按钮
    rightToolbar.appendToolbarItem(this._showSettingsPaneButton);
  } 
}

对应到页面上:

1004120325.png

我们也可以自己实现一个组件,并添加到右侧工具条中。 Web Components这个概念已经提了很久,而在开发者工具中,基本上全部都是使用了这个技术。

这里我不展开如何自定义一个组件了,分享一下,目前现有的CounterButton组件实现

// devtools-frontend/chromium-4400/ui/components/Icon.js
import * as LitHtml from '../../third_party/lit-html/lit-html.js';
const isString = (value) => value !== undefined;
export class Icon extends HTMLElement {
    constructor() {
        super(...arguments);
        this.shadow = this.attachShadow({ mode: 'open' });
        this.iconPath = '';
        this.color = 'rgb(110 110 110)';
        this.width = '100%';
        this.height = '100%';
    }
    set data(data) {
        const { width, height } = data;
        this.color = data.color;
        this.width = isString(width) ? width : (isString(height) ? height : this.width);
        this.height = isString(height) ? height : (isString(width) ? width : this.height);
        this.iconPath = 'iconPath' in data ? data.iconPath : `Images/${data.iconName}.svg`;
        if ('iconName' in data) {
            this.iconName = data.iconName;
        }
        this.render();
    }
    get data() {
        const commonData = {
            color: this.color,
            width: this.width,
            height: this.height,
        };
        if (this.iconName) {
            return {
                ...commonData,
                iconName: this.iconName,
            };
        }
        return {
            ...commonData,
            iconPath: this.iconPath,
        };
    }
    getStyles() {
        const { iconPath, width, height, color } = this;
        const commonStyles = {
            width,
            height,
            display: 'block',
        };
        if (color) {
            return {
                ...commonStyles,
                webkitMaskImage: `url(${iconPath})`,
                webkitMaskPosition: 'center',
                webkitMaskRepeat: 'no-repeat',
                webkitMaskSize: '100%',
                backgroundColor: `var(--icon-color, ${color})`,
            };
        }
        return {
            ...commonStyles,
            backgroundImage: `url(${iconPath})`,
            backgroundPosition: 'center',
            backgroundRepeat: 'no-repeat',
            backgroundSize: '100%',
        };
    }
    render() {
        // clang-format off
        LitHtml.render(LitHtml.html `
      <style>
        :host {
          display: inline-block;
          white-space: nowrap;
        }
      </style>
      <div class="icon-basic" style=${LitHtml.Directives.styleMap(this.getStyles())}></div>
    `, this.shadow);
        // clang-format on
    }
}
if (!customElements.get('devtools-icon')) {
    customElements.define('devtools-icon', Icon);
}

但需要注意的一点是,Web Components 使用了 Shadow DOM 后,其事件传播是有一些特殊点的:

Shadow DOM 内部的元素事件传播机制与普通 DOM 并无二致,可以放心的去注册事件。但是事件中的 target 属性却有一点差别。

当事件还在 Shadow DOM 内部传播时,Event.target 准确的反映了当前触发事件的元素,而当事件传播一旦到达 Shadow Host 时,后续的事件中 Event.target 则变为了 Shadow Host 元素,可能这也是作为一种保护机制,不能在外部直接拿到 Shadow DOM 内部的元素。

所以需要注意,如果在 Shadow DOM 的外部采用了事件委托机制,则无法通过Event.target准确的判断目标事件元素

这种情况下怎么处理呢?

1)方法一:添加两个事件委托,一个用来处理从 Shadow Host 冒泡的逻辑,一个用来处理 Shadow DOM 内部冒泡的逻辑

2)方法二:使用 Event.composedPath(),返回一个 EventTarget 对象数组,表示将在其上调用事件侦听器的对象,仅在 modeopen 时可以拿到 Shadow DOM 内部的元素的元素,否则以 Shadow Host 元素为第一个元素

四、 node.textContent VS node.innerText VS node.innerHTML

在上述日志导出中,我分析了开发者工具,默认是用了 node.textContent 导出,但你知道 node.textContent VS node.innerText VS node.innerHTML 这三个API的差别吗?

node.textContent :表示一个节点及其后代的文本内容

node.innerText :属性表示一个节点及其后代的“渲染”文本内容

node.innerHTML :设置或获取 HTML 语法表示的元素的后代

这里主要会混淆的是 node.textContent VS node.innerText

Note: innerText 很容易与Node.textContent混淆, 但这两个属性间实际上有很重要的区别. 大体来说, innerText 可操作已被渲染的内容, 而 textContent 则不会.

举例,比如 node 中有一个节点被 display: none 隐藏了,那么 innerText 中则不会包含此节点文本,而 textContent 则可以包含此节点文本。