Reading view

There are new articles available, click to refresh the page.

真的不可以在 React 组件内部嵌套定义子组件吗?

最近在 Code Review 时,看到有同事写了这样的代码:

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useCallback((props) => {    return <li>{props.text}</li>;  }, []);  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

有经验的 React 开发者肯定一下子就看出问题了:在组件内部嵌套定义组件,会导致子组件每次都重新挂载。因为每次渲染时,创建的函数组件其实都是不同的对象。

但是他又有包了 useCallback 让引用保持一致,好像又没什么问题……?

这波骚操作让我突然有点拿不准了,所以今天咱们一起来验证一下,用 useMemo 或者 useCallback 包裹嵌套定义的子组件,对 React 渲染会有什么影响。以及如果有影响,应该如何用更合适的方法重构。

TL;DR

先说结论:

永远不要在 React 组件内部嵌套定义子组件。

如果你有类似的代码,请使用以下方法替代:

  1. 把子组件移到最外层去,将原有的依赖项作为 props 传入
  2. 把子组件改为「渲染函数」,通过调用函数插入 JSX 节点

为什么?请接着往下看。

组件重新挂载会造成的问题

来看这段代码(可以在 StackBlitz Demo 中运行):

function TodoList() {  const [list, setList] = useState([]);  // 嵌套定义子组件(好孩子不要学哦)  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 渲染刚才定义的子组件 */}      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

可能不少初学者都写出过类似的代码:JavaScript 语言可以嵌套定义函数,React 函数式组件就是函数,那 React 组件不也可以嵌套定义?

还真不是这么回事。我们来实际运行一下这段代码看看:

Tips: 这里使用了 useTilg 这个库来展示组件生命周期。

nested-component

可以看到,每次点击 Add 按钮在 <TodoList/> 列表中添加元素时,之前旧的 <TodoItem/> 组件实例就会被卸载 (unmount)、销毁。React 会创建全新的组件实例,然后再挂载 (mount) 上去。

也就是说,这些组件实例全都变成一次性的了

这还只是一个简单的示例,如果是实际应用场景,一个组件和它的子组件中,可能包含了成百上千个 DOM 节点。如果每次状态更新都会导致这些组件和对应的 DOM 节点被卸载、创建、重新挂载……那应用性能可就是「画面太美我不敢看」了。

更严重的是,组件的卸载还会导致其内部的状态全部丢失

那怎么会这样呢?这要从 React 的渲染机制,以及 Reconciliation 流程说起。

React 渲染机制之 Reconciliation

我们知道 React 的渲染大致可以分为两个阶段

  1. Render 阶段:执行组件的渲染方法,找出更新前后节点树的变化,计算需要执行的改动;
  2. Commit 阶段:已经知道了需要执行哪些改动,于是操作真实 DOM 完成节点的修改。

其中,「找出变化 + 计算改动」这个过程就被叫做 Reconciliation (协调)。React 的协调算法可以在保证效率的同时,最大程度复用已有的 DOM,使得对 DOM 做出的修改尽量小。

render-and-commit

▲ 图片来自:Render and Commit – React

那么问题来了,React 怎么知道一个组件对应的 DOM 需要更新呢?

简单来说,React 在比较两棵 Fiber 树时,会从根节点开始递归遍历所有节点:

  1. 如果节点类型和之前一致
    1. 对于 DOM 元素,保持元素实例不变,仅更新有变化的属性
    2. 对于组件元素,需要重渲染的,使用新属性调用组件渲染方法
  2. 如果节点类型有改变
    1. 卸载该节点及其子节点 ⚠️
    2. 将对应的 DOM 元素标记为待删除
    3. 创建新的节点
    4. 将新的 DOM 元素标记为待插入

上面所说的子组件被卸载再挂载、状态丢失等问题,其实都是因为它们被判断为了「节点类型有改变」

引用相等性与组件重渲染

在 JavaScript 中,比较值时有两种相等性:

  • 值相等性 (Value Equality),即两个值类型一致,内容也一致
  • 引用相等性 (Reference Equality),即两个对象的引用在内存中指向同一块区域

举个例子:

// 两个长得一样的对象const a = { name: 'Anon Tokyo' };const b = { name: 'Anon Tokyo' };// "引用相等性" 比较 - falseconsole.log(a === b);console.log(Object.is(a, b));// "值相等性" 比较 - trueconsole.log(lodash.isEqual(a, b));console.log(a.name === b.name);

JavaScript 函数也是对象,所以这对于函数(React 组件)也成立。

到这里问题就比较明朗了。

function TodoList() {  const [list, setList] = useState([]);  // WARN: 这个语句每次都会创建一个全新的 TodoItem 函数组件!  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

在这段代码中,每次 <TodoList/> 组件重渲染时(即 TodoList 函数被调用时),其内部创建的 TodoItem 都是一个全新的函数组件。

虽然它们长得一样,但它们的「引用相等性」是不成立的。

回到上一节介绍的渲染流程中,React 在比较节点的 type,使用的是 === 严格相等。也就是说像上面那样不同的函数引用,会被视作不同的组件类型。进而导致在触发重渲染时,该组件的节点及其子节点全部被卸载,内部的状态也被全部丢弃。

如果我用 useMemo 包一下呢

到这里我们已经介绍了「在组件内部嵌套定义组件」会造成问题的原理。

这时候可能就有小机灵鬼要问了,既然组件每次都被判断为是不同 type 的原因是对象引用不同,那我用 useMemo / useCallback Hooks,让它每次都返回相同的函数对象不就行了?

能考虑到这一层的都是爱动脑筋的同学,点个赞!让我们再来试验一下:

function TodoList() {  const [list, setList] = useState([]);  // useMemo 允许我们缓存一个值,每次重渲染时拿到的缓存是一样的  // 这里我们返回了一个函数组件,让 useMemo 把这个子组件的函数对象缓存下来  const TodoItem = useMemo(    () => (props) => {      return <li>{props.text}</li>;    },    []  );  // 或者用 useCallback 也可以,都一样  // const TodoItem = useCallback((props) => {  //   return <li>{props.text}</li>;  // }, []);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo

可以看到,包一层 useMemo 之后,子组件确实不会再被 unmount 了。看起来十分正常呢!

让我们再拿 React.memo 来包一下,在 props 相同时跳过不必要的重渲染:

const TodoItem = useMemo(  () =>    memo((props) => {      return <li>{props.text}</li>;    }),  []);

nested-usememo-memo

OHHHHHHHHH!!

如果一个东西看起来像鸭子,叫起来也像鸭子,那么它就是鸭子。

同理,如果我们通过一系列操作可以让「嵌套定义的 React 组件」在渲染时表现得与「在外层定义的组件」一致,那是不是就意味着这种操作其实也是 OK 的呢?

嗯……答案是:没那么简单。

稍微偏个题,你可能会好奇 Hooks 和 memo 为什么也可以在嵌套定义的子组件内正常使用,因为这看起来和我们平时的用法完全不同。

实际上不管是模块顶层定义的函数组件,还是嵌套定义的函数组件,在 React Reconciler 看来都是独立的组件类型,且在渲染时都有着自己的 Fiber 节点来储存状态,而定义该函数的作用域是什么并不重要。想想看:HOC 高阶组件有时候也会返回内联定义的函数组件,其实是一个道理。

useMemo 的缓存失效策略

第一点,useMemouseCallback 的缓存并非完全可靠。

在某些条件下,缓存的值会被 React 丢弃。如果缓存失效,函数组件就会被重新创建,同样会被判断为是不同的组件类型。React 官方肯定不会推荐你把 Hooks 用于这种歪门邪道的用途。

In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should be fine if you rely on useMemo solely as a performance optimization.

Ref: useMemo – React

第二点,useMemouseCallback 都有依赖数组。

虽然上面的示例里嵌套组件定义的依赖数组都是空的,但是我们再想想,什么情况下会想要在组件内部定义子组件,而非将其拆成一个单独的组件呢?最主要的原因就是,这个子组件想要直接访问父组件函数作用域中的某些变量。

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useMemo(    () =>      memo((props) => {        // 注意看,这里子组件直接使用了父级作用域中的 list 变量        return <li>{`${props.text} of ${list.length}`}</li>;      }),    [list.length]  );  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo-memo-deps

从实际测试中可以看到,有了依赖项的 useMemo + 嵌套组件,又退化成了最开始的样子,每次都会被当成不同的组件类型,每次都会被 unmount。之前所做的努力全部木大!(顺带一提用 useRef 也是一样的,有依赖就歇菜)

也就是说,只有你的嵌套子组件完全不依赖父组件作用域时,才能保证 useMemo 的缓存一直有效,才能做到完全不影响渲染性能。但既然都已经完全不依赖了,那么又还有什么理由一定要把它定义在父组件内部呢?

重构包含嵌套组件的代码

所以我再重复一遍开头的结论:永远不要在 React 组件内部嵌套定义子组件。

因为这在大部分情况下会造成渲染问题,即使对这种写法做优化也没有意义,因为一不留神就可能掉进坑里,还有可能会误导其他看到你的代码的人。

如果你的代码库中已经有了这样的 💩 代码,可以使用下面的方法重构。

第一种方法,把子组件移到最外层去。

这种方法适用于子组件依赖项不多的情况,如果有之前直接使用的父级作用域中的变量,可以将其改造为 props 传入的方式。

// 组件定义移到模块顶层const TodoItem = memo((props) => {  return <li>{`${props.text} of ${props.listLength}`}</li>;});function TodoList() {  const [list, setList] = useState(['Item 1']);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          // 改造后:从 props 传入原来的依赖项          <TodoItem key={index} text={item} listLength={list.length} />        ))}      </ul>    </div>  );}

第二种方法,把子组件改为渲染函数 (Render Function)。

JSX 的本质就是 React.createElement(type),React 节点的本质其实就是一个 JavaScript 对象。你在组件 return 语句中直接写 JSX,和定义一个函数返回 JSX 然后再调用这个函数,本质上是一样的。

function TodoList() {  const [list, setList] = useState([]);  // 这不是函数组件,只是一个「返回 JSX 的函数」(函数名首字母非大写)  // 所以每次渲染都重新创建也没问题,也可以直接访问作用域内的变量  const renderTodoItem = (key, text) => {    return <li key={key}>{`${text} of ${list.length}`}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 调用的时候也和调用普通函数一样,而非组件的标签形式 */}      <ul>{list.map((item, index) => renderTodoItem(index, item))}</ul>    </div>  );}

不过需要注意的是,在使用「渲染函数」时,一定要搞清楚和「函数组件」的区别:

  • 渲染函数虽然和组件一样都返回 JSX,但它不是组件;
  • 渲染函数就是普通 JavaScript 函数,没有状态,也没有对应的 Fiber 节点;
  • 渲染函数只是当前组件的一部分,对于 React 渲染来说没有额外开销;
  • 渲染函数内部不能使用 Hooks,只有组件内部才能使用 Hooks;
  • 渲染函数命名一般以 render 开头,首字母小写(否则容易和组件搞混)。

另外,当渲染函数作为 props 传入其他组件时,它也被叫做渲染属性 (Render Props)。这种设计模式在 React 生态中有着大量的应用,可以放心使用。

结语

最后聊一下,如何避免这类问题的发生。

第一,配置 Lint 规则。

防范于未然,合理的 Lint 配置可以减少起码 80% 的代码规范问题。比如本文介绍的坑,其实完全可以通过 react/no-unstable-nested-components + react-rfc/no-component-def-in-render 规则提前规避。

最好再配合代码提交后的 CI 卡点检查,有效避免因开发者环境配置不当或者偷摸跳过检查,导致规则形同虚设的情况。

第二,定期 Code Review。

代码腐化是难以避免的,但我们可以通过流程和规范提早暴露、纠正问题,减缓腐化的速度。Code Review 同时也是一个知识共享、学习和成长的过程,对于 reviewer 和 reviewee 来说都是。

没有人一开始就什么都会,大家都是在不断的学习中成长起来的。

第三,了解一些 React 的原理与内部实现。

因为我自己就是吃这碗饭的,之前写过 React 的 Custom Renderer,也做过渲染性能优化,所以底层原理看的比较多,自然也就知道什么样的代码对性能会有影响。

我一直以来秉持的观点就是,学习框架时也要学习它「引擎盖下」的东西,知其然且知其所以然。如果你希望在这条路上一直走下去,相信这一定会对你有所帮助。


扩展阅读:

详解 PixiJS Filter 中的参数与坐标系

除草啦除草啦,再不更新博客就要变成热带雨林啦!🌿

最近在给一个 PixiJS 程序编写 WebGL Shader,被各种参数和坐标系搞得晕头转向。痛定思痛,整理了一下 PixiJS Filter 系统中的各种概念,以供后续参阅。

在 WebGL 中,我们可以通过编写顶点着色器 (Vertex Shader) 和片元着色器 (Fragment Shader) 来实现各种各样的渲染效果。而在 PixiJS 中,渲染引擎为我们屏蔽了绝大多数的底层实现,通常情况下用户是不需要自己调用 WebGL API 的。如果有编写自定义着色器代码的需求,一般是使用 Filter 来实现。

PixiJS Filter 是什么

PIXI.Filter 其实就是一个 WebGL 着色程序,即一组顶点着色器 + 片元着色器的封装。和 Photoshop 中的滤镜功能类似,它接受一个纹理 (Texture) 输入,然后将处理后的内容输出到 Framebuffer 中。使用滤镜,可以实现模糊、扭曲、水波、烟雾等高级特效

用户只需要定义着色器的 GLSL 代码,传入对应的参数,剩下的工作就全部交给 PixiJS 完成。如果你对这些概念不太熟悉,可以看看:WebGL 着色器和 GLSL

默认的 Filter 着色器代码

在 PixiJS 中,Filter 自带了一组默认的顶点着色器和片元着色器代码。用户在定义 Filter 时,如果省略了其中一个,就会使用默认的着色器代码运行。

new Filter(undefined, fragShader, myUniforms); // default vertex shadernew Filter(vertShader, undefined, myUniforms); // default fragment shadernew Filter(undefined, undefined, myUniforms);  // both default

这是 Filter 默认的顶点着色器代码

attribute vec2 aVertexPosition;uniform mat3 projectionMatrix;varying vec2 vTextureCoord;uniform vec4 inputSize;uniform vec4 outputFrame;vec4 filterVertexPosition(void){    vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;    return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){    return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){    gl_Position = filterVertexPosition();    vTextureCoord = filterTextureCoord();}

这是默认的片元着色器代码

varying vec2 vTextureCoord;uniform sampler2D uSampler;void main(void){    gl_FragColor = texture2D(uSampler, vTextureCoord);}

看着一脸懵逼不要紧,下面我们逐一解释。

WebGL 知识回顾

先来复习一下 WebGL 基础知识

  • WebGL - 基于 OpenGL ES 的图形渲染 API,可根据代码绘制点、线和三角形
  • 着色器 (Shader) - 运行在 GPU 上的代码,一种将输入转换为输出的程序
  • GLSL - 专门用于编写着色器的语言,语法类似 C 语言
  • 顶点 (Vertex) - 一个 2D/3D 坐标的数据集合,表示空间中的一个点
  • 顶点着色器 (Vertex Shader) - 处理顶点数据,生成裁剪空间坐标值
  • 片元 (Fragment) - 也叫片段,包含了渲染一个像素所需的所有数据
  • 片元着色器 (Fragment Shader) - 计算当前光栅化的像素的颜色值
  • 参数 - 着色器中获取数据的方法,主要有:attribute、uniform、texture、varying

pipeline

▲ OpenGL 图形渲染管线的流程。插图来自 LearnOpenGL

不太熟悉的同学可能会以为 WebGL 是 3D 渲染 API,但其实 WebGL 本身只是一个光栅化引擎,并没有提供什么 3D 功能。如果想要实现 3D 渲染,你需要将模型中的三维坐标点转换为裁剪空间的二维坐标,并提供对应的颜色。WebGL 负责将这些图元绘制到屏幕上,仅此而已。

试想:任何 3D 模型,不管怎样旋转、缩放、平移,最终展示到你的屏幕上,都是 2D 的。同样,不管模型上有什么贴图、光照、阴影、反射,最终改变的其实都是这个像素的颜色值。

再来复习一下 WebGL 中的坐标系统:

  • 局部坐标 (Local Coordinate)
    • 或称模型坐标,对应局部空间
    • 一个物体中的点相对于该物体原点的局部坐标
  • 世界坐标 (World Coordinate)
    • 对应世界空间
    • 局部坐标相对于世界原点的坐标,把物体放到整个世界中去看
  • 观察坐标 (View Coordinate)
    • 对应观察空间
    • 从摄像机/人眼的角度去观察世界,所看到的物体相对于观察者的坐标
    • 同一个世界坐标,从不同的距离、角度、视野去观察,得到的观察坐标也不同
  • 裁剪坐标 (Clip Coordinate)
    • 对应裁剪空间
    • 将观察空间内超出一定范围的坐标点都裁剪掉,只保留一定范围内的坐标
    • 任何超过这个范围的点都不会显示在你的屏幕上
    • 从观察坐标转换为裁剪坐标的过程,称作投影变换 (Projection)
  • 标准化设备坐标 (Normalized Device Coordinate, NDC)
    • 将裁剪空间的坐标值范围映射到 [-1, 1] 范围之间,即为 NDC
    • 坐标 (0, 0) 位于裁剪空间的正中间,左下角为 (-1, -1),右上角为 (1, 1)
  • 屏幕坐标 (Screen Coordinate)
    • 对应屏幕空间
    • 将标准化设备坐标映射到屏幕坐标的过程,称做视口变换
  • 纹理坐标 (Texture Coordinates)
    • 即纹理图像上的坐标
    • 纹理坐标与像素坐标不同,无论纹理是什么尺寸,纹理坐标范围始终是 [0, 1]
    • 纹理图像的左下角坐标为 (0, 0),右上角坐标为 (1, 1)

coordinate-systems

▲ 各种坐标与变换矩阵的关系。插图来自 LearnOpenGL

Filter 中的几种坐标系

下面介绍 PixiJS Filter 中的坐标系,以及它们和 WebGL 坐标系之间的关系。

Input Coordinate

输入坐标,用于表示传入 FilterSystem 的纹理上的坐标。

Normalized Input Coordinate 是标准化之后的输入坐标,即纹理坐标,范围是 [0, 1]

Screen Coordinate

相对于 canvas 视口的屏幕坐标,单位是 CSS 像素。

CSS 像素乘以分辨率 resolution 即为屏幕物理像素。

Filter Coordinate

滤镜坐标,即被 Filter 所覆盖的范围内的局部坐标,单位是 CSS 像素。

Normalized Filter Coordinate 是标准化之后的滤镜坐标,滤镜覆盖范围的左上角坐标是 (0, 0),右下角坐标是 (1, 1)

Sprite Texture Coordinate

额外的图片纹理坐标。可以用于采样其他输入中的纹理。

Demo: DisplacementFilter

Sprite Atlas Coordinate

额外的精灵图集纹理坐标。

Filter 的输入参数

讲完坐标的种类,下面介绍 Filter 着色器代码中传入的各个参数(attributes、uniform、varying)分别代表什么,对应的是什么坐标系,以及它们的取值分别是多少。

⚠️ 以下参数适用于 PixiJS v7 版本,不排除后续版本中有变动的可能。

aVertexPosition

  • 类型:vec2 二维向量
  • 含义:Filter 所覆盖的范围内的标准化坐标
  • 坐标系:Normalized Filter Coordinate
  • 取值范围:0.0 ~ 1.0

假设有一个长宽为 300x300 的矩形 A,其原点左上角位于 (100, 30) 世界坐标处。则有:

  • aVertexPosition (0.0, 0.0) 对应矩形左上角
  • aVertexPosition (1.0, 1.0) 对应矩形右下角

projectionMatrix

  • 类型:mat3 3x3 矩阵
  • 含义:投影矩阵,用于将观察空间坐标变换到裁剪空间坐标

具体是怎么变换的,请阅读本文底部的「投影矩阵」章节。

inputSize

  • 类型:vec4 四维向量
  • 含义:输入 filter 的临时 framebuffer 大小
  • 坐标系:Input Coordinate
  • 取值范围:长宽 > 0,单位 CSS 像素

假设有一个大小为 512x512 的 framebuffer,则 inputSize 的值为:

vec4(512, 512, 0.0020, 0.0020)

其中,前两个分量 x, y 为像素大小,后两个分量 z, w 是像素大小的倒数。倒数可用于将除法转换为乘法,因为乘以一个数的倒数等于除以这个数。

以下两个表达式是等价的:

aVertexPosition * (outputFrame.zw * inputSize.zw);aVertexPosition * (outputFrame.zw / inputSize.xy);

但是在计算机中,乘法比除法的计算速度更快。所以在 GLSL 着色器这类需要高速运行的代码中,通常会尽量避免直接使用除法,而使用倒数乘法替代。

outputFrame

  • 类型:vec4 四维向量
  • 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小
  • 坐标系:Screen Coordinate
  • 取值范围:位置不限,长宽 > 0,单位 CSS 像素

还是以矩形 A 为例,其 outputFrame 取值为:

vec4(100, 30, 300, 300)

其中,前两个分量 x, y 为输出区域的位置,后两个分量 z, w 为输出区域的长宽。

vTextureCoord

  • 类型:vec4 四维向量
  • 含义:用于采样输入 filter 的临时 framebuffer 的纹理坐标
  • 坐标系:Normalized Input Coordinate
  • 取值范围:0.0 ~ 1.0

uSampler

  • 类型:sampler2D 纹理
  • 含义:输入 filter 的纹理图像,可配合 vTextureCoord 纹理坐标进行采样

inputPixel

  • 类型:vec4 四维向量
  • 含义:输入 filter 的临时 framebuffer 物理像素大小
  • 坐标系:Input Coordinate
  • 取值范围:长宽 > 0,单位物理像素

和 inputSize 类似,但是单位不是 CSS 像素,而是物理像素。以下两个表达式等价:

inputPixel.xyinputSize.xy * resolution

同样地,inputPixel.zwinputPixel.xy 的倒数,用于转换除法为乘法。

inputClamp

  • 类型:vec4 四维向量
  • 含义:用于将纹理坐标钳制 (clamp) 在 framebuffer 的有效范围内
  • 坐标系:Normalized Input Coordinate
  • 取值范围:0.0 ~ 1.0

有效范围指的是临时 framebuffer 中实际包含纹理图像的部分,其余部分可能是透明的(具体原因可阅读文章下方的注意事项)。使用示例:

vec4 color = texture2D(uSampler, clamp(modifiedTextureCoord, inputClamp.xy, inputClamp.zw));

其中,inputClamp.xy 表示有效范围的左上角,inputClamp.zw 表示有效范围的右下角。

resolution

  • 类型:float
  • 含义:分辨率,即 CSS 像素与物理像素的比率,类似 devicePixelRatio

filterArea (legacy)

  • 类型:vec4 四维向量
  • 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小

注意,filterArea 已经被标记为 legacy,你应该考虑使用其他参数替代。

// 以下语句等价于直接使用 filterArea uniformvec4 filterArea = vec4(inputSize.xy, outputFrame.xy)

filterClamp (legacy)

  • 类型:vec4 四维向量
  • 含义:兼容旧版本的 legacy uniform,与 inputClamp 等价

坐标系之间的转换

Filter 中的各种坐标系直接可以通过公式转换。

参考:v5 Creating filters · pixijs/pixijs Wiki

// Input Coord// 单位:标准化坐标vTextureCoord// Input Coord -> Filter Coord// 单位:标准化坐标 -> CSS 像素vTextureCoord * inputSize.xy// Filter Coord -> Screen Coord// 单位:CSS 像素vTextureCoord * inputSize.xy + outputFrame.xy// Filter Coord -> Normalized Filter Coord// 单位:CSS 像素 -> 标准化坐标vTextureCoord * inputSize.xy / outputFrame.zw// Input Coord -> Physical Filter Coord// 单位:标准化坐标 -> 物理像素vTextureCoord * inputPixel.xy// Filter Coord -> Physical Filter Coord// 单位:CSS 像素 -> 物理像素vTextureCoord * inputSize.xy * resolution // 与上一条语句等价

注意事项

需要注意的是,在应用 Filter 之前,PixiJS 的 FilterSystem 会先把目标的 DisplayObject 渲染到一个临时的 Framebuffer 中。

这个 framebuffer 的大小不一定等于 DisplayObject 的大小,它是一个二次幂纹理 (Power-of-two Texture)。假如你有一个 300x300 的图片要应用滤镜,那么 PixiJS 会将其渲染到一个 512x512 尺寸的 framebuffer 上,然后将这个 framebuffer 作为输入传递给着色器代码。

根据这个 DisplayObject 上的 filters 属性定义,PixiJS 会依次执行数组中的 filter,前一个的输出作为后一个的输入,最后一个输出的将渲染到最终的 render target 中。

不过这个创建临时 framebuffer 的行为可能会在自定义着色器代码中导致一些问题,比如纹理坐标的偏移,有时间后续我会另外发文章讨论。

spector-js

▲ 通过 Spector.js 抓取到的 PixiJS 渲染过程

回顾默认着色器代码

好了,梳理完各种坐标系和参数后,我们再来回头看看上面的默认着色器代码:

// 标准化的「滤镜坐标」,范围是 filter 所覆盖的矩形区域attribute vec2 aVertexPosition;// 投影矩阵uniform mat3 projectionMatrix;// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 大小uniform vec4 inputSize;// filter 所覆盖的区域在屏幕坐标中的位置与大小uniform vec4 outputFrame;vec4 filterVertexPosition(void){    // position 算出来的是 filter 所覆盖的区域的屏幕坐标    vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;    // 通过投影矩阵,将屏幕坐标转换为裁剪空间 NDC 坐标    return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){    // 等价于 aVertexPosition * (outputFrame.zw / inputSize.xy)    // 也就是将「滤镜坐标」从 outputFrame 的范围缩放到 inputSize 的范围    // 计算出来的就是 inputSize 范围内的「纹理坐标」    return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){    // 裁剪空间 NDC 坐标传递给 WebGL    gl_Position = filterVertexPosition();    // 纹理坐标传递给片元着色器    vTextureCoord = filterTextureCoord();}
// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 纹理uniform sampler2D uSampler;void main(void){    // 使用纹理坐标在传入的纹理上采样得到颜色值,传递给 WebGL    gl_FragColor = texture2D(uSampler, vTextureCoord);}

怎么样,是不是感觉清晰了很多呢?

Bonus: 投影矩阵

如果你很好奇上面的投影矩阵是怎么做到乘一下就能把屏幕坐标转换为裁剪空间坐标的,那么这一小节就可以解答你的疑惑。

🤫 偷偷告诉你,CSS 中的 transform: matrix() 也是用了同样的矩阵变换原理。

投影矩阵的默认计算方式如下,代码来自 ProjectionSystem#calculateProjection

// 矩阵表示:// | a | c | tx|// | b | d | ty|// | 0 | 0 | 1 |// 数组表示:// [a, b, 0, c, d, 0, tx, ty, 1]//// 主要参数:// sourceFrame - Filter 所覆盖的区域的世界坐标,长、宽、X、Y,像素单位// root - 控制 Y 轴反转,当渲染到 framebuffer 时投影为 y-flippedcalculateProjection(){    const pm = this.projectionMatrix;    const sign = !root ? 1 : -1;    pm.identity();    pm.a = (1 / sourceFrame.width * 2);    pm.d = sign * (1 / sourceFrame.height * 2);    pm.tx = -1 - (sourceFrame.x * pm.a);    pm.ty = -sign - (sourceFrame.y * pm.d);}

这个投影矩阵主要做了两件事:

  1. 缩放变换,从像素坐标转换到 0.x ~ 2.x 范围
  2. 平移变换,将坐标转换为 -1.0 ~ 1.0 范围内的标准坐标

对于一个长宽为 300x300,原点左上角位于 (100, 30) 世界坐标处的矩形,可得:

  • sourceFrame.width = 300
  • sourceFrame.height = 300
  • sourceFrame.x = 100
  • sourceFrame.y = 30
  • sign = 1 (此处假设为渲染至 framebuffer)

计算出投影矩阵为:

使用矩阵乘法对世界坐标进行变换:

得到如下坐标:

  • 局部坐标:(0, 0) ~ (300, 300)
  • 世界坐标:(100, 30) ~ (400, 330)
  • 缩放变换:(0.67, 0.20) ~ (2.68, 2.21)
  • 平移变换:(-1.0, -1.0) ~ (1.0, 1.0)

即可将世界坐标转换为裁剪空间的标准化设备坐标。数学,很神奇吧!👊

更多关于矩阵变换的资料可参考:


顺便测试一下我的新插件 hexo-filter-tikzjax,别在意~

使用 ESLint + Prettier + Commitlint 规范代码风格与提交流程

最近因为课程需要开了几个多人协作的新项目,感觉有必要在团队中强制一下代码规范,免得提交上来的东西对 leader 血压不好。前后端都是 TypeScript 的,所以就用流行的 ESLint + Prettier 组合拳(配合 Standard 规范),EditorConfig 同步编辑器配置,再加上 commitlint 规范提交信息,最后用 Git Hooks 实现自动化检查。

配置虽然不难,但还是有点繁琐的,所以记录一下,如果忘了下次可以翻回来看。

简介

首先来介绍一下这些工具都是啥吧。

ESLint 是一个插件化并且可配置的 JavaScript 语法规则和代码风格的检查工具,能够帮你轻松写出高质量的 JavaScript 代码。

简单来说就是可以静态分析代码中的问题(包括语法问题和代码风格问题,比如未使用的变量,if 和括号之间没有空格),给出提示,并且能够自动修复。

Prettier 是一个“有态度” (opinionated) 的代码格式化工具。它支持大量编程语言,已集成到大多数编辑器中,且几乎不需要设置参数。

什么叫 opinionated?不同于 ESLint 什么都让你配置,Prettier 是一种严格规范的代码风格工具,主张尽量降低可配置性,严格规定组织代码的方式。

Prettier 只提供少量配置项,这么做的原因很简单:既然为停止争论而生,那么为何还需要设置更多选项让人们继续纠结于如何配置呢?

规矩就是这样,不服憋着。

检查你的 Git 提交信息是否符合配置的格式要求。

相信大家或多或少都见过某些人奔放不羁的 commit message,不仅给项目管理带来困难,看着也挺难受的。使用 commitlint 可以实现在提交前检查提交信息是否符合规范,配合 commitzen 食用更佳。


看到这里你可能有些疑问,ESLint 可以自动修复代码风格问题,Prettier 也可以格式化代码,那它们两个不会打架吗?没错,确实会有冲突的情况,而这也是我们后面要解决的。

既然会冲突,那为什么要同时使用它们呢?主要有这几个原因:

  • Prettier 的代码格式化能力更强。它的工作原理是把代码解析成 AST,然后根据规则重新输出,等于帮你整个儿重写了一遍代码。ESLint 的 --fix 自动修复虽然也可以实现一定程度的代码格式化,但没有 Prettier 效果好。
  • Prettier 支持的文件格式更多,比如 HTML、CSS、JSON、Markdown 等等。

当然,如果 ESLint 对你来说已经够用,那么不加入 Prettier 其实也是完全没问题的。

ESLint

这里我们采用 Standard 规范。

以 Vite 新建的 Vue 3 + TS 白板项目为例:

pnpm add -D \    @typescript-eslint/eslint-plugin \    @typescript-eslint/parser \    eslint \    eslint-config-standard-with-typescript \    eslint-plugin-import \    eslint-plugin-node \    eslint-plugin-promise \    eslint-plugin-vue

如果你的项目不使用 TypeScript,可以把 eslint-config-standard-with-typescript 规则替换为 eslint-config-standard

"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix"

.eslintrc.js

module.exports = {  parser: 'vue-eslint-parser',  parserOptions: {    parser: '@typescript-eslint/parser',    project: './tsconfig.json',    extraFileExtensions: ['.vue']  },  extends: [    'plugin:@typescript-eslint/recommended',    'standard-with-typescript',    'plugin:vue/vue3-recommended'  ],  root: true,  env: {    node: true  },  rules: {    'vue/script-setup-uses-vars': 'error',    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',    '@typescript-eslint/explicit-function-return-type': 'off',    '@typescript-eslint/explicit-module-boundary-types': 'off',    '@typescript-eslint/no-explicit-any': 'off',    '@typescript-eslint/strict-boolean-expressions': 'off'  },  globals: {    defineProps: 'readonly',    defineEmits: 'readonly',    defineExpose: 'readonly',    withDefaults: 'readonly'  }}

tsconfig.json

{  "compilerOptions": {    "target": "esnext",    "useDefineForClassFields": true,    "module": "esnext",    "moduleResolution": "node",    "strict": true,    "jsx": "preserve",    "sourceMap": true,    "resolveJsonModule": true,    "esModuleInterop": true,    "lib": ["esnext", "dom"]  },  "include": [    "src/**/*",    ".eslintrc.js",    "vite.config.ts"  ]}

Prettier

需要注意的是,Prettier 和 Standard 规范并不完全兼容。

pnpm add -D \    prettier \    eslint-config-prettier \    eslint-plugin-prettier

.eslintrc.js

 extends: [   'plugin:@typescript-eslint/recommended',   'standard-with-typescript',   'plugin:vue/vue3-recommended',+  'plugin:prettier/recommended' ],

.prettierrc

{  "trailingComma": "none",  "semi": false,  "singleQuote": true}
"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix","format": "prettier --write \"src/**/*.{vue,ts,js}\""

EditorConfig

自古以来,Tab or Space 就是不曾停歇的圣战。

不同成员都有不同的喜好,使用的编辑器/IDE 也不尽相同。那么为了 codebase 的规范,在所有项目成员中使用一个统一的配置是很有必要的。

root = true[*]charset = utf-8indent_style = spaceindent_size = 2end_of_line = lfinsert_final_newline = truetrim_trailing_whitespace = true[*.{js,jsx,ts,tsx,vue}]indent_style = spaceindent_size = 2trim_trailing_whitespace = trueinsert_final_newline = true

Commitlint

比如很多项目都采用的 Conventional Commits 就要求提交信息必须符合以下规范:

<type>[optional scope]: <description>[optional body][optional footer(s)]

为什么要使用 Conventional Commits?

  • 自动生成 CHANGELOG
  • 基于提交类型生成语义化版本号
  • 项目提交历史更清晰

Git Hooks

【鸽了】

反正就是让上面那些工具可以在 Git 提交时自动执行,检查不通过的就打回。

最后

还记得很久以前别人给我发了个 Pull Request,我一看,发现有好多地方的代码风格都和我不一样,比如单双引号、分号的使用,还有我最不能忍的 if(xxx){} 之间不加空格……

但我想想再叫人家改也怪麻烦的,就默默接受了 PR,然后再默默改成自己的代码风格……

现在有了这些东西工作流程就规范多了:

  • 你乱写也行,我直接给你格式化掉;
  • 语法检查,在编写过程中就排除潜在的 BUG;
  • 提交上来的代码必须通过以上验证,不然就拒绝;
  • 提交信息也要规范,不能瞎写乱写。

当然了,规矩是死的人是活的,这一套下来也没法保证一定万无一失。不过相比以前群魔乱舞的场面,已经省心了不少。

不过说实在话,比起配置这些工具,推行一个大家都能接受的规范才更难吧(x)

博客主题可以自动切换深色模式啦

有时候我也很佩服自己,这么简单的一个功能,写写也就几个小时,一年多前就想搞了,竟然给我拖到现在才装上去。拖延症,恐怖如斯!

以前我对深色模式其实不怎么感冒,主要感觉开了也没啥用,就系统界面变黑了,其他 App 里还是白色的,等于没开。不过这几年大部分应用的适配都跟上来了,体验也就好起来了,晚上玩手机看着不那么刺眼,挺好的。

现在浏览器网页也支持检测用户的系统主题色,所以我也凑个热闹,给博客加上了自动切换浅色/深色主题的功能。适配过程还是挺顺利的,记录一下供参考。

原理

就是使用 CSS 的 prefers-color-scheme 媒体查询。

@media (prefers-color-scheme: dark) {  /* dark theme styles go here */}

参考文档:prefers-color-scheme - CSS | MDN

不过需要注意的是,不支持 IE

使用方法

最简单的例子:

body {  background-color: white;  color: black;}@media (prefers-color-scheme: dark) {  body {    background-color: black;    color: white;  }}

这样在亮色模式下是白底黑字,在暗色模式下就是黑底白字。

依样画葫芦,给原主题中颜色相关的 CSS 加上对应的深色样式就差不多了。

使用 mixin 处理颜色

拿我自己写的这个主题举例,在主题中我们一般会用到很多颜色。一个常见的做法就是使用 CSS 预处理器,把这些颜色定义成变量方便后续使用(我用的是 Stylus):

$color-primary        = convert(hexo-config('primary_color'));$color-background     = #fff;$color-text           = #333;$color-text-secondary = #999;

同样,定义这些颜色的深色版本:

$color-primary-dark        = convert(hexo-config('primary_color_dark'));$color-background-dark     = #181a1b;$color-text-dark           = #c8c3bc;$color-text-secondary-dark = #a8a095;

引用之:

body {  background-color: $color-background;  color: $color-text;}a {  color: $color-primary;}@media (prefers-color-scheme: dark) {  body {    background-color: $color-background-dark;    color: $color-text-dark;  }  a {    color: $color-primary-dark;  }}

然而问题来了,这样岂不是要写很多媒体查询语句?麻烦且不说,看着都眼花。如果把不同地方的这些语句集中起来,放在一起,又会破坏模块设计,也不利于后续维护。

想要写得简洁一点,不妨利用 CSS 预处理器的 mixin 特性

定义 mixin(可以理解为可重用的代码片段):

// 根据传入参数拼装变量名color-themed(name) {  color: lookup('$color-' + name);  @media (prefers-color-scheme: dark) {    color: lookup('$color-' + name + '-dark');  }}

这个 mixin 的意思就是我们传一个名称进去,它会根据这个名称去查找对应的颜色变量及其深色版本,然后一起应用。

如此一来,上面的样式就可以简化为:

body {  background-color-themed: 'background';  color-themed: 'text';}a {  color-themed: 'primary';}

使用 CSS 变量处理颜色

用上面那种方法,比原来的是好了不少,但感觉不太直观。

另一种方法,就是用 CSS 原生的变量机制来处理颜色。定义变量:

:root {  --color-primary: #7065a3;  --color-background: #fff;  --color-text: #333;  --color-text-secondary: #999;}@media (prefers-color-scheme: dark) {  :root {    --color-primary: #bb86fc;    --color-background: #181a1b;    --color-text: #c8c3bc;    --color-text-secondary: #a8a095;  }}

使用:

body {  background-color: var(--color-background);  color: var(--color-text);}a {  color: var(--color-primary);}

是不是清爽了很多呢?

不过遗憾的是,IE 浏览器不支持 CSS 变量。(又是你!!!🙃

所以为了兼容性我还是选了预处理器 + mixin 的方法,这样在 IE 上虽然不能自动切换,但至少能保证默认的浅色主题是可以正常显示的。而如果全部使用 CSS 变量的话,在不支持的浏览器上就啥都没有了,得考虑 polyfill 和 fallback,还是算了。

如果不用考虑兼容旧浏览器的话,CSS 变量是最佳选择。

加载外部样式

使用 link 标签加载的外部 CSS 也可以指定媒体查询

比如本主题使用的 highlight.js 代码高亮的样式:

<link rel="stylesheet" href="atom-one-dark.min.css" media="screen and (prefers-color-scheme: dark)"><link rel="stylesheet" href="atom-one-light.min.css" media="screen and (prefers-color-scheme: light)">

这样在浅色模式下会加载 light 样式,在深色模式下会加载 dark 样式。

参考

另外,关于深色模式下的图片要如何处理,其实也是需要考虑的。

不过我懒,就直接不管了。更详细的相关内容可以参考:

最后是自动切换的效果图(视频):

React 入门:实现基本登录注册功能

前端老年人复健篇,堂堂连载!

两年多没碰前端,感觉自己已经完全与时代脱节了。作为一个从 jQuery 一把梭时代过来的选手,对于现在那些「大前端」的玩意儿基本都是只懂个皮毛的状态(指看过 Hello world)。四年前我引用过这么一篇文章,《在 2016 年学 JavaScript 是一种什么样的体验?》,放到现在要是出个 2021 版本,估计会更唬人吧。

不过嘛,主要的技术其实还是那么几个:

  • 前端工程化(模块化、组件化、自动化等)
  • TypeScript(支持静态类型检查的 JavaScript)
  • ES6+(箭头函数、模板字符串、async/await 等新语法)
  • 三大框架(Angular、React、Vue)
  • 跨端技术(Electron、React Native、Flutter、小程序)

对于初学者来说可能是有些复杂,但应该还没到「求不要更新了,老子学不动了」的程度。

举个例子,最开始,大家都用原生 HTML、CSS、JavaScript 三板斧来开发网页。后来有人觉得原生的那些方法操作 DOM 啥的太麻烦了,于是弄出了 jQuery 和大家喜闻乐见的 $('#id');觉得代码组织不方便,于是弄出了各种模块化解决方案;觉得 Callback Hell 太傻X了,于是有了 Promise 和 async/await。觉得 JS 没有类型系统太容易出 BUG,于是有了 TS;觉得手动监听事件、操作 DOM 太麻烦了,于是有了数据绑定和三大框架;因为上面这些乱七八糟的最后都得拿去浏览器跑,所以有了 Babel、Webpack 和各种工具链;还有人觉得前端技术这么牛逼,光写网页怎么够,于是有了各种大前端、跨端开发技术……

如此种种,其实没有哪个技术是凭空蹦出来的,都是有需求才有人去做。如果你在实际开发中体会到了这个需求,那么这些新技术的出现也就变得理所当然了。而这也是我推荐新人学一门编程语言不要从框架学起的原因:比如从 Vue 开始学前端,从 Laravel 开始学 PHP,从 Spring 开始学 Java,很容易知其然而不知其所以然。

好了废话就到这里,今天咱们来用 React 实现一个基本的登录注册系统。


【后面的内容鸽了】

❌