
背景
傳送門 的作用是將組件渲染到 DOM 樹的任意位置,從而擺脫當前組件樹的層次結構。常用于制作彈窗、彈出層等,通常 UI 框架 已經幫我們做了這部分工作( 比如渲染到 body 下 ),所以項目中很少用到。
-
<Teleport>是一個內置組件,它可以將一個組件內部的一部分模板“傳送”到該組件的 DOM 結構外層的位置去。 -
portal 允許組件將它們的某些子元素渲染到 DOM 中的不同位置。這使得組件的一部分可以“逃脫”它所在的容器。例如組件可以在頁面其余部分上方或外部顯示模態(tài)對話框和提示框。
portal 只改變 DOM 節(jié)點的所處位置。在其他方面,portal 中的 JSX 將作為實際渲染它的 React 組件的子節(jié)點。該子節(jié)點可以訪問由父節(jié)點樹提供的 context 對象、事件將仍然從子節(jié)點冒泡到父節(jié)點樹。
等效代碼:
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 節(jié)點中,由于不能在頁面組件的 DOM 樹之外插入元素,無法實現(xiàn)應用級別的<Portal>組件。但你仍可以在當前頁面中使用createPortal。示例項目: taro-react-portal
跑了文檔中的示例項目之后發(fā)現(xiàn) Teleport / Portal 的基本功能都是支持的,可以滿足將組件渲染到當前頁面中的某個節(jié)點中。
不明白 跨頁面的全局組件 的意義是什么( 難道是浮窗按鈕? ),畢竟一個屏幕下只能同時顯示一個頁面的內容,將 A 頁面中某個組件渲染到 B 頁面中也看不見,意義不大。如果真有這樣的需求,我覺得 頁面級全局組件 再配合 狀態(tài)管理工具( Redux 、 Pinia 等 )也能實現(xiàn)跨頁面后臺展示的效果。
需要用到 Teleport / Portal 的場景
一般我們會使用 position: fixed 來實現(xiàn)懸浮在某個位置的效果,不使用 Teleport / Portal 也能用,但是組件多了之后 z-index 的層級問題就不好控制了。
- 首先是遵循 DOM 的規(guī)則,同級的后面居上。
- 一般有定位屬性的元素會高于無定位屬性的同級元素。
- 都有定位屬性的同級元素,
z-index大者居上。- 如果是非同級的元素,則會忽略元素本身
z-index,取與對比元素同級的祖先元素的z-index屬性,大者居上。
層級問題還是其次,更關鍵的是 fixed 在一些場景下會失效降級為 absolute :
當元素祖先的
transform、perspective、filter或backdrop-filter屬性非none時,容器由視口改為該祖先。
一個列表左滑刪除的例子:左滑顯示刪除按鈕,點擊刪除顯示確認刪除的彈窗。
滑動組件 帶有 transform 樣式導致彈窗組件的 fixed 失效,為了修復這個問題只能將彈窗組件寫在滑動組件外部,這時封裝 ListItem 組件會非常麻煩,要通過事件向上傳遞和彈窗組件進行通訊。
項目中這樣的場景不在少數(shù),如果組件樹中某個中間節(jié)點增加了 transform 樣式就需要重新梳理組件結構了。
如果能將 fixed 組件直接渲染到外部的話,就完全不需要考慮這方面問題了。
整合思路與遇到的問題
封裝傳送門組件
主要是對內置的 Teleport / Portal 組件做了一層簡單封裝,因為 Taro 是跨平臺框架,各端實現(xiàn)有所差異,所以需要在這一層做兼容處理。
組件提供 enable 、 target 和 root 三個屬性,其中 enable 用于控制是否從頁面中脫離出來,剩下的屬性用于控制渲染邏輯:
-
指定了
target且值非空時,渲染到指定的節(jié)點上,可以是一個 DOM 元素對象或者其 id 。[!NOTE]
Vue 中不能用 class 選擇器,因為
querySelector是用getElementById模擬的 ,只支持 id 。 當
root值為'first'時,渲染到頁面根節(jié)點的第一個子節(jié)點。當
root值為true時,渲染到頁面根節(jié)點。當外層用傳送門組件的
Provider包裹時,渲染到Provider中提供的節(jié)點上。-
缺省渲染到頁面根節(jié)點。
[!NOTE]
無法應用 UI 框架 中
CSS variables方式的主題配置。
封裝 UI 框架的彈窗組件
本文中使用的 UI 框架 是 NutUI ,正好 Vue 和 React 兩個版本都支持。包裝一下 Popup 組件使其默認就渲染到頁面根節(jié)點的第一個子節(jié)點上,這樣使用的時候就會省事很多。
獲取用于渲染的節(jié)點
-
使用
ref語法來獲取節(jié)點。由于 不同平臺不同框架 ref 獲取到的節(jié)點類型不同 ,這種方式的可靠性還有待驗證。
-
使用
document.getElementByIdDOM API 來獲取節(jié)點。這種方式的限制就是需要保證組件 id 全局( 所有頁面 )唯一( 參考 ):
-
H5 端 多頁應用每個頁面是用
div模擬的,如果 id 不唯一就會獲取到其他頁面上的節(jié)點,導致失效。文檔中的 ID 必須是唯一的。如果一個文檔中有兩個及以上的元素具有相同的 ID ,那么該方法只會返回查找到的第一個元素。
-
小程序端
getElementById是通過全局的eventSource實現(xiàn)的。組件卸載的時候會調用
eventSource.removeNodeTree將組件對應的 id 從eventSource中移除( 參考 ),這就導致一個問題: 如果兩個頁面中都存在 id 為teleportId的組件,切換到下一頁再后退回來,就會發(fā)現(xiàn)當前頁面無法通過這個 id 獲取到組件了 。Taro 文檔中提供的示例項目 taro-vue-teleport 就有這個問題,其中
teleport的v-if和showModal綁定了,也就是說每次關閉彈窗再打開彈窗會創(chuàng)建新的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的重復卸載創(chuàng)建,卸載teleport還可能會導致 slot 中的一些事件無法觸發(fā)。比如下面這個例子中,打開彈窗后點擊遮罩沒法關閉的,只能點擊自定義的關閉按鈕才行。<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 唯一的方法,我看一些項目中用到 隨機數(shù) 來作為 id ,但這種方式還是無法完全避免重復,其實 Taro 中已經提供 自增 id 的算法,直接拿來用就好了,具體參考下面代碼中的
nextTeleportId。 -
獲取頁面根節(jié)點
由于 H5 端 多頁應用每個頁面是用 div 模擬的,如果直接渲染到 body 或者 #app ( 小程序中沒有的 )上,不同頁面中的組件放在一起,樣式效果容易打架。 每個頁面的組件應該只渲染在當前頁面所屬的 div 下面,不要越界。
Taro 內部實現(xiàn)了一層 Page 組件作為頁面的根節(jié)點,我們在項目代碼中沒法直接對它進行修改。所幸 Page 組件都是有 id 的,也就是 當前頁面的路由路徑 ( 參考 ),有了 id 就能拿到頁面根節(jié)點并渲染到上面,開箱即用也省得要自己手動埋點了。
不過這個 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 的參數(shù)不能包含一些特殊字符。然而同樣的 id 使用 getElementById 是不會報錯的。
模擬報錯效果:
const id = '/pages/index/index?stamp=AA';
document.getElementById(id);
document.querySelector(`#${id}`);
解決辦法:使用 CSS.escape 進行轉義( 參考 )
document.querySelector(`#${CSS.escape(id)}`);
在 Vue 中使用 Teleport

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;
/**
* 優(yōu)先級小于 `target`
*
* `true` - 渲染到頁面根節(jié)點
* `'first'` - 渲染到頁面根節(jié)點的第一個子節(jié)點,用于適配 `ConfigProvider` 全局配置
*/
root?: boolean | 'first';
}
const show = ref(false);
onMounted(() => {
// 卸載 teleport 會導致 slot 中的一些事件無法觸發(fā)
// 首次渲染完成前無法獲取 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 獲取到的節(jié)點類型不同 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';
/**
* 注入頁面根節(jié)點 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;
}
/**
* 獲取頁面根節(jié)點 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;
}
/**
* 獲取頁面根節(jié)點
*/
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;
/**
* 優(yōu)先級小于 `target`
*
* `true` - 渲染到頁面根節(jié)點
* `'first'` - 渲染到頁面根節(jié)點的第一個子節(jié)點,用于適配 `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="渲染到頁面根節(jié)點">
<biz-teleport>
<nut-popup v-model:visible="show.value">
<content>頁面根節(jié)點</content>
</nut-popup>
</biz-teleport>
</demo>
<demo v-slot="{ show }" title="渲染到頁面根節(jié)點的第一個子節(jié)點">
<biz-popup v-model:visible="show.value">
<content>第一子節(jié)點</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 更簡單。
默認渲染到頁面根節(jié)點( 或者其第一個子節(jié)點 ),要實現(xiàn)渲染到自定義節(jié)點需要進一步改造。
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>
提供一個用于渲染的節(jié)點,并將其 id 通過 依賴注入 的方式傳遞給子組件。這樣在子組件中使用 biz-teleport 就能自動渲染到這個節(jié)點上。
- 用法
<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 獲取節(jié)點,然后傳遞給 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 中的實現(xiàn),主要的區(qū)別是少了 校驗 并對 Symbol.for 做了兼容處理。
@tarojs/react 是小程序專用的 ,由于 過于精簡 ,用在 H5 端 反而會引起一些錯誤。并且 @tarojs/plugin-framework-react 插件針對 小程序端 專門做了一層 alias ,將 react-dom 導入映射為 @tarojs/react ,所以在項目中直接統(tǒng)一使用 react-dom 就好了。
微信小程序也提供了
root-portal組件,原生支持了Portal的能力。 ?? Taro 文檔

biz-portal.tsx
和 Vue 中的實現(xiàn)比做了一點簡化,其中
target屬性不支持傳 id 字符串,因為加了之后邏輯會復雜很多。可以在外部根據(jù) 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;
/**
* 優(yōu)先級小于 `target`
*
* `true` - 渲染到頁面根節(jié)點
* `'first'` - 渲染到頁面根節(jié)點的第一個子節(jié)點,用于適配 `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';
/**
* 獲取頁面根節(jié)點 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;
}
/**
* 根據(jù) 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;
}
/**
* 獲取頁面根節(jié)點
*
* 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;
/**
* 優(yōu)先級小于 `target`
*
* `true` - 渲染到頁面根節(jié)點
* `'first'` - 渲染到頁面根節(jié)點的第一個子節(jié)點,用于適配 `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="渲染到頁面根節(jié)點">
{({ show, setShow }) => (
<BizPortal>
<Popup visible={show} onClose={() => setShow(false)}>
<Content>頁面根節(jié)點</Content>
</Popup>
</BizPortal>
)}
</Demo>
<Demo title="渲染到頁面根節(jié)點的第一個子節(jié)點">
{({ show, setShow }) => (
<BizPopup visible={show} onClose={() => setShow(false)}>
<Content>第一子節(jié)點</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 組件。
只是單純不想多創(chuàng)建文件,寫法上繁瑣很多,也缺少語法提示,平時不建議用。
需要將 <script> 標簽上的 lang 屬性設置為 jsx 或者 tsx ( 否則 prettier 會報錯 ):
<script setup lang="tsx">
</script>
除了 Taro 內置組件 ( 比如 View )需要 手動導入 外其他組件可以 自動按需引入 ,然后將事件綁定改為 onCamelcase 格式的屬性寫法,其他的組件名和屬性名都可以寫成 kebab-case 格式的。
- 為函數(shù)式組件標注類型
-
函數(shù)簽名 (
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 的執(zhí)行順序
一直以來只是拿 useEffect 來模擬 class 組件的生命周期 ( 生命周期圖譜 ),沒怎么了解過其他 Hook 的執(zhí)行順序,跑個 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
微信開發(fā)者工具中 fixed 失效時頁面閃爍的問題
微信開發(fā)者工具 升級到目前最新的 1.06.2402040 版本還是有問題。 真機測試沒問題。
復現(xiàn)步驟:
- 打開一個
fixed失效的彈窗 - 打開一個正常的彈窗并關閉
- 不斷切換第一個失效的彈窗,可以發(fā)現(xiàn)界面在不斷閃爍,閃爍的畫面甚至可以看到上一個頁面的內容( 頁面穿透了 )。
解決辦法:
初步排查是祖先元素同時設置了 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 簡單記錄下,結果拔出蘿卜帶出泥,越是深入了解坑踩得越多,不過也收獲了很多,也是應證了學無止境那句話。