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

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

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

  • 视图层:react作为纯视图渲染

  • 数据层(领域层),相当于聚合了业务数据和业务数据的处理,这样仅仅在数据层就能清楚的看到数据流变化的方向,做技术方案时仅仅把数据层的逻辑梳理清楚就很不错了。

  • 异步管理层,主要请求后端数据和解决一些复杂的异步管理问题,例如利用rxjs处理一些复杂的异步问题,例如我这篇文章就有一些场景👍 实用rxjs学习案例

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

  • 可读性,代码分层后,更加符合SOLID中的单一职责原则,一类代码分在一起,所以可读性是更高的。

  • 可维护性,代码分层后,每个模块负责一类事情,这个跟SOLID原则中的开闭原则相符,所以可维护性更高。

  • 可测试性,上面我们说了,因为分层之后,每一层更专注它的功能,所以可以把每一层单独拿来测试,所以是更容易测试的。

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

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

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

kotlin 复制代码
// 以下加入了两个interface
export interface XXX {

}

interface XXX {
}

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

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

javascript 复制代码
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组件的核心逻辑了

typescript 复制代码
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就很适合了。

typescript 复制代码
class MessageStore{
    constructor(){
        this.messageList = {}
    }
    getState: 
    add(noticeProps: MessageProps){
      // xxx
    },

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

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

    remove: (id: number) => {
      // xxx
    },
    
    subscribe: (change) => {
      const listener = {
        listenerId: v4(),
        onStoreChange: change,
      } as SubListener
      store.listener.push(listener)
      listener.onStoreChange()
      return listener
    },
    unSubscribe: (listenerId) => {
      store.listener.splice(
        store.listener.findIndex(
          (listener) => listener.listenerId === listenerId,
        ),
        1,
      )
    },
}

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

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

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

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

  • 可读性,一看函数命名就知道这个store是干什么的了。

  • 可维护性,因为跟渲染层react的jsx完全隔离,更佳符合职责单一原则,这就是一个纯粹是数据层,所以更好维护,比如我们要加一个处理Message的逻辑,直接在store里加一个函数就行了。

  • 可测试性,因为职责单一,我们可以仅仅测试这里数据层,我们测试的时候甚至不用管Message组件的jsx到底写的是什么,只要我们模拟一个button点击事件分别对增删改查这几个函数测试就差不多了,大大降低了单元测试的心智负担。

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

ini 复制代码
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组件如何用函数驱动渲染

ini 复制代码
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元素的?

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

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

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

我采取的hooks的方式

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

javascript 复制代码
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元素呢?按道理来说一般都是这样才行

javascript 复制代码
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[github.com/lio-mengxia...

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

相关推荐
酷酷的阿云10 分钟前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:1379712058712 分钟前
web端手机录音
前端
齐 飞17 分钟前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
神仙别闹34 分钟前
基于tensorflow和flask的本地图片库web图片搜索引擎
前端·flask·tensorflow
GIS程序媛—椰子1 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0012 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端2 小时前
Content Security Policy (CSP)
前端·javascript·面试
木舟10092 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43912 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢2 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js