Taro 項目中使用 Teleport 和 Portal

cover

背景


傳送門 的作用是將組件渲染到 DOM 樹的任意位置,從而擺脫當前組件樹的層次結構。常用于制作彈窗、彈出層等,通常 UI 框架 已經幫我們做了這部分工作( 比如渲染到 body 下 ),所以項目中很少用到。

  • Teleport

    <Teleport> 是一個內置組件,它可以將一個組件內部的一部分模板“傳送”到該組件的 DOM 結構外層的位置去。

  • Portal

    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 在文檔中是這么描述的:

跑了文檔中的示例項目之后發(fā)現(xiàn) Teleport / Portal 的基本功能都是支持的,可以滿足將組件渲染到當前頁面中的某個節(jié)點中。

不明白 跨頁面的全局組件 的意義是什么( 難道是浮窗按鈕? ),畢竟一個屏幕下只能同時顯示一個頁面的內容,將 A 頁面中某個組件渲染到 B 頁面中也看不見,意義不大。如果真有這樣的需求,我覺得 頁面級全局組件 再配合 狀態(tài)管理工具Redux 、 Pinia 等 )也能實現(xiàn)跨頁面后臺展示的效果。

需要用到 Teleport / Portal 的場景


一般我們會使用 position: fixed 來實現(xiàn)懸浮在某個位置的效果,不使用 Teleport / Portal 也能用,但是組件多了之后 z-index 的層級問題就不好控制了。

  1. 首先是遵循 DOM 的規(guī)則,同級的后面居上。
  2. 一般有定位屬性的元素會高于無定位屬性的同級元素。
  3. 都有定位屬性的同級元素, z-index 大者居上。
  4. 如果是非同級的元素,則會忽略元素本身 z-index ,取與對比元素同級的祖先元素的 z-index 屬性,大者居上。

層級問題還是其次,更關鍵的是 fixed 在一些場景下會失效降級為 absolute

當元素祖先的 transform 、perspectivefilterbackdrop-filter 屬性非 none 時,容器由視口改為該祖先。

一個列表左滑刪除的例子:左滑顯示刪除按鈕,點擊刪除顯示確認刪除的彈窗。

滑動組件 帶有 transform 樣式導致彈窗組件的 fixed 失效,為了修復這個問題只能將彈窗組件寫在滑動組件外部,這時封裝 ListItem 組件會非常麻煩,要通過事件向上傳遞和彈窗組件進行通訊。

項目中這樣的場景不在少數(shù),如果組件樹中某個中間節(jié)點增加了 transform 樣式就需要重新梳理組件結構了。

如果能將 fixed 組件直接渲染到外部的話,就完全不需要考慮這方面問題了。

整合思路與遇到的問題


封裝傳送門組件

主要是對內置的 Teleport / Portal 組件做了一層簡單封裝,因為 Taro 是跨平臺框架,各端實現(xiàn)有所差異,所以需要在這一層做兼容處理。

組件提供 enable 、 targetroot 三個屬性,其中 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 ,正好 VueReact 兩個版本都支持。包裝一下 Popup 組件使其默認就渲染到頁面根節(jié)點的第一個子節(jié)點上,這樣使用的時候就會省事很多。

獲取用于渲染的節(jié)點

  • 使用 ref 語法來獲取節(jié)點。

    由于 不同平臺不同框架 ref 獲取到的節(jié)點類型不同 ,這種方式的可靠性還有待驗證。

  • 使用 document.getElementById DOM API 來獲取節(jié)點。

    這種方式的限制就是需要保證組件 id 全局( 所有頁面 )唯一( 參考 ):

    • H5 端 多頁應用每個頁面是用 div 模擬的,如果 id 不唯一就會獲取到其他頁面上的節(jié)點,導致失效。

      文檔中的 ID 必須是唯一的。如果一個文檔中有兩個及以上的元素具有相同的 ID ,那么該方法只會返回查找到的第一個元素。

    • 小程序端 getElementById 是通過全局的 eventSource 實現(xiàn)的。

      組件卸載的時候會調用 eventSource.removeNodeTree 將組件對應的 ideventSource 中移除( 參考 ),這就導致一個問題: 如果兩個頁面中都存在 id 為 teleportId 的組件,切換到下一頁再后退回來,就會發(fā)現(xiàn)當前頁面無法通過這個 id 獲取到組件了

      Taro 文檔中提供的示例項目 taro-vue-teleport 就有這個問題,其中 teleportv-ifshowModal 綁定了,也就是說每次關閉彈窗再打開彈窗會創(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


演示效果 - 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;
  /**
   * 優(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-varsVue@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 文檔

演示效果 - React

biz-portal.tsx


Vue 中的實現(xiàn)比做了一點簡化,其中 target 屬性不支持傳 id 字符串,因為加了之后邏輯會復雜很多。可以在外部根據(jù) id 獲取到對應的 DOM 元素對象 后再傳入,具體參考下文的用法示例。

Providerref 的值要用 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


NutUI-ReactPopup 組件已經有 portal 屬性了,也可以直接用。

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 格式的。

在 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: hiddenborder-radius 導致的,把 hidden 取消掉或者 border-radius 設置為 0 都能解決這個閃爍問題,猜測是 fixed 降級為 absolute 時圓角裁剪有問題。

演示效果:

演示效果 - 微信開發(fā)者工具閃爍問題

源碼


完整項目代碼 ?? 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 簡單記錄下,結果拔出蘿卜帶出泥,越是深入了解坑踩得越多,不過也收獲了很多,也是應證了學無止境那句話。


轉載請注明出處: https://github.com/anyesu/blog/issues/51

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容