Skip to content

一次闲谈引发的关于DOM的思考 #10

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Closed
wolfdu opened this issue Dec 17, 2017 · 0 comments
Closed

一次闲谈引发的关于DOM的思考 #10

wolfdu opened this issue Dec 17, 2017 · 0 comments

Comments

@wolfdu
Copy link
Owner

wolfdu commented Dec 17, 2017

http://wolfdu.fun/post?postId=5a362d1e25322c62a62e7b0a

今天在和大佬聊天的时候
大佬:你觉得作为前端人员的最基本需要具备那些能力?
我:我觉得JavaScript,HTML是比较重要的。
大佬:你觉得你这些技术咋样?
我(感觉这是决定命运的回答):感觉还阔以( •ิ _ •ิ )
大佬:你能用原生js给我写个DOM事件的冒泡么?
我:( ͡° ͜ʖ ͡°) 额,我们先吃饭吧 °(°ˊДˋ°) °

当时我是懵逼的,原生js写DOM事件的冒泡?
首先对于DOM事件的冒泡,只知道DOM的事件会从子节点向父节点一级一级的冒泡,,,冒泡。
js绑定事件,不就是$(...).on(...)嘛,此时赶脚前端知识体系的崩塌,原生JavaScript中DOM相关操作方法记得的寥寥无几。

既然痛点已经暴露如此彻底,那就好好的揭揭伤疤,回顾现在项目中的技术框架,spring MVC前端后端未完全分离,前端DOM相关操作重度依赖jQuery。当然这里不是说jQuery不好(jQuery是个非常优秀的js库),由于是野路子前端,基础本来就不够好的情况下$帮我做了太多事情,让我几乎遗忘了DOM本来的特性和JavaScript对它的实现和用法。

接下来就来恶补一下相关知识吧,以下都是在学习过程发现比较优质的资源,和相关知识整理。

DOM is 瓦特?

DOM,文档对象模型(Document Object Model)。
它的作用是将网页转为一个 JavaScript 对象,从而可以用脚本进行各种操作(比如增删内容)。

DOM 有自己的国际标准,如DOM0,DOM1,DOM2,DOM3,DOM4,目前的通用版本是DOM 3
实际并没有DOM0这个标准可以理解为历史的起点,感兴趣可以扒一扒这段历史。

浏览器会根据 DOM 模型,将结构化文档(比如 HTML 和 XML)解析成一系列的节点,再由这些节点组成一个树状结构(DOM Tree),所有的节点和最终的树状结构,都有规范的对外接口。这里的接口就是我们所说的DOM。

DOM节点

DOM的最小组成单位叫做节点(node)。节点类型一共有12种,这里介绍常用的7种类型。
所有节点对象都是浏览器(除IE)内置的Node对象的实例,继承了Node属性和方法。这是所有节点的共同特征。
每个节点都有一些基本属性如:nodeType属性,用于表明节点的类型,nodeName属性返回节点的名称,nodeValue属性表示当前节点本身的文本值。

节点类型通过定义数值常量和字符常量两种方式来表示,IE只支持数值常量,因为低版本IE浏览器没有内置Node对象。

| 类型 | nodeName | nodeType(数值常量/字符常量) |
| -------- | -------- | -------- | -------- |
| Element | 大写的HTML元素名(如:DIV) | 1 / ELEMENT_NODE |
| Attribute | 等同于Attr.name | 2 / ATTRIBUTE_NODE |
| Text | #text | 3 / TEXT_NODE |
| Comment | #comment | 8 / COMMENT_NODE |
| Document | #document | 9 / DOCUMENT_NODE |
| DocumentFragment | #document-fragment | 11/ DOCUMENT_FRAGMENT_NODE |
| DocumentType | 等同于DocumentType.name | 10 / DOCUMENT_TYPE_NODE |

通过一个实例来看看:

<!DOCTYPE html>  
<html lang="en">  
<head>  
    <meta charset="UTF-8">  
    <title>DocumentFragment文档片段节点</title>  
</head>  
<body> 
<!-- tip区域 -->
    <div id="tip">test1</div> 
    <ul class="list-node">
    <li>test2<li>
    </ul>  
    <script>  
        var frag = document.createDocumentFragment();  
        for (var i = 0; i < 10; i++) {  
            var li = document.createElement("li");  
            li.innerHTML = "List item" + i;  
            frag.appendChild(li);  
        }  
        document.getElementById("list-node").appendChild(frag);  
    </script>  
</body>  
</html>  
  1. Element:网页的各种HTML标签(比如<body><a>等)
var divNode = document.getElementById('tip');
console.log(`nodeName: ${divNode.nodeName}, nodeType: ${divNode.nodeType}, nodeValue: ${divNode.nodeValue}`);
// nodeName: DIV, nodeType: 1, nodeValue: null

通常元素节点由子元素、文本节点或者两者的结合组成,元素节点是唯一能够拥有属性的节点类型。
例子中的:html、heade、meta、title、body、div、ul、li、script都属于Element(元素节点);

  1. Attribute:网页元素的属性(比如class="right")
var attrNode = divNode.attributes[0];
console.log(`nodeName: ${attrNode.nodeName}, nodeType: ${attrNode.nodeType}, nodeValue: ${attrNode.nodeValue}`);
// nodeName: id, nodeType: 2, nodeValue: tip

属性节点被看做是包含它的元素节点的一部分,它并不作为单独的一个节点在DOM树中出现。
例子中的:lang、charset、id、class都属于Attr(属性节点);

  1. Text:标签之间或标签包含的文本
var textNode = divNode.childNodes[0];
console.log(`nodeName: ${textNode.nodeName}, nodeType: ${textNode.nodeType}, nodeValue: ${textNode.nodeValue}`);
// nodeName: #text, nodeType: 3, nodeValue: test1

只包含文本内容的节点,在DOM树中元素的文本内容和属性的文本内容都是由文本节点来表示的。
例子中的:DocumentFragment文档片段节点、test1、test2、元素节点之后的空白区域都属于Text;

  1. Comment:注释
var commentNode = document.body.childNodes[1];
console.log(`nodeName: ${commentNode.nodeName}, nodeType: ${commentNode.nodeType}, nodeValue: ${commentNode.nodeValue}`);
// nodeName: #comment, nodeType: 8, nodeValue:  tip区域

注释的内容
例子中的:<!-- tip区域 -->都属于Comment(注释节点);

  1. Document:整个文档树的顶层节点
console.log(`nodeName: ${document.nodeName}, nodeType: ${document.nodeType}, nodeValue: ${document.nodeValue}`);
// nodeName: #document, nodeType: 9, nodeValue: null

文档树的根节点,它是文档中其他所有节点的父节点。没个网页都有自己的document节点
例子中的:、html作为Document(文档节点)的子节点出现;

  1. DocumentFragment:文档的片段
console.log(`nodeName: ${frag.nodeName}, nodeType: ${frag.nodeType}, nodeValue: ${frag.nodeValue}`);
// nodeName: #document-fragment, nodeType: 11, nodeValue: null

DocumentFragment节点代表一个文档的片段,本身就是一个完整的DOM树形结构。它没有父节点,parentNode返回null,但是可以插入任意数量的子节点。它不属于当前文档,操
DocumentFragment节点,要比直接操作DOM树快得多。
例子中的:var frag = document.createDocumentFragment();就属于DocumentFragment(文档片段节点);

  1. DocumentType:doctype标签(比如)
var doctype = document.doctype;
console.log(`nodeName: ${doctype.nodeName}, nodeType: ${doctype.nodeType}, nodeValue: ${doctype.nodeValue}`);
// nodeName: html, nodeType: 10, nodeValue: null

每一个Document都有一个DocumentType属性,它的值或者是null,或者是DocumentType对象。
例子中的:<!DOCTYPE html> 就属于DocumentType(文档类型节点);

更多DOM节点方法阮一峰-DOM 模型概述

真的知道DOMReady是啥吗?

初学JavaScript时,老师傅都会告诉我们要把JavaScript写在HTML的最底部,说是页面元素会有加载顺序,可能会出现页面元素还没有加载完,JavaScript代码就运行了,导致页面功能失效。
在我的理解中,这一直都是一个模糊的概念,那么我们来探一探究竟。
先来看看这个例子:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Dom not ready</title>
    <script>
      document.getElementById("header").style.color = "red";
    </script>
  </head>
  <body>
    <h1 id="header">这里是h1元素包含的内容</h1>
  </body>
</html>

我们想让h1中的文字变成红色,但是结果却没有变化,看控制台会发现打印了一个异常Uncaught TypeError: Cannot read property 'style' of null,这也似乎印证了我们之前了解的模糊概念。那么从我们写好了HTML到渲染到页面发生了什么呢???

HTML to DOM

浏览器中负责解析HTML的东东叫做**渲染引擎(rendering engine)**我们看一下渲染引擎是如何处理的。
渲染引擎首先通过网络获得所请求文档的内容,通常以8K分块的方式完成。
渲染引擎在取得内容之后的基本流程:

rendering engine

  • 解析html以构建dom树 :
    渲染引擎开始解析html,并将标签转化为内容树中的dom节点。
  • 构建render树 :
    接着,它解析外部CSS文件及style标签中的样式信息。这些样式信息以及html中的可见性指令将被用来构建另一棵树——render树。
  • 布局render树 :
    Render树构建好了之后,将会执行布局过程,它将确定每个节点在屏幕上的确切坐标。
  • 绘制render树 :
    再下一步就是绘制,即遍历render树,并使用UI后端层绘制每个节点。

我们来看看webkit渲染页面的大致流程:
webkit-render
这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的html都解析完成之后再去构建和布局render树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容,比如图片、脚本、iframe等。

这里只是抛砖引玉如果想详细了解浏览器的工作原理可以戳这里浏览器内部工作原理

DOMReady的实现

在了解了以上知识后我们再来分析上面的问题,浏览器是从上到下,从左向右渲染元素的,在解析器遇到 <script> 标记时立即解析并执行脚本。此时文档的解析将停止,直到脚本执行完毕。如果脚本是外部的,那么解析过程会停止,直到从网络同步抓取资源完成后再继续,这里也就解答了我们遇到的问题。
就是在HTML标签还没有解析成dom的时候我们就已经执行了JavaScript脚本,导致控制台异常,页面也没有效果。

这里就引入了DOMReady概念:

当页面上所有的html标签都解析为dom节点以后,dom树构建完毕就是DOMReady。

那我们将JavaScript脚本写在html底部是不是就完美解决这个问题了呢?

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Dom not ready</title>
  </head>
  <body>
    <h1 id="header">这里是h1元素包含的内容</h1>
    <script>
       document.getElementById("header").style.color = "red";
    </script>
  </body>
</html>

我们将JavaScript代码移动到html底部的时候,发现文字变成了红色。
这里我们结合日常开发的经验来思考这个问题,我们通常不会将JavaScript代码直接写在HTML代码中(生产中的业务逻辑通常比较复杂),而是通过<script>标签引入外部代码,其中js代码之间还会有调用关系,所以我们需要一个DOMReady的方法,来保证DOM树已经构建完成这样我们就可以在任何位置调用我们的逻辑代码了。

window.onload

我们可以通过window.onload方法来达到目的:

在文档装载完成后会触发 load 事件。此时,在文档中的所有对象都在DOM中,所有图片,脚本,链接以及子框都完成了装载。
MDN-GlobalEventHandlers.onload

可以发现window.onload方法是在所有外部资源都加载完之后调用的方法,但是我们之前了解的DOMReady完成的时候其他外部资源是有可能还处于加载过程中。这个方法虽然满足了DOMReady后调用,但是当外部资源很多的时候呢?那么页面很可能处于假死状态,这样就很影响用户体验了。

DOMContentLoaded

既然我们需要在DOMReady完成的时候就执行JavaScript脚本,那我我们看看有木有刚刚好在DOMReady的时候执行的方法呢?是有的。

当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完成加载。
MDN-DOMContentLoaded

从上述描述我们就可以很清晰了解到,该方法的作用了。
但是这里还是有一个问题,那就是浏览器的兼容问题->“万恶的IE”

在IE8中,可以使用readystatechange事件来检测DOM文档是否加载完毕.在更早的IE版本中,可以通过每隔一段时间执行一次document.documentElement.doScroll("left")来检测这一状态,因为这条代码在DOM加载完毕之前执行时会抛出错误(throw an error)。
MDN-readystatechange事件

那么这里我们来看看我们常用的jQuery中DOMReady是如何实现的呢?

使用过jQuery都应该见到这样的代码$(document).ready(function(){})或者$(function(){})可以看一看jQuery-ready源码

这里我们自己来实现一个兼容各个浏览器的DOMReady方法基本策略和jQuery大同小异:

function myReady(fn){
    // 对于现代浏览器,采用事件绑定的方式
	if(document.addEventListener){
		document.addEventListener('DOMContentLoaded', fn, false);
	}else{
		IEContentLoaded(fn);
	}
	// 模拟ie浏览器中的DOMContentLoaded
	function IEContentLoaded(fn){
		var d = document;
		var done = false;

        // 只执行一次回调函数
		var init = function(){
			if(!done){
				done = true;
				fn();
			}
		};

		(function(){// 使用hack方法兼容更老版本的ie
			try{
			    // DOMReady之前调用doScroll方法会抛出异常
				d.documentElement.doScroll('left');
			}catch(e){
			    // 如果有异常就会延迟,在调用一次当前函数
				setTimeout(arguments.callee, 50);
				return;
			}
		})();

		// 监听document的加载状态
		d.onreadystatechange = function(){
			// if DOMReady 立即执行 fn
			if (d.readyState === 'complete'){
				d.onreadystatechange = null;
				init();
			}
		}
	}	
}

=͟͟͞͞( •̀д•́) 猴!说了那么多,我们来对比一下window.onloadDOMReady之前差异:

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="utf-8">
    <title>domReady与window.onload</title>
    <script src="js/myReady.js"></script>
</head>

<body>
    <div id="showMsg"></div>
    <div>
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/DOM-thinking/render-engine.png" alt="">
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/DOM-thinking/webkit-render.png" alt="">
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/about/about.jpg" alt="">
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/execution-context/ec1.jpg" alt="">
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/execution-context/ecstack.jpg" alt="">
        <img src="http://oyf26dx0a.bkt.clouddn.com/image/blog/execution-context/js.gif" alt="">
    </div>
    <script>
			var d = document;
			var msgBox = d.getElementById("showMsg");
			var imgs = d.getElementsByTagName("img");
			var time1 = null,
					time2 = null;
			myReady(function() {
					msgBox.innerHTML += "dom已加载!<br>";
					time1 = new Date().getTime();
					msgBox.innerHTML += "时间戳:" + time1 + "<br>";
			});
			window.onload = function() {
					msgBox.innerHTML += "onload已加载!<br>";
					time2 = new Date().getTime();
					msgBox.innerHTML += "时间戳:" + time2 + "<br>";
					msgBox.innerHTML += "domReady比onload快:" + (time2 - time1) + "ms<br>";
			};
    </script>
</body>

</html>

这是执行结果:

dom已加载!
时间戳:1513487472466
onload已加载!
时间戳:1513487473535
domReady比onload快:1069ms

效果还是灰常明显的( ´´ิ∀´ิ` )

终于知道为什么书会越读越厚了(;´༎ຶД༎ຶ`) ,看来想要到达读薄的境界还有很长的路要走呀。

感觉是时候来回答大佬的问题了。 ( ͡° ͜ʖ ͡°)

大佬的问题

前面都是前言啊( ⊙ o ⊙ )啊!
这里才是正文~~~

用原生js写一个DOM事件的冒泡,我的理解,这个问题就是考察事件执行过程中在DOM节点间的传播。
那么是不是用一个示例来展示一个事件在执行过程中的传播过程就可以呢?( ͡° ͜ʖ ͡°)✧

(눈_눈)但是传播过程中我只知道有个事件的冒泡。。。

那么顺么是事件的传播呢?

事件的传播

当一个事件发生以后,它会在不同的DOM节点之间传播(propagation)。
这种传播分成三个阶段:

  1. 从window对象传导到目标节点,称为“捕获阶段”(capture phase)。
  2. 在目标节点上触发,称为“目标阶段”(target phase)。
  3. 从目标节点传导回window对象,称为“冒泡阶段”(bubbling phase)。

也就是说一个子节点事件的触发会在多个父子节点间触发同样的事件?
我们来看一个示例:

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="utf-8">
    <title>event propagation</title>
    <script src="js/myReady.js"></script>
</head>

<body>
    <div>
        <p>Click me</p>
    </div>
    <script>
    myReady(function() {
        var phases = {
          1: 'capture',
          2: 'target',
          3: 'bubble'
        };

        var div = document.querySelector('div');
        var p = document.querySelector('p');
        
        // addEventListener函数的第三个参数
        // useCapture:布尔值,表示监听函数是否在捕获阶段(capture)触发
        // 默认为false(监听函数只在冒泡阶段被触发)。
        div.addEventListener('click', callback, true);
        p.addEventListener('click', callback, true);
        div.addEventListener('click', callback, false);
        p.addEventListener('click', callback, false);

        function callback(event) {
          var tag = event.currentTarget.tagName;
          var phase = phases[event.eventPhase];
          console.log(`Tag: '${tag}'. EventPhase: '${phase}'`);
        }
    });
    </script>
</body>

</html>

运行结果:

Tag: 'DIV'. EventPhase: 'capture'
Tag: 'P'. EventPhase: 'target'
Tag: 'P'. EventPhase: 'target'
Tag: 'DIV'. EventPhase: 'bubble'

可以发现click事件被触发了四次:<p>节点的捕获阶段和冒泡阶段各1次,<div>节点的捕获阶段和冒泡阶段各1次。

  • 捕获阶段:事件从<div><p>传播时,触发<div>的click事件;
  • 目标阶段:事件从<div>到达<p>时,触发<p>的click事件;
  • 目标阶段:事件离开<p>时,触发<p>的click事件;
  • 冒泡阶段:事件从<p>传回<div>时,再次触发<div>的click事件。

用户点击网页的时候,浏览器总是假定click事件的目标节点,就是点击位置的嵌套最深的那个节点(嵌套在<div>节点的<p>节点)。所以,<p>节点的捕获阶段和冒泡阶段,都会显示为target阶段。

以下是事件在DOM树中大致传播流程:
事件传播的最上层对象是window,接着依次是document,html(document.documentElement)和body(document.dody)。也就是说,如果<body>元素中有一个<div>元素,点击该元素。事件的传播顺序,在捕获阶段依次为window、document、html、body、div,在冒泡阶段依次为div、body、html、document、window。

那么我们一句话事件冒泡:
当一个DOM元素上的事件被触发的时候(如:示例中p的点击事件),同样的事件将会在那个元素的所有父元素中被触发,从这个事件会从原始元素开始一直传递到DOM树的最上层,这一过程被称为事件冒泡。

具体关于事件冒泡的应用就不在这里扩展了,在日常开发中也经常遇到。
以上就是和大佬聊完之后的一些思考。

总结

每次和比自己厉害的人(哪怕是看起来厉害(╯▔(▔)╯)聊天,都能让自己看到自己的不足。经过这次也再次感觉到学无止境,前路总有自己不知道的东西,这TM什么时候才是头呀(;´༎ຶД༎ຶ´),
同时我还发现我们在习惯了jQuery之后,似乎我们对于很多JavaScript操作DOM的方法是都知之甚少,例如事件的绑定感觉就只限于$(...).on(...)了,导致在使用vue,react这些框架时会有很多疑惑,原生js操作DOM十分不顺手。看来要好好看看You-Dont-Need-jQuery,来减少依赖jQuery带来的恐惧呀,不过这也不是jQuery的锅,学艺不精而已,基础的东西才是根本呀。

以下是本次参考学习资料:

慕课视频--DOM探索之基础详解篇
大牛整理的视频笔记--jawil整理的笔记
阮大大-JavaScript 标准参考教程

若文中有知识整理错误或遗漏的地方请务必指出,非常感谢。如果对你有一丢丢帮助或引起你的思考,可以点赞鼓励一下作者=^_^=

@wolfdu wolfdu changed the title blog 一次闲谈引发的关于DOM的思考 Dec 17, 2017
@wolfdu wolfdu closed this as completed Dec 18, 2017
# for free to join this conversation on GitHub. Already have an account? # to comment
Projects
None yet
Development

No branches or pull requests

1 participant