前端模块化-手写mini-vite

news2024/11/15 17:28:39

前言

本文总结了一些关于 Vite 的工作原理,以及一些实现细节。

本节对应的 demo 可以在这里找到。

什么是 Vite

Vite 是一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。

实现步骤

  • 项目搭建
  • 实现 cli
  • 起静态服务器, nodemon 监听文件修改,执行 vite 命令
  • 处理 index.html
  • 处理 js,处理 node_modules 的引入
  • 中间件拆分
  • 处理 react 文件

项目结构

├── _example
├── cli
│ └── index.js
├── src
│ └── index.js

_example 通过 npx create-vite-app 创建的 vite 项目, 用于和 mini-vite 对比

npx create-vite _example --template react

实现 cli

新建 cli/index.js

#! /usr/bin/env node
console.log("mini-vite!");

mini-vite/package.json

{
  "bin": "cli/index.js"
}

通过 yarn link 将 cli 链接到全局

#  _demo/mini-vite目录
yarn link

在 _example 中 link

#  _demo/mini-vite/_example目录
yarn link mini-vite

在 package.json 中添加命令

{
  "scripts": {
    "dev:mini-vite": "mini-vite"
  }
}

跑下 dev:mini-vite 命令,可以看到控制台已经打印出 mini-vite!

起静态服务器

依赖安装

yarn add koa koa-static

在 src 目录下新建 index.js

// src/index.js
const Koa = require("koa");
const KoaStatic = require("koa-static");

const app = new Koa();

// 执行命令时的路径
const rootPath = process.cwd();
app.use(KoaStatic(rootPath));

app.listen(8000, () => {
  console.log("mini-vite server启动成功!");
});

同时,在_example 中的 package.json 中添加命令

{
  "scripts": {
    "dev:mini-vite": "nodemon -w ../ --exec mini-vite",
    "mini-vite": "mini-vite"
  }
}

并安装 nodemon

# mini-vite/_example
yarn add nodemon -D

执行,可以看到控制台打印出 mini-vite server 启动成功!同时在浏览器中打开 http://localhost:8000/ 可以看到项目已经跑起来了。(这里的端口号是 8000,是因为 create-vite-app 默认的端口号是 3000,所以这里我们用 8000)

同时修改 index.js 也可以看到 terminal 中打印出修改成功。

处理 jsx

现在我们已经可以返回静态文件了,但是在返回 index.html 中后,浏览器随即发起了 src/main.jsx 的请求

<script type="module" src="/src/main.jsx"></script>

然后就报错了,因为浏览器无法解析 jsx 文件,所以我们需要对.jsx 进行处理,将 src/main.jsx 改为 src/main.js

main.jsx:1 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of “text/jsx”. Strict MIME type checking is enforced for module scripts per HTML spec.

首先是 jsx 的转换

在 mini-vite 目录安装依赖:

# mini-vite
yarn add  @babel/core @babel/plugin-transform-react-jsx

添加 transformJsx 函数

function transformJsx(jsxCode) {
  const babel = require("@babel/core");

  const options = {
    // presets: ['@babel/preset-env'], // 注意这里不要使用 @babel/preset-env,因为它会将所有的代码都转换成 ES5,包括import
    plugins: [
      [
        "@babel/plugin-transform-react-jsx",
        {
          pragma: "React.createElement",
          pragmaFrag: "React.Fragment",
        },
      ],
    ],
  };

  const { code } = babel.transform(jsxCode, options);

  return code;
}

修改 src/index.js 的代码, 进行了一点重构, 添加中间件的机制

// index.js
const Koa = require("koa");
const KoaStatic = require("koa-static");

function createServer() {
  const app = new Koa();

  const context = {
    app,
    rootPath: process.cwd(),
  };
  const resolvePlugins = [moduleRewirePlugin, serverStaticPlugin];

  resolvePlugins.forEach((plugin) => plugin(context));
}

createServer();

function serverStaticPlugin({ app, rootPath }) {
  app.use(KoaStatic(rootPath));
  app.use(KoaStatic(rootPath, "/public"));

  app.listen(8000, () => {
    console.log("mini-vite server启动成功!");
  });
}

function moduleRewirePlugin({ app, context }) {
  app.use(async (ctx, next) => {
    await next();
    if (ctx.body && ctx.response.is("jsx")) {
      // 初始的 ctx.body 是一个 Readable 流,需要转换成字符串
      const jsxCode = await readBody(ctx.body);
      // 通过babel转换jsx代码
      const transformedCode = transformJsx(jsxCode);

      ctx.type = "application/javascript";
      ctx.body = transformedCode;
    }
  });
}

function transformJsx(jsxCode) {
  const babel = require("@babel/core");

  const options = {
    // presets: ['@babel/preset-env'], // 注意这里不要使用 @babel/preset-env,因为它会将所有的代码都转换成 ES5,包括import
    plugins: [
      [
        "@babel/plugin-transform-react-jsx",
        {
          pragma: "React.createElement",
          pragmaFrag: "React.Fragment",
        },
      ],
    ],
  };

  const { code } = babel.transform(jsxCode, options);

  return code;
}

function readBody(stream) {
  return new Promise((resolve, reject) => {
    if (!stream.readable) {
      resolve(stream);
    } else {
      let res = "";
      stream.on("data", (data) => {
        res += data;
      });
      stream.on("end", () => {
        resolve(res);
      });
      stream.on("error", (err) => {
        reject(err);
      });
    }
  });
}

可以看到此时浏览器已经成功请求到了 main.js, 并且我们的 jsx 语法也被转换成了 React.createElement

但此时浏览器报错了

Uncaught TypeError: Failed to resolve module specifier “react”. Relative references must start with either “/”, “./”, or “…/”.

原因是我们在 main.js 中引入了 react,但是浏览器无法解析 node_modules 中的模块,所以我们需要对 node_modules 中的模块进行处理。

处理 node_modules

添加自定义的 babel 插件

module.exports = function ({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path, state) {
        const { node } = path;
        const id = node.source.value;
        // 简化场景: 不是以 / . 开头的,都是第三方模块,不考虑alias等其他情况
        if (/^[^\/\.]/.test(id)) {
          node.source = t.stringLiteral("/@modules/" + id);
        }
      },
    },
  };
};

服务端做对应的处理

const customAliasPlugin = require("./babel-plugin-custom-alias");

const regex = /^\/@modules\//;
function moduleResolvePlugin({ app, context }) {
  app.use(async (ctx, next) => {
    if (!regex.test(ctx.path)) {
      return next();
    }
    const id = ctx.path.replace(regex, "");
    console.log("id", id);

    const mapping = {
      // 从package.json中读取esm读出来的字段,这里只是简化了一下,正常应该从package.json中读取esm导出
      react: path.resolve(process.cwd(), "node_modules/react/index.js"),
      "react-dom/client": path.resolve(
        process.cwd(),
        "node_modules/react-dom/client.js"
      ),
    };

    ctx.type = "application/javascript";

    const content = fs.readFileSync(mapping[id], "utf-8");

    ctx.body = content;
  });
}

上述操作遇到一个问题就是,react 没有提供 esm 的版本!

看了下 React 官方的 package.json 的 export 字段

{
  "exports": {
    ".": {
      "react-server": "./react.shared-subset.js",
      "default": "./index.js"
    },
    "./package.json": "./package.json",
    "./jsx-runtime": "./jsx-runtime.js",
    "./jsx-dev-runtime": "./jsx-dev-runtime.js"
  }
}

找到对应的 index.js

"use strict";

if (process.env.NODE_ENV === "production") {
  module.exports = require("./cjs/react.production.min.js");
} else {
  module.exports = require("./cjs/react.development.js");
}

这里也提到了:https://segmentfault.com/q/1010000043780457

两种方案

    1. 找一个有 esm 的版本,比如 https://github.com/esm-bundle/react
    1. 还是原来的包,但是需要在服务端做一些处理,将 cjs 的包转换成 esm 的包

看看 vite-plugin-react 是如何这个问题的, 还是回到浏览器,查看正常 vite 打包出来的文件

import __vite__cjsImport0_react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=78b1e259";
const jsxDEV = __vite__cjsImport0_react_jsxDevRuntime["jsxDEV"];
import __vite__cjsImport1_react from "/node_modules/.vite/deps/react.js?v=78b1e259";
const React = __vite__cjsImport1_react.__esModule
  ? __vite__cjsImport1_react.default
  : __vite__cjsImport1_react;
import __vite__cjsImport2_reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=78b1e259";
const ReactDOM = __vite__cjsImport2_reactDom_client.__esModule
  ? __vite__cjsImport2_reactDom_client.default
  : __vite__cjsImport2_reactDom_client;
import App from "/src/App.jsx";
// ReactDOM.createRoot(document.getElementById("root")).render

看了下 node_modules/.vite/deps/react.js,确实是把代码 copy 了一份,然后把 cjs 的包转换成了 esm 的包,这个过程在 vite 中称为 optimizeDeps

处理 commonJS

vite 内部用了 esbuild 去处理,这里我们就不用 esbuild 了,直接用 babel 去处理

yarn add @babel/core babel-plugin-transform-commonjs

🚧🚧🚧: 注意,这里有两个包

  1. @babel/plugin-transform-modules-commonjs: 将 esm 转换成 cjs
  2. @babel/plugin-transform-commonjs: 将 cjs 转换成 esm

我们在启动服务的时候,添加一个 setupDevDepsAssets 的过程

setupDevDepsAssets(process.cwd());
/**
 * 依赖预构建,将react, react-dom, scheduler等第三方库转换成ES Module, 写入开发临时文件夹
 * @param {*} rootPath
 */
function setupDevDepsAssets(rootPath) {
  //查看node_modules/.mini-vite
  const tempDevDir = path.resolve(rootPath, "node_modules", ".mini-vite");

  if (!fs.existsSync(tempDevDir)) {
    fs.mkdirSync(tempDevDir);
  }

  // 将项目中的 react, react-dom, scheduler 等第三方库转换成 ES Module,写入到 node_modules/.mini-vite 目录下
  // 这里只是简化,实际上要从index.html中开始递归查找依赖,然后再转换
  const mapping = {
    react: {
      sourcePath: path.resolve(
        rootPath,
        "node_modules/react/cjs/react.development.js"
      ),
      targetPath: path.resolve(tempDevDir, "react.js"),
    },
    "react-dom/client": {
      sourcePath: path.resolve(
        rootPath,
        "node_modules/react-dom/cjs/react-dom.development.js"
      ),
      targetPath: path.resolve(tempDevDir, "react-dom.js"),
    },
    scheduler: {
      sourcePath: path.resolve(
        rootPath,
        "node_modules/scheduler/cjs/scheduler.development.js"
      ),
      targetPath: path.resolve(tempDevDir, "scheduler.js"),
    },
  };

  Object.keys(mapping).forEach((key) => {
    const { sourcePath, targetPath } = mapping[key];
    transformCjsToEsm(sourcePath, targetPath);
  });

  /**
   * 将 CommonJS 转换成 ES Module,部分三方库没有提供 ES Module 版本,比如React
   * @param {*} sourcePath
   * @param {*} targetPath
   */
  function transformCjsToEsm(sourcePath, targetPath) {
    const content = fs.readFileSync(sourcePath, "utf-8");
    const babel = require("@babel/core");

    // 转换CommonJS代码为esm
    const transformedCode = babel.transform(content, {
      plugins: ["transform-commonjs"],
    }).code;

    // 路径重写,将 require('react') 转换成 require('/@modules/react')
    // TODO: 两段代码合并
    const pathRewritedCode = babel.transform(transformedCode, {
      plugins: [customAliasPlugin],
    }).code;

    fs.writeFileSync(targetPath, pathRewritedCode);
  }
}

添加之后查看网络请求,可以看到已经成功请求到了 react.js 和 react-dom.js

看到控制台有报错,原因是在 App.js 中没有引入 React

React 自动引入

熟悉 React 的朋友都知道,在 React17 之前, 我们在使用 React 的时候,需要手动引入 React,原因是 JSX 语法会被转换成 React.createElement。

import React from "react";
function App() {
  return <div>hello world1</div>;
}

但是在 React17 之后,我们不需要手动引入 React 了, 有兴趣可以看看官网介绍, 因为 React 会自动注入到全局中,所以我们需要在 App.js 中添加 React 的引入

安装

yarn add  @babel/plugin-transform-react-jsx-development

我们在代码转化中添加自动引入的逻辑

const transformedCode = babel.transform(jsxCode, {
  plugins: [
    "@babel/plugin-transform-react-jsx-development", // 引入jsx
    customAliasPlugin,
  ],
}).code;

可以看到代码成功做了转化

接着是 import { jsxDEV as _jsxDEV } from "/@modules/react/jsx-dev-runtime";的处理

在原来的 mapping 中添加 jsx-dev-runtime 的引入

mapping = {
  react: {
    sourcePath: path.resolve(
      rootPath,
      "node_modules/react/cjs/react.development.js"
    ),
    targetPath: path.resolve(tempDevDir, "react.js"),
  },
  ["react/jsx-dev-runtime"]: {
    sourcePath: path.resolve(
      rootPath,
      "node_modules/react/cjs/react-jsx-dev-runtime.development.js"
    ),
    targetPath: path.resolve(tempDevDir, "jsx-dev-runtime.js"),
  },
};

可以看到 hello world1 已经成功渲染到页面上了

参考

  • vite-plugin-react

本文首发于个人Github前端开发笔记,由于笔者能力有限,文章难免有疏漏之处,欢迎指正

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1990166.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

PyTorch深度学习实战(5)—— Tensor的命名张量和基本结构

1. 命名张量 命名张量&#xff08;Named Tensors&#xff09;允许用户将显式名称与Tensor的维度关联起来&#xff0c;便于对Tensor进行其他操作。笔者推荐使用维度的名称进行维度操作&#xff0c;这样可以避免重复计算Tensor每个维度的位置。支持命名张量的工厂函数&#xff08…

怎么删除iPhone重复照片:解放你的存储空间

在数字化时代&#xff0c;iPhone已成为我们记录生活点滴的重要工具。从家庭聚会的快乐时光到户外冒险的壮观景象&#xff0c;我们依靠iPhone捕捉无数珍贵瞬间。然而&#xff0c;这种便利性带来的一个副作用是&#xff0c;相册很快就会充满重复的照片&#xff0c;不仅占用了宝贵…

【IC设计】时序分析面试题总结(亚稳态、建立/保持裕量计算、最高时钟频率计算、时序违例解决办法)

文章目录 基本概念亚稳态建立时间和保持时间 常见问题1.为什么触发器要满足建立时间和保持时间&#xff1f;2.建立时间裕量和保持时间裕量的计算&#xff1f;3.最高时钟频率的计算&#xff1f;流水线思想&#xff1f;4.时序违例的解决办法&#xff1f; 基本概念 亚稳态 亚稳态…

简单的 CompletableFuture学习笔记

简单的 CompletableFuture学习笔记 这里记录一下自己学习的内容&#xff0c;简单记录一下方便后续学习&#xff0c;内容部分参考 CompletableFuture学习博客 1. CompletableFuture简介 在api接口调用时间过长&#xff0c;调用过多外围接口时&#xff0c;为了提升性能&#x…

Self-study Python Fish-C Note14 P50to51

函数 (part 4) 本节主要讲函数 递归 递归 (recursion) 递归就是函数调用自身的过程 示例1&#xff1a; def fun1(i):if i > 0: print(something)i-1fun1(i) fun1(5) # 这样就会打印五遍 somethingsomething something something something something要让递归正常工作&am…

IDEA2024.2重磅发布,更新完有4G!

JetBrains 今天宣布了其 IDE 家族版本之 2024.2 更新&#xff0c;其亮点是新 UI 现在已是默认设置&#xff0c;并且对 AI Assistant &#xff08;AI助手&#xff09;进行了几项改进。 安装密道 新 UI 的设计更加简约&#xff0c;可以根据需要以视觉方式扩展复杂功能。值得开发…

Arduino学习笔记2——初步认识Arduino程序

Arduino使用的编程语言是C。 一、注释文字 我们可以在程序中插入注释文字来提示开发者代码的作用。在Arduino中&#xff0c;单行注释用的是两个斜杠&#xff0c;多行注释用的是对称的斜杠加星号&#xff1a; 二、函数 和C语言相同&#xff0c;可以看到在打开IDE自动打开的默…

高并发下的分布式缓存 | Cache-Aside缓存模式

Cache-aside 模式的缓存操作 Cache-aside 模式&#xff0c;也叫旁路缓存模式&#xff0c;是一种常见的缓存使用方式。在这个模式下&#xff0c;应用程序可能同时需要同缓存和数据库进行数据交互&#xff0c;而缓存和数据库之间是没有直接联系的。这意味着&#xff0c;应用程序…

Java数据结构 | 二叉树基础及基本操作

二叉树 一、树型结构1.1 树的概念1.2 关于树的一些常用概念&#xff08;很重要&#xff01;&#xff01;&#xff01;&#xff09;1.3 树的表示形式1.4 树的应用 二、二叉树2.1 二叉树的概念2.2 两种特殊的二叉树2.3 二叉树的性质2.4 二叉树的存储2.5 二叉树的基本操作2.5.1 代…

【前端可视化】 大屏可视化项目二 scale适配方案 g6流程图 更复杂的图表

项目介绍 第二个大屏可视化&#xff0c;整个项目利用scale进行按比例适配。 图表更加复杂&#xff0c;涉及到图表的叠加&#xff0c;mark&#xff0c;地图&#xff0c;g6流程图的能等 始终保持比例适配(本项目方案),始终满屏适配(项目一). echarts绘制较为复杂图表&#xff0…

C++:string类(auto+范围for,typeid)

目录 前言 auto typeid 范围for 使用方法 string类的模拟实现 默认构造函数 拷贝构造函数 swap 赋值重载 析构函数 迭代器iterator begin和end c_str clear size capacity []运算符重载 push_back reserve append 运算符重载 insert erase find npos…

postgresql 宝塔 连接不上,prisma

不太熟悉pgsql; 配置搞了半天; 一直连不上远程数据库; 后台经过探索发现需要以下配置 1. 端口放行; 5422 (pgsql的端口) 2.编辑 pg_hba.conf 文件最后新增一条,这样可以外部使用postgres超级管理员账号 host all all 0.0.0.0/0 md5 3. pris…

数据结构复杂度

文章目录 一. 数据结构前言1.1 数据结构1.2 算法 二. 算法效率2.1 时间复杂度2.1.1 T(N)函数式2.1.2 大O的渐进表示法 一. 数据结构前言 1.1 数据结构 什么是数据结构呢&#xff1f;打开一个人的主页&#xff0c;有很多视频&#xff0c;这是数据&#xff08;杂乱无章&#xf…

了解k8s架构,搭建k8s集群

kubernetes 概述 Kubernetes 集群图例 安装控制节点 安装网络插件 安装 calico 安装计算节点 2、node 安装 查看集群状态 kubectl get nodes # 验证容器工作状态 [rootmaster ~]# kubectl -n kube-system get pods

【学习笔记】:Maven初级

一、Maven简介 1、为什么需要maven Maven是一个依赖管理工具,解决如下问题: 项目依赖jar包多jar包来源、版本问题jar包导入问题jar包之间的依赖Maven是一个构建工具: 脱离IDE环境的项目构建操作,需要专门的工具2、Maven介绍 https://maven.apache.org/what-is-maven.htm…

代码随想录算法训练营第44天|LeetCode 1143.最长公共子序列、1035.不相交的线、53. 最大子序和、392.判断子序列

1. LeetCode 1143.最长公共子序列 题目链接&#xff1a;https://leetcode.cn/problems/longest-common-subsequence/description/ 文章链接&#xff1a;https://programmercarl.com/1143.最长公共子序列.html 视频链接&#xff1a;https://www.bilibili.com/video/BV1ye4y1L7CQ…

苹果离线打包机配置和打包

1、虚拟机安装 macOS虚拟机安装全过程&#xff08;VMware&#xff09;-腾讯云开发者社区-腾讯云 给 windows 虚拟机装个 mac 雪之梦 1、安装苹果镜像 去网上下载&#xff0c;打包机的镜像要和自己mac电脑上的保持一致。 同时打包机的用户名也需要和自己的mac保持一致。 2、…

云原生专题-k8s基础系列-k8s-namespaces详解

获取所有的pod实例&#xff1a; k8s中&#xff0c;命名空间&#xff08;Namespace&#xff09;提供一种机制&#xff0c;将同一集群中的资源划分为相互隔离的组。同一命名空间内的资源名称要唯一&#xff0c;命名空间是用来隔离资源的&#xff0c;不隔离网络。 https://kubern…

Kafka 实战使用、单机搭建、集群搭建、Kraft集群搭建

文章目录 实验环境单机服务启动停止服务简单收发消息其他消费模式理解Kakfa的消息传递机制 集群服务为什么要使用集群部署Zookeeper集群部署Kafka集群理解服务端的Topic、Partition和Broker总结 Kraft集群相关概念 实验环境 准备三台虚拟机 三台机器均预装CentOS7 操作系统。…

探索Transformer中的多头注意力机制:如何利用GPU并发

什么是多头注意力机制&#xff1f; 首先&#xff0c;什么是多头注意力机制&#xff1f;简单来说&#xff0c;它是Transformer模型的核心组件之一。它通过并行计算多个注意力头&#xff08;attention heads&#xff09;&#xff0c;使模型能够从不同的表示子空间中捕捉不同的特…