当客户端 App 主进程创建 WKWebView 对象时,会创建另外两个子进程:渲染进程与网络进程。主进程 WKWebView 发起请求时,先将请求转发给渲染进程,渲染进程再转发给网络进程,网络进程请求服务器。如果请求的是一个网页,网络进程会将服务器的响应数据 HTML 文件字符流吐给渲染进程。渲染进程拿到 HTML 文件字符流,首先要进行解析,将 HTML 文件字符流转换成 DOM 树,然后在 DOM 树的基础上,进行渲染操作,也就是布局、绘制。最后渲染进程通知主进程 WKWebView 创建对应的 View 展现视图。整个流程如下图所示:
一、什么是DOM树
渲染进程获取到 HTML 文件字符流,会将HTML文件字符流转换成 DOM 树。下图中左侧是一个 HTML 文件,右边就是转换而成的 DOM 树。
可以看到 DOM 树的根节点是 HTMLDocument,代表整个文档。根节点下面的子节点与 HTML 文件中的标签是一一对应的,比如 HTML 中的
标签就对应 DOM 树中的 head 节点。同时 HTML 文件中的文本,也成为 DOM 树中的一个节点,比如文本 'Hello, World!',在 DOM 树中就成为div节点的子节点。在 DOM 树中每一个节点都是具有一定方法与属性的对象,这些对象由对应的类创建出来。比如 HTMLDocument 节点,它对应的类是 class HTMLDocument,下面是 HTMLDocument 的部分源码:
class HTMLDocument : public Document { // 继承自 Document
...
WEBCORE_EXPORT int width();
WEBCORE_EXPORT int height();
...
}
从源码中可以看到,HTMLDocument 继承自类 Document,Document 类的部分源码如下:
class Document
: public ContainerNode // Document继承自 ContainerNode,ContainerNode继承自Node
, public TreeScope
, public ScriptExecutionContext
, public FontSelectorClient
, public FrameDestructionObserver
, public Supplementable
, public Logger::Observer
, public CanvasObserver {
WEBCORE_EXPORT ExceptionOr> createElementForBindings(const AtomString& tagName); // 创建Element的方法
WEBCORE_EXPORT Ref createTextNode(const String& data); // 创建文本节点的方法
WEBCORE_EXPORT Ref createComment(const String& data); // 创建注释的方法
WEBCORE_EXPORT Ref createElement(const QualifiedName&, bool createdByParser); // 创建Element方法
....
}
上面源码可以看到 Document 继承自 Node,而且还可以看到前端十分熟悉的 createElement、createTextNode 等方法,JavaScript 对这些方法的调用,最后都转换为对应 C++ 方法的调用。
类 Document 有这些方法,并不是没有原因的,而是 W3C 组织给出的标准规定的,这个标准就是 DOM(Document Object Model,文档对象模型)。DOM 定义了 DOM 树中每个节点需要实现的接口和属性,下面是 HTMLDocument、Document、HTMLDivElement 的部分 IDL(Interactive Data Language,接口描述语言,与具体平台和语言无关)描述,完整的 IDL 可以参看 W3C 。
在 DOM 树中,每一个节点都继承自类 Node,同时 Node 还有一个子类 Element,有的节点直接继承自类 Node,比如文本节点,而有的节点继承自类 Element,比如 div 节点。因此针对上面图中的 DOM 树,执行下面的 JavaScript 语句返回的结果是不一样的:
document.childNodes; // 返回子Node集合,返回DocumentType与HTML节点,都继承自Node
document.children; // 返回子Element集合,只返回HTML节点,DocumentType不继承自Element
下图给出部分节点的继承关系图:
二、DOM树构建
DOM 树的构建流程可以分为4个步骤: 解码、分词、创建节点、添加节点。
2.1 解码
渲染进程从网络进程接收过来的是 HTML 字节流,而下一步分词是以字符为单位进行的。由于各种编码规范的存在,比如 ISO-8859-1、UTF-8 等,一个字符常常可能对应一个或者多个编码后的字节,解码的目的就是将 HTML 字节流转换成 HTML 字符流,或者换句话说,就是将原始的 HTML 字节流转换成字符串。
2.1.1 解码类图
从类图上看,类 HTMLDocumentParser 处于解码的核心位置,由这个类调用解码器将 HTML 字节流解码成字符流,存储到类 HTMLInputStream 中。
2.1.2 解码流程
整个解码流程当中,最关健的是如何找到正确的编码方式。只有找到了正确的编码方式,才能使用对应的解码器进行解码。解码发生的地方如下面源代码所示,这个方法在上图第3个栈帧被调用:
// HTMLDocumentParser是DecodedDataDocumentParser的子类
void DecodedDataDocumentParser::appendBytes(DocumentWriter& writer, const uint8_t* data, size_t length)
{
if (!length)
return;
String decoded = writer.decoder().decode(data, length); // 真正解码发生在这里
if (decoded.isEmpty())
return;
writer.reportDataReceived();
append(decoded.releaseImpl());
}
上面代码第7行 writer.decoder() 返回一个 TextResourceDecoder 对象,解码操作由 TextResourceDecoder::decode 方法完成。下面逐步查看 TextResourceDecoder::decode 方法的源码:
// 只保留了最重要的部分
String TextResourceDecoder::decode(const char* data, size_t length)
{
...
// 如果是HTML文件,就从head标签中寻找字符集
if ((m_contentType == HTML || m_contentType == XML) && !m_checkedForHeadCharset) // HTML and XML
if (!checkForHeadCharset(data, length, movedDataToBuffer))
return emptyString();
...
// m_encoding存储者从HTML文件中找到的编码名称
if (!m_codec)
m_codec = newTextCodec(m_encoding); // 创建具体的编码器
...
// 解码并返回
String result = m_codec->decode(m_buffer.data() + lengthOfBOM, m_buffer.size() - lengthOfBOM, false, m_contentType == XML && !m_useLenientXMLDecoding, m_sawError);
m_buffer.clear(); // 清空存储的原始未解码的HTML字节流
return result;
}
从源码中可以看到,TextResourceDecoder 首先从 HTML 的
标签中去找编码方式,因为 标签可以包含 标签, 标签可以设置 HTML 文件的字符集:
DOM Tree
如果能找到对应的字符集,TextResourceDeocder 将其存储在成员变量 m\_encoding 当中,并且根据对应的编码创建真正的解码器存储在成员变量 m\_codec 中,最终使用 m\_codec 对字节流进行解码,并且返回解码后的字符串。如果带有字符集的 标签没有找到,TextResourceDeocder 的 m\_encoding 有默认值 windows-1252(等同于ISO-8859-1)。
下面看一下 TextResourceDecoder 寻找 标签中字符集的流程,也就是上面源码中第8行对 checkForHeadCharset 函数的调用:
// 只保留了关健代码
bool TextResourceDecoder::checkForHeadCharset(const char* data, size_t len, bool& movedDataToBuffer)
{
...
// This is not completely efficient, since the function might go
// through the HTML head several times.
size_t oldSize = m_buffer.size();
m_buffer.grow(oldSize + len);
memcpy(m_buffer.data() + oldSize, data, len); // 将字节流数据拷贝到自己的缓存m_buffer里面
movedDataToBuffer = true;
// Continue with checking for an HTML meta tag if we were already doing so.
if (m_charsetParser)
return checkForMetaCharset(data, len); // 如果已经存在了meta标签解析器,直接开始解析
....
m_charsetParser = makeUnique(); // 创建meta标签解析器
return checkForMetaCharset(data, len);
}
上面源代码中第11行,类 TextResourceDecoder 内部存储了需要解码的 HTML 字节流,这一步骤很重要,后面会讲到。先看第17行、21行、22行,这3行主要是使用标签解析器解析字符集,使用了懒加载的方式。下面看下 checkForMetaCharset 这个函数的实现:
bool TextResourceDecoder::checkForMetaCharset(const char* data, size_t length)
{
if (!m_charsetParser->checkForMetaCharset(data, length)) // 解析meta标签字符集
return false;
setEncoding(m_charsetParser->encoding(), EncodingFromMetaTag); // 找到后设置字符编码名称
m_charsetParser = nullptr;
m_checkedForHeadCharset = true;
return true;
}
上面源码第3行可以看到,整个解析 标签的任务在类 HTMLMetaCharsetParser::checkForMetaCharset 中完成。
// 只保留了关健代码
bool HTMLMetaCharsetParser::checkForMetaCharset(const char* data, size_t length)
{
if (m_doneChecking) // 标志位,避免重复解析
return true;
// We still don't have an encoding, and are in the head.
// The following tags are allowed in :
// SCRIPT|STYLE|META|LINK|OBJECT|TITLE|BASE
//
// We stop scanning when a tag that is not permitted in
// is seen, rather when is seen, because that more closely
// matches behavior in other browsers; more details in
// .
//
// Additionally, we ignore things that looks like tags in ,
纯文本的解析过程比较简单,就是不停的在 DataState 状态上跳转,缓存遇到的字符,直到遇见一个结束标签的 '<' 字符,相关代码如下:
BEGIN_STATE(DataState)
if (character == '&')
ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInDataState);
if (character == '<') { // 如果在解析文本的过程中遇到开标签,分两种情况
if (haveBufferedCharacterToken()) // 第一种,如果缓存了文本字符就直接按当前DataState返回,并不移动字符,所以下次再进入分词操作时取到的字符仍为'<'
RETURN_IN_CURRENT_STATE(true);
ADVANCE_PAST_NON_NEWLINE_TO(TagOpenState); // 第二种,如果没有缓存任何文本字符,直接进入TagOpenState状态,进入到起始标签解析过程,并且移动下一个字符
}
if (character == kEndOfFileMarker)
return emitEndOfFile(source);
bufferCharacter(character); // 缓存遇到的字符
ADVANCE_TO(DataState); // 循环跳转到当前DataState状态,并且移动到下一个字符
END_STATE()
由于流程比较简单,下面只给出解析div标签中纯文本的结果:
2.3 创建节点与添加节点
2.3.1 相关类图
2.3.2 创建、添加流程
上面的分词循环中,每分出一个 Token,就会根据 Token 创建对应的 Node,然后将 Node 添加到 DOM 树上(HTMLDocumentParser::pumpTokenizerLoop 方法在上面分词中有介绍)。
上面方法中首先看 HTMLTreeBuilder::constructTree,代码如下:
// 只保留关健代码
void HTMLTreeBuilder::constructTree(AtomHTMLToken&& token)
{
...
if (shouldProcessTokenInForeignContent(token))
processTokenInForeignContent(WTFMove(token));
else
processToken(WTFMove(token)); // HTMLToken在这里被处理
...
m_tree.executeQueuedTasks(); // HTMLContructionSiteTask在这里被执行,有时候也直接在创建的过程中直接执行,然后这个方法发现队列为空就会直接返回
// The tree builder might have been destroyed as an indirect result of executing the queued tasks.
}
void HTMLConstructionSite::executeQueuedTasks()
{
if (m_taskQueue.isEmpty()) // 队列为空,就直接返回
return;
// Copy the task queue into a local variable in case executeTask
// re-enters the parser.
TaskQueue queue = WTFMove(m_taskQueue);
for (auto& task : queue) // 这里的task就是HTMLContructionSiteTask
executeTask(task); // 执行task
// We might be detached now.
}
上面代码中 HTMLTreeBuilder::processToken 就是处理 Token 生成对应 Node 的地方,代码如下所示:
void HTMLTreeBuilder::processToken(AtomHTMLToken&& token)
{
switch (token.type()) {
case HTMLToken::Uninitialized:
ASSERT_NOT_REACHED();
break;
case HTMLToken::DOCTYPE: // HTML中的DOCType标签
m_shouldSkipLeadingNewline = false;
processDoctypeToken(WTFMove(token));
break;
case HTMLToken::StartTag: // 起始HTML标签
m_shouldSkipLeadingNewline = false;
processStartTag(WTFMove(token));
break;
case HTMLToken::EndTag: // 结束HTML标签
m_shouldSkipLeadingNewline = false;
processEndTag(WTFMove(token));
break;
case HTMLToken::Comment: // HTML中的注释
m_shouldSkipLeadingNewline = false;
processComment(WTFMove(token));
return;
case HTMLToken::Character: // HTML中的纯文本
processCharacter(WTFMove(token));
break;
case HTMLToken::EndOfFile: // HTML结束标志
m_shouldSkipLeadingNewline = false;
processEndOfFile(WTFMove(token));
break;
}
}
可以看到上面代码对7类 Token 做了处理,由于处理的流程都是类似的,这里分析5 个节点case的创建添加过程,分别是 标签, 起始标签,
Case1:!DOCTYPE 标签
// 只保留关健代码
void HTMLTreeBuilder::processDoctypeToken(AtomHTMLToken&& token)
{
ASSERT(token.type() == HTMLToken::DOCTYPE);
if (m_insertionMode == InsertionMode::Initial) { // m_insertionMode的初始值就是InsertionMode::Initial
m_tree.insertDoctype(WTFMove(token)); // 插入DOCTYPE标签
m_insertionMode = InsertionMode::BeforeHTML; // 插入DOCTYPE标签之后,m_insertionMode设置为InsertionMode::BeforeHTML,表示下面要开是HTML标签插入
return;
}
...
}
// 只保留关健代码
void HTMLConstructionSite::insertDoctype(AtomHTMLToken&& token)
{
...
// m_attachmentRoot就是Document对象,文档根节点
// DocumentType::create方法创建出DOCTYPE节点
// attachLater方法内部创建出HTMLContructionSiteTask
attachLater(m_attachmentRoot, DocumentType::create(m_document, token.name(), publicId, systemId));
...
}
// 只保留关健代码
void HTMLConstructionSite::attachLater(ContainerNode& parent, Ref&& child, bool selfClosing)
{
...
HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert); // 创建HTMLConstructionSiteTask
task.parent = &parent; // task持有当前节点的父节点
task.child = WTFMove(child); // task持有需要操作的节点
task.selfClosing = selfClosing; // 是否自关闭节点
// Add as a sibling of the parent if we have reached the maximum depth allowed.
// m_openElements就是HTMLElementStack,在这里还看不到它的作用,后面会讲。这里可以看到这个stack里面加入的对象个数是有限制的,最大不超过512个。
// 所以如果一个HTML标签嵌套过多的子标签,就会触发这里的操作
if (m_openElements.stackDepth() > m_maximumDOMTreeDepth && task.parent->parentNode())
task.parent = task.parent->parentNode(); // 满足条件,就会将当前节点添加到爷爷节点,而不是父节点
ASSERT(task.parent);
m_taskQueue.append(WTFMove(task)); // 将task添加到Queue当中
}
从代码可以看到,这里只是创建了 DOCTYPE 节点,还没有真正添加。真正执行添加的操作,需要执行 HTMLContructionSite::executeQueuedTasks,这个方法在一开始有列出来。下面就来看下每个 Task 如何被执行。
// 方法位于HTMLContructionSite.cpp
static inline void executeTask(HTMLConstructionSiteTask& task)
{
switch (task.operation) { // HTMLConstructionSiteTask存储了自己要做的操作,构建DOM树一般都是Insert操作
case HTMLConstructionSiteTask::Insert:
executeInsertTask(task); // 这里执行insert操作
return;
// All the cases below this point are only used by the adoption agency.
case HTMLConstructionSiteTask::InsertAlreadyParsedChild:
executeInsertAlreadyParsedChildTask(task);
return;
case HTMLConstructionSiteTask::Reparent:
executeReparentTask(task);
return;
case HTMLConstructionSiteTask::TakeAllChildrenAndReparent:
executeTakeAllChildrenAndReparentTask(task);
return;
}
ASSERT_NOT_REACHED();
}
// 只保留关健代码,方法位于HTMLContructionSite.cpp
static inline void executeInsertTask(HTMLConstructionSiteTask& task)
{
ASSERT(task.operation == HTMLConstructionSiteTask::Insert);
insert(task); // 继续调用插入方法
...
}
// 只保留关健代码,方法位于HTMLContructionSite.cpp
static inline void insert(HTMLConstructionSiteTask& task)
{
...
ASSERT(!task.child->parentNode());
if (task.nextChild)
task.parent->parserInsertBefore(*task.child, *task.nextChild);
else
task.parent->parserAppendChild(*task.child); // 调用父节点方法继续插入
}
// 只保留关健代码
void ContainerNode::parserAppendChild(Node& newChild)
{
...
executeNodeInsertionWithScriptAssertion(*this, newChild, ChildChange::Source::Parser, ReplacedAllChildren::No, [&] {
if (&document() != &newChild.document())
document().adoptNode(newChild);
appendChildCommon(newChild); // 在Block回调中调用此方法继续插入
...
});
}
// 最终调用的是这个方法进行插入
void ContainerNode::appendChildCommon(Node& child)
{
ScriptDisallowedScope::InMainThread scriptDisallowedScope;
child.setParentNode(this);
if (m_lastChild) { // 父节点已经插入子节点,运行在这里
child.setPreviousSibling(m_lastChild);
m_lastChild->setNextSibling(&child);
} else
m_firstChild = &child; // 如果父节点是首次插入子节点,运行在这里
m_lastChild = &child; // 更新m_lastChild
}
经过执行上面方法之后,原来只有一个根节点的 DOM 树变成了下面的样子:
Case2:html 起始标签
// processStartTag内部有很多状态处理,这里只保留关健代码
void HTMLTreeBuilder::processStartTag(AtomHTMLToken&& token)
{
ASSERT(token.type() == HTMLToken::StartTag);
switch (m_insertionMode) {
case InsertionMode::Initial:
defaultForInitial();
ASSERT(m_insertionMode == InsertionMode::BeforeHTML);
FALLTHROUGH;
case InsertionMode::BeforeHTML:
if (token.name() == htmlTag) { // html标签在这里处理
m_tree.insertHTMLHtmlStartTagBeforeHTML(WTFMove(token));
m_insertionMode = InsertionMode::BeforeHead; // 插入完html标签,m_insertionMode = InsertionMode::BeforeHead,表明即将处理head标签
return;
}
...
}
}
// 只保留关健代码
void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomHTMLToken&& token)
{
auto element = HTMLHtmlElement::create(m_document); // 创建html节点
setAttributes(element, token, m_parserContentPolicy);
attachLater(m_attachmentRoot, element.copyRef()); // 同样调用了attachLater方法,与DOCTYPE类似
m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element.copyRef(), WTFMove(token))); // 注意这里,这里向HTMLElementStack中压入了正在插入的html起始标签
executeQueuedTasks(); // 这里在插入操作直接执行了task,外面HTMLTreeBuilder::constructTree方法调用的executeQueuedTasks方法就会直接返回
...
}
执行上面代码之后,DOM 树变成了如下图所示:
Case3:title 起始标签
当插入
Case4:title 标签文本
`// 只保留关健代码
void HTMLConstructionSite::insertTextNode(const String& characters, WhitespaceMode whitespaceMode)
{
HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert);
task.parent = ¤tNode(); // 直接取HTMLElementStack m_openElements的栈顶节点,此时节点是title`
unsigned currentPosition = 0;
unsigned lengthLimit = shouldUseLengthLimit(*task.parent) ? Text::defaultLengthLimit : std::numeric_limits
// 可以看到如果文本过长,会将分割成多个文本节点
while (currentPosition < characters.length()) {
AtomString charactersAtom = m_whitespaceCache.lookup(characters, whitespaceMode);
auto textNode = Text::createWithLengthLimit(task.parent->document(), charactersAtom.isNull() ? characters : charactersAtom.string(), currentPosition, lengthLimit);
// If we have a whole string of unbreakable characters the above could lead to an infinite loop. Exceeding the length limit is the lesser evil.
if (!textNode->length()) {
String substring = characters.substring(currentPosition);
AtomString substringAtom = m_whitespaceCache.lookup(substring, whitespaceMode);
textNode = Text::create(task.parent->document(), substringAtom.isNull() ? substring : substringAtom.string()); // 生成文本节点
}
currentPosition += textNode->length(); // 下一个文本节点包含的字符起点
ASSERT(currentPosition <= characters.length());
task.child = WTFMove(textNode);
executeTask(task); // 直接执行Task插入
}
}
从代码可以看到,如果一个节点后面跟的文本字符过多,会被分割成多个文本节点插入。下面的例子将 节点后面的文本字符个数设置成85248,使用 Safari 查看确实生成了2个文本节点:
![图片](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/584362db5ed04e2fb9d761f8d6f2d83c~tplv-k3u1fbpfcp-zoom-1.image)
**Case5:结束标签**
当遇到 结束标签,代码处理如下:
// 代码内部有很多状态处理,这里只保留关健代码
void HTMLTreeBuilder::processEndTag(AtomHTMLToken&& token)
{
ASSERT(token.type() == HTMLToken::EndTag);
switch (m_insertionMode) {
...
case InsertionMode::Text: // 由于遇到title结束标签之前插入了文本,因此此时的插入模式就是InsertionMode::Text
m_tree.openElements().pop(); // 因为遇到了title结束标签,整个标签已经处理完毕,从HTMLElementStack栈中弹出栈顶元素title
m_insertionMode = m_originalInsertionMode; // 恢复之前的插入模式
break;
}
每当遇到一个标签的结束标签,都会像上面一样将 HTMLElementStack m\_openElementsStack 的栈顶元素弹出。执行上面代码之后,DOM 树与 HTMLElementStack 如下图所示:
三、内存中的DOM树
当整个 DOM 树构建完成之后,DOM 树和 HTMLElementStack m\_openElements 如下图所示:
从上图可以看到,当构建完 DOM,HTMLElementStack m\_openElements 并没有将栈完全清空,而是保留了2个节点: html 节点与 body 节点。这可以从 Xcode 的控制台输出看到:
同时可以看到,内存中的 DOM 树结构和文章开头画的逻辑上的 DOM 树结构是不一样的。逻辑上的 DOM 树父节点有多少子节点,就有多少指向子节点的指针,而内存中的 DOM 树,不管父节点有多少子节点,始终只有2个指针指向子节点: m\_firstChild 与 m\_lastChild。同时,内存中的 DOM 树兄弟节点之间也相互有指针引用,而逻辑上的 DOM 树结构是没有的。
举个例子,如果一棵 DOM 树只有1个父节点,100个子节点,那么使用逻辑上的 DOM 树结构,父节点就需要100个指向子节点的指针。如果一个指针占8字节,那么总共占用800字节。使用上面内存中 DOM 树的表示方式,父节点需要2个指向子节点的指针,同时兄弟节点之间需要198个指针,一共200个指针,总共占用1600字节。相比逻辑上的 DOM 树结构,内存上并不占优势,但是内存中的 DOM 树结构,无论父节点有多少子节点,只需要2个指针就可以了,不需要添加子节点时,频繁动态申请内存,创建新的指向子节点的指针。
---------- END ----------
百度 Geek 说
百度官方技术公众号上线啦!
技术干货 · 行业资讯 · 线上沙龙 · 行业大会
招聘信息 · 内推信息 · 技术书籍 · 百度周边