跳到主要内容

2024-12-22 小汇总

· 阅读需 3 分钟

级联选择

复选框树形选择器比较常见,比如antd里的TreeSelectCascader组件。当选择某一项时,有以下几种可能:

  1. 如果是选中,则它的下级全都会被选中,并且还需要判断他有没有父级,如果有父级,还需要判断父级是否也是全选中或者半选中状态;
  2. 如果是取消选中,它的下级也会全部被取消选中,并且也要判断父级(或者说是上级)的选中状态。

要实现一个updateSelectedStatus函数,它的前面如下:

enum SelectedStatus {
/** 未选中 */
Unselected,
/** 选中 */
Selected,
/** 半选中 */
Indeterminate,
}

interface Cascade {
id: string;
selectedStatus: SelectedStatus;
children?: Cascade[];
}

function updateSelectedStatus(
checked: boolean,
current: Cascade,
tree: Cascade[]
): Cascade[];

updateSelectedStatus的参数如下:

  • checked 当前项是不是选中状态;
  • current 当前项;
  • tree 整颗级联树;

返回值是路径数组,比如一个属性结构是:

1
|----1_1
|--------1_1_1
|--------1_1_2
|----1_2
|--------1_2_1
|--------1_2_2
2
|----2_1
|--------2_1_1
|--------2_1_2
|----2_2

如果一开始都是未选中状态,这时候我去选了1_2这一项。则:1_2和他的下级1_2_11_2_2都会被选中,并且1这一项将变成半选状态。返回值将会是:

[
{
id: '1_2',
selectedStatus: SelectedStatus.Selected,
children: [...]
},
{
id: '1',
selectedStatus: SelectedStatus.Indeterminate,
children: [...]
},
]

实现:

/** 更新父选择状态 */
function updateParentSelectedType (parent: Cascade) {
const children = parent.children;
/* 是不是半选 */
let hasIndeterminate = false;

if (children?.length) {
const checkedList = children.filter((item) => {
if (item.selectedType === SelectedType.Indeterminate) {
hasIndeterminate = true;
return false;
}
return item.selectedType === SelectedType.Selected;
}
);

if (hasIndeterminate) {
parent.selectedType = SelectedType.Indeterminate;
} else if (checkedList.length === children.length) {
parent.selectedType = SelectedType.Selected;
} else if (!checkedList.length) {
parent.selectedType = SelectedType.Unselected;
} else {
parent.selectedType = SelectedType.Indeterminate;
}
}
return parent;
};

function updateSelectedStatus(
checked: boolean,
current: Cascade,
tree: Cascade[],
) {
const _f = (
checked: boolean,
current: Cascade,
tree: Cascade[],
parent?: Cascade
): Cascade[] => {
// 存放更新的路径
const records: Cascade[] = [];

for (const child of tree) {
if (child.id === current.id) {
child.selectedType = checked
? SelectedType.Selected
: SelectedType.Unselected;

records.push(child);

/** 更新子项 */
if (child.children?.length) {
child.children.forEach((item) => {
_f(checked, item, child.children, child);
});
}

/** 更新父项 */
if (parent) {
records.push(updateParentSelectedType(parent));
}

// 找到后就可以返回了,不用再循环去找了
return records;
} else {
if (child.children?.length) {
const list = _f(checked, current, child.children, child);

// list 中有值说明在下级找到了 current,否则就是没找到
if (parent && list.length) {
return [...list, updateParentSelectedType(parent)];
}
return list;
}
}
}

return records;
};

return _f(checked, current, tree);
}

最大高度与纵向滚动

浏览器渲染原理

· 阅读需 5 分钟

当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传给渲染主线程的消息队列。在时间循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。

整个渲染流程分为多个阶段:HTML解析、样式计算、布局、分层、绘制、分块、光栅化、画。

渲染进程中包含:渲染主线程和合成线程。

1. HTML 解析(Parse)

解析 HTML 字符串产生两棵树(对象结构):DOM Tree 和 CSSOM Tree。

解析过程中遇到CSS代码怎么办?

为了提高解析效率,浏览器会启动一个预解析线程先下载和解析CSS。

例如:当遇到 link 标签引用的CSS文件时,预解析线程会把CSS资源链接交给网络线程下载文件,下载完成后把CSS内容交给预解析线程解析CSS,解析完成后再交给渲染主线程最后生成CSSOM Tree。

因下载和解析CSS的工作是在预解析线程中进行的,因此CSS不会阻塞HTML解析。

解析过程中遇到JS代码怎么办?

预解析线程可以分担一点下载JS的任务,解析HTML时发现JS文件在下载中,这时会暂停解析HTML,等待JS下载完成,下载完成后执行JS代码,然后继续解析HTML。

JS阻塞HTML解析过程原因是:JS代码执行过程中有可能更改已经解析过的HTML。

如果是带有 defer 或 async 属性的 script 标签则规则略有不同,带有async属性的脚本在下载JS时不会阻塞解析HTML,一旦下载完成,渲染线程会暂停解析HTML,转而执行JS代码;而带有defer属性的脚本在下载时也不会阻塞解析HTML,在文档被解析后,但在触发 DOMContentLoaded 事件之前(会阻塞该事件的触发)执行该脚本。

2. 样式计算(Style)

CSS 属性值的计算过程发生在这一步。 比如层叠、继承。许多预设值会变成绝对值,比如red变成rgb(255,0,0);相对单位变成绝对单位,比如em变成px。

遍历DOM Tree和CSSOM Tree,计算出每个节点的最终样式(Computed Style)。

3. 布局(Layout)

根据上一步计算好的样式算出每个节点的尺寸和位置,生成Layout Tree。比如margin的合并、BFC规则、浮动规则等。计算出的位置和尺寸的值相对于 包含块

DOM树和Layout树不一定是一一对应的,比如:

  • DOM 树中存在display:none;的节点,而Layout Tree中会去除这样的节点;
  • DOM 树中不存在伪元素(如::before),而在Layout Tre中会被解析为一个节点;
  • 内容必须在行盒中,行盒和块盒不能相邻;

4. 分层(Layer)

对布局树进行分层的好处在于,将来某一层改变后,仅会对该层进行后续处理,从而提示效率。

滚动条、堆叠上下文、transform、opacity等样式都会或多或少影响分层效果,也可以通过will-change属性更大程度的影响分层结果。

5. 绘制(Paint)

主线程会为每个层单独绘制指令集,用于描述这一层的内容如何画出来。

6. 分块(Tiling)

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程(是在渲染主进程中)完成。

合成线程首先对每个图层进行分块,将其划分为更多的小区域。会从线程池中拿出多个线程来完成分块工作。

7. 光栅化(Raster)

合成线程会将分块信息交给GPU进程,以极高的速度完成光栅化。

GPU进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。

光栅化的结果就是一块快的位图。

8. 画(Draw)

合成线程拿到每个层、每个块的位图后,生成一个个的quad信息,然后把这些信息交给GPU进程,由GPU进程产生系统调用,交给GPU硬件处理,完成最终的屏幕成像。

quad信息会标识出每个位图应该画到屏幕哪个位置,以及会考虑旋转、缩放等变形。

变形发生在合成线程,与渲染主线程无关,这就是transform属性效率高的本质原因。

回流(Reflow)

比如更改了CSS尺寸或者位置,这时会触发第二步即样式计算,然后触发重新布局、分层等后续操作。

为了避免连续多次操作导致布局树反复计算,浏览器会合并这些操作。当JS代码全部完成后再进行统一计算。

但在JS中获取几何信息的属性(如clientWidth)时会立即reflow。

重绘(Repaint)

比如改动了背景颜色、字体颜色与几何信息无关的样式,repaint的本质是重新根据分层信息计算了绘制指令。

Layout 甚至 Layer步骤会被跳过。

Reflow 一定会导致 Repaint

为什么 transform 的效率高

因为 transform 既不影响布局也不影响绘制指令,它影响的只是渲染流程的最后一个 Draw 阶段。

由于 Draw 阶段在合成线程中,所以 transform 的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响 transform 的变化。

2024-11-16 小汇总

· 阅读需 7 分钟

1. navigator.onLine 可能不准确

最近有用户反馈页面数据加载异常,UI加载出来了,但是页面中的表格数据是空的,但是周围同学一个也复现不出来,看用户演示,切到那个页面时,页面并没有出现loading,看着像是没有调接口。打开用户控制台,发现确实没有调接口。

接着看接口调用逻辑,很直白的调用方式,怎么会不调接口!?排查陷入迷茫状态。晚上下班想了想觉得逻辑写的是没毛病的,难道是接口调用用的React-Query这个库有问题?带着疑惑,来到github上react-query的官方仓库。翻了翻issue,发现有一条可疑的:

https://github.com/TanStack/query/issues/5679

问题描述跟我遇到的情况很相似,看了下面的回复,似乎是Mac电脑的chrome存在的一个bug:

navigator.online

来到chromium的bug页面:navigator.onLine returns false even though there is internet connectivity,下面回复有用react-query时出现的问题。

react-query v4版本内部使用了navigator.onLine这个浏览器API,用于判断当前网络环境,默认情况下如果当前网络是断开的,则不会调用接口。

packages/query-core/src/queryClient.ts

那是不是这个问题引起的呢?目前没法排查出来,只能等有人再次反馈时把他的控制台打开,看看navigator.online的值是不是有问题。不过我可以先把这个问题修了,React-Query中有一个networkMode配置(默认值是online,有网络连接才会触发查询/突变),我将它配置成always,这样会忽略在线/离线状态,当网络异常时也会调接口。

幸运的是,在改动代码上线之前,组里有个同学也出了相同的问题,最后确认是navigator.online不对引起的。

2. svgr 更改图标大小问题

svgr是一个很好用的插件,它可以把SVG文件转成组件,在项目中直接引入使用。比如在React项目中,把svg文件直接引入作为icon图标使用:

import { ReactComponent as FolderOutlined } from '@/icons/folder-outlined.svg';

const App = () => {
return (
<div>
<FolderOutlined />
</div>
);
}

当我想给图标更改大小时,发现设置后的样式有问题。

<FolderOutlined width={24} height={24} />

检查元素看了对应的HTML,发现svg的viewBox没了。本来文件里的viewBox好好的,经过svgr后就没了。

当svg中的viewBox没有指定时,他会默认与viewport相同,viewport就是我们设置的widthheightviewport24*24的大小,viewBox将是0 0 24 24viewBox属性定义的是画布上可以显示的区域:从 (0,0) 点开始,24宽*24高的区域。也就是两者刚好1:1。svg元素里面的子元素pathrect等它们的坐标也会跟着缩放,1:1意味着坐标没发生变化,因此UI上绘制区域(原始的viewBox)如果很大就会展示不全,如果绘制区域(原始的viewBox)比viewport小,就会出现图标大小没变,svg整体区域(viewport区域)变大了。

如果原始的viewBox没被移除,就不会有这个问题,比如原始的viewBox0 0 12 12,viewport是24x24,这个12*12的区域,会放到24*24的画布上显示,形成放大两倍的效果。

后来也是看了svgr的官方仓库找到了解决办法,相关bug链接:Preserving viewBox #18

svgr

配置完成后,看到viewBox没有丢失,问题也就解决了。我项目中用的svgr是比较老的版本了,新版本应该没有这个问题了。

3. 其他一些小问题

  1. React 中是不支持给内联样式添加!important标记的,下面的写法将不生效。
<div style={{ fontSize: '20px !important' }}>
Hello
</div>

相关bug链接:Support !important for styles? #1881

  1. 在使用@testing-library/react这个库写一些UI测试用例时,如果我们的测试用例涉及到scrollHeight/clientHeight等DOM相关的API时,需要怎么测试?

因为jsdom不支持布局,像这样的测量值将始终返回0。答案是自己模拟数据。

相关bug:scrollHeight/clientHeight for an element is always zero #353

相关文档:Testing element dimensions without the browser

  1. 依赖报错问题

最近用了一个三方包A,A包里依赖了很多其他三方包,而这些其他三方包好多都依赖一个B包,有些三方包规定B包的版本需要是0.x,但问题是B包连正式版都还没有,只有beta版本。因为找不到B包的正式版本,下载依赖时总是会报错。但我们在项目中需要使用A这个包,他比较复杂且重要,目前卡在了安装环节。

我们是知道B这个包用最新的beta版本就可以的,那些第三方包是版本依赖书写的有问题。需求紧急,只好使用overrides这个配置了,它是将依赖项树中的包替换为其他版本或完全替换成其他包的方法。

如果你需要对依赖项的依赖项进行特定更改,例如将依赖项的版本替换为已知的安全版本,或者确保在任何地方都使用相同版本的包,则可以添加这个配置。

最后我们决定在package.json中增加这个配置,问题得以解决。

{
"overrides": {
"B": "0.0.1-beta.144"
},
}
  1. 快速找到组件对应代码

项目比较大,对一个没新接触的模块或者很久没参与过的模块改需求或者修复bug,先去找到这个模块在哪可能就比较麻烦。有没有什么办法可以快速定位到代码在那个文件夹里?

最原始的办法可能就是利用页面上的文案全局进行搜索,如果文案比较特别那还是很容易定位到的,如果文案都差不多,或者有些组件写的很像,一搜可能出现好多个结果,这就有点麻烦了,可能需要改点UI先试一下,看页面上有没有反应。

另一种办法是使用插件,chrome插件商店有一个LocatorJS插件,在开发环境下,可以快速定位到代码对应的文件。

LocatorJS

React项目为例,使用这个插件之前你最好先安装一下React-Dev-Tools这个浏览器插件,不然可能定位不出来。

安装完后,启动本地开发环境,Mac上使用Option+鼠标点击快捷键就可以定位组件了,Window则使用Alt+鼠标点击快捷键。

使用 ts-morph 库编辑TS文件

· 阅读需 6 分钟

时间如梭,停更快三个月了,时间过得太快了!

今天分享一个库:ts-morph。使用它可以读取和操作TS文件,并实时保存文件内容。这个库包装了 TypeScript 编译器 API,可以让我们方便的操作TS AST。

起因

接触到这个库的起因是这样的:不久前我下载了stable diffusion webui,玩了一段一时间的AI文生图。然后想把这些图片分享出来,比如创建一个网站。但是图片比较大,原版的png格式文件基本都在5MB以上,转成jpeg格式基本也要1MB以上。

如果网页上图片很多,而且还都是尺寸很大的图片,不仅加载慢,而且占内存。大部分网站都会把原始的图片压缩或者剪切生成空间小、尺寸小的图片,当用户要预览的时候再动态加载原始图片。

只是做一个很简单的分享网站,我采取的方案是使用JamStack相关框架,比如:DocusaurusVitePressAstro等。然后在public或者static文件夹下存放图片资源。

使用 sharp 这样的库读取、压缩图片。然后我需要生成一个配置文件,比如json文件,他内部存放的都是图片的信息,比如:hashwidthheightpathkeywordscategory等信息。

然后就是需要一个脚本,两种思路:

  1. 我需要读取这个json文件,然后看哪些图片还没处理,就去读取图片,生成完整的图片信息;
  2. 我去先读取文件,对比json文件中还没有的图片,然后给这些新的图片增加信息;

最后我采用了第一种办法,第一种办法更可控一些,但是无需要先把要使用的图片的基础信息编辑到配置文件里,比如它的文件路径、它的关键词、它的分类等等,不过这些还好(后续获取可以利用AI工具自动帮忙生成关键词)。

But,我没有使用json文件,现在想想获取使用json文件最省事🤔,我最终选用了TS文件,使用TS文件,我可以在前端直接引入(当然,用json也不是不行~)。

TS文件可不是想json一样,他不算是数据文件。在脚本里,json文件直接引入即可,处理完数据直接从新写入新的数据即可。

TS文件你怎么读取里面的数据变量?并且只是改这个数据变量的值?好像只有AST了(想想工作量就上来了😔),先读取文件,然后语法解析,更改值、生成新的语法树、转换成原始code,最后保存文件...

有没有可以简化的方案?当然有的,比如ts-morph。他使用起来就简单多了。只需要读取文件,然后操作AST、调用save,文件内容直接就变了(😊)。前提是我们需要了解一点AST的知识,比如这个网站:https://ts-ast-viewer.com/

Demo

举个例子,summary.ts我们待处理的文件,他的内容如下:

interface Person {
name: string,
age: number,
}

const members = [
{ name: 'John', age: 18 },
{ name: 'Mary', age: 20 }
];

我们想修改John的年龄改成24,需要怎么做?代码如下:

import { Project, SyntaxKind } from 'ts-morph';

const project = new Project();
const fileSource = project.addSourceFileAtPath('./summary.ts');
const variableDeclaration = fileSource.getVariableDeclarationOrThrow('members');
const children = variableDeclaration.getChildren();
const arrayLiteralExpressionNode = children.filter(child => child.getKind() === SyntaxKind.ArrayLiteralExpression);

for (let node of arrayLiteralExpressionNode) {
const arrayLiteralExpression = node.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);

const expressionList = arrayLiteralExpression.getElements();

for (let expression of expressionList) {
const objectLiteralExpression = expression.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const nameProperty = objectLiteralExpression.getPropertyOrThrow("name");
const ageProperty = objectLiteralExpression.getPropertyOrThrow("age");
const nameLiteral = nameProperty.getFirstChildByKind(SyntaxKind.StringLiteral);
const ageLiteral = ageProperty.getFirstChildByKind(SyntaxKind.NumericLiteral);

if (nameLiteral?.getLiteralValue() === 'John') {
if (ageLiteral) ageLiteral.setLiteralValue(24);
else objectLiteralExpression.addPropertyAssignment({
name: 'age',
initializer: `${24}`,
});

project.saveSync(); // 同步保存修改内容
}
}
}

我们把summary.ts的代码粘贴进去,就可以得到语法树了。

TS AST Viewer

解释一下上面代码的意思,首先使用ts-morph时,第一步要先创建一个项目(new Project),然后让项目里添加文件,就是addSourceFileAtPath,他会返回一个当前文件的SourceFile对象,这个对象提供了很多用于操作AST的API,其中getVariableDeclarationOrThrow就可以获取到文件中的某个变量声明,这个方法后带有OrThrow,表明如果获取不到就会停止执行代码,直接抛错。当然如果不叫OrThrow,则会返回VariableDeclaration | undefined类型,代码会接着往下走。

variableDeclaration 对应到语法树,就是这个位置:

接下来的认为就是沿着树结构往下找,一直找到StringLiteralNumericLiteral两个节点。

代码第五行getChildren,可以获取VariableDeclaration节点的子节点们,即:IdentifierArrayLiteralExpression,显然我们想要ArrayLiteralExpression。因此做一下过滤,把节点类型是ArrayLiteralExpression的给过滤出来。或者我们使用下面的方法也可以得到arrayLiteralExpression,这样就不用再做遍历了。

const arrayLiteralExpression = variableDeclaration.getFirstChildByKindOrThrow(SyntaxKind.ArrayLiteralExpression);

接着就是获取数组的元素列表,使用getElements()就可以拿到了,然后遍历列表,把元素类型定义为ObjectLiteralExpression,即是对象类型。

得到对象类型后,就是获取对象中的属性,使用getPropertyOrThrow拿到nameage属性结点:

然后还是使用getFirstChildByKind获取属性值节点即可,这个方法会返回各种类型的Literal对象。

使用getLiteralValue可以获取值,使用setLiteralValue设置值。当然如果没有Literal这个节点,或者没有Property这个节点,我们也可以使用addPropertyAssignment,添加一个新的属性。需要注意的是initializer参数需要是一个字符串,如果属性值是Number类型,我们需要传"4"即可,而如果是String类型,则需要多包一层字符串:"'Tom'"

了解了ts-morph的原理和使用,接下来就好办了,无非就是增删改查。再比如,我想往数组里添加新的一项,该怎么做?代码如下:

arrayLiteralExpression.addElement("{ name: 'Tom', age: 16 }");

直接调用ArrayLiteralExpression对象的addElement方法即可,而且是传字符串即可,它会自己解析。

项目配置

因为都是使用ts,用node跑程序,我需要下面几个包辅助:

  • ts-node、@types/node、typescript

然后还需要配置一下tsconfig.json

{
"compilerOptions": {
"target": "es2016",

/* Modules */
"module": "ESNext",
"moduleResolution": "node10",
"forceConsistentCasingInFileNames": true,

/* Type Checking */
"strict": true,
"skipLibCheck": true
}
}

然后就是package.json的配置,需要添加"type": "module",因为我们用的是ES Module模块。然后还需要配置脚本:

{
"type": "module",
"scripts": {
"parse": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts.ts"
}
}

2024-03-24 小汇总

· 阅读需 8 分钟

算法:合并公共范围

假如有一个数字类型的二维数组,数组中的每一项都是固定长度为 2 的子数组,代表范围,比如 [2, 5] 表示数字范围在 2~5 之间,并且包含 2 和 5。要求你设计一个函数,它接受这样格式的二维数组,合并有交集的范围(Range),最终返回新的二维数组,这个新二维数组的数字范围与原数组一样,且总体是按照从小到大排列的,只是有些重复的范围被合并了。

比如下面的一个二维数组,经过函数处理后最终得到新的二维数组为:[[1,5], [7,9]]

[ [3, 4], [1, 3], [7, 9], [2, 5] ]

已知条件:子数组的第一个数字比第二个要小(从小到大排列)。

2024-03-16 小汇总

· 阅读需 8 分钟

1. 文本自适应超出省略

单行文本超出省略时,通常需要指定一个固定的宽度,或者使用百分比宽度。比如下面的 div 元素,虽然没有指定宽度,但是他会继承父元素的宽度,超出出现省略符。

<div>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Et, ducimus eaque laborum nostrum sed cumque optio quisquam nihil soluta aliquid! Iste officiis delectus distinctio veniam? Recusandae consectetur sit pariatur saepe!
</div>

<style>
div {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

但有时候我们可能会遇到下面这种布局,父容器的宽度不是固定的,中间的文本内容会自动超出省略,而它两侧的内容可以正常展现。

中间文本超出省略

琐碎点汇总

· 阅读需 26 分钟
多云转晴
前端开发

在 Node 环境中使用 ESM

两种方式:

  1. 把模块文件的后缀改成 .mjs
  2. 给最近的上级 package.json 文件添加名为 type 的字段,并将字段值改成 module

渲染原理

· 阅读需 8 分钟

事件循环

事件循环

  1. 在最开始的时候,渲染主线程会进入一个无限循环。
  2. 每一次循环会检查消息队列中是否还有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有的线程(包括其他进程的线程)可以随时向消息队列中添加任务。新任务会加到消息队列的末尾,在添加新任务是,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务,这样一来,就可以让每个任务有条不紊、持续的进行下去。

用 Stream 编程

· 阅读需 19 分钟

缓冲模式和流模式

  • 缓冲模式(buffer mode),在这种模式下系统会把某份资源传来的所有数据,都先收集到一个缓冲区里,直到操作完成为止。然后,系统把这些数据当成一个模块回传给调用方。比如 fs.writeFilefs.readFile 等;

  • 流模式(stream mode),在流模式下,系统会把自己从资源端收到的每一块新数据都立刻传给消费方,让后者有机会立刻处理该数据。

哈夫曼编码 Node.js 简单实现

· 阅读需 14 分钟
多云转晴
前端开发

哈夫曼树

定义

  1. 给定 n 个权值作为 n 个叶子结点,构造一棵二叉树,若该树的带权路径长度(WPL)达到最小,称这样的二叉树为 最优二叉树,也称为 哈夫曼树

  2. 哈夫曼树是带权路径长度最短的树,权值较大的结点离根结点较近。

  3. 树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度(WPL,weighted path length)之和,权值越大的结点离根结点越近的二叉树才是最优二叉树。

  4. WPL 最小的就是哈夫曼树。

WPL计算过程