我的项目背景

随着前端业务的一直倒退,前端对设计稿的还原水平也成为了影响用户对产品体验的一个要害指标,作为最靠近用户侧的研发,前端工程师通常须要和设计师同学通力配合来晋升用户体验。其中,设计走查是设计同学最常见的测试前端同学是否完满还原了本人设计理念的形式,本文旨在通过设计走查平台在前端侧的实际总结下在前端上游研发链路中的一些根底建设,以期可能为其余有相干须要的同学提供一些实际思路。

计划

一个前端工程师的次要指标是寻找一款贴近浏览器原生的框架,svelte 是你不二的抉择。

前端架构选型,从整体的产品业务状态来看,属于较为简单的繁多页面状态,并且思考到要反对浏览器的插件生态,因此前端局部抉择应用了svelte的框架计划。抉择 svelte 作为本业务的前端技术选型次要思考到以下两方面起因:一是思考到业务状态较为简单,只有一个上传图片的页面;二是因为浏览器插件相干的编写还是更加偏差原生js一些,应用大型框架有些大材小用。综合近几年 svelte 的迅猛发展,小型业务还是思考应用它作为一个框架应用的,其在编译时利用位掩码做的脏值查看的思路其实还是能够作为框架开发者借鉴的一个思路的(ps:对这个感兴趣的同学,能够看一下新兴前端框架 Svelte 从入门到原理这篇文章的介绍),然而目前倒退绝对还是比拟初期,整个生态绝对还不够欠缺,同时也是给宽广开发者提供了很好的蓝海空间,比方:目前还没有呈现相似 Element UIAnt Design 这种非常好用的组件库零碎,尽管有几个,然而个人感觉很不好用,作者在本我的项目中对须要用到的几个组件做了简略的封装,有须要的同学能够参考借鉴一下。

目录

  • public

    • build
    • bg.jpeg
    • favicon.png
    • global.css
    • index.html
    • manifest.json
  • scripts

    • setupCrxScript.js
    • setupTypeScript.js
  • src

    • components

      • Button.svelte
      • Dialog.svelte
      • Icon.svelte
      • Input.svelte
      • Message.svelte
      • Tooltip.svelte
      • Upload.svelte
    • utils

      • function.js
      • image.js
      • index.js
    • App.svelte
    • main.js
  • rollup.config.js

实际

设计走查平台提供了治理平台及对应的Chrome插件,可提供给测试及UI同学用于图片的比对,晋升研发效率

源码

svelte作者--前端驰名的轮子哥Rich Harris,同时也是rollup的作者,因此本我的项目中就抉择了 rollup 作为打包构建的工具,同时为了将Chrome插件公布到内网中(ps:本我的项目次要用于我的项目外部基建利用,因此未在私有云及Chrome官网平台去公布,服务端波及到了图片的比对计算);在scripts目录下内置了两个脚本,一个用于生成ts,一个用于向云平台发送压缩包;因为 svelte 的组件库生态绝对不是特地丰盛(ps:业界常见曾经开源的 svelte 组件库能够参看这篇文章Svelte的UI组件库),比照了业界几个相干的组件库后,决定本人实现下业务中须要用到的组件,具体组件放在了components目录下

rollup.config.js

import svelte from 'rollup-plugin-svelte';import commonjs from '@rollup/plugin-commonjs';import resolve from '@rollup/plugin-node-resolve';import livereload from 'rollup-plugin-livereload';import { terser } from 'rollup-plugin-terser';import css from 'rollup-plugin-css-only';const production = !process.env.ROLLUP_WATCH;function serve() {    let server;    function toExit() {        if (server) server.kill(0);    }    return {        writeBundle() {            if (server) return;            server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {                stdio: ['ignore', 'inherit', 'inherit'],                shell: true            });            process.on('SIGTERM', toExit);            process.on('exit', toExit);        }    };}export default {    input: 'src/main.js',    output: {        sourcemap: true,        format: 'iife',        name: 'app',        file: 'public/build/bundle.js'    },    plugins: [        svelte({            compilerOptions: {                // enable run-time checks when not in production                dev: !production            }        }),        // we'll extract any component CSS out into        // a separate file - better for performance        css({ output: 'bundle.css' }),        // If you have external dependencies installed from        // npm, you'll most likely need these plugins. In        // some cases you'll need additional configuration -        // consult the documentation for details:        // https://github.com/rollup/plugins/tree/master/packages/commonjs        resolve({            browser: true,            dedupe: ['svelte']        }),        commonjs(),        // In dev mode, call `npm run start` once        // the bundle has been generated        !production && serve(),        // Watch the `public` directory and refresh the        // browser on changes when not in production        !production && livereload('public'),        // If we're building for production (npm run build        // instead of npm run dev), minify        production && terser()    ],    watch: {        clearScreen: false    }};

scripts

setupCrxScript.js

通过minio这个库来进行公有云平台的对象存储库上传,archiver这个次要用于压缩

const fs = require('fs');const path = require('path');const archiver = require('archiver');const Minio = require('minio');const minio = new Minio.Client({  endPoint: '',  port: 80,  useSSL: false,  accessKey: '',  secretKey: ''})const output = fs.createWriteStream(path.resolve(__dirname,'../pixelpiper.zip'));const archive = archiver('zip', {  zlib: { level: 9 }});output.on('close', function() {  console.log(archive.pointer() + ' total bytes');  console.log('archiver has been finalized and the output file descriptor has closed.');  // 压缩实现后向 cdn 中传递压缩包  const file = path.resolve(__dirname, '../pixelpiper.zip');  fs.stat(file, function(error, stats) {    if(error) {      return console.error(error)    }    minio.putObject('cdn', 'pixelpiper.zip', fs.createReadStream(file), stats.size, 'application/zip', function(err, etag) {      return console.log(err, etag) // err should be null    })  })  });output.on('end', function() {  console.log('Data has been drained');});archive.on('warning', function(err) {  if (err.code === 'ENOENT') {  } else {    throw err;  }});archive.on('error', function(err) {  throw err;});archive.pipe(output);archive.directory(path.resolve(__dirname, '../public'), false);archive.finalize();

setupTypeScript.js

// @ts-check/** This script modifies the project to support TS code in .svelte files like:  <script lang="ts">      export let name: string;  </script>   As well as validating the code for CI.  *//**  To work on this script:  rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template*/const fs = require("fs")const path = require("path")const { argv } = require("process")const projectRoot = argv[2] || path.join(__dirname, "..")// Add deps to pkg.jsonconst packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {  "svelte-check": "^1.0.0",  "svelte-preprocess": "^4.0.0",  "@rollup/plugin-typescript": "^8.0.0",  "typescript": "^4.0.0",  "tslib": "^2.0.0",  "@tsconfig/svelte": "^1.0.0"})// Add script for checkingpackageJSON.scripts = Object.assign(packageJSON.scripts, {  "validate": "svelte-check"})// Write the package JSONfs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, "  "))// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this tooconst beforeMainJSPath = path.join(projectRoot, "src", "main.js")const afterMainTSPath = path.join(projectRoot, "src", "main.ts")fs.renameSync(beforeMainJSPath, afterMainTSPath)// Switch the app.svelte file to use TSconst appSveltePath = path.join(projectRoot, "src", "App.svelte")let appFile = fs.readFileSync(appSveltePath, "utf8")appFile = appFile.replace("<script>", '<script lang="ts">')appFile = appFile.replace("export let name;", 'export let name: string;')fs.writeFileSync(appSveltePath, appFile)// Edit rollup configconst rollupConfigPath = path.join(projectRoot, "rollup.config.js")let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")// Edit importsrollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';import sveltePreprocess from 'svelte-preprocess';import typescript from '@rollup/plugin-typescript';`)// Replace name of entry pointrollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)// Add preprocessorrollupConfig = rollupConfig.replace(  'compilerOptions:',  'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:');// Add TypeScriptrollupConfig = rollupConfig.replace(  'commonjs(),',  'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),');fs.writeFileSync(rollupConfigPath, rollupConfig)// Add TSConfigconst tsconfig = `{  "extends": "@tsconfig/svelte/tsconfig.json",  "include": ["src/**/*"],  "exclude": ["node_modules/*", "__sapper__/*", "public/*"]}`const tsconfigPath =  path.join(projectRoot, "tsconfig.json")fs.writeFileSync(tsconfigPath, tsconfig)// Delete this script, but not during testingif (!argv[2]) {  // Remove the script  fs.unlinkSync(path.join(__filename))  // Check for Mac's DS_store file, and if it's the only one left remove it  const remainingFiles = fs.readdirSync(path.join(__dirname))  if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {    fs.unlinkSync(path.join(__dirname, '.DS_store'))  }  // Check if the scripts folder is empty  if (fs.readdirSync(path.join(__dirname)).length === 0) {    // Remove the scripts folder    fs.rmdirSync(path.join(__dirname))  }}// Adds the extension recommendationfs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{  "recommendations": ["svelte.svelte-vscode"]}`)console.log("Converted to TypeScript.")if (fs.existsSync(path.join(projectRoot, "node_modules"))) {  console.log("\nYou will need to re-run your dependency manager to get started.")}

components

我的项目中用到了一些通用组件,借鉴了下Element UI的组件款式和思路,次要封装了 Button(按钮)Dialog(对话框)Icon(图标)Input(输入框)Message(音讯)Tooltip(提醒工具)Upload(上传) 几个组件

Button.svelte

<script>    import Icon from './Icon.svelte';    import { createEventDispatcher } from 'svelte';    const dispatch = createEventDispatcher();    export let icon, type='default';    function handleClick() {        dispatch('click')    }    function computedButtonClass(type) {        switch (type) {            case 'primary':                return 'button button-primary';            case 'default':                return 'button button-default';            case 'text':                return 'button button-text';            default:                return 'button button-default';        }    }</script><style>    .button {        border: 0;        border-radius: 2px;    }    .button:hover {        cursor: pointer;    }    .button-primary {        background-color: rgb(77, 187, 41, 1);        color: white;        border: 1px solid rgb(77, 187, 41, 1);    }    .button-primary:hover {        background-color: rgba(77, 187, 41,.8);    }    .button-default {        background-color: white;        color: #999;        border: 1px solid #e0e0e0;    }    .button-default:hover {        background-color: rgba(77, 187, 41,.1);        color: rgba(77, 187, 41,.6);        border: 1px solid #e0e0e0;    }    .button-text {        background-color: transparent;        border: none;    }</style><button class={computedButtonClass(type)} on:click={handleClick}>    {#if icon}        <Icon name="{icon}" />    {/if}    <span>        <slot></slot>    </span></button>

Dialog.svelte

<script>import Icon from "./Icon.svelte";import Button from "./Button.svelte";import { createEventDispatcher } from 'svelte';const dispatch = createEventDispatcher();export let title, visible = false;function handleClose() { visible = false;}function handleShade() {    visible = false;}function handleCancel() {    visible = false;}function handleSubmit() {    dispatch('submit')}</script><style>.dialog-wrapper {    width: 100vw;    height: 100vh;    position: absolute;    z-index: 100000;    background-color: rgba(0, 0, 0, .3);}.dialog {    width: 400px;    height: max-content;    background-color: white;    box-shadow: 0 0 10px #ececec;    position: absolute;    z-index: 100001;    border-radius: 2px;    margin: auto;    top: 0;    bottom: 0;    left: 0;    right: 0;}.dialog-header {    display: flex;    align-items: center;    justify-content: space-between;    border-bottom: 1px solid #ececec;    padding: 10px;}.dialog-header .dialog-title {    font-size: 16px;}.dialog-body {    padding: 10px;}.dialog-footer {    display: flex;    justify-content: right;    padding: 10px;}</style>{#if visible}<div class="dialog-wrapper" on:click={handleShade}>    </div><div class="dialog">    <div class="dialog-header">        <slot name="title">            <span class="dialog-title">{ title }</span>        </slot>        <Button type="text" on:click={handleClose}>            <Icon name="iconclose"  />        </Button>            </div>    <div class="dialog-body">        <slot></slot>    </div>    <div class="dialog-footer">        <div class="dialog-button-group">            <Button on:click={handleCancel}>勾销</Button>            <Button type="primary" on:click={handleSubmit}>确定</Button>        </div>    </div></div>{/if}

Icon.svelte

罕用的icon次要通过iconfont来引入

<script> export let name;</script><i class="{`iconfont ${name}`}"></i>

Input.svelte

<script>    export let value;    import { createEventDispatcher } from 'svelte';    const dispatch = createEventDispatcher();    function handleChange() {        dispatch('input')    }</script><style>    .input {        border: 1px solid rgb(77, 187, 41, 1);        width: 100px;    }    .input:focus {        border: 1px solid rgb(77, 187, 41, .5);        outline: none;    }</style><input class="input" bind:value={value} on:change={handleChange} />

Message.svelte

<script>import Icon from './Icon.svelte';export let type = 'info', show = false;const computedMessageClass = type => {    switch (type) {        case 'success':            return 'message message-success';        case 'warning':            return 'message message-warning';        case 'info':            return 'message message-info';        case 'error':            return 'message message-error';        default:            return 'message message-info';    }}const computedIconName = type => {    switch (type) {        case 'success':            return 'iconsuccess';        case 'warning':            return 'iconwarning';        case 'info':            return 'iconinfo';        case 'error':            return 'iconerror';        default:            return 'iconinfo';    }}</script><style>.message {    position: absolute;    z-index: 100000;    width: 200px;    height: max-content;    left: 0;    right: 0;    margin: auto;    padding: 4px 10px;    animation: show 2s ease-in-out forwards;    display: flex;    justify-content: left;    align-items: center;    border-radius: 4px;}.message-content {    margin-left: 10px;}@keyframes show {    from {        opacity: 1;        top: 0;    }    to {        opacity: 0;        top: 100px;    }}.message-success {    background-color: rgba(77, 187, 41, .2);    color: rgba(77, 187, 41, 1);}.message-info {    background-color: rgb(144, 147, 153, .2);    color: rgb(144, 147, 153, 1);}.message-warning {    background-color: rgb(230, 162, 60, .2);    color: rgb(230, 162, 60, 1);}.message-error {    background-color: rgb(245, 108, 108, .2);    color: rgb(245, 108, 108, 1);}</style>{#if show}<div class={computedMessageClass(type)}>    <Icon name={computedIconName(type)} />    <p class="message-content">        <slot></slot>    </p></div>{/if}

Tooltip.svelte

<script>    export let content, tooltip;</script><style>.tooltip {    position: relative;}.tooltip .tip-container {    position: absolute;    background: #666;    padding: 0 10px;    border-radius: 4px;    right: -180px;    top: 50%;    margin-top: -24px;}.tip-container .tip-triple {    width: 0;    height: 0;    border: 8px solid transparent;    border-right-color: #666;    position: absolute;    left: -16px;    top: 16px;}.tip-container .tip-content {    line-height: 24px;    font-size: 12px;    color: white;}</style><div class="tooltip">    <slot class="tip-component"></slot>    {#if tooltip}    <div class="tip-container">        <div class="tip-triple"></div>        <p class="tip-content">{content}</p>    </div>    {/if}</div>

Upload.svelte

<script>    export let action, onSuccess, beforeUpload, id;    function ajax(options) {        const xhr = new XMLHttpRequest();        const action = options.action;        let fd = new FormData();                fd.append(options.filename, options.file);        xhr.onerror = function (err) {            console.error(err)        }        xhr.onload = function() {            const text = xhr.responseText || xhr.response;            console.log('text', text)            text && options.success(JSON.parse(text))        }        xhr.open('post', action, true);        xhr.send(fd);        return xhr;    }    function post(rawFile) {        const options = {            id: id,            file: rawFile,            filename: 'img',            action: action,            success: res => onSuccess(res, rawFile, id)        }        const req = ajax(options);        if(req && req.then) {            req.then(options.onSuccess)        }    }    async function handleChange(e) {        const rawFile = e.target.files[0];        if(!beforeUpload) {            return post(rawFile)        }        let flag = await beforeUpload(rawFile, id);        if(flag) post(rawFile)    }    function handleClick() {        const plus = document.getElementById(id);        plus.value = '';        plus.click()    }</script><style>    .upload {        width: 250px;        height: 250px;        border: 1px dashed #ececec;        text-align: center;        display: flex;        justify-content: center;        align-items: center;    }    .upload:hover {        cursor: pointer;    }    .native-input {        display: none;    }</style><div class="upload" on:click={handleClick}>    <div class="upload-slot" >        <slot></slot>    </div>    <input {id} class="native-input" type="file" multiple="false" accept="image/png" on:change={handleChange} /></div>

utils

通用工具库次要封装了图片及函数式编程须要用到的一些工具函数

function.js

export const curry = (fn, arr = []) => (...args) => (    arg => arg.length === fn.length ?    fn(...arg) :    curry(fn, arg))([...arr, ...args]);export const compose = (...args) => args.reduce((prev, current) => (...values) => prev(current(...values)));

image.js

export const getBase64 = file => {    const reader = new FileReader();    reader.readAsDataURL(file);    return new Promise((resolve) => {        reader.onload = () => {            resolve(reader.result);        };    });};export const getPixel = img => {    const image = new Image();    image.src = img;    return new Promise((resolve) => {        image.onload = () => {            const width = image.width;            const height = image.height;            resolve({ width, height });        };    });}

App.svelte

<script>    import Icon from './components/Icon.svelte';    import Button from './components/Button.svelte';    import Upload from './components/Upload.svelte';    import Input from './components/Input.svelte';    import Message from './components/Message.svelte';    import Tooltip from './components/Tooltip.svelte';    import axios from 'axios';    import { getBase64, getPixel } from './utils';    const bgUrl = './bg.jpeg',          logoUrl = './favicon.png',          actionUrl = '',          compareUrl = '',          downloadUrl = '',          crxUrl = '';    let width = 0, height = 0, flag, compareName, errorMsg, successMsg, show, tooltip;    let uploaders = [        {            id: 'design',            title: '设计图',            filename: '',            url: ''        },        {            id: 'code',            title: '实现图',            filename: '',            url: ''        }    ];    const handleCompare = () => {        show = false;        const len = uploaders.filter(f => !!f.filename).length;        if(len == 2) {            axios.post(compareUrl, {                designName: uploaders[0]['filename'],                codeName: uploaders[1]['filename'],            }).then(res => {                console.log('compare', res)                if(res.data.success) {                     compareName = res.data.data.compareName;                    return true                } else {                    flag = 'error';                    show = true;                    errorMsg = res.data.data                }             }).then(c => {                if(c) {                    flag = 'success';                    successMsg = '比照胜利';                    show = true;                    handleDownload()                    handleDelete()                }            })        } else if(len == 1) {            window.alert('设计图或开发图短少,请确认已全副上传后再进行比拟!')        } else {            window.alert('必须有图片能力进行比拟!')        }    };    const handleBeforeUpload = async function(rawFile, id) {        const fileBase64 = await getBase64(rawFile);        const res = await getPixel(fileBase64);        // console.log('res', res)        if(res.width == width && res.height == height) {            switch (id) {                case 'design':                    uploaders[0]['url'] = fileBase64                    break;                case 'code':                    uploaders[1]['url'] = fileBase64                    break;                default:                    break;            }            return true;        } else {            window.alert('上传图片不合乎分比率要求');            return false;        }    }    const handleSuccess = (response, rawFile, id) => {        console.log('response', response, rawFile, id);        if(response.success) {            switch (id) {                case 'design':                    uploaders[0]['filename'] = response.data.filename                    break;                case 'code':                    uploaders[1]['filename'] = response.data.filename                    break;                default:                    break;            }        }    }    function handleDownload() {        axios({            method: 'POST',            url: downloadUrl,            responseType: 'blob',            data: {                compareName: compareName            }        }).then(res => {            console.log('download', res)            if(res.status == 200) {                var blob = new Blob([res.data]);                // 创立一个URL对象                var url = window.URL.createObjectURL(blob);                console.log('url', url)                // 创立一个a标签                var a = document.createElement("a");                a.href = url;                a.download = compareName;// 这里指定下载文件的文件名                a.click();                // 开释之前创立的URL对象                window.URL.revokeObjectURL(url);            }        })    }    function handleDelete() {        uploaders = [        {            id: 'design',            title: '设计图',            filename: '',            url: ''        },        {            id: 'code',            title: '实现图',            filename: '',            url: ''        }    ];    }</script><style>    .pixel-piper {        width: 100%;        height: 100%;        display: flex;        justify-content: center;        align-items: center;    }    .main {        width: 600px;        margin: 0 auto;        padding: 20px;        border: 1px solid #eee;        background-color: #fff;        border-radius: 4px;        box-shadow: 0 0 10px #e0e0e0;    }    .main .logo-container {        display: flex;        justify-content: center;        align-items: center;    }    .logo-container .logo:hover {        opacity: 90%;        cursor: pointer;    }    .main .select-container {        display: flex;        align-items: center;        align-content: center;        justify-content: center;        justify-items: center;        line-height: 40px;    }    .main .upload-container {        display: flex;        padding: 0 0 10px 0;        justify-content: space-between;        text-align: center;    }    .main .button-container {        display: flex;        justify-content: center;        align-items: center;    }    .main .info-container {        text-align: center;        color: red;        font-size: 12px;        margin: 10px 0;    }</style><div class="pixel-piper" style="{`background: url(${bgUrl}) no-repeat; background-size: cover`}">    <section class="main">        <div class="logo-container">            <Tooltip content="点击logo可下载chrome插件" {tooltip}>                <a href={crxUrl}>                    <img                         class="logo"                         src={logoUrl}                         alt="logo"                         width="100px"                         on:mouseenter={() => tooltip=true}                         on:mouseleave={() => tooltip=false}                    >                </a>            </Tooltip>        </div>        <div class="select-container">            <p class="select-name"><Input bind:value={width} /> x <Input bind:value={height} /> </p>         </div>        <div class="upload-container">            {#each uploaders as uploader}            <div class="uploader">                <Upload                    id={uploader.id}                    onSuccess={handleSuccess}                     beforeUpload={handleBeforeUpload}                     action={actionUrl}                >                    {#if !uploader.url}                        <Icon name="iconplus" />                    {:else}                        <img class="uploader-image" style="object-fit: contain;" width="250" height="250" src={uploader.url} alt={uploader.id} />                    {/if}                </Upload>                <span class="uploader-title">{uploader.title}</span>            </div>            {/each}        </div>        <div class="info-container">            {#if uploaders.filter(f => !!f.filename).length == 2}                <span class="info-tips">注:请在两分钟内进行图片比照!!</span>            {/if}        </div>        <div class="button-container">            <Button icon="iconposition" on:click={handleCompare} type="primary">比照</Button>            {#if uploaders.filter(f => !!f.filename).length == 2}                <div style="margin-left: 10px">                    <Button  icon="icondelete" on:click={handleDelete} type="default">革除图片</Button>                </div>            {/if}        </div>    </section>    {#if flag == 'success'}        <Message type="success" {show} >{successMsg}</Message>    {:else if flag == 'error'}        <Message type="error" {show} >{errorMsg}</Message>    {/if}</div>

总结

作为前端工程师,咱们是间隔用户侧最近的研发同学,不仅仅在于实现业务的代码实现,而且不同于其余研发同学,咱们也承当着产品用户体验的重要职责,而这其中页面的还原水平是一项重要的指标,可能让设计同学认可咱们实现的工作也是评估大家前端能力的一个维度,毕竟上下游通力合作,能力把产品体验做到最佳,共勉!!!

参考

  • Svelte 中文文档
  • 一文详解 Svelte
  • 干货 | 携程机票前端Svelte生产实践
  • Svelte 原理浅析与评测
  • 新兴前端框架 Svelte 从入门到原理
  • 被称为“三大框架”代替计划,Svelte如何简化Web开发工作
  • 设计小姐姐都说好的视觉还原比照利器
  • 应用Svelte开发Chrome Extension
  • Chrome插件manifest.json文件详解
  • 30分钟开发一款抓取网站图片资源的浏览器插件
  • 一天学会Chrome插件开发