❤ star me if you like concent ^_^
序言
redux
、mobx
本身是一個獨立的狀態管理框架,各自有自己的抽象api,以其他UI框架無關(react, vue...),本文主要說的和react
搭配使用的對比效果,所以下文裡提到的redux
、mobx
暗含了react-redux
、mobx-react
這些讓它們能夠在react
中發揮功能的繫結庫,而concent
本身是為了react
貼身打造的開發框架,資料流管理只是作為其中一項功能,附帶的其他增強react開發體驗的特性可以按需使用,後期會刨去concent
裡所有與react
相關聯的部分發布concent-core
,它的定位才是與redux
、mobx
相似的。
所以其實將在本文裡登場的選手分別是
redux & react-redux
- slogan
JavaScript 狀態容器,提供可預測化的狀態管理 - 設計理念
單一資料來源,使用純函式修改狀態
mobx & mobx-react
- slogan:
簡單、可擴充套件的狀態管理 - 設計理念
任何可以從應用程式狀態派生的內容都應該派生
concent
- slogan:
可預測、0入侵、漸進式、高效能的react開發方案 - 設計理念
相信融合不可變+依賴收集的開發方式是react的未來,增強react元件特性,寫得更少,做得更多。
介紹完三者的背景,我們的舞臺正式交給它們,開始一輪輪角逐,看誰到最後會是你最中意的範兒?
結果預覽
以下5個較量回合實戰演示程式碼較多,此處將對比結果提前告知,方便粗讀看客可以快速瞭解。
store配置 | concent | mbox | redux |
---|---|---|---|
支援分離 | Yes | Yes | No |
無根Provider & 使用處無需顯式匯入 | Yes | No | No |
reducer無this | Yes | No | Yes |
store資料或方法無需人工對映到元件 | Yes | Yes | No |
redux counter示例
mobx counter示例
concent counter示例
_
狀態修改 | concent | mbox | redux |
---|---|---|---|
基於不可變原則 | Yes | No | Yes |
最短鏈路 | Yes | Yes | No |
ui源頭可追蹤 | Yes | No | No |
無this | Yes | No | Yes |
原子拆分&合併提交 | Yes(基於lazy) | Yes(基於transaction) | No |
_
依賴收集 | concent | mbox | redux |
---|---|---|---|
支援執行時收集依賴 | Yes | Yes | No |
精準渲染 | Yes | Yes | No |
無this | Yes | No | No |
只需一個api介入 | Yes | No | No |
_
衍生資料 | concent | mbox | redux(reselect) |
---|---|---|---|
自動維護計算結果之間的依賴 | Yes | Yes | No |
觸發讀取計算結果時收集依賴 | Yes | Yes | No |
計算函式無this | Yes | No | Yes |
redux computed示例
mobx computed示例
concent computed示例
_
todo-mvc實戰
redux todo-mvc
mobx todo-mvc
concent todo-mvc
round 1 - 程式碼風格初體驗
counter作為demo界的靚仔被無數次推上舞臺,這一次我們依然不例外,來個counter體驗3個框架的開發套路是怎樣的,以下3個版本都使用create-react-app
建立,並以多模組的方式來組織程式碼,力求接近真實環境的程式碼場景。
redux(action、reducer)
通過models
把按模組把功能拆到不同的reducer裡,目錄結構如下
|____models # business models
| |____index.js # 暴露store
| |____counter # counter模組相關的action、reducer
| | |____action.js
| | |____reducer.js
| |____ ... # 其他模組
|____CounterCls # 類元件
|____CounterFn # 函式元件
|____index.js # 應用入口檔案
此處僅與redux的原始模板組織程式碼,實際情況可能不少開發者選擇了rematch
,dva
等基於redux做二次封裝並改進寫法的框架,但是並不妨礙我們理解counter例項。
構造counter的action
// code in models/counter/action
export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
export const increase = number => {
return { type: INCREMENT, payload: number };
};
export const decrease = number => {
return { type: DECREMENT, payload: number };
};
構造counter的reducer
// code in models/counter/reducer
import { INCREMENT, DECREMENT } from "./action";
export default (state = { count: 0 }, action) => {
const { type, payload } = action;
switch (type) {
case INCREMENT:
return { ...state, count: state.count + payload };
case DECREMENT:
return { ...state, count: state.count - payload };
default:
return state;
}
};
合併reducer
構造store
,並注入到根元件
mport { createStore, combineReducers } from "redux";
import countReducer from "./models/counter/reducer";
const store = createStore(combineReducers({counter:countReducer}));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
使用connect連線ui與store
import React from "react";
import { connect } from "react-redux";
import { increase, decrease } from "./redux/action";
@connect(
state => ({ count: state.counter.count }),// mapStateToProps
dispatch => ({// mapDispatchToProps
increase: () => dispatch(increase(1)),
decrease: () => dispatch(decrease(1))
}),
)
class Counter extends React.Component {
render() {
const { count, increase, decrease } = this.props;
return (
<div>
<h1>Count : {count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>decrease</button>
</div>
);
}
}
export default Counter;
上面的示例書寫了一個類元件,而針對現在火熱的hook
,redux v7
也釋出了相應的apiuseSelector
、useDispatch
import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import * as counterAction from "models/counter/action";
const Counter = () => {
const count = useSelector(state => state.counter.count);
const dispatch = useDispatch();
const increase = () => dispatch(counterAction.increase(1));
const decrease = () => dispatch(counterAction.decrease(1));
return (
<>
<h1>Fn Count : {count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>decrease</button>
</>
);
};
export default Counter;
渲染這兩個counter,檢視redux示例
function App() {
return (
<div className="App">
<CounterCls/>
<CounterFn/>
</div>
);
}
mobx(store, inject)
當應用存在多個store時(這裡我們可以把一個store理解成redux裡的一個reducer塊,聚合了資料、衍生資料、修改行為),mobx的store獲取方式有多種,例如在需要用的地方直接引入放到成員變數上
import someStore from 'models/foo';// 是一個已經例項化的store例項
@observer
class Comp extends React.Component{
foo = someStore;
render(){
this.foo.callFn();//調方法
const text = this.foo.text;//取資料
}
}
我們此處則按照公認的最佳實踐來做,即把所有store合成一個根store掛到Provider上,並將Provider包裹整個應用根元件,在使用的地方標記inject
裝飾器即可,我們的目錄結構最終如下,和redux
版本並無區別
|____models # business models
| |____index.js # 暴露store
| |____counter # counter模組相關的store
| | |____store.js
| |____ ... # 其他模組
|____CounterCls # 類元件
|____CounterFn # 函式元件
|____index.js # 應用入口檔案
構造counter的store
import { observable, action, computed } from "mobx";
class CounterStore {
@observable
count = 0;
@action.bound
increment() {
this.count++;
}
@action.bound
decrement() {
this.count--;
}
}
export default new CounterStore();
合併所有store
為根store
,並注入到根元件
// code in models/index.js
import counter from './counter';
import login from './login';
export default {
counter,
login,
}
// code in index.js
import React, { Component } from "react";
import { render } from "react-dom";
import { Provider } from "mobx-react";
import store from "./models";
import CounterCls from "./CounterCls";
import CounterFn from "./CounterFn";
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
建立一個類元件
import React, { Component } from "react";
import { observer, inject } from "mobx-react";
@inject("store")
@observer
class CounterCls extends Component {
render() {
const counter = this.props.store.counter;
return (
<div>
<div> class Counter {counter.count}</div>
<button onClick={counter.increment}>+</button>
<button onClick={counter.decrement}>-</button>
</div>
);
}
}
export default CounterCls;
建立一個函式元件
import React from "react";
import { useObserver, observer } from "mobx-react";
import store from "./models";
const CounterFn = () => {
const { counter } = store;
return useObserver(() => (
<div>
<div> class Counter {counter.count}</div>
<button onClick={counter.increment}>++</button>
<button onClick={counter.decrement}>--</button>
</div>
));
};
export default CounterFn;
渲染這兩個counter,檢視mobx示例
function App() {
return (
<div className="App">
<CounterCls/>
<CounterFn/>
</div>
);
}
concent(reducer, register)
concent和redux一樣,存在一個全域性單一的根狀態RootStore
,該根狀態下第一層key用來當做模組名稱空間,concent的一個模組必需配置state
,剩下的reducer
、computed
、watch
、init
是可選項,可以按需配置,如果把store所有模組寫到一處,最簡版本的concent
示例如下
import { run, setState, getState, dispatch } from 'concent';
run({
counter:{// 配置counter模組
state: { count: 0 }, // 【必需】定義初始狀態, 也可寫為函式 ()=>({count:0})
// reducer: { ...}, // 【可選】修改狀態的方法
// computed: { ...}, // 【可選】計算函式
// watch: { ...}, // 【可選】觀察函式
// init: { ...}, // 【可選】非同步初始化狀態函式
}
});
const count = getState('counter').count;// count is: 0
// count is: 1,如果有元件屬於該模組則會被觸發重渲染
setState('counter', {count:count + 1});
// 如果定義了counter.reducer下定義了changeCount方法
// dispatch('counter/changeCount')
啟動concent
載入store後,可在其它任意類元件或函式元件裡註冊其屬於於某個指定模組或者連線多個模組
import { useConcent, register } from 'concent';
function FnComp(){
const { state, setState, dispatch } = useConcent('counter');
// return ui ...
}
@register('counter')
class ClassComp extends React.Component(){
render(){
const { state, setState, dispatch } = this.ctx;
// return ui ...
}
}
但是推薦將模組定義選項放置到各個檔案中,以達到職責分明、關注點分離的效果,所以針對counter,目錄結構如下
|____models # business models
| |____index.js # 配置store各個模組
| |____counter # counter模組相關
| | |____state.js # 狀態
| | |____reducer.js # 修改狀態的函式
| | |____index.js # 暴露counter模組
| |____ ... # 其他模組
|____CounterCls # 類元件
|____CounterFn # 函式元件
|____index.js # 應用入口檔案
|____runConcent.js # 啟動concent
構造counter的state
和reducer
// code in models/counter/state.js
export default {
count: 0,
}
// code in models/counter/reducer.js
export function increase(count, moduleState) {
return { count: moduleState.count + count };
}
export function decrease(count, moduleState) {
return { count: moduleState.count - count };
}
兩種方式配置store
- 配置在run函式裡
import counter from 'models/counter';
run({counter});
- 通過
configure
介面配置,run
介面只負責啟動concent
// code in runConcent.js
import { run } from 'concent';
run();
// code in models/counter/index.js
import state from './state';
import * as reducer from './reducer';
import { configure } from 'concent';
configure('counter', {state, reducer});// 配置counter模組
建立一個函式元件
import * as React from "react";
import { useConcent } from "concent";
const Counter = () => {
const { state, dispatch } = useConcent("counter");
const increase = () => dispatch("increase", 1);
const decrease = () => dispatch("decrease", 1);
return (
<>
<h1>Fn Count : {state.count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>decrease</button>
</>
);
};
export default Counter;
該函式元件我們是按照傳統的hook
風格來寫,即每次渲染執行hook
函式,利用hook
函式返回的基礎介面再次定義符合當前業務需求的動作函式。
但是由於concent提供setup
介面,我們可以利用它只會在初始渲染前執行一次的能力,將這些動作函式放置到setup
內部定義為靜態函式,避免重複定義,所以一個更好的函式元件應為
import * as React from "react";
import { useConcent } from "concent";
export const setup = ctx => {
return {
// better than ctx.dispatch('increase', 1);
increase: () => ctx.moduleReducer.increase(1),
decrease: () => ctx.moduleReducer.decrease(1)
};
};
const CounterBetter = () => {
const { state, settings } = useConcent({ module: "counter", setup });
const { increase, decrease } = settings;
// return ui...
};
export default CounterBetter;
建立一個類元件,複用setup
裡的邏輯
import React from "react";
import { register } from "concent";
import { setup } from './CounterFn';
@register({module:'counter', setup})
class Counter extends React.Component {
render() {
// this.state 和 this.ctx.state 取值效果是一樣的
const { state, settings } = this.ctx;
// return ui...
}
}
export default Counter;
渲染這兩個counter,檢視concent示例
function App() {
return (
<div className="App">
<CounterCls />
<CounterFn />
</div>
);
}
回顧與總結
此回合裡展示了3個框架對定義多模組狀態時,不同的程式碼組織與結構
redux
通過combineReducers
配合Provider
包裹根元件,同時還收手寫mapStateToProps
和mapActionToProps
來輔助元件獲取store的資料和方法mobx
通過合併多個subStore
到一個store
物件並配合Provider
包裹根元件,store的資料和方法可直接獲取concent
通過run
介面集中配置或者configure
介面分離式的配置,store的資料和方法可直接獲取
store配置 | concent | mbox | redux |
---|---|---|---|
支援分離 | Yes | Yes | No |
無根Provider & 使用處無需顯式匯入 | Yes | No | No |
reducer無this | Yes | No | Yes |
store資料或方法無需人工對映到元件 | Yes | Yes | No |
round 2 - 狀態修改
3個框架對狀態的修改風格差異較大。redux
裡嚴格限制狀態修改途徑,所以的修改狀態行為都必須派發action
,然後命中相應reducer
合成新的狀態。
mobx
具有響應式的能力,直接修改即可,但因此也帶來了資料修改途徑不可追溯的煩惱從而產生了mobx-state-tree
來配套約束脩改資料行為。
concent
的修改完完全全遵循react
的修改入口setState
風格,在此基礎之上進而封裝dispatch
、invoke
、sync
系列api,且無論是呼叫哪一種api,都能夠不只是追溯資料修改完整鏈路,還包括觸發資料修改的源頭。
redux(dispatch)
同步的action
export const changeFirstName = firstName => {
return {
type: CHANGE_FIRST_NAME,
payload: firstName
};
};
非同步的action,藉助redux-thunk
來完成
// code in models/index.js, 配置thunk中介軟體
import thunk from "redux-thunk";
import { createStore, combineReducers, applyMiddleware } from "redux";
const store = createStore(combineReducers({...}), applyMiddleware(thunk));
// code in models/login/action.js
export const CHANGE_FIRST_NAME = "CHANGE_FIRST_NAME";
const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));
// 工具函式,輔助寫非同步action
const asyncAction = asyncFn => {
return dispatch => {
asyncFn(dispatch).then(ret => {
if(ret){
const [type, payload] = ret;
dispatch({ type, payload });
}
}).catch(err=>alert(err));
};
};
export const asyncChangeFirstName = firstName => {
return asyncAction(async (dispatch) => {//可用於中間過程多次dispatch
await delay();
return [CHANGE_FIRST_NAME, firstName];
});
};
mobx版本(this.XXX)
同步action與非同步action
import { observable, action, computed } from "mobx";
const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));
class LoginStore {
@observable firstName = "";
@observable lastName = "";
@action.bound
changeFirstName(firstName) {
this.firstName = firstName;
}
@action.bound
async asyncChangeFirstName(firstName) {
await delay();
this.firstName = firstName;
}
@action.bound
changeLastName(lastName) {
this.lastName = lastName;
}
}
export default new LoginStore();
直接修改
const LoginFn = () => {
const { login } = store;
const changeFirstName = e => login.firstName = e.target.value;
// ...
}
通過action修改
const LoginFn = () => {
const { login } = store;
const const changeFirstName = e => login.changeFirstName(e.target.value);
// ...
}
concent(dispatch,setState,invoke,sync)
concent裡不再區分action
和reducer
,ui直接呼叫reducer
方法即可,同時reducer
方法可以是同步也可以是非同步,支援相互任意組合和lazy呼叫,大大減輕開發者的心智負擔。
同步reducer
與非同步reducer
// code in models/login/reducer.js
const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));
export function changeFirstName(firstName) {
return { firstName };
}
export async function asyncChangeFirstName(firstName) {
await delay();
return { firstName };
}
export function changeLastName(lastName) {
return { lastName };
}
可任意組合的reducer,屬於同一個模組內的方法可以直接基於方法引用呼叫,且reducer函式並非強制一定要返回一個新的片斷狀態,僅用於組合其他reducer也是可以的。
// reducerFn(payload:any, moduleState:{}, actionCtx:IActionCtx)
// 當lazy呼叫此函式時,任何一個函式出錯了,中間過程產生的所有狀態都不會提交到store
export async changeFirstNameAndLastName([firstName, lastName], m, ac){
await ac.dispatch(changeFirstName, firstName);
await ac.dispatch(changeFirstName, lastName);
// return {someNew:'xxx'};//可選擇此reducer也返回新的片斷狀態
}
// 檢視處
function UI(){
const ctx useConcent('login');
// 觸發兩次渲染
const normalCall = ()=>ctx.mr.changeFirstNameAndLastName(['first', 'last']);
// 觸發一次渲染
const lazyCall = ()=>ctx.mr.changeFirstNameAndLastName(['first', 'last'], {lazy:true});
return (
<>
<button onClick={handleClick}> normalCall </button>
<button onClick={handleClick}> lazyCall </button>
</>
)
}
非lazy呼叫流程
lazy呼叫流程
當然了,除了reducer
,其他3種方式都可以任意搭配,且和reducer
一樣擁有同步狀態到其他屬於同一個模組且對某狀態有依賴的例項上
- setState
function FnUI(){
const {setState} = useConcent('login');
const changeName = e=> setState({firstName:e.target.name});
// ... return ui
}
@register('login')
class ClsUI extends React.Component{
changeName = e=> this.setState({firstName:e.target.name})
render(){...}
}
- invoke
function _changeName(firstName){
return {firstName};
}
function FnUI(){
const {invoke} = useConcent('login');
const changeName = e=> invoke(_changeName, e.target.name);
// ... return ui
}
@register('login')
class ClsUI extends React.Component{
changeName = e=> this.ctx.invoke(_changeName, e.target.name)
render(){...}
}
- sync
function FnUI(){
const {sync, state} = useConcent('login');
return <input value={state.firstName} onChange={sync('firstName')} />
}
@register('login')
class ClsUI extends React.Component{
changeName = e=> this.ctx.invoke(_changeName, e.target.name)
render(){
return <input value={this.state.firstName} onChange={this.ctx.sync('firstName')} />
}
}
還記得我們在round 2開始比較前對concent提到了這樣一句話:能夠不只是追溯資料修改完整鏈路,還包括觸發資料修改的源頭,它是何含義呢,因為每一個concent元件的ctx
都擁有一個唯一idccUniqueKey
標識當前元件例項,它是按{className}_{randomTag}_{seq}
自動生成的,即類名(不提供是就是元件型別$$CClass`, `$$CCFrag
, $$CCHook
)加隨機標籤加自增序號,如果想刻意追蹤修改源頭ui,則人工維護tag
,ccClassKey
既可,再配合上concent-plugin-redux-devtool就能完成我們的目標了。
function FnUI(){
const {sync, state, ccUniqueKey} = useConcent({module:'login', tag:'xxx'}, 'FnUI');
// tag 可加可不加,
// 不加tag,ccUniqueKey形如: FnUI_xtst4x_1
// 加了tag,ccUniqueKey形如: FnUI_xxx_1
}
@register({module:'login', tag:'yyy'}, 'ClsUI')
class ClsUI extends React.Component{...}
接入concent-plugin-redux-devtool
後,可以看到任何動作修改Action裡都會包含一個欄位ccUniqueKey
。
回顧與總結
這一個回合我們針對資料修改方式做了全面對比,從而讓開發者瞭解到從concent
的角度來說,為了開發者的編碼體驗做出的各方面巨大努力。
針對狀態更新方式, 對比redux
,當我們的所有動作流程壓到最短,無action-->reducer這樣一條鏈路,無所謂的存函式還是副作用函式的區分(rematch
、dva
等提取的概念),把這些概念交給js
語法本身,會顯得更加方便和清晰,你需要純函式,就寫export function
,需要副作用函式就寫export async function
。
對比mobx
,一切都是可以任何拆開任意組合的基礎函式,沒有this
,徹底得面向FP,給一個input
預期output
,這樣的方式對測試容器也更加友好。
狀態修改 | concent | mbox | redux |
---|---|---|---|
基於不可變原則 | Yes | No | Yes |
最短鏈路 | Yes | Yes | No |
ui源頭可追蹤 | Yes | No | No |
無this | Yes | No | Yes |
原子拆分&合併提交 | Yes(基於lazy) | Yes(基於transaction) | No |
round 3 - 依賴收集
這個回合是非常重量級的一個環節,依賴收集讓ui渲染可以保持最小範圍更新,即精確更新,所以vue
某些測試方面會勝出react
,當我們為react
插上依賴收集的翅膀後,看看會有什麼更有趣的事情發生吧。
再開始聊依賴收集
之前,我們覆盤一下react
原本的渲染機制吧,當某一個元件發生狀態改變時,如果它的自定義元件沒有人工維護shouldComponentUpdate
判斷時,總是會從上往下全部渲染一遍,而redux
的cconnect
介面接管了shouldComponentUpdate
行為,當一個action觸發了動作修改時,所有connect過的元件都會將上一刻mapStateToProps
得到的狀態和當前最新mapStateToProps
得到的狀態做淺比較,從而決定是否要重新整理包裹的子元件。
到了hook時代,提供了React.memo
來使用者阻斷這種"株連式"的更新,但是需要使用者儘量傳遞primitive
型別資料或者不變化的引用給props
,否則React.memo
的淺比較會返回false。
但是redux
存在的一個問題是,如果檢視裡某一刻已經不再使用某個狀態了,它不該被渲染卻被渲染了,mobx
攜帶得基於執行時獲取到ui對資料的最小訂閱子集理念優雅的解決了這個問題,但是concent
更近一步將依賴收集行為隱藏的更優雅,使用者不需要不知道observable
等相關術語和概念,某一次渲染你取值有了點這個值的依賴,而下一次渲染沒有了對某個stateKey
的取值行為就應該移出依賴,這一點vue
做得很好,為了讓react
擁有更優雅、更全面的依賴收集機制,concent
同樣做出了很多努力。
redux版本(不支援)
解決依賴收集不是redux
誕生的初衷,這裡我們只能默默的將它請到候選區,參與下一輪的較量了。
mobx版本(computed,useObserver)
利用裝飾器或者decorate
函式標記要觀察的屬性或者計算的屬性
import { observable, action, computed } from "mobx";
const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));
class LoginStore {
@observable firstName = "";
@observable lastName = "";
@computed
get fullName(){
return `${this.firstName}_${this.lastName}`
}
@computed
get nickName(){
return `${this.firstName}>>nicknick`
}
@computed
get anotherNickName(){
return `${this.nickName}_another`
}
}
export default new LoginStore();
ui裡使用了觀察狀態或者結算結果時,就產生了依賴
- 僅對計算結果有依賴,類元件寫法
@inject("store")
@observer
class LoginCls extends Component {
state = {show:true};
toggle = ()=> this.setState({show:!this.state.show})
render() {
const login = this.props.store.login;
return (
<>
<h1>Cls Small Comp</h1>
<button onClick={this.toggle}>toggle</button>
{this.state.show ? <div> fullName:{login.fullName}</div>: ""}
</>
)
}
}
- 僅對計算結果有依賴,函式元件寫法
import { useObserver } from "mobx-react";
// show為true時,當前元件讀取了fullName,
// fullName由firstName和lastName計算而出
// 所以他的依賴是firstName、lastName
// 當show為false時,當前元件無任何依賴
export const LoginFnSmall = React.memo((props) => {
const [show, setShow] = React.useState(true);
const toggle = () => setShow(!show);
const { login } = store;
return useObserver(() => {
return (
<>
<h1>Fn Small Comp</h1>
<button onClick={toggle}>toggle</button>
{show ? <div> fullName:{login.fullName}</div>: ""}
</>
)
});
});
對狀態有依賴和對計算結果有依賴無任何區別,都是在執行時從this.props.login
上獲取相關結果就產生了ui對資料的依賴關係。
concent(state,moduleComputed)
無需任何裝飾器來標記觀察屬性和計算結果,僅僅是普通的json
物件和函式,執行時階段被自動轉為Proxy
物件。
計算結果依賴
// code in models/login/computed.js
// n: newState, o: oldState, f: fnCtx
// fullName的依賴是firstName lastName
export function fullName(n, o, f){
return `${n.firstName}_${n.lastName}`;
}
// nickName的依賴是firstName
export function nickName(n, o, f){
return `${n.firstName}>>nicknick`
}
// anotherNickName基於nickName快取結果做二次計算,而nickName的依賴是firstName
// 所以anotherNickName的依賴是firstName,注意需將此函式放置到nickName下面
export function anotherNickName(n, o, f){
return `${f.cuVal.nickName}_another`;
}
- 僅對計算結果有依賴,類元件寫法
@register({ module: "login" })
class _LoginClsSmall extends React.Component {
state = {show:true};
render() {
const { state, moduleComputed: mcu, syncBool } = this.ctx;
// show為true時例項的依賴為firstName+lastName
// 為false時,則無任何依賴
return (
<>
<h1>Fn Small Comp</h1>
<button onClick={syncBool("show")}>toggle</button>
{state.show ? <div> fullName:{mcu.fullName}</div> : ""}
</>
);
}
}
- 僅對計算結果有依賴,函式元件寫法
export const LoginFnSmall = React.memo(props => {
const { state, moduleComputed: mcu, syncBool } = useConcent({
module: "login",
state: { show: true }
});
return (
<>
<h1>Fn Small Comp</h1>
<button onClick={syncBool("show")}>toggle</button>
{state.show ? <div> fullName:{mcu.fullName}</div> : ""}
</>
);
});
和mobx
一樣,對狀態有依賴和對計算結果有依賴無任何區別,在執行時從ctx.state
上獲取相關結果就產生了ui對資料的依賴關係,每一次渲染concent
都在動態的收集當前例項最新的依賴,在例項didUpdate
階段移出已消失的依賴。
- 生命週期依賴
concent
的架構裡是統一了類元件和函式元件的生命週期函式的,所以當某個狀態被改變時,對此有依賴的生命週期函式會被觸發,並支援類與函式共享此邏輯
export const setupSm = ctx=>{
// 當firstName改變時,元件渲染渲染完畢後會觸發
ctx.effect(()=>{
console.log('fisrtName changed', ctx.state.fisrtName);
}, ['firstName'])
}
// 類元件裡使用
export const LoginFnSmall = React.memo(props => {
console.log('Fn Comp ' + props.tag);
const { state, moduleComputed: mcu, sync } = useConcent({
module: "login",setup: setupSm, state: { show: true }
});
//...
}
// 函式元件裡使用
@register({ module: "login", setup:setupSm })
class _LoginClsSmall extends React.Component {...}
回顧與總結
在依賴收集這一個回合,concent
的依賴收集形式、和元件表達形式,和mobx
區別都非常大,整個依賴收集過程沒有任何其他多餘的api介入, 而mbox
需用computed
修飾getter欄位,在函式元件需要使用useObserver
包狀態返回UI,concent
更注重一切皆函式,在組織計算程式碼的過程中消除的this
這個關鍵字,利用fnCtx
函式上下文傳遞已計算結果,同時顯式的區分state
和computed
的盛放容器物件。
依賴收集 | concent | mbox | redux |
---|---|---|---|
支援執行時收集依賴 | Yes | Yes | No |
精準渲染 | Yes | Yes | No |
無this | Yes | No | No |
只需一個api介入 | Yes | No | No |
round 4 - 衍生資料
還記得mobx
的口號嗎?任何可以從應用程式狀態派生的內容都應該派生,揭示了一個的的確確存在且我們無法逃避的問題,大多數應用狀態傳遞給ui使用前都會伴隨著一個計算過程,其計算結果我們稱之為衍生資料。
我們都知道在vue
裡已內建了這個概念,暴露了一個可選項computed
用於處理計算過程並快取衍生資料,react
並無此概念,redux
也並不提供此能力,但是redux
開放的中介軟體機制讓社群得以找到切入點支援此能力,所以此處我們針對redux
說到的計算指的已成為事實上的流行標準庫reslect
.
mobx
和concent
都自帶計算支援,我們在上面的依賴收集回合裡已經演示了mobx
和concent
的衍生資料程式碼,所以此輪僅針對redux
書寫衍生資料示例
redux(reselect)
redux最新發布v7
版本,暴露了兩個api,useDispatch
和useSelector
,用法以之前的mapStateToState
和mapDispatchToProps
完全對等,我們的示例裡會用類元件和函式元件都演示出來。
定義selector
import { createSelector } from "reselect";
// getter,僅用於取值,不參與計算
const getFirstName = state => state.login.firstName;
const getLastName = state => state.login.lastName;
// selector,等同於computed,手動傳入計算依賴關係
export const selectFullName = createSelector(
[getFirstName, getLastName],
(firstName, lastName) => `${firstName}_${lastName}`
);
export const selectNickName = createSelector(
[getFirstName],
(firstName) => `${firstName}>>nicknick`
);
export const selectAnotherNickName = createSelector(
[selectNickName],
(nickname) => `${nickname}_another`
);
類元件獲取selector
import React from "react";
import { connect } from "react-redux";
import * as loginAction from "models/login/action";
import {
selectFullName,
selectNickName,
selectAnotherNickName
} from "models/login/selector";
@connect(
state => ({
firstName: state.login.firstName,
lastName: state.login.lastName,
fullName: selectFullName(state),
nickName: selectNickName(state),
anotherNickName: selectAnotherNickName(state),
}), // mapStateToProps
dispatch => ({
// mapDispatchToProps
changeFirstName: e =>
dispatch(loginAction.changeFirstName(e.target.value)),
asyncChangeFirstName: e =>
dispatch(loginAction.asyncChangeFirstName(e.target.value)),
changeLastName: e => dispatch(loginAction.changeLastName(e.target.value))
})
)
class Counter extends React.Component {
render() {
const {
firstName,
lastName,
fullName,
nickName,
anotherNickName,
changeFirstName,
asyncChangeFirstName,
changeLastName
} = this.props;
return 'ui ...'
}
}
export default Counter;
函式元件獲取selector
import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import * as loginAction from "models/login/action";
import {
selectFullName,
selectNickName,
selectAnotherNickName
} from "models/login/selector";
const Counter = () => {
const { firstName, lastName } = useSelector(state => state.login);
const fullName = useSelector(selectFullName);
const nickName = useSelector(selectNickName);
const anotherNickName = useSelector(selectAnotherNickName);
const dispatch = useDispatch();
const changeFirstName = (e) => dispatch(loginAction.changeFirstName(e.target.value));
const asyncChangeFirstName = (e) => dispatch(loginAction.asyncChangeFirstName(e.target.value));
const changeLastName = (e) => dispatch(loginAction.changeLastName(e.target.value));
return 'ui...'
);
};
export default Counter;
mobx(computed裝飾器)
見上面依賴收集的例項程式碼,此處不再重敘。
concent(moduleComputed直接獲取)
見上面依賴收集的例項程式碼,此處不再重敘。
回顧與總結
相比mobx
可以直接從this.pops.someStore
獲取,concent
可以直接從ctx.moduleComputed
上獲取,多了一個手動維護計算依賴的過程或對映挑選結果的過程,相信哪種方式是開發者更願意使用的這個結果已經一目瞭然了。
衍生資料 | concent | mbox | redux(reselect) |
---|---|---|---|
自動維護計算結果之間的依賴 | Yes | Yes | No |
觸發讀取計算結果時收集依賴 | Yes | Yes | No |
計算函式無this | Yes | No | Yes |
round 5 - 實戰TodoMvc
上面4個回合結合了一個個鮮活的程式碼示例,綜述了3個框架的特點與編碼風格,相信讀者期望能有更加接近生產環境的程式碼示例來看出其差異性吧,那麼最後讓我們以TodoMvc
來收尾這次特性大比拼,期待你能夠更多的瞭解並體驗concent
,開啟 不可變 & 依賴收集 的react程式設計之旅吧。
redux-todo-mvc
action 相關
reducer 相關
computed 相關
mobx-todo-mvc
action 相關
computed 相關
concent-todo-mvc
reducer相關
computed相關
end
最後讓我們用一個最簡版本的concent應用結束此文,未來的你會選擇concent作為你的react開發武器嗎?
import React from "react";
import "./styles.css";
import { run, useConcent, defWatch } from 'concent';
run({
login:{
state:{
name:'c2',
addr:'bj',
info:{
sex: '1',
grade: '19',
}
},
reducer:{
selectSex(sex, moduleState){
const info = moduleState.info;
info.sex = sex;
return {info};
}
},
computed: {
funnyName(newState){
// 收集到funnyName對應的依賴是 name
return `${newState.name}_${Date.now()}`
},
otherFunnyName(newState, oldState, fnCtx){
// 獲取了funnyName的計算結果和newState.addr作為輸入再次計算
// 所以這裡收集到otherFunnyName對應的依賴是 name addr
return `${fnCtx.cuVal.funnyName}_${newState.addr}`
}
},
watch:{
// watchKey name和stateKey同名,預設監聽name變化
name(newState, oldState){
console.log(`name changed from ${newState.name} to ${oldState.name}`);
},
// 從newState 讀取了addr, info兩個屬性的值,當前watch函式的依賴是 addr, info
// 它們任意一個發生變化時,都會觸發此watch函式
addrOrInfoChanged: defWatch((newState, oldState, fnCtx)=>{
const {addr, info} = newState;
if(fnCtx.isFirstCall)return;// 僅為了收集到依賴,不執行邏輯
console.log(`addr is${addr}, info is${JSON.stringify(info)}`);
}, {immediate:true})
}
}
})
function UI(){
console.log('UI with state value');
const {state, sync, dispatch} = useConcent('login');
return (
<div>
name:<input value={state.name} onChange={sync('name')} />
addr:<input value={state.addr} onChange={sync('addr')} />
<br />
info.sex:<input value={state.info.sex} onChange={sync('info.sex')} />
info.grade:<input value={state.info.grade} onChange={sync('info.grade')} />
<br />
<select value={state.info.sex} onChange={(e)=>dispatch('selectSex', e.target.value)}>
<option value="male">male</option>
<option value="female">female</option>
</select>
</div>
);
}
function UI2(){
console.log('UI2 with comptued value');
const {state, moduleComputed, syncBool} = useConcent({module:'login', state:{show:true}});
return (
<div>
{/* 當show為true的時候,當前元件的依賴是funnyName對應的依賴 name */}
{state.show? <span>dep is name: {moduleComputed.funnyName}</span> : 'UI2 no deps now'}
<br/><button onClick={syncBool('show')}>toggle show</button>
</div>
);
}
function UI3(){
console.log('UI3 with comptued value');
const {state, moduleComputed, syncBool} = useConcent({module:'login', state:{show:true}});
return (
<div>
{/* 當show為true的時候,當前元件的依賴是funnyName對應的依賴 name addr */}
{state.show? <span>dep is name,addr: {moduleComputed.otherFunnyName}</span> : 'UI3 no deps now'}
<br/><button onClick={syncBool('show')}>toggle show</button>
</div>
);
}
export default function App() {
return (
<div className="App">
<h3>try click toggle btn and open console to see render log</h3>
<UI />
<UI />
<UI2 />
<UI3 />
</div>
);
}