This site runs best with JavaScript enabled.

💾 数据管理层 NOVUS



集中管理数据状态模型,并且享受 TypeScript 的便利,从明天起,做一个幸福的人,不去关心同步异步、不去关心一个逻辑写多处、不去关心复杂的心智模型、不去关心不可推断的运行时错误。从今以后简单点,只关心数据和变更,只使用代码提示,就此一生,完成项目。
点击按钮尝试 demo:



代码

源码:
NOVUS

📦 实现一个简单朴素的 react 数据管理层


使用范例:
novus.tsx
// novus.tsx
import React, {
Component,
ComponentClass,
FunctionComponent,
useState,
useEffect,
useRef,
} from 'react'
interface TModelsPool {
[ind: string]: NovusBaseModel<any, any>
}
type IDisposable = () => void
type TElement<T> = string | ComponentClass<T, any> | FunctionComponent<T>
export type TConnectProps = {
getModel: <K extends keyof TModelsPool>(name: K) => TModelsPool[K]
getModels: () => TModelsPool
}
// simple polyfill for requestIdleCallback
const _requestIdleCallback =
(window || (global as any)).requestIdleCallback || ((cb: Function) => setTimeout(cb, 1))
const _cancelIdleCallback =
(window || (global as any)).cancelIdleCallback || ((id: number) => clearTimeout(id))
let subscribeIndex = 0
let idleHandler: number
class Novus {
// 提供一个集中的聚合地方,这块可以不使用
models: TModelsPool = {}
// 用订阅通知的模式,简单实现数据变更通知,外部可以不关心
listeners: Map<string, [Function, string[]]> = new Map() // private
/**
* 绑定数据模型,集中管理数据模型
*
* @param model 数据模型 NovusBaseModel
*/
bindModel(model: NovusBaseModel<any>): IDisposable {
if (this.models[model.namespace]) {
console.error('sorry, you should bind with unque namespace!!')
delete this.models[model.namespace]
}
model.novus = this
this.models[model.namespace] = model
return () => {
delete this.models[model.namespace]
}
}
/**
* 订阅器,订阅对应 model 改变的函数,外部可以不关心
*
* @param func 订阅触发函数
* @param deps 订阅的 model,仅订阅的 model 改变后才会触发函数
*
* @returns 返回取消订阅的函数
*/
subscribe = (func: Function, deps: string[] = []): IDisposable => {
if (deps.length == 0 || typeof deps === 'undefined')
console.warn(
"you didn't specify the dependency list, it works, but we recommend you fill a list to get a better performance",
)
const id = (subscribeIndex++).toString()
this.listeners.set(id, [func, deps])
return () => {
this.listeners.delete(id)
}
}
/**
* 触发器,更新完数据仓之后必须触发才能通知 UI 变更
*
* @param namespace model 的命名空间 id,只会触发订阅对应 model 的 mapStatetoProps 函数。留空表示触发全部
*/
notifyChangesListeners = (namespace?: string) => {
this.listeners.forEach((v, key) => {
if (typeof namespace === 'undefined' || v[1].length === 0 || v[1].includes(namespace)) {
// 将需要通知的函数放进队列等待通知
this.needNotifiedListeners.add(key)
}
})
// 需要通知各个订阅了
this.startNotifies()
}
needNotifiedListeners = new Set<string>([])
startNotiTime: number
startNotifies = () => {
if (idleHandler) _cancelIdleCallback(idleHandler)
// 执行之前先标记下时间
this.startNotiTime = new Date().getTime()
this.doTheNotifies()
}
/**
* 确实去通知订阅的部分,根据不同的执行可能再不同的宏任务队列被执行
*/
doTheNotifies = () => {
if (this.needNotifiedListeners.size > 0) {
const nextId = this.needNotifiedListeners.values().next().value
this.needNotifiedListeners.delete(nextId)
this.listeners.get(nextId)[0]()
// 如果当前渲染的递归还有时间,继续,否则等待下一次宏任务
if (new Date().getTime() - this.startNotiTime < 16) {
this.doTheNotifies()
} else {
idleHandler = _requestIdleCallback(this.startNotifies)
}
}
}
connectProps = {
getModel: <K extends keyof TModelsPool>(name: K): TModelsPool[K] => {
return this.models[name]
},
getModels: (): TModelsPool => this.models,
}
/**
* 将 models state 数据转换挂载到组件上,类似 react-redux 的 connect 功能,不推荐,但是支持
*
* @param Comp 要挂载的组件,props 需求有基础的和被 connected 的
* @param mapStatetoProps 数据转换函数
* @param deps 依赖的 models,不在这个数组的 models 改变不会执行 mapStatetoProps 函数
*
* @returns NewComponent 返回新的组件,props 提示只有基础的
*/
connect = <OriginProps, ConnectedProps>(
Comp: TElement<OriginProps & ConnectedProps & TConnectProps>,
mapStatetoProps: (models: TModelsPool, app: Novus, props: OriginProps) => ConnectedProps,
deps: string[] = [],
): React.ComponentClass<OriginProps> => {
class OutputCom extends Component<OriginProps, ConnectedProps> {
constructor(props: OriginProps) {
super(props)
this.state = mapStatetoProps(novus.models, novus, props)
}
dispose: Function
componentDidMount() {
this.dispose = novus.subscribe(() => {
let newState = mapStatetoProps(novus.models, novus, this.props)
this.setState(newState)
}, deps)
}
componentWillUnmount() {
if (typeof this.dispose !== 'undefined') {
this.dispose()
}
}
render() {
const allProps = {
...this.props,
...this.state,
...novus.connectProps,
}
return <Comp {...allProps} />
}
}
return OutputCom
}
/**
* 删除全部模型
*/
clean = () => {
for (let m in this.models) delete this.models[m]
this.listeners.clear()
this.needNotifiedListeners.clear()
}
}
export const novus = new Novus()
/**
* 一个基础的数据模型。用户可以继承此类后自己添加更新函数
*
* - namespace 作为唯一的 id
* - state 作为数据储存仓
* - setState 同步更新 state 的函数
* - actions 用户的数据模型的更新函数,支持异步函数
*/
export class NovusBaseModel<S, A = {}> {
/**
* 唯一的命名空间
*/
namespace: string
novus: Novus = novus
/**
* 数据仓
*
* 由于继承后会 state 覆盖,所以子组件要显式指定类型
*/
state: Readonly<S>
/**
* 纯函数的数据更新函数,同步执行
*
* @param state 要更新的属性或者函数
*/
protected setState = <K extends keyof S>(state: Pick<S, K> | ((states: S) => Pick<S, K>)) => {
if (typeof state === 'function') {
const newState = state(this.state)
this.state = {...this.state, ...newState}
} else {
this.state = {...this.state, ...state}
}
this.notifyChanges()
}
protected getModel = <K extends keyof TModelsPool>(modelName: K): TModelsPool[K] => {
return novus.models[modelName]
}
/**
* 通过标志变量控制多次的事件通知在一个 EventLoop 中只触发一次
*/
protected waitingNotify = false
/**
* 通知上层触发订阅的 connect 转换函数,每次修改 state 必须触发!!!
*/
protected notifyChanges = () => {
// setState 是同步的,但是事件通知不是同步
if (this.waitingNotify) return
this.waitingNotify = true
new Promise(r => r()).then(() => {
novus.notifyChangesListeners(this.namespace)
this.waitingNotify = false
})
}
/**
* 数据更新的函数,相当于 effects 或者 reducers 的概念集合
*/
readonly actions: A
}
/**
* 提供 hook 函数,在函数组件中非常简洁的使用
*
* @template T
* @param {(models: TModelsPool, app: Novus) => T} mapStatetoHooks 怎么从 models 中拿到状态
* @param {string[]} [deps] 依赖哪些 models 的变更通知
* @returns {T}
*/
export const useNovus = function<T>(
mapStatetoHooks: (models: TModelsPool, app: Novus) => T,
deps?: string[],
): T {
// don't run deps collect after first render
const initState = useRef<T | null>(null)
let _deps = deps
if (initState.current === null) {
if (typeof deps === 'undefined') {
const autoDeps = new Set([])
const handler = {
get(target: TModelsPool, key: string) {
if (typeof target[key] !== 'undefined') {
autoDeps.add(key)
}
return target[key]
},
}
const autoModels = new Proxy(novus.models, handler)
initState.current = mapStatetoHooks(autoModels, novus)
_deps = Array.from(autoDeps)
} else {
initState.current = mapStatetoHooks(novus.models, novus)
}
}
let [state, setState] = useState<T>(initState.current)
useEffect(() => {
const dispose = novus.subscribe(() => {
setState(oldState => {
let newState = mapStatetoHooks(novus.models, novus)
// ↓ 浅比较一下判断是否需要更新 state,触发订阅
for (const key in oldState) {
if (oldState[key] !== newState[key]) {
return newState
}
}
return oldState
})
}, _deps)
return () => {
dispose()
}
}, [])
return state
}
/**
* 封装一个组件,默认携带 novus 属性,方便引用
*
* @export
* @class NovusComponent
* @extends {(Component<P, S, SS>)}
* @template P
* @template S
* @template SS
*/
export class NovusComponent<P, S = {}, SS = any> extends Component<P, S, SS> {
novus = novus
}
export default Novus
Copy
// todoModel.ts
import {NovusBaseModel, novus} from './novus'
const todoDto = {
sync: async newTodo => {
await new Promise(r => setTimeout(r, 1000))
},
}
export enum TodoStatus {
Normal,
Completed,
Deleted,
}
export type TTodo = {
status: TodoStatus
tags: string[]
content: string
}
export interface ITodoState {
loading: boolean
groups: string[]
list: TTodo[]
filters: {
tags: string[]
statuses: TodoStatus[]
}
}
// 使用继承类的形式限制属性和方法
class TodoModel extends NovusBaseModel<ITodoState> {
namespace = 'todoModel'
constructor() {
super()
this.state = {
loading: false,
groups: [],
list: [],
filters: {
tags: [],
statuses: [],
},
}
}
// 自定义状态更新函数
syncData = async list => {
this.setState({loading: true})
await todoDto.sync(list)
this.setState({list, loading: false})
}
// actions 集合,可以调用异步函数,约定为修改状态的逻辑,提供数据操作方法
actions = {
create: async (content: string, tags: string[] = []) => {
const newTodo = {
tags,
content,
status: TodoStatus.Normal,
}
await this.syncData([newTodo, ...this.state.list])
},
update: async (index: number, todo: TTodo) => {
const newTodo = {
...this.state.list[index],
...todo,
}
await this.syncData([
...this.state.list.slice(0, index - 1),
newTodo,
...this.state.list.slice(index + 1),
])
},
delete: async (index: number) => {
await this.syncData([...this.state.list.slice(0, index), ...this.state.list.slice(index + 1)])
},
}
}
export const model = new TodoModel()
novus.bindModel(model)
Copy
// TodoExample.tsx
import React, {useCallback} from 'react'
import {useNovus} from './novus'
import {model as todoModel, TTodo} from './todoModel'
export const ExampleTodoExample = props => {
let [list, loading] = useNovus<[TTodo[], boolean]>(
models => {
return [models.todoModel.state.list, models.todoModel.state.loading]
},
['todoModel'],
)
const deleter = useCallback(async (index: number) => {
await todoModel.actions.delete(index)
}, [])
const adder = useCallback(async () => {
await todoModel.actions.create(new Date().toTimeString())
}, [])
return (
<div>
<button disabled={loading} onClick={adder}>
{loading ? 'loading' : 'ADD'}
</button>
<table>
<tbody>
{list.map((todo, index) => {
return (
<tr key={todo.content}>
<td>{todo.content}</td>
<td>
<button onClick={deleter.bind(null, index)}>关闭</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
Copy

🐞 关于

有时间就会分享一些技术文章、专业内容、经典问题、系列功能等。

{⛔ 未标注内容均为原创,请勿转载 ©️}