JavaScript权威指南 第十五章 网络编程 第二部分

本文详细介绍了JavaScript如何操作CSS,包括添加和删除CSS类、设置行内样式、计算样式以及操作样式表。此外,还探讨了CSS动画与事件,以及如何处理文档的几何属性,如滚动、视口大小和元素位置。最后,讨论了Web组件,包括自定义元素、HTML模板和影子DOM,强调了它们在封装和重用组件中的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

15.4 操作CSS

我们已经知道了JavaScript可以控制HTML文档的逻辑结构和内容。通过对CSS编程,JavaScript也可以控制文档的外观和布局。接下来几节讲解几种JavaScript可以用来操作CSS的不同技术。

本书是讲JavaScript而不是讲CSS的,因此本节假设读者已经了解如何使用CSS为HTML内容添加样式。不过,这里还是有必要提几个JavaScript中常用的CSS样式:

  • 把display样式设置为“none”可以隐藏元素。随后再把display设置为其他值可以再显示元素。
  • 把position样式设置为“absolute”“relative”或“fixed”,然后再把top和left样式设置为相应的坐标,可以动态改变元素的位置。这个技术对于使用JavaScript显示模态对话框或红怒条动态内容很重要。
  • 通过transform样式一移动、缩放和旋转元素
  • 通过transition样式可以动态改变其他CSS样式。这些动画由浏览器自动处理,不需要JavaScript,但可以使用JavaScript启动动画。

15.4.1 CSS类

使用JavaScript影响文档内容样式的最简单方式是给HTML标签的class属性添加或删除CSS类名。15.3.3节在“class属性”中介绍过,Element对象的classList属性可以用来方便地实现此类操作。

比如,假设文档的样式包含一个“hidden”类的定义:

.hidden{
   display:none;
}

基于这个定义,可以通过如下代码隐藏(和显示)元素:

//假设"tooltip"元素在HTML中有class="hidden"
//可以像这样它变得可见
document.querySelector("#tooltip").classList.remove("hidden");

//可以像这样让它再隐藏起来:
document.querySelector("#tooltip").classList.add("hidden");

15.4.2 行内样式

继续前面工具提示条(tooltip)的例子,假设文档的结构中只包含一个提示条元素,而我们想在显示它之前先动态把它定位好。一般来说,我们不可能只对提示条的所有可能位置都创建一个类,因此classList属性不能用于定位。

这种情况下,我们需要用程序修改提示条在HTML中的style属性,设置只针对它自己的行内样式。DOM在所有Element对象上都定义了对应的style属性。但与大多数镜像属性不同,这个style属性不是字符串,而是CSSStyleDeclaration对象,是对HTML中作为style属性值的CSS样式文本解码之后得到的一个表示。要在JavaScript中显示和设置提示条的位置,可以使用类似下面的代码:

function displayAt(tooltip,x,y){
    tooltip.style.display="block";
    tooltip.style.position="absolute";
    tooltip.style.left=`${x}px`
    tooltip.style.top=`${y}px`
}

在使用CSSStyleDeclaration的样式属性时,要记住所有值都必须是字符串。在样式表或style属性里,可以这样写:

display:block;font-family:sans-serif;background-color:#ffffff;

但在JavaScript要对元素e设置相同的样式,必须给所有值都加上引号:

e.style.display="block";
e.style.fontFamily="sans-serif";
e.style.backgroundColor="#ffffff";

注意分号不包含在字符串中,它们只是普遍的JavaScript分号。我们在CSS样式表中是哟的分号在通过JavaScript设置字符串值时并不是必需的。

15.4.3 计算样式

元素的计算样式是浏览器根据一个元素的行内样式和所有样式表中适用的样式规则导出(或计算得到)的一组属性值,浏览器实际上适用这组属性值来显示该元素。与行内样式类似,计算样式同样以CSSStyleDeclaration对象表示。但与行内样式不同的是,计算样式是只读的,不能修改计算样式,但表示一个元素计算样式的CSSStyleDeclaration对象可以让你知道浏览器在渲染该元素时,使用了哪些属性和值。

使用Window对象的getComputedStyle()得到可以获取一个元素的计算样式。这个方法的第一个参数是要查询的元素,可选的第二个参数用于指定一个CSS伪元素(如::before或::after):

let title=document.querySelector("#section1title");
let styles=window.getComputedStyle(title);
let beforeStyles=window.getComputedStyle(title,"::before");

getComputedStyle()的返回值是一个CSSStyleDeclaration对象,该对象包含应用给指定元素(或伪元素)的所有样式。这个CSSStyleDeclaration对象与表示行内你样式的CSSStyleDeclaration对象有一些重要的区别:

  • 计算样式的属性是只读的。
  • 计算样式的属性是绝对值,百分比和点等相对单位都被转换成了绝对值。任何指定大小的属性(如外边距大小和字体大小)都将像素度量。相应的值会包含“px”后缀,虽然还需要解析,但不用解析或转换其他单位。值为颜色的属性将以“rgb()”或“rgb()”格式返回。
  • 简写属性不会被计算,只有它们代表的基础属性会被计算。例如,不能查询margin属性,而要查询borderLeftWidth、boderTopWidth。
  • 计算样式的cssText属性是undefined。

getComputedStyle()返回的CSSStyleDeclaration对象中包含的属性,通常要比行内style属性对应的CSSStyleDelaration对象多很多。但计算样式比较难说,查询它们并一定总能得到想要的信息。以font-family属性为例,它接收逗号分隔的字体族的列表,以实现跨平台兼容。在查询计算样式的fontFamily属性时,只是得到应用给元素的最特定于font-family样式的值,这可能会返回类似“arial,helvetica,sans-serif”这样的值,并不说明实际使用了哪种字体。再比如,如果某元素没有被绝对定义,通过计算样式查询器top和left属性经常会返回auto。这是个合法的CSS值,但却不一定是你想找的。

尽管CSS可以精确到指定文档元素的位置和大小,查询元素的计算样式并非确定该元素大小和位置的理想方式。

15.4.4 操作样式表

除了操作class属性和行内样式,JavaScript也可以操作样式表。样式表是通过< style >标签或< link rel=“stylesheet” >标签与HTML文档关联起来的。这两个标签都是普通的HTML标签,因此可以为它们指定一个id属性,然后使用document.querySelector()找到它们。

< style >和< link >标签对应的Element对象都有disabled属性,可以用它禁用整个样式表。比如,可以像下面这样使用这个属性:

//这个函数可以实现"light"和"dark"主题的切换
function toggleTheme(){
    let lightTheme=document.querySelector("#light-theme");
    let darkTheme=document.querySelector("#dark-theme");
    if(darkTheme.disabled){               //当前是浅色主题,切换到深色主题
       lightTheme.disabled=true;
       darkTheme.disabled=false;
    }else{
       lightTheme.disabled=true;
       darkTheme.disabled=false;
    }
}

另一个操作样式表的简单方式是使用前面介绍的DOM API向文档中插入新样式表。例如:

function setTheme(name){
    //创建新<link rel="stylesheet">元素,用以加载指定name的样式表
    let link=document.createElement("link");
    link.id="theme";
    link.rel="stylesheet";
    link.href=`themes/${name}.css`;
    
    //通过id="theme"查找当前的<link>元素
    let currentTheme=document.querySelector("#theme");
    if(currentTheme){
        //如果找到了,则将当前主题替换为新主题
        currentTheme.replaceWith(link);
    }else{
        //否则,直接插入包含主题的<link>元素
        document.head.append(link);
    }
}

虽然算不上巧妙,但也可以向文档中插入一段包含< style >标签的HTML字符串。这是一种好玩的技术,例如:

document.head.insertAdjacentHTML(
   "beforeend",
   "<style>body{transform:rotate(180deg)}</style>"
);

浏览器定义了一套API,以便JavaScript能够再样式表中查询、修改、插入或删除样式规则。这套API太专业了,我们没办法在这里讲解。大家可以在MDN上自行搜索“CSS Object Model”或“CSSStyleSheet”并阅读。

15.4.5 CSS动画与事件

假设你的样式表中定义了下面两个CSS类:

.transparent{opacity:0}
.fadeable{transition:opacity .5s ease-in}

如果把第一个样式应用给某个元素,该元素会变成完全透明,不可见。而第二个样式中的过渡属性(transition)会告诉浏览器当元素的不透明度(opacity)变化时,该变化应该在0.5秒的时间内以动画的形式呈现。其中的ease-in要求不透明度的变化动画应该先慢后快。

现在假设HTML文档中包含一个有“fadeable”类的元素:

<div id="subscribe" class="fadeable notification">...</div>

在JavaScript中,可以为它添加“transparent”类:

document.querySelector("#subscribe").classList.add("transparent");

这个元素是为不透明动画而配置的。给它添加“transparent”类,改变不透明度,会触发一次动画:浏览器会在半秒内让元素“淡出”为完全透明。

相反的过程也能触发动画:如果删除“fadable”元素的“transparent”类,又会改变不透明度,因此元素将淡入,变得再次可见。

这个过程不需要JavaScript做任何事情,是纯粹的CSS动画效果。但JavaScript可以用来触发这种动画。

JavaScript也可以用来监控CSS过渡动画的精度,因为浏览器在过渡动画的开始和结束都会触发事件。首次触发过渡时,浏览器会派发“transitionrun”事件。这时候可能刚刚指定transition-delay样式,而视觉上还没有任何变化。当发生视觉变化时,又会派发“transitionstart”事件,而当动画完成时,则会派发“transitionend”事件。当然,所有这些事件的目标都是发生动画的元素。这些事件传给处理程序的事件对象是一个TransitionEvent对象。该对象的propertyName属性是发生动画的CSS属性,而“transitioned”事件对应的事件对象的elapsedTime属性是从“transitionstart”事件开始经过的秒数。

除了过渡之外,CSS也支持更复杂的动画形式,可以称其为“CSS动画”。这会用到animation-name、animation-duration和特殊的@keyframes规则来定义动画细节。如果你是在一个CSS类上定义了所有这些动画属性,那只要使用JavaScript把这个类添加到要做成动画的元素上就可以触发动画。

与CSS过渡类似,CSS动画也触发事件,可以供JavaScript代码监听。动画开始时触发“animationstart”事件,完成时触发“animationend”事件。如果动画会重复播放,则每次重复(不包括最后一次)都会触发“animationiteration”事件。事件目标是发生动画的元素,而传给处理程序的事件对象是AnimationEvent对象。这个对象的animationName属性是定义动画的animation-name属性,而elapsedTime属性反映了自动画开始以后经过了多少秒。

15.5 文档几何与滚动

本章到现在,我们一直把文档想象成元素和文本节点的抽象树,但当浏览器在窗口中渲染文档时,它会创建文档的一个视觉表示,其中每个元素都有自己的位置和大小。有时候,Web应用可以把文档看成元素的树,不考虑这些元素在屏幕上如何展示。但有时候,又必须知道某个元素精确的几何位置。例如,要使用CSS动态把一个元素(如提示条)定位到某个常规定位的元素旁边,必须先知道这个常规定位元素的位置。

接下来几节将介绍如何在基于树的抽象文档模型和基于几何坐标系的文档视图之间切换。

15.5.1 文档坐标与视口坐标

文档元素的位置以CSS像素度量,其中x坐标向右表示增大,y坐标向下表示增大。但是有两个点可以用作坐标视点:元素的x和y坐标可以相对于文档的左上角,也亏相对于显示文档的视口的左上角。在顶级窗口和标签页中,“视口”就是浏览器窗口中实际显示文档内容的区域。因此不包含浏览器的“外框”,如菜单、工具条和标签。对于显示在< iframe >标签中的文档,由DOM中的内嵌窗格元素定义嵌套文档的视口。无论哪种情况,说到元素位置,必须首先搞清楚是使用文档坐标还是视口坐标(有时候,视口坐标也被称为“窗口坐标”)。

如果文档比视口小,或者如果文档没有被滚动过,则文档左上角就位于视口左上角,文档和视口坐标系是相同的。但通常情况下,要实现这两种坐标系的转换,都必须加上或减去滚动位移。如果元素在文档坐标系中的y坐标是200像素,用户向下滚动了75像素,则元素在视口坐标中的y坐标是125像素。类似地,如果用户在视口中滚动200像素之后元素在视口坐标中的x坐标的400像素,则元素自文档坐标中的x坐标是600像素。

如果以打印的纸质文档做比喻,则任由用户怎么上下左右移动文档,其中每个元素怒在文档坐标中都拥有不变的位置。纸质文档具有的这种性质也适用于简单的网页文档。但一般来说,文档坐标并不真正适合网页。问题在于CSS的overflow属性允许文档中的元素包含比它更能显示的更多的内容。元素可以有自己的滚动条,并作为它们所包含内容的视口。Web允许在滚动文档中存在滚动元素,意味着不可能只使用一个(x,y)点描述元素在文档中的位置。

既然文档坐标实际上没什么用,客户端JavaScript更多地会使用视口坐标。接下来介绍的getBoundingClientRent()和elementFromPoint()方法使用的就是视口坐标,而鼠标和指针事件对象的clientX和clientY属性使用的也是这个坐标。

在使用CSS的position:fixed显式定位元素时,top和left属性相对于视口坐标来解释。如果使用position:relative,则元素会相对于没给它设置position属性时的位置进行定位。如果使用position:relative,则元素会相对于没给它设置position属性时的位置进行定位。如果使用position:absolute,则top和left相对于文档或者最近的包含定位元素。这意味着,如果一个相对定位元素而不是整个文档定位。实践中,经常会把元素设置为相对定位,同时将其top和left设置为0(这样作为容器它的布局没有变化),从而为它包含的绝对定位元素建立一个新的坐标系统。可以把这个新的坐标系统称为“容器坐标”,以便区分文档坐标和视口坐标。

15.5.2 查询元素的几何大小

调用getBoundingClientRect()方法可以确定元素的大小(包括CSS边框和内边距,不包括外边距)和位置(在视口坐标中)。这个方法没有参数,返回一个对象,对象有left、right、top、bottom、width和height属性。其中,left和top属性是元素左上角的x和y坐标,right和bottom属性是右下角的坐标。这两对属性值的差技术width和height属性。

块级元素(如图片、段落和< div >元素)在浏览器的布局中始终是矩形。行内元素(如< span >、< code >和< b >元素)则可能跨行,因而包含多个矩形。比如,< em >和< /em >标签间的文本显示在了两行上,则它的矩形会包含第一行末尾和第二行开头。如果在这个元素上调用getBoundingClientRect(),则边界会包含第一行末尾和第二行开头。如果在这个元素上调用getBoundingClientRect(),则边界矩形将包含两行的整个宽度。如果想查询行内元素中的个别矩形,可以调用getClientRects()方法,得到一个只读的类数组对象,其元素类似getBoundingClientRect()返回的矩形对象。

15.5.3 确定位于某一点的元素

使用getBoundingClientRect()方法可以确定视口中某个元素的当前位置。有时候,我们想从另一个方向出发,确定在视口中某个给定位置上的是哪个元素。为此可以使用Document对象的elementFromPoint()方法。调用这个方法并传入一个点的x和y坐标(视口坐标,而非文档坐标。比如,可以使用鼠标事件中的clientX和clientY坐标)。elementFromPoint()返回一个位于指定位置的Element对象。选择元素的碰撞检测算法并没有明确规定,但这个方法的意图是返回相应位置上最内部(嵌套最深)、最外层(最大的CSSz-index属性)的元素。

15.5.4 滚动

Window对象的scrollTo()方法接收一个点的x和y坐标(文档坐标),并据以设置滚动条的位移。换句话说,这个方法会滚动窗口,从而让指定的点位于视口的左上角。如果这个点不太接近文档底部或右边,浏览器会尽可能让视口左上角接近这个点,但不可能真的移动到该点。以上代码会滚动浏览器让文档最底部的页面显示出来:

//取得文档和视口的高度
let documentHeight=document.documentElement.offsetHeight;
let viewportHeight=window.innerHeight;
//滚动到最后一“页”在视口中可见
window.scrollTo(0,documentHeight-viewportHeight);

Window对象的scrollBy()方法与scrollTo()类似,但它的参数是个相对值,会加在当前滚动位置之上:

//每500毫秒向下滚动50像素。注意,没有办法停止!
setInterval(()=>scrollBy(0,50)},500);

如果想让scrollTo()和scrollBy()平滑滚动,需要传入一个对象,而不是两个数值,比如:

window.scrollTo({
  left:0,
  top:documentHeight-viewportHeight;
  behavior:"smooth"
});

有时候,我们不是想让文档滚动既定的像素距离,而是想滚动到某个元素在视口中可见。此时可以在相应HTML元素上调用scrollTntoView()方法。这个方法包装在上面调用它的那个元素在视口中可见。默认情况下。滚动后的结果尽量让元素的上边对齐或接近视口上沿。如果这个方法传入唯一的参数false,则滚动后的结果会尽量让元素的底边对齐视口下沿。为了让元素可见,浏览器也会水平滚动视口。

同样可以给scrollTntoView()传入一个对象,设置behavior:"smooth"属性,以实现平滑滚动。而设置block属性可以指定元素在垂直方向上如何定位,设置inline属性可以指定元素在水平方向上如何定位(假设需要水平滚动)。这两个属性的有效值均包括start、end、nearest和center。

15.5.5 视口大小、内容大小和滚动位置

前面说过,浏览器窗口和一些HTML元素和显示滚动的内容。在这种情况下,我们有时候需要知道视口大小、内容大小和视口中内容的滚动位移。

对浏览器窗口而言,视口大小可以通过window.innerWidth和window.innerHeight属性获得(针对移动设备优化的网页通常会在< head >使用< meta name=“viewport”>标签为页面设置想要的视口宽度)。文档的整体大小与< html >元素,即document.documentElement的大小相同。要获得文档的宽度和高度,可以使用document.documentElement的getBoudingClientRect()方法,页可以使用document.documentElement的offsetWidth和offsetHeight属性。文档在视口中滚动位移可以通过window.scrollX和window.scrollY获得。这两个属性都是只读的,因此不能通过设置它们的值来滚动文档。滚动文档应该使用window.scrollTo()。

对元素来说,问题稍微复杂一点。每个Element对象都定义了下列三组属性:
在这里插入图片描述
元素的offsetWidth和offsetHeight属性返回它们在屏幕上的CSS像素大小。这个大小包含元素边框和内边距,但不包含外边距。元素的offsetLeft和offsetTop属性返回元素的x和y坐标。对很多元素来说,这两个值都是文档坐标。但对于定位元素的后代或者另一些元素(如表格单元)来说,这两个值是相对于祖先而非文档的坐标。而offsetParent属性保存着前述坐标值相对于哪个元素。这一组属性都是只读的。

元素的clientWidth和clientHeight属性与offsetWidth和offsetHeight类似,只是它们不包含元素边框,只包含内容区及内边距。clientLeft和clientTop属性没有多大用处,它们是元素内边距外沿到边框外沿的水平和垂直距离。一般来说,这两个值就等于左边框和上边框的宽度。这一组属性都是只读的。对于行内元素(如< i >、< code >和< span >),这些属性的值全为0。

元素的scrollWidth和scrollHeight属性是元素内容大小加上元素内边距,再加上溢出内容的大小。在内容适合内容区而没有溢出时,这两个属性等同于clientWidth和clientHeight。scrollLeft和scrollTop是元素内容在元素视口中的滚动位移。与本节介绍的其他属性不同,scrollLeft和scrollTop是可写属性,因此可以通过设置它们的值滚动元素中的内容(在多数浏览器中,Element对象也跟Window对象一样有scrollTo()和scrollBy()方法,但并非所有浏览器都支持)。

15.6 Web组件

15.6.1 使用Web组件

Web组件是在JavaScript中定义的,因此要在HTML中使用Web组件,需要包含定义该组件的JavaScript文件。Web组件是相对比较新的技术,经常以JavaScript模块形式写成,因此需要在HTML中像下面这样包含Web组件:

<script type="module" src=components/search-box.js">

Web组件要定义自己的HTML标签名,但有一个重要的限制就是标签必须包含一个连字符(这意味着未来的HTML版本可以增加没有连字符的新标签,而这些标签不会跟任何人的Web组件冲突)。要使用Web组件,只要像下面这样在HTML文件中使用其标签即可:

<search-box placeholder="Search..."></search-box>

Web组件可以像常规HTML标签一样具有属性,你使用组件的文档应该告诉你它支持哪些属性。Web组件不能使用自关闭定义,比如不能写成< seach-box />。你的HTML文件必须既包含开标签页包含闭标签。

与常规HTML元素类似,有的Web组件需要子组件,而有的Web组件不需要(也不显示)子组件。还有的Web组件可选地接收有标识地子组件,这些子组件会出现在命名的“插槽”中。在图15-3展示并在示例15-3中实现的< search-box >组件,就使用“插槽”,就使用“插槽”传递要显示的两个图标。如果想在< search-box >中使用不同的图标,可以这样使用HTML:

<search-box>
   <img src="images/search-icon.png" slot="left"/>
   <img src="images/cancel-icon.png" slot="right"/>
</search-box>

这个slot属性是对HTML的一个扩展,用于指定把哪个子元素放到那里。而插槽的名字“left”和“right”是由这个Web组件定义的。如果你使用的组件支持插槽,其文档中应该说明。

前面提到过Web组件经常以JavaScript模块来实现,因此可以通过< script type=“module”>标签引入HTML文件中。可能你还记得,本章开头介绍过模块会在运行包含< search-box>定义的代码之前,就要解析和渲染< search-box>标签。这在使用Web组件时是正常的。浏览器中的HTML解析器很灵活,对自己不理解的输入非常宽容。当在Web组件还没有定义就遇到其标签时,浏览器会向DOM树中添加一个通用的HTMLElement,即便它们不知道要对它做什么。之后,当自定义元素有定义之后,这个通用元素会被“升级”,从而具备预期的外观和行为。

如果Web组件包含子元素,那么在组件有定义之前它们可能会被不适当地显示出来。可以使用下面地CSS将Web组件隐藏到它们有定义为止:

/*
 * 让<search-box>组件在有定义前不可见
 * 同时尝试复现其最总布局和大小,以便近旁
 * 内容在它有定义时不会移动
 */
 search-box:bot(:defined){
     opacity:0;
     display:inline-block;
     width:300px;
     height:50px;
 }

与常规HTML元素一样,Web组件可以在JavaScript中使用。如果在网页中包含< search-box>标签,就可以通过querySelector()和适当地CSS选择符获得对它地引用,就像对任何其他HTML标签一样。一般来说,只有在定义这个组件地模块运行之后这样做才有意义。因此在查询Web组件时要注意不要过早地做这件事。Web组件实现通常都会(但并非必须)为它们支持的每个HTML属性都定义一个JavaScript属性。另外,与HTML元素相似,它们也可能定义有用的方法。同样,你所使用Web组件的文档应该指出可以在JavaScript中使用什么属性和方法。

15.6.2 HTML模板

HTML的< template >标签跟Web组件的关系虽然没有那么亲密,但通过它确实可以对网页中频繁使用的组件进行优化。< template>标签及其子元素永远不会被浏览器渲染,只能在使用JavaScript的网页中使用。这个标签背后的思想是,当网页包含多个重复的基本HTML结构时(比如表格行货Web组件的内部实现),就可以使用< template >定义一次该结构,然后通过JavaScript按照需要任意重复该结构。

在JavaScript中,< template >标签对应的是一个HTMLTemplateElement对象。这个对象只定义了一个content属性,而这个属性的值是包含< template>所有子节点的DocumentFragment。可以克隆这个DocumentFragmet,然后把克隆的副本插入文档中需要的地方。这个片段自身不会被插入,只有其子节点户。假设你的问中包含一个< table >和< template id=“row”>标签,而后者作为模板定义了表格中行的结构,那可以像下面这样使用模板:

let tableBody=document.querySelector("tobody");
let template=document.querySelector("#row");
let clone=template.content.cloneNode(true);  //深度克隆
//先使用DOM把内容插入克隆的<td>元素
//然后把克隆且已初始化的表格插入表格体
tableBody.append(clone);

这个模板元素并非只有出现在HTML文档中才可以使用。也可以在JavaScript代码中创建一个模板,通过innerHTML创建其子节点,然后再按照需要克隆任意多个副本。这样还不必每次都解析innerHTML。而且这也是Web组件中使用HTML模板的方式,示例15-3颜色了这个技术。

15.6.3 自定义元素

实现Web的第二个浏览器特性是“自定义元素”,即可以把一个HTML标签与一个JavaScript类关联起来,然后文档中出现的这个标签就会在DOM树中转换为相应类的实例。创建自定义元素需要使用customElements.define()方法,这个方法以一个Web组件的标签名作为第一个参数(记住这个标签名必须包含一个连字符),以一个HTMLElement的子类作为其第二个参数。文档中具有该标签名的任何元素都会被“升级”为这个类的一个新实例。如果浏览器将来再解析HTML,都会自动为遇到的这个标签创建一个这个类的实例。

传给customElements.define()的类应该扩展HTMLElement,且不说一个更具体地类型(如HTMLButtonElement)。第9章曾介绍过,当一个JavaScript类扩展另一个类时,构造函数必须先调用super()然后才能使用this关键字。因此如果自定义元素类有构造器,应该先定义super()(没有参数),然后再干别的。

浏览器会自动调用自定义元素类的特定“生命周期方法”。当自定义元素被插入文档时,会调用connectedCallback()方法。很多自定义元素通过这个方法来执行初始化。还有一个disconnectedCallback()方法,会在(如果)自定义元素从文档中被移除时调用,但用得不多。

如果自定义元素类定义了静态的observedAttributes属性,其值为一个属性名的数组,且如果任何这些命名属性在这个自定义元素的一个实例上被设置(或修改),浏览器就会调用attributeChangedCallback()方法,传入属性名、旧值和新值。这个回调可以更具属性值的变化采取必要的步骤以更新组件。

自定义元素类也可以按照需要定义其他属性和方法。通常,它们都会定义设置方法和获取方法,让元素的属性可以暴露为JavaScript属性。

下面举一个自定义元素的例子。假设我们想在一个常规文本段落中显示圆圈。我希望可以像下面这样写HTML,以提出图15-4所示的数学故事问题:

<p>
   The documeny has one marble:<inline-circle></inline-circle>
   The HTML parser instantiates two more marbles:
   <inline-circle diameter="1.2em" color="blue"></inline-circle>
   <inline-circle diamater=".6em" color="gold"></inline-circle>
   How many marbles does the document contain now?
</p>

在这里插入图片描述
实例15-2中的代码实现了这个< inline-circle >自定义元素:
示例15-2:< inline-circle>自定义元素

cutomElements.define("inline-circle",class InlineCircle extends HTMLElement{
    //浏览器会在一个<inline-circle>元素被插入文档时
    //调用这个方法。还有一个disconnectedCallback()
    //方法,但这个例子中没有用到
    connectedCallback(){
        //设置圆圈所需的样式
        this.style.display="inline-block";
        this.style.borderRadius="50%";
        this.style.border="solid black 1px";
        this.style.transform="translateY(10%)";
        //如果没有定义大小,则基于当前
        //字体来设置一个默认大小
        if(!this.style.width){
            this.style.width="0.8em";
            this.style.height="0.8em";
        }
    }
    
    //这个静态的observedAttributes属性用于指定我们
    //想在哪个属性变化时收到通知(这里使用了
    //获取方法,是因为只能对方法使用了static关键字)
    static get observedAttributes(){
        return ["diameter","color"];
    }
    
    //这个回调会在上面列出的属性变化时调用,
    //从自定义元素被解析开始,包括之前的变化
    attributeChangedCallback(name,oldValue,newValue){
        switch(name){
            case "diameter":
                //如果diameter属性改变了,更新大小样式
                this.style.width=newValue;
                this.style.height=newValue;
                break;
            case "color":
                //如果color属性改变了,更新颜色样式
                this.style.backgroundColor=newValue;
                break;
        }
    }
    
    //定义与元素的标签属性对应的JavaScript属性
    //这些获取和设置方法只是获取和设置底层属性
    //如果设置了JavaScript的属性,则修改底层的
    //属性会触发调用attributeChangedCallback()
    //进而更新元素的样式
    get diameter(){
        return this.getAttribute("diameter");
    }
    set diameter(diameter){
        this.setAttribute("diameter",diameter);
    }
    get color(){
        return this.getAttribute("color");
    }
    set color(color){
        this.setAttribute("color",color);
    }
});

15.6.4 影子DOM

示例15-2定义的自定义元素并没有恰当地封装。比如,设置其diameter或color属性会导致其style属性被修改,而对于一个真正的HTML元素,这并不是我们希望看到的行为。要把一个自定义元素转换为真正的Web组件,还需要使用一个强大的封装机制:影子DOM(shadow DOM)。

影子DOM允许把一个“影子根节点”附加给一个自定义元素(页可以附加给< div>、< span >、< body >、< article >、< main >、< nav >、< header >、< footer >、< section >、< p>、< blockquote>、< aside>或< h1>到< h6>元素),而后者被称为“ 影子宿主”。影子宿主与所有HTML元素一样,随时可以作为包含后代元素和文本节点的正常DOM树的根、影子根节点则是另一个更私密的后代元素树的根,这些元素从影子根节点上生长出来,可以把它们当成一个迷你文档。

“影子DOM”中的“影子”指的是作为影子节点后代的元素“藏在影子里”。也就是说,这个子树并不属于常规DOM树,不会出现在它们宿主元素的children数组中,而且对于querySelector()等常规的DOM遍历方法也不可见。相对而言,影子宿主的常规、普遍DOM子树有时候也被称为“阳光DOM”。

要理解影子DOM的用途,可以想象一下HTML的< audio >和< video>元素。这两个元素都会显示一个并不简单的用户见面,用于控制媒体播放,但播放和暂停按钮以及其他UI元素都不属于DOM树,不能通过JavaScript来操控。既然浏览器是涉及用来显示HTML的,那浏览器厂商只有使用HTML来显示这样的内部UI才是最自然的。事实上,多数浏览器很早就实现了这样的机制,只不过影子DOM让它称为Web平台的标准而已。

  • 前面以及提到过,影子DOM中的元素树对querySelectorAll()等常规DO方法是不可见的。在创建影子根节点并将其附加于影子宿主时,可以指定其模式是“开放”还是“关闭”。关闭的影子根节点将完全封闭,不可访问。不过,影子根节点更多地是以“开放”模式创建地,这意味着影子宿主会有一个shadowRoot属性,如果需要,JavaScript可以通过这个属性来访问影子根节点的元素。
  • 在影子根节点之下定义的样式对该子树是私有的,永远不会影响外部的阳关DOM元素(影子根节点可以为其宿主元素定义默认样式,但这些样式可以被阳光DOM样式覆盖)。类似地,应用给影子宿主的阳光DOM样式也不会影响影子根节点。影子DOM中的元素会从阳光DOM继承字体大小和背景颜色等,而影子DOM中的样式可以选择阳光DOM中定义的CSS变量。不过在大多数情况下,阳光DOM的样式与影子DOM的样式是完全独立的。因此Web组件的作者和Web组件的用户不用担心它们的样式会冲突或抵触。可以像这样限定CSS的范围或许是影子DOM最重要的特性。
  • 影子DOM中发生的某些事件(如“load")会被封闭在影子DOM中。另外一些事件,像focus、mouse和键盘事件则会向上冒泡、穿透影子DOM。当一个发源于影子DOM内的事件跨过了边界开始向阳光DOM传播时,其target属性会变成影子宿主元素,就好像事件直接起源于该元素一样。
影子插槽和阳光DOM子元素

作为影子宿主的HTML元素有两个后代子树。一个是children[]数组,即宿主元素常规的阳光DOM后代;另一个则是影子节点及其后代。有人可能会问:位于同一宿主主元素中的两个完全不同的内容树是怎么显示的呢?下面是它们的工作原理:

  • 影子根节点的后代始终显示在影子宿主内。
  • 如果这些后代中包含一个< slot>元素,那么宿主元素的常规阳光DOM子元素会像它们本来就是该< slot>的子元素一样显示,替代该插槽中的任何影子DOM元素。如果影子DOM不包含< slot >,那么宿主的阳光DOM内容永远不会显示。如果影子DOM有一个< slot>,但影子宿主没有阳光DOM子元素,那么该插槽的影子DOM内容作为默认内容显示。
  • 当阳光DOM内容显示在影子DOM插槽中,我们说哪些元素“已分配”,此时关键要理解:那些元素实际上并未变成影子DOM的一部分。使用querySelector()依旧可以查询它们,它们仍然会作为宿主元素的子元素或后代出现在阳光DOM中。
  • 如果影子DOM定义了多个< slot >,且通过name属性为它们命名,那么影子宿主的阳光DOM后代可以通过slot="slotname"属性指定自己想出现在哪个插槽中。15.6.1节展示过一个这种用法的例子,该例子演示了如何定义由< search-box >组件显示的图标。
影子 DOM API

就其强大的能力而言,影子DOM并未提供太多JavaScript API。要把一个阳光DOM元素转换为影子宿主,只要调用其attachShadow()方法,传入{mode:“open”}这个唯一的参数即可。这个方法返回一个影子根节点对象,同时也将该对象设置为这个宿主的shadowRoot属性的值。这个影子根节点对象是一个DocumentFragment,可以使用DOM方法为它添加内容,也可以直接将其innerHTML属性设置为一个HTML字符串。

如果你的Web组件想知道影子DOM(slot)中的阳光DOM内容什么时候变化,那它可以直接在该< slot>元素上注册一个"slotchanged"事件。

15.6.5 示例:< search-box > Web 组件

图15-3直观地展示了一个< search-box>Web组件。示例15-3演示定义了Web组件的三种技术。这个示例用自定义元素实现了< search-box >组件,并使用< template >标签来提高效率,使用影子根节点做到了封装。

这个示例展示了如何直接使用低级Web组件API。实践中,很多Web组件都是使用某个高级的库(比如lit-element)创建的。之所以使用库,一个原因是可重用且可定制的组件其实很难写好,很多细节都必须处理到位。示例15-3演示了如何实现Web组件并添加一些基本的键盘焦点处理逻辑,但没有考虑无障碍,也没有使用恰当的ARIA属性,好让这个组件便于在屏幕阅读器和其他辅助技术中使用。

示例 15-3:实现Web组件

/**
 * 这个类定义了一个自定义的HTML <search-box>元素,用于显示一个
 * <input>文本输入字段加两个图标或标签符号。默认情况下,它在文本
 * 字段的左侧显示一个放大镜表情符号(表示搜索),在文本字段的右侧
 * 显示一个X表情符号(表示取消)。它会隐藏输入字段的边框,显示自己
 * 环绕一周的边框,让两个表情符号看起来位于输入字段的内部。类似地,
 * 当内部输入字段获得焦点时,焦点环也会显示在<search-box>的周围
 *  
 * 要覆盖默认的图标,可以让<search-box>包含<span>或<img>子元素,
 * 并分别指定slot="left"和slot="right"属性
 *  
 * <search-box>支持正常的HTML disabled和hidden属性,以及size
 * 和placeholder属性,它们对这个元素具有对<input>元素一样的作用
 *  
 * 内部<input>元素的输入事件会向上冒泡,事件目标会被设置为外部的
 * <search-box>元素
 *  
 * 当用户单击左侧绘文字(放大镜)时,这个元素会发送“search”事件,
 * 事件对象的detail属性会设置为当前输入的字符串。另外,当内部文本
 * 字段生成“change”事件(文本发送变化且用户按下回车或Tab键)时,
 * 也会派发这个“search”事件
 *  
 * 当用户当即右侧表情符号(X)时,这个元素会发送“clear”事件。如果这
 * 个事件的处理程序没有调用preventDefault(),则这个元素会在事件派
 * 发完成时清除用户的输入
 *  
 * 注意,HTML和JavaScript都没有onsearch和onclear属性。“search”
 * 和“clear”事件的处理程序员只能通过addEventListener()来注册
 */
 class SearchBox extends HTMLElement{
    constructor(){
        super();   //调用超类的构造器;必须先调用
        
        //创建一个影子DOM树并将其附加到这个元素
        //设置为this.shadowRoot的值
        this.attachShadow({mode:"open"});
        
        //克隆模板,模板定义了这个自定义组件的后代和样式
        //然后把内容追加到影子根节点
        this.shadowRoot.append(SearchBox.template.content.cloneNode(true));
        
        //取得对影子DOM中重要元素的引用
        this.input=this.shadowRoot.querySelector("#input");
        let leftSlot=this.showdowRoot.querySelector('slot[name="left"]');
        let rightSlot=this.shadowRoot.querySelector('slot[name="right"]');
        
        //当内部输入字段获得或失去焦点时,设置或移除
        //focused属性,以便样式表在整个组件上显示或
        //隐藏人造的焦点环。注意,“blur”和“focus”
        //现在变量x的值就是0
        //事件会冒泡,就像来源自<search-box>一样
        this.input.onfocus=()=>{
            this.setAttribute("focused","");
        };
        this.input.onblur=()=>{
            this.removeAttribute("focused");
        };
        
        //如果用户点击了放大镜,则触发“search”事件。
        //同样,在输入字发生"change"事件也会触发这
        //个事件(“change”事件不会冒泡到影子DOM外面
        leftSlot.onclick=this.input.onchange=(event)=>{
            event.stopPropagation();     //阻止事件冒泡
            if(this.disabled) return;    //如果被禁用则什么也不做
            this.dispatchEvent(new CustomEvent("search",{
                detail:this.input.value
            }))
        };
        
        //如果用户单击了X,则触发“clear”事件。如果事件的
        //处理程序没有调用preventDefault(),则清除输入
        rightSlot.onclick=(event)=>{
            event.stopProgpagation;      //不让单击事件向上冒泡
            if(this.disabled) return;    //如果被禁用则什么也不做
            let e=new CustomEvent("clear",{cancelable:true});
            this.dispatchEvent(e);
            if(!e.defaultPrevented){     //如果事件没有被取消
                this.input.value="";     //则清除输入字段
            }
        };
    }
    
    //在有些属性被设置或改变时,我们需要设置内部<input>
    //元素对应的值。这个生命周期方法与下面代码的静态属性
    //observedAttributes相互配合,实现回调
    attributeChangedCallback(name,oldValue,newValue){
        if(name==="disabled"){
            this.input.disabled=newValue!==null;
        }else if(name==="placeholder"){
            this.input.placeholder=newValue;
        }else if(name==="size"){
            this.input.size=newValue;
        }else if(name==="value"){
            this.input.value=newValue;
        }
    }
    
    //最后,为我们支持的HTML属性定义相应的获取方法和设置方法
    //获取方法简单地返回属性的值(或存在与否),而设置方法也只
    //是设置属性的值(或存在与否)。当某个设置方法修改了一个属性
    //时,浏览器会自动调用上面的attributeChangedCallback回调
    get placeholder(){
        return this.getAttribute("placeholder");
    }
    
    get size(){
        return this.getAttribute("size")
    }
    
    get value(){
        return this.getAttribute("value");
    }
    
    get disabled(){
        return this.getAttribute("disabled");
    }
    
    get hidden(){
        return this.hasAttribute("hidden");
    }
    
    set placeholder(value){
        this.setAttribute("placeholder",value);
    }
    
    set size(value){
        this.setAttribute("size",value);
    }
    
    set value(text){
        this.setAttribute("value",text);
    }
    
    set disabled(value){
        if(value) this.setAttribute("disabled","");
        else this.removeAttribute("disabled");
    }
    set hidden(value){
        if(value) this.setAttribute("hidden","");
        else this.removeAttribute("hidden");
    }
}
undefined
class SearchBox extends HTMLElement{
    constructor(){
        super();   //调用超类的构造器;必须先调用
        
        //创建一个影子DOM树并将其附加到这个元素
        //设置为this.shadowRoot的值
        this.attachShadow({mode:"open"});
        
        //克隆模板,模板定义了这个自定义组件的后代和样式
        //然后把内容追加到影子根节点
        this.shadowRoot.append(SearchBox.template.content.cloneNode(true));
        
        //取得对影子DOM中重要元素的引用
        this.input=this.shadowRoot.querySelector("#input");
        let leftSlot=this.showdowRoot.querySelector('slot[name="left"]');
        let rightSlot=this.shadowRoot.querySelector('slot[name="right"]');
        
        //当内部输入字段获得或失去焦点时,设置或移除
        //focused属性,以便样式表在整个组件上显示或
        //隐藏人造的焦点环。注意,“blur”和“focus”
        //现在变量x的值就是0
        //事件会冒泡,就像来源自<search-box>一样
        this.input.onfocus=()=>{
            this.setAttribute("focused","");
        };
        this.input.onblur=()=>{
            this.removeAttribute("focused");
        };
        
        //如果用户点击了放大镜,则触发“search”事件。
        //同样,在输入字发生"change"事件也会触发这
        //个事件(“change”事件不会冒泡到影子DOM外面
        leftSlot.onclick=this.input.onchange=(event)=>{
            event.stopPropagation();     //阻止事件冒泡
            if(this.disabled) return;    //如果被禁用则什么也不做
            this.dispatchEvent(new CustomEvent("search",{
                detail:this.input.value
            }))
        };
        
        //如果用户单击了X,则触发“clear”事件。如果事件的
        //处理程序没有调用preventDefault(),则清除输入
        rightSlot.onclick=(event)=>{
            event.stopProgpagation;      //不让单击事件向上冒泡
            if(this.disabled) return;    //如果被禁用则什么也不做
            let e=new CustomEvent("clear",{cancelable:true});
            this.dispatchEvent(e);
            if(!e.defaultPrevented){     //如果事件没有被取消
                this.input.value="";     //则清除输入字段
            }
        };
    }
    
    //在有些属性被设置或改变时,我们需要设置内部<input>
    //元素对应的值。这个生命周期方法与下面代码的静态属性
    //observedAttributes相互配合,实现回调
    attributeChangedCallback(name,oldValue,newValue){
        if(name==="disabled"){
            this.input.disabled=newValue!==null;
        }else if(name==="placeholder"){
            this.input.placeholder=newValue;
        }else if(name==="size"){
            this.input.size=newValue;
        }else if(name==="value"){
            this.input.value=newValue;
        }
    }
    
    //最后,为我们支持的HTML属性定义相应的获取方法和设置方法
    //获取方法简单地返回属性的值(或存在与否),而设置方法也只
    //是设置属性的值(或存在与否)。当某个设置方法修改了一个属性
    //时,浏览器会自动调用上面的attributeChangedCallback回调
    get placeholder(){
        return this.getAttribute("placeholder");
    }
    
    get size(){
        return this.getAttribute("size")
    }
    
    get value(){
        return this.getAttribute("value");
    }
    
    get disabled(){
        return this.getAttribute("disabled");
    }
    
    get hidden(){
        return this.hasAttribute("hidden");
    }
    
    set placeholder(value){
        this.setAttribute("placeholder",value);
    }
    
    set size(value){
        this.setAttribute("size",value);
    }
    
    set value(text){
        this.setAttribute("value",text);
    }
    
    set disabled(value){
        if(value) this.setAttribute("disabled","");
        else this.removeAttribute("disabled");
    }
    set hidden(value){
        if(value) this.setAttribute("hidden","");
        else this.removeAttribute("hidden");
    }
}

//这个静态属性对attributeChangesCallback方法是必需的
//只有在这个数组中列出的属性名才会触发对该方法的调用
SearchBox.observedAttributes=["disabled","placeholder","size","value"];

//创建一个<template>元素,用于保存样式表和元素树,
/可以在每个SearchBox元素的实例中使用它们
SearchBox.template=document.createElement("template");

//通过解析HTML字符串初始化模板。不过要注意,当实例化一个
//SearchBox时,我们可以克隆这个模板中的节点,不需要再次
//解析HTML
SearchBox.template.innerHTML=`
    <style>
    /*
     * 这里的:host选择符引用的是阳光DOM中的<search-box>元素
     * 这些样式是默认的,<search-box>的使用者可以通过阳光DOM中
     * 的样式来覆盖这些样式
     */
     :host{
       display: inline-block;  /* 默认显示为行内块 */
       border: solid black 1px; /* 在<inout>和<slots>周围添加圆角边框 */
       border-radius:5px;
       padding:4px 6px;         /* 边框内部留出适当间隙 */
     }
     :host([hidden]){           /* 注意小括号:当宿主隐藏时 */
       display:none;            /* 通过属性设置为不显示 */
     }
     :host([disabled]){         /* 当宿主有disabled属性时 */
     opacity:0.5;               /* 将其变灰 */
     }
     :host([focused]){          /* 当宿主有focused属性时 */
     box-shadow:0 0 2px 2px #6AE; /* 显示人造的焦点环 */
     
     /* 剩下的样式表只应用给影子DOM中的元素 */
     input{
        border-width:0;      /* 隐藏内部输入字段的边框 */
        outline:none;        /* 也隐藏焦点环
        font:inherit;        /* <input>元素默认不会继承字段 */
        background:inherit;  /* 背景颜色也需要明确继承 */
     }
     slot{
        cursor:default;      /* 光标移到按钮上显示箭头 */
        user-select:none;    /* 不让用户选择表情符号文本*/
     }
     </style>
     <div>
       <slot name="left">\u{1f50d}</slot> <!-- U+1F50D是放大镜 -->
       <input type="text" id="input"/>  <!-- 实际的输入元素 -->
       <slot name="right">\u{2573}</slot> <!-- U+2573 是X -->
     </div>
     `;
     
     //最后,我们调用customElement.define()将SearchBox元素
     //注册为<search-box>标签的实现。自定义元素的标签名必需
     //包含一个连字名
    customElements.define("search-box",SearchBox);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值