当客户端App主进程创建WKWebView对象时,会创建另外两个子进程:渲染进程与网络进程。主进程WKWebView发起请求时,先将请求转发给渲染进程,渲染进程再转发给网络进程,网络进程请求服务器。如果请求的是一个网页,网络进程会将服务器的响应数据HTML文件字符流吐给渲染进程。渲染进程拿到HTML文件字符流,首先要进行解析,将HTML文件字符流转换成DOM树,然后在DOM树的基础上,进行渲染操作,也就是布局、绘制。最后渲染进程将渲染数据吐给主进程WKWebView,WKWebView根据渲染数据创建对应的View展现视图。整个流程如下图所示:
什么是DOM树
渲染进程获取到HTML文件字符流,会将HTML文件字符流转换成DOM树。下图中左侧是一个HTML文件,右边就是转换而成的DOM树。
可以看到DOM树的根节点是HTMLDocument,代表整个文档。根节点下面的子节点与HTML文件中的标签是一一对应的,比如HTML中的<head>
标签就对应DOM树中的head节点。同时HTML文件中的文本,也成为DOM树中的一个节点,比如文本'Hello, World!',在DOM树中就成为div节点的子节点。
在DOM树中每一个节点都是具有一定方法与属性的对象,这些对象由对应的类创建出来。比如HTMLDocument节点,它对应的类是class HTMLDocument,下面是HTMLDocument的部分源码:
1 class HTMLDocument : public Document { // 继承自Document
2 …
3 WEBCORE_EXPORT int width();
4 WEBCORE_EXPORT int height();
5 …
6 }
从源码中可以看到,HTMLDocument继承自类Document,Document类的部分源码如下:
1 class Document
2 : public ContainerNode // Document继承自ContainerNode,ContainerNode继承自Node
3 , public TreeScope
4 , public ScriptExecutionContext
5 , public FontSelectorClient
6 , public FrameDestructionObserver
7 , public Supplementable
8 , public Logger::Observer
9 , public CanvasObserver {
10 WEBCORE_EXPORT ExceptionOr> createElementForBindings(const AtomString& tagName); // 创建Element的方法
11 WEBCORE_EXPORT Ref
12 WEBCORE_EXPORT Ref
13 WEBCORE_EXPORT Ref
14 ….
15 }
上面源码可以看到Document继承自Node,而且还可以看到前端十分熟悉的createElement、createTextNode等方法,JavaScript对这些方法的调用,最后都转换为对应C++方法的调用。
类Document有这些方法,并不是没有原因的,而是W3C组织给出的标准规定的,这个标准就是DOM(Document Object Model,文档对象模型)。DOM定义了DOM树中每个节点需要实现的接口和属性,下面是HTMLDocument、Document、HTMLDivElment的部分IDL(Interactive Data Language,接口描述语言,与具体平台和语言无关)描述,完整的IDL可以参看W3C 。
1 interface HTMLDocument : Document { // HTMLDocument
2 getter (WindowProxy or Element or HTMLCollection) (DOMString name);
3 };
4
5
6 interface Document : Node { // Document
7 [NewObject, ImplementedAs=createElementForBindings] Element createElement(DOMString localName); // createElement
8 [NewObject] Text createTextNode(DOMString data); // createTextNode
9 …
10 }
11
12
13 interface HTMLDivElement : HTMLElement { // HTMLDivElement
14 [CEReactions=NotNeeded, Reflect] attribute DOMString align;
15 };
在DOM树中,每一个节点都继承自类Node,同时Node还有一个子类Element,有的节点直接继承自类Node,比如文本节点,而有的节点继承自类Element,比如div节点。因此针对上面图中的DOM树,执行下面的JavaScript语句返回的结果是不一样的:
1 document.childNodes; // 返回子Node集合,返回DocumentType与HTML节点,都继承自Node
2 document.children; // 返回子Element集合,只返回HTML节点,DocumentType不继承自Element
下图给出部分节点的继承关系图:
DOM树的构建
DOM树的构建流程可以分位4个步骤: 解码、分词、创建节点、添加节点。
1 解码
渲染进程从网络进程接收过来的是HTML字节流,而下一步分词是以字符为单位进行的。由于各种编码规范的存在,比如ISO-8859-1、UTF-8等,一个字符常常可能对应一个或者多个编码后的字节,解码的目的就是将HTML字节流转换成HTML字符流,或者换句话说,就是将原始的HTML字节流转换成字符串。
2 解码类图
从类图上看,类HTMLDocumentParser处于解码的核心位置,由这个类调用解码器将HTML字节流解码成字符流,存储到类HTMLInputStream中。
3 解码流程
整个解码流程当中,最关健的是如何找到正确的编码方式。只有找到了正确的编码方式,才能使用对应的解码器进行解码。解码发生的地方如下面源代码所示,这个方法在上图第3个栈帧被调用:
1 // HTMLDocumentParser是DecodedDataDocumentParser的子类
2 void DecodedDataDocumentParser::appendBytes(DocumentWriter& writer, const uint8_t* data, size_t length)
3 {
4 if (!length)
5 return;
6
7 String decoded = writer.decoder().decode(data, length); // 真正解码发生在这里
8 if (decoded.isEmpty())
9 return;
10
11 writer.reportDataReceived();
12 append(decoded.releaseImpl());
13 }
上面代码第7行writer.decoder()返回一个TextResourceDecoder对象,解码操作由TextResourceDecoder::decode方法完成。下面逐步查看TextResourceDecoder::decode方法的源码:
1 // 只保留了最重要的部分
2 2 String TextResourceDecoder::decode(const char* data, size_t length)
3 3 {
4 4 …
5 5
6 6 // 如果是HTML文件,就从head标签中寻找字符集
7 7 if ((m_contentType == HTML || m_contentType == XML) && !m_checkedForHeadCharset) // HTML and XML
8 8 if (!checkForHeadCharset(data, length, movedDataToBuffer))
9 9 return emptyString();
10 10
11 11 …
12 12
13 13 // m_encoding存储者从HTML文件中找到的编码名称
14 14 if (!m_codec)
15 15 m_codec = newTextCodec(m_encoding); // 创建具体的编码器
16 16
17 17 …
18 18
19 19 // 解码并返回
20 20 String result = m_codec->decode(m_buffer.data() + lengthOfBOM, m_buffer.size() - lengthOfBOM, false, m_contentType == XML && !m_useLenientXMLDecoding, m_sawError);
21 21 m_buffer.clear(); // 清空存储的原始未解码的HTML字节流
22 22 return result;
23 23 }
从源码中可以看到,TextResourceDecoder首先从HTML的
标签中去找编码方式,因为标签可以包含标签,标签可以设置HTML文件的字符集:1
2 3如果能找到对应的字符集,TextResourceDeocder将其存储在成员变量m_encoding当中,并且根据对应的编码创建真正的解码器存储在成员变量m_codec中,最终使用m_codec对字节流进行解码,并且返回解码后的字符串。如果带有字符集的标签没有找到,TextResourceDeocder的m_encoding有默认值windows-1252(等同于ISO-8859-1)。
下面看一下TextResourceDecoder寻找标签中字符集的流程,也就是上面源码中第8行对checkForHeadCharset函数的调用:
1 // 只保留了关健代码
2 bool TextResourceDecoder::checkForHeadCharset(const char* data, size_t len, bool& movedDataToBuffer)
3 {
4 …
5
6 // This is not completely efficient, since the function might go
7 // through the HTML head several times.
8
9 size_t oldSize = m_buffer.size();
10 m_buffer.grow(oldSize + len);
11 memcpy(m_buffer.data() + oldSize, data, len); // 将字节流数据拷贝到自己的缓存m_buffer里面
12
13 movedDataToBuffer = true;
14
15 // Continue with checking for an HTML meta tag if we were already doing so.
16 if (m_charsetParser)
17 return checkForMetaCharset(data, len); // 如果已经存在了meta标签解析器,直接开始解析
18
19 ….
20
21 m_charsetParser = makeUnique
22 return checkForMetaCharset(data, len);
23 }
上面源代码中第11行,类TextResourceDecoder内部存储了需要解码的HTML字节流,这一步骤很重要,后面会讲到。先看第17行、21行、22行,这3行主要是使用标签解析器解析字符集,使用了懒加载的方式。下面看下checkForMetaCharset这个函数的实现:
1 bool TextResourceDecoder::checkForMetaCharset(const char* data, size_t length)
2 {
3 if (!m_charsetParser->checkForMetaCharset(data, length)) // 解析meta标签字符集
4 return false;
5
6 setEncoding(m_charsetParser->encoding(), EncodingFromMetaTag); // 找到后设置字符编码名称
7 m_charsetParser = nullptr;
8 m_checkedForHeadCharset = true;
9 return true;
10 }
上面源码第3行可以看到,整个解析标签的任务在类HTMLMetaCharsetParser::checkForMetaCharset中完成。
1 // 只保留了关健代码
2 bool HTMLMetaCharsetParser::checkForMetaCharset(const char* data, size_t length)
3 {
4 if (m_doneChecking) // 标志位,避免重复解析
5 return true;
6
7
8 // We still don't have an encoding, and are in the head.
9 // The following tags are allowed in
纯文本的解析过程比较简单,就是不停的在DataState状态上跳转,缓存遇到的字符,直到遇见一个结束标签的'<'字符,相关代码如下:
1 BEGIN_STATE(DataState)
2 if (character == '&')
3 ADVANCE_PAST_NON_NEWLINE_TO(CharacterReferenceInDataState);
4 if (character == '<') { // 如果在解析文本的过程中遇到开标签,分两种情况
5 if (haveBufferedCharacterToken()) // 第一种,如果缓存了文本字符就直接按当前DataState返回,并不移动字符,所以下次再进入分词操作时取到的字符仍为'<'
6 RETURN_IN_CURRENT_STATE(true);
7 ADVANCE_PAST_NON_NEWLINE_TO(TagOpenState); // 第二种,如果没有缓存任何文本字符,直接进入TagOpenState状态,进入到起始标签解析过程,并且移动下一个字符
8 }
9 if (character == kEndOfFileMarker)
10 return emitEndOfFile(source);
11 bufferCharacter(character); // 缓存遇到的字符
12 ADVANCE_TO(DataState); // 循环跳转到当前DataState状态,并且移动到下一个字符
13 END_STATE()
由于流程比较简单,下面只给出解析div标签中纯文本的结果:
创建节点与添加节点
1 相关类图
2 创建、添加流程
上面的分词循环中,每分出一个Token,就会根据Token创建对应的Node,然后将Node添加到DOM树上。(HTMLDocumentParser::pumpTokenizerLoop方法在上面分词中有介绍)。
上面方法中首先看HTMLTreeBuilder::constructTree,代码如下:
1 // 只保留关健代码
2 void HTMLTreeBuilder::constructTree(AtomHTMLToken&& token)
3 {
4 …
5
6 if (shouldProcessTokenInForeignContent(token))
7 processTokenInForeignContent(WTFMove(token));
8 else
9 processToken(WTFMove(token)); // HTMLToken在这里被处理
10
11 …
12
13 m_tree.executeQueuedTasks(); // HTMLContructionSiteTask在这里被执行,有时候也直接在创建的过程中直接执行,然后这个方法发现队列为空就会直接返回
14 // The tree builder might have been destroyed as an indirect result of executing the queued tasks.
15 }
16
17
18 void HTMLConstructionSite::executeQueuedTasks()
19 {
20 if (m_taskQueue.isEmpty()) // 队列为空,就直接返回
21 return;
22
23 // Copy the task queue into a local variable in case executeTask
24 // re-enters the parser.
25 TaskQueue queue = WTFMove(m_taskQueue);
26
27 for (auto& task : queue) // 这里的task就是HTMLContructionSiteTask
28 executeTask(task); // 执行task
29
30 // We might be detached now.
31 }
上面代码中HTMLTreeBuilder::processToken就是处理Token生成对应Node的地方,代码如下所示:
1 void HTMLTreeBuilder::processToken(AtomHTMLToken&& token)
2 {
3 switch (token.type()) {
4 case HTMLToken::Uninitialized:
5 ASSERT_NOT_REACHED();
6 break;
7 case HTMLToken::DOCTYPE: // HTML中的DOCType标签
8 m_shouldSkipLeadingNewline = false;
9 processDoctypeToken(WTFMove(token));
10 break;
11 case HTMLToken::StartTag: // 起始HTML标签
12 m_shouldSkipLeadingNewline = false;
13 processStartTag(WTFMove(token));
14 break;
15 case HTMLToken::EndTag: // 结束HTML标签
16 m_shouldSkipLeadingNewline = false;
17 processEndTag(WTFMove(token));
18 break;
19 case HTMLToken::Comment: // HTML中的注释
20 m_shouldSkipLeadingNewline = false;
21 processComment(WTFMove(token));
22 return;
23 case HTMLToken::Character: // HTML中的纯文本
24 processCharacter(WTFMove(token));
25 break;
26 case HTMLToken::EndOfFile: // HTML结束标志
27 m_shouldSkipLeadingNewline = false;
28 processEndOfFile(WTFMove(token));
29 break;
30 }
31 }
可以看到上面代码对7类Token做了处理,由于处理的流程都是类似的,这里只给出3种HTML标签的创建添加过程,分别是DOCTYPE标签,html标签,title标签文本,剩下的过程都使用图表示。
2.1 DOCTYPE标签
1 // 只保留关健代码
2 void HTMLTreeBuilder::processDoctypeToken(AtomHTMLToken&& token)
3 {
4 ASSERT(token.type() == HTMLToken::DOCTYPE);
5 if (m_insertionMode == InsertionMode::Initial) { // m_insertionMode的初始值就是InsertionMode::Initial
6 m_tree.insertDoctype(WTFMove(token)); // 插入DOCTYPE标签
7 m_insertionMode = InsertionMode::BeforeHTML; // 插入DOCTYPE标签之后,m_insertionMode设置为InsertionMode::BeforeHTML,表示下面要开是HTML标签插入
8 return;
9 }
10
11 …
12 }
13
14 // 只保留关健代码
15 void HTMLConstructionSite::insertDoctype(AtomHTMLToken&& token)
16 {
17 …
18
19 // m_attachmentRoot就是Document对象,文档根节点
20 // DocumentType::create方法创建出DOCTYPE节点
21 // attachLater方法内部创建出HTMLContructionSiteTask
22 attachLater(m_attachmentRoot, DocumentType::create(m_document, token.name(), publicId, systemId));
23
24 …
25 }
26
27 // 只保留关健代码
28 void HTMLConstructionSite::attachLater(ContainerNode& parent, Ref
29 {
30 …
31
32 HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert); // 创建HTMLConstructionSiteTask
33 task.parent = &parent; // task持有当前节点的父节点
34 task.child = WTFMove(child); // task持有需要操作的节点
35 task.selfClosing = selfClosing; // 是否自关闭节点
36
37 // Add as a sibling of the parent if we have reached the maximum depth allowed.
38 // m_openElements就是HTMLElementStack,在这里还看不到它的作用,后面会讲。这里可以看到这个stack里面加入的对象个数是有限制的,最大不超过512个。
39 // 所以如果一个HTML标签嵌套过多的子标签,就会触发这里的操作
40 if (m_openElements.stackDepth() > m_maximumDOMTreeDepth && task.parent->parentNode())
41 task.parent = task.parent->parentNode(); // 满足条件,就会将当前节点添加到爷爷节点,而不是父节点
42
43 ASSERT(task.parent);
44 m_taskQueue.append(WTFMove(task)); // 将task添加到Queue当中
45 }
从代码可以看到,这里只是创建了DOCTYPE节点,还没有真正添加。真正执行添加的操作,需要执行HTMLContructionSite::executeQueuedTasks,这个方法在一开始有列出来。下面就来看下每个Task如何被执行。
1 // 方法位于HTMLContructionSite.cpp
2 static inline void executeTask(HTMLConstructionSiteTask& task)
3 {
4 switch (task.operation) { // HTMLConstructionSiteTask存储了自己要做的操作,构建DOM树一般都是Insert操作
5 case HTMLConstructionSiteTask::Insert:
6 executeInsertTask(task); // 这里执行insert操作
7 return;
8 // All the cases below this point are only used by the adoption agency.
9 case HTMLConstructionSiteTask::InsertAlreadyParsedChild:
10 executeInsertAlreadyParsedChildTask(task);
11 return;
12 case HTMLConstructionSiteTask::Reparent:
13 executeReparentTask(task);
14 return;
15 case HTMLConstructionSiteTask::TakeAllChildrenAndReparent:
16 executeTakeAllChildrenAndReparentTask(task);
17 return;
18 }
19 ASSERT_NOT_REACHED();
20 }
21
22 // 只保留关健代码,方法位于HTMLContructionSite.cpp
23 static inline void executeInsertTask(HTMLConstructionSiteTask& task)
24 {
25 ASSERT(task.operation == HTMLConstructionSiteTask::Insert);
26
27 insert(task); // 继续调用插入方法
28
29 …
30 }
31
32 // 只保留关健代码,方法位于HTMLContructionSite.cpp
33 static inline void insert(HTMLConstructionSiteTask& task)
34 {
35 …
36
37 ASSERT(!task.child->parentNode());
38 if (task.nextChild)
39 task.parent->parserInsertBefore(*task.child, *task.nextChild);
40 else
41 task.parent->parserAppendChild(*task.child); // 调用父节点方法继续插入
42 }
43
44 // 只保留关健代码
45 void ContainerNode::parserAppendChild(Node& newChild)
46 {
47 …
48
49 executeNodeInsertionWithScriptAssertion(*this, newChild, ChildChange::Source::Parser, ReplacedAllChildren::No, [&] {
50 if (&document() != &newChild.document())
51 document().adoptNode(newChild);
52
53 appendChildCommon(newChild); // 在Block回调中调用此方法继续插入
54
55 …
56 });
57 }
58
59 // 最终调用的是这个方法进行插入
60 void ContainerNode::appendChildCommon(Node& child)
61 {
62 ScriptDisallowedScope::InMainThread scriptDisallowedScope;
63
64 child.setParentNode(this);
65
66 if (m_lastChild) { // 父节点已经插入子节点,运行在这里
67 child.setPreviousSibling(m_lastChild);
68 m_lastChild->setNextSibling(&child);
69 } else
70 m_firstChild = &child; // 如果父节点是首次插入子节点,运行在这里
71
72 m_lastChild = &child; // 更新m_lastChild
73 }
经过执行上面方法之后,原来只有一个根节点的DOM树变成了下面的样子:
2.2 html标签
1 // processStartTag内部有很多状态处理,这里只保留关健代码
2 void HTMLTreeBuilder::processStartTag(AtomHTMLToken&& token)
3 {
4 ASSERT(token.type() == HTMLToken::StartTag);
5 switch (m_insertionMode) {
6 case InsertionMode::Initial:
7 defaultForInitial();
8 ASSERT(m_insertionMode == InsertionMode::BeforeHTML);
9 FALLTHROUGH;
10 case InsertionMode::BeforeHTML:
11 if (token.name() == htmlTag) { // html标签在这里处理
12 m_tree.insertHTMLHtmlStartTagBeforeHTML(WTFMove(token));
13 m_insertionMode = InsertionMode::BeforeHead; // 插入完html标签,m_insertionMode = InsertionMode::BeforeHead,表明即将处理head标签
14 return;
15 }
16
17 …
18 }
19 }
20
21
22 // 只保留关健代码
23 void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomHTMLToken&& token)
24 {
25 auto element = HTMLHtmlElement::create(m_document); // 创建html节点
26 setAttributes(element, token, m_parserContentPolicy);
27 attachLater(m_attachmentRoot, element.copyRef()); // 同样调用了attachLater方法,与DOCTYPE类似
28 m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element.copyRef(), WTFMove(token))); // 注意这里,这里向HTMLElementStack中压入了正在插入的html起始标签
29
30 executeQueuedTasks(); // 这里在插入操作直接执行了task,外面HTMLTreeBuilder::constructTree方法调用的executeQueuedTasks方法就会直接返回
31
32 …
33 }
执行上面代码之后,DOM树变成了如下图所示:
当要插入title起始标签之后,DOM树以及HTMLElementStack m_openElements如下图所示:
3.3 title标签文本,
title标签的文本作为文本节点插入,生成文本节点的代码如下:
1 // 只保留关健代码
2 void HTMLConstructionSite::insertTextNode(const String& characters, WhitespaceMode whitespaceMode)
3 {
4 HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert);
5 task.parent = ¤tNode(); // 直接取HTMLElementStack m_openElements的栈顶节点,此时节点是title
6
7 …
8
9 unsigned currentPosition = 0;
10 unsigned lengthLimit = shouldUseLengthLimit(*task.parent) ? Text::defaultLengthLimit : std::numeric_limits
11
12 …
13
14
15 // 可以看到如果文本过长,会将分割成多个文本节点
16 while (currentPosition < characters.length()) {
17 AtomString charactersAtom = m_whitespaceCache.lookup(characters, whitespaceMode);
18 auto textNode = Text::createWithLengthLimit(task.parent->document(), charactersAtom.isNull() ? characters : charactersAtom.string(), currentPosition, lengthLimit);
19 // 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.
20 if (!textNode->length()) {
21 String substring = characters.substring(currentPosition);
22 AtomString substringAtom = m_whitespaceCache.lookup(substring, whitespaceMode);
23 textNode = Text::create(task.parent->document(), substringAtom.isNull() ? substring : substringAtom.string()); // 生成文本节点
24 }
25
26 currentPosition += textNode->length(); // 下一个文本节点包含的字符起点
27 ASSERT(currentPosition <= characters.length());
28 task.child = WTFMove(textNode);
29
30 executeTask(task); // 直接执行Task插入
31 }
32 }
从代码可以看到,如果一个节点后面跟的文本字符过多,会被分割成多个文本节点插入。下面的例子将title节点后面的文本字符个数设置成85248,使用Safari查看确实生成了2个文本节点:
当遇到title结束标签,代码处理如下:
1 // 代码内部有很多状态处理,这里只保留关健代码
2 void HTMLTreeBuilder::processEndTag(AtomHTMLToken&& token)
3 {
4 ASSERT(token.type() == HTMLToken::EndTag);
5 switch (m_insertionMode) {
6 …
7
8 case InsertionMode::Text: // 由于遇到title结束标签之前插入了文本,因此此时的插入模式就是InsertionMode::Text
9
10 m_tree.openElements().pop(); // 因为遇到了title结束标签,整个标签已经处理完毕,从HTMLElementStack栈中弹出栈顶元素title
11 m_insertionMode = m_originalInsertionMode; // 恢复之前的插入模式
12 break;
13
14 …
15 }
每当遇到一个标签的结束标签,都会像上面一样将HTMLElementStack m_openElementsStack的栈顶元素弹出。执行上面代码之后,DOM树与HTMLElementStack如下图所示:
当整个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结构所占用的空间大大减少,同时也能达到遍历整棵树的效果。试想一下,如果一个父节点有100个子节点,那么使用逻辑上的DOM树结构,父节点就需要100个指向子节点的指针,如果一个指针占用8字节,那么总共就要占用800字节。但是使用上面内存中DOM的表示方式,父节点只需要2个指针就可以了,总共占用16字节,内存消耗大大减少。虽然两者实现方式不一样,但是两者是等价的,都可以正确的表示HTML文档。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章