Sigi framework introduction
前言
這篇文章只會介紹Sigi framework
的設計理念以及試圖解決哪些問題,不會對各個API
有詳細的描述,如果你想開始學習並使用Sigi framework
請到
從Redux 而來
在Redux時代,有無數人努力著讓業務中的樣板代碼(boilerplate code)稍微少一點。最早的時候,我們通過redux-actions
redux-toolkit
等工具庫減少樣板代碼,在不考慮TypeScript
的情況下這些工具有非常好的抽象效果,在這兩個庫的文檔中可以看到在JavaScript
項目中使用它們之後帶來的顯著效果。但隨著TypeScript
的到來,有很多種方式的努力都付諸東流,因為大家發現除了與Redux
相關的Action/Reducer/Middleware
三件套的樣板代碼需要去除,連接這三個部分的類型代碼也同樣多如牛毛。
業務邏輯割裂
業務邏輯割裂分為兩個方面,一個是code path斷裂,一個是類型推導割裂。
Redux
分離Action
, Reducer
與Side effect
的設計能讓我們在寫業務的時候更容易寫出乾淨無副作用的組件,並且能讓我們更好分離各部分業務的職責。而這種設計如果不加以封裝則會讓代碼的Code path過於冗長,不利於連貫的進行代碼閱讀與業務邏輯理解,提高代碼的維護成本。而早期社區推崇的Rails風格的抽象方式(將action/reducer/side effect的代碼分文件夾放在一起)更是極大的放大了這一問題。
隨著社區實踐的完善,大家發現遵循Domain style/Ducts來組織業務邏輯相對於Rails 风格
更適合大型Redux
應用,但它還是沒有徹底解決業務邏輯Code path
過長、邏輯割裂的問題。我們以一個典型的基於redux-actions
和Ducts
風格組織的Redux
應用為例:
// count.module.tsconstADD_COUNT=createAction<number>('ADD_COUNT')exportinterfaceCountDispatchProps{addOne:typeofADD_COUNT}exportinterfaceCountStateProps{count:number}// reducerexportconstreducer=handleActions({[`${ADD_COUNT}`]:(state:CountStateProps,{payload}:Action<number>)=>{return{...state,count:state.count+payload}}},{count:0})// own props which passed by parent componentsinterfaceÇountOwnProps{countToAdd:number}typeCountProps=CountStateProps&CountDispatchProps&CountOwnPropsclassCountComponentextendsReact.PureComponent<CountProps>{privateonClickAddCount=()=>{this.props.addCount(this.props.countToAdd)}render(){return(<div><buttononClick={this.onClickAddCount}>addcount</button>{this.props.count}</div>)}}// react actions dispatcherexportconstCount=connect(mapStateToProps,(dispatch)=>bindActionCreators({addCount:ADD_COUNT,}asCountDispatchProps,dispatch))(CountComponent)
我們在閱讀這個簡單的組件的業務邏輯的時候,如果想看this.props.addCount
背後到底是什麼樣的業務邏輯,需要先找到connect
中,這個props
是如何被傳入組件的,然後找到這個dispatch props
對應的Aciton
是什麼,然後跳轉到count.module.ts
文件中,找到Aciton
的定義,再利用文件內搜索功能,找到哪裡的Reducer/Side effect
處理了這個Action
。歸納下來:
- 找到
mapDispatchToProps
中對應的Action
- 找到
module
文件中對應的Action
- 搜索
Action
對應的Reducer/Side effect
並且隨之而來的是,在TypeScript
的項目中, Action
處定義的類型並不能自動傳遞給調用這個Action
的地方。比如在上面的例子中, ADD_COUNT
定義的類型payload
為number
,而在消費這個Action
的reducer
中, payload
類型必須重新指定一次,而且即使不一致也不會被TypeScript
捕獲到。
分形
Redux
的中心是一個單例的Store
對象,任何基於Redux
的組件都必須關聯到這個Store
上才能正常使用。這意味著在編寫一個帶業務邏輯的組件時,如果我們想要使用Redux
抽像一些複雜的邏輯,或者復用已有的一些基於Redux
的通用代碼時,不得不考慮暴露的API
的易用性。這些情況下簡單的暴露組件是不夠的,還必須讓使用方把自己的reducer/side effect
等邏輯接入到Store
中,並且還要考慮命名衝突等問題。
也就是說基於Redux
很難做出分形的組件。
Sigi 的設計
邏輯的連貫
Sigi
的核心借鑒了Redux
的設計,所有的高層次的概念都是基於Action/Reducer/Side effect
封裝而成。在業務代碼中我們的API
設計理念跟Redux
也比較類似,強制讓業務的Dispatcher/Reducer/Side effect
的代碼分開編寫,保持邏輯的干淨。而在徹底的分離背後,我們也保持了邏輯的連貫。與大多數Redux
封裝不一樣的是, Sigi
的dispatch props
可以通過TypeScript
提供的的jump to definition
功能直接跳轉到dispatcher
對應的邏輯:
// index.tsximport"reflect-metadata";importReactfrom"react";import{render}from"react-dom";import{useEffectModule}from"@sigi/react";import{initDevtool}from"@sigi/devtool";import{AppModule}from"./app.module";functionApp(){const[state,dispatcher]=useEffectModule(AppModule);constloading=state.loading?<div>loading</div> : null;constlist=(state.list||[]).map(value=><likey={value}>{value}</li>);return(<div><h1>HelloCodeSandbox</h1><buttononClick={dispatcher.fetchList}>fetchList</button><buttononClick={dispatcher.cancel}>cancel</button>{loading}<ul>{list}</ul></div>);}constrootElement=document.getElementById("app");render(<App/>,rootElement);initDevtool();import{Module,EffectModule,Reducer,Effect,Action}from"@sigi/core";import{Observable}from"rxjs";import{exhaustMap,takeUntil,map,tap,startWith,endWith}from"rxjs/operators";import{HttpClient}from"./http.service";interfaceAppState{loading:boolean;list:string[]|null;}@Module("App")exportclassAppModuleextendsEffectModule<AppState>{defaultState:AppState={list:null,loading:false};constructor(privatereadonlyhttpClient:HttpClient){super();}@Reducer()cancel(state:AppState){return{...state,...this.defaultState};}@Reducer()setLoading(state:AppState,loading:boolean){return{...state,loading};}@Reducer()setList(state:AppState,list:string[]){return{...state,list};}@Effect()fetchList(payload$:Observable<void>):Observable<Action>{returnpayload$.pipe(exhaustMap(()=>{returnthis.httpClient.get(`/resources`).pipe(tap(()=>{console.info("Got response");}),map(response=>this.getActions().setList(response)),startWith(this.getActions().setLoading(true)),endWith(this.getActions().setLoading(false)),takeUntil(this.getAction$().cancel));}));}}
在這個代碼範例中,組件中的diaptcher.fetchList
可以直接跳轉到EffectModule
的fetchList
實現,並且類型簽名是自動互相匹配的。比如聲明這樣一個Reducer
:
@Reducer()addCount(state:State,payload:number){return{...state,count:state.count+payload}}
它對應的dispatcher.addCount
簽名就是(payload: number) => void
,在你不小心傳入錯誤類型的payload
之後, TypeScript
會直接告訴你錯誤的原因。在Sigi
的EffectModule
中, Effect
和ImmerReducer
也有同樣的效果。
分形
Sigi
沒有全局Store
的概念,它在全局唯一的限制是每一個EffectModule
的名字必須不一樣,這樣做是為了更方便的在devtool
中追踪異步事件的流程,以及方便SSR
場景下將數據從Node
透傳到前端。
所以在實踐中,你可以大量依賴Sigi
去抽象帶複雜業務邏輯的業務組件,將各種複雜的狀態封裝到局部。而對外暴露的API
就僅僅是一個普通的React
組件。
測試
Sigi
底層有一個小巧的Denpendencies injection實現,所以使用Sigi
的時候推薦將大部分複雜的業務通過Class
組織起來,然後通過DI
組合它們。這樣做有幾個好處,其中最重要的部分就體現在測試的便捷性上。
下面兩個代碼片段展示了有DI
和沒有DI
的時候在編寫測試上的區別:
import{stub,useFakeTimers,SinonFakeTimers,SinonStub}from'sinon'import{Store}from'redux'import{noop}from'lodash'constfakeAjax={getJSON:noop}jest.mock('rxjs/ajax',()=>({ajax:fakeAjax}))import{configureStore}from'@demo/app/redux/store'import{GlobalState}from'@demo/app/redux'import{REQUESTED_USER_REPOS}from'./index'import{of,timer,throwError}from'rxjs'import{mapTo}from'rxjs/operators'describe('raw redux-observable specs',()=>{letstore:Store<GlobalState>letdispose:()=>voidletfakeTimer:SinonFakeTimersletajaxStub:SinonStubconstdebounce=300// debounce in epicbeforeEach(()=>{store=configureStore().storedispose=store.subscribe(noop)fakeTimer=useFakeTimers()ajaxStub=stub(fakeAjax,'getJSON')})afterEach(()=>{ajaxStub.restore()fakeTimer.restore()dispose()})it('should get empty repos by name',()=>{constusername='fake user name'ajaxStub.returns(of([]))store.dispatch(REQUESTED_USER_REPOS(username))fakeTimer.tick(debounce)expect(store.getState().raw.repos).toHaveLength(0)})it('should get repos by name',()=>{constusername='fake user name'constrepos=[{name:1},{name:2}]ajaxStub.returns(of(repos))store.dispatch(REQUESTED_USER_REPOS(username))fakeTimer.tick(debounce)expect(store.getState().raw.repos).toEqual(repos)})it('should set loading and finish loading',()=>{constusername='fake user name'constdelay=300ajaxStub.returns(timer(delay).pipe(mapTo([])))store.dispatch(REQUESTED_USER_REPOS(username))expect(store.getState().raw.loading).toBe(false)fakeTimer.tick(debounce)expect(store.getState().raw.loading).toBe(true)fakeTimer.tick(delay)expect(store.getState().raw.loading).toBe(false)})it('should catch error',()=>{constusername='fake user name'constdebounce=300// debounce in epicajaxStub.returns(throwError(newTypeError('whatever')))store.dispatch(REQUESTED_USER_REPOS(username))fakeTimer.tick(debounce)expect(store.getState().raw.error).toBe(true)})})import{Test,SigiTestModule,SigiTestStub}from'@sigi/testing'import{SinonFakeTimers,SinonStub,useFakeTimers,stub}from'sinon'import{of,timer,throwError}from'rxjs'import{mapTo}from'rxjs/operators'import{RepoService}from'./service'import{HooksModule,StateProps}from'./index'classFakeRepoService{getRepoByUsers=stub()}describe('ayanami specs',()=>{letfakeTimer:SinonFakeTimersletajaxStream$:letmoduleStub:SigiTestStub<AppModule,AppState>constdebounce=300// debounce in epicbeforeEach(()=>{fakeTimer=useFakeTimers()consttestModule=Test.createTestingModule({TestModule:SigiTestModule,}).overrideProvider(RepoService).useClass(FakeRepoService).compile()moduleStub=testModule.getTestingStub(HooksModule)constajaxStub=testModule.getInstance(RepoService).getRepoByUsersasSinonStub})afterEach(()=>{ajaxStub.reset()fakeTimer.restore()})it('should get empty repos by name',()=>{constusername='fake user name'ajaxStub.returns(of([]))moduleStub.dispatcher.fetchRepoByUser(username)fakeTimer.tick(debounce)expect(moduleStub.getState().repos).toHaveLength(0)})it('should get repos by name',()=>{constusername='fake user name'constrepos=[{name:1},{name:2}]ajaxStub.returns(of(repos))moduleStub.dispatcher.fetchRepoByUser(username)fakeTimer.tick(debounce)expect(moduleStub.getState().repos).toEqual(repos)})it('should set loading and finish loading',()=>{constusername='fake user name'constdelay=300ajaxStub.returns(timer(delay).pipe(mapTo([])))moduleStub.dispatcher.fetchRepoByUser(username)expect(moduleStub.getState().loading).toBe(false)fakeTimer.tick(debounce)expect(moduleStub.getState().loading).toBe(true)fakeTimer.tick(delay)expect(moduleStub.getState().loading).toBe(false)})it('should catch error',()=>{constusername='fake user name'constdebounce=300// debounce in epicajaxStub.returns(throwError(newTypeError('whatever')))moduleStub.dispatcher.fetchRepoByUser(username)fakeTimer.tick(debounce)expect(moduleStub.getState().error).toBe(true)})})
從範例可以看出,編寫Sigi
的測試在Mock/Stub/Spy
上有非常大的優勢,並且在測試中的代碼與業務代碼在邏輯與類型上也是連貫的,更利於維護。在實踐中,我們推薦對Sigi
的EffectModule
進行全面的单元测试
,而组件
的邏輯盡量保持簡單乾淨,這樣可以大大降低測試的維護與運行成本(Mock掉外部依賴的純EffectModule
測試代碼運行起來非常快! )。
你也可以在Sigi文檔·編寫測試中實際運行感受一下Sigi
編寫測試的便捷性。
SSR
對於需要SEO
或者需要提升用戶首屏體驗的項目來說, SSR
是不得不考慮的因素。 Sigi
設計了一套強大且易用的SSR
API。
Server 端運行副作用
@sigi/ssr
模塊中提供了一個emitSSREffects
的函數,它的簽名如下:
functionemitSSREffects<Context>(ctx:Context,modules:Constructor<EffectModule<unkown>>[])=>Promise<StateToPersist>
Sigi
的Effect
在SSR
模式下只需要將對應的Decorator
換成SSREffect
就可以復用了。在Server
端與在Client
端不一樣的是, Effect
對應的Payload
的獲取上下文是組件,也就是組件作用域內的Props/State/Router
等一系列客戶端特有的狀態。而在Server
端, SSREffect
提供了payloadGetter
option來在Server
端獲取payload
。它的簽名如下:
payloadGetter:(ctx:Context,skip:()=>typeofSKIP_SYMBOL)=>Payload|Promise<Payload>|typeofSKIP_SYMBOL
其中第一個ctx
就是emitSSREffects
中的第一個參數,通常在Express
下你可以傳入Reqest
對象,在Koa
下你可以傳入Context
對象。
第二個參數skip
是一個函數,如果在某種業務條件下,比如權限錯誤直接return skip()
, Sigi
就會跳過這個Effect
,不再等待它的值。
因為Sigi
的設計是基於RxJS
的,在一個應用的生命週期內,每個Effect
都可能會有多個值被emit
。所以在需要SSR的Effect
的邏輯中,我們還要保證獲取到SSR
需要的數據後, emit
一個TERMINATE_ACTION
來告訴Sigi
這個Effect
已經運行完成了。
emitSSREffects
函數會等待所有傳入的EffectModule
的SSREffect
都emit
了一個TERMINATE_ACTION
之後,將它們的state
返回出來。
這個時候,再render
包含Sigi EffectModule
的組件,它們將直接使用emitSSREffects
之後Module
中的組件狀態,從而渲染出對應的HTML
。而emitSSREffects
返回的StateToPersist
對象,你可以調用上面的renderToJSX
方法將它放到渲染出來的HTML
中。這樣做之後在服務端獲取過的數據將通過HTML
透傳到客戶端,從而在客戶端第一次觸發同樣的的Effect
的時候直接忽略掉,節省請求和計算。當然這個行為也可以通過SSREffect
的option中skipFirstClientDispatch
選項關閉。
在SSR example中,有一個簡單的EffectModule
模塊能比較好的示意這個過程:
import{Module,EffectModule,ImmerReducer,TERMINATE_ACTION}from'@sigi/core'import{SSREffect}from'@sigi/ssr'import{Observable,of}from'rxjs'import{exhaustMap,map,startWith,delay,endWith,mergeMap}from'rxjs/operators'import{Draft}from'immer'importmd5from'md5'interfaceState{count:numbersigiMd5:string|null}@Module('demoModule')exportclassDemoModuleextendsEffectModule<State>{defaultState={count:0,sigiMd5:null,}@ImmerReducer()setCount(state:Draft<State>,count:number){state.count=count}@ImmerReducer()addOne(state:Draft<State>){state.count++}@ImmerReducer()setSigiMd5(state:Draft<State>,hashed:string){state.sigiMd5=hashed}@SSREffect({payloadGetter:()=>{returnmd5('sigi')},})getSigiMd5(payload$:Observable<string>){returnpayload$.pipe(delay(100),// mock asyncmergeMap((hashed)=>of(this.getActions().setSigiMd5(hashed),TERMINATE_ACTION)),)}@SSREffect()asyncEffect(payload$:Observable<void>){returnpayload$.pipe(exhaustMap(()=>of({count:10}).pipe(delay(1000),map(({count})=>this.getActions().setCount(count)),startWith(this.getActions().setCount(0)),endWith(TERMINATE_ACTION),),),)}}// renderer.tsximport'reflect-metadata'import{resolve}from'path'importfsfrom'fs'importReactfrom'react'import{renderToNodeStream}from'react-dom/server'importwebpackfrom'webpack'import{Request,Response}from'express'import{emitSSREffects}from'@sigi/ssr'import{SSRContext}from'@sigi/react'import{Home}from'@c/home'import{DemoModule}from'@c/module'exportasyncfunctionrenderer(req:Request,res:Response){conststate=awaitemitSSREffects(req,[DemoModule])conststats:webpack.Stats.ToJsonOutput=JSON.parse(fs.readFileSync(resolve(__dirname,'../client/output-stats.json'),{encoding:'utf8'}),)constscripts=(stats.assets||[]).map((asset)=><scriptkey={asset.name}src={`/${asset.name}`}/>)consthtml=renderToNodeStream(<html><head><metacharSet="UTF-8"/><metalang="zh-cms-hans"/><title>Sigissrexample</title></head><body><divid="app"><SSRContext.Providervalue={req}><Home/></SSRContext.Provider></div>{state.renderToJSX()}{scripts}</body></html>,)res.status(200)html.pipe(res)}
建議將SSR example項目下載並運行,深入感受一下Sigi
在SSR
場景下的設計。
Tree shaking
在使用同構(Isomorphic) SSR
框架時,我們有時候會出現這樣的尷尬場景:我們編寫的包含大量Server端業務邏輯的代碼被打包工具打包到了Client
端產物中。這些邏輯里通常包含了很多请求/缓存
邏輯,有時候甚至會require
一些只適合在Node
下使用的體積巨大的第三方庫,我們通常需要很複雜的工程化手段消除這些邏輯帶來的影響。
Sigi
在同構側只提供了唯一的邏輯入口,即SSREffect
的payloadGetter
選項。在這個前提下,我們提供了@sigi/ts-plugin
在編譯時將這些邏輯刪掉。這樣即使是你在編寫SSR
業務時編寫了大量Node only
的邏輯,在編譯Client
端代碼的時候,也會被輕鬆消除掉。
@Module('A')exportclassModuleAextendsEffectModule<AState>{@SSREffect({skipFirstClientDispatch:true,payloadGetter:(req:Request)=>{returnrequire('md5')('hello')},})whatever(payload$:Observable<string>){returnpayload$.pipe(map(()=>this.createNoopAction()))}}↓↓↓↓↓↓// TypeScript after transform:import{EffectModule,Module}from'@sigi/core';import{SSREffect}from'@sigi/ssr';import{Request}from'express';import{Observable}from'rxjs';import{map}from'rxjs/operators';interfaceAState{}@Module('A')exportclassModuleAextendsEffectModule<AState>{@SSREffect({})whatever(payload$:Observable<string>){returnpayload$.pipe(map(()=>this.createNoopAction()));}}
你可以下載SSR example項目並運行yarn build:client
命令查看Tree shaking
之後的效果。
依賴替換
Node
端與Client
還有一個非常不一樣的地方是: Client端通常使用http
請求獲取數據,而在Node
端我們可以使用更高效的RPC
方式甚至直接讀取數據庫、緩存等方式獲取數據。
因為Sigi
基於DI
構建,所以我們可以很輕鬆的在SSR
場景下將發請求/獲取數據的Service
替換成更高效的實現,並且完全不會侵入原有的業務邏輯。這裡有一個簡單的範例來看依賴替換API
的形態:
import"@abraham/reflection";importReactfrom"react";import{render}from"react-dom";import{ClassProvider}from"@sigi/di";import{useEffectModule,InjectionProvidersContext}from"@sigi/react";import{HttpErrorClient}from"./http-with-error.service";import{HttpBetterClient}from"./http-better.service";import{AppModule}from"./app.module";constAppContainer=React.memo(({appTitle}:{appTitle:string})=>{const[list,dispatcher]=useEffectModule(AppModule,{selector:state=>state.list});constloading=!list?<div>loading</div> : null;consttitle=listinstanceofError?<h1>{list.message}</h1> : <h1>{appTitle}</h1>;constlistNodes=Array.isArray(list)?list.map(value=><likey={value}>{value}</li>):null;return(<div>{title}<buttononClick={dispatcher.fetchList}>fetchList</button><buttononClick={dispatcher.cancel}>cancel</button>{loading}<ul>{listNodes}</ul></div>);});functionApp(){constbetterHttpProvider:ClassProvider<HttpErrorClient>={provide:HttpErrorClient,useClass:HttpBetterClient};return(<><AppContainerappTitle="Always error"/><InjectionProvidersContextproviders={[betterHttpProvider]}><AppContainerappTitle="Better http client"/></InjectionProvidersContext></>);}constrootElement=document.getElementById("app");render(<App/>,rootElement);
局限
只支持React hooks 形式的API
目前Sigi
只支持react hooks
形式的API。
對於React class component
我們也暫時不考慮提供相應的支持。
對於Vue 2/3
,我們已經有相應的計劃,正在緊鑼密鼓的進行中,順利的話很快就能與大家見面。
只為TypeScript 項目優化
我們對基於Babel
的純JavaScript
項目與Flow
項目的支持目前沒有排期,但是將來會支持。其中主要的成本是需要抹平Babel
與TypeScript
在Decorator
實現上的差異,並且要考慮如何向純JavaScript
項目提供TypeScript
中的emitDecoratorMetadata
功能的API。
體積
雖然Sigi
源碼已經盡量精簡了,但是由於依賴了RxJS
的大量特性,所以Sigi
加上其依賴之後的體積gzip
之後也達到了16k
左右(immer ~ 6.29kb, rxjs ~ 6.8kb, sigi ~ 2.96kb)。但如果你在大型項目中使用, Sigi
高度的抽象和強大的功能一定能給你省下超過這個體積許多的業務代碼體積。
在未來我們會慢慢剝離一些RxJS
的大體積依賴比如BehaviorSubject
與ReplaySubject
,進一步優化體積。