面试:(简单粗暴点)百度一面,直接问痛我
前言
这次的百度面试挺紧张的,在写算法题的时候脑子都有点空白,还是按照脑海中那点残存的算法技巧才写出来,不至于太尴尬,以及第一次面试百度这种级别的公司,难免出现了一些平常不至于出现的问题或没注意的缺点,在这里分享给大家。
百度一面
1. 如何用chatgpt提升前端开发效率
因为我嘴贱,平时习惯了使用chatgpt,然后自我介绍说了一句,由于之前面得公司都没问过,导致我没怎么往这方面准备,以至于答得时候牛头不对马嘴,所以说不愧是大厂啊。
- 问题解答和指导:
ChatGPT
可以帮助回答与前端开发相关的问题。当你在编写代码的时候,当一时忘记了某个API怎么用,就可以向ChatGPT
提问,并获得解答和指导,甚至还会给出一些更加深入且性能更好的应用。这可以帮助更快地解决问题和理解前端开发中的概念。 - 代码片段和示例:
ChatGPT
可以帮助你生成常见的前端代码片段和示例。你可以描述你想要实现的功能或解决的问题,然后向ChatGPT
请求相关代码片段。这样,您可以更快地获得一些基础代码,从而加快开发速度。 - 自动生成文档:
ChatGPT
可以帮助你生成前端代码的文档。你可以描述一个函数、组件或类,并向ChatGPT
请求生成相关的文档注释。这可以帮助您更轻松地为你的代码添加文档,提高代码的可读性和可维护性。 - 问题排查和调试:在开发过程中,您可能会遇到问题或错误。您可以向
ChatGPT
描述您遇到的问题,或者直接把代码交给它,并请求帮助进行排查和调试。ChatGPT
可以提供一些建议和指导,帮助您更快地找到问题的根本原因并解决它们。 - 学习资源和最新信息:
ChatGPT
可以为你提供关于前端开发的学习资源和最新信息。你可以向ChatGPT
询问关于前端开发的最佳实践、最新的框架或库、前端设计原则等方面的问题。这可以帮助我们不断学习和更新自己的前端开发知识,从而提高效率。
2. [1, 2, 3, 4, 5, 6, 7, 8, 9] => [[1, 2, 3],[4, 5, 6],[7, 8, 9]],把一个一维数组变成三个三个的二维数组
在JavaScript中,可以使用数组的slice
方法和一个循环来将一个一维数组转换为一个二维数组。下面是一个示例代码:
function convertTo2DArray(arr, chunkSize) {
var result = [];
for (var i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
}
var inputArray = [1, 2, 3, 4, 5, 6, 7, 8, 9];
var outputArray = convertTo2DArray(inputArray, 3);
console.log(outputArray);
输出结果将是:
[[1, 2, 3], [4, 5, 6], [7, 8, 9]]
slice
不会修改原数组,只会返回一个浅复制了原数组中的元素的一个新数组,不信的话自己可以编译一下。
这段代码中的convertTo2DArray
函数接受两个参数:arr
表示输入的一维数组,chunkSize
表示每个子数组的大小。它使用slice
方法来从输入数组中提取每个子数组,并使用循环来遍历整个数组并构建输出二维数组。最后,它返回生成的二维数组。
3. 输出结果,为什么?
const obj3 = {a: 1};
const obj4 = {b: 2};
console.log(obj3 == obj4); // false
console.log(obj3 === obj4); // false
结果:
false,false
原因:
在这段代码中,obj3
和obj4
分别是两个独立的对象,它们开辟的堆内存地址是完全不一样。==
运算符用于比较两个操作数是否相等,而===
运算符用于比较两个操作数是否严格相等。
根据对象的比较规则,当使用==
运算符比较两个对象时,它们将会进行类型转换后再进行比较。由于obj3
和obj4
是不同的对象,即使它们的属性值相同,它们的引用也不同,因此在进行类型转换后,它们会被视为不相等的对象。因此,console.log(obj3 == obj4);
的输出结果将会是false
。
而在使用===
运算符比较两个对象时,不会进行类型转换,而是直接比较两个操作数的值和类型是否完全相同。由于obj3
和obj4
是不同的对象,且类型也不同,即使它们的属性值相同,它们也不会被视为严格相等的对象。因此,console.log(obj3 === obj4);
的输出结果同样会是false
。
总结起来,无论是使用
==
运算符还是===
运算符,obj3
和obj4
都不会被视为相等或严格相等的对象,因为它们是不同的对象。
4. this有关 输出结果,为什么?
const obj1 = {
fn: () => {
return this
}
}
const obj2 = {
fn: function(){
return this
}
}
console.log(obj1.fn());
console.log(obj2.fn());
输出结果:
window || undefined
obj2
原因是:
在箭头函数 fn
中的 this
关键字指向的是定义该函数的上下文,而不是调用该函数的对象。因此,当 obj1.fn()
被调用时,由于箭头函数没有它自己的this,当你调用fn()函数时,this指向会向上寻找,因此箭头函数中的 this
指向的是全局对象(在浏览器环境下通常是 window
对象),因此返回的是 undefined
。
而在普通函数 fn
中的 this
关键字指向的是调用该函数的对象。在 obj2.fn()
中,函数 fn
是作为 obj2
的方法被调用的,所以其中的 this
指向的是 obj2
对象本身,因此返回的是 obj2
。
需要注意的是,在严格模式下,普通函数中的 this
也会变为 undefined
,因此即使是 obj2.fn()
也会返回 undefined
。但在示例中没有明确指定使用严格模式,所以默认情况下运行在非严格模式下。
5. Promise有关输出结果,为什么?
console.log('1');
function promiseFn() {
return new Promise((resolve, reject) => {
setTimeout(()=> {
console.log('2');
})
resolve('3');
console.log('4')
})
}
promiseFn().then(res => {
console.log(res);
});
输出结果: 1 4 3 2
原因是:
- 首先,代码从上往下执行,把
console.log('1')
放入同步任务 - 再调用promiseFn(),因为
new Promise
是同步任务,所以放入同步任务,继续执行 - 遇到setTimout这个宏任务,放入宏任务队列中
- 遇到resolve('3'),把res返回
- 之后再执行.then(),因为promise.then是微任务,所以放入微任务队列
- 代码是先执行同步任务,再执行微任务,之后再是宏任务
- 所以输出结果为1 4 3 2
这里涉及到了EventLoop的执行机制,如果不是太清楚可以看看我的面试题:小男孩毕业之初次面试第二家公司第一题
6. 实现斐波那契的第N个值(从0开始),要求时间复杂度为O(n)
首先,说到斐波那契第一个想到的肯定是如下的算法,但这可是百度啊,如果只是这种程度的话如何能和同样面相同岗位的人竞争呢,所以我们得想到如下算法有什么缺点,然后如何优化
function fib(n) {
if (n == 0 || n === 1) return 1;
return fib(n - 1) + fib(n - 2);
};
console.log(fib(3)); // 5
console.log(fib(5)); // 8
单纯的使用递归看似没什么问题,也能运算出结果,但是里面有个致命的问题,首先,时间复杂度就不对,递归思想的复杂度为 O(2^n) ,它不为O(n),然后还有会重复计算,比如计算n=3时,会计算fib(1) + fib(2)
,再次计算fib(4)时,会先算fib(3) = fib(1) + fib(2)
,然后再计算fib(4) = fib(1) + fib(2) + fib(3)
,在这里,fib(1)和fib(2)重复计算了两次,对于性能损耗极大。此时的你如果对动态规划敏感的话,就会从中想到动态规划其中最关键的特征——重叠子问题
因此,使用动态规划来规避重复计算问题,算是比较容易想到较优的一种解法,并且向面试官展现了你算法能力中有动态规划的思想,对于在面试中的你加分是极大的。
以下是动态规划思路的算法,状态转移方程为dp[i] = dp[i-1] + dp[i-2]
function fibonacci(n) {
if (n <= 1) return n;
let fib = [0, 1]; // 保存斐波那契数列的结果
for (let i = 2; i <= n; i++) {
fib[i] = fib[i - 1] + fib[i - 2]; // 计算第i个斐波那契数
}
return fib[n];
}
当然,你可能会说,在面试中怎么可能一下子就能想到动态规划,所以在面试前你需要背一背相关的状态转移方程,当你对算法问题分析到一定程度时,就能够记忆起这些状态转移方程,提高你写算法的速度。
在面试中,动态规划的常用状态转移方程可以根据问题的具体情况有所不同。以下是几个常见的动态规划问题和它们对应的状态转移方程示例:
斐波那契数列(Fibonacci Sequence):
dp[i] = dp[i-1] + dp[i-2]
,其中dp[i]
表示第i
个斐波那契数。
爬楼梯问题(Climbing Stairs):
dp[i] = dp[i-1] + dp[i-2]
,其中dp[i]
表示爬到第i
级楼梯的方法数。
背包问题(Knapsack Problem):
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
,其中dp[i][j]
表示在前i
个物品中选择总重量不超过j
的最大价值,weight[i]
表示第i
个物品的重量,value[i]
表示第i
个物品的价值。
最长递增子序列(Longest Increasing Subsequence):
dp[i] = max(dp[j] + 1, dp[i])
,其中dp[i]
表示以第i
个元素结尾的最长递增子序列的长度,j
为0
到i-1
的索引,且nums[i] > nums[j]
。
最大子数组和(Maximum Subarray Sum):
dp[i] = max(nums[i], nums[i] + dp[i-1])
,其中dp[i]
表示以第i
个元素结尾的最大子数组和。
最长公共子序列(Longest Common Subsequence):
如果
str1[i]
等于str2[j]
,则dp[i][j] = dp[i-1][j-1] + 1
;否则,
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
,其中dp[i][j]
表示str1
的前i
个字符和str2
的前j
个字符的最长公共子序列的长度。
编辑距离(Edit Distance):
如果
word1[i]
等于word2[j]
,则dp[i][j] = dp[i-1][j-1]
;否则,
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
,其中dp[i][j]
表示将word1
的前i
个字符转换为word2
的前j
个字符所需的最少操作次数。
打家劫舍(House Robber):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
,其中dp[i]
表示前i
个房屋能够获得的最大金额,nums[i]
表示第i
个房屋中的金额。
最大正方形(Maximal Square):
如果
matrix[i][j]
等于 1,则dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
;否则,
dp[i][j] = 0
,其中dp[i][j]
表示以matrix[i][j]
为右下角的最大正方形的边长。
7. 手写EventBus
当需要手动实现一个简单的 EventBus
时,你可以创建一个全局的事件总线对象,并在该对象上定义事件的订阅和发布方法。
class EventBus {
constructor() {
this.events = {}; // 存储事件及其对应的回调函数列表
}
// 订阅事件
subscribe(eventName, callback) {
this.events[eventName] = this.events[eventName] || []; // 如果事件不存在,创建一个空的回调函数列表
this.events[eventName].push(callback); // 将回调函数添加到事件的回调函数列表中
}
// 发布事件
publish(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(callback => {
callback(data); // 执行回调函数,并传递数据作为参数
});
}
}
// 取消订阅事件
unsubscribe(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(cb => cb !== callback); // 过滤掉要取消的回调函数
}
}
}
使用上述 EventBus
类,你可以执行以下操作:
// 创建全局事件总线对象
const eventBus = new EventBus();
const callback1 = data => {
console.log('Callback 1:', data);
};
const callback2 = data => {
console.log('Callback 2:', data);
};
// 订阅事件
eventBus.subscribe('event1', callback1);
eventBus.subscribe('event1', callback2);
// 发布事件
eventBus.publish('event1', 'Hello, world!');
// 输出:
// Callback 1: Hello, world!
// Callback 2: Hello, world!
// 取消订阅事件
eventBus.unsubscribe('event1', callback1);
// 发布事件
eventBus.publish('event1', 'Goodbye!');
// 输出:
// Callback 2: Goodbye!
在上述示例中,我们创建了一个 EventBus 类,该类具有 subscribe
、publish
和 unsubscribe
方法。subscribe
方法用于订阅事件,publish
方法用于发布事件并触发相关的回调函数,unsubscribe
方法用于取消订阅事件。我们使用全局的 eventBus
对象来执行订阅和发布操作。
这个简单的 EventBus
实现允许你在不同的组件或模块之间发布和订阅事件,以实现跨组件的事件通信和数据传递。你可以根据需要对 EventBus
类进行扩展,添加更多的功能,如命名空间、一次订阅多个事件等。
当问到EventBus时,得预防面试官问到EvnetEmitter,不过当我在网上查找相关的资料时,发现很多人似乎都搞混了这两个概念,虽然我在这里的手写原理似乎也差不多,但在实际使用中,两者可能在细节上有所不同。因此,在具体场景中,你仍然需要根据需求和所选用的实现来查看相关文档或源码,以了解它们的具体实现和用法。
下面是一个简单的 EventEmitter 类实现的基本示例:
class EventEmitter {
constructor() {
this.events = {}; // 用于存储事件及其对应的回调函数列表
}
// 订阅事件
on(eventName, callback) {
this.events[eventName] = this.events[eventName] || []; // 如果事件不存在,创建一个空的回调函数列表
this.events[eventName].push(callback); // 将回调函数添加到事件的回调函数列表中
}
// 发布事件
emit(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(callback => {
callback(data); // 执行回调函数,并传递数据作为参数
});
}
}
// 取消订阅事件
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(cb => cb !== callback); // 过滤掉要取消的回调函数
}
}
// 添加一次性的事件监听器
once(eventName, callback) {
const onceCallback = data => {
callback(data); // 执行回调函数
this.off(eventName, onceCallback); // 在执行后取消订阅该事件
};
this.on(eventName, onceCallback);
}
}
使用上述 EventEmitter 类,你可以执行以下操作:
const emitter = new EventEmitter();
const callback1 = data => {
console.log('Callback 1:', data);
};
const callback2 = data => {
console.log('Callback 2:', data);
};
// 添加一次性事件监听器
const onceCallback = data => {
console.log('Once Callback:', data);
};
// 订阅事件
emitter.on('event1', callback1);
emitter.on('event1', callback2);
emitter.once('event1', onceCallback);
// 发布事件
emitter.emit('event1', 'Hello, world!');
// 输出:
// Callback 1: Hello, world!
// Callback 2: Hello, world!
// Once Callback: Hello, world!
// 取消订阅事件
emitter.off('event1', callback1);
// 发布事件
emitter.emit('event1', 'Goodbye!');
// 输出:
// Callback 2: Goodbye!
在上述示例中,EventEmitter 类具有 on
、emit
、 off
和once
方法。on
方法用于订阅事件,emit
方法用于发布事件并触发相关的回调函数,off
方法用于取消订阅事件,once
方法用于添加一次性的事件监听器。你可以根据需求对 EventEmitter
类进行扩展,添加更多的功能,比如一次订阅多个事件、取消所有事件订阅等。
eventBus,eventEmitter的区别
EventBus
和 EventEmitter
都是用于实现事件发布-订阅模式的工具,但它们在实现和使用上有一些区别。
实现方式:
EventBus
:EventBus
是一个全局的事件总线,通常是作为一个单例对象存在,用于在不同组件或模块之间传递事件和数据。在 Vue.js 中,Vue 实例可以充当EventBus
的角色。EventEmitter
:EventEmitter
是一个基于类的模块,通常是作为一个实例对象存在,用于在单个组件或模块内部实现事件的发布和订阅。
使用范围:
EventBus
:EventBus
的作用范围更广泛,可以跨越不同组件、模块或文件进行事件的发布和订阅。它可以实现多个组件之间的通信和数据传递。EventEmitter
:EventEmitter
主要用于单个组件或模块内部,用于实现内部事件的处理和通信。
依赖关系:
EventBus
:EventBus
通常需要一个中央管理的实例,因此需要在应用程序的某个地方进行创建和管理。在 Vue.js 中,Vue 实例可以用作全局的EventBus
。EventEmitter
:EventEmitter
可以在需要的地方创建实例对象,并将其用于内部事件的发布和订阅。
命名空间:
EventBus
:EventBus
可以使用不同的事件名称来进行事件的区分和分类,可以使用命名空间来标识不同类型的事件。EventEmitter
:EventEmitter
通常使用字符串作为事件的名称,没有直接支持命名空间的概念。
总结起来,EventBus 主要用于实现跨组件或模块的事件通信和数据传递,适用于大型应用程序;而 EventEmitter 主要用于组件或模块内部的事件处理和通信,适用于小型应用程序或组件级别的事件管理。选择使用哪种工具取决于你的具体需求和应用场景。
8. (场景题)在浏览器中一天只能弹出一个弹窗,如何实现,说一下你的思路?
要在浏览器中实现一天只能弹出一个弹窗的功能,可以使用本地存储(localStorage)来记录弹窗状态。下面是一种实现方案:
- 当页面加载时,检查本地存储中是否已存在弹窗状态的标记。
- 如果标记不存在或者标记表示上一次弹窗是在前一天,则显示弹窗并更新本地存储中的标记为当前日期。
- 如果标记存在且表示上一次弹窗是在当天,则不显示弹窗。
以下是示例代码:
// 检查弹窗状态的函数
function checkPopupStatus() {
// 获取当前日期
const currentDate = new Date().toDateString();
// 从本地存储中获取弹窗状态标记
const popupStatus = localStorage.getItem('popupStatus');
// 如果标记不存在或者标记表示上一次弹窗是在前一天
if (!popupStatus || popupStatus !== currentDate) {
// 显示弹窗
displayPopup();
// 更新本地存储中的标记为当前日期
localStorage.setItem('popupStatus', currentDate);
}
}
// 显示弹窗的函数
function displayPopup() {
// 在这里编写显示弹窗的逻辑,可以是通过修改 DOM 元素显示弹窗,或者调用自定义的弹窗组件等
console.log('弹出弹窗');
}
// 在页面加载时调用检查弹窗状态的函数
checkPopupStatus();
在这个实现中,checkPopupStatus
函数会在页面加载时被调用。它首先获取当前日期,并从本地存储中获取弹窗状态的标记。如果标记不存在或者表示上一次弹窗是在前一天,就会调用 displayPopup
函数显示弹窗,并更新本地存储中的标记为当前日期。
通过这种方式,就可以确保在同一天只能弹出一个弹窗,而在后续的页面加载中不会重复弹窗。
9. 项目中的性能优化?
对组件和图片进行懒加载:对暂时未使用的组件和图片使用懒加载可以显著地减少页面加载时间,比如在我的项目中路由配置中除了需要频繁切换的页面组件外,其他的组件都使用箭头函数引入组件进行懒加载,以及一些没有展现在界面的图片也进行了一个
VueLazy
的懒加载。减少HTTP请求数量:由于频繁的请求会对后端服务器造成极大的负担,所以应该减少不必要的请求,比如在我的项目中的搜索界面,对于搜索按钮增加了防抖功能
使用缓存:使用浏览器缓存可以减少资源请求,从而提高页面加载速度。项目中我会把用户的一些需要持久化的信息存入本地存储。
异步请求使用Promise.all:异步请求可以在后台加载资源,从而避免阻塞页面加载。在请求数据时,我会使用
Promise.all
一次性并行的请求类似的数据,而不需要一个一个的请求,较少了请求时间。图片优化:使用适当的图片格式和大小可以减少页面的资源请求和加载时间,项目中我会把图片转化成
base64
的格式和webp
格式,这样可以使图片大小更小使用CDN加速:使用
CDN
可以提高资源的访问速度,从而加快页面加载速度。我项目中的一些第三方资源有时需要请求,因此我会使用CDN
内容分发网络来提高访问速度。骨架屏(Skeleton Screen):它可以提升用户感知的加载速度和用户体验。虽然骨架屏本身并不直接影响代码性能,但它可以改善用户对应用程序的感知,提供更好的用户体验。
10. 项目中遇到的难点,如何解决
1. 数据状态管理
前端登录状态管理
- 我在一个练手的项目中做前端登录功能的时候, 碰到了购物车需要登录判断的功能,比如用
isLogin
来判断有没有登录,当时由于没有深入了解vuex
,所以我一开始想着把这个isLogin
通过组件与组件的传值方法,把这个值传给相应的组件,然后在需要登录组件中进行判断,但后来发现这个方法太麻烦了 - 后来通过学习了解,使用了
vuex
这个全局状态管理的方法, 通过使用createStore
这个vuex
中的API创建了一个全局的登录状态,再通过actions mutations
实现登录判断和登录状态共享
组件数据状态管理
- 我项目中一开始首页、详情页等其他页面越来越多的状态放在同一个
store
上,虽然感觉有点乱,但实现了数据流和组件开发的分离,使得我更能够专注于数据的管理 - 但随着数据的增多,感觉实在太乱了,然后得知
vuex
中可以使用modules
来进行分模块,相应的页面放入相应的模块状态中,之后再用actions,mutations,state,getters
这四件套, 更好的模块化管理数据,能够知道哪些状态是全局共享的(登录), 哪些状态是模块共享的 - 然后在新的项目中,也就是现在简历上的项目里,尝试使用
pinia
来管理,因为我发现它更简单(没有mutations
),模块化更好,让我对组件状态管理的更加得心应手,学习起来也更加的方便。
node的错误处理
- 一开始用
node
写后端的时候,一堆错误,比如路由没配置,数据库报错。使得后面的代码都无法运行,写着写着就感觉写不下去,经常一个错误就需要反复的在脑海中想最后依靠那一丝的灵光一闪才解决 - 之后我就在
app.js
这个后端入口文件的最后,添加一个统一的错误处理的中间件,向前端返回状态码和相应的信息后,直接使用next()
向后继续执行,这样虽然服务器报了错,但仍然可以执行后续的代码。
跨域问题
- 在我写完前端项目的时候,想要提升一下自己,就转去学习了Koa,在搭建了大致的服务器,写了一个简单的接口并运行服务器后,我想当然的就在前端直接请求后端的端口,结果报了一个跨域的错误,由于当时初学后端,不怎么了解跨域,所以找了很多的解答并逐个在项目中进行尝试,比如跨域中的
script
,postMessage
,html本身的Websocket
- 但发现最实用的还是在服务器中配置
Access-Control-Allow-Origin
来控制跨域请求的url地址,以及其他一些Access-Control-Allow
头来控制跨域请求方法等,然后跨域请求url的白名单我放入了.env这个全局环境变量中。
axios响应拦截
- 在后端返回数据的时候,我返回数据有一个状态码以及添加到
data
这个需要返回的数据(代码如下),这导致我在获取接口里的数据时需要多.data
(引用一层data),当时我没意识到,结果一直获取不到数据。之后输出获取的数据才发现在数据外面包了一层,虽然这个时候解决了服务器那边数据返回的问题,但后面每次获取数据时都需要在往里再获取,非常的麻烦。 - 最后在学习了并在项目中使用axios进行请求和响应后,就在响应的时候设置一个拦截器,对响应进行一番处理之后就可以直接拿到后端接口返回的值,而不会导致接口返回的值不会有太多的嵌套了。
11. 如何学习前端的,学了几年?
这个就看个人情况了,但其中,你得展现出你的学习积极性和对前端的热爱,让面试官能够欣赏你
我大致说说我回答的,仅作参考
我从大二开始就对前端很感兴趣,当时正好学校也分了Web前端的方向,于是就跟着学校的课程开始学习基本的html,css,js三剑客,但之后感觉到老师教的很慢,就自己到B站上学习了,之后由于参加过一次蓝桥杯,就看到了蓝桥云课上有相关的基于html,css,js比较基础项目,接着我还学习了一些行内大牛写的一些博客文章,比如阮一峰,张鑫旭,廖雪峰等这些老师。之后又学习了vue并且在GitHub上学习相关的设计理念,根据GitHub上项目中不懂的东西又逐渐学习了各种UI组件库和数据请求方式,最后又学习了Nodejs中的Koa,用Vue和Koa仿写了一个全栈型项目,目前正在学习一些typescript的基本用法并尝试着运用到项目中,并在学习Vue的一些底层源码。
结语及吐槽
大厂的面试终归到底还是和我之前面的公司不一样,它们更加看重的是代码底层的实现和你的算法基础
,终归到底,这次面试只是一次小尝试,想要知道自己的水平到底在哪里,并且能够借此完善自己的能力,努力的提升自己,希望能够给
来源:juejin.cn/post/7240751116701728805