同时开发React组件库和Vue组件库,工作量能减少到哪里

149 阅读5分钟

背景

在使用不同的技术栈开发组件库的同一功能组件的时候会在想,哪些工作量能减少

  1. 纯函数
  2. 样式
  3. 国际化
  4. 组件逻辑

其中1、2、3都没问题,4要怎么做或者能做哪些呢?

能否使用React或者vue的方式去开发另一个呢?

到底用React的组件方式去开发Vue呢,还是反之呢?

组件示例

先看下基本平常单独开发一个组件库的基本示例

React组件

// React组件 test.tsx
const Test: React.FC<{
  count: number;
  changeCount: (count: number) => void;
  children?: (params: { count: number }) => React.ReactNode;
}> = ({ count = 0, changeCount, children = () => 'children' }) => {
  return (
    <div className="test" onClick={() => changeCount?.(count + 1)}>
      Test<span>{count}</span>
      { children({ count }) }
    </div>
  );
};

export default Test;


Vue组件

方式一

// Vue组件 Test.vue
<template>
    <div class="test" @click="() => changeCount?.(count + 1)">
      Test<span>{{count}}</span>
      <slot :count="count">
          "children"
      </slot>
    </div>
</template>
<script lang="ts" setup>
import type { VNodeChild } from 'vue';

withDefaults(defineProps<{
  count: number;
  changeCount: (count: number) => void;
}>(), {
  count: 0,
})

defineSlots<{
  default: (params: { count: number }) => VNodeChild;
}>();
    
</script>

方式二

// Vue组件 Test.tsx
import type { SlotsType } from 'vue';
import { defineComponent } from "vue";

export default defineComponent<
    {
      count: number;
      changeCount: (count: number) => void;
    }, 
    {}, 
    string, 
    SlotsType<{
      default: (params: { count: number }) => VNodeChild;
    }>
>((props, { slots }) => {
    return () => {
        const { count, changeCount } = props;
        return (<div class="test" onClick={() => changeCount?.(count + 1)}>
            Test<span>{count}</span>
            {slots.default ? slots.default({ count }) : "children"}
      </div>)
    };
  });

方式三

// Vue组件 Test.tsx
import { defineComponent } from "vue";

export default defineComponent({
  props: {
    count: {
      type: Number,
      default: 0,
    },
    changeCount: {
      type: Function,
      default: () => {},
    },
  },
  render() {
    const { count, changeCount, $slots } = this;
    return (
      <div class="test" onClick={() => changeCount?.(count + 1)}>
        Test<span>{count}</span>
        {$slots.default ? $slots.default({ count }) : "children"}
      </div>
    );
  },
});

方式四

// Vue组件 Test.tsx
// 函数式组件
import { FunctionalComponent } from 'vue';

const Test: FunctionalComponent<{
  count: number;
  changeCount: (count: number) => void;
  children?: (params: { count: number }) => React.ReactNode;
}> = ({ count = 0, changeCount, children = () => 'children' }) => {
  return (
    <div class="test" onClick={() => changeCount?.(count + 1)}>
      Test<span>{count}</span>
      { children({ count }) }
    </div>
  );
};

export default Test;

组件差异

综上:React写法比Vue的代码更简单, 以React为源码风格

Vue组件方式四函数式样组件相似度几乎可以达到100%但操作性不强,Vue函数式组件不支持状态属性,无生命周期,这些其实都可以借鉴Reac函数式组件处理,本期先不讨论,放到 Vue模拟React Hooks render版/函数式组件版

Vue组件方式二和React相似度及可操作性综合更高,但还是有差异的,差异如下:

类型ReactVue
文件拓展名tsx.vue
class对应属性名classNameclass
子元素来源propsslot
默认子元素keychildrendefault
渲染函数完整的函数式组件setup返回的函数
响应式核心及hooks此篇先不展开说此篇先不展开说

跨库组件库差异填平

如何填平这些差异呢?

设计一个function createComponent,在函数内部去翻转地球

使用时如下

// React/Vue组件 test/index.tsx

import type { C } from '../utils';
import { createComponent, getClsObj, dealChild } from '../utils';

const Test: C<{
  count: number;
  changeCount: (count: number) => void;
  children?: (params: { count: number }) => React.ReactNode;
}> = ({ count = 0, changeCount, children = () => 'children' }) => {
  return (
    <div {...getClsObj('test')} onClick={() => changeCount?.(count + 1)}>
      Test<span>{count}</span>
      { dealChild(children)?.({ count }) }
    </div>
  );
};

export default createComponent(Test, { childKeys: ['children'] });

经过 createComponent 的处理

  1. React的className及Vue的class都通过getClsObj去处理
  2. Vue组件开发时的所有子元素也从props中读取,但使用的时候无感知
  3. 同时支持props设置子元素,但如果通过默认插槽设置了子元素同时props设置了children, 即相同功能插槽优先级高于props
  4. 所有用作子元素的props属性需要通过 createComponent 第二个参数传入,目的是 createComponent内部会做处理即如果是undefined或function直接返回,如果其他处理为function,使用时如上children?.()

React组件库中的utils如下

// React组件的utils/index.ts

// 填平className key不同的差异
export const getClsObj = (cls: any) => {
  return {
    className: cls,
  };
};

/**
 * 处理子节点
 * --------
 * 全部处理为函数
 */
const dealChild = (child: any) => {
  if (child === undefined){
      return
  }
  if (typeof child === "function") {
    return child;
  // } else if (React.isValidElement(child)) {
  //  return () => child;
  } else {
    return () => child;
  }
};


export type C<P extends Record<string, unknown>> = React.FC<P>;

export const createComponent = <P extends Record<string, unknown>>(
  c: C<P>,
  { childKeys = [] }: { childKeys?: Array<keyof P> },
) => {
  const res = {
    [c.name]: ((propsInit: P, ...args: any[]) => {
      const props = { ...propsInit };

      Object.entries(props).forEach(([key, value]) => {
        if (childKeys.includes(key as keyof P)) {
          props[key as keyof P] = dealChild(value);
        }
      });

      return c(props, ...args);
    }) as C<P>,
  };

  // 保留函数名
  const dealRender = res[c.name];

  return dealRender;
};


Vue组件库中的utils如下

// Vue组件的utils/index.ts
import type { VNodeChild } from "vue";
import { isVNode, defineComponent, computed } from "vue";

// 填平className key不同的差异
export const getClsObj = (cls: any) => {
  return {
    class: cls,
  };
};

/**
 * 处理子节点
 * --------
 * 全部处理为函数
 */
export const dealChild = (child: any) => {
  if (child === undefined){
      return
  }
  if (typeof child === "function") {
    return child;
  // } else if (isVNode(child)) {
  //  return () => child;
  } else {
    return () => child;
  }
};

const DEFAULT_KEY = "default";
const CHILDREN_KEY = "children";

const transformProps = <P extends Record<string, unknown>>(
  initProps: P & { default: P["children"] },
  keysToTransform: (typeof DEFAULT_KEY | keyof P)[],
) => {
  const props: P = { ...initProps };
  
  Object.entries(props).forEach(([key, value]) => {
  
    if (keysToTransform.includes(key as keyof P)) {
      props[(key === DEFAULT_KEY ? CHILDREN_KEY : key) as keyof P] =
        dealChild(value);
    }
    
  });
  
  return props;
};

export type C<P extends Record<string, unknown>> = (props: P) => VNodeChild;

export const createComponent = <P extends Record<string, unknown>>(
  c: C<P>,
  { childKeys = [] }: { childKeys?: Array<keyof P> },
) => {
  const keysToTransform = childKeys.map((key) => (key === CHILDREN_KEY ? DEFAULT_KEY : key));

  return defineComponent<P>(
    (props, { slots }) => {
      const params = computed<P>(() => {
        return transformProps<P>(
          {
            ...props,
            ...(slots as P & { default: P["children"] }),
          },
          keysToTransform,
        );
      });

      return () => c(params.value);
    },
    {
      name: c.name,
    },
  );
};

项目结构

新建一个monorepo项目,两个子库,component-react, component-vue, 保证src中除utils目录以外的内容完全一样,可以选择其中一个包作为源码库创建硬连接去另一个包中,这样无需拷贝,为啥不直接创建个第三个包以提取公共部分,当然可以如styles、locales、tools可以,但组件如test目录不可以,因为在在React和Vue中虽然都是如下,但显然React和Vue的utils实现是不同的,只是暴露相同名称Api及类型而已。

import type { C } from '../utils';
import { createComponent, getClsObj } from '../utils';

目录仅供参考如下:

- packages

-- component-react
--- utils.ts 
--- test // 内容同component-vue同路径文件
---- index.tsx // 内容同component-vue同路径文件

-- component-vue
--- utils.ts // Vue组件库专门的utils.ts
--- test // 内容同component-vue同路径文件
---- index.tsx // 内容同component-vue同路径文件

project/
│
├── packages/
│   │
│   ├── component-react/
│   │   │
│   │   ├── src/
│   │   │   ├── utils/ // React组件库专门的utils.ts
│   │   │   │   └── index.ts 
│   │   │   │
│   │   │   ├── test/  // 内容同component-vue同路径文件
│   │   │   │   └── index.tsx
│   │   │   │
│   │   │   ├── tools/  // 内容同component-vue同路径文件
│   │   │   │   └── index.tsx
│   │   │   │
│   │   │   ├── styles/ // 内容同component-vue同路径文件
│   │   │   │   └── test.less
│   │   │   │   └── index.less
│   │   │   │
│   │   │   └── locales/ // 内容同component-vue同路径文件
│   │   │       └── zh-CN.json
│   │   │       └── en-US.json
│   │   │       └── index.ts
│   │   │
│   │   ├── package.json
│   │   └── ... (其他文件和目录)
│   │
│   ├── component-vue/
│   │   │
│   │   ├── src/
│   │   │   ├── utils/ // Vue组件库专门的utils.ts
│   │   │   │   └── index.ts 
│   │   │   │
│   │   │   ├── test/  // 内容同component-react同路径文件
│   │   │   │   └── index.tsx
│   │   │   │
│   │   │   ├── tools/  // 内容同component-vue同路径文件
│   │   │   │   └── index.tsx
│   │   │   │
│   │   │   ├── styles/  // 内容同component-react同路径文件
│   │   │   │   └── test.less
│   │   │   │   └── index.less
│   │   │   │
│   │   │   └── locales/  // 内容同component-react同路径文件
│   │   │       └── zh-CN.json
│   │   │       └── en-US.json
│   │   │       └── index.ts
│   │   │
│   │   ├── package.json
│   │   └── ... (其他文件和目录)
│   │
│   └── ... (其他目录和文件)
│
├── package.json
└── ... (其他项目层级的文件和目录)