10

如何阅读'嵌套深'&'引用关系复杂'的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

     针对特定类型的文件, 控制只在'开发环境下'为元素标签注入'路径属性', 并且它本身就很方便获得当前文件所属路径。

     本篇也只是做了个小功能插件, 虽然没解决大问题, 但是思考过程还挺有意思的。

效果图

当鼠标选停放在元素上, 则展示出该元素的文件夹路径

image.png

三. 样式方案

     赋予标签属性之后我们就要思考如何获取它了, 显而易见我们这次要用属性选择器, 把所有标签属性有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:

  1. npx create-react-app show_path --template typescript, ts在后面有坑慢慢欣赏。
  2. yarn eject 暴露配置。
  3. 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
    };
    
  4. 打开show_path/config/webpack.config.js文件, 大概第557行, 添加如下代码:

         {
           test: /\.(tsx)$/,
           use: [
             require.resolve("./loaders/loader.js")
         },

image.png

五. 正则'难以招架'的几种情况

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树的插件有esprimarecast, 我们可以把步骤差分成三部分, 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);
};

image.png

七. 如何生成与解析react代码

     这时我们可以为其传入一个参数jsx:true:

  let astTree = esprima.parseModule(context, { jsx: true });
遍历这颗树

     由于树结构可能会非常深, 我们可以用工具函数estraverse来做遍历:

    estraverse.traverse(astTree, {
      enter(node) {
        console.log(node);
      },
    });

此时报错了, 一起欣赏下吧:

image.png

解决遍历问题

     我在网上找到了解决办法, 就是用专门处理jsxElement的循环插件yarn add estraverse-fb:

// 替换前
const estraverse = require("estraverse");

// 替换后
const estraverse = require("estraverse-fb");

可以正常循环:
image.png

生成代码

     我平时常用的解析纯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);
};

然后就又报错了:
image.png

但此时问题肯定是出在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,
          },
        });
        }
      },
    });
  1. 筛选出JSXOpeningElement类型的元素
  2. node.attributes.push将要新增的属性放入元素的属性队列
  3. JSXIdentifier属性名类型
  4. Literal属性值类型

image.png

配合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>;
}

则会产生如下报错信息:

image.png

     也好理解, 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

     这次就是这样, 希望与你一起进步。


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者