- 用 React Hooks 写异步表单校验
- React Hooks vs HOC 性能对比探讨
- Flexbox vs Grid
- git-history 把玩
- 一个 React 优化模式
用 React Hooks 写异步表单校验
春节期间,React 发布了 16.8 的版本,正式支持了 React Hooks。本文将使用 React Hooks API 通过 100 行代码来实现支持异步的表单校验功能。当然,本文最终的例子接近 200 行代码,这其中包含了使用 Hooks 的部分。
许多与表单相关的教程都不怎么涉及这三个问题:
- 异步校验
- 其他字段发生变化时触发的字段校验
- 校验频率的优化
在实际的表单应用场景中,上述三点是非常普遍的。那么这里我们就以下面的若干点作为实现的目标:
- 同步校验表单域及当表单域变化时的依赖域
- 异步校验表单域及当表单域变化时的依赖域
- 提交前所有表单域的同步校验
- 提交前所有表单域的异步步校验
- 尝试异步提交,如失败,展示返回值中的错误信息
- 为开发者暴露校验方法,开发者可在 onBlur 或其他时机进行校验
- 允许一个域的多种校验
- 如果表单有错误,拦截提交
- 直到表单修改后,或尝试发起表单提交后才展示一个表单域的错误信息
下面的例子是一个带有用户名(username),密码(password)和确认密码(confirmPassword)的账号注册场景。我们先从简单的开始:
import React, { Component, useState, useEffect, useRef } from "react";
import { useForm, useField } from "./formHooks";
const form = useForm({
onSubmit,
});
const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
],
fieldsToValidateOnChange: []
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
// const { onSubmit, getFormData, addField, isValid, validateFields, submitted, submitting } = form
// const { name, value, onChange, errors, setErrors, pristine, validate, validating } = usernameField
这是相对简单的 API,但应该会给我们很大的灵活性。可以注意到,此接口包含两个命名类似的函数,validation 和 validate。我们将定义 validation 为接收表单数据和字段名称的函数,如果发现问题则返回错误消息,否则将返回 false。另一方面,函数 validate 将运行字段的所有校验函数,并将更新字段的错误列表。
首先,我们需要一个基本框架来处理表单域变化和表格提交。我们第一次迭代不包括任何校验,它只会处理表单状态。
// Skipping some boilerplate: imports, ReactDOM, etc.
export const useField = (name, form, { defaultValue } = {}) => {
let [value, setValue] = useState(defaultValue);
let field = {
name,
value,
onChange: e => {
setValue(e.target.value);
}
};
// Register field with the form
form.addField(field);
return field;
};
export const useForm = ({ onSubmit }) => {
let fields = [];
const getFormData = () => {
// Get an object containing raw form data
return fields.reduce((formData, field) => {
formData[field.name] = field.value;
return formData;
}, {});
};
return {
onSubmit: async e => {
e.preventDefault(); // Prevent default form submission
return onSubmit(getFormData());
},
addField: field => fields.push(field),
getFormData
};
};
const Field = ({ label, name, value, onChange, ...other }) => {
return (
<FormControl className="field">
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input value={value} onChange={onChange} {...other} />
</FormControl>
);
};
const App = props => {
const form = useForm({
onSubmit: async formData => {
window.alert("Account created!");
}
});
const usernameField = useField("username", form, {
defaultValue: ""
});
const passwordField = useField("password", form, {
defaultValue: ""
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: ""
});
return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field {...usernameField} label="Username" />
<Field {...passwordField} label="Password" type="password" />
<Field {...confirmPasswordField} label="Confirm Password" type="password" />
<Button type="submit">Submit</Button>
</form>
</div>
);
};
我们只对字段值进行跟踪。每个字段在使用 useField 初始化结束时使用 form 进行注册。我们的 onChange 也很简单。而 getFormData 也就是遍历表单字段,生成一个表单键值对进行存储。
现在让我们添加对校验的支持。我们还不会指定在字段值更改时应校验哪些字段。相反,我们将在值发生更改时以及每次提交表单时校验所有字段。
export const useField = (
name,
form,
{ defaultValue, validations = [] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]);
const validate = async () => {
let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation => validation(formData, name))
);
errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
setErrors(errorMessages);
let fieldValid = errorMessages.length === 0;
return fieldValid;
};
useEffect(
() => {
form.validateFields(); // Validate fields when value changes
},
[value]
);
let field = {
name,
value,
errors,
validate,
setErrors,
onChange: e => {
setValue(e.target.value);
}
};
// Register field with the form
form.addField(field);
return field;
};
export const useForm = ({ onSubmit }) => {
let fields = [];
const getFormData = () => {
// Get an object containing raw form data
return fields.reduce((formData, field) => {
formData[field.name] = field.value;
return formData;
}, {});
};
const validateFields = async () => {
let fieldsToValidate = fields;
let fieldsValid = await Promise.all(
fieldsToValidate.map(field => field.validate())
);
let formValid = fieldsValid.every(isValid => isValid === true);
return formValid;
};
return {
onSubmit: async e => {
e.preventDefault(); // Prevent default form submission
let formValid = await validateFields();
return onSubmit(getFormData(), formValid);
},
addField: field => fields.push(field),
getFormData,
validateFields
};
};
const Field = ({
label,
name,
value,
onChange,
errors,
setErrors,
validate,
...other
}) => {
let showErrors = !!errors.length;
return (
<FormControl className="field" error={showErrors}>
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input
id={name}
value={value}
onChange={onChange}
onBlur={validate}
{...other}
/>
<FormHelperText component="div">
{showErrors &&
errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
</FormHelperText>
</FormControl>
);
};
const App = props => {
const form = useForm({
onSubmit: async formData => {
window.alert("Account created!");
}
});
const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
]
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
]
});
return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field {...usernameField} label="Username" />
<Field {...passwordField} label="Password" type="password" />
<Field {...confirmPasswordField} label="Confirm Password" type="password" />
<Button type="submit">Submit</Button>
</form>
</div>
);
};
上面的代码是一个改进,乍一看,它似乎可以很好地工作,但实际上它还差很多。我们需要一些必要的 flag 来避免在不适当的时机出现错误信息,在用户再次修改之前能够立刻显示出相应的错误。
最起码我们需要提供一个 pristine flag 来告诉 UI 如果用户还没有对表单域进行改动,就不必展示错误。当然我们还可以更进一步,引入其他更多的 flag 来细化行为。
我们需要一个 flag 来标识用户已尝试提交表单,还需要 flag 来标识表单是否正在提交以及每个表单域是否正在进行异步校验。
此外,为什么我们要在 useEffect中 调用 validateFields 而不是 onChange 中?我们需要 useEffect,因为 setValue 是异步发生的,而这两者都既不返回 promise 也不提供回调。因此,我们可以确定 setValue 已经完成的唯一方法是通过 useEffect 监听 value 的变化。
下面我们就来尝试实现这些 flag 并借助他们来清理 UI 及处理边界 case,注释已经详细的写在代码中:
export const useField = (
name,
form,
{ defaultValue, validations = [], fieldsToValidateOnChange = [name] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]); // 初始时没有错误
let [pristine, setPristine] = useState(true); // 初始时表单域未修改
let [validating, setValidating] = useState(false); // 初始时未处于校验状态
let validateCounter = useRef(0); // 计数器 优化多次校验的场景
const validate = async () => {
let validateIteration = ++validateCounter.current; // 每次调用先 +1
setValidating(true);
let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation => validation(formData, name))
); // 所有的校验遍历 返回 false 或 错误提示字符串
errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
if (validateIteration === validateCounter.current) {
// 只会使用最新的一次校验
setErrors(errorMessages);
setValidating(false); // 校验状态置回
}
let fieldValid = errorMessages.length === 0;
return fieldValid; // 表单域正确 flag
};
useEffect(
() => {
if (pristine) return; // 避免刚挂载就开始校验
form.validateFields(fieldsToValidateOnChange); // 注意修改状态的位置
},
[value]
);
let field = {
name,
value,
errors,
setErrors,
pristine,
onChange: e => {
if (pristine) {
setPristine(false); // 只在第一次修改时会触发
}
setValue(e.target.value); // 这里只修改表单 不修改 Hooks 的状态!
},
validate,
validating
};
form.addField(field); // 注册表单域
return field;
};
export const useForm = ({ onSubmit }) => {
let [submitted, setSubmitted] = useState(false); // 已经提交
let [submitting, setSubmitting] = useState(false); // 正在提交
let fields = [];
// 参数就是 fieldsToValidateOnChange,变动时需要校验的表单域
const validateFields = async fieldNames => {
let fieldsToValidate; // 存放 useField 生成的需要校验的表单域
if (fieldNames instanceof Array) {
fieldsToValidate = fields.filter(field =>
fieldNames.includes(field.name)
);
} else {
//if fieldNames not provided, validate all fields
fieldsToValidate = fields;
}
let fieldsValid = await Promise.all(
fieldsToValidate.map(field => field.validate())
);
let formValid = fieldsValid.every(isValid => isValid === true);
return formValid;
};
const getFormData = () => {
return fields.reduce((formData, f) => {
formData[f.name] = f.value;
return formData;
}, {});
};
return {
onSubmit: async e => {
e.preventDefault();
setSubmitting(true);
setSubmitted(true); // User has attempted to submit form at least once
let formValid = await validateFields(); // 全部校验
let returnVal = await onSubmit(getFormData(), formValid);
setSubmitting(false);
return returnVal;
},
isValid: () => fields.every(f => f.errors.length === 0), // 全部域没错误
addField: field => fields.push(field), // 注册表单域
getFormData,
validateFields,
submitted,
submitting
};
};
// 表单域
const Field = ({
label,
name,
value,
onChange,
errors,
setErrors,
pristine,
validating,
validate,
formSubmitted,
...other
}) => {
// 如果 修改过或提交过 且存在校验错误,展示错误
let showErrors = (!pristine || formSubmitted) && !!errors.length;
return (
<FormControl className="field" error={showErrors}>
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input
id={name}
value={value}
onChange={onChange}
onBlur={() => !pristine && validate()} // 如果已经修改过 则触发校验
endAdornment={
<InputAdornment position="end">
{
// 通过 validating flag 控制异步校验的旋转 icon
validating && <LoadingIcon className="rotate" />
}
</InputAdornment>
}
{...other}
/>
<FormHelperText component="div">
{showErrors &&
errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
</FormHelperText>
</FormControl>
);
};
const App = props => {
const form = useForm({
onSubmit: async (formData, valid) => {
if (!valid) return;
await timeout(2000); // Simulate network time
if (formData.username.length < 10) {
//Simulate 400 response from server
usernameField.setErrors(["Make a longer username"]);
} else {
//Simulate 201 response from server
window.alert(
`form valid: ${valid}, form data: ${JSON.stringify(formData)}`
);
}
}
});
const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
],
fieldsToValidateOnChange: []
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
let requiredFields = [usernameField, passwordField, confirmPasswordField];
return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field
{...usernameField}
formSubmitted={form.submitted}
label="Username"
/>
<Field
{...passwordField}
formSubmitted={form.submitted}
label="Password"
type="password"
/>
<Field
{...confirmPasswordField}
formSubmitted={form.submitted}
label="Confirm Password"
type="password"
/>
<Button
type="submit"
disabled={
!form.isValid() || // 表单没有处于校验状态
form.submitting || // 表单没有处于正在提交状态
requiredFields.some(f => f.pristine) // 每个表单域都有过修改(填写)属于必填校验
}
>
{form.submitting ? "Submitting" : "Submit"}
</Button>
</form>
</div>
);
};
这一版我们加了很多内容,首先包括 4 个 flag:
- pristine
- validating
- submitted
- submitting
当然,还增加了 fieldsToValidateOnChange 这一参数,传入 validateFields 来表示当表单域变化时哪些域要触发校验。
这些 flag 的用处就是:控制 UI 中的小菊花和错误提示,以及在适当时刻禁用提交按钮。
需要注意的是 validateCounter。我们需要跟踪调用验证函数的次数,因为在校验函数完成时,可能会再次调用新的校验。如果是这种情况,我们需要忽略此次调用的结果,并仅使用最近调用的结果来更新表单域的错误状态。
这个版本还有很多不足,不过这个 Demo 本身还是值得学习的。
源地址:https://medium.freecodecamp.o...
React Hooks vs HOC 性能对比探讨
Medium 上一篇文章给出结论说 Hooks 性能相对差一些,Dan Abramov 对此予以回应。我们来看看具体是怎么讨论的。
为什么说 React Hooks 性能稍逊?
这篇文章中的结论是,尽管最近 HOC 因为 wrapper hell 而越来越多的被诟病,根据其评判标准,HOC 仍然比 Hooks 快。当然他也提出,如果评判标准的测试结果有误,也请随时指正。
那么在了解 Dan 的回应之前,我们先来看看他是如何对二者进行对比的。
测试 App
作者设计了一个简单的测试场景:简而言之,就是使用 React Hooks 和 HOC 分别渲染一个具有 10,000 个子组件的根组件。其中,每个子组件包含三个状态值,以及第一次 render 后设置三个状态值的副作用。根组件负责记录渲染完这 10,000 个子组件所耗费的时间,当然,这个记录过程也是通过一个副作用来完成。
Hooks 版本
import React, { useEffect, useState } from 'react';
import { render } from 'react-dom';
const array = [];
for (let i = 0; i < 10000; i++) array[i] = true;
const Component = () => {
const [a, setA] = useState('');
const [b, setB] = useState('');
const [c, setC] = useState('');
useEffect(() => {
setA('A');
setB('B');
setC('C');
}, []);
return <div>{a + b + c}</div>;
};
const Benchmark = ({ start }) => {
useEffect(() => {
console.log(Date.now() - start);
});
return array.map((item, index) => <Component key={index} />);
};
render(<Benchmark start={Date.now()} />, document.getElementById('root'));
HOC 版本
import React from 'react';
import { render } from 'react-dom';
import { compose, withState, withEffect } from '@reactorlib/core';
const array = [];
for (let i = 0; i < 10000; i++) array[i] = true;
const _Component = ({ a, b, c }) => {
return <div>{a + b + c}</div>;
};
const Component = compose(
withState({
a: '',
b: '',
c: ''
}),
withEffect(({ setA, setB, setC }) => {
setA('A');
setB('B');
setC('C');
}, true)
)(_Component);
const _Benchmark = () => {
return array.map((item, index) => <Component key={index} />);
};
const Benchmark = compose(
withEffect(({ start }) => {
console.log(Date.now() - start);
})
)(_Benchmark);
render(<Benchmark start={Date.now()} />, document.getElementById('root'));
上述的 reactorlib 是作者自己封的包,基本上代码没什么差别。
测试设备
具体设备我们并不关心,两者都是在 15 年的 MacBook 上跑的。
测试结果
作者展示了 10 组结果,从数据上看,似乎是 Hooks 稳定的落后于 HOC。
Rendering Time in milliseconds
Run# Hooks HOCs
-----------------------------
1 2197 1440
2 2302 1757
3 2749 1407
4 2243 1309
5 2167 1644
6 2219 1516
7 2322 1673
8 2268 1630
9 2164 1446
10 2071 1597
那么事实真的如此吗?
评判标准存在什么问题?
Dan 提出,首先应该统一在生产模式才有比较的意义,毕竟开发模式下会有大量的提示信息等拖慢速度,生产模式的性能才是现实中更需要考量的。
当统一成生产模式后,二者跑出的结果都显著变快,不过 Hooks 仍略慢一筹。
Hooks HOCs
(NOTE: these results are still wrong, see below)
175 156
171 164
169 154
181 138
167 159
153 194
151 152
155 152
147 163
160 162
当然结果还不对,原因在于这两者并没有在相同的评判标准下运行。
我们看到,Hooks 版本的测试是通过 useEffect() 来更新状态和计算时间的。我们看下文档中的说法:
因此,useEffect() 可以让浏览器在运行副作用之前就完成视图的绘制,对于用户来说,useEffect() 通常能提供更好的体验,因为初始渲染不需要等待副作用的执行,而这一点正是 React class 存在的一个普遍问题。
因为需要等待初始渲染, useEffect() 版的结果自然会比 class 版本的要慢一些了。那么我们来看看 HOC 到底做了什么呢?
从源码上看,HOC 是在 componentDidMount() 中设置状态并 log 时间开销。这就意味着这一过程执行的更早了一些,但同时用户还没有真实看到视图。即使按照拟定的标准来评判,结果上更好看,但 app 本身的响应性并不强。
为了将二者放到统一评判标准上进行比较,Dan 使用了 useLayoutEffect(),这一副作用是在布局阶段执行的,这与 componentDidMount() 相似。重新跑一边程序就会发现,Hooks 明显是要快了一些。
Hooks HOCs
(These rows compare the same thing--although not a very useful one)
121 156
106 170
111 157
141 152
111 156
121 158
105 170
111 162
108 166
108 157
那会不会是因为 useLayoutEffect() 本身就比 useEffect() 执行的快吗?并不,这只是意味着 useLayoutEffect() 或 componentDidMount() 执行的更早一些。除非我们的副作用场景与布局有关,否则我们通常都会希望先渲染出初始视图,然后在执行副作用。而这正式 useEffect() 帮助我们做到的。
如果我们在 useEffect() 中更新状态,但在 useLayoutEffect()(类似 componentDidMount)中计算时间,我们甚至能得到更好的结果。
Hooks HOCs
(Don't pass a screenshot like this around--read the text below.
They're not doing equivalent work!)
106 174
104 198
88 149
88 154
88 149
89 163
85 162
90 157
89 164
91 153
这个结果并不奇怪,因为 useEffect() 版本在渲染之前所做的工作一定是更少的:我们首先展示了初始渲染的结果,然后执行了改变状态的副作用,这就意味着更新的时候会有一些闪烁。不过,这一评判标准其实并不能代表真实的 UI 场景,我们并不能断定究竟哪种方法更好。Hooks 可以让我们选择是否阻塞渲染过程,从而根据自己的场景来决定什么是比较好的。
当然,渲染 10,000 个文字节点并且一次性更新它们并不能真实的还原现实应用中所会遇到的性能挑战,同时,这一场景也不能为不同场景下的性能权衡提供充分的上下文。
总之,这二者的对比本身并没那么重要,当发布一项评判标准时,知道浏览器中究竟发生了什么才更值得深究。
Benchmarking is hard.
源地址:
https://hackernoon.com/react-...
https://medium.com/@dan_abram...
Flexbox vs Grid
Flexbox 和 grid 之间有很多相似的地方,它们可以拉伸,可以收缩,可以居中,可以重新排序,可以排列等等。很多布局场景下,这两者我们都可以选来用,当然,有些场景也确实存在其中一个比另一个更合适的情况。那么我们就来关注下这二者的差异究竟在哪里:
Flexbox 可以选择性的 wrap
我们可以指定 Flexbox 在一行放不下后另一起行,当然,这样就会产生参差不齐的感觉;而 Grid 则会充满一行(如果我们允许自动充满的话)。
Flexbox 可看作「一维」,Grid 可看作「二维」
尽管 Flexbox 可以形成行列布局,并且允许元素 wrap,但我们无法指定一个元素作为一行的结束,而仅仅是沿着一个坐标轴推出去,然后按照能否 wrap 挨个排列而已。
.parent {
display: flex;
flex-flow: row wrap; /* 元素会沿着一行尽可能的排列 然后根据情况进行 wrap */
}
而 Grid 就更像是「二维」的东西,我们可以指定行和列的尺寸,显式的指定东西在行列的位置:
.parent {
display: grid;
grid-template-columns: 3fr 1fr; /* Two columns, one three times as wide as the other */
grid-template-rows: 200px auto 100px; /* Three columns, two with explicit widths */
grid-template-areas:
"header header header"
"main . sidebar"
"footer footer footer";
}
/*
Now, we can explicitly place items in the defined rows and columns.
*/
.child-1 {
grid-area: header;
}
.child-2 {
grid-area: main;
}
.child-3 {
grid-area: sidebar;
}
.child-4 {
grid-area: footer;
}
Grid 的定位基本在父元素上,而 Flexbox 基本在子元素上
/*
Flex 子元素完成大部分定位工作
*/
.flexbox {
display: flex;
> div {
&:nth-child(1) { // logo
flex: 0 0 100px;
}
&:nth-child(2) { // search
flex: 1;
max-width: 500px;
}
&:nth-child(3) { // avatar
flex: 0 0 50px;
margin-left: auto;
}
}
}
/*
Grid 父元素完成大部分定位工作
*/
.grid {
display: grid;
grid-template-columns: 1fr auto minmax(100px, 1fr) 1fr;
grid-template-rows: 100px repeat(3, auto) 100px;
grid-gap: 10px;
}
Grid 更擅长覆盖
如果是 Flexbox 想做到元素重叠的话,就要寻求一些传统方法,如负 margin、transform 或绝对定位。当时通过 Grid 我们可以讲子元素放置的覆盖网格线,甚至将元素完全覆盖在同一个网格里。
Flexbox 可以把东西「推」走
Flexbox 有个独有的特性,就是可以通过 margin auto 的方式来把子元素「推到一边」。这主要是因为 Flexbox 在 margin auto 这块的一些有趣特性,具体略,下面附一张图来展示这一特性:
源地址:https://css-tricks.com/quick-...
git-history 把玩
git-history 可以快速的翻看 Github 仓库中的文件、或者本地仓库中的文件的提交历史信息。是不是真的有用另说,效果还是蛮酷的,展示如下(动图约 8M,略过也可):
具体用法在文档中有写,就不啰嗦了。
他们也提供了 Chrome 插件,其实就是个带链接的按钮:
那么我们就看看它具体是怎么做的吧(虽然大佬们一眼就知道这是怎么做的了)。
本地版 CLI 解析
逻辑
毫无疑问的是,这个小应用的核心之一就是 git 命令,那么我们先来看看本地版都做了哪些工作:
首先从入口文件看起,然后会发现如下代码:
const cli = window._CLI;
export default function App() {
if (cli) {
return <CliApp data={cli} />;
}
const [repo, sha, path] = getUrlParams();
if (!repo) {
return <Landing />;
} else {
return <GitHubApp repo={repo} sha={sha} path={path} />;
}
}
这个 _CLI 是哪里来的呢?可以看出,如果存在 cli,就会走 CliApp,也就是本地版的代码。那我们先去找下 cli 的源头看看,可以找到如下代码:
const server = http.createServer((request, response) => {
if (request.url === "/") {
Promise.all([indexPromise, commitsPromise]).then(([index, commits]) => {
const newIndex = index.replace(
"<script>window._CLI=null</script>",
`<script>window._CLI={commits:${JSON.stringify(
commits
)},path:'${path}'}</script>`
);
var headers = { "Content-Type": "text/html" };
response.writeHead(200, headers);
response.write(newIndex);
response.end();
});
} else {
return handler(request, response, { public: sitePath });
}
});
// ...
}
而在 index.html 中,可以看到:
<script>
window._CLI = null;
</script>
也就是说,本地跑了一个服务,并且在获取到提交信息之后,会替换掉 index.html 中注入的 window._CLI,用来存放文件路径和提交记录,再回到上面的 CliApp 上,就可以拿到相应数据进行页面的渲染了。
在研究提交记录究竟是怎么获取的之前(下一小节统一聊用到的 git 命令),我们先继续看下 CliApp 的逻辑。
function CliApp({ data }) {
let { commits, path } = data;
const fileName = path.split("/").pop();
useDocumentTitle(`Git History - ${fileName}`);
commits = commits.map(commit => ({ ...commit, date: new Date(commit.date) }));
const [lang, loading, error] = useLanguageLoader(path);
if (error) {
return <Error error={error} />;
}
if (loading) {
return <Loading path={path} />;
}
return <History commits={commits} language={lang} />;
}
CliApp 居然是一个 Hook!其中使用了 useDocumentTitle 和 useLanguageLoader 这两个自定义 Hook。useDocumentTitle 本身就是个副作用,内部将传入的拼接字符串替换为页面的 title:
export function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
而 useLanguageLoader 做了什么呢?
export function useLanguageLoader(path) {
return useLoader(async () => {
const lang = getLanguage(path);
await loadLanguage(lang);
return lang;
}, [path]);
}
可以看到,本身也是调用了自定义 Hook useLoader,那么 useLoader 又做了什么呢 -。-?
function useLoader(promiseFactory, deps) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
useEffect(() => {
promiseFactory()
.then(data => {
setState({
data,
loading: false,
error: false
});
})
.catch(error => {
setState({
loading: false,
error
});
});
}, deps);
return [state.data, state.loading, state.error];
}
原来,useLoader 就是会在 useEffect 中根据指定的依赖执行一个 promise,而这个 Hook 在内部维护这个 promise 的相应状态:data、loading、error,说起来就是封装了一个「带状态的异步逻辑」。OK,也就是说我们只要关心传入的异步方法就对了。回到上面的 useLanguageLoader 继续看:
export function getLanguage(filename) {
return filenameRegex.find(x => x.regex.test(filename)).lang;
}
filenameRegex 其实就是模块内维护的针对不同文件后缀的识别列表,根据文件路径返回该文件的文件类型。下面的我们忽略,即可知道 useLanguageLoader 就是用来返回文件类型的。晕,原来是干这的,罢了罢了。
回到 CliApp,我们可以看出,正常渲染的页面是 History,那我们进去看下:
export default function History({ commits, language }) {
const codes = commits.map(commit => commit.content);
const slideLines = getSlides(codes, language);
return <Slides slideLines={slideLines} commits={commits} />;
}
function Slides({ commits, slideLines }) {
const [current, target, setTarget] = useSliderSpring(commits.length - 1);
const setClampedTarget = newTarget =>
setTarget(Math.min(commits.length - 0.75, Math.max(-0.25, newTarget)));
const index = Math.round(current);
const nextSlide = () => setClampedTarget(Math.round(target + 0.51));
const prevSlide = () => setClampedTarget(Math.round(target - 0.51));
useEffect(() => {
document.body.onkeydown = function(e) {
if (e.keyCode === 39) {
nextSlide();
} else if (e.keyCode === 37) {
prevSlide();
} else if (e.keyCode === 32) {
setClampedTarget(current);
}
};
});
return (
<React.Fragment>
<CommitList
commits={commits}
currentIndex={current}
selectCommit={index => setClampedTarget(index)}
/>
<Swipeable onSwipedLeft={nextSlide} onSwipedRight={prevSlide}>
<Slide time={current - index} lines={slideLines[index]} />
</Swipeable>
</React.Fragment>
);
}
至此我们可以看出,后面就是根据提交记录的数据来进行幻灯片的展示了,over。
git 命令
回到本地版最根本的功能上,搞了半天,根本的功能居然都不在 src 目录下,而是在 cli 目录下,而 cli 目录的功能是通过起本地服务来进行的。
回到之前的 runServer,我们去找寻下 commitsPromise 是如何实现的:
const execa = require("execa");
async function getCommits(path) {
const format = `{"hash":"%h","author":{"login":"%aN"},"date":"%ad"},`;
const { stdout } = await execa("git", [
"log",
"--follow",
"--reverse",
`--pretty=format:${format}`,
"--date=iso",
"--",
path
]);
const json = `[${stdout.slice(0, -1)}]`;
const messagesOutput = await execa("git", [
"log",
"--follow",
"--reverse",
`--pretty=format:%s`,
"--",
path
]);
const messages = messagesOutput.stdout.replace('"', '\\"').split(/\r?\n/);
const result = JSON.parse(json)
.map((commit, i) => ({
...commit,
date: new Date(commit.date),
message: messages[i]
}))
.slice(-20);
return result;
}
async function getContent(commit, path) {
const { stdout } = await execa("git", ["show", `${commit.hash}:${path}`]);
return stdout;
}
module.exports = async function(path) {
const commits = await getCommits(path);
await Promise.all(
commits.map(async commit => {
commit.content = await getContent(commit, path);
})
);
return commits;
};
核心的代码都在 git.js 文件中了,而文件暴露的方法就是用来生成 commitsPromise 的。我们可以看到,本质上就是通过 getCommits 和 getContent 拿到提交数据,他们究竟做了什么呢?getCommits 主要执行了 两个 git log 命令,而 getContent 则执行的是 git show 命令。我只会些最近本的 git,来复习下这俩吧:
git log
该命令是为了在提交若干更新之后,又或者克隆了某个项目,想回顾下提交历史时使用的。上面代码中使用到的参数功能如下:
- --follow
列举单个文件的提交历史列表(即使重命名) - -- reverse
逆序输出选择展示的提交记录 - ----pretty=format:
按格式输出
git show
通过 git show commitHash:filePath
可得到这次提交的文件内容。
综上,这个工具本地版 CLI 的实现原理,就是通过 git log 命令,找到指定文件的提交记录,并遍历 hash 或叫 tag 列表,利用 git show 拿到每次提交的时候文件的 content。
源地址:https://github.com/pomber/git...
一个 React 优化模式
今天有个推主提了一个优化模式,我们来看推主给出的代码:
function Foo({ children }) {
const [x, updateX] = useState(0);
return <div style={{ width: x }}>{ children }</div>;
}
在上面的代码中,如果我们以某种方式调用了 updateX,也就是说更新了 Foo 内部的状态,组件就会重新 render。但是,因为 children 并没有更新(引用相等),Foo 就不会尝试着去重新 render children。
这样做有什么价值呢?这样写的话我们就可以不用 React.memo 这类方法来避免计算开销。我们可以写一个更容易看清的例子来展示这种模式的作用:
可以看到,左边的写法中,父组件本身依赖组件内部的一个计算,父子组件之间没有关联。当点击从而状态更新结果后,App 需要重新 render,并且连带着子组件一起重新渲染,这显然存在性能浪费。
而右边的写法中,计算逻辑被封装在组件内部了,其 children 属性的引用没有发生变化,这样一来重新渲染子组件的开销就可以节省掉了,也即 CompA 和 COMPB 并没有重新 render。
此外,对 class 而言这个模式也是成立的,只要传入的 children 不发生变化(引用相等)就可以。不过,我们的代码通常会使 props 发生变化,所以通常情况下引用也不会相等。