WebKit Inside: DOM树的构建
当客户端App主进程创建WKWebView对象时,会创建另外两个子进程:渲染进程与网络进程。主进程WKWebView发起请求时,先将请求转发给渲染进程,渲染进程再转发给网络进程,网络进程请求服务器。如果请求的是一个网页,网络进程会将服务器的响应数据HTML文件字符流吐给渲染进程。渲染进程拿到HTML文件字符流,首先要进行解析,将HTML文件字符流转换成DOM树,然后在DOM树的基础上,进行渲染操作,也就是布局、绘制。最后渲染进程将渲染数据吐给主进程WKWebView,WKWebView根据渲染数据创建对应的View展现视图。整个流程如下图所示:
什么是DOM树
渲染进程获取到HTML文件字符流,会将HTML文件字符流转换成DOM树。下图中左侧是一个HTML文件,右边就是转换而成的DOM树。
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 Supplementable8 , public Logger::Observer 9 , public CanvasObserver { 10 WEBCORE_EXPORT ExceptionOr> createElementForBindings(const AtomString& tagName); // 创建Element的方法 11 WEBCORE_EXPORT Ref createTextNode(const String& data); // 创建文本节点的方法 12 WEBCORE_EXPORT Ref createComment(const String& data); // 创建注释的方法 13 WEBCORE_EXPORT Ref createElement(const QualifiedName&, bool createdByParser); // 创建Element方法 14 .... 15 }
上面源码可以看到Document继承自Node,而且还可以看到前端十分熟悉的createElement、createTextNode等方法,JavaScript对这些方法的调用,最后都转换为对应C++方法的调用。
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 <head> 2 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 3 <title>DOM Treetitle> 4 <script>window.name = 'Lucy';script> 5 head>
如果能找到对应的字符集,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(); // 创建meta标签解析器 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 : 10 // SCRIPT|STYLE|META|LINK|OBJECT|TITLE|BASE 11 // 12 // We stop scanning when a tag that is not permitted in 13 // is seen, rather when is seen, because that more closely 14 // matches behavior in other browsers; more details in 15 // <http://bugs.webkit.org/show_bug.cgi?id=3590>. 16 // 17 // Additionally, we ignore things that looks like tags in,