继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

我的AI编程搭档发现React Hooks其实就是在不必要的地方增加开销的函数调用

呼唤远方
关注TA
已关注
手记 376
粉丝 82
获赞 368

我在做简单的登录表单时,同时在记忆化的 useEffect 钩子中实现了每一个 React 钩子——没有什么比一个复杂的钩子更能体现“企业级准备就绪”了——这时我的 AI 助手开始质疑我的乱七八糟的创意。你觉得框架变得不那么重要了吗?让我告诉你,有一次我的 AI 看了我的依赖数组,笑得前仰后合。

在喝了凌晨三点的NW浑浊IPA(天呐,我想念NE的啤酒了)的同时,我刷了一下推特,发现最新的框架之争又开始了。评论区果然热闹起来:

“AI编程助手们不能提供稳定的代码!”

框架就是用来让系统更简单的改来改去的!

“这在团队环境中绝对行不通!”

"这能规模化吗?"

听着,我不是来参与框架之争的。但看着开发者们死守他们的框架信仰,让我想起了我们在React代码库中发现的一些现象。

当大家都在争论框架是否有必要存在时,我却在想我们是不是正确地用了手头的框架。因为我让AI审查了我们对hooks的使用后,它揭示了关于我们如何使用hooks的一些情况。

这看起来真不怎么样。更讽刺的是,实际上恰恰证明了框架本身没有问题——我们才是问题。我们错用了 React 的功能,实际上影响了代码的可维护性和团队协作。

大钩大检

想象一下:凌晨三点。我正在让我的AI帮忙优化一个特别慢的React组件。类似于:

    function ProductList({ products, category, search, sortOrder }) {  
     // 按类别筛选  
     const filteredByCategory = useMemo(() => {  
     console.log('按类别筛选');  
     return category   
     ? products.filter(p => p.category === category)   
     : products;  
     }, [products, category]);  

     // 按搜索筛选  
     const filteredBySearch = useMemo(() => {  
     console.log('按搜索筛选');  
     return search  
     ? filteredByCategory.filter(p =>   
     p.name.toLowerCase().includes(search.toLowerCase()))  
     : filteredByCategory;  
     }, [filteredByCategory, search]);  

     // 排序产品  
     const sortedProducts = useMemo(() => {  
     console.log('排序产品');  
     return [...filteredBySearch].sort((a, b) => {  
     if (sortOrder === 'price-asc') return a.price - b.price;  
     if (sortOrder === 'price-desc') return b.price - a.price;  
     if (sortOrder === 'name-asc') return a.name.localeCompare(b.name);  
     return b.name.localeCompare(a.name); // 按名称降序  
     });  
     }, [filteredBySearch, sortOrder]);  

     return (  
     <div className="product-list">  
     {sortedProducts.map(product => (  
     <ProductCard key={product.id} product={product} />  
     ))}  
     </div>  
     );  
    }

克劳德看了一会这些内容,然后问:“你有没有测试过这些 useMemo 调用,看看它们是否真的提高了性能?”

“好吧,不是的,”我承认,“但是想要做这件事的那位JR说你应该把那些计算成本高的部分记忆化。”

“这些计算到底要多少钱?”克劳德问道:“”

“嗯……他们在筛选数组,所以……挺贵吧?”

泰勒·德伦的“斗长”开始渗入体内了。

useMemo 优化探究

克劳德建议咱们添加一些时间戳,看看实际在发生什么。

function ProductList({ products, category, search, sortOrder }) {  
 // 根据类别筛选产品  
 const filteredByCategory = useMemo(() => {  
 console.time('category-filter');  
 const result = category   
 ? products.filter(p => p.category === category)   
 : products;  
 console.timeEnd('category-filter');  
 return result;  
 }, [products, category]);  

 // 更多其他过滤器的计时……  
}

我们在开发模式下运行此程序,使用包含500个产品的现实数据集。结果如下所示:
- category-filter: 0.3 ms
- search-filter: 0.2 ms
- sorting: 0.5 ms

过滤和排序总共用时大约是1毫秒。

“所以,”克劳德平静地说,“我们用useMemo来缓存那些仅需1毫秒的操作,但React的缓存系统本身也有开销,这并不是免费的。它需要存储旧值,检查依赖数组,还要维护缓存。

异形生物开始感觉到了一阵灼热感。

钩子恐怖秀

事情变得更糟了。Claude 要求看看我们更多的部件,并发现我们用的是:

    // 记忆化一个简单的字符串拼接  
    const displayName = useMemo(() => {  
     return `${firstName} ${lastName}`;  
    }, [firstName, lastName]);  
    // 记忆化一个简单的条件检查  
    const isValid = useMemo(() => {  
     return email.includes('@') && password.length >= 8;  
    }, [email, password]);  
    // 记忆化一个布尔检查  
    const isAdmin = useMemo(() => {  
     return user?.role === 'admin';  
    }, [user]);

“你有没有想过,你用的那个复杂的 React 钩子,它有依赖跟踪、记忆化和缓存管理功能,只是为了判断一个字符串里是否有 @ 符号?”克劳德温柔地说。

酸性的血液开始侵蚀那层象征的地板,穿透了下去。

The useCallback 坑

然后我们来看看 useCallback。我们的代码库中到处都是这种情况。

[JK: ,别用 useCallback]

    const handleClick = useCallback(() => {  
     console.log('点击了按钮!');  
     setIsOpen(!isOpen);  
    }, [isOpen, setIsOpen]);  
    const handleChange = useCallback((e) => {  
     setInputValue(e.target.value);  
    }, [setInputValue]);  
    const handleSubmit = useCallback((e) => {  
     e.preventDefault();  
     onSubmit(inputValue);  
    }, [inputValue, onSubmit]);

“我来问你个问题,”克劳德说,“这些组件频繁地重新渲染,这是否已经成了你的瓶颈?”

不过最好别去搞新的函数。

真的吗?你有没有衡量过影响呢?

我也没做过。也没人做过。我们只是看过一篇关于React优化的文章,就开始把所有东西都放进钩子里面去了。

大家可能忽略的真实优化

克劳德给我看了这些过度使用钩子的替代方法后,我忽然明白了。

“等会儿,”我说,“为什么我们要在这个组件里优化过滤和排序?不论是使用useMemo还是不使用,我们还是在客户端做所有这些工作。”

克劳德笑了笑,意思是“我在想你什么时候会注意到”。

真正的问题不在于我们是如何过滤和排序的,而是我们根本在客户端做这样的处理。实际的解决方案更像是:

    // 在我们的API调用或数据层里  
    function fetchProducts(filters) {  
     return api.get('/products', {  
     params: {  
     category: filters.category,  
     search: filters.search,  
     sortBy: filters.sortOrder,  
     // 其他过滤器参数  
     }  
     });  
    }  
    // 然后我们的组件就变得很简单了  
    function ProductList({ products }) {  
     return (  
     <div className="product-list">  
     {products.map(product => (  
     <ProductCard key={product.id} product={product} />  
     ))}  
     </div>  
     );  
    }  
    // 父组件或数据钩  
    function ProductPage() {  
     const [filters, setFilters] = useState({  
     category: null,  
     search: '',  
     sortOrder: 'name-asc'  
     });  
    const { data: products, loading } = useQuery(  
     ['products', filters],  
     () => fetchProducts(filters)  
     );  
    if (loading) return <Loading />;  
    return (  
     <>  
     <FilterControls filters={filters} onChange={setFilters} />  
     <ProductList products={products} />  
     </>  
     );  
    }

“如果我们不能改API怎么办?”我问。

“即使这样,也将过滤与排序的逻辑移到单独的一层,”Claude建议说。

    // 在独立的数据服务中
    function productService() {
     // 过滤结果缓存
     const cache = new Map();
     return {
      async getFilteredProducts(filters) {
       const cacheKey = JSON.stringify(filters);

       // 先检查缓存
       if (cache.has(cacheKey)) {
        return cache.get(cacheKey);
       }

       // 如果需要,获取所有产品数据
       const products = await fetchAllProducts();

       // 应用过滤
       let result = products;

       if (filters.category) {
        result = result.filter(p => p.category === filters.category);
       }

       if (filters.search) {
        result = result.filter(p => 
        p.name.toLowerCase().includes(filters.search.toLowerCase()));
       }

       // 进行排序
       if (filters.sortOrder) {
        result = [...result].sort(/* 排序方式 */);
       }

       // 缓存筛选结果
       cache.set(cacheKey, result);
       return result;
      },

      invalidateCache() {
       cache.clear();
      }
     };
    }

重点不在于你具体使用哪种模式——而是应该将数据转换与渲染分开,React 组件应该主要负责渲染,而不是数据处理。

不论是后端 API、微前端架构,还是应用内的数据层,原则都是一样的:在合适的抽象层次上进行数据转换,而不要在渲染函数里做。

我们过于专注于如何优化React组件,以至于忘记了去想这些任务是否真的应该由它们(这些组件)来完成。

useEffect 爆炸

注:此注释应移除,直接保留原文直译。若需解释,应置于脚注或原文之外的注释中。

如果需要保留技术准确性,并确保技术社区理解,可以将 "useEffect" 保持原样,或在括号中做简要解释。因此,翻译应为:

useEffect 爆炸

useEffect 问题

useEffect 惊现

根据上下文调整为更合适的表述。

最后,我们终于来到了最令人害怕的部分:useEffect。

    function Dashboard() {  
     const [user, setUser] = useState(null);  
     const [posts, setPosts] = useState([]);  
     const [comments, setComments] = useState([]);  
     const [likes, setLikes] = useState([]);  
     const [followers, setFollowers] = useState([]);  

     // 加载用户信息  
     useEffect(() => {  
     fetchUser().then(setUser);  
     }, []);  

     // 在用户变化时加载帖子  
     useEffect(() => {  
     if (user) fetchPosts(user.id).then(setPosts);  
     }, [user]);  

     // 帖子更新时加载评论  
     useEffect(() => {  
     if (posts.length) fetchComments(posts.map(p => p.id)).then(setComments);  
     }, [posts]);  

     // 用户或帖子变化时,加载点赞  
     useEffect(() => {  
     if (user && posts.length) fetchLikes(user.id, posts.map(p => p.id)).then(setLikes);  
     }, [user, posts]);  

     // 依赖关系的级联就这样继续下去…  
    }

克劳德盯着这段代码看了好一会儿。

“你基本上重新发明了一个复杂的异步状态机架构,”实际上,“但它没有提供任何安全保证,也没有清晰的状态管理流程。每个效果都会触发下一个,形成一个脆弱的依赖链,让人难以理清其逻辑。”

这里有更好的办法。

    // 使用一个合适的数据加载库
    function Dashboard() {
     // 获取用户信息
     const { data: user } = useQuery(['user'], fetchUser);

     // 获取用户后,获取帖子
     const { data: posts = [] } = useQuery(
     ['posts', user?.id],
     () => fetchPosts(user.id),
     { enabled: !!user }
     );

     // 获取帖子后,再获取评论
     const { data: comments = [] } = useQuery(
     ['comments', posts.map(p => p.id)],
     () => fetchComments(posts.map(p => p.id)),
     { enabled: posts.length > 0 }
     );

     // 其余组件可以只专注于渲染,不必关心数据获取
    }

[at 这时候我开始想为什么我还要做代码审阅 (可以听一下 Blur 的 “There’s No Other Way”)]

或者更干脆一点,把这些都移到你的数据层,这样你的组件就可以保持整洁了。

    // 在你的数据层中  
    async function fetchDashboardData(userId) {  
     const user = await fetchUser(userId);  
     const posts = await fetchPosts(user.id);  
     const comments = await fetchComments(posts.map(p => p.id));  
     const likes = await fetchLikes(user.id, posts.map(p => p.id));  

     return {  
     user,  
     posts,  
     comments,  
     likes  
     };  
    }  
    // 在你的组件内部  
    function Dashboard({ userId }) {  
     const { data, loading } = useQuery(  
     ['dashboard', userId],  
     () => fetchDashboardData(userId)  
     );  

     如果正在加载则返回<Loading />;  

     返回 (  
     <div>  
     <UserProfile user={data.user} />  
     <PostList   
     posts={data.posts}   
     comments={data.comments}   
     likes={data.likes}   
     />  
     </div>  
     );  
    }
服务器端的牺牲

哦,等一下,我差点忘了最好的一个。上周,我偶然发现一个开发者,他认真地用 useMemo 包裹了一个计算,放在一个 React 服务器组件内部。没错,你没看错——一个服务器组件,在那里连钩子根本不起作用。

    // 在一个明确标注了 'use server' 的文件中
    function ProductServerComponent({ productId }) {
     // 这位开发者担心在服务器上运行的组件会频繁重新渲染……
     const formattedId = useMemo(() => {
     // 这里使用 useMemo 优化性能,避免不必要的重新渲染
     return `PROD-${productId.toString().padStart(5, '0')}`;
     // 将 productId 格式化为 5 位数字,不足部分用零填充,并在前面加上 'PROD-' 前缀
     }, [productId]);

     return <div>{formattedId}</div>;
     // 将格式化后的 id 包裹在一个 div 标签中返回
    }

当我问起时,他们说他们是“在 prop 变化时避免不必要的重新渲染。”

我实在不忍心告诉他们这些:

  1. 服务器组件在服务器上运行,并作为 HTML 发送。
  2. 客户端不会重新渲染它们。
  3. Server Components 中 React 钩子甚至都无法使用。
  4. 如果数据发生变化,整个组件都会被替换。

这就好比有人在自行车上装了汽车报警器,然后把自行车藏在金库里。虽然这样做确实安全,但你对这个问题的理解可能有点偏差。

疾风税(特别版)

哦,顺便也来说说 Tailwind 吧。因为说到“可维护的 CSS”,没有什么比得上:

    <button className="flex items-center justify-center px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out">  
     点我  
    </button>

你本可以直接

    <button className="primary-button">  
     点我  
    </button>

和:

.primary-button {  
    display: flex;  
    align-items: center;  
    justify-content: center;  
    padding: 0.5rem 1rem;  
    background-color: #3b82f6;  
    color: white;  
    font-weight: bold;  
    border-radius: 0.25rem;  
    transition: background-color 150ms ease-in-out;  
}  
.primary-button:hover {  
    background-color: #1d4ed8;  
}  
.primary-button:focus {  
    outline: none;  
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);  
}

这里展示了如何使用CSS为按钮设置样式。.primary-button 类设置了按钮的基本样式,包括布局、背景颜色、文字颜色等。当鼠标悬停在按钮上(.primary-button:hover)时,背景颜色会变为更深的蓝色。当按钮获得焦点(.primary-button:focus)时,会取消轮廓线,并添加一个模糊的蓝色阴影。

不过我要岔开话题了。不过我要说的是,没有什么比让一个AI来“优化”一个Tailwind组件更令人惊讶的了,结果它生成了一个如此之长的类字符串,以至于你的代码检查器崩溃,代码格式化器也失去了方向。“你在给按钮添加样式,还是在用实用类写《战争与和平》?”有一次,AI助手这么问我。确实是个好问题。

最后一课

所以是的,AI 代码助手显示框架有时被不当使用。不是因为钩子本身有错,而是因为我们一直在不假思索地使用它们——每个函数都用 useCallback 包装,每次计算都用 useMemo 包裹,并导致一团复杂的 useEffect 依赖关系,没人能理清。

更令人惊讶的是,AI 并不在乎 React 最佳实践。它不在乎:
— 关于优化的 Medium 博文
— 关于钩子模式的 Twitter 话题讨论
— 你的 memoization 策略的社会地位

我见过一些你这些开发者无法想象的事情……在猎户星座肩部附近燃起大火的攻击飞船,像镁一样明亮。我见过C光束在坦纳霍尔之门附近闪耀。所有这些时刻都将消失在时间中,就像雨中的泪水……就像你那些低效的React组件中不必要的重新渲染。

AI只是看看实际发生了什么,然后问:“这真的有用吗?”

这里我要同意那些框架支持者所说的,而不是他们所想的方式。他们总是大喊“维护性怎么办?”和“团队环境怎么办?”好像简单的代码就不可维护似的。

但哪个更容易维护?
- 一个能在1毫秒内完成过滤和排序的单一函数?
- 还是同一个函数被包裹在三层记忆化钩子中,未来的开发者需要解开这些钩子?

哪一个更能适应变化的需求的是:
- 一个清晰的数据变换层,负责所有过滤和排序逻辑
- 还是分散在各个组件中的十几个相互依赖的useEffect钩子?

如果你担心那些迟迟拿不定主意的客户很烦人,或许一开始就可以避免把业务逻辑直接嵌入到React组件里。

还有那些抱怨“团队环境中怎么办?”的人——你们听说过CHANGELOG.md文件吗?或者注释?或者实际的文档?如果你们的团队在没有框架强制遵循某种模式的情况下,无法理解简单的函数和干净的抽象,那么你们的问题比优化钩子要严重得多。一个好的抽象加上详细的文档,在任何时候都比一个包裹在钩子中的糟糕抽象要好得多。

记住:
- 不是每个函数都需要 useCallback
- 不是每个计算都需要 useMemo
- 数据转换最好放在组件外部
- 不要在错误的层次上优化

毕竟,我们最终的目标都是做出互动的用户界面。有些人更倾向于不去假装每个组件都需要如此严格的性能优化,以至于需要七层缓存。

这篇帖子是由一只混沌猴因为在钩子引发的精神混乱期间撰写而成的。在写这篇帖子时,没有伤害到任何React开发者——虽然他们的自尊心可能受到了一些打击。

关于作者:一位拥有数十年经验的认证混沌大师,专长于搞砸生产系统,现担任您的 React 简化咨询™ 公司的减少钩子服务主管。搞砸的生产系统数量多得数不清。这篇文章可能是这位AI助手帮忙写的,也可能是作者自己写的。

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP