前言
作为一个前端开发,最常使用的浏览器莫过于 Chrome,对于 Chrome 的开发者工具更是大家最贴心的工具。但是如果你留心观察过 Console 的对象输出,就会发现一个奇怪的现象:
比如我们定义一个对象,如下:
var a = { prop1: 1 }
这时候我们打印这个对象,输出一个收起的对象,如下:
这时我们修改一下对象,并输出
a.prop1 = 2
这时候输出的也是一个默认收起的对象,如下:
但是这个时候,我们点击上面输出的对象展开,就会发现,这个输出也被修改了
接着我们再次修改对象:
a.prop1 = 3
这个时候查看第一次的输出(前述已经展开预览过),会发现哪怕再次点击展开收起对象,值都不会再更新了,一直保持第一次被展开时获取到的对象数据:
而第二次的输出,我们点击展开后,则会得到最新的对象数据:
这个是为什么呢?
一、Console的日志输出模块逻辑
实际上,Chrome 的开发者工具模块交互,其实也是一个前端应用,和前端开发的其他页面一样,我们可以单独部署这个前端应用,然后分析
注,如果你想了解更多关于如何自己部署这个前端,可以去看我的这篇文章:《远程调试原理及端智能场景下远程调试实现方案》这里就不赘述了
开发者工具这个可视化的前端应用,其实是通过 CDP 和调试后端(代码真正执行的地方,比如浏览器中的 V8 引擎)交互,而这两部分建立交互的方式则是通过消息通道,这里也即 websocket
通过在开发者工具这个前端应用的入口 html 访问时,增加 ws 参数,即可建立这个连接,打开开发者工具,我们就可以看到这个接口
我们所有在开发者工具中做的操作,都只是一种前端可视化交互,所有的数据及代码执行,都是在调试后端中。这些日志输出,其实是通过上述的 websocket 消息通道,由调试后端告诉开发者工具这个前端应用,做什么样的数据展示。
如上述输出的对象,实际上是通过调试后端发送了对应的消息,Runtime.consoleAPICalled
其中,type
用来表明使用了 console
的哪个方法,可选值有 log, debug, info, error, warning, dir, dirxml, table, trace, clear, startGroup, startGroupCollapsed, endGroup, assert, profile, profileEnd, count, timeEnd
stackTrace
用来表明调用栈
而最关键的则是args
参数,是最终需要输出的日志参数信息,它的定义如下:
分析上述 消息,此时 args
只有一个参数,其类型是一个对象 object
,但没有具体的对象属性信息,有一个 objectId
属性
而默认展示的对象信息,则是读取的 preview
属性,全部说明详见 Runtime.ObjectPreview ,使用 overflow
表明属性没有全部获取预览(展示...),使用 properties
获取预览的属性信息
至此,我们就知道了,实际上,开发者工具中的 Console模块,展示了对象的输出时,实际上并没有获取最终的对象全部属性信息,仅仅是做了一个简单的"预览"
那么当我们点击日志对象展开时,会发生什么呢?
前端会发两个消息出去,两个消息都归属于 Runtime.getProperties,用来向调试器后端查询对象的属性信息,如下:
调试后端再收到这个消息后,则会根据查询的参数配置,返回对应对象的全部属性信息
之所以会有两个消息,是因为 ownProperties
及 accessorPropertiesOnly
查询参数不同,分别用来查询原型属性及对象自身属性
至此,我们就知道了,当第一次点击展开的时候,实际上会发接口查询对象此时的全部属性信息,并渲染到页面上,因此,此时展开渲染的是对象当时的属性信息。
后面再次展开查看对象信息时,由于已经渲染完了,此时开发者工具的前端实现,缓存了这个渲染结果,并不会再次触发接口查询。
二、更好的日志导出
如果你在 Console 面板下,右键就会弹出一个菜单,此时会有一个日志导出的选项,可以将控制台目前展示的日志导出
我们尝试导出并看一下导出的日志信息,会发现对于对象这类信息的导出内容并不全面,如下:
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 元素
那最终每一个 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);
}
}
对应到页面上:
我们也可以自己实现一个组件,并添加到右侧工具条中。 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
对象数组,表示将在其上调用事件侦听器的对象,仅在 mode
为 open
时可以拿到 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
则可以包含此节点文本。