React + TypeScript + Tailwindcss 开发油猴脚本


起因

想给 jkforum_helper 油猴脚本加入前端 UI,用前端框架来开发提高开发效率。

需求

此脚本只需要一个页面,里面只有表单元素。

20220419013050

技术选型

TypeScript 类型检查可以提前发现代码问题,提高代码质量,以及更强大的代码补全,必选。

首先去网上搜索、分析、尝试了很多框架,都没有理想的。

分析自己常用的脚本,看到 Bilibili-Evolved 项目已经很成熟,插件化、组件化代码开发符合脚本开发的理想状态,想直接使用此项目作为模板开发。

仔细分析其代码过后,觉得对于我的需求代码冗余,我想要一个简单通用的 TypeScript 前端项目,又因其使用的是 Vue2,对 TypeScript 不友好,决定使用 React + TypeScript 自己写。

UI 库方面,计划使用人气很高的 Ant Design,实际尝试后,对开发的打包速度和生成文件的大小影响较大,并且会影响源网站的样式。尝试了平时使用较多的 Tailwindcss,虽然同样会影响源网站的样式,但打包更快,体积小,更灵活,更适合这种轻前端的项目。

最终测试下来:React + TypeScript + Tailwindcss 符合需求。

项目移植

初始化环境

npm init

添加依赖

package.json

{
  "name": "jkforum-helper",
  "version": "1.0.0",
  "description": "![JKFicon64oval2](https://cdn.jsdelivr.net/gh/eished/jkforum_helper/readme.assets/JKFicon64oval2.png)",
  "main": "/dist/jkforum.user.js",
  "scripts": {
    "start": "webpack serve",
    "dev": "webpack --watch",
    "style": "npx tailwindcss -i ./src/input.css -o ./src/output.css --watch",
    "build": "yarn lint && npx tailwindcss -i ./src/input.css -o ./src/output.css && webpack --env production",
    "lint": "eslint 'src/**/*.{ts,tsx}'",
    "lint:fix": "yarn lint --fix"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Eished/jkforum_helper.git"
  },
  "keywords": ["JKForum", "TamperMonkey", "tailwindcsss", "typescript"],
  "author": "Eished",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/Eished/jkforum_helper/issues"
  },
  "homepage": "https://github.com/Eished/jkforum_helper#readme",
  "devDependencies": {
    "@babel/core": "^7.17.9",
    "@babel/preset-env": "^7.16.11",
    "@babel/preset-react": "^7.16.7",
    "@tailwindcss/forms": "^0.5.1",
    "@types/file-saver": "^2.0.5",
    "@types/react": "^18.0.5",
    "@types/react-dom": "^18.0.1",
    "@typescript-eslint/eslint-plugin": "^5.19.0",
    "@typescript-eslint/parser": "^5.19.0",
    "autoprefixer": "^10.4.4",
    "babel-loader": "^8.2.4",
    "css-loader": "^6.7.1",
    "eslint": "^8.13.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-html": "^6.2.0",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-react": "^7.29.4",
    "eslint-plugin-react-hooks": "^4.4.0",
    "html-webpack-plugin": "^5.5.0",
    "less": "^4.1.2",
    "less-loader": "^10.2.0",
    "postcss": "^8.4.12",
    "postcss-loader": "^6.2.1",
    "postcss-preset-env": "^7.4.3",
    "prettier": "^2.6.2",
    "style-loader": "^3.3.1",
    "tailwindcss": "^3.0.24",
    "to-string-loader": "^1.2.0",
    "ts-loader": "^9.2.8",
    "typescript": "^4.6.3",
    "webpack": "^5.72.0",
    "webpack-cli": "^4.9.2",
    "webpack-dev-server": "^4.9.0"
  },
  "dependencies": {
    "file-saver": "^2.0.5",
    "jszip": "^3.9.1",
    "react": "^18.1.0",
    "react-dom": "^18.1.0"
  }
}

配置 TypeScript

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react",
    "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "module": "commonjs" /* Specify what module code is generated. */,
    "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
    "paths": {
      "@/*": ["src/*"]
    } /* Specify a set of entries that re-map imports to additional lookup locations. */,
    "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
    "strict": true /* Enable all strict type-checking options. */,
    "skipLibCheck": false /* Skip type checking all .d.ts files. */
  }
}

油猴 API 类型声明

global.d.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
import { XhrResponseType } from '@/commonType';
// https://juejin.cn/post/6898710177969602574

declare global {
  interface MonkeyXhrResponse {
    finalUrl: string;
    readyState: number;
    status: number;
    statusText: string;
    responseHeaders: any;
    response: any;
    responseXML: Document;
    responseText: string;
  }
  interface MonkeyXhrBasicDetails {
    url: string;
    method?: 'GET' | 'POST' | 'HEAD';
    headers?: { [name: string]: string };
    data?: string;
    binary?: boolean;
    timeout?: number;
    context?: any;
    responseType?: XhrResponseType;
    overrideMimeType?: string;
    anonymous?: boolean;
    fetch?: boolean;
    username?: string;
    password?: string;
  }
  interface MonkeyXhrDetails extends MonkeyXhrBasicDetails {
    onabort?: (response: MonkeyXhrResponse) => void;
    onerror?: (response: MonkeyXhrResponse) => void;
    onloadstart?: (response: MonkeyXhrResponse) => void;
    onprogress?: (response: MonkeyXhrResponse) => void;
    onreadystatechange?: (response: MonkeyXhrResponse) => void;
    ontimeout?: (response: MonkeyXhrResponse) => void;
    onload?: (response: MonkeyXhrResponse) => void;
  }
  type RunAtOptions = 'document-start' | 'document-end' | 'document-idle' | 'document-body' | 'context-menu';
  interface MonkeyInfo {
    script: {
      author: string;
      copyright: string;
      description: string;
      excludes: string[];
      homepage: string;
      icon: string;
      icon64: string;
      includes: string[];
      lastUpdated: number;
      matches: string[];
      downloadMode: string;
      name: string;
      namespace: string;
      options: {
        awareOfChrome: boolean;
        compat_arrayleft: boolean;
        compat_foreach: boolean;
        compat_forvarin: boolean;
        compat_metadata: boolean;
        compat_prototypes: boolean;
        compat_uW_gmonkey: boolean;
        noframes: boolean;
        override: {
          excludes: false;
          includes: false;
          orig_excludes: string[];
          orig_includes: string[];
          use_excludes: string[];
          use_includes: string[];
        };
        run_at: RunAtOptions;
      };
      position: number;
      resources: string[];
      'run-at': RunAtOptions;
      system: boolean;
      unwrap: boolean;
      version: string;
    };
    scriptMetaStr: string;
    scriptSource: string;
    scriptUpdateURL: string;
    scriptWillUpdate: boolean;
    scriptHandler: string;
    isIncognito: boolean;
    version: string;
  }
  const GM_info: MonkeyInfo;
  function GM_xmlhttpRequest(details: MonkeyXhrDetails): { abort: () => void };
  function GM_setValue<T>(name: string, value: T): void;
  function GM_getValue<T>(name: string, defaultValue?: T): T;
  function GM_deleteValue(name: string): void;
  function GM_getResourceText(name: string): string;
  function GM_getResourceURL(name: string): string;
  function GM_addStyle(name: string): string;
  function GM_addElement(name: string): string;
  function GM_notification(name: string): string;
  function GM_openInTab(name: string): string;
}
export {};

配置 Tailwindcss

tailwind.config.js

module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [require('@tailwindcss/forms')],
}

配置 webpack

webpack.config.js

const { resolve } = require('path')
const path = require('path')
const webpack = require('webpack')
const TerserPlugin = require('terser-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

const commonMeta = require('./src/common.meta.json')

const year = new Date().getFullYear()
const getBanner = meta => `// ==UserScript==\n${Object.entries(Object.assign(meta, commonMeta))
  .map(([key, value]) => {
    if (Array.isArray(value)) {
      return value.map(item => `// @${key.padEnd(20, ' ')}${item}`).join('\n')
    }
    return `// @${key.padEnd(20, ' ')}${value.replace(/\[year\]/g, year)}`
  })
  .join('\n')}
// ==/UserScript==
/* eslint-disable */ /* spell-checker: disable */
// @[ You can find all source codes in GitHub repo ]`

const relativePath = p => path.join(process.cwd(), p)
const src = relativePath('src')

module.exports = env => {
  console.log(env)
  const options = {
    entry: './src/index.tsx',
    output: {
      path: resolve(__dirname, 'dist'),
      filename: env.production ? 'jkforum.user.js' : 'jkforum.dev.user.js',
      publicPath: '/',
    },
    externals: {},
    module: {
      rules: [
        {
          test: /\.(js|jsx|mjs)$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                // 预设:指示babel做怎么样的兼容性处理。
                presets: [
                  [
                    '@babel/preset-env',
                    {
                      corejs: {
                        version: 3,
                      }, // 按需加载
                      useBuiltIns: 'usage',
                    },
                  ],
                  '@babel/preset-react',
                ],
              },
            },
          ],
        },
        {
          test: /\.tsx?$/,
          use: 'ts-loader',
          exclude: /node_modules/,
          include: [src],
        },
        {
          test: /\.css$/,
          use: ['style-loader', 'css-loader'],
          include: /node_modules/,
        },
        {
          test: /\.css$/,
          // 使用哪些 loader 进行处理
          use: [
            // use 数组中 loader 执行顺序:从右到左,从下到上 依次执行
            // 创建 style 标签,将 js 中的样式资源插入进行,添加到 head 中生效
            // GM_addStyle 不需要 style-loader
            // 'style-loader',
            'to-string-loader',
            // 将 css 文件变成 commonjs 模块加载 js 中,里面内容是样式字符串
            // esModule: false 可以 toString()
            {
              loader: 'css-loader',
              options: {
                esModule: false,
              },
            },
          ],
          include: [src],
        },
        {
          test: /\.less$/,
          // 使用哪些 loader 进行处理
          use: [
            // use 数组中 loader 执行顺序:从右到左,从下到上 依次执行
            // 创建 style 标签,将 js 中的样式资源插入进行,添加到 head 中生效
            'style-loader',
            // 将 css 文件变成 commonjs 模块加载 js 中,里面内容是样式字符串
            'css-loader',
            {
              loader: 'less-loader',
              options: {
                lessOptions: {
                  javascriptEnabled: true,
                },
              },
            },
          ],
        },
      ],
    },
    optimization: {
      minimize: true,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            output: {
              comments: /==\/?UserScript==|^[ ]?@|eslint-disable|spell-checker/i,
            },
          },
          extractComments: false,
        }),
      ],
    },
    watchOptions: {
      ignored: /node_modules/,
    },
    resolve: {
      extensions: ['.tsx', '.ts', '.js', '.jsx', '.json'],
      alias: {
        '@': src,
      },
    },
    mode: env.production ? 'production' : 'development',
    // devtool: 'source-map',
    plugins: [
      new webpack.BannerPlugin({
        banner: getBanner({ name: env.production ? 'JKForum 助手' : 'JKForum 助手-dev' }),
        raw: true,
        entryOnly: true,
      }),
    ],
  }

  if (!env.production) {
    options.devServer = {
      static: {
        directory: path.join(__dirname, 'public'),
      },
      compress: true,
      port: 8080,
      hot: true,
      open: true,
      watchFiles: ['src/**/*.tsx'], // 无效
    }
    options.plugins.push(
      new HtmlWebpackPlugin({
        template: './public/index.html',
      })
    )
  }

  return options
}

添加代码规范

module.exports = {
  parser: '@typescript-eslint/parser', // 定义ESLint的解析器
  extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], //定义文件继承的子规范
  plugins: ['@typescript-eslint', 'react-hooks', 'eslint-plugin-react', 'html', 'react', 'prettier'], //定义了该eslint文件所依赖的插件
  env: {
    //指定代码的运行环境
    browser: true,
    node: true,
    es2021: true,
  },
  settings: {
    //自动发现React的版本,从而进行规范react代码
    react: {
      pragma: 'React',
      version: 'detect',
    },
  },
  parserOptions: {
    //指定ESLint可以解析JSX语法
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  rules: {
    // 自定义的一些规则
    'prettier/prettier': 'error',
    'linebreak-style': ['error', 'unix'],
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    'react/jsx-uses-react': 'error',
    'react/jsx-uses-vars': 'error',
    'react/react-in-jsx-scope': 'error',
    'valid-typeof': [
      'warn',
      {
        requireStringLiterals: false,
      },
    ],
    '@typescript-eslint/no-var-requires': 'off',
  },
}

试运行

yarn dev

进行代码移植

项目文件结构

| dist
| public
| doc
| src
| | idnex.tsx   // React 入口
| | app.tsx     // 悬浮按钮初始化数据
| | output.css  // tailwindcss 样式文件
| | global.d.ts // 油猴 API 的类型声明
| | —— components
| | —— lib
| | —— utils

优化提升

TailwindCSS 样式与源网站冲突

webpack.config.js

{
   test: /\.css$/,
   // 使用哪些 loader 进行处理
   use: [
     // use 数组中 loader 执行顺序:从右到左,从下到上 依次执行
     // 创建 style 标签,将 js 中的样式资源插入进行,添加到 head 中生效
     // GM_addStyle 不需要 style-loader
     // 'style-loader',
     'to-string-loader',
     // 将 css 文件变成 commonjs 模块加载 js 中,里面内容是样式字符串
     // esModule: false 可以 toString()
     {
       loader: 'css-loader',
       options: {
         esModule: false,
       },
     },
   ],
   include: [src],
 },

使用 loadStyle 函数过滤掉冲突的样式,再用 GM_addStyle 插入样式

本地调试与热刷新

油猴头文件加入 // @match http://localhost:8080/*

webpack.config.js

if (!env.production) {
  options.devServer = {
    static: {
      directory: path.join(__dirname, 'public'),
    },
    compress: true,
    port: 8080,
    hot: true,
    open: true,
    watchFiles: ['src/**/*.tsx'], // 无效
  }
  options.plugins.push(
    new HtmlWebpackPlugin({
      template: './public/index.html',
    })
  )
}

start: style & dev 启动项目,然后 yarn start

把目标网站的静态 html 文件复制到 public 文件夹下,插入到 index.html 即可本地调试脚本静态功能。

  • yarn start devServer 提供 web 服务和网页热刷新功能

  • start: style & dev 生成脚本,让 tampermonkey 使用

拆包发布

webpack 配置 splitchunks 拆包,减小发布体积

webpack.config.js

// 单独打包react 和 react-dom file-saver jszip
// greasyfork 无法引入私人库
  splitChunks: {
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/](react|react-dom|file-saver|jszip)[\\/]/,
        name: 'vendor',
        chunks: 'all',
      },
    },
  },

生成主文件发布,生成的 module 文件使用 @require 从自己代码库的 CDN 导入

实测提示:Code 使用了一个未经批准的外部脚本,无法发布。

GItHub Actions 自动构建

总结

项目地址:https://github.com/Eished/jkforum_helper

参考项目及其文档:https://bilibili-evolved-doc.vercel.app/developer


文章作者: iKnow
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 iKnow !
  目录