verymuch

CSS补丁的痛楚 — 菲利普·沃尔顿

verymuch · 2017-02-21翻译 · 771阅读 原文链接

今年早些时候我写了一篇关于Houdini的文章,文章在Smashing Magazine上。我称它为“关于CSS你从没听说过的最激动人心的改进”。在这篇文章中,我指出通过Houdini APIs(包括一些其他东西)能够用一种现在还不能做到的方式进行CSS功能补全。

当这篇文章被大家所普遍接受时,我在我的收件箱和推特上一次又一次地提到了一个突然出现的问题。这一问题的根本主旨是:

> 为什么CSS补丁这么难?我用过很多CSS补丁,它们都能够很好地满足我。

然后我意识到大家应当也会遇到这一麻烦。如果你从来没有自己去编写一个CSS补丁,你或许可能从来没经历过这种痛楚。

所以我想能够说明这一问题最好的办法就是向你展示一下CSS打补丁到底有多困难,这也能表明为什么我对于Houdini感到那么兴奋。

那么,最好的方式就是我们动手写一个补丁。

> 提示:这篇文章是我在dotCSS on December 2, 2016上演讲的文字版本。这篇文章会更加详细,但是如果你愿意也可以观看视频(可在原文中查看,大概23分钟)。

关键字random

我们将要实现(或者模拟)的补丁功能是CSS中的新关键字random,给出一个0到1间的值(和Javascript中Math.random的返回值相同)。

下面演示一下random如何使用:

.foo {
  color: hsl(calc(random * 360), 50%, 50%);
  opacity: random;
  width: calc(random * 100%);
}

如你所见,random返回一个无单位的数值,能够用calc()转变为任何值。既然它能被转变为任何值,它就能用于任何属性(如coloropacitywidth等)。

接下来,我们将一起来看下我在演讲中所用的示例。下面是示例的界面:

Random keywork polyfill demo page

页面如果使用了random关键字的效果示例

这个页面包括引导语“Hello World”,在内容区域的顶部有四个Bootstrap.progress-bar元素。

除了bootstrap.css,这个例子还使用以下规则包含了其他的CSS文件:

.progress-bar {
  width: calc(random * 100%);
}

不过上面给出的例子中,进度条使用了固定的(写死的)宽度值。而我们的想法是实现random补丁,这样每次刷新页面时,进度条都会有不同的、随机的宽度。

补丁如何生效

在Javascript中,写补丁相对来说较为容易,因为Javascript是一门动态语言,允许在运行时编辑内部对象。

例如,你可以这样实现Math.random的补丁:

if (typeof Math.random != 'function') {
  Math.random = function() {
    // Implement polyfill here...
  };
}

相反的,CSS并不是动态的。它不可能(至少还不能)在运行时进行编辑,从而告诉浏览器实现一个原生不支持的功能。

这就意味着给一个浏览器不能理解的CSS功能打补丁,就是你必须动态地编辑CSS来伪装成浏览器能够理解该功能的一种行为。

换句话说,你必须将下面的代码进行转换:

.foo {
  width: calc(random * 100%);
}

像下面这样,在运行时随机生成:

.foo {
  width: calc(0.35746 * 100%);
}

转换CSS

因此,我们必须将现有的CSS进行修改,添加新的样式规则,这样能模拟出和想要打补丁功能的表现行为。

CSS对象模型(CSSOM)便是你能够这么做的最常规的地方。可以通过documnet.styleSheets访问。相关代码如下面所示:

for (const stylesheet of document.styleSheets) {
  // 展开嵌套的规则(如@media块)到一个单独的数组
  const rules = [...stylesheet.rules].reduce((prev, next) => {
    return prev.concat(next.cssRules ? [...next.cssRules] : [next]);
  }, []);

  // 遍历每个展开的规则,并且替换random为一个随机数字
  for (const rule of rules) {
    for (const property of Object.keys(rule.style)) {
      const value = rule.style[property];

      if (value.includes('random')) {
        rule.style[property] = value.replace('random', Math.random());
      }
    }
  }
}

> 提示: 如果是一个真实的补丁,你不能只是简单地查找并替换random关键字,因为有其他情况会存在random这一字符串(如,在URL中、content属性的值等)。最终的示例的实际代码中使用了一个更强健的替换机制,但是为了便于讲解,我在这里使用了简化的版本。

如果你打开示例2,将上述代码复制到console并运行,它确实做了如你所想的工作,但是运行结束后你并不会看到任何随机长度的进度条。

这是因为CSSOM中不包含存在random关键字的规则。

你应该知道为什么了,当浏览器遇到一个无法识别的CSS规则时,它会直接忽略。大多数情况下,这是一件好事。因为这样版本较低的浏览器也能加载CSS,页面不会完全无法查看。不幸的是,这也意味着你必须访问未改变的原始CSS,并且必须自己去获取它。

手动获取页面样式

CSS规则可以通过<style>元素或者<link rel="stylesheet">元素添加,所以想要获取未改变的原始CSS,你可以执行querySelectorAll(),手动获取<style>元素的innerHTML或者使用fetch()获取<link ref="stylesheet">中的资源文件。

下面的代码定义了一个获取页面样式的通用方法getPageStyles。返回一个Promise,包含全部的页面样式CSS文本。

const getPageStyles = () => {
  // Query the document for any element that could have styles.
  var styleElements =
      [...document.querySelectorAll('style, link[rel="stylesheet"]')];

  // Fetch all styles and ensure the results are in document order.
  // Resolve with a single string of CSS text.
  return Promise.all(styleElements.map((el) => {
    if (el.href) {
      return fetch(el.href).then((response) => response.text());
    } else {
      return el.innerHTML;
    }
  })).then((stylesArray) => stylesArray.join('\n'));
}

如果你打开 示例3,将上述代码拷贝到浏览器的控制台,这样会定义getPageStyles()函数,执行下面的代码就能够获得完成的CSS文本。

getPageStyles().then((cssText) => {
  console.log(cssText);
});

解析获取到的样式

有了原始的CSS文本后,接下来需要解析它。

你可能认为浏览器已经有能够解析CSS的函数可供使用。不幸的是,并不是这样。并且,即使浏览器提供了这样一个函数,如parseCSS(),也仍然改变不了浏览器不能理解random关键字的事实,所以parseCSS()函数有可能仍然没用(期待将来的解析规范能够允许未知的不符合现存语法的关键值)。

现在有一些很好的,开源的CSS解析器,为了实现本示例,我们将选用PostCSS(因为它能够在浏览器端加载插件系统,这一点我们要在后面利用到)。

如果你针对下面的CSS文本执行postcss.parse()

.progress-bar {
  width: calc(random * 100%);
}

你将获得类似下面的内容:

{
  "type": "root",
  "nodes": [
    {
      "type": "rule",
      "selector": ".progress-bar",
      "nodes": [
        {
          "type": "decl",
          "prop": "width",
          "value": "calc(random * 100%)"
        }
      ]
    }
  ]
}

这个json对象被称作抽象语法树 (AST),你也可以将其理解为我们自己版本的CSSOM。

现在我们已经有能够获取完整CSS文本的通用函数,并且有一个函数来解析它,那么目前为止,我们的补丁将看起来像下面这样:

import postcss from 'postcss';
import getPageStyles from './get-page-styles';

getPageStyles()
  .then((css) => postcss.parse(css))
  .then((ast) => console.log(ast));

如果你打开示例4并查看控制台,你将看到一个包含了整个页面样式的完整PostCSS AST对象。

实现补丁

到现在为止,我们写了很多代码,但奇怪的是,没有一行对我们想要实现的补丁功能起到效果。这些都只是我们为了最终效果所必需做的前期准备工作,也就是我们必须手动地完成一系列浏览器本该替我们做的事情。

想要最终实现补丁逻辑,我还要进行以下工作。

  • 修改CSS AST,将出现的random关键字替换为一个随机数。
  • 将修改后的CSS AST字符串化成CSS。
  • 用修改后的样式替换现有的页面样式。

修改CSS抽象语法树

PostCSS带来了一个很好的插件系统,它提供了修改CSS抽象语法树的辅助函数。我们可以使用那些函数来将出现的random关键字的地方替换成一个随机数。

const randomKeywordPlugin = postcss.plugin('random-keyword', () => {
  return (css) => {
    css.walkRules((rule) => {
      rule.walkDecls((decl, i) => {
        if (decl.value.includes('random')) {
          decl.value = decl.value.replace('random', Math.random());
        }
      });
    });
  };
});

将CSS AST字符串化成CSS

另一个关于PostCSS插件的好处是它提供了将CSS抽象语法树字符串化成CSS的内部逻辑。我们需要做的就是创建一个PostCSS实例,传入你想使用的插件(或插件组),然后执行process(),将返回一个已经resolved的promise对象,包含一个字符串CSS对象作为参数。

postcss([randomKeywordPlugin]).process(css).then((result) => {
  console.log(result.css);
});

替换页面样式

想要完成页面样式的替换,我们可以编写一个类似于getPageStyles()函数的通用函数。这个方法能够找到所有的<style>元素和<link ref="stylesheet>元素,并移除它们。然后创建一个新的<style>标签,将传入函数的CSS值设为<style>的内容.

const replacePageStyles = (css) => {
  // Get a reference to all existing style elements.
  const existingStyles =
      [...document.querySelectorAll('style, link[rel="stylesheet"]')];

  // Create a new <style> tag with all the polyfilled styles.
  const polyfillStyles = document.createElement('style');
  polyfillStyles.innerHTML = css;
  document.head.appendChild(polyfillStyles);

  // Remove the old styles once the new styles have been added.
  existingStyles.forEach((el) => el.parentElement.removeChild(el));
};

整合所有代码

整合了用于修改CSS抽象语法树的PostCSS插件和我们编写的两个用来获取和替换页面样式的通用函数,我们的插件代码看起来像下面这样子:

import postcss from 'postcss';
import getPageStyles from './get-page-styles';
import randomKeywordPlugin from './random-keyword-plugin';
import replacePageStyles from './replace-page-styles';

getPageStyles()
  .then((css) => postcss([randomKeywordPlugin]).process(css))
  .then((result) => replacePageStyles(result.css));

如果你打开示例5,你将看到具体的效果。多刷新几次页面,并注意观察我们的完全随机数。额...好像并不是想象中的样子,是吗?

到底哪里错了呢?

从技术的角度来说,这个插件是有效的,它对选择器匹配的每个元素应用了相同的随机值。

仔细想想我们所做的事情就能知道,我们完成的只是重写了单一规则的单一属性。

确实是这样子,但最简单的CSS插件需要的不止是重写单一的属性值,而是需要对每一个匹配的DOM元素有着很详细的了解(大小,内容,顺序等)。这也是为什么这个问题的预处理和服务端解决方法绝对不能单独存在的原因。

那么我们想一下这个重要的问题:我们该如何修改插件,才能针对不同的元素进行修改?

标记独立的匹配元素

以我的个人经验来说,有三种方法能够标记独立的DOM元素,但是它们之中没有一个是很棒的。

方法一:内联样式

到目前为止,内联样式这一方法是我所见过的其他插件作者解决标记独立的元素问题的常用方式。这一方法是使用CSS规则选择器查找所有页面的匹配元素,然后直接对它们应用内联样式。

我们可以向下面这样修改我们的PostCSS插件:

// ...

  rule.walkDecls((decl, i) => {
    if (decl.value.includes('random')) {
      const elements = document.querySelectorAll(rule.selector);
      for (const element of elements) {
        element.style[decl.prop] =
            decl.value.replace('random', Math.random());
      }
    }
  });

// ...

具体的效果如示例6所示。

乍一看,好像能够很好的工作。但不幸的是,这很容易打破。想象下,如果我们在现有的.progress-bar规则后面又加了另一条规则。

.progress-bar {
  width: calc(random * 100%);
}

#some-container .progress-bar {
  width: auto;
}

上述的代码表明除了作为#some-container后代元素的进度条的宽度不是随机值,其他所有进度条元素都有一个随机的宽度。

显然地,这并不能正常工作,因为我们应对所有元素应用了内联样式,而内联样式的优先级要比通过#some-containner .progress-bar定义的样式优先级高。

这就意味着我们的插件打破了CSS的一些根本设定(所以就我自己而言,我认为这个方法是不能接受的)。

方法二:使用内联样式,但是尝试对方法一中的陷阱进行处理。

第二种方法接受方法一中许多常规的CSS用法可能会失败,所以它试着解决它们。特别地,在方法二中我们将实现更新如下:

  • 检查剩下的CSS匹配规则,然后只有当某条规则是最后一条匹配规则时,才将其中的随机关键字替换成随机数,然后应用到内联样式中。
  • 等一下,这还不够,我们必须知道规则的特殊性,所以必须手动解析每一个选择器并计算特殊性。这样我们才能够根据特殊性从低到高对匹配的规则进行排序,然后只应用特殊性最高的选择器对应的规则声明。
  • 此外,还有@media媒体检测中的规则,我们同样需要手动核对其中匹配的规则。
  • 提到@规则,同样不能忘记@support
  • 最后我们要考虑到属性的继承,对于此,针对每一个匹配的元素我们需要遍历DOM树,查找它所有的祖先,得到完整的计算属性集合。
  • 还有一件事:我们还要处理!important,这一标志针对每个单一的属性而不是每个规则。因此,我们必须有一个单独的映射表来算出哪些声明会最终生效。

是的,如果你不能理解我刚刚说的内容,我其实只是描述了一下层叠,这是我们应该依赖于浏览器为我们做的事情。

尽管使用Javascript确切地可能重新实现层叠,但它将需要很多工作,而我宁愿看一看方法3是什么。

方法三: 为匹配的独立元素重写CSS并保持层叠顺序

第三个方法,我认为是这些坏的方法中最好的一个。它重写CSS,将原本的一个CSS选择器的规则转变为匹配多个元素的多个选择器,每一个都匹配一个单独的元素,并且不改变最终的匹配元素集合。

最后一句话可能并不好理解,让我通过一个例子来解释吧。考虑下下面的CSS文件,引用于一个包含三个段落元素的页面。

* {
  box-sizing: border-box;
}
p { /* Will match 3 paragraphs on the page. */
  opacity: random;
}
.foo {
  opacity: initial;
}

如果我们给DOM中的每个段落元素添加一个唯一的data属性,我们可以向下面这样改写CSS,为每个段落指定它们所特有的独立的规则:

* {
  box-sizing: border-box;
}
p[data-pid="1"] {
  opacity: .23421;
}
p[data-pid="2"] {
  opacity: .82305;
}
p[data-pid="3"] {
  opacity: .31178;
}
.foo {
  opacity: initial;
}

当然,如果你仔细想想,你会发现这仍然不能很好的工作,因为这改变了这些选择器的特殊性,这很有可能导致不希望出现的影响。然而,我们可以通过一些巧妙的技巧,让每个其他的选择器提高相同的特殊性,从而保证正确的层叠顺序。

*​:not(.z) {
  box-sizing: border-box;
}
p[data-pid="1"] {
  opacity: .23421;
}
p[data-pid="2"] {
  opacity: .82305;
}
p[data-pid="3"] {
  opacity: .31178;
}
.foo:not(.z) {
  opacity: initial;
}

上面的改变使用了:not()伪类选择器方法,传入一个DOM中不存在的类名(这里我使用了.z,意味着如果你在DOM中使用了.z类,你就要选择一个其他的名字)。那么,既然:not()因为给定类名不存在而总是生效,那么这就能够用来提高选择器的特殊性并且不会改变它所匹配的元素。

示例7展示了这一策略实现的结果,你可以通过示例的源码来查看random-keyword插件的一系列改变。

方法三种最好的部分就是仍让让浏览器来控制层叠,这一点浏览器很擅长处理。这就意味着你可以随意地使用媒体查询,!important规则,自定义属性,@support规则以及任何CSS功能,它都能很好地工作。

缺陷

看起来通过方法三我解决了这一CSS插件的所有问题,但事实并非如此。仍然还有很多遗留的问题,一些是能够通过更多的工作解决的问题,一些是不能解决并且难以避免的问题。

未解决的问题

首先,我故意忽略了一些style><link ref="stylesheet">标签之外的,但存在页面之内的CSS样式位置。

  • 内联样式
  • 隐藏DOM

我们能够修改我们的插件来解决这些问题,但是这种方式将需要更多的工作,我想在我的博客上进行讨论。

我们也没有考虑DOM树发生改变的可能性。毕竟,我们是基于DOM内容重写CSS,所以我们必须在DOM发生改变的同时重写CSS。

不可避免的问题

除了我上面所提到的这些问题(虽然很难,但是能解决的问题),还有一些问题难以避免。

  • 它们需要大量的额外代码。
  • 对跨域的样式表不起作用。
  • 当需要改变(如DOM改变,滚动或变换尺寸等)时它将表现的很糟糕。

我们的random关键字插件只是一个简单的例子,但是我确信你能够轻易想象position:sticky这样一个插件会有多么糟糕的表现,因为每次用户滚动式,都需要重新执行一遍我描述的所有逻辑。

可能的改进方法

由于时间限制,有一个解决方法我在演讲中并没有提到。这一方法有可能减轻前两种问题,那就是在服务端编译过程中进行解析和获取CSS.

那么,你将不需要从style中加载文件,而是加载一个包含抽象语法树的Javascript文件,那么第一步要做的事情就是字符串化抽象语法树,然后给页面添加样式。你还可以添加一个<noscript>标签,在用户禁止Javascript的时候,指向原始的CSS文件。

例如,你可以替换下面的代码:

`<link ref="stylesheet" href="styles.css">`

替换成:

``<script src="styles.css.js">``</script>
<noscript><link ref="stylesheet" href="styles.css"></noscript>

正如我所提到的,这么做解决了在Javascript中引入整个CSS解析器的问题,并且也允许你提前进行CSS的解析,但这并不能解决所有性能问题。

不过你如何尝试,你仍然需要在CSS发生改变的时候重写CSS。

理解性能的影响

为了让大家理解为什么CSS补丁的性能如此差,大家必须理解浏览器的渲染过程——尤其是作为一个开发者所能够接触到的各个步骤。

Javascript访问CSS渲染流

Javascript访问CSS渲染流

如你所见,整个流程中只有DOM这一个真实的节点,我们的补丁主要就是在这一节点中进行,查询匹配相应CSS选择器的元素,并且通过<style>标签更新CSS文本。

但是由于Javascript访问浏览器渲染流程的现状,这一方式是我们的补丁必须采取的方式。

补丁在浏览器渲染流中的实体节点

补丁在浏览器渲染流中的实体节点

可以看出,Javascript在DOM构建之后不能干涉原始的渲染流程,这就意味着我们的补丁造成的任何改变都需要整个渲染过程重新开始。

这就意味着CSS补丁的性能保持在60fps是不可能,因为所有的改变导致随之而来的一系列渲染和构造。

总结

这就是我通过本文想向大家传达的观点:“对CSS进行打补丁是非常困难”。因为我们作为开发者需要做的所有工作都受到了现如今web的样式和布局的限制。

下面是在我们的补丁中需要手动去实现的浏览器已经做了的事情,但是我们作为开发者却不能够使用:

  • 获取CSS

  • 解析CSS

  • 创建CSS文件模式(CSSOM)

  • 控制样式的层叠

  • 使样式失效

  • 再使样式生效

这就是我为什么对 Houdini感到那么兴奋的原因. 没有Houdini APIs,开发者被迫去求助于Hack方法,并且牺牲了性能和可用性。

这意味着CSS补丁必然会出现下列情况:

  • 太大

  • 太慢

  • 太多错误

不过我们并不会三种情况都出现,我们需要做出选择。

没有低级的样式原语,革新的速度和浏览器实施的速度一样慢。

开发者们抱怨Javascript社区更新太慢。但是你从来没有听过有人抱怨CSS。一部分原因就是我在文章中所提到的这些限制。

我想我们需要去改变这一情况。我想我们需要使得CSS能够满足任何情况

译者verymuch尚未开通打赏功能

相关文章