只聽說過 CSS in JS,怎麼還有 JS in CSS?

CSS in JS

CSS in JS是一種解決 css 問題想法的集合,而不是一個指定的庫。從CSS in JS的字面意思可以看出,它是將 css 樣式寫在 JavaScript 文件中,而不需要獨立出.css.less之類的文件。將 css 放在 js 中使我們更方便的使用 js 的變量模塊化tree-shaking。還解決了 css 中的一些問題,譬如:更方便解決基於狀態的樣式更容易追溯依賴關係生成唯一的選擇器來鎖定作用域。儘管CSS in JS不是一個很新的技術,但國內的普及程度並不高。由於 Vue 和 Angular 都有屬於他們自己的一套定義樣式的方案,React 本身也沒有管用戶怎樣定義組件的樣式 [1],所以CSS in JS在 React 社區的熱度比較高。

目前爲止實現CSS in JS的第三方庫有很多:(http://michelebertoli.github.io/css-in-js/)。像 JSS[2]、styled-components[3] 等。在這裏我們就不展開贅述了 (相關鏈接已放在下方),這篇文章的重點是JS in CSS😀。

JS in CSS 又是什麼

在上面我們提到CSS in JS就是把 CSS 寫在 JavaScript 中,那麼JS in CSS我們可以推斷出就是可以在 CSS 中使用 JavaScript 腳本,如下所示。可以在 CSS 中編寫 Paint API 的功能。還可以訪問:ctx,geom。甚至我們還可以編寫自己的 css 自定義屬性等。這些功能的實現都基於 CSS Houdini[4]。

.el {
  --color: cyan;
  --multiplier: 0.24;
  --pad: 30;
  --slant: 20;
  --background-canvas: (ctx, geom) => {
    let multiplier = var(--multiplier);
    let c = `var(--color)`;
    let pad = var(--pad);
    let slant = var(--slant);

    ctx.moveTo(0, 0);
    ctx.lineTo(pad + (geom.width - slant - pad) * multiplier, 0);
    ctx.lineTo(pad + (geom.width - slant - pad) * multiplier + slant, geom.height);
    ctx.lineTo(0, geom.height);
    ctx.fillStyle = c;
    ctx.fill();
  };
  background: paint(background-canvas);
  transition: --multiplier .4s;
}
.el:hover {
  --multiplier: 1;
}

Houdini 解決了什麼問題

CSS 與 JS 的標準制定流程對比

在如今的 Web 開發中,JavaScript 幾乎佔據了項目代碼的大部分。我們可以在項目開發中使用 ES 2020、ES2021、甚至提案中的新特性(如:Decorator[5]),即使瀏覽器尚未支持,也可以編寫Polyfill或使用Babel之類的工具進行轉譯,讓我們可以將最新的特性應用到生產環境中(如下圖所示)。

JavaScript 標準制定流程. png

而 CSS 就不同了,除了制定 CSS 標準規範所需的時間外,各家瀏覽器的版本、實戰進度差異更是曠日持久(如下圖所示),最多利用 PostCSS、Sass 等工具來幫我們轉譯出瀏覽器能接受的 CSS。開發者們能操作的就是通過 JS 去控制DOMCSSOM來影響頁面的變化,但是對於接下來的LayoutPaintComposite就幾乎沒有控制權了。爲了解決上述問題,爲了讓 CSS 的魔力不在受到瀏覽器的限制,Houdini就此誕生。

CSS 標準制定流程. png

CSS Polyfill

我們上文中提到 JavaScript 中進入提案中的特性我們可以編寫 Polyfill,只需要很短的時間就可以講新特性投入到生產環境中。這時,腦海中閃現出的第一個想法就是CSS Polyfill,只要 CSS 的 Polyfill 足夠強大,CSS 或許也能有 JavaScript 一樣的發展速度,令人可悲的是編寫CSS Polyfill異常的困難,並且大多數情況下無法在不破壞性能的情況下進行。這是因爲 JavaScript 是一門動態腳本語言 [6]。它帶來了極強的擴展性,正是因爲這樣,我們可以很輕鬆使用 JavaScript 做出 JavaScript 的 Polyfill。但是 CSS 不是動態的,在某些場景下,我們可以在編譯時將一種形式的 CSS 的轉換成另一種 (如 PostCSS[7])。如果你的 Polyfill 依賴於 DOM 結構或者某一個元素的佈局、定位等,那麼我們的 Polyfill 就無法編譯時執行,而需要在瀏覽器中運行了。不幸的是,在瀏覽器中實現這種方案非常不容易。

頁面渲染流程. png

如上圖所示,是從瀏覽器獲取到 HTML 到渲染在屏幕上的全過程,我們可以看到只有帶顏色(粉色、藍色)的部分是 JavaScript 可以控制的環節。首先我們根本無法控制瀏覽器解析 HTML 與 CSS 並將其轉化爲DOMCSSOM的過程,以及Cascade,Layout,Paint,Composite我們也無能爲力。整個過程中我們唯一完全可控制的就是DOM,另外CSSOM部分可控。

CSS Houdini 草案中提到,這種程度的暴露是不確定的、兼容性不穩定的以及缺乏對關鍵特性的支持的。比如,在瀏覽器中的 CSSOM 是不會告訴我們它是如何處理跨域的樣式表,而且對於瀏覽器無法解析的 CSS 語句它的處理方式就是不解析了,也就是說——如果我們要用 CSS polyfill 讓瀏覽器去支持它尚且不支持的屬性,那就不能在 CSSOM 這個環節做,我們只能遍歷一遍 DOM,找到 <style><link rel="stylesheet"> 標籤,獲取其中的 CSS 樣式、解析、重寫,最後再加回 DOM 樹中。令人尷尬的是,這樣 DOM 樹全部刷新了,會導致頁面的重新渲染(如下如所示)。

即便如此,有的人可能會說:“除了這種方法,我們也別無選擇,更何況對網站的性能也不會造成很大的影響”。那麼對於部分網站是這樣的。但如果我們的Polyfill是需要對可交互的頁面呢?例如scrollresizemousemovekeyup等等,這些事件隨時會被觸發,那麼意味着隨時都會導致頁面的重新渲染,交互不會像原本那樣絲滑,甚至導致頁面崩潰,對用戶的體驗也極其不好。

綜上所述,如果我們想讓瀏覽器解析它不認識的樣式(低版本瀏覽器使用 grid 佈局),然而渲染流程我們無法介入,我們也只能通過手動更新 DOM 的方式,這樣會帶來很多問題,Houdini的出現正是致力於解決他們。

Houdini API

Houdini 是一組底層 API,它公開了 CSS 引擎的各個部分,如下圖所示展示了每個環節對應的新 API(灰色部分各大瀏覽器還未實現),從而使開發人員能夠通過加入瀏覽器渲染引擎的樣式和佈局過程來擴展 CSS。Houdini 是一羣來自 Mozilla,Apple,Opera,Microsoft,HP,Intel 和 Google 的工程師組成的工作小組設計而成的。它們使開發者可以直接訪問 CSS 對象模型(CSSOM),使開發人員可以編寫瀏覽器可以解析爲 CSS 的代碼,從而創建新的 CSS 功能,而無需等待它們在瀏覽器中本地實現。

CSS Houdini-API

Properties & Values API

儘管當前已經有了 CSS 變量,可以讓開發者控制屬性值,但是無法約束類型或者更嚴格的定義,CSS Houdini 新的 API,我們可以擴展 css 的變量,我們可以定義 CSS 變量的類型,初始值,繼承。它是 css 變量更強大靈活。

CSS 變量現狀:

.dom {
  --my-color: green;
  --my-color: url('not-a-color'); // 它並不知道當前的變量類型
  color: var(--my-color);
}

Houdini 提供了兩種自定義屬性的註冊方式,分別是在 js 和 css 中。

CSS.registerProperty({
  name: '--my-prop', // String 自定義屬性名
  syntax: '<color>', // String 如何去解析當前的屬性,即屬性類型,默認 *
  inherits: false, // Boolean 如果是true,子節點將會繼承
  initialValue: '#c0ffee', // String 屬性點初始值
});

我們還可以在 css 中註冊,也可以達到上面的效果

@property --my-prop {
  syntax: '<color>';
  inherits: false;
  initial-value: #c0ffee;
}

這個 API 中最令人振奮人心的功能是自定義屬性上添加動畫,像這樣:transition: --multiplier 0.4s;,這個功能我們在前面介紹什麼是 js in css 那個 demo[8] 用使用過。我們還可以使用+使syntax屬性支持一個或多個類型,也可以使用|來分割。更多syntax屬性值:

Worklets

Worklets是渲染引擎的擴展,從概念上來講它類似於 Web Workers[9],但有幾個重要的區別:

  1. 設計爲並行,每個Worklets必須始終有兩個或更多的實例,它們中的任何一個都可以在被調用時運行

  2. 作用域較小,限制不能訪問全局作用域的 API(Worklet 的函數除外)

  3. 渲染引擎會在需要的時候調用他們,而不是我們手動調用

Worklet 是一個 JavaScript 模塊,通過調用 worklet 的 addModule 方法(它是個 Promise)來添加。比如registerLayout, registerPaint, registerAnimator 我們都需要放在 Worklet 中

//加載單個
await demoWorklet.addModule('path/to/script.js');

// 一次性加載多個worklet
Promise.all([
  demoWorklet1.addModule('script1.js'),
  demoWorklet2.addModule('script2.js'),
]).then(results => {});

registerDemoWorklet('name', class {

  // 每個Worklet可以定義要使用的不同函數
  // 他們將由渲染引擎在需要時調用
  process(arg) {
    return !arg;
  }
});

Worklets 的生命週期

Worklets lifecycle

  1. Worklet 的生命週期從渲染引擎內開始

  2. 對於 JavaScript,渲染引擎啓動 JavaScript 主線程

  3. 然後他將啓動多個 worklet 進程,並且可以運行。這些進程理想情況下是獨立於主線程的線程,這樣就不會阻塞主線程 (但它們也不需要阻塞)

  4. 然後在主線程中加載我們瀏覽器的 JavaScript

  5. 該 JavaScript 調用 worklet.addModule 並異步加載一個 worklet

  6. 加載後,將 worklet 加載到兩個或多個可用的 worklet 流程中

  7. 當需要時,渲染引擎將通過從加載的 Worklet 中調用適當的處理函數來執行 Worklet。該調用可以針對任何並行的 Worklet 實例。

Typed OM

Typed OM是對現有的CSSOM的擴展,並實現 Parsing APIProperties & Values API相關的特性。它將 css 值轉化爲有意義類型的 JavaScript 的對象,而不是像現在的字符串。如果我們嘗試將字符串類型的值轉化爲有意義的類型並返回可能會有很大的性能開銷,因此這個 API 可以讓我們更高效的使用 CSS 的值。

現在讀取 CSS 值增加了新的基類CSSStyleValue,他有許多的子類可以更加精準的描述 css 值的類型:

使用Typed OM主要有兩種方法:

  1. 通過attributeStyleMap設置和獲取有類型的行間樣式

  2. 通過computedStyleMap獲取元素完整的Typed OM樣式

使用 attributeStyleMap 設置並獲取

myElement.attributeStyleMap.set('font-size', CSS.em(2));
myElement.attributeStyleMap.get('font-size'); // CSSUnitValue { value: 2, unit: 'em' }

myElement.attributeStyleMap.set('opacity', CSS.number(.5));
myElement.attributeStyleMap.get('opacity'); // CSSUnitValue { value: 0.5, unit: 'number' };

在線 demo[10]

使用 computedStyleMap

.foo {
  transform: translateX(1em) rotate(50deg) skewX(10deg);
  vertical-align: baseline;
  width: calc(100% - 3em);
}
const cs = document.querySelector('.foo').computedStyleMap();

cs.get('vertical-align');
// CSSKeywordValue {
//  value: 'baseline',
// }

cs.get('width');
// CSSMathSum {
//   operator: 'sum',
//   length: 2,
//   values: CSSNumericArray {
//     0: CSSUnitValue { value: -90, unit: 'px' },
//     1: CSSUnitValue { value: 100, unit: 'percent' },
//   },
// }

cs.get('transform');
// CSSTransformValue {
//   is2d: true,
//   length: 3,
//   0: CSSTranslate {
//     is2d: true,
//     x: CSSUnitValue { value: 20, unit: 'px' },
//     y: CSSUnitValue { value: 0, unit: 'px' },
//     z: CSSUnitValue { value: 0, unit: 'px' },
//   },
//   1: CSSRotate {...},
//   2: CSSSkewX {...},
// }

Layout API

開發者可以通過這個 API 實現自己的佈局算法,我們可以像原生 css 一樣使用我們自定義的佈局(像display:flex, display:table)。在 Masonry layout library[11] 上我們可以看到開發者們是有多想實現各種各樣的複雜佈局,其中一些佈局光靠 CSS 是不行的。雖然這些佈局會讓人耳目一新印象深刻,但是它們的頁面性能往往都很差,在一些低端設備上性能問題猶爲明顯。

CSS Layout API 暴露了一個registerLayout方法給開發者,接收一個佈局名(layout name)作爲後面在 CSS 中使用的屬性值,還有一個包含有這個佈局邏輯的 JavaScript 類。

my-div {
  display: layout(my-layout);
}
// layout-worklet.js
registerLayout('my-layout', class {
  static get inputProperties() { return ['--foo']; }

    static get childrenInputProperties() { return ['--bar']; }

    async intrinsicSizes(children, edges, styleMap) {}

  async layout(children, edges, constraints, styleMap) {}
});
await CSS.layoutWorklet.addModule('layout-worklet.js');

目前瀏覽器大部分還不支持

Painting API

我們可以在 CSS background-image中使用它,我們可以使用 Canvas 2d 上下文,根據元素的大小控制圖像,還可以使用自定義屬性。

await CSS.paintWorklet.addModule('paint-worklet.js');
registerPaint('sample-paint', class {
  static get inputProperties() { return ['--foo']; }

  static get inputArguments() { return ['<color>']; }

  static get contextOptions() { return {alpha: true}; }

  paint(ctx, size, props, args) { }
});

Animation API

這個 API 讓我們可以控制基於用戶輸入的關鍵幀動畫,並且以非阻塞的方式。還能更改一個 DOM 元素的屬性,不過是不會引起渲染引擎重新計算佈局或者樣式的屬性,比如 transform、opacity 或者滾動條位置(scroll offset)。Animation API的使用方式與 Paint APILayout API略有不同我們還需要通過new一個WorkletAnimation來註冊 worklet。

// animation-worklet.js
registerAnimator('sample-animator', class {
  constructor(options) {
  }
  animate(currentTime, effect) {
    effect.localTime = currentTime;
  }
});
await CSS.animationWorklet.addModule('animation-worklet.js');

// 需要添加動畫的元素
const elem = document.querySelector('#my-elem');
const scrollSource = document.scrollingElement;
const timeRange = 1000;
const scrollTimeline = new ScrollTimeline({
  scrollSource,
  timeRange,
});

const effectKeyframes = new KeyframeEffect(
  elem,
  // 動畫需要綁定的關鍵幀
  [
    {transform: 'scale(1)'},
    {transform: 'scale(.25)'},
    {transform: 'scale(1)'}
  ],
  {
    duration: timeRange,
  },
);
new WorkletAnimation(
  'sample-animator',
  effectKeyframes,
  scrollTimeline,
  {},
).play();

關於此 API 的更多內容:(https://github.com/w3c/css-houdini-drafts/tree/main/css-animation-worklet-1)

Parser API

允許開發者自由擴展 CSS 詞法分析器。

解析規則:

const background = window.cssParse.rule("background: green");
console.log(background.styleMap.get("background").value) // "green"

const styles = window.cssParse.ruleSet(".foo { background: green; margin: 5px; }");
console.log(styles.length) // 5
console.log(styles[0].styleMap.get("margin-top").value) // 5
console.log(styles[0].styleMap.get("margin-top").type) // "px"

解析 CSS:

const style = fetch("style.css")
        .then(response => CSS.parseStylesheet(response.body));
style.then(console.log);

Font Metrics API

它將提供一些方法來測量在屏幕上呈現的文本元素的尺寸,將允許開發者控制文本元素在屏幕上呈現的方式。使用當前功能很難或無法測量這些值,因此該 API 將使開發者可以更輕鬆地創建與文本和字體相關的 CSS 特性。例如:

Houdini 目前進展

Is Houdini ready yet

(https://ishoudinireadyyet.com/)

Houdini 的藍圖

瞭解到這裏,部分開發者可能會說:“我不需要這些花裏胡哨的技術,並不能帶收益。我只想簡簡單單的寫幾個頁面,做做普通的 Web App,並不想試圖干預瀏覽器的渲染過程從而實現一些實驗性或炫酷的功能。” 如果這樣想的話,我們不妨退一步再去思考。回憶下最近做過的項目,用於實現頁面效果所使用到的技術,grid佈局方式在考慮兼容老版本瀏覽器時也不得不放棄。我們想控制瀏覽器渲染頁面的過程並不是僅僅爲了炫技,更多的是爲了幫助開發者們解決以下兩個問題:

  1. 統一各大瀏覽器的行爲

  2. JavaScript一樣,在推出新的特性時,我們可以通過Polyfill的形式快速的投入生產環境中。

幾年過後再回眸,當主流瀏覽器完全支持Houdini的時候。我們可以在瀏覽器上隨心所欲的使用任何 CSS 屬性,並且他們都能完美支持。像今天的grid佈局在舊版本瀏覽器支持的並不友好的這類問題,那時我們只需要安裝對應的Polyfill就能解決類似的問題。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CIQdHYI2_z8JZk9XESpoqw