文章目录

Q:JS有哪些数据类型?

7种基本类型

  • 数字 number
  • 字符串 string
  • 布尔值 boolean
  • undefined
  • null
  • symbol(ES6)
  • bigint(ES10)

1种引用类型

  • 对象 object

Q:ES6数据结构 Set / Map

ES6两个新的存储数据的方式,相比Object的优点:

  1. 纯Hash,性能比Object好很多;
  2. 自带方法高效简洁;
  3. 用法与 Java 的 HashSet、HashMap 相似。

Set

Set 与 Array 的关系

  1. 类似于数组的数据结构,成员的值都是唯一的,没有重复的值;
  2. Array 是索引集合,Set是键集合,只有键值,没有索引和键名(或者说键名和键值是同一个值);
  3. Array 的元素按照索引值排序,Set 的元素按照插入顺序排序;
  4. Set 是纯hash,性能比 Array 好。

Set 特性

  1. 元素不重复;
  2. 遍历顺序:插入顺序;
  3. 没有键只有值,可认为键和值两值相等;
  4. 添加多个NaN时,只会存在一个NaN;
  5. 添加相同的对象时,会认为是不同的对象;
  6. 添加值时不会发生类型转换(5 !== “5”);
  7. keys()和values()的行为完全一致,entries()返回的遍历器同时包括键和值且两值相等;

Set 常用API

  • add() 添加
const mySet = new Set()
mySet.add(123)
mySet.add(567)
mySet.add("abc")
console.log(mySet) // Set[3] {123, 567, "abc"}
  • delete() 删除。根据是否有该元素,返回 true 或 false
mySet.delete(123) // true
console.log(mySet) // Set[2] {567, "abc"}
  • has() 判断是否存在某元素,返回 true 或 false
mySet.has(567) // true
  • size() 获取Set元素的数量
mySet.size // 2
  • forEach / for of 遍历
mySet.forEach(val => {
    console.log(val)
})
// 567
// "abc"

for (val of mySet) {
    console.log(val)
}
// 567
// "abc"

Map

Map 与 Object 的关系

  1. 类似于对象的数据结构,Object 的键只能是简单数据类型(string、number、symbol),Map 的键可以是任意数据类型
  2. Map 的元素按照插入顺序排序,Object 没有顺序;
  3. Map 继承自 Object 对象;
  4. Map 是纯hash,性能比 Object 好。

Map 特性

  1. 遍历顺序:插入顺序;
  2. 对同一个键多次赋值,后面的值将覆盖前面的值;
  3. 对同一个对象的引用,被视为一个键;
  4. 对同样值的两个实例,被视为两个键;
  5. 键跟内存地址绑定,只要内存地址不一样就视为两个键;
  6. 添加多个以NaN作为键时,只会存在一个以NaN作为键的值;
  7. Object结构提供字符串—值的对应,Map结构提供值—值的对应;

Map 常用API

  • set() 添加
const person = new Map()
person.set("name", "无止尘")
person.set("age", 26)
person.set("hobby", ["篮球", "电影", "摄影"])
console.log(person) // Map(3) {"name" => "无止尘", "age" => 27, "hobby" => Array(3)}
person.set("name", "Truman") // 添加同键名元素会覆盖原值,不会重复添加
  • delete() 删除。根据是否有该元素,返回 true 或 false
person.delete("age") // true
console.log(person) //  Map(3) {"name" => "Truman", "hobby" => Array(3)}
  • get() 获取
person.get("age") // 26
  • has() 判断是否存在某元素,返回 true 或 false
person.has("name") // true
person.has("gender") // false
  • size() 获取Map元素的数量
person.size // 3
  • forEach / for of 遍历
// 6. forEach / for of 遍历
person.forEach((value, key) => {
    console.log(value, key)
})
// "Truman", "name"
// ["篮球", "电影", "摄影"], "hobby"

for ([key, value] of person) {
    console.log(key, value)
}
// "name", "Truman"
// "hobby", ["篮球", "电影", "摄影"]

Q:call, apply, bind 用法和区别

call() 和 apply()

作用:调用函数,并修改函数运行时的this指向

fn.call(thisArg, arg1, arg2, ...)
fn.apply(thisArg, [arg1, arg2, ...])
// thisArg 当前调用函数this的指向对象
// arg1, arg2... 传递的参数
  1. 调用函数
this.name = "张三"
function fn(x, y) {
    console.log(this)
    console.log(this.name)
    console.log(x + y)
}
fn(3, 4) // window对象; "张三"; 7
fn.call() // window对象; "张三"; NaN
fn.call(null, 2, 4) // window对象; "张三"; 6
fn.bind(null, [3, 4]) //  // window对象; "张三"; 7
  1. 修改函数运行时的this指向
const obj = {
    name: "李四"
}
fn.call(obj, 5, 4) // obj对象; "李四"; 9
fn.bind(obj, [3, 4]) // obj对象; "李四"; 7

场景

  1. 借用构造函数,继承父类的属性;
  2. 借用其他对象的方法
// 例:伪数组借用数组的方法添加成员
var fakeArr = {
    0:'a',
    1:'b',
    length: 2
};
// 数组的push()方法内部的this原本指向数组,改为指向fakeArr
Array.prototype.push.call(fakeArr, 'newItme');
// fakeArr现在的结果为
fakeArr = {
    0:'a',
    1:'b',
    2:'newItem',
    length: 3
}

区别

  • call(thisArg, [val1], [val2], [val3])
    第一个参数是改变后的 this 指向,其后参数都是要传入函数的元素;
  • apply(thisArg, [val1, val2, val3])
    第一个参数是改变后的 this 指向,第二个参数必须是数组,apply 可以把数组每一项展开传入函数。
var arr = [5,1,3,6];
// 使用apply()调用Math.max方法,不需要改变该方法的this指向,所以第一个参数是Math或者null
// 需要把数组里每一项展开,所以第二个参数传入数组
Math.max.apply(Math, arr);

bind()

作用
不会调用函数,会返回一个新的函数。该函数的第一个参数是绑定的 this 指向,原函数中的参数通过第二个参数传递。

fn.bind(thisArg, val)

场景

var obj = {
    name: '张三',
    age: 18,
    say: () => {
        setInterval(
        	// function(){}.bind(this) 生成一个新的函数,该函数内部this指向原函数的this,原函数是obj调用的,所以this指向obj
        	function(){console.log(this)}.bind(this)
        ,1000)
    }
};
obj.say();

Q:new 的执行过程

/// 构造函数:
const Student = function(name, age) {
    this.name = name
    this.age = age
}

使用new关键字调用构造函数 Student,分为四步:

1. 在内存中创建了一个实例对象(内容为空)

var stu1 = {};

2. 设置该实例对象的__proto__属性指向构造函数的prototype原型对象

stu1.__proto__ == Student.prototype;

3. 使用该实例对象stu1调用构造函数,改变构造函数中的this指向为实例对象stu1

Student('张三', 20).call(stu1);

4. 返回刚刚创建好的实例对象

Q:JS常见的内存泄漏

1. 意外的全局变量

2. 被遗忘的计时器或回调函数

3. 脱离DOM的引用

4. 闭包

Q:cookie、sessionStorage、localStorage的区别?

cookie

cookie存储的数据会固定携带在请求头中,每次发送请求都会传输,浪费带宽。

// 设置cookie
setcookie('name', '张三', time()+3600)

有效范围:当前目录及子目录有效,上级目录无效;
生命周期:不设置或设置为-1,关闭浏览器即失效;设置为时间戳,则该时间失效;
储存大小:约4k;
数据类型:只能存储字符串类型的数据;

sessionStorage

// 获取文本框内容
var val = document.querySelector('input').value; //JS方法
// 存储文本框内容
window.sessionStorage.setItem('val', val); 
// 读取该数据
window.sessionStorage.getItem('val');
// 删除该数据
window.sessionStorage.removeItem('val');
// 清空所有数据
window.sessionStorage.clear();

有效范围:同一页面内共享,不能跨域;

生命周期:关闭浏览器就失效;

存储大小:约5M;

数据类型:只能存储字符串类型的数据;对象类型的数据用 JSON.stringify() 编码后存储

注:sessionStorage 和 session 无关

localStorage

// 获取文本框内容
var val = $('input').val(); //jQuery方法
// 存储文本框内容
window.localStorage.setItem('val', val);
// 使用该数据
window.localStorage.getItem('val');
// 删除该数据
window.localStorage.removeItem('val');
// 清除所有数据
window.localStorage.clear();

有效范围:同一域内多窗口共享,不能跨域;

生命周期:永久有效,除非手动删除浏览器缓存;

存储大小:约20M;

数据类型:只能存储字符串类型的数据,对象类型的数据用 JSON.stringify() 编码后存储

Q:跨域解决方案

参考资料:前端常见跨域解决方案(全)

什么是跨域?
跨域:不同源的脚本操作其他源下的对象,读写其他源的资源。
什么是同源?
同源策略是浏览器的安全功能,阻止跨域。目的是防止恶意网站窃取数据,保护信息安全
同源:同协议,同域名,同端口号

1. cors 跨域资源共享

CORS的基本思想就是使用自定义的HTTP头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功还是失败。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。在服务器端声明不用同源策略:
header(‘Access-Control-Allow-Origin: *’) ,允许不同源网站对本站进行跨域访问,浏览器就不会对返回的数据进行限制了;
header(‘Access-Control-Allow-Origin: url’) 表示对指定的 url网站不用同源策略
header(‘Access-Control-Allow-Oring: *’) 表示对所有外部网站不用同源策略

  • Access-Control-Allow-Origin: http://www.YOURDOMAIN.com
    该字段必需。设置允许请求的域名,多个域名以逗号分隔,也可以设置成 * 即允许所有源访问
  • Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
    该字段必需。设置允许请求的方法,多个方法以逗号分隔
  • Access-Control-Allow-Headers: Authorization
    该字段可选。设置允许请求自定义的请求头字段,多个字段以逗号分隔
  • Access-Control-Allow-Credentials: true
    该字段可选。设置是否允许发送 Cookies

2. Jsonp

通常为了减轻web服务器的负载,我们把js、css,img等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许。即页面中的链接、重定向、表单提交不受同源策略限制。基于此原理,我们可以用 srchref 两个属性跨域访问。
例如通过动态创建script,再请求一个带参网址实现跨域通信。
前端原生实现

var script = document.createElement('script');
script.type = 'text/javascript';
// 传参回调函数名 handleCallback 给后端,方便后端返回时执行这个在前端定义的回调函数
script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback';
document.head.appendChild(script);

// 回调执行函数
function handleCallback(res) {
    console.log('请求成功',res);
}

服务端返回如下(返回时即执行全局 handleCallback 函数):

handleCallback({"status": true, "user": "admin"})

后端 node.js 代码

var querystring = require('querystring');
var http = require('http');
var server = http.createServer();

server.on('request', function(req, res) {
    var params = qs.parse(req.url.split('?')[1]);
    var fn = params.callback;

    // jsonp返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn + '(' + JSON.stringify(params) + ')');

    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

3. nginx代理

跨域原理:同源策略是浏览器的安全策略,不是HTTP协议的一部分。服务器端调用HTTP接口只是使用HTTP协议,不会执行JS脚本,不需要同源策略,也就不存在跨越问题。
实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。

nginx配置

// 例如:前端域名 http://localhost:9099  后端域名 http://localhost:9871
server{
    # 监听9099端口
    listen 9099;
    # 域名是localhost
    server_name localhost;
    #凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871 
    location ^~ /api {
        proxy_pass http://localhost:9871; # 反向代理
    }    
}

4. postMessage API

postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题:
a.) 页面和其打开的新窗口的数据传递
b.) 多窗口之间消息传递
c.) 页面与嵌套的iframe消息传递
d.) 上面三个场景的跨域数据传递

postMessage(data,origin)      
@params data: html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。       
@params origin: 协议+主机+端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"

1. http://www.domain1.com/a.html

<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>       
    var iframe = document.getElementById('iframe');
    iframe.onload = function() {
        var data = {
            name: 'aym'
        };
        // 向domain2传送跨域数据
        iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
    };

    // 接受domain2返回数据
    window.addEventListener('message', function(e) {
        alert('data from domain2 ---> ' + e.data);
    }, false);
</script>

2. http://www.domain2.com/b.html

<script>
    // 接收domain1的数据
    window.addEventListener('message', function(e) {
        alert('data from domain1 ---> ' + e.data);

        var data = JSON.parse(e.data);
        if (data) {
            data.number = 16;

            // 处理后再发回domain1
            window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
        }
    }, false);
</script>

Q:常见状态码

1XX:信息性状态码

2XX:成功状态码

200:OK 请求成功,一般用于GET和POST请求
201:Created 已创建,成功请求并创建了新的资源

3XX:重定向状态码

301:Moved Permanently 永久重定向,请求的资源已被分配了新的URL,以后应该使用新的URL去访问该资源
302:Found 临时重定向,请求的资源已被分配了新的URL,希望用户本次使用新的URL
304:Not Modified 未修改

4XX:客户端错误状态码

403:Forbidden 禁止访问,未获得访问权限
404:Not Found 未找到请求的资源

5XX:服务器错误状态码

500:Internal Server Error 服务器内部错误
503:Service Unavailable 服务器超载或系统维护,暂时无法处理请求

Q:创建 Ajax 过程

1. 实例化 XMLHttpRequest 对象

var xhr;
if(window.XMLHttpRequest){
    // 标准浏览器
    xhr = new XMLHttpRequest();
}else{
    // IE浏览器
    xhr = new ActiveXObject('Mcxml2.XMLHTTP');
}

2. 准备Ajax请求

xhr.open('get', 'url');

3. 发送Ajax请求

xhr.send('data');

4. 服务端接收并处理Ajax请求,返回处理结果(字符串)给客户端

<?php
    echo String;
    print_r String;
?>

5.客户端接收服务端返回的数据

xhr.onreadystatechange = function () {
	if(xhr.readyState == 4){
		DOM.innerHTML = xhr.responseText;
	}
}

Q:浏览器进程和线程

标签页进程

Browser进程(浏览器主进程)

插件进程

GPU进程

Render进程(浏览器内核,即浏览器渲染进程)

  • js引擎线程
  • GUI渲染线程
  • 事件触发线程
  • 定时触发器线程
  • 异步HTTP请求线程

Q:从输入URL到页面加载发生了什么?

参考视频:https://www.bilibili.com/video/av40168673?from=search&seid=6185720200065621331

一、DNS解析(网址->IP地址)

  1. 从浏览器中查找DNS缓存
  2. 如果没有,继续到操作系统中查询DNS缓存
  3. 如果没有,开始分级查询
    3.1 本地DNS服务器
    3.2 根域名服务器
    3.3 COM顶级域名服务器
    3.4 goole.com域名服务器
  • 查到IP地址后,继续下一步

二、TCP连接(三次握手)

http超文本传输协议是TCP/IP协议的子集,TCP有6种标示:SYN(建立联机) ACK(确认) PSH(传送) FIN(结束) RST(重置) URG(紧急)

1. 第一次握手:客户端 -> 服务端

  • 客户端向服务端发送请求报文,请求建立连接,等待服务端确认。发送字段:
    SYN=1(synchronization 同步。请求建立连接)
    Seq=x(sequence 客户端序列号)

2. 第二次握手: 服务端 -> 客户端

  • 服务端表明收到请求报文并同意建立连接,发送确认报文,询问客户端是否准备好。返回字段:
    SYN=1(同意建立连接)
    ACK=x+1(acknowledgement 答复。客户端序列号+1)
    Seq=y(服务端序列号)

3. 第三次握手:客户端 -> 服务端

  • 客户端表明收到确认报文,再返回服务端一个确认报文,表示准备开始发送信息。发送字段:
    SYN=0(开始发送信息)
    ACK=y+1(服务端序列号+1)
    Seq=x+1(序列号设置为服务端ACK)
第一次表明客户端有发送信息的能力
第二次表明服务端有接收信息和发送信息的能力
第三次表明客户端有接收信息的能力

三、 客户端发送HTTP请求

四、服务端处理请求,并返回HTTP报文

五、浏览器解析并渲染页面过程

浏览器解析页面过程:

  1. 浏览器接收到HTML模板文件,自上而下解析HTML
  2. 浏览器遇到CSS样式文件表,暂停解析HTML,请求CSS文件
  3. 服务端返回CSS文件,浏览器开始解析CSS
  4. 浏览器解析完CSS,继续解析HTML,遇到DOM节点,解析DOM
  5. 浏览器遇到img图片,异步请求图片,继续解析后面的代码
  6. 浏览器返回图片,由于图片占有面积影响布局,浏览器重绘
  7. 浏览器遇到js脚本文件,停止所有文件加载和解析,请求并执行脚本文件(js阻塞,所以js标签尽量放在body最后)
  8. 浏览器执行完脚本文件,如果后面还有代码,继续解析
  9. 加载并解析完所有HTML、CSS、JS文件,页面出现

浏览器渲染页面过程:

  1. 解析HTML,构建DOM树;解析CSS,生成CSS规则
  2. 构建render树(渲染树,HTML + CSS,不包括display:none)
  3. 布局render树(layout过程,计算宽高、定位、坐标、换行、positions、overflow、z-index等属性)
    4、绘制render树(painting过程,背景 —> 浮动 -> content -> padding -> border)
引起浏览器重新布局layout:
js 修改 DOM 属性或 css 属性,分为两种情况:
1. 重绘,不改变定位、宽高等,只改变元素展示方式
2. 重排/回流,影响文档内容、结构或元素定位
优化:
1. 脱离文档流的重排只影响自己子孙元素
2. 读取element属性会造成重排,尽量避免。如offsetTop/scrollTop/clientTop系列

六、TCP连接结束(四次挥手)

1. 第一次挥手:客户端 -> 服务端

  • 客户端发送断开连接的请求
    FIN (finish 结束)
    ACK=1000 (acknowledgement 答复)
    Seq=x (sequence 客户端序列号)

2. 第二次挥手:服务端 -> 客户端

  • 服务端接收并同意断开连接的请求
    ACK=x+1 (acknowledgement 答复,客户端序列号+1)
    Seq=1000 (服务端序列号设置为客户端ACK)

3. 第三次挥手:服务端 -> 客户端

  • 服务端发送断开连接的请求
    FIN(finish 结束)
    ACK=x+1 (客户端序列号+1)
    Seq=1000(服务端序列号)

4. 第四次挥手:客户端 -> 服务端

  • 客户端断开连接,完成通讯
    ACK=1000+1 (服务端序列号+1)
    Seq=x+1(客户端序列号设置为服务端ACK)
第一次表明发完了
第二次表明知道发完了
第三次表明收完了
第四次表明知道收完了

Q:JS Event Loop 事件循环

参考视频:https://www.bilibili.com/video/BV1MJ41197Eu?p=36

注:浏览器是多进程、多线程的,JS是单线程的
浏览器每个标签页是一个进程,每个进程里同时有js线程、网络线程、渲染线程等

heap 堆(主要用于内存分配)

  • 对象

tack 执行栈/方法调用栈(先进后出)

  • 当JS引擎执行函数时,会把函数按照执行顺序放入stack 执行栈,并按照执行完毕的顺序从执行栈里移除。(先进后出)
  • 如果一直在调用函数而没有结束(自调用死循环),执行栈容量会达到上限,报错。

task queue 任务队列(异步)

  • 如果调用到异步函数,会把异步函数先放入task queue 任务队列,继续执行stack 执行栈里的同步函数。当执行栈的函数全部执行完毕并移除,再把任务队列里的异步函数按照加入任务队列的先后顺序放入执行栈,继续执行。
  • JS中用于储存待执行回调函数的队列包含两个不同特定的列队:微队列、宏队列。

microtask 微任务(优先级高)

promise,process.nextTick,Object.obverse,MutationObserver

macrotask 宏任务(优先级低)

定时器,setImmediate,I/O(键盘、网络),UI rending

事件循环过程

  1. 执行全局 JS 同步代码,有的是同步语句,有的是异步语句(如setTimeout等)。放入 stack 执行栈
  2. Event Loop 事件循环 不断检查 stack 执行栈 是否为空;
  3. 为空时检查 task queue 任务队列(微队列、宏队列) 是否有异步任务, 如果有则开始执行;
  4. 在本次循环中,取出 microtask queue 微队列 中第一个 microtask 微任务,放入 stack 执行栈 中执行,完成后 microtask queue 微队列 长度减1;
  5. 继续取出 microtask queue 微队列 中第一个 microtask 微任务,放入 stack 执行栈 中执行,以此类推,直到把 microtask queue 微队列 清空;
注意:如果在执行 microtask 微任务 的过程中,又产生了新的 microtask 微任务 ,会加入到队尾,也在本次循环中执行;
  1. 取出 macrotask queue 宏队列 中第一个 macrotask 宏任务,放入 stack 执行栈 中执行;
即 所有微任务 microtask + 一个宏任务 macrotask 。(所以多个网络请求可以同时处于等待状态)
  1. 执行完毕后,调用栈Stack为空;
  2. 重复 Event Loop 事件循环 (第2-7步)

Q:前端异常监控

基本异常

try {
	// 正常逻辑
} catch (error) {
	// 异常处理
}

全局异常

window.onerror = () => {
	// 异常处理
}

window.addEventListener('error', () => {
	// 异常处理
})

Promise内部异常

window.onunhandledrejection = () => {
	// 异常处理
}
 
window.addEventListener('unhandledrejection', () => {
	// 异常处理
})

vue异常

Vue.config.errorHandler = () => {
	// 异常处理
}

Q:vue生命周期

  1. beforeCreate:创建前状态
// 例:
beforeCreated () {
    // 如果localStorage里没有token
    if(!localStorage.getItem('token')){
        // 编程式导航跳转回登录页面
        this.$router.push({
            name:'login';
        })
    }
}
  1. created:创建完毕状态
  2. beforeMount:挂载前状态
  3. mounted:挂载结束状态
  4. beforeUpdate:更新前状态
  5. updated:更新完状态
  6. beforeDestroy:销毁前状态
  7. destroyed:销毁完成状态

Q:Vue Router 导航守卫

导航守卫主要通过跳转或取消的方式守卫导航,有三种方式植入路由导航过程中。

全局守卫

  • 全局前置守卫 router.beforeEach((to, from, next) => {})
  • 全局后置守卫 router.afterEach ((to, from) => {})

router.js 路由模块

// 实例化路由器,配置路由
const router = new VueRouter({
    routes:[{
        path:'/login',
        component: Login
    }, {
        path:'/b',
        component: comB
    }]
})
// 拦截路由配置,在所有路由配置生效之前,先执行 beforeEach 方法
router.beforeEach((to, from, next) => {
    if(to.path === '//login'){
        next()
    }else{
        const token = localStorage.getItem('token');
        if(!token){
            router.push({path:'/login'})
        }else{
            next();  
        }
    }
})

路由独享的守卫

routes:[{
	path:'/a',
	component: comA,
	beforeEnter: (to, from, next) => {}	
}]

组件内的守卫

const comA = {
	template: '\<div>这是路由组件\</div>'beforeRouteEnter (to, from, next) {},
	beforeRouteUpdate (to, from, next) {},
	beforeRouteLeave (to, from, next) {}
}

Q:盒模型

盒模型范围:content, padding, border, margin

标准盒模型

盒子的宽高 = content 的宽高

.box {
	box-sizing: content-box;
}

IE盒模型

盒子的宽高 = content + padding + border 的宽高

.box {
	box-sizing: border-box; 
}
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐