最近有幸参与了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
组件是使用对象方式声明,最简情况下要包含props
和setup
标签,其中setup
返回了render
函数,所以并不需要再重新声明render
——这种做法仅限VUE3
。
props
props
是在声明这个组件对外的接口,其中的
type
表示类型,但注意这里使用的是Constructor
表示类型,也就是JS原生数据的那几个类型的构造器。default
表示默认值required
表示该属性是否必需。
setup
这里的setup
实际上是个高阶函数,是render
所在的闭包。整个过程并没有用到this
,说明这部分可以与上下文无关,有解耦的可能。
用Vue编译jsx的函数式组件
我们先用webpack
来实现,实打实接触一下编译过程。
React
项目,一般通过babel-loader
来处理js文件,同时在loader中挂载react
专用的presets
和plugin
,实现对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也有对应的配套方案:reactive
和onMouted
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
}
通过这两个类型,我们实现了props
中type
和required
两个属性的声明,剩下的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
,还并没有做更大范围测试。
以上。