diff --git a/.changeset/fresh-pillows-sin.md b/.changeset/fresh-pillows-sin.md new file mode 100644 index 0000000000..dcacea2693 --- /dev/null +++ b/.changeset/fresh-pillows-sin.md @@ -0,0 +1,15 @@ +--- +'@ice/plugin-miniapp': minor +'@ice/app': minor +'@ice/miniapp-loader': minor +'@ice/miniapp-react-dom': minor +'@ice/miniapp-runtime': minor +'@ice/shared': minor +'@ice/shared-config': minor +'@ice/webpack-config': minor +'@ice/rspack-config': minor +'@ice/route-manifest': minor +'@ice/runtime': minor +--- + +feat: improve miniapp runtime diff --git a/.github/workflows/pr-temp.yml b/.github/workflows/pr-temp.yml new file mode 100644 index 0000000000..5ba5cdf1e6 --- /dev/null +++ b/.github/workflows/pr-temp.yml @@ -0,0 +1,36 @@ +name: PR Release + +on: + pull_request: + +jobs: + release: + name: Release + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18] + + steps: + - name: Checkout Branch + uses: actions/checkout@v3 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Setup + run: pnpm run setup + + - name: Config npm + run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - run: pnpm run release:snapshot diff --git a/packages/ice/package.json b/packages/ice/package.json index c0fb1008b6..ced905f3e9 100644 --- a/packages/ice/package.json +++ b/packages/ice/package.json @@ -47,17 +47,18 @@ "bugs": "https://github.com/alibaba/ice/issues", "homepage": "https://v3.ice.work", "dependencies": { - "@ice/bundles": "0.2.7", - "@ice/route-manifest": "1.2.2", - "@ice/runtime": "^1.4.13", - "@ice/shared-config": "1.2.9", - "@ice/webpack-config": "1.1.16", - "@ice/rspack-config": "1.1.10", + "@ice/bundles": "workspace:*", + "@ice/route-manifest": "workspace:*", + "@ice/runtime": "workspace:^", + "@ice/shared-config": "workspace:*", + "@ice/webpack-config": "workspace:*", + "@ice/rspack-config": "workspace:*", "@swc/helpers": "0.5.1", "@types/express": "^4.17.14", "address": "^1.1.2", "build-scripts": "^2.1.2-0", "chalk": "^4.0.0", + "chokidar": "^3.5.3", "commander": "^9.0.0", "consola": "^2.15.3", "cross-spawn": "^7.0.3", @@ -89,7 +90,6 @@ "@types/micromatch": "^4.0.2", "@types/multer": "^1.4.7", "@types/temp": "^0.9.1", - "chokidar": "^3.5.3", "esbuild": "^0.17.16", "jest": "^29.0.2", "react": "^18.2.0", diff --git a/packages/ice/src/bundler/webpack/index.ts b/packages/ice/src/bundler/webpack/index.ts index 943d809700..84a959a0be 100644 --- a/packages/ice/src/bundler/webpack/index.ts +++ b/packages/ice/src/bundler/webpack/index.ts @@ -31,7 +31,7 @@ async function bundler( if (useDevServer) { devServer = await startDevServer(compiler, webpackConfigs, context, options); } else { - await invokeCompilerWatch(compiler, context); + await invokeCompilerWatch(compiler, webpackConfigs, context, options); } } else if (command === 'build') { await build(compiler, webpackConfigs, context, options); diff --git a/packages/ice/src/bundler/webpack/start.ts b/packages/ice/src/bundler/webpack/start.ts index 70d1635ac1..4682bb5bb6 100644 --- a/packages/ice/src/bundler/webpack/start.ts +++ b/packages/ice/src/bundler/webpack/start.ts @@ -118,12 +118,22 @@ export async function startDevServer( export async function invokeCompilerWatch( compiler: webpack.Compiler, + webpackConfigs: Configuration[], context: Context, + options: BundlerOptions, ) { - const { userConfig, rootDir } = context; + const { userConfig, rootDir, applyHook, commandArgs } = context; const { outputDir } = userConfig; + const { taskConfigs, hooksAPI } = options; const absoluteOutputDir = path.resolve(rootDir, outputDir); let messages: { errors: string[]; warnings: string[] }; + await applyHook('before.start.run', { + commandArgs, + taskConfigs, + webpackConfigs, + ...hooksAPI, + }); + let isFirstCompile = true; compiler.watch({ aggregateTimeout: 200, ignored: ['**/node_modules/**', `${absoluteOutputDir}/**`], @@ -139,10 +149,22 @@ export async function invokeCompilerWatch( } else { messages = formatWebpackMessages(stats.toJson({ all: false, warnings: true, errors: true })); } - + const isSuccessful = !messages.errors.length; if (messages.errors.length) { logger.error('Webpack compile error'); throw new Error(messages.errors.join('\n\n')); } + + await applyHook('after.start.compile', { + stats, + isSuccessful, + isFirstCompile, + messages, + taskConfigs, + ...hooksAPI, + }); + if (isSuccessful) { + isFirstCompile = false; + } }); } diff --git a/packages/ice/templates/core/env.ts.ejs b/packages/ice/templates/core/env.ts.ejs index fd177327ad..55af9bdf7c 100644 --- a/packages/ice/templates/core/env.ts.ejs +++ b/packages/ice/templates/core/env.ts.ejs @@ -11,7 +11,7 @@ export const isKuaiShouMiniProgram = isClient && import.meta.target === 'kuaisho export const isWeChatMiniProgram = isClient && import.meta.target === 'wechat-miniprogram'; export const isKraken = isClient && import.meta.target === 'kraken'; export const isQuickApp = false; // Now ice.js will not implement quick app target. -export const isMiniApp = isAliMiniApp || isByteDanceMicroApp || isBaiduSmartProgram || isKuaiShouMiniProgram || isWeChatMiniProgram; +export const isMiniApp = isAliMiniApp;// in universal-env, isMiniApp is equals to isAliMiniApp // Following variables are runtime executed envs. // See also @uni/env. diff --git a/packages/miniapp-loader/package.json b/packages/miniapp-loader/package.json index c156b0d888..4d54c0ff25 100644 --- a/packages/miniapp-loader/package.json +++ b/packages/miniapp-loader/package.json @@ -18,7 +18,7 @@ }, "sideEffects": false, "dependencies": { - "@ice/bundles": "^0.2.0" + "@ice/bundles": "workspace:^" }, "devDependencies": { "webpack": "^5.88.0" diff --git a/packages/miniapp-loader/src/page.ts b/packages/miniapp-loader/src/page.ts index b174b95adb..fc42eb09f1 100644 --- a/packages/miniapp-loader/src/page.ts +++ b/packages/miniapp-loader/src/page.ts @@ -16,9 +16,9 @@ export default function (this: webpack.LoaderContext) { const config = getPageConfig(loaderConfig, this.resourcePath); const configString = JSON.stringify(config); const stringify = (s: string): string => stringifyRequest(this, s); - const { loaders } = this; + const { loaders, resourcePath } = this; const thisLoaderIndex = loaders.findIndex(item => normalizePath(item.path).indexOf('miniapp-loader/lib/page') >= 0); - const componentPath = this.request.split('!').slice(thisLoaderIndex + 1).join('!'); + const componentPath = [...loaders.slice(thisLoaderIndex + 1).map(loader => `${loader.path}${loader.query}`), '!', resourcePath].join('!'); const getDataAndConfigString = `${hasExportConfig ? 'pageConfig, ' : ''}${hasExportData ? 'dataLoader' : ''}`; let instantiatePage = `var inst = Page(createPageConfig(component, '${options.name}', {root:{cn:[]}}, { ${getDataAndConfigString} }, config || {}))`; diff --git a/packages/miniapp-react-dom/package.json b/packages/miniapp-react-dom/package.json index dd4bc5ade9..aef86a5021 100644 --- a/packages/miniapp-react-dom/package.json +++ b/packages/miniapp-react-dom/package.json @@ -23,8 +23,8 @@ }, "sideEffects": false, "dependencies": { - "@ice/miniapp-runtime": "^1.1.2", - "@ice/shared": "^1.0.2", + "@ice/miniapp-runtime": "workspace:^", + "@ice/shared": "workspace:^", "react-reconciler": "0.27.0", "scheduler": "^0.20.1" }, diff --git a/packages/miniapp-runtime/package.json b/packages/miniapp-runtime/package.json index 31210b77be..137870be27 100644 --- a/packages/miniapp-runtime/package.json +++ b/packages/miniapp-runtime/package.json @@ -25,8 +25,8 @@ }, "sideEffects": false, "dependencies": { - "@ice/shared": "^1.0.2", - "@ice/runtime": "^1.2.9", + "@ice/shared": "workspace:^", + "@ice/runtime": "workspace:^", "miniapp-history": "^0.1.7" }, "devDependencies": { diff --git a/packages/miniapp-runtime/src/app/App.tsx b/packages/miniapp-runtime/src/app/App.tsx index 02cd818647..c9163492e6 100644 --- a/packages/miniapp-runtime/src/app/App.tsx +++ b/packages/miniapp-runtime/src/app/App.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { AppErrorBoundary, useAppContext } from '@ice/runtime'; +import { AppErrorBoundary, usePublicAppContext } from '@ice/runtime'; import { AppWrapper } from './connect.js'; export default function App() { - const { appConfig } = useAppContext(); + const { appConfig } = usePublicAppContext(); const { strict, errorBoundary } = appConfig.app; const StrictMode = strict ? React.StrictMode : React.Fragment; const ErrorBoundary = errorBoundary ? AppErrorBoundary : React.Fragment; diff --git a/packages/miniapp-runtime/src/app/connect.tsx b/packages/miniapp-runtime/src/app/connect.tsx index b9ee36c58c..eb65f76f18 100644 --- a/packages/miniapp-runtime/src/app/connect.tsx +++ b/packages/miniapp-runtime/src/app/connect.tsx @@ -70,7 +70,7 @@ export function connectReactPage( }; static getDerivedStateFromError(error: Error) { - triggerAppHook('onError', error.message + error.stack); + triggerAppHook(this, 'onError', error.message + error.stack); return { hasError: true }; } render() { @@ -185,38 +185,38 @@ export class AppWrapper extends React.Component { [ON_LAUNCH]: setDefaultDescriptor({ value(options) { setRouterParams(options); - triggerAppHook('onLaunch', options); + triggerAppHook(this, 'onLaunch', options); }, }), [ON_SHOW]: setDefaultDescriptor({ value(options) { setRouterParams(options); - triggerAppHook('onShow', options); + triggerAppHook(this, 'onShow', options); }, }), [ON_HIDE]: setDefaultDescriptor({ value() { - triggerAppHook('onHide'); + triggerAppHook(this, 'onHide'); }, }), [ON_ERROR]: setDefaultDescriptor({ value(error: string) { - triggerAppHook('onError', error); + triggerAppHook(this, 'onError', error); }, }), [ON_PAGE_NOT_FOUND]: setDefaultDescriptor({ value(res: unknown) { - triggerAppHook('onPageNotFound', res); + triggerAppHook(this, 'onPageNotFound', res); }, }), [ON_UNHANDLED_REJECTION]: setDefaultDescriptor({ value(res: unknown) { - triggerAppHook('onUnhandledRejection', res); + triggerAppHook(this, 'onUnhandledRejection', res); }, }), }); @@ -224,7 +224,7 @@ export class AppWrapper extends React.Component { if (lifecycles.onShareAppMessage) { // Only works in ali miniapp appObj.onShareAppMessage = function (res) { - return triggerAppHook('onShareAppMessage', res); + return triggerAppHook(this, 'onShareAppMessage', res); }; } @@ -232,15 +232,15 @@ export class AppWrapper extends React.Component { return App(appObj); } -function triggerAppHook(lifecycle: keyof PageLifeCycle | keyof AppInstance, ...option): any { +function triggerAppHook(app: unknown, lifecycle: keyof PageLifeCycle | keyof AppInstance, ...option): any { const instance = getPageInstance(HOOKS_APP_ID); if (instance) { const func = hooks.call('getLifecycle', instance, lifecycle); if (Array.isArray(func)) { if (lifecycle === 'onShareAppMessage') { - return func[0].apply(null, option); + return func[0].apply(app, option); } - func.forEach(cb => cb.apply(null, option)); + func.forEach(cb => cb.apply(app, option)); } } } diff --git a/packages/miniapp-runtime/src/app/runClientApp.tsx b/packages/miniapp-runtime/src/app/runClientApp.tsx index 84d100af67..043d4b1542 100644 --- a/packages/miniapp-runtime/src/app/runClientApp.tsx +++ b/packages/miniapp-runtime/src/app/runClientApp.tsx @@ -12,16 +12,19 @@ import { setHistory } from './history.js'; import injectMiniappLifecycles from './injectMiniappLifecycles.js'; export default async function runClientApp(options: RunClientAppOptions) { - const { app, runtimeModules } = options; + const { app, runtimeModules, runtimeOptions } = options; const appConfig = getAppConfig(app); const appContext: AppContext = { appExport: app, appConfig, appData: null, }; - const runtime = new Runtime(appContext); - if (runtimeModules.statics) { - await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean)); + const runtime = new Runtime(appContext, runtimeOptions); + if (runtimeModules.statics && runtimeModules.statics.length > 0) { + const promises = runtimeModules.statics.map(m => runtime.loadModule(m)); + if (promises.some(promise => promise && promise.then)) { + console.warn('Miniapp is not support async static runtime modules'); + } } const { miniappManifest, miniappLifecycles = {} } = app; diff --git a/packages/miniapp-runtime/src/dom/event-target.ts b/packages/miniapp-runtime/src/dom/event-target.ts index a41ba1732b..1d819f6c28 100644 --- a/packages/miniapp-runtime/src/dom/event-target.ts +++ b/packages/miniapp-runtime/src/dom/event-target.ts @@ -92,4 +92,20 @@ export class EventTarget { const isAnyEventBinded = Object.keys(handlers).find(key => handlers[key].length); return Boolean(isAnyEventBinded); } + + public triggerEventListenerInternal(type: string, args: unknown[]) { + type = type.toLowerCase(); + const handlers = this.__handlers[type]; + if (!isArray(handlers)) { + return; + } + for (const handler of handlers) { + handler(...args); + } + } + + public getListenerNames(): string[] { + const handlers = this.__handlers; + return Object.keys(handlers).filter(key => handlers[key].length); + } } diff --git a/packages/miniapp-runtime/src/dom/event.ts b/packages/miniapp-runtime/src/dom/event.ts index 26de05aa00..32c7ad4b70 100644 --- a/packages/miniapp-runtime/src/dom/event.ts +++ b/packages/miniapp-runtime/src/dom/event.ts @@ -14,6 +14,7 @@ import env from '../env.js'; import type { EventOptions, MpEvent } from '../interface/index.js'; import { isParentBinded } from '../utils/index.js'; import type { Element } from './element.js'; +import { type Node } from './node.js'; // 事件对象。以 Web 标准的事件对象为基础,加入小程序事件对象中携带的部分信息,并模拟实现事件冒泡。 export class Event { @@ -179,3 +180,23 @@ export function eventHandler(event: MpEvent) { } } } + +export function createEventHandlerForThirdComponent(sid: string, eventName: string) { + return (...args: unknown[]) => { + const node = env.document.getElementById(sid); + if (node) { + node.triggerEventListenerInternal(eventName, args); + } + }; +} + +export function bindEventHandlersForThirdComponentNode(node: Node) { + const instance = node._root?.ctx; + if (!instance) { + return; + } + const eventNames = node.getListenerNames(); + for (const eventName of eventNames) { + instance[`eh_${node.sid}_${eventName}`] = createEventHandlerForThirdComponent(node.sid, eventName); + } +} diff --git a/packages/miniapp-runtime/src/dom/root.ts b/packages/miniapp-runtime/src/dom/root.ts index 70b53074de..aea2e77851 100644 --- a/packages/miniapp-runtime/src/dom/root.ts +++ b/packages/miniapp-runtime/src/dom/root.ts @@ -1,4 +1,4 @@ -import { isFunction, isUndefined, Shortcuts } from '@ice/shared'; +import { hooks, isFunction, isUndefined, Shortcuts } from '@ice/shared'; import { CUSTOM_WRAPPER, @@ -166,6 +166,7 @@ export class RootElement extends Element { // eslint-disable-next-line no-console console.log('custom wrapper setData: ', data); } + hooks.call('modifySetDataPayload', data, ctx); ctx.setData(data, cb); }); } @@ -176,6 +177,7 @@ export class RootElement extends Element { // eslint-disable-next-line no-console console.log('page setData:', normalUpdate); } + hooks.call('modifySetDataPayload', data, ctx); ctx.setData(normalUpdate, cb); } }, 0); diff --git a/packages/miniapp-runtime/src/hydrate.ts b/packages/miniapp-runtime/src/hydrate.ts index 54f36b1331..544f268f99 100644 --- a/packages/miniapp-runtime/src/hydrate.ts +++ b/packages/miniapp-runtime/src/hydrate.ts @@ -76,16 +76,8 @@ export function hydrate(node: Element | Text): MiniData { } } - let { childNodes } = node; - - // 过滤 comment 节点 - childNodes = childNodes.filter(node => !isComment(node)); - - if (childNodes.length > 0) { - data[Shortcuts.Childnodes] = childNodes.map(hydrate); - } else { - data[Shortcuts.Childnodes] = []; - } + // Children + data[Shortcuts.Childnodes] = node.childNodes.filter(node => !isComment(node)).map(hydrate); if (node.className !== '') { data[Shortcuts.Class] = node.className; @@ -108,6 +100,8 @@ export function hydrate(node: Element | Text): MiniData { delete data[prop]; } } + } else { + hooks.call('hydrateNativeComponentNode', node); } return data; diff --git a/packages/miniapp-runtime/src/index.ts b/packages/miniapp-runtime/src/index.ts index eb14a3cba7..47c478e859 100644 --- a/packages/miniapp-runtime/src/index.ts +++ b/packages/miniapp-runtime/src/index.ts @@ -11,7 +11,7 @@ export { caf as cancelAnimationFrame, now, raf as requestAnimationFrame } from ' export { window } from './bom/window.js'; // dom export { Element } from './dom/element.js'; -export { createEvent, eventHandler, Event } from './dom/event.js'; +export { createEvent, eventHandler, Event, createEventHandlerForThirdComponent, bindEventHandlersForThirdComponentNode } from './dom/event.js'; export { FormElement } from './dom/form.js'; export { Node } from './dom/node.js'; export { RootElement } from './dom/root.js'; diff --git a/packages/miniapp-runtime/src/types.ts b/packages/miniapp-runtime/src/types.ts index 00ded7518c..dd0b786b3d 100644 --- a/packages/miniapp-runtime/src/types.ts +++ b/packages/miniapp-runtime/src/types.ts @@ -1,7 +1,7 @@ /** * 微信小程序全局 Window 配置和页面配置的公共项目 */ - interface CommonConfig { +interface CommonConfig { /** 导航栏背景颜色,HexColor * @default: "#000000" */ @@ -61,6 +61,11 @@ export interface MiniappPageConfig extends CommonConfig { * @default: false */ disableScroll?: boolean; + /** 是否使用页面全局滚动,MPA下默认为全局滚动,SPA默认为局部滚动 + * 只在H5生效 + * @default: MPA:true SPA:false + */ + usingWindowScroll?: boolean; /** 禁止页面右滑手势返回 * * **注意** 自微信客户端 7.0.5 开始,页面配置中的 disableSwipeBack 属性将不再生效, @@ -77,6 +82,12 @@ export interface MiniappPageConfig extends CommonConfig { * @default false */ enableShareTimeline?: boolean; + /** + * 页面是否需要使用 \ 和 \ 组件 + * @default false + * @support weapp, alipay + */ + enablePageMeta?: boolean; /** 页面自定义组件配置 * @see https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/ */ @@ -88,6 +99,63 @@ export interface MiniappPageConfig extends CommonConfig { style?: string; /** 单页模式相关配置 */ singlePage?: SinglePage; + /** + * 事件监听是否为 passive + * @default false + * @see https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html#enablePassiveEvent + */ + enablePassiveEvent?: + | boolean + | { + /** + * 是否设置 touchstart 事件为 passive + * @default false + */ + touchstart?: boolean; + /** + * 是否设置 touchmove 事件为 passive + * @default false + */ + touchmove?: boolean; + /** + * 是否设置 wheel 事件为 passive + * @default false + */ + wheel?: boolean; + }; + /** + * 渲染后端 + * @default "webview" + */ + renderer?: 'webview' | 'skyline'; + /** + * 组件框架 + * @default "exparser" + * @see https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/glass-easel/migration.html + */ + componentFramework?: 'exparser' | 'glass-easel'; + /** + * 指定特殊的样式隔离选项 + * + * isolated 表示启用样式隔离,在自定义组件内外,使用 class 指定的样式将不会相互影响(一般情况下的默认值) + * + * apply-shared 表示页面 wxss 样式将影响到自定义组件,但自定义组件 wxss 中指定的样式不会影响页面 + * + * shared 表示页面 wxss 样式将影响到自定义组件,自定义组件 wxss 中指定的样式也会影响页面和其他设置了 apply-shared 或 shared 的自定义组件。(这个选项在插件中不可用。) + */ + styleIsolation?: 'isolated' | 'apply-shared' | 'shared'; + /** + * 设置导航栏额外图标,目前支持设置属性 icon,值为图标 url(以 https/http 开头)或 base64 字符串,大小建议 30*30 px + * + * 点击后触发 onOptionMenuClick(**注意**:该配置即将废弃。)。 + * @supported alipay + */ + optionMenu?: Record; + /** + * 设置导航栏图标主题,仅支持真机预览。"default" 为蓝色图标,"light" 为白色图标。 + * @supported alipay + */ + barButtonTheme?: string; } interface WindowConfig extends CommonConfig { @@ -221,6 +289,8 @@ interface RouterAnimate { } export interface MiniappAppConfig { + /** 小程序默认启动首页,未指定 entryPagePath 时,数组的第一项代表小程序的初始页面(首页)。 */ + entryPagePath?: string; /** 接受一个数组,每一项都是字符串,来指定小程序由哪些页面组成,数组的第一项代表小程序的初始页面 */ pages?: string[]; /** 全局的默认窗口表现 */ @@ -238,7 +308,11 @@ export interface MiniappAppConfig { * @default false * @since 2.1.0 */ - functionalPages?: boolean; + functionalPages?: + | boolean + | { + independent?: boolean; + }; /** 分包结构配置 * @example * ```json @@ -254,11 +328,12 @@ export interface MiniappAppConfig { * @since 1.7.3 */ subPackages?: SubPackage[]; + subpackages?: SubPackage[]; /** Worker 代码放置的目录 * 使用 Worker 处理多线程任务时,设置 Worker 代码放置的目录 * @since 1.9.90 */ - workers?: string; + workers?: string | string[]; /** 申明需要后台运行的能力,类型为数组。目前支持以下项目: * - audio: 后台音乐播放 * - location: 后台定位 @@ -277,7 +352,16 @@ export interface MiniappAppConfig { * - chooseAddress: 获取用户地址信息 * @see https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html#requiredPrivateInfos */ - requiredPrivateInfos?: ('getFuzzyLocation' | 'getLocation' | 'onLocationChange' | 'startLocationUpdate' | 'startLocationUpdateBackground' | 'chooseLocation' | 'choosePoi' | 'chooseAddress')[]; + requiredPrivateInfos?: ( + | 'getFuzzyLocation' + | 'getLocation' + | 'onLocationChange' + | 'startLocationUpdate' + | 'startLocationUpdateBackground' + | 'chooseLocation' + | 'choosePoi' + | 'chooseAddress' + )[]; /** 使用到的插件 * @since 1.9.6 */ @@ -341,16 +425,14 @@ export interface MiniappAppConfig { */ themeLocation?: string; /** 配置自定义组件代码按需注入 */ - lazyCodeLoading?: string; + lazyCodeLoading?: 'requiredComponents' | string; /** 单页模式相关配置 */ singlePage?: SinglePage; /** 聊天素材小程序打开相关配置 * @see https://developers.weixin.qq.com/miniprogram/dev/framework/material/support_material.html */ supportedMaterials?: { - /** 支持文件类型的MimeType,音频,视频支持二级配置的通配模式,例如: video - 通配模式配置和精确类型配置同时存在时,则优先使用精确类型的配置(例如video/*和video/mp4同时存在,会优先使用video/mp4的配置)。 - */ + /** 支持文件类型的MimeType,音频,视频支持二级配置的通配模式,例如: video/*。通配模式配置和精确类型配置同时存在时,则优先使用精确类型的配置(例如video/*和video/mp4同时存在,会优先使用video/mp4的配置)。 */ materialType: string; /** 开发者配置的标题,在素材页面会展示该标题,配置中必须包含${nickname}, 代码包编译后会自动替换为小程序名称,如果声明了简称则会优先使用简称。除去${nickname}其余字数不得超过6个。 */ name: string; @@ -373,15 +455,32 @@ export interface MiniappAppConfig { /** 是否开启 FPS 面板,默认false */ enableFPSPanel?: boolean; }; - /** touch 事件监听是否为 passive,默认false */ - enablePassiveEvent?: boolean | { - /** 是否设置 touchstart 事件为 passive,默认false */ - touchstart?: boolean; - /** 是否设置 touchmove 事件为 passive,默认false */ - touchmove?: boolean; - /** 是否设置 wheel 事件为 passive,默认false */ - wheel?: boolean; - }; + /** + * touch 相关事件默认的 passive 为 false。如果小程序不使用 catchtouch 事件时,可以通过这个选项将 passive 置为 true,以提高滚动性能。 + * 具体原理可参考[MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners) + * + * 可以直接设置这个选项为 true,也可以分别控制某个事件。 + * @default false + */ + enablePassiveEvent?: + | boolean + | { + /** + * 是否设置 touchstart 事件为 passive + * @default false + */ + touchstart?: boolean; + /** + * 是否设置 touchmove 事件为 passive + * @default false + */ + touchmove?: boolean; + /** + * 是否设置 wheel 事件为 passive + * @default false + */ + wheel?: boolean; + }; /** 自定义模块映射规则 */ resolveAlias?: Record; /** 接受一个数组,每一项都是字符串,来指定编译为原生小程序组件的组件入口 */ @@ -396,9 +495,58 @@ export interface MiniappAppConfig { * @since 3.3.18 */ animation?: RouterAnimate | boolean; + /** + * 指定小程序全局的默认渲染后端。 + * @default "webview" + */ + renderer?: 'webview' | 'skyline'; + /** + * 渲染后端选项 + * @see https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html#rendererOptions + */ + // rendererOptions?: RenderOptions; + /** + * 指定小程序使用的组件框架 + * @default "exparser" + * @see https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/glass-easel/migration.html + */ + componentFramework?: 'exparser' | 'glass-easel'; + /** + * 多端模式场景接入身份管理服务时开启小程序授权页相关配置 + * @see https://dev.weixin.qq.com/docs/framework/getting_started/auth.html#%E6%96%B0%E6%89%8B%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B + */ + miniApp?: { + /** + * 用于 wx.weixinMinProgramLogin 在小程序中插入「小程序授权 Page」 + */ + useAuthorizePage: boolean; + }; + /** + * 在 2023年9月15号之前,在 app.json 中配置 __usePrivacyCheck__: true 后,会启用隐私相关功能,如果不配置或者配置为 false 则不会启用。 + * 在 2023年9月15号之后,不论 app.json 中是否有配置 __usePrivacyCheck__,隐私相关功能都会启用 + * @supported weapp + */ + __usePrivacyCheck__?: boolean; + /** + * 正常情况下默认所有资源文件都被打包发布到所有平台,可以通过 static 字段配置特定每个目录/文件只能发布到特定的平台(多端场景) + * @see https://dev.weixin.qq.com/docs/framework/guideline/devtools/condition-compile.html#%E8%B5%84%E6%BA%90 + */ + static?: { pattern: string; platforms: string[] }[]; + /** + * 动态插件配置规则,声明小程序需要使用动态插件 + * @supported alipay + */ + useDynamicPlugins?: boolean; + /** + * 用于改变小程序若干运行行为 + * @supported alipay + */ + // behavior?: Behavior; } -export interface MiniappConfig extends MiniappPageConfig, MiniappAppConfig {} +export interface MiniappConfig extends MiniappPageConfig, MiniappAppConfig { + cloud?: boolean; +} export interface MiniappLifecycles { onLaunch?: (options: any) => void; @@ -408,5 +556,6 @@ export interface MiniappLifecycles { onPageNotFound?: (options: any) => void; onUnhandledRejection?: (options: any) => void; onShareAppMessage?: (options: any) => Record; + [key: string]: any; } diff --git a/packages/plugin-miniapp/README.md b/packages/plugin-miniapp/README.md index e3558935eb..1f342d17e2 100644 --- a/packages/plugin-miniapp/README.md +++ b/packages/plugin-miniapp/README.md @@ -1,5 +1,9 @@ # @ice/plugin-miniapp +> Forked from [taro](https://github.com/NervJS/taro) with respect ❤️ +> Licensed under the MIT License +> https://github.com/NervJS/taro/blob/main/LICENSE + ice.js plugin to enable miniapp development. See [documentation](https://v3.ice.work) for more details. diff --git a/packages/plugin-miniapp/package.json b/packages/plugin-miniapp/package.json index 7cf04be161..a8297ec87c 100644 --- a/packages/plugin-miniapp/package.json +++ b/packages/plugin-miniapp/package.json @@ -19,7 +19,8 @@ "types": "./esm/runtime/index.d.ts", "import": "./esm/runtime/index.js", "default": "./esm/runtime/index.js" - } + }, + "./targets/*": "./esm/targets/*" }, "main": "./esm/index.js", "types": "./esm/index.d.ts", @@ -33,15 +34,17 @@ "build": "tsc" }, "dependencies": { - "@ice/bundles": "^0.2.0", - "@ice/miniapp-loader": "^1.1.2", - "@ice/miniapp-react-dom": "^1.0.2", - "@ice/miniapp-runtime": "^1.1.2", - "@ice/shared": "^1.0.2", + "@ice/bundles": "workspace:*", + "@ice/miniapp-loader": "workspace:*", + "@ice/miniapp-react-dom": "workspace:*", + "@ice/miniapp-runtime": "workspace:*", + "@ice/route-manifest": "workspace:*", + "@ice/shared": "workspace:*", "acorn-walk": "^8.2.0", "chalk": "^4.0.0", "consola": "^2.15.3", "fast-glob": "^3.2.11", + "fs-extra": "^10.0.0", "html-minifier": "^4.0.0", "regenerator-runtime": "^0.11.0", "sax": "^1.2.4" diff --git a/packages/plugin-miniapp/src/constant.ts b/packages/plugin-miniapp/src/constant.ts index 68e2a80440..98e6b766fe 100644 --- a/packages/plugin-miniapp/src/constant.ts +++ b/packages/plugin-miniapp/src/constant.ts @@ -6,30 +6,18 @@ export const BAIDU_SMARTPROGRAM = 'baidu-smartprogram'; export const KUAISHOU_MINIPROGRAM = 'kuaishou-miniprogram'; export const MINIAPP_TARGETS = [ - ALI_MINIAPP, WECHAT_MINIPROGRAM, BYTEDANCE_MICROAPP, - BAIDU_SMARTPROGRAM, KUAISHOU_MINIPROGRAM, + ALI_MINIAPP, + WECHAT_MINIPROGRAM, + BYTEDANCE_MICROAPP, + BAIDU_SMARTPROGRAM, + KUAISHOU_MINIPROGRAM, ]; -export const ALL_TARGETS = [ - WEB, - ...MINIAPP_TARGETS, -]; - -export const REG_TEMPLATE = /\.(wxml|axml|ttml|swan|ksml)(\?.*)?$/; -export const REG_STYLE = /\.(css|scss|sass|less|styl|stylus|wxss|acss|ttss)(\?.*)?$/; - -export const NODE_MODULES_REG = /(.*)node_modules/; - -export enum META_TYPE { - ENTRY = 'ENTRY', - PAGE = 'PAGE', - COMPONENT = 'COMPONENT', - NORMAL = 'NORMAL', - STATIC = 'STATIC', - CONFIG = 'CONFIG', - EXPORTS = 'EXPORTS', -} - -export const JS_EXT: string[] = ['.js', '.jsx']; -export const TS_EXT: string[] = ['.ts', '.tsx']; -export const SCRIPT_EXT: string[] = JS_EXT.concat(TS_EXT); +export const ALL_TARGETS = [WEB, ...MINIAPP_TARGETS]; +export const MINIAPP_TARGET_FOLDER_NAMES = { + [ALI_MINIAPP]: 'ali', + [WECHAT_MINIPROGRAM]: 'wechat', + [BYTEDANCE_MICROAPP]: 'bytedance', + [BAIDU_SMARTPROGRAM]: 'baidu', + // TODO: add kuaishou +}; diff --git a/packages/plugin-miniapp/src/helper/constants.ts b/packages/plugin-miniapp/src/helper/constants.ts new file mode 100644 index 0000000000..214ab19180 --- /dev/null +++ b/packages/plugin-miniapp/src/helper/constants.ts @@ -0,0 +1,166 @@ +import * as os from 'node:os'; + +import { chalk } from './terminal.js'; + +// eslint-disable-next-line dot-notation +export const PLATFORMS = (global['PLATFORMS'] = global['PLATFORMS'] || {}); + +export const enum processTypeEnum { + START = 'start', + CREATE = 'create', + COMPILE = 'compile', + CONVERT = 'convert', + COPY = 'copy', + GENERATE = 'generate', + MODIFY = 'modify', + ERROR = 'error', + WARNING = 'warning', + UNLINK = 'unlink', + REFERENCE = 'reference', + REMIND = 'remind', +} + +export interface IProcessTypeMap { + [key: string]: { + name: string; + color: string | chalk.Chalk; + }; +} + +export const processTypeMap: IProcessTypeMap = { + [processTypeEnum.CREATE]: { + name: '创建', + color: 'cyan', + }, + [processTypeEnum.COMPILE]: { + name: '编译', + color: 'green', + }, + [processTypeEnum.CONVERT]: { + name: '转换', + color: chalk.rgb(255, 136, 0), + }, + [processTypeEnum.COPY]: { + name: '拷贝', + color: 'magenta', + }, + [processTypeEnum.GENERATE]: { + name: '生成', + color: 'blue', + }, + [processTypeEnum.MODIFY]: { + name: '修改', + color: 'yellow', + }, + [processTypeEnum.ERROR]: { + name: '错误', + color: 'red', + }, + [processTypeEnum.WARNING]: { + name: '警告', + color: 'yellowBright', + }, + [processTypeEnum.UNLINK]: { + name: '删除', + color: 'magenta', + }, + [processTypeEnum.START]: { + name: '启动', + color: 'green', + }, + [processTypeEnum.REFERENCE]: { + name: '引用', + color: 'blue', + }, + [processTypeEnum.REMIND]: { + name: '提示', + color: 'green', + }, +}; + +export const CSS_EXT: string[] = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus', '.wxss', '.acss']; +export const SCSS_EXT: string[] = ['.scss']; +export const JS_EXT: string[] = ['.js', '.jsx']; +export const TS_EXT: string[] = ['.ts', '.tsx']; +export const UX_EXT: string[] = ['.ux']; +export const SCRIPT_EXT: string[] = JS_EXT.concat(TS_EXT); +export const VUE_EXT: string[] = ['.vue']; + +export const REG_JS = /\.m?js(\?.*)?$/; +export const REG_SCRIPT = /\.m?(js|jsx)(\?.*)?$/; +export const REG_TYPESCRIPT = /\.(tsx|ts)(\?.*)?$/; +export const REG_SCRIPTS = /\.m?[tj]sx?$/i; +export const REG_VUE = /\.vue$/i; +export const REG_SASS = /\.(s[ac]ss)$/; +export const REG_SASS_SASS = /\.sass$/; +export const REG_SASS_SCSS = /\.scss$/; +export const REG_LESS = /\.less$/; +export const REG_STYLUS = /\.styl(us)?$/; +export const REG_STYLE = /\.(css|scss|sass|less|styl|stylus|wxss|acss|ttss|jxss|qss)(\?.*)?$/; +export const REG_CSS = /\.(css|qss|jxss|wxss|acss|ttss)(\?.*)?$/; +export const REG_MEDIA = /\.(mp4|webm|ogg|mp3|m4a|wav|flac|aac)(\?.*)?$/; +export const REG_IMAGE = /\.(png|jpe?g|gif|bpm|svg|webp)(\?.*)?$/; +export const REG_FONT = /\.(woff2?|eot|ttf|otf)(\?.*)?$/; +export const REG_JSON = /\.json(\?.*)?$/; +export const REG_UX = /\.ux(\?.*)?$/; +export const REG_TEMPLATE = /\.(wxml|axml|ttml|qml|swan|jxml)(\?.*)?$/; +export const REG_WXML_IMPORT = / REG_NODE_MODULES.test(filename); + +export function isNpmPkg(name: string): boolean { + if (/^(\.|\/)/.test(name)) { + return false; + } + return true; +} + +export function isQuickAppPkg(name: string): boolean { + return /^@(system|service)\.[a-zA-Z]{1,}/.test(name); +} + +export function isAliasPath(name: string, pathAlias: Record = {}): boolean { + const prefixes = Object.keys(pathAlias); + if (prefixes.length === 0) { + return false; + } + return prefixes.includes(name) || new RegExp(`^(${prefixes.join('|')})/`).test(name); +} + +export function replaceAliasPath(filePath: string, name: string, pathAlias: Record = {}) { + // 后续的 path.join 在遇到符号链接时将会解析为真实路径,如果 + // 这里的 filePath 没有做同样的处理,可能会导致 import 指向 + // 源代码文件,导致文件被意外修改 + filePath = fs.realpathSync(filePath); + + const prefixes = Object.keys(pathAlias); + if (prefixes.includes(name)) { + return promoteRelativePath(path.relative(filePath, fs.realpathSync(resolveScriptPath(pathAlias[name])))); + } + const reg = new RegExp(`^(${prefixes.join('|')})/(.*)`); + name = name.replace(reg, (_m, $1, $2) => { + return promoteRelativePath(path.relative(filePath, path.join(pathAlias[$1], $2))); + }); + return name; +} + +export function promoteRelativePath(fPath: string): string { + const fPathArr = fPath.split(path.sep); + let dotCount = 0; + fPathArr.forEach((item) => { + if (item.indexOf('..') >= 0) { + dotCount++; + } + }); + if (dotCount === 1) { + fPathArr.splice(0, 1, '.'); + return fPathArr.join('/'); + } + if (dotCount > 1) { + fPathArr.splice(0, 1); + return fPathArr.join('/'); + } + return normalizePath(fPath); +} + +export function resolveStylePath(p: string): string { + const realPath = p; + const removeExtPath = p.replace(path.extname(p), ''); + const taroEnv = process.env.TARO_ENV; + for (let i = 0; i < CSS_EXT.length; i++) { + const item = CSS_EXT[i]; + if (taroEnv) { + if (fs.existsSync(`${removeExtPath}.${taroEnv}${item}`)) { + return `${removeExtPath}.${taroEnv}${item}`; + } + } + if (fs.existsSync(`${p}${item}`)) { + return `${p}${item}`; + } + } + return realPath; +} + +export function printLog(type: processTypeEnum, tag: string, filePath?: string) { + const typeShow = processTypeMap[type]; + const tagLen = tag.replace(/[\u0391-\uFFE5]/g, 'aa').length; + const tagFormatLen = 8; + if (tagLen < tagFormatLen) { + const rightPadding = new Array(tagFormatLen - tagLen + 1).join(' '); + tag += rightPadding; + } + const padding = ''; + filePath = filePath || ''; + if (typeof typeShow.color === 'string') { + console.log(chalk[typeShow.color](typeShow.name), padding, tag, padding, filePath); + } else { + console.log(typeShow.color(typeShow.name), padding, tag, padding, filePath); + } +} + +export function recursiveFindNodeModules(filePath: string, lastFindPath?: string): string { + const findWorkspaceRoot = require('find-yarn-workspace-root'); + if (lastFindPath && normalizePath(filePath) === normalizePath(lastFindPath)) { + return filePath; + } + const dirname = path.dirname(filePath); + const workspaceRoot = findWorkspaceRoot(dirname); + const nodeModules = path.join(workspaceRoot || dirname, 'node_modules'); + if (fs.existsSync(nodeModules)) { + return nodeModules; + } + if (dirname.split(path.sep).length <= 1) { + printLog(processTypeEnum.ERROR, `在${dirname}目录下`, '未找到node_modules文件夹,请先安装相关依赖库!'); + return nodeModules; + } + return recursiveFindNodeModules(dirname, filePath); +} + +export function getUserHomeDir(): string { + function homedir(): string { + const { env } = process; + const home = env.HOME; + const user = env.LOGNAME || env.USER || env.LNAME || env.USERNAME; + + if (process.platform === 'win32') { + return env.USERPROFILE || `${env.HOMEDRIVE}${env.HOMEPATH}` || home || ''; + } + + if (process.platform === 'darwin') { + return home || (user ? `/Users/${user}` : ''); + } + + if (process.platform === 'linux') { + return home || (process.getuid?.() === 0 ? '/root' : user ? `/home/${user}` : ''); + } + + return home || ''; + } + return typeof (os.homedir as (() => string) | undefined) === 'function' ? os.homedir() : homedir(); +} + +export function getHash(text: Buffer | string): string { + return createHash('sha256').update(text).digest('hex').substring(0, 8); +} + +export function getSystemUsername(): string { + const userHome = getUserHomeDir(); + const systemUsername = process.env.USER || path.basename(userHome); + return systemUsername; +} + +export function shouldUseYarn(): boolean { + try { + execSync('yarn --version', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +export function shouldUseCnpm(): boolean { + try { + execSync('cnpm --version', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +export function isEmptyObject(obj: any): boolean { + if (obj == null) { + return true; + } + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + return false; + } + } + return true; +} + +// export function resolveSync(id: string, opts: TResolve.SyncOpts & { mainFields?: string[] } = {}): string | null { +// try { +// const resolve = require('resolve').sync as (typeof TResolve)['sync']; +// return resolve(id, { +// ...opts, +// packageFilter(pkg, pkgfile, dir) { +// if (opts.packageFilter) { +// pkg = opts.packageFilter(pkg, pkgfile, dir); +// } else if (opts.mainFields?.length) { +// pkg.main = pkg[opts.mainFields.find((field) => pkg[field] && typeof pkg[field] === 'string') || 'main']; +// } +// return pkg; +// }, +// }); +// } catch (error) { +// return null; +// } +// } + +export function resolveMainFilePath(p: string, extArrs = SCRIPT_EXT): string { + if (p.startsWith('pages/') || p === 'app.config') { + return p; + } + const realPath = p; + const taroEnv = process.env.TARO_ENV; + for (let i = 0; i < extArrs.length; i++) { + const item = extArrs[i]; + if (taroEnv) { + if (fs.existsSync(`${p}.${taroEnv}${item}`)) { + return `${p}.${taroEnv}${item}`; + } + if (fs.existsSync(`${p}${path.sep}index.${taroEnv}${item}`)) { + return `${p}${path.sep}index.${taroEnv}${item}`; + } + if (fs.existsSync(`${p.replace(/\/index$/, `.${taroEnv}/index`)}${item}`)) { + return `${p.replace(/\/index$/, `.${taroEnv}/index`)}${item}`; + } + } + if (fs.existsSync(`${p}${item}`)) { + return `${p}${item}`; + } + if (fs.existsSync(`${p}${path.sep}index${item}`)) { + return `${p}${path.sep}index${item}`; + } + } + // 存在多端页面但是对应的多端页面配置不存在时,使用该页面默认配置 + if (taroEnv && path.parse(p).base.endsWith(`.${taroEnv}.config`)) { + const idx = p.lastIndexOf(`.${taroEnv}.config`); + return resolveMainFilePath(`${p.slice(0, idx)}.config`); + } + return realPath; +} + +export function resolveScriptPath(p: string): string { + return resolveMainFilePath(p); +} + +export function generateEnvList(env: Record): Record { + const res = {}; + if (env && !isEmptyObject(env)) { + for (const key in env) { + try { + res[`process.env.${key}`] = JSON.parse(env[key]); + } catch (err) { + res[`process.env.${key}`] = env[key]; + } + } + } + return res; +} + +/** + * 获取 npm 文件或者依赖的绝对路径 + * + * @param {string} 参数1 - 组件路径 + * @param {string} 参数2 - 文件扩展名 + * @returns {string} npm 文件绝对路径 + */ +export function getNpmPackageAbsolutePath(npmPath: string, defaultFile = 'index'): string | null { + try { + let packageName = ''; + let componentRelativePath = ''; + const packageParts = npmPath.split(path.sep); + + // 获取 npm 包名和指定的包文件路径 + // taro-loader/path/index => packageName = taro-loader, componentRelativePath = path/index + // @tarojs/runtime/path/index => packageName = @tarojs/runtime, componentRelativePath = path/index + if (npmPath.startsWith('@')) { + packageName = packageParts.slice(0, 2).join(path.sep); + componentRelativePath = packageParts.slice(2).join(path.sep); + } else { + packageName = packageParts[0]; + componentRelativePath = packageParts.slice(1).join(path.sep); + } + + // 没有指定的包文件路径统一使用 defaultFile + componentRelativePath ||= defaultFile; + // require.resolve 解析的路径会包含入口文件路径,通过正则过滤一下 + const packageJsonPath = require.resolve(path.join(packageName, 'package.json')); + const packageDir = path.dirname(packageJsonPath); + const packageJson = require(packageJsonPath); + + if (packageJson.miniprogram) { + return path.join(packageDir, packageJson.miniprogram, componentRelativePath); + } + + return path.join(packageDir, `./${componentRelativePath}`); + } catch (error) { + return null; + } +} + +export function generateConstantsList(constants: Record): Record { + const res = {}; + if (constants && !isEmptyObject(constants)) { + for (const key in constants) { + if (isPlainObject(constants[key])) { + res[key] = generateConstantsList(constants[key]); + } else { + try { + res[key] = JSON.parse(constants[key]); + } catch (err) { + res[key] = constants[key]; + } + } + } + } + return res; +} + +export function cssImports(content: string): string[] { + const results: string[] = []; + const cssImportRegx = new RegExp(REG_CSS_IMPORT); + let match: RegExpExecArray | null; + + content = String(content).replace(/\/\*.+?\*\/|\/\/.*(?=[\n\r])/g, ''); + + while ((match = cssImportRegx.exec(content))) { + results.push(match[2]); + } + + return results; +} + +/*eslint-disable*/ +const retries = process.platform === 'win32' ? 100 : 1; +export function emptyDirectory( + dirPath: string, + opts: { excludes: Array | string | RegExp } = { excludes: [] }, +) { + if (fs.existsSync(dirPath)) { + fs.readdirSync(dirPath).forEach((file) => { + const curPath = path.join(dirPath, file); + if (fs.lstatSync(curPath).isDirectory()) { + let removed = false; + let i = 0; // retry counter + do { + try { + const excludes = Array.isArray(opts.excludes) ? opts.excludes : [opts.excludes]; + const canRemove = + !excludes.length || + !excludes.some((item) => (typeof item === 'string' ? curPath.indexOf(item) >= 0 : item.test(curPath))); + if (canRemove) { + emptyDirectory(curPath); + fs.rmdirSync(curPath); + } + removed = true; + } catch (e) { + } finally { + if (++i < retries) { + continue; + } + } + } while (!removed); + } else { + fs.unlinkSync(curPath); + } + }); + } +} +/* eslint-enable */ + +export const pascalCase: (str: string) => string = (str: string): string => + str.charAt(0).toUpperCase() + camelCase(str.substr(1)); + +// export function getInstalledNpmPkgPath(pkgName: string, basedir: string): string | null { +// try { +// const resolve = require('resolve').sync as (typeof TResolve)['sync']; +// return resolve(`${pkgName}/package.json`, { basedir }); +// } catch (err) { +// return null; +// } +// } + +// export function getInstalledNpmPkgVersion(pkgName: string, basedir: string): string | null { +// const pkgPath = getInstalledNpmPkgPath(pkgName, basedir); +// if (!pkgPath) { +// return null; +// } +// return fs.readJSONSync(pkgPath).version; +// } + +export const recursiveMerge = (src: Partial, ...args: (Partial | undefined)[]) => { + return mergeWith(src, ...args, (value, srcValue) => { + const typeValue = typeof value; + const typeSrcValue = typeof srcValue; + if (typeValue !== typeSrcValue) return; + if (Array.isArray(value) && Array.isArray(srcValue)) { + return value.concat(srcValue); + } + if (typeValue === 'object') { + return recursiveMerge(value, srcValue); + } + }); +}; + +export const mergeVisitors = (src, ...args) => { + const validFuncs = ['exit', 'enter']; + return mergeWith(src, ...args, (value, srcValue, key, object, srcObject) => { + if (!object.hasOwnProperty(key) || !srcObject.hasOwnProperty(key)) { + return undefined; + } + + const shouldMergeToArray = validFuncs.indexOf(key) > -1; + if (shouldMergeToArray) { + return flatMap([value, srcValue]); + } + const [newValue, newSrcValue] = [value, srcValue].map((v) => { + if (typeof v === 'function') { + return { + enter: v, + }; + } else { + return v; + } + }); + return mergeVisitors(newValue, newSrcValue); + }); +}; + +export const applyArrayedVisitors = (obj) => { + let key; + for (key in obj) { + const funcs = obj[key]; + if (Array.isArray(funcs)) { + obj[key] = (astPath, ...args) => { + funcs.forEach((func) => { + func(astPath, ...args); + }); + }; + } else if (typeof funcs === 'object') { + applyArrayedVisitors(funcs); + } + } + return obj; +}; + +export const getAllFilesInFolder = async (folder: string, filter: string[] = []): Promise => { + let files: string[] = []; + const list = readDirWithFileTypes(folder); + + await Promise.all( + list.map(async (item) => { + const itemPath = path.join(folder, item.name); + if (item.isDirectory) { + const _files = await getAllFilesInFolder(itemPath, filter); + files = [...files, ..._files]; + } else if (item.isFile) { + if (!filter.find((rule) => rule === item.name)) files.push(itemPath); + } + }), + ); + + return files; +}; + +export interface FileStat { + name: string; + isDirectory: boolean; + isFile: boolean; +} + +export function readDirWithFileTypes(folder: string): FileStat[] { + const list = fs.readdirSync(folder); + const res = list.map((name) => { + const stat = fs.statSync(path.join(folder, name)); + return { + name, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + }; + }); + return res; +} + +export function extnameExpRegOf(filePath: string): RegExp { + return new RegExp(`${path.extname(filePath)}$`); +} + +export function addPlatforms(platform: string) { + const upperPlatform = platform.toLocaleUpperCase(); + if (PLATFORMS[upperPlatform]) return; + PLATFORMS[upperPlatform] = platform; +} + +export const getModuleDefaultExport = (exports) => (exports.__esModule ? exports.default : exports); + +export function removeHeadSlash(str: string) { + return str.replace(/^(\/|\\)/, ''); +} + +// converts ast nodes to js object +function exprToObject(node: any) { + const types = ['BooleanLiteral', 'StringLiteral', 'NumericLiteral']; + + if (types.includes(node.type)) { + return node.value; + } + + if (node.name === 'undefined' && !node.value) { + return undefined; + } + + if (node.type === 'NullLiteral') { + return null; + } + + if (node.type === 'ObjectExpression') { + return genProps(node.properties); + } + + if (node.type === 'ArrayExpression') { + return node.elements.reduce( + (acc: any, el: any) => [ + ...acc, + ...(el!.type === 'SpreadElement' ? exprToObject(el.argument) : [exprToObject(el)]), + ], + [], + ); + } +} + +// converts ObjectExpressions to js object +function genProps(props: any[]) { + return props.reduce((acc, prop) => { + if (prop.type === 'SpreadElement') { + return { + ...acc, + ...exprToObject(prop.argument), + }; + } else if (prop.type !== 'ObjectMethod') { + const v = exprToObject(prop.value); + if (v !== undefined) { + return { + ...acc, + [prop.key.name || prop.key.value]: v, + }; + } + } + return acc; + }, {}); +} + +// read page config from a sfc file instead of the regular config file +// function readSFCPageConfig(configPath: string) { +// if (!fs.existsSync(configPath)) return {}; +// +// const sfcSource = fs.readFileSync(configPath, 'utf8'); +// const dpcReg = /definePageConfig\(\{[\w\W]+?\}\)/g; +// const matches = sfcSource.match(dpcReg); +// +// let result: any = {}; +// +// if (matches && matches.length === 1) { +// const callExprHandler = (p: any) => { +// const { callee } = p.node; +// if (!callee.name) return; +// if (callee.name && callee.name !== 'definePageConfig') return; +// +// const configNode = p.node.arguments[0]; +// result = exprToObject(configNode); +// p.stop(); +// }; +// const configSource = matches[0]; +// const ast = babel.parse(configSource, { filename: '' }) as babel.ParseResult; +// +// babel.traverse(ast.program, { CallExpression: callExprHandler }); +// } +// +// return result; +// } + +// export function readPageConfig(configPath: string) { +// let result: any = {}; +// const extNames = ['.js', '.jsx', '.ts', '.tsx', '.vue']; +// +// // check source file extension +// extNames.some((ext) => { +// const tempPath = configPath.replace('.config', ext); +// if (fs.existsSync(tempPath)) { +// try { +// result = readSFCPageConfig(tempPath); +// } catch (error) { +// result = {}; +// } +// return true; +// } +// }); +// return result; +// } + +// interface IReadConfigOptions { +// defineConstants?: Record; +// alias?: Record; +// } +// NOTE: 请使用 combination.readConfig 代替 +// export function readConfig(configPath: string, options: T = {} as T) { +// let result: any = {}; +// if (fs.existsSync(configPath)) { +// if (REG_JSON.test(configPath)) { +// result = fs.readJSONSync(configPath); +// } else { +// result = requireWithEsbuild(configPath, { +// customConfig: { +// alias: options.alias || {}, +// define: defaults({}, options.defineConstants || {}, { +// define: 'define', // Note: 该场景下不支持 AMD 导出,这会导致 esbuild 替换 babel 的 define 方法 +// }), +// }, +// customSwcConfig: { +// jsc: { +// parser: { +// syntax: 'typescript', +// decorators: true, +// }, +// transform: { +// legacyDecorator: true, +// }, +// experimental: { +// plugins: [[path.resolve(__dirname, '../swc/swc_plugin_define_config.wasm'), {}]], +// }, +// }, +// module: { +// type: 'commonjs', +// }, +// }, +// }); +// } +// +// result = getModuleDefaultExport(result); +// } else { +// result = readPageConfig(configPath); +// } +// return result; +// } + +// 去除路径前缀,比如 /, ./ +export function removePathPrefix(filePath = '') { + const normalizedPath = path.normalize(filePath); + const parsedPath = path.parse(normalizedPath); + const { root, dir, base } = parsedPath; + + let result = path.join(dir, base); + + if (result.startsWith(root)) { + result = result.slice(root.length); + } + + return result; +} + +export { fs }; + +// 集中引入 babel 工具箱,供编译时使用 +// export const babelKit = { +// types: t, +// parse: babelParser.parse, +// generate: babelGenerator, +// traverse: babelTraverse, +// }; diff --git a/packages/plugin-miniapp/src/index.ts b/packages/plugin-miniapp/src/index.ts index b4ddc73336..e4fa119e81 100644 --- a/packages/plugin-miniapp/src/index.ts +++ b/packages/plugin-miniapp/src/index.ts @@ -4,8 +4,7 @@ import consola from 'consola'; import chalk from 'chalk'; import type { Plugin } from '@ice/app/esm/types'; import getMiniappTask from './miniapp/index.js'; -import { MINIAPP_TARGETS } from './constant.js'; - +import { MINIAPP_TARGET_FOLDER_NAMES, MINIAPP_TARGETS } from './constant.js'; interface MiniappOptions { // TODO: specify the config type of native. @@ -18,7 +17,7 @@ const { name: PLUGIN_NAME } = packageJSON; const plugin: Plugin = (miniappOptions = {}) => ({ name: PLUGIN_NAME, - setup: ({ registerTask, onHook, context, generator }) => { + setup: ({ registerTask, onHook, context, generator, modifyUserConfig }) => { const { nativeConfig = {} } = miniappOptions; const { commandArgs, rootDir, command } = context; const { target } = commandArgs; @@ -54,26 +53,34 @@ const plugin: Plugin = (miniappOptions = {}) => ({ exports: importSpecifiers.join(',\n'), }, }); + generator.addRuntimeOptions({ + source: `${PLUGIN_NAME}/targets/${MINIAPP_TARGET_FOLDER_NAMES[target]}/runtime.js`, + }); + modifyUserConfig('optimization', { + router: false, + disableRouter: true, + }); // Get server compiler by hooks onHook(`before.${command as 'start' | 'build'}.run`, async ({ getAppConfig, getRoutesConfig }) => { configAPI.getAppConfig = getAppConfig; configAPI.getRoutesConfig = getRoutesConfig; }); - registerTask('miniapp', getMiniappTask({ - rootDir, - command, - target, - configAPI, - runtimeDir: '.ice', - nativeConfig, - })); + registerTask( + 'miniapp', + getMiniappTask({ + rootDir, + command, + target, + configAPI, + runtimeDir: '.ice', + nativeConfig, + }), + ); onHook(`after.${command as 'start' | 'build'}.compile`, async ({ isSuccessful, isFirstCompile }) => { const shouldShowLog = isSuccessful && ((command === 'start' && isFirstCompile) || command === 'build'); if (shouldShowLog) { - const outputDir = context.userConfig?.outputDir || 'build'; let logoutMessage = '\n'; - logoutMessage += chalk.green(`Use ${target} developer tools to open the following folder:`); - logoutMessage += `\n${chalk.underline.white(path.join(rootDir, outputDir))}\n`; + logoutMessage += chalk.green(`Use ${target} developer tools to open the this project or build folder`); consola.log(`${logoutMessage}\n`); } }); diff --git a/packages/plugin-miniapp/src/miniapp/html/index.ts b/packages/plugin-miniapp/src/miniapp/html/index.ts index 73941fbc6e..6402a6df7b 100644 --- a/packages/plugin-miniapp/src/miniapp/html/index.ts +++ b/packages/plugin-miniapp/src/miniapp/html/index.ts @@ -7,17 +7,100 @@ interface OnParseCreateElementArgs { componentConfig: ComponentConfig; } -const inlineElements = ['i', 'abbr', 'select', 'acronym', 'small', 'bdi', 'kbd', 'strong', 'big', 'sub', 'sup', 'br', 'mark', 'meter', 'template', 'cite', 'object', 'time', 'code', 'output', 'u', 'data', 'picture', 'tt', 'datalist', 'var', 'dfn', 'del', 'q', 'em', 's', 'embed', 'samp', 'b']; -const blockElements = ['body', 'svg', 'address', 'fieldset', 'li', 'span', 'article', 'figcaption', 'main', 'aside', 'figure', 'nav', 'blockquote', 'footer', 'ol', 'details', 'p', 'dialog', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'dd', 'header', 'section', 'div', 'hgroup', 'table', 'dl', 'hr', 'ul', 'dt', 'view', 'view-block']; -const specialElements = ['slot', 'form', 'iframe', 'img', 'audio', 'video', 'canvas', 'a', 'input', 'label', 'textarea', 'progress', 'button']; +const inlineElements = [ + 'i', + 'abbr', + 'select', + 'acronym', + 'small', + 'bdi', + 'kbd', + 'strong', + 'big', + 'sub', + 'sup', + 'br', + 'mark', + 'meter', + 'template', + 'cite', + 'object', + 'time', + 'code', + 'output', + 'u', + 'data', + 'picture', + 'tt', + 'datalist', + 'var', + 'dfn', + 'del', + 'q', + 'em', + 's', + 'embed', + 'samp', + 'b', +]; +const blockElements = [ + 'body', + 'svg', + 'address', + 'fieldset', + 'li', + 'span', + 'article', + 'figcaption', + 'main', + 'aside', + 'figure', + 'nav', + 'blockquote', + 'footer', + 'ol', + 'details', + 'p', + 'dialog', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'pre', + 'dd', + 'header', + 'section', + 'div', + 'hgroup', + 'table', + 'dl', + 'hr', + 'ul', + 'dt', + 'view', + 'view-block', +]; +const specialElements = [ + 'slot', + 'form', + 'iframe', + 'img', + 'audio', + 'video', + 'canvas', + 'a', + 'input', + 'label', + 'textarea', + 'progress', + 'button', +]; // 收集使用到的小程序组件 export default function onParseCreateElement({ nodeName, componentConfig }: OnParseCreateElementArgs) { - if (!( - inlineElements.includes(nodeName) || - blockElements.includes(nodeName) || - specialElements.includes(nodeName) - )) return; + if (!(inlineElements.includes(nodeName) || blockElements.includes(nodeName) || specialElements.includes(nodeName))) return; const simple = ['audio', 'button', 'canvas', 'form', 'label', 'progress', 'textarea', 'video']; const special = { @@ -32,7 +115,7 @@ export default function onParseCreateElement({ nodeName, componentConfig }: OnPa includes.add(nodeName); } else if (nodeName in special) { const maps = special[nodeName]; - maps.forEach(item => { + maps.forEach((item) => { !includes.has(item) && includes.add(item); }); } diff --git a/packages/plugin-miniapp/src/miniapp/index.ts b/packages/plugin-miniapp/src/miniapp/index.ts index 31150f480c..711b83c1db 100644 --- a/packages/plugin-miniapp/src/miniapp/index.ts +++ b/packages/plugin-miniapp/src/miniapp/index.ts @@ -8,6 +8,7 @@ import { createRequire } from 'node:module'; import fs from 'fs-extra'; import fg from 'fast-glob'; import type { Config } from '@ice/app/esm/types'; +import { mergeInternalComponents } from '@ice/shared'; import getMiniappTargetConfig from '../targets/index.js'; import getMiniappWebpackConfig from './webpack/index.js'; @@ -29,17 +30,10 @@ function getEntry(rootDir: string, runtimeDir: string) { }; } -const getMiniappTask = ({ - rootDir, - command, - target, - configAPI, - runtimeDir, - nativeConfig, -}): Config => { +const getMiniappTask = ({ rootDir, command, target, configAPI, runtimeDir, nativeConfig }): Config => { const entry = getEntry(rootDir, runtimeDir); const mode = command === 'start' ? 'development' : 'production'; - const { template, globalObject, fileType, projectConfigJson } = getMiniappTargetConfig(target); + const { template, globalObject, fileType, projectConfigJson, modifyBuildAssets, components } = getMiniappTargetConfig(target); const { plugins, module } = getMiniappWebpackConfig({ rootDir, template, @@ -47,9 +41,13 @@ const getMiniappTask = ({ configAPI, projectConfigJson, nativeConfig, + modifyBuildAssets, }); const isPublicDirExist = fs.existsSync(path.join(rootDir, 'public')); const defaultLogging = command === 'start' ? 'summary' : 'summary assets'; + + mergeInternalComponents(components); + return { mode, entry, @@ -78,8 +76,11 @@ const getMiniappTask = ({ }, // FIXME: enable cache will cause error, disable it temporarily enableCache: false, + enableEnv: false, plugins, loaders: module?.rules, + assetsManifest: false, + fastRefresh: false, optimization: { sideEffects: true, usedExports: true, @@ -120,9 +121,16 @@ const getMiniappTask = ({ enableCopyPlugin: isPublicDirExist, // Only when public dir exists should copy-webpack-plugin be enabled swcOptions: { removeExportExprs: ['serverDataLoader', 'staticDataLoader'], + compilationConfig: { + jsc: { + // 小程序强制编译到 es5 + target: 'es5', + }, + }, }, cssFilename: `[name]${fileType.style}`, cssChunkFilename: `[name]${fileType.style}`, + cssExtensionAlias: ['.qss', '.jxss', '.wxss', '.acss', '.ttss'], enableRpx2Vw: false, // No need to transform rpx to vw in miniapp logging: process.env.WEBPACK_LOGGING || defaultLogging, }; diff --git a/packages/plugin-miniapp/src/miniapp/webpack/combination.ts b/packages/plugin-miniapp/src/miniapp/webpack/combination.ts new file mode 100644 index 0000000000..7aba6f7feb --- /dev/null +++ b/packages/plugin-miniapp/src/miniapp/webpack/combination.ts @@ -0,0 +1,83 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { createRouteIdByFile } from '@ice/route-manifest'; +import type { MiniappWebpackOptions } from '../../types.js'; +import type { IMiniBuildConfig } from './utils/types.js'; +import { MiniWebpackPlugin } from './plugin.js'; +import { MiniWebpackModule } from './module.js'; + +export class MiniCombination { + config: IMiniBuildConfig; + sourceRoot: string; + sourceDir: string; + + constructor(public appPath: string, public rawConfig: MiniappWebpackOptions) { + this.sourceRoot = 'src'; + this.sourceDir = path.resolve(appPath, this.sourceRoot); + // for mock + this.config = { + sourceRoot: this.sourceRoot, + fileType: rawConfig.fileType, + env: rawConfig.env, + template: rawConfig.template, + modifyBuildAssets: rawConfig.modifyBuildAssets, + // the follow value is writing for type check, do not make any sense. + isBuildPlugin: false, + isBuildNativeComp: false, + isSupportRecursive: false, // TODO + platform: 'wechat', + nodeModulesPath: '', + isSupportXS: true, + globalObject: '', + taroComponentsPath: '', + mode: 'development', + buildAdapter: '', + platformType: 'mini', + onParseCreateElement: async (nodeName, componentConfig) => {}, + modifyComponentConfig: async () => {}, + }; + } + + process() { + const webpackPlugin = new MiniWebpackPlugin(this); + const webpackModule = new MiniWebpackModule(this); + + return { + plugins: webpackPlugin.getPlugins(), + module: webpackModule.getModules(), + }; + } + + async readConfig(configPath: string, appPath: string): Promise { + const relativePath = path.relative(appPath, configPath); + const relativePathSegments = relativePath.split(path.sep); + if (relativePathSegments[0] === '.ice' && relativePathSegments[1] === 'entry.miniapp.config') { + const { miniappManifest } = await this.rawConfig.configAPI.getAppConfig(['miniappManifest']); + if (!miniappManifest) { + throw new Error('缺少 miniappManifest,请检查!'); + } + const appConfig = { + ...miniappManifest, + pages: miniappManifest.routes.map((route) => `pages/${route}`), + subPackages: miniappManifest.subPackages?.map((subPackage) => ({ + ...subPackage, + root: `pages/${subPackage.root}`, + })) ?? [], + }; + delete appConfig.routes; + + return appConfig; + } else if (configPath.endsWith('.json')) { + return await fs.readJSON(configPath); + } else if (relativePathSegments[0] === 'src' && relativePathSegments[1] === 'pages' && relativePathSegments[relativePathSegments.length - 1].endsWith('.config')) { + // starts with src/pages and ends with .config + // for example: src/pages/home/a.config => home/a + const route = [...relativePathSegments.slice(2, -1), relativePathSegments[relativePathSegments.length - 1].slice(0, -'.config'.length)].join('/'); + const routeId = createRouteIdByFile(`${route}.tsx`); + const configFn = await this.rawConfig.configAPI.getRoutesConfig(routeId); + return configFn?.() ?? {}; + } + + throw new Error(`Unknown config file of ${configPath}`); + } +} diff --git a/packages/plugin-miniapp/src/miniapp/webpack/index.ts b/packages/plugin-miniapp/src/miniapp/webpack/index.ts index 07b0dfcafc..3af9c3548f 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/index.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/index.ts @@ -1,13 +1,8 @@ import type { MiniappWebpackOptions, MiniappWebpackConfig } from '../../types.js'; -import { MiniWebpackModule } from './module.js'; -import { MiniWebpackPlugin } from './plugin.js'; +import { MiniCombination } from './combination.js'; export default function getMiniappWebpackConfig(rawConfig: MiniappWebpackOptions): MiniappWebpackConfig { - const webpackPlugin = new MiniWebpackPlugin(rawConfig); - const webpackModule = new MiniWebpackModule(rawConfig); + const combination = new MiniCombination(rawConfig.rootDir, rawConfig); - return { - plugins: webpackPlugin.getPlugins(), - module: webpackModule.getModules(), - }; + return combination.process(); } diff --git a/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniTemplateLoader.ts b/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniTemplateLoader.ts index 5fa0ffd718..93f92887f4 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniTemplateLoader.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniTemplateLoader.ts @@ -2,7 +2,7 @@ import loaderUtils from '@ice/bundles/compiled/loader-utils/index.js'; import sax from 'sax'; const { isUrlRequest, urlToRequest } = loaderUtils; -export default function miniTemplateLoader(source) { +export default function miniTemplateLoader(source: string) { this.cacheable && this.cacheable(); /** * 两种fix方案: @@ -15,34 +15,38 @@ export default function miniTemplateLoader(source) { * */ const sourceWithRoot = `${source}`; const parser = sax.parser(false, { lowercase: true }); - const requests: Set = new Set(); + const requests = new Map(); const callback = this.async(); - const loadModule = request => - new Promise((resolve, reject) => { - this.addDependency(request); - this.loadModule(request, (err, src) => { - if (err) { - reject(err); - } else { - resolve(src); - } - }); - }); + const loadModule = (request) => this.importModule(request); - parser.onattribute = ({ name, value }) => { - if (value && name === 'src' && isUrlRequest(value)) { + parser.onattribute = (attr) => { + const { name, value } = attr; + if (value && (name === 'src' || name === 'from') && isUrlRequest(value) && !requests.has(value)) { const request = urlToRequest(value); - requests.add(request); + requests.set(value, { + url: request, + name: name, + }); } }; parser.onend = async () => { try { - const requestsArray = Array.from(requests); + const requestsArray = Array.from(requests.values()).map(req => req.url); if (requestsArray.length) { for (let i = 0; i < requestsArray.length; i++) { await loadModule(requestsArray[i]); } } + for (let url of requests.keys()) { + if (url.indexOf('node_modules/') !== -1) { + const changedUrl = url.replace(/^.*node_modules\//, '/npm/'); + source = source.replace(url, changedUrl); + } + } callback(null, source); } catch (error) { callback(error, source); diff --git a/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniXScriptLoader.ts b/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniXScriptLoader.ts new file mode 100644 index 0000000000..4c17147b02 --- /dev/null +++ b/packages/plugin-miniapp/src/miniapp/webpack/loaders/miniXScriptLoader.ts @@ -0,0 +1,23 @@ +import loaderUtils from '@ice/bundles/compiled/loader-utils/index.js'; + +const { isUrlRequest, urlToRequest } = loaderUtils; +export default async function (source) { + const REG_REQUIRE = /require\(['"](.+\.wxs)['"]\)/g; + const callback = this.async(); + const importings: any[] = []; + let res; + + try { + while ((res = REG_REQUIRE.exec(source)) !== null) { + const dep = res[1]; + if (isUrlRequest(dep)) { + const request = urlToRequest(dep); + importings.push(this.importModule(request)); + } + } + await Promise.all(importings); + callback(null, source); + } catch (error) { + callback(error, source); + } +} diff --git a/packages/plugin-miniapp/src/miniapp/webpack/module.ts b/packages/plugin-miniapp/src/miniapp/webpack/module.ts index dc0190e5a5..2f7fd0da41 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/module.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/module.ts @@ -1,11 +1,10 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'node:module'; -import { REG_TEMPLATE } from '../../constant.js'; -import type { MiniappWebpackOptions } from '../../types.js'; +import { REG_NODE_MODULES, REG_TEMPLATE } from '../../helper/index.js'; +import type { MiniCombination } from './combination.js'; - -interface IRule { +export interface IRule { test?: any; exclude?: any[]; include?: any[]; @@ -30,14 +29,10 @@ interface IRule { const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export class MiniWebpackModule { - config: MiniappWebpackOptions; - sourceRoot: string; +const nodeModulesRegx = new RegExp(REG_NODE_MODULES, 'gi'); - constructor(config: MiniappWebpackOptions) { - this.config = config; - this.sourceRoot = 'src'; - } +export class MiniWebpackModule { + constructor(public combination: MiniCombination) {} getLoader(loaderName: string, options?: Record) { return { @@ -47,9 +42,7 @@ export class MiniWebpackModule { } getModules() { - const { - fileType, - } = this.config; + const { fileType, sourceRoot, buildAdapter } = this.combination.config; const rules: Array = [ { @@ -57,13 +50,28 @@ export class MiniWebpackModule { test: REG_TEMPLATE, type: 'asset/resource', generator: { - filename({ filename }) { + filename: ({ filename }) => { const extname = path.extname(filename); - return filename.replace(`${this.sourceRoot}/`, '').replace(extname, fileType.templ); + if (filename.startsWith(`${sourceRoot}/`)) filename = filename.slice(sourceRoot.length + 1); + return filename.replace(extname, fileType.templ).replace(nodeModulesRegx, 'npm'); }, }, - use: [this.getLoader(path.resolve(__dirname, './loaders/miniTemplateLoader'))], + use: [this.getLoader(path.resolve(__dirname, './loaders/miniTemplateLoader'), { + buildAdapter, + })], + }, + { + test: new RegExp(`\\${fileType.xs || 'wxs'}$`), + type: 'asset/resource', + generator: { + filename({ filename }) { + return filename + .replace(`${sourceRoot}/`, '') + .replace(nodeModulesRegx, 'npm'); + }, }, + use: [this.getLoader(path.resolve(__dirname, './loaders/miniXScriptLoader'))], + }, ]; return { rules }; } diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugin.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugin.ts index 0ee7ff5dd8..ac22f8799b 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugin.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugin.ts @@ -1,24 +1,16 @@ import webpack from '@ice/bundles/compiled/webpack/index.js'; -import type { MiniappWebpackOptions } from '../../types.js'; import MiniPlugin from './plugins/MiniPlugin.js'; +import type { MiniCombination } from './combination.js'; export class MiniWebpackPlugin { - config: MiniappWebpackOptions; - - constructor(config: MiniappWebpackOptions) { - this.config = config; - } + constructor(public combination: MiniCombination) {} getPlugins() { const providerPlugin = this.getProviderPlugin(); const definePlugin = this.getDefinePlugin(); const miniPlugin = this.getMainPlugin(); // TODO: any type - const plugins: Array = [ - providerPlugin, - definePlugin, - miniPlugin, - ]; + const plugins: Array = [providerPlugin, definePlugin, miniPlugin]; return plugins; } @@ -36,9 +28,7 @@ export class MiniWebpackPlugin { } getDefinePlugin() { - const { - env = {}, - } = this.config; + const { env = {} } = this.combination.config; const envConstants = Object.keys(env).reduce((target, key) => { target[`process.env.${key}`] = env[key]; @@ -51,18 +41,12 @@ export class MiniWebpackPlugin { } getMainPlugin() { - const { rootDir, template, fileType, configAPI, nativeConfig, projectConfigJson } = this.config; - const options = { - rootDir, - fileType, - template, + return new MiniPlugin({ commonChunks: ['runtime', 'vendors', 'common', 'ice'], - baseLevel: 16, - minifyXML: {}, - configAPI, - nativeConfig, - projectConfigJson, - }; - return new MiniPlugin(options); + constantsReplaceList: {}, + pxTransformConfig: {}, + hot: false, + combination: this.combination, + }); } } diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugins/LoadChunksPlugin.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugins/LoadChunksPlugin.ts index c113104bc1..edbaf43384 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugins/LoadChunksPlugin.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugins/LoadChunksPlugin.ts @@ -1,7 +1,9 @@ -import webpack from '@ice/bundles/compiled/webpack/index.js'; -import type { MiniappComponent } from '../../../types.js'; +import webpack, { type Compiler, type Compilation, type Chunk } from '@ice/bundles/compiled/webpack/index.js'; +import { toDashed } from '@ice/shared'; import { getChunkEntryModule, addRequireToSource, getChunkIdOrName } from '../utils/webpack.js'; -import { META_TYPE } from '../../../constant.js'; +import type { AddPageChunks, IComponent } from '../utils/types.js'; +import { META_TYPE, taroJsComponents } from '../../../helper/index.js'; +import { componentConfig } from '../utils/component.js'; import type NormalModule from './NormalModule.js'; const { ConcatSource } = webpack.sources; @@ -9,39 +11,86 @@ const PLUGIN_NAME = 'LoadChunksPlugin'; interface IOptions { commonChunks: string[]; - pages: Set; + isBuildPlugin: boolean; + framework: string; + addChunkPages?: AddPageChunks; + pages: Set; needAddCommon?: string[]; isIndependentPackages?: boolean; } export default class LoadChunksPlugin { commonChunks: string[]; - pages: Set; + isBuildPlugin: boolean; + framework: string; + addChunkPages?: AddPageChunks; + pages: Set; isCompDepsFound: boolean; needAddCommon: string[]; isIndependentPackages: boolean; constructor(options: IOptions) { this.commonChunks = options.commonChunks; + this.isBuildPlugin = options.isBuildPlugin; + this.framework = options.framework; + this.addChunkPages = options.addChunkPages; this.pages = options.pages; this.needAddCommon = options.needAddCommon || []; this.isIndependentPackages = options.isIndependentPackages || false; } - apply(compiler: webpack.Compiler) { - compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation: webpack.Compilation) => { + apply(compiler: Compiler) { + const pagesList = this.pages; + const addChunkPagesList = new Map(); + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation: Compilation) => { let commonChunks; const fileChunks = new Map(); - compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.Chunk[]) => { - // TODO:原先用于收集用到的组件,以减少 template 体积。ICE 中无法收集,需要提供可让用户手动配置的方法 + compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks: Chunk[]) => { const chunksArray = Array.from(chunks); - commonChunks = chunksArray.filter( - chunk => this.commonChunks.includes(chunk.name) && chunkHasJs(chunk, compilation.chunkGraph), - ).reverse(); + /** + * 收集 common chunks 中使用到 @tarojs/components 中的组件 + */ + commonChunks = chunksArray + .filter((chunk) => this.commonChunks.includes(chunk.name!) && chunkHasJs(chunk, compilation.chunkGraph)) + .reverse(); + + this.isCompDepsFound = false; + for (const chunk of commonChunks) { + this.collectComponents(compiler, compilation, chunk); + } + if (!this.isCompDepsFound) { + // common chunks 找不到再去别的 chunk 中找 + chunksArray + .filter((chunk) => !this.commonChunks.includes(chunk.name!)) + .some((chunk) => { + this.collectComponents(compiler, compilation, chunk); + return this.isCompDepsFound; + }); + } + + /** + * 收集开发者在 addChunkPages 中配置的页面及其需要引用的公共文件 + */ + if (typeof this.addChunkPages === 'function') { + this.addChunkPages( + addChunkPagesList, + Array.from(pagesList).map((item) => item.name), + ); + chunksArray.forEach((chunk) => { + const id = getChunkIdOrName(chunk); + addChunkPagesList.forEach((deps, pageName) => { + if (pageName === id) { + const depChunks = deps.map((dep) => ({ name: dep })); + fileChunks.set(id, depChunks); + } + }); + }); + } }); - webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).render.tap(PLUGIN_NAME, + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).render.tap( + PLUGIN_NAME, (modules: webpack.sources.ConcatSource, { chunk }) => { const chunkEntryModule = getChunkEntryModule(compilation, chunk) as any; if (chunkEntryModule) { @@ -57,17 +106,24 @@ export default class LoadChunksPlugin { } else { return modules; } - }); + }, + ); /** * 在每个 chunk 文本刚生成后,按判断条件在文本头部插入 require 语句 */ - webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).render.tap(PLUGIN_NAME, + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).render.tap( + PLUGIN_NAME, (modules: webpack.sources.ConcatSource, { chunk }) => { const chunkEntryModule = getChunkEntryModule(compilation, chunk) as any; if (chunkEntryModule) { + if (this.isBuildPlugin) { + return addRequireToSource(getChunkIdOrName(chunk), modules, commonChunks); + } + const entryModule: NormalModule = chunkEntryModule.rootModule ?? chunkEntryModule; - const { miniType } = entryModule; + const { miniType, isNativePage } = entryModule; + if (this.needAddCommon.length) { for (const item of this.needAddCommon) { if (getChunkIdOrName(chunk) === item) { @@ -80,10 +136,15 @@ export default class LoadChunksPlugin { return addRequireToSource(getChunkIdOrName(chunk), modules, commonChunks); } - // addChunkPages - if (fileChunks.size && - (miniType === META_TYPE.PAGE || miniType === META_TYPE.COMPONENT) + if ( + this.isIndependentPackages && + (miniType === META_TYPE.PAGE || miniType === META_TYPE.COMPONENT || isNativePage) ) { + return addRequireToSource(getChunkIdOrName(chunk), modules, commonChunks); + } + + // addChunkPages + if (fileChunks.size && (miniType === META_TYPE.PAGE || miniType === META_TYPE.COMPONENT)) { let source; const id = getChunkIdOrName(chunk); fileChunks.forEach((v, k) => { @@ -96,9 +157,34 @@ export default class LoadChunksPlugin { } else { return modules; } - }); + }, + ); }); } + + collectComponents(compiler: Compiler, compilation: Compilation, chunk: Chunk) { + const { chunkGraph } = compilation; + const { moduleGraph } = compilation; + const modulesIterable: Iterable = chunkGraph.getOrderedChunkModulesIterable( + chunk, + compiler.webpack.util.comparators.compareModulesByIdentifier, + ) as any; + for (const module of modulesIterable) { + // if (module.rawRequest === taroJsComponents) { + // this.isCompDepsFound = true; + // const { includes } = componentConfig; + // const moduleUsedExports = moduleGraph.getUsedExports(module, chunk.runtime); + // if (moduleUsedExports === null || typeof moduleUsedExports === 'boolean') { + // componentConfig.includeAll = true; + // } else { + // for (const item of moduleUsedExports) { + // includes.add(toDashed(item)); + // } + // } + // break; + // } + } + } } function chunkHasJs(chunk: webpack.Chunk, chunkGraph: webpack.ChunkGraph) { diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugins/MiniPlugin.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugins/MiniPlugin.ts index a0078a4496..4690f41855 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugins/MiniPlugin.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugins/MiniPlugin.ts @@ -1,100 +1,140 @@ +// eslint-disable prefer-destructuring import { fileURLToPath } from 'url'; import path from 'path'; import { createRequire } from 'module'; import type { RecursiveTemplate, UnRecursiveTemplate } from '@ice/shared'; -import type { Config } from '@ice/app/esm/types'; import type { MiniappAppConfig, MiniappConfig } from '@ice/miniapp-runtime/esm/types'; import fs from 'fs-extra'; -import { minify } from 'html-minifier'; import loaderUtils from '@ice/bundles/compiled/loader-utils/index.js'; -import webpack from '@ice/bundles/compiled/webpack/index.js'; +import type { Compilation, Compiler } from '@ice/bundles/compiled/webpack/index.js'; import EntryDependency from '@ice/bundles/compiled/webpack/EntryDependency.js'; import SingleEntryDependency from '../dependencies/SingleEntryDependency.js'; -import { componentConfig } from '../template/component.js'; -import type { MiniappComponent, FileType } from '../../../types.js'; -import { META_TYPE, NODE_MODULES_REG, REG_STYLE, SCRIPT_EXT } from '../../../constant.js'; -import { promoteRelativePath, resolveMainFilePath } from '../utils/index.js'; -import LoadChunksPlugin from './LoadChunksPlugin.js'; +import { componentConfig } from '../utils/component.js'; + +import { + getNpmPackageAbsolutePath, + isAliasPath, + isEmptyObject, + META_TYPE, + NODE_MODULES, + printLog, + processTypeEnum, + promoteRelativePath, + REG_NODE_MODULES, + REG_NODE_MODULES_DIR, + REG_STYLE, + replaceAliasPath, + resolveMainFilePath, + SCRIPT_EXT, +} from '../../../helper/index.js'; +import { addRequireToSource, getChunkEntryModule, getChunkIdOrName } from '../utils/webpack.js'; +import type { IComponent, IComponentExtraPath, IFileType, IMiniFilesConfig } from '../utils/types.js'; +import type { MiniCombination } from '../combination.js'; +import SingleEntryPlugin from './SingleEntryPlugin.js'; import NormalModulesPlugin from './NormalModulesPlugin.js'; +import LoadChunksPlugin from './LoadChunksPlugin.js'; +import type NormalModule from './NormalModule.js'; -const { ConcatSource, RawSource } = webpack.sources; const { urlToRequest } = loaderUtils; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const PLUGIN_NAME = 'MiniPlugin'; -const APP_CONFIG_FILE = 'app.json'; -interface MiniPluginOptions { - rootDir: string; +function isLoaderExist(loaders, loaderName: string) { + return loaders.some((item) => item.loader === loaderName); +} + +const baseCompName = 'comp'; +const customWrapperName = 'custom-wrapper'; +const CHILD_COMPILER_TAG = 'child'; + +interface IIceMiniPluginOptions { commonChunks: string[]; - baseLevel: number; - minifyXML?: { - collapseWhitespace?: boolean; + constantsReplaceList: Record; + pxTransformConfig: { + baseFontSize?: number; + deviceRatio?: any; + designWidth?: number; + unitPrecision?: number; + targetUnit?: string; }; - fileType: FileType; - template: RecursiveTemplate | UnRecursiveTemplate; + hot: boolean; + combination: MiniCombination; loaderMeta?: Record; - configAPI: { - getAppConfig: Config['getAppConfig']; - getRoutesConfig: Config['getRoutesConfig']; - }; - nativeConfig: Record; - projectConfigJson?: string; } -interface FilesConfig { - [configName: string]: { - content: MiniappConfig; - path: string; +interface IOptions extends IIceMiniPluginOptions { + sourceDir: string; + framework: string; + frameworkExts: string[]; + template: RecursiveTemplate | UnRecursiveTemplate; + runtimePath: string | string[]; + isBuildPlugin: boolean; + blended: boolean; + newBlended: boolean; + fileType: IFileType; + skipProcessUsingComponents: boolean; + logger?: { + quiet?: boolean; }; } -function isLoaderExist(loaders, loaderName: string) { - return loaders.some(item => item.loader === loaderName); -} - -function isEmptyObject(obj: any): boolean { - if (obj == null) { - return true; - } - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - return false; - } - } - return true; -} +type IndependentPackage = { pages: string[]; components: string[] }; export default class MiniPlugin { /** 插件配置选项 */ - options: MiniPluginOptions; + options: IOptions; context: string; - /** 源码路径 */ - sourceDir: string; /** app 入口文件路径 */ appEntry: string; /** app config 配置内容 */ appConfig: MiniappAppConfig; /** app、页面、组件的配置集合 */ - filesConfig: FilesConfig = {}; - routeManifest: Record = []; + filesConfig: IMiniFilesConfig = {}; + routeManifest: Record[] = []; isWatch = false; /** 页面列表 */ - pages = new Set(); + pages = new Set(); + components = new Set(); + /** 新的混合原生编译模式 newBlended 模式下,需要单独编译成原生代码的 component 的Map */ + nativeComponents = new Map(); /** tabbar icon 图片路径列表 */ tabBarIcons = new Set(); + prerenderPages = new Set(); dependencies = new Map(); loadChunksPlugin: LoadChunksPlugin; themeLocation: string; pageLoaderName = '@ice/miniapp-loader/lib/page.js'; - independentPackages = new Map(); + independentPackages = new Map(); + projectConfig: any = {}; + + constructor(options: IIceMiniPluginOptions) { + const { combination } = options; + const miniBuildConfig = combination.config; + const { template, baseLevel = 16 } = miniBuildConfig; + + this.options = { + sourceDir: combination.sourceDir, + framework: miniBuildConfig.framework || 'react', + frameworkExts: miniBuildConfig.frameworkExts || [], + template, + runtimePath: miniBuildConfig.runtimePath || '', + isBuildPlugin: miniBuildConfig.isBuildPlugin || false, + blended: miniBuildConfig.blended || false, + newBlended: miniBuildConfig.newBlended || false, + logger: miniBuildConfig.logger, + skipProcessUsingComponents: miniBuildConfig.skipProcessUsingComponents || false, + fileType: miniBuildConfig.fileType, + combination, + commonChunks: options.commonChunks || ['runtime', 'vendors'], + constantsReplaceList: options.constantsReplaceList, + pxTransformConfig: options.pxTransformConfig, + hot: options.hot, + loaderMeta: options.loaderMeta || {}, + }; - constructor(options = {} as MiniPluginOptions) { - this.options = options; - this.sourceDir = path.join(this.options.rootDir, 'src'); - const { template, baseLevel } = this.options; if (template.isSupportRecursive === false && baseLevel > 0) { (template as UnRecursiveTemplate).baseLevel = baseLevel; } @@ -103,36 +143,43 @@ export default class MiniPlugin { /** * 自动驱动 tapAsync */ - tryAsync(fn: (target: T) => Promise) { + tryAsync(fn: (target: T) => Promise) { return async (arg: T, callback: any) => { try { await fn(arg); callback(); } catch (err) { + console.error(err); callback(err); } }; } /** - * entry of the plugin + * 插件入口 */ - apply(compiler: webpack.Compiler) { + apply(compiler: Compiler) { this.context = compiler.context; this.appEntry = this.getAppEntry(compiler); - const { - commonChunks, - } = this.options; - const routeManifestPath = path.join(this.options.rootDir, '.ice', 'route-manifest.json'); + + const { commonChunks, combination, framework, isBuildPlugin, newBlended } = this.options; + + const { addChunkPages, onCompilerMake, modifyBuildAssets, onParseCreateElement } = combination.config; + + const routeManifestPath = path.join(combination.appPath, '.ice', 'route-manifest.json'); this.routeManifest = fs.readJSONSync(routeManifestPath); + /** build mode */ compiler.hooks.run.tapAsync( PLUGIN_NAME, - this.tryAsync(async compiler => { - await this.run(); + this.tryAsync(async (compiler) => { + await this.run(compiler); new LoadChunksPlugin({ commonChunks: commonChunks, + isBuildPlugin, + addChunkPages, pages: this.pages, + framework: framework, }).apply(compiler); }), ); @@ -140,16 +187,19 @@ export default class MiniPlugin { /** watch mode */ compiler.hooks.watchRun.tapAsync( PLUGIN_NAME, - this.tryAsync(async compiler => { + this.tryAsync(async (compiler) => { const changedFiles = this.getChangedFiles(compiler); - if (changedFiles?.size > 0) { + if (changedFiles && changedFiles?.size > 0) { this.isWatch = true; } - await this.run(); + await this.run(compiler); if (!this.loadChunksPlugin) { this.loadChunksPlugin = new LoadChunksPlugin({ commonChunks: commonChunks, + isBuildPlugin, + addChunkPages, pages: this.pages, + framework: framework, }); this.loadChunksPlugin.apply(compiler); } @@ -159,18 +209,27 @@ export default class MiniPlugin { /** compilation.addEntry */ compiler.hooks.make.tapAsync( PLUGIN_NAME, - this.tryAsync(async compilation => { + this.tryAsync(async (compilation) => { const { dependencies } = this; const promises: Promise[] = []; - dependencies.forEach(dep => { - promises.push(new Promise((resolve, reject) => { - compilation.addEntry(this.sourceDir, dep, { - name: dep.name, - ...dep.options, - }, err => (err ? reject(err) : resolve(null))); - })); + this.compileIndependentPages(compiler, compilation, dependencies, promises); + dependencies.forEach((dep) => { + promises.push( + new Promise((resolve, reject) => { + compilation.addEntry( + this.options.sourceDir, + dep, + { + name: dep.name, + ...dep.options, + }, + (err) => (err ? reject(err) : resolve(null)), + ); + }), + ); }); await Promise.all(promises); + await onCompilerMake?.(compilation, compiler, this); }), ); @@ -181,63 +240,213 @@ export default class MiniPlugin { /** * webpack NormalModule 在 runLoaders 真正解析资源的前一刻, - * 往 NormalModule.loaders 中插入对应的 miniapp Loader + * 往 NormalModule.loaders 中插入对应的 mini Loader */ - webpack.NormalModule.getCompilationHooks(compilation).loader.tap(PLUGIN_NAME, - (_loaderContext, module:/** NormalModule */ any) => { - if (module.miniType === META_TYPE.PAGE) { - const loaderName = require.resolve(this.pageLoaderName); + compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap( + PLUGIN_NAME, + (_loaderContext, module: /** NormalModule */ any) => { + const { framework, loaderMeta, pxTransformConfig } = this.options; + + if (module.miniType === META_TYPE.ENTRY) { + // TODO + // const loaderName = '@ice/miniapp-loader'; + // if (!isLoaderExist(module.loaders, loaderName)) { + // module.loaders.unshift({ + // loader: loaderName, + // options: { + // framework, + // loaderMeta, + // prerender: this.prerenderPages.size > 0, + // config: this.appConfig, + // runtimePath: this.options.runtimePath, + // blended: this.options.blended, + // newBlended: this.options.newBlended, + // pxTransformConfig, + // }, + // }); + // } + } else if (module.miniType === META_TYPE.PAGE) { + let isIndependent = false; + this.independentPackages.forEach(({ pages }) => { + if (pages.includes(module.resource)) { + isIndependent = true; + } + }); + const isNewBlended = this.nativeComponents.has(module.name); + const loaderName = + isNewBlended || isBuildPlugin + ? '@ice/miniapp-loader/lib/native-component.js' + : isIndependent + ? '@ice/miniapp-loader/lib/independentPage.js' + : this.pageLoaderName; + if (!isLoaderExist(module.loaders, loaderName)) { const routeInfo = this.routeManifest.find(route => path.join('pages', route.id) === module.name); const hasExportData = routeInfo?.exports?.includes('dataLoader'); const hasExportConfig = routeInfo?.exports?.includes('pageConfig'); module.loaders.unshift({ - loader: loaderName, + loader: require.resolve(loaderName), options: { + framework, loaderMeta: { + ...loaderMeta, hasExportData, hasExportConfig, }, + isNewBlended, name: module.name, + prerender: this.prerenderPages.has(module.name), config: this.filesConfig, appConfig: this.appConfig, - miniType: module.miniType, + runtimePath: this.options.runtimePath, + hot: this.options.hot, + }, + }); + } + } else if (module.miniType === META_TYPE.COMPONENT) { + const loaderName = isBuildPlugin + ? '@ice/miniapp-loader/lib/native-component.js' + : '@ice/miniapp-loader/lib/component.js'; + if (!isLoaderExist(module.loaders, loaderName)) { + module.loaders.unshift({ + loader: require.resolve(loaderName), + options: { + framework, + loaderMeta, + name: module.name, + prerender: this.prerenderPages.has(module.name), + runtimePath: this.options.runtimePath, }, }); } } - // TODO: 组件 loader 处理 - }); + }, + ); + const { PROCESS_ASSETS_STAGE_ADDITIONAL, PROCESS_ASSETS_STAGE_OPTIMIZE, PROCESS_ASSETS_STAGE_REPORT } = + compiler.webpack.Compilation; compilation.hooks.processAssets.tapAsync( { name: PLUGIN_NAME, - stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + stage: PROCESS_ASSETS_STAGE_ADDITIONAL, }, this.tryAsync(async () => { - await this.generateMiniFiles(compilation); + // 如果是子编译器,证明是编译独立分包,进行单独的处理 + if ((compilation as any).__tag === CHILD_COMPILER_TAG) { + await this.generateIndependentMiniFiles(compilation, compiler); + } else { + await this.generateMiniFiles(compilation, compiler); + } + }), + ); + compilation.hooks.processAssets.tapAsync( + { + name: PLUGIN_NAME, + // 删除 assets 的相关操作放在触发时机较后的 Stage,避免过早删除出现的一些问题,#13988 + // Stage 触发顺序:https://webpack.js.org/api/compilation-hooks/#list-of-asset-processing-stages + stage: PROCESS_ASSETS_STAGE_OPTIMIZE, + }, + this.tryAsync(async () => { + await this.optimizeMiniFiles(compilation, compiler); + }), + ); + + compilation.hooks.processAssets.tapAsync( + { + name: PLUGIN_NAME, + // 该 stage 是最后执行的,确保 taro 暴露给用户的钩子 modifyBuildAssets 在内部处理完 assets 之后再调用 + stage: PROCESS_ASSETS_STAGE_REPORT, + }, + this.tryAsync(async () => { + if (typeof modifyBuildAssets === 'function') { + await modifyBuildAssets(compilation.assets, this); + } }), ); }); compiler.hooks.afterEmit.tapAsync( PLUGIN_NAME, - this.tryAsync(async compilation => { + this.tryAsync(async (compilation) => { await this.addTarBarFilesToDependencies(compilation); }), ); - new NormalModulesPlugin().apply(compiler); + new NormalModulesPlugin(onParseCreateElement).apply(compiler); + + newBlended && this.addLoadChunksPlugin(compiler); + } + + addLoadChunksPlugin(compiler: Compiler) { + const fileChunks = new Map(); + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks) => { + for (const chunk of chunks) { + const id = getChunkIdOrName(chunk); + if (this.options.commonChunks.includes(id)) return; + + const deps: { name: string }[] = []; + + for (const group of chunk.groupsIterable) { + group.chunks.forEach((chunk) => { + const currentChunkId = getChunkIdOrName(chunk); + if (id === currentChunkId) return; + deps.push({ + name: currentChunkId, + }); + }); + } + + fileChunks.set(id, deps); + } + }); + compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).render.tap( + PLUGIN_NAME, + (modules, { chunk }) => { + const chunkEntryModule = getChunkEntryModule(compilation, chunk) as any; + if (!chunkEntryModule) return; + const entryModule: NormalModule = chunkEntryModule.rootModule ?? chunkEntryModule; + // addChunkPages + if (fileChunks.size) { + let source; + const id = getChunkIdOrName(chunk); + const { miniType } = entryModule as any; + const entryChunk = [{ name: 'app' }]; + if (this.nativeComponents.has(id) || miniType === META_TYPE.STATIC) { + fileChunks.forEach((v, k) => { + if (k === id) { + source = addRequireToSource(id, modules, v); + } + }); + return source; + } else if (miniType === META_TYPE.PAGE) { + return addRequireToSource(id, modules, entryChunk); + } + } + }, + ); + }); } /** * 根据 webpack entry 配置获取入口文件路径 * @returns app 入口文件路径 */ - getAppEntry(compiler: webpack.Compiler) { + getAppEntry(compiler: Compiler) { + // const originalEntry = compiler.options.entry as webpack.EntryObject + // compiler.options.entry = {} + // return path.resolve(this.context, originalEntry.app[0]) + const { entry } = compiler.options; + if (this.options.isBuildPlugin) { + const entryCopy = Object.assign({}, entry); + compiler.options.entry = {}; + return entryCopy; + } + function getEntryPath(entry) { - const app = entry.main; + const { main: app } = entry; if (Array.isArray(app)) { return app[0]; } else if (Array.isArray(app.import)) { @@ -245,12 +454,22 @@ export default class MiniPlugin { } return app; } + const appEntryPath = getEntryPath(entry); compiler.options.entry = {}; return appEntryPath; } - getChangedFiles(compiler: webpack.Compiler) { + getIndependentPackage(pagePath: string): IndependentPackage | undefined { + return Array.from(this.independentPackages.values()).find((independentPackage) => { + const { pages } = independentPackage; + if (pages.includes(pagePath)) { + return independentPackage; + } + }); + } + + getChangedFiles(compiler: Compiler) { return compiler.modifiedFiles; } @@ -258,12 +477,56 @@ export default class MiniPlugin { * 分析 app 入口文件,搜集页面、组件信息, * 往 this.dependencies 中添加资源模块 */ - async run() { - this.appConfig = await this.getAppConfig(); - this.getPages(); - await this.getPagesConfig(); - this.getDarkMode(); - this.addEntries(); + async run(compiler: Compiler) { + if (this.options.isBuildPlugin) { + this.getPluginFiles(); + this.getConfigFiles(compiler); + } else { + this.getProjectConfig(); + this.appConfig = await this.getAppConfig(); + await this.getPages(); + await this.getPagesConfig(); + this.getDarkMode(); + this.getConfigFiles(compiler); + this.addEntries(); + } + } + + getPluginFiles() { + throw new Error('Unsupported plugin build'); + } + + modifyPluginJSON(pluginJSON) { + const { main, publicComponents } = pluginJSON; + const isUsingCustomWrapper = componentConfig.thirdPartyComponents.has('custom-wrapper'); + if (main) { + pluginJSON.main = this.getTargetFilePath(main, '.js'); + } + + if (!this.options.template.isSupportRecursive) { + pluginJSON.publicComponents = Object.assign({}, publicComponents, { + [baseCompName]: baseCompName, + }); + } + + if (isUsingCustomWrapper) { + pluginJSON.publicComponents = Object.assign({}, publicComponents, { + [customWrapperName]: customWrapperName, + }); + } + } + + getProjectConfig(): void { + const projectConfigJsonPath = path.join(this.options.combination.appPath, this.options.combination.rawConfig.projectConfigJson); + if (!fs.existsSync(projectConfigJsonPath)) { + this.projectConfig = this.options.combination.rawConfig.nativeConfig; + return; + } + const localProjectConfig = JSON.parse(fs.readFileSync(projectConfigJsonPath, 'utf-8')); + this.projectConfig = { + ...localProjectConfig, + ...this.options.combination.rawConfig.nativeConfig, + }; } /** @@ -271,21 +534,24 @@ export default class MiniPlugin { * @returns app config 配置内容 */ async getAppConfig(): Promise { - const { configAPI } = this.options; - const { miniappManifest } = await configAPI.getAppConfig(['miniappManifest']); - if (!miniappManifest) { - throw new Error('缺少 miniappManifest,请检查!'); - } - const appConfig = { - pages: miniappManifest.routes.map(route => `pages/${route}`), - ...miniappManifest, - }; - delete appConfig.routes; + const appName = path.basename(this.appEntry).replace(path.extname(this.appEntry), ''); - this.filesConfig[APP_CONFIG_FILE] = { - content: appConfig, - path: APP_CONFIG_FILE, - }; + await this.compileFile({ + name: appName, + path: this.appEntry, + isNative: false, + }); + + const fileConfig = this.filesConfig[this.getConfigFilePath(appName)]; + const appConfig = fileConfig ? fileConfig.content || {} : {}; + + if (isEmptyObject(appConfig)) { + throw new Error('缺少 app 全局配置文件,请检查!'); + } + const { modifyAppConfig } = this.options.combination.config; + if (typeof modifyAppConfig === 'function') { + await modifyAppConfig(appConfig); + } return appConfig as MiniappAppConfig; } @@ -293,19 +559,28 @@ export default class MiniPlugin { * 根据 app config 的 pages 配置项,收集所有页面信息, * 包括处理分包和 tabbar */ - getPages() { + async getPages() { if (isEmptyObject(this.appConfig)) { throw new Error('缺少 app 全局配置文件,请检查!'); } + const appPages = this.appConfig.pages; if (!appPages || !appPages.length) { throw new Error('全局配置缺少 pages 字段,请检查!'); } - this.getTabBarFiles(this.appConfig); + if (!this.isWatch && this.options.logger?.quiet === false) { + printLog(processTypeEnum.COMPILE, '发现入口', this.getShowPath(this.appEntry)); + } + + const { newBlended, frameworkExts, combination } = this.options; + // const { prerender } = combination.config; + + // this.prerenderPages = new Set(validatePrerenderPages(appPages, prerender).map((p) => p.path)); + await this.getTabBarFiles(this.appConfig); this.pages = new Set([ - ...appPages.map(item => { - const pagePath = resolveMainFilePath(path.join(this.sourceDir, item), SCRIPT_EXT); + ...appPages.map((item) => { + const pagePath = resolveMainFilePath(path.join(this.options.sourceDir, item), frameworkExts); const pageTemplatePath = this.getTemplatePath(pagePath); const isNative = this.isNativePageORComponent(pageTemplatePath); return { @@ -313,28 +588,81 @@ export default class MiniPlugin { path: pagePath, isNative, stylePath: isNative ? this.getStylePath(pagePath) : undefined, - templatePath: isNative ? this.getTemplatePath(pagePath) : undefined, + skeletonPath: isNative ? this.getSkeletonExtraPath(pagePath) : undefined, }; }), ]); - // TODO: 收集分包配置中的页面 + this.getSubPackages(this.appConfig); + // 新的混合原生编译模式 newBlended 下,需要收集独立编译为原生自定义组件 + newBlended && this.getNativeComponent(); + } + + /** + * 收集需要转换为本地化组件的内容 + */ + getNativeComponent() { + const { frameworkExts } = this.options; + const components = this.appConfig.components || []; + components.forEach((item) => { + const pagePath = resolveMainFilePath(path.join(this.options.sourceDir, item), frameworkExts); + const componentObj = { + name: item, + path: pagePath, + isNative: false, + }; + if (!this.isWatch && this.options.logger?.quiet === false) { + printLog(processTypeEnum.COMPILE, `发现[${item}]Native组件`); + } + this.pages.add(componentObj); + // 登记需要编译成原生版本的组件 + this.nativeComponents.set(item, componentObj); + }); } /** * 读取页面及其依赖的组件的配置 */ async getPagesConfig() { - const { configAPI } = this.options; - const routesConfig = await configAPI.getRoutesConfig(); - for (let page of this.pages) { - await this.compileFile(page, routesConfig); + for (const page of this.pages) { + if (!this.isWatch && this.options.logger?.quiet === false) { + printLog(processTypeEnum.COMPILE, '发现页面', this.getShowPath(page.path)); + } + + const pagePath = page.path; + const independentPackage = this.getIndependentPackage(pagePath); + + await this.compileFile(page, independentPackage); } } + /** + * 往 this.dependencies 中新增或修改所有 config 配置模块 + */ + getConfigFiles(compiler: Compiler) { + const { filesConfig } = this; + Object.keys(filesConfig).forEach((item) => { + if (fs.existsSync(filesConfig[item].path)) { + this.addEntry(filesConfig[item].path, item, META_TYPE.CONFIG); + } + }); + + // webpack createChunkAssets 前一刻,去除所有 config chunks + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.beforeChunkAssets.tap(PLUGIN_NAME, () => { + const { chunks } = compilation; + const configNames = Object.keys(filesConfig); + + for (const chunk of chunks) { + if (configNames.find((configName) => configName === chunk.name)) chunks.delete(chunk); + } + }); + }); + } + /** * 在 this.dependencies 中新增或修改模块 */ - addEntry(entryPath: string, entryName: string, entryType: any, options = {}) { + addEntry(entryPath: string, entryName: string, entryType: META_TYPE, options = {}) { let dep: SingleEntryDependency; if (this.dependencies.has(entryPath)) { dep = this.dependencies.get(entryPath)!; @@ -355,25 +683,48 @@ export default class MiniPlugin { */ addEntries() { const { template } = this.options; + this.addEntry(this.appEntry, 'app', META_TYPE.ENTRY); if (!template.isSupportRecursive) { this.addEntry(path.resolve(__dirname, '..', 'template/comp'), 'comp', META_TYPE.STATIC); } this.addEntry(path.resolve(__dirname, '..', 'template/custom-wrapper'), 'custom-wrapper', META_TYPE.STATIC); - this.pages.forEach(item => { + + const resolveComponentStyleEntry = (name: string, stylePaths: string[]) => { + for (const stylePath of stylePaths) { + if (fs.existsSync(stylePath)) { + this.addEntry(stylePath, this.getTargetFilePath(name, this.options.fileType.style), META_TYPE.NORMAL); + break; + } + } + }; + + const resolveComponentEntry = (nonNativeType: META_TYPE) => (item: IComponent) => { if (item.isNative) { - this.addEntry(item.path, item.name, META_TYPE.NORMAL); - if (item.stylePath && fs.existsSync(item.stylePath)) { - this.addEntry(item.stylePath, this.getStylePath(item.name), META_TYPE.NORMAL); + this.addEntry(item.path, item.name, META_TYPE.NORMAL, { isNativePage: true }); + if (item.stylePath) { + resolveComponentStyleEntry(item.name, item.stylePath); } if (item.templatePath && fs.existsSync(item.templatePath)) { this.addEntry(item.templatePath, this.getTemplatePath(item.name), META_TYPE.NORMAL); } + + if (item.skeletonPath) { + if (item.skeletonPath.template) { + this.addEntry(item.skeletonPath.template, this.getTargetFilePath(item.name, `${this.options.fileType.skeletonMidExt}${this.options.fileType.templ}`), META_TYPE.NORMAL); + } + if (item.skeletonPath.style) { + resolveComponentStyleEntry(this.getTargetFilePath(item.name, this.options.fileType.skeletonMidExt), item.skeletonPath.style); + } + } } else { - this.addEntry(item.path, item.name, META_TYPE.PAGE); + this.addEntry(item.path, item.name, nonNativeType); } - }); - // TODO:处理 this.components + }; + + this.pages.forEach(resolveComponentEntry(META_TYPE.PAGE)); + + this.components.forEach(resolveComponentEntry(META_TYPE.COMPONENT)); } replaceExt(file: string, ext: string) { @@ -383,21 +734,140 @@ export default class MiniPlugin { /** * 读取页面、组件的配置,并递归读取依赖的组件的配置 */ - async compileFile(file: MiniappComponent, routesConfig: any) { - // Remove pages/ prefix - const id = file.name.slice(6); - const routeConfig = routesConfig[id]?.() || {}; + async compileFile(file: IComponent, independentPackage?: IndependentPackage) { const filePath = file.path; const fileConfigPath = file.isNative ? this.replaceExt(filePath, '.json') : this.getConfigFilePath(filePath); - // TODO: 如果使用原生小程序组件,则需要配置 usingComponents,需要递归收集依赖的第三方组件 - // const { usingComponents } = fileConfig; + // const fileConfig = readConfig(fileConfigPath, this.options.combination.config); + const fileConfig = await this.options.combination.readConfig(fileConfigPath, this.options.combination.appPath); + const { componentGenerics, usingComponents } = fileConfig; + + if (this.options.isBuildPlugin && componentGenerics) { + Object.keys(componentGenerics).forEach((component) => { + if (componentGenerics[component]) { + if (!componentConfig.thirdPartyComponents.has(component)) { + componentConfig.thirdPartyComponents.set(component, new Set()); + } + } + }); + } + + // 递归收集依赖的第三方组件 + if (usingComponents) { + const componentNames = Object.keys(usingComponents); + const depComponents: Array<{ name: string; path: string }> = []; + // alias 的值需要从最终的 chain 中拿,避免用户在 webpackChain 中设置的 alias 无法被读取到 + // const alias = this.options.combination.chain.toConfig().resolve?.alias; + const alias = {}; + + for (const compName of componentNames) { + let compPath: string = usingComponents[compName]; + + if (isAliasPath(compPath, alias)) { + compPath = replaceAliasPath(filePath, compPath, alias); + fileConfig.usingComponents[compName] = compPath; + } + + // 判断是否为第三方依赖的正则,如果 test 为 false 则为第三方依赖 + const notNpmPkgReg = /^[.\\/]/; + if ( + !this.options.skipProcessUsingComponents && + !compPath.startsWith('plugin://') && + !notNpmPkgReg.test(compPath) + ) { + const tempCompPath = getNpmPackageAbsolutePath(compPath); + + if (tempCompPath) { + compPath = tempCompPath; + fileConfig.usingComponents[compName] = compPath; + } + } + + depComponents.push({ + name: compName, + path: compPath, + }); + + if (!componentConfig.thirdPartyComponents.has(compName) && !file.isNative) { + componentConfig.thirdPartyComponents.set(compName, new Set()); + } + } + + await Promise.all(depComponents.map(async (item) => { + const componentPath = resolveMainFilePath(path.resolve(path.dirname(file.path), item.path)); + if (fs.existsSync(componentPath) && !Array.from(this.components).some((item) => item.path === componentPath)) { + const componentName = this.getComponentName(componentPath); + // newBlended 模式下,本地化组件使用Page进行处理,此处直接跳过 + if (this.nativeComponents.has(componentName)) return; + const componentTempPath = this.getTemplatePath(componentPath); + const isNative = this.isNativePageORComponent(componentTempPath); + const componentObj = { + name: componentName, + path: componentPath, + isNative, + stylePath: isNative ? this.getStylePath(componentPath) : undefined, + templatePath: isNative ? this.getTemplatePath(componentPath) : undefined, + }; + + // 收集独立分包的组件,用于后续单独编译 + independentPackage?.components?.push(componentPath); + + this.components.add(componentObj); + await this.compileFile(componentObj, independentPackage); + } + })); + } this.filesConfig[this.getConfigFilePath(file.name)] = { - content: routeConfig, + content: fileConfig, path: fileConfigPath, }; } + /** + * 收集分包配置中的页面 + */ + getSubPackages(appConfig: MiniappAppConfig) { + const { subPackages } = appConfig; // || appConfig.subpackages; + const { frameworkExts } = this.options; + if (subPackages && subPackages.length) { + subPackages.forEach((item) => { + if (item.pages && item.pages.length) { + const { root } = item; + const isIndependent = !!item.independent; + if (isIndependent) { + this.independentPackages.set(root, { pages: [], components: [] }); + } + item.pages.forEach((page) => { + let pageItem = `${root}/${page}`; + pageItem = pageItem.replace(/\/{2,}/g, '/'); + let hasPageIn = false; + this.pages.forEach(({ name }) => { + if (name === pageItem) { + hasPageIn = true; + } + }); + if (!hasPageIn) { + const pagePath = resolveMainFilePath(path.join(this.options.sourceDir, pageItem), frameworkExts); + const templatePath = this.getTemplatePath(pagePath); + const isNative = this.isNativePageORComponent(templatePath); + if (isIndependent) { + const independentPages = this.independentPackages.get(root)?.pages; + independentPages?.push(pagePath); + } + this.pages.add({ + name: pageItem, + path: pagePath, + isNative, + stylePath: isNative ? this.getStylePath(pagePath) : undefined, + templatePath: isNative ? this.getTemplatePath(pagePath) : undefined, + }); + } + }); + } + }); + } + } + /** * 收集 dark mode 配置中的文件 */ @@ -409,22 +879,164 @@ export default class MiniPlugin { } } + compileIndependentPages(compiler, compilation, dependencies, promises) { + const { independentPackages } = this; + if (independentPackages.size) { + const JsonpTemplatePlugin = require('webpack/lib/web/JsonpTemplatePlugin'); + const NaturalChunkIdsPlugin = require('webpack/lib/ids/NaturalChunkIdsPlugin'); + const SplitChunksPlugin = require('webpack/lib/optimize/SplitChunksPlugin'); + const RuntimeChunkPlugin = require('webpack/lib/optimize/RuntimeChunkPlugin'); + const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + + independentPackages.forEach(({ pages, components }, name) => { + const childCompiler = compilation.createChildCompiler(PLUGIN_NAME, { + path: `${compiler.options.output.path}/${name}`, + chunkLoadingGlobal: `subpackage_${name}`, + }); + const compPath = path.resolve(__dirname, '..', 'template/comp'); + childCompiler.inputFileSystem = compiler.inputFileSystem; + childCompiler.outputFileSystem = compiler.outputFileSystem; + childCompiler.context = compiler.context; + new JsonpTemplatePlugin().apply(childCompiler); + new NaturalChunkIdsPlugin().apply(childCompiler); + new MiniCssExtractPlugin({ + filename: `[name]${this.options.fileType.style}`, + chunkFilename: `[name]${this.options.fileType.style}`, + }).apply(childCompiler); + new compiler.webpack.DefinePlugin(this.options.constantsReplaceList).apply(childCompiler); + if (compiler.options.optimization) { + new SplitChunksPlugin({ + chunks: 'all', + maxInitialRequests: Infinity, + minSize: 0, + cacheGroups: { + common: { + name: `${name}/common`, + minChunks: 2, + priority: 1, + }, + vendors: { + name: `${name}/vendors`, + minChunks: 1, + test: (module) => { + const nodeModulesDirRegx = new RegExp(REG_NODE_MODULES_DIR); + return nodeModulesDirRegx.test(module.resource) && module.resource.indexOf(compPath) < 0; + }, + priority: 10, + }, + }, + }).apply(childCompiler); + new RuntimeChunkPlugin({ + name: `${name}/runtime`, + }).apply(childCompiler); + } + const childPages = new Set(); + pages.forEach((pagePath) => { + if (dependencies.has(pagePath)) { + const dep = dependencies.get(pagePath); + new SingleEntryPlugin(compiler.context, dep?.request, dep?.name, dep?.miniType, dep?.options).apply( + childCompiler, + ); + } + this.pages.forEach((item) => { + if (item.path === pagePath) { + childPages.add(item); + } + }); + dependencies.delete(pagePath); + }); + components.forEach((componentPath) => { + if (dependencies.has(componentPath)) { + const dep = dependencies.get(componentPath); + new SingleEntryPlugin(compiler.context, dep?.request, dep?.name, dep?.miniType, dep?.options).apply( + childCompiler, + ); + } + + dependencies.delete(componentPath); + }); + new LoadChunksPlugin({ + commonChunks: [`${name}/runtime`, `${name}/vendors`, `${name}/common`], + isBuildPlugin: false, + addChunkPages: this.options.combination.config.addChunkPages, + pages: childPages, + framework: this.options.framework, + isIndependentPackages: true, + needAddCommon: [`${name}/comp`, `${name}/custom-wrapper`], + }).apply(childCompiler); + // 添加 comp 和 custom-wrapper 组件 + new SingleEntryPlugin( + compiler.context, + path.resolve(__dirname, '..', 'template/comp'), + `${name}/comp`, + META_TYPE.STATIC, + ).apply(childCompiler); + new SingleEntryPlugin( + compiler.context, + path.resolve(__dirname, '..', 'template/custom-wrapper'), + `${name}/custom-wrapper`, + META_TYPE.STATIC, + ).apply(childCompiler); + + // 给每个子编译器标记上名称和 tag + // tag 用于生成模板和 config 时区别于主编译器走不同的方法 + // 名称用于在生成资源时判断是否为当前子编译器的资源 + childCompiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.__name = name; + compilation.__tag = CHILD_COMPILER_TAG; + }); + + promises.push( + new Promise((resolve, reject) => { + childCompiler.runAsChild((err) => { + if (err) { + return reject(err); + } + resolve(null); + }); + }).catch((err) => console.log(err)), + ); + }); + } + } + /** * 搜集 tabbar icon 图标路径 * 收集自定义 tabbar 组件 */ - getTabBarFiles(appConfig: MiniappAppConfig) { + async getTabBarFiles(appConfig: MiniappAppConfig) { const { tabBar } = appConfig; + const { sourceDir, frameworkExts } = this.options; if (tabBar && typeof tabBar === 'object' && !isEmptyObject(tabBar)) { // eslint-disable-next-line dot-notation const list = tabBar['list'] || []; - list.forEach(item => { + list.forEach((item) => { // eslint-disable-next-line dot-notation item['iconPath'] && this.tabBarIcons.add(item['iconPath']); // eslint-disable-next-line dot-notation item['selectedIconPath'] && this.tabBarIcons.add(item['selectedIconPath']); }); - // TODO: custom tabBar + if (tabBar.custom) { + const isAlipay = process.env.TARO_ENV === 'alipay'; + const customTabBarPath = path.join(sourceDir, isAlipay ? 'customize-tab-bar' : 'custom-tab-bar'); + const customTabBarComponentPath = resolveMainFilePath(customTabBarPath, [...frameworkExts, ...SCRIPT_EXT]); + if (fs.existsSync(customTabBarComponentPath)) { + const customTabBarComponentTemplPath = this.getTemplatePath(customTabBarComponentPath); + const isNative = this.isNativePageORComponent(customTabBarComponentTemplPath); + if (!this.isWatch && this.options.logger?.quiet === false) { + printLog(processTypeEnum.COMPILE, '自定义 tabBar', this.getShowPath(customTabBarComponentPath)); + } + const componentObj: IComponent = { + name: isAlipay ? 'customize-tab-bar/index' : 'custom-tab-bar/index', + path: customTabBarComponentPath, + isNative, + stylePath: isNative ? this.getStylePath(customTabBarComponentPath) : undefined, + templatePath: isNative ? this.getTemplatePath(customTabBarComponentPath) : undefined, + }; + await this.compileFile(componentObj); + this.components.add(componentObj); + } + } } } @@ -437,122 +1049,359 @@ export default class MiniPlugin { return filePath.replace(this.context, '').replace(/\\/g, '/').replace(/^\//, ''); } - /** 生成小程序相关文件 */ - async generateMiniFiles(compilation: webpack.Compilation) { - const { template } = this.options; - const baseTemplateName = 'base'; - const baseCompName = 'comp'; - const customWrapperName = 'custom-wrapper'; + // 调整 config 文件中 usingComponents 的路径 + // 1. 将 node_modules 调整为 npm + // 2. 将 ../../../node_modules/xxx 调整为 /npm/xxx + adjustConfigContent(config: MiniappConfig) { + const { usingComponents } = config; - // TODO:与原生小程序混写时解析模板与样式 + if (!usingComponents || this.options.skipProcessUsingComponents) return; + for (const [key, value] of Object.entries(usingComponents)) { + if (!value.includes(NODE_MODULES)) return; + + const match = value.replace(NODE_MODULES, 'npm').match(/npm.*/); + usingComponents[key] = match ? `${path.sep}${match[0]}` : value; + } + } - // project.config.json - this.generateProjectConfigFile(compilation); - // app.json - this.generateConfigFile(compilation, APP_CONFIG_FILE, this.filesConfig[APP_CONFIG_FILE].content); + /** 生成小程序独立分包的相关文件 */ + async generateIndependentMiniFiles(compilation: Compilation, compiler: Compiler) { + const { template, sourceDir } = this.options; + const baseTemplateName = 'base'; + const isUsingCustomWrapper = componentConfig.thirdPartyComponents.has('custom-wrapper'); + // @ts-ignore + const childName = compilation.__name; + // 为独立分包生成 base/comp/custom-wrapper + this.independentPackages.forEach((_pages, name) => { + // independentPackages 是包含了所有 ChildCompiler 的资源,如果不是当前 ChildCompiler 的资源不做处理 + if (name !== childName) return; - if (template.isSupportRecursive) { - this.generateConfigFile(compilation, customWrapperName, { + this.generateTemplateFile( + compilation, + compiler, + `${name}/${baseTemplateName}`, + template.buildTemplate, + componentConfig, + ); + if (!template.isSupportRecursive) { + // 如微信、QQ 不支持递归模版的小程序,需要使用自定义组件协助递归 + this.generateConfigFile(compilation, compiler, `${name}/${baseCompName}`, { + component: true, + usingComponents: { + [baseCompName]: `./${baseCompName}`, + [customWrapperName]: `./${customWrapperName}`, + }, + }); + this.generateTemplateFile( + compilation, + compiler, + `${name}/${baseCompName}`, + template.buildBaseComponentTemplate, + this.options.fileType.templ, + ); + } + this.generateConfigFile(compilation, compiler, `${name}/${customWrapperName}`, { component: true, usingComponents: { [customWrapperName]: `./${customWrapperName}`, }, }); - } else { + this.generateTemplateFile( + compilation, + compiler, + `${name}/${customWrapperName}`, + template.buildCustomComponentTemplate, + this.options.fileType.templ, + ); + this.generateXSFile(compilation, compiler, `${name}/utils`); + }); + + this.pages.forEach((page) => { + let importBaseTemplatePath = promoteRelativePath( + path.relative(page.path, path.join(sourceDir, this.getTemplatePath(baseTemplateName))), + ); + const config = this.filesConfig[this.getConfigFilePath(page.name)]; + let isIndependent = false; + let independentName = ''; + this.independentPackages.forEach(({ pages }, name) => { + // independentPackages 是包含了所有 ChildCompiler 的资源,如果不是当前 ChildCompiler 的资源不做处理 + if (pages.includes(page.path) && name === childName) { + isIndependent = true; + independentName = name; + importBaseTemplatePath = promoteRelativePath( + path.relative(page.path, path.join(sourceDir, name, this.getTemplatePath(baseTemplateName))), + ); + } + }); + + if (!isIndependent) return; + + // 生成页面模板需要在生成页面配置之前,因为会依赖到页面配置的部分内容 + if (!page.isNative) { + this.generateTemplateFile( + compilation, + compiler, + page.path, + template.buildPageTemplate, + importBaseTemplatePath, + config, + ); + } + + if (config) { + const importBaseCompPath = promoteRelativePath( + path.relative(page.path, path.join(sourceDir, independentName, this.getTargetFilePath(baseCompName, ''))), + ); + const importCustomWrapperPath = promoteRelativePath( + path.relative( + page.path, + path.join(sourceDir, independentName, this.getTargetFilePath(customWrapperName, '')), + ), + ); + config.content.usingComponents = { + ...config.content.usingComponents, + }; + + if (isUsingCustomWrapper) { + config.content.usingComponents[customWrapperName] = importCustomWrapperPath; + } + if (!template.isSupportRecursive && !page.isNative) { + config.content.usingComponents[baseCompName] = importBaseCompPath; + } + this.generateConfigFile(compilation, compiler, page.path, config.content); + } + }); + } + + /** 生成小程序相关文件 */ + async generateMiniFiles(compilation: Compilation, compiler: Compiler) { + const { RawSource } = compiler.webpack.sources; + const { template, combination, isBuildPlugin, sourceDir } = this.options; + const { modifyMiniConfigs } = combination.config; + const baseTemplateName = 'base'; + const isUsingCustomWrapper = componentConfig.thirdPartyComponents.has('custom-wrapper'); + + /** + * 与原生小程序混写时解析模板与样式 + */ + compilation.getAssets().forEach(({ name: assetPath }) => { + const styleExt = this.options.fileType.style; + if (new RegExp(`${styleExt}${styleExt}$`).test(assetPath)) { + const assetObj = compilation.assets[assetPath]; + const newAssetPath = assetPath.replace(styleExt, ''); + compilation.assets[newAssetPath] = assetObj; + } + }); + + if (typeof modifyMiniConfigs === 'function') { + await modifyMiniConfigs(this.filesConfig); + } + + compilation.assets[combination.rawConfig.projectConfigJson] = new RawSource(JSON.stringify(this.projectConfig, null, 2)); + + if ((!this.options.blended || !this.options.newBlended) && !isBuildPlugin) { + const appConfigName = path.basename(this.appEntry).replace(path.extname(this.appEntry), ''); + const appConfigPath = this.getConfigFilePath(appConfigName); + this.generateConfigFile(compilation, compiler, 'app.js', this.filesConfig[appConfigPath].content); + } + + if (!template.isSupportRecursive) { // 如微信、QQ 不支持递归模版的小程序,需要使用自定义组件协助递归 this.generateTemplateFile( compilation, + compiler, baseCompName, template.buildBaseComponentTemplate, this.options.fileType.templ, ); - this.generateConfigFile(compilation, baseCompName, { - component: true, - usingComponents: { - [baseCompName]: `./${baseCompName}`, - [customWrapperName]: `./${customWrapperName}`, - }, - }); - this.generateConfigFile(compilation, customWrapperName, { + + const baseCompConfig = { component: true, usingComponents: { [baseCompName]: `./${baseCompName}`, - [customWrapperName]: `./${customWrapperName}`, }, - }); + }; + + if (isUsingCustomWrapper) { + baseCompConfig.usingComponents[customWrapperName] = `./${customWrapperName}`; + this.generateConfigFile(compilation, compiler, customWrapperName, { + component: true, + styleIsolation: 'apply-shared', + usingComponents: { + [baseCompName]: `./${baseCompName}`, + [customWrapperName]: `./${customWrapperName}`, + }, + }); + } + + this.generateConfigFile(compilation, compiler, baseCompName, baseCompConfig); + } else { + if (isUsingCustomWrapper) { + this.generateConfigFile(compilation, compiler, customWrapperName, { + component: true, + usingComponents: { + [customWrapperName]: `./${customWrapperName}`, + }, + }); + } } - this.generateTemplateFile(compilation, baseTemplateName, template.buildTemplate, componentConfig); - this.generateTemplateFile( - compilation, - customWrapperName, - template.buildCustomComponentTemplate, - this.options.fileType.templ, - ); - this.generateXSFile(compilation, 'utils'); - this.pages.forEach(page => { - let importBaseTemplatePath = promoteRelativePath( + + this.generateTemplateFile(compilation, compiler, baseTemplateName, template.buildTemplate, componentConfig); + isUsingCustomWrapper && + this.generateTemplateFile( + compilation, + compiler, + customWrapperName, + template.buildCustomComponentTemplate, + this.options.fileType.templ, + ); + this.generateXSFile(compilation, compiler, 'utils'); + + this.components.forEach((component) => { + const importBaseTemplatePath = promoteRelativePath( + path.relative( + component.path, + path.join(sourceDir, isBuildPlugin ? 'plugin' : '', this.getTemplatePath(baseTemplateName)), + ), + ); + const config = this.filesConfig[this.getConfigFilePath(component.name)]; + if (config) { + this.generateConfigFile(compilation, compiler, component.path, config.content); + } + if (!component.isNative) { + this.generateTemplateFile( + compilation, + compiler, + component.path, + template.buildPageTemplate, + importBaseTemplatePath, + ); + } + }); + + this.pages.forEach((page) => { + const importBaseTemplatePath = promoteRelativePath( path.relative( page.path, - path.join(this.sourceDir, this.getTemplatePath(baseTemplateName)), + path.join(sourceDir, isBuildPlugin ? 'plugin' : '', this.getTemplatePath(baseTemplateName)), ), ); const config = this.filesConfig[this.getConfigFilePath(page.name)]; + // pages 里面会混合独立分包的,在这里需要过滤一下,避免重复生成 assets + const isIndependent = !!this.getIndependentPackage(page.path); + + if (isIndependent) return; + + // 生成页面模板需要在生成页面配置之前,因为会依赖到页面配置的部分内容 + if (!page.isNative) { + this.generateTemplateFile( + compilation, + compiler, + page.path, + template.buildPageTemplate, + importBaseTemplatePath, + config, + ); + } + if (config) { - let importBaseCompPath = promoteRelativePath(path.relative(page.path, path.join(this.sourceDir, this.getTargetFilePath(baseCompName, '')))); - let importCustomWrapperPath = promoteRelativePath(path.relative(page.path, path.join(this.sourceDir, this.getTargetFilePath(customWrapperName, '')))); + const importBaseCompPath = promoteRelativePath( + path.relative(page.path, path.join(sourceDir, this.getTargetFilePath(baseCompName, ''))), + ); + const importCustomWrapperPath = promoteRelativePath( + path.relative(page.path, path.join(sourceDir, this.getTargetFilePath(customWrapperName, ''))), + ); config.content.usingComponents = { - [customWrapperName]: importCustomWrapperPath, ...config.content.usingComponents, }; + + if (isUsingCustomWrapper) { + config.content.usingComponents[customWrapperName] = importCustomWrapperPath; + } if (!template.isSupportRecursive && !page.isNative) { config.content.usingComponents[baseCompName] = importBaseCompPath; } - this.generateConfigFile(compilation, page.path, config.content); - } - if (!page.isNative) { - this.generateTemplateFile(compilation, page.path, template.buildPageTemplate, importBaseTemplatePath); + this.generateConfigFile(compilation, compiler, page.path, config.content); } }); - this.generateTabBarFiles(compilation); - this.injectCommonStyles(compilation); + + this.generateTabBarFiles(compilation, compiler); + this.injectCommonStyles(compilation, compiler); if (this.themeLocation) { - this.generateDarkModeFile(compilation); + this.generateDarkModeFile(compilation, compiler); } - } - generateProjectConfigFile(compilation: webpack.Compilation) { - const { nativeConfig, projectConfigJson } = this.options; - if (projectConfigJson) { - const projectConfigStr = JSON.stringify(nativeConfig); - compilation.assets[projectConfigJson] = new RawSource(projectConfigStr); + if (isBuildPlugin) { + const pluginJSONPath = path.join(sourceDir, 'plugin', 'plugin.json'); + if (fs.existsSync(pluginJSONPath)) { + const pluginJSON = fs.readJSONSync(pluginJSONPath); + this.modifyPluginJSON(pluginJSON); + compilation.assets['plugin.json'] = new RawSource(JSON.stringify(pluginJSON)); + } } + + // 将三方的自定义组件信息输出到目录中,方便后续处理 + compilation.assets['third-party-components.json'] = new RawSource(JSON.stringify( + Array.from(componentConfig.thirdPartyComponents.entries()), + )); + } + + async optimizeMiniFiles(compilation: Compilation, _compiler: Compiler) { + const isUsingCustomWrapper = componentConfig.thirdPartyComponents.has('custom-wrapper'); + + /** + * 与原生小程序混写时解析模板与样式 + */ + compilation.getAssets().forEach(({ name: assetPath }) => { + const styleExt = this.options.fileType.style; + const templExt = this.options.fileType.templ; + if (new RegExp(`(\\${styleExt}|\\${templExt})\\.js(\\.map){0,1}$`).test(assetPath)) { + delete compilation.assets[assetPath]; + } else if (new RegExp(`${styleExt}${styleExt}$`).test(assetPath)) { + delete compilation.assets[assetPath]; + } + if (!isUsingCustomWrapper && assetPath === 'custom-wrapper.js') { + delete compilation.assets[assetPath]; + } + }); } generateConfigFile( - compilation: webpack.Compilation, + compilation: Compilation, + compiler: Compiler, filePath: string, - config: MiniappConfig & { component?: boolean }, + config: MiniappConfig & { + component?: boolean; + }, ) { + const { RawSource } = compiler.webpack.sources; const fileConfigName = this.getConfigPath(this.getComponentName(filePath)); - const unOfficalConfigs = ['components']; - unOfficalConfigs.forEach(item => { + + const unofficialConfigs = ['enableShareAppMessage', 'enableShareTimeline', 'enablePageMeta', 'components']; + unofficialConfigs.forEach((item) => { delete config[item]; }); + + this.adjustConfigContent(config); + const fileConfigStr = JSON.stringify(config); compilation.assets[fileConfigName] = new RawSource(fileConfigStr); } generateTemplateFile( - compilation: webpack.Compilation, + compilation: Compilation, + compiler: Compiler, filePath: string, templateFn: (...args) => string, ...options ) { + const { RawSource } = compiler.webpack.sources; let templStr = templateFn(...options); const fileTemplName = this.getTemplatePath(this.getComponentName(filePath)); - if (this.options.minifyXML?.collapseWhitespace) { + if (this.options.combination.config.minifyXML?.collapseWhitespace) { + const { minify } = require('html-minifier'); templStr = minify(templStr, { collapseWhitespace: true, keepClosingSlash: true, @@ -562,9 +1411,12 @@ export default class MiniPlugin { compilation.assets[fileTemplName] = new RawSource(templStr); } - generateXSFile(compilation: webpack.Compilation, xsPath) { + generateXSFile(compilation: Compilation, compiler: Compiler, xsPath) { + const { RawSource } = compiler.webpack.sources; const ext = this.options.fileType.xs; - if (ext == null) { + const isSupportXS = this.options.template.supportXS; + + if (ext == null || !isSupportXS) { return; } @@ -576,11 +1428,32 @@ export default class MiniPlugin { getComponentName(componentPath: string) { let componentName: string; - if (NODE_MODULES_REG.test(componentPath)) { - componentName = componentPath.replace(this.context, '').replace(/\\/g, '/').replace(path.extname(componentPath), ''); - componentName = componentName.replace(/node_modules/gi, 'npm'); + if (componentPath.startsWith(this.options.sourceDir)) { + // 如果在源码文件夹下,直接处理即可,无需考虑其他情况 + componentName = componentPath.slice(this.options.sourceDir.length) + .replace(/\\/g, '/') + .replace(path.extname(componentPath), ''); + if (this.options.isBuildPlugin) { + componentName = componentName.replace(/plugin\//, ''); + } + } else if (REG_NODE_MODULES.test(componentPath)) { + // 如果是在 npm 下的路径中 + const nodeModulesRegx = new RegExp(REG_NODE_MODULES, 'gi'); + + componentName = componentPath + .replace(this.context, '') + .replace(/\\/g, '/') + .replace(path.extname(componentPath), ''); + componentName = componentName.replace(nodeModulesRegx, 'npm'); } else { - componentName = componentPath.replace(this.sourceDir, '').replace(/\\/g, '/').replace(path.extname(componentPath), ''); + // 兜底情况,理论上应该不会走到这里来了 + componentName = componentPath + .replace(this.options.sourceDir, '') + .replace(/\\/g, '/') + .replace(path.extname(componentPath), ''); + if (this.options.isBuildPlugin) { + componentName = componentName.replace(/plugin\//, ''); + } } return componentName.replace(/^(\/|\\)/, ''); @@ -599,9 +1472,26 @@ export default class MiniPlugin { return this.getTargetFilePath(filePath, this.options.fileType.templ); } + getSkeletonExtraPath(filePath: string): IComponentExtraPath | null { + const { fileType } = this.options; + if (!fileType.skeletonMidExt) return null; + return { + template: this.getTargetFilePath(filePath, `${fileType.skeletonMidExt}${fileType.templ}`), + style: [ + this.getTargetFilePath(filePath, `${fileType.skeletonMidExt}${fileType.style}`), + this.getTargetFilePath(filePath, `${fileType.skeletonMidExt}.less`), + this.getTargetFilePath(filePath, `${fileType.skeletonMidExt}.sass`), + ], + }; + } + /** 处理样式文件后缀 */ getStylePath(filePath: string) { - return this.getTargetFilePath(filePath, this.options.fileType.style); + return [ + this.getTargetFilePath(filePath, this.options.fileType.style), + this.getTargetFilePath(filePath, '.less'), + this.getTargetFilePath(filePath, '.sass'), + ]; } /** 处理 config 文件后缀 */ @@ -622,8 +1512,9 @@ export default class MiniPlugin { * 输出 themeLocation 文件 * @param compilation */ - generateDarkModeFile(compilation: webpack.Compilation) { - const themeLocationPath = path.resolve(this.sourceDir, this.themeLocation); + generateDarkModeFile(compilation: Compilation, { webpack }: Compiler) { + const { RawSource } = webpack.sources; + const themeLocationPath = path.resolve(this.options.sourceDir, this.themeLocation); if (fs.existsSync(themeLocationPath)) { const themeLocationSource = fs.readFileSync(themeLocationPath); compilation.assets[this.themeLocation] = new RawSource(themeLocationSource); @@ -633,9 +1524,10 @@ export default class MiniPlugin { /** * 输出 tabbar icons 文件 */ - generateTabBarFiles(compilation: webpack.Compilation) { - this.tabBarIcons.forEach(icon => { - const iconPath = path.resolve(this.sourceDir, icon); + generateTabBarFiles(compilation: Compilation, { webpack }: Compiler) { + const { RawSource } = webpack.sources; + this.tabBarIcons.forEach((icon) => { + const iconPath = path.resolve(this.options.sourceDir, icon); if (fs.existsSync(iconPath)) { const iconSource = fs.readFileSync(iconPath); compilation.assets[icon] = new RawSource(iconSource); @@ -646,26 +1538,84 @@ export default class MiniPlugin { /** * 小程序全局样式文件中引入 common chunks 中的公共样式文件 */ - injectCommonStyles({ assets }: webpack.Compilation) { + injectCommonStyles({ assets }: Compilation, { webpack }: Compiler) { + const { newBlended } = this.options; + const { ConcatSource, RawSource } = webpack.sources; const styleExt = this.options.fileType.style; const appStyle = `app${styleExt}`; const REG_STYLE_EXT = new RegExp(`\\.(${styleExt.replace('.', '')})(\\?.*)?$`); - const source = new ConcatSource(''); - Object.keys(assets).forEach(assetName => { + + const originSource = assets[appStyle] || new RawSource(''); + const commons = new ConcatSource(''); + const componentCommons: string[] = []; + const independentPackageNames: string[] = []; + + this.independentPackages.forEach((_, name) => { + independentPackageNames.push(name); + }); + + Object.keys(assets).forEach((assetName) => { const fileName = path.basename(assetName, path.extname(assetName)); - if ((REG_STYLE.test(assetName) || - REG_STYLE_EXT.test(assetName)) && - this.options.commonChunks.includes(fileName) + if ( + (REG_STYLE.test(assetName) || REG_STYLE_EXT.test(assetName)) && + this.options.commonChunks.includes(fileName) && + // app.wxss 不能引入独立分包中的 common 样式文件 + independentPackageNames.every((name) => !assetName.includes(name)) ) { - source.add(`@import ${JSON.stringify(urlToRequest(assetName))};\n`); - assets[appStyle] = source; + commons.add('\n'); + commons.add(`@import ${JSON.stringify(urlToRequest(assetName))};`); + componentCommons.push(assetName); } }); + + if (commons.size() > 0) { + const APP_STYLE_NAME = `app-origin${styleExt}`; + assets[APP_STYLE_NAME] = new ConcatSource(originSource); + const source = new ConcatSource(''); + source.add(`@import ${JSON.stringify(urlToRequest(APP_STYLE_NAME))};`); + source.add(commons); + source.add('\n'); + assets[appStyle] = source; + if (newBlended) { + // 本地化组件引入common公共样式文件 + this.pages.forEach((page) => { + if (page.isNative) return; + const pageStyle = `${page.name}${styleExt}`; + if (this.nativeComponents.has(page.name)) { + // 本地化组件如果没有wxss则直接写入一个空的 + if (!(pageStyle in assets)) { + assets[pageStyle] = new ConcatSource(''); + } + const source = new ConcatSource(''); + const originSource = assets[pageStyle]; + componentCommons.forEach((item) => { + source.add( + `@import ${JSON.stringify(urlToRequest(path.posix.relative(path.dirname(pageStyle), item)))};\n`, + ); + }); + source.add(originSource); + assets[pageStyle] = source; + } else { + if (pageStyle in assets) { + const source = new ConcatSource(''); + const originSource = assets[pageStyle]; + source.add( + `@import ${JSON.stringify( + urlToRequest(path.posix.relative(path.dirname(pageStyle), `app${styleExt}`)), + )};\n`, + ); + source.add(originSource); + assets[pageStyle] = source; + } + } + }); + } + } } - addTarBarFilesToDependencies(compilation: webpack.Compilation) { + addTarBarFilesToDependencies(compilation: Compilation) { const { fileDependencies, missingDependencies } = compilation; - this.tabBarIcons.forEach(icon => { + this.tabBarIcons.forEach((icon) => { if (!fileDependencies.has(icon)) { fileDependencies.add(icon); } diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModule.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModule.ts index bac5b4f088..a9f6590a15 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModule.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModule.ts @@ -1,18 +1,88 @@ import webpack from '@ice/bundles/compiled/webpack/index.js'; +import { isEmpty } from '@ice/shared'; +import type { META_TYPE } from '../../../helper/index.js'; +import { componentConfig, elementNameSet, componentNameSet } from '../utils/component.js'; -export default class NormalModule extends webpack.NormalModule { +export class BaseNormalModule extends webpack.NormalModule { + elementNameSet: Set; + + componentNameSet: Set; + + collectProps: { [name: string]: string }; + + constructor(data) { + super(data); + + this.collectProps = {}; + this.elementNameSet = new Set(); + this.componentNameSet = new Set(); + } + + clear() { + this.collectProps = {}; + this.elementNameSet.clear(); + this.componentNameSet.clear(); + } + + serialize(context) { + const { write } = context; + + write(this.collectProps); + write(this.elementNameSet); + write(this.componentNameSet); + + super.serialize(context); + } + + deserialize(context) { + const { read } = context; + + this.collectProps = read(); + this.elementNameSet = read(); + this.componentNameSet = read(); + + if (!isEmpty(this.collectProps)) { + for (const key in this.collectProps) { + const attrs = componentConfig.thirdPartyComponents.get(key); + const value = this.collectProps[key]; + + if (!attrs) continue; + + value.split('|').forEach((item) => { + attrs.add(item); + }); + } + } + + for (const elementName of this.elementNameSet) { + elementNameSet.add(elementName); + } + for (const componentName of this.componentNameSet) { + componentNameSet.add(componentName); + } + + return super.deserialize(context); + } +} + +export default class NormalModule extends BaseNormalModule { name: string; - miniType: any; + miniType: META_TYPE; + // 在 TaroLoadChunksPlugin 用于判断是否为独立分包,来添加 common、runtime 和 vendor 头部引用 + isNativePage?: boolean; + constructor(data) { super(data); this.name = data.name; this.miniType = data.miniType; + this.isNativePage = data.isNativePage || false; } serialize(context) { const { write } = context; write(this.name); write(this.miniType); + write(this.isNativePage); super.serialize(context); } @@ -20,12 +90,16 @@ export default class NormalModule extends webpack.NormalModule { const { read } = context; this.name = read(); this.miniType = read(); + this.isNativePage = read(); super.deserialize(context); } } -export function registerSerialization() { - webpack.util.serialization.register(NormalModule, '@ice/app/esm/tasks/miniapp/webpack/plugins/NormalModule', 'NormalModule', { +webpack.util.serialization.register( + NormalModule, + '@ice/app/esm/tasks/miniapp/webpack/plugins/NormalModule', + 'NormalModule', + { serialize(obj, context) { obj.serialize(context); }, @@ -51,6 +125,38 @@ export function registerSerialization() { obj.deserialize(context); return obj; }, - }); -} + }, +); +webpack.util.serialization.register( + BaseNormalModule, + '@ice/app/esm/tasks/miniapp/webpack/plugins/BaseNormalModule', + 'BaseNormalModule', + { + serialize(obj, context) { + obj.serialize(context); + }, + deserialize(context) { + const obj = new BaseNormalModule({ + // will be deserialized by Module + layer: null, + type: '', + // will be filled by updateCacheModule + resource: '', + context: '', + request: null, + userRequest: null, + rawRequest: null, + loaders: null, + matchResource: null, + parser: null, + parserOptions: null, + generator: null, + generatorOptions: null, + resolveOptions: null, + }); + obj.deserialize(context); + return obj; + }, + }, +); diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModulesPlugin.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModulesPlugin.ts index ef71150368..4140a913ba 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModulesPlugin.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugins/NormalModulesPlugin.ts @@ -1,68 +1,139 @@ -import type webpack from '@ice/bundles/compiled/webpack/index.js'; +import type { Compiler } from '@ice/bundles/compiled/webpack/index.js'; import * as walk from 'acorn-walk'; +import type { Func } from '@ice/miniapp-runtime'; import SingleEntryDependency from '../dependencies/SingleEntryDependency.js'; -import { componentConfig } from '../template/component.js'; -import onParseCreateElement from '../../html/index.js'; -import NormalModule, { registerSerialization } from './NormalModule.js'; - +import { componentConfig, componentNameSet, elementNameSet } from '../utils/component.js'; +import NormalModule, { BaseNormalModule } from './NormalModule.js'; const PLUGIN_NAME = 'NormalModulesPlugin'; export default class NormalModulesPlugin { - constructor() { - registerSerialization(); + isCache = true; + + onParseCreateElement: Func | undefined; + + constructor(onParseCreateElement: Func | undefined) { + this.onParseCreateElement = onParseCreateElement; } - apply(compiler: webpack.Compiler) { - compiler.hooks.compilation.tap(PLUGIN_NAME, (_, { normalModuleFactory }) => { + + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation, { normalModuleFactory }) => { + // cache 开启后,会跳过 JavaScript parser 环节,因此需要收集组件信息,在 finishModules 阶段处理 + compilation.hooks.finishModules.tap(PLUGIN_NAME, (_) => { + if (!this.isCache) return; + + for (const name of elementNameSet) { + this.onParseCreateElement?.(name, componentConfig); + } + + for (const name of componentNameSet) { + if (name === 'CustomWrapper' && !componentConfig.thirdPartyComponents.get('custom-wrapper')) { + componentConfig.thirdPartyComponents.set('custom-wrapper', new Set()); + + return; + } + } + }); + normalModuleFactory.hooks.createModule.tapPromise(PLUGIN_NAME, (data, { dependencies }) => { const dependency = dependencies[0]; if (dependency instanceof SingleEntryDependency) { - return Promise.resolve(new NormalModule(Object.assign(data, - { miniType: dependency.miniType, name: dependency.name }, - ))); + return Promise.resolve( + new NormalModule( + Object.assign(data, { + miniType: dependency.miniType, + name: dependency.name, + isNativePage: dependency.options.isNativePage, + }), + ), + ); } - return Promise.resolve(); + return Promise.resolve(new BaseNormalModule(data)); }); // react 的第三方组件支持 normalModuleFactory.hooks.parser.for('javascript/auto').tap(PLUGIN_NAME, (parser) => { parser.hooks.program.tap(PLUGIN_NAME, (ast) => { - walk.simple(ast, { - CallExpression: node => { + this.isCache = false; + + const currentModule = parser.state.current as BaseNormalModule; + currentModule.clear(); + + walk.ancestor(ast, { + CallExpression: (node, _ancestors) => { // @ts-ignore const { callee } = node; + if (callee.type === 'MemberExpression') { + if (callee.property.type !== 'Identifier') { + return; + } if (callee.property.name !== 'createElement') { return; } } else { - // 兼容 react17 new jsx transtrom - if (callee.name !== '_jsx' && callee.name !== '_jsxs') { + const nameOfCallee = (callee as any).name; + if ( + // 兼容 react17 new jsx transtrom 以及esbuild-loader的ast兼容问题 + !/^_?jsxs?$/.test(nameOfCallee) && + // 兼容 Vue 3.0 渲染函数及 JSX + !(nameOfCallee && nameOfCallee.includes('createVNode')) && + !(nameOfCallee && nameOfCallee.includes('createBlock')) && + !(nameOfCallee && nameOfCallee.includes('createElementVNode')) && + !(nameOfCallee && nameOfCallee.includes('createElementBlock')) && + !(nameOfCallee && nameOfCallee.includes('resolveComponent')) && // 收集使用解析函数的组件名称 + !(nameOfCallee && nameOfCallee.includes('_$createElement')) // solidjs创建元素 + ) { return; } } // @ts-ignore const [type, prop] = node.arguments; + + if (!type) return; + const componentName = type.name; - onParseCreateElement({ nodeName: type.value, componentConfig }); + if (type.value) { + this.onParseCreateElement?.(type.value, componentConfig); + // @ts-ignore + currentModule.elementNameSet.add(type.value); + } - if (componentName === 'CustomWrapper' && !componentConfig.thirdPartyComponents.get('custom-wrapper')) { - componentConfig.thirdPartyComponents.set('custom-wrapper', new Set()); + if (componentName) { + currentModule.componentNameSet.add(componentName); + if (componentName === 'CustomWrapper' && !componentConfig.thirdPartyComponents.get('custom-wrapper')) { + componentConfig.thirdPartyComponents.set('custom-wrapper', new Set()); + } } + if (componentConfig.thirdPartyComponents.size === 0) { return; } + // @ts-ignore const attrs = componentConfig.thirdPartyComponents.get(type.value); if (attrs == null || !prop || prop.type !== 'ObjectExpression') { return; } - prop.properties - .filter(p => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name !== 'children') - .forEach(p => attrs.add(p.key.name)); + function getPropName(key): string { + return key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : null; + } + + const props = prop.properties.filter((p) => { + if (p.type !== 'Property') return false; + + const propName = getPropName(p.key); + return propName && propName !== 'children' && propName !== 'id'; + }); + // @ts-ignore + const res = props.map((p) => getPropName(p.key)).join('|'); + // @ts-ignore + props.forEach((p) => attrs.add(getPropName(p.key))); + // @ts-ignore + currentModule.collectProps[type.value] = res; }, }); }); diff --git a/packages/plugin-miniapp/src/miniapp/webpack/plugins/SingleEntryPlugin.ts b/packages/plugin-miniapp/src/miniapp/webpack/plugins/SingleEntryPlugin.ts index d27bdd9218..89f69efd74 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/plugins/SingleEntryPlugin.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/plugins/SingleEntryPlugin.ts @@ -6,7 +6,7 @@ export default class SingleEntryPlugin { name: string; miniType: any; - constructor(context, entry, name, miniType) { + constructor(context, entry, name, miniType, public options = {}) { this.context = context; this.entry = entry; this.name = name; @@ -14,25 +14,16 @@ export default class SingleEntryPlugin { } apply(compiler) { - compiler.hooks.compilation.tap( - 'SingleEntryDependency', - (compilation, { normalModuleFactory }) => { - compilation.dependencyFactories.set( - SingleEntryDependency, - normalModuleFactory, - ); - }, - ); + compiler.hooks.compilation.tap('SingleEntryDependency', (compilation, { normalModuleFactory }) => { + compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory); + }); - compiler.hooks.make.tapAsync( - 'SingleEntryPlugin', - (compilation, callback) => { - const { entry, name, context, miniType } = this; + compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => { + const { entry, name, context, miniType } = this; - const dep = SingleEntryPlugin.createDependency(entry, name, miniType); - compilation.addEntry(context, dep, name, callback); - }, - ); + const dep = SingleEntryPlugin.createDependency(entry, name, miniType); + compilation.addEntry(context, dep, name, callback); + }); } static createDependency(entry, name, miniType) { diff --git a/packages/plugin-miniapp/src/miniapp/webpack/template/component.ts b/packages/plugin-miniapp/src/miniapp/webpack/utils/component.ts similarity index 53% rename from packages/plugin-miniapp/src/miniapp/webpack/template/component.ts rename to packages/plugin-miniapp/src/miniapp/webpack/utils/component.ts index 32939253fd..ca30e83470 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/template/component.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/utils/component.ts @@ -9,9 +9,23 @@ export interface IComponentConfig { } export const componentConfig: IComponentConfig = { - includes: new Set(['view', 'catch-view', 'static-view', 'pure-view', 'scroll-view', 'image', 'static-image', 'text', 'static-text']), + includes: new Set([ + 'view', + 'catch-view', + 'static-view', + 'pure-view', + 'scroll-view', + 'image', + 'static-image', + 'text', + 'static-text', + ]), exclude: new Set(), thirdPartyComponents: new Map(), // TODO: include all components temporarily includeAll: true, }; + +// 用户 cache 功能开启时,记录 parser 过程中的组件信息 +export const elementNameSet = new Set(); +export const componentNameSet = new Set(); diff --git a/packages/plugin-miniapp/src/miniapp/webpack/utils/index.ts b/packages/plugin-miniapp/src/miniapp/webpack/utils/index.ts index 7e733ff285..3e2ad298f9 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/utils/index.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/utils/index.ts @@ -1,9 +1,6 @@ -import { networkInterfaces } from 'os'; -import path from 'path'; -import fs from 'fs-extra'; -import { SCRIPT_EXT } from '../../../constant.js'; +import { networkInterfaces } from 'node:os'; +import path from 'node:path'; -export const emptyObj = {}; export const emptyTogglableObj = { enable: false, config: {}, @@ -15,12 +12,15 @@ export const getRootPath = function () { export const addLeadingSlash = (url = '') => (url.charAt(0) === '/' ? url : `/${url}`); export const addTrailingSlash = (url = '') => (url.charAt(url.length - 1) === '/' ? url : `${url}/`); -export const hasBasename = (path = '', prefix = '') => new RegExp(`^${prefix}(\\/|\\?|#|$)`, 'i').test(path) || path === prefix; -export const stripBasename = (path = '', prefix = '') => (hasBasename(path, prefix) ? path.substr(prefix.length) : path); -export const stripTrailingSlash = (path = '') => (path.charAt(path.length - 1) === '/' ? path.substring(0, path.length - 1) : path); +export const hasBasename = (path = '', prefix = '') => + new RegExp(`^${prefix}(\\/|\\?|#|$)`, 'i').test(path) || path === prefix; +export const stripBasename = (path = '', prefix = '') => + (hasBasename(path, prefix) ? path.substr(prefix.length) : path); +export const stripTrailingSlash = (path = '') => + (path.charAt(path.length - 1) === '/' ? path.substring(0, path.length - 1) : path); export const addHtmlSuffix = (path = '') => `${path}.html`; -export const formatOpenHost = host => { +export const formatOpenHost = (host?: string) => { let result = host; // 配置host为0.0.0.0时,可以转换为ip打开, 其他以配置host默认打开 if (!result || result === '0.0.0.0' || result.startsWith('local-ip')) { @@ -28,9 +28,9 @@ export const formatOpenHost = host => { result = 'localhost'; const interfaces = networkInterfaces(); for (const devName in interfaces) { - const isEnd = interfaces[devName]?.some(item => { + const isEnd = interfaces[devName]?.some((item) => { // 取IPv4, 不为127.0.0.1的内网ip - if (item.family === 'IPv4' && item.address !== '127.0.0.1' && !item.internal) { + if (['IPv4', 4, '4'].includes(item.family) && item.address !== '127.0.0.1' && !item.internal) { result = item.address; return true; } @@ -45,39 +45,6 @@ export const formatOpenHost = host => { return result; }; -export function normalizePath(fpath: string) { - return fpath.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); -} - -export function promoteRelativePath(fPath: string): string { - const fPathArr = fPath.split(path.sep); - let dotCount = 0; - fPathArr.forEach(item => { - if (item.indexOf('..') >= 0) { - dotCount++; - } - }); - if (dotCount === 1) { - fPathArr.splice(0, 1, '.'); - return fPathArr.join('/'); - } - if (dotCount > 1) { - fPathArr.splice(0, 1); - return fPathArr.join('/'); - } - return normalizePath(fPath); -} - -export function resolveMainFilePath(p: string, extArrs = SCRIPT_EXT): string { - const realPath = p; - for (let i = 0; i < extArrs.length; i++) { - const item = extArrs[i]; - if (fs.existsSync(`${p}${item}`)) { - return `${p}${item}`; - } - if (fs.existsSync(`${p}${path.sep}index${item}`)) { - return `${p}${path.sep}index${item}`; - } - } - return realPath; +export function parsePublicPath(publicPath = '/') { + return ['', 'auto'].includes(publicPath) ? publicPath : addTrailingSlash(publicPath); } diff --git a/packages/plugin-miniapp/src/miniapp/webpack/utils/types.ts b/packages/plugin-miniapp/src/miniapp/webpack/utils/types.ts new file mode 100644 index 0000000000..9a833123b1 --- /dev/null +++ b/packages/plugin-miniapp/src/miniapp/webpack/utils/types.ts @@ -0,0 +1,341 @@ +import type { MiniappAppConfig, MiniappConfig } from '@ice/miniapp-runtime/esm/types.js'; +import type { RecursiveTemplate, UnRecursiveTemplate } from '@ice/shared'; +import type { Compilation, Compiler } from '@ice/bundles/compiled/webpack'; +import type { IComponentConfig } from './component.js'; + +export interface IOption { + [key: string]: any; +} + +export interface IComponent { + name: string; + path: string; + isNative: boolean; + stylePath?: string[]; + templatePath?: string; + skeletonPath?: IComponentExtraPath; +} + +export interface IComponentExtraPath { + template?: string; + style?: string[]; +} + +export interface IComponentObj { + name?: string; + path: string | null; + type?: string; +} + +export interface IChain { + [key: string]: any; +} + +export interface IFileType { + style: string; + script: string; + templ: string; + config: string; + xs?: string; + skeletonMidExt?: string; +} + +interface IMiniAppConfig { + isWatch?: boolean; + port?: number; + /** 项目名称 */ + projectName?: string; + + /** 项目创建日期 */ + date?: string; + + /** 设计稿尺寸 */ + // designWidth?: number | ((size?: string | number | Input) => number) + + /** 设计稿尺寸换算规则 */ + // deviceRatio?: TaroGeneral.TDeviceRatio + + watcher?: any[]; + + /** 源码存放目录 (默认值:'src') */ + sourceRoot?: string; + + /** 代码编译后的生产目录 (默认值:'dist') */ + outputRoot?: string; + + /** + * 用于配置`process.env.xxxx`相关的环境变量 + * @deprecated 建议使用根目录下的 .env 文件替代 + * @description 注意:这里的环境变量只能在业务代码中使用,编译时的 node 环境中无法使用 + * @example + * ```ts + * // config/index.ts + * export default defineConfig({ + * env: { + * xxxx: '"测试"' + * } + * }) + * + * // src/app.ts + * onShow() { + * console.log(process.env.xxxx) // 打印 "测试" + * } + * ``` + */ + env?: IOption; + + /** 用于配置目录别名,从而方便书写代码引用路径 */ + alias?: IOption; + + /** + * 用于配置一些常量供代码中进行全局替换使用 + * @description 注意:这里的环境变量只能在业务代码中使用,编译时的 node 环境中无法使用 + * @example + * ```ts + * // config/index.ts + * export default defineConfig({ + * defineConstants: { + * __TEST__: JSON.stringify('test') + * } + * }) + * + * // src/app.ts + * onShow() { + * console.log(__TEST__) // 打印 "test" + * } + * ``` + */ + defineConstants?: IOption; + + /** 用于把文件从源码目录直接拷贝到编译后的生产目录 */ + // copy?: ICopyOptions + + /** 配置 JS 压缩工具 (默认 terser) */ + jsMinimizer?: 'terser' | 'esbuild'; + + /** 配置 CSS 压缩工具 (默认 csso) */ + cssMinimizer?: 'csso' | 'esbuild' | 'lightningcss'; + + /** 配置 csso 工具以压缩 CSS 代码 */ + // csso?: TogglableOptions + + /** 配置 terser 工具以压缩 JS 代码 */ + // terser?: TogglableOptions + + // esbuild?: Record<'minify', TogglableOptions> + + // uglify?: TogglableOptions + + /** 用于控制对 scss 代码的编译行为,默认使用 dart-sass,具体配置可以参考 https://www.npmjs.com/package/sass */ + // sass?: ISassOptions + + /** 配置 Taro 插件 */ + // plugins?: PluginItem[] + + /** 一个 preset 是一系列 Taro 插件的集合,配置语法同 plugins */ + // presets?: PluginItem[] + + /** 模板循环次数 */ + baseLevel?: number; + + /** 使用的开发框架。可选值:react、preact、vue3 */ + framework?: 'react' | 'preact' | 'solid' | 'vue3'; + frameworkExts?: string[]; + + /** 使用的编译工具。可选值:webpack5 */ + // compiler?: Compiler + + /** Webpack5 持久化缓存配置。具体配置请参考 [WebpackConfig.cache](https://webpack.js.org/configuration/cache/#cache) */ + // cache?: ICache + + /** 控制 Taro 编译日志的输出方式 */ + // logger?: ILogger + logger?: any; + + /** 用于控制是否生成 js、css 对应的 sourceMap */ + enableSourceMap?: boolean; + + /** + * 编译开始 + */ + onBuildStart?: (...args: any[]) => Promise; + + /** + * 编译完成(启动项目后首次编译结束后会触发一次) + */ + onBuildComplete?: (...args: any[]) => Promise; + + /** + * 编译结束(保存代码每次编译结束后都会触发) + */ + onBuildFinish?: (res: { error; stats; isWatch }) => Promise; + + /** + * 修改编译过程中的页面组件配置 + */ + onCompilerMake?: (compilation: Compilation, compiler: Compiler, plugin: any) => Promise; + + // onWebpackChainReady?: (webpackChain: Chain) => Promise + + modifyAppConfig?: (appConfig: MiniappAppConfig) => Promise; + + /** + * 编译中修改 webpack 配置,在这个钩子中,你可以对 webpackChain 作出想要的调整,等同于配置 [`webpackChain`](./config-detail#miniwebpackchain) + */ + // modifyWebpackChain?: (chain: Chain, webpack: typeof Webpack, data: IModifyChainData) => Promise + + /** + * 编译中修改 vite 配置 + */ + // modifyViteConfig?: (viteConfig: any, data: IModifyChainData) => void + + /** + * 修改编译后的结果 + */ + modifyBuildAssets?: (assets: any, miniPlugin?: any) => Promise; + + /** + * 修改编译过程中的页面组件配置 + */ + modifyMiniConfigs?: (configMap: IMiniFilesConfig) => Promise; + + /** + * 修改 Taro 编译配置 + */ + modifyRunnerOpts?: (opts: any) => Promise; +} + +interface IProjectBaseConfig { + /** 用于控制是否生成 js、css 对应的 sourceMap (默认值:watch 模式下为 true,否则为 false) */ + enableSourceMap?: boolean; + + /** 默认值:'cheap-module-source-map', 具体参考[Webpack devtool 配置](https://webpack.js.org/configuration/devtool/#devtool) */ + sourceMapType?: string; + + /** 指定 React 框架相关的代码是否使用开发环境(未压缩)代码,默认使用生产环境(压缩后)代码 */ + debugReact?: boolean; + + /** 是否跳过第三方依赖 usingComponent 的处理,默认为自动处理第三方依赖的自定义组件 */ + skipProcessUsingComponents?: boolean; + + /** 压缩小程序 xml 文件的相关配置 */ + minifyXML?: { + /** 是否合并 xml 文件中的空格 (默认false) */ + collapseWhitespace?: boolean; + }; + + /** + * 自定义 Webpack 配置 + * @param chain [webpackChain](https://github.com/neutrinojs/webpack-chain) 对象 + * @param webpack webpack 实例 + * @param PARSE_AST_TYPE 小程序编译时的文件类型集合 + * @returns + */ + // webpackChain?: (chain: Chain, webpack: typeof Webpack, PARSE_AST_TYPE: any) => void + + /** webpack 编译模式下,可用于修改、拓展 Webpack 的 output 选项,配置项参考[官方文档](https://webpack.js.org/configuration/output/) + * vite 编译模式下,用于修改、扩展 rollup 的 output,目前仅适配 chunkFileNames 和 assetFileNames 两个配置,修改其他配置请使用 vite 插件进行修改。配置想参考[官方文档](https://rollupjs.org/configuration-options/) + */ + // output?: T extends 'vite' + // ? Pick & OutputExt + // : Webpack.Configuration['output'] & OutputExt + + /** 配置 postcss 相关插件 */ + // postcss?: IPostcssOption<'mini'> + + /** [css-loader](https://github.com/webpack-contrib/css-loader) 的附加配置 */ + cssLoaderOption?: IOption; + + /** [sass-loader](https://github.com/webpack-contrib/sass-loader) 的附加配置 */ + sassLoaderOption?: IOption; + + /** [less-loader](https://github.com/webpack-contrib/less-loader) 的附加配置 */ + lessLoaderOption?: IOption; + + /** [stylus-loader](https://github.com/shama/stylus-loader) 的附加配置 */ + stylusLoaderOption?: IOption; + + /** 针对 mp4 | webm | ogg | mp3 | wav | flac | aac 文件的 [url-loader](https://github.com/webpack-contrib/url-loader) 配置 */ + // mediaUrlLoaderOption?: IUrlLoaderOption + + /** 针对 woff | woff2 | eot | ttf | otf 文件的 [url-loader](https://github.com/webpack-contrib/url-loader) 配置 */ + // fontUrlLoaderOption?: IUrlLoaderOption + + /** 针对 png | jpg | jpeg | gif | bpm | svg 文件的 [url-loader](https://github.com/webpack-contrib/url-loader) 配置 */ + // imageUrlLoaderOption?: IUrlLoaderOption + + /** [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) 的附加配置 */ + miniCssExtractPluginOption?: IOption; + + /** 用于告诉 Taro 编译器需要抽取的公共文件 */ + commonChunks?: string[] | ((commonChunks: string[]) => string[]); + + /** 为某些页面单独指定需要引用的公共文件 */ + addChunkPages?: (pages: Map, pagesNames?: string[]) => void; + + /** 优化主包的体积大小 */ + optimizeMainPackage?: { + enable?: boolean; + exclude?: any[]; + }; + + /** 小程序编译过程的相关配置 */ + compile?: { + exclude?: (string | RegExp)[]; + include?: (string | RegExp)[]; + filter?: (filename: string) => boolean; + }; + + /** 插件内部使用 */ + // runtime?: Runtime + + /** 使用的编译工具。可选值:webpack5、vite */ + // compiler?: Compiler + + /** 体验式功能 */ + experimental?: { + /** 是否开启编译模式 */ + compileMode?: boolean | string; + }; +} + +export interface CommonBuildConfig extends IProjectBaseConfig { + // entry?: webpack.EntryObject; + mode: 'production' | 'development' | 'none'; + buildAdapter: string; // weapp | swan | alipay | tt | qq | jd | h5 + platformType: string; // mini | web + /** special mode */ + isBuildNativeComp?: boolean; + newBlended?: boolean; + withoutBuild?: boolean; + noInjectGlobalStyle?: boolean; + /** hooks */ + onParseCreateElement: (nodeName, componentConfig) => Promise; + modifyComponentConfig: (componentConfig: IComponentConfig, config: Partial) => Promise; +} + +export interface IMiniBuildConfig extends CommonBuildConfig, IMiniAppConfig { + isBuildPlugin: boolean; + isSupportRecursive: boolean; + isSupportXS: boolean; + nodeModulesPath: string; + fileType: IFileType; + globalObject: string; + platform: string; + // prerender?: PrerenderConfig + prerender?: never; + template: RecursiveTemplate | UnRecursiveTemplate; + runtimePath?: string | string[]; + taroComponentsPath: string; + blended?: boolean; + hot?: boolean; +} + +export type AddPageChunks = (pages: Map, pagesNames?: string[]) => void; + +export interface IMiniFilesConfig { + [configName: string]: { + content: MiniappConfig; + path: string; + }; +} diff --git a/packages/plugin-miniapp/src/miniapp/webpack/utils/webpack.ts b/packages/plugin-miniapp/src/miniapp/webpack/utils/webpack.ts index 109c2cd3a5..4ee061c6a9 100644 --- a/packages/plugin-miniapp/src/miniapp/webpack/utils/webpack.ts +++ b/packages/plugin-miniapp/src/miniapp/webpack/utils/webpack.ts @@ -1,6 +1,6 @@ import path from 'path'; import webpack from '@ice/bundles/compiled/webpack/index.js'; -import { promoteRelativePath } from './index.js'; +import { promoteRelativePath } from '../../../helper/index.js'; const { ConcatSource } = webpack.sources; @@ -15,13 +15,13 @@ export function getChunkEntryModule(compilation: webpack.Compilation, chunk: web /** * 在文本头部加入一些 require 语句 */ - export function addRequireToSource( +export function addRequireToSource( id: string, modules: any, commonChunks: (webpack.Chunk | { name: string })[], ): webpack.sources.ConcatSource { const source = new ConcatSource(); - commonChunks.forEach(chunkItem => { + commonChunks.forEach((chunkItem) => { source.add(`require(${JSON.stringify(promoteRelativePath(path.relative(id, chunkItem.name)))});\n`); }); source.add('\n'); diff --git a/packages/plugin-miniapp/src/targets/ali/components.ts b/packages/plugin-miniapp/src/targets/ali/components.ts new file mode 100644 index 0000000000..14d927688f --- /dev/null +++ b/packages/plugin-miniapp/src/targets/ali/components.ts @@ -0,0 +1,205 @@ +import { singleQuote } from '@ice/shared'; + +export const components = { + // ======== 调整属性 ======== + View: { + 'disable-scroll': 'false', + hidden: 'false', + bindAppear: '', + bindDisappear: '', + bindFirstAppear: '', + }, + Text: { + 'number-of-lines': '', + }, + Map: { + skew: '0', + rotate: '0', + polygons: '[]', + 'include-padding': '', + 'ground-overlays': '[]', + 'tile-overlay': '', + 'custom-map-style': '', + panels: '[]', + setting: '{}', + optimize: 'false', + 'show-compass': 'false', + 'show-scale': 'false', + 'enable-overlooking': 'false', + 'enable-zoom': 'true', + 'enable-scroll': 'true', + 'enable-rotate': 'false', + 'enable-traffic': 'false', + 'enable-poi': 'true', + 'enable-building': 'true', + 'enable-satellite': 'false', + bindRegionChange: '', + bindPanelTap: '', + bindInitComplete: '', + }, + Button: { + scope: '', + 'public-id': '', + bindGetAuthorize: '', + bindError: '', + bindGetUserInfo: '', + bindGetPhoneNumber: '', + bindFollowLifestyle: '', + }, + Checkbox: { + bindChange: '', + }, + Input: { + 'always-system': 'false', + 'random-number': 'false', + controlled: 'false', + enableNative: 'true', + name: '', + }, + Slider: { + 'track-size': '4', + 'handle-size': '22', + 'handle-color': singleQuote('#ffffff'), + }, + Switch: { + controlled: 'false', + }, + Textarea: { + 'show-count': 'true', + controlled: 'false', + enableNative: 'false', + }, + MovableView: { + bindChangeEnd: '', + }, + ScrollView: { + 'scroll-animation-duration': '', + 'trap-scroll': 'false', + }, + Swiper: { + 'active-class': '', + 'changing-class': '', + acceleration: 'false', + 'disable-programmatic-animation': 'false', + 'disable-touch': 'false', + bindAnimationEnd: '', + }, + Image: { + 'default-source': '', + }, + Camera: { + mode: singleQuote('normal'), + 'output-dimension': singleQuote('720P'), + 'frame-size': singleQuote('medium'), + bindScanCode: '', + bindReady: '', + }, + Canvas: { + type: '', + width: singleQuote('300px'), + height: singleQuote('225px'), + bindReady: '', + }, + Video: { + 'poster-size': singleQuote('contain'), + 'show-thin-progress-bar': 'false', + 'mobilenet-hint-type': '1', + 'floating-mode': singleQuote('none'), + enableNative: 'true', + bindLoading: '', + bindUserAction: '', + bindStop: '', + bindRenderStart: '', + }, + // ======== 额外组件 ======== + Lottie: { + autoplay: 'false', + path: '', + speed: '1.0', + 'repeat-count': '0', + 'auto-reverse': 'false', + 'assets-path': '', + placeholder: '', + djangoId: '', + md5: '', + optimize: 'false', + bindDataReady: '', + bindDataFailed: '', + bindAnimationStart: '', + bindAnimationEnd: '', + bindAnimationRepeat: '', + bindAnimationCancel: '', + bindDataLoadReady: '', + }, + Lifestyle: { + 'public-id': '', + memo: '', + bindFollow: '', + }, + LifeFollow: { + sceneId: '', + checkFollow: '', + bindCheckFollow: '', + bindClose: '', + }, + ContactButton: { + 'tnt-inst-id': '', + scene: '', + size: '25', + color: singleQuote('#00A3FF'), + icon: '', + 'alipay-card-no': '', + 'ext-info': '', + }, + ArCamera: { + devicePosition: singleQuote('back'), + marker: '', + mode: singleQuote('imageTracking'), + useCapturedImage: 'false', + bindInit: '', + bindStop: '', + bindError: '', + bindARFrame: '', + }, + PageContainer: { + show: 'false', + duration: '300', + 'z-index': '100', + overlay: 'true', + position: singleQuote('bottom'), + round: 'false', + 'close-on-slide-down': 'false', + 'overlay-style': '', + 'custom-style': '', + bindBeforeEnter: '', + bindEnter: '', + bindEnterCancelled: '', + bindAfterEnter: '', + bindBeforeLeave: '', + bindLeave: '', + bindLeaveCancelled: '', + bindAfterLeave: '', + bindClickOverlay: '', + }, + ShareElement: { + name: '', + transform: 'false', + duration: '300', + 'easing-function': singleQuote('ease-out'), + }, + RootPortal: { + enable: 'true', + }, + PageMeta: { + 'background-color': '', + 'background-color-top': '', + 'background-color-bottom': '', + 'root-background-color': '', + 'scroll-top': "''", + 'scroll-duration': '300', + 'page-style': "''", + 'root-font-size': "''", + 'page-font-size': "''", + bindScroll: '', + }, +}; diff --git a/packages/plugin-miniapp/src/targets/ali/index.ts b/packages/plugin-miniapp/src/targets/ali/index.ts index 281ca435fb..e3c4ec1226 100644 --- a/packages/plugin-miniapp/src/targets/ali/index.ts +++ b/packages/plugin-miniapp/src/targets/ali/index.ts @@ -1,4 +1,5 @@ import Template from './template.js'; +import { components } from './components.js'; export default { globalObject: 'my', @@ -9,6 +10,71 @@ export default { config: '.json', script: '.js', xs: '.sjs', + skeletonMidExt: '.loading', }, template: new Template(), + modifyBuildAssets, + components, }; + +function getIsBuildPluginPath(filePath, isBuildPlugin) { + return isBuildPlugin ? `plugin/${filePath}` : filePath; +} + +async function modifyBuildAssets(assets: any, miniPlugin: any) { + const pages: string[] = []; + + // 筛选出使用了自定义组件的页面 + miniPlugin.pages.forEach(page => { + const config = miniPlugin.filesConfig[miniPlugin.getConfigFilePath(page.name)].content; + if (!page.isNative && config?.hasOwnProperty('usingComponents') && Object.keys(config.usingComponents).length) { + pages.push(page.name); + } + }); + + if (!pages.length) return; + + const baseXml = assets[getIsBuildPluginPath('base.axml', miniPlugin.options.isBuildPlugin)].source(); + + pages.forEach(page => { + const templateName = `${page}.axml`; + const assetsItem = assets[templateName]; + const src = assetsItem._value ? assetsItem._value.toString() : assetsItem.source(); + let relativePath; + const templateCaller = src.replace(//, (_, $1) => { + relativePath = $1; + return ''; + }); + const main = baseXml.replace(//, () => { + return src.includes('`; + }); + + const res = `${templateCaller} +${main}`; + assets[templateName] = { + size: () => res.length, + source: () => res, + }; + }); + if (miniPlugin.options.isBuildPlugin) { + const miniProjectJSONStr = JSON.stringify({ + miniprogramRoot: 'miniprogram', + pluginRoot: 'plugin', + compileType: 'plugin', + }, null, 2); + assets['mini.project.json'] = { + size: () => miniProjectJSONStr.length, + source: () => miniProjectJSONStr, + }; + const pluginJSON = JSON.parse(assets['/plugin/plugin.json'].source()); + pluginJSON.publicPages = pluginJSON.pages; + pluginJSON.pages = Object.values(pluginJSON.publicPages); + const pluginJSONStr = JSON.stringify(pluginJSON, null, 2); + assets['/plugin/plugin.json'] = { + size: () => pluginJSONStr.length, + source: () => pluginJSONStr, + }; + } +} diff --git a/packages/plugin-miniapp/src/targets/ali/runtime.ts b/packages/plugin-miniapp/src/targets/ali/runtime.ts new file mode 100644 index 0000000000..bba94b9d88 --- /dev/null +++ b/packages/plugin-miniapp/src/targets/ali/runtime.ts @@ -0,0 +1,17 @@ +import { hooks, mergeInternalComponents } from '@ice/shared'; +import { createEventHandlerForThirdComponent, bindEventHandlersForThirdComponentNode } from '@ice/miniapp-runtime'; +import { components } from './components.js'; + +mergeInternalComponents(components); + +hooks.tap('onAddEvent', (type, handler, options, node) => { + const instance = node._root.ctx; + if (!instance) { + return; + } + instance[`eh_${node.sid}_${type}`] = createEventHandlerForThirdComponent(node.sid, type); +}); + +hooks.tap('hydrateNativeComponentNode', node => { + bindEventHandlersForThirdComponentNode(node); +}); diff --git a/packages/plugin-miniapp/src/targets/ali/template.ts b/packages/plugin-miniapp/src/targets/ali/template.ts index 756b63af7b..a56a4c0a01 100644 --- a/packages/plugin-miniapp/src/targets/ali/template.ts +++ b/packages/plugin-miniapp/src/targets/ali/template.ts @@ -1,8 +1,11 @@ -import { capitalize, toCamelCase, RecursiveTemplate } from '@ice/shared'; +import { capitalize, RecursiveTemplate, Shortcuts, toCamelCase } from '@ice/shared'; +import { components } from './components.js'; + export default class Template extends RecursiveTemplate { exportExpr = 'export default'; supportXS = true; + isXMLSupportRecursiveReference = false; adapter = { if: 'a:if', else: 'a:else', @@ -15,8 +18,15 @@ export default class Template extends RecursiveTemplate { type: 'alipay', }; - buildXsTemplate() { - return ''; + transferComponents: Record> = {}; + + constructor() { + super(); + this.nestElements.set('root-portal', 3); + } + + buildXsTemplate(filePath = './utils') { + return ``; } replacePropName(name, value, compName, componentAlias) { @@ -45,14 +55,13 @@ export default class Template extends RecursiveTemplate { buildThirdPartyAttr(attrs: Set) { return [...attrs].reduce((str, attr) => { if (attr.startsWith('@')) { + // TODO: vue 模式暂时不支持 return `${str}on${capitalize(attr.slice(1))}="eh" `; - } else if (attr.startsWith('bind')) { - return `${str}${attr}="eh" `; - } else if (attr.startsWith('on')) { - return `${str}${attr}="eh" `; + } else if (attr.startsWith('bind') || attr.startsWith('on')) { + return `${str}${attr}="eh_{{ i.sid }}_${attr.replace(/^(bind|on)/, '').toLowerCase()}" `; } - return `${str}${attr}="{{ i.${toCamelCase(attr)} }}" `; + return `${str} ${attr}="{{ i.${toCamelCase(attr)} }}" `; }, ''); } @@ -62,6 +71,11 @@ export default class Template extends RecursiveTemplate { // 兼容支付宝 2.0 构建 delete result.slot; delete result['slot-view']; + delete result['native-slot']; + + // PageMeta & NavigationBar + this.transferComponents['page-meta'] = result['page-meta']; + delete result['page-meta']; return result; } @@ -88,7 +102,7 @@ export default class Template extends RecursiveTemplate { -