Coder Social home page Coder Social logo

lin-xin / blog Goto Github PK

View Code? Open in Web Editor NEW
1.8K 1.8K 507.0 4.41 MB

林鑫的个人博客,用于总结平时工作和学习中的经验。

Home Page: https://lin-xin.gitee.io/

CSS 57.95% HTML 3.39% JavaScript 36.59% TypeScript 0.03% Vue 2.02% SCSS 0.01%
blog html5 javascript lin-xin vue

blog's People

Contributors

lin-xin avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

JavaScript 中 this 的详解

this 的指向

this 是 js 中定义的关键字,它自动定义于每一个函数域内,但是它的指向却让人很迷惑。在实际应用中,this 的指向大致可以分为以下四种情况。

1.作为普通函数调用

当函数作为一个普通函数被调用,this 指向全局对象。在浏览器里,全局对象就是 window。

window.name = 'linxin';
function getName(){
    console.log(this.name);
}
getName();                   // linxin

可以看出,此时 this 指向了全局对象 window。
在ECMAScript5的严格模式下,这种情况 this 已经被规定不会指向全局对象了,而是 undefined。

'use strict';
function fun(){
    console.log(this);
}
fun();                      // undefined

2.作为对象的方法调用

当函数作为一个对象里的方法被调用,this 指向该对象

var obj = {
    name : 'linxin',
    getName : function(){
        console.log(this.name);
    }
}

obj.getName();              // linxin

如果把对象的方法赋值给一个变量,再调用这个变量:

var obj = {
    fun1 : function(){
        console.log(this);
    }
}
var fun2 = obj.fun1;
fun2();                     // window

此时调用 fun2 方法 输出了 window 对象,说明此时 this 指向了全局对象。给 fun2 赋值,其实是相当于:

var fun2 = function(){
    console.log(this);
}

可以看出,此时的 this 已经跟 obj 没有任何关系了。这时 fun2 是作为普通函数调用。

3.作为构造函数调用

js中没有类,但是可以从构造器中创建对象,并提供了 new 运算符来进行调用该构造器。构造器的外表跟普通函数一样,大部分的函数都可以当做构造器使用。当构造函数被调用时,this 指向了该构造函数实例化出来的对象。

var Person = function(){
    this.name = 'linxin';
}
var obj = new Person();
console.log(obj.name);      // linxin

如果构造函数显式的返回一个对象,那么 this 则会指向该对象。

var Person = function(){
    this.name = 'linxin';
    return {
        name : 'linshuai'
    }
}
var obj = new Person();
console.log(obj.name);      // linshuai

如果该函数不用 new 调用,当作普通函数执行,那么 this 依然指向全局对象。

4.call() 或 apply() 调用

通过调用函数的 call() 或 apply() 方法可动态的改变 this 的指向。

var obj1 = {
    name : 'linxin',
    getName : function(){
        console.log(this.name);
    }
}
var obj2 = {
    name : 'linshuai'
}

obj1.getName();             // linxin
obj1.getName.call(obj2);    // linshuai
obj1.getName.apply(obj2);   // linshuai

这两个方法在js中都是非常常用的方法,可以阅读下一篇:javascript 中 apply 、call 的详解

HTML5 进阶系列:indexedDB 数据库

前言

在 HTML5 的本地存储中,有一种叫 indexedDB 的数据库,该数据库是一种存储在客户端本地的 NoSQL 数据库,它可以存储大量的数据。从上篇:HTML5 高级系列:web Storage ,我们知道 web Storage 可以方便灵活的在本地存取简单数据,但是对于大量结构化存储,indexedDB 的优势就更加明显。接下来我们来看看 indexedDB 如何存储数据。

连接数据库

一个网站可以有多个 indexedDB 数据库,但每个数据库的名称是唯一的。我们需要通过数据库名来连接某个具体的数据库。

var request = indexedDB.open('dbName', 1);  // 打开 dbName 数据库
request.onerror = function(e){              // 监听连接数据库失败时执行
    console.log('连接数据库失败');
}
request.onsuccess = function(e){            // 监听连接数据库成功时执行
    console.log('连接数据库成功');
}

我们使用 indexedDB.open 方法来连接数据库,该方法接收两个参数,第一个是数据库名,第二个是数据库版本号。该方法会返回一个 IDBOpenDBRequest 对象,代表一个请求连接数据库的请求对象。我们可以通过监听请求对象的 onsuccess 和 onerror 事件来定义连接成功或失败需执行的方法。

因为 indexedDB API 中不允许数据库中的数据仓库在同一版本中发生变化,所以需要在 indexedDB.open 方法中传入新的版本号来更新版本,避免在同一版本中重复修改数据库。版本号必须为整数!

var request = indexedDB.open('dbName', 2);  // 更新版本,打开版本为2的数据库
// ...
request.onupgradeneeded = function(e){
    console.log('新数据库版本号为=' + e.newVersion);
}

我们通过监听请求对象的 onupgradeneeded 事件来定义数据库版本更新时执行的方法。

关闭数据库

使用 indexedDB.open 连接数据库成功后会返回一个 IDBOpenDBRequest 对象,我们可以调用该对象的 close 方法来关闭数据库。

var request = indexedDB.open('dbName', 2);
// ...
request.onsuccess = function(e){
    console.log('连接数据库成功');
    var db = e.target.result;
    db.close();
    console.log('数据库已关闭');
}

删除数据库

indexedDB.deleteDatabase('dbName');
console.log('数据库已删除');

创建对象仓库

object store(对象仓库)是 indexedDB 数据库的基础,在indexedDB 中并没有数据库表,而对象仓库,就是相当于一个数据库表。

var request = indexedDB.open('dbName', 3);
// ...
request.onupgradeneeded = function(e){
    var db = e.target.result;
    var store = db.createObjectStore('Users', {keyPath: 'userId', autoIncrement: false});
    console.log('创建对象仓库成功');
}

db.createObjectStore 方法接收两个参数,第一个为对象仓库名,第二个参数为可选参数,值为一个js对象。该对象中的 keyPath 属性为主键,相当于数据库表中 id 为主键。autoIncrement 属性为 false,则表示主键值不自增,添加数据时需指定主键值。

注意:在数据库中,对象仓库名不可重复,否则浏览器会报错。

创建索引

indexedDB 数据库中通过数据对象的某个属性来创建索引,在数据库中进行检索时,只能通过被设为索引的属性进行检索。

var request = indexedDB.open('dbName', 4);
// ...
request.onupgradeneeded = function(e){
    var db = e.target.result;
    var store = db.createObjectStore('newUsers', {keyPath: 'userId', autoIncrement: false});
    var idx = store.createIndex('usernameIndex','userName',{unique: false})
    console.log('创建索引成功');
}

store.createIndex 方法接收三个参数,第一个为索引名,第二个为数据对象的属性,上例中使用 userName 属性来创建索引,第三个参数为可选参数,值为一个js对象。该对象中的 unique 属性为 true,代表索引值不可以相同,即两条数据的 userName 不可以相同,为 false 则可以相同。

基于事务

在 indexedDB 中,所有数据操作都只能在事务中执行。连接数据库成功后,可以使用 IDBOpenDBRequest 对象的 transaction 方法开启只读事务或读写事务。

var request = indexedDB.open('dbName', 5);
// ...
request.onupgradeneeded = function(e){
    var db = e.target.result;
    var tx = db.transaction('Users','readonly');
    tx.oncomplete = function(e){
        console.log('事务结束了');
    }
    tx.onabort = function(e){
        console.log('事务被中止了');
    }
}

db.transaction 方法接收两个参数,第一个参数可以是字符串或数组,字符串时则是一个对象仓库名,数组时则是由对象仓库名组成的数组,transaction 可以对参数中任何一个对象仓库进行操作。第二个参数为事务模式,传入 readonly 时只能对对象仓库进行读操作,无法写操作。可以传入 readwrite 进行读写操作。

操作数据

  • add() : 增加数据。接收一个参数,为需要保存到对象仓库中的对象。
  • put() : 增加或修改数据。接收一个参数,为需要保存到对象仓库中的对象。
  • get() : 获取数据。接收一个参数,为需要获取数据的主键值。
  • delete() : 删除数据。接收一个参数,为需要获取数据的主键值。
var request = indexedDB.open('dbName', 5);
// ...
request.onsuccess = function(e){
    var db = e.target.result;
    var tx = db.transaction('Users','readwrite');
    var store = tx.objectStore('Users');
    var value = {
        'userId': 1,
        'userName': 'linxin',
        'age': 24
    }
    var req1 = store.put(value);		// 保存数据
    var req2 = store.get(1);			// 获取索引userId为1的数据
    req2.onsuccess = function(){
        console.log(this.result.userName);	// linxin
    }
    var req3 = store.delete(1);             // 删除索引为1的数据
    req3.onsuccess = function(){
        console.log('删除数据成功');        // 删除数据成功
    }
}

add 和 put 的作用类似,区别在于 put 保存数据时,如果该数据的主键在数据库中已经有相同主键的时候,则会修改数据库中对应主键的对象,而使用 add 保存数据,如果该主键已经存在,则保存失败。

检索数据

上面我们知道使用 get() 方法可以获取数据,但是需要制定主键值。如果我们想要获取一个区间的数据,可以使用游标。游标通过对象仓库的 openCursor 方法创建并打开。

openCursor 方法接收两个参数,第一个是 IDBKeyRange 对象,该对象创建方法主要有以下几种:

// boundRange 表示主键值从1到10(包含1和10)的集合。
// 如果第三个参数为true,则表示不包含最小键值1,如果第四参数为true,则表示不包含最大键值10,默认都为false
var boundRange = IDBKeyRange.bound(1, 10, false, false);

// onlyRange 表示由一个主键值的集合。only() 参数则为主键值,整数类型。
var onlyRange = IDBKeyRange.only(1);

// lowerRaneg 表示大于等于1的主键值的集合。
// 第二个参数可选,为true则表示不包含最小主键1,false则包含,默认为false
var lowerRange = IDBKeyRange.lowerBound(1, false);

// upperRange 表示小于等于10的主键值的集合。
// 第二个参数可选,为true则表示不包含最大主键10,false则包含,默认为false
var upperRange = IDBKeyRange.upperBound(10, false);

openCursor 方法的第二个参数表示游标的读取方向,主要有以下几种:

  • next : 游标中的数据按主键值升序排列,主键值相等的数据都被读取
  • nextunique : 游标中的数据按主键值升序排列,主键值相等只读取第一条数据
  • prev : 游标中的数据按主键值降序排列,主键值相等的数据都被读取
  • prevunique : 游标中的数据按主键值降序排列,主键值相等只读取第一条数据
var request = indexedDB.open('dbName', 6);
// ...
request.onsuccess = function(e){
    var db = e.target.result;
    var tx = db.transaction('Users','readwrite');
    var store = tx.objectStore('Users');
    var range = IDBKeyRange.bound(1,10);
    var req = store.openCursor(range, 'next');
    req.onsuccess = function(){
        var cursor = this.result;
        if(cursor){
            console.log(cursor.value.userName);
            cursor.continue();
        }else{
            console.log('检索结束');
        }
    }
}

当存在符合检索条件的数据时,可以通过 update 方法更新该数据:

cursor.updata({
    userId : cursor.key,
    userName : 'Hello',
    age : 18
});

可以通过 delete 方法删除该数据:

cursor.delete();

可以通过 continue 方法继续读取下一条数据,否则读到第一条数据之后不再继续读取:

cursor.continue();

总结

从连接数据库,创建对象仓库、索引,到操作、检索数据,完成了 indexedDB 存取数据的完整流程。下面通过一个完整的例子来更好地掌握 indexedDB 数据库。代码地址:indexedDB-demo

移动端图片上传旋转、压缩的解决方案

前言

在手机上通过网页 input 标签拍照上传图片,有一些手机会出现图片旋转了90度d的问题,包括 iPhone 和个别三星手机。这些手机竖着拍的时候才会出现这种问题,横拍出来的照片就正常显示。因此,可以通过获取手机拍照角度来对照片进行旋转,从而解决这个问题。

Orientation

这个参数并不是所有图片都有的,不过手机拍出来的图片是带有这个参数的。

旋转角度 参数值
1
顺时针90° 6
逆时针90° 8
180° 3

参数为 1 的时候显示正常,那么在这些横拍显示正常,即 Orientation = 1 的手机上,竖拍的参数为 6。

想要获取 Orientation 参数,可以通过 exif.js 库来操作。exif.js 功能很多,体积也很大,未压缩之前足足有 30k,这对手机页面加载来说是非常大影响的。而我只需要获取 Orientation 信息而已,所以我这里删减了 exif.js 库的一些代码,将代码缩小到几KB。

exif.js 获取 Orientation :

EXIF.getData(file, function() {  
    var Orientation = EXIF.getTag(this, 'Orientation');
});

file 则是 input 文件表单上传的文件。上传的文件经过 fileReader.readAsDataURL(file) 就可以实现预览图片了,这方面不清楚的可以查看:HTML5 进阶系列:文件上传下载

旋转

旋转需要用到 canvas 的 rotate() 方法。

ctx.rotate(angle);

rotate 方法的参数为旋转弧度。需要将角度转为弧度:degrees * Math.PI / 180

旋转的中心点默认都在 canvas 的起点,即 ( 0, 0 )。旋转的原理如下图:

旋转原理图

旋转之后,如果从 ( 0, 0 ) 点进行 drawImage(),那么画出来的位置就是在左图中的旋转 90 度后的位置,不在可视区域呢。旋转之后,坐标轴也跟着旋转了,想要显示在可视区域呢,需要将 ( 0, 0 ) 点往 y 轴的反方向移 y 个单位,此时的起始点则为 ( 0, -y )。

同理,可以获得旋转 -90 度后的起始点为 ( -x, 0 ),旋转 180 度后的起始点为 ( -x, -y )。

压缩

手机拍出来的照片太大,而且使用 base64 编码的照片会比原照片大,那么上传的时候进行压缩就非常有必要的。现在的手机像素这么高,拍出来的照片宽高都有几千像素,用 canvas 来渲染这照片的速度会相对比较慢。

因此第一步需要先对上传照片的宽高做限制,判断宽度或高度是否超出哪个范围,则等比压缩其宽高。

var ratio = width / height;
if(imgWidth > imgHeight && imgWidth > xx){
    imgWidth = xx;
    imgHeight = Math.ceil(xx / ratio);
}else if(imgWidth < imgHeight && imgHeight > yy){
    imgWidth = Math.ceil(yy * ratio);
    imgHeight = yy;
}

第二步就通过 canvas.toDataURL() 方法来压缩照片质量。

canvas.toDataURL("image/jpeg", 1);

toDataURL() 方法返回一个包含图片展示的 data URI 。使用两个参数,第一个参数为图片格式,默认为 image/png。第二个参数为压缩质量,在指定图片格式为 image/jpeg 或 image/webp的情况下,可以从 0 到 1 的区间内选择图片的质量。

总结

综合以上,例子的代码包括精简的exif.js库地址:file-demo

主要的核心代码如下:

<input type="file" id="files" >
<img src="blank.gif" id="preview">
<script src="small-exif.js"></script>
<script>
var ipt = document.getElementById('files'),
    img = document.getElementById('preview'),
    Orientation = null;
ipt.onchange = function () {
    var file = ipt.files[0],
        reader = new FileReader(),
        image = new Image();
        
    if(file){
        EXIF.getData(file, function() {  
            Orientation = EXIF.getTag(this, 'Orientation');
        });
        reader.onload = function (ev) {
            image.src = ev.target.result;
            image.onload = function () {
                var imgWidth = this.width,
                    imgHeight = this.height;
                // 控制上传图片的宽高
                if(imgWidth > imgHeight && imgWidth > 750){
                    imgWidth = 750;
                    imgHeight = Math.ceil(750 * this.height / this.width);
                }else if(imgWidth < imgHeight && imgHeight > 1334){
                    imgWidth = Math.ceil(1334 * this.width / this.height);
                    imgHeight = 1334;
                }
                
                var canvas = document.createElement("canvas"),
                ctx = canvas.getContext('2d');
                canvas.width = imgWidth;
                canvas.height = imgHeight;
                if(Orientation && Orientation != 1){
                    switch(Orientation){
                        case 6:     // 旋转90度
                            canvas.width = imgHeight;    
                            canvas.height = imgWidth;    
                            ctx.rotate(Math.PI / 2);
                            // (0,-imgHeight) 从旋转原理图那里获得的起始点
                            ctx.drawImage(this, 0, -imgHeight, imgWidth, imgHeight);
                            break;
                        case 3:     // 旋转180度
                            ctx.rotate(Math.PI);    
                            ctx.drawImage(this, -imgWidth, -imgHeight, imgWidth, imgHeight);
                            break;
                        case 8:     // 旋转-90度
                            canvas.width = imgHeight;    
                            canvas.height = imgWidth;    
                            ctx.rotate(3 * Math.PI / 2);    
                            ctx.drawImage(this, -imgWidth, 0, imgWidth, imgHeight);
                            break;
                    }
                }else{
                    ctx.drawImage(this, 0, 0, imgWidth, imgHeight);
                }
                img.src = canvas.toDataURL("image/jpeg", 0.8);
            }
        }
        reader.readAsDataURL(file);
    }
}
</script>

请教一下

小白提问
如果当前我登录成功了页面要发生跳转,是url跳转并非ajax请求,这时要怎么带token给后台验证啊?

Electron 实战桌面计算器应用

前言

Electron 是一个搭建跨平台桌面应用的框架,仅仅使用 JavaScript、HTML 以及 CSS,即可快速而容易地搭建一个原生应用。这对于想要涉及其他领域的开发者来说是一个非常大的福利。

项目介绍

仓库地址:lin-xin/calculator

我这里通过 Electron 实现了仿 iPhone 的计算器,通过菜单可以切换横屏和竖屏,横屏有更多的运算。而对于 JavaScript 进行浮点数计算来说,精度丢失是个很大问题,所以我这里使用了第三方库 math.js 来解决这个精度的问题。
尽可能的实现了跟 iPhone 一样的运算:

  • 1 + 2 × 3 = 7
  • 3 += 6 (再按 = 等于 9)
  • 0.1 + 0.2 = 0.3 (浮点数精度处理)

Image text
Image text

不过我下面并不是要讲计算器,而是用到的 Electron 的知识点。

生命周期

在主进程中通过 app 模块控制整个应用的生命周期。

当 Electron 完成初始化时触发 ready 事件:

app.on('ready', () => {
    // 创建窗口、加载页面等操作
})

当所有的窗口都被关闭时会触发 window-all-closed 事件:

app.on('window-all-closed', () => {
    if(process.platform !== 'darwin'){
        app.quit();     // 退出应用
    }
})

在开发中发现,没有监听该事件,打包后的应用关闭后,进程还保留着,会占用系统的内存。

窗口

本来我们的 html 只显示在浏览器中,而 electron 提供了一个 BrowserWindow 模块用于创建和控制浏览器窗口,我们的页面就是显示在这样的窗口中。

创建窗口

通过关键字 new 实例化返回 win 对象,该对象有丰富的方法对窗口进行控制。

win = new BrowserWindow({
    width: 390,         // 窗口宽度
    height: 670,        // 窗口高度
    fullscreen: false,  // 不允许全屏
    resizable: false    // 不允许改变窗口size,不然布局就乱了啊
});

加载页面

窗口创建完是一片空白的,可以通过 win.loadURL() 来加载要显示的页面。

const path = require('path');
const url = require('url');

win.loadURL(url.format({    // 加载本地的文件
    pathname: path.join(__dirname, 'index.html'),
    protocol: 'file',
    slashes: true
}))

也可以直接加载远程链接 win.loadURL('http://blog.gdfengshuo.com');

菜单

桌面应用菜单栏是最常见的功能。Electron 提供了 Menu 模块来创建原生的应用菜单和 context 菜单,

const template = [                              // 创建菜单模板
    {
        label: '查看',
        submenu: [
            {label: '竖屏', type: 'radio', checked: true},      // type 属性让菜单为 radio 可选
            {label: '横屏', type: 'radio', checked: false},
            {label: '重载',role:'reload'},
            {label: '退出',role:'quit'},
        ]
    }
]

const menu = Menu.buildFromTemplate(template);  // 通过模板返回菜单的数组
Menu.setApplicationMenu(menu);                  // 将该数组设置为菜单

在子菜单中,通过点击竖屏或横屏来进行一些操作,那就可以给 submenu 监听 click 事件。

const template = [
    {
        label: '查看',
        submenu: [
            {
                label: '横屏'
                click: () => {              // 监听横屏的点击事件
                    win.setSize(670,460);   // 设置窗口的宽高
                }
            }
        ]
    }
]

主进程和渲染进程通信

虽然点击横屏的时候,可以设置窗口的宽高,但是要如何去触发页面里的方法,这里就需要主进程跟渲染进程之间进行通信。

主进程,可以理解为 main.js 用来写 electron api 的就是主进程,渲染进程就是渲染出来的页面。

ipcMain

在主进程中可以使用 ipcMain 模块,它控制着由渲染进程(web page)发送过来的异步或同步消息。

const {ipcMain} = require('electron')
ipcMain.on('send-message', (event, arg) => {
    event.sender.send('reply-message', 'hello world')
})

ipcMain 监听 send-message 事件,当消息到达时可以调用 event.sender.send 来回复异步消息,向渲染进程发送 reply-message 事件,也可以带着参数发送过去。

ipcRenderer

在渲染进程可以调用 ipcRenderer 模块向主进程发送同步或异步消息,也可以收到主进程的相应。

const {ipcRenderer} = require('electron')
ipcRenderer.on('reply-message', (event, arg) => {
    console.log(arg);       // hello world
})

ipcRenderer.send('anything', 'hello everyone');

ipcRenderer 可以监听到来自主进程的 reply-message 事件并拿到参数进行操作,也可以使用 send() 方法向主进程发送消息。

webContents

webContents 是一个事件发出者,它负责渲染并控制网页,也是 BrowserWindow 对象的属性。在 ipcMain 中的 event.sender,返回发送消息的 webContents 对象,所以包含着 send() 方法用于发送消息。

const win = BrowserWindow.fromId(1);        // fromId() 方法找到ID为1的窗口
win.webContents.on('todo', () => {
    win.webContents.send('done', 'well done!')
})

remote

remote 模块提供了一种在渲染进程(网页)和主进程之间进行进程间通讯(IPC)的简便途径。在 Electron 中,有许多模块只存在主进程中,想要调用这些模块的方法需要通过 ipc 模块向主进程发送消息,让主进程调用这些方法。而使用 remote 模块,则可以在渲染进程中调用这些只存在于主进程对象的方法了。

const {remote} = require('electron')
const BrowserWindow = remote.BrowserWindow      // 访问主进程中的BrowserWindow模块

let win = new BrowserWindow();                  // 其他的跟主进程的操作都一样

remote 模块除了可以访问主进程的内置模块,自身还有一些方法。

remote.require(module)          // 返回在主进程中执行 require(module) 所返回的对象
remote.getCurrentWindow()       // 返回该网页所属的 BrowserWindow 对象
remote.getCurrentWebContents()  // 返回该网页的 WebContents 对象
remote.getGlobal(name)          // 返回在主进程中名为 name 的全局变量(即 global[name])
remote.process                  // 返回主进程中的 process 对象,等同于 remote.getGlobal('process') 但是有缓存

shell 模块

使用系统默认应用管理文件和 URL,而且在主进程和渲染进程中都可以用到该模块。在菜单中,我想点击子菜单打开一个网站,那么就可以用到 shell.openExternal() 方法,则会在默认浏览器中打开 URL

const {shell} = require('electron');
shell.openExternal('https://github.com/lin-xin/calculator');

打包应用

其实将程序打包成桌面应用才是比较麻烦的事。我这里尝试了 electron-packager 和 electron-builder。

electron-packager

electron-packager 可以将项目打包成各平台可直接运行的程序,而不是安装包。

先使用 npm 安装: npm install electron-packager -S

运行打包命令:

electron-packager ./ 计算器 --platform=win32 --overwrite --icon=./icon.ico

打包会把项目文件包括 node_modules 也一起打包进去,当然可以通过 --ignore=node_modules 来忽略文件,但是如果项目中有用到第三方库,忽略的话则找不到文件报错了。

正确的做法就是严格区分 dependencies 和 devDependencies,打包的时候只会把 dependencies 的库打包,而使用 cnpm 安装的会有一大堆 .0.xx@xxx 的文件,也会被打包,所以最好不要用 cnpm

electron-builder

electron-builder 是基于 electron-packager 打包出来的程序再做安装处理,将项目打包成安装文件。

安装:npm install electron-builder -S

打包:electron-builder --win

打包过程中,第一次下载 electron 可能会出现连接超时,可以使用 yarn 试试。还有 winCodeSign 和 nsis-resources 也可能会失败,可以参考 electron-builder/issues 解决。

总结

Electron 用起来还是相对容易的,可以创建个简单的桌面应用,只是打包的过程比较容易遇到问题,网上好像也有一键打包的工具,没尝试过。以上也都是基于 windows 7 的实践,毕竟没有 Mac 搞不了。

Vue.js 插件开发详解

前言

随着 Vue.js 越来越火,Vue.js 的相关插件也在不断的被贡献出来,数不胜数。比如官方推荐的 vue-router、vuex 等,都是非常优秀的插件。但是我们更多的人还只停留在使用的阶段,比较少自己开发。所以接下来会通过一个简单的 vue-toast 插件,来了解掌握插件的开发和使用。

认识插件

想要开发插件,先要认识一个插件是什么样子的。

Vue.js 的插件应当有一个公开方法 install 。这个方法的第一个参数是 Vue 构造器 , 第二个参数是一个可选的选项对象:

MyPlugin.install = function (Vue, options) {
  Vue.myGlobalMethod = function () {  // 1. 添加全局方法或属性,如: vue-custom-element
    // 逻辑...
  }
  Vue.directive('my-directive', {  // 2. 添加全局资源:指令/过滤器/过渡等,如 vue-touch
    bind (el, binding, vnode, oldVnode) {
      // 逻辑...
    }
    ...
  })
  Vue.mixin({
    created: function () {  // 3. 通过全局 mixin方法添加一些组件选项,如: vuex
      // 逻辑...
    }
    ...
  })
  Vue.prototype.$myMethod = function (options) {  // 4. 添加实例方法,通过把它们添加到 Vue.prototype 上实现
    // 逻辑...
  }
}

接下来要讲到的 vue-toast 插件则是通过添加实例方法实现的。我们先来看个小例子。先新建个js文件来编写插件:toast.js

// toast.js
var Toast = {};
Toast.install = function (Vue, options) {
    Vue.prototype.$msg = 'Hello World';
}
module.exports = Toast;

在 main.js 中,需要导入 toast.js 并且通过全局方法 Vue.use() 来使用插件:

// main.js
import Vue from 'vue';
import Toast from './toast.js';
Vue.use(Toast);

然后,我们在组件中来获取该插件定义的 $msg 属性。

// App.vue
export default {
    mounted(){
        console.log(this.$msg);         // Hello World
    }
}

可以看到,控制台成功的打印出了 Hello World 。既然 $msg 能获取到,那么我们就可以来实现我们的 vue-toast 插件了。

开发 vue-toast

需求:在组件中通过调用 this.$toast('网络请求失败') 来弹出提示,默认在底部显示。可以通过调用 this.$toast.top() 或 this.$toast.center() 等方法来实现在不同位置显示。

整理一下思路,弹出提示的时候,我可以在 body 中添加一个 div 用来显示提示信息,不同的位置我通过添加不同的类名来定位,那就可以开始写了。

// toast.js
var Toast = {};
var showToast = false;  // showToast 用于存储toast的显示状态
Toast.install = function (Vue, options) {
    Vue.prototype.$toast = (tips) => {
        let toastTpl = Vue.extend({     // 1、创建构造器,定义好提示信息的模板
            data(){
                return {
                    show: showToast
                }
            },
            template: '<div v-show="show" class="vue-toast">' + tips + '</div>'
        });
        var vm = new toastTpl();        // 2、创建实例
        var tpl = vm.$mount().$el;      // 3、挂载实例
        
        document.body.appendChild(tpl); // 4、使用原生DOM API把它插入文档中
        vm.show = showToast = true;     // 5、显示该元素
        
        setTimeout(function () {        // 6、延迟2.5秒后隐藏该元素
            vm.show = showToast = false;
        }, 2500)
    }
}
module.exports = Toast;

好像很简单,我们就实现了 this.$toast() ,接下来显示不同位置。

// toast.js
['bottom', 'center', 'top'].forEach(type => {
    Vue.prototype.$toast[type] = (tips) => {
        return Vue.prototype.$toast(tips,type)
    }
})

这里把 type 传给 $toast 在该方法里进行不同位置的处理,上面说了通过添加不同的类名(toast-bottom、toast-top、toast-center)来实现,那 $toast 方法需要小小修改一下。

Vue.prototype.$toast = (tips,type) => {     // 添加 type 参数
    let toastTpl = Vue.extend({             // 模板添加位置类
        data(){
            return {
                show: showToast
            }
        },
        template: '<div v-show="show" class="vue-toast toast-'+ type +'">' + tips + '</div>'
    });
    ...
}

好像差不多了。但是如果我想默认在顶部显示,我每次都要调用 this.$toast.top() 好像就有点多余了,我能不能 this.$toast() 就直接在我想要的地方呢?还有我不想要 2.5s 后才消失呢?这时候注意到 Toast.install(Vue,options) 里的 options 参数,我们可以在 Vue.use() 通过 options 传进我们想要的参数。最后修改插件如下:

var Toast = {};
var showToast = false;
Toast.install = function (Vue, options) {
    let opt = {
        defaultType:'bottom',   // 默认显示位置
        duration:'2500'         // 持续时间
    }
    for(let property in options){
        opt[property] = options[property];  // 使用 options 的配置
    }
    Vue.prototype.$toast = (tips,type) => {
        if(type){
            opt.defaultType = type;         // 如果有传type,位置则设为该type
        }
        if(showToast){
            // 如果toast还在,则不再执行
            return;
        }
        let toastTpl = Vue.extend({
            data(){
                return {
                    show: showToast
                }
            },
            template: '<div v-show="show" class="vue-toast toast-'+opt.defaultType+'">' + tips + '</div>'
        });
        var vm = new toastTpl()
        var tpl = vm.$mount().$el;
        
        document.body.appendChild(tpl);
        vm.show = showToast = true;
        
        setTimeout(function () {
            vm.show = showToast = false;
        }, opt.duration)
    }
    ['bottom', 'center', 'top'].forEach(type => {
        Vue.prototype.$toast[type] = (tips) => {
            return Vue.prototype.$toast(tips,type)
        }
    })
}
module.exports = Toast;

这样子一个简单的 vue 插件就实现了,并且可以通过 npm 打包发布,下次就可以使用 npm install 来安装了。

vue-manage-system 后台管理系统开发总结

前言

vue-manage-system,一个基于 Vue.js 和 element-ui 的后台管理系统模板,从2016年年底第一个commit,到现在差不多两年了,GitHub上也有了 5k star,也是这些让我有了持续更新的动力,其中也踩了很多坑,在这总结一下。

github地址:vue-manage-system

线上地址:blog.gdfengshuo.com/example/work/

自定义图标

element-ui 自带的字体图标比较少,而且许多比较常见的都没有,因此需要自己引入自己想要的字体图标。最受欢迎的图标库 Font Awesome,足足有 675 个图标,但也因此导致字体文件比较大,而项目中又不需要用到这么多图标。那么这时候,阿里图标库就是一个非常不错的选择。

首先在阿里图标上创建一个项目,设置图标前缀,比如 el-icon-lx,设置Font Family,比如 lx-iconfont,添加需要用到的图标到项目中,我这边选择 Font class 生成在线链接,因为所有页面都需要用到图标,就直接在 index.html 中引入该css链接就行了

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>vue-manage-system</title>
    <!-- 这里引入阿里图标样式 -->
    <link rel="stylesheet" href="//at.alicdn.com/t/font_830376_qzecyukz0s.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

然后需要设置前缀为 el-icon-lx 的图标类名使用 lx-iconfont 字体。

[class*="el-icon-lx"], [class^=el-icon-lx] {
    font-family: lx-iconfont!important;
}

但是这个样式要放在哪里才可以呢?这可不是随便放就行的。在 main.js 中,引入了 element-ui 的样式,而样式中有这样的一段css:

[class*=" el-icon-"], [class^=el-icon-]{
    font-family: element-icons!important;
    speak: none;
    font-style: normal;
    font-weight: 400;
    font-variant: normal;
    text-transform: none;
    line-height: 1;
    vertical-align: baseline;
    display: inline-block;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

很明显,如果这段 css 在我们自定义样式后面才执行,就会覆盖了我们的样式,那自定义的图标就显示不了。而在 build 项目的时候,会把 APP.vue 中的的样式打包进 app.css 中,然后再把 main.js 中引用到的样式追加到后面。那么我们可以把自定义样式放到一个css文件中,然后在 main.js 引入 element-ui css 的后面引入,那就可以覆盖掉默认字体了,然后便可以在项目中通过 <i class="el-icon-lx-people"></i> 使用图标了。

那机智的人就发现了,我自定义图标的前缀不要含 el-icon- 就不会有这样的问题了。是的,那么为了和原有字体保持一样的样式,需要复制它的整段css

/* 假设前缀为 el-lx */
[class*="el-lx-"], [class^=el-lx-]{
    font-family: lx-iconfont!important;
    speak: none;
    font-style: normal;
    font-weight: 400;
    font-variant: normal;
    text-transform: none;
    line-height: 1;
    vertical-align: baseline;
    display: inline-block;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

导航菜单

element-ui 关于导航菜单的文档也是非常详细了,但是还是有人提 issue 或者加 QQ 问我:三级菜单怎么弄等等。而且具体的菜单项可能是服务器端根据权限而返回特定的数据项,因此不能写死在模板中。

首先定好菜单数据的格式如下,即使服务器端返回的格式不是这样,也需要前端处理成下面的格式:

export default {
    data() {
        return {
            items: [{
                icon: 'el-icon-lx-home',
                index: 'dashboard',
                title: '系统首页'
            },{
                icon: 'el-icon-lx-calendar',
                index: '1',
                title: '表单相关',
                subs: [{
                    index: '1-1',
                    title: '三级菜单',
                    subs: [{
                        index: 'editor',
                        title: '富文本编辑器'
                    }]
                }]
            },{
                icon: 'el-icon-lx-warn',
                index: '2',
                title: '错误处理',
                subs: [{
                    index: '404',
                    title: '404页面'
                }]
            }]
        }
    }
}

icon 就是菜单图标,就可以用到我们上面自定义的图标了;index 就是路由地址;title 就是菜单名称;subs 就是子菜单了。而模板则通过判断菜单中是否包含 subs 从而显示二级菜单和三级菜单。

<el-menu :default-active="onRoutes" :collapse="collapse" router>
    <template v-for="item in items">
        <template v-if="item.subs">
            <el-submenu :index="item.index" :key="item.index">
                <template slot="title">
                    <i :class="item.icon"></i><span slot="title">{{ item.title }}</span>
                </template>
                <template v-for="subItem in item.subs">
                    <el-submenu v-if="subItem.subs" :index="subItem.index" :key="subItem.index">
                        <template slot="title">{{ subItem.title }}</template>
                        <!-- 三级菜单 -->
                        <el-menu-item v-for="(threeItem,i) in subItem.subs" :key="i" :index="threeItem.index">
                            {{ threeItem.title }}
                        </el-menu-item>
                    </el-submenu>
                    <el-menu-item v-else :index="subItem.index" :key="subItem.index">
                        {{ subItem.title }}
                    </el-menu-item>
                </template>
            </el-submenu>
        </template>
        <!-- 没有二级菜单 -->
        <template v-else>
            <el-menu-item :index="item.index" :key="item.index">
                <i :class="item.icon"></i><span slot="title">{{ item.title }}</span>
            </el-menu-item>
        </template>
    </template>
</el-menu>

这样就完成了一个动态的导航菜单。

通过 Header 组件中的一个按钮来触发 Sidebar 组件展开或收起,涉及到了组件之间传递数据,这里通过 Vue.js 单独的事件中心(Event Bus)管理组件间的通信。

const bus = new Vue();

在 Header 组件中点击按钮时触发 collapse 事件:

bus.$emit('collapse', true);

在 Sidebar 组件中监听 collapse 事件:

bus.$on('collapse', msg => {
    this.collapse = msg;
})

图表自适应

vue-manage-system 中用到的图表插件是 vue-schart,是把一个基于 canvas 的图表插件 schart.js 进行了封装。要做到图表能够自适应宽度,随着 window 或者父元素的大小改变而重新渲染,如果图表插件里没实现该功能,就需要自己手动实现。

vue-schart 中提供了 renderChart() 的方法可以重新渲染图表,Vue.js 中父组件调用子组件的方法,可以通过 $refs 进行调用。

<schart ref="bar" canvasId="bar" :data="data" type="bar" :options="options"></schart>

然后监听 window 的 resize 事件,调用 renderChart() 方法重新渲染图表。

import Schart from 'vue-schart';
export default {
    components: {
        Schart
    },
    mounted(){
        window.addEventListener('resize', ()=>{
            this.$refs.bar.renderChart();
        })
    }
}

不过也要记得组件销毁时移除监听哦!监听窗口大小改变完成了,那父元素大小改变呢?因为父元素宽度设为百分比,当侧边栏折叠的时候,父元素的宽度发生了变化。但是 div 并没有 resize 事件,无法监听到它的宽度改变,但是触发折叠的时候,我们是知道的。那么是否可以通过监听到折叠变化的时候,再调用渲染函数重新渲染图表呢?那么还是通过 Event Bus 监听侧边栏的改变,并在 300ms 后重新渲染,因为折叠时候有 300ms 的动画过程

bus.$on('collapse', msg => {
    setTimeout(() => {
        this.$refs.bar.renderChart();
    }, 300);
});

多标签页

多标签页,也是提 issue 最多的一个功能。

当在 A 标签页输入一些内容之后,打开 B 标签再返回到 A,要保留离开前的状态,因此需要使用 keep-alive 进行缓存,而且关闭之后的标签页就不再缓存,避免关闭后再打开还是之前的状态。keep-alive 的属性 include 的作用就是只有匹配的组件会被缓存。include 匹配的不是路由名,而是组件名,那么每个组件都需要添加 name 属性。

在 Tags 组件中,监听路由变化,将打开的路由添加到标签页中:

export default {
    data() {
        return {
            tagsList: []
        }
    },
    methods: {
        setTags(route){
            const isExist = this.tagsList.some(item => {
                return item.path === route.fullPath;
            })
            if(!isExist){
                this.tagsList.push({
                    title: route.meta.title,
                    path: route.fullPath,
                    name: route.matched[1].components.default.name
                })
            }
        }
    },
    watch:{
        $route(newValue, oldValue){
            this.setTags(newValue);
        }
    }
}

在 setTags 方法中,将一个标签对象存到标签数组中,包括title(标签显示的title),path(标签的路由地址),name(组件名,用于include匹配的)。路由地址需要用 fullPath 字段,如果使用 path 字段,那如果地址后面带有参数,就都没保存起来了。

在 Home 组件中,监听到标签的变化,缓存需要的组件。

<keep-alive :include="tagsList">
    <router-view></router-view>
</keep-alive>
export default {
    data(){
        return {
            tagsList: []
        }
    },
    created(){
        // 只有在标签页列表里的页面才使用keep-alive,即关闭标签之后就不保存到内存中了。
        bus.$on('tags', msg => {
            let arr = [];
            for(let i = 0, len = msg.length; i < len; i ++){
                // 提取组件名存到tagsList中,通过include匹配
                msg[i].name && arr.push(msg[i].name);
            }
            this.tagsList = arr;
        })
    }
}

当我在提交表单之后,想关闭当前的标签页,不要手动去点,那要如何实现呢?

这里依然是使用 Event Bus,在 Tags.vue 中监听 close_current_tags 事件,当我在表单提交时触发该事件,完成关闭当前标签页的操作。

在 Tags 组件中:

import bus from './bus';
export default {
    data() {
        return {
            tagsList: []
        }
    },
    created(){
        this.setTags(this.$route);
        // 监听关闭当前页面的标签页
        bus.$on('close_current_tags', () => {
            for (let i = 0, len = this.tagsList.length; i < len; i++) {
                const item = this.tagsList[i];
                if(item.path === this.$route.fullPath){
                    if(i < len - 1){
                        this.$router.push(this.tagsList[i+1].path);
                    }else if(i > 0){
                        this.$router.push(this.tagsList[i-1].path);
                    }else{
                        this.$router.push('/');
                    }
                    this.tagsList.splice(i, 1);
                    break;
                }
            }
        })
    }
}

在 BaseForm 组件中触发提交时:

import bus from './bus';
export default {
    methods: {
        onSubmit() {
            bus.$emit('close_current_tags');
        }
    }
}

只需要在你想要关闭当前标签页的地方触发 close_current_tags 即可。

总结

由于该项目中不包含任何业务代码,所以还是相对比较简单的,不过从开发中还是积累了一些经验,在其它项目中可以更加熟练地开发。功能虽然不算多,但是也勉强够用,如果有什么好的建议,可以开 issue 一起讨论。

vue项目中实现Excel文件的上传和下载

前言

在后台管理项目中,Excel文件的上传和下载是比较常见的需求,可用于批量导入和导出数据。虽然这只是一个平平无奇的需求,但是对于作为一名前端开发者来说,有2种实现方式,分别为复杂和简单。首先来说下复杂的实现方式。

上传

xlsx.js 是一个用来转换 BASE64 编码的 XLSX 文件数据为 JavaScript 对象,也支持 JavaScript 对象到 XLSX 数据的转换。前端使用 xlsx.js 来解析 Excel,把解析后的数据根据服务器需要的格式进行处理后上传给服务器,可以减少服务器的处理工作,在并发量大的时候,把数据处理放大前端,是有利于服务器性能的。先安装xlsx,yarn add xlsx

<el-upload
	action="#"
	:limit="1"
	accept=".xlsx, .xls"
	:show-file-list="false"
	:before-upload="beforeUpload"
	:http-request="handleUpload"
>
	<el-button type="success" plain>上传Excel</el-button>
</el-upload>
<el-link href="/template.xlsx" target="_blank">下载Excel模板</el-link>

我们期望用户能按照我们想要的格式上传文件,所以需要给用户一个模板可以参考,只需要把模板文件 template.xlsx 放到 /public 目录下即可。

<script setup lang="ts">
import { UploadProps } from 'element-plus';
import * as XLSX from 'xlsx';

const arr = ref<any>([]);
const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => {
	// 3.拿到了excel文件中的数据存到数组中
    arr.value = await analysisExcel(rawFile);
    return true;
};
const analysisExcel = (file: File) => {
    return new Promise(function (resolve, reject) {
        const reader = new FileReader();
        reader.onload = function (e: ProgressEvent<FileReader>) {
            const data = e.target && e.target.result;
			// 2.data为readAsBinaryString转成的值,所以type需要保持一致
			//返回的datajson为WordBook对象
            let datajson = XLSX.read(data, {
                type: 'binary',
            });

            const sheetName = datajson.SheetNames[0];
            const result = XLSX.utils.sheet_to_json(datajson.Sheets[sheetName]);
            resolve(result);
        };
		// 1.读取文件为二进制格式
        reader.readAsBinaryString(file);
    });
};

const handleUpload = async () => {
	//4. 把数组传给服务器
   await axios.post('/api/xxx', {data: arr.value})
};
</script>

根据以上几个步骤,便可以把excel文件的内容提取出来并上传。

下载

上面说到 xlsx.js 也支持把 JS 对象转成 XLSX 数据,依然是从服务器返回数据给前端,前端再生成 Excel 文件进行下载。

const downloadExcel = async () => {
    const res = await axios.post('/api/xxx');
	const list = res.data; // 这里数据需要怎么处理就处理
	const WorkSheet = XLSX.utils.aoa_to_sheet(list);
	const new_workbook = XLSX.utils.book_new();
	XLSX.utils.book_append_sheet(new_workbook, WorkSheet, '第一页');
	XLSX.writeFile(new_workbook, `下载.xlsx`);
};

总结

以上就是复杂的实现方式,把文件处理部分放在了前端实现。那么简单的实现方式便是把处理逻辑交给服务器,前端打开掘金沸点,继续当一个快乐的摸鱼人。上传时只需要把文件上传给服务器,下载时服务器返回一个excel文件的下载地址,简单几行代码便完成需求,继续在等服务器接口。

讲真的,像这种前后端都可实现的需求,我们就要站在团队的角度去看待了,不要为了自身减少工作量,把事情都推给另外一端。把自己当成团队的老大,甚至是公司老板,站在更高的角度,为公司的最大利益化去思考每一件事情,有了这样的思维,我们肯定能把事情做好,而且很快,老板又可以换房换车了。

JavaScript 中 apply 、call 的详解

apply 和 call 的区别

ECMAScript 规范给所有函数都定义了 call 与 apply 两个方法,它们的应用非常广泛,它们的作用也是一模一样,只是传参的形式有区别而已。

apply( )

apply 方法传入两个参数:一个是作为函数上下文的对象,另外一个是作为函数参数所组成的数组。

var obj = {
    name : 'linxin'
}

function func(firstName, lastName){
    console.log(firstName + ' ' + this.name + ' ' + lastName);
}

func.apply(obj, ['A', 'B']);    // A linxin B

可以看到,obj 是作为函数上下文的对象,函数 func 中 this 指向了 obj 这个对象。参数 A 和 B 是放在数组中传入 func 函数,分别对应 func 参数的列表元素。

call( )

call 方法第一个参数也是作为函数上下文的对象,但是后面传入的是一个参数列表,而不是单个数组。

var obj = {
    name: 'linxin'
}

function func(firstName, lastName) {
    console.log(firstName + ' ' + this.name + ' ' + lastName);
}

func.call(obj, 'C', 'D');       // C linxin D

对比 apply 我们可以看到区别,C 和 D 是作为单独的参数传给 func 函数,而不是放到数组中。

对于什么时候该用什么方法,其实不用纠结。如果你的参数本来就存在一个数组中,那自然就用 apply,如果参数比较散乱相互之间没什么关联,就用 call。

apply 和 call 的用法

1.改变 this 指向

var obj = {
    name: 'linxin'
}

function func() {
    console.log(this.name);
}

func.call(obj);       // linxin

我们知道,call 方法的第一个参数是作为函数上下文的对象,这里把 obj 作为参数传给了 func,此时函数里的 this 便指向了 obj 对象。此处 func 函数里其实相当于

function func() {
    console.log(obj.name);
}

2.借用别的对象的方法

先看例子

var Person1  = function () {
    this.name = 'linxin';
}
var Person2 = function () {
    this.getname = function () {
        console.log(this.name);
    }
    Person1.call(this);
}
var person = new Person2();
person.getname();       // linxin

从上面我们看到,Person2 实例化出来的对象 person 通过 getname 方法拿到了 Person1 中的 name。因为在 Person2 中,Person1.call(this) 的作用就是使用 Person1 对象代替 this 对象,那么 Person2 就有了 Person1 中的所有属性和方法了,相当于 Person2 继承了 Person1 的属性和方法。

3.调用函数

apply、call 方法都会使函数立即执行,因此它们也可以用来调用函数。

function func() {
    console.log('linxin');
}
func.call();            // linxin

call 和 bind 的区别

在 EcmaScript5 中扩展了叫 bind 的方法,在低版本的 IE 中不兼容。它和 call 很相似,接受的参数有两部分,第一个参数是是作为函数上下文的对象,第二部分参数是个列表,可以接受多个参数。
它们之间的区别有以下两点。

1.bind 发返回值是函数

var obj = {
    name: 'linxin'
}

function func() {
    console.log(this.name);
}

var func1 = func.bind(obj);
func1();                        // linxin

bind 方法不会立即执行,而是返回一个改变了上下文 this 后的函数。而原函数 func 中的 this 并没有被改变,依旧指向全局对象 window。

2.参数的使用

function func(a, b, c) {
    console.log(a, b, c);
}
var func1 = func.bind(null,'linxin');

func('A', 'B', 'C');            // A B C
func1('A', 'B', 'C');           // linxin A B
func1('B', 'C');                // linxin B C
func.call(null, 'linxin');      // linxin undefined undefined

call 是把第二个及以后的参数作为 func 方法的实参传进去,而 func1 方法的实参实则是在 bind 中参数的基础上再往后排。

在低版本浏览器没有 bind 方法,我们也可以自己实现一个。

if (!Function.prototype.bind) {
        Function.prototype.bind = function () {
            var self = this,                        // 保存原函数
                context = [].shift.call(arguments), // 保存需要绑定的this上下文
                args = [].slice.call(arguments);    // 剩余的参数转为数组
            return function () {                    // 返回一个新函数
                self.apply(context,[].concat.call(args, [].slice.call(arguments)));
            }
        }
    }

Parcel:常见技术栈的集成方式

前言

Parcel 是什么

Parcel 是一个前端构建工具,Parcel 官网 将它定义为极速零配置的Web应用打包工具。没错,又是一个构建工具,你一定会想,为什么前端的构建工具层出不穷,搞那么多工具又要花时间去学习,真的有意义吗?在 webpack 已经成为前端构建工具主流的今天,一个新的工具能有什么优势来站稳脚跟呢?

为什么要用 Parcel

一个好的打包工具在前端工程中占着比较重要的地位。然,何谓之好?或功能强大,或简单易用,或提高效率,或适合自己。在时代不断发展中,一个个好的工具正在被一个更好的工具所替代。随着对 webpack 复杂配置的吐槽声越来越多,Parcel 打着 "快速、零配置" 的旗子出来了。

Parcel 的特性

  • 快速打包:启用多核编译,并具有文件系统缓存
  • 打包所有资源:支持JS,CSS,HTML,文件资源等等 - 不需要安装任何插件
  • 自动转换:使用 Babel,PostCSS 和 PostHTML 自动转换
  • 零配置代码拆分:使用动态 import() 语法拆分您的输出包,只加载初始加载时所需的内容
  • 模块热替换:不需要进行任何配置
  • 友好的错误记录:以语法高亮的形式打印的代码帧,以帮助你查明问题

如何使用

快速使用

全局安装 npm i parcel-bundler -gyarn add parcel-bundler global

Parcel 使用一个文件作为入口,最好是 HTML 或 JavaScript 文件,我们在项目中新建 index.html 文件,直接运行命令 parcel index.html 即可启动本地服务器

在浏览器中访问 http://localhost:1234/ ,可以通过 parcel index.html -p 8888 重新设置端口号。

无需配置文件!

Parcel 支持 CommonJS 模块语法、ES6 模块语法、在 js 文件中导入 node 模块或 css、在 css 中使用 import 等,也都无需配置文件!

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Parcel</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <h1>Hello Parcel</h1>
        <script src="src/js/index.js"></script>
    </body>
</html>
// src/js/index.js

const main1 = require('./main1.js');	// 支持 CommonJS 模块语法
import main2 from './main2.js';			// 支持 ES6 模块语法
import '../css/index.css';				// 支持在 js 中导入 css

main1();
main2();

上面只是简单的使用了 Parcel,但在实际项目中,我们会用到各种技术栈,下面我们来看看 Parcel 如何集成各种技术栈的。

注意:Parcel 里使用了 async await,因此需要 node 7.6 以上的版本才支持

集成技术栈

首先在项目下创建 package.json 、.babelrc、以及 index-react.html、index-vue.html、index-ts.html 三个作为各自技术栈 demo 的入口文件。

在 package.json 中添加以下命令

"scripts": {
  "react": "parcel index-react.html",
  "vue": "parcel index-vue.html",
  "ts": "parcel index-ts.html"
}

React

安装 React 的相关依赖 npm i -S parcel-bundler react react-dom babel-preset-env babel-preset-react

在 .babelrc 中添加

{
  "presets": ["env","react"]
}

这就是上面讲到的 Parcel 的特性:自动转换。该文件是让 Parcel 自动转换 ES6 和 React JSX。

<!-- index-react.html -->
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Parcel React</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <div id="react-app"></div>
        <script src="src/react/index.js"></script>
    </body>
</html>
// src/react/index.js
import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class Hello extends Component {
    render() {
        return <h1>Hello React</h1>;
    }
}

ReactDOM.render(<Hello />, document.getElementById('react-app'));

运行命令 npm run react 打开 http://localhost:1234/ 即可看到 Hello React

Vue

就在不久前,Parcel 终于支持 .vue 文件了,只需要引入一个包 parcel-plugin-vue,不需要任何配置,即可打包 Vue 了。

安装 Vue 相关依赖,npm i -S vue parcel-plugin-vue

<!-- index-vue.html -->
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Parcel Vue</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <div id="vue-app"></div>
        <script src="src/vue/index.js"></script>
    </body>
</html>
// src/vue/index.js
import Vue from 'vue';
import App from './app.vue';

new Vue({
    el: '#vue-app',
    render: h => h(App)
})
<!-- src/vue/app.vue -->
<template>
    <div>
        <h1>Hello Vue</h1>
    </div>
</template>

运行命令 npm run vue 打开 http://localhost:1234/ 即可看到 Hello Vue

TypeScript

集成 TypeScript 也非常简单,只需要安装 typescript 模块即可,也无需配置。

安装 TypeScript 相关依赖,npm i -S typescript

<!-- index-ts.html -->
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Parcel TypeScript</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <h1 id="ts-app"></h1>
        <script src="src/typescript/index.ts"></script>
    </body>
</html>
interface Name {
    value: string;
}
function showName(name: Name){
    document.getElementById('ts-app').innerHTML = 'Hello ' + name.value;
}

showName({value: 'TypeScript'});

运行命令 npm run ts 打开 http://localhost:1234/ 即可看到 Hello TypeScript

Sass

将 Sass 在上面技术栈中使用也非常简单,只需要安装 node-sass 模块即可,也无需配置。

安装 Sass 相关依赖,npm 可能会下载不成功,这里使用 cnpm 来安装,cnpm i -S node-sass

在 src/vue/app.vue 中来使用 Sass

<!-- src/vue/app.vue -->
<template>
    <div class="main">
        <h1>Hello Vue</h1>
    </div>
</template>

<style lang="scss">
    @import '../sass/main.scss';
</style>
.main{
    h1{
        color: #0099ff;
    }
}

再次运行命令 npm run vue 即可看到带有蓝色字体的 Hello Vue

以上的 demo 源码地址:parcel-demo

生产环境

  • 设置环境变量parcel build index.html NODE_ENV=production
  • 设置输出目录parcel build index.html -d build/output
  • 设置要提供服务的公共 URLparcel build index.html --public-url ./
  • 禁用压缩parcel build index.html --no-minify
  • 禁用文件系统缓存parcel build index.html --no-cache

疑问

  • 输出目录里是否可以再分子目录,例如 css / js / img 等?
  • 页面引用的 html 被打包后也会重命名成很长的一串,是否可以不重命名?

前端情报局

鉴于最近 Parcel 打着零配置的口号俘获了不少前端开发者的心,并且伴随着吐槽 webpack 使用配置复杂的声音。webpack 核心开发者特意解释道,webpack v4.0.0-alpha.1 中加入了 mode 这个配置,这使得很多复杂繁琐的配置(诸如: sourcemaps、 tree shaking,、minification、scope hoisting)webpack 都替我们做好了,对于使用者来说,基本上也是零配置了。

基于vue2.0+vuex+localStorage开发的本地记事本

项目地址

功能说明

  • 支持回车添加事件
  • 支持事件状态切换
    • 添加事件 -> 进入未完成列表
    • 未完成 -> 已完成(勾选checkbox)
    • 未完成 -> 已取消(点击取消按钮)
    • 已完成 -> 未完成(取消勾选checkbox)
    • 已取消 -> 未完成(点击恢复按钮)
  • 支持下载数据到notepad.txt文件
  • 支持筛选事件
  • 支持编辑事件
  • 支持删除事件
  • 支持清空所有事件
  • 支持本地化存储
  • 支持折叠面板

项目笔记

本项目是使用vue-cli脚手架生成的项目,项目代码可以到我的github上clone下来。clone下来之后可进入文件目录

// 执行
npm install
// 安装依赖完成之后再执行
npm run dev
// 即可在本地开启 http://localhost:8080 访问该项目

// 如果 node-sass 安装失败,可使用 cnpm 安装
npm install cnpm -g --registry=https://registry.npm.taobao.org
cnpm -v 			// 查看cnpm版本号确认安装成功
cnpm install node-sass -D

//安装成功后再看看是否可以正确运行了

二、主要难点

1.折叠面板

难点:点击折叠面板title,要动画实现sliderUp和sliderDown,但是div高度auto,使用transition: height .3s无效。

解决方法:点击时候获取div高度值,赋值给style.height,然后再改变高度为0,这样transition才会生效。

代码如下:

<template>
	<div id="app">
		<div class="event-tab" @click="changeCollapse(0,$event)">未完成</div>
		<div class="event-box" :style="{'height':'auto','display':'block'}">
	        <ul>
	            <li class="event-list" v-for="value in getToDo">
	                <div>{{value.content}}</div>
	            </li>
	        </ul>
		</div>
	</div>
</template>
<script>
	export default {
        data(){
            return {
                collapse:[
                    {
                        show: true,  					// show == true, 表示当前折叠面板显示
                    }
                ]
			}
		},
		methods:{
			changeCollapse(num,event){    				// 根据折叠面板当前状态进行显示或折叠
                const show = this.collapse[num].show;
                if (show) {
                    this.closeCollapse(event);
                } else {
                    this.openCollapse(event);
                }
                this.collapse[num].show = !show;
            },
            closeCollapse(num,event){					// closeCollapse,关闭折叠面板
                let ulElement = event.currentTarget.nextElementSibling,
                    children = ulElement.getElementsByTagName('ul')[0];
                ulElement.style.height = children.offsetHeight + 'px';
                setTimeout(function(){
                    ulElement.style.height = '0px';
                    setTimeout(function () {
                        ulElement.style.display = 'none';
                    }, 300)
                },10)
            },
            openCollapse(num,event){					// openCollapse,显示折叠面板
                let ulElement = event.currentTarget.nextElementSibling,
                    children = ulElement.getElementsByTagName('ul')[0];
                ulElement.style.display = 'block';
                ulElement.style.height = children.offsetHeight + 'px';
                setTimeout(function () {
                    ulElement.style.height = 'auto';
                }, 300)
            }
		}
	}
</script>
<style lang="scss" rel="stylesheet/scss">
	ul.event-box{
        list-style: none;
        overflow: hidden;
        border:{
            left:1px solid #eee;
            right:1px solid #eee;
        }
        transition: height .3s;							// transition,添加折叠或显示时的动画效果
	}
</style>

2.切换状态

难点:在不同的状态间切换,实时地把事件在不同状态列表中显示出来

解决方法:利用vuex进行状态管理,把所有事件和状态存储在store对象中,在组件中通过计算属性获得事件,因此就有了实时性。

代码如下:

// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions.js';
Vue.use(Vuex);
const state = {
    event: []  // event,用来存储所有事件
}
const mutations = {
    EVENTDONE(states,obj){  // EVENTDONE,用来修改事件的状态为已完成
        for (let i = 0; i < states.event.length; i++) {
            if (states.event[i].id === obj.id) {
                states.event[i].type = 2;   // type == 2,表示状态为已完成
                break;
            }
        }
    }
}
export default new Vuex.Store({
    state,
    actions,
    mutations
})

// store/actions.js
export const eventdone = ({ commit }, param) =>{
    commit('EVENTDONE',{id: param});
}

// App.vue
<template>
	<div id="app">
        <ul class="event-box">
            <li class="event-list" v-for="value in getToDo">
                <input type="checkbox" @click="moveToDone(value.id,$event)">
                <div>{{value.content}}</div>
            </li>
        </ul>
	</div>
</template>
<script>
	export default {
		computed:{
			getToDo(){    // getToDo,实时获取状态为未完成的事件
                return this.$store.state.event.filter(function(d){
                    if(d.type === 1){   // type == 1,表示状态为未完成
                        return d;
                    }
                });
            }
		},
		methods:{
			moveToDone(id,event){  // moveToDone,选中checkbox将事件移至已完成
                this.$store.dispatch('eventdone',id);
            }
		}
	}
</script>

3.本地存储

知识点:localStorage是HTML5提供的一种在客户端存储数据的新方法,没有时间限制,第二天、第二周或下一年之后,数据依然可用。

用法:

1)存储数据:localStorage.setItem(item, value)
2)获取数据:localStorage.getItem(item)
3)移除数据:localStorage.removeItem(item)

代码如下:

// store/index.js
const LocalEvent = function(item){     		// 定义一个本地存储的构造函数
    this.get = function () {				// 拿数据
        return JSON.parse(localStorage.getItem(item));
    }
    this.set = function (obj) {				// 存数据
        localStorage.setItem(item,JSON.stringify(obj));
    }
    this.clear = function () {				// 删数据
        localStorage.removeItem(item);
    }
}
const local = new LocalEvent('lx_notepad'); // 创建一个本地存储的事例
const state = local.get() || {
    event: [],
    count: 0
}
const mutations = {
    ADDEVENT(states,obj){					// ADDEVENT,添加新的事件,并存储到localStorage里
		states.count++;
		obj.items.id = states.count;
        states.event.unshift(obj.items);
        local.set(states);
    }
}

4.父子组件间的通讯

知识点:组件实例的作用域是孤立的。这意味着不能并且不应该在子组件的模板内直接引用父组件的数据。

1)父组件可以使用 props 把数据传给子组件。
2)子组件可以使用 $emit 触发父组件的自定义事件。

代码如下:

// App.vue
<template>
    <div id="app">
		// 通过 isShow、msg 把数据传个子组件,监听自定义事件cancel、sure。
		<n-dialog :is-show="dialog" :msg="tips" @cancel="dialog = false" @sure="sureDialog"></n-dialog>
	</div>
</template>
<script>
	import nDialog from './components/dialog.vue';
	export default {
		data(){
        	return {
            	dialog: true,
            	tips: '清除后无法恢复,确认清除吗?'
			}
        },
		components: {
            nDialog
        },
		methods:{
			sureDialog(){
            	this.$store.dispatch('clearevent');
            	this.dialog = false;
        	}
		}
	}
</script>

// dialog.vue
<template>
    <div class="dialog" :class="{'dialog-show':isShow}">
        <div class="dialog-wrapper">
            <div class="dialog-content">
                {{msg}}
            </div>
            <div class="dialog-btns">
                <button type="button" class="cancel-btn" @click="cancelEvent">取消</button>
                <button type="button" class="sure-btn" @click="sureEvent">确定</button>
            </div>
        </div>
    </div>
</template>
<script>
    export default {
        props:['isShow','msg'],  // 通过 props 属性获得父组件传递过来的数据
        methods: {
            cancelEvent(){
                this.$emit('cancel');  // 取消按钮触发父组件的 cancel 自定义事件
            },
            sureEvent(){
                this.$emit('sure');    // 确认按钮触发父组件的 sure 自定义事件
            }
        }
    }
</script>

5.筛选功能

功能描述:可根据 类型 和 关键词 进行筛选

知识点:在返回所有事件的计算属性上,使用过滤器( filter ),进行对 type 和 content 的筛选,返回符合条件的事件。

代码如下:

<script>
    export default {
        data: function(){
            return {
                screen_type: 0,														// 筛选类型,0 表示不筛选
                screen_title: '',													// 筛选关键词,'' 表示不筛选
            }
        },
        computed:{
            notapad(){
                var self = this;
                return self.$store.state.event.filter(function(d){					// 使用过滤器
                    if(self.screen_type !== 0 && self.screen_title === ''){			// 只筛选类型
                        if( d.type === self.screen_type ){
                            return d;
                        }
                    }else if(self.screen_type !== 0 && self.screen_title !== ''){	// 筛选类型和关键词
                        if( d.type === self.screen_type && d.content.indexOf(self.screen_title) !== -1){
                            return d;
                        }
                    }else if(self.screen_type === 0 && self.screen_title !== ''){	// 只筛选关键词
                        if(d.content.indexOf(self.screen_title) !== -1){
                            return d;
                        }
                    }else{															// 不进行筛选
                        return d;
                    }
                });
            }
        }
	}	
</script>

总结

虽然只是做了个小小的记事本,但是我感觉收获还是很大的,很多知识点掌握得更加的牢固。这个记事本只做了一个页面,就没有用vue-router,路由也是vue里很强大的功能。
做这个记事本的初衷,是因为在工作中,我都会把最近要做的事情给记在本子上,完成之后就会打钩,所以想把这个给放到电脑上去实现。

2019年Gulp自动化压缩合并构建的解决方案

虽然网上有很多的 gulp 构建文章,但是很多都已经随着 gulp 插件的更新无法运行了。因此,我写了这个比较简单的构建方案。本文基于 gulp 最新的 4.0.2 版本进行了修改。现在前端组件化项目大多是基于 webpack 进行构建,但是有一个零散的小业务,静态页面之类的,使用 gulp 反而会更加简单方便。

如果还不熟悉 gulp 的插件,可以阅读上一篇文章:精通gulp常用插件

这个方案主要是为了实现es6转es5、js/css的压缩合并、自动添加版本号和压缩html。

  • gulp-babel es6转es5
  • gulp-csso 压缩优化css
  • gulp-uglify 压缩js
  • gulp-htmlmin 压缩html
  • gulp-filter 过滤文件
  • gulp-rev-all 生成版本号

主要通过上面插件实现功能,其他插件配合使用。

安装相关依赖:npm i gulp gulp-uglify gulp-htmlmin gulp-useref gulp-csso gulp-filter gulp-rev-all gulp-base64 gulp-autoprefixer del gulp-babel @babel/core @babel/preset-env -D

// gulpfile.js
const { series, parallel, src, dest } = require('gulp');
const uglify = require('gulp-uglify');
const htmlmini = require('gulp-htmlmin');
const useref = require('gulp-useref');
const csso = require('gulp-csso');
const filter = require('gulp-filter');
const babel = require('gulp-babel');
const RevAll = require('gulp-rev-all');
const base64 = require('gulp-base64');
const autoprefixer = require('gulp-autoprefixer');
const del = require('del');

// 压缩html配置
const options = {
    removeComments: true,
    collapseWhitespace: true,
    collapseBooleanAttributes: true,
    removeEmptyAttributes: true,
    removeScriptTypeAttributes: true,
    removeStyleLinkTypeAttributes: true,
    minifyJS: true,
    minifyCSS: true
};

const defaultTask = cb => {
    const jsFilter = filter('**/*.js', { restore: true });
    const cssFilter = filter('**/*.css', { restore: true });
    const htmlFilter = filter(['**/*.html'], { restore: true });
    src('*.html')
        .pipe(useref())                     // 解析html中的构建块
        .pipe(jsFilter)                     // 过滤所有js
        .pipe(babel({
            presets: ['@babel/env'],
            sourceType: 'script'
        }))
        .pipe(uglify())                     // 压缩js
        .pipe(jsFilter.restore)
        .pipe(cssFilter)                    // 过滤所有css
        .pipe(autoprefixer())               // 添加css前缀
        .pipe(base64())
        .pipe(csso())                       // 压缩优化css
        .pipe(cssFilter.restore)
        .pipe(RevAll.revision({             // 生成版本号
            dontRenameFile: ['.html'],      // 不给 html 文件添加版本号
            dontUpdateReference: ['.html']  // 不给文件里链接的html加版本号
        }))
        .pipe(htmlFilter)                   // 过滤所有html
        .pipe(htmlmini(options))            // 压缩html
        .pipe(htmlFilter.restore)
        .pipe(dest('./dist'))
        .on('error', function (err) {
            throw new Error(err.toString())
        })
    cb();
}

const delDist = cb => {
    del.sync(['./dist']);
    cb();
}

const copyAssets = cb => {
    src('static/img/**').pipe(dest('./dist/static/img/'));
    cb();
}

exports.default = series(delDist, parallel(defaultTask, copyAssets));

在html中,我们需要先定义好构建块。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>gulp自动化构建解决方案</title>
    <!-- build:css static/css/index.css -->     // 定义了构建后引用的css路径
    <link rel="stylesheet" href="static/css/common.css"/>
    <link rel="stylesheet" href="static/css/index.css"/>
    <!-- endbuild -->
</head>
<body>
    ......
    
    <!-- build:js static/js/index.js -->        // 定义了构建后引用的js路径
    <script src="static/js/jquery.js"></script>
    <script src="static/js/common.js"></script>
    <script src="static/js/index.js"></script>
    <!-- endbuild -->
</body>
</html>

执行构建完成后,会生成 dist 文件夹,目录为:

|-dist
|   |-static
|       |-css
|           |-index.96cf44da.css
|       |-img
|       |-js
|           |-index.42ce3282.js
|   |-index.html

构建完的index.html,我们忽略压缩的html,完成了压缩合并添加版本号等功能。

// dist/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>gulp自动化构建解决方案</title>
    <link rel="stylesheet" href="static/css/index.96cf44da.css"/>
</head>
<body>
    ......

    <script src="static/js/index.42ce3282.js"></script>
</body>
</html>

HTML5 进阶系列:文件上传下载

前言

HTML5 中提供的文件API在前端中有着丰富的应用,上传、下载、读取内容等在日常的交互中很常见。而且在各个浏览器的兼容也比较好,包括移动端,除了 IE 只支持 IE10 以上的版本。想要更好地掌握好操作文件的功能,先要熟悉每个API。

FileList 对象和 file 对象

HTML 中的 input[type="file"] 标签有个 multiple 属性,允许用户选择多个文件,FileList对象则就是表示用户选择的文件列表。这个列表中的每一个文件,就是一个 file 对象。

file 对象的属性:

  • name : 文件名,不包含路径。
  • type : 文件类型。图片类型的文件都会以 image/ 开头,可以由此来限制只允许上传图片。
  • size : 文件大小。可以根据文件大小来进行其他操作。
  • lastModified : 文件最后修改的时间。
<input type="file" id="files" multiple>
<script>
    var elem = document.getElementById('files');
    elem.onchange = function (event) {
    	var files = event.target.files;
    	for (var i = 0; i < files.length; i++) {
    	    // 文件类型为 image 并且文件大小小于 200kb
            if(files[i].type.indexOf('image/') !== -1 && files[i].size < 204800){
                console.log(files[i].name);
            }
    	}
    }
</script>

input 中有个 accept 属性,可以用来规定能够通过文件上传进行提交的文件类型。

accept="image/*" 可以用来限制只允许上传图像格式。但是在 Webkit 浏览器下却出现了响应滞慢的问题,要等上好几秒才弹出文件选择框。

解决方法就是将 * 通配符改为指定的 MIME 类型。

<input type="file" accept="image/gif,image/jpeg,image/jpg,image/png">

Blob 对象

Blob 对象相当于一个容器,可以用于存放二进制数据。它有两个属性,size 属性表示字节长度,type 属性表示 MIME 类型。

如何创建

Blob 对象可以使用 Blob() 构造函数来创建。

var blob = new Blob(['hello'], {type:"text/plain"});

Blob 构造函数中的第一个参数是一个数组,可以存放 ArrayBuffer对象、ArrayBufferView 对象、Blob对象和字符串。

Blob 对象可以通过 slice() 方法来返回一个新的 Blob 对象。

var newblob = blob.slice(0,5, {type:"text/plain"});

slice() 方法使用三个参数,均为可选。第一个参数代表要从Blob对象中的二进制数据的起始位置开始复制,第二个参数代表复制的结束位置,第三个参数为 Blob 对象的 MIME 类型。

canvas.toBlob() 也可以创建 Blob 对象。toBlob() 使用三个参数,第一个为回调函数,第二个为图片类型,默认为 image/png,第三个为图片质量,值在0到1之间。

var canvas = document.getElementById('canvas');
canvas.toBlob(function(blob){ console.log(blob); }, "image/jpeg", 0.5);

下载文件

Blod 对象可以通过 window.URL 对象生成一个网络地址,结合 a 标签的 download 属性来实现下载文件功能。

比如把 canvas 下载为一个图片文件。

var canvas = document.getElementById('canvas');
canvas.toBlob(function(blob){
    // 使用 createObjectURL 生成地址,格式为 blob:null/fd95b806-db11-4f98-b2ce-5eb16b38ba36
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.download = 'canvas';
    a.href = url;
    // 模拟a标签点击进行下载
    a.click();
    // 下载后告诉浏览器不再需要保持这个文件的引用了
    URL.revokeObjectURL(url);
});

也可以将字符串保存为一个文本文件,方法类似。

FileReader 对象

FileReader 对象主要用来把文件读入内存,并且读取文件中的数据。通过构造函数创建一个 FileReader 对象

var reader = new FileReader();

该对象有以下方法:

  • abort:中断读取操作。
  • readAsArrayBuffer:读取文件内容到ArrayBuffer对象中。
  • readAsBinaryString:将文件读取为二进制数据。
  • readAsDataURL:将文件读取为data: URL格式的字符串。
  • readAsText:将文件读取为文本。

上传图片预览

在常见的应用就是在客户端上传图片之后通过 readAsDataURL() 来显示图片。

<input type="file" id="files" accept="image/jpeg,image/jpg,image/png">
<img src="blank.gif" id="preview">
<script>
    var elem = document.getElementById('files'),
        img = document.getElementById('preview');
    elem.onchange = function () {
    	var files = elem.files,
    	    reader = new FileReader();
    	if(files && files[0]){
    	    reader.onload = function (ev) {
    	        img.src = ev.target.result;
    	    }
    	    reader.readAsDataURL(files[0]);
    	}
    }
</script>

但是在一些手机上竖着拍照上传照片时会有bug,会发现照片倒了,包括三星和iPhone。。。解决方案这里不做讲解,有兴趣可以查看:移动端图片上传旋转、压缩的解决方案

数据备份与恢复

FileReader 对象的 readAsText() 可以读取文件的文本,结合 Blob 对象下载文件的功能,那就可以实现将数据导出文件备份到本地,当数据要恢复时,通过 input 把备份文件上传,使用 readAsText() 读取文本,恢复数据。

代码跟上面功能类似,这里不重复,具体的应用可以参考:notepad

Base64 编码

在 HTML5 中新增了 atob 和 btoa 方法来支持 Base64 编码。它们的命名也很简单,b to a 和 a to b,即代表着编码和解码。

var a = "https://lin-xin.github.io";
var b = btoa(a);
var c = atob(b);

console.log(a);     // https://lin-xin.github.io
console.log(b);     // aHR0cHM6Ly9saW4teGluLmdpdGh1Yi5pbw==
console.log(c);     // https://lin-xin.github.io

btoa 方法对字符串 a 进行编码,不会改变 a 的值,返回一个编码后的值。
atob 方法对编码后的字符串进行解码。

但是参数中带中文,已经超出了8位ASCII编码的字符范围,浏览器就会报错。所以需要先对中文进行 encodeURIComponent 编码处理。

var a = "哈喽 世界";
var b = btoa(encodeURIComponent(a));
var c = decodeURIComponent(atob(b));

console.log(b);     // JUU1JTkzJTg4JUU1JTk2JUJEJTIwJUU0JUI4JTk2JUU3JTk1JThD
console.log(c);     // 哈喽 世界

HTML5 进阶系列:拖放 API 实现拖放排序

前言

HTML5 中提供了直接拖放的 API,极大的方便我们实现拖放效果,不需要去写一大堆的 js,只需要通过监听元素的拖放事件就能实现各种拖放功能。

想要拖放某个元素,必须设置该元素的 draggable 属性为 true,当该属性为 false 时,将不允许拖放。而 img 元素和 a 元素都默认设置了 draggable 属性为 true,可直接拖放,如果不想拖放这两个元素,把属性设为 false 即可。

拖放事件

拖放事件由不同的元素产生。一个元素被拖放,他可能会经过很多个元素上,最终到达想要放置的元素内。这里,我暂时把被拖放的元素称为源对象,被经过的元素称为过程对象,到达的元素我称为目标对象。不同的对象产生不同的拖放事件。

源对象:

  • dragstart:源对象开始拖放。
  • drag:源对象拖放过程中。
  • dragend:源对象拖放结束。

过程对象:

  • dragenter:源对象开始进入过程对象范围内。
  • dragover:源对象在过程对象范围内移动。
  • dragleave:源对象离开过程对象的范围。

目标对象:

  • drop:源对象被拖放到目标对象内。
<div id="source" draggable="true">a元素</div>
<div id="process">b元素</div>
<div id="target">c元素</div>

<script>
    var source = document.getElementById('source'),     // a元素
        process = document.getElementById('process'),   // b元素
        target = document.getElementById('target');     // c元素
    
    source.addEventListener('dragstart',function(ev){   // dragstart事件由a元素产生
        console.log('a元素开始被拖动');
    },false)

    process.addEventListener('dragenter',function(ev){  // dragenter事件由b元素产生
        console.log('a元素开始进入b元素');
    },false)
    process.addEventListener('dragleave',function(ev){  // dragleave事件由b元素产生
        console.log('a元素离开b元素');
    },false)

    target.addEventListener('drop',function(ev){        // drop事件由c元素产生
        console.log('a元素拖放到c元素了');
        ev.preventDefault();
    },false)
    document.ondragover = function(e){e.preventDefault();}
</script>

dataTransfer 对象

在所有拖放事件中提供了一个数据传递对象 dataTransfer,用于在源对象和目标对象间传递数据。接下来认识一下这个对象的方法和属性,来了解它是如何传递数据的。

setData()

该方法向 dataTransfer 对象中存入数据。接收两个参数,第一个表示要存入数据种类的字符串,现在支持有以下几种:

  • text/plain:文本文字。
  • text/html:HTML文字。
  • text/xml:XML文字。
  • text/uri-list:URL列表,每个URL为一行。

第二个参数为要存入的数据。例如:

event.dataTransfer.setData('text/plain','Hello World');

getData()

该方法从 dataTransfer 对象中读取数据。参数为在 setData 中指定的数据种类。例如:

event.dataTransfer.getData('text/plain');

clearData()

该方法清除 dataTransfer 对象中存放的数据。参数可选,为数据种类。若参数为空,则清空所有种类的数据。例如:

event.dataTransfer.clearData();

setDragImage()

该方法通过用img元素来设置拖放图标。接收三个参数,第一个为图标元素,第二个为图标元素离鼠标指针的X轴位移量,第三个为图标元素离鼠标指针的Y轴位移量。例如:

var source = document.getElementById('source'),
    icon = document.createElement('img');

icon.src = 'img.png';

source.addEventListener('dragstart',function(ev){
    ev.dataTransfer.setDragImage(icon,-10,-10)
},false)

effectAllowed 和 dropEffect 属性

这两个属性结合起来设置拖放的视觉效果。

值得注意的是:dataTransfer 对象不支持IE。对,不支持IE。

实现拖放排序

上面已经熟悉了拖放 API 的使用,我们来实现个简单的拖放排序,这也是在项目中比较常见的。先来理一下思路:

  • 在一个列表中,每个元素都可以被拖放,那首先要给每个元素设置 draggable 属性为 true。
  • 监听每个元素的 dragstart 事件,对源对象做样式处理来区分。
  • 监听每个元素的 dragenter 事件,当源对象进入到当前元素里,就把源对象添加到该元素之前。这样子后面的元素就会被源对象挤下去了,实现了排序的效果。
  • 但是会发现,源对象无法排到最后一个去,只能在倒数第二。这时就要监听 dragleave 事件,当过程对象是最后一个元素时,源对象离开了过程对象,这时就把源对象添加到最后去。

主要代码如下:

var source = document.querySelectorAll('.list'),
    dragElement = null;

for(var i = 0; i < source.length; i++){
    source[i].addEventListener('dragstart',function(ev){
        dragElement = this;
    },false);

    source[i].addEventListener('dragenter', function(ev){
        if(dragElement != this){
            this.parentNode.insertBefore(dragElement,this);
        }
    }, false)

    source[i].addEventListener('dragleave', function(ev){
        if(dragElement != this){
            if(this == this.parentNode.lastElementChild || this == this.parentNode.lastChild){
                this.parentNode.appendChild(dragElement);
            }
        }
    }, false)
};

document.ondragover = function(e){e.preventDefault();}
document.ondrop = function(e){e.preventDefault();}

完整的代码地址:drag-demo

兼容

主要是在IE中的兼容不太好,不过至少在IE10中能兼容上面的拖动排序。

而且在我简单的试验中发现,就是在 IE 中元素不设置 height 的时候,不会触发 dragleave 事件。

更重要的一点是在 ios 和安卓上,完全不兼容。不过还好,有一个插件能让它在移动端完美兼容。
插件地址:mobile-drag-drop

只需要在原有的代码中引入该插件,即可在移动端上实现拖动了。

<script src="./mobile-drag-drop/index.min.js"></script>
<script src="./mobile-drag-drop/scroll-behaviour.min.js"></script>
<script>
        MobileDragDrop.polyfill({
            // use this to make use of the scroll behaviour
            dragImageTranslateOverride: MobileDragDrop.scrollBehaviourDragImageTranslateOverride
        });
</script>

bind()实现中的 [].concat.call(args, [].slice.call(arguments)

为什么要把参数再拼接concat一次?[].concat.call(args, [].slice.call(arguments)最后一行代码的这个部分没有理解。

bind获取原函数,获取传入的参数,然后利用 原函数.call(新的上下问,参数),这个理解对吗

Vuex 模块化实现待办事项的状态管理

前言

在vue里,组件之间的作用域是独立的,父组件跟子组件之间的通讯可以通过prop属性来传参,但是在兄弟组件之间通讯就比较麻烦了。比如A组件要告诉一件事给B组件,那么A就要先告诉他们的爸组件,然后爸组件再告诉B。当组件比较多,要互相通讯的事情很多的话,爸组件要管他们那么多事,很累的。vuex正是为了解决这个问题,让多个子组件之间可以方便的通讯。

项目介绍

image

待办事项中的一个事件,它可能拥有几个状态,未完成、已完成、已取消或被删除等。这个事件需要在这多种状态之间切换,那么使用vuex来管理也是非常方便的。

来看一下vuex怎么完成状态管理的:

image

所有组件都是调用actions,分发mutation去修改state,然后state经过getter又更新到各个组件里。state又通过localStorage存储数据到本地,下次重新打开时再读取保存的数据。

模块化

为什么要用模块化?当我们的项目比较大,组件很多,功能也多,会导致state里要存放很多内容,整个 store 都会很庞大,很难管理。

我模块化的store目录如下:

|-store/                   // 存放vuex代码
|   |-eventModule          // 事件模块
|   |   |-actions.js
|   |   |-getters.js
|   |   |-index.js
|   |   |-mutations.js
|   |   |-state.js
|   |-themeModule           // 主题颜色模块
|   |   |-actions.js
|   |   |-getters.js
|   |   |-index.js
|   |   |-mutations.js
|   |   |-state.js
|   |-index.js              // vuex的核心,创建一个store

可以看到,每个模块拥有自己的state、mutation、action、getter,这样子我们就可以把我们的项目根据功能划分为多个模块去使用vuex了,而且后期维护也不会一脸懵逼。

状态管理

接下来,我们来看看vuex完成状态管理的一个流程。
举个栗子:一个待办事项,勾选之后,会在未完成列表里移除,并在已完成的列表里出现。这个过程,是这个待办事项的状态发生了改变。勾选的时候,是执行了一个方法,那我们就先写这个方法。在 event_list.vue 文件里新建一个moveToDone方法。

methods: {
    moveToDone(id){ //移至已完成
        this.$store.dispatch('eventdone', id);
    }
}

在 moveToDone 方法中通过 store.dispatch 方法触发 action, 接下来我们在 eventModule/actions.js 中来注册这个 action, 接受一个 id 的参数。

export default {
    eventdone = ({ commit }, param) =>{
        commit('EVENTDONE',{id: param});
    }
}

action 通过调用 store.commit 提交载荷(也就是{id: param}这个对象)到名为'EVENTDONE'的 mutation,那我们再来注册这个 mutation

export default {
    EVENTDONE(states,obj){
        for (let i = 0; i < states.event.length; i++) {
            if (states.event[i].id === obj.id) {
                states.event[i].type = 2;
                states.event[i].time = getDate();
                var item = states.event[i];
                states.event.splice(i, 1);          // 把该事件在数组中删除
                break;
            }
        }
        states.event.unshift(item);                 // 把该事件存到数组的第一个元素
        local.set(states);                          // 将整个状态存到本地
    }
}

通过 mutation 去修改 state, state里我们存放了一个 event 属性

export default {
    event: []
};

在组件中要获得这个 state 里的 event, 那就需要写个getters

export default {
    getDone(states){
        return states.event.filter(function (d) {
            if (d.type === 2) {                 // type == 2表示已完成
                return d;                       // 返回已完成的事件
            }
        });
    }
};

然后每个module里都有一个index.js文件,把自己的state、mutation、action、getters都集合起来,就是一个module

import * as func from '../function';
import actions from './actions.js';
import mutations from './mutations.js';
import state from './state.js';
import getters from './getters.js';

export default {
    state,
    getters,
    actions,
    mutations
}

在 store/index.js 里创建一个 store 对象来存放这个module

import Vue from 'vue';
import Vuex from 'vuex';
import event from './eventModule';
Vue.use(Vuex);
export default new Vuex.Store({
    modules: {
        event
    }
});

最后在 event_list.vue 组件上,我们通过计算属性 computed 来获取到这个从未完成的状态改变到已完成的状态,我们要用到 store 这个对象里的getters

computed: {
    getDone(){
        return this.$store.getters.getDone;
    }
}

这样子,完成了 '未完成' => '已完成' 从提交修改到更新视图读取的整个流程,也是 vuex 工作的整个流程。通过 module 的封装,更加方便多模块项目的开发和维护。

演示地址 : demo

源码地址 : notepad

HTML5 高级系列:web Storage

前言

HTML5 的 web Storage 存储方式有两种:localStorage 和 sessionStorage。

这两种方式都是通过键值对保存数据,存取方便,不影响网站性能。他们的用法相同,存储时间不同。
localStorage 的数据保存在本地硬件上,可以永久保存,可以手动调用api清除数据。sessionStorage 保存在 session 对象中,会在浏览器关闭时被清除。

web Storage 的大小在浏览器上是有限制的,不同浏览器大小会有区别,在主流浏览器中,大小约为 5M,用来存储普通数据其实已经足够。

用法

以 localStorage 为例,sessionStorage 用法一样:

setItem

保存数据:localStorage.setItem(key,value);

示例:

localStorage.setItem('name','Hello World');

当 key 相同时会覆盖之前的 value,用于修改数据。如果 value 为对象,需转为 json 字符串,否则你读取出来的将会是 [object Object]

getItem

读取数据:localStorage.getItem(key);

示例:

localStorage.getItem('name');       // Hello World

removeItem

删除单个数据:localStorage.removeItem(key);

示例:

localStorage.removeItem('name');
localStorage.getItem('name');       // null

删除 key 为 name 的数据后,loaclStorage 里已经获取不到该数据,则返回 null;

clear

删除所有数据:localStorage.clear();

示例:

localStorage.clear();

此时会把 localStorage 中的所有数据都删除。

key

得到某个索引的key:localStorage.key(index);
示例:

localStorage.setItem('name1','Hello World');
localStorage.setItem('name2','Hello Linxin');
localStorage.key(1);                // name2

获取到索引为 1 的 key,即 name2。

构造函数

在实际项目中,可能需要多次对 localStorage 进行操作,我们可以通过一个构造函数来更好的操作。

示例:

var localEvent = function (item) {
    this.get = function () {
        return localStorage.getItem(item);
    }
    this.set = function (val) {
        localStorage.setItem(item, val);
    }
    this.remove = function () {
        localStorage.removeItem(item);
    }
    this.clear = function () {
        localStorage.clear();
    }
}

// 使用new字符把构造函数实例化出多个对象
var local1 = new localEvent('name1');
var local2 = new localEvent('name2');

local1.set('Hello World');
local2.set('Hello Linxin');

local1.get();               // Hello World
local2.get();               // Hello Linxin

这里只是简单的演示,像我们平时在项目中可能要把对象存储起来,就需要在代码里做些处理。

监听 storage 事件

可以通过监听 window 对象的 storage 事件并指定其事件处理函数,当页面中对 localStorage 或 sessionStorage 进行修改时,则会触发对应的处理函数。

window.addEventListener('storage',function(e){
    console.log('key='+e.key+',oldValue='+e.oldValue+',newValue='+e.newValue);
})

触发事件的时间对象(e 参数值)有几个属性:

  • key : 键值。
  • oldValue : 被修改前的值。
  • newValue : 被修改后的值。
  • url : 页面url。
  • storageArea : 被修改的 storage 对象。

注意:在谷歌浏览器中,需要在不同标签页中修改 storage 才会触发该事件,即 网页A 监听该事件,在 网页B 中修改 localStorage,则 网页A 会触发事件函数。但是在 IE 中,在同个网页修改 localStorage 都会触发该事件。

调试

谷歌浏览器自带调试工具(chrome devtools)非常好用,可以用来调试 localStorage 和 sessionStorage。打开浏览器按f12调出调试工具,可以看到 Application ,点击打开可以看到左边栏有 Storage,包括了 localStorage、sessionStorage、IndexedDB等,选中我们要调试的网站域名,可以看到右边有对应的 key 和 value,可以通过右键进行编辑或删除等。

兼容

IE8 以上就兼容,但是比较特别,需要在服务器上打开的才支持,直接双击打开文件的 file:// 是不兼容的。

到了 IE11 才支持 file:// 下打开的,其他浏览器的支持程度都很高,包括在手机上的兼容。具体兼容可查看:http://caniuse.com/#search=localstorage

下一篇将介绍本地存储的另一种方式 :HTML5 高级系列:indexedDB 数据库

sChart.js:一个小型简单的图表库

介绍

sChart.js 作为一个小型简单的图表库,没有过多的图表类型,只包含了柱状图、折线图、饼状图和环形图四种基本的图表。麻雀虽小,五脏俱全。sChart.js 基本可以满足这四种图表的需求。而它的小,体现在它的体积上,代码只有 8kb,如果经过服务器的Gzip压缩,那就更小了,因此不用担心造成项目代码冗余。

该库使用 canvas 实现,兼容 IE9 以上浏览器,支持移动端。

使用指南

$ npm install schart.js
import sChart from 'schart.js'

也可以直接插入script到你的HTML页面:

<script src="schart.min.js"></script>

使用简单:

new sChart(canvasId, options);

即可生成一个图表。

canvasId:canvas标签的id,必填。

options:图表配置参数,可选,具体参考文档

不同类型图表格式都相同,可以同个数据生成不同图表。

vue相关

vue-schart:是用vue.js封装了 sChart.js 的一个库。方便在vue的项目中使用。

仓库地址:https://github.com/lin-xin/vue-schart

在项目中使用了 vue-schart 的有:vue-manage-system

总结

当然,现在有很多成熟的图表库,Chart.js、echarts等等,有着丰富的图表和炫酷的效果。这个库当然不足以与它们相媲美。

但是很多时候我的项目追求的是小,我并不需要用到那么多的功能,我只想展示一下就OK。那它们也可以按需引用,是吧?

所以我觉得,适合自己项目的,才是最好的!

提高 webpack 构建 Vue 项目的速度

前言

最近有人给我的 Vue2 后台管理系统解决方案 提了 issue ,说执行 npm run build 构建项目的时候极其慢,然后就引起我的注意了。在项目中,引入了比较多的第三方库,导致项目大,而每次修改,都不会去修改到这些库,构建却都要再打包这些库,浪费了不少时间。所以,把这些不常变动的第三方库都提取出来,下次 build 的时候不再构建这些库,这样既可大大缩短构建时间。那么要怎么去实现呢?

解决方案

DllPlugin 和 DllReferencePlugin

查找了一下资料,发现我们可以利用 webpack 的插件 DllPlugin 和 DllReferencePlugin 来实现我们要的功能。

DllPlugin 可以把我们需要打包的第三方库打包成一个 js 文件和一个 json 文件,这个 json 文件中会映射每个打包的模块地址和 id,DllReferencePlugin 通过读取这个json文件来使用打包的这些模块。

接下来就看如何实现。

配置文件

在 build 文件夹中新建 webpack.dll.conf.js (项目基于 vue-cli 的)

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    vendor: ['vue/dist/vue.common.js','vue-router', 'babel-polyfill','axios','vue-echarts-v3']
  },
  output: {
    path: path.join(__dirname, '../static/js'),
    filename: '[name].dll.js',
    library: '[name]_library'       // vendor.dll.js中暴露出的全局变量名
  },
  plugins: [
    new webpack.DllPlugin({
      path: path.join(__dirname, '.', '[name]-manifest.json'),
      name: '[name]_library'
    }),
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      }
    })
  ]
};

然后在 package.json 中配置命令

"scripts": {
    ...
    "build:dll": "webpack --config build/webpack.dll.conf.js"
}

执行 npm run build:dll 命令来生成 vendor.dll.js 和 vendor-manifest.json

需要在 index.html 引入 vendor.dll.js

<body>
    <div id="app"></div>
    <script src="./static/js/vendor.dll.js"></script>
</body>

vendor-manifest.json 的内容大概如下:

{
  "name": "vendor_library",
  "content": {
    "./node_modules/core-js/modules/_export.js": {
      "id": 0,
      "meta": {}
    },
    ...
}

接下来就在 webpack.base.config.js 中通过 DLLReferencePlugin 来使用 DllPlugin 生成的 DLL Bundle

var webpack = require('webpack');

module.exports = {
    entry: {
        app: ['./src/main.js']
    },
    module: {
        ...
    }
    // 添加DllReferencePlugin插件
    plugins: [
        new webpack.DllReferencePlugin({
            context: path.resolve(__dirname, '..'),
            manifest: require('./vendor-manifest.json')
        }),
    ]
}

原先 build 需要 95446ms,配置之后执行 build 只需 14360ms,减少了 75% 的时间。

但是存在一个问题,当把太多的第三方依赖都打包到 vendor.dll.js 中去,该文件太大也会影响首屏加载时间。所以要权衡利弊,可以异步加载的插件就没有必要打包进来了,不要一味的把所有都打包到这里面来获取构建时的快感。

示例地址:vue-manage-system

个人博客:lin-xin/blog

HTML5 桌面通知:Notification API

前言

Notification API 是 HTML5 新增的桌面通知 API,用于向用户显示通知信息。该通知是脱离浏览器的,即使用户没有停留在当前标签页,甚至最小化了浏览器,该通知信息也一样会置顶显示出来。

用户权限

想要向用户显示通知消息,需要获取用户权限,而相同的域名只需要获取一次权限。只有用户允许的权限下,Notification 才能起到作用,避免某些网站的广告滥用 Notification 或其它给用户造成影响。那么如何知道用户到底是允不允许的?

Notification.permission 该属性用于表明当前通知显示的授权状态,可能的值包括:

  • default :不知道用户的选择,默认。
  • granted :用户允许。
  • denied :用户拒绝。
if(Notification.permission === 'granted'){
    console.log('用户允许通知');
}else if(Notification.permission === 'denied'){
    console.log('用户拒绝通知');
}else{
    console.log('用户还没选择,去向用户申请权限吧');
}

请求权限

当用户还没选择的时候,我们需要向用户去请求权限。Notification 对象提供了 requestPermission() 方法请求用户当前来源的权限以显示通知。

以前基于回调的语法已经弃用(当然在现在的浏览器中还是能用的),最新的规范已将此方法更新为基于 promise 的语法:

Notification.requestPermission().then(function(permission) {
    if(permission === 'granted'){
        console.log('用户允许通知');
    }else if(permission === 'denied'){
        console.log('用户拒绝通知');
    }
});

推送通知

获取用户授权之后,就可以推送通知了。

var notification = new Notification(title, options)

参数如下:

  • title:通知的标题
  • options:通知的设置选项(可选)。
    • body:通知的内容。
    • tag:代表通知的一个识别标签,相同tag时只会打开同一个通知窗口。
    • icon:要在通知中显示的图标的URL。
    • image:要在通知中显示的图像的URL。
    • data:想要和通知关联的任务类型的数据。
    • requireInteraction:通知保持有效不自动关闭,默认为false。

还有一些其他的参数,因为用不了或者没什么用这里就没必要说了。

var n = new Notification('状态更新提醒',{
    body: '你的朋友圈有3条新状态,快去查看吧',
    tag: 'linxin',
    icon: 'http://blog.gdfengshuo.com/images/avatar.jpg',
    requireInteraction: true
})

通知消息的效果图如下:

image

关闭通知

从上面的参数可以看出,并没有一个参数用来配置显示时长的。我想要它 3s 后自动关闭的话,这时可以调用 close() 方法来关闭通知。

var n = new Notification('状态更新提醒',{
    body: '你的朋友圈有3条新状态,快去查看吧'
})

setTimeout(function() {
    n.close();
}, 3000);

事件

Notification 接口的 onclick属性指定一个事件侦听器来接收 click 事件。当点击通知窗口时会触发相应事件,比如打开一个网址,引导用户回到自己的网站去。

var n = new Notification('状态更新提醒',{
    body: '你的朋友圈有3条新状态,快去查看吧',
    data: {
        url: 'http://blog.gdfengshuo.com'
    }
})
n.onclick = function(){
    window.open(n.data.url, '_blank');      // 打开网址
    n.close();                              // 并且关闭通知
}

应用场景

前面说那么多,其实就是为了用。那么到底哪些地方可以用到呢?

现在网站的消息提醒,大多数都是在消息中心显示个消息数量,然后发邮件告诉用户,这流程完全没有错。不过像我这种用户,觉得别人点个赞,收藏一下都要发个邮件提醒我,老是要去删邮件(强迫症),我是觉得挺烦的甚至关闭了邮件提醒。

当然这里并不是说要用 Notification,毕竟它和邮件的功能完全不一样。

我觉得比较适合的是新闻网站。用户浏览新闻时,可以推送给用户实时新闻。以腾讯体育为例,它就使用了 Notification API。在页面中引入了一个 notification2017_v0118.js,有兴趣可以看看别人是怎么成熟的使用的。

一进入页面,就获取授权,同时自己页面有个浮动框提示你允许授权。如果允许之后,就开始给你推送通知了。不过它在关闭标签卡的时候,通知也会被关闭,那是因为监听了页面 beforeunload 事件。

function addOnBeforeUnload(e) {
	FERD_NavNotice.notification.close();
}
if(window.attachEvent){
	window.attachEvent('onbeforeunload', addOnBeforeUnload);
} else {
	window.addEventListener('beforeunload', addOnBeforeUnload, false);
}

兼容

说到兼容,自然是倒下一大片,而且各浏览器的表现也会有点差异。移动端的几乎全倒,PC端的还好大多都能支持,除了IE。所以使用前,需要先检查一下浏览器是否支持 Notification。

HTML5 进阶系列:canvas 动态图表

前言

canvas 强大的功能让它成为了 HTML5 中非常重要的部分,至于它是什么,这里就不需要我多作介绍了。而可视化图表,则是 canvas 强大功能的表现之一。

现在已经有了很多成熟的图表插件都是用 canvas 实现的,Chart.js、ECharts等可以制作出好看炫酷的图表,而且几乎覆盖了所有图表的实现。

有时候自己只想画个柱状图,自己写又觉得麻烦,用别人插件又感觉累赘,最后打开百度,拷段代码,粘贴上来修修改改。还不如自己撸一个呢。

效果

动画效果图片显示不出来,可以到最下面找demo地址

image

分析

可以这个图表由 xy轴、数据条形和标题组成。

  • 轴线:可以使用 moveTo() & lineTo() 实现
  • 文字:可以使用 fillText() 实现
  • 长方形:可以使用 fillRect() 实现

这样看来,似乎并没有多难。

实现

定义画布

<canvas id="canvas" width="600" height="500"></canvas>

canvas 标签只是个容器,真正实现画图的还是 JavaScript。

画坐标轴

坐标轴就是两条横线,也就是canvas里最基础的知识。

  • 由 ctx.beginPath() 开始一条新的路径
  • ctx.lineWidth=1 设置线条宽度
  • ctx.strokeStyle='#000000' 设置线条颜色
  • ctx.moveTo(x,y) 定义线条的起点
  • ctx.lineTo(x1,y1) 定义线条的终点
  • 最后 ctx.stroke() 把起点和终点连成一条线
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
var width = canvas.width;
var height = canvas.height;
var padding = 50;		// 坐标轴到canvas边框的边距,留边距写文字

ctx.beginPath();
ctx.lineWidth = 1;
// y轴线
ctx.moveTo(padding + 0.5, height - padding + 0.5);
ctx.lineTo(padding + 0.5, padding + 0.5);
ctx.stroke();
// x轴线
ctx.moveTo(padding + 0.5, height - padding + 0.5);
ctx.lineTo(width - padding + 0.5, height - padding + 0.5);
ctx.stroke();

画坐标点

y轴上多少坐标点由自己来定义,需要获取到数据的最大值来计算y轴上的坐标值。x轴的点则由传入的数据长度决定,坐标值由传入数据的 xAxis 属性决定。

  • 坐标值就是文字,由 ctx.fillText(value, x, y) 填充文字,value 为文字值,x y 为值的坐标
  • ctx.textAlign='center' 设置文字居中对齐
  • ctx.fillStyle='#000000' 设置文字填充颜色
var yNumber = 5;                                                // y轴的段数
var yLength = Math.floor((height - padding * 2) / yNumber);     // y轴每段的真实长度
var xLength = Math.floor((width - padding * 2) / data.length);  // x轴每段的真实长度

ctx.beginPath();
ctx.textAlign = 'center';
ctx.fillStyle = '#000000';
ctx.strokeStyle = '#000000';
// x轴刻度和值
for (var i = 0; i < data.length; i++) {
    var xAxis = data[i].xAxis;
    var xlen = xLength * (i + 1);
    ctx.moveTo(padding + xlen, height - padding);
    ctx.lineTo(padding + xlen, height - padding + 5);
    ctx.stroke();                                       // 画轴线上的刻度
    ctx.fillText(xAxis, padding + xlen - xLength / 2, height - padding + 15);   // 填充文字
}
// y轴刻度和值
for (var i = 0; i < yNumber; i++) {
    var y = yFictitious * (i + 1);
    var ylen = yLength * (i + 1);
    ctx.moveTo(padding, height - padding - ylen);
    ctx.lineTo(padding - 5, height - padding - ylen);
    ctx.stroke();
    ctx.fillText(y, padding - 10, height - padding - ylen + 5);
}

柱状动画

接下来要把数据通过柱状的高低显示出来,这里有个动画效果,柱状会从0升到对应的值。在 canvas 上实现动画我们可以使用 setInterval、setTimeout 和 requestAnimationFrame。

requestAnimationFrame 不需要自己设置定时时间,而是跟着浏览器的绘制走。这样就不会掉帧,自然就流畅。
requestAnimationFrame 原本只支持IE10以上,不过可以通过兼容的写法实现兼容到IE6都行。

function looping() {
    looped = requestAnimationFrame(looping);
    if(current < 100){      
    // current 用来计算当前柱状的高度占最终高度的百分之几,通过不断循环实现柱状上升的动画
        current = (current + 3) > 100 ? 100 : (current + 3);
        drawAnimation();
    }else{
        window.cancelAnimationFrame(looped);
        looped = null;
    }
}
function drawAnimation() {
    for(var i = 0; i < data.length; i++) {
        var x = Math.ceil(data[i].value * current / 100 * yRatio);
        var y = height - padding - x;
        ctx.fillRect(padding + xLength * (i + 0.25), y, xLength/2, x);
        // 保存每个柱状的信息
        data[i].left = padding + xLength / 4 + xLength * i;
        data[i].top = y;
        data[i].right = padding + 3 * xLength / 4 + xLength * i;
        data[i].bottom = height - padding;
    }
}
looping();
  • 柱状即是画矩形,由 ctx.fillRect(x, y, width, height) 实现,x y 为矩形左上角的坐标,width height 为矩形的宽高,单位为像素
  • ctx.fillStyle='#1E9FFF' 设置填充颜色

到这里,一个最基本的柱状图就完成了。接下来,我们可以为他添加标题。

标题

要放置标题,就会发现我们一大早定义的 padding 内边距确实有用,总不能把标题给覆盖到柱状图上吧。但是标题有的是在顶部,有的在底部,那么就不能写死了。定一个变量 position 来判断位置去画出来。这个简单。

// 标题
if(title){                      // 也不一定有标题
    ctx.textAlign = 'center';
    ctx.fillStyle = '#000000';  // 颜色,也可以不用写死,个性化嘛
    ctx.font = '16px Microsoft YaHei'
    if(titlePosition === 'bottom' && padding >= 40){
    	ctx.fillText(title,width/2,height-5)
    }else{
    	ctx.fillText(title,width/2,padding/2)
    }
}

监听鼠标移动事件

我们看到,有些图表,把鼠标移上去,当前的柱状就变色了,移开之后又变回原来的颜色。这里就需要监听 mouseover 事件,当鼠标的位置位于柱状的面积内,触发事件。

那我怎么知道在柱状里啊,发现在 drawAnimation() 里会有每个柱状的坐标,那我干脆把坐标给保存到 data 里。那么鼠标在柱状里的条件应该是:

  • ev.offsetX > data[i].left
  • ev.offsetX < data[i].right
  • ev.offsetY > data[i].top
  • ev.offsetY < data[i].bottom
canvas.addEventListener('mousemove',function(ev){
 	var ev = ev||window.event;
 	for (var i=0;i<data.length;i++){
    for (var i=0;i<data.length;i++){
        if(ev.offsetX > data[i].left &&
        ev.offsetX < data[i].right &&
        ev.offsetY > data[i].top &&
        ev.offsetY < data[i].bottom){
    	    console.log('我在第'+i+'个柱状里。');
        }
    }
})

总结

为了更方便的使用,封装成构造函数。通过

var chart = new sBarChart('canvas',data,{
    title: 'xxx公司年度盈利',   // 标题
    titleColor: '#000000',      // 标题颜色
    titlePosition: 'top',       // 标题位置
    bgColor: '#ffffff',         // 背景色
    fillColor: '#1E9FFF',       // 柱状填充色
    axisColor: '#666666',       // 坐标轴颜色
    contentColor: '#a5f0f6'     // 内容横线颜色
});

参数可配置,很简单就生成一个个性化的柱状图。代码地址:canvas-demo

element ui upload怎样修改缩略图路径

elelemet ui 上传完手机拍的照片 显示缩略图是旋转的,引用完你的这个small-exif.js, 拿到转化完的 base64图片路径后,怎么替换组件绑定的file-list属性呢,求教了

使用 Node.js 开发简单的脚手架工具

前言

像我们熟悉的 vue-cli,react-native-cli 等脚手架,只需要输入简单的命令 vue init webpack project,即可快速帮我们生成一个初始项目。在实际工作中,我们可以定制一个属于自己的脚手架,来提高自己的工作效率。

为什么需要需要脚手架?

  • 减少重复性的工作,不再需要复制其他项目再删除无关代码,或者从零创建一个项目和文件。
  • 根据交互动态生成项目结构和配置文件等。
  • 多人协作更为方便,不需要把文件传来传去。

思路

要开发脚手架,首先要理清思路,脚手架是如何工作的?我们可以借鉴 vue-cli 的基本思路。vue-cli 是将项目模板放在 git 上,运行的时候再根据用户交互下载不同的模板,经过模板引擎渲染出来,生成项目。这样将模板和脚手架分离,就可以各自维护,即使模板有变动,只需要上传最新的模板即可,而不需要用户去更新脚手架就可以生成最新的项目。那么就可以按照这个思路来进行开发了。

第三方库

首先来看看会用到哪些库。

  • commander.js,可以自动的解析命令和参数,用于处理用户输入的命令。
  • download-git-repo,下载并提取 git 仓库,用于下载项目模板。
  • Inquirer.js,通用的命令行用户界面集合,用于和用户进行交互。
  • handlebars.js,模板引擎,将用户提交的信息动态填充到文件中。
  • ora,下载过程久的话,可以用于显示下载中的动画效果。
  • chalk,可以给终端的字体加上颜色。
  • log-symbols,可以在终端上显示出 √ 或 × 等的图标。

初始化项目

首先创建一个空项目,暂时命名为 okii-cli,然后新建一个 index.js 文件,再执行 npm init 生成一个 package.json 文件。最后安装上面需要用到的依赖。

npm install commander download-git-repo inquirer handlebars ora chalk log-symbols -S

处理命令行

node.js 内置了对命令行操作的支持,在 package.json 中的 bin 字段可以定义命令名和关联的执行文件。所以现在 package.json 中加上 bin 的内容:

{
  "name": "okii-cli",
  "version": "1.0.0",
  "description": "基于node的脚手架工具",
  "bin": {
    "okii": "index.js"
  },
  ...
}

然后在 index.js 中来定义 init 命令:

#!/usr/bin/env node
const program = require('commander');

program.version('1.0.0', '-v, --version')
       .command('init <name>')
       .action((name) => {
           console.log(name);
       });
program.parse(process.argv);

调用 version('1.0.0', '-v, --version') 会将 -v 和 --version 添加到命令中,可以通过这些选项打印出版本号。
调用 command('init <name>') 定义 init 命令,name 则是必传的参数,为项目名。
action() 则是执行 init 命令会发生的行为,要生成项目的过程就是在这里面执行的,这里暂时只打印出 name。
其实到这里,已经可以执行 init 命令了。我们来测试一下,在 okii-cli 的同级目录下执行:

node ./okii-cli/index.js init HelloWorld

可以看到命令行工具也打印出了 HelloWorld,那么很清楚, action((name) => {}) 这里的参数 name,就是我们执行 init 命令时输入的项目名称。

命令已经完成,接下来就要下载模板生成项目结构了。

下载模板

download-git-repo 支持从 Github、Gitlab 和 Bitbucket 下载仓库,各自的具体用法可以参考官方文档。

由于是公司项目,所以把模板仓库放在了 Gitlab 上,那么在 action() 中进行操作下载模板:

#!/usr/bin/env node
const program = require('commander');
const download = require('download-git-repo');

program.version('1.0.0', '-v, --version')
       .command('init <name>')
       .action((name) => {
           download('http://xxxxxx:9999:HTML5/H5Template#master', name, {clone: true}, (err) => {
				console.log(err ? 'Error' : 'Success')
		   })
       });
program.parse(process.argv);

download() 第一个参数就是仓库地址,但是有一点点不一样。实际的仓库地址是 http://xxxxxx:9999/HTML5/H5Template#master ,可以看到端口号后面的 '/' 在参数中要写成 ':',#master 代表的就是分支名,不同的模板可以放在不同的分支中,更改分支便可以实现下载不同的模板文件了。第二个参数是路径,上面我们直接在当前路径下创建一个 name 的文件夹存放模板,也可以使用二级目录比如 test/${name}

命令行交互

命令行交互功能可以在用户执行 init 命令后,向用户提出问题,接收用户的输入并作出相应的处理。这里使用 inquirer.js 来实现。

const inquirer = require('inquirer');
inquirer.prompt([
	{
		type: 'input',
		name: 'author',
		message: '请输入作者名称'
	}
]).then((answers) => {
	console.log(answers.author);
})

通过这里例子可以看出,问题就放在 prompt() 中,问题的类型为 input 就是输入类型,name 就是作为答案对象中的 key,message 就是问题了,用户输入的答案就在 answers 中,使用起来就是这么简单。更多的参数设置可以参考官方文档。

通过命令行交互,获得用户的输入,从而可以把答案渲染到模板中。

渲染模板

这里用 handlebars 的语法对 HTML5/H5Template 仓库的模板中的 package.json 文件做一些修改

{
  "name": "{{name}}",
  "version": "1.0.0",
  "description": "{{description}}",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "{{author}}",
  "license": "ISC"
}

并在下载模板完成之后将用户输入的答案渲染到 package.json 中

program.version('1.0.0', '-v, --version')
.command('init <name>')
.action((name) => {
	inquirer.prompt([
	{
		name: 'description',
		message: '请输入项目描述'
	},
	{
		name: 'author',
		message: '请输入作者名称'
	}
	]).then((answers) => {
		download('xxxxx#master',name,{clone: true},(err) => {
			const meta = {
				name,
				description: answers.description,
				author: answers.author
			}
			const fileName = `${name}/package.json`;
			const content = fs.readFileSync(fileName).toString();
			const result = handlebars.compile(content)(meta);
			fs.writeFileSync(fileName, result);
		})
	})
});

这里使用了 node.js 的文件模块 fs,将 handlebars 渲染完后的模板重新写入到文件中。

视觉美化

在用户输入答案之后,开始下载模板,这时候使用 ora 来提示用户正在下载中。

const ora = require('ora');
// 开始下载
const spinner = ora('正在下载模板...');
spinner.start();

// 下载失败调用
spinner.fail();

// 下载成功调用
spinner.succeed();

然后通过 chalk 来为打印信息加上样式,比如成功信息为绿色,失败信息为红色,这样子会让用户更加容易分辨,同时也让终端的显示更加的好看。

const chalk = require('chalk');
console.log(chalk.green('项目创建成功'));
console.log(chalk.red('项目创建失败'));

除了给打印信息加上颜色之外,还可以使用 log-symbols 在信息前面加上 √ 或 × 等的图标

const chalk = require('chalk');
const symbols = require('log-symbols');
console.log(symbols.success, chalk.green('项目创建成功'));
console.log(symbols.error, chalk.red('项目创建失败'));

完整示例

#!/usr/bin/env node
const fs = require('fs');
const program = require('commander');
const download = require('download-git-repo');
const handlebars = require('handlebars');
const inquirer = require('inquirer');
const ora = require('ora');
const chalk = require('chalk');
const symbols = require('log-symbols');

program.version('1.0.0', '-v, --version')
	.command('init <name>')
    .action((name) => {
        if(!fs.existsSync(name)){
            inquirer.prompt([
                {
					name: 'description',
					message: '请输入项目描述'
				},
				{
					name: 'author',
					message: '请输入作者名称'
				}
            ]).then((answers) => {
                const spinner = ora('正在下载模板...');
                spinner.start();
				download('http://xxxxxx:9999:HTML5/H5Template#master', name, {clone: true}, (err) => {
                    if(err){
                        spinner.fail();
                        console.log(symbols.error, chalk.red(err));
                    }else{
                        spinner.succeed();
                        const fileName = `${name}/package.json`;
                        const meta = {
                            name,
                            description: answers.description,
                            author: answers.author
                        }
                        if(fs.existsSync(fileName)){
                            const content = fs.readFileSync(fileName).toString();
                            const result = handlebars.compile(content)(meta);
                            fs.writeFileSync(fileName, result);
                        }
                        console.log(symbols.success, chalk.green('项目初始化完成'));
                    }
                })
            })
        }else{
            // 错误提示项目已存在,避免覆盖原有项目
            console.log(symbols.error, chalk.red('项目已存在'));
        }
    })
program.parse(process.argv);

效果如下:
cli

完成之后,就可以把脚手架发布到 npm 上面,通过 -g 进行全局安装,就可以在自己本机上执行 okii init [name] 来初始化项目,这样便完成了一个简单的脚手架工具了。

前端常见的加密算法介绍

一、背景

在信息安全越来越受重视的今天,前端的各种加密也变得更加重要。通常跟服务器的交互中,为保障数据传输的安全性,避免被人抓包篡改数据,除了https的应用,还需要对传输数据进行加解密。

目前常见的加密算法可以分成三类

  • 对称加密算法:AES、...
  • 非对称加密算法:RSA、...
  • Hash算法:MD5、...

二、对称加密算法

对称加密(也叫私钥加密)指加密和解密使用相同密钥的加密算法。它要求发送方和接收方在安全通信之前,商定一个密钥。对称算法的安全性依赖于密钥,泄漏密钥就意味着任何人都可以对他们发送或接收的消息解密,所以密钥的保密性对通信的安全性至关重要。

特点

  • 优点:算法公开、计算量小、加密速度快、加密效率高。
  • 缺点:在数据传送前,发送方和接收方必须商定好密钥,然后双方保存好密钥。如果一方的密钥被泄露,那么加密信息也就不安全了
  • 使用场景:本地数据加密、https通信、网络传输等

AES

AES:高级加密标准(Advanced Encryption Standard)为最常见的对称加密算法(微信小程序加密传输就是用这个加密算法的)。

图片1

密钥:用来加密明文的密码。密钥为接收方与发送方协商产生,但不可以直接在网络上传输,否则会导致密钥泄漏,通常是通过非对称加密算法加密密钥,然后再通过网络传输给对方,或者直接面对面商量密钥。密钥是绝对不可以泄漏的,否则会被攻击者还原密文,窃取数据。

在项目中需要用到AES加密时,可以使用开源的js库:crypto-js

var CryptoJS = require("crypto-js");

var data = {id: 1, text: 'Hello World'};

// 加密生成密文
var ciphertext = CryptoJS.AES.encrypt(JSON.stringify(data), 'secret_key_123').toString();

// 解密得到明文
var bytes  = CryptoJS.AES.decrypt(ciphertext, 'secret_key_123');
var decryptedData = JSON.parse(bytes.toString(CryptoJS.enc.Utf8));

三、非对称加密算法

非对称加密算法需要两个密钥:公开密钥(publickey:简称公钥)和私有密钥(privatekey:简称私钥)。公钥与私钥是一对,如果用公钥对数据进行加密,只有用对应的私钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。

特点

  • 优点:非对称加密与对称加密相比其安全性更好
  • 缺点:加密和解密花费时间长、速度慢,只适合对少量数据进行加密。
  • 使用场景:https会话前期、CA数字证书、信息加密、登录认证等

RSA

RSA加密算法是非对称加密算法最常见的一种。RSA是1977年由Ron Rivest、Adi Shamir和Leonard Adleman一起提出的。RSA就是他们三人姓氏开头字母拼在一起组成的。

图片2

在项目中需要用到RSA加密时,可以使用开源的js库:jsencrypt

// 使用公钥加密
var publicKey = 'public_key_123';
var encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
var encrypted = encrypt.encrypt('Hello World');

// 使用私钥解密
var privateKey = 'private_key_123';
var decrypt = new JSEncrypt();
decrypt.setPrivateKey(privateKey);
var uncrypted = decrypt.decrypt(encrypted);

四、Hash算法

Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。

简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

特点

  • 优点:不可逆、易计算、特征化
  • 缺点:可能存在散列冲突
  • 使用场景:文件或字符串一致性校验、数字签名、鉴权协议

MD5

MD5是比较常见的Hash算法,对于MD5而言,有两个特性是很重要的,第一:明文数据经过散列以后的值是定长的;第二:是任意一段明文数据,经过散列以后,其结果必须永远是不变的。前者的意思是可能存在有两段明文散列以后得到相同的结果,后者的意思是如果我们散列特定的数据,得到的结果一定是相同的。

比如在登录时将密码进行 md5 加密再传输给服务器,服务器中的密码也是用 md5 加密后存储的,那么只要验证加密后的密文是否一致则可。

在项目中需要用到MD5加密时,可以使用开源的js库:JavaScript-MD5

var hash = md5('Hello World');
// b10a8db164e0754105b7a99be72e3fe5

五、Base64编码

Base64编码只是一种编码格式并不是加密算法,它可用于在HTTP环境下传递较长的标识信息。

特点

  • 可以将任意的二进制数据进行Base64编码
  • 数据加密之后,数据量会变大,变大1/3左右
  • 编码后有个非常显著的特点,末尾有个=号
  • 可进行反向解码
  • Base64编码具有不可读性

现代浏览器都提供了Base64编码、解码方法,btoa() 和 atob()

var enc = window.btoa('Hello World');
// SGVsbG8gV29ybGQ=

var str = window.atob(enc);
// Hello World

六、总结

在业务http请求中,AES的密钥在前端随机生成,从服务器获取RSA的公钥,对AES的密钥进行非对称加密,把加密后的密钥在请求头中传给服务器,用AES对body进行加密。服务器收到请求头中的加密后的密钥,用RSA的密钥进行解密,得到明文的AES密钥,即可对body进行解密。md5有校验字符串一致性的特性,为避免请求被拦截后篡改body,可在发请求时,将body字符串进行一个md5加密后在请求头传输,服务器收到请求后,解密body后再md5与请求头的进行校验,可验证是否请求被篡改。

JavaScript 中 闭包 的详解

闭包是什么

在 JavaScript 中,闭包是一个让人很难弄懂的概念。ECMAScript 中给闭包的定义是:闭包,指的是词法表示包括不被计算的变量的函数,也就是说,函数可以使用函数之外定义的变量。

是不是看完这个定义感觉更加懵逼了?别急,我们来分析一下。

  • 闭包是一个函数
  • 闭包可以使用在它外面定义的变量
  • 闭包存在定义该变量的作用域中

好像有点清晰了,但是使用在它外面定义的变量是什么意思,我们先来看看变量作用域。

变量作用域

变量可分为全局变量和局部变量。全局变量的作用域就是全局性的,在 js 的任何地方都可以使用全局变量。在函数中使用 var 关键字声明变量,这时的变量即是局部变量,它的作用域只在声明该变量的函数内,在函数外面是访问不到该变量的。

var func = function(){
    var a = 'linxin';
    console.log(a);         // linxin
}
func();
console.log(a);             // Uncaught ReferenceError: a is not defined

作用域相对比较简单,我们不多讲,来看看跟闭包关系比较大的变量生存周期。

变量生存周期

全局变量,生命周期是永久的。局部变量,当定义该变量的函数调用结束时,该变量就会被垃圾回收机制回收而销毁。再次调用该函数时又会重新定义了一个新变量。

var func = function(){
    var a = 'linxin';
    console.log(a);
}
func();

a 为局部变量,在 func 调用完之后,a 就会被销毁了。

var func = function(){
    var a = 'linxin';
    var func1 = function(){
        a += ' a';
        console.log(a);
    }
    return func1;
}
var func2 = func();
func2();                    // linxin a
func2();                    // linxin a a
func2();                    // linxin a a a

可以看出,在第一次调用完 func2 之后,func 中的变量 a 变成 'linxin a',而没有被销毁。因为此时 func1 形成了一个闭包,导致了 a 的生命周期延续了。

这下子闭包就比较明朗了。

  • 闭包是一个函数,比如上面的 func1 函数
  • 闭包使用其他函数定义的变量,使其不被销毁。比如上面 func1 调用了变量 a
  • 闭包存在定义该变量的作用域中,变量 a 存在 func 的作用域中,那么 func1 也必然存在这个作用域中。

现在可以说,满足这三个条件的就是闭包了。

下面我们通过一个简单而又经典的例子来进一步熟悉闭包。

for (var i = 0; i < 4; i++) {
    setTimeout(function () {
        console.log(i)
    }, 0)
}

我们可能会简单的以为控制台会打印出 0 1 2 3,可事实却打印出了 4 4 4 4,这又是为什么呢?我们发现,setTimeout 函数时异步的,等到函数执行时,for循环已经结束了,此时的 i 的值为 4,所以 function() { console.log(i) } 去找变量 i,只能拿到 4。

我们想起上一个例子中,闭包使 a 变量的值被保存起来了,那么这里我们也可以用闭包把 0 1 2 3 保存起来。

for (var i = 0; i < 4; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i)
        }, 0)
    })(i)
}

当 i=0 时,把 0 作为参数传进匿名函数中,此时 function(i){} 此匿名函数中的 i 的值为 0,等到 setTimeout 执行时顺着外层去找 i,这时就能拿到 0。如此循环,就能拿到想要的 0 1 2 3。

内存管理

在闭包中调用局部变量,会导致这个局部变量无法及时被销毁,相当于全局变量一样会一直占用着内存。如果需要回收这些变量占用的内存,可以手动将变量设置为null。

然而在使用闭包的过程中,比较容易形成 JavaScript 对象和 DOM 对象的循环引用,就有可能造成内存泄露。这是因为浏览器的垃圾回收机制中,如果两个对象之间形成了循环引用,那么它们都无法被回收。

function func() {
    var test = document.getElementById('test');
    test.onclick = function () {
        console.log('hello world');
    }
}

在上面例子中,func 函数中用匿名函数创建了一个闭包。变量 test 是 JavaScript 对象,引用了 id 为 test 的 DOM 对象,DOM 对象的 onclick 属性又引用了闭包,而闭包又可以调用 test ,因而形成了循环引用,导致两个对象都无法被回收。要解决这个问题,只需要把循环引用中的变量设为 null 即可。

function func() {
    var test = document.getElementById('test');
    test.onclick = function () {
        console.log('hello world');
    }
    test = null;
}

如果在 func 函数中不使用匿名函数创建闭包,而是通过引用一个外部函数,也不会出现循环引用的问题。

function func() {
    var test = document.getElementById('test');
    test.onclick = funcTest;
}
function funcTest(){
    console.log('hello world');
}

Vue2 后台管理系统解决方案

项目地址

前言

之前在公司用了Vue + Element组件库做了个后台管理系统,基本很多组件可以直接引用组件库的,但是也有一些需求无法满足。像图片裁剪上传、富文本编辑器、图表等这些在后台管理系统中很常见的功能,就需要引用其他的组件才能完成。从寻找组件,到使用组件的过程中,遇到了很多问题,也积累了宝贵的经验。所以我就把开发这个后台管理系统的经验,总结成这个后台管理系统解决方案。

该方案作为一套多功能的后台框架模板,适用于绝大部分的后台管理系统(Web Management System)开发。基于vue.js,使用vue-cli脚手架快速生成项目目录,引用Element UI组件库,方便开发快速简洁好看的组件。分离颜色样式,支持手动切换主题色,而且很方便使用自定义主题色。

功能

  • Element UI
  • 登录/注销
  • 表格
  • 表单
  • 图表 📊
  • 富文本编辑器
  • markdown编辑器
  • 图片拖拽/裁剪上传
  • 支持切换主题色 ✨

安装步骤

git clone https://github.com/lin-xin/manage-system.git		// 把模板下载到本地
cd manage-system											// 进入模板目录
npm install													// 安装项目依赖,等待安装完成之后

本地开发

// 开启服务器,浏览器访问 http://localhost:8080
npm run dev

构建生产

// 执行构建命令,生成的dist文件夹放在服务器下即可访问
npm run build

组件使用说明与演示

element-ui

一套基于vue.js2.0的桌面组件库。访问地址:element

vue-datasource

一个用于动态创建表格的vue.js服务端组件。访问地址:vue-datasource

<template>
	<div>
		<datasource language="en" :table-data="information.data"
	        :columns="columns"
	        :pagination="information.pagination"
	        :actions="actions"
	        v-on:change="changePage"
	        v-on:searching="onSearch"></datasource>
	</div>
</template>

<script>
	import Datasource from 'vue-datasource';					// 导入quillEditor组件
    export default {
        data: function(){
            return {
                information: {
	                pagination: {...},						// 页码配置
	                data: [...]
	            },
	            columns: [...],								// 列名配置
	            actions: [...]								// 功能配置
            }
        },
        components: {
            Datasource										// 声明组件Datasource
        },
	    methods: {
	        changePage(values) {...},
	        onSearch(searchQuery) {...}
	    }
	}
</script>

Vue-Quill-Editor

基于Quill、适用于Vue2的富文本编辑器。访问地址:vue-quill-editor

<template>
	<div>
		<quill-editor ref="myTextEditor" v-model="content" :config="editorOption"></quill-editor>
	</div>
</template>

<script>
	import { quillEditor } from 'vue-quill-editor';			// 导入quillEditor组件
    export default {
        data: function(){
            return {
                content: '',								// 编辑器的内容
                editorOption: {								// 编辑器的配置
                    // something config
                }
            }
        },
        components: {
            quillEditor										// 声明组件quillEditor
        }
	}
</script>

Vue-SimpleMDE

Vue.js的Markdown Editor组件。访问地址:Vue-SimpleMDE

<template>
    <div>
        <markdown-editor v-model="content" :configs="configs" ref="markdownEditor"></markdown-editor>
    </div>
</template>

<script>
    import { markdownEditor } from 'vue-simplemde';			// 导入markdownEditor组件
    export default {
        data: function(){
            return {
                content:'',									// markdown编辑器内容
                configs: {									// markdown编辑器配置参数
                    status: false,							// 禁用底部状态栏
                    initialValue: 'Hello BBK',				// 设置初始值
                    renderingConfig: {
                        codeSyntaxHighlighting: true,		// 开启代码高亮
                        highlightingTheme: 'atom-one-light' // 自定义代码高亮主题
                    }
                }
            }
        },
        components: {
            markdownEditor									// 声明组件markdownEditor
        }
    }
</script>

Vue-Core-Image-Upload

一款轻量级的vue上传插件,支持裁剪。访问地址:Vue-Core-Image-Upload

<template>
    <div>
		<img :src="src">									// 用于显示上传的图片
        <vue-core-image-upload :class="['pure-button','pure-button-primary','js-btn-crop']"
           :crop="true"										// 是否裁剪
           text="上传图片"
           url=""											// 上传路径
           extensions="png,gif,jpeg,jpg"					// 限制文件类型
           @:imageuploaded="imageuploaded">					// 监听图片上传完成事件
		</vue-core-image-upload>
    </div>
</template>

<script>
    import VueCoreImageUpload  from 'vue-core-image-upload';	// 导入VueCoreImageUpload组件
    export default {
        data: function(){
            return {
                src:'../img/1.jpg'							// 默认显示图片地址
            }
        },
        components: {
            VueCoreImageUpload								// 声明组件VueCoreImageUpload
        },
        methods:{
            imageuploaded(res) {							// 定义上传完成执行的方法
                console.log(res)
            }
        }
    }
</script>

vue-echarts-v3

基于vue2和eCharts.js3的图表组件。访问地址:vue-echarts-v3

<template>
    <div>
        <IEcharts :option="bar"></IEcharts>
    </div>
</template>
	
<script>
    import IEcharts from 'vue-echarts-v3';					// 导入IEcharts组件
    export default {
        data: function(){
            return {
                bar: {
			        title: {
			          text: '柱状图'							// 图标标题文本
			        },
			        tooltip: {},	
			        xAxis: {								// 横坐标
			          data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
			        },
			        yAxis: {},								// 纵坐标
			        series: [{
			          name: '销量',
			          type: 'bar',							// 图标类型
			          data: [5, 20, 36, 10, 10, 20]
			        }]
			   	}
            }
        },
        components: {
            IEcharts								// 声明组件VueCoreImageUpload
        }
    }
</script>

其他注意事项

一、如果我不想用到上面的某些组件呢,那我怎么在模板中删除掉不影响到其他功能呢?

举个栗子,我不想用 vue-datasource 这个组件,那我需要分四步走。

第一步:删除该组件的路由,在目录 src/router/index.js 中,找到引入改组件的路由,删除下面这段代码。

{
    path: '/vuetable',
    component: resolve => require(['../components/page/VueTable.vue'], resolve)     // vue-datasource组件
},

第二步:删除引入该组件的文件。在目录 src/components/page/ 删除 VueTable.vue 文件。

第三步:删除该页面的入口。在目录 src/components/common/Sidebar.vue 中,找到该入口,删除下面这段代码。

<el-menu-item index="vuetable">Vue表格组件</el-menu-item>

第四步:卸载该组件。执行以下命令:

npm un vue-datasource -S

完成。

二、如何切换主题色呢?

第一步:打开 src/main.js 文件,找到引入 element 样式的地方,换成浅绿色主题。

import 'element-ui/lib/theme-default/index.css';    // 默认主题
// import '../static/css/theme-green/index.css';       // 浅绿色主题

第二步:打开 src/App.vue 文件,找到 style 标签引入样式的地方,切换成浅绿色主题。

@import "../static/css/main.css";
@import "../static/css/color-dark.css";     /*深色主题*/
/*@import "../static/css/theme-green/color-green.css";   !*浅绿色主题*!*/

第三步:打开 src/components/common/Sidebar.vue 文件,找到 el-menu 标签,把 theme="dark" 去掉即可。

项目截图

默认皮肤

Image text

浅绿色皮肤

Image text

Vue3 插件开发详解尝鲜版

前言

vue3.0-beta 版本已经发布了一段时间了,正式版本据说在年中发布(直播的时候说的是年中还是年终,网上传闻说是6月份)。嘴上说着学不动,身体却很诚实地创建一个vue3的项目,兴致勃勃地引入 vue2 插件的时候,眉头一皱,发现事情并没有那么简单。

浏览器无情地抛出了一个错误:

Uncaught TypeError: Cannot set property '$toast' of undefined

不是说兼容vue2的写法吗,插件不兼容,这是闹哪样?发下牢*之后还是得解决问题。研究插件的代码,是这么实现的

var Toast = {};
Toast.install = function (Vue, options) {
    Vue.prototype.$toast = 'Hello World';
}
module.exports = Toast;

vue2 插件的时候会暴露一个 install 方法,并通过全局方法 Vue.use() 使用插件。install 方法的第一个参数是 Vue 构造器,插件的方法就添加在 Vue 构造器的原型对象上。通过 new Vue() 实例化后,在组件中就能调用 this.$toast 使用组件了。(具体实现可以参考我之前的一篇文章:Vue.js 插件开发详解,下面也是基于这个插件demo对比修改)。

而 vue3 的 install 方法不是传入 Vue 构造器,没有原型对象,Vue.prototype 是 undefined,所以报错了。vue3 采用依赖注入的方式,在插件内部利用 provide 和 inject 并暴露出一个组合函数,在组件中使用。

插件实现

基本框架

下面先实现一个插件的基本框架。

import { provide, inject } from 'vue';
const ToastSymbol = Symbol(); 	// 用Symbol创建一个唯一标识,多个插件之间不会冲突
const _toast = () => {}		// 插件的主体方法
export function provideToast(config) {	// 对外暴露的方法,将 _toast 方法提供给后代组件
    provide(ToastSymbol, _toast);
}

export function useToast() {	// 后代组件可以从该方法中拿到 toast 方法
    const toast = inject(ToastSymbol);
    if (!toast) {
        throw new Error('error');
    }
    return toast;
}

组件使用

在 provideToast 方法中,提供了 _toast 方法,那在根组件中 调用 provideToast 方法,传入插件参数,子组件中就能使用 toast 功能了。

// app.vue 根组件
import { provideToast } from 'vue2-toast';
export default {
    setup() {
        provideToast({
			width: '200px',		// 配置toast宽度
			duration: 2000		// 配置toast持续显示时长
		});
    }
};

在 useToast 方法中,返回了 toast 方法,那在所有的后代组件中调用 useToast 方法,就能拿到 toast 方法了。

// child.vue 子组件
import { useToast } from 'vue2-toast';
export default {
    setup() {
        const Toast = useToast(); // 所有的子组件都要从useToast拿到toast方法
		Toast('Hello World');
    }
};

数据响应

想要控制 toast DOM 元素的显示隐藏,以及提示文本的变化,需要创建响应式的数据,在 vue3 中可以通过 reactive 方法创建一个响应式对象。

const state = reactive({
    show: true,		// DOM元素是否显示
    text: ''	// 提示文本
});

挂载DOM

在页面中显示一个 toast,那就免不了要创建一个 DOM 并挂载到页面中。在 vue2 中是通过Vue.extend 创建一个子类,将子类生成的实例挂载到某个元素中,而 vue3 中通过 createApp 方法来实现的。

const _toast = text => {
    state.show = true;
    state.text = text;
	toastVM = createApp({
		setup() {
			return () =>
				h('div', {
					style: { display: state.show ? 'block' : 'none' }	// display 设置显示隐藏
				},
				state.text
			);
		}
	});
	toastWrapper = document.createElement('div');
	toastWrapper.id = 'toast';
	document.body.appendChild(toastWrapper);  // 给页面添加一个节点用来挂载toast元素
	toastVM.mount('#toast');
};

完整示例

上面的每一步是这个插件的关键内容,接下来就是完善下细节,比如用户配置插件的全局参数,设置 toast 的样式,持续时间,显示位置等,这里就不一一细讲了,完整示例如下:

import { provide, inject, reactive, createApp, h } from 'vue';
const ToastSymbol = Symbol();

const globalConfig = {
    type: 'bottom', // toast位置
    duration: '2500', // 持续时长
    wordWrap: false, // 是否自动换行
    width: 'auto' // 宽度
};

const state = reactive({
    toast: {
        show: false, // toast元素是否显示
        text: ''
    },
    loading: {
        show: false, // loading元素是否显示
        text: ''
    }
});

let [toastTimer, toastVM, toastWrapper, loadingVM, loadingWrapper] = [null, null, null, null, null];

const _toast = text => {
    state.toast.show = true;
    state.toast.text = text;
    if (!toastVM) {
        // 如果toast实例存在则不重新创建
        toastVM = createApp({
            setup() {
                return () =>
                    h(
                        'div',
                        {
                            // 这里是根据配置显示一样不同的样式
                            class: [
                                'lx-toast',
                                `lx-toast-${globalConfig.type}`,
                                globalConfig.wordWrap ? 'lx-word-wrap' : ''
                            ],
                            style: {
                                display: state.toast.show ? 'block' : 'none',
                                width: globalConfig.width
                            }
                        },
                        state.toast.text
                    );
            }
        });
    }

    if (!toastWrapper) {
        // 如果该节点以经存在则不重新创建
        toastWrapper = document.createElement('div');
        toastWrapper.id = 'lx-toast';
        document.body.appendChild(toastWrapper);
        toastVM.mount('#lx-toast');
    }
    if (toastTimer) clearTimeout(toastTimer);
    // 定时器,持续时长之后隐藏
    toastTimer = setTimeout(() => {
        state.toast.show = false;
        clearTimeout(toastTimer);
    }, globalConfig.duration);
};

const _loading = {};
_loading.show = text => {
    state.loading.show = true;
    state.loading.text = text;
    if (!loadingVM) {
        // 如果loging实例存在则不重新创建
        loadingVM = createApp({
            setup() {
                return () =>
                    h(
                        'div',
                        {
                            class: 'lx-load-mark',
                            style: { display: state.loading.show ? 'block' : 'none' }
                        },
                        [
                            h('div', { class: 'lx-load-box' }, [
                                h(
                                    'div',
                                    {
                                        class: state.loading.text ? 'lx-loading' : 'lx-loading-nocontent'
                                    },
                                    // loading 菊花节点元素
                                    Array.apply(null, { length: 12 }).map((value, index) => {
                                        return h('div', {
                                            class: ['loading_leaf', `loading_leaf_${index}`]
                                        });
                                    })
                                ),
                                h('div', { class: 'lx-load-content' }, state.loading.text)
                            ])
                        ]
                    );
            }
        });
    }

    if (!loadingWrapper) {
        // 如果该节点以经存在则不重新创建
        loadingWrapper = document.createElement('div');
        loadingWrapper.id = 'lx-loading';
        document.body.appendChild(loadingWrapper);
        loadingVM.mount('#lx-loading');
    }
};
_loading.close = () => {
    state.loading.show = false;
};

export function provideToast(config = {}) {
    for (const key in config) {
        globalConfig[key] = config[key];
    }
    provide(ToastSymbol, { Toast: _toast, Loading: _loading });
}

export function useToast() {
    const toast = inject(ToastSymbol);
    if (!toast) {
        throw new Error('error');
    }
    return toast;
}

vue-schart : vue.js 的图表组件

介绍

vue-schart 是使用vue.js封装了sChart.js图表库的一个小组件。支持vue.js 1.x & 2.x

sChart.js 作为一个小型简单的图表库,没有过多的图表类型,只包含了柱状图、折线图、饼状图和环形图四种基本的图表。麻雀虽小,五脏俱全。sChart.js 基本可以满足这四种图表的需求。而它的小,体现在它的体积上,代码只有 8kb,如果经过服务器的Gzip压缩,那就更小了,因此不用担心造成项目代码冗余。

该库使用 canvas 实现,兼容 IE9 以上浏览器。

效果

demo

使用指南

安装:

npm install vue-schart -S

在vue组件中使用:

<template>
    <div id="app">
        <schart :canvasId="canvasId"
            :type="type"
            :width="width"
            :height="height"
            :data="data"
            :options="options"
        ></schart>
    </div>
</template>
<script>
import Schart from 'vue-schart';
export default {
    data() {
        return {
            canvasId: 'myCanvas',
            type: 'bar',
            width: 500,
            height: 400,
            data: [
                {name: '2014', value: 1342},
                {name: '2015', value: 2123},
                {name: '2016', value: 1654},
                {name: '2017', value: 1795},
            ],
            options: {
                title: 'Total sales of stores in recent years'
            }
        }
    },
    components:{
        Schart
    }
}
</script>

应用

vue-manage-system 后台框架中应用了 vue-schart 组件,体积小,加载快。
演示地址:http://blog.gdfengshuo.com/example/work/#/basecharts

Node.js 应用:Koa2 使用 JWT 进行鉴权

前言

在前后端分离的开发中,通过 Restful API 进行数据交互时,如果没有对 API 进行保护,那么别人就可以很容易地获取并调用这些 API 进行操作。那么服务器端要如何进行鉴权呢?

Json Web Token 简称为 JWT,它定义了一种用于简洁、自包含的用于通信双方之间以 JSON 对象的形式安全传递信息的方法。JWT 可以使用 HMAC 算法或者是 RSA 的公钥密钥对进行签名。

说得好像跟真的一样,那么到底要怎么进行认证呢?

jwt流程图

首先用户登录时,输入用户名和密码后请求服务器登录接口,服务器验证用户名密码正确后,生成token并返回给前端,前端存储token,并在后面的请求中把token带在请求头中传给服务器,服务器验证token有效,返回正确数据。

既然服务器端使用 Koa2 框架进行开发,除了要使用到 jsonwebtoken 库之外,还要使用一个 koa-jwt 中间件,该中间件针对 Koa 对 jsonwebtoken 进行了封装,使用起来更加方便。下面就来看看是如何使用的。

生成token

这里注册了个 /login 的路由,用于用户登录时获取token。

const router = require('koa-router')();
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel.js');

router.post('/login', async (ctx) => {
	const data = ctx.request.body;
	if(!data.name || !data.password){
		return ctx.body = {
			code: '000002',
			data: null,
			msg: '参数不合法'
		}
	}
	const result = await userModel.findOne({
		name: data.name,
		password: data.password
	})
	if(result !== null){
		const token = jwt.sign({
			name: result.name,
			_id: result._id
		}, 'my_token', { expiresIn: '2h' });
		return ctx.body = {
			code: '000001',
			data: token,
			msg: '登录成功'
		}
	}else{
		return ctx.body = {
			code: '000002',
			data: null,
			msg: '用户名或密码错误'
		}
	}
});

module.exports = router;

在验证了用户名密码正确之后,调用 jsonwebtoken 的 sign() 方法来生成token,接收三个参数,第一个是载荷,用于编码后存储在 token 中的数据,也是验证 token 后可以拿到的数据;第二个是密钥,自己定义的,验证的时候也是要相同的密钥才能解码;第三个是options,可以设置 token 的过期时间。

获取token

接下来就是前端获取 token,这里是在 vue.js 中使用 axios 进行请求,请求成功之后拿到 token 保存到 localStorage 中。这里登录成功后,还把当前时间存了起来,除了判断 token 是否存在之外,还可以再简单的判断一下当前 token 是否过期,如果过期,则跳登录页面

submit(){
	axios.post('/login', {
		name: this.username,
		password: this.password
	}).then(res => {
		if(res.code === '000001'){
			localStorage.setItem('token', res.data);
			localStorage.setItem('token_exp', new Date().getTime());
			this.$router.push('/');
		}else{
			alert(res.msg);
		}
	})
}

然后请求服务器端API的时候,把 token 带在请求头中传给服务器进行验证。每次请求都要获取 localStorage 中的 token,这样很麻烦,这里使用了 axios 的请求拦截器,对每次请求都进行了取 token 放到 headers 中的操作。

axios.interceptors.request.use(config => {
    const token = localStorage.getItem('token');
    config.headers.common['Authorization'] = 'Bearer ' + token;
    return config;
})

验证token

通过 koa-jwt 中间件来进行验证,用法也非常简单

const koa = require('koa');
const koajwt = require('koa-jwt');
const app = new koa();

// 错误处理
app.use((ctx, next) => {
    return next().catch((err) => {
        if(err.status === 401){
            ctx.status = 401;
      		ctx.body = 'Protected resource, use Authorization header to get access\n';
        }else{
            throw err;
        }
    })
})

app.use(koajwt({
	secret: 'my_token'
}).unless({
	path: [/\/user\/login/]
}));

通过 app.use 来调用该中间件,并传入密钥 {secret: 'my_token'},unless 可以指定哪些 URL 不需要进行 token 验证。token 验证失败的时候会抛出401错误,因此需要添加错误处理,而且要放在 app.use(koajwt()) 之前,否则不执行。

如果请求时没有token或者token过期,则会返回401。

解析koa-jwt

我们上面使用 jsonwebtoken 的 sign() 方法来生成 token 的,那么 koa-jwt 做了些什么帮我们来验证 token。

resolvers/auth-header.js

module.exports = function resolveAuthorizationHeader(ctx, opts) {
    if (!ctx.header || !ctx.header.authorization) {
        return;
    }
    const parts = ctx.header.authorization.split(' ');
    if (parts.length === 2) {
        const scheme = parts[0];
        const credentials = parts[1];
        if (/^Bearer$/i.test(scheme)) {
            return credentials;
        }
    }
    if (!opts.passthrough) {
        ctx.throw(401, 'Bad Authorization header format. Format is "Authorization: Bearer <token>"');
    }
};

在 auth-header.js 中,判断请求头中是否带了 authorization,如果有,将 token 从 authorization 中分离出来。如果没有 authorization,则代表了客户端没有传 token 到服务器,这时候就抛出 401 错误状态。

verify.js

const jwt = require('jsonwebtoken');

module.exports = (...args) => {
    return new Promise((resolve, reject) => {
        jwt.verify(...args, (error, decoded) => {
            error ? reject(error) : resolve(decoded);
        });
    });
};

在 verify.js 中,使用 jsonwebtoken 提供的 verify() 方法进行验证返回结果。jsonwebtoken 的 sign() 方法来生成 token 的,而 verify() 方法则是用来认证和解析 token。如果 token 无效,则会在此方法被验证出来。

index.js

const decodedToken = await verify(token, secret, opts);
if (isRevoked) {
	const tokenRevoked = await isRevoked(ctx, decodedToken, token);
	if (tokenRevoked) {
		throw new Error('Token revoked');
	}
}
ctx.state[key] = decodedToken;  // 这里的key = 'user'
if (tokenKey) {
	ctx.state[tokenKey] = token;
}

在 index.js 中,调用 verify.js 的方法进行验证并解析 token,拿到上面进行 sign() 的数据 {name: result.name, _id: result._id},并赋值给 ctx.state.user,在控制器中便可以直接通过 ctx.state.user 拿到 name_id

安全性

  • 如果 JWT 的加密密钥泄露的话,那么就可以通过密钥生成 token,随意的请求 API 了。因此密钥绝对不能存在前端代码中,不然很容易就能被找到。
  • 在 HTTP 请求中,token 放在 header 中,中间者很容易可以通过抓包工具抓取到 header 里的数据。而 HTTPS 即使能被抓包,但是它是加密传输的,所以也拿不到 token,就会相对安全了。

总结

这上面就是 jwt 基本的流程,这或许不是最完美的,但在大多数登录中使用已经足够了。
上面的代码可能不够具体,这里使用 Koa + mongoose + vue.js 实现的一个例子 : jwt-demo,可以做为参考。

React Native组件集成到Android原生项目

前言

为了把 React Native 集成到 Android 原生项目中,踩了很多坑,因为作为web前端开发,本来就不熟悉安卓,参考了网上很多文章,但是都很旧了,而 React Native 已经升级到了 0.55 版本了,入口文件已经合成了一个 index.js,下面的内容也是基于这个版本实践的。

环境搭建

已经搭建好 React Native 环境的可以跳过,还没有的可以参考 React Native 中文网的 搭建教程,比较详细。

创建Android原生项目

安卓开发者自然很熟悉这个步骤,然而对于web前端开发者还是比较迷茫的。可以参考一下 使用Android Studio创建一个新的Android工程,创建一个 Empty Activity,接下来会比较好操作。

集成React Native

步骤一:安装相关依赖
在项目根目录下执行 npm init 命令,生成 package.json 文件,添加以下命令

"scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start"
}

执行 npm i react react-native -S 安装 react 和 react-native

步骤二:配置maven
在你的 app/ 目录下的 build.gradle 文件中的 dependencies 里添加 React Native 依赖:

dependencies {
	...
    compile "com.facebook.react:react-native:+"
}

在项目根目录下的 build.gradle 文件中为 React Native 添加一个 maven 依赖的入口,必须写在 "allprojects" 代码块中:

allprojects {
    repositories {
        ...
        maven {
            url "$rootDir/node_modules/react-native/android"
        }
    }
}

步骤三:代码集成,创建我们的 react-native 组件,在根目录下创建 index.js,(在 react-native 0.49.0版本以前,是 index.android.js 和 index.ios.js 作为入口文件,现在合成一个 index.js 文件了)

import React, {Component} from 'react';
import {AppRegistry,View,Text} from 'react-native';

class App extends Component{
	render(){
		return (
			<View>
				<Text>哈哈哈</Text>
			</View>
		)
	}
}

AppRegistry.registerComponent('ReactNativeView', () => App);

然后创建 MyReactActivity,Activity 是安卓中基本的页面单元,简单的说,可以看做 web 开发中的一个 html 页面。
在上面创建安卓项目的时候,已经创建了一个 MainActivity,在它的同级目录下,在 Android Studio右键新建一个 Activity,命名为 MyReactActivity,然后把内容改为如下:

package com.example.administrator.androidreactnative;

import javax.annotation.Nullable;
import com.facebook.react.ReactActivity;

public class MyReactActivity extends ReactActivity {
    @Nullable
    @Override
    protected String getMainComponentName() {
        return "ReactNativeView";
    }
}

接着创建 MainApplication

package com.example.administrator.androidreactnative;

import android.app.Application;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import com.facebook.soloader.SoLoader;
import java.util.Arrays;
import java.util.List;

public class MainApplication extends Application implements ReactApplication {

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
				new MainReactPackage()
            );
        }

    };

    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        SoLoader.init(this,false);
    }
}

最后在 app/src/main/AndroidManifest.xml 文件中,添加一些权限,以及声明MainApplication 跟 MyReactActivity

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.administrator.androidreactnative">

    <uses-permission android:name="android.permission.INTERNET"/>   <!-- 网络权限 -->
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <!-- 弹框权限 -->
    <uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW"/> <!-- 窗体覆盖权限 -->
	<!-- 声明MainApplication -->
    <application
        android:name=".MainApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
		<!-- 声明MyReactActivity -->
        <activity
            android:name=".MyReactActivity"
            android:label="@string/app_name"
            android:theme="@style/AppTheme">
        </activity>
		<!-- 声明可以通过晃动手机或者点击Menu菜单打开相关的调试页面 -->
        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
    </application>
</manifest>

原生页面跳转到react-native页面

在 MainActivity 添加一个按钮跳转到 MyReactActivity,首先在 app/src/main/res/layout 下的 activity_main.xml 添加一个按钮元素

<Button
	android:id="@+id/btn"
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:text="点击跳转到RN界面"/>

然后在 MainActivity 里添加点击跳转事件

package com.example.administrator.androidreactnative;

import android.support.v7.app.AppCompatActivity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
		// 点击按钮跳转到 react-native 页面
        findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                startActivity(new Intent(MainActivity.this,MyReactActivity.class));
            }
        });
    }
}

然后在Android Studio 的模拟器中打开就可以看到以下页面:

同时执行 npm start 启动本地服务器,点击按钮,出现了红屏,也就是错误页面。

从错误信息 error: bundling failed: NotFoundError: Cannot find entry file index.android.js in any of the roots 我们可以看出找不到入口文件index.android.js,而我们的入口文件是 index.js,因此我们需要另外加一些配置让它知道我们的入口文件其实是 index.js
解决方法参考 react-native/issues/16517。在 app/ 目录下的 build.gradle 文件中最上面添加

apply plugin: 'com.android.application'	// 这时原来存在的
apply from: "../node_modules/react-native/react.gradle"
project.ext.react = [
    entryFile: "index.js"
]

然后在 MainApplication 的 ReactNativeHost 类中添加:

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
	...
	// 这是需要添加的
	@Override
	protected String getJSMainModuleName() {
		return "index";
	}
}

重新在模拟器中运行,就可以正常跳转到 react-native 的页面了。

源码地址:lin-xin/ReactNativeInAndroid

vue3 + tsrpc +mongodb 实现后台管理系统

前言

之前上线了一个vue后台管理系统,有小伙伴问我有没有后端代码,咱只是个小前端,这就有点为难我了。不过不能辜负小伙伴的信任,nodejs也可以啊,废话不多说,开搞!后端采用 TSRPC 框架实现 API 接口,前端采用 vue-manage-system 后台管理系统框架,数据库采用 mongodb。TSRPC 是专为 TypeScript 设计的 RPC 框架,经千万级用户验证。适用于 HTTP API、WebSocket 实时应用、NodeJS 微服务等场景。有兴趣深入了解可以参考 TSRPC官方文档。

创建项目

用 TSRPC 脚手架快速创建一个项目,会生成 backend 和 frontend 两个文件夹,把 vue-manage-system 前端代码替换到 frontend 中,安装相关依赖,就完成一个基本的前后端完整项目了。

使用 mongodb,在backend/src下创建目录和文件 mongodb/index.ts

import { Db, MongoClient } from "mongodb";

export class Global {
    static db: Db;
    static async initDb() {
        const uri = 'mongodb://127.0.0.1:27017/test?authSource=admin';
        const client = await new MongoClient(uri).connect();
        this.db = client.db();
    }
}

在 src/index.ts 中初始化 mongodb 连接

import { Global } from './mongodb/index';

async function init() {
    // ...
    await Global.initDb();
};

vue-manage-system 是基于vue3实现的一个后台管理系统解决方案,代码简单,上手容易,已经在多个项目中应用。下载代码覆盖到 frontend 文件夹下,保留 src/client.ts 文件,这是 tsrpc 框架提供给客户端调用后端接口的方法。重装依赖,即可运行起来。
接下来实现一个用户管理的前后端功能。

后端接口

在 backend/shared/protocols 下新建一个 users 文件夹,用于定义用户管理的相关接口。在该目录下,新建 db_User.ts 文件,用于定义用户集合的字段类型,先按照vue-manage-system前端框架中已有的表格字段随便定义下吧。

import { ObjectId } from 'mongodb';

export interface db_User {
    _id: ObjectId;
    name: string;	// 用户名
    pwd: string;    // 密码
    thumb?: string;  // 头像
    money: number;  // 账户余额
    state: number;  // 账户状态
    address: string;    // 地址
    date: Date; // 注册日期
}

一个用户拥有以上的字段,接下来实现用户管理的增删查改操作。在users目录下分别创建 PtlAdd.ts、PtlDel.ts、PtlGet.ts、PtlUpdate.ts文件,TSRPC 完全通过文件名和类型名来识别协议,务必要严格按照 TSRPC 规定的名称前缀来命名,文件名为:Ptl{接口名}.ts,在 src/api/users 目录下,也会生成对应的 Apixxx.ts 文件,就是对应的接口 users/Add、users/Del、users/Get、users/Update。

新增

// PtlAdd.ts
import { BaseRequest, BaseResponse, BaseConf } from "../base";
import { db_User } from "./db_User";

export interface ReqAdd extends BaseRequest {
    query: Omit<db_User, '_id'>		// 除了_id自动生成,db_User其它属性都作为入参
}

export interface ResAdd extends BaseResponse {
    newID: string;		// 请求成功时返回_id
}

TSRPC 有统一的 错误处理 规范,这里不需要考虑成功、失败和错误的情况,不用定义code、data、message等字段,TSRPC 会返回以下格式

{
	isSucc: true,
	data: {
		newID: 'xxx'
	}
}

在 src/api/users/ApiAdd.ts 中,实现接口的主要逻辑,把数据插入数据库集合中。

import { Global } from './../../mongodb/index';
import { ApiCall } from "tsrpc";
import { ReqAdd, ResAdd } from "../../shared/protocols/users/PtlAdd";

export default async function (call: ApiCall<ReqAdd, ResAdd>) {
	// 这里就省略了各种判断
    const ret = await Global.db.collection('User').insertOne(call.req.query);
    return call.succ({ newID: ret.insertedId.toString() })
}

同理,把另外三个接口也加上

删除

// PtlDel.ts
import { ObjectId } from "mongodb";
import { BaseRequest, BaseResponse, BaseConf } from "../base";

export interface ReqDel extends BaseRequest {
    _id: ObjectId
}

export interface ResDel extends BaseResponse {
    matchNum: number;
}

// ApiDel.ts
import { ApiCall } from "tsrpc";
import { Global } from "../../mongodb";
import { ReqDel, ResDel } from "../../shared/protocols/users/PtlDel";

export default async function (call: ApiCall<ReqDel, ResDel>) {
    const ret = await Global.db.collection('User').deleteOne({ _id: call.req._id });
    return call.succ({ matchNum: ret.deletedCount })
}

查询

// PtlGet.ts
import { db_User } from './db_User';
import { BaseRequest, BaseResponse, BaseConf } from "../base";

export interface ReqGet extends BaseRequest {
    query: {
        pageIndex: number;
        pageSize: number;
        name?: string;
    };
}

export interface ResGet extends BaseResponse {
    data: db_User[],
    pageTotal: number
}

// ApiGet.ts
import { Global } from './../../mongodb/index';
import { ApiCall } from "tsrpc";
import { ReqGet, ResGet } from "../../shared/protocols/users/PtlGet";

export default async function (call: ApiCall<ReqGet, ResGet>) {
    const { pageIndex, pageSize, name } = call.req.query;
    const filter: any = {}
    if (name) {
        filter.filter = new RegExp(name!)
    }
    const ret = await Global.db.collection('User').aggregate([
        {
            $match: filter
        },
        {
            $facet: {
                total: [{ $count: 'total' }],
                data: [{ $sort: { _id: -1 } }, { $skip: (pageIndex - 1) * pageSize }, { $limit: pageSize }],
            },
        },
    ]).toArray()
    return call.succ({
        data: ret[0].data,
        pageTotal: ret[0].total[0]?.total || 0
    })
}

修改

// PtlUpdate.ts
import { BaseRequest, BaseResponse, BaseConf } from "../base";
import { db_User } from "./db_User";

export interface ReqUpdate extends BaseRequest {
    updateObj: Pick<db_User, '_id'> & Partial<Pick<db_User, 'name' | 'money' | 'address' | 'thumb'>>;
}

export interface ResUpdate extends BaseResponse {
    updatedNum: number;
}

// ApiUpdate.ts
import { Global } from './../../mongodb/index';
import { ApiCall } from "tsrpc";
import { ReqUpdate, ResUpdate } from "../../shared/protocols/users/PtlUpdate";

export default async function (call: ApiCall<ReqUpdate, ResUpdate>) {
    let { _id, ...reset } = call.req.updateObj;

    let op = await Global.db.collection('User').updateOne(
        {
            _id: _id,
        },
        {
            $set: reset,
        }
    );

    call.succ({
        updatedNum: op.matchedCount,
    });
}

后端的增删查改接口已经完成,接下来在前端中调用接口。

前端调用接口

在 frontend/src/client.ts 中,TSRPC 提供了 client.callApi 来调用 API 接口,在 table.vue 中我们来调用查询接口并加载到表格中。

import { client } from '../client';
const query = reactive({
	name: '',
	pageIndex: 1,
	pageSize: 10
});
const tableData = ref<TableItem[]>([]);
const pageTotal = ref(0);
// 获取表格数据
const getData = async () => {
	const ret = await client.callApi('users/Get', {
		query: query
	});
	if (ret.isSucc) {
		tableData.value = ret.res.data;
		pageTotal.value = ret.res.pageTotal;
	}
};
getData();

删除操作

const handleDelete = async (id: string) => {
	const ret = await client.callApi('users/Del', { _id });
	if (ret.isSucc) {
		ElMessage.success('删除成功');
	}
};

接口调用比较简单,新增和修改这里就不多描述了,有需要可以看代码。在用户字段中,有个头像,需要后端提供上传图片的接口,在实际业务中,大多数文件上传都会上传到cdn服务器上,不过这里没钱买cdn存储,就只能直接上传到服务器本地。

上传文件

先实现后端上传文件的接口,在 backend/shared/protocols 下新建一个 upload 文件夹,然后在 upload 里创建 PtlUpload.ts 文件

// PtlUpload.ts
import { BaseRequest, BaseResponse, BaseConf } from "../base";

export interface ReqUpload extends BaseRequest {
    fileName: string;
    fileData: Uint8Array;
}

export interface ResUpload extends BaseResponse {
    url: string;
}

这里用到了 Uint8Array 类型,它用于表示8位无符号整数的值的数组。Uint8Array主要提供字节级别的处理能力,如文件读写、二进制数据处理等。

import { ApiCall } from "tsrpc";
import { ReqUpload, ResUpload } from "../../shared/protocols/upload/PtlUpload";
import fs from 'fs/promises';

export default async function (call: ApiCall<ReqUpload, ResUpload>) {
    await fs.access('uploads').catch(async () => {
        await fs.mkdir('uploads')
    })
    await fs.writeFile('uploads/' + call.req.fileName, call.req.fileData);

    call.succ({
        url: call.req.fileName,
    });
}

把上传的文件存储到 uploads 目录下,如果该目录不存在,则先创建。如果想要比较细的话,可以多创建出一个日期的目录,按天存储。

注意:这里文件名是由用户传过来的,有可能出现重名的,按上面的逻辑会覆盖到之前的文件,所以这里可以改成文件名由后端自己生成。

在前端结合 element-plus 的上传组件调用api上传

<el-upload class="avatar-uploader" action="#" :show-file-list="false" :http-request="localUpload">
	<img v-if="form.thumb" :src="UPLOADURL + form.thumb" class="avatar" />
	<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
const localUpload = async (params: UploadRequestOptions) => {
	const ab = await params.file.arrayBuffer();
	var array = new Uint8Array(ab);
	const res = await client.callApi('upload/Upload', {
		fileName: Date.now() + '__' + params.file.name,
		fileData: array
	});
	if (res.isSucc) {
		form.value.thumb = res.res.url;
	} else {
		ElMessage.error(res.err.message);
	}
};

可是在上传后会发现,上传接口成功了,服务器的图片文件也存在,但是图片地址加载失败。原来是 TSRPC 默认创建的项目中没有直接支持静态文件服务,需要我们通过中间件简单处理下即可

静态文件服务

创建 getStaticFile.ts 文件,在中间件中自定义 HTTP 响应,对 Get 类型的请求,找到服务器上对应的文件并返回

import { HttpConnection, HttpServer } from 'tsrpc';
import fs from 'fs/promises';
import * as path from 'path';

export function getStaticFile(server: HttpServer) {
    server.flows.preRecvDataFlow.push(async (v) => {
        let conn = v.conn as HttpConnection;
        if (conn.httpReq.method === 'GET') {
            // 静态文件服务
            if (conn.httpReq.url) {
                // 检测文件是否存在
                let resFilePath = path.join('./', decodeURI(conn.httpReq.url));
                let isExisted = await fs
                    .access(resFilePath)
                    .then(() => true)
                    .catch(() => false);
                if (isExisted) {
                    // 返回文件内容
                    let content = await fs.readFile(resFilePath);
                    conn.httpRes.end(content);
                    return undefined;
                }
            }
            // 默认 GET 响应
            conn.httpRes.end('Not Found');
            return undefined;
        }
        return v;
    });
}

在 backend/src/index.ts 中使用,让每个网络请求都经过这个工作流

import { HttpServer } from "tsrpc";
import { serviceProto } from "./shared/protocols/serviceProto";
import { getStaticFile } from './models/getStaticFile'
const server = new HttpServer(serviceProto, {
    port: 3000,
    json: true
});
getStaticFile(server);

于是图片在前端就可以正常加载出来了。

总结

作为一个小前端,也能做一个完整前后端功能的后台管理系统,再也不用可怜兮兮的等后端接口了,自己一把梭哈,挺适合发展自己的副业余爱好。上面只是个基础的功能,还有许多功能需要慢慢完善,有兴趣可以看代码:tsrpc-manage-system

Node.js 应用:Koa2 之文件上传下载

前言

上传下载在 web 应用中还是比较常见的,无论是图片还是其他文件等。在 Koa 中,有很多中间件可以帮助我们快速的实现功能。

文件上传

在前端中上传文件,我们都是通过表单来上传,而上传的文件,在服务器端并不能像普通参数一样通过 ctx.request.body 获取。我们可以用 koa-body 中间件来处理文件上传,它可以将请求体拼到 ctx.request 中。

// app.js
const koa = require('koa');
const app = new koa();
const koaBody = require('koa-body');

app.use(koaBody({
    multipart: true,
    formidable: {
        maxFileSize: 200*1024*1024	// 设置上传文件大小最大限制,默认2M
    }
}));

app.listen(3001, ()=>{
    console.log('koa is listening in 3001');
})

使用中间件后,就可以在 ctx.request.body.files 中获取上传的文件内容。需要注意的就是设置 maxFileSize,不然上传文件一超过默认限制就会报错。

接收到文件之后,我们需要把文件保存到目录中,返回一个 url 给前端。在 node 中的流程为

  1. 创建可读流 const reader = fs.createReadStream(file.path)
  2. 创建可写流 const writer = fs.createWriteStream('upload/newpath.txt')
  3. 可读流通过管道写入可写流 reader.pipe(writer)
const router = require('koa-router')();
const fs = require('fs');

router.post('/upload', async (ctx){
	const file = ctx.request.body.files.file;	// 获取上传文件
	const reader = fs.createReadStream(file.path);	// 创建可读流
	const ext = file.name.split('.').pop();		// 获取上传文件扩展名
	const upStream = fs.createWriteStream(`upload/${Math.random().toString()}.${ext}`);		// 创建可写流
	reader.pipe(upStream);	// 可读流通过管道写入可写流
	return ctx.body = '上传成功';
})

该方法适用于上传图片、文本文件、压缩文件等。

koa-body 是将上传的文件放到了系统的临时文件里,然后我们再从临时文件里读取到 upload/ 目录下。其实 koa-body 还可以通过 formidable.uploadDir 属性直接设置存储目录

app.use(koaBody({
    multipart: true,
    formidable: {
        maxFileSize: 200*1024*1024,	// 设置上传文件大小最大限制,默认2M
		uploadDir: 'upload/',
		onFileBegin: (name, file)=>{	// 文件存储之前对文件进行重命名处理
            const fileFormat = file.name.split('.');
            file.name = `${Date.now()}.${fileFormat[fileFormat.length-1]}`
            file.path = `upload/${file.name}`;
        }
    }
}));

然后就可以通过 ctx.request.body.files.file 直接获得到上传的文件了。

const router = require('koa-router')();
const fs = require('fs');

router.post('/upload', async (ctx){
	const file = ctx.request.body.files.file;	// 获取上传文件
	return ctx.body = file.path;	// upload/xxx.xx
})

文件下载

koa-send 是一个静态文件服务的中间件,可用来实现文件下载功能。

const router = require('koa-router')();
const send = require('koa-send');

router.post('/download/:name', async (ctx){
	const name = ctx.params.name;
	const path = `upload/${name}`;
	ctx.attachment(path);
    await send(ctx, path);
})

在前端进行下载,有两个方法: window.open 和表单提交。这里使用简单一点的 window.open

<button onclick="handleClick()">立即下载</button>
<script>
	const handleClick = () => {
		window.open('/download/1.png');
	}
</script>

这里 window.open 默认是开启一个新的窗口,一闪然后关闭,给用户的体验并不好,可以加上第二个参数 window.open('/download/1.png', '_self');,这样就会在当前窗口直接下载了。然而这样是将 url 替换当前的页面,则会触发 beforeunload 等页面事件,如果你的页面监听了该事件做一些操作的话,那就有影响了。那么还可以使用一个隐藏的 iframe 窗口来达到同样的效果。

<button onclick="handleClick()">立即下载</button>
<iframe name="myIframe" style="display:none"></iframe>
<script>
	const handleClick = () => {
		window.open('/download/1.png', 'myIframe');
	}
</script>

批量下载

批量下载和单个下载也没什么区别嘛,就多执行几次下载而已嘛。这样也确实没什么问题。如果把这么多个文件打包成一个压缩包,再只下载这个压缩包,是不是体验起来就好一点了呢。

文件打包

archiver 是一个在 Node.js 中能跨平台实现打包功能的模块,支持 zip 和 tar 格式。

const router = require('koa-router')();
const send = require('koa-send');
const archiver = require('archiver');

router.post('/downloadAll', async (ctx){
	// 将要打包的文件列表
	const list = [{name: '1.txt'},{name: '2.txt'}];
	const zipName = '1.zip';
	const zipStream = fs.createWriteStream(zipName);
    const zip = archiver('zip');
    zip.pipe(zipStream);
	for (let i = 0; i < list.length; i++) {
		// 添加单个文件到压缩包
		zip.append(fs.createReadStream(list[i].name), { name: list[i].name })
	}
	await zip.finalize();
	ctx.attachment(zipName);
	await send(ctx, zipName);
})

如果直接打包整个文件夹,则不需要去遍历每个文件 append 到压缩包里

const zipStream = fs.createWriteStream('1.zip');
const zip = archiver('zip');
zip.pipe(zipStream);
// 添加整个文件夹到压缩包
zip.directory('upload/');
zip.finalize();

注意:打包整个文件夹,生成的压缩包文件不可存放到该文件夹下,否则会不断的打包。

中文编码问题

当文件名含有中文的时候,可能会出现一些预想不到的情况。所以上传时,含有中文的话我会对文件名进行 encodeURI() 编码进行保存,下载的时候再进行 decodeURI() 解密。

ctx.attachment(decodeURI(path));
await send(ctx, path);

ctx.attachment 将 Content-Disposition 设置为 “附件” 以指示客户端提示下载。通过解码后的文件名作为下载文件的名字进行下载,这样下载到本地,显示的还是中文名。

然鹅,koa-send 的源码中,会对文件路径进行 decodeURIComponent() 解码:

// koa-send
path = decode(path)

function decode (path) {
  try {
    return decodeURIComponent(path)
  } catch (err) {
    return -1
  }
}

这时解码后去下载含中文的路径,而我们服务器中存放的是编码后的路径,自然就找不到对应的文件了。

要想解决这个问题,那么就别让它去解码。不想动 koa-send 源码的话,可使用另一个中间件 koa-sendfile 代替它。

const router = require('koa-router')();
const sendfile = require('koa-sendfile');

router.post('/download/:name', async (ctx){
	const name = ctx.params.name;
	const path = `upload/${name}`;
	ctx.attachment(decodeURI(path));
    await sendfile(ctx, path);
})

Node.js 入门:Express + Mongoose 基础使用

前言

Express 是基于 Node.js 平台的 web 应用开发框架,在学习了 Node.js 的基础知识后,可以使用 Express 框架来搭建一个 web 应用,实现对数据库的增删查改。

数据库选择 MongoDB,它是一个基于分布式文件存储的开源数据库系统,Mongoose 是 MongoDB 的对象模型工具,可以在异步环境里工作。

接下来就使用 Express + Mongoose 来实现简单的增删查改,在实现的过程中来学习 Express 和 Mongoose 的基础知识。

准备

既然是基于 Node.js 的框架,那么肯定需要装 node.js,还有 MongoDB,网上有很多安装教程。然后使用 express-generator 来快速生成一个 Express 项目。那么先安装一下 express-generator

npm install -g express-generator

然后初始化一个名为 express-demo 的项目

express express-demo

目前 Express 已经发布到了 4.x 版本,接下来也是基于这个版本来实现的。

cd express-demo
npm install
npm start

浏览器打开 http://localhost:3000 ,就可以看到已经可以访问了。

目录

├─bin/      // 启动文件
├─public/   // 资源文件
├─routes/   // 路由
├─views/    // 视图
├─app.js
└─package.json

初始化的项目目录简单明了,接下来我们来看看 app.js 里是写了什么。

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

这是设置模板引擎,使用了 jade 模板引擎,views/ 目录下都是 .jade 格式文件,这种写法我并不熟悉,那么来改一下,改成 ejs 引擎,.html 格式的视图文件。(需要 npm 安装 ejs 模块)

var ejs = require('ejs');
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('html', ejs.__express);
app.set('view engine', 'html');

实现

以上准备都好了之后,我们就来看看如何实现用户信息的增删查改。这里我们先把视图和路由搭建起来,能访问页面之后再来实现数据库操作的功能。

用户列表

在 view/ 视图目录下创建以下文件: UserList.html

<!DOCTYPE html>
<html>
<head>
    <title>用户列表页面</title>
</head>
<body>
    <table>
        <tr>
            <th>用户名</th>
            <th>邮箱</th>
            <th>操作</th>
        </tr>
        <% for(var i in user){ %>
            <tr>
                <td><%= user[i].username %></td>
                <td><%= user[i].email %></td>
                <td>
                    <div>
                        <a href="/users/detail/<%= user[i]._id %>"> 查看 </a>
                        <a href="/users/edit/<%= user[i]._id %>"> 编辑 </a>
                        <a href="#" class="del" data-id="<%= list[i]._id %>"> 删除 </a>
                    </div>
                </td>
            </tr>
        <% } %>
    </table>
</body>
</html>

<% %> 就是 ejs 模板引擎的语法,user 是在路由渲染页面的时候传过来的,它是如何传的,待会再看。
接下来实现上面视图对应的路由,项目中默认已经给我们生成了两个路由。在 routes/ 路由目录下已经有了两个路由文件:index.js 和 users.js。

app.js 中,已经帮我们设置好了这两个路由:

var index = require('./routes/index');
var users = require('./routes/users');
app.use('/', index);
app.use('/users', users);

只要浏览器访问 http://localhost:3000/users ,就能访问到 users 对应的页面了。我们来看看路由里 users.js 是如何写的。

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

module.exports = router;

express.Router 类创建模块化、可挂载的路由句柄。我们修改上面代码来创建用户列表的路由 users/list

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

router.get('/list', function(req, res, next) {
  var list = [{_id: 1, username: 'linxin', email: '[email protected]'}];
  res.render('UserList',{
      user: list
  })
});

module.exports = router;

还记得在 UserList.html 视图中的 user 变量吗,这里用到了 res.render() 响应方法,功能就是渲染视图模板,第一个参数为视图文件名,第二个参数为对象,用于向模板中传递数据,user 就是在这里传过去的。更改完路由之后重启服务器,访问 http://localhost:3000/users/list 就可以看到用户列表页面了。

但是这用户信息是写死的,要怎么从数据库中读取呢?

连接数据库

我们这里用到了 Mongoose,需要先安装 npm install mongoose -S 。安装之后在项目中引入并连接到数据库 userdb

var mongoose = require('mongoose');
mongoose.Promise = global.Promise;
mongoose.connect('mongodb://localhost:27017/userdb', {useMongoClient: true});

连接成功之后,定义一个 Schema,它一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力。

var userSchema = new mongoose.Schema({
    username: String,
    email: String
})

这里的 userSchema 还不能对数据库进行操作,只是定义了数据模型属性 username, email 为字符串类型。需要将该 Schema 发布为 Model,Model 是由 Schema 发布生成的模型,具有抽象属性和行为的数据库操作对。

var model = mongoose.model('user', userSchema);

最后 model 就可以对数据库进行操作了,把上面的代码封装成 userModel.js 到根目录下新建一个 models/ 目录下面,用 module.exports = model; 将 model 暴露出来供其他文件使用。

在 user.js 路由文件里,我们来引入 userModel.js 进行数据库操作。

var userModel = require('../models/userModel.js');

router.get('/list', function(req, res, next) {
  userModel.find(function(err, data){
    if(err){ return console.log(err) }
    res.render('UserList',{
      user: data
    })
  })
});

这里使用 userModel.find() 查询到所有用户。但是现在数据库里还是空的,我们来新增一个添加用户页面向数据库里插入数据。

添加用户

在 views/ 目录下新建 UserAdd.html 添加用户视图

<!DOCTYPE html>
<html>
<head>
    <title>用户编辑页面</title>
</head>
<body>
    <form action="/users/add" method="post">
        <input type="text" name="username" value="">
        <input type="email" name="email" value="">
        <button type="submit">submit</button>
    </form>
</body>
</html>

在 user.js 路由文件里来添加对应视图的路由

router.get('/add', function(req, res, next) {
  res.render('UserAdd');
});

这是渲染视图页面的路由,我们需要添加一个 post 方法的路由,在点击提交按钮的时候,把数据存进数据库里。

router.post('/add', function(req, res, next) {
  var newUser = new userModel({
    username: req.body.username,
    email: req.body.email
  })
  newUser.save(function(err, data){
    if(err){ return console.log(err) }
    res.redirect('/users/list');
  })
});

这里使用 new userModel() 创建了一个 Entity,它是由 Model 创建的实体,它的操作也会影响数据库。newUser 调用 save() 方法将数据保存到数据库中。然后 res.redirect() 将页面重定向到用户列表页面,这时就可以看到我们新增的用户显示在列表中了。接下来我们看看如何来编辑用户信息。

编辑用户

依然是创建相应的用户编辑视图:UserEdit.html

<!DOCTYPE html>
<html>
<head>
    <title>用户编辑页面</title>
</head>
<body>
    <form action="/users/update" method="post">
        <input type="hidden" name="id" value="<%= user._id %>">
        <input type="text" name="username" value="<%= user.username %>">
        <input type="email" name="email" value="<%= user.email %>">
        <button type="submit">update</button>
    </form>
</body>
</html>

添加对应的路由:/users/edit/:id 来渲染视图,/users/update 来修改数据库数据

router.get('/edit/:id', function (req, res, next) {
  var id = req.params.id;
  userModel.findOne({_id: id}, function (err, data) {
    res.render('UserEdit', {
      user: data
    })
  })
});
router.post('/update', function (req, res, next) {
  var id = req.body.id;
  userModel.findById(id, function (err, data) {
    if(err){ return console.log(err); }
    data.username = req.body.username;
    data.email = req.body.email;
    data.save(function(err){
      res.redirect('/users/list');
    })
  })
});

userModel.findOne() 会根据查询条件 {_id: id} 查询到对应的一条数据,那么同理,查看用户详情的实现也是如此,只是渲染你到另外一个模板而已,这里就不重复写了;userModel.findById() 查询到 data 对象,该对象也属于 Entity,有 save() 操作。req.body.username 就可以获取到我们修改后的 username,修改 data 对象之后调用 save() 方法保存到数据库中。接下来看看如何删除用户吧。

删除用户

在用户列表中,点击删除按钮,就把该用户从数据库中给删除了,不需要视图,那直接写路由吧。

router.delete('/del', function (req, res) {
  var id = req.query.id;
  userModel.remove({_id: id}, function (err, data) {
    if(err){ return console.log(err); }
    res.json({code: 200, msg: '删除成功'});
  })
})

点击按钮,发送删除的请求,那我们可以使用 ajax 来实现。在用户列表页面引入 jquery,方便我们操作。然后添加 ajax 请求

$('.del').on('click',function(){
    var id = $(this).data('id');
    $.ajax({
        url: '/users/del?id='+id,
        type: 'delete',
        success: function (res) { console.log(res); }
    })
})

重启服务器,进入 users/list,点击删除按钮,如果看到控制台中已经打印了 {code: 200, msg: '删除成功'} ,表示已经成功删除了,这时我们刷新页面,看看列表中确实已经不存在该用户了。

代码地址: express-demo

总结

通过对用户的增删查改,学习如何写路由已经如何操作数据库。我们来总结一下:

  1. 定义 Schema,由 Schema 发布 Model 来操作数据库。
  2. Model 创建的实体 Entity,可以调用 save() 方法将数据保存到数据库中。
  3. Model.find() 方法查询到该 Schema 下的所有数据,findOne() 根据条件查询数据,findById() 根据 id 查询数据。
  4. Model.remove() 删除数据。

微信小程序之购物车功能

前言

以往的购物车,基本都是通过大量的 DOM 操作来实现。微信小程序其实跟 vue.js 的用法非常像,接下来就看看小程序可以怎样实现购物车功能。

需求

image

先来弄清楚购物车的需求。

  • 单选、全选和取消,而且会随着选中的商品计算出总价
  • 单个商品购买数量的增加和减少
  • 删除商品。当购物车为空时,页面会变为空购物车的布局

根据设计图,我们可以先实现静态页面。接下来,再看看一个购物车需要什么样的数据。

  • 首先是一个商品列表(carts),列表里的单品需要:商品图(image),商品名(title),单价(price),数量(num),是否选中(selected),商品id(id)
  • 然后左下角的全选,需要一个字段(selectAllStatus)表示是否全选了
  • 右下角的总价(totalPrice)
  • 最后需要知道购物车是否为空(hasList)

知道了需要这些数据,在页面初始化的时候我们先定义好这些。

代码实现

初始化

Page({
    data: {
        carts:[],               // 购物车列表
        hasList:false,          // 列表是否有数据
        totalPrice:0,           // 总价,初始为0
        selectAllStatus:true    // 全选状态,默认全选
    },
    onShow() {
        this.setData({
          hasList: true,        // 既然有数据了,那设为true吧
          carts:[
            {id:1,title:'新鲜芹菜 半斤',image:'/image/s5.png',num:4,price:0.01,selected:true},
            {id:2,title:'素米 500g',image:'/image/s6.png',num:1,price:0.03,selected:true}
          ]
        });
      },
})

购物车列表数据我们一般是通过请求服务器拿到的数据,所以我们放在生命周期函数里给 carts 赋值。想到每次进到购物车都要获取购物车的最新状态,而onLoad和onReady只在初始化的时候执行一次,所以我需要把请求放在 onShow 函数里。(这里先拿点假数据冒充一下吧)

布局 wxml

修好之前写好的静态页面,绑定数据。

 <view class="cart-box">
    <!-- wx:for 渲染购物车列表 -->
    <view wx:for="{{carts}}">
    
        <!-- wx:if 是否选择显示不同图标 -->
        <icon wx:if="{{item.selected}}" type="success" color="red" bindtap="selectList" data-index="{{index}}" />
        <icon wx:else type="circle" bindtap="selectList" data-index="{{index}}"/>
        
        <!-- 点击商品图片可跳转到商品详情 -->
        <navigator url="../details/details?id={{item.id}}">
            <image class="cart-thumb" src="{{item.image}}"></image>
        </navigator>
        
        <text>{{item.title}}</text>
        <text>¥{{item.price}}</text>
        
        <!-- 增加减少数量按钮 -->
        <view>
            <text bindtap="minusCount" data-index="{{index}}">-</text>
            <text>{{item.num}}</text>
            <text bindtap="addCount" data-index="{{index}}">+</text>
        </view>
        
        <!-- 删除按钮 -->
        <text bindtap="deleteList" data-index="{{index}}"> × </text>
    </view>
</view>

<!-- 底部操作栏 -->
<view>
    <!-- wx:if 是否全选显示不同图标 -->
    <icon wx:if="{{selectAllStatus}}" type="success_circle" color="#fff" bindtap="selectAll"/>
    <icon wx:else type="circle" color="#fff" bindtap="selectAll"/>
    <text>全选</text>
    
    <!-- 总价 -->
    <text>¥{{totalPrice}}</text>
</view>

计算总价

总价 = 选中的商品1的 价格 * 数量 + 选中的商品2的 价格 * 数量 + ...
根据公式,可以得到

getTotalPrice() {
    let carts = this.data.carts;                  // 获取购物车列表
    let total = 0;
    for(let i = 0; i<carts.length; i++) {         // 循环列表得到每个数据
        if(carts[i].selected) {                   // 判断选中才会计算价格
            total += carts[i].num * carts[i].price;     // 所有价格加起来
        }
    }
    this.setData({                                // 最后赋值到data中渲染到页面
        carts: carts,
        totalPrice: total.toFixed(2)
    });
}

页面中的其他操作会导致总价格变化的都需要调用该方法。

选择事件

点击时选中,再点击又变成没选中状态,其实就是改变 selected 字段。通过 data-index="{{index}}" 把当前商品在列表数组中的下标传给事件。

selectList(e) {
    const index = e.currentTarget.dataset.index;    // 获取data- 传进来的index
    let carts = this.data.carts;                    // 获取购物车列表
    const selected = carts[index].selected;         // 获取当前商品的选中状态
    carts[index].selected = !selected;              // 改变状态
    this.setData({
        carts: carts
    });
    this.getTotalPrice();                           // 重新获取总价
}

全选事件

全选就是根据全选状态 selectAllStatus 去改变每个商品的 selected

selectAll(e) {
    let selectAllStatus = this.data.selectAllStatus;    // 是否全选状态
    selectAllStatus = !selectAllStatus;
    let carts = this.data.carts;

    for (let i = 0; i < carts.length; i++) {
        carts[i].selected = selectAllStatus;            // 改变所有商品状态
    }
    this.setData({
        selectAllStatus: selectAllStatus,
        carts: carts
    });
    this.getTotalPrice();                               // 重新获取总价
}

增减数量

点击+号,num加1,点击-号,如果num > 1,则减1

// 增加数量
addCount(e) {
    const index = e.currentTarget.dataset.index;
    let carts = this.data.carts;
    let num = carts[index].num;
    num = num + 1;
    carts[index].num = num;
    this.setData({
      carts: carts
    });
    this.getTotalPrice();
},
// 减少数量
minusCount(e) {
    const index = e.currentTarget.dataset.index;
    let carts = this.data.carts;
    let num = carts[index].num;
    if(num <= 1){
      return false;
    }
    num = num - 1;
    carts[index].num = num;
    this.setData({
      carts: carts
    });
    this.getTotalPrice();
}

删除商品

点击删除按钮则从购物车列表中删除当前元素,删除之后如果购物车为空,改变购物车为空标识hasList为false

deleteList(e) {
    const index = e.currentTarget.dataset.index;
    let carts = this.data.carts;
    carts.splice(index,1);              // 删除购物车列表里这个商品
    this.setData({
        carts: carts
    });
    if(!carts.length){                  // 如果购物车为空
        this.setData({
            hasList: false              // 修改标识为false,显示购物车为空页面
        });
    }else{                              // 如果不为空
        this.getTotalPrice();           // 重新计算总价格
    }   
}

总结

虽然一个购物车功能比较简单,但是里面涉及到微信小程序的知识点还是比较多的,适合新手练习掌握。

完整的小程序商城demo含购物车,请戳:wxapp-mall

Rollup.js: 开源JS库的打包利器

前言

Rollup 是一个 JavaScript 模块打包器,说到模块打包器,自然就会想到 webpack。webpack 是一个现代 JavaScript 应用程序的静态模块打包器,那么在 webpack 已经成为前端构建主流的今天,为什么还要用 Rollup 呢?

Rollup 中文文档 中介绍到,它可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。可以看到它的应用场景之一,就是打包 JS 库。自己写个 JS 库,在我们开发工作中和开源项目中还是比较常见的。可谓是生命不息,造轮子不止。如果还没写过,那就赶紧来提升下自己的技(逼)术(格)吧。

对比 webpack

用过 webpack 的都知道,它可以将所有的静态资源,导入到应用程序中,也是因为它强大的功能,所以打包 bundle 文件时,会产生很多冗余的代码,在大型的应用中,这点冗余代码就会显得微不足道,但是在一个小小的库中,就会显得比较明显了。比如这么一个类:

class People{
    constructor(){
        this.name  = 'linxin'
    }
    getName(){ return this.name; }
}
export default People;

经过 webpack 打包之后,变成下面这样(案例移除了具体内容),多出了很多方法,这显然不是我们想要的。

/******/ (function(modules) { // webpackBootstrap
/******/ 	var installedModules = {};
/******/ 	function __webpack_require__(moduleId) { **** }
/******/ 	__webpack_require__.m = modules;
/******/ 	__webpack_require__.c = installedModules;
/******/ 	__webpack_require__.d = function(exports, name, getter) { *** };
/******/ 	__webpack_require__.r = function(exports) { *** };
/******/ 	__webpack_require__.t = function(value, mode) { *** };
/******/ 	__webpack_require__.n = function(module) { *** };
/******/ 	__webpack_require__.o = function(object, property) { *** };
/******/ 	__webpack_require__.p = "";
/******/ 	return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/******/ ([ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__);
class People{
    constructor(){
        this.name  = 'linxin'
    }
    getName(){
        return this.name;
    }
}
/* harmony default export */ __webpack_exports__["default"] = (People); }) ]);

而 Rollup 打包之后的代码跟源码基本一致,作为一个 JS 库,我们还是希望简洁一点,代码量少点。毕竟实现相同的功能,谁都不想去引入一个更繁重的库吧。

特性

ES模块

Rollup 使用 ES6 的模块标准,而不像 CommonJS 和 AMD,虽然也叫模块化,其实只是一种临时的解决方案。Rollup 的模块可以使我们开发时可以独立的开发每个小模块,代码小而简单,更加方便测试每个小模块,在构建时才打包成一个完成的 JS 库。

Tree-shaking

tree shaking 指的是移除 JavaScript 上下文中的未引用代码,它依赖于 ES2015 模块系统中的静态结构特性,例如 import 和 export。静态结构的 import 就好像是变量引用一样,不需要执行代码,在编译时就可以确定它是否有引用到,如果没引用,就不把该段代码打包进来。比如用到了一个第三方库的一个功能,但我肯定不要把它完整的库都打包进来,我只需要打包用到的代码即可,这时候 tree shaking 就可以发挥出它的作用了。

应用

开发一个 JS 库,我需要 Rollup 能为我提供些常用的功能:

  • 支持 ES6 转 ES5
  • 代码压缩
  • ESLint
  • 支持 Typescript

基本配置

Rollup 使用一个 rollup.config.js 文件进行配置。

// rollup.config.js
export default {
	input: 'src/index.js',
	output: {
		file: 'dist/bundle.js',
		format: 'umd'
	}
};

配置跟其他工具基本一致,从入口文件 index.js 打包后输出文件 bundle.js。format 是生成包的格式,可选有 amd,cjs,es,iife,umd,umd 是通用模块定义,打包后可以通过 <script> 标签引入,也可以通过 import 等方式引入,作为一个 JS 库要适用各个场景,应选择 umd 。

babel

Rollup 通过插件在打包的关键过程中更改行为,babel的插件就是 rollup-plugin-babel,需要先安装相关依赖

npm i rollup-plugin-babel@latest @babel/core @babel/preset-env -D

新建 .babelrc 文件,配置 babel

{
    "presets": ["@babel/preset-env"]
}

代码压缩

npm i rollup-plugin-uglify -D

因为 rollup-plugin-uglify 无法压缩 ES6 的语法,所以必须先用 babel 转。如果想直接压缩 ES6 的语法,可换成 rollup-plugin-terser

ESLint

开发一个 JS 库,不能乱七八糟,随心所欲的写代码,必须规范起来,当别人为你的开源库做贡献时,也必须遵循你的开发规范。安装 ESLint 插件

npm i rollup-plugin-eslint -D

然后初始化生成一个 ESLint 配置文件 ./node_modules/.bin/eslint --init

那么最终的 rollup.config.js 配置文件如下:

import babel from 'rollup-plugin-babel';
import { uglify } from 'rollup-plugin-uglify';
import { eslint } from "rollup-plugin-eslint";
export default {
	input: './index.js',
	output: {
        file: 'dist/bundle.js',
        name: 'People',
		format: 'umd'
    },
    plugins: [
		eslint({
			fix: true,
		  	exclude: 'node_modules/**'
		}),
        babel({
          exclude: 'node_modules/**'
        }),
		uglify()
    ]
};

TypeScript

如果使用 TypeScript 进行开发,则需要安装 rollup-plugin-typescript2 插件和相关依赖

npm i rollup-plugin-typescript2 typescript -D

然后初始化生成一个 tsconfig.js 配置文件 tsc --init,那么使用 TypeScript 的打包文件如下:

import typescript from 'rollup-plugin-typescript2';

export default {
	input: './index.ts',
	output: {
		file: 'dist/bundle.js',
		name: 'People',
		format: 'umd'
	},
	plugins: [
		typescript()
	]
}

插件

除了以上用的这些插件之外,还有一些可能根据项目需求也有需要

  • rollup-plugin-commonjs:让 Rollup 识别 commonjs 类型的包,默认只支持导入ES6
  • rollup-plugin-node-resolve:让 Rollup 能识别 node_modules 中的包,引入第三方库,默认识别不了的
  • rollup-plugin-json:支持 json 文件
  • rollup-plugin-replace:支持字符串替换
  • rollup-plugin-sourcemaps:能生成 sourcemaps 文件

总结

以上只是介绍了 Rollup 的一些基本用法,更多的请参考官方文档。Rollup 已被许多主流的 JavaScript 库使用,包括 vue 和 react。它也可用于构建绝大多数应用程序,但是代码拆分和运行时态的动态导入这类高级功能,它还不能支持,如果需用这些功能,那就可以使用 webpack。

精通 gulp 常用插件的功能和用法

gulp的官方定义非常简洁: 基于文件流的构建系统 。通过代码优于配置的策略,Gulp 让简单的任务简单,复杂的任务可管理。利用 Node.js 流的威力,你可以快速构建项目并减少频繁的 IO 操作。Gulp 严格的插件指南确保插件如你期望的那样简洁高质得工作。

匹配符 *、**、!、{}

src('./js/*.js')               // * 匹配js文件夹下所有.js格式的文件
src('./js/**/*.js')            // ** 匹配js文件夹的0个或多个子文件夹
src(['./js/*.js','!./js/index.js'])    // ! 匹配除了index.js之外的所有js文件
src('./js/**/{omui,common}.js')        // {} 匹配{}里的文件名

文件操作

del (替代gulp-clean)

const del = require('del');

del('./dist');                      // 删除整个dist文件夹

gulp-rename

描述:重命名文件。

const { src, dest } = require('gulp');
const rename = require("gulp-rename");

const renameTask = cb => {
    src('./hello.txt')
        .pipe(rename('gb/goodbye.md'))    // 直接修改文件名和路径
        .pipe(dest('./dist')); 
    cb();
}
const optionTask = cb => {
    src('./hello.txt')
        .pipe(rename({
            dirname: "text",                // 路径名
            basename: "goodbye",            // 主文件名
            prefix: "pre-",                 // 前缀
            suffix: "-min",                 // 后缀
            extname: ".html"                // 扩展名
        }))
        .pipe(dest('./dist'));
    cb();
}

gulp-concat

描述:合并文件。

const { src, dest } = require('gulp');
const concat = require('gulp-concat');

const concatTask = cb => {
    src('./js/*.js')
        .pipe(concat('all.js'))         // 合并all.js文件
        .pipe(dest('./dist'));
    cb();
}

gulp-filter

描述:在虚拟文件流中过滤文件。

const { src, dest } = require('gulp');
const filter = require('gulp-filter');

const f = filter(['**', '!*/index.js']);
const fTask = cb => {
    src('js/**/*.js')
        .pipe(f)                        // 过滤掉index.js这个文件
        .pipe(dest('dist'));
    cb();
}

const f1 = filter(['**', '!*/index.js'], {restore: true});
const f1Task = cb => {
    src('js/**/*.js')
        .pipe(f1)                       // 过滤掉index.js这个文件
        .pipe(uglify())                 // 对其他文件进行压缩
        .pipe(f1.restore)               // 返回到未过滤执行的所有文件
        .pipe(dest('dist'));       // 再对所有文件操作,包括index.js
    cb();
}

压缩

gulp-uglify

描述:压缩js文件大小。

const { src, dest } = require('gulp');
const uglify = require("gulp-uglify");

const uglifypTask = cb => {
    src('./hello.js')
        .pipe(uglify())                 // 直接压缩hello.js
        .pipe(dest('./dist'))
    cb();
}

const optionpTask = cb => {
    src('./hello.js')
        .pipe(uglify({
            mangle: true,               // 是否修改变量名,默认为 true
            compress: true,             // 是否完全压缩,默认为 true
            preserveComments: 'all'     // 保留所有注释
        }))
        .pipe(dest('./dist'))
    cb();
}

gulp-csso

描述:压缩优化css。

const { src, dest } = require('gulp');
const csso = require('gulp-csso');

const cssopTask = cb => {
    src('./css/*.css')
        .pipe(csso())
        .pipe(dest('./dist/css'))
    cb();
}

gulp-html-minify

描述:压缩HTML。

const { src, dest } = require('gulp');
const htmlmini = require('gulp-htmlmin');

const minpTask = cb => {
    src('index.html')
        .pipe(htmlmini())
        .pipe(dest('./dist'))
    cb();
}

gulp-imagemin

描述:压缩图片。

const { src, dest } = require('gulp');
const imagemin = require('gulp-imagemin');

const minpTask = cb => {
    src('./img/*.{jpg,png,gif,ico}')
        .pipe(imagemin())
        .pipe(dest('./dist/img'))
    cb();
}

gulp-zip

描述:ZIP压缩文件。

const { src, dest } = require('gulp');
const zip = require('gulp-zip');

const zipTask = cb => {
    src('./src/*')
        .pipe(zip('all.zip'))                   // 压缩成all.zip文件
        .pipe(dest('./dist'))
    cb();
}

JS/CSS自动注入

gulp-autoprefixer

描述:自动为css添加浏览器前缀。

const { src, dest } = require('gulp');
const autoprefixer = require('gulp-autoprefixer');

const prefTask = cb => {
    src('./css/*.css')
        .pipe(autoprefixer())           // 直接添加前缀
        .pipe(dest('dist'))
    cb();
}
const optionTask = cb => {
    src('./css/*.css')
        .pipe(autoprefixer({
            browsers: ['last 2 versions'],      // 浏览器版本
            cascade:true                       // 美化属性,默认true
            add: true                           // 是否添加前缀,默认true
            remove: true                        // 删除过时前缀,默认true
            flexbox: true                       // 为flexbox属性添加前缀,默认true
        }))
        .pipe(dest('./dist'))
    cb();
}

查看更多配置:options

更多浏览器版本:browsers

gulp-useref

描述:解析构建块在HTML文件来代替引用未经优化的脚本和样式表。

<!-- index.html -->
<!-- build:css /css/all.css -->
<link rel="stylesheet" href="css/normalize.css">
<link rel="stylesheet" href="css/main.css">
<!-- endbuild -->
// gulpfile.js
const { src, dest } = require('gulp');
const useref = require('gulp-useref');
const userefTask = cb => {
    src('index.html')
        .pipe(useref())
        .pipe(dest('./dist'))
    cb();
}

替换之后的index.html中就会变成:

<link rel="stylesheet" href="css/all.css">  // 之前的两个<link>替换成一个了

gulp-rev

描述:给静态资源文件名添加hash值:unicorn.css => unicorn-d41d8cd98f.css

const { src, dest } = require('gulp');
const rev = require('gulp-rev');

const revTask = cb => {
    src('./css/*.css')
        .pipe(rev())
        .pipe(dest('./dist/css'))
    cb();
}

gulp-rev-replace

描述:重写被gulp-rev重命名的文件名。

const { src, dest } = require('gulp');
const rev = require('gulp-rev');
const revReplace = require('gulp-rev-replace');
const useref = require('gulp-useref');

const revReplaceTask = cb => {
    src('index.html')
        .pipe(useref())                         // 替换HTML中引用的css和js
        .pipe(rev())                            // 给css,js,html加上hash版本号
        .pipe(revReplace())                     // 把引用的css和js替换成有版本号的名字
        .pipe(dest('./dist'))
    cb();
}

gulp-html-replace

描述:替换html中的构建块。

<!-- index.html -->

<!-- build:css -->                          // css是buildName,可以自己定义
<link rel="stylesheet" href="css/normalize.css">
<link rel="stylesheet" href="css/main.css">
<!-- endbuild -->
// gulpfile.js
const { src, dest } = require('gulp');
const htmlreplace = require('gulp-html-replace');

const replaceTask = cb => {
    src('index.html')
        .pipe(htmlreplace({
            'css':'all.css'                     // css是index.html中定义的buildName
        }))
        .pipe(dest('./dist'))
    cb();
}

替换之后的index.html中就会变成:

    <link rel="stylesheet" href="all.css">      <!-- 之前的两个<link>替换成一个了 -->

流控制

gulp-if

描述:有条件地运行一个任务。

const { src, dest } = require('gulp');
const gulpif = require('gulp-if');
const uglify = require('gulp-uglify');
const concat = require('gulp-concat');
const condition = true; 

const ifTask = cb => {
    src('./js/*.js')
        .pipe(gulpif(condition, uglify(), concat('all.js')))  // condition为true时执行uglify(), else 执行concat('all.js')
        .pipe(dest('./dist/'));
    cb();
}

工具

gulp-load-plugins

描述:从包的依赖和附件里加载gulp插件到一个对象里给你选择。

// package.json 

"devDependencies": {
    "gulp": "^3.9.1",
    "gulp-concat": "^2.6.1",
    "gulp-rename": "^1.2.2",
    "gulp-uglify": "^2.0.1"
}

// gulpfile.js
const { src, dest } = require('gulp');
const $ = require('gulp-load-plugins')();     // $ 是一个对象,加载了依赖里的插件

const minTask = cb => {
    src('./**/*.js')
        .pipe($.concat('all.js'))               // 使用插件就可以用$.PluginsName()
        .pipe($.uglify())
        .pipe($.rename('all.min.js'))
        .pipe(dest('./dist'))
    cb();
}

gulp-sass

描述:编译sass。

const { src, dest, watch } = require('gulp');
const sass = require('gulp-sass');

const sassTask = cb => {
    src('./sass/**/*.scss')
        .pipe(sass({
            outputStyle: 'compressed'           // 配置输出方式,默认为nested
        }))
        .pipe(dest('./dist/css'));
    cb();
}
watch('./sass/**/*.scss', sassTask);   // 实时监听sass文件变动,执行sass任务

gulp-babel

描述:将ES6代码编译成ES5。

const { src, dest } = require('gulp');
const babel = require('gulp-babel');

const jsTask = cb => {
    src('./js/index.js')
        .pipe(babel({
            presets: ['@babel/env'],
            sourceType: 'script'
        }))
        .pipe(dest('./dist'))
    cb();
}

vue.js 组件之间传递数据

前言

组件是 vue.js 最强大的功能之一,而组件实例的作用域是相互独立的,这就意味着不同组件之间的数据无法相互引用。如何传递数据也成了组件的重要知识点之一。

组件

组件与组件之间,还存在着不同的关系。父子关系与兄弟关系(不是父子的都暂称为兄弟吧)。

父子组件

父子关系即是组件 A 在它的模板中使用了组件 B,那么组件 A 就是父组件,组件 B 就是子组件。

// 注册一个子组件
Vue.component('child', {
    data: function(){
        return {
            text: '我是father的子组件!'
        }
    },
    template: '<span>{{ text }}</span>'
})
// 注册一个父组件
Vue.component('father', {
    template: '<div><child></child></div>'  // 在模板中使用了child组件
})

直接使用 father 组件的时候:

<div id="app">
    <father></father>
</div>

页面中就会渲染出 :我是father的子组件!

father 组件在模板中使用了 child 组件,所以它就是父组件,child 组件被使用,所以 child 组件就是子组件。

兄弟组件

两个组件互不引用,则为兄弟组件。

Vue.component('brother1', {
    template: '<div>我是大哥</div>'
})
Vue.component('brother2', {
    template: '<div>我是小弟</div>'
})

使用组件的时候:

<div id="app">
    <brother1></brother1>
    <brother2></brother2>
</div>

页面中就会渲染出 :

我是大哥

我是小弟

Prop

子组件想要使用父组件的数据,我们需要通过子组件的 props 选项来获得父组件传过来的数据。以下我使用在 .vue 文件中的格式来写例子。

如何传递数据

在父组件 father.vue 中引用子组件 child.vue,把 name 的值传给 child 组件。

<template>
    <div class="app">
        // message 定义在子组件的 props 中
        <child :message="name"></child>
    </div>
</template>
<script>
    import child from './child.vue';
    export default {
        components: {
            child
        },
        data() {
            return {
                name: 'linxin'
            }
        }
    }
</script>

在子组件 child.vue 中的 props 选项中声明它期待获得的数据

<template>
    <span>Hello {{message}}</span>
</template>
<script>
    export default {
        // 在 props 中声明获取父组件的数据通过 message 传过来
        props: ['message']
    }
</script>

那么页面中就会渲染出:Hello linxin

单向数据流

当父组件的 name 发生改变,子组件也会自动地更新视图。但是在子组件中,我们不要去修改 prop。如果你必须要修改到这些数据,你可以使用以下方法:

方法一:把 prop 赋值给一个局部变量,然后需要修改的话就修改这个局部变量,而不影响 prop

export default {
    data(){
        return {
            newMessage: null
        } 
    },
    props: ['message'],
    created(){
        this.newMessage = this.message;
    }
}

方法二:在计算属性中对 prop 进行处理

export default {
    props: ['message'],
    computed: {
        newMessage(){
            return this.newMessage + ' 哈哈哈';
        }
    }
}

自定义事件

prop 是单向绑定的:当父组件的属性变化时,将传导给子组件,但是不会反过来。修改子组件的 prop 值,是不会传回给父组件去更新视图的。那么子组件要如何去与父组件通讯呢?

那就是自定义事件。通过在父组件 $on(eventName) 监听自定义事件,当子组件里 $emit(eventName) 触发该自定义事件的时候,父组件执行相应的操作。

比如在父组件中控制一个弹框子组件的显示,在子组件中按下关闭之后,告诉父组件去隐藏它,然后父组件就执行操作隐藏弹框。

<template>
    <div class="app">
        // hide 为自定义事件,名字可以自己随便起,不能有大写字母,可以使用短横线
        // @hide 监听子组件触发 hide 事件,则会执行 hideDialog 方法
        <dialog :is-show="show" @hide="hideDialog"></dialog>
        <button @click="showDialog">显示弹框</button>
    </div>
</template>
<script>
    import dialog from './dialog.vue';
    export default {
        components: { dialog },
        data() {
            return {
                show: false
            }
        },
        methods: {
            showDialog() {
                this.show = true;
            },
            hideDialog() {
                this.show = false;
            }
        }
    }
</script>

在子组件 dialog.vue 中:

<template>
    <div class="dialog" v-show="isShow">
        <p>这里是弹框子组件</p>
        <button @click="toHide">关闭弹框</button>
    </div>
</template>
<script>
    export default {
        // 驼峰式命名的 prop 需要转换为相对应的短横线隔开式 is-show
        props: ['isShow'],
        methods: {
            toHide(){
                // $emit 方法触发父组件的监听事件
                this.$emit('hide');
            }
        }
    }
</script>

这样就实现了父子组件之间的相互通讯。

Event Bus

有时候两个组件之间需要进行通信,但是它们彼此不是父子组件的关系。在一些简单场景,你可以使用一个空的 Vue 实例作为一个事件总线中心(central event bus):

var bus = new Vue();

// 在组件 A 的 methods 方法中触发事件
bus.$emit('say-hello', 'world')

// 在组件 B 的 created 钩子函数中监听事件
bus.$on('say-hello', function (arg) {
  console.log('hello ' + arg);          // hello world
})

Vuex

在复杂场景中,你应该考虑使用专门的状态管理模式 Vuex。关于 Vuex,可查看我的另一篇文章:Vuex 模块化实现待办事项的状态管理

总结

组件通讯并不是一定要使用必须要使用 Vuex,对于一些简单的数据传递,event bus 或者 prop 也可以完成。本文主要是对组件传参的一些基础知识点的记录,实战可以参考 notepad 这个例子,使用 prop 实现子组件的显示与隐藏,使用 vuex 来实现组件间的数据状态管理。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.