用 Composer 从零搭一个 PHP mini 框架:路由 + PSR-4 + 容器

想理解 Laravel、Symfony 是怎么跑起来的,别一头扎进它们的源码——十几万行随便一个 ServiceProvider 就把人绕晕。自己用 Composer 手写一个 100 行的 mini 框架,把路由、自动加载、IoC 弄明白,再去看大框架就轻松了。 目录结构 my-framework/ ├── app/ │ └── Controller/ │ └── HomeController.php ├── public/ │ └── index.php ├── src/ │ ├── Router.php │ ├── Container.php │ └── Response.php ├── vendor/ # composer install 后生成 └── composer.jsonapp/ 是业务代码、src/ 是框架核心、public/ 是入口。 composer.json 与 PSR-4 mkdir my-framework && cd my-framework composer init改 composer.json: { "name": "you/mini-framework", "type": "project", "require": { "php": ">=8.1" }, "autoload": { "psr-4": { "App\\": "app/", "Framework\\": "src/" } } }生成自动加载器: composer dump-autoloadPSR-4 的规则很简单:类的命名空间路径 = 文件相对路径。App\Controller\HomeController 就在 app/Controller/HomeController.php。 Router:50 行的路由 src/Router.php: <?php namespace Framework;class Router { private array $routes = []; public function get(string $path, callable|array $handler): void { $this->routes['GET'][$path] = $handler; } public function post(string $path, callable|array $handler): void { $this->routes['POST'][$path] = $handler; } public function dispatch(): void { $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $handler = $this->routes[$method][$path] ?? null; if (!$handler) { http_response_code(404); echo "404 Not Found"; return; } // 支持 [Controller::class, 'method'] if (is_array($handler)) { [$class, $method] = $handler; $instance = new $class(); echo $instance->$method(); return; } // 支持匿名函数 echo $handler(); } }入口 public/index.php: <?php require __DIR__ . '/../vendor/autoload.php';use Framework\Router; use App\Controller\HomeController;$router = new Router();$router->get('/', fn() => "Hello Framework"); $router->get('/home', [HomeController::class, 'index']);$router->dispatch();Controller: <?php namespace App\Controller;class HomeController { public function index(): string { return "home page"; } }跑起来: php -S localhost:8000 -t public访问 http://localhost:8000/ 和 /home 都能看到内容。 到这里,Laravel 最核心的三件事你都实现了:Composer PSR-4 加载、路由、控制器分发。 第二步:加个 IoC 容器 Laravel 的灵魂其实不是路由,是容器(Container / Service Container)。它做两件事:帮你 new 对象,自动注入构造参数 让你把接口绑定到具体实现,实现依赖倒置最简版本: <?php namespace Framework;use ReflectionClass;class Container { private array $bindings = []; public function bind(string $abstract, callable|string $concrete): void { $this->bindings[$abstract] = $concrete; } public function make(string $class): object { // 1. 有绑定就走绑定 if (isset($this->bindings[$class])) { $c = $this->bindings[$class]; return is_callable($c) ? $c($this) : $this->make($c); } // 2. 反射构造函数依赖,递归 make $ref = new ReflectionClass($class); $ctor = $ref->getConstructor(); if (!$ctor) return new $class(); $args = []; foreach ($ctor->getParameters() as $p) { $type = $p->getType()?->getName(); $args[] = $type ? $this->make($type) : null; } return $ref->newInstanceArgs($args); } }用法: class Logger { public function log(string $msg): void { file_put_contents('log', $msg); } }class UserService { public function __construct(public Logger $logger) {} }$c = new Container(); $user = $c->make(UserService::class); // 自动注入 Logger $user->logger->log("hello");这 40 行代码就是 Laravel Container 的核心思路。真正的 Laravel 加了:方法注入、上下文绑定、单例、tag、event、缓存反射等等,本质还是这个。 后续可以往上加 按需实现,每加一个都是一次深度理解:Middleware — 前后置拦截 Request/Response 对象 View 层(模板引擎或 Blade 简版) ORM(先用 PDO 封个 Query Builder) Event / Listener CLI 命令(symfony/console 或自己写) Config 加载 环境变量(vlucas/phpdotenv)一步步加,你会发现 Laravel 每个模块都能对应上。 一句话总结 Composer PSR-4 + Router + Container,三件套加起来 100 行代码,就是一个能跑的 mini 框架。学 Laravel 前先自己写一遍,再去看源码会很轻松。

开源网盘工具对比:AList / Cloudreve / go-drive

想把 Google Drive 变成自己的网盘前端,有几款成熟的开源工具可以选。 AList(最推荐,个人使用) 定位:单用户多存储聚合,支持在线播放和分享 支持的存储后端:Google Drive、OneDrive、S3、WebDAV、阿里云盘、百度网盘等,几乎覆盖所有主流云存储。 Docker 一键部署: docker run -d \ --name alist \ -p 5244:5244 \ -v /opt/alist:/opt/alist/data \ xhofe/alist:latest初始密码: docker exec -it alist ./alist admin random特点:部署极简,资源占用极低 支持视频在线播放、图片预览 支持目录密码、分享链接 WebDAV 挂载(可接 rclone、Infuse)适合:个人云盘聚合展示,或用 WebDAV 将 Google Drive 挂载到本地工具。 Cloudreve(多用户场景) 定位:完整的网盘系统,支持用户注册和管理 比 AList 功能更重,适合做小型共享网盘站:多用户注册/管理 文件分享链接(有效期、访问次数) WebDAV 支持 离线下载(Aria2 集成) 后台管理面板 存储策略(本地/OneDrive/S3/七牛等)docker run -d \ --name cloudreve \ -p 5212:5212 \ -v /opt/cloudreve:/cloudreve/uploads \ cloudreve/cloudreve:latest适合:需要用户系统、搭建私有云盘分享站的场景。 go-drive(多云聚合) 定位:专注将多个云盘聚合成统一视图 支持:Google Drive、OneDrive、Dropbox、S3、WebDAV、本地存储。 特点:前后端分离,Docker 部署 支持直接上传到各云盘(不经过中转服务器) 断点续传适合:纯聚合需求,不需要用户系统。 功能对比功能 AList Cloudreve go-driveGoogle Drive 支持 ✅ ✅ ✅多用户 ❌ ✅ ❌离线下载 ❌ ✅ ❌视频在线播放 ✅ ✅ ✅部署复杂度 低 中 低资源占用 极低 中 低选择建议个人用:AList,Docker 部署 5 分钟搞定 小团队共享:Cloudreve,有用户管理 多云盘统一入口:AList 或 go-drive 需要离线下载:Cloudreve + Aria2Google Drive 授权配置 以 AList 为例,管理后台 → 存储 → 添加 → Google Drive:在 Google Cloud Console 创建 OAuth 2.0 凭据 填入 Client ID 和 Client Secret 点击授权,完成 OAuth 流程 授权后即可访问 Google Drive 的全部内容注意:Google Drive API 有每日配额限制(默认 100 次/秒、每日 10 亿次查询),个人使用基本不会触及。

Vue 3 前端导出 Excel:xlsx + file-saver 封装 useExportExcel

安装 npm install xlsx file-saver基础用法 import * as XLSX from 'xlsx' import { saveAs } from 'file-saver'const data = [ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ]const ws = XLSX.utils.json_to_sheet(data) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })saveAs(blob, '数据.xlsx')封装 useExportExcel // useExportExcel.js import * as XLSX from 'xlsx' import { saveAs } from 'file-saver' import { ref } from 'vue'export function useExportExcel() { const exporting = ref(false) const exportExcel = (data = [], fileName = '数据表', headerMap = {}) => { if (!data.length) return exporting.value = true try { // 按 headerMap 重命名列 const exportData = data.map(row => { const newRow = {} Object.keys(row).forEach(key => { newRow[headerMap[key] || key] = row[key] }) return newRow }) const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveAs(blob, `${fileName}.xlsx`) } finally { exporting.value = false } } const exportSelected = (allData, selectedRows, fileName, headerMap = {}) => { exportExcel(selectedRows?.length ? selectedRows : allData, fileName, headerMap) } return { exporting, exportExcel, exportSelected } }配合 Element Plus el-table 使用 <template> <el-button :loading="exporting" @click="onExportAll">导出全部</el-button> <el-button :loading="exporting" @click="onExportSelected">导出选中</el-button> <el-table :data="tableData" @selection-change="selectedRows = $event"> <el-table-column type="selection" /> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="city" label="城市" /> </el-table> </template><script setup> import { ref } from 'vue' import { useExportExcel } from './useExportExcel'const tableData = ref([ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ])const selectedRows = ref([])const { exporting, exportExcel, exportSelected } = useExportExcel()const headerMap = { name: '姓名', age: '年龄', city: '城市' }const onExportAll = () => exportExcel(tableData.value, '用户列表', headerMap) const onExportSelected = () => exportSelected(tableData.value, selectedRows.value, '选中用户', headerMap) </script>导出 DOM table 如果页面已有 <table> 元素,不需要操作数据,直接从 DOM 生成: const table = document.getElementById('myTable') const wb = XLSX.utils.table_to_book(table) XLSX.writeFile(wb, 'table.xlsx')大数据后端流式下载 超过 5 万行时前端导出会明显卡顿(浏览器内存限制),推荐后端生成流式 Excel: const exportExcelFromServer = async (params) => { const res = await axios({ url: '/api/export', method: 'get', params, responseType: 'blob' }) const blob = new Blob([res.data]) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = '数据.xlsx' link.click() URL.revokeObjectURL(link.href) }后端(Laravel + Maatwebsite\Excel): return Excel::download(new UserExport(), 'users.xlsx');

油猴脚本获取 __webpack_require__:webpackChunk.push 注入比 Hook call 更稳

为什么 Hook Function.prototype.call 不好用 一种常见思路是拦截 webpack 模块执行时的 call: const oldCall = Function.prototype.call;Function.prototype.call = function (...args) { if (this.name == '84686') { webpackRequire = args[3]; } return oldCall.apply(this, args); };这个方法有两个核心问题:webpack 模块函数通常没有名字,this.name 是 '' 或 anonymous,而不是模块 ID,所以 this.name == '84686' 几乎永远不成立。性能灾难:现代前端框架(React、Vue、Webpack Runtime)每秒调用 call 数以万次,全部经过你的 hook,页面直接卡死。另外,如果 webpack 已经执行完才开始 hook,Function.prototype.call 根本拿不到任何东西——模块早就加载完了。 正确方案:向 webpackChunk 注入入口模块 webpack 5 会在 window 上挂一个全局数组,名字通常是 webpackChunk 加项目名前缀。向这个数组 push 一个特殊的 chunk,可以在 runtime 函数里拿到 __webpack_require__: // 找到 webpackChunk 数组 const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));if (chunkKey) { let webpackRequire; window[chunkKey].push([ [Symbol()], // chunk ID(用 Symbol 避免冲突) {}, // 模块定义(空) function (require) { webpackRequire = require; // 在这里拿到 __webpack_require__ } ]); console.log(webpackRequire); // 现在可以用 webpackRequire(模块ID) 访问任意模块 }这个方法有效的原因:即使 webpack 已经执行完,也可以通过这种方式从现有的 chunk 数组里拿到 __webpack_require__,因为 webpack 会立即处理新 push 进来的 chunk。 在 webpack 执行前 Hook(如果需要监听所有模块) 如果你的目标是在每个模块加载时做点什么(比如修改特定模块的导出),需要在 webpack 执行之前 hook push 方法: const chunkKey = Object.keys(window).find(k => k.startsWith('webpackChunk'));const oldPush = window[chunkKey].push;window[chunkKey].push = function (...args) { const runtime = args[0][2]; // 拦截 runtime 函数 if (typeof runtime === 'function') { args[0][2] = function (__webpack_require__) { // 保存引用 window._webpackRequire = __webpack_require__; return runtime.apply(this, arguments); }; } return oldPush.apply(this, args); };用 webpack_require 访问模块 拿到 __webpack_require__ 后,可以直接访问任意 webpack 模块: // 按模块 ID 获取模块导出 const lodash = webpackRequire(96486); const mtopModule = webpackRequire(82122);console.log(lodash); console.log(mtopModule.default);模块 ID 可以在浏览器 Sources 面板里搜索特征字符串定位,也可以遍历 webpackRequire.m(模块注册表)查找: Object.keys(webpackRequire.m).forEach(id => { const src = webpackRequire.m[id].toString(); if (src.includes('targetFunction')) { console.log('found at module id:', id); } });油猴脚本注意:@run-at 时机 在 Tampermonkey 里使用时,需要根据目的选择合适的 @run-at:时机 说明document-start 最早,适合在 webpack 执行前 hook pushdocument-end 适合页面加载完后注入 chunk 拿引用如果 webpack 在 DOMContentLoaded 之前执行完,用 document-start + push hook;如果只是要访问模块,用 document-end + push 注入即可。

分页 API 全量遍历:Python 与 JavaScript 实现模式

分页接口通常返回 totalCount、pageSize、当页数据。需要全量数据时,用 while 循环按页请求即可。 Python 实现 按 totalCount 推进(稳定写法) import mathpage_size = 20 current = 1 all_items = []while True: params = { "status": "ON_SALE", "current": current, "pageSize": page_size } response = fetchProductList(params) data = response.get("model", []) all_items.extend(data) total_count = response.get("totalCount", 0) total_pages = math.ceil(total_count / page_size) print(f"已获取第 {current}/{total_pages} 页") if current >= total_pages: break current += 1print(f"总共获取 {len(all_items)} 条")按当页数量判断末页(防 totalCount 不准) 如果接口的 totalCount 有时不准确,用当页返回数量是否小于 pageSize 来判断末页更稳: page_size = 20 current = 1 all_items = []while True: params = {"status": "ON_SALE", "current": current, "pageSize": page_size} response = fetchProductList(params) items = response.get("model", []) if not items: break all_items.extend(items) print(f"获取第 {current} 页,{len(items)} 条") if len(items) < page_size: break current += 1生成器模式(内存友好) 适合商品数量很大、需要边翻页边处理的场景: def iter_products(): current = 1 page_size = 20 while True: resp = fetchProductList({ "status": "ON_SALE", "current": current, "pageSize": page_size }) items = resp.get("model", []) if not items: break yield from items if len(items) < page_size: break current += 1for item in iter_products(): print(item["title"])JavaScript 实现 async/await 版本 async function getAllProducts() { const pageSize = 20; let current = 1; const allItems = []; while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (items.length === 0) { break; } allItems.push(...items); console.log(`获取第 ${current} 页,${items.length} 条`); if (items.length < pageSize) { break; } current++; } console.log(`总共获取 ${allItems.length} 条`); return allItems; }const products = await getAllProducts();按 totalCount 计算总页数 async function getAllProducts() { const pageSize = 20; let current = 1; let totalPages = 1; const allItems = []; while (current <= totalPages) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; allItems.push(...items); totalPages = Math.ceil((response.totalCount || 0) / pageSize); console.log(`获取第 ${current}/${totalPages} 页`); current++; } return allItems; }流式处理(不存全量数据) 适合直接对每条数据执行操作(改价格、同步库存等),避免一次性把几千条数据加载到内存: let current = 1; const pageSize = 20;while (true) { const response = await fetchProductList({ status: "ON_SALE", current, pageSize }); const items = response?.model || []; if (!items.length) { break; } for (const item of items) { // 在这里处理每条商品 await updateStock(item.id, item.quantity); } if (items.length < pageSize) { break; } current++; }两种终止条件对比策略 适用场景 风险current >= totalPages totalCount 准确 totalCount 不准时多/少请求items.length < pageSize 更通用 最后一页恰好整除时多一次空请求两种可以结合使用:先按 totalPages 推进,同时检查当页数量作为保险。