上一主题 下一主题
ScriptCat,新一代的脚本管理器脚本站,与全世界分享你的用户脚本油猴脚本开发指南教程目录
12下一页
返回列表 发新帖

[作废] 给VanillaJS的油猴脚本添加jsx语法糖支持

[复制链接]
  • TA的每日心情

    昨天 16:31
  • 签到天数: 309 天

    [LV.8]以坛为家I

    12

    主题

    63

    回帖

    642

    积分

    荣誉开发者

    积分
    642

    荣誉开发者生态建设者

    发表于 2022-10-22 23:05:40 | 显示全部楼层 | 阅读模式

    本帖最后由 朱焱伟 于 2022-10-24 21:20 编辑

    给VanillaJS的油猴脚本添加jsx语法糖支持

    更新:作废原因

    说明一下作废原因,搬运一下评论区老哥的分析,这总结的很好,正是我想说的:

    1.我之前认为 require 会让这个脚本在每次运行之前先发起网络请求获取cdn代码
    2.因此我不想通过require引入库,但是又要保持 build.user.js 的大小尽量小
    3.所以要自己替换 jsx h 函数,所以才有这个帖子。
    现在我的第一个认知前提已经被纠正了,后面的自然没有存在的必要了

    这个帖子配置过程可能没有什么太大的错误,但是这个探索目的是寻求避免require请求的方法,原来是我理解错误,require只需要安装时请求一次保存本地,并不是每次要请求。这样我要解决的问题本来就不存在,因此作废。

    本文的核心就是自定义jsx的h函数,达到减少build.user.js的大小。如果是想让油猴脚本支持jsx,加个webpack-loader或者rollup-plugin就行,不必这样搞。

    这个帖子的评论纠正了我对require错误的认识,也是很好的事。

    ----------------------------------------分割线-------------------------------------------


    普通的油猴脚本,在不使用React等框架的情况下,如何支持jsx语法糖?

    问题

    基于vite和rollup的两种油猴工程化方案的帖子里,留下两个问题:

    • 配置create-tampermonkey生成的工程,使之支持jsx语法糖
    • 配置vite-plugin-monkey生成的工程,使之支持sourcemap

    很荣幸,帖子得到monkey插件的作者留言,插件从v2.8.0起,已支持为 build.user.js 生成正确的 sourcemap 映射。第二个问题得到解决。

    之前讨论的油猴脚本工程化解决方案中,个人观点:

    vite-plugin-monkey > create-tampermonkey > 火猴模板 > 暴力猴模板

    油猴工程化,大部分情况应该使用monkey插件。

    还剩第一个问题,本帖将试图解决。先说明配置方法(手写函数或使用vhtml);再讨论使用场景,描述摸索过程;最后再实现LOGO替换的简单Demo。

    <壹> 配置篇

    这里先按照印度老哥Kartik Nair的方法,给create-tampermonkey生成的工程加上jsx语法糖支持,然后再顺便加上对css的sourcemap支持。

    jsx without React

    在google上搜索jsx without React,第一条结果就是medium上的how-to-use-jsx-without-react
    medium链接不能打开,可以看作者Kartik Nair博客上的jsx-without-react,内容一样,这个能打开。

    读完此文,可以相应修改curlyのcreate-tampermonkey初始化后的工程,直接来吧!

    step1:初始化项目

    使用create-tampermonkey插件初始化

    npx create-tampermonkey helloworld
    cd helloworld
    pnpm install

    为简单起见,未添加typescript和linter支持

    step2:添加babel下支持jsx的插件

    pnpm install -D @babel/plugin-transform-react-jsx
    pnpm install -D @babel/plugin-syntax-jsx

    step3:修改rollup对babel插件的配置

    工程目录下rollup_configs/default.js

      plugins: [
        babel({
          babelHelpers: 'bundled',
          exclude: 'node_modules/**',
          extensions,
          plugins: [
            ["@babel/plugin-transform-react-jsx", {
              // "runtime": "automatic"
            }],
            ["@babel/plugin-syntax-jsx",{
              "pragma": "createElement", // React.createElement
              "pragmaFrag": "createFragment", // React.Fragment
            }]
          ],
        }),
        commonjs(),
        nodeResolve({
          extensions
        }),

    来替换默认的

    // 请把我替换掉
      plugins: [
        commonjs(),
        nodeResolve({
          extensions
        }),
        babel({
          babelHelpers: 'bundled',
          exclude: 'node_modules/**',
          extensions
        }),

    这里关键是要在rollup设置里把babel的配置放在commonjs之前。

    step4:复制内容到main.js

    codesandbo上jsx-in-the-browser/main.js里的代码复制到我们的main.js里。注意有个错误,作者在第二行多打了一个星号,要把这多出来的星号删除。第二行变成:

    /** @jsxFrag createFragment */

    此时再pnpm run build,编译成功,已经可以编译类似如下的语句。

    document.getElementById("root").appendChild(<UsingFragment name="foo" />);

    至此,添加jsx语法糖支持已经成功,且编译结果是纯VanillaJS,不需要require像React这样的库。END撒花

    sourcemap

    create-tampermonkey模板共有三处设置sourcemap的地方,rollup.config.js文件中devConfigs里的sourcemap已经默认开启,prodConfigs没开,这样build出来的user.js不带sourcemap; rollup.config.js里还会读取rollup_config/default.js里的配置,在default.js里可以设置开启postcss插件的sourceMap。

    rollup_config的default.js里设置postcss的插件

        postcss({
          plugins: [autoprefixer()],
          writeDefinitions: false,
          sourceMap:"inline"
        })

    注意,postcss这里的sourceMap的M是大写。这样在F12>Sources>被注入的目标网站的文件夹可以看到写的css代码(js在脚本管理器文件夹看)。

    rollup.config.js里设置prodConfigs

    除了devConfigs还有prodConfigs里的output也可以设置sourcemap:'inline'

    <貳> vhtml篇

    以上为按照印度老哥的方法设置油猴脚本添加jsx语法糖的过程,顺便也设置了一下sourcemap。

    stackoverflow上,也有类似的讨论:can-i-use-jsx-without-react-to-inline-html-in-script

    从中可知,实现jsx without React也有很多方法。这里就另外尝试一下preact之父搞的vhtml

    在按照上文配置篇的设置方法操作后,从印度老哥的方法切换到使用vhtml的方法。操作并不复杂,两步就能改为由vhtml支持jsx语法糖。

    step1:安装vhtml

    这里默认已按照配置篇设置了环境,这是前提。

    pnpm install -D vhtml

    step2:替换main.js

    把main.js里面换成vhtml中usage下的代码段,然后就能pnpm run build成功。
    引入h函数,并且通过注解告诉babel用h函数去替换。(下面省略了测试部分)

    // import the library:
    import h from 'vhtml';
    
    // tell babel to transpile JSX to h() calls:
    /** @jsx h */

    <叁> 痛点篇

    油猴脚本在写复杂一点的界面时,通常有两种做法:一是反引号包裹大段html字符串,二是引入框架,用webpack之类工具打包。

    方法一的问题是:反引号内的大段html写起来没有语法高亮和代码补全,也比较难维护和复用。不知道油猴脚本中写这种大段html字符串时,是不是有辅助的插件什么的,反正我感觉写起来比较困难。

    方法二引入框架,每次访问目标网站要对require的库发起请求。大型脚本这样做没问题,但如果ui不太复杂,有点得不偿失。(表述有点问题,大概指访问目标网站要访问相应的cdn)

    比如,在火猴LOGO替换的Demo里,引入React库会使性能下降,不能达到原生JS的速度,而做的仅仅是替换一两个元素,偷鸡不成蚀把米属于是。

    换用solidjs,svelte也是一种可行的办法,它们和Vue,React本质区别在于,他们是编译器而不是框架,直接体现就在于,它们编译出来的user.js不需要额外的require。我觉得是比较好的思路,但试了下,加个按钮这类操作编译出来的user.js也差不多几百行。

    对暴力猴模板的摸索让我了解到,设置一下babel的pragma和pragmaFrag,自己搞两个函数来替代React.createElement和React.Fragment,就能添加jsx语法糖支持。本帖对此进行了探索,设置后,编译出来的user.js比较短,还属于普通的VanillaJS,不需要额外require,性能上和反引号写html字符串没有太大差别。

    所以:大中型脚本使用vite插件;功能不太复杂的写点小界面的脚本使用这种jsx without react的方法,我觉得是比较好的。

    <肆> 解释篇

    h函数

    暴力猴脚手架,会将jsx语法,翻译成这样的语句:document.body.append(VM.m(VM.h("div", null, "hello, world")));,这是通过在babel里设置pragma: 'VM.h'实现的,h函数比较常见,就是上文粘贴的手写createElement函数,和React里的React.createElement。vue之父在What does h mean?里这样解释h函数:

    It comes from the term "hyperscript", which is commonly used in many virtual-dom implementations. "Hyperscript" itself stands for "script that generates HTML structures" because HTML is the acronym for "hyper-text markup language".

    即:createElement函数是用来生成 HTML DOM 元素的,也就是上文中的 generate HTML structures,也就是 Hyperscript,因此把 createElement 简写成 h。

    <伍> 坑人篇

    主要有两个坑:一是create-tampermonkey默认生成的工程里,rollup插件的顺序有问题;二是印度老哥的代码里多打了一个星号。

    坑1:rollup插件的先后顺序

    配置rollup_configs/default.js中最关键的一点是:插件中,babel要写在前面,commonjs写在后面。默认生成的工程中,这两个是反的,这直接导致我无论怎么配置babel,都会报语法错误。我不懂如何配置rollup,之前想依葫芦画瓢把暴力猴的jsx支持加进来,未遂,就卡在这里,看到CSDN上rollup.js 打包错误原因:plugin有顺序,才发现问题。

    具体rollup插件执行的先后顺序优先级有解释:

    请注意,大多数情况下@rollup/plugin-commonjs应该在其他插件转化你的模块之前进行,这是为了防止其他插件的更改导致对 CommonJS 的检测被破坏。这个规则的一个例外是 Babel 插件,如果你正在使用它,那么把它放在 commonjs 插件之前。

    坑2:@jsx注解前面只能有两个星号

    配置篇直接复制codesandbox上的代码到main.js,若不作修改,pnpm run build同样可以编译成功(若不自宫,也能成功);但编译出来的user.js运行时会报React.Fragment未定义的错误。

    这是因为@jsxFrag前面错误地打了三个星号,导致babel-plugin-transform-react-jsx插件不知道要用手写的createFragment函数去处理Fragment,默认编译成调用React.Fragment,运行时在没有引入React的情况下是undefined。正确的写法要移除一个*:

    /** @jsxFrag createFragment */

    <陆> 摸索篇

    Error01.Unexpected token

    开始时模板默认的babel和commonjs的配置顺序错误,写jsx会报语法错

    [!] (plugin commonjs) SyntaxError: Unexpected token (33:4) in main.js

    Error02.jsx is not currently enabled

    在default.js里把babel配置调到commonjs之前,会出现另外的报错:

    [!] (plugin babel) SyntaxError: main.js: Support for the experimental syntax 'jsx' isn't currently enabled:

    Error03.automatic runtime

    在babel的plugin里加上@babel/plugin-transform-react-jsx,并安装该插件,尝试这样配置babel:

        babel({
          babelHelpers: 'bundled',
          exclude: 'node_modules/**',
          extensions,
          plugins: [
            ["@babel/plugin-transform-react-jsx", {
              "runtime": "automatic"
            }]
          ],
        }),

    报错变为:

    [!] (plugin babel) SyntaxError: main.js: pragma and pragmaFrag cannot be set when runtime is automatic.

    于是注释掉automatic那行再配,原因可见:pragma 和 pragmaFrag, automatic runtime
    至此,编译已不报错。

    Error04.运行时缺少React.Fragment

    但是,在运行编译出来的user.js时,发现在目标网站会报React.Fragment这个undefined。
    检查编译出来的语句:

    const UsingFragment = () => createElement("div", null, createElement("p", null, "This is regular paragraph"), createElement("div", null, createElement("p", null, "This is a paragraph in a fragment"), createElement(React.Fragment, null, createElement("p", null, "Hello")), createElement("ul", null, [1, 2, 3].map(item => createElement("li", null, item)))));

    发现含React.Fragment,这是@jsxFrag注解前面多打一个星号导致的,删去即可移除该依赖,React.Fragment被手写的createFragment函数代替。
    至此,运行时也不报错,编译产物是VanillaJS,不需要require React库。

    <柒> 讨论篇

    上文仅说明了对create-tampermonkey插件生成的VanillaJS工程的jsx语法支持,没有配typescript的。jsx without React应该还有其他做法,这里猜测一下。

    脚本猫注入

    看到jsx-in-the-browser@codesandbox里两行:

    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <script type="text/babel" src="/main.js"></script>

    估计如果搞歪门邪道的话,让脚本猫内置babel,给目标网站先注入babel.min.js,然后注入user.js时把type设为"text/babel",就能直接在用户脚本里写jsx。当然,效率是另一回事,这里仅猜测一下可能性。

    monkey插件

    create-tampermonkey打包干净,monkey也接近。使用体验上,还是monkey插件更好。下次再试试在monkey插件生成的VanillaJS工程里配置jsx,应该也差不多。

    其他库

    这里的库,指的还是编译user.js时的库,不是指最后要require的。stackoverflow上有相关讨论:can-i-use-jsx-without-react-to-inline-html-in-script@stackoverflow

    从中可知,还有不少其他方法,本文仅试了其中preact之父的vhtml。关于其他方法的可能,引用一段论述过来:

    You should also look at libraries which do exactly this such as nervjs, jsx-render and deku. All of these use a JSX html syntax without react. Some (such as jsx-render) are only focused on converting JSX to the final JS, which might be what you're looking for.

    比如凹♀凸♂工作室的nervjs为某东首页加载提供速度支持,体积比react小。不过本文讨论的是VanillaJS,希望语法糖转换都在编译user.js时完成,运行时不要require任何库。

    <捌> Demo篇

    虽然某hub的LOGO已经被玩烂了,但不妨再搞两个Demo,这样和原版原生JS可以有速度的比较。

    猫咪嘤文网LOGO替换

    这是在配置篇完成后,接着做的Demo。是对印度老哥手写两个函数去替换React.createElement及React.Fragment的方法的演示。
    猫咪嘤文网.png

    大黄汪文网LOGO替换vhtml

    这是在vhtml篇完成后,接着做的demo。是对vhtml库(非运行时require,仅编译期使用)方法的演示。
    大黄汪文网.png

    比较

    本文的两个Demo,都比之前的火猴LOGO替换好很多,因为这次不需要require了,而且创建的是真实DOM。因为编译出来的就是VanillaJS,所以和原版LOGO替换在速度上没有大的差异。本帖的使用场景主要就是小脚本里用点jsx特性,这样有较好的语法高亮和补全支持;看vhtml的描述,也可以写简单的Components。当然,如果开发较正式的脚本,还是使用vite脚手架。

    <玖> 源码篇

    配置好的工程

    以上完整工程tmvanillaJSX.7z的百度网盘秒传链接(里面两个branch分别对应两个Demo):

    5295753fed94f1454b561559d5f90945#5295753fed94f1454b561559d5f90945#183227#tmvanillaJSX.7z

    除了工程中的配置,Demo运行时,src中的main.js可以从F12的Sources看到。

    印度佬源码

    为方便查阅,把印度老哥在codesandbox的主要源码贴出来:

    /** @jsx createElement */
    /** @jsxFrag createFragment */
    
    const createElement = (tag, props, ...children) => {
      if (typeof tag === "function") return tag(props, ...children);
      const element = document.createElement(tag);
    
      Object.entries(props || {}).forEach(([name, value]) => {
        if (name.startsWith("on") && name.toLowerCase() in window)
          element.addEventListener(name.toLowerCase().substr(2), value);
        else element.setAttribute(name, value.toString());
      });
    
      children.forEach((child) => {
        appendChild(element, child);
      });
    
      return element;
    };
    
    const appendChild = (parent, child) => {
      if (Array.isArray(child))
        child.forEach((nestedChild) => appendChild(parent, nestedChild));
      else
        parent.appendChild(child.nodeType ? child : document.createTextNode(child));
    };
    
    const createFragment = (props, ...children) => {
      return children;
    };

    相关资料

    已有1人评分好评 油猫币 贡献 理由
    王一之 + 1 + 4 + 1 哥哥整理得好全

    查看全部评分 总评分:好评 +1  油猫币 +4  贡献 +1 

    当冥想的日子飞逝,喧嚣的日子把我们唤去,且在此地留下些微的痕迹
  • TA的每日心情
    开心
    8 小时前
  • 签到天数: 213 天

    [LV.7]常住居民III

    305

    主题

    4189

    回帖

    4056

    积分

    管理员

    积分
    4056

    管理员荣誉开发者油中2周年生态建设者喜迎中秋油中3周年挑战者 lv2

    发表于 2022-10-22 23:51:36 | 显示全部楼层
    图片怎么挂了,我前一次看还有的
    上不慕古,下不肖俗。为疏为懒,不敢为狂。为拙为愚,不敢为恶。
    回复

    使用道具 举报

  • TA的每日心情

    昨天 16:31
  • 签到天数: 309 天

    [LV.8]以坛为家I

    12

    主题

    63

    回帖

    642

    积分

    荣誉开发者

    积分
    642

    荣誉开发者生态建设者

    发表于 2022-10-23 00:05:13 | 显示全部楼层
    王一之 发表于 2022-10-22 23:51
    图片怎么挂了,我前一次看还有的

    第一次上传多了一张图片,虽然在markdown编辑器删除了,但还是附在文末,我返回普通编辑器删去的,markdown又乱了,重新粘贴的。
    当冥想的日子飞逝,喧嚣的日子把我们唤去,且在此地留下些微的痕迹
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    60

    回帖

    89

    积分

    初级工程师

    积分
    89
    发表于 2022-10-23 22:35:59 | 显示全部楼层
    > 方法二引入框架,每次访问目标网站要对require的库发起请求。大型脚本这样做没问题,但如果ui不太复杂,有点得不偿失。

    这里是不是描述有点问题。require 引入的库是在安装的时候就已经下载好了的,后续每次运行拼接好的源码,是不需要二次下载的

    ---
    回复

    使用道具 举报

  • TA的每日心情

    昨天 16:31
  • 签到天数: 309 天

    [LV.8]以坛为家I

    12

    主题

    63

    回帖

    642

    积分

    荣誉开发者

    积分
    642

    荣誉开发者生态建设者

    发表于 2022-10-23 22:50:15 | 显示全部楼层
    shabby 发表于 2022-10-23 22:35
    > 方法二引入框架,每次访问目标网站要对require的库发起请求。大型脚本这样做没问题,但如果ui不太复杂, ...

    我说的有问题,大概指的是,脚本使用时要@require相关的cdn,不是指开发阶段安装的。比如"https://cdn.jsdelivr.net/npm/vue@next",每次要访问它
    当冥想的日子飞逝,喧嚣的日子把我们唤去,且在此地留下些微的痕迹
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    60

    回帖

    89

    积分

    初级工程师

    积分
    89
    发表于 2022-10-23 23:15:20 | 显示全部楼层
    本帖最后由 shabby 于 2022-10-23 23:20 编辑
    朱焱伟 发表于 2022-10-23 22:50
    我说的有问题,大概指的是,脚本使用时要@require相关的cdn,不是指开发阶段安装的。比如"https://cdn.js ...

    不好意思,我还是没懂这里的 "每次要访问它" 的意思

    访问是指网络请求?还是执行这个库的代码?


    ---

    还有,这个 “油猴中文网”能不能向 github 一样,在有评论消息时发邮件给我,我没找到这块的设置,我现在要主动刷新才能看见消息
    回复

    使用道具 举报

  • TA的每日心情

    昨天 16:31
  • 签到天数: 309 天

    [LV.8]以坛为家I

    12

    主题

    63

    回帖

    642

    积分

    荣誉开发者

    积分
    642

    荣誉开发者生态建设者

    发表于 2022-10-23 23:19:14 | 显示全部楼层
    本帖最后由 朱焱伟 于 2022-10-23 23:23 编辑
    shabby 发表于 2022-10-23 23:15
    不好意思,我还是没懂这里的 "每次要访问它" 的意思

    访问是指网络请求?还是执行这个库的代码?

    网络请求。大概意思是说,为了加个按钮这种小功能,每次网络请求cdn不划算。小功能不想:


    // @require     https://unpkg.com/react@18/umd/react.development.js



    当冥想的日子飞逝,喧嚣的日子把我们唤去,且在此地留下些微的痕迹
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    60

    回帖

    89

    积分

    初级工程师

    积分
    89
    发表于 2022-10-23 23:25:02 | 显示全部楼层
    朱焱伟 发表于 2022-10-23 23:19
    网络请求。大概意思是说,为了加个按钮这种小功能,每次网络请求cdn不划算 ...

    require资源网络请求只会在安装脚本的时候产生

    后续每次执行脚本都是用第一次下载好的库代码

    这是我的理解,难不成每次都要请求 require 和 resource 标记的资源吗?

    或者和 cdn 的缓存回复头有关吗?
    回复

    使用道具 举报

  • TA的每日心情

    昨天 16:31
  • 签到天数: 309 天

    [LV.8]以坛为家I

    12

    主题

    63

    回帖

    642

    积分

    荣誉开发者

    积分
    642

    荣誉开发者生态建设者

    发表于 2022-10-23 23:30:18 | 显示全部楼层
    shabby 发表于 2022-10-23 23:25
    require资源网络请求只会在安装脚本的时候产生

    后续每次执行脚本都是用第一次下载好的库代码

    具体require原理我也不懂,可能是说的缓存好了的意思吧。有时候cdn的地址会失效,导致脚本不能运行。
    当冥想的日子飞逝,喧嚣的日子把我们唤去,且在此地留下些微的痕迹
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    60

    回帖

    89

    积分

    初级工程师

    积分
    89
    发表于 2022-10-23 23:36:52 | 显示全部楼层
    朱焱伟 发表于 2022-10-23 23:30
    具体require原理我也不懂,可能是说的缓存好了的意思吧。有时候cdn的地址会失效,导致脚本不能运行。 ...

    根据 https://violentmonkey.github.io/api/metadata-block/#require 的描述

    所需的脚本将随安装一起下载,并在执行脚本之前执行。

    回复

    使用道具 举报

    发表回复

    本版积分规则

    快速回复 返回顶部 返回列表