Deep understanding of V8 Inspector

This paper introduces the implementation of V8 Inspector, but it will not involve the implementation of specific commands. There are many commands of V8 Inspector. After understanding the processing flow, if you are interested in a command, you can analyze it separately.

First, let's take a look at several key roles in V8 Inspector.

1. V8InspectorSession

class V8_EXPORT V8InspectorSession {
  // After receiving the peer-to-peer message, call this method to determine whether it can be distributed
  static bool canDispatchMethod(StringView method);
  // After receiving the peer-to-peer message, call this method to determine the distribution
  virtual void dispatchProtocolMessage(StringView message) = 0;

V8InspectorSession is a base class. It implements the canDispatchMethod method, and the subclass implements the dispatchProtocolMessage method. Take a look at the implementation of canDispatchMethod.

bool V8InspectorSession::canDispatchMethod(StringView method) {
  return stringViewStartsWith(method,
                              protocol::Runtime::Metainfo::commandPrefix) ||
                              protocol::Debugger::Metainfo::commandPrefix) ||
                              protocol::Profiler::Metainfo::commandPrefix) ||
             method, protocol::HeapProfiler::Metainfo::commandPrefix) ||
                              protocol::Console::Metainfo::commandPrefix) ||

canDispatchMethod determines which commands are currently supported in V8. Next, let's look at the implementation of the subclass V8InspectorSession.

class V8InspectorSessionImpl : public V8InspectorSession,
                               public protocol::FrontendChannel {
  // Static method for creating v8inspector sessionimpl
  static std::unique_ptr<V8InspectorSessionImpl> create(V8InspectorImpl*,
                                                        int contextGroupId,
                                                        int sessionId,
                                                        StringView state);
  // Implement command distribution
  void dispatchProtocolMessage(StringView message) override;
  // What commands are supported
  std::vector<std::unique_ptr<protocol::Schema::API::Domain>> supportedDomains() override;
  // Send message to opposite end
  void SendProtocolResponse(int callId, std::unique_ptr<protocol::Serializable> message) override;
  void SendProtocolNotification(std::unique_ptr<protocol::Serializable> message) override;
  // Session id
  int m_sessionId;
  // Associated V8Inspector object
  V8InspectorImpl* m_inspector;
  // Associated channel, which represents both ends of the session
  V8Inspector::Channel* m_channel;
  // Processing command distribution objects
  protocol::UberDispatcher m_dispatcher;
  // A proxy object that handles a command
  std::unique_ptr<V8RuntimeAgentImpl> m_runtimeAgent;
  std::unique_ptr<V8DebuggerAgentImpl> m_debuggerAgent;
  std::unique_ptr<V8HeapProfilerAgentImpl> m_heapProfilerAgent;
  std::unique_ptr<V8ProfilerAgentImpl> m_profilerAgent;
  std::unique_ptr<V8ConsoleAgentImpl> m_consoleAgent;
  std::unique_ptr<V8SchemaAgentImpl> m_schemaAgent;

Let's take a look at the specific implementation of the core method.

  1. Create v8inspector sessionimpl
V8InspectorSessionImpl::V8InspectorSessionImpl(V8InspectorImpl* inspector,
                                               int contextGroupId,
                                               int sessionId,
                                               V8Inspector::Channel* channel,
                                               StringView savedState)
    : m_contextGroupId(contextGroupId),
      m_schemaAgent(nullptr) {
  m_runtimeAgent.reset(new V8RuntimeAgentImpl(this, this, agentState(protocol::Runtime::Metainfo::domainName)));
  protocol::Runtime::Dispatcher::wire(&m_dispatcher, m_runtimeAgent.get());

  m_debuggerAgent.reset(new V8DebuggerAgentImpl(this, this, agentState(protocol::Debugger::Metainfo::domainName)));
  protocol::Debugger::Dispatcher::wire(&m_dispatcher, m_debuggerAgent.get());

  m_profilerAgent.reset(new V8ProfilerAgentImpl(this, this, agentState(protocol::Profiler::Metainfo::domainName)));
  protocol::Profiler::Dispatcher::wire(&m_dispatcher, m_profilerAgent.get());

  m_heapProfilerAgent.reset(new V8HeapProfilerAgentImpl(this, this, agentState(protocol::HeapProfiler::Metainfo::domainName)));

  m_consoleAgent.reset(new V8ConsoleAgentImpl(this, this, agentState(protocol::Console::Metainfo::domainName)));
  protocol::Console::Dispatcher::wire(&m_dispatcher, m_consoleAgent.get());

  m_schemaAgent.reset(new V8SchemaAgentImpl(this, this, agentState(protocol::Schema::Metainfo::domainName)));
  protocol::Schema::Dispatcher::wire(&m_dispatcher, m_schemaAgent.get());

V8 supports many commands. When creating a v8inspector sessionimpl object, all commands and the processor processing the command will be registered. We'll analyze it separately later.
2. Receive request

void V8InspectorSessionImpl::dispatchProtocolMessage(StringView message) {
  using v8_crdtp::span;
  using v8_crdtp::SpanFrom;
  span<uint8_t> cbor;
  std::vector<uint8_t> converted_cbor;
  if (IsCBORMessage(message)) {
    use_binary_protocol_ = true;
    m_state->setBoolean("use_binary_protocol", true);
    cbor = span<uint8_t>(message.characters8(), message.length());
  } else {
    auto status = ConvertToCBOR(message, &converted_cbor);
    cbor = SpanFrom(converted_cbor);
  v8_crdtp::Dispatchable dispatchable(cbor);
  // Message distribution

After receiving the message, it passes m internally_ Dispatcher.dispatch is used for distribution, which is just like that we distribute according to the route after receiving the request in Node.js. The specific distribution logic will be analyzed separately.
3. Respond to requests

void V8InspectorSessionImpl::SendProtocolResponse(
    int callId, std::unique_ptr<protocol::Serializable> message) {
  m_channel->sendResponse(callId, serializeForFrontend(std::move(message)));

The specific processing logic is implemented by channel, which is implemented by V8 users, such as Node.js.
4. Data push

void V8InspectorSessionImpl::SendProtocolNotification(
    std::unique_ptr<protocol::Serializable> message) {

In addition to a request corresponding to a response, V8 Inspector also needs the ability of active push, and the specific processing logic is also implemented by channel. From the above analysis, we can see that the concept of v8inspector sessionimpl is equivalent to a server. A series of routes are registered at startup. When a connection is established, a channel object representation will be created. The caller can complete the request and receive the response through the channel. The structure is shown in the figure below.

2. V8Inspector

class V8_EXPORT V8Inspector {
  // Static method for creating V8Inspector
  static std::unique_ptr<V8Inspector> create(v8::Isolate*, V8InspectorClient*);
  // Used to create a v8inspector session
  virtual std::unique_ptr<V8InspectorSession> connect(int contextGroupId,
                                                      StringView state) = 0;

V8Inspector is a communication manager. It is not responsible for specific communication. It is only responsible for managing communicators, and Channel is responsible for communication. Let's take a look at the implementation of the V8Inspector subclass.

class V8InspectorImpl : public V8Inspector {
  V8InspectorImpl(v8::Isolate*, V8InspectorClient*);
  // Create a session
  std::unique_ptr<V8InspectorSession> connect(int contextGroupId,
                                              StringView state) override;

  v8::Isolate* m_isolate;
  // Associated V8Inspector client object. V8Inspector client encapsulates V8Inspector and is implemented by the caller
  V8InspectorClient* m_client;
  // Save all sessions
  std::unordered_map<int, std::map<int, V8InspectorSessionImpl*>> m_sessions;

V8inspector impl provides a method to create a session and saves all the created sessions. Take a look at the logic of creating a session.

std::unique_ptr<V8InspectorSession> V8InspectorImpl::connect(int contextGroupId, V8Inspector::Channel* channel, StringView state) {
  int sessionId = ++m_lastSessionId;
  std::unique_ptr<V8InspectorSessionImpl> session = V8InspectorSessionImpl::create(this, contextGroupId, sessionId, channel, state);
  m_sessions[contextGroupId][sessionId] = session.get();
  return std::move(session);

connect creates a v8inspector sessionimpl object and saves it to the map by id. The structure diagram is as follows.

3. UberDispatcher

UberDispatcher is a command distributor.

class UberDispatcher {
  // Object representing distribution results
  class DispatchResult {};
  // Distribution processing function
  DispatchResult Dispatch(const Dispatchable& dispatchable) const;
  // Register commands and processors 
  void WireBackend(span<uint8_t> domain,
                   const std::vector<std::pair<span<uint8_t>, span<uint8_t>>>&,
                   std::unique_ptr<DomainDispatcher> dispatcher);

  // Find the processor corresponding to the command, which is used in Dispatch
  DomainDispatcher* findDispatcher(span<uint8_t> method);
  // Associated channel
  FrontendChannel* const frontend_channel_;
  std::vector<std::pair<span<uint8_t>, span<uint8_t>>> redirects_;
  // Command processor queue
  std::vector<std::pair<span<uint8_t>, std::unique_ptr<DomainDispatcher>>>

Let's take a look at the implementation of registration and distribution.

  1. register
void UberDispatcher::WireBackend(span<uint8_t> domain, std::unique_ptr<DomainDispatcher> dispatcher) {
  dispatchers_.insert(dispatchers_.end(), std::make_pair(domain, std::move(dispatcher))););

WireBackend is to insert a new domain and processor combination into the queue.
2. Distribution order

UberDispatcher::DispatchResult UberDispatcher::Dispatch(
    const Dispatchable& dispatchable) const {
  span<uint8_t> method = FindByFirst(redirects_, dispatchable.Method(),
  // The offset of. Is found, and the command format is A.B                                   
  size_t dot_idx = DotIdx(method);
  // Get the domain, the first part of the command
  span<uint8_t> domain = method.subspan(0, dot_idx);
  // Get the order
  span<uint8_t> command = method.subspan(dot_idx + 1);
  // Find the corresponding processor through the domain
  DomainDispatcher* dispatcher = FindByFirst(dispatchers_, domain);
  if (dispatcher) {
    // Give it to the processor corresponding to the domain to continue processing
    std::function<void(const Dispatchable&)> dispatched =
    if (dispatched) {
      return DispatchResult(
          true, [dispatchable, dispatched = std::move(dispatched)]() {

4. DomainDispatcher

We just analyzed UberDispatcher. UberDispatcher is a command level distributor, because the command is in the format of domain.cmd. UberDispatcher performs preliminary distribution according to the domain, and DomainDispatcher finds the processor corresponding to the specific command.

class DomainDispatcher {
  // Distribution logic, subclass implementation
  virtual std::function<void(const Dispatchable&)> Dispatch(span<uint8_t> command_name) = 0;

  // Response after processing
  void sendResponse(int call_id,
                    const DispatchResponse&,
                    std::unique_ptr<Serializable> result = nullptr);
  // Associated channel
  FrontendChannel* frontend_channel_;

DomainDispatcher defines the logic of command distribution and response. The distribution logic of different domain s will have different implementations, but the response logic is the same, so the base class is implemented.

void DomainDispatcher::sendResponse(int call_id,
                                    const DispatchResponse& response,
                                    std::unique_ptr<Serializable> result) {
  std::unique_ptr<Serializable> serializable;
  if (response.IsError()) {
    serializable = CreateErrorResponse(call_id, response);
  } else {
    serializable = CreateResponse(call_id, std::move(result));
  frontend_channel_->SendProtocolResponse(call_id, std::move(serializable));

Through frontend_channel_ Returns a response. Next, let's look at the implementation of subclasses. Here, take HeapProfiler as an example.

class DomainDispatcherImpl : public protocol::DomainDispatcher {
    DomainDispatcherImpl(FrontendChannel* frontendChannel, Backend* backend)
        : DomainDispatcher(frontendChannel)
        , m_backend(backend) {}
    ~DomainDispatcherImpl() override { }

    using CallHandler = void (DomainDispatcherImpl::*)(const v8_crdtp::Dispatchable& dispatchable);
	// Implementation of distribution
    std::function<void(const v8_crdtp::Dispatchable&)> Dispatch(v8_crdtp::span<uint8_t> command_name) override;
	// Commands supported by HeapProfiler
    void addInspectedHeapObject(const v8_crdtp::Dispatchable& dispatchable);
    void collectGarbage(const v8_crdtp::Dispatchable& dispatchable);
    void disable(const v8_crdtp::Dispatchable& dispatchable);
    void enable(const v8_crdtp::Dispatchable& dispatchable);
    void getHeapObjectId(const v8_crdtp::Dispatchable& dispatchable);
    void getObjectByHeapObjectId(const v8_crdtp::Dispatchable& dispatchable);
    void getSamplingProfile(const v8_crdtp::Dispatchable& dispatchable);
    void startSampling(const v8_crdtp::Dispatchable& dispatchable);
    void startTrackingHeapObjects(const v8_crdtp::Dispatchable& dispatchable);
    void stopSampling(const v8_crdtp::Dispatchable& dispatchable);
    void stopTrackingHeapObjects(const v8_crdtp::Dispatchable& dispatchable);
    void takeHeapSnapshot(const v8_crdtp::Dispatchable& dispatchable);
    Backend* m_backend;

Domaindispatcher impl defines the commands supported by HeapProfiler. Next, analyze the processing logic of command registration and distribution. The following is the logic for HeapProfiler to register domain and processor (when creating v8inspector sessionimpl)

// backend is the specific object for processing commands. For heapprofiler, domain is V8HeapProfilerAgentImpl
void Dispatcher::wire(UberDispatcher* uber, Backend* backend)
	// channel is the opposite end of communication
    auto dispatcher = std::make_unique<DomainDispatcherImpl>(uber->channel(), backend);
    // Register the processor corresponding to the domain
    uber->WireBackend(v8_crdtp::SpanFrom("HeapProfiler"), std::move(dispatcher));

Next, let's look at the specific distribution logic when receiving the command.

std::function<void(const v8_crdtp::Dispatchable&)> DomainDispatcherImpl::Dispatch(v8_crdtp::span<uint8_t> command_name) {
  // Find the processing function according to the command
  CallHandler handler = CommandByName(command_name);
  // Returns a function to execute
  return [this, handler](const v8_crdtp::Dispatchable& dispatchable) {

Look at the logic of the lookup.

DomainDispatcherImpl::CallHandler CommandByName(v8_crdtp::span<uint8_t> command_name) {
  static auto* commands = [](){
    auto* commands = new std::vector<std::pair<v8_crdtp::span<uint8_t>, DomainDispatcherImpl::CallHandler>>{
		// Too many, not one by one
    return commands;
  return v8_crdtp::FindByFirst<DomainDispatcherImpl::CallHandler>(*commands, command_name, nullptr);

Take another look at the implementation of domaindispatcher impl:: enable.

void DomainDispatcherImpl::enable(const v8_crdtp::Dispatchable& dispatchable)
    std::unique_ptr<DomainDispatcher::WeakPtr> weak = weakPtr();
    // Call m_backend is the enable of V8HeapProfilerAgentImpl
    DispatchResponse response = m_backend->enable();
    if (response.IsFallThrough()) {
        channel()->FallThrough(dispatchable.CallId(), v8_crdtp::SpanFrom("HeapProfiler.enable"), dispatchable.Serialized());
    if (weak->get())
        weak->get()->sendResponse(dispatchable.CallId(), response);

Domaindispatcher impl is only encapsulated, and the specific command processing is handed over to M_ The object pointed to by backend, here is V8HeapProfilerAgentImpl. The following is the implementation of V8HeapProfilerAgentImpl enable.

Response V8HeapProfilerAgentImpl::enable() {
  m_state->setBoolean(HeapProfilerAgentState::heapProfilerEnabled, true);
  return Response::Success();

The structure diagram is as follows.

5. V8HeapProfilerAgentImpl

Just now, we analyzed the enable function of V8HeapProfilerAgentImpl. Here, we take V8HeapProfilerAgentImpl as an example to analyze the logic of the command processor class.

class V8HeapProfilerAgentImpl : public protocol::HeapProfiler::Backend {
  V8HeapProfilerAgentImpl(V8InspectorSessionImpl*, protocol::FrontendChannel*,
                          protocol::DictionaryValue* state);


  V8InspectorSessionImpl* m_session;
  v8::Isolate* m_isolate;
  // protocol::HeapProfiler::Frontend defines which events are supported
  protocol::HeapProfiler::Frontend m_frontend;
  protocol::DictionaryValue* m_state;

V8HeapProfilerAgentImpl defines supported events through protocol::HeapProfiler::Frontend, because the Inspector can not only process commands sent by the caller, but also actively push messages to the caller. This push is triggered by events.

class  Frontend {
  explicit Frontend(FrontendChannel* frontend_channel) : frontend_channel_(frontend_channel) {}
    void addHeapSnapshotChunk(const String& chunk);
    void heapStatsUpdate(std::unique_ptr<protocol::Array<int>> statsUpdate);
    void lastSeenObjectId(int lastSeenObjectId, double timestamp);
    void reportHeapSnapshotProgress(int done, int total, Maybe<bool> finished = Maybe<bool>());
    void resetProfiles();

  void flush();
  void sendRawNotification(std::unique_ptr<Serializable>);
  // Point to the V8InspectorSessionImpl object
  FrontendChannel* frontend_channel_;

Let's take a look at addHeapSnapshotChunk, which is the logic used to obtain heap snapshots.

void Frontend::addHeapSnapshotChunk(const String& chunk)
    v8_crdtp::ObjectSerializer serializer;
    serializer.AddField(v8_crdtp::MakeSpan("chunk"), chunk);
    frontend_channel_->SendProtocolNotification(v8_crdtp::CreateNotification("HeapProfiler.addHeapSnapshotChunk", serializer.Finish()));

Finally, the heapprofiler.addheapsnapshothook event is triggered. In addition, V8HeapProfilerAgentImpl inherits Backend and defines which request commands are supported corresponding to the functions in domaindispatcher impl, such as obtaining heap snapshots.

class  Backend {
    virtual ~Backend() { }
    // Don't list them one by one
    virtual DispatchResponse takeHeapSnapshot(Maybe<bool> in_reportProgress, Maybe<bool> in_treatGlobalObjectsAsRoots, Maybe<bool> in_captureNumericValue) = 0;

The structure diagram is as follows.

6. Encapsulation of V8 Inspector by node.js

Next, let's take a look at how V8 Inspector is used in Node.js. The user of V8 Inspector needs to implement v8inspector client and V8Inspector::Channel. Let's take a look at the implementation of Node.js.

class NodeInspectorClient : public V8InspectorClient {
  explicit NodeInspectorClient() {
    // Create a V8Inspector
    client_ = V8Inspector::create(env->isolate(), this);

  int connectFrontend(std::unique_ptr<InspectorSessionDelegate> delegate,
                      bool prevent_shutdown) {
    int session_id = next_session_id_++;
    channels_[session_id] = std::make_unique<ChannelImpl>(env_,
                                                          // After receiving the data, it is processed by delegate
    return session_id;

  std::unique_ptr<V8Inspector> client_;
  std::unordered_map<int, std::unique_ptr<ChannelImpl>> channels_;

NodeInspectorClient encapsulates V8 Inspector and maintains multiple channels. The upper code of Node.js can be connected to the V8 Inspector through connectFrontend and get the session_id, this connection is implemented with ChannelImpl. Let's take a look at the implementation of ChannelImpl.

explicit ChannelImpl(const std::unique_ptr<V8Inspector>& inspector, 
					 std::unique_ptr<InspectorSessionDelegate> delegate): 
					 // delegate_  Be responsible for processing the data sent from V8
					 delegate_(std::move(delegate)) {
    session_ = inspector->connect(CONTEXT_GROUP_ID, this, StringView());

ChannelImpl encapsulates V8InspectorSession and sends commands through V8InspectorSession. ChannelImpl implements the logic of receiving responses and receiving V8 push data. After understanding the capability of encapsulating V8 Inspector, take a look at the whole process through an example. We usually communicate with V8 Inspector in the following ways.

const { Session } = require('inspector');
new Session().connect();

We start with connect.

 connect() {
    this[connectionSymbol] =
      new Connection((message) => this[onMessageSymbol](message));

Create a new C + + layer object JSBindingsConnection.

JSBindingsConnection(Environment* env,
                       Local<Object> wrap,
                       Local<Function> callback)
                       : AsyncWrap(env, wrap, PROVIDER_INSPECTORJSBINDING),
                         callback_(env->isolate(), callback) {
    Agent* inspector = env->inspector_agent();
    session_ = LocalConnection::Connect(inspector, std::make_unique<JSBindingsSessionDelegate>(env, this));

static std::unique_ptr<InspectorSession> Connect(
     Agent* inspector, std::unique_ptr<InspectorSessionDelegate> delegate) {
   return inspector->Connect(std::move(delegate), false);
std::unique_ptr<InspectorSession> Agent::Connect(
    std::unique_ptr<InspectorSessionDelegate> delegate,
    bool prevent_shutdown) {
  int session_id = client_->connectFrontend(std::move(delegate),
  return std::unique_ptr<InspectorSession>(
      new SameThreadInspectorSession(session_id, client_));

During initialization of JSBindingsConnection, Agent::Connect will be finally called through agent - > connect to establish a channel to V8, and JSBindingsSessionDelegate will be passed in as the agent for data processing (used in the channel). Finally, a SameThreadInspectorSession object is returned and saved to the session_ After that, you can start communication. Continue to look at the logic when sending a request through the post of the JS layer.

  post(method, params, callback) {
    const id = this[nextIdSymbol]++;
    const message = { id, method };
    if (params) {
      message.params = params;
    if (callback) {
      this[messageCallbacksSymbol].set(id, callback);

A id is generated for each request, because it is returned asynchronously, and the dispatch function is finally called.

 static void Dispatch(const FunctionCallbackInfo<Value>& info) {
    Environment* env = Environment::GetCurrent(info);
    JSBindingsConnection* session;
    ASSIGN_OR_RETURN_UNWRAP(&session, info.Holder());

    if (session->session_) {
          ToProtocolString(env->isolate(), info[0])->string());

Take a look at SameThreadInspectorSession::Dispatch (that is, session - > session - > dispatch).

void SameThreadInspectorSession::Dispatch(
    const v8_inspector::StringView& message) {
  auto client = client_.lock();
  if (client)
    client->dispatchMessageFromFrontend(session_id_, message);

A sessionId is maintained in SameThreadInspectorSession. Continue to call client - > dispatchmessagefromfrontend. Client is the NodeInspectorClient object.

void dispatchMessageFromFrontend(int session_id, const StringView& message) {

dispatchMessageFromFrontend finds the corresponding channel through sessionId. Continue to call the dispatchProtocolMessage of the channel.

  void dispatchProtocolMessage(const StringView& message) {
    std::string raw_message = protocol::StringUtil::StringViewToUtf8(message);
    std::unique_ptr<protocol::DictionaryValue> value =
            raw_message, false));
    int call_id;
    std::string method;
    node_dispatcher_->parseCommand(value.get(), &call_id, &method);
    if (v8_inspector::V8InspectorSession::canDispatchMethod(
            Utf8ToStringView(method)->string())) {

Finally, call the session of v8inspector sessionimpl - > The following contents of the dispatch protocol message (message) have been mentioned earlier and will not be analyzed. Finally, let's look at the logic of data response or push. The following code is from ChannelImpl.

void sendResponse(
  int callId,
    std::unique_ptr<v8_inspector::StringBuffer> message) override {

void sendNotification(
    std::unique_ptr<v8_inspector::StringBuffer> message) override {

void sendMessageToFrontend(const StringView& message) {

We see that delegate - > is finally called Sendmessagetofrontend, delegate is the JSBindingsSessionDelegate object.

void SendMessageToFrontend(const v8_inspector::StringView& message)
        override {
  Isolate* isolate = env_->isolate();
  HandleScope handle_scope(isolate);
  Context::Scope context_scope(env_->context());
  MaybeLocal<String> v8string =
      String::NewFromTwoByte(isolate, message.characters16(),
                             NewStringType::kNormal, message.length());
  Local<Value> argument = v8string.ToLocalChecked().As<Value>();

Then call connection_ - > Onmessage (argument), connection is the JSBindingsConnection object.

void OnMessage(Local<Value> value) {
  MakeCallback(callback_.Get(env()->isolate()), 1, &value);

C + + layer callback JS layer.

[onMessageSymbol](message) {
    const parsed = JSONParse(message);
    try {
      // Determine whether it is a response or a push by whether there is an id
      if ( {
        const callback = this[messageCallbacksSymbol].get(;
        if (callback) {
          if (parsed.error) {
            return callback(new ERR_INSPECTOR_COMMAND(parsed.error.code,

          callback(null, parsed.result);
      } else {
        this.emit(parsed.method, parsed);
        this.emit('inspectorNotification', parsed);
    } catch (error) {

The above completes the analysis of the whole link. The overall structure diagram is as follows.

7. Summary

The design and implementation of V8 Inspector is complex, and the relationship between objects is complex. Because V8 doesn't seem to have many documents for debugging and diagnosing JS, it's not very perfect. It's just to briefly describe what the command does. It's not always enough. After understanding the specific implementation, you can see the specific implementation yourself.

Tags: Javascript

Posted on Fri, 22 Oct 2021 08:59:55 -0400 by VirtualOdin