Skip to content

每日一报

在开源项目中使用AI的思考

参考:https://roe.dev/blog/using-ai-in-open-source

  1. 不要让 LLM 为你讲话

    如果我阅读了您的评论、邮件、issue 或 PR,我想知道这是您的话。我不在乎语法或拼写——对此我不会对您评头论足。我更关心真正的关系点。

    AI 生成的 PR 通常是冗长且密集(且不准确!),简单理解就是废话多而不凸显核心观念。简单就是一种艺术,沟通的目标并非是令人“印象深刻”,而是清晰表达。

  2. 不要让 LLM 为你思考

    继续前进 - 将代码库放入LLM中或让它编写您需要的函数或测试。但在将AI生成的代码贡献给开源项目之前,最后一步应始是要审查代码的内容。

    使用计算机辅助工具(未成熟的AI)指引你朝正确方向发展,但始终要承担个人责任。我们不能将AI生成的内容当作规范或所谓的标准答案,我们始终需要对 AI 所生成的内容持有怀疑态度,需要进行审查。审查时不能总以“我不确定这是否有帮助,但ChatGPT说…”来逃避责任,我不想在 PR 或 issue 中看到 LLM 的引用 - 我想知道你的想法。

PostCss

参考:https://postcss.org/docs/postcss-architecture#overview

  1. 不是像 Sass 或 Less 那样的样式预处理器

    PostCSS 自身不自定义语法和语义且实际上并非是一种语言。PostCSS 与 CSS 一起工作,并可以轻松集成到上述工具中。也就是说,任何有效的 CSS 都可以由 PostCSS 处理。

  2. 是用于 CSS 语法转换工具

    它允许您定义类似于自定义 CSS 的语法,这些语法可被插件理解和转换。也就是说,PostCSS并非严格遵循CSS规范,而是关于定义 CSS 语法方式。通过这种方式,您可以定义诸如 at-rule 之类的自定义语法结构,在围绕 PostCSS 构建的工具中可能会非常有帮助。PostCSS扮演着构建出色的用于 CSS 操作工具框架角色。

  3. 在 CSS 生态系统中占据重要地位

    大量优秀的工具(如Autoprefixer、Stylelint、Cssnano)都基于 Postcss 生态系统构建而而成。很有可能你已经在使用它了,可以从 node_modules 中悉知。

WorkFlow

这是整个PostCSS工作流程的高级概述

正如您可以从上面的图表中看到的那样,PostCSS架构非常简单明了,但其中的一些部分可能会被误解。

您可以看到一个名为Parser的部分,这个结构将在稍后详细描述,现在只需将其视为一个能够理解您的CSS类似语法并创建其对象表示形式的结构即可。

话虽如此,编写 parser 有几种方法。

  1. 使用字符串到 AST 转换的单个文件

    这种方法非常流行,例如,Rework 分析器 就是以这种风格编写的。但是在大型代码库中,代码变得难以阅读且速度较慢。

  2. 将整个过程分为词法分析和语法解析两个步骤(源字符串 → tokensAST

    1. 词法分析(Lexical Analysis): 将源代码字符串转换为标记(tokens)
    2. 语法解析(Parsing): 将标记(tokens)转换为抽象语法树(AST)

    这种方法在 PostCSS 中使用,也是目前最流行的做法。许多解析器, 如 Babel 的 [@babel/parser]((https://github.com/babel/babel/tree/master/packages/babel-parser) 和 CSSTree,都采用这种方式。

    将解析流程分为两步骤的的主要原因是:

    1. 性能优化:
      • 从字符串到标记(tokens)的转换是比较耗时的,需要逐字符处理大量源代码。
      • 将这一步骤独立出来,只需执行一次,可以提高整体性能。
    2. 抽象复杂性:
      • 词法分析可以编写得非常快速(虽然代码可能较难阅读)。
      • 语法解析逻辑上更复杂,但可以写得更易读(尽管速度较慢)。

    通过这种拆分,我们可以在性能和代码可读性之间取得平衡。词法分析保证速度,语法解析保证可读性。

    总的来说,这种方法既提高了性能,又改善了代码的可读性,是一种在 PostCSS 等工具中广泛采用的有效策略。

接下来,文章提到将详细介绍在 PostCSS workflow 中起主要作用的几个核心结构:

  1. 标记生成器(Tokenizer) - lib/tokenize.js

    • 也称为词法分析器(Lexer)
    • 接收CSS字符串,返回标记(tokens)列表
    • 每个标记描述语法的一部分,如 at-rule, commentword
    • 标记可包含位置信息,便于生成更详细的错误信息
    • 标记以列表形式表示,包含类型、内容、开始位置、结束位置等信息
    • PostCSS的标记生成器优化了性能,代码可能看起来较复杂
    • PostCSS 的 Tokenizer 使用一种流/链接 API,在其中向 Parser 公开 nextToken() 方法。 通过这种方式,我们为 Parser 提供了清晰的接口,并通过仅存储少量标记而不是整个标记列表来减少内存使用量。

    例子:

    css
    .className {
      color: #fff;
    }

    对应的PostCSS解析后的tokens如下

    json
    [
        ["word", ".className", 1, 1, 1, 10]
        ["space", " "]
        ["{", "{", 1, 12]
        ["space", " "]
        ["word", "color", 1, 14, 1, 18]
        [":", ":", 1, 19]
        ["space", " "]
        ["word", "#FFF" , 1, 21, 1, 23]
        [";", ";", 1, 24]
        ["space", " "]
        ["}", "}", 1, 26]
    ]

    正如上述示例所见,一个 token 表示一个列表并且 space token 没有位置信息。

    让我们更仔细地看一下像 word 这样的单个 token。就像所说的那样,每个标记都表示为一个列表,并遵循这种模式。

    js
    const token = [
      // represents token type
      'word',
    
      // represents matched word
      '.className',
    
      // This two numbers represent start position of token.
      // It is optional value as we saw in the example above,
      // tokens like `space` don't have such information.
    
      // Here the first number is line number and the second one is corresponding column.
      1,
      1,
    
      // Next two numbers also optional and represent end position for multichar tokens like this one. Numbers follow same rule as was described above
      1,
      10
    ];
  2. 解析器(Parser) - lib/parse.jslib/parser.js

    • 负责对输入的CSS进行语法分析
    • 生成抽象语法树(AST)
    • 与标记生成器协同工作,处理标记而非源字符串
    • 主要使用标记生成器提供的 nextTokenback 方法
    • 构建AST的各个部分,称为"节点"(Node)
  3. 处理器(Processor) - lib/processor.js

    • 初始化插件并运行语法转换
    • 提供少量公共API方法
  4. 字符串化器(Stringifier) - lib/stringify.jslib/stringifier.js

    • 将修改后的 AST 转换回纯 CSS 字符串
    • 从提供的节点开始遍历 AST,生成原始字符串表示
    • 核心方法是 Stringifier.stringify,接受初始节点和分号指示符

这些结构共同工作,完成CSS的解析、转换和生成过程:

  1. 标记生成器将CSS字符串转换为标记。
  2. 解析器使用这些标记构建AST。
  3. 处理器应用插件对AST进行转换。
  4. 字符串化器将修改后的AST转换回CSS字符串。

这种结构设计使得PostCSS能够高效地处理CSS,同时保持良好的可扩展性和可维护性。每个组件都专注于特定任务,使得整个系统更加模块化和灵活。

React.createElement的性能优化和简化

提案概述

这个提案旨在简化React.createElement的工作方式,并最终消除对forwardRef的需求。虽然听起来可能令人担忧,但对大多数开发者来说,升级路径应该相对简单,因为被弃用的功能主要是一些边缘情况,大部分情况都可以通过代码修改工具(codemod)自动处理。

主要变更

  • 1. 弃用"模块模式"组件

    js
    const Foo = props => {
      return {
        onClick() {
          // ...
        },
        render() {
          return <div onClick={this.onClick.bind(this)} />;
        }
      };
    };

    新方式:

    js
    function Foo(props) {
      const onClick = () => {
        // ...
      };
      return <div onClick={onClick} />;
    }
  • 2. 弃用函数组件上的 defaultProps

    js
    function Foo(props) {
      return <div>{props.name}</div>;
    }
    Foo.defaultProps = { name: 'Guest' };

    新方式:

    js
    function Foo({ name = 'Guest' }) {
      return <div>{name}</div>;
    }
  • 3. 弃用从对象中展开 key 属性

    js
    const props = { key: 'unique', className: 'button' };
    return <div {...props} />;

    新方式:

    js
    const { key, ...restProps } = props;
    return <div key={key} {...restProps} />;
  • 4. 弃用字符串 ref(并移除生产模式的 _owner 字段)

    jsx
    <input ref="myInput" />

    新方式:

    jsx
    <input ref={el => (this.myInput = el)} />;
    
    // 或在类组件中使用 React.createRef();
    this.myInput = React.createRef();
    <input ref={this.myInput} />;
    
    // 或在函数组件中使用 React.useRef();
    const ref = React.useRef();
    <input ref={ref} />;
  • 5. 将 ref 提取移至类渲染时间和 forwardRef 渲染时间

    这个变化主要是内部实现的改变,但可能会影响一些边缘情况。例如,在类组件中:

    js
    class MyComponent extends React.Component {
      static defaultProps = { name: 'Guest' };
    
      render() {
        // 在新的实现中,this.props.name 的解析会在这里进行,而不是在createElement时
        return <div>{this.props.name}</div>;
      }
    }
  • 6. 将 defaultProps 解析移至类渲染时间

  • 7. 更改 JSX 编译过程,使用新的元素创建方法

    • 始终将 children 作为 props 传递。

    • key 与其他 props 分开传递。

    • 在开发环境中

      • 传递一个标志以确定是否为静态内容。
      • __source__self 与其他 props 分开传递。
      js
      React.createElement('div', { className: 'example' }, 'Hello', 'World');

      新方式:

      js
      // react
      export const jsx = (type, props, key) => {
        return {
          $$typeof: ReactElementSymbol,
          type,
          key,
          props
        };
      };
      // main.jsx
      import { jsx } from 'react';
      
      jsx('div', { className: 'example', children: ['Hello', 'World'] });

动机

  1. 在React 0.12时期,我们对keyrefdefaultProps的工作方式进行了一系列小的改动。特别是,它们在React.createElement(...)调用中被提前解析。当一切都是类组件时,这是有意义的,但自那以后,我们引入了函数组件。Hooks也使函数组件变得更加普遍。现在可能是时候重新评估一些设计,以简化事物(至少对于函数组件而言)。
  2. 元素创建是一个十分常用的,因为它被大量使用,而且在每次重新渲染时都会重新创建。
  3. React.createElement 存在如下问题:
    • 我们需要在每次元素创建调用期间对组件进行动态测试,以检查它是否有.defaultProps。这无法很好地优化,因为调用它的函数是高度多态的。
    • 元素创建中的 .defaultPropsReact.lazy 不兼容,所以在这种情况下我们还必须在渲染阶段检查解析 defaultProps,这意味着语义无论如何是不一致的。
    • Children 作为可变参数传递,我们必须动态地将它们添加到 props 上,而不是在调用点静态地知道 props 的结构。
    • 转译是使用React.createElement, 这是一个动态属性查找,而不是一个封闭在模块作用域内的常量。这导致最小化效果不佳,并且运行时略有成本。
    • 我们不知道传入的props是否是用户创建的可以被修改的对象,所以我们必须总是克隆它一次。
    • keyref 从提供的JSX props中提取,所以即使我们不克隆,我们也必须删除一个prop,这会导致该对象变成类似Map的结构。
    • keyref可以动态展开,所以没有禁止性分析,我们不知道这些模式是否会包含它们<div {...props} />
    • 转换依赖于JSX作用域中存在React名称。也就是说,你必须导入默认值。这是不幸的,因为像Hooks这样的更多东西通常作为命名参数使用。理想情况下,你不需要导入任何东西就可以使用JSX。

详细设计

设计将包括三个步骤。

  1. 新的JSX转换。
  2. 弃用和警告。
  3. 实际的语义性破坏。

JSX转换更改

前提说明

由于我们对 React JSX 的转换做了些许修改,那么有许多转译器、打包工具和下游工具的组合需要相继做出修改。

  1. 自动导入

    我们需要改变的第一件事是消除需要在作用域内有 React 标识符的要求。

    理想情况下,创建元素应该是编译器处理的一部分。存在一些实际问题。首先,我们有开发模式和生产模式,开发模式版本更复杂,并集成到React中。我们还在版本之间进行微妙的更改 - 比如这个。

    通过部署npm包来迭代新版本要比更新编译器工具链容易得多。因此,最好实际实现仍然存在于react包中。

    理想情况下,你不需要编写任何导入就可以使用JSX:

    javascript
    function Foo() {
      return <div />;
    }

    然后它会编译以包含这个依赖,随后,打包工具会将其解析为它想要的任何内容。

    javascript
    import { jsx } from 'react';
    function Foo() {
      return jsx('div' /** ... */);
    }

    问题是并非所有工具都支持从转换中添加新的依赖。第一步是弄清楚如何在当前生态系统中以习惯的方式完成这一点。

  2. keyprops 分开传递

    目前,key 作为 props 的一部分传递,但我们将来想要特殊处理它,所以我们需要将其作为单独的参数传递。

    javascript
    jsx('div', props, key);
  3. 始终将 children 作为 props 传递

    createElement中,children 作为可变参数进行传递。而在新的转换中,我们将 children 内联添加到 props 对象中。

    我们将它们作为可变参数传递的原因是为了在开发环境中区分静态children和动态children。我们可以改为传递一个布尔值或使用两个不同的函数来区分它们。我的建议是将<div>{a}{b}</div>编译为jsxs('div', {children: [a, b]}),将<div>{a}</div>编译为jsx('div', {children:a})jsxs函数表示顶层数组是由 React 创建的。这种策略的好处是,即使你没有为生产和开发环境设置单独的构建步骤,我们仍然可以发出 key 警告,并且在生产环境中不会产生任何成本。

  4. 仅用于开发环境的转换

    我们有一些特殊的转换仅用于开发环境。__source__self 不是 props 的一部分。我们可以将它们作为单独的参数传递。

    一个可能的解决方案是将开发环境编译为一个单独的函数:

    javascript
    jsxDEV(type, props, key, isStaticChildren, source, self);

    这样,如果转换不匹配,我们可以轻松地报错。

  5. 仅展开

    这种特殊的模式:

    javascript
    <div {...props} />

    目前可以安全地优化为:

    javascript
    createElement('div', props);

    这是因为createElement()总是克隆传递的对象。我们希望在新转换的jsx()函数中避免克隆。大多数情况下,这不会被观察到,因为JSX无论如何都会创建一个新的内联对象。这是一个特殊情况,它不会这样做。

    我们可以通过始终内联克隆来解决这个问题:

    javascript
    jsx('div', { ...props });

    或者,我们可以保持这样:

    javascript
    jsx('div', props);

    这将是一个破坏性的变更,但我们可以在次要版本中在调用中始终克隆,然后在主要版本中进行破坏性变更。新的语义将是传入的对象在开发环境中被冻结。

弃用和警告

  1. 弃用"模块模式"组件

    javascript
    const Foo = props => {
      return {
        onClick() {
          // ...
        },
        render() {
          return <div onClick={this.onClick.bind(this)} />;
        }
      };
    };

    它仅仅因为存在就导致了一些实现复杂性。

    从这里升级是相当直接的。这是一种非常不寻常的模式,大多数人不知道你可以这样做。关键是你的类构造函数需要有一个Component.prototype.isReactComponent属性,并且能够处理用new调用(即不使用箭头函数)。即使你碰巧使用模块模式,你也可以添加一个带有isReactComponent属性的原型,并使用函数表达式而不是箭头函数。

    javascript
    function Foo(props) {
      return {
        onClick() {
          //...
        },
        render() {
          return <div onClick={this.onClick.bind(this)} />;
        }
      };
    }
    Foo.prototype = { isReactComponent: true };

    这里的重要目标是,如果我们要在类和函数组件之间引入不同的语义,我们需要在调用它们之前知道我们将要应用哪种语义。

  2. 弃用函数组件上的defaultProps

    defaultProps在类上非常有用,因为props对象被传递给许多不同的方法。生命周期、回调等。每一个都在自己的作用域中。这使得使用JS默认参数变得困难,因为你必须在每个函数中复制相同的默认值。

    javascript
    class Foo {
      static defaultProps = { foo: 1 };
      componentDidMount() {
        const foo = this.props.foo;
        console.log(foo);
      }
      componentDidUpdate() {
        const foo = this.props.foo;
        console.log(foo);
      }
      componentWillUnmount() {
        const foo = this.props.foo;
        console.log(foo);
      }
      handleClick = () => {
        const foo = this.props.foo;
        console.log(foo);
      };
      render() {
        const foo = this.props.foo;
        console.log(foo);
        return <div onClick={this.handleClick} />;
      }
    }

    然而,在函数组件中,这种模式并不是很需要,因为你可以直接使用JS默认参数,而且你通常使用这些值的所有地方都在同一个作用域内。

    javascript
    function Foo({ foo = 1 }) {
      useEffect(() => {
        console.log(foo);
        return () => {
          console.log(foo);
        };
      });
      const handleClick = () => {
        console.log(foo);
      };
      console.log(foo);
      return <div onClick={handleClick} />;
    }

    我们会在createElement中添加一个警告,如果某个没有.prototype.isReactComponent的东西使用了defaultProps。这包括其他特殊组件,如forwardRefmemo

    如果你传递整个props对象,升级会更棘手,但你总是可以在需要时重构它:

    javascript
    function Foo({ foo = 1, bar = 'hello' }) {
      const props = { foo, bar };
      //...
    }
  3. 弃用从对象中展开 key

    目前支持这种模式:

    javascript
    const randomObj = { key: 'foo' };
    const element = <div {...randomObj} />;
    element.key; // 'foo'

    这个问题在于我们无法静态地知道这个对象是否会传递一个key。所以对于每组props,我们必须进行昂贵的动态属性检查,以查看是否有keyprop。

    我的建议是,我们通过将静态keyprop视为与通过展开提供的key不同来解决这个问题。我认为作为第二步,我们甚至可能想要给出单独的语法,例如:

    html
    <div @key="Hi" />

    为了最小化变动并开启关于这种语法的更广泛讨论,我们会将key视为JSX中的关键字并单独传递。

    jsx(...)的向后兼容实现中,我们仍然支持作为props传递的key。我们只会将其从props中提取出来,并发出警告说这种模式已被弃用。升级路径是如果你需要它,就将其单独传递给JSX。

    javascript
    const { key, ...props } = obj;
    <div key={key} {...props} />;

    一个未解决的问题是我们如何区分<div key="Hi" {...props} /><div {...props} key="Hi" />,它们目前具有不同的语义,取决于props是否有key

    在以后的主要版本中,我们将停止从props中提取key,因此props现在只是直接传递。

  4. 弃用字符串refs(并移除生产模式的_owner字段)

    我们知道我们想要弃用字符串refs。我们已经在严格模式下对此发出警告。现在是时候开始普遍发出警告了。

    在未来的主要版本中,我们将移除字符串refs,这将让我们摆脱元素中的_owner字段。

    我们有一个添加__self的转换。我们可以使用它在__self_owner具有相同值时发出不同的警告。在这些情况下,可以安全地运行一个自动化的代码修改,将字符串refs从ref="foo"转换为ref={n => this.refs.foo = n}。因此,建议首先修复__self_owner不同的所有情况,因为这些需要手动干预。这个警告可以更早发出。在该警告生效后,我们可以接着告诉人们为其余部分运行代码修改工具。

ref提取移至类渲染时间和forwardRef渲染时间

在一个次要版本中,如果元素上定义了ref,我们将为props.ref添加一个可枚举的getter(仅在开发环境中)。如果你尝试访问它,这将发出警告。然而,在类组件中,我们会检测到这一点并在将props传递给类之前创建props的副本。同样的情况也适用于forwardRef。我们也可以特殊处理cloneElement

由于你不能将ref传递给除了宿主组件、类组件和forwardRef之外的任何东西,所以你展开带有ref的props应该是相当不常见的。希望可以解决剩余的情况。

在下一个主要版本中,我们将开始将ref同时复制到props和element.ref上。React现在将使用props.ref作为forwardRef和类的真实来源,并且在这些情况下仍然会创建一个不包括ref的props的浅拷贝。同时,我们将在开发环境中为element.ref添加一个getter,如果你访问它就会发出警告。升级路径现在是如果你需要从元素中获取它,就直接从props中访问它。

defaultProps解析移至类渲染时间

在一个次要版本中,在我们已经为非类组件弃用了defaultProps之后,我们将开始为所有使用defaultProps解析的props添加getter(仅在开发环境中)。在将这个对象传递给类之前,我们会进行浅克隆并传递没有getter的props。这些getter会发出警告,表示你正在过早地从元素的props中读取。也许cloneElement会得到特殊处理。

这里的升级路径是避免从element.props中读取,或者不再依赖defaultProps,而是显式地传入它们或在类中解析它们。

在下一个主要版本中,我们将停止在元素创建期间解析defaultProps,而是只在将它们传递给类组件之前解析它。

缺点

这里的主要缺点是需要一些手动工作来升级用户代码。需要更改的内容类型是不常见的模式,但它们可能分散在各处。我们可以添加警告来跟踪大多数模式,所以如果你有足够的日志工具来跟进这些少数边缘情况,升级应该是可管理的。

它也给周围的工具生态系统带来了变动 - 比如类型系统。

在过渡期间可能还会有轻微的性能成本。

替代方案

一个替代方案是暂时保持现状,然后尝试作为一个更大的编译器项目更全面地解决它,这可能包括更多的变更。

采用策略

早期我们必须部署对JSX转换的任何更改,因为这些更改需要很长时间才能部署,而且它们通常不与React一起版本化。实现将向后兼容。

在接近预期的主要版本发布时间的次要版本中,我们将包括对已弃用模式的警告,以及如何将它们更改为不发出警告的模式的说明。

在下一个主要版本中,我们将实际上将ref/defaultProps解析移至类。forwardRef将从props中提取它,但现在我们可以软弃用forwardRef,而是只建议从props中提取它。

如何教授这个

难的部分是教授升级路径。一旦完成,结果会显著简化,因为我们移除了许多概念。特别是如果你首先教授函数组件。

未解决的问题

  • 在升级路径阶段,我们如何区分<div key="foo" {...props} /><div {...props} key="foo" />

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)