1. 首页
  2. webpack

基于webpack的热重载live reload和热更新HMR

基于webpack的热重载live reload和热更新HMR — 当文件被修改后如何让浏览器更新代码

在前端应用框架中不管是react还是vue,官方都提供了相应的脚手架方便开发者快速入手,当我们在开发时修改某个js或者css文件时,webpack会自动编译我们的文件,我们刷新浏览器就可以看到编译后的文件。为此我们会想,如果我们修改保存之后,文件被编译、浏览器自动刷新、或者浏览器局部刷新(不刷新整个浏览器),这样的话多好。当然,基于webpack打包工具的相关库已经实现了。下面对此部分流程做简单的分析

  • 热重载live reload: 就是当修改文件之后,webpack自动编译,然后浏览器自动刷新->等价于页面window.location.reload()

  • 热更新HMR: 热重载live reload并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失。举个列子:页面中点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而webapck热更新HMR则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率

相关版本选择:

  1. webpack 版本git checkout v2.7.0 版本

  2. webpack-dev-middleware 版本git checkout v1.12.2 版本

  3. webpack-dev-server 版本git checkout v2.9.7 版本

说明: 这里选择webpack的版本为V2,是因为之前debug webpack的打包流程时恰为v2的版本,那可能会问webpack-dev-server版本为什么是这个呢? 这里解释下,通过package.json中的字段peerDependencies可以选定版本,为此我选择了对应的最新版本 v2.9.7。同样webpack-dev-middleware版本选择也是一样的,主要看依赖关系。 附上webpack-dev-server库的package.json文件描述


"name": "webpack-dev-server", "version": "2.9.7", "peerDependencies": { "webpack": "^2.2.0 || ^3.0.0" // 这里说明需要的版本号 } 复制代码

进入主题 demo是 webpack-dev-server 目录下面的examples/api/simple列子,只粘贴出关键代码,建议clone代码比对一下

server.js 入口文件


'use strict'; const Webpack = require('webpack'); const WebpackDevServer = require('../../../lib/Server'); const webpackConfig = require('./webpack.config'); const compiler = Webpack(webpackConfig); const devServerOptions = Object.assign({}, webpackConfig.devServer, { stats: { colors: true } }); const server = new WebpackDevServer(compiler, devServerOptions); server.listen(8080, '127.0.0.1', () => { console.log('Starting server on http://localhost:8080'); }); 复制代码

const webpackConfig = require(‘./webpack.config’); 文件如下


'use strict'; var path = require("path"); // our setup function adds behind-the-scenes bits to the config that all of our // examples need const { setup } = require('../../util'); module.exports = setup({ context: __dirname, entry: [ './app.js', '../../../client/index.js?http://localhost:8080/', 'webpack/hot/dev-server' ], devServer: { // 这里配置hot值决定当开发时文件被修改并保存后 更新模式为热更新HMR hot: true } }); 复制代码

入口entry 包含’../../../client/index.js?http://localhost:8080/’ 以及 ‘webpack/hot/dev-server’ 作用分别是:前者是WebpackDevServer的客户端浏览器代码,通过sockjs-client来链接Server端进行通信,比如开发时代码修改后保存,WebpackDevServer会通过 webpack-dev-middleware 拿到webpack编译后的结果,通过websockets 发送消息类型给客户端浏览器。 后者是webpack热更新HMR的客户端浏览器代码,打包时会insert进去,作用是当浏览器收到websockets发过来消息后,如果webpackConfig配置了webpack.HotModuleReplacementPlugin插件,就会走热更新HMR模式

../../../client/index.js 文件如下


'use strict'; const socket = require('./socket'); let urlParts; let hotReload = true; // __resourceQuery 也就是../../../client/index.js后面的参数 http://localhost:8080/ 通过webpack 打包时候替换 if (typeof __resourceQuery === 'string' && __resourceQuery) { // If this bundle is inlined, use the resource query to get the correct url. urlParts = url.parse(__resourceQuery.substr(1)); } else { // ... } let hot = false; let currentHash = ''; const onSocketMsg = { hot: function msgHot() { hot = true; }, hash: function msgHash(hash) { currentHash = hash; }, ok: function msgOk() { reloadApp(); } }; // 建立websockets 链接 socket(socketUrl, onSocketMsg); function reloadApp() { if (isUnloading || !hotReload) { return; } // 如果webpackConfig 中配置devServer.hot 为true,就走热更新HMR的模式,结论可以通过webpack-dev-server 的lib/Server.js 文件逻辑得出 if (hot) { const hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); } else { // 否则走热重载live reload 直接刷新浏览器 applyReload(rootWindow, intervalId); } function applyReload(rootWindow, intervalId) { clearInterval(intervalId); log.info('[WDS] App updated. Reloading...'); rootWindow.location.reload(); } } 复制代码

const socket = require(‘./socket’); 文件如下


'use strict'; const SockJS = require('sockjs-client'); let sock = null; function socket(url, handlers) { sock = new SockJS(url); sock.onclose = function onclose() { // 此处是重连的逻辑 省略... }; sock.onmessage = function onmessage(e) { // 当收到Server端的websockets 消息后执行对应的消息类型逻辑 // This assumes that all data sent via the websocket is JSON. const msg = JSON.parse(e.data); if (handlers[msg.type]) { handlers[msg.type](msg.data); } }; } module.exports = socket; 复制代码

‘webpack/hot/dev-server’ 文件如下


// => module.hot 被替换成true:在前期ast语法树分析过程中标识代码位置,然后在webpack assets阶段被替换 // => module.hot 被替换成true:在前期ast语法树分析过程中标识代码位置,然后在webpack assets阶段被替换 if(module.hot) { var lastHash; var upToDate = function upToDate() { return lastHash.indexOf(__webpack_hash__) >= 0; }; var check = function check() { module.hot.check(true).then(function(updatedModules) { if(!updatedModules) { console.warn("[HMR] Cannot find update. Need to do a full reload!"); console.warn("[HMR] (Probably because of restarting the webpack-dev-server)"); window.location.reload(); return; } if(!upToDate()) { check(); } require("./log-apply-result")(updatedModules, updatedModules); if(upToDate()) { console.log("[HMR] App is up to date."); } }).catch(function(err) { var status = module.hot.status(); if(["abort", "fail"].indexOf(status) >= 0) { console.warn("[HMR] Cannot apply update. Need to do a full reload!"); console.warn("[HMR] " + err.stack || err.message); // window.location.reload(); } else { console.warn("[HMR] Update failed: " + err.stack || err.message); } }); }; var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; if(!upToDate() && module.hot.status() === "idle") { console.log("[HMR] Checking for updates on the server..."); check(); } }); console.log("[HMR] Waiting for update signal from WDS..."); } else { throw new Error("[HMR] Hot Module Replacement is disabled."); } 复制代码

结论:被insert到客户端浏览器中的这段代码决定了 webpack热更新HMR 的开始,当热更新HMR模式失败时,就直接刷新浏览器了

const { setup } = require(‘../../util’); 文件如下


module.exports = { setup(config) { const defaults = { plugins: [], devServer: {} }; const result = Object.assign(defaults, config); result.plugins.push(new webpack.HotModuleReplacementPlugin()); result.plugins.push(new HtmlWebpackPlugin({ filename: 'index.html', template: path.join(__dirname, '.assets/layout.html'), title: exampleTitle })); return result; } }; 复制代码

webpack.HotModuleReplacementPlugin 插件的作用就是:在webpack打包生成的代码中添加功能代码,当我们开发时,修改某个文件并保存后,浏览器会拿到修改的模块代码,然后执行并更新依赖, 当然浏览器如何拿到代码以及如何执行更新,下面会讲到,这里先提一下这个插件的作用

webpack entry 入口文件app.js


'use strict'; require('./example'); if (module.hot) { module.hot.accept((err) => { if (err) { console.error('Cannot apply HMR update.', err); } }); } 复制代码

webpack entry 入口文件example.js


'use strict'; const target = document.querySelector('#target'); target.innerHTML = 'Modify to update this element without reloading the page.'; 复制代码

html Template 模板文件


<!doctype html> <html> <head> <title>WDS ▻ API: Simple Server</title> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="shortcut icon" href="/.assets/favicon.ico"/> <link rel="stylesheet" href="https://fonts.lug.ustc.edu.cn/css?family=Source+Code+Pro:400,600|Source+Sans+Pro:400,400i,500,600"/> <link rel="stylesheet" href="/.assets/style.css"/> </head> <body> <main> <header> <h1> <img src="/.assets/icon-square.svg" style="width: 35px; height: 35px;"/> webpack-dev-server </h1> </header> <section> <h2>API: Simple Server</h2> <div id="target"></div> </section> <section> <div id="targetmodule"></div> </section> </main> <script type="text/javascript" src="main.js"></script></body> </html> 复制代码

以上是涉及到的一些文件…

下面来看具体的效果: 运行 node –inspect-brk server.js 文件, 访问http://localhost:8080

基于webpack的热重载live reload和热更新HMR

上图左侧是 webpack-dev-server 中 websockets server端的代码,借助webpack-dev-middleware注册webapck打包生命周期事件回调函数,将打包过程关键生命点同步到客户端浏览器(右侧) ,从console处可以知道收到了消息类型type:hot、hash、ok。其中hot类型是告诉客户端浏览器更新代码的方式采用 热更新HMR 的方式, 而不是采用热重载live reload 直接刷新浏览器的方式,hash是本次webpack打包后的hash值, ok标识webpack打包生命周期已经完成,可以进行客户端浏览器代码的更新操作了,也就是 webpack热更新HMR的过程。

下面当修改 example.js 文件 也就是浏览器如何更新代码流程 关键时刻到了


//target.innerHTML = 'Modify to update this element without reloading the page.'; target.innerHTML = '热更新HMR的模式'; 复制代码

文件变化后,webpack.HotModuleReplacementPlugin 插件 中关键的 webpack Compilation 对象事件回调函数如下

compilation.plugin("record", function(compilation, records) {
// 生成的 records 用于当文件变化后找出变话的模块
debugger
if(records.hash === this.hash) return;
records.hash = compilation.hash;
records.moduleHashs = {};
// 循环每个module, webpack中一个文件就是一个module,且通过hash值判断文件是否有更改
this.modules.forEach(function(module) {
var identifier = module.identifier();
var hash = require("crypto").createHash("md5");
module.updateHash(hash);
records.moduleHashs[identifier] = hash.digest("hex");
});
records.chunkHashs = {};
// this webpack compilation 对象
this.chunks.forEach(function(chunk) {
records.chunkHashs[chunk.id] = chunk.hash;
});
records.chunkModuleIds = {};
this.chunks.forEach(function(chunk) {
records.chunkModuleIds[chunk.id] = chunk.modules.map(function(m) {
return m.id;
});
});
});
var initialPass = false;
var recompilation = false;
compilation.plugin("after-hash", function() {
// records 相应的hash 决定模块变化之后的标识
debugger
var records = this.records;
if(!records) {
initialPass = true;
return;
}
if(!records.hash)
initialPass = true;
var preHash = records.preHash || "x";
var prepreHash = records.prepreHash || "x";
if(preHash === this.hash) {
recompilation = true;
this.modifyHash(prepreHash);
return;
}
records.prepreHash = records.hash || "x";
records.preHash = this.hash;
// complain 对象的hash值
this.modifyHash(records.prepreHash);
});
compilation.plugin("additional-chunk-assets", function() {
// 这里当modul变化之后,找出变化的module 并并生成json 和对应的module Template模板信息
debugger
var records = this.records;
if(records.hash === this.hash) return;
if(!records.moduleHashs || !records.chunkHashs || !records.chunkModuleIds) return;
// 循环遍历module 通过hash值标识module是否变化了
this.modules.forEach(function(module) {
var identifier = module.identifier();
var hash = require("crypto").createHash("md5");
module.updateHash(hash);
hash = hash.digest("hex");
module.hotUpdate = records.moduleHashs[identifier] !== hash;
});
// this.hash webpack Compilation 对象的hash值
var hotUpdateMainContent = {
h: this.hash,
c: {}
};
// records.chunkHashs 包含了 所有chunk的hash值信息
Object.keys(records.chunkHashs).forEach(function(chunkId) {
chunkId = isNaN(+chunkId) ? chunkId : +chunkId;
// 修改文件导致module 变化 => 找到对应的chunk
var currentChunk = this.chunks.find(chunk => chunk.id === chunkId);
if(currentChunk) {
// 通过chunk 来确定是哪个module变化了
var newModules = currentChunk.modules.filter(function(module) {
return module.hotUpdate;
});
var allModules = {};
currentChunk.modules.forEach(function(module) {
allModules[module.id] = true;
});
// 如果项目中有某个模块没有引用了 就会找出改模块
var removedModules = records.chunkModuleIds[chunkId].filter(function(id) {
return !allModules[id];
});
// 如果发生了模块module的变化
if(newModules.length > 0 || removedModules.length > 0) {
// 根据变化的module 得到 module字符串模板
var source = hotUpdateChunkTemplate.render(chunkId, newModules, removedModules, this.hash, this.moduleTemplate, this.dependencyTemplates);
var filename = this.getPath(hotUpdateChunkFilename, {
hash: records.hash,
chunk: currentChunk
});
this.additionalChunkAssets.push(filename);
// filename 就是: `${currentChunk}.${records.hash}.hot-update.js}` => 0.9236d98784cee1af7a96.hot-update.js文件
this.assets[filename] = source;
// 标识module变化了
hotUpdateMainContent.c[chunkId] = true;
currentChunk.files.push(filename);
this.applyPlugins("chunk-asset", currentChunk, filename);
}
} else {
hotUpdateMainContent.c[chunkId] = false;
}
}, this);
// 下面是 `${records.hash}.hot-update.json` => 9236d98784cee1af7a96.hot-update.json 文件内容
var source = new RawSource(JSON.stringify(hotUpdateMainContent));
var filename = this.getPath(hotUpdateMainFilename, {
hash: records.hash
});
this.assets[filename] = source;

// 注: 以上添加到this.assets 的内容在 Compiler.emitAssets 阶段 生成文件内容
});

复制代码

```javascript

> 结论: 当文件变化后,webpack 就会编译生成 hot-update.json、以及对应的文件模块hot-update.js信息 用于在Compiler.emitAssets 阶段生成js文件

webpack 打包完事后,如何通知浏览器呢?如下[webpack-dev-server][7] Server.js文件

</code></pre>

function Server(compiler, options) {
// debugger
// Default options
if (!options) options = {};

// webpack 配置中的属性,决定通过热更新的方式
this.hot = options.hot || options.hotOnly;

compiler.plugin('done', (stats) => {
// 这里注册 webpack compiler 对象的事件, 通过websockets 通知客户端浏览器
debugger
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});

// Init express server
const app = this.app = new express(); // eslint-disable-line

app.all('*', (req, res, next) => { // eslint-disable-line
if (this.checkHost(req.headers)) { return next(); }
res.send('Invalid Host header');
});

// webpackDevMiddleware 监听文件的变换 watch -> build
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(compiler, options);
// ...
this.listeningApp = http.createServer(app);
// ...
}

// delegate listen call and init sockjs
Server.prototype.listen = function (port, hostname, fn) {
this.listenHostname = hostname;
// eslint-disable-next-line

const returnValue = this.listeningApp.listen(port, hostname, (err) => {
const sockServer = sockjs.createServer({
// Use provided up-to-date sockjs-client
sockjs_url: '/<strong>webpack_dev_server</strong>/sockjs.bundle.js',
// Limit useless logs
log(severity, line) {
if (severity === 'error') {
log(line);
}
}
});

<pre><code>sockServer.on('connection', (conn) => {
if (!conn) return;
if (!this.checkHost(conn.headers)) {
this.sockWrite([conn], 'error', 'Invalid Host header');
conn.close();
return;
}
this.sockets.push(conn);

conn.on('close', () => {
const connIndex = this.sockets.indexOf(conn);
if (connIndex >= 0) {
this.sockets.splice(connIndex, 1);
}
});
// 这里根据webpackConfig 中的配置 devServer.hot= true 通知客户端浏览 更新代码的方式
if (this.hot) this.sockWrite([conn], 'hot');

if (!this._stats) return;
this._sendStats([conn], this._stats.toJson(clientStats), true);
});

if (fn) {
fn.call(this.listeningApp, err);
}
</code></pre>

});

return returnValue;
};

Server.prototype.sockWrite = function (sockets, type, data) {
sockets.forEach((sock) => {
sock.write(JSON.stringify({
type,
data
}));
});
};

// send stats to a socket or multiple sockets
Server.prototype._sendStats = function (sockets, stats, force) {
if (!force &&
stats &&
(!stats.errors || stats.errors.length === 0) &&
stats.assets &&
stats.assets.every(asset => !asset.emitted)
) { return this.sockWrite(sockets, 'still-ok'); }
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); } else { this.sockWrite(sockets, 'ok'); }
};

module.exports = Server;
复制代码

<pre><code class="language-javascript line-numbers"><br />**当客户端浏览器收到消息后** type: ok 消息类型发生时,流程如下: webpack打包后的部分代码

</code></pre>

//webpack/hot/dev-server.js 也就是webpack 入口添加的文件
module.hot.check(true).then(function(updatedModules) {}).catch(function(updatedModules) {})

// 进入
function hotCheck(apply) {
if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status");
hotApplyOnUpdate = apply;
hotSetStatus("check");
return hotDownloadManifest().then(function(update) {

<pre><code>// update.c标识对应的chunk是否发生了变化
hotAvailableFilesMap = update.c;
hotUpdateNewHash = update.h;

hotSetStatus("prepare");
var promise = new Promise(function(resolve, reject) {
});
// 开始请求 hot-update.json 文件
hotEnsureUpdateChunk(chunkId);
return promise;
</code></pre>

});
}

// 请求之前webpack 生成的hot-update.json文件
function hotDownloadManifest() { // eslint-disable-line no-unused-vars
return new Promise(function(resolve, reject) {
if(typeof XMLHttpRequest === "undefined")
return reject(new Error("No browser support"));
try {
var request = new XMLHttpRequest();
var requestPath = <strong>webpack_require</strong>.p + "" + hotCurrentHash + ".hot-update.json";
request.open("GET", requestPath, true);
request.timeout = 10000;
request.send(null);
} catch(err) {
return reject(err);
}
request.onreadystatechange = function() {
if(request.readyState !== 4) return;
// ...
resolve(update);
}
};
});
}

// 请求之前webpack 生成的hot-update.js 文件
function hotDownloadUpdateChunk(chunkId) { // eslint-disable-line no-unused-vars
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.charset = "utf-8";
script.src = <strong>webpack_require</strong>.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
head.appendChild(script);
}

// 请求的js文件执行如下代码
function webpackHotUpdateCallback(chunkId, moreModules) { // eslint-disable-line no-unused-vars
hotAddUpdateChunk(chunkId, moreModules);
if(parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;

// 后续部分逻辑...
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
if(!module) continue;

var data = {};

// Call dispose handlers
var disposeHandlers = module.hot._disposeHandlers;
for(j = 0; j < disposeHandlers.length; j++) {
cb = disposeHandlers[j];
cb(data);
}
hotCurrentModuleData[moduleId] = data;

// disable module (this disables requires from this module)
module.hot.active = false;

// 删除缓存
// remove module from cache
delete installedModules[moduleId];

// remove "parents" references from all children
for(j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if(!child) continue;
idx = child.parents.indexOf(moduleId);
if(idx >= 0) {
child.parents.splice(idx, 1);
}
}
}

// 插入变化的模块
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}

// 插入模块后, 重新执行js文件,这个过程浏览器是没有刷新的,可以通过浏览器Network看出
// Load self accepted modules
for(i = 0; i < outdatedSelfAcceptedModules.length; i++) {
var item = outdatedSelfAcceptedModules[i];
moduleId = item.module;
hotCurrentParents = [moduleId];
try {
<strong>webpack_require</strong>(moduleId);
} catch(err) {}
}

// <strong>webpack_require</strong>(moduleId); 再次进入 app.js 文件执行 =>

/* 37 */
/***/ (function(module, exports, <strong>webpack_require</strong>) {

"use strict";

<strong>webpack_require</strong>(71);

if (true) {
module.hot.accept((err) => {
if (err) {
console.error('Cannot apply HMR update.', err);
}
});
}
复制代码
```
最后再总结一下整个热更新HMR流程吧:

当我们修改文件并保存时,webpack-dev-server 通过 webpack-dev-middleware 能够拿到webpack打包过程的各个生命周期点, webpack打包过程通过HotModuleReplacementPlugin插件生成hot-update.js和hot-update.json文件,前者是变化的模块字符串信息,后者是本次打包之后module模块所对应的chunk信息以及打包后的hash值,决定客户端浏览器是否更新。 然后webpack-dev-server 通过 websockets把消息发送给客户端浏览器,浏览器收到消息后,分别请求这两个文件,后续为删除installedModules全局缓存对象,并重新赋值,再次执行对应的文件,这样就达到了在无刷新浏览器的条件下,更新变化的模块了,webpack更新模块的代码比较复杂,有的细节没有debug到,到此从Server 到 Client流程以及从Client 到 Server流程也就说清楚了

最后

内容有点多,笔误请谅解!涉及到的相关技术点有的没有提到,比如webpack的打包流程、webpack中检测文件变化的模块、webpack-dev-middleware相关、webpack-dev-server模块还有请求转发等功能没有说到,这个也不在讨论范围内,有兴趣的可以自己clone 代码查看,如果你对webpack打包流程 debug 过 相信再来了解这些东西会好很多 很多...

可能会有同学会说:看这些有什么作用,当然对我来说是当时的好奇心,通过了解大牛的代码实现,能学习到相关优秀的lib库、增强自己对代码的阅读能力。再有就是了解了一些底层再对其使用时,也能游刃有余。

参考:
1、zhuanlan.zhihu.com/p/30669007
2、fed.taobao.org/blog/taofed…
3、github.com/webpack/tap… webpack 如何管理生命周期的核心库
4、astexplorer.net 对了解webpack 如何对代码进行ast分析对照很有用

关注公众号前端论道

作者:快狗打车前端团队
链接:https://juejin.im/post/5e3a28e6e51d4526f76ea753

看完两件小事

如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:

  1. 关注我们的 GitHub 博客,让我们成为长期关系
  2. 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
  3. 关注公众号 「IT平头哥联盟」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程

JS中文网是中国领先的新一代开发者社区和专业的技术媒体,一个帮助开发者成长的社区,目前已经覆盖和服务了超过 300 万开发者,你每天都可以在这里找到技术世界的头条内容。欢迎热爱技术的你一起加入交流与学习,JS中文网的使命是帮助开发者用代码改变世界

本文著作权归作者所有,如若转载,请注明出处

转载请注明:文章转载自「 Js中文网 · 前端进阶资源教程 」https://www.javascriptc.com

标题:基于webpack的热重载live reload和热更新HMR

链接:https://www.javascriptc.com/3944.html

« TypeScript 被吹过头了?
Webpack 转译 Typescript 现有方案»
Flutter 中文教程资源

相关推荐

QR code