起因
想给 jkforum_helper 油猴脚本加入前端 UI,用前端框架来开发提高开发效率。
需求
此脚本只需要一个页面,里面只有表单元素。
技术选型
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 使用了一个未经批准的外部脚本,无法发布。