diff --git a/.gitignore b/.gitignore index 1ed9582ece..daff07cbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn.lock pnpm-lock.yaml lerna-debug.log packages/design-core/bundle-deps +designer-demo/bundle-deps # local env files .env.local diff --git a/designer-demo/env/.env.alpha b/designer-demo/env/.env.alpha index d1784a37aa..ef7fa0443c 100644 --- a/designer-demo/env/.env.alpha +++ b/designer-demo/env/.env.alpha @@ -1,12 +1,9 @@ # alpha mode, used by the "build:alpha" script NODE_ENV=production -# VITE_CDN_DOMAIN=https://unpkg.com VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false # VITE_ORIGIN= # 错误监控上报 url diff --git a/designer-demo/env/.env.development b/designer-demo/env/.env.development index 2642f040e7..25a3fd1e9d 100644 --- a/designer-demo/env/.env.development +++ b/designer-demo/env/.env.development @@ -1,11 +1,8 @@ # development mode, used by the "vite" command NODE_ENV=development -# VITE_CDN_DOMAIN=https://unpkg.com VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false # request data via alpha service # VITE_ORIGIN= diff --git a/designer-demo/env/.env.localCDN.example b/designer-demo/env/.env.localCDN.example new file mode 100644 index 0000000000..802e49e30c --- /dev/null +++ b/designer-demo/env/.env.localCDN.example @@ -0,0 +1,10 @@ +# CDN 本地化配置示例 + +# 将画布、页面预览需要的 vue、vue-i18n 等等依赖复制到构建产物中 +VITE_LOCAL_IMPORT_MAPS=true + +# 将本地物料 bundle.json 的 script 和 css 复制到构建产物中 +VITE_LOCAL_BUNDLE_DEPS=true + +# 将 VITE_LOCAL_BUNDLE_DEPS 复制到构建产物中的目录名称,默认为 local-cdn-static +VITE_LOCAL_IMPORT_PATH=local-cdn-static diff --git a/designer-demo/env/.env.production b/designer-demo/env/.env.production index 4d9d9f2f36..73e6739cf0 100644 --- a/designer-demo/env/.env.production +++ b/designer-demo/env/.env.production @@ -5,6 +5,4 @@ NODE_ENV=production VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false #VITE_ORIGIN= diff --git a/designer-demo/package.json b/designer-demo/package.json index b201f06da9..7ead5cc9fa 100644 --- a/designer-demo/package.json +++ b/designer-demo/package.json @@ -5,8 +5,10 @@ "type": "module", "scripts": { "dev": "cross-env vite", - "build:alpha": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode alpha", - "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build" + "build:alpha": "cross-env NODE_OPTIONS=--max-old-space-size=10240 vite build --mode alpha", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=10240 vite build", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@opentiny/tiny-engine": "workspace:^", @@ -25,6 +27,7 @@ "@opentiny/tiny-engine-vite-config": "workspace:^", "@vitejs/plugin-vue": "^5.1.2", "cross-env": "^7.0.3", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "3.0.9" } } diff --git a/designer-demo/public/mock/bundle.json b/designer-demo/public/mock/bundle.json index f6ae8f7337..4ce436270e 100644 --- a/designer-demo/public/mock/bundle.json +++ b/designer-demo/public/mock/bundle.json @@ -10166,7 +10166,8 @@ "devMode": "proCode", "npm": { "package": "@opentiny/vue", - "exportName": "TinyGridColumn" + "exportName": "TinyGridColumn", + "destructuring": true }, "group": "component", "priority": 2, diff --git a/designer-demo/tests/localCdnBasic.test.js b/designer-demo/tests/localCdnBasic.test.js new file mode 100644 index 0000000000..32e687ddd2 --- /dev/null +++ b/designer-demo/tests/localCdnBasic.test.js @@ -0,0 +1,74 @@ +/** + * localCDN 功能测试 + * 这个测试文件用于验证 localCDN 本地化功能是否正常工作 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execSync } from 'node:child_process' +import { ensureEnvVarEnabled, backupEnvFile, restoreEnvFile } from './utils/envHelpers.js' + +// 获取当前文件目录 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, '..') +const distDir = path.resolve(projectRoot, 'dist') +const localCdnDir = path.resolve(distDir, 'local-cdn-static') +const envAlphaPath = path.resolve(projectRoot, 'env', '.env.alpha') + +describe('localCDN 功能测试', () => { + beforeAll(() => { + // 备份环境变量文件 + backupEnvFile(envAlphaPath) + + // 确保环境变量正确设置 + let envContent = fs.readFileSync(envAlphaPath, 'utf-8') + + // 确保关键环境变量已启用 + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_IMPORT_MAPS') + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_BUNDLE_DEPS') + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_IMPORT_PATH', 'local-cdn-static') + + // 写回更新后的环境变量 + fs.writeFileSync(envAlphaPath, envContent) + + // 执行构建 + execSync('pnpm run build:alpha', { + cwd: projectRoot, + stdio: 'inherit' + }) + }) + + // 测试结束后恢复原始环境变量 + afterAll(() => { + restoreEnvFile(envAlphaPath) + }) + + it('应该在构建后生成 local-cdn-static 目录', () => { + expect(fs.existsSync(localCdnDir)).toBe(true) + }) + + it('应该正确复制 @vue/devtools-api 依赖', () => { + // 寻找 @vue/devtools-api 文件夹 + const devToolsDirs = fs.readdirSync(path.resolve(localCdnDir, '@vue')) + .find(dir => dir.startsWith('devtools-api@')) + + expect(devToolsDirs).toBeDefined() + + // 检查 index.js 是否存在 + const indexJsExists = fs.existsSync(path.resolve(localCdnDir, '@vue', devToolsDirs, 'lib/esm/index.js')) + + expect(indexJsExists).toBe(true) + }) + + it('应该正确复制 vue 依赖', () => { + // 寻找 vue 文件夹 + const runtimeDirs = fs.readdirSync(localCdnDir).find(dir => dir.startsWith('vue@')) + + expect(runtimeDirs).toBeDefined() + + const vueProdDist = path.resolve(localCdnDir, runtimeDirs, 'dist/vue.runtime.esm-browser.js') + + expect(fs.existsSync(vueProdDist)).toBe(true) + }) +}) \ No newline at end of file diff --git a/designer-demo/tests/localCdnBundleDeps.test.js b/designer-demo/tests/localCdnBundleDeps.test.js new file mode 100644 index 0000000000..0afdb11de9 --- /dev/null +++ b/designer-demo/tests/localCdnBundleDeps.test.js @@ -0,0 +1,140 @@ +/** + * localCDN bundle依赖本地化测试 + * 测试物料需要的CDN资源本地化功能 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execSync } from 'node:child_process' +import { ensureEnvVarEnabled, updateCdnDomain, backupEnvFile, restoreEnvFile } from './utils/envHelpers.js' + +// 获取当前文件目录 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, '..') +const publicDir = path.resolve(projectRoot, 'public') +const bundleJsonDir = path.resolve(publicDir, 'mock') +const envAlphaPath = path.resolve(projectRoot, 'env', '.env.alpha') +const distDir = path.resolve(projectRoot, 'dist') +const bundleJsonPath = path.resolve(bundleJsonDir, 'bundle.json') + +// 准备测试用的 bundle.json 文件 +const testBundleJson = { + data: { + materials: { + packages: [ + { + "name": "TinyVue组件库", + "package": "@opentiny/vue", + "version": "3.20.0", + "script": "https://unpkg.com/@opentiny/vue-runtime@3.20/dist3/tiny-vue-pc.mjs", + "css": "https://unpkg.com/@opentiny/vue-theme@3.20/index.css" + }, + { + "name": "element-plus组件库", + "package": "element-plus", + "version": "2.4.2", + "script": "https://registry.npmmirror.com/element-plus/2.4.2/files/dist/index.full.mjs", + "css": "https://registry.npmmirror.com/element-plus/2.4.2/files/dist/index.css" + } + ] + } + } +} + +describe('localCDN bundle依赖本地化测试', () => { + let originalBundleJson = null + + beforeAll(() => { + // 备份环境变量 + backupEnvFile(envAlphaPath) + + // 确保目录存在 + if (!fs.existsSync(bundleJsonDir)) { + fs.mkdirSync(bundleJsonDir, { recursive: true }) + } + + // 备份原始的 bundle.json 文件(如果存在) + if (fs.existsSync(bundleJsonPath)) { + originalBundleJson = fs.readFileSync(bundleJsonPath, 'utf-8') + } + + // 创建测试用的 bundle.json + fs.writeFileSync(bundleJsonPath, JSON.stringify(testBundleJson, null, 2)) + + // 设置环境变量 + let envContent = fs.readFileSync(envAlphaPath, 'utf-8') + + // 更新CDN域名 + envContent = updateCdnDomain(envContent, 'https://unpkg.com') + + // 确保启用了 bundle 依赖本地化 + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_BUNDLE_DEPS') + + fs.writeFileSync(envAlphaPath, envContent) + + // 执行构建 + execSync('pnpm run build:alpha', { + cwd: projectRoot, + stdio: 'inherit' + }) + }) + + // 测试完成后清理测试文件并恢复环境变量 + afterAll(() => { + // 恢复原始的 bundle.json 文件 + if (originalBundleJson) { + fs.writeFileSync(bundleJsonPath, originalBundleJson) + } else if (fs.existsSync(bundleJsonPath)) { + // 如果原始文件不存在,则删除测试创建的文件 + fs.unlinkSync(bundleJsonPath) + } + + // 恢复环境变量 + restoreEnvFile(envAlphaPath) + }) + + it('应该将物料CDN依赖本地化', () => { + const distBundleJsonPath = path.resolve(distDir, 'mock', 'bundle.json') + + // 检查构建后的 bundle.json 是否存在 + expect(fs.existsSync(distBundleJsonPath)).toBe(true) + + // 检查构建后的 bundle.json 中是否已将远程URL替换为本地路径 + const packages = JSON.parse(fs.readFileSync(distBundleJsonPath, 'utf-8')).data.materials.packages + + // 检查 vue 的路径是否已本地化 + expect(packages[0].script).not.toContain('https://unpkg.com') + expect(packages[0].script).toContain('./material-static/') + + // 检查 element-plus 的路径是否已本地化 + expect(packages[1].script).toContain('https://registry.npmmirror.com') + expect(packages[1].script).not.toContain('./material-static/') + expect(packages[1].css).toContain('https://registry.npmmirror.com') + expect(packages[1].css).not.toContain('./material-static/') + }) + + it('应该将物料依赖包复制到产物CDN目录', () => { + const localCdnDir = path.resolve(distDir, 'material-static/@opentiny') + + // 检查 vue 是否已复制 + const tinyVueDir = fs.readdirSync(localCdnDir) + .find(dir => dir.startsWith('vue-runtime@')) + const tinyVueThemeDir = fs.readdirSync(localCdnDir) + .find(dir => dir.startsWith('vue-theme@')) + expect(tinyVueDir).toBeDefined() + + // 检查 tiny-vue-pc.mjs 是否存在 + const tinyVueJsPath = path.resolve(localCdnDir, tinyVueDir, 'dist3', 'tiny-vue-pc.mjs') + const tinyVueCssPath = path.resolve(localCdnDir, tinyVueThemeDir, 'index.css') + + expect(fs.existsSync(tinyVueJsPath)).toBe(true) + expect(fs.existsSync(tinyVueCssPath)).toBe(true) + + // 检查 element-plus 是否已复制 + const elementDir = fs.readdirSync(localCdnDir) + .find(dir => dir.startsWith('element-plus@')) + + expect(elementDir).not.toBeDefined() + }) +}) \ No newline at end of file diff --git a/designer-demo/tests/localCdnCustomConfig.test.js b/designer-demo/tests/localCdnCustomConfig.test.js new file mode 100644 index 0000000000..d5229e053d --- /dev/null +++ b/designer-demo/tests/localCdnCustomConfig.test.js @@ -0,0 +1,159 @@ +/** + * localCDN 自定义配置测试 + * 测试文档中描述的自定义配置功能 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execSync } from 'node:child_process' +import { ensureEnvVarEnabled, backupEnvFile, restoreEnvFile } from './utils/envHelpers.js' + +// 获取当前文件目录 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, '..') +const viteConfigPath = path.resolve(projectRoot, 'vite.config.js') +const envAlphaPath = path.resolve(projectRoot, 'env', '.env.alpha') +const originalViteConfig = fs.readFileSync(viteConfigPath, 'utf-8') +const distDir = path.resolve(projectRoot, 'dist') + +/** + * 更新 registry.js 文件以支持自定义 importMap + */ +function updateRegistryFile() { + const registryPath = path.resolve(projectRoot, 'registry.js') + + if (!fs.existsSync(registryPath)) { + return + } + + // 备份原始文件 + fs.copyFileSync(registryPath, registryPath + '.bak') + + const registryContent = fs.readFileSync(registryPath, 'utf-8') + + // 向 config 对象添加 importMap + const updatedContent = registryContent.replace( + /config: {([^}]*)}/, + `config: {$1, + importMap: { + imports: { + 'vue': "\${VITE_CDN_DOMAIN}/vue\${versionDelimiter}3.4.21\${fileDelimiter}/dist/vue.runtime.esm-browser.js" + } + } + }` + ) + + fs.writeFileSync(registryPath, updatedContent) +} + +/** + * 恢复 registry.js 文件 + */ +function restoreRegistryFile() { + const registryPath = path.resolve(projectRoot, 'registry.js') + const backupPath = registryPath + '.bak' + + if (fs.existsSync(backupPath)) { + fs.copyFileSync(backupPath, registryPath) + fs.unlinkSync(backupPath) + } +} + +describe('localCDN 自定义配置测试', () => { + beforeAll(() => { + // 备份原始的 vite.config.js + fs.writeFileSync(viteConfigPath + '.bak', originalViteConfig) + + // 备份环境变量文件 + backupEnvFile(envAlphaPath) + + // 修改 vite.config.js 添加自定义配置 + const updatedViteConfig = originalViteConfig.replace( + 'const baseConfig = useTinyEngineBaseConfig({', + `const baseConfig = useTinyEngineBaseConfig({ + importMapLocalConfig: { + importMap: { + imports: { + 'vue': "\${VITE_CDN_DOMAIN}/vue\${versionDelimiter}3.4.21\${fileDelimiter}/dist/vue.runtime.esm-browser.prod.js" + } + }, + copy: { + 'vue': { + filePathInPackage: '/dist/' + } + } + },` + ) + + fs.writeFileSync(viteConfigPath, updatedViteConfig) + + // 确保环境变量设置 + let envContent = fs.readFileSync(envAlphaPath, 'utf-8') + + // 确保关键环境变量已启用 + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_IMPORT_MAPS') + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_BUNDLE_DEPS') + envContent = ensureEnvVarEnabled(envContent, 'VITE_LOCAL_IMPORT_PATH', 'local-cdn-static') + + // 写回更新后的环境变量 + fs.writeFileSync(envAlphaPath, envContent) + + // 修改 registry.js 以支持自定义 importMap + updateRegistryFile() + + // 执行构建 + execSync('pnpm run build:alpha', { + cwd: projectRoot, + stdio: 'inherit' + }) + }) + + // 测试完成后恢复原始配置 + afterAll(() => { + // 恢复 vite.config.js + if (fs.existsSync(viteConfigPath + '.bak')) { + fs.copyFileSync(viteConfigPath + '.bak', viteConfigPath) + fs.unlinkSync(viteConfigPath + '.bak') + } + + // 恢复环境变量 + restoreEnvFile(envAlphaPath) + + // 恢复 registry.js + restoreRegistryFile() + }) + + it('应该正确应用自定义 importMap 配置', () => { + const localCdnDir = path.resolve(distDir, 'local-cdn-static') + + // 检查 vue 是否被正确复制 + const vueDirs = fs.readdirSync(localCdnDir) + .filter(dir => dir.startsWith('vue@')) + .map(dir => path.resolve(localCdnDir, dir)) + + expect(vueDirs.length).toBeGreaterThan(0) + + // 检查 dist 目录是否存在 + const distExists = vueDirs.some(dir => { + return fs.existsSync(path.resolve(dir, 'dist')) + }) + + expect(distExists).toBe(true) + + // 检查 vue.global.prod.js 文件是否存在 + const vueJsExists = vueDirs.some(dir => { + return fs.existsSync(path.resolve(dir, 'dist', 'vue.runtime.esm-browser.js')) + }) + + expect(vueJsExists).toBe(true) + + // 检查 dist 目录下的文件数量是否大于1 (因为我们的复制配置是复制整个文件夹) + const distFileCount = vueDirs.some(dir => { + const distPath = path.resolve(dir, 'dist') + return fs.existsSync(distPath) && fs.readdirSync(distPath).length > 1 + }) + + expect(distFileCount).toBe(true) + }) +}) \ No newline at end of file diff --git a/designer-demo/tests/utils/envHelpers.js b/designer-demo/tests/utils/envHelpers.js new file mode 100644 index 0000000000..6d97f894eb --- /dev/null +++ b/designer-demo/tests/utils/envHelpers.js @@ -0,0 +1,69 @@ +/** + * 环境变量处理工具函数 + */ +import fs from 'node:fs' + +/** + * 更新环境变量,如果变量不存在或值为false则设置为true + * @param {string} content - 环境变量文件内容 + * @param {string} key - 环境变量名 + * @returns {string} - 更新后的内容 + */ +export function ensureEnvVarEnabled(content, key, value = 'true') { + // 检查是否包含该环境变量 + const regex = new RegExp(`${key}\\s*=\\s*(.*)`, 'm') + const match = content.match(regex) + + if (!match) { + // 变量不存在,添加 + return `${content}\n${key}=${value}` + } else if (match[1].trim() !== value) { + // 变量存在但值为跟 value 不相等,替换为提供的值 value + return content.replace(regex, `${key}=${value}`) + } + + // 变量已存在且不是false,保持不变 + return content +} + +/** + * 更新环境变量中的CDN域名 + * @param {string} content - 环境变量文件内容 + * @param {string} cdnDomain - CDN域名 + * @returns {string} - 更新后的内容 + */ +export function updateCdnDomain(content, cdnDomain) { + const regex = /VITE_CDN_DOMAIN\s*=\s*(.*)/m + const match = content.match(regex) + + if (!match) { + return `${content}\nVITE_CDN_DOMAIN=${cdnDomain}` + } else { + return content.replace(regex, `VITE_CDN_DOMAIN=${cdnDomain}`) + } +} + +/** + * 备份环境变量文件 + * @param {string} envFilePath - 环境变量文件路径 + * @returns {void} + */ +export function backupEnvFile(envFilePath) { + if (fs.existsSync(envFilePath)) { + const backupPath = envFilePath + '.bak' + fs.copyFileSync(envFilePath, backupPath) + } +} + +/** + * 恢复环境变量文件 + * @param {string} envFilePath - 环境变量文件路径 + * @returns {void} + */ +export function restoreEnvFile(envFilePath) { + const backupPath = envFilePath + '.bak' + if (fs.existsSync(backupPath)) { + fs.copyFileSync(backupPath, envFilePath) + fs.unlinkSync(backupPath) + } +} diff --git a/designer-demo/vitest.config.js b/designer-demo/vitest.config.js new file mode 100644 index 0000000000..be7417831a --- /dev/null +++ b/designer-demo/vitest.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: 1_000 * 60 * 10, // 10分钟超时,因为构建可能需要较长时间 + include: ['tests/**/*.test.js'], + hookTimeout: 1_000 * 60 * 10, // 10分钟超时,因为构建可能需要较长时间 + // 这里需要串行执行,否则构建会相互覆盖,无法测试 + fileParallelism: false + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + } +}) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index f9c9061ac1..032c7fbb53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ - [区块局域网发布方案(Node.js服务端)](./solutions/block-lan-release-solution.md) - [设计器中引入第三方组件库](./solutions/third-party-library-in-designer.md) - [物料同步方案](./solutions/material-sync-solution.md) + - [本地化CDN方案](./solutions/import-map-local.md) - 扩展能力介绍 - [新架构介绍](./extension-capabilities-overview/new-architecture.md) - [注册表](./extension-capabilities-overview/registry.md) diff --git a/docs/catalog.json b/docs/catalog.json index 3d8a3febc2..d68f52ac1a 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -78,7 +78,8 @@ { "title": "区块发布方案(Node.js服务端)", "name": "block-release-solution.md" }, { "title": "区块局域网发布方案(Node.js服务端)", "name": "block-lan-release-solution.md" }, { "title": "设计器中引入第三方组件库", "name": "third-party-library-in-designer.md" }, - { "title": "物料同步方案", "name": "material-sync-solution.md" } + { "title": "物料同步方案", "name": "material-sync-solution.md" }, + { "title": "本地化CDN方案", "name": "import-map-local.md" } ] }, { diff --git a/docs/solutions/import-map-local.md b/docs/solutions/import-map-local.md new file mode 100644 index 0000000000..bf14ed6388 --- /dev/null +++ b/docs/solutions/import-map-local.md @@ -0,0 +1,242 @@ +# CDN 依赖本地化方案 + +## 概述 + +TinyEngine 在画布和预览都使用了 import-map 的方式来加载依赖,例如 `vue`、`vue-i18n` 以及物料等,这些 import-map 默认会依赖于 npmmirror 的 CDN 服务。 +然而,在一些企业场景中,由于可用性和稳定性要求,无法依赖外部的 CDN服务。 + +当前可以采取的方案有: +- 搭建企业私有网络的 unpkg 服务。 +- 使用本文档介绍的CDN依赖本地化方案 + +CDN依赖本地化的核心思想是:在构建过程中,将 TinyEngine 所需的远程CDN资源替换为构建产物中的本地文件。其主要优点包括: + +1. 减少对外部CDN的依赖,提高应用的可靠性 +2. 支持离线环境或内网环境中对 import-map 所需资源的访问。 +3. 加快资源加载速度,提升应用性能。 + +## 使用方法 + +### 启用 CDN 依赖本地化 + +请按照以下步骤操作启用 CDN 依赖本地化功能: + +1. **修改环境变量** + +在 `.env.alpha`、`.env.production` 文件中添加以下配置 + +```bash +# 启用 CDN 依赖本地化功能 +VITE_LOCAL_IMPORT_MAPS=true + +# 启用物料的资源本地化功能。注意⚠️:这里需要您的物料package需要能够通过 npm 的方式进行下载,否则会失效。 +VITE_LOCAL_BUNDLE_DEPS=true + +# 定义本地化资源存放目录,默认为 local-cdn-static +VITE_LOCAL_IMPORT_PATH=local-cdn-static +``` + +2. 【可选】 在 `vite.config.js` 中传入自定义配置 + +可通过 importMapLocalConfig 选项配置导入映射规则和文件复制行为: + +```javascript +const baseConfig = useTinyEngineBaseConfig({ + importMapLocalConfig: { + importMap: { imports: { ... } }, + copy: { ... } + } + // ...otherConfig +}) +``` + +### 配置选项 + +CDN 本地化接受以下配置选项: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| importMapLocalConfig | Object | `{ importMap: { imports: {} }, copy: {} }` | 本地CDN配置对象 | +| importMapLocalConfig.importMap | Object | `{ imports: {} }` | 导入映射配置,定义需要本地化的CDN依赖 | +| importMapLocalConfig.copy | Object | `{}` | 自定义复制配置,可以覆盖特定包的默认配置 | + +#### 导入映射(importMap) 详细说明 + +`importMapLocalConfig.importMap` 是一个包含 `imports` 属性的对象,它定义了需要本地化的CDN依赖。在插件内部,它会与默认的导入映射配置合并。 + +importMap 的格式示例: +```json +{ + "imports": { + "vue": "${VITE_CDN_DOMAIN}/vue${versionDelimiter}3.2.37${fileDelimiter}/dist/vue.runtime.esm-browser.js", + "vue-router": "${VITE_CDN_DOMAIN}/vue-router${versionDelimiter}4.1.5${fileDelimiter}/dist/vue-router.global.js", + "pinia": "${VITE_CDN_DOMAIN}/pinia${versionDelimiter}2.0.23${fileDelimiter}/dist/pinia.iife.prod.js" + } +} +``` + +URL格式说明: +- `${VITE_CDN_DOMAIN}` - CDN域名占位符,将被替换为本地路径 +- `${versionDelimiter}` - 版本分隔符,用于分隔包名和版本 +- `${fileDelimiter}` - 文件路径分隔符,用于分隔版本和文件路径 + +例如: +unpkg 的 cdn 链接为:`https://unpkg.com/vue@3.5.13/dist/vue.runtime.esm-browser.js`。 +那么我们根据这个地址,可以拆分成以下几个部分: +- `https://unpkg.com` CDN 服务域名,即 `VITE_CDN_DOMAIN` +- `vue` 依赖的名称(packageName) +- `@` 包名与版本号的分隔符 `versionDelimiter` +- `3.5.13` 版本号 +- `/dist/vue.runtime.global.prod.js` 具体依赖的文件路径 + +如果是 npmmirror CDN 服务,则链接为:`https://registry.npmmirror.com/vue/3.5.13/files/dist/vue.runtime.esm-browser.js`。 +我们可以拆分成以下几个部分: +- `https://registry.npmmirror.com` CDN 服务域名。即 `VITE_CDN_DOMAIN` +- `vue` 依赖的名称 (packageName) +- `/` 包名与版本的分隔符 `versionDelimiter` +- `3.5.13` 版本号 +- `/files` 版本号与具体文件路径的分隔符,即 `${fileDelimiter}` +- `/dist/vue.runtime.global.prod.js` 具体依赖的文件路径 + +假如我们希望依赖 vue 的 3.5+ 版本,那么我们就可以传入约定的 importMap 配置: + +```json +{ + "imports": { + "vue": "${VITE_CDN_DOMAIN}/vue${versionDelimiter}^3.5${fileDelimiter}/dist/vue.runtime.esm-browser.js" + } +} +``` + +传入配置之后,插件将解析这些URL,提取包名、版本和文件路径,然后在构建时将它们替换为本地路径。 +即:`./local-cdn-static/vue@^3.5/dist/vue.runtime.esm-browser.js` + +**重要说明**:如果您在 Vite 配置中传递了 `importMapLocalConfig.importMap`,还需要在 registry 注册表的 config 中传入同样的配置,以确保应用在运行时能正确读取自定义的 importMap 配置。例如: + +```javascript +// 在注册表配置中 +{ + config: { + id: 'engine.config', + importMap: importMapLocalConfig.importMap, + // ... 其他配置 + } +} +``` + +这是因为画布和页面预览默认会从注册表 `getMergeMeta('engine.config')?.importMap` 中读取自定义的映射配置,如果获取失败,则会读取默认的映射。 + +#### 文件复制配置 copy 说明【可选】 + +`importMapLocalConfig.copy` 可自定义特定包的文件复制配置。 默认配置如下 + +```javascript +{ + '@opentiny/vue-theme': { + filePathInPackage: '/' + }, + '@opentiny/vue-renderless': { + filePathInPackage: '/' + }, + '@opentiny/vue-runtime': { + filePathInPackage: '/dist3/' + }, + '@vue/devtools-api': { + filePathInPackage: '/' + } +} +``` + +配置支持以下选项: +- `filePathInPackage`: 指定要复制的包内路径,如果想要复制整个包,则配置为 `'/'` +- `version`: (可选) 覆盖包的版本号(注意:只影响下载的版本号,不影响实际请求 URL 的版本号) + +例如,自定义配置: +```javascript +{ + 'vue': { + filePathInPackage: '/dist/' + }, + 'element-plus': { + filePathInPackage: '/dist/index.css', + version: '2.2.0' + } +} +``` + +### 处理bundle文件中的CDN链接 + +如果需要处理bundle文件中的CDN链接,可以在 `.env.xxx` 文件中配置 `VITE_LOCAL_BUNDLE_DEPS=true`: + +注意⚠️:物料文件 bundle.json 中 packages 数组中,只有前缀与 env 文件配置的 `VITE_CDN_DOMAIN` 一致,才会被复制打包并改写 bundle.json。 + +比如有如下物料配置和 .env 配置 + +1. **`bundle.json`**: +```json +{ + "packages": [ + { + "name": "TinyVue组件库", + "package": "@opentiny/vue", + "version": "3.20.0", + "destructuring": true, + "script": "https://npmmirror.com/@opentiny/vue-runtime/~3.20/files/dist3/tiny-vue-pc.mjs", + "css": "https://npmmirror.com/@opentiny/vue-theme/~3.20/files/index.css" + }, + { + "name": "element-plus组件库", + "package": "element-plus", + "version": "2.4.2", + "script": "https://unpkg.com/element-plus@2.4.2/dist/index.full.mjs", + "css": "https://unpkg.com/element-plus@2.4.2/dist/index.css" + } + ] +} +``` + +2. **`.env.alpha`**: + +```bash +VITE_CDN_DOMAIN=https://unpkg.com +``` + +此时,只有前缀匹配的 `element-plus` 才会被解析并复制 npm 内容。而 `@opentiny/vue-runtime` 则不会被复制 + + +## 实现原理 + +本地化CDN依赖可以分为以下几个核心步骤: + +### 1. 分析导入映射并收集依赖 + +`importMapLocalPlugin`会分析提供的`importMapLocalConfig`和默认的导入映射,识别所有需要本地化的CDN依赖。这个过程包括: + +- 解析CDN URL获取包名、版本和文件路径 +- 合并用户配置和默认配置 +- 生成文件复制任务列表 + +### 2. 判断和安装依赖包 + +插件会检查所需的依赖包是否已在本地存在,对于不存在或版本不匹配的包,会通过`installPackageTemporary`函数临时安装到指定目录。 + +### 3. 复制并转换文件 + +对于已识别的CDN依赖,插件会: + +- 复制所需文件到目标CDN目录 +- 对JavaScript文件进行路径替换转换 (比如: `import push from './push'` 需要改成 `import push from './push.js'`) +- 保持目录结构与原始CDN一致 + +### 4. 处理Bundle文件 + +`copyBundleDeps`功能专门处理bundle文件中的CDN链接: + +- 提取bundle文件中的CDN链接 +- 替换为本地路径 +- 生成新的bundle文件 + +## 注意事项 + +1. 本地化CDN会增加构建输出的大小,但会提高应用的可靠性和性能 +2. 某些特定格式的CDN URL可能需要在`copy`中进行特别配置 diff --git a/packages/build/vite-config/package.json b/packages/build/vite-config/package.json index 6d7ed05bed..f42f804929 100644 --- a/packages/build/vite-config/package.json +++ b/packages/build/vite-config/package.json @@ -31,6 +31,7 @@ "esbuild-plugin-copy": "^2.1.1", "eslint": "^8.38.0", "eslint-plugin-vue": "^8.0.0", + "fast-glob": "^3.3.2", "fs-extra": "^10.1.0", "husky": "^8.0.0", "lerna": "^7.2.0", @@ -39,6 +40,7 @@ "path": "^0.12.7", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-visualizer": "^5.8.3", + "semver": "^7.7.1", "shelljs": "^0.8.5", "svg-sprite-loader": "^6.0.11", "vite": "^5.4.2", diff --git a/packages/build/vite-config/src/default-config.js b/packages/build/vite-config/src/default-config.js index fd6ffba938..67a9d3c9ce 100644 --- a/packages/build/vite-config/src/default-config.js +++ b/packages/build/vite-config/src/default-config.js @@ -10,7 +10,7 @@ import esbuildCopy from 'esbuild-plugin-copy' import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' import visualizerCjs from 'rollup-plugin-visualizer' import generateComment from '@opentiny/tiny-engine-vite-plugin-meta-comments' -import { getBaseUrlFromCli, copyBundleDeps, copyPreviewImportMap } from './localCdnFile/index.js' +import { getBaseUrlFromCli, copyBundleDeps, importMapLocalPlugin } from './localCdnFile/index.js' import { devAliasPlugin } from './vite-plugins/devAliasPlugin.js' import { htmlUpgradeHttpsPlugin } from './vite-plugins/upgradeHttpsPlugin.js' import { canvasDevExternal } from './canvas-dev-external.js' @@ -134,7 +134,12 @@ export function useTinyEngineBaseConfig(engineConfig) { const { envDir = '', viteConfigEnv } = engineConfig const { command = 'serve', mode = 'serve' } = viteConfigEnv const env = loadEnv(mode, envDir) - const { VITE_CDN_DOMAIN = 'https://unpkg.com', VITE_LOCAL_IMPORT_MAPS, VITE_LOCAL_BUNDLE_DEPS } = env + const { + VITE_CDN_DOMAIN = 'https://unpkg.com', + VITE_LOCAL_IMPORT_MAPS, + VITE_LOCAL_BUNDLE_DEPS, + VITE_LOCAL_IMPORT_PATH + } = env const isLocalImportMap = VITE_LOCAL_IMPORT_MAPS === 'true' // true公共依赖库使用本地打包文件,false公共依赖库使用公共CDN const isCopyBundleDeps = VITE_LOCAL_BUNDLE_DEPS === 'true' // true bundle里的cdn依赖处理成本地依赖, false 不处理 const monacoPublicPath = 'editor/monaco-workers' @@ -160,22 +165,25 @@ export function useTinyEngineBaseConfig(engineConfig) { originCdnPrefix: VITE_CDN_DOMAIN, // mock 中bundle的域名当前和环境的VITE_CDN_DOMAIN一致 base: getBaseUrlFromCli(config.base) }).plugin(command === 'serve') - : [], - isLocalImportMap - ? copyPreviewImportMap({ - // FIXME: 相对路径需要修正 - importMapJson: './src/preview/src/preview/importMap.json', - targetImportMapJson: 'preview-import-map-static/preview-importmap.json', - originCdnPrefix: VITE_CDN_DOMAIN, - base: getBaseUrlFromCli(config.base), - packageCopyLib: [ - // 以下的js存在相对路径引用,不能单独拷贝一个文件,需要整个包拷贝 - '@vue/devtools-api' - ] - }) : [] ) + // 添加本地化CDN插件支持 + if (isLocalImportMap) { + const logger = console + logger.log('[local-cdn-plugin]: Initializing local CDN plugin') + + const importMapPlugins = importMapLocalPlugin({ + importMapLocalConfig: engineConfig.importMapLocalConfig, + base: getBaseUrlFromCli(config.base), + cdnDir: VITE_LOCAL_IMPORT_PATH + }) + + if (importMapPlugins && importMapPlugins.length > 0) { + config.plugins.push(...importMapPlugins) + } + } + config.plugins.push(devAliasPlugin(env, engineConfig.useSourceAlias)) if (engineConfig.useSourceAlias && command === 'serve') { diff --git a/packages/build/vite-config/src/localCdnFile/copyBundleDeps.js b/packages/build/vite-config/src/localCdnFile/copyBundleDeps.js index 7ee965b519..2e5256dc81 100644 --- a/packages/build/vite-config/src/localCdnFile/copyBundleDeps.js +++ b/packages/build/vite-config/src/localCdnFile/copyBundleDeps.js @@ -13,22 +13,36 @@ import { const { readJsonSync } = fs export function extraBundleCdnLink(filename, originCdnPrefix) { - const result = [] + const result = new Set() const bundle = readJsonSync(filename) + // 兼容旧版的 npm 协议 bundle.data?.materials?.components?.forEach((component) => { if (component.npm) { const possibleUrl = [component.npm.script, component.npm.css] possibleUrl.forEach((url) => { - if (url?.startsWith(originCdnPrefix) && !result.includes(url)) { - result.push(url) + if (url?.startsWith(originCdnPrefix) && !result.has(url)) { + result.add(url) } }) } }) - return result + + bundle.data?.materials?.packages?.forEach((packageItem) => { + if (packageItem) { + const possibleUrl = [packageItem.script, packageItem.css] + possibleUrl.forEach((url) => { + if (url?.startsWith(originCdnPrefix)) { + result.add(url) + } + }) + } + }) + + return [...result] } export function replaceBundleCdnLink(bundle, fileMap) { + // 兼容旧版的 npm 协议 bundle.data?.materials?.components?.forEach((component) => { if (component.npm) { const possibleUrl = ['script', 'css'] @@ -40,6 +54,18 @@ export function replaceBundleCdnLink(bundle, fileMap) { }) } }) + + bundle.data?.materials?.packages?.forEach((packageItem) => { + if (packageItem) { + const possibleUrl = ['script', 'css'] + possibleUrl.forEach((key) => { + const matchRule = fileMap.find((rule) => packageItem[key] === rule.originUrl) + if (matchRule) { + packageItem[key] = matchRule.newUrl + } + }) + } + }) } export function copyBundleDeps({ @@ -50,9 +76,12 @@ export function copyBundleDeps({ dir = 'material-static', bundleTempDir = 'bundle-deps/material-static' }) { - const cdnFiles = extraBundleCdnLink(bundleFile, originCdnPrefix).map((url) => - getCdnPathNpmInfoForSingleFile(url, originCdnPrefix, base, dir, false, bundleTempDir) - ) + const cdnFiles = extraBundleCdnLink(bundleFile, originCdnPrefix) + .map((url) => getCdnPathNpmInfoForSingleFile(url, originCdnPrefix, base, dir, false, bundleTempDir)) + // 比如 url 前缀跟 originCdnPrefix 不匹配,或者 url 格式不正确 的场景有可能会导致匹配失败 + // 匹配失败时,会返回 null,需要过滤掉 + .filter(Boolean) + const { packages: packageNeedToInstall, files } = getPackageNeedToInstallAndFilesUsingSameVersion(cdnFiles) const plugin = (isDev) => { diff --git a/packages/build/vite-config/src/localCdnFile/copyImportMap.js b/packages/build/vite-config/src/localCdnFile/copyImportMap.js index 476d204854..cad4a5a92a 100644 --- a/packages/build/vite-config/src/localCdnFile/copyImportMap.js +++ b/packages/build/vite-config/src/localCdnFile/copyImportMap.js @@ -26,9 +26,15 @@ export const copyLocalImportMap = ({ } return getCdnPathNpmInfoForSingleFile(libPath, originCdnPrefix, base, dir, false, bundleTempDir) }) + // 比如 url 前缀跟 originCdnPrefix 不匹配,或者 url 格式不正确 的场景有可能会导致匹配失败 + // 匹配失败时,会返回 null,需要过滤掉 + .filter(Boolean) const styleFiles = styleUrls .filter((styleUrl) => styleUrl.startsWith(originCdnPrefix)) .map((url) => getCdnPathNpmInfoForSingleFile(url, originCdnPrefix, base, dir, false), bundleTempDir) + // 比如 url 前缀跟 originCdnPrefix 不匹配,或者 url 格式不正确 的场景有可能会导致匹配失败 + // 匹配失败时,会返回 null,需要过滤掉 + .filter(Boolean) const { packages: packageNeedToInstall, files } = getPackageNeedToInstallAndFilesUsingSameVersion( importMapFiles.concat(styleFiles) diff --git a/packages/build/vite-config/src/localCdnFile/importMapLocalPlugin.js b/packages/build/vite-config/src/localCdnFile/importMapLocalPlugin.js new file mode 100644 index 0000000000..8ad636c55d --- /dev/null +++ b/packages/build/vite-config/src/localCdnFile/importMapLocalPlugin.js @@ -0,0 +1,238 @@ +import path from 'node:path' +import fs from 'fs-extra' +import semver from 'semver' +import { installPackageTemporary } from '../vite-plugins/installPackageTemporary.js' +import { copyPlugin } from '../vite-plugins/cdnCopyPlugin.js' +import { dedupeCopyFiles } from './locateCdnNpmInfo.js' + +const logger = console + +// 默认的复制配置,这几个 package 需要复制整个目录,所以需要有默认配置进行复制整个目录 +const defaultCopyConfig = { + '@opentiny/vue-theme': { + filePathInPackage: '/' + }, + '@opentiny/vue-renderless': { + filePathInPackage: '/' + }, + '@opentiny/vue-runtime': { + filePathInPackage: '/dist3/' + }, + '@vue/devtools-api': { + filePathInPackage: '/' + } +} + +/** + * 从importMapUrl字符串中提取包名、版本和文件路径 + * ${versionDelimiter} 和 ${fileDelimiter} 是默认的 importMapUrl 中的占位符, + * 例如: + * importMapUrl = '${VITE_CDN_DOMAIN}/${packageName}${versionDelimiter}${version}${fileDelimiter}${filePath}' + * 提取的信息对象: + * { + * packageName: '${packageName}', + * version: '${version}', + * filePathInPackage: '${filePath}' + * } + * @param {string} str - 导入字符串 + * @returns {Object} - 提取的信息对象 + * @returns {string} packageName - 包名 + * @returns {string} version - 版本 + * @returns {string} filePathInPackage - 包内文件路径 + * */ +function extractInfo(str) { + try { + let [packageName, versionAndPath] = str.split('${versionDelimiter}') + packageName = packageName.replace(/^\$\{VITE_CDN_DOMAIN\}\//, '') + const [version, filePath] = versionAndPath.split('${fileDelimiter}') + + return { + packageName, + version, + filePathInPackage: filePath || '/' + } + } catch (error) { + logger.error(`[import-map-local-plugin]: Failed to extract info from ${str} 提取 importMap 信息失败`, error) + } +} + +/** + * 比较两个版本号是否相同 + * @param {string} versionOrigin - 源版本号, 可能包含 ^ 或 ~ 开头 + * @param {string} versionTarget - 目标版本号,来源于 package.json 的 version, 不能包含 ^ 或 ~ 开头 + * @returns {boolean} - 是否相同 + */ +const compareIsSameVersion = (versionOrigin, versionTarget) => { + if (versionOrigin === versionTarget) { + return true + } + + return semver.satisfies(versionTarget, versionOrigin) +} + +function getCdnPathNpmInfo( + cdnDependencyItem, + base, // build构建的base(BASE_URL)参数 + cdnDir, // 复制到目标的文件目录 + tempDir = 'bundle-deps', // 新安装包的安装目录 + copyConfig = {} // 复制配置 +) { + let { packageName, version, filePathInPackage } = cdnDependencyItem + const originVersion = version + + if (copyConfig[packageName]) { + const { version: copyVersion, filePathInPackage: copyFilePathInPackage } = copyConfig[packageName] + + if (copyVersion) { + version = copyVersion + } + if (copyFilePathInPackage) { + filePathInPackage = copyFilePathInPackage + } + } + + let isFolder = filePathInPackage.endsWith('/') + let src = `node_modules/${packageName}${filePathInPackage}` + const pkgFilePath = `node_modules/${packageName}/package.json` + let isSameVersion = false + + if (fs.existsSync(pkgFilePath)) { + try { + const pkg = JSON.parse(fs.readFileSync(path.resolve(pkgFilePath))) + isSameVersion = compareIsSameVersion(version, pkg.version) + } catch (error) { + // ignore + } + } + // 只有包存在 且 版本号一致 才认为源文件存在 + const sourceExist = fs.existsSync(path.resolve(src)) && isSameVersion + + if (sourceExist) { + const stat = fs.statSync(path.resolve(src)) + if (stat.isDirectory()) { + isFolder = true + } + } else { + src = tempDir + '/' + src + } + + const destPackageDir = `${cdnDir}/${packageName}@${originVersion}` + const destFullPath = `${destPackageDir}${filePathInPackage}` + const destFullPathWithoutTailSlash = destFullPath + const dest = destFullPathWithoutTailSlash + let destDir = dest + + // 不是文件夹,则取文件所在目录 + if (!isFolder) { + destDir = path.dirname(destFullPathWithoutTailSlash) + } + + return { + src, + packageName, + version, + sourceExist, + dest: destDir + } +} + +function parseImportMapLocalConfig(importMapLocalConfig) { + let parsedImportMapLocalConfig = importMapLocalConfig + + if (!parsedImportMapLocalConfig || typeof parsedImportMapLocalConfig !== 'object') { + logger.warn('[import-map-local-plugin]: Invalid importMapLocalConfig, using defaults') + + parsedImportMapLocalConfig = { importMap: { imports: {} }, copy: {} } + } + + if (!parsedImportMapLocalConfig.importMap || typeof parsedImportMapLocalConfig.importMap !== 'object') { + logger.warn('[import-map-local-plugin]: Invalid importMapConfig, using defaults') + + parsedImportMapLocalConfig.importMap = { imports: {} } + } + + if (!parsedImportMapLocalConfig.copy || typeof parsedImportMapLocalConfig.copy !== 'object') { + logger.warn('[import-map-local-plugin]: Invalid copyConfig, using defaults') + + parsedImportMapLocalConfig.copy = {} + } + + return parsedImportMapLocalConfig +} + +/** + * 本地化CDN插件 + * @param {Object} options - 配置选项 + * @param {Object} options.importMapLocalConfig - 本地CDN配置 + * @param {Object} options.importMapLocalConfig.importMap - 导入映射配置,定义需要本地化的CDN依赖 + * @param {Object} options.importMapLocalConfig.copy - 自定义复制配置,可以覆盖特定包的默认配置 + * @param {string} options.base - 构建的base URL + * @param {string} options.cdnDir - 构建目录中的CDN文件夹名称 + * @param {string} options.bundleTempDir - 临时存放下载的包的目录 + * @returns {Array} - Vite插件数组 + */ +export function importMapLocalPlugin({ + importMapLocalConfig = { importMap: { imports: {} }, copy: {} }, + base = './', + cdnDir = 'local-cdn-static', // 构建目录中的CDN文件夹名称 + bundleTempDir = 'bundle-deps/local-cdn' // 临时存放下载的包的目录 +}) { + let parsedImportMapLocalConfig = parseImportMapLocalConfig(importMapLocalConfig) + + const copyConfig = parsedImportMapLocalConfig.copy || {} + const defaultImportMapConfig = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), './node_modules/@opentiny/tiny-engine/dist/import-map.json'), 'utf-8') + ) + const parsedDefaultImportMapConfig = Object.values(defaultImportMapConfig.imports) + .map((item) => extractInfo(item)) + .filter(Boolean) + const parsedImportMapConfig = Object.values(parsedImportMapLocalConfig.importMap.imports) + .map((item) => extractInfo(item)) + .filter(Boolean) + // 处理内置的物料样式,后续不再内置物料后,需要用户自行引入,相关逻辑也需要同步删除 + const parsedImportMapStylesConfig = Object.values(defaultImportMapConfig.importStyles || {}) + .map((item) => extractInfo(item)) + .filter(Boolean) + const overriddenImportMap = parsedDefaultImportMapConfig.filter((item) => { + return !parsedImportMapConfig.find((parsedItem) => parsedItem.packageName === item.packageName) + }) + const combinedImportMapConfig = [...overriddenImportMap, ...parsedImportMapConfig, ...parsedImportMapStylesConfig] + + if (combinedImportMapConfig.length === 0) { + logger.warn('[import-map-local-plugin]: No CDN dependencies found or configured') + return [] + } + const combinedCopyConfig = { ...defaultCopyConfig, ...copyConfig } + + // 处理每个CDN URL,获取复制信息 + const cdnFiles = combinedImportMapConfig.map((cdnDependencyItem) => + getCdnPathNpmInfo(cdnDependencyItem, base, cdnDir, bundleTempDir, combinedCopyConfig) + ) + + // 获取需要安装的包列表和文件列表 + const packageNeedToInstall = cdnFiles + .filter((item) => !item.sourceExist) + .map(({ packageName, version }) => ({ packageName, version })) + .reduce((acc, cur) => { + // 同个包避免多个版本只保留一个版本 + if (!acc.some(({ packageName }) => cur.packageName === packageName)) { + acc.push(cur) + } + return acc + }, []) + + // 日志一下将要处理的内容 + logger.log( + `[import-map-local-plugin]: Processing ${combinedImportMapConfig.length} CDN dependencies to local directory: ${cdnDir}` + ) + logger.log(`[import-map-local-plugin]: Need to install ${packageNeedToInstall.length} packages`) + + const targetFiles = dedupeCopyFiles(cdnFiles) + // 返回插件数组 + return [ + // 安装需要的包 + ...installPackageTemporary(packageNeedToInstall, bundleTempDir), + // 使用自定义的copyPlugin替代直接调用copy + copyPlugin(targetFiles) + ] +} diff --git a/packages/build/vite-config/src/localCdnFile/index.js b/packages/build/vite-config/src/localCdnFile/index.js index daa97291e0..8becd9c4e2 100644 --- a/packages/build/vite-config/src/localCdnFile/index.js +++ b/packages/build/vite-config/src/localCdnFile/index.js @@ -4,3 +4,4 @@ export * from './copyPreviewImportMap.js' export * from './utils.js' export * from './locateCdnNpmInfo.js' export * from './replaceImportPath.mjs' +export * from './importMapLocalPlugin.js' diff --git a/packages/build/vite-config/src/localCdnFile/locateCdnNpmInfo.js b/packages/build/vite-config/src/localCdnFile/locateCdnNpmInfo.js index 2477809f44..7ef6b900b0 100644 --- a/packages/build/vite-config/src/localCdnFile/locateCdnNpmInfo.js +++ b/packages/build/vite-config/src/localCdnFile/locateCdnNpmInfo.js @@ -38,6 +38,34 @@ export function copyfileToDynamicSrcMapper({ src, dest, transform, rename, folde } } +function extractPackageInfo(url, originCdnPrefix) { + let match = null + + try { + const mergedRegex = new RegExp( + `^${originCdnPrefix}/?` + + // 包名捕获组(支持作用域包 @scope/name 格式) + // (?:@[^/]+/)? 匹配 @scope/ 格式 + // [^/@]+ 匹配包名,不包含 '/' 和 '@' + `(?(?:@[^/]+/)?[^/@]+)` + + // 版本号部分(@或/分隔) + // /(?=.*/files) 匹配斜杠的分割的文件路径,但是需满足正向预查,确保后续路径包含 /files + `(?:@|/(?=.*/files))` + + // 捕获版本号 + `(?[^/]+)` + + // 路径部分 处理/files前缀(npmmirror) + `(?:/files)?` + + // 路径部分 匹配文件路径 + `(?.*?)$` + ) + match = url.match(mergedRegex) + } catch (error) { + // ignore + } + + return match +} + // 生成复制单个文件所需要的信息 export function getCdnPathNpmInfoForSingleFile( url, // cdn托管的npm文件地址数组 @@ -48,9 +76,13 @@ export function getCdnPathNpmInfoForSingleFile( tempDir = 'bundle-deps' // 新安装包的安装目录 ) { const baseSlash = base.endsWith('/') ? '' : '/' - const { packageName, versionDemand, filePathInPackage } = url.match( - new RegExp(`^${originCdnPrefix}/?(?.+?)@(?[^/]+)(?.*?)$`) - ).groups + const match = extractPackageInfo(url, originCdnPrefix) + + if (!match) { + return null + } + + const { packageName, versionDemand, filePathInPackage } = match.groups let version = versionDemand let isFolder = filePathInPackage.endsWith('/') let src = replaceTailSlash(`node_modules/${packageName}${filePathInPackage}`) @@ -111,9 +143,14 @@ export function getCdnPathNpmInfoForPackage( tempDir = 'bundle-deps' // 新安装包的安装目录 ) { const baseSlash = base.endsWith('/') ? '' : '/' - const { packageName, versionDemand, filePathInPackage } = url.match( - new RegExp(`^${originCdnPrefix}/?(?.+?)@(?[^/]+)(?.*?)$`) - ).groups + // 使用匹配到的结果 + const match = extractPackageInfo(url, originCdnPrefix) + + if (!match) { + return null + } + + const { packageName, versionDemand, filePathInPackage } = match.groups let version = versionDemand let src = `node_modules/${packageName}` const sourceExist = fs.existsSync(path.resolve(src)) diff --git a/packages/build/vite-config/src/localCdnFile/replaceImportPath.mjs b/packages/build/vite-config/src/localCdnFile/replaceImportPath.mjs index 877945d54b..afe341f36d 100644 --- a/packages/build/vite-config/src/localCdnFile/replaceImportPath.mjs +++ b/packages/build/vite-config/src/localCdnFile/replaceImportPath.mjs @@ -6,10 +6,21 @@ import generatePkg from '@babel/generator' const traverse = traversePkg.default const generate = generatePkg.default +/** + * 将相对路径转换为以'./'开头的格式,并确保使用Unix风格的路径分隔符 + * @param {string} relativePath - 相对路径 + * @returns {string} 转换后的相对路径 + */ export function relativePathPattern(relativePath) { return './' + (path.sep === '/' ? relativePath : relativePath.replace(/\\/g, '/')) } +/** + * 根据导入路径和当前文件路径,尝试找到实际的文件路径 + * @param {string} importPath - 导入路径 + * @param {string} currentFilePath - 当前文件的完整路径 + * @returns {string|null} 返回解析后的路径,如果找不到则返回null + */ export function resolvePath(importPath, currentFilePath) { if (['js', 'mjs'].some((suffix) => importPath.endsWith(suffix))) { // 文件名已经带有.js,.mjs后缀 @@ -47,7 +58,14 @@ export function resolvePath(importPath, currentFilePath) { return null } -// babel 替换 js的相对地址引用为确定文件后缀 +/** + * 使用Babel替换JavaScript文件中的相对路径引用为确定的文件后缀 + * @param {string} content - 要处理的代码内容 + * @param {string} currentFilePath - 当前文件的完整路径 + * @param {Console} [logger=console] - 用于记录警告和错误的Logger对象 + * @returns {{code: string|null, success: Array<{before: string, after: string}>, error: Array}} + * 包含处理后的代码、成功替换的路径和失败的路径 + */ export function babelReplaceImportPathWithCertainFileName(content, currentFilePath, logger = console) { let fileChangedMark = false let result = { diff --git a/packages/build/vite-config/src/vite-plugins/cdnCopyPlugin.js b/packages/build/vite-config/src/vite-plugins/cdnCopyPlugin.js new file mode 100644 index 0000000000..8d77cedcfe --- /dev/null +++ b/packages/build/vite-config/src/vite-plugins/cdnCopyPlugin.js @@ -0,0 +1,154 @@ +import path from 'node:path' +import fs from 'fs-extra' +import fg from 'fast-glob' +import { babelReplaceImportPathWithCertainFileName } from '../localCdnFile/replaceImportPath.mjs' + +/** + * 对.js和.mjs文件内容进行转换处理,将import路径如 import { a } from './b' 转换为 import { a} from './b.js' + * @param {string} content - 文件内容 + * @param {string} filename - 文件名 + * @returns {string} - 处理后的内容 + */ +function replaceJsImportPaths(content, filename) { + const result = babelReplaceImportPathWithCertainFileName(content, filename, console) + + return result.code || content +} + +const logger = console + +async function copyFile(srcPath, destPath) { + // 确保目标文件的目录存在 + await fs.ensureDir(path.dirname(destPath)) + + if (srcPath.endsWith('.js') || srcPath.endsWith('.mjs')) { + // 读取文件内容 + const content = await fs.readFile(srcPath, 'utf-8') + + // 应用转换 + const transformedContent = replaceJsImportPaths(content, srcPath) + // 写入转换后的内容 + await fs.writeFile(destPath, transformedContent) + } else { + // 复制文件 + await fs.copyFile(srcPath, destPath) + } +} + +/** + * 复制文件或目录到目标路径 + * @param {string} srcPath - 源文件/目录路径 + * @param {string[]} destPaths - 目标路径数组 + * @param {Set} copiedFiles - 已复制文件集合 + * @param {string} outDir - 输出目录 + */ +async function copyFileOrDirectory(srcPath, destPaths, copiedFiles, outDir) { + // 生成一个唯一标识,避免重复复制相同文件 + const copyId = `${srcPath}:${destPaths.join(',')}` + + if (copiedFiles.has(copyId)) { + logger.log(`[vite-cdn-copy-plugin]: Skipping already copied file: ${srcPath}`) + return + } + + copiedFiles.add(copyId) + + // 检查源文件是否存在 + if (!fs.existsSync(srcPath)) { + logger.warn(`[vite-cdn-copy-plugin]: Source does not exist: ${srcPath}`) + return + } + + const isDirectory = fs.statSync(srcPath).isDirectory() + + // 为每个目标路径执行复制 + for (const destPath of destPaths) { + const fullDestPath = path.resolve(outDir, destPath) + + try { + // 确保目标目录存在 + await fs.ensureDir(path.dirname(fullDestPath)) + + if (isDirectory) { + // 如果是目录,使用 fast-glob 遍历所有文件并处理 + logger.log(`[vite-cdn-copy-plugin]: Copying directory recursively: ${srcPath} -> ${fullDestPath}`) + + // 确保目标路径存在 + await fs.ensureDir(fullDestPath) + + // 使用绝对路径 + const absoluteSrcPath = path.resolve(process.cwd(), srcPath) + + // 使用 fast-glob 查找所有文件 + const files = fg.sync(`${absoluteSrcPath}/**/*`, { onlyFiles: true }) + + // 处理每个文件 + for (const file of files) { + const relativePath = path.relative(absoluteSrcPath, file) + const destFilePath = path.join(fullDestPath, relativePath) + + await copyFile(file, destFilePath) + } + } else { + // 如果是单个文件 + logger.log(`[vite-cdn-copy-plugin]: Copying file: ${srcPath} -> ${fullDestPath}`) + + let finalDestPath = path.join(fullDestPath, path.basename(srcPath)) + + await copyFile(srcPath, finalDestPath) + } + } catch (err) { + logger.error(`[vite-cdn-copy-plugin]: Failed to copy ${srcPath} to ${fullDestPath}`, err) + } + } +} + +/** + * 创建复制插件 + * @param {Array} targets - 复制目标配置数组 + * @param {string|Array} targets[].src - 源文件路径或路径数组 + * @param {string|Array} targets[].dest - 目标文件路径或路径数组 + * @returns {Object} Vite插件对象 + */ + +export function copyPlugin(targets) { + let resolvedConfig = null + let copiedFiles = new Set() + + return { + name: 'vite-cdn-copy-plugin', + configResolved(getResolvedConfig) { + resolvedConfig = getResolvedConfig + }, + async writeBundle() { + if (!targets || !targets.length) { + return + } + + const outDir = resolvedConfig.build.outDir || 'dist' + + logger.log('[vite-cdn-copy-plugin]: Start copying files to dist directory') + + // 遍历所有复制目标 + for (const target of targets) { + const { src, dest } = target + + if (!src || !dest) { + logger.warn('[vite-cdn-copy-plugin]: Skipping target with missing src or dest', target) + continue + } + + // 处理源路径,支持数组形式 + const srcPaths = Array.isArray(src) ? src : [src] + // 处理目标路径,支持数组形式 + const destPaths = Array.isArray(dest) ? dest : [dest] + + for (const srcPath of srcPaths) { + await copyFileOrDirectory(srcPath, destPaths, copiedFiles, outDir) + } + } + + logger.log('[vite-cdn-copy-plugin]: Finished copying files') + } + } +} diff --git a/packages/canvas/DesignCanvas/src/DesignCanvas.vue b/packages/canvas/DesignCanvas/src/DesignCanvas.vue index 01fab5a399..217cefd006 100644 --- a/packages/canvas/DesignCanvas/src/DesignCanvas.vue +++ b/packages/canvas/DesignCanvas/src/DesignCanvas.vue @@ -78,7 +78,7 @@ export default { return } - const { importMap, importStyles } = getImportMapData(getMergeMeta('engine.config')?.importMapVersion, deps) + const { importMap, importStyles } = getImportMapData(deps) canvasSrcDoc.value = initCanvas(importMap, importStyles).html } diff --git a/packages/canvas/DesignCanvas/src/importMap.ts b/packages/canvas/DesignCanvas/src/importMap.ts index 60393b2752..499154c47d 100644 --- a/packages/canvas/DesignCanvas/src/importMap.ts +++ b/packages/canvas/DesignCanvas/src/importMap.ts @@ -1,38 +1,77 @@ -import { VITE_CDN_DOMAIN, VITE_CDN_TYPE } from '@opentiny/tiny-engine-common/js/environments' - -export function getImportMapData(overrideVersions = {}, canvasDeps = { scripts: [], styles: [] }) { - const importMapVersions = Object.assign( - { - vue: '3.4.23', - tinyVue: '~3.20', - vueI18n: '^9.9.0' - }, - overrideVersions - ) +import { useEnv, getMergeMeta } from '@opentiny/tiny-engine-meta-register' +import { importMapConfig } from '@opentiny/tiny-engine-common/js/importMap' + +const getImportUrl = (pkgName: string) => { + // 自定义的 importMap + const customImportMap = getMergeMeta('engine.config')?.importMap + const { + VITE_CDN_TYPE, + VITE_CDN_DOMAIN, + VITE_LOCAL_IMPORT_PATH = 'local-cdn-static', + BASE_URL, + VITE_LOCAL_IMPORT_MAPS + } = useEnv() + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' + const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' + const cdnDomain = isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN + + if (customImportMap?.imports?.[pkgName]) { + return customImportMap.imports[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } + + if (importMapConfig.imports[pkgName]) { + return importMapConfig.imports[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } +} + +// 获取样式文件的URL,后续去除物料内置逻辑之后,需要用户自行引入,相关逻辑也需要同步删除 +const getImportStyleUrl = (pkgName: string) => { + const { + VITE_CDN_TYPE, + VITE_CDN_DOMAIN, + VITE_LOCAL_IMPORT_PATH = 'local-cdn-static', + BASE_URL, + VITE_LOCAL_IMPORT_MAPS + } = useEnv() + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' + const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' + const cdnDomain = isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN - const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' ? '/' : '@' - const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' ? '/files' : '' + if (importMapConfig.importStyles[pkgName]) { + return importMapConfig.importStyles[pkgName] + .replace('${VITE_CDN_DOMAIN}', cdnDomain) + .replace('${versionDelimiter}', versionDelimiter) + .replace('${fileDelimiter}', fileDelimiter) + } +} +export function getImportMapData(canvasDeps = { scripts: [], styles: [] }) { // 以下内容由于区块WebComponent加载需要补充 const blockRequire = { imports: { - '@opentiny/vue': `${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${importMapVersions.tinyVue}${fileDelimiter}/dist3/tiny-vue-pc.mjs`, - '@opentiny/vue-icon': `${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${importMapVersions.tinyVue}${fileDelimiter}/dist3/tiny-vue-icon.mjs`, - 'element-plus': `${VITE_CDN_DOMAIN}/element-plus${versionDelimiter}2.4.2${fileDelimiter}/dist/index.full.mjs`, - '@opentiny/tiny-engine-builtin-component': `${VITE_CDN_DOMAIN}/@opentiny/tiny-engine-builtin-component${versionDelimiter}^2.0.0${fileDelimiter}/dist/index.mjs` + // TODO: 后续版本发通知,不再内置物料,需要用户自行引入 + '@opentiny/vue': getImportUrl('@opentiny/vue'), + '@opentiny/vue-icon': getImportUrl('@opentiny/vue-icon'), + '@opentiny/tiny-engine-builtin-component': getImportUrl('@opentiny/tiny-engine-builtin-component') }, - importStyles: [ - `${VITE_CDN_DOMAIN}/@opentiny/vue-theme${versionDelimiter}${importMapVersions.tinyVue}${fileDelimiter}/index.css`, - `${VITE_CDN_DOMAIN}/element-plus${versionDelimiter}2.4.2${fileDelimiter}/dist/index.css` - ] + importStyles: [getImportStyleUrl('@opentiny/vue-theme')] } // 以下内容由于物料协议不支持声明子依赖而@opentiny/vue需要依赖所以需要补充 + // TODO: 后续版本发通知,不再内置物料,需要用户自行引入 const tinyVueRequire = { imports: { - '@opentiny/vue-common': `${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${importMapVersions.tinyVue}${fileDelimiter}/dist3/tiny-vue-common.mjs`, - '@opentiny/vue-locale': `${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${importMapVersions.tinyVue}${fileDelimiter}/dist3/tiny-vue-locale.mjs`, - echarts: `${VITE_CDN_DOMAIN}/echarts${versionDelimiter}5.4.1${fileDelimiter}/dist/echarts.esm.js` + '@opentiny/vue-common': getImportUrl('@opentiny/vue-common'), + '@opentiny/vue-locale': getImportUrl('@opentiny/vue-locale'), + echarts: getImportUrl('echarts') } } @@ -46,8 +85,8 @@ export function getImportMapData(overrideVersions = {}, canvasDeps = { scripts: const importMap = { imports: { - vue: `${VITE_CDN_DOMAIN}/vue${versionDelimiter}${importMapVersions.vue}${fileDelimiter}/dist/vue.runtime.esm-browser.prod.js`, - 'vue-i18n': `${VITE_CDN_DOMAIN}/vue-i18n${versionDelimiter}${importMapVersions.vueI18n}${fileDelimiter}/dist/vue-i18n.esm-browser.js`, + vue: getImportUrl('vue'), + 'vue-i18n': getImportUrl('vue-i18n'), ...blockRequire.imports, ...tinyVueRequire.imports, ...materialsAndUtilsRequire diff --git a/packages/design-core/src/preview/src/preview/importMap.json b/packages/common/js/import-map.json similarity index 76% rename from packages/design-core/src/preview/src/preview/importMap.json rename to packages/common/js/import-map.json index fd90ca3efc..3bbb9959fa 100644 --- a/packages/design-core/src/preview/src/preview/importMap.json +++ b/packages/common/js/import-map.json @@ -2,7 +2,7 @@ "imports": { "vue": "${VITE_CDN_DOMAIN}/vue${versionDelimiter}3.4.23${fileDelimiter}/dist/vue.runtime.esm-browser.js", "vue/server-renderer": "${VITE_CDN_DOMAIN}/@vue/server-renderer${versionDelimiter}3.4.23${fileDelimiter}/dist/server-renderer.esm-browser.js", - "vue-i18n": "${VITE_CDN_DOMAIN}/vue-i18n${versionDelimiter}9.2.0-beta.36${fileDelimiter}/dist/vue-i18n.esm-browser.js", + "vue-i18n": "${VITE_CDN_DOMAIN}/vue-i18n${versionDelimiter}^9.9.0${fileDelimiter}/dist/vue-i18n.esm-browser.js", "vue-router": "${VITE_CDN_DOMAIN}/vue-router${versionDelimiter}4.0.16${fileDelimiter}/dist/vue-router.esm-browser.js", "@vue/devtools-api": "${VITE_CDN_DOMAIN}/@vue/devtools-api${versionDelimiter}6.5.1${fileDelimiter}/lib/esm/index.js", "@vueuse/core": "${VITE_CDN_DOMAIN}/@vueuse/core${versionDelimiter}9.6.0${fileDelimiter}/index.mjs", @@ -13,11 +13,14 @@ "@opentiny/tiny-engine-builtin-component": "${VITE_CDN_DOMAIN}/@opentiny/tiny-engine-builtin-component${versionDelimiter}^2.0.0${fileDelimiter}/dist/index.mjs", "vue-demi": "${VITE_CDN_DOMAIN}/vue-demi${versionDelimiter}0.13.11${fileDelimiter}/lib/index.mjs", "pinia": "${VITE_CDN_DOMAIN}/pinia${versionDelimiter}2.0.22${fileDelimiter}/dist/pinia.esm-browser.js", - "@opentiny/vue": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${opentinyVueVersion}${fileDelimiter}/dist3/tiny-vue-pc.mjs", - "@opentiny/vue-icon": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${opentinyVueVersion}${fileDelimiter}/dist3/tiny-vue-icon.mjs", - "@opentiny/vue-common": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${opentinyVueVersion}${fileDelimiter}/dist3/tiny-vue-common.mjs", - "@opentiny/vue-locale": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}${opentinyVueVersion}${fileDelimiter}/dist3/tiny-vue-locale.mjs", - "@opentiny/vue-renderless/": "${VITE_CDN_DOMAIN}/@opentiny/vue-renderless${versionDelimiter}${opentinyVueVersion}${fileDelimiter}/", + "@opentiny/vue": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}~3.20${fileDelimiter}/dist3/tiny-vue-pc.mjs", + "@opentiny/vue-icon": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}~3.20${fileDelimiter}/dist3/tiny-vue-icon.mjs", + "@opentiny/vue-common": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}~3.20${fileDelimiter}/dist3/tiny-vue-common.mjs", + "@opentiny/vue-locale": "${VITE_CDN_DOMAIN}/@opentiny/vue-runtime${versionDelimiter}~3.20${fileDelimiter}/dist3/tiny-vue-locale.mjs", + "@opentiny/vue-renderless/": "${VITE_CDN_DOMAIN}/@opentiny/vue-renderless${versionDelimiter}~3.20${fileDelimiter}/", "echarts": "${VITE_CDN_DOMAIN}/echarts${versionDelimiter}5.4.1${fileDelimiter}/dist/echarts.esm.js" + }, + "importStyles": { + "@opentiny/vue-theme": "${VITE_CDN_DOMAIN}/@opentiny/vue-theme${versionDelimiter}~3.20${fileDelimiter}/index.css" } } diff --git a/packages/common/js/importMap.js b/packages/common/js/importMap.js new file mode 100644 index 0000000000..844734b797 --- /dev/null +++ b/packages/common/js/importMap.js @@ -0,0 +1 @@ +export { default as importMapConfig } from './import-map.json' diff --git a/packages/common/package.json b/packages/common/package.json index c8b795e9b8..5f9fe7f840 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -52,7 +52,8 @@ "@vitejs/plugin-vue": "^5.1.2", "@vitejs/plugin-vue-jsx": "^4.0.1", "glob": "^10.3.4", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vite-plugin-static-copy": "^1.0.6" }, "peerDependencies": { "@opentiny/vue": "^3.20.0", diff --git a/packages/common/vite.config.ts b/packages/common/vite.config.ts index 49ecd7e4cb..2f183e5ed8 100644 --- a/packages/common/vite.config.ts +++ b/packages/common/vite.config.ts @@ -17,6 +17,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx' import { glob } from 'glob' import { fileURLToPath } from 'node:url' import generateComments from '@opentiny/tiny-engine-vite-plugin-meta-comments' +import { viteStaticCopy } from 'vite-plugin-static-copy' const jsEntries = glob.sync('./js/**/*.js').map((file) => { return [file.slice(0, file.length - path.extname(file).length), fileURLToPath(new URL(file, import.meta.url))] @@ -24,7 +25,20 @@ const jsEntries = glob.sync('./js/**/*.js').map((file) => { // https://vitejs.dev/config/ export default defineConfig({ - plugins: [generateComments(), vue(), vueJsx()], + plugins: [ + generateComments(), + vue(), + vueJsx(), + // 复制 import-map.json到产物,提供给构建插件读取 + viteStaticCopy({ + targets: [ + { + src: './js/import-map.json', + dest: '.' + } + ] + }) + ], publicDir: false, resolve: {}, base: './', diff --git a/packages/design-core/package.json b/packages/design-core/package.json index 98e296d954..daae2ed6d9 100644 --- a/packages/design-core/package.json +++ b/packages/design-core/package.json @@ -116,7 +116,8 @@ "less": "^4.1.2", "lint-staged": "^13.2.0", "rollup-plugin-polyfill-node": "^0.13.0", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vite-plugin-static-copy": "^1.0.6" }, "peerDependencies": { "@opentiny/vue": "^3.20.0", diff --git a/packages/design-core/src/preview/src/preview/http.js b/packages/design-core/src/preview/src/preview/http.js index d354fa4aef..79a8792698 100644 --- a/packages/design-core/src/preview/src/preview/http.js +++ b/packages/design-core/src/preview/src/preview/http.js @@ -36,11 +36,6 @@ export const fetchMetaData = async ({ platform, app, type, id, history, tenant } }) : {} -export const fetchImportMap = async () => { - const baseUrl = new URL(import.meta.env.BASE_URL, location.href) - return fetch(new URL('./preview-import-map-static/preview-importmap.json', baseUrl).href).then((res) => res.json()) -} - export const fetchAppSchema = async (id) => getMetaApi(META_SERVICE.Http).get(`/app-center/v1/api/apps/schema/${id}`) export const fetchBlockSchema = async (blockName) => getMetaApi(META_SERVICE.Http).get(`/material-center/api/block?label=${blockName}`) diff --git a/packages/design-core/src/preview/src/preview/importMap.js b/packages/design-core/src/preview/src/preview/importMap.js index 11e0d70a83..c99c1c117f 100644 --- a/packages/design-core/src/preview/src/preview/importMap.js +++ b/packages/design-core/src/preview/src/preview/importMap.js @@ -11,18 +11,27 @@ */ import { useEnv } from '@opentiny/tiny-engine-meta-register' -import importMapJSON from './importMap.json' +import { importMapConfig as importMapJSON } from '@opentiny/tiny-engine-common/js/importMap' const importMap = {} const opentinyVueVersion = '~3.20' function replacePlaceholder(v) { - const versionDelimiter = useEnv().VITE_CDN_TYPE === 'npmmirror' ? '/' : '@' - const fileDelimiter = useEnv().VITE_CDN_TYPE === 'npmmirror' ? '/files' : '' + const { + VITE_CDN_TYPE, + VITE_CDN_DOMAIN, + VITE_LOCAL_IMPORT_PATH = 'local-cdn-static', + BASE_URL, + VITE_LOCAL_IMPORT_MAPS + } = useEnv() + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + const versionDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' + const fileDelimiter = VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' + const cdnDomain = isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN return v - .replace('${VITE_CDN_DOMAIN}', useEnv().VITE_CDN_DOMAIN) + .replace('${VITE_CDN_DOMAIN}', cdnDomain) .replace('${opentinyVueVersion}', opentinyVueVersion) .replace('${versionDelimiter}', versionDelimiter) .replace('${fileDelimiter}', fileDelimiter) diff --git a/packages/design-core/src/preview/src/preview/srcFiles.js b/packages/design-core/src/preview/src/preview/srcFiles.js index 1d9572b640..a517e70a4d 100644 --- a/packages/design-core/src/preview/src/preview/srcFiles.js +++ b/packages/design-core/src/preview/src/preview/srcFiles.js @@ -10,6 +10,7 @@ * */ +import { useEnv } from '@opentiny/tiny-engine-meta-register' import appVue from './srcFiles/App.vue?raw' import injectGlobalJS from './srcFiles/injectGlobal.js?raw' import constantJS from './srcFiles/constant/index.js?raw' @@ -25,15 +26,20 @@ import storesJS from './srcFiles/stores.js?raw' import storesHelperJS from './srcFiles/storesHelper.js?raw' const srcFiles = {} - -const versionDelimiter = import.meta.env.VITE_CDN_TYPE === 'npmmirror' ? '/' : '@' -const fileDelimiter = import.meta.env.VITE_CDN_TYPE === 'npmmirror' ? '/files' : '' +const isLocalBundle = import.meta.env.VITE_LOCAL_IMPORT_MAPS === 'true' +const versionDelimiter = import.meta.env.VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/' : '@' +const fileDelimiter = import.meta.env.VITE_CDN_TYPE === 'npmmirror' && !isLocalBundle ? '/files' : '' srcFiles['App.vue'] = appVue srcFiles['Main.vue'] = mainVue srcFiles['constant.js'] = constantJS srcFiles['app.js'] = appJS - .replaceAll('${VITE_CDN_DOMAIN}', import.meta.env.VITE_CDN_DOMAIN) + .replaceAll( + '${VITE_CDN_DOMAIN}', + isLocalBundle + ? import.meta.env.BASE_URL + (import.meta.env.VITE_LOCAL_IMPORT_PATH || 'local-cdn-static') + : import.meta.env.VITE_CDN_DOMAIN + ) .replaceAll('${versionDelimiter}', versionDelimiter) .replaceAll('${fileDelimiter}', fileDelimiter) @@ -48,6 +54,9 @@ srcFiles['stores.js'] = storesJS srcFiles['storesHelper.js'] = storesHelperJS export const genPreviewTemplate = () => { + const { VITE_CDN_DOMAIN, VITE_LOCAL_IMPORT_PATH = 'local-cdn-static', BASE_URL, VITE_LOCAL_IMPORT_MAPS } = useEnv() + const isLocalBundle = VITE_LOCAL_IMPORT_MAPS === 'true' + return [ { fileName: 'App.vue', @@ -62,7 +71,10 @@ export const genPreviewTemplate = () => { { fileName: 'app.js', path: '', - fileContent: appJS.replace(/VITE_CDN_DOMAIN/g, import.meta.env.VITE_CDN_DOMAIN) + fileContent: appJS.replace( + /VITE_CDN_DOMAIN/g, + isLocalBundle ? BASE_URL + VITE_LOCAL_IMPORT_PATH : VITE_CDN_DOMAIN + ) }, { fileName: 'injectGlobal.js', diff --git a/packages/design-core/src/preview/src/preview/usePreviewData.ts b/packages/design-core/src/preview/src/preview/usePreviewData.ts index 081920e0a6..632a7e9370 100644 --- a/packages/design-core/src/preview/src/preview/usePreviewData.ts +++ b/packages/design-core/src/preview/src/preview/usePreviewData.ts @@ -4,15 +4,7 @@ import vueJsx from '@vue/babel-plugin-jsx' import { constants } from '@opentiny/tiny-engine-utils' import { getImportMap as getInitImportMap } from './importMap' import { getMetaApi } from '@opentiny/tiny-engine-meta-register' -import { - fetchMetaData, - fetchImportMap, - fetchAppSchema, - fetchBlockSchema, - getPageById, - getBlockById, - fetchPageHistory -} from './http' +import { fetchMetaData, fetchAppSchema, fetchBlockSchema, getPageById, getBlockById, fetchPageHistory } from './http' import { PanelType } from '../constant' import generateMetaFiles, { processAppJsCode } from './generate' import srcFiles from './srcFiles' @@ -154,16 +146,6 @@ const getPageOrBlockByApi = async (): Promise<{ currentPage: IPage | null; ances } } const getImportMap = async (scripts = {}) => { - if (import.meta.env.VITE_LOCAL_BUNDLE_DEPS === 'true') { - const mapJSON = await fetchImportMap() - - return { - imports: { - ...mapJSON.imports, - ...scripts - } - } - } return getInitImportMap(scripts || {}) } diff --git a/packages/design-core/vite.config.js b/packages/design-core/vite.config.js index d39cfe09f5..456a2caf73 100644 --- a/packages/design-core/vite.config.js +++ b/packages/design-core/vite.config.js @@ -6,6 +6,7 @@ import nodeGlobalsPolyfillPluginCjs from '@esbuild-plugins/node-globals-polyfill import nodeModulesPolyfillPluginCjs from '@esbuild-plugins/node-modules-polyfill' import nodePolyfill from 'rollup-plugin-polyfill-node' import { fileURLToPath } from 'node:url' +import { viteStaticCopy } from 'vite-plugin-static-copy' const nodeGlobalsPolyfillPlugin = nodeGlobalsPolyfillPluginCjs.default const nodeModulesPolyfillPlugin = nodeModulesPolyfillPluginCjs.default @@ -31,7 +32,19 @@ const addViteIgnorePlugin = () => { } export default defineConfig({ - plugins: [vue(), vueJsx()], + plugins: [ + vue(), + vueJsx(), + // 复制 import-map.json到产物,提供给构建插件读取 + viteStaticCopy({ + targets: [ + { + src: './node_modules/@opentiny/tiny-engine-common/dist/import-map.json', + dest: '.' + } + ] + }) + ], publicDir: false, optimizeDeps: { esbuildOptions: { @@ -53,7 +66,9 @@ export default defineConfig({ 'import.meta.env.VITE_ORIGIN': 'import.meta.env.VITE_ORIGIN', 'import.meta.env.VITE_CDN_DOMAIN': 'import.meta.env.VITE_CDN_DOMAIN', 'import.meta.env.VITE_API_MOCK': 'import.meta.env.VITE_API_MOCK', - 'import.meta.env.VITE_CDN_TYPE': 'import.meta.env.VITE_CDN_TYPE' + 'import.meta.env.VITE_CDN_TYPE': 'import.meta.env.VITE_CDN_TYPE', + 'import.meta.env.VITE_LOCAL_IMPORT_PATH': 'import.meta.env.VITE_LOCAL_IMPORT_PATH', + 'import.meta.env.VITE_LOCAL_IMPORT_MAPS': 'import.meta.env.VITE_LOCAL_IMPORT_MAPS' }, build: { commonjsOptions: { diff --git a/packages/engine-cli/template/designer/env/.env.alpha b/packages/engine-cli/template/designer/env/.env.alpha index d1784a37aa..dcb2546a5b 100644 --- a/packages/engine-cli/template/designer/env/.env.alpha +++ b/packages/engine-cli/template/designer/env/.env.alpha @@ -5,8 +5,6 @@ NODE_ENV=production VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false # VITE_ORIGIN= # 错误监控上报 url diff --git a/packages/engine-cli/template/designer/env/.env.development b/packages/engine-cli/template/designer/env/.env.development index 2642f040e7..edb637579a 100644 --- a/packages/engine-cli/template/designer/env/.env.development +++ b/packages/engine-cli/template/designer/env/.env.development @@ -5,7 +5,5 @@ NODE_ENV=development VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false # request data via alpha service # VITE_ORIGIN= diff --git a/packages/engine-cli/template/designer/env/.env.production b/packages/engine-cli/template/designer/env/.env.production index 4d9d9f2f36..73e6739cf0 100644 --- a/packages/engine-cli/template/designer/env/.env.production +++ b/packages/engine-cli/template/designer/env/.env.production @@ -5,6 +5,4 @@ NODE_ENV=production VITE_CDN_DOMAIN=https://registry.npmmirror.com # 使用npmmirror的cdn 时,需要声明 VITE_CDN_TYPE=npmmirror VITE_CDN_TYPE=npmmirror -VITE_LOCAL_IMPORT_MAPS=false -VITE_LOCAL_BUNDLE_DEPS=false #VITE_ORIGIN= diff --git a/scripts/updateTemplate.mjs b/scripts/updateTemplate.mjs index ec4d9b12ce..0d991384cd 100644 --- a/scripts/updateTemplate.mjs +++ b/scripts/updateTemplate.mjs @@ -12,7 +12,7 @@ const __dirname = path.dirname(__filename) const templateSrcPath = path.resolve(__dirname, '../designer-demo') const templateDistPath = path.resolve(__dirname, '../packages/engine-cli/template/designer') -const ignoreFolder = ['node_modules', 'dist', 'temp', 'tmp'] +const ignoreFolder = ['node_modules', 'dist', 'temp', 'tmp', 'vitest.config.js', 'tests', 'bundle-deps'] const filter = (src, _dest) => { if (ignoreFolder.some((item) => src.includes(item))) {