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"]
}

React原理 生命周期

预备

React 有两个重要阶段,render 阶段和 commit 阶段,React 在调和( render )阶段会深度遍历 React fiber 树,目的就是发现不同( diff ),不同的地方就是接下来需要更新的地方,对于变化的组件,就会执行 render 函数。在一次调和过程完毕之后,就到了commit 阶段,commit 阶段会创建修改真实的 DOM 节点。

类组件的处理逻辑在beginWork中被调用,react-reconciler/src/ReactFiberBeginWork.js

① instance 类组件对应实例。
② workInProgress 树,当前正在调和的 fiber 树 ,一次更新中,React 会自上而下深度遍历子代 fiber ,如果遍历到一个 fiber ,会把当前 fiber 指向 workInProgress。
③ current 树,在初始化更新中,current = null ,在第一次 fiber 调和之后,会将 workInProgress 树赋值给 current 树。React 来用workInProgress 和 current 来确保一次更新中,快速构建,并且状态不丢失。
④ Component 就是项目中的 class 组件。
⑤ nextProps 作为组件在一次更新中新的 props 。
⑥ renderLanes 作为下一次渲染的优先级。

在组件实例上可以通过 _reactInternals 属性来访问组件对应的 fiber 对象。在 fiber 对象上,可以通过 stateNode 来访问当前 fiber 对应的组件实例。

class Instance . _reactInternals => class Fiber

class Fiber . stateNode => class Instance

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
function updateClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
) {
// stateNode 是 fiber 指向 类组件实例的指针。
const instance = workInProgress.stateNode;
let shouldUpdate;
// instance 为组件实例,如果组件实例不存在,证明该类组件没有被挂载过,那么会走初始化流程
if (instance === null) {
// 在这个方法中组件通过new被实例化
constructClassInstance(workInProgress, Component, nextProps);
// 初始化挂载组件流程
mountClassInstance(workInProgress, Component, nextProps, renderLanes);
// shouldUpdate 标识用来证明 组件是否需要更新。
shouldUpdate = true;
} else if (current === null) {
// 已经存在了一个实例可以被复用
shouldUpdate = resumeMountClassInstance(
workInProgress,
Component,
nextProps,
renderLanes,
);
} else {
// 更新组件流程
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderLanes,
);
}

const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderLanes,
);
return nextUnitOfWork;
}

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 finishClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
shouldUpdate: boolean,
hasContext: boolean,
renderLanes: Lanes,
) {
// 即使 shouldComponentUpdate 返回了 false,Refs也应该被更新
markRef(current, workInProgress);

const instance = workInProgress.stateNode;

// Rerender
ReactCurrentOwner.current = workInProgress;
// 获取子节点
let nextChildren = instance.render();

// 调和子节点
reconcileChildren(current, workInProgress, nextChildren, renderLanes);

// Memoize state using the values we just used to render.
// TODO: Restructure so we never read values from the instance.
workInProgress.memoizedState = instance.state;

// The context might have changed so we need to recalculate it.
if (hasContext) {
invalidateContextProvider(workInProgress, Component, true);
}

return workInProgress.child;
}

初始化阶段

constructClassInstance构建了组件的实例,在实例化组件之后,会调用 mountClassInstance 组件初始化。

react-reconciler/src/ReactFiberClassComponent.js

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
// 在从没有渲染过的实例上执行挂载生命周期
function mountClassInstance(
workInProgress: Fiber,
ctor: any,
newProps: any,
renderLanes: Lanes,
): void {
// 组件实例
const instance = workInProgress.stateNode;
instance.props = newProps;
instance.state = workInProgress.memoizedState;
instance.refs = emptyRefsObject;

initializeUpdateQueue(workInProgress);

// 拿到类组件构造函数的静态方法
const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
if (typeof getDerivedStateFromProps === 'function') {
var prevState = workInProgress.memoizedState;
// 返回更新之后的state
var partialState = getDerivedStateFromProps(nextProps, prevState);
// 如果返回的state不合法,使用原有状态,否则合并两个状态生成一个新的state对象
var memoizedState = partialState === null || partialState === undefined ? prevState : _assign({}, prevState, partialState);
workInProgress.memoizedState = memoizedState;

// Once the update queue is empty, persist the derived state onto the
// base state.
if (workInProgress.lanes === NoLanes) {
// Queue is always non-null for classes
var updateQueue = workInProgress.updateQueue;
updateQueue.baseState = memoizedState;
}
instance.state = workInProgress.memoizedState;
}

if (typeof instance.componentDidMount === 'function') {
workInProgress.flags |= fiberFlags;
}
}

render 函数执行

到此为止 mountClassInstance 函数完成,但是上面 updateClassComponent 函数, 在执行完 mountClassInstance 后,执行了 render 渲染函数,形成了 children , 接下来 React 调用 reconcileChildren 方法深度调和 children

componentDidMount函数执行

上述提及的几生命周期都是在 render 阶段执行的。一旦 React 调和完所有的 fiber 节点,就会到 commit 阶段,在组件初始化 commit 阶段,会调用 componentDidMount 生命周期。

1
2
3
4
function commitRootImpl(root, renderPriorityLevel){
const finishedWork = root.finishedWork;
commitLayoutEffects(finishedWork, root, lanes);
}

17.0.2

1
2
3
4
5
6
7
8
9
10
11
12
function commitLifeCycles(finishedRoot,current,finishedWork){
switch (finishedWork.tag){ /* fiber tag 在第一节讲了不同fiber类型 */
case ClassComponent: { /* 如果是 类组件 类型 */
const instance = finishedWork.stateNode /* 类实例 */
if(current === null){ /* 类组件第一次调和渲染 */
instance.componentDidMount()
}else{ /* 类组件更新 */
instance.componentDidUpdate(prevProps,prevState,instance.__reactInternalSnapshotBeforeUpdate);
}
}
}
}

17.0.3

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 commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
switch (finishedWork.tag) {
case ClassComponent: {
const instance = finishedWork.stateNode;
if (!offscreenSubtreeWasHidden) {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
try {
startLayoutEffectTimer();
instance.componentDidMount();
} finally {
recordLayoutEffectDuration(finishedWork);
}
} else {
instance.componentDidMount();
}

commitUpdateQueue(finishedWork, updateQueue, instance);
}
break;
}
}
}

更新阶段

回到了最开始 updateClassComponent 函数了,当发现 current 不为 null 的情况时,说明该类组件被挂载过,那么直接按照更新逻辑来处理。

react-reconciler/src/ReactFiberClassComponent.js

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 updateClassInstance(current,workInProgress,ctor,newProps,renderExpirationTime){
// 类组件实例
const instance = workInProgress.stateNode;
// 判断是否具有 getDerivedStateFromProps 生命周期
const hasNewLifecycles = typeof ctor.getDerivedStateFromProps === 'function'
if(!hasNewLifecycles && typeof instance.componentWillReceiveProps === 'function' ){
// 浅比较 props 不相等
if (oldProps !== newProps || oldContext !== nextContext) {
// 执行生命周期 componentWillReceiveProps
instance.componentWillReceiveProps(newProps, nextContext);
}
}
let newState = (instance.state = oldState);
if (typeof getDerivedStateFromProps === 'function') {
/* 执行生命周期getDerivedStateFromProps ,逻辑和mounted类似 ,合并state */
ctor.getDerivedStateFromProps(nextProps,prevState)
newState = workInProgress.memoizedState;
}
let shouldUpdate = true
/* 执行生命周期 shouldComponentUpdate 返回值决定是否执行render ,调和子节点 */
if(typeof instance.shouldComponentUpdate === 'function' ){
shouldUpdate = instance.shouldComponentUpdate(newProps,newState,nextContext,);
}
if(shouldUpdate){
if (typeof instance.componentWillUpdate === 'function') {
/* 执行生命周期 componentWillUpdate */
instance.componentWillUpdate();
}
return shouldUpdate
}

getSnapshotBeforeUpdate 的执行也是在 commit 阶段,commit 阶段细分为 before Mutation( DOM 修改前),Mutation ( DOM 修改),Layout( DOM 修改后) 三个阶段,getSnapshotBeforeUpdate 发生在before Mutation 阶段

销毁阶段

在一次调和更新中,如果发现元素被移除,就会打对应的 Deletion 标签 ,然后在 commit 阶段就会调用 componentWillUnmount 生命周期,接下来统一卸载组件以及 DOM 元素。

1
2
3
4
5
6
7
8
var callComponentWillUnmountWithTimer = function (current, instance) {
instance.props = current.memoizedProps;
instance.state = current.memoizedState;

{
instance.componentWillUnmount();
}
};

各生命周期最佳实践

constructor
1
2
3
4
5
6
7
8
9
10
11
12
constructor(props){
super(props) // 执行 super ,别忘了传递props,才能在接下来的上下文中,获取到props。
this.state={ //① 可以用来初始化state,比如可以用来获取路由中的
name:'alien'
}
this.handleClick = this.handleClick.bind(this) /* ② 绑定 this */
this.handleInputChange = debounce(this.handleInputChange , 500) /* ③ 绑定防抖函数,防抖 500 毫秒 */
const _render = this.render
this.render = function(){
return _render.bind(this) /* ④ 劫持修改类组件上的一些生命周期 */
}
}
UNSAFE_componentWillMount

在新版本的react(v16.3)中componentWillMount已经变更为UNSAFE_componentWillMount,而且不在推荐使用,其中很大一部分原因是经常被滥用

  • 初始化状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ExampleComponent extends React.Component {
constructor(props){
this.state = {
color: "red"
};
}
state = {
color: "red"
};
componentWillMount() {
// 应该将初始化状态放到构造函数或属性的初始化状态中
// this.setState({
// color: "red"
// });
}
}
  • 获取异步的外部数据
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
// Before
class ExampleComponent extends React.Component {
state = {
externalData: null,
};

componentWillMount() {
this._asyncRequest = loadMyAsyncData().then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}

componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}

render() {
if (this.state.externalData === null) {
// 渲染加载状态 ...
} else {
// 渲染真实 UI ...
}
}
}

上述代码对于服务器渲染(异步的请求数据不会被放到state中)和即将推出的异步渲染模式(可能执行多次)都存在问题。通常会把上面的操作放到 componentDidMount

另一个问题是,componentWillMount的名字比较反直觉,听起来觉得在这个生命周期中获取数据,可以避免第一次render的时候进行一次空渲染,单实际上 componentWillMount执行后 render方法会立即执行,如果componentWillMount 没有获取到可用数据,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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// This is an advanced example! It is not intended for use in application code.
// Libraries like Relay may make use of this technique to save some time on low-end mobile devices.
// Most components should just initiate async requests in componentDidMount.

class ExampleComponent extends React.Component {
_hasUnmounted = false;

state = {
externalData: null,
};

constructor(props) {
super(props);

// Prime an external cache as early as possible.
// Async requests are unlikely to complete before render anyway,
// So we aren't missing out by not providing a callback here.
asyncLoadData(this.props.someId);
}

componentDidMount() {
// Now that this component has mounted,
// Wait for earlier pre-fetch to complete and update its state.
// (This assumes some kind of external cache to avoid duplicate requests.)
asyncLoadData(this.props.someId).then(externalData => {
if (!this._hasUnmounted) {
this.setState({ externalData });
}
});
}

componentWillUnmount() {
this._hasUnmounted = true;
}

render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
}
  • 事件监听
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Before
class ExampleComponent extends React.Component {
componentWillMount() {
this.setState({
subscribedValue: this.props.dataSource.value,
});
// 这是不安全的,它会导致内存泄漏!
this.props.dataSource.subscribe(
this.handleSubscriptionChange
);
}

componentWillUnmount() {
this.props.dataSource.unsubscribe(
this.handleSubscriptionChange
);
}

handleSubscriptionChange = dataSource => {
this.setState({
subscribedValue: dataSource.value,
});
};
}

上面的代码在服务端可能永远不会调用 componentWillUnmount, 或者在渲染完成之前可能被中断,导致不调用 componentWillUnmount,这两种场景都可能导致内存泄露,推荐的做法是移到componentDidMount

订阅的触发,导致属性和状态的改变,ReduxMobX 会帮助我们实现,对于应用开发场景可以使用 create-subscription, 在这里可以看到源码分析。

UNSAFE_componentWillReceiveProps getDerivedStateFromProps

首先明确一下这个两个方法在使用时,最常见的错误

  1. 直接复制 props 到 state 上
  2. 如果 props 和 state 不一致就更新 state
  3. 经常被误解只有props改变时这两个方法才会调用,实际上只要父组件重新渲染这两个方法就会被调用

想说清楚造成这两个错误的原因,需要先了解一个概念叫做 受控

受控非受控通常用来指代表单的 inputs,但是也可以用来描述数据频繁更新的组件。如果组件完全依赖于外部传入的props,可以认为是受控状态,因为组件完全被父组件的props控制。如果组件的状态只保存在组件(state)内部,可以认为是非受控的,因为组件有自己的状态,不受父组件的控制。

而组件中一旦将两种模式混为一谈(同时包含propsstate)就会造成问题

直接复制 props 到 state 上造成的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class EmailInput extends Component {
state = { email: this.props.email };

render() {
return <input onChange={this.handleChange} value={this.state.email} />;
}

handleChange = event => {
this.setState({ email: event.target.value });
};

componentWillReceiveProps(nextProps) {
// 这会覆盖所有组件内的 state 更新!
this.setState({ email: nextProps.email });
}
}

初看还觉得可以,但是问题很严重,当通过input的输入改变了组件的状态,这时如果父组件更新就会触发componentWillReceiveProps方法,会将state.email状态重写,覆盖了刚才通过input输入更新的状态,导致状态丢失,这是两种模式混用最明显的错误。在实际的使用中会有多个props属性,任意一个属性的更新都会导致内部状态可能被覆盖。

既然这样,可以很容易想到,能不能只用props来更新组件,不让组件有自己的内部状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class EmailInput extends Component {
state = {
email: this.props.email
};

componentWillReceiveProps(nextProps) {
// 只要 props.email 改变,就改变 state
if (nextProps.email !== this.props.email) {
this.setState({
email: nextProps.email
});
}
}
}

但是仍然有个问题。想象一下,如果这是一个密码输入组件,拥有同样 email 的两个账户(假设一个邮箱可以注册多个账户)进行切换时,这个输入框不会重置(用来让用户重新登录)。因为父组件传来的 prop 值没有变化!这会让用户非常惊讶,因为这看起来像是帮助一个用户分享了另外一个用户的密码

最佳实践:完全可控的组件

从组件里面删除state,完全让外部的props的接管组件的状态

最佳实践:有 key 的非可控组件

让组件自己存储临时的 email state。在这种情况下,组件仍然可以从 prop 接收“初始值”,但是更改之后的值就和 prop 没关系了

1
2
3
4
5
6
7
8
9
10
11
class EmailInput extends Component {
state = { email: this.props.defaultEmail };

handleChange = event => {
this.setState({ email: event.target.value });
};

render() {
return <input onChange={this.handleChange} value={this.state.email} />;
}
}

我们可以使用 key 这个特殊的 React 属性。当 key 变化时, React 会创建一个新的而不是更新一个既有的组件。 Keys 一般用来渲染动态列表,但是这里也可以使用。在这个示例里,当用户输入时,我们使用 user ID 当作 key 重新创建一个新的 email input 组件

不用为每次输入都添加 key,在整个表单上添加 key 更有位合理。每次 key 变化,表单里的所有组件都会用新的初始值重新创建。

1
2
3
4
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>

这听起来很慢,但是这点的性能是可以忽略的。如果在组件树的更新上有很重的逻辑,这样反而会更快,因为省略了子组件 diff。

备选:用 prop 的 ID 重置非受控组件

如果某些情况下 key 不起作用(可能是组件初始化的开销太大),一个麻烦但是可行的方案是在 getDerivedStateFromProps 观察 userID 的变化:、

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class EmailInput extends Component {
state = {
email: this.props.defaultEmail,
prevPropsUserID: this.props.userID
};

static getDerivedStateFromProps(props, state) {
// 只要当前 user 变化,
// 重置所有跟 user 相关的状态。
// 这个例子中,只有 email 和 user 相关。
if (props.userID !== state.prevPropsUserID) {
return {
prevPropsUserID: props.userID,
email: props.defaultEmail
};
}
return null;
}
}

getDerivedStateFromProps 的存在只有一个目的:让组件在 props 变化时更新 state。 代替了原来的componentWillReceiveProps

nextProps 父组件新传递的 props ;

你可能想知道为什么我们不将上一个 props 作为参数传递给 getDerivedStateFromProps。我们在设计 API 时考虑过这个方案,但最终决定不采用它,原因有两个:

  • prevProps 参数在第一次调用 getDerivedStateFromProps(实例化之后)时为 null,需要在每次访问 prevProps 时添加 if-not-null 检查。

  • 在 React 的未来版本中,不传递上一个 props 给这个方法是为了释放内存。(如果 React 无需传递上一个 props 给生命周期,那么它就无需保存上一个 props 对象在内存中。)

prevState 组件在此次更新前的 state

需要注意每次组件更新时getDerivedStateFromProps都会执行,无论是以哪那种方式更新

通常用于吧props混入state作为初始状态,合并后的state可以作为 shouldComponentUpdate 第二个参数 newState ,可以判断是否渲染组件。

1
getDerivedStateFromProps(nextProps,prevState)

总结

最重要的是确定组件是受控组件还是非受控组件。不要直接复制(mirror) props 的值到 state 中,而是去实现一个受控的组件,然后在父组件里合并两个值。

对于不受控的组件,当你想在 prop 变化(通常是 ID )时重置 state 的话,可以选择以下几种方式:

建议: 重置内部所有的初始 state,使用 key 属性
选项一:仅更改某些字段,观察特殊属性的变化(比如 props.userID)。

UNSAFE_componentWillUpdate getSnapshotBeforeUpdate

当组件收到新的 propsstate 时,会在渲染之前调用 UNSAFE_componentWillUpdate()。 有时人们使用 componentWillUpdate 是出于一种反直觉,当 componentDidUpdate 触发时,更新其他组件的 state 已经”太晚”了。事实并非如此。在UI渲染之前,componentWillUpdatecomponentDidUpdate中的state改变都将被记录。

1
getSnapshotBeforeUpdate(prevProps, prevState)

componentWillUpdate常见的错误是在生命周期中使用异步获取数据的方法,因为任何state的更新和父组件的重新渲染会触发componentWillUpdate重新执行,所有获取数据的方法可能被执行多次。相反,应该使用 componentDidUpdate 生命周期,因为它保证每次更新只调用一次。

1
2
3
4
5
6
7
8
9
10
class ExampleComponent extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (
this.state.someStatefulValue !==
prevState.someStatefulValue
) {
this.props.onChange(this.state.someStatefulValue);
}
}
}

更新前读取 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
class ListBox extends React.Component {
ref = React.createRef();
previousScrollOffset=0;
// 在列表更新的时候,读取DOM属性
componentWillUpdate(nextProps, nextState) {
// 当列列表个数被改变的时候计算偏移量
if (this.props.list.length < nextProps.list.length) {
this.previousScrollOffset =
this.ref.current.scrollHeight - this.ref.current.scrollTop;
}
}
// 在列表被挂载的时候修改DOM属性
componentDidUpdate(){
// previousScrollOffset !== 容器高度时(2px是边框高度),表示滚动条没有滚动到底部,可能在查看历史记录的状态
if(this.previousScrollOffset!== this.ref.current.offsetHeight-2) return;

// newScrollHeight - oldScrollHeight + lastScrollTop
// 相当于在上一次的scrollTop上加上ScrollHeight的增量
this.ref.current.scrollTop = (this.ref.current.scrollHeight - this.previousScrollOffset )
this.previousScrollOffset = 0;
}
render() {
return (<div style={{ width: 300, height: 200, overflow: 'auto', border: '1px solid' }} ref={this.ref}>
{this.props.list.map(item => <div style={{ height: 20 }}>{item.val}</div>)}
</div>)
}
}

在上面的示例中,componentWillUpdate 用于读取 DOM 属性。但是,对于异步渲染,“渲染”阶段的生命周期(如 componentWillUpdate 和 render)和”提交”阶段的生命周期(如 componentDidUpdate)之间可能存在延迟。如果用户在这段时间内调整窗口大小,那么从 componentWillUpdate 读取的 scrollHeight 值将过时。

这个问题的解决方案是使用新的“提交”阶段生命周期 getSnapshotBeforeUpdate。这个方法在发生变化 前立即 被调用(例如在更新 DOM 之前)。它可以返回一个 React 的值作为参数传递给 componentDidUpdate 方法,该方法在发生变化 后立即 被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ListBox extends React.Component {
ref = React.createRef();
getSnapshotBeforeUpdate(prevProps, nextState) {
if (this.props.list.length > prevProps.list.length) {
return this.ref.current.scrollHeight - this.ref.current.scrollTop;
}
}
componentDidUpdate(prevProps, prevState, snapshot){
if(snapshot>this.ref.current.offsetHeight) return;
this.ref.current.scrollTop = (this.ref.current.scrollHeight - snapshot )
console.log(this.ref.current.scrollTop);
}
render() {
return (<div style={{ width: 300, height: 200, overflow: 'auto', border: '1px solid' }} ref={this.ref}>
{this.props.list.map(item => <div style={{ height: 20 }}>{item.val}</div>)}
</div>)
}
}
componentDidMount

componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。依赖于 DOM 节点的初始化应该放在这里。如需通过网络请求获取数据,此处是实例化请求的好地方。

这个方法是比较适合添加订阅的地方。如果添加了订阅,请不要忘记在 componentWillUnmount() 里取消订阅

你可以在 componentDidMount() 里直接调用 setState()。它将触发额外渲染,但此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。请谨慎使用该模式,因为它会导致性能问题。通常,你应该在 constructor() 中初始化 state。如果你的渲染依赖于 DOM 节点的大小或位置,比如实现 modals 和 tooltips 等情况下,你可以使用此方式处理

useEffect 和 useLayoutEffect

对于 useEffect 执行, React 处理逻辑是采用异步调用 ,对于每一个 effectcallback, React 会像 setTimeout回调函数一样,放入任务队列,等到主线程任务完成,DOM 更新,js 执行完成,视图绘制完毕,才执行。所以 effect 回调函数不会阻塞浏览器绘制视图。

useLayoutEffectuseEffect 不同的地方是采用了同步执行

首先 useLayoutEffect 是在 DOM 绘制之前,这样可以方便修改 DOM ,这样浏览器只会绘制一次,如果修改 DOM 布局放在 useEffect ,那 useEffect 执行是在浏览器绘制视图之后,接下来又改 DOM ,就可能会导致浏览器再次回流和重绘。而且由于两次绘制,视图上可能会造成闪现突兀的效果。useLayoutEffect callback 中代码执行会阻塞浏览器绘制。

useEffect 对 React 执行栈来看是异步执行的,而 componentDidMount / componentDidUpdate 是同步执行的,useEffect代码不会阻塞浏览器绘制。在时机上 ,componentDidMount / componentDidUpdate 和 useLayoutEffect 更类似。

React原理 props深入

props的几种用法

① props 作为一个子组件渲染数据源。
② props 作为一个通知父组件的回调函数。
③ props 作为一个单纯的组件传递。
④ props 作为渲染函数。
⑤ render props , 和④的区别是放在了 children 属性上。
⑥ render component 插槽组件。

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
/* children 组件 */
function ChidrenComponent(){
return <div> In this chapter, let's learn about react props ! </div>
}
/* props 接受处理 */
class PropsComponent extends React.Component{
componentDidMount(){
console.log(this,'_this')
}
render(){
const { children , mes , renderName , say ,Component } = this.props
const renderFunction = children[0]
const renderComponent = children[1]
/* 对于子组件,不同的props是怎么被处理 */
return <div>
{ renderFunction() }
{ mes }
{ renderName() }
{ renderComponent }
<Component />
<button onClick={ () => say() } > change content </button>
</div>
}
}
/* props 定义绑定 */
class Index extends React.Component{
state={
mes: "hello,React"
}
node = null
say= () => this.setState({ mes:'let us learn React!' })
render(){
return <div>
<PropsComponent
mes={this.state.mes} // ① props 作为一个渲染数据源
say={ this.say } // ② props 作为一个回调函数 callback
Component={ ChidrenComponent } // ③ props 作为一个组件
renderName={ ()=><div> my name is alien </div> } // ④ props 作为渲染函数
>
{ ()=> <div>hello,world</div> } { /* ⑤render props */ }
<ChidrenComponent /> { /* ⑥render component */ }
</PropsComponent>
</div>
}
}

监听props改变

类组件

getDerivedStateFromProps 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新 state,如果返回 null 则不更新任何内容。

函数组件

函数组件中同理可以用 useEffect 来作为 props 改变后的监听函数。

props+children 最佳实践

增强子组件

通过 props.children 属性访问到 Chidren 组件,为 React element 对象。

  1. 可以根据需要控制 Chidren 是否渲染。

  2. Container 可以用 React.cloneElement 强化 props (混入新的 props ),或者修改 Chidren 的子元素。

1
2
3
<Container>
<Children>
</Container>

函数式子组件

  1. 根据需要控制 Chidren 渲染与否。
  2. 可以将需要传给 Children 的 props 直接通过函数参数的方式传递给执行函数 children 。
1
2
3
<Container>
{ (ContainerProps)=> <Children {...ContainerProps} /> }
</Container>

像下面这种情况下 children 是不能直接渲染的,直接渲染会报错。

1
2
3
4
5
6
7
function  Container(props) {
const ContainerProps = {
name: 'alien',
mes:'let us learn react'
}
return props.children(ContainerProps)
}

混合使用

1
2
3
4
<Container>
<Children />
{ (ContainerProps)=> <Children {...ContainerProps} name={'haha'} /> }
</Container>
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
const Children = (props)=> (<div>
<div>hello, my name is { props.name } </div>
<div> { props.mes } </div>
</div>)

function Container(props) {
const ContainerProps = {
name: 'alien',
mes:'let us learn react'
}
return props.children.map(item=>{
if(React.isValidElement(item)){ // 判断是 react elment 混入 props
return React.cloneElement(item,{ ...ContainerProps },item.props.children)
}else if(typeof item === 'function'){
return item(ContainerProps)
}else return null
})
}

const Index = ()=>{
return <Container>
<Children />
{ (ContainerProps)=> <Children {...ContainerProps} name={'haha'} /> }
</Container>
}

props的意义

层级间数据传递

父组件 props 可以把数据层传递给子组件去渲染消费。另一方面子组件可以通过 props 中的 callback ,来向父组件传递信息。还有一种可以将视图容器作为 props 进行渲染。

React 可以把组件的闭合标签里的插槽,转化成 children 属性,一会将详细介绍这个模式。

用于更新判断

在 React 中,props 在组件更新中充当了重要的角色,在 fiber 调和阶段中,diff 可以说是 React 更新的驱动器,熟悉 vue 的同学都知道 vue 中基于响应式,数据的变化,就会颗粒化到组件层级,通知其更新,但是在 React 中,无法直接检测出数据更新波及到的范围,props 可以作为组件是否更新的重要准则,变化即更新,于是有了 PureComponent ,memo 等性能优化方案。

使用技巧

使用剩余参数过滤props

1
2
3
4
function Father(props){
const { age,...fatherProps } = props
return <Son { ...fatherProps } />
}

混合props

1
2
3
function Father(prop){
return React.cloneElement(prop.children,{ mes:'let us learn React !' })
}

实现一个简易 webpack

webpack 的执行流程

  • 初始化 Compiler: new Webpack(config) 得到 Compiler 对象
  • 开始编译,调用 Compiler 对象 run 方法开始编译。
  • 确定入口,根据配置中的 entry 找出所有入口文件
  • 编译模块,从入口出发,调用所有配置的 Loader 对模块进行编译,找出该模块依赖的模块,递归直到所有的模块被加载进来。
  • 完成模块编译:在经过第四步使用 Loader 编译完所有模块之后,得到了每个模块被编译后的最终内容以及他们之间的依赖关系。
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表。(注意:这步是可以修改输出内容的最后机会)
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

做一些准备工作

想要打包总要有个项目吧,让我们着手准备一些项目文件

src,目录是项目的源文件,包含了一个工具方法util/add.js

1
2
const add = (a, b) => a + b;
export default add;

还有另一个打印方法 log.js 有一个更深层的依赖文件

1
2
3
4
import bind from "./util/bind";
const log = bind(console.log, console);

export default log;

在项目的入口文件中,引入并使用这两个方法

1
2
3
4
5
import add from "./util/add";
import log from "./log";

const count = add(1, 2);
log(count);

下面我们需要添加打包命令,就像 create-react-app 做的一样,我们想通过一个npm build命令打包, 所以通过 npm init -y初始化了package.json文件并添加了一个脚本

1
2
3
4
5
6
7
8
9
{
"name": "my-webpack",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "node scripts/build"
}
}

显然我们并没有用于打包的可执行脚本,所以要创建一个,放在scripts/build.js文件夹下面

正如上一小结流程描述的一样,我们通过一个自定义的myWebpack方法,传入配置后生成了compiler对象

1
2
3
4
const webpack = require("../lib/myWebpack");
const config = require("../config/webpack.config");
const compiler = webpack(config);
compiler.run();

myWebpack文件是主要要去实现的功能,我们暂时先建一个空文件,那么剩下的就只有这个webpack.config.js配置文件了,简单的给一些必须配置

1
2
3
4
5
6
7
8
9
const path = require("path");

module.exports = {
entry: "../src/index.js",
output: {
path: path.resolve(__dirname, "../dist"),
filename: "main.js",
},
};

解析入口文件依赖并编译代码

myWebpack.js

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
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("@babel/core");

const webpack = (config) => {
return new Compiler(config);
};

class Compiler {
constructor(options = {}) {
this.options = options;
}

run() {
const { entry } = this.options;
// 获取node进程执行的目录
const cwdPath = process.cwd();

// 因为readFile中使用相对路径是以node进程执行时的路径作为基准路径
// 可能有查不到文件报错的情况,这里使用path.resolve转换成绝对路径
const relativeEntryPath = path.resolve(__dirname, entry);
const file = fs.readFileSync(relativeEntryPath, "utf-8");

// 把文件内容转换成ast抽象语法树
// https://www.babeljs.cn/docs/babel-parser
const ast = parse(file, {
sourceType: "module",
});

// 收集入口文件依赖
const deps = [];
// 分析ast中的依赖关系保存奥依赖中
traverse(ast, {
ImportDeclaration: ({ node }) => {
// 获取到依赖文件的引用路径
const traverseModulePath = node.source.value;
// 转换为绝对路径
const relativePath = path.resolve(cwdPath, "src", traverseModulePath);
deps.push(relativePath);
},
});

// 编译代码,从ast编译为es5

const { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
console.log(code);
}
}
module.exports = webpack;

深层递归,生成依赖关系图

虽然我们拿到了入口文件的依赖,但显然是不够的,依赖的文件可能还有自己的依赖,需要递归的去获取

可以把递归解析依赖的方法,抽取成公共方法

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
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("@babel/core");

const webpack = (config) => {
return new Compiler(config);
};

class Compiler {
constructor(options = {}) {
this.options = options;
}
analysis(entry) {
// 获取node进程执行的目录
const cwdPath = process.cwd();

// 因为readFile中使用相对路径是以node进程执行时的路径作为基准路径
// 可能有查不到文件报错的情况,这里使用path.resolve转换成绝对路径
const relativeEntryPath = path.resolve(cwdPath, "src", entry);
const file = fs.readFileSync(relativeEntryPath, "utf-8");

// 把文件内容转换成ast抽象语法树
// https://www.babeljs.cn/docs/babel-parser
const ast = parse(file, {
sourceType: "module",
});

// 收集入口文件依赖
const deps = {};
// 分析ast中的依赖关系保存到依赖中
traverse(ast, {
ImportDeclaration: ({ node }) => {
// 获取到依赖文件的引用路径
const traverseModulePath = node.source.value + ".js";
// 转换为绝对路径
const relativePath = path.resolve(cwdPath, "src", traverseModulePath);
deps[traverseModulePath] = relativePath;
},
});

// 编译代码,从ast编译为es5

const { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});

return {
code,
deps,
entry,
};
}
run() {
const { entry } = this.options;
// 保存加载的模块
let module = [];
let index = 0;
let parseModule = this.analysis(entry);
module.push(parseModule);

while ((parseModule = module[index])) {
const { deps } = parseModule;
Object.keys(deps).forEach((depPath) => {
parseModule = this.analysis(depPath);
module.push(parseModule);
});
index++;
}

// 把各模块依赖关系从数据形式转换成对象的形式,方便使用
module = module.reduce((o, item) => {
o[item.entry] = {
deps: item.deps,
code: item.code,
};
return o;
}, {});
}
}

module.exports = webpack;

生成代码

我们需要用刚才创建好的依赖关系图来动态加载我们的代码

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
class Compiler {
generate(module) {
/**
* 入口文件
* var _add = _interopRequireDefault(require("./util/add"));
* var _log = _interopRequireDefault(require("./log"));
* function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
* var count = (0, _add["default"])(1, 2);
*/

/**
* 模块文件
* Object.defineProperty(exports, "__esModule", {
* value: true
* });
* exports["default"] = void 0;
* var bind = require('./util/bind');
* var log = bind(console.log, console);
* var _default = log;
* exports["default"] = _default;
*/
const js = `
(function(modules){
//加载入口文件
var fn = function(path){
var code = modules[path].code;
// 提供给模块内部使用的require
var require = function(path){
return fn(path+'.js');
}
const exports = {};

// 根据commonjs规范包装模块的方法
(function(exports,require,code){
// eval方法中的字符串在运行时会向上级作用于查找需要的变量
eval(code)
})(exports,require,code);
// 导出给下一个模块使用
return exports;
}

// 加载入口文件
fn('${this.options.entry}')

})(${JSON.stringify(module)})
`;
const filename = path.resolve(
this.options.output.path,
this.options.output.filename
);
fs.writeFileSync(filename, js, "utf-8");
}
}

把关系图直接变成函数声明

由于用了JSON.stringify所以生成的文件中各个模块都是以字符串的形式保存,需要用eval执行

所以我们选择另一种处理方法,直接拼接出字符串形式的模块依赖表,这样在生成文件的时候可以直接变成可执行函数

下面是优化过的完整代码

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
class Compiler {
constructor(options = {}) {
this.options = options;
}
analysis(entry) {
// 获取node进程执行的目录
const cwdPath = process.cwd();

// 因为readFile中使用相对路径是以node进程执行时的路径作为基准路径
// 可能有查不到文件报错的情况,这里使用path.resolve转换成绝对路径
const relativeEntryPath = path.resolve(cwdPath, "src", entry);
const file = fs.readFileSync(relativeEntryPath, "utf-8");

// 把文件内容转换成ast抽象语法树
// https://www.babeljs.cn/docs/babel-parser
const ast = parse(file, {
sourceType: "module",
});

// 收集入口文件依赖
const deps = {};
// 分析ast中的依赖关系保存到依赖中
traverse(ast, {
ImportDeclaration: ({ node }) => {
// 获取到依赖文件的引用路径
const traverseModulePath = node.source.value + ".js";
// 转换为绝对路径
const relativePath = path.resolve(cwdPath, "src", traverseModulePath);
deps[traverseModulePath] = relativePath;
},
});

// 编译代码,从ast编译为es5

let { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
code = `
(function(exports,require){
${code}
})
`;
return {
code,
deps,
entry,
};
}
run() {
const { entry } = this.options;
// 保存加载的模块
let module = [];
let index = 0;
let parseModule = this.analysis(entry);
module.push(parseModule);

while ((parseModule = module[index])) {
const { deps } = parseModule;
Object.keys(deps).forEach((depPath) => {
parseModule = this.analysis(depPath);
module.push(parseModule);
});
index++;
}
let moduleString = "{";
module.forEach((m) => {
moduleString += `"${m.entry}":${m.code},`;
});
moduleString += "}";
this.generate(moduleString);
}
generate(moduleString) {
/**
* 入口文件
* var _add = _interopRequireDefault(require("./util/add"));
* var _log = _interopRequireDefault(require("./log"));
* function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
* var count = (0, _add["default"])(1, 2);
*/

/**
* 模块文件
* Object.defineProperty(exports, "__esModule", {
* value: true
* });
* exports["default"] = void 0;
* var bind = require('./util/bind');
* var log = bind(console.log, console);
* var _default = log;
* exports["default"] = _default;
*/
const js = `
(function(modules){
function require(path){
var exports = {};
modules[path+'.js'](exports,require)
return exports;
}
// 加载入口文件
modules['${this.options.entry}']({},require)

})(${moduleString})
`;
const filename = path.resolve(
this.options.output.path,
this.options.output.filename
);
fs.writeFileSync(filename, js, "utf-8");
}
}
  • Copyrights © 2015-2025 SunZhiqi

此时无声胜有声!

支付宝
微信