如何阅读'嵌套深'&'引用关系复杂'的react+ts项目? 要不咱写个loader缓解一下~
介绍
本文讲述了我为做出这个功能所经历的全过程, 不断的掉进坑里又不断地爬出来, 相比于结果过程更有趣, 所以才想把它分享出来。
一. 项目太'复杂', 找个组件都发愁
随着项目越做越大(cha), 会多出不少很深的代码模块, 比如你看到页面上显示的一个'名片框', 但你可能需要找好几分钟才能找到这个'名片框'的代码写在了哪个文件里, 如果这个项目你只是接收过来, 前几年不是你在维护, 那么寻找代码这个过程会很痛苦, React Developer Tools
也并没有很好的解决这个问题。
要明确一点所谓的'复杂'可能只是大家代码写的'差', 代码结构设计的不合理, 比如过分抽象, 很多人认为只要不断的抽出组件代码, 并且注释越少越好, 这样写的就是好代码, 其实这只是处于'比较初级的水平', 代码是写给人看的将代码写的逻辑清晰, 并且容易读懂容易找到核心的功能节点才是好代码, 往往过分的抽离出小组件会使性能下降, 毕竟难免要生成新的作用域, 很多人写react
比写vue
更容易过分抽象。
这里我想到的解决方案之一是这样的, 为每个元素添加一个'地址'属性: (本次以react
+ Ts
项目为例)
- 比如某个导出的
button
组件, 代码所在位置'object/src/page/home/index.tsx'
- 则我们就可以这样写
<button tipx='object/src/page/home/index.tsx'>按钮</button>
- 我们可以悬停展示路径, 也可以通过控制台查看路径信息
- 比如img、input这种无法使用
伪元素
的标签需要打开控制台查看
二. 方案选择
谷歌浏览器插件
这个虽然很容易为标签插入属性, 但是无法读取到插件所在的开发路径, 这个方案可以排除了。
vscode 插件
可以很好的读取到开发文件所在的文件夹, 但是添加路径属性的话会破坏整体的代码结构, 并且不好处理用户主动删掉某些属性以及区分开发环境与生产环境, 毕竟生产环境我们可不会做处理。
loader
针对特定类型的文件, 控制只在'开发环境下'为元素标签注入'路径属性', 并且它本身就很方便获得当前文件所属路径。
本篇也只是做了个小功能插件, 虽然没解决大问题, 但是思考过程还挺有意思的。
效果图
当鼠标选停放在元素上, 则展示出该元素的文件夹路径
三. 样式方案
赋予标签属性之后我们就要思考如何获取它了, 显而易见我们这次要用属性选择器
, 把所有标签属性有tipx
的标签全部检索出来, 然后我们通过伪元素befour
或者after
来展示这个文件地址。
attr
你还记得不?
这个属性是css
代码用来获取dom
标签属性的, 而我们就可以有如下的写法:
[tipx]:hover[tipx]::after{
content: attr(tipx);
color: white;
display: flex;
position: relative;
align-items: center;
background-color: red;
justify-content: center;
opacity: .6;
font-size: 16px;
padding: 3px 7px;
border-radius: 4px;
}
四. 方案1: loader配正则
简单粗暴的方式那肯定非 正则
莫属, 匹配出所有的开始标签, 比如<div
替换成<div tipx='xxxxxx'
, 这里要注意我们不用向自定义的组件上放属性, 要把属性放在原生标签上。
// 大概就是这个意思, 列举出所有的原生标签名
context = context.replace(
/\<(div|span|p|ul|li|i|a")/g,
`<$1 tipx='${this.resourcePath}'`
);
我们从头创建react
项目并设置loader
:
npx create-react-app show_path --template typescript
, ts在后面有坑慢慢欣赏。yarn eject
暴露配置。在
config
文件夹下建立loaders/loader.js
。module.exports = function (context) { // .... 稍后在此大(lang)展(bei)身(bu)手(kan) context = context.replace( /\<(div|span|p|ul|li|i|a")/g, `<$1 tipx='${this.resourcePath}'` ); return context };
打开
show_path/config/webpack.config.js
文件, 大概第557行, 添加如下代码:{ test: /\.(tsx)$/, use: [ require.resolve("./loaders/loader.js") },
五. 正则'难以招架'的几种情况
1:div字符串
const str = "<div>是古代程序员, 经常使用的标签"
上述情况会被正则误判成真实标签, 但其实不应该修改这个字符串。
2:名称重复
<divss>自定义标签名<divss>
此类标签几率小, 但是有几率出现重名的情况
3:单引号双引号
const str = "<div>标签外层已经有双引号</div>"
// 替换后报错
const str = "<div tipx="xxx/xx/xx/x.index">标签外层已经有双引号</div>"
我们不好判断外层是单引号还是双引号
4:styled-components
这个技术的书写方式使我们没法拆分出来, 比如下面的写法:
import styled from "styled-components";
export default function Home() {
const MyDiv = styled.div`
border: 1px solid red;
`;
return <MyDiv>123</MyDiv>
}
六. 方案2: AST树 & 获取当前文件路径
终于到达主线任务了, 将代码解析成树结构就可以更舒服的分析了, 比较好用的转换AST树的插件有esprima
和recast
, 我们可以把步骤差分成三部分, code转树结构
、循环遍历树结构
、树结构转code
。
当前文件路径webpack
已经注入了loader里面, this.resourcePath
就可以取到, 但它会是一个全局路径, 也就是从根目录一直到当前目录的电脑完整路径, 有需要的话我们可以进行一下拆分展示。
我们为loader.js
写入代码,进行 "第一步" 解析的时候报错了, 原因是它不认识jsx
语法。
const esprima = require("esprima");
module.exports = function (context, map, meta) {
let astTree = esprima.parseModule(context);
console.log(astTree);
this.callback(null, context, map, meta);
};
七. 如何生成与解析react
代码
这时我们可以为其传入一个参数jsx:true
:
let astTree = esprima.parseModule(context, { jsx: true });
遍历这颗树
由于树结构可能会非常深, 我们可以用工具函数estraverse
来做遍历:
estraverse.traverse(astTree, {
enter(node) {
console.log(node);
},
});
此时报错了, 一起欣赏下吧:
解决遍历问题
我在网上找到了解决办法, 就是用专门处理jsxElement的循环插件yarn add estraverse-fb
:
// 替换前
const estraverse = require("estraverse");
// 替换后
const estraverse = require("estraverse-fb");
可以正常循环:
生成代码
我平时常用的解析纯js代码的工具函数登场了escodegen
:
const esprima = require("esprima");
const estraverse = require("estraverse-fb");
const escodegen = require("escodegen");
module.exports = function (context, map, meta) {
let astTree = esprima.parseModule(context, { jsx: true });
estraverse.traverse(astTree, {
enter(node) {}
});
// 此处将AST树转成js代码
context = escodegen.generate(astTree);
this.callback(null, context, map, meta);
};
然后就又报错了:
但此时问题肯定是出在AST树还原成jscode这一步了, 搜索了escodegen
的各种配置并没有找到可以解决当前问题的配置, 当时也只好去寻找其他插件了。
八. recast
recast
也是一款很好用的AST转换库, recast官网地址, 但他没有自带好用的遍历方法, 使用方式如下:
const recast = require("recast");
module.exports = function (context, map, meta) {
// 1: 生成树
const ast = recast.parse(context);
// 2: 转换树
const out = recast.print(ast).code;
context = out;
this.callback(null, context, map, meta);
};
那我们忍痛割爱只取它的树转code功能:
// 替换前
context = escodegen.generate(astTree);
// 替换后
context = recast.print(astTree).code;
九. 找到目标 & 赋予属性
前后流程都打通了现在需要对标签赋予属性了, 这里直接看我总结的写法吧:
const path = this.resourcePath;
estraverse.traverse(astTree, {
enter(node) {
if (node.type === "JSXOpeningElement") {
node.attributes.push({
type: "JSXAttribute",
name: {
type: "JSXIdentifier",
name: "tipx",
},
value: {
type: "Literal",
value: path,
},
});
}
},
});
- 筛选出
JSXOpeningElement
类型的元素 node.attributes.push
将要新增的属性放入元素的属性队列JSXIdentifier
属性名类型Literal
属性值类型
配合recast
确实可以把代码还原的不错, 但这就真的结束了么?
十. ts有话说!
当我把开发的loader
投入到实际项目时, 那真是大写的傻眼, 假设开发的代码如下:
import React from "react";
export default function Home() {
interface C {
name: string;
}
const c: C = {
name: "金毛",
};
return <div title="title">home 页面</div>;
}
则会产生如下报错信息:
也好理解, interface
不能随意使用, 因为这是ts
的语法咱们js
不认识, 我第一时间想到的是ts-loader
并且尝试了让ts-loader
先编译, 然后我们解析它编译过的代码, 但是果然行不通。
esprima
这边无法直接读懂ts语法
, ts-loader
无法很好的解析jsx
并且解析后的代码无法与我们之前写的各种解析AST树的代码相配合, 我当时一度陷入'泥潭', 这个时候万能的babel-loader
勇敢的站了出来!
十一. babel
改变了切
我们把它放在最前面执行:
{
test: /\.(tsx)$/,
use: [
require.resolve("./loaders/loader.js"),
{
loader: require.resolve("babel-loader"),
options: {
presets: [[require.resolve("babel-preset-react-app")]],
},
},
],
},
当时给自己鼓了4.6s的掌, 终于通过了, 但是不能就这样结束了, 由于文件已经被babel
处理过了, 所以理论上我们之前针对jsx
的特殊处理都可以去掉了:
// 之前的
const estraverse = require("estraverse-fb");
// 现在的
const estraverse = require("estraverse");
// 之前的
let astTree = esprima.parseModule(context, { jsx: true });
// 现在的
let astTree = esprima.parseModule(context);
循环的已经不是jsx了, 循环体里面也要大改
// 之前的
estraverse.traverse(astTree, {
enter(node) {
if (node.type === "JSXOpeningElement") {
node.attributes.push({
type: "JSXAttribute",
name: {
type: "JSXIdentifier",
name: "tipx",
},
value: {
type: "Literal",
value: path,
},
});
}
},
});
// 现在的
estraverse.traverse(astTree, {
enter(node) {
if (node.type === "ObjectExpression") {
node.properties.push({
type: "Property",
key: { type: "Identifier", name: "tipx" },
computed: false,
value: {
type: "Literal",
value: path,
raw: '""',
},
kind: "init",
method: false,
shorthand: false,
});
}
},
});
此时启动我们的项目就已经可以解析ts
语言了, 但是...投入实际项目里又又又出问题了!
十二. 实际开发时的错误
按照我上面配置的方式原封不动的放入正式项目, 竟然报错了, 我就直接说吧错误原因是package.json
里面需要为babel
指定类型:
"babel": {
"presets": [
"react-app"
]
},
这里再附上我babel
的版本:
"@babel/core": "7.12.3",
"babel-loader": "8.1.0",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
你以为这就没bug了?
十三. 竟然真需要try登场!
真的是一些语法仍然有问题, 可能需要结合每个项目的特点进行一个独特的配置, 但是进百页代码只有3页报了奇怪的错, 最后还是选择使用try catch
包裹住了整个过程, 这样也是最严谨的做法, 毕竟只是个辅助插件不应影响主体流程的进行。
十四. 完整代码
const esprima = require('esprima');
const estraverse = require('estraverse');
const recast = require('recast');
module.exports = function (context, map, meta) {
const path = this.resourcePath;
let astTree = '';
try {
astTree = esprima.parseModule(context);
estraverse.traverse(astTree, {
enter(node) {
if (node.type === 'ObjectExpression') {
node.properties.push({
type: 'Property',
key: { type: 'Identifier', name: 'tipx' },
computed: false,
value: {
type: 'Literal',
value: path,
raw: '""',
},
kind: 'init',
method: false,
shorthand: false,
});
}
},
});
context = recast.print(astTree).code;
} catch (error) {
console.log('>>>>>>>>错误');
}
return context;
};
配置
{
test: /\.(tsx)$/,
use: [
require.resolve("./loaders/loader.js"),
{
loader: require.resolve("babel-loader"),
options: {
presets: [[require.resolve("babel-preset-react-app")]],
},
},
],
},
十五. 我的收获?
虽然最终的代码并不长, 但是过程真的是挺坎坷的, 不断的尝试各种库, 并且要想解决问题就要挖一挖这些库到底做了什么, 就这样一次就使我对编译方面有了更好的理解。
整个组件只能标出组件代码所在的位置, 并不能很好的指出其父级所在的文件位置, 还需要打开控制台查看他父级标签的tipx
属性, 但至少当某个小小的组件出问题, 恰好这个小组件的命名不规范,且套还有点深, 而且我们还不熟悉代码, 那就试试使用这个loader
找出他吧。
end
这次就是这样, 希望与你一起进步。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。