从零原生搭建一个微前端简易框架

写在文章前,本文章是初学微前端,作者想初步了解微前端的原理做的一次实践,特别声明本文是根据 www.bilibili.com/video/BV1vU... 大佬的教学来进行创建的,其中自己也有些自己踩到的坑,来记录一下

从零搭建一个微前端框架

不依赖 qiankun、Micro-app 等成熟方案,手把手从最原始的方案开始,一步步演进成一个可用的微前端框架。

一、技术栈与架构

先明确整体架构------一个基座 + 两个子应用 + 静态文件服务器:

角色 技术 打包方式
基座(主应用) Vue 3 + Vite 正常打包
子应用 app1 Vue 3 + Vite UMD
子应用 app2 React(CRA) UMD
静态服务 Node.js ---

为什么要 UMD? 子应用打包成 UMD 后,会把入口挂到 window 上(比如 window.vueapp),基座通过 fetch 拿到 JS 文本后 eval 执行,执行完就能拿到子应用暴露的方法。这是整个框架能跨框架加载子应用的基础。 注意:为什么采用umd,本来我是用的正常的打包,但这样的打包会产生improt等模块文件,在基座中导入时会报错:无法解析。

基座的页面结构很简单------左边侧边栏放导航,右边 #son 容器承载子应用:

html 复制代码
<div class="wrapper">
  <div class="slider-bar">
    <nav>
      <div><a href="/app1">app1</a></div>
      <div><a href="/app2">app2</a></div>
    </nav>
  </div>
  <div class="container">
    <div id="son"></div>
  </div>
</div>

二、起点------最原始的加载方案

2.1 直接 fetch + eval

最初的思路非常简单:既然 iframe 隔离太死、通信困难,那就干脆不用 iframe,直接用 fetch 把子应用的 JS/CSS 拉到基座里执行。

js 复制代码
function loadJs(url) {
  fetch(url)
    .then((res) => res.text())
    .then((text) => {
      eval(text);
    });
}

function loadCss(url) {
  fetch(url)
    .then((res) => res.text())
    .then((text) => {
      const styledom = document.createElement('style');
      styledom.textContent = text;
      document.body.appendChild(styledom);
    });
}

然后用一个 pathMap 配置子应用的资源地址,createSonApp 按 URL 路由匹配加载:

js 复制代码
const pathMap = {
  app1: {
    js: "http://localhost:3000/app1/index-BEaeQMVv.js",
    css: "http://localhost:3000/app1/index-CKGB1dSI.css"
  },
  app2: {
    js: "http://localhost:3000/app2/main.f4618f29.js",
    css: "http://localhost:3000/app2/index-BQmE1JZV.css"
  }
};

function createSonApp(url) {
  const path = url || window.location.pathname;
  const appName = path.split("/")[1];
  const sourceMap = pathMap[appName];
  if (sourceMap) {
    loadJs(sourceMap.js);
    loadCss(sourceMap.css);
  }
}

效果图

2.2 这个版本的问题

能跑,但到处都是坑:

  1. JS 全局污染 ------eval(text) 直接在主窗口执行,两个子应用如果有同名变量,后面的会直接覆盖前面的
  2. CSS 全局污染------样式直接塞进 body,两个子应用的样式互相打架,谁在后面谁赢
  3. 没有生命周期------加载了就加载了,没办法卸载。子应用里注册的事件监听器、定时器会一直跑,内存泄漏
  4. 路由不管用 ------createSonApp 只在页面初始化时调一次,点导航切子应用没反应
  5. hash 文件名 ------Vite/CRA 默认打包带 hash 的文件名(index-BEaeQMVv.js),每次构建文件名都变,得手动改 pathMap

这些问题驱动了后续的演进。

三、容器挂载与路由劫持

3.1 挂载到 #son 容器

首先把 JS 和 CSS 的挂载目标从 document.body 改成基座的 #son 容器:

js 复制代码
function loadCss(url) {
  return fetch(url)
    .then((res) => res.text())
    .then((text) => {
      const styledom = document.createElement('style');
      styledom.textContent = text;
      document.getElementById("son").appendChild(styledom);
    });
}

子应用的 DOM 和样式都被圈在 #son 这个 div 里,不会飞得到处都是。但这里还有一个隐患------CSS 只是挂到了 #son 下,并没有做作用域隔离 。如果子应用里写了 body { margin: 0 } 或者 .header { color: red },照样能穿透到基座,污染全局样式。这个问题留到后面的 CSS 隔离方案去解决。

3.2 路由切换的两种方式

页面导航在浏览器里有两种触发方式:

a 标签跳转(<a href="/app2">

这种走的是完整的浏览器导航,整个页面会刷新。路由变了,页面重新加载,createSonApp 自然会被重新调用。能用,但体验差------每次切子应用都白屏闪一下。

history.pushState

这是真正的麻烦。pushState 只改地址栏 URL,不会触发 popstate 事件 ,也不会导致页面重新渲染。在微前端框架里,用户点了导航、或者代码里调了 pushState,URL 变了但页面纹丝不动------子应用不会切换。

3.3 重写 pushState

解决办法是 Monkey-patch window.history.pushState:先执行原始逻辑(改 URL),再手动派发一个自定义 popstate 事件,把旧 appName 和新 URL 传过去:

js 复制代码
function writeFn() {
  const originPushState = window.history.pushState;

  window.history.pushState = function (a, b, url) {
    const nowAppName = window.location.pathname.split("/")[1];

    // 先执行原始 pushState,改 URL
    originPushState.call(this, a, b, url);

    // 用 setTimeout 确保 URL 已经变了,再读取 pathname 派发事件
    setTimeout(() => {
      window.dispatchEvent(new CustomEvent("popstate", {
        detail: { url, nowAppName }
      }));
    }, 0);
  };
}

然后监听这个自定义事件,做子应用的切换:

js 复制代码
function initEvent() {
  window.addEventListener("popstate", (e) => {
    let url = e.detail.url;
    let nowAppName = e.detail.nowAppName;

    // 处理 http 开头的绝对路径
    if (url.startsWith("http")) {
      url = new URL(url).pathname;
    }

    // 目标 appName 和当前一样,避免重复加载
    const targetAppName = url.split("/")[1];
    if (targetAppName === nowAppName) return;

    // 卸载旧子应用,加载新子应用
    window[nowAppName]?.unmount?.();
    createSonApp(url);
  });
}

整个路由切换的流程变成了:

scss 复制代码
pushState("/app2")
  → 地址栏 URL 变成 /app2
  → setTimeout(0) 后 dispatchEvent("popstate", { url, nowAppName })
  → 事件监听器收到,发现 appName 从 app1 变成了 app2
  → unmount app1 → load app2

这里 setTimeout(..., 0) 的作用很关键------必须等 pushState 的原逻辑完全执行完、window.location.pathname 真正变了之后,再去读取和派发事件。不然可能读到的还是旧路径。

四、当前进度与待解决问题

到此为止,我们有了一个能跑但很脆弱的微前端框架:

  • 已解决:子应用加载 → 容器隔离 → 路由切换
  • 待解决
    • JS 沙箱(全局变量隔离,避免子应用互相覆盖)
    • CSS 隔离(样式作用域,避免子应用样式互相污染)
    • 生命周期管理(mount/unmount + 事件/定时器清理)
    • 子应用打包规范(UMD 输出,稳定文件名)

下一篇继续从沙箱入手,用 Proxy 伪造 window 实现子应用的 JS 隔离。

五、框架化------抽离 wqd.js

前面所有的函数(loadJsloadCsscreateSonAppwriteFninitEvent)都是散落在基座代码里的,看起来就是一堆工具函数。为了让这套方案更像一个"框架"而不是"草稿",我们把它们全部收进一个文件 wqd.js,对外暴露一组 API:

js 复制代码
// wqd.js ------ 一个手搓的微前端框架

const wqd = {
  pathMap: {},
  timerList: [],
  eventList: [],

  // 注册子应用
  registerApp(pathMap) {
    this.pathMap = pathMap;
  },

  // 启动框架(劫持路由 + 监听事件)
  start() {
    this._patchSetInterval();
    this._patchPushState();
    this._initEvent();
    this._loadCurrentApp();
  },

  // 加载子应用
  _loadApp(url) {
    const path = url || window.location.pathname;
    const appName = path.split("/")[1];
    const sourceMap = this.pathMap[appName];
    if (sourceMap) {
      this._loadJs(sourceMap.js, appName);
      this._loadCss(sourceMap.css);
    }
  },

  // 卸载子应用
  _unmountApp() {
    // 清空定时器
    this.timerList.forEach((timer) => {
      clearInterval(timer);
      clearTimeout(timer);
    });
    this.timerList = [];

    // 移除事件监听
    this.eventList.forEach((eventObj) => {
      window.removeEventListener(eventObj.type, eventObj.fn);
    });
    this.eventList = [];

    // 清空 DOM
    const container = document.getElementById("son");
    if (container) {
      container.innerHTML = '';
    }
  },

  // ------ 以下方法后续详细展开 ------
  _patchSetInterval() { /* 第八节 */ },
  _patchPushState() { /* 第三节 */ },
  _initEvent() { /* 第三节 */ },
  _loadCurrentApp() { /* 第二节 */ },
  _loadJs(url, name) { /* 第六节 */ },
  _loadCss(url) { /* 第七节 */ },
};

基座使用起来就清爽多了:

js 复制代码
// 基座 main.js
wqd.registerApp({
  app1: {
    js: "http://localhost:3000/app1/index.js",
    css: "http://localhost:3000/app1/index.css"
  },
  app2: {
    js: "http://localhost:3000/app2/main.js",
    css: "http://localhost:3000/app2/index.css"
  }
});
wqd.start();

框架化的好处不只是代码整洁------后面每个待解决问题(沙箱、CSS 隔离、生命周期管理)都有了明确的归属,往 wqd 对象上加方法就行。

六、JS 沙箱------用 Proxy 伪造 window

第一个待解决的问题就是 JS 全局污染。两个子应用如果都往 window 上写变量,后加载的会把先加载的覆盖掉。解决思路是:给每个子应用一个假的 window,它以为自己写的是全局变量,实际上写的是一个隔离的代理对象

6.1 设计思路:同名 window 而非 window.app1

业界常见的做法是 window.app1 = { mount, unmount },子应用所有导出挂在一个命名空间下。但这种方式的侵入性比较强------子应用的开发者得知道自己叫 app1,代码里可能要写成 window.app1.xxx

我们换一个思路:子应用代码里完全不用改,它照样写 window.xxx,但这个 window 不是真的 window,而是一个我们伪造的代理对象。对子应用来说,这个代理对象"长得像" window------它有 window 上的所有属性(通过原型链 fallthrough 读到真实值),但它自己写入的新属性只会存在代理上,不会污染真实 window

js 复制代码
function createProxyWindow() {
  const realWindow = window;

  // 用一个空���象做原型,后面可以往上挂自定义方法
  const fakeWindow = Object.create({
    addEventListener(type, fn) {
      // 记录下注册的事件,方便后续卸载时清理
      wqd.eventList.push({ type, fn });
      return realWindow.addEventListener.call(this, type, fn);
    }
  });

  return new Proxy(fakeWindow, {
    get(target, property) {
      // 先从 fakeWindow 上找,找不到再去 realWindow 上找
      const value = target[property] || realWindow[property];
      // 如果是函数,bind 到 realWindow,避免 "Illegal invocation" 报错
      if (typeof value === 'function') {
        return value.bind(realWindow);
      }
      return value;
    },
    set(target, property, value) {
      // 写操作只写到 fakeWindow,不污染真实 window
      target[property] = value;
      return true;
    }
  });
}

这里几个设计点很关键:

Object.create({ addEventListener }) 而不是直接 {} 子应用里调用 window.addEventListener 的时候,我们不希望它和真实 window.addEventListener 行为完全一样------我们想顺便把注册的事件记下来。所以用 Object.create 把自定义的 addEventListener 挂在原型上,这样子应用调用 addEventListener 时走的就是我们的版本,既能正常注册事件,又能悄悄记一笔账。

get 里的 fallthrough 逻辑。 子应用可能需要用到 documentlocalStoragefetch 这些真正的全局 API。如果代理上没有,就顺着去 realWindow 上找。这样就实现了"白名单不需要配置"------子应用可以读取任何真实全局属性,但写入的变量全部被代理拦截。

value.bind(realWindow) 这是踩坑踩出来的。window.setTimeoutwindow.addEventListener 这类方法如果在代理对象上调用,this 指向不对,浏览器会抛 Illegal invocation。所以所有函数类型属性都 bind(realWindow) 一下,保证 this 正确。

set 只写代理。 子应用里写的 window.myVar = 123 只会落在 fakeWindow 上,真实 window.myVar 不受影响。两个子应用各自有自己的 proxyWindow,井水不犯河水。

6.2 把 proxyWindow 注入子应用代码

有了代理 window,怎么让子应用的代码用上它?办法是把 eval 包在一个 IIFE 里,把 globalwindowself 三个参数全部替换成 proxyWindow

js 复制代码
function loadJs(url, name) {
  return fetch(url)
    .then((res) => res.text())
    .then((text) => {
      const proxyWindow = createProxyWindow();

      // 子应用代码在 IIFE 里执行,global/window/self 都被替换为 proxyWindow
      (function (global, window, self) {
        eval(text);
      })(proxyWindow, proxyWindow, proxyWindow);

      // 把 UMD 导出桥接到真实 window
      if (proxyWindow[name]) {
        window[name] = proxyWindow[name];
        proxyWindow[name].mount();
      }
    });
}

来分析一下这行代码干了什么:

javascript 复制代码
(function (global, window, self) {
  eval(text);
})(proxyWindow, proxyWindow, proxyWindow);

子应用的 UMD 打包产物大概是这样的:

js 复制代码
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined'
    ? factory(exports)
    : factory((global.app1 = {}));
})(this, function (exports) {
  exports.mount = function () { /* ... */ };
  exports.unmount = function () { /* ... */ };
});

正常情况下 this 指向真实 window,所以 global.app1 = {} 写在全局上。但现在我们把 global 替换成了 proxyWindow,所以 proxyWindow.app1 = {}。子应用从头到尾操作的都是代理,真实 window 毫发无损。

6.3 一个踩坑:mount 方法无法透传

按上面的逻辑,子应用 mount/unmount 等生命周期方法应该都存在 proxyWindow[name] 上。但基座的路由切换代码里,卸载旧子应用时:

js 复制代码
window[nowAppName]?.unmount?.();  // 从真实 window 上读------读不到!

问题来了:window[name] 是真实 window,proxyWindow[name] 是代理 window,两者不互通。卸载旧子应用时从真实 window 读不到 unmount,自然就报错了。

所以加了这行桥接代码:

js 复制代码
if (proxyWindow[name]) {
  window[name] = proxyWindow[name];  // 桥接到真实 window
  proxyWindow[name].mount();         // 挂载子应用
}

这确实是一个补丁,不够优雅。更理想的做法是基座内部的切换逻辑也统一从代理对象上读写,而不混用真实 window。但在这个阶段,先让它跑通------后面重构生命周期管理时会一并解决。

七、CSS 隔离------给样式加上作用域

前面提到 loadCss 虽然把样式塞进了 #son 容器,但并没有做作用域隔离。子应用里写 body { margin: 0 } 照样穿透到基座。CSS 隔离要解决的本质问题是:让子应用的每一行样式只作用于它自己的 DOM 范围

7.1 把 CSS 插入 #son 下

首先修正 loadCss 的目标容器------从 document.body 改为 #son

js 复制代码
function loadCss(url) {
  return fetch(url)
    .then((res) => res.text())
    .then((text) => {
      const styledom = document.createElement('style');
      styledom.textContent = text;
      document.getElementById("son").appendChild(styledom);
    });
}

但这只解决了"样式标签不飞得到处都是"的问题。假设子应用的 CSS 里有一行:

css 复制代码
body { margin: 0; }

<style> 标签虽然在 #son 里,但 body 选择器能命中页面上的任何 <body>------包括基座自己的。样式依然会穿透,污染全局。

再比如:

css 复制代码
.header { color: red; }

两个子应用如果都有 .header 类名,后加载的会覆盖前一个的样式。这跟 JS 全局变量的污染本质上是同一个问题------大家都在同一个全局作用域里打架。

7.2 思路:给选择器加 #son 前缀

解决方式很朴素------在子应用的 CSS 里,给每一个选择器 前面拼上 #son

css 复制代码
/* 原始 */
body { margin: 0; }
.header { color: red; }

/* 处理后 */
#son body { margin: 0; }
#son .header { color: red; }

这样一来,样式被限制在 #son 容器内生效。两个子应用各自有自己的 #son 范围,互不干扰。

怎么把原始 CSS 文本里的每个选择器自动加上前缀?正则替换

7.3 scopeCSSWithParentClass------正则加工 CSS 文本

js 复制代码
function scopeCSSWithParentClass(cssContent, parentClass) {
  if (!parentClass || !cssContent) return cssContent;

  const normalizedParent = parentClass;

  return cssContent.replace(
    /([^{}]+)(?=\s*\{)/g,
    (selectorsBlock) => {
      const selectors = selectorsBlock.split(',').map(s => s.trim());

      const scopedSelectors = selectors.map(selector => {
        // 跳过 @keyframes 动画定义
        if (selector.startsWith('@keyframes') || selector.includes('@keyframes')) {
          return selector;
        }

        // 跳过 @media、@supports 等规则块
        // 跳过 :root、html、body 等全局选择器
        if (selector.startsWith('@') ||
            selector.includes(':root') ||
            selector.includes('html') ||
            selector.includes('body')) {
          return selector;
        }

        // 给普通选择器加上父容器前缀
        return scopeSingleSelector(selector, normalizedParent);
      });

      return scopedSelectors.join(', ');
    }
  );
}

function scopeSingleSelector(selector, parentClass) {
  return `${parentClass} ${selector}`;
}

核心逻辑分三步:

第一步,正则匹配选择器块。 /([^{}]+)(?=\s*\{)/g 匹配的是 { 之前的选择器部分。比如对 body { margin: 0 } .header { color: red },它会先后匹配到 body .header

第二步,处理逗号分隔的多选择器。 CSS 里经常有 h1, h2, h3 { font-weight: bold } 这种写法,需要按逗号拆开,每个选择器单独加前缀,再拼回去。

第三步,跳过不需要加前缀的特殊规则。 不是所有 { 前面的东西都是普通选择器:

  • @keyframes:动画定义,不能加前缀,否则 @keyframes #son slideIn 直接无效
  • @media@supports:条件规则块,前缀应该加在里面的选择器上,而不是 @ 规则本身
  • :roothtmlbody:这些全局选择器加了前缀意义不大(#son body 依然能命中基座的 body),跳过处理

7.4 集成到 loadCss

最终 loadCss 变成这样:

js 复制代码
function loadCss(url) {
  return fetch(url)
    .then((res) => res.text())
    .then((text) => {
      // 给每个选择器加上 #son 前缀,实现样式隔离
      const scopedCSS = scopeCSSWithParentClass(text, '#son');

      const styledom = document.createElement('style');
      styledom.textContent = scopedCSS;
      document.getElementById("son").appendChild(styledom);
    });
}

子应用的 CSS 拉下来 → 正则处理一遍 → 插入 #son 容器。两个子应用的样式都在各自的 #son 作用域里,互不打架,也不污染基座。

7.5 这个方案的局限性

得诚实:纯正则方案不是完美的。它有几个已知的边界情况:

  • 多层嵌套的 @media / @supports@media screen { .foo { color: red } } 里的 .foo 不会被加前缀,因为正则只匹配了 @media screen 这个外层 block
  • CSS-in-JS 动态插入 :如果子应用运行时动态创建 <style> 标签,这些样式不会被我们的正则处理
  • 优先级问题#son .header 的优先级比 .header 高,如果基座有全局样式、子应用想覆盖,可能需要调整选择器权重

但对手搓框架来说,这个方案已经能解决大多数实际问题了。

八、定时器清理------拦截 setInterval / setTimeout

8.1 问题:基座看不见子应用的定时器

子应用里写了 setIntervalsetTimeout,这些定时器的引用只有子应用自己知道。一旦子应用被卸载,DOM 没了、变量销毁了,但定时器还在跑------回调里试图操作已经不存在的东西,轻则报错,重则内存泄漏。

基座理论上可以要求子应用在 unmount 里自己清理,但这太依赖子应用开发者的自觉性。一个靠谱的框架应该做到:不管你子应用写不写清理代码,我都能帮你清干净。

8.2 和 addEventListener 同样的套路

前面在 createProxyWindow 里,我们已经干过类似的事了------覆盖 addEventListener,每次注册事件时把 { type, fn } 记到 eventList 里。

setIntervalsetTimeout 同理:覆盖这两个方法,每次调用时把返回的 timer ID 记下来:

js 复制代码
function _patchSetInterval() {
  const originSetInterval = window.setInterval;
  const originSetTimeout = window.setTimeout;

  // 覆盖 setInterval
  window.setInterval = function (fn, time) {
    const timer = originSetInterval.call(this, fn, time);
    wqd.timerList.push(timer);
    return timer;
  };

  // 覆盖 setTimeout
  window.setTimeout = function (fn, time) {
    const timer = originSetTimeout.call(this, fn, time);
    wqd.timerList.push(timer);
    return timer;
  };
}

注意这里覆盖的是真实 window 上的方法,而不是 proxyWindow。原因很简单------定时器是浏览器层面的能力,setInterval 必须在真实的全局对象上调用才能正常工作。

8.3 卸载时统一清理

有了 timerList 和之前积累的 eventList,卸载子应用的逻辑就出来了------遍历、清空:

js 复制代码
_unmountApp() {
  // 清空所有定时器
  this.timerList.forEach((timer) => {
    clearInterval(timer);
    clearTimeout(timer);   // 拿不准是 interval 还是 timeout,两个都调
  });
  this.timerList = [];

  // 移除所有事件监听
  this.eventList.forEach((eventObj) => {
    window.removeEventListener(eventObj.type, eventObj.fn);
  });
  this.eventList = [];

  // 清空子应用 DOM
  const container = document.getElementById("son");
  if (container) {
    container.innerHTML = '';
  }
}

这里 clearIntervalclearTimeout 两个都调了------因为我们把 interval 和 timeout 的 ID 都塞在同一个 timerList 里。事实上浏览器内部这两个方法是互通的,都调也不会有副作用。

8.4 完整的卸载流程

把路由切换和定时器/事件清理串起来:

js 复制代码
window.addEventListener("popstate", (e) => {
  const targetAppName = e.detail.url.split("/")[1];
  const nowAppName = e.detail.nowAppName;

  if (targetAppName === nowAppName) return;

  // 1. 卸载旧的(清定时器 → 清事件 → 清 DOM)
  wqd._unmountApp();

  // 2. 加载新的
  wqd._loadApp(e.detail.url);
});

卸载的完整链条:清定时器 → 清事件监听 → 清 DOM。反过来,加载子应用时,定时器和事件监听会通过被重写的 setInterval / addEventListener 自动记录,下次卸载时一并清理。

8.5 这个方案的盲区

和 CSS 隔离的正则方案一样,拦截 setInterval / setTimeout 也有覆盖不到的情况:

  • requestAnimationFrame :如果子应用用了 rAF,需要按同样套路拦截 window.requestAnimationFrame,卸载时调 cancelAnimationFrame
  • Promise / async/await:异步 Promise 链无法"取消",如果微任务里引用了已销毁的 DOM,还是可能报错。这个问题在微前端领域至今没有完美方案,一般靠子应用防御性编程(检查 DOM 是否存在)
  • 第三方库自己管理定时器 :比如 Lodash 的 _.debounce 内部用的 setTimeout,会被我们的拦截覆盖,因为最终还是调了全局的 setTimeout。但如果库用的是自己维护的定时器系统,就抓不到了

总的来说,拦截 + 记录 + 批量清理这条思路已经能覆盖绝大多数场景。

九、总结

至此,我们从一个最简单的 fetch + eval 开始,一步步搭建了一个具备核心能力的微前端框架:

能力 实现方式
子应用加载 fetch + eval(IIFE 注入 proxyWindow)
路由切换 Monkey-patch pushState + CustomEvent
JS 沙箱 Proxy 伪造 window,写隔离、读 fallthrough
CSS 隔离 正则给选择器加 #son 前缀
事件清理 覆盖 addEventListener 记录事件,卸载时批量 removeEventListener
定时器清理 覆盖 setInterval/setTimeout 记录 ID,卸载时批量 clear
框架化 抽离 wqd.js,统一 API(registerApp / start)

它当然比不上 qiankun 的完善程度------没有子应用预加载、没有应用间通信、没有错误边界、没有 Shadow DOM 隔离------但正因为手搓,所以每一个设计决策背后的"为什么"都看得清清楚楚。

微前端的核心问题就三个:加载、隔离、通信。这篇解决了前两个,通信留给后续吧。

相关推荐
小月土星1 小时前
React + TypeScript 企业级开发实战:从类型约束到组件设计
前端
沙洲1 小时前
Vite 环境变量终极指南:从原理到企业级实战
前端
刘婉晴1 小时前
【Web漏洞】SQL 注入实战技巧
前端·数据库·sql
di24k24k2 小时前
多个 el-form 共用同一 ref 导致表单校验部分失效
前端·javascript·vue.js·elementui
NutShell Wang2 小时前
每帧重建整条路径、每秒倾倒 48MB 给 GC:实时折线图渲染架构的实测复盘
前端·性能优化·架构·图形渲染·数据可视化·vibe coding
小彤花园2 小时前
和 AI 结对写网站:从 JSON 到一整个工具集
前端·人工智能·程序员
RD_daoyi3 小时前
Google核心算法不再通知!全年持续滚动更新
大数据·服务器·前端·网络·搜索引擎·.net
এ慕ོ冬℘゜3 小时前
纯 CSS 实现自定义 Switch 开关(商品上下架滑块)
前端·css
再吃一根胡萝卜3 小时前
dompdf.js 分页功能完整实现指南
前端