如何优雅的处理”💩”

💩导致了什么问题

最近上线的一个活动,线上偶尔报“High surrogate without following low surrogate”这个错误。最后定位到是用户昵称截取的时候把emoji表情截成了一半,输入到canvas的时候会报一个fatal error,导致用户无法进行下步操作。

为什么会导致💩问题

这就要从字符编码说起,由于js出生的年代是在太久远,所以使用的是过时的UCS-2编码。因为”💩”在 JS 的编码是”\uD83D\uDCA9″,而 JS 认为每 16 位 (2 字节)即表示一个字符,所以一坨大便是占 2 个字符的。例如


"\uD83D\uDCA9" === "💩" true "💩".length // 多字节字符在js中的长度有问题 2 "💩"[0] // 取便便的第一个字符会输入半坨乱码 "�" "我是一坨便便💩".split('').reverse().join('') // 便便倒过来也会有问题 "��便便坨一是我" // 甚至还有更奇怪的family emoji 👩‍👩‍👧‍👦,长度是11个字节(为什么family是两个妈妈一个儿子一个女儿不要问我🙅) "👩‍👩‍👧‍👦".length // family 的字符长度是11 11

如何安全的处理💩

ES6 已经在很努力地填坑了,可以用es6的解构语法或者Array.from 安全的将string转换为数组来处理。例如

"我是一坨便便💩".length // 常规js把emoji当做两个长度来处理
8

[..."我是一坨便便💩"].length  //解构方法把emoji表情当做一个长度处理
7
 Array.from("我是一坨便便💩").length // es6 的数组处理也可以把表情当做一个字符处理
7


震惊!前端竟拿go做这种事情

越是火的语言社区越是喜欢玩跨界。最近go语言社区就出了这么一个框架,竟然可以用来写前端页面。你肯定会好奇了,go语言不是后端语言么,怎么可以在浏览器里运行呢。具体怎么回事,让咱们通过一个demo来说明吧。

  1. 首先创建一个空文件夹,可以起名字叫 testapp 或者你喜欢的名字。
  2. 声明一个 go.mod 文件,你需要在里边声明自己的模块,例如:module example.org/someone/testapp
  3. 创建一个名为root.vugu组件文件,为什么是vugu后缀,因为这个框架的名字就叫做 Vugu,里边的内容如下:
<div class="my-first-vugu-comp">
    <button @click="data.Toggle()">Click Me</button>
    <div vg-if="data.Show">Hello World!</div>
</div>

<style>
.my-first-vugu-comp { background: #eee; }
</style>

<script type="application/x-go">
type RootData struct { Show bool }
func (data *RootData) Toggle() { data.Show = !data.Show }
</script>
  1. 创建一个devserver.go文件,内容如下:
// +build ignore

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/vugu/vugu/simplehttp"
)

func main() {
    wd, _ := os.Getwd()
    l := "127.0.0.1:8844"
    log.Printf("Starting HTTP Server at %q", l)
    h := simplehttp.New(wd, true)
    // include a CSS file
    // simplehttp.DefaultStaticData["CSSFiles"] = []string{ "/my/file.css" }
    log.Fatal(http.ListenAndServe(l, h))
}
  1. 启动服务 go run devserver.go
  2. 访问:http:///127.0.0.1:8844
  3. 这个时候网页上会出现Click Me 的字符串,点击后就会出现经典的Hello World!

打开浏览器的控制台,你会发现浏览器加载了一个叫做main.wasm的文件,打开后会发现是一串乱码。这就要提到浏览器的特性WebAssembly了

什么是WebAssembly

WebAssembly 是一种新的字节码格式, 和 JS 需要解释执行不同的是,WebAssembly 字节码和底层机器码很相似可快速装载运行,因此性能相对于 JS 解释执行大大提升。 也就是说 WebAssembly 并不是一门编程语言,而是一份字节码标准,需要用高级编程语言编译出字节码放到 WebAssembly 虚拟机中才能运行, 浏览器厂商需要做的就是根据 WebAssembly 规范实现虚拟机。

WebAssembly 原理

WebAssembly 字节码是一种抹平了不同 CPU 架构的机器码,WebAssembly 字节码不能直接在任何一种 CPU 架构上运行, 但由于非常接近机器码,可以非常快的被翻译为对应架构的机器码,因此 WebAssembly 运行速度和机器码接近,这听上去非常像 Java 字节码。

关于Vugu

  1. 前端语法属于一个Vue的超子集,只实现了基本的组件功能。
  2. 目前还是一个纯试验性质的库
  3. 相比于传统前端框架除了效率高一些,并没有什么优势,因为前端的壁垒不在语言,而是庞大的生态,其他语言要在短期赶上很难。

WebAssembly的展望

既然Vugu目前还处于一个纯探索性质的库,那WebAssembly还有什么用呢?当然有用了,因为WebAssembly性能快的特性,另外在移动端的支持性已经非常好,详见支持列表。已经可以用在一些计算密集的领域了。

  1. 各种加密库
  2. Vue、React、AngularJs框架的核心库,像dom diff 这些运算密集的操作就可以用Webassembly来做。
  3. 游戏引擎,粒子特效、龙骨等运算量大的操作其实都可以把核心库迁移到WebAssembly来做,例如WebAssembly 在白鹭引擎5.0中的实践

现在开始学习WebAssembly

说了WebAssembly这么多的好处,那么我要从哪里开始入手呢?这里给大家推荐几个比较好的学习资源

  1. awesome-wasm awesome系列,万物皆可awesome,哪里不会点哪里,妈妈再也不用担心我的学习。
  2. WebAssembly 现状与实战 IBM Developer上的关于WebAssembly的介绍,建议入门阅读
  3. http://webassembly.org.cn/ 官方WebAssembly的中文镜像。
  4. assemblyscript WebAssembly的js实现,根本上就是把js转成机器码让速度更快

Web XPath 科普

XPath定义

XPath即为XML路径语言(XML Path Language),它是一种用来确定XML文档中某部分位置的语言。

XPath历史

在 W3C 建议下,XPath 1.0于 1999年 11月16日 发表。 XPath 2.0 正在W3C审核过程的最终阶段。XPath 2.0表达了XPath语言在大小与能力上显著的增加。值得一提的改变是XPath 2.0有了更丰富的型别系统;XPath 2.0支持不可分割型态,如在 XML Schema 内建型态定义一样,并且也可自纲要(schema)导入用户自定型别。每个值都是一个序列(一个单一不可分割值或节点都被视为长度一的序列)。XPath 1.0节点组被节点序列取代,它可以是任何顺序。

XPath查看

可以在Chrome控制台对应的元素上右键,选择Copy Xpath便可以把XPath路径粘贴出来了。

XPath长什么样子

一般来说,从控制台粘出来的的XPaht路径如下,每个元素都可以对应一个唯一的XPATH路径。

//*[@id="format-exp"]/div[1]/div/div/div/p

XPath好处

XPath 的选择功能十分强大,它提供了非常简洁明了的路径选择表达式,另外它还提供了超过 100 个内建函数用于字符串、数值、时间的匹配以及节点、序列的处理等等,几乎所有我们想要定位的节点都可以用XPath来选择。

XPath常用规则

我们现用表格列举一下几个常用规则:

表达式描述
nodename选取此节点的所有子节点
/从当前节点选取直接子节点
//从当前节点选取子孙节点
.选取当前节点
..选取当前节点的父节点
@选取属性

在这里列出了XPath的常用匹配规则,例如 / 代表选取直接子节点,// 代表选择所有子孙节点,. 代表选取当前节点,.. 代表选取当前节点的父节点,@ 则是加了属性的限定,选取匹配属性的特定节点。

例如:

//title[@lang=’eng’]

这就是一个 XPath 规则,它就代表选择所有名称为 title,同时属性 lang 的值为 eng 的节点。

XPath用处

XPath 一般用于爬虫、数据上报等领域。例如现在流行的无埋点技术,有些就是通过XPath技术来定位元素然后上报的。

平时Web开发需要学习XPath吗

XPath学习难度还是比较深的,需要记住节点、路径表达式、谓语条件、通配符等等,加上排列组合,比CSS的选择器还要复杂的多,平时用的的情况也比较少,所以建议平时了解一下就可以,真正用的时候翻文档也不迟。

如何批量下载YouTube视频

需求

YouTube是世界最大的视频分享网站,从上边我们学到各种各样的知识,有时候我们可能会有批量从下边下载视频的需求。这就要用到我今天给大家推荐的工具youtube-dl,这个工具在github上star已经到了5w+,名字虽然叫youtube_dl,但是却可以抓取大多数网站的视频,包括B站、优酷、P站,甚至QQ音乐都可以下载。从suppportedsites可以看到所有的支持列表。下面我们以YouTube为例来介绍。

准备工作

下载视频,除了一个好的网速,我们还需要能够访问到视频(废话)。如果你浏览器输入youtube.com,提示无法访问此网站的话, 可以先去看另外一位同事的博客:科学上网。如果有什么问题可以在那篇博客底下留言。

第一步,安装

Mac

sudo -H pip install --upgrade youtube-dl

linux

sudo curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/local/bin/youtube-dl
sudo chmod a+rx /usr/local/bin/youtube-dl

使用方式

我们以批量下载ted的视频为例,

youtube-dl --merge-output-format mp4 -f 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' --proxy socks5://127.0.0.1:6153 https://www.youtube.com/watch?v=qAC-5hTK-4c&amp;list=UUAuUUnT6oDeKwE6v1NGQxug


-f 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'  // 以质量最好的视频和音频下载

--merge-output-format  mp4 // 将视频和音频合并成mp4格式,需要用的ffmpeg的音频处理库

--proxy socks5://127.0.0.1:6153  // 命令行可能需要设置代理

通过上边的命令,我们就可以下载列表中的所有视频啦。我们还可以添加 –playlist-start 参数,来指定从第几个视频下载,这样中途出错还可以继续下载。

其他

另外推荐几个比较好的youtube频道

  • Linus Tech Tips 非常火的科技博主
  • TED TED演讲,Ideas worth spreading
  • JSConf JS Conferences,全是前端干货
  • The Economist 经济学人,非常好的国际新闻来源,不止局限于经济。

用SVG+CSS+JS实现漂亮的动画效果

什么是SVG

SVG 是一种基于 XML 语法的图像格式,全称是可缩放矢量图(Scalable Vector Graphics)。其他图像格式都是基于像素处理的,SVG 则是属于对图像的形状描述,所以它本质上是文本文件,体积较小,且不管放大多少倍都不会失真。如果对SVG不熟悉,建议看下阮一峰的SVG 图像入门教程,还是比较浅显易懂的。

首先用SVG来绘制填充区域

<svg width="300" height="200">
    <g id="background">
        <circle cx="0" cy="50" r="50" />
        <circle cx="50" cy="50" r="50" />
        <circle cx="100" cy="50" r="50" />
        <circle cx="150" cy="50" r="50" />
        <circle cx="200" cy="50" r="50" />

        <circle cx="0" cy="100" r="50" />
        <circle cx="50" cy="100" r="50" />
        <circle cx="100" cy="100" r="50" />
        <circle cx="150" cy="100" r="50" />
        <circle cx="200" cy="100" r="50" />

        <circle cx="0" cy="150" r="50" />
        <circle cx="50" cy="150" r="50" />
        <circle cx="100" cy="150" r="50" />
        <circle cx="150" cy="150" r="50" />
        <circle cx="200" cy="150" r="50" />

        <circle cx="0" cy="200" r="50" />
        <circle cx="50" cy="200" r="50" />
        <circle cx="100" cy="200" r="50" />
        <circle cx="150" cy="200" r="50" />
        <circle cx="200" cy="200" r="50" />
    </g>
</svg>

上述代码,是在300pxx200px,绘制16个半径为50px的圆形,cx和cy是圆心的坐标,r指的是半径.

使用SVG的clipPath元素来限定填充区域

<svg width="300" height="200">
    <clipPath id="textClip" class="filled-heading">
        <text y="50" fill="#000">
            WE
        </text>
        <text y="100" fill="#000">
            LOVE
        </text>
        <text y="150" fill="#000">
            PALFISH
        </text>
    </clipPath>
    <g id="background" clip-path="url('#textClip')">
        <circle cx="0" cy="50" r="50" />
        <circle cx="50" cy="50" r="50" />
        <circle cx="100" cy="50" r="50" />
        <circle cx="150" cy="50" r="50" />
        <circle cx="200" cy="50" r="50" />

        <circle cx="0" cy="100" r="50" />
        <circle cx="50" cy="100" r="50" />
        <circle cx="100" cy="100" r="50" />
        <circle cx="150" cy="100" r="50" />
        <circle cx="200" cy="100" r="50" />

        <circle cx="0" cy="150" r="50" />
        <circle cx="50" cy="150" r="50" />
        <circle cx="100" cy="150" r="50" />
        <circle cx="150" cy="150" r="50" />
        <circle cx="200" cy="150" r="50" />

        <circle cx="0" cy="200" r="50" />
        <circle cx="50" cy="200" r="50" />
        <circle cx="100" cy="200" r="50" />
        <circle cx="150" cy="200" r="50" />
        <circle cx="200" cy="200" r="50" />
    </g>
</svg>

这里最关键的是clipPath元素和clip-path属性,clipPath是指当绘制的图形超出了剪切路径所指定的区域,超出区域的部分将不会被绘制。clip-path属性则可以创建一个只有元素的部分区域可以显示的剪切区域。

我们用js来为16个圆形上不同的颜色,并且让颜色随时间变化

    const hues = []
    let circles = []
    window.onload = function() {
        circles = document.querySelectorAll("#background circle");
        for(let i = 0; i  {
        circles.forEach((circle, index) =&gt; {
            const hue =  hues[index]
            circle.style.fill = `hsla(${hues[index]}, 100%, 50%, 1)`
            hues[index] = hue+ Math.random() * 5 
        });
        setTimeout(() =&gt; {
            updateColors()
        }, 30)
    }

上述js代码会在页面加载后执行一些初始化的操作,并调用updateColors函数来给svg的圆上色,通过setTimeout的循环调用来变化颜色。

使用CSS的animation来增加动画效果

.filled-heading {
    font-size: 50px;
    line-height: 1;
}

#background circle {
    animation: scale 3s ease-in-out infinite;
    transform-origin: center center;
    transform-box: fill-box;
}

@keyframes scale {
    from {
        transform: scale(1);
    }
    to {
        transform: scale(0);
    }
}

让每个圆从大到小循环,来实现颜色的动态变化

最终效果

动态效果点这里

参考文章 Animate a Blob of Text with SVG and Text Clipping

Web Workers 初探

介绍

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

API

首先我们来总览下Web Workers的Api

主线程:

  1. 构造函数 Worker(), 用来初始化一个worker对象

worker对象的属性和方法如下

  1. self.name: Worker 的名字。该属性只读,由构造函数指定。
  2. self.onmessage:指定message事件的监听函数。
  3. self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  4. self.close():关闭 Worker 线程。
  5. self.postMessage():向产生这个 Worker 线程发送消息。
  6. self.importScripts():加载 JS 脚本。

子线程:

注意,子线程是有自己的全局对象的,也就是说子线程是没办法调取获取主线程的windows对象。

  1. self.name: Worker 的名字。该属性只读,由构造函数指定。
  2. self.onmessage:指定message事件的监听函数。
  3. self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  4. self.close():关闭 Worker 线程。
  5. self.postMessage():向产生这个 Worker 线程发送消息。
  6. self.importScripts():加载 JS 脚本。

使用方法

创建一个worker

Worker构造函数可以接收两个参数,第一个是jsUrl,也就是work要执行的js脚本,第二个参数是option,可以指定worker的名称

    const myWorker = new Worker('mywork.js', {name: 'mywork'})

主线程像子线程发消息

myWorker.postMessage('Hello 子线程!')

子线程接收消息

self.onmessage = function (e) {
  console.log('来自主线程的消息:' + e.data);
}

子线程发消息

self.postMessage('Hello 主线程!')

主线程接收消息

myWorker.onmessage = function (e) {
  console.log('来自主线程的消息:' + e.data);
}

关闭子线程

// 主线程主动关闭
myWorker.terminate();

// 子线程主动关闭
self.close();

对Web Workers的一些思考

目前来看,web worker对传统的网站开发作用不是很大,但是在一些特殊领域会发挥非常大的作用

  1. 图片处理、文件加密、视频转码等运算密集型的功能上
  2. 非浏览器宿主环境的应用,JS的领域目前不止web领域,在桌面啊客户端、游戏引擎、云服务中都可以拿JS来写业务,这些业务中是Web Worker的。
  3. 需要用到多核性能的场景,例如大数据处理等。

Javascript避免“Cannot read property ‘foo’ of undefined”错误的几种方式

“Uncaught TypeError: Cannot read property ‘foo’ of undefined.”
相信每个前端开发都在控制台碰到过这个错误,有可能是api返回的数据格式不规范,也有可能是某个对象的变量未初始化。这个错误会导致后续的代码无法执行,让整个页面的功能失常。那如何避免这个错误呢,今天我们就来聊聊这个话题。

基础库

有些基础库已经帮我们解决了这个问题,例如lodash中的_.get方法和Ramda中的R.path方法可以让我们安全的访问一个对象。

逻辑运算符&& 和 ||

非常有趣的是逻辑运算符并不总是返回一个布尔类型。根据文档,“&&或||运算的结果并不是布尔类型,而是参与运算的两个变量中的其中之一”

首先来看下 &&, 例如 “0 && 1” 的运算结果为0, “1 && 2 && 3” 的运算结果为3,“1 && 2 && 3 && null && 4″ 的运算结果为 null, 也就是说&& 运算符会返回第一个boolean为false的参数,如果都为true,就返回最后一个参数。

然后再来看下 ||, 例如 “0 && 1″的运算结果为1, “1 || 2 || 3” 的结果为1,
“null || 0″的结果为0, 也就是说 || 会返回第一个第一个boolean值不为ture的参数,如果都为false,就返回最后一个参数。

使用方法:例如有个family对象,不太确定有没有father这个对象,我们如果想获取father的名字,则可以写成

const fatherName = family &amp;&amp; family.father &amp;&amp; family.father.name
if(fatherName) {
    console.log(`孩子的父亲是${fatherName}`)
} else {
    console.log(`找不到孩子的父亲`)
}

如果找不到孩子父亲,我们就假定孩子的父亲是老王,则可以写成下边的代码

const father = family.father || {}
const fatherName = father.name || '老王'

console.log(`孩子的父亲是${fatherName}`)

try/catch

最简单的方式就是利用try/catch来安全的访问参数,但是会导致代码层级较深。

let fatherName
try {
    fatherName = family.father.name
} catch(err) {
    fatherName = null
}

合并一个预设对象

我们可以通过和一个预设对象进行对象合并来保证值始终是可以取到的。经常遇到的场景就是插件的config参数,需要保证用户未配置的选项也有一个默认值。

const defaultFamily = {
    father: {
        name: 'Lilei'
    },
    mather: {
        name: 'Hanmeimei'
    }
}

const family = {
    father: {
        name: 'Jim Green'
    }
}

const mergeFamily = {
    ...defaultFamily,
    ...family
}

const fatherName = mergeFamily.father.name
console.log(fatherName)
// Gim Green

解构赋值指定默认值

有些时候我们通过解构赋值可以更方便的获取一个对象的参数,解构赋值的过程中其实是可以指定默认值的。

const {father = {}, mather = {}} = {...family}
const fatherName = father.name || 'lilei'

未来:自判断链接(Optional Chaining)

TC39委员会已经有了一个提案,叫做”optional chaining.”写法如下:

// ?. 运算符会先判断前面的值,如果是 null 或 undefined,就结束调用、返回 undefined
console.log(family?.father?.name)

// 如果需要一个默认值,可以写成下边的方式
console.log(family?.father?.name || 'lilei')
// 找不到值得时候会返回'lilei'

不过这个提案目前还处在stage-1 的阶段,真正投入使用可能还需要一段时间。

现在:Babel v7

babel 现在已经有plugin-proposal-optional-chaining的插件来支持optional chaining 的特性了,不过考虑到提案目前还处于stage-1的阶段,后期如果提案变更会导致代码难以维护。具体就看开发者取舍了。

参考文章:《avoiding-those-dang-cannot-read-property-of-undefined-errors》

Quicklink-利用最新的浏览器特性让你的链接实现秒开

关于Quicklink

Quicklink是18年11月份由谷歌开源的一项新技术,贡献者是来自Google Chrome的工程师Addy Osmani,同时也是《JavaScript设计模式》的作者。Qucklink旨在为网站提供一套解决方案,预获取处于用户视区中的链接,同时保持极小的体积(minifiy/gzip 后 <1KB)。

工作原理

Quicklink 可以在空闲时间预获取页面可视区域(以下简称视区)内的链接,加快后续加载速度。

通过以下方式加快后续页面的加载速度:

  • 检测视区中的链接(使用 Intersection Observer)。
  • 等待浏览器空闲(使用 requestIdleCallback)。
  • 确认用户并未处于慢速连接(使用 navigator.connection.effectiveType)或启用省流模式(使用 navigator.connection.saveData)。
  • 预获取视区内的 URL(使用 或 XHR)。可根据请求优先级进行控制(若支持 fetch() 可进行切换)。

在VUE及React中的应用

关于在现代框架中的使用,官方文档提及比较少,Github也没有比较成熟的组件实现。不过利用quicklink可以针对DOM调用的特性,我们可以先通过ref拿到组件dom,然后初始化quicklink时传入组件dom就可以了。具体实现可以参考掘金的两篇文章

适用范围

Quicklink最适合的场景应该是内容提供类的网站,例如博客,新闻类。如果网站内直接跳转不多或者用户点击概率不高,则需要衡量一下维护难度与用户体验之间的平衡点。另外如果是站外链接的话,可能会遇到CORB 以及 CORS 问题!大家可能都听过CORS,关于CORB大家如果想了解的可以参考这篇文章:30 分钟理解 CORB 是什么

其他

其他的一些详细配置大家可以在官方文档中找到。

使用Mock.js模拟接口数据

前言

敏捷开发过程中,经常出现后端和前端需要并行开发的情况。如果等后端开发完成再进行接口联调,会大大拖慢开发进度。这时候前端就需要要进行后端数据的模拟。

##通用做法

目前大概有两种做法,一种是在前端直接模拟数据,通过拦截xhr的方式来注入数据,如mock.js。另外一种是通过第三方服务去模拟数据请求,如json-server,阿里的RAP,去哪的YApi。今天咱们就说一下使用第一种方式,用Mock.js怎样来模拟数据。

Mock.js介绍

Mock.js 最初的灵感来自 Elijah Manor 的博文 Mocking Introduction,语法参考了 mennovanslooten/mockJSON,随机数据参考了 victorquinn/chancejs

使用Mock.js

安装

yarn add mockjs

引用

import Mock from 'mockjs';

模拟数据

let response = Mock.mock({
  code: 200,
  msg: '',
  data: {
    // 属性 list 的值是一个数组,其中含有 1 到 10 个元素
    'list|1-10': [{
       // 属性 id 是一个自增数,起始值为 1,每次增 1
      'id|+1' :1 
    }]
  }
})

拦截请求

// 通过覆盖和模拟XMLHttpRequest的行为来拦截api/product请求,并返回response
Mock.mock(/api\/product/, response);

有时候会考虑网络较差的情况下,接口返回比较慢时页面的展现优化

// 设置接口返回时间,在100-1500毫秒中随机
Mock.setup({
    timeout: '100-1500'
});

其它

有时候服务端返回的不是一个随机时间,而是一个随机时间戳,mock.js文档中并没有提及怎么实现,所以需要我们处理一下

// 先获取一个随机时间的毫秒数,再去掉毫秒
timestamp: Mock.Random.Data('T')/1000

mock.js的图片模拟是像dummyimage这样的纯色图,在模拟头像时并不能很好的还原真实的场景,所以我们要借用第三方的place image服务

// 传入一个随机参数来保证每条数据返回不同的图片
avatr: `https://placeimg.com/100/100/any?rand=${Math.random()}`

用canvas实现一个粒子系统

工作需要实现一些酷炫的烟花特效,本来想上github上当个勤奋的搬运工,无奈没有找到专门的粒子特效库,只有一些零散的文章,于是准备自己实现一个。

写之前呢首先要了解粒子系统的基本概念,有两篇文章对粒子系统描述的比较清楚,一篇是:维基百科/粒子系统 ,另外一篇是unreal的游戏引擎文档粒子系统的关键概念 粒子系统表示三维计算机图形学中模拟一些特定的模糊现象的技术,而这些现象用其它传统的渲染技术难以实现的真实感的游戏图形。经常使用粒子系统模拟的现象有火、爆炸、烟、水流、火花、落叶、云、雾、雪、尘、流星尾迹或者象发光轨迹这样的抽象视觉效果等等。

好了,知道了粒子系统的概念,那开始动手做吧,首先我们可以把粒子系统抽象成三个类

1. 粒子:包含粒子的宽、高、形状、颜色、大小等属性
2. 发射器:用来维护单个粒子的轨迹、状态变化、颜色变化等。
3. 粒子系统:用来维护多个粒子的数量、状态等。

首先,我们来实现第一个类:粒子。(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let particle = new Particle({ctx})
canvas.addEventListener('click', (e) => {
ctx.clearRect(0,0, canvas.width, canvas.height);
particle.render({x: e.clientX, y: e.clientY})
})

接下来我们实现发射器来让小球动起来(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


// 发射器
class Emitter {
constructor({ctx, x, y, id}) {
this.particle = new Particle({ctx, x, y});
let offset = Math.random()*7 + 3; //3-10随机
let radian = Math.random() * 2 * Math.PI;
this.x = x;
this.y = y;
this.radius = Math.random()*10 + 10; // 10-20随机
this.hue = Math.random() * 200 - 100;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
this.offsetX = offset * Math.cos(radian);
this.offsetY = offset * Math.sin(radian);
this.id = id;
}
updateX() { this.x += this.offsetX; }
updateY() { this.y += this.offsetY; }
updateColor() {
this.hue -= 0.5;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
}
updateRadius() {
this.radius = this.radius*0.98;
}

update() {
this.updateX();
this.updateY();
this.updateColor();
this.updateRadius();
this.particle.render({
x: this.x,
y: this.y,
color: this.color,
radius: this.radius,
});
}
}

let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let emitters = []
canvas.addEventListener('click', (e) => {
let emitter = new Emitter({ctx, x: e.clientX, y: e.clientY});
emitters.push(emitter);
});
let loop = () => {
window.requestAnimationFrame(() => {
ctx.clearRect(0,0, canvas.width, canvas.height);
emitters.forEach(emitter => {
emitter.update();
})
loop();
})
}
loop();

最后一步,我们来实现一个粒子系统来维护发射器的数量和生命周期(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


// 发射器
class Emitter {
constructor({ctx, x, y, id}) {
this.particle = new Particle({ctx, x, y});
let offset = Math.random()*7 + 3; //3-10随机
let radian = Math.random() * 2 * Math.PI;
this.x = x;
this.y = y;
this.radius = Math.random()*10 + 10; // 10-20随机
this.hue = Math.random() * 200 - 100;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
this.offsetX = offset * Math.cos(radian);
this.offsetY = offset * Math.sin(radian);
this.id = id;
}
updateX() { this.x += this.offsetX; }
updateY() { this.offsetY += 0.5 ; this.y += this.offsetY; }
updateColor() {
this.hue -= 0.5;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
}
updateRadius() {
this.radius = this.radius*0.97;
}

update() {
this.updateX();
this.updateY();
this.updateColor();
this.updateRadius();
this.particle.render({
x: this.x,
y: this.y,
color: this.color,
radius: this.radius,
});
}
}


// 粒子系统
class ParticleSystem {

constructor({ctx, width, height }) {
this.ctx = ctx;
this.particleNu = Math.random() * 20 + 30; // 30~50
this.width = width;
this.height = height;
this.requestId = 0;
}
addParticle(x, y) {
for(let i=0; i< this.particleNu; i++) { let id = ParticleSystem.emitterOffset; let emitter = new Emitter( {ctx: this.ctx, x, y, id}) ParticleSystem.emitters.set(id, emitter); ParticleSystem.emitterOffset++; } } nextTick() { if(ParticleSystem.emitters.size === 0) { return; } ctx.clearRect(0,0, canvas.width, canvas.height); ParticleSystem.emitters.forEach(emitter => {
if(emitter.x < 0 || emitter.y < 0 || emitter.x > this.width || emitter.y > this.height) {
ParticleSystem.emitters.delete(emitter.id);
return;
}
emitter.update();
})
this.requestId = window.requestAnimationFrame(() => {
this.nextTick();
});
}
burst(x, y) {
this.addParticle(x, y);
window.cancelAnimationFrame(this.requestId);
this.nextTick()
}
}


ParticleSystem.emitters = new Map();
ParticleSystem.emitterOffset = 0;

let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let particleSystem = new ParticleSystem({
ctx,
width: canvas.width,
height: canvas.height
})
canvas.addEventListener('click', (e) => {
console.log(e);
particleSystem.burst(e.clientX, e.clientY);
})

大功告成,是不是很简单。后续我们还可以继承我们的类来进行扩展,比方重写particle的render方法来把小球变成雪花、五角星,给Emitter类添加新的方法来给小球的轨迹增加重力、加速度、旋转等物理效果,还可以修改ParticleSystem的逻辑来将目前的爆炸效果改成下雨、云雾等效果,限制我们的只是我们的想象力。