这几天下了 Chrome 的源码,安装了一个 debug 版的 Chromium 研究了一下,虽然很多地方都一知半解,但是还是有一点收获,将在这篇文章介绍 DOM 树是如何构建的,看了本文应该可以回答以下问题:
先说一下,怎么安装一个可以 debug 的 Chrome
为了可以打断点 debug,必须得从头编译 (编译的时候带上 debug 参数)。所以要下载源码,Chrome 把最新的代码更新到了 Chromium 的工程,是完全开源的,你可以把它整一个 git 工程下载下来。Chromium 的下载安装可参考它的 , 这里把一些关键点说一下,以 Mac 为例。你需要先下载它的安装脚本工具,然后下载源码:
- fetchchromium --no-history
–no-history 的作用是不把整个 git 工程下载下来,那个实在是太大了。或者是直接执行 git clone:
- gitclone https://chromium.googlesource.com/chromium/src
这个就是整一个 git 工程,下载下来有 6.48GB(那时)。博主就是用的这样的方式,如果下载到最后提示出错了:
- fatal: Theremoteend hungupunexpectedly
- fatal: earlyEOF
- fatal: index-packfailed
可以这样解决:
- gitconfig --global core.compression 0
- gitclone --depth 1https://chromium.googlesource.com/chromium/src
就不用重头开始 clone,因为实在太大、太耗时了。
下载好之后生成 build 的文件:
- gngenout/gn --ide=xcode
–ide=xcode 是为了能够使用苹果的 XCode 进行可视化进行调试。gn 命令要下载 Chrome 的 devtools 包,文档里面有说明。
装备就绪之后就可以进行编译了:
- ninja -C out/gnchrome
在笔者的电脑上编译了 3 个小时,firfox 的源码需要编译 7、8 个小时,所以相对来说已经快了很多,同时没报错,一次就过,相当顺利。编译组装好了之后,会在 out/gn 目录生成 Chromium 的可执行文件,具体路径是在:
- out/gn/Chromium.app/Contents/MacOS/Chromium
运行这个就可以打开 Chromium 了:
那么怎么在可视化的 XCode 里面进行 debug 呢?
在上面生成 build 文件的同时,会生成 XCode 的工程文件:sources.xcodeproj,具体路径是在:
- out/gn/sources.xcodeproj
双击这个文件,打开 XCode,在上面的菜单栏里面点击 Debug -> AttachToProcess -> Chromium,要先打开 Chrome,才能在列表里面看到 Chrome 的进程。然后小试牛刀,打个断点试试,看会不会跑进来:
在左边的目录树,打开 这个文件,然后在这个文件的 ParseCommand 函数 里面打一个断点,按照字面理解这个函数应该是解析控制台的命令。打开 Chrome 的控制台,输入一条命令,例如:new Date(),按回车可以看到断点生效了:
通过观察变量值,可以看到刚刚敲进去的命令。这就说明了我们安装成功,并且可以通过可视化的方式进行调试。
但是我们要 debug 页面渲染过程,Chrome 的 blink 框架使用多进程技术,每打开一个 tab 都会新开一个进程,按上面的方式是 debug 不了构建 DOM 过程的,从 可以查到,需要在启动的时候带上一个参数:
- Chromium --renderer-startup-dialog
Chrom 的启动进程就会绪塞,并且提示它的渲染进程 ID:
[7339:775:0102/210122.254760:ERROR:child_process.cc(145)] Renderer (7339) paused waiting for debugger to attach. Send SIGUSR1 to unpause.
7339 就是它的渲染进程 id,在 XCode 里面点 Debug -> AttachToProcess By Id or Name -> 填入 id -> 确定,attach 之后,Chrome 进程就会恢复,然后就可以开始调试渲染页面的过程了。
在 content/renderer/render_view_impl.cc 这个文件的 1093 行 RenderViewImpl::Create 函数里面打个断点,按照上面的方式,重新启动 Chrome,在命令行带上某个 html 文件的路径,为了打开 Chrome 的时候就会同时打开这个文件,方便调试。执行完之后就可以看到断点生效了。 可以说 render_view_impl.cc 这个文件是第一个具体开始渲染页面的文件——它会初始化页面的一些默认设置,如字体大小、默认的 viewport 等,响应关闭页面、 OrientationChange 等事件 ,而在它再往上的层主要是一些负责通信的类。
先画出构建 DOM 的几个关键的类的 UML 图,如下所示:
第一个类 HTMLDocumentParser 负责解析 html 文本为 tokens,一个 token 就是一个标签文本的序列化,并借助 HTMLTreeBuilder 对这些 tokens 分类处理,根据不同的标签类型、在文档不同位置,调用 HTMLConstructionSite 不同的函数构建 DOM 树。而 HTMLConstructionSite 借助一个工厂类对不同类型的标签创建不同的 html 元素,并建立起它们的父子兄弟关系,其中它有一个 m_document 的成员变量,这个变量就是这棵树的根结点,也是 js 里面的 window.document 对象。
为作说明,用一个简单的 html 文件一步步看这个 DOM 树是如何建立起来的:
- <!DOCTYPE html>
- <html>
- <head>
- <metacharset="utf-8">
- </head>
- <body>
- <div>
- <h1class="title">
- demo
- </h1>
- <inputvalue="hello">
- </div>
- </body>
- </html>
然后按照上面第 2 点提到 debug 的方法,打开 Chromium 并开始 debug:
- chromium ~/demo.html --renderer-startup-dialog
我们先来研究一下 Chrome 的加载和解析机制
以发 http 请求去加载 html 文本做为我们分析的第一步,在此之前的一些初始化就不考虑了。Chrome 是在 DocumentLoader 这个类里面的 startLoadingMainResource 函数里去加载 url 返回的数据,如访问一个网站则返回 html 文本:
- FetchRequestfetchRequest(<strong>m_request</strong>, FetchInitiatorTypeNames::document,
- mainResourceLoadOptions);
- m_mainResource =
- RawResource::fetchMainResource(fetchRequest, fetcher(), m_substituteData);
把 m_request 打印出来,在这个函数里面加一行代码:
- LOG(INFO) << "request url is: " << m_request.url().getString()
并重新编译 Chrome 运行,控制台输出:
[22731:775:0107/224014.494114:INFO:DocumentLoader.cpp(719)] request url is: "file:///Users/yincheng/demo.html"
可以看到,这个 url 确实是我们传进的参数。
发请求后,每次收到的数据块,会通过 Blink 封装的 IPC 进程间通信,触发 DocumentLoader 的 dataReceived 函数,里面会去调它 commit Data 函数,开始处理具体业务逻辑:
- void DocumentLoader::commitData(const char* bytes, size_tlength) {
- ensureWriter(m_response.mimeType());
- if (length)
- m_dataReceived = true;
- m_writer->addData(bytes, length);
- }
这个函数关键行是最 2 行和第 7 行,ensureWriter 这个函数会去初始化上面画的 UML 图的解析器 HTMLDocumentParser (Parser),并实例化 document 对象,这些实例都通过实例 m_writer 去带动的。也就是说,writer 会去实例化 Parser,然后第 7 行 writer 传递数据给 Parser 去解析。
检查一下收到的数据 bytes 是什么东西:
可以看到 bytes 就是请求返回的 html 文本。
在 ensureWriter 函数里面有个判断:
- void DocumentLoader::ensureWriter(const AtomicString& mimeType,
- const KURL& overridingURL) {
- if (m_writer)
- return;
- }
如果 m_writer 已经初始化过了,则直接返回。也就是说 Parser 和 document 只会初始化一次。
在上面的 addData 函数里面,会启动一条线程执行 Parser 的任务:
- if (!m_haveBackgroundParser)
- startBackgroundParser();
并把数据传递给这条线程进行解析,Parser 一旦收到数据就会序列成 tokens,再构建 DOM 树。
这里我们只要关注序列化后的 token 是什么东西就好了,为此,写了一个函数,把 tokens 的一些关键信息打印出来:
- String getTokenInfo(){
- String tokenInfo = "";
- tokenInfo = "tagName: " + this->m_name + "|type: " + getType() + "|attr:" + getAttributes() + "|text: " + this->m_data;
- return tokenInfo;
- }
打印出来的结果:
- tagName: html |type: DOCTYPE |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: html |type: startTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: head |type: startTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n "
- tagName: meta |type: startTag |attr:charset=utf-8 |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: head |type: EndTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: body |type: startTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n "
- tagName: div |type: startTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n "
- tagName: h1 |type: startTag |attr:class=title |text: "
- tagName: |type: Character |attr: |text: demo"
- tagName: h1 |type: EndTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n "
- tagName: input |type: startTag |attr:value=hello |text: "
- tagName: |type: Character |attr: |text: \n "
- tagName: div |type: EndTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: body |type: EndTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: html |type: EndTag |attr: |text: "
- tagName: |type: Character |attr: |text: \n"
- tagName: |type: EndOfFile |attr: |text: "
这些内容有标签名、类型、属性和 innerText,标签之间的文本(换行和空白)也会被当作一个标签处理。Chrome 总共定义了 7 种标签类型:
- enum TokenType {
- Uninitialized,
- DOCTYPE,
- StartTag,
- EndTag,
- Comment,
- Character,
- EndOfFile,
- };
有了一个根结点 document 和一些格式化好的 tokens,就可以构建 dom 树了。
在研究这个过程之前,先来看一下一个 DOM 结点的数据结构是怎么样的。以 p 标签 HTMLParagraphElement 为例,画出它的 UML 图,如下所示:
Node 是最顶层的父类,它有三个指针,两个指针分别指向它的前一个结点和后一个结点,一个指针指向它的父结点;
ContainerNode 继承于 Node,添加了两个指针,一个指向第一个子元素,另一个指向最后一个子元素;
Element 又添加了获取 dom 结点属性、clientWidth、scrollTop 等函数
HTMLElement 又继续添加了 Translate 等控制,最后一级的子类 HTMLParagraphElement 只有一个创建的函数,但是它继承了所有父类的属性。
需要提到的是每个 Node 都组合了一个 treeScope,这个 treeScope 记录了它属于哪个 document(一个页面可能会嵌入 iframe)。
构建 DOM 最关键的步骤应该是建立起每个结点的父子兄弟关系,即上面提到的成员指针的指向。
到这里我们可以先回答上面提出的第一个问题,什么是浏览器内核
浏览器内核也叫渲染引擎,上面已经看到了 Chrome 是如何实例化一个 P 标签的,而从 firefox 的源码里面 P 标签的依赖关系是这样的:
在代码实现上和 Chrome 没有任何关系。这就好像 W3C 出了道题,firefox 给了一个解法,取名为 Gecko,Safari 也给了自己的答案,取名 Webkit,Chrome 觉得 Safari 的解法比较好直接拿过来用,又结合自身的基础又封装了一层,取名 Blink。由于 W3C 出的这道题 "开放性" 比较大,出的时间比较晚,导致各家实现各有花样。
明白了这点后,继续 DOM 构建。下面开始不再说 Chrome,叫 Webkit 或者 Blink 应该更准确一点
Webkit 把 tokens 序列好之后,传递给构建的线程。在 HTMLDocumentParser::processTokenizedChunkFromBackgroundParser 的这个函数里面会做一个循环,把解析好的 tokens 做一个遍历,依次调
进行处理。
- constructTreeFromCompactHTMLToken
根据上面的输出,最开始处理的第一个 token 是 docType 的那个:
- "tagName: html |type: DOCTYPE |attr: |text: "
在那个函数里面,首先 Parser 会调 TreeBuilder 的函数:
- m_treeBuilder->constructTree(&token);
然后在 TreeBuilder 里面根据 token 的类型做不同的处理:
- void HTMLTreeBuilder: :processToken(AtomicHTMLToken * token) {
- if (token - >type() == HTMLToken: :Character) { processCharacter(token);
- return;
- }
- switch (token - >type()) {
- case HTMLToken:
- :
- DOCTYPE:
- processDoctypeToken(token);
- break;
- case HTMLToken:
- :
- StartTag:
- processStartTag(token);
- break;
- case HTMLToken:
- :
- EndTag:
- processEndTag(token);
- break; //othercode
- }
- }
它会对不同类型的结点做相应处理,从上往下依次是文本节点、doctype 节点、开标签、闭标签。doctype 这个结点比较特殊,单独作为一种类型处理
在 Parser 处理 doctype 的函数里面调了 HTMLConstructionSite 的插入 doctype 的函数:
- void HTMLTreeBuilder::processDoctypeToken(AtomicHTMLToken* token) {
- m_tree.insertDoctype(token);
- setInsertionMode(BeforeHTMLMode);
- }
在这个函数里面,它会先创建一个 doctype 的结点,再创建插 dom 的 task,并设置文档类型:
- void HTMLConstructionSite::insertDoctype(AtomicHTMLToken* token) {
- //const String& publicId = ...
- //const String& systemId = ...
- DocumentType* doctype =
- DocumentType::create(m_document, token->name(), publicId, systemId); //创建DOCType结点
- attachLater(m_attachmentRoot, doctype); //创建插DOM的task
- setCompatibilityModeFromDoctype(token->name(), publicId, systemId); //设置文档类型
- }
我们来看一下不同的 doctype 对文档类型的设置有什么影响,如下:
- // Check for Quirks Mode.
- if (name != "html" ) {
- setCompatibilityMode(Document::QuirksMode);
- return;
- }
如果 tagName 不是 html,那么文档类型将会是怪异模式,以下两种就会是怪异模式:
- <!DOCTypesvg>
- <!DOCTypemath>
而常用的 html4 写法:
- <!DOCTYPEHTMLPUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
- "http://www.w3.org/TR/html4/loose.dtd">
在源码里面这个将是有限怪异模式:
- // Check for Limited Quirks Mode.
- if (!systemId.isEmpty() &&
- publicId.startsWith("-//W3C//DTD HTML 4.01 Transitional//",
- TextCaseASCIIInsensitive))) {
- setCompatibilityMode(Document::LimitedQuirksMode);
- return;
- }
上面的 systemId 就是 "http://www.w3.org/TR/html4/loose.dtd",它不是空的,所以判断成立。而如果 systemId 为空,则它将是怪异模式。如果既不是怪异模式,也不是有限怪异模式,那么它就是标准模式:
- // Otherwise we are No Quirks Mode.
- setCompatibilityMode(Document::NoQuirksMode);
常用的 html5 的写法就是标准模式,如果连 DOCType 声明也没有呢?那么会默认设置为怪异模式:
- void HTMLConstructionSite::setDefaultCompatibilityMode() {
- setCompatibilityMode(Document::QuirksMode);
- }
这些模式有什么区别,从源码注释可窥探一二:
- // There are three possible compatibility modes:
- // Quirks - quirks mode emulates WinIE and NS4. CSS parsing is also relaxed in
- // this mode, e.g., unit types can be omitted from numbers.
- // Limited Quirks - This mode is identical to no-quirks mode except for its
- // treatment of line-height in the inline box model.
- // No Quirks - no quirks apply. Web pages will obey the specifications to the
- // letter.
大意是说,怪异模式会模拟 IE,同时 CSS 解析会比较宽松,例如数字单位可以省略,而有限怪异模式和标准模式的唯一区别在于在于对 inline 元素的行高处理不一样。标准模式将会让页面遵守文档规定。
怪异模式下的 input 和 textarea 的默认盒模型将会变成 border-box:
标准模式下的文档高度是实际内容的高度:
而在怪异模式下的文档高度是窗口可视域的高度:
在有限怪异模式下,div 里面的图片下方不会留空白,如下图左所示;而在标准模式下 td 下方会留点空白,如下图右所示:
- <div>
- <imgsrc="test.jpg" style="height:100px">
- </div>
这个空白是 div 的行高撑起来的,当把 div 的行高设置成 0 的时候,就没有下面的空白了。在怪异模和有限怪异模式下,为了计算行内子元素的最小高度,一个块级元素的行高必须被忽略。
这里的叙述虽然跟解读源码没有直接的关系(我们还没解读到 CSS 处理),但是很有必要提一下。
接下来我们开始正式说明 DOM 构建
- HTMLConstructionSite::HTMLConstructionSite(
- Document& document)
- : m_document(&document),
- m_attachmentRoot(document)) {
- }
所以 html 结点的父结点就是 document,实际的操作过程是这样的:
- void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) {
- HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document);
- attachLater(m_attachmentRoot, element);
- m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token));
- executeQueuedTasks();
- }
第二行先创建一个 html 结点,第三行把它加到一个任务队列里面,传递两个参数,第一个参数是父结点,第二个参数是当前结点,第五行执行队列里面的任务。代码第四行会把它压到一个栈里面,这个栈存放了未遇到闭标签的所有开标签。
第三行 attachLater 是如何建立一个 task 的:
- void HTMLConstructionSite::attachLater(ContainerNode* parent,
- Node* child,
- bool selfClosing) {
- HTMLConstructionSiteTasktask(HTMLConstructionSiteTask::Insert);
- task.parent = parent;
- task.child = child;
- task.selfClosing = selfClosing;
- // Add as a sibling of the parent if we have reached the maximum depth
- // allowed.
- if (m_openElements.stackDepth() > maximumHTMLParserDOMTreeDepth &&
- task.parent->parentNode())
- task.parent = task.parent->parentNode();
- queueTask(task);
- }
代码逻辑比较简单,比较有趣的是发现 DOM 树有一个最大的深度:maximumHTMLParserDOMTreeDepth,超过这个最大深度就会把它子元素当作父无素的同级节点,这个最大值是多少呢?512:
- static const unsigned maximumHTMLParserDOMTreeDepth = 512;
我们重点关注 executeQueuedTasks 干了些什么,它会根据 task 的类型执行不同的操作,由于本次是 insert 的,它会去执行一个插入的函数:
- void ContainerNode::parserAppendChild(Node* newChild) {
- if (!checkParserAcceptChild(*newChild))
- return;
- AdoptAndAppendChild()(*this, *newChild, nullptr);
- }
- notifyNodeInserted(*newChild, ChildrenChangeSourceParser);
- }
在插入里面它会先去检查父元素是否支持子元素,如果不支持,则直接返回,就像 video 标签不支持子元素。然后再去调具体的插入:
- void ContainerNode::appendChildCommon(Node& child) {
- child.setParentOrShadowHostNode(this);
- if (m_lastChild) {
- child.setPreviousSibling(m_lastChild);
- m_lastChild->setNextSibling(&child);
- } else {
- setFirstChild(&child);
- }
- setLastChild(&child);
- }
上面代码第二行,设置子元素的父结点,也就是会把 html 结点的父结点指向 document,然后如果没有 lastChild,会将这个子元素作为 firstChild,由于上面已经有一个 docype 的子结点了,所以已经有 lastChild 了,因此会把这个子元素的 previousSibling 指向老的 lastChild,老的 lastChild 的 nexSibling 指向它。最后倒数第二行再把子元素设置为当前 ContainerNode(即 document)的 lastChild。这样就建立起了 html 结点的父子兄弟关系。
可以看到, 借助上一次的 m_lastChild 建立起了兄弟关系 。
这个时候你可能会有一个问题,为什么要用一个 task 队列存放将要插入的结点呢,而不是直接插入呢?一个原因放到 task 里面方便统一处理,并且有些 task 可能不能立即执行,要先存起来。不过在我们这个案例里面都是存完后下一步就执行了。
当遇到 head 标签的 token 时,也是先创建一个 head 结点,然后再创建一个 task,插到队列里面:
- void HTMLConstructionSite::insertHTMLHeadElement(AtomicHTMLToken* token) {
- m_head = HTMLStackItem::create(createHTMLElement(token), token);
- attachLater(currentNode(), m_head->element());
- m_openElements.pushHTMLHeadElement(m_head);
- }
attachLater 传参的第一个参数为父结点,这个 currentNode 为开标签栈里面的最顶的元素:
- ContainerNode* currentNode() const {
- return m_openElements.topNode();
- }
我们刚刚把 html 元素压了进去,则栈顶元素为 html 元素,所以 head 的父结点就为 html。所以每当遇到一个开标签时,就把它压起来,下一次再遇到一个开标签时,它的父元素就是上一个开标签。
所以,初步可以看到, 借助一个栈建立起了父子关系 。
而当遇到一个闭标签呢?
当遇到一个闭标签时,会把栈里面的元素一直 pop 出来,直到 pop 到第一个和它标签名字一样的:
- m_tree.openElements()->popUntilPopped(token->name());
我们第一个遇到的是闭标签是 head 标签,它会把开的 head 标签 pop 出来,栈里面就剩下 html 元素了,所以当再遇到 body 时,html 元素就是 body 的父元素了。
这个是栈的一个典型应用。
以下面的 html 为例来研究压栈和出栈的过程:
- <!DOCTYPE html>
- <html>
- <head>
- <metacharset="utf-8">
- </meta>
- </head>
- <body>
- <div>
- <p>
- <b>
- hello
- </b>
- </p>
- <p>
- demo
- </p>
- </div>
- </body>
- </html>
把 push 和 pop 打印出来是这样的:
- push "HTML" m_stackDepth = 1
- push "HEAD" m_stackDepth = 2
- pop "HEAD" m_stackDepth = 1
- push "BODY" m_stackDepth = 2
- push "DIV" m_stackDepth = 3
- push "P" m_stackDepth = 4
- push "B" m_stackDepth = 5
- pop "B" m_stackDepth = 4
- pop "P" m_stackDepth = 3
- push "P" m_stackDepth = 4
- pop "P" m_stackDepth = 3
- pop "DIV" m_stackDepth = 2
- "tagName: body |type: EndTag |attr: |text: "
- "tagName: html |type: EndTag |attr: |text: "
这个过程确实和上面的描述一致,遇到一个闭标签就把一次的开标签 pop 出来。
并且可以发现遇到 body 闭标签后,并不会把 body 给 pop 出来,因为如果 body 闭标签后面又再写了标签的话,就会自动当成 body 的子元素。
假设上面的 b 标签的闭标签忘记写了,又会发生什么:
- <p>
- <b>
- hello
- </p>
打印出来的结果是这样的:
- push "P" m_stackDepth = 4
- push "B" m_stackDepth = 5
- "tagName: p |type: EndTag |attr: |text: "
- pop "B" m_stackDepth = 4
- pop "P" m_stackDepth = 3
- push "B" m_stackDepth = 4
- push "P" m_stackDepth = 5
- pop "P" m_stackDepth = 4
- pop "B" m_stackDepth = 3
- pop "DIV" m_stackDepth = 2
- push "B" m_stackDepth = 3
同样地,在上面第 3 行,遇到 P 闭标签时,会把所有的开标签 pop 出来,直到遇到 P 标签。不同的是后续的过程中会不断地插入 b 标签,最后渲染的页面结构:
因为 b 等带有格式化的标签会特殊处理,遇到一个开标签时会它们放到一个列表里面:
- // a, b, big, code, em, font, i, nobr, s, small, strike, strong, tt, and u.
- m_activeFormattingElements.append(currentElementRecord()->stackItem());
遇到一个闭标签时,又会从这个列表里面删掉。每处理一个新标签时就会进行检查和这个列表和栈里的开标签是否对应,如果不对应则会 reconstruct:重新插入一个开标签。因此 b 就不断地被重新插入,直到遇到下一个 b 的闭标签为止。
如果上面少写的是一个 span,那么渲染之后的结果是正常的:
而对于文本节点是实例化了 Text 的对象,这里不再展开讨论。
在浏览器里面可以看到,自定义标签默认不会有任何的样式,并且它默认是一个行内元素:
初步观察它和 span 标签的表现是一样的:
但是你可以用 js 定义一个自定义标签,定义它的属性等,Webkit 会去读它的定义:
- // "4. Let definition be the result of looking up a custom element ..." etc.
- CustomElementDefinition* definition =
- m_isParsingFragment ? nullptr
- : lookUpCustomElementDefinition(document, token);
例如给自定义标签创建一个原生属性:
- <high-schoolcountry="China">
- NO. 2 high school
- </high-school>
上面定义了一个 country,为了可以直接获取这个属性:
- console.log(document.getElementsByTagName("high-school")[0].country);
来源: