現代 CSS 高階技巧,不規則邊框解決方案

chokcoco 發表於 2022-12-18
CSS

本文是 CSS Houdini 之 CSS Painting API 系列第四篇。

在上三篇中,我們詳細介紹了 CSS Painting API 是如何一步一步,實現自定義圖案甚至實現動畫效果的!

在這一篇中,我們將繼續探索,嘗試使用 CSS Painting API,去實現過往 CSS 中非常難以實現的一個點,那就是如何繪製不規則圖形的邊框。

CSS Painting API

再簡單快速的過一下,什麼是 CSS Painting API。

CSS Painting API 是 CSS Houdini 的一部分。而 Houdini 是一組底層 API,它們公開了 CSS 引擎的各個部分,從而使開發人員能夠透過加入瀏覽器渲染引擎的樣式和佈局過程來擴充套件 CSS。Houdini 是一組 API,它們使開發人員可以直接訪問 CSS 物件模型 (CSSOM),使開發人員可以編寫瀏覽器可以解析為 CSS 的程式碼,從而建立新的 CSS 功能,而無需等待它們在瀏覽器中本地實現。

CSS Paint API 目前的版本是 CSS Painting API Level 1。它也被稱為 CSS Custom Paint 或者 Houdini's Paint Worklet。

我們可以把它理解為 JS In CSS,利用 JavaScript Canvas 畫布的強大能力,實現過往 CSS 無法實現的功能。

過往 CSS 實現不規則圖形的邊框方式

CSS 實現不規則圖形的邊框,一直是 CSS 的一個難點之一。在過往,雖然我們有很多方式利用 Hack 出不規則圖形的邊框,我在之前的多篇文章中有反覆提及過:

我們來看看這樣一個圖形:

現代 CSS 高階技巧,不規則邊框解決方案

利用 CSS 實現這樣一個圖形是相對簡單的,可以利用 mask 或者 background 中的漸變實現,像是這樣:

<div class="arrow-button"></div>
.arrow-button {
    position: relative;
    width: 180px;
    height: 64px;
    background: #f49714;

    &::after {
        content: "";
        position: absolute;
        width: 32px;
        height: 64px;
        top: 0;
        right: -32px;
        background: 
            linear-gradient(-45deg, transparent 0, transparent 22px, #f49714 22px, #f49714 100%),
            linear-gradient(-135deg, transparent 0, transparent 22px, #f49714 22px, #f49714 100%);
        background-size: 32px 32px;
        background-repeat: no-repeat;
        background-position: 0 bottom, 0 top;
    }
}

但是,如果,要實現這個圖形,但是隻有一層邊框,利用 CSS 就不那麼好實現了,像是這樣:

image

在過往,有兩種相對還不錯的方式,去實現這樣一個不規則圖形的邊框:

  1. 藉助 filter,利用多重 drop-shadow()
  2. 藉助 SVG 濾鏡實現

我們快速回顧一下這兩個方法。

藉助 filter,利用多重 drop-shadow() 實現不規則邊框

還是上面的圖形,我們利用多重 drop-shadow(),可以大致的得到它的邊框效果。程式碼如下:

div {
    position: relative;
    width: 180px;
    height: 64px;
    background: #fff;

    &::after {
        content: "";
        position: absolute;
        width: 32px;
        height: 64px;
        top: 0;
        right: -32px;
        background: 
            linear-gradient(-45deg, transparent 0, transparent 22px, #fff 22px, #fff 100%),
            linear-gradient(-135deg, transparent 0, transparent 22px, #fff 22px, #fff 100%);
        background-size: 32px 32px;
        background-repeat: no-repeat;
        background-position: 0 bottom, 0 top;
    }
}
div {
    filter: 
        drop-shadow(0px 0px .5px #000)
        drop-shadow(0px 0px .5px #000)
        drop-shadow(0px 0px .5px #000);
}

可以看到,這裡我們透過疊加 3 層 drop-shadow(),來實現不規則圖形的邊框,雖然 drop-shadow() 是用於生成陰影的,但是多層值很小的陰影疊加下,竟然有了類似於邊框的效果:

image

藉助 SVG 濾鏡實現實現不規則邊框

另外一種方式,需要掌握比較深的 SVG 濾鏡知識。透過實現一種特殊的 SVG 濾鏡,再透過 CSS 的 filter 引入,實現不規則邊框。

看看程式碼:

<div></div>

<svg width="0" height="0">
    <filter id="outline">
        <feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="1"></feMorphology>
        <feMerge>
            <feMergeNode in="DILATED" />
            <feMergeNode in="SourceGraphic" />
        </feMerge>
    </filter>
</svg>
div {
    position: relative;
    width: 180px;
    height: 64px;
    background: #fff;

    &::after {
        content: "";
        position: absolute;
        width: 32px;
        height: 64px;
        top: 0;
        right: -32px;
        background: 
            linear-gradient(-45deg, transparent 0, transparent 22px, #fff 22px, #fff 100%),
            linear-gradient(-135deg, transparent 0, transparent 22px, #fff 22px, #fff 100%);
        background-size: 32px 32px;
        background-repeat: no-repeat;
        background-position: 0 bottom, 0 top;
    }
}
div {
    filter: url(#outline);
}

簡單淺析一下這段 SVG 濾鏡程式碼:

  1. <feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="1"></feMorphology> 將原圖的不透明部分作為輸入,採用了 dilate 擴張模式且程度為 radius="1",生成了一個比原圖大 1px 的黑色圖塊
  2. 使用 feMerge 將黑色圖塊和原圖疊加在一起
  3. 可以透過控制濾鏡中的 radius="1" 來控制邊框的大小

這樣,也可以實現不規則圖形的邊框效果:

image

CodePen Demo -- 3 ways to achieve unregular border

利用 CSS Painting API 實現不規則邊框

那麼,到了今天,利用 CSS Painting API ,我們有了一種更為直接的方式,更好的解決這個問題。

還是上面的圖形,我們利用 clip-path 來實現一下。

<div></div>
div {
    position: relative;
    width: 200px;
    height: 64px;
    background: #f49714;
    clip-path: polygon(85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;); 
}

我們可以得到這樣一個圖形:

image

當然,本文的主角是 CSS Painting API,既然我們有 clip-path 的引數,其實完全也可以利用 CSS Painting API 的 borderDraw 來繪製這個圖形。

我們嘗試一下,改造我們的程式碼:

<div></div>
<script>
if (CSS.paintWorklet) {              
   CSS.paintWorklet.addModule('/CSSHoudini.js');
}
</script>
div {
    position: relative;
    width: 200px;
    height: 64px;
    background: paint(borderDraw);
    --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;); 
}

這裡,我們將原本的 clip-path 的具體路徑引數,定義為了一個 CSS 變數 --clipPath,傳入我們要實現的 borderDraw 方法中。整個圖形效果,就是要利用 background: paint(borderDraw) 繪製出來。

接下來,看看,我們需要實現 borderDraw。核心的點在於,我們透過拿到 --clipPath 引數,解析它,然後透過迴圈函式利用畫布把這個圖形繪製出來。

// CSSHoudini.js 檔案
registerPaint(
    "borderDraw",
    class {
        static get inputProperties() {
            return ["--clipPath"];
        }

        paint(ctx, size, properties) {
            const { width, height } = size;
            const clipPath = properties.get("--clipPath");
            const paths = clipPath.toString().split(",");
            const parseClipPath = function (obj) {
                const x = obj[0];
                const y = obj[1];
                let fx = 0,
                    fy = 0;
                if (x.indexOf("%") > -1) {
                    fx = (parseFloat(x) / 100) * width;
                } else if (x.indexOf("px") > -1) {
                    fx = parseFloat(x);
                }
                if (y.indexOf("%") > -1) {
                    fy = (parseFloat(y) / 100) * height;
                } else if (y.indexOf("px") > -1) {
                    fy = parseFloat(y);
                }
                return [fx, fy];
            };

            var p = parseClipPath(paths[0].trim().split(" "));
            ctx.beginPath();
            ctx.moveTo(p[0], p[1]);
            for (var i = 1; i < paths.length; i++) {
                p = parseClipPath(paths[i].trim().split(" "));
                ctx.lineTo(p[0], p[1]);
            }
            ctx.closePath();            
            ctx.fill();
        }
    }
);

簡單解釋一下上述的程式碼,注意其中最難理解的 parseClipPath() 方法的解釋。

  1. 首先我們,透過 properties.get("--clipPath"),我們能夠拿到傳入的 --clipPath 引數
  2. 透過 spilt() 方法,將 --clipPath 分成一段段,也就是我們的圖形實際的繪製步驟
  3. 這裡有一點非常重要,也就是 parseClipPath() 方法,由於我們的 -clipPath 的每一段可能是 100% 50% 這樣的構造,但是實際在繪圖的過程中,我們需要的實際座標的絕對值,譬如在一個 100 x 100 的畫布上,我們需要將 50% 50% 的百分比座標,轉化為實際的 50 50 這樣的絕對值
  4. 在理解了 parseClipPath() 後,剩下的就都非常好理解了,我們透過 ctx.beginPath()ctx.movectx.lineTo 以及 ctx.closePath() 將整個 --clipPath 的圖形繪製出來
  5. 最後,利用 ctx.fill() 給圖形上色

這樣,我們就得到了這樣一個圖形:

image

都拿到了完整的圖形了,那麼我們只給這個圖形繪製邊框,不上色,不就得到了它的邊框效果了嗎?

簡單改造一些 JavaScript 程式碼的最後部分:

// CSSHoudini.js 檔案
registerPaint(
    "borderDraw",
    class {
        static get inputProperties() {
            return ["--clipPath"];
        }
        paint(ctx, size, properties) {
            // ...
            ctx.closePath();            
            // ctx.fill();
            ctx.lineWidth = 1;
            ctx.strokeStyle = "#000";
            ctx.stroke();
        }
    }
);

這樣,我們就得到了圖形的邊框效果:

image

僅僅利用 background 繪製的缺陷

但是,僅僅利用 [bacg](background: paint(borderDraw)) 來繪製邊框效果,會有一些問題。

上述的圖形,我們僅僅賦予了 1px 的邊框,如果我們把邊框改成 5px 呢?看看會發生什麼?

// CSSHoudini.js 檔案
registerPaint(
    "borderDraw",
    class {
        static get inputProperties() {
            return ["--clipPath"];
        }
        paint(ctx, size, properties) {
            // ...
            ctx.lineWidth = 5;
            ctx.strokeStyle = "#000";
            ctx.stroke();
        }
    }
);

此時,整個圖形會變成:

image

可以看到,沒有展示完整的 5px 的邊框,這是由於整個畫布只有元素的高寬大小,而上述的程式碼中,元素的邊框有一部分繪製到了畫布之外,因此,整個圖形並非我們期待的效果。

因此,我們需要換一種思路解決這個問題,繼續改造一下我們的程式碼,僅僅需要改造 CSS 程式碼即可:

div {
    position: relative;
    width: 200px;
    height: 64px;
    margin: auto;
    clip-path: polygon(var(--clipPath)); 
    --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;
    
    &::before {
      content:"";
      position:absolute;
      inset: 0;
      mask: paint(borderDraw);
      background: #000;
    }
}

這裡,我們的元素本身,還是利用了 clip-path: polygon(var(--clipPath)) 剪下了自身,同時,我們藉助了一個偽元素,利用這個偽元素去實現具體的邊框效果。

這裡其實用了一種內外切割的思想,去實現的邊框效果:

  1. 利用父元素的 clip-path: polygon(var(--clipPath)) 剪下掉外圍的圖形
  2. 利用給偽元素的 mask 作用實際的 paint(borderDraw) 方法,把圖形的內部鏤空,只保留邊框部分

還是設定 ctx.lineWidth = 5,再看看效果:

image

看上去不錯,但是實際上,雖然設定了 5px 的邊框寬度,但是實際上,上圖的邊框寬度只有 2.5px 的,這是由於另外一點一半邊框實際上被切割掉了。

因此,我們如果需要實現 5px 的效果,實際上需要 ctx.lineWidth =10

當然,我們可以透過一個 CSS 變數來控制邊框的大小:

div {
    position: relative;
    width: 200px;
    height: 64px;
    margin: auto;
    clip-path: polygon(var(--clipPath)); 
    --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;
    --borderWidth: 5;
    
    &::before {
      content:"";
      position:absolute;
      inset: 0;
      mask: paint(borderDraw);
      background: #000;
    }
}

在實際的 borderDraw 函式中,我們將傳入的 --borderWidth 引數,乘以 2 使用就好:


registerPaint(
    "borderDraw",
    class {
        static get inputProperties() {
            return ["--clipPath", "--borderWidth"];
        }
        paint(ctx, size, properties) {
            const borderWidth = properties.get("--borderWidth");
            // ...
            ctx.lineWidth = borderWidth * 2;
            ctx.strokeStyle = "#000";
            ctx.stroke();
        }
    }
);

這樣,我們每次都能得到我們想要的邊框長度:

image

CodePen Demo -- CSS Hudini & Unregular Custom Border

到這裡,整個實現就完成了,整個過程其實有多處非常關鍵的點,會有一點點難以理解,具體可能需要自己實際除錯一遍找到實現的原理。

具體應用

在掌握了上述的方法後,我們就可以利用這個方式,實現各類不規則圖形的邊框效果,我們只需要傳入對於的 clip-path 引數以及我們想要的邊框長度即可。

好,這樣,我們就能實現各類不同的不規則圖形的邊框效果了。

像是這樣:

div {
    position: relative;
    width: 200px;
    height: 200px;
    clip-path: polygon(var(--clipPath)); 
    --clipPath: 0% 15%, 15% 15%, 15% 0%, 85% 0%, 85% 15%, 100% 15%, 100% 85%, 85% 85%, 85% 100%, 15% 100%, 15% 85%, 0% 85%;
    --borderWidrh: 1;
    --color: #000;
    
    &::before {
      content:"";
      position:absolute;
      inset: 0;
      mask: paint(borderDraw);
      background: var(--color);
    }
}

div:nth-child(2) {
    --clipPath: 50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%;
    --borderWidrh: 2;
    --color: #ffcc00;
}
div:nth-child(3) {
    --clipPath: 90% 58%90% 58%, 69% 51%, 69% 51%, 50% 21%, 50% 21%, 39% 39%, 39% 39%, 15% 26%, 15% 26%, 15% 55%, 15% 55%, 31% 87%, 31% 87%, 14% 84%, 14% 84%, 44% 96%, 44% 96%, 59% 96%, 59% 96%, 75% 90%, 75% 90%, 71% 83%, 71% 83%, 69% 73%, 69% 73%, 88% 73%, 88% 73%, 89% 87%, 89% 87%, 94% 73%, 94% 73%;
    --borderWidrh: 1;
    --color: deeppink;
}
div:nth-child(4) {
    --clipPath: 0% 0%, 100% 0%, 100% 75%, 75% 75%, 75% 100%, 50% 75%, 0% 75%;
    --borderWidrh: 1;
    --color: yellowgreen;
}
div:nth-child(5) {
    --clipPath: 20% 0%, 0% 20%, 30% 50%, 0% 80%, 20% 100%, 50% 70%, 80% 100%, 100% 80%, 70% 50%, 100% 20%, 80% 0%, 50% 30%;
    --borderWidrh: 3;
    --color: #c7b311;
}

得到不同圖形的邊框效果:

image

CodePen Demo -- CSS Hudini & Unregular Custom Border

又或者是基於它們,去實現各類按鈕效果,這種效果在以往使用 CSS 是非常非常難實現的:

image

它們的核心原理都是一樣的,甚至加上 Hover 效果,也是非常的輕鬆:

現代 CSS 高階技巧,不規則邊框解決方案

完整的程式碼,你可以戳這裡:CodePen Demo -- https://codepen.io/Chokcoco/pen/ExRLqdO

至此,我們再一次利用 CSS Painting API 實現了我們過往 CSS 完全無法實現的效果。這個也就是 CSS Houdini 的魅力,是 JS In CSS 的魅力。

相容性?

好吧,其實上一篇文章也談到了相容問題,因為可能有很多看到本篇文章並沒有去翻看前兩篇文章的同學。那麼,CSS Painting API 的相容性到底如何呢?

CanIUse - registerPaint 資料如下(截止至 2022-11-23):

image

Chrome 和 Edge 基於 Chromium 核心的瀏覽器很早就已經支援,而主流瀏覽器中,Firefox 和 Safari 目前還不支援。

CSS Houdini 雖然強大,目前看來要想大規模上生產環境,仍需一段時間的等待。讓我們給時間一點時間!

最後

好了,本文到此結束,希望本文對你有所幫助 😃

如果還有什麼疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。