[{"data":1,"prerenderedAt":4755},["ShallowReactive",2],{"\u002Ftutorials\u002Fbrowser-extension":3,"tutorials-search\u002Ftutorials\u002Fbrowser-extension":4555},{"id":4,"title":5,"body":6,"category":4544,"cover":4545,"date":4546,"description":4547,"extension":4548,"meta":4549,"navigation":996,"path":4550,"seo":4551,"sitemap":4552,"stem":4553,"__hash__":4554},"tutorials\u002Ftutorials\u002Fbrowser-extension.md","浏览器插件开发：从 Hello World 到 Manifest V3 实战",{"type":7,"value":8,"toc":4478},"minimark",[9,13,25,40,46,51,55,154,161,165,252,259,262,276,291,295,299,306,316,322,459,473,477,482,924,931,934,939,1407,1421,1424,1428,1640,1643,1649,1653,1657,1689,1695,1698,1756,1762,1765,1771,1783,1790,1793,1853,1856,1860,1863,2028,2031,2035,2115,2121,2124,2135,2152,2161,2165,2169,2172,2183,2242,2245,2248,2251,2258,2327,2334,2338,2345,2352,2357,2361,2365,2368,2375,2385,2389,2402,2643,2699,2707,2711,2718,2764,2779,2783,2786,2789,2804,2807,2817,2941,2945,3232,3235,3238,3306,3314,3319,3323,3326,3385,3395,3398,3404,3582,3588,3591,3605,3622,3632,3635,3642,3645,3653,3657,3660,3666,3676,3701,3704,3707,3711,3785,3788,3792,3799,3806,3811,3815,3819,3822,3872,3879,3883,3899,3962,3968,3981,3985,3996,4016,4021,4025,4028,4042,4114,4117,4121,4124,4133,4136,4139,4142,4147,4151,4154,4301,4305,4315,4332,4338,4344,4347,4379,4386,4389,4475],[10,11,12],"p",{},"浏览器插件看起来是工具栏上的一个小图标，实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件，再拆开解释 Popup、Content Script 和 Service Worker，最后补齐消息通信、存储、工程化与发布。",[10,14,15,19,20,24],{},[16,17,18],"strong",{},"学习目标：不用脚手架也能写出一个可加载的 Manifest V3 插件，知道代码在哪运行、需要什么权限、报错去哪看。"," 前置知识是 HTML、CSS、JavaScript，以及 Promise 和 ",[21,22,23],"code",{},"async\u002Fawait","；还不熟悉时，可以先看本站对应教程。",[10,26,27,28,35,36,39],{},"内容组织参考",[29,30,34],"a",{"href":31,"rel":32},"https:\u002F\u002Fwww.fengfengzhidao.com\u002Fspecial\u002F7\u002F81",[33],"nofollow","枫枫知道的浏览器插件开发课程","中课程介绍、Hello World、Content Script、Popup、Service Worker、Plasmo 的主题顺序，讲解和示例为重新编写。本文以桌面版 Chrome \u002F Edge 的 ",[16,37,38],{},"Manifest V3（MV3）"," 为基线，不把旧教程中的 Manifest V2 写法当作当前方案；Firefox 的兼容差异另行说明。",[10,41,42,45],{},[16,43,44],{},"示例运行边界："," 浏览器扩展 API 不能在普通网页或本站的沙箱 iframe 中直接调用。因此本文提供完整文件代码和本地加载步骤，不用模拟按钮冒充真正安装的插件。主线示例不连接后端、不上传网页内容，也不需要安装 npm 依赖。",[47,48,50],"h2",{"id":49},"一先认识插件不是一个权限更大的网页","一、先认识插件：不是一个权限更大的网页",[52,53,54],"h3",{"id":54},"插件由哪些部分组成",[56,57,58,77],"table",{},[59,60,61],"thead",{},[62,63,64,68,71,74],"tr",{},[65,66,67],"th",{},"部分",[65,69,70],{},"运行在哪里",[65,72,73],{},"有什么能力",[65,75,76],{},"不能假设什么",[78,79,80,95,109,123,140],"tbody",{},[62,81,82,86,89,92],{},[83,84,85],"td",{},"Manifest",[83,87,88],{},"浏览器读取的 JSON 配置",[83,90,91],{},"声明入口、权限、资源与版本",[83,93,94],{},"不是 JavaScript，不能写注释",[62,96,97,100,103,106],{},[83,98,99],{},"Popup",[83,101,102],{},"点击工具栏图标打开的扩展页面",[83,104,105],{},"展示按钮、读取配置、调用获准的扩展 API",[83,107,108],{},"不能直接拿到当前网页的 DOM",[62,110,111,114,117,120],{},[83,112,113],{},"Content Script",[83,115,116],{},"注入目标标签页的脚本环境",[83,118,119],{},"读写该页面 DOM，发送扩展消息",[83,121,122],{},"默认不共享网页脚本的 JS 变量",[62,124,125,128,131,134],{},[83,126,127],{},"Service Worker",[83,129,130],{},"扩展的事件驱动后台",[83,132,133],{},"响应事件、处理消息、协调任务",[83,135,136,137],{},"不是常驻进程，也没有 ",[21,138,139],{},"document",[62,141,142,145,148,151],{},[83,143,144],{},"Options Page",[83,146,147],{},"独立的扩展设置页面",[83,149,150],{},"管理较复杂的配置",[83,152,153],{},"不是必需入口",[10,155,156,157,160],{},"用后端开发的经验来理解：Popup 像控制台，Content Script 像部署在网页现场的执行器，Service Worker 像事件处理器。它们通过消息和存储协作，",[16,158,159],{},"不是靠共享一个全局变量协作","。",[52,162,164],{"id":163},"manifest-v2-与-v3-的常见对照","Manifest V2 与 V3 的常见对照",[56,166,167,177],{},[59,168,169],{},[62,170,171,174],{},[65,172,173],{},"旧资料中的写法",[65,175,176],{},"本文采用的 MV3 写法",[78,178,179,191,203,216,228,244],{},[62,180,181,186],{},[83,182,183],{},[21,184,185],{},"manifest_version: 2",[83,187,188],{},[21,189,190],{},"manifest_version: 3",[62,192,193,198],{},[83,194,195],{},[21,196,197],{},"browser_action",[83,199,200],{},[21,201,202],{},"action",[62,204,205,211],{},[83,206,207,208],{},"后台页、",[21,209,210],{},"background.scripts",[83,212,213],{},[21,214,215],{},"background.service_worker",[62,217,218,223],{},[83,219,220],{},[21,221,222],{},"chrome.tabs.executeScript()",[83,224,225],{},[21,226,227],{},"chrome.scripting.executeScript()",[62,229,230,237],{},[83,231,232,233,236],{},"把网站访问权限混在 ",[21,234,235],{},"permissions"," 中",[83,238,239,240,243],{},"使用 ",[21,241,242],{},"host_permissions"," 等明确声明",[62,245,246,249],{},[83,247,248],{},"依赖后台一直存在",[83,250,251],{},"按可随时停止、下次事件再启动设计",[10,253,254,255,258],{},"MV3 不只是改几个字段。后台生命周期、远程代码限制和权限模型都会影响架构。看到一份教程先检查 ",[21,256,257],{},"manifest_version","，再判断代码能否直接用。",[52,260,261],{"id":261},"本章的坑",[10,263,264,267,268,271,272,275],{},[16,265,266],{},"插件不等于万能脚本。"," ",[21,269,270],{},"chrome:\u002F\u002F","、",[21,273,274],{},"edge:\u002F\u002F","、扩展商店等页面受到保护；有权限不意味着能在任何页面注入。先用一个普通 HTTPS 页面验证，再排查特殊页面。",[10,277,278,284,285,287,288,160],{},[16,279,280,283],{},[21,281,282],{},"chrome"," 对象存在不代表扩展 API 可用。"," 普通网页里可能也有名为 ",[21,286,282],{}," 的对象，但没有插件上下文与授权，不能据此判断可调用 ",[21,289,290],{},"chrome.storage",[47,292,294],{"id":293},"二hello-world四个文件做出阅读助手","二、Hello World：四个文件做出阅读助手",[52,296,298],{"id":297},"建立目录与-manifest","建立目录与 Manifest",[10,300,301,302,305],{},"在电脑上新建 ",[21,303,304],{},"reading-helper"," 文件夹，保持以下四个文件位于同一层。下面四段代码共同组成第一版完整插件，不需要后台文件。",[307,308,314],"pre",{"className":309,"code":311,"language":312,"meta":313},[310],"language-text","reading-helper\u002F\n├── manifest.json\n├── popup.html\n├── popup.js\n└── content.js\n","text","",[21,315,311],{"__ignoreMap":313},[10,317,318,321],{},[21,319,320],{},"manifest.json","：",[307,323,327],{"className":324,"code":325,"language":326,"meta":313,"style":313},"language-json shiki shiki-themes github-dark","{\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.0.0\",\n  \"description\": \"由用户点击，为当前页面切换阅读样式。\",\n  \"permissions\": [\"activeTab\", \"scripting\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  }\n}\n","json",[21,328,329,338,354,368,381,394,415,424,436,447,453],{"__ignoreMap":313},[330,331,334],"span",{"class":332,"line":333},"line",1,[330,335,337],{"class":336},"s95oV","{\n",[330,339,341,345,348,351],{"class":332,"line":340},2,[330,342,344],{"class":343},"sDLfK","  \"manifest_version\"",[330,346,347],{"class":336},": ",[330,349,350],{"class":343},"3",[330,352,353],{"class":336},",\n",[330,355,357,360,362,366],{"class":332,"line":356},3,[330,358,359],{"class":343},"  \"name\"",[330,361,347],{"class":336},[330,363,365],{"class":364},"sU2Wk","\"阅读助手\"",[330,367,353],{"class":336},[330,369,371,374,376,379],{"class":332,"line":370},4,[330,372,373],{"class":343},"  \"version\"",[330,375,347],{"class":336},[330,377,378],{"class":364},"\"1.0.0\"",[330,380,353],{"class":336},[330,382,384,387,389,392],{"class":332,"line":383},5,[330,385,386],{"class":343},"  \"description\"",[330,388,347],{"class":336},[330,390,391],{"class":364},"\"由用户点击，为当前页面切换阅读样式。\"",[330,393,353],{"class":336},[330,395,397,400,403,406,409,412],{"class":332,"line":396},6,[330,398,399],{"class":343},"  \"permissions\"",[330,401,402],{"class":336},": [",[330,404,405],{"class":364},"\"activeTab\"",[330,407,408],{"class":336},", ",[330,410,411],{"class":364},"\"scripting\"",[330,413,414],{"class":336},"],\n",[330,416,418,421],{"class":332,"line":417},7,[330,419,420],{"class":343},"  \"action\"",[330,422,423],{"class":336},": {\n",[330,425,427,430,432,434],{"class":332,"line":426},8,[330,428,429],{"class":343},"    \"default_title\"",[330,431,347],{"class":336},[330,433,365],{"class":364},[330,435,353],{"class":336},[330,437,439,442,444],{"class":332,"line":438},9,[330,440,441],{"class":343},"    \"default_popup\"",[330,443,347],{"class":336},[330,445,446],{"class":364},"\"popup.html\"\n",[330,448,450],{"class":332,"line":449},10,[330,451,452],{"class":336},"  }\n",[330,454,456],{"class":332,"line":455},11,[330,457,458],{"class":336},"}\n",[10,460,461,462,465,466,469,470,160],{},"这里只有两个权限：",[21,463,464],{},"activeTab"," 在用户主动调用插件时提供对当前标签页的临时访问能力，",[21,467,468],{},"scripting"," 允许使用脚本注入 API。我们不需要开局就申请所有网站访问权限，也不需要为了查当前标签页 ID 额外申请 ",[21,471,472],{},"tabs",[52,474,476],{"id":475},"编写-popup-页面","编写 Popup 页面",[10,478,479,321],{},[21,480,481],{},"popup.html",[307,483,487],{"className":484,"code":485,"language":486,"meta":313,"style":313},"language-html shiki shiki-themes github-dark","\u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  \u003Ctitle>阅读助手\u003C\u002Ftitle>\n  \u003Cstyle>\n    body { width: 280px; margin: 0; padding: 20px; font: 14px\u002F1.6 sans-serif; }\n    h1 { margin: 0 0 12px; font-size: 20px; }\n    button { width: 100%; padding: 10px; cursor: pointer; }\n    #status { min-height: 44px; margin-bottom: 0; overflow-wrap: anywhere; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>阅读助手\u003C\u002Fh1>\n  \u003Cbutton id=\"toggle\" type=\"button\">切换阅读样式\u003C\u002Fbutton>\n  \u003Cp id=\"status\" role=\"status\" aria-live=\"polite\">请先打开一个普通网页。\u003C\u002Fp>\n  \u003Cscript src=\"popup.js\">\u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[21,488,489,505,523,532,550,574,588,597,664,698,738,772,782,792,802,816,847,883,906,915],{"__ignoreMap":313},[330,490,491,494,498,502],{"class":332,"line":333},[330,492,493],{"class":336},"\u003C!",[330,495,497],{"class":496},"s4JwU","DOCTYPE",[330,499,501],{"class":500},"svObZ"," html",[330,503,504],{"class":336},">\n",[330,506,507,510,512,515,518,521],{"class":332,"line":340},[330,508,509],{"class":336},"\u003C",[330,511,486],{"class":496},[330,513,514],{"class":500}," lang",[330,516,517],{"class":336},"=",[330,519,520],{"class":364},"\"zh-CN\"",[330,522,504],{"class":336},[330,524,525,527,530],{"class":332,"line":356},[330,526,509],{"class":336},[330,528,529],{"class":496},"head",[330,531,504],{"class":336},[330,533,534,537,540,543,545,548],{"class":332,"line":370},[330,535,536],{"class":336},"  \u003C",[330,538,539],{"class":496},"meta",[330,541,542],{"class":500}," charset",[330,544,517],{"class":336},[330,546,547],{"class":364},"\"UTF-8\"",[330,549,504],{"class":336},[330,551,552,554,556,559,561,564,567,569,572],{"class":332,"line":383},[330,553,536],{"class":336},[330,555,539],{"class":496},[330,557,558],{"class":500}," name",[330,560,517],{"class":336},[330,562,563],{"class":364},"\"viewport\"",[330,565,566],{"class":500}," content",[330,568,517],{"class":336},[330,570,571],{"class":364},"\"width=device-width, initial-scale=1\"",[330,573,504],{"class":336},[330,575,576,578,581,584,586],{"class":332,"line":396},[330,577,536],{"class":336},[330,579,580],{"class":496},"title",[330,582,583],{"class":336},">阅读助手\u003C\u002F",[330,585,580],{"class":496},[330,587,504],{"class":336},[330,589,590,592,595],{"class":332,"line":417},[330,591,536],{"class":336},[330,593,594],{"class":496},"style",[330,596,504],{"class":336},[330,598,599,602,605,608,610,613,617,620,623,625,628,630,633,635,638,640,642,645,647,650,652,655,658,661],{"class":332,"line":426},[330,600,601],{"class":496},"    body",[330,603,604],{"class":336}," { ",[330,606,607],{"class":343},"width",[330,609,347],{"class":336},[330,611,612],{"class":343},"280",[330,614,616],{"class":615},"snl16","px",[330,618,619],{"class":336},"; ",[330,621,622],{"class":343},"margin",[330,624,347],{"class":336},[330,626,627],{"class":343},"0",[330,629,619],{"class":336},[330,631,632],{"class":343},"padding",[330,634,347],{"class":336},[330,636,637],{"class":343},"20",[330,639,616],{"class":615},[330,641,619],{"class":336},[330,643,644],{"class":343},"font",[330,646,347],{"class":336},[330,648,649],{"class":343},"14",[330,651,616],{"class":615},[330,653,654],{"class":336},"\u002F",[330,656,657],{"class":343},"1.6",[330,659,660],{"class":343}," sans-serif",[330,662,663],{"class":336},"; }\n",[330,665,666,669,671,673,675,677,680,683,685,687,690,692,694,696],{"class":332,"line":438},[330,667,668],{"class":496},"    h1",[330,670,604],{"class":336},[330,672,622],{"class":343},[330,674,347],{"class":336},[330,676,627],{"class":343},[330,678,679],{"class":343}," 0",[330,681,682],{"class":343}," 12",[330,684,616],{"class":615},[330,686,619],{"class":336},[330,688,689],{"class":343},"font-size",[330,691,347],{"class":336},[330,693,637],{"class":343},[330,695,616],{"class":615},[330,697,663],{"class":336},[330,699,700,703,705,707,709,712,715,717,719,721,724,726,728,731,733,736],{"class":332,"line":449},[330,701,702],{"class":496},"    button",[330,704,604],{"class":336},[330,706,607],{"class":343},[330,708,347],{"class":336},[330,710,711],{"class":343},"100",[330,713,714],{"class":615},"%",[330,716,619],{"class":336},[330,718,632],{"class":343},[330,720,347],{"class":336},[330,722,723],{"class":343},"10",[330,725,616],{"class":615},[330,727,619],{"class":336},[330,729,730],{"class":343},"cursor",[330,732,347],{"class":336},[330,734,735],{"class":343},"pointer",[330,737,663],{"class":336},[330,739,740,743,745,748,750,753,755,757,760,762,764,766,769],{"class":332,"line":455},[330,741,742],{"class":500},"    #status",[330,744,604],{"class":336},[330,746,747],{"class":343},"min-height",[330,749,347],{"class":336},[330,751,752],{"class":343},"44",[330,754,616],{"class":615},[330,756,619],{"class":336},[330,758,759],{"class":343},"margin-bottom",[330,761,347],{"class":336},[330,763,627],{"class":343},[330,765,619],{"class":336},[330,767,768],{"class":343},"overflow-wrap",[330,770,771],{"class":336},": anywhere; }\n",[330,773,775,778,780],{"class":332,"line":774},12,[330,776,777],{"class":336},"  \u003C\u002F",[330,779,594],{"class":496},[330,781,504],{"class":336},[330,783,785,788,790],{"class":332,"line":784},13,[330,786,787],{"class":336},"\u003C\u002F",[330,789,529],{"class":496},[330,791,504],{"class":336},[330,793,795,797,800],{"class":332,"line":794},14,[330,796,509],{"class":336},[330,798,799],{"class":496},"body",[330,801,504],{"class":336},[330,803,805,807,810,812,814],{"class":332,"line":804},15,[330,806,536],{"class":336},[330,808,809],{"class":496},"h1",[330,811,583],{"class":336},[330,813,809],{"class":496},[330,815,504],{"class":336},[330,817,819,821,824,827,829,832,835,837,840,843,845],{"class":332,"line":818},16,[330,820,536],{"class":336},[330,822,823],{"class":496},"button",[330,825,826],{"class":500}," id",[330,828,517],{"class":336},[330,830,831],{"class":364},"\"toggle\"",[330,833,834],{"class":500}," type",[330,836,517],{"class":336},[330,838,839],{"class":364},"\"button\"",[330,841,842],{"class":336},">切换阅读样式\u003C\u002F",[330,844,823],{"class":496},[330,846,504],{"class":336},[330,848,850,852,854,856,858,861,864,866,868,871,873,876,879,881],{"class":332,"line":849},17,[330,851,536],{"class":336},[330,853,10],{"class":496},[330,855,826],{"class":500},[330,857,517],{"class":336},[330,859,860],{"class":364},"\"status\"",[330,862,863],{"class":500}," role",[330,865,517],{"class":336},[330,867,860],{"class":364},[330,869,870],{"class":500}," aria-live",[330,872,517],{"class":336},[330,874,875],{"class":364},"\"polite\"",[330,877,878],{"class":336},">请先打开一个普通网页。\u003C\u002F",[330,880,10],{"class":496},[330,882,504],{"class":336},[330,884,886,888,891,894,896,899,902,904],{"class":332,"line":885},18,[330,887,536],{"class":336},[330,889,890],{"class":496},"script",[330,892,893],{"class":500}," src",[330,895,517],{"class":336},[330,897,898],{"class":364},"\"popup.js\"",[330,900,901],{"class":336},">\u003C\u002F",[330,903,890],{"class":496},[330,905,504],{"class":336},[330,907,909,911,913],{"class":332,"line":908},19,[330,910,787],{"class":336},[330,912,799],{"class":496},[330,914,504],{"class":336},[330,916,918,920,922],{"class":332,"line":917},20,[330,919,787],{"class":336},[330,921,486],{"class":496},[330,923,504],{"class":336},[10,925,926,927,930],{},"脚本放在单独的本地文件中，不写 ",[21,928,929],{},"onclick=\"...\"","，也不放内联 JavaScript。扩展页面的内容安全策略（CSP）比普通练习页面更严格，外链 CDN 脚本也不是这里的默认方案。",[52,932,933],{"id":933},"编写点击逻辑",[10,935,936,321],{},[21,937,938],{},"popup.js",[307,940,944],{"className":941,"code":942,"language":943,"meta":313,"style":313},"language-js shiki shiki-themes github-dark","const toggleButton = document.querySelector('#toggle')\nconst statusElement = document.querySelector('#status')\n\n\u002F**\n * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n * @returns {Promise\u003Cvoid>} 注入结束并更新弹窗状态后完成。\n * @example\n * \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n * toggleButton.addEventListener('click', toggleReading)\n *\u002F\nasync function toggleReading() {\n  toggleButton.disabled = true\n  statusElement.textContent = '正在切换…'\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })\n    if (!Number.isInteger(tab?.id) || !\u002F^https?:\\\u002F\\\u002F\u002Fi.test(tab.url || '')) {\n      throw new Error('请在普通 HTTP\u002FHTTPS 网页上使用。')\n    }\n    const results = await chrome.scripting.executeScript({\n      target: { tabId: tab.id },\n      files: ['content.js']\n    })\n    statusElement.textContent = results[0]?.result\n      ? '已开启阅读样式，再点一次即可恢复。'\n      : '已恢复原始样式。'\n  } catch (error) {\n    statusElement.textContent = `操作失败：${error.message || String(error)}`\n  } finally {\n    toggleButton.disabled = false\n  }\n}\n\ntoggleButton.addEventListener('click', toggleReading)\n","js",[21,945,946,972,992,998,1004,1009,1014,1019,1033,1040,1048,1053,1058,1072,1082,1092,1100,1139,1206,1224,1229,1250,1256,1268,1274,1290,1299,1308,1320,1354,1364,1375,1380,1385,1390],{"__ignoreMap":313},[330,947,948,951,954,957,960,963,966,969],{"class":332,"line":333},[330,949,950],{"class":615},"const",[330,952,953],{"class":343}," toggleButton",[330,955,956],{"class":615}," =",[330,958,959],{"class":336}," document.",[330,961,962],{"class":500},"querySelector",[330,964,965],{"class":336},"(",[330,967,968],{"class":364},"'#toggle'",[330,970,971],{"class":336},")\n",[330,973,974,976,979,981,983,985,987,990],{"class":332,"line":340},[330,975,950],{"class":615},[330,977,978],{"class":343}," statusElement",[330,980,956],{"class":615},[330,982,959],{"class":336},[330,984,962],{"class":500},[330,986,965],{"class":336},[330,988,989],{"class":364},"'#status'",[330,991,971],{"class":336},[330,993,994],{"class":332,"line":356},[330,995,997],{"emptyLinePlaceholder":996},true,"\n",[330,999,1000],{"class":332,"line":370},[330,1001,1003],{"class":1002},"sAwPA","\u002F**\n",[330,1005,1006],{"class":332,"line":383},[330,1007,1008],{"class":1002}," * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n",[330,1010,1011],{"class":332,"line":396},[330,1012,1013],{"class":1002}," * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n",[330,1015,1016],{"class":332,"line":417},[330,1017,1018],{"class":1002}," * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n",[330,1020,1021,1024,1027,1030],{"class":332,"line":426},[330,1022,1023],{"class":1002}," * ",[330,1025,1026],{"class":615},"@returns",[330,1028,1029],{"class":500}," {Promise\u003Cvoid>}",[330,1031,1032],{"class":1002}," 注入结束并更新弹窗状态后完成。\n",[330,1034,1035,1037],{"class":332,"line":438},[330,1036,1023],{"class":1002},[330,1038,1039],{"class":615},"@example\n",[330,1041,1042,1045],{"class":332,"line":449},[330,1043,1044],{"class":1002}," *",[330,1046,1047],{"class":1002}," \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n",[330,1049,1050],{"class":332,"line":455},[330,1051,1052],{"class":1002}," * toggleButton.addEventListener('click', toggleReading)\n",[330,1054,1055],{"class":332,"line":774},[330,1056,1057],{"class":1002}," *\u002F\n",[330,1059,1060,1063,1066,1069],{"class":332,"line":784},[330,1061,1062],{"class":615},"async",[330,1064,1065],{"class":615}," function",[330,1067,1068],{"class":500}," toggleReading",[330,1070,1071],{"class":336},"() {\n",[330,1073,1074,1077,1079],{"class":332,"line":794},[330,1075,1076],{"class":336},"  toggleButton.disabled ",[330,1078,517],{"class":615},[330,1080,1081],{"class":343}," true\n",[330,1083,1084,1087,1089],{"class":332,"line":804},[330,1085,1086],{"class":336},"  statusElement.textContent ",[330,1088,517],{"class":615},[330,1090,1091],{"class":364}," '正在切换…'\n",[330,1093,1094,1097],{"class":332,"line":818},[330,1095,1096],{"class":615},"  try",[330,1098,1099],{"class":336}," {\n",[330,1101,1102,1105,1108,1111,1114,1116,1119,1122,1125,1128,1131,1134,1136],{"class":332,"line":849},[330,1103,1104],{"class":615},"    const",[330,1106,1107],{"class":336}," [",[330,1109,1110],{"class":343},"tab",[330,1112,1113],{"class":336},"] ",[330,1115,517],{"class":615},[330,1117,1118],{"class":615}," await",[330,1120,1121],{"class":336}," chrome.tabs.",[330,1123,1124],{"class":500},"query",[330,1126,1127],{"class":336},"({ active: ",[330,1129,1130],{"class":343},"true",[330,1132,1133],{"class":336},", currentWindow: ",[330,1135,1130],{"class":343},[330,1137,1138],{"class":336}," })\n",[330,1140,1141,1144,1147,1150,1153,1156,1159,1162,1165,1167,1170,1174,1177,1180,1184,1186,1189,1192,1195,1198,1200,1203],{"class":332,"line":885},[330,1142,1143],{"class":615},"    if",[330,1145,1146],{"class":336}," (",[330,1148,1149],{"class":615},"!",[330,1151,1152],{"class":336},"Number.",[330,1154,1155],{"class":500},"isInteger",[330,1157,1158],{"class":336},"(tab?.id) ",[330,1160,1161],{"class":615},"||",[330,1163,1164],{"class":615}," !",[330,1166,654],{"class":364},[330,1168,1169],{"class":615},"^",[330,1171,1173],{"class":1172},"sns5M","https",[330,1175,1176],{"class":615},"?",[330,1178,1179],{"class":1172},":",[330,1181,1183],{"class":1182},"sRjNt","\\\u002F\\\u002F",[330,1185,654],{"class":364},[330,1187,1188],{"class":615},"i",[330,1190,1191],{"class":336},".",[330,1193,1194],{"class":500},"test",[330,1196,1197],{"class":336},"(tab.url ",[330,1199,1161],{"class":615},[330,1201,1202],{"class":364}," ''",[330,1204,1205],{"class":336},")) {\n",[330,1207,1208,1211,1214,1217,1219,1222],{"class":332,"line":908},[330,1209,1210],{"class":615},"      throw",[330,1212,1213],{"class":615}," new",[330,1215,1216],{"class":500}," Error",[330,1218,965],{"class":336},[330,1220,1221],{"class":364},"'请在普通 HTTP\u002FHTTPS 网页上使用。'",[330,1223,971],{"class":336},[330,1225,1226],{"class":332,"line":917},[330,1227,1228],{"class":336},"    }\n",[330,1230,1232,1234,1237,1239,1241,1244,1247],{"class":332,"line":1231},21,[330,1233,1104],{"class":615},[330,1235,1236],{"class":343}," results",[330,1238,956],{"class":615},[330,1240,1118],{"class":615},[330,1242,1243],{"class":336}," chrome.scripting.",[330,1245,1246],{"class":500},"executeScript",[330,1248,1249],{"class":336},"({\n",[330,1251,1253],{"class":332,"line":1252},22,[330,1254,1255],{"class":336},"      target: { tabId: tab.id },\n",[330,1257,1259,1262,1265],{"class":332,"line":1258},23,[330,1260,1261],{"class":336},"      files: [",[330,1263,1264],{"class":364},"'content.js'",[330,1266,1267],{"class":336},"]\n",[330,1269,1271],{"class":332,"line":1270},24,[330,1272,1273],{"class":336},"    })\n",[330,1275,1277,1280,1282,1285,1287],{"class":332,"line":1276},25,[330,1278,1279],{"class":336},"    statusElement.textContent ",[330,1281,517],{"class":615},[330,1283,1284],{"class":336}," results[",[330,1286,627],{"class":343},[330,1288,1289],{"class":336},"]?.result\n",[330,1291,1293,1296],{"class":332,"line":1292},26,[330,1294,1295],{"class":615},"      ?",[330,1297,1298],{"class":364}," '已开启阅读样式，再点一次即可恢复。'\n",[330,1300,1302,1305],{"class":332,"line":1301},27,[330,1303,1304],{"class":615},"      :",[330,1306,1307],{"class":364}," '已恢复原始样式。'\n",[330,1309,1311,1314,1317],{"class":332,"line":1310},28,[330,1312,1313],{"class":336},"  } ",[330,1315,1316],{"class":615},"catch",[330,1318,1319],{"class":336}," (error) {\n",[330,1321,1323,1325,1327,1330,1333,1335,1338,1341,1344,1346,1348,1351],{"class":332,"line":1322},29,[330,1324,1279],{"class":336},[330,1326,517],{"class":615},[330,1328,1329],{"class":364}," `操作失败：${",[330,1331,1332],{"class":336},"error",[330,1334,1191],{"class":364},[330,1336,1337],{"class":336},"message",[330,1339,1340],{"class":615}," ||",[330,1342,1343],{"class":500}," String",[330,1345,965],{"class":364},[330,1347,1332],{"class":336},[330,1349,1350],{"class":364},")",[330,1352,1353],{"class":364},"}`\n",[330,1355,1357,1359,1362],{"class":332,"line":1356},30,[330,1358,1313],{"class":336},[330,1360,1361],{"class":615},"finally",[330,1363,1099],{"class":336},[330,1365,1367,1370,1372],{"class":332,"line":1366},31,[330,1368,1369],{"class":336},"    toggleButton.disabled ",[330,1371,517],{"class":615},[330,1373,1374],{"class":343}," false\n",[330,1376,1378],{"class":332,"line":1377},32,[330,1379,452],{"class":336},[330,1381,1383],{"class":332,"line":1382},33,[330,1384,458],{"class":336},[330,1386,1388],{"class":332,"line":1387},34,[330,1389,997],{"emptyLinePlaceholder":996},[330,1391,1393,1396,1399,1401,1404],{"class":332,"line":1392},35,[330,1394,1395],{"class":336},"toggleButton.",[330,1397,1398],{"class":500},"addEventListener",[330,1400,965],{"class":336},[330,1402,1403],{"class":364},"'click'",[330,1405,1406],{"class":336},", toggleReading)\n",[10,1408,1409,1412,1413,1416,1417,1420],{},[21,1410,1411],{},"chrome.tabs.query()"," 查的是浏览器标签页，不是网页里的 DOM。",[21,1414,1415],{},"executeScript()"," 才负责把 ",[21,1418,1419],{},"content.js"," 送到指定标签页执行。默认只注入主框架，页面里的 iframe 不会因此全部执行一遍。",[52,1422,1423],{"id":1423},"编写页面脚本",[10,1425,1426,321],{},[21,1427,1419],{},[307,1429,1431],{"className":941,"code":1430,"language":943,"meta":313,"style":313},"\u002F**\n * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n * @returns {boolean} true 表示已开启，false 表示已恢复。\n * @example\n * \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n *\u002F\n(() => {\n  const styleId = 'reading-helper-style-v1'\n  const existing = document.getElementById(styleId)\n  if (existing) {\n    existing.remove()\n    return false\n  }\n  const style = document.createElement('style')\n  style.id = styleId\n  style.textContent = `\n    body { background-color: #f5f0e6 !important; }\n    p, li { line-height: 1.9 !important; }\n    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n  `\n  ;(document.head || document.documentElement).append(style)\n  return true\n})()\n",[21,1432,1433,1437,1442,1447,1452,1464,1470,1477,1481,1491,1504,1521,1529,1540,1547,1551,1572,1582,1592,1597,1602,1607,1612,1628,1635],{"__ignoreMap":313},[330,1434,1435],{"class":332,"line":333},[330,1436,1003],{"class":1002},[330,1438,1439],{"class":332,"line":340},[330,1440,1441],{"class":1002}," * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n",[330,1443,1444],{"class":332,"line":356},[330,1445,1446],{"class":1002}," * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n",[330,1448,1449],{"class":332,"line":370},[330,1450,1451],{"class":1002}," * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n",[330,1453,1454,1456,1458,1461],{"class":332,"line":383},[330,1455,1023],{"class":1002},[330,1457,1026],{"class":615},[330,1459,1460],{"class":500}," {boolean}",[330,1462,1463],{"class":1002}," true 表示已开启，false 表示已恢复。\n",[330,1465,1466,1468],{"class":332,"line":396},[330,1467,1023],{"class":1002},[330,1469,1039],{"class":615},[330,1471,1472,1474],{"class":332,"line":417},[330,1473,1044],{"class":1002},[330,1475,1476],{"class":1002}," \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n",[330,1478,1479],{"class":332,"line":426},[330,1480,1057],{"class":1002},[330,1482,1483,1486,1489],{"class":332,"line":438},[330,1484,1485],{"class":336},"(() ",[330,1487,1488],{"class":615},"=>",[330,1490,1099],{"class":336},[330,1492,1493,1496,1499,1501],{"class":332,"line":449},[330,1494,1495],{"class":615},"  const",[330,1497,1498],{"class":343}," styleId",[330,1500,956],{"class":615},[330,1502,1503],{"class":364}," 'reading-helper-style-v1'\n",[330,1505,1506,1508,1511,1513,1515,1518],{"class":332,"line":455},[330,1507,1495],{"class":615},[330,1509,1510],{"class":343}," existing",[330,1512,956],{"class":615},[330,1514,959],{"class":336},[330,1516,1517],{"class":500},"getElementById",[330,1519,1520],{"class":336},"(styleId)\n",[330,1522,1523,1526],{"class":332,"line":774},[330,1524,1525],{"class":615},"  if",[330,1527,1528],{"class":336}," (existing) {\n",[330,1530,1531,1534,1537],{"class":332,"line":784},[330,1532,1533],{"class":336},"    existing.",[330,1535,1536],{"class":500},"remove",[330,1538,1539],{"class":336},"()\n",[330,1541,1542,1545],{"class":332,"line":794},[330,1543,1544],{"class":615},"    return",[330,1546,1374],{"class":343},[330,1548,1549],{"class":332,"line":804},[330,1550,452],{"class":336},[330,1552,1553,1555,1558,1560,1562,1565,1567,1570],{"class":332,"line":818},[330,1554,1495],{"class":615},[330,1556,1557],{"class":343}," style",[330,1559,956],{"class":615},[330,1561,959],{"class":336},[330,1563,1564],{"class":500},"createElement",[330,1566,965],{"class":336},[330,1568,1569],{"class":364},"'style'",[330,1571,971],{"class":336},[330,1573,1574,1577,1579],{"class":332,"line":849},[330,1575,1576],{"class":336},"  style.id ",[330,1578,517],{"class":615},[330,1580,1581],{"class":336}," styleId\n",[330,1583,1584,1587,1589],{"class":332,"line":885},[330,1585,1586],{"class":336},"  style.textContent ",[330,1588,517],{"class":615},[330,1590,1591],{"class":364}," `\n",[330,1593,1594],{"class":332,"line":908},[330,1595,1596],{"class":364},"    body { background-color: #f5f0e6 !important; }\n",[330,1598,1599],{"class":332,"line":917},[330,1600,1601],{"class":364},"    p, li { line-height: 1.9 !important; }\n",[330,1603,1604],{"class":332,"line":1231},[330,1605,1606],{"class":364},"    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n",[330,1608,1609],{"class":332,"line":1252},[330,1610,1611],{"class":364},"  `\n",[330,1613,1614,1617,1619,1622,1625],{"class":332,"line":1258},[330,1615,1616],{"class":336},"  ;(document.head ",[330,1618,1161],{"class":615},[330,1620,1621],{"class":336}," document.documentElement).",[330,1623,1624],{"class":500},"append",[330,1626,1627],{"class":336},"(style)\n",[330,1629,1630,1633],{"class":332,"line":1270},[330,1631,1632],{"class":615},"  return",[330,1634,1081],{"class":343},[330,1636,1637],{"class":332,"line":1276},[330,1638,1639],{"class":336},"})()\n",[10,1641,1642],{},"这是“切换几条阅读样式”，不是完整的正文提取器。复杂网站的深色主题、Shadow DOM、内联样式、特殊布局都可能影响效果。先保持功能边界小，后面再考虑站点适配。",[10,1644,1645,1648],{},[16,1646,1647],{},"本章的坑："," 注入文件的路径相对于扩展根目录，不是相对于网页 URL；代码保存后也不会自动更新已加载的插件，下一章会说明完整刷新顺序。",[47,1650,1652],{"id":1651},"三本地加载与调试先找对控制台","三、本地加载与调试：先找对控制台",[52,1654,1656],{"id":1655},"chrome-与-edge-的加载步骤","Chrome 与 Edge 的加载步骤",[1658,1659,1660,1671,1680,1686],"ol",{},[1661,1662,1663,1664,1667,1668,160],"li",{},"Chrome 打开 ",[21,1665,1666],{},"chrome:\u002F\u002Fextensions","，Edge 打开 ",[21,1669,1670],{},"edge:\u002F\u002Fextensions",[1661,1672,1673,1674,1676,1677,1679],{},"开启“开发者模式”，选择“加载已解压的扩展程序”，选中包含 ",[21,1675,320],{}," 的 ",[21,1678,304],{}," 文件夹。",[1661,1681,1682,1683,160],{},"在工具栏的扩展菜单里固定阅读助手，打开一个普通网页，例如 ",[21,1684,1685],{},"https:\u002F\u002Fexample.com\u002F",[1661,1687,1688],{},"点击阅读助手图标，再点“切换阅读样式”。背景和段落行高应发生变化，再点一次恢复。",[10,1690,1691,1692,160],{},"加载的是文件夹，不是 ZIP 文件。若提示找不到清单文件，先检查是不是选到了外层目录，或文件被编辑器保存成了 ",[21,1693,1694],{},"manifest.json.txt",[52,1696,1697],{"id":1697},"三个环境对应三个调试入口",[56,1699,1700,1713],{},[59,1701,1702],{},[62,1703,1704,1707,1710],{},[65,1705,1706],{},"出问题的位置",[65,1708,1709],{},"去哪里看",[65,1711,1712],{},"典型错误",[78,1714,1715,1726,1736,1746],{},[62,1716,1717,1720,1723],{},[83,1718,1719],{},"Manifest \u002F 加载",[83,1721,1722],{},"扩展管理页的错误详情",[83,1724,1725],{},"JSON 格式错误、文件路径错误",[62,1727,1728,1730,1733],{},[83,1729,99],{},[83,1731,1732],{},"打开弹窗后，在弹窗内右键“检查”",[83,1734,1735],{},"找不到按钮、注入 Promise 被拒绝",[62,1737,1738,1740,1743],{},[83,1739,113],{},[83,1741,1742],{},"目标网页的 DevTools，必要时切换执行上下文",[83,1744,1745],{},"DOM 不存在、选择器错误",[62,1747,1748,1750,1753],{},[83,1749,127],{},[83,1751,1752],{},"扩展管理页中对应的 Service Worker 检查入口",[83,1754,1755],{},"消息校验失败、异步任务失败",[10,1757,1758,1759,1761],{},"Popup 控制台和网页控制台不是同一个地方。把日志写进 ",[21,1760,1419],{}," 后，只盯着 Popup 检查窗口会误以为代码没有执行。",[52,1763,1764],{"id":1764},"修改代码后的正确顺序",[10,1766,1767,1768,160],{},"一般采用：",[16,1769,1770],{},"保存文件 → 扩展管理页重新加载 → 刷新测试网页 → 重新打开 Popup",[1772,1773,1774,1777,1780],"ul",{},[1661,1775,1776],{},"改 Manifest、后台或静态注入规则：重新加载扩展。",[1661,1778,1779],{},"改已注入的 Content Script：刷新网页，旧页面里已经运行的代码不会自动替换。",[1661,1781,1782],{},"改 Popup：重新打开弹窗；为了排除缓存与上下文问题，调试时仍建议完整刷新。",[10,1784,1785,1786,1789],{},"第一版按点击重新注入文件，但旧的页面样式节点仍可能保留，所以开发时刷新目标页面最省事。以后出现 ",[21,1787,1788],{},"Extension context invalidated","，也优先检查是不是扩展重载后网页还留着旧脚本。",[52,1791,1792],{"id":1792},"本章验收清单",[56,1794,1795,1805],{},[59,1796,1797],{},[62,1798,1799,1802],{},[65,1800,1801],{},"操作",[65,1803,1804],{},"预期",[78,1806,1807,1818,1826,1834,1842],{},[62,1808,1809,1812],{},[83,1810,1811],{},"普通网页第一次点击",[83,1813,1814,1815],{},"添加 ",[21,1816,1817],{},"#reading-helper-style-v1",[62,1819,1820,1823],{},[83,1821,1822],{},"同页第二次点击",[83,1824,1825],{},"移除该节点，不破坏原有内联样式",[62,1827,1828,1831],{},[83,1829,1830],{},"关闭再打开 Popup",[83,1832,1833],{},"页面样式仍在；弹窗本身重新初始化",[62,1835,1836,1839],{},[83,1837,1838],{},"刷新网页",[83,1840,1841],{},"样式恢复，因为当前版本没有自动重新应用",[62,1843,1844,1850],{},[83,1845,1846,1847,1849],{},"在 ",[21,1848,1666],{}," 上点击",[83,1851,1852],{},"弹窗给出普通网页使用提示",[10,1854,1855],{},"开发工具打开时可能改变 Popup 的关闭行为或后台生命周期。功能调试完成后，关掉检查窗口再走一次正常用户流程。",[47,1857,1859],{"id":1858},"四manifest-与权限需要什么才申请什么","四、Manifest 与权限：需要什么才申请什么",[52,1861,1862],{"id":1862},"常用字段速查",[56,1864,1865,1878],{},[59,1866,1867],{},[62,1868,1869,1872,1875],{},[65,1870,1871],{},"字段",[65,1873,1874],{},"用途",[65,1876,1877],{},"示例",[78,1879,1880,1893,1915,1929,1947,1961,1982,1996,2015],{},[62,1881,1882,1886,1889],{},[83,1883,1884],{},[21,1885,257],{},[83,1887,1888],{},"清单格式版本",[83,1890,1891],{},[21,1892,350],{},[62,1894,1895,1904,1907],{},[83,1896,1897,1900,1901],{},[21,1898,1899],{},"name"," \u002F ",[21,1902,1903],{},"version",[83,1905,1906],{},"插件名称与发布版本",[83,1908,1909,1900,1912],{},[21,1910,1911],{},"阅读助手",[21,1913,1914],{},"1.0.0",[62,1916,1917,1922,1925],{},[83,1918,1919],{},[21,1920,1921],{},"action.default_popup",[83,1923,1924],{},"工具栏弹窗入口",[83,1926,1927],{},[21,1928,481],{},[62,1930,1931,1935,1938],{},[83,1932,1933],{},[21,1934,235],{},[83,1936,1937],{},"API 与特殊能力",[83,1939,1940,271,1943,271,1945],{},[21,1941,1942],{},"storage",[21,1944,468],{},[21,1946,464],{},[62,1948,1949,1953,1956],{},[83,1950,1951],{},[21,1952,242],{},[83,1954,1955],{},"持续访问目标站点的范围",[83,1957,1958],{},[21,1959,1960],{},"https:\u002F\u002Fapi.example.com\u002F*",[62,1962,1963,1968,1971],{},[83,1964,1965],{},[21,1966,1967],{},"content_scripts",[83,1969,1970],{},"按匹配规则静态注入",[83,1972,1973,1974,271,1977,271,1979],{},"指定 ",[21,1975,1976],{},"matches",[21,1978,943],{},[21,1980,1981],{},"run_at",[62,1983,1984,1988,1991],{},[83,1985,1986],{},[21,1987,215],{},[83,1989,1990],{},"后台事件脚本",[83,1992,1993],{},[21,1994,1995],{},"background.js",[62,1997,1998,2003,2006],{},[83,1999,2000],{},[21,2001,2002],{},"options_ui",[83,2004,2005],{},"设置页面",[83,2007,2008,2011,2012],{},[21,2009,2010],{},"page"," 和 ",[21,2013,2014],{},"open_in_tab",[62,2016,2017,2022,2025],{},[83,2018,2019],{},[21,2020,2021],{},"icons",[83,2023,2024],{},"扩展自身图标",[83,2026,2027],{},"按尺寸映射图片路径",[10,2029,2030],{},"教程为方便复制省略图标，开发时可用默认图标。发布前准备商店要求的图片资源；不要在 Manifest 中声明一个不存在的图标文件。",[52,2032,2034],{"id":2033},"activetabtabs-与-host_permissions-的区别","activeTab、tabs 与 host_permissions 的区别",[56,2036,2037,2050],{},[59,2038,2039],{},[62,2040,2041,2044,2047],{},[65,2042,2043],{},"权限",[65,2045,2046],{},"解决的问题",[65,2048,2049],{},"注意点",[78,2051,2052,2064,2076,2091,2103],{},[62,2053,2054,2058,2061],{},[83,2055,2056],{},[21,2057,464],{},[83,2059,2060],{},"用户调用扩展后临时访问当前标签页",[83,2062,2063],{},"并非永久访问所有站点；跨源导航或关闭标签页会撤销授权",[62,2065,2066,2070,2073],{},[83,2067,2068],{},[21,2069,472],{},[83,2071,2072],{},"读取标签页的 URL、标题等敏感属性",[83,2074,2075],{},"不等于允许往任意网页注入脚本",[62,2077,2078,2082,2085],{},[83,2079,2080],{},[21,2081,468],{},[83,2083,2084],{},"使用脚本\u002FCSS 注入与注册 API",[83,2086,2087,2088,2090],{},"注入还需 ",[21,2089,464],{}," 或相应站点权限",[62,2092,2093,2097,2100],{},[83,2094,2095],{},[21,2096,242],{},[83,2098,2099],{},"为指定站点授予持续访问能力",[83,2101,2102],{},"应尽量限制到确实需要的域名",[62,2104,2105,2109,2112],{},[83,2106,2107],{},[21,2108,1942],{},[83,2110,2111],{},"使用扩展存储 API",[83,2113,2114],{},"不等于可以读取网站的 Cookie 或存储",[10,2116,2117,2118,2120],{},"第一版使用 ",[21,2119,464],{},"，所以用户通过工具栏图标调用扩展后，可以读取当前页相关属性并注入。不能把这段代码搬进一个未获授权的定时任务，再期待它任意访问所有标签页。",[52,2122,2123],{"id":2123},"可选权限与网站匹配",[10,2125,2126,2127,2130,2131,2134],{},"不是每个人都会用到的站点能力，可以声明在 ",[21,2128,2129],{},"optional_host_permissions","，在用户点击相应功能时调用 ",[21,2132,2133],{},"chrome.permissions.request()","。要处理用户拒绝的分支，不要反复强迫授权。",[10,2136,2137,2138,2141,2142,2145,2146,2151],{},"网站匹配模式不是正则表达式。",[21,2139,2140],{},"https:\u002F\u002Fexample.com\u002F*"," 表示 HTTPS 的指定主机和路径；",[21,2143,2144],{},"https:\u002F\u002F*.example.com\u002F*"," 用于匹配该域及其子域。正式声明前用",[29,2147,2150],{"href":2148,"rel":2149},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fmatch-patterns",[33],"官方匹配模式文档","核对范围。",[10,2153,2154,2156,2157,2160],{},[16,2155,1647],{}," 为了让示例“什么网站都能跑”直接申请 ",[21,2158,2159],{},"\u003Call_urls>","，会扩大权限、增加授权提示和审核解释成本。阅读助手的手动开关并不需要它。",[47,2162,2164],{"id":2163},"五content-script能改-dom不等于能读网页变量","五、Content Script：能改 DOM，不等于能读网页变量",[52,2166,2168],{"id":2167},"隔离世界与共享-dom","隔离世界与共享 DOM",[10,2170,2171],{},"默认的 Content Script 运行在隔离世界（ISOLATED）。它和网页脚本共享页面 DOM，但拥有分离的 JavaScript 执行环境。",[10,2173,2174,2175,2178,2179,2182],{},"假设网页脚本执行 ",[21,2176,2177],{},"window.appToken = 'page-value'","，Content Script 中读 ",[21,2180,2181],{},"window.appToken"," 不会因此自动得到该值；反过来，Content Script 声明的普通变量也不会自动暴露给网页脚本。",[56,2184,2185,2194],{},[59,2186,2187],{},[62,2188,2189,2191],{},[65,2190,1801],{},[65,2192,2193],{},"默认 Content Script",[78,2195,2196,2206,2214,2222,2234],{},[62,2197,2198,2203],{},[83,2199,2200],{},[21,2201,2202],{},"document.querySelector('h1')",[83,2204,2205],{},"可以，访问当前注入框架的 DOM",[62,2207,2208,2211],{},[83,2209,2210],{},"修改段落文字或插入按钮",[83,2212,2213],{},"可以，但要避免破坏网站交互",[62,2215,2216,2219],{},[83,2217,2218],{},"读取网页 JS 的局部变量",[83,2220,2221],{},"不可以直接读",[62,2223,2224,2231],{},[83,2225,2226,2227,2230],{},"访问所有 ",[21,2228,2229],{},"chrome.*"," API",[83,2232,2233],{},"不可以，仅有一部分 API 可直接使用",[62,2235,2236,2239],{},[83,2237,2238],{},"读取跨源 iframe 的 DOM",[83,2240,2241],{},"不能绕过浏览器的跨源限制",[10,2243,2244],{},"把脚本改为 MAIN world 会让它进入网页的执行环境，也会改变安全边界和 API 可用性。仅仅为了读写 DOM 不需要这样做，更不应该把凭证放进网页可读的属性中。",[52,2246,2247],{"id":2247},"动态注入与静态注入怎么选",[10,2249,2250],{},"第一版采用动态注入：用户点击按钮才执行一次，适合阅读开关、截取选中文本等明确由用户触发的功能。",[10,2252,2253,2254,2257],{},"如果产品需求变为“打开指定网站就展示工具条”，可以使用以下",[16,2255,2256],{},"独立的 Manifest 片段","，合并到自己的清单中；不要与主线的切换脚本直接叠加使用，否则自动执行和手动执行会互相切换状态。",[307,2259,2261],{"className":324,"code":2260,"language":326,"meta":313,"style":313},"{\n  \"content_scripts\": [\n    {\n      \"matches\": [\"https:\u002F\u002Fexample.com\u002F*\"],\n      \"js\": [\"content.js\"],\n      \"run_at\": \"document_idle\"\n    }\n  ]\n}\n",[21,2262,2263,2267,2275,2280,2292,2304,2314,2318,2323],{"__ignoreMap":313},[330,2264,2265],{"class":332,"line":333},[330,2266,337],{"class":336},[330,2268,2269,2272],{"class":332,"line":340},[330,2270,2271],{"class":343},"  \"content_scripts\"",[330,2273,2274],{"class":336},": [\n",[330,2276,2277],{"class":332,"line":356},[330,2278,2279],{"class":336},"    {\n",[330,2281,2282,2285,2287,2290],{"class":332,"line":370},[330,2283,2284],{"class":343},"      \"matches\"",[330,2286,402],{"class":336},[330,2288,2289],{"class":364},"\"https:\u002F\u002Fexample.com\u002F*\"",[330,2291,414],{"class":336},[330,2293,2294,2297,2299,2302],{"class":332,"line":383},[330,2295,2296],{"class":343},"      \"js\"",[330,2298,402],{"class":336},[330,2300,2301],{"class":364},"\"content.js\"",[330,2303,414],{"class":336},[330,2305,2306,2309,2311],{"class":332,"line":396},[330,2307,2308],{"class":343},"      \"run_at\"",[330,2310,347],{"class":336},[330,2312,2313],{"class":364},"\"document_idle\"\n",[330,2315,2316],{"class":332,"line":417},[330,2317,1228],{"class":336},[330,2319,2320],{"class":332,"line":426},[330,2321,2322],{"class":336},"  ]\n",[330,2324,2325],{"class":332,"line":438},[330,2326,458],{"class":336},[10,2328,2329,2330,2333],{},"静态声明按匹配规则注入，规则本身涉及站点访问授权。",[21,2331,2332],{},"document_idle"," 适合一般 DOM 操作，但不保证 SPA 的异步数据已经渲染完。",[52,2335,2337],{"id":2336},"spa-与重复注入","SPA 与重复注入",[10,2339,2340,2341,2344],{},"单页应用切换路由可能不产生新的文档，因此不会每次都重新触发静态 Content Script。应根据业务选择事件委托、特定容器的 ",[21,2342,2343],{},"MutationObserver","，或必要的导航事件，而不是每隔几毫秒扫描整个文档。",[10,2346,2347,2348,2351],{},"观察 DOM 时要限制范围，并在不再需要时 ",[21,2349,2350],{},"disconnect()","；插件自己插入节点也可能触发观察器，处理不当就会产生循环。",[10,2353,2354,2356],{},[16,2355,1647],{}," 重复注入脚本可能重复绑定事件、重复插入按钮，或遇到顶层变量重声明。主线示例的立即执行函数解决作用域问题，唯一的样式节点解决可逆切换问题；更复杂的工具条应设计明确的初始化标记和销毁逻辑。",[47,2358,2360],{"id":2359},"六popup-与设置弹窗关了变量就没了","六、Popup 与设置：弹窗关了，变量就没了",[52,2362,2364],{"id":2363},"popup-的生命周期","Popup 的生命周期",[10,2366,2367],{},"Popup 是一个扩展页面，但用户点击其他区域后通常就会关闭。下次打开时，HTML 和 JavaScript 重新执行，局部变量也重新创建。",[10,2369,2370,2371,2374],{},"因此不要用 ",[21,2372,2373],{},"let enabled = true"," 作为长期配置来源，也不要让 Popup 承担“必须持续完成”的后台任务。第一版不在 Popup 里保存开启状态，而是根据实际注入结果展示提示。",[10,2376,2377,2378,2380,2381,2384],{},"另外，声明 ",[21,2379,1921],{}," 后，点击工具栏图标会打开弹窗，不会同时触发该扩展的 ",[21,2382,2383],{},"chrome.action.onClicked"," 事件。想走后台点击处理路线，就不要给同一个 action 配置 Popup。",[52,2386,2388],{"id":2387},"使用-chromestorage-保存偏好","使用 chrome.storage 保存偏好",[10,2390,2391,2392,2394,2395,2397,2398,2401],{},"如需保存阅读主题，先在 ",[21,2393,235],{}," 中加入 ",[21,2396,1942],{},"。下面是",[16,2399,2400],{},"可选的扩展示例","，不影响前四个文件运行；只演示保存配置，不会自动把主题应用到网页。",[307,2403,2405],{"className":941,"code":2404,"language":943,"meta":313,"style":313},"\u002F**\n * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n * @param {'paper'|'light'} theme 用户选择的主题。\n * @returns {Promise\u003Cvoid>} 写入扩展本地存储后完成。\n * @example\n * await saveTheme('paper')\n *\u002F\nasync function saveTheme(theme) {\n  if (!['paper', 'light'].includes(theme)) throw new Error('不支持的主题')\n  await chrome.storage.local.set({ readingTheme: theme })\n}\n\n\u002F**\n * 读取主题；首次安装或数据异常时回退到 paper。\n * @returns {Promise\u003C'paper'|'light'>} 可直接用于界面选择器的主题标识。\n * @example\n * const theme = await loadTheme()\n *\u002F\nasync function loadTheme() {\n  const { readingTheme } = await chrome.storage.local.get({ readingTheme: 'paper' })\n  return readingTheme === 'light' ? 'light' : 'paper'\n}\n",[21,2406,2407,2411,2416,2432,2443,2449,2454,2458,2476,2518,2532,2536,2540,2544,2549,2561,2567,2572,2576,2587,2615,2639],{"__ignoreMap":313},[330,2408,2409],{"class":332,"line":333},[330,2410,1003],{"class":1002},[330,2412,2413],{"class":332,"line":340},[330,2414,2415],{"class":1002}," * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n",[330,2417,2418,2420,2423,2426,2429],{"class":332,"line":356},[330,2419,1023],{"class":1002},[330,2421,2422],{"class":615},"@param",[330,2424,2425],{"class":500}," {'paper'|'light'}",[330,2427,2428],{"class":336}," theme",[330,2430,2431],{"class":1002}," 用户选择的主题。\n",[330,2433,2434,2436,2438,2440],{"class":332,"line":370},[330,2435,1023],{"class":1002},[330,2437,1026],{"class":615},[330,2439,1029],{"class":500},[330,2441,2442],{"class":1002}," 写入扩展本地存储后完成。\n",[330,2444,2445,2447],{"class":332,"line":383},[330,2446,1023],{"class":1002},[330,2448,1039],{"class":615},[330,2450,2451],{"class":332,"line":396},[330,2452,2453],{"class":1002}," * await saveTheme('paper')\n",[330,2455,2456],{"class":332,"line":417},[330,2457,1057],{"class":1002},[330,2459,2460,2462,2464,2467,2469,2473],{"class":332,"line":426},[330,2461,1062],{"class":615},[330,2463,1065],{"class":615},[330,2465,2466],{"class":500}," saveTheme",[330,2468,965],{"class":336},[330,2470,2472],{"class":2471},"s9osk","theme",[330,2474,2475],{"class":336},") {\n",[330,2477,2478,2480,2482,2484,2487,2490,2492,2495,2498,2501,2504,2507,2509,2511,2513,2516],{"class":332,"line":438},[330,2479,1525],{"class":615},[330,2481,1146],{"class":336},[330,2483,1149],{"class":615},[330,2485,2486],{"class":336},"[",[330,2488,2489],{"class":364},"'paper'",[330,2491,408],{"class":336},[330,2493,2494],{"class":364},"'light'",[330,2496,2497],{"class":336},"].",[330,2499,2500],{"class":500},"includes",[330,2502,2503],{"class":336},"(theme)) ",[330,2505,2506],{"class":615},"throw",[330,2508,1213],{"class":615},[330,2510,1216],{"class":500},[330,2512,965],{"class":336},[330,2514,2515],{"class":364},"'不支持的主题'",[330,2517,971],{"class":336},[330,2519,2520,2523,2526,2529],{"class":332,"line":449},[330,2521,2522],{"class":615},"  await",[330,2524,2525],{"class":336}," chrome.storage.local.",[330,2527,2528],{"class":500},"set",[330,2530,2531],{"class":336},"({ readingTheme: theme })\n",[330,2533,2534],{"class":332,"line":455},[330,2535,458],{"class":336},[330,2537,2538],{"class":332,"line":774},[330,2539,997],{"emptyLinePlaceholder":996},[330,2541,2542],{"class":332,"line":784},[330,2543,1003],{"class":1002},[330,2545,2546],{"class":332,"line":794},[330,2547,2548],{"class":1002}," * 读取主题；首次安装或数据异常时回退到 paper。\n",[330,2550,2551,2553,2555,2558],{"class":332,"line":804},[330,2552,1023],{"class":1002},[330,2554,1026],{"class":615},[330,2556,2557],{"class":500}," {Promise\u003C'paper'|'light'>}",[330,2559,2560],{"class":1002}," 可直接用于界面选择器的主题标识。\n",[330,2562,2563,2565],{"class":332,"line":818},[330,2564,1023],{"class":1002},[330,2566,1039],{"class":615},[330,2568,2569],{"class":332,"line":849},[330,2570,2571],{"class":1002}," * const theme = await loadTheme()\n",[330,2573,2574],{"class":332,"line":885},[330,2575,1057],{"class":1002},[330,2577,2578,2580,2582,2585],{"class":332,"line":908},[330,2579,1062],{"class":615},[330,2581,1065],{"class":615},[330,2583,2584],{"class":500}," loadTheme",[330,2586,1071],{"class":336},[330,2588,2589,2591,2593,2596,2599,2601,2603,2605,2608,2611,2613],{"class":332,"line":917},[330,2590,1495],{"class":615},[330,2592,604],{"class":336},[330,2594,2595],{"class":343},"readingTheme",[330,2597,2598],{"class":336}," } ",[330,2600,517],{"class":615},[330,2602,1118],{"class":615},[330,2604,2525],{"class":336},[330,2606,2607],{"class":500},"get",[330,2609,2610],{"class":336},"({ readingTheme: ",[330,2612,2489],{"class":364},[330,2614,1138],{"class":336},[330,2616,2617,2619,2622,2625,2628,2631,2633,2636],{"class":332,"line":1231},[330,2618,1632],{"class":615},[330,2620,2621],{"class":336}," readingTheme ",[330,2623,2624],{"class":615},"===",[330,2626,2627],{"class":364}," 'light'",[330,2629,2630],{"class":615}," ?",[330,2632,2627],{"class":364},[330,2634,2635],{"class":615}," :",[330,2637,2638],{"class":364}," 'paper'\n",[330,2640,2641],{"class":332,"line":1252},[330,2642,458],{"class":336},[56,2644,2645,2658],{},[59,2646,2647],{},[62,2648,2649,2652,2655],{},[65,2650,2651],{},"存储区域",[65,2653,2654],{},"适合放什么",[65,2656,2657],{},"不适合放什么",[78,2659,2660,2673,2686],{},[62,2661,2662,2667,2670],{},[83,2663,2664],{},[21,2665,2666],{},"storage.local",[83,2668,2669],{},"本机偏好、适量缓存",[83,2671,2672],{},"无上限的大文件、明文机密",[62,2674,2675,2680,2683],{},[83,2676,2677],{},[21,2678,2679],{},"storage.sync",[83,2681,2682],{},"小体积、需要跨设备同步的设置",[83,2684,2685],{},"高频写入日志、大量网页内容",[62,2687,2688,2693,2696],{},[83,2689,2690],{},[21,2691,2692],{},"storage.session",[83,2694,2695],{},"浏览器会话内的临时状态，支持后台重启后读取",[83,2697,2698],{},"重启浏览器仍必须存在的数据",[10,2700,2701,2703,2704,2706],{},[21,2702,2679],{}," 受配额、登录及同步状态影响，不要把它当作即时数据库。",[21,2705,2692],{}," 默认不暴露给 Content Script；不要把不同区域的访问规则混为一谈。",[52,2708,2710],{"id":2709},"什么时候需要-options-page","什么时候需要 Options Page",[10,2712,2713,2714,2717],{},"配置只有一个开关时放 Popup 就够了；有站点白名单、快捷键说明和较多表单时再加设置页。下面配置中的 ",[21,2715,2716],{},"options.html"," 必须是实际存在的扩展文件。",[307,2719,2721],{"className":324,"code":2720,"language":326,"meta":313,"style":313},"{\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n}\n",[21,2722,2723,2727,2734,2746,2756,2760],{"__ignoreMap":313},[330,2724,2725],{"class":332,"line":333},[330,2726,337],{"class":336},[330,2728,2729,2732],{"class":332,"line":340},[330,2730,2731],{"class":343},"  \"options_ui\"",[330,2733,423],{"class":336},[330,2735,2736,2739,2741,2744],{"class":332,"line":356},[330,2737,2738],{"class":343},"    \"page\"",[330,2740,347],{"class":336},[330,2742,2743],{"class":364},"\"options.html\"",[330,2745,353],{"class":336},[330,2747,2748,2751,2753],{"class":332,"line":370},[330,2749,2750],{"class":343},"    \"open_in_tab\"",[330,2752,347],{"class":336},[330,2754,2755],{"class":343},"true\n",[330,2757,2758],{"class":332,"line":383},[330,2759,452],{"class":336},[330,2761,2762],{"class":332,"line":396},[330,2763,458],{"class":336},[10,2765,2766,267,2768,2011,2771,2774,2775,2778],{},[16,2767,1647],{},[21,2769,2770],{},"localStorage",[21,2772,2773],{},"chrome.storage.local"," 不是一回事。Service Worker 没有 DOM，也不能使用 ",[21,2776,2777],{},"window.localStorage","；Content Script 使用网页存储时又涉及宿主页的上下文，不能用来冒充统一的扩展配置中心。",[47,2780,2782],{"id":2781},"七service-worker按事件唤醒不是常驻后台","七、Service Worker：按事件唤醒，不是常驻后台",[52,2784,2785],{"id":2785},"什么时候才需要后台",[10,2787,2788],{},"第一版没有后台，因为“点按钮 → 改当前页面”不需要中转。出现右键菜单、定时事件、统一网络访问、跨页面消息协调时，再引入 Service Worker。",[10,2790,2791,2792,271,2795,2797,2798,2800,2801,160],{},"MV3 后台没有 ",[21,2793,2794],{},"window",[21,2796,139],{},"，不能直接改网页 DOM。后台读写页面要通过 Content Script 或 ",[21,2799,468],{}," API，而不是写 ",[21,2802,2803],{},"document.querySelector()",[52,2805,2806],{"id":2806},"为阅读助手加入后台",[10,2808,2809,2810,2812,2813,2816],{},"从本节开始做第二版。在第一版基础上新增 ",[21,2811,1995],{},"，并把 Manifest ",[16,2814,2815],{},"完整替换","为下面内容。其他三个文件保持不变；新增后台能力用于下一章的消息练习，原来的阅读开关仍然可用。",[307,2818,2820],{"className":324,"code":2819,"language":326,"meta":313,"style":313},"{\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.1.0\",\n  \"description\": \"切换当前网页阅读样式，并保存本地阅读偏好。\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  }\n}\n",[21,2821,2822,2826,2836,2846,2857,2868,2887,2893,2903,2911,2916,2923,2933,2937],{"__ignoreMap":313},[330,2823,2824],{"class":332,"line":333},[330,2825,337],{"class":336},[330,2827,2828,2830,2832,2834],{"class":332,"line":340},[330,2829,344],{"class":343},[330,2831,347],{"class":336},[330,2833,350],{"class":343},[330,2835,353],{"class":336},[330,2837,2838,2840,2842,2844],{"class":332,"line":356},[330,2839,359],{"class":343},[330,2841,347],{"class":336},[330,2843,365],{"class":364},[330,2845,353],{"class":336},[330,2847,2848,2850,2852,2855],{"class":332,"line":370},[330,2849,373],{"class":343},[330,2851,347],{"class":336},[330,2853,2854],{"class":364},"\"1.1.0\"",[330,2856,353],{"class":336},[330,2858,2859,2861,2863,2866],{"class":332,"line":383},[330,2860,386],{"class":343},[330,2862,347],{"class":336},[330,2864,2865],{"class":364},"\"切换当前网页阅读样式，并保存本地阅读偏好。\"",[330,2867,353],{"class":336},[330,2869,2870,2872,2874,2876,2878,2880,2882,2885],{"class":332,"line":396},[330,2871,399],{"class":343},[330,2873,402],{"class":336},[330,2875,405],{"class":364},[330,2877,408],{"class":336},[330,2879,411],{"class":364},[330,2881,408],{"class":336},[330,2883,2884],{"class":364},"\"storage\"",[330,2886,414],{"class":336},[330,2888,2889,2891],{"class":332,"line":417},[330,2890,420],{"class":343},[330,2892,423],{"class":336},[330,2894,2895,2897,2899,2901],{"class":332,"line":426},[330,2896,429],{"class":343},[330,2898,347],{"class":336},[330,2900,365],{"class":364},[330,2902,353],{"class":336},[330,2904,2905,2907,2909],{"class":332,"line":438},[330,2906,441],{"class":343},[330,2908,347],{"class":336},[330,2910,446],{"class":364},[330,2912,2913],{"class":332,"line":449},[330,2914,2915],{"class":336},"  },\n",[330,2917,2918,2921],{"class":332,"line":455},[330,2919,2920],{"class":343},"  \"background\"",[330,2922,423],{"class":336},[330,2924,2925,2928,2930],{"class":332,"line":774},[330,2926,2927],{"class":343},"    \"service_worker\"",[330,2929,347],{"class":336},[330,2931,2932],{"class":364},"\"background.js\"\n",[330,2934,2935],{"class":332,"line":784},[330,2936,452],{"class":336},[330,2938,2939],{"class":332,"line":794},[330,2940,458],{"class":336},[10,2942,2943,321],{},[21,2944,1995],{},[307,2946,2948],{"className":941,"code":2947,"language":943,"meta":313,"style":313},"\u002F**\n * 处理本扩展发来的主题读取请求。\n * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n * @param {{type?: string}} message 调用方提交的消息；运行时仍需校验。\n * @param {chrome.runtime.MessageSender} sender 浏览器提供的发送者身份。\n * @param {function(Object): void} sendResponse 向调用方回传 JSON 可序列化对象。\n * @returns {boolean} true 表示本监听器稍后会异步响应。\n * @example\n * \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n *\u002F\nfunction handleMessage(message, sender, sendResponse) {\n  if (sender.id !== chrome.runtime.id || message?.type !== 'GET_THEME') {\n    return false\n  }\n  chrome.storage.local.get({ readingTheme: 'paper' })\n    .then(({ readingTheme }) => {\n      sendResponse({ ok: true, theme: readingTheme === 'light' ? 'light' : 'paper' })\n    })\n    .catch((error) => {\n      sendResponse({ ok: false, error: error.message || String(error) })\n    })\n  return true\n}\n\nchrome.runtime.onMessage.addListener(handleMessage)\n",[21,2949,2950,2954,2959,2964,2969,2984,2999,3014,3025,3031,3038,3042,3066,3091,3097,3101,3114,3134,3162,3166,3184,3203,3207,3213,3217,3221],{"__ignoreMap":313},[330,2951,2952],{"class":332,"line":333},[330,2953,1003],{"class":1002},[330,2955,2956],{"class":332,"line":340},[330,2957,2958],{"class":1002}," * 处理本扩展发来的主题读取请求。\n",[330,2960,2961],{"class":332,"line":356},[330,2962,2963],{"class":1002}," * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n",[330,2965,2966],{"class":332,"line":370},[330,2967,2968],{"class":1002}," * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n",[330,2970,2971,2973,2975,2978,2981],{"class":332,"line":383},[330,2972,1023],{"class":1002},[330,2974,2422],{"class":615},[330,2976,2977],{"class":500}," {{type?: string}}",[330,2979,2980],{"class":336}," message",[330,2982,2983],{"class":1002}," 调用方提交的消息；运行时仍需校验。\n",[330,2985,2986,2988,2990,2993,2996],{"class":332,"line":396},[330,2987,1023],{"class":1002},[330,2989,2422],{"class":615},[330,2991,2992],{"class":500}," {chrome.runtime.MessageSender}",[330,2994,2995],{"class":336}," sender",[330,2997,2998],{"class":1002}," 浏览器提供的发送者身份。\n",[330,3000,3001,3003,3005,3008,3011],{"class":332,"line":417},[330,3002,1023],{"class":1002},[330,3004,2422],{"class":615},[330,3006,3007],{"class":500}," {function(Object): void}",[330,3009,3010],{"class":336}," sendResponse",[330,3012,3013],{"class":1002}," 向调用方回传 JSON 可序列化对象。\n",[330,3015,3016,3018,3020,3022],{"class":332,"line":426},[330,3017,1023],{"class":1002},[330,3019,1026],{"class":615},[330,3021,1460],{"class":500},[330,3023,3024],{"class":1002}," true 表示本监听器稍后会异步响应。\n",[330,3026,3027,3029],{"class":332,"line":438},[330,3028,1023],{"class":1002},[330,3030,1039],{"class":615},[330,3032,3033,3035],{"class":332,"line":449},[330,3034,1044],{"class":1002},[330,3036,3037],{"class":1002}," \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n",[330,3039,3040],{"class":332,"line":455},[330,3041,1057],{"class":1002},[330,3043,3044,3047,3050,3052,3054,3056,3059,3061,3064],{"class":332,"line":774},[330,3045,3046],{"class":615},"function",[330,3048,3049],{"class":500}," handleMessage",[330,3051,965],{"class":336},[330,3053,1337],{"class":2471},[330,3055,408],{"class":336},[330,3057,3058],{"class":2471},"sender",[330,3060,408],{"class":336},[330,3062,3063],{"class":2471},"sendResponse",[330,3065,2475],{"class":336},[330,3067,3068,3070,3073,3076,3079,3081,3084,3086,3089],{"class":332,"line":784},[330,3069,1525],{"class":615},[330,3071,3072],{"class":336}," (sender.id ",[330,3074,3075],{"class":615},"!==",[330,3077,3078],{"class":336}," chrome.runtime.id ",[330,3080,1161],{"class":615},[330,3082,3083],{"class":336}," message?.type ",[330,3085,3075],{"class":615},[330,3087,3088],{"class":364}," 'GET_THEME'",[330,3090,2475],{"class":336},[330,3092,3093,3095],{"class":332,"line":794},[330,3094,1544],{"class":615},[330,3096,1374],{"class":343},[330,3098,3099],{"class":332,"line":804},[330,3100,452],{"class":336},[330,3102,3103,3106,3108,3110,3112],{"class":332,"line":818},[330,3104,3105],{"class":336},"  chrome.storage.local.",[330,3107,2607],{"class":500},[330,3109,2610],{"class":336},[330,3111,2489],{"class":364},[330,3113,1138],{"class":336},[330,3115,3116,3119,3122,3125,3127,3130,3132],{"class":332,"line":849},[330,3117,3118],{"class":336},"    .",[330,3120,3121],{"class":500},"then",[330,3123,3124],{"class":336},"(({ ",[330,3126,2595],{"class":2471},[330,3128,3129],{"class":336}," }) ",[330,3131,1488],{"class":615},[330,3133,1099],{"class":336},[330,3135,3136,3139,3142,3144,3147,3149,3151,3153,3155,3157,3160],{"class":332,"line":885},[330,3137,3138],{"class":500},"      sendResponse",[330,3140,3141],{"class":336},"({ ok: ",[330,3143,1130],{"class":343},[330,3145,3146],{"class":336},", theme: readingTheme ",[330,3148,2624],{"class":615},[330,3150,2627],{"class":364},[330,3152,2630],{"class":615},[330,3154,2627],{"class":364},[330,3156,2635],{"class":615},[330,3158,3159],{"class":364}," 'paper'",[330,3161,1138],{"class":336},[330,3163,3164],{"class":332,"line":908},[330,3165,1273],{"class":336},[330,3167,3168,3170,3172,3175,3177,3180,3182],{"class":332,"line":917},[330,3169,3118],{"class":336},[330,3171,1316],{"class":500},[330,3173,3174],{"class":336},"((",[330,3176,1332],{"class":2471},[330,3178,3179],{"class":336},") ",[330,3181,1488],{"class":615},[330,3183,1099],{"class":336},[330,3185,3186,3188,3190,3193,3196,3198,3200],{"class":332,"line":1231},[330,3187,3138],{"class":500},[330,3189,3141],{"class":336},[330,3191,3192],{"class":343},"false",[330,3194,3195],{"class":336},", error: error.message ",[330,3197,1161],{"class":615},[330,3199,1343],{"class":500},[330,3201,3202],{"class":336},"(error) })\n",[330,3204,3205],{"class":332,"line":1252},[330,3206,1273],{"class":336},[330,3208,3209,3211],{"class":332,"line":1258},[330,3210,1632],{"class":615},[330,3212,1081],{"class":343},[330,3214,3215],{"class":332,"line":1270},[330,3216,458],{"class":336},[330,3218,3219],{"class":332,"line":1276},[330,3220,997],{"emptyLinePlaceholder":996},[330,3222,3223,3226,3229],{"class":332,"line":1292},[330,3224,3225],{"class":336},"chrome.runtime.onMessage.",[330,3227,3228],{"class":500},"addListener",[330,3230,3231],{"class":336},"(handleMessage)\n",[10,3233,3234],{},"这里用存储的默认值避免依赖某个只运行一次的初始化动作。即使后台被停止后重新启动，也能重新读取主题，而不是依赖已经丢失的全局变量。",[52,3236,3237],{"id":3237},"生命周期带来的设计约束",[56,3239,3240,3250],{},[59,3241,3242],{},[62,3243,3244,3247],{},[65,3245,3246],{},"错误假设",[65,3248,3249],{},"更稳妥的方式",[78,3251,3252,3263,3278,3290,3298],{},[62,3253,3254,3257],{},[83,3255,3256],{},"全局变量永远在",[83,3258,3259,3260,3262],{},"可恢复状态放 ",[21,3261,1942],{}," 或 IndexedDB",[62,3264,3265,3271],{},[83,3266,3267,3270],{},[21,3268,3269],{},"setInterval"," 可以永久运行",[83,3272,3273,3274,3277],{},"定时需求考虑 ",[21,3275,3276],{},"chrome.alarms","，处理延迟与重建",[62,3279,3280,3287],{},[83,3281,3282,3283,3286],{},"先 ",[21,3284,3285],{},"await"," 读取配置，再注册监听",[83,3288,3289],{},"顶层先注册，事件处理函数内部再读取",[62,3291,3292,3295],{},[83,3293,3294],{},"一个端口能确保后台永不结束",[83,3296,3297],{},"不把连接当作永久保活保证",[62,3299,3300,3303],{},[83,3301,3302],{},"检查窗口里正常就说明生命周期没问题",[83,3304,3305],{},"关闭 DevTools 后重新验证后台停止与唤醒",[10,3307,3308,3309,160],{},"浏览器通常会在空闲一段时间后终止后台，还存在单次任务时间等限制；具体规则会随浏览器版本变化。不要把某个观察到的秒数写成业务计时保证，详见",[29,3310,3313],{"href":3311,"rel":3312},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fservice-workers\u002Flifecycle",[33],"官方生命周期说明",[10,3315,3316,3318],{},[16,3317,1647],{}," 不要试图用无意义的循环或持续请求保持后台存活。应该让事件可以重入、状态可以恢复；下载、较长流程等需求则选择对应的浏览器 API 和可恢复的任务设计。",[47,3320,3322],{"id":3321},"八消息通信先弄清楚收件人是谁","八、消息通信：先弄清楚收件人是谁",[52,3324,3325],{"id":3325},"三条常见通信路线",[56,3327,3328,3341],{},[59,3329,3330],{},[62,3331,3332,3335,3338],{},[65,3333,3334],{},"调用方式",[65,3336,3337],{},"典型方向",[65,3339,3340],{},"适合场景",[78,3342,3343,3356,3369],{},[62,3344,3345,3350,3353],{},[83,3346,3347],{},[21,3348,3349],{},"chrome.runtime.sendMessage()",[83,3351,3352],{},"Popup \u002F Content Script → 后台等扩展监听器",[83,3354,3355],{},"一次请求，一次响应",[62,3357,3358,3363,3366],{},[83,3359,3360],{},[21,3361,3362],{},"chrome.tabs.sendMessage(tabId, message)",[83,3364,3365],{},"Popup \u002F 后台 → 指定标签页的 Content Script",[83,3367,3368],{},"读取页面状态、执行页面操作",[62,3370,3371,3379,3382],{},[83,3372,3373,1900,3376],{},[21,3374,3375],{},"runtime.connect()",[21,3377,3378],{},"tabs.connect()",[83,3380,3381],{},"创建长期消息端口",[83,3383,3384],{},"多次通信，但要处理断线与重连",[10,3386,3387,3390,3391,3394],{},[21,3388,3389],{},"runtime.sendMessage()"," 不是“自动把消息发给当前网页”。给页面中的 Content Script 发消息时，使用 ",[21,3392,3393],{},"tabs.sendMessage()","，并保证目标页面已经注入且注册了监听器。",[52,3396,3397],{"id":3397},"调用刚才的后台",[10,3399,3400,3401,3403],{},"在第二版 ",[21,3402,938],{}," 末尾追加下面代码。弹窗打开后，开发者工具会打印保存的主题；这只是后台通信练习，原有按钮逻辑保持不变。",[307,3405,3407],{"className":941,"code":3406,"language":943,"meta":313,"style":313},"\u002F**\n * 请求后台读取主题，并检查业务层响应。\n * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n * @returns {Promise\u003Cvoid>} 读取完成；失败时输出可定位的错误。\n * @example\n * \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n * printSavedTheme()\n *\u002F\nasync function printSavedTheme() {\n  try {\n    const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' })\n    if (!response?.ok) throw new Error(response?.error || '后台未返回有效结果')\n    console.log('当前保存的主题：', response.theme)\n  } catch (error) {\n    console.error('读取主题失败：', error)\n  }\n}\n\nprintSavedTheme()\n",[21,3408,3409,3413,3418,3423,3434,3440,3447,3452,3456,3467,3473,3498,3525,3541,3549,3563,3567,3571,3575],{"__ignoreMap":313},[330,3410,3411],{"class":332,"line":333},[330,3412,1003],{"class":1002},[330,3414,3415],{"class":332,"line":340},[330,3416,3417],{"class":1002}," * 请求后台读取主题，并检查业务层响应。\n",[330,3419,3420],{"class":332,"line":356},[330,3421,3422],{"class":1002}," * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n",[330,3424,3425,3427,3429,3431],{"class":332,"line":370},[330,3426,1023],{"class":1002},[330,3428,1026],{"class":615},[330,3430,1029],{"class":500},[330,3432,3433],{"class":1002}," 读取完成；失败时输出可定位的错误。\n",[330,3435,3436,3438],{"class":332,"line":383},[330,3437,1023],{"class":1002},[330,3439,1039],{"class":615},[330,3441,3442,3444],{"class":332,"line":396},[330,3443,1044],{"class":1002},[330,3445,3446],{"class":1002}," \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n",[330,3448,3449],{"class":332,"line":417},[330,3450,3451],{"class":1002}," * printSavedTheme()\n",[330,3453,3454],{"class":332,"line":426},[330,3455,1057],{"class":1002},[330,3457,3458,3460,3462,3465],{"class":332,"line":438},[330,3459,1062],{"class":615},[330,3461,1065],{"class":615},[330,3463,3464],{"class":500}," printSavedTheme",[330,3466,1071],{"class":336},[330,3468,3469,3471],{"class":332,"line":449},[330,3470,1096],{"class":615},[330,3472,1099],{"class":336},[330,3474,3475,3477,3480,3482,3484,3487,3490,3493,3496],{"class":332,"line":455},[330,3476,1104],{"class":615},[330,3478,3479],{"class":343}," response",[330,3481,956],{"class":615},[330,3483,1118],{"class":615},[330,3485,3486],{"class":336}," chrome.runtime.",[330,3488,3489],{"class":500},"sendMessage",[330,3491,3492],{"class":336},"({ type: ",[330,3494,3495],{"class":364},"'GET_THEME'",[330,3497,1138],{"class":336},[330,3499,3500,3502,3504,3506,3509,3511,3513,3515,3518,3520,3523],{"class":332,"line":774},[330,3501,1143],{"class":615},[330,3503,1146],{"class":336},[330,3505,1149],{"class":615},[330,3507,3508],{"class":336},"response?.ok) ",[330,3510,2506],{"class":615},[330,3512,1213],{"class":615},[330,3514,1216],{"class":500},[330,3516,3517],{"class":336},"(response?.error ",[330,3519,1161],{"class":615},[330,3521,3522],{"class":364}," '后台未返回有效结果'",[330,3524,971],{"class":336},[330,3526,3527,3530,3533,3535,3538],{"class":332,"line":784},[330,3528,3529],{"class":336},"    console.",[330,3531,3532],{"class":500},"log",[330,3534,965],{"class":336},[330,3536,3537],{"class":364},"'当前保存的主题：'",[330,3539,3540],{"class":336},", response.theme)\n",[330,3542,3543,3545,3547],{"class":332,"line":794},[330,3544,1313],{"class":336},[330,3546,1316],{"class":615},[330,3548,1319],{"class":336},[330,3550,3551,3553,3555,3557,3560],{"class":332,"line":804},[330,3552,3529],{"class":336},[330,3554,1332],{"class":500},[330,3556,965],{"class":336},[330,3558,3559],{"class":364},"'读取主题失败：'",[330,3561,3562],{"class":336},", error)\n",[330,3564,3565],{"class":332,"line":818},[330,3566,452],{"class":336},[330,3568,3569],{"class":332,"line":849},[330,3570,458],{"class":336},[330,3572,3573],{"class":332,"line":885},[330,3574,997],{"emptyLinePlaceholder":996},[330,3576,3577,3580],{"class":332,"line":908},[330,3578,3579],{"class":500},"printSavedTheme",[330,3581,1539],{"class":336},[10,3583,3584,3585,3587],{},"同一个功能在 Popup 内直接调用 ",[21,3586,290],{}," 也能完成。这里刻意经过后台是为了演示协议，不代表所有存储读写都必须转发一次。",[52,3589,3590],{"id":3590},"异步响应与序列化",[10,3592,3593,3594,3597,3598,3601,3602,3604],{},"本文后台使用普通函数监听，异步调用 ",[21,3595,3596],{},"sendResponse()","，并同步 ",[21,3599,3600],{},"return true","。这样可以避开不同浏览器版本对“直接返回 Promise”支持情况的差异。",[21,3603,3600],{}," 不是业务成功，而是“响应稍后到达”。",[10,3606,3607,3608,271,3611,3614,3615,271,3618,3621],{},"消息应使用普通 JSON 数据，例如 ",[21,3609,3610],{},"{ type: 'GET_THEME' }",[21,3612,3613],{},"{ ok: true, theme: 'paper' }","。不要发送 DOM、函数或假设 ",[21,3616,3617],{},"Map",[21,3619,3620],{},"Date"," 在所有浏览器中保持原类型；Chrome 和其他实现的序列化行为存在差异。",[10,3623,3624,3625,3628,3629,3631],{},"多个监听器存在时，只处理自己认识的 ",[21,3626,3627],{},"type","。不要无条件使用 ",[21,3630,1062],{}," 监听器接管所有消息，也不要期望多个响应自动汇总成一个结果。",[52,3633,3634],{"id":3634},"消息安全边界",[10,3636,3637,3638,3641],{},"即使 ",[21,3639,3640],{},"sender.id"," 是本扩展，也不能把消息里的任意 URL、HTML 或命令直接执行。来自 Content Script 的数据可能受网页影响，应继续校验操作类型、字段类型、长度和目标域名。",[10,3643,3644],{},"本例只允许读取一个固定配置键，不接受“读取任意存储键”或“请求任意 URL”的指令。以后如果功能只允许由 Popup 触发，还应按业务约束检查发送者 URL、是否来自标签页等，而不只是比较扩展 ID。",[10,3646,3647,267,3649,3652],{},[16,3648,1647],{},[21,3650,3651],{},"Could not establish connection. Receiving end does not exist."," 往往意味着目标没有监听器、脚本尚未注入，或重载扩展后标签页仍保留旧上下文。先查接收方，再改消息格式。",[47,3654,3656],{"id":3655},"九扩展能力网络请求资源与安全","九、扩展能力：网络请求、资源与安全",[52,3658,3659],{"id":3659},"请求后端不等于绕过所有限制",[10,3661,3662,3663,3665],{},"扩展页面或后台访问指定远程主机时，可以通过 ",[21,3664,242],{}," 获得相应跨源访问能力；Content Script 的请求仍受到其运行上下文的跨源规则约束，不会因为清单里写了域名就变成任意跨域代理。",[10,3667,3668,3669,160,3672,3675],{},"例如需要访问自己的 API，可增加下面的",[16,3670,3671],{},"配置片段",[21,3673,3674],{},"api.example.com"," 是占位域名，不是教程提供的可用接口。",[307,3677,3679],{"className":324,"code":3678,"language":326,"meta":313,"style":313},"{\n  \"host_permissions\": [\"https:\u002F\u002Fapi.example.com\u002F*\"]\n}\n",[21,3680,3681,3685,3697],{"__ignoreMap":313},[330,3682,3683],{"class":332,"line":333},[330,3684,337],{"class":336},[330,3686,3687,3690,3692,3695],{"class":332,"line":340},[330,3688,3689],{"class":343},"  \"host_permissions\"",[330,3691,402],{"class":336},[330,3693,3694],{"class":364},"\"https:\u002F\u002Fapi.example.com\u002F*\"",[330,3696,1267],{"class":336},[330,3698,3699],{"class":332,"line":356},[330,3700,458],{"class":336},[10,3702,3703],{},"后台只应调用预先限定的接口与参数，并处理超时、非 2xx 状态和数据格式异常。不要设计“Content Script 传入任意 URL，后台原样 fetch”的通用转发器，否则网页可能借扩展的权限访问本不应访问的资源。",[10,3705,3706],{},"浏览器权限也不能绕过服务端鉴权、限流或业务授权。插件包内的代码和常量可以被查看，服务端密钥不应打包进去。",[52,3708,3710],{"id":3709},"远程代码csp-与-dom-注入","远程代码、CSP 与 DOM 注入",[56,3712,3713,3723],{},[59,3714,3715],{},[62,3716,3717,3720],{},[65,3718,3719],{},"做法",[65,3721,3722],{},"建议",[78,3724,3725,3739,3754,3762,3777],{},[62,3726,3727,3734],{},[83,3728,3729,3730,3733],{},"在 Popup 内写 ",[21,3731,3732],{},"onclick"," 或内联脚本",[83,3735,3736,3737],{},"改成本地 JS 文件与 ",[21,3738,1398],{},[62,3740,3741,3751],{},[83,3742,3743,3744,1900,3747,3750],{},"下载 JS 后 ",[21,3745,3746],{},"eval()",[21,3748,3749],{},"new Function()"," 执行",[83,3752,3753],{},"不采用；扩展逻辑随包发布",[62,3755,3756,3759],{},[83,3757,3758],{},"从接口读取 JSON 配置",[83,3760,3761],{},"可以设计，但校验结构和取值，不把数据当代码执行",[62,3763,3764,3770],{},[83,3765,3766,3767],{},"把网页选中文本写入 ",[21,3768,3769],{},"innerHTML",[83,3771,3772,3773,3776],{},"优先 ",[21,3774,3775],{},"textContent","，不要把页面输入当可信 HTML",[62,3778,3779,3782],{},[83,3780,3781],{},"把长期令牌放在 DOM 属性中",[83,3783,3784],{},"不采用；网页脚本可以读取共享 DOM",[10,3786,3787],{},"MV3 对远程托管代码有严格限制。即使某种方式在开发环境暂时运行，也不代表满足商店政策。",[52,3789,3791],{"id":3790},"web_accessible_resources-不是文件总清单","web_accessible_resources 不是文件总清单",[10,3793,3794,3795,3798],{},"Popup 直接引用扩展包里的本地脚本，不需要把它们全部声明为网页可访问资源。只有普通网页确实需要加载的扩展资源，才考虑 ",[21,3796,3797],{},"web_accessible_resources"," 并限制匹配范围。",[10,3800,3801,3802,3805],{},"例如 Content Script 往页面插入一张扩展自带图片时，用 ",[21,3803,3804],{},"chrome.runtime.getURL('images\u002Fbadge.png')"," 获取地址，并按需声明该图片的访问规则。不要简单公开整个扩展目录。",[10,3807,3808,3810],{},[16,3809,1647],{}," 插件可以接触用户浏览内容，所以“仅本地处理”“保存多久”“是否上传”必须与实现一致。阅读助手不需要收集浏览历史，也不应该为了调试而长期记录完整网页正文。",[47,3812,3814],{"id":3813},"十从原生到-plasmo框架解决工程问题","十、从原生到 Plasmo：框架解决工程问题",[52,3816,3818],{"id":3817},"先理解原生再选择工具","先理解原生，再选择工具",[10,3820,3821],{},"原生四文件版本已经可以工作。框架主要解决热更新、打包、TypeScript、UI 组件、资源处理和多浏览器构建，并不会取消 Manifest、隔离环境或权限限制。",[56,3823,3824,3837],{},[59,3825,3826],{},[62,3827,3828,3831,3834],{},[65,3829,3830],{},"方案",[65,3832,3833],{},"适合谁",[65,3835,3836],{},"需要承担什么",[78,3838,3839,3850,3861],{},[62,3840,3841,3844,3847],{},[83,3842,3843],{},"原生 HTML \u002F CSS \u002F JS",[83,3845,3846],{},"入门、小型工具、理解扩展机制",[83,3848,3849],{},"自己管理入口与打包",[62,3851,3852,3855,3858],{},[83,3853,3854],{},"Plasmo",[83,3856,3857],{},"希望使用 React \u002F TypeScript 快速搭建扩展 UI",[83,3859,3860],{},"学习文件约定与构建产物",[62,3862,3863,3866,3869],{},[83,3864,3865],{},"WXT 等扩展框架",[83,3867,3868],{},"希望比较 Vue 等 UI 技术与多浏览器流程",[83,3870,3871],{},"核对当前版本支持与迁移成本",[10,3873,3874,3875,3878],{},"Plasmo 官方文档突出 React 与 TypeScript 支持。不要因为自己使用 Vue，就假设所有 Plasmo 示例可以直接改成 ",[21,3876,3877],{},".vue"," 文件运行；选型时先确认框架的当前能力。",[52,3880,3882],{"id":3881},"plasmo-的基本开发流程","Plasmo 的基本开发流程",[10,3884,3885,3886,3889,3890,3892,3893,3898],{},"下面是",[16,3887,3888],{},"另建工程的可选路线","，不要在前面的 ",[21,3891,304],{}," 文件夹里混用。需要 Node.js 和 pnpm；实际版本要求及脚手架提示以",[29,3894,3897],{"href":3895,"rel":3896},"https:\u002F\u002Fdocs.plasmo.com\u002Fframework",[33],"Plasmo 官方文档","为准。",[307,3900,3904],{"className":3901,"code":3902,"language":3903,"meta":313,"style":313},"language-bash shiki shiki-themes github-dark","# 在你自己的项目目录下执行，创建一个新的扩展工程\npnpm create plasmo\n\n# 进入脚手架实际创建的目录，再运行开发构建\ncd your-extension\npnpm dev\n\n# 完成功能后生成生产包\npnpm build\n","bash",[21,3905,3906,3911,3922,3926,3931,3939,3946,3950,3955],{"__ignoreMap":313},[330,3907,3908],{"class":332,"line":333},[330,3909,3910],{"class":1002},"# 在你自己的项目目录下执行，创建一个新的扩展工程\n",[330,3912,3913,3916,3919],{"class":332,"line":340},[330,3914,3915],{"class":500},"pnpm",[330,3917,3918],{"class":364}," create",[330,3920,3921],{"class":364}," plasmo\n",[330,3923,3924],{"class":332,"line":356},[330,3925,997],{"emptyLinePlaceholder":996},[330,3927,3928],{"class":332,"line":370},[330,3929,3930],{"class":1002},"# 进入脚手架实际创建的目录，再运行开发构建\n",[330,3932,3933,3936],{"class":332,"line":383},[330,3934,3935],{"class":343},"cd",[330,3937,3938],{"class":364}," your-extension\n",[330,3940,3941,3943],{"class":332,"line":396},[330,3942,3915],{"class":500},[330,3944,3945],{"class":364}," dev\n",[330,3947,3948],{"class":332,"line":417},[330,3949,997],{"emptyLinePlaceholder":996},[330,3951,3952],{"class":332,"line":426},[330,3953,3954],{"class":1002},"# 完成功能后生成生产包\n",[330,3956,3957,3959],{"class":332,"line":438},[330,3958,3915],{"class":500},[330,3960,3961],{"class":364}," build\n",[10,3963,3964,3967],{},[21,3965,3966],{},"your-extension"," 是占位目录名，不是固定生成的名称。创建命令会下载并运行第三方脚手架，执行前确认包来源；教程本身不需要执行这些命令。",[10,3969,3970,3971,271,3974,2011,3977,3980],{},"典型入口包括 ",[21,3972,3973],{},"popup.tsx",[21,3975,3976],{},"content.ts",[21,3978,3979],{},"background.ts","，更复杂的 Content Script 也有目录式组织方式。框架把这些入口转换为清单和构建文件；修改目录结构时要对照对应版本文档。",[52,3982,3984],{"id":3983},"加载产物不是加载源代码目录","加载产物，不是加载源代码目录",[10,3986,3987,3988,3991,3992,3995],{},"Plasmo 开发构建通常输出到 ",[21,3989,3990],{},"build\u002Fchrome-mv3-dev","，生产构建通常输出到 ",[21,3993,3994],{},"build\u002Fchrome-mv3-prod","，以实际终端输出和项目配置为准。",[10,3997,3998,3999,4005,4006,2011,4008,4011,4012,4015],{},"扩展管理页应加载",[16,4000,4001,4002,4004],{},"产物中包含 ",[21,4003,320],{}," 的目录","，不是只有 ",[21,4007,3973],{},[21,4009,4010],{},"package.json"," 的源码目录。生产发布前运行 ",[21,4013,4014],{},"pnpm build","，不要把带开发连接与调试逻辑的包直接上传。",[10,4017,4018,4020],{},[16,4019,1647],{}," 框架热更新不保证已打开网页里的旧 Content Script 总能无缝替换。遇到异常仍然可以使用“重新加载扩展 → 刷新页面 → 重开 Popup”的排查基线。",[47,4022,4024],{"id":4023},"十一发布与兼容能加载不代表能上架","十一、发布与兼容：能加载不代表能上架",[52,4026,4027],{"id":4027},"打包前检查什么",[10,4029,4030,4031,4033,4034,4037,4038,4041],{},"对于原生版本，把 ",[21,4032,320],{}," 和它引用的文件放在 ZIP 根目录；不要多套一层 ",[21,4035,4036],{},"reading-helper\u002F","。框架版本则打包生产产物，而不是 ",[21,4039,4040],{},"node_modules"," 与源码仓库。",[56,4043,4044,4054],{},[59,4045,4046],{},[62,4047,4048,4051],{},[65,4049,4050],{},"检查项",[65,4052,4053],{},"为什么要做",[78,4055,4056,4066,4074,4082,4090,4098,4106],{},[62,4057,4058,4063],{},[83,4059,4060,4061],{},"递增 Manifest 的 ",[21,4062,1903],{},[83,4064,4065],{},"新版本上传需要有效的版本标识",[62,4067,4068,4071],{},[83,4069,4070],{},"清理开发地址、日志与测试账号",[83,4072,4073],{},"防止开发配置进入正式包",[62,4075,4076,4079],{},[83,4077,4078],{},"所有声明的脚本、图标和页面存在",[83,4080,4081],{},"避免本地遗漏或路径大小写问题",[62,4083,4084,4087],{},[83,4085,4086],{},"权限对应可见功能",[83,4088,4089],{},"降低不必要的授权与审核疑问",[62,4091,4092,4095],{},[83,4093,4094],{},"准备商店图标、截图和说明",[83,4096,4097],{},"用户需要知道插件做什么",[62,4099,4100,4103],{},[83,4101,4102],{},"按实际数据行为填写隐私披露",[83,4104,4105],{},"收集、传输与保存行为必须一致",[62,4107,4108,4111],{},[83,4109,4110],{},"使用干净环境完成一次安装测试",[83,4112,4113],{},"不能依赖开发机遗留存储才能运行",[10,4115,4116],{},"Chrome Web Store 和 Microsoft Edge Add-ons 的账号、素材与审核规则分别维护，提交前查各自官方文档。不要把某一时期的费用、审核时长或图片要求当作永远固定的事实。",[52,4118,4120],{"id":4119},"chromeedge-与-firefox","Chrome、Edge 与 Firefox",[10,4122,4123],{},"Chrome 与 Edge 都基于 Chromium，大量 MV3 API 相近，但仍需要分别验证目标浏览器版本、商店规则和受限页面。",[10,4125,4126,4127,1900,4130,4132],{},"Firefox 的 WebExtensions 与 Chromium 有许多相似 API，但不能承诺同一个 Manifest 原样通用。尤其要检查后台声明、API 支持、权限提示，以及 ",[21,4128,4129],{},"browser.*",[21,4131,2229],{}," 与 Promise 行为。Polyfill 可以改善部分调用差异，不能补齐所有平台能力。",[52,4134,4135],{"id":4135},"升级与卸载场景",[10,4137,4138],{},"以后改变存储结构时，应为旧版本数据设计迁移与默认值，而不是直接假设字段存在。并行事件中的“读 → 改 → 写”也不具备数据库事务保证，不适合用来维护高频精确计数。",[10,4140,4141],{},"升级后保留设置、关闭浏览器后恢复偏好、用户撤销站点访问权限，都是正常使用场景。不要仅测试“开发者工具一直开着、所有权限已同意”的理想路径。",[10,4143,4144,4146],{},[16,4145,1647],{}," 本地加载成功只说明浏览器能识别当前包，不代表商店审核通过，也不代表隐私合规已经完成。",[47,4148,4150],{"id":4149},"十二排错速查与进阶练习","十二、排错速查与进阶练习",[52,4152,4153],{"id":4153},"按症状定位问题",[56,4155,4156,4169],{},[59,4157,4158],{},[62,4159,4160,4163,4166],{},[65,4161,4162],{},"症状",[65,4164,4165],{},"优先检查",[65,4167,4168],{},"处理方式",[78,4170,4171,4182,4193,4209,4223,4234,4246,4257,4268,4279,4290],{},[62,4172,4173,4176,4179],{},[83,4174,4175],{},"插件无法加载",[83,4177,4178],{},"JSON、文件名、选中的目录",[83,4180,4181],{},"检查是否存在根目录 Manifest，无注释与尾逗号",[62,4183,4184,4187,4190],{},[83,4185,4186],{},"Popup 按钮没反应",[83,4188,4189],{},"Popup 控制台、JS 路径、CSP",[83,4191,4192],{},"确认外部脚本加载成功，事件正确注册",[62,4194,4195,4200,4203],{},[83,4196,4197],{},[21,4198,4199],{},"Cannot access contents of url",[83,4201,4202],{},"页面类型、站点权限、用户是否调用扩展",[83,4204,4205,4206,4208],{},"换普通网页，核对 ",[21,4207,464],{}," \u002F 站点授权",[62,4210,4211,4217,4220],{},[83,4212,4213,4216],{},[21,4214,4215],{},"chrome.scripting"," 不可用",[83,4218,4219],{},"是否在扩展上下文、是否声明权限",[83,4221,4222],{},"不要在普通网页控制台直接运行 Popup 代码",[62,4224,4225,4228,4231],{},[83,4226,4227],{},"找不到消息接收端",[83,4229,4230],{},"注入和监听是否完成",[83,4232,4233],{},"重载插件并刷新页面，再确认收件人",[62,4235,4236,4240,4243],{},[83,4237,4238],{},[21,4239,1788],{},[83,4241,4242],{},"页面脚本是否来自旧扩展版本",[83,4244,4245],{},"刷新目标页面",[62,4247,4248,4251,4254],{},[83,4249,4250],{},"后台变量突然变初始值",[83,4252,4253],{},"Worker 是否被停止并重启",[83,4255,4256],{},"使用可恢复存储，不依赖全局状态",[62,4258,4259,4262,4265],{},[83,4260,4261],{},"Content Script 读不到网页变量",[83,4263,4264],{},"默认隔离世界",[83,4266,4267],{},"优先基于 DOM 工作，不盲目切 MAIN world",[62,4269,4270,4273,4276],{},[83,4271,4272],{},"SPA 切页后功能不生效",[83,4274,4275],{},"是否发生真正文档加载",[83,4277,4278],{},"按需监听局部 DOM\u002F路由变化，避免全页轮询",[62,4280,4281,4284,4287],{},[83,4282,4283],{},"生产包报远程脚本或 CSP 错误",[83,4285,4286],{},"CDN、内联脚本、开发构建产物",[83,4288,4289],{},"将代码随包构建，检查生产 CSP",[62,4291,4292,4295,4298],{},[83,4293,4294],{},"主题保存了但网页没变",[83,4296,4297],{},"读取配置与应用样式是否接通",[83,4299,4300],{},"存储不会自动驱动网页 DOM 更新",[52,4302,4304],{"id":4303},"如何测量而不是凭感觉说没问题","如何测量，而不是凭感觉说“没问题”",[10,4306,4307,4310,4311,4314],{},[16,4308,4309],{},"注入次数："," 在测试网页控制台检查 ",[21,4312,4313],{},"document.querySelectorAll('#reading-helper-style-v1').length","。第一版反复点击时应在 0 与 1 之间切换，而不是越点越多。",[10,4316,4317,4320,4321,4324,4325,4328,4329,160],{},[16,4318,4319],{},"恢复能力："," 使用第二版，在 Popup 检查窗口执行 ",[21,4322,4323],{},"chrome.storage.local.set({ readingTheme: 'light' })","。关闭检查窗口与 Popup，等待后台进入非活动状态后重新打开 Popup，再检查打印的主题；应该仍为 ",[21,4326,4327],{},"light","。随后删除该键再试，应回退为 ",[21,4330,4331],{},"paper",[10,4333,4334,4337],{},[16,4335,4336],{},"权限边界："," 分别测试普通网页、受保护页面和跨源导航后的标签页。临时授权失效时，应允许用户重新调用扩展，而不是无限重试。",[10,4339,4340,4343],{},[16,4341,4342],{},"性能："," 若加入 DOM 观察器，在网页 Performance 面板记录操作前后，观察长任务与回调次数。先证明观察范围、频率或处理量有问题，再引入节流；不要用一个固定频率全页扫描来“保证生效”。",[52,4345,4346],{"id":4346},"三个循序渐进的练习",[1658,4348,4349,4364,4370],{},[1661,4350,4351,4354,4355,1900,4357,4359,4360,4363],{},[16,4352,4353],{},"把主题设置接入阅读开关。"," 让 Popup 保存 ",[21,4356,4331],{},[21,4358,4327],{},"，通过注入函数的 ",[21,4361,4362],{},"args"," 或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。",[1661,4365,4366,4369],{},[16,4367,4368],{},"做一个只在指定网站出现的工具条。"," 使用静态 Content Script，确保 SPA 导航后仍可用，重复初始化不产生两个工具条，并能完整移除自己的 DOM 与监听器。",[1661,4371,4372,4375,4376,4378],{},[16,4373,4374],{},"增加用户主动触发的本地摘录。"," 只保存用户明确选择的文字与允许保存的来源信息，不自动收集整页内容；限制条目大小，提供清空入口，并使用 ",[21,4377,3775],{}," 展示摘录。",[10,4380,4381,4382,4385],{},"到这里，你应该能说清楚四个关键问题：",[16,4383,4384],{},"入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。"," 这比记住一组 API 名称更重要，也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。",[52,4387,4388],{"id":4388},"参考资料与继续阅读",[1772,4390,4391,4398,4406,4414,4422,4430,4437,4445,4453,4459,4467],{},[1661,4392,4393,4397],{},[29,4394,4396],{"href":31,"rel":4395},[33],"枫枫知道：浏览器插件开发课程","：本文选题与核心模块的参考入口。",[1661,4399,4400,4405],{},[29,4401,4404],{"href":4402,"rel":4403},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Freference\u002Fmanifest",[33],"Chrome：Manifest 文件格式","：清单字段与 MV3 配置。",[1661,4407,4408,4413],{},[29,4409,4412],{"href":4410,"rel":4411},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002FactiveTab",[33],"Chrome：activeTab","：用户调用与临时站点访问。",[1661,4415,4416,4421],{},[29,4417,4420],{"href":4418,"rel":4419},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fcontent-scripts",[33],"Chrome：Content Scripts","：隔离环境、注入方式与脚本能力。",[1661,4423,4424,4429],{},[29,4425,4428],{"href":4426,"rel":4427},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fmessaging",[33],"Chrome：消息通信","：一次性消息、端口与安全注意事项。",[1661,4431,4432,4436],{},[29,4433,4435],{"href":3311,"rel":4434},[33],"Chrome：Service Worker 生命周期","：后台停止、唤醒与版本差异。",[1661,4438,4439,4444],{},[29,4440,4443],{"href":4441,"rel":4442},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Freference\u002Fapi\u002Fstorage",[33],"Chrome：Storage API","：存储区域、配额与访问规则。",[1661,4446,4447,4452],{},[29,4448,4451],{"href":4449,"rel":4450},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FMozilla\u002FAdd-ons\u002FWebExtensions\u002FAPI\u002Fruntime\u002FonMessage",[33],"MDN：runtime.onMessage","：异步响应与跨浏览器兼容提示。",[1661,4454,4455,4458],{},[29,4456,3897],{"href":3895,"rel":4457},[33],"：工程化、文件约定和构建流程。",[1661,4460,4461,4466],{},[29,4462,4465],{"href":4463,"rel":4464},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fwebstore\u002Fpublish",[33],"Chrome Web Store 发布指南","：提交包、素材与审核流程。",[1661,4468,4469,4474],{},[29,4470,4473],{"href":4471,"rel":4472},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fmicrosoft-edge\u002Fextensions\u002Fpublish\u002Fpublish-extension",[33],"Microsoft Edge 扩展发布文档","：Edge 商店的发布流程。",[594,4476,4477],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":313,"searchDepth":340,"depth":340,"links":4479},[4480,4485,4491,4497,4502,4507,4512,4517,4523,4528,4533,4538],{"id":49,"depth":340,"text":50,"children":4481},[4482,4483,4484],{"id":54,"depth":356,"text":54},{"id":163,"depth":356,"text":164},{"id":261,"depth":356,"text":261},{"id":293,"depth":340,"text":294,"children":4486},[4487,4488,4489,4490],{"id":297,"depth":356,"text":298},{"id":475,"depth":356,"text":476},{"id":933,"depth":356,"text":933},{"id":1423,"depth":356,"text":1423},{"id":1651,"depth":340,"text":1652,"children":4492},[4493,4494,4495,4496],{"id":1655,"depth":356,"text":1656},{"id":1697,"depth":356,"text":1697},{"id":1764,"depth":356,"text":1764},{"id":1792,"depth":356,"text":1792},{"id":1858,"depth":340,"text":1859,"children":4498},[4499,4500,4501],{"id":1862,"depth":356,"text":1862},{"id":2033,"depth":356,"text":2034},{"id":2123,"depth":356,"text":2123},{"id":2163,"depth":340,"text":2164,"children":4503},[4504,4505,4506],{"id":2167,"depth":356,"text":2168},{"id":2247,"depth":356,"text":2247},{"id":2336,"depth":356,"text":2337},{"id":2359,"depth":340,"text":2360,"children":4508},[4509,4510,4511],{"id":2363,"depth":356,"text":2364},{"id":2387,"depth":356,"text":2388},{"id":2709,"depth":356,"text":2710},{"id":2781,"depth":340,"text":2782,"children":4513},[4514,4515,4516],{"id":2785,"depth":356,"text":2785},{"id":2806,"depth":356,"text":2806},{"id":3237,"depth":356,"text":3237},{"id":3321,"depth":340,"text":3322,"children":4518},[4519,4520,4521,4522],{"id":3325,"depth":356,"text":3325},{"id":3397,"depth":356,"text":3397},{"id":3590,"depth":356,"text":3590},{"id":3634,"depth":356,"text":3634},{"id":3655,"depth":340,"text":3656,"children":4524},[4525,4526,4527],{"id":3659,"depth":356,"text":3659},{"id":3709,"depth":356,"text":3710},{"id":3790,"depth":356,"text":3791},{"id":3813,"depth":340,"text":3814,"children":4529},[4530,4531,4532],{"id":3817,"depth":356,"text":3818},{"id":3881,"depth":356,"text":3882},{"id":3983,"depth":356,"text":3984},{"id":4023,"depth":340,"text":4024,"children":4534},[4535,4536,4537],{"id":4027,"depth":356,"text":4027},{"id":4119,"depth":356,"text":4120},{"id":4135,"depth":356,"text":4135},{"id":4149,"depth":340,"text":4150,"children":4539},[4540,4541,4542,4543],{"id":4153,"depth":356,"text":4153},{"id":4303,"depth":356,"text":4304},{"id":4346,"depth":356,"text":4346},{"id":4388,"depth":356,"text":4388},"browser-extension","\u002Fimg\u002Fbrowser-extension-cover.svg","2026-10-23","从零开发 Chrome 与 Edge 浏览器插件：Manifest V3、Popup、Content Script、Service Worker、消息通信、权限与存储，以完整的阅读助手串起实战，再了解 Plasmo 工程化、调试与商店发布。","md",{},"\u002Ftutorials\u002Fbrowser-extension",{"title":5,"description":4547},{"loc":4550},"tutorials\u002Fbrowser-extension","h0MQxnydAYd-XyqYy3F6Hl-6-pZ9I3CL_YvIOczMHHA",[4556,4559,4562,4566,4570,4574,4577,4581,4585,4589,4593,4596,4600,4604,4608,4612,4615,4619,4623,4627,4630,4634,4638,4642,4645,4649,4653,4657,4660,4664,4668,4672,4675,4679,4683,4687,4691,4694,4698,4702,4706,4709,4713,4717,4721,4724,4728,4732,4736,4739,4743,4747,4751],{"id":4550,"title":5,"titles":4557,"content":4558,"level":333},[],"从零开发 Chrome 与 Edge 浏览器插件：Manifest V3、Popup、Content Script、Service Worker、消息通信、权限与存储，以完整的阅读助手串起实战，再了解 Plasmo 工程化、调试与商店发布。 浏览器插件看起来是工具栏上的一个小图标，实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件，再拆开解释 Popup、Content Script 和 Service Worker，最后补齐消息通信、存储、工程化与发布。 学习目标：不用脚手架也能写出一个可加载的 Manifest V3 插件，知道代码在哪运行、需要什么权限、报错去哪看。 前置知识是 HTML、CSS、JavaScript，以及 Promise 和 async\u002Fawait；还不熟悉时，可以先看本站对应教程。 内容组织参考枫枫知道的浏览器插件开发课程中课程介绍、Hello World、Content Script、Popup、Service Worker、Plasmo 的主题顺序，讲解和示例为重新编写。本文以桌面版 Chrome \u002F Edge 的 Manifest V3（MV3） 为基线，不把旧教程中的 Manifest V2 写法当作当前方案；Firefox 的兼容差异另行说明。 示例运行边界： 浏览器扩展 API 不能在普通网页或本站的沙箱 iframe 中直接调用。因此本文提供完整文件代码和本地加载步骤，不用模拟按钮冒充真正安装的插件。主线示例不连接后端、不上传网页内容，也不需要安装 npm 依赖。",{"id":4560,"title":50,"titles":4561,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#一先认识插件不是一个权限更大的网页",[5],{"id":4563,"title":54,"titles":4564,"content":4565,"level":356},"\u002Ftutorials\u002Fbrowser-extension#插件由哪些部分组成",[5,50],"部分 运行在哪里 有什么能力 不能假设什么 Manifest 浏览器读取的 JSON 配置 声明入口、权限、资源与版本 不是 JavaScript，不能写注释 Popup 点击工具栏图标打开的扩展页面 展示按钮、读取配置、调用获准的扩展 API 不能直接拿到当前网页的 DOM Content Script 注入目标标签页的脚本环境 读写该页面 DOM，发送扩展消息 默认不共享网页脚本的 JS 变量 Service Worker 扩展的事件驱动后台 响应事件、处理消息、协调任务 不是常驻进程，也没有 document Options Page 独立的扩展设置页面 管理较复杂的配置 不是必需入口 用后端开发的经验来理解：Popup 像控制台，Content Script 像部署在网页现场的执行器，Service Worker 像事件处理器。它们通过消息和存储协作，不是靠共享一个全局变量协作。",{"id":4567,"title":164,"titles":4568,"content":4569,"level":356},"\u002Ftutorials\u002Fbrowser-extension#manifest-v2-与-v3-的常见对照",[5,50],"旧资料中的写法 本文采用的 MV3 写法 manifest_version: 2 manifest_version: 3 browser_action action 后台页、background.scripts background.service_worker chrome.tabs.executeScript() chrome.scripting.executeScript() 把网站访问权限混在 permissions 中 使用 host_permissions 等明确声明 依赖后台一直存在 按可随时停止、下次事件再启动设计 MV3 不只是改几个字段。后台生命周期、远程代码限制和权限模型都会影响架构。看到一份教程先检查 manifest_version，再判断代码能否直接用。",{"id":4571,"title":261,"titles":4572,"content":4573,"level":356},"\u002Ftutorials\u002Fbrowser-extension#本章的坑",[5,50],"插件不等于万能脚本。 chrome:\u002F\u002F、edge:\u002F\u002F、扩展商店等页面受到保护；有权限不意味着能在任何页面注入。先用一个普通 HTTPS 页面验证，再排查特殊页面。 chrome 对象存在不代表扩展 API 可用。 普通网页里可能也有名为 chrome 的对象，但没有插件上下文与授权，不能据此判断可调用 chrome.storage。",{"id":4575,"title":294,"titles":4576,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#二hello-world四个文件做出阅读助手",[5],{"id":4578,"title":298,"titles":4579,"content":4580,"level":356},"\u002Ftutorials\u002Fbrowser-extension#建立目录与-manifest",[5,294],"在电脑上新建 reading-helper 文件夹，保持以下四个文件位于同一层。下面四段代码共同组成第一版完整插件，不需要后台文件。 reading-helper\u002F\n├── manifest.json\n├── popup.html\n├── popup.js\n└── content.js manifest.json： {\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.0.0\",\n  \"description\": \"由用户点击，为当前页面切换阅读样式。\",\n  \"permissions\": [\"activeTab\", \"scripting\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  }\n} 这里只有两个权限：activeTab 在用户主动调用插件时提供对当前标签页的临时访问能力，scripting 允许使用脚本注入 API。我们不需要开局就申请所有网站访问权限，也不需要为了查当前标签页 ID 额外申请 tabs。",{"id":4582,"title":476,"titles":4583,"content":4584,"level":356},"\u002Ftutorials\u002Fbrowser-extension#编写-popup-页面",[5,294],"popup.html： \u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  \u003Ctitle>阅读助手\u003C\u002Ftitle>\n  \u003Cstyle>\n    body { width: 280px; margin: 0; padding: 20px; font: 14px\u002F1.6 sans-serif; }\n    h1 { margin: 0 0 12px; font-size: 20px; }\n    button { width: 100%; padding: 10px; cursor: pointer; }\n    #status { min-height: 44px; margin-bottom: 0; overflow-wrap: anywhere; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>阅读助手\u003C\u002Fh1>\n  \u003Cbutton id=\"toggle\" type=\"button\">切换阅读样式\u003C\u002Fbutton>\n  \u003Cp id=\"status\" role=\"status\" aria-live=\"polite\">请先打开一个普通网页。\u003C\u002Fp>\n  \u003Cscript src=\"popup.js\">\u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml> 脚本放在单独的本地文件中，不写 onclick=\"...\"，也不放内联 JavaScript。扩展页面的内容安全策略（CSP）比普通练习页面更严格，外链 CDN 脚本也不是这里的默认方案。",{"id":4586,"title":933,"titles":4587,"content":4588,"level":356},"\u002Ftutorials\u002Fbrowser-extension#编写点击逻辑",[5,294],"popup.js： const toggleButton = document.querySelector('#toggle')\nconst statusElement = document.querySelector('#status')\n\n\u002F**\n * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n * @returns {Promise\u003Cvoid>} 注入结束并更新弹窗状态后完成。\n * @example\n * \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n * toggleButton.addEventListener('click', toggleReading)\n *\u002F\nasync function toggleReading() {\n  toggleButton.disabled = true\n  statusElement.textContent = '正在切换…'\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })\n    if (!Number.isInteger(tab?.id) || !\u002F^https?:\\\u002F\\\u002F\u002Fi.test(tab.url || '')) {\n      throw new Error('请在普通 HTTP\u002FHTTPS 网页上使用。')\n    }\n    const results = await chrome.scripting.executeScript({\n      target: { tabId: tab.id },\n      files: ['content.js']\n    })\n    statusElement.textContent = results[0]?.result\n      ? '已开启阅读样式，再点一次即可恢复。'\n      : '已恢复原始样式。'\n  } catch (error) {\n    statusElement.textContent = `操作失败：${error.message || String(error)}`\n  } finally {\n    toggleButton.disabled = false\n  }\n}\n\ntoggleButton.addEventListener('click', toggleReading) chrome.tabs.query() 查的是浏览器标签页，不是网页里的 DOM。executeScript() 才负责把 content.js 送到指定标签页执行。默认只注入主框架，页面里的 iframe 不会因此全部执行一遍。",{"id":4590,"title":1423,"titles":4591,"content":4592,"level":356},"\u002Ftutorials\u002Fbrowser-extension#编写页面脚本",[5,294],"content.js： \u002F**\n * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n * @returns {boolean} true 表示已开启，false 表示已恢复。\n * @example\n * \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n *\u002F\n(() => {\n  const styleId = 'reading-helper-style-v1'\n  const existing = document.getElementById(styleId)\n  if (existing) {\n    existing.remove()\n    return false\n  }\n  const style = document.createElement('style')\n  style.id = styleId\n  style.textContent = `\n    body { background-color: #f5f0e6 !important; }\n    p, li { line-height: 1.9 !important; }\n    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n  `\n  ;(document.head || document.documentElement).append(style)\n  return true\n})() 这是“切换几条阅读样式”，不是完整的正文提取器。复杂网站的深色主题、Shadow DOM、内联样式、特殊布局都可能影响效果。先保持功能边界小，后面再考虑站点适配。 本章的坑： 注入文件的路径相对于扩展根目录，不是相对于网页 URL；代码保存后也不会自动更新已加载的插件，下一章会说明完整刷新顺序。",{"id":4594,"title":1652,"titles":4595,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#三本地加载与调试先找对控制台",[5],{"id":4597,"title":1656,"titles":4598,"content":4599,"level":356},"\u002Ftutorials\u002Fbrowser-extension#chrome-与-edge-的加载步骤",[5,1652],"Chrome 打开 chrome:\u002F\u002Fextensions，Edge 打开 edge:\u002F\u002Fextensions。 开启“开发者模式”，选择“加载已解压的扩展程序”，选中包含 manifest.json 的 reading-helper 文件夹。 在工具栏的扩展菜单里固定阅读助手，打开一个普通网页，例如 https:\u002F\u002Fexample.com\u002F。 点击阅读助手图标，再点“切换阅读样式”。背景和段落行高应发生变化，再点一次恢复。 加载的是文件夹，不是 ZIP 文件。若提示找不到清单文件，先检查是不是选到了外层目录，或文件被编辑器保存成了 manifest.json.txt。",{"id":4601,"title":1697,"titles":4602,"content":4603,"level":356},"\u002Ftutorials\u002Fbrowser-extension#三个环境对应三个调试入口",[5,1652],"出问题的位置 去哪里看 典型错误 Manifest \u002F 加载 扩展管理页的错误详情 JSON 格式错误、文件路径错误 Popup 打开弹窗后，在弹窗内右键“检查” 找不到按钮、注入 Promise 被拒绝 Content Script 目标网页的 DevTools，必要时切换执行上下文 DOM 不存在、选择器错误 Service Worker 扩展管理页中对应的 Service Worker 检查入口 消息校验失败、异步任务失败 Popup 控制台和网页控制台不是同一个地方。把日志写进 content.js 后，只盯着 Popup 检查窗口会误以为代码没有执行。",{"id":4605,"title":1764,"titles":4606,"content":4607,"level":356},"\u002Ftutorials\u002Fbrowser-extension#修改代码后的正确顺序",[5,1652],"一般采用：保存文件 → 扩展管理页重新加载 → 刷新测试网页 → 重新打开 Popup。 改 Manifest、后台或静态注入规则：重新加载扩展。 改已注入的 Content Script：刷新网页，旧页面里已经运行的代码不会自动替换。 改 Popup：重新打开弹窗；为了排除缓存与上下文问题，调试时仍建议完整刷新。 第一版按点击重新注入文件，但旧的页面样式节点仍可能保留，所以开发时刷新目标页面最省事。以后出现 Extension context invalidated，也优先检查是不是扩展重载后网页还留着旧脚本。",{"id":4609,"title":1792,"titles":4610,"content":4611,"level":356},"\u002Ftutorials\u002Fbrowser-extension#本章验收清单",[5,1652],"操作 预期 普通网页第一次点击 添加 #reading-helper-style-v1 同页第二次点击 移除该节点，不破坏原有内联样式 关闭再打开 Popup 页面样式仍在；弹窗本身重新初始化 刷新网页 样式恢复，因为当前版本没有自动重新应用 在 chrome:\u002F\u002Fextensions 上点击 弹窗给出普通网页使用提示 开发工具打开时可能改变 Popup 的关闭行为或后台生命周期。功能调试完成后，关掉检查窗口再走一次正常用户流程。",{"id":4613,"title":1859,"titles":4614,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#四manifest-与权限需要什么才申请什么",[5],{"id":4616,"title":1862,"titles":4617,"content":4618,"level":356},"\u002Ftutorials\u002Fbrowser-extension#常用字段速查",[5,1859],"字段 用途 示例 manifest_version 清单格式版本 3 name \u002F version 插件名称与发布版本 阅读助手 \u002F 1.0.0 action.default_popup 工具栏弹窗入口 popup.html permissions API 与特殊能力 storage、scripting、activeTab host_permissions 持续访问目标站点的范围 https:\u002F\u002Fapi.example.com\u002F* content_scripts 按匹配规则静态注入 指定 matches、js、run_at background.service_worker 后台事件脚本 background.js options_ui 设置页面 page 和 open_in_tab icons 扩展自身图标 按尺寸映射图片路径 教程为方便复制省略图标，开发时可用默认图标。发布前准备商店要求的图片资源；不要在 Manifest 中声明一个不存在的图标文件。",{"id":4620,"title":2034,"titles":4621,"content":4622,"level":356},"\u002Ftutorials\u002Fbrowser-extension#activetabtabs-与-host_permissions-的区别",[5,1859],"权限 解决的问题 注意点 activeTab 用户调用扩展后临时访问当前标签页 并非永久访问所有站点；跨源导航或关闭标签页会撤销授权 tabs 读取标签页的 URL、标题等敏感属性 不等于允许往任意网页注入脚本 scripting 使用脚本\u002FCSS 注入与注册 API 注入还需 activeTab 或相应站点权限 host_permissions 为指定站点授予持续访问能力 应尽量限制到确实需要的域名 storage 使用扩展存储 API 不等于可以读取网站的 Cookie 或存储 第一版使用 activeTab，所以用户通过工具栏图标调用扩展后，可以读取当前页相关属性并注入。不能把这段代码搬进一个未获授权的定时任务，再期待它任意访问所有标签页。",{"id":4624,"title":2123,"titles":4625,"content":4626,"level":356},"\u002Ftutorials\u002Fbrowser-extension#可选权限与网站匹配",[5,1859],"不是每个人都会用到的站点能力，可以声明在 optional_host_permissions，在用户点击相应功能时调用 chrome.permissions.request()。要处理用户拒绝的分支，不要反复强迫授权。 网站匹配模式不是正则表达式。https:\u002F\u002Fexample.com\u002F* 表示 HTTPS 的指定主机和路径；https:\u002F\u002F*.example.com\u002F* 用于匹配该域及其子域。正式声明前用官方匹配模式文档核对范围。 本章的坑： 为了让示例“什么网站都能跑”直接申请 \u003Call_urls>，会扩大权限、增加授权提示和审核解释成本。阅读助手的手动开关并不需要它。",{"id":4628,"title":2164,"titles":4629,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#五content-script能改-dom不等于能读网页变量",[5],{"id":4631,"title":2168,"titles":4632,"content":4633,"level":356},"\u002Ftutorials\u002Fbrowser-extension#隔离世界与共享-dom",[5,2164],"默认的 Content Script 运行在隔离世界（ISOLATED）。它和网页脚本共享页面 DOM，但拥有分离的 JavaScript 执行环境。 假设网页脚本执行 window.appToken = 'page-value'，Content Script 中读 window.appToken 不会因此自动得到该值；反过来，Content Script 声明的普通变量也不会自动暴露给网页脚本。 操作 默认 Content Script document.querySelector('h1') 可以，访问当前注入框架的 DOM 修改段落文字或插入按钮 可以，但要避免破坏网站交互 读取网页 JS 的局部变量 不可以直接读 访问所有 chrome.* API 不可以，仅有一部分 API 可直接使用 读取跨源 iframe 的 DOM 不能绕过浏览器的跨源限制 把脚本改为 MAIN world 会让它进入网页的执行环境，也会改变安全边界和 API 可用性。仅仅为了读写 DOM 不需要这样做，更不应该把凭证放进网页可读的属性中。",{"id":4635,"title":2247,"titles":4636,"content":4637,"level":356},"\u002Ftutorials\u002Fbrowser-extension#动态注入与静态注入怎么选",[5,2164],"第一版采用动态注入：用户点击按钮才执行一次，适合阅读开关、截取选中文本等明确由用户触发的功能。 如果产品需求变为“打开指定网站就展示工具条”，可以使用以下独立的 Manifest 片段，合并到自己的清单中；不要与主线的切换脚本直接叠加使用，否则自动执行和手动执行会互相切换状态。 {\n  \"content_scripts\": [\n    {\n      \"matches\": [\"https:\u002F\u002Fexample.com\u002F*\"],\n      \"js\": [\"content.js\"],\n      \"run_at\": \"document_idle\"\n    }\n  ]\n} 静态声明按匹配规则注入，规则本身涉及站点访问授权。document_idle 适合一般 DOM 操作，但不保证 SPA 的异步数据已经渲染完。",{"id":4639,"title":2337,"titles":4640,"content":4641,"level":356},"\u002Ftutorials\u002Fbrowser-extension#spa-与重复注入",[5,2164],"单页应用切换路由可能不产生新的文档，因此不会每次都重新触发静态 Content Script。应根据业务选择事件委托、特定容器的 MutationObserver，或必要的导航事件，而不是每隔几毫秒扫描整个文档。 观察 DOM 时要限制范围，并在不再需要时 disconnect()；插件自己插入节点也可能触发观察器，处理不当就会产生循环。 本章的坑： 重复注入脚本可能重复绑定事件、重复插入按钮，或遇到顶层变量重声明。主线示例的立即执行函数解决作用域问题，唯一的样式节点解决可逆切换问题；更复杂的工具条应设计明确的初始化标记和销毁逻辑。",{"id":4643,"title":2360,"titles":4644,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#六popup-与设置弹窗关了变量就没了",[5],{"id":4646,"title":2364,"titles":4647,"content":4648,"level":356},"\u002Ftutorials\u002Fbrowser-extension#popup-的生命周期",[5,2360],"Popup 是一个扩展页面，但用户点击其他区域后通常就会关闭。下次打开时，HTML 和 JavaScript 重新执行，局部变量也重新创建。 因此不要用 let enabled = true 作为长期配置来源，也不要让 Popup 承担“必须持续完成”的后台任务。第一版不在 Popup 里保存开启状态，而是根据实际注入结果展示提示。 另外，声明 action.default_popup 后，点击工具栏图标会打开弹窗，不会同时触发该扩展的 chrome.action.onClicked 事件。想走后台点击处理路线，就不要给同一个 action 配置 Popup。",{"id":4650,"title":2388,"titles":4651,"content":4652,"level":356},"\u002Ftutorials\u002Fbrowser-extension#使用-chromestorage-保存偏好",[5,2360],"如需保存阅读主题，先在 permissions 中加入 storage。下面是可选的扩展示例，不影响前四个文件运行；只演示保存配置，不会自动把主题应用到网页。 \u002F**\n * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n * @param {'paper'|'light'} theme 用户选择的主题。\n * @returns {Promise\u003Cvoid>} 写入扩展本地存储后完成。\n * @example\n * await saveTheme('paper')\n *\u002F\nasync function saveTheme(theme) {\n  if (!['paper', 'light'].includes(theme)) throw new Error('不支持的主题')\n  await chrome.storage.local.set({ readingTheme: theme })\n}\n\n\u002F**\n * 读取主题；首次安装或数据异常时回退到 paper。\n * @returns {Promise\u003C'paper'|'light'>} 可直接用于界面选择器的主题标识。\n * @example\n * const theme = await loadTheme()\n *\u002F\nasync function loadTheme() {\n  const { readingTheme } = await chrome.storage.local.get({ readingTheme: 'paper' })\n  return readingTheme === 'light' ? 'light' : 'paper'\n} 存储区域 适合放什么 不适合放什么 storage.local 本机偏好、适量缓存 无上限的大文件、明文机密 storage.sync 小体积、需要跨设备同步的设置 高频写入日志、大量网页内容 storage.session 浏览器会话内的临时状态，支持后台重启后读取 重启浏览器仍必须存在的数据 storage.sync 受配额、登录及同步状态影响，不要把它当作即时数据库。storage.session 默认不暴露给 Content Script；不要把不同区域的访问规则混为一谈。",{"id":4654,"title":2710,"titles":4655,"content":4656,"level":356},"\u002Ftutorials\u002Fbrowser-extension#什么时候需要-options-page",[5,2360],"配置只有一个开关时放 Popup 就够了；有站点白名单、快捷键说明和较多表单时再加设置页。下面配置中的 options.html 必须是实际存在的扩展文件。 {\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n} 本章的坑： localStorage 和 chrome.storage.local 不是一回事。Service Worker 没有 DOM，也不能使用 window.localStorage；Content Script 使用网页存储时又涉及宿主页的上下文，不能用来冒充统一的扩展配置中心。",{"id":4658,"title":2782,"titles":4659,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#七service-worker按事件唤醒不是常驻后台",[5],{"id":4661,"title":2785,"titles":4662,"content":4663,"level":356},"\u002Ftutorials\u002Fbrowser-extension#什么时候才需要后台",[5,2782],"第一版没有后台，因为“点按钮 → 改当前页面”不需要中转。出现右键菜单、定时事件、统一网络访问、跨页面消息协调时，再引入 Service Worker。 MV3 后台没有 window、document，不能直接改网页 DOM。后台读写页面要通过 Content Script 或 scripting API，而不是写 document.querySelector()。",{"id":4665,"title":2806,"titles":4666,"content":4667,"level":356},"\u002Ftutorials\u002Fbrowser-extension#为阅读助手加入后台",[5,2782],"从本节开始做第二版。在第一版基础上新增 background.js，并把 Manifest 完整替换为下面内容。其他三个文件保持不变；新增后台能力用于下一章的消息练习，原来的阅读开关仍然可用。 {\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.1.0\",\n  \"description\": \"切换当前网页阅读样式，并保存本地阅读偏好。\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  }\n} background.js： \u002F**\n * 处理本扩展发来的主题读取请求。\n * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n * @param {{type?: string}} message 调用方提交的消息；运行时仍需校验。\n * @param {chrome.runtime.MessageSender} sender 浏览器提供的发送者身份。\n * @param {function(Object): void} sendResponse 向调用方回传 JSON 可序列化对象。\n * @returns {boolean} true 表示本监听器稍后会异步响应。\n * @example\n * \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n *\u002F\nfunction handleMessage(message, sender, sendResponse) {\n  if (sender.id !== chrome.runtime.id || message?.type !== 'GET_THEME') {\n    return false\n  }\n  chrome.storage.local.get({ readingTheme: 'paper' })\n    .then(({ readingTheme }) => {\n      sendResponse({ ok: true, theme: readingTheme === 'light' ? 'light' : 'paper' })\n    })\n    .catch((error) => {\n      sendResponse({ ok: false, error: error.message || String(error) })\n    })\n  return true\n}\n\nchrome.runtime.onMessage.addListener(handleMessage) 这里用存储的默认值避免依赖某个只运行一次的初始化动作。即使后台被停止后重新启动，也能重新读取主题，而不是依赖已经丢失的全局变量。",{"id":4669,"title":3237,"titles":4670,"content":4671,"level":356},"\u002Ftutorials\u002Fbrowser-extension#生命周期带来的设计约束",[5,2782],"错误假设 更稳妥的方式 全局变量永远在 可恢复状态放 storage 或 IndexedDB setInterval 可以永久运行 定时需求考虑 chrome.alarms，处理延迟与重建 先 await 读取配置，再注册监听 顶层先注册，事件处理函数内部再读取 一个端口能确保后台永不结束 不把连接当作永久保活保证 检查窗口里正常就说明生命周期没问题 关闭 DevTools 后重新验证后台停止与唤醒 浏览器通常会在空闲一段时间后终止后台，还存在单次任务时间等限制；具体规则会随浏览器版本变化。不要把某个观察到的秒数写成业务计时保证，详见官方生命周期说明。 本章的坑： 不要试图用无意义的循环或持续请求保持后台存活。应该让事件可以重入、状态可以恢复；下载、较长流程等需求则选择对应的浏览器 API 和可恢复的任务设计。",{"id":4673,"title":3322,"titles":4674,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#八消息通信先弄清楚收件人是谁",[5],{"id":4676,"title":3325,"titles":4677,"content":4678,"level":356},"\u002Ftutorials\u002Fbrowser-extension#三条常见通信路线",[5,3322],"调用方式 典型方向 适合场景 chrome.runtime.sendMessage() Popup \u002F Content Script → 后台等扩展监听器 一次请求，一次响应 chrome.tabs.sendMessage(tabId, message) Popup \u002F 后台 → 指定标签页的 Content Script 读取页面状态、执行页面操作 runtime.connect() \u002F tabs.connect() 创建长期消息端口 多次通信，但要处理断线与重连 runtime.sendMessage() 不是“自动把消息发给当前网页”。给页面中的 Content Script 发消息时，使用 tabs.sendMessage()，并保证目标页面已经注入且注册了监听器。",{"id":4680,"title":3397,"titles":4681,"content":4682,"level":356},"\u002Ftutorials\u002Fbrowser-extension#调用刚才的后台",[5,3322],"在第二版 popup.js 末尾追加下面代码。弹窗打开后，开发者工具会打印保存的主题；这只是后台通信练习，原有按钮逻辑保持不变。 \u002F**\n * 请求后台读取主题，并检查业务层响应。\n * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n * @returns {Promise\u003Cvoid>} 读取完成；失败时输出可定位的错误。\n * @example\n * \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n * printSavedTheme()\n *\u002F\nasync function printSavedTheme() {\n  try {\n    const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' })\n    if (!response?.ok) throw new Error(response?.error || '后台未返回有效结果')\n    console.log('当前保存的主题：', response.theme)\n  } catch (error) {\n    console.error('读取主题失败：', error)\n  }\n}\n\nprintSavedTheme() 同一个功能在 Popup 内直接调用 chrome.storage 也能完成。这里刻意经过后台是为了演示协议，不代表所有存储读写都必须转发一次。",{"id":4684,"title":3590,"titles":4685,"content":4686,"level":356},"\u002Ftutorials\u002Fbrowser-extension#异步响应与序列化",[5,3322],"本文后台使用普通函数监听，异步调用 sendResponse()，并同步 return true。这样可以避开不同浏览器版本对“直接返回 Promise”支持情况的差异。return true 不是业务成功，而是“响应稍后到达”。 消息应使用普通 JSON 数据，例如 { type: 'GET_THEME' }、{ ok: true, theme: 'paper' }。不要发送 DOM、函数或假设 Map、Date 在所有浏览器中保持原类型；Chrome 和其他实现的序列化行为存在差异。 多个监听器存在时，只处理自己认识的 type。不要无条件使用 async 监听器接管所有消息，也不要期望多个响应自动汇总成一个结果。",{"id":4688,"title":3634,"titles":4689,"content":4690,"level":356},"\u002Ftutorials\u002Fbrowser-extension#消息安全边界",[5,3322],"即使 sender.id 是本扩展，也不能把消息里的任意 URL、HTML 或命令直接执行。来自 Content Script 的数据可能受网页影响，应继续校验操作类型、字段类型、长度和目标域名。 本例只允许读取一个固定配置键，不接受“读取任意存储键”或“请求任意 URL”的指令。以后如果功能只允许由 Popup 触发，还应按业务约束检查发送者 URL、是否来自标签页等，而不只是比较扩展 ID。 本章的坑： Could not establish connection. Receiving end does not exist. 往往意味着目标没有监听器、脚本尚未注入，或重载扩展后标签页仍保留旧上下文。先查接收方，再改消息格式。",{"id":4692,"title":3656,"titles":4693,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#九扩展能力网络请求资源与安全",[5],{"id":4695,"title":3659,"titles":4696,"content":4697,"level":356},"\u002Ftutorials\u002Fbrowser-extension#请求后端不等于绕过所有限制",[5,3656],"扩展页面或后台访问指定远程主机时，可以通过 host_permissions 获得相应跨源访问能力；Content Script 的请求仍受到其运行上下文的跨源规则约束，不会因为清单里写了域名就变成任意跨域代理。 例如需要访问自己的 API，可增加下面的配置片段。api.example.com 是占位域名，不是教程提供的可用接口。 {\n  \"host_permissions\": [\"https:\u002F\u002Fapi.example.com\u002F*\"]\n} 后台只应调用预先限定的接口与参数，并处理超时、非 2xx 状态和数据格式异常。不要设计“Content Script 传入任意 URL，后台原样 fetch”的通用转发器，否则网页可能借扩展的权限访问本不应访问的资源。 浏览器权限也不能绕过服务端鉴权、限流或业务授权。插件包内的代码和常量可以被查看，服务端密钥不应打包进去。",{"id":4699,"title":3710,"titles":4700,"content":4701,"level":356},"\u002Ftutorials\u002Fbrowser-extension#远程代码csp-与-dom-注入",[5,3656],"做法 建议 在 Popup 内写 onclick 或内联脚本 改成本地 JS 文件与 addEventListener 下载 JS 后 eval() \u002F new Function() 执行 不采用；扩展逻辑随包发布 从接口读取 JSON 配置 可以设计，但校验结构和取值，不把数据当代码执行 把网页选中文本写入 innerHTML 优先 textContent，不要把页面输入当可信 HTML 把长期令牌放在 DOM 属性中 不采用；网页脚本可以读取共享 DOM MV3 对远程托管代码有严格限制。即使某种方式在开发环境暂时运行，也不代表满足商店政策。",{"id":4703,"title":3791,"titles":4704,"content":4705,"level":356},"\u002Ftutorials\u002Fbrowser-extension#web_accessible_resources-不是文件总清单",[5,3656],"Popup 直接引用扩展包里的本地脚本，不需要把它们全部声明为网页可访问资源。只有普通网页确实需要加载的扩展资源，才考虑 web_accessible_resources 并限制匹配范围。 例如 Content Script 往页面插入一张扩展自带图片时，用 chrome.runtime.getURL('images\u002Fbadge.png') 获取地址，并按需声明该图片的访问规则。不要简单公开整个扩展目录。 本章的坑： 插件可以接触用户浏览内容，所以“仅本地处理”“保存多久”“是否上传”必须与实现一致。阅读助手不需要收集浏览历史，也不应该为了调试而长期记录完整网页正文。",{"id":4707,"title":3814,"titles":4708,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#十从原生到-plasmo框架解决工程问题",[5],{"id":4710,"title":3818,"titles":4711,"content":4712,"level":356},"\u002Ftutorials\u002Fbrowser-extension#先理解原生再选择工具",[5,3814],"原生四文件版本已经可以工作。框架主要解决热更新、打包、TypeScript、UI 组件、资源处理和多浏览器构建，并不会取消 Manifest、隔离环境或权限限制。 方案 适合谁 需要承担什么 原生 HTML \u002F CSS \u002F JS 入门、小型工具、理解扩展机制 自己管理入口与打包 Plasmo 希望使用 React \u002F TypeScript 快速搭建扩展 UI 学习文件约定与构建产物 WXT 等扩展框架 希望比较 Vue 等 UI 技术与多浏览器流程 核对当前版本支持与迁移成本 Plasmo 官方文档突出 React 与 TypeScript 支持。不要因为自己使用 Vue，就假设所有 Plasmo 示例可以直接改成 .vue 文件运行；选型时先确认框架的当前能力。",{"id":4714,"title":3882,"titles":4715,"content":4716,"level":356},"\u002Ftutorials\u002Fbrowser-extension#plasmo-的基本开发流程",[5,3814],"下面是另建工程的可选路线，不要在前面的 reading-helper 文件夹里混用。需要 Node.js 和 pnpm；实际版本要求及脚手架提示以Plasmo 官方文档为准。 # 在你自己的项目目录下执行，创建一个新的扩展工程\npnpm create plasmo\n\n# 进入脚手架实际创建的目录，再运行开发构建\ncd your-extension\npnpm dev\n\n# 完成功能后生成生产包\npnpm build your-extension 是占位目录名，不是固定生成的名称。创建命令会下载并运行第三方脚手架，执行前确认包来源；教程本身不需要执行这些命令。 典型入口包括 popup.tsx、content.ts 和 background.ts，更复杂的 Content Script 也有目录式组织方式。框架把这些入口转换为清单和构建文件；修改目录结构时要对照对应版本文档。",{"id":4718,"title":3984,"titles":4719,"content":4720,"level":356},"\u002Ftutorials\u002Fbrowser-extension#加载产物不是加载源代码目录",[5,3814],"Plasmo 开发构建通常输出到 build\u002Fchrome-mv3-dev，生产构建通常输出到 build\u002Fchrome-mv3-prod，以实际终端输出和项目配置为准。 扩展管理页应加载产物中包含 manifest.json 的目录，不是只有 popup.tsx 和 package.json 的源码目录。生产发布前运行 pnpm build，不要把带开发连接与调试逻辑的包直接上传。 本章的坑： 框架热更新不保证已打开网页里的旧 Content Script 总能无缝替换。遇到异常仍然可以使用“重新加载扩展 → 刷新页面 → 重开 Popup”的排查基线。",{"id":4722,"title":4024,"titles":4723,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#十一发布与兼容能加载不代表能上架",[5],{"id":4725,"title":4027,"titles":4726,"content":4727,"level":356},"\u002Ftutorials\u002Fbrowser-extension#打包前检查什么",[5,4024],"对于原生版本，把 manifest.json 和它引用的文件放在 ZIP 根目录；不要多套一层 reading-helper\u002F。框架版本则打包生产产物，而不是 node_modules 与源码仓库。 检查项 为什么要做 递增 Manifest 的 version 新版本上传需要有效的版本标识 清理开发地址、日志与测试账号 防止开发配置进入正式包 所有声明的脚本、图标和页面存在 避免本地遗漏或路径大小写问题 权限对应可见功能 降低不必要的授权与审核疑问 准备商店图标、截图和说明 用户需要知道插件做什么 按实际数据行为填写隐私披露 收集、传输与保存行为必须一致 使用干净环境完成一次安装测试 不能依赖开发机遗留存储才能运行 Chrome Web Store 和 Microsoft Edge Add-ons 的账号、素材与审核规则分别维护，提交前查各自官方文档。不要把某一时期的费用、审核时长或图片要求当作永远固定的事实。",{"id":4729,"title":4120,"titles":4730,"content":4731,"level":356},"\u002Ftutorials\u002Fbrowser-extension#chromeedge-与-firefox",[5,4024],"Chrome 与 Edge 都基于 Chromium，大量 MV3 API 相近，但仍需要分别验证目标浏览器版本、商店规则和受限页面。 Firefox 的 WebExtensions 与 Chromium 有许多相似 API，但不能承诺同一个 Manifest 原样通用。尤其要检查后台声明、API 支持、权限提示，以及 browser.* \u002F chrome.* 与 Promise 行为。Polyfill 可以改善部分调用差异，不能补齐所有平台能力。",{"id":4733,"title":4135,"titles":4734,"content":4735,"level":356},"\u002Ftutorials\u002Fbrowser-extension#升级与卸载场景",[5,4024],"以后改变存储结构时，应为旧版本数据设计迁移与默认值，而不是直接假设字段存在。并行事件中的“读 → 改 → 写”也不具备数据库事务保证，不适合用来维护高频精确计数。 升级后保留设置、关闭浏览器后恢复偏好、用户撤销站点访问权限，都是正常使用场景。不要仅测试“开发者工具一直开着、所有权限已同意”的理想路径。 本章的坑： 本地加载成功只说明浏览器能识别当前包，不代表商店审核通过，也不代表隐私合规已经完成。",{"id":4737,"title":4150,"titles":4738,"content":313,"level":340},"\u002Ftutorials\u002Fbrowser-extension#十二排错速查与进阶练习",[5],{"id":4740,"title":4153,"titles":4741,"content":4742,"level":356},"\u002Ftutorials\u002Fbrowser-extension#按症状定位问题",[5,4150],"症状 优先检查 处理方式 插件无法加载 JSON、文件名、选中的目录 检查是否存在根目录 Manifest，无注释与尾逗号 Popup 按钮没反应 Popup 控制台、JS 路径、CSP 确认外部脚本加载成功，事件正确注册 Cannot access contents of url 页面类型、站点权限、用户是否调用扩展 换普通网页，核对 activeTab \u002F 站点授权 chrome.scripting 不可用 是否在扩展上下文、是否声明权限 不要在普通网页控制台直接运行 Popup 代码 找不到消息接收端 注入和监听是否完成 重载插件并刷新页面，再确认收件人 Extension context invalidated 页面脚本是否来自旧扩展版本 刷新目标页面 后台变量突然变初始值 Worker 是否被停止并重启 使用可恢复存储，不依赖全局状态 Content Script 读不到网页变量 默认隔离世界 优先基于 DOM 工作，不盲目切 MAIN world SPA 切页后功能不生效 是否发生真正文档加载 按需监听局部 DOM\u002F路由变化，避免全页轮询 生产包报远程脚本或 CSP 错误 CDN、内联脚本、开发构建产物 将代码随包构建，检查生产 CSP 主题保存了但网页没变 读取配置与应用样式是否接通 存储不会自动驱动网页 DOM 更新",{"id":4744,"title":4304,"titles":4745,"content":4746,"level":356},"\u002Ftutorials\u002Fbrowser-extension#如何测量而不是凭感觉说没问题",[5,4150],"注入次数： 在测试网页控制台检查 document.querySelectorAll('#reading-helper-style-v1').length。第一版反复点击时应在 0 与 1 之间切换，而不是越点越多。 恢复能力： 使用第二版，在 Popup 检查窗口执行 chrome.storage.local.set({ readingTheme: 'light' })。关闭检查窗口与 Popup，等待后台进入非活动状态后重新打开 Popup，再检查打印的主题；应该仍为 light。随后删除该键再试，应回退为 paper。 权限边界： 分别测试普通网页、受保护页面和跨源导航后的标签页。临时授权失效时，应允许用户重新调用扩展，而不是无限重试。 性能： 若加入 DOM 观察器，在网页 Performance 面板记录操作前后，观察长任务与回调次数。先证明观察范围、频率或处理量有问题，再引入节流；不要用一个固定频率全页扫描来“保证生效”。",{"id":4748,"title":4346,"titles":4749,"content":4750,"level":356},"\u002Ftutorials\u002Fbrowser-extension#三个循序渐进的练习",[5,4150],"把主题设置接入阅读开关。 让 Popup 保存 paper \u002F light，通过注入函数的 args 或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。 做一个只在指定网站出现的工具条。 使用静态 Content Script，确保 SPA 导航后仍可用，重复初始化不产生两个工具条，并能完整移除自己的 DOM 与监听器。 增加用户主动触发的本地摘录。 只保存用户明确选择的文字与允许保存的来源信息，不自动收集整页内容；限制条目大小，提供清空入口，并使用 textContent 展示摘录。 到这里，你应该能说清楚四个关键问题：入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。 这比记住一组 API 名称更重要，也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。",{"id":4752,"title":4388,"titles":4753,"content":4754,"level":356},"\u002Ftutorials\u002Fbrowser-extension#参考资料与继续阅读",[5,4150],"枫枫知道：浏览器插件开发课程：本文选题与核心模块的参考入口。 Chrome：Manifest 文件格式：清单字段与 MV3 配置。 Chrome：activeTab：用户调用与临时站点访问。 Chrome：Content Scripts：隔离环境、注入方式与脚本能力。 Chrome：消息通信：一次性消息、端口与安全注意事项。 Chrome：Service Worker 生命周期：后台停止、唤醒与版本差异。 Chrome：Storage API：存储区域、配额与访问规则。 MDN：runtime.onMessage：异步响应与跨浏览器兼容提示。 Plasmo 官方文档：工程化、文件约定和构建流程。 Chrome Web Store 发布指南：提交包、素材与审核流程。 Microsoft Edge 扩展发布文档：Edge 商店的发布流程。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",1793263560527]