[Implement] 40 行實現 React `useState`, `useEffect`
實作前,先來了解 Closures
的概念
在實現 useState
之前,我們需要先了解 Closures
的概念,因為有了 Closures
的概念,我們才能在 function 中有 state 的概念
對於 Closures
的定義,最清楚且簡單的解釋為 W3Schools:
Closure makes it possible for a function to have "private" variables.
其他解釋為:
A closure is the combination of a function and the lexical environment within which that function was declared
Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.
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
的實作如下:
- 取得舊的 dependencies
- 如果 dependencies 有改變,就執行 callback
- 將新的 dependencies 存回 hooks
- 將 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'
結論
- React hooks 的核心原理,就是用
Closure
的概念記住每個 hook 的狀態 - 利用
render
的機會,將idx
歸零,並在每次讀到一個 hook 時 += 1,讓每次useState
,useEffect
都能正確 mapping 到 hooks 的正確值