Preact Hooks Internal

เดือนที่แล้วนั่งอ่าน Inferno ว่าจะทำ Hook มั้ย

คืออยากหา framework ที่คิดเหมือนเราว่า Hook มันคือ Magic และมัน implement ได้โดยไม่ต้องใช้ Magic นั่นแหละ

ก็ยังไม่รู้ว่า Inferno คิดยังไง แต่ก็มีคนลองๆ ทำอยู่ ซึ่งเค้าก็ไปลอก Preact มาอีกที ไม่รู้ว่าจะเหมือนกับ React มั้ย แต่คิดว่าอ่านแล้วแล้วเข้าใจง่ายดีเลยเอามา blog ไว้หน่อย

useState

ซอร์สของ Preact Hook อยู่ที่ hooks/src/index.js เราจะไล่จากด้านนอกที่เราคุ้นเคยไปก่อนน่าจะง่าย นั่นคือ useState ซึ่งพื้นๆ มาก…

export function useState(initialState) {
	currentHook = 1;
	return useReducer(invokeOrReturn, initialState);
}

ปรากฏว่า useState คือ wrapper บน useReducer ซึ่งก็ทำให้หายสงสัยว่าทำไม React ต้องมี useReducer API ทั้งที่ๆ คนปกติก็จะไปใช้ Redux อยู่แล้ว

ส่วน invokeOrReturn คิดว่าถ้าจำได้ว่า reducer เขียนยังไง แล้ว setState function มันรับอะไรน่าจะพอเดาได้ว่ามัน implement ยังไง

function invokeOrReturn(oldState, f) {
	return typeof f == 'function' ? f(oldState) : f;
}

ง่ายๆ ก็คือมันคืนค่าที่เราเรียกนี่แหละ แต่ถ้า setState เรียกด้วย function มันจะได้ state เก่าเป็น argument แล้วเราคืนค่าใหม่มาแทน

useReducer

export function useReducer(reducer, initialState, init) {
	const hookState = getHookState(currentIndex++, 2);
	hookState._reducer = reducer;
	if (!hookState._component) {
		hookState._component = currentComponent;

		hookState._value = [
			!init ? invokeOrReturn(undefined, initialState) : init(initialState),

			action => {
				const nextValue = hookState._reducer(hookState._value[0], action);
				if (hookState._value[0] !== nextValue) {
					hookState._value = [nextValue, hookState._value[1]];
					hookState._component.setState({});
				}
			}
		];
	}

	return hookState._value;
}

ขอข้าม getHookState ไปก่อนที่จะอธิบายต่อไป

useReducer มี state คือ

  • Reducer function ที่เราส่งเข้าไป
  • Return value ของมันที่ cache ไว้ ก็คือ [state, dispatch] อยู่ใน __value
  • Component instance ซึ่งมันเก็บไว้เรียก setState({}) เพื่อ invalidate view

จะเห็นว่ามันใช้ global state อยู่ 2 อันคือ currentComponent และ currentIndex แล้วใครเซตตัวแปรพวกนี้?

Actual hooks

Preact internal มีส่วนที่เปิดให้ implement functionality เพิ่มเติมได้ซึ่งมันเรียกว่า hook ซึ่งเป็น hook ในความหมายดั้งเดิมคือเราเอา function ไปผูกไว้กับอีกส่วนของ library หรือถ้าใครเขียน Vue มันคือ Hook ในความหมายของ Lifecycle hook นั่นแหละ

Hook ที่เราสนใจคงเป็น _render hook ซึ่งใน docs มันเขียนว่า “Attach a hook that is invoked before a vnode has rendered”

_render hook ของ hook เขียนว่า

options._render = vnode => {
	if (oldBeforeRender) oldBeforeRender(vnode);

	currentComponent = vnode._component;
	currentIndex = 0;

	const hooks = currentComponent.__hooks;
	if (hooks) {
		hooks._pendingEffects.forEach(invokeCleanup);
		hooks._pendingEffects.forEach(invokeEffect);
		hooks._pendingEffects = [];
	}
};

VNode

ถ้าใครเขียน Vue จะเห็นว่า Vue expose VNode API ออกมาให้ด้วย แต่ใน React API ไม่มีให้ใช้เพราะถือเป็น internals

VNode คือ Virtual DOM node นั่นแหละ (Component เราไม่ได้เป็น VNode ตรงๆ แต่จะถูกครอบอีกทีนึง อย่างที่เห็นว่ามันต้อง read vnode._component ออกมาในโค้ดด้านบน)

ฉะนั้นสิ่งที่มันทำก็คือ ทุกครั้งที่เราอยู่บน node ใหม่ มันจะเซฟ currentComponent เก็บไว้ แล้วรีเซต currentIndex เป็น 0

นี่คือ Magic ของ Hook คือแทนที่มันจะเก็บ local state แบบ this.xxx เค้ากลับเลือกที่จะยกออกไปเป็น global ภายในตัว framework

ที่ทำได้เพราะ JavaScript code เป็น single thread (ไม่ต้องกังวลว่ารันโค้ด concurrent แล้วจะมีคนเขียนทับ global) และ Preact rendering ก็เป็น synchronous (ไม่มีแบบว่า render อยู่แล้วสลับไปสลับมา)

ที่น่าสนใจต่อคือ React fiber กำลังจะทำ asynchronous rendering แล้วมัน implement ตรงนี้ยังไงกันนะ?

getHookState

เมื่อกี้เราติด getHookState ไว้

const hookState = getHookState(currentIndex++, 2);

โค้ดของ getHookState คือ

function getHookState(index, type) {
	if (options._hook) {
		options._hook(currentComponent, index, currentHook || type);
	}
	currentHook = 0;

	const hooks =
		currentComponent.__hooks ||
		(currentComponent.__hooks = {
			_list: [],
			_pendingEffects: []
		});

	if (index >= hooks._list.length) {
		hooks._list.push({});
	}
	return hooks._list[index];
}

ส่วนแรกคือ Hook เองเปิดโอกาสให้ใส่ functionality hook เข้าไปได้อีก มันเลยต้องมีตัวแปร currentHook เพื่อบอกให้คนที่เข้ามาต่อรู้ว่ากำลังจะ execute hook ประเภทไหนอยู่ สำหรับค่าต่างๆ มีประกาศไว้ใน type definition

ถัดมามันก็จะอ่าน __hooks บน component ปัจจุบัน หรือสร้างใหม่ ด้านในจะมี array อยู่ซึ่งมันจะอ่านตัวที่ currentIndex ที่ส่งเข้ามา ซึ่งในโค้ด useReducer จะเขียนว่า currentIndex++ ก็คืออ่านค่าปัจจุบันแล้วเลื่อน index ไปลำดับถัดไป

นั่นคืออีก Magic ของ hook ที่ทำให้ function ธรรมดาเรียกสองครั้งแล้วได้ค่าไม่เหมือนเดิม เพราะมันแอบมี global state อยู่นั่นเอง แต่ global state นั้นถูก reset ระหว่าง component ทำให้รู้สึกเหมือนว่ามันเป็น local state

ถ้าถามผมผมก็ยังคิดว่า local state มันควรจะเป็น attribute บน this แล้วใช้ mixin เพื่อ compose behavior ซึ่ง React เคยมีแต่ถอดไปนานแล้ว เพราะมักจะเจอปัญหาตั้งชื่อชนกัน หรือใช้ 2 อันไม่ได้ ใน Hook ก็เลยไม่ให้ตั้งชื่อแล้วทำเป็น index เลื่อนไปเรื่อยๆ แทน

และนี่คือที่มาของกฎของ hook ว่าทำไมห้ามใช้ hook ใน if เพราะลำดับจะเลื่อนไม่ตรงกันแล้ว hook state จะผสมกันมั่ว

ที่น่าสนใจคือ ยังไม่มีตรงไหนบอกว่าทำไม Hook ใช้กับ class component ไม่ได้ ซึ่งถ้าไปอ่าน issue ต้นเรื่อง ทีม Preact บอกว่า

You may be wondering why we mix those and the reason is simply to save some bytes. And yes, this allows hooks to be used in class components! We don’t really advertise that though 👍

บน Preact ใช้ Hook บน class component ได้! (แต่ไม่ใช่ public API)

สรุป

สิ่งที่ Preact Hook ทำคือ

  1. เมื่อกำลังจะ render vdom node ใหม่ ให้เซต currentComponent และเซต currentIndex=0
  2. เมื่อเราเรียก hook มันจะไปอ่าน state บางอย่างบน component ปัจจุบัน
  3. อ่านแล้วมันจะเลื่อน pointer ไปยังช่องถัดไปเพื่อให้ hook ตัวถัดไปได้ state ไม่ตรงกัน

Final thoughts

หลังอ่านจบก็เริ่มสงสัยว่าคนเขียน React สักกี่คนนะที่เชียร์ Hook แล้วเข้าใจจริงๆ ว่า Hook มัน implement อย่างไร (ให้ project เปล่าๆ ห้ามโหลดของจาก npm เขียน Hook ยังไง?)

ก็เกือบไปลองออกเป็นข้อสอบ interview ดู แต่คิดดูอีกทีหนึ่งแล้ว คนที่เขียน Hook ได้คือคนที่แม่น JavaScript (ซึ่งเรา require) แต่นั่นยังไม่ใช่ทั้งหมดที่จะเป็น frontend dev ที่ดีได้ (ยังมีเรื่องอื่นๆ อีกเยอะแยะ เช่น CSS, box layout, sematic markup, web optimization, SEO) มันไม่น่าจะเป็นข้อสอบที่ดีเท่าไร

ในขณะเดียวกัน เอาไปเทส backend dev ก็ไม่ได้เหมือนกันเพราะ React เป็นเรื่องของ Frontend

แล้วใน software team จะให้ role ไหนเป็นคนสร้าง React?

มันเป็นโจทย์ที่ยากมากและ require ความรู้ลึกทั้งฝั่ง backend (โดยเฉพาะ VDOM implementation กับ Suspense) และ frontend เลย หรือนั่นคือนิยามจริงๆ ของ Full stack developer กันนะ

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 เก่าและใหม่อยู่ด้วยกัน