worklet של Houdini'

שיפור האנימציות באפליקציית האינטרנט

בקיצור: Animation Worklet מאפשר לכם לכתוב אנימציות אימפרטיביות שפועלות בקצב הפריימים המקורי של המכשיר, כדי להשיג אנימציות חלקות במיוחד ללא גמגום. בנוסף, הוא מאפשר ליצור אנימציות עמידות יותר בפני גמגום בשרשור הראשי, ולקשר אותן לגלילה במקום לזמן. ‫Animation Worklet זמין ב-Chrome Canary (מאחורי התכונה הניסיונית Experimental Web Platform features) ואנחנו מתכננים גרסת מקור לניסיון ל-Chrome 71. אפשר להתחיל להשתמש בו כשיפור הדרגתי עוד היום.

עוד Animation API?

לא, זהו הרחבה של מה שכבר קיים, ויש לכך סיבה טובה! Let's start at the beginning. אם רוצים להנפיש אלמנט DOM כלשהו באינטרנט היום, יש 2.5 אפשרויות: CSS Transitions (מעברים ב-CSS) למעברים פשוטים מ-A ל-B,‏ CSS Animations (אנימציות ב-CSS) לאנימציות מורכבות יותר על בסיס זמן, שיכולות להיות מחזוריות, ו-Web Animations API (WAAPI) לאנימציות מורכבות כמעט באופן שרירותי. מטריצת התמיכה של WAAPI לא נראית טוב במיוחד, אבל המצב משתפר. עד אז, יש polyfill.

המשותף לכל השיטות האלה הוא שהן חסרות מצב (stateless) ומופעלות לפי זמן. אבל חלק מהאפקטים שמפתחים מנסים ליצור הם לא מבוססי-זמן ולא חסרי-מצב. לדוגמה, אפקט הגלילה המפורסם של פרלקסה מבוסס על גלילה, כפי שאפשר להבין מהשם שלו. הטמעה של גלילה עם אפקט פרלקסה באתר היום היא משימה קשה באופן מפתיע.

ומה לגבי חוסר מצב? לדוגמה, שורת כתובת האתר של Chrome ב-Android. אם גוללים למטה, הוא יוצא מהתצוגה. אבל ברגע שמגללים למעלה, הוא חוזר, גם אם הגלילה היא רק באמצע הדף. האנימציה תלויה לא רק במיקום הגלילה, אלא גם בכיוון הגלילה הקודם. היא מבוססת על מצב.

בעיה נוספת היא עיצוב של סרגלי גלילה. הן ידועות כבלתי ניתנות לעיצוב – או לפחות לא ניתנות לעיצוב מספיק. מה אם אני רוצה חתול ניאן כפס הגלילה שלי? לא משנה באיזו טכניקה תבחרו, בניית סרגל גלילה בהתאמה אישית היא לא פשוטה ולא יעילה.

הנקודה היא שכל הדברים האלה הם מביכים וקשה עד בלתי אפשרי ליישם אותם ביעילות. רובם מסתמכים על אירועים ו/או על requestAnimationFrame, מה שעשוי להגביל אתכם ל-60fps, גם אם המסך שלכם יכול לפעול ב-90fps, ב-120fps או יותר, ולהשתמש בחלק קטן מתקציב הפריימים של ה-main thread.

‫Animation Worklet מרחיב את היכולות של מחסנית האנימציות באינטרנט כדי להקל על יצירת האפקטים האלה. לפני שמתחילים, חשוב לוודא שאתם מכירים את היסודות של אנימציות.

הסבר בסיסי על אנימציות וצירי זמן

‫WAAPI ו-Animation Worklet משתמשים בטיימליין באופן נרחב כדי לאפשר לכם לתזמן אנימציות ואפקטים בצורה הרצויה. בקטע הזה נסביר בקצרה על ציר הזמן ואיך הוא פועל עם אנימציות.

כל מסמך כולל document.timeline. הוא מתחיל ב-0 כשהמסמך נוצר וסופר את המילישניות מאז שהמסמך התחיל להתקיים. כל האנימציות במסמך פועלות ביחס לציר הזמן הזה.

כדי להמחיש את הדברים, נבחן את קטע הקוד הבא של WAAPI

const animation = new Animation(
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
      {
        transform: 'translateY(500px)',
      },
    ],
    {
      delay: 3000,
      duration: 2000,
      iterations: 3,
    }
  ),
  document.timeline
);

animation.play();

כשקוראים ל-animation.play(), האנימציה משתמשת ב-currentTime של ציר הזמן כשעת ההתחלה שלה. האנימציה שלנו כוללת השהיה של 3,000 אלפיות השנייה, כלומר האנימציה תתחיל (או תהפוך ל'פעילה') כשהציר הזמני יגיע ל-`startTime

  • ‫3000. After that time, the animation engine will animate the given element from the first keyframe (translateX(0)), through all intermediate keyframes (translateX(500px)) all the way to the last keyframe (translateY(500px)) in exactly 2000ms, as prescribed by thedurationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline'scurrentTimeisstartTime + 3000 + 1000and the last keyframe atstartTime + 3000 + 2000`. הנקודה היא שציר הזמן קובע איפה אנחנו נמצאים באנימציה.

אחרי שהאנימציה מגיעה לתמונת המפתח האחרונה, היא חוזרת לתמונת המפתח הראשונה ומתחילה את האיטרציה הבאה של האנימציה. התהליך הזה חוזר על עצמו 3 פעמים כי הגדרנו iterations: 3. אם רוצים שהאנימציה לא תיפסק אף פעם, כותבים iterations: Number.POSITIVE_INFINITY. זו התוצאה של הקוד שלמעלה.

‫WAAPI הוא כלי רב עוצמה, ויש בו עוד הרבה תכונות כמו החלקה, היסטים של התחלה, משקלים של מסגרות מפתח והתנהגות מילוי, שחורגות מההיקף של המאמר הזה. אם רוצים לקבל מידע נוסף, מומלץ לקרוא את המאמר הזה על אנימציות CSS ב-CSS Tricks.

כתיבת Animation Worklet

אחרי שהבנו את הרעיון של ציר הזמן, אפשר להתחיל לבדוק את Animation Worklet ואת האופן שבו הוא מאפשר לשנות את ציר הזמן. ‫Animation Worklet API לא מבוסס רק על WAAPI, אלא הוא – במובן של האינטרנט הניתן להרחבה – פרימיטיב ברמה נמוכה יותר שמסביר איך WAAPI פועל. מבחינת התחביר, הם דומים מאוד:

Animation Worklet WAAPI
new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(500px)'
      }
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY
    }
  ),
  document.timeline
).play();
      
        new Animation(

        new KeyframeEffect(
        document.querySelector('#a'),
        [
        {
        transform: 'translateX(0)'
        },
        {
        transform: 'translateX(500px)'
        }
        ],
        {
        duration: 2000,
        iterations: Number.POSITIVE_INFINITY
        }
        ),
        document.timeline
        ).play();
        

ההבדל הוא בפרמטר הראשון, שהוא השם של ה-worklet שמפעיל את האנימציה הזו.

זיהוי תכונות

‫Chrome הוא הדפדפן הראשון שכולל את התכונה הזו, ולכן צריך לוודא שהקוד לא מסתמך רק על קיומו של AnimationWorklet. לכן, לפני טעינת ה-worklet, צריך לבדוק אם הדפדפן של המשתמש תומך ב-AnimationWorklet באמצעות בדיקה פשוטה:

if ('animationWorklet' in CSS) {
  // AnimationWorklet is supported!
}

טעינת רכיב Worklet

‫Worklets הם קונספט חדש שהוצג על ידי צוות המשימה של Houdini כדי להקל על בנייה והרחבה של הרבה ממשקי API חדשים. בהמשך נסביר יותר על worklets, אבל בשלב הזה אפשר לחשוב עליהם כעל תהליכים זולים וקלילים (כמו workers).

אנחנו צריכים לוודא שטענו worklet בשם passthrough לפני שמצהירים על האנימציה:

// index.html
await CSS.animationWorklet.addModule('passthrough-aw.js');
// ... WorkletAnimation initialization from above ...

// passthrough-aw.js
registerAnimator(
  'passthrough',
  class {
    animate(currentTime, effect) {
      effect.localTime = currentTime;
    }
  }
);

מה קורה כאן? אנחנו רושמים מחלקה כאנימטור באמצעות הקריאה registerAnimator() של AnimationWorklet, ונותנים לה את השם passthrough. זה אותו שם שבו השתמשנו בבונה WorkletAnimation() שלמעלה. אחרי שההרשמה תושלם, ההבטחה שמוחזרת על ידי addModule() תיפתר ונוכל להתחיל ליצור אנימציות באמצעות ה-worklet הזה.

השיטה animate() של המופע שלנו תקרא לכל פריים שהדפדפן רוצה לעבד, ותעביר את currentTime של ציר הזמן של האנימציה וגם את האפקט שעובר כרגע עיבוד. יש לנו רק אפקט אחד, KeyframeEffect, ואנחנו משתמשים ב-currentTime כדי להגדיר את localTime של האפקט, ולכן האנימטור הזה נקרא 'מעבר'. עם הקוד הזה של ה-worklet, ה-WAAPI וה-AnimationWorklet שלמעלה מתנהגים בדיוק אותו הדבר, כמו שאפשר לראות בהדגמה.

שעה

הפרמטר currentTime של השיטה animate() הוא currentTime של ציר הזמן שהעברנו לקונסטרוקטור WorkletAnimation(). בדוגמה הקודמת, פשוט העברנו את השעה לאפקט. אבל מכיוון שזה קוד JavaScript, אנחנו יכולים לשנות את הזמן 💫

function remap(minIn, maxIn, minOut, maxOut, v) {
  return ((v - minIn) / (maxIn - minIn)) * (maxOut - minOut) + minOut;
}
registerAnimator(
  'sin',
  class {
    animate(currentTime, effect) {
      effect.localTime = remap(
        -1,
        1,
        0,
        2000,
        Math.sin((currentTime * 2 * Math.PI) / 2000)
      );
    }
  }
);

אנחנו לוקחים את Math.sin() של currentTime וממפים מחדש את הערך לטווח [0; 2000], שהוא טווח הזמן שבו מוגדר האפקט שלנו. עכשיו האנימציה נראית שונה מאוד, בלי ששיניתי את מסגרות המפתח או את האפשרויות של האנימציה. קוד ה-worklet יכול להיות מורכב ככל שרוצים, והוא מאפשר להגדיר באופן פרוגרמטי אילו אפקטים יופעלו, באיזה סדר ובאיזו מידה.

אפשרויות מעל אפשרויות

יכול להיות שתרצו לעשות שימוש חוזר ב-Worklet ולשנות את המספרים שלו. לכן, הקונסטרוקטור WorkletAnimation מאפשר להעביר אובייקט אפשרויות ל-worklet:

registerAnimator(
  'factor',
  class {
    constructor(options = {}) {
      this.factor = options.factor || 1;
    }
    animate(currentTime, effect) {
      effect.localTime = currentTime * this.factor;
    }
  }
);

new WorkletAnimation(
  'factor',
  new KeyframeEffect(
    document.querySelector('#b'),
    [
      /* ... same keyframes as before ... */
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY,
    }
  ),
  document.timeline,
  {factor: 0.5}
).play();

בדוגמה הזו, שתי האנימציות מופעלות באמצעות אותו קוד, אבל עם אפשרויות שונות.

תגיד לי מה המצב אצלך!

כמו שרמזתי קודם, אחת הבעיות העיקריות ש-Animation Worklet נועד לפתור היא אנימציות עם מצב. ל-Animation worklets מותר להחזיק מצב. עם זאת, אחת התכונות המרכזיות של Worklets היא שאפשר להעביר אותם ל-thread אחר או אפילו להרוס אותם כדי לחסוך במשאבים, וכך גם להרוס את המצב שלהם. כדי למנוע אובדן של מצב, מודול ה-worklet של האנימציה מציע hook שנקרא לפני שמירת ה-worklet, ואפשר להשתמש בו כדי להחזיר אובייקט מצב. האובייקט הזה יועבר לקונסטרוקטור כשה-worklet ייווצר מחדש. בזמן היצירה הראשונית, הפרמטר הזה יהיה undefined.

registerAnimator(
  'randomspin',
  class {
    constructor(options = {}, state = {}) {
      this.direction = state.direction || (Math.random() > 0.5 ? 1 : -1);
    }
    animate(currentTime, effect) {
      // Some math to make sure that `localTime` is always > 0.
      effect.localTime = 2000 + this.direction * (currentTime % 2000);
    }
    destroy() {
      return {
        direction: this.direction,
      };
    }
  }
);

בכל פעם שמרעננים את הדמו הזה, יש סיכוי של 50/50 לאיזה כיוון יסתובב הריבוע. אם הדפדפן יפרק את ה-worklet ויעביר אותו לשרשור אחר, תהיה עוד קריאה ל-Math.random() בזמן היצירה, מה שעלול לגרום לשינוי פתאומי בכיוון. כדי לוודא שזה לא יקרה, אנחנו מחזירים את הכיוון שנבחר באקראי לאנימציות כמצב ומשתמשים בו בבונה, אם הוא מסופק.

התחברות לרצף הזמן-חלל: ScrollTimeline

כפי שראינו בקטע הקודם, AnimationWorklet מאפשר לנו להגדיר באופן פרוגרמטי איך התקדמות ציר הזמן משפיעה על האפקטים של האנימציה. אבל עד עכשיו, ציר הזמן שלנו תמיד היה document.timeline, שבו אפשר לעקוב אחרי הזמן.

ScrollTimeline מאפשרת לכם ליהנות מאפשרויות חדשות ולהפעיל אנימציות באמצעות גלילה במקום באמצעות זמן. נשתמש שוב ב-worklet הראשון שלנו מסוג passthrough לצורך ההדגמה הזו:

new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
    ],
    {
      duration: 2000,
      fill: 'both',
    }
  ),
  new ScrollTimeline({
    scrollSource: document.querySelector('main'),
    orientation: 'vertical', // "horizontal" or "vertical".
    timeRange: 2000,
  })
).play();

במקום להעביר את document.timeline, אנחנו יוצרים ScrollTimeline חדש. ניחשתם נכון, ScrollTimeline לא משתמש בזמן אלא במיקום הגלילה של scrollSource כדי להגדיר את currentTime ב-worklet. אם גוללים עד למעלה (או לימין), הערך הוא currentTime = 0, ואם גוללים עד למטה (או לשמאל), הערך הוא currentTime.timeRange אם גוללים את התיבה בהדגמה הזו, אפשר לשלוט במיקום של התיבה האדומה.

אם יוצרים ScrollTimeline עם רכיב שלא ניתן לגלול, ציר הזמן currentTime יהיה NaN. לכן, במיוחד כשמדובר בעיצוב רספונסיבי, תמיד צריך להיות מוכנים לכך ש-NaN יהיה currentTime. לרוב, כדאי להגדיר את ערך ברירת המחדל כ-0.

קישור אנימציות למיקום הגלילה הוא משהו שחיפשו הרבה זמן, אבל אף פעם לא הצליחו להשיג אותו ברמת הדיוק הזו (חוץ מפתרונות עקיפים מסובכים עם CSS3D). ‫Animation Worklet מאפשר להטמיע את האפקטים האלה בצורה פשוטה ועם ביצועים גבוהים. לדוגמה: אפקט גלילה פרלקסי כמו זה בהדגמה מראה שמעכשיו צריך רק כמה שורות כדי להגדיר אנימציה שמופעלת בגלילה.

הפרטים הטכניים

מודולים מסוג Worklet

‫Worklets הם הקשרים של JavaScript עם היקף מבודד וממשק API קטן מאוד. ממשק ה-API הקטן מאפשר אופטימיזציה אגרסיבית יותר מהדפדפן, במיוחד במכשירים בסיסיים. בנוסף, worklets לא קשורים ללולאת אירועים ספציפית, אבל אפשר להעביר אותם בין שרשורים לפי הצורך. זה חשוב במיוחד ל-AnimationWorklet.

Compositor NSync

יכול להיות שאתם יודעים שחלק ממאפייני ה-CSS מונפשים במהירות, ואחרים לא. חלק מהמאפיינים דורשים רק עבודה מסוימת ב-GPU כדי להנפיש אותם, בעוד שאחרים גורמים לדפדפן לסדר מחדש את כל המסמך.

ב-Chrome (כמו בדפדפנים רבים אחרים) יש תהליך שנקרא 'קומפוזיטור'. התפקיד שלו – ואני מפשט כאן מאוד – הוא לסדר שכבות ומרקמים, ואז להשתמש ב-GPU כדי לעדכן את המסך בתדירות גבוהה ככל האפשר, באופן אידיאלי מהר ככל שהמסך יכול להתעדכן (בדרך כלל 60Hz). בהתאם למאפייני ה-CSS שמונפשים, יכול להיות שהדפדפן יצטרך רק שה-compositor יבצע את העבודה שלו, בעוד שמאפיינים אחרים יצטרכו להפעיל פריסה, שהיא פעולה שרק השרשור הראשי יכול לבצע. בהתאם למאפיינים שאתם מתכננים להנפיש, ה-worklet של האנימציה יהיה קשור לשרשור הראשי או יפעל בשרשור נפרד בסנכרון עם ה-compositor.

עונש קל

בדרך כלל יש רק תהליך אחד של קומפוזיציה, שאולי משותף לכמה כרטיסיות, כי המעבד הגרפי הוא משאב שמתחרים עליו. אם מנהל החלונות נחסם מסיבה כלשהי, הדפדפן כולו נתקע ולא מגיב לקלט של המשתמש. חשוב להימנע מכך בכל מחיר. אז מה קורה אם הוורקלט לא מצליח לספק את הנתונים שהקומפוזיטור צריך בזמן כדי שהפריים יעבור רינדור?

אם זה קורה, ה-worklet יכול – בהתאם למפרט – 'לפספס' את ההזדמנות. הוא מפגר אחרי הקומפוזיטור, והקומפוזיטור רשאי לעשות שימוש חוזר בנתונים של הפריים האחרון כדי לשמור על קצב הפריימים. מבחינה ויזואלית, זה ייראה כמו ג'אנק, אבל ההבדל הגדול הוא שהדפדפן עדיין מגיב לקלט של המשתמש.

סיכום

ל-AnimationWorklet יש הרבה היבטים והוא מביא איתו יתרונות רבים לאינטרנט. היתרונות הברורים הם יותר שליטה באנימציות ודרכים חדשות להפעלת אנימציות, כדי להוסיף לאינטרנט רמה חדשה של נאמנות חזותית. אבל העיצוב של ממשקי ה-API מאפשר גם לשפר את העמידות של האפליקציה מפני תקלות, וגם לקבל גישה לכל התכונות החדשות בו-זמנית.

ה-Animation Worklet נמצא ב-Canary, ואנחנו שואפים להשיק גרסת מקור לניסיון ב-Chrome 71. אנחנו מחכים בקוצר רוח לחוויות האינטרנט החדשות והנהדרות שלכם, ונשמח לשמוע מה אפשר לשפר. יש גם polyfill שנותן לכם את אותו API, אבל לא מספק את הבידוד של הביצועים.

חשוב לזכור שמעברים ב-CSS ואנימציות ב-CSS הם עדיין אפשרויות תקפות, והם יכולים להיות פשוטים יותר לאנימציות בסיסיות. אבל אם אתם רוצים להשתמש באנימציה מורכבת יותר, AnimationWorklet הוא הפתרון בשבילכם.