likes
comments
collection
share

字节一面,丢了闪现,被拿一血

作者站长头像
站长
· 阅读数 3

如果您也和我一样准备春招,只为TOP20大厂,欢迎加我微信shunwuyu,一起交流面经,一起屡败屡战。

下午五点面完字节,饭也没心思吃,就和室友们一起开了把LOL,把被面试官这个蛮王虐哭的委屈,发泄出来。结果没想一级团,就被隔壁寝室爆锤,交了闪现,队友寒冰还被拿了一血...

前言

这周的投递策略调整为300一天以上实习,可没想, 简历发给BOSS的那一刹那,才发现是字节跳动的商业产品与技术前端岗。就当一个退无可退的英雄赵信吧,闪现也难逃被抓的运命。只要不面的太差,不进黑名单,三个月后还是条好汉,再面之。

BOSS直聘里的这位BOSS, 仿佛蛤蟆卡萨丁,快速将我活吞入肚,丝毫不给半点反应,马上发来了面试安排....

写下此文,记字节跳动拿了我的一血。 感谢信在此,各位烧纸...

字节一面,丢了闪现,被拿一血

面经

1. 实现一个formatNumber 方法, 接受一个数字,返回一个【以千分位分隔】的字符串,要求支持正数、小数、负数等场景

1.1 原生JS实现


function formatNumberWithCommas(number) {
    // 类型检测 
    if (typeof num !== 'number') {
        return '';
    }
    // 先转成字符串
    number += '';
    // 支持小数,按小数点分成两部分  使用了es6解构
    let [integer,decimal] = number.split('.');
    // 封装了doSplit方法 第二个参数isInteger来表示是整数部分还是小数部分
    const doSplit = (num,isInteger = true) => {
        // 如果为空,直接返回
        if(num === '') return '';
        // 如果是整数部分  先按位切割再返转
        // 整数部分数字从右往左数,每3位插入一个逗号
        // 小数部分从左往右数
        // 两次反转,它的逗号顺序是一样的。
        if(isInteger) num = num.split('').reverse();
        let str = [];
        for(let i = 0; i < num.length; i++){
            if(i !== 0 && i % 3 === 0) str.push(',');
            str.push(num[i]);
        }
        if(isInteger) return str.reverse().join('');
        return str.join('');
    };
    integer = doSplit(integer);
    decimal = doSplit(decimal,false);
    return integer + (decimal === '' ? '' : '.' + decimal);
};

1.2 正则表达式

function formatNumberWithRegex(number) {
    return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

1.3 toLocalString

语言中会有不同时区、地区的一些本地化功能,符合格式化数字的需求

function formatNumberWithLocale(number) {
    let [integer,decimal = ''] = (number + '').split('.');
    // 1,234,567 整数部分可通过toLocalString实现
    integer = (+integer).toLocaleString();
    if(decimal === '') return integer;
    // 小数部分另外处理
    decimal = decimal.split('').reverse().join('');
    decimal = (+decimal).toLocaleString();
    decimal = decimal.split('').reverse().join('');
    return integer + '.' + decimal;
}

2. DFS(深度优先)

有如下结构的树,使用DFS遍历

字节一面,丢了闪现,被拿一血

遍历一棵树,我们可以使用深度优先和广度优先,都是面试中的常客。

字节一面,丢了闪现,被拿一血

从上图可以看出,深度优先通过递归深入到树或图的最深层,然后回溯到上一层继续搜索。递归函数可以很好地模拟这种深度搜索过程,使得代码简洁且易于理解。

function dfs(node) {
    // 访问当前节点
    console.log(node.value);

    // 遍历子节点
    for (let child of node.children) {
        dfs(child);
    }
}

// 1 2 3 4 5

我们也把广度优先的代码一起复习下:

const bfs = (root) => {
    const queue = [root];
    
    while (queue.length > 0) {
        // 先出队
        const currentNode = queue.shift();
        console.log(currentNode.value);

        if (currentNode.children) {
            // 进队  
            currentNode.children.forEach(child => {
                queue.push(child);
            });
        }
    }
};
// 1  2  5  3  4

广度优先搜索需要按层级遍历,队列的先进先出(FIFO)特性适合处理这种需求,确保每一层的节点都能按顺序被访问和处理,从而实现广度优先搜索的效果。

所以,本题是大厂考查同学数据结构基本功的热题。只要我们能将DFS与递归,BFS与队列对应理解,那就是一道送分题。

3. 为什么要用JWT(JSON Web Token)?

介绍项目的时候,有聊到jwt生成token登录。这是相对于传统的cookie(客户端+服务器)+session(服务端)来问的,我打算先从cookie+session的登录方式讲起,再说缺点,然后jwt怎么解决了这些问题。

JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。

  • 头部(Header) :包含了关于生成该 JWT 的信息以及所使用的算法类型。
  • 载荷(Payload) :包含了要传递的数据,例如身份信息和其他附属数据。
  • 签名(Signature) 使用密钥对头部和载荷进行签名,以验证其完整性。

cookie + session 登录

字节一面,丢了闪现,被拿一血

在Cookie + Session登录中,用户登录成功后,服务器生成唯一sessionId,存储在用户浏览器的Cookie中。每次请求时,浏览器发送Cookie中的sessionId给服务器,服务器根据sessionId查找对应会话信息,维持用户状态。Session数据通常存储在服务器端,安全性高;sessionId则作为指向对应session数据的标识符,通过Cookie在用户端和服务器端间传递。

JWT 登录

字节一面,丢了闪现,被拿一血

JWT(JSON Web Token)登录原理是用户登录成功后,服务器生成一个包含用户身份信息的JWT,并将其发送给客户端。客户端在以后的请求中携带该JWT,在每次请求中服务器验证JWT的有效性,从而实现用户的身份验证和状态保持。JWT包含了加密签名,因此可以确保数据的完整性和安全性。

为何要用JWT

  • 无需服务器存储状态

    传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等。而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展。

  • 跨域支持

    由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权

  • 适应微服务架构

    在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。

  • 自包含

    JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制

  • 扩展性 JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。

  • COOKIE不安全

Cookie 在传输过程中可能受到跨站脚本攻击(XSS)和跨站请求伪造(CSRF)等安全威胁,而 JWT 通过签名验证确保数据的完整性,防止篡改。此外,JWT 可以使用加密算法对数据进行加密,增强安全性。相比之下,Cookie 存储在客户端的敏感信息可能被窃取,而 JWT 不存储敏感信息,减少了泄霏风险。

cookie、localStorage、token

果然,在聊完JWT的概念和代码流程后,面试官继续追问。但其实追问也是有套路的,我在面试准备的时候,JWT相关题就有思考顺势聊localStorage,cookie等,所以这道题很轻松。

特点CookieLocalStorageToken
存储位置存储在客户端,通过HTTP请求发送给服务器存储在客户端,仅在客户端使用存储在客户端,作为身份验证令牌
大小限制每个Cookie大小通常限制为4KB存储容量通常为5MB长度没有严格限制
跨域受同源策略限制受同源策略限制可以跨域发送
自动发送每次请求自动发送给服务器不会自动发送给服务器需要手动添加到请求头或参数中
安全性可能受到XSS和CSRF攻击相对较安全,不易受到攻击通过签名验证确保安全性
使用场景保存会话信息、身份验证等临时数据存储、本地缓存等身份验证、授权令牌传递等
存储有效期可设置过期时间,可以是会话性(当浏览器关闭时过期)或持久性永久存储,除非被清除或手动删除可以设置过期时间,通常用于控制访问权限时效性

csrf 攻击

网络安全是大厂考查的点,面试官顺着JWT追问的第三板斧也砍下来了,还好有准备。

csrf全称跨站请求伪造(Cross-site request forgery),指的是攻击者携带网站cookie,冒充用户请求的攻击行为

与 xss攻击 的区别

xss攻击主要指网站被攻击者利用漏洞恶意注入脚本并执行,是一种注入类攻击;

如果 xss攻击的执行脚本包含请求服务器的行为,其也会携带用户cookie达到冒充用户请求的行为,这时候也属于 csrf攻击,不过我们一般称这样的情况为 xsrf攻击。

举例

假设被攻击的网站是一个在线银行系统,该系统使用基于 Cookie 的身份验证机制来识别用户。攻击者创建了一个恶意网站,并在该网站上放置了一个图片标签,如下所示:

<img src="https://bank.com/transfer?to=attacker&amount=10000" width="0" height="0">

当被攻击者访问恶意网站时,浏览器会自动加载图片,并向 https://bank.com/transfer 发送请求,携带了被攻击者的身份信息和转账参数。由于被攻击者已经在银行系统中登录,服务器会认为这个请求是合法的,并执行了转账操作,将 10000 单位的资金转到攻击者的账户。

这种攻击方式的危险性在于,攻击者利用了用户已经登录的会话状态,绕过了常规的身份验证机制。为了防范 CSRF 攻击,开发者可以采取以下措施:

  1. 验证来源:服务器端可以通过检查请求的来源(Referer)或使用 CSRF Token 来验证请求的合法性。

字节一面,丢了闪现,被拿一血

  1. 同源策略:浏览器的同源策略可以帮助防止其他网站对用户敏感操作的伪造请求。

  2. 使用验证码:在执行敏感操作前,要求用户输入验证码进行二次验证,降低攻击成功的可能性。

  3. 限制权限:降低被攻击者账户的权限,特别是对于敏感操作。

有没有用过原生JS进行开发

开发的时候不是VUE就是REACT, 有点懵。给面试官怎啥绝活啊?我这么回答的:

  • 开发的时候用VUE/REACT比较多,因为虚拟DOM性能更好,所以业余开发基本都是MVVM
  • 写Canvas,比如three.js 实现数据可视化的时候是原生JS
  • 用node写后端的时候
  • 学习vue/axios源码,设计模式的时候用的是原生
  • 写一些hooks函数的时候,比如useRequest
import { ref } from 'vue';

const useRequest = (url) => {
    const data = ref(null);
    const loading = ref(false);
    const error = ref(null);

    const fetchData = async () => {
        loading.value = true;
        try {
            const response = await fetch(url);
            const result = await response.json();
            data.value = result;
        } catch (err) {
            error.value = err;
        } finally {
            loading.value = false;
        }
    };

    return { data, loading, error, fetchData };
};

export default useRequest;


import { defineComponent } from 'vue';
import useRequest from './useRequest';

export default defineComponent({
    setup() {
        const { data, loading, error, fetchData } = useRequest('https://api.example.com/data');

        fetchData();

        return {
            data,
            loading,
            error
        };
    }
});

怎么面试官从JWT的三连问,突然问了这么个问题?这是啥脑回路啊?可能是到饭点了,面试官想不按常理出牌,一记重拳想KO我,还好接的可以。

接下来是关于VUE的三板斧。

vue的优点

  • 模板语法和指令

    提供了直观的模板语法和丰富的指令,易于学习和使用

  • 响应式和数据绑定

    现代MVVM开发方式,比传统DOM编程性能更好,更关注业务

  • Composition API

    相比于选项式API, vue3 提供的Composition API使得代码组织更加清晰

    如果组件逻辑比较复杂,代码行数>100, 那么跟一个状态相关的computed、事件、生命周期就得坐电梯查找,而compostion api 可以把它们放一起。

    比如 vue-eleme-app/src/components/goods/goods.vue at master · cccyb/vue-eleme-app (github.com) 这样的代码用vue3 composition api 就好管理很好。我们可以把相关的状态、生命周期、事件、计算属性等放一起。

  • 单文件组件

    支持单文件组件格式,包含模板、样式和逻辑代码

  • 生态系统和社区支持

    生态系统丰富,拥有大量插件和工具,社区支持良好, 文档可读性良好。

  • TypeScript 支持

    对 TypeScript 有较好的支持

  • Hooks 编程

    VUE3也全面支持hooks编程。

    函数式编程:使用 Hooks 可以将组件编写为纯函数,使组件的逻辑更加简洁、可预测和易于测试

    逻辑复用:Hooks 允许将组件的逻辑进行拆分和复用,通过自定义 Hooks 可以将一组相关的逻辑抽象成可重用的函数。

Vue中的生命周期

组件的生命周期,由各种钩子函数按生成、挂载、更新、卸载等几个阶段构成。

生命周期钩子函数作用
onBeforeCreate在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。
onCreated在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),属性和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el 属性目前不可见。
onBeforeMount在挂载开始之前被调用:相关的 render 函数首次被调用。
onMounted实例已经挂载完成时调用。注意如果根实例挂载到了一个文档内的元素,当 mounted 被调用时 vm.$el 还不可用,因为不是所有子树都被挂载了。
onBeforeUpdate数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁之前。
onUpdated由于数据更改导致的虚拟 DOM 重新渲染和打补丁完成之后调用。
onBeforeUnmount在卸载之前调用。可以用来清理定时器或监听的事件。
onUnmounted在实例被销毁之后调用。
onErrorCaptured当捕获一个来自子孙组件的错误时被调用。
onRenderTracked当一个依赖项在渲染函数中被追踪时被调用。
onRenderTriggered当一个依赖项在渲染函数中触发更新时被调用。

vuex 传值的底层原理

防抖(debounce)节流(throttle)的区别

防抖是等待一段时间后执行函数,而节流是设定一个时间间隔,在该时间间隔内只执行一次函数。两者的主要区别在于函数执行的时机和频率。

比如页面滚动事件用节流, 搜索建议/窗口调整大小用防抖。

手写防抖节流

function debounce(fun, delay) {
    return function (args) {
        let that = this
        let _args = args
        clearTimeout(fun.id)
        fun.id = setTimeout(function () {
            fun.call(that, _args)
        }, delay)
    }
}


  function throttle(fun, delay) {
        let last, deferTimer
        return function (args) {
            let that = this
            let _args = arguments
            let now = +new Date()
            if (last && now < last + delay) {
                clearTimeout(deferTimer)
                deferTimer = setTimeout(function () {
                    last = now
                    fun.apply(that, _args)
                }, delay)
            }else {
                last = now
                fun.apply(that,_args)
            }
        }
    }
    

图片懒加载

如果做水平垂直居中

水平垂直居中是常见的布局需求,比如登录弹窗。

方法描述
使用 Flexbox 布局使用 Flexbox 布局可以轻松实现元素的水平和垂直居中。设置父元素的 display: flex 和 justify-content: center; align-items: center;
使用 Grid 布局使用 CSS Grid 布局同样可以实现元素的水平和垂直居中。将父元素设为网格容器,然后使用 place-items: center;
使用绝对定位和 transform 属性将要居中的元素设置为 position: absolute,然后利用 top: 50%; left: 50%; transform: translate(-50%, -50%); 来进行居中。
使用表格布局创建一个包含单元格的表格,然后将内容放置在表格单元格中,并设置单元格样式为 text-align: center; vertical-align: middle;
使用绝对定位和负 margin 值将要居中的元素设置为 position: absolute,然后使用 top: 50%; left: 50%; margin: -128px(元素大小) 来进行居中。

如何设置1:2:1

  • Flex布局

    水平/垂直方向都可以(flex-direction),按1:2:1设置子元素的比例就好。

  • Grid布局

    display: grid; 
    grid-template-rows: 1fr 2fr 1fr;
    grid-template-columns也可以,看哪个方向
    
  • 使用绝对定位和百分比高/宽

有很多个子结点,每个都绑一个点击事件性能很差,如何优化?

这是小红书经典的事件相关热题目。这是因为每个点击事件处理函数都需要单独注册和管理,当子节点数量较多时,会增加页面的事件监听器数量,影响性能。

使用事件代理(Event Delegation)。通过将点击事件绑定到父元素上,利用事件冒泡机制,在父元素上捕获所有子元素的点击事件,然后通过判断点击的具体子元素(event.target)来执行相应的操作。这样可以减少事件监听器的数量,提高性能。

相关考点表达

  • 冒泡和捕获

字节一面,丢了闪现,被拿一血

事件会经历事件捕获和事件冒泡两个阶段。通过捕获浏览器知道事件发生在哪些元素上,再冒泡将这些事件都触发。

也可以通过修改第三个参数为true(useCapture), 让事件在捕获阶段就触发(由外到内),默认是在冒泡阶段触发(由内到外)。

快排

  • 快排用的是什么算法思想?跟归并排序有什么区别

快排和归并排序一致,用的是分治思想。区别在于,快速排序不会把真的数组分割开来再合并到一个新数组中去, 而是直接在原有数组内部进行排序。

  • 思路

首先, 选择(数组中间)一个基准值(pivot)。

接着,左右指针指向数组的两端。先移动左指针,直到找到一个不小于基准值的值为止(左边都比基准值小);然后移动右指针,直到找到一个不大于基准值的值为止。

然后不断的重复上述过程,即递归。

  • 代码
// 快速排序入口
function quickSort(arr, left = 0, right = arr.length - 1) {
  // 定义递归边界,若数组只有一个元素,则没有排序必要
  if(arr.length > 1) {
      // lineIndex表示下一次划分左右子数组的索引位
      const lineIndex = partition(arr, left, right)
      // 如果左边子数组的长度不小于1,则递归快排这个子数组
      if(left < lineIndex-1) {
        // 左子数组以 lineIndex-1 为右边界
        quickSort(arr, left, lineIndex-1)
      }
      // 如果右边子数组的长度不小于1,则递归快排这个子数组
      if(lineIndex<right) {
        // 右子数组以 lineIndex 为左边界
        quickSort(arr, lineIndex, right)
      }
  }
  return arr
}
// 以基准值为轴心,划分左右子数组的过程
function partition(arr, left, right) {
  // 基准值默认取中间位置的元素
  let pivotValue = arr[Math.floor(left + (right-left)/2)]
  // 初始化左右指针
  let i = left
  let j = right
  // 当左右指针不越界时,循环执行以下逻辑
  while(i<=j) {
      // 左指针所指元素若小于基准值,则右移左指针
      while(arr[i] < pivotValue) {
          i++
      }
      // 右指针所指元素大于基准值,则左移右指针
      while(arr[j] > pivotValue) {
          j--
      }

      // 若i<=j,则意味着基准值左边存在较大元素或右边存在较小元素,交换两个元素确保左右两侧有序
      if(i<=j) {
          swap(arr, i, j)
          i++
          j--
      }

  }
  // 返回左指针索引作为下一次划分左右子数组的依据
  return i
}

// 快速排序中使用 swap 的地方比较多,我们提取成一个独立的函数
function swap(arr, i, j) {
  [arr[i], arr[j]] = [arr[j], arr[i]]
}

由以上代码看出, 我们没有像归并排序分割原数组再合并到一个新数组,而是直接在原有的数组内部进行排序。

  • 时间复杂度

时间复杂度的好坏,由基准值决定。

最好时间复杂度:每次选择基准值,刚好是当前子数组的中间数。这时,可以确保每一次分割都能将数组分为两半,进而只需要递归 log(n) 次。这时,快速排序的时间复杂度分析思路和归并排序相似,最后结果也是 O(nlog(n))

最坏时间复杂度:每次划分取到的都是当前数组中的最大值/最小值。大家可以尝试把这种情况代入快排的思路中,你会发现此时快排已经退化为了冒泡排序,对应的时间复杂度是 O(n^2)

平均时间复杂度: O(nlog(n))

总结

  • 字节面试官喜欢以数据结构/算法/编程题开场,写出来再继续,不浪费时间。

本次面试两题都是传统题,面试官其实手下留情了,并没有考动规啥的。

  • 根据项目,连环追问,有点让人透不过气。还好项目中的概念或技术点可以提前预判,大部分的追问在多几次面试洗礼下,其实就像程咬金的三板斧,有准备就好。

参考资料