美文网首页程序员
Taro 项目中使用 Teleport 和 Portal

Taro 项目中使用 Teleport 和 Portal

作者: anyesu | 来源:发表于2024-04-26 00:40 被阅读0次
    cover

    背景


    传送门 的作用是将组件渲染到 DOM 树的任意位置,从而摆脱当前组件树的层次结构。常用于制作弹窗、弹出层等,通常 UI 框架 已经帮我们做了这部分工作( 比如渲染到 body 下 ),所以项目中很少用到。

    • Teleport

      <Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。

    • Portal

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

    跑了文档中的示例项目之后发现 Teleport / Portal 的基本功能都是支持的,可以满足将组件渲染到当前页面中的某个节点中。

    不明白 跨页面的全局组件 的意义是什么( 难道是浮窗按钮? ),毕竟一个屏幕下只能同时显示一个页面的内容,将 A 页面中某个组件渲染到 B 页面中也看不见,意义不大。如果真有这样的需求,我觉得 页面级全局组件 再配合 状态管理工具ReduxPinia 等 )也能实现跨页面后台展示的效果。

    需要用到 Teleport / Portal 的场景


    一般我们会使用 position: fixed 来实现悬浮在某个位置的效果,不使用 Teleport / Portal 也能用,但是组件多了之后 z-index 的层级问题就不好控制了。

    1. 首先是遵循 DOM 的规则,同级的后面居上。
    2. 一般有定位属性的元素会高于无定位属性的同级元素。
    3. 都有定位属性的同级元素, z-index 大者居上。
    4. 如果是非同级的元素,则会忽略元素本身 z-index ,取与对比元素同级的祖先元素的 z-index 属性,大者居上。

    层级问题还是其次,更关键的是 fixed 在一些场景下会失效降级为 absolute

    当元素祖先的 transformperspectivefilterbackdrop-filter 属性非 none 时,容器由视口改为该祖先。

    一个列表左滑删除的例子:左滑显示删除按钮,点击删除显示确认删除的弹窗。

    滑动组件 带有 transform 样式导致弹窗组件的 fixed 失效,为了修复这个问题只能将弹窗组件写在滑动组件外部,这时封装 ListItem 组件会非常麻烦,要通过事件向上传递和弹窗组件进行通讯。

    项目中这样的场景不在少数,如果组件树中某个中间节点增加了 transform 样式就需要重新梳理组件结构了。

    如果能将 fixed 组件直接渲染到外部的话,就完全不需要考虑这方面问题了。

    整合思路与遇到的问题


    封装传送门组件

    主要是对内置的 Teleport / Portal 组件做了一层简单封装,因为 Taro 是跨平台框架,各端实现有所差异,所以需要在这一层做兼容处理。

    组件提供 enabletargetroot 三个属性,其中 enable 用于控制是否从页面中脱离出来,剩下的属性用于控制渲染逻辑:

    • 指定了 target 且值非空时,渲染到指定的节点上,可以是一个 DOM 元素对象或者其 id

      [!NOTE]

      Vue 中不能用 class 选择器,因为 querySelector 是用 getElementById 模拟的 ,只支持 id

    • root 值为 'first' 时,渲染到页面根节点的第一个子节点。

    • root 值为 true 时,渲染到页面根节点。

    • 当外层用传送门组件的 Provider 包裹时,渲染到 Provider 中提供的节点上。

    • 缺省渲染到页面根节点。

      [!NOTE]

      无法应用 UI 框架CSS variables 方式的主题配置。

    封装 UI 框架的弹窗组件

    本文中使用的 UI 框架NutUI ,正好 VueReact 两个版本都支持。包装一下 Popup 组件使其默认就渲染到页面根节点的第一个子节点上,这样使用的时候就会省事很多。

    获取用于渲染的节点

    • 使用 ref 语法来获取节点。

      由于 不同平台不同框架 ref 获取到的节点类型不同 ,这种方式的可靠性还有待验证。

    • 使用 document.getElementById DOM API 来获取节点。

      这种方式的限制就是需要保证组件 id 全局( 所有页面 )唯一( 参考 ):

      • H5 端 多页应用每个页面是用 div 模拟的,如果 id 不唯一就会获取到其他页面上的节点,导致失效。

        文档中的 ID 必须是唯一的。如果一个文档中有两个及以上的元素具有相同的 ID ,那么该方法只会返回查找到的第一个元素。

      • 小程序端 getElementById 是通过全局的 eventSource 实现的。

        组件卸载的时候会调用 eventSource.removeNodeTree 将组件对应的 ideventSource 中移除( 参考 ),这就导致一个问题: 如果两个页面中都存在 id 为 teleportId 的组件,切换到下一页再后退回来,就会发现当前页面无法通过这个 id 获取到组件了

        Taro 文档中提供的示例项目 taro-vue-teleport 就有这个问题,其中 teleportv-ifshowModal 绑定了,也就是说每次关闭弹窗再打开弹窗会创建新的 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-varsVue@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 就好了。

    微信小程序也提供了 root-portal 组件,原生支持了 Portal 的能力。 👉 Taro 文档

    演示效果 - React

    biz-portal.tsx


    Vue 中的实现比做了一点简化,其中 target 属性不支持传 id 字符串,因为加了之后逻辑会复杂很多。可以在外部根据 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;
      /**
       * 优先级小于 `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


    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;
      /**
       * 优先级小于 `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 中扩展已有的组件

    对应 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: hiddenborder-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 简单记录下,结果拔出萝卜带出泥,越是深入了解坑踩得越多,不过也收获了很多,也是应证了学无止境那句话。


    转载请注明出处: https://github.com/anyesu/blog/issues/51

    相关文章

      网友评论

        本文标题:Taro 项目中使用 Teleport 和 Portal

        本文链接:https://www.haomeiwen.com/subject/wvtoxjtx.html