tiny-vue/convert-vue-to-jsx.js

456 lines
15 KiB
JavaScript
Raw Permalink Normal View History

const fs = require('node:fs')
const path = require('node:path')
const { parse } = require('@vue/compiler-sfc')
const fse = require('fs-extra')
// Vue文件都在这个目录下
const vueFilesDir = './packages/vue/src'
// 转换后的JSX文件将保存在这个目录下
const jsxFilesDir = './packages/react-components'
function convertWord(str, first) {
if (!str) return str
const arr = str.split('-')
let result = str
if (arr[1]) {
result = arr[0] + String(arr[1][0]).toLocaleUpperCase() + arr[1].slice(1)
}
if (first) result = String(result[0]).toLocaleUpperCase() + result.slice(1)
return result
}
function uniqueArray(arr) {
return Array.from(new Set(arr))
}
function extractPropNames(str) {
const lines = str.split('\n')
let inProps = false
const symbolTable = []
const props = []
let returnObj = null
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) continue
if (trimmedLine === 'props: {') {
symbolTable.push('{')
inProps = true
} else if (inProps) {
if (!trimmedLine.includes('{') && !trimmedLine.includes('}') && symbolTable.length === 1) {
if (
trimmedLine.includes('...props') ||
trimmedLine.includes('...$props') ||
trimmedLine.includes('//') ||
trimmedLine.includes('props')
) {
continue
}
// console.log(trimmedLine, symbolTable, str, 'str')
const exec = /([a-z_][a-zA-Z0-9]*)[/:]/.exec(trimmedLine)
props.push(exec[1])
} else {
if (trimmedLine.includes('{') && (!trimmedLine.includes('}') || trimmedLine.includes('{}'))) {
symbolTable.push('{')
if (trimmedLine.includes('{}')) {
symbolTable.pop()
}
if (returnObj) {
continue
}
if (trimmedLine.includes('return')) {
returnObj = { index: symbolTable.length }
}
if (trimmedLine.includes('validator(') || !trimmedLine.includes(':')) {
continue
}
// console.log(trimmedLine, symbolTable, str, 'str')
const exec = /([a-z_][a-zA-Z0-9]*)[/:]/.exec(trimmedLine)
props.push(exec[1])
}
if (trimmedLine.includes('}') && !trimmedLine.includes('{')) {
if (symbolTable.length > 0 && symbolTable.slice(-1)[0] === '{') {
symbolTable.pop()
if (returnObj && symbolTable.length === returnObj.index) {
returnObj = null
}
if (symbolTable.length === 0) {
inProps = false
break
}
} else {
inProps = false
break
}
}
}
}
}
return props
}
// 将Vue组件转换为JSX的函数
function convertVueToJSX(vueFilePath, reactFilePath, componentName) {
const vueContent = fs.readFileSync(vueFilePath, 'utf-8')
const { template, script } = parse(vueContent).descriptor
let code = ''
let refs = []
let events = []
if (template) {
code = astToJSX(template.ast, refs, events)
refs = uniqueArray(refs)
events = uniqueArray(events)
}
const regex = /props:\s*\[([^\]]*)\]/
const match = script?.content.match(regex)
let props = []
if (match) {
props = match[1]
.trim()
.replace(/'/g, '')
.replace(/,/g, '')
.replace(/\n/g, '')
.split(' ')
.filter((item) => item && item !== '...props')
}
if (script) {
if (props.length === 0) {
if (!script?.content.includes('props')) {
props = []
} else {
props = extractPropNames(script.content)
}
}
}
const str = refs.length > 0 ? refs.map((item) => `const ${item}=useRef()`).join('\n') : ''
// 构造React组件的基本结构
const reactComponent = `
import { renderless, api } from '@opentiny/vue-renderless/${componentName}/vue'
import '@opentiny/vue-theme/${componentName}/index.less'
import { vc, If, Component, Slot, useSetup, useVm, $props, m } from '@opentiny/react-common'
import {useRef} from 'react'
export default function ${convertWord(path.basename(componentName, '.vue'))}(props) {
const {${props.join(',')}} = props
const defaultProps = {
...$props,
...props,
${props.join(',')}
}
const { ref, current: vm, parent } = useVm()
${str}
const { state,${events.join(',')} } = useSetup({
props: defaultProps,
renderless,
api,
constants: _constants,
vm,
parent,
doms:[${refs.join(',')}]
})
return (
${code}
);
}
`
// 写入JSX文件
fs.writeFileSync(reactFilePath, reactComponent)
}
// 定义一个函数用于将AST节点转换成JSX代码
function astToJSX(node, refs = [], events = [], ifArr = []) {
// 如果节点类型为文本节点,则直接返回文本内容
if (node.type === 2) {
return node.content
}
if (node.type === 5) {
return `{${node.content.content}}`
}
// 如果节点为元素节点
if (node.type === 1) {
let forAStr = ''
let className = ''
// 生成标签的属性代码
let props = node.props
.map((prop) => {
if (prop.name === 'bind') {
const exp = prop.exp
const arg = prop.arg
if (exp.isStatic && arg.isStatic) return `${arg.content}="${exp.content}"`
else if (!exp.isStatic) {
if (arg?.content === 'class') {
if (exp.content.startsWith('[') || exp.content.startsWith('{')) {
if (className) className = `vc(${exp.content},'${className}'`
else className = `vc(${exp.content}`
return ''
} else {
if (className) className = `vc([${exp.content},'${className}]'`
else className = `vc([${exp.content}`
// className = handleClass(className, exp.content)
return ''
}
} else if (!arg) {
return exp.content
}
return `${arg.content}={${exp.content}}`
}
} else if (prop.name === 'class') {
className = handleClass(className, prop.value.content)
return ''
} else if (prop.name === 'if' || prop.name === 'show') {
const exp = prop.exp
if (!exp) return ''
if (prop.name === 'if') {
ifArr.push(exp.content)
}
return `style={{display:${exp.content}?'block':'none'}}`
} else if (prop.name === 'else' || prop.name === 'else-if') {
let content = ifArr.slice(-1)?.[0]
ifArr.push(`!(${content})`)
return `style={{display:!(${content})?'block':'none'}}`
} else if (prop.name === 'model') {
const exp = prop.exp
return `value={${exp.content}} onChange={(e)=>${exp.content} = e.value}`
} else if (prop.name === 'on') {
const exp = prop.exp
const arg = prop.arg
if (!exp) return ''
if (!exp.content.includes('=') && !exp.content.includes('$emit')) {
let index = exp.content.indexOf('(')
if (index > -1) {
events.push(exp.content.slice(0, index))
} else events.push(exp.content)
}
if (!arg) {
return `{...${exp.content}}`
}
return `${convertWord('on-' + arg.content)}={${exp.content}}`
} else if (prop.name === 'for') {
if (!prop.exp?.content) {
return prop.loc.source
}
forAStr = prop.exp.content
return ''
} else {
if (prop.name === 'ref') {
refs.push(prop.value?.content)
return `ref={${prop.value?.content}} v-ref="${prop.value?.content}"`
}
if (prop.value) return `${prop.name}="${prop.value.content}"`
return prop.name
}
return ''
})
.join(' ')
// 递归处理子节点
const children = node.children.map((child) => astToJSX(child, refs, events, ifArr)).join('\n')
let tag = node.tag
if (tag === 'template' && !props.trim()) {
return children
}
if (node.tag === 'slot') {
props += ` parent_children={props.children} slots={props.slots}`
tag = 'Slot'
} else if (node.tag === 'component') {
tag = 'Component'
} else {
tag = convertWord(tag)
}
if (className && className.includes('vc')) {
className = `className={${className})}`
props += ' '
props += className
} else if (className) {
className = `className="${className}"`
props += ' '
props += className
}
if (forAStr) {
const arr = forAStr.split(' in ')
return `
{
${arr[1]}.map(${arr[0]}=>(
<${tag} ${props}>${children}</${tag}>
))
}
`
}
// 生成包含属性和子节点的标签代码
return `<${tag} ${props}>${children}</${tag}>`
}
}
function handleClass(className, content) {
if (className) {
if (className.includes('vc')) {
className = `${className},'${content}'`
} else {
className += ` ${content}`
}
} else {
className = content
}
return className
}
// 更新package.json
function updatePackageJson(componentName) {
const vuePackageJsonPath = path.join(vueFilesDir, componentName, 'package.json')
const reactPackageJsonPath = path.join(jsxFilesDir, componentName, 'package.json')
if (fs.existsSync(vuePackageJsonPath)) {
const vuePackageJson = JSON.parse(fs.readFileSync(vuePackageJsonPath, 'utf-8'))
const reactPackageJson = {
name: vuePackageJson.name.replace('vue', 'react'),
devDependencies: {},
scripts: {},
dependencies: Object.entries(vuePackageJson.dependencies).reduce((acc, [depName, depVersion]) => {
let arr = ['@opentiny/vue-renderless', '@opentiny/vue-theme', '@opentiny/vue-theme-mobile']
if (depName.startsWith('@opentiny/vue-') && !arr.includes(depName)) {
acc[`@opentiny/react${depName.slice(13)}`] = depVersion
} else {
acc[depName] = depVersion
}
return acc
}, {})
}
fs.writeFileSync(reactPackageJsonPath, JSON.stringify(reactPackageJson, null, 2))
}
}
// 更新index.ts文件的函数
function updateIndexTs(componentName) {
const reactIndexTsPath = path.join(jsxFilesDir, componentName, 'index.ts')
const reactIndexTsContent = `import ${convertWord(componentName, true)} from './src/index'\nimport '@opentiny/vue-theme/${componentName}/index.less'\n\nexport default ${convertWord(componentName, true)}`
fs.writeFileSync(reactIndexTsPath, reactIndexTsContent)
}
// 主函数:处理转换逻辑
function convertVueComponentsToReact(componentName) {
// 确保react组件目录存在
if (!fs.existsSync(jsxFilesDir)) {
fs.mkdirSync(jsxFilesDir, { recursive: true })
}
// 获取Vue组件目录列表
const vueComponentDirs = fs.readdirSync(vueFilesDir)
// 如果指定了组件名称,则只转换该组件
if (componentName) {
// 目前先不处理图表和表格
if (componentName.includes('chart') || componentName.includes('gird')) {
return
}
const vueComponentDir = path.join(vueFilesDir, componentName)
const reactComponentDir = path.join(jsxFilesDir, componentName)
if (!fs.existsSync(reactComponentDir)) fs.mkdirSync(reactComponentDir, { recursive: true })
// 确保Vue组件目录存在
if (fs.existsSync(vueComponentDir)) {
// 处理非.vue文件到目标目录
updatePackageJson(componentName)
// 获取src目录路径
const vueSrcDir = path.join(vueComponentDir, 'src')
const reactSrcDir = path.join(reactComponentDir, 'src')
// 确保src目录存在
if (!fs.existsSync(reactSrcDir)) {
fs.mkdirSync(reactSrcDir, { recursive: true })
}
updateIndexTs(componentName)
if (!fs.existsSync(vueSrcDir)) return
// 遍历Vue文件并生成JSX文件
const vueFiles = fs.readdirSync(vueSrcDir)
const vueFilesName = []
vueFiles.forEach((vueFile) => {
const vueFilePath = path.join(vueSrcDir, vueFile)
const reactFilePath = path.join(reactSrcDir, vueFile)
if (vueFile.endsWith('.vue')) {
if (['mobile.vue', 'mobile-first.vue', 'pc.vue'].includes(vueFile)) vueFilesName.push(vueFile.split('.')[0])
const jsxFilePath = path.join(reactSrcDir, vueFile.replace(/\.vue$/, '.jsx'))
convertVueToJSX(vueFilePath, jsxFilePath, componentName)
} else if (vueFile === 'index.ts') {
// 处理index.ts文件
} else {
copyNonVueFiles(vueFilePath, reactFilePath)
}
})
const code = `
${vueFilesName.map((item) => `import ${convertWord(item)} from './${item}'`).join('\n')}
export default function (props) {
const { tiny_mode = 'pc' } = props
const S = {
${vueFilesName.map((item) => `'${item}':${convertWord(item)}`).join(',')}
}[tiny_mode]
return S(props)
}
`
fs.writeFileSync(path.join(reactSrcDir, 'index.ts'), code)
}
} else {
// 如果没有指定组件名称,则转换所有组件
vueComponentDirs.forEach((vueComponent) => {
convertVueComponentsToReact(vueComponent)
})
}
}
async function copyNonVueFiles(sourceDir, targetDir) {
// 确保sourceDir是字符串类型
if (typeof sourceDir !== 'string') {
throw new TypeError('sourceDir must be a string')
}
// 确保targetDir是字符串类型
if (typeof targetDir !== 'string') {
throw new TypeError('targetDir must be a string')
}
// 读取sourceDir的stats信息
const stats = await fse.stat(sourceDir)
// 如果sourceDir是文件则直接复制排除.vue文件
if (stats.isFile()) {
const extname = path.extname(sourceDir)
if (extname !== '.vue') {
await fse.copy(sourceDir, targetDir)
}
}
// 如果sourceDir是文件夹则递归复制其中的非.vue文件
else if (stats.isDirectory()) {
const files = await fse.readdir(sourceDir)
for (const file of files) {
const sourceFilePath = path.join(sourceDir, file)
const targetFilePath = path.join(targetDir, file)
// 如果是文件夹递归调用copyNonVueFiles
const fileStats = await fse.stat(sourceFilePath)
if (fileStats.isDirectory()) {
// 如果目标文件夹不存在,则创建
await fse.mkdirp(targetFilePath)
// 递归复制子文件夹
await copyNonVueFiles(sourceFilePath, targetFilePath)
}
// 如果是文件,并且不是.vue文件则复制
else if (fileStats.isFile() && path.extname(file) !== '.vue') {
await fse.copy(sourceFilePath, targetFilePath)
}
}
} else {
throw new Error('sourceDir is not a file or directory')
}
}
// 运行脚本
if (process.argv.length > 2) {
const componentName = process.argv[2]
convertVueComponentsToReact(componentName)
} else {
convertVueComponentsToReact()
}