自我介绍
面试官您好,我叫吴浩燃,来自西安科技大学通信工程专业,是一名大四的学生。
大一上学期我加入了我们学校的校软件实验室,经过重重考核一直留了下来。大二的时候接触到了前端,带给了我一种不一样的编程体验,于是产生了极大的兴趣。后来也是确定了前端开发这个方向。
我自己个人完成了两个项目,主要涉及到的技术栈有 React、Node、Webpack等等。今年上半年的时候在字节跳动互娱研发部门担任前端实习生,实习了一个月的时间,完成并上线了一个需求。涉及到的技术栈主要也是React、Node。
个人博客项目
你项目有什么难点?
- 使用同构渲染进行首屏直出,优化首屏加载速度,减少首页白屏时间。
- 用node写后端用sequelize连接数据库的时候,由于对数据库、SQL不是很熟悉,在大概明白了一条SQL语句的作用之后,对应到sequelize的时候又有点困难了。因此感觉就sequelize的上手成本就上来了,因为在使用sequelize的时候,还是需要数据库以及sql的前置知识。总结就是自己对于数据库和sql的知识欠缺导致的。虽然我后端的逻辑比较简单,但是也是花了比较长的时间。
数据表的设计
sequelize的联表查询
你说一下什么是同构?
就是将代码在服务端跑一遍,然后在客户端再跑一遍,服务端渲染完成页面结构,客户端渲染绑定事件。在 SPA 的基础上,利用服务端渲染直出首屏,解决了单页面应用首屏渲染慢的问题
为什么单页面首屏渲染慢?
单页应用首次渲染只有一个 <div>
标签,然后内容是通过 JS 资源的加载获得的,也就是说,还需要发送至少第二次请求,才能将首页内容展示出来,这里就多了一步请求的过程。
单页应用有什么优缺点?
单页应用只有一个页面,浏览器开始会加载所有的 html/css/js 文件,所有内容都包含在主页面中,通过前端路由动态更新局部资源,不需要向后端请求。
所以它的优点:
- 用户体验好,内容改变只更新局部页面,页面切换不需要向后端发出请求
缺点:
- 因为搜索引擎只认识 html 的内容,所以不利于 SEO
- 需要两次请求,而且需要加载所有的 html/css/js 文件,所以首屏加载慢,容易造成白屏
多页应用优缺点?
多页面应用每一次页面跳转的时候,都会发起请求。
优点:首屏快,SEO 效果好。
缺点:页面切换慢,而且是从一个页面跳转到另一个页面,无法实现跳转动画,用户体验差。你是怎么做同构的?
之前看过网上一些方案,只有客户端入口文件,服务端这边返回的html是手写的模板字符串的方式。然后将css和内容插入进去。路由是使用集中式路由方案,数据的请求都是放在redux等这些状态管理里面,数据/请求方法这些通过Provider传递下去,css 通过StaticRouter的context传进去,然后在componentWillMount 里面拿到css。服务端的webpack配置里style-loader需要替换为 isomorphic-style-loader。
但是这个方案我总觉得不好,首先比较麻烦,而且,使用的是类组件的方式,还使用了不推荐的生命周期。后来我又通过大量的查找,受到的启发,最终返回的html字符串可以直接从打包后的文件里读取,这样css的问题也没有了,也不用staticRouter之类的东西了,简化了很多东西,并且将类组件改写成hooks的写法。
首先客户端和服务端设置 两个不同的入口,分别进行打包。在打包后的文件夹里我们可以获取到服务端打包后的代码,用 react-dom/server 的renderToString
方法转换成 html 字符串,把 index.html 的 body 内容给替换掉,也就是<div id="root"></div>
里面传入上面生成的 html 字符串,通过 ctx.body 发送到页面。这样我们就完成了页面的展示。
然后是数据部分,这里会涉及到在首页有请求的方法获取数据。把请求数据的这个方法放在路由配置里,这里我们用的是集中式路由管理的方式,可以把请求方法放在**loadData**
属性上,然后在服务端的入口文件处通过路由匹配的方式拿到请求方法,请求数据,挂载到 ctx.window 上,使在服务端获取到数据。然后通过注水和脱水的方式,在服务端“注水”挂载到全局 window 上,并且把这个 script 传入替换的模板中。通过 redux 状态管理,在创建 store 的时候,从 window 上拿到数据,使得让客户端也能拿到数据,然后如果客户端刷新的话,可以判断一下,是否需要重新发起请求。注水,就是服务端返回 HTML 字符的时候,将数据用
JSON.stringify
一并返回 脱水就是客户端不需要请求数据,直接使用服务端下发的数据
项目中还有做过其他性能优化的点吗?
- 叙述项目中使用到的性能优化的点
- 代码切割 —— SplitChunks
- 前端使用懒加载 —— loadable
- 介绍通用性能优化方案:
前端领域,性能问题通常表现为 空间和时间 两种。
空间性能可以理解为 内存占用、CPU 占用、本地缓存过多带来的问题(比如卡顿)
时间性能问题意味着 用户等待时间过长,包括页面加载、渲染、可交互等耗时
常见的性能优化方案可以从以下角度进行:- 缓存:尽可能地利用缓存来减少资源请求时间,包括请求缓存(HTTP 缓存/CDN 缓存/后台服务缓存)、数据缓存(Storage/indexDB)、Service Worker/PWA 等方式
- 资源优化:降低资源包大小,包括压缩(代码/图片)、按需引入、异步加载、tree-shaking、差异化服务(比如读写分离)、代码复用(组件化、公共包)、图片优化(瓦片化、按需加载大小)等方式
- 渲染优化:提升页面渲染性能/用户感知体验,包括减少 DOM 数量、减少或合并 DOM 操作、FPS60、离屏渲染、使用骨架屏、使用 GPU 合成、使用过渡动画等
- 内存优化:降低内存占用,包括 Web Worker、Webassembly 等技术提升大数据的计算速度
- 数据查询:通过使用索引等方式来提升数据的查询速度
Service Worker 充当 Web 应用程序、浏览器和网络之间的代理服务器,主要用于创建更有效的离线体验,会拦截网络请求并根据网络是否可用来采取适当的动作、更新服务器的资源。处于安全考虑,只能由 HTTPS 承载。
但是对于一个项目来说,我们通常会从以下两个角度来进行性能优化:
- 首屏性能提速,涉及技术方案可能包括 按需加载/懒加载/预加载、秒看、SSR 直出、客户端容器化、客户端离线化等
- 网络请求优化,涉及到的技术方案可能包括 CDN 优化、缓存优化、使用 HTTP2、资源压缩(Gzip)、请求优化(合并请求、域名拆分、减少 DNS 查询时间)等
当然,React 中也有一些性能优化方案。
- 减少 render 次数方面
- 使用 Immutable,Immutable 中有
is
方法,会比较两个 Immutable 对象是否完全相同。当 某个 state 变化的时候,不会渲染所有的节点。 - 设置防抖
- 使用私有属性
- shouldComponentUpdate,内部可以判断组件外部接收的最新属性和之前的属性是否一致,从而约束 render 调用的时机
- pureComponent ,内部机制通过浅比较去实现。
- React.memo,一个高阶组件,仅检查 props 的变更
- useCallback,传递一个函数和依赖项,只有依赖项改变,才会创建新的函数。减少子组件的渲染
- 使用 Immutable,Immutable 中有
- 减少计算量
- useMemo。第一个参数是函数,第二个参数是依赖项。useMemo 会缓存第一个函数的结果并作为自己的返回值,只有当依赖项改变的时候,才会重新执行第一个函数然后缓存新的结果。
使用 Webpack SplitChunks 对代码进行分割/提取公共代码
介绍性能指标
- TTFB(Time To First Byte),浏览器从请求页面到接收到第一个字节的时间
- FP(First Paint),从开始加载到浏览器首次绘制像素到屏幕上的时间,它并不表示页面内容完整性
- FCP(First Contentful Paint),浏览器首次绘制来自 DOM 内容的时间,这是用户第一次开始看到页面内容
- FMP(First Meaningful Paint),页面的主要内容绘制到屏幕上的时间
- LCP(Largest Contentful Paint),可视区域最大的内容元素呈现到屏幕上的时间
- FSP(First Screen Paint),页面从开始加载到首屏内容全部绘制完成的时间
- TTI(Time to Interactive),表示网页第一次完全到达可交互状态的时间
- SI(Speed Index),表示页面可视区域内容填充速度的指标
- FPS(Frames Per Second),帧率是视频设备产生图像地速率
- TBT(Total Blocking Time),总阻塞时间
- DOMContentLoaded(DCL),DomContentLoaded 事件触发时间(HTML 文档被完全加载和解析完),无需等待样式表、图像、子框架加载完成
- L(Load),onload 事件触发时间(页面所有资源都加载完成,包括图片、CSS)
Lighthouse 里面就可以看到 FCP、SI、LCP、TTI、TBT
除了 lighthouse 怎么查看页面性能问题?
通过 navigation timing 里面的 API,来计算不同阶段的耗时,然后针对性的优化。
本地预处理阶段
- Prompt for unload 是一个预处理阶段,要进行性能分析得从它的结束位置开始
- redirect: 本地跳转,检查缓存
- unload 对上一个页面的卸载、清除
- App cache:找本地缓存
网络阶段
- DNS:
- TCP:
- Request
- Response
浏览器渲染阶段
- Processing:DOM 的加载相关,DOMContentLoaded 事件就在这
- onload:页面渲染完成
JWT 和 cookie session 的区别
jwt 的构成是三部分——头部( header )、载荷( payload )、签证( signature ),用 .
分割。
头部包含了声明类型和加密算法;
payload 存放传递的信息,包含消息体,存储用户 id,过期时间等;
signature 是对前两步的签名,防止数据被篡改,需要一个 secret 密钥。
token 的验证流程:
- 客户端用户名和密码登录
- 服务端验证用户信息
- 验证成功后签发一个 token 并发送给客户端
- 客户端存储 token,每次请求时在 header 中带上 token
- 服务端验证 token,成功后返回数据
token 的特点:
- 服务端无状态化,减轻服务器压力,用解析 token 的时间换取存储 session 的空间
- token 完全由应用管理,所以可以避开同源策略,并在多机状态存储中表现良好
session 的验证流程:
- 用户第一次请求,服务端根据用户信息,创建 session
- 请求返回时将 session 唯一标识
session id
返回给客户端 - 客户端会把
session id
存入 cookie 中,同时 cookie 记录此session id
属于哪个域名 - 当第二次访问服务器时,请求会自动判断此域名下是否存在此 cookie 信息
主要区别:session 使服务端有状态化,token 使服务端无状态化
你都做了哪些webpack配置
- 对打包的文件名做
chunkhash
- resolve 里面添加了用到的文件扩展名以及路径别名设置
module 的 rules 里面配置了一些 loader 处理相关的文件,比如对于 js 和 ts 使用 babel-loader,对于 css 使用style-loader、css-loader、postcss-loader,图片等使用
assets
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1, // 可往前找一个loader
modules: true, // 开启css module
esModule: false,
},
},
'postcss-loader',
],
}
开发模式下使用 devServer
devServer: {
hot: true, // 启动热模块替换,且构建失败修改后不刷新,局部更新
compress: true, // 压缩
port: 8080, // 指定端口
historyApiFallback: true, // 避免使用history api 路由跳转后刷新找不到资源
}
一些插件,比如 HtmlWebpackPlugin(简化生成HTML入口文件)、CleanWebpackPlugin(每次构建前删除打包的文件)、CopyWebpackPlugin(将一些静态资源文件复制到打包后的文件夹下)、MinCSSExtractPlugin
组件库
为什么选择 rollup 呢 Webpack 和 rollup 的区别
- rollup 配置写起来会简单一些
打包体积也会比较小,而且可读性更强。webpack 里面有很多
__webpack_require__
工具函数不同的模块怎么引入
output 里面可以根据不同的
format
值打包成不同模块规范的代码,我指定了三种,cjs
、esm
、umd
,其中 umd 格式必须加上 name 值,最后会打包出来三个文件夹,分别对应不同的模块规范。然后可以通过package.json
中的exports
字段,可以指定引入不同的路径,和使用不同方式引入时,导出的文件位置不一样拖拽上传怎么实现的
定义了一个属性来判断是否开启拖拽上传功能。然后通过
onDrop
监听到文件放下时获取文件,通过e.dataTransfer.files
获取文件作为参数传递父组件的函数 prop。然后转成类数组的形式,遍历,把单个文件写成一个对象,其中包含了文件本身,文件名、文件大小、状态、uid、上传进度等。然后如果有beforeUpload
事件,那么可以先执行这个,之后再上传。上传的时候new FormData
对象,然后把文件 append 进去,指定content-type: multipart/form-data
。通过onUploadProgress
监听进度,其中e.loaded
表示上传了多少,e.total
表示总的大小,所以可以由此计算出百分比。ESM 和 CJS 有什么区别
首先语法有一定区别,cjs 是 require 导入,module.exports 或 exports导出;esm 是 import 导入,export 导出
- CJS 输出的是一个值的拷贝,ESM 输出的是值的引用
- CJS 模块是 运行时加载,所以要在某个模块加载时才能确定模块的依赖情况和判断这个模块是否存在;ESM 是在静态编译期间就决定所有模块的依赖情况
- CJS 的
require()
是同步加载模块,ESM 模块的import
是异步加载,有一个独立的模块依赖的解析阶段 - 处理循环依赖时的区别:
CJS的做法是,一旦出现某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出
ESM根本不会关心是否发生了循环加载,只是生成一个指向被加载模块的引用,需要开发者自己保证。真正取值的时候能够取到值。因为可能出现:循环引用时,某个模块还没有执行完成,那么久没有相应的导出,因为上一次的模块引用还没产生返回值
第 3 点的原因:CJS 加载的是一个对象,module.exports 属性,该对象只有在脚本运行完才会生成。而 ESM 模块不是对象,它的对外接口只是一种静态定义,在编译阶段就能生成
第 2 点的原因:
CJS 输出的是一个值的拷贝,也就是说一旦输出这个值,模块內部的变化就影响不到这个值。
ESM 的运行机制是,JS 引擎对脚本静态分析时,遇到模块加载命令 import,就会生成一个只读引用,等到脚本真正执行的时候,再根据这个只读引用,到被加载的那个模块里去取值。ESM 输入的模块变量是只读的,对它进行重新赋值会报错。
大文件上传
- 获取文件后,使用Blob对象的slice方法对其进行切割,并封装一些上传的数据
- 计算整个文件的 MD5 值,这部分放在 web worker 中进行
- 获得文件得 MD5 值之后,将 MD5 值以及文件大小发送到后端,后端查询是否存在该文件,如果不存在,查询是否存在文件的切片文件,如果存在,返回切片文件的详细信息
- 根据后端返回结果,依次判断是否满足秒传或者断点续传的条件,如果满足,更新文件的状态和文件进度
- 根据文件切片的状态,发送上传请求,由于存在并发限制,我们限制request创建个数,避免页面卡死
- 对于上传失败的文件,设置最大重试次数,将其继续加入到上传文件中,超过最大重试次数的才认为上传失败
- 后端收到文件后,首先保存文件,保存成功后记录切片信息,判断当前切片是否是最后一个切片,如果是,记录文件信息,认为文件上传成功,清空切片记录
断点续传关键是后端需要记录文件切片信息,在上传一个文件之前,先询问服务器,当前文件是否存在已经上传完毕的切片,如果存在,需要返回切片信息。前端根据返回的信息,调整当前的进度,上传未完成的切片
秒传的关键在于计算文件的唯一性标识,即计算文件的hash
限制请求个数,先计算出最大并发数,然后在请求的回调中再次创建请求,直到全部请求都发出为止
并发重试,设定一个最大重试次数,在最大次数之后依然没有上传成功,认为上传失败。具体的做法是定义一个retry数组,用于记录文件上传失败的次数。改造catch方法,一个文件切片上传报错的时候,先判断retry数组中的切片的错误次数是否达到最大值,如果没有的话,情况当前切片上传进度,重新请求,相应的,之前只上传处于ready状态的切片,现在error状态的切片也获得上传资格
你在字节都学到什么?
- 首先从技术角度的话,相比之前,主要就是新学到了使用 node 做 BFF 层来对后端返回的数据进行整合之后发送给前端,node 与后端使用 RPC 通信,node 和前端使用 HTTP 通信。以及使用 mobx 进行状态管理
Mobx 的话是一种响应式代理的方案,对全局 state 做了一层代理,状态的 get 收集依赖,set 的时候触发依赖更新,是面向对象的思想 - 然后从非技术角度的话,学到的就更多了。做项目期间与很多人交流,之前大多都是单打独斗,现在需要和 UI、测试、后端进行沟通推进开发
然后就是写完代码之后会有一个规范的流程,代码开发完成后先有一个测试阶段,需要上 BOE 环境;上线的环境里还会有组内人员的 code review,然后才会通过的代码,并且还会有测试。
你实习时做了什么事
首先,我先介绍一下整体的项目。简单来说,这个项目是一个翻译平台,有两个端,一个是管理端,另一个是译员端。管理端主要就是负责创建翻译任务,把任务发送到译员端交给专业的人员进行翻译,完成之后再交给管理端。
管理端分为三个板块,文案翻译、文档翻译和视频翻译,这三个板块需要一个 webhooks 模块来进行监听特定的事件触发点,然后返回该事件触发成功后需要呈现给使用者的信息。webhooks 有两个部分,一个是创建部分,可以选择在哪些事件触发成功后看到对应的信息;另一个就是 webhooks 历史,就是用来查看某些事件触发成功后的信息。具体的实现就是在事件触发后,在 node 那边根据当前发送的请求判断是属于哪种 webhooks 类型,比如是在文案翻译下的创建任务,那就是文案翻译的创建任务类型,在构造 message 的时候要写入相应的 webhooks 类型,和后端交互成功之后,把消息推入到消息队列中。查看 webhooks 历史的时候,会展示 webhooks 创建时选中的事件的对应信息
BFF 有什么好处?
在服务端需要支持多种前端设备的情况下,常常面临现有 API 与某一端 UI 紧耦合的情况。
比如 PC 端页面设计的 API 需要支持移动端,发现现有的接口从设计到实现都与桌面 UI 展示需求强相关,无法简单地适应移动端的需求。因为通常移动端 UI 与 PC 端不一样,比如:
- 屏幕空间小,显示数据少
- 分成多个连接会增加耗电,前端做数据聚合成本高
- 交互的方式差异比较大,后端 API 需要支持的功能也不同,比如 PC 端填表单,移动端扫码等
另外,一个后端 API 通常为多个前端应用提供支持,此时单一后端可能会成为版本迭代的瓶颈,因为对接 N 个前端,所以工作量巨大,从而产生一个庞大的后端团队,然后各前端团队都需要与该团队沟通变更,而该后端团队要平衡多个前端团队的需求优先级……继而面临跨团队协作低效,资源协调困难的问题。
通过 BFF 可以进行拆分,一个 UI 对应一个 BFF 或者每一种前端设备对应一个 BFF,这部分 BFF 由该前端团队负责和维护,这样前端就可以根据需要聚合想要的数据,保留更多的自主权,整体开发效率也能得到提升。
为什么使用 RPC,RPC 和 REST 的区别?
RPC 是远程过程调用,也就是像调用本地函数一样调用其他进程或机器上的函数
从通信协议角度来看,RPC 一般有两种实现方案,基于 HTTP 的,也就是我们常用的使用 ajax 请求使用的协议,返回结果通常是 JSON 或者 XML;另一种是基于 TCP 的。
基于 HTTP 的方式优点是实现简单,标准化,缺点就是 HTTP 传输效率比较低,开销比较大,有较多头部报文。
基于 TCP 的处于协议栈的下层,优点是更加灵活的对协议字段进行定制,减少网络开销,提高性能,实现更大的吞吐量和并发数。缺点是需要更多地关注底层复杂的细节,跨平台难度大,实现代价更高,比较适合内部系统之间追求极致性能的场景
从序列化协议角度来看,客户端和服务端之间使用的是 json 序列化协议,采用的是文本格式存储和表示数据,易于人阅读和编写。服务端之间使用的是二进制协议,比如 thrift、protobuf,效率比 json 更高,性能更好。前后端交换数据为什么使用 json 呢?因为 JavaScript 原生支持 json,转换非常方便。
吞吐量:网络、设备、端口、虚电路或其他设施,单位时间内传送数据的数量
实现方式?
因为 TCP 通道里传输的数据只能是二进制形式的,所以我知道 nodejs 中实现 RPC 的话,是用 Buffer 去实现,需要设计一个 Head,一个 Payload,合起来叫一个包。
反问
一面:
我应该需要加强哪些方面?
部门的前端大概负责哪些内容?运用到哪些技术栈?
二面
部门主要是做什么的?如果可以通过的话,我过来是做什么?
团队大概多少人?
三面
杂谈
排序算法有哪些?哪些是稳定的
- 冒泡排序 -> 稳定
- 插入排序 -> 稳定
- 基数排序 -> 稳定
- 归并排序 -> 稳定
- 选择排序 -> 不稳定
- 希尔排序 -> 不稳定
- 快速排序 -> 不稳定
- 堆排序 -> 不稳定
- 桶排序 -> 不稳定
关系型数据和非关系型数据库
关系型数据库是指采用关系模型来组织数据的数据库,关系模型就是二维表格模型——mysql、Oracle
非关系型数据库就是以 kv 形式存储——MongoDB,Redis