ReactNative总结系列二 --- 小工具&小技巧md

35 阅读8分钟

小技巧

条件渲染组件陷阱

  • 如果按这样来判断是否要渲染View progress && <View/> ,在progress是数字0时会报错

Text strings must be rendered within a <Text> component

  • 这是因为当 progress0 时,表达式 0 && <View /> 的返回值为 0,会被直接输出到视图树中。

    • js中的 && 机制是回第一个为假的对象或者最后一个对象
    • progress如果是其他的是没问题的会被忽略,比如false,空字符串,null,undefined
  • 解决方案:

    • 不要偷懒写三元表达式 {progress ? <View /> : null}
    • 数值比较将前面转成boolean {progress > 0 && <View />}

防抖函数可能无效

  • 当使用throttle函数来生成防抖函数,防止按钮短时间多次点击时,如果是按下面这样写,那么这个防抖会失效
const [count, setCount] = useState(0);
// ❌ 点击函数直接由throttle生成,但是里面用了setState刷新了界面
// 导致函数重新生成了,新函数还是可以点
const handleClick = _.throttle(() => {
  setCount(c => c + 1);
  console.log('clicked', count);
}, 1000);
  • 解决方案:
    1. 不用throttle,用一个变量根据时间判断
    const lastClickRef = useRef(0);
    const handleClick = useCallback(() => {
      const now = Date.now();
      // 1 秒内直接忽略
      if (now - lastClickRef.current < 1000) {
        return; 
      }
      lastClickRef.current = now;
      setCount(c => c + 1);
    }, []);
    
    1. 用useCallback保存throttle生成的函数,里面实际执行的函数使用ref每次刷新时保存
    const [count, setCount] = useState(0);
    // ref 始终持有最新执行逻辑,这里赋初值
    const handleClickRef = useRef(() => {
      setCount(c => c + 1);
    });
    // 每次render更新click函数
    handleClickRef.current = () => {
      setCount(c => c + 1);
    };
    
    const handleClick = useCallback(
      _.throttle(
        () => handleClickRef.current(),
        1000,
        { trailing: false }
      ),
      []
    );
    
    • 也可以封装成自定义hook
    export function useThrottle(callback, wait) {
      const callbackRef = useRef(callback);
      callbackRef.current = callback;
    
      // 返回稳定的触发入口,供 onClick 直接绑定
      return useCallback(_.throttle((...args) => callbackRef.current(...args), 
          wait, { trailing: false }), []);
    }
    // 使用
    const handleClick = useThrottle(() => {
      setCount(c => c + 1);
    }, 1000);
    

promise执行顺序

  • 先介绍一下js的宏任务和微任务
    • 宏任务:主代码块,setTimeout,setInterval等
      • new Promise((resolve,reject) => {...}) 这个里面是也是同步的宏任务
    • 微任务:Promise.then() 、process.nextTick、await后的代码 等
      • await 后面的代码相当于await结束后才推入一个微任务,而Promise.resolve().then是直接推入微任务
graph TD
    A[宏任务] --> B[执行结束]
    B --> C{有微任务?}
    C -->|有| D[执行所有微任务]
    C -->|无| E[浏览器渲染]
    D --> E
    E -->|下一个宏任务| A

    style A fill:#d4e6ff,stroke:#333,stroke-width:2px
    style B fill:#d4e6ff,stroke:#333,stroke-width:2px
    style C fill:#ffcccc,stroke:#333,stroke-width:2px
    style D fill:#e6ffcc,stroke:#333,stroke-width:2px
    style E fill:#fff7cc,stroke:#333,stroke-width:2px
// 模拟异步请求数据
const fetchUserData = (data) => Promise.resolve(data).then(res => res);

// 主业务流程
async function processUserData() {
    let userData = await fetchUserData(111);
    console.log(userData ? userData : 0);
}

console.log("开始初始化");
processUserData();
// ⚠️这里先将123推入微任务,上面的await比这个慢,是执行完await才推
Promise.resolve(123).then(res => console.log(res));
console.log("初始化完成");

//开始初始化
//初始化完成
//123
//111

表达式内联 await导致结果错误

  • ❌ 不要图省事,在表达式里内联await
let count = 0;
const addFetch = async (addCount) => {
  // ❌这两种写法结果都是 200
  // 因为下面写法相当于 count = 0 + await Promise.resolve(addCount); count的值一开始就确定了
  count += await Promise.resolve(addCount);
}
// 这里是 0 + 100 ,并赋值给count,count为100
addFetch(100);
// 虽然上面count的值是100了,但是这里仍然是 0 + 200,count的值被覆盖为200
addFetch(200);
setTimeout(() => {
  console.log(`count = ${count}`)
}, 500);

 // ✅ 这种写法结果是 300,获取到await的结果后,再操作
const addFetch = async (addCount) => {
  const result = await Promise.resolve(addCount);
  count += result;
}

Suspense异步转同步原理

  • 第一次看到时,我有个疑问,Suspense 是如何做到等待子组件加载后再显示子组件,之前使用fallback,渲染不是同步的么。
    • 经过研究和参考,明白了它其实是通过抛出一个异常,异常是个promise来做到的。
graph TD
    A["组件首次渲染"] --> B{"缓存有值?"}
    B -- No --> C["发起请求,throw Promise"]
    C --> D["Suspense 捕获 Promise"]
    D --> E["显示 fallback,并等待promise完成"]
    E --> F["Promise 完成"]
    F --> G["Suspense 触发重渲染"]
    G --> A
    B -- Yes --> H["读取缓存值,正常渲染"]
<Suspense fallback={<ActivityIndicator size="large" />}>
    <ChildDetail>
</Suspense>
...
// ChildDetail
const ChildDetail = () =>{
    // fetchDetail第一次抛出一个异常,异常的值是Promise
    // promise执行结束后,suspense会再次渲染childDetail,
    // 此时再调用fetchDetail里有缓存,用缓存的值就同步渲染了
    const detail = fetchDetail();
    return <Text>
        {detail.name}
    </Text>
}
// fetchDetail 类似这样
function fetchDetail(): any {
  // 缓存命中 → 直接返回值
  if (cacheData !== undefined) {
    return cacheData;
  }

  // 缓存未命中 → 发起请求
  if (!cacheDataPromise) {
    cacheDataPromise =  fetch('xxx')
        .then((res) => res.json())
        .then((data) => {
          cacheData = data; // 写入缓存
        })
  }

  // 同步抛出 Promise,让suspense渲染fallback
  throw cache[url].promise;
}

iOS踩坑

  • textinput上的lineHeight和textAlign偶现的bug
    • 如果设置了lineHeight,那么在失去焦点时可能会不显示文字超出的部分(也并没有变成...),获得焦点才显示
    • 如果不设置textAlign或者lineHeight,那么概率会出现placeholder被挤到输入框下面显示不全 微信图片_20260519014621_50_69.jpg
    • 上面两个bug就矛盾了,一个要设置,一个不要设置。
    • 解决方案:textAlign: Platform.select({ ios: value ? undefined : 'auto' })
      • value是textinput的值,有值的时候不设置textAlign,没值的时候设置成auto
      • auto和left的区别仅在于根据语言顺序,自动文字顺序是从左到右还是从右到左

小工具

  • 安装npm install @azsxdc12356/utils

createCanceledPromise — 可取消的 Promise

  • 痛点:原生 Promise 一旦创建无法取消。网络请求、页面跳转等场景需要中断正在进行的异步操作,否则结果回来后会更新已不存在的 UI 或引发报错。
  • 给 Promise 加上 cancel 方法,调用后返回的 promise 会 reject 并携带 { canceled: true },用户可据此区分取消和正常错误。
const cancelable = createCanceledPromise(fetchData(), () => abortController.abort())

try {
  const data = await cancelable
} catch (e) {
  if (e?.canceled) {
    // 用户主动取消
  } else {
    // 真正的错误
  }
}

// 需要取消时
cancelable.cancel()

createSinglePromise - 同时只运行一个的promise

  • 痛点:多个模块同时请求同一份配置/资源,不加控制会发出多个重复请求。
  • createSinglePromise 保证同一时刻只有一个请求,所有调用者共享同一个 promise。
  • 多次调用 get() 时,如果上一次还没完成,直接返回正在执行的 promise,不会重复执行。
const singleGetConfig = createSinglePromise(() => fetchConfig())

// 多处同时调用,只会发一次请求
const config1Promise = singleGetConfig.get()
const config2Promise = singleGetConfig.get()

createSharedStateHook — 轻量跨组件共享状态

  • 痛点:跨组件共享状态通常需要引入 Context + Provider 或状态管理库,对于简单场景太重了
  • createSharedStateHook 在模块顶层创建一个 hook,多个组件调用同一个 hook 即可共享状态,无需 Context + Provider 包裹。
const useUserInfo = createSharedStateHook({ name: '', age: 0 })

function Header() {
  const [user, setUser] = useUserInfo()
  return <Text>{user.name}</Text>
}

function Editor() {
  const [user, setUser] = useUserInfo()
  return (
    <TextInput
      value={user.name}
      onChangeText={(text) => setUser({ ...user, name: text })}
    />
  )
}

onlyUpdate 选项:只获取 setter 不订阅更新,适合只需要写不需要读的场景(避免不必要的重渲染)。

const [, setUser] = useUserInfo({ onlyUpdate: true })

异步轮询器

  • 原生的setTimeout、setInterval 是定时执行,如果内部执行函数是一个promise,它不会去等promise完成,而是到时间直接执行下一次。如果下一次的结果返回得比上一次快 ,那么最后界面上显示的的就不是最新的结果了
共同特性

三者都继承自 AsyncPolling 基类,具有以下特性:

  • 重新 start 时旧结果不回调:运行中途重新调用 start,上一次请求的结果会被丢弃
  • stop 后结果不回调:调用 stop 后,正在执行的请求结果不会回调
  • setCallback 动态更新回调:运行过程中可以替换回调函数
AsyncOnce — 只取最后一次
  • 痛点:搜索框输入、按钮连点等场景下快速触发同一异步操作,前面请求的结果回来后会覆盖最新结果或导致 UI 闪烁。
  • 快速连续调用同一异步操作时,只回调最后一次的结果,前几次的过期结果会被丢弃。
const once = new AsyncOnce<string, string>(
  async (url) => fetch(url).then(r => r.text()),
  (result) => console.log(result)
)

once.start('/api/1')  // 还没返回
once.start('/api/2')  // 还没返回
once.start('/api/3')  // 只有这次的结果会回调
AsyncInterval — 固定间隔轮询(上一个没完不执行下一个)
  • 痛点:原生 setInterval 不关心上次请求是否完成,响应慢时请求会并发堆积。AsyncInterval 保证不并发,且 stop() 和重新 start() 时旧结果不会回调。
  • 按固定间隔执行异步操作,如果上一次还没返回则跳过本次,到下一个interval再检查,如果返回了才再次执行。
const interval = new AsyncInterval<string, Data>(
  3000,
  async (param) => fetchData(param),
  (result) => console.log(result)
)

interval.start('param')  // 立即执行一次,之后每 3s 检查一次,完成了再执行,不完成跳过。
// ...
interval.stop()          // 停止轮询,正在执行的请求结果不会回调
AsyncTimeout — 上次完成后等固定间隔再执行
  • 痛点:原生 setInterval 不关心上次请求是否完成,响应慢时请求会并发堆积。
  • AsyncTimeout 保证上一次完成后才开始计时,上一次异步操作完成后,等待固定间隔再执行下一次。
const timeout = new AsyncTimeout<string, Data>(
  3000,
  async (param) => fetchData(param),
  (result) => console.log(result)
)

timeout.start('param')  // 立即执行一次,完成后等 3s 再执行下一次
// ...
timeout.stop()          // 停止轮询

ConcurrencyQueue — 并发控制队列

  • 痛点:批量发起网络请求(上传文件、批量下载)时,不加控制会瞬间创建大量并发连接,导致浏览器卡顿或服务端拒绝。
  • ConcurrencyQueue 控制同时执行的任务数量,超出并发上限的任务排队等待,完成一个自动执行下一个。让并发数始终在可控范围内。
const queue = new ConcurrencyQueue('upload', 3)

queue.addItem({
  id: 'file1',
  start: () => uploadFile('file1'),
})
queue.addItem({
  id: 'file2',
  start: () => uploadFile('file2'),
})

// 移除排队中的任务
queue.removeItem('file2')
// 清空所有排队任务
queue.removeAll()