TRPG Engine 的项目工程化实践

First of all

一个人维护一个项目很久了, 差不多是时候总结一些经验做一些些输出了。因此来汇总一下这些年我对我的开源项目TRPG Engine做的工程化实践。

首先,我们要明确一点,即为什么要做工程化:

  • 提升开发效率
  • 降低开发成本
  • 提升产品质量
  • 降低企业成本

所有的代码, 所有的技术都依托于业务, 所有的手段都是为了最终目的而服务的。因此我们工程化最终目的就是提高产出。

Git workflow

参考文章:

Commitlint

使用 Commitlint 来保证项目成员或者外部贡献者的提交确保同样的格式。

TRPG Engine是使用 commitlint 来实现的提交内容校验

一般常用的一种提交方式是 angular 格式。

例:

1
2
3
fix: some message

fix(scope): some message

参考文档: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-format

提交的类型说明:

1
2
3
4
5
6
7
8
feat:新功能(feature
fix:修补bug
docs:文档(documentation)
style: 格式(不影响代码运行的变动)
refactor:重构(即不是新增功能,也不是修改bug的代码变动)
perf:性能优化
test:增加测试
chore:构建过程或辅助工具的变动

https://github.com/angular/angular/blob/master/CONTRIBUTING.md#type

通过标准的 commit message 可以生成Change log

基于git commit message来自动收集并生成CHANGELOG

在升版本时可以通过standard-version来实现package.json version和changelog一起生成

Prettier and Eslint

确保项目有一个统一的格式是非常重要, 可以有效的防止贡献者因为不统一的格式带来的提交变更过大。

试想一下,一个人用的是4空格缩进,另一个人用的是2空格缩进。那么会来带的协作上的问题并导致code review无法正常进行

目前前端流行使用prettier + eslint的组合来确保项目的格式统一。

prettier是目前前端流行的Formatter工具, 用于保证项目能具有统一的格式,各大编辑器平台都具有插件支持,同时也支持许多语言

eslint 是一款确保你的代码质量的工具,特别是融合了tslint后更加成为了前端代码质量保证的唯一选择。其功能与prettier有部分交集,因此在同时使用两者时需要使用eslintprettier预设来确保不会出现冲突

另外推荐一个跨语言的格式工具, EditorConfig, 虽然功能没有prettier这么强大,但是他的优势是跨语言,更加具有通用性。

使用 lint-staged 来在工程化运行时中确保格式正确

Testing and benchmark

单元测试与基准测试是程序员要面对的两大关键测试方式。

单元测试应当用于几乎所有的代码, 来确保代码不出错(主要是可以防止其他协作者无意间改坏你的代码)

而基准测试用于对性能要求较高的场合,比如后端的高并发场景,以及前端的高CPU计算(By the way, 对于前端的高CPU场景我建议是使用web worker来处理,这样可以使用多线程而不会影响用户正常操作)

如何写单元测试

总所周知,一个纯函数是最好测的。那么单元测试的存在就相当于监督我们时刻注意将一些可以抽象出来的纯函数抽象出来,来方便写单元测试。能有效提高代码质量。

而对于一些副作用比较大的场景,我们需要想办法构建其上下文。比如TRPG Engine的后端代码,单元测试就是真实起了一个空白的数据库, redis, 和后端实例,通过数据库操作以及及时清理测试过的数据库代码来保证环境干净

对于比较难以测试的前端组件, TRPG Engine的做法是打快照,通过快照的变更告知开发者是否有一个 预期/非预期 的变更出现

单元测试的存在也应当集成到CI中,以确保每次执行都可用。

Bundler

在现代前端编程中, 打包编译是前端不得不重视的一环。

从less scss等css拓展语言, 到ts, coffee的js拓展。

从babel的es6,7,8,9支持, 到各种动态加载, 各种优化压缩。

面对日益复杂的现状,前端已经离不开打包工具的存在。

一般来说,我们常用的打包工具是webpackwebpack大而全,并提供足够的自定义化能力。是目前来说前端业务开发打包的不二之选。但成也萧何败萧何,webpack虽然十分强大, 但是配置非常非常复杂,甚至有webpack工程师这一说法,因此在一些特殊场景下, 我也十分推荐一些其他的打包工具。

CI/CD

Continuous Integration and Continuous Delivery
持续集成与持续交付

市面上有很多免费的CI系统, 比如 travis, appveyor, circleci, github action等等, 再比如gitlab自带的ci系统。

总的来说都大同小异, 我们使用CI系统无非是关注单元测试有没有跑通,如何可以的话顺便输出一份coverage覆盖率报告。如果再可以的话可以把代码编译了以后输出编译报告。来方便衡量每一次提交的代码质量。

一般来说CI/CD都放在一起来讲,因为只是最终的输出不一样罢了。

CD可以做一些每次提交都编译文件, 或者往特殊分支提交就部署页面的功能。(比如每次向docs提交代码都编译文档并部署到远程服务器上)

Analytics and Monitor

一些现成的分析服务:

  • Google Analytics
  • Datadog
  • Posthog
  • Sentry Tracking
  • Grafana
  • uptimerobot

这些工具是帮助你的项目在上线后能分析或监控的方式。通过这些工具可以收集用户的行为,检测服务可用性等。

监控可以帮助你的服务稳定可靠,发生宕机的情况能够第一时间发现,减少用户损失。没有监控的服务就是没有地图和罗盘的轮船 —— 什么时候沉默?天知道!

而用户行为收集是下一步迭代的重要依据,如果是用户比较少用的功能则可以考虑减慢开发进度。

对于监控,我推荐posthog,这是一款新兴的分析系统。推荐的理由很简单,因为他是开源的,我们可以自己部署,然后把他的数据进行二次加工与处理。

Performance

性能是提升用户体验的重要一环,即常规感知中的“卡不卡”。

我们有很多方式去提升性能,比如采集用户的首屏渲染时间,比如手动打开devtool去对具体某个操作进行堆栈分析,再比如用Lighthouse跑个分 —— google的工具都非常棒。

参考文档:

Logging

日志应当是我们分析问题最关键的一步,重视日志是一个有一定规模的项目最基本的一步。

大部分项目都会记录本地日志,但本地日志过于原始,很难产生一定价值。目前业内流行的方案是为日志单独准备一个elasticsearch服务, 所有日志中心化到一个数据库,再通过配套的 kibana 进行数据检索。

另外使用外部日志中心的好处在于项目的微服务化与集群化。因为项目离开单实例部署后日志分散在各地,更加难以查询问题。

对于 TRPG Engine 来说,目前使用的第三方日志服务是 Loggly, 因为ELK部署较耗资源,而其他大多数的日志服务都是收费的。Loggly具有一定的免费额度, 但是对中文编码不是很友好。

相关资源:

  • local file
  • Loggly
  • ELK
  • 阿里云日志腾讯云日志等服务商…

Error Report

除了日志, 我们可能需要一个单独的错误汇报中心。因为日志是一种被动式的、托底的问题查找方式。一个主动的错误汇报会让我们更早介入问题以防止更大的错误扩散。

TRPG Engine使用了Sentry作为错误汇报中心。开源,云服务具有一定免费额度,错误汇报可以带上堆栈信息和相关上下文,并且新的错误会发送邮件到相关人员。

开源对于企业的意义在于能够自己部署,企业也可以部署自己的sentry,就像是gitlab一样

  • Sentry

Develop for Distributed

有一点比较重要的就是在开始一个项目的时候就要考虑到之后的场景。在开发时就需要考虑分布式部署的场景。至少对于可能有分布式的场景进行一层抽象,就算现在不做,以后也要做。这点TRPG Engine走过很多弯路。

  • 比如日志,需要考虑使用外部日志的情况
  • 比如文件管理,需要考虑使用云文件
  • 比如配置,需要考虑使用外部的配置中心
  • 比如缓存,少用内存缓存而用外部缓存
  • 比如数据库 —— 当然这个大多数情况不用操心,除非用的是sqlite

因为现代的程序架构早就不是以前一台服务器打天下的时候了。有效组合各个服务可以帮助程序快速增长。

Coding with config

基于配置的代码会使你的程序更加灵活。特别是线上情况往往不适合发布,或者长期故障。通过配置我们可以将一部分代码关闭以保证整体代码可用性。

很多公司的功能开发分成两种管理方案,一种是做功能时切出一个功能分支,等到开发完毕后再合并到主分支。

还有一种方案是持续合并到主干分支,但是由配置来将新功能关闭。

说不清那种方案好,但是基于配置进行开发给与工程化代码更加灵活。

Read with Document and Comment

文档也是工程化代码的实践

一个静态文档网站可以帮助使用者快速理解整个项目

一行注释可以帮助代码阅读者理解你的用意,更重要的是可以防止其他的协作者在不了解你的用意的情况下改坏代码。

好的开源项目一定有足够文档,而一个好的企业项目代码中一定有很多注释。

对于企业业务项目来说,文档可能没有办法强制要求,但是需要明确一点的是注释是为自己写的,试想一下,一个复杂一点的方法,等一个月后,还能保证自己能理解当时自己的用意么?

Flexible Architecture

可变、灵活架构。

一个项目想要换底层架构是非常困难且痛苦的,想要解决这个问题,只有架构预先进行足够的设计,提前预想未来5年10年的业务变更。

比如插件化的架构就能保证业务代码的可拓展性。

MiniStar: 一个用于实现微内核(插件化)架构的前端框架

Dockerize

docker是现在开发的趋势,统一且单一的环境。

做过以前代码部署的工程师一定了解在不同环境下部署程序的痛苦,就算打包成一个war包也可能会有各种环境导致的奇怪问题。而docker就是解决这个问题的工具。

在实际中有很多使用场景:

  • 统一开发环境(统一开发环境)
  • 快速部署(无需搭建环境)
  • 集群部署(k8s, docker swarm)

记一个多平台End of Line的坑

问题

在执行自己编译的cli时出现:

1
env: node\r: No such file or directory

原因

在windows电脑发布代码, 其单行结束符为\r\n。然后mac执行时仅将\n视为换行符。因此程序试图去找node\r这个程序。当然是找不到的了。

如何处理该问题?

1
npx crlf --set=LF ./**/*.js

可以在发布脚本执行前执行一下以确保End of Line的正确。

相关库

参考资料

requirejs踩坑记录

灵异事件之薛定谔的白屏

场景复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<head>
<script>
setTimeout(function () {
var sha1Script = document.createElement('script');
sha1Script.src = 'https://xxx/sha1.min.js';
sha1Script.addEventListener('load', () => {
// any logic
})
document.body.appendChild(sha1Script);
})
</script>
</head>

<body>
<script type="text/javascript" src="require.min.js"></script>
<script type="text/javascript" src="main.js"></script>
</body>
</html>

main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
require.config({
// ... here is config
});

requirejs.onError = function (err) {
console.error(err);
};

require([
// ...
], function () {
// main logic
});

控制台会出现报错Error: Mismatched anonymous define() module: function(){return y}, 但是有时候白屏有时候正常。

分析原因

可知白屏是因为标记为main logic的代码没有正常工作导致的。

分析下来可得如下结论

当出现错误时代码执行顺序为:

1
2
3
-> require.min.js
-> sha1.min.js
-> main.js

当出现正确时代码执行顺序为:

1
2
3
-> require.min.js
-> main.js
-> sha1.min.js

易得是代码执行时序的问题。那么问题来了, 为什么会出现这种情况?

源码分析

我们可以看一下requirejs的源码: https://github.com/requirejs/requirejs/blob/HEAD/require.js

当我们执行sha1.min.js时, sha1.min.js检测到当前有amd环境,调用define将自己注入到requirejs的运行时中, 因为没有调用require相关方法, 因此requirejs将其定义推入自身的globalDefQueue中。(相关代码: L2061)。

当我们执行main.js时, 调用require.config时, requirejs会尝试消费所有的globalDefQueue, 此时在queue中的参数为[null, [], function(){…}], 因为第一个参数(name)为null, 则会抛出异常Mismatched anonymous define() module...(相关代码: L1244)

requirejs 会通过调用自身的onError方法抛出异常, 如果没有手动覆盖onError的话会调用内置的defaultOnError方法(相关代码: L1870), 而defaultOnError的实现很简单:

1
2
3
function defaultOnError(err) {
throw err;
}

直接向顶层抛出异常, 导致整个script的运行时中断,后续的代码当然无法执行。

解决方案

可以看见原来的实现是有onError的覆写的, 只不过因为在require.config之后执行导致没有执行。

最佳的解决方案是尽可能早的覆写requirejs.onError方法。

吐槽

如果看源码的话。可以看到requirejs写明了允许匿名模块

Allow for anonymous modules

相关代码

但是实际使用中却会报错。而且默认的报错是直接向顶层抛出。

这种情况就是A做错了,但是却导致B无法正常执行。这种场景非常难debug

关于人生, 关于生老病死

今天凌晨4点多, 噩耗传来。小时候一直疼爱我的外公结束了自己的一生。自然死亡, 寿终正寝。

心里有说不出的难受,又一位长辈永远离开了我。昨天见了外公的最后一眼,面对躺在床上无法与人交流,生活不能自理的老人,纵有千言万语我却说不出一句话,而现在,我已经永远失去了这个机会。而逐渐的,令人恐惧而不得不面对的是,迟早会轮到我的父母。而我无疑是爱他们的。我不害怕死亡,但我害怕离别。

有时候我就会在想,宇宙广阔而浩瀚,人如浮游生物一般,终生被重力束缚在大地上。而短短这一生百年,大多数人也无法对人类史留下什么令人称道的痕迹。令人舒适的事在于所有的一切都依据之前的惯性前进,昨天、今天、明天,每天都按照一样的痕迹走下去,今天的自己比昨天的自己更加优秀一点,而明天的自己再比今天的自己更加优秀一点,太阳照常升起,照常落下。而在这惯性之中,难免生活会时不时来打破这种惯性:生老病死,自然天灾,人心莫测。有时候我会想,也许人生就是一场游戏,为了不让这个游戏太过无聊,设计者会在这场游戏中增加一些随机的事件,也许是好事、也许是坏事。但往往总是违背人们本身的意愿去发生。

有的时候,人生平静如水,让人觉得厌烦与无聊。而有的时候,意外却接踵而至,这时候,人又开始向往原来平静的生活了。人本身就是这样矛盾对立统一的综合体。

而拓展到整个宇宙的视角中,一个人内心不论是怎么样的波涛汹涌,在宇宙的尺度下都会显得无比渺小。对面这浩瀚空间,也许很多人用一生为之奋斗的东西,其实毫无意义。但也不能说是毫无意义,每个独立的个体都没有义务向他人负责,自我实现就是最大的负责。

然而, 生活还要继续, 人生还要前行。做好自己,行出不后悔,即可。

一些胡言乱语罢了。

HTTPS客户端证书校验

简介

互联网正在逐步走向越来越安全的趋势, Chrome 90 将默认使用https。而但凡对这方面有一定了解的都会知道https: 证书, 校验, 签发机构…等等等等。

而这些我们所熟知的东西, 即一般我们所说的通过ssl层进行传输与校验的, 一般指的是服务端证书。而我们今天要说说客户端安全证书。

Client Authenticate

客户端安全证书一般不常见, 只出现在对安全有一定需求的内部系统中。他的作用是规定哪些人可以访问: 客户端根据服务端配置的证书签发来下的子证书来对服务端的资源进行访问, 而服务端会对其进行校验 —— 校验不通过则不允许访问。

服务端证书恰恰相反: 他是客户端来校验服务端是否是一个正确的, 没有被篡改过的服务端。

Handshake

以下是一个客户端进行TLS握手授权的示例:

  • 协商阶段
    • 客户端发送一个 ClientHello 消息到服务端
    • 服务端返回一个 ServerHello 消息
    • 服务端发送他的 Certificate 消息
    • 服务端发送他的 ServerKeyExchange 消息以用于交换秘钥
    • 服务端发送一个 CertificateRequest 来请求客户端发送他的证书
    • 服务端发送一个 ServerHelloDone 消息表示服务端已经完成了协商消息的发送
    • 客户端返回一个 Certificate 消息, 其中包含了客户端的证书
    • 客户端发送 ClientKeyExchange 消息, 其中包含了公钥或者公钥加密的 PreMasterSecret
    • 客户端发送 CertificateVerify 消息, 这是使用客户机证书的私钥对先前握手消息的签名。可以使用客户端证书的公钥来验证此签名。这让服务器知道客户端可以访问证书的私钥,以确保客户端是合法的。
    • 协商完毕, 现在他们双方有一个用于对称加密的随机数秘钥了
  • 客户端发送一个ChangeCipherSpec 记录来告知服务器: 所有的信息都将进行身份验证
  • 服务端返回 ChangeCipherSpec
  • 握手完毕

参考文档

字节跳动面试记录

一面

关于项目:

  • 抽象结构树
    • 如何根据一颗 AST 渲染出组件
  • 富文本编辑器
    • 实现的难点

常规问题:

  • webpack 打包细节

    • commonjs 与 esmodule 是如何相互转换(这里不会)
  • Task 与 Microtask 的区别, 以及时序

    详细说明可以见: https://html.spec.whatwg.org/multipage/webappapis.html#queuing-tasks

    简单的说就是每次执行task之前会把microtask都处理掉。在处理过程中加入的microtask也会按照顺序处理掉

  • 闭包问题:

    1
    2
    3
    4
    5
    for (var i = 0; i < 5; i++) {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    }

    结果是什么? (我回答了5个4, 其实是5个5..扶额)

    然后问如何让其输出 0 1 2 3 4
    解法一:

    1
    2
    3
    4
    5
    6
    7
    for (var i = 0; i < 5; i++) {
    (function (_i) {
    setTimeout(() => {
    console.log(_i);
    }, 1000);
    })(i);
    }

    解法二:

    1
    2
    3
    4
    5
    for (let i = 0; i < 5; i++) {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    }

    我没有说出解法二, 想了半天想出了一个解法三。。

    1
    2
    3
    4
    5
    6
    7
    for (var i = 0; i < 5; i++) {
    Promise.resolve(i).then((i) => {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    });
    }
  • 中文数字转阿拉伯数字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    // 五千三百万零五百零一

    const chineseNumMap = {
    一: 1,
    二: 2,
    三: 3,
    四: 4,
    五: 5,
    六: 6,
    七: 7,
    八: 8,
    九: 9,
    };

    const unitMap = {
    千: 1000,
    百: 100,
    十: 10,
    };

    function parseChineseNumWhenLowerThan10K(chineseNum: string) {
    let res = 0;
    let hasNum = false;
    let currentNum = 0;
    for (const s of chineseNum) {
    if (hasNum === false && chineseNumMap[s]) {
    res += chineseNumMap[s];
    hasNum = true;
    currentNum = chineseNumMap[s];
    } else if (hasNum === true && unitMap[s]) {
    res -= currentNum;
    res += currentNum * unitMap[s];
    hasNum = false;
    currentNum = 0;
    }
    }

    return res;
    }

    function chineseNumToInt(input: string): number {
    const chineseNumArr = input.split("万");
    let res = 0;

    res += parseChineseNumWhenLowerThan10K(chineseNumArr[0]);

    if (typeof chineseNumArr[1] === "string") {
    res *= 10000;
    res += parseChineseNumWhenLowerThan10K(chineseNumArr[1]);
    }

    return res;
    }

    console.log(chineseNumToInt("五千三百万零五百零一"));

二面

一面过后第二天 HR 就打电话过来约二面,就效率方面来说还是非常迅速的

二面没有考察具体代码,主要是问了问项目方面的问题。

比如公司项目的架构,公司团队协作方面是如何协作的,除了平时工作之外有没有做什么其他的事情,有自己的项目么,自己的项目由什么亮点难点这种比较抽象的问题。

其中可能也有混入价值观方面的问题。总之需要注意一下。

然后我这面被刷了,第三天收到了感谢面试的信。至少有个反馈,从这点来看字节做的还是非常不错的。

再战

又被别的部门捞起来了, 之前是飞书, 现在是抖音部门, 就再战一次呗。

一面

  • 聊聊项目
  • esmodule 和 commonjs 的区别
  • webpack 打包原理
  • nodejs 的 event loop
  • nodejs 的使用 microtask 和 task 的时机

算法题

路径总和:给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class TreeNode {
left: TreeNode | null = null;
right: TreeNode | null = null;
value: number;
}

function getTreePathSum(node: TreeNode, targetSum: number): boolean {
if (!node) {
return false;
}

if (targetSum - node.value === 0) {
if (node.left === null && node.right === null) {
// 如果为叶子节点: 即没有子节点
return true;
}

// 否则继续往下走。
}

return (
getTreePathSum(node.left, targetSum - node.value) ||
getTreePathSum(node.right, targetSum - node.value)
);
}

const root = new TreeNode();
root.value = 1;
const left = new TreeNode();
left.value = 2;
const right = new TreeNode();
right.value = 3;

root.left = left;
root.right = right;

console.log(getTreePathSum(root, 5));

二面

  • js有哪些类型
  • js中基本类型和引用类型分别存在哪里
    • 基本类型存在栈中,引用类型的数据存在堆中
  • 如何获得地址输入 - 缓存 - 加载模块 - 渲染 - 加载请求 - 渲染可交互 这个过程中各个区域的时延
  • 如何设计一个全链路的监控体系
    我的回答:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    1. 监听所有的用户事件
    并区分重点事件,和普通事件。
    2. 全局捕获错误信息。并将错误信息上报,以及错误信息所在的堆栈信息。
    如果可能的话,同时发送相关的dom接口与网络请求,以及截屏。
    如果是无法复现的一些线上问题,内置一个web录屏工具,在开启一个flag后录屏并将录屏信息上传(rrweb)
    3. 对用户的操作,比如RUM,等关键信息,以及用户的请求。划分不同的measure标准。并通过这些信息来衡量用户的痛苦程度。
    资源是有限的,我们应当优先处理用户最痛苦的部分——即使用频率最高,相对痛苦程度最高的部分。
    4. 增加有效的反馈机制,比如聊天机器人,比如报出异常时弹出反馈错误的模态框。或者引导用户到相关的社区。来尽可能让用户知晓我们会尽快解决问题。
    同时能够收集来自用户的直接反馈,而这些是看log很难看到的信息。
    5. 对线上服务进行监控。使用监控工具来检测各个关键服务,以及相对独立的服务的可访问性。以及相关的报警措施(比如钉钉消息)
  • React Native 的热更新 / 部署
    • 拆解
    • 怎么推送
  • 长列表 VirturalList
    • 如何解决闪屏
  • React Native的事件推送

算法题

二面算法题难度急速升高, 反正我都没做过。

1
2
3
4
5
用 Javascript 对象模拟 DOM 树,并实现它的 render 方法,通过调用该方法可以转成真正的 DOM 节点,例如我们已经实现了 element.js,通过 require('element.js'),我们可以定义 ul, 如下:

function el(tag, props, children) {
return ...
}

我的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function el(tag, props, children) {
const el = document.createElement(tag);
for(let key in props) {
el.setAttrible(key, props[key])
}

const childrenEls = children.map(item => {
if(typeof item === 'string') {
return item;
}

return el(item.tag, item.props, item.children);
})
el.append(...childrenEls)

return el;
}

1
2
3
4
5
6
7
8
9
给你一副类似于如下的地图:
0000000000
0010001011
000010E001
00S0000100
0000001000
1000000000

初始时你在S的位置, 你可以向上下左右四个方向发射一枚子弹; 子弹碰到1时, 会顺时针变向90度, 然后继续前进; 问你在S向哪个方向射击, 能够最快将子弹打入E内; 假设一定有一个方向可以成功, 如上图中向上射击;

我的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const land = [
'0000000000',
'0010001011',
'000010E001',
'00S0000100',
'0000001000',
'1000000000'
].map((str) => {
return str.split('')
});

function findS(target: string): [number, number] {
for(let i = 0; i < land.length; i++) {
for(let j = 0; j < land[i].length; j++) {
if(land[i][j] === target) {
return [i, j]
}
}
}
}

function getLandSymbol(y: number, x: number): '0' | '1' | 'S' | 'E' | undefined {
return land[y][x] as any;
}

const size = [land.length, land[0].length];
const sPos = findS('S');

function tryPath(step: number, currentPos: [number,number], vector: [number, number]): number {
step = step + 1;
const symbol = getLandSymbol(currentPos[0], currentPos[1]);
if(symbol === '1') {
// [-1. 0] => [0, 1]
// [0, 1] => [1, 0]
// [1, 0] => [0, -1]
// [-1, 0] => [0, 1]
vector = [vector[1], -vector[0]] // 顺时针旋转90deg
}else if(symbol === 'E') {
return step;
}else if(symbol === undefined) {
return 999999;
}

return tryPath(step, [currentPos[0] + vector[0], currentPos[1] + vector[1]], vector);
}
console.log(tryPath(0, sPos, [-1, 0])); // 向上

三面

面试官上来就说,之前面了很多技术问题,我们来聊一聊项目吧。

大概问了问项目经历,工作经历,职业规划,如何协作,如何codereview,最近在学什么新技术等等看上去很随意的问题。

回头跟朋友们复盘了一下,其实是一道情商题,只有情商高的人才能答对,而我情商。。不高,就是一个憨憨。

如何面对这种软刀子题:

记住以下几点:

  • 面试不是跟你谈心,作为候选人得揣测对方想听啥。
  • 可以先说下自己有啥优点,经验。能为公司,部门,要做的事情带来什么转机。然后再夸一把公司,部门。说来虚心学习。
  • 学会包装自己,技术是为业务服务的

三战

又换了个团队继续,直接从二面开始

二面

  • 项目经历。项目是如何打包,如何发布的。
  • 有使用过一些自动化工具来确保项目质量么。
  • 有了解过webpack打包原理么?你们的项目是用什么打包的
  • 了解过lock文件么,升级单个依赖时会遇到什么坑
  • 看你的项目用到过cypress, 你是如何解决cypress的下载问题的

题:

1
2
3
4
5
6
7
8
设计一个组件,用 React 编写,写出伪代码,有三个要求

Input 组件
非受控
Validate:
1. 只接受英文字母

2. 如果出现不合法的字符,就删掉

我的解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, {useState, useCallback} from 'react';

interface Props {
initVal: string;
onChange: (newVal: string) => void;
}
const MyInput: React.FC<Props> = React.memo((props) => {
const [val, setVal] = useState(props.initVal ?? '');
const handleChange = useCallback((e) => {
const text = e.target.value;
const newVal = '';
for(let c of text) {
if(/[a-zA-Z]/.test(c)) {
// 这里忘记怎么取c的ascii码的方法了,直接用正则表达式先对付着
newVal += c
}
}

if(typeof props.onChange === 'function') {
props.onChange(newVal);
}

setVal(newVal);
}, [props.onChange])

return <input value={val} onChange={handleChange} />
})
MyInput.displayName = 'MyInput'

其实还有就是使用jquery时代的代码来进行处理,然后外面包一层react代码
但是我觉得不够react因此没有选择这个方案。

三面

杂七杂八的聊了聊,大部分都忘了,就记录一下记得的

  • 讲讲你的开源项目
  • 你的开源项目是怎么宣传的,取得了什么样的milestone
  • 你的开源项目未来的发展是怎么样的
  • 你公司的产品的基础架构是怎么样的
  • 你对未来三年的职业规划是怎么样的
  • 你希望做什么样的产品,是to b的还是to develop的
  • 你如何实现一个需求,在用户不知道自己想要什么样的功能的情况下
  • 你现在是965, 可以接受996么

一道题:

1
2
3
4
5
6
7
8
9
实现 tom().eat('apple').play('football').sleep(5).eat('apple').play('football')

输出:
// tom
// eat apple
// play football
// 停顿 5 s
// eat apple
// play football

我的写法, 其实不对。主要是一个sleep函数不太会写。求大佬给出正解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function tom() {
return new Tom()
}

function Tom() {
this.p = Promise.resolve()
console.log('tom')
}

Tom.prototype.eat = function(name: string) {
this.p.then(() => {
console.log('eat ' + name);
})

return this;
}

Tom.prototype.play = function(name: string) {
this.p.then(() => {
console.log('play ' + name);
})

return this;
}

Tom.prototype.sleep = function(interval: number) {
this.p.then(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve(1);
}, interval * 1000)
})
})

return this;
}

四战

直接三面

  • 聊了聊项目
  • 怎么做工程化
  • 如何监控前端的性能
  • 我们会做很多页面,如何提升开发体验
  • 了解无头浏览器么?它内部的引擎是如何去计算代码的

题:
实现一个LRU:

我的回答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class LRU {
list: string[] = [];
map: Record<string, unknown> = {};

get(key: string): unknown {
const index = this.list.findIndex((item) => key === item);
if (index >= 0) {
this.list.splice(index, 1);
}
this.list.unshift(key);

return this.map[key];
}

set(key: string, value: unknown): void {
const index = this.list.findIndex((item) => key === item);
if(index >= 0) {
this.list.splice(index, 1);
}
this.list.unshift(key);

if(this.list.length > 1000) {
const _key = this.list.pop();
delete this.map[_key];
}

this.map[key] = value;
}
}

我的不是一个优解。
建议正确的答案参考:

  • Map 实现
  • 双向链表实现

五战

又被教育部门捞起来了,反正我来者不拒继续面呗。

一面

  • 一个空数组的原型链是怎么样的
  • 用过flex么? 讲讲flex, 有那些熟悉,干嘛用的
  • webpack_require esmodule commonjs, 他们的区别是什么。esmodule可以通过拼字符串来动态加载么
  • function的构造函数与class的构造函数有什么区别
  • 讲讲HTTP2解决了什么问题
    • 二进制传输
    • Header 压缩
    • 多路复用
    • server push
    • TLS(虽然HTTP2不强制, 但是chrome, firefox 只支持tls的HTTP2)
  • 讲讲箭头函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
inner = 'window';

function say() {
console.log(inner);
console.log(this.inner);
}

var obj1 = (function() {
var inner = '1-1';
return {
inner: '1-2',
say: function() {
console.log(inner);
console.log(this.inner);
}
}
})();

var obj2 = (function() {
var inner = '2-1';
return {
inner: '2-2',
say: () => {
console.log(inner);
console.log(this.inner);
}
}
})();


say();// window window
obj1.say(); // 1-1 1-2
obj2.say(); // 2-1 window
obj1.say = say;
obj1.say(); // window 1-2
obj1.say = obj2.say;
obj1.say(); // 2-1 window

做算法题:
实现16进制加法,不能将两数直接转成十进制相加再转回十六进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var map = {
a: 10,
b: 11,
c: 12,
d: 13,
e: 14,
f: 15
}

function parseToNumber(hex) {
if(hex === undefined) {
return 0;
}

if(Object.keys(map).includes(hex)) {
return map[hex]
}else {
return Number(hex)
}
}

function hexAdd(hex1, hex2) {
const arr1 = hex1.split('')
const arr2 = hex2.split('')

let addon = 0;
const result = [];
for(let i = 0; i < Math.max(arr1.length, arr2.length) + 1; i++) {
const num1 = parseToNumber(arr1[arr1.length - 1 - i])
const num2 = parseToNumber(arr2[arr2.length - 1 - i])

let sum = num1 + num2 + addon;
addon = Math.floor(sum / 16);
let rest = sum % 16;
let r = String(rest)
if(rest >= 10) {
const [hex] = Object.entries(map).find(([key, val]) => {
return val === rest
})

r = hex
}

result.unshift(r)
}

return result.join('')
}

console.log(hexAdd('ff', '1'))

结果

最后还是没有成功拿到offer, 很遗憾,原因是【不太适合团队协作】(当然也有可能只是单纯的敷衍)。面试者除了提升自己的硬实力,还应当提升一下自己在职场的软实力,比如朋友推荐的这本书 软技能

六战

被飞书部门又拉起来了,继续

一面

  • 聊一下项目
  • 聊一下你对前端的优化的实践,达成了那些目的
    • 如何判断前端用户体验
  • 聊一下在前端体验方面做的工作
  • 聊聊vue和react的区别
  • 聊聊如何处理中文输入法在输入过程中定时保存状态的问题

算法题

  1. 用css实现一个布局, 来实现一个3x3的布局,每个窗口都要实现16比9的比例

没做出来,关键词 padding-bottom百分比

实现: https://blog.csdn.net/weixin_39357177/article/details/81186498

  1. 实现一个方法,通过栈来存储,实现O(1)的复杂度下获取栈的最小值。

没做出来

框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MiniStack {
store = [];

push(val: number) {
//
}

pop() {
//
}

getMini(): number {
// 实现它
}
}

原题: https://leetcode-cn.com/problems/min-stack/

二面

  • 聊聊公司的项目实现
  • 了解http3么
  • 聊聊react hooks
  • 聊聊公司的业务规范与组件设计规范
  • 聊聊业务代码
  • 聊聊你是如何进行项目优化的
  • 聊聊项目上线的流程
  • 聊聊网络安全性方面的问题,做过xss用户输入防护么
  • 聊聊你是如何关注用户的体验的,主要关心那些指标
  • 让你设计一个倒计时组件,你会怎么设计

做题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 消除字符串中全部的y和连续的xz.
// 用例:
// 'xxyyz' -> 'x'
// 'xxxyyyzzz' -> ''
// 'xyzwzyx' -> 'wzx'

function fixStr(str) {
let result = str;

function loop(str) {
const len = str.length
const fixedStr1 = str.replace('y', '');
if(len !== fixedStr1.length) {
// 变了
loop(fixedStr1)
return;
}

const fixedStr2 = str.replace('xz', '');
if(len !== fixedStr2.length) {
loop(fixedStr2);
return
}


result = str;
}

loop(str)

return result
}
console.log(fixStr('xxyyz'))
console.log(fixStr('xxxyyyzzz'))
console.log(fixStr('xyzwzyx'))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 实现一个功能, 发送5个请求,当请求数
// >=4 报错,则渲染错误页面
// 否则渲染正确页面

function renderErrorPage() {
//
}

function renderSuccessPage(resultMap) {
//
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const successedResultMap = {};

let errorCount = 0;

Promise.all(
arr.map((url, i) =>
fetch(url)
.then((res) => {
successedResultMap[url] = res;
})
.catch(() => {
errorCount++;
})
)
).finally(() => {
if (errorCount >= 4) {
renderErrorPage();
} else {
renderSuccessPage(successedResultMap);
}
});

该问题可以使用Promise.allSettled进行简化

三面

问的和二面差不多,只是更加细一点

算法题在面试官的要求下不做公开

HR面

反正基本不挂人,主要是谈薪的问题。有一个比较难受的点就是字节的薪资是基于你上家薪资来的,或者说大部分大厂的薪资都是基于上家来的。所以比较好的做法是可以先去别的厂然后再来字节996。我就是基础薪资比较低的那种,最后也没感觉有太大的竞争力,比较菜。

结论

万万没想到,我最终还是拿到了Offer。虽然经历比较坎坷,但总不算完全浪费时间。总得来说跳槽这事三分靠运气七分靠实力,但绝对不要被眼前的利益限制住了自己的发展,主要还是要看这次跳槽在自己的职业生涯中能收获什么,而不要为跳而跳。

CORS 现代浏览器跨域请求简明手册

简介

本文主要是阐述与总结现代浏览器的跨域问题

同源策略

同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

定义

如果两个 URL 的 protocol、port (en-US) (如果有指定的话)和 host 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”,或者直接是 “元组”。(“元组” 是指一组项目构成的整体,双重/三重/四重/五重/等的通用形式)。

下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:

URL 结果 原因
http://store.company.com/dir2/other.html 同源 只有路径不同
http://store.company.com/dir/inner/another.html 同源 只有路径不同
https://store.company.com/secure.html 失败 协议不同
http://store.company.com:81/dir/etc.html 失败 端口不同 (http:// 默认端口是80)
http://news.company.com/dir/other.html 失败 主机不同

开始一个跨域请求

你可以使用XMLHttpRequestFetch发起一个跨域请求

你可以在网站http://foo.com发起一个对http://bar.com的请求,如果对方网站许可,那么便能拿到对应的响应,否则则失败。

请求分两类

预检请求

预检请求是一个OPTIONS 请求,在跨域时预先发送到服务端以获取该服务器对跨域访问控制的一些配置,以决定接下来的请求是否会被发送。一般以Header头的形式返回, 相关配置一般以Access-Control-*作为开头

实际请求

简单请求

简单请求不会触发CORS 预检请求

若请求满足所有下述条件,则该请求可视为”简单请求”:

  • 使用下列方法之一:
    • GET
    • HEAD
    • POST
  • 除了被用户代理自动设置的首部字段(例如 Connection, User-Agent)和在 Fetch 规范中定义为 禁用首部名称 的其他首部,允许人为设置的字段为 Fetch 规范定义的 对 CORS 安全的首部字段集合。该集合为:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (需要注意额外的限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
    • Content-Type 的值仅限于下列三者之一:
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
  • 请求中没有使用 ReadableStream 对象。

复杂请求

除了简单请求以外的所有请求都被称为复杂请求,复杂请求在进行跨域访问前会发送一个 CORS 预检请求

跨域请求响应Headers

跨域时携带Cookies

需要确保请求发起时信任对方

1
2
3
fetch('http://bar.com', {
credentials: 'include'
})

credentials 含义:

  • “omit”: Excludes credentials from this request, and causes any credentials sent back in the response to be ignored.
  • “same-origin”: Include credentials with requests made to same-origin URLs, and use any credentials sent back in responses from same-origin URLs.
  • “include”: Always includes credentials with this request, and always use any credentials sent back in the response.

1
2
3
4
5
const req = new XMLHttpRequest();
req.open('GET', url, true);
req.withCredentials = true;
req.onreadystatechange = handler;
req.send();

需要确保服务端响应头返回

1
Access-Control-Allow-Credentials: true

注意此时Access-Control-Allow-Origin不能为 “\“*

需要确保要发送的Cookie满足SameSite条件

注意Chrome 80 后将默认值从原来的None改为Lax, 相关影响可以看如下文章

参考文章

HTTP中的各种缓存标签

  • ETag(如: 50b1c1d4f775c61:df3):
    发送请求时服务端会通过ETag返回资源计算出的实体值,在再次发送请求时浏览器会带上一个If-None-Match(如W / "50b1c1d4f775c61:df3")请求头,服务端会对这个请求与这个资源的实体值进行对比,如果相同则直接返回304
  • Last-Modified(如: Fri , 12 May 2006 18:53:33 GMT):
    发送请求时服务端会通过ETag返回资源最后更新时间,在再次发送请求时浏览器会带上一个If-Modified-Since请求头,服务端会对这个请求与这个资源的最后更新时间进行对比,如果服务端资源的最后更新时间>=If-Modified-Since则返回304
  • Expires/Cache-Control:
    浏览器缓存,如果当前时间<过期时间则不会发送请求。该过程不需要服务端介入,是浏览器本身的缓存行为。可以通过首次请求资源后服务端返回的响应头来被服务端进行控制。
    • Expires来源于http/1.0
    • Cache-Control来源于http/1.1 max-age单位为秒
    • 如果Cache-ControlExpires同时存在,Cache-Control生效

用户操作与缓存

用户操作 Expires/Cache-Control Last-Modified/Etag
地址栏回车 有效 有效
页面链接跳转 有效 有效
新开窗口 有效 有效
前进后退 有效 有效
F5刷新 无效 有效
Ctrl+F5强制刷新 无效 无效