深度挖掘Concent的effect,全面提升useEffect的開發體驗

幻魂發表於2019-12-07

❤ star me if you like concent ^_^

管理副作用程式碼

在hook還沒有誕生時,我們通常都會在class內建的生命週期函式componentDidMountcomponentDidUpdatecomponentWillUnmount書寫副作用邏輯。

這裡就不再討論componentWillUpdatecomponentWillReceiveProps了,因為隨著react支援非同步渲染後,這些功能已標記為不安全,讓我們跟隨者歷史的大潮流,徹底忘記他們吧?

深度挖掘Concent的effect,全面提升useEffect的開發體驗

我們來舉一個最典型的應用場景如下:

class SomePage extends Component{
    state = { products: [] }
    componentDidMount(){
        api.fetchProducts()
        .then(products=>this.setState({products}))
        .catch(err=> alert(err.message));
    }
}
複製程式碼

這樣的類似程式碼是你100%一定曾經寫過的,表達的含義也很簡單,元件初次掛載完畢時,獲取一下產品列表資料。

我們的頁面通常都會是這樣子的,頭部是一個條件輸入或者選擇區域,中央大塊區域是一個表格,現在我們對這個頁面提一些需求,選擇區域裡任何值發生改變時,都觸發自動查詢更新列表,元件銷燬時做些其他事情,親愛的讀者一定都寫過類似如下程式碼:

class SomePage extends Component{
    state = { products: [], type:'', sex:'', addr:'', keyword:'' }
    
    componentDidMount(){
        this.fetchProducts();
    }
    
    fetchProducts = ()=>{
        const {type, sex, addr, keyword} = this.state;
        api.fetchProducts({type, sex, addr, keyword})
        .then(products=>this.setState({products}))
        .catch(err=> alert(err.message));
    }
    
    changeType = (e)=> this.setState({type:e.currentTarget.value})
    
    changeSex = (e)=> this.setState({sex:e.currentTarget.value})
    
    changeAddr = (e)=> this.setState({addr:e.currentTarget.value})
    
    changeKeyword = (e)=> this.setState({keyword:e.currentTarget.value})
    
    componentDidUpdate(prevProps, prevState){
        const curState = this.state;
        if(
            curState.type!==prevState.type ||
            curState.sex!==prevState.sex || 
            curState.addr!==prevState.addr || 
            curState.keyword!==prevState.keyword 
        ){
            this.fetchProducts();
        }
    }
    
    componentWillUnmount(){
        // 這裡搞清理事情
    }
    
    render(){
        const { type, sex, addr, keyword } = this.state;
        return (
            <div className="conditionArea">
                <select value={type} onChange={this.changeType} >{/**some options here*/}</select>
                <select value={sex} onChange={this.changeSex}>{/**some options here*/}</select>
                <input value={addr} onChange={this.changeAddr} />
                <input value={keyword} onChange={this.changeKeyword} />
            </div>
        );
    }
}
複製程式碼

當然一定有騷氣蓬勃的少年不想寫那麼多change***,在渲染節點裡標記data-***來減少程式碼,大概率如下:

class SomePage extends Component{
    changeKey = (e)=> this.setState({[e.currentTarget.dataset.key]:e.currentTarget.value})
    // 其他略...
    render(){
        const { type, sex, addr, keyword } = this.state;
        return (
            <div className="conditionArea">
                <select data-key="type" value={type} onChange={this.changeKey} >
                    {/**some options here*/}
                </select>
                <select data-key="sex" value={sex} onChange={this.changeKey}>
                    {/**some options here*/}
                </select>
                <input data-key="addr" value={addr} onChange={this.changeKey} />
                <input data-key="keyword" value={keyword} onChange={this.changeKey} />
            </div>
        );
    }
}
複製程式碼

如果此元件的某個狀態還需要接受來自props的值來更新,那麼使用class裡的新函式getDerivedStateFromProps替代了不推薦的componentWillReceiveProps,程式碼書寫大致如下:

class SomePage extends Component{
    static getDerivedStateFromProps (props, state) {
        if (props.tag !== state.tag) return {tag: props.tag}
        return null
    }
}
複製程式碼

到此,我們完成了class元件對副作用程式碼管理的討論,接下來我們讓hook粉末登場━(`∀´)ノ亻!

hook爸爸教做人

hook誕生之初,都拿上面類似例子來輪,會將上面例子改寫為更簡單易懂的例子,分分鐘教class元件重新做人?

深度挖掘Concent的effect,全面提升useEffect的開發體驗

我們來看一個改寫後的程式碼

const FnPage = React.memo(function({ tag:propTag }) {
  const [products, setProducts] = useState([]);
  const [type, setType] = useState("");
  const [sex, setSex] = useState("");
  const [addr, setAddr] = useState("");
  const [keyword, setKeyword] = useState("");
  const [tag, setTag] = useState(propTag);//使用來自props的tag作為初始化值

  const fetchProducts = (type, sex, addr, keyword) =>
    api
      .fetchProducts({ type, sex, addr, keyword })
      .then(products => setProducts(products))
      .catch(err => alert(err.message));

  const changeType = e => setType(e.currentTarget.value);
  const changeSex = e => setSex(e.currentTarget.value);
  const changeAddr = e => setAddr(e.currentTarget.value);
  const changeKeyword = e => setKeyword(e.currentTarget.value);

  // 等價於上面類元件裡componentDidMount和componentDidUpdate裡的邏輯
  useEffect(() => {
    fetchProducts(type, sex, addr, keyword);
  }, [type, sex, addr, keyword]);
  // 填充了4個依賴項,初次渲染時觸發此副作用
  // 此後元件處於存在期,任何一個改變都會觸發此副作用
  
  useEffect(()=>{
      return ()=>{// 返回一個清理函式
          // 等價於componentWillUnmout, 這裡搞清理事情
      }
  }, []);//第二位引數傳空陣列,次副作用只在初次渲染完畢後執行一次1
  
  useEffect(()=>{
     // 首次渲染時,此副作用還是會執行的,在內部巧妙的再比較一次,避免一次多餘的ui更新
     // 等價於上面元件類裡getDerivedStateFromProps裡的邏輯
     if(tag !== propTag)setTag(tag);
  }, [propTag, tag]);

  return (
    <div className="conditionArea">
      <select value={type} onChange={changeType}>
        {/**some options here*/}
      </select>
      <select data-key="sex" value={sex} onChange={changeSex}>
        {/**some options here*/}
      </select>
      <input data-key="addr" value={addr} onChange={changeAddr} />
      <input data-key="tkeywordype" value={keyword} onChange={changeKeyword} />
    </div>
  );
});
複製程式碼

看起來好清爽啊有木有,寫起來很騷氣似不似?巧妙的利用useEffect替換掉了類元件裡各個生命週期函式,而且上下文裡完全沒有了迷惑的this,真面向函式式程式設計!

深度挖掘Concent的effect,全面提升useEffect的開發體驗

更讓人喜歡的是,hook是可以自由組合、自由巢狀的,所以你的這個看起看起來很胖的FnPage裡的邏輯可以瞬間瘦身為

function useMyLogic(propTag){
    //剛才那一堆邏輯可以完全拷貝到這裡,然後把狀態和方法返回出去
    return {
      type, sex, addr, keyword, tag,
      changeType,changeSex,changeAddr, changeKeyword,
    };
}

const FnPage = React.memo(function({ tag: propTag }) {
  const {
    type, sex, addr, keyword, tag,
    changeType,changeSex,changeAddr, changeKeyword,
   } = useMyLogic(propTag);
  // return your ui
});
複製程式碼

useMyLogic函式可以在其他任意地方被複用!這將是多麼的方便,如果狀態更新比較複雜,官方還配套有useReducer來將業務邏輯從hook函式裡分離出去,如下程式碼Dan Abramov給的例子:
點選此處檢視線上示例

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>
  );
}
複製程式碼

?說到此處,是不是感覺對class無愛了呢?但是這樣使用hook組織業務程式碼真的就完美了嗎?沒有弱點了嗎?

使用Concent的effect,升級useEffect使用體驗

useMyLogic的確可以到處複用,useReducer的確將狀態從hook函式再度解耦和分離出去,但是它們的問題如下:

  • 問題1,本質上來說,hook是鼓勵開發者使用閉包的,因為hook元件函式每一幀渲染建立了對應那一刻的scope,在scope內部生成的各種狀態或者方法都將只對那一幀有效,但是我們逃不掉的是每一幀渲染都真真實實的建立了大量臨時的閉包函式,不短累計的確給js立即回收帶來了一些額外的壓力,我們能不能避免掉反覆建立臨時閉包函式這些這個問題呢?答案是當然可以,具體原因參見往期文章setup帶來的變革,這裡主要主要討論useEffect和Concent的effect做對比,針對setup就不在次做贅述。
  • 問題2,useReducer只是解決了解耦更新狀態邏輯和hook函式的問題,但是它本身只是一個純函式,非同步邏輯是無法寫在裡面的,你的非同步邏輯最終還是落地到自定義hook函式內部,且useReducer只是一個區域性的狀態管理,我們能不能痛快的實現狀態更新可非同步,可同步,能自由組合,且可以輕易的提升為全域性狀態管理目的呢,答案是當然可以,Concent的invoke介面將告訴你最終答案!
  • 問題3,useEffect的確解決了副作用程式碼管理的詬病,但是我們將類元件換為函式元件時,需要程式碼調整和邏輯轉換,我們能不能統一副作用程式碼管理方式,且讓類元件和函式元件可以0改造共用呢,答案同樣是完全可以,基於Concent的effect介面,你可以一行程式碼不用改而實現統一的副作用管理,這意味著你的元件可以任你在類與函式之間自由切換!

我們總結一下將要解決的3個問題:

  • 1 避免反覆建立臨時閉包函式。
  • 2 狀態更新可非同步,可同步,能自由組合,且可以輕易的提升為全域性狀態管理目的。
  • 3 統一副作用程式碼管理方式,讓類與函式實現0成本的無痛共享。

讓我們開始表演吧

深度挖掘Concent的effect,全面提升useEffect的開發體驗

改造FnPage函式元件

構造setup函式

const setup = ctx => {
  console.log('setup函式只會在元件初次渲染之前被執行一次');
  const fetchProducts = () => {
    const { type, sex, addr, keyword } = ctx.state;
    api.fetchProducts({ type, sex, addr, keyword })
      .then(products => ctx.setState({ products }))
      .catch(err => alert(err.message));
  };

  ctx.effect(() => {
    fetchProducts();
  }, ["type", "sex", "addr", "keyword"]);//這裡只需要傳key名稱就可以了
  /** 原函式元件內寫法:
    useEffect(() => {
      fetchProducts(type, sex, addr, keyword);
    }, [type, sex, addr, keyword]);
  */

  ctx.effect(() => {
    return () => {
      // 返回一個清理函式
      // 等價於componentWillUnmout, 這裡搞清理事情
    };
  }, []);
  /** 原函式元件內寫法:
    useEffect(()=>{
      return ()=>{// 返回一個清理函式
        // 等價於componentWillUnmout, 這裡搞清理事情
      }
    }, []);//第二位引數傳空陣列,次副作用只在初次渲染完畢後執行一次
  */

  ctx.effectProps(() => {
    // 對props上的變更書寫副作用,注意這裡不同於ctx.effect,ctx.effect是針對state寫副作用
    const curTag = ctx.props.tag;
    if (curTag !== ctx.prevProps.tag) ctx.setState({ tag: curTag });
  }, ["tag"]);//這裡只需要傳key名稱就可以了
  /** 原函式元件內寫法:
  useEffect(()=>{
    // 首次渲染時,此副作用還是會執行的,在內部巧妙的再比較一次,避免一次多餘的ui更新
    // 等價於上面元件類裡getDerivedStateFromProps裡的邏輯
    if(tag !== propTag)setTag(tag);
  }, [propTag, tag]);
 */

  return {// 返回結果收集在ctx.settings裡
    fetchProducts,
    //推薦使用此方式,把方法定義在settings裡,下面示例故意直接使用sync語法糖函式
    changeType: ctx.sync('type'),
  };
};
複製程式碼

setup邏輯構造完畢了,我們來看看函式元件是長什麼樣子滴

import { useConcent } from 'concent';

//定義狀態建構函式,傳遞給useConcent
const iState = () => ({ products:[], type: "", sex: "", addr: "", keyword: "", tag: "" });

const ConcentFnPage = React.memo(function({ tag: propTag }) {
  // useConcent返回ctx,這裡直接解構ctx,拿想用的物件或方法
  const { state, settings, sync } = useConcent({ setup, state: iState });
  const { products, type, sex, addr, keyword, tag } = state;
  const { fetchProducts } = settings;

  // 下面UI中使用sync語法糖函式同步狀態,如果為了最求極致的效能
  // 可將它們定義在setup返回結果裡,這樣不用每次渲染都生成臨時的更新函式
  return (
    <div className="conditionArea">
      <h1>concent setup compnent</h1>
      <select value={type} onChange={sync('type')}>
        <option value="1">1</option>
        <option value="2">2</option>
      </select>
      <select data-key="sex" value={sex} onChange={sync('sex')}>
        <option value="1">male</option>
        <option value="0">female</option>
      </select>
      <input data-key="addr" value={addr} onChange={sync('addr')} />
      <input data-key="keyword" value={keyword} onChange={sync('keyword')} />
      <button onClick={fetchProducts}>refresh</button>
      {products.map((v, idx)=><div key={idx}>name:{v.name} author:{v.author}</div>)}
    </div>
  );
});
複製程式碼

setup的強大之處在於,它只會在元件首次渲染之前執行一次,返回的結果蒐集在settings裡,這意味著你的api都是靜態宣告好的,而不是每次渲染再建立!同時在這個空間內你還可以定義其他的函式,如ctx.on定義事件監聽,ctx.computed定義計算函式,ctx.watch定義觀察函式等,這裡我們重點講得是ctx.effect,其他的使用方法可以查閱以下例子:
codesandbox.io/s/concent-g…
stackblitz.com/edit/concen…

我們現在看看效果吧

深度挖掘Concent的effect,全面提升useEffect的開發體驗

避免反覆建立臨時閉包函式

到此為止,我們解決了第一個問題即避免反覆建立臨時閉包函式

那如果我們的狀態更新邏輯伴隨著很多複雜的操作,避免不了的我們的setup body會越來臃腫,我們當然可以在把這些函式封裝一遍抽象出去,最後返回結果然後呼叫ctx.state去更新,但是concent提供更優雅的介面invoke讓你做這個事情,我們將這些邏輯封裝成一個個函式放置在一個檔案logic.js中,然後返回新的片段狀態,使用invoke呼叫它們

//code in logic.js

export function simpleUpdateType(type, moduleState, actionCtx){
    return { type };
}
複製程式碼

在你的setup體內你就可以構造一個將被收集到settings裡的屬性呼叫該函式了。

import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> ctx.invoke(lc.simpleUpdateType, e.currentTarget.value);
    }
}
複製程式碼

這也許看起來沒什麼嘛,不就是一個呼叫嗎,來來,我們換一個非同步的寫法

//code in logic.js
export async function complexUpdate(type, moduleState, actionCtx){
    await api.updateType(type);
    return { type };
}

// code in setup
import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> ctx.invoke(lc.complexUpdate, e.currentTarget.value);
    }
}
複製程式碼

是不是看起來舒服多了,更棒的是支援我們來書寫多個函式然後自由組合,大家或許注意到函式引數列表除了第一位payload,還有第二位moduleState,第三位actionCtx,若呼叫方不屬於任何模組則第二為引數是一個無內容的物件{},何時有值我們後面再做分析,這裡我們重點看第三位引數actionCtx,可以用它來串聯其他的函式,是不是特別方便呢?

//code in logic.js
export async function complexUpdateType(type, moduleState, actionCtx){
    await api.updateType(type);
    return { type };
}

export async function complexUpdateSex(sex, moduleState, actionCtx){
    await api.updateSex(sex);
    return { sex };
}

export async function updateTypeAndSex({type, sex}, moduleState, actionCtx){
    await actionCtx.invoke(complexUpdateType, type);
    await actionCtx.invoke(complexUpdateSex, sex);
}

// code in setup
import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> {
            // 為了配合這個演示,我們另開兩個key存type,sex^_^
            const {tmpType, tmpSex} = ctx.state;
            ctx.invoke(lc.updateTypeAndSex, {type:tmpType, sex:tmpSex}};
        }
    }
}
複製程式碼

那如果這個狀態我想其他元件共享改怎麼辦呢?我們只需要先將狀態的配置在run函式裡(z注:使用concent是一定要在渲染根元件前先呼叫run函式的),居然在使用useConcent的時候,標記模組名就ok了

先配置好模組

import { useConcent, run } from "concent";
import * as lc from './logic';

run({
    product:{
        //這裡複用剛才的狀態生成函式
        state: iState(), 
        // 把剛才的邏輯函式模組當做reducer配置在此處
        // 當然這裡可以不配置,不過推薦配上,方便呼叫處不需要再引入logic.js
        reducer: lc,
    }
});
複製程式碼

接下來在元件里加上模組標記吧,和ConcentFnPage對比,僅僅是將state屬性改為了module並設定為product

const ConcentFnModulePage = React.memo(function({ tag: propTag }) {
  // useConcent返回ctx,這裡直接解構ctx,拿想用的物件或方法
  const { state, settings, sync } = useConcent({ setup, module:'product' });
  const { products, type, sex, addr, keyword, tag } = state;
  const { fetchProducts } = settings;
    
  //此處略,和ConcentFnPage 一毛一樣的程式碼
  );
});
複製程式碼

注意哦,原ConcentFnPage依然能正常執行,一行程式碼也不用改,新的ConcentFnModulePage也只是在使用useConcent時,傳入了module值並去掉state,ctx.state將有所屬的模組注入,其他的程式碼包括setup體內也是一行都沒有改,但是它們執行起來效果是不一樣的,ConcentFnPage是無模組元件,它的例項們狀態是各自孤立的,例如例項1改變了狀態不會影響例項2,但是ConcentFnModulePage是註冊了product模組的元件,這意味著它的任何一個例項修改了狀態都會被同步到其他例項,狀態提升為共享是如此輕鬆!僅僅標記了一個模組記號。

來讓我們看看效果吧!注意concent shared comp2個例項的狀態是同步的。

深度挖掘Concent的effect,全面提升useEffect的開發體驗

到此為止,我們解決了第二個問題即狀態更新可非同步,可同步,能自由組合,且可以輕易的提升為全域性狀態管理,且提升的過程是如此絲滑與愜意。

統一副作用程式碼管理方式

那我們還剩最後一個目標:統一副作用程式碼管理方式,讓類與函式實現0成本的無痛共享。

這對於Concent更是輕而易舉了,總而言之,concent在setup裡提供的effect會自動根據註冊的元件型別來做智慧適配,對於類元件適配了它的各個生命週期函式即componentDidMountcomponentDidMountcomponentWillUnmount,對於函式元件適配了useEffect,所以切換成本一樣的是0代價!

改寫後的class元件如下,ctx從this獲取,註冊的引數交給register介面,注意哦,setup也是直接複用了的。

class ConcentFnModuleClass extends React.Component{
  render(){
    const { state, settings, sync } = this.ctx;
    const { products, type, sex, addr, keyword, tag } = state;
    const { fetchProducts, fetchByInfoke } = settings;
  
    //此處略,一毛一樣的程式碼
  }
}

export default register({ setup, module:'product' })(ConcentFnModuleClass);
複製程式碼

來看看效果吧!

shared comp 是函式元件,shared class comp是類元件。

深度挖掘Concent的effect,全面提升useEffect的開發體驗

結語

本文到此結束,我知道親愛的你一定有不少疑惑,或者想親自試一試,以上程式碼片段的線上示例在這裡,歡迎點選檢視,fork,並修改

當然了,還為你準備有一個生產可用的標準程式碼模板示例
js: codesandbox.io/s/concent-g…
ts: codesandbox.io/s/concent-g…

人到中年,生活不易,禿頭幾乎無法阻止,碼字艱辛,看上的看官就來顆✨星星唄 ❤ star me if you like concent ^_^

深度挖掘Concent的effect,全面提升useEffect的開發體驗

我們知道hook的誕生提升了react的開發體驗,那麼對於Concent來說呢,它做的遠比你想的更多,程式碼的拆分與組合,邏輯的分離與複用,狀態的定義與共享,都能給你的開發體驗再度幸福提升double or more,因為Concent的slogan是一個可預測、0入侵、漸進式、高效能的增強型狀態管理方案

於我而言,華髮雖已開始墜落,但若能以一人掉落的代價換來更多開發能夠保留住那一頭烏黑亮麗的濃密頭髮,瞬間覺得值了,哈哈?

深度挖掘Concent的effect,全面提升useEffect的開發體驗

相關文章