From c76cde371146b8fb2b47f569f608b5f548934f19 Mon Sep 17 00:00:00 2001 From: hiyuki <674883329@qq.com> Date: Tue, 26 May 2026 12:38:00 +0800 Subject: [PATCH 01/31] rn support runtime style transform --- .../mpx2rn/references/rn-style-reference.md | 46 +- .../lib/platform/style/wx/index.js | 22 +- .../runtime/components/react/mpx-image.tsx | 4 +- .../components/react/mpx-movable-view.tsx | 4 +- .../components/react/mpx-scroll-view.tsx | 4 +- .../lib/runtime/components/react/mpx-view.tsx | 17 +- .../lib/runtime/components/react/utils.tsx | 1121 ++++++++++++----- solutions/rn-runtime-shorthand-style.md | 538 +++----- 8 files changed, 995 insertions(+), 761 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-style-reference.md b/.agents/skills/mpx2rn/references/rn-style-reference.md index f0ec5bf331..8dbe6baa21 100644 --- a/.agents/skills/mpx2rn/references/rn-style-reference.md +++ b/.agents/skills/mpx2rn/references/rn-style-reference.md @@ -251,7 +251,7 @@ Mpx 框架抹平了平台差异,使 `view` 等容器节点可以直接包裹 RN 仅原生支持部分 CSS 简写属性,Mpx 分别在**编译时**和**运行时**对其他简写属性进行增强处理,以兼容 RN。 -**RN 原生支持的简写属性:** 以下属性在 RN 中原生支持,可以在 `style` 内联样式和 `class` 类样式中使用,无需编译转换: +**RN 原生支持的简写属性:** 以下属性在 RN 中原生支持,可以在 在 `class` 类样式、`style` 内联样式和 CSS 变量中使用: - **布局**:`flex`(支持 `flex: 1` / `flex: 0` 等) - **间距**:`margin`、`padding`(**仅支持单值语法**,如 `margin: 10px`) @@ -259,39 +259,34 @@ RN 仅原生支持部分 CSS 简写属性,Mpx 分别在**编译时**和**运 - **阴影**:`box-shadow`(RN 0.76+ 原生支持,如 `box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1)`) - **变换**:`transform`、`transform-origin`(支持字符串多值语法,如 `transform: 'rotate(45deg) scale(2)'`) -**Mpx 运行时增强的简写属性:** 以下属性由 Mpx 在运行时进行解析和转换处理,支持在 `style` 内联样式中使用: +**Mpx 编译和运行时增强的简写属性:** 以下简写属性由 Mpx 在编译和运行时自动展开,支持在 `class` 类样式、`style` 内联样式和 CSS 变量中使用: - **过渡简写**:`transition` - 在运行时将 `transition` 字符串简写解析为动画配置,如 `transition: opacity 0.3s ease` → 解析出 `property`、`duration`、`timingFunction` 等参数 - - 同时支持 `transition-property`、`transition-duration`、`transition-delay`、`transition-timing-function` 子属性的独立设置和合并 - 仅 `view` 组件支持;`transition-property` 不支持 `all`,需显式指定属性名;不支持 `step-start`、`step-end`、`steps()` 等阶梯时间函数 -- **背景尺寸**:`background-size` - - 在运行时解析多值语法(如 `background-size: 200rpx 100rpx`),将宽高值分别提取并转换为 RN 可用的数值格式 - - 仅 `view` 组件支持,需配合 `background-image` 使用 -- **背景位置**:`background-position` - - 在运行时解析多值语法(如 `background-position: 50% 50%`),支持关键字(`center`、`left`、`right`、`top`、`bottom`)、百分比和数值 - - 仅 `view` 组件支持,需配合 `background-image` 使用 - -**Mpx 编译时增强的简写属性:** 以下属性及其多值语法 RN 不原生支持,Mpx 会在编译时自动将其展开为 RN 支持的多属性结构: - - **间距多值语法**:`margin`、`padding` 的 2-4 值语法 - - 如 `margin: 10px 20px` → `marginTop: 10, marginRight: 20...` + - 如 `margin: 10px 20px` → `marginTop: 10, marginRight: 20, marginBottom: 10, marginLeft: 20` + - 单值语法 RN 原生支持,运行时不展开 - **边框多值语法**:`border-width`、`border-color`、`border-radius` 的 2-4 值语法 - - 如 `border-width: 1px 2px` → `borderTopWidth: 1, borderRightWidth: 2...` -- **边框简写**:`border` + - 如 `border-width: 1px 2px` → `borderTopWidth: 1, borderRightWidth: 2, borderBottomWidth: 1, borderLeftWidth: 2` + - 单值语法 RN 原生支持,运行时不展开 +- **边框简写**:`border`、`border-top`、`border-right`、`border-bottom`、`border-left` - 如 `border: 1px solid red` → `borderWidth: 1, borderStyle: 'solid', borderColor: 'red'` -- **方向边框简写**:`border-top`、`border-right`、`border-bottom`、`border-left` -- **布局简写**:`flex` 的多值语法、`flex-flow` - - 如 `flex: 1 1 auto` → `flexGrow: 1, flexShrink: 1, flexBasis: 'auto'` -- **背景简写**:`background`(仅 `view` 支持) + - `border: none` → `borderWidth: 0` +- **布局简写**:`flex`、`flex-flow` + - `flex: 1` → `flexGrow: 1, flexShrink: 1, flexBasis: 0`;`flex: 1 1 auto` → `flexGrow: 1, flexShrink: 1, flexBasis: 'auto'` + - `flex: none` → `flexGrow: 0, flexShrink: 0`;`flex: initial` → `flexGrow: 0, flexShrink: 1` + - `flex-flow: row wrap` → `flexDirection: 'row', flexWrap: 'wrap'` +- **背景简写**:`background`、`background-size`、`background-position`(仅 `view` 支持) + - `background: url(bg.png) no-repeat center/cover #fff` → 展开为 `backgroundImage`、`backgroundRepeat`、`backgroundPosition`、`backgroundSize`、`backgroundColor` + - `background-size` 多值字符串自动转换为数组格式,支持 `cover`、`contain`、`auto` 及长度值 + - `background-position` 多值字符串自动转换为数组格式,支持关键字(`center`、`left`、`right`、`top`、`bottom`)、百分比及长度值 - **阴影简写**:`text-shadow` + - 如 `text-shadow: 1px 2px 3px red` → `textShadowOffset`、`textShadowRadius`、`textShadowColor` - **装饰简写**:`text-decoration` + - 如 `text-decoration: line-through solid red` → `textDecorationLine`、`textDecorationStyle`、`textDecorationColor` **使用限制:** - -- ✅ **class 类样式**:支持所有简写属性及其多值语法,Mpx 会在编译时自动展开。 -- ❌ **style 内联样式**:仅支持 RN 原生简写属性和 Mpx 运行时增强的简写属性,**不支持** Mpx 编译增强的简写属性(如 `border`、多值 `margin` 等),在内联样式中使用这些属性会导致 RN 运行时报错或无效。 -- ❌ **CSS 变量**:编译增强的简写属性不支持单个 `var()` 函数(如 `margin: var(--spacing)`),但支持多值简写中使用多个 `var()`(如 `margin: var(--v) var(--h)`)。RN 原生支持的简写属性(如单值 `margin`)支持单个 `var()`。 - ❌ **`border-style` 多值语法**:`border-style` 不支持 2-4 值语法(如 `border-style: solid dashed`),因为 RN 环境中不支持分别设置各方向的 `borderStyle`,仅支持统一设置单值(如 `border-style: solid`)。 ### CSS 变量与函数 @@ -450,6 +445,7 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样 | `flex-grow` | `number` | `0` | 放大比例 | `flex-grow: 1` 等比例分配剩余空间 | | `flex-shrink` | `number` | `0` | 收缩比例 | `flex-shrink: 1` 空间不足时允许收缩 | | `flex-basis` | `auto` \| `length` \| `%` | `auto` | 初始大小 | `flex-basis: 200rpx` 初始宽度 200rpx | +| `flex-flow` | ` ` | - | 简写属性,运行时自动展开为 `flex-direction` 和 `flex-wrap` | `flex-flow: row wrap` | | `gap` / `row-gap` / `column-gap` | `length` | - | 行列间距 | `gap: 20rpx`;`row-gap: 10rpx; column-gap: 20rpx` 分别设置行列间距 | ### 定位与层级 @@ -511,11 +507,11 @@ Mpx 在 RN 平台支持 CSS 背景图及渐变背景,框架会自动处理样 | 属性 | 值类型 | 默认值 | 说明 | 示例 | | --- | --- | --- | --- | --- | | `color` | `color` | - | 文本颜色 | `color: #333`;`color: rgba(0, 0, 0, 0.8)` | -| `font-family` | `string` | - | 字体(仅支持单字体) | `font-family: PingFangSC-Regular` | +| `font-family` | `string` | - | 字体(仅支持单字体),多字体 fallback 自动取首值并去除引号 | `font-family: PingFangSC-Regular` | | `font-size` | `length` | - | 字体大小 | `font-size: 28rpx`;`font-size: 16px` | | `font-weight` | `normal` \| `bold` \| `100-900` | `normal` | 字体粗细 | `font-weight: bold`;`font-weight: 500` | | `font-style` | `normal` \| `italic` | `normal` | 字体样式 | `font-style: italic` 斜体 | -| `line-height` | `length` \| `number` \| `%` | - | 行高 | `line-height: 40rpx`;`line-height: 1.5` | +| `line-height` | `length` \| `number` \| `%` | - | 行高,纯数字值自动转换为百分比(如 `1.5` → `150%`) | `line-height: 40rpx`;`line-height: 1.5` | | `text-align` | `left` \| `right` \| `center` \| `justify` | `auto` | 文本对齐;`auto` 是 RN 默认值,不支持在用户源码中显式书写 | `text-align: center` 文本居中 | | `vertical-align` | `auto` \| `top` \| `bottom` \| `middle` | - | 垂直对齐(Android / Harmony 支持,iOS 不支持) | `vertical-align: middle` | | `text-decoration` | `line style color` | - | 装饰线简写;`text-decoration-style` / `text-decoration-color` 仅 iOS 支持 | `text-decoration: underline`;`text-decoration: line-through solid red` | diff --git a/packages/webpack-plugin/lib/platform/style/wx/index.js b/packages/webpack-plugin/lib/platform/style/wx/index.js index f242f514a4..81ad89cd8d 100644 --- a/packages/webpack-plugin/lib/platform/style/wx/index.js +++ b/packages/webpack-plugin/lib/platform/style/wx/index.js @@ -18,6 +18,14 @@ module.exports = function getSpec({ warn, error }) { const calcExp = /calc\(/ const envExp = /env\(/ const silentVerify = 'silent' + const namedColorSet = new Set(['transparent', 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen']) + const hexColorExp = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ + const colorFnExp = /^(rgb|rgba|hsl|hsla|hwb)\(.+\)$/ + const valueExp = { + integer: /^(-?(\d+(\.\d+)?|\.\d+))$/, + length: /^((-?(\d+(\.\d+)?|\.\d+))(rpx|px|%|vw|vh)?|hairlineWidth)$/, + color: { test: (v) => namedColorSet.has(v) || hexColorExp.test(v) || colorFnExp.test(v) } + } // 不支持的属性提示 const unsupportedPropError = ({ prop, value, selector }, { mode }, isError = true) => { const tips = isError ? error : warn @@ -145,12 +153,6 @@ module.exports = function getSpec({ warn, error }) { // calc() / env() 跳过值校验,但保留 rawValue 输出 if (calcExp.test(valueForVerify) || envExp.test(valueForVerify)) return true - const namedColor = ['transparent', 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen'] - const valueExp = { - integer: /^(-?(\d+(\.\d+)?|\.\d+))$/, - length: /^((-?(\d+(\.\d+)?|\.\d+))(rpx|px|%|vw|vh)?|hairlineWidth)$/, - color: new RegExp(('^(' + namedColor.join('|') + ')$') + '|(^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$)|^(rgb|rgba|hsl|hsla|hwb)\\(.+\\)$') - } const type = getValueType(prop) const tipsType = (type) => { const info = { @@ -330,7 +332,7 @@ module.exports = function getSpec({ warn, error }) { // background 相关属性的转换 Todo // 仅支持以下属性,不支持其他背景相关的属性 // /^((?!(-color)).)*background((?!(-color)).)*$/ 包含background且不包含background-color - const checkBackgroundImage = ({ prop, value, selector }, { mode }) => { + const formatBackground = ({ prop, value, selector }, { mode }) => { const bgPropMap = { image: 'background-image', color: 'background-color', @@ -650,9 +652,9 @@ module.exports = function getSpec({ warn, error }) { rules: [ { // 背景相关属性的处理 test: /^(background|background-image|background-size|background-position)$/, - ios: checkBackgroundImage, - android: checkBackgroundImage, - harmony: checkBackgroundImage + ios: formatBackground, + android: formatBackground, + harmony: formatBackground }, { // margin padding 内外边距的处理 test: /^(margin|padding|border-radius|border-width|border-color)$/, diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-image.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-image.tsx index 0b7ceae699..e1bd3b1205 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-image.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-image.tsx @@ -27,7 +27,7 @@ import { noop } from '@mpxjs/utils' import { LocalSvg, SvgCssUri } from 'react-native-svg/css' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' -import { SVG_REGEXP, useLayout, useTransformStyle, renderImage, extendObject, isAndroid } from './utils' +import { svgRegExp, useLayout, useTransformStyle, renderImage, extendObject, isAndroid } from './utils' import Portal from './mpx-portal' export type Mode = @@ -109,7 +109,7 @@ function getImageUri (src: string | ImageSourcePropType) { function isSvgSource (src: string | ImageSourcePropType) { const uri = getImageUri(src) - return SVG_REGEXP.test(uri) + return svgRegExp.test(uri) } function getImageSize (src: string | ImageSourcePropType, success: (width: number, height: number) => void, fail: () => void = noop) { diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-movable-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-movable-view.tsx index d27254ed12..080c082459 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-movable-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-movable-view.tsx @@ -22,7 +22,7 @@ import { StyleSheet, View, LayoutChangeEvent } from 'react-native' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' import { MovableAreaContext } from './context' -import { useTransformStyle, splitProps, splitStyle, HIDDEN_STYLE, wrapChildren, GestureHandler, flatGesture, extendObject, omit, useNavigation, useRunOnJSCallback, useTextPassThroughValue } from './utils' +import { useTransformStyle, splitProps, splitStyle, hiddenStyle, wrapChildren, GestureHandler, flatGesture, extendObject, omit, useNavigation, useRunOnJSCallback, useTextPassThroughValue } from './utils' import { GestureDetector, Gesture, GestureTouchEvent, GestureStateChangeEvent, PanGestureHandlerEventPayload, PanGesture } from 'react-native-gesture-handler' import Animated, { useSharedValue, @@ -719,7 +719,7 @@ const _MovableView = forwardRef, MovableViewP return handlers } - const layoutStyle = !hasLayoutRef.current && hasSelfPercent ? HIDDEN_STYLE : {} + const layoutStyle = !hasLayoutRef.current && hasSelfPercent ? hiddenStyle : {} // bind 相关 touch 事件直接由 gesture 触发,无须重复挂载 // catch 相关 touch 事件需要重写并通过 useInnerProps 注入阻止冒泡逻辑 diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx index 94d28c273d..a8a386c2a6 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx @@ -38,7 +38,7 @@ import Animated, { useSharedValue, withTiming, useAnimatedStyle, runOnJS } from import { warn, hasOwn } from '@mpxjs/utils' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' -import { splitProps, splitStyle, useTransformStyle, useLayout, wrapChildren, extendObject, flatGesture, GestureHandler, HIDDEN_STYLE, useRunOnJSCallback, useTextPassThroughValue } from './utils' +import { splitProps, splitStyle, useTransformStyle, useLayout, wrapChildren, extendObject, flatGesture, GestureHandler, hiddenStyle, useRunOnJSCallback, useTextPassThroughValue } from './utils' import { IntersectionObserverContext, ScrollViewContext } from './context' import Portal from './mpx-portal' @@ -255,7 +255,7 @@ const _ScrollView = forwardRef, S const hasRefresherLayoutRef = useRef(false) // layout 完成前先隐藏,避免安卓闪烁问题 - const refresherLayoutStyle = useMemo(() => { return !hasRefresherLayoutRef.current ? HIDDEN_STYLE : {} }, [hasRefresherLayoutRef.current]) + const refresherLayoutStyle = useMemo(() => { return !hasRefresherLayoutRef.current ? hiddenStyle : {} }, [hasRefresherLayoutRef.current]) const lastOffset = useRef(0) if (scrollX && scrollY) { diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx index dc1d334470..ada7053dfd 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx @@ -12,7 +12,7 @@ import useAnimationHooks, { AnimationType } from './animationHooks/index' import type { AnimationProp } from './animationHooks/utils' import { ExtendedViewStyle } from './types/common' import useNodesRef, { HandlerRef } from './useNodesRef' -import { parseUrl, PERCENT_REGEX, splitStyle, splitProps, useTransformStyle, wrapChildren, useLayout, renderImage, pickStyle, extendObject, useHover, useTextPassThroughValue } from './utils' +import { parseUrl, percentRegExp, splitStyle, splitProps, useTransformStyle, wrapChildren, useLayout, renderImage, pickStyle, extendObject, useHover, useTextPassThroughValue } from './utils' import { error, isFunction } from '@mpxjs/utils' import LinearGradient from 'react-native-linear-gradient' import { GestureDetector, PanGesture } from 'react-native-gesture-handler' @@ -127,18 +127,7 @@ const applyHandlers = (handlers: Handler[], args: any[]) => { } } -const normalizeStyle = (style: ExtendedViewStyle = {}) => { - ['backgroundSize', 'backgroundPosition'].forEach(name => { - if (style[name] && typeof style[name] === 'string') { - if (style[name].trim()) { - style[name] = style[name].split(' ') - } - } - }) - return style -} - -const isPercent = (val: string | number | undefined): val is string => typeof val === 'string' && PERCENT_REGEX.test(val) +const isPercent = (val: string | number | undefined): val is string => typeof val === 'string' && percentRegExp.test(val) const isBackgroundSizeKeyword = (val: string | number): boolean => typeof val === 'string' && /^cover|contain$/.test(val) @@ -546,7 +535,7 @@ function normalizeBackgroundSize ( } function preParseImage (imageStyle?: ExtendedViewStyle) { - const { backgroundImage = '', backgroundSize = ['auto'], backgroundPosition = [0, 0] } = normalizeStyle(imageStyle) || {} + const { backgroundImage = '', backgroundSize = ['auto'], backgroundPosition = [0, 0] } = imageStyle || {} const { type, src, linearInfo } = parseBgImage(backgroundImage) return { diff --git a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx index f25af495df..3719e0f6e8 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx @@ -7,46 +7,51 @@ import { initialWindowMetrics } from 'react-native-safe-area-context' import type { AnyFunc, ExtendedFunctionComponent } from './types/common' import { Gesture } from 'react-native-gesture-handler' -export const TEXT_STYLE_MAP: Record = { +// ============================================================ +// declare + constants +// ============================================================ + +declare const __mpx_mode__: 'ios' | 'android' | 'harmony' + +export const percentRegExp = /^\s*-?\d+(\.\d+)?%\s*$/ +export const svgRegExp = /\.svg(?:[?#].*)?$/i +export const hiddenStyle = { + opacity: 0 +} +export const isIOS = __mpx_mode__ === 'ios' +export const isAndroid = __mpx_mode__ === 'android' +export const isHarmony = __mpx_mode__ === 'harmony' +export const extendObject = Object.assign + +const textStyleMap: Record = { color: true, letterSpacing: true, lineHeight: true, includeFontPadding: true, writingDirection: true } -export const PERCENT_REGEX = /^\s*-?\d+(\.\d+)?%\s*$/ -export const URL_REGEX = /^\s*url\(["']?(.*?)["']?\)\s*$/ -export const SVG_REGEXP = /\.svg(?:[?#].*)?$/i -export const BACKGROUND_STYLE_MAP: Record = { +const urlRegExp = /^\s*url\(["']?(.*?)["']?\)\s*$/ +const linearGradientRegExp = /^\s*linear-gradient\(.*\)\s*$/ +const digitStartRegExp = /^\d/ +const varDecRegExp = /^--/ +const varUseRegExp = /var\(/ +const unoVarDecRegExp = /^--un-/ +const unoVarUseRegExp = /var\(--un-/ +const calcUseRegExp = /calc\(/ +const envUseRegExp = /env\(/ +const defaultBoxSizingStyle = { + boxSizing: 'content-box' +} +const backgroundStyleMap: Record = { backgroundImage: true, backgroundSize: true, backgroundRepeat: true, backgroundPosition: true } -export const TEXT_PROPS_MAP: Record = { +const textPropsMap: Record = { ellipsizeMode: true, numberOfLines: true } -export const DEFAULT_FONT_SIZE = 16 -export const HIDDEN_STYLE = { - opacity: 0 -} -export const DEFAULT_BOX_SIZING_STYLE = { - boxSizing: 'content-box' -} - -declare const __mpx_mode__: 'ios' | 'android' | 'harmony' - -export const isIOS = __mpx_mode__ === 'ios' -export const isAndroid = __mpx_mode__ === 'android' -export const isHarmony = __mpx_mode__ === 'harmony' - -const varDecRegExp = /^--/ -const varUseRegExp = /var\(/ -const unoVarDecRegExp = /^--un-/ -const unoVarUseRegExp = /var\(--un-/ -const calcUseRegExp = /calc\(/ -const envUseRegExp = /env\(/ const boxSizingAffectingStyleMap: Record = { padding: true, paddingTop: true, @@ -57,47 +62,285 @@ const boxSizingAffectingStyleMap: Record = { borderTopWidth: true, borderRightWidth: true, borderBottomWidth: true, - borderLeftWidth: true + borderLeftWidth: true, + border: true, + borderTop: true, + borderRight: true, + borderBottom: true, + borderLeft: true +} +const runtimeAbbreviationMap: Record = { + margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], + padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], + borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'], + borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], + border: ['borderWidth', 'borderStyle', 'borderColor'], + borderTop: ['borderTopWidth', 'borderTopStyle', 'borderTopColor'], + borderRight: ['borderRightWidth', 'borderRightStyle', 'borderRightColor'], + borderBottom: ['borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'], + borderLeft: ['borderLeftWidth', 'borderLeftStyle', 'borderLeftColor'], + flexFlow: ['flexDirection', 'flexWrap'], + textShadow: ['textShadowOffset.width', 'textShadowOffset.height', 'textShadowRadius', 'textShadowColor'], + textDecoration: ['textDecorationLine', 'textDecorationStyle', 'textDecorationColor'] +} +const runtimeCompositeStyleMap: Record = { + margin: true, + padding: true, + borderRadius: true, + borderWidth: true, + borderColor: true } - const safeAreaInsetMap: Record = { 'safe-area-inset-top': 'top', 'safe-area-inset-right': 'right', 'safe-area-inset-bottom': 'bottom', 'safe-area-inset-left': 'left' } +const radiusPercentRule: Record = { + borderTopLeftRadius: 'width', + borderBottomLeftRadius: 'width', + borderBottomRightRadius: 'width', + borderTopRightRadius: 'width', + borderRadius: 'width' +} +const selfPercentRule: Record = extendObject({ + translateX: 'width', + translateY: 'height' +}, radiusPercentRule) +const parentHeightPercentRule: Record = { + height: true, + minHeight: true, + maxHeight: true, + top: true, + bottom: true +} +const bgRepeatMap: Record = { + repeat: true, + 'repeat-x': true, + 'repeat-y': true, + 'no-repeat': true +} +const bgPositionMap: Record = { + left: true, + right: true, + top: true, + bottom: true, + center: true +} +const namedColorSet: Record = { + transparent: true, + aliceblue: true, + antiquewhite: true, + aqua: true, + aquamarine: true, + azure: true, + beige: true, + bisque: true, + black: true, + blanchedalmond: true, + blue: true, + blueviolet: true, + brown: true, + burlywood: true, + cadetblue: true, + chartreuse: true, + chocolate: true, + coral: true, + cornflowerblue: true, + cornsilk: true, + crimson: true, + cyan: true, + darkblue: true, + darkcyan: true, + darkgoldenrod: true, + darkgray: true, + darkgreen: true, + darkgrey: true, + darkkhaki: true, + darkmagenta: true, + darkolivegreen: true, + darkorange: true, + darkorchid: true, + darkred: true, + darksalmon: true, + darkseagreen: true, + darkslateblue: true, + darkslategrey: true, + darkturquoise: true, + darkviolet: true, + deeppink: true, + deepskyblue: true, + dimgray: true, + dimgrey: true, + dodgerblue: true, + firebrick: true, + floralwhite: true, + forestgreen: true, + fuchsia: true, + gainsboro: true, + ghostwhite: true, + gold: true, + goldenrod: true, + gray: true, + green: true, + greenyellow: true, + grey: true, + honeydew: true, + hotpink: true, + indianred: true, + indigo: true, + ivory: true, + khaki: true, + lavender: true, + lavenderblush: true, + lawngreen: true, + lemonchiffon: true, + lightblue: true, + lightcoral: true, + lightcyan: true, + lightgoldenrodyellow: true, + lightgray: true, + lightgreen: true, + lightgrey: true, + lightpink: true, + lightsalmon: true, + lightseagreen: true, + lightskyblue: true, + lightslategrey: true, + lightsteelblue: true, + lightyellow: true, + lime: true, + limegreen: true, + linen: true, + magenta: true, + maroon: true, + mediumaquamarine: true, + mediumblue: true, + mediumorchid: true, + mediumpurple: true, + mediumseagreen: true, + mediumslateblue: true, + mediumspringgreen: true, + mediumturquoise: true, + mediumvioletred: true, + midnightblue: true, + mintcream: true, + mistyrose: true, + moccasin: true, + navajowhite: true, + navy: true, + oldlace: true, + olive: true, + olivedrab: true, + orange: true, + orangered: true, + orchid: true, + palegoldenrod: true, + palegreen: true, + paleturquoise: true, + palevioletred: true, + papayawhip: true, + peachpuff: true, + peru: true, + pink: true, + plum: true, + powderblue: true, + purple: true, + rebeccapurple: true, + red: true, + rosybrown: true, + royalblue: true, + saddlebrown: true, + salmon: true, + sandybrown: true, + seagreen: true, + seashell: true, + sienna: true, + silver: true, + skyblue: true, + slateblue: true, + slategray: true, + snow: true, + springgreen: true, + steelblue: true, + tan: true, + teal: true, + thistle: true, + tomato: true, + turquoise: true, + violet: true, + wheat: true, + white: true, + whitesmoke: true, + yellow: true, + yellowgreen: true +} -export const extendObject = Object.assign +// ============================================================ +// interfaces / types +// ============================================================ -export function getDefaultAllowFontScaling (): boolean { - return global.__mpx?.config?.rnConfig?.allowFontScaling ?? false +interface PercentConfig { + fontSize?: number | string + width?: number + height?: number + parentFontSize?: number + parentWidth?: number + parentHeight?: number } -export function transformBoxSizing (style: Record = {}, hasBoxSizingAffectingStyle = false) { - if (hasBoxSizingAffectingStyle && style.boxSizing === undefined) { - style.boxSizing = global.__mpx?.config?.rnConfig?.defaultBoxSizing ?? DEFAULT_BOX_SIZING_STYLE.boxSizing - } - return style +interface PositionMeta { + hasPositionFixed: boolean } -export function isBoxSizingAffectingStyle (key: string) { - return hasOwn(boxSizingAffectingStyleMap, key) +interface TransformStyleConfig { + enableVar?: boolean + externalVarContext?: Record + parentFontSize?: number + parentWidth?: number + parentHeight?: number + transformRadiusPercent?: boolean } -function isTextStyle (key: string) { - return hasOwn(TEXT_STYLE_MAP, key) || key.startsWith('font') || key.startsWith('text') +export interface VisitorArg { + target: Record + key: string + value: any + keyPath: Array } -function getSafeAreaInset (name: string, navigation: Record | undefined) { - const insets = extendObject({}, initialWindowMetrics?.insets, navigation?.insets) - return insets[safeAreaInsetMap[name]] +type GroupData = Record> + +interface LayoutConfig { + props: Record + hasSelfPercent: boolean + setWidth?: Dispatch> + setHeight?: Dispatch> + onLayout?: (event?: LayoutChangeEvent) => void + nodeRef: React.RefObject } -export function useNavigation (): Record | undefined { - const { navigation } = useContext(RouteContext) || {} - return navigation +export interface WrapChildrenConfig { + hasVarDec: boolean + varContext?: Record + textPassThrough?: TextPassThroughContextValue | null +} + +export interface TextPassThroughValueOptions { + inheritTextProps?: boolean + disabled?: boolean } +export interface GestureHandler { + nodeRefs?: Array<{ getNodeInstance: () => { nodeRef: unknown } }> + current?: unknown +} + +// ============================================================ +// generic utility functions +// ============================================================ + export function omit (obj: T, fields: K[]): Omit { const shallowCopy: any = extendObject({}, obj) for (let i = 0; i < fields.length; i += 1) { @@ -107,31 +350,9 @@ export function omit (obj: T, fields: K[]): Omit { return shallowCopy } -/** - * 用法等同于 useEffect,但是会忽略首次执行,只在依赖更新时执行 - */ -export const useUpdateEffect = (effect: any, deps: any) => { - const isMounted = useRef(false) - - // for react-refresh - useEffect(() => { - return () => { - isMounted.current = false - } - }, []) - - useEffect(() => { - if (!isMounted.current) { - isMounted.current = true - } else { - return effect() - } - }, deps) -} - export const parseUrl = (cssUrl = '') => { if (!cssUrl) return - const match = cssUrl.match(URL_REGEX) + const match = cssUrl.match(urlRegExp) return match?.[1] } @@ -143,6 +364,10 @@ export const getRestProps = (transferProps: any = {}, originProps: any = {}, del ) } +export function getDefaultAllowFontScaling (): boolean { + return global.__mpx?.config?.rnConfig?.allowFontScaling ?? false +} + export function isText (ele: ReactNode): ele is ReactElement { if (isValidElement(ele)) { const displayName = (ele.type as ExtendedFunctionComponent)?.displayName @@ -158,8 +383,6 @@ export function isStringChildren (children: ReactNode) { return children.every((child) => typeof child === 'string') } -type GroupData = Record> - export function groupBy> ( obj: T, callback: (key: string, val: T[keyof T]) => string, @@ -173,6 +396,76 @@ export function groupBy> ( return group } +// 多value解析 +export function parseValues (str: string, char = ' ') { + let stack = 0 + let temp = '' + const result = [] + for (let i = 0; i < str.length; i++) { + if (str[i] === '(') { + stack++ + } else if (str[i] === ')') { + stack-- + } + // 非括号内 或者 非分隔字符且非空 + if (stack !== 0 || str[i] !== char) { + temp += str[i] + } + if ((stack === 0 && str[i] === char) || i === str.length - 1) { + result.push(temp.trim()) + temp = '' + } + } + return result +} + +export const debounce = ( + func: T, + delay: number +): ((...args: Parameters) => void) & { clear: () => void } => { + let timer: any + const wrapper = (...args: ReadonlyArray) => { + timer && clearTimeout(timer) + timer = setTimeout(() => { + func(...args) + }, delay) + } + wrapper.clear = () => { + timer && clearTimeout(timer) + timer = null + } + return wrapper +} + +// ============================================================ +// style classification & splitting +// ============================================================ + +function isTextStyle (key: string) { + return hasOwn(textStyleMap, key) || key.startsWith('font') || key.startsWith('text') +} + +function isColorValue (token: string): boolean { + if (token.startsWith('#') || token.startsWith('rgb(') || token.startsWith('rgba(') || token.startsWith('hsl(') || token.startsWith('hsla(')) return true + return hasOwn(namedColorSet, token.toLowerCase()) +} + +function getSafeAreaInset (name: string, navigation: Record | undefined) { + const insets = extendObject({}, initialWindowMetrics?.insets, navigation?.insets) + return insets[safeAreaInsetMap[name]] +} + +export function isBoxSizingAffectingStyle (key: string) { + return hasOwn(boxSizingAffectingStyleMap, key) +} + +export function transformBoxSizing (style: Record = {}, hasBoxSizingAffectingStyle = false) { + if (hasBoxSizingAffectingStyle && style.boxSizing === undefined) { + style.boxSizing = global.__mpx?.config?.rnConfig?.defaultBoxSizing ?? defaultBoxSizingStyle.boxSizing + } + return style +} + export function splitStyle> (styleObj: T, sideEffect?: (key: string, val: T[keyof T]) => void): { textStyle?: Partial backgroundStyle?: Partial @@ -182,81 +475,44 @@ export function splitStyle> (styleObj: T, sideEffe sideEffect && sideEffect(key, val) if (isTextStyle(key)) { return 'textStyle' - } else if (hasOwn(BACKGROUND_STYLE_MAP, key)) { + } else if (hasOwn(backgroundStyleMap, key)) { return 'backgroundStyle' } else { return 'innerStyle' } }) } -const radiusPercentRule: Record = { - borderTopLeftRadius: 'width', - borderBottomLeftRadius: 'width', - borderBottomRightRadius: 'width', - borderTopRightRadius: 'width', - borderRadius: 'width' -} -const selfPercentRule: Record = extendObject({ - translateX: 'width', - translateY: 'height' -}, radiusPercentRule) - -const parentHeightPercentRule: Record = { - height: true, - minHeight: true, - maxHeight: true, - top: true, - bottom: true -} -interface PercentConfig { - fontSize?: number | string - width?: number - height?: number - parentFontSize?: number - parentWidth?: number - parentHeight?: number +export function splitProps> (props: T): { + textProps?: Partial + innerProps?: Partial +} { + return groupBy(props, (key) => { + if (hasOwn(textPropsMap, key)) { + return 'textProps' + } else { + return 'innerProps' + } + }) as { + textProps: Partial + innerProps: Partial + } } -interface PositionMeta { - hasPositionFixed: boolean +export function pickStyle (styleObj: Record = {}, pickedKeys: Array, callback?: (key: string, val: number | string) => number | string) { + return pickedKeys.reduce>((acc, key) => { + if (key in styleObj) { + acc[key] = callback ? callback(key, styleObj[key]) : styleObj[key] + } + return acc + }, {}) } -function resolvePercent (value: string | number | undefined, key: string, percentConfig: PercentConfig): string | number | undefined { - if (!(typeof value === 'string' && PERCENT_REGEX.test(value))) return value - let base - let reason - if (key === 'fontSize') { - base = percentConfig.parentFontSize - reason = 'parent-font-size' - } else if (key === 'lineHeight') { - base = resolvePercent(percentConfig.fontSize, 'fontSize', percentConfig) - reason = 'font-size' - } else if (selfPercentRule[key]) { - base = percentConfig[selfPercentRule[key]] - reason = selfPercentRule[key] - } else if (parentHeightPercentRule[key]) { - base = percentConfig.parentHeight - reason = 'parent-height' - } else { - base = percentConfig.parentWidth - reason = 'parent-width' - } - if (typeof base !== 'number') { - error(`[${key}] can not contain % unit unless you set [${reason}] with a number for the percent calculation.`) - return value - } else { - return parseFloat(value) / 100 * base - } -} +// ============================================================ +// style transform pipeline +// ============================================================ -function transformPercent (styleObj: Record, percentKeyPaths: Array>, percentConfig: PercentConfig) { - percentKeyPaths.forEach((percentKeyPath) => { - setStyle(styleObj, percentKeyPath, ({ target, key, value }) => { - target[key] = resolvePercent(value, key, percentConfig) - }) - }) -} +// --- var --- function resolveVar (input: string, varContext: Record) { const parsed = parseFunc(input, 'var') @@ -300,6 +556,8 @@ function transformVar (styleObj: Record, varKeyPaths: Array, envKeyPaths: Array>, navigation: Record | undefined) { envKeyPaths.forEach((envKeyPath) => { setStyle(styleObj, envKeyPath, ({ target, key, value }) => { @@ -316,6 +574,46 @@ function transformEnv (styleObj: Record, envKeyPaths: Array, percentKeyPaths: Array>, percentConfig: PercentConfig) { + percentKeyPaths.forEach((percentKeyPath) => { + setStyle(styleObj, percentKeyPath, ({ target, key, value }) => { + target[key] = resolvePercent(value, key, percentConfig) + }) + }) +} + +// --- calc --- + function transformCalc (styleObj: Record, calcKeyPaths: Array>, formatter: (value: string, key: string) => number) { calcKeyPaths.forEach((calcKeyPath) => { setStyle(styleObj, calcKeyPath, ({ target, key, value }) => { @@ -337,6 +635,15 @@ function transformCalc (styleObj: Record, calcKeyPaths: Array, meta: PositionMeta) { + if (styleObj.position === 'fixed') { + styleObj.position = 'absolute' + meta.hasPositionFixed = true + } +} + function transformStringify (styleObj: Record) { if (isNumber(styleObj.fontWeight)) { styleObj.fontWeight = '' + styleObj.fontWeight @@ -347,34 +654,8 @@ function transformStringify (styleObj: Record) { } } -function transformPosition (styleObj: Record, meta: PositionMeta) { - if (styleObj.position === 'fixed') { - styleObj.position = 'absolute' - meta.hasPositionFixed = true - } -} -// 多value解析 -export function parseValues (str: string, char = ' ') { - let stack = 0 - let temp = '' - const result = [] - for (let i = 0; i < str.length; i++) { - if (str[i] === '(') { - stack++ - } else if (str[i] === ')') { - stack-- - } - // 非括号内 或者 非分隔字符且非空 - if (stack !== 0 || str[i] !== char) { - temp += str[i] - } - if ((stack === 0 && str[i] === char) || i === str.length - 1) { - result.push(temp.trim()) - temp = '' - } - } - return result -} +// --- transform / shadow --- + // parse string transform, eg: transform: 'rotateX(45deg) rotateZ(0.785398rad)' function parseTransform (transformStr: string) { const values = parseValues(transformStr) @@ -431,6 +712,7 @@ function parseTransform (transformStr: string) { }) return transform } + // format style transform function transformTransform (style: Record) { if (!style.transform || Array.isArray(style.transform)) return @@ -444,15 +726,226 @@ function transformBoxShadow (styleObj: Record) { }, '') } -interface TransformStyleConfig { - enableVar?: boolean - externalVarContext?: Record - parentFontSize?: number - parentWidth?: number - parentHeight?: number - transformRadiusPercent?: boolean +// --- shorthand --- + +function expandCompositeValues (values: string[]): string[] { + const v = values.slice(0, 4) + switch (v.length) { + case 1: + return [v[0], v[0], v[0], v[0]] + case 2: + return [v[0], v[1], v[0], v[1]] + case 3: + return [v[0], v[1], v[2], v[1]] + default: + return v + } +} + +function expandAbbreviation (values: string[], props: string[]): Array<[string, any]> { + const result: Array<[string, any]> = [] + const dotMap: Record> = {} + for (let i = 0; i < props.length && i < values.length; i++) { + const prop = props[i] + const formatted = global.__formatValue(values[i]) + if (prop.includes('.')) { + const [main, sub] = prop.split('.') + if (!dotMap[main]) { + dotMap[main] = {} + result.push([main, dotMap[main]]) + } + dotMap[main][sub] = formatted + } else { + result.push([prop, formatted]) + } + } + return result +} + +function expandFlex (value: string): Array<[string, any]> | null { + const values = parseValues(value) + if (values.length === 0) return null + if (values.length === 1) { + if (values[0] === 'none') { + return [['flexGrow', 0], ['flexShrink', 0]] + } + if (values[0] === 'initial') { + return [['flexGrow', 0], ['flexShrink', 1]] + } + } + const result: Array<[string, any]> = [] + let i = 0 + const isNum = (v: string) => !isNaN(+v) + if (isNum(values[i])) { + result.push(['flexGrow', +values[i++]]) + } else { + result.push(['flexGrow', 1]) + } + if (i < values.length && isNum(values[i])) { + result.push(['flexShrink', +values[i++]]) + } else { + result.push(['flexShrink', 1]) + } + if (i < values.length) { + if (values[i] !== 'auto') { + result.push(['flexBasis', global.__formatValue(values[i])]) + } + } else { + result.push(['flexBasis', 0]) + } + return result +} + +function transformFlex (styleObj: Record) { + const value = styleObj.flex + if (typeof value !== 'string') return + const flexResult = expandFlex(value) + if (!flexResult) return + delete styleObj.flex + for (const [prop, val] of flexResult) { + if (!hasOwn(styleObj, prop)) styleObj[prop] = val + } +} + +function transformShorthand (styleObj: Record, shorthandKeys: string[]) { + if (shorthandKeys.length === 0) return + for (const key of shorthandKeys) { + const value = styleObj[key] + if (typeof value !== 'string') continue + if ((key === 'border' || key === 'borderTop' || key === 'borderRight' || key === 'borderBottom' || key === 'borderLeft') && value.trim() === 'none') { + const prop = runtimeAbbreviationMap[key][0] + delete styleObj[key] + if (!hasOwn(styleObj, prop)) styleObj[prop] = 0 + continue + } + const values = parseValues(value) + const props = runtimeAbbreviationMap[key] + if (!props) continue + if (hasOwn(runtimeCompositeStyleMap, key) && values.length === 1) continue + const expandedValues = hasOwn(runtimeCompositeStyleMap, key) ? expandCompositeValues(values) : values + const pairs = expandAbbreviation(expandedValues, props) + delete styleObj[key] + for (const [prop, val] of pairs) { + if (!hasOwn(styleObj, prop)) styleObj[prop] = val + } + } +} + +// --- runtime alignment --- + +function transformLineHeight (styleObj: Record) { + const value = styleObj.lineHeight + if (typeof value === 'number' && value !== 0) { + styleObj.lineHeight = `${Math.round(value * 100)}%` + } +} + +function transformFontFamily (styleObj: Record) { + const value = styleObj.fontFamily + if (typeof value !== 'string') return + const stripped = value.replace(/["']/g, '').trim() + if (!stripped) return + const values = parseValues(stripped, ',') + styleObj.fontFamily = values[0].trim() +} + +function transformBackground (styleObj: Record) { + if (typeof styleObj.backgroundSize === 'string') { + styleObj.backgroundSize = parseValues(styleObj.backgroundSize) + } + if (typeof styleObj.backgroundPosition === 'string') { + const parts = parseValues(styleObj.backgroundPosition) + styleObj.backgroundPosition = parts.map(v => v === 'center' ? '50%' : v) + } + const value = styleObj.background + if (typeof value !== 'string') return + delete styleObj.background + if (value === 'none') { + styleObj.backgroundImage = 'none' + styleObj.backgroundColor = 'transparent' + return + } + const tokens = parseValues(value) + const positionValues: string[] = [] + const sizeValues: string[] = [] + let isSize = false + for (const token of tokens) { + if (urlRegExp.test(token)) { + styleObj.backgroundImage = token + } else if (linearGradientRegExp.test(token)) { + styleObj.backgroundImage = token + } else if (hasOwn(bgRepeatMap, token)) { + styleObj.backgroundRepeat = token + } else if (isColorValue(token)) { + styleObj.backgroundColor = token + } else if (token === '/') { + isSize = true + } else { + const slashParts = parseValues(token, '/') + if (slashParts.length > 1) { + const posPart = slashParts[0] + if (posPart) positionValues.push(posPart === 'center' ? '50%' : posPart) + isSize = true + for (let i = 1; i < slashParts.length; i++) { + if (slashParts[i]) sizeValues.push(slashParts[i]) + } + } else if (isSize) { + sizeValues.push(token) + } else if (hasOwn(bgPositionMap, token) || percentRegExp.test(token) || digitStartRegExp.test(token)) { + positionValues.push(token === 'center' ? '50%' : token) + } + } + } + if (positionValues.length) styleObj.backgroundPosition = positionValues + if (sizeValues.length) styleObj.backgroundSize = sizeValues +} + +// ============================================================ +// style traversal +// ============================================================ + +export function traverseStyle (styleObj: Record, visitors: Array<(arg: VisitorArg) => void>) { + const keyPath: Array = [] + function traverse> (target: T) { + if (Array.isArray(target)) { + target.forEach((value, index) => { + const key = String(index) + keyPath.push(key) + visitors.forEach(visitor => visitor({ target, key, value, keyPath })) + traverse(value) + keyPath.pop() + }) + } else if (isObject(target)) { + Object.entries(target).forEach(([key, value]) => { + keyPath.push(key) + visitors.forEach(visitor => visitor({ target, key, value, keyPath })) + traverse(value) + keyPath.pop() + }) + } + } + traverse(styleObj) } +export function setStyle (styleObj: Record, keyPath: Array, setter: (arg: VisitorArg) => void) { + let target = styleObj + const lastKey = keyPath[keyPath.length - 1] + for (let i = 0; i < keyPath.length - 1; i++) { + target = target[keyPath[i]] + if (!target) return + } + setter({ + target, + key: lastKey, + value: target[lastKey], + keyPath + }) +} + +// ============================================================ +// core style hook +// ============================================================ + export function useTransformStyle (styleObj: Record = {}, { enableVar, transformRadiusPercent, externalVarContext, parentFontSize, parentWidth, parentHeight }: TransformStyleConfig) { const varStyle: Record = {} const unoVarStyle: Record = {} @@ -466,6 +959,7 @@ export function useTransformStyle (styleObj: Record = {}, { enableV const percentKeyPaths: Array> = [] const calcKeyPaths: Array> = [] const envKeyPaths: Array> = [] + const shorthandKeys: string[] = [] const [width, setWidth] = useState(0) const [height, setHeight] = useState(0) const navigation = useNavigation() @@ -517,14 +1011,20 @@ export function useTransformStyle (styleObj: Record = {}, { enableV function percentVisitor ({ key, value, keyPath }: VisitorArg) { // fixme 去掉 translate & border-radius 的百分比计算 // fixme Image 组件 borderRadius 仅支持 number - if (transformRadiusPercent && hasOwn(radiusPercentRule, key) && PERCENT_REGEX.test(value)) { + if (transformRadiusPercent && hasOwn(radiusPercentRule, key) && percentRegExp.test(value)) { hasSelfPercent = true percentKeyPaths.push(keyPath.slice()) - } else if ((key === 'fontSize' || key === 'lineHeight') && PERCENT_REGEX.test(value)) { + } else if ((key === 'fontSize' || key === 'lineHeight') && percentRegExp.test(value)) { percentKeyPaths.push(keyPath.slice()) } } + function shorthandVisitor ({ key, keyPath }: VisitorArg) { + if (keyPath.length === 1 && hasOwn(runtimeAbbreviationMap, key)) { + shorthandKeys.push(key) + } + } + function visitOther ({ target, key, value, keyPath }: VisitorArg) { if (typeof value === 'string' && (value.includes('%') || value.includes('calc(') || value.includes('env('))) { [envVisitor, percentVisitor, calcVisitor].forEach(visitor => visitor({ target, key, value, keyPath })) @@ -532,7 +1032,7 @@ export function useTransformStyle (styleObj: Record = {}, { enableV } // traverse var & generate normalStyle - traverseStyle(styleObj, [varVisitor, boxSizingVisitor]) + traverseStyle(styleObj, [varVisitor, boxSizingVisitor, shorthandVisitor]) hasVarDec = hasVarDec || !!externalVarContext enableVar = enableVar || hasVarDec || hasVarUse const enableVarRef = useRef(enableVar) @@ -576,7 +1076,7 @@ export function useTransformStyle (styleObj: Record = {}, { enableV transformPercent(normalStyle, percentKeyPaths, percentConfig) // apply calc transformCalc(normalStyle, calcKeyPaths, (value: string, key: string) => { - if (PERCENT_REGEX.test(value)) { + if (percentRegExp.test(value)) { if (hasOwn(selfPercentRule, key)) { hasSelfPercent = true } @@ -597,11 +1097,17 @@ export function useTransformStyle (styleObj: Record = {}, { enableV transformPosition(normalStyle, positionMeta) // transform number enum stringify transformStringify(normalStyle) - // transform rpx to px + // transform unit transformBoxShadow(normalStyle) - // transform 字符串格式转化数组格式(先转数组再处理css var) + // transform 字符串格式转化数组格式 transformTransform(normalStyle) transformBoxSizing(normalStyle, hasBoxSizingAffectingStyle) + // apply runtime style processing alignment + transformLineHeight(normalStyle) + transformFontFamily(normalStyle) + transformFlex(normalStyle) + transformShorthand(normalStyle, shorthandKeys) + transformBackground(normalStyle) return { hasVarDec, @@ -614,79 +1120,41 @@ export function useTransformStyle (styleObj: Record = {}, { enableV } } -export interface VisitorArg { - target: Record - key: string - value: any - keyPath: Array -} +// ============================================================ +// other React hooks +// ============================================================ -export function traverseStyle (styleObj: Record, visitors: Array<(arg: VisitorArg) => void>) { - const keyPath: Array = [] - function traverse> (target: T) { - if (Array.isArray(target)) { - target.forEach((value, index) => { - const key = String(index) - keyPath.push(key) - visitors.forEach(visitor => visitor({ target, key, value, keyPath })) - traverse(value) - keyPath.pop() - }) - } else if (isObject(target)) { - Object.entries(target).forEach(([key, value]) => { - keyPath.push(key) - visitors.forEach(visitor => visitor({ target, key, value, keyPath })) - traverse(value) - keyPath.pop() - }) - } - } - traverse(styleObj) +export function useNavigation (): Record | undefined { + const { navigation } = useContext(RouteContext) || {} + return navigation } -export function setStyle (styleObj: Record, keyPath: Array, setter: (arg: VisitorArg) => void) { - let target = styleObj - const lastKey = keyPath[keyPath.length - 1] - for (let i = 0; i < keyPath.length - 1; i++) { - target = target[keyPath[i]] - if (!target) return - } - setter({ - target, - key: lastKey, - value: target[lastKey], - keyPath - }) -} +/** + * 用法等同于 useEffect,但是会忽略首次执行,只在依赖更新时执行 + */ +export const useUpdateEffect = (effect: any, deps: any) => { + const isMounted = useRef(false) -export function splitProps> (props: T): { - textProps?: Partial - innerProps?: Partial -} { - return groupBy(props, (key) => { - if (hasOwn(TEXT_PROPS_MAP, key)) { - return 'textProps' + // for react-refresh + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) + + useEffect(() => { + if (!isMounted.current) { + isMounted.current = true } else { - return 'innerProps' + return effect() } - }) as { - textProps: Partial - innerProps: Partial - } + }, deps) } -interface LayoutConfig { - props: Record - hasSelfPercent: boolean - setWidth?: Dispatch> - setHeight?: Dispatch> - onLayout?: (event?: LayoutChangeEvent) => void - nodeRef: React.RefObject -} export const useLayout = ({ props, hasSelfPercent, setWidth, setHeight, onLayout, nodeRef }: LayoutConfig) => { const layoutRef = useRef({}) const hasLayoutRef = useRef(false) - const layoutStyle = useMemo(() => { return !hasLayoutRef.current && hasSelfPercent ? HIDDEN_STYLE : {} }, [hasLayoutRef.current]) + const layoutStyle = useMemo(() => { return !hasLayoutRef.current && hasSelfPercent ? hiddenStyle : {} }, [hasLayoutRef.current]) const layoutProps: Record = {} const navigation = useNavigation() const enableOffset = props['enable-offset'] @@ -715,17 +1183,6 @@ export const useLayout = ({ props, hasSelfPercent, setWidth, setHeight, onLayout } } -export interface WrapChildrenConfig { - hasVarDec: boolean - varContext?: Record - textPassThrough?: TextPassThroughContextValue | null -} - -export interface TextPassThroughValueOptions { - inheritTextProps?: boolean - disabled?: boolean -} - export function useTextPassThroughValue ( textStyle?: TextStyle, textProps?: Record, @@ -758,104 +1215,6 @@ export function useTextPassThroughValue ( return valueRef.current } -export function wrapChildren (props: Record = {}, { hasVarDec, varContext, textPassThrough }: WrapChildrenConfig) { - let { children } = props - if (textPassThrough) { - children = {children} - } - if (hasVarDec && varContext) { - children = {children} - } - return children -} - -export const debounce = ( - func: T, - delay: number -): ((...args: Parameters) => void) & { clear: () => void } => { - let timer: any - const wrapper = (...args: ReadonlyArray) => { - timer && clearTimeout(timer) - timer = setTimeout(() => { - func(...args) - }, delay) - } - wrapper.clear = () => { - timer && clearTimeout(timer) - timer = null - } - return wrapper -} - -export const useDebounceCallback = ( - func: T, - delay: number -): ((...args: Parameters) => void) & { clear: () => void } => { - const debounced = useMemo(() => debounce(func, delay), [func]) - return debounced -} - -export const useStableCallback = ( - callback: T -): T extends AnyFunc ? T : () => void => { - const ref = useRef(callback) - ref.current = callback - return useCallback( - (...args: any[]) => ref.current?.(...args), - [] - ) -} - -export function usePrevious (value: T): T | undefined { - const ref = useRef() - const prev = ref.current - ref.current = value - return prev -} - -export interface GestureHandler { - nodeRefs?: Array<{ getNodeInstance: () => { nodeRef: unknown } }> - current?: unknown -} - -export function flatGesture (gestures: Array = []) { - return (gestures && gestures.flatMap((gesture: GestureHandler) => { - if (gesture && gesture.nodeRefs) { - return gesture.nodeRefs - .map((item: { getNodeInstance: () => any }) => item.getNodeInstance()?.instance?.gestureRef || {}) - } - return gesture?.current ? [gesture] : [] - })) || [] -} - -export function getCurrentPage (pageId: number | null | undefined) { - if (!global.getCurrentPages) return - const pages = global.getCurrentPages() - return pages.find((page: any) => isFunction(page.getPageId) && page.getPageId() === pageId) -} - -export function renderImage ( - imageProps: ImageProps, - enableFastImage = true -) { - let Component = Image - if (enableFastImage) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fastImageModule = require('@d11/react-native-fast-image') - Component = fastImageModule.default || fastImageModule - } - return createElement(Component, imageProps) -} - -export function pickStyle (styleObj: Record = {}, pickedKeys: Array, callback?: (key: string, val: number | string) => number | string) { - return pickedKeys.reduce>((acc, key) => { - if (key in styleObj) { - acc[key] = callback ? callback(key, styleObj[key]) : styleObj[key] - } - return acc - }, {}) -} - export function useHover ({ enableHover, hoverStartTime, hoverStayTime, disabled }: { enableHover: boolean, hoverStartTime: number, hoverStayTime: number, disabled?: boolean }) { const enableHoverRef = useRef(enableHover) if (enableHoverRef.current !== enableHover) { @@ -933,3 +1292,73 @@ export function useRunOnJSCallback (callbackMapRef: MutableRefObject ( + func: T, + delay: number +): ((...args: Parameters) => void) & { clear: () => void } => { + const debounced = useMemo(() => debounce(func, delay), [func]) + return debounced +} + +export const useStableCallback = ( + callback: T +): T extends AnyFunc ? T : () => void => { + const ref = useRef(callback) + ref.current = callback + return useCallback( + (...args: any[]) => ref.current?.(...args), + [] + ) +} + +export function usePrevious (value: T): T | undefined { + const ref = useRef() + const prev = ref.current + ref.current = value + return prev +} + +// ============================================================ +// component helpers +// ============================================================ + +export function wrapChildren (props: Record = {}, { hasVarDec, varContext, textPassThrough }: WrapChildrenConfig) { + let { children } = props + if (textPassThrough) { + children = {children} + } + if (hasVarDec && varContext) { + children = {children} + } + return children +} + +export function renderImage ( + imageProps: ImageProps, + enableFastImage = true +) { + let Component = Image + if (enableFastImage) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fastImageModule = require('@d11/react-native-fast-image') + Component = fastImageModule.default || fastImageModule + } + return createElement(Component, imageProps) +} + +export function flatGesture (gestures: Array = []) { + return (gestures && gestures.flatMap((gesture: GestureHandler) => { + if (gesture && gesture.nodeRefs) { + return gesture.nodeRefs + .map((item: { getNodeInstance: () => any }) => item.getNodeInstance()?.instance?.gestureRef || {}) + } + return gesture?.current ? [gesture] : [] + })) || [] +} + +export function getCurrentPage (pageId: number | null | undefined) { + if (!global.getCurrentPages) return + const pages = global.getCurrentPages() + return pages.find((page: any) => isFunction(page.getPageId) && page.getPageId() === pageId) +} diff --git a/solutions/rn-runtime-shorthand-style.md b/solutions/rn-runtime-shorthand-style.md index d2a71664e4..a3e4f0fd79 100644 --- a/solutions/rn-runtime-shorthand-style.md +++ b/solutions/rn-runtime-shorthand-style.md @@ -1,82 +1,50 @@ -# Mpx2RN useTransformStyle 运行时简写属性展开方案 +# Mpx2RN useTransformStyle 运行时样式处理能力拉齐方案 ## 背景 -Mpx2RN 当前对 CSS 简写属性的支持主要分为两类: +Mpx2RN 当前对 CSS 样式处理的支持分为两类: -1. `