Sigi framework introduction

blank

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 , ReducerSide effect的設計能讓我們在寫業務的時候更容易寫出乾淨無副作用的組件,並且能讓我們更好分離各部分業務的職責。而這種設計如果不加以封裝則會讓代碼的Code path過於冗長,不利於連貫的進行代碼閱讀與業務邏輯理解,提高代碼的維護成本。而早期社區推崇的Rails風格的抽象方式(將action/reducer/side effect的代碼分文件夾放在一起)更是極大的放大了這一問題。

隨著社區實踐的完善,大家發現遵循Domain style/Ducts來組織業務邏輯相對於Rails 风格更適合大型Redux應用,但它還是沒有徹底解決業務邏輯Code path過長、邏輯割裂的問題。我們以一個典型的基於redux-actionsDucts風格組織的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定義的類型payloadnumber ,而在消費這個Actionreducer中, 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封裝不一樣的是, Sigidispatch props可以通過TypeScript提供的的jump to definition功能直接跳轉到dispatcher對應的邏輯:

Try it!

// 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可以直接跳轉到EffectModulefetchList實現,並且類型簽名是自動互相匹配的。比如聲明這樣一個Reducer :

@Reducer()addCount(state:State,payload:number){return{...state,count:state.count+payload}}

它對應的dispatcher.addCount簽名就是(payload: number) => void ,在你不小心傳入錯誤類型的payload之後, TypeScript會直接告訴你錯誤的原因。在SigiEffectModule中, EffectImmerReducer也有同樣的效果。

分形

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上有非常大的優勢,並且在測試中的代碼與業務代碼在邏輯與類型上也是連貫的,更利於維護。在實踐中,我們推薦對SigiEffectModule進行全面的单元测试,而组件的邏輯盡量保持簡單乾淨,這樣可以大大降低測試的維護與運行成本(Mock掉外部依賴的純EffectModule測試代碼運行起來非常快! )。

你也可以在Sigi文檔·編寫測試中實際運行感受一下Sigi編寫測試的便捷性。

SSR

對於需要SEO或者需要提升用戶首屏體驗的項目來說, SSR是不得不考慮的因素。 Sigi設計了一套強大且易用的SSR API。

Server 端運行副作用

@sigi/ssr模塊中提供了一個emitSSREffects的函數,它的簽名如下:

functionemitSSREffects<Context>(ctx:Context,modules:Constructor<EffectModule<unkown>>[])=>Promise<StateToPersist>

SigiEffectSSR模式下只需要將對應的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函數會等待所有傳入的EffectModuleSSREffectemit了一個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項目下載並運行,深入感受一下SigiSSR場景下的設計。

Tree shaking

在使用同構(Isomorphic) SSR框架時,我們有時候會出現這樣的尷尬場景:我們編寫的包含大量Server端業務邏輯的代碼被打包工具打包到了Client端產物中。這些邏輯里通常包含了很多请求/缓存邏輯,有時候甚至會require一些只適合在Node下使用的體積巨大的第三方庫,我們通常需要很複雜的工程化手段消除這些邏輯帶來的影響。

Sigi在同構側只提供了唯一的邏輯入口,即SSREffectpayloadGetter選項。在這個前提下,我們提供了@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的形態:

Sigi文檔·依賴替換

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項目的支持目前沒有排期,但是將來會支持。其中主要的成本是需要抹平BabelTypeScriptDecorator實現上的差異,並且要考慮如何向純JavaScript項目提供TypeScript中的emitDecoratorMetadata功能的API。

體積

雖然Sigi源碼已經盡量精簡了,但是由於依賴了RxJS的大量特性,所以Sigi加上其依賴之後的體積gzip之後也達到了16k左右(immer ~ 6.29kb, rxjs ~ 6.8kb, sigi ~ 2.96kb)。但如果你在大型項目中使用, Sigi高度的抽象和強大的功能一定能給你省下超過這個體積許多的業務代碼體積

在未來我們會慢慢剝離一些RxJS的大體積依賴比如BehaviorSubjectReplaySubject ,進一步優化體積。

交流群

blank

What do you think?

Written by marketer

blank

Vue.set 的副作用

blank

nodejs事件循環階段之定時器