UNPKG

saven

Version:
690 lines (657 loc) 23.7 kB
const fs = require('fs-extra') const path = require('path') const {performance} = require('perf_hooks') const chokidar = require('chokidar') const chalk = require('chalk') const vfs = require('vinyl-fs') const ejs = require('ejs') const Vinyl = require('vinyl') const through2 = require('through2') const babel = require('babel-core') const traverse = require('babel-traverse').default const t = require('babel-types') const generate = require('babel-generator').default const template = require('babel-template') const _ = require('lodash') const shelljs = require('shelljs') const wxTransformer = require('@tarojs/transformer-wx') const Util = require('./util') const npmProcess = require('./util/npm') const CONFIG = require('./config') const babylonConfig = require('./config/babylon') const AstConvert = require('./util/ast_convert') const {getPkgVersion} = require('./util') const StyleProcess = require('./rn/styleProcess') const appPath = process.cwd() const projectConfig = require(path.join(appPath, Util.PROJECT_CONFIG))(_.merge) const sourceDirName = projectConfig.sourceRoot || CONFIG.SOURCE_DIR const sourceDir = path.join(appPath, sourceDirName) const tempDir = '.rn_temp' const tempPath = path.join(appPath, tempDir) const entryFilePath = Util.resolveScriptPath(path.join(sourceDir, CONFIG.ENTRY)) const entryFileName = path.basename(entryFilePath) const pluginsConfig = projectConfig.plugins || {} const pkgPath = path.join(__dirname, './rn/pkg') let isBuildingStyles = {} const styleDenpendencyTree = {} const reactImportDefaultName = 'React' let taroImportDefaultName // import default from @tarojs/taro let componentClassName // get app.js class name const providerComponentName = 'Provider' const setStoreFuncName = 'setStore' const routerImportDefaultName = 'TaroRouter' const taroApis = [ 'getEnv', 'ENV_TYPE', 'eventCenter', 'Events', 'internal_safe_get', 'internal_dynamic_recursive' ] const PACKAGES = { '@savenjs/taro': '@savenjs/saven', '@savenjs/taro-rn': '@savenjs/saven-rn', '@savenjs/taro-router-rn': '@savenjs/saven-router-rn', '@savenjs/redux': '@savenjs/redux', '@savenjs/components': '@savenjs/components', '@savenjs/components-rn': '@savenjs/components-rn', 'react': 'react', 'react-native': 'react-native', 'react-redux-rn': '@savenjs/saven-redux-rn' } function isEntryFile (filePath) { return path.basename(filePath) === entryFileName } function getClassPropertyVisitor ({filePath, pages, iconPaths}) { return (astPath) => { const node = astPath.node const key = node.key const value = node.value if (key.name !== 'config' || !t.isObjectExpression(value)) return // 入口文件的 config ,与页面的分开处理 if (isEntryFile(filePath)) { // 读取 config 配置 astPath.traverse({ ObjectProperty (astPath) { const node = astPath.node const key = node.key const value = node.value // if (key.name !== 'pages' || !t.isArrayExpression(value)) return if (key.name === 'pages' && t.isArrayExpression(value)) { value.elements.forEach(v => { pages.push(v.value) }) astPath.remove() } // window if (key.name === 'window' && t.isObjectExpression(value)) { return } if (key.name === 'tabBar' && t.isObjectExpression(value)) { astPath.traverse({ ObjectProperty (astPath) { let node = astPath.node let value = node.value.value if (node.key.name === 'iconPath' || node.key.value === 'iconPath' || node.key.name === 'selectedIconPath' || node.key.value === 'selectedIconPath' ) { if (typeof value !== 'string') return let iconName = _.camelCase(value.split('/')) iconPaths.push(value) astPath.insertAfter(t.objectProperty( t.identifier(node.key.name || node.key.value), t.identifier(iconName) )) astPath.remove() } } }) } } }) } astPath.node.static = 'true' } } function getJSAst (code, filePath) { return wxTransformer({ code, sourcePath: filePath, isNormal: true, isTyped: Util.REG_TYPESCRIPT.test(filePath), env: { TARO_ENV: Util.BUILD_TYPES.RN } }).ast } /** * TS 编译器会把 class property 移到构造器, * 而小程序要求 `config` 和所有函数在初始化(after new Class)之后就收集到所有的函数和 config 信息, * 所以当如构造器里有 this.func = () => {...} 的形式,就给他转换成普通的 classProperty function * 如果有 config 就给他还原 */ function resetTSClassProperty (body) { for (const method of body) { if (t.isClassMethod(method) && method.kind === 'constructor') { for (const statement of _.cloneDeep(method.body.body)) { if (t.isExpressionStatement(statement) && t.isAssignmentExpression(statement.expression)) { const expr = statement.expression const {left, right} = expr if ( t.isMemberExpression(left) && t.isThisExpression(left.object) && t.isIdentifier(left.property) ) { if ( (t.isArrowFunctionExpression(right) || t.isFunctionExpression(right)) || (left.property.name === 'config' && t.isObjectExpression(right)) ) { body.push( t.classProperty(left.property, right) ) _.remove(method.body.body, statement) } } } } } } } const ClassDeclarationOrExpression = { enter (astPath) { const node = astPath.node if (!node.superClass) return if ( node.superClass.type === 'MemberExpression' && node.superClass.object.name === taroImportDefaultName ) { node.superClass.object.name = reactImportDefaultName if (node.id === null) { const renameComponentClassName = '_TaroComponentClass' componentClassName = renameComponentClassName astPath.replaceWith( t.classDeclaration( t.identifier(renameComponentClassName), node.superClass, node.body, node.decorators || [] ) ) } else { componentClassName = node.id.name } } else if (node.superClass.name === 'Component') { resetTSClassProperty(node.body.body) if (node.id === null) { const renameComponentClassName = '_TaroComponentClass' componentClassName = renameComponentClassName astPath.replaceWith( t.classDeclaration( t.identifier(renameComponentClassName), node.superClass, node.body, node.decorators || [] ) ) } else { componentClassName = node.id.name } } } } function parseJSCode (code, filePath) { let ast try { ast = getJSAst(code, filePath) } catch (e) { throw e } const styleFiles = [] let pages = [] // app.js 里面的config 配置里面的 pages let iconPaths = [] // app.js 里面的config 配置里面的需要引入的 iconPath const isEntryFile = path.basename(filePath) === entryFileName let hasAddReactImportDefaultName = false let providorImportName let storeName let hasAppExportDefault let classRenderReturnJSX traverse(ast, { ClassExpression: ClassDeclarationOrExpression, ClassDeclaration: ClassDeclarationOrExpression, ImportDeclaration (astPath) { const node = astPath.node const source = node.source const value = source.value const valueExtname = path.extname(value) const specifiers = node.specifiers // 引入的包为 npm 包 if (!Util.isNpmPkg(value)) { // import 样式处理 if (Util.REG_STYLE.test(valueExtname)) { const stylePath = path.resolve(path.dirname(filePath), value) if (styleFiles.indexOf(stylePath) < 0) { styleFiles.push(stylePath) } } return } if (value === PACKAGES['@tarojs/taro']) { let specifier = specifiers.find(item => item.type === 'ImportDefaultSpecifier') if (specifier) { hasAddReactImportDefaultName = true taroImportDefaultName = specifier.local.name specifier.local.name = reactImportDefaultName } else if (!hasAddReactImportDefaultName) { hasAddReactImportDefaultName = true node.specifiers.unshift( t.importDefaultSpecifier(t.identifier(reactImportDefaultName)) ) } // 删除从@tarojs/taro引入的 React specifiers.forEach((item, index) => { if (item.type === 'ImportDefaultSpecifier') { specifiers.splice(index, 1) } }) const taroApisSpecifiers = [] specifiers.forEach((item, index) => { if (item.imported && taroApis.indexOf(item.imported.name) >= 0) { taroApisSpecifiers.push(t.importSpecifier(t.identifier(item.local.name), t.identifier(item.imported.name))) specifiers.splice(index, 1) } }) source.value = PACKAGES['@tarojs/taro-rn'] // insert React astPath.insertBefore(template(`import React from 'react'`, babylonConfig)()) if (taroApisSpecifiers.length) { astPath.insertBefore(t.importDeclaration(taroApisSpecifiers, t.stringLiteral(PACKAGES['@tarojs/taro-rn']))) } if (!specifiers.length) { astPath.remove() } } else if (value === PACKAGES['@tarojs/redux']) { const specifier = specifiers.find(item => { return t.isImportSpecifier(item) && item.imported.name === providerComponentName }) if (specifier) { providorImportName = specifier.local.name } else { providorImportName = providerComponentName specifiers.push(t.importSpecifier(t.identifier(providerComponentName), t.identifier(providerComponentName))) } source.value = PACKAGES['react-redux-rn'] } else if (value === PACKAGES['@tarojs/components']) { source.value = PACKAGES['@tarojs/components-rn'] } }, ClassProperty: getClassPropertyVisitor({filePath, pages, iconPaths}), // 获取 classRenderReturnJSX ClassMethod (astPath) { let node = astPath.node const key = node.key if (key.name !== 'render' || filePath !== entryFilePath) return astPath.traverse({ BlockStatement (astPath) { if (astPath.parent === node) { node = astPath.node astPath.traverse({ ReturnStatement (astPath) { if (astPath.parent === node) { astPath.traverse({ JSXElement (astPath) { classRenderReturnJSX = generate(astPath.node).code } }) } } }) } } }) }, ExportDefaultDeclaration () { if (filePath === entryFilePath) { hasAppExportDefault = true } }, JSXOpeningElement: { enter (astPath) { if (astPath.node.name.name === 'Provider') { for (let v of astPath.node.attributes) { if (v.name.name !== 'store') continue storeName = v.value.expression.name break } } } }, Program: { exit (astPath) { const node = astPath.node astPath.traverse({ ClassMethod (astPath) { const node = astPath.node const key = node.key if (key.name !== 'render' || filePath !== entryFilePath) return let funcBody = classRenderReturnJSX if (pages.length > 0) { funcBody = `<RootStack/>` } if (providerComponentName && storeName) { // 使用redux funcBody = ` <${providorImportName} store={${storeName}}> ${funcBody} </${providorImportName}>` } node.body = template(`{return (${funcBody});}`, babylonConfig)() }, CallExpression (astPath) { const node = astPath.node const callee = node.callee const calleeName = callee.name const parentPath = astPath.parentPath if (t.isMemberExpression(callee)) { if (callee.object.name === taroImportDefaultName && callee.property.name === 'render') { astPath.remove() } } else { if (calleeName === setStoreFuncName) { if (parentPath.isAssignmentExpression() || parentPath.isExpressionStatement() || parentPath.isVariableDeclarator()) { parentPath.remove() } } } } }) // import Taro from @tarojs/taro-rn if (taroImportDefaultName) { const importTaro = template( `import ${taroImportDefaultName} from '${PACKAGES['@tarojs/taro-rn']}'`, babylonConfig )() node.body.unshift(importTaro) } if (isEntryFile) { // 注入 import page from 'XXX' pages.forEach(item => { const pagePath = item.startsWith('/') ? item : `/${item}` const screenName = _.camelCase(pagePath.split('/'), {pascalCase: true}) const importScreen = template( `import ${screenName} from '.${pagePath}'`, babylonConfig )() node.body.unshift(importScreen) }) iconPaths.forEach(item => { const iconPath = item.startsWith('/') ? item : `/${item}` const iconName = _.camelCase(iconPath.split('/')) const importIcon = template( `import ${iconName} from '.${iconPath}'`, babylonConfig )() node.body.unshift(importIcon) }) // Taro.initRouter 生成 RootStack const routerPages = pages .map(item => { const pagePath = item.startsWith('/') ? item : `/${item}` const screenName = _.camelCase(pagePath.split('/'), {pascalCase: true}) return `['${item}',${screenName}]` }) .join(',') node.body.push(template( `const RootStack = ${routerImportDefaultName}.initRouter( [${routerPages}], ${taroImportDefaultName}, App.config )`, babylonConfig )()) // initNativeApi const initNativeApi = template( `${taroImportDefaultName}.initNativeApi(${taroImportDefaultName})`, babylonConfig )() node.body.push(initNativeApi) // import @tarojs/taro-router-rn if (isEntryFile) { const importTaroRouter = template( `import TaroRouter from '${PACKAGES['@tarojs/taro-router-rn']}'`, babylonConfig )() node.body.unshift(importTaroRouter) } // export default App if (!hasAppExportDefault) { const appExportDefault = template( `export default ${componentClassName}`, babylonConfig )() node.body.push(appExportDefault) } } } } }) try { const constantsReplaceList = Object.assign({}, Util.generateEnvList(projectConfig.env || {}), Util.generateConstantsList(projectConfig.defineConstants || {})) // TODO 使用 babel-plugin-transform-jsx-to-stylesheet 处理 JSX 里面样式的处理,删除无效的样式引入待优化 ast = babel.transformFromAst(ast, code, { plugins: [ require('babel-plugin-transform-jsx-to-stylesheet'), require('babel-plugin-transform-decorators-legacy').default, [require('babel-plugin-danger-remove-unused-import'), { ignore: ['@tarojs/taro', 'react', 'react-native', 'nervjs'] }], [require('babel-plugin-transform-define').default, constantsReplaceList] ] }).ast } catch (e) { throw e } return { code: unescape(generate(ast).code.replace(/\\u/g, '%u')), styleFiles } } function compileDepStyles (filePath, styleFiles) { if (isBuildingStyles[filePath] || styleFiles.length === 0) { return Promise.resolve({}) } isBuildingStyles[filePath] = true return Promise.all(styleFiles.map(async p => { // to css string const filePath = path.join(p) const fileExt = path.extname(filePath) Util.printLog(Util.pocessTypeEnum.COMPILE, _.camelCase(fileExt).toUpperCase(), filePath) return StyleProcess.loadStyle({filePath, pluginsConfig}) })).then(resList => { // postcss return Promise.all(resList.map(item => { return StyleProcess.postCSS({...item, projectConfig}) })) }).then(resList => { let styleObjectEntire = {} resList.forEach(item => { let styleObject = StyleProcess.getStyleObject(item.css) // validate styleObject StyleProcess.validateStyle({styleObject, filePath: item.filePath}) Object.assign(styleObjectEntire, styleObject) if (filePath !== entryFilePath) { // 非入口文件,合并全局样式 Object.assign(styleObjectEntire, _.get(styleDenpendencyTree, [entryFilePath, 'styleObjectEntire'], {})) } styleDenpendencyTree[filePath] = { styleFiles, styleObjectEntire } }) return JSON.stringify(styleObjectEntire, null, 2) }).then(css => { let tempFilePath = filePath.replace(sourceDir, tempPath) const basename = path.basename(tempFilePath, path.extname(tempFilePath)) tempFilePath = path.join(path.dirname(tempFilePath), `${basename}_styles.js`) StyleProcess.writeStyleFile({css, tempFilePath}) }).catch((e) => { throw new Error(e) }) } function initProjectFile (cb) { // generator app.json const appJson = new Vinyl({ path: 'app.json', contents: Buffer.from(JSON.stringify({ expo: { sdkVersion: '27.0.0' } }, null, 2)) }) // generator .${tempPath}/package.json TODO JSON.parse 这种写法可能会有隐患 const pkgTempObj = JSON.parse( ejs.render( fs.readFileSync(pkgPath, 'utf-8'), { projectName: projectConfig.projectName, version: getPkgVersion() } ).replace(/(\r\n|\n|\r|\s+)/gm, '') ) const dependencies = require(path.join(process.cwd(), 'package.json')).dependencies pkgTempObj.dependencies = Object.assign({}, pkgTempObj.dependencies, dependencies) const pkg = new Vinyl({ path: 'package.json', contents: Buffer.from(JSON.stringify(pkgTempObj, null, 2)) }) // Copy bin/crna-entry.js ? const crnaEntryPath = path.join(path.dirname(npmProcess.resolveNpmSync('@tarojs/rn-runner')), 'src/bin/crna-entry.js') const crnaEntryCode = fs.readFileSync(crnaEntryPath).toString() const crnaEntry = new Vinyl({ path: 'bin/crna-entry.js', contents: Buffer.from(crnaEntryCode) }) this.push(appJson) Util.printLog(Util.pocessTypeEnum.GENERATE, 'app.json', path.join(tempPath, 'app.json')) this.push(pkg) Util.printLog(Util.pocessTypeEnum.GENERATE, 'package.json', path.join(tempPath, 'package.json')) this.push(crnaEntry) Util.printLog(Util.pocessTypeEnum.COPY, 'crna-entry.js', path.join(tempPath, 'bin/crna-entry.js')) cb() } function buildTemp () { // fs.emptyDirSync(tempPath) fs.ensureDirSync(path.join(tempPath, 'bin')) return new Promise((resolve, reject) => { vfs.src(path.join(sourceDir, '**')) .pipe(through2.obj(async function (file, enc, cb) { if (file.isNull() || file.isStream()) { return cb(null, file) } const filePath = file.path let content = file.contents.toString() if (Util.REG_STYLE.test(filePath)) { return cb() } if (Util.REG_SCRIPTS.test(filePath)) { if (Util.REG_TYPESCRIPT.test(filePath)) { file.path = file.path.replace(/\.(tsx|ts)(\?.*)?$/, '.js') } Util.printLog(Util.pocessTypeEnum.COMPILE, _.camelCase(path.extname(filePath)).toUpperCase(), filePath) // parseJSCode let transformResult = parseJSCode(content, filePath) const jsCode = transformResult.code const styleFiles = transformResult.styleFiles // compileDepStyles await compileDepStyles(filePath, styleFiles) file.contents = Buffer.from(jsCode) } this.push(file) cb() }, initProjectFile)) .pipe(vfs.dest(path.join(tempPath))) .on('end', () => { if (!fs.existsSync(path.join(tempPath, 'node_modules'))) { console.log() console.log(chalk.yellow('开始安装依赖~')) process.chdir(tempPath) let command if (Util.shouldUseYarn()) { command = 'yarn' } else if (Util.shouldUseCnpm()) { command = 'cnpm install' } else { command = 'npm install' } shelljs.exec(command, {silent: false}) } resolve() }) }).catch(e => { throw e }) } async function buildDist ({watch}) { const entry = { app: path.join(tempPath, entryFileName) } const rnConfig = projectConfig.rn || {} rnConfig.env = projectConfig.env rnConfig.defineConstants = projectConfig.defineConstants rnConfig.designWidth = projectConfig.designWidth rnConfig.entry = entry if (watch) { rnConfig.isWatch = true } rnConfig.projectDir = tempPath const rnRunner = await npmProcess.getNpmPkg('@tarojs/rn-runner') rnRunner(rnConfig) } async function processFiles (filePath) { isBuildingStyles = {} // 清空 // 后期可以优化,不编译全部 let t0 = performance.now() await buildTemp() let t1 = performance.now() Util.printLog(Util.pocessTypeEnum.COMPILE, `编译完成,花费${Math.round(t1 - t0)} ms`) } function watchFiles () { const watcher = chokidar.watch(path.join(sourceDir), { ignored: /(^|[/\\])\../, persistent: true, ignoreInitial: true }) watcher .on('ready', () => { console.log() console.log(chalk.gray('初始化完毕,监听文件修改中...')) console.log() }) .on('add', filePath => { const relativePath = path.relative(appPath, filePath) Util.printLog(Util.pocessTypeEnum.CREATE, '添加文件', relativePath) processFiles(filePath) }) .on('change', filePath => { const relativePath = path.relative(appPath, filePath) Util.printLog(Util.pocessTypeEnum.MODIFY, '文件变动', relativePath) processFiles(filePath) }) .on('unlink', filePath => { const relativePath = path.relative(appPath, filePath) Util.printLog(Util.pocessTypeEnum.UNLINK, '删除文件', relativePath) processFiles(filePath) }) .on('error', error => console.log(`Watcher error: ${error}`)) } async function build ({watch}) { fs.ensureDirSync(tempPath) let t0 = performance.now() await buildTemp() let t1 = performance.now() Util.printLog(Util.pocessTypeEnum.COMPILE, `编译完成,花费${Math.round(t1 - t0)} ms`) await buildDist({watch}) if (watch) { watchFiles() } } module.exports = { build }