阅读视图

发现新文章,点击刷新页面。

我接手了一个 10 年前的 jQuery 老项目,用 Web Components 给它续了命

3680358_4d2e_2.webp

前几个月,主管把我拉进一个小黑屋,语重心长地说:那个 2015 年上线的 CRM 系统,客户想加个AI 智能客服的功能,虽然是个老项目,但这是公司的大金矿,你来负责呗!😃。

我打开代码仓库的那一刻,两眼一黑。

  • jQuery 1.8.3,古董级的版本。
  • 没有 Webpack,没有 Vite,只有无尽的 <script> 标签。
  • 全局变量漫天飞,一个 common.js 有 8000 行,里面充斥着 varfunction
  • CSS 样式全剧透,随便改个 div 的 padding,隔壁页面的布局就崩了。

这时候,我的脑子里有两个小人在打架:

  • 全删了!用 Vue3 + Vite 重构!这代码是人写的吗?😖
  • 别冲动!这项目逻辑极其复杂,重构就是火葬场,上线出了 Bug 你负责?

现实是残酷的:业务只给了 3 天时间,重构是不可能的😒。

但在满屏 $('#id').show() 的代码里写现代化的 AI 对话界面?光是解决 CSS 样式冲突就能让我加班到猝死。

最后,我没有选择 React,也没用 iframe(通信太麻烦),而是掏出了浏览器原生的大杀器——Web Components

结果?我只用了一个 JS 文件,就完美地把现代 UI种进了这坨老代码里,而且没引发任何副作用。

今天就来聊聊,这套屎山续命指南


为什么我选择了 Web Components?

1_NE0TVSyDZ2MC7ngfGYa7gg.jpg

在老项目里加新功能,最大的痛点是什么?

不是 JS 逻辑混乱,是 CSS 污染。

老项目里通常会有这种霸道的全局样式:

/* style.css */
div { box-sizing: content-box; }
input { border: 1px solid red; } /* 不知道哪位前辈留下的坑 */
.btn { float: left; }

如果你直接引入一个 Vue/React 组件,这些全局样式会瞬间摧毁你的 UI。

Web Components 的 Shadow DOM(影子 DOM) ,就是为此而生的。

它创造了一个完全隔离的 DOM 作用域

  • 外部的 CSS 进不来(你的组件不会乱)。
  • 内部的 CSS 出不去(你不会搞崩老页面)。

在 jQuery 页面里开始植入AI 助手

假设我们不需要任何构建工具(Webpack/Vite),直接在老页面的 HTML 里写。

定义组件

不用引入任何库,直接用原生 ES6 Class。

// ai-assistant.js
class AiAssistant extends HTMLElement {
  constructor() {
    super();
    // 1. 开启 Shadow DOM,这是隔离的关键
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // 2. 渲染 UI 和 独立的 CSS
    this.render();
    // 3. 绑定事件
    this.shadow.querySelector('#send-btn').addEventListener('click', () => {
      this.handleSend();
    });
  }

  render() {
    this.shadow.innerHTML = `
      <style>
        /* 这里的样式绝对安全,不会影响外部,也不受外部影响 */
        :host {
          position: fixed;
          bottom: 20px;
          right: 20px;
          z-index: 9999;
        }
        .container {
          background: #fff;
          border-radius: 12px;
          box-shadow: 0 4px 20px rgba(0,0,0,0.15);
          width: 300px;
          font-family: 'Segoe UI', sans-serif; /* 我们可以用现代字体 */
        }
        .header { background: #007bff; color: white; padding: 10px; border-radius: 12px 12px 0 0; }
        .content { height: 200px; padding: 10px; overflow-y: auto; color: #333; }
        input { width: 70%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
        button { background: #007bff; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; }
      </style>

      <div class="container">
        <div class="header">🤖 AI 助手</div>
        <div class="content" id="chat-box">
          <div>你好,我是你的智能助手,有什么可以帮您?</div>
        </div>
        <div style="padding: 10px; border-top: 1px solid #eee;">
          <input type="text" id="input-msg" placeholder="输入问题..." />
          <button id="send-btn">发送</button>
        </div>
      </div>
    `;
  }

  handleSend() {
    const input = this.shadow.querySelector('#input-msg');
    const text = input.value;
    if (!text) return;

    // 模拟添加消息
    const chatBox = this.shadow.querySelector('#chat-box');
    chatBox.innerHTML += `<div style="text-align:right; margin:5px 0; color: #007bff;">${text}</div>`;
    input.value = '';

    // 关键:如何跟外部 jQuery 通信?抛出原生 CustomEvent
    this.dispatchEvent(new CustomEvent('new-question', {
      detail: { question: text },
      bubbles: true,
      composed: true // 允许穿透 Shadow DOM 冒泡出去
    }));
  }
}

// 注册组件
customElements.define('ai-assistant', AiAssistant);

在 jQuery 老页面中使用

只需要引入 JS,然后像写 <div> 一样写标签。

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="legacy-style.css">
  <script src="jquery-1.8.3.min.js"></script>
</head>
<body>
  <div class="old-header">...</div>
  
  <ai-assistant></ai-assistant>

  <script src="ai-assistant.js"></script>

  <script>
    // jQuery 监听组件抛出的事件
    $(document).ready(function() {
      // 这里的事件监听依然丝滑
      $('ai-assistant').on('new-question', function(e) {
        // 注意:jQuery 的 event 对象封装了一层,原生的 detail 在 originalEvent 里
        var question = e.originalEvent.detail.question;
        console.log("老页面收到了 AI 提问:", question);
        
        // 这里可以调用老系统的 API
        // $.ajax(...) 
      });
    });
  </script>
</body>
</html>

为什么说这是屎山的终极解法?

侵入性为零

我在 ai-assistant.js 里写了 input { width: 70% }

如果是普通的 Vue/React 组件,这行样式可能会把老页面所有的 input 都搞乱。

但因为有 Shadow DOM,它只对自己生效。老页面的 style.css 就算写了 div { display: none },也影响不到我 Shadow Root 里的布局。

技术栈框架无关

Web Components 是浏览器原生标准。

这意味着,无论这坨屎山是基于 jQuery、AngularJS 1.x、PHP 模板还是 JSP,只要它是浏览器,它就能跑这个组件

未来如果你们终于决定重构,换成了 React 19,这个 <ai-assistant> 标签依然可以直接复制过去用,不用改一行代码。

渐进式迁移

我们可以不用搞重构。

今天把用户卡片改成 Web Component。

明天把侧边栏改成 Web Component。

这是一种 岛屿架构 (Astro 类似) 。我们在屎山💩的海洋里,建立一个个小岛。等岛屿连成一片,屎山自然就消失了。


避坑指南(干货)

虽然很香,但也有几个坑要注意:

  1. React 兼容性: 如果你的老项目里混用了 React 16/17,它们对 Web Components 的事件绑定支持不太好(React 19 已完美修复)。
  2. 字体图标: Shadow DOM 里的字体图标(如 FontAwesome)如果定义在外部 CSS 里,内部读不到。需要在 Shadow DOM 的 <style>@import 进来,或者用 <link> 引入。
  3. 构建工具: 如果组件逻辑复杂,还是建议用 LitStencil 这种轻量库来写,然后打包成一个 bundle.js 丢给老项目引用,开发体验会好很多(支持 TS、响应式数据)。

面对老旧项目,我们往往有两种极端的态度:要么摆烂(接着堆屎),要么激进点(推倒重来)。

但作为成熟的工程师,我们更需要中间平滑路线。Web Components 恰恰给了我们非常好的解决方案。

下次再遇到老板让你给 10 年前的 JSP 页面加功能,别急着提离职。

试着建一个 <my-feature>,你会发现,屎山其实也没那么可怕😁。

大家平时怎么解决老古董项目呢?🤔

谢谢大家.gif

Monorepo的实现原理

Monorepo的实现需要

  1. 基础层:包管理器的Workspace功能

  2. 管理层:Lerna等版本和发布管理

  3. 构建层:Turborepo/Nx等高性能构建工具

  4. 架构层:合理的项目结构和依赖管理策略

  5. 工程层:CI/CD、代码质量、团队协作流程


Monorepo的实现原理(底层机制详解)

Monorepo的本质

  • 链接机制:通过符号链接/硬链接连接本地包
  • 依赖提升:共享相同版本的依赖,减少冗余
  • 统一入口:通过根package.json管理公共配置
  • 原子操作:跨包的操作(构建、测试、发布)保持一致性
  • 依赖图感知:理解包之间的关系,优化执行顺序

一、最核心原理:符号链接(Symlink)

1. 基本概念

# 传统多仓库
project-a/
├── node_modules/
│   └── project-b/     # 从npm安装
project-b/             # 独立的仓库

# Monorepo实现后
monorepo/
├── packages/
│   ├── project-a/
│   │   └── node_modules/
│   │       └── project-b -> ../../project-b  # 符号链接!
│   └── project-b/

2. 符号链接如何工作

# 创建符号链接的底层实现
ln -s ../../packages/utils packages/ui/node_modules/@company/utils

# 结果:
# packages/ui/node_modules/@company/utils -> packages/utils

# 验证链接
ls -l packages/ui/node_modules/@company/
# lrwxr-xr-x  1 user  group  20 Jan 25 10:00 utils -> ../../../utils

二、依赖解析机制

1. Node.js的模块解析算法

// Node.js查找模块的顺序:
1. 当前目录的node_modules
2. 父目录的node_modules(递归向上)
3. 全局node_modules

// Monorepo利用这个机制:
monorepo/
├── node_modules/                    # 根级(提升的依赖)
│   ├── react/
│   └── lodash/
├── packages/
│   ├── ui/
│   │   └── node_modules/
│   │       ├── @company/utils -> ../../utils  # 符号链接
│   │       └── local-dep/          # 本地特有依赖
│   └── utils/

2. 包管理器的工作原理

// npm/yarn/pnpm在Monorepo中的解析流程
function resolvePackage(packageName, currentDir) {
  // 1. 检查是否是工作区包
  if (isWorkspacePackage(packageName)) {
    // 返回本地路径而不是从npm下载
    return getLocalPackagePath(packageName);
  }

  // 2. 正常解析npm包
  return resolveFromNpm(packageName);
}

三、工作区(Workspace)的实现

1. 配置识别

// package.json
{
  "workspaces": ["packages/*", "apps/*"]
}

// 包管理器读取后:
const workspaces = ["packages/*", "apps/*"];
const packages = glob(workspaces); // 展开所有匹配的包

2. 依赖图构建

// 构建包之间的依赖关系图
class DependencyGraph {
  constructor() {
    this.nodes = new Map(); // 包名 -> 包信息
    this.edges = new Map(); // 包名 -> 依赖的包列表
  }

  buildFromWorkspaces() {
    // 1. 扫描所有workspace包
    for (const pkg of this.findAllPackages()) {
      this.nodes.set(pkg.name, pkg);

      // 2. 解析依赖
      const deps = this.extractDependencies(pkg);
      this.edges.set(pkg.name, deps);
    }
  }

  // 关键:检测内部依赖
  extractDependencies(pkg) {
    const deps = [];

    for (const [depName, version] of Object.entries(pkg.dependencies)) {
      if (version.startsWith('workspace:') || this.isLocalPackage(depName)) {
        // 这是工作区内部的依赖
        deps.push({
          name: depName,
          type: 'internal',
          path: this.findLocalPath(depName)
        });
      } else {
        // 外部npm依赖
        deps.push({
          name: depName,
          type: 'external',
          version: version
        });
      }
    }

    return deps;
  }
}

四、依赖提升(Hoisting)

1. 如何实现依赖提升

class HoistingResolver {
  hoistDependencies(packages) {
    // 1. 收集所有依赖
    const allDeps = this.collectAllDependencies(packages);

    // 2. 找出可提升的依赖(版本一致)
    const hoistable = this.findHoistableDeps(allDeps);

    // 3. 在根目录安装
    this.installAtRoot(hoistable);

    // 4. 从子包中移除重复依赖
    this.removeDuplicatesFromChildren(packages, hoistable);
  }

  findHoistableDeps(allDeps) {
    const depVersions = new Map();

    // 统计每个依赖的版本
    for (const {name, version} of allDeps) {
      if (!depVersions.has(name)) {
        depVersions.set(name, new Set());
      }
      depVersions.get(name).add(version);
    }

    // 只有所有包使用相同版本的依赖才可提升
    const hoistable = [];
    for (const [name, versions] of depVersions) {
      if (versions.size === 1) {
        // 所有包使用相同版本
        hoistable.push({name, version: Array.from(versions)[0]});
      }
    }

    return hoistable;
  }
}

五、构建和任务执行原理

1. 任务依赖图

// 计算任务执行顺序
class TaskScheduler {
  scheduleTasks(packages, taskName) {
    // 1. 构建任务图
    const taskGraph = this.buildTaskGraph(packages, taskName);

    // 2. 拓扑排序(确保依赖先执行)
    const executionOrder = this.topologicalSort(taskGraph);

    // 3. 并行执行无依赖的任务
    return this.executeInParallel(executionOrder);
  }

  buildTaskGraph(packages, taskName) {
    const graph = new Map();

    for (const pkg of packages) {
      const dependencies = this.getPackageDeps(pkg);
      const dependentTasks = [];

      // 找出依赖此包的其他包
      for (const otherPkg of packages) {
        if (otherPkg.dependencies[pkg.name]) {
          dependentTasks.push(`${otherPkg.name}:${taskName}`);
        }
      }

      graph.set(`${pkg.name}:${taskName}`, {
        dependencies: dependencies.map(dep => `${dep}:${taskName}`),
        dependents: dependentTasks
      });
    }

    return graph;
  }
}

2. 增量构建的实现

class IncrementalBuilder {
  constructor(cacheDir = '.cache') {
    this.cacheDir = cacheDir;
  }

  shouldBuild(packagePath, task) {
    // 1. 计算输入哈希
    const inputHash = this.calculateInputHash(packagePath, task);

    // 2. 检查缓存
    const cacheKey = this.getCacheKey(packagePath, task);
    const cachedHash = this.readCache(cacheKey);

    // 3. 比较哈希
    if (cachedHash === inputHash) {
      // 缓存命中,跳过构建
      return false;
    }

    // 4. 需要重新构建
    this.writeCache(cacheKey, inputHash);
    return true;
  }

  calculateInputHash(packagePath, task) {
    // 包括:
    // - 源代码文件
    // - 依赖项版本
    // - 构建配置
    // - 环境变量
    const sources = glob(`${packagePath}/src/**/*`);
    const pkgJson = require(`${packagePath}/package.json`);

    return hash({
      sources: sources.map(f => hashFile(f)),
      deps: pkgJson.dependencies,
      config: this.getBuildConfig(task),
      env: process.env.NODE_ENV
    });
  }
}

六、跨包引用的编译时处理

1. TypeScript路径映射

// tsconfig.base.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@company/*": ["packages/*/src"]
    }
  }
}

// 编译时,TypeScript将:
// import { utils } from '@company/utils';
// 转换为:
// import { utils } from '../packages/utils/src';

2. Babel/Webpack模块解析插件

// 自定义模块解析器
class MonorepoResolver {
  apply(resolver) {
    resolver.plugin('module', (request, callback) => {
      if (request.request.startsWith('@company/')) {
        // 重写请求路径
        const newRequest = Object.assign({}, request, {
          path: this.resolveLocalPackage(request.request)
        });
        return resolver.doResolve('module', newRequest, callback);
      }
      callback();
    });
  }

  resolveLocalPackage(packageName) {
    // @company/utils -> packages/utils/src
    const pkg = packageName.replace('@company/', '');
    return path.join(__dirname, 'packages', pkg, 'src');
  }
}

七、版本管理和发布的原理

1. 版本依赖更新算法

class VersionManager {
  updateVersions(packages, versionMap) {
    for (const pkg of packages) {
      // 1. 更新自己的版本
      pkg.version = versionMap[pkg.name];

      // 2. 更新依赖中的内部包版本
      this.updateDependencies(pkg, versionMap);
    }
  }

  updateDependencies(pkg, versionMap) {
    const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];

    for (const depType of depTypes) {
      if (!pkg[depType]) continue;

      for (const [depName, currentVersion] of Object.entries(pkg[depType])) {
        if (versionMap[depName]) {
          // 这是内部依赖,需要更新版本
          pkg[depType][depName] = `^${versionMap[depName]}`;
        }
      }
    }
  }
}

2. 发布时的包过滤

function getChangedPackages(commitRange) {
  // 1. 获取变更的文件
  const changedFiles = git.getChangedFiles(commitRange);

  // 2. 映射文件到包
  const changedPackages = new Set();

  for (const file of changedFiles) {
    const pkg = findOwningPackage(file);
    if (pkg) {
      changedPackages.add(pkg);

      // 3. 递归添加依赖此包的包
      const dependents = findDependents(pkg);
      for (const dep of dependents) {
        changedPackages.add(dep);
      }
    }
  }

  return Array.from(changedPackages);
}
❌