【翻】On Styling Web Components

为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文:On Styling Web Components
原作者:Harshal Patil

正文

以正确的方式进行 — 使用CSSOM

更好的封装性可能是你开始使用Web Components的主要原因。特别是,ShadowDOM是实现真正封装性的机制。多年来,Web社区发明了一些方案来修复和改善CSS和JS的全局特性,这些解决方案虽起作用了但并不完美。直到有了Shadow DOM,我们才真正实现了CSS的完美作用域。

在这篇文章中,我们会探索如何将样式应用到Web Components上,并尝试找到最合适的方法。此外,我们还将尝试将我们的解决方案和当前的构建工具相结合。

The naive way — Plain <style> tag

在使用Shadow DOM时,为了使 style sheet 发挥作用,目前是要在每个shadow root中使用<style> 元素,在大多数文章中,都会很表面的说出这个方案

class FancyComponent extends HTMLElement {

  constructor() {
    super();

    const shadowRoot = this.attachShadow({ mode: 'open' });

    shadowRoot.innerHTML = `
      <!-- Styles are scoped -->
      <style>
        p { color: red; }
      </style>
      <div>
        <p>Hello World</p>
      </div>
    `;
  }
}

customElements.define('fancy-comp', FacyComponent);

这个解决办法简单直接,然而有一个问题:

对于页面中添加的每个组件实例,浏览器都会解析它的样式表规则。

这对性能会产生很大的影响,比如时间增加和内存增加。时间会增加是因为浏览器需要解析更多的原始字符串,内存成本增加是因为每个组件都会存储样式规则。浏览器没有办法知道同一个组件的两个实例是否共享相同的样式。

原作者更新:Eric Bidelman在评论中指出,性能方面不一定会影响。浏览器可能在内部进行优化,这样它就不会在每次创建实例时解析样式标签。事实上,Blink(Chrome、Opera等)引擎已经对此进行了优化

Can we do something better?

其实,我们可以通过创建一个样式节点,然后在组件的每个实例中进行深度克隆,来防止样式规则的重新解析:

// Create style node outside of WebComponent
const style = document.createElement('style');

style.innerHTML = `
  p { color: blue; }
`;

class FancyComponent extends HTMLElement {

  constructor() {
    super();

    const shadowRoot = this.attachShadow({ mode: 'open' });

    shadowRoot.innerHTML = `
      <div>
        <p>Hello World</p>
      </div>
    `;

    // Let us try to optimize
    // Deep cloning of style node
    const clonedStyle = style.cloneNode(true);

    shadowRoot.appendChild(clonedStyle);
  }
}

不过,这种方法虽然很容易,但是仍然有问题。

  • 首先,它不能真正的帮助我们在组件实例间共享样式。新的样式节点仍然会创建
  • 其次这个方法与 lit-htmlhyperHTML 等声明式解决方案一起使用有点尴尬。

Enter the Constructible Stylesheets

顾名思义,可构建的样式表允许在使用Shadow DOM时创建和共享样式。

const sheet = new CSSStyleSheet();

// Replace all styles synchronously for this style sheet
sheet.replaceSync('p { color: green; }');

class FancyComponent1 extends HTMLElement {

  constructor() {
    super();

    const shadowRoot = this.attachShadow({ mode: 'open' });

    // Attaching the style sheet to the Shadow DOM of this component
    shadowRoot.adoptedStyleSheets = [sheet];

    shadowRoot.innerHTML = `
      <div>
        <p>Hello World</p>
      </div>
    `;

  }
}

class FancyComponent2 extends HTMLElement {

  constructor() {
    super();

    const shadowRoot = this.attachShadow({ mode: 'open' });

    // Same style sheet can also be used by another web component
    shadowRoot.adoptedStyleSheets = [sheet];

    // You can even manipulate the style sheet with plain JS manipulations
    setTimeout(() => shadowRoot.adoptedStyleSheets = [], 2000);

    shadowRoot.innerHTML = `
      <div>
        <p>Hello World</p>
      </div>
    `;

  }
}

除了解决重复拷贝的问题,它还有一些特点:

  • 首先样式不仅仅被同一组件的实例共享,也会被多个不同组件所共享
  • 它还支持处理异步样式。例如,在你的CSS代码中使用 url@import
  • 最后,adoptedStyleSheets 是一个数组。这意味着你可以在我们以前不可能做到的方式来组成可复用的样式表。首先你可以把你的CSS规则分成小块,然后只应用需要的那部分。甚至你可以做下面这样的事情:
shadowRoot.adoptedStyleSheets = [sheet];
// Remove stylesheets after two seconds
setTimeout(() => shadowRoot.adoptedStyleSheets = [], 2000);

Using with Webpack and SCSS

SCSS作为一个CSS预处理器,Webpack作为一个模块打包和构建工具,都是非常常见的配置。其中
CSSStyleSheet.replace()CSSStyleSheet.replaceSync() 预期接收一个原始字符串形式的CSS规则。我们可以使用一个简单的loader chain,包括sass-loader和raw-loader,如下:

module.exports = {

  entry: { /* config */ },

  output: { /* config */ },

  module: {
    rules: [
      // Regular css files
      {
        test: /\.css$/,
        loader: ['style-loader', 'css-loader']
      },

      // Transforming SCSS file into CSS string
      {
        test: /\.scss$/,
        use: [
          'raw-loader',
          {
            loader: 'sass-loader',
            options: {
              includePaths: [path.resolve(__dirname, 'node_modules')]
            }
          }
        ]
      },
    ]
  },
  plugins: [],
};

在你的代码中可以导入SCSS文件:

// Read SCSS file as a raw CSS text
import styleText from './my-component.scss';

const sheet = new CSSStyleSheet();
sheet.replaceSync(styleText);

在Rollup.js中也可以进行类似的设置。此外,来自Polymer团队的新库LitElement已经在使用这种方法,并具有回退机制

*注意:目前可构建的样式表是在Chromium系列的浏览器中实现。然而,通过合理的渐进式增强,为不支持的浏览器提供支持应该是容易的。

Under the hood — CSSOM

*如果你对其中细节不感兴趣,可以跳过这一节。

如果HTML被转化为DOM,那么CSS则被转化为CSSOM。这些都是独立的数据结构。最终的渲染树是由这两个数据结构构建的。作为一个前端开发者,理解CSSOM的机制其实并不重要,因此它是一个不太知名的概念。

粗略地说,一个<link type='text/css' href=''/>标签对应一个CSS样式表。它在CSSOM中由CSSStyleSheet接口表示。一个CSSStyleSheet由多个规则组成。每个规则由CSSRule接口表示。

要访问document上的CSSStyleSheet对象,你可以使用 document.styleSheets 属性。实际上,如果你想创建一个CSSStyleSheet对象,你必须创建一个样式标签,然后将其添加到document中并使用sheet属性:

const styleNode = document.createElement('style');
// It is important to add style node to the document
document.head.appendChild(styleNode);
const sheet = styleNode.sheet;

然后,可构建的样式表启用了两个API — CSSStyleSheet()构造函数和document.adoptedStyleSheets

有了可构造的样式表,你可以使用CSSStyleSheet()构造函数来创建样式表

在Shadow Roots和Documents上 adoptedStyleSheets 是可用的。它允许我们将一个CSSStyleSheet定义的样式应用到一个给定的DOM子树上。另需要注意,adoptedStyleSheets是一个不可变的数组,因此 pushsplice 会不起作用。

就CSSRule而言,有许多CSSRule type都扩展了CSSRule interface。以下是MDN提供的一个所有规则的列表:

CSS Object MOdel是很庞大的,而且一直都在不停的发展,你可以在下面找到详细的信息:

另外,不要忘记新的 CSS Typed Object Model正在积极开发中,它肯定会对如何用JS编写CSS产生影响,你可以在下面的文章中找到更多关于它的内容:

Side notes

不管你是在Shadow DOM中如何处理style,你都应该牢记以下几项:

  • 您可以通过使用@import语句来在 Shadow DOM 中使用外部样式
  • 插槽可以通过全局CSS或容器组件的样式表进行设置
  • CSS自定义属性可以跨越Shadow DOM边界使用和修改

因此,这些自定义属性是从外部处理 ShadowDOM 样式的首选

  • 除了自定义属性外,还可以使用新的CSS选择器,如 :host:part:theme,进一步提供来自外部的样式定制
  • 在很大程度上,使用Shadow DOM进行样式封装是替代CSS-in-JS方法的很好选择
  • 虚拟组件(没有元素的组件,如Vue的<router-view />或React的<Route />)是不可能使用Web Components
  • Web Components非常适合替代子组件,如输入框、面板、卡片、选择器等