Emotion CSS SSR ทำงานอย่างไร?

ผมเป็นแฟน Emotion CSS มาสักพักแล้ว รู้สึกว่ามัน clean กว่าตัวเลือกอื่นๆ มาก โดยเฉพาะว่ามันไม่ผูกกับ React เลยรู้สึกเหมือนเขียน style ก่อน แล้วเอาไปใส่ component ไม่ใช่ component ที่ถูก style มาแล้ว (พูดง่ายๆ คือมันรู้สึกเหมือนเขียน old school CSS มากกว่า )

แต่ Emotion กลับชอบเปลี่ยน core บ่อยมาก

  • Emotion 5 ใช้ CSS parser ที่ดึงมาจาก styled component แล้ว insertStyle เอง
  • Emotion 6 ใช้ glam
  • Emotion 8 ใช้ stylis.js

วันนี้อยากจะพูดถึงสักหน่อยว่า Emotion มันทำ server side rendering อย่างไร เนื่องจากว่าก่อนหน้านี้เพิ่งส่ง pull request เข้าไปแก้บั๊กเกี่ยวกับ SSR

Emotion 9

ใน Emotion 9 ฟังค์ชั่น css จะ return string cache_key-name ทำให้เราสามารถใช้ className={...} ได้ ซึ่งปกติ cache.key ก็จะตั้งเป็น css ทำให้เราจะเห็นเว็บที่ใช้ emotion ใช้ class ประมาณว่า css-1fe12ej

จากนั้น เวลาเราเรียก renderStylesToString ใน server side rendering มันจะใช้ regex <|${cssKey}-([a-zA-Z0-9-_]+) match เข้าไปยัง HTML ที่ได้จาก React renderToString

จะเห็นว่า Regex นี้ match ได้สองแบบคือ

  1. ตัว < (open tag)
  2. cache_key-name ตามรูปแบบที่ css สร้างไว้

เมื่อพบข้อความที่คล้าย class name แล้ว มันจะทำ list ไว้จนกระทั่งพบตัว < ถัดไป พอพบแล้ว ที่ตัว < ก่อนหน้าจะถูกแทรก <style> tag ที่มี definition ของ class เหล่านั้นลงไป เราจึงจะเห็นว่าในเว็บที่ใช้ emotion จะมี <style> tag อยู่จำนวนมาก แต่จะแทรกอยู่หน้าครั้งแรกที่ใช้ครั้งเดียว ไม่ใช่เป็นก้อนใหญ่ๆ ไว้ในหัวเว็บ

วิธีนี้แน่นอนว่าไม่ใช่ foolproof เพราะผมเองก็เคยเจอบั๊กที่ว่าเว็บมี <a href="...-css-programmer"> แล้ว emotion เลย lookup id programmer ทำให้เกิด <style>undefined</style> ซึ่งก็แก้ไปใน pull request ที่ส่งเข้าไปแล้ว

หรืออีกวิธีหนึ่งที่เทสได้คือลองพิมพ์ css-... ที่ตรงกับ class ที่มีอยู่จริงลงในหน้าเว็บ ก็จะปรากฏว่ามันจะแทรก css ชื่อนั้นลงไปหน้า tag นั้นด้วย ตรงนี้คิดว่าไม่อันตรายถึงขั้น security (เพราะแค่แทรก style tag จาก style ที่มีอยู่จริงในเว็บ) แต่ก็ทำให้เว็บมันรกๆ ได้

<style data-emotion-css="1fjv9nj">.css-1fjv9nj{color:#36629e;}</style><li>ugc textcss-1fjv9nj</li>

ความเจ๋งของเทคนิคทั้งหมดนี้คือ Emotion จึงไม่ได้ผูกกับ React หรือ library ใดเลย เพราะจะสังเกตว่าทุกอย่างทำงานอยู่บน string ทั้งหมด จะใช้กับ HTML อย่างเดียวก็ได้

Emotion 10

Emotion 10 มาพร้อมกับฟีเจอร์ใหม่ Zero configuration serverless ซึ่งตอนนี้น่าจะเป็นเจ้าเดียวที่มี มันทำได้ยังไง?

Emotion 10 เปลี่ยน API ของ css ออกไป โดยจะ return เป็น object แทน จากนั้นจะเห็นว่า Emotion 10 บังคับให้ลง babel plugin หรือ เขียน /** @jsx jsx */ ไว้ที่หัวไฟล์

สิ่งที่ directive นี้ทำคือเปลี่ยนวิธีที่ Babel compile React component ใหม่ จากเดิมคือ

<div css="example">body</div>

React.createElement(
    "div"
    {css: "example"},
    "body"
)

จะเปลี่ยนเป็น

jsx("div", {css: "example"}, "body")

ซึ่งจะเห็นว่าเหมือนกับของเดิม แค่เปลี่ยน function เป็น jsx เท่านั้น แล้วในโค้ดเราจะต้อง import {jsx} from "@emotion/core" มาด้วย

เมื่อลองไล่โค้ด jsx ดูก็จะเห็นว่ามันครอบ React.createElement จริงไว้อีกทีหนึ่ง โดยถ้า component มี prop ชื่อ css มันจะแอบสลับ component ให้เป็น <Emotion __EMOTION_TYPE_PLEASE_DO_NOT_USE__="div" css="example">body</Emotion>

ท่านี้คนเขียน React อาจจะคุ้นเคยถ้าเรียกมันว่า Higher order component ซึ่งอาจจะเขียนได้แบบนี้

function jsx(Element){
    return (...props) => {
        if(!props.css){
            return <Element {...props} />;
        }

        return <Emotion __EMOTION_TYPE_PLEASE_DO_NOT_USE__={Element} {...props} />
    }
}

แล้วเอา HOC นี้ไปครอบทุก element ในหน้า รวมถึง primitive element ด้วย Emotion จึงเลือกใช้การเปลี่ยน React.createElement แทนที่จะใช้ HOC ปกติเพื่อความสะดวก

ทีนี้ <Emotion /> ทำงานยังไง?

ซอร์สโค้ดของ component นี้อยู่ในไฟล์เดียวกัน ก็มีส่วนซับซ้อนมากมาย แต่ส่วนที่เราสนใจคือส่วนที่มันทำให้ zero config server side rendering ได้

วิธีการที่มันทำก็น่าสนใจมาก โดยใช้ React Fragment ที่เพิ่งมาใหม่ (แต่ก็เก่าแล้ว)

return (
  <React.Fragment>
    <style
      {...{
        [`data-emotion-${cache.key}`]: serializedNames,
        dangerouslySetInnerHTML: { __html: rules },
        nonce: cache.sheet.nonce
      }}
    />
    {ele}
  </React.Fragment>
)

พูดง่ายๆ ก็คือ <Emotion /> นั้นก็เป็น HOC อีกชั้นหนึ่งที่จะครอบ component จริงไว้คู่กับ <style> tag ใน React Fragment (และ inject className เข้าไป) หรือเป็นโค้ด HOC อาจจะแบบนี้

function Emotion({css, __EMOTION_TYPE_PLEASE_DO_NOT_USE__, ...props}){
    let rules = getCssString(css);
    let className = getCssClassName(css);
    let Element = __EMOTION_TYPE_PLEASE_DO_NOT_USE__;
    return (
        <>
            <style dangerouslySetInnerHTML: { __html: rules } />
            <Element {...props} className={className} />
        </>
    )
}

ด้วยการใช้ Fragment ทำให้ Emotion 10 ทำ server side rendering ได้ โดยไม่ต้องแก้โค้ดฝั่ง server ใดๆ

Tradeoff

ก็เสียดายว่าท่าพวกนี้นั้นกลับกลายเป็นว่า Emotion 10 ผูกติดกับ React แน่นมาก

ใน Docs ของ Emotion เองแนะนำให้ migrate ไปใช้ API ใหม่นี้ทั้งหมด แต่ก็เขียนไว้ว่าเฉพาะถ้าคุณใช้ React นะ ก็ยังไม่รู้ว่าในอนาคต API เดิมจะยังมีการพัฒนาต่อหรือไม่ ตอนนี้ใน GitHub เองก็ยังมีทั้ง package ของ API เก่าและใหม่อยู่ด้วยกัน