diff --git a/packages/uni-cli-shared/__tests__/transformImports.spec.ts b/packages/uni-cli-shared/__tests__/transformImports.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fabf9cd5e0ec0e1da04d42586fb0d17fe40162a --- /dev/null +++ b/packages/uni-cli-shared/__tests__/transformImports.spec.ts @@ -0,0 +1,194 @@ +import path from 'path' +import { ResolvedId } from 'rollup' +import { transformVueComponentImports } from '../src/mp/transformImports' +const root = '/usr/xxx/projects/test/src' +const importer = '/usr/xxx/projects/test/src/pages/index/index.vue' +async function resolve(id: string, importer?: string) { + return { + id: importer ? path.resolve(path.dirname(importer), id) : id, + } as ResolvedId +} + +function dynamicImport(name: string, source: string) { + return `const ${name} = ()=>import('${source}')` +} + +describe('transformVueComponentImports', () => { + test(`basic`, async () => { + const source = `import test1 from "${root}/components/test1.vue"; + const _sfc_main = { + components: { + test1 + } + }; + const __BINDING_COMPONENTS__ = '{"test1":{"name":"_component_test1","type":"unknown"}}'; + function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { + return {}; + } + import "${importer}?vue&type=style&index=0&lang.css"; + import _export_sfc from "plugin-vue:export-helper"; + export default /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render]]); +` + const { code, usingComponents } = await transformVueComponentImports( + source, + importer, + { + root, + resolve, + dynamicImport, + } + ) + expect(code).toContain( + `const test1 = ()=>import('${root}/components/test1.vue')` + ) + expect(usingComponents).toMatchObject({ test1: '/components/test1' }) + }) + + test(`easycom`, async () => { + const source = `import test1 from "../../components/test1.vue"; + import MyComponentName from "../../components/test1.vue"; + const _sfc_main = { + components: { + test1, + MyComponentName + } + }; + const __BINDING_COMPONENTS__ = '{"test":{"name":"_easycom_test","type":"unknown"},"test1":{"name":"_component_test1","type":"unknown"},"MyComponentName":{"name":"_component_MyComponentName","type":"unknown"},"my-component-name":{"name":"_component_my_component_name","type":"unknown"}}'; + import _easycom_test from "${root}/components/test/test.vue"; + if (!Math) { + Math.max.call(Max, _easycom_test); + } + function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { + return {}; + } + import "${root}/pages/index/index.vue?vue&type=style&index=0&lang.css"; + import _export_sfc from "plugin-vue:export-helper"; + export default /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render]]); +` + const { code, usingComponents } = await transformVueComponentImports( + source, + importer, + { + root, + resolve, + dynamicImport, + } + ) + expect(code).toContain( + `const _easycom_test = ()=>import('${root}/components/test/test.vue')` + ) + expect(usingComponents).toMatchObject({ + test: '/components/test/test', + test1: '/components/test1', + 'my-component-name': '/components/test1', + }) + }) + + test(`PascalCase`, async () => { + const source = `import test1 from "../../components/test1.vue"; + import MyComponentName from "../../components/test1.vue"; + const _sfc_main = { + components: { + test1, + MyComponentName + } + }; + const __BINDING_COMPONENTS__ = '{"test1":{"name":"_component_test1","type":"unknown"},"MyComponentName":{"name":"_component_MyComponentName","type":"unknown"},"my-component-name":{"name":"_component_my_component_name","type":"unknown"}}'; + function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { + return {}; + } + import "${root}/pages/index/index.vue?vue&type=style&index=0&lang.css"; + import _export_sfc from "plugin-vue:export-helper"; + export default /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render]]); +` + const { code, usingComponents } = await transformVueComponentImports( + source, + importer, + { + root, + resolve, + dynamicImport, + } + ) + expect(code).toContain( + `const MyComponentName = ()=>import('${root}/components/test1.vue')` + ) + expect(usingComponents).toMatchObject({ + test1: '/components/test1', + 'my-component-name': '/components/test1', + }) + }) + + test(`setup`, async () => { + const source = `import { defineComponent as _defineComponent } from "vue"; + const __BINDING_COMPONENTS__ = '{"test1":{"name":"test1","type":"setup"},"MyComponentName":{"name":"MyComponentName","type":"setup"},"my-component-name":{"name":"MyComponentName","type":"setup"}}'; + if (!Math) { + Math.max.call(Max, test1, MyComponentName, MyComponentName); + } + import test1 from "../../components/test1.vue"; + import MyComponentName from "../../components/test1.vue"; + const _sfc_main = /* @__PURE__ */ _defineComponent({ + setup(__props) { + return (_ctx, _cache) => { + return {}; + }; + } + }); + import "${root}/pages/index/index.vue?vue&type=style&index=0&lang.css"; + export default _sfc_main; +` + const { code, usingComponents } = await transformVueComponentImports( + source, + importer, + { + root, + resolve, + dynamicImport, + } + ) + expect(code).toContain( + `const MyComponentName = ()=>import('${root}/components/test1.vue')` + ) + expect(usingComponents).toMatchObject({ + test1: '/components/test1', + 'my-component-name': '/components/test1', + }) + }) + test(`setup with easycom`, async () => { + const source = `import { defineComponent as _defineComponent } from "vue"; + const __BINDING_COMPONENTS__ = '{"test":{"name":"_easycom_test","type":"unknown"},"test1":{"name":"test1","type":"setup"},"MyComponentName":{"name":"MyComponentName","type":"setup"},"my-component-name":{"name":"MyComponentName","type":"setup"}}'; + import _easycom_test from "${root}/components/test/test.vue"; + if (!Math) { + Math.max.call(Max, _easycom_test, test1, MyComponentName, MyComponentName); + } + import test1 from "../../components/test1.vue"; + import MyComponentName from "../../components/test1.vue"; + const _sfc_main = /* @__PURE__ */ _defineComponent({ + setup(__props) { + return (_ctx, _cache) => { + return {}; + }; + } + }); + import "${root}/pages/index/index.vue?vue&type=style&index=0&lang.css"; + export default _sfc_main; +` + const { code, usingComponents } = await transformVueComponentImports( + source, + importer, + { + root, + resolve, + dynamicImport, + } + ) + expect(code).toContain( + `const _easycom_test = ()=>import('${root}/components/test/test.vue')` + ) + expect(usingComponents).toMatchObject({ + test: '/components/test/test', + test1: '/components/test1', + 'my-component-name': '/components/test1', + }) + }) +}) diff --git a/packages/uni-cli-shared/src/constants.ts b/packages/uni-cli-shared/src/constants.ts index a7a41c64a0a9018b570cd90e0dfb5eb12c7bbb3d..d4cfd9d8c8e5a4d6d9ed1435dbfad867d08befad 100644 --- a/packages/uni-cli-shared/src/constants.ts +++ b/packages/uni-cli-shared/src/constants.ts @@ -4,6 +4,7 @@ export const EXTNAME_VUE = ['.vue', '.nvue'] export const EXTNAME_VUE_RE = /\.(vue|nvue)$/ export const EXTNAME_JS_RE = /\.[jt]sx?$/ +export const BINDING_COMPONENTS = '__BINDING_COMPONENTS__' // APP 平台解析页面后缀的优先级 export const PAGE_EXTNAME_APP = ['.nvue', '.vue', '.tsx', '.jsx', '.js'] // 其他平台解析页面后缀的优先级 diff --git a/packages/uni-cli-shared/src/json/app/pages/nvue.ts b/packages/uni-cli-shared/src/json/app/pages/nvue.ts index f61a5fa831a0b1a816139c84c4b94c79e2652da5..3e1f1c2e640aae96934538a925ac0e4dbf4c6593 100644 --- a/packages/uni-cli-shared/src/json/app/pages/nvue.ts +++ b/packages/uni-cli-shared/src/json/app/pages/nvue.ts @@ -17,7 +17,7 @@ export function initWebpackNVueEntry(pages: UniApp.PagesJsonPageOptions[]) { if (!path) { return } - const subNVuePath = removeExt(path.split('?')[0]) + const subNVuePath = removeExt(normalizePath(path.split('?')[0])) process.UNI_NVUE_ENTRY[subNVuePath] = genWebpackBase64Code( genNVueEntryCode(subNVuePath) ) diff --git a/packages/uni-cli-shared/src/json/mp/index.ts b/packages/uni-cli-shared/src/json/mp/index.ts index 8141742ed1b7777b468572abe44d0ce5ef0af5c3..a73135cb7f48bb2f7c6cda337f2f920095bfba2d 100644 --- a/packages/uni-cli-shared/src/json/mp/index.ts +++ b/packages/uni-cli-shared/src/json/mp/index.ts @@ -1,3 +1,4 @@ +export * from './jsonFile' export { AppJson } from './types' export { parseMiniProgramPagesJson } from './pages' export { parseMiniProgramProjectJson } from './project' diff --git a/packages/uni-cli-shared/src/json/mp/jsonFile.ts b/packages/uni-cli-shared/src/json/mp/jsonFile.ts index 11712f708d5ae0f6420d110794a1d084ea7f6c73..ce881a0ac1aa20a216b46c1295b50fccff3fb9f7 100644 --- a/packages/uni-cli-shared/src/json/mp/jsonFile.ts +++ b/packages/uni-cli-shared/src/json/mp/jsonFile.ts @@ -1,17 +1,63 @@ +import { extend } from '@vue/shared' import { ComponentJson, PageWindowOptions, UsingComponents } from './types' +import { normalizeNodeModules } from '../../utils' -export const jsonPagesCache = new Map() -export const jsonComponentsCache = new Map() -export const jsonUsingComponentsCache = new Map() +let appJsonCache: Record = {} +const jsonFilesCache = new Map() +const jsonPagesCache = new Map() +const jsonComponentsCache = new Map() +const jsonUsingComponentsCache = new Map() -export function addPageJson(filename: string, json: PageWindowOptions) { +export function normalizeJsonFilename(filename: string) { + return normalizeNodeModules(filename) +} + +export function findChangedJsonFiles() { + const changedJsonFiles = new Map() + function findChangedFile(name: string, json: Record) { + const newJson = extend({}, json) + if (!newJson.usingComponents) { + newJson.usingComponents = {} + } + extend(newJson.usingComponents, jsonUsingComponentsCache.get(name)) + const jsonStr = JSON.stringify(newJson, null, 2) + if (jsonFilesCache.get(name) !== jsonStr) { + changedJsonFiles.set(name, jsonStr) + jsonFilesCache.set(name, jsonStr) + } + } + function findChangedFiles(jsonsCache: Map) { + for (const name of jsonsCache.keys()) { + findChangedFile(name, jsonsCache.get(name)) + } + } + findChangedFile('app', appJsonCache) + findChangedFiles(jsonPagesCache) + findChangedFiles(jsonComponentsCache) + return changedJsonFiles +} + +export function addMiniProgramAppJson(appJson: Record) { + appJsonCache = appJson +} + +export function addMiniProgramPageJson( + filename: string, + json: PageWindowOptions +) { jsonPagesCache.set(filename, json) } -export function addComponentJson(filename: string, json: ComponentJson) { +export function addMiniProgramComponentJson( + filename: string, + json: ComponentJson +) { jsonComponentsCache.set(filename, json) } -export function addUsingComponents(filename: string, json: UsingComponents) { +export function addMiniProgramUsingComponents( + filename: string, + json: UsingComponents +) { jsonUsingComponentsCache.set(filename, json) } diff --git a/packages/uni-cli-shared/src/mp/index.ts b/packages/uni-cli-shared/src/mp/index.ts index a86ec02958dab750ee291f34e2d21db2d149d414..288b072ae4a865b1b51606b90e2eee57b1df2719 100644 --- a/packages/uni-cli-shared/src/mp/index.ts +++ b/packages/uni-cli-shared/src/mp/index.ts @@ -1,2 +1,2 @@ export * from './event' -export { findVueComponentImports } from './imports' +export { transformVueComponentImports } from './transformImports' diff --git a/packages/uni-cli-shared/src/mp/transformImports.ts b/packages/uni-cli-shared/src/mp/transformImports.ts new file mode 100644 index 0000000000000000000000000000000000000000..42ced9a5ba8185822bd6685f7349b1aed6b4627a --- /dev/null +++ b/packages/uni-cli-shared/src/mp/transformImports.ts @@ -0,0 +1,196 @@ +import { parse, ParserPlugin } from '@babel/parser' +import { + ImportDeclaration, + isIdentifier, + isImportDeclaration, + isObjectExpression, + isObjectProperty, + isStringLiteral, + isVariableDeclaration, + ObjectProperty, + Program, + Statement, + StringLiteral, +} from '@babel/types' +import { camelize, capitalize, hyphenate } from '@vue/shared' +import { walk } from 'estree-walker' +import MagicString from 'magic-string' +import { PluginContext } from 'rollup' +import { BINDING_COMPONENTS } from '../constants' +import { normalizeMiniProgramFilename, removeExt } from '../utils' + +interface TransformVueComponentImportsOptions { + root: string + resolve: PluginContext['resolve'] + dynamicImport: (name: string, source: string) => string + babelParserPlugins?: ParserPlugin[] +} +export async function transformVueComponentImports( + code: string, + importer: string, + { + root, + resolve, + dynamicImport, + babelParserPlugins, + }: TransformVueComponentImportsOptions +): Promise<{ + code: string + usingComponents: Record +}> { + if (!code.includes(BINDING_COMPONENTS)) { + return { code, usingComponents: {} } + } + const s = new MagicString(code) + const scriptAst = parse(code, { + plugins: [...(babelParserPlugins || [])], + sourceType: 'module', + }).program + + const imports = findVueComponentImports( + scriptAst.body, + parseComponents(scriptAst, findBindingComponents(scriptAst.body)) + ) + const usingComponents: Record = {} + for (let i = 0; i < imports.length; i++) { + const { + tag, + import: { + start, + end, + specifiers: [specifier], + source, + }, + } = imports[i] + const resolveId = await resolve(source.value, importer) + if (resolveId) { + s.overwrite( + start!, + end!, + dynamicImport(specifier.local.name, resolveId.id) + ';' + ) + const componentName = hyphenate(tag) + if (!usingComponents[componentName]) { + usingComponents[componentName] = + '/' + removeExt(normalizeMiniProgramFilename(resolveId.id, root)) + } + } + } + return { code: s.toString(), usingComponents } +} + +type BindingComponents = Record< + string, + { tag: string; type: 'unknown' | 'setup' | 'self' } +> +/** + * 解析编译器生成的 bindingComponents + * @param ast + * @returns + */ +function findBindingComponents(ast: Statement[]): BindingComponents { + for (const node of ast) { + if (!isVariableDeclaration(node)) { + continue + } + const declarator = node.declarations[0] + if ( + isIdentifier(declarator.id) && + declarator.id.name === BINDING_COMPONENTS + ) { + const bindingComponents = JSON.parse( + (declarator.init as StringLiteral).value + ) as Record + return Object.keys(bindingComponents).reduce( + (bindings, tag) => { + const binding = bindingComponents[tag] + bindings[binding.name] = { + tag, + type: binding.type, + } + return bindings + }, + {} + ) + } + } + return {} +} +/** + * 从 components 中查找定义的组件,修改 bindingComponents + * @param ast + * @param bindingComponents + */ +function parseComponents(ast: Program, bindingComponents: BindingComponents) { + ;(walk as any)(ast, { + enter(child: Node) { + if (!isObjectExpression(child)) { + return + } + const componentsProp = child.properties.find( + (prop) => + isObjectProperty(prop) && + isIdentifier(prop.key) && + prop.key.name === 'components' + ) as ObjectProperty + if (!componentsProp) { + return + } + const componentsExpr = componentsProp.value + if (!isObjectExpression(componentsExpr)) { + return + } + componentsExpr.properties.forEach((prop) => { + if (!isObjectProperty(prop)) { + return + } + if (!isIdentifier(prop.key) && !isStringLiteral(prop.key)) { + return + } + if (!isIdentifier(prop.value)) { + return + } + const tag = isIdentifier(prop.key) ? prop.key.name : prop.key.value + const name = findBindingComponent(tag, bindingComponents) + if (name) { + bindingComponents[prop.value.name] = bindingComponents[name] + } + }) + }, + }) + return bindingComponents +} + +function findBindingComponent( + tag: string, + bindingComponents: BindingComponents +) { + return Object.keys(bindingComponents).find((name) => { + const componentTag = bindingComponents[name].tag + const camelName = camelize(componentTag) + const PascalName = capitalize(camelName) + return tag === componentTag || tag === camelName || tag === PascalName + }) +} + +function findVueComponentImports( + ast: Statement[], + bindingComponents: BindingComponents +) { + const imports: { tag: string; import: ImportDeclaration }[] = [] + for (let i = 0; i < ast.length; i++) { + const node = ast[i] + if (!isImportDeclaration(node)) { + continue + } + if (node.specifiers.length !== 1) { + continue + } + const { name } = node.specifiers[0].local + if (!bindingComponents[name]) { + continue + } + imports.push({ tag: bindingComponents[name].tag, import: node }) + } + return imports +} diff --git a/packages/uni-cli-shared/src/utils.ts b/packages/uni-cli-shared/src/utils.ts index aa562efe7ffc4d2ecbdddb603ce9eddf79c8d8c4..5f0dbd1afed391fe1acf06033e851bed3aeb5392 100644 --- a/packages/uni-cli-shared/src/utils.ts +++ b/packages/uni-cli-shared/src/utils.ts @@ -6,6 +6,7 @@ import { once } from '@dcloudio/uni-shared' export { default as hash } from 'hash-sum' import { PAGE_EXTNAME, PAGE_EXTNAME_APP } from './constants' +import { SFCTemplateCompileOptions } from '@vue/compiler-sfc' export const isWindows = os.platform() === 'win32' export function normalizePath(id: string): string { return isWindows ? id.replace(/\\/g, '/') : id @@ -42,12 +43,8 @@ export function normalizePagePath(pagePath: string, platform: UniApp.PLATFORM) { console.error(`${pagePath} not found`) } -export function removeExt(str: string, ext?: string) { - if (ext) { - const reg = new RegExp(ext.replace(/\./, '\\.') + '$') - return normalizePath(str.replace(reg, '')) - } - return normalizePath(str.replace(/\.\w+$/g, '')) +export function removeExt(str: string) { + return str.split('?')[0].replace(/\.\w+$/g, '') } const NODE_MODULES_REGEX = /(\.\.\/)?node_modules/g @@ -61,3 +58,36 @@ export function normalizeNodeModules(str: string) { } return str } + +export function normalizeMiniProgramFilename( + filename: string, + inputDir?: string +) { + if (!inputDir) { + return normalizeNodeModules(filename) + } + return normalizeNodeModules(path.relative(inputDir, filename)) +} + +export function createUniVueTransformAssetUrls( + base: string +): SFCTemplateCompileOptions['transformAssetUrls'] { + return { + base, + tags: { + audio: ['src'], + video: ['src', 'poster'], + img: ['src'], + image: ['src'], + 'cover-image': ['src'], + // h5 + 'v-uni-audio': ['src'], + 'v-uni-video': ['src', 'poster'], + 'v-uni-image': ['src'], + 'v-uni-cover-image': ['src'], + // nvue + 'u-image': ['src'], + 'u-video': ['src', 'poster'], + }, + } +} diff --git a/packages/uni-mp-compiler/__tests__/transformElement.spec.ts b/packages/uni-mp-compiler/__tests__/transformElement.spec.ts index e0072a968e3b302094e30c57c44b3cdd65bd7be7..2bce9b15ceb22333c5bf0b4e4536cc44dba26522 100644 --- a/packages/uni-mp-compiler/__tests__/transformElement.spec.ts +++ b/packages/uni-mp-compiler/__tests__/transformElement.spec.ts @@ -1,8 +1,11 @@ import { BindingTypes, ElementNode, RootNode } from '@vue/compiler-core' +import { compileTemplate, TemplateCompiler } from '@vue/compiler-sfc' import { compile } from '../src' +import * as MPCompiler from '../src' import { MPErrorCodes } from '../src/errors' import { CodegenRootNode, CompilerOptions } from '../src/options' import { BindingComponentTypes } from '../src/transform' +import { createUniVueTransformAssetUrls } from '@dcloudio/uni-cli-shared' function parseWithElementTransform( template: string, @@ -24,12 +27,33 @@ function parseWithElementTransform( } describe('compiler: element transform', () => { + test(`transformAssetUrls`, () => { + const result = compileTemplate({ + source: ``, + filename: 'foo.vue', + id: 'foo', + compiler: MPCompiler as unknown as TemplateCompiler, + compilerOptions: { + mode: 'module', + }, + transformAssetUrls: { + includeAbsolute: true, + ...(createUniVueTransformAssetUrls('/') as Record), + }, + }) + expect(result.code).toBe(`import _imports_0 from '/static/logo.png' + +export function render(_ctx, _cache) { + return { a: _imports_0 } +}`) + }) + test('import + resolve component', () => { const { root } = parseWithElementTransform(``) expect((root as CodegenRootNode).bindingComponents).toEqual({ Foo: { name: '_component_Foo', type: BindingComponentTypes.UNKNOWN }, }) - // expect(code).toContain(`if (!Math) {Math.max.call(Max, _component_Foo)}`) + // expect(code).toContain(`if (!Math) { Math.max.call(Max, _component_Foo) }`) }) test('import + resolve component multi', () => { @@ -48,7 +72,9 @@ describe('compiler: element transform', () => { Example: { name: '$setup["Example"]', type: BindingComponentTypes.SETUP }, Test: { name: '_component_Test', type: BindingComponentTypes.SELF }, }) - expect(code).toContain(`if (!Math) {Math.max.call(Max, $setup["Example"])}`) + expect(code).toContain( + `if (!Math) { Math.max.call(Max, $setup["Example"]) }` + ) }) test('resolve implcitly self-referencing component', () => { @@ -69,7 +95,9 @@ describe('compiler: element transform', () => { expect((root as CodegenRootNode).bindingComponents).toEqual({ Example: { name: '$setup["Example"]', type: BindingComponentTypes.SETUP }, }) - expect(code).toContain(`if (!Math) {Math.max.call(Max, $setup["Example"])}`) + expect(code).toContain( + `if (!Math) { Math.max.call(Max, $setup["Example"]) }` + ) }) test('resolve component from setup bindings (inline)', () => { @@ -83,7 +111,7 @@ describe('compiler: element transform', () => { Example: { name: '_unref(Example)', type: BindingComponentTypes.SETUP }, }) expect(preamble).toContain( - `if (!Math) {Math.max.call(Max, _unref(Example))}` + `if (!Math) { Math.max.call(Max, _unref(Example)) }` ) }) @@ -97,7 +125,7 @@ describe('compiler: element transform', () => { expect((root as CodegenRootNode).bindingComponents).toEqual({ Example: { name: 'Example', type: BindingComponentTypes.SETUP }, }) - expect(preamble).toContain(`if (!Math) {Math.max.call(Max, Example)}`) + expect(preamble).toContain(`if (!Math) { Math.max.call(Max, Example) }`) }) test('resolve namespaced component from setup bindings', () => { diff --git a/packages/uni-mp-compiler/__tests__/vShow.spec.ts b/packages/uni-mp-compiler/__tests__/vShow.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a2017ea66a66170908dbfc3dc3a28619838b018 --- /dev/null +++ b/packages/uni-mp-compiler/__tests__/vShow.spec.ts @@ -0,0 +1,13 @@ +import { assert } from './testUtils' + +describe('compiler: transform v-show', () => { + test('basic', () => { + assert( + ``, + `