React原理 Suspense lazy

lazy

/react/packages/react/src/ReactLazy.js

lazy的本质是返回一个包含thenable的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. Which is the same as what
// happens if the ctor or any wrappers processing the ctor throws. This might
// end up fixing it if the resolution was a concurrency bug.
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;

return moduleObject.default;
} else {
throw payload._result;
}
}

export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: -1,
_result: ctor,
};

const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};

return lazyType;
}

Render阶段

beginWork中遇到LazyComponent类型组件,会调用mountLazyComponent 方法处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function mountLazyComponent(_current, workInProgress, elementType, updateLanes, renderLanes) {
if (_current !== null) {
// A lazy component only mounts if it suspended inside a non-
// concurrent tree, in an inconsistent state. We want to treat it like
// a new mount, even though an empty version of it already committed.
// Disconnect the alternate pointers.
_current.alternate = null;
workInProgress.alternate = null; // Since this is conceptually a new fiber, schedule a Placement effect

workInProgress.flags |= Placement;
}

var props = workInProgress.pendingProps;
var lazyComponent = elementType;
var payload = lazyComponent._payload;
var init = lazyComponent._init;

// 第一次挂载组件时,因为promise状态不是完成状态,会抛出错误被上层的try catch捕获
var Component = init(payload);

workInProgress.type = Component;
// 如果已经加载成功,分析出异步组件的类型
var resolvedTag = workInProgress.tag = resolveLazyComponentTag(Component);

// 合并异步组件的props和通过lazy创建组件时传入的props
var resolvedProps = resolveDefaultProps(Component, props);
var child;

// 根据不同的组件类型处理,如果没有则抛出错误
switch (resolvedTag) {
//...
}
{
throw Error( "Element type is invalid. Received a promise that resolves to: " + Component + ". Lazy element type must resolve to a class or function." + hint );
}
}

下面是Suspense如何被创建并影响lazy创建的异步组件

React并没有直接创建Suspense组件,最开始的时候Suspense组件只是一个标识用于导出 REACT_SUSPENSE_TYPE as Suspense

节点类型的创建,在初始只会创建出根节点的 fiber,后续的创建在 beginWork 入口,进入 reconcile 过程,会判断节点可复用性,然后不能复用的就通过 createFiberFromTypeAndProps 创建新节点。

1
2
3
4
5
6
7
8
9
10
function createFiberFromSuspense(pendingProps, mode, lanes, key) {
var fiber = createFiber(SuspenseComponent, pendingProps, key, mode); // TODO: The SuspenseComponent fiber shouldn't have a type. It has a tag.
// This needs to be fixed in getComponentName so that it relies on the tag
// instead.

fiber.type = REACT_SUSPENSE_TYPE;
fiber.elementType = REACT_SUSPENSE_TYPE;
fiber.lanes = lanes;
return fiber;
}

beginWork中发现是Suspense类型会执行 updateSuspenseComponent

第一次执行创建子节点 workInProgress.child = mountChildFibers

第二次执行,建立节点间联系

1
2
3
4
5
6
7
fallbackChildFragment.return = workInProgress;

primaryChildFragment.sibling = fallbackChildFragment;

workInProgress.child = primaryChildFragment;

return fallbackChildFragment

由于第一次执行的时候lazy组件抛出错误,会被renderRootSync捕获

1
2
3
4
5
6
7
8
9
10
function renderRootSync(root, lanes) {
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function handleError(root, thrownValue) {
do {
try {
...
// 继续执行
throwException(...);
// 这里完成时 会将wip设置为自己的父节点 也就是 suspense 节点
workInProgress = completeUnitOfWork(workInProgress);
} catch (yetAnotherThrownValue) {
...
continue
}
// Return to the normal work loop.
return;
} while (true);
}

继续执行 throwException,这里会将抛出的 promise 放入子组件的 updateQueue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function throwException(
root: FiberRoot,
returnFiber: Fiber,
sourceFiber: Fiber,
value: mixed,
renderExpirationTime: ExpirationTime,
) {
...
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// This is a thenable.
const thenable: Thenable = (value: any);

...
do {
if (
workInProgress.tag === SuspenseComponent &&
shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
) {
// 一个 set 结构存储在 updateQueue
const thenables: Set<Thenable> = (workInProgress.updateQueue: any);
if (thenables === null) {
const updateQueue = (new Set(): any);
updateQueue.add(thenable);
// 第一次新增
workInProgress.updateQueue = updateQueue;
} else {
// 追加
thenables.add(thenable);
}
...
// 同步设置
sourceFiber.expirationTime = Sync;

return;
}

...
workInProgress = workInProgress.return;
} while (workInProgress !== null);

}
...
}

commit阶段

commitWork 中会处理队列中的Promise

会对之前渲染的 fallback 组件标记删除,对新的渲染数据标记更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function attachSuspenseRetryListeners(finishedWork) {
// If this boundary just timed out, then it will have a set of wakeables.
// For each wakeable, attach a listener so that when it resolves, React
// attempts to re-render the boundary in the primary (pre-timeout) state.
var wakeables = finishedWork.updateQueue;

if (wakeables !== null) {
finishedWork.updateQueue = null;
var retryCache = finishedWork.stateNode;

if (retryCache === null) {
retryCache = finishedWork.stateNode = new PossiblyWeakSet();
}

wakeables.forEach(function (wakeable) {
// Memoize using the boundary fiber to prevent redundant listeners.
var retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);

if (!retryCache.has(wakeable)) {
{
if (wakeable.__reactDoNotTraceInteractions !== true) {
retry = tracing.unstable_wrap(retry);
}
}

retryCache.add(wakeable);
// 通过then方法在resolve之后执行
wakeable.then(retry, retry);
}
});
}
}

实现一个异步组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
*
* @param {*} Component 需要异步数据的component
* @param {*} api 请求数据接口,返回Promise,可以再then中获取与后端交互的数据
* @returns
*/
function AysncComponent(Component,api){
const AysncComponentPromise = () => new Promise(async (resolve)=>{
const data = await api()
resolve({
default: (props) => <Component rdata={data} { ...props} />
})
})
return React.lazy(AysncComponentPromise)
}
  • 用 AysncComponent 作为一个 HOC 包装组件,接受两个参数,第一个参数为当前组件,第二个参数为请求数据的 api 。
  • 声明一个函数给 React.lazy 作为回调函数,React.lazy 要求这个函数必须是返回一个 Promise 。在 Promise 里面通过调用 api 请求数据,然后根据返回来的数据 rdata 渲染组件,别忘了接受并传递 props 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* 数据模拟 */
const getData=()=>{
return new Promise((resolve)=>{
//模拟异步
setTimeout(() => {
resolve({
name:'alien',
say:'let us learn React!'
})
}, 1000)
})
}
/* 测试异步组件 */
function Test({ rdata , age}){
const { name , say } = rdata
console.log('组件渲染')
return <div>
<div> hello , my name is { name } </div>
<div>age : { age } </div>
<div> i want to say { say } </div>
</div>
}
/* 父组件 */
export default class Index extends React.Component{
LazyTest = AysncComponent(Test,getData)
/* 需要每一次在组件内部声明,保证每次父组件挂载,都会重新请求数据 ,防止内存泄漏。 */
render(){
const { LazyTest } = this
return <div>
<Suspense fallback={<div>loading...</div>} >
<LazyTest age={18} />
</Suspense>
</div>
}
}

React原理 HOC高阶组件

属性代理

属性代理,就是用组件包裹一层代理组件,在代理组件上,可以做一些,对源组件的强化操作。这里注意属性代理返回的是一个新组件,被包裹的原始组件,将在新的组件里被挂载。

1
2
3
4
5
6
7
8
9
10
function HOC(WrapComponent){
return class Advance extends React.Component{
state={
name:'alien'
}
render(){
return <WrapComponent { ...this.props } { ...this.state } />
}
}
}

优点:

① 属性代理可以和业务组件低耦合,零耦合,对于条件渲染和 props 属性增强,只负责控制子组件渲染和传递额外的 props 就可以了,所以无须知道,业务组件做了些什么。所以正向属性代理,更适合做一些开源项目的 HOC ,目前开源的 HOC 基本都是通过这个模式实现的。
② 同样适用于类组件和函数组件。
③ 可以完全隔离业务组件的渲染,因为属性代理说白了是一个新的组件,相比反向继承,可以完全控制业务组件是否渲染。
④ 可以嵌套使用,多个 HOC 是可以嵌套使用的,而且一般不会限制包装 HOC 的先后顺序。

缺点:

① 一般无法直接获取原始组件的状态,如果想要获取,需要 ref 获取组件实例。
② 无法直接继承静态属性。如果需要继承需要手动处理,或者引入第三方库。
③ 因为本质上是产生了一个新组件,所以需要配合 forwardRef 来转发 ref。

反向继承

反向继承和属性代理有一定的区别,在于包装后的组件继承了原始组件本身,所以此时无须再去挂载业务组件。

1
2
3
4
5
6
7
8
9
10
11
class Index extends React.Component{
render(){
return <div> hello,world </div>
}
}
function HOC(Component){
return class wrapComponent extends Component{ /* 直接继承需要包装的组件 */

}
}
export default HOC(Index)

优点:

① 方便获取组件内部状态,比如 state ,props ,生命周期,绑定的事件函数等。
② es6继承可以良好继承静态属性。所以无须对静态属性和方法进行额外的处理。

缺点:

① 函数组件无法使用。
② 和被包装的组件耦合度高,需要知道被包装的原始组件的内部状态,具体做了些什么?
③ 如果多个反向继承 HOC 嵌套在一起,当前状态会覆盖上一个状态。这样带来的隐患是非常大的,比如说有多个 componentDidMount ,当前 componentDidMount 会覆盖上一个 componentDidMount 。这样副作用串联起来,影响很大。

HOC的几种场景

强化props

强化 props 就是在原始组件的 props 基础上,加入一些其他的 props ,强化原始组件功能。举个例子,为了让组件也可以获取到路由对象,进行路由跳转等操作,所以 React Router 提供了类似 withRouter 的 HOC 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function withRouter(Component) {
const displayName = `withRouter(${Component.displayName || Component.name})`;
const C = props => {
/* 获取 */
const { wrappedComponentRef, ...remainingProps } = props;
return (
<RouterContext.Consumer>
{context => {
return (
<Component
{...remainingProps} // 组件原始的props
{...context} // 存在路由对象的上下文history location
ref={wrappedComponentRef}
/>
);
}}
</RouterContext.Consumer>
);
};

C.displayName = displayName;
C.WrappedComponent = Component;
/* 继承静态属性 */
return hoistStatics(C, Component);
}
export default withRouter

分离出 props 中 wrappedComponentRef 和 remainingProps , remainingProps 是原始组件真正的 props, wrappedComponentRef 用于转发 ref。
用 Context.Consumer 上下文模式获取保存的路由信息。( React Router 中路由状态是通过 context 上下文保存传递的)
将路由对象和原始 props 传递给原始组件,所以可以在原始组件中获取 history ,location 等信息。

控制渲染

HOC 反向继承模式,可以通过 super.render() 得到 render 之后的内容,利用这一点,可以做渲染劫持 ,更有甚者可以修改 render 之后的 React element 对象。

1
2
3
4
5
6
7
8
9
10
const HOC = (WrapComponent) =>
class Index extends WrapComponent {
render() {
if (this.props.visible) {
return super.render()
} else {
return <div>暂无数据</div>
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Index extends React.Component{
render(){
return <div>
<ul>
<li>react</li>
<li>vue</li>
<li>Angular</li>
</ul>
</div>
}
}
function HOC (Component){
return class Advance extends Component {
render() {
const element = super.render()
const otherProps = {
name:'alien'
}
/* 替换 Angular 元素节点 */
const appendElement = React.createElement('li' ,{} , `hello ,world , my name is ${ otherProps.name }` )
const newchild = React.Children.map(element.props.children.props.children,(child,index)=>{
if(index === 2) return appendElement
return child
})
return React.cloneElement(element, element.props, newchild)
}
}
}
export default HOC(Index)
动态加载

dva 中 dynamic 就是配合 import ,实现组件的动态加载的,而且每次切换路由,都会有 Loading 效果,接下来看看大致的实现思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default function dynamicHoc(loadRouter) {
return class Content extends React.Component {
state = {Component: null}
componentDidMount() {
if (this.state.Component) return
loadRouter()
.then(module => module.default) // 动态加载 component 组件
.then(Component => this.setState({Component},
))
}
render() {
const {Component} = this.state
return Component ? <Component {
...this.props
}
/> : <Loading />
}
}
}
组件赋能

ref获取实例

对于属性代理虽然不能直接获取组件内的状态,但是可以通过 ref 获取组件实例,获取到组件实例,就可以获取组件的一些状态,或是手动触发一些事件,进一步强化组件,但是注意的是:类组件才存在实例,函数组件不存在实例。

1
2
3
4
5
6
7
8
9
10
11
function Hoc(Component){
return class WrapComponent extends React.Component{
constructor(){
super()
this.node = null /* 获取实例,可以做一些其他的操作。 */
}
render(){
return <Component {...this.props} ref={(node) => this.node = node } />
}
}
}

事件监控

HOC 不一定非要对组件本身做些什么?也可以单纯增加一些事件监听,错误监控。接下来,接下来做一个 HOC ,只对组件内的点击事件做一个监听效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function ClickHoc (Component){
return function Wrap(props){
const dom = useRef(null)
useEffect(()=>{
const handerClick = () => console.log('发生点击事件')
dom.current.addEventListener('click',handerClick)
return () => dom.current.removeEventListener('click',handerClick)
},[])
return <div ref={dom} ><Component {...props} /></div>
}
}

@ClickHoc
class Index extends React.Component{
render(){
return <div className='index' >
<p>hello,world</p>
<button>组件内部点击</button>
</div>
}
}
export default ()=>{
return <div className='box' >
<Index />
<button>组件外部点击</button>
</div>
}

总结

下面对 HOC 具体能实现那些功能,和如何编写做一下总结:

  • 强化 props ,可以通过 HOC ,向原始组件混入一些状态。

  • 渲染劫持,可以利用 HOC ,动态挂载原始组件,还可以先获取原始组件的渲染树,进行可控性修改。

  • 可以配合 import 等 api ,实现动态加载组件,实现代码分割,加入 loading 效果。

  • 可以通过 ref 来获取原始组件实例,操作实例下的属性和方法。

  • 可以对原始组件做一些事件监听,错误监控等。

注意事项

谨慎修改原型链

如上 HOC 作用仅仅是修改了原来组件原型链上的 componentDidMount 生命周期。但是这样有一个弊端就是如果再用另外一个 HOC 修改原型链上的 componentDidMount ,那么这个HOC的功能即将失效。

1
2
3
4
5
6
7
8
function HOC (Component){
const proDidMount = Component.prototype.componentDidMount
Component.prototype.componentDidMount = function(){
console.log('劫持生命周期:componentDidMount')
proDidMount.call(this)
}
return Component
}

不要在函数组件内部或类组件render函数中使用HOC

由于属性的引用不同,每次都会重新渲染

ref的处理

高阶组件的约定是将所有 props 传递给被包装组件,但这对于 ref 并不适用。那是因为 ref 实际上并不是一个 prop , 就像 key 一样,对于 ref 属性它是由 React 专门处理的。那么如何通过 ref 正常获取到原始组件的实例呢?在 ref 章节已经讲到,可以用 forwardRef做 ref 的转发处理。

修饰器的嵌套顺序

静态属性需要处理

React原理 context深入

context三中用法

定义context

1
2
3
const ThemeContext = React.createContext(null) //
const ThemeProvider = ThemeContext.Provider //提供者
const ThemeConsumer = ThemeContext.Consumer // 订阅消费者

使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ConsumerDemo extends React.Component{
render(){
const { color,background } = this.context
return <div style={{ color,background } } >消费者</div>
}
}
ConsumerDemo.contextType = ThemeContext

function ProviderDemo(){
const [ contextValue , setContextValue ] = React.useState({ color:'#ccc', background:'pink' })
return <div>
<ThemeProvider value={ contextValue } >
<ConsumerDemo />
</ThemeProvider>
</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConsumerDemo extends React.Component {
render() {
return <ThemeConsumer>
{({ color, background }) => <div style={{ color, background }} >消费者</div>}
</ThemeConsumer>
}
}
function ProviderDemo() {
const [contextValue, setContextValue] = React.useState({ color: '#ccc', background: 'pink' })
return <div>
<ThemeProvider value={contextValue} >
<ConsumerDemo />
</ThemeProvider>
</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
function ConsumerDemo() {
const { color, background } = React.useContext(ThemeContext);
return <div style={{ color, background }} >消费者</div>
}

function ProviderDemo() {
const [contextValue, setContextValue] = React.useState({ color: '#ccc', background: 'pink' })
return <div>
<ThemeProvider value={contextValue} >
<ConsumerDemo />
</ThemeProvider>
</div>
}

displayName

context 对象接受一个名为 displayName 的 property,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。

1
2
3
4
5
const MyContext = React.createContext(/* 初始化内容 */);
MyContext.displayName = 'MyDisplayName';

<MyContext.Provider> // "MyDisplayName.Provider" 在 DevTools 中
<MyContext.Consumer> // "MyDisplayName.Consumer" 在 DevTools 中

源码

createContext 创建了一个包含, Provider 和 Consumer 组件的对象,通过_context属性形成相互引用的循环链表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function createContext(defaultValue, calculateChangedBits) {
var context = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue,
_currentValue2: defaultValue,
Provider: null,
Consumer: null
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context
};

{
var Consumer = {
$$typeof: REACT_CONTEXT_TYPE,
_context: context,
};
Object.defineProperties(Consumer, {
// 添加getter 和 setter
});

context.Consumer = Consumer;
}

return context;
}

如果当前类型的 fiber 不需要更新,那么会 FinishedWork 中止当前节点和子节点的更新。

如果当前类型 fiber 需要更新,那么会调用不同类型 fiber 的处理方法。当然 ContextProvider 也有特有的 fiber 更新方法 —— updateContextProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function updateContextProvider(current, workInProgress, renderLanes) {
// 通过type属性获取Provider组件
var providerType = workInProgress.type;
// 拿到createContext定义的上下文
var context = providerType._context;

// 获取传递到Provider组件的属性
var newProps = workInProgress.pendingProps;
var oldProps = workInProgress.memoizedProps;
var newValue = newProps.value;

// 方法内部通过context._currentValue = nextValue 给context赋值
pushProvider(workInProgress, newValue);

// 上一次props有值得时候需要判断是否进行子元素的调度
if (oldProps !== null) {
var oldValue = oldProps.value;

// 1.判断引用是否相同
// 2.尝试使用自定义函数判断是否相同 changedBits & MAX_SIGNED_31_BIT_INT) !== changedBits 如果不是有效数字则报错
// 3 通过 |0 操作将非法结果强制转换成0;
var changedBits = calculateChangedBits(context, newValue, oldValue);

if (changedBits === 0) {
// context没有变化,且子节点没有变化,legacy context没改变,则退出更新
// No change. Bailout early if children are the same.
if (oldProps.children === newProps.children && !hasContextChanged()) {
// 子元素引用没有变化则停止调度
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
} else {
// The context value changed. Search for matching consumers and schedule
// them to update.
propagateContextChange(workInProgress, context, changedBits, renderLanes);
}
}

// 获取到子节点并继续在子节点上调度
var newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function updateContextConsumer(current, workInProgress, renderLanes) {
var context = workInProgress.type;

var newProps = workInProgress.pendingProps;
var render = newProps.children;

// 如果子元素不是一个函数则抛出错误
{
if (typeof render !== 'function') {
error('A context consumer was rendered with multiple children, or a child ' + "that isn't a function. A context consumer expects a single child " + 'that is a function. If you did pass a function, make sure there ' + 'is no trailing or leading whitespace around it.');
}
}

prepareToReadContext(workInProgress, renderLanes);
// 获取最新的值
var newValue = readContext(context, newProps.unstable_observedBits);
var newChildren;

// 执行函数获取下一个节点
{
ReactCurrentOwner$1.current = workInProgress;
setIsRendering(true);
newChildren = render(newValue);
setIsRendering(false);
} // React DevTools reads this flag.

//继续在下一个节点上调度
workInProgress.flags |= PerformedWork;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}

React原理 Ref深入

Ref相关的问题

  • Ref是如何通过 createRefuseRef 创建对象的

  • React 对标签上的 ref 属性是如何处理的

  • React 内部处理Ref的逻辑是怎样的,如何做 Ref 转发的

创建Ref对象

React.create 源码 react/src/ReactCreateRef.js

1
2
3
4
5
6
export function createRef(): RefObject {
const refObject = {
current: null,
};
return refObject;
}

React.useRef /react/src/ReactHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ReactCurrentDispatcher = {
/**
* @internal
* @type {ReactComponent}
*/
current: (null: null | Dispatcher),
};
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
return ((dispatcher: any): Dispatcher);
}
export function useRef<T>(initialValue: T): {|current: T|} {
const dispatcher = resolveDispatcher();
return dispatcher.useRef(initialValue);
}

useRef的初始化逻辑藏的比较深,当引入useRef的是否,dispatcher.current===null 并没有挂载方法。

而是通过 exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals; 挂载在 ReactSharedInternals对象上并导出给 react-reconciler 中初始化(最后打包的时候react-reconciler会打包在react-dom中)。

/react-reconciler/src/ReactFiberHooks.new.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// This dispatcher handles an edge case where a component is updating,
// but no stateful hooks have been used.
// We want to match the production code behavior (which will use HooksDispatcherOnMount),
// but with the extra DEV validation to ensure hooks ordering hasn't changed.
// This dispatcher does that.
ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
}
}

简单来说 Ref 就是一个对象,其中的current属性用于保存DOM元素,或组件实例。useRef 底层逻辑是和 createRef 差不多,就是 ref 保存位置不相同,类组件有一个实例 instance 能够维护像 ref 这种信息,但是由于函数组件每次更新都是一次新的开始,所有变量重新声明,所以 useRef 不能像 createRef 把 ref 对象直接暴露出去,如果这样每一次函数组件执行就会重新声明 Ref,此时 ref 就会随着函数组件执行被重置,这就解释了在函数组件中为什么不能用 createRef 的原因。

为了解决这个问题,hooks 和函数组件对应的 fiber 对象建立起关联,将 useRef 产生的 ref 对象挂到函数组件对应的 fiber 上,函数组件每次执行,只要组件不被销毁,函数组件对应的 fiber 对象一直存在,所以 ref 等信息就会被保存下来。对于 hooks 原理,后续章节会有对应的介绍。

Ref的几种用法

String 类型Ref

在老的React版本中使用,新版本中已经不推荐使用,可以用 React.createRef 或回调形式的 Ref 来代替。v17版本中使用refs获取对象时,只会返回一个空对象,String类型的Ref会导致很多问题:

  • React必须跟踪当前渲染的组件,因为它不知道this指向谁,这会导致React变慢

  • 下面例子中,string类型的refs写法会让ref被放置在DataTable组件中,而不是MyComponent中。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyComponent extends Component {
renderRow = (index) => {
// This won't work. Ref will get attached to DataTable rather than MyComponent:
return <input ref={'input-' + index} />;

// This would work though! Callback refs are awesome.
return <input ref={input => this['input-' + index] = input} />;
}

render() {
return <DataTable data={this.props.data} renderRow={this.renderRow} />
}
}
  • 如果一个库在传递的子组件(子元素)上放置了一个ref,那用户就无法在它上面再放一个ref了。但函数式可以实现这种组合。
函数类型Ref
1
2
3
4
5
6
export default class Index extends React.Component{
render=()=> <div>
<div ref={(node)=> this.currentDom = node } >hello word</div>
<Children ref={(node) => this.currentComponentInstance = node } />
</div>
}

当用一个函数来标记 Ref 的时候,将作为 callback 形式,等到真实 DOM 创建阶段,执行 callback ,获取的 DOM 元素或组件实例,将以回调函数第一个参数形式传入,所以可以像上述代码片段中,用组件实例下的属性 currentDom和 currentComponentInstance 来接收真实 DOM 和组件实例。

Ref对象
1
2
3
4
5
6
7
8
9
export default class Index extends React.Component{
currentDom = React.createRef(null)
currentComponentInstance = React.createRef(null)

render=()=> <div>
<div ref={ this.currentDom } >hello word</div>
<Children ref={ this.currentComponentInstance } />
</div>
}

Ref高级用法

Ref转发

初衷是用来实现将一个ref分发到一个组件的子组件中,这在写一些库的时候非常有用。

你可能会注意到,即使不通过refApi仅仅通过props的传递也可以获取,子组件的DOM。像下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}

class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1 topRef={this.topRef}/>
}
}

这与Ref转发的本意不符,对于高可复用“叶”组件来说是不方便的。这些组件倾向于在整个应用中以一种类似常规 DOM button 和 input 的方式被使用,并且访问其 DOM 节点对管理焦点,选中或动画来说是不可避免的。也可以理解成是对原声DOM的封装,而且还能方便的获取到原声DOM的引用

下面的例子,通过Ref转发获取到了 <FancyButton/>组件的子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}
const Level1Ref = React.forwardRef((props,ref)=> <Level1 {...props} topRef={ref}/>)
class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1Ref ref={this.topRef}/>
}
}
export default TopLevel;

// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;
  • 我们通过调用 React.createRef 创建了一个 React ref 并将其赋值给 ref 变量。
  • 我们通过指定 ref 为 JSX 属性,将其向下传递给
  • React 传递 ref 给 forwardRef 内函数 (props, ref) => …,作为其第二个参数。
  • 我们向下转发该 ref 参数到
  • 当 ref 挂载完成,ref.current 将指向

所以在最开的错误案例中,可以通过ref转发让叶组件获取ref,再通过props在将其在组件内部传递到需要的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Level1 extends React.Component{
render(){
return <Level2 topRef={this.props.topRef}/>
}
}
const Level1Ref = React.forwardRef((props,ref)=> <Level1 {...props} topRef={ref}/>)
class Level2 extends React.Component{
render(){
return <input name='level2' ref={this.props.topRef}/>
}
}

class TopLevel extends React.Component{
topRef = React.createRef();
componentDidMount(){
console.log(this.topRef.current)
}
render(){
return <Level1Ref ref={this.topRef}/>
}
}
合并Ref转发

理解了上面通过 forwardRefprops 共同传递ref,供给子组件消费,就很容易理解合并Ref转发

forwardRefref 可以通过 props 传递,那么如果用 ref 对象标记的 ref ,那么 ref 对象就可以通过 props 的形式,提供给子孙组件消费,当然子孙组件也可以改变 ref 对象里面的属性,或者像如上代码中赋予新的属性,这种 forwardref + ref 模式一定程度上打破了 React 单向数据流动的原则。当然绑定在 ref 对象上的属性,不限于组件实例或者 DOM 元素,也可以是属性值或方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 表单组件
class Form extends React.Component{
render(){
return <div>{...}</div>
}
}
// index 组件
class Index extends React.Component{
componentDidMount(){
const { forwardRef } = this.props
forwardRef.current={
form:this.form, // 给form组件实例 ,绑定给 ref form属性
index:this, // 给index组件实例 ,绑定给 ref index属性
button:this.button, // 给button dom 元素,绑定给 ref button属性
}
}
form = null
button = null
render(){
return <div >
<button ref={(button)=> this.button = button } >点击</button>
<Form ref={(form) => this.form = form } />
</div>
}
}
const ForwardRefIndex = React.forwardRef(( props,ref )=><Index {...props} forwardRef={ref} />)
// home 组件
export default function Home(){
const ref = useRef(null)
useEffect(()=>{
console.log(ref.current)
},[])
return <ForwardRefIndex ref={ref} />
}
在高阶组件中转发Ref

高阶组件中,属性是可以透传的,但是ref不可以,ref是特殊属性,这就导致使用高阶组件的时候,仅仅通过ref不能传递到基础组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function logProps(WrappedComponent) {
class LogProps extends React.Component {
render() {
return <WrappedComponent {...this.props} />;
}
}
return LogProps;
}

class FancyButton extends React.Component {
focus() {
console.log('focus')
}
render(){
return <div>wefwef</div>
}
}

const HOCFancyButton = logProps(FancyButton);

class MyComponent extends React.Component {
ref = React.createRef();
componentDidMount(){
console.log(this.ref.current)
}
render(){
// 使用高阶组件的时候 ref指向的是LogProps,而不是FancyButton
return <HOCFancyButton ref={this.ref}/>
}
}

可以使用forwardRef在高阶组件中做Ref转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function logProps(WrappedComponent) {
class LogProps extends React.Component {
render() {
const { forwardedRef, ...rest } = this.props;
return <WrappedComponent {...rest} ref={forwardedRef} />;
}
}
return React.forwardRef((props, ref) => <LogProps {...props} forwardRef={ref} />);
}

class FancyButton extends React.Component {
focus() {
console.log('focus')
}
render() {
return <div>wefwef</div>
}
}

const HOCFancyButton = logProps(FancyButton);

class MyComponent extends React.Component {
ref = React.createRef();
componentDidMount() {
console.log(this.ref.current)
}
render() {
return <HOCFancyButton ref={this.ref} />
}
}
类组件通过Ref通信

有一种类似表单(Form)的场景,不希望表单元素的更新是通过父组件(Form)更新触发render并传递props到子组件来更新。而是希望不用触发父组件的render,直接子组件,子组件有自己的状态,这时就需要父组件能获取到子组件的实例。调用子组件实例方法,更新子组件内部状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Child extends React.Component{
receiveMessageFromParent = (msg)=>{
console.log(msg)
}
render(){
return <>
<button onClick={()=>this.receiveMessageFromParent('MessageFromChild')}>发消息给父组件</button>
<div>child</div>
</>
}
}

class Parent extends React.Component {
childRef = React.createRef();
sendMessageToChild = ()=>{
this.childRef.current.receiveMessageFromParent('MessageFromParent')
}
receiveMessageFromChild = (msg)=>{
console.log(msg);
}
render(){
return <>
<button type='button' onClick={this.sendMessageToChild}>发消息给子组件</button>
<Child ref={this.childRef} receiveMessageFromChild={this.receiveMessageFromChild}/>
</>
}
}
函数组件通信

useImperativeHandle可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用:

1
2
3
4
5
6
7
8
9
10
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} />;
}
FancyInput = forwardRef(FancyInput);

上面的例子在函数式组件中可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function Child(props, ref) {
const inputRef = React.useRef();
const sayChild = useCallback(()=>{
console.log('child')
},[])
React.useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
sayChild
}));
return <>
<button onClick={()=> props.receiveMessageFromChild('MessageFromChild')}>发消息给父组件</button>
<input ref={ref}/>
</>
}
Child = React.forwardRef(Child)

const Parent = () => {
const ref = React.useRef();
React.useEffect(()=>{
// 可以拿到子组件定义的方法,或者操作子组件的DOM元素
console.log(ref.current)
},[ref])
const receiveMessageFromChild =useCallback((msg)=>{
console.log(msg);
},[])
return <>
<Child ref={ref} receiveMessageFromChild={receiveMessageFromChild}/>
</>
}

Ref的原理

对于整个 Ref 的处理,都是在 commit 阶段发生的。因为在 commit 阶段才会对真正的 Dom 进行操作,这是用 ref 保存真正的 DOM 节点,或组件实例。

对Ref的更新会调用两个方法 commitDetachRefcommitAttachRef 一个发生在 commit 之前,一个发生在 commit 之后

react-reconciler/src/ReactFiberCommitWork.js

1
2
3
4
5
6
7
8
9
10
11
// 在 commit 的 mutation 阶段会清空Ref
function commitDetachRef(current: Fiber) {
const currentRef = current.ref;
if (currentRef !== null) {
if (typeof currentRef === 'function') {
currentRef(null);
} else {
currentRef.current = null;
}
}
}

清空之后会进入DOM更新,根据不同的effect标签,操作真实的dom

最后 Layout 阶段会会更新Ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent: //元素节点 获取元素
instanceToUse = getPublicInstance(instance);
break;
default: // 类组件直接使用实例
instanceToUse = instance;
}
if (typeof ref === 'function') {
ref(instanceToUse); //* function 和 字符串获取方式。 */
} else {
ref.current = instanceToUse; /* ref对象方式 */
}
}
}

字符串形式的ref,最后被包装成一个函数,以函数的形式执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function coerceRef(returnFiber, current, element) {
// 会用_stringRef给函数做标记,如果相同则直接返回原来的函数引用
if (current !== null && current.ref !== null && typeof current.ref === 'function' && current.ref._stringRef === stringRef) {
return current.ref;
}

var ref = function (value) {
var refs = inst.refs;

if (refs === emptyRefsObject) {
// This is a lazy pooled frozen object, so we need to initialize.
refs = inst.refs = {};
}

if (value === null) {
delete refs[stringRef];
} else {
refs[stringRef] = value;
}
};

ref._stringRef = stringRef;
return ref;
}

事实上并不是每次创建或更新这两个函数都会执行

react-reconciler/src/ReactFiberWorkLoop.js

commitDetachRef 执行位置

每次都设为null,是防止内存泄漏 如果 ref 每次绑定一个全新的 对象(Ref.current,callback)上,而不清理对旧的 dom节点 或者 类实例 的引用,则可能会产生内存泄漏。

1
2
3
4
5
6
7
8
function commitMutationEffects(){
if (effectTag & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
}

commitAttachRef 执行位置

1
2
3
4
5
function commitLayoutEffects(){
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
}

想要挂载Ref,是必须要打上effectTag的标签,所以只有在Ref改变的时候才会更新

react-reconciler/src/ReactFiberBeginWork.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function markRef(current, workInProgress) {
var ref = workInProgress.ref;

if (
// fiber初始化的时候,且定义了ref属性
current === null && ref !== null
// fiber更新的时候,ref对象的引用已经改变
|| current !== null && current.ref !== ref
) {
// Schedule a Ref effect
workInProgress.flags |= Ref;
}
}

所以绑定匿名函数的写法,会导致函数每次都执行,因为函数的引用不一样

1
2
3
4
5
// 可以把函数定义为类的方法
<div ref={(node)=>{
this.node = node
console.log('此时的参数是什么:', this.node )
}} >ref元素节点</div>

被卸载的 fiber 会被打成 Deletion effect tag ,然后在 commit 阶段会进行 commitDeletion 流程。对于有 ref 标记的 ClassComponent (类组件) 和 HostComponent (元素),会统一走 safelyDetachRef 流程,这个方法就是用来卸载 ref。

react-reconciler/src/ReactFiberCommitWork.js

1
2
3
4
5
6
7
8
9
10
function safelyDetachRef(current) {
const ref = current.ref;
if (ref !== null) {
if (typeof ref === 'function') { // 函数式 | 字符串
ref(null)
} else {
ref.current = null; // ref 对象
}
}
}

实现一个CLI工具

CLI与GUI

CLI(Command Line Interface) 命令行接口, 在服务器端通常是没有可视化界面的,所有的操作都是在黑窗口的命令行中操作。

GUI(Graphical User Interface)图形用户界面接口, 通过可视化的界面, 可以避免CLI中的命令操作, 某些场景可以增加效率,减少学习成本, 例如 mysql-workbanch 提供的可视化数据库管理工具,或者是 GitHub for Desktop 一个基于 git 命令的 GUI 工具。

而 node 中的 CLI 工具就是通过命令行的方式,可以让我们快速根据交互中输入的配置初始化项目。或实现其他工具。

必备npm包

  • commander 完整的 node.js 命令行解决方案。

option 用于定义选项, 默认提供-h选项,可以查看命令行当前的命令提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
program
// 通过 node xxx-cli.js -v 可以查看指定版本
.version(require('../package').version, "-v, --version")

// 可以修改首行的信息
// 默认 Usage: sun-cra-cli [options] [command]
// 修改为: sun-cra-cli [options-my] [command-my]
.usage('<options-my> [command-my]')

// 如果指定了-h 选项,默认-h 选项会被覆盖
// .option('-h, --help', 'help information')
.option('-s, --small', 'small pizza size')
.option('-p, --pizza-type <type>', 'flavour of pizza')

// 一定要放在参数处理的逻辑之前,否则不能执行
program.parse(process.argv);

// 获取选项执行其他的逻辑
const options = program.opts();
if (options.small) console.log('- small pizza size');
if (options.pizzaType) console.log(`- ${options.pizzaType}`);

如果参数不全可以手动打印提示信息

1
2
3
4
5
function help () {
program.parse(process.argv)
if (program.args.length < 1) return program.help()
}
help()

command 定义命令

当执行 node xxx-cli.js 会打印所有的提示信息

当执行 node xxx-cli.js init 会自动执行全局注册的 xxx-cli-init.js

1
2
program
.command('init', 'generate a new project from a template')

打印:

1
2
3
4
5
6
7
8
Usage: sun-cra-cli [options] [command]

Options:
-h, --help display help for command

Commands:
init generate a new project from a template
help [command] display help for command

也可以让命令有可选参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
program
// 如果想让命令带上参数,就不能把命令描述写在第二个参数上,要用description方法
.command('init')
.description('clone a repository into a newly created directory')
// 两个都是必选参数
.argument('<username>', 'user to login')
.argument('<password>', 'password for user, if required')

// 这时执行 `node xxx-cli.js init` 并不会自动执行init命令所对应的文件
// 需要在action中处理执行逻辑
.action((username, password) => {
console.log('username:', username);
console.log('password:', password);
});

program.parse(process.argv);

  • chalk 一个可以让命令行带上颜色工具

  • Inquirer 交互式命令行用户界面。可以收集用户的输入

  • ora 一个终端加载过度效果

连续调用可以输出多行信息

1
2
3
4
5
6
7
8
9
spinner.start('waiting')
spinner.succeed('successfully')
spinner.start('waiting')
spinner.succeed('successfully')

/**
✔ Initialization successful.
✔ Initialization successful.
*/
  • boxen 可以在终端展示矩形框

收集信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env node
const { program } = require("commander");

/**
* Usage: create-bigdata-frontend [options] <name>
* name 项目名称 --ts 是否使用ts模板
*/
program
.version(require('../package').version, "-v, --version") // 定义版本选项
// .command('create [name]', 'create a project') // 定义命令+描述
.arguments("<name>") // 定义命令参数
.option("--ts", "using the typescript template") // 定义可提供的选项+描述: 是否使用ts
.description("Create a project", { name: "Project name" }) // 描述+参数,描述
.action((name, options, command) => { // 处理函数:(命令声明的所有参数, 选项, 命令对象自身)
require("../lib/create.js")(name, options && options.ts);
})
.program.parse();

初始化逻辑

1
2
3
4
5
6
7
8
const CLIManager = require("./CLIManager");
module.exports = async (appName, ts) => {
const cliM = new CLIManager({ appName });
await cliM.downloadTemplate('https//:xxx.xxx.xxx/xx.git'); // 获取远程模板
await cliM.writePackageJson(); // 修改package.json
await cliM.rmGit(); // 移除原有.git信息
await cliM.install(); // 安装依赖
};

CLIManager类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
module.exports = class CLIManager {
constructor({ appName }) {
this.appName = appName;
// 获取当前命令执行是的目录
this.cwd = process.cwd();
this.targetDir = path.join(process.cwd(), appName);
}

// 执行命令
run(command, options, cb) {
exec(command, options, (error, stdout, stderr) => {
if (error !== null) {
console.log(chalk.red("X"), "exec error: " + error);
return;
}
cb(stdout);
});
}

// 拉取远程模板
downloadTemplate(repositiry) {
return new Promise((resolve, reject) => {
exec(
`git clone https://github.com/jquery/jquery.git ${this.appName}`,
(error, stdout, stderr) => {
if (error !== null) {
spinner.fail(`Failed fetching remote git repo`);
reject(error);
return;
}
resolve(stdout);
}
);
});
}

// 删除.git
rmGit() {
return new Promise((resolve) => {
this.run("rm -rf .git", { cwd: this.targetDir }, (stdout) => {
resolve(stdout);
});
});
}

// 安装依赖
install() {
return new Promise((resolve) => {
this.run("npm ci", { cwd: this.targetDir }, (stdout) => {
resolve(stdout);
});
});
}
};

npm-lock的作用

版本被修改了?

很久很久以前,你创建了一个项目叫做 ProjectA, 并且引入了 jquery

npm view jquery versions 查看了jquery版本 ,考虑许久之后,决定安装最新版,当时的最新版本是 2.1.0,执行了 npm install -S jquery 之后,在两个文件中生成了版本信息

package.json

1
2
3
4
5
6
{
"dependencies": {
"jquery": "^2.1.0"
}
}

package-lock.json

1
2
3
4
5
6
7
8
9
{
"dependencies": {
"jquery": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-2.1.0.tgz",
"integrity": "sha1-HJqMlx0rU9rhDXLhbLtaHfFqSs4="
}
}
}

时光飞逝,虽然看不懂这两个文件的意思,项目圆满的结束了。

多年以后,一个新项目的经理想到了你曾经做过的项目,让你把项目拿过来参考一下。

于是你拉取了项目,发现项目里只用到了一个依赖就是 jquery, 于是你在命令行输入了 npm install jquery

安装成功之后,你惊讶的发现安装的版本为什么和拉取代码的版本不是同一个,而是拉取的代码版本号中,大版本中的最后一个版本呢

~ 会匹配最近的小版本依赖包,比如~1.2.3会匹配所有1.2.x版本,但是不包括1.3.0
^ 会匹配最新的大版本依赖包,比如^1.2.3会匹配所有1.x.x的包,包括1.3.0,但是不包括2.0.0
* 这意味着安装最新版本的依赖包

这时的 package.json 文件变成:

1
2
3
4
5
{
"dependencies": {
"jquery": "^2.2.4"
}
}

package-lock.json

1
2
3
4
5
6
7
8
9
{
"dependencies": {
"jquery": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
"integrity": "sha1-HJqMlx0rU9rhDXLhbLtaHfFqSs4="
}
}
}

你查阅了资料之后发现这是 npm 有意为之,因为 package.json 中 jquery 的版本是 ^2.1.0, 当使用 npm install 安装时会安装大版本相同的最新版本, 也就解释了为什么版本号会变成 2.2.4

lock文件解决的问题

那这个lock文件的版本又表示什么呢?简单说就是锁住你曾经安装过的包的版本

当通过 npm install xxx@xx.xx.xx 安装某个包时(如果没有指定版本则安装最新版本呢), 会在 package.json 中生成安装的包的版本信息,也会在 package-lock.json 中生成相同的版本信息

但是 package.json 中的版本前面会带着一个符号,它表示的是一个版本范围,以上面的^2.1.0 为例,表示的大版本为2的,高于或等于2.1.0的其他版本

当你想通过 npm install 初始化项目依赖的时候,他会去找 package-lock.json中锁住的版本,如果锁住的版本恰好在 package.json 指定的范围内,就会安装锁住的版本,否则安装版本范围内的最新版本,并且覆盖原有的版本信息

package.json

1
2
3
4
5
6
{
"dependencies": {
"jquery": "^2.1.0"
}
}

package-lock.json

1
2
3
4
5
6
7
8
9
10
{
"dependencies": {
"jquery": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz",
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw=="
}
}
}

因为 package-lock.json 的版本在 package.json 指定范围内,所以会安装 2.2.1 版本

package.json

1
2
3
4
5
6
{
"dependencies": {
"jquery": "^3.0.0"
}
}

package-lock.json

1
2
3
4
5
6
7
8
9
10
11
12
{
"dependencies": {
"jquery": {
"version": "2.2.1",
// 被重写为
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz",
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw=="
}
}
}

因为不在版本范围内,所以安装了版本范围中的最新版本 3.6.0

不想改变版本

那有没有一种办法可以只安装当时的版本呢? 让我们更好的固定版本

npm 提供了 npm ci 的命令, 当通过npm ci xxx 安装包时,如果锁住的版本在版本范围内,就会安装锁住的版本,否则就会抛出错误停止安装

npm ci 命令必须依赖于 package-lock.json 如果没有这个文件就会报错,可以使用 npm install 代替

注意

在没有 package-lock.json 文件的时候,通过npm install 初始化项目依赖,会安装版本范围内的最新版本, 在生成的 package-lock.json 会记录版本信息,而且会覆盖package.json 中的版本

StyleLint配置指南

基础包

stylelint 有力的,现代的 lint 工具,帮助你在 style 中避免错误, 按照约定转换会规则。

stylelint-config-standard stylelint 配置共享库,可以通过 rules 覆盖规则

stylelint-order 一个为 stylelint 规则排序的插件包

stylelint-config-sass-guidelines 如果你写 SCSS 可以用这个包

stylelint-scss 一个SCSS的规则集合

配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
{
"extends": "stylelint-config-sass-guidelines",
"plugins": [
"stylelint-scss",
"stylelint-order"
],
"rules": {
"order/properties-order": [
"position",
"top",
"right",
"bottom",
"left",
"z-index",
"display",
"justify-content",
"align-items",
"float",
"clear",
"overflow",
"overflow-x",
"overflow-y",
"margin",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"border",
"border-style",
"border-width",
"border-color",
"border-top",
"border-top-style",
"border-top-width",
"border-top-color",
"border-right",
"border-right-style",
"border-right-width",
"border-right-color",
"border-bottom",
"border-bottom-style",
"border-bottom-width",
"border-bottom-color",
"border-left",
"border-left-style",
"border-left-width",
"border-left-color",
"border-radius",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"width",
"min-width",
"max-width",
"height",
"min-height",
"max-height",
"font-size",
"font-family",
"font-weight",
"text-align",
"text-justify",
"text-indent",
"text-overflow",
"text-decoration",
"white-space",
"color",
"background",
"background-position",
"background-repeat",
"background-size",
"background-color",
"background-clip",
"opacity",
"filter",
"list-style",
"outline",
"visibility",
"box-shadow",
"text-shadow",
"resize",
"transition"
]
}
}

忽略配置

  • 忽略整个文件,在首行加入 /* stylelint-disable */
1
2
/* stylelint-disable */
html {}
  • 忽略多行
1
2
3
4
5
6
/* stylelint-disable */
html {}
.div {
color: red;
}
/* stylelint-enable */
  • 忽略一行, 在样式前加入 /* stylelint-disable-next-line */ 以忽略该行
1
2
3
4
#id {
/* stylelint-disable-next-line */
color: pink !important;
}
  • .stylelintrc.json 配置文件

自动格式化

  • 安裝 StyleLint

  • 在 settings.json 文件设置

1
2
3
4
5
{
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": true
}
}

与 Prettier 结合

stylelint-prettier 让 Prettier 作为 StyleLint 的规则,并让 StyleLint 统一报错

stylelint-config-prettier 关闭所有可能冲突的配置

配置文件

1
2
3
4
5
6
7
8
9
{
"extends": [
"stylelint-config-sass-guidelines",
"stylelint-prettier/recommended"
],
"plugins": [
"stylelint-scss"
]
}

Prettire配置指南

基础库

prettier 定义并实现了基本规则

eslint-config-prettier 关闭所有可能和 prettier 有冲突的规则

eslint-plugin-prettier 屏蔽了冲突规则之后,仍然想让eslint统一报错信息

prettier-eslint 可以通过 eslint --fix 来使用 prettier 格式化代码

prettier-eslint-cli 以 cli 方式执行prettier-eslint

Prettier 影响的规则

规则

Prettier 配置文件

一共有三种方式支持对 Prettier 进行配置:

  • 根目录创建.prettierrc 文件,能够写入 YML、JSON 的配置格式,并且支持.yaml/.yml/.json/.js 后缀;
  • 根目录创建.prettier.config.js 文件,并对外 export 一个对象;
  • 在 package.json 中新建 prettier 属性。

更多配置

1
2
3
4
5
6
{
"singleQuote": true,
"semi": true,
"printWidth": 80,
"useTabs": false
}

与 ESLint 结合

安装 prettier 插件

ESLint 配置指南

ESLint配置指南

基础包

ESLint: lint 代码的主要工具,所以的一切都是基于此包

解析器(parser)

babel-eslint 已经变更为 @babel/eslint-parser: 该依赖包允许你使用一些实验特性的时候,依然能够用上 ESlint 语法检查。

@typescript-eslint/parser: 与@babel/eslint-parser类似,如果你使用 typescript,需要使用 typescript 专有的解析器

扩展的配置

eslint-config-airbnb: 提供了 Airbnb 的 eslintrc 作为可扩展的共享配置。默认导出包含我们所有的 ESLint 规则,包括 ECMAScript 6+ 和 React。引入了 eslinteslint-plugin-importeslint-plugin-reacteslint-plugin-react-hooks,和 eslint-plugin-jsx-a11y。如果您不需要 React,请使用eslint-config-airbnb-base

eslint-config-jest-enzyme: 只用当你使用jest-environment-enzyme 这个库的时候,这个扩展才会有效,使用 jest-environment-enzyme 时有一些全局变量,这个规则可以让 eslint 不报警告。

插件

eslint-plugin-babel 已经变更为 @babel/eslint-plugin: 和 babel-eslint 一起用的一款插件.babel-eslint 在将 eslint 应用于 Babel 方面做得很好,但是它不能更改内置规则来支持实验性特性。eslint-plugin-babel 重新实现了有问题的规则,因此就不会误报一些错误信息

eslint-plugin-import 该插目的是要支持对 ES2015+ (ES6+) import/export 语法的校验, 并防止一些文件路径拼错或者是导入名称错误的情况

eslint-plugin-jsx-a11y 在 JSX 元素上,对可访问性规则进行静态 AST 检查。

eslint-import-resolver-webpack 在 webpack 项目之中, 我们会借助 alias 别名提升代码效率和打包效率。但是在使用了自定义的路径指向后,eslint 就会对应产生找不到模块的报错。这时候就需要eslint-import-resolver-webpack

eslint-import-resolver-typescript 和 eslint-import-resolver-webpack 类似,主要是为了解决 alias 的问题

eslint-plugin-react React 专用的校验规则插件.

eslint-plugin-jest Jest 专用的 Eslint 规则校验插件.

eslint-plugin-prettier 该插件辅助 Eslint 可以平滑地与 Prettier 一起协作,并将 Prettier 的解析作为 Eslint 的一部分,在最后的输出可以给出修改意见。这样当 Prettier 格式化代码的时候,依然能够遵循我们的 Eslint 规则。如果你禁用掉了所有和代码格式化相关的 Eslint 规则的话,该插件可以更好得工作。所以你可以使用 eslint-config-prettier 禁用掉所有的格式化相关的规则(如果其他有效的 Eslint 规则与 prettier 在代码如何格式化的问题上不一致的时候,报错是在所难免的了)

@typescript-eslint/eslint-plugin Typescript 辅助 Eslint 的插件。

eslint-plugin-promise promise 规范写法检查插件,附带了一些校验规则。

其他工具

husky git 命令 hook 专用配置.

lint-staged 可以定制在特定的 git 阶段执行特定的命令。

ESLint 配置文件

ESLint 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
module.exports =  {
// 表示eslint检查只在当前目录生效
root:true,

// 默认ESlint使用Espree作为解析器,但是一旦我们使用babel的话,我们需要用@babel/eslint-parser。
// 如果使用TS,则使用 @typescript-eslint/parser
// Specifies the ESLint parser
parser: '@babel/eslint-parser',

parserOptions: {
// ecmaVersion: 默认值是5,可以设置为3、5、6、7、8、9、10,用来指定使用哪一个ECMAScript版本的 // 语法。也可以设置基于年份的JS标准,比如2015(ECMA 6),也可以设置 latest 使用最近支持的版本
// specify the version of ECMAScript syntax you want to use: 2015 => (ES6)
ecmaVersion: 'latest',
// 如果你的代码是ECMAScript 模块写的,该字段配置为module,否则为script(默认值)
sourceType: 'module', // Allows for the use of imports
// 额外的语言特性
ecmaFeatures: {
jsx: true, // enable JSX
impliedStrict: true // enable global strict mode
},
// babel 文件路径
babelOptions: {
configFile: './.babelrc',
},
},

// 指定扩展的配置,配置支持递归扩展,支持规则的覆盖和聚合。
extends: [
// // Uses airbnb, it including the react rule(eslint-plugin-react/eslint-plugin-jsx-a11y)
'airbnb',
// prettier规则额放在最后需要覆盖默认规则
'plugin:prettier/recommended',
],

// 字段定义的数据可以在所有的插件中共享。这样每条规则执行的时候都可以访问这里面定义的数据
settings: {
'import/resolver': { // This config is used by eslint-import-resolver-webpack
webpack: {
config: './webpack/webpack-common-config.js'
}
},
},
// 环境可以提供的全局变量
env: {
// enable all browser global variables
browser: true
},

// 配置那些我们想要Linting规则的插件。
// plugins: ['react-hooks', 'promise'],

// 自定义规则,可以覆盖掉extends的配置。
rules: {
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
};

VSCode 使用 eslint 自动修复

  • 下载插件 eslint

  • setting.json开启 eslint 自动修复配置

1
2
3
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}

ESLint 和 Prettier 区别

ESLint 是什么呢?

是一个开源的 JavaScript 的 linting 工具,使用 espree 将 JavaScript 代码解析成抽象语法树 (AST),然后通过AST 来分析我们代码,从而给予我们两种提示:

  • 代码质量问题:使用方式有可能有问题(problematic patterns)
  • 代码风格问题:风格不符合一定规则 (doesn’t adhere to certain style guidelines)
    (这里的两种问题的分类很重要,下面会主要讲)

你可能开始为了缩进问题配置了一个规则

1
2
3
4
// .eslintrc    
{
"indent": ["error", 2]
}
  • 还安装了 ESLint 的 VSCode 插件,没有通过 ESLint 校验的代码 VSCode 会给予下滑波浪线提示。

  • 为了万无一失,你还添加一个 pre-commit 钩子 eslint --ext .js src,确保没有通过 lint 的代码不会被提交。

  • 更让人开心的是,之前不统一的代码也能通过 eslint –fix 来修改成新的格式。

Airbnb Style Guide

配置完了缩进之后你可能又会发现有人写大括号的时候不会换行。最终你找到了一个和你有一样困惑的公司Airbnb,并且他们自行讨论出一套完整的校验规则。你 installeslint-config-airbnb ,并且将 .eslintrc 文件改成了下面这样,终于大功告成。

1
2
3
4
// .eslintrc
{
"extends": ["airbnb"]
}

Prettier

上面我们说到,ESLint 主要解决了两类问题,但其实 ESLint 主要解决的是代码质量问题。另外一类代码风格问题其实 Airbnb JavaScript Style Guide 并没有完完全全做完,因为这些问题”没那么重要”,代码质量出问题意味着程序有潜在 Bug,而风格问题充其量也只是看着不爽。

  • 代码质量规则 (code-quality rules)

  • no-unused-vars

  • no-extra-bind

  • no-implicit-globals

  • prefer-promise-reject-errors

  • 代码风格规则 (code-formatting rules)

  • max-len

  • no-mixed-spaces-and-tabs

  • keyword-spacing

  • comma-style

这时候就出现了 Prettier,Prettier 声称自己是一个有主见 (偏见) 的代码格式化工具 (opinionated code formatter),Prettier 认为格式很重要,但是格式好麻烦,我来帮你们定好吧。简单来说,不需要我们再思考究竟是用 single quote,还是 double quote 这些乱起八糟的格式问题,Prettier 帮你处理。最后的结果,可能不是你完全满意,但是,绝对不会丑,况且,Prettier 还给予了一部分配置项,可以通过 .prettierrc 文件修改。

所以相当于 Prettier 接管了两个问题其中的代码格式的问题,而使用 Prettier + ESLint 就完完全全解决了两个问题。但实际上使用起来配置有些小麻烦,但也不是什么大问题。因为 Prettier 和 ESLint 一起使用的时候会有冲突,所以

首先我们需要使用 eslint-config-prettier 来关掉 (disable) 所有和 Prettier 冲突的 ESLint 的配置(这部分配置就是上面说的,格式问题的配置,所以关掉不会有问题),方法就是在 .eslintrc 里面将 prettier 设为最后一个 extends

1
2
3
4
5
// .eslintrc
{
"extends": ["prettier"] // prettier 一定要是最后一个,才能确保覆盖
}

然后再启用 eslint-plugin-prettier ,将 prettier 的 rules 以插件的形式加入到 ESLint 里面。这里插一句,为什么”可选” ?当你使用 Prettier + ESLint 的时候,其实格式问题两个都有参与,disable ESLint 之后,其实格式的问题已经全部由 prettier 接手了。那我们为什么还要这个 plugin?其实是因为我们期望报错的来源依旧是 ESLint ,使用这个,相当于把 Prettier 推荐的格式问题的配置以 ESLint rules 的方式写入,这样相当于可以统一代码问题的来源。

1
2
3
4
5
6
7
8
// .eslintrc    
{
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error"
}
}

将上面两个步骤和在一起就是下面的配置,也是官方的推荐配置

1
2
3
4
// .eslintrc
{
"extends": ["plugin:prettier/recommended"]
}
  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信