工程化
浏览器是如何渲染页面的
- 浏览器从后端获取到html文件
- 浏览器将html文本拆成token,流程如下,逐字符读取(scanner):
- 读到 <:判断 “开始标签的起始”,继续读后面的字符;
- 读到 p:判断是标签名(
<p>),生成一个 “开始标签 Token”,类型标记为StartTag:p; - 读到空格:判断 “标签名结束,接下来是属性”,继续读;
- 读到 c-l-a-s-s:识别为属性名 “class”,再读到 = 和 "text",生成一个 “属性 Token”,关联到前面的StartTag:p;
- 读到 >:判断 “开始标签结束”,完成StartTag:p的生成;
- 读到 H-e-l-l-o W-o-r-l-d:没有 < 开头,判断是文本内容,生成 “文本 Token”,内容为 “Hello World”;
- 读到 <:再次进入标签识别,继续读 /p>,生成 “结束标签 Token”,类型标记为EndTag:p。
- 最终这一段文本会拆成 3 个核心 Token:[StartTag:p(属性:class="text"), Text:Hello World, EndTag:p]
- 通过token生成dom节点:一边读取token,一边通过栈来存储,因为标签都需要闭合,所以可以形成一个个dom节点
- 通过嵌套关系构成dom树
- 当html解析中遇到
<link>链接的时候,会请求链接对应的css文件,如果是行内的<style>则直接解析,后面定义的css,如果前者更高的优先级就会被后者覆盖 - 通过DOM和CSSOM构建RenderTree,因为css的条数众多,因此在匹配css和对应节点的时候,使用的是从右到左的匹配方式,可以筛选掉大量的不匹配节点
- 下一步进行布局计算(回流/重排 reflow),需要根据节点的样式信息和层级关系,计算元素在屏幕上的位置、宽高、间距等
- 一般以html根元素的大小为基准,根元素的大小受视口大小影响,定义的root样式作用在html根元素上,body包含了所有的可见元素
- 基于盒子模型确定元素的大小
- 根据布局模式确定元素的排列(正常流,浮动,定位,flex,grid)
- 递归计算,从根节点开始,先计算父元素的布局信息,再将必要的约束传递给子元素,子元素基于这些约束计算自己的布局,同时可能将结果反馈给父元素,形成 “父→子→父” 的递归循环。
- 下一步进行重绘,即填充像素,浏览器会分成多个绘制层计算
- 将多个绘制层合并成多个合并层
- 引入script
- 引入script时候,会打断原来DOM的解析过程
- 引入async script。导致的结果就是异步下载script,拿到script后再执行,实质还是会阻塞html的解析,只不过大多数情况下,在获取到script之后,dom已经加载完毕了
- 只有外部脚本才可以使用async
- defer script就是异步加载,但是等到dom加载完毕后再执行script
- 默认script写在不同地方的影响
- 如果写在header中,会请求并加载script,导致无法获取script中的dom对象,可能部分功能失效
- 如果写在body的顶部,其结果与header中一致
- 如果写在body中部,则可以获取到script标签上方的dom元素
- 如果写在body底部和body后,则可以获取到全量的dom元素
- 引入script时候,会打断原来DOM的解析过程
补充:
- css编写方式的影响:使用style编写的时候,会自动执行解析,而使用link的时候,会先请求,待请求完毕后再解析
白屏
可能是资源过大,当资源未完全加载完成的时候,浏览器不会渲染DOM
可能是浏览器引入的script脚本中,存在死循环导致阻塞
当我点击页面上一个button两次发生了什么
所有的 addEventListener 触发的回调,均会按序添加到一个临时的事件处理队列,默认的addEventListener 监听的是冒泡阶段。等到一个捕获->目标->冒泡流程完成后,将临时队列中的内容添加到宏任务队列中等待事件循环机制执行
事件捕获
浏览器从 最顶层的文档根节点(document) 开始,向下遍历 DOM 树,直到找到被点击的 button 元素。
如果经过的节点在addEventLister中配置了 {capture:true} ,则会把 click 对应的回调函数添加到临时任务队列
事件目标
将 button 的点击事件添加到临时任务队列
事件冒泡
事件从目标元素 button 开始,向上回溯 DOM 树,回到文档根节点。同时将经过元素的 click 回调事件加入临时任务队列
以上是单次点击的流程,如果点击两次则会触发两次,同时浏览器会监听两次单击的时间间隔,如果间隔少于一定时间,则会触发dbclick事件。dbclick 事件同样会走捕获->目标->冒泡的流程
最后将临时任务队列中的回调添加到全局的宏任务队列等待事件循环机制执行
PS:原生的dbclick一定先触发两次click事件,如果需要取消click事件,需要在click 回调中监听两次操作的间隔。
Finally:[第一次点击的捕获回调 → 第一次点击的目标回调 → 第一次点击的冒泡回调 → 第二次点击的捕获回调 → 第二次点击的目标回调 → 第二次点击的冒泡回调 → dblclick的捕获回调 → dblclick的目标回调 → dblclick的冒泡回调]
addEventListener(type, listener, options)
type: 事件名称,大小写敏感
listener: 回调事件
options: 可选配置参数
capture: boolean。表示事件是在捕获阶段事发还是冒泡阶段触发,默认为false,即冒泡阶段触发
once:boolean。为true时,当执行一次完毕后就会销毁监听器
对目标元素的 capture 配置无论是 true 还是 false ,都会在目标阶段执行,但 capture 为 true 的事件会在 capture 为 false 的事件前执行(可以对一个目标元素的一个事件配置两个回调函数,通过 capture 来区分出先后)。对于 capture 配置一样的则是先绑定的先执行。
Vite是如何实现冷启动和热更新的
是基于 ESM 模式实现的,Vite需要在入口文件的<script>中配置type="module",使得浏览器可以执行<script>中的import。添加了type="module" 会使得 script 有 defer 的效果
当然,原生的 JS 是不支持import xx from 'xx'这种模块路径的 ,因此 Vite 启动了一个开发服务器,拦截浏览器的模块请求,并动态解析和返回模块内容(也就是 localhost:xx ),比如说解析包地址(类似将'vue'解析到 node_modules 中),通过 ESM 的方式加载模块,Vite 会将非 JS 文件(如 .vue、.ts)通过编译工具转换为浏览器可识别的 JS 模块。
一般来说,本地的虚拟服务器指向的是index.html这个入口文件,然后会逐步解析(index.html中引入的script,该后续引入的script和import)。通过ESM的方式导入到浏览器中,如果导入的文件发生了更新,Vite会捕获到更新,并同步到浏览器中。
Vite可以实现热更新是因为 Vite 启动的服务器与浏览器是建立了Websocket的连接,当浏览器第一次打开本地服务器地址的时候,会通过http建立Websocket的连接,后续的信息交换都是通过websocket实现的

从图片中可以看到,当我修改了Vite中的vue文件时,会通过 websocket 实现信息的传递,当浏览器接收到信息后,会获得组件的地址,以此重新加载组件。当开启了热更新后,每次更新组件之后都会通知浏览器重新获取更新的组件。如果关闭了热更新的话(hmr:false),Vite 不会再在组件更新的时候通知浏览器,需要手动刷新
淘宝/天猫如何实现登录态共享的
可以通过cookie实现
以下为个人理解:
阿里系的登录页面都是.taobao.com域名下的(或者登录的接口是.taobao.com),通过url中的query参数区分登录途径,登录页面地址类似(以下为tmall):
https://login.taobao.com/havanaone/login/login.htm?bizName=taobao&spm=a21bo.tmall/a.754894437.1.6614c3d5ZKPh65&f=top&redirectURL=https%3A%2F%2Fwww.tmall.com%2F
在以上url中登录的时候,如果登录成功,会向.taobao.com中写入cookie,同时携带cookie向以下的url发送一个POST请求。
https://login.taobao.com/newlogin/silentHasLogin.do?documentReferer=https%3A%2F%2Fwww.tmall.com%2F&appName=taobao&appEntrance=tmallSdkSilent_nav&fromSite=0<l=true

cookie验证成功后,会返回一些同系统内的重定向地址(这些网站的登录页面都是.taobao.com下的,具体的说就是第一个url),同时通过query明文传输cookie

当接受到重定向地址后,通过js向dom中添加img元素,src指向重定向地址

此时会向这些url发送请求,后端会验证url中的cookie,如果验证成功,则会给对应的域名设置一个cookie,这样就实现了登录态的共享
上图中有登录飞猪、天猫的过程,但图中没有出现taobao的授权过程
taobao的授权过程不同,taobao的域名和登录的域名一致,因此授权过程是通过set-cookie的方式实现的,同时添加了P3P和SameSite='none'属性来扩展cookie

图片懒加载
一种比较简单的方法是给图片加上loading="lazy",这是HTML5中提供的原生懒加载属性。另一种图片懒加载最常见的实现方式是使用视口观察器 IntersectionObserver。它可以监听某个元素是否进入视口,而不需要手动监听 scroll 事件并频繁计算位置,因此性能更好。
实现思路
- 给图片先设置一个占位图,真实图片地址先放到
data-src中。 - 创建
IntersectionObserver,监听图片元素是否进入视口。 - 当图片进入视口时,把
data-src赋值给src。 - 图片加载完成后,取消观察,避免重复触发。
- 可以根据不同的规则,拆分多个
IntersectionObserver来监听不同的元素。
示例代码
<!-- img里的定义的(data-*)属性会统一放到img.dataset,*会作为dataset中的属性(并转成驼峰法),比如data-real-src会变成img.dataset.realSrc -->
<img src="placeholder.jpg" data-src="real.jpg" class="lazy-img" />
<script>
const images = document.querySelectorAll('.lazy-img');
const observer = new IntersectionObserver((entries, observerInstance) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src; // 此时才真正加载图片real.jpg
img.onload = () => observerInstance.unobserve(img);
});
}, {
root: null,
threshold: 0.1,
rootMargin: '0px 0px 200px 0px', // 上、右、下、左。基于root做偏移,默认视口
});
images.forEach((img) => observer.observe(img));
</script>说明
root: null表示以浏览器视口作为观察区域。threshold表示目标元素可见到什么比例时触发回调。rootMargin可以提前触发加载,比如图片距离视口还有 200px 时就开始请求,减少白屏等待。- 图片加载完成后要记得
unobserve,否则滚动时会继续回调。
优点
- 减少
scroll事件带来的频繁计算。 - 浏览器原生支持,代码更简洁。
- 支持按需加载,适合长列表、文章流、图片墙等场景。
也有的旧版浏览器不支持 IntersectionObserver,可以使用 scroll 事件监听来实现懒加载,但需要注意性能优化,比如使用节流函数来限制回调频率。
降级策略
如果浏览器不支持 IntersectionObserver,就退回到 scroll + resize + getBoundingClientRect 的方案。
<img src="placeholder.jpg" data-src="real.jpg" data-loaded="false" class="lazy-img" />
const images = Array.from(document.querySelectorAll('.lazy-img'));
function loadImage(img) {
const realSrc = img.dataset.src;
if (!realSrc || img.dataset.loaded === 'true') return;
img.src = realSrc;
img.dataset.loaded = 'true';
}
function isInViewport(element) {
const rect = element.getBoundingClientRect();
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
}
let timer = null;
function checkImages() {
images.forEach((img) => {
if (img.dataset.loaded === 'true') return;
if (!isInViewport(img)) return;
loadImage(img);
});
}
function onScrollOrResize() {
if (timer) return;
timer = setTimeout(() => {
checkImages();
timer = null;
}, 100);
}
checkImages(); // 首次检查,加载初始可见的图片
window.addEventListener('scroll', onScrollOrResize, { passive: true });
window.addEventListener('resize', onScrollOrResize);说明
scroll兜底方案要配合节流,否则滚动时会频繁触发计算。getBoundingClientRect()可以判断元素和视口的相对位置。resize也要一起监听,因为视口变化后图片位置可能发生变化。
虚拟列表
虚拟列表(Virtual List)是一种性能优化技术,主要用于渲染大量数据时,避免一次性渲染所有 DOM 元素导致的性能问题。它通过只渲染当前可见区域内的元素,并在用户滚动时动态更新渲染的内容,从而大幅提升页面性能。
实现思路
- 只创建固定数量的 DOM 节点,作为“可复用的视图容器”。
- 通过滚动位置计算当前应该展示的数据范围。
- 不直接
push新节点,而是复用现有节点,替换它们绑定的数据内容。 - 因为 item 高度不固定,所以要先把每个 item 的高度测出来,再用前缀和快速定位滚动位置对应的数据索引。
示例代码
<div id="list" class="virtual-list"></div>
<script>
const container = document.getElementById('list');
const data = Array.from({ length: 1000 }, (_, index) => ({
id: index + 1,
title: `第 ${index + 1} 条数据`,
content: '这里是一段比较长的内容,用来模拟不定高 item。'.repeat((index % 5) + 1),
}));
const bufferCount = 3; // 上下缓冲区
const visibleCount = 10; // 可见区域内的 item 数量(大约)
const poolSize = visibleCount + bufferCount * 2;
const pool = [];
const itemHeights = data.map(() => 0);
const prefixHeights = data.map(() => 0);
// 初始化 DOM 池
for (let i = 0; i < poolSize; i++) {
const item = document.createElement('div');
item.className = 'virtual-item';
item.style.position = 'absolute';
item.style.left = '0';
item.style.right = '0';
pool.push(item);
container.appendChild(item);
}
function renderItem(node, itemData, index) {
node.dataset.index = String(index);
node.innerHTML = `
<h4>${itemData.title}</h4>
<p>${itemData.content}</p>
`;
}
function measureItem(node, index) {
const height = node.getBoundingClientRect().height; // 获取当前item的实际高度
if (itemHeights[index] === height) return; // 高度没变就保持
itemHeights[index] = height; // 高度变了就更新高度
updatePrefixHeights();
}
function updatePrefixHeights() {
let sum = 0;
for (let i = 0; i < data.length; i++) {
prefixHeights[i] = sum; // 前缀和:每个索引存储前面所有item的高度总和
sum += itemHeights[i] || 120; // 如果还没测量过,先用一个默认值(比如120)占位,后续测量后会更新前缀和
}
container.style.height = `${sum}px`; // 设置容器高度
}
// 二分查找当前滚动位置对应的数据索引,scollTop 落在 prefixHeights[i-1] 和 prefixHeights[i] 之间,那么 i-1 就是当前应该渲染的起始索引。
function binarySearch(scrollTop) {
let left = 0;
let right = data.length - 1;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const top = prefixHeights[middle];
const bottom = top + (itemHeights[middle] || 120);
if (scrollTop >= bottom) {
left = middle + 1;
} else if (scrollTop < top) {
right = middle - 1;
} else {
return middle;
}
}
return Math.max(0, left - 1);
}
function render() {
const scrollTop = container.scrollTop; // 在容器内的滚动距离
const startIndex = Math.max(0, binarySearch(scrollTop) - bufferCount);
for (let i = 0; i < pool.length; i++) {
const dataIndex = startIndex + i;
const node = pool[i];
if (dataIndex >= data.length) { // 超出数据范围,隐藏节点
node.style.display = 'none';
continue;
}
node.style.display = 'block';
node.style.transform = `translateY(${prefixHeights[dataIndex]}px)`;
if (node.dataset.index !== String(dataIndex)) {
renderItem(node, data[dataIndex], dataIndex);
}
}
// 渲染完成后测量实际高度,更新前缀和,下一次滚动时就能更准确地定位
requestAnimationFrame(() => {
pool.forEach((node) => {
const index = Number(node.dataset.index);
if (Number.isNaN(index)) return;
measureItem(node, index);
});
});
}
container.addEventListener('scroll', render);
updatePrefixHeights();
render();
</script>说明
- 这个实现的关键不是“新增节点”,而是“复用节点、替换内容”。
pool是固定数量的 DOM 容器,滚动时只改它们绑定的数据,不会无限增长。- item 不定高时,不能只依赖固定高度计算,所以要维护
itemHeights和prefixHeights。 binarySearch用来根据scrollTop快速找到当前应该渲染的起始索引。- 首次渲染后通过
getBoundingClientRect()测量真实高度,再回写前缀和,后续滚动会更准确。
优点
- DOM 数量稳定,不会随着数据量增加而膨胀。
- 适合超长列表、聊天记录、日志流等场景。
- 通过内容复用而不是 push 新节点,更新成本更低。
git和svn 的区别
| 问题 | Git | SVN |
|---|---|---|
| 模型类型 | 分布式(Distributed) | 集中式(Centralized) |
| 核心差异 | - 每个开发者本地有完整仓库副本 - 数据分散存储,支持离线操作 | - 所有代码存储在中央服务器 - 必须联网才能提交代码 |
| 分支实现 | - 轻量级分支(创建 / 切换耗时毫秒级) - 基于指针(commit 哈希),不复制文件 | - 重量级分支(基于文件系统复制) - 耗存储、操作慢(尤其大项目) |
| 合并能力 | - 支持三方合并(自动识别共同祖先) - 冲突解决灵活(可视化工具如 VS Code) | - 依赖路径匹配,跨分支合并易出错 - 复杂场景需手动处理冲突 |
git常用命令
git init // 新建 git 代码库
git add // 添加指定文件到暂存区
git rm // 删除工作区文件,并且将这次删除放入暂存区
git commit -m [message] // 提交暂存区到仓库区
git branch // 列出所有分支
git checkout -b [branch] // 新建一个分支,并切换到该分支
git status // 显示有变更文件的状态git pull 和 git fetch 的区别
- git fetch 只是将远程仓库的变化下载下来,并没有和本地分支合并。
- git pull 会将远程仓库的变化下载下来,并和当前分支合并。
git rebase 和 git merge 的区别
git merge 和 git rebase 都是用于分支合并,关键在 commit 记录的处理上不同:
- git merge 会新建一个新的 commit 对象,然后两个分支以前的 commit 记录都指向这个新 commit 记录。这种方法会保留之前每个分支的 commit 历史。
- git rebase ,当前所在分支为源分支,rebase xx中的xx为目标分支,会先找到两个分支的第一个共同的 commit 祖先记录,然后将提取源分支这之后的所有 commit 记录,然后将这个 commit 记录添加到目标分支的最新提交后面。经过这个合并后,两个分支合并后的 commit 记录就变为了线性的记录了。
设计模式
创造模式
单例模式
确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
举例:全局的window,事件总线,全局状态管理(如vuex、redux,全局只有一个store)等
工厂模式
简单工厂
通过一个工厂生产所有产品
类似组件库里的弹窗组件,弹窗组件根据传入的参数来决定是生产一个alert、confirm还是prompt组件
// 产品接口
class Product {
operation() { return "Base product"; }
}
// 具体产品
class ConcreteProductA extends Product {
operation() { return "Product A"; }
}
class ConcreteProductB extends Product {
operation() { return "Product B"; }
}
// 简单工厂
class Factory {
createProduct(type) {
if (type === "A") return new ConcreteProductA();
if (type === "B") return new ConcreteProductB();
throw new Error("Invalid product type");
}
}
// 使用工厂
const factory = new Factory();
const productA = factory.createProduct("A");
console.log(productA.operation()); // 输出: "Product A"工厂方法
工厂提供一个接口,子类实现接口
// 工厂基类
abstract class Creator {
abstract factoryMethod(): Product; // 抽象产品,具体产品由子类实现
someOperation() {
const product = this.factoryMethod();
return `Operation with ${product.operation()}`;
}
}
// 具体工厂
class ConcreteCreatorA extends Creator {
factoryMethod() { return new ConcreteProductA(); }
}
class ConcreteCreatorB extends Creator {
factoryMethod() { return new ConcreteProductB(); }
}
// 使用工厂
const creatorA = new ConcreteCreatorA();
console.log(creatorA.someOperation()); // 输出: "Operation with Product A"原型模式
原型模式是基于已有对象(原型)创建新对象,新对象会继承原型的属性和方法,避免重复定义相似对象。
结构型模式
代理模式
代理模式为一个对象提供 “代理” 对象,外界通过代理对象访问原对象,代理可以在访问前后添加额外逻辑(如缓存、权限校验、日志记录)。
前端中常见的代理模式应用是 JavaScript 的 Proxy 对象,它可以拦截对目标对象的访问,并在访问前后执行自定义逻辑。路由守卫、环境沙箱 也是这种设计思路
适配器模式
当两个接口不兼容(如旧接口与新系统不匹配),通过一个 “适配器” 中间层,将一个接口转换为另一个接口,使原本无法一起工作的组件可以协同
// 目标接口(新系统需要的接口)
interface ImagePrinter {
printImage(image: string): void;
}
// 被适配者(旧接口,不兼容新系统)
class OldPrinter {
printText(text: string): void {
console.log(`OldPrinter 打印文本:${text}`);
}
}
// 适配器(组合被适配者,转换接口)
class PrinterAdapter implements ImagePrinter {
private oldPrinter: OldPrinter; // 持有被适配者实例
constructor(oldPrinter: OldPrinter) {
this.oldPrinter = oldPrinter;
}
// 将 printImage 转换为旧接口的 printText
printImage(image: string): void {
// 模拟图片转文本逻辑(实际可能更复杂)
const text = "图片内容"+image;
this.oldPrinter.printText(text);
}
}
// 使用示例
const oldPrinter = new OldPrinter();
const adapter = new PrinterAdapter(oldPrinter);
adapter.printImage("风景.jpg"); // 输出:OldPrinter 打印文本:图片内容:风景.jpg装饰器模式
js中还是试验性功能,ts中可以使用
主要用在类和方法上,相较于继承是静态的,装饰器可以动态添加,会在类或者方法定义完后再运行装饰器
行为型设计模式
观察者模式
观察者模式定义了对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,并自动更新
被观察者内部维护了一个观察者数组,当自身发生变化时,会触发观察者的更新函数
vue3中的响应式就是利用了观察者模式,定义时收集依赖,修改状态时,会触发依赖的更新。浏览器中的事件监听机制也是观察者模式的一个典型应用,事件源(被观察者)维护了一个事件监听器列表(观察者),当事件发生时,事件源会通知所有注册的监听器执行相应的回调函数。
发布订阅模式
发布者和订阅者需要通过发布订阅中心进行关联,发布者的发布动作和订阅者的订阅动作相互独立,无需关注对方,消息派发由发布订阅中心负责。
类似vue中的事件总线机制,通过on将事件和回调挂载到总线上,通过emit触发事件来执行所有的相关回调。
迭代器模式
迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。
在JS中,迭代器模式通过 “迭代协议” 实现:
- 可迭代协议:对象必须有[Symbol.iterator]方法,该方法返回一个迭代器对象。
- 迭代器协议:迭代器对象必须有next()方法,每次调用返回{ done: boolean, value: any }(done表示是否遍历结束,value表示当前值)。
组件库
如何对一个组件库进行打包使其可以生成UMD、ESM等不同格式的包
- 在
package.json中配置不同的build命令,分别对应不同的模块格式
{
"scripts": {
"build-es": "vite build --config vite.es.config.ts",
"build-umd": "vite build --config vite.umd.config.ts",
}
}- 创建不同的
vite配置文件,分别指定输出格式
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// ...
// vite.config.es.ts
build: {
outDir: 'dist/es',
lib: {
entry:resolve(__dirname, 'src/index.ts'),
name: 'wy-comp',
fileName:'wy-components',
formats: ['es'],
},
rollupOptions: {
external: ['vue'], // 将vue作为外部依赖,不打包到组件库中,使用组件的应用需要安装vue,并且在代码中import Vue
},
},
// vite.config.umd.ts
build: {
outDir: 'dist/umd',
lib: {
entry:resolve(__dirname, 'src/index.ts'),
name: 'wy-comp',
fileName:'wy-components',
formats: ['umd'],
},
rollupOptions: {
external: ['vue'], // 将vue作为外部依赖,不打包到组件库中
output: {
exports: 'named', // 以命名方式导出组件库的API
globals: {
vue: 'Vue', // 在UMD模式下,外部依赖vue将通过全局变量Vue访问,一般external配置的key是包名,globals配置的key也是包名,value是全局变量名
},
},
},
});乾坤微服务架构
乾坤是阿里巴巴开源的微前端框架,主要用于构建微前端应用。它允许开发者将一个大型的前端应用拆分成多个独立的子应用,每个子应用可以由不同的团队独立开发、部署和维护,同时又能无缝地集成在一起。
原理
1. 应用加载机制 (HTML Entry)
乾坤通过 import-html-entry 库实现子应用的加载。区别于传统的 JS Entry(只加载一个 JS 文件),HTML Entry 直接请求子应用的 HTML 链接。
- 解析 HTML:通过正则匹配请求回来的 HTML 文本,剥离出
script、link和style标签。 - 静态资源预加载:在解析的同时,乾坤会自动预下载子应用的静态资源,提高切换速度。
- 子应用导出:子应用通过 Webpack 打包成
umd格式,在 HTML 中执行后,乾坤会从 window 上捕获子应用导出的bootstrap、mount、unmount生命周期钩子。
2. JS 隔离 (沙箱机制)
为了防止子应用污染全局 window 对象,乾坤实现了三种沙箱:
SnapshotSandbox (快照沙箱):
- 原理:在子应用挂载前记录当前 window 的快照,卸载时对比差异并恢复原样,同时记录子应用的修改。
- 缺点:无法支持多实例(同时运行多个子应用)。
LegacySandbox (单例沙箱):
- 原理:基于
Proxy监听 window 修改。它其实也是一种 Proxy 沙箱,但它直接操作的是真实的window对象,通过set捕获修改并记录在addedPropsMapInSandbox、modifiedPropsOriginalValueMapInSandbox等 Map 中。 - 卸载:根据记录的 Map 将 window 还原回原始状态。
- 缺点:由于它直接操作真实 window,同一时间只能运行一个子应用,否则状态会混乱。
- 原理:基于
ProxySandbox (多实例沙箱):
- 原理:基于
Proxy实现。它不再操作真实 window,而是为每个子应用创建一个空白的fakeWindow(通常是一个Map或普通对象)。 - 读写分离:
- 写:所有的赋值操作都发生在
fakeWindow上。 - 读:先从
fakeWindow中找,如果找不到,再从真实window中读。
- 写:所有的赋值操作都发生在
- 优点:每个子应用拥有完全独立的
fakeWindow上下文,互不干扰,从而完美支持多实例。这是目前乾坤最核心、性能最好的隔离方案。
多实例沙箱 (ProxySandbox) 简化实现示例:
javascriptclass ProxySandbox { constructor() { const rawWindow = window; const fakeWindow = {}; const proxy = new Proxy(fakeWindow, { set(target, p, value) { target[p] = value; // 所有的写操作都限制在 fakeWindow 内 return true; }, get(target, p) { // 优先从 fakeWindow 读,找不到再从原生 window 读 return p in target ? target[p] : rawWindow[p]; } }); this.proxy = proxy; } } // 子应用 A 和 B 分别拥有独立的沙箱 const sandboxA = new ProxySandbox(); const sandboxB = new ProxySandbox(); // 模拟子应用 A 运行 ((window) => { window.a = 'appA'; console.log('AppA window.a:', window.a); // 'appA' })(sandboxA.proxy); // 模拟子应用 B 运行 ((window) => { window.a = 'appB'; console.log('AppB window.a:', window.a); // 'appB' })(sandboxB.proxy); console.log('真实 window.a:', window.a); // undefined,全局 window 未受污染代码要点解释:
- 立即执行函数 (IIFE) / 闭包的作用:
- 打破全局绑定:
- 在浏览器全局环境下,
var a = 1会自动成为window.a。 - 但在 函数内部,
var a = 1只会成为该函数的 局部变量,不会挂载到window上。
- 在浏览器全局环境下,
- 显式调用的拦截:
- 子应用代码中如果写
window.a = 1(明着调用),由于函数形参中定义了window,根据 词法作用域 规则,它会找最近的变量,即我们传入的proxy。 - 这样,即使子应用“固执”地要找
window,它抓到的也是我们给它的“假 window”(沙箱代理)。
- 子应用代码中如果写
- 实现原理:这本质上是利用了 JS 的作用域链查找规则。闭包在这里就像一个“过滤器”,把子应用原本要发送给全局
window的请求全拦截了下来,转发给了代理对象。
- 打破全局绑定:
- 原理总结:通过
IIFE+Proxy,乾坤让每个子应用都“以为”自己在操作真实的全局window,但实际上它们都在操作各自独立的“影子”对象。
- 原理:基于
3. 样式隔离
- Shadow DOM:启用
strictStyleIsolation: true时,乾坤会将子应用包裹在 Shadow DOM 中。- 优点:最严格的隔离。
- 缺点:某些第三方库(如模态框)挂载在 body 下,会导致样式失效。
- Scoped CSS:启用
experimentalStyleIsolation: true时,乾坤会动态修改 css 规则,为所有选择器加上应用前缀(类似 Vue 的 scoped)。 - 约定隔离:通过 BEM 命名规范或 CSS Modules 进行项目级约定。
4. 应用间通信
- Actions 通信 (官方方案):
- 基于 发布订阅模式。
- 乾坤提供
initGlobalState方法初始化全局状态,返回onGlobalStateChange和setGlobalState。 - 主应用和子应用都可以监听和修改状态。
- Props 通信:
- 主应用在注册子应用时,可以通过
props参数将数据或方法(如回调函数)传递给子应用。 - 子应用在
mount生命周期钩子中接收这些参数。
- 主应用在注册子应用时,可以通过
- 浏览器原生方案:
- 使用
CustomEvent自定义事件。 - 或者利用
localStorage、IndexedDB等本地存储。
- 使用
5. 路由拦截
乾坤劫持了原生的 pushState 和 replaceState 事件,当 URL 变化时,匹配对应的子应用并触发生命周期切换。
混合模型
移动端通过混合模型来实现原生和 WebView 的交互。原生通过 JS Bridge 暴露接口给 WebView,WebView 通过调用这些接口来获取原生能力,比如获取地理位置、调用摄像头等。同时,原生也可以通过 JS Bridge 调用 WebView 中的 JS 函数来实现一些交互,比如更新页面内容、触发动画等。
WebView 的加载过程
- 前期准备
- 前端打包 H5 项目,生成 dist 目录(包含 index.html、css、js、img、font)
- 后台将 dist 压缩为 zip 离线包,带上版本号,提供下载地址
- App 内维护离线包版本管理、下载、解压、映射表
- App 启动 / 闲时:预下载 & 解压离线包
- 请求后台接口,获取当前最新离线包信息
- 包含 url、version、md5、对应 H5 业务入口
- 对比本地已有版本
- 无此包时,完整下载
- 旧版本时,下载差分包或完整包更新
- 下载完成后校验 md5,防止损坏
- 解压到 App 私有目录,例如
offline/shop_activity/ - 建立 URL 到本地文件路径的映射表
https://xxx.com/h5/shop.html -> 本地路径/index.html
https://xxx.com/h5/js/app.js -> 本地路径/js/app.js- 打开 H5 页面:原生创建并配置 WebView
- 创建 WebView 实例
- 开启 JS、DomStorage、缓存
- 设置 UA(用于 H5 判断 App 环境)
- 设置监听:加载开始、完成、错误、资源拦截
- 开始加载:webView.loadUrl("https://xxx.com/h5/shop")
- WebView 内核开始发起网络请求,准备获取 HTML、CSS、JS 等资源。
- 核心:拦截资源请求 → 优先使用离线包
- 这一步是离线包能生效的关键。
- WebView 每请求一个资源(html/css/js/img...)
- 原生在拦截方法中捕获该 URL
- 查询离线包映射表
- 命中时,直接读取本地文件返回给 WebView
- 未命中时,继续走正常网络请求
- WebView 拿到资源后继续解析渲染
- 这一步用户完全无感,但页面会变成本地秒开
- WebView 解析 & 渲染页面
- 解析 HTML 生成 DOM
- 解析 CSS 生成 CSSOM
- 执行 JS 逻辑
- 布局、绘制、显示页面
- 接口请求仍然走正常网络,不受离线包影响
- H5 ↔ 原生双向通信(JSBridge)
- H5 通过 window.xxxBridge 调用原生能力,如定位、支付、扫码、访问存储空间,这些通常需要 App 壳提供桥接支持。
- 页面关闭 & 后续复用
- 页面退出,WebView 销毁或回池
- 离线包依然存在本地
- 下次打开同一 H5 时,直接再次走本地拦截,瞬间打开
- 离线包更新机制
- App 启动或进入相关业务时,检查后台新版本
- 下载新包、校验、解压覆盖
- 映射表更新
- 下次加载自动使用新版资源,实现热更新
路由功能实现
前端路由的核心在于:改变 URL 时不引起页面的完全刷新,并且能够监听到 URL 的变化,从而动态渲染对应的视图组件。
无论是 Vue Router 还是 React Router,底层都是基于浏览器的两种核心 API 实现的:Hash 和 History。
1. 浏览器底层 API
① Hash 模式
- 原理:基于 URL 的 hash 字段(即
#及后面的部分)。hash 值的改变不会导致浏览器向服务器发送请求,因此也不会刷新页面。 - API 与监听:
- 读取/修改:
window.location.hash - 监听机制:通过监听
hashchange事件来捕获变化。当用户改变 hash(例如手动修改、点击带 hash 的<a>标签、或者使用前进后退按钮)时,会触发hashchange事件。
- 读取/修改:
② History 模式
- 原理:基于 HTML5 引入的 History API。相比 Hash 模式,URL 更加美观(没有
#)。 - API 与监听:
- 修改 URL:使用
history.pushState(state, title, url)和history.replaceState(state, title, url)。调用这两个方法改变 URL 时,浏览器不会刷新页面,也不会主动触发任何事件(包括popstate)。pushState会在浏览器的历史记录栈中新增一条记录。修改 URL 后,用户点击浏览器的“后退”按钮可以回到之前的页面。replaceState会替换当前的历史记录项。它不会增加历史记录栈的长度,修改 URL 后,用户点击“后退”按钮会跳过被替换的这个状态,直接回到前一个页面。
- 监听机制:通过监听
popstate事件。只有在用户点击浏览器的前进/后退按钮,或者通过 JS 调用history.back(),history.forward(),history.go()时,才会触发popstate事件。
- 修改 URL:使用
- 注意:History 模式在刷新页面时,会将完整 URL 发送到服务器请求资源。如果服务器没有配置对应的路由兜底转发(如全部重定向到
index.html),会返回 404 错误。
2. Vue Router 的实现机制
Vue Router 的核心是利用了 Vue 的 响应式系统。
- 初始化与监听:在应用启动时,Vue Router 会根据配置初始化 Hash 或 History 路由实例,并给全局
window绑定hashchange或popstate事件。 - 响应式状态:内部维护了一个响应式的对象(在 Vue 2 中通常使用
Vue.observable,Vue 3 中是reactive/ref),保存当前的路由信息currentRoute。 - 触发更新:
- 手动跳转:调用
router.push('/about')时,Vue Router 内部调用原生的history.pushState改变 URL,并主动修改内部响应式的currentRoute对象。 - 原生变动:用户点击浏览器后退触发
popstate或hashchange事件,Vue Router 在事件回调中解析当前最新 URL,并更新currentRoute。
- 手动跳转:调用
- 视图渲染:页面上的
<router-view>组件在渲染阶段收集了对响应式currentRoute的依赖。只要currentRoute发生变化,就会触发<router-view>的重新计算和渲染,从而挂载匹配的新组件并销毁旧组件。
3. React Router 的实现机制(详细解析)
React Router 的核心机制可以总结为一句话:监听 URL 变化 -> 触发顶层组件的 setState -> 通过 Context 传递新 URL -> 下层组件拿新 URL 重新匹配并渲染。
具体拆解为以下四个步骤:
① 依赖 history 库抹平差异
React Router 底层并没有直接裸写原生的 pushState 或监听 popstate,而是引入了一个第三方的专门处理路由的库,就叫 history。
- 这个库封装了 Hash 和 History 模式,提供了一致的 API(例如
history.push)。 - 它的核心作用是搭建了一个发布-订阅模型:当 URL 改变时,它可以主动通知所有订阅了它的函数。
② 顶层 <Router> 的状态管理与事件订阅
不论你使用的是 <BrowserRouter> 还是 <HashRouter>,它们内部都会渲染一个基础的 <Router> 组件。这个顶层组件做了两件最重要的事情:
- 存状态:它使用 React 的 State 存下了当前的路由信息,也就是
location对象(表示当前路径是哪里)。 - 做监听:在组件挂载时(由于它是个外层组件,最先挂载),它调用了类似于
history.listen()的方法,告诉history库:“只要地址栏网址变了,你就喊我”。
③ 通过 Context 向下广播状态
React 的组件树可能非常深,底层的那些 <Route> 组件怎么知道当前的 URL 是多少呢?如果一层层传 props 太麻烦了。 这就是 Context API 的作用。顶层的 <Router> 把它存在 State 里的 location 对象,放到了一个全局可见的 Context.Provider 中。底下所有的路由组件和钩子(如 useLocation)都可以直接从 Context 中读取到最新的 location。
④ 完整的触发渲染流程(以点击 <Link> 为例)
当用户点击页面上的 <Link to="/about"> 时,应用里会发生这样一场连锁反应:
- 拦截点击:
<Link>组件阻止了<a>标签默认的页面跳转刷新行为。 - 改变 URL:
<Link>内部调用了history.push('/about')。history库底层调用原生的history.pushState悄悄把浏览器地址栏的网址改了,此时页面没有刷新。 - 通知顶层:
history库发现地址改了,触发了之前顶层<Router>绑定的监听回调。 - 触发重渲染(核心):在这个回调函数中,
<Router>获取到了新的路径,并执行了setState({ location: 新路径 })。在 React 中,只要组件setState,就一定会触发组件的重新渲染。 - 视图更新:因为顶层
<Router>重新渲染了,它通过 Context 往下分发的location也变成了最新的。底下的各个<Route path="/xxx">接收到最新的 URL 后,开始和自己的path属性进行对比。如果吻合,就渲染内部包裹的组件;如果不吻合,就返回null(把旧组件卸载掉)。