《从0到1手写可插拔前端框架》5.源码调试:bin脚本断点调试

810 阅读4分钟

该小节主要知识点:

  • 为什么需要调试 bin 脚本
  • 如何快速上手调试 bin 脚本
  • 什么是 sourcemap ?调试中为什么需要 sourcemap ?
  • 实战:调试 Umi 源代码
  • 基于调试中的问题,给 Umi 提个 pr

本小节最终效果:

(这是我手写 mini-umi 时在 Umi 源码 中打的所有 关键断点 ) 屏幕录制2022-12-08-07.49.25.gif

1.为什么需要调试 bin 脚本?

你是否有思考过,当你在使用 VueCLI 或 create-react-app 创建的模版项目中使用 npm run dev 时,Webpack是如何 从0到1 启动这整个项目的吗?

当你在使用 Vue3+Vite 项目时,Vite dev 命令到底做了什么事情呢?Vite dev 与 Webpack 的 dev 又有什么不一样呢?

除了 Webpack,Vite,更多的还有 NuxtNextEsbuild等框架或工具

除了 dev,还有 buildlinttest等指令

如果你想知道他们工作以及运转的原理,就需要阅读他们的源代码,而通过调试 CLI入 口的 bin脚本,就可以看到所有指令 从0到1 是如何 执行

2.如何快速上手调试 bin 脚本

bin 脚本的调试其实是非常简单的,我们来简单盘一下逻辑

还记得我们在第4小节手写CLI时学过:如果你是一个 Node 侧的命令行工具,当你去执行 npm run xxx 时,其实都是去执行了 bin 目录下的脚本文件

而 Node 环境的脚本本质上不过是一个 Nodejs 文件

所以,我们调试 bin 脚本 和调试运行一个 Nodejs 文件又有什么区别呢?

那如何调试一个 Nodejs 文件呢?

接下来我带着大家一步一步尝试:

1.创建我们的调试项目

mkdir debugger
code debugger

2.创建调试的入口文件

// debugger.js
const yang = 'yang'

const yangyang = yang + 'yang'

console.log(yangyang+'yang');

3.尝试运行

node debugger.js 
// yangyangyang

运行成功✅

接下来开始调试的部分

1.进入vscode调试面板

image.png

2.给我们的 debugger.js 文件打一个断点

image.png 也可以这样打断点,使用 debugger 关键字

+ debugger;
  const yang = 'yang'
  const yangyang = yang + 'yang'
  console.log(yangyang + 'yang');

3.点击运行和调试按钮

image.png

4.这里直接选择 Nodejs 环境

image.png

这时候你会发现,调试模式已经启动了,成功✅ image.png

当然第4步这里其实还是要说明一下

4.这里直接选择 Nodejs 环境 你目前所在的工作文件是debugger.js,所以当你这里直接选择debugger.js文件之后,等同于vscode帮你自动创建了调试的配置文件,并把 program 字段指向了我们的 debugger.js

你可以使用配置文件尝试一下,效果是等价

创建调试配置文件

image.png

配置文件的 program 指向谁,相当于启动调试哪个 Nodejs 文件

// .vscode/launch.json
+{
+  // 使用 IntelliSense 了解相关属性。 
+  // 悬停以查看现有属性的描述。
+  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "启动程序",
+      "skipFiles": [
+        "<node_internals>/**"
+      ],
+      "program": "${workspaceFolder}/debugger.js"
+    }
+  ]
+}

问题来了

调试 Nodejs 文件我会了,但是命令行工具是启动脚本啊, bin脚本 该怎么调试呢?

我教给大家一种万能的方法,不管你的项目结构多复杂,有多少packages,这样调试 bin脚本,就非常简单

在我们的 debugger文件夹下创建bin脚本

pnpm init
// package.json
  "bin": {
+    "debugger": "./bin/debugger.js"
  },
// bin/debugger.js
#!/usr/bin/env node
const yang = 'yang'
const yangyang = yang + 'yang'
console.log(yangyang + 'yang');

验证bin脚本成功

npm link
debugger
// yangyangynag

进入配置文件

// .vscode/launch.json
{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": [
        "<node_internals>/**"
      ],
-      "program": "${workspaceFolder}/debugger.js"
+      "program": "${workspaceFolder}/bin/debugger.js"
    }
  ]
}

其实就是把 NodeJS文件 的入口指定成我们的 bin脚本 的入口,是不是有点过于简单了

因为 npm run xxx本质就是执行 Node环境的bin脚本 而 Node环境的 bin脚本 就是执行Nodejs代码 image.png

3.什么是sourcemap?调试中为什么需要sourcemap?

1.通常我们写好代码之后都会在打包的时候进行压缩polyfill等一系列优化处理

2.为了让使用者得到良好的类型支持,现代大多数类库都会选择用 TypeScript 进行开发,ts到js需要编译处理

什么是SourceMap?

SourceMap 是一个信息文件,里面存储了代码打包编译转换后的位置信息 通过开启sourcemap之后生成的xxx.map文件,你就可以知道

  • 打包之后的哪一个文件对应打包之前的哪一个文件

当然,它能清晰的对应到具体的代码行数

no say, let's code 这里在前几小节已经详解过了,不过多赘述

tsc --init
// tsconfig.json
+     "declaration": true,     //开启 ts声明文件 -- d.ts
// src/index.ts
export const yang: string = 'yang'

const yangyang = yang + 'yang'

console.log(yangyang + 'yang');
npm i father
// .fatherrc.ts
import { defineConfig } from 'father'
export default defineConfig({
  cjs: {
    output: "dist",
+   sourcemap: true   // 开启编译时sourcemap
  }
})
// package.json
  "scripts": {
+    "dev": "father dev",
+    "build": "father build"
  },
npm run dev

成功生成 产物.map文件 image.png

我们来分别看下生成的 产物sourcemap文件

// 产物文件:dist/index.js
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

// src/index.ts
var src_exports = {};
__export(src_exports, {
  yang: () => yang
});
module.exports = __toCommonJS(src_exports);
var yang = "yang";
var yangyang = yang + "yang";
console.log(yangyang + "yang");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
  yang
});
//# sourceMappingURL=index.js.map
// sourcemap文件:dist/index.js.map
{
  "version": 3,
  "sources": ["../src/index.ts"],
  "sourcesContent": ["export const yang: string = 'yang'\n\nconst yangyang = yang + 'yang'\n\nconsole.log(yangyang + 'yang');\n"],
  "mappings": ";;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAO,IAAM,OAAe;AAE5B,IAAM,WAAW,OAAO;AAExB,QAAQ,IAAI,WAAW,MAAM;",
  "names": []
}

产物中指明了对应的 sourcemap 文件

sourceMappingURL=index.js.map sourcemap 文件中也指明了对应的源产物sourcesContent

接下来,我们来利用 sourcemap打断点调试

// bin/debugger.js
#!/usr/bin/env node
+ require('../dist/index.js')
- const yang = 'yang'
- const yangyang = yang + 'yang'
- console.log(yangyang + 'yang');

src/index.ts 里面打断点

image.png 调试配置文件的 program 字段 还是指向 bin/debugger.js

// .vscode/launch.json
{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": [
        "<node_internals>/**"
      ],
     "program": "${workspaceFolder}/bin/debugger.js"
    }
  ]
}

现在我们就可以 直接调试源代码 而不是打包压缩编译之后看不懂的代码了

他会在执行编译之后的产物代码的时候,自动定位到编译之前的代码,这样我们就可以调试 源码 了! image.png

所以你可以回答我们为什么需要sourcemap吗?

如果你能看懂编译polyfill压缩等一系列操作之后之后生成的产物代码,就不需要 sourcmap 了(嘿嘿

4.实战:调试 Umi 源代码

我们来看 Umi 的源码

Umi 这个仓库采用 pnpm 的 Monorepo 管理

我们随便进入一个其中一个 package,可以发现也是使用 father 进行编译

1. clone Umi 源码到本地

// 终端
git clone https://github.com/umijs/umi.git
code umi

2.寻找 bin 脚本入口

tips:可以在某个项目中安装 Umi,然后查看 node_modules 下 umi 中的 bin 命令

image.png

然后我们就可以在 Umi源码 中去定位入口 bin 脚本的位置了
-- packages/umi/bin/umi.js

// packages/umi/bin/umi.js
#!/usr/bin/env node

// disable since it's conflicted with typescript cjs + dynamic import
// require('v8-compile-cache');

// patch console for debug
// ref: https://remysharp.com/2014/05/23/where-is-that-console-log
if (process.env.DEBUG_CONSOLE) {
  ['log', 'warn', 'error'].forEach((method) => {
    const old = console[method];
    console[method] = function () {
      let stack = new Error().stack.split(/\n/);
      // Chrome includes a single "Error" line, FF doesn't.
      if (stack[0].indexOf('Error') === 0) {
        stack = stack.slice(1);
      }
      const args = [].slice.apply(arguments).concat([stack[1].trim()]);
      return old.apply(console, args);
    };
  });
}

require('../dist/cli/cli')
  .run()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  });

3.安装依赖、生成 bin 脚本需要的 dist 目录

// 根目录
pnpm i 
npm run build  //执行了 turo run build

4.设置调试配置

注意:这里新增一个 “stopOnEntry”: true,作用是会在入口文件第一行打断点,不需要我们亲手去打,可以从入口开始调试

// .vscode/launch.json
{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "program": "${workspaceFolder}/packages/umi/bin/umi.js",
      "args": ["dev"],
      "stopOnEntry": true
    }
  ]
}

5.出现问题

这里我们忘记了一个关键因素,sourcemap 我这里准备去他的 .fatherrc.ts 配置文件开启一下sourcemap

结果发现他现在father版本是3.x不支持开启sourcemap

5.基于调试中的问题,给 Umi 提个pr

先提个issuegithub.com/umijs/umi/i…
pr--升级father版本到4.x支持sourcemapgithub.com/umijs/umi/p…

到现在为止,该 pr 已经合并

这样,我们就可以开始调试 Umi 源代码了!而且每次都会进入源码ts文件中 ✅