Taro 4 微信小程序:RootPortal CSS 变量继承问题与自建 PagePortal 解决方案

Taro 4 微信小程序:RootPortal CSS 变量继承问题与自建 PagePortal 解决方案

背景

在基于 Taro 4 + React 开发的微信小程序中,我们有一个下拉筛选组件,展开时需弹出全屏透明遮罩 + 选项抽屉面板。

由于下拉内容被包裹在 <ScrollView> 组件内,而微信小程序的 scroll-view 会裁剪内部 position: fixed 的子元素(fixed 定位相对于 scroll-view 而非视口),因此浮层必须逃离 ScrollView 才能正常工作。

第一回合:尝试 RootPortal

微信小程序提供了 <root-portal> 原生组件,可以将子节点渲染到页面根节点。Taro 4 也提供了对应的 <RootPortal> 组件:

tsx 复制代码
import { RootPortal } from '@tarojs/components'

{open && (
  <RootPortal>
    <View className="my-mask" />
    <View className="my-panel">...</View>
  </RootPortal>
)}

现象

编译产物一切正常------<root-portal> 在 WXML 中正确渲染,class="{``{i.cl}}" 透传正确,CSS 选择器在页面级 wxss 中正确产出。但运行后,遮罩和面板完全不可见

原因排查

微信 <root-portal> 原生组件具有 styleIsolation: isolated 属性,这意味着它内部的子节点与页面形成独立的样式上下文

虽然 Taro 4 采用了递归组件(comp)编译模型,所有组件样式展平到页面级 wxss,class 选择器确实能匹配到被搬运的节点------但 CSS 变量 var(--xxx) 的继承链被 isolated 隔离机制掐断了

当 root-portal 内部的节点使用 var(--bg-card)var(--color-primary) 时,它们无法从 <page> 上挂载的 CSS 变量声明中获取值,因为这些变量在隔离上下文中不可见 。最终结果是 var() 解析失败,背景色回退为默认透明。

第二回合:内联 style 绕行

之前的 AI 开发者发现 class 方式不行后,将所有浮层样式改为了内联 style,颜色直接写死 hex 值:

tsx 复制代码
const maskStyle: CSSProperties = { position: 'fixed', background: 'transparent', ... }
const panelStyle: CSSProperties = { position: 'fixed', background: '#FFFFFF', ... }

<RootPortal>
  <View style={maskStyle} />
  <View style={panelStyle}>...</View>
</RootPortal>

这确实解决了可见性问题,但引入了新问题:

  • 设计 token 形同虚设:颜色写死 hex,无法跟随主题变量统一变更
  • 代码丑陋 :大量 CSSProperties 常量堆砌在组件文件中
  • 维护困难:每次颜色调整都要改 JSX 而非 scss

第三回合:设计方案

用户的思路非常清晰------既然 RootPortal 的 isolation 机制有问题,那就别用它。自己建一个 Portal 组件,用纯 View 容器实现相同的「将元素渲染到远处」的能力。

核心需求:

  1. 逃出 ScrollView 裁剪:浮层 DOM 节点必须在 ScrollView 子树之外
  2. CSS 变量正常继承:渲染容器不能有任何 isolation
  3. 使用方式简单:和在 ScrollView 内写 JSX 一样自然
  4. 避免循环渲染:Portal 内容传递不能引发无限重渲染
  5. 支持内容动态更新:浮层内的状态变化(如 open→close 切换)要能同步到宿主,不能只有一次性渲染

最终方案:PagePortal 组件族

架构图

复制代码
<page>  ← CSS 变量定义在此
└── <PortalHost>  ← 包裹页面根内容
    ├── 页面主内容(含 <ScrollView> 内的 <PagePortal>)
    │   └── PagePortal 在 render 阶段写入模块级注册表
    └── <View.page-portal__root>  ← 位于 ScrollView 外
        └── portal 内容在此渲染(fixed 正常、CSS 变量正常)

工作原理

模块级注册表PagePortal 在 React render 阶段直接写入一个模块级的 Map<string, ReactNode>。这一步是同步的,没有任何异步或批次延迟。

Context 信号PagePortal 通过 useEffectPortalHost 发送轻量信号,PortalHost 据此维护 activeIds Set,并从注册表读取内容渲染。信号分三种:

信号 触发时机 作用
mountPortal(id) 挂载(空 deps useEffect) 首次渲染内容
unmountPortal(id) 卸载(effect cleanup) 移除渲染
updatePortal(id) version prop 变化(isFirstRender 守卫跳过首次) 强制重渲染,读取最新内容

防循环设计PortalHost 的三个方法都用 useCallback([]) 包裹,函数引用永远稳定。PagePortal 的挂载/卸载 useEffect 显式声明空依赖数组,内容更新 useEffect 只依赖 version。因此 PortalHost 的重渲染不会 导致 PagePortal 再次触发信号,彻底避免了无限循环。

为什么这不会产生陈旧闭包? 因为 id 是用 useRef 生成的稳定字符串(首次渲染确定,终身不变),不需要在 deps 中追踪。

为什么需要 version 而不是直接比较 children children 是 JSX 表达式,每次渲染都会生成新引用,无法用 prevChildren !== children 判断内容是否真的变了。用 version 由调用方显式标记「内容实质变化」,引用比较才可靠。

tsx 复制代码
// src/components/PagePortal/index.scss
.page-portal__root {
  /* 无视觉样式------portal 内容通过 position:fixed 脱离流布局 */
}
tsx 复制代码
// src/components/PagePortal/index.tsx
/**
 * PagePortal --- 页面级 Portal 组件族
 *
 * 替代微信原生 root-portal,避免 styleIsolation 导致的 CSS 变量继承断裂。
 *
 * 使用方式:
 *   1. 用 <PortalHost> 包裹页面根层
 *   2. 在深层任意位置使用 <PagePortal> 包裹要逃离裁剪容器的内容
 *
 * 核心原理:PagePortal 在 render 阶段将子元素写入模块级注册表,
 * PortalHost 收到挂载信号后重渲染,从注册表读取内容并渲染到页面末梢的
 * .page-portal__root 容器。该容器位于所有裁剪容器(ScrollView 等)之外,
 * position:fixed 不受限制,CSS 变量从 page{} 正常继承。
 *
 * 内容变更:PagePortal 通过 version prop 的变化来感知内容变更,
 * 并通知 PortalHost 重渲染以读取最新内容。isFirstRender 守卫
 * 跳过首次渲染的冗余通知,避免 PortalHost→PagePortal 级联循环。
 */

import { View } from '@tarojs/components'
import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState
} from 'react'
import './index.scss'

// ── 模块级注册表 ──

/** portal id → ReactNode 的映射,PagePortal 在 render 阶段同步写入 */
const portalContents = new Map<string, ReactNode>()

// ── Context ──

interface PortalContextValue {
  /** 通知宿主:指定 id 的 portal 挂载,宿主首次渲染其内容 */
  mountPortal: (id: string) => void
  /** 通知宿主:指定 id 的 portal 卸载,宿主移除其渲染 */
  unmountPortal: (id: string) => void
  /** 通知宿主:指定 id 的 portal 内容已变更,宿主重渲染以读取最新内容 */
  updatePortal: (id: string) => void
}

const PortalContext = createContext<PortalContextValue | null>(null)

// ── id 计数器 ──

let portalIdCounter = 0

// ── PagePortal ──

interface PagePortalProps {
  /** 要挂载到宿主容器中的内容 */
  children?: ReactNode
  /**
   * 版本标识。当浮层内容发生实质性变化时传入不同值(如 open→close 切换),
   * PagePortal 据此通知 PortalHost 重渲染以读取最新内容。
   * 使用 boolean→number 转换即可:version={open ? 1 : 0}
   */
  version?: number
}

/**
 * PagePortal --- 将子元素挂载到 PortalHost 末梢的 .page-portal__root 容器中。
 *
 * 必须在 PortalHost 包裹范围内使用。通过 version prop 感知内容变更,
 * 通知宿主重渲染。isFirstRender 守卫跳过首次挂载,防止多余渲染。
 *
 * @param props.children 要挂载到宿主容器中的内容
 * @param props.version  内容版本,变化时触发宿主重渲染
 * @returns null(本身不渲染任何 DOM)
 *
 * @example
 * <PagePortal version={open ? 1 : 0}>
 *   <View className='my-mask' />
 *   <View className='my-panel'>panel 内容</View>
 * </PagePortal>
 */
const PagePortal = (props: PagePortalProps): null => {
  const { children, version } = props
  const ctx = useContext(PortalContext)
  const idRef = useRef<string>('')

  // 首次渲染时生成稳定 id(不依赖 useState,避免额外渲染)
  if (!idRef.current) {
    idRef.current = `pp-${++portalIdCounter}`
  }

  const id = idRef.current

  // render 阶段同步写入注册表(Taro 4 微信小程序不使用 Concurrent Mode / Strict Mode,
  // 因此 render 阶段的副作用在实际运行中是安全的)
  portalContents.set(id, children)

  // 仅挂载/卸载时通知宿主(空 deps),避免循环触发
  // biome-ignore lint/correctness/useExhaustiveDependencies: 空 deps 有意为之------ctx?.mountPortal/unmountPortal 指向稳定的 useCallback 引用,填入会导致 PortalHost→PagePortal 级联重渲染的无限循环,与核心设计矛盾
  useEffect(() => {
    ctx?.mountPortal(id)
    return () => {
      ctx?.unmountPortal(id)
      portalContents.delete(id)
    }
  }, [])

  // 内容变更通知:version 变化时通知宿主重渲染。
  // isFirstRender 守卫跳过首次挂载(此时 mountPortal 已触发宿主渲染)
  const isFirstRender = useRef(true)
  // biome-ignore lint/correctness/useExhaustiveDependencies: 唯一意图依赖为 version。ctx?.updatePortal 与 id 均为稳定引用(useCallback[] + useRef),填入会导致宿主→子级级联循环
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false
      return
    }
    ctx?.updatePortal(id)
  }, [version])

  return null
}

// ── PortalHost ──

interface PortalHostProps {
  /** 页面内容 */
  children?: ReactNode
}

/**
 * PortalHost --- 页面级 Portal 宿主组件
 *
 * 包裹页面根层内容,并在页面末梢渲染 .page-portal__root 容器,
 * 所有 PagePortal 注册的内容都会渲染在该容器中。
 *
 * @param props.children 页面内容
 * @returns 包裹后的页面 JSX
 *
 * @example
 * <PortalHost>
 *   <View className='page-content'>
 *     <MyDropdown />
 *   </View>
 * </PortalHost>
 */
const PortalHost = (props: PortalHostProps): JSX.Element => {
  const { children } = props
  const [activeIds, setActiveIds] = useState<Set<string>>(new Set())

  const mountPortal = useCallback((id: string) => {
    setActiveIds(prev => {
      if (prev.has(id)) return prev
      const next = new Set(prev)
      next.add(id)
      return next
    })
  }, [])

  const unmountPortal = useCallback((id: string) => {
    setActiveIds(prev => {
      if (!prev.has(id)) return prev
      const next = new Set(prev)
      next.delete(id)
      return next
    })
  }, [])

  /** 强制宿主重渲染,读取 portalContents 中最新的内容 */
  const updatePortal = useCallback((_id: string) => {
    setActiveIds(prev => new Set(prev))
  }, [])

  const ctxValue = useMemo(
    () => ({ mountPortal, unmountPortal, updatePortal }),
    [mountPortal, unmountPortal, updatePortal]
  )

  return (
    <PortalContext.Provider value={ctxValue}>
      {children}

      {/* .page-portal__root ------ portal 内容的物理挂载容器
          位于页面 DOM 末梢,不受 ScrollView 等裁剪容器约束。
          portal 内部元素用 position:fixed 脱离流布局,
          CSS 变量从 page{} 正常继承。 */}
      <View className='page-portal__root'>
        {[...activeIds].map(id => (
          <View key={id}>{portalContents.get(id)}</View>
        ))}
      </View>
    </PortalContext.Provider>
  )
}

export { PagePortal, PortalHost }
export default PagePortal

配套方案:ExpandOverlay(入场/离场动画)

解决了「渲染到远处」之后,浮层的动画又带来一个新坑:PortalHost 只渲染静态内容,浮层内部的状态变化(open→close)如果只靠 {open && ...} 条件渲染,离场动画根本来不及播放(内容瞬间被移除)。

解法是加一层生命周期管理组件 ExpandOverlay

  • 它不定义具体动画效果,只负责挂载/卸载时序 + CSS class 切换
  • 具体的 transition 由子组件在自身 scss 中利用 .expand-overlay--enter / .expand-overlay--leave 编写
  • 离场动画结束后通过 onCloseEnd 通知父组件卸载 portal
tsx 复制代码
// src/components/ExpandOverlay/index.tsx
/**
 * ExpandOverlay --- 展开式浮层生命周期管理组件
 *
 * 控制浮层的入场/离场过渡周期。展开时自动处理「挂载 → 下一帧触发入场动画」
 * 的时序;收起时保持 DOM 挂载直至离场动画完成,再通过 onCloseEnd 通知
 * 父组件卸载(从而配合 PagePortal 正确清理 portal 内容)。
 *
 * 动画由子组件的 scss 定义,利用以下 class 选择器:
 *   .expand-overlay                          基类(容器)
 *   .expand-overlay--enter                   入场态(子组件应定义从隐藏→显示的 transition)
 *   .expand-overlay--leave                   离场态(子组件应定义从显示→隐藏的 transition)
 *
 * @example
 * <ExpandOverlay open={open} duration={200} onCloseEnd={() => setMounted(false)}>
 *   <View className='my-mask' />
 *   <View className='my-panel' />
 * </ExpandOverlay>
 */

import { View } from '@tarojs/components'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import './index.scss'

/** ExpandOverlay 组件属性 */
interface ExpandOverlayProps {
  /** 是否展开 */
  open: boolean
  /** 过渡时长(毫秒),默认 200 */
  duration?: number
  /** 浮层内容 */
  children?: ReactNode
  /** 离场动画完成后的回调(用于父组件清理 portal 挂载) */
  onCloseEnd?: () => void
}

const ExpandOverlay = (props: ExpandOverlayProps): JSX.Element => {
  const { open, duration = 200, children, onCloseEnd } = props
  const [animClass, setAnimClass] = useState('')
  const prevOpenRef = useRef<boolean | undefined>(undefined)

  useEffect(() => {
    // 首次挂载:立即触发入场动画(prevOpenRef 为 undefined,不走守卫)
    if (prevOpenRef.current === undefined) {
      prevOpenRef.current = open
      const timer = setTimeout(() => setAnimClass('expand-overlay--enter'), 16)
      return () => clearTimeout(timer)
    }

    // 后续状态变化守卫:open 没变则跳过
    if (open === prevOpenRef.current) return
    prevOpenRef.current = open

    let timer: ReturnType<typeof setTimeout>

    if (open) {
      // 展开:先清空动画 class,下一帧添加入场 class 触发 transition
      setAnimClass('')
      timer = setTimeout(() => setAnimClass('expand-overlay--enter'), 16)
    } else {
      // 收起:添加离场 class,过渡结束后清 class + 通知父组件
      setAnimClass('expand-overlay--leave')
      timer = setTimeout(() => {
        setAnimClass('')
        onCloseEnd?.()
      }, duration)
    }

    return () => clearTimeout(timer)
  }, [open, duration, onCloseEnd])

  return <View className={`expand-overlay${animClass ? ` ${animClass}` : ''}`}>{children}</View>
}

export default ExpandOverlay
scss 复制代码
// src/components/ExpandOverlay/index.scss
/** ExpandOverlay --- 展开式浮层生命周期管理组件
 *
 * 本文件仅定义容器基类,不包含过渡规则。
 * 子组件在其各自的 scss 中使用
 *   .expand-overlay--enter <子选择器>
 *   .expand-overlay--leave <子选择器>
 * 定义入场/离场过渡效果。 */

.expand-overlay {
  /* 容器本身无视觉样式,仅作为动画 class 的宿主 */
}

prevOpenRef 的一个关键坑 :如果初始值写成 useRef(open),当组件以 open=true 首次挂载时,prevOpenRef.current 也等于 true,首次挂载分支 if (prevOpenRef.current === undefined) 不会进入,入场动画被守卫直接跳过------浮层全程透明、卡在页面上。必须初始化为 undefined,让首次挂载走「直接触发入场动画」的分支。

使用方式

1. 用 PortalHost 包裹页面根层

tsx 复制代码
// pages/index/index.tsx
import { PortalHost } from '@/components/PagePortal'

const Index = (): JSX.Element => {
  return (
    <PortalHost>
      <View className='page-content'>
        {/* 页面内容 */}
      </View>
    </PortalHost>
  )
}

2. 在深层组件中:双状态 + PagePortal + ExpandOverlay

双状态分离 是关键:portalActive 控制浮层 DOM 的存在与否,open 控制动画方向。展开时两者同时置 true(portal 挂载 + 入场动画);收起时只把 open 置 false(离场动画播放),等 onCloseEnd 回调再卸载 portal。

tsx 复制代码
// components/MyDropdown/index.tsx
import { useCallback, useState } from 'react'
import ExpandOverlay from '@/components/ExpandOverlay'
import PagePortal from '@/components/PagePortal'

const MyDropdown = (): JSX.Element => {
  /** portal 是否挂载(控制浮层 DOM 的存在与否) */
  const [portalActive, setPortalActive] = useState(false)
  /** 动画方向:true=入场 / false=离场 */
  const [open, setOpen] = useState(false)

  /** 离场动画完成回调:卸载 portal,清除浮层 DOM */
  const handleCloseEnd = useCallback(() => {
    setPortalActive(false)
  }, [])

  const handleClose = useCallback(() => {
    setOpen(false) // 触发离场动画,动画结束后 handleCloseEnd 卸载 portal
  }, [])

  return (
    <View className='my-dropdown'>
      <View className='my-dropdown__trigger' onClick={() => setOpen(true)}>
        {/* 触发器 */}
      </View>

      {portalActive && (
        <PagePortal version={open ? 1 : 0}>
          <ExpandOverlay open={open} duration={200} onCloseEnd={handleCloseEnd}>
            {/* 遮罩:全屏透明,点击关闭;catchMove 阻止滚动穿透 */}
            <View className='dropdown__mask' onClick={handleClose} catchMove />
            {/* 抽屉面板 */}
            <View className='dropdown__panel'>
              {/* 下拉选项 */}
            </View>
          </ExpandOverlay>
        </PagePortal>
      )}
    </View>
  )
}

注意:展开时要把 setPortalActive(true)setOpen(true) 一起调用(同一批次),否则会出现「portal 挂载了但 open 还是 false」的中间态。

3. 浮层样式用 class + var(),正常写 scss + 动画

scss 复制代码
/* 遮罩:全屏透明 */
.dropdown__mask {
  position: fixed;
  left: 0; right: 0; top: 0; bottom: 0;
  z-index: 100;
  background: transparent;
  opacity: 0;
  transition: opacity 200ms ease;
}

/* 入场:遮罩淡入 */
.expand-overlay--enter .dropdown__mask {
  opacity: 1;
}

/* 离场:遮罩淡出 */
.expand-overlay--leave .dropdown__mask {
  opacity: 0;
}

/* 抽屉面板:白色圆角卡 */
.dropdown__panel {
  position: fixed;
  left: 0; right: 0;
  z-index: 101;
  background: var(--bg-card);    /* ✅ var() 正常生效 */
  border-radius: 0 0 20rpx 20rpx;
  padding: 24rpx 48rpx 40rpx;
  transform: translateY(-20%);
  opacity: 0;
  transition: transform 200ms ease, opacity 200ms ease;
}

/* 入场:面板从顶部向下滑入 + 淡入 */
.expand-overlay--enter .dropdown__panel {
  transform: translateY(0);
  opacity: 1;
}

/* 离场:面板向上收起 + 淡出 */
.expand-overlay--leave .dropdown__panel {
  transform: translateY(-20%);
  opacity: 0;
}

/* 选中状态 */
.dropdown__option--checked {
  background: var(--color-primary); /* ✅ var() 正常生效 */
  border: 2rpx solid var(--color-primary);
}

与 ReactDOM.createPortal 的区别

Web 端的 ReactDOM.createPortal 是将元素渲染到指定的 DOM 节点(通常挂到 document.body)。我们的 PagePortal 做的是同一件事,但受限于微信小程序的架构:

  • 微信小程序没有 document.body,DOM 操作受限
  • 不能直接操作 WXML 模板外的节点
  • 所以用模块级注册表 + Context 信号间接实现「渲染到远处」

最终效果等价:开发者写 JSX 时感觉元素就在原地,实际 DOM 位置在独立容器中

关键设计决策

为什么不用 Context 传递 ReactNode?

这是最容易想到的方案,但会引入循环渲染问题:

  1. PortalHost setState → 重渲染 → PagePortal 重渲染
  2. PagePortal 重渲染 → useEffect → addPortal → PortalHost setState → 循环

我们的方案通过模块级 Map 将内容传递从 React 渲染周期中剥离,PagePortal 的 useEffect 只传递挂载/卸载/更新信号 (调用 useCallback([]) 稳定函数),不传递内容本身,循环被自然阻断。

为什么内容更新需要显式 version 信号?

如果把内容更新的 useEffect 依赖写成 [children],会导致死循环------因为 children 是 JSX 表达式,PortalHost 每次重渲染都会生成新的 children 引用,effect 又触发 updatePortal → PortalHost 再重渲染 → 无限循环。

version 由调用方显式声明 内容变化时机,配合 isFirstRender 守卫跳过首次挂载,才能安全地通知宿主。这也意味着:浮层内部状态一变化,调用方必须同步更新 version (本项目用 version={open ? 1 : 0} 一行搞定)。

为什么用 ref 生成 id?

如果用 useState 生成 id,会导致一次额外渲染;用 useRef 在 render 阶段同步生成,零额外渲染。这对频繁展开/关闭的浮层场景很重要。

render 阶段写 Map 安全吗?

在 Taro 4 微信小程序环境下,不使用 Concurrent Mode 或 Strict Mode,render 阶段的副作用不会导致重复执行。同时因为 Map 写入是幂等的(相同 id 覆盖相同内容),即使 Strict Mode 下双调也不会出问题。

成果

  • ✨ 所有浮层样式回归 scss + var(--xxx) 设计 token,无需内联 style
  • position: fixed 在 PagePortal 容器中正常工作(不受 ScrollView 约束)
  • CSS 变量page{} 正常继承(无 styleIsolation 阻断)
  • 内容动态更新:version 信号让浮层状态变化(展开/收起)同步到宿主
  • 入场/离场动画 :ExpandOverlay 统一管理过渡生命周期,{open && ...} 条件渲染导致的「动画来不及播」问题被消除
  • ✨ 使用方式简单------两处 import,一处包裹,一处替换组件名

总结

diff 复制代码
- RootPortal(微信原生)← styleIsolation: isolated 阻断 CSS 变量继承
+ PagePortal(自建)     ← 纯 View 容器,无隔离,CSS 变量正常继承

这次踩坑的核心教训是:不要盲目信任原生组件的「等价替代」 。微信 <root-portal> 虽然功能上等价于 React 的 createPortal,但 styleIsolation: isolated 的副作用在 Taro 4 的编译模型下被放大------class 选择器能匹配(让人误以为一切正常)、但 CSS 变量继承链断了(只有真机实测才能发现)。

自建 PagePortal 方案不仅解决了当前问题,还为后续所有需要逃出裁剪容器的浮层(筛选面板、弹窗、下拉菜单等)提供了统一的、CSS 变量友好的基础设施。配套的 ExpandOverlay 则补齐了浮层动画的生命周期管理,两个组件组合使用,即可获得「渲染正确 + 样式正确 + 动画流畅」的完整浮层方案。

文章由 FungLeo 主导,DeepSeek 操刀编写,转发请保留收发地址,谢谢。

相关推荐
海纳百川·纳海川2 天前
租房行业数字化:换个思路解决“老问题”
大数据·微信小程序·小程序
JasonMa1534 天前
从零搭建一个微信小程序活动管理平台(一):架构设计与技术选型
javascript·微信小程序·node.js
鲁Q同志5 天前
微信小程序【uni-file-picker文件预览】
微信小程序·小程序
2601_963869955 天前
【计算机毕业设计】基于微信小程序的拼车服务系统设计与实现
微信小程序·小程序·课程设计
虚惊一场6 天前
撤销为什么只需要 pop:用事件重放实现离线台球计分
微信小程序·uni-app
虚惊一场6 天前
从一个小程序到一座工具箱:uni-app 分包与 Flask 微服务实践
微信小程序·uni-app
beichenxingzhui6 天前
茶饮品牌“新品口感盲测问答”:适合微信小程序与互动页的工具 Top 3
微信小程序·小程序
海纳百川·纳海川7 天前
数字化升级,让废品回收更简单高效
大数据·微信小程序·小程序
VX_bysjlw9858 天前
基于微信小程序的宠物用品商城系统-后端74346-计算机毕设原创(免费领源码+带部署教程)
java·redis·微信小程序·eclipse·mybatis·idea·微信开发者工具