共计 19100 个字符,预计需要花费 48 分钟才能阅读完成。
我的项目背景
随着前端业务的一直倒退,前端对设计稿的还原水平也成为了影响用户对产品体验的一个要害指标,作为最靠近用户侧的研发,前端工程师通常须要和设计师同学通力配合来晋升用户体验。其中,设计走查是设计同学最常见的测试前端同学是否完满还原了本人设计理念的形式,本文旨在通过设计走查平台在前端侧的实际总结下在前端上游研发链路中的一些根底建设,以期可能为其余有相干须要的同学提供一些实际思路。
计划
一个前端工程师的次要指标是寻找一款贴近浏览器原生的框架,svelte 是你不二的抉择。
前端架构选型,从整体的产品业务状态来看,属于较为简单的繁多页面状态,并且思考到要反对浏览器的插件生态,因此前端局部抉择应用了 svelte 的框架计划。抉择 svelte
作为本业务的前端技术选型次要思考到以下两方面起因:一是思考到业务状态较为简单,只有一个上传图片的页面;二是因为浏览器插件相干的编写还是更加偏差原生 js 一些,应用大型框架有些大材小用。综合近几年 svelte
的迅猛发展,小型业务还是思考应用它作为一个框架应用的,其在编译时利用位掩码做的脏值查看的思路其实还是能够作为框架开发者借鉴的一个思路的 (ps:对这个感兴趣的同学,能够看一下新兴前端框架 Svelte 从入门到原理这篇文章的介绍),然而目前倒退绝对还是比拟初期,整个生态绝对还不够欠缺,同时也是给宽广开发者提供了很好的蓝海空间,比方:目前还没有呈现相似 Element UI
和 Ant 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.json
const 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 checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {"validate": "svelte-check"})
// Write the package JSON
fs.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 too
const 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 TS
const 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 config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
// Edit imports
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`)
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
// Add preprocessor
rollupConfig = rollupConfig.replace(
'compilerOptions:',
'preprocess: sveltePreprocess({sourceMap: !production}),\n\t\t\tcompilerOptions:'
);
// Add TypeScript
rollupConfig = 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 TSConfig
const 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 testing
if (!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 recommendation
fs.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 插件开发