💾 数据管理层 NOVUS
较大型应用:🌋 WebIDE 的开发记录其一(前言和概览)
集中管理数据状态模型,并且享受 TypeScript 的便利,从明天起,做一个幸福的人,不去关心同步异步、不去关心一个逻辑写多处、不去关心复杂的心智模型、不去关心不可推断的运行时错误。从今以后简单点,只关心数据和变更,只使用代码提示,就此一生,完成项目。
点击按钮尝试 demo:
使用范例:
novus.tsx
"use client";// novus.tsximport 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 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 = 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 renderconst 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.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
import React, {useCallback} from 'react'import {useNovus} from './novus'import {model as todoModel, TTodo} from './todoModel'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>)}export default ExampleTodoExample;
Copy