和Houdini,CSSPaintAPI打個招呼吧

雲棲直播~發表於2018-07-06

原文連結:SAY HELLO TO HOUDINI AND THE CSS PAINT API
作者:Will Boyd
相關閱讀:Houdini:CSS 領域最令人振奮的革新

瀏覽器發展至今,已經很久沒有感受過這種期待了。

Hodini 的出現將賦予開發者前所未有的控制頁面視覺表現的能力。這個專案的第一步是實現 CSS Paint API。本篇將解釋為什麼 Houdini 的到來讓人如此興奮,以及向讀者展示如何開始使用 Paint API。

老生常談的問題

相信每次要使用 CSS 新特性時,你都會看到下面這句話:

Wooo,這個效果太酷了!我想等到(大概兩年後吧)大部分瀏覽器都支援的時候就用上。

但我們並不想等那麼久,那乾脆用 CSS polyfills 好了。但在一些邊界情況下 polyfills 也無能為力。更何況它還可能帶來效能問題。在大部分情況下原生瀏覽器的實現都優於 polyfills。

如果對此你還有疑問,可以看看這篇說的 CSS polyfill 的壞處

新的希望

看到這裡,是不是有些失望了?別灰心,很快你不用等瀏覽器廠商,可以直接自己實現一個新忒性。這就是 Houdini 要做的事,它來自可擴充的 Web Manifesto,允許開發者直接操作瀏覽器的 CSS 引擎,開發者擁有極大的許可權,甚至能干預瀏覽器原生的渲染流程。

這些自定義的 CSS 屬性可以在 worklet 中定義,worklet 也用 JavaScript 編寫,只是瀏覽器執行它們的方式和我們認知裡不同,稍後會詳聊這部分。成功使用之後, worklet 將在訪問者的瀏覽器內植入了新特性,使用者就能看到新特性下的視覺效果了。

這就表示,開發者不用再等待瀏覽器廠商了,只要支援了 Houdini 就能用上新特性。甚至是瀏覽器壓根不打算實現的,開發者也能自力更生傳達完美的效果給使用者。

瀏覽器支援

好訊息是 Apple、Google、微軟、Mozilla、Opera 都是 Houdini 專案的推動者。不過到目前為止只有 Google Chrome 落地實施了這個計劃。撰寫本文時,各個瀏覽器廠商的實現程度:

01.005eb0aacbaf.png

這個表格資訊量有些大,容我細細解釋。

Houdini 就好比是一張拼圖,它是一系列 API 的統稱。開發者可以通過 Layout API 控制元素的佈局;通過 Parser API 控制 CSS 表示式處理引數的邏輯…不過看得出來,Houdini 專案之路漫漫。

好訊息是,其中一個 API 已經可以用起來了:Paint API。通過 Paint API 開發者可以畫出影像,然後把這些影像運用到合適的 CSS 屬性上,比如 bakcground-image 和 list-style-image

暫時你還只能在 Chrome 上做試驗。Chrome 65+ 已預設開啟該介面,65 以下的 Chrome 需要通過訪問 chrome://flags 開啟 Experimental Web Platform features

可以通過以下任意一種方式確認 Chrome 是否支援該 API:

if (`paintWorklet` in CSS) {
    // 邏輯寫這裡
}
@supports (background: paint(id)) {
    /* 樣式在此 */
}

也可以通過這個 Codepen demo 確認,如果訪問連結看到的是兩個綠色打鉤,就說明瀏覽器已經準備好了!

技術性提示

Paint API 必須要在支援 https 伺服器上或者本地 localhost 上才能使用。所以如果你是在本地開發,可以用 http-server 在本地快速搭建一個伺服器。

要記得禁用瀏覽器快取,讓最新的 worklets 立馬生效。

目前暫時無法在 worklets 中打斷點或者插入 debugger ,不過 console.log() 還是可以用的。

簡單的 Paint Worklet

讓我們用 Paint API 搞點事情!先來個小前菜:在一個元素上畫一個叉。這個效果的實際應用就是佔位符,常見於一些模型設計/線框圖中,表示該佔位需要放一張圖片。·

效果如下,程式碼在此

02.633f9c6d1bb4.jpg

繪製程式碼會被寫入 paint worklet 中,它的作用域和功能都有限。Paint Worklet 無法操作 DOM 和全域性方法(比如 setInterval)。這樣的特性保證了 worklet 的高效和可多執行緒化(目前還不支援,但這點是眾望所歸)。

class PlaceholderBoxPainter {
    paint(ctx, size) {
        ctx.lineWidth = 2;
        ctx.strokeStyle = `#666`;

        // 從左上角到右下角的一條線
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(size.width, size.height);
        ctx.stroke();

        // 從右上角到左下角的一條線
        ctx.beginPath();
        ctx.moveTo(size.width, 0);
        ctx.lineTo(0, size.height);
        ctx.stroke();
    }
}

registerPaint(`placeholder-box`, PlaceholderBoxPainter);

當重繪元素被觸發時,paint() 方法就會被呼叫。它接收兩個傳入引數,第一個是將被繪製的 ctx物件,和 CanvasRenderingContext2D 物件差不多,不過多了些限制(比如無法繪製文字)。size決定了繪製元素的寬和高。

接下來,瀏覽器頁面將接收這個 paint worklet,給頁面加一個 <div class="placeholder"> 標籤。

<script>
    CSS.paintWorklet.addModule(`worklet.js`);
</script>

<div class="placeholder"></div>

最後,將 worklet 和 <div> 通過 css 關聯起來:

.placeholder {
    background-image: paint(placeholder-box);

    /* 其他樣式... */
}

嗯,就是這樣。

恭喜!看來你已經知道怎麼用 Paint API 了!

Input Property 的使用

現在我們寫的叉中,線的粗細程度和顏色都是硬編碼的,如果想要改成對齊容器邊框的粗細和顏色要怎麼寫呢?

我們可以通過 input property(輸入屬性)實現,這一特性由 Typed Object Model (也可以稱之為 Typed OM)提供。Typed OM 同屬於 Houdini,但和 Paint API 不同的是,需要手動開啟 chrome://flags 中的 Experimental Web Platform features

可以通過下面的程式碼確認是否成功啟用該特性:

if (`CSSUnitValue` in window) {
    // 樣式在此
}

啟用之後,就可以修改原來的 paint worklet 讓它可以接收 input property 了:

class PlaceholderBoxPropsPainter {
    static get inputProperties() {
        return [`border-top-width`, `border-top-color`];
    }

    paint(ctx, size, props) {
        // 預設值
        ctx.lineWidth = 2;
        ctx.strokeStyle = `#666`;

        // 設定線的寬度為(如果存在的)頂邊寬度
        let borderTopWidthProp = props.get(`border-top-width`);
        if (borderTopWidthProp) {
            ctx.lineWidth = borderTopWidthProp.value;
        }

        // 設定線的樣式為(如果存在的)定邊樣式
        let borderTopColorProp = props.get(`border-top-color`);
        if (borderTopColorProp) {
            ctx.strokeStyle = borderTopColorProp.toString();
        }

        // 上面 demo 中的程式碼從這裡開始...
    }
}

registerPaint(`placeholder-box-props`, PlaceholderBoxPropsPainter);

通過新增 inputProperties,paint worklet 就知道要去哪裡找 CSS 屬性。paint() 函式也能夠接收第三個傳入引數 props,通過它獲取到 CSS 屬性值。現在,我們的佔位符看著自然多了(codepen 連結):

03.713b193468b4.png

用 border 也可以,不過要記得這個屬性其實是簡寫,背後其實有12個屬性:

.shorthand {
    border: 1px solid blue;
}

.expanded {
    border-top-width: 1px;
    border-right-width: 1px;
    border-bottom-width: 1px;
    border-left-width: 1px;
    border-top-style: solid;
    border-right-style: solid;
    border-bottom-style: solid;
    border-left-style: solid;
    border-top-color: blue;
    border-right-color: blue;
    border-bottom-color: blue;
    border-left-color: blue;
}

paint worklet 需要指明具體屬性,到目前為止的例子裡,我們用到的屬性是 border-top-width 和 border-top-color

值得注意的是,paint worklet 在處理 border-top-width 時會轉化為以畫素為單位的數值。這個處理方式堪稱完美,正是 ctx.lineWidth 所希望的處理方式。什麼?怎麼知道會轉成畫素的?看看 demo 中的第三個佔位符,它的 border-top-width 是 1rem,但 paint worklet 接收以後就變成了 16px

帶鋸齒的邊界

讓我們把目光投向新的舞臺 — 用 paint worklet 畫一個帶鋸齒的邊界,程式碼在此

04.fc7958e7c9d4.png

接下來,讓我們詳細看看具體實現:

class JaggedEdgePainter {
    static get inputProperties() {
        return [`--tooth-width`, `--tooth-height`];
    }

    paint(ctx, size, props) {
        let toothWidth = props.get(`--tooth-width`).value;
        let toothHeight = props.get(`--tooth-height`).value;

        // 為確保「牙齒」排列集中,需要進行一系列計算
        let spaceBeforeCenterTooth = (size.width - toothWidth) / 2;
        let teethBeforeCenterTooth = Math.ceil(spaceBeforeCenterTooth / toothWidth);
        let totalTeeth = teethBeforeCenterTooth * 2 + 1;
        let startX = spaceBeforeCenterTooth - teethBeforeCenterTooth * toothWidth;

        // 從左開始畫
        ctx.beginPath();
        ctx.moveTo(startX, toothHeight);

        // 給所有「牙齒」畫上鋸齒
        for (let i = 0; i < totalTeeth; i++) {
            let x = startX + toothWidth * i;
            ctx.lineTo(x + toothWidth / 2, 0);
            ctx.lineTo(x + toothWidth, toothHeight);
        }

        // 閉合「牙齒」的曲線,並填色
        ctx.lineTo(size.width, size.height);
        ctx.lineTo(0, size.height);
        ctx.closePath();
        ctx.fill();
    }
}

registerPaint(`jagged-edge`, JaggedEdgePainter);

這裡我們又用上了 inputProperties,需要控制每個「牙齒」的寬度和高度。還用到了自定義屬性(也被稱為CSS 變數--tooth-width 和 --tooth-height。這確實比佔用現有的 CSS 屬性要好,但想在 paint worklet 中使用自定義屬性還要多走一步。

你看,瀏覽器能夠識別它已知的 CSS 屬性值和對應的變數值,知道某一個屬性需要「長度」作為它的屬性值(比如上面的 border-top-width)。但自定義屬性是開發者控制的,會有各種各樣的屬性值,瀏覽器不知道哪個屬性該對應什麼樣的值才合法。所以要用自定義屬性就多了一步,需要告知瀏覽器識別屬性值。

Properties and Values API 做的就是這件事情。這個 API 也是 Houdini 的一部分,同樣需要手動開啟(譯者:方法同上,不再贅述)。

可以通過 JS 確認是否成功開啟:

if (`registerProperty` in CSS) {
    // 這裡寫程式碼
}

確認開啟後,在 paint worklet 外面加上下面這一段:

CSS.registerProperty({
    name: `--tooth-width`,
    syntax: `<length>`,
    initialValue: `40px`
});
CSS.registerProperty({
    name: `--tooth-height`,
    syntax: `<length>`,
    initialValue: `20px`
});

在 --tooth-width 和 --tooth-height 上填長度相關的值後,瀏覽器就知道在 paint worklet 中使用這兩個屬性時,需要把對應值轉成畫素。甚至可以用 calc() !如果不小心寫成非長度值,則會傳入 initialValue 不至於報錯。

.jagged {
    background: paint(jagged-edge);
    /* 其他樣式... */
}

.slot:nth-child(1) .jagged {
    --tooth-width: 50px;
    --tooth-height: 25px;
}

.slot:nth-child(2) .jagged {
    --tooth-width: 2rem;
    --tooth-height: 3rem;
}

.slot:nth-child(3) .jagged {
    --tooth-width: calc(33vw - 31px);
    --tooth-height: 2em;
}

並不是只允許使用 <length> 型別,更多可選型別請參考這裡

比如我們也能定義 --tooth-color 自定義屬性,並規定屬性值是 <color>。不過在實現鋸齒邊距上,我還有個更好的方案:在 paint worklet 中用 -webkit-mask-image 。這個方案不用修改鋸齒背景色就能實現各種各樣背景的鋸齒了:

.jagged {
    --tooth-width: 80px;
    --tooth-height: 30px;
    -webkit-mask-image: paint(jagged-edge);

    /* 其他樣式... */
}

.slot:nth-child(1) .jagged {
    background-image: linear-gradient(to right, #22c1c3, #fdbb2d);
}

.slot:nth-child(2) .jagged {
    /* 圖源來自遊戲 Iconoclasts http://www.playiconoclasts.com/ */
    background-image: url(`iconoclasts.png`);
    background-size: cover;
    background-position: 50% 0;
}

paint worklet 程式碼修改不大,具體效果如下:

05.9ba309ff7814.png

輸入引數

可以通過輸入引數 (input arguments) 向 paint worklet 中傳參,從 CSS 中傳入引數:

.solid {
    background-image: paint(solid-color, #c0eb75);

    /* 其他的樣式... */
}

paint worklet 中定義了 inputArguments 需要傳入什麼樣的引數。paint() 函式可以通過第四個傳入引數獲取到所有 inputArguments,第四個引數是名為 args 的陣列:

class SolidColorPainter {
    static get inputArguments() {
        return [`<color>`];
    }

    paint(ctx, size, props, args) {
        ctx.fillStyle = args[0].toString();
        ctx.fillRect(0, 0, size.width, size.height);
    }
}

registerPaint(`solid-color`, SolidColorPainter);

說實話,我並非這種寫法的擁躉。而且我認為相比之下,自定義屬性更靈活,還可以通過變數名得到自文件化的 CSS。

動畫革命

最後一個 demo 了。通過以上所學知識,我們能做出下面這漂亮的褪色圓點圖案

06.e607e085b15d.png

為了控制這些漸變點,第一步就是先註冊幾個自定義屬性:

CSS.registerProperty({
    name: `--dot-spacing`,
    syntax: `<length>`,
    initialValue: `20px`
});
CSS.registerProperty({
    name: `--dot-fade-offset`,
    syntax: `<percentage>`,
    initialValue: `0%`
});
CSS.registerProperty({
    name: `--dot-color`,
    syntax: `<color>`,
    initialValue: `#fff`
});

註冊之後 paint worklet 就能使用這些變數啦,接下來就是進行一系列計算,畫出想要的褪色效果:

class PolkaDotFadePainter {
    static get inputProperties() {
        return [`--dot-spacing`, `--dot-fade-offset`, `--dot-color`];
    }

    paint(ctx, size, props) {
        let spacing = props.get(`--dot-spacing`).value;
        let fadeOffset = props.get(`--dot-fade-offset`).value;
        let color = props.get(`--dot-color`).toString();

        ctx.fillStyle = color;
        for (let y = 0; y < size.height + spacing; y += spacing) {
            for (let x = 0; x < size.width + spacing; x += spacing * 2) {
                // 通過變換 x 在每一行中建立交錯的點
                let staggerX = x + ((y / spacing) % 2 === 1 ? spacing : 0);

                // 通過 fade offset和每個點的橫座標,計算出該點的半徑
                let fadeRelativeX = staggerX - size.width * fadeOffset / 100;
                let radius = spacing * Math.max(Math.min(1 - fadeRelativeX / size.width, 1), 0);

                // 畫出目標點
                ctx.beginPath();
                ctx.arc(staggerX, y, radius, 0, 2 * Math.PI);
                ctx.fill();
            }
        }
    }
}

registerPaint(`polka-dot-fade`, PolkaDotFadePainter);

最後,還要在 CSS 中用上這個 paint worklet 才能看到效果:

.polka-dot {
    --dot-spacing: 20px;
    --dot-fade-offset: 0%;
    --dot-color: #40e0d0;
    background: paint(polka-dot-fade);

    /* 其他樣式... */
}

現在,故事的轉折點來了!動畫效果可以通過改變自定義屬性的方式實現。當屬性值發生變化時,paint worklet 會被呼叫,然後瀏覽器重繪元素,最終實現動畫效果。

那麼來試試通過 CSS 動畫中的 keyframestransition 也可以)改變 --dot-fade-offset 和 --dot-color

.polka-dot {
    --dot-spacing: 20px;
    --dot-fade-offset: 0%;
    --dot-color: #fc466b;
    background: paint(polka-dot-fade);

    /* 其他樣式... */
}

.polka-dot:hover, .polka-dot:focus {
    animation: pulse 2s ease-out 6 alternate;

    /* 其他樣式... */
}

@keyframes pulse {
    from {
        --dot-fade-offset: 0%;
        --dot-color: #fc466b;
    }
    to {
        --dot-fade-offset: 100%;
        --dot-color: #3f5efb;
    }
}

最終效果如下,完整程式碼在此

banner.54e9e80d1008.gif

看到 houdini 的潛力了吧!是不是酷斃了,paint worlets + 自定義屬性的組合將會給動畫帶來革命!

優點和缺點

讓我們再回顧一下 Houdini 的優點(著重回顧本篇大量用到的 CSS Paint API):

  • 不受限制,開發者能創造各種各樣的視覺效果。
  • 不需要新增 DOM 節點。
  • 在瀏覽器渲染管道中執行,效率高。
  • 比起 polyfill,更加效能友好,也更健壯。
  • 這是瀏覽器原生支援的介面,開發者能有不用 hack 的選擇了。
  • 用於實現視覺效果的 CSS 常常被詬病不像一門程式語言,幾乎無法表達完整的邏輯。那現在可以用 paint worklet 編寫視覺效果上的邏輯了。
  • 動畫革命。
  • 快瀏覽器廠商一步實現特性,而且這些特效能實實在在地展現在使用者的裝置上。
  • 五大瀏覽器廠商都表示支援 Houdini。

當然了,缺點也不能避而不談:

  • Houdini 的實現之路漫漫。
  • 雖然它可以緩解相容問題,但首先,瀏覽器們得先相容 Houdini…
  • 瀏覽器載入 paint worklet 並執行它需要時間,這是非同步的,可能導致樣式上的閃動。
  • 開發者工具尚不支援 paint worklet 的斷點除錯(也不支援 debugger),不過 console.log()還能用。

結論

Houdini 將會改變我們現在編寫 CSS 的方式。雖然可能它將歷時不短,但從目前可用的部分(比如,Paint API)來看,潛力驚人。所以,請繼續關注 Houdini 啊~

本文中用到的 demo 都在 Github 上了。更多效果請移步 @iamvdo 的作品

原文釋出時間為:2018年03月07日
本文作者:Will Boyd
本文來源:前端外刊 如需轉載請聯絡原作者


相關文章