{
return payload$.pipe(
exhaustMap(() => {
return this.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'
const fakeAjax = {
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', () => {
let store: Store
let dispose: () => void
let fakeTimer: SinonFakeTimers
let ajaxStub: SinonStub
const debounce = 300 // debounce in epic
beforeEach(() => {
store = configureStore().store
dispose = store.subscribe(noop)
fakeTimer = useFakeTimers()
ajaxStub = stub(fakeAjax, 'getJSON')
})
afterEach(() => {
ajaxStub.restore()
fakeTimer.restore()
dispose()
})
it('should get empty repos by name', () => {
const username = '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', () => {
const username = 'fake user name'
const repos = [{ 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', () => {
const username = 'fake user name'
const delay = 300
ajaxStub.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', () => {
const username = 'fake user name'
const debounce = 300 // debounce in epic
ajaxStub.returns(throwError(new TypeError('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'
class FakeRepoService {
getRepoByUsers = stub()
}
describe('ayanami specs', () => {
let fakeTimer: SinonFakeTimers
let ajaxStream$:
let moduleStub: SigiTestStub
const debounce = 300 // debounce in epic
beforeEach(() => {
fakeTimer = useFakeTimers()
const testModule = Test.createTestingModule({
TestModule: SigiTestModule,
})
.overrideProvider(RepoService)
.useClass(FakeRepoService)
.compile()
moduleStub = testModule.getTestingStub(HooksModule)
const ajaxStub = testModule.getInstance(RepoService).getRepoByUsers as SinonStub
})
afterEach(() => {
ajaxStub.reset()
fakeTimer.restore()
})
it('should get empty repos by name', () => {
const username = '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', () => {
const username = 'fake user name'
const repos = [{ 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', () => {
const username = 'fake user name'
const delay = 300
ajaxStub.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', () => {
const username = 'fake user name'
const debounce = 300 // debounce in epic
ajaxStub.returns(throwError(new TypeError('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
的函数,它的签名如下:
function emitSSREffects<Context>(ctx: Context, modules: Constructor>[]) => Promise<StateToPersist>
Sigi
的 Effect
在 SSR
模式下只需要将对应的 Decorator
换成 SSREffect
就可以复用了。在 Server
端与在Client
端不一样的是,Effect
对应的 Payload
的获取上下文是组件,也就是组件作用域内的 Props/State/Router
等一系列客户端特有的状态。而在 Server
端,SSREffect
提供了 payloadGetter
option 来在 Server
端获取 payload
。它的签名如下:
payloadGetter: (ctx: Context, skip: () => typeof SKIP_SYMBOL) => Payload | Promise | typeof SKIP_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'
import md5 from 'md5'
interface State {
count: number
sigiMd5: string | null
}
@Module('demoModule')
export class DemoModule extends EffectModule {
defaultState = {
count: 0,
sigiMd5: null,
}
@ImmerReducer()
setCount(state: Draft, count: number) {
state.count = count
}
@ImmerReducer()
addOne(state: Draft) {
state.count++
}
@ImmerReducer()
setSigiMd5(state: Draft, hashed: string) {
state.sigiMd5 = hashed
}
@SSREffect({
payloadGetter: () => {
return md5('sigi')
},
})
getSigiMd5(payload$: Observable<string>) {
return payload$.pipe(
delay(100), // mock async
mergeMap((hashed) => of(this.getActions().setSigiMd5(hashed), TERMINATE_ACTION)),
)
}
@SSREffect()
asyncEffect(payload$: Observable<void>) {
return payload$.pipe(
exhaustMap(() =>
of({ count: 10 }).pipe(
delay(1000),
map(({ count }) => this.getActions().setCount(count)),
startWith(this.getActions().setCount(0)),
endWith(TERMINATE_ACTION),
),
),
)
}
}
// renderer.tsx
import 'reflect-metadata'
import { resolve } from 'path'
import fs from 'fs'
import React from 'react'
import { renderToNodeStream } from 'react-dom/server'
import webpack from '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'
export async function renderer(req: Request, res: Response) {
const state = await emitSSREffects(req, [DemoModule])
const stats: webpack.Stats.ToJsonOutput = JSON.parse(
fs.readFileSync(resolve(__dirname, '../client/output-stats.json'), { encoding: 'utf8' }),
)
const scripts = (stats.assets || []).map((asset) => )
const html = renderToNodeStream(
Sigi ssr example
{state.renderToJSX()}
{scripts}
,
)
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
端代码的时候,也会被轻松消除掉。