【前端面经】2023面试复盘之小红书

6,189 阅读12分钟

相关链接:
一图搞定前端面试之基础篇
【前端面经】2023面试复盘之美团
【前端面经】2023面试复盘之字节跳动
【前端面经】2023面试复盘之阿里云
【前端面经】2023面试复盘之蚂蚁金服
【前端面经】2023面试复盘之快手

结论

✅通过

久闻小红书大名,这也是自己第一次面小红书。整体感觉跟其他家相比,小红书的面试官确实更加年轻(除了年龄上之外,资历上也是这样),这应该跟小红书本身的招人风格有关吧,听说低级别是不招30岁以上的,还蛮夸张。

问的问题像是面三年经验的候选人,能略微窥见小红书里面应该没有很多技术特别强的人(个人瞎感觉,接受🍠喷我)。后来问了一下其他去过的朋友,也差不多是这样。不过在这样的团队中,对于已经有一些工作经验的候选人来讲,还是有很多的机会和发展空间的,毕竟目前互联网中保持高速增长的中大厂也没有几个了。

一面

总时长:50min

小红书的一面相对来说比较轻松,前半个小时依然是聊项目讲经历,后面问了两个技术问题,都是React相关的,我对这个还算熟悉就尽可能讲的完善一点,讲完之后就没有更多的问题了做了一道代码题,也比较简单,5分钟内就结束了,后面应该就等二面通知了。

介绍项目

分别从技术角度和业务角度介绍了一下最近一段工作经历中的项目,主要还是我自己去讲,包含项目中的重点和难点、解决重难点的一些技术细节、项目的业务价值以及如何衡量等。

整体讲的时间还是一如既往的在半小时左右,后面就是问了两个技术问题并做了一道算法题。

介绍一下React的合成事件

先讲了一下React合成事件的优势:

  1. 抹平不同浏览器直接的差异,提供统一的API使用体验
  2. 通过事件委托的方式统一绑定和分发事件,有利于提升性能,减少内存消耗

之后详细说了一下合成事件的绑定及分发流程:

  1. React应用启动时,会在页面渲染的根元素上绑定原生的DOM事件,将该根元素作为委托对象
  2. 在组件渲染时,会通过JSX解析出元素上绑定的事件,并将这些事件与原生事件进行一一映射
  3. 当用户点击页面元素时,事件会冒泡到根元素,之后根元素监听的事件通过dispatchEvent方法进行事件派发
  4. dispatchEvent会根据事件的映射关系以及DOM元素找到React中与之对应的fiber节点
  5. 找到fiber节点后,将其绑定的合成事件函数加到一个函数执行队列中
  6. 最后则依次执行队列中的函数完成事件的触发流程

介绍一下React的patch流程

一开始面试官说让讲一下批处理,我还一脸懵,又确认了一下发现是React的更新流程。也是抱着举一反三的态度,完整的讲了一下React的渲染流程。

  1. React新版架构新增了一个Scheduler调度器主要用于调度Fiber节点的生成和更新任务
  2. 当组件更新时,Reconciler协调器执行组件的render方法生成一个Fiber节点之后再递归的去生成Fiber节点的子节点
  3. 每一个Fiber节点的生成都是一个单独的任务,会以回调的形式交给Scheduler进行调度处理,在Scheduler里会根据任务的优先级去执行任务
  4. 任务的优先级的指定是根据车道模型,将任务进行分类,每一类拥有不同的优先级,所有的分类和优先级都在React中进行了枚举
  5. Scheduler按照优先级执行任务时,会异步的执行,同时每一个任务执行完成之后,都会通过requestIdleCallBack去判断下一个任务是否能在当前渲染帧的剩余时间内完成
  6. 如果不能完成就发生中断,把线程的控制权交给浏览器,剩下的任务则在下一个渲染帧内执行
  7. 整个ReconcilerScheduler的任务执行完成之后,会生成一个新的workInProgressFiber的新的节点树,之后Reconciler触发Commit阶段通知Render渲染器去进行diff操作,也就是我们说的patch流程

React的Diff算法可以分为单节点Diff多节点Diff,其中单节点Diff相对简单,包含以下流程:

  1. 首先会判断老的Fiber树上有没有对应的Fiber节点,若没有则说明是新增操作,直接在老Fiber树上新增节点并更新DOM
  2. 若老Fiber节点也存在,则判断节点上的key值是否相同,若不同则删除老节点并新增新节点
  3. key值相同,则判断节点的type是否相同,若不同则删除老节点并新增节点
  4. type值也相同,则认为是一个可复用的节点,直接返回老节点就行

多节点的Diff操作主要用于map返回多个相同节点的情况下,可以分为三种情况:新增节点、删除节点以及节点移动,React采用双重遍历的方式来进行三种情况的判断,流程如下:

  1. 第一轮遍历会依次将 children[i] 和 currentFiber 以及 children[i++] 和 currentFiber.sibling 进行对比,当发现节点不可复用时提前结束遍历
  2. 当第一轮遍历无提前结束时,说明所有节点都可以复用,直接返回老节点
  3. 若children遍历完成,currentFiber未完成,则说明是删除操作,需要对未完成的 currentFiber 兄弟节点标记删除
  4. 若children遍历未完成,currentFiber完成,则说明是新增操作,需要生成新的workInProgressFiber节点
  5. 若children和currentFiber都未完成,则说明是节点位置发送了变更,那就对剩余的currentFiber进行遍历,并通过key值找到每一个节点在children中对应的老节点,并将老节点中的位置替换为新节点的

【代码题】查找多个字符串中最长公共前缀

样例输入:strs = ['abcdef', 'abdefw', 'abc']
输出:'ab',若没有找到公共前缀则输出空字符串

const findCommonPrefix = arr => {
    let str = '';
    const n = arr.map(item => item.length).sort()[0];
    for (let i = 0; i < n; i++) {
        str += arr[0][i];
        if (arr.some(item => !item.startsWith(str)) {
            return str.slice(0, str.length - 1);
        }
    }
    return str;
}

二面

总时长:75min

二面的话基本是一直围绕着项目在讲,问的问题比较散,最后面完能记住的问题也不多,整体感觉是比较平淡的一场面试,如果不出意外的话应该也是能过,但是不清楚面评会不会好。

小程序性能优化做了哪些事情

先讲了一下小程序的架构和渲染原理,阐述小程序性能的影响因素,之后则介绍对应的性能优化手段有哪些。
主要包含以下:

  1. 使用小程序原生语法而不是类React或者Vue框架
  2. 减少setData次数,同时优化setData的数据量大小
  3. 请求预加载,重写路由方法,将下一个页面的请求提前到路由方法里调用
  4. 减少wxml的嵌套深度和节点数量,同时对wxss相同样式做合并处理
  5. 一些常规的优化手段:骨架屏、首屏数据缓存、分包、子包预加载、首屏接口合并、懒加载等方式

请求库是怎么实现的

整体逻辑比较简单,就是基于XMLHttpRequest对象进行了Promise封装,同时最核心的内容为插件机制的实现,这部分借鉴了koa的中间件,通过requestPluginsresponsePlugins分别对请求和响应维护一个插件执行队列,所有的自定义及扩展都通过编写插件实现。
为了使用起来更加方便,也提供了装饰器的使用方式,支持对某个请求方法单独设置插件,来实现单个请求的特殊定制。具体实现可以查看github上的源码 GitHub - helianthuswhite/RestClient: Flexible HTTP Client.

为什么要自己去做一个请求库

有以下几个点,分别介绍了为什么需要一个请求库以及为什么自己从零做了这个请求库:

  1. 业务技术栈升级,需要引入一套与框架无关的前端请求库
  2. 该请求库需要满足几十个不同的产品同时使用,接入方式需要足够简单,同时能够支持各个产品线的定制化需求
  3. 该请求库能够提供丰富的扩展能力,并通过拔插的方式提供一些统一规范的请求处理,如短信验证、异常捕获、失败重试等
  4. Axios通过API调用请求与我们期望的Class方式不一样,同时它当时并不支持插件(虽然现在支持了),值提供了hooks钩子函数的方式进行定制,这种定制方式在需要部分统一同时各个业务线又有差异的情况下比较难处理和维护

Sketch插件是怎么实现的

通过插件的开发语言cocoascript和其中的webview的方式来实现的。为了降低开发成本,插件面板内的所有内容都是在webview中去开发前端页面。当从面板中拖拽某个图标或组件出来时,通过message将组件的信息传递到插件原生中,之后通过cocoascript开发sketch文件的查找和放置画板的逻辑。

为了简单实现,我们在插件安装的时候内置了符合业务规范的sketch组件包,因此面板中的每个组件都有唯一标识与sketch组件一一对应。最后导出页面时同样是对画板中模板和组件进行遍历,找到对应的组件代码并进行组装。

小程序的日志和监控怎么做的

分为两部分,第一部分为日志的埋点和上传,包含了代码日志和业务日志。主要是自己实现了一个基于WebSocket的日志服务,在客户端项目加载的时候启动WebSocket,然后通过提供的log方法在代码中去进行日志打点。服务端收到上传的日志之后传入到公司内的数仓,之后通过数仓的API实现日志的查询。
对于业务数据的埋点则是在对应的用户操作时进行埋点上报。

另一部分则是告警的实现,这部分利用公司的统一基础设施去做。在拿到前面的埋点信息之后,在公司的告警平台可以看到对应的埋点数据的趋势图,根据趋势可以设置告警阈值。告警阈值主要是通过人工去指定告警策略并根据实际情况进行调整和优化,以实现更准确的告警。

如何衡量技术产生的业务价值

可以通过技术迭代前后的业务指标变化来看技术带来了哪些业务价值。以上文说的监控告警为例,我们通过对业务流程制定业务指标(如用户下单支付率 = 用户最终支付的埋点数 / 用户下单的埋点数),在技术改造前后可以观察业务指标的变化情况。当技术改造上线之后通过大盘可以看出业务指标有正向提升,那么就能够说明此次的技术改造能够产生明确的业务价值。

但也有很多没办法直接量化的技术价值,就需要对整个技术到业务的流程进行拆解,先看技术对其上游产生了多少的价值,再由上游往其上游推断产生了多少价值,最后得到整个业务产生了多少价值。

三面

总时长:70min

三面我理解正常应该是二面的 +1,但是小红书这里却是其他部门的交叉面,问的问题跟前面也都差不多,但是最后那个算法题没有完全写对,感觉有点不太稳🤦‍♂。

聊项目

聊项目这一块跟前面的都类似,这里就不多赘述了。

【代码题】字符串解码

样例输入:s = "3[a2[c]]"
样例输出:accaccacc

//  我当时的写法记不起来了,这里就给出下来看了题解之后的正确写法吧,主要是用栈去实现
function decodeString(s) {
    const stack = [];
    let numStr = '';
    let i = 0;

    while (i < s.length) {
        //  判断是否是数字
        if (!isNaN(+s[i])) {
            numStr += s[i];
        } else {
            //  考虑多位数字的情况
            if (numStr) {
                stack.push(numStr);
                numStr = '';
            }

            //  当遇到右括号的时候执行出栈的逻辑
            if (s[i] === ']') {
                const temp = [];
                //  这里简单处理不考虑异常输入的情况
                while (true) {
                    const current = stack.pop();
                    //  如果出栈的时候遇到左括号就用前一个值计算当前字符串
                    //  并且把当前字符串作为一个新的值入栈(即把多层嵌套解析后的值作为一个新的字符串整体考虑)
                    //  这里字符串链接的时候需要注意逆序一下,不然顺序会反
                    if (current === '[') {
                        const num = +stack.pop();
                        const tempResult = Array(num).fill(temp.reverse().join('')).join('');
                        stack.push(tempResult);
                        break;
                    } else {
                        temp.push(current);
                    }
                }
            //  其他情况直接入栈即可
            } else {
                stack.push(s[i]);
            }
        }
        i++;
    }

    return stack.join('');
}