• chrome V3插件入门到放弃,Plasmo不完全使用指南


    chrome V3插件入门到放弃,Plasmo不完全使用指南

    没有插件的浏览器是没有灵魂的。今天来近距离感受一下chrome的灵魂

    开始之前了解一下灵魂chrome插件的版本。

    Chrome 浏览器从88版本开始支持MV3啦(即Manifest Version 3),现在浏览器版本都100+了。而MV2(即Manifest Version 2)将会在2023年 退休 。所以今天要讲的就是MV3版本

    后续的文章中,因为我没有魔法,所以贴出来的文档地址都是国内可以访问的文档(有条件的同学可以直接看谷歌的原文档 https://developer.chrome.com/docs/extensions/mv3/

    版本变更的变动

    manifest.json 作为插件的配置清单最能体现相关的变动了 从manifest.json 参考文档 可以很清楚地看到配置升级其实主要加了2个 「action」和 「host_permissions」

    比较小的变动

    Host Permissions

    在V2中,有两种方法为你的api或任何主机获得权限,要么在 permissions 数组或 optional_permissions 数组。

    {
      "permissions": ["https://xxxx.com/*"]
    }
    
    • 1
    • 2
    • 3

    在V3中,所有主机权限现在都单独存在一个新数组中,该数组的键为 host_permissions。主机权限不再与其他权限一起添加。

    {
      "host_permissions": ["https://xxx.com/*"]
    }
    
    • 1
    • 2
    • 3
    Actions

    在V2中,分为 browser_actionpage_action

    • browser_action 更多是负责插件的icon的切换等操作。参考文档: API-browserAction
    • page_action 更多是针对某个页面进行地址栏的操作 参考文档:API-pageAction

    感兴趣的可以在MDN插件开发文档里面看一看。

    在V3中,都统一合并为 action 。参考文档:API-action

    content_security_policy 变动

    在V2的manifest.json 的 content_security_policy 配置是一个字符串类型。升级到 V3 后变成了一个对象类型。详细的变更看文档会更加清晰:content_security_policy 参考文档

    web_accessible_resources

    详细变更参考 web_accessible_resources

    // v2 写法
    {
    	"web_accessible_resources": ["images/my-image.png"]
    }
    
    • 1
    • 2
    • 3
    • 4
    // v3 写法
    {
      // …
      "web_accessible_resources": [
        {
          "resources": [ "test1.png", "test2.png" ],
          "matches": [ "https://web-accessible-resources-1.glitch.me/*" ]
        }, {
          "resources": [ "test3.png", "test4.png" ],
          "matches": [ "https://web-accessible-resources-2.glitch.me/*" ],
          "use_dynamic_url": true
        }
      ],
      // …
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    不在允许远程托管代码

    以前一些功能可以依赖于网络请求动态加载,V3 则不允许这样的操作了

    • 不再支持加载远程托管的代码主要出于两个原因:
      • 安全因素,远程代码总是有不安全因素存在
      • Chrome 在审核提交的插件时更可靠,更高效,不需要再去关注远程代码,只需要审核包内的代码即可。

    只能把以前通过链接加载的js下载到插件包中,改改资源引入就好~

    Promises

    V3 现在原生支持 Promise。许多常用 API 现在都支出,最终所有合适的 API 都会支持 Promise。

    如果使用 callback,就不会返回 Promise,优先执行 callback。

    较大的变动

    将 Background Scripts 改造成 Service Workers

    在V2中,Background是可以通过 persistent 配置来确保页面时候需要 持久化 。而且还能支持 .html

    "background": {
      "scripts": ["background-script.js"],
      "persistent": false
    }
    
    //  或
    "background": {
      "page": "background-page.html",
      "persistent": false
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    很多小技巧都依赖于 html 这特性,把数据挂载在 background 的 window 对象上进行数据中转

    V3 则是强制使用了 Service Workers,禁止了持久化。background只能使用js文件

    "background": { "scripts": ["background.js"] },
    
    • 1
    网络拦截,使用新的declarativeNetRequest来修改请求

    这个变动非常的大,在本文后面详细讲这一块的内容。而且 MDN 文档还没更新 declarativeNetRequest 相关的内容,等下要找个新文档来看

    弃用的API
    • chrome.extension.getExtensionTabs()
    • chrome.extension.getURL()
    • chrome.extension.lastError
    • chrome.extension.onRequest
    • chrome.extension.onRequestExternal
    • chrome.extension.sendRequest()
    • chrome.tabs.getAllInWindow()
    • chrome.tabs.getSelected()
    • chrome.tabs.onActiveChanged
    • chrome.tabs.onHighlightChanged
    • chrome.tabs.onSelectionChanged
    • chrome.tabs.sendRequest()
    • chrome.tabs.selected

    在查看 MDN 文档时会有相关的提示

    使用chrome官方文档时的提示

    查阅官方文档时,那些标签也能帮助到我们。
    Promise 标签:支持 Promise
    <=MV2 标签:该API仅在V2前支持
    >=MV3标签 :该API在V3后支持
    Deprecated 标签:已废弃的 API


    使用 Plasmo 开发

    都2022年了,或许每次开发一些新东西的时候你都会在想:

    • 我要用什么技术栈?Vue3/React?
    • 开发插件,能不能用npm上的包啊,如果能的话我是不是还得配webpack或者vite?
    • webpack的话,我是不是得配置多个入口和出口才能满足插件的入口要求

    好麻烦啊,上github找找有没有现成的。
    好像都还可以,收藏吃灰,下次在开发把

    不要下次了!就这次把,强烈推荐 Plasmo

    官网: plasmo
    github: PlasmoHQ/plasmo

    官方自己的介绍(说的非常的朴素,我一个路人都觉得这功能写少了)

    作为一个过来人的感受,我只能说用 Plasmo 很舒服~

    Plasmo 上手体验

    想体验先安装Plasom,快速上手文档: getting-started

    注意下自己的 pnpm 版本或者 npm 版本,我用的是pnpm

    # 使用下面的命令进行项目初始化
    
    pnpm dlx plasmo init
    # OR npm v7
    npm x plasmo init
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这时候如果你是新手,建议直接从 PlasmoHQ/examples 找一个模版看一下他的目录结构,然后找到自己想要的功能进行开发

    比如我想基于vue,开发一个 popup 的界面。可以在示例中直接找到 examples/with-vue

    还有各种技术栈(React,svelte,tailwindcss,nextjs…)

    不止 popup 页,还有 background, devtool, options 页面都能在 examples 仓库找到相关的模版

    Plasmo 有一个很方便的地方在于:我开发 popup 的页面,我只需要有一个叫 popup.(tsx | vue) 的文件,开发background,只需要有一个 background.ts 文件。

    这些作为对应的入口文件我们只需要按命名规范写好(甚至可以写成 popup/index.vue ),剩下的 manifest.json 配置就交给 Plasmo

    从安装脚手架到现在,我们都没见到 manifest.json 文件,更加说明了这些入口不需要我们显示声明

    Plasmo 修改 manifest.json 配置

    虽然没有 manifest.json ,但是该要写的配置还是得写的

    比如我们开发一个针对 http://xxxx.com 网页的插件,首先得申请权限 host_permissions

    这部分配置写在了 package.json 中的 "manifest" 下。包括申请权限,注入资源都在 "manifest" 中去配置。

    // package.json
    {
    	// ...
    	"manifest": {
    		"permissions":["declarativeNetRequest"], // 获取拦截网络请求的权限
    
    		// 页面注入静态资源
    		"web_accessible_resources": [
    			{
    				"resources": [
    					"inject.js"
    				],
    				// 针对全部界面注入
    				"matches": [
    					""
    				]
    			}
    		],
    
    		// 针对哪些页面生效
    		"host_permissions": [
    			"https://xxxx.com/*",
    			"http://xxxx.com/*"
    		]
    	}
    	// ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    有个例外就是 content.ts (注入到网页的那部分内容)

    因为 content.ts 对应的配置是 MDN文档:manifest.json/content_scripts

    正常的配置应该是这样的

    "content_scripts": [
      {
        "matches": ["*://*.mozilla.org/*"],
        "js": ["content.js"]
      }
    ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    因为 content.ts 是动态入口,也就是说 content_scripts[0].js 的内容是框架去生成的,而不是我们自己手动填的

    这也就造成了 content_scripts 的配置只能是写在 content.ts 这个页面中。这样 Plasmo 才能既知道入口路径,也知道对应的配置

    以下示例代码来自: with-content-script/content.ts

    // file - content.ts
    
    import type { PlasmoContentScript } from "plasmo"
    
    // 进行 content_scripts 的配置
    export const config: PlasmoContentScript = {
      matches: ["https://www.plasmo.com/*"]
    }
    
    window.addEventListener("load", () => {
      console.log("content script loaded")
    
      document.body.style.background = "pink"
    })
    
    // 运行后出来的配置可能就是
    // "content_scripts": [
    //   {
    //     "matches": ["https://www.plasmo.com/*"],
    //     "js": ["content.[hash].js"]
    //   }
    // ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 可能又有同学有疑问,就是这样的配置写完,那我岂不是只能写一个 content.ts ? 如果我想一个插件针对不用的站点做不同的操作呢?

    好问题,去example找找模版就知道了 example/with-many-content-scripts。这里提供了多个 content.ts 的示例,这样就能针对不同页面注入不同的 content.ts

    框架也提供了 自定义 manifest.json 的能力。更多的配置可以看 官方文档: plasmo customization 这部分

    Plasmo 提供的一些库和功能

    1. @plasmohq/storage

    参考文档 https://docs.plasmo.com/framework-api/storage

    @plasmohq/storage 是一个来自 plasmo 的实用程序库,它抽象了浏览器扩展可用的持久存储 API。当扩展存储 API 不可用时,它会回退到本地存储,允许在弹出窗口 - 选项 - 内容 - 背景之间进行状态同步。

    官网还说了一句,如果使用了这个库,配置会自动把 storage 的权限加上

    我觉得还是挺好的,这样依赖抹平了不同平台之间存储的差异,也做了保底方案


    1. 一些比较特殊的标记符 url: data-text:~ data-base64:~

    这部分标记符可以在这些文档中找到 content-scriptscontent-scripts-uiassets

    用起来就类似这样的:

    import cssText from "data-text:~/contents/plasmo-overlay.css"
    
    import someCoolImage from "data-base64:~assets/some-cool-image.png"
    
    import myJavascriptFile from "url:./path/to/my/file/something.js"
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这部分更多的可能是为了相对路径,或者引入一些特殊的内容。比如 data-text:~ 这个就很有用,我可以在 .css 文件中更好的编写我的内容,然后通过 data-text:~ 把文件的内容以 text 引入,用于我注入到页面上

    url: 这个也是为了获取这个文件在打包后所处的位置。

    比方说我们按正常模式写文件,写完后可能要给 content.js 动态注入到页面去,这时候可以动态创建script标签,
    src = chrome.runtime.getURL('xxx.js')

    不过因为我们这个是进过了 Plasmo 打包的,有可能对应的资源被加上了hash值,这时候 url: 就是获取文件的路径了(类似 chrome.runtime.getURL(‘xxx.js’) 的功能了)

    在示例仓库 examples/with-devtools/devtools.tsx 就有这么一段代码:

    import fontPickerHTML from "url:./panels/font-picker/index.html"
    import fontPropertiesHTML from "url:./panels/font-properties/index.html"
    
    chrome.devtools.panels.create(
      "Font Picker",
      null,
      // See: https://github.com/PlasmoHQ/plasmo/issues/106#issuecomment-1188539625
      fontPickerHTML.split("/").pop()
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    可以自己打印一下 fontPropertiesHTML 变量,其实是一个网页的路径。(使用.split(“/”)是为了处理一个bug,issuse链接也在备注里了,可以看看了解了解)

    文档链接在都贴出来了,更多的用法就自己去摸索了

    插件运行和打包

    不管是运行 npm run dev 还是 run build,都会生成一个 build/xxxx 目录。里面就是存放着可以运行的chrome插件代码

    默认是 chrome-mv3-dev 代表开发 chrome 插件,v3 版本,dev环境

    当然你也可以用 --target 指定是开发 firefox 版本/开发 mv2版本, –target-flag 毕竟都不推荐开发 mv2 的东西了。就不细说了

    运行 npm run dev 后,把 build/chrome-mv3-dev 这个文件夹拖到浏览器安装插件的位置,就能看到了。不知道怎么操作的建议看下文档: loading-the-extension

    build/chrome-mv3-dev 目录下也有 manifest.json 文件,也就是我们在 package.json 里面 + content.ts 的配置,所有的配置都汇总在这里了。想看配置有没有生效看这里就行


    插件打包,打包为zip

    pnpm build -- --zip
    # OR
    npm run build -- --zip
    # OR
    plasmo build --zip
    
    打包到firefox
    plasmo build --target=firefox-mv2 --zip
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    插件在多平台的兼容性问题

    build --target=firefox 的作用体现在哪里?

    说实话我也没发现,可能是为了多区分开一个目录,或者 firefox 没升级到 mv3 版本,又或者是同样的配置 firefox 有细微差别,Plasmo就可以自动处理掉

    至于代码兼容性

    在开发过程中,我们都是用 chrome 作为插件API,比如 chrome.runtime.sendMessage

    • chrome 这个标识各大平台也都识别,比如 360浏览器,包括火狐也兼容 chrome.xxx.xxx 。正常来说不用特别的适配,写的话也按 chrome 来写即可

    开头也提到,我因为不能看外网的chrome插件开发文档,好在国内还可以访问 MDN ,API这一块同步的还是比较快的,甚至有些页面有中文翻译了,平时查API可以到这里查

    • MDN 的文档的API是用的 browser 开头,兼容性可以看对应文档下面的表格。(如果你用browser,在开发过程是没有智能提示的,毕竟我们装的ts包是 @types/chrome)。

    也会真的发生有兼容性问题,毕竟chrome更新一直都很快的

    • 用尤大开发的 vue-devtool 插件来看看尤大都是如何处理兼容性问题的(虽然尤大用的不是 Plasmo,不过不影响我们学习代码)

    判断运行环境: /packages/shared-utils/src/env.ts

    // env.ts 节选代码
    export const isBrowser = typeof navigator !== 'undefined'
    export const target: any = isBrowser
      ? window
      : typeof global !== 'undefined'
        ? global
        : {}
    export const isChrome = typeof target.chrome !== 'undefined' && !!target.chrome.devtools
    export const isFirefox = isBrowser && navigator.userAgent.indexOf('Firefox') > -1
    export const isWindows = isBrowser && navigator.platform.indexOf('Win') === 0
    export const isMac = isBrowser && navigator.platform === 'MacIntel'
    export const isLinux = isBrowser && navigator.platform.indexOf('Linux') === 0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    根据环境,使用不同的处理方式,比如网页快照

    /packages/app-frontend/src/features/timeline/composable/screenshot.ts

    在chrome中,能直接使用 chrome.tabs.captureVisibleTab

    当然还有随处可见的这样的判断

    • 如果一定要说框架有帮我们处理什么兼容问题,那可能就是本地存储了

    提供了 @plasmohq/storage 抹平各个平台的存储api差异,还提供了快捷的方式然我们更新本地存储的内容

    处理兼容性问题从来都不是一件容易的事情,搞不好开发人员都处理的很头大,所以更加别指望框架能自动处理。

    总的看下来 --target 好像更多是用于发布到不同平台的时候有用,而不是帮我们处理不同浏览器的兼容问题(我的插件不发布到商店去,所以暂时找不到用途)

    Plasmo 的介绍就到这里了。我也没开发什么出名的插件(很惭愧)都是处理公司需要的内容,所以可能还有很多好玩的功能没发掘到

    Plasmo 还能一键发布到各个平台之类的功能,等着你们自己去探索了


    如何应对 MV3 版本中“较大的变动”

    一开始介绍的时候有提到版本变动有较小的,还有2个较大的。在我看来较小的变动可能只是改一下配置,不用影响太多业务逻辑代码就能运行的。

    而较大的变动就影响挺大的

    background 变动的影响

    说一个场景,比如我们都很熟悉的浏览器拦截插件,或者其他的插件,下面都有角标。关键是这些角标是根据当前的域名记录的。

    怎么做到的呢?依赖 popup 的页面记录吗?
    popup 几乎不可能,因为在我开发过程中,popup 在每次打开的时候其实都会重新运行一遍。同一个站点如果打开2次popup.tsx对应的组件就会在执行2次

    所以这部分的数据就得留给 background.ts 或者 content.ts 去做

    为了搞懂这其中的技巧,我看了一下 猫抓 这个插件的代码

    以下代码节选自 猫抓 插件

    // js/popup.js
    var BG = chrome.extension.getBackgroundPage();
    var tabid;
    chrome.windows.getCurrent(function(wnd) {
        chrome.tabs.getSelected(wnd.id, function(tab) {
            tabid = tab.id;
            var id = "tabid" + tab.id;
            ShowMedia(BG.mediaurls[id]);
        });
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    // js/background.js
    
    //初始化
    if (typeof mediaurls === 'undefined') {
        var mediaurls = new Array();
    }
    
    // ...
    // 中间的代码用了 chrome.webRequest.onResponseStarted 监听请求
    // 然后筛选出 .m3u8 和 分析出对应的 .ts 文件,感兴趣的自己在看看
    // ...
    
    //标签更新,清除该标签之前记录
    chrome.tabs.onUpdated.addListener(function(tabId, changeInfo) {
        if (changeInfo.status == "loading") //在载入之前清除之前记录
        {
            var id = "tabid" + tabId; //记录当前请求所属标签的id
            if (mediaurls[id])
                mediaurls[id] = [];
        }
    });
    
    //标签关闭,清除该标签之前记录
    chrome.tabs.onRemoved.addListener(function(tabId) {
        var id = "tabid" + tabId; //记录当前请求所属标签的id
        if (mediaurls[id])
            delete mediaurls[id];
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    可以看到,在 popup.js 里面获取了一个BG

    因为 MV2的background是有window对象的。所以 BG 可以理解为 background.html 的 window对象 var BG = chrome.extension.getBackgroundPage();

    从 window 对象中获取 mediaurls 参数,获取对应tab要显示的角标数,然后给到 popup 显示

    如果 background 变成了一个 Service Worker ,那就不存在 window 对象了

    解决方案就是改用通信的方式,popup发起一个sendMessage。background来监听,并且进行回调给popup
    整体的思路还是用 background 来存储和转发消息,background 收到的内容后存储到本地去。

    【非常严重】网络请求拦截的变动 declarativeNetRequest

    只能感叹一句,V2 版本的拦截请求还是很好用的。

    推荐一篇教程: 小茗同学: 【干货】Chrome插件(扩展)开发全攻略 这里面的的攻略很多都没过时,除了上面说的改动其他都很值得参考学习。我也是看这个入的门

    可以看到教程的 8.6. webRequest 看看v2版本的拦截网络请求写法

    之前是通过声明 webRequestwebRequestBlocking 等权限来进行网络请求的拦截。不过现在声明了也没用了 issues/1163

    都要改为 declarativeNetRequest 来拦截

    虽然新版的API也能拦截请求,修改head头之类的操作
    但是,这些操作都没有回调!!(V2版本是有回调的,猫抓就是基于回调才抓的请求地址)

    不过进过一通瞎找,找到另外一个文档(MDN还没更新 declarativeNetRequest 的内容)

    onRuleMatchedDebug

    这个方法有这么一段话

    Fired when a rule is matched with a request. Only available for unpacked extensions with the declarativeNetRequestFeedback permission as this is intended to be used for debugging purposes only.

    当规则与请求匹配时触发。 仅适用于具有 declarativeNetRequestFeedback 权限的解压扩展,因为这仅用于调试目的。

    注意是 解压扩展,意思就是必须是解压的包/zip包,并且声明了这个权限才能用。
    如果你的插件是想发布到应用市场,或者生成 .crx 后缀的插件包,一样是用不了 onRuleMatchedDebug 滴(累了)


    虽然background不能直接监听返回的内容,不过 devtool 面板可以啊 devtools/network。但是如果你想用devtool面板的API话,你得打开F12才能用 (累了*2)

    所以目前拦截回调的这一块还没有想出非常通用的方案,或许这就是chrome口中的安全,隐私…

    如果想粗暴点解决的话其实可以把要拦截的源文件下载下来,然后手动添加一个 window.postMessage(xxx) 主动给 content.ts 发消息,然后 content.ts 在转发到后台去
    background 部分就拦截网络请求,redirect 到插件下载的源文件那边去(其实就是针对性很强针对某个网页的某个js可以这么搞)

    如果是想篡改某些js的内容,而且自己会本地开一个服务的话,用 redirect 真的是很方便的

    既然都讲到拦截了,顺便讲讲 如何拦截网页发出的请求。原理就是用 content.js 注入js,修改 window.XMLHttpRequestwindow.fetch 方法就能拦截到了

    推荐直接学习: YGYOOO/ajax-interceptor 这里面的代码

    唯一的问题可能就是 content.js 注入的速度没有页面发起请求的快,就会有几条漏网之鱼。

    最后

    这篇文章主要还是想介绍下 Plasmo。个人感觉用下来还是挺好用的

    至于chrome 插件要升级到 MV3 最严重的其实还是网络请求相关的。其余的应该都还好(最起码有解决方案)

    讲了那么多其实没有讲到一些开发的技巧类东西,主要是一些需要注意的坑。所以汇总一下链接方便查找学习

    文档类

    入门教程推荐

    • 小茗同学 【干货】Chrome插件(扩展)开发全攻略 (http://blog.haoji.me/chrome-plugin-develop.html)[http://blog.haoji.me/chrome-plugin-develop.html]

    插个题外话,如果你既没有魔法,又想看原汁原味文档(挺好的,很有追求)

    可以上 github https://github.com/GoogleChrome/developer.chrome.com

    把整个 developer.chrome.com 搞下来(下面的命令不用我细说了把)

    # 安装依赖的
    npm run ci
    
    # dev 后 打开 http://localhost:8080/ 就可以看到
    npm run dev
    
    # 如果你想同步一份到自己服务器,就运行把
    npm run production && npm start
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    插件开发介绍就到这了,如果你有好的插件记得也推荐给我

  • 相关阅读:
    docker学习笔记(3)- 镜像
    MATLAB 多信号显示方案
    【JavaWeb】jsp
    day02论文学习:能够使大语言模型产生带有引用的文章
    C专家编程 第8章 为什么程序员无法分清万圣节和圣诞节 8.9 如何进行强制类型转换,为何要进行类型强制转换
    Android向服务器的数据库MySQL传输数据:经过修正的 Android + HTTP + xampp +mysql : Post / Get
    c++ 学习 之 静态存储区域 和常量字符串的联系
    Ubuntu 升级cuda版本与切换
    kubernetes之服务发现
    中国高级测试经理对敏捷测试的理解
  • 原文地址:https://blog.csdn.net/Jioho_chen/article/details/126672461