每日一报
在开源项目中使用AI的思考
参考:https://roe.dev/blog/using-ai-in-open-source
不要让 LLM 为你讲话
如果我阅读了您的评论、邮件、issue 或 PR,我想知道这是您的话。我不在乎语法或拼写——对此我不会对您评头论足。我更关心真正的关系点。
AI 生成的 PR 通常是冗长且密集(且不准确!),简单理解就是废话多而不凸显核心观念。简单就是一种艺术,沟通的目标并非是令人“印象深刻”,而是清晰表达。
不要让 LLM 为你思考
继续前进 - 将代码库放入LLM中或让它编写您需要的函数或测试。但在将AI生成的代码贡献给开源项目之前,最后一步应始是要审查代码的内容。
使用计算机辅助工具(未成熟的AI)指引你朝正确方向发展,但始终要承担个人责任。我们不能将AI生成的内容当作规范或所谓的标准答案,我们始终需要对 AI 所生成的内容持有怀疑态度,需要进行审查。审查时不能总以“我不确定这是否有帮助,但ChatGPT说…”来逃避责任,我不想在 PR 或 issue 中看到 LLM 的引用 - 我想知道你的想法。
PostCss
参考:https://postcss.org/docs/postcss-architecture#overview
不是像 Sass 或 Less 那样的样式预处理器
PostCSS 自身不自定义语法和语义且实际上并非是一种语言。PostCSS 与 CSS 一起工作,并可以轻松集成到上述工具中。也就是说,任何有效的 CSS 都可以由 PostCSS 处理。
是用于 CSS 语法转换工具
它允许您定义类似于自定义 CSS 的语法,这些语法可被插件理解和转换。也就是说,PostCSS并非严格遵循CSS规范,而是关于定义 CSS 语法方式。通过这种方式,您可以定义诸如 at-rule 之类的自定义语法结构,在围绕 PostCSS 构建的工具中可能会非常有帮助。PostCSS扮演着构建出色的用于 CSS 操作工具框架角色。
在 CSS 生态系统中占据重要地位
大量优秀的工具(如Autoprefixer、Stylelint、Cssnano)都基于 Postcss 生态系统构建而而成。很有可能你已经在使用它了,可以从 node_modules 中悉知。
WorkFlow
这是整个PostCSS工作流程的高级概述
正如您可以从上面的图表中看到的那样,PostCSS架构非常简单明了,但其中的一些部分可能会被误解。
您可以看到一个名为Parser的部分,这个结构将在稍后详细描述,现在只需将其视为一个能够理解您的CSS类似语法并创建其对象表示形式的结构即可。
话虽如此,编写 parser 有几种方法。
使用字符串到
AST
转换的单个文件这种方法非常流行,例如,Rework 分析器 就是以这种风格编写的。但是在大型代码库中,代码变得难以阅读且速度较慢。
将整个过程分为词法分析和语法解析两个步骤(源字符串 → tokens →
AST
)- 词法分析(Lexical Analysis): 将源代码字符串转换为标记(tokens)
- 语法解析(Parsing): 将标记(tokens)转换为抽象语法树(AST)
这种方法在 PostCSS 中使用,也是目前最流行的做法。许多解析器, 如 Babel 的 [@babel/parser]((https://github.com/babel/babel/tree/master/packages/babel-parser) 和 CSSTree,都采用这种方式。
将解析流程分为两步骤的的主要原因是:
- 性能优化:
- 从字符串到标记(tokens)的转换是比较耗时的,需要逐字符处理大量源代码。
- 将这一步骤独立出来,只需执行一次,可以提高整体性能。
- 抽象复杂性:
- 词法分析可以编写得非常快速(虽然代码可能较难阅读)。
- 语法解析逻辑上更复杂,但可以写得更易读(尽管速度较慢)。
通过这种拆分,我们可以在性能和代码可读性之间取得平衡。词法分析保证速度,语法解析保证可读性。
总的来说,这种方法既提高了性能,又改善了代码的可读性,是一种在 PostCSS 等工具中广泛采用的有效策略。
接下来,文章提到将详细介绍在 PostCSS workflow 中起主要作用的几个核心结构:
标记生成器(Tokenizer) -
lib/tokenize.js
- 也称为词法分析器(Lexer)
- 接收CSS字符串,返回标记(tokens)列表
- 每个标记描述语法的一部分,如
at-rule
,comment
或word
- 标记可包含位置信息,便于生成更详细的错误信息
- 标记以列表形式表示,包含类型、内容、开始位置、结束位置等信息
- 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
。就像所说的那样,每个标记都表示为一个列表,并遵循这种模式。jsconst 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 ];
解析器(Parser) -
lib/parse.js
、lib/parser.js
- 负责对输入的CSS进行语法分析
- 生成抽象语法树(AST)
- 与标记生成器协同工作,处理标记而非源字符串
- 主要使用标记生成器提供的
nextToken
和back
方法 - 构建AST的各个部分,称为"节点"(Node)
处理器(Processor) -
lib/processor.js
- 初始化插件并运行语法转换
- 提供少量公共API方法
字符串化器(Stringifier) -
lib/stringify.js
、lib/stringifier.js
- 将修改后的 AST 转换回纯 CSS 字符串
- 从提供的节点开始遍历 AST,生成原始字符串表示
- 核心方法是
Stringifier.stringify
,接受初始节点和分号指示符
这些结构共同工作,完成CSS的解析、转换和生成过程:
- 标记生成器将CSS字符串转换为标记。
- 解析器使用这些标记构建AST。
- 处理器应用插件对AST进行转换。
- 字符串化器将修改后的AST转换回CSS字符串。
这种结构设计使得PostCSS能够高效地处理CSS,同时保持良好的可扩展性和可维护性。每个组件都专注于特定任务,使得整个系统更加模块化和灵活。
React.createElement的性能优化和简化
提案概述
这个提案旨在简化React.createElement的工作方式,并最终消除对forwardRef的需求。虽然听起来可能令人担忧,但对大多数开发者来说,升级路径应该相对简单,因为被弃用的功能主要是一些边缘情况,大部分情况都可以通过代码修改工具(codemod)自动处理。
主要变更
1. 弃用"模块模式"组件
jsconst Foo = props => { return { onClick() { // ... }, render() { return <div onClick={this.onClick.bind(this)} />; } }; };
新方式:
jsfunction Foo(props) { const onClick = () => { // ... }; return <div onClick={onClick} />; }
2. 弃用函数组件上的
defaultProps
jsfunction Foo(props) { return <div>{props.name}</div>; } Foo.defaultProps = { name: 'Guest' };
新方式:
jsfunction Foo({ name = 'Guest' }) { return <div>{name}</div>; }
3. 弃用从对象中展开
key
属性jsconst props = { key: 'unique', className: 'button' }; return <div {...props} />;
新方式:
jsconst { 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
渲染时间这个变化主要是内部实现的改变,但可能会影响一些边缘情况。例如,在类组件中:
jsclass 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
分开传递。
jsReact.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'] });
动机
- 在React 0.12时期,我们对
key
、ref
和defaultProps
的工作方式进行了一系列小的改动。特别是,它们在React.createElement(...)
调用中被提前解析。当一切都是类组件时,这是有意义的,但自那以后,我们引入了函数组件。Hooks也使函数组件变得更加普遍。现在可能是时候重新评估一些设计,以简化事物(至少对于函数组件而言)。 - 元素创建是一个十分常用的,因为它被大量使用,而且在每次重新渲染时都会重新创建。
- React.createElement 存在如下问题:
- 我们需要在每次元素创建调用期间对组件进行动态测试,以检查它是否有
.defaultProps
。这无法很好地优化,因为调用它的函数是高度多态的。 - 元素创建中的
.defaultProps
与React.lazy
不兼容,所以在这种情况下我们还必须在渲染阶段检查解析defaultProps
,这意味着语义无论如何是不一致的。 Children
作为可变参数传递,我们必须动态地将它们添加到props
上,而不是在调用点静态地知道props
的结构。- 转译是使用
React.createElement
, 这是一个动态属性查找,而不是一个封闭在模块作用域内的常量。这导致最小化效果不佳,并且运行时略有成本。 - 我们不知道传入的props是否是用户创建的可以被修改的对象,所以我们必须总是克隆它一次。
key
和ref
从提供的JSX props中提取,所以即使我们不克隆,我们也必须删除一个prop,这会导致该对象变成类似Map的结构。key
和ref
可以动态展开,所以没有禁止性分析,我们不知道这些模式是否会包含它们<div {...props} />
。- 转换依赖于JSX作用域中存在
React
名称。也就是说,你必须导入默认值。这是不幸的,因为像Hooks这样的更多东西通常作为命名参数使用。理想情况下,你不需要导入任何东西就可以使用JSX。
- 我们需要在每次元素创建调用期间对组件进行动态测试,以检查它是否有
详细设计
设计将包括三个步骤。
- 新的JSX转换。
- 弃用和警告。
- 实际的语义性破坏。
JSX转换更改
前提说明
由于我们对 React JSX
的转换做了些许修改,那么有许多转译器、打包工具和下游工具的组合需要相继做出修改。
自动导入
我们需要改变的第一件事是消除需要在作用域内有
React
标识符的要求。理想情况下,创建元素应该是编译器处理的一部分。存在一些实际问题。首先,我们有开发模式和生产模式,开发模式版本更复杂,并集成到React中。我们还在版本之间进行微妙的更改 - 比如这个。
通过部署npm包来迭代新版本要比更新编译器工具链容易得多。因此,最好实际实现仍然存在于
react
包中。理想情况下,你不需要编写任何导入就可以使用JSX:
javascriptfunction Foo() { return <div />; }
然后它会编译以包含这个依赖,随后,打包工具会将其解析为它想要的任何内容。
javascriptimport { jsx } from 'react'; function Foo() { return jsx('div' /** ... */); }
问题是并非所有工具都支持从转换中添加新的依赖。第一步是弄清楚如何在当前生态系统中以习惯的方式完成这一点。
将
key
与props
分开传递目前,key 作为 props 的一部分传递,但我们将来想要特殊处理它,所以我们需要将其作为单独的参数传递。
javascriptjsx('div', props, key);
始终将 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
警告,并且在生产环境中不会产生任何成本。仅用于开发环境的转换
我们有一些特殊的转换仅用于开发环境。
__source
和__self
不是props
的一部分。我们可以将它们作为单独的参数传递。一个可能的解决方案是将开发环境编译为一个单独的函数:
javascriptjsxDEV(type, props, key, isStaticChildren, source, self);
这样,如果转换不匹配,我们可以轻松地报错。
仅展开
这种特殊的模式:
javascript<div {...props} />
目前可以安全地优化为:
javascriptcreateElement('div', props);
这是因为
createElement()
总是克隆传递的对象。我们希望在新转换的jsx()
函数中避免克隆。大多数情况下,这不会被观察到,因为JSX无论如何都会创建一个新的内联对象。这是一个特殊情况,它不会这样做。我们可以通过始终内联克隆来解决这个问题:
javascriptjsx('div', { ...props });
或者,我们可以保持这样:
javascriptjsx('div', props);
这将是一个破坏性的变更,但我们可以在次要版本中在调用中始终克隆,然后在主要版本中进行破坏性变更。新的语义将是传入的对象在开发环境中被冻结。
弃用和警告
弃用"模块模式"组件
javascriptconst Foo = props => { return { onClick() { // ... }, render() { return <div onClick={this.onClick.bind(this)} />; } }; };
它仅仅因为存在就导致了一些实现复杂性。
从这里升级是相当直接的。这是一种非常不寻常的模式,大多数人不知道你可以这样做。关键是你的类构造函数需要有一个
Component.prototype.isReactComponent
属性,并且能够处理用new
调用(即不使用箭头函数)。即使你碰巧使用模块模式,你也可以添加一个带有isReactComponent
属性的原型,并使用函数表达式而不是箭头函数。javascriptfunction Foo(props) { return { onClick() { //... }, render() { return <div onClick={this.onClick.bind(this)} />; } }; } Foo.prototype = { isReactComponent: true };
这里的重要目标是,如果我们要在类和函数组件之间引入不同的语义,我们需要在调用它们之前知道我们将要应用哪种语义。
弃用函数组件上的
defaultProps
defaultProps
在类上非常有用,因为props对象被传递给许多不同的方法。生命周期、回调等。每一个都在自己的作用域中。这使得使用JS默认参数变得困难,因为你必须在每个函数中复制相同的默认值。javascriptclass 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默认参数,而且你通常使用这些值的所有地方都在同一个作用域内。
javascriptfunction 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
。这包括其他特殊组件,如forwardRef
和memo
。如果你传递整个props对象,升级会更棘手,但你总是可以在需要时重构它:
javascriptfunction Foo({ foo = 1, bar = 'hello' }) { const props = { foo, bar }; //... }
弃用从对象中展开
key
目前支持这种模式:
javascriptconst randomObj = { key: 'foo' }; const element = <div {...randomObj} />; element.key; // 'foo'
这个问题在于我们无法静态地知道这个对象是否会传递一个key。所以对于每组props,我们必须进行昂贵的动态属性检查,以查看是否有
key
prop。我的建议是,我们通过将静态
key
prop视为与通过展开提供的key不同来解决这个问题。我认为作为第二步,我们甚至可能想要给出单独的语法,例如:html<div @key="Hi" />
为了最小化变动并开启关于这种语法的更广泛讨论,我们会将
key
视为JSX中的关键字并单独传递。在
jsx(...)
的向后兼容实现中,我们仍然支持作为props传递的key
。我们只会将其从props中提取出来,并发出警告说这种模式已被弃用。升级路径是如果你需要它,就将其单独传递给JSX。javascriptconst { key, ...props } = obj; <div key={key} {...props} />;
一个未解决的问题是我们如何区分
<div key="Hi" {...props} />
和<div {...props} key="Hi" />
,它们目前具有不同的语义,取决于props是否有key
。在以后的主要版本中,我们将停止从props中提取key,因此props现在只是直接传递。
弃用字符串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" />
?