初探CSS对象模型(CSSOM)

发布于 大漠

今年花了不少的时间在学习DOM相关的知识,经过这段时间的学习,可以通过一些JavaScript的API操作和处理Web页面上的HTML元素。在Web中除了DOM之外还有另外一个对象模型:CSS对象模型(即CSSOM)。或许你已经在项目中已经用过了,只不过没有意识到这一点而以。今天这篇文章中,我们主要来一起探讨有关于CSSOM相关的特性。

CSSOM是什么?

既然我们要探讨CSSOM是什么?那就很有必要先了解它是一个什么东东?MDN上对CSSOM的描述是这样的:

The CSS Object Model is a set of APIs allowing the manipulation of CSS from JavaScript. It is much like the DOM, but for the CSS rather than the HTML. It allows users to read and modify CSS style dynamically.

大致的意思就是:CSSOM是一组允许JavaScript操作CSS的API。它非常类似于DOM,但是用于CSS而不是HTML。它允许用户动态读取和修改CSS样式。

CSSOM在W3C规范中有一个独立的模块,对于我们学习CSSOM还是很有帮助的,但相较于MDN而来,更难于阅读和理解。

为了更好的理解CSSOM是什么?我来们先来看一个简单的示例。

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <link href="style.css" rel="stylesheet">
        <title>Critical Path</title>
    </head>
    <body>
        <p>Hello <span>web performance</span> students!</p>
        <div><img src="awesome-photo.jpg"></div>
    </body>
</html>

// style.css
body { 
    font-size: 16px 
}
p { 
    font-weight: bold 
}
span { 
    color: red 
}
p span { 
    display: none 
}
img { 
    float: right 
}

这是一个非常简单的Web页面,“包含了一些文本和一幅图片”。浏览器处理这个页面的过程如下:

根据前面所学,其对应的DOM结构建如下:

对于Web的样式,其处理HTML有点类似,需要将收到的CSS规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复HTML过程,只不过是为CSS而不是HTML:

CSS字节转换成字符,接着转换成令牌和节点,最后链接到一个CSSOM的树结构中:

是不是看上去和DOM结构树类似呀。那么CSSOM为何具有树结构呢?为页面上的任何对象计算最后一组样式时,浏览器都会先从适用于该节点的最通用规则开始,比如,如果该节点是body元素的子元素,则应用所有body样式,然后通过应用更具体的规则(这里将会运用CSS层级相关的管理规则)以递归方式优化计算的样式。

上面的示例就很形象的介绍了CSSOM。

注意,上图显示的树并非是一颗完整的CSSOM树,它只显示了我们决定在样式表中替换的样式。

事实上这一过程是相当复杂的过程,在这里不做过多的介绍,如果你感兴趣的话,可以阅读下面两篇文章:

但这一切都并不重要,重要的是我们可以通过这篇文章来学习CSSOM一些常见的特性,有利于我们更好的掌握CSSOM相关的特性和API所起的相关作用。

使用ele.style设置元素行内样式

在Web开发中,我们有的时候需要动态的控制HTML元素的样式,对于这样的场景,大多数都是通过JavaScript的API来控制HTML的style属性。面对这样的场景是使用ele.style这个API来控制style对象。我们可以通过在浏览器的控制台中,输入$0.style可以输出对应元素的style所对应的属性:

比如我们要修改表单元素input的背景颜色,我们可以这么做:

$0.style.backgroundColor='green'

这样元素input自动加下了style属性,而且值为background-color: green。同时表单的背景颜色变成了green

$0是浏览器调试器中的一个技巧,指定是选择中的元素。在实际使用的时候,可以通过JavaScript选择器相关的API来获取你想要的DOM元素。最为常见的就是使用getElement*querySelector* API,有关于这方面更为详细的介绍,可以阅读DOM系列中的《getElement*querySelector*》一文。

也就是说,我们可以使用相同的格式添加或更改页面上任何对象的CSS:ele.style.propertyName,其中ele指的是DOM元素,propertyName指的是希望给ele元素要添加的样式属性(记住,带有-中划线的CSS属性需要改用陀峰形式,比如上面示例中的background-color属性要写成backgroundColor)。

注意,在动态设置float属性时,需要使用cssFloat,这是因为float是JavaScript中的一个关键词。这个有点类似于getAttribute()给HTML元素设置for属性时,需要使用htmlFor

这种方式是使用JavaScript给DOM元素设置样式最简单的方法。但是以这种方式给DOM元素设置样式有一个最大的局限性:只能给DOM元素添加内联样式。同样的,如果我们想获取一个DOM元素的内联样式中某个属性的值时,也可以采用这种方式:

$0.style.backgroundColor // => green

当然,通过上面方式获取DOM元素内联样式对应属性的值时,有个前提条件,那就是该元素定义了该内联样式。如果未指定(定义)该样式,那么将不会返回任何值:

$0.style.color // => ""

CSS Houdini中的CSSOM,我们可以使用.attributeStyleMap属性来替代ele.style。可以使用ele.attributeStyleMap.set(property, value)来设置元素内联样式:

$0.attributeStyleMap.set('background-color', 'green')

其得到的效果和ele.style.property = value等同的效果。另外,.attributeStyleMap类似于Map对象,所以它们支持对象常有的一些方法,比如getsetkeysvaluesentry等。这样让我们的工作也变得更为灵活:

$0.attributeStyleMap.set('background-color', 'green') // 设置background-color的值为green
$0.attributeStyleMap.get('background-color').value === 'green' // => false
$0.attributeStyleMap.has('background-color') // => true
$0.attributeStyleMap.delete('background-color') // => 删除background-color
$0.attributeStyleMap.clear() // => 删除所有样式

获取计算样式

我们可以使用window.getComputedStyle()方法获取元素上任何CSS的计算值。

在浏览器的Computed一项中,我们可以查看到任何元素具有的可计算的样式。如上图所示。那么我们可以通过window.getComputedStyle()方法获取相应的计算样式,比如像下面这样:

window.getComputedStyle($0).backgroundColor // => "rgb(0, 128, 0)"

上面只是获取了其中一个计算样式值。除了上述的方式,我们还可以通过其他的方式来获取,比如:

window.getComputedStyle(el).backgroundColor;
window.getComputedStyle(el)['background-color'];
window.getComputedStyle(el).getPropertyValue('background-color');

而在新的CSSOM中有一个新的API,可以让我们获取计算值。比如:

el.computedStyleMap().get('opacity').value // => 0.5

注意,window.getComputedStyle()ele.computedStyleMap()的差别是,前者返回的是解析值,而后值返回计算值。类如,如果你的样式中有一个这样的值,width: 50%,那么在Typed OM中将保留百分值(width: 50%);而CSSOM中返回的是解析值(width: 200px)。

上面示例中,window.getComputedStyle()方法只传了一个参数,对于普通元素可以省略第二个参数,或者显示的传一个null值:

window.getComputedStyle(ele, null).property;

其实,它有一个小细节,它允许你检索伪元素的样式信息:

window.getComputedStyle(ele, '::before').property;

CSSStyleDeclaration 相关API

通过前面的内容我们知道如何通过style对象或使用getComputedStyle()访问样式属性,这两个其实是CSSStyleDeclaration接口。也就是说,我们可以像下面这样将body元素上返回一个CSSStyleDeclaration对象:

document.body.style;
window.getComputedStyle(document.body);

我们可以在浏览器控制台中看到上面的命令将会输出的内容:

这两者有点不同,前者其实是前面介绍的ele.style,它可以获取和设置元素CSS属性的值,只不过只是给元素添加内联样式;但window.getComputedStyle(ele)获取的是只读值。

CSSStyleDeclaration有几个常用的方法:

  • setProperty():给一个声明了CSS样式的对象设置一个新的值
  • getPropertyValue():用来获取CSS属性的值
  • item():通过下标从CSSStyleDeclaration返回一个CSS属性值
  • getPropertyPriority():根据传入的CSS属性,返回一个DOMString来表示该属性的权重(优先级)
  • removeProperty():移除style对象的一个属性

接下来分别看这几个方法是如何使用的。

setProperty()

该方法可以给CSS的属性设置一个新的值。可以像下面这样使用:

ele.style.setProperty(property, value, priority)

其中property指的是CSS属性,value设置的属性的值,priority允许设置CSS的权重,即!important。比如下面这个示例:

$0.style.setProperty('color', 'red')

window.getComputedStyle($0).color  // => "rgb(255, 0, 0)"

getPropertyValue()

该方法可以用来获取CSS属性的值,比如像下面这样:

$0.style.getPropertyValue('color')  // => "red"

使用该方法时,如果getComputedStyle没有给元素指定属性时,它将返回一个空字符串:

$0.style.getPropertyValue('background-color') // => ""

item()

CSSStyleDeclarationitem()方式可以让我们通过下标从CSSStyleDeclaration返回一个CSS属性值。其使用格式:

ele.style.item(index)

其中index是需要查找节点的索引,索引下标从0开始。如果我们要获取元素行内样式中所有的属性时可以通过下面的方式遍历出来:

for(let i = 0; i < $0.style.length; i++) {
    console.log($0.style.item(i))
}

// => clear
// => position
// => zoom

这里有一个小细节,item()方法只要传入参数,这个方法就不会抛出异常,当传入的下标越界时会返回空字符串,当未传入参数时会抛出一个TypeError

getPropertyPriority()

getPropertyPriority()方法是一个很有意思的方法。这个方法会根据传入的CSS属性,返回一个DOMString来表示该属性的优先级。如果有的话,则返回important;如果不存在的话,返回空字符串。

在介绍style.setProperty()方法的时候,我们在给其传参数的时候,第三个参数就可以指定属性的优先级。或者在原有的CSS中带有!important时,该方法也会返回important字符串。比如下面这个小示例:

$0.style.setProperty('border', '2px solid red', 'important')
$0.style.setProperty('background-color', 'orange')

$0.style.getPropertyPriority('border')                // => "important"
$0.style.getPropertyPriority('background-color')      // => ""

上面的示例中,第一行代码和第二行代码使用了ele.style.setProperty()方式给元素分别设置了borderbackground-color两个属性,不同之处是,第一个传了第三个参数priority(即"important")。这个参数就相当于在给属性值后面附加了!important关键字。

在用!important设置属性之后,使用ele.style.getPropertyPriority()方法检查该属性的优先级。前面也提到过了,如果元素的style中的属性带有!important值,也可以使用该方法进行检查。

这里有一个小细节需要注意,如果内联样式中的简写属性,比如margin属性值带有!important关键词,如果我们使用ele.style.getPropertyPriority()在检查简写属性或示简写的属性的时,都将返回important的值。比如下面的代码:

$0.style.getPropertyPriority('margin')       // =>  "important"
$0.style.getPropertyPriority('margin-top')   // =>  "important"
$0.style.getPropertyPriority('margin-right') // =>  "important"
$0.style.getPropertyPriority('margin-bottom')// =>  "important"
$0.style.getPropertyPriority('margin-left')  // =>  "important"

removeProperty()

该方法可以移除style对象的一个属性:

$0.style.removeProperty('margin')    // => ""
$0.style.getPropertyValue('margin')  // => ""

这个时候,DOM元素中style里的margin属性被移除了,比如下图所示的结果:

CSSStyleSheet接口

前面我们所聊的内容大部分都是关于元素内联样式(通常局限性较大)和计算样式(通常很有用,但过于具体)。接下来要聊的CSSStyleSheet相关的API是一个更有用的API,它允许检索具有可读和可写值的样式表,而不仅仅是内联样式表。简单地说,该接口代表一个单一的CSS样式表。

在写Web页面的时候,我们一直都提倡将页面的样式规则放入到一个单一(或多个)样式文件中,或者<style>标签中。这两种方式写样式都会包含一组CSS规则。每条CSS规则可以通过与之相关联的对象进行操作,这个关联对象实现了CSSStyleRule接口,而CSSStyleRule反过来实现了CSSRuleCSSStyleSheet允许你检测与修改和它相关联的的样式表,包括样式表的规则列表。

实际上,CSSStyleSheet也实现了更为通用的StyleSheet接口。实现一个document的样式表的CSSStyleSheet列表可以过document.styleSheet属性获取(这个document通过外联样式表或内嵌的style元素定义样式)。

比如,我们可以使用下面的方式来查看一个页面(文档)中有多少样式表:

document.styleSheets.length  // => 5

上面代码查询出W3cplus网站总共用了多少个CSS样式表(样式文件):

同样的,我们可以使用下标索引引用文档中的任何样式表,比如:

我们也可遍历出来所有运用到的样式表的相关信息:

for(let i = 0; i < document.styleSheets.length; i++) {
    console.log(document.styleSheets[i])
}

在上面两个截图中,我们都可以看到cssRulesownerRule两个属性:

  • cssRules:返回样式表中CSS规则的CSSRuleList对象
  • ownerRule:如果一个样式表示通过@import规则引入document的,则ownerRule将返回那个CSSImportRule对象,否则返回null

其中cssRules属性是较为有用的。此属性提供样式表中包含的所有CSS规则(包括声明块、at-rules和媒体查询等)的列表。

在这个示例中,总共有116个CSS规则。

在接下来的部分中,我们将详细介绍如何使用这个API来操作和读取外部样式表中的样式。比如我们要把第一个.css文件中所有选择器打印出来,我们就可以像下面这样做:

let myRules = document.styleSheets[0].cssRules

for (i of myRules) {
    if (i.type === 1) {
        console.log(i.selectorText)
    }
}

打印出来的结果类似下图这样:

在上面的代码中需要注意两件事。首先,把第一个样式表中的cssRules对象赋值给一个变量缓存起来,然后使用for... of循环来循环该对象中的所有规则,检查每个规则的类型。在这种情况之下,我们需要的规则类型(type)是1,它表示STYLE_RULE常量。其他常量包括IMPORT_RULE(对应的type = 3)、MEDIA_RULE(对应的type=4)和KEYFRAMES_RULE(对应的type=7)。更多的类型如下图所示,也可以在MDN上查阅

同样的,我们可以使用类似的方法打印出@media@keyframes里面相关的信息。也可以以类似方式打印出类似selectorText相关的信息,比如stylestyleMapcssText等。比如:

let myRules = document.styleSheets[0].cssRules

for (i of myRules) {
    if (i.type === 1) {
        console.log(i.cssText)
    }
}

打印出来的结果类似下图:

CSSStyleSheet接口中除了上面提到的两个常见属性之外,还有两个方法,允许你从样式表中添加或删除整个规则。

  • insertRule:向样式表中插入一条新规则
  • deleteRule:从当前样式表对象中删除指定的样式规则

比如我们要给第一个样式表中添加一条新的样式规则:

let firstStylesheet = document.styleSheets[0]
console.log(firstStylesheet.cssRules.length)  // => 116

firstStylesheet.insertRule(
    `body {
        background-color: orange;
        font-size: 3em;
        padding: 2em;
    }`,
    firstStylesheet.cssRules.length
)

这个时候在样式表中添加了下面的样式:

我也可以通过下面的代码,来验证:

for (i of firstStylesheet.cssRules) {
    if (i.type === 1) {
        console.log(i.cssText)
    }
}

cssRuleslength值由116变成117

console.log(firstStylesheet.cssRules.length)   // => 117

stylesheet.insertRule()方法接受两值参数:

  • rule:一个字符串,也就是想插入的样式规则,包含选择器和对应的样式规则
  • index:一个数字,表示要插入的位置,这是一个可选参数

注意,对于普通样式规则来说,要插入的字符串应该包含选择器和样式声明。对于@规则来说,要插入的字符串应该包含@标识符和样式规则的内容。另外,index未设置的话,则默认为0,新添加的rule将会插入到样式表的最前面,如果index索引值恰好大于cssRules.length,将会抛出一个错误。

deleteRule()方法相对来说更为容易,它只接受一个参数indexindex就是一个数字,用来指定样式规则的位置。作为参数传入的所选index必须小于cssRules.length,否则将抛出错误。比如我们现在要删除刚才新增加的样式规则:

body {
    background-color: orange;
    font-size: 3em;
    padding: 2em;
}

我们就可以像下面这样来删除这条规则:

firstStylesheet.deleteRule(116)

对应的样式规则就删除了。如果把116换成117就会报错:

CSSOM的未来

在介绍ele.style这个API的时候,简单的提到过,CSS Houdini中提到了新的CSSOM(即CSS Typed OM)。新的CSSOM相关的API能提供更大的优势。有关于这方面的介绍,可以阅读Google开发者文档中@Eric Bidelman写的博文

总结

通过JavaScript中的相关API来操作CSS样式表肯定不是每个项目中都会用到的。但文章中提到的一些API的的确确可以帮助我们实现一些复杂交互。因此,掌握这些API是很有必要的,同时能加强我们处理业务的能力。

扩展阅读