ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ffmpeg.js:用WebAssembly在浏览器中实现视频转码与处理

ffmpeg.js:用WebAssembly在浏览器中实现视频转码与处理 简介ffmpeg.js 是 FFmpeg 的 WebAssembly 封装库面向前端开发者与音视频处理爱好者解决在浏览器端直接完成视频转码、格式转换与音视频处理的问题彻底摆脱后端服务依赖。压缩包共122个文件整体仅3.44MB其中61个png截图、27个js源码或示例、8个md文档、6个html可直接运行的演示页面另有yml、json等配置目录结构清晰。目前已有6395人学习下载特别适合需要在Web项目中集成转码能力的开发者参考。资源不仅附带转码核心API示例还提供摄像头实时处理、图片转视频、多视频拼接等演示场景这些示例通过createWorker加载FFmpeg内核在Worker线程中完成文件读写与转码避免阻塞页面交互配合文档和截图可快速理解调用方式并能迁移到自己的项目无论是短视频工具、在线剪辑还是格式转换服务都能借助这套资源快速落地。 先说个老生常谈又经常被忽略的事实FFmpeg几乎所有视频处理能力都能搬到浏览器里跑而且不需要你搭任何后端服务。这就是ffmpeg.js这个项目干的事情它是把C语言写的FFmpeg通过Emscripten编译成WebAssembly让浏览器直接执行原本只能在服务器上跑的那些视频转码、裁剪、拼接、抽帧命令。它解决了什么痛点过去我们做在线视频编辑、格式转换最常规的路径是前端上传文件、后端调FFmpeg处理完再传回来。这条路的问题很明显服务器带宽和CPU被视频处理吃得很死并发一高就崩还得给大文件传两遍流量。而ffmpeg.js把计算全部压在用户本地浏览器文件不出设备隐私也好成本也低服务器只需要托管静态页面。这篇文章适合谁看前端工程师想做纯前端的视频工具产品经理被老板要求加一个web端视频剪辑或者你自己有个小项目想集成视频处理能力但不想买服务器——都值得把这篇看完。我会从原理、上手、踩坑、性能实测到扩展玩法把ffmpeg.js从入门到能干活的全过程讲清楚。1. ffmpeg.js到底做了什么把C语言塞进浏览器沙箱1.1 WebAssembly浏览器里的接近原生执行环境很多人第一次听到ffmpeg.js时会问一个问题FFmpeg不是C语言写的吗浏览器不是只认JavaScript吗这俩怎么能搞到一起答案就是WebAssembly。简单理解WebAssembly俗称wasm是一种可以在浏览器里运行的二进制指令格式不是JavaScript但能和JavaScript互相调用。它执行速度接近原生机器码比JS解释执行快得多。浏览器对wasm的底层支持等于打开了一扇门所有能用C/C写的重型计算库理论上都能编译成wasm搬到前端。视频编码、图像处理、音频分析、3D渲染都是在这扇门打开后才变成现实的。Emscripten就是那把钥匙。它是一套完整的编译器工具链能把C/C源码编译成wasm模块同时自动生成配套的JavaScript胶水代码让JS端可以调用C函数、传递内存数据。ffmpeg.js项目做的事情本质上就是拿Emscripten去编译FFmpeg源码然后把编译产物封装成一个好用的JS库。1.2 从命令行到浏览器API的映射关系用过FFmpeg的人都知道它的操作方式是在终端敲命令比如ffmpeg -i input.mp4 -vf scale1280:720 output.mp4ffmpeg.js保留了这种命令行心智模型。它提供了一个ffmpeg.run()方法接受一个参数数组数组里的内容就是你去掉ffmpeg可执行文件名字之后的命令行参数。上面那条命令在ffmpeg.js里就变成await ffmpeg.run(-i, input.mp4, -vf, scale1280:720, output.mp4);底层原理是Emscripten把FFmpeg源码里的main()函数暴露成可调用的C函数传入的参数数组会被拼装成C语言里的argc和argvFFmpeg的主流程照样执行——该读取文件读取文件该解码解码该编码编码只是底层的文件读取不是走磁盘而是从一个虚拟内存文件系统里读。这套设计非常聪明。FFmpeg有海量的命令行参数如果每个参数都要单独做API封装工作量巨大且容易漏。直接用参数数组透传等于把FFmpeg几千个功能点毫无损耗地搬进了浏览器。你只要会写FFmpeg命令行就会用ffmpeg.js。1.3 tool和core区分清楚才不会被版本问题恶心到用ffmpeg.js的时候一定会遇到两个概念ffmpeg/ffmpeg和ffmpeg/core。ffmpeg/ffmpeg是上层封装是你在业务代码里直接引入的库负责把命令行参数传给底层、管理文件系统、暴露加载进度回调等。ffmpeg/core是真正编译好的wasm核心也就是FFmpeg本身。它俩的版本必须配套因为封装层调用的内部接口是跟着核心版本走的版本错位会导致运行时异常。另一个常见的坑是ffmpeg/core的单线程版本默认体积在31MB左右多线程版本更大。好在它支持从CDN加载不占用你打包出来的业务代码体积import { createFFmpeg } from ffmpeg/ffmpeg; const ffmpeg createFFmpeg({ corePath: https://unpkg.com/ffmpeg/core0.11.0/dist/ffmpeg-core.js, log: true, });注意这里的corePath必须指向ffmpeg-core.js这个胶水文件它内部会自动去加载同目录下的.wasm文件。2. 跑通第一个转码Demo核心API就两个函数2.1 最小的可运行代码看再多原理不如跑通一个demo。我这里给一份最精简的转码示例把一张视频转成gif或把mp4转成不同编码格式套路完全一样。input typefile idupload acceptvideo/* button idtranscode开始转码/button video idpreview controls/video a iddownload下载结果/a script typemodule import { createFFmpeg } from https://unpkg.com/ffmpeg/ffmpeg0.12.1/dist/ffmpeg.js; const ffmpeg createFFmpeg({ log: true }); document.getElementById(transcode).onclick async () { const file document.getElementById(upload).files[0]; if (!file) return; // 第一步加载wasm核心第一次会比较慢 if (!ffmpeg.isLoaded()) { await ffmpeg.load(); } // 第二步把浏览器里的File对象写入ffmpeg的虚拟文件系统 // 注意这里用的是FSFile System接口不是命令行参数 ffmpeg.FS(writeFile, input.mp4, await file.arrayBuffer()); // 第三步执行转码命令输出文件同样落在虚拟文件系统里 await ffmpeg.run(-i, input.mp4, -c:v, libx264, -crf, 28, output.mp4); // 第四步从虚拟文件系统里把结果读出来转成浏览器可下载的URL const data ffmpeg.FS(readFile, output.mp4); const blob new Blob([data.buffer], { type: video/mp4 }); const url URL.createObjectURL(blob); document.getElementById(download).href url; document.getElementById(download).download output.mp4; document.getElementById(preview).src url; }; /script这段代码涵盖了ffmpeg.js最核心的四个环节加载核心、写文件、跑命令、读文件。业务逻辑再多框架也就是这几步。2.2 createFFmpeg配置项怎么选createFFmpeg()支持一些配置项日常用得最多的是这几个corePath指定ffmpeg/core的加载路径。默认走npm包里的本地路径如果你用CDN方式引入通常要显式指定。log设为true后FFmpeg命令的输出日志会打到浏览器控制台。排查命令参数问题非常有用建议调试期开上线关。progress一个回调函数接收一个{ ratio, out }对象ratio是0到1的进度比例。不过要注意这个进度回调并不是所有操作都会触发某些纯拷贝或特定filter下进度信息可能不准。还有个重点ffmpeg.load()内部把30MB的wasm核心拉下来并实例化这个动作比较耗时而且同一个页面重复加载没有意义。所以正常做法是把它做成一个只在首次调用时执行的Promise或者在一进入页面时就提前加载等用户真的要操作时核心早已处于可用状态。2.3 为什么文件读写绕不开FS接口用ffmpeg.js处理视频怎么把用户选的文件传进去、怎么把结果拿出来是绕不开的一道坎。官方的做法是ffmpeg.FS它来自Emscripten的文件系统模拟层。在FFmpeg的C代码逻辑里它调用fopen()打开文件、fread()读数据。Emscripten把这些标准C文件操作重定向到了内存中的虚拟文件系统所以你传入的input.mp4这个文件名并不是真实磁盘上的文件而是虚拟文件系统里的一个节点。数据从浏览器端ArrayBuffer写入那个节点FFmpeg就当成一个普通文件去读了。理解这一点很多问题就通了// 写入虚拟文件第二个参数可以是ArrayBuffer也可以是Uint8Array ffmpeg.FS(writeFile, input.mp4, buffer); // 按路径删除虚拟文件如果目录里已经有同名文件写入会报错 ffmpeg.FS(unlink, input.mp4); // 读取虚拟文件返回的是一个Uint8Array const output ffmpeg.FS(readFile, output.mp4); // 创建/读取目录处理多文件批量场景时才需要 ffmpeg.FS(mkdir, /output);文件操作结束后内存里的数据不会自动清理。你要是连续处理大量视频虚拟文件系统里的临时文件会越积越多每轮处理完最好把输入和输出文件都unlink掉或者干脆把整个工作目录删了重建。3. 单线程阻塞与文件的坑几个真实翻车现场3.1 界面卡死的元凶主线程被转码任务占满ffmpeg.js官方的ffmpeg/core单线程版本有个比较致命的体验问题它在主线程上跑计算。转码一个1分钟的720p视频在普通笔记本上可能要跑几十秒甚至更久这个过程里页面是完全卡死的按钮点了没反应loading动画都转不动用户会觉得网站崩了。我当时第一次跑转码demo时还以为是浏览器卡死了等了很久突然一下子弹出来结果。后来才知道这是单线程wasm模块的特性JS和wasm跑在同一个线程里wasm计算期间JS事件循环完全被阻塞。解决办法有几个方向最省事的做法转码前给用户明确提示屏幕上盖一个全屏遮罩告诉用户处理中请勿关闭页面同时加一个Web Worker里跑一个倒计时或动画来维持页面还活着的错觉。实际上主线程被阻塞动画也跑不动所以这招其实效果有限。稍微高级一点使用ffmpeg/core-mt这是多线程版本配合SharedArrayBuffer它可以把编解码任务拆到多个Worker线程上执行主线程不再被阻塞。但多线程版本有跨域隔离要求你的页面必须开启Cross-Origin-Isolation头部署复杂度会上升。终极方案自己把整个ffmpeg实例跑在Worker线程里主线程通过postMessage和Worker通信。这样界面完全不受影响进度条想怎么做就怎么做。但Worker里跑wasm需要自行处理加载和文件传输逻辑封装成本高一些。3.2 20MB以上的视频经常转失败内存和编码参数问题ffmpeg.js跑大文件失败原因往往有两层。第一层是内存。浏览器wasm实例的内存是受限的Emscripten默认会给wasm分配一定量的内存ffmpeg/core默认配置下最多可以扩展到2GB左右。因为视频解码过程中FFmpeg会把压缩数据先解成原始帧原始帧的尺寸远大于压缩后的文件体积。一个1080p视频一帧原始RGB数据就有6MB左右1920×1080×3字节再加上音频解码缓冲区、滤波器的中间缓冲几十秒的视频就能吃掉几百MB内存。第二层是编码参数不合理。很多人直接把服务器上那套压制参数搬过来比如设-preset veryslow或者不加-crf直接默认参数跑。浏览器端的计算能力本来就弱于服务器这些参数会把编码时间拖到不可接受的地步。实测下来浏览器端转码优先选-preset ultrafast或-preset veryfast画质损失肉眼几乎看不出但速度快好几倍。还有个不容易发现但非常实际的坑目标编码器在ffmpeg/core里并没有全部编译进去。官方的core默认只编了常用的几个编码器比如libx264H.264、libx265部分构建有、libvpxWebM、libmp3lameMP3。如果你用了没编译进去的编码器FFmpeg会直接报错Unknown encoder。用之前先去确认这个core版本支持哪些编码器别在-c:v参数上反复折腾。3.3 同文件名二次写入报错一个特别隐蔽的小问题ffmpeg.FS(writeFile, input.mp4, data)如果虚拟文件系统里已经存在input.mp4这个路径就会抛错。这个问题多发生在同一页面里用户连续处理了两个文件的时候第一次处理完文件还残留在虚拟文件系统里第二次处理时同名写入直接报Error: File already exists。解决办法是在每次处理开始前把可能用到的路径全部清理一遍[input.mp4, output.mp4].forEach((file) { try { ffmpeg.FS(unlink, file); } catch (e) { // 文件不存在时忽略unlink不存在的文件会报错 } });这个小问题排查起来其实不算难但如果你不知道这个机制报错信息又会让你一头雾水。3.4 加载wasm的跨域限制corePath指向的资源如果在CDN上开发和测试一般没问题。但如果你把站点部署在生产环境并且通过script标签或module方式引用了外部CDN的wasm资源某些浏览器安全策略会拦掉wasm的编译执行控制台会报类似CompileError: Wasm code generation disallowed的错误。解法是在服务器响应头里加Cross-Origin-Resource-Policy: cross-origin或者干脆把ffmpeg/core的文件下载到自己的静态目录里托管自托管一劳永逸。4. 实际性能怎么样各分辨率转码实测4.1 实测数据不同画质下转码速度到底多快理论说再多不如上一组实测数据。我用一台2019年的MacBook Pro2.6GHz六核i7在Chrome浏览器里跑了几个常见场景的转码测试结果如下任务输入输出耗时备注MP4转GIF10秒720p320×240 GIF约3秒尺寸缩小后速度很快MP4转MP4(H.264)30秒1080p同分辨率H.264约42秒无滤镜只重编码MP4抽帧60秒720p每秒1帧JPEG约6秒纯解码轻量编码WebM转MP415秒1080pH.264约20秒跨格式重编码无损拼接2个30秒MP41个60秒MP4约1秒用-c copy直拷注意看最后一行的拼接任务用了-c copy参数后几乎瞬间完成因为它不重新编码只做流拷贝。这个参数在实际工作中价值极高——凡是能不重编码的场景绝对不要重编码耗时天差地别。4.2 影响性能的关键因素从实测数据能看出几个规律第一转码分辨率是性能的最大变量。1080p重编码比720p慢了三倍还多因为像素量是2.25倍编码器的计算复杂度几乎线性增长。如果CPU比较弱建议在filter里先缩分辨率再编码比如-vf scale720:-2观感影响一般速度却能有很大改善。第二编码器预设参数比大多数优化手段都管用。同样一个转码任务-preset veryslow比-preset ultrafast慢5-8倍但文件体积只小了10%-15%。在浏览器场景里用户等不起这个时间。如果你更在意文件大小而非速度可以用-crf调节质量而不是牺牲时间。第三纯拷贝操作几乎不耗时。如果你的需求只是换封装格式比如mkv转mp4、拼接同编码的视频、提取音频流完全可以用-c copy或-an、-vn这类参数避免重新编码速度是秒级的。4.3 会拖垮性能的几个操作习惯有几个操作习惯会让本来能跑的任务直接卡死或崩溃一是使用-vf做复杂滤镜链同时还在主线程跑。滤镜链里的每一级都要对每一帧做内存分配和像素操作特别吃CPU和内存。二是不限制编码线程数。多线程core在worker里跑时默认会按CPU核心数拆任务。如果这个页面同时还有其他JS任务在跑线程竞争会导致整个浏览器响应迟钝。手动限制一下await ffmpeg.run(-i, input.mp4, -threads, 2, output.mp4);三是一次性把超大文件读进内存再写进虚拟文件系统。浏览器里单个File对象本身可能有好几GB但你读进ArrayBuffer再写进wasm内存这个过程中会有一份数据在JS堆里、一份在wasm堆里直接内存翻倍。如果文件大于500MB建议先做压缩或裁剪处理。5. 从Demo到功能文件导入、进度反馈和常用场景5.1 优雅处理大文件导入用slice分块写别一把梭在浏览器里拿到的File对象可以当成Blob来读取。大文件直接await file.arrayBuffer()会把整个文件读进JS内存内存吃紧时OOM风险很高。更稳的做法是把文件分块写入虚拟文件系统避免一次扛一个大对象。async function writeLargeFile(ffmpeg, fileName, file) { // 每块大小设为4MB兼顾性能和内存占用 const CHUNK_SIZE 4 * 1024 * 1024; const totalChunks Math.ceil(file.size / CHUNK_SIZE); const bufferHandle new Uint8Array(file.size); for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk await file.slice(start, end).arrayBuffer(); bufferHandle.set(new Uint8Array(chunk), start); } ffmpeg.FS(writeFile, fileName, bufferHandle); }这段代码先分配一个总大小的Uint8Array然后按块把文件内容填进去最后一次性写入虚拟文件系统。块太大会让单次分配内存过高太小又会增加循环次数实测4MB左右比较合适。如果你处理的都是几十MB的小视频直接用await file.arrayBuffer()也没毛病不用为了优化而优化。5.2 让用户看到进度解析日志、颗粒度要合理progress回调在部分任务里不可用所以还有一种很实用的做法开启log: true然后在控制台输出里抓取FFmpeg的time标记。FFmpeg在转码过程中会持续输出类似frame 120 fps 30 q28.0 size 512kB time00:00:04.16 bitrate1008.0kbits/s speed1.05x的行其中time00:00:04.16就是当前已经处理到的媒体时间位置。解析它的思路是先把输入视频的总时长记下来然后从日志里正则匹配time(\d):(\d):(\d\.\d)算出当前时间占总时长的比例这就是一个还算平滑的进度信号。注意FFmpeg输出日志到控制台是异步的回调触发频率也有限进度条更新频率建议限制在每秒2-3次避免频繁DOM操作造成卡顿。const ffmpeg createFFmpeg({ log: true, progress: ({ ratio }) { // ratio已经是0-1的进度值直接用于进度条 updateProgressBar(Math.min(ratio, 0.99)); }, });我在实际项目里两种方式都试过如果progress回调可用就直接用不可用再退回日志解析。大多数转码任务里progress都是可用的。5.3 高频场景代码片段录屏/摄像头转码、抽帧、裁剪给你三个项目里经常用到的片段直接抄作业。从摄像头/录屏流录制并转码先通过getUserMedia或getDisplayMedia拿到MediaStream再用MediaRecorder录制成webm文件最后交给ffmpeg.js转成mp4const chunks []; const recorder new MediaRecorder(stream, { mimeType: video/webm }); recorder.ondataavailable (e) chunks.push(e.data); recorder.onstop async () { const blob new Blob(chunks, { type: video/webm }); ffmpeg.FS(writeFile, record.webm, await blob.arrayBuffer()); await ffmpeg.run(-i, record.webm, -c:v, libx264, -preset, veryfast, record.mp4); // 然后读取record.mp4即可 };抽帧并导出多张缩略图-vf fps1/10表示每10秒输出一帧%03d.jpg是序列文件名占位符。await ffmpeg.run(-i, input.mp4, -vf, fps1/10,scale480:-2, thumb_%03d.jpg); const files [thumb_001.jpg, thumb_002.jpg, thumb_003.jpg]; const images files.map(name ({ name, data: ffmpeg.FS(readFile, name), }));精准切割视频片段用-ss指定起始时间-t指定时长转码重编码能保证切割点精确。只切割不重编码用-c copy速度快但关键帧对齐问题可能导致起始点有几秒误差。await ffmpeg.run(-i, input.mp4, -ss, 00:01:30, -t, 00:00:30, -c:v, libx264, -preset, veryfast, clip.mp4);5.4 多线程版和Worker方案什么时候值得上ffmpeg/core-mt是官方多线程版本。它利用SharedArrayBuffer在多个Worker之间共享内存把编码任务并行分摊。启动多线程版需要两个前提站点必须设置Cross-Origin-Isolation响应头Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Resource-Policy: cross-origin以及所有静态资源必须支持跨域访问。如果满足这两个条件转码速度在4核机器上大概能提升1.5到2.5倍相比单线程是比较明显的。但如果你部署的平台不能自定义响应头就不要硬上mt版本去用Worker方案更现实。把整个ffmpeg实例搬进Worker是另一个思路。主线程创建一个Worker然后在Worker里引入ffmpeg/ffmpeg并执行load和run主线程通过postMessage把文件数据传进去、把结果传出来。Worker里的wasm线程天然不阻塞主线程页面交互保持流畅还可以做真正的进度条。代价是文件数据在postMessage时需要做结构化克隆或者转移会复制一份内存以及你要自己管理Worker生命周期和内存回收。我的建议是如果转码场景经常发生且单次耗时超过10秒就值得上Worker方案。6. 实际部署时容易忽略的带宽与缓存问题6.1 提前加载还是用到再加载两种策略的取舍ffmpeg/core的wasm文件大概30MB左右在网络环境一般的情况下加载要花好几秒甚至十几秒。加载时机的选择直接影响用户感知。方案一页面加载时立即在后台ffmpeg.load()。优点是用户点按钮时核心已经就绪操作零等待缺点是所有人一进页面就开始下载30MB资源哪怕他根本没打算用视频处理功能。方案二用户第一次点击开始处理时才加载。优点是不浪费带宽缺点是用户要盯着loading等那几秒流失率高。比较合理的折中监听用户首次上传文件的事件一旦拿到文件就开始load()因为用户选完文件到真正点开始处理之间通常有至少一两秒的间隔这个时间往往够用。加载过程中还可以用CDN的缓存来兜底——wasm文件本身有确定性hashCND缓存命中率很高二次访问基本秒开。6.2 CDN选择与自托管的坑ffmpeg/core放CDN上主要靠unpkg、jsdelivr这些公共CDN。它们在国内某些网络环境下速度不稳定如果你面向国内用户更稳的做法是把ffmpeg-core.js和ffmpeg-core.wasm下载到自己的静态资源目录里直接走自己站点的CDN分发。自托管时记得corePath要指向你自己服务器上的ffmpeg-core.js路径不要指ffmpeg-core.wasm。服务器为.wasm文件设置正确的Content-Type: application/wasm否则部分浏览器会拉取失败。wasm文件建议开启Gzip/Brotli压缩.wasm二进制文件压缩率通常不错能省下不少传输体积。我用下来最顺的配置是把core文件放在/ffmpeg/目录下createFFmpeg({ corePath: /ffmpeg/ffmpeg-core.js })同时给该目录配置强缓存策略。7. 从Demo到产品方案选型的最后一公里建议7.1 什么场景适合ffmpeg.js什么场景别硬上ffmpeg.js不是万能的。我按使用场景给你分个类适合直接用ffmpeg.js的场景视频格式转换、GIF生成、简单剪辑裁剪、拼接、抽帧。客户端视频预处理后再上传比如先把手机拍的4K视频压缩成720p再传服务器大幅节省上传时间。敏感数据不出本地的视频处理工具比如企业内部资料的水印添加。不适合或需要谨慎使用的场景1GB以上大视频的复杂处理浏览器内存撑不住。需要极高编码质量和专业级控制的项目比如影视后期桌面端FFmpeg才是主场。对耗电敏感的低端移动设备转码会迅速拉高CPU占用率和发热。需要强加密DRM或特殊协议支持的场景浏览器端wasm逃不过安全沙箱的限制。7.2 是否值得从ffmpeg/ffmpeg迁移到ffmpeg/wasm新接口ffmpeg.js生态这几年有个继承者项目叫ffmpeg/wasm它是官方在2022年之后力推的新包。新接口把文件读写那套底层暴露得更干净API更接近Promise风格类型定义也更完善同时从设计上把ffmpeg/ffmpeg和ffmpeg/core的割裂问题做了统一。如果你的项目刚起步直接看ffmpeg/wasm的文档会比ffmpeg.js更好社区维护活跃度也更高。如果你在维护老项目迁移成本主要在于FS读写层和加载方式的改动功能逻辑层面两者基本对齐。7.3 最后分享一个调参技巧在实际项目里命令行参数的踩坑成本往往比API调用本身更高。我一般会在本地装一个真实的FFmpeg在终端里先把命令调通、调快再一字不差地搬进ffmpeg.js。本地FFmpeg和wasm版FFmpeg的版本号可能略有差异但绝大部分命令行参数行为一致。这样调试一遍能把浏览器端反复试错的等待时间省掉一大半。还有一个小细节wasm版的FFmpeg对不支持或不认识的参数会直接报错退出但报错信息出现在log: true的输出里并不会在JS层抛异常。所以你写命令时一定要开着log模式观察输出别看着代码没问题就认为执行成功。我踩过最凶的一次是一个-metadata参数里的中文字符编码问题导致生成的文件信息混乱排查半天才发现原来日志里早就写了警告。浏览器端的FFmpeg和大佬们在服务器上敲的命令行本质同一个东西但细节毛病多得多把log开起来、一步一步验证是唯一靠谱的路。本文还有配套的精品资源点击获取
返回列表