js实现一键打印网页中所有的图片

最近写了一个小工具,可以一键打印网页中所有的图片链接,效果如下:

实现思路主要分为两部分,一是如何获取网页中所有的图片链接,二是如何在浏览器的控制台打印图片。封装形式用了便捷的书签的方式,可以点一个标签来运行。

(代码链接在最后)

获取网页中所有图片链接

网页中图片主要有三种形式:一是img标签,二是css中的背景图片,三是在style中设置的背景图片。我们分别获取一下这三种图片:

img标签

用dom查询的api获取所有的img标签,返回src的数组

function getDomImage() {
    let imgList = [].slice.call(document.querySelectorAll('img')).map(item => item.src);
    return imgList;
}

有style属性的元素的背景图片

首先通过*[style]选出所有有style属性的标签,然后把内容拼成一个css格式的字符串。之后对这个字符串使用正则匹配url()中的链接,然后放到数组中返回。

function getStyleImage() {
    const imgList = [];
    let styleEles = [].slice.call(document.querySelectorAll("*[style]"));
    styleEles && styleEles.map(styleEle => {
        const styleStr = Object.entries(styleEle.style).filter(item => item[1]).map(item => item[0] + ':' + item[1]).join(';');
        let styleImages = styleStr.match(/url\((.*)\)/g);
        styleImages = styleImages && styleImages.map(item => item.replace(/url\(['"]*([^'"]*)['"]*\)/,'$1'));
        if(styleImages) imgList.push(...styleImages);
    });
    return imgList;
}

css中的背景图片

首先选出所有的style元素,然后获取textContent,之后也是通过正则匹配url()中的链接,然后放入数组返回。

function getCssImage() {
    const styleEles = document.querySelectorAll('style');
    return [].slice.call(styleEles).map(styleEle => {
        const css = styleEle.textContent;
        const cssImages = css.match(/url\((.*)\)/g);
        return cssImages && cssImages.map(item => item.replace(/url\((.*)\)/,'$1')) || [];
    });
}

去重

获取到这三种图片之后,合并到一个数组中。

    function getImages() {
        return getDomImage().concat(...getCssImage()).concat(...getStyleImage());
    }

但现在的数组中可能有重复的图片,因为一个图片可能在页面上出现多次。我们可以通过set来去重。

function uniqueArr(arr) {
    return Array.from(new Set(arr))
}

控制台打印图片

现在有了所有图片的链接,下一步就是打印到控制台了。浏览器console支持%c指定css样式,可以通过background-image的方式来设置图片。这是一种hack的方式。

先打印了一堆的空格,留出空间来显示背景图,然后在这段空白的文字区域实现图片。

    function formatConsole(list) {

        if (window.console) {
            var cons = console;
            if (cons) {
                list.forEach(item => {
                    cons.log("%c\n                                                                                    ", "font-size:100px;background:url('"+ item+"') no-repeat;background-size:contain;");
                    cons.log(item);
                });
            }
        }
    }

这三步完成之后组合调用一下:

let imgs = getImages();
imgs = uniqueArr(imgs);
formatConsole(imgs);

现在获取网页所有图片并打印到控制台的功能已经完成了。

浏览器新建标签

功能已经完成了,可是怎么使用呢。开发一个chrome插件可以,而且不止可以打印到控制台,还可以传到服务器之类的,能做更多的事情。

但我们现在只想打印到控制台,可以使用一种简单的方式:浏览器书签。

在浏览器中新建一个书签,比如内容为javascript:alert(1);,那么你在书签栏点击这个标签的时候就会执行这段js。

基于此,我们只需要把上面的代码再包装一层就可以用了:

javascript: (function() {
    //xxx
})()

总结

至此,我们已经实现了一键打印网页中所有的图片。可以新建一个书签,然后内容复制下面链接中的代码,之后点击书签栏的书签就可以运行了。

代码链接

console-all-image

使用canvas绘制唱跳rap🏀

背景介绍

最近唱跳rap挺流行的,我经常在各种地方看到相关的信息,有个朋友的头像和签名都换成这个了,我想逗他一下,所以想着用canvas画一个。

最终效果如下:

如果好奇可以先把代码下下来跑跑看哈,代码链接在最后。

前期准备

绘制序列帧

其中那个跳舞的人只能用序列帧来做,这几天我买了一个数位板,正好用了一下,以为有了数位板就能画的很好是想多了,最终结合ps和数位板勉强画完了整个跳舞的过程。放在了一张图上:

有没有种武功秘籍的感觉~

createjs简介

自己实现帧动画还是挺复杂的,所以我使用了createjs

先简单介绍一下我对createjs所了解的一些知识,createjs包括几个部分
easeljs用来绘制各种图形的,包括了Stage、Container、Sprite还有Graphics等,帧动画也是这部分实现的
tweenjs是用来制作动画的,各种属性动画在这里实现
preloadjs是用来预加载资源的,绘制时难免会用到各种assets,可以用preloadjs提前加载。
soundjs是音频相关的一些api

easeljs组织各种图形是使用stage、container、spite三层来管理的,stage只有一个,container和sprite可以多个。我感觉比较方便的一点是之前多个canvas中绘制的内容,现在使用多个container就可以了。

我们主要用到了easeljs来组织各种图形和绘制帧动画,包括文字、跳舞的人、星星、心,使用了tweenjs来绘制属性动画,主要是星星的下落和文字的出现动画,使用了preloadjs来预加载资源,也就是跳舞的人需要的那个序列帧图片。

使用了vue、webpack,使用vue-cli生成的基本结构,但基本没用到vue的啥特性,感兴趣的自己去了解下。

绘制

初始化

首先放一个大的canvas在页面中:

<canvas ref="can" width=1200 height=600></canvas>

然后创建根stage,和一个container

const can = this.$refs.can;

const stage = new createjs.Stage(can);     
const container = new createjs.Container();
stage.addChild(container);

加载资源

接下来我们使用preloadjs来加载资源,只要给出一个清单就可以了,id方便后面取资源,src就是资源的路径。

const queue = new createjs.LoadQueue();
queue.loadManifest([
    { id: 'singjumprap', src: './images/singjumprap.png' },
]);
queue.on('complete', () => {
   const img =  queue.getResult('singjumprap')

});

在加载完成之后开始我们的绘制,通过getResult拿到图片,这里的图片已经是HTMLImageElement了,可以直接用。

帧率设置

canvas绘制一般都是使用requestAnimationFrame来做定时的重绘,但帧率是不可控的,这里我们使用了createjs的Ticker来做帧率控制:

createjs.Ticker.addEventListener("tick", tick);
createjs.Ticker.setFPS(30);

tick是每次重绘都会执行的回调函数,有一个event的参数可以拿到每次的间隔事件等信息。

function tick() {
    stage.update();
}

需要在tick里调用stage.uopdate来更新。

绘制跳舞的人

拿到资源了,帧率设置好了,接下来就是绘制跳舞的人的帧动画了。我封装了一个方法:

function startDrawPerson(container, queue) {
    const frames = [
        [13, 250, 840, 1018],
        [1104, 250, 740, 1018],
        [2105, 250, 740,1018],
        [2994, 250, 740,1018],
        [4147, 250, 740,1018],
        [5263, 250, 740,1018],
        [6174, 250, 680,1018],
        [7100, 250, 580,1018],
        [7899, 250, 580,1018],
        [8457, 250, 720,1018],
        [9130, 250, 740,1018],
        [9899, 250, 840,1018],
        [10959, 250, 860,1018],
        [12255, 250, 740,1018],
        [13650, 250, 840,1018],
        [14457, 250, 740,1018],
        [15245, 250, 740,1018],
        [16081, 250, 740,1018],
        [16918, 250, 740,1018],
        [17716, 250, 740,1018],
        [18476, 250, 740,1018] 
    ];
    const spriteSheet = new createjs.SpriteSheet({
        images: [queue.getResult('singjumprap')],
        frames,
        animations: {
            person: [0],
            singjumprap: frames.map((item, index) => index)
        },
    });
    const person = new createjs.Sprite(spriteSheet, 'person');
    person.set({x:300,y:100,scaleX:0.5,scaleY:0.5}); 
    container.addChild(person);
    person.gotoAndPlay('singjumprap');
    return person;
}

这个方法需要传入container和用来拿资源的queue,返回绘制完的sprite。

frames是序列帧的信息,每一个都包括x、y、width、height来从图中截取一部分,这就像web中的雪碧图一样。

images是我们加载好的图片,frames是从中截取出的序列帧,animations就是帧动画信息了,我们从上面的frames中取出一些帧来组成一个帧动画。

我这里取了两个帧动画,一个是开始的person,另一个是跳舞的动作。

然后初始化sprite,调整下大小和位置,之后添加到container中,执行跳舞动画。

跳舞过程的控制

现在已经可以实现跳舞了,但是有两个问题,一个是每一帧切换过快,因为我们设置了FPS是30,并且每一帧都调用了stage.update;二是跳舞是循环的,我们只希望跳一次。

这些可以在tick里控制:

let delTime = 0;
let frameIndex = 0;
let isStop = false;
function tick(evt) {
    delTime += evt.delta
    if( delTime < 300) {
        person.paused = true;
    } else {
        if (frameIndex < frames.length -1) {
            person.paused = false;
            delTime = 0;
            frameIndex++;                     
        } else {
            if(!isStop){
                startDrawHeart(container, heartCan);
                isStop = true;
            }
        }
    }
    stage.update();
}

person就是跳舞的人的sprite对象,可以通过设置paused为true来暂停序列帧的播放,我们几率一个delTime,当超过300ms时让他动一次。

同时每次动的时候记录index,当跳到最后一个动作的时候就设置paused为true,并且不再修改。

然后开始绘制❤️。

绘制跳动的心

首先我们先把心绘制出来,我使用了另一个canvas:

<canvas ref="heartCan" width=50 height=60 style="display:none;"></canvas>

拿到context,然后开始绘制

const heartCan = this.$refs.heartCan;
const heartCtx = heartCan.getContext('2d');
drawHeart(heartCtx, 25,25,10, 0);

绘制心的函数,需要传入context,以及x、y、r以及旋转角度rot

function drawHeart(ctx,x,y,R,rot) { 
    function heartPath(ctx) { 
        ctx.beginPath(); 
        ctx.arc(-1,0,1,Math.PI,0,false); 
        ctx.arc(1,0,1,Math.PI,0,false); //貝塞尔曲线画心 
        ctx.bezierCurveTo(1.9, 1.2, 0.6, 1.6, 0, 3.0); 
        ctx.bezierCurveTo( -0.6, 1.6,-1.9, 1.2,-2,0); 
        ctx.closePath(); 
    }
    ctx.save(); 
    ctx.translate(x,y); 
    ctx.rotate(rot/180*Math.PI); 
    ctx.scale(R, R); 
    heartPath(ctx);
    ctx.fillStyle = "red"; 
    ctx.shadowColor = "gray"; 
    ctx.shadowOffsetX = 2; 
    ctx.shadowOffsetY = 2; 
    ctx.shadowBlur = 2; 
    ctx.fill(); 
} 

心的是使用贝塞尔曲线来绘制的心形路径,之后设置了fillStyle和shadow来填充。

接下来把绘制的心通过Bitmap类型的Sprite创建并添加到container中,并且开始执行跳动的动画。

const startDrawHeart = (container, heartImg) => {
    var bitmap = new createjs.Bitmap(heartImg);
    bitmap.x = 385;
    bitmap.y = 280;
    bitmap.width = 50;
    bitmap.height = 50;
    container.addChild(bitmap);
    const heartBounce = () => {
        setTimeout(heartBounce, 1000);
        createjs.Tween.get(bitmap).to({scale: 1.3}, 500).to({scale: 1}, 500);
    }
    heartBounce();
}

跳动动画逻辑比较简单,就是scale从1到1.3到1之间变换,每次时间间隔都是500ms。然后1秒执行一次动画。

绘制文字和星星坠落

画完之后我觉得表意不够明确,所以我加了一行文字,之后又在文字上加了一些星星。

星星

先绘制星星:

<canvas ref="canStar" width=20 height=20 style="display:none;"></canvas>
const starCan = this.$refs.canStar;
const starCtx = starCan.getContext('2d');
drawStar(starCtx, 1);
const drawStar = (ctx, scale, color = 'gold') => {
    ctx.save();
    ctx.clearRect(0, 0, 100, 100);
    ctx.strokeStyle = color;
    ctx.scale(scale, scale);
    ctx.beginPath();
    ctx.moveTo(0, 10);
    ctx.lineTo(20, 10);
    ctx.moveTo(10, 0);
    ctx.lineTo(10, 20);
    ctx.moveTo(5, 5);
    ctx.lineTo(15, 15);   
    ctx.moveTo(5, 15);
    ctx.lineTo(15, 5);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
}

和绘制心是一样的过程,我画的星星很简单,就是横竖斜线~

文字
const texts = "从未有人像你让我如此怦然心动";
const textAnimInfos = texts.split('').map((item, index) => {
    let text = new createjs.Text(item, "40px monospace", "#000000");
    text.x = 150 + index * 50;
    text.y = 50;
    text.rotation = random( -30, 30);
    text.scale = random(1.2, 1.5);
    return text;
});
let i = 0;
function renderText() {
    const text = textAnimInfos[i];
    container.addChild(text);
    createjs.Tween.get(text).to({rotation: random(-10, 10), scale: random(0.8, 1)}, 100);

    i++;    
    if (i < texts.length) {
        setTimeout(renderText, 350); 
    }          
}
setTimeout(() => {
    renderText();
}, 2000);

因为每个文字都有出现动画,所以每个是一个单独的sprite,先循环生成每个文字的x、y以及初始的rotation和scale等信息,放到textAnimInfos中,每两秒往container中添加一个,出现时执行一些scale和rotation的动画。

然后绘制星星和星星坠落动画:

let j =0;
function renderStar() {
    const text = textAnimInfos[j];
    for (let i =0; i< 10; i++){
        const startX =text.x + random(0, 20);
        const startY =text.y + random(0, 20);
        var star = new createjs.Bitmap(starCan);
        star.x = startX;
        star.y = startY;
        star.width = 10;
        star.height = 10;
        container.addChild(star);
        createjs.Tween.get(star).wait(0).to({y: random(1000, 1200), x: random(0, 1200)}, random(5000, 8000));
    }

    j++;
    if (j < texts.length) {  
        setTimeout(renderStar, 350); 
    }
}

setTimeout(() => {
    renderText();
    renderStar();
}, 2000);

星星因为是和文字结合的,这里位置信息和文字关联,每次绘制从textAnimInfos中取出文字的x、y,然后创建10个(这里可以调整)随机位置的星星添加到容器中,并且执行一个坠落动画。

这里的坠落动画没用啥运动公式,只是随机了一个下方的结束位置并且随机了运动时间。但感觉坠落的还挺好看的~

源码链接

代码在这,需要自取,有问题可以提issue哈
canvas-singjumprap

nodejs实现远程桌面监控

描述

最近使用node实现了一个远程桌面监控的应用,分为服务端和客户端,客户端可以实时监控服务端的桌面,并且可以通过鼠标和键盘来控制服务端的桌面。

这里因为我是用的同一台电脑,所以监控画面是这样的,当然使用两台电脑一个跑客户端,一个跑服务端才有意义。

原理

其实这个应用的功能主要分为两部分,一是实现监控,即在客户端可以看到服务端的桌面,这部分功能是通过定时截图来实现的,比如服务端一秒截几次图,然后通过socketio发送到客户端,客户端通过改变img的src来实现一帧帧的显示最新的图片,这样就能看到动态的桌面了。监控就是这样实现的。

另一个功能是控制,即客户端对监控画面的操作,包括鼠标和键盘的操作都可以在服务端的桌面真正的生效,这部分功能的实现是在electron的应用中监听了所有的鼠标和键盘事件,比如keydown、keyup、keypress,mousedown、mouseup、mousemove、click等,然后通过socketio把事件传递到服务端,服务端通过 robot-js来执行不同的事件,这样就能使得客户端的事件在服务端触发了。

实现

原理讲完,我们来具体实现一下(源码链接在这)。

实现socket通信

首先,服务端和客户端分别引入socket.iosocket.io-client, 分别初始化

服务端:

const app = new Koa();
const server = http.createServer(app.callback());
createSocketIO(server);

app.use((ctx): void => {
    ctx.body = 'please connect use socket';
});

server.listen(port, (): void => {
    console.log('server started at http://localhost:' + port);
});
//createSocketIO
const io = socketIO(server, {
        pingInterval: 10000,
        pingTimeout: 5000,
        cookie: false
    });

io.on('connect', (socket): void => {
    socket.emit('msg', 'connected');
}

客户端:

var socket = this.socket = io('http://' + this.ip + ':3000')
socket.on('msg', (msg) => {
  console.log(msg)
})
socket.on('error', (err) => {
  alert('出错了' + err)
})

这样,服务端和客户端就通过socketio建立了链接。

实现桌面监控

之后我们首先要在服务端来截图,使用screenshot-desktop这个包

const screenshot = require('screenshot-desktop')

const SCREENSHOT_INTERVAL = 500;

export const createScreenshot = (): Promise<[string, Buffer]> => {
    return screenshot({format: 'png'}).then((img): [string, Buffer] => {
        return [ img.toString('base64'), img];
    }).catch((err): {} => {
        console.log('截图失败', err);
        return err;
    })
}

export const startScreenshotTimer = (callback): {} => {
    return setInterval((): void => {
        createScreenshot().then(([imgStr, img]): void => {
            callback(['data:image/png;base64,' + imgStr, img]);
        })
    }, SCREENSHOT_INTERVAL)
}

然后通过socketio的emit来传到客户端:

startScreenshotTimer(([imgStr, img]): void => {
    io.sockets.emit('screenshot', imgStr);
});

客户端收到图片后,设置到img的src上(这里是base64的图片url):

 <img 
    class="screenshot" 
    :src="screenshot"
/>
data () {
  return {
    screenshot: ''
  }
}
socket.on('screenshot', (data) => {
  this.screenshot = data
})

其实这样就已经实现了桌面监控了,有兴趣的同学可以照着这个思路实现看看,并不是很麻烦。

当然这样的方案是有问题的,因为我们需要知道服务端桌面尺寸的大小,然后根据这个来调整客户端显示的图片尺寸。

实现这个细节是使用的get-pixels这个库,可以读取本地图片文件的宽度高度等信息,所以我先把图片写入本地,然后又读取出来,这样获取到的屏幕尺寸。

interface ScreenSize {
    width: number;
    height: number;
}

function getScreenSize(img): Promise<ScreenSize> {
    const imgPath = path.resolve(process.cwd(), './tmp.png');
    fs.writeFileSync(imgPath, img);
    return new Promise((resolve): void => {
        getPixels(imgPath, function(err, pixels): void {
            if(err) {
                console.log("Bad image path")
                return
            }
            resolve({
                width: pixels.shape[0],
                height: pixels.shape[1]
            });
        });
    })
}

然后通过socektio传递给客户端

getScreenSize(img).then(({ width, height}) => {
    io.sockets.emit('screensize', {
        width,
        height
    })
});

客户端收到之后调整图片大小就可以了

<img 
    class="screenshot" 
    :src="screenshot"
    :style="screenshotStyle"
/>
data () {
  return {
    screenshot: '',
    screenshotStyle: '',
  }
}
socket.on('screensize', (screensize) => {
  this.screenshotStyle = {'width': screensize.width + 'px', 'height': screensize.height + 'px'}
})

至此已经实现了桌面监控,并且图片尺寸和服务端屏幕的尺寸是一致的。

这里还有一个细节,就是获取到的图片大小是物理像素,而客户端设置的px是设备无关像素,也就是要除以dpr才是px的值。这里需要获取dpr,因为目前只是在mac下用,所以直接除以2了。

实现远程控制

代码写到这里,客户端的electron应用中已经可以实时显示服务端的桌面了。(当然像输入ip的弹框,以及electron-vue和typescript等和主要逻辑无关的细节就不展开了。)

接下来我们要实现远程控制,也就是监听事件,传递事件,执行事件这几部分。

首先我们定义一下传递的事件的格式:

interface MouseEvent {
    type: string;
    buttonType: string;
    x: number;
    y: number;
}

interface KeyboardEvent {
    type: string;
    keyCode: number;
    keyName: string;
}

鼠标事件MouseEvent,type为鼠标事件的类型,具体的值包括mousedown、mouseup、mousemove、click、dblclick,buttonType指的是鼠标的左键还是右键,值为 left 或 right,x和y是具体的坐标。

键盘事件KeyboardEvent,type为键盘事件的类型,具体的值包括keydown、keyup、keypress,keyCode为键盘码,keyName为键的名字。

接下来我们要在客户端监听事件:

<img 
    class="screenshot" 
    :src="screenshot"
    :style="screenshotStyle"
    @mousedown="handleMouseEvent"
    @mousemove="handleMouseEvent" 
    @mouseup="handleMouseEvent"
    @click="handleMouseEvent"
    @dblclick="handleMouseEvent"   
/>
window.onkeypress = window.onkeyup = window.onkeydown = this.handleKeyboardEvent

通过socekt把事件传递到服务端

  handleKeyboardEvent (e) {
    this.socket && this.socket.emit('userevent', {
      type: 'keyboard',
      event: {
        type: e.type,
        keyName: e.key,
        keyCode: e.keyCode
      }
    })
  },
  handleMouseEvent (e) {
    this.socket && this.socket.emit('userevent', {
      type: 'mouse',
      event: {
        type: e.type,
        buttonType: e.buttons === 2 ? 'right' : 'left',
        x: e.clientX,
        y: e.clientY
      }
    })
  },

然后在服务端把事件取出来执行,执行事件使用的是robot-js


const { Mouse, Point, Keyboard } = require('robot-js'); interface MouseEvent { type: string; buttonType: string; x: number; y: number; } interface KeyboardEvent { type: string; keyCode: number; keyName: string; } export default class EventExecuter { public mouse; public keyboard; public constructor(){ this.mouse = new Mouse(); this.keyboard = new Keyboard(); } public executeKeyboardEvent(event: KeyboardEvent): void { switch(event.type) { case 'keydown': this.keyboard.press(event.keyCode); break; case 'keyup': this.keyboard.release(event.keyCode); break; case 'keypress': this.keyboard.click(event.keyCode); break; default: break; } } public executeMouseEvent(event): void { Mouse.setPos(new Point(event.x, event.y)); const button = event.buttonType === 'left' ? 0 : 2 switch(event.type) { case 'mousedown': this.mouse.press(button); break; case 'mousemove': break; case 'mouseup': this.mouse.release(button); break; case 'click': this.mouse.click(button); break; case 'dblclick': this.mouse.click(button); this.mouse.click(button); break; default: break; } } public exectue(eventInfo): void { console.log(eventInfo); switch (eventInfo.type) { case 'keyboard': this.executeKeyboardEvent(eventInfo.event); break; case 'mouse': this.executeMouseEvent(eventInfo.event); break; default: break; } } }

至此,桌面监控和远程控制的客户端还有服务端的部分,以及两端的通信都已经实现了。思路其实并不麻烦,但细节还是很多的。有兴趣的同学可以把代码下下来跑跑试试,或者按着这个思路自己实现一遍,还是挺好玩的。

源码链接

remote-monitor-server

remote-monitor-client

webpack treeshking的三个要点

场景介绍

最近做一个需求的时候,想引入lodash来简化一些逻辑处理,我只用到了get、isArray等几个函数,但打包出的bundle却增加了69kb。说明webpack处理时把lodash整个都给打包进去了。

使用webpack-bundle-analyzer分析bundle的结果:

webpack从2.0开始加入了treeshking的功能,但是这里明显没有触发treesking。经过查阅文档和实践,解决了这个问题。

先看下成果:

treeshking的原理

webpack的treeshking是基于 es module的静态分析,能够在编译期间就确定哪些模块用到了哪些模块没用到,并且配合解构赋值还能确定哪些export用到了,哪些export没用到。然后对用到的部分和没用到的部分进行标记,在压缩阶段就可以删除标记出的没有用到的部分,从而达到treeshking的目的。

触发treeshking的三个要点

根据treeshking的原理,想要触发treesking需要满足3个条件:

1. 使用es module的模块规范、使用解构赋值

treeshking建立在es module静态分析的基础之上,所以代码必须使用esm的规范。业务代码一般都会使用esm,但是引入的第三方依赖就不一定了。比如lodash就是commonjs规范的,直接使用lodash是不会触发treeshking的,解决方案就是使用lodash的esm版本lodash-es。

package.json中的main字段是node package的入口,但是是commonjs规范的。想要使用treeshking的功能必须使用esm的入口,所以rollup(最早的treeshking实现)提出了module字段的提案,在这里配置es module的入口,这种约定虽然还没有成为规范,但已经被很多包所实现了。比如vue的package.json:

也有的包是esm规范的和commonsjs规范的分成了两个包,比如lodash和lodash-es。

总之,业务代码和第三方依赖都需要使用esm的规范。

然后引入的方式需要使用解构赋值的方式,

import { get } from 'lodash';

这种写法才可以在编译期间就能确定用到了哪些export,而

import _ from 'lodash';

这种写法无法在编译期间确定用到了哪部分,所以也无法进行treeshking。关于这点我做过测试,有兴趣的同学也可以试下。(分别使用两种引入方式,使用webpack-bundle-analyzer 分析打包出的bundle中lodash这个模块的大小)

2. 开启 optimization.usedExports

编译时可以分析出解构写法引入的esm模块,哪些export用到了,哪些模块没有用到。然后就需要分别进行标记,开启标记的配置项就是 optimization.usedExports

标记类似这样:

/* harmony export (immutable) */ __webpack_exports__["xxx"] = xxxx;

/* unused harmony export xxx */

unused harmony export标记的部分就是需要删掉的。

3. 使用压缩的插件

在编译期间对不同模块标记之后,在压缩时就可以删掉没用到的部分,任何一个压缩的plugin都可以做到这个。

其实上面的2、3两步,也就是开启optimization.usedExports和使用压缩的插件,在webpack4的mode设置为production时,已经默认开启了,所以开发者只需要关心业务代码和第三方包的模块规范是不是es module,以及有没有使用解构赋值的引入方式。

treeshking的其他注意事项

1. 有副作用的模块不能被treeshking

treeshking只是建立在某个es module的某一些export有没有被用到的基础上的,但是有一些代码会有副作用,比如在window上挂一个变量、写本地文件等,这种代码虽然没有export一些内容,但也是不能被treeshking掉的。对于这些文件需要过滤掉,配置的方式就是在package.json中添加sideEffects字段,因为webpack的模块包括图片、字体文件、css文件等,这些模块都是需要配置的。

2. treeshking只能做到export级别

如果一些模块导出了一个对象,用到这个模块的地方只使用了某几个方法,其余的方式是不能被treeshking的。原因也是因为编译期间的静态分析只能对es module的相关语法做分析,是不会真正去执行代码的。

所以为了更好的配合treeshking,能够写成分散的export的就不要封装成对象,这样能够配置treeshking打包成最小的bundle。

总结

treeshking是减小打包的bundle size很重要的一个手段,但触发treeshking是有条件的,首先需要代码是es module规范的并且使用解构赋值的方式引入,第二要开始optimization.usedExports来标记使用和未使用的模块,第三是使用压缩的插件进行删除未使用代码。 webpack4的mode设置为production之后,我们只需要关心第一点就好了。

treeshking是有限制的,副作用的代码不能treeshking,只能对export进行treeshking。

了解了treeshking的原理和触发条件,以及treeshking的限制,我们才能针对性的优化代码来达到最小的bundle size。当然达到最优可能还需要结合code spliting等其他方式。