likes
comments
collection
share

想要开发组件库?那你一定要提前了解一下这个神器

作者站长头像
站长
· 阅读数 12

前言

相信很多人都有过制作组件库的经历,也有一部分人做的组件库需要适配各种不同的框架,或者相同框架的不同版本(如:Vue2Vue3等)此时很多人就会选择 Web Components 来作为组件库的技术栈。如果不知道什么是 Web Components 的话可以去看一下这个视频:

《什么是 Web Components》

这里就不做过多赘述了,视频里讲的很详细。国内用 Web Components 做组件库最知名的案例应该就是凹凸实验室的 Taro UI 了吧:

《Taro Next H5 跨框架组件库实践》

凹凸团队在文章指出用纯原生语法构建 Web Components 过于繁琐,希望能用声明式语法来构建 Web Components,所以选择了 Stencil 这个框架/库/编译器(我也不知道叫啥好,就先叫框架吧。大家能明白意思就行,不必过于钻牛角尖非要论证这个到底是个框架还是库还是编译器)但 Stencil 生成出来的组件有虚拟 DOM 及简易版 Diff 算法,编译出来的组件还是有点重。比方说我在 React 里用 Stencil 编译出来的组件库,React 本身就已经有一套完善的虚拟 DOMDiff 算法了,然而引入的组件库却包含了另一套虚拟 DOMDiff 算法,这就很是浪费。

而且 Web Components 也是有兼容性要求的:

想要开发组件库?那你一定要提前了解一下这个神器

有些人公司的技术栈是 Vue 2 + 3,因为可能有些项目还是需要兼容低版本浏览器的,所以不得不用 Vue2,新项目可以用 Vue3,那么此时用 Web Components 就有点不太合适了。当然有人可能会说不是有 Polyfill 么?首先 Polyfill 体积太大了,我们仅仅就是想用个组件库而已,不至于再引入一个 Polyfill。其次 SafariWeb Components 的支持有一点瑕疵,从上图可以看到 Safari 虽然也是绿色,但是不是那种绿意盎然的绿,而是一种脏绿色。这种脏绿代表部分支持或者虽然支持但是有 bugSafari 属于前者,当然这个其实也有 Polyfill,张鑫旭从 GitHubFork 了一份然后进行了改进:

《Safari不支持build-in自定义元素的兼容处理》

不过还是太麻烦了点,很多人用 Web Components 的目的仅仅只是为了能一套代码适配多个框架而已。那么有没有这样一种方案:同样也是只写一套代码,通过编译的手段来达成相同的目的。

Angular 之父:那必须有啊!我这方案可比 Web Components 好多了

Mitosis

这个叫 Mitosis 的项目出自 Angular 之父之手,意思是有丝分裂:

想要开发组件库?那你一定要提前了解一下这个神器

这个名字起的也非常符合它的职责,一个变俩、俩变四、四变八、八变十六… 我们只需要编写一套DNA(代码)就能为我们分裂出一大堆细胞(组件):

想要开发组件库?那你一定要提前了解一下这个神器

我们再来看看它 GitHub 上的标语:

想要开发组件库?那你一定要提前了解一下这个神器

Web Components:Write components once, run everywhere 这不是我的口号么?你特么抢我台词啊!

Mitosis:我特么不仅要抢你台词,我还要把你的工作都给抢过来!

想要开发组件库?那你一定要提前了解一下这个神器

从口号中也可以看出,它与 Web Components 的作用高度重合,但一个是运行时方案,一个是编译方案。这两个方案各有利弊吧,随着最近重编译的框架迅速崛起,很多人都觉得这是一个更先进的方案。之前在某问答平台上看过这样一条回答(关于Svelte的):

想要开发组件库?那你一定要提前了解一下这个神器

当然 Svelte 是编译成原生 DOM 操作,既然能编译成原生语法那为什么不能编译成框架语法呢?Angular 之父就是这么想的,我们一起来看一下 Mitosis 的语法。

简介

我们点击进入 Mitosis 的官网 mitosis.builder.io,映入眼帘的是这样的一幅界面:

想要开发组件库?那你一定要提前了解一下这个神器

左边就是我们写的代码(他们写的 咱们啥也没写呢还),一股浓烈的 React 味道扑鼻而来。右侧就是编译后的代码,默认是 Vue2,你看 Angular 他爸多体贴,不仅给咱们提供了 TSJS 选项,甚至还能选择编译成 Options API 还是 Composition API

想要开发组件库?那你一定要提前了解一下这个神器

从这一个简单的小案例就能侧面反映出如今 Vue 的地位,身处C位啊家人们!那么多框架凭什么就 Vue 排第一?我知道有人会说这肯定不是按照流行程度或者使用人数来排的名,因为如果按照这个标准来排名的话肯定是 React 排第一。这么说确实没毛病,但我觉得 React 之所以没有排在 Vue 前面的原因是因为左侧编辑器代码写的本来就是浓浓的 React 风,如果右侧编译区默认还是 React 的话那就是从 React 编译为 React

想要开发组件库?那你一定要提前了解一下这个神器

这有什么意思啊?就是多引入了一个 Styled JSX 呗,这怎么能体现出 Mitosis 的技术含量呢?所以才会把 React 往后排一位,咱就说能编译的框架有这么老多,在排除 React 之后为啥就 Vue 能排在最前面:

想要开发组件库?那你一定要提前了解一下这个神器

我知道肯定又会有人说:你光在那凸显右侧编译区 Vue 的地位,怎么不说说左侧编辑区没有 Vue 呢?人家可不光是只支持 JSX,甚至还支持 Svelte

想要开发组件库?那你一定要提前了解一下这个神器

如果 Vue 真像你说的那么地位显赫,那为什么只有 MITOSIS JSXSVELTOSIS?怎么没有 VUETOSIS?也就是说为什么只能把 React 编译成 Vue 却不能反过来把 Vue 编译成 React 呢?

首先我觉得 Vue 的单文件组件语法糖有点过多了,写法也越来越多:

<template>
  <div/>
<template>

<script lang="ts" setup generic="T extends { name: string }">
...
</script>

看上去就没有 Svelte 简洁,概念太多,需要你对 Vue 有一定的了解,而且还有很多莫名其妙的全局变量语法糖:

<template>
  <div/>
<template>

<script lang="ts" setup generic="T extends { name: string }">
defineOptions({
  name: 'Foo'
})

defineExpose({ a, b })

const props = defineProps<{
  a: string,
  b?: number
}>()

const emit = defineEmits<{
  (evt: 'update:modelValue', value: number): void
}>()

const slots = defineSlots<{
  default(props: { foo: string; bar: number }): any
}>()
</script>

反正就是越来越复杂,而且 Vue 还在不停的更新迭代中,每次大更新就又往里塞语法糖,鬼知道 Vue3.43.53.6、甚至是 4.0 会变成什么鬼样子!随之而来的争议也越来越大:

想要开发组件库?那你一定要提前了解一下这个神器

Svelte 则要简洁的多,语法糖就一个 $:,触发更新也好记,只有等号 = 才会触发更新,对使用者而言不需要多么了解 Svelte,只需要记住几个简单的规则就可以迅速上手。而 JSX 也是同理,JSX 也不需要你多么了解 React,大部分都是 JS,你只需要了解一下在 JS 里面写 XML 的规则就行,所以这才是左侧编辑区仅支持 JSXSvelte 的原因之一。

当然个人认为这同样也是 SvelteVue 更受(国外)新手欢迎的原因之一,Svelte 在国外已经抢走了原本属于 Vue2 的一部分市场份额。之前 Vue 正是凭借简洁易用等优势在各大培训机构里获得了老师们的青睐,老师讲啥底下的学生就学啥,一批批 “Vue” 开发者大批量的涌入市场,进一步提升了 Vue 的市场份额。但目前明显是 Svelte 更简单,只不过在中国没人会招只会 Svelte 的前端,所以为了就业,培训班们估计也不会教。

但如果 Vue 的复杂程度超过了某个临界点达到了培训班里大多数学生都学不会的那种程度或是学习周期过长的话,就很有可能会给 SvelteSolid 这类新秀一定的可乘之机。当然也有可能只教 Vue2,毕竟 Vue2 也是 Vue,而且好学的多。学会了 Vue2 差不多也就“毕业”了,开始找工作了,到时候再让他们在工作中摸索 Vue3 吧。

言归正传,我们先从 MitosisREADME 看起:

想要开发组件库?那你一定要提前了解一下这个神器

翻译:我们正在积极寻找有兴趣成为 Mitosis 贡献者的人。鉴于它的编译的范围很广,任何工程师都有很大的空间对项目产生巨大而持续的影响。我们投入大量时间帮助新手入职,并教他们如何更改代码库(参见以下示例:#847#372#734)。如果有兴趣,请查看我们的优秀首期列表或联系我们的Discord

确实他们步子迈的很大,编译的可不仅仅只是那几个常见框架而已。现在正是缺人的时候,想在自己简历上增添亮点的小伙伴们可得抓紧了!我们来看一下它到底能编译成多少种技术:

想要开发组件库?那你一定要提前了解一下这个神器

  • VUE
  • REACT
  • QWIK
  • ANGULAR
  • SVELTE
  • REACT NATIVE
  • RSC
  • SWIFT
  • SOLID
  • STENCIL
  • MARKO
  • PREACT
  • LIT
  • ALPINE.JS
  • WEB COMPONENTS
  • HTML
  • LIQUID
  • TEMPLATE
  • MITOSIS
  • JSON
  • BUILDER

真不是我吹 Vue 的地位,如果刨除掉 React 这一特殊因素的话,Vue 甚至都排在私心之前。什么是私心呢?给自己造出来的东西一个比较靠前的排序就叫私心,Angular 之父对吧?当然 Angular 本身就是三大框架之一,排序靠前也是应该的。Qwik 这个就能看出私心来了,虽然是个很有潜力的超新星,但总体实力是比不上 Angular 的。Qwik 同样也是 Angular 之父的最新力作(Angular之父牛逼),所以排在了 Angular 的前面。

我们发现 Mitosis 居然还可以编译为 JSON,编译成其他框架其实也是根据生成的 JSON 数据来进行编译的。Mitosis 还支持插件功能,你可以根据 JSON 来生成任何你想要的东西。这对于最近几年很火的低代码平台是个福音,Mitosis 所在的 Builder 就是做低代码的,Mitosis 正是他们在做低代码时所创造的,目前也正在为他们的低代码产品提供支持(所以不用担心会出现赚不到钱不维护了的情况):

想要开发组件库?那你一定要提前了解一下这个神器

Mitosis 采用的是静态 JSX 来限制灵活性,不然的话根本没法编译:

  • 高情商:静态 JSX
  • 低情商:阉割版 JSX

想要开发组件库?那你一定要提前了解一下这个神器

阉割版静态版 JSX 灵感源自于 Solid.js,一个正常的组件将会被编译为如下 JSON

想要开发组件库?那你一定要提前了解一下这个神器

除了编写组件库之外,Mitosis 同样也可以用于编写 SDK。他们的新一代 SDK 就是用 Mitosis 写的,并且还因为交付速度很快而受到了赞扬:

想要开发组件库?那你一定要提前了解一下这个神器

心动不如马上行动,接下来我们就按照官方文档来创建一个 Mitosis 项目。

创建项目

官网里没有类似于 create-react-appcreate-vue 这样的工具,而是稍显繁琐,既然不好用那就是机会!大家可以立刻去给 create-vitePR,让它支持 Mitosis,相信我,这玩意日后一定会火🔥

README 写的第一步就是:

想要开发组件库?那你一定要提前了解一下这个神器

这真的很不人性化,因为这一看就是在一个已有项目里的操作,我们还得 npm init 一个项目:

想要开发组件库?那你一定要提前了解一下这个神器

然后 npm i @builder.io/mitosis-cli @builder.io/mitosis,紧接着再在根目录下创建一个 mitosis.config.js

想要开发组件库?那你一定要提前了解一下这个神器

mitosis.config.js 里面写上:

/** @type {import('@builder.io/mitosis').MitosisConfig} */
module.exports = {
  files: 'src/**',
  targets: ['vue3', 'solid', 'svelte', 'react'],
};

然后再来安装一下 TSnpm i -D typescript,随即在根目录下创建一个 tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "jsx": "preserve",
    "jsxImportSource": "@builder.io/mitosis"
  }
}

接下来咱们再来安装一个 ESLint 插件,由于 MitosisJSX被阉割过被静态化过的 JSX,所以必须要用 ESLint 来限制你的灵活性。我们来看一下它都有哪些规则,首先就是 css-no-vars 来限制你在 css 属性里写表达式,哦对了,在 React 里写的 style 属性到了这里变成 css 属性了:

<button css={{ color: a ? 'red' : 'green' }} />

这段代码在 React 里没问题,但请记住,Mitosis 是一个编译型框架,React 之所以能那么灵活就是因为它更依仗运行时,我们必须写的比较静态一点才能被编译出来一个具体值:

<button css={{ color: 'red' }} />

说实话其实还是蛮不爽的,style 的值是动态的这是一个很常见的需求,但在这里却只能写静态值,这也是作为一个编译型框架所不得不承受的缺点。再接下来的规则是 jsx-callback-arg-name,这个规则可能会让人比较费解:

<button onClick={e => console.log(e)} />

这段代码有毛病么?在 Mitosis 里还真就有毛病!这个参数不能写成 e,要写成别的(如event):

<button onClick={event => console.log(event)} />

写成 e 会影响编译么?我不知道,我只想问一句 Why

想要开发组件库?那你一定要提前了解一下这个神器

接下来这个规则还算好接受一点,叫 jsx-callback-arrow-function,顾名思义,跟 jsx 的回调函数以及箭头函数有关,只接受箭头函数来作为回调函数,其他值都不行:

// 这些都是反例:

<button onClick={ function(event) {} }/>

<button onClick={ null }/>

<button onClick={ "string" }/>

<button onClick={ 1 }/>

<button onClick={ true }/>

<button onClick={ {} }/>

<button onClick={ [] }/>

<button onBlur={ [] }/>

<button onChange={ [] }/>

必须箭头函数:

<button onClick={ event => doSomething(event) }/>

<button onClick={ event => doSomething() }/>

<button onClick={ event => {} }/>

<button onClick={ () => doSomething() }/>

再然后的条规则叫 no-assign-props-to-state,一看名字就明白了,不能把 props 分配给 state

import { useStore } from '@builder.io/mitosis';

export default function MyComponent(props) {
  const state = useStore({ text: props.text });
}

Mitosis 中用 useStore 来表示响应式值,你可以把它简单的理解为 Vuereactive

import { reactive } from 'vue';

const props = defineProps(...)
const state = reactive({ text: props.text })

下一条规则是不能在 state 里写异步函数(no-async-methods-on-state):

// 反例:
export default function MyComponent() {
  const state = useStore({
    async doSomethingAsync(event) {
      return;
    },
  });
}

那我们需要异步操作的话要怎么办?可以用一个立即执行函数来包裹一下:

export default function MyComponent() {
  const state = useStore({
    doSomethingAsync(event) {
      void (async function () {
        const response = await fetch();
      })();
    },
  });
}

接下来这条规则会严重限制我们写 jsx 的灵活性,不能在 return 出去的 jsx 里写条件判断(no-conditional-logic-in-component-render)我们之前可能会经常写出这样的代码:

// 反例:
export default function MyComponent(props) {
  if (x > 10) return <a />;
  else if (x < 5) return <b />;
  else return <c />
}

Mitosis 里可不行,你就只能有一个 return

// 反例:
export default function MyComponent(props) {
  // 这个 return 必须是静态的:
  return <a />
}

接下来是 state 不能解构(no-state-destructuring)跟 Vue 一样,因为最终也会编译成 Vue 嘛:

export default function MyComponent() {
  const state = useStore({ foo: '1' });
  // 不能解构:
  const { foo } = state;
}

下面这个规则就比较令人费解了,不能在 JSX 里声明变量(no-var-declaration-in-jsx):

export default function MyComponent(props) {
  return (
    <div>
      {a.map((x) => {
        // 不能在这声明变量:
        const foo = 'bar';
        return <span>{x}</span>;
      })}
    </div>
  );
}

是不是有点扯蛋?但也不是说完全不能声明变量,比方说解构声明就可以:

export default function MyComponent(props) {
  return (
    <div
      someProp={a.find((b) => {
        // 解构声明的话可以:
        const { x } = b;
        return x < 1;
      })}
    />
  );
}

接下来这条规则也挺操蛋的,在组件里不能把一个变量赋值给另外一个变量:

// 以下全部都是反例:
export default function MyComponent(props) {
  const a = b;

  return <div />;
}

let a;
export default function MyComponent(props) {
  a = b;

  return <div />;
}

export default function MyComponent(props) {
  let a;
  a = b;

  return <div />;
}

下面这条规则对词汇量不高的人来说很不友好,不能声明一个与 state 属性同名的变量(no-var-name-same-as-state-property):

import { useStore } from '@builder.io/mitosis';

export default function MyComponent(props) {
  const state = useStore({
    a: 1,
  });

  // 这个 a 和 stata.a 里的属性重名了:
  const a = 1;

  return <div />;
}


export function MyComponent(props) {
  const state = useStore({
    a: 1,
  });

  function myFunction() {
    // 哪怕说你声明在其他作用域里也不行:
    const a = 1;
  }

  return <div />;
}

接下来这条规则是想把 JSX 给变成一个单文件组件,因为除了可以导出类型之外就只能用 export default 默认导出了(only-default-function-and-imports):

// 
export default function MyComponent(props) {
  return <div />;
}

// 这么写不行 只能 export default
export const x = y;

// 当然如果你导出的是一个 TS 类型的的话还是可以的:
export type a = 1;

接下来就是和 Solid.js 差不多的规则了,不要用三元表达式来进行 JSX 的条件判断,取而代之的是 <Show> 组件(prefer-show-over-ternary-operator):

export default function MyComponent(props) {
  // 这么写可不行:
  return <div>{foo ? <bar /> : <baz />}</div>;
  
  // 要写成这样:
  return (
    <div>
      <Show when={foo}>
        <bar />
      </Show>
      <Show when={!foo}>
        <baz />
      </Show>
    </div>
  );
}

下面的规则是为了纠正你从 React 那里带过来的习惯,ref 不需要 .current 属性(ref-no-current):

import { useRef } from '@builder.io/mitosis';

export default function MyComponent(props) {
  const inputRef = useRef();

  const myFn = () => {
    // 不需要 .current
    inputRef.current.focus();
  };

  return <div />;
}

最后一条规则是 useStore 的返回值必须叫 stateuse-state-var-declarator)叫别的不行:

export default function MyComponent(props) {
  // useStore() 的返回值起名不是 state:
  const a = useStore();
  // 必须写成这样:
  const state = useStore();

  return <div />;
}

还有就是 useState 的返回值必须解构,也就是我们常用的一种写法:

export default function MyComponent(props) {
  const [name, setName] = useState();
  return <div />;
}

写成这样是万万不行的:

export default function MyComponent(props) {
  const a = useState();
  return <div />;
}

了解完这几条规则之后我们就来安装 ESLint 插件吧:

npm i -D eslint @builder.io/eslint-plugin-mitosis

安装完成后再运行一下 npx eslint --init,接下来就是根据提示选择自己想要的 eslint 规范:

想要开发组件库?那你一定要提前了解一下这个神器

运行完成后所生成的 .eslintrc.js 长这样:

想要开发组件库?那你一定要提前了解一下这个神器

我们给它改成这样:

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: [
    'xo',
    'plugin:@builder.io/mitosis/recommended',
  ],
  overrides: [{
    extends: ['xo-typescript'],
    files: ['*.ts', '*.tsx'],
  }],
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    ecmaFeatures: {jsx: true},
  },
  rules: {'@builder.io/mitosis/css-no-vars': 'error'},
  plugins: ['@builder.io/mitosis'],
};

接下来再来新建一个 src 文件夹,文件夹里新建一个 Component.lite.tsx,记住中间一定要有 .lite,如果你写成 Component.tsx 那是万万不行的。

import {useState} from '@builder.io/mitosis';

export default function MyComponent() {
  const [name, setName] = useState('Alex');
  return (
    <div>
      <input
        css={{
          color: 'red',
        }}
        value={name}
        onChange={event => {
          setName(event.target.value);
        }}
      />
      Hello! I can run in React, Vue, Solid, or Liquid!
    </div>
  );
}

想要生成组件的话我们就要运行一下:npm exec mitosis build,接下来我们就可以看到命令行里:

想要开发组件库?那你一定要提前了解一下这个神器

定睛一看,根目录下多了个 output 文件夹:

想要开发组件库?那你一定要提前了解一下这个神器

我们挨个来看下:

// React:
import * as React from "react";
import { useState } from "react";

function MyComponent(props) {
  const [name, setName] = useState(() => "Alex");
  return (
    <>
      <div>
        <input
          className="input"
          value={name}
          onChange={(event) => {
          setName(event.target.value);
          }}
        />
        Hello! I can run in React, Vue, Solid, or Liquid!
      </div>
      <style jsx>{`
        .input {
          color: red;
        }
      `}</style>
    </>
  );
}

export default MyComponent;
// Solid:
import { createSignal } from "solid-js";
import { css } from "solid-styled-components";

function MyComponent(props) {
  const [name, setName] = createSignal("Alex");

  return (
    <div>
      <input
        class={css({
          color: "red",
        })}
        value={name()}
        onInput={(event) => {
          setName(event.target.value);
        }}
      />
      Hello! I can run in React, Vue, Solid, or Liquid!
    </div>
  );
}

export default MyComponent;
<!-- Svelte -->
<script>
  let name = "Alex";
</script>

<div>
  <input class="input" bind:value={name} />
  Hello! I can run in React, Vue, Solid, or Liquid!
</div>

<style>
  .input {
    color: red;
  }
</style>
<!-- Vue3 -->
<template>
  <div>
    <input class="input" :value="name" @input="name = $event.target.value" />
    Hello! I can run in React, Vue, Solid, or Liquid!
  </div>
</template>

<script>
import { defineComponent } from "vue";

export default defineComponent({
  name: "my-component",
  data() {
    return { name: "Alex" };
  },
});
</script>

<style scoped>
.input {
  color: red;
}
</style>

是不是还挺智能的?不过有一点我很好奇,就是 React 的函数组件是每次更新都会执行一次,也就是说如果我们写成这样:

想要开发组件库?那你一定要提前了解一下这个神器

那么每次组件更新时都会 console.log 一遍,换算成 Vue 的话就相当于:

export default {
  updated () {
    console.log(this.name);
  }
};

那么会被编译成这样吗?我们再来运行一遍 npm exec mitosis build 试试。神奇的是,这次编译把这个 console.log 直接编译没了,无论哪个框架的组件都没有这段代码,这也可以理解,为了让所有框架行为一致,这些东西必须写在生命周期里,那我们就:

import {useState, onMount} from '@builder.io/mitosis';

export default function MyComponent() {
  const [name, setName] = useState('Alex');
  
  onMount(() => {
    console.log(name);
  });

 return (
    <div>
      <input
        css={{
          color: 'red',
        }}
        value={name}
        onChange={event => {
          setName(event.target.value);
        }}
      />
      Hello! I can run in React, Vue, Solid, or Liquid!
    </div>
  );
}

最终编译(只截取生命周期部分):

// React:
useEffect(() => {
  console.log(name);
}, []);
// Solid:
onMount(() => {
  console.log(name);
});
// Svelte:
onMount(() => {
  console.log(name);
});
// Vue
mounted() {
  console.log(name);
}

VueSolid 这里就有点毛病了,因为按理来说应该是:

  • Vue::console.log(this.name)
  • Solid:console.log(name())

但它们全部都被编译成了 console.log(name),实话实说,我觉得还是有点坑,哪怕说 Vue 这边没有这个 bug,但我在组件里除了声明变量以外什么都不能写,想写逻辑的话就只能写在生命周期内,不然就全给你删了。而且这个语法限制有点过多了,毕竟想要写一套代码编译成差异巨大的各个框架组件的话,还是很难做到的。

不过随着这个项目的持续迭代,说不定这些问题都会有对应的解决方案,不过目前来看不太推荐大家拿它来写组件库,文档写的也语焉不详,有点坑…

往期精彩文章