使用rollup+vue开发油猴插件

85 阅读2分钟

今天将今日掘友分油猴插件使用rollup+vue重写了。

具体的步骤我这里列一下:

新建workspace

git init
npm init

拆分文件

ProgressBar.js

今日掘友分进度组件。

// src/ProgressBar.js
import { getGrowthTaskList } from './utils/api';

const TaskDetailPopup = {
  props: {
    task: {
      type: Array,
      default() {
        return [];
      },
    },
  },

  render(h) {
    const { icon, title, score, done, limit } = this.task;
    return h('div', { staticClass: 'task-detail-popup' }, [
      h('div', { staticClass: 'task-icon' }, [
        h('img', { attrs: { src: icon, alt: title } }),
      ]),
      h('div', { staticClass: 'task-info' }, [
        h('span', {}, title),
        h('span', {}, `掘友分+${score},已完成${done}/${limit}`),
      ]),
    ]);
  },
};

export default {
  name: 'ProgressBar',

  data() {
    return {
      // 今日已获掘友分
      today_jscore: 0,
      // 有完成次数的任务
      limitTasks: [],
      // 头部区域是否展示
      headerVisible: true,
    };
  },

  render(h) {
    return h(
      'div',
      { staticClass: 'pl-progressbar', class: { top: !this.headerVisible } },
      [
        // 进度条
        h(
          'div',
          { staticClass: 'pl-progressbar-container' },
          this.limitTasks.map(task => {
            const { done, limit, maxScore } = task;
            // 任务进度
            const taskProgressItems = [];
            for (let i = 0; i < limit; i++) {
              taskProgressItems.push(
                h('div', {
                  staticClass: 'task-progress-item',
                  class: { 'item-done': i < done },
                })
              );
            }
            return h(
              'div',
              {
                staticClass: 'task-item',
                class: {
                  'all-finished': done === limit,
                  'not-start': done === 0,
                },
                // 按比例占据宽度
                style: {
                  'flex-grow': maxScore,
                },
                on: {
                  // 点击任务跳转去完成
                  click() {
                    window.open(task.web_jump_url);
                  },
                },
              },
              [
                h(
                  'div',
                  { staticClass: 'progress-item-wrapper' },
                  taskProgressItems
                ),
                h(TaskDetailPopup, { props: { task } }),
              ]
            );
          })
        ),
        // 总分
        h(
          'div',
          { staticClass: 'pl-progressbar-totalscore' },
          `今日掘友分 ${this.today_jscore}`
        ),
      ]
    );
  },

  mounted() {
    this.getGrowthTaskList();
    this.observeHeader();
  },

  methods: {
    // 需要监听头部的展示与隐藏
    observeHeader() {
      const header = document.querySelector('header.main-header');
      const observer = new MutationObserver(mutationsList => {
        const [record] = mutationsList;
        if (record.attributeName !== 'class') return;
        this.headerVisible = header.classList.contains('visible');
      });
      observer.observe(header, {
        attributes: true,
        childList: false,
        subtree: false,
      });
    },
    getGrowthTaskList() {
      getGrowthTaskList().then(data => {
        Object.assign(this, data);
      });
    },
  },
};

index.scss

组件样式以及对原页面的样式修改。

// src/index.scss
// 覆盖原有样式 === begin
// 取消header下部的阴影 
.main-header {
  box-shadow: none !important;
}
// 用户页面header下方出现list-header的sticky元素
.list-block .list-header.sticky {
  top: calc(5rem + 12px) !important;
  &.top {
    top: 12px !important;
  }
}
// 评论通知页 /notification
.view-nav {
  top: calc(5rem + 12px) !important;
}
// 覆盖原有样式 === end

.pl-progressbar {
  display: flex;
  justify-content: space-between; 
  padding: 1px;
  font-size: 12px;
  height: 10px;
  color: var(--juejin-brand-1-normal);
  background: var(--juejin-navigation);
  position: fixed;
  top: 5rem;
  left: 0;
  right: 0;
  z-index: 1249;
  transition: transform .2s;
  &.top {
    transform: translate3d(0,-5rem,0);
  }
  &-container {
    flex-grow: 1;
    display: flex;
  }
  &-totalscore {
    align-self: center;
    padding: 0 20px;
  }
}
body[data-theme="dark"] .pl-progressbar  {
  background: var(--juejin-navigation);
}

.task-item {
  overflow: hidden;
  display: flex;
  cursor: pointer;

  .progress-item-wrapper {
    position: relative;
    flex: 1;
    display: flex;
    &::before {
      content: '';
      border: 5px solid var(--juejin-brand-1-normal);
      border-left: 3px solid var(--juejin-navigation);
      position: absolute;
      z-index: 1;
    }
    &::after {
      content: '';
      border: solid 5px var(--juejin-navigation);
      border-left: 3px solid transparent;
      position: absolute;
      right: -5px;
    }
  }

  .task-detail-popup {
    position: absolute;
    top: 12px;
    display: none;
    align-items: center;
    background: var(--juejin-navigation);
    padding: 5px 10px;
    border-radius: 5px;
    border: solid 1px;
    z-index: 999;

    .task-icon {
      width: 40px;
      height: 40px;
      flex-shrink: 0;
      img {
        width: 100%;
        height: 100%;
      }
    }
    .task-info {
      margin-left: 10px;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
    }
  }

  &.all-finished {
    .progress-item-wrapper::after {
      border-left-color: var(--juejin-brand-1-normal);
    }
  }
  &.not-start {
    .progress-item-wrapper::before {
      border-top-color: transparent;
      border-bottom-color: transparent;
      border-right-color: transparent;
    }
  }
  &:hover {
    .progress-item-wrapper {
      opacity: .5;
    }
    .task-detail-popup {
      display: flex;
    }
  }

  .task-progress-item {
    flex: 1;
    background: #f2f3f5;
    position: relative;
    &.item-done {
      background: var(--juejin-brand-1-normal);
    }
    &+.task-progress-item {
      margin-left: 1px;
    }
  }
}

banner.txt

将油猴插件需要的头部注释拆到该文件中。

// ==UserScript==
// @name         今日掘友分
// @namespace    http://tampermonkey.net/
// @version      0.1.1
// @description  try to take over the world!
// @author       奇幻心灵
// @match        https://juejin.cn/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=juejin.cn
// @connect      juejin.cn
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/vue@2.6.14
// ==/UserScript==

api.js

封装油猴GM_xmlhttpRequestapi,以及页面中的请求。

const post = (url, data = {}) => {
  const headers = {
    'Content-Type': 'application/json;charset=UTF-8',
  };
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: 'POST',
      url,
      headers,
      responseType: 'json',
      data: JSON.stringify(data),
      onload: xhr => resolve(xhr.response),
      onerror: err => reject(err),
    });
  });
};

const getUrlPath = relativePath => `https://api.juejin.cn${relativePath}`;

// 获取成长任务列表
export function getGrowthTaskList() {
  const url = getUrlPath('/growth_api/v1/user_growth/task_list');
  return post(url, {
    growth_type: 1,
  }).then(rsp => {
    const { err_no, data } = rsp;
    if (err_no !== 0) return;
    const { growth_tasks, today_jscore } = data;
    // 有完成次数的任务
    const limitTasks = [];
    Object.values(growth_tasks).forEach(tasks => {
      tasks.forEach(task => {
        const { limit, score } = task;
        // -1表示无限制,任务可以完成任意次数
        if (limit !== -1) {
          task.maxScore = limit * score;
          limitTasks.push(task);
        }
      });
    });
    // 排序:优先展示单任务分值较大的任务,已完成的任务往后排
    limitTasks.sort((a, b) => {
      if (b.done === b.limit && a.done !== a.limit) return -1;
      if (a.done === a.limit && b.done !== b.limit) return 1;
      if (a.score !== b.score) return b.score - a.score;
      return b.maxScore - a.maxScore;
    });
    return { today_jscore, limitTasks };
  });
}
  1. main.js 入口文件,使用rollup打包的入口。
import Vue from 'vue';
import ProgressBar from './ProgressBar';

import './index.scss';
const el = document.createElement('div');
const mainHeaderBox = document.querySelector('.main-header-box');
mainHeaderBox.appendChild(el);
new Vue({ el, render: h => h(ProgressBar) }).$mount();

写了一个banner插件

由于rollup-plugin-banner不满足要求,新写了一个。

// plugins/banner/index.js
const path = require('path');
const fs = require('fs');

export default function (opts) {
  const { file, encoding = 'utf-8' } = opts;
  return {
    name: 'banner',
    renderChunk(code) {
      const filepath = path.resolve(file);
      const exists = fs.existsSync(filepath);
      if (!exists) return;
      const content = fs.readFileSync(filepath, encoding);
      return content + '\r\n\r\n' + code;
    },
  };
}

rollup配置

import banner from './plugins/banner/index';
import sass from 'rollup-plugin-sass';

const path = require('path');

export default {
  input: 'src/main.js',
  output: {
    file: 'bundle.js',
    format: 'iife', // 导出自执行函数
    globals: {
      vue: 'Vue',
    },
  },
  external: ['vue'],
  plugins: [
    banner({ file: path.join(__dirname, 'src/banner.txt') }),
    sass({ insert: true }),
  ],
};