3 月春招 web 前端面试小结

2018-03-28 15:02:12 +08:00
 callmexiaobo
2343 次点击
所在节点    职场话题
15 条回复
jas0ndyq
2018-03-28 15:08:19 +08:00
无话可说?
callmexiaobo
2018-03-28 15:11:21 +08:00
### 说一下 box-sizing 的应用场景
1. box-sizing 的属性值分为两个,border-box 和 content-box,其中,
border-box:width=content+padding+border
content-box:width=content

应用场景
2. border-box 属性在 form 上的使用
当我们在要做一个登陆页面的时候,这时候就需要表单和按钮这些元素
首先我们在 div 中设置两个表单,一个用来输入用户名,一个用来输入密码,同时还有一个登录按钮,
当我们想让这两个表单和一个登录按钮的长度相同时,我们试着把她们三个的 width 的值设置为 100%
但她们的长度并不一致,表单和按钮的 padding,border 值不统一,这时我们给表单的属性中添加一个 box-sizing:border-box,此时表单和按钮的长度保持一致
当不给表单添加 box-sizing:border-box 时,而是添加一个 padding 值会发现表单的长度都有所增加
当给按钮添加 padding 时,她的长度并不会改变,由此可以看出表单默认为 content-box,按钮 submit 默认为 border-box,button 的默认值也为 border-box

3. border-box 属性在盒子中的使用
当我们设置一个宽度为 500px 的盒子,在里面放入四个盒子,分别为上(width:100%),中左(width:60%),中右(width:40%),下(width: 100%),此时在大盒子里面四个盒子排列的很整齐
这是我们给上盒子设置一个 padding 或者 border 为 5px,这时上盒子的长度就会超出大盒子的宽度,此时我们给上盒子添加 box-sizing:border-box,就可以使他的宽度不超出

4. 说到这里你应该就能看到,box-sizing:border-box 这个属性值可以使 dom 元素的 padding 和 border 属性值作用于自身,而不对同级的兄弟元素造成影响

参考链接 [box-sizing 的使用场景]( https://www.jianshu.com/p/3375b15f568f)
callmexiaobo
2018-03-28 15:11:51 +08:00
### 说一下你了解的弹性 flex 布局
1. flex 布局是什么?
flex 布局意为弹性布局,任何一个容器都可以指定为 flex 布局(`display: flex`),行内元素也可以使用 flex 布局(`display: inline-flex`),webkit 内核的浏览器,必须加上(`diaplay: -webkit-flex`),设为 flex 布局后,子元素的 float,clear,vertical-align 属性将失效

2. 基本概念
采用 flex 布局的元素,成为 flex 容器,她的所有子元素自动成为容器成员,称为 flex 项目
容器默认存在两根轴,水平的主轴(main axis)和垂直的交叉轴(cross axis),默认沿主轴排列,单个项目占据的主轴空间叫做 main size

3. 容器的属性
这 6 个属性设置在容器上,flex-direction,flex-wrap, flex-flow, justify-content, align-items, align-content
- flex-direction 决定主轴的方向,即项目排列的方向
flex-direction:row | row-reserve | column | column-reserve

- flex-wrap 决定项目在一条轴线上排不下时,如何换行
flex-wrap:nowrap | wrap | wrap-reserve

- flex-flow 是 flex-direction 属性和 flex-wrap 属性的简写,默认为 row nowrap

- justify-content 决定项目在主轴上的对齐方式
justify-content: flex-start | flex-end | center | space-between | space-around

- align-items 决定项目在交叉轴上如何对齐
align-items:flex-start | flex-end | center | baseline | stretch

- ailgn-content 决定多根轴线的对齐方式,如果项目只有一根轴线,该属性不起作用
align-content: flex-start | flex-end | center | space-between | space-around | stretch
callmexiaobo
2018-03-28 15:12:14 +08:00
4. 项目的属性
这 6 个属性设置在项目上,order,flex-grow,flex-shrink,flex-basis,flex,algin-self
- order 定义项目的排列顺序,数值越小,排列越靠前,默认为 0
order:<`integer`>

- flex-grow 定义项目的放大比例,默认为 0,即如果存在剩余空间,也不放大
flex-grow:<`number`>
如果所有项目的 flex-grow 属性都为 1,则他们将等分剩余空间(如果有的话)

- flex-shrink 定义项目的缩小比例,默认为 1,即如果空间不足,该项目将缩小
flex-shrink: <`number`>
如果一个项目的 flex-shrink 属性为 0,其他项目为 1,则空间不足时,前者不缩小

- flex-basis 定义项目在分配多余空间之前,项目占据的主轴空间,默认为 auto,可以设置为 width 或 height 一样的值
flex-basis:<`length`> | `auto`

- flex 属性是 flex-grow,flex-shrink,flex-basis 的简写,默认为 0 1 auto
默认存在两个快捷值 auto ( 1,1,auto )和 none ( 0,0,auto )

- align-self 允许单个项目和其他项目不一样的对齐方式,可覆盖 align-items 属性,默认值为 auto
align-self:auto | flex-start | flex-end | center | baseline | stretch

参考链接 [flex 布局教程:语法篇]( http://www.ruanyifeng.com/blog/2015/07/flex-grammar.html)
参考链接 [flex 布局教程:实战篇]( http://www.ruanyifeng.com/blog/2015/07/flex-examples.html)
callmexiaobo
2018-03-28 15:12:39 +08:00
### 说一下一个未知宽高元素怎么上下左右垂直居中
1. position+absolute
```
position: absolute;
top: 50%;
left: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
```
2. flex
```
display: flex;
align-items: center;
justify-content: center;
```
3. table-cell
```
display: table;(父)

display: table-cell;(子)
text-align: center;
vertical-align: middle;
```
4. position+margin
```
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
```

参考链接 [未知宽高元素水平居中]( http://www.cnblogs.com/shytong/p/4959287.html)
callmexiaobo
2018-03-28 15:12:55 +08:00
### 说一下原型链,对象,构造函数之间的一些联系
1. javascript 函数的 new 关键字到底是干什么的
不用创建临时对象,因为 new 会帮你做
不用绑定原型,因为 new 会帮你做
不用 return 临时对象,因为 new 会帮你做
不要给原型想名字了,因为 new 指定名字为 prototype

2. 对象与原型链(__proto__和 prototype)
每个 JS 对象一定对应一个原型对象,并从原型对象继承属性和方法
对象__proto__属性的值就是他所对应的原型对象
只有函数才有 prototype 属性,当你创建函数时,JS 会为这个函数自动添加 prototype 属性,值是一个有 constructor 属性的对象,不是空对象,而一旦你把这个函数当作构造函数调用时,那么 JS 就会帮你创建该构造函数的实例,实例继承构造函数 prototype 的所有属性和方法
对象的__proto__指向自己构造函数的 prototype
Object.prototype 是原型链的顶端,Object 本身是构造函数,继承了 Function.prototype,Function 也是对象,继承了 Object.prototype
Object.prototype.__proto__ === null,说明原型链到 Object.prototype 终止
null 表示‘没有对象’,即此处不该有值
Function 本身就是函数,Function.__proto__是标准的内置对象 Function.prototype,而 Function.prototype.__proto__是标准的内置对象 Object.prototype

3. 构造函数和原型
原型:每一个 JS 对象(除 null 外)在创建的时候就会与之关联另一个对象,这个对象就是我们说的原型,每个对象都会从原型继承属性
__proto__:每一个 JS 对象(除 null 外)都具有的一个属性,叫__proto__,这个属性会指向该对象的原型
constructor:每一个原型都有一个 constructor 属性指向关联的构造函数


参考链接 [js 的 new 到底是干什么的]( https://zhuanlan.zhihu.com/p/23987456?utm_medium=social&utm_source=wechat_session)
参考链接 [从__proto__和 prototype 来深入理解 JS 对象和原型链]( https://github.com/creeperyang/blog/issues/9)
参考链接 [javascript 深入之从原型到原型链]( https://github.com/mqyqingfeng/Blog/issues/2)
参考链接 [从探究 Function.__proto__===Function.prototype 过程中的一些收获]( https://github.com/jawil/blog/issues/13)
callmexiaobo
2018-03-28 15:13:15 +08:00
### DOM 事件绑定的几种方式
1. html 中直接绑定
html 中绑定事件叫做内联绑定事件,不利于分离

2. js 中直接绑定
js 中直接绑定称为赋值绑定函数,缺点是只能绑定一次

3. addEventListener
target.addEventListener(type, listener[, useCapture])
target 表示要监听事件的目标对象,可以是一个文档上的元素 DOM 本身,Window 或者 XMLHttpRequest
type 表示事件类型的字符串
listener 为当指定的事件类型发生时被通知到的一个对象
useCapture 为设置事件的捕获或者冒泡
true 为捕获,false 为冒泡(默认)
addEventListener 可以给同一个 dom 元素绑定多个函数,并且执行顺序按照绑定顺序执行,且执行顺序与 useCapture 无关
给一个 dom 元素绑定同一个函数,最多只能绑定 useCapture 类型不同的两次
addEventListener 只支持到 IE9,为兼容性考虑,在兼容 IE8 及一下浏览器可以用 attachEvent 函数,和 addEventListener 函数表现一样,但它绑定函数的 this 会指向全局

4. 事件的解绑
通过 dom 的 on***属性设置的事件,可以用`dom.onclick = null`来解绑
通过 addEventListener 绑定的事件可以使用 removeEventListener 来解绑,接受参数一样
对于使用 removeEventListener 函数解绑事件,需要传入的 listener,useCapture 和 addEventListener 完全一致才可以解绑事件

5. 事件冒泡
事件开始时由最具体的元素接受,然后逐级向上传播到较为不具体的节点

6. 事件捕获
事件捕获的思想是不太具体的 DOM 节点应该更早接收到事件,而最具体的节点应该最后接收到事件,与事件冒泡顺序相反

7. DOM 事件流
DOM 事件流包括三个阶段,事件捕获阶段,处于目标阶段,事件冒泡阶段,首先发生的是事件捕获,为截获事件提供机会,然后是实际的目标接受事件,最后一个阶段是事件冒泡阶段,可以在这个阶段对事件作出响应

8. stopPropagation()和 stopImmediatePropagation()
stopPropagation()既可以阻止事件冒泡,也可以阻止事件捕获,也可以阻止处于目标阶段
stopImmediatePropagation()既可以阻止事件冒泡,也可以阻止事件捕获,还会阻止该元素其他事件的发生

参考链接 [从一个事件绑定说起-DOM]( https://qiutc.me/post/binding-event-of-dom.html)
callmexiaobo
2018-03-28 15:13:38 +08:00
### 有没有了解 http2,websocket,https,说一下你的理解以及你了解的特性
1. http2.0 和 http1.1 的区别
- 多路复用
多路复用允许单一的 http2 连接同时发起多重的请求-响应信息
http 性能优化的关键并不在于高带宽,而是低延迟,TCP 连接会随着时间进行自我调谐,起初会限制连接的最大速度,如果数据成功传输,会随着时间的推移提高传输的速度,这种调谐则称之为 TCP 慢启动,由于这种原因,让原本就具有突发性和短时性的 http 连接变得十分低效
http2 通过让所有数据流共用同一个连接,可以更有效的使用 TCP 连接,让高带宽也能真正服务于 http 的性能提升
小总结:多路复用技术,单连接多资源的方式,减少服务端的链接压力,内存占用更少,连接吞吐量更大,由于减少 TCP 慢启动时间,提高传输的速度

因为所有的 http2 的请求都在一个 TCP 连接上,所以在 http1 中的自动化合并文件和 Sprite 合图等资源合并减少请求的优化手段对于 http2 来说是没有效果的

- 二进制分帧
http2 在应用层和传输层之间增加一个二进制分帧层,http2 会将所有传输的信息分割成更小的消息和帧,并对他们采用二进制格式的编码,其中 http1 的首部信息会被封装成 Headers 帧,而我们的 request body 则封装到 Data 帧里面

- 首部压缩
http 请求和响应都是由状态行,请求/响应头部,消息主题三部分组成,一般而言,消息主题都会经过 gzip 压缩,或者本身传输的就是压缩后的二进制文件,但状态行和头部却没有经过任何压缩,直接以纯文本传输,浪费流量资源
原理:头部压缩需要在支持 http2 的浏览器和服务端之间,维护一份相同的静态字典,包含常见的头部名称与值的组合,维护一份相同的动态字典,可以动态的添加内容,支持基于静态哈夫曼码表的哈夫曼编码

- http2 支持服务器推送
服务端推送是一种在客户端请求之前发送数据的机制,当代网页使用了许多资源:html,样式表,脚本等,在 http1.x 中这些资源每一个都必须明确的请求,这可能是一个很慢的过程,因为服务器必须等待浏览器做的每一个请求,网络经常是空闲和未充分使用的
为了改善延迟,http2 引入了 server push,它允许服务端推送资源给浏览器,在浏览器明确请求之前,一个服务器经常知道一个页面需要更多的附加资源,在他响应浏览器第一个请求时,可以开始推送这些资源,这允许服务端去完全充分利用一个可能空闲的网络,改善页面加载的时间
有了 http2 的服务端推送,http1 时代的内嵌资源的优化手段也变得没有意义了,使用服务端推送更高效,因为客户端可以缓存起来,甚至可以不同页面之间共享

- 并行双向字节流的请求和响应
在 http2 上,客户端和服务端可以把 http 消息分解成回不依赖的帧,然后乱序发送,最后再在另一端把她们重新组合起来,同一链接上可以有多个不同方向上的数据在传输,客户端可以一边乱序发送 stream,也可以一边接收着服务端的响应,在服务端同理
把 http 消息分解为独立的帧,交错发送,然后在另一端重新组装是 http2 最重要的一项增强,这个机制会在整个 web 技术栈中引发一系列的连锁反应,从而带来巨大的性能提升,因为
1. 可以并行交错的发送请求,请求之间互不影响
2. 可以并行交错的发送响应,响应之间互不干扰
3. 只使用同一个连接即可并行的发送多个请求和响应
4. 消除不必要的延迟,从而减少页面加载的时间
也就是说‘域名分区’的优化手段对于 http2 来说是无用的,因为资源都是并行交错发送,且没有限制,不需要额外的多域名并行下载

- http2 的请求优先级
每个 http2 流里面有个优先值,这个优先值确定着客户端和服务端处理不同的流采取不同的优先级策略,高优先级的流应该优先发送,但又不是绝对的准守,可能又会引入首队阻塞的问题,高优先级的请求慢导致阻塞其他文件的交付,分配处理资源和客户端与服务器间的带宽,不同优先级的混合是必须的

2. https
http 协议传输的数据都是未加密的,也就是明文的,因此使用 http 协议传输隐私信息非常不安全,为了保证这些隐私数据能加密传输,于是网景公司设计了 SSL 协议用于对 http 协议传输的数据进行加密,从而诞生了 https,现在的 https 使用的都是 TSL 协议

https 在传输数据之前需要客户端和服务端之间进行一次握手,在握手的过程中将确立双方加密传输数据的密码信息,TSL / SSL 协议不仅仅是一套加密传输的协议,TSL/SSL 中使用了非对称加密,对称加密以及 hash 算法

握手过程:
- 浏览器将自己支持的一套加密规则发送给网站

- 网站从中选出一组加密算法和 hash 算法,并将自己的身份信息以证书的形式发回给浏览器,证书里面包含了网站地址,加密公钥,以及证书的颁发机构等信息
- 获得网站证书后浏览器要做以下工作
1. 验证证书的合法性(颁发证书的机构是否合法,证书中包含的网站地址是否与正在访问的地址一致)如果证书受信任,则浏览器栏里会显示一个小锁头,否则会给出证书不受信的提示
2. 如果证书受信任,或者是用户接受了不受信的证书,浏览器会生成一串随机数的密码,并用证书中提供的公钥加密
3. 使用约定好的 hash 计算握手信息,并使用生成的随机数对消息进行加密,最后将之前生成的所有信息发送给网站

- 网站接收浏览器发来的数据之后要做以下工作
1. 使用自己的私钥将信息解密取出密码,使用密码解密浏览器发来的握手信息,并验证 hash 是否与浏览器发来的一致
2. 使用密码加密一段握手信息,发送给浏览器

- 浏览器解密并计算握手信息的 hash,如果与服务端发来的 hash 一致,此时握手过程结束,之后所有的通信数据将由之前浏览器生成的随机密码并利用对称加密算法进行加密

这里浏览器与网站互相发送加密的握手信息并验证,目的是为了保证双发都获得了一致的密码,并且可以正常的加密解密数据

其中非对称加密算法用于在握手过程中加密生成的密码,对称加密算法用于对真正传输的数据进行加密,而 hash 算法用于验证数据的完整性

由于浏览器生成的密码是整个数据加密的关键,因此在传输的时候使用了非对称加密算法对其进行加密,非对称加密算法会生成公钥和私钥,公钥只能用于加密数据,因此可以随意传输,而网站的私钥用于对数据进行解密,所以网站都会非常小心的保管自己的私钥,防止泄漏

TSL 握手的过程中如果有任何错误,都会使加密连接断开,从而阻止了隐私数据的传输,正是由于 https 非常的安全,攻击者无法从中找到下手的地方,于是更多的是采用了假证书的手法来欺骗客户端,从而获取明文信息

3. webSocket 概述
http 协议是一种无状态的协议,要实现有状态的会话必须借助一些外部机制如 session 和 cookie,这或多或少或带来一些不便,尤其是服务端和客户端需要实时交换数据的时候
webSocket 允许服务器和客户端进行全双工通信,传统的 http 是单工通信的,它只允许客户端向服务端发出请求,服务端被动返回数据,而不能主动向客户端传递数据
webSocket 的请求头部
```
Connection: Upgrade //通信协议提升
Upgrade: websocket //传输协议升级为 websocket
Sec-WebSocket-Key: ********** //握手协议密钥,base64 位编码的 16 字节的随机字符串
```
webSocket 的响应头部
```
Connection: Upgrade //通信协议提升
Upgrade: websocket //传输协议升级为 websocket
Sec-WebSocket-Accept: ********** //将客户上报的 Sec-WebSocket-Key 和一段 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)进行拼接,再将这个拼接的字符串做 SHA-1 hash 计算,然后再把得到的结果通过 base64 加密,最后再返回给客户端
```
WebSocket,ajax 轮询和 long poll

参考链接 [http2.0 协议你应该准备的面试题]( https://www.toutiao.com/i6491669443721036302/)
参考链接 [面试时如何优雅的谈论 http]( https://www.jianshu.com/p/52d86558ca57)
参考链接 [http2.0 的奇妙日常]( http://www.alloyteam.com/2015/03/http2-0-di-qi-miao-ri-chang/) 参考链接 [浅谈 WebSocket 协议及其实现]( http://geocld.github.io/2017/10/21/websocket/)
callmexiaobo
2018-03-28 15:13:58 +08:00
1. 说说对洗牌算法的理解和如何验证其正确性
洗牌算法之前没了解过,刚面到的时候好蒙,闲话不多说,这里说下洗牌算法的 js 实现

Fisher-Yates
这是最经典的洗牌算法,其算法思想是从原数组中随机抽取一个新的元素到新数组中

从还没处理的数组(假如还剩 n 个)中,产生一个[0,n]之间的随机数 random

从剩下的 n 个元素中把第 random 个元素取出到新数组中

删除原数组第 random 个元素

重复第 2 3 步直到所有的元素取完

最终返回一个新的打乱的数组

代码实现
```
function shufle(arr){
var result = [],
random;
while(arr.length > 0){
random = Math.floor(Math.random() * arr.length);
result.push(arr[random])
arr.splice(random, 1)
}
return result;
}
```
这种算法的时间复杂度是 O(n2)

参考链接 [洗牌算法的 js 实现]( https://github.com/ccforward/cc/issues/44)
参考链接 [Fisher – Yates shuffle 洗牌算法]( https://gaohaoyang.github.io/2016/10/16/shuffle-algorithm/)
callmexiaobo
2018-03-28 15:14:18 +08:00
2. 说一下你对事件委托和事件代理的理解?
什么是事件委托?它还有一个名字叫事件代理,JavaScript 高级程序设计上讲:事件委托就是利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件,当我们需要对很多元素添加事件的时候,可以通过事件添加到他们的父节点二将时间委托给父节点来触发处理函数

为什么要使用事件委托?
一般来说,dom 需要有事件处理程序,我们都会直接给它设置事件处理程序就好了,那如果是很多的 dom 需要添加事件处理呢?比如我们这里有 100 个 li,每个 li 都有相同的 click 事件,那么我们会用 for 循环的方法来遍历所有的 li,然后给他们添加事件,那么这样会存在什么问题呢?
在 JavaScript 中,添加到页面上的事件处理程序的数量将直接关联到页面整体的运行性能,因为需要不断的与 dom 节点进行交互,访问 dom 的次数越多,引起浏览器重绘与重排的次数就越多,就会延长整个页面交互就绪时间,这就是为什么性能优化的主要思想是减少 dom 操作的原因,如果使用事件委托,就会将所有的操作放到 js 程序里面,与 dom 的操作就只需要交互一次,这样就能大大的减少与 dom 的交互次数,提高性能
每个函数都是一个对象,是对象就会占用内存,内存占用率就越大,自然性能就差了,比如上面的 100 个 li,就要占用 100 个内存空间,如果是 1000 个,10000 个呢,如果使用事件委托,那么我们就可以只对它的父级这一个对象(如果只有一个父级)进行操作,这样我们就需要一个内存空间就够了,是不是省了很多,自然性能就会更好

事件委托的原理?
事件委托是利用事件的冒泡机制来实现的,何为事件冒泡呢?这里介绍下浏览器 dom 事件处理的过程,dom2.0 模型将事件流程分为三个阶段:事件捕获阶段,事件目标阶段,事件冒泡阶段。
事件捕获:当某个元素触发某个事件,顶层对象 document 就会发出一个事件流,随着 dom 树的节点向目标元素节点流去,直到到达事件真正发生的目标元素,在这个过程中,事件相应的监听函数是不会被触发的
事件目标:当到达目标元素之后,执行目标元素该事件相应的处理函数,如果没有绑定监听函数,那就不执行
事件冒泡:从目标元素开始,往顶层元素传播,途中如果有节点绑定了相应的事件处理函数,这些函数都会被一次触发,如果想阻止事件冒泡,可以使用 event.stopPropgation()或者 event.cancelBubble=true 来阻止事件的冒泡传播

事件委托怎么实现:
Event 对象提供了一个属性叫 target,可以返回事件的目标节点,我们称为事件源,也就是说,target 就可以表示为当前事件操作的 dom,但是不是真正操作的 dom,当然,这个是有兼容性的,标准浏览器用 event.target,IE 浏览器用 event.srcElement,此时知识获取了当前节点的位置,并不知道是什么节点名称,这里我们用 nodeName 来获取具体是什么标签名,这个返回的是一个大写的,一般转化为小写再进行比较
如果你想将事件委托给父元素来处理,但每个子元素的事件内容又不相同时,这里我们可以给每个子元素添加一个唯一的 key 来作标识,然后在父元素中对其进行分别的处理
```
const list = document.querySelector('#list)
const lists = list.querySelector('#list > li')
for(let i=0; i<lists.length; i++){
lists[i].dataset.key = 'list-' + i
}
list.addEventListener('click',function(e){
const event = e || window.event
const target = event.target || event.srcElement
if(target.nodeName.toLocaleLowerCase() === 'li'){
switch(target.dataset.key){
case 'list-1':
do something-1
break
case 'list-2':
do something-2
break
...
default:
do something-3
break
}
}
})
```
参考链接 [JavaScrip 事件代理和委托]( https://www.cnblogs.com/owenChen/archive/2013/02/18/2915521.html)
参考链接 [JS 的事件委托和事件代理详解]( https://www.cnblogs.com/liugang-vip/p/5616484.html)
HoHoibin
2018-03-28 16:48:05 +08:00
这篇我好像在掘金上看过了
callmexiaobo
2018-03-28 16:57:36 +08:00
@jas0ndyq 对啊,我在哪里发了篇试试
callmexiaobo
2018-03-28 16:57:56 +08:00
@HoHoibin 在这边莫名其妙发不出来
YuTengjing
2019-04-12 19:29:58 +08:00
洗牌算法 lodash 里面有,可以看看 lodash 的实现。
callmexiaobo
2019-04-16 14:21:47 +08:00
@YuTengjing 我已经好久没看算法了,已忘光

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/442139

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX