💾 数据管理层 NOVUS
较大型应用:🌋 WebIDE 的开发记录其一(前言和概览)
集中管理数据状态模型,并且享受 TypeScript 的便利,从明天起,做一个幸福的人,不去关心同步异步、不去关心一个逻辑写多处、不去关心复杂的心智模型、不去关心不可推断的运行时错误。从今以后简单点,只关心数据和变更,只使用代码提示,就此一生,完成项目。
点击按钮尝试 demo:
使用范例:
novus.tsx
// novus.tsximport React, {Component,ComponentClass,FunctionComponent,useState,useEffect,useRef,} from 'react'interface TModelsPool {[ind: string]: NovusBaseModel<any, any>}type IDisposable = () => voidtype 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 requestIdleCallbackconst _requestIdleCallback =(window || (global as any)).requestIdleCallback || ((cb: Function) => setTimeout(cb, 1))const _cancelIdleCallback =(window || (global as any)).cancelIdleCallback || ((id: number) => clearTimeout(id))let subscribeIndex = 0let idleHandler: numberclass 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 = thisthis.models[model.namespace] = modelreturn () => {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: numberstartNotifies = () => {if (idleHandler) _cancelIdleCallback(idleHandler)// 执行之前先标记下时间this.startNotiTime = new Date().getTime()this.doTheNotifies()}/*** 确实去通知订阅的部分,根据不同的执行可能再不同的宏任务队列被执行*/doTheNotifies = () => {if (this.needNotifiedListeners.size > 0) {const nextId = this.needNotifiedListeners.values().next().valuethis.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: FunctioncomponentDidMount() {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: stringnovus: 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) returnthis.waitingNotify = truenew 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 renderconst initState = useRef<T | null>(null)let _deps = depsif (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.tsimport {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: TodoStatustags: string[]content: string}export interface ITodoState {loading: booleangroups: 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.tsximport 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