CSS بدون درد با جادوی Houdini

CSS بدون درد با جادوی Houdini

در سالهایی نه چندان دور، پیاده کردن یک صفحه HTML با CSS کاری بسیار سخت و پرزحمت برای توسعه‌دهندگان بود. امروز دیگر آن روزهای سخت گذشته‌اند و به تاریخ پیوسته‌اند. اما انبوهی از قابلیتها و امکانات هم در راه هستند که CSS را از این که هست فراتر خواهند برد و تجربه توسعه را برای توسعه‌دهندگان لذت‌بخش‌تر از قبل خواهند کرد. یکی از این قابلیتها CSS Houdini است که امکان گسترش دادن قابلیتهای CSS را به توسعه دهندگان می‌دهد. با CSS Houdini دیگر لازم نیست توسعه‌دهندگان منتظر مرورگرها شوند تا از یک امکان جدید در CSS پشتیبانی کنند. می‌توانند خودشان این امکان را پیاده‌سازی کنند و با CSS Houdini به کدهایشان اضافه کنند. در این پست می‌خواهیم با CSS Houdini آشنا شویم.

در سالهای نخستین وب که CSS بسیار کوچک بود و امکانات زیادی نداشت، توسعه‌دهندگان برای پیاده‌سازی طرح دلخواه خود ناچار بودند که به حقه‌ها و کَلک‌های مختلفی متوسل شوند. به عنوان مثال ساختن یک مستطیل با گوشه‌های گرد با CSS نشدنی بود و برای این کار از عکس گوشه گرد در گوشه‌های اِلِمان Div استفاده می‌کردند. قرار دادن یک عنصر در مرکز یک عنصر دیگر به صورت افقی و عمودی واقعاً چالش‌برانگیز بود. کنار هم قرار دادن سه ستون با محتوای متفاوت کنار هم طوری که ارتفاع ستون‌ها برابر باشد غیرممکن به نظر می‌رسید.

حتی بعدها که قابلیتهای جدیدی به CSS اضافه شدند که انجام این کارها را امکان‌پذیر می‌ساختند، توسعه دهندگان باید منتظر می‌ماندند تا کاربران به مرورگرهای جدیدی مهاجرت کنند که از این قابلیتها پشتیبانی کنند. خلاصه این که توسعه‌دهندگان باید قابلیتهای مورد نیاز خود را که در CSS پشتیبانی نمی‌شدند polyfill می‌کردند یعنی سعی می‌کردند با امکانات موجود در مرورگر قابلیت مورد نظر خود را پیاده‌کنند. با توجه به این که در polyfill کردن، از JavaScript برای انجام محاسبات استفاده می‌شد یا اِلِمان‌های DOM بیشتری به صفحه اضافه کنند، polyfill کردن می‌توانست به عنوان مثال با بلاک کردن main thread، اثر منفی روی عملکرد سایت داشته باشد.

با CSS Houdini دیگر لازم نیست توسعه‌دهندگان، قابلیت‌های مورد نیاز خود را polyfill کنند و وبسایت خود را با خطر بد شدن performance مواجه سازند. آنها می‌توانند قابلیت‌های مورد نیازشان را یک‌راست به CSS اضافه کنند و از بهبودهایی که موتور رندر مرورگر هنگام رندر صفحه و تبدیل CSS به تصویر می‌دهد بهره‌مند شوند.

قبل از اینکه به سراغ CSS Houdini برویم بیایید ببینیم مرورگر چطور صفحه وبسایت شما را رندر می‌کند و آن را به تصویر روی نمایشگر تبدیل می‌کند.

مرورگر برای رندر کردن صفحه، مسیری را طی می‌کند که به آن Critical Rendering Path می‌گویند. در این مسیر، پس از آن که مرورگر فایل HTML را دریافت کرد آن را parse می‌کند و DOM و CSSOM را می‌سازد. DOM و CSSOM دو ساختار درختی هستند که اولی ساختار اِلمان‌های صفحه و دومی اطلاعات مربوط به نمایش آنها را نشان می‌دهند. مرورگر سپس با ترکیب این دو ساختار درختی، استایلهای لازم برای نمایش هر عنصر صفحه را محاسبه می‌کند، بعد چیدمان یا Layout صفحه را حساب می‌کند تا بداند هر عنصر را کجای صفحه قرار دهد، بعد مرحله paint را اجرا می‌کند یعنی رنگ و تصویر پس‌زمینه را برای عنصرها و لایه‌های مختلف حساب می‌کند و در نهایت در مرحله Compose، لایه‌های مختلف صفحه را با هم ترکیب می‌کند.

هربار که JavaScript، DOM یا CSSOM را تغییر می‌دهد این گامهای چهارگانه یعنی Style، Layout، Paint و Compose تکرار می‌شوند تا تغییرات جدید را منعکس کنند. البته بسته به نوع تغییر لزوماً همه این گامها تکرار نخواهند شد. ممکن است یک تغییر در CSS باعث اجرای Paint نشود و فقط Compose را اجرا کند. به عنوان مثال تغییر transform در CSS اینگونه است.

https://developers.google.com/web/fundamentals/performance/rendering

حال تصور کنید که ما به render engine یا موتور رندر مرورگر دسترسی داشتیم و می‌توانستیم به آن بگوییم در هریک از این مراحل چکار کند. در این صورت نیازی به polyfill کردن با دستکاری DOM و بلاک کردن main thread با اسکریپتهای پرهزینه نبود. دسترسی به موتور رندر مرورگر دقیقا همان کاری است که CSS Houdini می‌کند.

CSS Houdini مجموعه‌ای از API ها را در اختیار ما قرار می‌دهد که دسترسی به render engine مرورگر را برای ما فراهم می‌کنند. به این ترتیب می‌توانیم اسکریپتهای شخصی خودمان را تعریف کنیم و به موتور رندر مرورگر بگوییم که در مراحل مختلف رندر صفحه (style، layout، pain و composite) آنها را اجرا کند.

CSS Houdini با کمک worklet ها این دسترسی را برای ما ایجاد می‌کند. worklet ها مشابه Service Worker ها هستند از این نظر که این امکان را دارند که خارج از main thread اجرا شوند و آن را بلاک نکنند. worklet ها باید در یک فایل جداگانه تعریف شوند و به عنوان ماژول در اسکریپتی که قرار است مورد استفاده قرار بگیرند ثبت شوند.

https://houdini.glitch.me/worklets

worklet ها انواع مختلفی دارند و موتور رندر مرورگر با توجه به نوع worklet، آن را در یکی از مراحل مختلف رندر صفحه اجرا می‌کند.

انواع worklet ها عبارتند از:

  • PaintWorklet که در مرحله paint اجرا می‌شود.
  • LayoutWorklet که در مرحله layout اجرا می‌شود و برای تعریف اندازه و چیدمان عناصر مختلف در صفحه به کار می‌رود.
  • AnimationWorklet که برای تعریف انیمیشن‌های با پرفورمنس بالا در صفحه به کار می‌رود که می‌توانند وابسته به اسکرول یا زمان باشند. این worklet در مرحله compose اجرا می‌شود.

یک API دیگر که با CSS Houdini در اختیار ما قرار می‌گیرد CSS Properties and Values API نام دارد که به ما این امکان را می‌دهد که Custom CSS Property هایی را که در استایلهای خود به کار می‌بریم با کمک JavaScript به طور دقیق‌تر تعریف کنیم و ویژگی‌های آنها را به طور دقیق‌تر مشخص کنیم و به عنوان مثال می‌توانیم تعیین کنیم نوع مقداری که یک CSS Custom Property می‌پذیرد، اندازه یا زاویه یا رنگ یا درصد یا عدد و غیره یا ترکیبی از آنها باشد. لیست کامل انواع مقادیر CSS Property ها را اینجا ببینید.

در ادامه هرکدام از این API ها را با جزییات بیشتری توضیح می‌دهیم و کاربرد آنها را با چند مثال نشان می‌دهیم. کد کامل مربوط به مثال‌ها را می‌توانید اینجا ببینید.

CSS Properties and Values API

این API این امکان را به ما می‌دهد که با استفاده از جاوااسکریپت، Custom CSS Property را که در استایلهای خود به کار می‌بریم به طور دقیق و صریح تعریف کنیم.

یک Custom CSS Property در CSS با دو دَش (--*) در ابتدای نام آن Property تعریف می‌شود. به عنوان مثال در قطعه کد زیر:

.box {
    --box-color: red;
}

--box-color یک Custom CSS Property است. اما ما به صراحت تعریف نکرده‌ایم که این Property چه نوع مقادیری را می‌تواند بپذیرد. به عنوان مثال به جای رنگ می‌توانیم اندازه یا زاویه یا url یا هرچیز دیگری را به عنوان مقدار این property وارد کنیم و مرورگر هم هیچ تصوری ندارد که چه مقادیری برای ذخیره شدن در این CSS Property پذیرفتنی نیستند.

با استفاده از CSS Properties and Values API می‌توانیم ویژگی‌های دقیق این Custom CSS Property را به شکل زیر تعریف کنیم:

CSS.registerProperty({
    name: '--box-color',
    syntax: '<color>',
    inherits: 'true',
    initialValue: 'blue'
});

اما چه لزومی دارد مرورگر بداند که یک Custom CSS Property چه نوع مقادیری را می‌پذیرد؟ مهم‌ترین کاربرد این مساله انیمیشن است. در انیمیشن‌های CSS ما تعیین می‌کنیم که مقدار یک property در یک بازه زمانی یا در فاصله بین دو key frame، از چه مقداری به چه مقداری برود. اگر مرورگر نداند که نوع مقادیر یک CSS Property که انیمیشن روی آن انجام می‌شود چیست نمی‌تواند مقدار آن property را در بازه بین دو key frame محاسبه یا interpolate کند و مقدار آن property صرفاً بین مقدار اولیه و نهایی پرش خواهد کرد. بنابراین برای آنکه مرورگر بتواند یک CSS Property را متحرک کند و مقدارش را در transition های مختلف interpolate کند باید آن Custom Property را به صراحت با استفاده از Properties and Values API تعریف کنیم.

PaintWorklet

PaintWorklet به شما اجازه می‌دهد تا برای مرورگر تعریف کنید که در مرحله paint چه چیزی را در اِلمان HTML رسم کند.

برای تعریف یک PaintWorklet کافی است که در یک فایل جداگانه یک کلاس بسازید که متد paint را پیاده سازی کند. هربار که لازم باشد محتوای یک اِلمان HTML با استفاده از این worklet دوباره رسم شود، مرورگر متد paint را اجرا می‌کند. بعد از تعریف این کلاس، باید آن را به عنوان یک PaintWorklet ثبت کنید و به آن یک اسم بدهید:

class MyPainter {
    paint (context, geometry, properties) {
        // ...
    }
}

registerPaint('myPainter', MyPainter);

متد paint سه آرگومان به عنوان ورودی از سمت مرورگر دریافت می‌کند. context مشابه همان context دوبعدی است که در canvas برای رسم شکل‌های دوبعدی مورد استفاده قرار می‌گرفت و API یی مشابه آن دارد. این context سطح اِلمان HTML را در اختیار شما قرار می‌دهد تا بتوانید هرچه می‌خواهید در آن رسم کنید. geometry ابعاد و اندازه‌های مربوط به context را در اختیارتان می‌گذارد. ممکن است چیزی که می‌خواهید در اِلمان HTML خود رسم کنید به CSS Property های دیگر این اِلمان بستگی داشته باشد. آرگومان سوم یعنی properties این CSS Property ها را در دسترس worklet شما قرار می‌دهد.

بعد از آنکه PaintWorklet را تعریف کردید باید در اسکریپتی که می‌خواهید از آن استفاده کنید این worklet را به عنوان ماژول ثبت کنید:

CSS.paintWorklet.addModule('./my-paint-module.js');

و در استایلهای CSS خود با صدا زدن تابع paint که یک تابع CSS است، به این صورت استفاده کنید:

.box {
    background-image: paint(myPainter);
}

در استایلهای CSS هر جا که امکان استفاده از image به عنوان تصویر پس‌زمینه بود می‌توانید از paint(myPainter) استفاده کنید و مرورگر با رسیدن به تابع paint از worklet شما برای رندر کردن اِلمان HTML استفاده خواهد کرد.

به عنوان مثال می‌خواهیم یک PaintWorklet تعریف کنیم که اِلمان HTML ما را تبدیل به یک progress bar کند. این worklet از روی استایلهای تعریف شده برای اِلمان HTML ی که می‌خواهیم تبدیل به progress bar شود --progress را می‌خواند و بر حسب مقدار آن، میزان پیشرفت را روی اِلمان مورد نظر به صورت نوار رسم می‌کند.

برای این کار، worklet را به صورت زیر در یک فایل جداگانه تعریف می‌کنیم:

class ProgressBarPaint {
    static get inputProperties() {
        return ['--progress'];
    }

    paint (ctx, geometry, properties) {
        const { width, height } = geometry;
        const progress = properties.get('--progress');
        
        // Draw the progress rectangle
        ctx.beginPath();
        ctx.rect(0, 0, width * progress / 100, height);
        ctx.fillStyle = "#419cd9";
        ctx.fill();

        // Draw border of the progress bar
        ctx.beginPath();
        ctx.rect(0, 0, width, height);
        ctx.lineWidth = "4";
        ctx.strokeStyle = "#06578f";
        ctx.stroke();
    }
}

registerPaint('progressPaint', ProgressBarPaint);

همانطور که می‌بینید، یک متد استاتیک به اسم inputProperties هم تعریف شده است که یک آرایه از رشته‌ها برمی‌گرداند که --progress تنها عضو آن است. از میان CSS Property های اِلمان HTML که این worklet روی آن کار می‌کند تنها آنهایی به متد paint پاس داده می‌شوند که متد inputProperties آنها را برگرداند.

برای استفاده از این worklet باید آن را در اسکریپت مورد نظرمان به عنوان ماژول اضافه کنیم:

if (CSS && CSS.paintWorklet) {
    CSS.paintWorklet.addModule('./worklets/progress-bar.js');
}

البته قبل از آن چک کردیم که مرورگر از PaintWorklet ها پشتیبانی کند.

بعد از اضافه کردن در اسکریپت، می‌توانیم به شکل زیر در CSS از آن استفاده کنیم:

.progress-bar {
    --progress: 30;
    background-image: paint(progressPaint);
    height: 20px;
    max-width: 500px;
    width: 100%;
    margin: 0 auto;
}

حال می‌توانیم --progress را برای اِلمان HTML ی که با کلاس .progress-bar مشخص شده است از طریق JavaScript یا از طریق DevTools در مرورگر تغییر دهیم. از آنجا که worklet ما به --progress وابسته است، با تغییر --progress به طور خودکار، repaint اتفاق می‌افتد و نوار رندر شده به روز می‌شود.

در حال حاضر مرورگر نمی‌داند که --progress چه نوع مقادیری می‌تواند داشته باشد بنابراین هیچ نوع انیمیشنی روی این property نمی‌تواند اعمال کند. به عنوان مثال اگر شما یک transition برای این progress bar تعریف کنید و --progress را از 0 به 100 تغییر دهید عرض نوار از 0 به 100 درصد پرش می‌کند. برای ‌آن که بتوانید انیمیشن هم داشته باشید باید با استفاده از Properties and Values API، این property را به طور صریح تعریف کنید:

if (CSS && CSS.registerProperty) {
    CSS.registerProperty({
        name: '--progress',
        syntax: '<number>',
        initialValue: 0,
        inherits: false,
    });
}

در این ریپازیتوری روی گیت‌هاب مثال دیگری از کاربرد PaintWorklet آورده شده است که با دادن تعداد گوشه‌ها به عنوان CSS Property می‌توانید شکل‌های ستاره‌ای مختلفی را به دست بیاورید. با مراجعه به لینک می‌توانید کدهای مربوط به آن را ببینید.

AnimationWorklet

AnimationWorklet این امکان را به شما می‌دهد تا انیمیشن‌هایی را برای اِلمان‌های مختلف HTML تعریف کنید که وابسته به تایملاین‌های مختلف باشند و به صورت بهینه و performant روی مرورگر اجرا شوند.

وقتی از تایملاین صحبت می‌کنیم منظور فقط سیر زمانی نیست. ممکن است انیمیشن شما بر حسب میزان اسکرول کردن در صفحه پیش برود. بنابراین در AnimationWorklet ها وقتی از زمان صحبت می‌شود منظور عددی نیست که ساعت نشان می‌دهد. تایملاین فقط یکجور abstraction است از بازه‌ای که می‌توان روی آن به جلو یا عقب حرکت کرد و currentTime روی این تایملاین فقط نقطه‌ای است که موقعیت فعلی روی این بازه را نشان می‌دهد، نه لزوما زمان را. حال این تایملاین می‌تواند پیاده‌سازی‌ها و implementation های مختلفی داشته باشد که چگونگی حرکت currentTime روی این بازه را مشخص کند. currentTime می‌تواند در یک پیاده‌سازی با اسکرول کردن روی تایملاین حرکت کند و در پیاده‌سازی دیگر می‌تواند با گذشت زمان واقعی به پیش برود.

برای تعریف یک AnimationWorklet باید در یک فایل جداگانه، یک کلاس تعریف کنیم که متد animate را پیاده‌سازی کند و بعد آن کلاس را با یک اسم به عنوان AnimationWorklet ثبت کنیم:

registerAnimator('scrollanimate', class {
    animate (currentTime, effect) {
        effect.localTime = currentTime;
    }
});

در متد animate پارامتر currentTime نشان دهنده میزان پیشرفت روی تایملاینی است که انیمیشن روی آن اجرا می‌شود (که می‌تواند وابسته به اسکرول یا زمان باشد). و پارامتر effect نشان‌دهنده ویژگی و property ای است که متحرک‌سازی روی آن انجام می‌شود. این property می‌تواند translation یا rotation یا هر property دیگری باشد که باعث repaint در مرورگر نشود چون این انیمیشن‌ها باید در مرحله compose اتفاق بیفتند.

هر effect یک localTime دارد که نشان می‌دهد انیمیشنی که برای آن effect خاص تعریف شده است چقدر پیش رفته است. مثلا اگر تایملاینی که انیمیشن روی آن اجرا می‌شود اسکرول باشد و بخواهیم با اسکرول کردن ما انیمیشنی که روی اِلمان مورد نظر تعریف کرده‌ایم پیش برود همین کافی است که localTime را برابر با currentTime قرار دهیم.

بعد از آن که AnimationWorklet را در یک فایل جداگانه تعریف کردیم باید آن را به عنوان یک ماژول به اسکریپت خودمان اضافه کنیم:

if (CSS && CSS.animationWorklet) {
        CSS.animationWorklet.addModule("./worklets/animate.js").then(() => {
            // Do something
        });
}

addModule یک promise به ما برمی‌گرداند که بعد از resolve شدن آن می‌توانیم از AnimationWorklet ی که تعریف کردیم برای animate کردن اِلمان‌های HTML در صفحه استفاده کنیم.

به عنوان مثال با قطعه کد زیر می‌توانیم یک اِلمان را به اسکرول صفحه وصل کنیم طوری که با اسکرول کردن صفحه، اِلمان مورد نظر هم بچرخد:

if (CSS && CSS.animationWorklet) {
        CSS.animationWorklet.addModule("./worklets/animate.js").then(() => {
            new WorkletAnimation(
                'scrollanimate',
                new KeyframeEffect(
                    document.querySelector('.gear-3'),
                    [
                        { transform: 'rotate(0deg)' },
                        { transform: 'rotate(360deg)' }
                    ],
                    {
                        duration: 1000,
                        fill: 'both',
                        iterations: Infinity,
                    }
                ),
                new ScrollTimeline({
                    scrollSource: document.documentElement,
                    orientation: "vertical", // "horizontal" or "vertical".
                    timeRange: 100,
                }),
            ).play();
        });
}

همانطور که در کد بالا می‌بینید برای استفاده از AnimationWorklet باید یک نمونه از WorkletAnimation بسازیم. Constructor این کلاس سه پارامتر به عنوان ورودی می‌گیرد. پارامتر اول نامی است که AnimationWorklet با آن ثبت شده است. پارامتر دوم یک نمونه از KeyframeEffect است که مشخص می‌کند انیمیشن روی کدام اِلمان HTML و کدام property های آن انجام می‌شود و انیمیشن و تایملاین مربوط به آن افکت را مشخص می‌کند. و پارامتر سوم تایملاینی است که انیمیشن افکت را به پیش می‌برد. این تایملاین می‌تواند از روی میزان اسکرول در صفحه فعلی یا یکی از اِلمانهای آن حساب شود یا همراه با زمان مرورگر و مستقل از برهم‌کنش‌های کاربر به پیش برود. در اینجا ما یک نمونه از تایملاین اسکرول را ساخته‌ایم و به عنوان پارامتر به WorkletAnimation پاس داده‌ایم. به این ترتیب هربار اسکرول در صفحه اتفاق بیفتد، متد animate مربوط به worklet ی که تعریف کردیم صدا زده می‌شود و تایملاین مربوط به افکت مورد نظر را به روز می‌کند.

در نهایت پس از آن که نمونه WorkletAnimation را با پارامترهای داده شده ساختیم متد play از آن نمونه را صدا می‌زنیم تا اجرای انیمیشن شروع شود.

مثال کامل این worklet را می‌توانید در این ریپازیتوری Github ببینید.

نتیجه

در میان انواع worklet هایی که در این پست بررسی کردیم، PaintWorklet از بقیه پایدارتر بود و از نظر پشتیبانی وضع بهتری داشت. AnimationWorklet هم با تنظیم یک flag روی کروم که برخی قابلیت‌های آزمایشی را روی مرورگر فعال می‌کرد روی مرورگر قابل تست بود. اما در میان این worklet ها LayoutWorklet کمتر از بقیه رشد یافته بود و مرورگر هم پشتیبانی چندان مناسبی از آن نداشت و documentation و مطالب آموزشی اندکی در مورد آن روی اینترنت موجود بود به همین دلیل به آن نپرداختیم.

در حال حاضر پشتیبانی از CSS Houdini روی مرورگرها محدود است و API های مربوط به آن به پایداری کامل نرسیده‌اند و ممکن است هرلحظه با تغییراتی مواجه شوند. اما در آینده‌ای نزدیک این API ها به ثبات کافی خواهند رسید و مرورگرها پشتیبانی کافی از آنها خواهند داشت که بتوان روی Production از آنها استفاده کرد. پس از آن دریچه‌ای تازه از امکانات و قابلیتها به روی توسعه‌دهندگان باز خواهد شد. پس بد نیست به عنوان توسعه‌دهنده فرانت‌اند چشمی به تغییرات و خبرهای مربوط به CSS Houdini داشته باشیم.

کدهای مربوط به مثالهایی که در این پست آورده شده بودند به طور کامل در این لینک روی Github قرار دارند.

همچنین روی این لینک می‌توانید ویدیوی یوتیوب مربوط به ارائه این مطلب را ببینید.