远程调试原理及端智能场景下远程调试实现方案

前言

作为前端,你一定使用过chrome的DevTools功能,我们可以在这里查看代码运行日志,可以在这里debug代码,可以在这里查看网络请求等等。DevTools简直是前端开发中最必不可少的工具了。

如果你有过开发node server项目的经验,那你一定使用过node的Debugging模式,可以使我们在chrome中调试node代码。

那你是否产生过好奇心理,我们常规的前端项目的调试和node的调试,界面看起来非常相似,甚至可以说基本是一样的,但是仔细想一下,常规的前端项目是运行在浏览器上的,使用浏览器提供的开发者工具可以调试,好像是很理所当然的事情。但是node项目,是运行在机器上的,哪怕是本地调试,也是在本地机器上运行的,而不是由浏览器执行的,那浏览器是怎么做到的调试呢?

是不是突然感觉到了浓浓的求知欲,本篇文章就让我带你来走进chrome DevTools的世界。

一、DevTools是什么?

DevToolsChromium的一部分。本质上可以看成是一个前端小应用。你可以直接访问devtools://devtools/bundled/inspector.html 查看运行效果。

它主要由四部分组成:

  • Fontend: 调试器前端,默认由Chromium内核层集成。我们在chrome中操作的界面其实就是这一部分,是一个纯静态的模块,就类似于我们前端常做的页面是一样的,提供了交互和UI展示。这里要澄清一个很容易会搞混的一点,无论什么情况,我们调试的代码都不是在这里去执行的,而是由下面要介绍的调试器后端去执行的。(笔者就因为一开始异想天开,走了很多弯路)
  • Backend: 调试器后端,可能是Chromium、V8或Node.js。我们的代码实际运行的环境,如果是在前端项目,那么基本上就是Chromium,如果是node项目,那么基本上就是V8或Node.js
  • Protocol: 调试协议,调试器前端和后端使用此协议通信。
  • Message Channels: 消息通道,消息通道是在后端和前端之间发送协议消息的一种方式。包括:EmbedderChannelWebSocketChannelChromeExtensions ChannelUSB/ADBChannel,前言说的两种场景,我们使用的即是WebSocketChannel

这四部分的交互逻辑如下图所示:
交互逻辑

这里我们重点介绍一下调试协议CDP(Chrome DevTools Proticol)

  • 官方文档:chromedevtools.github.io/devtools-pr…
  • 简单来说,在上述描述的调试场景中,实际是调试器后端和调试器前端,借助websocket建立了消息通道,而在这个消息通道中,来回交换的内容就是调试协议CDP

二、如何部署我们自己的调试器前端

2.1 源码下载

DevTools Frontend 项目属于 Chromium 项目的子项目,代码托管在 Google 开源仓库上,代码开源从这里下载:

谷歌使用了自己的gclient工具管理仓库(跨平台git仓库管理工具,用来将多个git仓库组成一个solution进行管理)

2.2 编译

1、安装 depot_tools 工具

// 下载 depot_tools 源码:
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

// 打开 ~/.bash_profile 文件,将 depot_tools 添加到 PATH 中 (/path/to/depot_tools 为你 depot_tools 本地的路径):
export PATH=$PATH:/path/to/depot_tools

// 执行环境变量立即生效命令:
source ~/.bash_profile
复制代码

2、检出代码

mkdir devtools
cd devtools
git clone https://chromium.googlesource.com/devtools/devtools-frontend
gclient config https://chromium.googlesource.com/devtools/devtools-frontend --unmanaged
复制代码

3、编译

cd devtools-frontend
gclient sync
gn gen out/Default
autoninja -C out/Default
复制代码

4、完成

构建生成的文件在 out/Default

2.3 部署

经由上面两步,实际上你已经可以直接更改DevTools Frontend源码,定制属于你自己的调试前端交互,并编译出最终的构建产物了。

你可以选择把这部分内容放到远程(比如CDN),这样别人就可以访问到你部署的调试器前端了。

但如果你这么做了,你会很快发现一个残酷的问题:比如你访问最常规的inspector.html页面,你会发现它需要的依赖资源,高达700个左右,这是什么概念?这意味着你的页面加载至少要在5s以上,即使你做了缓存,甚至使用http2的服务端推送都不会提高这个速度,因为异步加载模块的逻辑,执行这700个左右的模块依赖真的是非常耗时。

所以这里也建议,如果你有类似的需求,在可以接受的范围内,可以直接尝试使用chrome已经内嵌的调试器前端,丝滑般的体验。

当然,使用chrome内嵌的页面,也意味着我们对浏览器的版本有了依赖,而最重要的是在你的业务中,如果真的需要远程调试,极大的可能你是需要使用iframe内嵌这一个模块的功能,那么跨域的问题就是一个绕不开的点了,实际上,我们仍然是有其他的选择的,这里我先卖个关子,在后面第四节中我会分享一下我最终采取的方案。

三、模拟实现一个调试器后端

3.1 项目搭建

这里我们使用浏览器内嵌的调试页面,我们把场景简化,使用无调试页面的调试页面,也即devtools://devtools/bundled/inspector.html?experiments=true&v8only=true,你可以在浏览器中访问看一下。
效果

核心问题来了,这个调试前端,如何建立消息通道的呢?

非常简单,只要我们在上述地址后,追加一个ws的参数,并附上调试器后端的ws服务地址就可以了!

也即访问devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:8181

这里我们的调试器后端其实就仅仅是一个ws服务

var WebSocketServer = require('ws').Server,
wss = new WebSocketServer({ port: 8181 });

wss.on('connection', function (ws) {
    ws.on('message', function incoming(message) {
        console.log('received: %s', message);
    });
    ws.on('close', function (message) {
        console.log('close ' + message);
    })
});
复制代码

我们看一下这个时候的调试前端,打开控制台,看一下网络请求:
网络请求

这个时候调试前端会自动去连接我们追加的search中的ws参数,所以会有一个ws://127.0.0.1:8181/的网络请求,我们看一下具体的消息:
消息

会发现在建立连接后,调试器前端发了8条消息出去,而这些消息其实就是调试协议CDP的内容

3.2 消息分析

我们以第一条消息解读一下:

{"id":1,"method":"Runtime.enable","params":{}}

这里我们先详细讲一下CDP接口,主要有两类:

  1. Methods:这一类接口往往都是需要返回值的,也就是说发了一个消息出去,是需要等待响应的, request/response的模式。所以这类消息都会需要有一个message id用来匹配同一个request/response,它的过程通常是这样的
request: {"id":1,"method":"Page.canScreencast"}
response: {"id":1,"result":{"result":false}}
复制代码
  1. Event: 这一类接口就简单很多了,不需要关心响应,仅仅是单方面的通知。和“事件”的概念类似
{"method":"Network.loadingFinished","params:{"requestId":"14307.143","timestamp":1424097364.31611,"encodedDataLength":0}}
复制代码

远程调试协议把操作又划分为不同的域 domain, 比如常见的:

  • DOM
  • Debugger
  • Network
  • Console
  • Timeline

可以理解为 DevTools 中的不同功能模块。每个域(domain)定义了它所支持的 Methods 和它所产生的 Event(就是上面讲的两种接口)

MethodsEvent 中可能涉及到非基本数据类型,在 domain 中被归为 Type,比如:’frameId’: ,其中 FrameId 为非基本数据类型。

至此,你已经了解了CDP的规则,阅读消息就非常简单了:

{"id":1,"method":"Runtime.enable","params":{}}

  • 这个消息是Runtime域下的enable Methods,所以它有一个message id
  • 我们再来看一下其含义:

Enables reporting of execution contexts creation by means of executionContextCreated event. When the reporting gets enabled the event will be sent immediately for each existing execution context

实际上有关上下文相关功能的启用

3.3 调试器后端发送消息

这里我们举一个很简单的场景,我们要在调试前端展示一条错误,如下效果:
错误

经过3.2的分析相信你也马上就可以写出来了,是的,我们只需要找到Console的Event就可以了

ws.send(JSON.stringify({
        method: "Runtime.consoleAPICalled",
        params: {
            type: 'error',
            args: ["我是error"],
            executionContextId: 1, // 模拟实现,无实际意义
            timestamp: (new Date()).getTime(),
        }
    }))
复制代码

至此,我们已经完成了一个很简单的调试过程,实际的项目调试过程,涉及了非常多的CDP协议消息的往来,还是很复杂的。在下一节结束后,你可以参照我的实现,观测一下实际业务里的CDP消息往来,实际感受一下。

四、调试工具原理探索

4.1 代码运行的原理是什么

代码的运行方式可以分为直接执行和解释执行两类。

1)直接执行

cpu 提供了一套指令集,基于这套指令集就可以控制整个计算机的运转,机器语言的代码就是由这些指令和对应的操作数构成的,这些机器码可以直接跑在计算机上,也就是可直接执行。由它们构成的文件叫做可执行文件。

编译型语言
编译型语言会经过编译、汇编、链接的阶段,编译是把源代码转成汇编语言构成的中间代码,汇编是把中间代码变成目标代码,链接会把目标代码组合成可执行文件。这个可执行文件是可以在操作系统上直接执行的。就因为它是由 cpu 的机器指令构成的,可以直接控制 cpu。所以可以直接 ./xxx 就可以执行。

2)解释执行

编译型语言都是生成可执行文件直接在操作系统上来执行的,不需要安装解释器,而 jspython 等解释型语言的代码需要用解释器来跑。

为什么有了解释器就不需要生成机器码了,cpu 仍然不认识这些代码啊?

那是因为解释器是需要编译成机器码的,cpu 知道怎么执行解释器,而解释器知道怎么执行更上层的脚本代码,就这样,由机器码解释执行解释器,再由解释器解释执行上层代码,这就是脚本语言的原理。 包括 jspython 等都是这样。

但是解释器毕竟多了一层,所以有的时候会把它编译成机器码来直接执行,这就是 JIT 编译器。比如 js 引擎一般就是由 parser解释器JIT 编译器GC 构成,大部分代码是由解释器解释执行的,而热点代码会经过 JIT 编译器编译成由机器码,直接在操作系统上执行以提高性能。

4.2 debugger 的原理

我们知道了 debugger 是调试程序必不可少的,那么它是怎么实现的呢?

可执行文件的 debugger

其实 cpu、操作系统在设计的时候就支持了 debugger 的能力(可见 debugger 的重要性),cpu 里面有 4 个寄存器可以做硬中断,操作系统提供了系统调用来做软中断。这是编译型语言的 debugger 实现的基础。

中断

cpu 只会不断的执行下一条指令,但程序运行过程中难免要处理一些外部的消息,比如 io、网络、异常等等,所以设计了中断的机制,cpu 每执行完一条指令,就会去看下中断标记,是否需要中断了。就像 event loop 每次 loop 完都要检查下是否需要渲染一样。

INT 指令

cpu 支持 INT 指令来触发中断,中断有编号,不同的编号有不同的处理程序,记录编号和中断处理程序的表叫做中断向量表。其中 INT 3 (3 号中断)可以触发 debugger,这是一种约定。

那么可执行文件是怎么利用这个 3 号中断来 debugger 的呢?其实就是运行时替换执行的内容,debugger 程序会在需要设置断点的位置把指令内容换成 INT 3,也就是 0xCC,这就断住了。就可以获取这时候的环境数据来做调试。
中断向量表

通过机器码替换成 0xccINT 3)是把程序断住了,可是怎么恢复执行呢?其实也比较简单,把当时替换的机器码记录下来,需要释放断点的时候再换回去就行了。

这就是可执行文件的 debugger 的原理了,最终还是靠 cpu 支持的中断机制来实现的。

中断寄存器

上面说的 debugger 实现方式是修改内存中的机器码的方式,但有的时候修改不了代码,比如 ROM,这种情况就要通过 cpu 提供的 4 个中断寄存器(DR0 – DR3)来做了。这种叫做硬中断。

总之,INT 3 的软中断,还有中断寄存器的硬中断,是可执行文件实现 debugger 的两种方式。

解释型语言的 debugger

编译型语言因为直接在操作系统之上执行,所以要利用 cpu 和操作系统的中断机制和系统调用来实现 debugger。但是解释型语言是自己实现代码的解释执行的,所以不需要那一套,但是实现思路还是一样的,就是插入一段代码来断住,支持环境数据的查看和代码的执行,当释放断点的时候就继续往下执行。

比如 javascript 中支持 debugger 语句,当解释器执行到这一条语句的时候就会断住。

解释型语言的 debugger 相对简单一些,不需要了解 cpuINT 3 中断。

4.3 debugger adaptor protocol

前面我们讲过浏览器的调试协议,实际上不同语言比如pythonc# 等肯定也有自己的调试协议,如果要实现 ide,都要对接一遍太过麻烦。所以后来出现了一个中间层协议,DAPdebugger adaptor protocol

debugger adaptor protocol, 顾名思义,就是适配的,一端适配各种 debugger 协议,一端提供给客户端统一的协议。这是适配器模式的一个很好的应用。

before

before

after

after

更多内容,可以查看官网了解:microsoft.github.io/debug-adapt…

五、端智能场景下如何实现一个远程调试功能?

5.1 背景

在端智能的场景下,平台在线IDE编写算子等资源,动态下发到端上执行,是很多公司在采用的方案。

这些动态下发的资源,如果是采用js编写的方式,那么就涉及借由jsBridge调用客户端一些方法等。

在线IDE允许用户编写核心逻辑,通过远程构建,将最终的资源下发到端上运行。

但是这些在端上才能运行的js如何去调试,就成了一个影响效率的关键。除此之外,端智能场景下,整个流程的日志如何追踪、排查问题也是一个关键的问题。

5.2 方案实现

5.2.1 调试器后端的实现探索

这里其实有2个思路:

1)真机调试js在客户端JavaScriptCore的运行环境中,开启调试模式

2)模拟调试js从客户端的执行转移到支持调试的环境中执行

经过调研,真机调试的成本是非常高的,AndroidIOS对于调试的支持并不统一,比如Android支持CDP协议,但IOS支持WIP(WebKit Inspection Protocol)协议,两端开启Unix Domain Socket端口的方式也不同,建立进程间通信通道的方式也不同。这就意味着我们需要花费大量的精力去兼容两端不同的实现,这显然超出我们的预期了。

那模拟调试呢?

a)node调试:这里我们首先想到的就是node自带的调试功能

nodeinspect功能,可使我们断点调试node服务,因为都是js语言,都是相通的,那可不可以把在客户端上运行的js放到node中运行呢?

理论上是可行的,但在实际调研中却发现,node的调试功能,仅在http等服务中,可被响应式常驻的断点调试,这就意味着我们还需要动态创建http服务来支持调试,那成本其实也是比较高的

并且node的调试服务,目前发展并不如浏览器的调试功能强大,比如解析字符串代码执行(如eval方法)的sourceMap,无法被解析为源码

b)无头浏览器:无头浏览器可在内存中启动浏览器,并支持调试功能

无头浏览器因为本身就是驱动的Chromium执行代码,对于我们而言更加熟悉,且支持也更好一点,和我们常用的效果基本都是一致的

我们需要实现一个调试页面,用来承载js的执行。这个成本其实是比较小的。

实际上我们写一个简单的html页面就可以了,这个页面由无头浏览器加载执行。

这里有两个工具:

对比 Playwright Puppeteer
描述 跨浏览器的支持。它可以驱动ChromiumWebKit(用于Safari的浏览器引擎)和Firefox 驱动ChromeChromiumChrome所基于的开源浏览器)
维护者 微软(2020年1月31日发布首个版本) Google
支持语言 多,支持java node
API 非常相似
其他 If you compare the contributor pages for Playwright and Puppeteer, you’ll notice the top two contributors to Puppeteer now work on Playwright. The Puppeteer team essentially moved from Google to Microsoft and became the Playwright team.

看起来好像模拟调试的方案成本更小一点,我们可以选择使用无头浏览器的方案,将原本在客户端执行的 js放到无头浏览器中执行(Playwright),但这个方案到目前可以走通吗?

是的,这里有一个很大的问题,模拟调试的环境(也即无头浏览器)是没有客户端的环境的,这就意味着我们的桥接方法调用是调用不到的

因此,我们还需要解决的一个问题,打通无头浏览器中执行的调试js和客户端。

方案 建立ws双向通信 使用无头浏览器API
方案详情 整体描述 在调试页面,建立ws链接,双向通信 使用无头浏览器API
浏览器调客户端 使用ws发出消息 使用Page.exposeBindingPage.exposeFunctionwindow上绑定方法,可由调试js调用
客户端调浏览器 监听消息接收 使用Page.evaluate执行js代码
优点 完全借助现有的API,成本更低
缺点 需要建立双向通信,成本高

综上,我们可以采用无头浏览器API实现。

但这里其实还有一个隐藏的坑,客户端调浏览器使用Page.evaluate执行js代码,无头浏览器在执行js时,本身是不会保障队列的,如果上一个js执行在断点中,那下一个js会先执行完成的,因此我们实现的调试页面还需要控制这个队列执行

至此,我们的方案基本就可以敲定了,使用模拟调试的方案,采用无头浏览器作为调试器后端,并使用其API打通执行的调试js和客户端

5.2.2 整体方案实现

总体的方案思路如图所示,云侧IDE和客户端通过中间层后端,分别建立WS双向通信,从而实现扫码建立连接的过程,并通过这个通道,控制整体流程。

同时客户端通过此通道将原本在客户端执行的js通过消息告诉后端,由后端调用起无头浏览器作为调试后端执行。采用调试模式开启的无头浏览器,会建立起调试后端(WS)服务。

云侧IDE单独部署的调试器前端,则可直接连接上述无头浏览器建立的调试后端调试。
方案图

这里我们针对其中几个关键点重点讲一下

1)如何利用无头浏览器开始调试

这里扩展讲一下,js和客户端通信的问题,实现的方案有很多,感兴趣的可以读一下前端工程师所需要了解的WebView这篇文章,这里就不赘述了。直接讲一下我们采用的方案:

1)js调用客户端:客户端注入API的方式,将方法绑定到window上,前端直接调用即可

2)客户端调用js:将方法绑定到window上,客户端直接执行字符串即可。

对于上述我们的方案,除了整体流程的通信,关于js和客户端的通信,流程如下(上述讲的无头浏览器API方案):

1)js调用客户端:后端在启用无头浏览器时,使用Page.exposeBindingPage.exposeFunction注入全局方法(如proxyCallNative)到js环境中,当js环境调用客户端的方法时,实际上则变成了执行后端注入的全局方法,而这个全局方法,则实现将调用的方法及参数,通过约定消息,使用建立的ws通道,传递给客户端执行。

2)客户端执行js:客户端通过建立的ws通道,将调用的方法及参数传递给后端,而后端则使用Page.evaluate调用js环境的全局方法执行。

这里我写一个简单的demo示意:

const { chromium } = require('playwright');


async function launchHeadless() {
    // 启动浏览器,开启调试服务
    browser = await chromium.launch({
        args: [`--remote-debugging-port=8085`, `--disable-gpu`],
    })

  	// 新建页面
    page = await browser.newPage()

  	// 注入回调,桥接方法代理
    await page.exposeFunction('proxyCallNative', (...params) =>
        console.log(params.join(""))
        // 这里可以将消息透传给客户端执行
        // ...
    );

  	// 模拟客户端调用js, 实际上是在ws通信接收到指定的消息后调用指定的方法,这里仅mock示意
    setTimeout(async () => {
        const res = await page.evaluate(`window.callJS('a', '[123]')`);
    }, 10000)

  	// 执行调试页面
    await page.goto(`http://127.0.0.1:5501/demo/test/index.html`)
}

launchHeadless();
复制代码

上述代码执行后,会在内存中打开谷歌浏览器,并开始调试模式,这时,会自动建立ws调试服务,调试端口即通过remote-debugging-port设定的端口,这时可以通过http://localhost:8085/json

// http://localhost:8085/json
[
  {
    "description": "",
    "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:8085/devtools/page/F96CD07FAEE3E63C3DF47871868CD08A",
    "id": "F96CD07FAEE3E63C3DF47871868CD08A",
    "title": "Document",
    "type": "page",
    "url": "http://127.0.0.1:5501/demo/test/index.html",
    "webSocketDebuggerUrl": "ws://localhost:8085/devtools/page/F96CD07FAEE3E63C3DF47871868CD08A"
  }
]
复制代码

基本上调试器后端,都会返回这样一个接口,描述可调试页面的信息

  • devtoolsFrontendUrl:调试地址,可直接打开,本质上其实就是可视化的调试器前端,和我们平常看到的调试工具一样
  • webSocketDebuggerUrl:调试器后端地址
  • url:调试页面地址

这里我们打开devtoolsFrontendUrl就可以看到这样一个页面:
效果

是不是很熟悉,此时我们实际上就已经可以可视化的调试http://127.0.0.1:5501/demo/test/index.html这个页面了。

2)调试页面实现

调试页面作为原本在客户端执行jsJavaScriptCore的替代品,这里要实现的核心有3点

a)重写客户端方法,调用后端注入的全局方法(如proxyCallNative

// 假如调用native的方法为callNative,在调试页面重写这个方法
window.callNative = function(...params) {
    window.proxyCallNative("callNative", JSON.stringify(params));
}
复制代码

b)实现callJS方法,后端调用callJS方法,统一调用js,并在此实现队列执行

// 队列
class Queue {
  constructor() {
    this.items = [];
  }

  // 入队
  enqueue(...params) {
    this.items.push(...params);
  }

  // 出队
  dequeue() {
    return this.items.shift();
  }

  size() {
    return this.items.length;
  }

  // 是否正在执行
  isRunning = false;

  // 自动执行
  run() {
    try {
      if (this.size() && !this.isRunning) {
        // 出队,并执行任务
        this.isRunning = true;
        const nowFunc = this.dequeue();
        const params = JSON.parse(nowFunc.params);
        const method = nowFunc.method;
        window[method](...params);

        this.isRunning = false;
        // 继续执行
        this.run();
      }
    } catch (err) {
      console.log(err);
    }
  }
}

const queue = new Queue();
// 设置环境信息
window.isDebug = true;
/*
@desc 端上调用js代理方法
@param method js桥方法名
@param params json处理的参数数组
*/
window.callJS = function (method, params) {
  queue.enqueue({
    method,
    params
  });
  // 后端因为java多线程操作chrome会崩溃,改为了单线程,故异步执行代码
  setTimeout(() => {
    queue.run();
  });
}
复制代码

3)调试器前端部署优化方案

在真实的业务项目中,如果我们将调试器前端嵌入到云侧IDE系统中,不可避免的需要处理登陆等逻辑,那么使用Chrome内嵌的devtools协议下的资源,就会存在跨域的问题,而自行构建部署又会发现页面加载过长,那么有没有其他的方案呢?

经过上面利用无头浏览器开始调试,你会发现在调试过程中,其devtoolsFrontendUrl就是一个调试地址,其实也是一个调试器前端的实现,但它的整体加载性能就非常好,基本上可以保障在2S内打开页面,而对比自行构建部署的DevTools Frontend 项目,则基本需要7S+的访问时长。

实际上,DevTools Frontend编译后,大概有1235个文件,在首次加载中,基本会需要700左右的文件。

而无头浏览器开启的调试页面在首次加载时,仅仅需要150个文件,这仅占21.5%的资源,无疑很大程度上提高了性能。

因为调试器前端基本上是一个相对较为稳定的纯前端静态服务,那么我们是不是可以直接使用无头浏览器开启的调试页面作为调试器前端呢?然而翻查了Playwright的源码,却并没有找到单独的这部分资源。

这里推荐我使用的一个黑科技的方案:Save All Resources谷歌插件,直接在运行时,将网页资源全部下载下来。
步骤1

然后找到资源中的目录,拷贝部署
步骤2
步骤3

一般情况下,调试器前端的代码基本上是稳定无需迭代就可以支持需求的,但在端智能的场景中,实际上只会关注consolesource模块,其他模块是没有意义的,并且由于将启用无头浏览器放到了后端环境,没有GPU,如果用户操作了elements,可能会出现崩溃的情况,因此也可以做一些其他的优化,将多余的模块隐藏掉了,方案也很简单,每一个模块都有一个对应的meta配置文件,修改这里的配置即可,这里就不展开了。
示例

在真实的业务场景中,实际上可以做的优化还有很多,比如日志模块中自定义tab、高亮信息等,前端一个小小的优化,可能对于业务使用的体验,就是一个巨大的飞跃。

5.2.3 反解析源码实现

什么是Source map

简单说,Source map就是一个信息文件,里面储存着位置信息。也就是说,转换后的代码的每一个位置,所对应的转换前的位置。

有了它,出错的时候,除错工具将直接显示原始代码,而不是转换后的代码。这无疑给开发者带来了很大方便。

如何生成Source map

前面我提到了,在云侧IDE,仅仅编写简单的算子等js,实际上是在后台做了构建打包,才下发到端上执行的,这个时候端上运行的代码是基本不具备可读性的(经历了被打包、压缩等),这里可以选择webpack(版本5)作为打包构建工具。

webpack的配置中,有一项devtool,可以控制source map文件的生成,这里可以选择source-map:整个 source map 作为一个单独的文件生成。它为 bundle 添加了一个引用注释,以便开发工具知道在哪里可以找到它。

而调试器前端,会自动去根据bundle中的source map的引用注释,解析出源码,供调试使用。

//@ sourceMappingURL=XXX.js.map
复制代码

关于source map的更多详细内容,可以阅读阮一峰老师《JavaScript Source Map 详解》

我们其实只在调试模式会使用到source map,那么实现成统一下发,既有安全隐患也浪费性能,这里,可以选择将其上传到了内网的CDN上。

由于上面选择了webpack作为构建打包工具,这里我简要说一下可以处理source map并上传CDN的时机:

1)compiler.hooks.makecompilation 结束之前执行

2)compilation.hooks.afterOptimizeAssetsasset 已经优化,生成了最终的产物及source map,这个时候还可以修改引用注释,将上传后的source map地址替换

function myPlugin(options) {
    this.options = options;
}
myPlugin.prototype.apply = function (compiler) {
  // make compilation 结束之前执行
    compiler.hooks.make.tapAsync('myPlugin', (compilation, callback) => {
      // 如果有sourceMap, 上传远程
        if (compiler.options.devtool === 'source-map') {
            // afterOptimizeAssets: asset 已经优化
            compilation.hooks.afterOptimizeAssets.tap('changeSourceMapPlugin', assets => {
                const assetName = this.options.assetName;
                upload(assets, assetName);
            });
        }
        callback();
    }
}
// 上传s3
function upload(){
 		// ...
}
复制代码

这里也分享一下,在构建过程中做的其他事情的时机:

1)修改构建产物,加一下headtail代码,使其在端上运行

2)zip压缩代码

如果修改构建产物,并通过webpack自带的功能生成source map文件,可能会存在代码优化的问题,比如顶层函数return优化及debugger移除的问题,可通过优化配置的方案解决:

optimization: {
        minimize: true,
        minimizer: [
            new TerserPlugin({
                terserOptions: {
                    compress: {
                        // 解决编译后,会把顶层函数的return去掉的问题
                        // https://github.com/terser/terser#minify-options
                        // https://github.com/terser/terser#compress-options
                        // expression (default: false) -- Pass true to preserve completion values from terminal statements without return, e.g. in bookmarklets.
                        expression: true,
                        // remove debugger; statements
                        drop_debugger: false
                    }
                }
            })
        ]
    }
复制代码

5.2.4 解析字符串执行

端上执行js代码会涉及到字符串执行的方式,这里也分享一下我们常用的解析字符串执行的方法:

1)setInterval("要执行的字符串",500);

window对象的方法既可以传字符串,也可以传函数。该函数第一个参数传字符串容易引起内存泄漏,尽量避免这样写。

2)setTimeOut("要执行的字符串",500);

window对象的方法既可以传字符串,也可以传函数。该函数第一个参数传字符串容易引起内存泄漏,尽量避免这样写。

3)eval("要执行的字符串");

4)new Function("要执行的字符串");

这里我们主要探讨一下evalnew Function

1)eval:(MDN eval

特殊点

如果你间接的使用 eval(),比如通过一个引用来调用它,而不是直接的调用 eval。 从 ECMAScript 5 起,它工作在全局作用域下,而不是局部作用域中。这就意味着,例如,下面的代码的作用声明创建一个全局函数,并且 eval 中的这些代码在执行期间不能在被调用的作用域中访问局部变量。

function test() {
  var x = 2, y = 4;
  console.log(eval('x + y'));  // 直接调用,使用本地作用域,结果是 6
  var geval = eval; // 等价于在全局作用域调用
  console.log(geval('x + y')); // 间接调用,使用全局作用域,throws ReferenceError 因为`x`未定义
  (0, eval)('x + y'); // 另一个间接调用的例子
}
复制代码

缺点

eval()是一个危险的函数, 它使用与调用者相同的权限执行代码。如果你用eval()运行的字符串代码被恶意方(不怀好意的人)修改,您最终可能会在您的网页/扩展程序的权限下,在用户计算机上运行恶意代码。更重要的是,第三方代码可以看到某一个eval()被调用时的作用域,这也有可能导致一些不同方式的攻击。相似的Function就不容易被攻击。

eval()通常比其他替代方法更慢,因为它必须调用 JS 解释器,而许多其他结构则可被现代 JS 引擎进行优化。

此外,现代JavaScript解释器将javascript转换为机器代码。 这意味着任何变量命名的概念都会被删除。 因此,任意一个eval的使用都会强制浏览器进行冗长的变量名称查找,以确定变量在机器代码中的位置并设置其值。 另外,新内容将会通过eval()引进给变量, 比如更改该变量的类型,因此会强制浏览器重新执行所有已经生成的机器代码以进行补偿。 但是,(谢天谢地)存在一个非常好的eval替代方法:只需使用 window.Function

限制

如果你必须执行这段代码, 应考虑以更低的权限运行。此建议主要适用于扩展和 XUL 应用程序,可以使用 Components.utils.evalInSandbox 做到降低权限。

2)Function:(MDN Function

new Function ([arg1[, arg2[, ...argN]],] functionBody)
复制代码

特殊点

Function 构造函数创建一个新的 Function 对象。直接调用此构造函数可用动态创建函数,但会遇到和 eval 类似的的安全问题和(相对较小的)性能问题。然而,与 eval 不同的是,Function 创建的函数只能在全局作用域中运行

只有在被调用时,字符串才会被解析执行

其他知识点:(浏览器调试模式下,如node V12版本的调试模式都不能解析source map

1)eval解析字符串,如果有sourceMap,可以解析并打断点执行

2)Function解析字符串,如果有sourceMap,可以解析出源代码,但不能打断点执行(断点不能增加,点击增加断点无效,无反应)

可能的原因:如上eval缺点1:更重要的是,第三方代码可以看到某一个eval()被调用时的作用域,这也有可能导致一些不同方式的攻击。相似的Function就不容易被攻击

因此,可以采用在线上使用Function解析字符串,在线下调试模式使用eval解析字符串的方案

六、其他

1. macOS下安装ninja

可参考资料:macOS (Big Sur)下安装ninja

2. gn安装

git clone https://gn.googlesource.com/gn

cd gn

# 使用python命令生成build.ninja配置文件
python build/gen.py

# 使用ninja命令编译
ninja -C out/

# 将out/gn链接到/usr/local/bin目录中
ln -sf out/gn /usr/local/bin  
复制代码

参考文档

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享