Skip to content

如何在代码质量上超过大多数react ui 组件库 (拿Message组件举例) #26

Closed
@lio-mengxiang

Description

@lio-mengxiang

以下是本文完整的示例代码和demo在文末。欢迎一起交流。

绝大多数react组件库,甚至包括个别大厂,写ui组件的时候基本上是不分层的,首先什么是代码分层,以及带来的好处,我们拿平时业务开发的组件来说,一般有3层(符合整洁架构这本书对于简洁架构的要求):

  • 视图层:react作为纯视图渲染
  • 数据层(领域层),相当于聚合了业务数据和业务数据的处理,这样仅仅在数据层就能清楚的看到数据流变化的方向,做技术方案时仅仅把数据层的逻辑梳理清楚就很不错了。
  • 异步管理层,主要请求后端数据和解决一些复杂的异步管理问题,例如利用rxjs处理一些复杂的异步问题,例如我这篇文章就有一些场景👍 实用rxjs学习案例

具体案例后面会讲,我们先看看为什么分层之后你的代码质量会更高。

  • 可读性,代码分层后,更加符合SOLID中的单一职责原则,一类代码分在一起,所以可读性是更高的。
  • 可维护性,代码分层后,每个模块负责一类事情,这个跟SOLID原则中的开闭原则相符(开闭原则并不是完全禁止修改,而是最小化修改的范围),所以可维护性更高。
  • 可测试性,上面我们说了,因为分层之后,每一层更专注它的功能,所以可以把每一层单独拿来测试,所以是更容易测试的。

我们拿一家国内算是Top3的大厂的Message组件的代码来看看我们的Message组件为什么分层之后比它的代码质量好很多。

我们从上到下简单梳理一下它的Message.tsx代码:

interface直接写到了组件的文件里,如果这个组件代码量很少,比如10-20行是没有什么问题的,但是你这个组件代码本身就比较长,大概200多行,最好还是把interface单独提到一个文件里更合适。

// 以下加入了两个interface
export interface XXX {

}

interface XXX {
}

下面的代码我直接没有看下去的兴趣了,为什么呢?一大堆函数放到一个文件里,一眼看上去也不知道干什么,只有一行一行读代码才行,我认为好的代码是你看到它的命名大概就知道它要干什么了,后面会拿我们的代码做对比。

我觉得直接说人家代码不好有点攻击性太强了,我把其中的代码省略掉,有兴趣知道是哪个大厂的,可以自己去探索一下,哈哈

const MessageContainer: React.FC<MessageContainerProps> = (props) => {
  // xxx代码省略

  useEffect(() => {
   // xxx
  }, []);

  return (
    xxx
  );
};


function createContainer({ attach, zIndex, placement = 'top' }: MessageOptions): Promise<Element> {
  // xxx
}


async function renderElement(theme, config: MessageOptions): Promise<MessageInstance> {
  // xxx
}

function isConfig(content: MessageOptions | React.ReactNode): content is MessageOptions {
 // xxx
}


const messageMethod: MessageMethod = (theme: MessageThemeList, content, duration?: number) => {
  // xxx
};

// 创建
export const MessagePlugin: MessagePlugin = (theme, message, duration) => messageMethod(theme, message, duration);
MessagePlugin.info = xx
MessagePlugin.error =  xx
MessagePlugin.warning = xx
MessagePlugin.success = xx
MessagePlugin.question = xx
MessagePlugin.loading = xx
MessagePlugin.config = xx


MessagePlugin.close = (messageInstance) => {
  // xx
};

MessagePlugin.closeAll = (): MessageCloseAllMethod => {
  // xx
};

export default MessageComponent;

那么一般情况下一个文件很多函数会怎么做呢,一般如果是工具函数,会放在当前组件的utils目录下,如果是hooks,会放在当前组件的hooks目录下,如果是常量会放在当前组件的constans.ts文件下等等。

这样一看文件名的命名就知道这个文件大概是干什么的了。我们再看看我这里的Message组件是如何做的

我的Message组件

首先我们看下目录:

  • hooks目录一看名字就知道是存放react hooks的
  • style目录一看名字就知道是存放样式的
  • utils目录一看名字就知道存放工具函数的
  • constants文件,一看就知道存放常量的
  • interface,一看名字就知道是存放接口定义的

Message组件核心逻辑 store.tsx

剩下几个,我们逐个详解,首先看store.tsx,这是一个领域层(数据 + 数据转换的函数),我们看一下文件里面,就马上知道这个Message组件的核心逻辑了

function useStore(defaultPosition: IPosition) {
  const [state, setState] = useState<MessageStates>({ ...initialState });

  return {
    state,
    add: (noticeProps: MessageProps) => {
      // xxx
    },

    update: (id: number, options: MessageProps) => {
      // xxx
    },

    clearAll: () => {
      // xxx
    },

    remove: (id: number) => {
      // xxx
    },
  };
}

export default useStore;

是不是看到我们的核心数据都在state中,然后对于数据的操作包含:

  • add方法,增加Message
  • update方法,更新Message
  • clearAll方法,清除所有Message
  • remove方法,清除某个Message

这就是我之前说的数据层(数据 + 数据转换的函数),这个按道理来说是跟UI层,也就是react渲染层没有任何关系的,但是我们更新视图必须用setState,所以跟框架是产生耦合了的。

如何实现一个跟框架无关的数据层

所以我们这个数据层并不纯粹,按道理应该是跟框架无关的,react,vue等等都可以有一个相同的数据层。但是因为我未来不会去做其他框架的ui组件,所以采取这种方式减少工作量了。举个例子,假如你要实现跟框架无关的数据层,这时候使用class就很适合了。

class MessageStore{
    constructor(){
        this.messages = []
        this.listener = []
    }
   
    getState(){ return this.messages }
    add(noticeProps: MessageProps){
      this.messages.push(noticeProps)
      this.listener.forEach((listener) => {
        listener.onStoreChange()
      })
    },

    update: (messageId, message) => {
    const index = this.modals.findIndex((message) => message.id === modalId)
      if (index != -1) {
        this.modals[index] = modal
        this.listener.forEach((listener) => {
          listener.onStoreChange()
        })
      }
    },
    ...省略部分方法,原理是一样的
    
    subscribe: (change) => {
      const listener = {
        listenerId: v4(),
        onStoreChange: change,
      } as SubListener
      this.listener.push(listener)
      listener.onStoreChange()
      return listener
    },
    unSubscribe: (listenerId) => {
      store.listener.splice(
        store.listener.findIndex(
          (listener) => listener.listenerId === listenerId,
        ),
        1,
      )
    },
}

然后如何跟框架产生联系呢,利用发布会订阅模式,如上面的subscribe用来注册框架的渲染函数,这样所有框架都可以注册了,实现了跨框架的数据层。

如果是react,如下示例代码:

  const [modalList, setModalList] = useState<Messagerops[]>([])
  const messageStore = new MessageStore();
  useEffect(() => {
    const listener = messageStore.subscribe(() => {
      setModalList([...messageStore.getState()])
    })
    return () => {
      messageStore.unSubscribe(listener.listenerId)
    }
  }, [])

简而言之,就是你有一个class的store,然后数据变更的时候调用框架的渲染函数(比如react的setState),做到数据和视图同步更新,但问题也有,就是react 18的可中断的渲染方式,需要使用useSyncExternalStore包裹,因为这种方式不常用,大概了解这个知识点就好。

所以看到这里,我们从以下几个维度去看看目前store这一层带来的好处:

  • 可读性,一看函数命名就知道这个store是干什么的了。
  • 可维护性,因为跟渲染层react的jsx完全隔离,更佳符合职责单一原则,这就是一个纯粹是数据层,所以更好维护,比如我们要加一个处理Message的逻辑,直接在store里加一个函数就行了。
  • 可测试性,因为职责单一,我们可以仅仅测试这里数据层,我们测试的时候甚至不用管Message组件的jsx到底写的是什么,只要我们模拟一个button点击事件分别对增删改查这几个函数测试就差不多了,大大降低了单元测试的心智负担。

我们这接着看视图层MessageWrapper.tsx里的内容

function MessageWrapper(props: MessageCardProps) {
  const { onMouseEnter, onMouseLeave } = useTimer(props);
  const { icon, type, style, title, content, operation, closable, showIcon, className, remove, id, onClose, position, themeStyle } = props;

  const toastStyle = useMemo(() => getCardStyle(position), [position]);

  return (
    <motion.div
      layout
      variants={applyNotificationSlide}
      custom={{ position }}
      animate="animate"
      exit="exit"
      initial="initial"
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      style={toastStyle}
    >
      <Alert
        icon={icon}
        type={type}
        themeStyle={themeStyle}
        style={style}
        title={title}
        content={content}
        operation={operation}
        closable={closable}
        showIcon={showIcon}
        className={className}
        _onClose={() => {
          remove?.(id);
        }}
        onClose={onClose}
      />
    </motion.div>
  );
}

别看简单的30多行代码,其中包含了:

  • useTimer:Message出现,默认3秒后就关闭了,但是鼠标移动上去后,就不会关闭,等鼠标移除后,再开始计时3秒后关闭,所以用一个hooks封装了这个逻辑,这算是数据层
  • toastStyle数据css样式层,封装在一个getStyle函数中。
  • motion.div,是动画系统framer-motion提供的api,这是一个动画层,但是暴露出来就是很简洁的一个div元素
  • Alert组件,是我们组件库的警告提示组件,充当渲染组件,直接消费数据就行了。

这里强调一下,视图层主要的作用就是两点

  • 消费数据层的数据
  • 绑定事件更新数据和视图层

所以整体看下来代码逻辑就清晰,所以维护的时候就简单很多,大家觉得的呢?欢迎在评论区交流互动。

最后还有两个文件比较重要,我们简单分析一下

  • useMessage,使用Message组件的hooks函数
  • messageProvider,自动帮你引入的全局Provider,为什么需要这样的Provider呢?这就要说起一个Message组件的实现原理了

Message组件核心渲染逻辑

传统ant4版本,arco,tdesign的渲染逻辑

我们看下ant4版本的Message组件如何用函数驱动渲染

const App = () => {
  return (
    <Button
      onClick={() => {
        Message.info({
          content: 'This is a message!',
          closable: true,
          duration: 10000,
        });
      }}
      type='primary'
    >
      Open Message
    </Button>
  );
};

export default App;

你有没有想过,为什么调用一个 Message.info函数就渲染了dom?毕竟在react里,渲染dom是需要比如函数组件return一个react元素的?

这里第一个方案主要是采用reactDOM.render函数去渲染,这就是可以在不return react元素的情况下渲染了。

但是目前有个问题,就是react18版本跟之前的版本,render方法不一样,在react18要用createRoot 这个API,之前的版本是用render方法。

这也导致了一些小bug,因为react18的渲染是异步的,之前是同步的,所以需要写组件库的同学封装一个兼容react17和react18的方法,把react18的render也变成同步的,怎么办呢,可以使用flushSync包裹一下就好了。

我采取的hooks的方式

跟ant这种直接靠reactDOM.render来渲染的Message组件的方式不同,我这里是借助的react本身的常规的组件渲染,使用方式如下:

import { useMessage, Button } from '@mx-design/web';

function App() {
  const Message = useMessage();
  return (
    <Button
      onClick={() => {
        Message.add({
          type: 'info',
          content: 'This is an info message!'
        });
      }}
    >
      Open Message
    </Button>
  );
};

这里就有问题考考大家了,我这里直接调用Message.add为什么不依靠React.DOM的render就能渲染出dom元素呢?按道理来说一般都是这样才行

import { useMessage, Button, Message } from '@mx-design/web';

function App() {
  const Message = useMessage();
  return (
    <>
        <Message />
        <Button
          onClick={() => {
            Message.add({
              type: 'info',
              content: 'This is an info message!'
            });
          }}
        >
          Open Message
        </Button>
    </>
  );
};

本文完整代码

本文(demo展示)

如上,就是你要return里面有一个插槽渲染Message的dom对吧,我都没插槽按道理怎么渲染呢?

其实我是在全局的Provider里内置了一个全局的Message管理器,那里会在body元素里内置一个插槽。

都看到这里了,求一个star[https://github.com/lio-mengxiang/mx-design],哈哈

后续会持续更新这个系列,跟别的个人开发者不同,我这里的react 组件库参考了至少5个国内外知名的ui库,所以整体的设计和代码质量都是有保证的

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions