背景
一切的一切都要从一场惨无人道的面试开始说起。年少的小李怀揣着对职场的憧憬,悻然的来到一家高大上的互联网公司面试求职,他所面试的岗位是「前端开发工程师」。面试前小李已经熟读前端八股文、react 和 vue 的各种大法,然而他不知道的是,一个阴谋的老油条面试官「老余」将要摧残这朵即将踏上职场的小花骨朵。
初入江湖
面试官「老余」看了看小李的简历说道:『看你的项目简历,你对 react 和 vue 比较熟悉,那这样吧,你手写一个响应式,满足以下条件』
1 | javascript复制代码var obj = { a: 1 }; |
看到这个面试题,小李内心十分开心,这不就是网上经常可以搜到的面试题吗。核心点就是 Object.defineProperty
这个 API,它能够拦截对象的 get、set
操作
Object.defineProperty 可以拦截对象的 set 和 get 操作,支持三个参数
- target 源对象
- key 需要操作的 key
- config
- get 、 set 拦截器
- value 属性值
- enumerable 是否可枚举
- writeable 是否可修改属性的值
- configurable 是否可删除、可修改属性特性
于是小李三两下就完成了以下的 coding
1 | javascript复制代码let value; |
问题百出
面试官「老余」看着小李写完的代码,不经眉头一皱,十分生气的说道:『你写的什么乱七八糟的东西,为什么 set 拦截器
里写死了执行 fn
,而且我需要支持所有属性都能响应式。你令我有点失望,重新写吧』。小李重新审视了下题目,的确发现问题百出。针对面试官的第二个问题(支持所有属性都能响应式),直接遍历就可以了。但是第一个问题(如何何知道哪个函数依赖当前对象)该如何解决呢?
这时小李灵光一闪 JS 是单线程的,也就是说同一时间只可能有一个函数在运行,所以我只要给函数封装下就可以了,于是便有了下面的代码👇
1 | javascript复制代码let preObj = {}; |
步入险境
面试官老余看完代码后说道:『的确能满足我的需求,但是还是有很多问题。首先你没有解决数组变异问题、其次你的代码不支持深度对象』
Tips: 数组变异是指,数组的某些方法调用后会修改原数组。例如
push
方法
听完后,小李有些紧张,看来碰到老司机了。解决深度对象还好,用个递归就行了。但如果要解决数组变异有两种方式:
- 重写数组的所有变异方法,在变异方法内对数组再次进行响应式监听
- 使用 Proxy 这个新特性
Proxy 和 Object.definePropery 区别
- Proxy代理整个对象,Object.defineProperty只代理对象上的某个属性
- Proxy不兼容IE,Object.defineProperty不兼容IE8及以下
- Proxy 天然支持数组的操作,Object.defineProperty 需要一些骚操作
- Proxy 只是代理原对象,而 Object.defineProperty 会修改原对象
权衡之下,小李决定用 Proxy
来实现响应式,见如下代码
1 | javascript复制代码const isObj = (_) => _ && typeof _ === "object"; |
这里使用了 WeakMap,那么 WeakMap 和 Map 以及 对象有什么区别呢?
- Map 和对象的区别:
- 对象:对象的 key 必须是单一类型,并且只能是整数、字符串或是Symbol类型、对象不报序
- Map: Map 的 key可以为任意数据类型(Object, Array等)、保留所有元素的顺序
- WeakMap 和 Map 的区别:
- Map: key 可以是任意类型、可以遍历
- WeakMap: key 只接受对象作为键(弱引用,key 所指的对象被 gc 回收后,key 便无效)、不可遍历
适配框架
不知不觉时间已经过去 10 分钟了,小李自信的把代码交给面试官看。面试官老余仔细的审查之后说:『小伙子不做,这么快就能满足我的需求了,但是呢,我们公司主要是用 React 技术栈的,如果我想在 React Hooks 组件里使用这个响应式该如何去实现呢?』。小李木讷的看着面试官油光发亮的脑门,心里嘀咕着『我算是要栽在你手里了,没办法了只能上了』。小李思考了下:如何在 React 里使用其实不难,只需要把 React 组件当成一个函数就行了,于是便有了以下代码
注意:响应式的代码没有改动,只是新增了一个 watch 高阶组件
1 | javascript复制代码import React, { useEffect, useRef, useState } from "react"; |
使用方式同 redux saga dva
等框架提供的高阶组件类似
精益求精
面试官老余看着小李的代码心想『这小伙子可以呀,不一会就写出来了。但是不行我必须要难倒他』,于是老余就说:『代码是可以的,但是不够完善。还是很多问题,你看看该怎么解决?』
问题:
- 1、如果是组件里面引用了子组件,你该如何正确的知道是哪个组件依赖了当前对象的,另外当对象更新时如何做到只更新对应的子组件
- 2、如何在组件或者函数销毁时取消依赖
- 3、对象同步更新后,如何将组件的批量更新转为单次更新
这几个问题如晴天霹雳打在小李的头上,为了得到工作早日迎娶白富美走上人生巅峰。小李没办法只能硬抗了。思考了几分钟之后便有了一个初步的方案:
解决方案:
- 1、通过堆栈的形式对 runner 进行改造
- 2、增加一个 WeakMap 用来计算 runner 和响应式对象的依赖,组件销毁时通过这个 map 反向删除依赖
- 3、维护一个任务队列,异步进行处理
更新后的代码如下(代码太多,这里只针对以上问题展示差异代码)
1 | javascript复制代码export const makeProxy = (proxyTarget) => { |
1 | javascript复制代码// run.js 用来处理运行函数的模块 |
1 | javascript复制代码class Queue { |
极致性能
全部完成后,小李洋洋得意的把最终 coding 代码交给面试官,这时整体的响应式以及和 React 组件的交互都已经初步成形了。面试官老余端详后心里又开始想:『看来小伙子有把刷子,容老夫看看还有哪里可以刁难一下他的』,思考片刻后,面试官老余发话了:『虽然你完成了我交给你的 coding 任务,但是你缺乏进一步的🤔思考。对细节点的把控有所欠缺。你要学会举一反三。否则我看不到你的亮点。比如说吧,在你的代码基础上,如何解决以下问题呢?』
- 1、如何避免响应式里的重复收集依赖问题?
- 2、如何做到响应式按需收集依赖(并不是在开始时就就深度遍历)?
- 3、异步队列的渲染时机是最好的时机吗?
- 4、Proxy 性能比 Object.definePropery 性能好吗?一定要用 Proxy 吗?
- 5、你的代码里限制了只能对 object 类型进行响应式,那对普通类型如何实现呢?
『当然还有很多其他问题了,我就不一一举例了。我希望看到你对这些问题的思考』
听完这番话后小李陷入了深思 『我擦,看来遇到 PUA 之神,就这么一会儿功夫很难完成呀!得拿出看家功夫了』,小李思考后便针对以上问题一一回复了:
- 1、通过 Map 来判断当前的 key 是否已经收集过依赖,避免重复收集
- 2、开始不递归、在触发 get 时,再对 key 对应的 value 进行深一层的响应式初始化
- 3、Promise 换成 RAF
- 4、Proxy 性能差一点,但是适配性好
- 5、可以通过编译时将普通变量转为对象形式,在使用变量时自动拓展 key。(可以配合开发一个 babel 插件)
1 | javascript复制代码// 源码 |
- 这里说下 Promise 换成 RAF 的思考,我们知道 JS 是单线程的,Promise 是微任务,如何把更新队列的触发放在 Promise 里,还是会有一定概率出现重复渲染的。而 RAF 是在 UI render 发生之前微任务之后执行的,这个地方更合适些,可以参考事件循环的官方介绍 HTML5 事件循环介绍
- 关于 Proxy 和 Object.definePropery 性能比较:同样是拦截
get set
,Proxy 性能稍微差点,但是差距不大。但是 Proxy 的适配性更好,这也是为什么 Vue3 里把 Object.definePropery 换成了 Proxy
用一张简单概述下 EventLoop
最后小李把思考后的所有代码都进行了更新,由于篇幅有点长,就直接贴出地址:最终版响应式
巅峰造极
上面的面试基本就结束了,但还是有很多细节点可以深入探索,比如以下几点:
- 1、React 组件如何合并渲染(当响应式触发的重新渲染和组件自身的重新渲染重叠时如何优化)?
- 2、并发渲染队列?
- 3、编译时 & 运行时?
合并渲染
结论是很难做到,除非你去调用 Scheduler Update 队列去合并渲染。用一张图阐述下 React 更新时机。
1 | javascript复制代码export default App() { |
图中灰色框框里的阶段会随时被中断、重复执行(由于是在内存中的,所以不会影响真实 DOM)。这样的话我们没办法通过简单的判断 Reconciler 执行的时机,如果想要将它和响应式触发的重新执行合并的话,就得往数据层上着手了
当响应式数据发生改变时,将更新函数设置到 queue 之后,如果同样的 target key 在 queue 执行前被访问了,那么这次 target key 对应的更新函数就直接从 queue 里移除。但是这样方式也不能百分百保证渲染合并
React 渲染队列
上面的代码里没有考虑到 Hooks 里使用异步代码的情况,如果在异步操作没有返回之前,由于响应式数据的改动造成了一次 hook 的重新执行,这时多个异步的竞态可能会引发出意料之外的问题。所以一个比较好的思路是在 watch
里维护一个 Hooks 的更新队列,用来存放由于响应式改变而产生的需要 Hook 重新执行的 update 函数。并且这个更新队列需要放在 Hooks 自身导致的执行之后再执行,那么问题又来了,这个任务队列执行的时机放在何时比较何时呢?
- Promise
- setTimeout
- RAF
- getSnapshotBeforeUpdate
- useLayoutEffect
- useEffect
编译时 & 运行时
上述的代码都是运行时的,是否可以将部分代码通过 babel 等插件在编译时自动注入,从而让开发更加简洁?
面试结果
面试了将近一个小时,面试官老余说道『好的,今天面试到此结束,你还有什么其他问题想问我的吗?好的没有就算了,走好不送』。小李诧异的走出了会议室,看着老余那迎风飘扬的发际线不经陷入深思。。。
附录
最后的福利
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~
本文转载自: 掘金