Skip to main content

· 9 min read

编码规范

以我们团队的项目为例,整体是从以下几个方面约定编码规范:

当然还有其他方面的编码规范,比如 vue、JSON 等等

以上规范只是说提供一套规范依据(可以协助 code review),团队在开发的过程中要尽可能遵循(当然可以提出自己的修改意见),然后在实践过程中渐渐形成自己的一套规范,并不断去完善这一套规范

这些编码规范都可以借助一些第三方插件来辅助落实。编辑器建议用 vscode,然后在本地可以借助 vscode 插件来约束代码(可以让我们在编写代码时实时发现一些语法问题,以及保存时自动格式化代码)

辅助工具

eslint

针对 js/ts/react/vue/... 代码进行语法检查和错误预警,减少一些低级错误;同时也能提高 code review 的效率。注意 eslint 不做代码格式规范

yarn add eslint -D
yarn eslint --init

.eslintrc.js 推荐配置:

module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
jest: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react", "@typescript-eslint"],
rules: {
"no-var-requires": 0,
},
};

然后记得在 vscode 中安装 eslint 插件

eslint 详细配置参考 https://eslint.org/docs/user-guide/configuring/

stylelint

类似于 eslintstylelint 可以帮助检查和修复 css/less/scss 等样式的代码

yarn add stylelint stylelint-config-standard -D

项目根目录下新增 .stylelintrc.js

module.exports = {
extends: "stylelint-config-standard",
rules: {}, // 自定义规则
};

然后记得在 vscode 中安装 stylelint 插件

prettier

统一代码格式,包括 js/ts/css/less/...,可以让团队代码形成一种统一的风格,提高代码可读性,也便于多人开发协作

yarn add prettier -D

.prettierrc.js 推荐配置:

module.exports = {
printWidth: 160, //编辑器每行的长度,默认80
tabWidth: 4, //制表符tab的宽度,默认值是2
useTabs: false, //代码缩进是否用制表符tab,默认false
semi: true, //是否使用分号,默认true,使用分号
singleQuote: true, //是否使用单引号,默认为false
quoteProps: "as-needed", //对象属性的引号使用 as-needed 仅在需要的时候使用 consistent 有一个属性需要引号,就都需要引号 preserve 保留用户输入的情况
jsxSingleQuote: false,
trailingComma: "none", //末尾逗号 none 末尾没有逗号 es5 es5有效的地方保留 all 在可能的地方都加上逗号
bracketSpacing: true, //字面量对象括号中的空格,默认true true - Example: { foo: bar }. false - Example: {foo: bar}.
jsxBracketSameLine: false,
arrowParens: "avoid", //箭头函数中的括号always avoid
htmlWhitespaceSensitivity: "ignore",
vueIndentScriptAndStyle: false, //是否给vue中的 <script> and <style>标签加缩进
endOfLine: "auto", //行末尾标识
eslintIntegration: true, //不让prettier使用eslint的代码格式进行校验
};

需要安装 vscode 的 prettier 插件,然后可以选择开启保存时自动格式化代码的功能

prettier 详细配置参考 https://prettier.io/docs/en/configuration.html

EditorConfig

使用不同编辑器打开同一份文件,如果编辑器配置不统一,显示效果和输入内容很有可能不一致。EditorConfig 就主要用于统一代码编辑器编码风格

.editorConfig 配置文件参考

# https://editorconfig.org

# 已经是顶层配置文件,不必继续向上搜索
root = true

[*]
# 编码字符集
charset = utf-8
# 缩进风格是空格
indent_style = space
# 一个缩进占用两个空格,因没有设置tab_with,一个Tab占用2列
indent_size = 2
# 换行符 lf
end_of_line = lf
# 文件以一个空白行结尾
insert_final_newline = true
# 去除行首的任意空白字符
trim_trailing_whitespace = true

[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

详细配置参考 https://editorconfig.org/

commitlint

commitlint 用于检测 commit message 是否合法。message 的格式如下:

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
  1. 标题行(subject): 必填, 描述主要修改类型和内容
  2. 主题内容(body): 描述为什么修改, 做了什么样的修改 以及开发的思路等等。
  3. 页脚注释(footer): 可以写注释,放 BUG 号的链接。=

type 类型约定如下:

feat: 新功能、新特性
fix: 修改 bug
perf: 更改代码,以提高性能(在不影响代码内部行为的前提下,对程序性能进行优化)
refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
docs: 文档修改
style: 代码格式修改, 注意不是 css 修改(例如分号修改)
test: 测试用例新增、修改
build: 影响项目构建或依赖项修改
chore: 其他修改(不在上述类型中的修改)
revert: 恢复上一次提交
ci: 持续集成相关文件修改
release: 发布新版本

配置方式在下一节 Husky 中提及

Husky

Husky 是一个操作 git 钩子的工具,可以做代码提交检测,阻止不规范的代码提交和推送。注意:husky 要求 git 版本 > 2.13.0

lint-stage 是一个本地暂存代码检查工具,可以让 husky 只检验 git 工作区的文件

  1. 安装依赖,包括 commitlint 相关依赖
npm i husky lint-stage -D
npm i @commitlint/cli @commitlint/config-conventional -D
  1. 在 package.json 追加配置
"scripts": {
//...
"prepare": "husky install",
"precommit": "eslint src/**/*.js"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --cache --fix",
"git add"
]
},
  1. 配置 commitlint 规则(commitlint.config.js):
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {}, // 自定义规则
};
  1. 初始化 husky
yarn prepare
# 这个命令在 yarn 安装依赖后会自动执行
  1. 然后执行以下命令,会自动在生成的 .husky 文件夹下新建两个钩子文件 commit-msgpre-commit
npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'

文件内容如下(确保自动生成的文件内容跟以下的保持一致):

  • commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 校验 commit-msg ======="
yarn commitlint --edit $1
  • pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 执行 lint-staged ======="
yarn lint-staged

注意:以上脚本执行的路径要确保与 .git 同级,不同级可以参考 https://blog.zhouweibin.top/FEED/create-react-app/#git扩展-9 提供的解决方法

配置好 eslint/stylelint/prettier 后,在 package.json 中配置对应的 script 命令便于全局检查和格式化项目代码

"scripts": {
"eslint": "eslint --fix src/**/*.{js,ts,jsx,tsx}",
"stylelint": "stylelint --fix src/**/*.{css,less}",
"prettier": "prettier --write src/**/*.{js,ts,jsx,tsx,less,css}"
}

其他工具

  • commitlizen。以命令行交互的方式协助输入 commit 信息

备注

以上工具均需要在项目中增加额外的依赖和配置文件,更好的方式是通过定制脚手架的方式快速安装依赖和生成配置文件,提供开箱即用的项目模板

· 4 min read

最近新建了一个项目,突然需要接入登录功能,同事直接让我去把之前项目的代码搬过来,不料这个过程有点坎坷,主要还是对前端登录的实现了解的不多。多少感觉有些不爽,于是就索性研究下前端登录

主流的登录实现主要有以下几种:

  • cookie + session
  • Token + 本地存储
  • SSO 单点登录
  • OAuth 第三方登录

这个应该是比较老牌的登录实现了

  1. 前端用一个 post 请求发送账号密码到后端
  2. 后端存储账号和密码(密码加密存储),然后生成一个 session 对象,保存 sessionId 和其他信息
  3. 将 sessionId 写入 cookie
  4. 前端再次请求后端接口时会携带 cookie,后端验证 cookie 中的 sessionId

缺点:

  • 服务器需要保存 session
  • sessionId 存放在 Cookie 中,容易遭受 CSRF 攻击
  • 多个域名难以共享 sessionId

jwt + 本地存储

JSON Web Token(jwt) 可以解决上一个登录方案的缺点,交由客户端保存状态

  1. 前端用一个 post 请求发送账号密码到后端
  2. 后端生成 token(携带用户的部分信息)并返回前端
  3. 前端保存 token 到本地存储(localStorage)
  4. 前端请求时手动将 token 带上,一般是将 token 挂在请求头上

缺点:

  • 服务器需要额外解析 token

SSO 单点登录

单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。 SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统(来自百度百科)

单点登录的核心在于共享。需要有一个通用的服务(CAS)来承载这个登录功能,比如 blog.zhouweibin.top 和 code.zhouweibin.top 的登录都由 passport.zhouweibin.top 来实现

可以看到,只要在服务器 A 登录后,CAS 的 cookie 会保持时效,从而实现不同域名间的状态共享

OAuth 第三方登录

以 github 授权为例:

  1. 前端点击第三方登录,跳转到 github 的认证网页,询问授权
  2. 授权完成后,会重定向到页面 A,并且携带 code
  3. 前端再通过 code 和先前在 github 上注册的 appsecret 去请求 github,获取到 token 值
  4. 通过 token 值,再去请求 github 服务器获取部分用户信息

其实授权模式主要分为四种,上述 github 这种授权模式属于授权码模式,是最常用且安全的模式,其他的还有隐藏模式、密码模式、凭证模式等,可以参考 阮一峰 - OAuth 2.0 的四种方式

· 8 min read

前端工程化其实是软件工程在前端方面的应用,是指将系统化的、规范化的、可度量的方法用于前端应用的开发、运行和维护的过程。其主要的作用有:

  • 提升生产效率
  • 降低前端开发成本,解放生产力
  • 提高前端应用质量(质量保证)
  • 降低企业成本

简单说就是研究如何 降本提效和更好地支撑业务

前端工程化的思维导图参考下图: image.png

一个简单的研发流程如下: image.png

以上其实只是我个人现阶段对前端工程化的理解,还会持续更新。文章也主要基于以上这个流程来阐述下前端工程化

脚手架

大厂基本上都会自研脚手架,通过 node-cli 去选择和拉取需要的脚手架到本地进行开发,除了统一的项目结构,此时脚手架里面还包含了这次项目所需的依赖和一些基础配置文件,包括 eslintprettier等代码规范的配置文件

开发

这个阶段是对编码规范、git 规范、项目规范的实践

编码规范

可以参考 Google 的代码风格指南

Javascript 风格指南

TypeScript 风格指南

Git 规范

分支管理规范

  • master、release、develop、feature
  • 基于 develop 创建新的 feature 分支进行开发
  • 如果是多人协作,则需要先额外约定一个合并分支,再基于该分支创建新的 feature 分支

commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
  • type
feat: 新功能、新特性
fix: 修改 bug
perf: 更改代码,以提高性能
refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
docs: 文档修改
style: 代码格式修改, 注意不是 css 修改(例如分号修改)
test: 测试用例新增、修改
build: 影响项目构建或依赖项修改
revert: 恢复上一次提交
ci: 持续集成相关文件修改
chore: 其他修改(不在上述类型中的修改)
release: 发布新版本
workflow: 工作流相关文件修改
  • scope: 这次 commit 影响的范围, 可以是文件夹或者某个文件
  • subject: commit 的概述
  • body: commit 具体修改内容, 可以分为多行
  • footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接

借助 husky 验证 commit 规范,主要通过 git 的 pre-commit 钩子函数来进行

npm i husky -D

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 commit-check.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS;
const msg = require("fs").readFileSync(msgPath, "utf-8").trim();

const commitRE =
/^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/;

if (!commitRE.test(msg)) {
console.error(`commit 消息不规范`);
process.exit(1);
}

最后在 package.json 加上下面的代码

"husky": {
"hooks": {
"pre-commit": "npm run lint",
"commit-msg": "node script/commit-check.js",
"pre-push": "npm test"
}
}
  1. commit 之前检查代码格式
  2. 检查 commit message
  3. 将代码推送到远程仓库前,执行 npm test 进行测试,测试失败则不会推送

项目规范

项目结构

文件名称统一使用小写,名称过长用 - 隔开

├─assets (静态资源)
├─components (公共组件)
├─containers (页面)
├─styles (公共样式)
├─routes (路由)
├─store (数据管理)
├─services (接口函数)
├─helper (工具文件夹)
├─request (请求函数)
└─utils (工具函数)

其他

  • 权限配置
  • 路由配置
  • 菜单
  • ...

ui 规范

ui 规范需要由前端、UI/UE、产品商量和制定,建议使用统一的组件库

统一 ui 标准,可以减少前端在开发中由于 ui 设计额外增加的工作量

测试

前端其实很少会做比较规范的测试,毕竟这事吃力不讨好。比较多的应该是用 Jest 做单元测试,主要针对工具函数和公共组件,确保这部分高复用代码的质量

构建

构建这一步主要看使用的构建工具吧,现在主流的还是 webpack,所以针对 webpack 构建会创建好最基础的配置,然后可以做一些编译优化来提升构建速度。以及做持续构建和集成,这一步具体做啥我还不清楚

部署

简单的持续部署可以利用 jenkinswebhook 钩子函数来实现,webhook 会监听某个事件,当触发某个事件(比如 push 事件)时,通过钩子函数通知 jenkins 并执行预先设置好的脚本去部署应用

部署时也要讲究一定的策略,减少上线过程造成的异常。可以参考 大公司里怎样开发和部署前端代码?,里面有讲到一些蛮不错的方法

监控

主要分为 性能监控异常监控。作用是监视应用情况、预警和定位问题,然后可以根据应用情况来做一些有效的性能优化。可以通过买 sentry 的服务或者自研的方式实现一个监控系统

性能监控

一般借助 window.performance 来采集网页的性能指标

image.png

初探 performance – 监控网页与程序性能

如何进行 web 性能监控

异常监控

异常监控其实就是收集应用的报错信息,主要收集以下三种报错信息:

  • 资源加载错误
  • js 执行报错
  • 异步错误,比如 promise。

通过报错收集,可以了解到网站发生错误的类型和数量,从而可以做相应的预防措施来减少网站异常的问题

沉淀了 3 年的自研前端错误监控系统,打通你的脉络

埋点

  • 性能数据上报,由性能监控消费
  • 异常数据上报,由异常监控消费
  • 用户数据收集和分析(navigator、UV、PV、跳转来源、页面停留时间...)
  • 业务数据收集...

最后

以上只是我现阶段对前端工程化的理解和概括,后面我会对工程化的各个环节做更深的探索和实践,进一步夯实前端工程化能力

· 11 min read

基于 canvas,我们可以通过 js 绘制 2D 图形,而同样使用 canvas 元素的 WebGL API 则可以用于绘制硬件加速的 2D 和 3D 图形。现在对 canvas 的应用很普遍,比如画板工具、图表绘制(echarts)、处理视频、图片/富文本编辑器、excel、游戏等

基本使用

在 react 项目中简单画个矩形如下:

function App() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
function drawRect() {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 150, 100);
}
return (
<div>
<canvas ref={canvasRef} width={300} height={200}>
你的浏览器不支持canvas,请升级浏览器{" "}
</canvas>
<button onClick={drawRect}>render</button>
</div>
);
}

Canvas 元素默认的 width 和 height 属性值是 300 和 150 像素,如果直接使用样式类或者 style 属性来设置宽高时,如果新设置的宽度和高度与默认值不同,就会导致 Canvas 元素被缩放

canvas 创造了一个固定大小的画布,它公开了一个或多个渲染上下文,其可以用来绘制和处理要展示的内容。所以要绘制图形前,需要先获取到 渲染上下文

const ctx = canvas.getContext("2d");
// 可以用以下方法检测浏览器对canvas的支持性
// if (canvas.getContext){}

图形绘制

Canvas 使用的是 W3C 坐标系,坐标空间参考如下:

普通图形

canvas 支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段)

绘制矩形

// 绘制填充矩形
ctx.fillRect(x, y, width, height);
// 绘制矩形边框
ctx.strokeRect(x, y, width, height);
// 清除矩形区域
ctx.clearRect(x, y, width, height);
// 或者用下面的方法绘制
// ctx.rect(x, y, w, h);
// ctx.stroke();
// ctx.fill();
// 拐角样式,默认尖角/斜角 bevel/圆角 round
// cxt.lineJoin = 'bevel'

绘制直线

ctx.beginPath();
// 定义直线的起点坐标
ctx.moveTo(10, 10);
// 定义直线的终点坐标
ctx.lineTo(50, 10);
// 沿着坐标点顺序的路径绘制直线
// 设置虚线
// cxt.setLineDash([10, 20]) // 10px间隔20px
ctx.stroke();
// 关闭当前的绘制路径
ctx.closePath();

绘制三角形

ctx.beginPath();
ctx.moveTo(75, 50);
ctx.lineTo(100, 75);
ctx.lineTo(100, 25);
// 会自动让所有形状闭合,区别与stroke
ctx.fill();

绘制圆

ctx.beginPath();
ctx.arc(100, 75, 50, 0, 2 * Math.PI);
ctx.stroke();
ctx.closePath();
// ctx.arc(x, y, r, sAngle, eAngle, counterclockwise);
// x,y:圆心坐标; r:半径大小;
// sAngle:起始角,以弧度计(弧的圆形的三点钟位置是 0 度)
// eAngel:结束角,以弧度计
// counterclockwise:可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针

设置阴影

// 水平位移
ctx.shadowOffsetX = 10;
// 垂直位移
ctx.shadowOffsetY = 10;
// 设置模糊度
ctx.shadowBlur = 5;
// 设置阴影颜色
ctx.shadowColor = "rgba(0,0,0,0.5)";
// ctx.fillstyle = "#000"
// ctx.fillRect(100, 100, 100, 100)

阴影绘制是比较耗费性能的,尽量禁用阴影,其他优化手段有缓存阴影(预定义 canvas 渲染上下文)和优化阴影属性,尽可能使用较小的阴影模糊半径和偏移量,从而减少计算量。同时,使用 rgba() 颜色表示法,而不是十六进制颜色表示法,因为 rgba() 颜色表示法支持 alpha 透明度属性,而十六进制颜色表示法则不支持

不得不说,原生的 api 挺不方便的,所以大部分情况我们都会用 canvas 的封装库或者自己封装一些常用的绘制函数,减小使用成本

文本

const text = "Hello world";
ctx.font = "48px serif";
// 文本对齐方式
ctx.textAlign = "center";
// 填充颜色
ctx.fillStyle = "#000";
// 设置内容和坐标
ctx.fillText(text, 10, 50);
// 空心字
// ctx.strokeText(text, 10, 50);
// 获取文本宽度
// ctx.measureText(text).width

绘制文本都是比较耗费性能的,可以尽量避免,优化策略有缓存文本(offScreenCanvas 或 image 缓存)、禁止文本变换、使用字体缓存(在 DOM 上缓存 font)

加载图片

可以通过 drawImage 加载图片

// 正常加载图片
ctx.drawImage(image, x, y, width, height);
// 图片裁减,s前缀参数标识了裁减的范围
context.drawImage(img, sx, sy, swidth, sheight, x, y, width, height);

图片源可以是 HTMLImageElement、HTMLVideoElement、HTMLCanvasElement、ImageBitmap。ImageBitmap 是一个高性能的位图,创建过程是异步的(createImageBitmap,在另一个线程创建,不阻塞主线程),它可以从上述的所有源以及其他几种源中生成。并且可以在 worker 中创建,然后借助离屏 canvas 实现低延迟的绘制

加载视频的原理其实也是基于 drawImage。更多详情可以参考 使用图像

动画

动画原理其实就是不断的清空画布和重绘,可以用 requestAnimationFrame 来实现

为了避免动画卡顿,还需要尽可能减少动画的前置计算逻辑,可以分享个我们实际项目中的小车行驶场景,要通过一系列参数和算法计算出多辆小车的下一个位置,如果是前端计算,耗时会比较长,也就不可避免要造成卡顿,最终方案是后端同事封装了 c++函数,然后我们再将其封装成 wasm 模块供前端调用,从而减小计算耗时

优化

监控帧率

可以借助三方库 stats.jsreact-stats。自己实现的话,需要计算 1s 内的帧数得出 fps,可以参考以下代码:

var fps = 0;
var startTime = performance.now();

function loop(timestamp) {
var progress = timestamp - startTime;
fps++;
if (progress >= 1000) {
console.log(fps + "fps");
fps = 0;
startTime = timestamp;
}
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);

性能优化

可以先看下 MDN 的优化建议

常见的性能优化手段如下:

  • requestAnimationFrame 优化高频重绘
  • 分层绘制
  • 可视范围画布
  • 脏矩形优化(局部重绘)
  • 离屏 canvas
  • ...

这一块后面进阶篇再仔细探究吧,感觉还是挺有趣的

三方库

我用的比较多的是 fabricjs,是一个比较出名的一个 canvas 库,不过国内用的人很少。该库封装了大量的图形对象,声明式绘制图形,然后也做了性能优化,比如分层绘制、offscreen、objectCache、脏矩形优化等,这些后面会整理篇 canvas 的进阶文章来串讲下

先定义一个全局的 Fabric 对象

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
initialize () {
let myCanvas = new fabric.Canvas(CANVAS_ID);
myCanvas.selection = false;
myCanvas.setWidth(canvasWidth);
myCanvas.setHeight(canvasHeight);
// 鼠标事件监听
canvas.on('mouse:wheel', (evt) => {
// actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
// rafThrottle 是节流函数
rafThrottle(() = actionManager.handleMouseWheel(evt));
});
canvas.on('mouse:move', (evt) => {
rafThrottle(() => actionManager.handleMouseMove(evt));
});
// 对象选中监听
canvas.on('selection:created', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:updated', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:cleared', (e: any) => {
actionManager.handleObjectUnSelect();
});
canvas.on('object:moving', (e: any) => {
actionManager.handleObjectMove(e);
});
// 对象变动监听,包括位置、大小、角度等变化的监听
canvas.on('object:modified', (e: any) => {
actionManager.handleObjectModified(e);
});
}
}

在组件中使用

useEffect(() => {
new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

更多使用参考我之前的总结 https://blog.zhouweibin.top/fabric/fabric-practice/

其他三方库

  • Konva.js
  • p5.js
  • Paper.js

基本没有用过,就不深入探究之间的区别了

参考

常见问题

对比 DOM

参考 https://zhuanlan.zhihu.com/p/143829714

对比 SVG

参考 https://github.com/abcrun/abcrun.github.com/issues/13

1px 线条模糊

本质原因是 Canvas 元素默认的坐标系是以像素的中心点为基准的,比如你尝试在 (0.5, 0.5)(或者其他像素点的中心点)处绘制一条 1px 的线,那么实际上它会被绘制成两条跨越了两个像素的线段,而不是你期望的一条 1px 线。解决方法:

  • translate 将绘制区域向右移动半个像素的距离
  • 虚线绘制
  • globalCompositeOperation

文字锯齿

  • 使用 CSS 进行抗锯齿处理 <canvas id="myCanvas" style="image-rendering: optimizeSpeed; -webkit-font-smoothing: antialiased;"></canvas>
  • 使用 textBaseline 属性对齐锚点 context.textBaseline = "middle"; // 设置对齐锚点为垂直方向的中心点
  • 如果需要对较小字体进行平滑处理,也可以考虑使用 WebFont 或 SVG 标签等技术来达到最佳效果。

cavans 污染

  • 只从可信的源获取图像数据,并且仅允许 trusted-image-source 进行绘制
  • 限制 Canvas 对外部的访问权限,只授权访问某些特定的域或者协议
  • 禁止用户输入代码并在 Canvas 上执行,或者在执行前先进行验证或者过滤,确保输入的内容不会对 Canvas 造成不良影响

推荐资源

· 11 min read

其实之前读书期间就折腾了好长时间在整服务器,主要就是做自己的博客网页,这篇文章算是把之前磕磕绊绊的经验稍微总结了一下吧,入个门应该还是可以的 ~

购置

服务器

操作系统看个人喜好吧,我选择的是 centos 7.6,因为比较熟悉..下面也是基于这个操作系统来讲的

域名

个人网站需要

https 证书

  • FreeSSL。可以申请免费的证书
  • 腾讯云和阿里云也都可以申请一年期限的免费证书

腾讯云免费证书申请 https://console.cloud.tencent.com/ssl

远程连接

mac 远程连接服务器,终端软件推荐使用 iterm2,FTP 软件推荐使用 FileZilla

具体连接过程参考 https://github.com/GitHubJackson/efficient-mac/blob/master/frontend-dev.md

yum

linux 的包管理工具,常用命令如下:

# -- 检索(会同时列出 Installed Packages 和 Available Packages)
yum list nodejs
yum list installed # 单独列出 Installed Packages
yum search nodejs # list 只搜索软件包名称,而 search 不光搜索包名,还包括摘要和描述

# -- 安装
yum install nodejs (加 -y 可自动应答 yes)

# -- 更新
yum check-update # 列出每个包可升至的版本
yum update
yum update nodejs

# -- 查看详情(可查看安装的也可查看未安装的包)
yum info nodejs

列出全部/可用/不可用仓库
yum repolist enabled

# -- 卸载
yum remove nodejs

# -- 缓存
yum clean all 清除缓存
yum makecache 生成新的缓存

详情参考 https://wangchujiang.com/linux-command/c/yum.html

zsh

因为我自己在本地使用的是 zsh,而且 zsh 也兼容 bash,为了保持一致,先配置一下 zsh 吧(当然,你要是觉得麻烦,完全可以继续用 bash 或者其他 Shell,想知道 zsh 优势的话,可以参考这篇文章 https://zhuanlan.zhihu.com/p/19556676 ~)

yum install git // 后续需要从git仓库下载插件
yum install zsh
which zsh
chsh -s /usr/bin/zsh // 切换 shell

安装 oh my zsh(命令记忆、补全能力和主题太香了 ~)

sh -c "$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)"

安装插件和配置的具体过程跟本地配置类似,参考 https://github.com/GitHubJackson/efficient-mac/blob/master/tools.md

前端环境搭建

这个网站主要是放前端相关的项目,所以必备的环境得先搭建好

nvm

管理 node 版本

curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash

node/npm

nvm install stable
node -v
npm -v

jenkins

用于搭建 CI/CD,主要是拉取 github 项目,将其部署到服务器上。在提交代码到 github 时,可以通过 webhook 触发 jenkins 自动部署 ~

  1. 安装好 java 环境和 jenkins
yum install -y java
wget -O /etc/yum.repos.d/jenkins.repo http://pkg.jenkins-ci.org/redhat/jenkins.repo
rpm --import https://pkg.jenkins.io/redhat/jenkins.io.key
yum install -y jenkins
  1. 修改 jenkins 的默认端口
// /etc/sysconfig/jenkins
JENKINS_HOME -- Jenkins的主目录
JENKINS_USER -- Jenkins的用户,拥有$JENKINS_HOME和/var/log/jenkins的权限
JENKINS_PORT -- Jenkins的端口,默认端口是8080,建议改变,防止占用端口冲突
  1. 启动服务和开机自启
systemctl start jenkins
systemctl enable jenkins
  1. 安装插件和创建管理员用户

安装 ssh 插件Publish Over SSH,可以验证服务器是否开启远程登录

nginx

做子域名映射和负载均衡 balabala...

yum install nginx
systemctl start nginx
systemctl enable nginx

nginx 配置文件目录为/etc/nginx/nginx.conf

其他常用命令:

systemctl restart nginx
systemctl status nginx
systemctl stop nginx

pm2

为 node 应用守护进程,比如博客后台、ssr 服务等...

npm -g install pm2

创建软链接:

ln -s [pm2命令位置,which pm2] /usr/bin/pm2

开启 nextjs 应用

pm2 start npm --name "my-next" -- run start

搭建 CI/CD

一个简单的 CI/CD 流程主要有几点:

  1. 设置 github 项目 webhook
  2. github 代码更新,触发 webhook
  3. jenkins 执行脚本
    1. 拉取最新代码到指定目录(覆盖)
    2. 安装依赖
    3. 执行项目

项目配置

node 应用

  1. 关联 github 仓库
    1. Github 项目填写对应的 url(非仓库 url)
    2. 源码管理选择 Git(如果是私有仓库,要创建带 ssh 秘钥的凭据,通过 ssh 方式连接仓库)
    3. 构建触发器选择 GitHub hook trigger for GITScm polling
    4. 构建环境选择 node,需要先配置 nodejs 插件
    5. 构建脚本选择 shell,脚本可以参考下面的代码片段

ssh 方式连接仓库,需要先在主机生成 ssh key,cd /.ssh 查看密钥,pub结尾的为公钥,追加到在 settings > SSH And GPG keys的 ssh keys 列表中

ssh-keygen -t rsa -b 4096 -C "your email"
ll /.ssh
  1. 构建环境选择 ==node==
  2. 开始构建,执行 shell 脚本
# 以下的_name_都要替换成你实际的项目名
# 要先在 /var/lib/jenkins/workspace 创建项目,name跟jenkins项目名保持一致
#!/bin/bash
cd /var/lib/jenkins/workspace/blog-server
rm -rf node_modules
node -v
npm -v
npm install
tar -zcvf blog-server.tar.gz *

cd /home/servers
if [ ! -d "blog-server" ]; then
sudo mkdir blog-server
fi
cd /home/servers/blog-server
npm run prd
# pm2 start app --watch
sudo mv /var/lib/jenkins/workspace/blog-server/blog-server.tar.gz ./
sudo tar -zxvf blog-server.tar.gz -C ./
sudo rm -rf blog-server.tar.gz
# pm2 -v
# pm2 restart all --watch # 第一次要先手动将项目添加进pm2进程

如果遇到 pm2: command not found,就找到 pm2 的地址,做一个软连接到/usr/bin/

ln -s [pm2地址,可用 whereis pm2 查找] /usr/bin/pm2

如果是运行 ts 代码,需要先在主机给 pm2 安装 ts 和 ts-node,执行以下命令即可

pm2 install typescript
# "start": "NODE_ENV=production ts-node app --port $PORT",
pm2 start npm --name 'node' -- run start --watch
  1. 触发自动构建

a.先关联 github server 并测试连接。这一步是先建立主机和 github 的信任

image.png

b.新建凭据。setting --> Personal Access Token --> Generate new token,需要配置读写权限,之后会生成一个 token,作为凭据的内容(secret key)。

c.在 github > settings 配置 webhook。这一步是为了后续 github 项目在触发对应节点时能发送请求给 jenkins。在[项目仓库] > settings > webhooks 里面新增一个,Payload URL 格式类似于

http://[ip]:[jenkins port]/github-webhook/

之后可以在 Recent Deliveries 里面看最近的触发记录

d.git push 触发自动构建试试!

域名解析

目标是将 http://x.x.x.x:3001/api/test 替换成 http://blog-api.jacksonzhou.com:3001/api/test

  1. 申请域名
  2. 解析二级域名,记录值填 ip,主机记录填写 blog-api(自行定义)

nginx 重定向域名

目标是将 http://blog-api.jacksonzhou.com:3001/api/test 替换成 http://blog-api.jacksonzhou.com/api/test

编辑 nginx.conf,一般在 /etc/nginx

server {
listen 80;
location /api/ {
proxy_pass http://x.x.x.x:3001;
}
}

重启 nginx 试试

systemctl restart nginx

https 配置

目标是将 http://blog-api.zhouweibin/api/test 替换成 https://blog-api.zhouweibin/api/test

  1. 申请证书。可以到腾讯云或阿里云申请免费的证书(这里是用的 pem 与 key 文件)
  2. 安装 Nginx 的 SSL 模块。可以使用 nginx -V 检查是否已安装(在输出中查找–with-http_ssl_module
  3. 在 nginx 目录新建 cert 文件夹存放证书文件。将证书文件上传(可以使用scp命令或者 FileZilla 软件)
  4. Nginx.conf 配置

先新增 https server

server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name zhouweibin.top www.zhouweibin.top;
root /usr/share/nginx/html;

ssl_certificate "/etc/nginx/cert/zhouweibin.top.pem";
ssl_certificate_key "/etc/nginx/cert/zhouweibin.top.key";
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 10m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
proxy_pass http://nextblog;
# root /home/blog;
# index index.html;
}

location /api/ {
proxy_pass http://127.0.0.1:3001;
}

error_page 404 /404.html;
location = /40x.html {
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}

将 http 重定向到 https

server {
listen 80;
server_name zhouweibin.top www.zhouweibin.top;
return 301 https://$server_name$request_uri;
}
  1. 重启 nginx

SPA

部署单页面应用的步骤如下:

  1. build 生成静态页面
  2. nginx 配置端口,用来访问这个静态页面
  3. 注意重定向到 index.html(spa history 模式)

搭建 ci:

  1. github 项目配置 webhook
  2. jenkins 新增项目(jenkins 配置参照之前的 node 项目即可)
  3. shell 脚本
#!/bin/sh
cd /var/lib/jenkins/workspace/_name_
rm -rf node_modules dist
node -v
npm -v
npm config set registry https://registry.npm.taobao.org/
npm install #安装项目中的依赖

#!/bin/sh
npm run build
cd dist
rm -rf _name_.tar.gz #删除上次打包生成的压缩文件
tar -zcvf _name_.tar.gz * #把生成的项目打包成压缩包,方便移动到项目部署目录

cd /home #进入web项目根目录
if [ ! -d "_name_" ]; then
sudo mkdir _name_
fi
cd /home/_name_ #进入web项目根目录
sudo mv /var/lib/jenkins/workspace/_name_/dist/_name_.tar.gz ./ #移动刚刚打包好的项目到web项目根目录
sudo tar -zxvf _name_.tar.gz -C ./ #解压项目到dist目录
sudo rm -rf _name_.tar.gz #删除压缩包
  1. nginx 配置

next 应用(ssr)

部署 next 应用的步骤如下:

  1. next build
  2. next start

搭建 ci:

  1. github 项目配置 webhook
  2. jenkins 新增项目(jenkins 配置参照之前的 node 项目即可)
  3. shell 脚本
#!/bin/sh
cd /var/lib/jenkins/workspace/next-blog
tar -zcvf next-blog.tar.gz

cd /home/next-blog
sudo mv /var/lib/jenkins/workspace/next-blog/next-blog.tar.gz ./
sudo tar -zxvf next-blog.tar.gz -C ./
sudo rm -rf next-blog.tar.gz

rm -rf node_modules .next
node -v
npm -v
npm config set registry https://registry.npm.taobao.org/
npm install

npm run build
pm2 restart next-blog --watch
  1. nginx 配置代理
upstream nextblog {
server 127.0.0.1:3000; # next-blog
keepalive 64;
}
server {
# ...
location / {
proxy_pass http://nextblog;
}
}

· 6 min read

写在前面

emm,大部分插件都是要翻墙下载的,搜索建议用 google(某度广告是真的多,而且很多知识也需要翻墙才能看),建议先解决这个问题,毕竟都 2021 了,程序员不翻墙说不过去

shell

可以把 shell 理解成 操作系统内核外的一层壳,它是用户和操作系统交互的桥梁,后来 shell 也慢慢地变成了内核与用户交互的脚本语言的总称,比如:bash、zsh、csh、ksh、ash 等等。

// 查看支持的 shell
cat /etc/shells

常见的 shell 命令如下:

~;      // 表示当前用户根目录,比如 ~/.zshrc
pwd; // 显示当前目录
cd; // 跳转至目录
ls; // 列出当前目录下的文件及文件夹
mkdir; // 新建文件夹
touch; // 新建文件
mv; // 移动文件
rm -r; // 删除文件,-r 参数表示递归删除目录
echo; // 打印字符串或变量
cat; // 输出文件内容
vim; // 编辑文件内容
source; // 执行某一个文件,比如 zshrc 修改之后要生效,可以通过这个命令 source ~/.zshrc,或者重开一个窗口

iterm2

了解了 shell,我们还需要一个终端来输入 shell 命令和查看命令执行结果。mac 下最好用的终端当然是 iterm2 了,官网安装即可:https://iterm2.com/

oh my zsh

目前 macOS 最流行的 shell 脚本语言应该就是 zsh 了。默认的 zsh 配置很繁琐,可以通过 oh my zsh 更好地体验 zsh

通过官网提供的命令行安装 oh my zsh:https://ohmyz.sh/#install 安装过程如果遇到这个问题: Failed to connect to raw.githubusercontent.com port 443:Connection refused,试试给 Wifi 的 DNS 配置加一个 114.114.114.114

homebrew

可以通过官网提供的命令行安装,不过我一直安装失败,提示如下:

fatal: unable to access 'https://github.com/Homebrew/brew/': LibreSSL SSL_read: SSL_ERROR_SYSCALL, errno 60
Failed during: git fetch --force origin

搜了其他解决方案,比较多的是使用中科院的镜像,不过前提是先安装 Git,不过我没参考这个,感觉有点麻烦,具体可以去 google 搜下;我用的是国内的源

/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"

具体解决方案参考:https://gitee.com/cunkai/HomebrewCN

homebrew 使用指北:https://sspai.com/post/56009

nvm / node

通过 nvm 管理 node 版本(包括 npm)

brew install nvm

安装完会提示到 ~/.zshrc 文件中补充 nvm 命令的配置,可以通过 vim 编辑 ~/.zshrc 保存

nvm install stable // 安装最新稳定版 node
node -v
npm -v

nvm 常用命令可以参考这个文章,整理的还可以:https://titangene.github.io/article/nvm.html 当然,你可以直接上 Github:https://github.com/nvm-sh/nvm

Git / nrm

日常开发最常用的版本控制工具,有时候也会通过 Git 下载一些第三方包,比如 zsh 的一些插件等。然后 nrm 主要是用来管理 git 源,比如 taobao、cnpm、企业源...

brew install git
npm i -g nrm // 要先安装 node
nrm use taobao // 我一般选taobao源

远程连接

必备

  • 申请服务器,比如 腾讯云阿里云...个人网站一般 1 核 2g 就够用了,操作系统的话推荐使用 centos7.6 系统
  • 用户名和 IP 地址
  • 账号和密码

连接

ssh 连接远程服务器的过程是这样的:

  1. 用户服务器发起登录请求:ssh user@IP
  2. 服务器收到用户的登录请求,把自己的公钥发给用户
  3. 用户使用这个公钥,将登录密码加密后,发送回远程主机
  4. 服务器用自己的私钥,解密登录密码,如果密码正确,就同意用户登录

假设你已经买了一台 Linux 主机,IP 地址 为 x.x.x.x,用户名为 root (大部分默认为 root),使用以下命令可发起建立 ssh 连接:

ssh root@x.x.x.x

接下来输入密码登录即可

也可以通过 sshKey 实现免密码登录

brew install ssh-copy-id
// 生成本机密钥/公钥文件
ssh-keygen -t rsa
// 将本机公钥上传到远程服务器上
ssh-copy-id root@x.x.x.x

iterm2 设置快捷登录服务器,步骤参考:

  1. 打开 iterm2 -> preferences -> Profiles,或者快捷键 command + ,
  2. 点击左侧列表下的 + 号,新建一个 Profile
  3. 右边配置相关信息,主要是配置 command,在初始化该窗口时自动执行连接服务器的命令
// Command
ssh root@x.x.x.x

之后右键点击 iterm2,就可以快速创建一个连接远程服务器的窗口了

总结

以上就基本搭建好一个前端开发环境了,后续有新的内容会继续补充,欢迎大家提 issue

其他

· 6 min read

写在前面

各有所好,各取所需,love & peace

点击对应的工具名称,可以到达下载页面,大部分页面需要翻墙访问,建议先安装科学上网软件,推荐 Xiyou

mac 辅助工具

  • Alfred。mac 神器不用多说,可以快速搜索文件和打开应用等
  • Xnip。截图工具,可以在桌面放置多个悬浮截图
  • licecap。录屏工具,可以录取指定屏幕范围的活动、生成 Gif
  • Keka。解压缩工具
  • Xiyou。稳定的科学上网软件,要收费,支持多端

浏览器

包管理工具

官网的方式可能会安装不了,可以参考下搭建环境里面介绍的方法:https://github.com/GitHubJackson/efficient-mac/blob/master/frontend-dev.md

命令行工具

安装过程如果遇到这个问题: Failed to connect to raw.githubusercontent.com port 443:Connection refused,试试给网络的 DNS 配置加一个 114.114.114.114

zsh 插件

基本安装思路是进入插件目录,将插件拷贝到这个目录

cd ~/.oh-my-zsh/plugins
git clone [插件仓库]

然后在 ~/.zshrc 文件中修改代码

plugins=(git zsh-autosuggestions zsh-syntax-highlighting web-search) // 空格间隔
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git
git clone https://github.com/zsh-users/zsh-autosuggestions.git
git clone https://github.com/sinetoami/web-search.git

也可以尝试用另一个 https://github.com/lesonky/web-search, 这个还支持知乎、mdn 等搜索

不过貌似都还不支持搜索中文内容 emmm,提了 issue,看看后续有没有办法解决:https://github.com/sineto/web-search/issues/3

git clone https://github.com/sinetoami/web-search.git

其他插件可以在这里找:https://github.com/unixorn/awesome-zsh-plugins

前端开发相关

  • vscode。编辑器
  • sublime Text。轻量级编辑器
  • Dash。离线技术文档
  • charles。网络代理工具
  • postman。api 调试工具
  • Git。版本控制工具,建议通过 homebrew 安装
  • node。前端必备的运行环境,包括 npm,建议通过 homebrew 安装
  • nrm。管理 git 源,比如 taobao、cnpm...建议通过 npm 全局安装
  • nvm。管理 node 版本
  • nodemon。监听 node 应用代码,如果有改动会重新启动应用

数据库管理

待办事项工具

  • 滴答清单。UI 美观、操作方便、账号同步,个人感觉蛮好用的一款清单软件

笔记

  • 有道云笔记。云盘共享
  • Notion。功能很强的一款笔记软件,包括清单、日历、表格等功能

画图

  • draw.io。流程图、时序图等
  • processon。和 draw.io 类似,UI 更美观些,不过免费空间很小
  • xmind。思维导图

office

chrome 插件

  • FEhelper
  • Octortree
  • 沙拉查词
  • Clear Cache。快速清理网页缓存
  • Website IP
  • SimilarWeb。测网站流量来源及相关信息
  • Dark Reader
  • React Developer Tools
  • NIM。node 调试工具
  • 下载+
  • GitZip for Github。方便下载 github 仓库文件
  • 股票基金助手。股市有风险

建议登录谷歌账号同步设置

vscode 插件

  • Todo Tree
  • GitLens
  • Auto Rename Tag
  • open in browser
  • Bracket Pair Colorizer 2
  • ES7 React/Redux/GraphQL/React-Native snippets
  • Material Theme
  • Material Theme Icons
  • Path Autocomplete
  • koroFileHeader。生成文件头部注释和函数注释
  • Paste JSON as Code。能快速生成 JSON 数据的 ts 数据类型

建议登录 vscode 同步设置

云存储

  • Google 云盘。免费空间 15G,不下载视频啥的,应该够用了,而且不限速(emmm,我并没有在暗示什么)...
  • 百度网盘

网站

  • AST
  • Babel
  • quicktype。可以快速生成对象的数据类型,支持多种语言,比如 typescript
  • codesandbox。网页版编辑器,内置了很多模板
  • FreeSSL。免费生成 SSL 证书
  • Pexels。大量免费的素材图片
  • carbon。生成代码片段截图,蛮好看的,可以用在自己的文章里面
  • xml-sitemaps。帮助生成网站的 sitemap
  • favicon。制作 favicon

FTP

  • FileZilla。比较流行的 FTP 软件,有远程服务器的童鞋可能需要

其他

  • 微信
  • QQ
  • 网易云音乐
  • 搜狗输入法。手机、pc 共享剪切板,有点香 ~
  • keyManager。证书管理工具,配合 FreeSSl 使用
  • JustFocus。休息一会、或者专注工作一会

· 14 min read

在 React 中,数据获取、设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 16.8 新增了 Hook,这些副作用都可以通过 useEffect 或者 useLayoutEffect 来调用。前阵子抽空学了下 useState 的源码和做了总结,今天也稍微总结了下 use(Layout)Effect。

写在前面

不熟悉 Fiber 架构的话可以参考 https://blog.zhouweibin.top/detail/60aa954b6da89c0011cca404

useState 原理感兴趣的可以参考我之前写的博客 https://blog.zhouweibin.top/detail/60a16c423ba50e0011ffbc3b

涉及到上面知识的本文可能不会赘述,下面主要重点谈谈 use(Layout)Effect 这两个 Hooks 在 React 挂载和更新过程中做的事情

effectList

这是一个副作用链表,会连接具有副作用 effect 的 fiber 节点,副作用的数据格式如下

{
create: Function, // 传入use(Layout)Effect函数的第一个参数,即回调函数
destroy: Function, // 回调函数return的函数,在组件销毁的时候执行
deps: any[], // 依赖项
next: Effect, // 指向下一个effect
tag: string, // effect的类型,区分是useEffect还是useLayoutEffect
}

副作用在函数组件中体现为 use(Layout)Effect 的回调和销毁函数(即回调的返回值)(统称为副作用)

收集

从单个 fiber 节点看,fiber.memorizedState 挂载着 Hooks 链表,Hooks 链表会按顺序连接该节点上的 Hook,其中 use(Layout)Effect 上的 memorizedState 会存放副作用,待执行的副作用会串联成一个循环链表存储在updateQueue上,如下图所示:

从整个 Fiber Tree 来看,render 阶段会自下而上创建 effectList,主要是通过completeUnitOfWork这个方法创建,只有依赖项有变化才会将当前的 fiber 节点追加到 effectList。如下图所示:

最终的 effectList 如下所示:

首次挂载组件(mount),执行 useEffect 时,会调用mountEffectImpl,简化后的源码如下:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook(); // 创建hook对象
const nextDeps = deps === undefined ? null : deps; // 依赖

// NOTE 给 fiber 节点打上副作用的 effectTag
currentlyRenderingFiber.flags = fiberFlags;

// 创建 effect 对象,挂载到 hook 的 memoizedState 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps
);
}

更新时(update),会调用 updateEffectImpl

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;

if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
// 获取上一次 effect 的 destroy 函数
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// NOTE 如果前后依赖相同
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}

// NOTE flags的作用
currentlyRenderingFiber.flags |= fiberFlags;
// 如果前后依赖有变,在 effect 的 tag 中加入 HookHasEffect
// 并将新的 effect 挂载到 hook.memoizedState 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps
);
}

组件 mount 和 update 时收集 effect 的区别在于,前者无须调用前一次的销毁函数,后者在创建 effect 前会判断依赖项,主要依赖项变化才会创建 effect 并挂载到 hook 上

不管组件处于什么状态,都会调用到pushEffect这个函数它的作用其实就是创建 effect 对象,将 effect 对象追加到 fiber 的 updateQueue

function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag,
create,
destroy,
deps,
next,
};

// NOTE 从 workInProgress 上取到 updateQueue(和 effectList 的区别)
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
// 如果 updateQueue 为空,把 effect 放到链表中,和它自己形成闭环
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue 不为空,将 effect 追加到链表上
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}

在执行完 fiber 节点时,如果有更新 effect,会将该 fiber 节点追加到父节点的 effectList

function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;

let next = completeWork(current, completedWork, subtreeRenderLanes);

// effect list构建
if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
// 层层拷贝
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
// 说明当前节点是兄弟节点,子节点有effect,已经给returnFiber.lastEffect赋值过了
if (returnFiber.lastEffect !== null) {
// 连接兄弟节点的effect
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}

const flags = completedWork.flags;

// NOTE: 该 fiber 节点有 effect
if (flags > PerformedWork) {
// 当前节点有effect连接上effect list
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
// returnFiber没有firstEffect的情况是第一次遇见有effect的节点
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}

// 兄弟元素遍历再到返返回父级
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}

执行

render 阶段 useEffect 和 useLayoutEffect 没什么区别,在 commit 阶段会有比较明显的区别。先看下 useEffect,主要有三个地方会执行

useEffect

  1. 刚进入 commit 阶段
function commitRootImpl(root, renderPriorityLevel) {
// 进入commit阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
do {
try {
// beforeMutation阶段的处理函数:commitBeforeMutationEffects内部,
// 异步调度 useEffect
commitBeforeMutationEffects();
} catch (error) {
...
}
} while (nextEffect !== null);
// ...
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

if (rootDoesHavePassiveEffects) {
// NOTE 记录有副作用的 effect
rootWithPendingPassiveEffects = root;
}
}
  1. beforeMutation,该阶段会异步调度 useEffect
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
// ...
if ((flags & Passive) !== NoFlags) {
// 如果fiber节点上的flags存在Passive调度useEffect
if (!rootDoesHavePassiveEffects) {
// 锁住调度状态,防止多次调度
rootDoesHavePassiveEffects = true;
// NOTE NormalSchedulerPriority 一般优先级
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
  1. layout 阶段填充 effect 执行数组,真正执行 useEffect 的时候,实际上是先执行上一次 effect 的销毁函数,再执行本次 effect 的创建函数
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// ...
// layout阶段填充effect执行数组
schedulePassiveEffects(finishedWork);
return;
}
}

在调用 schedulePassiveEffects 填充 effect 执行数组时,有一个重要的地方就是只在包含 HasEffect 的 effectTag 的时候,才将 effect 放到数组内,这一点保证了依赖项有变化再去处理 effect。也就是:如果前后依赖未变,则 effect 的 tag 就赋值为传入的 hookFlags,否则,在 tag 中加入 HookHasEffect 标志位。正是因为这样,在处理 effect 链表时才可以只处理依赖变化的 effect,use(Layout)Effect 才可以根据它的依赖变化情况来决定是否执行回调。

function schedulePassiveEffects(finishedWork: Fiber) {
// 获取到函数组件的 updateQueue
const updateQueue = finishedWork.updateQueue;
// 获取 effect 链表
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
// 循环 effect 链表
do {
const { next, tag } = effect;
if (
(tag & HookPassive) !== NoHookEffect &&
(tag & HookHasEffect) !== NoHookEffect
) {
// 当 effect 的 tag 含有 HookPassive 和 HookHasEffect 时
// 分别插入销毁函数数组和创建函数数组
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}

上边已经填充好 effect 执行数组了,接下来就是执行这个数组。执行过程是先循环待销毁的 effect 数组,再循环待创建的 effect 数组,这一过程发生在flushPassiveEffectsImpl函数中

function flushPassiveEffectsImpl() {
// 先校验,如果root上没有 Passive effectTag 的节点,则直接return
if (rootWithPendingPassiveEffects === null) {
return false;
}
// ...
// 执行effect的销毁
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = ((unmountEffects[i]: any): HookEffect);
const fiber = ((unmountEffects[i + 1]: any): Fiber);
const destroy = effect.destroy;
effect.destroy = undefined;

if (typeof destroy === "function") {
try {
destroy();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
}

// 再执行effect的创建
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = ((mountEffects[i]: any): HookEffect);
const fiber = ((mountEffects[i + 1]: any): Fiber);
try {
const create = effect.create;
effect.destroy = create();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
// ...
return true;
}

useLayoutEffect

useLayoutEffect 在执行的时候,也是先执行上一次的销毁函数再执行创建函数。和 useEffect 不同的是这两者都是同步执行的,前者在 mutation 阶段执行,后者在 layout 阶段执行。

与 useEffect 不同的是,它不用数组去存储销毁函数和创建函数,而是直接操作当前 fiber 上的 updateQueue

  1. 卸载上一次的 effect,发生在 mutation 阶段
// 调用卸载layout effect的函数,传入layout有关的effectTag和说明effect有变化的effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

// 循环updateQueue上的effect链表
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
  1. 执行本次的 effect 创建,发生在 layout 阶段
// 调用创建layout effect的函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// 创建
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}

一些问题

  • useEffectuseLayoutEffect 的使用和区别

useEffect在实现上利用 scheduler(关于 scheduler 后续会写篇文章介绍) 的异步调度函数:scheduleCallback,这函数会将执行 useEffect 的动作作为一个任务去调度,这个任务会异步调用,发生在 DOM 渲染之后

useLayoutEffect发生在 DOM 渲染之前,执行时机和componentDidMountcomponentDidUpdate是一致的,也就是说如果在useEffect中触发状态更新或者 DOM 渲染,会额外追加一次 DOM 渲染

一般情况下建议使用 useEffect,因为它 不会阻塞渲染,useLayoutEffect 会阻塞渲染,只有在涉及到修改 DOM、动画等场景下考虑使用 useLayoutEffect

  • 不同 useEffect 回调触发了多次 setState,会触发多次 render 吗?

不会。setState 最后其实都会调用 scheduleUpdateOnFiber

// scheduleUpdateOnFiber
// ...
if (executionContext === NoContext) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
flushSyncCallbackQueue();
}
// ...

只有当 executionContextNoContext 才会触发同步更新,其余情况都是批量更新。在 commit 的 beforeMutation 阶段,会赋值 executionContextCommitContext,代表进入 commit 阶段

// beforeMutation
// 将当前上下文标记为CommitContext,作为commit阶段的标志
// ...
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// ...

· 13 min read

得闲翻下 react17 的源码和相关文章,学习一下 react 的 useState 的实现原理。整个过程蛮有趣的,首先得理解 fiber 架构,其次在 debugger 源码的时候得学习和理解一些必要的上下文,整体来说受益匪浅

前置知识

闭包

在 hooks 的应用比如 dispatch 函数,也就是 useState 返回的第二个参数

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。

单链表

A-->B-->C。fiber 架构通过使用这种数据结构,实现了 render 过程的可中断性。比如更新了 B 节点,突然有高优先级插入,这个时候只需要记住当前更新到的节点是 B 即可,然后去执行高优任务,之后再从 B 节点继续更新

fiber & hook

fiber 对象和 hook 对象的关系如下图: fiber & hook

fiber 架构

React 16 引入了 fiber 架构,会基于 ReactElement 生成唯一的 fiber 对象。在更新阶段,会基于旧的 fiber 链表创建一个新的 fiber 链表(如下图),可以复用旧的对象,并且获取旧 hooks 的状态

fiber双链表

两个单链表

hooks 链表

/*
Hooks are stored as a linked list on the fiber's memoizedState field.
hooks 以链表的形式存储在fiber节点的memoizedState属性上
The current hook list is the list that belongs to the current fiber.
当前的hook链表就是当前正在遍历的fiber节点上的
The work-in-progress hook list is a new list that will be added to the work-in-progress fiber.
work-in-progress hook 就是即将被添加到正在遍历fiber节点的hooks新链表
*/
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

无论是初次挂载还是更新,每调用一次 hooks 函数,都会产生一个 hook 对象与之对应,hook 对象结构如下

{
baseQueue: null, // 当前 update
baseState: 'hook1', // 初始值,即 useState 入参
memoizedState: null, // 当前状态(更新时表示上一次的状态)
queue: null, // 待执行的更新队列(queue.pending)
next: { // 下一个 hook
baseQueue: null,
baseState: null,
memoizedState: 'hook2',
next: null
queue: null
}
}

产生的 hook 对象依次排列,形成链表存储到函数组件 fiber.memoizedState 上。在这个过程中,有一个十分重要的指针:workInProgressHook,它通过记录当前生成(更新)的 hook 对象,可以间接反映在组件中当前调用到哪个 hook 函数了。每调用一次 hook 函数,就将这个指针的指向移到该 hook 函数产生的 hook 对象上

比如先调用 hookA

fiber.memoizedState: hookA
^
workInProgressHook

调用 hookB

fiber.memoizedState: hookA -> hookB
^
workInProgressHook

hooks 的更新队列

这其实是个单链表环,存放某个 hook 的更新队列,如下图,是一个 useState 的更新队列:

任务待执行链表.png

其实还有一个副作用链表,跟 useEffect 有关,最终会挂到 root 节点上

源码执行流程

renderWithHooks

对于函数组件(FunctionComponent)类型,在 beginwork 的时候会调用 renderWithHooks 注册,根据 mount 还是 update 调用一个函数生成 hooks 链表挂在 Fiber 上

// renderWithHooks
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
...
// 当前fiber
currentlyRenderingFiber$1 = workInProgress;
// hooks队列
workInProgress.memoizedState = null;
// 副作用队列
workInProgress.updateQueue = null;
{
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountInDEV;
}
}
...
return children;
}

mount

usestate源码执行顺序mount.png

update

usestate源码执行顺序.png

mount 阶段

/**
* ReactCurrentDispatcher 是一个内部储存状态的状态机.
* 主要作用还是用于切换不同执行时机的 dispatcher 对象
* */
const ReactCurrentDispatcher = { current: null };
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
return dispatcher;
}
function useState(initialState) {
var dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
// resolveDispatcher
...
useState: function (initialState) {
...
try {
return mountState(initialState);
} finally {
ReactCurrentDispatcher$1.current = prevDispatcher;
}
},
...
// mountWorkInProgressHook
function mountWorkInProgressHook() {
var hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};

if (workInProgressHook === null) {
currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}

return workInProgressHook;
}

// mountState
function mountState(initialState) {
// 创建 hook 对象
var hook = mountWorkInProgressHook();

if (typeof initialState === "function") {
initialState = initialState();
}

hook.memoizedState = hook.baseState = initialState;

var queue = (hook.queue = {
pending: null, // update 队列
dispatch: null, // 触发函数,会触发更新,useState返回值的第二个参数
lastRenderedReducer: basicStateReducer, // 最后一次使用的 reducer,初始化时使用最基础的 reducer
lastRenderedState: initialState, // 上一次的值,挂载阶段该值为初始值
});
var dispatch = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber$1,
queue
));
// dispatch是一个闭包函数
return [hook.memoizedState, dispatch];
}

useState 默认使用的更新器是 basicStateReducer

function basicStateReducer(state, action) {
return typeof action === "function" ? action(state) : action;
}

update 阶段

先执行 useState 返回的 dispatch 函数

// mountState
...
var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
return [hook.memoizedState, dispatch];
function dispatchAction(fiber, queue, action) {
...
// 更新对象,是一个链表节点
var update = {
lane: lane, // 优先级
action: action, // 新值,也就是 setState 传入的参数
next: null, // 指向下一个更新对象
eagerReducer: null, // 上一次使用的 reducer
eagerState: null, // 旧值
};

var pending = queue.pending;
if (pending === null) {
// 在队列上添加当前hook的首个更新对象,并保持循环
update.next = update;
} else {
// 非首个更新对象,会追加到更新队列之后
update.next = pending.next;
pending.next = update;
}
queue.pending = update;

// fiber.alternate 指向旧 fiber 链表
var alternate = fiber.alternate;

if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
} else {
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
var lastRenderedReducer = queue.lastRenderedReducer;

if (lastRenderedReducer !== null) {
var prevDispatcher;

{
// NOTE 暂不清楚这一步的作用
prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}

try {
var currentState = queue.lastRenderedState;
// 计算新的 state
var eagerState = lastRenderedReducer(currentState, action);

update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;

if (objectIs(eagerState, currentState)) {
// 前后值没变,没必要更新
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
{
ReactCurrentDispatcher$1.current = prevDispatcher;
}
}
}
}
...
// 发起一次调度更新
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
}

更新 WorkInProgressHook

function updateWorkInProgressHook() {
var nextCurrentHook;

if (currentHook === null) {
var current = currentlyRenderingFiber$1.alternate;

if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}

var nextWorkInProgressHook;

if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber$1.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}

if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
...
currentHook = nextCurrentHook;
var newHook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null
};

if (workInProgressHook === null) {
currentlyRenderingFiber$1.memoizedState = workInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
}

return workInProgressHook;
}

真正得到最终状态,其实是在下一次获取状态的时候。

更新阶段开始时,同样会执行 renderWithHooks,此时会将 ReactCurrentDispatcher.current 指向 HooksDispatcherOnUpdate 对象

const HooksDispatcherOnUpdate: Dispatcher = {
...
useState: updateState,
}
function updateState(initialState) {
return updateReducer(basicStateReducer, initialState);
}
...
// updateReducer
function updateReducer(reducer, initialArg, init) {
var hook = updateWorkInProgressHook();
var queue = hook.queue;
...
do {
...
if (update.eagerReducer === reducer) {
newState = update.eagerState;
} else {
// 遍历更新队列,得到最新值
var action = update.action;
newState = reducer(newState, action);
}
...
update = update.next;
} while (update !== null && update !== first);
...
var dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];
}

简单总结一下 update 的过程:

  1. 调用 dispatcher 函数
  2. 收集 update,将 update 对象按序插入更新队列 queue.pending
  3. 调度一次 React 的更新
  4. 重新执行组件函数时,useState 会被重新执行,在 resolve dispatcher 的阶段取到负责更新的 dispatcher
  5. 按序执行更新队列,拿到最新的 state
  6. 渲染真实 DOM,更新结束

其他问题

为什么在 React 16 前,函数式组件不能拥有状态管理?

因为 16 以前只有类组件在更新时存在实例,而 16 以后 Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力

为什么只能在函数组件中使用 hooks?

只有函数组件才走 renderWithHooks 的逻辑

为什么 hooks 不能在循环、判断语句中调用,而只能在函数最外层使用?

因为在更新时,这个队列需要是一致的,才能保证 hooks 的结果正确。

useState 的 setState 是同步操作还是异步操作?

默认是异步。从上面的源码分析可以看出,执行 setState 时会将更新对象(update)存入更新队列,等到 commit 阶段才会一次性执行该更新队列。在没有重新执行 app 函数时,拿到的始终是旧状态,所以就造成了异步的现象

连续执行同一个 setState,会造成多次渲染吗?比如下面这段代码:

onClick = () => {
setState(0);
setState(1);
setState(2);
};

不会。执行事件函数时,会判断当前是否处于批量更新(具体逻辑可以参见 scheduleUpdateOnFiber 源码),如果处于批量更新状态,说明还不能重新渲染,需要等待该状态结束(比如 react 中的 onClick 事件)。在批量更新阶段,每个 hooks 会通过 queue 将更新(update)存入更新队列(单链表)中,这个队列会保证更新顺序。等到批量更新状态结束了,才会重新执行组件函数,之后执行到对应 hook 时,会一次性执行挂载在 hook 上的更新队列,从而更新状态。

执行两次 setState(0) 会执行两次函数组件吗?

不会。在更新时会通过 ObjectIs 判断更新前后的值是否变化,如果没变化是不会调度更新的

· 12 min read

在 react 中可以通过 setState 来更新状态,重新渲染该组件及其子组件。react 15 在一次事务更新中,会合并多次状态更新(批量更新),等到事务结束再统一更新状态;而 react 16 中已经没有了事务的概念,在 Fiber 架构下,setState 是如何实现的呢?

写在前面

先看道面试题

// ...
constructor() {
super();
this.state = {
val: 0
};
}

componentDidMount() {
this.setState({val: this.state.val + 1});
console.log(this.state.val);
this.setState({val: this.state.val + 1});
console.log(this.state.val);
setTimeout(() => {
this.setState({val: this.state.val + 1});
console.log(this.state.val);
this.setState({val: this.state.val + 1});
console.log(this.state.val);
}, 0);
}
// ...
};

上面代码的执行结果是

0
0
2
3

直观点看,在 setTimeout 外面的状态更新是异步的,而 setTimeout 里的状态更新是同步的。说明 setState 更新状态并不总是异步的。当它处于 react 的生命周期钩子函数、合成事件,表现为异步;其他情况则表现为同步,比如 setTimeout 和原生事件等

本文会结合源码分析,总结下 react 15 和 16 两个版本关于 setState 的实现

react 15

react 15 是通过事务机制和锁来控制批量更新时机,实现“异步”更新

关键词:

  • Transaction(事务)
  • dirtyComponents
  • isBatchingUpdates(锁)

Transaction

看下官方关于 Transaction 的注释:

这个注释其实就是 Transaction 的核心功能,主要是包装要执行的函数 method(生命周期钩子函数或者 react 事件处理函数等),通过 Transaction.perform 来执行 method

下面是一个基础的 Transaction 对象,是 react 事务对象的父类

var Transaction = {
// 重置 Transaction,刷新之前的数据
reinitializeTransaction: function () {
// getTransactionWrappers 其实是一个获取wrapper的抽象方法,需要具体实现
// 用于获取当前事务需要的所有 wrappers
this.transactionWrappers = this.getTransactionWrappers();
if (this.wrapperInitData) {
this.wrapperInitData.length = 0;
} else {
this.wrapperInitData = [];
}
this._isInTransaction = false;
},

_isInTransaction: false,

getTransactionWrappers: null,

// 判断当前事务是否正在执行
// 可用于防止当前事务被打断
isInTransaction: function () {
return !!this._isInTransaction;
},

perform: () => {
// this._isInTransaction = true 表明正处于事务中
// this.initializeAll
// 执行method
// this.closeAll
// this._isInTransaction = false
},
initializeAll: () => {
// 遍历所有wrappers,执行initialize方法
}
closeAll: () => {
// 遍历所有wrappers,执行所有close方法
}

Transaction 执行 perform 前,会先执行所有已注入的 wrapperinitialize 方法来初始化事务,执行完方法后再调用 wrapperclose 方法来结束事务

setState

setState 的执行流程如下:

看下源码:

ReactComponent.prototype.setState = function(partialState, callback) {
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};

// ...
enqueueSetState: function(publicInstance, partialState) {
var internalInstance = getInternalInstanceReadyForUpdate(
publicInstance,
'setState',
);

var queue =
internalInstance._pendingStateQueue ||
(internalInstance._pendingStateQueue = []);
queue.push(partialState);

enqueueUpdate(internalInstance);
},
// ...

function enqueueUpdate(internalInstance) {
ReactUpdates.enqueueUpdate(internalInstance);
}
// ...
function enqueueUpdate(component) {
// ...
if (!batchingStrategy.isBatchingUpdates) {
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
dirtyComponents.push(component);
}

假设现在触发了一次 react 的点击事件,并执行 setState ,此时 setState 处于 react 的更新事务流程中。开始执行事务时会设置 isBatchingUpdates 为 true,表示当前事务处于批量更新过程(锁住这个状态),导致后续加入的 setState 只会加入 dirtyComponents 中,等到事务 close 后,将 isBatchingUpdates 设置为 false,执行 batchingStrategy.batchedUpdates 合并全部状态,再更新。

react 中有一个更新策略对象 ReactDefaultBatchingStrategy,主要用于维护批量更新状态(isBatchingUpdates)和启动更新事务(batchedUpdates

// 更新事务的 wrappers
var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];
// 修改批量更新状态
var RESET_BATCHED_UPDATES = {
initialize: emptyFunction,
close: function () {
ReactDefaultBatchingStrategy.isBatchingUpdates = false;
}
};
// 遍历所有的 dirtyComponents 更新组件
var FLUSH_BATCHED_UPDATES = {
initialize: emptyFunction,
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
};
// ...
// 更新策略对象
var ReactDefaultBatchingStrategy = {
// 是否批量更新
isBatchingUpdates: false,

batchedUpdates: function (callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true;
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e);
} else {
return transaction.perform(callback, null, a, b, c, d, e);
}
}
};
// ...
// 更新事务对象,是 Transition 对象的子类
function ReactDefaultBatchingStrategyTransaction() {
// 初始化事务
this.reinitializeTransaction();
}
// ...
// 实现了 getTransactionWrappers 这个抽象方法,即注入更新事务的 wrappers
Object.assign(ReactDefaultBatchingStrategyTransaction.prototype, Transaction, {
getTransactionWrappers: function() {
return TRANSACTION_WRAPPERS;
},
});
...
var transaction = new ReactDefaultBatchingStrategyTransaction();

从源码可以看到,setState 会调用 ReactDefaultBatchingStrategy.batchedUpdates,从而执行 transition.perform 开启更新事务

react 16

react 16 引入了 Fiber 架构,抛弃了 react 15 的事务机制,借助锁和更新链表实现批量更新

关键词:

  • updateQueue(状态更新队列)
  • isBatchingUpdates(锁)

react 16 的组件渲染过程主要分为 render 阶段和 commit 阶段,从执行 setState 开始看下两个阶段都做了什么

主要执行流程如下:

以下源码参考自 v16.8.3

render

这个阶段主要是收集各个 fiber 节点上的更新,将它们追加到 updateQueue 上

// react/packages/react/src/ReactBaseClasses.js
Component.prototype.setState = function (partialState, callback) {
// ...
this.updater.enqueueSetState(this, partialState, callback, "setState");
};

// react/packages/react-reconciler/src/ReactFiberClassComponent.js
const classComponentUpdater = {
// ...
enqueueSetState(inst, payload, callback) {
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);

// 创建一个更新队列
const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
// ...
update.callback = callback;
}
// TODO 暂不清楚这个的具体作用
flushPassiveEffects();
// 将更新任务挂载到 fiber 上
enqueueUpdate(fiber, update);
// 调度更新
scheduleWork(fiber, expirationTime);
},
// ...
};

// react/packages/react-reconciler/src/ReactUpdateQueue.js
export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
// Update queues are created lazily.
const alternate = fiber.alternate;
let queue1;
let queue2;
// ...
{
appendUpdateToQueue(queue1, update);
}
// ...
}
// ...
function appendUpdateToQueue<State>(
queue: UpdateQueue<State>,
update: Update<State>
) {
// 将 update 对象挂载到更新队列 updateQueue 上,形成一个环状单向链表
if (queue.lastUpdate === null) {
queue.firstUpdate = queue.lastUpdate = update;
} else {
queue.lastUpdate.next = update;
queue.lastUpdate = update;
}
}

render 阶段,在更新单个 fiber 时(beginWork),会遍历更新队列计算得到最新的 state,该操作由 processUpdateQueue 完成。状态更新过程的调用栈有:beginWork -> updateClassComponent -> updateClassInstance -> processUpdateQueue

// react/packages/react-reconciler/src/ReactUpdateQueue.js
function updateClassInstance(
current: Fiber,
workInProgress: Fiber,
ctor: any,
newProps: any,
renderExpirationTime: ExpirationTime,
): boolean {
// ...
const oldState = workInProgress.memoizedState;
let newState = (instance.state = oldState);
let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
// 遍历更新队列计算得到新值
processUpdateQueue(
workInProgress,
updateQueue,
newProps,
instance,
renderExpirationTime,
);
newState = workInProgress.memoizedState;
}
// ...
}
// ...
// 处理更新队列,返回新的 state
export function processUpdateQueue<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
props: any,
instance: any,
renderExpirationTime: ExpirationTime,
): void {
// ...
while (update !== null) {
// ...
{
resultState = getStateFromUpdate(
workInProgress,
queue,
update,
resultState,
props,
instance,
);
// 收集 callback,在 commit 阶段执行
const callback = update.callback;
if (callback !== null) {
workInProgress.effectTag |= Callback;
update.nextEffect = null;
if (queue.lastEffect === null) {
queue.firstEffect = queue.lastEffect = update;
} else {
queue.lastEffect.nextEffect = update;
queue.lastEffect = update;
}
}
}
// Continue to the next update.
update = update.next;
}
// ...
workInProgress.memoizedState = resultState;
// ...
}
// ...
function getStateFromUpdate (..., partialState, ...) {
// ...
if (typeof _payload2 === 'function') {
// ...
// Merge the partial state and the previous state.
return _assign({}, prevState, partialState);
}
}

commit

该阶段又分为三个子阶段

  • before mutation
  • mutation
  • layout

在第一个阶段,classComponent 会更新当前的 state 和 props(实际计算新值的操作在render阶段的beginWork中执行,并赋值给 fiber 节点的memorizedStatememorizedProps

其中执行 setStatecallback 发生在第三个阶段 layout,入口代码如下:

// react/packages/react-reconciler/src/ReactFiberScheduler.js
// ...
while (nextEffect !== null) {
try {
commitAllLifeCycles(root, committedExpirationTime);
} catch (e) {
// ...
}
}
// ...

commitAllLifeCycles 这个函数内主要调用了 commitLifeCycles

// react/packages/react-reconciler/src/ReactFiberCommitWork.js
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedExpirationTime: ExpirationTime
): void {
switch (finishedWork.tag) {
// ...
case ClassComponent: {
const instance = finishedWork.stateNode;
// ...
const updateQueue = finishedWork.updateQueue;
if (updateQueue !== null) {
// ...
commitUpdateQueue(
finishedWork,
updateQueue,
instance,
committedExpirationTime
);
}
return;
}
// ...
}
}

commitUpdateQueue 会遍历更新队列执行 setStatecallback

// react/packages/react-reconciler/src/ReactUpdateQueue.js
export function commitUpdateQueue<State>(
finishedWork: Fiber,
finishedQueue: UpdateQueue<State>,
instance: any,
renderExpirationTime: ExpirationTime
): void {
// If the finished render included captured updates, and there are still
// lower priority updates left over, we need to keep the captured updates
// in the queue so that they are rebased and not dropped once we process the
// queue again at the lower priority.
if (finishedQueue.firstCapturedUpdate !== null) {
// Join the captured update list to the end of the normal list.
if (finishedQueue.lastUpdate !== null) {
finishedQueue.lastUpdate.next = finishedQueue.firstCapturedUpdate;
finishedQueue.lastUpdate = finishedQueue.lastCapturedUpdate;
}
// Clear the list of captured updates.
finishedQueue.firstCapturedUpdate = finishedQueue.lastCapturedUpdate = null;
}

// Commit the effects
commitUpdateEffects(finishedQueue.firstEffect, instance);
finishedQueue.firstEffect = finishedQueue.lastEffect = null;

commitUpdateEffects(finishedQueue.firstCapturedEffect, instance);
finishedQueue.firstCapturedEffect = finishedQueue.lastCapturedEffect = null;
}

function commitUpdateEffects<State>(
effect: Update<State> | null,
instance: any
): void {
while (effect !== null) {
const callback = effect.callback;
if (callback !== null) {
effect.callback = null;
callCallback(callback, instance);
}
effect = effect.nextEffect;
}
}

批量更新

在 react 15 是借助了事务和锁实现了批量更新,在 react 16 是怎么实现的呢?

比如我们触发了一个合成事件 onClick,会触发 dispatchEvent ,执行到 batchedUpdates 函数

function batchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
const previousIsBatchingUpdates = isBatchingUpdates; // 默认是 false
isBatchingUpdates = true;
try {
return fn(a);
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performSyncWork();
}
}
}

这里同样是用了锁(isBatchingUpdates)来开启批量更新模式,所以如果我们在合成事件(fn)里面触发 多次setState,react 会根据 isBatchingEventUpdates = true 判断当前处于批量更新模式,会把更新任务挂载到更新队列上,当合成事件执行完毕后才统一调度更新

其他

react 17 和 react 16 其他版本,其实跟 react v16.8.3 实现 setState 的思路大同小异(后面有空再补上最新版本的)。大体上都是产生一个 update 对象去承载新的状态。多个 update 对象会连接成一个环状单向链表 - updateQueue 并挂载 fiber 上, 然后在更新该 fiber 的时候 (beginWork)会循环该 updateQueue,依次处理其中的 update,计算得到最新的 state

· 17 min read

在 react 16 之前,更新虚拟 DOM 为 stack reconciler,这是一个递归的过程,在树很深的时候,单次 diff 时间过长会造成 JS 线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿。为了解决这种问题,react 团队使用 fiber 架构重构了 React,使其能够将任务分片,划分优先级,同时能够实现类似于操作系统中对线程的抢占式调度

写在前面

浏览器每一帧都需要完成哪些工作? 浏览器一帧内的工作.png

通过上图可看到,一帧内需要完成如下六个步骤的任务:

  1. 处理用户交互
  2. 解析 js 脚本
  3. Begin frame。resize、scroll 等事件的处理
  4. rAF
  5. 布局
  6. 绘制

页面是一帧一帧绘制出来的,当 FPS(每秒绘制的帧数)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。也就是说浏览器 1s 绘制 60 帧,每一帧分到的时间是 1000/60 ≈ 16 ms。所以我们应该力求不让一帧的工作量超过 16ms。

由此我们知道,浏览器是一帧一帧执行的,在两个执行帧之间,主线程通常会有一小段空闲时间

浏览器一帧工作的空余.png

requestIdleCallback 可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务

Fiber reconciler

Fiber 协调核心:可中断可恢复优先级

新特性如下:

  • 增量式渲染。把渲染任务拆分成块,匀到多帧
  • 能够暂停、终止和复用渲染任务
  • 给不同类型的更新赋予优先级
  • 并行渲染(还在开发中,值得期待)

将以前的 stack reconciler 拆分成两个阶段:rendercommit。render 阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期执行。然后是 commit 阶段,这个阶段是不拆分且不能打断的,将最终的 effectList 一口气更新到页面上。这两个阶段后续会详细介绍

  • 每个更新任务都会赋予一个优先级
  • 当更新任务抵达调度器时,高优先级的更新任务(A)会更快地被调度进入 Reconciler —— 优先级
  • 此时有新的更新任务(B),调度器会检查它优先级,若高于当前任务(A),处于当前 Reconciler 层的 A 任务会被中断,调度器将 B 任务推入 Reconciler 层 —— 可中断
  • 当 B 任务完成渲染后,新一轮调度开始,之前被中断的 A 任务将会被重新推入 Reconciler 层,继续它的渲染 —— 可恢复

fiber 对象

React Fiber 把更新过程碎片化,执行过程如下面的图所示,每执行完一段更新过程,就把控制权交还给 React 负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。

维护每一个分片的数据结构,就是 Fiber。

有了分片之后,更新过程的调用栈如下图所示,中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。让线程处理别的事情

fiber调度.png

一个 react 节点对应着一个 fiber 节点

type Fiber = {
// 标识不同的组件类型
// FunctionComponent: 0; ClassComponent: 1...
tag: WorkTag,

key: null | string,

// react 元素类型
// 可以为ClassComponent、FunctionComponent、Symbol、HostComponent...
elementType: any,

// The resolved function/class/ associated with this fiber.
type: any,

// 该fiber节点对应的ReactElement对象
stateNode: any,

// fiber的父级
return: Fiber | null,

// 单链表树状结构
child: Fiber | null,
sibling: Fiber | null,
index: number,

ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,

// 新 props
pendingProps: any,
// 旧 props
memoizedProps: any,

// 更新队列
updateQueue: UpdateQueue<any> | null,

// 旧 state
memoizedState: any,

// DOM diff相关
effectTag: SideEffectTag,

// 副作用链表
nextEffect: Fiber | null,
firstEffect: Fiber | null,
lastEffect: Fiber | null,

// 标识该组件应该在未来的某个时刻完成更新
// expirationTime 五种类型:
// NoWork(默认0)、Never(1)、Interactive(100 - 150)、Async(250 - 5000)、Sync(Number.MAXINTEGER)
expirationTime: ExpirationTime,

// 快速确定子fiber树有没有挂起的更改
childExpirationTime: ExpirationTime,

// fiber 的镜像节点
alternate: Fiber | null,
};

fiber 树示意图如下:

fiber树结构.png

演示:

创建fiber.gif

双链表

在 react 中始终存在 workInprogressTree(future vdom)oldTree(current vdom)两个链表,两个链表相互引用。

fiber双链表

双链表的好处有:

  • 复用内部对象(fiber),可以节省内存分配、GC 的时间开销
  • 获取旧状态
  • 当 workInprogressTree 生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在

三个阶段

Scheduler

该阶段主要是给任务分配优先级,统筹任务调度

Scheduler 的源码解析可以参考:https://juejin.cn/post/6889314677528985614

react 16 基于 requestIdleCallback 的 polyfill 方案来实现任务调度,用法如下:

window.requestIdleCallback(callback[, options])
let handle = window.requestIdleCallback((idleDeadline) => {
const {didTimeout, timeRemaining} = idleDeadline;
console.log(`是否超时?${didTimeout}`);
console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
// do sth
const now = Date.now(), timespent = 10;
while (Date.now() < now + timespent);
console.log(`花了${timespent}ms`);
console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
}, {timeout: 1000});
// 是否超时?false
// 可用时间剩余45ms
// 花了12ms
// 可用时间剩余32ms

由于 requestIdleCallback 有兼容问题,react 团队采用的是它的 polyfill 方案,可参考

Render

遍历 Fiber 树,得出需要更新的节点信息。可以被打断,让位于优先级更高的操作,比如用户点击等

  1. Fiber Root开始遍历,构建一个新的 Fiber 树。performSyncWorkOnRoot(root)-->renderRootSync
  2. 更新每个 fiber。workLoopSync-->performUnitOfWork-->beginWork
function workLoopSync() {
// workInProgress:当前正在处理的节点
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
// ...
// NOTE
next = beginWork(current, unitOfWork, subtreeRenderLanes);
// ...
if (next === null) {
// 如果没有子节点,则完成当前工作
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}

beginWork 主要做了以下事情:

  1. 判断 fiber 节点是否可以复用
  2. 根据不同的 Tag(标记不同的组件类型:纯组件、函数组件、类组件),生成不同的 fiber 节点赋值给workInprogress.child
function beginWork(current, workInProgress, renderLanes) {
// ...
switch (workInProgress.tag) {
// ...
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);

// NOTE
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
// 执行 render()、生命周期钩子函数等 -> reconcileChildren
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case HostRoot:
// ReactDOM.render(<App/>) -> reconcileChildren
return updateHostRoot(current, workInProgress, renderLanes);
// ...
}

updateFunctionComponent为例,主要干了几件事:

  1. 生成新的 hooks 链表并挂载在 fiber 节点上
  2. 调用 reconcileChildren
  3. 返回 workInprogress.child
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes
) {
// ...
{
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes
);
}
// ...
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
// ...

reconcileChildren主要是处理子 fiber 节点,在这个阶段做 diff,并在变更的节点上标记副作用类型

// 处理子 fiber 节点
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
// ...
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const isUnkeyedTopLevelFragment = ...;
// Handle object types
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// 根据不同的类型,处理不同的节点对比
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
...
}

if (typeof newChild === 'string' || typeof newChild === 'number') {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
// 多节点数组
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
...
}
...
// reconcileChildFibers 会判断节点变更的类型,赋值flags
// 在commit阶段,根据flags更新dom
// placeSingleChild 会先判断是否是第一次渲染,是的话会增加Placement副作用
function placeSingleChild(newFiber: Fiber): Fiber {
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags = Placement;
}
return newFiber;
}
...
// 对于 ReactElement,会调用 reconcileSingleElement
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
expirationTime: ExpirationTime,
): Fiber {
// 新 key
const key = element.key;
// 旧的第一个子节点
let child = currentFirstChild;

while (child !== null) {
// 新旧key相同
if (child.key === key) {
// 节点类型未改变
if (
child.tag === Fragment
? element.type === REACT_FRAGMENT_TYPE
: child.elementType === element.type ||
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false)
) {
// 复用当前 child,先删除它的兄弟节点
deleteRemainingChildren(returnFiber, child.sibling);
// 复制 fiber 节点,重置 index 和 sibling
const existing = useFiber(
child,
element.type === REACT_FRAGMENT_TYPE
? element.props.children
: element.props,
expirationTime,
);
// ...
// 设置父节点
existing.return = returnFiber;
return existing;
} else {
// 节点类型不同,要删除旧节点
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// 新旧 key 不相同,直接删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}

// child 为 null,需要建立子节点
if (element.type === REACT_FRAGMENT_TYPE) {
// 创建 Fragment 类型的 fiber 节点
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
expirationTime,
element.key,
);
created.return = returnFiber;
return created;
} else {
// 创建 Element 类型的 fiber 节点
const created = createFiberFromElement(
element,
returnFiber.mode,
expirationTime,
);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}

...
// 副作用类型
import {
NoEffect,
PerformedWork,
Placement, // 挂载,didMount
Update, // 更新, didUpdate
Snapshot, // getSnapshotBeforeUpdate,更新之前设置快照
PlacementAndUpdate,
Deletion, // 卸载,willUnmount
ContentReset,
Callback,
DidCapture,
Ref,
Incomplete,
HostEffectMask,
Passive,
} from'shared/ReactSideEffectTags';
  1. fiber 节点更新收尾,串联 fiber 节点,收集 effect(如果有副作用)。completeUnitOfWorkcompleteWork
function completeUnitOfWork(unitOfWork: Fiber) {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
if ((completedWork.flags & Incomplete) === NoFlags) {
let next;
// 处理 fiber 的 props、创建 dom 对象、绑定事件等
next = completeWork(current, completedWork, subtreeRenderLanes);
// ...
// 收集 effect,把子节点 side Effect 加到父节点的 side Effect 上
if (
returnFiber !== null &&
(returnFiber.flags & Incomplete) === NoFlags
) {
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}

const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}
} else {
// ...
}
// 存在兄弟节点,将 workInProgress 指向兄弟节点,并return,执行兄弟节点的beginWork -> Fiber Node
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
// 没有兄弟节点,返回父节点
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
// ...
}

收集 effect 时,会创建 effectList,如下图所示,橙色节点上有 effect

commit

执行到 commitRoot 时,意味着 render 阶段结束,进入 commit 阶段。该阶段主要是处理 FiberRoot 上收集的 effectList,只针对变化的节点做工作,并执行生命周期钩子函数

先看下几个生命周期的执行时机:

// 第1阶段 render/reconciliation
componentWillMount;
componentWillReceiveProps;
shouldComponentUpdate;
componentWillUpdate;

// 第2阶段 commit
componentDidMount;
componentDidUpdate;
componentWillUnmount;

第 1 阶段的生命周期函数可能会被多次调用,默认以低优先级执行,被高优先级任务打断后,稍后会重新执行

commitRoot 主要是执行commitRootImpl这个函数

function commitRoot(root) {
// ...
commitRootImpl.bind(null, root, renderPriorityLevel)
// ...
}
// ...
function commitRootImpl() {
do {
// NOTE 调用 flushPassiveEffects 执行完所有 effect
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
firstEffect = finishedWork.firstEffect;
if (firseEffect !== null) {
nextEffect = firstEffect;
// 第一阶段,before mutation
do {
commitBeforeMutationEffects();
} while(nextEffect !== null)
// ...
// 重置 nextEffect 为 firstEffect,接着进行第二阶段
nextEffect = firstEffect;
// 第二阶段 mutation
do {
commitMutationEffects(root, renderPriorityLevel);
} while(nextEffect !== null)

// 将当前的 workInProgress树 作为 current 树
root.current = finishedWork;

// ...
// 第三阶段 layout
do {
commitLayoutEffects(root, expirationTime);
} while(nextEffect)
// ...
// 确保 root 上所有的 work 都被调度完
ensureRootIsScheduled(root);
// 检测在 useLayoutEffect 中是否做了布局修改等,刷新布局,如果在 layoutEffect 中调用了 setState 也会在该函数中检测中并开启新的一轮调度
flushSyncCallbackQueue();
}
...
}

commit 阶段可以细分为以下三个阶段:

  1. before mutation:读取组件变更前的状态,对于类组件,会调用生命周期函数getSnapshotBeforeUpdate,在 DOM 变更前可以获取到组件实例相关的信息;对于函数组件,会异步调度 useEffect
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 对于使用 getSnapShowBeforeUpdate 的组件 fiber.effectTag |= SnapShot
if ((effectTag & Snapshot) !== NoEffect) {
// ...
const current = nextEffect.alternate;
// 执行 getSnapShotBeforeUpdate 生命周期
commitBeforeMutationEffectOnFiber(current, nextEffect);
// ...
}
// 对于使用 useEffect 的组件,其 Fiber.effectTag = UpdateEffect | PassiveEffect
if ((effectTag & Passive) !== NoEffect) {
// If there are passive effects, schedule a callback to flush at
// the earliest opportunity.
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
// NOTE
scheduleCallback(NormalPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
  1. mutation:根据 effectTag 执行对应的 dom 操作。对于类组件,还会调用 componentWillUnmount;对于函数组件,还会执行 useLayoutEffect 的销毁函数
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
while (nextEffct) {
// ...
const effectTag = nextEffect.effectTag;
let primaryEffectTag =
effectTag & (Placement | Update | Deletion | Hydrating);
// ...
switch (primaryEffectTag) {
// 挂载 DOM
case Placement: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
return;
}
// 更新组件及DOM
case PlacementAndUpdate: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 刷新 layoutEffect.destroy
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新组件
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 卸载
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
// ...
}
nextEffect = nextEffect.nextEffect;
}
}
  1. layout:在 DOM 操作完成后,读取组件的状态,对于类组件,会调用生命周期componentDidMountcomponentDidUpdate;对于函数式组件,主要执行 commitHookEffectListMount
function commitLayoutEffects(root, committedExpirationTime) {
while (nextEffect !== null) {
// ...
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
recordEffect();
const current = nextEffect.alternate;
// didmount
commitLayoutEffectOnFiber(
root,
current,
nextEffect,
committedExpirationTime
);
}
// ...
}
}

function commitLayoutEffectOnFiber(
finishedRoot,
current,
finishedWork,
committedExpirationTime
) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// ...
return;
}
case ClassComponent: {
// didMount
}
}
}

· 10 min read

上一次用 vue 在工作中开发项目是在大四的实习期间,不过那会儿还是清一色的 vue2,最近 vue3 也是正式上线不久,看了下文档发现改动蛮大的

写在前面

vue3 的改变主要有以下几点:

  • 实现响应式的 api 由 defineProperty 改为 proxy
  • 副作用 effect(代替 vue2 的 Observer、Watcher 模块)

其他的还有 Composition API、缓存事件处理函数、Block Tree、拥抱 ts...

详情可以参考 Vue3 对比 Vue2.x 差异性、注意点、整体梳理

Proxy

Proxy 是 vue3 实现响应式更新的基石。这个 api 的作用是代理对象,可以拦截对对象的操作,vue3 主要用到了 Proxy 的两个 api 来实现响应式数据:

// 拦截对象属性的访问
get(target, prop, receiver)
// 拦截对象属性的设置,最后返回一个布尔值
set(target, prop, value, receiver)

但是 Proxy 不支持 IE,所以说 vue3 使用 Proxy 也意味着抛弃 IE

更多内容可以参考 Proxy - MDN

简单实现对象数据的响应式如下:

function createReactiveObject(target) {
if (!isObject(target)) {
return target;
}
const baseHandler = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
return res;
},
};
const observed = new Proxy(target, baseHandler);
return observed;
}

可以看到,如果我们访问和增加一个不存在的属性,同样也会触发getset操作。这样就不必使用$set这个 api 了。

当访问的 key 是一个对象时,会触发递归(如果不访问这个对象,就永远不会去做这一步递归操作),比如下面这个例子:

data = {
title: "007",
person: {
name: "",
},
};
data.person.name = "JacksonZhou";

当对data.person.name赋值时,因为会首先访问data.person,所以会触发get,此时就可以将data.person这个对象转换为响应式对象,之后修改 data.person.name 就会触发 set

对比 defineProperty

  1. 更多的拦截操作
  2. 监听对象新增和删除属性。vue2 不能监测对象新增和删除属性,可以用 $set 方法监听
  3. 更好地监听数组。defineProperty 不能监听对数组的 length 属性的修改以及新增的元素。这也是 vue2 需要改写数组原型的原因之一
// 改写数组原型
const arrayProto = Array.prototype;
const subArrProto = Object.create(arrayProto);
const methods = [
"pop",
"shift",
"unshift",
"sort",
"reverse",
"splice",
"push",
];
methods.forEach((method) => {
// 重写原型方法
subArrProto[method] = function () {
arrayProto[method].call(this, ...arguments);
};
// 监听这些方法
Object.defineProperty(subArrProto, method, {});
});
  1. 性能优化。Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现使用defineProperty,对所有 key 绑定响应式。当 data 的层级关系很深时,会影响性能;而 proxy 可以实现只在访问对象深层级的属性时收集依赖

Reflect

Reflect 是一个内置的对象,它提供拦截 js 对象操作的方法,这些方法与 proxy handlers 的方法相同,所以 vue3.0 使用 ReflectProxy 来操作响应式对象

参见 Reflect - MDN

WeakMap

这个是 ES6 新增的数据类型,WeakMap 对象其实是一组键值对的映射。vue3 收集的依赖会以 WeakMap 对象的形式存储。相比 Map ,区别在于

  • 键必须是对象
  • 键是弱引用的。如果我们在 WeakMap 中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存中自动清除

关于 WeakMap 可以参考这篇好文 《你不知道的 WeakMap》番外篇

响应式更新(reactivity)

先看下整体的响应式更新流程 响应式原理.png

源码的工作流程可以参见这个图

其实整个响应式更新过程主要分为两个阶段,分别是依赖收集和派发通知

依赖收集

其实这就是一个发布订阅,在访问对象(get)时收集依赖。

在 Vue3.0 中引入了 effect 副作用函数(类似于 react hooks 的useEffect),这个函数默认会首先执行一次,此时全局的 effect 会指向这个函数,访问函数中的数据时,会收集这个副作用函数作为数据的依赖;当数据发生变化的时候就会执行所有依赖该数据的副作用函数

这里其实用了栈的数据结构来存储执行中的 effect 函数,可以确保嵌套 effect 函数的正确执行,实现如下:

const activeEffectStacks = []; // 栈
function effect(fn) {
const effect = createReactiveEffect(fn);
effect();
}

function createReactiveEffect(fn) {
const effect = function () {
return run(effect, fn);
};
return effect;
}

function run(effect, fn) {
try {
activeEffectStacks.push(effect); // effect 入栈
fn(); // 此过程访问到响应式数据时,会触发 get 并收集依赖
} finally {
activeEffectStacks.pop(); // effect 出栈
}
}

前面知道,我们会在访问对象时触发依赖收集,源码中主要是通过track函数收集依赖

get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// 收集依赖,将 key 和 effect 关联起来
track(target, key)
return isObject(res) ? reactive(res) : res
}

对象的依赖集合如下图所示:

vue 中有一个总的 targetMap, 它是一个 WeakMap ,key 是 target(代理的对象), value 是一个 Map ,称之为 depsMap,它是用于管理当前 target 中每个 key 的 deps(依赖/副作用),每个 deps 是一个 Set ,代码表示如下:

// targetMap
{
target: {
key: [effect1, effect2]
},
// ...
}

vue3 通过 track 方法收集依赖,参考源码如下:

const targetsMap = new WeakMap();
function track(target, key) {
const effect = activeEffectStacks[activeEffectStacks.length - 1];
// 确保当前有 effect 在执行
if (effect) {
let depsMap = targetsMap.get(target);
if (!depsMap) {
targetsMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
if (!deps.has(effect)) {
// 收集当前执行的 effect 作为依赖
deps.add(effect);
}
}
}

派发通知

更改对象(set)后,会遍历通知之前收集的所有依赖,并更新视图(也就是执行 renderEffect)

set(target, key, value, receiver) {
const hadKey = hasOwn(target, key)
const oldValue = target[key]
const res = Reflect.set(target, key, value, receiver)
if (!hadKey) {
trigger(target, key)
} else if (oldValue !== value) {
trigger(target, key)
}
return res
}

vue3 通过 trigger 函数派发通知,参考源码如下:

function trigger(target, key) {
const depsMap = targetsMap.get(target);
if (depsMap) {
const deps = depsMap.get(key);
if (deps) {
// 依次执行所有依赖该数据的 effect
deps.forEach((effect) => {
effect();
});
}
}
}

其他

重复代理

在 Vue 中是使用了 hash 表来实现避免重复代理的,也就是使用了 WeakMap 来在第一次创建代理后缓存一个映射关系,下一次代理的时候如果之前已经代理过了就直接返回之前的代理

Proxy 操作数组会发生多次 set

如下代码

let arr = [1, 2, 3];
arr.push(4);

默认情况下会执行两次操作,分别是修改下标为 3 的属性值和修改 length 属性

我们只需要屏蔽掉修改 length 属性的操作就可以了

set(target, key, value, receiver) {
const hadKey = hasOwn(target, key)
const oldValue = target[key]
const res = Reflect.set(target, key, value, receiver)
if (!hadKey) {
console.log('添加属性')
} else (oldValue !== value) {
// 添加4之后,其实arr.length已经默认调整为4了,所以不会走到这一步
console.log('修改属性')
}
return res
}
}

· 3 min read

vue2 主要是基于 Object.defineProperty 做的数据双向绑定,这里从 observer、watcher、dep 出发做一个简单的数据绑定实例

主要步骤

  1. observer 递归监听 data 对象的所有属性
  2. watcher 订阅器,视图、指令等需要绑定某个属性时会新建一个订阅器,并加入 Dep
  3. Dep 存放绑定某个属性的所有订阅器,可以添加订阅器和通知变化
  4. 数据更改时,通知 Dep 去执行相应的更新函数

vue原理图示

Dep

订阅器集合,也就是绑定了某个属性的视图或其他对象的集合。

class Dep {
constructor() {
this.deps = [];
}
// 添加订阅器
addDep(dep) {
this.deps.push(dep);
}
// 更新所有订阅器
notify(newData) {
for (let dep of this.deps) {
dep.update(newData);
}
}
}

Observer

递归监听 data 对象的属性

// defineReactive 监听对象属性
function defineReactive(obj, key, val) {
// deps 订阅器集合,作为闭包,方便处理订阅器的添加和更新操作
let deps = new Dep();

Object.defineProperty(obj, key, {
get() {
// 判断是否需要添加订阅器
// Dep.target 用于暂存订阅器
Dep.target && deps.addDep(Dep.target);
},
set(newData) {
// 对比数据,如果变化了就通知订阅器
if (newData !== val) {
deps.notify(newData);
}
},
});
}
function observer(data) {
if (!data || typeof data !== "object") {
return null;
}
for (key in data) {
if (data.hasOwnProperty(key)) {
if (typeof data[key] === "object") {
// 递归监听
observer(data[key]);
}
defineReactive(data, key, data[key]);
}
}
}

Watcher

  • 绑定数据时,需要新建一个 watcher 实例,比如视图绑定、computed、watch
  • 订阅器初始化时,会自动将自己添加进 deps 里
// Watcher 订阅器
class Watcher {
constructor(id, vm, key, cb) {
console.log(`订阅器${id}`);
this.id = id; // 订阅器标识
this.vm = vm; // 当前环境
this.cb = cb; // 更新回调
this.key = key; // 当前监听属性
// 触发getter,将自身添加到属性的订阅器数组deps中
Dep.target = this;
let val = vm.data[key]; // 强制执行 get,加入到订阅器集合
Dep.target = null;
}
update(newData) {
console.log(`订阅器${this.id}数据更新了,newData: ${newData}`);
// this.cb()
}
}

触发更新

// 新增订阅器
this.data = {
name: "jack",
action: "eating",
};
observer(this.data);
new Watcher(1, this, "name");
new Watcher(2, this, "action");
this.data.name = "Tom";

· 6 min read

Vue2.x 不能检测以下数组的变动:1.直接修改数组长度;2.通过数组下标赋值。第一点主要是收到defineProperty的限制,第二点要究其原因,还是得从源码分析。

直接修改数组长度

Vue2.x 利用Object.defineProperty劫持对象的访问器,在属性值发生变化时我们可以获取变化,实现对数据变化的双向绑定。但是Object.defineProperty并不能监控数组长度的变化,因为数组对象中的 length 属性的 configurablefalse,不允许访问器对该属性进行操作

关于数据双向绑定,可以参考基于 defineProperty 和 proxy 实现数据双向绑定

通过数组下标赋值

vue 怎么监听数组

Observer,是实现 vue 响应式的核心模块,用于监听对象和依赖追踪

// Observer 监听对象
var Observer = function Observer(value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
def(value, "__ob__", this);
if (Array.isArray(value)) {
// 将重写的数组方法挂载到数组原型对象上
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
this.walk(value);
}
};

可以看到,observer 遇到数组会走单独的处理逻辑,首先将重写的数组方法挂到数组原型方法上,然后进一步通过observeArray处理数组

// observeArray
Observer.prototype.observeArray = function observeArray(items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
// 如果items[i]是数组,会新建 Observer 对象,然后继续执行observe, 直到遍历完毕
}
};
// observe
function observe(value, asRootData) {
if (!isObject(value) || value instanceof VNode) {
// 如果要观测的数据不是一个对象或者是 VNode 实例,则不做监听
return;
}
var ob;
if (hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
// 已经添加过observer实例,直接返回
ob = value.__ob__;
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob;
}

可以看到,vue 并没有对数组的所有元素进行监听,所以通过数组下标修改或增加元素,会发现依赖该数组元素的视图并不会更新

重写数组方法

Vue 2.x 针对数组的部分原型方法做了特殊处理,通过调用这些重写的方法,可以对新增的元素的索引进行监听,确保新增元素是响应式的

var methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];

methodsToPatch.forEach(function (method) {
var original = arrayProto[method];
def(arrayMethods, method, function mutator() {
var args = [],
len = arguments.length;
while (len--) args[len] = arguments[len];

var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) {
ob.observeArray(inserted);
}
// 重新触发依赖更新
ob.dep.notify();
return result;
});
});

通过源码可以看到,在操作数组时,如果使用 push、unshift、splice 添加或修改元素,会重新触发依赖的更新函数,渲染对应视图或对象

Vue.set

除了上述方法,我们还可以通过Vue.set来实现对数组的更新,并渲染对应视图。贴一波 set 函数源码,源码分析可以看下注释

function set (target, key, val) {
......
if (Array.isArray(target) && isValidArrayIndex(key)) { // 处理数组元素
target.length = Math.max(target.length, key)
// 使用重写过的splice更新数组
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
// 是对象原有属性,赋值即可触发更新函数
target[key] = val
return val
}
const ob = target.__ob__
// __ob__指向target的observer实例
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
// 对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性
// 所以会直接返回值
return val
}
// target非响应式对象,不用对属性监听,直接赋值并返回
if (!ob) {
target[key] = val
return val
}
// 给新增的对象属性添加依赖
defineReactive(ob.value, key, val)
// 触发依赖,重新渲染页面
ob.dep.notify()
return val
}

· 11 min read

本文主要是记录 Git 在实际开发中的常见用法和技巧

环境配置(Mac)

1. 检查环境变量

在终端输入 git,如果出现下面的提示,则需要先初始化 xcode

Agreeing to the Xcode/iOS license requires admin privileges, please run “sudo xcodebuild -license” and then retry this command.

打开 Xcode 软件进行初始化即可,之后再次输入'git',出现下面的提示则说明,环境变量配置成功

usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
[--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
[-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
[--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
<command> [<args>]

2. 配置用户基本信息

每一条提交都会产生一条记录,这条记录标记了提交人的姓名与邮箱,以便他人查看与联系。

git config --global user.name "JacksonZhou"
git config --global user.email "1359926897@qq.com"
// 查看全局配置
git config --list

3. 配置 ssh

配置了 SSH 到指定服务器上,即说明让服务器信任你的笔记本,每次从 gitlab 或 github 拉代码和上传代码时不用输入用户名和密码

生成 ssh key

ssh-keygen -t rsa -C "1359926897@qq.com"

检查是否生成成功

cat ~/.ssh/id_rsa.pub
// ssh key 生成成功的话,就会看到如下输出
// ssh-rsa ......

复制 ssh-rsa 后面的内容,粘贴到你服务器网站的 SSH Key 配置项里面即完成配置,接下来就可以拉取代码了。

Git 基本操作

先看下 Git 的基本工作流 image.png 下面是一些实际开发过程常见的操作,更详细的内容建议查看 Git 中文手册

工作区 <--> 远程仓库

克隆远程仓库到本地

  • git clone
git clone origin <remote_repo>
git clone -b <branch> <remote_repo> // 从指定分支拉取代码

连接远程仓库

  • git remote
git remote add origin <remote_repo>     // 链接远程仓库
git remote set-url origin <remote_repo> // 修改远程仓库
git remote rm origin // 删除远程仓库
git remote -v // 查看远程仓库列表
git remote show origin // 查看具体仓库细节

upstream

在 Github 上,我们可以 fork 任意开源项目到个人 ID 下,当我们把个人 ID 下的仓库 clone 到本地时,除了默认的 origin 仓库外,我们还需要配置一个 upstream 仓库,指向原始的开源项目地址,保证可以定期同步源仓库的更新。(用的比较少)

git remote add upstream <git_repository_url> // 建立 upstream 仓库
git remote remove upstream // 删除远程 upstream 仓库

实际开发过程中,如果没有先确定分支的追踪关系,当我们使用 git pull 或 git push 时就需要指定从远程的哪个分支拉取合并和推送到远程的哪个分支。

git branch -u <> <>    // 指定本地分支和远程分支的追踪关系
git push -u <> <> // 确定追踪关系并提交代码

与远程仓库同步

  • git pull
git pull origin <branch> // git fetch + git merge,从指定分支拉取代码并更新
git pull --rebase // git fetch + git rebase,不会生成 merge 记录,建议是用于个人分支

工作区 <--> 暂存区

提交

  • git add
git add .
git add -i // 筛选想要提交的修改并提交

撤销

  • git restore

暂存区 <--> 本地仓库

提交

  • git commit
git commit -m "" // 提交代码到本地仓库

重新提交

如果发现已经提交了改动,但是还有修改也是需要合到那一部分改动中的,可以这样操作

git add .
git commit --amend -m ""
// amend 也可以单纯用来修改 commit 信息

撤销

  • git reset - 适用于本地记录,即还没提交到远程仓库的记录
git reset HEAD^1 --hard   // 重置为上一个 commit,并清空工作区域的代码改动
git reset HEAD^1 --soft // 重置为上一个 commit,不过会保留工作区域的代码改动
  • git revert - 适用于已经提交到远程仓库的记录的撤销

commit message 规范

# 主 type
feat: 增加新功能
fix: 修复bug

# 其他 type
docs: 只改动了文档相关的内容
style: 不影响代码含义的改动,例如去掉空格、改变缩进、增删分号
build: 构造工具的或者外部依赖的改动,例如webpack,npm
refactor: 代码重构时使用
revert: 执行git revert打印的message
test: 添加测试或者修改现有测试
perf: 提高性能的改动
ci:CI(持续集成服务)有关的改动
chore: 不修改src或者test的其余修改,例如构建过程或辅助工具的变动

本地仓库 <--> 远程仓库

同步

  • git fetch
  1. 从远程仓库下载本地仓库中缺失的提交记录
  2. 更新远程分支指针

提交

  • git push

    在多人协作的分支上尽量不用 git push -f、git rebase、reset 的操作

git push -f.gif

撤销

HEAD 总是指向当前分支上最近一次提交记录。

git checkout <commit_hash> // 让 HEAD 指向指定的 commit
HEAD^ // 向前移动一个提交记录
HEAD^2 // TODO 移动到前面一排记录的第二个提交记录
HEAD~3 // 向前提交三个记录

通过 cat .git/head 可以查看 HEAD 指向

分支

  • git branch - 创建本地分支
git branch '' // 创建分支
git switch '' // 切换分支
git checkout -b '' // 创建分支并切换到该分支
git branch -f <branch> <commit_hash/branch> // 将指定 branch 的 HEAD 指向某个提交记录或者某个分支的 HEAD

stash

git stash 可以将当前工作状态(WIP,work in progress)临时存放在 stash 列表中,待 pull / merge 操作完成后,再从 stash 中重新应用这些修改。

git stash list
git stash save 'message' // 存储一个自定义 message 的 WIP 到 stash 栈中
git stash -u save '' // -u 参数表明新增的文件也一起 stash
git stash pop // 恢复上一次的 WIP 状态,并从 stash 栈中移除
git stash pop stash@{num} // 恢复指定编号的 WIP,并从 stash 栈中移除
git stash apply stash@{num} // 恢复指定编号的 WIP,但不从 stash 栈中移除

如果 git stash 的文件刚好被其他提交修改过了,那么在 pop 时会自动将修改过的文件标记为 conflict 状态

合并代码

merge

该命令主要按以下两种策略合并代码:

Fast-forword(--ff)

git_merge_ff.gif

no-fast-forword(--no-ff)

git_merge_noff.gif

rebase

Rebase 实际上就是取出一系列的提交记录,将他们复制到其他地方。相比 merge,rebase 能提供更加线性的提交历史。这个操作比较危险,会改变提交历史,因此建议不要对已经处于远端的多人共用分支做 rebase 操作。

reword:修改提交信息;
edit:修改此提交;
squash:将提交融合到前一个提交中;
fixup:将提交融合到前一个提交中,不保留该提交的日志消息;
exec:在每个提交上运行我们想要 rebase 的命令;
drop:移除该提交。

git_rebase.gif

复制记录

  • cherry-pick - 可以复制指定的 commit

查看日志

  • git config
// 1. git 配置 alias
git config --global alias.ls 'log --name-status --oneline --graph'
git ls
git config --global alias.lg "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --date=relative"
git lg
// 2. 用 zsh 配置 alias

git show <commit_id> // 查看具体某次commit记录
git log -p -n // n是代表最近几次的提交,可以查看最近几次commit记录

开发实践

合并多个 commit

  • git rebase -i HEAD~3 // 编辑最近三个记录
p HEAD1
s HEAD2
s HEAD3

如何修改之前的某个提交(已 push)?

  1. git rebase 先调整待修改的提交到最新
  2. 修改后提交
  3. git rebase 再调整回来

如何修改之前的某个 commit?

  • git commit --amend -- 修改 commit message 或追加文件改动建议用这个
  • git reset HEAD^1 --soft

如何复制其他分支的文件夹到当前分支?

git checkout <branch> -- [file_name]

参考

· 16 min read

最近在用 typescript + react 开发项目,学习到了蛮多。其实 typescript 是 js 的超集,最终还是要编译为 js 代码。typescript 提供的静态类型系统,大大增强了代码的可读性以及可维护性,可以更好地支持大规模 JavaScript 应用的开发和维护。

写在前面

JavaScript 是一门弱类型语言,变量的数据类型具有动态性,只有执行时才能确定变量的类型。以往的前端开发过程中,很容易出现以下的报错:

// 读取对象上不存在的属性
Uncaught TypeError: Cannot read property ... of undefined

// 类型错误
TypeError: 'undefined' is not a ...

以对象 obj 为例,在 typescript 中可以用接口来定义一个对象类型

interface IObj {
name: string
}

const obj: IObj;

通过这种方式限制 obj 的属性类型,在赋值时只能是添加 name 属性,并且不能给 name 赋值 string 以外的类型;每当你获取 obj 属性的值时,会在编译阶段检查该属性是否存在,不存在会提前抛出类型错误。

在变量声明之前做好类型定义,并通过提前暴露和修复这些可能出现的类型错误,可以大大降低应用在运行时出现错误的概率,使得 javascript 应用更加健壮,这一切得益于类型思维。

作用总结为以下几点:

  • 类型检查
  • 提供更丰富的语法(es6)
  • 编译器提示友好

思维方式决定了编程习惯,编程习惯奠定了工程质量,工程质量划定了能力边界

数据类型

number

数字类型,但是new Number(0)这种是对象,并不是数字类型

let num: number = 0;
let num = 0; // 这样也可以,ts 会进行隐性的类型推论,不过还是建议明确定义类型

string

let str: string = "str";

boolean

let bool: boolean = true;

数组

主要有两种方式定义数组类型,一般只能定义数组元素都为某一种类型的数组

let arr: number[] = [1, 2, 3];
let arr: Array<number> = [1, 2, 3];
// 定义一个不确定元素类型的数组
let arr: any[] = [];
// 定义一个只读的数组
let arr: ReadonlyArray<number> = [1, 2];

元组

元组可以用于定义一个具有多种类型元素的数组,元素数量已知,并且他们的顺序是确定的

const arr: [string, number] = ["cat", 1];
const arr: [string, number?] = ["cat", 1];
const arr: [string, number?] = ["cat"];
function fn(...args: [number, string]): void;

TypeScript 4.0 支持为元组类型设置标签

function fn(...args: [age: number, id: string]): void;

枚举

列出所有可用值,默认初始值是 0。常用于状态值管理等

enum MyPets {
cat = "lazy cat",
dog,
mouse,
}
let cat: MyPets = MyPets.cat;

枚举的原理其实也很简单,相当于定义对象时多做了 value-->key 的映射,以上面的代码为例

var MyPets;
(function (MyPets) {
MyPets["cat"] = "lazy cat";
MyPets[MyPets["dog"] = void 0] = "dog";
MyPets[MyPets["mouse"] = void 0] = "mouse";
})(MyPets || (MyPets = {}));

null

类似于 js 的 null,是所有类型的子类型

undefined

类似于 js 的 undefined,是所有类型的子类型

void

当函数没有返回值时,可以直接用 void 定义返回值类型。

function func(): void {
...
}

声明一个 void 类型的变量没有什么用,因为你只能将它赋值为 undefined 和 null

unknown

所有类型都可以分配给 unknown,但只能将 unknown 类型的变量赋值给 any 和 unknown

any

声明一个 any 类型的变量,它可以赋给任何类型值,也可以接受任何类型值,也就意味着放弃对这个变量的类型检查,不建议使用。

let demo: any = 2; // 接受任何类型值
let demo_2: string;
demo_2 = demo; // 赋给任何类型值

any 和 unknown 的最大区别是, unknown 是 top type (任何类型都是它的 subtype) , 而 any 即是 top type, 又是 bottom type (它是任何类型的 subtype ) ,这导致 any 基本上就是放弃了任何类型检查.

never

当一个函数永远不会返回时,我们可以声明返回值类型为 never

function func(): never {
throw new Error("never reach");
}

可以利用 never 在类型收窄时做全面性的检测,防止后面对 TPayload 的意外修改,确保类型安全

type TPayload = number | string;
function func(payload: TPayload) {
if (typeof payload === "number") {
return 0;
} else if (typeof payload === "string") {
return "";
} else {
const res: never = payload;
return res;
}
}

接口

接口(interface)常用于定义对象类型

interface IObj {
name: string;
age?: number; // 可选
readonly type: string; // 只读属性,只能在创建的时候赋值
[key: string]: string; // 索引类型,允许定义多个key为string的string类型值
}

同名接口会合并成员,如果有相同的成员,必须为同类型,否则编译器会报错。

interface IObj {
name: string;
func(id: string): string;
}
interface IObj {
age: number;
func(id: number): number;
}
// 相当于
interface IObj {
name: string;
age: number;
func(id: number): number;
func(id: string): string;
}

更详细的合并规则可以参考 typescript 的声明合并

函数

定义入参和返回值类型,必传参数可具有默认值

// age是可选参数,需要放在必传参数name后面
function func (id: string = '007', num?: number): string {
...
}
let func: (a: string) => string = function(a: string): string{
return 'JacksonZhou'
}

也可以通过接口定义函数,分为 callable 和 newable 两种形式

// callable
interface Func {
(id: number, ...others: any[]): string;
}
const fn: Func;
// newable
interface NewFunc {
new (): string;
}
const fn: NewFunc;
new fn();

函数重载

function func(a: number, b: number);
function func(a: string, b: string);
function func(a: number, b: number, c: number) {
//...
}

函数重载的原理是重新定义所有重名函数,并放到一个列表里,匹配函数时会从上到下,通过匹配参数类型来选择指定函数

交叉类型

let id: string & number;

联合类型

联合类型的变量在被赋值的时候,会根据类型推论的规则推断出一个类型;

let id: string | number;
id = "2";
console.log(id.length);
id = 2;
console.log(id.length); // 报错,数字类型没有length属性

类型断言

对类型有把握但是又不能准确定义的时候,可以使用类型断言

常规断言(<> or as):

const id: any = "hello";
const length = (id as string).length;
// const length = (<string>id).length;

实际开发中使用的几率不大,比如下面这种情况就可以使用断言来规避编译报错

interface Cat {
name: string;
run(): void;
}
interface Fish {
name: string;
swim(): void;
}

function isFish(animal: Cat | Fish) {
if (typeof (animal as Cat).run === "function") {
return true;
}
return false;
}

非空断言(!):

function fn(maybeString: string | undefined | null) {
const onlyString1: string = maybeString; // Error
const onlyString2: string = maybeString!; // Ok
}

function fn(getNum: () => number | undefined) {
const num1 = getNum(); // Error
const num2 = getNum!(); // OK
}

类型守卫

类型守卫指的就是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内,主要有以下几种方法检测类型:

  • in
  • typeof
  • instanceof
  • is
function isNumber(x: any): x is number {
return typeof x === "number";
}

泛型

定义未来数据类型

function func<T>(arg: T): T {
return arg;
}

let func_1 = func<string>("cat");
let func_2 = func("cat");

typescript 对类属性的访问控制 可访问性|public|protected|private -|-|-|- 类|yes|yes|yes 子类|yes|yes|no 类实例|yes|no|no

class Cat {
name: string; // 这个是 this.name 的类型定义
food: string;

constructor(name: string, food: string) {
this.name = name;
this.food = food;
}

eat() {
console.log(this.name + " eat " + this.food);
}
}

let myCat: Cat = new Cat("Tom", fish);
console.log(myCat.eat());

操作符

?.

可选链,在引用某个对象的属性时,会经常用到,在遇到 undefined 和 null 时会立即阻止表达式的运行,并返回 undefined,编译后的 es5 代码如下:

a?.b?.c

(_a = a === null || a === void 0 ? void 0 : a.b) === null || _a === void 0 ? void 0 : _a.c;

void 运算符能对给定的表达式进行求值,然后返回undefined,在这里void 0就相当于undefined,为什么不直接用undefined呢?原因是undefined并不是保留字,它只是全局对象的一个属性,虽然在 es5 是全局对象的一个只读属性,但是在局部作用域中依然可以被重写。

??

更多人会接触到短路操作符 ||,常用于数据兜底,但是它会影响 falsy 值,包括''、0、false、undefined、null...,而 ?? 只会影响 undefined 和 null。?? 不能和 || 或者 && 并用,会报 SyntaxError 错误,但是可以用括号定义运算优先级来避免这种错误

!非空断言

可以用于忽略 undefined 和 null

function myFunc(id: string | undefined | null) {
const myId: string = id; // Error
const ignoreUAndN: string = id!; // Ok
}
function myFunc(fn: Function | undefined) {
fn!(); // Ok
}

内置对象

object/Object/{}

  • object - 表示非原始类型
  • Object - 所有 Object 类的实例的类型
// node_modules/typescript/lib/lib.es5.d.ts
interface Object {
constructor: Function;
toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: PropertyKey): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: PropertyKey): boolean;
}

interface ObjectConstructor {
// Object 类的所有实例都继承了 Object 接口中的所有属性
new (value?: any): Object;
(value?: any): any;
readonly prototype: Object;
getPrototypeOf(o: any): any;
// ···
}

declare var Object: ObjectConstructor;
  • {} - 没有成员的对象

DOM

Document、HTMLElement、Event、NodeList

其他

Boolean、Error、Date、RegExp

ts.config.js

作用

  • 用于标识 TypeScript 项目的根路径
  • 用于配置 TypeScript 编译器
  • 用于指定编译的文件

重要字段

  • files - 设置要编译的文件的名称;
  • include - 设置需要进行编译的文件,支持路径模式匹配;
  • exclude - 设置无需进行编译的文件,支持路径模式匹配;
  • compilerOptions - 设置与编译流程相关的选项。部分选项及其作用参考:
{
"compilerOptions": {
/* 基本选项 */
"target": "es5", // 指定 ECMAScript 目标版本: 'ES3' (default), 'ES5', 'ES6'/'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'
"module": "commonjs", // 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
"lib": [], // 指定要包含在编译中的库文件
"allowJs": true, // 允许编译 javascript 文件
"checkJs": true, // 报告 javascript 文件中的错误
"jsx": "preserve", // 指定 jsx 代码的生成: 'preserve', 'react-native', or 'react'
"declaration": true, // 生成相应的 '.d.ts' 文件
"sourceMap": true, // 生成相应的 '.map' 文件
"outFile": "./", // 将输出文件合并为一个文件
"outDir": "./", // 指定输出目录
"rootDir": "./", // 用来控制输出目录结构 --outDir.
"removeComments": true, // 删除编译后的所有的注释
"noEmit": true, // 不生成输出文件
"importHelpers": true, // 从 tslib 导入辅助工具函数
"isolatedModules": true, // 将每个文件做为单独的模块 (与 'ts.transpileModule' 类似).

/* 严格的类型检查选项 */
"strict": true, // 启用所有严格类型检查选项
"noImplicitAny": true, // 在表达式和声明上有隐含的 any类型时报错
"strictNullChecks": true, // 启用严格的 null 检查
"noImplicitThis": true, // 当 this 表达式值为 any 类型的时候,生成一个错误
"alwaysStrict": true, // 以严格模式检查每个模块,并在每个文件里加入 'use strict'

/* 额外的检查 */
"noUnusedLocals": true, // 有未使用的变量时,抛出错误
"noUnusedParameters": true, // 有未使用的参数时,抛出错误
"noImplicitReturns": true, // 并不是所有函数里的代码都有返回值时,抛出错误
"noFallthroughCasesInSwitch": true, // 报告 switch 语句的 fallthrough 错误。(即,不允许 switch 的 case 语句贯穿)

/* 模块解析选项 */
"moduleResolution": "node", // 选择模块解析策略: 'node' (Node.js) or 'classic' (TypeScript pre-1.6)
"baseUrl": "./", // 用于解析非相对模块名称的基目录
"paths": {}, // 模块名到基于 baseUrl 的路径映射的列表
"rootDirs": [], // 根文件夹列表,其组合内容表示项目运行时的结构内容
"typeRoots": [], // 包含类型声明的文件列表
"types": [], // 需要包含的类型声明文件名列表
"allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。

/* Source Map Options */
"sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置
"mapRoot": "./", // 指定调试器应该找到映射文件而不是生成文件的位置
"inlineSourceMap": true, // 生成单个 soucemaps 文件,而不是将 sourcemaps 生成不同的文件
"inlineSources": true, // 将代码与 sourcemaps 生成到一个文件中,要求同时设置了 --inlineSourceMap 或 --sourceMap 属性

/* 其他选项 */
"experimentalDecorators": true, // 启用装饰器
"emitDecoratorMetadata": true // 为装饰器提供元数据的支持
}
}

辅助工具

· 5 min read

泛型对前端开发人员来说会比较陌生,其实它可以被理解成是一种预先设置的数据类型,在后面通过推导得出真实的类型后再替换。在 typescript 中有很多的工具泛型,可以帮助我们更加灵活地处理类型,简化类型编程的过程,提高生产力。

关键词

要熟悉工具泛型及其原理,首先要掌握 typescript 的关键词

typeof

获取变量的类型

keyof

keyof T,获取类型 T 的所有键组成的联合类型,keyof 也被称为 索引类型查询操作符

interface IPerson {
id: string;
age: number;
}

type IPersonKeys = keyof IPerson; // 'id' | 'age'
type IPersonKeys = IPerson[keyof IPerson]; // string | number

[]

索引访问操作符,可以进行索引访问

interface T {
K: string;
}

type TypeK = T[K]; // string

in

可以对联合类型进行遍历。通过 [K in Keys] 可以实现映射类型,从旧类型中创建新类型的一种方式

type Index = 'a' | 'b' | 'c'
type FromIndex = { [K in Index]?: number }

const index_1: FromIndex = { b: 1, c: 2 }
const index_2: FromIndex = { b: 1, d: 3 } // 报错,不能添加d属性

extends

  1. 用来扩展已有的类型
interface IAnimal = {
name: string;
}
interface ICat extends IAnimal {
action: string;
}

// 等价于
type ICat = IAnimal & {
action: string;
}
  1. 对类型进行条件限定,比如判断两种类型是否相等:
type IsEqualType<A, B> = A extends B ? (B extends A ? true : false) : false;
  1. 对于 T extends U ? X : Y 来说,还存在一个特性,当 T 是一个联合类型时,会进行条件分发。这一点对于理解后面的工具泛型很关键
type Demo = string | number;
type IsString<T> = T extends string ? "yes" : "no";

type IsDemoString = IsString<Demo>; // 'yes' | 'no';

实际上,extends 的转换类似于下面这一步:

(string extends string ? 'yes' : 'no') | (number extends string ? 'yes' : 'no')

infer

在有条件类型的 extends 子语句中,允许出现 infer 声明,它会引入一个待推断的类型变量,简单说就是用它取到函数返回值的类型来使用

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

readonly

将属性标记为只读

工具泛型

Record

将 K 中所有的属性的值转化为 T 类型

type Record<K extends keyof any, T> = { [P in K]: T };

Pick

从 T 中取出一系列 K 的值

type Pick<T, K extends keyof T> = { [P in K]: T[P] };

Exclude

从 T 中排除 U

type Exclude<T, U> = T extends U ? never : T;

Extract

从 T 中提取 U

type Extract<T, U> = T extends U ? T : never;

Omit

相当于 Pick + Exclude, 实现忽略对象某些属性功能,Pick+Exclude

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Partial

将 T 中的属性都变为可选属性

type Partial<T> = { [k in keyof T]?: T[k] };

Required

将 T 中的属性都变为必选属性

type Required<T> = { [k in keyof T]-?: T[k] };

Readonly

将 T 中的属性都变为只读属性

type Readonly<T> = { readonly [k in keyof T]: T[k] };

Mutable

将 T 中的属性都变为可读属性

type Mutable<T> = { -readonly [k in keyof T]: T[k] };

ReturnType

获取函数的返回值的类型

type ReturnType<T extends (...args: any[]) => any> = T extends (
...args: any[]
) => infer R
? R
: never;

Parameters

获取函数的参数的类型

type Parameters<T extends (...args: any[]) => any> = T extends (
...args: infer P
) => any
? P
: never;

· 5 min read

对于面向对象的编程语言这一说法,准确来说应该是 支持使用面向对象程序设计思想进行编程的编程语言,而这一思想的本质其实是抽象。从这点出发,其实 JavaScript 就是一门面向对象的语言,但是其他面向对象的语言(比如 Java、C++)是基于 class 来实现的,而 JavaScript 是基于原型来实现"

class

es6 新增了 class,下面创建一个关于 Animal 的类

class Animal {
constructor(name) {
// 这里面的this会指向实例
this.name = name;
}
static parentName = "Animal"; // 静态属性
static getParentName() {
// 1.静态方法的this只会指向父类
// 2.可以通过父类调用
// 3.静态方法不能被实例继承,但可以被子类继承
console.log(this.parentName);
return this.parentName;
}
getName() {
console.log(this.name);
return this.name;
}
}

实现 class

其实 es6 的 class 就是一个语法糖,是基于 js 构造函数和原型去实现的,实现代码如下:

function createClass(Constructor, protoProps, staticProps) {
if (protoProps) {
Object.defineProperties(Constructor.prototype, protoProps);
}
if (staticProps) {
Object.defineProperties(Constructor, staticProps);
}
return Constructor;
}
function Animal(name) {
// 实例属性赋值
this.name = name;
}

// 在构造函数的原型对象上挂载实例方法
createClass(
Animal,
{
getName: {
value: function getName() {
console.log("调用实例方法,获取实例属性", this.name);
return this.name;
},
},
},
{
getParentName: {
// 在构造函数上挂载静态属性和方法
value: function getParentName() {
console.log("调用静态方法,获取静态属性", this.parentName);
return this.parentName;
},
},
parentName: {
value: "Animal",
},
}
);

let animal1 = new Animal("animal1");

// 调用实例方法
animal1.getName();
// 调用静态方法
Animal.getParentName();

在创建对象实例时 let animal1 = new Animal('animal1'),主要会经历几个步骤

// 1.新建空对象
let newObj = new Object();
// 2.指向构造函数的原型对象,共享实例方法
newObj.__proto__ = Animal.prototype;
// 3.以新对象为上下文执行构造函数
let result = Animal.call(newObj, "animal1");
// 4.返回一个新对象
// 如果构造函数返回一个对象或函数,对象实例就是该对象或函数;否则返回一个新对象
return result instanceof Object ? result : newObj;

继承

下面通过 extends 关键字实现对 Animal 的继承

class Animal {
constructor(name) {
// 实例属性赋值,这里面的this会指向实例
this.name = name;
}
static parentName = "Animal"; // 静态属性
getName() {
console.log("调用实例方法,获取实例属性", this.name);
return this.name;
}
}

class Cat extends Animal {
constructor(name) {
super(name);
}
}

let cat = new Cat("jack");
cat.getName();
console.log(Cat.parentName);
// 可以发现,静态属性和方法也被子类继承了

实现 extends

extends 本质上是基于寄生式组合继承来实现的

  1. new 一个实例时,调用父类构造函数,继承实例属性
  2. 通过原型链继承父类实例方法
  3. 设置子类的 proto = 父类,让子类可以调用父类静态属性和方法
// 已知 Animal 这个父类
function _inherits(child, parent) {
let proto = Object.create(parent.prototype);
proto.constructor = child;
child.prototype = proto;
}

function Cat(name) {
// 调用父类的构造函数,继承实例属性
Animal.call(this, name);
}

// 继承父类实例方法
_inherits(Cat, Animal);
// 设置子类的 __proto__ = 父类,让子类可以调用父类静态属性和方法
Object.setPrototypeOf(Cat, Animal);

let cat = new Cat("jack");
cat.getName();
console.log(Cat.parentName);

· 13 min read

不论是 react 还是 vue 脚手架,都依赖 webpack 进行构建和打包。这一篇主要是我之前在入门 webpack 4 的时候记的一点小笔记,详细内容还得参见官方文档

简单打个包

初始化项目,会生成一个 package.json

npm init
// 快速 init
// 1、默认都 yes
npm init -y
// 2、设置 init 模板
npm config set init.author.name "JacksonZhou"
npm init -y // 或者 -f

安装 webpack 和 webpack-cli

npm install webpack -D
npm install webpack-cli -D

我们随意创建一个 src 目录下的 index.js 文件,然后在 package.json 修改 scripts

"build": "webapck --mode production"
npm run build

这时会生成一个 dist 文件夹,这个里面就是 webpack 打包好的文件

创建配置文件

创建一个 webpack.config.js,执行 webpack 命令时会去执行这个配置文件。若是自定义配置文件的名字,则应该使用命令 webpack config 文件名

entry

入口,webpack 会从这里开始解析依赖。默认会按 entry 来划分 chunk

entry: './src/index.js'

output

webpack 最终构建出来的静态文件,可配置输出结果的文件名、路径等

const path = require('path') // 本质上webpack配置文件就是nodejs脚本,所以可以使用nodejs的模块
output: {
publicPath: '', // 输出资源的网址前缀
path: path.resolve(__dirname, 'dist'),
filename: '[name].js' // 原文件名
}

关于 filename 的 hash 命名中 hash、chunkhash、contenthash 的区别

  • hash是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,并且全部文件都共用相同的 hash 值
  • chunkhash根据不同的入口文件(Entry)进行依赖文件解析、构建对应的 chunk,生成对应的哈希值,但是 chunkhash 下,引用其他文件的文件一旦修改,也会改变其他文件的 hash 值,即使其他文件没动过。
  • contenthash保证文件内容必须要修改后,hash 值才会变化

loader

通过使用不同的 loader 来解析处理不同类型的文件。webpack 默认支持打包 js 文件,其他格式的文件需要借助对应的 loader 进行转换

换句话说,当 webpack 遇到其他文件的时候,会去配置文件的 module 选项中去找相应的规则。比如在配置文件中我们可以规定当 webpack 遇到图片文件的时候,就使用 file-loader 来帮我们进行打包文件

常见 loader

  1. babel-loader 将 es6/es7 转换成 es5(待确认)
npm i babel-loader @babel/core @babel/preset-env @babel/polyfill -D
npx babel src --out-dir lib // 测试 babel
module: {
rules: [
{
// 命中 js 文件
test: '/\.js$/',
// 使用 babel-loader 来解析 js 文件
loader: 'babel-loader',
// 只命中 src 目录下的 js 文件,加快 webpack 的编译速度
include: path.resolve(__dirname, 'src'),
// 排除 node_modules,加快 webpack 的编译速度
exclude: /node_modules/,
}
]
},

这样还不能发挥 Babel 的作用。在项目根目录下创建一个 .babelrc 文件,添加代码:

{
"presets": [
"@babel/preset-env"
]
}
  1. 构建 CSS
npm i css-loader style-loader -D
module: {
rules: [
{
test: /\.css/,
include: [
path.resolve(__dirname, 'public/style'),
],
use: ['style-loader','css-loader']
// loader 的执行顺序是从下到上,从右到左
}
]
}
// 在index.js里引入
import '../public/style/index.css'

css-loader 负责解析 CSS 代码,主要是为了处理 CSS 中的依赖,帮我们分析出几个 css 文件之间的关系,并最终把这些 css 文件合并成一段 css,例如解析 @import 和 url() 等引用外部文件的声明;

style-loader 会将 css-loader 解析的结果转变成 JS 代码,运行时动态插入 style 标签来让 CSS 代码生效。

经由上述两个 loader 的处理后,CSS 代码会转变为 JS,和 index.js 一起打包了。

  1. 处理预处理语言
npm i less-loader -D
module: {
rules: [
{
test: /\.less$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [
'css-loader',
'less-loader',
],
}),
},
],
},
  1. 处理图片

file-loader 可以用于处理很多类型的文件,它的主要作用是直接输出文件,把构建后的文件路径返回

url-loader会将引入的图片编码,生成 dataURl 并将其打包到文件中。但是如果图片较大,编码会消耗性能。因此 url-loader 提供了一个 limit 参数,小于 limit 字节的文件会被转为 DataURl,大于 limit 的还会使用 file-loader 进行 copy

image-webpack-loader 可用于压缩图片

{
test: '/\.(png|gif|jpe?g)$/i',
use: [
{
loader: 'url-loader',
options: {
limit: 8192 // 单位是 Byte,一般当文件小于 8KB 时作为 DataURL 处理
}
}
]
},
{
test: '/.*\.(gif|png|jpe?g|svg|webp)$/i',
use: [
{
loader: 'file-loader',
options: {}
},
{
loader: 'image-webpack-loader',
options: {
mozjpeg: { // 压缩 jpeg 的配置
progressive: true,
quality: 65
},
optipng: { // 使用 imagemin-optipng 压缩 png,enable: false 为关闭
enabled: false,
},
pngquant: { // 使用 imagemin-pngquant 压缩 png
quality: '65-90',
speed: 4
},
gifsicle: { // 压缩 gif 的配置
interlaced: false,
},
webp: { // 开启 webp,会把 jpg 和 png 图片压缩为 webp 格式
quality: 75
},
}
},
]
}

使用html-withimg-loader进行处理 img 标签引入的图片

npm i html-withimg-loader --save-dev
{
test:/\.(html|htm)$/,
use:'html-withimg-loader'
}
  1. 打包图标字体

其他常用 loader

  • px2rem-loader。移动端 css px 自动转换为 rem 的 loader
  • postcss-loader + autoprefixer。在 css 属性上加上相应的浏览器的厂商前缀,如-webkit、-ms、-moz 等
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')
]
}
// webpack.config.js
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
'less-loader',
'postcss-loader',
]
}
  • ts-loader。转换 ts 代码
  • thread-loader。多进程打包资源
  • raw-loader。将文件以字符串的形式导入
  • ...

plugin

在 webpack 的构建流程中,plugin 用于处理更多其他的一些构建任务。比如有以下这些常见的使用场景

清除 dist 文件

npm i clean-webpack-plugin -D
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

plugins: [new CleanWebpackPlugin()];

关联 HTML

  1. webpack 从入口开始构建 JS ,而通常一个 spa 项目都是从一个 html 页面出发的,这时候需要使用 script 标签引用构建好的 JS 文件。
  2. 但是问题来了,这些构建好的 JS 文件名可能会变,比如用了 hash 值作为文件名,所以我们需要通过插件来自动关联 html 文件
npm i -D html-webpack-plugin
// webpack.config.js
...
const HtmlWebpackPlugin = require('html-webpack-plugin');

plugins: [
new HtmlWebpackPlugin({
filename: 'index.html', // 输出文件
template: './public/index.html' // 模板文件
})
]

之后打包npm run build,dist 目录就有了 index.html 了

其他 plugin

  • ProvidePlugin:设置全局变量
  • mini-css-extract-plugin:将 css 从 bundle 文件中提取成一个独立的 css 文件
  • terser-webpack-plugin:压缩 js 的插件,支持压缩 es6 代码
  • copy-webpack-plugin:将文件或者文件夹拷贝到构建的输出目录
  • ...

mode

开启不同的环境,webpack 4 会根据这个参数做不同的处理和优化,具体处理和优化可以参见 https://webpack.js.org/configuration/mode/#mode-development

resolve

该配置用于扩展模块的解析路径,我们可以通过它配置一些 alias

module.exports = {
...
resolve: {
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
'src': path.resolve(__dirname, '../src'),
},
},
};

optimization

在构建后期负责压缩和优化代码,常见的代码分割现在是通过这个配置来控制。optimization.splitChunks 默认是不用设置的。如果 modeproduction,那 Webpack 4 就会开启 Code Splitting,不过只会对按需加载的代码做分割。如果我们需要配置初始加载的代码也加入到代码分割中,可以设置 splitChunks.chunks'all'

Webpack 4 的 Code Splitting 最大的特点就是配置简单,和基于内置规则自动拆分。内置的代码切分的规则是这样的:

  • 新 bundle 被两个及以上模块引用,或者来自 node_modules
  • 新 bundle 大于 30kb (压缩之前)
  • 异步加载并发加载的 bundle 数不能大于 5 个
  • 初始加载的 bundle 数不能大于 3 个

简单的说,Webpack 会把代码中的公共模块自动抽出来,变成一个包,前提是这个包大于 30kb,不然 Webpack 是不会抽出公共代码的,因为增加一次请求的成本是不能忽视的。

一般用默认的配置就行了。如果有特殊的需求,也可以通过下面的 optimization.splitChunks API 定制。

splitChunks

用法示例

chunks: 'all',
name: true,
cacheGroups: {
// 缓存组,会继承和覆盖splitChunks的配置
default: {
// 模块缓存规则,设置为false,默认缓存组将禁用
minChunks: 2, // 模块被引用>=2次,拆分至vendors公共模块
priority: -20, // 优先级
reuseExistingChunk: true, // 默认使用已有的模块
},
vendors: {
test: /[\\/]node_modules[\\/]/, // 表示默认拆分node_modules中的模块
priority: -10,
},
antd: {
name: 'antd', // 单独将 antd 拆包
priority: 15, // 权重需大于其它缓存组
test: /[\/]node_modules[\/](antd|@ant-design)[\/]/,
},
bizcharts: {
name: 'bizcharts', // 单独将 bizcharts 拆包
priority: 10,
test: /[\/]node_modules[\/]bizcharts[\/]/,
},
antv: {
name: 'antv', // 单独将 antv 拆包
priority: 12,
test: /[\/]node_modules[\/]@antv[\/]/,
},
},

sourceMap

sourceMap 其实就是源码和打包文件的映射关系,可以帮助我们快速定位一些代码问题。webpack 4 默认会开启,关键配置为 devtool,下面是它的一些配置对比:

webpack-sourcemap-type.png

  • inline。直接会将 .map 文件直接打包到对应的 js 中去,从而加快相应的速度。使用这个我们会发现,打包出来的文件没有 .map 文件了,而是以 base64 的形式放入了打包的文件中了。
  • cheap。map 文件只会帮你定为到具体的 某一行,并不会把代码定位到 具体的 某一行 某一列,从而加快速度;cheap 还有一个作用,就是这个选项只使针对业务代码,也就是说只能定位到业务代码里面的错误,并不能定位到我们引用的第三方文件(比如说 loader,第三方模块)的错误。
  • module。不仅会帮我们定位 自己的业务代码中的错误,还会同时帮我们定位第三方模块的错误。
  • eval。使用 eval 包裹模块代码,并且存在 //@sourceURL,这个是打包速度最快,性能最好的的一种方式,但是有的时候,对于代码比较复杂的情况,它提示出来的错误可能不够全面。

建议配置

开发环境:cheap-module-eval-source-map

生产环境不建议开启,如果需要定位错误,可以尝试用cheap-module-source-map

· 8 min read

Promise 是异步编程的一种解决方案。Promise 本质上是一个绑定了回调的对象,而不是将回调传进函数内部,从它可以获取异步操作的信息

人物介绍

Promise 异步操作有三种状态:

  • pending(进行中)
  • fulfilled(已成功)-- resolve
  • rejected(已失败)-- reject

Promise 对象只有两种状态转换:

  • pending => fulfilled
  • pending => rejected

从形式上看,Promise API 就是 Promise、resolve、reject 三个元件

  • Promise
let promise = new Promise((resolve, reject) => {});
  • resolve -- 进入 fulfilled 状态
// Promise.resolve(value)的返回值是一个 promise 对象
// 可以对返回值进行.then调用
Promise.resolve(1).then(function (value) {
console.log(value); // 打印出1
});
  • reject -- 进入 rejected 状态

Promise.then(链式调用)

当连续执行两个或者多个异步操作时,每一个后来的操作都在前面的操作执行成功之后,带着上一步操作所返回的结果开始执行

promise 基本原理

then 方法可以让我们以扁平化的形式处理异步,它将返回一个 resolved 或 rejected 状态的 Promise 对象用于链式调用,且 Promise 对象的值就是这个返回值。

// 添加多个回调函数,会按照插入顺序并且独立运行。
// 后面的请求依赖于前面请求的结果
const p = new Promise(function (resolve, reject) {
resolve(1);
})
.then(function (value) {
// 第一个then // 1
console.log(value);
return value * 2;
})
.then(function (value) {
// 第二个then // 2
console.log(value);
})
.then(function (value) {
// 第三个then // undefined
console.log(value);
return Promise.resolve("resolve");
})
.then(function (value) {
// 第四个then // resolve
console.log(value);
return Promise.reject("reject");
})
.then(
function (value) {
// 第五个then // reject:reject
console.log("resolve:" + value);
},
function (err) {
console.log("reject:" + err);
}
);

错误机制

大多数浏览器中不能终止的 Promise 链里的 rejection,建议后面都跟上

// 一个 Promise 链式程序遇到异常就会停止,查看链式的底端
// 寻找 catch 处理程序来代替当前执行
.catch(error => console.log(error));

从源头发起的错误,会依次流经 .then(resolve, reject).catch(), 只要错误被提供的 reject 方法处理了,下游将不会有这个错误出现;只要存在错误,并且不曾被方法处理,最终都会被.catch()捕获。用传统的异步回调处理这些错误,很大概率会乱成一团~

其他方法

Promise.all(统一回调)

Promise.all 可以将多个 Promise 实例包装成一个新的 Promise 实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被 reject 失败状态的值。

// 传统异步回调处理三个请求
$.get("url_a", function (data) {
if (checkAllWith("a")) {
mainRender(data);
}
});
$.get("url_b", function (data) {
if (checkAllWith("b")) {
mainRender(data);
}
});
$.get("url_c", function (data) {
if (checkAllWith("c")) {
mainRender(data);
}
});
// 使用promise.all
// $getA 是上文中用过的 $.get() 方法的 promise 封装
let $getA = function () {
return $get("a").then(callback_a);
};
let $getB = function () {
return $get("b").then(callback_b);
};
let $getC = function () {
return $get("c").then(callback_c);
};
let all = Promise.all([$getA, $getB, $getC]);
// 3个promise 全部完成后,才会触发回调
all.then((data) => {
mainRender(data);
});
// 定时器例子
let wake = (time) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`${time / 1000}秒后醒来`);
}, time);
});
};

let p1 = wake(3000);
let p2 = wake(2000);

Promise.all([p1, p2])
.then((result) => {
console.log(result);
// [ '3秒后醒来', '2秒后醒来' ]
// 同接收到的数组顺序保持一致
})
.catch((error) => {
console.log(error);
});

在前端开发请求数据的过程中,偶尔会遇到发送多个请求并根据请求顺序获取和使用数据的场景,使用 Promise.all 毫无疑问可以解决这个问题。

Promise.race(竞跑)

Promise.race([p1, p2, p3])里面哪个结果返回的快,就返回那个结果,不管结果本身是成功状态还是失败状态

let p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("success");
}, 1000);
});

let p2 = new Promise((resolve, reject) => {
setTimeout(() => {
reject("failed");
}, 500);
});

Promise.race([p1, p2])
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error); // 打开的是 'failed'
});

尝试实现 api

all

并行执行多个 promise,并等待所有 promise 都准备就绪,但是如果中途有发生错误,则会忽略其他所有 promise

其实它的实现很简单,就是遍历执行 promise 的 then 函数,将回调追加到微任务里。加到微任务的时机则由 promise 确定

function promiseAll(promiseArr) {
let result = new Array(promiseArr.length).fill(null);
let count = 0;
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
result[i] = res;
count++;
if (count === promiseArr.length) {
resolve(result);
}
})
.catch((err) => {
reject(err);
});
}
});
}

allSettled

这是新出的 api,可以解决 all 这个方法的不足。allSettled 可以并行执行多个 promise,并等待所有 promise 就绪,不论是成功还是失败

function promiseAllSettled(promiseArr) {
let result = new Array(promiseArr.length).fill(null);
let count = 0;
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
result[i] = { status: "rejected", value: res };
count++;
if (count === promiseArr.length) {
resolve(result);
}
})
.catch((err) => {
result[i] = { status: "rejected", reason: err };
count++;
if (count === promiseArr.length) {
resolve(result);
}
});
}
});
}

race

与 Promise.all 类似,同样是并行执行多个 promise,但只等待第一个 settled 的 promise 并获取其结果

function promiseRace(promiseArr) {
return new Promise((resolve, reject) => {
for (let i = 0; i < promiseArr.length; i++) {
promiseArr[i]
.then((res) => {
resolve(res);
})
.catch((err) => {
reject(err);
});
}
});
}

resolve/reject

  • Promise.resolve 方法会将传入的对象转为 Promise 对象,并执行 then 方法
function promiseResolve(promise) {
return new Promise((resolve, reject) => {
promise.then((res) => {
resolve(res);
});
});
}
  • Promise.reject 方法会将传入的对象转为 Promise 对象,并执行 catch 方法
function promiseReject(promise) {
return new Promise((resolve, reject) => {
promise.then((res) => {
reject(res);
});
});
}

模拟实现串行调用

最简单的应该就是 promise 嵌套或 async/await

// promise
function serialPromise(ajaxArray) {
let p = Promise.resolve();
let arr = [];
ajaxArray.forEach((promise) => {
p = p.then(promise).then((data) => {
arr.push(data);
return arr;
});
});
return p;
}
// async
function serialPromise(promiseArr) {
let arr = [];
async function run() {
for (let p of promiseArr) {
let val = await p();
arr.push(val);
}
return arr;
}
return run();
}

也可以借助 reduce 实现

function serialPromise(tasks) {
var result = [];
return tasks.reduce((accumulator, item, index) => {
return accumulator.then((res) => {
return getResponse(item).then((res) => {
result[index] = res;
return index == tasks.length - 1 ? result : item;
});
});
}, Promise.resolve());
}

参考

使用 Promises - MDN

ES6 Promise 对象 - 菜鸟教程

重读 ES6 - 简书