`
lovnet
  • 浏览: 6766038 次
  • 性别: Icon_minigender_1
  • 来自: 武汉
文章分类
社区版块
存档分类
最新评论

VC 运行时库中的 new/delete 函数

 
阅读更多

原文:VC 运行时库中的 new/delete 函数
作者:Breaker <breaker.zy_AT_gmail>


Windows VC CRT 运行时库中导出的 new/delete 二进制接口

目录


缘起^

用 dependency walker (depends) 跟了一下,发现 operator new/delete 函数是从 msvcr[ver].dll 中导出的(如图),其中 ver 是 VC 运行时库 (CRT) 的版本,例如:VC 2005 (VC8) 环境下,Release 版本为 80,Debug 版本为 80d。本以为 operator new/delete 是从另一个 msvcp[ver].dll 导出的,其实不是,msvcp[ver].dll 有自己导出的 operator new/delete,但并不是我们编程常规用的 new/delete 操作符

CRT 的动态链接模块^

VC 的运行时库,通常简称 CRT。特指时,它表示 msvcr[ver].dll 这个动态链接库,但在泛义上它和其它几个动态链接库有着紧密的联系,概念上的划分也有共享的部分。这几个都含 Runtime Library 语义的 dll 分别是:msvcr[ver].dll、msvcp[ver].dll、msvcm[ver].dll 和 msvcrt.dll

看看这些 dll 名字的都代表了什么意思:

  • msvcr[ver].dll

    全称为 Microsoft C Runtime Library。msvcr[ver].dll 导出所有的标准 C 库 API,和微软的标准 C 库扩展 API,以及一些 C++ 基本语言特性需要的 API

  • msvcp[ver].dll

    全称 Microsoft C++ Runtime Library。msvcp[ver].dll 导出标准 C++ 库中的 STL 和 iostream 类

  • msvcm[ver].dll

    它也叫做 Microsoft C Runtime Library,不过是给托管代码用的 CRT,所以后缀 m 的含义可以理解为 managed(托管)。msvcm 没有任何对应的静态库,也就是说要使用托管 CRT,只能动态链接到 msvcm[ver].dll。有两种方式指定使用托管 CRT:

    1. 使用 /clr 编译选项,这时客户程序可以使用 managed/native 混合代码,并且使用 msvcmrt.lib 导入库

    2. 使用 /clr:pure 编译选项,这时客户程序使用纯粹的 MSIL 托管代码

    由于 CLR 的 COM 实质,msvcm[ver].dll 依赖这些 dll:Native CRT:msvcr[ver].dll,OLE Library:ole32.dll,以及 .NET Runtime Execution Engine:mscoree.dll

上面的 msvcr、msvcp、msvcm 是 VC 引入的 dll,在部署应用程序时可以使用微软提供的 VC Redistributable Package 包来安装这些 dll,安装使用 side by side 方式,dll 拷贝到 %SystemRoot%winsxs 目录下,并且这些 dll 都是以 Release 方式编译的

另外,还有一种直接拷贝这些 dll 到目标系统的部署方法,参考 MSDN 的 How to: Deploy using XCopy (VC8) 的 Deploying Visual C++ library DLLs as private assemblies 章节。这些要拷贝的 dll 保存在:Path-to-VSVCredist(Release 版)和 Path-to-VSVCredistDebug_NonRedist(Debug 版)

  • msvcrt.dll

    全称 Windows NT CRT DLL,它的名字是固定不带版本号的,位置总在 %SystemRoot%system32 下。msvcr[ver].dll 和 msvcrt.dll 的作用区别借用 MSDN 的话说为:

    from: C Run-Time Libraries (VC8)

    What is the difference between msvcrt.dll and msvcr80.dll?

    The msvcrt.dll is now a "known DLL", meaning that it is a system component owned and built by Windows. It is intended for future use only by system-level components.

    msvcr[ver].dll 依赖 msvcrt.dll

使用 msvcr[ver].dll 导出的 new^

下面说下用 VC 编译的 C++ 程序中,常规使用的 new/delete 操作符,也就是 msvcr[ver].dll 导出的 operator new/delete 函数,它们的调用和二进制模块 msvcr[ver].dll 的导出函数的对应关系,以及申请内存失败的处理机制

声明:下面程序的编译、测试环境均为 VC8,用模块名 msvcr80[d].dll 表示无差别情况下的 Debug 或 Release 版 CRT 动态链接库;当提到 VC 的头文件、源文件、静态库、导入库、对象文件等路径时,均是相对于 Visual Studio 2005 的安装目录;给出的 MSDN 参考,若非指明,也是适用于 VC8 版本

工具:用 depends 查找模块导出符号时,有些不方便。可以使用 VC 的附带工具 dumpbin,用法参考 DUMPBIN Reference (VC9),例如:dumpbin /exports msvcp80d.dll > res.txt

头文件和模块 msvcr80[d].dll 中 new/delete 的对应^

有两个关于 new/delete 的头文件:标准 C++ 库的头文件 <new>(没有 .h 后缀),和 CRT 的头文件 <new.h>。<new.h> 对应的模块是 msvcr80[d].dll;而 <new> 中声明的大部分函数对应于 msvcr80[d].dll,如常规的 new/delete 函数,而有些函数,例如下面会讲的 set_new_handler(),则对应 msvcp80[d].dll

msvcr80[d].dll 导出的 new/delete 有:

这些就是编程中常规用的 new/delete 的二进制接口

在 C++ 源码级别,总共有 6 个 new 函数声明,上面的第一个导出函数 void* operator new(unsigned int) 对应其中的 4 个:

为什么二进制的 void* operator new(unsigned int) 也会对应声明的 operator new[] 原型?后面会慢慢道来

另外 2 个 new 函数的声明是:

这两个被称为放置式的 new,不对应任何运行时库的动态链接库,它们的二进制代码会编译进客户程序中

上面 6 个 new 的使用语法参考:operator new (CRT)operator new[] (CRT)

下面就说说这几个 new 的区别

标量 new 与矢量 new[]^

标量 new(scalar new),即 operator new;矢量 new(vector new),operator new[]

按照 MSDN operator new[] (CRT) 的说法,当使用 new 申请数组块内存时,就调用矢量的 operator new[]

在实际的 VC8 环境测试中,发现下列客户代码均会调用 msvcr80[d].dll 导出的 void* operator new(unsigned int),而非导出的 void* operator new[](unsigned int):

反汇编调试后发现,在客户程序中 VC8 编译器已经将申请数组块的整体大小计算出来了,比如 sizeof(TestObj) = 40,则编译器就会计算出 new TestObj[10] 申请的大小为 40 * 10 = 400

  • 如果此时仅包含 <new.h>,或者不包含 <new> 和 <new.h> 任一个,则会调用 msvcr80[d].dll 中导出的 void * operator new(unsigned int)

  • 如果仅包含 <new>,或者 <new>、<new.h> 两个都包含(顺序无关),则会调用 VCcrtsrcnewaop.cpp 中定义的 operator new[](aop 的含义是 array operator)。那么 newaop.cpp 中的 operator new[] 是否是 msvcr80[d].dll 中导出的 void* operator new[](unsigned int)?答案不是,从 VC 的 Call Stack 中看出 newaop.cpp 对应的二进制模块是客户程序的模块而非 msvcr80[d].dll。newaop.cpp 定义的 operator new[] 是 msvcr80[d].dll 导出的 void* operator new(unsigned int) 的简单包裹,最后将调用传给它

delete/delete[] 的调用也有这种受包含 <new> 还是 <new.h> 影响的问题,见下面对 delete 的讨论

所以问题是,虽然在 msvcr80[d].dll 中导出了 operator new[],但似乎不能通过标准的方法使用它

放置式 new^

按 new 的行为是申请存储位置,还是将对象放置到某个存储位置,可以分为:非放置式(nonplacement)new,和 放置式(placement)new

放置式 new 语法参考:《C++ 程序设计语言》特别版(Bjarne)章节 10.4.11 对象的放置。有两种惯用的放置手法:1. 放置到已有存储位置。2. 使用 Arena(场地)分配存储位置(自定义放置式 new)

VC CRT 中提供了第一种放置式 new,在 <new> 和 <new.h> 中都有声明:

这些放置 new 都是 inline 函数,声明的同时给出定义,函数体都是一句简单的 return (_Where),所以不存在 msvcr80[d].dll 中导出的放置式 new

测试放置式 new 的代码:

no-throw 的 new^

还有一种 MSDN 上称为 placement, no-throw 的 operator new/new[],在 <new.h> 和 <new> 中都有声明:

不过它和放置位置没有关系,而是影响内存申请失败时的报告机制,如果使用这种 new,则申请失败时将以 operator new 返回 0 值表示失败,否则使用默认抛出异常的方式表示申请失败

以调用 new(std::nothrow) char[BIG_SIZE] 为例,调用顺序如下:

  1. 客户程序模块: newaopnt.cpp: void* __CRTDECL operator new[](::size_t count, const std::nothrow_t& x)

  2. 客户程序模块: newopnt.cpp: void* __CRTDECL operator new(size_t count, const std::nothrow_t&)

  3. msvcr80[d].dll: 导出的 void* operator new(unsigned int)

newaopnt.cpp 和 newopnt.cpp 在 VCcrtsrc 目录下,后缀 nt 表示 no-throw

更详细的 new 申请失败报告机制在后面叙述

调试版的 new^

在 msvcr80[d].dll 中有两个 4 个参数的 operator new 函数的导出:

这两个函数的声明在 <crtdbg.h> 中,实现源码在 <dbgnew.cpp>,用法参考 The Debug Heap from C++。示例:

在工程公共头文件中:

在源文件中:

上面申请数组块内存时,会调用 msvcr80d.dll 导出的 void* operator new[](unsigned int, int, char const*, int)

_CrtMemDumpAllObjectsSince(NULL) 将从程序开始到其调用点的所有堆对象调试信息,转储到调试输出,比如上面用 operator new[](unsigned int, int, char const*, int) 申请内存时,就会产生调试信息。_CrtMemDumpAllObjectsSince() 只在调试版起作用(有 _DEBUG 定义),当没有 _DEBUG 定义时,_CrtMemDumpAllObjectsSince() 就被替换成一个空操作 ((void)0),如同 _ASSERT() 的实现一样(_ASSERT() 也在 <crtdbg.h> 中定义)。可以用 VC 的调试 Output 窗口,或 DebugView 工具查看 _CrtMemDumpAllObjectsSince() 的输出

new 申请内存失败^

参考:The new and delete Operators

msvcr80[d].dll 中导出的 new 的报告申请失败的默认方式是抛出 std::bad_alloc 异常。测试例子:

上面的代码编译后不会链接到 msvcp80[d].dll,因为标准 C++ 异常类(即 std::bad_alloc)和 C++ RTTI 类(type_info),均在 msvcr80[d].dll 中有导出

如果想使用返回 0 来表示 new 申请内存失败,除了使用上面提到的 placement, no-throw 的 new 外,调试版的 operator new/new[](unsigned int, int, char const*, int) 也是以返回 0 表示失败,并不抛出异常

另外,还有一种用返回 0 来表示失败的方法,就是和 VClibnothrownew.obj 链接,此时便不会调用 msvcr80[d].dll 中导出的 new(用 depends 可以观察到),而调用 nothrownew.obj 中包含的 new

VC 编译出代码的异常处理方式(Exception Handling Model)和编译选项 /EH 有关系,它会影响 C 和 C++ 两种语言中的异常处理,以及标准 C++ 规范中的异常(try-catch 结构)和 Windows 特有的异常处理方式 SEH (Structured Exception Handling)(__try-__except-__finally 结构)

关于 VC 中 C/C++ 的异常处理,和更多 new/delete 操作的内容,请查阅文章最后的参考

std::set_new_handler 和 _set_new_handler^

  • std::set_new_handler

    参考:

    set_new_handler() 是 C++ 标准中定义的 new 申请失败处理设定函数,使用的失败处理函数类型为:typedef void (*new_handler)()。相关函数、类型在 <new> 中声明,set_new_handler() 在 msvcp80[d].dll 中导出

    例子:

    上面代码会链接到 msvcp80[d].dll

    自定义的错误处理函数 new_handler,必需完成 3 种功能之一:

    1. 产生更多可用的内存以供申请,例如使用垃圾回收、不常用对象交换到文件等手段,具体方式和应用有关,此时该函数可以用直接返回的方式离开,随后控制返回给 operator new(),并再次尝试申请内存

    2. 调用 abort 或 exit 函数,让 CRT 负责并终止程序执行

    3. 抛出一个异常,通常是 std::bad_alloc,该异常会穿过 operator new() 一直上抛到客户程序,此时 new_handler 通过异常方式离开 operator new()

    所以用户的 new_handler 实现中,如果即没有产生更多可用内存的工作,又不通过异常或 abort/exit 方式离开 operator new(),则 operator new() 就会陷入一直调用 new_handler 的死循环

  • _set_new_handler

    参考:_set_new_handler

    _set_new_handler() 是 CRT 提供的 new 申请失败处理设定函数,使用的失败处理函数类型为:typedef int (*_PNH)(size_t)。相关函数、类型在 <new.h> 中声明,_set_new_handler() 在 msvcr80[d].dll 中导出

    例子:

    上面代码会仅会链接到 msvcr80[d].dll,而不会链接 msvcp80[d].dll

    和标准 C++ 库中的 new 失败处理函数不同,CRT 中规定的失败处理函数 int (*_PNH)(size_t),有返回值和参数。_PNH 的参数 size_t,表示请求申请但失败的内存大小,而 int 型返回值表示:

    1. 返回非 0 值,表示 _PNH 做过一些产生更多可用内存的工作,控制返回给 operator new() 后,会再次尝试申请内存。该情况和 C++ new_handler 的直接返回类似

    2. 返回 0,表示无需让 operator new() 再次尝试申请内存,申请操作已经彻底失败,最终控制会以默认抛出异常方式回到客户程序。该情况和 C++ new_handler 的以抛出异常方式结束申请类似

    所以类似标准 C++ 库的 new_handler,_PNH 如果即没有产生更多可用内存的工作,又返回了非 0 值,则 operator new() 就会陷入一直调用 _PNH 的死循环

    _PNH 是作用于 CRT 提供的全局 operator new() 的,要想 _PNH 也作用于 malloc(),使得 malloc() 申请失败时也调用 _PNH 处理,可以调用 _set_new_mode(1) 激活 malloc() 的失败处理机制

    在二进制层次,_PNH 函数是在所有模块间共享的,在 dll 中设定的 _PNH 会影响到主 exe 和其它 dll 中的 operator new() 行为

使用 msvcr[ver].dll 导出的 delete^

delete 和 delete[]^

在客户代码中使用 delete[] 时,如果仅包含 <new>,或者 <new>、<new.h> 两个都包含(顺序无关),则调用 msvcr80[d].dll 导出的 void operator delete[](void*)

如果仅包含 <new.h>,或者不包含 <new> 和 <new.h> 任一个,则调用 msvcr80[d].dll 导出的 void operator delete(void*)

实际上 msvcr80[d].dll 的 void operator delete[](void*) 实现,仅仅是对 void operator delete(void*) 做了一个简单的包裹。delete[] 的源码在 VCcrtsrcdelete2.cpp,Release 版的 delete 的源码在 VCcrtsrcdelete.cpp,Debug 版的 delete 的源码在 VCcrtsrcdbgdel.cpp

两次重复 delete^

示例:

在 Debug 配置下,即链接到 msvcr80d.dll,dbgdel.cpp 中定义的 delete 在程序运行时会检查到这种情况,并报 assert 诊断错误

在 Release 配置下,即链接到 msvcr80.dll,如果程序运行时 attach 到 VC 的调试器,则在重复 delete 的位置会给出警告,中止运行并报错 "This may be due to a corruption of the heap, and indicates a bug in [program-name.exe] or any of the DLLs it has loaded.",可以让调试器继续运行程序。如果没有 attach 到调试器,而是独立运行程序,则不会看到任何警告提示,程序运行直到结束,这可能会造成复杂程序中 bug 的潜藏点

delete 空指针^

在上面重复 delete 的代码中加上一句,如下:

和重复 delete 不同,上述 delete 空指针无论是从 C++ 语法标准的角度(参考《C++ 程序设计语言》章节 6.2.6 自由存储),还是在实际的 VC8 环境中都是正确的,Debug 和 Release 版的 CRT 库均会正常运行,不会报出错误或警告

调试版的 delete^

参考:The Debug Heap from C++

和使用调试版的 void* operator new(unsigned int, int, char const*, int) 不同,不需用户写 delete 操作的替换宏和更改任何代码,只需用 Debug 配置方式编译程序即可使用调试版的 delete,而换到 Release 方式编译就可使用一般的 delete

msvcp80[d].dll 导出的 new/delete^

最后,不妨看看 msvcp80.dll 导出的 operator new/delete 函数,大家想想这些接口在何种情况下被调用呢?

除了上述全局的 new/delete 外,msvcp80[d].dll 中还导出了 std::locale::facet 类定义的 new/delete,不过这些跟用户常规的 new/delete 操作都没有关系

总结^

CRT 的模块 msvcr80[d].dll 中并非只含 C API,下面标准 C++ 库的 API 也在其中:

  • 常规使用的 operator new/delete,包括调试版 void* operator new(unsigned int, int, char const*, int),对应头文件 <new> 和 <new.h>

  • 标准 C++ 异常类,对应头文件 <exception>

  • 标准 C++ RTTI 类,对应头文件 <type_info> 和 <type_info.h>

而 msvcp80[d].dll 则侧重于标准 C++ 库中的 STL 和 iostream 类的实现,basic_string、vector、basic_ostream 等均在这里导出,另外还包括 <new> 中声明的 std::set_new_handler()

VC 的这种混合 C API 和标准 C++ 库的模块管理方式,让人感觉很混乱并摸不着头脑。其实,微软这么做是有考虑的,试想一下,如果你即想使用 C++ 的基本语言特性,如 new/delete、RTTI 等,又不想依赖额外的标准 C++ 库,如一大堆 STL 模板类和 iostream 类,此时就可以仅链接 msvcr80[d].dll。总之,只要记住 msvcr80[d].dll 包括所有 VC 基本的 C/C++ 语言支持就好了,这也是 CRT“运行时”库的内涵所在

参考^

  • C Run-Time Libraries: 说明 VC 中运行时库 (CRT)、托管代码运行时库、标准 C++ 库,对应的静态、动态、导入库文件,以及编译选项
  • CRT Debugging Techniques: CRT 中的调试技术,包括调试用到的宏、函数,调试版的堆管理函数 malloc、new/delete 等
  • /EH (Exception Handling Model): VC 用来控制异常处理方式的编译选项 /EH,默认的 VC 工程通常使用 /EHsc 选项编译
  • Exception Handling in Visual C++: 讲述 VC 支持的两种异常处理方式:C++ 异常 和 SEH (Structured Exception Handling) 的工作机制和语法。建议先看其中的Exception Specifications,明白函数的 throw() 修饰词怎样和编译选项 /EH 相互作用
分享到:
评论

相关推荐

    VC9-14运行库

    4. **内存管理**:运行库包含内存分配和释放的函数,如`new`和`delete`,以及内存泄漏检测等功能。 5. **安全更新**:微软会定期发布安全补丁,修复运行库中的漏洞,保持系统的安全性。 6. **编译器版本兼容性**:...

    VC函数库VC小词典

    《VC函数库VC小词典》是一份针对Visual C++(简称VC)编程的重要参考资料,它集合了大量在VC编程中常用的函数库及其详细解释,旨在帮助开发者更好地理解和使用这些函数,提高编程效率。这份“小词典”包含了丰富的...

    中文vc知识库

    - **API概述**:讲解Windows API的作用,以及如何在VC项目中使用API函数。 - **常见API调用**:介绍一些常用的API,如文件操作、窗口管理、事件处理等。 5. **内存管理** - **动态内存分配**:涵盖new和delete...

    VC6.0 运行库参考手册.rar

    《VC6.0运行库参考手册》是针对微软的Visual C++ 6.0开发环境的重要参考资料,它包含了运行库的详细信息,是开发者解决与VC6.0相关的编译、链接和运行时问题的必备工具。Visual C++ 6.0是微软推出的一款经典集成开发...

    VC60中文版运行库参考手册.rar

    《VC60中文版运行库参考手册》是一个针对Windows平台的原版教程,它包含了关于Microsoft Visual C++ 6.0(简称VC60)运行库的详细信息。这个手册对于开发者来说是一份非常重要的参考资料,它能帮助理解并解决在使用...

    VC常用函数查询系统

    VC支持C++语言,C++拥有大量的内置函数,如标准库中的IO流函数(如`std::cout`和`std::endl`)、内存管理函数(如`new`和`delete`)、数学函数(如`sin`和`cos`)等,同时开发者还可以自定义函数以满足特定需求。...

    让vc6支持new 抛出异常

    在项目的C/C++属性页中,检查“C++” -&gt; “代码生成” -&gt; “运行时库”是否选择“多线程异常处理 (/EHsc)”。 通过以上步骤,我们就可以在VC6中实现`new`失败时抛出异常的功能,从而增强程序的错误处理能力,使其更...

    VC函数(API,VC常用内存分配函数等)

    在Microsoft Visual C++ (VC) 编程环境中,开发者经常需要使用各种函数来实现特定功能。其中,内存管理是至关重要的部分,因为它涉及到程序的稳定性和效率。标题和描述中提到的`HeapAlloc`、`GlobalAlloc`和`...

    VC6知识库1

    6. **内存管理**:理解C++中的动态内存分配(new/malloc)和释放(delete/free)是避免内存泄漏的关键。 7. **异常处理**:使用try、catch块进行错误处理,可以有效地捕获并处理运行时发生的异常。 8. **调试工具*...

    vc小助手,包含大量函数

    在学习和使用VC函数库时,有几个关键知识点值得深入探讨: 1. **函数原型**:每个函数都有其特定的原型,定义了函数的返回类型和参数列表。理解函数原型有助于正确调用函数,避免编译错误。 2. **参数传递**:了解...

    c\c++帮助(经典函数库)chm版

    5. 动态内存管理(Memory):包括new、delete操作符和智能指针(shared_ptr、unique_ptr等)。 6. 标准IO流库(Iostreams):提供输入/输出操作,如cin、cout、fstream等。 C Standard Library则包含了C语言的基本...

    vc常用的函数集

    - `new` 和 `delete`:C++中的动态内存管理,提供类型检查和异常处理。 2. **数学函数**: - `sqrt`:计算平方根。 - `pow`:计算幂次。 - `sin`, `cos`, `tan`:三角函数。 - `log`, `exp`:对数和指数函数。...

    vc标准库

    C++标准库更推荐使用`std::new`和`std::delete`进行动态内存管理,以及`std::unique_ptr`、`std::shared_ptr`和`std::weak_ptr`智能指针来自动管理对象生命周期,防止内存泄漏。 ### 6. 算法 标准库包含大量实用的...

    VC6.0使用PNG库读取png图片数据

    在本文中,我们将深入探讨如何在Visual C++ 6.0(简称VC6.0)环境中使用PNG库来读取PNG图像数据。PNG(Portable Network Graphics)是一种无损压缩的位图格式,广泛用于网络和应用程序中。由于VC6.0的年代较早,它并...

    VC函数小词典

    使用《VC函数小词典》这样的工具,开发者可以快速查找和理解这些函数的用途、参数、返回值以及使用示例,从而避免在编写代码时迷失在函数海洋中。对于初学者,这是一本宝贵的参考书;对于经验丰富的开发者,它则是一...

    vc函数经典

    3. **内存管理函数**:如new、delete用于动态内存分配与释放,malloc、calloc、free等C风格的内存管理函数。 4. **异常处理函数**:如try、catch、throw用于异常处理,确保程序在遇到错误时能够优雅地退出。 5. **...

    VC转DELPHI的东东

    - **内存管理**:VC使用手动内存管理(new/delete),而Delphi采用自动引用计数(ARC)和智能指针。 - **库和框架**:MFC和VCL提供不同的库和框架支持,需要理解两者间的API差异。 - **编译和链接**:VC使用MSVC...

    vc++函数大全PDF格式

    8. **内存管理**:VC++中,`new`和`delete`关键字用于动态分配和释放内存,而智能指针(如`std::unique_ptr`和`std::shared_ptr`)则自动管理内存,降低了内存泄漏的风险。 通过《VC++函数大全》PDF,开发者不仅...

    VC实例

    在VC实例中,你会遇到动态内存分配(new/delete)、堆栈和堆的使用等主题。 4. **异常处理**:C++支持异常处理机制,用于处理运行时错误。通过VC实例,你可以学习如何使用try-catch块来捕获和处理异常。 5. **STL...

    vc2010 crt 源文件

    微软的Visual C++ 2010(简称VC2010)是一款强大的C++开发工具,它包含了丰富的库支持,其中最重要的一部分就是C运行时库(C Runtime Library,简称CRT)。CRT是C语言和C++语言编程时必不可少的组件,它提供了许多...

Global site tag (gtag.js) - Google Analytics