Taro 项目中使用 Teleport 和 Portal
背景
传送门 的作用是将组件渲染到 DOM 树的任意位置,从而摆脱当前组件树的层次结构。常用于制作弹窗、弹出层等,通常 UI 框架 已经帮我们做了这部分工作( 比如渲染到 body
下 ),所以项目中很少用到。
-
<Teleport>
是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。 -
portal 允许组件将它们的某些子元素渲染到 DOM 中的不同位置。这使得组件的一部分可以“逃脱”它所在的容器。例如组件可以在页面其余部分上方或外部显示模态对话框和提示框。
portal 只改变 DOM 节点的所处位置。在其他方面,portal 中的 JSX 将作为实际渲染它的 React 组件的子节点。该子节点可以访问由父节点树提供的 context 对象、事件将仍然从子节点冒泡到父节点树。
等效代码:
const node = document.createElement('div');
node.setAttribute('style', 'position: fixed;z-index: 1000;background: rgba(0, 0, 0, 0.45);width: 100vw;height: 100vh;left: 0;top: 0;');
document.body.appendChild(node); // 插入到 body 最后面
document.body.insertBefore(node, document.body.firstChild); // 插入到 body 最前面
Taro 在文档中是这么描述的:
-
由于不能在页面组件的 DOM 树之外插入元素,因此不支持应用级别的
<teleport>
。但你仍可以在当前页面内使用<teleport>
。示例项目: taro-vue-teleport
-
React
createPortal
支持将组件渲染至特定的 dom 节点中,由于不能在页面组件的 DOM 树之外插入元素,无法实现应用级别的<Portal>
组件。但你仍可以在当前页面中使用createPortal
。示例项目: taro-react-portal
跑了文档中的示例项目之后发现 Teleport / Portal 的基本功能都是支持的,可以满足将组件渲染到当前页面中的某个节点中。
不明白 跨页面的全局组件 的意义是什么( 难道是浮窗按钮? ),毕竟一个屏幕下只能同时显示一个页面的内容,将 A 页面中某个组件渲染到 B 页面中也看不见,意义不大。如果真有这样的需求,我觉得 页面级全局组件 再配合 状态管理工具( Redux 、 Pinia 等 )也能实现跨页面后台展示的效果。
需要用到 Teleport / Portal 的场景
一般我们会使用 position: fixed
来实现悬浮在某个位置的效果,不使用 Teleport / Portal 也能用,但是组件多了之后 z-index
的层级问题就不好控制了。
- 首先是遵循 DOM 的规则,同级的后面居上。
- 一般有定位属性的元素会高于无定位属性的同级元素。
- 都有定位属性的同级元素,
z-index
大者居上。- 如果是非同级的元素,则会忽略元素本身
z-index
,取与对比元素同级的祖先元素的z-index
属性,大者居上。
层级问题还是其次,更关键的是 fixed
在一些场景下会失效降级为 absolute
:
当元素祖先的
transform
、perspective
、filter
或backdrop-filter
属性非none
时,容器由视口改为该祖先。
一个列表左滑删除的例子:左滑显示删除按钮,点击删除显示确认删除的弹窗。
滑动组件 带有 transform
样式导致弹窗组件的 fixed
失效,为了修复这个问题只能将弹窗组件写在滑动组件外部,这时封装 ListItem
组件会非常麻烦,要通过事件向上传递和弹窗组件进行通讯。
项目中这样的场景不在少数,如果组件树中某个中间节点增加了 transform
样式就需要重新梳理组件结构了。
如果能将 fixed
组件直接渲染到外部的话,就完全不需要考虑这方面问题了。
整合思路与遇到的问题
封装传送门组件
主要是对内置的 Teleport / Portal 组件做了一层简单封装,因为 Taro 是跨平台框架,各端实现有所差异,所以需要在这一层做兼容处理。
组件提供 enable
、 target
和 root
三个属性,其中 enable
用于控制是否从页面中脱离出来,剩下的属性用于控制渲染逻辑:
-
指定了
target
且值非空时,渲染到指定的节点上,可以是一个 DOM 元素对象或者其 id 。[!NOTE]
Vue 中不能用 class 选择器,因为
querySelector
是用getElementById
模拟的 ,只支持 id 。 -
当
root
值为'first'
时,渲染到页面根节点的第一个子节点。 -
当
root
值为true
时,渲染到页面根节点。 -
当外层用传送门组件的
Provider
包裹时,渲染到Provider
中提供的节点上。 -
缺省渲染到页面根节点。
[!NOTE]
无法应用 UI 框架 中
CSS variables
方式的主题配置。
封装 UI 框架的弹窗组件
本文中使用的 UI 框架 是 NutUI ,正好 Vue 和 React 两个版本都支持。包装一下 Popup
组件使其默认就渲染到页面根节点的第一个子节点上,这样使用的时候就会省事很多。
获取用于渲染的节点
-
使用
ref
语法来获取节点。由于 不同平台不同框架 ref 获取到的节点类型不同 ,这种方式的可靠性还有待验证。
-
使用
document.getElementById
DOM API 来获取节点。这种方式的限制就是需要保证组件 id 全局( 所有页面 )唯一( 参考 ):
-
H5 端 多页应用每个页面是用
div
模拟的,如果 id 不唯一就会获取到其他页面上的节点,导致失效。文档中的 ID 必须是唯一的。如果一个文档中有两个及以上的元素具有相同的 ID ,那么该方法只会返回查找到的第一个元素。
-
小程序端
getElementById
是通过全局的eventSource
实现的。组件卸载的时候会调用
eventSource.removeNodeTree
将组件对应的 id 从eventSource
中移除( 参考 ),这就导致一个问题: 如果两个页面中都存在 id 为teleportId
的组件,切换到下一页再后退回来,就会发现当前页面无法通过这个 id 获取到组件了 。Taro 文档中提供的示例项目 taro-vue-teleport 就有这个问题,其中
teleport
的v-if
和showModal
绑定了,也就是说每次关闭弹窗再打开弹窗会创建新的teleport
组件,导致每次都会重新调用一遍resolveTarget
,再结合重复 id 的问题就会得到 下面的错误 :[Vue warn]: Failed to locate Teleport target with selector "#teleportId". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.
应该避免
teleport
的重复卸载创建,卸载teleport
还可能会导致 slot 中的一些事件无法触发。比如下面这个例子中,打开弹窗后点击遮罩没法关闭的,只能点击自定义的关闭按钮才行。<template> <view id="teleportId"> <nut-button @click="show = true">open</nut-button> <teleport v-if="show" to="#teleportId"> <nut-popup v-model:visible="show"> <nut-button @click="show = false">close</nut-button> </nut-popup> </teleport> </view> </template> <script setup lang="ts"> import { ref } from 'vue'; const show = ref(false); </script>
当然,不绑定
v-if
还是会报错:[Vue warn]: Invalid Teleport target on mount: null (object)
因为首次渲染完成前无法获取到 DOM 元素对象,需要延迟渲染
teleport
:<template> <view id="teleportId"> <nut-button @click="show = true">open</nut-button> <teleport v-if="showTeleport" to="#teleportId"> <nut-popup v-model:visible="show"> <nut-button @click="show = false">close</nut-button> </nut-popup> </teleport> </view> </template> <script setup lang="ts"> import { onMounted, ref } from 'vue'; const show = ref(false); const showTeleport = ref(false); onMounted(() => { showTeleport.value = true; }); </script>
说起保证 id 唯一的方法,我看一些项目中用到 随机数 来作为 id ,但这种方式还是无法完全避免重复,其实 Taro 中已经提供 自增 id 的算法,直接拿来用就好了,具体参考下面代码中的
nextTeleportId
。 -
获取页面根节点
由于 H5 端 多页应用每个页面是用 div
模拟的,如果直接渲染到 body
或者 #app
( 小程序中没有的 )上,不同页面中的组件放在一起,样式效果容易打架。 每个页面的组件应该只渲染在当前页面所属的 div
下面,不要越界。
Taro 内部实现了一层 Page 组件作为页面的根节点,我们在项目代码中没法直接对它进行修改。所幸 Page 组件都是有 id 的,也就是 当前页面的路由路径 ( 参考 ),有了 id 就能拿到页面根节点并渲染到上面,开箱即用也省得要自己手动埋点了。
不过这个 id 直接用到 teleport
中是会报错的:
Uncaught (in promise) DOMException: Failed to execute 'querySelector' on 'Document': '#/pages/index/index?stamp=AA' is not a valid selector.
因为 teleport
内部用到了 document.querySelector
,而 H5 端 querySelector
的参数不能包含一些特殊字符。然而同样的 id 使用 getElementById
是不会报错的。
模拟报错效果:
const id = '/pages/index/index?stamp=AA';
document.getElementById(id);
document.querySelector(`#${id}`);
解决办法:使用 CSS.escape
进行转义( 参考 )
document.querySelector(`#${CSS.escape(id)}`);
在 Vue 中使用 Teleport
演示效果 - Vue
biz-teleport.vue
<template>
<teleport v-if="!enable || show" :disabled :to="computedTarget">
<slot />
</teleport>
</template>
<script setup lang="ts">
import type { TaroElement } from '@tarojs/runtime';
import { isString } from '@tarojs/shared';
import { computed, inject, onMounted, ref, toRaw, toValue, type MaybeRef } from 'vue';
import { isWeb, TELEPORT_TARGET_KEY } from './constants';
import { useTaroPageRootElement } from './hooks';
const props = withDefaults(defineProps<Props>(), {
enable: true,
target: undefined,
root: undefined,
});
/**
* https://vuejs.org/guide/built-ins/teleport.html
*/
interface Props {
/**
* 是否从页面中脱离出来
*/
enable?: boolean;
/**
* 传送的目标:可以是一个 DOM 元素对象或者其 id
*
* teleport 中用 class 选择器在小程序中会报错,因为 `querySelector` 是用 `getElementById` 模拟的
*
* ref: https://github.com/NervJS/taro/commit/2db9bdf289dab4e3c514c1ca151d4d5997a62260#diff-d7ae218b39f54c0aed1ec3bd9d0a3e57347bf7df7583e0e354ba6d9630433acaR36-R43
*
* 组件 id 需要全局(所有页面)唯一,否则会失效
*
* ref: https://github.com/NervJS/taro/issues/7317#issuecomment-722169193
*/
target?: string | TaroElement | null;
/**
* 优先级小于 `target`
*
* `true` - 渲染到页面根节点
* `'first'` - 渲染到页面根节点的第一个子节点,用于适配 `ConfigProvider` 全局配置
*/
root?: boolean | 'first';
}
const show = ref(false);
onMounted(() => {
// 卸载 teleport 会导致 slot 中的一些事件无法触发
// 首次渲染完成前无法获取 dom 所以需要延迟显示 teleport ref: https://docs.taro.zone/docs/ref
show.value = true;
});
const pageNode = useTaroPageRootElement();
const provideTarget = inject<MaybeRef<TaroElement> | null>(TELEPORT_TARGET_KEY, null);
function parseTarget(to?: MaybeRef<TaroElement> | string) {
if (!isString(to)) {
// 不同平台 ref 获取到的节点类型不同 ref: https://docs.taro.zone/docs/ref#ref-%E8%AF%AD%E6%B3%95
return toRaw(toValue(to));
}
// use `CSS.escape` to escape the selector
// ref: https://github.com/bootstrap-vue/bootstrap-vue/issues/5561
// ref: https://github.com/facebook/react/issues/28404#issuecomment-1958470536
return to ? `#${isWeb ? CSS.escape(to) : to}` : undefined;
}
const computedTarget = computed(() => {
const { target, root } = props;
return parseTarget(
target ||
(root
? root === 'first'
? pageNode.value?.firstChild
: pageNode
: provideTarget ?? pageNode),
);
});
const disabled = computed(() => !(props.enable && computedTarget.value));
</script>
constants.ts
import { incrementId } from '@tarojs/runtime';
export const TELEPORT_TARGET_KEY = Symbol('teleport-target');
export const nodeId = incrementId();
export const nextTeleportId = () => `teleport-${nodeId()}`;
export const isWeb = process.env.TARO_ENV === 'h5';
hooks.ts
import type { TaroRootElement } from '@tarojs/runtime';
import type { Router } from '@tarojs/runtime/dist/current';
import { nextTick, useRouter } from '@tarojs/taro';
import { inject, ref } from 'vue';
/**
* 注入页面根节点 id
*
* ref: https://github.com/NervJS/taro/blob/v3.6.25/packages/taro-plugin-vue3/src/runtime/connect.ts#L88
*/
export function injectTaroPageId() {
return inject('id') as string;
}
/**
* 获取页面根节点 id
*
* ref: https://github.com/NervJS/taro/blob/v3.6.25/packages/taro-runtime/src/next-tick.ts#L21
*/
export function useTaroPageId() {
const router = useRouter();
return (router as unknown as Router).$taroPath;
}
/**
* 获取页面根节点
*/
export function useTaroPageRootElement() {
const pageId = useTaroPageId();
const dom = ref<TaroRootElement | null>();
nextTick(() => {
dom.value = document.getElementById(pageId) as TaroRootElement | null;
});
return dom;
}
[!NOTE]
其中
injectTaroPageId
目前还用不上,如果项目中只是为了获取页面组件的 id ,用这个注入的方式更好。
biz-popup.vue
<template>
<biz-teleport :root :target="teleport">
<nut-popup v-bind="$attrs">
<slot />
</nut-popup>
</biz-teleport>
</template>
<script setup lang="ts">
import type { TaroElement } from '@tarojs/runtime';
import type { ExtractPropTypes } from 'vue';
import { popupProps } from '@nutui/nutui-taro/dist/types/__VUE/popup/props';
import BizTeleport from './biz-teleport';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<Props>(), {
teleport: undefined,
root: 'first',
});
type PopupProps = Partial<ExtractPropTypes<typeof popupProps>>;
/**
* 只需要类型提示,加 `@vue-ignore` 可以避免运行时注册为属性,直接透传
*/
interface Props extends /* @vue-ignore */ PopupProps {
/**
* 传送的目标:可以是一个 DOM 元素对象或者其 id
*/
teleport?: string | TaroElement | null;
/**
* 优先级小于 `target`
*
* `true` - 渲染到页面根节点
* `'first'` - 渲染到页面根节点的第一个子节点,用于适配 `ConfigProvider` 全局配置
*/
root?: boolean | 'first';
}
</script>
用法示例
- index.vue
<template>
<nut-config-provider :theme-vars>
<demo v-slot="{ show }" title="不使用 Teleport">
<nut-popup v-model:visible="show.value">
<content>初始</content>
</nut-popup>
</demo>
<demo v-slot="{ show }" title="渲染到页面根节点">
<biz-teleport>
<nut-popup v-model:visible="show.value">
<content>页面根节点</content>
</nut-popup>
</biz-teleport>
</demo>
<demo v-slot="{ show }" title="渲染到页面根节点的第一个子节点">
<biz-popup v-model:visible="show.value">
<content>第一子节点</content>
</biz-popup>
</demo>
</nut-config-provider>
</template>
<script setup lang="tsx">
import { View } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { defineComponent, ref, type SetupContext } from 'vue';
import BizPopup from './biz-popup';
import BizTeleport from './biz-teleport';
const Demo = defineComponent(
({ title }, { slots }: SetupContext) => {
const show = ref(false);
return () => (
<View class="transform-container">
<nut-cell is-link title={title} onClick={() => (show.value = true)} />
{slots.default({ show })}
</View>
);
},
{
props: ['title'],
},
);
const Content = (_, { slots }: SetupContext) => (
<nut-button type="primary" onClick={navigate} style={{ margin: '30px' }}>
{slots.default()}
</nut-button>
);
const themeVars = ref({
primaryColor: '#a681fd',
});
const router = useRouter();
async function navigate() {
await Taro.navigateTo({ url: router.path.split('?')[0] });
}
</script>
<style lang="scss">
.transform-container {
transform: scale(1);
}
.nut-popup {
max-height: unset;
}
</style>
其中
:theme-vars
是Vue@3.4
新增的 同名简写 语法。
对比了使用 Teleport
前后的效果,使用 biz-popup
更简单。
默认渲染到页面根节点( 或者其第一个子节点 ),要实现渲染到自定义节点需要进一步改造。
biz-teleport-provider.vue
<template>
<slot />
<view :id="teleportId" />
</template>
<script setup lang="ts">
import { provide } from 'vue';
import { nextTeleportId, TELEPORT_TARGET_KEY } from './constants';
defineOptions({ inheritAttrs: false });
const teleportId = nextTeleportId();
provide(TELEPORT_TARGET_KEY, teleportId);
</script>
提供一个用于渲染的节点,并将其 id 通过 依赖注入 的方式传递给子组件。这样在子组件中使用 biz-teleport
就能自动渲染到这个节点上。
- 用法
<template>
<biz-teleport-provider>
<demo v-slot="{ show }" title="使用 Provider">
<biz-teleport>
<nut-popup v-model:visible="show.value">
<content>Provider</content>
</nut-popup>
</biz-teleport>
</demo>
</biz-teleport-provider>
</template>
<script setup lang="tsx">
// ...
import BizTeleportProvider from './biz-teleport-provider';
</script>
当前也可以使用 ref
获取节点,然后传递给 biz-teleport
:
<template>
<demo v-slot="{ show }" title="使用 ref">
<biz-teleport :target="targetRef">
<nut-popup v-model:visible="show.value">
<content>Ref</content>
</nut-popup>
</biz-teleport>
</demo>
<div v-if="isWeb" ref="targetRef" class="teleport-target" />
<view v-else ref="targetRef" class="teleport-target" />
</template>
<script setup lang="tsx">
// ...
import { isWeb } from './constants';
const targetRef = ref();
</script>
注意使用 ref
的方式,在 H5 端需要使用 div
而不能用 Taro 内置的 view
,否则会报错:
Uncaught (in promise) TypeError: parent.insertBefore is not a function
在 React 中没这个问题。
完整代码
👉 commit anyesu/taro-demo@f4511d4
在 React 中使用 Portal
其中 createPortal
是从 @tarojs/react
包导入的,对比 react-dom
中的实现,主要的区别是少了 校验 并对 Symbol.for
做了兼容处理。
@tarojs/react
是小程序专用的 ,由于 过于精简 ,用在 H5 端 反而会引起一些错误。并且 @tarojs/plugin-framework-react
插件针对 小程序端 专门做了一层 alias
,将 react-dom
导入映射为 @tarojs/react
,所以在项目中直接统一使用 react-dom
就好了。
演示效果 - React微信小程序也提供了
root-portal
组件,原生支持了Portal
的能力。 👉 Taro 文档
biz-portal.tsx
和 Vue 中的实现比做了一点简化,其中
target
属性不支持传 id 字符串,因为加了之后逻辑会复杂很多。可以在外部根据 id 获取到对应的DOM 元素对象
后再传入,具体参考下文的用法示例。
Provider
中ref
的值要用useState
存而不能用useRef
。( 参考 )
import { View } from '@tarojs/components';
import type { TaroElement } from '@tarojs/runtime';
import { createContext, useCallback, useContext, useState, type PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { useTaroPage } from './hooks';
export type BizPortalTarget = TaroElement | null | undefined;
export interface BizPortalProps extends PropsWithChildren {
/**
* 是否从页面中脱离出来
*/
enable?: boolean;
/**
* 传送的目标:DOM 元素对象
*/
target?: BizPortalTarget;
/**
* 优先级小于 `target`
*
* `true` - 渲染到页面根节点
* `'first'` - 渲染到页面根节点的第一个子节点,用于适配 `ConfigProvider` 全局配置
*/
root?: boolean | 'first';
}
const BizPortalRefContext = createContext<BizPortalTarget>(null);
export const useBizPortalRef = () => useContext(BizPortalRefContext);
export function BizPortalProvider({ children }: PropsWithChildren) {
// ref: https://stackoverflow.com/a/67906087
const [dom, setDom] = useState<BizPortalTarget>();
const ref = useCallback((node: BizPortalTarget) => node && setDom(node), []);
return (
<BizPortalRefContext.Provider value={dom}>
{children}
<View ref={ref} className="teleport-target" />
</BizPortalRefContext.Provider>
);
}
/**
* ref: https://react.dev/reference/react-dom/createPortal
* ref: https://docs.taro.zone/docs/components/viewContainer/root-portal
* ref: https://github.com/NervJS/taro/issues/7282#issuecomment-1676778571
*/
export default function BizPortal(props: BizPortalProps) {
const { children, enable = true, target, root } = props;
const provideTarget = useBizPortalRef();
const pageNode = useTaroPage();
const targetNode =
target ||
(root ? (root === 'first' ? pageNode?.firstChild : pageNode) : provideTarget ?? pageNode);
return enable && targetNode ? createPortal(children, targetNode as any) : children;
}
hooks.ts
参照这个 例子 拆分成了三个 hook ,方便灵活使用。
import type { TaroElement } from '@tarojs/runtime';
import type { Router } from '@tarojs/runtime/dist/current';
import { useRouter } from '@tarojs/taro';
import { useLayoutEffect, useState } from 'react';
/**
* 获取页面根节点 id
*
* ref: https://github.com/NervJS/taro/blob/v3.6.25/packages/taro-runtime/src/next-tick.ts#L21
*/
export function useTaroPageId() {
const router = useRouter();
return (router as unknown as Router).$taroPath;
}
/**
* 根据 id 获取 DOM 元素对象
*/
export function useTaroElement(id?: string) {
const [dom, setDom] = useState<TaroElement | null>(null);
useLayoutEffect(() => {
if (!id) return;
const node = document.getElementById(id) as TaroElement | null;
setDom(node);
}, [id]);
return dom;
}
/**
* 获取页面根节点
*
* ref: https://github.com/NervJS/taro/issues/7282#issuecomment-1676778571
*/
export function useTaroPage() {
const pageId = useTaroPageId();
return useTaroElement(pageId);
}
biz-popup.tsx
import type { TaroElement } from '@tarojs/runtime';
import { Popup, type PopupProps } from '@nutui/nutui-react-taro';
import BizPortal from './biz-portal';
export interface BizPopupProps extends Partial<PopupProps> {
/**
* 传送的目标:DOM 元素对象
*
* 不覆盖 `PopupProps['portal']`
*/
teleport?: TaroElement | null;
/**
* 优先级小于 `target`
*
* `true` - 渲染到页面根节点
* `'first'` - 渲染到页面根节点的第一个子节点,用于适配 `ConfigProvider` 全局配置
*/
root?: boolean | 'first';
}
export default function BizPopup({ root = 'first', teleport, ...rest }: BizPopupProps) {
return (
<BizPortal root={root} target={teleport}>
<Popup {...rest} />
</BizPortal>
);
}
用法示例
- index.tsx
import { View } from '@tarojs/components';
import { incrementId } from '@tarojs/runtime';
import Taro, { useRouter } from '@tarojs/taro';
import { useRef, useState, type PropsWithChildren, type ReactNode } from 'react';
import { ArrowRight } from '@nutui/icons-react-taro';
import { Button, Cell, ConfigProvider, Popup } from '@nutui/nutui-react-taro';
import BizPopup from './biz-popup';
import BizPortal, { BizPortalProvider } from './biz-portal';
import { useTaroElement } from './hooks';
import './index.scss';
interface SlotProps {
show: boolean;
setShow: (show: boolean) => void;
}
interface DemoProps {
title?: ReactNode;
children?: (slotProps: SlotProps) => ReactNode;
}
const nodeId = incrementId(); // 自增 id
const nextTeleportId = () => `teleport-${nodeId()}`;
function Demo({ children, title }: DemoProps) {
const [show, setShow] = useState(false);
return (
<View className="transform-container">
<Cell title={title} extra={<ArrowRight />} onClick={() => setShow(true)} />
{children?.({ show, setShow })}
</View>
);
}
function Content({ children }: PropsWithChildren) {
const router = useRouter();
async function navigate() {
await Taro.navigateTo({ url: router.path.split('?')[0] });
}
return (
<Button type="primary" onClick={navigate} style={{ margin: '30px' }}>
{children}
</Button>
);
}
const primaryColor = '#a681fd';
const theme = {
nutuiColorPrimary: primaryColor,
nutuiColorPrimaryStop1: primaryColor,
nutuiColorPrimaryStop2: primaryColor,
};
export default function Page() {
const targetId = useRef(nextTeleportId());
const targetRef = useTaroElement(targetId.current);
return (
<ConfigProvider theme={theme}>
<Demo title="不使用 Portal">
{({ show, setShow }) => (
<Popup visible={show} onClose={() => setShow(false)}>
<Content>初始</Content>
</Popup>
)}
</Demo>
<Demo title="渲染到页面根节点">
{({ show, setShow }) => (
<BizPortal>
<Popup visible={show} onClose={() => setShow(false)}>
<Content>页面根节点</Content>
</Popup>
</BizPortal>
)}
</Demo>
<Demo title="渲染到页面根节点的第一个子节点">
{({ show, setShow }) => (
<BizPopup visible={show} onClose={() => setShow(false)}>
<Content>第一子节点</Content>
</BizPopup>
)}
</Demo>
<BizPortalProvider>
<Demo title="使用 Provider">
{({ show, setShow }) => (
<BizPortal>
<Popup visible={show} onClose={() => setShow(false)}>
<Content>Provider</Content>
</Popup>
</BizPortal>
)}
</Demo>
</BizPortalProvider>
<Demo title="使用 id">
{({ show, setShow }) => (
<BizPortal target={targetRef}>
<Popup visible={show} onClose={() => setShow(false)}>
<Content>targetId</Content>
</Popup>
</BizPortal>
)}
</Demo>
<View id={targetId.current} />
</ConfigProvider>
);
}
- index.scss
.transform-container {
transform: scale(1);
}
.nut-popup {
max-height: unset;
}
完整代码
👉 commit anyesu/taro-demo@47e4ce8
( 修正 )
其他相关问题
在 Vue 单文件组件( SFC ) 中使用 JSX
对应 Vue 版本的用法示例中的 Demo
组件。
只是单纯不想多创建文件,写法上繁琐很多,也缺少语法提示,平时不建议用。
需要将 <script>
标签上的 lang
属性设置为 jsx
或者 tsx
( 否则 prettier 会报错 ):
<script setup lang="tsx">
</script>
除了 Taro 内置组件 ( 比如 View
)需要 手动导入 外其他组件可以 自动按需引入 ,然后将事件绑定改为 onCamelcase 格式的属性写法,其他的组件名和属性名都可以写成 kebab-case 格式的。
- 为函数式组件标注类型
-
函数签名 (
Vue@3.3+
) - 在 Taro 中使用 JSX 编写 Vue3 组件
在 Vue 中扩展已有的组件
对应 Vue 版本的 biz-popup
组件。
其属性通过继承 nut-popup
的属性得到完整的类型提示,然后通过 /* @vue-ignore */
注释避免了 biz-popup
的 运行时声明 包含属于 nut-popup
的属性,这样就可以直接 透传 给 nut-popup
而无需做额外处理。
在 React 中使用 Vue 中的 作用域插槽 用法
对应 React 版本的用法示例中的 Demo
组件。( 参考 )
React Hooks 的执行顺序
一直以来只是拿 useEffect
来模拟 class 组件的生命周期 ( 生命周期图谱 ),没怎么了解过其他 Hook 的执行顺序,跑个 demo 测试下:
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
function useHooksTest(name: string) {
console.log(`${name}: render`);
const [init, setInit] = useState(false);
const ref = useCallback(() => console.log(`${name}: ref`), [name]);
useEffect(() => {
setInit(true);
}, []);
useEffect(() => {
console.log(`${name}: useEffect`);
return () => {
console.log(`${name}: useEffect cleanup`);
};
});
useLayoutEffect(() => {
console.log(`${name}: useLayoutEffect`);
return () => {
console.log(`${name}: useLayoutEffect cleanup`);
};
});
return [ref, init] as const;
}
function Child() {
const [ref, init] = useHooksTest('子组件');
return init && <div ref={ref} />;
}
function Parent() {
const [ref, init] = useHooksTest('父组件');
return (
<>
<Child />
{init && <div ref={ref} />}
</>
);
}
export default function Page() {
return <Parent />;
}
运行结果:
父组件: render
子组件: render
// 在此之前纯净且不包含副作用,之后可以使用 DOM,运行副作用,安排更新
子组件: useLayoutEffect
父组件: useLayoutEffect
子组件: useEffect
父组件: useEffect
父组件: render
子组件: render
子组件: useLayoutEffect cleanup
父组件: useLayoutEffect cleanup
子组件: ref
子组件: useLayoutEffect
父组件: ref
父组件: useLayoutEffect
子组件: useEffect cleanup
父组件: useEffect cleanup
子组件: useEffect
父组件: useEffect
微信开发者工具中 fixed
失效时页面闪烁的问题
微信开发者工具 升级到目前最新的 1.06.2402040
版本还是有问题。 真机测试没问题。
复现步骤:
- 打开一个
fixed
失效的弹窗 - 打开一个正常的弹窗并关闭
- 不断切换第一个失效的弹窗,可以发现界面在不断闪烁,闪烁的画面甚至可以看到上一个页面的内容( 页面穿透了 )。
解决办法:
初步排查是祖先元素同时设置了 overflow: hidden
和 border-radius
导致的,把 hidden
取消掉或者 border-radius
设置为 0
都能解决这个闪烁问题,猜测是 fixed
降级为 absolute
时圆角裁剪有问题。
演示效果:
演示效果 - 微信开发者工具闪烁问题源码
完整项目代码 👉 anyesu/taro-demo
-
获取源代码
$ git clone https://github.com/anyesu/taro-demo $ cd taro-demo
-
安装依赖
$ pnpm i
-
运行项目
# cd packages/taro-demo-react $ cd packages/taro-demo-vue3 $ pnpm dev:h5
-
浏览器访问: http://127.0.0.1:10086
结语
最初只是想写个 demo 简单记录下,结果拔出萝卜带出泥,越是深入了解坑踩得越多,不过也收获了很多,也是应证了学无止境那句话。