diff --git a/.agents/skills/mpx2rn/SKILL.md b/.agents/skills/mpx2rn/SKILL.md index 6f9d3f6392..917ab6cc8b 100644 --- a/.agents/skills/mpx2rn/SKILL.md +++ b/.agents/skills/mpx2rn/SKILL.md @@ -2,7 +2,7 @@ name: mpx2rn description: Mpx 跨端输出 RN(简称 Mpx2RN 或 Mpx2DRN)的开发适配指南,覆盖模板、脚本、样式、JSON 配置四大维度。当用户进行 Mpx2RN 相关任务时强制调用,包括但不限于:技术方案设计、页面 / 组件的开发迭代、旧项目跨端适配改造、编译和运行时报错排查、Code Review 等。当用户问题不涉及 Mpx2RN 时不应调用,如 Mpx 小程序开发问题,RN 原生开发问题、Mpx2Web 相关问题等。 metadata: - version: "2.12.7" + version: "2.12.9" author: donghongping --- diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index bd62d9b721..65b95603e8 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -263,6 +263,8 @@ mpx.use(apiProxy, { 同步获取窗口、屏幕与安全区信息,常用于布局与刘海区域避让。 +可通过 `Mpx.config.rnConfig.customDimensions` 自定义尺寸,`getWindowInfo`、`getSystemInfo` 和 `getSystemInfoSync` 会基于处理后的尺寸计算相关字段。 + #### 入参 无。 diff --git a/.agents/skills/mpx2rn/references/rn-script-reference.md b/.agents/skills/mpx2rn/references/rn-script-reference.md index 7c301c9379..bdd86f7175 100644 --- a/.agents/skills/mpx2rn/references/rn-script-reference.md +++ b/.agents/skills/mpx2rn/references/rn-script-reference.md @@ -693,6 +693,7 @@ import Mpx from "@mpxjs/core" // 须在 createApp 与页面脚本执行前完成赋值 Mpx.config.rnConfig = { + dimensionsBase: "window", parseAppProps(props) { return { initialRouteName: "pages/index", @@ -721,6 +722,7 @@ Mpx.config.rnConfig = { | `onStateChange` | 导航 state 变化时回调。 | | `disablePageTransition` | 为 `true` 时禁用 RN 页面转场动画,框架内部映射为 `animation: "none"`。 | | `disableAppStateListener` | 为 `true` 时不注册 `AppState` 监听(避免与宿主 App 重复)。 | +| `dimensionsBase` | `rpx` / `vw` / `vh`、媒体查询与 `onResize` 使用的尺寸基准,可选 `"window"` 或 `"screen"`,默认为 `"window"`。首次尺寸计算后该配置固定,运行期间修改不会生效。 | | `openTypeHandler` | 对象,注册 `button` 组件在 RN 上 `open-type` 的容器侧实现,未注册对应键时点击会告警。 | | `openTypeHandler.onShareAppMessage` | 对应模板中 `open-type="share"`:框架会先取当前页 `onShareAppMessage` 的返回(含与默认 `title` / `path` 的合并及可选 `promise` 异步结果),再调用本回调,入参为 `{ title, path, imageUrl? }`,由宿主调起系统分享等能力。 | | `openTypeHandler.onUserInfo` | 对应模板中 `open-type="getUserInfo"`:由宿主实现获取用户信息的逻辑,结果需满足按钮侧对 `bindgetuserinfo` 的约定(以 `@mpxjs/webpack-plugin` 中 `mpx-button` 运行时为准)。 | diff --git a/.agents/skills/mpx2rn/references/rn-style-reference.md b/.agents/skills/mpx2rn/references/rn-style-reference.md index 6e02481b89..4896f5e469 100644 --- a/.agents/skills/mpx2rn/references/rn-style-reference.md +++ b/.agents/skills/mpx2rn/references/rn-style-reference.md @@ -168,15 +168,23 @@ Mpx 在 RN 平台支持多种 CSS 单位,并在运行时进行转换。 | 单位 | 说明 | 转换规则 | | --- | --- | --- | | `px` | 绝对像素 | 直接转换为 RN 的无单位数值 | -| `rpx` | 响应式像素 | `rpx值 × 屏幕宽度 / 750` | +| `rpx` | 响应式像素 | 按 `rpx值 × 视口基准宽度 / 750` 转换 | | `%` | 百分比 | 转换为字符串形式(如 `'50%'`),由 RN 原生支持或框架处理 | -| `vw` | 视口宽度百分比 | `vw值 × 屏幕宽度 / 100` | -| `vh` | 视口高度百分比 | `vh值 × 屏幕高度 / 100` | +| `vw` | 视口宽度百分比 | 按 `vw值 × 视口基准宽度 / 100` 转换 | +| `vh` | 视口高度百分比 | 按 `vh值 × 视口基准高度 / 100` 转换 | | `hairlineWidth` | RN 特有极细线 | `StyleSheet.hairlineWidth` | #### 样式计算基准与自定义 -`rpx`、`vw`、`vh` 的计算默认基于运行时的 `screen.width` 和 `screen.height`。 +`rpx`、`vw`、`vh` 与媒体查询的计算默认基于运行时的 `window.width` 和 `window.height`。如需保持旧版本基于 Screen 尺寸计算的效果,可将 `Mpx.config.rnConfig.dimensionsBase` 设置为 `"screen"`: + +`dimensionsBase` 应在首次尺寸计算前完成配置,首次读取后将保持不变。`customDimensions` 也建议在应用初始化阶段完成配置。 + +```javascript +Mpx.config.rnConfig.dimensionsBase = "screen" +``` + +该配置支持 `"window"` 和 `"screen"`,默认值为 `"window"`。所选尺寸发生变化时,依赖响应式单位或媒体查询的组件会重新计算样式。 同时支持通过运行时配置 `Mpx.config.rnConfig.customDimensions` 自定义样式计算基准: @@ -188,18 +196,15 @@ mpx.config.rnConfig = Object.assign({}, mpx.config.rnConfig, { const nextWindow = Object.assign({}, dimensions.window, { height: dimensions.window.height - 44 }) - const nextScreen = Object.assign({}, dimensions.screen, { - height: dimensions.screen.height - 44 - }) return { window: nextWindow, - screen: nextScreen + screen: dimensions.screen } } }) ``` -配置生效后,`rpx`、`vw`、`vh` 会按自定义后的 `screen` 宽高进行计算。 +配置生效后,`rpx`、`vw`、`vh` 与媒体查询会按自定义后的 `dimensionsBase` 对应尺寸进行计算。 #### 百分比计算规则 @@ -408,7 +413,7 @@ Mpx 在 RN 平台支持 `@media` 规则,但能力受限。 **限制:** -- 媒体查询中的宽度条件仅支持 `px` 单位,并基于运行时 `screen.width` 判断。 +- 媒体查询中的宽度条件仅支持 `px` 单位,并基于 `dimensionsBase` 选中尺寸的运行时 `width` 判断,默认使用 `window.width`。 - 不支持 `width` 精确匹配、`height`、`orientation`、`all` 等其他特性。 ### 动画支持 diff --git a/docs-vitepress/api-proxy/base/system/getWindowInfo.md b/docs-vitepress/api-proxy/base/system/getWindowInfo.md index 0031797142..6d17dcf1b5 100644 --- a/docs-vitepress/api-proxy/base/system/getWindowInfo.md +++ b/docs-vitepress/api-proxy/base/system/getWindowInfo.md @@ -4,6 +4,8 @@ 支持情况: 微信、支付宝、web、RN +> RN 下可通过 `Mpx.config.rnConfig.customDimensions` 自定义尺寸,`getWindowInfo`、`getSystemInfo` 和 `getSystemInfoSync` 会基于处理后的尺寸计算相关字段。 + [参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/base/system/wx.getWindowInfo.html) ### 参数 {#parameters} @@ -60,4 +62,4 @@ console.log(deviceInfo.brand) console.log(deviceInfo.model) console.log(deviceInfo.platform) console.log(deviceInfo.system) -``` \ No newline at end of file +``` diff --git a/docs-vitepress/guide/rn/application-api.md b/docs-vitepress/guide/rn/application-api.md index c16e0e29ca..82e6f5e841 100644 --- a/docs-vitepress/guide/rn/application-api.md +++ b/docs-vitepress/guide/rn/application-api.md @@ -468,17 +468,49 @@ mpx.config.rnConfig.disablePageTransition = true ### 折叠屏适配 {#foldable-screen-adaption} +#### mpx.config.rnConfig.dimensionsBase + +```ts +'window' | 'screen' +``` + +控制 `rpx` / `vw` / `vh`、媒体查询与 `onResize` 使用的尺寸基准,默认值为 `'window'`。 + +如果需要保持旧版本基于屏幕尺寸计算的效果,可配置为 `'screen'`: + +```js +mpx.config.rnConfig.dimensionsBase = 'screen' +``` + +配置后,响应式单位和媒体查询将使用 `Dimensions.get('screen')` 的宽高,并在 Screen 尺寸变化时重新计算相关样式及触发 `onResize`。 + +该配置应在首次尺寸计算前完成,首次读取后将保持不变。 + #### mpx.config.rnConfig.customDimensions ```ts (dimensions: { window: ScaledSize; screen: ScaledSize }) => { window: ScaledSize; screen: ScaledSize } | void ``` -在某些情况下,我们可能不希望当前应用全屏展示,Mpx 内部基于 ScreenWidth 与 ScreenHeight 作为 rpx、vh、vw、媒体查询、onResize等特性的依赖内容,此时可在 `mpx.config.rnConfig.customDimensions` 中自定义 screen 尺寸信息来得到想要的渲染效果。 +在某些情况下,我们可能不希望当前应用全屏展示。此时可在 `mpx.config.rnConfig.customDimensions` 中自定义 window 或 screen 尺寸信息,`rpx` / `vh` / `vw`、媒体查询与 `onResize` 会使用 `dimensionsBase` 指定的尺寸,并在该尺寸变化时触发相关更新。 + +可在此方法中返回修改后的 dimensions,如果无返回或返回 `undefined`,则使用原始入参。 + +例如,使用默认的 `dimensionsBase: 'window'` 时,折叠屏展开后如果期望应用只在一半窗口中展示,可在 `customDimensions` 中将 `window.width` 设为原来的一半。 -可在此方法中返回修改后的 dimensions,如果无返回或返回 undefined,则以入参作为返回值 +#### getDimensionsInfo -例如: 在折叠屏中我们期望只在其中一半屏上展示,可在 customDimensions 中判断当前是否为折叠屏展开状态,如果是则将 ScreenWidth 设置为原来的一半。 +```ts +(dimensionsBase?: 'window' | 'screen') => ScaledSize +``` + +返回最近一次初始化或 React Native Dimensions 变化后生效的尺寸副本。可传入 `'window'` 或 `'screen'` 指定本次获取的尺寸;不传时使用首次读取的 `rnConfig.dimensionsBase`,传参不会修改该配置。修改返回值不会影响框架内部缓存。 + +```js +const dimensions = getDimensionsInfo() +console.log(dimensions.width, dimensions.height) +const screenDimensions = getDimensionsInfo('screen') +``` ### 前后台切换 {#app-state-change} diff --git a/docs-vitepress/guide/rn/style.md b/docs-vitepress/guide/rn/style.md index bb71d6b346..62649e293c 100644 --- a/docs-vitepress/guide/rn/style.md +++ b/docs-vitepress/guide/rn/style.md @@ -103,19 +103,14 @@ Mpx 转 RN 支持以下单位,部分单位在特定情况下存在使用限制 |------|---------|----------| | `%` | ✅ 支持 | 百分比单位参考 [百分比单位说明](#percentage-unit-explanation) | | `px` | ✅ 支持 | 绝对像素单位 | -| `rpx` | ✅ 支持 | 响应式像素,根据屏幕宽度动态计算 | -| `vh` | ✅ 支持 | 相对于视口的高度 | -| `vw` | ✅ 支持 | 相对视口的宽度 | +| `rpx` | ✅ 支持 | 响应式像素,默认根据 Window 宽度动态计算 | +| `vh` | ✅ 支持 | 默认使用 Window 高度计算 | +| `vw` | ✅ 支持 | 默认使用 Window 宽度计算 | -> [!tip] vh 单位使用注意 +> [!tip] 尺寸基准说明 > -> - **问题**:使用系统默认导航栏时,`vh` 的计算基准可能会发生变化 -> - 页面首次加载:`100vh = 屏幕总高度` -> - 状态更新后:`100vh = 屏幕高度 - 导航栏高度` -> -> - **影响**:可能导致布局在运行时突然变化 -> -> - **建议**:如需使用 `vh` 单位,推荐配合自定义导航栏使用,以确保计算基准始终一致 +> `rpx` / `vw` / `vh` 和媒体查询默认使用 React Native `Dimensions.get('window')` 的尺寸。可将 `mpx.config.rnConfig.dimensionsBase` 设置为 `'screen'`,恢复基于 Screen 尺寸计算的旧版本效果。所选尺寸变化时,依赖这些能力的组件会重新计算样式。 + ### 百分比单位说明 {#percentage-unit-explanation} RN 原生较多属性不支持百分比,或对百分比的支持存在 bug(如 `font-size`、`translate` 等),但这些属性在编写 Web/小程序代码时使用较多,所以框架进行了抹平支持。 diff --git a/packages/api-proxy/src/platform/api/system/rnSystem.js b/packages/api-proxy/src/platform/api/system/rnSystem.js index d19e21c299..84a58da728 100644 --- a/packages/api-proxy/src/platform/api/system/rnSystem.js +++ b/packages/api-proxy/src/platform/api/system/rnSystem.js @@ -3,7 +3,7 @@ import { initialWindowMetrics } from 'react-native-safe-area-context' import { getFocusedNavigation } from '../../../common/js' const getWindowInfo = function () { - const dimensionsScreen = Dimensions.get('screen') + const dimensionsScreen = global.getDimensionsInfo?.('screen') || Dimensions.get('screen') const navigation = getFocusedNavigation() || {} const initialWindowMetricsInset = initialWindowMetrics?.insets || {} const navigationInsets = navigation.insets || {} diff --git a/packages/core/@types/global.d.ts b/packages/core/@types/global.d.ts index 51dbff4263..e86b33cb85 100644 --- a/packages/core/@types/global.d.ts +++ b/packages/core/@types/global.d.ts @@ -14,3 +14,5 @@ declare module '*?resolve' { declare let setAppShow: () => void declare let setAppHide: () => void + +declare let getDimensionsInfo: (dimensionsBase?: 'window' | 'screen') => Readonly diff --git a/packages/core/@types/index.d.ts b/packages/core/@types/index.d.ts index 01d4b387c6..0f407aeb7b 100644 --- a/packages/core/@types/index.d.ts +++ b/packages/core/@types/index.d.ts @@ -476,7 +476,7 @@ export interface RnConfig { }) => void /** - * 自定义屏幕尺寸信息,用于 mpx style 渲染等依赖尺寸的功能。 + * 自定义窗口和屏幕尺寸信息,用于 mpx style 渲染等依赖尺寸的功能。 * * @param dimensions 包含 window 和 screen 的尺寸信息 * @returns 返回修改后的尺寸对象,或 void 表示不修改 @@ -485,6 +485,13 @@ export interface RnConfig { dimensions: T ) => T | void + /** + * rpx、vw、vh、媒体查询与 onResize 使用的尺寸基准。 + * 首次尺寸计算后该配置固定,运行期间修改不会生效。 + * @default 'window' + */ + dimensionsBase?: 'window' | 'screen' + /** * 加载并执行异步分包的方法。 * diff --git a/packages/core/__tests__/common/dimensionsHelper.spec.js b/packages/core/__tests__/common/dimensionsHelper.spec.js new file mode 100644 index 0000000000..93c8578526 --- /dev/null +++ b/packages/core/__tests__/common/dimensionsHelper.spec.js @@ -0,0 +1,145 @@ +jest.mock('../../src/index', () => ({ + __esModule: true, + default: { + config: { + rnConfig: { + dimensionsBase: 'window' + } + } + } +})) + +jest.mock('@mpxjs/utils', () => ({ + getFocusedNavigation: jest.fn(), + hasOwn: (value, key) => Object.prototype.hasOwnProperty.call(value, key) +})) + +jest.mock('react-native', () => ({ + Dimensions: { + get: jest.fn(type => type === 'window' + ? { width: 360, height: 640 } + : { width: 720, height: 1280 }) + } +}), { virtual: true }) + +let Mpx +let getDimensionsInfo +let syncDimensions + +const dimensions = { + window: { width: 360, height: 640 }, + screen: { width: 720, height: 1280 } +} + +describe('RN dimensions helper', () => { + beforeEach(() => { + jest.resetModules() + Mpx = require('../../src/index').default + const dimensionsHelper = require('../../src/platform/dimensionsHelper') + getDimensionsInfo = dimensionsHelper.getDimensionsInfo + syncDimensions = dimensionsHelper.syncDimensions + Mpx.config.rnConfig = { + dimensionsBase: 'window' + } + global.__classCaches = new Set() + global.__mpxSizeCount = 0 + global.__mpxPageSizeCountMap = {} + global.__mpxPageStatusMap = {} + }) + + it('uses customDimensions for the initial style dimensions', () => { + const customDimensions = jest.fn((dimensions) => { + dimensions.window.width /= 2 + return dimensions + }) + Mpx.config.rnConfig.customDimensions = customDimensions + + expect(getDimensionsInfo().width).toBe(180) + expect(customDimensions).toHaveBeenCalledTimes(1) + }) + + it('handles Dimensions changes before the first dimensions read', () => { + syncDimensions({ + window: { width: 400, height: 700 }, + screen: { width: 800, height: 1400 } + }) + + expect(getDimensionsInfo('window')).toEqual({ width: 400, height: 700 }) + expect(getDimensionsInfo('screen')).toEqual({ width: 800, height: 1400 }) + }) + + it('applies runtime dimension config only after dimensions are synchronized', () => { + expect(getDimensionsInfo().width).toBe(360) + const customDimensions = jest.fn((dimensions) => { + dimensions.window.width /= 2 + return dimensions + }) + + Mpx.config.rnConfig.customDimensions = customDimensions + + expect(getDimensionsInfo().width).toBe(360) + expect(customDimensions).not.toHaveBeenCalled() + + syncDimensions(dimensions) + + expect(getDimensionsInfo().width).toBe(180) + expect(customDimensions).toHaveBeenCalledTimes(1) + }) + + it('does not expose the dimensions object retained by customDimensions', () => { + let retainedDimensions + Mpx.config.rnConfig.customDimensions = (dimensions) => { + retainedDimensions = dimensions + return dimensions + } + + expect(getDimensionsInfo().width).toBe(360) + + retainedDimensions.window.width = 180 + + expect(getDimensionsInfo().width).toBe(360) + }) + + it('keeps the last effective dimensions when customDimensions throws', () => { + expect(getDimensionsInfo().width).toBe(360) + Mpx.config.rnConfig.customDimensions = () => { + throw new Error('custom dimensions failed') + } + + expect(() => syncDimensions(dimensions)).toThrow('custom dimensions failed') + expect(getDimensionsInfo().width).toBe(360) + }) + + it('rejects dimensions-dependent APIs called while applying customDimensions', () => { + Mpx.config.rnConfig.customDimensions = () => getDimensionsInfo() + + expect(() => getDimensionsInfo()).toThrow( + 'Do not call getDimensionsInfo, getWindowInfo, getSystemInfo, or other APIs that depend on customDimensions results inside rnConfig.customDimensions.' + ) + + delete Mpx.config.rnConfig.customDimensions + expect(getDimensionsInfo().width).toBe(360) + }) + + it('rejects dimensions-dependent APIs during later customDimensions synchronization', () => { + expect(getDimensionsInfo().width).toBe(360) + Mpx.config.rnConfig.customDimensions = () => getDimensionsInfo() + + expect(() => syncDimensions(dimensions)).toThrow( + 'Do not call getDimensionsInfo, getWindowInfo, getSystemInfo, or other APIs that depend on customDimensions results inside rnConfig.customDimensions.' + ) + expect(getDimensionsInfo().width).toBe(360) + }) + + it('keeps the initial dimensions base after the config changes', () => { + expect(getDimensionsInfo().width).toBe(360) + + Mpx.config.rnConfig.dimensionsBase = 'screen' + syncDimensions({ + window: dimensions.window, + screen: { width: 1400, height: 800 } + }) + + expect(getDimensionsInfo().width).toBe(360) + }) +}) diff --git a/packages/core/__tests__/common/styleHelperMixin.ios.spec.js b/packages/core/__tests__/common/styleHelperMixin.ios.spec.js new file mode 100644 index 0000000000..22d252a2f7 --- /dev/null +++ b/packages/core/__tests__/common/styleHelperMixin.ios.spec.js @@ -0,0 +1,430 @@ +const mockDimensions = { + window: { width: 360, height: 640 }, + screen: { width: 720, height: 1280 } +} +let mockDimensionsChangeHandler + +jest.mock('react-native', () => ({ + StyleSheet: { + hairlineWidth: 0.5 + }, + Dimensions: { + get: jest.fn(type => type === 'window' + ? { width: 360, height: 640 } + : { width: 720, height: 1280 }), + addEventListener: jest.fn((event, handler) => { + mockDimensionsChangeHandler = handler + }) + } +}), { virtual: true }) + +jest.mock('@mpxjs/utils', () => ({ + isObject: value => value !== null && typeof value === 'object', + isArray: Array.isArray, + dash2hump: value => value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), + cached: fn => fn, + isEmptyObject: value => Object.keys(value).length === 0, + hasOwn: (value, key) => Object.prototype.hasOwnProperty.call(value, key), + getFocusedNavigation: jest.fn() +})) + +jest.mock('../../src/observer/reactive', () => ({ + reactive: value => value +})) + +jest.mock('@mpxjs/perf', () => ({ + scopeStart: jest.fn(), + scopeEnd: jest.fn() +})) + +jest.mock('../../src/index', () => ({ + __esModule: true, + default: { + config: { + rnConfig: { + dimensionsBase: 'window' + } + } + } +})) + +let Mpx +let styleHelperMixin + +describe('RN styleHelperMixin dimensions', () => { + beforeEach(() => { + jest.resetModules() + Mpx = require('../../src/index').default + styleHelperMixin = require('../../src/platform/builtInMixins/styleHelperMixin.ios').default + global.__mpx_perf_framework__ = false + global.__mpxSizeCount = 0 + global.__classCaches = new Set() + global.__externalClasses = ['custom-class', 'i-class'] + Mpx.config.rnConfig = { + dimensionsBase: 'window' + } + }) + + it('converts responsive units with window dimensions', () => { + Mpx.config.rnConfig = {} + + expect(global.__formatValue('750rpx')).toBe(360) + expect(global.__formatValue('100vw')).toBe(360) + expect(global.__formatValue('100vh')).toBe(640) + }) + + it('does not expose the mutable dimensions cache through the global getter', () => { + const dimensions = global.getDimensionsInfo() + + dimensions.width = 1 + + expect(global.__formatValue('750rpx')).toBe(360) + }) + + it('converts responsive units with screen dimensions when configured', () => { + Mpx.config.rnConfig.dimensionsBase = 'screen' + + expect(global.__formatValue('750rpx')).toBe(720) + expect(global.__formatValue('100vw')).toBe(720) + expect(global.__formatValue('100vh')).toBe(1280) + }) + + it('tracks dimensions dependency for dynamic responsive unit styles', () => { + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __trackPageSizeCount: jest.fn() + } + + const result = styleHelperMixin().methods.__getStyle.call(context, '', '', '', { + width: '750rpx' + }) + + expect(result.width).toBe(360) + expect(context.__trackPageSizeCount).toHaveBeenCalledTimes(1) + }) + + it('reads external class styles from raw props and tracks the internal version', () => { + let versionReads = 0 + const externalClassesVersion = {} + Object.defineProperty(externalClassesVersion, 'value', { + get () { + versionReads++ + return 0 + } + }) + const methods = styleHelperMixin().methods + const context = { + __props: { + 'custom-class': { color: 'red' } + }, + __mpxProxy: { + externalClassesVersion + }, + __trackExternalClassesVersion: methods.__trackExternalClassesVersion, + __trackPageSizeCount: jest.fn() + } + + const result = methods.__getStyle.call(context, 'custom-class') + + expect(result.color).toBe('red') + expect(versionReads).toBe(1) + }) + + it('tracks the internal version before an external class style is provided', () => { + let versionReads = 0 + const externalClassesVersion = {} + Object.defineProperty(externalClassesVersion, 'value', { + get () { + versionReads++ + return 0 + } + }) + const methods = styleHelperMixin().methods + const context = { + __props: {}, + __mpxProxy: { + externalClassesVersion + }, + __trackExternalClassesVersion: methods.__trackExternalClassesVersion, + __trackPageSizeCount: jest.fn() + } + + const result = methods.__getStyle.call(context, 'custom-class') + + expect(result).toEqual({}) + expect(versionReads).toBe(1) + }) + + it('removes the dimensions dependency marker from merged class styles', () => { + const classMap = { + responsive: formatValue => ({ width: formatValue('750rpx') }) + } + const classMapValueCache = new Map() + const cachedStyle = global.__GCC('responsive', classMap, classMapValueCache) + const context = { + __getClassStyle: className => global.__GCC(className, classMap, classMapValueCache), + __trackPageSizeCount: jest.fn() + } + + expect(cachedStyle._dependentWindowSize).toBe(true) + expect(Object.keys(cachedStyle)).toContain('_dependentWindowSize') + + const result = styleHelperMixin().methods.__getStyle.call(context, 'responsive') + + expect(result).toEqual({ width: 360 }) + expect(context.__trackPageSizeCount).toHaveBeenCalledTimes(1) + }) + + it('stops tracking dimensions after responsive styles switch to fixed values', () => { + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __trackPageSizeCount: jest.fn() + } + + styleHelperMixin().methods.__getStyle.call(context, '', '', '', { + width: '750rpx' + }) + context.__trackPageSizeCount.mockClear() + + const result = styleHelperMixin().methods.__getStyle.call(context, '', '', '', { + width: '240px' + }) + + expect(result.width).toBe(240) + expect(context.__trackPageSizeCount).not.toHaveBeenCalled() + }) + + it('updates responsive styles when window dimensions change', () => { + const cache = { clear: jest.fn() } + global.__classCaches.add(cache) + global.getDimensionsInfo() + + mockDimensionsChangeHandler({ + window: { width: 400, height: 700 }, + screen: mockDimensions.screen + }) + + expect(cache.clear).toHaveBeenCalledTimes(1) + expect(global.__mpxSizeCount).toBe(1) + expect(global.__formatValue('750rpx')).toBe(400) + + mockDimensionsChangeHandler({ + window: { width: 400, height: 700 }, + screen: { width: 800, height: 1400 } + }) + + expect(cache.clear).toHaveBeenCalledTimes(1) + expect(global.__mpxSizeCount).toBe(1) + }) + + it('updates responsive styles only when screen dimensions change in screen mode', () => { + Mpx.config.rnConfig.dimensionsBase = 'screen' + const cache = { clear: jest.fn() } + global.__classCaches.add(cache) + + mockDimensionsChangeHandler({ + window: { width: 400, height: 700 }, + screen: mockDimensions.screen + }) + + expect(cache.clear).not.toHaveBeenCalled() + expect(global.__mpxSizeCount).toBe(0) + + mockDimensionsChangeHandler({ + window: { width: 400, height: 700 }, + screen: { width: 800, height: 1400 } + }) + + expect(cache.clear).toHaveBeenCalledTimes(1) + expect(global.__mpxSizeCount).toBe(1) + expect(global.__formatValue('750rpx')).toBe(800) + }) + + it('keeps the initial dimensionsBase after config and Screen dimensions change', () => { + const classMap = { + box: formatValue => ({ width: formatValue('750rpx') }) + } + const classMapValueCache = new Map() + global.__classCaches.add(classMapValueCache) + + expect(global.__GCC('box', classMap, classMapValueCache).width).toBe(360) + + Mpx.config.rnConfig.dimensionsBase = 'screen' + + expect(global.getDimensionsInfo().width).toBe(360) + expect(global.__GCC('box', classMap, classMapValueCache).width).toBe(360) + expect(global.__mpxSizeCount).toBe(0) + + mockDimensionsChangeHandler({ + window: mockDimensions.window, + screen: { width: 800, height: 1400 } + }) + + expect(global.getDimensionsInfo().width).toBe(360) + expect(global.__GCC('box', classMap, classMapValueCache).width).toBe(360) + expect(global.__mpxSizeCount).toBe(0) + }) + + it('matches media queries with window width', () => { + const style = { + color: 'red', + _media: [{ + options: { minWidth: 500 }, + value: { opacity: 1 } + }] + } + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __getClassStyle: jest.fn(() => style), + __trackPageSizeCount: jest.fn() + } + + const result = styleHelperMixin().methods.__getStyle.call(context, 'responsive') + + expect(result.color).toBe('red') + expect(result.opacity).toBeUndefined() + expect(context.__trackPageSizeCount).toHaveBeenCalledTimes(1) + }) + + it('matches media queries with screen width when configured', () => { + Mpx.config.rnConfig.dimensionsBase = 'screen' + const style = { + color: 'red', + _media: [{ + options: { minWidth: 600 }, + value: { color: 'green' } + }] + } + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __getClassStyle: jest.fn(() => style), + __trackPageSizeCount: jest.fn() + } + + const result = styleHelperMixin().methods.__getStyle.call(context, 'responsive') + + expect(result.color).toBe('green') + expect(context.__trackPageSizeCount).toHaveBeenCalledTimes(1) + }) + + it('matches min/max media queries only within the inclusive range', () => { + const style = { + color: 'red', + _media: [{ + options: { minWidth: 600, maxWidth: 900 }, + value: { color: 'green' } + }] + } + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __getClassStyle: jest.fn(() => style), + __trackPageSizeCount: jest.fn() + } + const getColorAtWidth = width => { + mockDimensionsChangeHandler({ + window: { width, height: 640 }, + screen: mockDimensions.screen + }) + return styleHelperMixin().methods.__getStyle.call(context, 'responsive').color + } + + expect(getColorAtWidth(599)).toBe('red') + expect(getColorAtWidth(600)).toBe('green') + expect(getColorAtWidth(750)).toBe('green') + expect(getColorAtWidth(900)).toBe('green') + expect(getColorAtWidth(901)).toBe('red') + }) + + it('applies important declarations from all matching media queries', () => { + const style = { + width: 100, + height: 50, + _media: [{ + options: { minWidth: 300 }, + value: { + _inlineLayer: { + important: { width: 200 } + } + } + }, { + options: { maxWidth: 500 }, + value: { + _inlineLayer: { + important: { height: 120 } + } + } + }] + } + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __getClassStyle: jest.fn(() => style), + __trackPageSizeCount: jest.fn() + } + + const result = styleHelperMixin().methods.__getStyle.call(context, 'responsive') + + expect(result.width).toBe(200) + expect(result.height).toBe(120) + }) + + it('applies customDimensions before the first media-only style calculation', () => { + const originalGlobals = { + getDimensionsInfo: global.getDimensionsInfo, + GCC: global.__GCC, + formatValue: global.__formatValue, + sizeCount: global.__mpxSizeCount, + pageSizeCountMap: global.__mpxPageSizeCountMap, + classCaches: global.__classCaches, + dimensionsChangeHandler: mockDimensionsChangeHandler + } + + try { + jest.isolateModules(() => { + const FreshMpx = require('../../src/index').default + const freshStyleHelperMixin = require('../../src/platform/builtInMixins/styleHelperMixin.ios').default + const customDimensions = jest.fn((dimensions) => { + dimensions.window.width /= 2 + return dimensions + }) + FreshMpx.config.rnConfig = { + dimensionsBase: 'window', + customDimensions + } + const style = { + color: 'red', + _media: [{ + options: { minWidth: 300 }, + value: { color: 'green' } + }] + } + const context = { + __pageId: 'page', + __mpxProxy: { props: {} }, + __getClassStyle: jest.fn(() => style), + __trackPageSizeCount: jest.fn() + } + + const result = freshStyleHelperMixin().methods.__getStyle.call(context, 'responsive') + + expect(result.color).toBe('red') + expect(global.getDimensionsInfo().width).toBe(180) + expect(customDimensions).toHaveBeenCalledTimes(1) + expect(context.__trackPageSizeCount).toHaveBeenCalledTimes(1) + }) + } finally { + global.getDimensionsInfo = originalGlobals.getDimensionsInfo + global.__GCC = originalGlobals.GCC + global.__formatValue = originalGlobals.formatValue + global.__mpxSizeCount = originalGlobals.sizeCount + global.__mpxPageSizeCountMap = originalGlobals.pageSizeCountMap + global.__classCaches = originalGlobals.classCaches + mockDimensionsChangeHandler = originalGlobals.dimensionsChangeHandler + } + }) +}) diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 830dd60e0a..fdcb3d947c 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -150,7 +150,8 @@ Mpx.config = { */ rnConfig: { defaultBoxSizing: 'content-box', - disablePageTransition: false + disablePageTransition: false, + dimensionsBase: 'window' } } diff --git a/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js b/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js index de4bb694f1..73282fbe29 100644 --- a/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js +++ b/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js @@ -1,75 +1,46 @@ -import { isObject, isArray, dash2hump, cached, isEmptyObject, hasOwn, getFocusedNavigation } from '@mpxjs/utils' +import { isObject, isArray, dash2hump, cached, isEmptyObject, hasOwn } from '@mpxjs/utils' import * as perf from '@mpxjs/perf' import { StyleSheet, Dimensions } from 'react-native' import { reactive } from '../../observer/reactive' -import Mpx from '../../index' +import { getDimensionsInfo, syncDimensions } from '../dimensionsHelper' -global.__mpxAppDimensionsInfo = { - window: Dimensions.get('window'), - screen: Dimensions.get('screen') -} global.__mpxSizeCount = 0 global.__mpxPageSizeCountMap = reactive({}) global.__GCC = function (className, classMap, classMapValueCache) { if (!classMapValueCache.has(className)) { - const styleObj = classMap[className]?.(global.__formatValue) - styleObj && classMapValueCache.set(className, styleObj) - } - return classMapValueCache.get(className) -} - -let dimensionsInfoInitialized = false -function useDimensionsInfo (dimensions) { - dimensionsInfoInitialized = true - if (typeof Mpx.config.rnConfig?.customDimensions === 'function') { - dimensions = Mpx.config.rnConfig.customDimensions(dimensions) || dimensions - } - global.__mpxAppDimensionsInfo.window = dimensions.window - global.__mpxAppDimensionsInfo.screen = dimensions.screen -} - -function getPageSize (window = global.__mpxAppDimensionsInfo.screen) { - return window.width + 'x' + window.height -} + const originalDependentWindowSize = dependentWindowSize + dependentWindowSize = false -Dimensions.addEventListener('change', ({ window, screen }) => { - const oldScreen = getPageSize(global.__mpxAppDimensionsInfo.screen) - useDimensionsInfo({ window, screen }) - - // 对比 screen 高宽是否存在变化 - if (getPageSize(screen) === oldScreen) return + const styleObj = classMap[className]?.(formatValue) + if (!styleObj) { + dependentWindowSize = originalDependentWindowSize + return + } - global.__classCaches?.forEach(cache => cache?.clear()) + styleObj._dependentWindowSize = dependentWindowSize + dependentWindowSize = dependentWindowSize || originalDependentWindowSize - // 更新全局和栈顶页面的标记,其他后台页面的标记在show之后更新 - global.__mpxSizeCount++ + classMapValueCache.set(className, styleObj) + } + return classMapValueCache.get(className) +} - const navigation = getFocusedNavigation() +global.getDimensionsInfo = base => Object.assign({}, getDimensionsInfo(base)) - if (navigation) { - global.__mpxPageSizeCountMap[navigation.pageId] = global.__mpxSizeCount - if (hasOwn(global.__mpxPageStatusMap, navigation.pageId)) { - global.__mpxPageStatusMap[navigation.pageId] = `resize${global.__mpxSizeCount}` - } - } -}) +Dimensions.addEventListener('change', syncDimensions) -// TODO: 1 目前测试鸿蒙下折叠屏screen固定为展开状态下屏幕尺寸,仅window会变化,且window包含状态栏高度 -// TODO: 2 存在部分安卓折叠屏机型在折叠/展开切换时,Dimensions监听到的width/height尺寸错误,并触发多次问题 +// TODO: 存在部分安卓折叠屏机型在折叠/展开切换时,Dimensions 监听到的 width/height 尺寸错误,并触发多次问题 function rpx (value) { - const screenInfo = global.__mpxAppDimensionsInfo.screen // rn 单位 dp = 1(css)px = 1 物理像素 * pixelRatio(像素比) - // px = rpx * (750 / 屏幕宽度) - return value * screenInfo.width / 750 + // px = rpx * (样式计算宽度 / 750) + return value * getDimensionsInfo().width / 750 } function vw (value) { - const screenInfo = global.__mpxAppDimensionsInfo.screen - return value * screenInfo.width / 100 + return value * getDimensionsInfo().width / 100 } function vh (value) { - const screenInfo = global.__mpxAppDimensionsInfo.screen - return value * screenInfo.height / 100 + return value * getDimensionsInfo().height / 100 } const unit = { @@ -80,11 +51,12 @@ const unit = { const empty = {} +// 记录 style 是否依赖窗口尺寸 +let dependentWindowSize = false const isNum = (v) => !isNaN(+v) - function formatValue (value, unitType) { - if (!dimensionsInfoInitialized) useDimensionsInfo(global.__mpxAppDimensionsInfo) if (unitType && typeof unit[unitType] === 'function') { + dependentWindowSize = true return unit[unitType](+value) } if (value === 'hairlineWidth') { @@ -95,6 +67,7 @@ function formatValue (value, unitType) { if (!matched[2] || matched[2] === 'px') { return +matched[1] } else { + dependentWindowSize = true return unit[matched[2]](+matched[1]) } } @@ -228,16 +201,31 @@ function isNativeStyle (style) { function getMediaStyle (media) { if (!media || !media.length) return {} - const { width } = global.__mpxAppDimensionsInfo.screen + dependentWindowSize = true + const { width } = getDimensionsInfo() return media.reduce((styleObj, item) => { const { options = {}, value = {} } = item const { minWidth, maxWidth } = options - if (!isNaN(minWidth) && !isNaN(maxWidth) && width >= minWidth && width <= maxWidth) { - Object.assign(styleObj, value) - } else if (!isNaN(minWidth) && width >= minWidth) { - Object.assign(styleObj, value) - } else if (!isNaN(maxWidth) && width <= maxWidth) { - Object.assign(styleObj, value) + const hasMinWidth = !isNaN(minWidth) + const hasMaxWidth = !isNaN(maxWidth) + const matched = hasMinWidth && hasMaxWidth + ? width >= minWidth && width <= maxWidth + : hasMinWidth + ? width >= minWidth + : hasMaxWidth && width <= maxWidth + if (matched) { + Object.keys(value).forEach(key => { + if (key !== '_inlineLayer') styleObj[key] = value[key] + }) + if (value._inlineLayer) { + styleObj._inlineLayer = styleObj._inlineLayer || {} + Object.keys(value._inlineLayer).forEach(layer => { + styleObj._inlineLayer[layer] = Object.assign( + styleObj._inlineLayer[layer] || {}, + value._inlineLayer[layer] + ) + }) + } } return styleObj }, {}) @@ -261,8 +249,11 @@ const createLayer = (isNativeStyle) => { const mergeToLayer = (name, style, mediaStyle) => { const layer = layerMap[name] || layerMap.normal layer.push(style) - if (mediaStyle) layer.push(mediaStyle) if (style._inlineLayer) checkInlineLayer(style, mergeToLayer) + if (mediaStyle) { + layer.push(mediaStyle) + if (mediaStyle._inlineLayer) checkInlineLayer(mediaStyle, mergeToLayer) + } } const mergeToLayerWithStyles = (name, styles) => { @@ -289,6 +280,7 @@ const createLayer = (isNativeStyle) => { ...layerMap.important ) delete res._inlineLayer + delete res._dependentWindowSize return res } @@ -319,9 +311,12 @@ const HIDE_STYLE = { export default function styleHelperMixin () { return { methods: { - __getSizeCount () { + __trackPageSizeCount () { return global.__mpxPageSizeCountMap[this.__pageId] }, + __trackExternalClassesVersion () { + return this.__mpxProxy.externalClassesVersion.value + }, __getClass (staticClass, dynamicClass) { return concat(staticClass, stringifyDynamicClass(dynamicClass)) }, @@ -329,12 +324,12 @@ export default function styleHelperMixin () { let idTotal = -1 if (__mpx_perf_framework__) idTotal = perf.scopeStart('instance:render:getStyle') + // 重置依赖标记 + dependentWindowSize = false const isNativeStaticStyle = staticStyle && isNativeStyle(staticStyle) const { mergeToLayer, mergeToLayerWithStyles, genResult } = createLayer(isNativeStaticStyle) - this.__getSizeCount() - if (staticClass || dynamicClass) { let idClass = -1 if (__mpx_perf_framework__) idClass = perf.scopeStart('instance:render:getStyle:class') @@ -346,21 +341,31 @@ export default function styleHelperMixin () { let localStyle, appStyle, unoStyle, unoVarStyle if (localStyle = this.__getClassStyle?.(className)) { mergeToLayer(localStyle._layer || 'normal', localStyle, getMediaStyle(localStyle._media)) + dependentWindowSize = dependentWindowSize || localStyle._dependentWindowSize } else if (unoStyle = global.__getUnoStyle?.(className)) { mergeToLayer(unoStyle._layer || 'uno', unoStyle, getMediaStyle(unoStyle._media)) + dependentWindowSize = dependentWindowSize || unoStyle._dependentWindowSize if (unoStyle.transform || unoStyle.filter) needAddUnoPreflight = true } else if (unoVarStyle = global.__getUnoVarStyle?.(className)) { mergeToLayer('important', unoVarStyle) + dependentWindowSize = dependentWindowSize || unoVarStyle._dependentWindowSize } else if (appStyle = global.__getAppClassStyle?.(className)) { mergeToLayer(appStyle._layer || 'app', appStyle, getMediaStyle(appStyle._media)) - } else if (isObject(this.__props[className])) { - // externalClasses必定以对象形式传递下来 - mergeToLayer('normal', this.__props[className]) + dependentWindowSize = dependentWindowSize || appStyle._dependentWindowSize + } else if (global.__externalClasses?.includes(className)) { + // 始终读取版本号,确保 externalClasses 从无到有时也能触发样式重算。 + this.__trackExternalClassesVersion() + const externalClassStyle = this.__props[className] + if (isObject(externalClassStyle)) { + mergeToLayer('normal', externalClassStyle) + } } }) if (needAddUnoPreflight) { - mergeToLayer('preflight', global.__getAppClassStyle?.('__uno_preflight')) + const unoPreflightStyle = global.__getAppClassStyle?.('__uno_preflight') + mergeToLayer('preflight', unoPreflightStyle) + dependentWindowSize = dependentWindowSize || unoPreflightStyle._dependentWindowSize } if (__mpx_perf_framework__) perf.scopeEnd(idClass) @@ -392,6 +397,11 @@ export default function styleHelperMixin () { const result = genResult() const isEmpty = isNativeStaticStyle ? !result.length : isEmptyObject(result) + + // 仅在依赖窗口尺寸时才建立响应式关联,避免窗口尺寸变化时不必要的性能损耗 + if (dependentWindowSize) { + this.__trackPageSizeCount() + } if (__mpx_perf_framework__) perf.scopeEnd(idTotal) return isEmpty ? empty : result } diff --git a/packages/core/src/platform/dimensionsHelper.js b/packages/core/src/platform/dimensionsHelper.js new file mode 100644 index 0000000000..cdfdbc876e --- /dev/null +++ b/packages/core/src/platform/dimensionsHelper.js @@ -0,0 +1,82 @@ +import { getFocusedNavigation, hasOwn } from '@mpxjs/utils' +import { Dimensions } from 'react-native' +import Mpx from '../index' + +let dimensionsBase +let dimensionsInfo +let styleDimensionsSnapshot +let applyingCustomDimensions = false + +function assertNotApplyingCustomDimensions () { + if (applyingCustomDimensions) { + throw new Error('Do not call getDimensionsInfo, getWindowInfo, getSystemInfo, or other APIs that depend on customDimensions results inside rnConfig.customDimensions.') + } +} + +function cloneDimensionsInfo (dimensions) { + return { + window: Object.assign({}, dimensions.window), + screen: Object.assign({}, dimensions.screen) + } +} + +export function getDimensionsBase () { + if (!dimensionsBase) { + dimensionsBase = Mpx.config.rnConfig?.dimensionsBase === 'screen' ? 'screen' : 'window' + } + return dimensionsBase +} + +function getStyleDimensionsSnapshot (dimensionsBase, dimensions) { + const baseDimensions = dimensions[dimensionsBase] + return `${baseDimensions.width}x${baseDimensions.height}` +} + +function triggerStyleDimensionsChange () { + global.__classCaches?.forEach(cache => cache?.clear()) + global.__mpxSizeCount++ + + const navigation = getFocusedNavigation() + + if (navigation) { + global.__mpxPageSizeCountMap[navigation.pageId] = global.__mpxSizeCount + if (hasOwn(global.__mpxPageStatusMap, navigation.pageId)) { + global.__mpxPageStatusMap[navigation.pageId] = `resize${global.__mpxSizeCount}` + } + } +} + +export function syncDimensions (dimensions) { + const customDimensions = Mpx.config.rnConfig?.customDimensions + const currentDimensionsBase = getDimensionsBase() + dimensions = cloneDimensionsInfo(dimensions) + if (typeof customDimensions === 'function') { + applyingCustomDimensions = true + try { + dimensions = cloneDimensionsInfo(customDimensions(dimensions) || dimensions) + } finally { + applyingCustomDimensions = false + } + } + const nextStyleDimensionsSnapshot = getStyleDimensionsSnapshot(currentDimensionsBase, dimensions) + const styleDimensionsChanged = styleDimensionsSnapshot !== undefined && styleDimensionsSnapshot !== nextStyleDimensionsSnapshot + + // 自定义尺寸计算成功后再统一提交,避免中途异常留下部分更新状态。 + dimensionsInfo = dimensions + styleDimensionsSnapshot = nextStyleDimensionsSnapshot + + if (styleDimensionsChanged) { + triggerStyleDimensionsChange() + } +} + +export function getDimensionsInfo (base) { + assertNotApplyingCustomDimensions() + if (styleDimensionsSnapshot === undefined) { + syncDimensions({ + window: Dimensions.get('window'), + screen: Dimensions.get('screen') + }) + } + return dimensionsInfo[base || getDimensionsBase()] +} diff --git a/packages/core/src/platform/patch/getDefaultOptions.ios.js b/packages/core/src/platform/patch/getDefaultOptions.ios.js index b2cf2a6549..ae05967351 100644 --- a/packages/core/src/platform/patch/getDefaultOptions.ios.js +++ b/packages/core/src/platform/patch/getDefaultOptions.ios.js @@ -3,6 +3,7 @@ import * as ReactNative from 'react-native' import { ReactiveEffect } from '../../observer/effect' import { watch } from '../../observer/watch' import { del, reactive, set } from '../../observer/reactive' +import { ref } from '../../observer/ref' import { hasOwn, isFunction, noop, isObject, isArray, getByPath, collectDataset, hump2dash, dash2hump, callWithErrorHandling, wrapMethodsWithErrorHandling, error, setFocusedNavigation, getDefaultValueByType } from '@mpxjs/utils' import MpxProxy from '../../core/proxy' import { BEFOREUPDATE, ONLOAD, UPDATED, ONSHOW, ONHIDE, ONRESIZE, REACTHOOKSEXEC } from '../../core/innerLifecycle' @@ -19,12 +20,14 @@ import { PortalHost, useSafeAreaInsets, initialWindowMetrics } from '../env/navi import { useInnerHeaderHeight } from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-nav' import Mpx from '../../index' import * as perf from '@mpxjs/perf' +import { getDimensionsBase, getDimensionsInfo } from '../dimensionsHelper' function getSystemInfo () { - const windowDimensions = global.__mpxAppDimensionsInfo.window - const screenDimensions = global.__mpxAppDimensionsInfo.screen + const baseDimensions = getDimensionsInfo() + const windowDimensions = getDimensionsInfo('window') + const screenDimensions = getDimensionsInfo('screen') return { - deviceOrientation: windowDimensions.width > windowDimensions.height ? 'landscape' : 'portrait', + deviceOrientation: baseDimensions.width > baseDimensions.height ? 'landscape' : 'portrait', size: { screenWidth: screenDimensions.width, screenHeight: screenDimensions.height, @@ -79,7 +82,7 @@ function getRootProps (props, validProps) { const rootProps = {} for (const key in props) { const altKey = dash2hump(key) - if (!hasOwn(validProps, key) && !hasOwn(validProps, altKey) && key !== 'children') { + if (!hasOwn(validProps, key) && !hasOwn(validProps, altKey) && !global.__externalClasses?.includes(key) && key !== 'children') { rootProps[key] = props[key] } } @@ -315,6 +318,7 @@ function createInstance ({ propsRef, type, rawOptions, currentInject, validProps } const proxy = instance.__mpxProxy = new MpxProxy(rawOptions, instance) + proxy.externalClassesVersion = ref(0) proxy.created() if (type === 'page') { @@ -389,8 +393,11 @@ const triggerResizeEvent = (mpxProxy, sizeRef) => { const oldSize = sizeRef.current.size const systemInfo = getSystemInfo() const newSize = systemInfo.size + const dimensionsBase = getDimensionsBase() + const widthKey = `${dimensionsBase}Width` + const heightKey = `${dimensionsBase}Height` - if (oldSize && oldSize.windowWidth === newSize.windowWidth && oldSize.windowHeight === newSize.windowHeight) { + if (oldSize && oldSize[widthKey] === newSize[widthKey] && oldSize[heightKey] === newSize[heightKey]) { return } @@ -407,7 +414,7 @@ const triggerResizeEvent = (mpxProxy, sizeRef) => { } } -function usePageEffect (mpxProxy, pageId) { +function usePageEffect (mpxProxy, pageId, type) { const sizeRef = useRef(getSystemInfo()) useEffect(() => { @@ -415,19 +422,20 @@ function usePageEffect (mpxProxy, pageId) { const hasShowHook = hasPageHook(mpxProxy, [ONSHOW, 'show']) const hasHideHook = hasPageHook(mpxProxy, [ONHIDE, 'hide']) const hasResizeHook = hasPageHook(mpxProxy, [ONRESIZE, 'resize']) - if (hasShowHook || hasHideHook || hasResizeHook) { + // Page 即使没有注册页面生命周期,也需要监听 show 来追平后台期间错过的尺寸版本。 + // Component 仍仅在声明了对应生命周期时监听,避免无意义的 watcher。 + if (type === 'page' || hasShowHook || hasHideHook || hasResizeHook) { if (hasOwn(pageStatusMap, pageId)) { unWatch = watch(() => pageStatusMap[pageId], (newVal) => { if (newVal === 'show' || newVal === 'hide') { + // 后台页面重新显示时先追平尺寸版本,驱动依赖 rpx/vw/vh 和媒体查询的组件刷新。 + if (type === 'page' && newVal === 'show' && global.__mpxPageSizeCountMap[pageId] !== global.__mpxSizeCount) { + global.__mpxPageSizeCountMap[pageId] = global.__mpxSizeCount + } + triggerPageStatusHook(mpxProxy, newVal) // 仅在尺寸确实变化时才触发resize事件 triggerResizeEvent(mpxProxy, sizeRef) - - // 如果当前全局size与pagesize不一致,在show之后触发一次resize事件 - if (newVal === 'show' && global.__mpxPageSizeCountMap[pageId] !== global.__mpxSizeCount) { - // 刷新__mpxPageSizeCountMap, 每个页面仅会执行一次,直接驱动render刷新 - global.__mpxPageSizeCountMap[pageId] = global.__mpxSizeCount - } } else if (/^resize/.test(newVal)) { triggerResizeEvent(mpxProxy, sizeRef) } @@ -436,7 +444,9 @@ function usePageEffect (mpxProxy, pageId) { } return () => { unWatch && unWatch() - del(global.__mpxPageSizeCountMap, pageId) + if (type === 'page') { + del(global.__mpxPageSizeCountMap, pageId) + } } }, []) } @@ -655,6 +665,10 @@ function updateProps (instance, props, validProps) { }) } +function isExternalClassesChanged (props, oldProps) { + return global.__externalClasses?.some(externalClass => hasOwn(props, externalClass) && !Object.is(props[externalClass], oldProps[externalClass])) +} + export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { rawOptions = mergeOptions(rawOptions, type, false) const componentsMap = currentInject.componentsMap @@ -668,7 +682,7 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { if (rawOptions.methods) rawOptions.methods = wrapMethodsWithErrorHandling(rawOptions.methods) const defaultOptions = memo(forwardRef((props, ref) => { const instanceRef = useRef(null) - const propsRef = useRef(null) + const propsRef = useRef({}) const intersectionCtx = useContext(IntersectionObserverContext) const { pageId } = useContext(RouteContext) || {} const parentProvides = useContext(ProviderContext) @@ -676,6 +690,7 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { if (hasDescendantRelation || hasAncestorRelation) { relation = useContext(RelationsContext) } + const oldProps = propsRef.current propsRef.current = props let isFirst = false if (!instanceRef.current) { @@ -706,14 +721,19 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { } if (!isFirst) { + const externalClassesChanged = isExternalClassesChanged(props, oldProps) + const update = () => { + updateProps(instance, props, validProps) + if (externalClassesChanged) { + proxy.externalClassesVersion.value++ + } + } // 处理props更新 if (Mpx.config.forceFlushSync) { // 避免开启forceFlushSync时react报错:Cannot update a component while rendering a different component - Promise.resolve().then(() => { - updateProps(instance, props, validProps) - }) + Promise.resolve().then(update) } else { - updateProps(instance, props, validProps) + update() } } @@ -724,7 +744,7 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { } }) - usePageEffect(proxy, pageId) + usePageEffect(proxy, pageId, type) useEffect(() => { proxy.mounted() return () => { diff --git a/packages/webpack-plugin/lib/react/script-helper.js b/packages/webpack-plugin/lib/react/script-helper.js index e61d536b60..181f922d08 100644 --- a/packages/webpack-plugin/lib/react/script-helper.js +++ b/packages/webpack-plugin/lib/react/script-helper.js @@ -154,6 +154,7 @@ function buildGlobalParams ({ }) { let content = '' if (ctorType === 'app') { + const externalClasses = loaderContext.getMpx().externalClasses || [] content += ` global.getApp = function () {} global.getCurrentPages = function () { return [] } @@ -165,6 +166,7 @@ global.__style = ${JSON.stringify(jsonConfig.style || 'v1')} global.__mpxPageConfig = ${JSON.stringify(jsonConfig.window)} global.__appComponentsMap = ${shallowStringify(componentsMap)} global.__preloadRule = ${JSON.stringify(jsonConfig.preloadRule)} +global.__externalClasses = ${JSON.stringify(externalClasses || [])} global.currentInject.pagesMap = ${shallowStringify(pagesMap)} global.currentInject.firstPage = ${JSON.stringify(firstPage)}\n` } else { diff --git a/packages/webpack-plugin/lib/react/style-helper.js b/packages/webpack-plugin/lib/react/style-helper.js index 3f614927a2..7d9f24a088 100644 --- a/packages/webpack-plugin/lib/react/style-helper.js +++ b/packages/webpack-plugin/lib/react/style-helper.js @@ -180,22 +180,22 @@ function getClassMap ({ styles, filename, inputFileSystem, mode, srcMode, ctorTy if (classMapKeys.length) { classMapKeys.forEach((key) => { if (Object.keys(classMapValue).length) { - // set css defalut value - const val = classMap[key] || {} - classMap[key] = Object.assign(val, classMapValue) + classMap[key] = classMap[key] || {} - if (layer) { - classMap[key]._layer = layer - } - - // set css media + // Media declarations only take effect when their query matches. if (isMedia) { - const _media = classMap[key]?._media || [] + const _media = classMap[key]._media || [] _media.push({ options, value: classMapValue }) classMap[key]._media = _media + } else { + Object.assign(classMap[key], classMapValue) + } + + if (layer) { + classMap[key]._layer = layer } } }) diff --git a/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js index 575fc31094..26609e454b 100644 --- a/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js +++ b/packages/webpack-plugin/test/platform/wx/style/style-rn.spec.js @@ -90,6 +90,72 @@ describe('React Native style validation for CSS variables', () => { }) }) + describe('Media queries', () => { + test('keeps media declarations out of the default class style', () => { + const config = createConfig() + const result = getClassMap({ + content: ` + .media-box { + width: 280px; + background-color: red; + } + @media (min-width: 600px) { + .media-box { + width: 520px; + background-color: green; + } + } + `, + filename: 'test.css', + ...config + }) + + expect(result['media-box']).toEqual({ + width: '280', + backgroundColor: '"red"', + _media: [{ + options: { minWidth: 600 }, + value: { + width: '520', + backgroundColor: '"green"' + } + }] + }) + expect(config.error).not.toHaveBeenCalled() + }) + + test('keeps important declarations in the media style', () => { + const config = createConfig() + const result = getClassMap({ + content: ` + .media-box { + width: 100px; + } + @media (min-width: 300px) { + .media-box { + width: 200px !important; + } + } + `, + filename: 'test.css', + ...config + }) + + expect(result['media-box']).toEqual({ + width: '100', + _media: [{ + options: { minWidth: 300 }, + value: { + _inlineLayer: { + important: { width: '200' } + } + } + }] + }) + expect(config.error).not.toHaveBeenCalled() + }) + }) + describe('CSS variable fallback validation', () => { test('should filter out letter-spacing with invalid "normal" fallback', () => { const css = '.text { letter-spacing: var(--x, normal); }'