DevUI中VUE的TSX函数式组件实践

2,787 阅读5分钟

最近有幸参与了devui开源组件库的开发,开始接触vue。在使用了vue3 + tsx一段时间之后,感觉非常有趣,且舒适。

在开发过程中,很多时候我需要从网上查找资料,借鉴团队内部的代码,边学边做。总体感受是,vue中的jsx的应用,主要是替换template,仅是一种写法改变。而我(首先出于一种习惯),同时希望将react中的单向数据流、函数式组件等引入vue

在这种背景下,结合devui项目的具体情况,经过一段时间的摸索,终于形成了一套完整的vue3 + tsx组件写法,分享给大家,希望能有所启发。

这里我还想强调一下:在vue引入单向数据流和函数式组件等做法,仅出于一种技术可能性探讨的目的,希望能丰富vue的开发模式,无关框架的优劣强弱。且,由于vue3还有很多特性是实验性的,建议在生产过程还是采用相对保守的做法。

函数式组件的常见写法

一个组件的代码形态应该是下面的样子

// hello.jsx
export default ({ value = 'Hello Vue' }) => <h1>{value}</h1>

为了淡化框架的差异,这里并没有import React

函数式的好处之一,就是我们可以直接用高阶函数来复用。

// main.jsx
import Hello from './hello.jsx'
export default ({ name = 'JS老狗', ...rest }) => 
    <div><Hello { ...rest } /><h3>by {name}</h3></div>

相比类继承的组件复用方式,高阶复用可避免原型链过长,同时属性传递也是扁平的。

Vue3 defineComponent+JSX

感受一下vue3的组件写法

import { defineComponent } from 'vue'
export default defineComponent({
    props: {
        value: { type: String, default: 'Hello Vue3' }
    },
    setup(props) {
        // 这里不要这么写,如果这样就成了闭包变量了,脱离了响应式。
        // const { value = 'Hello Vue3' } = props || {}
        return () => {
            // 返回render函数内部,解构props是ok的
            const { value = 'Hello Vue3' } = props || {}
            return <h1>{value}</h1>
        }
    }
})

例子中的vue组件是使用对象方式声明,最简情况下要包含propssetup标签,其中setup返回了render函数,所以并不需要再重新声明render——这种做法仅限VUE3

props

props是在声明这个组件对外的接口,其中的

  • type表示类型,但注意这里使用的是Constructor表示类型,也就是JS原生数据的那几个类型的构造器。
  • default表示默认值
  • required表示该属性是否必需。

setup

这里的setup实际上是个高阶函数,是render所在的闭包。整个过程并没有用到this,说明这部分可以与上下文无关,有解耦的可能。

用Vue编译jsx的函数式组件

我们先用webpack来实现,实打实接触一下编译过程。

React项目,一般通过babel-loader来处理js文件,同时在loader中挂载react专用的presetsplugin,实现对jsx的支持。

Webpack支持

事实上,vue也有对应的loader来支持jsx:@vue/babel-plugin-jsx

Loader支持jsx

我们将jsx的rule配置改一下:

{
    test: /\.jsx?$/,
    use: {
        loader: 'babel-loader',
        options: {
            plugins: ['@vue/babel-plugin-jsx'],
        }
    },
    exclude: /node_modules/,
}

不要问我为什么没有presets配置,这个配置纯手摸出来的,亲测有效。

这样配置之后,jsx的函数式组件,都能被正确编译为vue组件。

Loader支持tsx

之前只要配一个ts-loader就可以。

推测vue并没有正式对tsx进行支持,jsx本来也只是替代template的,所以tsx这块完全没有头绪,也找不到相关资料。

后来收到style-loader css-loader的启发,直接吧jsx垫到ts-loader的后面即可:

{
    test: /\.tsx?$/,
    use: [{
        loader: 'babel-loader',
        options: {
            plugins: ['@vue/babel-plugin-jsx'],
        }
    }, 'ts-loader'],
    exclude: /node_modules/,
}

这一段代码,看起来很简单,实际上我折腾了2个多小时。

无状态组件

就很简洁:

export default ({ value = 'Hello Vue' }) => (
    <div>
        <h1>{value}</h1>
        <button>click me</button>
    </div>
)

内置状态组件

react中,函数式组件的内置状态通过useState来实现,生命周期弱化,替换成基于状态更新驱动的useEffect。Vue也有对应的配套方案:reactiveonMouted onUpdated等等系列生命周期hook。

React的内置状态实现

react中,状态机和return组件是在同一函数域内:

export default ({ value = 'Hello Vue' }) => {
    const [count, setCount] = useState(0)
    return (
        <div>
            <h1>{value}</h1>
            <button onClick={() => setCount(count + 1)}>
                click me {count}
            </button>
        </div>
    )
}

Vue内置状态实现

vue现在还做不到这种写法,我们可以模仿setup,在闭包中封入状态机:

const factory = () => {

    const state = reactive({
        count: 0
    })
    
    return ({ value = 'Hello Vue' }) => (
        <div>
            <h1>{value}</h1>
            <button onClick={() => state.count += 1}>
                click me {state.count}
            </button>
        </div>
    )
}

export default factory()

非常优雅,亲测有效。

强类型tsx对齐props

函数式组件是对setup的解耦,现在对齐props的能力。

props与state类型声明

props与state是组件核心的全部,我们借鉴react的做法,分开声明:

type TProps = {
    value?: string
}

type TState = {
    count?: number
}

通过这两个类型,我们实现了propstyperequired两个属性的声明,剩下的default,在程序中实现就可以了,比如解构时给默认值,或者短路等等。

const { value = 'abc' } = props

代码轻微重构

const factory = () => {
    const state = reactive<TState>({
        count: 0
    })

    return (props: TProps) => {
        const { value = 'Hello Vue' } = props
        const handleClick = () => {
            state.count = (state.count || 0) + 1
        }
        return (
            <div>
                <h1>{value}</h1>
                <button onClick={handleClick}>click me [{state.count}]</button>
            </div>
        )
    }
}
export default factory()

编译通过,运行正常。

关键代码

webpack.config.js

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
    mode: 'production',
    // mode: 'development',
    entry: './test1/index.jsx',
    output: {
        path: path.resolve(__dirname, './dist/test1'),
        filename: 'index.js',
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            template: './test1/index.html',
            filename: './index.html',
            inject: 'body'
        }),
    ],
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        alias: {}
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        plugins: ['@vue/babel-plugin-jsx'],
                    }
                },
                exclude: /node_modules/,
            },
            {
                test: /\.tsx?$/,
                use: [{
                    loader: 'babel-loader',
                    options: {
                        plugins: ['@vue/babel-plugin-jsx'],
                    }
                }, 'ts-loader'],
                exclude: /node_modules/,
            },
        ]
    },
    devServer: {
        compress: true,
        port: 9001,
    }
}

项目入口文件

// index.tsx
import { createApp } from 'vue'
import Page from './page'

createApp({
    render: () => <Page />
}).mount('#app')

后记

上面提到了,jsx可能只是template的一个替代,我用jsx写函数组件,有可能违背初衷。

在开发过程中,就className还是class的问题,提出过异议,但最终还是统一为class。大家给我的建议是,不要纠结这些细节。但如果vue真的出一套jsx的个性化写法,对开发者并不友好,不见得是件好事。

生命周期使用vue提供的on系列勾子就可以,但有些事件绑定名有区别,比如onMouseenter而不是onMouseEnter,还并没有做更大范围测试。

以上。