webpack loader源码转译,对React组件包裹错误边界

1,809 阅读6分钟

前言

React项目常常会遇到整个页面突然白屏。遇到这种问题大概率是界面渲染过程中,组件render方法抛出异常导致React runtime崩溃。本文介绍编写webpack loader在项目构建中模块编译时自动对React组件做错误边界包裹处理。

阅读本文,你将会了解使用@babel/parser@babel/traverse@babel/template@babel/generator四个库,把JavaScript模块代码,从源码按需转化成目标代码的实践过程。

为什么使用webpack loader

背景

webpack模块编译阶段,入口bundle用到的模块都将逐一编译,每个模块的编译过程中会根据webpackmodule配置,执行每个loaderloader默认暴露的方法,能获取到模块的源码,经过处理并最终返回目标代码完成对一个模块的一次转译过程。

结论

可以使用loader,通过接收源码,分析源码,修改源码,最终返回源码的步骤实现对React组件模块的源码,增加包裹错误边界的逻辑。

步骤

1.分析源码

分析jsx/tsx模块的源码,有很多种方式。本方案主要使用@babel/parser对源码进行AST抽象语法树对象的转换。

源码转换成AST对象后,接下来要做的就是对AST对象的分析。本文主要介绍React的ESM规范模块做相关的处理,ESM规范下React的组件暴露主要有以下四种方式:

export default ComponentA // 情况1 export default
export default {ComponentA, ComponentB, ComponentC} // 情况2 export default {}
// 情况3 export const
export const ComponentA = (props) => {
 // 组件代码实现
} 
export {ComponentA, ComponentB, ComponentC} // 情况4 export {}

假定前置开发好错误边界组件,为高阶函数HOC:ErrorBoundaryWrap,目前需要做的是对以上四种方式的React模块export时,进行HOC的包裹,上文代码对应的转换如下:

export default ErrorBoundaryWrap(ComponentA) // 情况1
// 情况2
export default {
  ComponentA: ErrorBoundaryWrap(ComponentA), 
  ComponentB: ErrorBoundaryWrap(ComponentB), 
  ComponentC: ErrorBoundaryWrap(ComponentC)
}
// 情况3
export const ComponentA = ErrorBoundaryWrap((props) => {
 // 组件代码实现
})
// 情况4
const ComponentAerrorBoundary = ErrorBoundaryWrap(ComponentA)
const ComponentBerrorBoundary = ErrorBoundaryWrap(ComponentB)
const ComponentCerrorBoundary = ErrorBoundaryWrap(ComponentC)
export {
  ComponentAerrorBoundary as ComponentA, 
  ComponentBerrorBoundary as ComponentB, 
  ComponentCerrorBoundary as ComponentC
}

目标转移:实现使用webpack loader对react组件包裹错误边界,可以理解为对上述四种情况的代码做转换,在webpack构建中,模块编译时,对源码进行转换,利用工程化手段。

2.修改源码

根据上述的四种情况,拟定一份源码,作为我们用例的编写:origin.jsx

import React from 'react'

const Hello1 = () => {
  return (
    <>
      <h1>Hello1 is here</h1>
    </>
  )
}
const Hello2 = () => {
  return (
    <>
      <h1>Hello2 is here</h1>
    </>
  )
}
export const Hello3 = () => {
  return (
    <>
      <h1>Hello3 is here</h1>
    </>
  )
}
const Hello5 = () => {
  return (
    <>
      <h1>Hello5 is here</h1>
    </>
  )
}
const Hello6 = () => {
  return (
    <>
      <h1>Hello6 is here</h1>
    </>
  )
}
export {Hello1, Hello2}

export default  {
  Hello5, Hello6
}

// export default Hello1

或许这里有同学会有疑问,为什么不用tsx作为源码?

其实在使用@babel/parser把源码转换AST对象过程中,可以通过配置引入typescript插件,对tsx进行转译为js语法,所以在实际处理AST对象的过程中无需考虑typescript语法相关的节点。同理jsx插件会把源码中的jsx语法转换成React Element对象。

const sourceAst = parser.parse(source, {
    sourceType: 'unambiguous',
    plugins: ['jsx', 'typescript']
 })

AST对象分析

不了解AST相关类型对象同学,可以到https://astexplorer.net/或相同功能的网站,粘贴源码直观查看源码转换成AST对象后的结构。

image.png

接下来细说,上述四种情况中情况2具体处理流程:

export default  {
  Hello5, Hello6
}

转换为

export default {
  Hello5: ErrorBoundaryWrap(Hello5), 
  Hello6: ErrorBoundaryWrap(Hello6), 
}

image.png

通过工具或者对源码parse后sourceAst对象进行打印,可以看出export default是一个类型为ExportDefaultDeclaration的节点。

确定类型后,该怎么在sourceAst中寻找节点呢?接下来引出第二个库@babel/traverse

traverse(sourceAst, {
    Program(path){
        // type 为'Program'节点处理
    }
    ImportDeclaration(path){
        // type 为'ImportDeclaration'节点处理
    }
    ArrowFunctionExpression(path){
        // type 为'ArrowFunctionExpression'节点处理
    }
    ExportSpecifier(path){
        // type 为'ExportSpecifier'节点处理
    }
    ExportDefaultDeclaration(path){
        // type 为'ExportDefaultDeclaration'节点处理
    }
})

@babel/traverse默认返回traverse方法,traverse主要接收两个参数,AST对象,和针对各种类型节点的处理回调函数。

traverse方法会遍历传入的AST对象的每个节点,根据当前节点的类型执行对应的回调函数,回调函数会接收到path对象入参,path对象包含当前节点信息及该节点父节点子节点兄弟节点相关对象的引用

由于// export default Hello1被注释,当前AST中仅有一个类型为ExportDefaultDeclaration节点,通过执行以下代码,能看到ExportDefaultDeclaration只会执行一次。

traverse(sourceAst, {
    // 其他节点类型处理
    ExportDefaultDeclaration(path){
        console.log(`#ExportDefaultDeclaration`, path)
        // type 为'ExportDefaultDeclaration'节点处理
    }
})
// 输出一次 #ExportDefaultDeclaratio

完整代码请见 github.com/efoxTeam/re… 执行yarn && yarn test运行代码。

接下来分析上述代码打印出来的path对象:

// path对象的方法属性不完全展示
NodePath {
  parentPath: <ref *1> NodePath {  // 父节点path对象引用
  },
  node: Node {  // 当前节点
    type: 'ExportDefaultDeclaration', // 节点类型
    declaration: Node { // 子节点类型 ObjectExpression 相对于代码 {Hello5, Hello6} 部分
      type: 'ObjectExpression',
      properties: [Array] // 子节点属性, 下文会再展开
    }
  },
  type: 'ExportDefaultDeclaration',  //当前节点类型
  parent: Node { // 父节点
  },
}

源AST转换为目标AST

得到要操作的节点对象,接下来再确认一下需要做的事情:

export default  {
  Hello5, Hello6
}

上述代码片段需要转换为下面的目标代码:

export default {
  Hello5: ErrorBoundaryWrap(Hello5), 
  Hello6: ErrorBoundaryWrap(Hello6), 
}

接下来打印ExportDefaultDeclaration类型的子节点properties属性值path.node.declaration.properties,对应代码{Hello5, Hello6}部分

console.log(path.node.declaration.properties)
// 保留关键部分的输出
ExportDefaultDeclaration [
  {
    type: 'ObjectProperty',
    computed: false,
    key: {
      type: 'Identifier',
      name: 'Hello5', // 能获取到Hello5 Key值
      loc: undefined,
      leadingComments: undefined,
      innerComments: undefined,
      trailingComments: undefined,
      extra: {}
    },
  },
  {
    type: 'ObjectProperty',
    computed: false,
    key: {
      type: 'Identifier',
      name: 'Hello6', // 能获取到Hello6 Key值
      loc: undefined,
      leadingComments: undefined,
      innerComments: undefined,
      trailingComments: undefined,
      extra: {}
    },
  }
]

拿到Hello5,Hello6两个Key值之后,可以构造出我们需要的代码片段,接下来介绍@babel/template库,使用templateAPI可以把源码转换为AST节点。生成新的AST节点后,可以对原AST进行节点插入或替换。代码如下:

let replaceNodeString = 'export default {'
let adot = ''
path.node.declaration.properties.forEach(item => {
  if (item?.value?.name) {
    replaceNodeString += ` ${adot} ${item.value.name}: ErrorBoundary(${item.value.name})`
    adot = ','
  }
})
replaceNodeString += '}'
const newNode = template.statement(replaceNodeString)()
newNode.isdeal = true
path.replaceWithMultiple([newNode])

上述代码通过遍历path.node.declaration.properties生成如下代码片段:

export default {
  Hello5: ErrorBoundaryWrap(Hello5), 
  Hello6: ErrorBoundaryWrap(Hello6), 
}

再把代码片段通过template.statement(replaceNodeString)()转换成目标节点对象,再使用path.replaceWithMultiple方法替换掉原本属于:

export default  {
  Hello5, Hello6
}

节点

以上就完成了对情况4的React暴露组件代码的错误边界包裹。另外三种情况的处理和避免重复处理相同的AST节点、误处理相同类型的AST节点、以及引入ErrorBoundaryWrap高阶组件的实现请见 github.com/efoxTeam/re…

3.生成目标代码

最后,当源AST经过处理,达到目标AST后,通过使用@babel/generator把目标AST转化为目标代码,作为loader露出方法的返回值返回。

const { code } = generate(sourceAst)
return code

到此,一个特定功能的模块转译loader完成。

结语

本方案主要通过webpack loader的实现,对jsx/tsx后缀的JavaScript模块,进行暴露引用的4种情况,进行错误边界的包裹。用工程化手段自动化对React组件做容错处理,避免组件渲染异常导致react runtime render的崩溃。

推荐相关读物