Skip to main content

[Implement] 40 行實現 React `useState`, `useEffect`


實作前,先來了解 Closures 的概念​


在實現 useState 之前,我們需要先了解 Closures 的概念,因為有了 Closures 的概念,我們才能在 function 中有 state 的概念

對於 Closures 的定義,最清楚且簡單的解釋為 W3Schools:

W3Schools

Closure makes it possible for a function to have "private" variables.



其他解釋為:

MDN

A closure is the combination of a function and the lexical environment within which that function was declared

You Don't Know JS

Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.

FreeCodeCamp

A closure is a feature in Javascript where an inner function has access to the outer (enclosing) function's variables - a scope chain."



如何讓 function 有 state​

了解 Closures 的概念後,我們可以來看下如何讓 function 有 state 的,後續我們才會知道 useState 的實作原理

例如有一個 function add,我們希望它能夠記住 foo 的值,並且每次呼叫 add 時,都能夠得到不同的 foo 值

let foo = 1;

function add() {
foo = foo + 1;
return foo;
}

console.log(add()); // 2
console.log(add()); // 3
console.log(add()); // 4
console.log(add()); // 5
console.log(add()); // 6


如果我們在中間去更改 foo 的值,就會讓 app 被毀壞:

let foo = 1;

function add() {
foo = foo + 1
return foo;
}

console.log(add()); // 2
console.log(add()); // 3
console.log(add()); // 4
foo = 999;
console.log(add()); // 10000
console.log(add()); // 10001


我們希望每個 function 都有自己獨立的 foo 變數,所以我們必須保護 foo 變數,讓其不要被 global scope 污染,要怎麼做呢?



作法 1:將 foo 直接移進 add function​

但這種作法每次都只會回傳相同的結果

function add() {
let foo = 1;
foo = foo + 1
return foo;
}

console.log(add()); // 2
console.log(add()); // 2
console.log(add()); // 2
// foo = 999; // will cause error
console.log(add()); // 2
console.log(add()); // 2


作法 2:將 function return 一個 add function​

這種作法可以讓每個 function 都有自己獨立且永久存在的 foo 變數,不會被 global scope 污染,且可以利用 function 提供的 setter 操作

這其實就是 Closure 的概念,裡面的 function 可以存取到外面 function 的變數,即使外面 function 已經執行完畢,我們利用這個概念讓每個 function 都有自己的 private states

function getAdd() {
let foo = 1;
return function() {
foo = foo + 1
return foo;
}
}

const add = getAdd();

console.log(add()); // 2
console.log(add()); // 3
console.log(add()); // 4
// foo = 23 // error
console.log(add()); // 5
console.log(add()); // 6


實作 useState​

單一 state​

我們先試著實作整個 app 只有單一 state 的狀況,我們依照 React 官網的格式嘗試建立 useState,如下程式碼

但解構出來的 setState 真的去改變值時,只有改變內部的 _val 值,外部的變數 count 已經被 assign 了,所以會一直是 1,沒有改變,但這跟我們要的 useState 概念不一樣

function useState(initVal) {
let _val = initVal;
const state = _val;

const setState = (newVal) => {
_val = newVal;
}

return [state, setState]
}

const [count, setCount] = useState(1);
console.log(count) // 1

setCount(2);
console.log(count) // 1


我們可以用一個非常簡單的方式來解決,就是將 state 變成一個 return _val 的 function (這是不是就是 SolidJS 的 signal???)

function useState(initVal) {
let _val = initVal;
const state = () => _val;

const setState = (newVal) => {
_val = newVal;
}

return [state, setState]
}

const [count, setCount] = useState(1);
console.log(count()) // 1

setCount(2);
console.log(count()) // 2


可是我們在使用 React 時,並沒有使用 state() 的方式來取得狀態, 因此,我們需要用另一種方法來模擬



React module​

我們可以先將 useState 搬到 const React 這個 module 裡,慢慢解決這個問題

const React = (function() {
function useState(initVal) {
let _val = initVal;
const state = _val;

const setState = (newVal) => {
_val = newVal;
}
return [state, setState]
}

return { useState }
})();


Component​

我們也同時撰寫一個 Component & React.render function,來實現 setState & rerender 後,拿到最新的 state 值的狀況

我們可以利用 Component 重新去 render,每次都會拿到最新的 _val 值,因為每次 render 時,Component 都會去拿到最新的 useState 和 _val 值,進而拿到最新的 state

我們呼叫 React.render 時,都會呼叫一個新的 Component function,這時候,就會透過 React.useState 從 React 中去拿 _val 的值, assign 給當下 component function,作為一個全新的 count 變數,這時候就會拿到 React 最新的 _val 值

const React = (function() {
let _val;

function useState(initVal) {
const state = _val;

const setState = (newVal) => {
_val = newVal;
}
return [state, setState]
}

function render(Component) {
const C = Component();
C.render();
return C;
}

return { useState, render }
})();


function Component() {
const [count, setCount] = React.useState(1);
return {
render: () => console.log(count),
click: () => setCount(count + 1)
}
}

var App = React.render(Component); // 1

App.click();
var App = React.render(Component); // 2

App.click();
var App = React.render(Component); // 3

App.click();
var App = React.render(Component); // 4

App.click();
var App = React.render(Component); // 5


多個 states​

當我們有 2nd state 時,當我們在執行任一個 setState 時,就會發生以下問題:

  • count, text 的值變成一樣了

這是因為我們只有一個 _val 在掌管所有 useState 可以取得的狀態,我們目前沒辦法把所有的 useState 的狀態給分開,導致所有 useState 共用狀態,才產生這個問題

function Component() {
const [count, setCount] = React.useState(1);
const [text, setText] = React.useState('apple');

return {
render: () => console.log({ count, text }),
click: () => setCount(count + 1),
type: (word) => setText(word)
}
}

var App = React.render(Component);
// { count: 1, text: 'apple' }

App.click();
var App = React.render(Component);
// { count: 2, text: 2 }

App.type('pear');
var App = React.render(Component);
// { count: 'pear', text: 'pear' }



1st. 將所有的 React states存成一個 array​

因此,我們需要將所有的狀態存成一個 array,記住每個 useState 的狀態

const React = (function() {
let hooks = [];
let idx = 0;

function useState(initVal) {
const state = hooks[idx] || initVal;
const setState = (newVal) => {
hooks[idx] = newVal;
}

idx += 1;
return [state, setState];
}

function render(Component) {
const C = Component();
C.render();
return C;
}

return { useState, render };
})


但還是會產生以下結果

var App = React.render(Component);

App.click();
var App = React.render(Component);

App.type('pear');
var App = React.render(Component);

// { count: 1, text: 'apple' }
// { count: 2, text: 'apple' }
// { count: 'pear', text: 'apple' }

這是因為:

var App = React.render(Component);
// hooks: [], 呼叫 2 次 useState,讓 idx === 2
// { count: 1, text: 'apple' }

App.click();
// setCount(count + 1) --> 將 count + 1 的值設在 hooks[2]


var App = React.render(Component);
// hooks: [ <2 empty items, 2],呼叫 2 次 useState,讓 idx === 4
// { count: 2, text: 'apple' }


App.type('pear');
// setText('pear') --> 將 'pear' 的值設在 hooks[4]


var App = React.render(Component);
// hooks: [ <2 empty items>, 2, <1 empty item>, 'pear' ]
// current idx === 4
// count === hooks[4] === 'pear'
// text === hooks[5] || initVal, hooks[5] 為空,所以 text === initVal === 'apple'
// { count: 'pear', text: 'apple' }


2nd. render 時 reset idx 為 0​

因為上述 idx 會在每次 React.render 後,根據 useState 的呼叫次數一直增加,導致我們對應不到正確的 hooks idx 值

所以我們在 render 時,把 idx 重設,試圖來解決這個問題

const React = (function() {

let hooks = [];
let idx = 0;

function useState(initVal) {
const state = hooks[idx] || initVal;
const setState = (newVal) => {
hooks[idx] = newVal;
}
idx += 1;
return [state, setState];
}

function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}

return { useState, render }

})();


但卻還是造成以下結果

var App = React.render(Component);

App.click();
var App = React.render(Component);

App.type('pear');
var App = React.render(Component);

// { count: 1, text: 'apple' }
// { count: 1, text: 'apple' }
// { count: 1, text: 'apple' }


這是因為:

var App = React.render(Component);
// hooks: [], 呼叫 2 次 useState,讓 idx === 2
// { count: 1, text: 'apple' }

App.click();
// setCount(count + 1) --> 將 count + 1 的值設在 hooks[2]


var App = React.render(Component);
// idx 被設為 0
// hooks: [ <2 empty items, 2],呼叫 2 次 useState,讓 idx === 2
// { count: 1, text: 'apple' }


App.type('pear');
// setText('pear') --> 將 'pear' 的值設在 hooks[2]


var App = React.render(Component);
// idx 被設為 0
// hooks: [ <2 empty items>, 'pear' ], 呼叫 2 次 useState,讓 idx === 2
// { count: 1, text: 'apple' }


3rd. 將現在的 idx 記在 useState 中​

我們在 useState 被呼叫時,將當下 React 的 idx 傳進到 useState 裡, 也是再次使用到 Closure 的概念,這樣 setState 就會設定到 hooks 正確的 idx 中了

const React = (function() {

let hooks = [];
let idx = 0;

function useState(initVal) {
const state = hooks[idx] || initVal;

const _idx = idx;
const setState = (newVal) => {
hooks[idx] = newVal;
}
idx += 1;
return [state, setState];
}

function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}

return { useState, render }

})();


結果就會如我們預期的一樣

var App = React.render(Component);
// { count: 1, text: 'apple' }

App.click();
var App = React.render(Component);
// { count: 2, text: 'apple' }


App.type('pear');
var App = React.render(Component);
// { count: 2, text: 'pear' }


Hooks rules​

React 有一條規則是:我們不能將 useState 包在 condition 中, 因為這樣 React 內部的 idx 就會看狀況 + 1,就不會將 hooks 的值正確的 mapping 到 Component 的 useState 中了



useEffect​

成功實現 useState 後,我們就可以來實現 useEffect 了解

我們的需求如下:

  • 如果沒有 dependency,我們希望在只一開始,印出 useEffect by Benson
  • 如果有 dependency,我們希望在 一開始和 dependency (e.g.text) 改變時,印出 useEffect by Benson
function Component() {
const [count, setCount] = React.useState(1);
const [text, setText] = React.useState('apple');

React.useEffect(() => {
console.log('useEfect by Benson');
}, [text]);

return {
render: () => console.log({ count, text }),
click: () => setCount(count + 1),
type: (text) => setText(text)
}
}


useEffect 的實作如下:

  1. 取得舊的 dependencies
  2. 如果 dependencies 有改變,就執行 callback
  3. 將新的 dependencies 存回 hooks
  4. 將 idx 往後移

在一開始,因為 oldDeps 不存在 hooks 中,所以一開始會執行 callback,但後續 re-render 時,oldDeps 就會存在 hooks 中,所以會去檢查 dependencies 是否有改變,如果沒有改變就不會執行 callback

const React = (function() {
let hooks = [];
let idx = 0;

function useState(initVal) {
const state = hooks[idx] || initVal;
const _idx = idx;
const setState = (newVal) => {
hooks[_idx] = newVal;
}

idx += 1;

return [state, setState];
}

function useEffect(cb, depArray) {
// NOTE: get old dependecies by idx
const oldDeps = hooks[idx];

// NOTE: by default, cb should be called every time
let hasChanged = true;

// NOTE: if there exists one value in new dependencies not equal to old dependencies
if (oldDeps) {
hasChanged = depArray.some(
(dep, i) => !Object.is(dep, oldDeps[i])
)
}

if (hasChanged) {
cb();
}

// should call cb
// NOTE: store the new dependencies to hooks, and continue to next hook
hooks[idx] = depArray;
idx += 1
}

function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}

return { useState, useEffect, render }
})();


我們執行後,就可以看到結果如下:

var App = React.render(Component);
// log: { count: 1, text: 'apple' }
// log: 'useEffect by Benson'

App.click();
var App = React.render(Component);
// log: { count: 2, text: 'apple' }

App.type('pear');
var App = React.render(Component);
// log: { count: 2, text: 'pear' }
// log: 'useEffect by Benson'



成功的在一開始,還有 text dependency 改變時,執行 useEffect 的 callback



useState 和 useEffect 實際搭配 JSX

因為後續在影片中,這段沒有特別清楚,就交給有緣人去研究了 😂




最終原始碼​

React 簡易 useState 和 useEffect 原始碼​

const React = (function() {
let hooks = [];
let idx = 0;

function useState(initVal) {
const state = hooks[idx] || initVal;
const _idx = idx;
const setState = (newVal) => {
hooks[_idx] = newVal;
}
idx += 1;
return [state, setState];
}

function useEffect(cb, depArray) {
const oldDeps = hooks[idx];
let hasChanged = true;

if (oldDeps) {
hasChanged = depArray.some(
(dep, i) => !Object.is(dep, oldDeps[i])
)
}

if (hasChanged) {
cb();
}

hooks[idx] = depArray;
idx += 1
}

function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}

return { useState, useEffect, render }
})();


測試原始碼​

function Component() {
const [count, setCount] = React.useState(1);
const [text, setText] = React.useState('apple');

React.useEffect(() => {
console.log('useEfect by Benson');
}, [text]);

return {
render: () => console.log({ count, text }),
click: () => setCount(count + 1),
type: (text) => setText(text)
}
}


var App = React.render(Component);

App.click();
var App = React.render(Component);

App.type('pear');
var App = React.render(Component);

// { count: 1, text: 'apple' }
// 'useEffect by Benson'
// { count: 2, text: 'apple' }
// { count: 2, text: 'pear' }
// 'useEffect by Benson'



結論​

  1. React hooks 的核心原理,就是用 Closure 的概念記住每個 hook 的狀態
  2. 利用 render 的機會,將 idx 歸零,並在每次讀到一個 hook 時 += 1,讓每次 useState, useEffect 都能正確 mapping 到 hooks 的正確值



參考資料​